From 599549938b10a02ea5e645b0fbc720f17dbd0afc Mon Sep 17 00:00:00 2001 From: DWAA1660 Date: Thu, 12 Nov 2026 11:15:55 -0700 Subject: [PATCH] fix(ui-egui): keep an ink file that cannot all be read A page's ink.svg that could not be read, or that held lines with no pen readings, loaded as an empty or partial drawing, and the next save wrote that drawing over the file, losing everything else in it without a word (#157). The pad now marks such a file incomplete and, before it first writes over it or takes it away, copies it as it was to ink.svg.bak (or the next free name). The window says where the original was kept. Fixes #157 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 12 +++++ crates/ui-egui/src/inkpad.rs | 85 +++++++++++++++++++++++++++++++++++- i18n/ar-XB.tsv | 1 + i18n/ar.tsv | 1 + i18n/de.tsv | 1 + i18n/en-XA.tsv | 1 + i18n/en.tsv | 1 + i18n/es.tsv | 1 + i18n/fa.tsv | 1 + i18n/fr.tsv | 1 + i18n/he.tsv | 1 + i18n/hi.tsv | 1 + i18n/ja.tsv | 1 + i18n/ko.tsv | 1 + i18n/ru.tsv | 1 + i18n/ur.tsv | 1 + i18n/zh.tsv | 1 + 17 files changed, 110 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b344b35a..3ca611ec 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -1560,6 +1560,7 @@ impl NoteCraftApp { return; } } + self.tell_kept_ink(); if let Some(ed) = self.editor.as_mut() { ed.blocks.retain(|b| !matches!(b, Block::Image { src, .. } if crate::inkpad::is_old_sheet(src))); ed.rebuild_body(); @@ -3732,6 +3733,7 @@ impl NoteCraftApp { Ok(()) => self.say(crate::i18n::format_message_id("ui.took_the_last_stroke_back_count_left", &[("count", &left.to_string())])), Err(e) => self.complain(&crate::i18n::format_message_id("ui.cannot_write_the_drawing_error", &[("error", &e.to_string())])), } + self.tell_kept_ink(); // The page stops naming ink it no longer has. self.sync_ink_reference(); } @@ -4066,6 +4068,15 @@ impl NoteCraftApp { self.say(crate::tl_id!("ui.write_anywhere_on_the_page")); } + /// Says where the page's ink file was kept, when writing the ink kept it first because it + /// held more than could be read back as strokes (issue #157). + fn tell_kept_ink(&mut self) { + let Some(kept) = self.pad.as_mut().and_then(|pad| pad.kept_original.take()) else { return }; + let shown = self.shown(&kept); + log::warn!("the page's ink file held more than its strokes; the original was kept: {kept:?}"); + self.say(crate::i18n::format_message_id("ui.kept_the_original_drawing_file_path", &[("path", &shown)])); + } + /// Writes the drawing again with no pen readings in it, for sending somewhere else. fn ink_export_plain(&mut self) { let Some(pad) = self.pad.as_ref() else { @@ -5898,6 +5909,7 @@ impl NoteCraftApp { Ok(()) => self.say(crate::i18n::format_message_id("ui.drawing_saved_count_strokes", &[("count", &strokes.to_string())])), Err(e) => self.complain(&crate::i18n::format_message_id("ui.cannot_write_the_drawing_error", &[("error", &e.to_string())])), } + self.tell_kept_ink(); self.sync_ink_reference(); } if let Some(href) = follow { diff --git a/crates/ui-egui/src/inkpad.rs b/crates/ui-egui/src/inkpad.rs index b96cbe97..38e44392 100644 --- a/crates/ui-egui/src/inkpad.rs +++ b/crates/ui-egui/src/inkpad.rs @@ -84,6 +84,11 @@ pub struct Pad { pub dirty: bool, /// How many strokes in the file were a picture only, with no readings to give back. pub flat: usize, + /// The file holds more than `drawing` does: it could not be read, or some of its lines are + /// a picture only. Before the file is first written over, it is kept beside the ink. + pub incomplete: bool, + /// Where that file was kept, once it has been, for the window to say so. + pub kept_original: Option, /// The stroke being drawn now, if the pen is down. drawing_now: Option, /// The strokes being held, by their place in the drawing. Never written to the file: @@ -125,13 +130,21 @@ impl Pad { /// drawn on, so a page nobody has drawn on stays as clean as it was. pub fn of_page(page_dir: &Path) -> Pad { let file = page_dir.join(FILE); - let (drawing, flat) = - std::fs::read_to_string(&file).ok().and_then(|text| svg::read(&text).ok()).unwrap_or_else(|| (Drawing::new(SHEET_W, SHEET_H), 0)); + let read = std::fs::read_to_string(&file).ok().and_then(|text| svg::read(&text).ok()); + // A file that is there but could not be read, or that holds lines with no readings, + // would lose them the first time the ink is written (issue #157). + let incomplete = match &read { + Some((_, flat)) => *flat > 0, + None => file.exists(), + }; + let (drawing, flat) = read.unwrap_or_else(|| (Drawing::new(SHEET_W, SHEET_H), 0)); Pad { file, drawing, dirty: false, flat, + incomplete, + kept_original: None, drawing_now: None, held: Vec::new(), loop_now: Vec::new(), @@ -150,6 +163,13 @@ impl Pad { if let Some(parent) = self.file.parent() { std::fs::create_dir_all(parent)?; } + // The file holds what this drawing does not: it is kept, as it was, before it is + // written over or taken away, and only then (issue #157). + if self.incomplete && self.file.is_file() { + let kept = self.keep_original()?; + self.kept_original = Some(kept); + } + self.incomplete = false; // A page drawn on and then rubbed clean loses its file rather than keeping an // empty one, so a page with no ink has no ink in its folder either. if self.drawing.is_empty() { @@ -165,6 +185,26 @@ impl Pad { Ok(()) } + /// Copies the ink file as it is to `ink.svg.bak`, or the first free `ink-N.svg.bak`, beside + /// it. Not an `.svg`, so nothing takes the copy for the page's ink or for a picture of it. + fn keep_original(&self) -> std::io::Result { + let folder = self.file.parent().map(Path::to_path_buf).unwrap_or_default(); + for n in 1usize..=10_000 { + let name = if n == 1 { format!("{FILE}.bak") } else { format!("ink-{n}.svg.bak") }; + let kept = folder.join(name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&kept) { + Ok(mut out) => { + let mut original = std::fs::File::open(&self.file)?; + std::io::copy(&mut original, &mut out)?; + return Ok(kept); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every name for keeping the original ink file is taken")) + } + /// Writes it again as a plain picture, beside the ink, for sending somewhere else. /// Returns where it was written. pub fn save_plain(&self) -> std::io::Result { @@ -819,6 +859,47 @@ mod tests { assert!(pad.file.ends_with(FILE), "the ink sits beside the page: {}", pad.file.display()); } + /// Issue #157: an ink file that holds lines with no readings, or that cannot be read at all, + /// is kept as it was before new ink is written over it, and only once. + #[test] + fn ink_that_cannot_all_be_read_is_kept_before_it_is_written_over() { + let original = ""; + for (label, text) in [("a picture's path", original), ("not an SVG at all", "this is not a drawing")] { + let t = TempDir::new("ink-keep-original"); + let page = t.path().to_path_buf(); + std::fs::write(page.join(FILE), text).expect("an ink file of someone else's"); + let mut pad = Pad::of_page(&page); + assert!(pad.incomplete, "{label}: the file holds more than the strokes read from it"); + + assert!(pad.drawing.add(stroke(10.0))); + pad.dirty = true; + pad.save().expect("saved"); + let kept = pad.kept_original.take().expect("the original was kept"); + assert_eq!(kept, page.join("ink.svg.bak"), "{label}"); + assert_eq!(std::fs::read_to_string(&kept).expect("kept"), text, "{label}: kept as it was"); + assert!(!crate::inkpad::is_old_sheet("ink.svg.bak")); + + // Saving again does not keep another copy: the file is NoteCraft's own now. + assert!(pad.drawing.add(stroke(20.0))); + pad.save().expect("saved again"); + assert_eq!(pad.kept_original, None, "{label}"); + assert!(!page.join("ink-2.svg.bak").exists(), "{label}"); + } + + // A file of NoteCraft's own, read whole, is written over as before, with nothing kept. + let t = TempDir::new("ink-keep-own"); + let page = t.path().to_path_buf(); + let mut pad = Pad::of_page(&page); + assert!(pad.drawing.add(stroke(10.0))); + pad.save().expect("saved"); + let mut again = Pad::of_page(&page); + assert!(!again.incomplete); + assert!(again.drawing.add(stroke(30.0))); + again.save().expect("saved"); + assert_eq!(again.kept_original, None); + assert!(!page.join("ink.svg.bak").exists()); + } + #[test] fn ink_is_written_beside_the_page_and_comes_back_as_strokes() { let t = TempDir::new("ink-layer-save"); diff --git a/i18n/ar-XB.tsv b/i18n/ar-XB.tsv index 9fb39f04..0eefb1d9 100644 --- a/i18n/ar-XB.tsv +++ b/i18n/ar-XB.tsv @@ -331,6 +331,7 @@ @id ui.json [[RLI]]$$$JSON@@@[[PDI]] @id ui.justification_goes_on_a_paragraph_of_text [[RLI]]$$$Justification goes on a paragraph of text@@@[[PDI]] @id ui.keep_this_window_above_the_others [[RLI]]$$$Keep this window above the others@@@[[PDI]] +@id ui.kept_the_original_drawing_file_path [[RLI]]$$$This page's drawing file held lines NoteCraft cannot edit. The file as it was is kept as {path}.@@@[[PDI]] @id ui.label [[RLI]]$$$label@@@[[PDI]] @id ui.label_not_built [[RLI]]$$${label} (not built)@@@[[PDI]] @id ui.label_not_built_yet [[RLI]]$$${label}: not built yet@@@[[PDI]] diff --git a/i18n/ar.tsv b/i18n/ar.tsv index 5e7c9e18..2c371235 100644 --- a/i18n/ar.tsv +++ b/i18n/ar.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text التبرير ينطبق على فقرة نصية @id ui.keep_this_window_above_the_others إبقاء هذه النافذة فوق النوافذ الأخرى +@id ui.kept_the_original_drawing_file_path احتوى ملف الرسم في هذه الصفحة على خطوط لا يستطيع NoteCraft تحريرها. تم الاحتفاظ بالملف الأصلي باسم {path}. @id ui.label تسمية @id ui.label_not_built {label} (غير مبنٍ) @id ui.label_not_built_yet {label}: لم يُبنَ بعد diff --git a/i18n/de.tsv b/i18n/de.tsv index 1be47428..cb7fcb18 100644 --- a/i18n/de.tsv +++ b/i18n/de.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text Der Textausrichtung bezieht sich auf einen Absatz @id ui.keep_this_window_above_the_others Dieses Fenster immer im Vordergrund halten +@id ui.kept_the_original_drawing_file_path Die Zeichnungsdatei dieser Seite enthielt Linien, die NoteCraft nicht bearbeiten kann. Die ursprüngliche Datei ist als {path} aufbewahrt. @id ui.label Beschriftung @id ui.label_not_built {label} (nicht implementiert) @id ui.label_not_built_yet {label}: noch nicht implementiert diff --git a/i18n/en-XA.tsv b/i18n/en-XA.tsv index adb16e8d..701d8295 100644 --- a/i18n/en-XA.tsv +++ b/i18n/en-XA.tsv @@ -331,6 +331,7 @@ @id ui.json $$$JSON@@@ @id ui.justification_goes_on_a_paragraph_of_text $$$Justification goes on a paragraph of text@@@ @id ui.keep_this_window_above_the_others $$$Keep this window above the others@@@ +@id ui.kept_the_original_drawing_file_path $$$This page's drawing file held lines NoteCraft cannot edit. The file as it was is kept as {path}.@@@ @id ui.label $$$label@@@ @id ui.label_not_built $$${label} (not built)@@@ @id ui.label_not_built_yet $$${label}: not built yet@@@ diff --git a/i18n/en.tsv b/i18n/en.tsv index d9e26e3d..e5ce305f 100644 --- a/i18n/en.tsv +++ b/i18n/en.tsv @@ -331,6 +331,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text Justification goes on a paragraph of text @id ui.keep_this_window_above_the_others Keep this window above the others +@id ui.kept_the_original_drawing_file_path This page's drawing file held lines NoteCraft cannot edit. The file as it was is kept as {path}. @id ui.label label @id ui.label_not_built {label} (not built) @id ui.label_not_built_yet {label}: not built yet diff --git a/i18n/es.tsv b/i18n/es.tsv index 751e5258..84534303 100644 --- a/i18n/es.tsv +++ b/i18n/es.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text La justificación se aplica a un párrafo de texto @id ui.keep_this_window_above_the_others Mantener esta ventana por encima de las demás +@id ui.kept_the_original_drawing_file_path El archivo de dibujo de esta página tenía trazos que NoteCraft no puede editar. El archivo original se conserva como {path}. @id ui.label label @id ui.label_not_built {label} (no compilado) @id ui.label_not_built_yet {label}: aún no compilado diff --git a/i18n/fa.tsv b/i18n/fa.tsv index 7a23657e..1a4e75f6 100644 --- a/i18n/fa.tsv +++ b/i18n/fa.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text تراز متن در یک بند اعمال می‌شود @id ui.keep_this_window_above_the_others این پنجره را بالای سایر پنجره‌ها نگه دارید +@id ui.kept_the_original_drawing_file_path فایل طراحی این صفحه خط‌هایی داشت که NoteCraft نمی‌تواند ویرایش کند. فایل اصلی با نام {path} نگه داشته شد. @id ui.label برچسب @id ui.label_not_built {label} (ساخته نشده) @id ui.label_not_built_yet {label}: هنوز ساخته نشده diff --git a/i18n/fr.tsv b/i18n/fr.tsv index 8b20dce5..17c0f8c2 100644 --- a/i18n/fr.tsv +++ b/i18n/fr.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text La justification s'applique à un paragraphe de texte @id ui.keep_this_window_above_the_others Garder cette fenêtre au-dessus des autres +@id ui.kept_the_original_drawing_file_path Le fichier de dessin de cette page contenait des traits que NoteCraft ne peut pas modifier. Le fichier d’origine est conservé sous {path}. @id ui.label label @id ui.label_not_built {label} (non implémenté) @id ui.label_not_built_yet {label} : pas encore implémenté diff --git a/i18n/he.tsv b/i18n/he.tsv index 0d6c1eef..9d72dba4 100644 --- a/i18n/he.tsv +++ b/i18n/he.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text יישור מתייחס לפסקת טקסט @id ui.keep_this_window_above_the_others החזק חלון זה מעל האחרים +@id ui.kept_the_original_drawing_file_path קובץ הציור של דף זה הכיל קווים ש-NoteCraft אינו יכול לערוך. הקובץ המקורי נשמר בשם {path}. @id ui.label תווית @id ui.label_not_built {label} (לא בנוי) @id ui.label_not_built_yet {label}: טרם נבנה diff --git a/i18n/hi.tsv b/i18n/hi.tsv index 6f4c2029..e2c896cc 100644 --- a/i18n/hi.tsv +++ b/i18n/hi.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text पंक्तिबद्धता पाठ के पैराग्राफ पर लागू होती है @id ui.keep_this_window_above_the_others इस विंडो को अन्य विंडोओं से ऊपर रखें +@id ui.kept_the_original_drawing_file_path इस पेज की ड्रॉइंग फ़ाइल में ऐसी रेखाएँ थीं जिन्हें NoteCraft संपादित नहीं कर सकता। मूल फ़ाइल {path} के रूप में रखी गई है। @id ui.label लेबल @id ui.label_not_built {label} (बिल्ड नहीं हुआ) @id ui.label_not_built_yet {label}: अभी तक बिल्ड नहीं हुआ diff --git a/i18n/ja.tsv b/i18n/ja.tsv index e22f1c94..49c898d5 100644 --- a/i18n/ja.tsv +++ b/i18n/ja.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text 段落のテキストに対して適用されます @id ui.keep_this_window_above_the_others このウィンドウを他のウィンドウの上に固定する +@id ui.kept_the_original_drawing_file_path このページの描画ファイルには NoteCraft で編集できない線が含まれていました。元のファイルは {path} として保存されています。 @id ui.label ラベル @id ui.label_not_built {label} (未実装) @id ui.label_not_built_yet {label}: まだ実装されていません diff --git a/i18n/ko.tsv b/i18n/ko.tsv index cfc831f8..f2a783f0 100644 --- a/i18n/ko.tsv +++ b/i18n/ko.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text 정렬은 텍스트 단락에 적용됩니다 @id ui.keep_this_window_above_the_others 이 창을 다른 창 위에 고정 +@id ui.kept_the_original_drawing_file_path 이 페이지의 그리기 파일에 NoteCraft에서 편집할 수 없는 선이 있었습니다. 원래 파일은 {path}(으)로 보관되었습니다. @id ui.label 레이블 @id ui.label_not_built {label} (미구현) @id ui.label_not_built_yet {label}: 아직 구현되지 않음 diff --git a/i18n/ru.tsv b/i18n/ru.tsv index c4290f4f..63e8e20a 100644 --- a/i18n/ru.tsv +++ b/i18n/ru.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text Выравнивание применяется к абзацу текста @id ui.keep_this_window_above_the_others Держать это окно поверх остальных +@id ui.kept_the_original_drawing_file_path Файл рисунка этой страницы содержал линии, которые NoteCraft не может изменить. Исходный файл сохранён как {path}. @id ui.label метка @id ui.label_not_built {label} (не реализовано) @id ui.label_not_built_yet {label}: ещё не реализовано diff --git a/i18n/ur.tsv b/i18n/ur.tsv index d41c1d6f..282ce689 100644 --- a/i18n/ur.tsv +++ b/i18n/ur.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text جسٹیفیکیشن متن کے ایک پیراگراف پر لاگو ہوتی ہے @id ui.keep_this_window_above_the_others اس ونڈو کو دوسروں کے اوپر رکھیں +@id ui.kept_the_original_drawing_file_path اس صفحے کی ڈرائنگ فائل میں ایسی لکیریں تھیں جنہیں NoteCraft مدون نہیں کر سکتا۔ اصل فائل {path} کے نام سے محفوظ رکھی گئی ہے۔ @id ui.label لیبل @id ui.label_not_built {label} (بنایا نہیں گیا) @id ui.label_not_built_yet {label}: ابھی تک نہیں بنایا گیا diff --git a/i18n/zh.tsv b/i18n/zh.tsv index 586cdb94..db4c6180 100644 --- a/i18n/zh.tsv +++ b/i18n/zh.tsv @@ -318,6 +318,7 @@ @id ui.json JSON @id ui.justification_goes_on_a_paragraph_of_text 对齐方式应用于一段文本 @id ui.keep_this_window_above_the_others 保持此窗口位于其他窗口之上 +@id ui.kept_the_original_drawing_file_path 此页面的绘图文件包含 NoteCraft 无法编辑的线条。原始文件已保存为 {path}。 @id ui.label 标签 @id ui.label_not_built {label}(未构建) @id ui.label_not_built_yet {label}:尚未构建