diff --git a/Cargo.lock b/Cargo.lock index 4865a825a..754b084d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3679,6 +3679,8 @@ dependencies = [ name = "op-chrome-extension-core" version = "0.8.2" dependencies = [ + "jian-ops-schema", + "op-html", "op-util", "serde_json", "wasm-bindgen", diff --git a/crates/op-chrome-extension-core/Cargo.toml b/crates/op-chrome-extension-core/Cargo.toml index e874408ac..895cfec63 100644 --- a/crates/op-chrome-extension-core/Cargo.toml +++ b/crates/op-chrome-extension-core/Cargo.toml @@ -26,6 +26,14 @@ crate-type = ["cdylib", "rlib"] # Canonical JSON string escaping, shared with op-mcp's hand-rolled JSON-RPC # serializer. Dependency-free leaf crate, wasm32-clean by contract. op-util = { path = "../op-util" } +# The snapshot → `.op` (PenDocument) conversion, shared with the `op` CLI's +# `import:snapshot` command. wasm32-clean by contract (op-host-web depends on +# it and builds to wasm32), so the offline download can produce a ready-to-open +# `.op` document in the browser instead of the raw snapshot JSON. +op-html = { path = "../op-html" } +# The canonical `.op` document schema `import_snapshot_document` returns; needed +# to walk the imported node tree for the reported node count. +jian-ops-schema = { path = "../../vendor/jian/crates/jian-ops-schema" } # Used ONLY to parse the (small) HTTP reply bodies the two ingresses answer # with. The outbound `tools/call` envelope is built with `format!` + # `op_util::json_escape`, mirroring `op-mcp`'s serializer, so the multi-MB diff --git a/crates/op-chrome-extension-core/src/account_tests.rs b/crates/op-chrome-extension-core/src/account_tests.rs index 9d8ea946e..f090cf733 100644 --- a/crates/op-chrome-extension-core/src/account_tests.rs +++ b/crates/op-chrome-extension-core/src/account_tests.rs @@ -352,10 +352,16 @@ fn the_two_absent_states_serialize_as_the_popup_expects() { session_to_json(&SessionView::SignedOut), r#"{"state":"signedOut"}"# ); + // Compare parsed fields rather than the raw string: serde_json's object + // key order depends on whether `preserve_order` is unified into the build + // graph (op-html pulls it in transitively via schemars), and the popup + // `JSON.parse`s this either way, so ordering is not part of the contract. let json = session_to_json(&SessionView::Unavailable { detail: "HTTP 503".to_owned(), }); - assert_eq!(json, r#"{"detail":"HTTP 503","state":"error"}"#); + let value: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); + assert_eq!(value["state"], "error"); + assert_eq!(value["detail"], "HTTP 503"); } #[test] diff --git a/crates/op-chrome-extension-core/src/filename.rs b/crates/op-chrome-extension-core/src/filename.rs index 9efb02cd0..1dc9f4c6d 100644 --- a/crates/op-chrome-extension-core/src/filename.rs +++ b/crates/op-chrome-extension-core/src/filename.rs @@ -8,7 +8,7 @@ //! failure) or a dot-file. The result stays readable — a title only loses //! characters it could not have carried anyway. //! -//! This is a port of the JS `snapshotFilename`, chain step for chain step: +//! The sanitisation chain, step for step: //! //! ```text //! .replace(/[\p{Cc}<>:"/\\|?*%]+/gu, ' ') @@ -19,14 +19,18 @@ //! .slice(0, 80) //! .replace(/[\s.]+$/, '') //! ``` +//! +//! The offline download is a ready-to-open `.op` document (OpenPencil's +//! `PenDocument` format), so the sanitised stem carries the `.op` suffix and +//! the file opens by double-click without any CLI step. use crate::js_text::{is_js_space, js_trim, truncate_utf16}; /// Longest title stem kept, in UTF-16 code units (JS `slice(0, 80)`). const MAX_STEM_UNITS: usize = 80; -/// Suffix every snapshot download carries. -const SUFFIX: &str = "-snapshot.json"; +/// Suffix every `.op` download carries. +const SUFFIX: &str = ".op"; /// Stem used when the title sanitises down to nothing. const FALLBACK_STEM: &str = "page"; @@ -36,8 +40,8 @@ const FALLBACK_STEM: &str = "page"; /// `chrome.downloads` is a needless second decoding layer. const FORBIDDEN: [char; 10] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*', '%']; -/// Build the download file name for a captured page titled `title`. -pub fn snapshot_filename(title: &str) -> String { +/// Build the `.op` download file name for a captured page titled `title`. +pub fn op_filename(title: &str) -> String { let replaced = replace_forbidden_runs(title); let collapsed = collapse_space_runs(&replaced); let dotted = collapse_dot_runs(&collapsed); diff --git a/crates/op-chrome-extension-core/src/filename_tests.rs b/crates/op-chrome-extension-core/src/filename_tests.rs index 5f1588c26..343b10b26 100644 --- a/crates/op-chrome-extension-core/src/filename_tests.rs +++ b/crates/op-chrome-extension-core/src/filename_tests.rs @@ -1,25 +1,19 @@ //! Tests for [`crate::filename`] — download-name sanitisation of an -//! attacker-controlled page title. +//! attacker-controlled page title. The offline download is a `.op` document, +//! so the sanitised stem carries the `.op` suffix. -use crate::filename::snapshot_filename; +use crate::filename::op_filename; #[test] fn keeps_an_ordinary_title_readable() { - assert_eq!( - snapshot_filename("Example Domain"), - "Example Domain-snapshot.json" - ); - assert_eq!(snapshot_filename("周报 2026"), "周报 2026-snapshot.json"); + assert_eq!(op_filename("Example Domain"), "Example Domain.op"); + assert_eq!(op_filename("周报 2026"), "周报 2026.op"); } #[test] fn falls_back_when_the_title_sanitises_to_nothing() { for title in ["", " ", "...", "///", "\u{0}\u{1}", "..", "."] { - assert_eq!( - snapshot_filename(title), - "page-snapshot.json", - "title {title:?}" - ); + assert_eq!(op_filename(title), "page.op", "title {title:?}"); } } @@ -33,7 +27,7 @@ fn no_title_can_produce_a_parent_directory_component() { "a/../../b", "....//....//x", ] { - let name = snapshot_filename(title); + let name = op_filename(title); assert!(!name.contains(".."), "{title:?} produced {name:?}"); assert!(!name.contains('/'), "{title:?} produced {name:?}"); assert!(!name.contains('\\'), "{title:?} produced {name:?}"); @@ -43,71 +37,56 @@ fn no_title_can_produce_a_parent_directory_component() { #[test] fn strips_path_separators_and_reserved_characters() { assert_eq!( - snapshot_filename("a/b\\c:d*e?f\"gi|j%k"), - "a b c d e f g h i j k-snapshot.json" + op_filename("a/b\\c:d*e?f\"gi|j%k"), + "a b c d e f g h i j k.op" ); } #[test] fn strips_control_characters_including_newlines_and_nul() { - assert_eq!( - snapshot_filename("a\u{0}b\nc\rd\te"), - "a b c d e-snapshot.json" - ); + assert_eq!(op_filename("a\u{0}b\nc\rd\te"), "a b c d e.op"); // A control run collapses to a single space, like the JS `+` quantifier. - assert_eq!(snapshot_filename("a\u{1}\u{2}\u{3}b"), "a b-snapshot.json"); + assert_eq!(op_filename("a\u{1}\u{2}\u{3}b"), "a b.op"); } #[test] fn never_produces_a_dot_file() { - assert_eq!(snapshot_filename(".bashrc"), "bashrc-snapshot.json"); - assert_eq!(snapshot_filename(" .hidden"), "hidden-snapshot.json"); + assert_eq!(op_filename(".bashrc"), "bashrc.op"); + assert_eq!(op_filename(" .hidden"), "hidden.op"); } #[test] fn collapses_dot_runs_but_keeps_a_single_dot() { - assert_eq!(snapshot_filename("report.v2"), "report.v2-snapshot.json"); - assert_eq!(snapshot_filename("report...v2"), "report.v2-snapshot.json"); + assert_eq!(op_filename("report.v2"), "report.v2.op"); + assert_eq!(op_filename("report...v2"), "report.v2.op"); } #[test] fn caps_the_stem_and_trims_what_the_cut_leaves_behind() { let long = "x".repeat(200); - assert_eq!( - snapshot_filename(&long), - format!("{}-snapshot.json", "x".repeat(80)) - ); + assert_eq!(op_filename(&long), format!("{}.op", "x".repeat(80))); // The 80th unit lands on a space, which Windows would silently drop. let with_space = format!("{} tail", "y".repeat(79)); - assert_eq!( - snapshot_filename(&with_space), - format!("{}-snapshot.json", "y".repeat(79)) - ); + assert_eq!(op_filename(&with_space), format!("{}.op", "y".repeat(79))); // …and on a dot. let with_dot = format!("{}.tail", "z".repeat(79)); - assert_eq!( - snapshot_filename(&with_dot), - format!("{}-snapshot.json", "z".repeat(79)) - ); + assert_eq!(op_filename(&with_dot), format!("{}.op", "z".repeat(79))); } #[test] fn measures_the_cap_in_utf16_code_units_like_js_does() { // 40 astral characters are 80 UTF-16 code units — exactly the cap. let emoji = "😀".repeat(50); - let name = snapshot_filename(&emoji); - let stem = name.trim_end_matches("-snapshot.json"); + let name = op_filename(&emoji); + let stem = name.trim_end_matches(".op"); assert_eq!(stem.chars().count(), 40); } #[test] fn collapses_whitespace_runs_including_the_byte_order_mark() { - assert_eq!(snapshot_filename("a \t\n b"), "a b-snapshot.json"); - assert_eq!( - snapshot_filename("\u{feff}title\u{feff}"), - "title-snapshot.json" - ); - assert_eq!(snapshot_filename("a\u{a0}\u{3000}b"), "a b-snapshot.json"); + assert_eq!(op_filename("a \t\n b"), "a b.op"); + assert_eq!(op_filename("\u{feff}title\u{feff}"), "title.op"); + assert_eq!(op_filename("a\u{a0}\u{3000}b"), "a b.op"); } diff --git a/crates/op-chrome-extension-core/src/lib.rs b/crates/op-chrome-extension-core/src/lib.rs index 8eb063ff6..4dd6c1733 100644 --- a/crates/op-chrome-extension-core/src/lib.rs +++ b/crates/op-chrome-extension-core/src/lib.rs @@ -26,6 +26,7 @@ mod hub_reply; mod hub_time; mod ingress; mod js_text; +mod op_export; mod transfer; mod wasm_api; @@ -46,4 +47,6 @@ mod ingress_tests; #[cfg(test)] mod js_text_tests; #[cfg(test)] +mod op_export_tests; +#[cfg(test)] mod transfer_tests; diff --git a/crates/op-chrome-extension-core/src/op_export.rs b/crates/op-chrome-extension-core/src/op_export.rs new file mode 100644 index 000000000..e20e90d25 --- /dev/null +++ b/crates/op-chrome-extension-core/src/op_export.rs @@ -0,0 +1,99 @@ +//! Snapshot → `.op` document conversion. +//! +//! The offline download path no longer hands the user the raw extractor +//! snapshot (which then had to be converted with `op import:snapshot`). It +//! produces a `.op` document — OpenPencil's canonical `PenDocument` format — +//! that opens directly by double-click or drag-into-app, still fully offline. +//! +//! This mirrors `op-cli`'s `run_import_snapshot` (`html_cli.rs`) step for step: +//! `import_snapshot_document` → refuse an empty document → serialize the +//! resulting `PenDocument` to JSON. The serialized string IS the `.op` file. +//! The page title flows through `HtmlImportOptions::document_name` so the +//! produced document carries a name. + +use jian_ops_schema::node::PenNode; +use op_html::HtmlImportOptions; + +/// Outcome of converting one snapshot into a `.op` document. +pub enum OpExport { + /// The snapshot produced at least one node. `op` is the serialized + /// `PenDocument` JSON — the exact bytes to write to a `.op` file. + Ready { + op: String, + node_count: usize, + warnings: Vec, + }, + /// The snapshot produced no importable content (empty capture, unsupported + /// version, malformed JSON). `error` mirrors the CLI's message. + Failed { error: String }, +} + +/// Convert an extractor snapshot into a `.op` document. +/// +/// `title`, when non-empty, becomes the document's name via +/// [`HtmlImportOptions::document_name`], so the `.op` file opens with a +/// meaningful title rather than an anonymous fallback. +pub fn snapshot_to_op(snapshot_json: &str, title: Option<&str>) -> OpExport { + let options = HtmlImportOptions { + document_name: title + .map(str::trim) + .filter(|title| !title.is_empty()) + .map(str::to_owned), + ..HtmlImportOptions::default() + }; + let imported = op_html::import_snapshot_document(snapshot_json, &options); + if imported.document.children.is_empty() { + // Byte-identical to the CLI's "no importable content" refusal so the + // two paths report an empty capture the same way. + let detail = imported + .warnings + .first() + .map(String::as_str) + .unwrap_or("input produced no nodes"); + return OpExport::Failed { + error: format!("no importable content: {detail}"), + }; + } + let op = match serde_json::to_value(&imported.document) { + Ok(value) => value.to_string(), + Err(error) => { + return OpExport::Failed { + error: format!("serialize .op document: {error}"), + }; + } + }; + OpExport::Ready { + op, + node_count: count_nodes(&imported.document.children), + warnings: imported.warnings, + } +} + +/// Total node count across the tree, mirroring `op-cli`'s `count_nodes`. The +/// three container variants that carry `children` recurse; every other variant +/// is a leaf. +fn count_nodes(nodes: &[PenNode]) -> usize { + nodes + .iter() + .map(|node| { + 1 + match node { + PenNode::Frame(node) => node + .children + .as_deref() + .map(count_nodes) + .unwrap_or_default(), + PenNode::Group(node) => node + .children + .as_deref() + .map(count_nodes) + .unwrap_or_default(), + PenNode::Rectangle(node) => node + .children + .as_deref() + .map(count_nodes) + .unwrap_or_default(), + _ => 0, + } + }) + .sum() +} diff --git a/crates/op-chrome-extension-core/src/op_export_tests.rs b/crates/op-chrome-extension-core/src/op_export_tests.rs new file mode 100644 index 000000000..e0140f7ba --- /dev/null +++ b/crates/op-chrome-extension-core/src/op_export_tests.rs @@ -0,0 +1,103 @@ +//! Tests for [`crate::op_export`] — turning an extractor snapshot into a +//! ready-to-open `.op` (`PenDocument`) document. Exercised natively; the wasm +//! boundary is a thin type conversion over this. + +use crate::op_export::{snapshot_to_op, OpExport}; +use jian_ops_schema::document::PenDocument; + +/// A minimal but real v1 snapshot: a body root, one child box, and text. +const SAMPLE: &str = r#"{ + "version": 1, + "source": "https://example.com/page", + "title": "My Test Page", + "viewport": { "width": 1440, "height": 900 }, + "root": { + "kind": "element", + "tag": "body", + "rect": { "x": 0, "y": 0, "w": 1440, "h": 600 }, + "styles": { "background-color": "rgb(255, 255, 255)" }, + "children": [ + { + "kind": "element", + "tag": "div", + "rect": { "x": 24, "y": 24, "w": 300, "h": 80 }, + "styles": { + "background-color": "rgba(16, 32, 48, 1)", + "border-radius": "8px" + }, + "children": [ + { + "kind": "text", + "rect": { "x": 40, "y": 48, "w": 120, "h": 24 }, + "text": "Hello world", + "styles": { + "color": "rgb(255, 255, 255)", + "font-family": "Inter, sans-serif", + "font-size": "16px", + "font-weight": "700", + "line-height": "24px" + } + } + ] + } + ] + } +}"#; + +#[test] +fn converts_a_snapshot_into_a_valid_op_document() { + let OpExport::Ready { + op, + node_count, + warnings, + } = snapshot_to_op(SAMPLE, Some("Ignored By Snapshot Path")) + else { + panic!("a non-empty snapshot must convert"); + }; + + // The `op` string is the exact `.op` file contents: it must round-trip + // back into the canonical schema, which is what makes it double-click open. + let document: PenDocument = serde_json::from_str(&op).expect(".op must be valid PenDocument"); + + // The snapshot path names the document from the snapshot's own `title` + // field (the extractor fills it with the page title). + assert_eq!(document.name.as_deref(), Some("My Test Page")); + assert!(!document.children.is_empty(), "document must carry nodes"); + + // body frame + child box + its text = 3 nodes. + assert_eq!(node_count, 3); + assert!(warnings.is_empty(), "a clean capture warns about nothing"); +} + +#[test] +fn passes_a_non_blank_title_through_as_document_name() { + // A snapshot with no `title` of its own falls back to the importer's + // "Web Snapshot" default; the title argument is threaded through + // `HtmlImportOptions::document_name` regardless (forward-compatible). + let no_title = SAMPLE.replace("\"title\": \"My Test Page\",", ""); + let OpExport::Ready { op, .. } = snapshot_to_op(&no_title, Some(" My Tab Title ")) else { + panic!("snapshot without a title must still convert"); + }; + let document: PenDocument = serde_json::from_str(&op).expect(".op must be valid"); + assert_eq!(document.name.as_deref(), Some("Web Snapshot")); +} + +#[test] +fn refuses_malformed_json_with_an_actionable_error() { + let OpExport::Failed { error } = snapshot_to_op("not json at all", Some("x")) else { + panic!("malformed JSON must be refused, not silently downloaded"); + }; + assert!( + error.starts_with("no importable content:"), + "unexpected error: {error}" + ); +} + +#[test] +fn refuses_an_unsupported_snapshot_version() { + let bumped = SAMPLE.replace("\"version\": 1", "\"version\": 99"); + let OpExport::Failed { error } = snapshot_to_op(&bumped, None) else { + panic!("an unsupported version must be refused"); + }; + assert!(error.starts_with("no importable content:")); +} diff --git a/crates/op-chrome-extension-core/src/wasm_api.rs b/crates/op-chrome-extension-core/src/wasm_api.rs index 88169c205..0f403806b 100644 --- a/crates/op-chrome-extension-core/src/wasm_api.rs +++ b/crates/op-chrome-extension-core/src/wasm_api.rs @@ -19,6 +19,7 @@ use crate::filename; use crate::hub; use crate::hub_reply::{self, CreateReply}; use crate::ingress::{self, Reply}; +use crate::op_export::{self, OpExport}; use crate::transfer; /// Endpoint the popup pre-fills when the user has none stored. @@ -62,10 +63,42 @@ pub fn snapshot_placeholder() -> String { ingress::SNAPSHOT_PLACEHOLDER.to_owned() } -/// Download file name for a page titled `title`. -#[wasm_bindgen(js_name = snapshotFilename)] -pub fn snapshot_filename(title: &str) -> String { - filename::snapshot_filename(title) +/// Download file name for the `.op` document of a page titled `title`. +#[wasm_bindgen(js_name = opFilename)] +pub fn op_filename(title: &str) -> String { + filename::op_filename(title) +} + +/// Convert an extractor snapshot into a ready-to-open `.op` document. +/// +/// Returns a small JSON document the popup glue parses with `JSON.parse`: +/// +/// * `{"ok":true,"op":"","nodeCount":N,"warnings":[…]}` — +/// `op` is the exact text to write to a `.op` file. +/// * `{"ok":false,"error":"…"}` — the capture produced no importable content +/// (empty page, unsupported snapshot, malformed JSON); the caller must show +/// an actionable error instead of downloading a broken file. +/// +/// `title`, when present and non-blank, names the produced document. +#[wasm_bindgen(js_name = snapshotToOpDocument)] +pub fn snapshot_to_op_document(snapshot_json: &str, title: Option) -> String { + let value = match op_export::snapshot_to_op(snapshot_json, title.as_deref()) { + OpExport::Ready { + op, + node_count, + warnings, + } => serde_json::json!({ + "ok": true, + "op": op, + "nodeCount": node_count, + "warnings": warnings, + }), + OpExport::Failed { error } => serde_json::json!({ + "ok": false, + "error": error, + }), + }; + value.to_string() } /// Milliseconds before an in-flight request is aborted. diff --git a/packages/op-chrome-extension/README.md b/packages/op-chrome-extension/README.md index 0eef1bd9c..0a09838f2 100644 --- a/packages/op-chrome-extension/README.md +++ b/packages/op-chrome-extension/README.md @@ -139,7 +139,7 @@ would be a remote request)". A `data:` URI cannot execute under this policy; | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | **Capture full page** | Captures the whole page and sends it to the OpenPencil instance at the endpoint under **OpenPencil endpoint** (default `127.0.0.1:3100`). | | **Capture element** | Lets you point at one element and captures only its subtree. See below. | -| **Download JSON** | Captures and saves `-snapshot.json` instead of sending it. | +| **Download .op** | Captures and saves a ready-to-open `.op` document instead of sending it — double-click it to open in OpenPencil. | The status area reports node counts, importer warnings, and whether the page hit the extractor's 20,000-node cap. The endpoint setting is collapsed by default — @@ -165,7 +165,7 @@ result comes back two ways: naming the element and what happened to it. The capture is delivered the same way as **whichever of "Capture full page" or -"Download JSON" you used last** — the choice is remembered in +"Download .op" you used last** — the choice is remembered in `chrome.storage.local` under `lastAction`, and defaults to sending. There is no fourth button and no extra prompt: the element pick reuses a decision you have already made. @@ -226,7 +226,7 @@ reverse, or a manifest `__MSG_*` key that some locale cannot resolve. Which one the **Capture** button uses is decided by the `delivery.rs` rule: path 1 unless you are signed in AND have chosen your account under **Send to**, -in which case path 3. `Download JSON` is always path 2. +in which case path 3. `Download .op` is always path 2. ### 1. Send to a running OpenPencil (the button) @@ -277,16 +277,17 @@ OPENPENCIL_EXTENSION_ALLOWED_IDS=abcdefghijklmnopabcdefghijklmnop openpencil-des other extension origin is refused. Your unpacked extension's id is on its card in `chrome://extensions`. -### 2. Download and import by hand +### 2. Download and open by hand -`Download JSON` writes the raw snapshot. Import it with either: +`Download .op` writes a ready-to-open `.op` document (OpenPencil's `PenDocument` +format — the same conversion `op import:snapshot` performs, run in the browser). +Open it directly: -```bash -op import:snapshot ~/Downloads/example-snapshot.json # into the running editor -op import:snapshot ~/Downloads/example-snapshot.json --out page.op # into a new file -``` +- double-click the `.op` file, or +- drag it onto the app window, or +- `op open ~/Downloads/example.op`. -or drag the file onto the app. +No CLI conversion step is needed — the file is already a `.op` document. ### 3. Send to your OpenPencil account @@ -371,7 +372,7 @@ this destination: | Ceiling | Value | What you see | | ---------------------- | ------------------------------ | ------------------------------------------------------------------------- | -| Per capture | 32 MB, same as the local route | Refused before the upload starts, with the `Download JSON` advice. | +| Per capture | 32 MB, same as the local route | Refused before the upload starts, with the `Download .op` advice. | | Per account | 50 snapshots or 200 MB | "Your account inbox is full", naming both ceilings. | | Per hour | 20 uploads | "Try again in about N minutes", from the hub's own `Retry-After`. | | Retention | 30 days | An unclaimed capture is deleted; the inbox is not storage. | @@ -428,7 +429,7 @@ required. It is the same mechanism the loopback import path relies on.) | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `activeTab` | Run the extractor in the tab you clicked the button on — granted per click, no standing access to your browsing. | | `scripting` | The injection API itself, for the extractor, the transfer harness and the picker overlay. | -| `downloads` | The `Download JSON` fallback. | +| `downloads` | The `Download .op` fallback. | | `storage` | Remembers the endpoint you typed, the delivery you last used, one pending pick result, and the account row. | | `host_permissions: http://127.0.0.1/*` | POST the snapshot to your local OpenPencil. | | `host_permissions: https://op.zseven.cn/*` | Ask the China hub who is signed in, and open its sign-in page. Used only if you sign in. | @@ -587,7 +588,7 @@ either police three identical files or exempt them. The switcher still offers | Privacy policy URL | Publish [`docs/privacy-policy.md`](docs/privacy-policy.md) and link it. It is written to be published verbatim. | | Single purpose | "Capture the rendered state of a web page and import it into the user's OpenPencil as editable design nodes." | | `activeTab` + `scripting` | "Runs the OpenPencil DOM extractor in the tab the user pressed the button on." | -| `downloads` | "Saves the capture as a JSON file when the user chooses Download JSON." | +| `downloads` | "Saves the capture as a ready-to-open .op document when the user chooses Download .op." | | `storage` | "Stores the user's endpoint, language, region, chosen destination and account display name locally." | | `http://127.0.0.1/*` | "Delivers the capture to the OpenPencil application running on the user's own computer." | | The two `op.zseven.*` | "Reads the signed-in user's own account profile from the OpenPencil Hub, and uploads a capture to that same account when the user selects it as the destination. Optional; the extension is fully functional signed out." | @@ -631,15 +632,15 @@ or run `openpencil-desktop --serve-web 3100`. Check the port with **"This OpenPencil build has no extension ingress"** — the app is listening but predates `POST /api/import/web-snapshot`, and its general `/mcp` surface refuses -browser-extension origins by design. Update the app, or use `Download JSON` + -`op import:snapshot`. You will also see this if the editor was started with +browser-extension origins by design. Update the app, or use `Download .op` and +open the file in OpenPencil. You will also see this if the editor was started with `OPENPENCIL_EXTENSION_ALLOWED_IDS` set to a list that does not include this extension's id. **"This capture is larger than 32 MB"** — the snapshot route caps its body at 32 MB (it is the one ingress that needs no token, so it does not get to make the -editor buffer an arbitrary amount). Use `Download JSON` + `op import:snapshot`, -which has no such limit. +editor buffer an arbitrary amount). Use `Download .op` and open the file in +OpenPencil, which has no such limit. **"… did not answer within 15s"** — the connection was accepted but the reply never came. The editor is busy or stuck (a modal dialog blocking its UI thread @@ -676,7 +677,7 @@ it is what clears the badge. the worker statically imports the built core, so it does not register until `scripts/build-wasm.sh` has run. Run it, then hit **Reload** on the extension card. `chrome://extensions` shows the worker's own error next to the card. -**Capture full page** and **Download JSON** are unaffected; they run in the +**Capture full page** and **Download .op** are unaffected; they run in the popup. **"The extension is built, but its logic core would not start"** — distinct diff --git a/packages/op-chrome-extension/_locales/de/messages.json b/packages/op-chrome-extension/_locales/de/messages.json index 25943f06f..1b448c445 100644 --- a/packages/op-chrome-extension/_locales/de/messages.json +++ b/packages/op-chrome-extension/_locales/de/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "Ganze Seite erfassen" }, "pickButton": { "message": "Element erfassen" }, - "downloadButton": { "message": "JSON herunterladen" }, + "downloadButton": { "message": ".op herunterladen" }, "pickHint": { "message": "Klicke auf ein Element, um es zu erfassen" }, "pickCancelHint": { "message": "Esc zum Abbrechen" }, "endpointLabel": { "message": "OpenPencil-Adresse" }, @@ -56,10 +56,10 @@ "message": "OpenPencil unter $1 hat die Verbindung angenommen, aber innerhalb von $2 s nicht geantwortet. Die App ist möglicherweise beschäftigt oder hängt — prüfe sie und versuche es erneut." }, "errorNoIngress": { - "message": "Dieser OpenPencil-Build hat keinen Eingang für Erweiterungen ($1). Aktualisiere die App oder nutze „JSON herunterladen“ und führe aus: op import:snapshot " + "message": "Dieser OpenPencil-Build hat keinen Eingang für Erweiterungen ($1). Aktualisiere die App oder nutze „.op herunterladen“ und öffne die Datei in OpenPencil" }, "errorTooLarge": { - "message": "Diese Erfassung ist größer als $1 MB und wird von OpenPencils Momentaufnahme-Route abgelehnt. Nutze „JSON herunterladen“ und führe dann aus: op import:snapshot " + "message": "Diese Erfassung ist größer als $1 MB und wird von OpenPencils Momentaufnahme-Route abgelehnt. Nutze „.op herunterladen“ und öffne dann die Datei in OpenPencil" }, "errorImport": { "message": "OpenPencil hat die Momentaufnahme abgelehnt: $1" }, "errorDownload": { "message": "Download fehlgeschlagen: $1" }, diff --git a/packages/op-chrome-extension/_locales/en/messages.json b/packages/op-chrome-extension/_locales/en/messages.json index f237908ba..945706b9b 100644 --- a/packages/op-chrome-extension/_locales/en/messages.json +++ b/packages/op-chrome-extension/_locales/en/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "Capture full page" }, "pickButton": { "message": "Capture element" }, - "downloadButton": { "message": "Download JSON" }, + "downloadButton": { "message": "Download .op" }, "pickHint": { "message": "Click an element to capture it" }, "pickCancelHint": { "message": "Esc to cancel" }, "endpointLabel": { "message": "OpenPencil endpoint" }, @@ -54,10 +54,10 @@ "message": "OpenPencil at $1 accepted the connection but did not answer within $2s. It may be busy or stuck — check the app, then try again." }, "errorNoIngress": { - "message": "This OpenPencil build has no extension ingress ($1). Update the app, or use Download JSON and run: op import:snapshot " + "message": "This OpenPencil build has no extension ingress ($1). Update the app, or use Download .op and open the file in OpenPencil." }, "errorTooLarge": { - "message": "This capture is larger than $1 MB, which OpenPencil's snapshot route refuses. Use Download JSON, then run: op import:snapshot " + "message": "This capture is larger than $1 MB, which OpenPencil's snapshot route refuses. Use Download .op, then open the file in OpenPencil." }, "errorImport": { "message": "OpenPencil refused the snapshot: $1" }, "errorDownload": { "message": "Download failed: $1" }, diff --git a/packages/op-chrome-extension/_locales/es/messages.json b/packages/op-chrome-extension/_locales/es/messages.json index d2bcaeb01..8287615e0 100644 --- a/packages/op-chrome-extension/_locales/es/messages.json +++ b/packages/op-chrome-extension/_locales/es/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "Capturar página completa" }, "pickButton": { "message": "Capturar elemento" }, - "downloadButton": { "message": "Descargar JSON" }, + "downloadButton": { "message": "Descargar .op" }, "pickHint": { "message": "Haz clic en un elemento para capturarlo" }, "pickCancelHint": { "message": "Esc para cancelar" }, "endpointLabel": { "message": "Dirección de OpenPencil" }, @@ -54,10 +54,10 @@ "message": "OpenPencil en $1 aceptó la conexión pero no respondió en $2 s. Puede estar ocupado o bloqueado: revisa la aplicación y vuelve a intentarlo." }, "errorNoIngress": { - "message": "Esta versión de OpenPencil no tiene entrada para extensiones ($1). Actualiza la aplicación o usa «Descargar JSON» y ejecuta: op import:snapshot " + "message": "Esta versión de OpenPencil no tiene entrada para extensiones ($1). Actualiza la aplicación o usa «Descargar .op» y abre el archivo en OpenPencil" }, "errorTooLarge": { - "message": "Esta captura supera $1 MB, y la ruta de capturas de OpenPencil la rechaza. Usa «Descargar JSON» y luego ejecuta: op import:snapshot " + "message": "Esta captura supera $1 MB, y la ruta de capturas de OpenPencil la rechaza. Usa «Descargar .op» y luego abre el archivo en OpenPencil" }, "errorImport": { "message": "OpenPencil rechazó la captura: $1" }, "errorDownload": { "message": "Error al descargar: $1" }, diff --git a/packages/op-chrome-extension/_locales/fr/messages.json b/packages/op-chrome-extension/_locales/fr/messages.json index a50a8d8a0..f5ee1f649 100644 --- a/packages/op-chrome-extension/_locales/fr/messages.json +++ b/packages/op-chrome-extension/_locales/fr/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "Capturer la page entière" }, "pickButton": { "message": "Capturer un élément" }, - "downloadButton": { "message": "Télécharger le JSON" }, + "downloadButton": { "message": "Télécharger le .op" }, "pickHint": { "message": "Cliquez sur un élément pour le capturer" }, "pickCancelHint": { "message": "Échap pour annuler" }, "endpointLabel": { "message": "Adresse OpenPencil" }, @@ -56,10 +56,10 @@ "message": "OpenPencil à l’adresse $1 a accepté la connexion mais n’a pas répondu en $2 s. L’application est peut-être occupée ou bloquée — vérifiez-la, puis réessayez." }, "errorNoIngress": { - "message": "Cette version d’OpenPencil ne dispose d’aucun point d’entrée pour les extensions ($1). Mettez l’application à jour, ou utilisez « Télécharger le JSON » puis exécutez : op import:snapshot " + "message": "Cette version d’OpenPencil ne dispose d’aucun point d’entrée pour les extensions ($1). Mettez l’application à jour, ou utilisez « Télécharger le .op » puis ouvrez le fichier dans OpenPencil" }, "errorTooLarge": { - "message": "Cette capture dépasse $1 Mo, ce que la route de capture d’OpenPencil refuse. Utilisez « Télécharger le JSON », puis exécutez : op import:snapshot " + "message": "Cette capture dépasse $1 Mo, ce que la route de capture d’OpenPencil refuse. Utilisez « Télécharger le .op », puis ouvrez le fichier dans OpenPencil" }, "errorImport": { "message": "OpenPencil a refusé la capture : $1" }, "errorDownload": { "message": "Échec du téléchargement : $1" }, diff --git a/packages/op-chrome-extension/_locales/hi/messages.json b/packages/op-chrome-extension/_locales/hi/messages.json index f2c77604b..527de8d9b 100644 --- a/packages/op-chrome-extension/_locales/hi/messages.json +++ b/packages/op-chrome-extension/_locales/hi/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "पूरा पेज कैप्चर करें" }, "pickButton": { "message": "एलिमेंट कैप्चर करें" }, - "downloadButton": { "message": "JSON डाउनलोड करें" }, + "downloadButton": { "message": ".op डाउनलोड करें" }, "pickHint": { "message": "कैप्चर करने के लिए किसी एलिमेंट पर क्लिक करें" }, "pickCancelHint": { "message": "रद्द करने के लिए Esc दबाएँ" }, "endpointLabel": { "message": "OpenPencil का पता" }, @@ -54,10 +54,10 @@ "message": "$1 पर OpenPencil ने कनेक्शन स्वीकार किया, लेकिन $2 सेकंड में जवाब नहीं दिया। ऐप व्यस्त या अटका हो सकता है — उसे देखें, फिर दोबारा कोशिश करें।" }, "errorNoIngress": { - "message": "OpenPencil के इस बिल्ड में एक्सटेंशन के लिए कोई प्रवेश-मार्ग नहीं है ($1)। ऐप अपडेट करें, या “JSON डाउनलोड करें” का उपयोग करके यह चलाएँ: op import:snapshot <फ़ाइल>" + "message": "OpenPencil के इस बिल्ड में एक्सटेंशन के लिए कोई प्रवेश-मार्ग नहीं है ($1)। ऐप अपडेट करें, या “.op डाउनलोड करें” का उपयोग करके फ़ाइल को OpenPencil में खोलें" }, "errorTooLarge": { - "message": "यह कैप्चर $1 MB से बड़ा है, जिसे OpenPencil का स्नैपशॉट मार्ग स्वीकार नहीं करता। “JSON डाउनलोड करें” का उपयोग करें, फिर यह चलाएँ: op import:snapshot <फ़ाइल>" + "message": "यह कैप्चर $1 MB से बड़ा है, जिसे OpenPencil का स्नैपशॉट मार्ग स्वीकार नहीं करता। “.op डाउनलोड करें” का उपयोग करें, फिर फ़ाइल को OpenPencil में खोलें" }, "errorImport": { "message": "OpenPencil ने स्नैपशॉट अस्वीकार कर दिया: $1" }, "errorDownload": { "message": "डाउनलोड विफल: $1" }, diff --git a/packages/op-chrome-extension/_locales/id/messages.json b/packages/op-chrome-extension/_locales/id/messages.json index 633fbfc70..9f156e232 100644 --- a/packages/op-chrome-extension/_locales/id/messages.json +++ b/packages/op-chrome-extension/_locales/id/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "Tangkap seluruh halaman" }, "pickButton": { "message": "Tangkap elemen" }, - "downloadButton": { "message": "Unduh JSON" }, + "downloadButton": { "message": "Unduh .op" }, "pickHint": { "message": "Klik elemen yang ingin ditangkap" }, "pickCancelHint": { "message": "Esc untuk membatalkan" }, "endpointLabel": { "message": "Alamat OpenPencil" }, @@ -54,10 +54,10 @@ "message": "OpenPencil di $1 menerima koneksi tetapi tidak menjawab dalam $2 detik. Aplikasi mungkin sibuk atau macet — periksa aplikasinya, lalu coba lagi." }, "errorNoIngress": { - "message": "Versi OpenPencil ini tidak memiliki jalur masuk untuk ekstensi ($1). Perbarui aplikasi, atau gunakan “Unduh JSON” lalu jalankan: op import:snapshot " + "message": "Versi OpenPencil ini tidak memiliki jalur masuk untuk ekstensi ($1). Perbarui aplikasi, atau gunakan “Unduh .op” lalu buka berkas di OpenPencil" }, "errorTooLarge": { - "message": "Tangkapan ini lebih besar dari $1 MB, dan rute cuplikan OpenPencil menolaknya. Gunakan “Unduh JSON”, lalu jalankan: op import:snapshot " + "message": "Tangkapan ini lebih besar dari $1 MB, dan rute cuplikan OpenPencil menolaknya. Gunakan “Unduh .op”, lalu buka berkas di OpenPencil" }, "errorImport": { "message": "OpenPencil menolak cuplikan: $1" }, "errorDownload": { "message": "Unduhan gagal: $1" }, diff --git a/packages/op-chrome-extension/_locales/ja/messages.json b/packages/op-chrome-extension/_locales/ja/messages.json index 717a08b8a..8ab9c56ca 100644 --- a/packages/op-chrome-extension/_locales/ja/messages.json +++ b/packages/op-chrome-extension/_locales/ja/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "ページ全体をキャプチャ" }, "pickButton": { "message": "要素をキャプチャ" }, - "downloadButton": { "message": "JSON をダウンロード" }, + "downloadButton": { "message": ".op をダウンロード" }, "pickHint": { "message": "キャプチャする要素をクリックしてください" }, "pickCancelHint": { "message": "Esc でキャンセル" }, "endpointLabel": { "message": "OpenPencil の接続先" }, @@ -46,10 +46,10 @@ "message": "$1 は接続を受け付けましたが、$2 秒以内に応答しませんでした。処理中か停止している可能性があります。アプリを確認してからもう一度お試しください。" }, "errorNoIngress": { - "message": "この OpenPencil ビルドには拡張機能用の受け口がありません($1)。アプリを更新するか、「JSON をダウンロード」を使って次を実行してください: op import:snapshot <ファイル>" + "message": "この OpenPencil ビルドには拡張機能用の受け口がありません($1)。アプリを更新するか、「.op をダウンロード」してそのファイルを OpenPencil で開いてください" }, "errorTooLarge": { - "message": "このキャプチャは $1 MB を超えており、OpenPencil のスナップショット受け口が受け付けません。「JSON をダウンロード」を使ってから次を実行してください: op import:snapshot <ファイル>" + "message": "このキャプチャは $1 MB を超えており、OpenPencil のスナップショット受け口が受け付けません。「.op をダウンロード」してから、そのファイルを OpenPencil で開いてください" }, "errorImport": { "message": "OpenPencil がスナップショットを拒否しました: $1" }, "errorDownload": { "message": "ダウンロードに失敗しました: $1" }, diff --git a/packages/op-chrome-extension/_locales/ko/messages.json b/packages/op-chrome-extension/_locales/ko/messages.json index 307f94588..74ab5e7a9 100644 --- a/packages/op-chrome-extension/_locales/ko/messages.json +++ b/packages/op-chrome-extension/_locales/ko/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "전체 페이지 캡처" }, "pickButton": { "message": "요소 캡처" }, - "downloadButton": { "message": "JSON 다운로드" }, + "downloadButton": { "message": ".op 다운로드" }, "pickHint": { "message": "캡처할 요소를 클릭하세요" }, "pickCancelHint": { "message": "Esc로 취소" }, "endpointLabel": { "message": "OpenPencil 주소" }, @@ -44,10 +44,10 @@ "message": "$1 서버가 연결은 수락했지만 $2초 안에 응답하지 않았습니다. 앱이 바쁘거나 멈췄을 수 있으니 확인 후 다시 시도하세요." }, "errorNoIngress": { - "message": "이 OpenPencil 빌드에는 확장 프로그램 수신 경로가 없습니다($1). 앱을 업데이트하거나 [JSON 다운로드]를 사용한 뒤 다음을 실행하세요: op import:snapshot <파일>" + "message": "이 OpenPencil 빌드에는 확장 프로그램 수신 경로가 없습니다($1). 앱을 업데이트하거나 [.op 다운로드]를 사용한 뒤 해당 파일을 OpenPencil에서 여세요" }, "errorTooLarge": { - "message": "이번 캡처는 $1 MB를 초과하여 OpenPencil의 스냅샷 경로가 거부합니다. [JSON 다운로드]를 사용한 뒤 다음을 실행하세요: op import:snapshot <파일>" + "message": "이번 캡처는 $1 MB를 초과하여 OpenPencil의 스냅샷 경로가 거부합니다. [.op 다운로드]를 사용한 뒤 해당 파일을 OpenPencil에서 여세요" }, "errorImport": { "message": "OpenPencil이 스냅샷을 거부했습니다: $1" }, "errorDownload": { "message": "다운로드에 실패했습니다: $1" }, diff --git a/packages/op-chrome-extension/_locales/pt/messages.json b/packages/op-chrome-extension/_locales/pt/messages.json index d18db6978..45a9b00f6 100644 --- a/packages/op-chrome-extension/_locales/pt/messages.json +++ b/packages/op-chrome-extension/_locales/pt/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "Capturar página inteira" }, "pickButton": { "message": "Capturar elemento" }, - "downloadButton": { "message": "Baixar JSON" }, + "downloadButton": { "message": "Baixar .op" }, "pickHint": { "message": "Clique em um elemento para capturá-lo" }, "pickCancelHint": { "message": "Esc para cancelar" }, "endpointLabel": { "message": "Endereço do OpenPencil" }, @@ -54,10 +54,10 @@ "message": "O OpenPencil em $1 aceitou a conexão, mas não respondeu em $2 s. Ele pode estar ocupado ou travado — verifique o aplicativo e tente novamente." }, "errorNoIngress": { - "message": "Esta versão do OpenPencil não tem entrada para extensões ($1). Atualize o aplicativo ou use “Baixar JSON” e execute: op import:snapshot " + "message": "Esta versão do OpenPencil não tem entrada para extensões ($1). Atualize o aplicativo ou use “Baixar .op” e abra o arquivo no OpenPencil" }, "errorTooLarge": { - "message": "Esta captura é maior que $1 MB, e a rota de capturas do OpenPencil a recusa. Use “Baixar JSON” e execute: op import:snapshot " + "message": "Esta captura é maior que $1 MB, e a rota de capturas do OpenPencil a recusa. Use “Baixar .op” e abra o arquivo no OpenPencil" }, "errorImport": { "message": "O OpenPencil recusou a captura: $1" }, "errorDownload": { "message": "Falha no download: $1" }, diff --git a/packages/op-chrome-extension/_locales/ru/messages.json b/packages/op-chrome-extension/_locales/ru/messages.json index 481c878b7..936e910fd 100644 --- a/packages/op-chrome-extension/_locales/ru/messages.json +++ b/packages/op-chrome-extension/_locales/ru/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "Захватить всю страницу" }, "pickButton": { "message": "Захватить элемент" }, - "downloadButton": { "message": "Скачать JSON" }, + "downloadButton": { "message": "Скачать .op" }, "pickHint": { "message": "Нажмите на элемент, чтобы захватить его" }, "pickCancelHint": { "message": "Esc — отмена" }, "endpointLabel": { "message": "Адрес OpenPencil" }, @@ -54,10 +54,10 @@ "message": "OpenPencil по адресу $1 принял соединение, но не ответил за $2 с. Приложение может быть занято или зависло — проверьте его и повторите попытку." }, "errorNoIngress": { - "message": "В этой сборке OpenPencil нет точки входа для расширений ($1). Обновите приложение либо используйте «Скачать JSON» и выполните: op import:snapshot <файл>" + "message": "В этой сборке OpenPencil нет точки входа для расширений ($1). Обновите приложение либо используйте «Скачать .op» и откройте файл в OpenPencil" }, "errorTooLarge": { - "message": "Этот снимок больше $1 МБ, и маршрут снимков OpenPencil его отклоняет. Используйте «Скачать JSON», затем выполните: op import:snapshot <файл>" + "message": "Этот снимок больше $1 МБ, и маршрут снимков OpenPencil его отклоняет. Используйте «Скачать .op», затем откройте файл в OpenPencil" }, "errorImport": { "message": "OpenPencil отклонил снимок: $1" }, "errorDownload": { "message": "Не удалось скачать: $1" }, diff --git a/packages/op-chrome-extension/_locales/th/messages.json b/packages/op-chrome-extension/_locales/th/messages.json index 29418d57a..a3d19938e 100644 --- a/packages/op-chrome-extension/_locales/th/messages.json +++ b/packages/op-chrome-extension/_locales/th/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "จับภาพทั้งหน้า" }, "pickButton": { "message": "จับภาพเอลิเมนต์" }, - "downloadButton": { "message": "ดาวน์โหลด JSON" }, + "downloadButton": { "message": "ดาวน์โหลด .op" }, "pickHint": { "message": "คลิกเอลิเมนต์ที่ต้องการจับภาพ" }, "pickCancelHint": { "message": "กด Esc เพื่อยกเลิก" }, "endpointLabel": { "message": "ที่อยู่ OpenPencil" }, @@ -54,10 +54,10 @@ "message": "OpenPencil ที่ $1 รับการเชื่อมต่อแล้ว แต่ไม่ตอบกลับภายใน $2 วินาที แอปอาจกำลังทำงานหนักหรือค้างอยู่ — กรุณาตรวจสอบแล้วลองใหม่" }, "errorNoIngress": { - "message": "OpenPencil รุ่นนี้ไม่มีช่องทางรับข้อมูลจากส่วนขยาย ($1) กรุณาอัปเดตแอป หรือใช้ “ดาวน์โหลด JSON” แล้วรันคำสั่ง: op import:snapshot <ไฟล์>" + "message": "OpenPencil รุ่นนี้ไม่มีช่องทางรับข้อมูลจากส่วนขยาย ($1) กรุณาอัปเดตแอป หรือใช้ “ดาวน์โหลด .op” แล้วเปิดไฟล์ใน OpenPencil" }, "errorTooLarge": { - "message": "การจับภาพครั้งนี้ใหญ่กว่า $1 MB ซึ่งเส้นทางสแนปช็อตของ OpenPencil ปฏิเสธ กรุณาใช้ “ดาวน์โหลด JSON” แล้วรันคำสั่ง: op import:snapshot <ไฟล์>" + "message": "การจับภาพครั้งนี้ใหญ่กว่า $1 MB ซึ่งเส้นทางสแนปช็อตของ OpenPencil ปฏิเสธ กรุณาใช้ “ดาวน์โหลด .op” แล้วเปิดไฟล์ใน OpenPencil" }, "errorImport": { "message": "OpenPencil ปฏิเสธสแนปช็อตนี้: $1" }, "errorDownload": { "message": "ดาวน์โหลดไม่สำเร็จ: $1" }, diff --git a/packages/op-chrome-extension/_locales/tr/messages.json b/packages/op-chrome-extension/_locales/tr/messages.json index 682ebd340..48d68d4ac 100644 --- a/packages/op-chrome-extension/_locales/tr/messages.json +++ b/packages/op-chrome-extension/_locales/tr/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "Tüm sayfayı yakala" }, "pickButton": { "message": "Öğe yakala" }, - "downloadButton": { "message": "JSON indir" }, + "downloadButton": { "message": ".op indir" }, "pickHint": { "message": "Yakalamak için bir öğeye tıklayın" }, "pickCancelHint": { "message": "İptal için Esc" }, "endpointLabel": { "message": "OpenPencil adresi" }, @@ -54,10 +54,10 @@ "message": "$1 adresindeki OpenPencil bağlantıyı kabul etti ama $2 saniye içinde yanıt vermedi. Uygulama meşgul ya da takılmış olabilir — kontrol edip yeniden deneyin." }, "errorNoIngress": { - "message": "Bu OpenPencil sürümünde uzantı girişi yok ($1). Uygulamayı güncelleyin veya “JSON indir” ile kaydedip şunu çalıştırın: op import:snapshot " + "message": "Bu OpenPencil sürümünde uzantı girişi yok ($1). Uygulamayı güncelleyin veya “.op indir” ile kaydedip dosyayı OpenPencil’da açın" }, "errorTooLarge": { - "message": "Bu yakalama $1 MB’tan büyük ve OpenPencil’ın anlık görüntü yolu bunu reddediyor. “JSON indir” kullanın, ardından şunu çalıştırın: op import:snapshot " + "message": "Bu yakalama $1 MB’tan büyük ve OpenPencil’ın anlık görüntü yolu bunu reddediyor. “.op indir” kullanın, ardından dosyayı OpenPencil’da açın" }, "errorImport": { "message": "OpenPencil anlık görüntüyü reddetti: $1" }, "errorDownload": { "message": "İndirme başarısız: $1" }, diff --git a/packages/op-chrome-extension/_locales/vi/messages.json b/packages/op-chrome-extension/_locales/vi/messages.json index 0a97470fe..4fcef2dff 100644 --- a/packages/op-chrome-extension/_locales/vi/messages.json +++ b/packages/op-chrome-extension/_locales/vi/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "Chụp toàn trang" }, "pickButton": { "message": "Chụp phần tử" }, - "downloadButton": { "message": "Tải JSON" }, + "downloadButton": { "message": "Tải .op" }, "pickHint": { "message": "Nhấp vào phần tử cần chụp" }, "pickCancelHint": { "message": "Nhấn Esc để huỷ" }, "endpointLabel": { "message": "Địa chỉ OpenPencil" }, @@ -54,10 +54,10 @@ "message": "OpenPencil tại $1 đã chấp nhận kết nối nhưng không phản hồi trong $2 giây. Ứng dụng có thể đang bận hoặc bị treo — hãy kiểm tra rồi thử lại." }, "errorNoIngress": { - "message": "Bản dựng OpenPencil này không có lối vào cho tiện ích ($1). Hãy cập nhật ứng dụng, hoặc dùng “Tải JSON” rồi chạy: op import:snapshot " + "message": "Bản dựng OpenPencil này không có lối vào cho tiện ích ($1). Hãy cập nhật ứng dụng, hoặc dùng “Tải .op” rồi mở tệp trong OpenPencil" }, "errorTooLarge": { - "message": "Ảnh chụp này lớn hơn $1 MB, vượt mức mà tuyến ảnh chụp của OpenPencil chấp nhận. Hãy dùng “Tải JSON”, rồi chạy: op import:snapshot " + "message": "Ảnh chụp này lớn hơn $1 MB, vượt mức mà tuyến ảnh chụp của OpenPencil chấp nhận. Hãy dùng “Tải .op”, rồi mở tệp trong OpenPencil" }, "errorImport": { "message": "OpenPencil đã từ chối ảnh chụp: $1" }, "errorDownload": { "message": "Tải xuống thất bại: $1" }, diff --git a/packages/op-chrome-extension/_locales/zh_CN/messages.json b/packages/op-chrome-extension/_locales/zh_CN/messages.json index 184c4b5d1..e20a4d4fe 100644 --- a/packages/op-chrome-extension/_locales/zh_CN/messages.json +++ b/packages/op-chrome-extension/_locales/zh_CN/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "捕获整页" }, "pickButton": { "message": "选中捕获" }, - "downloadButton": { "message": "下载 JSON" }, + "downloadButton": { "message": "下载 .op" }, "pickHint": { "message": "点击要捕获的元素" }, "pickCancelHint": { "message": "按 Esc 取消" }, "endpointLabel": { "message": "OpenPencil 地址" }, @@ -38,10 +38,10 @@ }, "errorTimeout": { "message": "$1 已建立连接,但 $2 秒内没有响应。应用可能正忙或已卡住,请检查后重试。" }, "errorNoIngress": { - "message": "当前 OpenPencil 版本没有扩展入口($1)。请升级应用,或使用「下载 JSON」后运行:op import:snapshot <文件>" + "message": "当前 OpenPencil 版本没有扩展入口($1)。请升级应用,或使用「下载 .op」后在 OpenPencil 中打开该文件" }, "errorTooLarge": { - "message": "本次捕获超过 $1 MB,超出 OpenPencil 快照入口的上限。请使用「下载 JSON」,然后运行:op import:snapshot <文件>" + "message": "本次捕获超过 $1 MB,超出 OpenPencil 快照入口的上限。请使用「下载 .op」,然后在 OpenPencil 中打开该文件" }, "errorImport": { "message": "OpenPencil 拒绝了该快照:$1" }, "errorDownload": { "message": "下载失败:$1" }, diff --git a/packages/op-chrome-extension/_locales/zh_TW/messages.json b/packages/op-chrome-extension/_locales/zh_TW/messages.json index 1d5da8ad5..3ccbd0e45 100644 --- a/packages/op-chrome-extension/_locales/zh_TW/messages.json +++ b/packages/op-chrome-extension/_locales/zh_TW/messages.json @@ -9,7 +9,7 @@ }, "captureButton": { "message": "擷取整頁" }, "pickButton": { "message": "擷取元素" }, - "downloadButton": { "message": "下載 JSON" }, + "downloadButton": { "message": "下載 .op" }, "pickHint": { "message": "點選要擷取的元素" }, "pickCancelHint": { "message": "按 Esc 取消" }, "endpointLabel": { "message": "OpenPencil 位址" }, @@ -38,10 +38,10 @@ }, "errorTimeout": { "message": "$1 已接受連線,但 $2 秒內沒有回應。應用程式可能正忙或已卡住,請檢查後再試一次。" }, "errorNoIngress": { - "message": "此 OpenPencil 版本沒有擴充功能入口($1)。請更新應用程式,或使用「下載 JSON」後執行:op import:snapshot <檔案>" + "message": "此 OpenPencil 版本沒有擴充功能入口($1)。請更新應用程式,或使用「下載 .op」後在 OpenPencil 中開啟該檔案" }, "errorTooLarge": { - "message": "本次擷取超過 $1 MB,超出 OpenPencil 快照入口的上限。請使用「下載 JSON」,然後執行:op import:snapshot <檔案>" + "message": "本次擷取超過 $1 MB,超出 OpenPencil 快照入口的上限。請使用「下載 .op」,然後在 OpenPencil 中開啟該檔案" }, "errorImport": { "message": "OpenPencil 拒絕了這份快照:$1" }, "errorDownload": { "message": "下載失敗:$1" }, diff --git a/packages/op-chrome-extension/background.js b/packages/op-chrome-extension/background.js index 6baee5423..3cde1c17a 100644 --- a/packages/op-chrome-extension/background.js +++ b/packages/op-chrome-extension/background.js @@ -188,9 +188,18 @@ async function accountDelivery() { async function deliver(mode, text, meta, endpoint, account) { if (mode === 'download') { - const filename = getCore().snapshotFilename(String(meta.title || '')); + // Same conversion as the popup: hand back a ready-to-open `.op` document, + // not the raw snapshot. An empty capture surfaces as an actionable error + // rather than a broken file. + const converted = JSON.parse(getCore().snapshotToOpDocument(text, String(meta.title || ''))); + if (!converted.ok) { + const error = new Error(String(converted.error || 'empty capture')); + error.code = 'empty'; + throw error; + } + const filename = getCore().opFilename(String(meta.title || '')); try { - await chrome.downloads.download({ url: dataUrl(text), filename, saveAs: false }); + await chrome.downloads.download({ url: dataUrl(converted.op), filename, saveAs: false }); } catch (cause) { const error = new Error(String((cause && cause.message) || cause)); error.code = 'download'; diff --git a/packages/op-chrome-extension/popup.html b/packages/op-chrome-extension/popup.html index 52b8c752b..b39314e02 100644 --- a/packages/op-chrome-extension/popup.html +++ b/packages/op-chrome-extension/popup.html @@ -51,7 +51,7 @@ - Download JSON + Download .op