Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,308 @@
|
||||
//! 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<String>,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 CSV whose first row names the columns.
|
||||
pub fn read_csv(path: &Path) -> io::Result<Table> {
|
||||
let mut reader = csv::Reader::from_path(path).map_err(io::Error::other)?;
|
||||
let headers: Vec<String> = 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::<Result<Vec<Vec<String>>, _>>()
|
||||
.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<Self>,
|
||||
then: impl FnOnce(&mut Self, SharedString, PathBuf, &mut Context<Self>) + '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<Self>) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<String>, Vec<String>),
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
log::info!("doc: previewing merge record {}", at + 1);
|
||||
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<Self>,
|
||||
) {
|
||||
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<PathBuf> = (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<PathBuf>, &'static str, PathBuf),
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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| {
|
||||
fill(&mut worker, &doc, &table.headers, row)?;
|
||||
doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension)?;
|
||||
worker.close_doc(doc)?;
|
||||
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 count = count.to_string();
|
||||
let text =
|
||||
Docs::text(cx, "doc.merge.done", &[("n", &count), ("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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use gpui::{Entity, TestAppContext};
|
||||
|
||||
use super::*;
|
||||
use crate::tests::{
|
||||
blank, commit, docs, settle, settled, text_of, type_into, with_window, written,
|
||||
};
|
||||
|
||||
fn tabs(app: &gpui::App, docs: &Entity<Docs>) -> usize {
|
||||
docs.read(app).core.tabs.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_fill_previews_and_files() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let source_id = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "Dear ");
|
||||
let csv = dir.path().join("people.csv");
|
||||
std::fs::write(&csv, "Name,City\nAda,London\n«City»,上海\n").expect("the csv writes");
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
type_into(&mut cx, &docs, " of ");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("City", cx)));
|
||||
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.preview_record(source_id, &table, 1, cx)
|
||||
})
|
||||
});
|
||||
let name = cx.update(|app| {
|
||||
Docs::text(
|
||||
app,
|
||||
"doc.merge.preview_name",
|
||||
&[("name", &source), ("n", "2")],
|
||||
)
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
tabs(app, &docs) == 2
|
||||
&& docs.read(app).core.tabs[1].name == name
|
||||
&& docs.read(app).core.tabs[1].view.read(app).settled()
|
||||
});
|
||||
let preview = dir.path().join("preview.odt");
|
||||
let id = cx.update(|app| docs.read(app).core.tabs[1].id);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, preview.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &preview, None);
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, &preview).contains("Dear «City» of 上海"),
|
||||
"the preview takes queued input and keeps values literal"
|
||||
);
|
||||
cx.update(|app| docs.update(app, |docs, _| docs.core.active = Some(docs.core.tabs[0].id)));
|
||||
let out = dir.path().join("out");
|
||||
std::fs::create_dir(&out).expect("the folder");
|
||||
let target = |n: usize, extension: &str| out.join(format!("{source} {n}.{extension}"));
|
||||
std::fs::write(target(2, "docx"), "mine").expect("a taken name");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.generate(source_id, table.clone(), false, out.clone(), cx)
|
||||
})
|
||||
});
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(target(2, "docx")).expect("reads"),
|
||||
"mine",
|
||||
"a taken name stops the merge"
|
||||
);
|
||||
assert!(!target(1, "docx").exists(), "nothing is written");
|
||||
std::fs::remove_file(target(2, "docx")).expect("frees the name");
|
||||
let staged = cx.update(|app| {
|
||||
let docs = docs.read(app);
|
||||
docs.core
|
||||
.work
|
||||
.join(format!("doc-{}", docs.core.next))
|
||||
.join("record.pdf")
|
||||
});
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.generate(source_id, table.clone(), true, out.clone(), cx)
|
||||
})
|
||||
});
|
||||
std::fs::write(target(1, "pdf"), "EXTERNAL").expect("a file arrives late");
|
||||
let worker = cx.update(|app| docs.read(app).core.worker.clone());
|
||||
settle(&cx, |_| staged.exists() && worker.try_lock().is_ok());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(target(1, "pdf")).expect("reads"),
|
||||
"EXTERNAL",
|
||||
"a file that arrives late stays"
|
||||
);
|
||||
assert!(
|
||||
!target(2, "pdf").exists(),
|
||||
"the merge stops at the taken name"
|
||||
);
|
||||
std::fs::remove_file(target(1, "pdf")).expect("frees the name");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.generate(source_id, table, true, out.clone(), cx)
|
||||
})
|
||||
});
|
||||
settle(&cx, |_| target(2, "pdf").exists());
|
||||
let first = std::fs::read(target(1, "pdf")).expect("the pdf reads");
|
||||
assert!(first.starts_with(b"%PDF"), "a PDF is written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_opens_a_marked_copy_and_keeps_both_files() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let id = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "A new line");
|
||||
settled(&cx, &docs);
|
||||
let older = dir.path().join("older.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, older.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &older, None);
|
||||
commit(&mut cx, &docs, " grows");
|
||||
let before = std::fs::read(&older).expect("the older file reads");
|
||||
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.compare(id, older.clone(), cx)));
|
||||
settle(&cx, |app| {
|
||||
tabs(app, &docs) == 2 && docs.read(app).side == crate::docs::Side::Review
|
||||
});
|
||||
let review = cx.update(|app| docs.read(app).review.clone());
|
||||
let tab = cx.update(|app| docs.read(app).core.tabs[1].view.clone());
|
||||
cx.update(|app| {
|
||||
let doc = docs.read(app).core.tabs[1].doc.clone();
|
||||
review.update(app, |review, cx| review.show(Some((&tab, &doc)), cx))
|
||||
});
|
||||
settle(&cx, |app| !review.read(app).changes.is_empty());
|
||||
let name = cx.update(|app| docs.read(app).core.tabs[1].name.to_string());
|
||||
assert!(name.contains(&source), "the copy names its source: {name}");
|
||||
let copy = dir.path().join("copy.odt");
|
||||
let into = cx.update(|app| docs.read(app).core.tabs[1].id);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(into, copy.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, ©, None);
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, ©).contains(" grows"),
|
||||
"the copy takes the queued input"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&older).expect("reads"),
|
||||
before,
|
||||
"the older file stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merge_keeps_its_source_when_the_tab_changes() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let other = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceB ");
|
||||
let source = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceA ");
|
||||
let csv = dir.path().join("one.csv");
|
||||
std::fs::write(&csv, "Name\nAda\n").expect("the csv writes");
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.insert_field("Name", cx);
|
||||
docs.preview_record(source, &table, 0, cx);
|
||||
docs.core.active = Some(other);
|
||||
})
|
||||
});
|
||||
let name = cx.update(|app| {
|
||||
let source = docs.read(app).tab(source).expect("a tab").name.clone();
|
||||
Docs::text(
|
||||
app,
|
||||
"doc.merge.preview_name",
|
||||
&[("name", &source), ("n", "1")],
|
||||
)
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
let docs = docs.read(app);
|
||||
// A filled preview is a modified copy.
|
||||
docs.core.tabs.len() == 3 && docs.core.tabs[2].name == name && docs.core.tabs[2].modified
|
||||
});
|
||||
let preview = dir.path().join("preview.odt");
|
||||
let id = cx.update(|app| docs.read(app).core.tabs[2].id);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, preview.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &preview, None);
|
||||
let text = text_of(&mut cx, &docs, &preview);
|
||||
assert!(text.contains("SourceA Ada"), "the preview keeps its source");
|
||||
let stem = cx.update(|app| docs.read(app).tab(source).expect("a tab").name.to_string());
|
||||
let out = dir.path().join("out");
|
||||
std::fs::create_dir(&out).expect("the folder");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.core.active = Some(other);
|
||||
docs.generate(source, table, false, out.clone(), cx)
|
||||
})
|
||||
});
|
||||
let target = out.join(format!("{stem} 1.docx"));
|
||||
let worker = cx.update(|app| docs.read(app).core.worker.clone());
|
||||
settle(&cx, |_| target.exists() && worker.try_lock().is_ok());
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, &target).contains("SourceA Ada"),
|
||||
"the file keeps its source"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_generations_keep_their_own_templates() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let mut sources = Vec::new();
|
||||
for text in ["SourceA ", "SourceB "] {
|
||||
sources.push(blank(&mut cx, &docs));
|
||||
type_into(&mut cx, &docs, text);
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
settled(&cx, &docs);
|
||||
}
|
||||
let csv = dir.path().join("one.csv");
|
||||
std::fs::write(&csv, "Name\nAda\n").expect("the csv writes");
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
let outs: Vec<_> = (0..2).map(|n| dir.path().join(format!("out{n}"))).collect();
|
||||
for (source, out) in sources.iter().zip(&outs) {
|
||||
std::fs::create_dir(out).expect("the folder");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.generate(*source, table.clone(), false, out.clone(), cx)
|
||||
})
|
||||
});
|
||||
}
|
||||
let names: Vec<String> = cx.update(|app| {
|
||||
let docs = docs.read(app);
|
||||
sources
|
||||
.iter()
|
||||
.map(|id| docs.tab(*id).expect("a tab").name.to_string())
|
||||
.collect()
|
||||
});
|
||||
let targets: Vec<_> = outs
|
||||
.iter()
|
||||
.zip(&names)
|
||||
.map(|(out, name)| out.join(format!("{name} 1.docx")))
|
||||
.collect();
|
||||
let worker = cx.update(|app| docs.read(app).core.worker.clone());
|
||||
settle(&cx, |_| {
|
||||
targets.iter().all(|target| target.exists()) && worker.try_lock().is_ok()
|
||||
});
|
||||
for (target, want) in targets.iter().zip(["SourceA Ada", "SourceB Ada"]) {
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, target).contains(want),
|
||||
"{want} lands"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user