From 54436b0aa23c21dc93e614a7bd5f926a7462229c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 22:59:30 -0700 Subject: [PATCH] i18n: open, save and recovery messages name their counts in the singular or the plural The status bar said "Opened with 1 note(s)", "Saved with 1 note(s)", "Discarded 1 recovered document(s)" and "Restored 1 recovered document(s)", and the Global Edit options showed "Result(s): 1". They now use the singular for 1 and the plural otherwise, in English through i18n::msg_n and in every catalog through @msgplural rows (the Results line through an @plural row). The engine's warnings for linked images and placed documents output as their previews, and its errors for a page past a placed file's last page or artboard, also name their counts in the singular or the plural. The catalogs drop the rows no message reads any more: the plain "(s)" rows of the changed messages and the @msg row for "Packaged {n} file(s){note}". Follows #5002. --- .../engine/src/cmd/fileio/tests_aiimport.rs | 2 +- crates/engine/src/cmd/globaledit.rs | 2 +- crates/engine/src/cmd/links.rs | 7 ++- crates/engine/src/cmd/place.rs | 6 +- crates/engine/src/cmd/place/document.rs | 13 +++-- crates/engine/src/tests/placed_document.rs | 10 ++-- .../src/dialogs/missing_fonts/tests.rs | 4 +- crates/ui-egui/src/dialogs/save_pdf/tests.rs | 4 +- crates/ui-egui/src/dialogs/tests_eps.rs | 2 +- .../src/dialogs/tests_raster_formats.rs | 2 +- .../ui-egui/src/dialogs/tests_save_for_web.rs | 2 +- crates/ui-egui/src/dialogs/tests_screens.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 26 ++------- crates/ui-egui/src/i18n/bs.tsv | 26 ++------- crates/ui-egui/src/i18n/ca.tsv | 26 ++------- crates/ui-egui/src/i18n/cs.tsv | 26 ++------- crates/ui-egui/src/i18n/da.tsv | 26 ++------- crates/ui-egui/src/i18n/de.tsv | 26 ++------- crates/ui-egui/src/i18n/el.tsv | 26 ++------- crates/ui-egui/src/i18n/es.tsv | 26 ++------- crates/ui-egui/src/i18n/fi.tsv | 26 ++------- crates/ui-egui/src/i18n/fr.tsv | 26 ++------- crates/ui-egui/src/i18n/hu.tsv | 26 ++------- crates/ui-egui/src/i18n/id.tsv | 26 ++------- crates/ui-egui/src/i18n/it.tsv | 26 ++------- crates/ui-egui/src/i18n/ja.tsv | 26 ++------- crates/ui-egui/src/i18n/ka.tsv | 26 ++------- crates/ui-egui/src/i18n/ko.tsv | 26 ++------- crates/ui-egui/src/i18n/nl.tsv | 26 ++------- crates/ui-egui/src/i18n/pl.tsv | 26 ++------- crates/ui-egui/src/i18n/pt-br.tsv | 26 ++------- crates/ui-egui/src/i18n/ru.tsv | 26 ++------- crates/ui-egui/src/i18n/sv.tsv | 26 ++------- crates/ui-egui/src/i18n/tests.rs | 56 +++++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 26 ++------- crates/ui-egui/src/i18n/uk.tsv | 26 ++------- crates/ui-egui/src/i18n/vi.tsv | 26 ++------- crates/ui-egui/src/i18n/zh-hans.tsv | 26 ++------- crates/ui-egui/src/i18n/zh-hant.tsv | 26 ++------- crates/ui-egui/src/io.rs | 6 +- crates/ui-egui/src/io/export.rs | 2 +- crates/ui-egui/src/io/open.rs | 7 ++- crates/ui-egui/src/io/save.rs | 9 ++- crates/ui-egui/src/panels/properties.rs | 12 ++-- crates/ui-egui/src/panels/properties/tests.rs | 2 +- crates/ui-egui/src/recovery.rs | 15 +++-- docs/target-app-parity.md | 1 + 47 files changed, 255 insertions(+), 587 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 29e0b0680..97980f7de 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -531,7 +531,7 @@ fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() { assert_eq!(size(1), Ok((Some(40.0), Some(40.0)))); assert_eq!(size(2), Ok((Some(60.0), Some(40.0)))); let third = size(3); - assert!(third.as_ref().is_err_and(|e| e.contains("has 2 page(s)")), "{third:?}"); + assert!(third.as_ref().is_err_and(|e| e.contains("has 2 pages")), "{third:?}"); let e = s.execute("file.place", &json!({"name": "damaged.ai", "dataBase64": damaged})).unwrap_err().to_string(); assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}"); let e = s.execute("file.place", &json!({"name": "plain.ai", "dataBase64": plain})).unwrap_err().to_string(); diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 6ff31dabe..3d1e27282 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -391,7 +391,7 @@ fn set_options(s: &mut Session, p: &Value) -> Result { Ok(o.json(match_count(s))) } -/// How many objects Start Global Edit would take now with the session's options (the Result(s) +/// How many objects Start Global Edit would take now with the session's options (the Results /// line of the options): 0 unless one supported object is selected. pub fn match_count(s: &Session) -> usize { match s.active() { diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index c0aacef93..8662cb2b2 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -382,9 +382,10 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> out.images.insert(key, ImageBlob { mime, bytes: img.blob.bytes, proxy: preview }); } let mut warnings = vec![]; - if !previews.is_empty() { - warnings - .push(format!("{} linked image(s) output as their previews: their files can't be found or read (relink or update them)", previews.len())); + match previews.len() { + 0 => {} + 1 => warnings.push("1 linked image output as its preview: its file can't be found or read (relink or update it)".to_string()), + n => warnings.push(format!("{n} linked images output as their previews: their files can't be found or read (relink or update them)")), } (Cow::Owned(out), warnings) } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index d76de1adb..9efb2cb3a 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -270,7 +270,8 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result (Cow<'_, Document>, Vec) { } } if !previews.is_empty() { - warnings.push(format!( - "{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)", - previews.len() - )); + warnings.push(match previews.len() { + 1 => "1 placed document output as its preview: its file can't be found, or changed since it was read (relink or update it)".to_string(), + n => format!( + "{n} placed documents output as their previews: their files can't be found, or changed since they were read (relink or update them)" + ), + }); out.placed_as_previews(&previews); } (Cow::Owned(out), warnings) diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 2182769aa..f94143cb6 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -191,7 +191,7 @@ fn the_preview_is_saved_so_the_document_shows_it_without_its_file() { assert_eq!(s.execute("links.check", &json!({})).unwrap()["missing"], 1); // Output with the file gone: the preview, with a warning. let png = s.execute("document.serialize", &json!({"format": "png"})).unwrap(); - assert!(png["warnings"].to_string().contains("output as their previews"), "{png}"); + assert!(png["warnings"].to_string().contains("1 placed document output as its preview"), "{png}"); let [r, g, b, _] = image::load_from_memory(&decode(&png)).unwrap().to_rgba8().get_pixel(200, 150).0; assert!(near([r, g, b], RED), "{:?}", [r, g, b]); // Saved for older apps: the art it shows (here its preview), as plain objects. @@ -233,7 +233,7 @@ fn ai_export_preserves_placed_links_while_drawing_full_art_or_previews() { let (images, paths) = pdf_contents(&bytes); if missing { assert!(images > 0, "the missing file draws its preview"); - assert!(out["warnings"].to_string().contains("output as their previews")); + assert!(out["warnings"].to_string().contains("1 placed document output as its preview")); } else { assert_eq!(images, 0, "the PDF uses the file's vectors"); assert!(paths > 0); @@ -860,7 +860,7 @@ fn pdf_editing_keeps_placed_links_separate_from_drawn_previews() { assert!(paths > 0); } assert_eq!( - out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("placed document(s) output as their previews")), + out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 placed document output as its preview")), missing, "{out}" ); @@ -929,7 +929,7 @@ fn eps_editing_keeps_placed_links_and_requested_source_bytes() { assert_eq!(blob.bytes.as_slice(), source_bytes.as_slice()); } assert_eq!( - out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("placed document(s) output as their previews")), + out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 placed document output as its preview")), missing, "{out}" ); @@ -999,7 +999,7 @@ fn svg_editing_keeps_placed_links_separate_from_drawn_previews() { let text = vectorcraft_svg::text_of(&bytes).unwrap(); assert_eq!(text.contains(" Result<(), St /// 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): …". +/// [`report_saved`] shows a save's notes: "Saved with n notes: …". pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { export_files(app, "document.exportForScreens", params) } diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index c8261ea85..5d944a2dd 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -122,11 +122,16 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O 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())); + app.status(opened_with_notes(w.len(), w[0].as_str().unwrap_or_default())); } crate::dialogs::missing_links::after_open(app, r); } +/// The status bar's line for a document opened with `n` notes, the first of them `first`. +pub(crate) fn opened_with_notes(n: usize, first: &str) -> String { + crate::i18n::msg_n(n as u64, "Opened with {n} note: {first}", "Opened with {n} notes: {first}", &[("first", first)]) +} + /// 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()) diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 72e45c0b0..1e40b7a83 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -379,10 +379,15 @@ pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { } /// [`report_saved`]'s message. -pub(super) fn saved_status(path: &str, r: &Value) -> String { +pub(crate) 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()), + Some(first) => crate::i18n::msg_n( + warnings.len() as u64, + "Saved {path} with {n} note: {first}", + "Saved {path} with {n} notes: {first}", + &[("path", path), ("first", first)], + ), None => format!("Saved {path}"), } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index a8f736e5a..c94b6e4ab 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -272,15 +272,19 @@ fn global_edit_options(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::check(ui, tl!("Include Objects On Canvas"), o.include_canvas, true) { set(app, json!({ "includeCanvas": !o.include_canvas })); } - let count = global_edit_count(app, ui.ctx()).to_string(); - dim_label(ui, &crate::i18n::fmt(tl!("Result(s): {count}"), &[("count", &count)])); + dim_label(ui, &global_edit_results(global_edit_count(app, ui.ctx()))); } -/// The cached Result(s) count: the (document, revision, options, one selected object) it was +/// The options' line with how many objects Start Global Edit would take, in the interface language. +pub(crate) fn global_edit_results(n: usize) -> String { + crate::i18n::tn(n as u64, "Result: {n}", "Results: {n}") +} + +/// The cached Results count: the (document, revision, options, one selected object) it was /// found for, and the count. type GlobalEditCount = Option<((u64, u64, vectorcraft_engine::cmd::globaledit::Options, Option), usize)>; -/// How many objects Start Global Edit would take now (the options' Result(s) line), cached per +/// How many objects Start Global Edit would take now (the options' Results line), cached per /// document revision, options and selected object, as finding them walks the whole document. fn global_edit_count(app: &VectorcraftApp, ctx: &egui::Context) -> usize { let Some(st) = app.session.active() else { return 0 }; diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 4dd7d4f22..f13e98384 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -135,7 +135,7 @@ fn start_global_edit_quick_action_and_escape() { assert!(!tall(&mut app, vec![]).iter().any(|(t, _)| t == "Start Global Edit"), "type has no Global Edit"); } -/// The options' Result(s) count is found once per document revision, options and selected +/// The options' Results count is found once per document revision, options and selected /// object, not every frame; another selected object or other options find it again. #[test] fn the_global_edit_result_count_is_cached() { diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index b187ffe6f..5287a4b6a 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -142,9 +142,16 @@ pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if !errors.is_empty() { return Err(errors.join("; ")); } - app.status(match id { - "file.recovery.discard" => format!("Discarded {} recovered document(s)", done.len()), - _ => format!("Restored {} recovered document(s): save them to keep them", done.len()), - }); + app.status(recovered_status(id == "file.recovery.discard", done.len())); Ok(json!(done)) } + +/// The status bar's line for `n` recovered documents discarded or restored. +pub(crate) fn recovered_status(discarded: bool, n: usize) -> String { + let (one, other) = if discarded { + ("Discarded {n} recovered document", "Discarded {n} recovered documents") + } else { + ("Restored {n} recovered document: save it to keep it", "Restored {n} recovered documents: save them to keep them") + }; + crate::i18n::msg_n(n as u64, one, other, &[]) +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f4b3a5634..9a348408a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -595,6 +595,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). The counts that Package's status and question, the folder search of Missing Fonts and Find Linked Files, the modified and missing linked file prompts and the CSS Properties panel's notes show use the interface language's plural forms instead of a fixed "(s)" form. Evidence: `the_missing_links_note_uses_each_count_form` in `crates/ui-egui/src/dialogs/tests_package.rs`, `the_modified_links_question_uses_each_count_form` in `crates/ui-egui/src/dialogs/missing_links.rs`, `search_counts_use_each_count_form` in `crates/ui-egui/src/dialogs/missing_fonts/tests.rs` and `the_unnamed_objects_note_uses_each_count_form` in `crates/ui-egui/src/panels/tests_css_properties.rs`. The status bar's messages for relinked, replaced, updated and embedded images, broken links, linked files that can't be read or changed on disk, images that can't be decoded, added and unavailable fonts, copied CSS, downloads and exports with their notes name one item in the singular and more in the plural, in English and in each language's plural forms (`counted_status_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). + The status bar's messages for documents opened or saved with notes and for discarded or restored recovered documents, and the Global Edit options' Results line, do the same (`open_save_and_recovery_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`); the warnings for linked images and placed documents output as their previews and the errors for a page past a placed file's last page or artboard name their counts in the singular or the plural in English. - Edit › Undo and Edit › Redo, in the in-window and native menus, name the step in the interface language as the History panel lists it (`panels::history::step_name`), so when the catalog has no plain row for a step named after an effect, such as Feather, the menu shows the translation of the effect's menu row without its ellipsis (`undo_and_redo_name_the_step_as_the_history_panel_does`). - The History panel translates the undo step names the engine, the tools and the shell write as literals: the untranslated report (`untranslated_report`) lists any a complete catalog lacks, and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis. - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. Not done: a new document from the selected state.