diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5944d701..50241905 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -63,10 +63,17 @@ pub fn specs() -> Vec { spec!( "file.saveAs", "Save as", - "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + "{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", false, save_as ), + spec!( + "file.markSaved", + "Mark saved", + "{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)", + false, + mark_saved + ), spec!( "file.export", "Export", @@ -77,7 +84,7 @@ pub fn specs() -> Vec { spec!( "file.exportLayers", "Export layers", - "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}", + "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}", false, export_layers ), @@ -855,37 +862,59 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { let path = string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); - path.is_some_and(|path| ext(&path) != "fig") + path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) +} + +/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). +fn wants_data(p: &Value) -> bool { + p.get("data").and_then(Value::as_bool) == Some(true) } /// Save the file of the active tab (the main file for a branch tab, with its branches), or export /// the active document as an experimental `.fig` copy without changing its native save state. -fn save_to(s: &mut Session, path: String) -> Result { - if ext(&path) == "fig" { +/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). +fn save_to(s: &mut Session, path: Option) -> Result { + if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { return export_fig(s, &json!({"path": path})); } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let mut snapshot = FileSnapshot::of(ds); let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); - if follows_path && ds.path.as_deref() != Some(path.as_str()) { - Arc::make_mut(&mut snapshot.doc).name = file_stem(&path); + if let Some(path) = path.as_deref() + && follows_path + && ds.path.as_deref() != Some(path) + { + Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } let bytes = snapshot.encode()?; + let Some(path) = path else { + // The download can still be refused: the caller marks the file saved once it was delivered. + let name = format!("{}.figcraft", ds.doc.name); + return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); + }; write(&path, &bytes)?; ds.doc = snapshot.doc; ds.path = Some(path.clone()); - ds.mark_saved(); - ds.revision += 1; - let uid = ds.uid; - branch_tabs_saved(s, uid); + mark_saved(s, &json!({}))?; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } +/// The file of the active tab (with its branch tabs) now matches what was written or delivered. +fn mark_saved(s: &mut Session, _: &Value) -> Result { + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + ds.mark_saved(); + ds.revision += 1; + let uid = ds.uid; + branch_tabs_saved(s, uid); + Ok(json!({"saved": true})) +} + fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { - return save_to(s, path); + return save_to(s, Some(path)); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. @@ -893,7 +922,7 @@ fn save(s: &mut Session, p: &Value) -> Result { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) } - Some(path) => save_to(s, path), + Some(path) => save_to(s, Some(path)), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) @@ -907,8 +936,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result { if ext(&path).is_empty() { path.push_str(".figcraft"); } - save_to(s, path) + save_to(s, Some(path)) } + None if wants_data(p) => save_to(s, None), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) @@ -1207,6 +1237,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); } let dir = string(p, "dir"); + // The browser saves one download at a time: several files go as one zip (#969). + let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); + let mut members = Vec::new(); // Print options passed here stand in for every PDF setting's own. let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; let mut files = Vec::with_capacity(jobs.len()); @@ -1226,12 +1259,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { write(&path, &bytes)?; f["path"] = json!(path); } + None if zip => members.push((job.file.as_str(), bytes)), None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), } files.push(f); } let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); - Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())})) + let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); + if zip { + let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; + r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); + } + Ok(r) } /// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` @@ -1578,6 +1617,25 @@ mod rest_import_tests { assert_eq!(s.docs.len(), open, "a refused open adds nothing"); } + #[test] + fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { + // The browser has no paths: Save as returns the file for a download (#969). + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); + assert_eq!(r["name"], "Untitled.figcraft"); + assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); + s.execute("file.markSaved", &json!({})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + let mut t = Session::new(); + t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); + assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); + } + #[test] fn open_and_import_rest_json() { let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bcbaf36c..2689cef9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -368,7 +368,8 @@ pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore /// Commands that first end a version preview: they act on the document itself. Saving does so /// only with a native destination; export copies and pending format choices keep the preview. -const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; +const ENDS_PREVIEW: &[&str] = + &["file.save", "file.saveAs", "file.markSaved", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc..d0775323 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -343,6 +343,20 @@ pub(crate) fn put( z.write_all(bytes).map_err(io_err) } +/// `files` (name, bytes; a `/` in a name makes a folder) as one zip archive: several exports +/// delivered as a single download (#969). +pub fn zip_files<'a>(files: impl IntoIterator) -> Result, FormatError> { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + for (name, bytes) in files { + put(&mut z, name, zip::write::SimpleFileOptions::default(), bytes)?; + } + z.finish().map_err(io_err)?; + } + Ok(buf.into_inner()) +} + /// Add `doc` as a `document.json`-style member, serialized straight into the compressor. fn put_json(z: &mut zip::ZipWriter, name: &str, doc: &Document) -> Result<(), FormatError> { z.start_file(name, json_options()).map_err(io_err)?; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ceb4d564..c2e8b017 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -323,6 +323,7 @@ name = "English" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" "Couldn't download the variables" = "Couldn't download the variables" +"Couldn't download {name}" = "Couldn't download {name}" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Couldn't read {name}." = "Couldn't read {name}." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 83670c62..5c004c96 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -14,6 +14,7 @@ status = "draft — needs native review" "Compatibility notes" = "Notas de compatibilidad" "Connector" = "Conector" "Couldn't download the variables" = "No se pudieron descargar las variables" +"Couldn't download {name}" = "No se pudo descargar {name}" "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" @@ -1965,6 +1966,7 @@ status = "draft — needs native review" "file.revert" = "Volver a lo guardado" "file.save" = "Guardar" "file.saveAs" = "Guardar como" +"file.markSaved" = "Marcar como guardado" "file.saveVersion" = "Guardar en el historial de versiones" "file.versionHistory" = "Mostrar historial de versiones" "fill.add" = "Añadir relleno" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 85d8ca15..13f3c4ad 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -695,7 +695,11 @@ pub(crate) fn file_saved(app: &mut FigApp, result: &Value) { } fn result_file_name(result: &Value) -> String { - result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default() + match result["path"].as_str() { + Some(p) => Path::new(p).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(), + // A download (#969). + None => result["name"].as_str().unwrap_or_default().to_owned(), + } } /// The `.fig` result stays an export: native save state is unchanged, and omissions are reported. @@ -711,6 +715,15 @@ pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) { /// Save the whole file as `.fig`; the toast says how much was left out. fn save_fig(app: &mut FigApp, name: &str) { let filename = format!("{name}.fig"); + if app.services.downloads() { + if let Some(mut r) = app.run("file.export", json!({"format": "fig"})) + && app.download(&filename, &r) + { + r["name"] = json!(filename); + fig_exported(app, &r); + } + return; + } app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { fig_exported(app, &r); diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 8617c35a..39fc4bd1 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -320,6 +320,16 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { [] => {} // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), + // No folders in the browser, and it lets a page start one download at a time: the files + // arrive together as one zip (#969). + _ if app.services.downloads() => { + let name = format!("{}.zip", app.session.doc().map_or("Export", |d| d.name.as_str())); + if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets, "zip": true})) + && app.download(&name, &r) + { + app.toast(trn("Exported {n} files", r["files"].as_array().map_or(0, Vec::len), &[])); + } + } _ => { let ids = ids.to_vec(); app.pick_folder(tr("Export"), move |app, dir| { @@ -337,6 +347,14 @@ pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting let ext = setting.format.ext(); let mut params = setting_params(setting); params["ids"] = json!(ids); + if app.services.downloads() { + if let Some(r) = app.run("file.export", params) + && app.download(file, &r) + { + app.toast(trf("Exported {name}", &[("name", &file)])); + } + return; + } app.pick_save(tr("Export"), file, &[ext], move |app, p| { params["path"] = json!(p.to_string_lossy()); if app.run("file.export", params).is_some() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 21510ba9..c96f99d1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -236,6 +236,8 @@ mod vector_tail_ui_tests; #[cfg(test)] mod web_clipboard_tests; #[cfg(test)] +mod web_save_tests; +#[cfg(test)] mod windows_qa_tests; #[cfg(test)] mod windows_tests; @@ -1185,6 +1187,15 @@ impl FigApp { } pub fn save_as_dialog(&mut self) { + if self.services.downloads() { + if let Some(r) = self.run("file.saveAs", json!({"data": true})) + && self.download(r["name"].as_str().unwrap_or_default(), &r) + { + self.run("file.markSaved", json!({})); + dialogs::file_saved(self, &r); + } + return; + } let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return }; self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| { if p.extension().is_none_or(|e| e.is_empty()) { @@ -1289,6 +1300,18 @@ impl FigApp { self.start_dialog(|services| services.pick_save(title, name, exts), true, done); } + /// Hand a command result's base64 `data` over as a download named `name`, where the platform + /// saves by downloads rather than save dialogs (the browser, #969). Says so when nothing arrived. + pub(crate) fn download(&mut self, name: &str, result: &Value) -> bool { + use base64::Engine as _; + let bytes = result["data"].as_str().and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok()); + if bytes.is_some_and(|b| self.services.download(name, &b)) { + return true; + } + self.toast(i18n::trf("Couldn't download {name}", &[("name", &name)])); + false + } + /// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does. pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { self.start_dialog(|services| services.pick_folder(title), true, done); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c48fa401..d23885de 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -282,6 +282,15 @@ pub fn invoke(app: &mut FigApp, id: &str) { /// Every visible top-level frame of the page as one PDF page each, through a save dialog. fn export_frames_pdf(app: &mut FigApp) { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); + if app.services.downloads() { + let file = format!("{name}.pdf"); + if let Some(r) = app.run("file.exportFramesPdf", json!({})) + && app.download(&file, &r) + { + app.toast(trf("Exported {name}", &[("name", &file)])); + } + return; + } app.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"], |app, p| { if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() { app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); diff --git a/crates/ui-egui/src/variables_export_ui_tests.rs b/crates/ui-egui/src/variables_export_ui_tests.rs index 40a84393..ce51a096 100644 --- a/crates/ui-egui/src/variables_export_ui_tests.rs +++ b/crates/ui-egui/src/variables_export_ui_tests.rs @@ -9,12 +9,12 @@ use serde_json::{Value, json}; use crate::variables_panel::export_dialog; use crate::{FigApp, NoServices, Services}; -type Downloads = Arc)>>>; +pub(crate) type Downloads = Arc)>>>; /// Services that save by downloads and keep them (`deliver` false: the browser refused). -struct Browser { - got: Downloads, - deliver: bool, +pub(crate) struct Browser { + pub(crate) got: Downloads, + pub(crate) deliver: bool, } impl Services for Browser { diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs new file mode 100644 index 00000000..4879add1 --- /dev/null +++ b/crates/ui-egui/src/web_save_tests.rs @@ -0,0 +1,72 @@ +//! Save, Save as and Export where the platform saves by downloads (the browser, which has no save +//! dialogs or paths): the file arrives as a download instead of nothing happening (#969). + +use base64::Engine as _; +use figcraft_doc::{ExportSetting, NodeId}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::FigApp; +use crate::variables_export_ui_tests::{Browser, Downloads}; + +fn browser(deliver: bool) -> (FigApp, Downloads, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let id = serde_json::from_value(r["id"].clone()).unwrap(); + let got = Downloads::default(); + (FigApp::new(s, Box::new(Browser { got: got.clone(), deliver })), got, id) +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +#[test] +fn save_downloads_the_figcraft_file() { + let (mut app, got, _) = browser(true); + app.save(); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Untitled.figcraft"); + let mut reopened = Session::new(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + reopened.execute("file.open", &json!({"data": data, "name": name})).unwrap(); + assert!(!app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "Saved Untitled.figcraft"); +} + +#[test] +fn export_downloads_the_exported_file() { + let (mut app, got, id) = browser(true); + crate::export_panel::save_one(&mut app, &[id], &ExportSetting::default(), "Rectangle.png"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Rectangle.png"); + assert!(bytes.starts_with(b"\x89PNG")); + assert_eq!(toast(&app), "Exported Rectangle.png"); +} + +#[test] +fn a_refused_download_says_so() { + let (mut app, got, _) = browser(false); + app.save_as_dialog(); + assert_eq!(got.lock().unwrap().len(), 1); + assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); + assert!(app.session.ds().unwrap().is_dirty(), "no file arrived: closing must still warn about the edits"); +} + +#[test] +fn several_layers_download_as_one_zip() { + // The browser lets a page start one download at a time: two downloads lost the second. + let (mut app, got, a) = browser(true); + let r = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + let b: NodeId = serde_json::from_value(r["id"].clone()).unwrap(); + app.session.execute("export.add", &json!({"ids": [a, b]})).unwrap(); + crate::export_panel::export_layers(&mut app, &[a, b], false); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Untitled.zip"); + assert!(bytes.starts_with(b"PK\x03\x04")); + assert!(bytes.windows(15).any(|w| w == b"Rectangle 2.png"), "both layers are in the zip"); + assert_eq!(toast(&app), "Exported 2 files"); +}