mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 09:59:47 -06:00
Variables: Save Variable Library… writes the variables and data sets as an XML library
Squashed from #5147 onto main.
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
//! `name` column, and header cells that define new variables (`#` for visibility, `@` for
|
||||
//! 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.
|
||||
//! Variable Library, and `variable.saveLibrary` writes the text variables and data sets as one.
|
||||
//! Agents reach every command through `run_command`, like all engine commands.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -26,7 +27,7 @@ mod csv;
|
||||
mod library;
|
||||
|
||||
use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import};
|
||||
use library::load_library;
|
||||
use library::{load_library, save_library};
|
||||
|
||||
/// The longest variable or dataset name, in characters (names arrive from MCP and files).
|
||||
const MAX_NAME: usize = 255;
|
||||
@@ -837,5 +838,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
load_library
|
||||
),
|
||||
cmd!(
|
||||
query "variable.saveLibrary",
|
||||
"Save Variable Library…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{path?} write the document's text variables and every data set as a variable library, the XML that variable.loadLibrary reads: a `variable` element with `varName` and `trait=\"textcontent\"` per text variable, and a `sampleDataSet` element with `dataSetName` per data set, holding an element named after each text variable it has a value for, with one `p` element per line of the value. Variables of other kinds are left out with their values and named in `skipped` and `warning`. Refused without a text variable, for a text variable whose name isn't an XML name (letters, digits, `-`, `_` and `.`, starting with a letter or `_`), for a data set name or value that holds a control character, for a data set name with white space at either end, for a name longer than 255 characters or used twice, and past variable.loadLibrary's caps (256 variables, 1,000 data sets, 8 MiB). Without path → {xml, variables, datasets, skipped, warning?}; with it → {path, variables, datasets, skipped, warning?}",
|
||||
has_doc,
|
||||
save_library
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, DataSet, DataValue, EngineError, MAX_NAME, Result, Session, Value, Variable, VariableKind, absolute_path, bad, json, named,
|
||||
read_file_head, str_param, variables_unlocked,
|
||||
read_file_head, str_param, variables_unlocked, write_file,
|
||||
};
|
||||
|
||||
/// The namespace of a variable library's elements, the default namespace of its data sets' values,
|
||||
/// and the `category` of its text variables.
|
||||
const NS_VARIABLES: &str = "http://ns.adobe.com/Variables/1.0/"; // brand-ok: the namespace variable libraries use
|
||||
|
||||
const NS_CUSTOM: &str = "http://ns.adobe.com/GenericCustomNamespace/1.0/"; // brand-ok: the namespace variable libraries use
|
||||
|
||||
const NS_FLOWS: &str = "http://ns.adobe.com/Flows/1.0/"; // brand-ok: the namespace variable libraries use
|
||||
|
||||
/// 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;
|
||||
@@ -285,17 +293,200 @@ pub(super) fn load_library(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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) {
|
||||
if let Some(warning) = left_out_warning(lib.other.iter().map(|(_, t)| t.as_str())) {
|
||||
out["warning"] = json!(warning);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The warning about variables that aren't text and were left out, given the kind of each: every
|
||||
/// kind once, in the order it first comes, with how many variables have it. `None` without any.
|
||||
fn left_out_warning<'a>(kinds: impl Iterator<Item = &'a str>) -> Option<String> {
|
||||
let mut counted: Vec<(&str, usize)> = Vec::new();
|
||||
for t in kinds {
|
||||
match counted.iter_mut().find(|(k, _)| *k == t) {
|
||||
Some((_, n)) => *n += 1,
|
||||
None => kinds.push((t, 1)),
|
||||
None => counted.push((t, 1)),
|
||||
}
|
||||
}
|
||||
if counted.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let kinds = counted.iter().map(|(k, n)| format!("{k} ({n})")).collect::<Vec<_>>().join(", ");
|
||||
Some(format!("variables that aren't text were left out: {kinds}"))
|
||||
}
|
||||
|
||||
/// Whether `name` can name an element: an XML name without a colon (XML 1.0, productions 4, 4a and
|
||||
/// 5). A data set holds a text variable's value in an element named after the variable.
|
||||
fn element_name(name: &str) -> bool {
|
||||
let start = |c: char| {
|
||||
matches!(c, 'A'..='Z' | '_' | 'a'..='z' | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' | '\u{370}'..='\u{37D}'
|
||||
| '\u{37F}'..='\u{1FFF}' | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}'
|
||||
| '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}')
|
||||
};
|
||||
let more = |c: char| start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}');
|
||||
let mut chars = name.chars();
|
||||
chars.next().is_some_and(start) && chars.all(more)
|
||||
}
|
||||
|
||||
/// Whether XML can hold `c` (XML 1.0, production 2): tab, line feed, carriage return, and the
|
||||
/// characters from U+0020 on except U+FFFE and U+FFFF.
|
||||
fn xml_char(c: char) -> bool {
|
||||
matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
|
||||
}
|
||||
|
||||
/// `s` escaped for XML text, or with `attribute` for a quoted attribute value. `&`, `<` and `>`
|
||||
/// become entity references, and so do quotes in an attribute value. A carriage return, and a tab
|
||||
/// or line feed in an attribute value, become character references, which the parser reads back
|
||||
/// as they are.
|
||||
fn escaped(s: &str, attribute: bool) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
push_escaped(&mut out, c, attribute);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `c` added to `out`, escaped as [`escaped`] says.
|
||||
fn push_escaped(out: &mut String, c: char, attribute: bool) {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'\r' => out.push_str(" "),
|
||||
'"' if attribute => out.push_str("""),
|
||||
'\'' if attribute => out.push_str("'"),
|
||||
'\t' if attribute => out.push_str("	"),
|
||||
'\n' if attribute => out.push_str(" "),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save Variable Library: the document's text variables and its data sets as a variable library in
|
||||
/// the layout [`parse_library`] reads, so Load Variable Library reads back the same text variables,
|
||||
/// data set names and text values. A text value is written as one `p` element per line. Variables
|
||||
/// of other kinds are left out with their values, named in `skipped` and in a warning. A text
|
||||
/// variable whose name can't name an element ([`element_name`]), a data set name or text value that
|
||||
/// holds a character XML can't hold, a name that Load Variable Library would read differently or
|
||||
/// refuse (white space at either end of a data set name, a name longer than `MAX_NAME` characters,
|
||||
/// a name used twice), and a library past the caps Load Variable Library reads are refused. With `path` the library is written to the file; without it the reply carries it as
|
||||
/// `xml`.
|
||||
pub(super) fn save_library(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.saveLibrary";
|
||||
let vars = &s.doc()?.doc.variables;
|
||||
if vars.variables.is_empty() {
|
||||
return Err(bad(C, "define a variable first"));
|
||||
}
|
||||
let (text, other): (Vec<&Variable>, Vec<&Variable>) = vars.variables.iter().partition(|v| v.kind == VariableKind::Text);
|
||||
if text.is_empty() {
|
||||
return Err(bad(C, "a variable library holds text variables, and the document has none"));
|
||||
}
|
||||
if let Some(name) = text.iter().map(|v| &v.name).find(|n| !element_name(n)) {
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.`"
|
||||
),
|
||||
));
|
||||
}
|
||||
// Load Variable Library refuses a name longer than `MAX_NAME` characters and a name declared
|
||||
// twice.
|
||||
let mut declared = BTreeSet::new();
|
||||
for v in &text {
|
||||
named(Some(&v.name), "varName", C)?;
|
||||
if !declared.insert(v.name.as_str()) {
|
||||
return Err(bad(C, format!("variable `{}` is already defined", v.name)));
|
||||
}
|
||||
}
|
||||
if !kinds.is_empty() {
|
||||
let kinds = kinds.iter().map(|(k, n)| format!("{k} ({n})")).collect::<Vec<_>>().join(", ");
|
||||
out["warning"] = json!(format!("variables that aren't text were left out: {kinds}"));
|
||||
if text.len() > MAX_LIBRARY_VARIABLES {
|
||||
return Err(bad(C, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables")));
|
||||
}
|
||||
if vars.datasets.len() > MAX_LIBRARY_DATASETS {
|
||||
return Err(bad(C, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets")));
|
||||
}
|
||||
fn text_value<'a>(ds: &'a DataSet, var: &str) -> Option<&'a str> {
|
||||
match ds.values.get(var) {
|
||||
Some(DataValue::Text(t)) => Some(t.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let mut xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
|
||||
<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\n\
|
||||
\t<!ENTITY ns_vars \"{NS_VARIABLES}\">\n\
|
||||
\t<!ENTITY ns_custom \"{NS_CUSTOM}\">\n\
|
||||
\t<!ENTITY ns_flows \"{NS_FLOWS}\">\n\
|
||||
]>\n\
|
||||
<svg>\n\
|
||||
<variableSets xmlns=\"&ns_vars;\">\n\
|
||||
\t<variableSet varSetName=\"binding1\" locked=\"none\">\n\
|
||||
\t\t<variables>\n"
|
||||
);
|
||||
for v in &text {
|
||||
xml += &format!("\t\t\t<variable varName=\"{}\" trait=\"textcontent\" category=\"&ns_flows;\"></variable>\n", v.name);
|
||||
}
|
||||
xml += "\t\t</variables>\n\t\t<v:sampleDataSets xmlns:v=\"&ns_vars;\" xmlns=\"&ns_custom;\">\n";
|
||||
// Load Variable Library caps the file and the text its entity references expand to: one
|
||||
// `&ns_flows;` per variable, two `&ns_vars;` and one `&ns_custom;`. The library stays within
|
||||
// the cap less what the references add.
|
||||
let grows = |value: &str, reference: &str| value.len().saturating_sub(reference.len());
|
||||
let growth = text.len() * grows(NS_FLOWS, "&ns_flows;") + 2 * grows(NS_VARIABLES, "&ns_vars;") + grows(NS_CUSTOM, "&ns_custom;");
|
||||
let room = MAX_LIBRARY_BYTES.saturating_sub(growth);
|
||||
let too_large = || bad(C, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"));
|
||||
let mut seen = BTreeSet::new();
|
||||
for ds in &vars.datasets {
|
||||
let name = &ds.name;
|
||||
// Load Variable Library reads a data set's name trimmed and at most `MAX_NAME` characters
|
||||
// long ([`named`]), and refuses a name declared twice.
|
||||
if named(Some(name), "dataSetName", C)? != *name {
|
||||
return Err(bad(C, format!("data set `{name}` starts or ends with white space, which a variable library can't hold")));
|
||||
}
|
||||
if !seen.insert(name.as_str()) {
|
||||
return Err(bad(C, format!("dataset `{name}` already exists")));
|
||||
}
|
||||
if !name.chars().all(xml_char) || text.iter().filter_map(|v| text_value(ds, &v.name)).any(|t| !t.chars().all(xml_char)) {
|
||||
return Err(bad(C, format!("data set `{name}` holds a control character, which a variable library can't hold")));
|
||||
}
|
||||
xml += &format!("\t\t\t<v:sampleDataSet dataSetName=\"{}\">\n", escaped(name, true));
|
||||
for v in &text {
|
||||
let Some(t) = text_value(ds, &v.name) else { continue };
|
||||
xml += &format!("\t\t\t\t<{}>\n", v.name);
|
||||
for line in t.split('\n') {
|
||||
xml += "\t\t\t\t\t<p>";
|
||||
// A value can be larger than the cap, so the text stops growing once it passes it.
|
||||
for c in line.chars() {
|
||||
push_escaped(&mut xml, c, false);
|
||||
if xml.len() > room {
|
||||
return Err(too_large());
|
||||
}
|
||||
}
|
||||
xml += "</p>\n";
|
||||
}
|
||||
xml += &format!("\t\t\t\t</{}>\n", v.name);
|
||||
}
|
||||
xml += "\t\t\t</v:sampleDataSet>\n";
|
||||
if xml.len() > room {
|
||||
return Err(too_large());
|
||||
}
|
||||
}
|
||||
xml += "\t\t</v:sampleDataSets>\n\t</variableSet>\n</variableSets>\n</svg>\n";
|
||||
if xml.len() > room {
|
||||
return Err(too_large());
|
||||
}
|
||||
let skipped: Vec<Value> = other.iter().map(|v| json!({"name": v.name, "kind": v.kind.label()})).collect();
|
||||
let mut out = json!({
|
||||
"variables": text.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), "datasets": vars.datasets.len(), "skipped": skipped,
|
||||
});
|
||||
if let Some(warning) = left_out_warning(other.iter().map(|v| v.kind.label())) {
|
||||
out["warning"] = json!(warning);
|
||||
}
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
let path = absolute_path(path);
|
||||
write_file(&path, xml.as_bytes())?;
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
None => out["xml"] = json!(xml),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -2096,6 +2096,230 @@ fn a_variable_library_expands_entities_of_plain_text() {
|
||||
assert_eq!(value, Some(vectorcraft_doc::DataValue::Text("x-y \"z\"-x&a;".into())));
|
||||
}
|
||||
|
||||
/// Save Variable Library writes what Load Variable Library reads back: the text variables in their
|
||||
/// order, every data set by name and in order, and each text value as it was, with several lines,
|
||||
/// markup characters, quotes, a carriage return and an empty value. Variables of other kinds are
|
||||
/// left out with their values, named in `skipped` and in the warning. The library is written while
|
||||
/// the variables are locked too, to a file with `path`.
|
||||
#[test]
|
||||
fn a_saved_variable_library_loads_back_the_text_variables_and_data_sets() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "Alice");
|
||||
for (name, kind) in [("Name", "text"), ("Shown", "visibility"), ("Note", "text"), ("Photo", "image"), ("Badge", "visibility")] {
|
||||
s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap();
|
||||
}
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "First", "values": {"Name": "Bravo", "Note": "One\nTwo & <three>\r\n'four' \"five\"", "Shown": true}}))
|
||||
.unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Say \"hi\" & <go>\tnow", "values": {"Name": "", "Photo": "a.png"}})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "Empty"})).unwrap();
|
||||
s.execute("variable.lock", &json!({"locked": true})).unwrap();
|
||||
let before = s.doc().unwrap().doc.variables.clone();
|
||||
|
||||
let saved = s.execute("variable.saveLibrary", &json!({})).unwrap();
|
||||
assert_eq!(saved["variables"], json!(["Name", "Note"]));
|
||||
assert_eq!(saved["datasets"], json!(3));
|
||||
assert_eq!(
|
||||
saved["skipped"],
|
||||
json!([{"name": "Shown", "kind": "visibility"}, {"name": "Photo", "kind": "image"}, {"name": "Badge", "kind": "visibility"}])
|
||||
);
|
||||
assert_eq!(saved["warning"], "variables that aren't text were left out: visibility (2), image (1)");
|
||||
assert_eq!(s.doc().unwrap().doc.variables, before, "saving changes nothing");
|
||||
|
||||
let mut other = session();
|
||||
let loaded = other.execute("variable.loadLibrary", &json!({"xml": saved["xml"]})).unwrap();
|
||||
assert_eq!((loaded["skipped"].clone(), loaded.get("warning")), (json!([]), None));
|
||||
let vars = &other.doc().unwrap().doc.variables;
|
||||
assert_eq!(vars.variables.iter().map(|v| (v.name.as_str(), v.kind.label())).collect::<Vec<_>>(), [("Name", "text"), ("Note", "text")]);
|
||||
let text_only: Vec<vectorcraft_doc::DataSet> = before
|
||||
.datasets
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let mut d = d.clone();
|
||||
d.values.retain(|k, _| k == "Name" || k == "Note");
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(vars.datasets, text_only);
|
||||
|
||||
let dir = Folder::new("vars-save-library");
|
||||
let path = dir.file("library.xml");
|
||||
let written = s.execute("variable.saveLibrary", &json!({"path": path})).unwrap();
|
||||
assert_eq!(written["path"], json!(path));
|
||||
assert!(written.get("xml").is_none());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), saved["xml"].as_str().unwrap());
|
||||
let mut from_file = session();
|
||||
from_file.execute("variable.loadLibrary", &json!({"path": path})).unwrap();
|
||||
assert_eq!(from_file.doc().unwrap().doc.variables.datasets, text_only);
|
||||
}
|
||||
|
||||
/// The library is the XML of the public grammar: a document type declaration with the namespace
|
||||
/// entities, `variableSets` › `variableSet` › `variables` with a `textcontent` variable per text
|
||||
/// variable, and `sampleDataSets` with a `sampleDataSet` per data set, holding an element per text
|
||||
/// variable it has a value for, with a `p` element per line. Markup characters, a carriage return,
|
||||
/// and a tab or quote in a data set name are escaped. A document with text variables and no data
|
||||
/// sets writes an empty `sampleDataSets`.
|
||||
#[test]
|
||||
fn a_saved_variable_library_has_the_library_layout() {
|
||||
let mut s = session();
|
||||
for (name, kind) in [("Name", "text"), ("Shown", "visibility"), ("Note", "text")] {
|
||||
s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap();
|
||||
}
|
||||
let head = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
|
||||
<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\" [\n\
|
||||
\t<!ENTITY ns_vars \"http://ns.adobe.com/Variables/1.0/\">\n\
|
||||
\t<!ENTITY ns_custom \"http://ns.adobe.com/GenericCustomNamespace/1.0/\">\n\
|
||||
\t<!ENTITY ns_flows \"http://ns.adobe.com/Flows/1.0/\">\n\
|
||||
]>\n\
|
||||
<svg>\n\
|
||||
<variableSets xmlns=\"&ns_vars;\">\n\
|
||||
\t<variableSet varSetName=\"binding1\" locked=\"none\">\n\
|
||||
\t\t<variables>\n\
|
||||
\t\t\t<variable varName=\"Name\" trait=\"textcontent\" category=\"&ns_flows;\"></variable>\n\
|
||||
\t\t\t<variable varName=\"Note\" trait=\"textcontent\" category=\"&ns_flows;\"></variable>\n\
|
||||
\t\t</variables>\n\
|
||||
\t\t<v:sampleDataSets xmlns:v=\"&ns_vars;\" xmlns=\"&ns_custom;\">\n";
|
||||
let tail = "\t\t</v:sampleDataSets>\n\t</variableSet>\n</variableSets>\n</svg>\n";
|
||||
let empty = s.execute("variable.saveLibrary", &json!({})).unwrap();
|
||||
assert_eq!(empty["xml"].as_str().unwrap(), format!("{head}{tail}"));
|
||||
assert_eq!(empty["datasets"], json!(0));
|
||||
|
||||
s.execute("dataset.new", &json!({"name": "A &\t\"B\"", "values": {"Name": "x\r\ny < z", "Shown": false}})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "C", "values": {"Note": ""}})).unwrap();
|
||||
let xml = s.execute("variable.saveLibrary", &json!({})).unwrap()["xml"].as_str().unwrap().to_string();
|
||||
let sets = "\t\t\t<v:sampleDataSet dataSetName=\"A &	"B"\">\n\
|
||||
\t\t\t\t<Name>\n\
|
||||
\t\t\t\t\t<p>x </p>\n\
|
||||
\t\t\t\t\t<p>y < z</p>\n\
|
||||
\t\t\t\t</Name>\n\
|
||||
\t\t\t</v:sampleDataSet>\n\
|
||||
\t\t\t<v:sampleDataSet dataSetName=\"C\">\n\
|
||||
\t\t\t\t<Note>\n\
|
||||
\t\t\t\t\t<p></p>\n\
|
||||
\t\t\t\t</Note>\n\
|
||||
\t\t\t</v:sampleDataSet>\n";
|
||||
assert_eq!(xml, format!("{head}{sets}{tail}"));
|
||||
}
|
||||
|
||||
/// Save Variable Library refuses a document without variables or without text variables, a text
|
||||
/// variable whose name can't name an element, a data set name or text value with a character XML
|
||||
/// can't hold, a name that Load Variable Library would read differently or refuse, and a library
|
||||
/// past the caps that Load Variable Library reads. A refused save writes no file.
|
||||
#[test]
|
||||
fn save_variable_library_refuses_what_a_library_cant_hold() {
|
||||
let dir = Folder::new("vars-save-refused");
|
||||
let path = dir.file("library.xml");
|
||||
let refused = |s: &mut Session, why: &str| {
|
||||
let e = s.execute("variable.saveLibrary", &json!({"path": path})).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{why}: {e}");
|
||||
assert!(!std::path::Path::new(&path).exists(), "{why}: a file was written");
|
||||
};
|
||||
|
||||
let mut s = session();
|
||||
refused(&mut s, "define a variable first");
|
||||
s.execute("variable.define", &json!({"name": "Shown", "kind": "visibility"})).unwrap();
|
||||
refused(&mut s, "holds text variables, and the document has none");
|
||||
|
||||
for name in ["First Name", "1st", "a:b", "x/y", "-dash"] {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
|
||||
refused(&mut s, &format!("variable `{name}` can't be saved in a variable library"));
|
||||
}
|
||||
for name in ["Ünïcode", "_x", "a.b-c_d", "名前"] {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "A", "values": {name: "v"}})).unwrap();
|
||||
let xml = s.execute("variable.saveLibrary", &json!({})).unwrap()["xml"].clone();
|
||||
let mut other = session();
|
||||
other.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap();
|
||||
assert_eq!(other.doc().unwrap().doc.variables.datasets, s.doc().unwrap().doc.variables.datasets, "{name}");
|
||||
}
|
||||
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "bell \u{7}"}})).unwrap();
|
||||
refused(&mut s, "data set `A` holds a control character");
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "B\u{1}"})).unwrap();
|
||||
refused(&mut s, "holds a control character");
|
||||
|
||||
// A document opened from a file can hold names that the commands don't make, and Load Variable
|
||||
// Library would read them differently or refuse them.
|
||||
let renamed = |dataset: Option<&str>, variable: Option<&str>| {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "A"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "B"})).unwrap();
|
||||
let vars = &mut std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).variables;
|
||||
if let Some(name) = dataset {
|
||||
vars.datasets[1].name = name.to_string();
|
||||
}
|
||||
if let Some(name) = variable {
|
||||
vars.variables[1].name = name.to_string();
|
||||
}
|
||||
s
|
||||
};
|
||||
for name in [" B", "B\t", "B\n"] {
|
||||
refused(&mut renamed(Some(name), None), "starts or ends with white space");
|
||||
}
|
||||
refused(&mut renamed(Some(""), None), "missing `dataSetName`");
|
||||
refused(&mut renamed(Some(&"d".repeat(256)), None), "`dataSetName` is longer than 255 characters");
|
||||
refused(&mut renamed(Some("A"), None), "dataset `A` already exists");
|
||||
refused(&mut renamed(None, Some(&"v".repeat(256))), "`varName` is longer than 255 characters");
|
||||
refused(&mut renamed(None, Some("Name")), "variable `Name` is already defined");
|
||||
let mut s = renamed(Some(&"d".repeat(255)), Some(&"v".repeat(255)));
|
||||
let xml = s.execute("variable.saveLibrary", &json!({})).unwrap()["xml"].clone();
|
||||
let mut other = session();
|
||||
other.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap();
|
||||
assert_eq!(other.doc().unwrap().doc.variables.datasets, s.doc().unwrap().doc.variables.datasets);
|
||||
|
||||
let mut s = session();
|
||||
for i in 0..257 {
|
||||
s.execute("variable.define", &json!({"name": format!("V{i}"), "kind": "text"})).unwrap();
|
||||
}
|
||||
refused(&mut s, "more than 256 variables");
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
for i in 0..1001 {
|
||||
s.execute("dataset.new", &json!({"name": format!("D{i}")})).unwrap();
|
||||
}
|
||||
refused(&mut s, "more than 1000 data sets");
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "x".repeat(9 << 20)}})).unwrap();
|
||||
refused(&mut s, "larger than 8388608 bytes");
|
||||
}
|
||||
|
||||
/// A library near the size cap is saved only when Load Variable Library reads it back, after its
|
||||
/// entity references expand: each text variable's `&ns_flows;`, two `&ns_vars;` and one
|
||||
/// `&ns_custom;` grow by 20, 25 and 36 bytes.
|
||||
#[test]
|
||||
fn a_saved_variable_library_near_the_size_cap_loads_back() {
|
||||
let mut s = session();
|
||||
for i in 0..200 {
|
||||
s.execute("variable.define", &json!({"name": format!("V{i}"), "kind": "text"})).unwrap();
|
||||
}
|
||||
s.execute("dataset.new", &json!({"name": "A", "values": {"V0": ""}})).unwrap();
|
||||
let base = s.execute("variable.saveLibrary", &json!({})).unwrap()["xml"].as_str().unwrap().len();
|
||||
let room = (8 << 20) - (200 * 20 + 2 * 25 + 36);
|
||||
s.execute("dataset.set", &json!({"name": "A", "values": {"V0": "x".repeat(room - base + 1)}})).unwrap();
|
||||
let e = s.execute("variable.saveLibrary", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("larger than 8388608 bytes"), "{e}");
|
||||
s.execute("dataset.set", &json!({"name": "A", "values": {"V0": "x".repeat(room - base)}})).unwrap();
|
||||
let xml = s.execute("variable.saveLibrary", &json!({})).unwrap()["xml"].clone();
|
||||
assert_eq!(xml.as_str().map(str::len), Some(room));
|
||||
let mut other = session();
|
||||
other.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap();
|
||||
assert_eq!(other.doc().unwrap().doc.variables.datasets, s.doc().unwrap().doc.variables.datasets);
|
||||
// The same library with the value grown to a file of exactly 8 MiB doesn't load back.
|
||||
let full = xml.as_str().unwrap().replacen("<p>x", &format!("<p>{}x", "x".repeat((8 << 20) - room)), 1);
|
||||
assert_eq!(full.len(), 8 << 20);
|
||||
let e = other.execute("variable.loadLibrary", &json!({"xml": full})).unwrap_err().to_string();
|
||||
assert!(e.contains("entities expand to more than"), "{e}");
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user