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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-09 15:05:52 +07:00
co-authored by Claude Opus 5.5
parent 5f4f82d885
commit 0559aac53e
13 changed files with 135 additions and 11 deletions
+19 -8
View File
@@ -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::<Vec<_>>().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::<Vec<_>>().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)) {
+1
View File
@@ -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;
+5 -1
View File
@@ -104,7 +104,11 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
// 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;
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 右面
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 Справа
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 右
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 右
Can't render this file because it contains an unexpected character in line 6 and column 47.
+52 -2
View File
@@ -239,9 +239,13 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result<SavePlan, Str
/// path?}` while a dialog is open.
pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result<Value, String> {
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<Value> {
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<Strin
std::borrow::Cow::Owned(d) => 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,
+39
View File
@@ -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);
}
+5
View File
@@ -77,6 +77,11 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
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);