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:
parent
a636efd7ae
commit
bc1f35ef9b
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
99
crates/op-chrome-extension-core/src/op_export.rs
Normal file
99
crates/op-chrome-extension-core/src/op_export.rs
Normal 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()
|
||||
}
|
||||
103
crates/op-chrome-extension-core/src/op_export_tests.rs
Normal file
103
crates/op-chrome-extension-core/src/op_export_tests.rs
Normal 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:"));
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 <fichier>"
|
||||
"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 <fichier>"
|
||||
"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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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ı 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 <dosya>"
|
||||
"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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue