feat(web): chrome extension offline download emits a ready-to-open .op file

The download fallback used to save the raw capture snapshot JSON, which
OpenPencil cannot open directly. Route the snapshot through op-html's
import_snapshot_document in the wasm core so the extension hands back a
canonical .op document (with node count reported and empty captures
surfaced as an actionable error instead of a broken file).
This commit is contained in:
Kayshen-X 2026-08-05 22:14:19 +08:00
parent a636efd7ae
commit bc1f35ef9b
27 changed files with 367 additions and 120 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -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]

View file

@ -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);

View file

@ -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\"g<h>i|j%k"),
"a b c d e f g h i j k-snapshot.json"
op_filename("a/b\\c:d*e?f\"g<h>i|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");
}

View file

@ -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;

View file

@ -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<String>,
},
/// 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()
}

View file

@ -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:"));
}

View file

@ -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":"<PenDocument JSON>","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>) -> 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.

View file

@ -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 `<page-title>-snapshot.json` instead of sending it. |
| **Download .op** | Captures and saves a ready-to-open `<page-title>.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 `<page-title>.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

View file

@ -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 <Datei>"
"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 <Datei>"
"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" },

View file

@ -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 <file>"
"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 <file>"
"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" },

View file

@ -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 <archivo>"
"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 <archivo>"
"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" },

View file

@ -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 à ladresse $1 a accepté la connexion mais na pas répondu en $2 s. Lapplication est peut-être occupée ou bloquée — vérifiez-la, puis réessayez."
},
"errorNoIngress": {
"message": "Cette version dOpenPencil ne dispose daucun point dentrée pour les extensions ($1). Mettez lapplication à jour, ou utilisez « Télécharger le JSON » puis exécutez : op import:snapshot <fichier>"
"message": "Cette version dOpenPencil ne dispose daucun point dentrée pour les extensions ($1). Mettez lapplication à 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 dOpenPencil refuse. Utilisez « Télécharger le JSON », puis exécutez : op import:snapshot <fichier>"
"message": "Cette capture dépasse $1 Mo, ce que la route de capture dOpenPencil 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" },

View file

@ -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" },

View file

@ -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 <berkas>"
"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 <berkas>"
"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" },

View file

@ -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" },

View file

@ -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" },

View file

@ -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 <arquivo>"
"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 <arquivo>"
"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" },

View file

@ -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" },

View file

@ -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" },

View file

@ -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 <dosya>"
"message": "Bu OpenPencil sürümünde uzantı girişi yok ($1). Uygulamayı güncelleyin veya “.op indir” ile kaydedip dosyayı OpenPencilda açın"
},
"errorTooLarge": {
"message": "Bu yakalama $1 MBtan 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 <dosya>"
"message": "Bu yakalama $1 MBtan büyük ve OpenPencilın anlık görüntü yolu bunu reddediyor. “.op indir” kullanın, ardından dosyayı OpenPencilda açın"
},
"errorImport": { "message": "OpenPencil anlık görüntüyü reddetti: $1" },
"errorDownload": { "message": "İndirme başarısız: $1" },

View file

@ -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 <tệp>"
"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 <tệp>"
"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" },

View file

@ -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" },

View file

@ -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" },

View file

@ -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';

View file

@ -51,7 +51,7 @@
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3v11m0 0 4-4m-4 4-4-4M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />
</svg>
<span data-i18n="downloadButton">Download JSON</span>
<span data-i18n="downloadButton">Download .op</span>
</button>
</div>
</div>