mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
M11: Data Merge (CSV or JSON rows → duplicated template spreads with <<Field>> replacement, one undo step)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bd0cae2068
commit
7cd23dd284
@@ -0,0 +1,162 @@
|
||||
//! Data Merge (Window → Utilities → Data Merge): a template spread with `<<Field>>` placeholders
|
||||
//! in its stories is duplicated once per CSV record and the placeholders are replaced. One undo step.
|
||||
|
||||
use designcraft_doc::{Content, ItemId, SpreadRef};
|
||||
use designcraft_geom::Vec2;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"data.merge",
|
||||
"Create Merged Document…",
|
||||
["Window", "Utilities", "Data Merge"],
|
||||
None,
|
||||
"{csv (header row first) | rows: [{field: value}], spread?: template spread index (default 0)} → {records, pages}",
|
||||
has_doc,
|
||||
merge
|
||||
),
|
||||
cmd!(query "data.fields", "Data Merge Fields", [], None, "{csv} → field names", has_doc, |_, p| {
|
||||
let rows = parse_csv(str_param(p, "csv").unwrap_or(""));
|
||||
Ok(json!(rows.first().cloned().unwrap_or_default()))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// RFC 4180-ish CSV: quoted fields, doubled quotes, CRLF/LF.
|
||||
pub fn parse_csv(s: &str) -> Vec<Vec<String>> {
|
||||
let mut rows = Vec::new();
|
||||
let mut row = Vec::new();
|
||||
let mut field = String::new();
|
||||
let mut quoted = false;
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if quoted {
|
||||
match c {
|
||||
'"' if chars.peek() == Some(&'"') => {
|
||||
field.push('"');
|
||||
chars.next();
|
||||
}
|
||||
'"' => quoted = false,
|
||||
c => field.push(c),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' if field.is_empty() => quoted = true,
|
||||
',' => row.push(std::mem::take(&mut field)),
|
||||
'\r' => {}
|
||||
'\n' => {
|
||||
row.push(std::mem::take(&mut field));
|
||||
if !(row.len() == 1 && row[0].is_empty()) {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
} else {
|
||||
row.clear();
|
||||
}
|
||||
}
|
||||
c => field.push(c),
|
||||
}
|
||||
}
|
||||
if !field.is_empty() || !row.is_empty() {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let records: Vec<Vec<(String, String)>> = if let Some(rows) = p.get("rows").and_then(Value::as_array) {
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
r.as_object()
|
||||
.map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))).collect())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
let rows = parse_csv(str_param(p, "csv").ok_or_else(|| bad("data.merge", "give `csv` or `rows`"))?);
|
||||
let Some((head, body)) = rows.split_first() else { return Err(bad("data.merge", "empty CSV")) };
|
||||
body.iter().map(|r| head.iter().cloned().zip(r.iter().cloned().chain(std::iter::repeat(String::new()))).collect()).collect()
|
||||
};
|
||||
if records.is_empty() {
|
||||
return Err(bad("data.merge", "no records"));
|
||||
}
|
||||
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
s.edit(|d, sel| {
|
||||
let template = d.spreads.get(si).cloned().ok_or_else(|| bad("data.merge", "no such spread"))?;
|
||||
let tpl_ids: Vec<ItemId> = template.items.iter().map(|i| i.id).collect();
|
||||
let parent = template.pages.first().and_then(|p| p.parent);
|
||||
let src = d.clone();
|
||||
// Spreads to fill: the template itself for record 0, then one copy per further record.
|
||||
let mut targets: Vec<Vec<ItemId>> = vec![tpl_ids.clone()];
|
||||
for _ in 1..records.len() {
|
||||
let after = d.page_count() - 1;
|
||||
d.insert_pages(Some(after), template.pages.len(), parent)?;
|
||||
let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("data.merge", "insert failed"))?;
|
||||
let ids = super::object::duplicate_from(d, &src, &tpl_ids, SpreadRef::Doc(nsi), Vec2::ZERO)?;
|
||||
targets.push(ids);
|
||||
}
|
||||
for (rec, ids) in records.iter().zip(&targets) {
|
||||
let mut stories = Vec::new();
|
||||
for id in ids {
|
||||
if let Some(it) = d.item(*id) {
|
||||
it.walk(&mut |i| {
|
||||
if let Content::Text(tf) = &i.content
|
||||
&& !stories.contains(&tf.story)
|
||||
{
|
||||
stories.push(tf.story);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for sid in stories {
|
||||
let Some(st) = d.story_mut(sid) else { continue };
|
||||
for (k, v) in rec {
|
||||
let pat = format!("<<{k}>>");
|
||||
while let Some(pos) = st.text.find(&pat) {
|
||||
st.replace(pos..pos + pat.len(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*sel = Default::default();
|
||||
Ok(json!({"records": records.len(), "pages": d.page_count()}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn csv_parsing() {
|
||||
let r = parse_csv("Name,Title\n\"Ada, Countess\",\"Analyst \"\"No. 1\"\"\"\r\nGrace,Admiral\n");
|
||||
assert_eq!(r, vec![vec!["Name", "Title"], vec!["Ada, Countess", "Analyst \"No. 1\""], vec!["Grace", "Admiral"]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_duplicates_pages_and_fills_fields() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"facingPages": false})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [36, 36, 400, 100], "content": "text", "text": "Dear <<Name>>, welcome aboard as <<Title>>."}))
|
||||
.unwrap();
|
||||
let r = s.execute("data.merge", &json!({"csv": "Name,Title\nAda,Analyst\nGrace,Admiral\nKatherine,Mathematician\n"})).unwrap();
|
||||
assert_eq!(r["records"], 3);
|
||||
assert_eq!(r["pages"], 3);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut texts: Vec<String> = d.stories.values().map(|st| st.text.clone()).collect();
|
||||
texts.sort();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![
|
||||
"Dear Ada, welcome aboard as Analyst.".to_string(),
|
||||
"Dear Grace, welcome aboard as Admiral.".to_string(),
|
||||
"Dear Katherine, welcome aboard as Mathematician.".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 2); // create frame + merge
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The command registry. Ids follow InDesign's menu structure.
|
||||
|
||||
mod datamerge;
|
||||
mod edit;
|
||||
mod export;
|
||||
mod file;
|
||||
@@ -114,6 +115,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(layout::specs());
|
||||
v.extend(inspect::specs());
|
||||
v.extend(find::specs());
|
||||
v.extend(datamerge::specs());
|
||||
v.extend(interactive::specs());
|
||||
v.extend(preflight::specs());
|
||||
v
|
||||
|
||||
Reference in New Issue
Block a user