diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 788d34206..73aa81c2e 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -395,14 +395,8 @@ fn is_loss(note: &str) -> bool { /// it left something out (hidden text, art or layers), unless `p` has `acknowledgeLoss: true`. /// Another name keeps the original file, with all it has. pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> { - let Some(source) = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty()) else { return Ok(()) }; - if bool_or(p, "acknowledgeLoss", false) || !same_file(source, target) { - return Ok(()); - } - let mut what = st.import_losses.iter().take(3).map(|n| format!("“{n}”")).collect::>().join("; "); - if st.import_losses.len() > 3 { - what.push_str(&format!("; {} more", st.import_losses.len() - 3)); - } + let Some(losses) = overwrite_losses(st, target, p) else { return Ok(()) }; + let what = losses_summary(losses); Err(bad( cmd, format!( @@ -411,6 +405,23 @@ pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: & )) } +/// What writing the file `target` for document `st` would lose for good: the notes of what reading +/// it left out when `target` is the file `st` was read from, unless `p` has `acknowledgeLoss: true`. +/// The UI asks before such a write and repeats it with `acknowledgeLoss` ([`check_not_lossy_overwrite`]). +pub fn overwrite_losses<'a>(st: &'a crate::DocState, target: &str, p: &Value) -> Option<&'a [String]> { + let source = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty())?; + (!bool_or(p, "acknowledgeLoss", false) && same_file(source, target)).then_some(st.import_losses.as_slice()) +} + +/// Up to three of `losses`, quoted, and how many more. +pub fn losses_summary(losses: &[String]) -> String { + let mut what = losses.iter().take(3).map(|n| format!("“{n}”")).collect::>().join("; "); + if losses.len() > 3 { + what.push_str(&format!("; {} more", losses.len() - 3)); + } + what +} + /// Do `a` and `b` name one file (spelled differently, or through a link)? fn same_file(a: &str, b: &str) -> bool { match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index dad634b65..4a71c4dba 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -53,6 +53,7 @@ pub(crate) use load::import_svg; pub(crate) use load::native_file; pub(crate) use load::source; pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; +pub use load::{losses_summary, overwrite_losses}; pub use native::with_compression_pref; pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs index 234141c9f..ca49854fb 100644 --- a/crates/ui-egui/src/dialogs/package.rs +++ b/crates/ui-egui/src/dialogs/package.rs @@ -104,7 +104,11 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } // The package holds the document as saved. if app.session.active().is_some_and(|st| st.is_dirty()) { - io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?; + let r = io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?; + // The save asks first (it would replace a file that loses what opening it left out). + if r.get("pending").is_some() { + return Ok(r); + } } let r = app.run("file.package", params)?; app.ui.dialog = None; diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 068cd35fa..ae9cfbfed 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2147,6 +2147,8 @@ Reverse Spine Invertir eje Revert Revertir Revert to the saved version of “{name}”? ¿Revertir a la versión guardada de “{name}”? + Replace “{name}”, the file this document was opened from? ¿Reemplazar “{name}”, el archivo desde el que se abrió este documento? + 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. Al abrirlo quedó fuera lo que VectorCraft aún no puede leer ({what}), así que reemplazarlo lo pierde para siempre. Guárdelo con otro nombre para conservarlo. Revolve… Revolución… RGB Color Color RGB Right Derecha diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ec309b0e0..6bcba24b0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2147,6 +2147,8 @@ Reverse Spine Inverti dorsale Revert Ripristina Revert to the saved version of “{name}”? Ripristinare la versione salvata di “{name}”? + Replace “{name}”, the file this document was opened from? Sostituire “{name}”, il file da cui è stato aperto questo documento? + 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. Aprendolo è rimasto fuori ciò che VectorCraft non sa ancora leggere ({what}), quindi sostituirlo lo perde per sempre. Salva con un altro nome per conservarlo. Revolve… Rivoluzione… RGB Color Colore RGB Right Destra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1540e48d1..3d7b6c6ba 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2115,6 +2115,8 @@ Reverse Path Direction Off パスの方向を反転: オフ Reverse Path Direction On パスの方向を反転: オン Revert to the saved version of “{name}”? 「{name}」を保存済みの状態に戻しますか? + Replace “{name}”, the file this document was opened from? このドキュメントの開き元のファイル「{name}」を置き換えますか? + 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. 開いたときに VectorCraft がまだ読み込めない内容({what})が除外されたため、置き換えるとそれは完全に失われます。保持するには別の名前で保存してください。 Right Grid: 右グリッド: Right Indent 右インデント Right Plane 右面 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1d168bcad..bf3679f92 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1904,6 +1904,8 @@ Reverse Spine Inverter Coluna Revert Reverter Revert to the saved version of “{name}”? Reverter para a versão salva de “{name}”? + Replace “{name}”, the file this document was opened from? Substituir “{name}”, o arquivo do qual este documento foi aberto? + 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. Ao abri-lo, ficou de fora o que o VectorCraft ainda não consegue ler ({what}), então substituí-lo perde isso para sempre. Salve com outro nome para mantê-lo. Revolve… Revolver… Right Grid: Grade Direita: Right Indent Recuo Direito diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c13dbad08..b4efe1df6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2143,6 +2143,8 @@ Reverse Spine Обратить остов Revert Вернуть Revert to the saved version of “{name}”? Вернуться к сохранённой версии «{name}»? + Replace “{name}”, the file this document was opened from? Заменить «{name}» — файл, из которого открыт этот документ? + 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. При открытии было пропущено то, что VectorCraft пока не умеет читать ({what}), поэтому замена файла потеряет это навсегда. Чтобы сохранить это, сохраните документ под другим именем. Revolve… Вращение… RGB Color Цвет RGB Right Справа diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 15939595c..0a2acad89 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2102,6 +2102,8 @@ Reverse Spine 反转路径 Revert 恢复 Revert to the saved version of “{name}”? 要恢复为“{name}”已存储的版本吗? + Replace “{name}”, the file this document was opened from? 要替换“{name}”(打开此文档的源文件)吗? + 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. 打开时略去了 VectorCraft 尚无法读取的内容({what}),替换该文件会永久丢失这些内容。要保留它们,请以其他名称存储。 Revolve… 回转… RGB Color RGB 颜色 Right 右 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d44527e25..3226c1280 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2150,6 +2150,8 @@ Reverse Spine 反轉軸線 Revert 回復 Revert to the saved version of “{name}”? 要回復為「{name}」已儲存的版本嗎? + Replace “{name}”, the file this document was opened from? 要取代「{name}」(開啟此文件的來源檔案)嗎? + 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. 開啟時略過了 VectorCraft 尚無法讀取的內容({what}),取代該檔案會永久遺失這些內容。若要保留,請以其他名稱儲存。 Revolve… 迴轉… RGB Color RGB 色彩 Right 右 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 96913fc04..635697f77 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -239,9 +239,13 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result Result { remember_view(app); - let first = plan(app, mode, p)?; + // 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); } @@ -261,7 +265,10 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo o.remove("format"); o.insert("path".into(), json!(with_save_extension(&picked, first.format))); } - let chosen = plan(app, mode, &q)?; + 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(); return ask_format_options(app, mode, chosen.format, &path); @@ -269,6 +276,42 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo 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 only on Save As (the options dialog after the save panel); Save, a Copy and templates /// reuse the remembered options or Use Compression. @@ -421,6 +464,13 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option std::sync::Arc::new(d), }; let path = target_path(app, path, f.extensions[0])?; + 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, diff --git a/crates/ui-egui/src/tests_save.rs b/crates/ui-egui/src/tests_save.rs index 30b0398e9..8a39c50a4 100644 --- a/crates/ui-egui/src/tests_save.rs +++ b/crates/ui-egui/src/tests_save.rs @@ -166,3 +166,42 @@ fn a_document_reopens_at_its_saved_view() { assert!(!app.view().unwrap().fitted); let _ = std::fs::remove_dir_all(d); } + +#[test] +fn replacing_a_file_that_opening_left_things_out_of_asks_first() { + let d = dir("lossy"); + let path = d.join("a.vectorcraft").to_string_lossy().to_string(); + let mut app = app(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + rect(&mut app); + app.run("file.saveAs", json!({"path": path})).unwrap(); + // As if reading the file had left out text it can't read yet. + let st = app.session.doc_mut().unwrap(); + st.imported_from = Some(path.clone()); + st.import_losses = vec!["hidden type".into()]; + let saved = std::fs::read(&path).unwrap(); + rect(&mut app); + let r = app.run("file.save", json!({})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert!(dialog.str("detail").contains("“hidden type”"), "{:?}", dialog.fields); + assert_eq!(std::fs::read(&path).unwrap(), saved, "nothing is written before OK"); + // Export As over it asks too; Cancel leaves the file. + app.ui.dialog = None; + assert_eq!(app.run("file.exportAs", json!({"path": path, "format": "svg"})).unwrap()["pending"], dialogs::confirm::KIND); + app.ui.dialog = None; + assert_eq!(std::fs::read(&path).unwrap(), saved); + // Another name doesn't ask. + let copy = d.join("b.svg").to_string_lossy().to_string(); + assert!(app.run("file.exportAs", json!({"path": copy, "format": "svg"})).unwrap().get("pending").is_none()); + crate::background::wait_all(&mut app); + assert!(app.ui.dialog.is_none()); + // OK replaces it. + app.run("file.save", json!({})).unwrap(); + press_ok(&mut app); + crate::background::wait_all(&mut app); + assert!(app.ui.dialog.is_none()); + assert_ne!(std::fs::read(&path).unwrap(), saved); + assert!(!app.session.active().unwrap().is_dirty()); + let _ = std::fs::remove_dir_all(d); +} diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index fc8e6d171..35c52dcac 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -77,6 +77,11 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result { if !d.bool("discard") { app.session.set_active(i); let r = io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?; + // A save that asks first (replacing a file that would lose what opening it left out) keeps + // the document open: it closes once saved. + if r.get("pending").is_some() { + return Ok(r); + } // Closing needs the file written: wait for a background save, and stop if it failed. if r["background"] == true { crate::background::wait_all(app);