From 5dfd01e1f115d3ffea208b5b5c2ad27827bc655b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:50 +0000 Subject: [PATCH] refactor(ui-egui): move io.rs items to io/{export,open,save}.rs --- crates/ui-egui/src/io.rs | 1105 +------------------------------ crates/ui-egui/src/io/export.rs | 302 +++++++++ crates/ui-egui/src/io/open.rs | 362 ++++++++++ crates/ui-egui/src/io/save.rs | 440 ++++++++++++ 4 files changed, 1121 insertions(+), 1088 deletions(-) create mode 100644 crates/ui-egui/src/io/export.rs create mode 100644 crates/ui-egui/src/io/open.rs create mode 100644 crates/ui-egui/src/io/save.rs diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 43c5b1746..c6b9c2677 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -13,1094 +13,23 @@ use crate::dialogs::svg_options; use crate::state::Dialog; use crate::{FilePick, Services, VectorcraftApp, dialogs}; -/// Template extensions New from Template's open dialog lists first. -const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; - -/// Open bytes of any readable format as a new document (templates open untitled); swatch and -/// graphic style library files open in the library panel, Libraries panel files are added to it, -/// flattener, PDF, print and perspective grid presets files are imported, a shortcut set another -/// app saved (`.kys`) opens Import Shortcut Set, and a script written for the reference app -/// (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). → `document.open`'s result for -/// a document opened now (its `warnings` say what didn't come in as it was), else null. -pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result { - let opens = opens_as(name); - // A WebAssembly plug-in is installed. - if opens == Some(OpensAs::Plugin) { - let module = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}); - return install_module(app, module).map(|_| Value::Null); - } - if opens == Some(OpensAs::ShortcutSet) { - return dialogs::shortcut_import::open(app, name, bytes).map(|_| Value::Null); - } - if opens == Some(OpensAs::Script) { - let p = match path { - Some(path) => json!({ "path": path }), - None => json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}), - }; - return dialogs::script_check::command(app, &p).map(|_| Value::Null); - } - if let Some(OpensAs::Presets { import, what }) = opens { - let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; - let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - app.status(format!("Imported {what}: {}", names.join(", "))); - return Ok(Value::Null); - } - if opens == Some(OpensAs::Library) { - return crate::panels::libraries::import(app, json!({"data": String::from_utf8_lossy(bytes)})).map(|_| Value::Null); - } - let swatches = opens == Some(OpensAs::SwatchLibrary); - if swatches || opens == Some(OpensAs::StyleLibrary) { - let p = match path { - Some(path) => serde_json::json!({ "path": path }), - // `.ase` swatch libraries are binary. - None if swatches => serde_json::json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}), - None => serde_json::json!({"name": name, "data": String::from_utf8_lossy(bytes)}), - }; - let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library }; - return load(app, p).map(|_| Value::Null); - } - // A PDF with several pages or a password asks first (the Import PDF dialog), and so does a - // DXF drawing (DXF Import Options). - if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) - || crate::dialogs::dxf_import::offer(app, name, bytes, path.clone(), None) - { - return Ok(Value::Null); - } - open_document(app, name, bytes, path, &Value::Null) -} - -/// The kinds of file File → Open installs, imports, checks, shows in the library panel or the -/// Libraries panel, or reviews in Import Shortcut Set ([`opens_as`]). -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum OpensAs { - /// A WebAssembly plug-in: installed. - Plugin, - /// A shortcut set another app saved (`.kys`): reviewed in Import Shortcut Set. - ShortcutSet, - /// A script written for the reference app: checked without running it (Check Script…). - Script, - /// Flattener, PDF, print or perspective grid presets: imported with command `import`, and - /// `what` names them in the status bar. - Presets { import: &'static str, what: &'static str }, - /// A Libraries panel file: added to the Libraries panel as a new library. - Library, - /// A swatch library: opened in the library panel. - SwatchLibrary, - /// A graphic style library: opened in the library panel. - StyleLibrary, -} - -/// The kind of file named `name` (by its extension) that File → Open installs, imports, checks, -/// shows in the library panel or the Libraries panel, or reviews in Import Shortcut Set -/// ([`open_bytes`]), else `None`. A drop opens these files the same way, wherever they are dropped -/// ([`VectorcraftApp::drop_target`]). -pub fn opens_as(name: &str) -> Option { - let ext = fileio::extension(name); - let presets = [ - (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), - (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), - (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), - (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), - ]; - if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { - Some(OpensAs::Plugin) - } else if vectorcraft_engine::cmd::shortcutset::EXTS.contains(&ext.as_str()) { - Some(OpensAs::ShortcutSet) - } else if vectorcraft_engine::cmd::scripts::EXTS.contains(&ext.as_str()) { - Some(OpensAs::Script) - } else if let Some(&(_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { - Some(OpensAs::Presets { import, what }) - } else if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) { - Some(OpensAs::Library) - } else if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str()) { - Some(OpensAs::SwatchLibrary) - } else if ext == vectorcraft_doc::style_libs::STYLES_EXT { - Some(OpensAs::StyleLibrary) - } else { - None - } -} - -/// Open a document through the engine loader with the `document.open` options in `p` → -/// `document.open`'s result. -pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { - let r = fileio::open_bytes_with(&mut app.session, name, bytes, path, p).map_err(|e| e.to_string())?; - opened(app, &r); - Ok(r) -} - -/// A document just opened (`r`: `document.open`'s result): the views follow, the status bar says -/// what didn't come in as it was, and missing links are asked about. -pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { - app.sync_views(); - if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { - app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default())); - } - crate::dialogs::missing_links::after_open(app, r); -} - -/// A path from the open dialog, or "cancelled". -fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result { - crate::picks::open(app, pick).ok_or_else(|| "cancelled".into()) -} - -/// Object › Plug-ins › Install Plug-in…: installs the module `p` gives (`path`, or `dataBase64` -/// with `name`), else a picked `.wasm` (the web's file picker hands the file to [`open_bytes`], -/// which installs it) → [`install_module`]'s result. -pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result { - let given = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| !v.is_empty()); - if given("path") || given("dataBase64") { - return install_module(app, p.clone()); - } - if app.services.open_async.is_some() { - return open_dialog(app).map(|_| Value::Null); - } - let path = - pick_open(app, &FilePick { filters: vec![FilePick::filter("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?; - install_module(app, json!({ "path": path })) -} - -/// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` -/// with `name`) → `plugin.install`'s result. A filter that asks for a heavy budget the user hasn't -/// allowed yet (`allowHeavy`) asks them first, and OK installs it → `{pending}` while it asks. -pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { - if module.get("allowHeavy").and_then(Value::as_bool) != Some(true) { - let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); - let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); - let info = app.run("plugin.inspect", source.clone())?; - if info["budget"] == "heavy" { - let name = info["name"].as_str().unwrap_or_default(); - let (minutes, times) = vectorcraft_plugins::heavy_terms(); - let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", name)]); - let detail = crate::i18n::fmt( - tl!( - "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." - ), - &[("minutes", &minutes.to_string()), ("times", ×.to_string())], - ); - let mut allowed = source; - allowed["allowHeavy"] = json!(true); - dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); - return Ok(json!({ "pending": dialogs::confirm::KIND })); - } - } - let r = app.run("plugin.install", module)?; - app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); - Ok(r) -} - -/// Where File → Open… starts: the folder of the most recently opened file, else the active -/// document's (#4122). -fn open_dialog_folder(app: &VectorcraftApp) -> Option { - app.ui.recent_files.iter().find_map(|p| fileio::dialog_folder(p)).or_else(|| suggested(app, "").1) -} - -/// File → Open… -pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { - if let Some(f) = app.services.open_async.as_mut() { - f(); - return Ok(()); - } - let path = - pick_open(app, &FilePick { filters: FilePick::filters(fileio::open_filters()), folder: open_dialog_folder(app), ..Default::default() })?; - open_reporting(app, &path).map(|_| ()) -} - -/// A file the user asked to open (File › Open, Open Recent, the Home screen, a drop, the Finder) -/// couldn't be: say so in a dialog as well as the status bar, which is easily missed (#861). -pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) { - app.status(format!("Couldn't open {name}: {e}")); - let message = crate::i18n::fmt(tl!("Can't open “{name}”."), &[("name", name)]); - dialogs::confirm::tell(app, &message, &crate::i18n::message(crate::i18n::current(), e)); -} - -/// A save or an export that didn't write `path`: say so in a dialog that names the file and the -/// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which -/// the caller's error becomes, is replaced by the next message, and a document that wasn't saved -/// must not look saved: it stays modified, and what was at `path` is as it was. Not for a request -/// an agent sent and waits on: the error is its reply. -pub(crate) fn report_write_error(app: &mut VectorcraftApp, export: bool, path: &str, e: &str) { - if app.ui.answering_control { - return; - } - let name = fileio::file_name(path); - let message = if export { tl!("Can't export “{name}”.") } else { tl!("Can't save “{name}”.") }; - dialogs::confirm::tell(app, &crate::i18n::fmt(message, &[("name", &name)]), &crate::i18n::message(crate::i18n::current(), e)); -} - -/// [`write_to`], a failure reported ([`report_write_error`]). -pub(crate) fn write_reporting(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> { - let r = write_to(&mut app.services, path, bytes); - if let Err(e) = &r { - report_write_error(app, false, path, e); - } - r -} - -/// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). On -/// the desktop a document is decoded off the UI thread ([`crate::opening`]): the result is then -/// `{background: true, status}`, and the document opens in its tab (or the failure is reported) -/// once it is ready. -pub fn open_reporting(app: &mut VectorcraftApp, path: &str) -> Result { - let r = open_file(app, path, crate::opening::available()); - if let Err(e) = &r { - report_open_error(app, &fileio::file_name(path), e); - } - r -} - -/// Home › Recent Files and File › Open Recent: focus an already-open tab for that path instead of -/// calling `document.open` again (#3455). -pub fn open_recent_reporting(app: &mut VectorcraftApp, path: &str) -> Result { - if let Some(r) = try_activate_open_document(app, path)? { - return Ok(r); - } - open_reporting(app, path) -} - -fn try_activate_open_document(app: &mut VectorcraftApp, path: &str) -> Result, String> { - let Some(index) = document_index_for_path(app, path) else { - return Ok(None); - }; - let st = &app.session.documents()[index]; - let r = json!({ - "index": index, - "title": st.title(), - "format": st.format, - "warnings": [], - "restored": false, - "activated": true, - }); - app.run("document.activate", json!({ "index": index }))?; - opened(app, &r); - Ok(Some(r)) -} - -fn document_index_for_path(app: &VectorcraftApp, path: &str) -> Option { - app.session.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_open_path(p, path))) -} - -fn same_open_path(a: &str, b: &str) -> bool { - match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { - (Ok(a), Ok(b)) => a == b, - _ => a == b, - } -} - -fn read(app: &VectorcraftApp, path: &str) -> Result, String> { - app.services.read.as_ref().ok_or("no file reader")?(path) -} - -/// Open the file at `path` (see [`open_bytes`]) → `document.open`'s result, or null. -pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result { - open_file(app, path, false) -} - -/// [`open_path`]; with `background`, a document (not a file that installs, imports or asks -/// first) is decoded off the UI thread ([`crate::opening::start`]). -pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result { - // A shortcut set is read by Import Shortcut Set, up to its reader's cap. - if dialogs::shortcut_import::is_kys(path) { - dialogs::shortcut_import::command(app, &json!({ "path": path }))?; - note_recent(app, path); - return Ok(Value::Null); - } - // A script is read by `script.check`, up to its limit. - if opens_as(path) == Some(OpensAs::Script) { - dialogs::script_check::command(app, &json!({ "path": path }))?; - note_recent(app, path); - return Ok(Value::Null); - } - let mut bytes = read(app, path)?; - if background && opens_as(path).is_none() { - // A PDF with several pages or a password, or a DXF drawing, asks first, as at once. - if crate::dialogs::import_pdf::offer(app, path, &bytes, Some(path.to_string()), None) - || crate::dialogs::dxf_import::offer(app, path, &bytes, Some(path.to_string()), None) - { - note_recent(app, path); - return Ok(Value::Null); - } - match crate::opening::start(app, path, bytes) { - Ok(r) => return Ok(r), - // No thread: at once. - Err(back) => bytes = back, - } - } - let r = open_bytes(app, path, &bytes, Some(path.to_string()))?; - note_recent(app, path); - Ok(r) -} - -/// File → New from Template…: a template (or any readable file) as a new untitled document. Without -/// a path the open dialog starts in the Templates folder. On the web the browser's file picker -/// opens it (`.vctemplate`, `.ait` and template files open untitled there too). -pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Result { - let path = match path { - Some(p) => p, - None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), - None => { - let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters())); - pick_open(app, &FilePick { folder: fileio::templates_dialog_folder(&app.session.prefs), filters, ..Default::default() })? - } - }; - let bytes = read(app, &path)?; - let r = fileio::open_template(&mut app.session, &path, &bytes, Some(&path)).map_err(|e| e.to_string())?; - app.sync_views(); - crate::dialogs::missing_links::after_open(app, &r); - Ok(r) -} - -/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`. -pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> { - if let Some(dl) = services.download.as_mut() { - return dl(&fileio::file_name(path), bytes); - } - services.write.as_mut().ok_or("no file writer")?(path, bytes) -} - -/// The web saves by downloading: no save panel, no folders. -pub(crate) fn is_web(app: &VectorcraftApp) -> bool { - app.services.download.is_some() -} - -/// Write `bytes` to `path`, else to a file picked in a save panel that suggests `name` next to the -/// document and shows its file type, [`file_types`] (the web downloads them as `name`) → where -/// they went. -pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: &str, bytes: &[u8]) -> Result { - let path = match path { - Some(p) => p, - None => { - let (_, folder) = suggested(app, ""); - let filters = file_types(&fileio::extension(name)); - pick_path(app, &FilePick { name: name.to_string(), folder, filters }, |_| false)? - } - }; - write_reporting(app, &path, bytes)?; - Ok(path) -} - -/// Where a file goes when no path was given: the suggested name on the web (a download), else the -/// save panel's choice with the suggested name's extension, [`with_written_extension`]: a name -/// typed with the extension of a format `keeps` takes stays as typed too. -fn pick_path(app: &mut VectorcraftApp, pick: &FilePick, keeps: impl Fn(&Format) -> bool) -> Result { - let ext = fileio::extension(&pick.name); - pick_finished(app, pick, |picked, _| with_written_extension(picked, &ext, &keeps)) -} - -/// `path` as typed in a save panel for a file with extension `ext`: kept when its extension is -/// `ext` in any letter case, or names the format `ext` names (`photo.jpeg` for a `.jpg` file) or -/// a format `keeps` takes. An extension that names another format (`card.svg` for a `.png` file) -/// is replaced with `ext`, and `.ext` is added to any other path (`v1.2` becomes `v1.2.png`). -fn with_written_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { - let written = fileio::format(ext); - let keeps = |g: &Format| written.is_some_and(|w| w.id == g.id) || keeps(g); - if !ext.is_empty() && !fileio::extension(path).eq_ignore_ascii_case(ext) && fileio::format_for_name(path).is_some_and(|g| !keeps(g)) { - return std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned(); - } - with_extension(path, ext, keeps) -} - -/// The suggested name made into a path by `finish` on the web (a download), else the save panel's -/// choice made into one, with the extension of the file type picked in the panel when it reports -/// one ([`crate::picks::PickAnswer`]). When `finish` changed the choice (an extension added or -/// replaced) and a file is there, which the panel did not ask about, a question asks before -/// replacing it ([`crate::picks::ask_to_replace`]) and this answers "cancelled"; the question's OK -/// runs what asked again. -fn pick_finished(app: &mut VectorcraftApp, pick: &FilePick, finish: impl Fn(&str, Option<&str>) -> String) -> Result { - if is_web(app) { - return Ok(finish(&pick.name, None)); - } - let (picked, file_type) = crate::picks::save_with_type(app, pick).ok_or("cancelled")?; - let path = finish(&picked, file_type.as_deref()); - if path != picked && crate::picks::ask_to_replace(app, crate::picks::PickRequest::Save(pick.clone()), &picked, file_type.as_deref(), &path) { - return Err("cancelled".into()); - } - Ok(path) -} - -/// `path` as typed in a save panel: kept when it ends in `ext` or its extension names a format -/// `keeps` takes, else with `.ext` added. -fn with_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { - let has = fileio::extension(path); - if ext.is_empty() || has.eq_ignore_ascii_case(ext) || fileio::format_for_name(path).is_some_and(keeps) { - path.to_string() - } else { - format!("{path}.{ext}") - } -} - -/// The active document's file name with extension `ext`, and its folder. -fn suggested(app: &VectorcraftApp, ext: &str) -> (String, Option) { - let st = app.session.active(); - let t = st.map(|d| d.path.clone().unwrap_or_else(|| d.doc.title.clone())).unwrap_or_else(|| "Untitled".into()); - let folder = st.and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(|p| p.to_string_lossy().to_string()); - (format!("{}.{ext}", fileio::file_stem(&t)), folder.filter(|f| !f.is_empty())) -} - -/// The save panel's file type, `(label, extensions)`, for the files `write_named` and -/// `target_path` write that are not a document format (`fileio::format` has those). -const FILE_TYPES: &[(&str, &[&str])] = &[ - ("Swatch libraries", &["vcswatches"]), - ("Swatch libraries", &["gpl"]), - ("Swatch libraries", &["ase"]), - ("Graphic Style Libraries", &[vectorcraft_doc::style_libs::STYLES_EXT]), - ("Libraries", vectorcraft_engine::cmd::library::LIBRARY_EXTS), - ("Flattener presets", vectorcraft_engine::cmd::flatten::PRESET_EXTS), - ("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS), - ("Perspective grid presets", vectorcraft_engine::cmd::perspgrid::PRESET_EXTS), - ("Print presets", vectorcraft_engine::cmd::printpresets::PRESET_EXTS), - ("CSS", &["css"]), - ("PostScript", &["ps"]), -]; - -/// The file type a save panel shows for a file with extension `ext`: its format's, else its -/// [`FILE_TYPES`] row; none for any other extension. -fn file_types(ext: &str) -> Vec { - if let Some(f) = fileio::format(ext) { - return vec![FilePick::filter(f.label, f.extensions)]; - } - let ext = ext.trim_start_matches('.').to_ascii_lowercase(); - FilePick::filters(FILE_TYPES.iter().find(|(_, exts)| exts.contains(&ext.as_str())).copied()) -} - -/// `path`, else one picked in a save panel (the web: the suggested name) for the active document -/// with extension `ext`, filtered to its file type when it has one ([`file_types`]). A name typed -/// with another format's extension gets `ext` instead ([`with_written_extension`]). -pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: &str) -> Result { - target_path_keeping(app, path, ext, |_| false) -} - -/// [`target_path`] for an export in format `f` (Export As's OK and [`export`]): a name typed with -/// `.svgz` for an SVG stays as typed, and [`export`] writes it compressed. -pub(crate) fn export_path(app: &mut VectorcraftApp, path: Option, f: &Format) -> Result { - target_path_keeping(app, path, f.extensions[0], |g| f.id == "svg" && g.id == "svgz") -} - -/// [`target_path`], keeping a typed extension of a format `keeps` takes. -fn target_path_keeping(app: &mut VectorcraftApp, path: Option, ext: &str, keeps: impl Fn(&Format) -> bool) -> Result { - if let Some(p) = path { - return Ok(p); - } - let (name, folder) = suggested(app, ext); - let filters = file_types(ext); - pick_path(app, &FilePick { name, folder, filters }, keeps) -} - -/// Keep the active document's saved view current before a save (native files reopen at it). -pub fn remember_view(app: &mut VectorcraftApp) { - let Some(v) = app.view().copied().filter(|v| v.fitted) else { return }; - if let Some(st) = app.session.active_mut() { - st.view = Some(SavedView { name: String::new(), center: v.center, zoom: v.zoom, rotation: v.rotation }); - } -} - -/// A path typed in a save panel: kept when its extension names one of the panel's file types (a -/// save format, or a template format for Save as Template, `mode`). An extension that names -/// another format (`card.png`, or `card.ait` in Save As) is replaced with `f`'s, and `f`'s is -/// added to any other path (`notes.v2` becomes `notes.v2.vectorcraft`). -fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String { - let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; - if fileio::format_for_name(path).is_some_and(|g| !formats.contains(&g.id)) { - return std::path::Path::new(path).with_extension(f.extensions[0]).to_string_lossy().into_owned(); - } - with_extension(path, f.extensions[0], |g| formats.contains(&g.id)) -} - -/// [`with_save_extension`] for a save panel that reports the file type picked in it (`picked`, -/// that type's extension; the XDG portal's panel on Linux and the BSDs), which then takes `f`'s -/// place. A path that still ends in the extension of the name the panel suggested (`suggested`), -/// in any letter case, gets the picked type's extension instead, unless that extension is one of -/// the picked type's; such a path stays as typed. Without a picked type, or with one the panel did not -/// offer, [`with_save_extension`] with `f`. -fn with_picked_type(path: &str, suggested: &str, picked: Option<&str>, f: &Format, mode: SaveMode) -> String { - let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; - let Some(g) = picked.and_then(fileio::format).filter(|g| formats.contains(&g.id)) else { - return with_save_extension(path, f, mode); - }; - let has = fileio::extension(path); - if !has.is_empty() && has == fileio::extension(suggested) && !g.extensions.contains(&has.as_str()) { - return std::path::Path::new(path).with_extension(g.extensions[0]).to_string_lossy().into_owned(); - } - with_save_extension(path, g, mode) -} - -/// Write every file of an export (one per artboard, linked images) for the destination `path`. -/// → the export's own files (without the linked images). -fn write_encoded(write: Writer, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result, String> { - let named = enc.named(doc, path); - for (p, bytes) in &named { - write(p, bytes)?; - } - Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect()) -} - -fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result { - fileio::save_plan(&app.session, mode, p).map_err(|e| e.to_string()) -} - -/// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the -/// engine command's `{path?, format?, options?, svg?}`. -/// -/// When no path is known, desktop asks with a save panel (one file type per save format, or one -/// per template format for Save as Template) and the web opens the Save As dialog (file name and -/// format). With `ask_options` and no options in `p`, -/// a format picked that way that has options asks for them before anything is written (SVG -/// Options, the Save PDF dialog, or the save options dialog: native files on Save As only, `.ai` -/// files on Save As and Save a Copy), and so does Save As or Save a Copy to a given SVG path. -/// → `{path, format, warnings…}` once written, or `{pending: , -/// path?}` while a dialog is open. -pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { - remember_view(app); - // Writing over the file the document was read from is asked about here, not refused. - let first = plan(app, mode, &acknowledged(p))?; - let ask = ask_options && !has_options(p); - if let Some(path) = first.path.clone() { - if let Some(r) = ask_before_losing(app, &path, save_command(mode), p) { - return Ok(r); - } - if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { - return ask_format_options(app, mode, first.format, &path); - } - return write_plan(app, first); - } - if is_web(app) && ask_options { - return Ok(open_options(app, mode.command(), first.format, &first.name, true)); - } - let filters = FilePick::filters(match mode { - SaveMode::Template => fileio::template_filters(first.format.id), - _ => fileio::save_filters(first.format.id), - }); - let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; - let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; - // The picked file type wins over a `format` param. - let mut q = if p.is_object() { p.clone() } else { json!({}) }; - if let Some(o) = q.as_object_mut() { - o.remove("format"); - o.insert("path".into(), json!(picked)); - } - let chosen = plan(app, mode, &acknowledged(&q))?; - if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) { - return Ok(r); - } - if ask && asks_options(mode, chosen.format) { - let path = chosen.path.clone().unwrap_or_default(); - let r = ask_format_options(app, mode, chosen.format, &path); - crate::dialogs::opened_by_panel(app); - return r; - } - write_plan(app, chosen) -} - -/// The UI command that runs a save in `mode`. -fn save_command(mode: SaveMode) -> &'static str { - match mode { - SaveMode::Save => "file.save", - SaveMode::SaveAs => "file.saveAs", - SaveMode::Copy => "file.saveCopy", - SaveMode::Template => "file.saveAsTemplate", - } -} - -/// `p` with `acknowledgeLoss: true`. -fn acknowledged(p: &Value) -> Value { - let mut p = if p.is_object() { p.clone() } else { json!({}) }; - p["acknowledgeLoss"] = json!(true); - p -} - -/// Writing `path` over the file the active document was read from, when reading it left things -/// out (hidden text, art or layers VectorCraft can't read yet): asks first, and OK runs `command` -/// with `params` and `acknowledgeLoss` → `{pending}` while it asks, `None` when nothing is lost. -fn ask_before_losing(app: &mut VectorcraftApp, path: &str, command: &str, params: &Value) -> Option { - let what = fileio::losses_summary(fileio::overwrite_losses(app.session.active()?, path, params)?); - let name = fileio::file_name(path); - let message = crate::i18n::fmt(tl!("Replace “{name}”, the file this document was opened from?"), &[("name", &name)]); - let detail = crate::i18n::fmt( - tl!("Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it."), - &[("what", &what)], - ); - let mut params = acknowledged(params); - if command != "file.save" { - params["path"] = json!(path); - } - dialogs::confirm::ask(app, &message, &detail, command, params); - Some(json!({ "pending": dialogs::confirm::KIND })) -} - -/// Does a save (`mode`) to a picked file of format `f` ask for its options first? Native and `.ai` -/// files on Save As (the options dialog after the save panel), and `.ai` files also on Save a Copy, -/// so the copy is not written in the version last saved whatever the panel's `.ai` type says -/// (#3141); Save and templates reuse the remembered options or Use Compression. -fn asks_options(mode: SaveMode, f: &Format) -> bool { - !f.options.is_empty() - && match f.id { - "vectorcraft" => mode == SaveMode::SaveAs, - "ai" => matches!(mode, SaveMode::SaveAs | SaveMode::Copy), - "template" | "ait" => false, - _ => true, - } -} - -/// Does `p` give format options (`options`, `svg: {…}` or SVG options at its top level)? -fn has_options(p: &Value) -> bool { - ["options", "svg"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) || fileio::svg_options(p).is_ok_and(|m| !m.is_empty()) -} - -/// Ask for format `f`'s options before a save (`mode`) to `path`: SVG Options for SVG, the Save -/// PDF dialog for PDF, else the save options dialog. OK finishes the save. -pub fn ask_format_options(app: &mut VectorcraftApp, mode: SaveMode, f: &Format, path: &str) -> Result { - match f.id { - "pdf" => ask_pdf_options(app, mode, path), - "svg" | "svgz" => { - let m = if mode == SaveMode::Copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save }; - svg_options::open(app, m, Some(path)); - Ok(json!({ "pending": svg_options::KIND, "dialog": svg_options::KIND, "path": path })) - } - _ => Ok(open_options(app, mode.command(), f, path, false)), - } -} - -/// A save as PDF asks with the Save PDF dialog, filled with the PDF options the document was last -/// saved with; its OK finishes the save ([`save_pdf`]). -pub fn ask_pdf_options(app: &mut VectorcraftApp, mode: SaveMode, path: &str) -> Result { - let mut p = Value::Object(plan(app, mode, &json!({ "path": path, "format": "pdf" }))?.options); - p["path"] = json!(path); - dialogs::open_save_pdf(app, &p)?; - let d = app.ui.dialog.as_mut().ok_or("the Save PDF dialog didn't open")?; - d.fields.insert("__save".into(), json!(mode.command())); - Ok(json!({ "pending": d.kind })) -} - -/// The Save PDF dialog's OK during a save (`mode`): `params` are its `document.exportPdf` options -/// and `path`. -pub fn save_pdf(app: &mut VectorcraftApp, mode: SaveMode, mut params: Value) -> Result { - let path = params.as_object_mut().and_then(|o| o.remove("path")); - let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); - let r = save(app, mode, &json!({ "path": path, "format": "pdf", "options": params }), false)?; - if view && let Some(path) = r["path"].as_str() { - view_file(app, path); - } - Ok(r) -} - -/// Open a written file in the system viewer (not on the web, which downloads it). -fn view_file(app: &mut VectorcraftApp, path: &str) { - if !is_web(app) { - app.open_url(&file_url(path)); - } -} - -/// "Saved ", with the first of the result's warnings. -pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { - app.status(saved_status(path, r)); -} - -/// [`report_saved`]'s message. -fn saved_status(path: &str, r: &Value) -> String { - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); - match warnings.first() { - Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()), - None => format!("Saved {path}"), - } -} - -/// Write a planned save through the services and report it in the status bar. -/// With Background Save on, the file is encoded and written from a snapshot off the UI thread -/// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once -/// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format -/// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. -fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { - let uid = app.session.active().ok_or("no document")?.uid; - // Saves of one document finish in order. - if app.background.busy_with(uid) { - background::wait_all(app); - } - let job = fileio::save_job(&mut app.session, plan).map_err(|e| e.to_string())?; - let (retargets, target) = (job.plan().retargets(), job.plan().path.clone()); - let (format, warnings) = (job.plan().format.id, job.known_warnings()); - let label = match &target { - Some(path) => format!("Saving {}", fileio::file_name(path)), - None => "Saving".into(), - }; - // Without a path the bytes come back to the caller: nothing to do in the background. - let enabled = app.session.prefs.background_save && target.is_some(); - // The work writes the file; the copy makes it the document's own afterwards. - let done = job.clone(); - // A native file streams into the file as it is encoded (desktop; not to a download). - let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); - let work = move |write: Writer| { - match stream { - Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), - None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), - } - .map_err(|e| e.to_string()) - }; - let failed = target.clone().unwrap_or_default(); - let then = move |app: &mut VectorcraftApp, r: Result| { - // Not written: the document keeps its file, format and modified state, and the user is told. - let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; - // The document may have closed meanwhile: the file is written all the same. - done.complete(&mut app.session, uid); - let path = r["path"].as_str().unwrap_or_default().to_string(); - if retargets { - note_recent(app, &path); - } - report_saved(app, &path, &r); - Ok(r) - }; - let mut r = background::run(app, enabled, label, Some(uid), work, then)?; - if let Some(path) = target.filter(|_| r.get("path").is_none()) { - r["path"] = json!(path); - } - if r.get("background").and_then(Value::as_bool) == Some(true) { - r["format"] = json!(format); - r["warnings"] = json!(warnings); - } - Ok(r) -} - -/// Open the save options dialog of format `f` before writing `path`: `action` is the save command -/// it finishes, and `pick` (the web's Save As) also names the file and chooses the format. -fn open_options(app: &mut VectorcraftApp, action: &str, f: &Format, path: &str, pick: bool) -> Value { - let mut fields = dialogs::save_options::option_values(app, f); - fields.insert("__action".into(), json!(action)); - fields.insert("__pick".into(), json!(pick)); - fields.insert("path".into(), json!(path)); - fields.insert("format".into(), json!(f.id)); - app.ui.dialog = Some(Dialog { kind: dialogs::save_options::KIND.into(), fields }); - json!({ "pending": dialogs::save_options::KIND }) -} - -/// The most recent files remembered: Preferences → File Handling → Number of Recent Files to Display -/// shows 0 to this many of them. -pub const MAX_RECENT_FILES: usize = 30; - -/// Put `path` at the top of File → Open Recent Files. -pub fn note_recent(app: &mut VectorcraftApp, path: &str) { - let r = &mut app.ui.recent_files; - r.retain(|p| p != path); - r.insert(0, path.to_string()); - r.truncate(MAX_RECENT_FILES); -} - -/// The recent files File → Open Recent Files lists. -pub fn recent_files(app: &VectorcraftApp) -> &[String] { - let r = &app.ui.recent_files; - &r[..r.len().min(app.session.prefs.recent_files_count as usize)] -} - -/// The status once an export was cancelled. -pub const EXPORT_CANCELLED: &str = "Export cancelled"; - -/// File → Cancel Export (the progress dialog's Cancel): every export running in the background -/// stops and writes nothing more → `{cancelled: n}`. -pub fn cancel_exports(app: &mut VectorcraftApp) -> Value { - let n = app.background.cancel_exports(); - if n > 0 { - app.status(EXPORT_CANCELLED); - } - json!({ "cancelled": n }) -} - -/// The progress dialog's Continue in Background: the exports go on, shown in the status bar only -/// → `{hidden: n}`. -pub fn hide_exports(app: &mut VectorcraftApp) -> Value { - json!({ "hidden": app.background.hide_exports() }) -} - -/// Export the active document in `format` (default: the path's extension, else PNG) with the -/// `document.export` options in `params` (artboard, range, useArtboards, selectedOnly, ppi, SVG -/// options…): every file it writes (one per artboard, linked images) goes next to `path`. The -/// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are -/// several). -pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { - // Options given as anything but an object are none: the path and format still reach the - // encoder (an exporter plug-in is told the file it writes). - let params = &if params.is_object() { params.clone() } else { json!({}) }; - let f = fileio::writable_format(format, path.as_deref())?; - let st = app.session.active().ok_or("no document")?; - let doc = match fileio::export_source(st, params).map_err(|e| e.to_string())? { - std::borrow::Cow::Borrowed(_) => st.doc.clone(), - std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), - }; - let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new); - let path = export_path(app, path, f)?; - let mut again = params.clone(); - if let Some(o) = again.as_object_mut() { - o.insert("format".into(), json!(f.id)); - } - if let Some(r) = ask_before_losing(app, &path, "file.exportAs", &again) { - return Ok(r); - } - // An SVG given a .svgz name is written compressed. - let f = match fileio::format_for_name(&path) { - Some(z) if f.id == "svg" && z.id == "svgz" => z, - _ => f, - }; - // Background Export: encoded and written from this snapshot while editing goes on. The - // encoder is told the file it writes (an exporter plug-in gets its name and extension). - let label = format!("Exporting {}", fileio::file_name(&path)); - let (mut params, target) = (params.clone(), path.clone()); - if let Some(o) = params.as_object_mut() { - o.insert("path".into(), json!(path)); - } - // The progress dialog's counts and Cancel: a cancelled export stops drawing and never starts - // writing. - let progress = Progress::new(); - let (reporting, asked) = (progress.clone(), progress.clone()); - let work = move |write: Writer| { - progress::scope(&reporting, || { - cancel::check().map_err(|e| e.to_string())?; - let enc = fileio::encode_all(&doc, f.id, ¶ms).map_err(|e| e.to_string())?; - cancel::check().map_err(|e| e.to_string())?; - let files = write_encoded(write, &doc, &path, &enc)?; - let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings }); - if files.len() > 1 { - out["files"] = json!(files); - } - Ok(out) - }) - }; - let enabled = app.session.prefs.background_export; - let failed = target.clone(); - let mut r = background::run_with_progress(app, enabled, label, None, Some(progress), work, move |app, r| { - // A file written before the Cancel arrived is reported as written. - if r.is_err() && asked.cancelled() { - app.status(EXPORT_CANCELLED); - return Ok(json!({ "cancelled": true })); - } - let r = r.inspect_err(|e| report_write_error(app, true, &failed, e))?; - let what = match r["files"].as_array() { - Some(files) => format!("{} files", files.len()), - None => r["path"].as_str().unwrap_or_default().to_string(), - }; - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); - app.status(match warnings.first() { - Some(first) => format!("Exported {what} with {} note(s): {first}", warnings.len()), - None => format!("Exported {what}"), - }); - Ok(r) - })?; - if r.get("path").is_none() { - r["path"] = json!(target); - } - Ok(r) -} - -/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path -/// (Export Selection, Save for Office Documents, Text Export Options and the presets dialogs' -/// Export…), reported with the first of the command's warnings ([`report_saved`]). -pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result { - let (path, v) = run_to_file(app, id, ext, params)?; - report_saved(app, &path, &v); - Ok(path) -} - -/// Run an engine command that returns `{data}` or `{dataBase64}` and write the bytes to a file -/// picked with file name `name` suggested next to the document (the web downloads them as `name`) -/// → where they went. -pub(crate) fn save_command_output_named(app: &mut VectorcraftApp, id: &str, name: &str, params: Value) -> Result { - let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; - let bytes = take_output(&mut v)?; - let path = write_named(app, None, name, &bytes)?; - app.status(format!("Saved {path}")); - Ok(path) -} - -/// The bytes a command returned, taken out of its result `v`, which keeps the rest: binary output -/// comes as base64, text (swatch libraries) as is. -fn take_output(v: &mut Value) -> Result, String> { - let o = v.as_object_mut().ok_or("no data")?; - let bytes = match (o.remove("dataBase64"), o.remove("data")) { - (Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64), - (_, Some(Value::String(text))) => Some(text.into_bytes()), - _ => None, - }; - bytes.ok_or_else(|| "no data".into()) -} - -/// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to -/// that path (else a picked or suggested name) → (path, the command's result without the data). -/// The command runs before a path is asked for, so bad params never open a save dialog, except -/// for Export Selection without a `format`, which takes it from the picked path. Export Selection -/// with a `format` picks a file with that format's extension in place of `ext`. -pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> { - let mut path = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string)); - let selection = id == "document.exportSelection"; - let given = params.get("format").and_then(Value::as_str).and_then(fileio::format).filter(|_| selection); - let ext = given.map_or(ext, |f| f.extensions[0]); - if let Some(o) = params.as_object_mut() - && o.get("format").is_none() - && selection - { - let picked = target_path_keeping(app, path, ext, |_| true)?; - let e = Some(fileio::extension(&picked)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into()); - o.insert("format".into(), serde_json::json!(e)); - path = Some(picked); - } - let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; - let bytes = take_output(&mut v)?; - let path = match path { - Some(p) => p, - None => target_path(app, None, ext)?, - }; - write_reporting(app, &path, &bytes)?; - Ok((path, v)) -} - -/// File → Save as PDF: `document.exportPdf` with `params` (the Save PDF dialog's options), written -/// to `path` (else a picked or suggested name) → `{path, bytes, warnings}`. With -/// `viewAfterSaving`, the written file opens in the system viewer (not on the web, which -/// downloads it). -pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result { - let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); - let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?; - if view { - view_file(app, &path); - } - report_saved(app, &path, &v); - v["path"] = Value::String(path); - Ok(v) -} - -/// `path` as an absolute `file://` URL for the system opener (bytes other than letters, digits and -/// `/-._~:` percent-encoded). -pub(crate) fn file_url(path: &str) -> String { - let abs = std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()); - let abs = abs.replace('\\', "/"); - let mut url = String::from(if abs.starts_with('/') { "file://" } else { "file:///" }); - for b in abs.bytes() { - if b.is_ascii_alphanumeric() || b"/-._~:".contains(&b) { - url.push(char::from(b)); - } else { - url.push_str(&format!("%{b:02X}")); - } - } - url -} - -/// File → Revert: ask first; OK runs `file.revert` with `confirmed`, which goes to the engine. -pub fn ask_revert(app: &mut VectorcraftApp) -> Result { - let c = vectorcraft_engine::find_command("file.revert").ok_or("no revert command")?; - (c.enabled)(&app.session)?; - let name = app.session.active().map(|d| d.title()).unwrap_or_default(); - let message = crate::i18n::fmt(tl!("Revert to the saved version of “{name}”?"), &[("name", &name)]); - dialogs::confirm::ask(app, &message, tl!("Changes made since it was last saved will be lost."), "file.revert", json!({ "confirmed": true })); - Ok(json!({ "pending": dialogs::confirm::KIND })) -} - -/// File → Show in Folder: the document's file in the system file manager. -pub fn reveal(app: &mut VectorcraftApp) -> Result { - let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?; - reveal_path(app, &path)?; - Ok(json!({ "path": path })) -} - -/// Show the file at `path` in the system file manager (desktop). -pub(crate) fn reveal_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { - app.services.reveal.as_mut().ok_or("no file manager here")?(path) -} - -/// Open `path` (a file or a folder) in the system's default app for it (desktop). -pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { - app.services.open_file.as_mut().ok_or("no app to open files here")?(path) -} - -/// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; -/// without one the web downloads them (the ZIP, or each file) and the other frontends get them -/// back as base64. With `openLocation`, the file manager shows the first file written. When an -/// export into a folder or a download has warnings, the status bar shows the first as -/// [`report_saved`] shows a save's notes: "Saved with n note(s): …". -pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { - export_files(app, "document.exportForScreens", params) -} - -/// [`export_for_screens`] through command `id` (`document.exportForScreens` or `assets.export`). -/// On the desktop with Background Export on, an export into a folder is checked, journaled and -/// prepared from the document as it is now, and the files are encoded and written on the -/// background thread ([`background`]): this returns `{background: true, status}` at once, with the -/// progress dialog's Cancel, and the status bar reports the files once they are written. -pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> Result { - let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); - let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); - // Without a folder the files come back in the reply, which the caller waits for. - let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty(); - if !in_background { - // Made at once, after the exports and saves started before it. - background::wait_all(app); - let r = app.run(id, params)?; - return report_exported(app, r, &folder, open_location); - } - let export = app.session.prepare_screen_export(id, ¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; - let label = format!("Exporting {}", fileio::file_name(&folder)); - // The progress dialog counts the files, and its Cancel stops the export before the next one. - let progress = Progress::of_files(); - let (reporting, asked) = (progress.clone(), progress.clone()); - let work = move |write: Writer| { - progress::scope(&reporting, || { - let mut write = |path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); - export.write(&mut write).map_err(|e| e.to_string()) - }) - }; - background::run_with_progress(app, true, label, None, Some(progress), work, move |app, r| { - // Files written before the Cancel arrived stay in the folder. - if r.is_err() && asked.cancelled() { - app.status(EXPORT_CANCELLED); - return Ok(json!({ "cancelled": true })); - } - let r = r.inspect_err(|e| report_write_error(app, true, &folder, e))?; - report_exported(app, r, &folder, open_location) - }) -} - -/// Report the files `r` (the reply of `document.exportForScreens`) in the status bar: the count -/// written into `folder`, else the ZIP or the files downloaded (the web); with `open_location`, -/// the file manager shows the first file written. -fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_location: bool) -> Result { - let count = r["files"].as_array().map_or(0, Vec::len); - let warned = r["warnings"].as_array().is_some_and(|w| !w.is_empty()); - // Files that came back as bytes: (name, base64). - let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() { - Some(zip) => vec![(r["name"].as_str().unwrap_or("Export.zip"), zip)], - None => { - r["files"].as_array().into_iter().flatten().filter_map(|f| Some((f.get("name")?.as_str()?, f.get("dataBase64")?.as_str()?))).collect() - } - }; - if returned.is_empty() { - let first = r["path"].as_str().or_else(|| r["files"][0].as_str()); - let mut status = format!("Exported {count} file(s) to {folder}"); - if warned { - status = saved_status(r["path"].as_str().unwrap_or(folder), &r); - } - if open_location - && let Some(first) = first - && let Err(e) = reveal_path(app, first) - { - status = format!("{status} ({e})"); - } - app.status(status); - } else if let Some(download) = app.services.download.as_mut() { - for (name, data) in &returned { - let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; - download(name, &bytes)?; - } - let what = match returned.as_slice() { - [(name, _)] => (*name).to_string(), - files => format!("{} files", files.len()), - }; - match returned.first().filter(|_| warned) { - Some((name, _)) => report_saved(app, name, &r), - None => app.status(format!("Downloaded {what}")), - } - } - Ok(r) -} - -/// Place a file's bytes (no path, so embedded) centered in the view: `file.place`, read off the UI -/// thread on the desktop ([`crate::place::run_for_user`]). -pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { - crate::place::run_for_user(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) -} +mod export; +mod open; +mod save; + +pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output}; +pub(crate) use export::{export_files, file_url, open_in_app, reveal_path, run_to_file, save_command_output_named}; +#[cfg(test)] +pub(crate) use open::open_file; +pub(crate) use open::opened; +pub use open::{ + MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document, + open_path, open_recent_reporting, open_reporting, opens_as, place_bytes, recent_files, report_open_error, +}; +#[cfg(test)] +pub(crate) use save::report_write_error; +pub use save::{ask_format_options, ask_pdf_options, remember_view, save, save_pdf}; +pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to}; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs new file mode 100644 index 000000000..05df48941 --- /dev/null +++ b/crates/ui-egui/src/io/export.rs @@ -0,0 +1,302 @@ +use super::save::{ + ask_before_losing, export_path, is_web, report_saved, report_write_error, saved_status, target_path, target_path_keeping, view_file, + write_encoded, write_named, write_reporting, +}; +use super::*; + +/// The status once an export was cancelled. +pub const EXPORT_CANCELLED: &str = "Export cancelled"; + +/// File → Cancel Export (the progress dialog's Cancel): every export running in the background +/// stops and writes nothing more → `{cancelled: n}`. +pub fn cancel_exports(app: &mut VectorcraftApp) -> Value { + let n = app.background.cancel_exports(); + if n > 0 { + app.status(EXPORT_CANCELLED); + } + json!({ "cancelled": n }) +} + +/// The progress dialog's Continue in Background: the exports go on, shown in the status bar only +/// → `{hidden: n}`. +pub fn hide_exports(app: &mut VectorcraftApp) -> Value { + json!({ "hidden": app.background.hide_exports() }) +} + +/// Export the active document in `format` (default: the path's extension, else PNG) with the +/// `document.export` options in `params` (artboard, range, useArtboards, selectedOnly, ppi, SVG +/// options…): every file it writes (one per artboard, linked images) goes next to `path`. The +/// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are +/// several). +pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { + // Options given as anything but an object are none: the path and format still reach the + // encoder (an exporter plug-in is told the file it writes). + let params = &if params.is_object() { params.clone() } else { json!({}) }; + let f = fileio::writable_format(format, path.as_deref())?; + let st = app.session.active().ok_or("no document")?; + let doc = match fileio::export_source(st, params).map_err(|e| e.to_string())? { + std::borrow::Cow::Borrowed(_) => st.doc.clone(), + std::borrow::Cow::Owned(d) => std::sync::Arc::new(d), + }; + let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new); + let path = export_path(app, path, f)?; + let mut again = params.clone(); + if let Some(o) = again.as_object_mut() { + o.insert("format".into(), json!(f.id)); + } + if let Some(r) = ask_before_losing(app, &path, "file.exportAs", &again) { + return Ok(r); + } + // An SVG given a .svgz name is written compressed. + let f = match fileio::format_for_name(&path) { + Some(z) if f.id == "svg" && z.id == "svgz" => z, + _ => f, + }; + // Background Export: encoded and written from this snapshot while editing goes on. The + // encoder is told the file it writes (an exporter plug-in gets its name and extension). + let label = format!("Exporting {}", fileio::file_name(&path)); + let (mut params, target) = (params.clone(), path.clone()); + if let Some(o) = params.as_object_mut() { + o.insert("path".into(), json!(path)); + } + // The progress dialog's counts and Cancel: a cancelled export stops drawing and never starts + // writing. + let progress = Progress::new(); + let (reporting, asked) = (progress.clone(), progress.clone()); + let work = move |write: Writer| { + progress::scope(&reporting, || { + cancel::check().map_err(|e| e.to_string())?; + let enc = fileio::encode_all(&doc, f.id, ¶ms).map_err(|e| e.to_string())?; + cancel::check().map_err(|e| e.to_string())?; + let files = write_encoded(write, &doc, &path, &enc)?; + let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings }); + if files.len() > 1 { + out["files"] = json!(files); + } + Ok(out) + }) + }; + let enabled = app.session.prefs.background_export; + let failed = target.clone(); + let mut r = background::run_with_progress(app, enabled, label, None, Some(progress), work, move |app, r| { + // A file written before the Cancel arrived is reported as written. + if r.is_err() && asked.cancelled() { + app.status(EXPORT_CANCELLED); + return Ok(json!({ "cancelled": true })); + } + let r = r.inspect_err(|e| report_write_error(app, true, &failed, e))?; + let what = match r["files"].as_array() { + Some(files) => format!("{} files", files.len()), + None => r["path"].as_str().unwrap_or_default().to_string(), + }; + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + app.status(match warnings.first() { + Some(first) => format!("Exported {what} with {} note(s): {first}", warnings.len()), + None => format!("Exported {what}"), + }); + Ok(r) + })?; + if r.get("path").is_none() { + r["path"] = json!(target); + } + Ok(r) +} + +/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path +/// (Export Selection, Save for Office Documents, Text Export Options and the presets dialogs' +/// Export…), reported with the first of the command's warnings ([`report_saved`]). +pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result { + let (path, v) = run_to_file(app, id, ext, params)?; + report_saved(app, &path, &v); + Ok(path) +} + +/// Run an engine command that returns `{data}` or `{dataBase64}` and write the bytes to a file +/// picked with file name `name` suggested next to the document (the web downloads them as `name`) +/// → where they went. +pub(crate) fn save_command_output_named(app: &mut VectorcraftApp, id: &str, name: &str, params: Value) -> Result { + let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; + let bytes = take_output(&mut v)?; + let path = write_named(app, None, name, &bytes)?; + app.status(format!("Saved {path}")); + Ok(path) +} + +/// The bytes a command returned, taken out of its result `v`, which keeps the rest: binary output +/// comes as base64, text (swatch libraries) as is. +fn take_output(v: &mut Value) -> Result, String> { + let o = v.as_object_mut().ok_or("no data")?; + let bytes = match (o.remove("dataBase64"), o.remove("data")) { + (Some(Value::String(b64)), _) => vectorcraft_format::base64_decode(&b64), + (_, Some(Value::String(text))) => Some(text.into_bytes()), + _ => None, + }; + bytes.ok_or_else(|| "no data".into()) +} + +/// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to +/// that path (else a picked or suggested name) → (path, the command's result without the data). +/// The command runs before a path is asked for, so bad params never open a save dialog, except +/// for Export Selection without a `format`, which takes it from the picked path. Export Selection +/// with a `format` picks a file with that format's extension in place of `ext`. +pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> { + let mut path = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string)); + let selection = id == "document.exportSelection"; + let given = params.get("format").and_then(Value::as_str).and_then(fileio::format).filter(|_| selection); + let ext = given.map_or(ext, |f| f.extensions[0]); + if let Some(o) = params.as_object_mut() + && o.get("format").is_none() + && selection + { + let picked = target_path_keeping(app, path, ext, |_| true)?; + let e = Some(fileio::extension(&picked)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into()); + o.insert("format".into(), serde_json::json!(e)); + path = Some(picked); + } + let mut v = app.session.execute(id, ¶ms).map_err(|e| e.to_string())?; + let bytes = take_output(&mut v)?; + let path = match path { + Some(p) => p, + None => target_path(app, None, ext)?, + }; + write_reporting(app, &path, &bytes)?; + Ok((path, v)) +} + +/// File → Save as PDF: `document.exportPdf` with `params` (the Save PDF dialog's options), written +/// to `path` (else a picked or suggested name) → `{path, bytes, warnings}`. With +/// `viewAfterSaving`, the written file opens in the system viewer (not on the web, which +/// downloads it). +pub fn export_pdf(app: &mut VectorcraftApp, params: Value) -> Result { + let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); + let (path, mut v) = run_to_file(app, "document.exportPdf", "pdf", params)?; + if view { + view_file(app, &path); + } + report_saved(app, &path, &v); + v["path"] = Value::String(path); + Ok(v) +} + +/// `path` as an absolute `file://` URL for the system opener (bytes other than letters, digits and +/// `/-._~:` percent-encoded). +pub(crate) fn file_url(path: &str) -> String { + let abs = std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()); + let abs = abs.replace('\\', "/"); + let mut url = String::from(if abs.starts_with('/') { "file://" } else { "file:///" }); + for b in abs.bytes() { + if b.is_ascii_alphanumeric() || b"/-._~:".contains(&b) { + url.push(char::from(b)); + } else { + url.push_str(&format!("%{b:02X}")); + } + } + url +} + +/// File → Show in Folder: the document's file in the system file manager. +pub fn reveal(app: &mut VectorcraftApp) -> Result { + let path = app.session.active().and_then(|d| d.path.clone()).ok_or("the document has never been saved")?; + reveal_path(app, &path)?; + Ok(json!({ "path": path })) +} + +/// Show the file at `path` in the system file manager (desktop). +pub(crate) fn reveal_path(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { + app.services.reveal.as_mut().ok_or("no file manager here")?(path) +} + +/// Open `path` (a file or a folder) in the system's default app for it (desktop). +pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { + app.services.open_file.as_mut().ok_or("no app to open files here")?(path) +} + +/// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; +/// without one the web downloads them (the ZIP, or each file) and the other frontends get them +/// back as base64. With `openLocation`, the file manager shows the first file written. When an +/// export into a folder or a download has warnings, the status bar shows the first as +/// [`report_saved`] shows a save's notes: "Saved with n note(s): …". +pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { + export_files(app, "document.exportForScreens", params) +} + +/// [`export_for_screens`] through command `id` (`document.exportForScreens` or `assets.export`). +/// On the desktop with Background Export on, an export into a folder is checked, journaled and +/// prepared from the document as it is now, and the files are encoded and written on the +/// background thread ([`background`]): this returns `{background: true, status}` at once, with the +/// progress dialog's Cancel, and the status bar reports the files once they are written. +pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> Result { + let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); + let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); + // Without a folder the files come back in the reply, which the caller waits for. + let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty(); + if !in_background { + // Made at once, after the exports and saves started before it. + background::wait_all(app); + let r = app.run(id, params)?; + return report_exported(app, r, &folder, open_location); + } + let export = app.session.prepare_screen_export(id, ¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; + let label = format!("Exporting {}", fileio::file_name(&folder)); + // The progress dialog counts the files, and its Cancel stops the export before the next one. + let progress = Progress::of_files(); + let (reporting, asked) = (progress.clone(), progress.clone()); + let work = move |write: Writer| { + progress::scope(&reporting, || { + let mut write = |path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + export.write(&mut write).map_err(|e| e.to_string()) + }) + }; + background::run_with_progress(app, true, label, None, Some(progress), work, move |app, r| { + // Files written before the Cancel arrived stay in the folder. + if r.is_err() && asked.cancelled() { + app.status(EXPORT_CANCELLED); + return Ok(json!({ "cancelled": true })); + } + let r = r.inspect_err(|e| report_write_error(app, true, &folder, e))?; + report_exported(app, r, &folder, open_location) + }) +} + +/// Report the files `r` (the reply of `document.exportForScreens`) in the status bar: the count +/// written into `folder`, else the ZIP or the files downloaded (the web); with `open_location`, +/// the file manager shows the first file written. +fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_location: bool) -> Result { + let count = r["files"].as_array().map_or(0, Vec::len); + let warned = r["warnings"].as_array().is_some_and(|w| !w.is_empty()); + // Files that came back as bytes: (name, base64). + let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() { + Some(zip) => vec![(r["name"].as_str().unwrap_or("Export.zip"), zip)], + None => { + r["files"].as_array().into_iter().flatten().filter_map(|f| Some((f.get("name")?.as_str()?, f.get("dataBase64")?.as_str()?))).collect() + } + }; + if returned.is_empty() { + let first = r["path"].as_str().or_else(|| r["files"][0].as_str()); + let mut status = format!("Exported {count} file(s) to {folder}"); + if warned { + status = saved_status(r["path"].as_str().unwrap_or(folder), &r); + } + if open_location + && let Some(first) = first + && let Err(e) = reveal_path(app, first) + { + status = format!("{status} ({e})"); + } + app.status(status); + } else if let Some(download) = app.services.download.as_mut() { + for (name, data) in &returned { + let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; + download(name, &bytes)?; + } + let what = match returned.as_slice() { + [(name, _)] => (*name).to_string(), + files => format!("{} files", files.len()), + }; + match returned.first().filter(|_| warned) { + Some((name, _)) => report_saved(app, name, &r), + None => app.status(format!("Downloaded {what}")), + } + } + Ok(r) +} diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs new file mode 100644 index 000000000..35da8b1b3 --- /dev/null +++ b/crates/ui-egui/src/io/open.rs @@ -0,0 +1,362 @@ +use super::save::suggested; +use super::*; + +/// Template extensions New from Template's open dialog lists first. +const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; + +/// Open bytes of any readable format as a new document (templates open untitled); swatch and +/// graphic style library files open in the library panel, Libraries panel files are added to it, +/// flattener, PDF, print and perspective grid presets files are imported, a shortcut set another +/// app saved (`.kys`) opens Import Shortcut Set, and a script written for the reference app +/// (`.jsx`, `.jsxinc`, `.jsxbin`) shows its check (Check Script…). → `document.open`'s result for +/// a document opened now (its `warnings` say what didn't come in as it was), else null. +pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result { + let opens = opens_as(name); + // A WebAssembly plug-in is installed. + if opens == Some(OpensAs::Plugin) { + let module = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}); + return install_module(app, module).map(|_| Value::Null); + } + if opens == Some(OpensAs::ShortcutSet) { + return dialogs::shortcut_import::open(app, name, bytes).map(|_| Value::Null); + } + if opens == Some(OpensAs::Script) { + let p = match path { + Some(path) => json!({ "path": path }), + None => json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}), + }; + return dialogs::script_check::command(app, &p).map(|_| Value::Null); + } + if let Some(OpensAs::Presets { import, what }) = opens { + let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; + let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + app.status(format!("Imported {what}: {}", names.join(", "))); + return Ok(Value::Null); + } + if opens == Some(OpensAs::Library) { + return crate::panels::libraries::import(app, json!({"data": String::from_utf8_lossy(bytes)})).map(|_| Value::Null); + } + let swatches = opens == Some(OpensAs::SwatchLibrary); + if swatches || opens == Some(OpensAs::StyleLibrary) { + let p = match path { + Some(path) => serde_json::json!({ "path": path }), + // `.ase` swatch libraries are binary. + None if swatches => serde_json::json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}), + None => serde_json::json!({"name": name, "data": String::from_utf8_lossy(bytes)}), + }; + let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library }; + return load(app, p).map(|_| Value::Null); + } + // A PDF with several pages or a password asks first (the Import PDF dialog), and so does a + // DXF drawing (DXF Import Options). + if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) + || crate::dialogs::dxf_import::offer(app, name, bytes, path.clone(), None) + { + return Ok(Value::Null); + } + open_document(app, name, bytes, path, &Value::Null) +} + +/// The kinds of file File → Open installs, imports, checks, shows in the library panel or the +/// Libraries panel, or reviews in Import Shortcut Set ([`opens_as`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OpensAs { + /// A WebAssembly plug-in: installed. + Plugin, + /// A shortcut set another app saved (`.kys`): reviewed in Import Shortcut Set. + ShortcutSet, + /// A script written for the reference app: checked without running it (Check Script…). + Script, + /// Flattener, PDF, print or perspective grid presets: imported with command `import`, and + /// `what` names them in the status bar. + Presets { import: &'static str, what: &'static str }, + /// A Libraries panel file: added to the Libraries panel as a new library. + Library, + /// A swatch library: opened in the library panel. + SwatchLibrary, + /// A graphic style library: opened in the library panel. + StyleLibrary, +} + +/// The kind of file named `name` (by its extension) that File → Open installs, imports, checks, +/// shows in the library panel or the Libraries panel, or reviews in Import Shortcut Set +/// ([`open_bytes`]), else `None`. A drop opens these files the same way, wherever they are dropped +/// ([`VectorcraftApp::drop_target`]). +pub fn opens_as(name: &str) -> Option { + let ext = fileio::extension(name); + let presets = [ + (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), + (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), + (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), + (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), + ]; + if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { + Some(OpensAs::Plugin) + } else if vectorcraft_engine::cmd::shortcutset::EXTS.contains(&ext.as_str()) { + Some(OpensAs::ShortcutSet) + } else if vectorcraft_engine::cmd::scripts::EXTS.contains(&ext.as_str()) { + Some(OpensAs::Script) + } else if let Some(&(_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { + Some(OpensAs::Presets { import, what }) + } else if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) { + Some(OpensAs::Library) + } else if vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str()) { + Some(OpensAs::SwatchLibrary) + } else if ext == vectorcraft_doc::style_libs::STYLES_EXT { + Some(OpensAs::StyleLibrary) + } else { + None + } +} + +/// Open a document through the engine loader with the `document.open` options in `p` → +/// `document.open`'s result. +pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { + let r = fileio::open_bytes_with(&mut app.session, name, bytes, path, p).map_err(|e| e.to_string())?; + opened(app, &r); + Ok(r) +} + +/// A document just opened (`r`: `document.open`'s result): the views follow, the status bar says +/// what didn't come in as it was, and missing links are asked about. +pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { + app.sync_views(); + if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { + app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default())); + } + crate::dialogs::missing_links::after_open(app, r); +} + +/// A path from the open dialog, or "cancelled". +fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result { + crate::picks::open(app, pick).ok_or_else(|| "cancelled".into()) +} + +/// Object › Plug-ins › Install Plug-in…: installs the module `p` gives (`path`, or `dataBase64` +/// with `name`), else a picked `.wasm` (the web's file picker hands the file to [`open_bytes`], +/// which installs it) → [`install_module`]'s result. +pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result { + let given = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + if given("path") || given("dataBase64") { + return install_module(app, p.clone()); + } + if app.services.open_async.is_some() { + return open_dialog(app).map(|_| Value::Null); + } + let path = + pick_open(app, &FilePick { filters: vec![FilePick::filter("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?; + install_module(app, json!({ "path": path })) +} + +/// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` +/// with `name`) → `plugin.install`'s result. A filter that asks for a heavy budget the user hasn't +/// allowed yet (`allowHeavy`) asks them first, and OK installs it → `{pending}` while it asks. +pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { + if module.get("allowHeavy").and_then(Value::as_bool) != Some(true) { + let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); + let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); + let info = app.run("plugin.inspect", source.clone())?; + if info["budget"] == "heavy" { + let name = info["name"].as_str().unwrap_or_default(); + let (minutes, times) = vectorcraft_plugins::heavy_terms(); + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", name)]); + let detail = crate::i18n::fmt( + tl!( + "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." + ), + &[("minutes", &minutes.to_string()), ("times", ×.to_string())], + ); + let mut allowed = source; + allowed["allowHeavy"] = json!(true); + dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); + return Ok(json!({ "pending": dialogs::confirm::KIND })); + } + } + let r = app.run("plugin.install", module)?; + app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); + Ok(r) +} + +/// Where File → Open… starts: the folder of the most recently opened file, else the active +/// document's (#4122). +fn open_dialog_folder(app: &VectorcraftApp) -> Option { + app.ui.recent_files.iter().find_map(|p| fileio::dialog_folder(p)).or_else(|| suggested(app, "").1) +} + +/// File → Open… +pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { + if let Some(f) = app.services.open_async.as_mut() { + f(); + return Ok(()); + } + let path = + pick_open(app, &FilePick { filters: FilePick::filters(fileio::open_filters()), folder: open_dialog_folder(app), ..Default::default() })?; + open_reporting(app, &path).map(|_| ()) +} + +/// A file the user asked to open (File › Open, Open Recent, the Home screen, a drop, the Finder) +/// couldn't be: say so in a dialog as well as the status bar, which is easily missed (#861). +pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) { + app.status(format!("Couldn't open {name}: {e}")); + let message = crate::i18n::fmt(tl!("Can't open “{name}”."), &[("name", name)]); + dialogs::confirm::tell(app, &message, &crate::i18n::message(crate::i18n::current(), e)); +} + +/// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). On +/// the desktop a document is decoded off the UI thread ([`crate::opening`]): the result is then +/// `{background: true, status}`, and the document opens in its tab (or the failure is reported) +/// once it is ready. +pub fn open_reporting(app: &mut VectorcraftApp, path: &str) -> Result { + let r = open_file(app, path, crate::opening::available()); + if let Err(e) = &r { + report_open_error(app, &fileio::file_name(path), e); + } + r +} + +/// Home › Recent Files and File › Open Recent: focus an already-open tab for that path instead of +/// calling `document.open` again (#3455). +pub fn open_recent_reporting(app: &mut VectorcraftApp, path: &str) -> Result { + if let Some(r) = try_activate_open_document(app, path)? { + return Ok(r); + } + open_reporting(app, path) +} + +fn try_activate_open_document(app: &mut VectorcraftApp, path: &str) -> Result, String> { + let Some(index) = document_index_for_path(app, path) else { + return Ok(None); + }; + let st = &app.session.documents()[index]; + let r = json!({ + "index": index, + "title": st.title(), + "format": st.format, + "warnings": [], + "restored": false, + "activated": true, + }); + app.run("document.activate", json!({ "index": index }))?; + opened(app, &r); + Ok(Some(r)) +} + +fn document_index_for_path(app: &VectorcraftApp, path: &str) -> Option { + app.session.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_open_path(p, path))) +} + +fn same_open_path(a: &str, b: &str) -> bool { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + +fn read(app: &VectorcraftApp, path: &str) -> Result, String> { + app.services.read.as_ref().ok_or("no file reader")?(path) +} + +/// Open the file at `path` (see [`open_bytes`]) → `document.open`'s result, or null. +pub fn open_path(app: &mut VectorcraftApp, path: &str) -> Result { + open_file(app, path, false) +} + +/// [`open_path`]; with `background`, a document (not a file that installs, imports or asks +/// first) is decoded off the UI thread ([`crate::opening::start`]). +pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result { + // A shortcut set is read by Import Shortcut Set, up to its reader's cap. + if dialogs::shortcut_import::is_kys(path) { + dialogs::shortcut_import::command(app, &json!({ "path": path }))?; + note_recent(app, path); + return Ok(Value::Null); + } + // A script is read by `script.check`, up to its limit. + if opens_as(path) == Some(OpensAs::Script) { + dialogs::script_check::command(app, &json!({ "path": path }))?; + note_recent(app, path); + return Ok(Value::Null); + } + let mut bytes = read(app, path)?; + if background && opens_as(path).is_none() { + // A PDF with several pages or a password, or a DXF drawing, asks first, as at once. + if crate::dialogs::import_pdf::offer(app, path, &bytes, Some(path.to_string()), None) + || crate::dialogs::dxf_import::offer(app, path, &bytes, Some(path.to_string()), None) + { + note_recent(app, path); + return Ok(Value::Null); + } + match crate::opening::start(app, path, bytes) { + Ok(r) => return Ok(r), + // No thread: at once. + Err(back) => bytes = back, + } + } + let r = open_bytes(app, path, &bytes, Some(path.to_string()))?; + note_recent(app, path); + Ok(r) +} + +/// File → New from Template…: a template (or any readable file) as a new untitled document. Without +/// a path the open dialog starts in the Templates folder. On the web the browser's file picker +/// opens it (`.vctemplate`, `.ait` and template files open untitled there too). +pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Result { + let path = match path { + Some(p) => p, + None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), + None => { + let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters())); + pick_open(app, &FilePick { folder: fileio::templates_dialog_folder(&app.session.prefs), filters, ..Default::default() })? + } + }; + let bytes = read(app, &path)?; + let r = fileio::open_template(&mut app.session, &path, &bytes, Some(&path)).map_err(|e| e.to_string())?; + app.sync_views(); + crate::dialogs::missing_links::after_open(app, &r); + Ok(r) +} + +/// Open the save options dialog of format `f` before writing `path`: `action` is the save command +/// it finishes, and `pick` (the web's Save As) also names the file and chooses the format. +pub(super) fn open_options(app: &mut VectorcraftApp, action: &str, f: &Format, path: &str, pick: bool) -> Value { + let mut fields = dialogs::save_options::option_values(app, f); + fields.insert("__action".into(), json!(action)); + fields.insert("__pick".into(), json!(pick)); + fields.insert("path".into(), json!(path)); + fields.insert("format".into(), json!(f.id)); + app.ui.dialog = Some(Dialog { kind: dialogs::save_options::KIND.into(), fields }); + json!({ "pending": dialogs::save_options::KIND }) +} + +/// The most recent files remembered: Preferences → File Handling → Number of Recent Files to Display +/// shows 0 to this many of them. +pub const MAX_RECENT_FILES: usize = 30; + +/// Put `path` at the top of File → Open Recent Files. +pub fn note_recent(app: &mut VectorcraftApp, path: &str) { + let r = &mut app.ui.recent_files; + r.retain(|p| p != path); + r.insert(0, path.to_string()); + r.truncate(MAX_RECENT_FILES); +} + +/// The recent files File → Open Recent Files lists. +pub fn recent_files(app: &VectorcraftApp) -> &[String] { + let r = &app.ui.recent_files; + &r[..r.len().min(app.session.prefs.recent_files_count as usize)] +} + +/// File → Revert: ask first; OK runs `file.revert` with `confirmed`, which goes to the engine. +pub fn ask_revert(app: &mut VectorcraftApp) -> Result { + let c = vectorcraft_engine::find_command("file.revert").ok_or("no revert command")?; + (c.enabled)(&app.session)?; + let name = app.session.active().map(|d| d.title()).unwrap_or_default(); + let message = crate::i18n::fmt(tl!("Revert to the saved version of “{name}”?"), &[("name", &name)]); + dialogs::confirm::ask(app, &message, tl!("Changes made since it was last saved will be lost."), "file.revert", json!({ "confirmed": true })); + Ok(json!({ "pending": dialogs::confirm::KIND })) +} + +/// Place a file's bytes (no path, so embedded) centered in the view: `file.place`, read off the UI +/// thread on the desktop ([`crate::place::run_for_user`]). +pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { + crate::place::run_for_user(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) +} diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs new file mode 100644 index 000000000..b5489b33d --- /dev/null +++ b/crates/ui-egui/src/io/save.rs @@ -0,0 +1,440 @@ +use super::export::file_url; +use super::open::{note_recent, open_options}; +use super::*; + +/// A save or an export that didn't write `path`: say so in a dialog that names the file and the +/// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which +/// the caller's error becomes, is replaced by the next message, and a document that wasn't saved +/// must not look saved: it stays modified, and what was at `path` is as it was. Not for a request +/// an agent sent and waits on: the error is its reply. +pub(crate) fn report_write_error(app: &mut VectorcraftApp, export: bool, path: &str, e: &str) { + if app.ui.answering_control { + return; + } + let name = fileio::file_name(path); + let message = if export { tl!("Can't export “{name}”.") } else { tl!("Can't save “{name}”.") }; + dialogs::confirm::tell(app, &crate::i18n::fmt(message, &[("name", &name)]), &crate::i18n::message(crate::i18n::current(), e)); +} + +/// [`write_to`], a failure reported ([`report_write_error`]). +pub(crate) fn write_reporting(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> { + let r = write_to(&mut app.services, path, bytes); + if let Err(e) = &r { + report_write_error(app, false, path, e); + } + r +} + +/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`. +pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> { + if let Some(dl) = services.download.as_mut() { + return dl(&fileio::file_name(path), bytes); + } + services.write.as_mut().ok_or("no file writer")?(path, bytes) +} + +/// The web saves by downloading: no save panel, no folders. +pub(crate) fn is_web(app: &VectorcraftApp) -> bool { + app.services.download.is_some() +} + +/// Write `bytes` to `path`, else to a file picked in a save panel that suggests `name` next to the +/// document and shows its file type, [`file_types`] (the web downloads them as `name`) → where +/// they went. +pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: &str, bytes: &[u8]) -> Result { + let path = match path { + Some(p) => p, + None => { + let (_, folder) = suggested(app, ""); + let filters = file_types(&fileio::extension(name)); + pick_path(app, &FilePick { name: name.to_string(), folder, filters }, |_| false)? + } + }; + write_reporting(app, &path, bytes)?; + Ok(path) +} + +/// Where a file goes when no path was given: the suggested name on the web (a download), else the +/// save panel's choice with the suggested name's extension, [`with_written_extension`]: a name +/// typed with the extension of a format `keeps` takes stays as typed too. +fn pick_path(app: &mut VectorcraftApp, pick: &FilePick, keeps: impl Fn(&Format) -> bool) -> Result { + let ext = fileio::extension(&pick.name); + pick_finished(app, pick, |picked, _| with_written_extension(picked, &ext, &keeps)) +} + +/// `path` as typed in a save panel for a file with extension `ext`: kept when its extension is +/// `ext` in any letter case, or names the format `ext` names (`photo.jpeg` for a `.jpg` file) or +/// a format `keeps` takes. An extension that names another format (`card.svg` for a `.png` file) +/// is replaced with `ext`, and `.ext` is added to any other path (`v1.2` becomes `v1.2.png`). +fn with_written_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { + let written = fileio::format(ext); + let keeps = |g: &Format| written.is_some_and(|w| w.id == g.id) || keeps(g); + if !ext.is_empty() && !fileio::extension(path).eq_ignore_ascii_case(ext) && fileio::format_for_name(path).is_some_and(|g| !keeps(g)) { + return std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned(); + } + with_extension(path, ext, keeps) +} + +/// The suggested name made into a path by `finish` on the web (a download), else the save panel's +/// choice made into one, with the extension of the file type picked in the panel when it reports +/// one ([`crate::picks::PickAnswer`]). When `finish` changed the choice (an extension added or +/// replaced) and a file is there, which the panel did not ask about, a question asks before +/// replacing it ([`crate::picks::ask_to_replace`]) and this answers "cancelled"; the question's OK +/// runs what asked again. +fn pick_finished(app: &mut VectorcraftApp, pick: &FilePick, finish: impl Fn(&str, Option<&str>) -> String) -> Result { + if is_web(app) { + return Ok(finish(&pick.name, None)); + } + let (picked, file_type) = crate::picks::save_with_type(app, pick).ok_or("cancelled")?; + let path = finish(&picked, file_type.as_deref()); + if path != picked && crate::picks::ask_to_replace(app, crate::picks::PickRequest::Save(pick.clone()), &picked, file_type.as_deref(), &path) { + return Err("cancelled".into()); + } + Ok(path) +} + +/// `path` as typed in a save panel: kept when it ends in `ext` or its extension names a format +/// `keeps` takes, else with `.ext` added. +fn with_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { + let has = fileio::extension(path); + if ext.is_empty() || has.eq_ignore_ascii_case(ext) || fileio::format_for_name(path).is_some_and(keeps) { + path.to_string() + } else { + format!("{path}.{ext}") + } +} + +/// The active document's file name with extension `ext`, and its folder. +pub(super) fn suggested(app: &VectorcraftApp, ext: &str) -> (String, Option) { + let st = app.session.active(); + let t = st.map(|d| d.path.clone().unwrap_or_else(|| d.doc.title.clone())).unwrap_or_else(|| "Untitled".into()); + let folder = st.and_then(|d| d.path.as_deref()).and_then(|p| std::path::Path::new(p).parent()).map(|p| p.to_string_lossy().to_string()); + (format!("{}.{ext}", fileio::file_stem(&t)), folder.filter(|f| !f.is_empty())) +} + +/// The save panel's file type, `(label, extensions)`, for the files `write_named` and +/// `target_path` write that are not a document format (`fileio::format` has those). +const FILE_TYPES: &[(&str, &[&str])] = &[ + ("Swatch libraries", &["vcswatches"]), + ("Swatch libraries", &["gpl"]), + ("Swatch libraries", &["ase"]), + ("Graphic Style Libraries", &[vectorcraft_doc::style_libs::STYLES_EXT]), + ("Libraries", vectorcraft_engine::cmd::library::LIBRARY_EXTS), + ("Flattener presets", vectorcraft_engine::cmd::flatten::PRESET_EXTS), + ("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS), + ("Perspective grid presets", vectorcraft_engine::cmd::perspgrid::PRESET_EXTS), + ("Print presets", vectorcraft_engine::cmd::printpresets::PRESET_EXTS), + ("CSS", &["css"]), + ("PostScript", &["ps"]), +]; + +/// The file type a save panel shows for a file with extension `ext`: its format's, else its +/// [`FILE_TYPES`] row; none for any other extension. +fn file_types(ext: &str) -> Vec { + if let Some(f) = fileio::format(ext) { + return vec![FilePick::filter(f.label, f.extensions)]; + } + let ext = ext.trim_start_matches('.').to_ascii_lowercase(); + FilePick::filters(FILE_TYPES.iter().find(|(_, exts)| exts.contains(&ext.as_str())).copied()) +} + +/// `path`, else one picked in a save panel (the web: the suggested name) for the active document +/// with extension `ext`, filtered to its file type when it has one ([`file_types`]). A name typed +/// with another format's extension gets `ext` instead ([`with_written_extension`]). +pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: &str) -> Result { + target_path_keeping(app, path, ext, |_| false) +} + +/// [`target_path`] for an export in format `f` (Export As's OK and [`export`]): a name typed with +/// `.svgz` for an SVG stays as typed, and [`export`] writes it compressed. +pub(crate) fn export_path(app: &mut VectorcraftApp, path: Option, f: &Format) -> Result { + target_path_keeping(app, path, f.extensions[0], |g| f.id == "svg" && g.id == "svgz") +} + +/// [`target_path`], keeping a typed extension of a format `keeps` takes. +pub(super) fn target_path_keeping( + app: &mut VectorcraftApp, + path: Option, + ext: &str, + keeps: impl Fn(&Format) -> bool, +) -> Result { + if let Some(p) = path { + return Ok(p); + } + let (name, folder) = suggested(app, ext); + let filters = file_types(ext); + pick_path(app, &FilePick { name, folder, filters }, keeps) +} + +/// Keep the active document's saved view current before a save (native files reopen at it). +pub fn remember_view(app: &mut VectorcraftApp) { + let Some(v) = app.view().copied().filter(|v| v.fitted) else { return }; + if let Some(st) = app.session.active_mut() { + st.view = Some(SavedView { name: String::new(), center: v.center, zoom: v.zoom, rotation: v.rotation }); + } +} + +/// A path typed in a save panel: kept when its extension names one of the panel's file types (a +/// save format, or a template format for Save as Template, `mode`). An extension that names +/// another format (`card.png`, or `card.ait` in Save As) is replaced with `f`'s, and `f`'s is +/// added to any other path (`notes.v2` becomes `notes.v2.vectorcraft`). +fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String { + let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; + if fileio::format_for_name(path).is_some_and(|g| !formats.contains(&g.id)) { + return std::path::Path::new(path).with_extension(f.extensions[0]).to_string_lossy().into_owned(); + } + with_extension(path, f.extensions[0], |g| formats.contains(&g.id)) +} + +/// [`with_save_extension`] for a save panel that reports the file type picked in it (`picked`, +/// that type's extension; the XDG portal's panel on Linux and the BSDs), which then takes `f`'s +/// place. A path that still ends in the extension of the name the panel suggested (`suggested`), +/// in any letter case, gets the picked type's extension instead, unless that extension is one of +/// the picked type's; such a path stays as typed. Without a picked type, or with one the panel did not +/// offer, [`with_save_extension`] with `f`. +fn with_picked_type(path: &str, suggested: &str, picked: Option<&str>, f: &Format, mode: SaveMode) -> String { + let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; + let Some(g) = picked.and_then(fileio::format).filter(|g| formats.contains(&g.id)) else { + return with_save_extension(path, f, mode); + }; + let has = fileio::extension(path); + if !has.is_empty() && has == fileio::extension(suggested) && !g.extensions.contains(&has.as_str()) { + return std::path::Path::new(path).with_extension(g.extensions[0]).to_string_lossy().into_owned(); + } + with_save_extension(path, g, mode) +} + +/// Write every file of an export (one per artboard, linked images) for the destination `path`. +/// → the export's own files (without the linked images). +pub(super) fn write_encoded(write: Writer, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result, String> { + let named = enc.named(doc, path); + for (p, bytes) in &named { + write(p, bytes)?; + } + Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect()) +} + +fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result { + fileio::save_plan(&app.session, mode, p).map_err(|e| e.to_string()) +} + +/// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the +/// engine command's `{path?, format?, options?, svg?}`. +/// +/// When no path is known, desktop asks with a save panel (one file type per save format, or one +/// per template format for Save as Template) and the web opens the Save As dialog (file name and +/// format). With `ask_options` and no options in `p`, +/// a format picked that way that has options asks for them before anything is written (SVG +/// Options, the Save PDF dialog, or the save options dialog: native files on Save As only, `.ai` +/// files on Save As and Save a Copy), and so does Save As or Save a Copy to a given SVG path. +/// → `{path, format, warnings…}` once written, or `{pending: , +/// path?}` while a dialog is open. +pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { + remember_view(app); + // Writing over the file the document was read from is asked about here, not refused. + let first = plan(app, mode, &acknowledged(p))?; + let ask = ask_options && !has_options(p); + if let Some(path) = first.path.clone() { + if let Some(r) = ask_before_losing(app, &path, save_command(mode), p) { + return Ok(r); + } + if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { + return ask_format_options(app, mode, first.format, &path); + } + return write_plan(app, first); + } + if is_web(app) && ask_options { + return Ok(open_options(app, mode.command(), first.format, &first.name, true)); + } + let filters = FilePick::filters(match mode { + SaveMode::Template => fileio::template_filters(first.format.id), + _ => fileio::save_filters(first.format.id), + }); + let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; + let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; + // The picked file type wins over a `format` param. + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + if let Some(o) = q.as_object_mut() { + o.remove("format"); + o.insert("path".into(), json!(picked)); + } + let chosen = plan(app, mode, &acknowledged(&q))?; + if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) { + return Ok(r); + } + if ask && asks_options(mode, chosen.format) { + let path = chosen.path.clone().unwrap_or_default(); + let r = ask_format_options(app, mode, chosen.format, &path); + crate::dialogs::opened_by_panel(app); + return r; + } + write_plan(app, chosen) +} + +/// The UI command that runs a save in `mode`. +fn save_command(mode: SaveMode) -> &'static str { + match mode { + SaveMode::Save => "file.save", + SaveMode::SaveAs => "file.saveAs", + SaveMode::Copy => "file.saveCopy", + SaveMode::Template => "file.saveAsTemplate", + } +} + +/// `p` with `acknowledgeLoss: true`. +fn acknowledged(p: &Value) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["acknowledgeLoss"] = json!(true); + p +} + +/// Writing `path` over the file the active document was read from, when reading it left things +/// out (hidden text, art or layers VectorCraft can't read yet): asks first, and OK runs `command` +/// with `params` and `acknowledgeLoss` → `{pending}` while it asks, `None` when nothing is lost. +pub(super) fn ask_before_losing(app: &mut VectorcraftApp, path: &str, command: &str, params: &Value) -> Option { + let what = fileio::losses_summary(fileio::overwrite_losses(app.session.active()?, path, params)?); + let name = fileio::file_name(path); + let message = crate::i18n::fmt(tl!("Replace “{name}”, the file this document was opened from?"), &[("name", &name)]); + let detail = crate::i18n::fmt( + tl!("Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it."), + &[("what", &what)], + ); + let mut params = acknowledged(params); + if command != "file.save" { + params["path"] = json!(path); + } + dialogs::confirm::ask(app, &message, &detail, command, params); + Some(json!({ "pending": dialogs::confirm::KIND })) +} + +/// Does a save (`mode`) to a picked file of format `f` ask for its options first? Native and `.ai` +/// files on Save As (the options dialog after the save panel), and `.ai` files also on Save a Copy, +/// so the copy is not written in the version last saved whatever the panel's `.ai` type says +/// (#3141); Save and templates reuse the remembered options or Use Compression. +fn asks_options(mode: SaveMode, f: &Format) -> bool { + !f.options.is_empty() + && match f.id { + "vectorcraft" => mode == SaveMode::SaveAs, + "ai" => matches!(mode, SaveMode::SaveAs | SaveMode::Copy), + "template" | "ait" => false, + _ => true, + } +} + +/// Does `p` give format options (`options`, `svg: {…}` or SVG options at its top level)? +fn has_options(p: &Value) -> bool { + ["options", "svg"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) || fileio::svg_options(p).is_ok_and(|m| !m.is_empty()) +} + +/// Ask for format `f`'s options before a save (`mode`) to `path`: SVG Options for SVG, the Save +/// PDF dialog for PDF, else the save options dialog. OK finishes the save. +pub fn ask_format_options(app: &mut VectorcraftApp, mode: SaveMode, f: &Format, path: &str) -> Result { + match f.id { + "pdf" => ask_pdf_options(app, mode, path), + "svg" | "svgz" => { + let m = if mode == SaveMode::Copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save }; + svg_options::open(app, m, Some(path)); + Ok(json!({ "pending": svg_options::KIND, "dialog": svg_options::KIND, "path": path })) + } + _ => Ok(open_options(app, mode.command(), f, path, false)), + } +} + +/// A save as PDF asks with the Save PDF dialog, filled with the PDF options the document was last +/// saved with; its OK finishes the save ([`save_pdf`]). +pub fn ask_pdf_options(app: &mut VectorcraftApp, mode: SaveMode, path: &str) -> Result { + let mut p = Value::Object(plan(app, mode, &json!({ "path": path, "format": "pdf" }))?.options); + p["path"] = json!(path); + dialogs::open_save_pdf(app, &p)?; + let d = app.ui.dialog.as_mut().ok_or("the Save PDF dialog didn't open")?; + d.fields.insert("__save".into(), json!(mode.command())); + Ok(json!({ "pending": d.kind })) +} + +/// The Save PDF dialog's OK during a save (`mode`): `params` are its `document.exportPdf` options +/// and `path`. +pub fn save_pdf(app: &mut VectorcraftApp, mode: SaveMode, mut params: Value) -> Result { + let path = params.as_object_mut().and_then(|o| o.remove("path")); + let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false); + let r = save(app, mode, &json!({ "path": path, "format": "pdf", "options": params }), false)?; + if view && let Some(path) = r["path"].as_str() { + view_file(app, path); + } + Ok(r) +} + +/// Open a written file in the system viewer (not on the web, which downloads it). +pub(super) fn view_file(app: &mut VectorcraftApp, path: &str) { + if !is_web(app) { + app.open_url(&file_url(path)); + } +} + +/// "Saved ", with the first of the result's warnings. +pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { + app.status(saved_status(path, r)); +} + +/// [`report_saved`]'s message. +pub(super) fn saved_status(path: &str, r: &Value) -> String { + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + match warnings.first() { + Some(first) => format!("Saved {path} with {} note(s): {first}", warnings.len()), + None => format!("Saved {path}"), + } +} + +/// Write a planned save through the services and report it in the status bar. +/// With Background Save on, the file is encoded and written from a snapshot off the UI thread +/// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once +/// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format +/// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { + let uid = app.session.active().ok_or("no document")?.uid; + // Saves of one document finish in order. + if app.background.busy_with(uid) { + background::wait_all(app); + } + let job = fileio::save_job(&mut app.session, plan).map_err(|e| e.to_string())?; + let (retargets, target) = (job.plan().retargets(), job.plan().path.clone()); + let (format, warnings) = (job.plan().format.id, job.known_warnings()); + let label = match &target { + Some(path) => format!("Saving {}", fileio::file_name(path)), + None => "Saving".into(), + }; + // Without a path the bytes come back to the caller: nothing to do in the background. + let enabled = app.session.prefs.background_save && target.is_some(); + // The work writes the file; the copy makes it the document's own afterwards. + let done = job.clone(); + // A native file streams into the file as it is encoded (desktop; not to a download). + let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); + let work = move |write: Writer| { + match stream { + Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), + None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), + } + .map_err(|e| e.to_string()) + }; + let failed = target.clone().unwrap_or_default(); + let then = move |app: &mut VectorcraftApp, r: Result| { + // Not written: the document keeps its file, format and modified state, and the user is told. + let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; + // The document may have closed meanwhile: the file is written all the same. + done.complete(&mut app.session, uid); + let path = r["path"].as_str().unwrap_or_default().to_string(); + if retargets { + note_recent(app, &path); + } + report_saved(app, &path, &r); + Ok(r) + }; + let mut r = background::run(app, enabled, label, Some(uid), work, then)?; + if let Some(path) = target.filter(|_| r.get("path").is_none()) { + r["path"] = json!(path); + } + if r.get("background").and_then(Value::as_bool) == Some(true) { + r["format"] = json!(format); + r["warnings"] = json!(warnings); + } + Ok(r) +}