From ed09ce8746edba3ff3af8ff3e985bc1f6e89a15e Mon Sep 17 00:00:00 2001 From: Valentin Millet Date: Sun, 13 Sep 2026 19:49:46 +0200 Subject: [PATCH] Degrade a bundle from a newer version instead of refusing it whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Note` carries two closed enums, so one note's `"language": "rust"` made serde fail and took the other 499 with it. `read_bundle` now walks the raw JSON first and brings any value this build cannot name down to the default, counting the notes it touched; `ImportReport.notes_degraded` reports them, counted on what was actually inserted so a re-import says nothing a second time. This is the stance the database read already took. The bridge stays strict. An added enum variant is not a format break, so it does not bump FORMAT_VERSION — which is now read off the raw JSON, before the bundle is built, so a genuinely future format answers with its own message. --- CLAUDE.md | 1 + docs/architecture.md | 20 +++ e2e/specs/10-library-transfer.e2e.ts | 37 +++- src-tauri/Cargo.toml | 3 - src-tauri/src/transfer/bundle.rs | 11 +- src-tauri/src/transfer/file.rs | 8 +- src-tauri/src/transfer/model.rs | 162 ++++++++++++++++-- src-tauri/tests/ipc_contract.rs | 10 +- src-tauri/tests/transfer.rs | 97 ++++++++++- src/app/core/ipc/bindings.ts | 5 + .../core/services/i18n/translations/en.json | 1 + .../core/services/i18n/translations/fr.json | 1 + src/app/core/state/library.store.spec.ts | 25 ++- src/app/core/state/library.store.ts | 21 ++- src/testing/fake-transfer-repository.ts | 7 +- 15 files changed, 364 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8e08f66..b69d446 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/ - **A modal is a shell, not a copy.** `DialogComponent` (`shared/dialog/`) owns the scrim, the panel, `role="dialog"`, `aria-modal`, the focus trap, Escape and the backdrop click; a dialog projects its content and declares a `layer`. The rung table in `dialog.model.ts` is the **only** place a modal `z-index` exists — its index in `LAYERS` is both the `z-index` and the Escape priority. `DialogStack` gives the keystroke to whichever modal is in front, ordering by rung and not by arrival (the fields form is created after the palette but drawn over it), and its `hasOpenDialog()` is what tells the canvas its keyboard is taken. What a shell cannot guess — width, height, gap, padding, where it sits on the scrim — comes from `--dialog-*` custom properties the consumer sets on the `app-dialog` element; no measurement goes through an `input()` or gets spelled in a template. - **⚠️ A command that touches the database or the disk is `#[tauri::command(async)]`.** A plain `#[tauri::command]` compiles as `ExecutionContext::Blocking` and runs its body **inline in the WebView's IPC handler** — the main thread — freezing the window for its whole duration. `(async)` on the same synchronous function moves it off that thread; nothing about the signature or `bindings.ts` changes. ⚠️ It lands on a **Tokio worker**, not on `spawn_blocking` — the macro emits `respond_async_serialized`, which is `async_runtime::spawn` — so the pool is the size of the core count and is shared with the updater and the plugins. Fine while the connection mutex serializes the database work anyway; a genuinely long command would need `async fn` plus an explicit `spawn_blocking`. The exception is `desktop.rs`: the tray and shortcut commands want the main thread, which is also why `desktop::init` manages every piece of native state up front rather than letting a command create its own. - **A module holding commands is `pub`; everything else is `pub(crate)`.** `#[specta::specta]` generates a macro per command that `collect_commands!` resolves from the crate root, so those modules cannot be narrowed. Narrowing the rest is what gives `dead_code` and `unreachable_pub` — both denied in `Cargo.toml` — something to say: a blanket `pub` had silenced them, and three unused items had accumulated behind it. +- **A bundle from a newer version degrades rather than failing whole.** `Note` carries two closed enums, so one note's `"language": "rust"` used to fail the import of the other 499. `transfer::model::read_bundle` walks the raw JSON first, brings any value this build cannot name down to the default and counts the notes it touched; `ImportReport.notes_degraded` says how many, counted on what was actually inserted so a re-import reports nothing. This matches the **database** read, which has always degraded — the bridge is what stays strict. ⚠️ An added enum variant therefore does **not** bump `FORMAT_VERSION`: the file still parses. - **The export file is staged then renamed, and an import is one transaction.** `fs::write` truncates first, so writing straight to the target destroyed the previous export when the disk filled; and a per-note transaction left half an import behind with the report lost. Both are in `transfer/file.rs` and `transfer/bundle.rs` — `transfer.rs` holds commands only. - **Zoneless.** Every component is `OnPush` and state is signal-based; derived state is `computed()`, never a manually maintained signal. - **Three stores hold the canvas, and the dependency runs one way.** `NotesQueryStore` answers _which notes are shown_ (search, filters, facets, the `resource`, the retained view); `NoteSelectionStore` answers _which one is pointed at_ (focus and ticks, both positions in `visibleNotes`); `NotesStore` answers _the note itself_ (open note, draft, writes, deletion, undo). The page injects them as `canvas`, `selection` and `store`. diff --git a/docs/architecture.md b/docs/architecture.md index ec7f39d..8789ea4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -557,6 +557,26 @@ Both fields carry `#[serde(default)]`. `transfer::Bundle` deserialises `Note` it required key would have made every export file written before todo lists unreadable — `FORMAT_VERSION` stays at 1 precisely because old files still read. +**A bundle from a newer version degrades, it does not fail.** `Note` carries two closed +enums, `language` and `kind`, so a file written once `Language` has grown a `rust` variant +used to fail serde outright and take the other 499 notes with it. `read_bundle` now walks +the raw JSON first and brings any value this build cannot name down to the default, counting +the notes it touched; `ImportReport.notes_degraded` reports them, and `merge` counts only +those actually inserted, so re-importing the same file says nothing a second time. + +That settles an inconsistency the two read paths had by accident. The **database** read has +always degraded (`notes::store`, `TryFrom`: `row.language.parse().unwrap_or_default()`); +the bundle read refused. A bundle is the same data through another door, so it degrades too. +Degrading also loses less than skipping the note would: the title, body, tags and deadline +all arrive, only the colouring is dropped. What stays strict is the **bridge** — a `language` +the front end cannot name is still a deserialisation failure there, which is what lets the +generated union be trusted. + +⚠️ Consequences for `FORMAT_VERSION`: an added enum variant **does not** bump it. It is not a +format break, since the file still parses. It is bumped when a file written today would stop +being readable — and it is read off the raw JSON before the bundle is built, so a genuinely +future format answers with the designed message rather than with a serde error about a field. + Progress (`done`/`total`) is **not** on the wire. The items already travel with the note, and a counter beside them would be the identity mapper this codebase refuses elsewhere; the card and the editor each count in a `computed()`. That is the same line as relative-time diff --git a/e2e/specs/10-library-transfer.e2e.ts b/e2e/specs/10-library-transfer.e2e.ts index 0c9c4fc..88faea0 100644 --- a/e2e/specs/10-library-transfer.e2e.ts +++ b/e2e/specs/10-library-transfer.e2e.ts @@ -1,5 +1,5 @@ import { browser, expect } from '@wdio/globals'; -import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -103,6 +103,41 @@ describe('Import, export and share', () => { expect(view.sections[0]?.notes[0]?.spaceId).toBe(homeId); }); + /** + * `language` and `kind` are closed enums on both sides, so one note written by a + * newer DevBox used to fail the **whole** file on a serde message about a variant. + * It now arrives with that field brought down to the default, and the report is what + * says the colouring was lost. + */ + it('imports a bundle from a newer version instead of refusing it whole', async () => { + const source = JSON.parse(readFileSync(bundlePath, 'utf8')) as { + notes: Record[]; + }; + const newerPath = join(directory, 'newer.json').replaceAll('\\', '/'); + writeFileSync( + newerPath, + JSON.stringify({ + ...source, + notes: [ + { + ...source.notes[0], + id: 'written-by-a-newer-devbox', + title: 'Ahead of this build', + language: 'rust', + }, + ], + }), + 'utf8', + ); + + const report = await bridge.importNotes(newerPath); + expect(report.notesImported).toBe(1); + expect(report.notesDegraded).toBe(1); + + const view = await bridge.queryNotes(query({ search: 'Ahead of this build' })); + expect(view.sections[0]?.notes[0]?.language).toBe('txt'); + }); + it('greys out the menu entries that have nothing to act on', async () => { await fileMenu.open(); // Nothing is ticked, so there is no selection to export. The entry stays in the DOM diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4524843..aed86c9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,9 +50,6 @@ tauri-plugin-clipboard-manager = "2" tauri-plugin-log = "2" log = "0.4" thiserror = "2.0.20" -# Search folds accents, which means decomposing before dropping the marks: `etape` -# has to find `Étape` on a French corpus. Hand-rolling a Latin table instead would -# be a list that goes stale the first time someone writes Polish or Vietnamese. unicode-normalization = "0.1.25" # Pulled in only by the `e2e` feature, and not optional in practice: the runner # waits on `window.wdioTauri` before it will open a session, so a binary without diff --git a/src-tauri/src/transfer/bundle.rs b/src-tauri/src/transfer/bundle.rs index b585243..bf353b4 100644 --- a/src-tauri/src/transfer/bundle.rs +++ b/src-tauri/src/transfer/bundle.rs @@ -7,7 +7,7 @@ use chrono::Utc; use diesel::SqliteConnection; use diesel::prelude::*; -use super::model::{self, Bundle, ImportReport}; +use super::model::{self, Bundle, ImportReport, IncomingBundle}; use crate::error::StorageError; use crate::notes::model::Note; use crate::notes::store as notes; @@ -41,8 +41,10 @@ pub fn collect( /// created and part of the notes in, with the report lost along with the error. pub fn merge( connection: &mut SqliteConnection, - bundle: Bundle, + incoming: IncomingBundle, ) -> Result { + let IncomingBundle { bundle, degraded } = incoming; + connection.transaction(|connection| { let mut report = ImportReport::default(); @@ -74,6 +76,11 @@ pub fn merge( if notes::insert_imported(connection, ¬e)? { report.notes_imported += 1; + // Only what actually came in: re-importing the same file imports + // nothing, and would otherwise keep reporting the same degradation. + if degraded.contains(¬e.id) { + report.notes_degraded += 1; + } } else { report.notes_skipped += 1; } diff --git a/src-tauri/src/transfer/file.rs b/src-tauri/src/transfer/file.rs index 6c6c96b..1cb9283 100644 --- a/src-tauri/src/transfer/file.rs +++ b/src-tauri/src/transfer/file.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use uuid::Uuid; -use super::model::{Bundle, ExportReport}; +use super::model::{Bundle, ExportReport, IncomingBundle}; use crate::count::saturating_u32; use crate::error::{AppError, StorageError}; @@ -32,7 +32,7 @@ pub fn write(path: &str, bundle: &Bundle) -> Result { Ok(report) } -pub fn read(path: &str) -> Result { +pub fn read(path: &str) -> Result { let json = std::fs::read_to_string(path) .map_err(|error| StorageError::File(format!("{path}: {error}")))?; @@ -147,8 +147,8 @@ mod tests { write(&target.to_string_lossy(), &bundle()).unwrap(); let read_back = read(&target.to_string_lossy()).unwrap(); - assert_eq!(read_back.notes.len(), 1); - assert_eq!(read_back.spaces[0].name, "Personal"); + assert_eq!(read_back.bundle.notes.len(), 1); + assert_eq!(read_back.bundle.spaces[0].name, "Personal"); std::fs::remove_dir_all(&directory).ok(); } } diff --git a/src-tauri/src/transfer/model.rs b/src-tauri/src/transfer/model.rs index 2d0acc8..c5a6440 100644 --- a/src-tauri/src/transfer/model.rs +++ b/src-tauri/src/transfer/model.rs @@ -2,8 +2,9 @@ //! to `Note` is exported without anyone thinking about it, and an older file stays //! readable as long as serde can fill the gap. -use std::collections::BTreeMap; -use std::fmt::Write; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::{Display, Write}; +use std::str::FromStr; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -11,11 +12,17 @@ use specta::Type; use crate::error::{StorageError, ValidationError}; use crate::notes::checklist::{self, NoteKind}; +use crate::notes::language::Language; use crate::notes::model::Note; use crate::spaces::model::Space; /// Bumped when a file written today would stop being readable. Refusing a newer /// version beats importing half of it. +/// +/// ⚠️ An **added enum variant does not bump this**. It is not a format break: the +/// file still parses, one field just names something this build has never heard of, +/// and [`read_bundle`] brings that field down to the default. Bumping here instead +/// would refuse a 500-note file over one note's `"rust"`. pub const FORMAT_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -42,20 +49,94 @@ pub struct ImportReport { pub spaces_created: u32, pub notes_imported: u32, pub notes_skipped: u32, + /// Imported, but with a `language` or a `kind` this build does not know brought + /// down to the default. Counted so the loss is said rather than discovered. + pub notes_degraded: u32, } -pub fn read_bundle(json: &str) -> Result { - let bundle: Bundle = serde_json::from_str(json) +/// A bundle read from a file, and the ids [`read_bundle`] had to bring down to a +/// shape this build knows. +#[derive(Debug)] +pub struct IncomingBundle { + pub bundle: Bundle, + pub degraded: BTreeSet, +} + +/// ⚠️ The version is read off the raw JSON, before the bundle is built: a file from +/// a future format may not deserialise at all, and the designed message beats serde's. +pub fn read_bundle(json: &str) -> Result { + let mut value: serde_json::Value = serde_json::from_str(json) .map_err(|error| StorageError::ImportFormat(error.to_string()))?; - if bundle.version > FORMAT_VERSION { + let version = value + .get("version") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default(); + + if version > u64::from(FORMAT_VERSION) { return Err(StorageError::ImportFormat(format!( - "format version {}, this version of DevBox reads up to {FORMAT_VERSION}", - bundle.version + "format version {version}, this version of DevBox reads up to {FORMAT_VERSION}" ))); } - Ok(bundle) + let degraded = degrade_unknown_values(&mut value); + + let bundle: Bundle = serde_json::from_value(value) + .map_err(|error| StorageError::ImportFormat(error.to_string()))?; + + Ok(IncomingBundle { bundle, degraded }) +} + +/// A newer DevBox may have written a `language` or a `kind` this build never heard of, +/// and `Note` deserialises both as closed enums — so one `"rust"` in a 500-note file +/// failed the whole import with a serde message about a variant. +/// +/// **It degrades, like the database read already does** (`notes::store`, +/// `TryFrom`): a bundle is the same data through another door, and that is the +/// one place the two disagreed. Degrading loses less than skipping the note — the title, +/// the body, the tags and the deadline all still arrive, only the colouring is dropped — +/// and the report says how many, so it is not silent. What stays strict is the +/// **bridge**: a value the front end cannot name has no business being written. +fn degrade_unknown_values(bundle: &mut serde_json::Value) -> BTreeSet { + let mut degraded = BTreeSet::new(); + + let Some(notes) = bundle + .get_mut("notes") + .and_then(serde_json::Value::as_array_mut) + else { + return degraded; + }; + + for note in notes { + let language = degrade_field::(note, "language"); + let kind = degrade_field::(note, "kind"); + + if (language || kind) + && let Some(id) = note.get("id").and_then(serde_json::Value::as_str) + { + degraded.insert(id.to_string()); + } + } + + degraded +} + +/// A field that is absent, or holds something other than a string, is left for serde +/// to judge: a malformed file is malformed, not a file from a newer version. +fn degrade_field( + note: &mut serde_json::Value, + field: &str, +) -> bool { + let Some(current) = note.get(field).and_then(serde_json::Value::as_str) else { + return false; + }; + + if current.parse::().is_ok() { + return false; + } + + note[field] = serde_json::Value::String(T::default().to_string()); + true } pub fn validate_path(path: &str) -> Result<(), ValidationError> { @@ -194,17 +275,69 @@ mod tests { #[test] fn a_bundle_from_a_newer_version_is_refused_rather_than_half_read() { + // Notes this build could not deserialise at all: the version is read off the + // raw JSON first, so the answer names the version and not a serde field. let json = serde_json::json!({ "version": FORMAT_VERSION + 1, "exportedAt": "2026-07-25T09:00:00.000Z", "spaces": [], - "notes": [], + "notes": [{ "shape": "from the future" }], }) .to_string(); - let error = read_bundle(&json).unwrap_err(); + let StorageError::ImportFormat(message) = read_bundle(&json).unwrap_err() else { + panic!("expected a format error"); + }; + + assert!(message.contains("format version")); + } - assert!(matches!(error, StorageError::ImportFormat(_))); + /// The file a newer DevBox writes once its `Language` has grown a variant. + fn bundle_with(field: &str, value: serde_json::Value) -> String { + let mut json = serde_json::json!({ + "version": FORMAT_VERSION, + "exportedAt": "2026-07-25T09:00:00.000Z", + "spaces": [{ "id": "s-1", "name": "Personal" }], + "notes": [serde_json::to_value(sample()).unwrap()], + }); + json["notes"][0][field] = value; + + json.to_string() + } + + #[test] + fn an_unknown_language_is_brought_down_to_the_default_rather_than_refused() { + let read = read_bundle(&bundle_with("language", "rust".into())).unwrap(); + + assert_eq!(read.bundle.notes[0].language, Language::default()); + assert_eq!(read.degraded.len(), 1); + assert!(read.degraded.contains(&read.bundle.notes[0].id)); + } + + #[test] + fn an_unknown_kind_is_brought_down_the_same_way() { + let read = read_bundle(&bundle_with("kind", "table".into())).unwrap(); + + assert_eq!(read.bundle.notes[0].kind, NoteKind::default()); + assert_eq!(read.degraded.len(), 1); + } + + #[test] + fn a_known_value_is_left_exactly_as_written() { + let read = read_bundle(&bundle_with("language", "sql".into())).unwrap(); + + assert_eq!(read.bundle.notes[0].language.to_string(), "sql"); + assert!(read.degraded.is_empty()); + } + + /// A malformed file is malformed, not a file from a newer version: only a string + /// this build cannot name is degraded. + #[test] + fn a_language_that_is_not_a_string_is_still_a_format_error() { + assert!(matches!( + read_bundle(&bundle_with("language", 42.into())).unwrap_err(), + StorageError::ImportFormat(_) + )); } #[test] @@ -229,8 +362,9 @@ mod tests { let read = read_bundle(&serde_json::to_string(&bundle).unwrap()).unwrap(); - assert_eq!(read.notes.len(), 1); - assert_eq!(read.notes[0].title, "Title"); - assert_eq!(read.spaces[0].name, "Personal"); + assert_eq!(read.bundle.notes.len(), 1); + assert_eq!(read.bundle.notes[0].title, "Title"); + assert_eq!(read.bundle.spaces[0].name, "Personal"); + assert!(read.degraded.is_empty()); } } diff --git a/src-tauri/tests/ipc_contract.rs b/src-tauri/tests/ipc_contract.rs index b71139e..2bbcaa1 100644 --- a/src-tauri/tests/ipc_contract.rs +++ b/src-tauri/tests/ipc_contract.rs @@ -421,12 +421,14 @@ fn an_import_report_names_what_it_skipped() { spaces_created: 1, notes_imported: 2, notes_skipped: 3, + notes_degraded: 4, }) .unwrap(); assert_eq!(json["spacesCreated"], 1); assert_eq!(json["notesImported"], 2); assert_eq!(json["notesSkipped"], 3); + assert_eq!(json["notesDegraded"], 4); } #[test] @@ -446,7 +448,7 @@ fn an_export_bundle_reads_back_the_notes_it_wrote() { assert!(json.contains("\"exportedAt\"")); let read = transfer::model::read_bundle(&json).unwrap(); - assert_eq!(read.notes[0].id, "n-1"); + assert_eq!(read.bundle.notes[0].id, "n-1"); } #[test] @@ -501,9 +503,9 @@ fn an_export_written_before_todo_lists_existed_still_reads() { let read = transfer::model::read_bundle(&json).unwrap(); - assert_eq!(read.notes[0].kind, NoteKind::Snippet); - assert!(read.notes[0].items.is_empty()); - assert!(read.notes[0].placeholder_values.is_empty()); + assert_eq!(read.bundle.notes[0].kind, NoteKind::Snippet); + assert!(read.bundle.notes[0].items.is_empty()); + assert!(read.bundle.notes[0].placeholder_values.is_empty()); } #[test] diff --git a/src-tauri/tests/transfer.rs b/src-tauri/tests/transfer.rs index c4f040b..11d97d5 100644 --- a/src-tauri/tests/transfer.rs +++ b/src-tauri/tests/transfer.rs @@ -8,7 +8,7 @@ use devbox_lib::notes::model::{NoteDraft, NoteLifecycle}; use devbox_lib::notes::store as notes; use devbox_lib::spaces::store as spaces; use devbox_lib::transfer::bundle::{collect, merge}; -use devbox_lib::transfer::model::{self, Bundle}; +use devbox_lib::transfer::model::{self, Bundle, IncomingBundle}; fn t0() -> DateTime { iso8601::parse("2026-07-25T09:00:00.000Z").unwrap() @@ -46,10 +46,19 @@ fn exported(connection: &mut SqliteConnection) -> Bundle { } /// The file as it is really written and read back, serialization included. -fn round_tripped(bundle: &Bundle) -> Bundle { +fn round_tripped(bundle: &Bundle) -> IncomingBundle { model::read_bundle(&serde_json::to_string(bundle).unwrap()).unwrap() } +/// The file a newer DevBox would write: the same bundle, with a value in `field` +/// that this build has never heard of. +fn written_by_a_newer_version(bundle: &Bundle, field: &str, value: &str) -> IncomingBundle { + let mut json: serde_json::Value = serde_json::to_value(bundle).unwrap(); + json["notes"][0][field] = serde_json::Value::String(value.to_string()); + + model::read_bundle(&json.to_string()).unwrap() +} + #[test] fn a_library_moves_whole_to_another_machine() { let mut source = library(); @@ -87,11 +96,11 @@ fn only_the_spaces_actually_cited_travel() { #[test] fn importing_the_same_file_twice_adds_nothing_the_second_time() { let mut source = library(); - let bundle = round_tripped(&exported(&mut source)); + let file = exported(&mut source); let mut target = open_in_memory().unwrap(); - merge(&mut target, round_tripped(&bundle)).unwrap(); - let second = merge(&mut target, bundle).unwrap(); + merge(&mut target, round_tripped(&file)).unwrap(); + let second = merge(&mut target, round_tripped(&file)).unwrap(); assert_eq!(second.notes_imported, 0); assert_eq!(second.notes_skipped, 2); @@ -128,11 +137,11 @@ fn a_space_of_the_same_name_is_reused_rather_than_duplicated() { #[test] fn a_note_whose_space_is_missing_from_the_file_is_skipped_not_misfiled() { let mut source = library(); - let mut bundle = round_tripped(&exported(&mut source)); - bundle.spaces.clear(); + let mut incoming = round_tripped(&exported(&mut source)); + incoming.bundle.spaces.clear(); let mut target = open_in_memory().unwrap(); - let report = merge(&mut target, bundle).unwrap(); + let report = merge(&mut target, incoming).unwrap(); assert_eq!(report.notes_imported, 0); assert_eq!(report.notes_skipped, 2); @@ -148,3 +157,75 @@ fn a_trashed_note_does_not_leave_with_the_export() { assert_eq!(bundle.notes.len(), 1); } + +/// The case the whole guard exists for: one note in an unknown language must not +/// cost the other 499. +#[test] +fn a_note_in_an_unknown_language_arrives_without_taking_the_file_down() { + let mut source = library(); + let file = exported(&mut source); + + let mut target = open_in_memory().unwrap(); + let report = merge( + &mut target, + written_by_a_newer_version(&file, "language", "rust"), + ) + .unwrap(); + + assert_eq!(report.notes_imported, 2); + assert_eq!(report.notes_degraded, 1); + + let arrived = notes::all(&mut target, None).unwrap(); + assert_eq!(arrived.len(), 2); + assert!( + arrived + .iter() + .any(|note| note.language == Language::default()) + ); +} + +#[test] +fn a_note_of_an_unknown_kind_is_degraded_the_same_way() { + let mut source = library(); + let file = exported(&mut source); + + let mut target = open_in_memory().unwrap(); + let report = merge( + &mut target, + written_by_a_newer_version(&file, "kind", "table"), + ) + .unwrap(); + + assert_eq!(report.notes_imported, 2); + assert_eq!(report.notes_degraded, 1); + assert!( + notes::all(&mut target, None) + .unwrap() + .iter() + .all(|note| note.kind == NoteKind::default()) + ); +} + +/// The count follows what came in, not what the file carried: the second import +/// adds nothing, so it has nothing to report as degraded either. +#[test] +fn a_degraded_note_is_counted_once_and_not_again_on_a_second_import() { + let mut source = library(); + let file = exported(&mut source); + + let mut target = open_in_memory().unwrap(); + merge( + &mut target, + written_by_a_newer_version(&file, "language", "rust"), + ) + .unwrap(); + + let second = merge( + &mut target, + written_by_a_newer_version(&file, "language", "rust"), + ) + .unwrap(); + + assert_eq!(second.notes_imported, 0); + assert_eq!(second.notes_degraded, 0); +} diff --git a/src/app/core/ipc/bindings.ts b/src/app/core/ipc/bindings.ts index 5c874b5..8621008 100644 --- a/src/app/core/ipc/bindings.ts +++ b/src/app/core/ipc/bindings.ts @@ -213,6 +213,11 @@ export type ImportReport = { spacesCreated: number, notesImported: number, notesSkipped: number, + /** + * Imported, but with a `language` or a `kind` this build does not know brought + * down to the default. Counted so the loss is said rather than discovered. + */ + notesDegraded: number, }; /** diff --git a/src/app/core/services/i18n/translations/en.json b/src/app/core/services/i18n/translations/en.json index 9ef0e74..e26ae7d 100644 --- a/src/app/core/services/i18n/translations/en.json +++ b/src/app/core/services/i18n/translations/en.json @@ -312,6 +312,7 @@ "quitConfirm": "Confirm?", "imported": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped.", "importedNothing": "Nothing to import: those {{skipped}} note(s) are already in your library.", + "importedFromNewerVersion": "{{notes}} note(s) imported from {{path}}, {{skipped}} skipped. {{degraded}} came from a newer version: their language or kind was brought down to what this version can read.", "exported": "{{notes}} note(s) exported to {{path}}.", "copied": "{{notes}} note(s) copied as Markdown to the clipboard.", "needsSelection": "Select at least one note.", diff --git a/src/app/core/services/i18n/translations/fr.json b/src/app/core/services/i18n/translations/fr.json index 3d9f6d1..0d3d498 100644 --- a/src/app/core/services/i18n/translations/fr.json +++ b/src/app/core/services/i18n/translations/fr.json @@ -312,6 +312,7 @@ "quitConfirm": "Confirmer ?", "imported": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s).", "importedNothing": "Rien à importer : ces {{skipped}} note(s) sont déjà dans votre bibliothèque.", + "importedFromNewerVersion": "{{notes}} note(s) importée(s) depuis {{path}}, {{skipped}} ignorée(s). {{degraded}} vien(nen)t d’une version plus récente : leur langage ou leur type a été ramené à ce que cette version sait lire.", "exported": "{{notes}} note(s) exportée(s) vers {{path}}.", "copied": "{{notes}} note(s) copiée(s) en Markdown dans le presse-papier.", "needsSelection": "Sélectionnez au moins une note.", diff --git a/src/app/core/state/library.store.spec.ts b/src/app/core/state/library.store.spec.ts index e43b432..1d461c8 100644 --- a/src/app/core/state/library.store.spec.ts +++ b/src/app/core/state/library.store.spec.ts @@ -60,13 +60,34 @@ describe('LibraryStore', () => { expect(await harness.store.import()).toBe(true); expect(harness.status.status()).toEqual({ key: 'file.imported', - params: { notes: '2', skipped: '0', path: 'devbox-2026-08-27.json' }, + params: { notes: '2', skipped: '0', degraded: '0', path: 'devbox-2026-08-27.json' }, }); }); + // A bundle from a newer DevBox imports rather than failing whole, and the note it + // brought down to a language this build knows is the only trace of it. + it('says when a note came from a newer version', async () => { + harness.dialog.openPath = 'C:/in.json'; + harness.repository.importReport = { + spacesCreated: 0, + notesImported: 5, + notesSkipped: 0, + notesDegraded: 1, + }; + + expect(await harness.store.import()).toBe(true); + expect(harness.status.status()?.key).toBe('file.importedFromNewerVersion'); + expect(harness.status.status()?.params).toMatchObject({ notes: '5', degraded: '1' }); + }); + it('says so plainly when everything was already there', async () => { harness.dialog.openPath = 'C:/in.json'; - harness.repository.importReport = { spacesCreated: 0, notesImported: 0, notesSkipped: 4 }; + harness.repository.importReport = { + spacesCreated: 0, + notesImported: 0, + notesSkipped: 4, + notesDegraded: 0, + }; expect(await harness.store.import()).toBe(false); expect(harness.status.status()?.key).toBe('file.importedNothing'); diff --git a/src/app/core/state/library.store.ts b/src/app/core/state/library.store.ts index 8643ce7..49ab976 100644 --- a/src/app/core/state/library.store.ts +++ b/src/app/core/state/library.store.ts @@ -3,6 +3,7 @@ import { ClipboardService } from '@core/services/clipboard/clipboard.service'; import { ErrorNotifier } from '@core/services/errors/error-notifier.service'; import { FileDialogService } from '@core/services/dialogs/file-dialog.service'; import { StatusNotifier } from '@core/services/notifications/status.service'; +import { ImportReport } from '@core/model/note.model'; import { TransferRepository } from '../data/transfer.repository'; import { NotesRevision } from './notes-revision'; @@ -11,6 +12,18 @@ function defaultFileName(now: Date): string { return `devbox-${now.toISOString().slice(0, 10)}.json`; } +/** + * The most common gesture — export then re-import at once — adds nothing at all, and + * saying so explicitly stops it looking like a breakdown. A note whose language or kind + * came from a newer version arrived all the same, brought down to what this build knows; + * the report is the only place that says so. + */ +function importedKey(report: ImportReport): string { + if (report.notesImported === 0) return 'file.importedNothing'; + + return report.notesDegraded > 0 ? 'file.importedFromNewerVersion' : 'file.imported'; +} + function fileNameOf(path: string): string { return path.split(/[/\\]/).pop() ?? path; } @@ -43,15 +56,11 @@ export class LibraryStore { const params = { notes: String(report.notesImported), skipped: String(report.notesSkipped), + degraded: String(report.notesDegraded), path: fileNameOf(path), }; - // The most common gesture — export then re-import at once — adds nothing at - // all. Saying so explicitly stops it looking like a breakdown. - this.status.notify({ - key: report.notesImported === 0 ? 'file.importedNothing' : 'file.imported', - params, - }); + this.status.notify({ key: importedKey(report), params }); const changed = report.notesImported > 0 || report.spacesCreated > 0; if (changed) this.revision.bump(); diff --git a/src/testing/fake-transfer-repository.ts b/src/testing/fake-transfer-repository.ts index 01f0854..41935de 100644 --- a/src/testing/fake-transfer-repository.ts +++ b/src/testing/fake-transfer-repository.ts @@ -16,7 +16,12 @@ export class FakeTransferRepository implements Pick {