From 0559aac53e70c679c14361178ca522b9107cf164 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 15:05:12 +0700 Subject: [PATCH] Save and Export ask before replacing a file whose import left things out The engine refuses to write over the file a document was read from when reading it left something out, unless acknowledgeLoss is passed. In the app that surfaced as an error naming a parameter. File > Save, Save As, Save a Copy, Save as Template and Export As now ask instead ("Replace 'a.eps', the file this document was opened from?", with what would be lost), and OK repeats the command with acknowledgeLoss. Closing a document whose save asks keeps it open, and so does Package. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/load.rs | 27 ++++++++++---- crates/engine/src/cmd/fileio/mod.rs | 1 + crates/ui-egui/src/dialogs/package.rs | 6 ++- crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/io.rs | 54 ++++++++++++++++++++++++++- crates/ui-egui/src/tests_save.rs | 39 +++++++++++++++++++ crates/ui-egui/src/unsaved.rs | 5 +++ 13 files changed, 135 insertions(+), 11 deletions(-) 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);