//! Mail merge work: the CSV, filled copies, previews and files. use std::{ collections::hash_map::RandomState, hash::BuildHasher, io, path::{Path, PathBuf}, sync::Arc, }; use ely_gpui_component::primitives::Severity; use ely_lok::{Doc, Worker}; use gpui::{AppContext as _, Context, SharedString, TaskExt as _}; use ely_office::files::{self, file_name}; use ely_office::prelude::*; use ely_office::{Source, in_window}; use crate::docs::Docs; /// A CSV: its name, column names and rows. #[derive(Clone, Debug)] pub struct Table { pub name: SharedString, pub headers: Vec, pub rows: Vec>, } /// Reads a UTF-8 CSV whose first row names the columns. pub fn read_csv(path: &Path) -> io::Result { let mut reader = csv::Reader::from_path(path).map_err(io::Error::other)?; let headers: Vec = reader .headers() .map_err(io::Error::other)? .iter() .map(|header| header.trim().to_string()) .collect(); if let Some(at) = headers.iter().position(String::is_empty) { return Err(io::Error::other(format!("column {} has no name", at + 1))); } // A name with a guillemet would blur the marker bounds. if let Some(header) = headers.iter().find(|header| header.contains(['«', '»'])) { return Err(io::Error::other(format!("column {header} holds « or »"))); } for (at, header) in headers.iter().enumerate() { if headers[..at].contains(header) { return Err(io::Error::other(format!("two columns are named {header}"))); } } let rows = reader .records() .map(|record| record.map(|record| record.iter().map(str::to_string).collect())) .collect::>, _>>() .map_err(io::Error::other)?; if rows.is_empty() { return Err(io::Error::other("the file has no rows")); } Ok(Table { name: file_name(path), headers, rows, }) } /// The marker a field leaves in the text. pub fn marker(header: &str) -> String { format!("«{header}»") } /// Replaces every field marker in `doc` with `row`'s values. fn fill(worker: &mut Worker, doc: &Doc, headers: &[String], row: &[String]) -> io::Result<()> { // Markers become tokens first, so values are never rescanned. let nonce = BuildHasher::hash_one(&RandomState::new(), 0); let token = |at: usize| format!("\u{e000}{nonce:x}-{at}\u{e001}"); let mut replace = |from: &str, to: &str| { worker.run( doc, "find.replace", &[from, "false", "3", "0", "0", "1", to], ) }; for (at, header) in headers.iter().enumerate() { replace(&marker(header), &token(at))?; } for (at, value) in row.iter().enumerate() { replace(&token(at), value)?; } Ok(()) } /// Copies `from` to `to`, never over an existing file. fn publish(from: &Path, to: &Path) -> io::Result<()> { let mut target = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(to)?; io::copy(&mut std::fs::File::open(from)?, &mut target)?; target.sync_all() } impl Docs { /// Saves tab `source` as the template copy, in the background. fn template( &mut self, source: u64, into: u64, cx: &mut Context, then: impl FnOnce(&mut Self, SharedString, PathBuf, &mut Context) + 'static, ) { let Some(name) = self.tab(source).map(|tab| tab.name.clone()) else { return self.merge_failed(&io::Error::other("the source document closed"), cx); }; let copy = match files::work_dir(&self.core.work, into) { Ok(dir) => dir.join("merge.odt"), Err(error) => return self.merge_failed(&error, cx), }; let url = ely_lok::file_url(©); self.read_in( source, move |worker, doc| doc.save_as(worker, &url, "odt"), move |docs, saved, _, cx| match saved { Ok(()) => then(docs, name, copy, cx), Err(error) => docs.merge_failed(&error, cx), }, cx, ); } fn merge_failed(&self, error: &io::Error, cx: &mut Context) { let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]); self.toast(text, Severity::Danger, cx); } /// Opens record `at` filled in, as a new tab. pub(crate) fn preview_record( &mut self, source: u64, table: &Table, at: usize, cx: &mut Context, ) { if !self.settled(source, cx) { let table = table.clone(); return self.when_settled(source, move |docs, _, cx| { docs.preview_record(source, &table, at, cx) }); } let (headers, row) = (table.headers.clone(), table.rows[at].clone()); // The preview's tab and folder, reserved now. let into = self.core.next; self.core.next += 1; self.template(source, into, cx, move |docs, name, copy, cx| { docs.preview_copy((into, at), name, copy, (headers, row), cx) }); } /// Opens the template copy and fills it with one row. fn preview_copy( &mut self, (into, at): (u64, usize), name: SharedString, copy: PathBuf, (headers, row): (Vec, Vec), cx: &mut Context, ) { log::info!("doc: previewing merge record {}", at + 1); self.merge.update(cx, |merge, _| merge.previews.insert(into)); self.load_into(into, Source::Copy(copy), cx); self.core.waiting.push(( into, Box::new(move |docs, _, cx| { let Some(tab) = docs.tab_mut(into) else { return; }; let n = (at + 1).to_string(); tab.name = Docs::text(cx, "doc.merge.preview_name", &[("name", &name), ("n", &n)]); cx.notify(); docs.read_in( into, move |worker, doc| fill(worker, doc, &headers, &row), |docs, filled, _, cx| { if let Err(error) = filled { docs.merge_failed(&error, cx); } }, cx, ); }), )); } /// Writes one file per row into `folder`; refuses to overwrite. pub(crate) fn generate( &mut self, source: u64, table: Table, pdf: bool, folder: PathBuf, cx: &mut Context, ) { if !self.settled(source, cx) { return self.when_settled(source, move |docs, _, cx| { docs.generate(source, table, pdf, folder, cx) }); } let Some(name) = self.tab(source).map(|tab| tab.name.clone()) else { return self.merge_failed(&io::Error::other("the source document closed"), cx); }; let stem = Path::new(name.as_ref()).file_stem().map_or_else( || name.to_string(), |stem| stem.to_string_lossy().into_owned(), ); let extension = if pdf { "pdf" } else { "docx" }; let targets: Vec = (1..=table.rows.len()) .map(|n| folder.join(format!("{stem} {n}.{extension}"))) .collect(); let taken = targets .iter() .find(|target| target.exists()) .map(|target| file_name(target)); if let Some(taken) = taken { let text = Docs::text(cx, "doc.merge.exists", &[("name", &taken)]); return self.toast(text, Severity::Danger, cx); } // Each run keeps its own template and staging folder. let into = self.core.next; self.core.next += 1; self.template(source, into, cx, move |docs, _, template, cx| { docs.write_records(template, (table, targets, extension, folder), cx) }); } /// Writes each row's file from the template copy. fn write_records( &mut self, template: PathBuf, (table, targets, extension, folder): (Table, Vec, &'static str, PathBuf), cx: &mut Context, ) { log::info!("doc: merging {} records", targets.len()); let worker = Arc::clone(&self.core.worker); cx.spawn(async move |this, cx| { let written = cx .background_spawn(async move { for (n, (row, target)) in table.rows.iter().zip(&targets).enumerate() { // One record at a time: open documents keep their turns. let mut worker = worker.lock().expect("the worker lock"); let staged = template.with_file_name(format!("record.{extension}")); let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| { let saved = fill(&mut worker, &doc, &table.headers, row) .and_then(|()| doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension)); // Closed even when this record failed. let closed = worker.close_doc(doc); saved.and(closed)?; publish(&staged, target) }); made.map_err(|error| (n + 1, error))?; } Ok(targets.len()) }) .await; in_window(&this, cx, |docs, _, cx| { let folder = folder.display().to_string(); let text = match written { Ok(count) => { let n = count.to_string(); let text = ely_ui::i18n::counted(cx, "doc.merge.done", count as u64, &[("n", &n), ("folder", &folder)]); return docs.toast(text, Severity::Success, cx); } Err((n, error)) => Docs::text( cx, "doc.merge.failed", &[("n", &n.to_string()), ("error", &error.to_string())], ), }; docs.toast(text, Severity::Danger, cx); }) }) .detach_and_log_err(cx); } } #[cfg(test)] mod tests { use super::*; #[test] fn a_csv_reads_or_says_why_not() { let dir = tempfile::tempdir().expect("a temp dir"); let path = dir.path().join("people.csv"); std::fs::write(&path, "\u{feff}Name,City\nAda,\"London, UK\"\nLin,上海\n").expect("writes"); let table = read_csv(&path).expect("reads"); assert_eq!(table.headers, ["Name", "City"]); assert_eq!(table.rows[0], ["Ada", "London, UK"]); assert_eq!(table.rows[1][1], "上海"); for (body, why) in [ ("Name,\nAda,x\n", "no name"), ("Name,Name\na,b\n", "two columns"), ("Name,«Name»\na,b\n", "holds « or »"), ("Name,City\n", "no rows"), ("Name,City\nAda\n", "found record"), ] { std::fs::write(&path, body).expect("writes"); let error = read_csv(&path).expect_err(body).to_string(); assert!(error.contains(why), "{body}: {error}"); } std::fs::write(&path, b"Name\n\xff\xfe\n").expect("writes"); assert!(read_csv(&path).is_err(), "bytes that are not UTF-8 fail"); } }