Merge pull request #1929 from Messhias/feat/g6-dataset-csv

G6: dataset CSV import/export
This commit is contained in:
BFlat
2026-10-16 05:12:50 +07:00
committed by GitHub
21 changed files with 379 additions and 6 deletions
+244 -2
View File
@@ -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<String> {
named(str_param(p, "name"), "name", cmd)
}
@@ -492,6 +499,223 @@ fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
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<Vec<u8>> {
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<Vec<Vec<String>>> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let (mut rows, mut row, mut cur) = (Vec::new(), Vec::<String>::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<bool> {
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<Option<DataValue>> {
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<Value> {
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<String, VariableKind> = st.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect();
let existing: Vec<String> = 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<DataSet> = 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<String> = 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<Value> {
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<Value> {
let st = s.doc()?;
let n = st.doc.variables.datasets.len();
@@ -657,5 +881,23 @@ pub fn specs() -> Vec<CommandSpec> {
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
),
]
}
+70
View File
@@ -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();
}
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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… データセットを書き出し…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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… 데이터 세트 내보내기…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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… Экспортировать наборы данных…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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… Експортувати набори даних…
Can't render this file because it contains an unexpected character in line 718 and column 40.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 3726 and column 10.
+2
View File
@@ -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… 导出数据集…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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… 匯出資料集…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+25
View File
@@ -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::*;
+4 -2
View File
@@ -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. |
+2 -1
View File
@@ -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`.
+4 -1
View File
@@ -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. |