feat: restore-all session recovery, ?file= deep links, offline PWA

Session recovery: more than one pending snapshot adds a 'Restore all'
button to the Recover Drawing modal — every snapshot goes through the
exact same guarded path as a single restore (deleted only after its
own open succeeds). The open-tab list and active tab persist to
localStorage on web (open/save/close) and the remembered tab is
re-selected once a restore batch settles; the modal notes how many
drawings the session had. Nothing is auto-opened.

Deep links: ?file=<url|name> opens a drawing at boot. http(s) URLs
must end in .dwg/.dxf and are fetched with the same streaming,
64 MB-capped, 8 s-deadline reader the remote images use, then go
through the guarded open queue; cross-origin opens announce the
origin first. Bare names resolve from the browser's recent storage,
with a hint when absent. Everything else (file:, javascript:, drive
letters, non-drawing tails) is refused with a console note.

Offline PWA: the app was installable (manifest) but downloaded its
55 MB bundle on every visit. A cache-first service worker now serves
the whole bundle from Cache Storage after one online visit —
navigations network-first so updates land, hashed assets
stale-while-revalidate, non-GET/cross-origin passthrough, old caches
cleaned on activate, and the waiting-worker update flow documented.
The build wrapper stamps the cache version from the bundle hash and
hard-fails if sw.js is missing or unstamped.

Also fixes a locale group mismatch on the DATALINK pick notice
(catalog said a display group that does not exist in the ftl
sources).
This commit is contained in:
Afonsop committed 2026-09-18 16:27:12 +01:00
1 parent d719fb7778
commit 5f9b345ee6
38 files changed
+1211 -50

No files matched your search

+3
View File
@@ -164,6 +164,9 @@ web-sys = { version = "=0.3.85", features = [
"Event", "Event",
"ReadableStreamDefaultReader", "ReadableStreamDefaultReader",
"ReadableStream", "ReadableStream",
# The ?file= boot deep link reads location.search and compares the link's
# origin against the page's (io::web_link).
"Location",
] } ] }
iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["webgl", "fira-sans"] } iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["webgl", "fira-sans"] }
+4
View File
@@ -89,6 +89,8 @@ The project is under active development. Keep backups of important production dr
Use the [web app](https://www.opencadstudio.com) for immediate access with no installation. Drawings are selected through the browser and saved as local downloads. Use the [web app](https://www.opencadstudio.com) for immediate access with no installation. Drawings are selected through the browser and saved as local downloads.
The web app also accepts a `?file=` URL parameter, so a shared or embedded link can open a drawing directly: `?file=<url-encoded https://example.com/plan.dwg>` fetches and opens that drawing (only `http(s)` URLs ending in a `.dwg`/`.dxf` name), while `?file=plan.dwg` reopens a drawing previously opened in the same browser. Opening from another origin is announced on the command line before the fetch starts.
Use the desktop application for native file associations, file-manager thumbnails, system printing, PDF output, external plugins, command scripts, and headless automation. Release builds are available for Windows, Linux, and Apple Silicon macOS. Use the desktop application for native file associations, file-manager thumbnails, system printing, PDF output, external plugins, command scripts, and headless automation. Release builds are available for Windows, Linux, and Apple Silicon macOS.
## Install ## Install
@@ -181,6 +183,8 @@ never go missing from the bundle):
sh scripts/build-web.sh sh scripts/build-web.sh
``` ```
The bundle ships a service worker (`web/sw.js`, deployed at `/app/sw.js`) that caches the app shell: after one online visit the app starts fully offline and the ~55 MB wasm bundle is served from the browser cache instead of re-downloaded. `build-web.sh` stamps a hash of the finished bundle into `sw.js` as the cache version, so every deploy ships a fresh cache and stale ones are cleaned up automatically.
## Automation ## Automation
The desktop binary supports one-shot conversion, a persistent headless server, and a client-neutral MCP endpoint for AI applications: The desktop binary supports one-shot conversion, a persistent headless server, and a client-neutral MCP endpoint for AI applications:
+4 -1
View File
@@ -2010,6 +2010,9 @@ file =
.drawing-not-loaded-in-the-browser = رسم غير محمّل في المتصفح: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = رسم غير محمّل في المتصفح: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" بأبعاد __ocs_fmt_1__×__ocs_fmt_2__ بكسل ويتطلب فك ترميزه __ocs_fmt_3__ ميغابايت، وهو فوق حد __ocs_fmt_4__ ميغابايت للصور في هذا الإصدار — لا يمكن إرفاقه. تُرفَق الصور ضمن الحد تلقائيًا بدقة عرض مخفَّضة عند الحاجة. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" بأبعاد __ocs_fmt_1__×__ocs_fmt_2__ بكسل ويتطلب فك ترميزه __ocs_fmt_3__ ميغابايت، وهو فوق حد __ocs_fmt_4__ ميغابايت للصور في هذا الإصدار — لا يمكن إرفاقه. تُرفَق الصور ضمن الحد تلقائيًا بدقة عرض مخفَّضة عند الحاجة.
.image-refusal-unknown-size = تعذّر قياس أبعاد "__ocs_fmt_0__" من ترويسته وسيتجاوز فك ترميزه حد الصور __ocs_fmt_1__ ميغابايت في هذا الإصدار — لا يمكن إرفاقه. .image-refusal-unknown-size = تعذّر قياس أبعاد "__ocs_fmt_0__" من ترويسته وسيتجاوز فك ترميزه حد الصور __ocs_fmt_1__ ميغابايت في هذا الإصدار — لا يمكن إرفاقه.
.restore-all = استعادة الكل
.restoring-drawing-s = جارٍ استعادة __ocs_fmt_0__ رسم…
.session-had-open-drawing-s = كانت الجلسة تضم __ocs_fmt_0__ رسمًا مفتوحًا.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ حدد الكائنات (__ocs_arg_count__ محدد، Enter للتطبيق): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ حدد الكائنات (__ocs_arg_count__ محدد، Enter للتطبيق):
@@ -3590,8 +3593,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: مقياس التعليق التوضيحي النشط غير متاح. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: مقياس التعليق التوضيحي النشط غير متاح.
.opening-ocs-discussions-for-help-and-questions = يجري فتح مناقشات OCS للمساعدة والأسئلة... .opening-ocs-discussions-for-help-and-questions = يجري فتح مناقشات OCS للمساعدة والأسئلة...
.opening-ocs-web = يجري فتح OCS Web... .opening-ocs-web = يجري فتح OCS Web...
.opening-the-command-reference = فتح مرجع الأوامر...
.browser-cannot-open-typed-path = لا يستطيع المتصفح فتح مسار مكتوب؛ اختر الملف بدلاً من ذلك. .browser-cannot-open-typed-path = لا يستطيع المتصفح فتح مسار مكتوب؛ اختر الملف بدلاً من ذلك.
.opening-the-command-reference = فتح مرجع الأوامر...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" محمّل بالفعل بمحتوى مختلف؛ تم الاحتفاظ بالملف الموجود. .already-loaded-different-contents-kept = "__ocs_fmt_0__" محمّل بالفعل بمحتوى مختلف؛ تم الاحتفاظ بالملف الموجود.
.opening-patreon-page = يجري فتح صفحة Patreon... .opening-patreon-page = يجري فتح صفحة Patreon...
.opening-feedback-page = يجري فتح صفحة الملاحظات... .opening-feedback-page = يجري فتح صفحة الملاحظات...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Чертежът не е зареден в браузъра: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Чертежът не е зареден в браузъра: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" е __ocs_fmt_1__×__ocs_fmt_2__ px и при декодиране би заемала __ocs_fmt_3__ MB — над __ocs_fmt_4__ MB лимита за изображения на тази версия, затова не може да бъде прикачена. Изображенията в рамките на лимита се прикачват автоматично с намалена разделителна способност при нужда. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" е __ocs_fmt_1__×__ocs_fmt_2__ px и при декодиране би заемала __ocs_fmt_3__ MB — над __ocs_fmt_4__ MB лимита за изображения на тази версия, затова не може да бъде прикачена. Изображенията в рамките на лимита се прикачват автоматично с намалена разделителна способност при нужда.
.image-refusal-unknown-size = Размерът на „__ocs_fmt_0__“ не можа да се измери от заглавната му част, а декодирането му би надвишило __ocs_fmt_1__ MB лимита на тази сборка — не може да се прикачи. .image-refusal-unknown-size = Размерът на „__ocs_fmt_0__“ не можа да се измери от заглавната му част, а декодирането му би надвишило __ocs_fmt_1__ MB лимита на тази сборка — не може да се прикачи.
.restore-all = Възстанови всички
.restoring-drawing-s = Възстановяване на __ocs_fmt_0__ чертеж(а)…
.session-had-open-drawing-s = Сесията е имала __ocs_fmt_0__ отворени чертежа.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Избери обекти (__ocs_arg_count__ избрани, Enter за прилагане): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Избери обекти (__ocs_arg_count__ избрани, Enter за прилагане):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: активният анотационен мащаб не е наличен. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: активният анотационен мащаб не е наличен.
.opening-ocs-discussions-for-help-and-questions = Отваряне на OCS дискусии за помощ и въпроси... .opening-ocs-discussions-for-help-and-questions = Отваряне на OCS дискусии за помощ и въпроси...
.opening-ocs-web = Отваряне на OCS уебсайта... .opening-ocs-web = Отваряне на OCS уебсайта...
.opening-the-command-reference = Отваряне на справочника за команди...
.browser-cannot-open-typed-path = Браузърът не може да отвори въведен път; изберете файла. .browser-cannot-open-typed-path = Браузърът не може да отвори въведен път; изберете файла.
.opening-the-command-reference = Отваряне на справочника за команди...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" вече е зареден с различно съдържание; съществуващият файл е запазен. .already-loaded-different-contents-kept = "__ocs_fmt_0__" вече е зареден с различно съдържание; съществуващият файл е запазен.
.opening-patreon-page = Отваряне на страницата в Patreon... .opening-patreon-page = Отваряне на страницата в Patreon...
.opening-feedback-page = Отваряне на страницата за обратна връзка... .opening-feedback-page = Отваряне на страницата за обратна връзка...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Výkres není v prohlížeči načten: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Výkres není v prohlížeči načten: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" má __ocs_fmt_1__×__ocs_fmt_2__ px a při dekódování by zabrala __ocs_fmt_3__ MB, přes limit __ocs_fmt_4__ MB pro obrázky v této verzi — nelze ji připojit. Obrázky v rámci limitu se podle potřeby připojují automaticky se sníženým rozlišením zobrazení. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" má __ocs_fmt_1__×__ocs_fmt_2__ px a při dekódování by zabrala __ocs_fmt_3__ MB, přes limit __ocs_fmt_4__ MB pro obrázky v této verzi — nelze ji připojit. Obrázky v rámci limitu se podle potřeby připojují automaticky se sníženým rozlišením zobrazení.
.image-refusal-unknown-size = „__ocs_fmt_0__“ nešlo změřit ze záhlaví a jeho dekódování by překročilo limit __ocs_fmt_1__ MB tohoto sestavení — nelze připojit. .image-refusal-unknown-size = „__ocs_fmt_0__“ nešlo změřit ze záhlaví a jeho dekódování by překročilo limit __ocs_fmt_1__ MB tohoto sestavení — nelze připojit.
.restore-all = Obnovit vše
.restoring-drawing-s = Obnovování __ocs_fmt_0__ výkres(ů)…
.session-had-open-drawing-s = Relace měla otevřeno __ocs_fmt_0__ výkres(ů).
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Vyberte objekty (vybráno __ocs_arg_count__, použít Enter): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Vyberte objekty (vybráno __ocs_arg_count__, použít Enter):
@@ -3575,8 +3578,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: aktivní měřítko anotací není k dispozici. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: aktivní měřítko anotací není k dispozici.
.opening-ocs-discussions-for-help-and-questions = Otevírání diskuzí OCS pro nápovědu a dotazy... .opening-ocs-discussions-for-help-and-questions = Otevírání diskuzí OCS pro nápovědu a dotazy...
.opening-ocs-web = Otevírání webu OCS... .opening-ocs-web = Otevírání webu OCS...
.opening-the-command-reference = Otevírání referenční příručky příkazů...
.browser-cannot-open-typed-path = Prohlížeč nemůže otevřít zadanou cestu; vyberte soubor. .browser-cannot-open-typed-path = Prohlížeč nemůže otevřít zadanou cestu; vyberte soubor.
.opening-the-command-reference = Otevírání referenční příručky příkazů...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" je již načten s jiným obsahem; existující soubor byl zachován. .already-loaded-different-contents-kept = "__ocs_fmt_0__" je již načten s jiným obsahem; existující soubor byl zachován.
.opening-patreon-page = Otevírání stránky Patreon... .opening-patreon-page = Otevírání stránky Patreon...
.opening-feedback-page = Otevírá se stránka zpětné vazby... .opening-feedback-page = Otevírá se stránka zpětné vazby...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Zeichnung ist nicht im Browser geladen: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Zeichnung ist nicht im Browser geladen: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" hat __ocs_fmt_1__×__ocs_fmt_2__ px und würde beim Dekodieren __ocs_fmt_3__ MB belegen, über dem __ocs_fmt_4__ MB Bildlimit dieses Builds — es kann nicht angehängt werden. Bilder innerhalb des Limits werden bei Bedarf automatisch mit reduzierter Anzeigeanflösung angehängt. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" hat __ocs_fmt_1__×__ocs_fmt_2__ px und würde beim Dekodieren __ocs_fmt_3__ MB belegen, über dem __ocs_fmt_4__ MB Bildlimit dieses Builds — es kann nicht angehängt werden. Bilder innerhalb des Limits werden bei Bedarf automatisch mit reduzierter Anzeigeanflösung angehängt.
.image-refusal-unknown-size = „__ocs_fmt_0__“ konnte anhand des Headers nicht vermessen werden, und das Dekodieren überschreitet das __ocs_fmt_1__-MB-Limit dieses Builds — kann nicht angehängt werden. .image-refusal-unknown-size = „__ocs_fmt_0__“ konnte anhand des Headers nicht vermessen werden, und das Dekodieren überschreitet das __ocs_fmt_1__-MB-Limit dieses Builds — kann nicht angehängt werden.
.restore-all = Alle wiederherstellen
.restoring-drawing-s = __ocs_fmt_0__ Zeichnung(en) werden wiederhergestellt…
.session-had-open-drawing-s = Die Sitzung hatte __ocs_fmt_0__ geöffnete Zeichnung(en).
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Objekte auswählen (__ocs_arg_count__ ausgewählt, Eingabe zum Anwenden): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Objekte auswählen (__ocs_arg_count__ ausgewählt, Eingabe zum Anwenden):
@@ -3576,8 +3579,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: Die aktive Annotationsskala ist nicht verfügbar. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: Die aktive Annotationsskala ist nicht verfügbar.
.opening-ocs-discussions-for-help-and-questions = Eröffnung OCS Diskussionen für Hilfe und Fragen... .opening-ocs-discussions-for-help-and-questions = Eröffnung OCS Diskussionen für Hilfe und Fragen...
.opening-ocs-web = Öffnen OCS Web... .opening-ocs-web = Öffnen OCS Web...
.opening-the-command-reference = Öffnen der Befehlsreferenz...
.browser-cannot-open-typed-path = Der Browser kann einen eingegebenen Pfad nicht öffnen; wähle die Datei. .browser-cannot-open-typed-path = Der Browser kann einen eingegebenen Pfad nicht öffnen; wähle die Datei.
.opening-the-command-reference = Öffnen der Befehlsreferenz...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" ist bereits mit anderem Inhalt geladen; die vorhandene Datei wurde behalten. .already-loaded-different-contents-kept = "__ocs_fmt_0__" ist bereits mit anderem Inhalt geladen; die vorhandene Datei wurde behalten.
.opening-patreon-page = Eröffnungsseite Patreon... .opening-patreon-page = Eröffnungsseite Patreon...
.opening-feedback-page = Feedbackseite öffnen... .opening-feedback-page = Feedbackseite öffnen...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Το σχέδιο δεν έχει φορτωθεί στο πρόγραμμα περιήγησης: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Το σχέδιο δεν έχει φορτωθεί στο πρόγραμμα περιήγησης: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = Το "__ocs_fmt_0__" είναι __ocs_fmt_1__×__ocs_fmt_2__ px και η αποκωδικοποίησή του θα απαιτούσε __ocs_fmt_3__ MB, πάνω από το όριο __ocs_fmt_4__ MB για εικόνες σε αυτή την έκδοση — δεν μπορεί να επισυναφθεί. Οι εικόνες εντός του ορίου επισυνάπτονται κατά ανάγκη αυτόματα σε μειωμένη ανάλυση εμφάνισης. .image-over-the-decode-limit-cannot-be-attached = Το "__ocs_fmt_0__" είναι __ocs_fmt_1__×__ocs_fmt_2__ px και η αποκωδικοποίησή του θα απαιτούσε __ocs_fmt_3__ MB, πάνω από το όριο __ocs_fmt_4__ MB για εικόνες σε αυτή την έκδοση — δεν μπορεί να επισυναφθεί. Οι εικόνες εντός του ορίου επισυνάπτονται κατά ανάγκη αυτόματα σε μειωμένη ανάλυση εμφάνισης.
.image-refusal-unknown-size = Το "__ocs_fmt_0__" δεν μπόρεσε να μετρηθεί από την κεφαλίδα του και η αποκωδικοποίησή του θα ξεπερνούσε το όριο __ocs_fmt_1__ MB αυτής της build — δεν μπορεί να επισυναφθεί. .image-refusal-unknown-size = Το "__ocs_fmt_0__" δεν μπόρεσε να μετρηθεί από την κεφαλίδα του και η αποκωδικοποίησή του θα ξεπερνούσε το όριο __ocs_fmt_1__ MB αυτής της build — δεν μπορεί να επισυναφθεί.
.restore-all = Επαναφορά όλων
.restoring-drawing-s = Επαναφορά __ocs_fmt_0__ σχέδιο(α)…
.session-had-open-drawing-s = Η συνεδρία είχε __ocs_fmt_0__ ανοιχτά σχέδια.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Επιλέξτε αντικείμενα (__ocs_arg_count__ επιλεγμένα, Enter για εφαρμογή): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Επιλέξτε αντικείμενα (__ocs_arg_count__ επιλεγμένα, Enter για εφαρμογή):
@@ -3572,8 +3575,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: η ενεργή κλίμακα επισημείωσης δεν είναι διαθέσιμη. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: η ενεργή κλίμακα επισημείωσης δεν είναι διαθέσιμη.
.opening-ocs-discussions-for-help-and-questions = Άνοιγμα των συζητήσεων OCS για βοήθεια και ερωτήσεις... .opening-ocs-discussions-for-help-and-questions = Άνοιγμα των συζητήσεων OCS για βοήθεια και ερωτήσεις...
.opening-ocs-web = Άνοιγμα του OCS Web... .opening-ocs-web = Άνοιγμα του OCS Web...
.opening-the-command-reference = Άνοιγμα της αναφοράς εντολών...
.browser-cannot-open-typed-path = Ο browser δεν μπορεί να ανοίξει πληκτρολογημένη διαδρομή· διάλεξε το αρχείο. .browser-cannot-open-typed-path = Ο browser δεν μπορεί να ανοίξει πληκτρολογημένη διαδρομή· διάλεξε το αρχείο.
.opening-the-command-reference = Άνοιγμα της αναφοράς εντολών...
.already-loaded-different-contents-kept = Το "__ocs_fmt_0__" είναι ήδη φορτωμένο με διαφορετικό περιεχόμενο· διατηρήθηκε το υπάρχον αρχείο. .already-loaded-different-contents-kept = Το "__ocs_fmt_0__" είναι ήδη φορτωμένο με διαφορετικό περιεχόμενο· διατηρήθηκε το υπάρχον αρχείο.
.opening-patreon-page = Άνοιγμα της σελίδας Patreon... .opening-patreon-page = Άνοιγμα της σελίδας Patreon...
.opening-feedback-page = Άνοιγμα της σελίδας σχολίων... .opening-feedback-page = Άνοιγμα της σελίδας σχολίων...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Drawing not loaded in the browser: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Drawing not loaded in the browser: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" is __ocs_fmt_1__×__ocs_fmt_2__ px and would decode to __ocs_fmt_3__ MB, over the __ocs_fmt_4__ MB image limit of this build — it cannot be attached. Images within the limit are attached at a reduced display resolution when needed. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" is __ocs_fmt_1__×__ocs_fmt_2__ px and would decode to __ocs_fmt_3__ MB, over the __ocs_fmt_4__ MB image limit of this build — it cannot be attached. Images within the limit are attached at a reduced display resolution when needed.
.image-refusal-unknown-size = "__ocs_fmt_0__" could not be sized from its header and decoding it exceeded the __ocs_fmt_1__ MB image limit of this build — it cannot be attached. .image-refusal-unknown-size = "__ocs_fmt_0__" could not be sized from its header and decoding it exceeded the __ocs_fmt_1__ MB image limit of this build — it cannot be attached.
.restore-all = Restore all
.restoring-drawing-s = Restoring __ocs_fmt_0__ drawing(s)…
.session-had-open-drawing-s = Session had __ocs_fmt_0__ open drawing(s).
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Select objects (__ocs_arg_count__ selected, Enter to apply): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Select objects (__ocs_arg_count__ selected, Enter to apply):
@@ -3573,8 +3576,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: the active annotation scale is unavailable. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: the active annotation scale is unavailable.
.opening-ocs-discussions-for-help-and-questions = Opening OCS Discussions for help and questions... .opening-ocs-discussions-for-help-and-questions = Opening OCS Discussions for help and questions...
.opening-ocs-web = Opening OCS Web... .opening-ocs-web = Opening OCS Web...
.opening-the-command-reference = Opening the command reference...
.browser-cannot-open-typed-path = The browser cannot open a typed path; pick the file instead. .browser-cannot-open-typed-path = The browser cannot open a typed path; pick the file instead.
.opening-the-command-reference = Opening the command reference...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" is already loaded with different contents; the existing file was kept. .already-loaded-different-contents-kept = "__ocs_fmt_0__" is already loaded with different contents; the existing file was kept.
.opening-patreon-page = Opening Patreon page... .opening-patreon-page = Opening Patreon page...
.opening-feedback-page = Opening feedback page... .opening-feedback-page = Opening feedback page...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Dibujo no cargado en el navegador: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Dibujo no cargado en el navegador: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" tiene __ocs_fmt_1__×__ocs_fmt_2__ px y al decodificarse ocuparía __ocs_fmt_3__ MB, por encima del límite de __ocs_fmt_4__ MB por imagen de esta versión; no se puede adjuntar. Las imágenes dentro del límite se adjuntan automáticamente con una resolución de visualización reducida cuando es necesario. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" tiene __ocs_fmt_1__×__ocs_fmt_2__ px y al decodificarse ocuparía __ocs_fmt_3__ MB, por encima del límite de __ocs_fmt_4__ MB por imagen de esta versión; no se puede adjuntar. Las imágenes dentro del límite se adjuntan automáticamente con una resolución de visualización reducida cuando es necesario.
.image-refusal-unknown-size = "__ocs_fmt_0__" no pudo medirse por su cabecera y decodificarlo excedería el límite de __ocs_fmt_1__ MB de esta build — no se puede adjuntar. .image-refusal-unknown-size = "__ocs_fmt_0__" no pudo medirse por su cabecera y decodificarlo excedería el límite de __ocs_fmt_1__ MB de esta build — no se puede adjuntar.
.restore-all = Restaurar todo
.restoring-drawing-s = Restaurando __ocs_fmt_0__ dibujos…
.session-had-open-drawing-s = La sesión tenía __ocs_fmt_0__ dibujos abiertos.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Seleccione objetos (__ocs_arg_count__ seleccionados, Enter para aplicar): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Seleccione objetos (__ocs_arg_count__ seleccionados, Enter para aplicar):
@@ -3578,8 +3581,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: la escala de anotación activa no está disponible. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: la escala de anotación activa no está disponible.
.opening-ocs-discussions-for-help-and-questions = Abriendo Debates de OCS para obtener ayuda y hacer preguntas... .opening-ocs-discussions-for-help-and-questions = Abriendo Debates de OCS para obtener ayuda y hacer preguntas...
.opening-ocs-web = Abriendo OCS Web... .opening-ocs-web = Abriendo OCS Web...
.opening-the-command-reference = Abriendo la referencia de comandos...
.browser-cannot-open-typed-path = El navegador no puede abrir una ruta escrita; elige el archivo. .browser-cannot-open-typed-path = El navegador no puede abrir una ruta escrita; elige el archivo.
.opening-the-command-reference = Abriendo la referencia de comandos...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" ya está cargado con contenido diferente; se conservó el archivo existente. .already-loaded-different-contents-kept = "__ocs_fmt_0__" ya está cargado con contenido diferente; se conservó el archivo existente.
.opening-patreon-page = Abriendo la página de Patreon... .opening-patreon-page = Abriendo la página de Patreon...
.opening-feedback-page = Abriendo la página de comentarios... .opening-feedback-page = Abriendo la página de comentarios...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Piirustusta ei ole ladattu selaimeen: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Piirustusta ei ole ladattu selaimeen: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" on __ocs_fmt_1__×__ocs_fmt_2__ px ja sen purkaminen veisi __ocs_fmt_3__ MB, yli tämän version __ocs_fmt_4__ MB:n kuvarajan — sitä ei voi liittää. Rajan sisällä olevat kuvat liitetään tarvittaessa automaattisesti pienemmällä näyttötarkkuudella. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" on __ocs_fmt_1__×__ocs_fmt_2__ px ja sen purkaminen veisi __ocs_fmt_3__ MB, yli tämän version __ocs_fmt_4__ MB:n kuvarajan — sitä ei voi liittää. Rajan sisällä olevat kuvat liitetään tarvittaessa automaattisesti pienemmällä näyttötarkkuudella.
.image-refusal-unknown-size = Kohteen "__ocs_fmt_0__" kokoa ei voitu mitata otsakkeesta ja sen purkaminen ylittäisi tämän käännöksen __ocs_fmt_1__ MB kuvarajan — sitä ei voi liittää. .image-refusal-unknown-size = Kohteen "__ocs_fmt_0__" kokoa ei voitu mitata otsakkeesta ja sen purkaminen ylittäisi tämän käännöksen __ocs_fmt_1__ MB kuvarajan — sitä ei voi liittää.
.restore-all = Palauta kaikki
.restoring-drawing-s = Palautetaan __ocs_fmt_0__ piirustusta…
.session-had-open-drawing-s = Istunnossa oli __ocs_fmt_0__ avointa piirustusta.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Valitse objektit (__ocs_arg_count__ valittu, Enter käytettäväksi): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Valitse objektit (__ocs_arg_count__ valittu, Enter käytettäväksi):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: aktiivinen huomautusasteikko ei ole käytettävissä. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: aktiivinen huomautusasteikko ei ole käytettävissä.
.opening-ocs-discussions-for-help-and-questions = OCS-keskustelujen avaaminen apua ja kysymyksiä varten... .opening-ocs-discussions-for-help-and-questions = OCS-keskustelujen avaaminen apua ja kysymyksiä varten...
.opening-ocs-web = Avataan OCS Web... .opening-ocs-web = Avataan OCS Web...
.opening-the-command-reference = Avataan komentoviittausta...
.browser-cannot-open-typed-path = Selain ei voi avata kirjoitettua polkua; valitse tiedosto. .browser-cannot-open-typed-path = Selain ei voi avata kirjoitettua polkua; valitse tiedosto.
.opening-the-command-reference = Avataan komentoviittausta...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" on jo ladattu eri sisällöllä; olemassa oleva tiedosto säilytettiin. .already-loaded-different-contents-kept = "__ocs_fmt_0__" on jo ladattu eri sisällöllä; olemassa oleva tiedosto säilytettiin.
.opening-patreon-page = Avataan Patreon-sivua... .opening-patreon-page = Avataan Patreon-sivua...
.opening-feedback-page = Palautesivua avataan... .opening-feedback-page = Palautesivua avataan...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Dessin non chargé dans le navigateur : __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Dessin non chargé dans le navigateur : __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" fait __ocs_fmt_1__×__ocs_fmt_2__ px et son décodage nécessiterait __ocs_fmt_3__ Mo, au-delà de la limite de __ocs_fmt_4__ Mo par image de cette version — elle ne peut pas être jointe. Les images dans la limite sont jointes automatiquement à une résolution d'affichage réduite si nécessaire. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" fait __ocs_fmt_1__×__ocs_fmt_2__ px et son décodage nécessiterait __ocs_fmt_3__ Mo, au-delà de la limite de __ocs_fmt_4__ Mo par image de cette version — elle ne peut pas être jointe. Les images dans la limite sont jointes automatiquement à une résolution d'affichage réduite si nécessaire.
.image-refusal-unknown-size = "__ocs_fmt_0__" n’a pas pu être mesuré par son en-tête et son décodage dépasserait la limite de __ocs_fmt_1__ Mo de cette version — impossible de l’attacher. .image-refusal-unknown-size = "__ocs_fmt_0__" n’a pas pu être mesuré par son en-tête et son décodage dépasserait la limite de __ocs_fmt_1__ Mo de cette version — impossible de l’attacher.
.restore-all = Tout restaurer
.restoring-drawing-s = Restauration de __ocs_fmt_0__ dessin(s)…
.session-had-open-drawing-s = La session avait __ocs_fmt_0__ dessin(s) ouvert(s).
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Sélectionner les objets (__ocs_arg_count__ sélectionnés, Entrez pour appliquer) : .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Sélectionner les objets (__ocs_arg_count__ sélectionnés, Entrez pour appliquer) :
@@ -3578,8 +3581,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE : l'échelle d'annotation active n'est pas disponible. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE : l'échelle d'annotation active n'est pas disponible.
.opening-ocs-discussions-for-help-and-questions = Ouverture OCS Discussions pour l'aide et les questions... .opening-ocs-discussions-for-help-and-questions = Ouverture OCS Discussions pour l'aide et les questions...
.opening-ocs-web = Ouverture du site Web OCS... .opening-ocs-web = Ouverture du site Web OCS...
.opening-the-command-reference = Ouverture de la référence des commandes...
.browser-cannot-open-typed-path = Le navigateur ne peut pas ouvrir un chemin saisi ; choisissez le fichier. .browser-cannot-open-typed-path = Le navigateur ne peut pas ouvrir un chemin saisi ; choisissez le fichier.
.opening-the-command-reference = Ouverture de la référence des commandes...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" est déjà chargé avec un contenu différent ; le fichier existant a été conservé. .already-loaded-different-contents-kept = "__ocs_fmt_0__" est déjà chargé avec un contenu différent ; le fichier existant a été conservé.
.opening-patreon-page = Ouverture de la page Patreon... .opening-patreon-page = Ouverture de la page Patreon...
.opening-feedback-page = Ouverture de la page de rétroaction... .opening-feedback-page = Ouverture de la page de rétroaction...
+4 -1
View File
@@ -1986,6 +1986,9 @@ file =
.drawing-not-loaded-in-the-browser = ड्रॉइंग ब्राउज़र में लोड नहीं है: __ocs_fmt_0__। .drawing-not-loaded-in-the-browser = ड्रॉइंग ब्राउज़र में लोड नहीं है: __ocs_fmt_0__।
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" __ocs_fmt_1__×__ocs_fmt_2__ px है और इसे डिकोड करने पर __ocs_fmt_3__ MB लगेगा, जो इस बिल्ड की __ocs_fmt_4__ MB छवि सीमा से अधिक है — इसे संलग्न नहीं किया जा सकता। सीमा के भीतर की छवियाँ आवश्यकता पड़ने पर स्वचालित रूप से घटी हुई डिस्प्ले रिज़ॉल्यूशन पर संलग्न की जाती हैं। .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" __ocs_fmt_1__×__ocs_fmt_2__ px है और इसे डिकोड करने पर __ocs_fmt_3__ MB लगेगा, जो इस बिल्ड की __ocs_fmt_4__ MB छवि सीमा से अधिक है — इसे संलग्न नहीं किया जा सकता। सीमा के भीतर की छवियाँ आवश्यकता पड़ने पर स्वचालित रूप से घटी हुई डिस्प्ले रिज़ॉल्यूशन पर संलग्न की जाती हैं।
.image-refusal-unknown-size = "__ocs_fmt_0__" को उसके हेडर से मापा नहीं जा सका और उसे डिकोड करना इस बिल्ड की __ocs_fmt_1__ MB छवि सीमा पार कर देगा — जोड़ा नहीं जा सकता। .image-refusal-unknown-size = "__ocs_fmt_0__" को उसके हेडर से मापा नहीं जा सका और उसे डिकोड करना इस बिल्ड की __ocs_fmt_1__ MB छवि सीमा पार कर देगा — जोड़ा नहीं जा सकता।
.restore-all = सभी पुनर्स्थापित करें
.restoring-drawing-s = __ocs_fmt_0__ ड्रॉइंग पुनर्स्थापित की जा रही हैं…
.session-had-open-drawing-s = सत्र में __ocs_fmt_0__ ड्रॉइंग खुली थीं।
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ ऑब्जेक्ट चुनें (__ocs_arg_count__ चयनित, लागू करने के लिए Enter दबाएँ): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ ऑब्जेक्ट चुनें (__ocs_arg_count__ चयनित, लागू करने के लिए Enter दबाएँ):
@@ -3566,8 +3569,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: सक्रिय एनोटेशन स्केल अनुपलब्ध है। .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: सक्रिय एनोटेशन स्केल अनुपलब्ध है।
.opening-ocs-discussions-for-help-and-questions = OCS मदद और प्रश्नों के लिए चर्चा खोलना। .opening-ocs-discussions-for-help-and-questions = OCS मदद और प्रश्नों के लिए चर्चा खोलना।
.opening-ocs-web = OCS वेब खोलना ... .opening-ocs-web = OCS वेब खोलना ...
.opening-the-command-reference = कमांड संदर्भ खोला जा रहा है...
.browser-cannot-open-typed-path = ब्राउज़र टाइप किए गए पथ को नहीं खोल सकता; इसके बजाय फ़ाइल चुनें। .browser-cannot-open-typed-path = ब्राउज़र टाइप किए गए पथ को नहीं खोल सकता; इसके बजाय फ़ाइल चुनें।
.opening-the-command-reference = कमांड संदर्भ खोला जा रहा है...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" अलग सामग्री के साथ पहले से लोड है; मौजूदा फ़ाइल रखी गई। .already-loaded-different-contents-kept = "__ocs_fmt_0__" अलग सामग्री के साथ पहले से लोड है; मौजूदा फ़ाइल रखी गई।
.opening-patreon-page = Patreon पृष्ठ खोला जा रहा है… .opening-patreon-page = Patreon पृष्ठ खोला जा रहा है…
.opening-feedback-page = प्रतिक्रिया पृष्ठ खोलना .opening-feedback-page = प्रतिक्रिया पृष्ठ खोलना
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = A raj nincs betöltve a böngészőben: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = A raj nincs betöltve a böngészőben: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = A(z) "__ocs_fmt_0__" __ocs_fmt_1__×__ocs_fmt_2__ px felbontású, és a dekódolása __ocs_fmt_3__ MB-ot igényelne — ez meghaladja e build __ocs_fmt_4__ MB-os képhatárát, ezért nem csatolható. A határon belüli képek szükség esetén automatikusan csökkentett megjelenítési felbontással csatolódnak. .image-over-the-decode-limit-cannot-be-attached = A(z) "__ocs_fmt_0__" __ocs_fmt_1__×__ocs_fmt_2__ px felbontású, és a dekódolása __ocs_fmt_3__ MB-ot igényelne — ez meghaladja e build __ocs_fmt_4__ MB-os képhatárát, ezért nem csatolható. A határon belüli képek szükség esetén automatikusan csökkentett megjelenítési felbontással csatolódnak.
.image-refusal-unknown-size = A(z) "__ocs_fmt_0__" nem mérhető a fejlécéből, és dekódolása meghaladná ezen build __ocs_fmt_1__ MB-os képhatárát — nem csatolható. .image-refusal-unknown-size = A(z) "__ocs_fmt_0__" nem mérhető a fejlécéből, és dekódolása meghaladná ezen build __ocs_fmt_1__ MB-os képhatárát — nem csatolható.
.restore-all = Összes visszaállítása
.restoring-drawing-s = __ocs_fmt_0__ rajz(ok) visszaállítása…
.session-had-open-drawing-s = A munkamenetben __ocs_fmt_0__ rajz volt megnyitva.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Objektumok kiválasztása (__ocs_arg_count__ kiválasztva, Enter alkalmazni): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Objektumok kiválasztása (__ocs_arg_count__ kiválasztva, Enter alkalmazni):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: az aktív annotációs skála nem érhető el. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: az aktív annotációs skála nem érhető el.
.opening-ocs-discussions-for-help-and-questions = A OCS beszélgetések megnyitása segítségért és kérdésekért... .opening-ocs-discussions-for-help-and-questions = A OCS beszélgetések megnyitása segítségért és kérdésekért...
.opening-ocs-web = A OCS web megnyitása... .opening-ocs-web = A OCS web megnyitása...
.opening-the-command-reference = A parancsreferencia megnyitása...
.browser-cannot-open-typed-path = A böngésző nem nyithat meg beírt útvonalat; válaszd a fájlt. .browser-cannot-open-typed-path = A böngésző nem nyithat meg beírt útvonalat; válaszd a fájlt.
.opening-the-command-reference = A parancsreferencia megnyitása...
.already-loaded-different-contents-kept = A(z) "__ocs_fmt_0__" már betöltve eltérő tartalommal; a meglévő fájl megőrizve. .already-loaded-different-contents-kept = A(z) "__ocs_fmt_0__" már betöltve eltérő tartalommal; a meglévő fájl megőrizve.
.opening-patreon-page = Patreon oldal megnyitása... .opening-patreon-page = Patreon oldal megnyitása...
.opening-feedback-page = Visszajelzési oldal megnyitása... .opening-feedback-page = Visszajelzési oldal megnyitása...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Disegno non caricato nel browser: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Disegno non caricato nel browser: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" è di __ocs_fmt_1__×__ocs_fmt_2__ px e la decodifica richiederebbe __ocs_fmt_3__ MB, oltre il limite di __ocs_fmt_4__ MB per immagine di questa build: non può essere allegata. Le immagini entro il limite vengono allegate automaticamente a risoluzione di visualizzazione ridotta quando necessario. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" è di __ocs_fmt_1__×__ocs_fmt_2__ px e la decodifica richiederebbe __ocs_fmt_3__ MB, oltre il limite di __ocs_fmt_4__ MB per immagine di questa build: non può essere allegata. Le immagini entro il limite vengono allegate automaticamente a risoluzione di visualizzazione ridotta quando necessario.
.image-refusal-unknown-size = "__ocs_fmt_0__" non è stato misurabile dall’intestazione e decodificarlo supererebbe il limite di __ocs_fmt_1__ MB di questa build — impossibile allegarlo. .image-refusal-unknown-size = "__ocs_fmt_0__" non è stato misurabile dall’intestazione e decodificarlo supererebbe il limite di __ocs_fmt_1__ MB di questa build — impossibile allegarlo.
.restore-all = Ripristina tutto
.restoring-drawing-s = Ripristino di __ocs_fmt_0__ disegno/i…
.session-had-open-drawing-s = La sessione aveva __ocs_fmt_0__ disegni aperti.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Seleziona oggetti (__ocs_arg_count__ selezionato, Enter da applicare): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Seleziona oggetti (__ocs_arg_count__ selezionato, Enter da applicare):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: la scala di annotazione attiva non è disponibile. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: la scala di annotazione attiva non è disponibile.
.opening-ocs-discussions-for-help-and-questions = Apertura delle discussioni OCS per assistenza e domande... .opening-ocs-discussions-for-help-and-questions = Apertura delle discussioni OCS per assistenza e domande...
.opening-ocs-web = Apertura del Web OCS... .opening-ocs-web = Apertura del Web OCS...
.opening-the-command-reference = Apertura del riferimento dei comandi...
.browser-cannot-open-typed-path = Il browser non può aprire un percorso digitato; scegli il file. .browser-cannot-open-typed-path = Il browser non può aprire un percorso digitato; scegli il file.
.opening-the-command-reference = Apertura del riferimento dei comandi...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" è già caricato con contenuti diversi; il file esistente è stato mantenuto. .already-loaded-different-contents-kept = "__ocs_fmt_0__" è già caricato con contenuti diversi; il file esistente è stato mantenuto.
.opening-patreon-page = Apertura della pagina Patreon... .opening-patreon-page = Apertura della pagina Patreon...
.opening-feedback-page = Apertura della pagina di feedback... .opening-feedback-page = Apertura della pagina di feedback...
+4 -1
View File
@@ -1988,6 +1988,9 @@ file =
.drawing-not-loaded-in-the-browser = 図面がブラウザーに読み込まれていません: __ocs_fmt_0__。 .drawing-not-loaded-in-the-browser = 図面がブラウザーに読み込まれていません: __ocs_fmt_0__。
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" は __ocs_fmt_1__×__ocs_fmt_2__ px で、デコードすると __ocs_fmt_3__ MB 必要となり、このビルドの __ocs_fmt_4__ MB の画像上限を超えるため添付できません。上限内の画像は、必要に応じて自動的に低下させた表示解像度で添付されます。 .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" は __ocs_fmt_1__×__ocs_fmt_2__ px で、デコードすると __ocs_fmt_3__ MB 必要となり、このビルドの __ocs_fmt_4__ MB の画像上限を超えるため添付できません。上限内の画像は、必要に応じて自動的に低下させた表示解像度で添付されます。
.image-refusal-unknown-size = "__ocs_fmt_0__" のサイズをヘッダーから測定できず、デコードするとこのビルドの __ocs_fmt_1__ MB 画像制限を超えます — 添付できません。 .image-refusal-unknown-size = "__ocs_fmt_0__" のサイズをヘッダーから測定できず、デコードするとこのビルドの __ocs_fmt_1__ MB 画像制限を超えます — 添付できません。
.restore-all = すべて復元
.restoring-drawing-s = __ocs_fmt_0__ 件の図面を復元しています…
.session-had-open-drawing-s = セッションでは __ocs_fmt_0__ 件の図面が開いていました。
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ オブジェクトを選択(__ocs_arg_count__ 個選択、Enter で適用): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ オブジェクトを選択(__ocs_arg_count__ 個選択、Enter で適用):
@@ -3566,8 +3569,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 現在の注釈尺度は利用できません。 .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 現在の注釈尺度は利用できません。
.opening-ocs-discussions-for-help-and-questions = ヘルプと質問のため OCS ディスカッションを開いています... .opening-ocs-discussions-for-help-and-questions = ヘルプと質問のため OCS ディスカッションを開いています...
.opening-ocs-web = OCS Web を開いています... .opening-ocs-web = OCS Web を開いています...
.opening-the-command-reference = コマンドリファレンスを開いています...
.browser-cannot-open-typed-path = ブラウザは入力されたパスを開けません。ファイルを選択してください。 .browser-cannot-open-typed-path = ブラウザは入力されたパスを開けません。ファイルを選択してください。
.opening-the-command-reference = コマンドリファレンスを開いています...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" は既に別の内容で読み込まれています。既存のファイルを保持しました。 .already-loaded-different-contents-kept = "__ocs_fmt_0__" は既に別の内容で読み込まれています。既存のファイルを保持しました。
.opening-patreon-page = Patreon ページを開いています... .opening-patreon-page = Patreon ページを開いています...
.opening-feedback-page = フィードバックページを開いています... .opening-feedback-page = フィードバックページを開いています...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = 도면이 브라우저에 로드되지 않았습니다: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = 도면이 브라우저에 로드되지 않았습니다: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__"은(는) __ocs_fmt_1__×__ocs_fmt_2__ px이며 디코딩 시 __ocs_fmt_3__ MB가 필요해 이 빌드의 __ocs_fmt_4__ MB 이미지 한도를 초과하므로 첨부할 수 없습니다. 한도 이내의 이미지는 필요할 때 자동으로 낮춘 표시 해상도로 첨부됩니다. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__"은(는) __ocs_fmt_1__×__ocs_fmt_2__ px이며 디코딩 시 __ocs_fmt_3__ MB가 필요해 이 빌드의 __ocs_fmt_4__ MB 이미지 한도를 초과하므로 첨부할 수 없습니다. 한도 이내의 이미지는 필요할 때 자동으로 낮춘 표시 해상도로 첨부됩니다.
.image-refusal-unknown-size = "__ocs_fmt_0__"의 크기를 헤더에서 측정할 수 없고 디코딩 시 이 빌드의 __ocs_fmt_1__ MB 이미지 한도를 초과합니다 — 첨부할 수 없습니다. .image-refusal-unknown-size = "__ocs_fmt_0__"의 크기를 헤더에서 측정할 수 없고 디코딩 시 이 빌드의 __ocs_fmt_1__ MB 이미지 한도를 초과합니다 — 첨부할 수 없습니다.
.restore-all = 모두 복원
.restoring-drawing-s = __ocs_fmt_0__ 도면을 복원하는 중…
.session-had-open-drawing-s = 세션에서 __ocs_fmt_0__ 도면이 열려 있었습니다.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 객체 선택(__ocs_arg_count__ 선택, Enter 적용): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 객체 선택(__ocs_arg_count__ 선택, Enter 적용):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 활성 주석 축척을 사용할 수 없습니다. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 활성 주석 축척을 사용할 수 없습니다.
.opening-ocs-discussions-for-help-and-questions = OCS 열기 도움말 및 질문에 대한 토론... .opening-ocs-discussions-for-help-and-questions = OCS 열기 도움말 및 질문에 대한 토론...
.opening-ocs-web = OCS 웹을 여는 중... .opening-ocs-web = OCS 웹을 여는 중...
.opening-the-command-reference = 명령 참조 열기...
.browser-cannot-open-typed-path = 브라우저는 입력한 경로를 열 수 없습니다. 파일을 선택하세요. .browser-cannot-open-typed-path = 브라우저는 입력한 경로를 열 수 없습니다. 파일을 선택하세요.
.opening-the-command-reference = 명령 참조 열기...
.already-loaded-different-contents-kept = "__ocs_fmt_0__"이(가) 이미 다른 내용으로 로드되었습니다. 기존 파일을 유지했습니다. .already-loaded-different-contents-kept = "__ocs_fmt_0__"이(가) 이미 다른 내용으로 로드되었습니다. 기존 파일을 유지했습니다.
.opening-patreon-page = Patreon 페이지를 여는 중... .opening-patreon-page = Patreon 페이지를 여는 중...
.opening-feedback-page = 피드백 페이지를 여는 중... .opening-feedback-page = 피드백 페이지를 여는 중...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Tekening is niet geladen in de browser: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Tekening is niet geladen in de browser: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" is __ocs_fmt_1__×__ocs_fmt_2__ px en zou bij het decoderen __ocs_fmt_3__ MB nodig hebben, boven de __ocs_fmt_4__ MB afbeeldingslimiet van deze build — het kan niet worden bijgevoegd. Afbeeldingen binnen de limiet worden zo nodig automatisch met een verlaagde weergaveresolutie bijgevoegd. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" is __ocs_fmt_1__×__ocs_fmt_2__ px en zou bij het decoderen __ocs_fmt_3__ MB nodig hebben, boven de __ocs_fmt_4__ MB afbeeldingslimiet van deze build — het kan niet worden bijgevoegd. Afbeeldingen binnen de limiet worden zo nodig automatisch met een verlaagde weergaveresolutie bijgevoegd.
.image-refusal-unknown-size = "__ocs_fmt_0__" kon niet via de header gemeten worden en decoderen overschrijdt de __ocs_fmt_1__ MB-limiet van deze build — kan niet worden toegevoegd. .image-refusal-unknown-size = "__ocs_fmt_0__" kon niet via de header gemeten worden en decoderen overschrijdt de __ocs_fmt_1__ MB-limiet van deze build — kan niet worden toegevoegd.
.restore-all = Alles herstellen
.restoring-drawing-s = __ocs_fmt_0__ tekening(en) worden hersteld…
.session-had-open-drawing-s = De sessie had __ocs_fmt_0__ tekening(en) open.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Selecteer objecten (__ocs_arg_count__ geselecteerd, Voer in om toe te passen): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Selecteer objecten (__ocs_arg_count__ geselecteerd, Voer in om toe te passen):
@@ -3576,8 +3579,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: de actieve annotatieschaal is niet beschikbaar. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: de actieve annotatieschaal is niet beschikbaar.
.opening-ocs-discussions-for-help-and-questions = Openen OCS Discussie voor hulp en vragen... .opening-ocs-discussions-for-help-and-questions = Openen OCS Discussie voor hulp en vragen...
.opening-ocs-web = Openen OCS Web... .opening-ocs-web = Openen OCS Web...
.opening-the-command-reference = Commandoreferentie openen...
.browser-cannot-open-typed-path = De browser kan een getypt pad niet openen; kies het bestand. .browser-cannot-open-typed-path = De browser kan een getypt pad niet openen; kies het bestand.
.opening-the-command-reference = Commandoreferentie openen...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" is al geladen met andere inhoud; het bestaande bestand is bewaard. .already-loaded-different-contents-kept = "__ocs_fmt_0__" is al geladen met andere inhoud; het bestaande bestand is bewaard.
.opening-patreon-page = Patreon pagina openen... .opening-patreon-page = Patreon pagina openen...
.opening-feedback-page = Herinneringspagina openen... .opening-feedback-page = Herinneringspagina openen...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = Rysunek nie został załadowany w przeglądarce: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Rysunek nie został załadowany w przeglądarce: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" ma __ocs_fmt_1__×__ocs_fmt_2__ px, a jej dekodowanie wymagałoby __ocs_fmt_3__ MB — ponad __ocs_fmt_4__ MB limit obrazu w tej wersji — więc nie może być dołączona. Obrazy w ramach limitu są w razie potrzeby dołączane automatycznie w obniżonej rozdzielczości wyświetlania. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" ma __ocs_fmt_1__×__ocs_fmt_2__ px, a jej dekodowanie wymagałoby __ocs_fmt_3__ MB — ponad __ocs_fmt_4__ MB limit obrazu w tej wersji — więc nie może być dołączona. Obrazy w ramach limitu są w razie potrzeby dołączane automatycznie w obniżonej rozdzielczości wyświetlania.
.image-refusal-unknown-size = „__ocs_fmt_0__” nie dał się zmierzyć z nagłówka, a jego dekodowanie przekroczy limit __ocs_fmt_1__ MB tej kompilacji — nie można go dołączyć. .image-refusal-unknown-size = „__ocs_fmt_0__” nie dał się zmierzyć z nagłówka, a jego dekodowanie przekroczy limit __ocs_fmt_1__ MB tej kompilacji — nie można go dołączyć.
.restore-all = Przywróć wszystkie
.restoring-drawing-s = Przywracanie __ocs_fmt_0__ rysunków…
.session-had-open-drawing-s = Sesja miała __ocs_fmt_0__ otwartych rysunków.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Wybierz obiekty (wybrano __ocs_arg_count__, zastosowano Enter): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Wybierz obiekty (wybrano __ocs_arg_count__, zastosowano Enter):
@@ -3577,8 +3580,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: aktywna skala adnotacji jest niedostępna. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: aktywna skala adnotacji jest niedostępna.
.opening-ocs-discussions-for-help-and-questions = Otwieranie dyskusji OCS w celu uzyskania pomocy i pytań... .opening-ocs-discussions-for-help-and-questions = Otwieranie dyskusji OCS w celu uzyskania pomocy i pytań...
.opening-ocs-web = Otwieranie OCS Web... .opening-ocs-web = Otwieranie OCS Web...
.opening-the-command-reference = Otwieranie dokumentacji poleceń...
.browser-cannot-open-typed-path = Przeglądarka nie otworzy wpisanej ścieżki; wybierz plik. .browser-cannot-open-typed-path = Przeglądarka nie otworzy wpisanej ścieżki; wybierz plik.
.opening-the-command-reference = Otwieranie dokumentacji poleceń...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" jest już wczytany z inną zawartością; zachowano istniejący plik. .already-loaded-different-contents-kept = "__ocs_fmt_0__" jest już wczytany z inną zawartością; zachowano istniejący plik.
.opening-patreon-page = Otwieranie strony Patreon... .opening-patreon-page = Otwieranie strony Patreon...
.opening-feedback-page = Otwieram stronę z opiniami... .opening-feedback-page = Otwieram stronę z opiniami...
+4 -1
View File
@@ -1998,6 +1998,9 @@ file =
.drawing-not-loaded-in-the-browser = Desenho não carregado no navegador: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Desenho não carregado no navegador: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" tem __ocs_fmt_1__×__ocs_fmt_2__ px e a decodificação exigiria __ocs_fmt_3__ MB, acima do limite de __ocs_fmt_4__ MB por imagem desta versão — não é possível anexá-la. Imagens dentro do limite são anexadas automaticamente com resolução de exibição reduzida quando necessário. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" tem __ocs_fmt_1__×__ocs_fmt_2__ px e a decodificação exigiria __ocs_fmt_3__ MB, acima do limite de __ocs_fmt_4__ MB por imagem desta versão — não é possível anexá-la. Imagens dentro do limite são anexadas automaticamente com resolução de exibição reduzida quando necessário.
.image-refusal-unknown-size = "__ocs_fmt_0__" não pôde ser medido pelo cabeçalho e a sua descodificação excedeu o limite de __ocs_fmt_1__ MB desta build — não pode ser anexado. .image-refusal-unknown-size = "__ocs_fmt_0__" não pôde ser medido pelo cabeçalho e a sua descodificação excedeu o limite de __ocs_fmt_1__ MB desta build — não pode ser anexado.
.restore-all = Restaurar tudo
.restoring-drawing-s = Restaurando __ocs_fmt_0__ desenhos…
.session-had-open-drawing-s = A sessão tinha __ocs_fmt_0__ desenhos abertos.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Selecione objetos (__ocs_arg_count__ selecionados, Enter para aplicar): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Selecione objetos (__ocs_arg_count__ selecionados, Enter para aplicar):
@@ -3578,8 +3581,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: a escala de anotação ativa não está disponível. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: a escala de anotação ativa não está disponível.
.opening-ocs-discussions-for-help-and-questions = Abrindo Discussões do OCS para ajuda e perguntas... .opening-ocs-discussions-for-help-and-questions = Abrindo Discussões do OCS para ajuda e perguntas...
.opening-ocs-web = Abrindo OCS Web... .opening-ocs-web = Abrindo OCS Web...
.opening-the-command-reference = Abrindo a referência de comandos...
.browser-cannot-open-typed-path = O navegador não pode abrir um caminho digitado; escolha o ficheiro. .browser-cannot-open-typed-path = O navegador não pode abrir um caminho digitado; escolha o ficheiro.
.opening-the-command-reference = Abrindo a referência de comandos...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" já está carregado com conteúdo diferente; o ficheiro existente foi mantido. .already-loaded-different-contents-kept = "__ocs_fmt_0__" já está carregado com conteúdo diferente; o ficheiro existente foi mantido.
.opening-patreon-page = Abrindo página do Patreon... .opening-patreon-page = Abrindo página do Patreon...
.opening-feedback-page = Abrindo página de comentários... .opening-feedback-page = Abrindo página de comentários...
+4 -1
View File
@@ -2001,6 +2001,9 @@ file =
.drawing-not-loaded-in-the-browser = Чертёж не загружен в браузере: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Чертёж не загружен в браузере: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" — __ocs_fmt_1__×__ocs_fmt_2__ px; для декодирования потребовалось бы __ocs_fmt_3__ МБ, что превышает ограничение __ocs_fmt_4__ МБ на изображение в этой сборке, поэтому вложить его нельзя. Изображения в пределах ограничения при необходимости вкладываются автоматически с пониженным разрешением отображения. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" — __ocs_fmt_1__×__ocs_fmt_2__ px; для декодирования потребовалось бы __ocs_fmt_3__ МБ, что превышает ограничение __ocs_fmt_4__ МБ на изображение в этой сборке, поэтому вложить его нельзя. Изображения в пределах ограничения при необходимости вкладываются автоматически с пониженным разрешением отображения.
.image-refusal-unknown-size = «__ocs_fmt_0__» не удалось измерить по заголовку, а его декодирование превысит лимит __ocs_fmt_1__ МБ этой сборки — вложить нельзя. .image-refusal-unknown-size = «__ocs_fmt_0__» не удалось измерить по заголовку, а его декодирование превысит лимит __ocs_fmt_1__ МБ этой сборки — вложить нельзя.
.restore-all = Восстановить все
.restoring-drawing-s = Восстановление __ocs_fmt_0__ чертежа(ей)…
.session-had-open-drawing-s = В сессии было открыто __ocs_fmt_0__ чертежа(ей).
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Выберите объекты (__ocs_arg_count__ Выберите, Введите для применения): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Выберите объекты (__ocs_arg_count__ Выберите, Введите для применения):
@@ -3579,8 +3582,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: активный масштаб аннотаций недоступен. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: активный масштаб аннотаций недоступен.
.opening-ocs-discussions-for-help-and-questions = Открытие OCS Дискуссии о помощи и вопросах .opening-ocs-discussions-for-help-and-questions = Открытие OCS Дискуссии о помощи и вопросах
.opening-ocs-web = Открытие OCS Веб-сайт .opening-ocs-web = Открытие OCS Веб-сайт
.opening-the-command-reference = Открытие справочника команд...
.browser-cannot-open-typed-path = Браузер не может открыть введённый путь; выберите файл. .browser-cannot-open-typed-path = Браузер не может открыть введённый путь; выберите файл.
.opening-the-command-reference = Открытие справочника команд...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" уже загружен с другим содержимым; существующий файл сохранён. .already-loaded-different-contents-kept = "__ocs_fmt_0__" уже загружен с другим содержимым; существующий файл сохранён.
.opening-patreon-page = Открытие страницы Patreon... .opening-patreon-page = Открытие страницы Patreon...
.opening-feedback-page = Открываем страницу обратной связи... .opening-feedback-page = Открываем страницу обратной связи...
+4 -1
View File
@@ -1981,6 +1981,9 @@ file =
.drawing-not-loaded-in-the-browser = Çizim tarayıcıya yüklenmedi: __ocs_fmt_0__. .drawing-not-loaded-in-the-browser = Çizim tarayıcıya yüklenmedi: __ocs_fmt_0__.
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" __ocs_fmt_1__×__ocs_fmt_2__ px boyutunda ve çözümlendiğinde __ocs_fmt_3__ MB gerektirir; bu sürümün __ocs_fmt_4__ MB görsel sınırını aştığından eklenemez. Sınır içindeki görseller gerektiğinde otomatik olarak düşürülmüş görüntü çözünürlüğüyle eklenir. .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__" __ocs_fmt_1__×__ocs_fmt_2__ px boyutunda ve çözümlendiğinde __ocs_fmt_3__ MB gerektirir; bu sürümün __ocs_fmt_4__ MB görsel sınırını aştığından eklenemez. Sınır içindeki görseller gerektiğinde otomatik olarak düşürülmüş görüntü çözünürlüğüyle eklenir.
.image-refusal-unknown-size = "__ocs_fmt_0__" başlığından ölçülemedi ve kodu çözülürse bu sürümün __ocs_fmt_1__ MB görüntü limiti aşılır — eklenemez. .image-refusal-unknown-size = "__ocs_fmt_0__" başlığından ölçülemedi ve kodu çözülürse bu sürümün __ocs_fmt_1__ MB görüntü limiti aşılır — eklenemez.
.restore-all = Tümünü geri yükle
.restoring-drawing-s = __ocs_fmt_0__ çizim geri yükleniyor…
.session-had-open-drawing-s = Oturumda __ocs_fmt_0__ açık çizim vardı.
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Nesneleri seçin (__ocs_arg_count__ seçili, uygulamak için Enter): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Nesneleri seçin (__ocs_arg_count__ seçili, uygulamak için Enter):
@@ -3528,8 +3531,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: Etkin açıklama ölçeği kullanılamıyor. .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: Etkin açıklama ölçeği kullanılamıyor.
.opening-ocs-discussions-for-help-and-questions = Yardım ve sorular için OCS Tartışmaları açılıyor... .opening-ocs-discussions-for-help-and-questions = Yardım ve sorular için OCS Tartışmaları açılıyor...
.opening-ocs-web = OCS Web açılıyor... .opening-ocs-web = OCS Web açılıyor...
.opening-the-command-reference = Komut başvurusu açılıyor...
.browser-cannot-open-typed-path = Tarayıcı yazılan bir yolu açamaz; dosyayı seçin. .browser-cannot-open-typed-path = Tarayıcı yazılan bir yolu açamaz; dosyayı seçin.
.opening-the-command-reference = Komut başvurusu açılıyor...
.already-loaded-different-contents-kept = "__ocs_fmt_0__" zaten farklı içerikle yüklü; mevcut dosya tutuldu. .already-loaded-different-contents-kept = "__ocs_fmt_0__" zaten farklı içerikle yüklü; mevcut dosya tutuldu.
.opening-patreon-page = Patreon sayfası açılıyor... .opening-patreon-page = Patreon sayfası açılıyor...
.opening-feedback-page = Geri bildirim sayfası açılıyor... .opening-feedback-page = Geri bildirim sayfası açılıyor...
+4 -1
View File
@@ -1985,6 +1985,9 @@ file =
.drawing-not-loaded-in-the-browser = 图形未加载到浏览器中:__ocs_fmt_0__。 .drawing-not-loaded-in-the-browser = 图形未加载到浏览器中:__ocs_fmt_0__。
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__"为 __ocs_fmt_1__×__ocs_fmt_2__ 像素,解码需要 __ocs_fmt_3__ MB,超过此版本的 __ocs_fmt_4__ MB 图像上限,因此无法附着。上限内的图像在需要时会自动以降低的显示分辨率附着。 .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__"为 __ocs_fmt_1__×__ocs_fmt_2__ 像素,解码需要 __ocs_fmt_3__ MB,超过此版本的 __ocs_fmt_4__ MB 图像上限,因此无法附着。上限内的图像在需要时会自动以降低的显示分辨率附着。
.image-refusal-unknown-size = 无法从文件头测得“__ocs_fmt_0__”的尺寸,解码将超过本版本 __ocs_fmt_1__ MB 的图像上限——无法附着。 .image-refusal-unknown-size = 无法从文件头测得“__ocs_fmt_0__”的尺寸,解码将超过本版本 __ocs_fmt_1__ MB 的图像上限——无法附着。
.restore-all = 全部恢复
.restoring-drawing-s = 正在恢复 __ocs_fmt_0__ 绘图…
.session-had-open-drawing-s = 会话中有 __ocs_fmt_0__ 个打开的绘图。
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 选择对象(已选择 __ocs_arg_count__ 个,按 Enter 应用): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 选择对象(已选择 __ocs_arg_count__ 个,按 Enter 应用):
@@ -3561,8 +3564,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 活动注释表没有可用 。 .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 活动注释表没有可用 。
.opening-ocs-discussions-for-help-and-questions = 开幕OCS 讨论帮助和问题... .opening-ocs-discussions-for-help-and-questions = 开幕OCS 讨论帮助和问题...
.opening-ocs-web = 打开OCS网络... .opening-ocs-web = 打开OCS网络...
.opening-the-command-reference = 正在打开命令参考...
.browser-cannot-open-typed-path = 浏览器无法打开键入的路径;请改为选择文件。 .browser-cannot-open-typed-path = 浏览器无法打开键入的路径;请改为选择文件。
.opening-the-command-reference = 正在打开命令参考...
.already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同内容加载;保留了现有文件。 .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同内容加载;保留了现有文件。
.opening-patreon-page = 打开帕特伦的页面... .opening-patreon-page = 打开帕特伦的页面...
.opening-feedback-page = 打开反馈页面... .opening-feedback-page = 打开反馈页面...
+4 -1
View File
@@ -1997,6 +1997,9 @@ file =
.drawing-not-loaded-in-the-browser = 圖面未載入瀏覽器:__ocs_fmt_0__。 .drawing-not-loaded-in-the-browser = 圖面未載入瀏覽器:__ocs_fmt_0__。
.image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__"為 __ocs_fmt_1__×__ocs_fmt_2__ 像素,解碼需要 __ocs_fmt_3__ MB,超過此組建的 __ocs_fmt_4__ MB 影像上限,因此無法貼附。上限內的影像會在需要時自動以降低的顯示解析度貼附。 .image-over-the-decode-limit-cannot-be-attached = "__ocs_fmt_0__"為 __ocs_fmt_1__×__ocs_fmt_2__ 像素,解碼需要 __ocs_fmt_3__ MB,超過此組建的 __ocs_fmt_4__ MB 影像上限,因此無法貼附。上限內的影像會在需要時自動以降低的顯示解析度貼附。
.image-refusal-unknown-size = 無法從檔頭測得「__ocs_fmt_0__」的尺寸,解碼將超過本版本 __ocs_fmt_1__ MB 的影像上限——無法附加。 .image-refusal-unknown-size = 無法從檔頭測得「__ocs_fmt_0__」的尺寸,解碼將超過本版本 __ocs_fmt_1__ MB 的影像上限——無法附加。
.restore-all = 全部恢復
.restoring-drawing-s = 正在恢復 __ocs_fmt_0__ 繪圖…
.session-had-open-drawing-s = 工作階段中有 __ocs_fmt_0__ 個開啟的繪圖。
selection = selection =
.cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 選擇物件(已選擇 __ocs_arg_count__ 個,按 Enter 套用): .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 選擇物件(已選擇 __ocs_arg_count__ 個,按 Enter 套用):
@@ -3573,8 +3576,8 @@ view =
.objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 活動註解表沒有可用 。 .objectscale-the-active-annotation-scale-is-una = OBJECTSCALE: 活動註解表沒有可用 。
.opening-ocs-discussions-for-help-and-questions = 開幕OCS 討論幫助和問題... .opening-ocs-discussions-for-help-and-questions = 開幕OCS 討論幫助和問題...
.opening-ocs-web = 開啟OCS網路... .opening-ocs-web = 開啟OCS網路...
.opening-the-command-reference = 正在開啟命令參考...
.browser-cannot-open-typed-path = 瀏覽器無法開啟輸入的路徑;請改為選擇檔案。 .browser-cannot-open-typed-path = 瀏覽器無法開啟輸入的路徑;請改為選擇檔案。
.opening-the-command-reference = 正在開啟命令參考...
.already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同內容載入;保留了現有檔案。 .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同內容載入;保留了現有檔案。
.opening-patreon-page = 開啟帕特倫的頁面... .opening-patreon-page = 開啟帕特倫的頁面...
.opening-feedback-page = 開啟反饋頁面... .opening-feedback-page = 開啟反饋頁面...
+25 -2
View File
@@ -1,5 +1,6 @@
#!/bin/sh #!/bin/sh
# Canonical web bundle build: Trunk plus a fonts sanity sync. # Canonical web bundle build: Trunk plus a fonts sanity sync and a service
# worker cache-version stamp.
# #
# Trunk's copy-dir pipeline always copies web/fonts in full, but its final # Trunk's copy-dir pipeline always copies web/fonts in full, but its final
# "apply staging to dist" step wipes the old dist and then moves dist/app/.stage # "apply staging to dist" step wipes the old dist and then moves dist/app/.stage
@@ -11,6 +12,13 @@
# successful Trunk build and verifies every font byte-for-byte, so a bundle # successful Trunk build and verifies every font byte-for-byte, so a bundle
# produced through it can never ship without the font set. # produced through it can never ship without the font set.
# #
# The wrapper also stamps the service worker's cache version: web/sw.js ships
# a "v1" placeholder (fine for `trunk serve`), but a deployed bundle must get
# a fresh cache name on every build or clients would keep serving the previous
# deploy's ~55 MB of cached assets. The hash covers every file in the finished
# bundle (fonts re-sync included, sw.js itself excluded), so any content
# change — or any Trunk re-hash of the wasm — produces a new version.
#
# Usage: sh scripts/build-web.sh [extra trunk build args] # Usage: sh scripts/build-web.sh [extra trunk build args]
# local: sh scripts/build-web.sh # local: sh scripts/build-web.sh
# CI: sh scripts/build-web.sh --locked --html-output index.html web-app.html # CI: sh scripts/build-web.sh --locked --html-output index.html web-app.html
@@ -46,4 +54,19 @@ for font in web/fonts/*.ttf; do
} }
count=$((count + 1)) count=$((count + 1))
done done
echo "web bundle built; verified $count fonts in dist/app/fonts" # Stamp the service worker's cache version from the finished bundle. Every
# file in dist/app except sw.js itself feeds the hash: the deployed sw.js
# then carries a cache name unique to exactly this bundle's contents, and its
# activate handler deletes any older cache.
[ -f dist/app/sw.js ] || {
echo "build-web.sh: dist/app/sw.js missing — is web/sw.js still listed as a copy-file link in web-app.html?" >&2
exit 1
}
bundle_hash=$(find dist/app -type f ! -name sw.js -print0 | sort -z | xargs -0 sha256sum | sha256sum | cut -c 1-12)
sed -i "s/^const CACHE_VERSION = \"[^\"]*\";/const CACHE_VERSION = \"$bundle_hash\";/" dist/app/sw.js
grep -q "^const CACHE_VERSION = \"$bundle_hash\";" dist/app/sw.js || {
echo "error: failed to stamp the cache version into dist/app/sw.js" >&2
exit 1
}
echo "web bundle built; verified $count fonts in dist/app/fonts; sw.js cache version $bundle_hash"
+77
View File
@@ -736,6 +736,25 @@ pub(super) struct OpenCADStudio {
/// prompt still has to offer, one at a time. /// prompt still has to offer, one at a time.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec<crate::io::web_autosave::WebAutosaveEntry>, web_autosave_pending: Vec<crate::io::web_autosave::WebAutosaveEntry>,
/// Snapshots a "Restore all" click still expects to settle (opened,
/// failed, cancelled, unreadable, or rejected by the open queue). Each
/// settle funnels through `queue_next_web_autosave_prompt`, which counts
/// this down; reaching zero arms `web_session_focus_pending`.
#[cfg(target_arch = "wasm32")]
web_autosave_restore_all_remaining: usize,
/// A Restore-all batch finished settling — apply the remembered
/// session's active tab at the next message boundary, after the last
/// restored open has actually inserted its tab (`web_autosave_opened`
/// runs before that insertion, so applying there would pick against a
/// tab list still missing the last restore).
#[cfg(target_arch = "wasm32")]
web_session_focus_pending: bool,
/// The tab list the previous session remembered (localStorage), loaded
/// after the boot autosave scan. Advisory only: it feeds the restore
/// modal's session line and the post-restore tab selection — nothing is
/// ever auto-opened from it.
#[cfg(target_arch = "wasm32")]
web_session: Option<crate::io::web_session::WebSession>,
/// The snapshot whose recovered open is in flight: `(autosave key, /// The snapshot whose recovered open is in flight: `(autosave key,
/// open id)`. The OPFS copy is the only one, so it is deleted only /// open id)`. The OPFS copy is the only one, so it is deleted only
/// once the open with exactly that id succeeds — identity, never the /// once the open with exactly that id succeeds — identity, never the
@@ -2045,6 +2064,11 @@ pub enum Message {
/// Web: an asynchronous OPFS copy written after Save is ready for recents. /// Web: an asynchronous OPFS copy written after Save is ready for recents.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
WebRecentStored(Result<PathBuf, String>), WebRecentStored(Result<PathBuf, String>),
/// Web: the `?file=` boot deep link settled — its bytes entered the
/// guarded open queue, or a notice/error text for the command line
/// (recent copy missing, refused or failed fetch).
#[cfg(target_arch = "wasm32")]
WebLinkSettled(crate::io::web_link::LinkOutcome),
/// Web: the boot scan finished — these autosave snapshots are newer than /// Web: the boot scan finished — these autosave snapshots are newer than
/// the drawings' last saved copies (newest first). /// the drawings' last saved copies (newest first).
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@@ -2052,6 +2076,12 @@ pub enum Message {
/// Web: restore the offered autosave snapshot into a recovered tab. /// Web: restore the offered autosave snapshot into a recovered tab.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
WebAutosaveRestore, WebAutosaveRestore,
/// Web: restore EVERY pending autosave snapshot — each is read and
/// enqueued through the guarded open queue with its own key (the poll
/// opens them sequentially); a snapshot is still consumed only after its
/// own open succeeds.
#[cfg(target_arch = "wasm32")]
WebAutosaveRestoreAll,
/// Web: delete the offered autosave snapshot (and offer the next, if any). /// Web: delete the offered autosave snapshot (and offer the next, if any).
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
WebAutosaveDiscard, WebAutosaveDiscard,
@@ -3895,6 +3925,12 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec::new(), web_autosave_pending: Vec::new(),
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
web_autosave_restore_all_remaining: 0,
#[cfg(target_arch = "wasm32")]
web_session_focus_pending: false,
#[cfg(target_arch = "wasm32")]
web_session: None,
#[cfg(target_arch = "wasm32")]
web_autosave_consumed: None, web_autosave_consumed: None,
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
web_autosave_clear_after: Vec::new(), web_autosave_clear_after: Vec::new(),
@@ -4443,6 +4479,46 @@ impl OpenCADStudio {
crate::io::web_autosave::scan(), crate::io::web_autosave::scan(),
Message::WebAutosavesScanned, Message::WebAutosavesScanned,
); );
// URL deep link (`?file=…`): a shareable/embeddable link that opens a
// drawing at boot. An http(s) URL is fetched with the same capped
// streaming reader the remote-image fetch uses and handed to the
// guarded drop-open queue; a bare name reads the OPFS recent copy the
// same way `open_recent_web` does. Everything else (other schemes,
// non-.dwg/.dxf URL tails) is refused with a console warning — the
// same scheme gate the image fetch applies. Pure parsing/validation
// lives in `io::web_link` and is unit-tested there.
let deep_link: Task<Message> = {
let search = web_sys::window()
.and_then(|window| window.location().search().ok())
.unwrap_or_default();
match crate::io::web_link::parse_link(&search) {
crate::io::web_link::ParsedLink::Target(target) => {
// Opening a URL from the query string is an implicit trust
// decision: name the origin before the fetch starts (info
// only — the bytes still go through the parse + recovery
// machinery like any other open). Same-origin links stay
// quiet: that drawing already comes from this site.
if let crate::io::web_link::LinkTarget::Url { url, name } = &target {
if !crate::io::web_link::same_origin(url) {
let origin = crate::io::web_link::display_origin(url)
.unwrap_or_else(|| url.clone());
s.command_line
.push_info(&format!("Opening {name} from {origin}…"));
}
}
Task::perform(crate::io::web_link::open_link(target), |outcome| {
Message::WebLinkSettled(outcome)
})
}
crate::io::web_link::ParsedLink::Ignored(refusal, value) => {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
&crate::io::web_link::refusal_warning(refusal, &value),
));
Task::none()
}
crate::io::web_link::ParsedLink::Absent => Task::none(),
}
};
( (
s, s,
Task::batch([ Task::batch([
@@ -4453,6 +4529,7 @@ impl OpenCADStudio {
discussions, discussions,
thumbs_fetch, thumbs_fetch,
autosave_scan, autosave_scan,
deep_link,
]), ]),
) )
} }
+4
View File
@@ -141,6 +141,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
if let Some(tab_id) = tab_id { if let Some(tab_id) = tab_id {
v4_support::on_tab_closed(tab_id); v4_support::on_tab_closed(tab_id);
} }
// The tab list just shrank: refresh the remembered session
// list (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
Task::none() Task::none()
} }
+4
View File
@@ -240,6 +240,10 @@ impl OpenCADStudio {
// last selection. #21. // last selection. #21.
self.sync_ribbon_layers(); self.sync_ribbon_layers();
self.sync_ribbon_from_selection(); self.sync_ribbon_from_selection();
// The tab list just shrank: refresh the remembered
// session list (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
return Task::batch([close_win, self.continue_tab_close_queue()]); return Task::batch([close_win, self.continue_tab_close_queue()]);
} }
Some(crate::app::PendingClose::Quit) => { Some(crate::app::PendingClose::Quit) => {
+116 -2
View File
@@ -1698,6 +1698,10 @@ impl OpenCADStudio {
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1); .set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
} }
self.opening.take(); self.opening.take();
// The tab list just changed (and the active tab with it): remember
// it for the next boot's restore flow (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
let pending_open_task = if let Some(report) = recovery_report { let pending_open_task = if let Some(report) = recovery_report {
self.recovery_report = Some(report); self.recovery_report = Some(report);
self.active_modal = Some(crate::app::ModalKind::Recovery); self.active_modal = Some(crate::app::ModalKind::Recovery);
@@ -2231,6 +2235,9 @@ impl OpenCADStudio {
); );
self.tabs[i].scene.document.version = version; self.tabs[i].scene.document.version = version;
self.tabs[i].recovery_save_as_required = false; self.tabs[i].recovery_save_as_required = false;
// A save may have renamed the tab (Save As): refresh the remembered
// session list (web session memory).
self.persist_web_session();
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch
&& self.tabs[i].edit_revision == edit_revision && self.tabs[i].edit_revision == edit_revision
&& self.tabs[i].scene.camera_generation == camera_generation; && self.tabs[i].scene.camera_generation == camera_generation;
@@ -3342,12 +3349,16 @@ impl OpenCADStudio {
/// restore prompt (behind any already-queued startup modal). An empty /// restore prompt (behind any already-queued startup modal). An empty
/// scan — a fresh profile, or everything already cleaned up — must not /// scan — a fresh profile, or everything already cleaned up — must not
/// surface an empty "Recover Drawing" modal, so the prompt is only /// surface an empty "Recover Drawing" modal, so the prompt is only
/// queued when there is something to offer. /// queued when there is something to offer. The remembered session list
/// is loaded right after the scan: it never opens anything itself, it
/// only names what the session had (a line in the restore modal) and
/// picks the active tab once restores complete.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosaves_scanned( pub(super) fn on_web_autosaves_scanned(
&mut self, &mut self,
result: Result<Vec<crate::io::web_autosave::WebAutosaveEntry>, String>, result: Result<Vec<crate::io::web_autosave::WebAutosaveEntry>, String>,
) -> Task<Message> { ) -> Task<Message> {
self.web_session = crate::io::web_session::load();
let Ok(mut entries) = result else { let Ok(mut entries) = result else {
// Best-effort feature: no storage access, no prompt. // Best-effort feature: no storage access, no prompt.
return Task::none(); return Task::none();
@@ -3384,6 +3395,44 @@ impl OpenCADStudio {
) )
} }
/// "Restore all": the whole pending list goes through the exact same
/// guarded path as a single restore — one read per snapshot, then
/// `enqueue_restore` with its own key, and the 50 ms poll opens them
/// sequentially. Nothing about consumption changes: each snapshot is
/// still deleted only when its own open succeeds (matched by open id),
/// and any failure (unreadable, parse error, cancel) keeps that
/// snapshot in OPFS for the next boot. The batch counts its settles in
/// `web_autosave_restore_all_remaining`; every settle path funnels
/// through `queue_next_web_autosave_prompt`, so the countdown — and the
/// remembered session's tab selection when it hits zero — needs no
/// extra bookkeeping sites.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_autosave_restore_all(&mut self) -> Task<Message> {
// Fewer than two pending: this is just Restore.
if self.web_autosave_pending.len() < 2 {
return self.on_web_autosave_restore();
}
let entries = std::mem::take(&mut self.web_autosave_pending);
self.web_autosave_restore_all_remaining = entries.len();
self.close_active_modal();
self.command_line.push_info(
crate::tf!("Restoring {} drawing(s)…", entries.len()).as_ref(),
);
let reads = crate::io::web_autosave::restore_all_plan(&entries)
.into_iter()
.map(|(open_name, key)| {
// Same contract as the single restore: the key travels with
// the bytes so the deletion marker is recorded by open id.
let read_key = key.clone();
Task::perform(
async move { crate::io::web_autosave::read(&read_key).await },
move |result| Message::WebAutosaveBytes(open_name, key, result),
)
})
.collect::<Vec<_>>();
Task::batch(reads)
}
/// The recovered drawing opened: its autosave snapshot — held back until /// The recovered drawing opened: its autosave snapshot — held back until
/// now — can finally be deleted, and the next queued snapshot can be /// now — can finally be deleted, and the next queued snapshot can be
/// offered. The caller already matched the marker to this open's id. /// offered. The caller already matched the marker to this open's id.
@@ -3477,14 +3526,79 @@ impl OpenCADStudio {
} }
/// Re-offer the remaining boot snapshots once the current one settled. /// Re-offer the remaining boot snapshots once the current one settled.
/// No-op when none are left (or the modal is already queued). /// No-op when none are left (or the modal is already queued). This is
/// also the single funnel every restore settle runs through (opened,
/// failed, cancelled, unreadable, or queue-rejected), so a Restore-all
/// batch counts down here — and when its last snapshot settles, the
/// remembered session decides which tab ends up active.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub(super) fn queue_next_web_autosave_prompt(&mut self) { pub(super) fn queue_next_web_autosave_prompt(&mut self) {
if self.web_autosave_restore_all_remaining > 0 {
self.web_autosave_restore_all_remaining -= 1;
if self.web_autosave_restore_all_remaining == 0 {
// Applied at the next message boundary: the last restore's
// settle (`web_autosave_opened`) runs BEFORE its own open
// inserts the tab, so focusing here could not see it yet.
self.web_session_focus_pending = true;
}
}
if !self.web_autosave_pending.is_empty() { if !self.web_autosave_pending.is_empty() {
self.queue_autosave_restore_prompt(); self.queue_autosave_restore_prompt();
} }
} }
/// A Restore-all batch has fully settled: focus the tab the crashed
/// session remembered as active, if it actually came back (or is open
/// under its own name). Restored tabs carry the "(recovered)" marker —
/// `recovered_name` maps the remembered name onto it idempotently. A
/// no-op without remembered memory, or when the remembered tab did not
/// return; the last-opened tab then stays active, which is fine. Called
/// from the after-message hook (`web_session_focus_pending`), so the
/// whole settle batch — including the last tab insertion — is done.
#[cfg(target_arch = "wasm32")]
pub(super) fn select_remembered_session_tab(&mut self) {
let Some(session) = self.web_session.as_ref() else {
return;
};
let Some(remembered) = session.tabs.get(session.active) else {
return;
};
let recovered = crate::io::web_autosave::recovered_name(remembered);
let Some(index) = self.tabs.iter().position(|tab| {
let name = tab.tab_display_name();
name == remembered.as_str() || name == recovered
}) else {
return;
};
self.active_tab = index;
// The active tab changed under the ribbon; re-sync its chips like
// every other tab switch does (#21).
self.sync_ribbon_layers();
self.sync_ribbon_styles();
self.sync_ribbon_from_selection();
}
/// Remember the open-tab list (names + active index) in localStorage —
/// the web session memory the next boot's restore flow consults. Cheap
/// by design: a small `Vec<String>` + index, rewritten in full. Called
/// on every save/open/close; the Start tab is not a drawing and is
/// skipped, with the active index remapped onto the filtered list.
#[cfg(target_arch = "wasm32")]
pub(super) fn persist_web_session(&self) {
let mut names: Vec<String> = Vec::new();
let mut active = 0;
for (index, tab) in self.tabs.iter().enumerate() {
if tab.is_start {
continue;
}
if index == self.active_tab {
active = names.len();
}
names.push(tab.tab_display_name());
}
crate::io::web_session::persist(&names, active);
}
/// Delete the `.sv$` autosave recovery files for all open drawings. They /// 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. /// exist only to survive a crash, so a clean save or exit removes them.
pub(in crate::app) fn cleanup_autosaves(&mut self) { pub(in crate::app) fn cleanup_autosaves(&mut self) {
+24
View File
@@ -291,6 +291,14 @@ impl OpenCADStudio {
let task = self.update_inner(msg); let task = self.update_inner(msg);
self.refresh_gpu_status(); self.refresh_gpu_status();
self.show_next_startup_modal(); self.show_next_startup_modal();
// A Restore-all batch finished settling on this message: now that
// the last restored open has inserted its tab, focus the tab the
// remembered session had active (web session memory).
#[cfg(target_arch = "wasm32")]
if self.web_session_focus_pending {
self.web_session_focus_pending = false;
self.select_remembered_session_tab();
}
self.sync_open_command_history(); self.sync_open_command_history();
// Close the document-level first-touch transaction started by // Close the document-level first-touch transaction started by
// push_undo_snapshot at this message boundary. // push_undo_snapshot at this message boundary.
@@ -1135,11 +1143,27 @@ impl OpenCADStudio {
} }
}, },
// The `?file=` boot deep link settled. Queued bytes are drained by
// the control poll's drop queue like any drop — nothing to say
// until that open starts (or its parse error surfaces there).
#[cfg(target_arch = "wasm32")]
Message::WebLinkSettled(outcome) => {
use crate::io::web_link::LinkOutcome;
match outcome {
LinkOutcome::Queued => {}
LinkOutcome::Info(text) => self.command_line.push_info(&text),
LinkOutcome::Error(text) => self.command_line.push_error(&text),
}
Task::none()
},
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result), Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result),
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
Message::WebAutosaveRestore => self.on_web_autosave_restore(), Message::WebAutosaveRestore => self.on_web_autosave_restore(),
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
Message::WebAutosaveRestoreAll => self.on_web_autosave_restore_all(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveDiscard => self.on_web_autosave_discard(), Message::WebAutosaveDiscard => self.on_web_autosave_discard(),
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
Message::WebAutosaveDefer => self.on_web_autosave_defer(), Message::WebAutosaveDefer => self.on_web_autosave_defer(),
+62 -23
View File
@@ -1599,8 +1599,15 @@ impl OpenCADStudio {
.first() .first()
.map(|entry| entry.name.clone()) .map(|entry| entry.name.clone())
.unwrap_or_default(); .unwrap_or_default();
let pending = self.web_autosave_pending.len();
// What the crashed session had open, when it remembered.
let session_drawings = self
.web_session
.as_ref()
.map(|session| session.tabs.len())
.filter(|count| *count > 0);
automatic_flow(ex, |flow| { automatic_flow(ex, |flow| {
autosave_restore_dialog_window(&name, flow) autosave_restore_dialog_window(&name, pending, session_drawings, flow)
}) })
} }
}) })
@@ -1777,36 +1784,68 @@ fn unsaved_changes_dialog_window(
/// Web crash-recovery prompt: an autosave snapshot of `name` is newer than /// 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 /// the drawing's last save. Restore opens it as a recovered tab; Discard
/// deletes the snapshot. Mirrors the unsaved-changes dialog's layout. /// deletes the snapshot. With more than one snapshot still pending, "Restore
/// all" puts the whole remaining list through the same guarded open queue.
/// When the crashed session left a tab list behind, a muted line says how
/// much it had open. Mirrors the unsaved-changes dialog's layout.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
fn autosave_restore_dialog_window( fn autosave_restore_dialog_window(
name: &str, name: &str,
pending: usize,
session_drawings: Option<usize>,
sizing: crate::ui::modal::ModalSizing, sizing: crate::ui::modal::ModalSizing,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
container( let mut body = column![text(crate::tf!(
column![ "\"{name}\" has an autosave copy newer than the last save."
text(crate::tf!( ))
"\"{name}\" has an autosave copy newer than the last save." .size(14)]
)) .spacing(0);
.size(14), if let Some(count) = session_drawings {
iced::widget::Space::new().height(4), body = body
.push(iced::widget::Space::new().height(4))
.push(
text(crate::tf!("Session had {count} open drawing(s).", count = count))
.size(12)
.style(dialog_muted_text_style),
);
}
body = body
.push(iced::widget::Space::new().height(4))
.push(
text(t!("Restore it as a recovered tab, or discard the copy.")) text(t!("Restore it as a recovered tab, or discard the copy."))
.size(12) .size(12)
.style(dialog_muted_text_style), .style(dialog_muted_text_style),
iced::widget::Space::new().height(20), )
row![ .push(iced::widget::Space::new().height(20));
dialog_button(t!("Restore"), Message::WebAutosaveRestore, button::primary), let mut buttons = row![dialog_button(
iced::widget::Space::new().width(8), t!("Restore"),
dialog_button(t!("Discard"), Message::WebAutosaveDiscard, button::danger), Message::WebAutosaveRestore,
], button::primary
] )];
.spacing(0), // Only worth its width once there is a batch to take in one click.
) if pending > 1 {
.style(dialog_body_style) buttons = buttons
.center_x(sizing.width) .push(iced::widget::Space::new().width(8))
.center_y(sizing.height) .push(dialog_button(
.padding([24, 28]) t!("Restore all"),
.into() Message::WebAutosaveRestoreAll,
button::secondary,
));
}
buttons = buttons
.push(iced::widget::Space::new().width(8))
.push(dialog_button(
t!("Discard"),
Message::WebAutosaveDiscard,
button::danger,
));
body = body.push(buttons);
container(body)
.style(dialog_body_style)
.center_x(sizing.width)
.center_y(sizing.height)
.padding([24, 28])
.into()
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
+6
View File
@@ -30,6 +30,12 @@ pub mod xref;
pub(crate) mod web_autosave; pub(crate) mod web_autosave;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub(crate) mod web_drop; pub(crate) mod web_drop;
// Same split as web_autosave: the ?file= deep-link parser (and its tests) are
// pure and compile everywhere; the fetch/open half is wasm-only.
pub(crate) mod web_link;
// Same split again: the session-memory serialization half (and its tests)
// compile everywhere; the localStorage half is wasm-only.
pub(crate) mod web_session;
// Minimal in-memory ZIP writer (classic format, flate2 CRC/deflate) used by // Minimal in-memory ZIP writer (classic format, flate2 CRC/deflate) used by
// the web ARCHIVE/ETRANSMIT transmittal download. // the web ARCHIVE/ETRANSMIT transmittal download.
pub mod zip; pub mod zip;
+38
View File
@@ -123,6 +123,20 @@ pub(crate) fn recovered_name(drawing: &str) -> String {
format!("{stem}{RECOVERED_MARKER}.{ext}") format!("{stem}{RECOVERED_MARKER}.{ext}")
} }
/// The open plan for a "Restore all" batch: one `(recovered tab name,
/// snapshot key)` pair per pending snapshot, in the pending list's order —
/// the same order the one-at-a-time prompts would have offered them
/// (newest first), and the order the batch's reads are fired in. The keys
/// travel unchanged: each snapshot is consumed only by its own successful
/// open, exactly like a single restore.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) fn restore_all_plan(entries: &[WebAutosaveEntry]) -> Vec<(String, String)> {
entries
.iter()
.map(|entry| (recovered_name(&entry.name), entry.name.clone()))
.collect()
}
/// Overwrite (or create) the snapshot for `name`. The optional thumbnail is /// Overwrite (or create) the snapshot for `name`. The optional thumbnail is
/// written alongside as `.thumb`; passing `None` removes any stale one. /// written alongside as `.thumb`; passing `None` removes any stale one.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@@ -371,6 +385,30 @@ mod tests {
); );
} }
#[test]
fn restore_all_keeps_the_pending_order_and_maps_only_the_names() {
let entries = vec![
entry("new.dwg", 300.0),
entry("mid.dwg", 200.0),
entry("plan (recovered).dxf", 100.0),
];
let plan = restore_all_plan(&entries);
assert_eq!(
plan.iter().map(|(name, _)| name.as_str()).collect::<Vec<_>>(),
[
"new (recovered).dwg",
"mid (recovered).dwg",
"plan (recovered).dxf"
]
);
// Keys ride along unchanged — consumption stays keyed by the exact
// snapshot identity, never by the display name.
assert_eq!(
plan.iter().map(|(_, key)| key.as_str()).collect::<Vec<_>>(),
["new.dwg", "mid.dwg", "plan (recovered).dxf"]
);
}
fn entry(name: &str, modified_ms: f64) -> WebAutosaveEntry { fn entry(name: &str, modified_ms: f64) -> WebAutosaveEntry {
WebAutosaveEntry { WebAutosaveEntry {
name: name.to_string(), name: name.to_string(),
+475
View File
@@ -0,0 +1,475 @@
//! Web URL deep links (`?file=`).
//!
//! The web build had no way to share or embed a link that opens a drawing:
//! `location.search` was never read. `?file=<value>` now opens a drawing at
//! boot, where `<value>` is a URL-encoded `http(s)` URL to fetch or a bare
//! drawing name to look up in the OPFS recent copies.
//!
//! The pure half (query parsing, scheme gate, trailing-name extraction) is
//! target-independent and unit-tested on the native host, mirroring how
//! `web_autosave` keeps its comparison logic testable; the fetch/open half is
//! wasm-only. Security mirrors the remote-raster fetch: only `http(s)` is
//! fetched (never `file:`, `javascript:`, or any other scheme — refused with
//! a console warning), the body is read with the same capped streaming reader
//! (64 MB cap, 8 s deadline, honest network/CORS/size/status classification),
//! and the URL must end in a `.dwg`/`.dxf` name like a drop. Fetched bytes
//! then enter the guarded drop-open queue (`web_drop::enqueue_open`), so the
//! deep link cannot bypass the open slot guard or the parse + recovery
//! machinery every other open goes through.
/// What `?file=` asked for, after validation.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkTarget {
/// An absolute `http(s)` URL whose trailing path segment is a
/// `.dwg`/`.dxf` name; `name` is that decoded segment.
Url { url: String, name: String },
/// A bare drawing name (no scheme) — resolved against the OPFS recent
/// copies, the same store `open_recent_web` reads.
RecentName(String),
}
/// Why a present `?file=` value was refused.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkRefusal {
/// A scheme other than `http`/`https` (`file:`, `javascript:`, `data:`,
/// or a Windows drive-letter colon) — the same gate the remote-image
/// fetch applies; never fetched, only warned about.
Scheme,
/// Started with `http(s)://` but did not parse as an absolute URL.
BadUrl,
/// Parsed, but the trailing path segment is not a `.dwg`/`.dxf` name.
NotDrawing,
}
/// Result of parsing the page's `location.search` for the deep-link param.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedLink {
/// No (non-empty) `file` param — boot proceeds normally.
Absent,
/// A validated target to open.
Target(LinkTarget),
/// Present but refused; the raw value is kept for the console warning.
Ignored(LinkRefusal, String),
}
/// Extract and validate the `file` query parameter from a raw `search`
/// string (with or without the leading `?`). Values are form-decoded
/// (`%XX` and `+`), trimmed, and scheme-gated: `http(s)` URLs must parse and
/// end in a `.dwg`/`.dxf` path segment; any other scheme-like prefix is
/// refused (never dereferenced); anything else is treated as a bare drawing
/// name for the OPFS recent-copy lookup.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub fn parse_link(search: &str) -> ParsedLink {
let search = search.strip_prefix('?').unwrap_or(search);
// `form_urlencoded` yields `Cow<str>` pairs in order; the FIRST `file`
// param decides — a duplicated one must not turn into two opens.
let value = url::form_urlencoded::parse(search.as_bytes())
.find(|(key, _)| key == "file")
.map(|(_, value)| value.into_owned());
let Some(value) = value else {
return ParsedLink::Absent;
};
let value = value.trim();
if value.is_empty() {
return ParsedLink::Absent;
}
let lower = value.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
let parsed = match url::Url::parse(value) {
Ok(parsed) => parsed,
Err(_) => return ParsedLink::Ignored(LinkRefusal::BadUrl, value.to_string()),
};
return match trailing_drawing_name(&parsed) {
Some(name) => ParsedLink::Target(LinkTarget::Url {
url: parsed.to_string(),
name,
}),
None => ParsedLink::Ignored(LinkRefusal::NotDrawing, value.to_string()),
};
}
if let Some(_scheme) = scheme_prefix(value) {
// Never dereference: the gate the remote-image fetch applies, so a
// `file:`/`javascript:`/drive-letter value dies here, warned only.
return ParsedLink::Ignored(LinkRefusal::Scheme, value.to_string());
}
ParsedLink::Target(LinkTarget::RecentName(value.to_string()))
}
/// The console warning for a refused param (mirrors the drop queues'
/// "ignored …" warnings, which also only reach the console).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub fn refusal_warning(refusal: LinkRefusal, value: &str) -> String {
match refusal {
LinkRefusal::Scheme => {
format!("ocs: ignored ?file=\"{value}\" (only http(s) URLs are fetched)")
}
LinkRefusal::BadUrl => {
format!("ocs: ignored ?file=\"{value}\" (not a valid absolute URL)")
}
LinkRefusal::NotDrawing => {
format!("ocs: ignored ?file=\"{value}\" (URL must end in a .dwg/.dxf name)")
}
}
}
/// RFC 3986 scheme prefix (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"`)
/// of `value`, if any. A single letter before a colon matches (so
/// `C:\drawings\plan.dwg` is refused as a scheme, which is correct on the
/// web: a native path is meaningless there and must not become a fetch).
fn scheme_prefix(value: &str) -> Option<String> {
let bytes = value.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
return None;
}
let end = bytes.iter().position(|&byte| byte == b':')?;
let scheme = &value[..end];
let valid = !scheme.is_empty()
&& scheme
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'));
valid.then(|| scheme.to_string())
}
/// The URL's trailing path segment, percent-decoded, when it is a
/// `.dwg`/`.dxf` drawing name. Same extension convention as the drop filter
/// (`web_drop::is_drawing_name`): a dot-less segment is its own extension.
fn trailing_drawing_name(url: &url::Url) -> Option<String> {
// `Url::path()` keeps the percent-encoding, so decode it here; query and
// fragment are already excluded by `path()`.
let segment = percent_decode(url.path().rsplit('/').next().unwrap_or_default());
if !is_drawing_name(&segment) {
return None;
}
Some(segment)
}
/// Same convention as `web_drop::is_drawing_name` (which lives in the
/// wasm-gated drop module, so the rule is restated here for the pure parser).
fn is_drawing_name(name: &str) -> bool {
matches!(
name.rsplit('.')
.next()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str(),
"dwg" | "dxf"
)
}
/// Decode `%XX` escapes (NOT `+`, which path segments do not carry). Invalid
/// or truncated escapes pass through verbatim; undecodable bytes become
/// U+FFFD via lossy conversion — a name is display data, never a path.
fn percent_decode(value: &str) -> String {
fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
let escape = (bytes[index] == b'%' && index + 2 < bytes.len()).then(|| {
(hex(bytes[index + 1]), hex(bytes[index + 2]))
});
match escape {
Some((Some(high), Some(low))) => {
out.push(high * 16 + low);
index += 3;
}
_ => {
out.push(bytes[index]);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
// ── Web open path ──────────────────────────────────────────────────────────
/// How a deep link settled; the `WebLinkSettled` update arm surfaces it.
/// `pub` to match `WebAutosaveEntry` (public type in a `pub(crate)` module),
/// keeping the `Message` payload free of privacy warnings.
#[cfg(target_arch = "wasm32")]
#[derive(Debug, Clone)]
pub enum LinkOutcome {
/// The bytes entered the guarded drop-open queue — the 50 ms poll opens
/// them exactly like a dropped file.
Queued,
/// Command-line notice (no drawing opened, nothing failed): the named
/// drawing is not in this browser's storage.
Info(String),
/// Command-line error: the fetch was refused or failed.
Error(String),
}
/// Resolve the boot deep link: fetch the URL with the same capped streaming
/// reader the remote-image fetch uses, or read the OPFS recent copy, then
/// queue the bytes for the guarded open poll. Errors render the honest
/// reason (network/CORS, HTTP status, size cap, deadline).
#[cfg(target_arch = "wasm32")]
pub(crate) async fn open_link(target: LinkTarget) -> LinkOutcome {
match target {
LinkTarget::Url { url, name } => {
match crate::scene::model::image_model::fetch_url_bytes(&url).await {
Ok(bytes) => enqueue_or_full(name, bytes),
Err(error) => LinkOutcome::Error(fetch_error_text(&url, error)),
}
}
LinkTarget::RecentName(name) => match super::web_recent::read(&name).await {
Ok(bytes) => enqueue_or_full(name, bytes),
Err(_) => LinkOutcome::Info(format!(
"\"{name}\" is not in this browser's recent drawings; open or drop it once and the link can reopen it."
)),
},
}
}
/// Hand the bytes to the guarded open queue (the same door a drop uses), or
/// report a full queue — the fetch succeeded, so the loss must be visible.
#[cfg(target_arch = "wasm32")]
fn enqueue_or_full(name: String, bytes: Vec<u8>) -> LinkOutcome {
if super::web_drop::enqueue_open(name.clone(), bytes) {
LinkOutcome::Queued
} else {
LinkOutcome::Error(format!(
"Deep-linked drawing \"{name}\" could not be opened: the open queue is full."
))
}
}
/// Plain-English twin of the remote-image failure notes (those strings are
/// built inline in the poll arm and mention images); the classification
/// itself is shared via `RemoteFetchError`.
#[cfg(target_arch = "wasm32")]
fn fetch_error_text(url: &str, error: crate::scene::model::image_model::RemoteFetchError) -> String {
use crate::scene::model::image_model::RemoteFetchError;
match error {
RemoteFetchError::Network => format!(
"Deep-linked drawing \"{url}\" could not be fetched: the network request failed (offline, DNS, or CORS)."
),
RemoteFetchError::Status(status) => format!(
"Deep-linked drawing \"{url}\" could not be fetched: the server returned HTTP {status}."
),
RemoteFetchError::TooLarge => format!(
"Deep-linked drawing \"{url}\" was not opened: it exceeds the 64 MB remote-file limit."
),
RemoteFetchError::Timeout => format!(
"Deep-linked drawing \"{url}\" could not be fetched: the request timed out."
),
}
}
/// Whether `url` shares the page's origin. Unparseable URLs (or a missing
/// window) read as foreign, so the trust notice errs on the loud side.
#[cfg(target_arch = "wasm32")]
pub(crate) fn same_origin(url: &str) -> bool {
let Some(page) = web_sys::window().and_then(|window| window.location().origin().ok()) else {
return false;
};
match url::Url::parse(url) {
// `ascii_serialization` and `location.origin()` agree on omitting
// default ports, so the string comparison is exact.
Ok(parsed) => parsed.origin().ascii_serialization() == page,
Err(_) => false,
}
}
/// The origin to show in the trust notice — the URL's own origin, falling
/// back to the full URL when it cannot be rendered.
#[cfg(target_arch = "wasm32")]
pub(crate) fn display_origin(url: &str) -> Option<String> {
url::Url::parse(url)
.ok()
.map(|parsed| parsed.origin().ascii_serialization())
.filter(|origin| !origin.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn url_target(search: &str) -> LinkTarget {
match parse_link(search) {
ParsedLink::Target(target) => target,
other => panic!("expected a target, got {other:?}"),
}
}
#[test]
fn absent_when_there_is_no_file_param() {
assert_eq!(parse_link(""), ParsedLink::Absent);
assert_eq!(parse_link("?"), ParsedLink::Absent);
assert_eq!(parse_link("?other=plan.dwg"), ParsedLink::Absent);
assert_eq!(parse_link("?file="), ParsedLink::Absent);
assert_eq!(parse_link("?file=%20"), ParsedLink::Absent);
// Only the exact lowercase key opens; `File=` is not a deep link.
assert_eq!(parse_link("?File=plan.dwg"), ParsedLink::Absent);
// The first occurrence wins — a duplicate must not open two files.
assert_eq!(
parse_link("?file=&file=plan.dwg"),
ParsedLink::Absent,
"an empty first value is absent, not skipped"
);
}
#[test]
fn http_urls_are_targets_with_their_trailing_name() {
assert_eq!(
url_target("?file=https%3A%2F%2Fexample.com%2Fplans%2Fsite.dwg"),
LinkTarget::Url {
url: "https://example.com/plans/site.dwg".to_string(),
name: "site.dwg".to_string()
}
);
// Unencoded URL in the query string works too (senders should
// encode, but browsers hand the raw text through).
assert_eq!(
url_target("?file=http://example.com/a.DXF"),
LinkTarget::Url {
url: "http://example.com/a.DXF".to_string(),
name: "a.DXF".to_string()
}
);
// Scheme check is case-insensitive; Url normalizes to lowercase.
assert_eq!(
url_target("?file=HTTPS://example.com/x.dwg"),
LinkTarget::Url {
url: "https://example.com/x.dwg".to_string(),
name: "x.dwg".to_string()
}
);
// Query and fragment never leak into the extracted name.
assert_eq!(
url_target("?file=https%3A%2F%2Fexample.com%2Fp.dwg%3Fv%3D1%23frag"),
LinkTarget::Url {
url: "https://example.com/p.dwg?v=1#frag".to_string(),
name: "p.dwg".to_string()
}
);
// Percent-encoded path segments decode into the tab name.
assert_eq!(
url_target("?file=https%3A%2F%2Fexample.com%2Fmy%2520plan.dwg"),
LinkTarget::Url {
// The param decodes to ".../my%20plan.dwg", which the URL
// keeps encoded in its path (a raw space is invalid there).
url: "https://example.com/my%20plan.dwg".to_string(),
name: "my plan.dwg".to_string()
}
);
}
#[test]
fn non_http_schemes_are_refused_not_fetched() {
for bad in [
"file:///C:/plan.dwg",
"javascript:alert(1)",
"data:text/plain,hello",
"ftp://example.com/plan.dwg",
"blob:https://example.com/uuid",
// A drive-letter colon is a valid single-letter scheme; a native
// path is meaningless on the web and must not become a fetch.
"C:\\drawings\\plan.dwg",
] {
assert_eq!(
parse_link(&format!("?file={bad}")),
ParsedLink::Ignored(LinkRefusal::Scheme, bad.to_string()),
"{bad} must be refused as a scheme"
);
}
// The refusal names the reason and echoes the value.
assert_eq!(
refusal_warning(LinkRefusal::Scheme, "javascript:alert(1)"),
"ocs: ignored ?file=\"javascript:alert(1)\" (only http(s) URLs are fetched)"
);
}
#[test]
fn http_urls_must_end_in_a_drawing_name() {
// No trailing segment, a directory URL, or a foreign extension.
for search in [
"?file=https%3A%2F%2Fexample.com%2F",
"?file=https%3A%2F%2Fexample.com%2Fplans%2F",
"?file=https%3A%2F%2Fexample.com%2Fplan.pdf",
"?file=https%3A%2F%2Fexample.com%2Fplan.dwg.txt",
// The extension check sees through dot-doubling tricks.
"?file=https%3A%2F%2Fexample.com%2F..dwgx",
] {
assert!(
matches!(parse_link(search), ParsedLink::Ignored(LinkRefusal::NotDrawing, _)),
"{search} must be refused as not a drawing"
);
}
// A dot-less segment is its own extension, mirroring the drop filter.
assert!(matches!(
url_target("?file=https%3A%2F%2Fexample.com%2FDWG"),
LinkTarget::Url { name, .. } if name == "DWG"
));
}
#[test]
fn malformed_http_urls_are_refused() {
assert!(matches!(
parse_link("?file=http%3A%2F%2F"),
ParsedLink::Ignored(LinkRefusal::BadUrl, _)
));
}
#[test]
fn bare_names_target_the_recent_store() {
assert_eq!(
url_target("?file=plan.dwg"),
LinkTarget::RecentName("plan.dwg".to_string())
);
// Encoded spaces and non-ASCII decode into the name.
assert_eq!(
url_target("?file=my+plan.dxf"),
LinkTarget::RecentName("my plan.dxf".to_string())
);
assert_eq!(
url_target("?file=%C3%A7izim.dwg"),
LinkTarget::RecentName("çizim.dwg".to_string())
);
// No scheme and no colon, but slashes stay a name — the recent store
// keys by the hashed name, so there is nothing to traverse.
assert_eq!(
url_target("?file=sub%2Fplan.dwg"),
LinkTarget::RecentName("sub/plan.dwg".to_string())
);
}
#[test]
fn percent_decode_only_touches_valid_escapes() {
assert_eq!(percent_decode("a%20b"), "a b");
assert_eq!(percent_decode("%2Fd%2E"), "/d.");
// Truncated and invalid escapes pass through verbatim.
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("%zz"), "%zz");
assert_eq!(percent_decode("%2"), "%2");
// Non-ASCII text survives untouched; bad UTF-8 escapes go lossy.
assert_eq!(percent_decode("çizim.dwg"), "çizim.dwg");
assert_eq!(percent_decode("%ff"), "\u{fffd}");
}
#[test]
fn scheme_prefix_matches_rfc3986_shape_only() {
assert_eq!(scheme_prefix("https://x"), Some("https".to_string()));
assert_eq!(scheme_prefix("c:\\x"), Some("c".to_string()));
assert_eq!(
scheme_prefix("x1+2-3.4:rest"),
Some("x1+2-3.4".to_string())
);
// A colon after a non-scheme character (space, slash, digit start)
// is not a scheme.
assert_eq!(scheme_prefix("plan.dwg"), None);
assert_eq!(scheme_prefix("a b:c"), None);
assert_eq!(scheme_prefix("1http://x"), None);
assert_eq!(scheme_prefix("no-colon"), None);
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Web session memory: which drawings were open (and which was active) the
//! last time a save/open/close touched the tab list.
//!
//! Deliberately NOT a session-restore engine: nothing ever auto-opens from
//! this list. The next boot's crash-recovery flow reads it only to show what
//! the session had and — once restores complete — to put the focus back on
//! the tab the session remembered as active. A small `Vec<String>` + index
//! in localStorage, rewritten in full on every save/open/close, is the whole
//! cost.
use serde::{Deserialize, Serialize};
/// localStorage key, next to `opencadstudio.settings`.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) const SESSION_KEY: &str = "opencadstudio.session";
/// The remembered tab list. `active` indexes `tabs`; construction and
/// parsing both clamp it so a stale index can never point outside the list.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WebSession {
pub tabs: Vec<String>,
pub active: usize,
}
impl WebSession {
pub fn new(tabs: Vec<String>, active: usize) -> Self {
Self {
active: active.min(tabs.len().saturating_sub(1)),
tabs,
}
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
/// Forgiving parse: a missing or out-of-range `active` clamps instead of
/// failing (the list is advisory memory, never load-bearing).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
fn from_json(json: &str) -> Option<Self> {
let session: Self = serde_json::from_str(json).ok()?;
Some(Self::new(session.tabs, session.active))
}
}
/// Remember `tabs`/`active` for the next boot. Best-effort: unavailable or
/// read-only storage silently skips the write.
#[cfg(target_arch = "wasm32")]
pub fn persist(tabs: &[String], active: usize) {
if cfg!(test) {
return;
}
let session = WebSession::new(tabs.to_vec(), active);
if let Some(storage) = web_sys::window().and_then(|window| window.local_storage().ok().flatten())
{
let _ = storage.set_item(SESSION_KEY, &session.to_json());
}
}
/// What the previous session had open, if it ever recorded anything.
#[cfg(target_arch = "wasm32")]
pub fn load() -> Option<WebSession> {
let json = web_sys::window()
.and_then(|window| window.local_storage().ok().flatten())
.and_then(|storage| storage.get_item(SESSION_KEY).ok().flatten())?;
WebSession::from_json(&json)
}
// Pure logic only — the localStorage half needs a browser and is exercised
// by hand (and by every web boot's restore flow).
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_session_round_trips_through_json() {
let session = WebSession::new(
vec!["plan.dwg".to_string(), "sketch.dxf".to_string()],
1,
);
let restored = WebSession::from_json(&session.to_json()).expect("must parse");
assert_eq!(restored, session);
assert_eq!(restored.tabs, ["plan.dwg", "sketch.dxf"]);
assert_eq!(restored.active, 1);
}
#[test]
fn an_out_of_range_active_index_clamps_instead_of_failing() {
// Construction clamps…
assert_eq!(WebSession::new(vec!["a.dwg".to_string()], 9).active, 0);
// …and so does a parse of stale or hostile JSON, both directions.
let over = WebSession::from_json(r#"{"tabs":["a.dwg","b.dwg"],"active":99}"#)
.expect("must parse");
assert_eq!(over.active, 1);
let under = WebSession::from_json(r#"{"tabs":["a.dwg"],"active":0}"#).unwrap();
assert_eq!(under.active, 0);
}
#[test]
fn garbage_is_rejected_and_an_empty_list_is_fine() {
assert!(WebSession::from_json("").is_none());
assert!(WebSession::from_json("not json").is_none());
assert!(WebSession::from_json("{}").is_none()); // missing `tabs` field
// A session whose every tab closed is still valid memory (it says
// the crash lost nothing that was visibly open).
let empty = WebSession::from_json(r#"{"tabs":[],"active":0}"#).unwrap();
assert!(empty.tabs.is_empty());
assert_eq!(empty.active, 0);
}
#[test]
fn duplicate_names_are_kept_as_is() {
// Two tabs may share a display name on the web; the memory list
// records the list as it was, de-duplication is not its job.
let session = WebSession::new(
vec!["a.dwg".to_string(), "a.dwg".to_string()],
1,
);
assert_eq!(
WebSession::from_json(&session.to_json()).unwrap(),
session
);
}
}
+4 -1
View File
@@ -1887,7 +1887,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Opening OCS Discussions for help and questions..." => Some(("view", "opening-ocs-discussions-for-help-and-questions")), "Opening OCS Discussions for help and questions..." => Some(("view", "opening-ocs-discussions-for-help-and-questions")),
"Opening OCS Web..." => Some(("view", "opening-ocs-web")), "Opening OCS Web..." => Some(("view", "opening-ocs-web")),
"Opening the command reference..." => Some(("view", "opening-the-command-reference")), "Opening the command reference..." => Some(("view", "opening-the-command-reference")),
"The browser cannot open a typed path; pick the file instead." => Some(("display", "browser-cannot-open-typed-path")), "The browser cannot open a typed path; pick the file instead." => Some(("view", "browser-cannot-open-typed-path")),
"\"{name}\" could not be sized from its header and decoding it exceeded the {limit} MB image limit of this build — it cannot be attached." => Some(("file", "image-refusal-unknown-size")), "\"{name}\" could not be sized from its header and decoding it exceeded the {limit} MB image limit of this build — it cannot be attached." => Some(("file", "image-refusal-unknown-size")),
"\"{name}\" is already loaded with different contents; the existing file was kept." => Some(("view", "already-loaded-different-contents-kept")), "\"{name}\" is already loaded with different contents; the existing file was kept." => Some(("view", "already-loaded-different-contents-kept")),
"Opening Patreon page..." => Some(("view", "opening-patreon-page")), "Opening Patreon page..." => Some(("view", "opening-patreon-page")),
@@ -4628,6 +4628,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Remote image \"{url}\" could not be fetched: the server returned HTTP {status}." => Some(("common", "remote-image-url-could-not-be-fetched-the-server-returned-http-status")), "Remote image \"{url}\" could not be fetched: the server returned HTTP {status}." => Some(("common", "remote-image-url-could-not-be-fetched-the-server-returned-http-status")),
"Remote image \"{url}\" was not loaded: it exceeds the 64 MB remote-image limit." => Some(("common", "remote-image-url-was-not-loaded-it-exceeds-the-64-mb-remote-image-limit")), "Remote image \"{url}\" was not loaded: it exceeds the 64 MB remote-image limit." => Some(("common", "remote-image-url-was-not-loaded-it-exceeds-the-64-mb-remote-image-limit")),
"Remote image \"{url}\" could not be fetched: the request timed out." => Some(("common", "remote-image-url-could-not-be-fetched-the-request-timed-out")), "Remote image \"{url}\" could not be fetched: the request timed out." => Some(("common", "remote-image-url-could-not-be-fetched-the-request-timed-out")),
"Restore all" => Some(("file", "restore-all")),
"Restoring {} drawing(s)…" => Some(("file", "restoring-drawing-s")),
"Session had {count} open drawing(s)." => Some(("file", "session-had-open-drawing-s")),
_ => None, _ => None,
} }
} }
+7 -1
View File
@@ -1170,7 +1170,9 @@ mod web_fetch {
/// The actual request. `GET url` with an abort signal armed at /// The actual request. `GET url` with an abort signal armed at
/// [`TIMEOUT_MS`]; the body is refused past [`MAX_BYTES`] using the /// [`TIMEOUT_MS`]; the body is refused past [`MAX_BYTES`] using the
/// declared `Content-Length` first and the received length second. /// declared `Content-Length` first and the received length second.
async fn fetch_once(url: &str) -> Result<Vec<u8>, RemoteFetchError> { /// `pub(crate)` so the `?file=` deep link can fetch its drawing through
/// this exact reader (see `io::web_link`).
pub(crate) async fn fetch_once(url: &str) -> Result<Vec<u8>, RemoteFetchError> {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
@@ -1261,6 +1263,10 @@ pub use web_fetch::{
take_outcome as take_remote_fetch_outcome, take_overflowed, take_outcome as take_remote_fetch_outcome, take_overflowed,
RemoteFetchError, RemoteFetchOutcome, RemoteFetchError, RemoteFetchOutcome,
}; };
// The capped streaming reader itself, shared with the `?file=` deep-link
// fetch (`io::web_link`) so both doors enforce the same 64 MB / 8 s bounds.
#[cfg(target_arch = "wasm32")]
pub(crate) use web_fetch::fetch_once as fetch_url_bytes;
#[cfg(all(test, not(target_arch = "wasm32")))] #[cfg(all(test, not(target_arch = "wasm32")))]
mod tests { mod tests {
+16
View File
@@ -45,6 +45,7 @@
<link data-trunk rel="copy-file" href="web/videos.json" /> <link data-trunk rel="copy-file" href="web/videos.json" />
<link data-trunk rel="copy-file" href="web/locale-labels.json" /> <link data-trunk rel="copy-file" href="web/locale-labels.json" />
<link data-trunk rel="copy-file" href="web/ocs-control.js" /> <link data-trunk rel="copy-file" href="web/ocs-control.js" />
<link data-trunk rel="copy-file" href="web/sw.js" />
</head> </head>
<body> <body>
<div id="loading"> <div id="loading">
@@ -53,6 +54,21 @@
<div class="sub">Loading…</div> <div class="sub">Loading…</div>
</div> </div>
<script src="ocs-control.js" defer></script> <script src="ocs-control.js" defer></script>
<script>
// Offline support: register the service worker. It is copied next to
// this page by Trunk (see the copy-file link in <head>), so the
// relative URL resolves to /app/sw.js and the registration scope is
// /app/ — the whole app. Registered immediately (not on load) so the
// worker starts capturing this visit's asset requests as early as
// possible. sw.js never reloads the page on update: a new worker
// waits until every tab on the old version has closed.
(() => {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker.register("sw.js").catch(error => {
console.warn("service worker registration failed; offline support disabled:", error);
});
})();
</script>
<script> <script>
(() => { (() => {
const splash = document.getElementById("loading"); const splash = document.getElementById("loading");
+136
View File
@@ -0,0 +1,136 @@
// Open CAD Studio web app service worker: offline app-shell caching.
//
// Trunk copies this file to dist/app/sw.js (copy-file link in web-app.html),
// so it is served at /app/sw.js and its scope is /app/ — the whole app. The
// page registers it with a relative URL; see the inline script in
// web-app.html.
//
// Caching strategy
// - Navigations (any path under /app/, deep links included): network-first.
// A successful response is cached under the canonical shell URL (the
// scope root) and returned, so updates land on the next visit; offline
// the cached shell is served instead, which also serves as the fallback
// for deep-linked paths.
// - Other same-origin GET requests (wasm, js, workers, fonts, JSON):
// cache-first with a background revalidate. Trunk content-hashes the
// heavy assets (OpenCADStudio-<hash>[_bg].{js,wasm}), so a cached copy
// can never be stale and revalidation is effectively free; non-hashed
// files (fonts/*.ttf, *.json, ocs-*.js) are silently refreshed in the
// background. Everything the app loads is captured during the first
// online visit, after which the ~55 MB bundle is served from the cache
// instead of re-downloaded on every visit.
// - Non-GET and cross-origin requests: passthrough (never intercepted).
//
// Versioning
// CACHE_VERSION below is a placeholder ("v1") so this file works verbatim
// under `trunk serve`. scripts/build-web.sh stamps it with a hash of the
// finished bundle after every build, so each deploy ships a fresh cache
// name and the activate step below deletes the previous deploy's cache.
//
// Update flow
// A changed sw.js installs a new worker, but this code deliberately never
// calls skipWaiting() and never reloads the page: the browser keeps the
// new worker in the "waiting" state until every tab running the old
// version has closed, so an in-progress editing session is never disrupted
// mid-session. The new worker (and its new cache) takes over on the first
// visit after that. Nothing is shown to the user — the browser chrome
// already handles the "update available" UX.
const CACHE_VERSION = "v1"; // stamped by scripts/build-web.sh (bundle hash)
const CACHE_NAME = `ocs-app-${CACHE_VERSION}`;
const SHELL_URL = new URL("./", self.registration.scope);
self.addEventListener("install", (event) => {
// Precache the app shell (index.html) so offline navigation works from the
// first visit after registration. If the shell cannot be fetched the
// install fails and the browser retries on a later navigation — a broken
// shell must never be cached.
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll([SHELL_URL.href])),
);
});
self.addEventListener("activate", (event) => {
// Delete every cache from previous versions, then start controlling pages
// that loaded before this worker was ready, so the current visit's requests
// are captured without needing a reload.
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name)),
);
await self.clients.claim();
})(),
);
});
// Cache a copy of a response unless it is not a complete success. Quota or
// write errors are swallowed: caching must never break the app itself.
const put = async (cache, key, response) => {
if (!response || !response.ok) return;
try {
await cache.put(key, response.clone());
} catch {}
};
self.addEventListener("fetch", (event) => {
const request = event.request;
// Only same-origin GETs are ours to serve.
if (request.method !== "GET") return;
if (new URL(request.url).origin !== self.location.origin) return;
// Range requests (media seeking) cannot be served from a full-body cache.
if (request.headers.has("range")) return;
if (request.mode === "navigate") {
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);
try {
const fresh = await fetch(request);
await put(cache, SHELL_URL, fresh);
return fresh;
} catch {
const cached = await cache.match(SHELL_URL);
if (cached) return cached;
return new Response("Open CAD Studio is offline.", {
status: 503,
headers: {"Content-Type": "text/plain; charset=utf-8"},
});
}
})(),
);
return;
}
// Cache-first with background revalidate. On a hit the cached copy is
// returned immediately and a fresh one is fetched (and cached) in the
// background; on a miss the network response is cached before being
// returned, making the next visit offline-capable.
event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const network = fetch(request)
.then(async (fresh) => {
await put(cache, request, fresh);
return fresh;
})
.catch(() => undefined);
if (cached) {
// Keep the worker alive until the background revalidate finishes.
event.waitUntil(network);
return cached;
}
const fresh = await network;
if (fresh) return fresh;
return new Response("Open CAD Studio is offline.", {
status: 503,
headers: {"Content-Type": "text/plain; charset=utf-8"},
});
})(),
);
});