mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 22:49:48 -06:00
428 lines
21 KiB
Rust
428 lines
21 KiB
Rust
//! Background export: the Export dialog, File → Export with Preset and Export with Previous hand
|
||
//! their batch to a worker thread so the window stays responsive. Photos are prepared on the UI
|
||
//! thread ([`lightcraft_engine::export::prepare_export`]: cheap, needs the session) and rendered,
|
||
//! encoded and written on the worker (several side by side: [`run_batch`]); its row in the
|
||
//! activity stack shows the count, the file in progress and ✕ (issue #345).
|
||
//!
|
||
//! Needs [`crate::Services::write_shared`] (a thread-safe writer); without it (web) the batch runs
|
||
//! synchronously as before.
|
||
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::mpsc::{Receiver, channel};
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
use lightcraft_engine::activity::{Cancel, TaskGuard};
|
||
use lightcraft_engine::export::{AfterExport, Destination, ExportOptions, PreparedExport, run_batch};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::LightcraftApp;
|
||
|
||
pub struct ExportTask {
|
||
pub total: usize,
|
||
/// Photos started so far and the file in progress.
|
||
pub progress: Arc<Mutex<(usize, String)>>,
|
||
pub cancel: Arc<AtomicBool>,
|
||
/// The files (per-photo results), what [`AfterExport`] did with them (`applePhotos`) and
|
||
/// whether the export was cancelled (as the worker saw it when the batch ended).
|
||
rx: Receiver<Result<(Vec<Value>, Option<Value>, bool), String>>,
|
||
/// The export's row in the activity stack (`activity.cancel` sets `cancel`).
|
||
guard: TaskGuard,
|
||
}
|
||
|
||
impl ExportTask {
|
||
/// `{total, done, current}` for `ui.inspect`.
|
||
pub fn status(&self) -> Value {
|
||
let (done, current) = self.progress.lock().map(|g| g.clone()).unwrap_or_default();
|
||
json!({"total": self.total, "done": done, "current": current})
|
||
}
|
||
}
|
||
|
||
/// Start exporting `items` in the background, then do `after` with the files written (Add to Apple
|
||
/// Photos). Errors per photo are collected, not fatal.
|
||
pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOptions, to: Destination, after: AfterExport) -> Result<Value, String> {
|
||
if app.export.is_some() {
|
||
return Err("an export is already running".into());
|
||
}
|
||
let write = app.services.write_shared.clone().ok_or("no background writer")?;
|
||
let total = items.len();
|
||
let progress = Arc::new(Mutex::new((0, String::new())));
|
||
let cancel = Arc::new(AtomicBool::new(false));
|
||
let (tx, rx) = channel();
|
||
let (p, c) = (progress.clone(), cancel.clone());
|
||
let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));
|
||
guard.progress(0, total as u64);
|
||
let row = guard.handle();
|
||
// translated here: the language is the UI thread's, and the worker would show English
|
||
let adding = crate::i18n::tr("Adding to Apple Photos…").to_string();
|
||
let work = move || {
|
||
let mut w = |path: &str, bytes: &[u8]| write(path, bytes);
|
||
let r = run_batch(items, &opts, &to, &mut w, &|path| std::path::Path::new(path).exists(), false, &mut |done, name| {
|
||
if let Ok(mut g) = p.lock() {
|
||
*g = (done, name.to_string());
|
||
}
|
||
!c.load(Ordering::Relaxed)
|
||
});
|
||
let r = r.map(|files| {
|
||
if !after.is_none() {
|
||
// Photos can't be stopped once asked: no ✕ from here, before the last look at
|
||
// the cancel flag, so a cancel either stops the export before Photos or is refused
|
||
row.set_cancellable(false);
|
||
if let Ok(mut g) = p.lock() {
|
||
g.1 = adding;
|
||
}
|
||
}
|
||
let cancelled = c.load(Ordering::Relaxed);
|
||
let photos = after.run(&files, cancelled);
|
||
(files, photos, cancelled)
|
||
});
|
||
let _ = tx.send(r);
|
||
};
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
std::thread::Builder::new().name("export".into()).spawn(work).map_err(|e| e.to_string())?;
|
||
#[cfg(target_arch = "wasm32")]
|
||
work();
|
||
app.export = Some(ExportTask { total, progress, cancel, rx, guard });
|
||
Ok(json!({"background": true, "total": total}))
|
||
}
|
||
|
||
/// Export a single paginated PDF with the same activity row and cancellation as image exports.
|
||
pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Value, String> {
|
||
if app.export.is_some() {
|
||
return Err("an export is already running".into());
|
||
}
|
||
let path = params.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("missing path")?.to_string();
|
||
app.session.check_write_target(&path)?;
|
||
let guard = app.session.original_guard();
|
||
let prepared = lightcraft_engine::contact_sheet::prepare(&mut app.session, params)?;
|
||
let total = prepared.len();
|
||
let write = app.services.write_shared.clone();
|
||
let progress = Arc::new(Mutex::new((0, String::new())));
|
||
let cancel = Arc::new(AtomicBool::new(false));
|
||
let (p, c) = (progress.clone(), cancel.clone());
|
||
let (tx, rx) = channel();
|
||
let work = move || {
|
||
let result = prepared
|
||
.run(&mut |done, name| {
|
||
if let Ok(mut g) = p.lock() {
|
||
*g = (done, name.to_string());
|
||
}
|
||
!c.load(Ordering::Relaxed)
|
||
})
|
||
.and_then(|doc| {
|
||
if c.load(Ordering::Relaxed) {
|
||
return Err("Contact sheet cancelled".into());
|
||
}
|
||
guard.check(std::path::Path::new(&path))?;
|
||
match write {
|
||
Some(write) => write(&path, &doc.bytes)?,
|
||
None => lightcraft_engine::export::write_file(&path, &doc.bytes)?,
|
||
}
|
||
Ok(vec![json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len(), "contactSheet": true})])
|
||
});
|
||
// a PDF isn't something Photos imports: contact sheets never add to Apple Photos
|
||
let _ = tx.send(result.map(|files| (files, None, c.load(Ordering::Relaxed))));
|
||
};
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
std::thread::Builder::new().name("contact-sheet".into()).spawn(work).map_err(|e| e.to_string())?;
|
||
#[cfg(target_arch = "wasm32")]
|
||
work();
|
||
let row = app.session.activity.start("export", "Exporting contact sheet", Cancel::Flag(cancel.clone()));
|
||
row.progress(0, total as u64);
|
||
app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });
|
||
Ok(json!({"background": true, "total": total}))
|
||
}
|
||
|
||
/// Print the session's current print (`print.get`) to a PDF in the background, with the same
|
||
/// activity row and cancellation as the other exports.
|
||
pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, String> {
|
||
use lightcraft_engine::print::{compose, output};
|
||
if app.export.is_some() {
|
||
return Err("an export is already running".into());
|
||
}
|
||
let path = params.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("missing path")?.to_string();
|
||
app.session.check_write_target(&path)?;
|
||
let guard = app.session.original_guard();
|
||
let settings = app.session.print.clone();
|
||
let ids = app.session.targets(params).map_err(|e| e.to_string())?;
|
||
let prepared = compose::prepare(&mut app.session, &settings, &ids)?;
|
||
let total = prepared.len();
|
||
let title = std::path::Path::new(&path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string();
|
||
let write = app.services.write_shared.clone();
|
||
let progress = Arc::new(Mutex::new((0, String::new())));
|
||
let cancel = Arc::new(AtomicBool::new(false));
|
||
let (p, c) = (progress.clone(), cancel.clone());
|
||
let (tx, rx) = channel();
|
||
let photos = ids.len();
|
||
let work = move || {
|
||
let result = prepared
|
||
.run(&mut |done, name| {
|
||
if let Ok(mut g) = p.lock() {
|
||
*g = (done, name.to_string());
|
||
}
|
||
!c.load(Ordering::Relaxed)
|
||
})
|
||
.and_then(|pages| {
|
||
if c.load(Ordering::Relaxed) {
|
||
return Err("Print cancelled".into());
|
||
}
|
||
let bytes = output::write(&pages, &settings.job, &title)?;
|
||
guard.check(std::path::Path::new(&path))?;
|
||
match write {
|
||
Some(write) => write(&path, &bytes)?,
|
||
None => lightcraft_engine::export::write_file(&path, &bytes)?,
|
||
}
|
||
Ok(vec![json!({"path": path, "pages": pages.len(), "photos": photos, "bytes": bytes.len(), "print": true})])
|
||
});
|
||
let _ = tx.send(result.map(|files| (files, None, c.load(Ordering::Relaxed))));
|
||
};
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
std::thread::Builder::new().name("print".into()).spawn(work).map_err(|e| e.to_string())?;
|
||
#[cfg(target_arch = "wasm32")]
|
||
work();
|
||
let row = app.session.activity.start("export", "Printing to PDF", Cancel::Flag(cancel.clone()));
|
||
row.progress(0, total as u64);
|
||
app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });
|
||
Ok(json!({"background": true, "total": total}))
|
||
}
|
||
|
||
/// The toast's account of [`AfterExport::run`]'s Apple Photos outcome.
|
||
pub fn apple_photos_note(outcome: &Value) -> String {
|
||
if let Some(e) = outcome.get("error").and_then(Value::as_str) {
|
||
return crate::i18n::tr_format!("not added to Apple Photos: {e}", e = e);
|
||
}
|
||
if let Some(why) = outcome.get("skipped").and_then(Value::as_str) {
|
||
if outcome.get("cancelled").and_then(Value::as_bool) == Some(true) {
|
||
return crate::i18n::tr("not added to Apple Photos (cancelled)").to_string();
|
||
}
|
||
return crate::i18n::tr_format!("not added to Apple Photos: {e}", e = why);
|
||
}
|
||
let n = outcome.get("imported").and_then(Value::as_u64).unwrap_or(0);
|
||
let mut m = match outcome.get("album").and_then(Value::as_str) {
|
||
Some(album) => crate::i18n::tr_format!("{n} added to Apple Photos album “{album}”", n = n, album = album),
|
||
None => crate::i18n::tr_format!("{n} added to Apple Photos", n = n),
|
||
};
|
||
if let Some(w) = outcome.get("warning").and_then(Value::as_str) {
|
||
m += &format!(" ({w})");
|
||
}
|
||
m
|
||
}
|
||
|
||
/// Per frame: when an Apple Photos import that no export task reports finishes (`app.export`
|
||
/// without `background`, `export.addToPhotos`), say how it went.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub fn poll_photos(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||
let jobs = app.session.apple_photos_imports.all();
|
||
if jobs.iter().any(|j| !j.finished()) {
|
||
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
||
}
|
||
for job in jobs {
|
||
if job.take_announcement() {
|
||
let outcome = job.json();
|
||
let msg = apple_photos_note(&outcome);
|
||
if outcome.get("error").is_some() {
|
||
app.toast_for(ctx, msg, 15.0);
|
||
} else {
|
||
app.toast(ctx, msg);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// No Apple Photos on the web.
|
||
#[cfg(target_arch = "wasm32")]
|
||
pub fn poll_photos(_: &mut LightcraftApp, _: &egui::Context) {}
|
||
|
||
/// Per frame: keep the activity row up to date; when the batch finishes, report it.
|
||
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||
let Some(task) = &app.export else { return };
|
||
match task.rx.try_recv() {
|
||
Ok(r) => {
|
||
let cancelled = matches!(&r, Ok((_, _, true)));
|
||
let total = task.total;
|
||
app.export = None;
|
||
// Photos refused (permission, not installed…): leave the way out on screen longer
|
||
let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some());
|
||
let msg = match r {
|
||
Ok((files, photos, _)) => {
|
||
let sheet = files.first().filter(|f| f["contactSheet"] == true);
|
||
let ok = files.iter().filter(|f| f.get("path").is_some()).count();
|
||
let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();
|
||
let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count();
|
||
let mut m =
|
||
crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total);
|
||
if let Some(sheet) = sheet {
|
||
m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]);
|
||
}
|
||
if let Some(pdf) = files.first().filter(|f| f["print"] == true) {
|
||
m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]);
|
||
}
|
||
if skipped > 0 {
|
||
m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped);
|
||
}
|
||
if let Some(first) = failed.first() {
|
||
m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error"));
|
||
}
|
||
if cancelled {
|
||
m += " · cancelled";
|
||
}
|
||
if let Some(photos) = &photos {
|
||
m += " · ";
|
||
m += &apple_photos_note(photos);
|
||
}
|
||
app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos}));
|
||
m
|
||
}
|
||
Err(e) => e,
|
||
};
|
||
if photos_failed {
|
||
app.toast_for(ctx, msg, 15.0);
|
||
} else {
|
||
app.toast(ctx, msg);
|
||
}
|
||
}
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
||
let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default();
|
||
task.guard.progress(done as u64, task.total as u64);
|
||
task.guard.detail(¤t);
|
||
}
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||
app.export = None;
|
||
app.toast(ctx, crate::i18n::tr("Export stopped unexpectedly"));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod contact_sheet_tests {
|
||
use super::*;
|
||
use crate::pick::{PickKind, PickRequest};
|
||
use crate::state::Dialog;
|
||
use std::time::{Duration, Instant};
|
||
|
||
#[test]
|
||
fn contact_sheet_menu_dialog_picker_and_background_export() {
|
||
let asked = Arc::new(Mutex::new(Vec::<PickRequest>::new()));
|
||
let requests = asked.clone();
|
||
let bytes = Arc::new(Mutex::new(Vec::new()));
|
||
let written = bytes.clone();
|
||
let services = crate::Services {
|
||
picker: Some(Box::new(move |req| {
|
||
requests.lock().unwrap().push(req);
|
||
let (tx, rx) = channel();
|
||
tx.send(vec!["Contact Sheet.pdf".into()]).unwrap();
|
||
Ok(rx)
|
||
})),
|
||
write_shared: Some(Arc::new(move |path, data| {
|
||
assert_eq!(path, "Contact Sheet.pdf");
|
||
*written.lock().unwrap() = data.to_vec();
|
||
Ok(())
|
||
})),
|
||
..Default::default()
|
||
};
|
||
// Exercise the menu/picker/background PDF workflow with a small procedural original.
|
||
// A full-size demo makes this test's deadline depend on unrelated concurrent renders.
|
||
let demo = lightcraft_engine::Session::with_demo();
|
||
let id = demo.active().unwrap();
|
||
let mut photo = (**demo.catalog.photo(id).unwrap()).clone();
|
||
photo.width = 96;
|
||
photo.height = 64;
|
||
let mut session = lightcraft_engine::Session::new();
|
||
session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(photo) }).unwrap();
|
||
session.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap();
|
||
let mut app = LightcraftApp::new(session, services);
|
||
app.run("dialog.contactSheet", json!({})).unwrap();
|
||
let Some(Dialog::ContactSheet { options }) = app.ui.dialog.as_mut() else { panic!("no contact sheet settings") };
|
||
options.paper = "letter".into();
|
||
options.landscape = true;
|
||
let dialog = app.ui.dialog.take().unwrap();
|
||
crate::panels::dialogs::confirm_dialog(&mut app, &dialog).unwrap();
|
||
assert_eq!(app.pending_picks.len(), 1);
|
||
let ctx = egui::Context::default();
|
||
crate::pick::poll(&mut app, &ctx);
|
||
assert!(app.pending_picks.is_empty());
|
||
assert!(app.export.is_some(), "picker did not start export: {:?}", app.ui.toast);
|
||
// a hang guard, not a speed check: the render queues behind other tests' demo scenes (generated one at a
|
||
// time process-wide), which on a loaded machine takes minutes
|
||
let deadline = Instant::now() + Duration::from_secs(300);
|
||
loop {
|
||
// Poll the real worker without starting unrelated gallery renders each frame.
|
||
poll(&mut app, &ctx);
|
||
if app.export.is_none() {
|
||
break;
|
||
}
|
||
assert!(Instant::now() < deadline, "export did not finish");
|
||
std::thread::sleep(Duration::from_millis(10));
|
||
}
|
||
let result = app.last_export_result.as_ref().unwrap_or_else(|| panic!("export failed: {:?}", app.ui.toast));
|
||
let req = asked.lock().unwrap();
|
||
assert_eq!(req[0].kind, PickKind::Save);
|
||
assert_eq!(req[0].filter.as_ref().unwrap().1, &["pdf"]);
|
||
assert_eq!(req[0].file_name.as_deref(), Some("Contact Sheet.pdf"));
|
||
let b = bytes.lock().unwrap();
|
||
assert!(b.starts_with(b"%PDF-1.4"));
|
||
assert!(String::from_utf8_lossy(&b).contains("/MediaBox [0 0 792.00 612.00]"));
|
||
assert_eq!(result["files"][0]["photos"], 1);
|
||
}
|
||
|
||
#[test]
|
||
fn contact_sheet_export_shows_a_row_and_its_cross_stops_it() {
|
||
let mut app = LightcraftApp::new(
|
||
lightcraft_engine::Session::with_demo(),
|
||
crate::Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() },
|
||
);
|
||
let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect();
|
||
start_contact_sheet(&mut app, &json!({"path": "Sheet.pdf", "ids": ids})).unwrap();
|
||
let rows = app.session.activity.list();
|
||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||
assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].total), ("export", "Exporting contact sheet", 24));
|
||
assert!(rows[0].cancellable);
|
||
app.session.activity.cancel(rows[0].id).unwrap();
|
||
assert!(app.export.as_ref().unwrap().cancel.load(Ordering::Relaxed), "the stack's ✕ stops the sheet");
|
||
let task = app.export.take().unwrap();
|
||
assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err(), "a stopped sheet is not written");
|
||
drop(task);
|
||
assert!(app.session.activity.list().is_empty(), "the row goes with the export");
|
||
}
|
||
|
||
#[test]
|
||
fn cancelled_contact_sheet_never_calls_writer() {
|
||
let mut app = LightcraftApp::new(
|
||
lightcraft_engine::Session::with_demo(),
|
||
crate::Services { write_shared: Some(Arc::new(|_, _| panic!("cancelled export wrote a file"))), ..Default::default() },
|
||
);
|
||
let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect();
|
||
start_contact_sheet(&mut app, &json!({"path": "Cancelled.pdf", "ids": ids})).unwrap();
|
||
app.export.as_ref().unwrap().cancel.store(true, Ordering::Relaxed);
|
||
let task = app.export.take().unwrap();
|
||
assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The toast's Apple Photos part (issue #236): the count and album, a partial import's
|
||
/// warning, an error with what to do, a cancelled export.
|
||
#[test]
|
||
fn apple_photos_outcomes_read_well() {
|
||
assert_eq!(apple_photos_note(&json!({"imported": 3, "requested": 3, "ids": [], "album": null})), "3 added to Apple Photos");
|
||
assert_eq!(apple_photos_note(&json!({"imported": 1, "album": "Trip"})), "1 added to Apple Photos album “Trip”");
|
||
let partial = apple_photos_note(&json!({"imported": 1, "requested": 2, "warning": "Photos added 1 of 2 files"}));
|
||
assert_eq!(partial, "1 added to Apple Photos (Photos added 1 of 2 files)");
|
||
let denied = apple_photos_note(
|
||
&json!({"error": "LightCraft isn't allowed to control Photos. Allow it in System Settings › Privacy & Security › Automation"}),
|
||
);
|
||
assert!(denied.starts_with("not added to Apple Photos: ") && denied.contains("Automation"), "{denied}");
|
||
assert_eq!(apple_photos_note(&json!({"skipped": "the export was cancelled", "cancelled": true})), "not added to Apple Photos (cancelled)");
|
||
assert_eq!(apple_photos_note(&json!({"skipped": "the export wrote no files"})), "not added to Apple Photos: the export wrote no files");
|
||
// decided on the `cancelled` flag, not the words (#1046): reworded it is still a cancel, and a
|
||
// reason that only mentions the word is shown as it is
|
||
assert_eq!(apple_photos_note(&json!({"skipped": "Export abgebrochen", "cancelled": true})), "not added to Apple Photos (cancelled)");
|
||
assert_eq!(
|
||
apple_photos_note(&json!({"skipped": "an earlier, cancelled request is still running"})),
|
||
"not added to Apple Photos: an earlier, cancelled request is still running"
|
||
);
|
||
}
|
||
}
|