mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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:
committed by
bflatastic
parent
a38c824066
commit
5ef090a0c2
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user