diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 4ce49f867..df578ed79 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -3,8 +3,9 @@ //! One template document produces many variants: bind a type object to a `text` //! variable, anything to a `visibility` variable, and a linked image to an `image` //! variable, put a row of values in each dataset, and `dataset.select` swaps the -//! whole document to that row in one undo step. Agents reach every command through -//! `run_command`, like all engine commands. +//! whole document to that row in one undo step. `dataset.import` / `dataset.export` +//! move those rows as CSV (header: `name` then variable names). Agents reach every +//! command through `run_command`, like all engine commands. use std::collections::{BTreeMap, BTreeSet}; @@ -12,12 +13,18 @@ use serde_json::{Value, json}; use vectorcraft_doc::{DataSet, DataValue, NodeId, NodeKind, Variable, VariableKind, Variables}; use super::edit::selected_roots; +use super::fileio::{absolute_path, read_file_head, write_file}; use super::textedit::set_plain_text; use super::*; /// The longest variable or dataset name, in characters (names arrive from MCP and files). const MAX_NAME: usize = 255; +/// Caps on a datasets CSV (paths and MCP payloads are untrusted). +const MAX_CSV_BYTES: usize = 1_048_576; +const MAX_CSV_ROWS: usize = 1_000; +const MAX_CSV_COLS: usize = 256; + fn var_name(p: &Value, cmd: &str) -> Result { named(str_param(p, "name"), "name", cmd) } @@ -492,6 +499,223 @@ fn dataset_select(s: &mut Session, p: &Value) -> Result { apply_dataset(s, &var_name(p, "dataset.select")?) } +/// Bytes of a datasets CSV from `path`, `data`, `dataBase64` or `csv`. +fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result> { + let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64"), str_param(p, "csv")) { + // One byte past the cap is enough to refuse the file below. + (Some(path), ..) => read_file_head(&absolute_path(path), MAX_CSV_BYTES + 1)?, + (None, Some(text), ..) => text.as_bytes().to_vec(), + (None, None, Some(b64), _) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?, + (None, None, None, Some(csv)) => csv.as_bytes().to_vec(), + _ => return Err(bad(cmd, "give `path`, `data`, `dataBase64` or `csv`")), + }; + if bytes.len() > MAX_CSV_BYTES { + return Err(bad(cmd, format!("CSV is larger than {MAX_CSV_BYTES} bytes"))); + } + Ok(bytes) +} + +/// Parse a datasets CSV into rows of cells (no header interpretation yet): commas between cells, +/// `"` quotes a cell (doubled inside), which may then hold commas and line breaks, as +/// [`csv_escape`] writes them. Blank lines are skipped. +fn parse_datasets_csv(text: &str, cmd: &str) -> Result>> { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let (mut rows, mut row, mut cur) = (Vec::new(), Vec::::new(), String::new()); + let (mut in_quotes, mut line) = (false, 1usize); + let mut chars = text.chars().peekable(); + loop { + let c = chars.next(); + match c { + Some('"') if in_quotes => { + if chars.peek() == Some(&'"') { + cur.push('"'); + chars.next(); + } else { + in_quotes = false; + } + } + Some('"') if cur.is_empty() => in_quotes = true, + Some(',') if !in_quotes => row.push(std::mem::take(&mut cur)), + Some('\r') if !in_quotes => {} + Some('\n') | None if !in_quotes => { + row.push(std::mem::take(&mut cur)); + if row.iter().any(|c| !c.trim().is_empty()) { + rows.push(std::mem::take(&mut row)); + // +1 for the header. + if rows.len() > MAX_CSV_ROWS + 1 { + return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows"))); + } + } + row.clear(); + if c.is_none() { + return Ok(rows); + } + line += 1; + } + None => return Err(bad(cmd, format!("line {line}: unclosed quote"))), + Some(c) => { + line += usize::from(c == '\n'); + cur.push(c); + } + } + if row.len() > MAX_CSV_COLS { + return Err(bad(cmd, format!("line {line}: more than {MAX_CSV_COLS} columns"))); + } + } +} + +fn csv_escape(s: &str) -> String { + if s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } +} + +/// A visibility cell: true/false, 1/0, yes/no (ASCII, case-insensitive). +fn parse_visible_cell(s: &str, var: &str, cmd: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Ok(true), + "false" | "0" | "no" => Ok(false), + _ => Err(bad(cmd, format!("value for `{var}` must be true or false"))), + } +} + +fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result> { + let t = cell.trim(); + if t.is_empty() { + return Ok(None); + } + Ok(Some(match kind { + VariableKind::Text => DataValue::Text(t.to_string()), + VariableKind::Image => DataValue::Image(t.to_string()), + VariableKind::Visibility => DataValue::Visible(parse_visible_cell(t, var, cmd)?), + })) +} + +fn value_to_cell(v: &DataValue) -> String { + match v { + DataValue::Text(t) | DataValue::Image(t) => t.clone(), + DataValue::Visible(b) => if *b { "true" } else { "false" }.to_string(), + } +} + +/// Build the CSV of every dataset (header `name` then variables in definition order). +fn datasets_to_csv(vars: &Variables) -> String { + let mut out = String::from("name"); + for v in &vars.variables { + out.push(','); + out.push_str(&csv_escape(&v.name)); + } + out.push('\n'); + for ds in &vars.datasets { + out.push_str(&csv_escape(&ds.name)); + for v in &vars.variables { + out.push(','); + if let Some(val) = ds.values.get(&v.name) { + out.push_str(&csv_escape(&value_to_cell(val))); + } + } + out.push('\n'); + } + out +} + +/// Import datasets from CSV: first column `name`, remaining headers are variable names. +/// Existing rows of the same name are replaced; new names are appended. One undo step. +fn dataset_import(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.import"; + let bytes = datasets_csv_bytes(p, C)?; + let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?; + let rows = parse_datasets_csv(text, C)?; + let Some(header) = rows.first() else { + return Err(bad(C, "CSV is empty")); + }; + if header.first().map(|h| h.trim().eq_ignore_ascii_case("name")) != Some(true) { + return Err(bad(C, "first header cell must be `name`")); + } + let (kinds, existing_names) = { + let st = s.doc()?; + let kinds: BTreeMap = st.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect(); + let existing: Vec = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); + (kinds, existing) + }; + let mut columns: Vec<(usize, String, VariableKind)> = Vec::new(); + for (i, h) in header.iter().enumerate().skip(1) { + let name = h.trim(); + if name.is_empty() { + return Err(bad(C, format!("empty header in column {}", i + 1))); + } + let kind = kinds.get(name).copied().ok_or_else(|| bad(C, format!("no variable named `{name}`")))?; + columns.push((i, name.to_string(), kind)); + } + if rows.len() == 1 { + return Err(bad(C, "CSV has a header but no data rows")); + } + let mut parsed: Vec = Vec::new(); + for (r_i, row) in rows.iter().enumerate().skip(1) { + let line = r_i + 1; + let raw_name = row.first().map(|c| c.trim()).unwrap_or(""); + let name = if raw_name.is_empty() { + // Same numbering as Capture Data Set when a row leaves the name blank. + (1..) + .map(|n| format!("Data Set {n}")) + .find(|n| parsed.iter().all(|d| d.name != *n) && !existing_names.iter().any(|e| e == n)) + .unwrap_or_else(|| format!("Data Set {line}")) + } else { + named(Some(raw_name), "name", C)? + }; + if parsed.iter().any(|d| d.name == name) { + return Err(bad(C, format!("line {line}: dataset `{name}` appears twice"))); + } + let mut values = BTreeMap::new(); + for &(col, ref var, kind) in &columns { + let cell = row.get(col).map(String::as_str).unwrap_or(""); + match cell_to_value(kind, cell, var, C) { + Ok(Some(v)) => { + values.insert(var.clone(), v); + } + Ok(None) => {} + Err(_) => return Err(bad(C, format!("line {line}: value for `{var}` must match its variable kind"))), + } + } + parsed.push(DataSet { name, values }); + } + let names: Vec = parsed.iter().map(|d| d.name.clone()).collect(); + let (imported, updated) = s.edit("Import Data Sets", |d, _| { + let mut imported = 0usize; + let mut updated = 0usize; + for row in parsed { + if let Some(existing) = d.variables.datasets.iter_mut().find(|x| x.name == row.name) { + *existing = row; + updated += 1; + } else { + d.variables.datasets.push(row); + imported += 1; + } + } + Ok((imported, updated)) + })?; + Ok(json!({"imported": imported, "updated": updated, "names": names})) +} + +/// Export every dataset as CSV (`name` then variables). With `path`, write the file; else return `{csv}`. +fn dataset_export(s: &mut Session, p: &Value) -> Result { + const C: &str = "dataset.export"; + let st = s.doc()?; + if st.doc.variables.variables.is_empty() { + return Err(bad(C, "define a variable first")); + } + let csv = datasets_to_csv(&st.doc.variables); + if csv.len() > MAX_CSV_BYTES { + return Err(bad(C, format!("CSV would be larger than {MAX_CSV_BYTES} bytes"))); + } + match str_param(p, "path") { + Some(path) => { + let path = absolute_path(path); + write_file(&path, csv.as_bytes())?; + Ok(json!({"path": path, "datasets": st.doc.variables.datasets.len()})) + } + None => Ok(json!({"csv": csv, "datasets": st.doc.variables.datasets.len()})), + } +} + fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result { let st = s.doc()?; let n = st.doc.variables.datasets.len(); @@ -657,5 +881,23 @@ pub fn specs() -> Vec { cmd!("dataset.prev", "Previous Data Set", ["Window", "Variables"], None, "{} apply the previous dataset, wrapping", has_doc, |s, _| { dataset_step(s, "dataset.prev", false) }), + cmd!( + "dataset.import", + "Import Data Sets…", + ["Window", "Variables"], + None, + "{path? | data? | dataBase64? | csv?} load datasets from CSV (header: name, then variable names; one row per dataset). Rows whose name already exists are replaced → {imported, updated, names}", + has_doc, + dataset_import + ), + cmd!( + query "dataset.export", + "Export Data Sets…", + ["Window", "Variables"], + None, + "{path?} write every dataset as CSV (header: name, then variables); without path → {csv, datasets}", + has_doc, + dataset_export + ), ] } diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index ce6f83840..90e48b3f5 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -877,3 +877,73 @@ fn image_datasets_swap_pixels_and_keep_bounds() { s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); assert!(s.execute("variable.makeImageDynamic", &json!({})).is_err(), "type is not an image"); } + +/// A text value with commas, quotes and line breaks comes back from the exported CSV as it went out. +#[test] +fn datasets_csv_keeps_line_breaks_in_quoted_cells() { + let mut s = session(); + let t = text(&mut s, "One"); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + let value = "Line one, \"quoted\"\nLine two"; + s.execute("dataset.new", &json!({"name": "A", "values": {"Name": value}})).unwrap(); + let csv = s.execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string(); + s.execute("dataset.delete", &json!({"names": ["A"]})).unwrap(); + s.execute("dataset.import", &json!({"csv": csv})).unwrap(); + let sets = &s.doc().unwrap().doc.variables.datasets; + assert_eq!(sets.len(), 1); + assert_eq!(sets[0].values.get("Name"), Some(&vectorcraft_doc::DataValue::Text(value.into()))); + assert!(s.execute("dataset.import", &json!({"csv": "name,Name\nX,\"open"})).is_err(), "unclosed quote"); +} + +/// Import / export datasets as CSV: header `name` then variables; round-trip keeps values. +#[test] +fn datasets_csv_round_trips_and_rejects_bad_input() { + let dir = Folder::new("vars-csv"); + let red_path = dir.file("red.png"); + let blue_path = dir.file("blue.png"); + write(&red_path, &png(8, 8, RED)); + write(&blue_path, &png(8, 8, BLUE)); + + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + let img = place(&mut s, &red_path); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Photo", "kind": "image"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Photo", "ids": [img.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "Alice", "Show": true, "Photo": red_path}})).unwrap(); + s.execute("dataset.new", &json!({"name": "B", "values": {"Name": "Bob", "Show": false, "Photo": blue_path}})).unwrap(); + + let exported = s.execute("dataset.export", &json!({})).unwrap(); + let csv = exported["csv"].as_str().unwrap().to_string(); + assert!(csv.starts_with("name,Name,Show,Photo\n"), "{csv}"); + assert!(csv.contains("Alice") && csv.contains("Bob") && csv.contains("false"), "{csv}"); + + // Clear rows, import again, apply B. + s.execute("dataset.delete", &json!({"names": ["A", "B"]})).unwrap(); + let imp = s.execute("dataset.import", &json!({"csv": csv})).unwrap(); + assert_eq!((imp["imported"].as_u64(), imp["updated"].as_u64()), (Some(2), Some(0))); + s.execute("dataset.select", &json!({"name": "B"})).unwrap(); + assert_eq!(plain(&s, t), "Bob"); + assert!(!s.doc().unwrap().doc.node(r).unwrap().visible); + assert_eq!(image(&s, img).link.as_ref().map(|l| l.path.as_str()), Some(blue_path.as_str())); + + // Re-import updates in place; unknown variable and bad visibility are errors. + let again = s.execute("dataset.import", &json!({"csv": csv})).unwrap(); + assert_eq!((again["imported"].as_u64(), again["updated"].as_u64()), (Some(0), Some(2))); + assert!(s.execute("dataset.import", &json!({"csv": "name,Ghost\nX,y"})).is_err()); + assert!(s.execute("dataset.import", &json!({"csv": "name,Show\nX,maybe"})).is_err()); + assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "first column must be name"); + + let out = dir.file("sets.csv"); + s.execute("dataset.export", &json!({"path": out})).unwrap(); + let from_file = s.execute("dataset.import", &json!({"path": out})).unwrap(); + assert_eq!(from_file["updated"].as_u64(), Some(2)); + + // One undo undoes the last import's replacements. + s.execute("edit.undo", &json!({})).unwrap(); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index aa35ef67c..3ba58aba1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4912,3 +4912,5 @@ mark-style Japanese Japonské @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" umísťuje lineární nebo radiální přechod, ne volný přechod @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" umísťuje samotný přechod: vynechte `start` a `end` (ty ho už umísťují v souřadnicích dokumentu) Make Image Dynamic Nastavit obraz jako dynamický + Import Data Sets… Importovat sady dat… + Export Data Sets… Exportovat sady dat… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index de50bd098..f1cfc0c00 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4448,3 +4448,5 @@ theme Appearance Erscheinungsbild @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" platziert einen linearen oder radialen Verlauf, keinen Freiform-Verlauf @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" platziert den Verlauf selbst: lassen Sie `start` und `end` weg (sie platzieren ihn bereits in Dokumentkoordinaten) Make Image Dynamic Bild dynamisch machen + Import Data Sets… Datensätze importieren… + Export Data Sets… Datensätze exportieren… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cfa784cb7..c3edf0638 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4854,3 +4854,5 @@ mark-style Japanese Japonesas @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" coloca un degradado lineal o radial, no uno de forma libre @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" coloca el propio degradado: omita `start` y `end` (ya lo colocan en coordenadas del documento) Make Image Dynamic Hacer imagen dinámica + Import Data Sets… Importar conjuntos de datos… + Export Data Sets… Exportar conjuntos de datos… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7011c9a70..f545f3cb5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4911,3 +4911,5 @@ theme Appearance Apparence @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" place un dégradé linéaire ou radial, pas un dégradé de forme libre @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" place le dégradé lui-même : omettez `start` et `end` (ils le placent déjà en coordonnées du document) 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… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6c04e27da..66958ee39 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4879,3 +4879,5 @@ imageTrace Outline Körvonal @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" lineáris vagy sugaras színátmenetet helyez el, nem szabad formájút @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" magát a színátmenetet helyezi el: hagyja el a `start` és `end` értéket (ezek már a dokumentum koordinátáiban helyezik el) Make Image Dynamic Kép dinamikussá tétele + Import Data Sets… Adatkészletek importálása… + Export Data Sets… Adatkészletek exportálása… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9682e96e9..632a28554 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4488,3 +4488,5 @@ lighting Light Luce @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" posiziona una sfumatura lineare o radiale, non una sfumatura a forma libera @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" posiziona la sfumatura stessa: ometti `start` e `end` (la posizionano già in coordinate del documento) Make Image Dynamic Rendi immagine dinamica + Import Data Sets… Importa set di dati… + Export Data Sets… Esporta set di dati… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c9be1b370..189661b4b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4483,3 +4483,5 @@ lighting Light ライト @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" は線形または円形グラデーションを配置します。フリーグラデーションは配置できません @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" はグラデーション自体を配置します: `start` と `end` は省略してください (これらはすでにドキュメント座標で配置を指定します) Make Image Dynamic 画像を動的にする + Import Data Sets… データセットを読み込み… + Export Data Sets… データセットを書き出し… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ee49ab563..beecbcd93 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4452,3 +4452,5 @@ lighting Light 광원 @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection"은 선형 또는 방사형 그레이디언트를 배치합니다. 자유형 그레이디언트는 배치할 수 없습니다 @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection"은 그레이디언트 자체를 배치합니다: `start`와 `end`를 빼세요 (이미 문서 좌표로 배치합니다) Make Image Dynamic 이미지를 동적으로 만들기 + Import Data Sets… 데이터 세트 가져오기… + Export Data Sets… 데이터 세트 내보내기… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a70c9151d..3dcb72816 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4756,3 +4756,5 @@ mark-style Japanese Japońskie @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" umieszcza gradient liniowy lub radialny, a nie dowolny @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" umieszcza sam gradient: pomiń `start` i `end` (one już umieszczają go we współrzędnych dokumentu) Make Image Dynamic Uczyń obraz dynamicznym + Import Data Sets… Importuj zestawy danych… + Export Data Sets… Eksportuj zestawy danych… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3a4439b84..125a9f836 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -873,6 +873,7 @@ Export As… Exportar Como… Export CSS Exportar CSS Export CSS… Exportar CSS… + Export Data Sets… Exportar Conjuntos de Dados… Export DXF Exportar DXF Export Document Exportar Documento Export EPS Exportar EPS @@ -1139,6 +1140,7 @@ Image: Imagem: Images: Imagens: Import Keyboard Shortcuts… Importar Atalhos de Teclado… + Import Data Sets… Importar Conjuntos de Dados… Import PDF Importar PDF Import PDF Presets Importar Predefinidos de PDF Import Perspective Grid Presets Importar Predefinidos de Grade de Perspectiva diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7a19d0e3b..4021e4e65 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4484,3 +4484,5 @@ lighting Light Свет @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" размещает линейный или радиальный градиент, а не произвольный @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" сам размещает градиент: уберите `start` и `end` (они уже задают его в координатах документа) Make Image Dynamic Сделать изображение динамическим + Import Data Sets… Импортировать наборы данных… + Export Data Sets… Экспортировать наборы данных… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 18cf79d25..7f63ec4f7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4468,3 +4468,5 @@ lighting Light Світло @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" розміщує лінійний або радіальний градієнт, а не довільний @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" сам розміщує градієнт: приберіть `start` і `end` (вони вже задають його в координатах документа) Make Image Dynamic Зробити зображення динамічним + Import Data Sets… Імпортувати набори даних… + Export Data Sets… Експортувати набори даних… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6add00133..f0194d76d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4468,3 +4468,5 @@ theme Appearance Giao diện @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" đặt dải chuyển màu tuyến tính hoặc xuyên tâm, không phải dạng tự do @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" tự đặt dải chuyển màu: hãy bỏ `start` và `end` (chúng đã đặt nó theo tọa độ tài liệu) 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… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 11a7ed60f..79d4d9afc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4029,3 +4029,5 @@ lighting Light 光源 Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封闭开放轮廓的圆形端部。部分旋转的切割侧面保持开放。 Distance 距离 Make Image Dynamic 使图像动态化 + Import Data Sets… 导入数据集… + Export Data Sets… 导出数据集… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121d3c0d6..053e7b656 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3816,3 +3816,5 @@ lighting Light 光源 Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封閉開放輪廓的圓形端部。部分旋轉的切割側面保持開放。 Distance 距離 Make Image Dynamic 使影像動態化 + Import Data Sets… 匯入資料集… + Export Data Sets… 匯出資料集… diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 2d7ad65b1..84990d9a5 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -293,6 +293,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { delete_active_dataset(app); } ui.separator(); + if widgets::menu_item(ui, tl!("Import Data Sets…"), true, false) { + import_datasets(app); + } + if widgets::menu_item(ui, tl!("Export Data Sets…"), !vars.is_empty(), false) { + export_datasets(app); + } + 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) { select_bound_object(app); @@ -302,6 +309,24 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Variables panel: Import Data Sets… — pick a CSV and run `dataset.import`. +fn import_datasets(app: &mut VectorcraftApp) { + let pick = crate::FilePick { filters: vec![("CSV", &["csv", "txt"])], ..Default::default() }; + let Some(path) = crate::picks::open(app, &pick) else { return }; + if let Err(e) = app.run("dataset.import", json!({"path": path})) { + app.status(e); + } +} + +/// Variables panel: Export Data Sets… — pick a path and run `dataset.export`. +fn export_datasets(app: &mut VectorcraftApp) { + let pick = crate::FilePick { name: "datasets.csv".into(), filters: vec![("CSV", &["csv"])], ..Default::default() }; + let Some(path) = crate::picks::save(app, &pick) else { return }; + if let Err(e) = app.run("dataset.export", json!({"path": path})) { + app.status(e); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/gaps.md b/docs/gaps.md index 2831c36ae..87f1007bb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -28,7 +28,7 @@ stage ([ROADMAP.md](../ROADMAP.md)). | 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 16–27 | no | [target-app-parity.md](target-app-parity.md) | | 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) | | 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) | -| 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 6–12 | no | [target-app-parity.md](target-app-parity.md) | +| 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 4–8 | no | [target-app-parity.md](target-app-parity.md) | | 15 | [G15 Performance budgets and a GPU renderer](#g15-performance-budgets-and-a-gpu-renderer) | 15–25 | budgets only | [hardware-parity.md](hardware-parity.md) | | 16 | [G16 Localization: the three missing key languages](#g16-localization-the-three-missing-key-languages) | 55–90 | no | [localization-parity.md](localization-parity.md) | | 17 | [G17 Ecosystem: third-party plug-ins](#g17-ecosystem-third-party-plug-ins) | 20–45 | no | — | @@ -266,12 +266,13 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G6 Automation and scripting - **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, graph variable + kinds. Actions, Variables (text, visibility and image), dataset CSV import/export and full agent control over kinds, dataset import/export. Actions, Variables (text, visibility and image) and full agent control over - **Exposed in MCP:** `add_text {vertical: true}` uses the existing vertical point, area and path type commands. Horizontal writing remains the default; the broader automation gap and estimate are unchanged. - **Missing:** a scripting surface over the command registry (Illustrator: JavaScript), Batch, image and graph variable kinds, dataset import/export. Actions, Variables (text and visibility) and full agent control over MCP are done. -- **Estimate:** 6–12 h. +- **Estimate:** 4–8 h. ## G15 Performance budgets and a GPU renderer @@ -319,6 +320,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G6: dataset CSV import/export (`dataset.import` / `dataset.export`); remaining: scripting surface, Batch, graph variables; estimate 6–12 → 4–8 h | | 2026-10-14 | minor | G6: image variables (`VariableKind::Image`, `variable.makeImageDynamic`, path values on `dataset.select` keeping bounds); remaining: scripting surface, Batch, graph variables, dataset import/export; estimate 8–14 → 6–12 h | | 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged | | 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index acf79aafa..a98423c52 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -988,7 +988,8 @@ already walks Image Trace. Short agent entry points: `brush.setCurrent {name}`, `brush.options {name, params?}`, `brush.freehand`, `object.expandBrush {ids?}`. - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image}`, `variable.bind {variable, ids?}`, `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.unbind`, - `dataset.new` / `dataset.set` / `dataset.select` (image values are file path strings; apply keeps object bounds). + `dataset.new` / `dataset.set` / `dataset.select` (image values are file path strings; apply keeps object bounds), + `dataset.import {path|data|dataBase64|csv}` / `dataset.export {path?}` (CSV: header `name` then variable names). - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 75f16b3d2..773e4be6c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -89,6 +89,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | anti-aliasing ghost borders (#983); TIFF choices and settings are implemented | 2–4 | estimated | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity | 2–4 | estimated | +| Automation (Actions, Variables, scripting, batch) | 3 | 65% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, graph variables | 4–8 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 62% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, graph variables, dataset import/export | 6–12 | estimated | | Automation (Actions, Variables, scripting, batch) | 3 | 60% | a scripting surface (Illustrator: JavaScript/ExtendScript), Batch, image and graph variables, dataset import/export | 8–14 | estimated; MCP `add_text` exposes existing vertical type; score and hours unchanged | | UI chrome (panels, Properties, workspaces, preferences) | 6 | 80% | Properties per context, ~16 Preferences options stored but unused (#394), panels outside the window, SVG Interactivity, context menus beyond the canvas, accessibility | 13–20 | estimated; macOS save Format menu implemented (#866), score and hours unchanged; Eyedropper cursor and precise setting checked (#1269), OS visual QA unverified; numeric-field focus and normal text drags regression-tested (#1326, in part); movements below the click threshold still select all; artboard navigation synchronizes the tool selection before destructive actions | @@ -336,6 +337,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 | +| Automation | 3 | 72% | scripting surface, batch (Variables: text/visibility/image variables bound to objects, an object may carry several, dataset rows applied in one undo step, CSV import/export, panel and Window › Variables with dialogs; graph kinds open) | 4–7 | | Automation | 3 | 68% | scripting surface, batch (Variables: text/visibility/image variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; graph kinds and dataset import/export open) | 5–9 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | | UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 80% | macOS native menu dismissal fixed (M14.8); Variables, Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done; remaining: SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas, about 14 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, devices…) | 13–20 | @@ -413,7 +415,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Automation:** - Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a variable whose objects are locked or inside a hidden or locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - - 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, 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 (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. - 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. @@ -653,6 +655,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Automation: dataset CSV import/export; headline Automation ~62% → ~65%, detail ~68% → ~72%, hours 6–12 → 4–8 / 5–9 → 4–7 (estimated) | | 2026-10-14 | minor | Automation: image variables (`kind: image`, `variable.makeImageDynamic`, path values on datasets that keep object bounds); headline Automation ~60% → ~62%, detail ~65% → ~68%, hours 8–14 → 6–12 / 6–10 → 5–9 (estimated) | | 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged | | 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |