From bbe026607f5c18d2038492c32de12862b13947a5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 00:11:12 +0700 Subject: [PATCH] =?UTF-8?q?Variables:=20Load=20Variable=20Library=E2=80=A6?= =?UTF-8?q?=20reads=20a=20variable=20library's=20text=20variables=20and=20?= =?UTF-8?q?data=20sets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from #2853 onto main. --- crates/engine/src/cmd/place/docx.rs | 2 +- crates/engine/src/cmd/place/mod.rs | 2 +- crates/engine/src/cmd/variablecmds.rs | 312 ++++++++++++++++++++++++- crates/engine/src/tests_variables.rs | 257 ++++++++++++++++++++ crates/engine/tests/import_fuzz.rs | 62 +++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/variables.rs | 32 +++ crates/ui-egui/src/tests_picks.rs | 23 ++ docs/mcp.md | 23 +- docs/target-app-parity.md | 1 + 32 files changed, 750 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index 2830fd437..bf394f63c 100644 --- a/crates/engine/src/cmd/place/docx.rs +++ b/crates/engine/src/cmd/place/docx.rs @@ -165,7 +165,7 @@ fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result usize { +pub(crate) fn nesting(xml: &str) -> usize { let b = xml.as_bytes(); let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); // The index just past the first `end` at or after `from` (the end of the text if none). diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index e4fde0331..6b084f226 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -30,7 +30,7 @@ pub(crate) mod adopt; pub(crate) mod document; -mod docx; +pub(crate) mod docx; mod rtf; mod text; diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 2a1fba843..9864fc7d5 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -7,8 +7,9 @@ //! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name` //! then variable names); import also reads tab-separated files, files without the //! `name` column, and header cells that define new variables (`#` for visibility, `@` for -//! image, no prefix for text). Agents reach every command through `run_command`, like all engine -//! commands. +//! image, no prefix for text). `variable.loadLibrary` replaces the variables and data sets with the +//! text variables and data sets of a variable library, the XML file of the reference app's Save +//! Variable Library. Agents reach every command through `run_command`, like all engine commands. use std::collections::{BTreeMap, BTreeSet}; @@ -30,9 +31,18 @@ const MAX_CSV_BYTES: usize = 1_048_576; const MAX_CSV_ROWS: usize = 1_000; const MAX_CSV_COLS: usize = 256; +/// Caps on a variable library (paths and MCP payloads are untrusted): its size, its XML nodes, how +/// deep its elements nest, and the entities, variables and data sets it declares. +const MAX_LIBRARY_BYTES: usize = 8 << 20; +const MAX_LIBRARY_ENTITIES: usize = 64; +const MAX_LIBRARY_NODES: u32 = 2_000_000; +const MAX_LIBRARY_NESTING: usize = 256; +const MAX_LIBRARY_VARIABLES: usize = 256; +const MAX_LIBRARY_DATASETS: usize = 1_000; + /// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options), -/// Make … Dynamic and an import whose header would define a variable; binding, unbinding and the -/// other dataset commands still run. +/// Make … Dynamic, an import whose header would define a variable and a Load Variable Library +/// that would delete or define one; binding, unbinding and the other dataset commands still run. fn variables_unlocked(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.active().is_some_and(|st| st.doc.variables.locked) { Err("variables are locked".into()) } else { Ok(()) } @@ -891,6 +901,289 @@ fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result { apply_dataset(s, &name) } +/// Bytes of a variable library from `path`, `xml` or `dataBase64`. +fn library_bytes(p: &Value, cmd: &str) -> Result> { + let bytes = match (str_param(p, "path"), str_param(p, "xml"), str_param(p, "dataBase64")) { + // One byte past the cap is enough to refuse the file below. + (Some(path), ..) => read_file_head(&absolute_path(path), MAX_LIBRARY_BYTES + 1)?, + (None, Some(xml), _) => xml.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?, + _ => return Err(bad(cmd, "give `path`, `xml` or `dataBase64`")), + }; + if bytes.len() > MAX_LIBRARY_BYTES { + return Err(bad(cmd, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"))); + } + Ok(bytes) +} + +/// `xml` without the document type declaration of its prolog, with each reference to a general +/// entity that the declaration defines replaced by the entity's value, its quotes and `>` escaped, +/// outside CDATA sections, comments and processing instructions. The declaration may hold an +/// external identifier and an internal subset of white space, comments, processing instructions and +/// up to [`MAX_LIBRARY_ENTITIES`] entity declarations, each with a name of at most [`MAX_NAME`] +/// bytes and a quoted value that holds no `<`, `&` or `%`. Anything else in it is refused, and so is +/// a text that the replacements make longer than [`MAX_LIBRARY_BYTES`]. The XML parser then refuses +/// any other document type declaration, so [`nesting`](super::place::docx::nesting) counts the +/// elements the parser reads. +fn without_doctype(xml: &str, cmd: &str) -> Result { + let b = xml.as_bytes(); + let past = |from: usize, end: &str| xml.get(from..).and_then(|rest| rest.find(end)).map(|at| from + at + end.len()); + let spaces = |from: usize| from + b.get(from..).map_or(0, |rest| rest.iter().take_while(|c| c.is_ascii_whitespace()).count()); + let at = |i: usize, s: &str| xml.get(i..).is_some_and(|rest| rest.starts_with(s)); + let refused = || bad(cmd, "the variable library's document type declaration holds something other than entities of plain text"); + // The prolog: an XML declaration, white space, comments and processing instructions. + let mut i = 0; + loop { + i = spaces(i); + if at(i, "").ok_or_else(refused)?; + } else if at(i, "").ok_or_else(refused)?; + } else { + break; + } + } + if !at(i, " Option<(usize, &str)> { + let quote = *b.get(i).filter(|c| matches!(c, b'"' | b'\''))?; + let len = b.get(i + 1..)?.iter().position(|c| *c == quote)?; + Some((i + len + 2, xml.get(i + 1..i + 1 + len)?)) + }; + // `' + i = spaces(i + "')).count()); + i = spaces(i); + for (id, literals) in [("SYSTEM", 1), ("PUBLIC", 2)] { + if at(i, id) { + i += id.len(); + for _ in 0..literals { + i = literal(spaces(i)).ok_or_else(refused)?.0; + } + i = spaces(i); + } + } + let mut entities: Vec<(&str, String)> = Vec::new(); + if at(i, "[") { + i += 1; + loop { + i = spaces(i); + if at(i, "]") { + i = spaces(i + 1); + break; + } else if at(i, "").ok_or_else(refused)?; + } else if at(i, "").ok_or_else(refused)?; + } else if at(i, "')).count()); + let name = xml.get(i..i + len).filter(|n| !n.is_empty() && n.len() <= MAX_NAME && !n.starts_with('%')).ok_or_else(refused)?; + let (end, value) = literal(spaces(i + len)).ok_or_else(refused)?; + if value.contains(['<', '&', '%']) { + return Err(refused()); + } + i = spaces(end); + if !at(i, ">") { + return Err(refused()); + } + i += 1; + if entities.len() == MAX_LIBRARY_ENTITIES { + return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_ENTITIES} entities"))); + } + entities.push((name, value.replace('"', """).replace('\'', "'").replace('>', ">"))); + } else { + return Err(refused()); + } + } + } + if !at(i, ">") { + return Err(refused()); + } + let mut out = String::with_capacity(xml.len()); + out.push_str(xml.get(..start).unwrap_or_default()); + // The rest, copied with the references replaced outside CDATA sections, comments and + // processing instructions, where the parser reads none. + let mut i = i + 1; + while let Some(c) = xml.get(i..).and_then(|rest| rest.chars().next()) { + let verbatim = [(""), (""), ("")].into_iter().find(|(open, _)| at(i, open)); + if let Some((open, close)) = verbatim { + let end = past(i + open.len(), close).unwrap_or(xml.len()); + out.push_str(xml.get(i..end).unwrap_or_default()); + i = end; + continue; + } + // Entity names are at most `MAX_NAME` bytes long, so the `;` is looked for that far. + let entity = if c == '&' { + let name = xml.get(i + 1..).and_then(|rest| rest.get(..rest.bytes().take(MAX_NAME + 1).position(|b| b == b';')?)); + name.and_then(|name| entities.iter().find(|(n, _)| *n == name)) + } else { + None + }; + match entity { + Some((name, value)) => { + out.push_str(value); + i += name.len() + 2; + if out.len() > MAX_LIBRARY_BYTES { + return Err(bad(cmd, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + } + None => { + out.push(c); + i += c.len_utf8(); + } + } + } + if out.len() > MAX_LIBRARY_BYTES { + return Err(bad(cmd, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + Ok(out) +} + +/// A variable library as Load Variable Library reads it: the names of its text variables in the +/// order it declares them, the name and `trait` of each variable of another kind, and its data +/// sets with their text values. +struct Library { + text: Vec, + other: Vec<(String, String)>, + datasets: Vec, +} + +/// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each +/// with a `variables` element of `variable` elements (`varName`, `trait`) and a `sampleDataSets` +/// element of `sampleDataSet` elements (`dataSetName`). Elements are matched by their local names. +/// A variable whose `trait` is `textcontent` is a text variable. In a data set, an element named +/// after a text variable gives its value: the text of its `p` elements, one paragraph each, and +/// none when it has no `p` element. Values for variables of other kinds are left out. A variable +/// or data set declared twice, a value for a variable the library doesn't declare, and a library +/// past the caps are refused. Entities that its document type declaration defines are read as +/// [`without_doctype`] says. +fn parse_library(xml: &str, cmd: &str) -> Result { + let xml = without_doctype(xml, cmd)?; + // roxmltree parses nested elements recursively. + if super::place::docx::nesting(&xml) > MAX_LIBRARY_NESTING { + return Err(bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep"))); + } + let opts = roxmltree::ParsingOptions { nodes_limit: MAX_LIBRARY_NODES, ..roxmltree::ParsingOptions::default() }; + let doc = + roxmltree::Document::parse_with_options(&xml, opts).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?; + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; + let Some(root) = doc.descendants().find(|n| is(n, "variableSets")) else { + return Err(bad(cmd, "the file holds no `variableSets` element")); + }; + let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); + let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new()); + for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { + let name = named(var.attribute("varName"), "varName", cmd)?; + if !declared.insert(name.clone()) { + return Err(bad(cmd, format!("variable `{name}` is declared twice"))); + } + if declared.len() > MAX_LIBRARY_VARIABLES { + return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); + } + match var.attribute("trait") { + Some("textcontent") => text.push(name), + Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())), + None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))), + } + } + if declared.is_empty() { + return Err(bad(cmd, "the variable library declares no variables")); + } + let text_names: BTreeSet<&str> = text.iter().map(String::as_str).collect(); + let (mut datasets, mut seen) = (Vec::new(), BTreeSet::new()); + for row in + sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "sampleDataSets")).flat_map(|d| d.children()).filter(|n| is(n, "sampleDataSet")) + { + let name = named(row.attribute("dataSetName"), "dataSetName", cmd)?; + if !seen.insert(name.clone()) { + return Err(bad(cmd, format!("data set `{name}` is declared twice"))); + } + if seen.len() > MAX_LIBRARY_DATASETS { + return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets"))); + } + let (mut values, mut held) = (BTreeMap::new(), BTreeSet::new()); + for el in row.children().filter(roxmltree::Node::is_element) { + let var = el.tag_name().name(); + if !declared.contains(var) { + return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare"))); + } + if !held.insert(var) { + return Err(bad(cmd, format!("data set `{name}` holds two values for `{var}`"))); + } + if !text_names.contains(var) { + continue; + } + let paras: Vec = el + .children() + .filter(|n| is(n, "p")) + .map(|p| p.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect()) + .collect(); + if !paras.is_empty() { + values.insert(var.to_string(), DataValue::Text(paras.join("\n"))); + } + } + datasets.push(DataSet { name, values }); + } + Ok(Library { text, other, datasets }) +} + +/// Load Variable Library: replace the document's variables and data sets with a variable library's +/// ([`parse_library`]) in one undo step. Every variable of the document is deleted with its bindings +/// and values, and every data set is deleted. The library's text variables are then defined, +/// unbound, and its data sets are added. The library's variables of other kinds are left out, named +/// in `skipped` and in a warning. +fn load_library(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.loadLibrary"; + let bytes = library_bytes(p, C)?; + let xml = std::str::from_utf8(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).map_err(|_| bad(C, "the variable library is not UTF-8"))?; + let lib = parse_library(xml, C)?; + let vars = &s.doc()?.doc.variables; + let deleted: Vec = vars.variables.iter().map(|v| v.name.clone()).collect(); + let deleted_sets: Vec = vars.datasets.iter().map(|d| d.name.clone()).collect(); + // Lock Variables refuses New Variable and Delete Variable, so the load is refused when it would + // delete or define a variable. + if !deleted.is_empty() || !lib.text.is_empty() { + variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?; + } + let added: Vec = lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(); + let names: Vec = lib.datasets.iter().map(|d| d.name.clone()).collect(); + s.edit("Load Variable Library", |d, _| { + d.variables.prune(&deleted); + d.variables.variables.extend(added); + d.variables.datasets = lib.datasets; + d.variables.active_dataset = None; + Ok(()) + })?; + // A highlight of a variable that is gone highlights nothing. + let st = s.doc_mut()?; + if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) { + st.variables_highlight = None; + } + st.revision += 1; + let skipped: Vec = lib.other.iter().map(|(name, t)| json!({"name": name, "trait": t})).collect(); + let mut out = json!({ + "variables": lib.text, "deleted": deleted, "deletedDataSets": deleted_sets, "names": names, "skipped": skipped, + }); + // Each `trait` once, in the order the library first uses it, with how many variables have it. + let mut kinds: Vec<(&str, usize)> = Vec::new(); + for (_, t) in &lib.other { + match kinds.iter_mut().find(|(k, _)| k == t) { + Some((_, n)) => *n += 1, + None => kinds.push((t, 1)), + } + } + if !kinds.is_empty() { + let kinds = kinds.iter().map(|(k, n)| format!("{k} ({n})")).collect::>().join(", "); + out["warning"] = json!(format!("variables that aren't text were left out: {kinds}")); + } + Ok(out) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -980,7 +1273,7 @@ pub fn specs() -> Vec { "Lock Variables", ["Window", "Variables"], None, - "{locked?: bool} lock or unlock the variables (default: toggle); saved with the document; a change is one undo step. While locked, variable.define, variable.delete, variable.rename and variable.make…Dynamic are refused; binding, unbinding and the dataset commands still run → {locked}", + "{locked?: bool} lock or unlock the variables (default: toggle); saved with the document; a change is one undo step. While locked, variable.define, variable.delete, variable.rename, variable.make…Dynamic and a variable.loadLibrary that would delete or define a variable are refused; binding, unbinding and the dataset commands still run → {locked}", has_doc, lock ), @@ -1075,5 +1368,14 @@ pub fn specs() -> Vec { has_doc, dataset_export ), + cmd!( + "variable.loadLibrary", + "Load Variable Library…", + ["Window", "Variables"], + None, + "{path? | xml? | dataBase64?} replace the document's variables and data sets with a variable library's (XML: `variableSets` › `variableSet` › `variables` of `variable` elements with `varName` and `trait`, and `sampleDataSets` of `sampleDataSet` elements with `dataSetName`) in one undo step. Every variable of the document is deleted with its bindings and values, and every data set is deleted; then variables whose `trait` is `textcontent` are defined as text variables, unbound, and each data set takes a value for a variable from the `p` elements of the element named after it, one paragraph each. Variables of other kinds are left out and named in `skipped` and `warning`. Refused while the variables are locked when it would delete or define a variable → {variables, deleted, deletedDataSets, names, skipped, warning?}", + has_doc, + load_library + ), ] } diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index 7d2b1dadb..cae174ce5 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -1346,6 +1346,263 @@ fn datasets_csv_round_trips_and_rejects_bad_input() { s.execute("edit.undo", &json!({})).unwrap(); } +/// A data set of [`library`]: its name, and each variable's name with its paragraphs. +type LibrarySet<'a> = (&'a str, &'a [(&'a str, &'a [&'a str])]); + +/// A variable library written from its public grammar: a document type declaration with the +/// namespace entities, `variableSets` › `variableSet` › `variables` with one `variable` element per +/// `(varName, trait)`, then `sampleDataSets` with one `sampleDataSet` per data set, holding an +/// element per variable with a `p` element per paragraph. +fn library(vars: &[(&str, &str)], sets: &[LibrarySet]) -> String { + let mut x = String::from( + "\n\ + \n\ + \t\n\ + \t\n\ + ]>\n\n\n\n\n", + ); + for (name, kind) in vars { + x += &format!("\n"); + } + x += "\n\n"; + for (set, values) in sets { + x += &format!("\n"); + for (var, paras) in *values { + let paras: String = paras.iter().map(|p| format!("

{p}

")).collect(); + x += &format!("<{var}>{paras}\n"); + } + x += "\n"; + } + x + "\n\n\n\n" +} + +/// Load Variable Library defines the library's text variables, unbound, and adds its data sets, +/// in one undo step. A value is the text of the `p` elements, one paragraph each; an element +/// without a `p` gives no value. A file, and base64 with a UTF-8 byte-order mark, load the same. +#[test] +fn a_variable_library_defines_text_variables_and_data_sets() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let xml = library( + &[("Name", "textcontent"), ("Note", "textcontent")], + &[("Row 1", &[("Name", &["Bravo"]), ("Note", &["One", "Two & three"])]), ("Row 2", &[("Name", &["Charlie"]), ("Note", &[])])], + ); + let r = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap(); + assert_eq!(r["variables"], json!(["Name", "Note"])); + assert_eq!((r["deleted"].clone(), r["deletedDataSets"].clone()), (json!([]), json!([]))); + assert_eq!(r["names"], json!(["Row 1", "Row 2"])); + assert_eq!((r["skipped"].clone(), r.get("warning")), (json!([]), None)); + let listed = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(listed["variables"], json!([{"name": "Name", "kind": "text", "bindings": 0}, {"name": "Note", "kind": "text", "bindings": 0}])); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "Bravo"}, "Note": {"text": "One\nTwo & three"}})); + assert_eq!(sets["datasets"][1]["values"], json!({"Name": {"text": "Charlie"}})); + assert_eq!(sets["active"], Value::Null); + assert_eq!(plain(&s, t), "Alice", "loading binds nothing and applies nothing"); + + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.select", &json!({"name": "Row 2"})).unwrap(); + assert_eq!(plain(&s, t), "Charlie"); + + // One undo step each: the select, the bind, then the load. + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + let vars = &s.doc().unwrap().doc.variables; + assert!(vars.variables.is_empty() && vars.datasets.is_empty()); + + let dir = Folder::new("vars-library"); + let path = dir.file("library.xml"); + write(&path, xml.as_bytes()); + let from_file = s.execute("variable.loadLibrary", &json!({"path": path})).unwrap(); + assert_eq!(from_file["names"], json!(["Row 1", "Row 2"])); + let bom = [b"\xEF\xBB\xBF".as_slice(), xml.as_bytes()].concat(); + let from_base64 = s.execute("variable.loadLibrary", &json!({"dataBase64": vectorcraft_format::base64_encode(&bom)})).unwrap(); + assert_eq!((from_base64["deleted"].clone(), from_base64["deletedDataSets"].clone()), (json!(["Name", "Note"]), json!(["Row 1", "Row 2"]))); + assert_eq!(s.doc().unwrap().doc.variables.datasets.len(), 2); +} + +/// Load Variable Library replaces the document's variables and data sets: every variable goes with +/// its bindings and values, a text variable of the same name included, and every data set goes, +/// the active one included. The highlight of a variable that went highlights nothing. Undo brings +/// back the variables, bindings, data sets and active data set as they were. +#[test] +fn a_variable_library_replaces_every_variable_and_data_set() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + for (name, kind, id) in [("Name", "text", t), ("Title", "visibility", r), ("Kept", "visibility", r)] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + s.execute("variable.bind", &json!({"variable": name, "ids": [id.0]})).unwrap(); + } + s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "Old", "Title": true}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Other", "values": {"Name": "Stays", "Title": false, "Kept": true}})).unwrap(); + s.execute("dataset.select", &json!({"name": "Other"})).unwrap(); + s.execute("variable.highlight", &json!({"variable": "Kept"})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + + let xml = library(&[("Name", "textcontent"), ("Title", "textcontent")], &[("A", &[("Name", &["New"]), ("Title", &["Heading"])])]); + let loaded = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap(); + assert_eq!(loaded["deleted"], json!(["Name", "Title", "Kept"])); + assert_eq!((loaded["deletedDataSets"].clone(), loaded["names"].clone()), (json!(["A", "Other"]), json!(["A"]))); + let listed = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(listed["variables"], json!([{"name": "Name", "kind": "text", "bindings": 0}, {"name": "Title", "kind": "text", "bindings": 0}])); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1)); + assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "New"}, "Title": {"text": "Heading"}})); + assert_eq!(sets["active"], Value::Null); + assert!(s.doc().unwrap().doc.variables.bindings.is_empty(), "the bindings went with the variables"); + assert!(s.doc().unwrap().variables_highlight.is_none(), "the highlighted variable went"); + assert_eq!(plain(&s, t), "Stays", "loading applies nothing"); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.variables, before, "one undo step brings everything back"); +} + +/// A library that is refused leaves the document's variables and data sets as they were. +#[test] +fn a_refused_variable_library_deletes_nothing() { + let mut s = session(); + let t = text(&mut s, "Alice"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "Old"}})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + let dup = library(&[("Name", "textcontent")], &[("B", &[]), ("B", &[])]); + assert!(s.execute("variable.loadLibrary", &json!({"xml": dup})).is_err()); + assert_eq!(s.doc().unwrap().doc.variables, before); +} + +/// A library's variables of other kinds are left out with their values, named in `skipped` and +/// in a warning that gives each kind once with its count; its text variables load. +#[test] +fn a_variable_library_leaves_out_variables_that_are_not_text() { + let mut s = session(); + let xml = library( + &[("Shown", "kindB"), ("Name", "textcontent"), ("Photo", "kindC"), ("Other", "kindB")], + &[("A", &[("Shown", &["true"]), ("Name", &["Bravo"]), ("Photo", &["a.png"])])], + ); + let r = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap(); + assert_eq!(r["variables"], json!(["Name"])); + assert_eq!(r["skipped"], json!([{"name": "Shown", "trait": "kindB"}, {"name": "Photo", "trait": "kindC"}, {"name": "Other", "trait": "kindB"}])); + assert_eq!(r["warning"], "variables that aren't text were left out: kindB (2), kindC (1)"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!(vars.variables.len(), 1); + assert_eq!(vars.dataset("A").map(|d| d.values.len()), Some(1)); +} + +/// A library that isn't one, or that breaks its grammar or the caps, is refused, and the document +/// is left as it was. +#[test] +fn malformed_variable_libraries_are_refused() { + let mut s = session(); + let ok = library(&[("Name", "textcontent")], &[("A", &[("Name", &["x"])])]); + let deep = format!("{}{}", "".repeat(300), "".repeat(300)); + let markup = "\">]>&deep;".to_string(); + // A document type declaration in a comment before the real one, and markup reached through + // another entity. + let hidden = format!("{markup}"); + let nested = "]>&a;".to_string(); + let parameter = "]>".to_string(); + let element = "]>".to_string(); + // A `>` and a comment opener inside a quoted system identifier, before elements nested too deep. + let quoted = format!("", + "x".repeat(1024), + "&big;".repeat(8000), + " ".repeat(300_000) + ); + let cases = [ + ("not well-formed XML", "".to_string()), + ("no `variableSets` element", "".to_string()), + ("declares no variables", "".to_string()), + ("is declared twice", library(&[("Name", "textcontent"), ("Name", "textcontent")], &[])), + ("has no `trait`", ok.replace(" trait=\"textcontent\"", "")), + ("missing `varName`", ok.replace("varName=\"Name\"", "varName=\" \"")), + ("missing `dataSetName`", ok.replace(" dataSetName=\"A\"", "")), + ("data set `A` is declared twice", library(&[("Name", "textcontent")], &[("A", &[]), ("A", &[])])), + ("which the library doesn't declare", library(&[("Name", "textcontent")], &[("A", &[("Nmae", &["x"])])])), + ("holds two values for `Name`", library(&[("Name", "textcontent")], &[("A", &[("Name", &["x"]), ("Name", &["y"])])])), + ("nests elements more than 256 deep", deep), + ("something other than entities of plain text", markup), + ("something other than entities of plain text", hidden), + ("something other than entities of plain text", nested), + ("something other than entities of plain text", parameter), + ("something other than entities of plain text", element), + ("nests elements more than 256 deep", quoted), + ("more than 64 entities", many_entities), + ("entities expand to more than", expanding), + ("entities expand to more than", then_literal), + ("larger than", format!("{ok}", " ".repeat(8 << 20))), + ]; + for (why, xml) in cases { + let e = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap_err().to_string(); + assert!(e.contains(why), "{why}: {e}"); + } + let names: Vec = (0..257).map(|i| format!("V{i}")).collect(); + let many: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), "textcontent")).collect(); + let e = s.execute("variable.loadLibrary", &json!({"xml": library(&many, &[])})).unwrap_err().to_string(); + assert!(e.contains("more than 256 variables"), "{e}"); + // A Latin-1 é in a comment. + let latin1 = [ok.as_bytes(), b""].concat(); + let e = s.execute("variable.loadLibrary", &json!({"dataBase64": vectorcraft_format::base64_encode(&latin1)})).unwrap_err().to_string(); + assert!(e.contains("not UTF-8"), "{e}"); + let e = s.execute("variable.loadLibrary", &json!({})).unwrap_err().to_string(); + assert!(e.contains("give `path`, `xml` or `dataBase64`"), "{e}"); + let vars = &s.doc().unwrap().doc.variables; + assert!(vars.variables.is_empty() && vars.datasets.is_empty(), "a refused library loads nothing"); +} + +/// Entities whose values hold plain text expand to it, in attribute values too, and a reference in +/// a CDATA section stays as written. +#[test] +fn a_variable_library_expands_entities_of_plain_text() { + let mut s = session(); + let xml = "]>\ + \ +

&a;-&b;-&a;

"; + s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap(); + let value = s.doc().unwrap().doc.variables.dataset("y \"z\"").and_then(|d| d.values.get("Name")).cloned(); + assert_eq!(value, Some(vectorcraft_doc::DataValue::Text("x-y \"z\"-x&a;".into()))); +} + +/// While the variables are locked, a library that would delete or define a variable is refused, as +/// Delete Variable and New Variable are, and the document keeps its variables and data sets. That +/// covers a library whose only variable is a text variable the document already has, and one +/// without text variables loaded into a document with variables. A library without text variables +/// loads into a document without variables, replacing its data sets. +#[test] +fn locked_variables_refuse_a_library_that_deletes_or_defines_variables() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "Old"}})).unwrap(); + s.execute("variable.lock", &json!({"locked": true})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + for vars in [&[("Name", "textcontent")][..], &[("Shown", "other")][..]] { + let e = s.execute("variable.loadLibrary", &json!({"xml": library(vars, &[("B", &[])])})).unwrap_err(); + assert!(matches!(&e, EngineError::Disabled(cmd, why) if cmd == "variable.loadLibrary" && why == "variables are locked"), "{e}"); + assert_eq!(s.doc().unwrap().doc.variables, before); + } + + let mut s = session(); + s.execute("dataset.new", &json!({"name": "A"})).unwrap(); + s.execute("variable.lock", &json!({"locked": true})).unwrap(); + let e = s.execute("variable.loadLibrary", &json!({"xml": library(&[("Name", "textcontent")], &[])})).unwrap_err(); + assert!(matches!(&e, EngineError::Disabled(_, why) if why == "variables are locked"), "{e}"); + let r = s.execute("variable.loadLibrary", &json!({"xml": library(&[("Shown", "other")], &[("B", &[])])})).unwrap(); + assert_eq!((r["deletedDataSets"].clone(), r["names"].clone()), (json!(["A"]), json!(["B"]))); + let vars = &s.doc().unwrap().doc.variables; + assert!(vars.variables.is_empty() && vars.locked); + assert_eq!(vars.datasets.iter().map(|d| d.name.as_str()).collect::>(), ["B"]); +} + #[test] fn graph_variable_applies_csv_and_captures() { let mut s = session(); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index b530f7883..4e1c2b759 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1252,6 +1252,68 @@ proptest! { } } +// ---------- variable library XML files ---------- + +/// A variable library written from its public grammar: two text variables (`Title` is a +/// variable of [`merge_session`]'s document), a variable of another kind and two data sets. +const VARIABLE_LIBRARY: &str = "\n\ +\n\n]>\n\ +\n\n\n\n\ +\n\n\ +\n\n\ +\n\ +\n

One

Two & three

\n

x

\n

true

\n\n\ +\n<p>Four</p>\n\n\n\ +\n\n
\n\n"; + +/// `variable.loadLibrary` with `params` on [`merge_session`]'s document, the first three data sets +/// it read selected in turn and every data set exported; the document then renders and exports. +fn variable_library(what: &str, params: Value) -> Result<(), TestCaseError> { + survive(what, || { + let mut s = merge_session(); + if let Ok(r) = no_panic(s.execute("variable.loadLibrary", ¶ms)) { + for name in r["names"].as_array().into_iter().flatten().take(3) { + let _ = no_panic(s.execute("dataset.select", &json!({"name": name}))); + } + } + let _ = no_panic(s.execute("dataset.export", &json!({}))); + Some((*s.doc().ok()?.doc).clone()) + }) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn variable_library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec![ + "", + "", + "", + "<p>", + "<!DOCTYPE svg [<!ENTITY a \"", + ])) { + variable_library("variable library garbage", json!({"xml": format!("{head}{s}")}))?; + } + + #[test] + fn variable_library_bytes_never_panic(bytes in prop::collection::vec(any::<u8>(), 0..300)) { + variable_library("variable library bytes", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)}))?; + } + + /// The library cut short and with characters replaced, then with `nest` elements nested in one + /// another inside its `svg` element. + #[test] + fn mutated_variable_libraries_never_panic( + cut in 0usize..1_200, + edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['<', '>', '"', '/', '=', '&', ';', '!', '[', ']', 'p', 'x', ' '])), 0..10), + nest in prop::sample::select(vec![0usize, 0, 0, 240, 100_000]), + ) { + let xml = mutate_text(VARIABLE_LIBRARY, cut, &edits); + let xml = xml.replacen("<svg>", &format!("<svg>{}", "<a>".repeat(nest)), 1).replacen("</svg>", &format!("{}</svg>", "</a>".repeat(nest)), 1); + variable_library("mutated variable library", json!({"xml": xml}))?; + } +} + // ---------- shortcut sets ---------- /// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 45f0e5e34..c817a8707 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4202,6 +4202,7 @@ axis Both И двете @msg unknown workspace (one of {_1}) непознато работно пространство (едно от {_1}) @msg unsupported: {0} не се поддържа: {0} @msg variables are locked променливите са заключени +@msg variables that aren't text were left out: {kinds} променливите, които не са текстови, бяха пропуснати: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файловете от версия {0} не могат да бъдат компресирани: само версия {COMPRESSED_SINCE} и по-нови отварят компресирани файлове @msg width and height are at least 1 pixel ширината и височината са най-малко 1 пиксел @msg workspace name can't be empty името на работното пространство не може да бъде празно @@ -5184,6 +5185,7 @@ theme Appearance Външен вид Make Image Dynamic Динамично изображение Import Data Sets… Импортиране на набори от данни… Export Data Sets… Експортиране на набори от данни… + Load Variable Library… Зареждане на библиотека с променливи… Make Graph Dynamic Динамична диаграма Batch… Пакет… Source Folder: Изходна папка: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4f77cafbc..61bb3dc96 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4597,6 +4597,7 @@ imageTrace Outline Obrys @msg unknown workspace (one of {_1}) neznámý pracovní prostor (jeden z {_1}) @msg unsupported: {0} nepodporováno: {0} @msg variables are locked proměnné jsou zamčené +@msg variables that aren't text were left out: {kinds} proměnné, které nejsou textové, byly vynechány: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files soubory verze {0} nelze komprimovat: komprimované soubory otevírá až verze {COMPRESSED_SINCE} a novější @msg width and height are at least 1 pixel šířka i výška jsou alespoň 1 obrazový bod @msg workspace name can't be empty název pracovního prostoru nesmí být prázdný @@ -5230,6 +5231,7 @@ mark-style Japanese Japonské Make Image Dynamic Nastavit obraz jako dynamický Import Data Sets… Importovat sady dat… Export Data Sets… Exportovat sady dat… + Load Variable Library… Načíst knihovnu proměnných… Make Graph Dynamic Nastavit graf jako dynamický Batch… Dávka… Source Folder: Zdrojová složka: diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index a573310ff..faffd623a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4969,6 +4969,8 @@ mark-style Japanese Japansk Make Image Dynamic Gør billede dynamisk Import Data Sets… Importér datasæt… Export Data Sets… Eksportér datasæt… + Load Variable Library… Indlæs variabelbibliotek… +@msg variables that aren't text were left out: {kinds} variabler, der ikke er tekst, blev udeladt: {kinds} Make Graph Dynamic Gør diagram dynamisk Batch… Batch… Source Folder: Kildemappe: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7a7daf42e..7175e78c5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4228,6 +4228,7 @@ theme Appearance Erscheinungsbild @msg unknown workspace (one of {_1}) unbekannter Arbeitsbereich (einer von {_1}) @msg unsupported: {0} nicht unterstützt: {0} @msg variables are locked die Variablen sind gesperrt +@msg variables that aren't text were left out: {kinds} die Variablen, die kein Text sind, wurden ausgelassen: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files Dateien der Version {0} können nicht komprimiert werden: Erst Version {COMPRESSED_SINCE} und neuer öffnen komprimierte Dateien @msg width and height are at least 1 pixel Breite und Höhe betragen mindestens 1 Pixel @msg workspace name can't be empty der Name des Arbeitsbereichs darf nicht leer sein @@ -4809,6 +4810,7 @@ effect Soft Weich Make Image Dynamic Bild dynamisch machen Import Data Sets… Datensätze importieren… Export Data Sets… Datensätze exportieren… + Load Variable Library… Variablenbibliothek laden… Make Graph Dynamic Diagramm dynamisch machen Batch… Stapelverarbeitung… Source Folder: Quellordner: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2b9d09b38..37a50d8bd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4260,6 +4260,7 @@ theme Appearance Εμφάνιση @msg unknown workspace (one of {_1}) άγνωστος χώρος εργασίας (ένας από τους {_1}) @msg unsupported: {0} δεν υποστηρίζεται: {0} @msg variables are locked οι μεταβλητές είναι κλειδωμένες +@msg variables that aren't text were left out: {kinds} οι μεταβλητές που δεν είναι κειμένου παραλείφθηκαν: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files τα αρχεία έκδοσης {0} δεν μπορούν να συμπιεστούν: μόνο η έκδοση {COMPRESSED_SINCE} και νεότερες ανοίγουν συμπιεσμένα αρχεία @msg width and height are at least 1 pixel το πλάτος και το ύψος πρέπει να είναι τουλάχιστον 1 εικονοστοιχείο @msg workspace name can't be empty το όνομα χώρου εργασίας δεν μπορεί να είναι κενό @@ -5095,6 +5096,7 @@ effect Points Σημεία Drawing : real size — 1:10 labels 1 unit on the page as 10. Σχέδιο : πραγματικό μέγεθος — με 1:10 η 1 μονάδα στη σελίδα εμφανίζεται ως 10. Editable Επεξεργάσιμο Export Data Sets… Εξαγωγή συνόλων δεδομένων… + Load Variable Library… Φόρτωση βιβλιοθήκης μεταβλητών… Filter Φίλτρο Filter Layers Panel Rows Φιλτράρισμα γραμμών πίνακα επιπέδων Flatten Ισοπέδωση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a36fe7ce9..d746a00ed 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4296,6 +4296,7 @@ axis Both Ambos @msg unknown workspace (one of {_1}) espacio de trabajo desconocido (uno de estos: {_1}) @msg unsupported: {0} no compatible: {0} @msg variables are locked las variables están bloqueadas +@msg variables that aren't text were left out: {kinds} se omitieron las variables que no son de texto: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files los archivos de la versión {0} no se pueden comprimir: solo la versión {COMPRESSED_SINCE} y posteriores abren archivos comprimidos @msg width and height are at least 1 pixel el ancho y el alto son de al menos 1 píxel @msg workspace name can't be empty el nombre del espacio de trabajo no puede estar vacío @@ -5172,6 +5173,7 @@ mark-style Japanese Japonesas Make Image Dynamic Hacer imagen dinámica Import Data Sets… Importar conjuntos de datos… Export Data Sets… Exportar conjuntos de datos… + Load Variable Library… Cargar biblioteca de variables… Make Graph Dynamic Hacer gráfico dinámico Batch… Lote… Source Folder: Carpeta de origen: diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 780f89ac2..be6a0d47e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4506,6 +4506,7 @@ imageTrace Outline Ääriviiva @msg unknown workspace (one of {_1}) tuntematon työtila (jokin seuraavista: {_1}) @msg unsupported: {0} ei tuettu: {0} @msg variables are locked muuttujat on lukittu +@msg variables that aren't text were left out: {kinds} muut kuin tekstimuuttujat jätettiin pois: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files version {0} tiedostoja ei voi pakata: pakattuja tiedostoja voi avata vain versiosta {COMPRESSED_SINCE} alkaen @msg width and height are at least 1 pixel leveys ja korkeus ovat vähintään 1 pikseli @msg workspace name can't be empty työtilan nimi ei voi olla tyhjä @@ -5107,6 +5108,7 @@ mark-style Japanese Japanilainen Drawing : real size — 1:10 labels 1 unit on the page as 10. Piirustus : todellinen koko — 1:10 merkitsee sivun 1 yksikön arvoksi 10. Editable Muokattava Export Data Sets… Vie tietojoukot… + Load Variable Library… Lataa muuttujakirjasto… Filter Suodata Filter Layers Panel Rows Suodata Tasot-paneelin rivit Flatten Litistä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 39741ab78..3206cb52f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4293,6 +4293,7 @@ axis Both Les deux @msg unknown workspace (one of {_1}) espace de travail inconnu (valeurs possibles : {_1}) @msg unsupported: {0} non pris en charge : {0} @msg variables are locked les variables sont verrouillées +@msg variables that aren't text were left out: {kinds} les variables qui ne sont pas du texte ({kinds}) ont été ignorées @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files les fichiers de version {0} ne peuvent pas être compressés : seules les versions {COMPRESSED_SINCE} et ultérieures ouvrent les fichiers compressés @msg width and height are at least 1 pixel la largeur et la hauteur valent au moins 1 pixel @msg workspace name can't be empty le nom de l’espace de travail ne peut pas être vide @@ -5229,6 +5230,7 @@ theme Appearance Apparence Make Image Dynamic Rendre l'image dynamique Import Data Sets… Importer des jeux de données… Export Data Sets… Exporter des jeux de données… + Load Variable Library… Charger une bibliothèque de variables… Make Graph Dynamic Rendre le graphe dynamique Batch… Traitement par lots… Source Folder: Dossier source : diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e4809a47d..9b34c44ec 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5023,6 +5023,7 @@ imageTrace Outline Körvonal @msg Updated {_1} image(s) Frissített képek: {_1} @msg Updated {_1} linked file(s) changed on disk Frissített, a lemezen módosult csatolt fájlok: {_1} @msg variables are locked a változók zárolva vannak +@msg variables that aren't text were left out: {kinds} a nem szöveges változók kimaradtak: {kinds} @msg VectorCraft doesn't search other apps' or the system's folders: {path} a VectorCraft nem keres más alkalmazások és a rendszer mappáiban: {path} @msg VectorCraft doesn't search the Shared or Public folder in Users: {path} a VectorCraft nem keres a Users alatti Shared és Public mappában: {path} @msg VectorCraft failed to start: {e} A VectorCraft nem tudott elindulni: {e} @@ -5216,6 +5217,7 @@ imageTrace Outline Körvonal Make Image Dynamic Kép dinamikussá tétele Import Data Sets… Adatkészletek importálása… Export Data Sets… Adatkészletek exportálása… + Load Variable Library… Változókönyvtár betöltése… Make Graph Dynamic Diagram dinamikussá tétele Batch… Kötegelt feldolgozás… Source Folder: Forrásmappa: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 70616f7b8..e5bf05582 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4969,6 +4969,8 @@ mark-style Japanese Jepang Make Image Dynamic Jadikan gambar dinamis Import Data Sets… Impor himpunan data… Export Data Sets… Ekspor himpunan data… + Load Variable Library… Muat pustaka variabel… +@msg variables that aren't text were left out: {kinds} variabel yang bukan teks dihilangkan: {kinds} Make Graph Dynamic Jadikan grafik dinamis Batch… Batch… Source Folder: Folder sumber: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index dcf9a8b00..264c9a961 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4336,6 +4336,7 @@ axis Both Entrambi @msg unknown workspace (one of {_1}) area di lavoro sconosciuta (una delle seguenti: {_1}) @msg unsupported: {0} non supportato: {0} @msg variables are locked le variabili sono bloccate +@msg variables that aren't text were left out: {kinds} le variabili che non sono di testo sono state escluse: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files i file della versione {0} non possono essere compressi: solo la versione {COMPRESSED_SINCE} e successive aprono file compressi @msg width and height are at least 1 pixel larghezza e altezza sono di almeno 1 pixel @msg workspace name can't be empty il nome dell'area di lavoro non può essere vuoto @@ -4843,6 +4844,7 @@ lighting Light Luce Make Image Dynamic Rendi immagine dinamica Import Data Sets… Importa set di dati… Export Data Sets… Esporta set di dati… + Load Variable Library… Carica libreria di variabili… Make Graph Dynamic Rendi grafico dinamico Batch… Elaborazione batch… Source Folder: Cartella di origine: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 96071c6e0..f52179340 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3924,6 +3924,7 @@ swatch Bright Violet ビビッドバイオレット @msg Make Guides: select paths ガイドを作成: パスを選択してください @msg Release Guides: no guides ガイドを解除: ガイドがありません @msg variables are locked 変数がロックされています +@msg variables that aren't text were left out: {kinds} テキスト以外の変数は除外されました: {kinds} @msg guides are locked ガイドがロックされています @msg handle must be in or out handle には in または out を指定してください @msg no saved selection recalled 保存した選択範囲を呼び出していません @@ -4883,6 +4884,7 @@ lighting Light ライト Make Image Dynamic 画像を動的にする Import Data Sets… データセットを読み込み… Export Data Sets… データセットを書き出し… + Load Variable Library… 変数ライブラリを読み込み… Make Graph Dynamic グラフを動的にする Batch… バッチ… Source Folder: ソースフォルダー: diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e3a7d9656..7c4fa5668 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4901,6 +4901,7 @@ imageTrace Outline კონტური Make Image Dynamic გამოსახულების დინამიკურად ქცევა Import Data Sets… მონაცემთა ნაკრებების იმპორტი… Export Data Sets… მონაცემთა ნაკრებების ექსპორტი… + Load Variable Library… ცვლადების ბიბლიოთეკის ჩატვირთვა… Make Graph Dynamic დიაგრამის დინამიკურად ქცევა Batch… პაკეტური დამუშავება… Source Folder: წყარო საქაღალდე: @@ -5177,6 +5178,7 @@ imageTrace Outline კონტური @msg the spine has no segments ხერხემალს სეგმენტები არ აქვს @msg too many library panels were opened in this session ამ სესიაში ზედმეტად ბევრი ბიბლიოთეკის პანელი გაიხსნა @msg variables are locked ცვლადები ჩაკეტილია +@msg variables that aren't text were left out: {kinds} ცვლადები, რომლებიც ტექსტი არ არის, გამოტოვებულია: {kinds} @msg x and y must be finite x და y სასრული უნდა იყოს @msg {id} opens another app: only the person at the keyboard can run it {id} სხვა აპს ხსნის: მისი გაშვება მხოლოდ კლავიატურასთან მყოფ ადამიანს შეუძლია @msg “{name}” is not a file „{name}“ ფაილი არ არის diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d00dbdec1..a58ee3a03 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3856,6 +3856,7 @@ theme Appearance 외관 @msg Make Guides: select paths 안내선 만들기: 패스를 선택하세요 @msg Release Guides: no guides 안내선 해제: 안내선이 없습니다 @msg variables are locked 변수가 잠겨 있습니다 +@msg variables that aren't text were left out: {kinds} 텍스트가 아닌 변수는 제외되었습니다: {kinds} @msg guides are locked 안내선이 잠겨 있습니다 @msg handle must be in or out handle은 in 또는 out이어야 합니다 @msg no saved selection recalled 불러온 저장된 선택 영역이 없습니다 @@ -4806,6 +4807,7 @@ lighting Light 광원 Make Image Dynamic 이미지를 동적으로 만들기 Import Data Sets… 데이터 세트 가져오기… Export Data Sets… 데이터 세트 내보내기… + Load Variable Library… 변수 라이브러리 불러오기… Make Graph Dynamic 그래프를 동적으로 만들기 Batch… 일괄 처리… Source Folder: 소스 폴더: diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d595a0dfa..5cb96f8c6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4981,6 +4981,7 @@ mark-style Japanese Japans Make Image Dynamic Afbeelding dynamisch maken Import Data Sets… Gegevenssets importeren… Export Data Sets… Gegevenssets exporteren… + Load Variable Library… Variabelenbibliotheek laden… Make Graph Dynamic Grafiek dynamisch maken Batch… Batch… Source Folder: Bronmap: @@ -5294,6 +5295,7 @@ swatch Bright Violet Felviolet @msg the image's pixels can't be read de pixels van de afbeelding kunnen niet worden gelezen @msg the spine has no segments de as heeft geen segmenten @msg variables are locked variabelen zijn vergrendeld +@msg variables that aren't text were left out: {kinds} variabelen die geen tekst zijn, zijn weggelaten: {kinds} @msg x and y must be finite x en y moeten eindig zijn @msg Persistent libraries not found: {files} Permanente bibliotheken niet gevonden: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` is geen bibliotheekpaneel (zie window.swatchLibrary) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d1baf5469..8a755a6d0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4422,6 +4422,7 @@ imageTrace Outline Kontur @msg unknown workspace (one of {_1}) nieznany obszar roboczy (jeden z: {_1}) @msg unsupported: {0} nieobsługiwane: {0} @msg variables are locked zmienne są zablokowane +@msg variables that aren't text were left out: {kinds} pominięto zmienne, które nie są tekstowe: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files plików w wersji {0} nie można kompresować: skompresowane pliki otwierają tylko wersje {COMPRESSED_SINCE} i nowsze @msg width and height are at least 1 pixel szerokość i wysokość muszą wynosić co najmniej 1 piksel @msg workspace name can't be empty nazwa obszaru roboczego nie może być pusta @@ -5081,6 +5082,7 @@ mark-style Japanese Japońskie Make Image Dynamic Uczyń obraz dynamicznym Import Data Sets… Importuj zestawy danych… Export Data Sets… Eksportuj zestawy danych… + Load Variable Library… Wczytaj bibliotekę zmiennych… Make Graph Dynamic Uczyń wykres dynamicznym Batch… Przetwarzanie wsadowe… Source Folder: Folder źródłowy: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f4ed36f83..80ed66f89 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -908,6 +908,7 @@ Export CSS Exportar CSS Export CSS… Exportar CSS… Export Data Sets… Exportar Conjuntos de Dados… + Load Variable Library… Carregar Biblioteca de Variáveis… Export DXF Exportar DXF Export Document Exportar Documento Export EPS Exportar EPS @@ -3874,6 +3875,7 @@ axis Both Ambos @msg unknown workspace (one of {_1}) espaço de trabalho desconhecido (um de {_1}) @msg unsupported: {0} não suportado: {0} @msg variables are locked as variáveis estão bloqueadas +@msg variables that aren't text were left out: {kinds} as variáveis que não são de texto foram omitidas: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files arquivos da versão {0} não podem ser compactados: somente a versão {COMPRESSED_SINCE} e posteriores abrem arquivos compactados @msg width and height are at least 1 pixel largura e altura têm pelo menos 1 pixel @msg workspace name can't be empty o nome do espaço de trabalho não pode estar vazio diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 562872175..2cdbdc41a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4332,6 +4332,7 @@ axis Both Оба @msg unknown workspace (one of {_1}) неизвестная рабочая среда (одна из: {_1}) @msg unsupported: {0} не поддерживается: {0} @msg variables are locked переменные заблокированы +@msg variables that aren't text were left out: {kinds} переменные, которые не являются текстовыми, пропущены: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файлы версии {0} не могут быть сжаты: только версия {COMPRESSED_SINCE} и новее открывают сжатые файлы @msg width and height are at least 1 pixel ширина и высота должны быть не менее 1 пикселя @msg workspace name can't be empty имя рабочей среды не может быть пустым @@ -4839,6 +4840,7 @@ lighting Light Свет Make Image Dynamic Сделать изображение динамическим Import Data Sets… Импортировать наборы данных… Export Data Sets… Экспортировать наборы данных… + Load Variable Library… Загрузить библиотеку переменных… Make Graph Dynamic Сделать диаграмму динамической Batch… Пакетная обработка… Source Folder: Исходная папка: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 14136b0aa..d396e82be 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4207,6 +4207,7 @@ axis Both Båda @msg unknown workspace (one of {_1}) okänd arbetsyta (en av {_1}) @msg unsupported: {0} stöds inte: {0} @msg variables are locked variablerna är låsta +@msg variables that aren't text were left out: {kinds} variabler som inte är text utelämnades: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files filer i version {0} kan inte komprimeras: endast version {COMPRESSED_SINCE} och senare kan öppna komprimerade filer @msg width and height are at least 1 pixel bredd och höjd är minst 1 pixel @msg workspace name can't be empty arbetsytans namn får inte vara tomt @@ -5099,6 +5100,7 @@ theme Appearance Utseende Drawing : real size — 1:10 labels 1 unit on the page as 10. Ritning : verklig storlek — 1:10 visar 1 enhet på sidan som 10. Editable Redigerbar Export Data Sets… Exportera datauppsättningar… + Load Variable Library… Läs in variabelbibliotek… Filter Filter Filter Layers Panel Rows Filtrera rader i panelen Lager Flatten Platta till diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6e1e0430c..d70d2ad9f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4320,6 +4320,7 @@ axis Both Обидві @msg unknown workspace (one of {_1}) невідоме робоче середовище (одна з: {_1}) @msg unsupported: {0} не підтримується: {0} @msg variables are locked змінні заблоковані +@msg variables that aren't text were left out: {kinds} змінні, що не є текстовими, пропущено: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файли версії {0} не можуть бути стиснуті: тільки версія {COMPRESSED_SINCE} і новіші відкривають стислі файли @msg width and height are at least 1 pixel ширина та висота повинні бути не менше 1 пікселя @msg workspace name can't be empty ім’я робочого середовища не може бути порожнім @@ -4823,6 +4824,7 @@ lighting Light Світло Make Image Dynamic Зробити зображення динамічним Import Data Sets… Імпортувати набори даних… Export Data Sets… Експортувати набори даних… + Load Variable Library… Завантажити бібліотеку змінних… Make Graph Dynamic Зробити діаграму динамічною Batch… Пакетна обробка… Source Folder: Вихідна папка: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3c227bd8d..464b0d377 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4682,6 +4682,7 @@ theme Appearance Giao diện @msg Updated {_1} image(s) Đã cập nhật {_1} ảnh @msg Updated {_1} linked file(s) changed on disk Đã cập nhật {_1} tệp liên kết đã thay đổi trên đĩa @msg variables are locked biến số bị khóa +@msg variables that aren't text were left out: {kinds} các biến số không phải văn bản đã bị bỏ qua: {kinds} @msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft không tìm kiếm trong thư mục của ứng dụng khác hoặc hệ thống: {path} @msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft không tìm kiếm thư mục Shared hoặc Public trong Users: {path} @msg VectorCraft failed to start: {e} VectorCraft khởi động thất bại: {e} @@ -4823,6 +4824,7 @@ theme Appearance Giao diện Make Image Dynamic Tạo hình ảnh động Import Data Sets… Nhập tập dữ liệu… Export Data Sets… Xuất tập dữ liệu… + Load Variable Library… Tải thư viện biến số… Make Graph Dynamic Tạo biểu đồ động Batch… Hàng loạt… Source Folder: Thư mục nguồn: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f143747cf..08ac98c29 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3340,6 +3340,7 @@ font-script Japanese 汉字 Lock Variables 锁定变量 Unlock Variables 解锁变量 @msg variables are locked 变量已锁定 +@msg variables that aren't text were left out: {kinds} 已省略非文本变量:{kinds} List Variables 列出变量 Bind Variable 绑定变量 Unbind Variable 解除变量绑定 @@ -5070,6 +5071,7 @@ lighting Light 光源 Make Image Dynamic 使图像动态化 Import Data Sets… 导入数据集… Export Data Sets… 导出数据集… + Load Variable Library… 载入变量库… Make Graph Dynamic 使图表动态化 Batch… 批处理… Source Folder: 源文件夹: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b44c2548b..3917676c6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4249,6 +4249,7 @@ font-script Japanese 漢字 Lock Variables 鎖定變數 Unlock Variables 解除鎖定變數 @msg variables are locked 變數已鎖定 +@msg variables that aren't text were left out: {kinds} 已略過非文字變數:{kinds} List Variables 列出變數 Bind Variable 綁定變數 Unbind Variable 解除變數綁定 @@ -4879,6 +4880,7 @@ lighting Light 光源 Make Image Dynamic 使影像動態化 Import Data Sets… 匯入資料集… Export Data Sets… 匯出資料集… + Load Variable Library… 載入變數資料庫… Make Graph Dynamic 使圖表動態化 Batch… 批次處理… Source Folder: 來源資料夾: diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 4e8cfb56f..1f7a90598 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -322,6 +322,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::menu_item(ui, tl!("Export Data Sets…"), !vars.is_empty(), false) { crate::picks::button(app, export_datasets); } + // Its file dialog, shown off the UI thread, loads the library when it answers. + if widgets::menu_item(ui, tl!("Load Variable Library…"), true, false) { + crate::picks::button(app, load_library); + } ui.separator(); // What the highlighted variable drives, selected — its bindings are left alone. if widgets::menu_item(ui, tl!("Select Bound Object"), !highlighted_bound.is_empty(), false) { @@ -350,6 +354,16 @@ fn export_datasets(app: &mut VectorcraftApp) { } } +/// Variables panel: Load Variable Library… opens a file dialog for an XML file and runs +/// `variable.loadLibrary` on the chosen file. +fn load_library(app: &mut VectorcraftApp) { + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("XML", &["xml"])], ..Default::default() }; + let Some(path) = crate::picks::open(app, &pick) else { return }; + if let Err(e) = app.run("variable.loadLibrary", json!({"path": path})) { + app.status(e); + } +} + #[cfg(test)] mod tests { use super::*; @@ -436,6 +450,7 @@ mod tests { "Update Data Set", "Rename Data Set…", "Delete Data Set", + "Load Variable Library…", "Select Bound Object", "Select All Bound Objects", ] { @@ -457,6 +472,23 @@ mod tests { assert!(text.contains("Name") && text.contains("Data Set 1"), "{text}"); } + /// Load Variable Library's warning about variables that aren't text shows in the status bar, + /// translated in every language but English. + #[test] + fn the_variable_library_warning_shows_in_the_status_bar() { + use crate::i18n::{Lang, message}; + let mut app = app_with_data(); + let xml = r#"<variableSets><variableSet><variables><variable varName="Logo" trait="other"/></variables></variableSet></variableSets>"#; + app.run("variable.loadLibrary", json!({"xml": xml})).unwrap(); + assert_eq!(app.ui.status, "variables that aren't text were left out: other (1)"); + let langs: Vec<Lang> = Lang::all().filter(|l| l.code() != "en").collect(); + assert!(langs.len() >= 23, "{} languages", langs.len()); + for l in langs { + let shown = message(l, &app.ui.status); + assert!(!shown.contains("left out") && shown.contains("other") && shown.contains('1'), "{}: {shown}", l.code()); + } + } + /// What the panel acts on is the highlight, and only while it names a variable: a stale one /// (deleted elsewhere, undone) highlights nothing, so Delete and Options… stay disabled. #[test] diff --git a/crates/ui-egui/src/tests_picks.rs b/crates/ui-egui/src/tests_picks.rs index 1df3cee9e..58ba8d2e0 100644 --- a/crates/ui-egui/src/tests_picks.rs +++ b/crates/ui-egui/src/tests_picks.rs @@ -290,6 +290,29 @@ fn other_graphic_style_library_asks_off_the_ui_thread() { let _ = std::fs::remove_dir_all(dir); } +/// The Variables panel menu's Load Variable Library… asks off the UI thread: the library picked +/// loads when the dialog answers. +#[test] +fn load_variable_library_asks_off_the_ui_thread() { + let (mut app, shown, _) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + let dir = std::env::temp_dir().join(format!("vc-picks-variables-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let library = dir.join("library.xml"); + let xml = r#"<variableSets><variableSet><variables><variable varName="Name" trait="textcontent"/></variables> + <sampleDataSets><sampleDataSet dataSetName="A"><Name><p>x</p></Name></sampleDataSet></sampleDataSets></variableSet></variableSets>"#; + std::fs::write(&library, xml).unwrap(); + let sets = |app: &VectorcraftApp| app.session.doc().unwrap().doc.variables.datasets.iter().map(|d| d.name.clone()).collect::<Vec<_>>(); + + click(&mut app, "Load Variable Library…", crate::panels::variables::menu); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)])); + assert!(sets(&app).is_empty(), "nothing loads before the dialog answers"); + shown.answer(&[&library.to_string_lossy()]); + poll(&mut app); + assert_eq!(sets(&app), ["A"], "{}", app.ui.status); + let _ = std::fs::remove_dir_all(dir); +} + /// The Keyboard Shortcuts dialog's Import… and Export… ask off the UI thread: the set imported /// replaces the dialog's working set (kept on OK), Export… writes it where the dialog answers, and /// an answer once the dialog is closed does nothing. diff --git a/docs/mcp.md b/docs/mcp.md index 743c63bd5..84962bea7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1288,9 +1288,10 @@ already walks Image Trace. Short agent entry points: `variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph values CSV as `graph.setData`). `variable.lock {locked?}` (Lock Variables) locks or unlocks the variables; without `locked` it toggles, and the lock is saved with the document. While the variables are locked, - `variable.define`, `variable.delete`, `variable.rename` and the four `variable.make…Dynamic` commands fail with - "variables are locked"; `variable.bind`, `variable.unbind` and the `dataset.*` commands still run (except a - `dataset.import` whose header would define a variable, below), and `variable.list` reports `locked`. + `variable.define`, `variable.delete`, `variable.rename`, the four `variable.make…Dynamic` commands and a + `variable.loadLibrary` that would delete or define a variable (below) fail with "variables are locked"; + `variable.bind`, `variable.unbind` and the `dataset.*` commands still run (except a `dataset.import` whose header + would define a variable, below), and `variable.list` reports `locked`. - Data sets: `dataset.list`, `dataset.new {name, values?}`, `dataset.set {name, values?}`, `dataset.rename`, `dataset.delete`, `dataset.capture`, `dataset.update`, `dataset.select {name}`, `dataset.next` / `dataset.prev` (each `dataset.select` / next / prev is one undo step over every bound object), `dataset.import {path|data|dataBase64|csv}` / @@ -1303,6 +1304,22 @@ already walks Image Trace. Short agent entry points: one whose name is a variable of another kind fails the import. A `%` cell that isn't a variable's exact name (graph data files) is refused. The reply's `defined` lists the variables the import defined. While the variables are locked, an import whose header would define a variable fails with "variables are locked". +- Variable libraries: `variable.loadLibrary {path|xml|dataBase64}` (the Variables panel menu's Load Variable + Library…) replaces the document's variables and data sets with a variable library's in one undo step. The library + is XML whose `variableSets` element holds `variableSet` elements, each with a `variables` element of `variable` + elements (`varName`, `trait`) and a `sampleDataSets` element of `sampleDataSet` elements (`dataSetName`). Every + variable of the document is deleted with its bindings and values, and every data set is deleted. Then the library's + variables whose `trait` is `textcontent` are defined as text variables, unbound. In each data set, the element named + after a text variable gives its value: the text of its `p` elements, one paragraph each. An element without a `p` + element gives no value. Variables of other kinds are left out with their values: the reply lists them in `skipped` + (`name`, `trait`), and its `warning` names each `trait` with its count. The reply's `deleted` and `deletedDataSets` + list the variables and data sets the load deleted. While the variables are locked, a library that would delete or + define a variable fails with "variables are locked". The reply is `{variables, deleted, deletedDataSets, names, + skipped, warning?}`. A library larger than 8 MiB, one whose elements nest more than 256 deep, and one with more + than 256 variables or 1,000 data sets are refused. Its document type declaration may hold an external identifier, + comments, processing instructions and up to 64 entity declarations whose values are plain text (no `<`, `&` or + `%`). A declaration that holds anything else, and entity references that expand the library past 8 MiB, are + refused. So are a variable or data set declared twice and a value for a variable the library doesn't declare. - Batch: `action.batch {commands, source?: folder|datasets, folder?, destination?: none|saveAndClose|folder, destinationFolder?, overrideOpen?, overrideSave?, overrideExport?, includeSubdirs?, stopOnError?}` plays an action on every file in a folder or every data set (the Actions panel menu's Batch…). Override Save and Override diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ee6d0bd86..5038ccfb0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -463,6 +463,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables (data merge): text, visibility and image variables bound to objects (an object may carry several), dataset rows applied in one undo step (`variable.*`, `dataset.*`) from a Variables panel and Window › Variables, with dialogs to define, bind and edit, Make Text/Visibility/Image Dynamic, Capture/Update Data Set, CSV import/export (`dataset.import` / `dataset.export`), and a row in italics when an edit leaves the art not matching it; an image dataset value is a file path and apply keeps the object's bounds; bindings let go of deleted art and locked art is left alone; every command, like the rest, is drivable over MCP and the CLI. - Variables: Import Data Sets… (`dataset.import`) reads tab-separated files as well as comma-separated ones, and files without a `name` column, whose rows take the first free names Data Set 1, Data Set 2 and so on. An import error about a row's values or name gives the file line the row starts on. - Variables: Import Data Sets… (`dataset.import`) defines the variables a file's header names that the document lacks, unbound. `#Shown` defines a visibility variable, `@Photo` defines an image variable, and a header without a prefix defines a text variable. A header that is a variable's exact name keeps naming it, and any other `%` header (graph data files) is refused. + - Variables: Load Variable Library… in the Variables panel menu (`variable.loadLibrary`) replaces the document's variables and data sets with a variable library's, read from its XML file, in one undo step: every variable is deleted with its bindings and values, and every data set is deleted; then the library's text variables are defined, unbound, and its data sets take the text of each value's `p` elements, one paragraph each. Variables of other kinds are left out with a warning that names their kinds. - JSON-lines control channel with real egui pointer and keyboard injection. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). - The control server writes each reply through a 64 KiB buffer. On Windows 11 it sends the 17 MB `document.inspect` reply of a document with 50,000 objects on one layer to a client over loopback in 0.09 s instead of 34 s.