G6: dataset CSV import/export with PT-BR UI labels

Load and save variable datasets as CSV for data-merge pipelines, and
translate the new Variables panel commands in Brazilian Portuguese.

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	docs/target-app-parity.md
This commit is contained in:
Fabio William Conceição
2026-10-16 04:50:07 +07:00
committed by bflatastic
parent a38c824066
commit 5ef090a0c2
7 changed files with 334 additions and 6 deletions
+245 -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, 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,224 @@ 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")) {
(Some(path), ..) => read_file(&absolute_path(path))?,
(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)
}
/// Split one CSV line into cells (commas; `"` quotes, doubled inside).
fn split_csv_line(line: &str) -> std::result::Result<Vec<String>, String> {
let mut cells = Vec::new();
let mut cur = String::new();
let mut chars = line.chars().peekable();
let mut in_quotes = false;
while let Some(c) = chars.next() {
match c {
'"' if in_quotes => match chars.peek() {
Some('"') => {
cur.push('"');
chars.next();
}
_ => in_quotes = false,
},
'"' if !in_quotes && cur.is_empty() => in_quotes = true,
',' if !in_quotes => {
cells.push(std::mem::take(&mut cur));
}
_ => cur.push(c),
}
}
if in_quotes {
return Err("unclosed quote".into());
}
cells.push(cur);
if cells.len() > MAX_CSV_COLS {
return Err(format!("more than {MAX_CSV_COLS} columns"));
}
Ok(cells)
}
/// Parse a datasets CSV into rows of cells (no header interpretation yet).
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 = Vec::new();
for (i, raw) in text.lines().enumerate() {
let line = raw.trim_end_matches('\r');
if line.trim().is_empty() {
continue;
}
let cells = split_csv_line(line).map_err(|e| bad(cmd, format!("line {}: {e}", i + 1)))?;
rows.push(cells);
if rows.len() > MAX_CSV_ROWS + 1 {
// +1 for the header.
return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows")));
}
}
Ok(rows)
}
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 +882,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
),
]
}