fix(storage): merge the manifest instead of overwriting a change from another device

A manifest changed by a sync (e.g. a page added on another device) could
be overwritten by the next autosave before disk_watch took it over, which
dropped that page from the list. save_manifest now notices that the file
is not the one the notebook in memory is based on and merges the two, with
that base as common ancestor; if there is nothing new to save here, the
other version simply stays.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
haraldandClaude Fable 5.2 committed 2026-11-14 17:56:45 +01:00
1 parent 6e67303ad3
commit 406e7701ce
4 files changed
+194 -15

No files matched your search

+1
View File
@@ -178,6 +178,7 @@ Liegen die Notizbücher in einem Ordner, den Nextcloud, Google Drive, Syncthing
- Solange alle lokalen Änderungen gespeichert sind, prüft die App den Ordner alle 3 Sekunden (und wenn das Fenster den Fokus bekommt). Geänderte Seitendateien werden neu geladen, ein geändertes `manifest.json` bringt neue, entfernte oder umsortierte Seiten und Titel mit. - Solange alle lokalen Änderungen gespeichert sind, prüft die App den Ordner alle 3 Sekunden (und wenn das Fenster den Fokus bekommt). Geänderte Seitendateien werden neu geladen, ein geändertes `manifest.json` bringt neue, entfernte oder umsortierte Seiten und Titel mit.
- Konfliktkopien der Sync-Clients (`page_<id> (….json`, `page_<id>_….json`, `page_<id>.sync-conflict-….json`) werden als neue Seiten hinter dem Original eingefügt und in `<name>.imported` umbenannt. - Konfliktkopien der Sync-Clients (`page_<id> (….json`, `page_<id>_….json`, `page_<id>.sync-conflict-….json`) werden als neue Seiten hinter dem Original eingefügt und in `<name>.imported` umbenannt.
- Bevor die App eine Seitendatei überschreibt, die sich seit ihrem letzten Lesen oder Schreiben geändert hat, sichert sie den vorgefundenen Inhalt als `page_<id> (conflict <Unix-Zeit>).json` – außer sie hat selbst nichts geändert (dann bleibt die Datei, wie sie ist). - Bevor die App eine Seitendatei überschreibt, die sich seit ihrem letzten Lesen oder Schreiben geändert hat, sichert sie den vorgefundenen Inhalt als `page_<id> (conflict <Unix-Zeit>).json` – außer sie hat selbst nichts geändert (dann bleibt die Datei, wie sie ist).
- Dasselbe gilt für `manifest.json`, nur werden die beiden Fassungen zusammengeführt statt kopiert: Auf beiden Seiten hinzugefügte oder entfernte Seiten, Titel und Zähler werden kombiniert, mit dem zuletzt von der App gelesenen oder geschriebenen Manifest als gemeinsamem Vorgänger.
### Neuere Versionen ### Neuere Versionen
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt. Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
+1
View File
@@ -178,6 +178,7 @@ If the notebooks are kept in a folder synced by Nextcloud, Google Drive, Syncthi
- While all local changes are saved, the app checks the folder every 3 seconds (and when the window gets the focus). Changed page files are reloaded, a changed `manifest.json` brings in new, removed or reordered pages and titles. - While all local changes are saved, the app checks the folder every 3 seconds (and when the window gets the focus). Changed page files are reloaded, a changed `manifest.json` brings in new, removed or reordered pages and titles.
- Conflict copies of sync clients (`page_<id> (….json`, `page_<id>_….json`, `page_<id>.sync-conflict-….json`) are added as new pages after the original and renamed to `<name>.imported`. - Conflict copies of sync clients (`page_<id> (….json`, `page_<id>_….json`, `page_<id>.sync-conflict-….json`) are added as new pages after the original and renamed to `<name>.imported`.
- Before the app overwrites a page file that changed since it last read or wrote it, it keeps the content found there as `page_<id> (conflict <unix time>).json`, unless it has no changes of its own (then the file is left as it is). - Before the app overwrites a page file that changed since it last read or wrote it, it keeps the content found there as `page_<id> (conflict <unix time>).json`, unless it has no changes of its own (then the file is left as it is).
- The same goes for `manifest.json`, except that the two versions are merged instead of copied: pages added or removed on either side, titles and counters are combined, with the manifest the app last read or wrote as common ancestor.
### Newer versions ### Newer versions
Files with a higher `format_version` than supported are not opened – this way an older app version cannot lose content it does not know. Files with a higher `format_version` than supported are not opened – this way an older app version cannot lose content it does not know.
+107 -2
View File
@@ -166,12 +166,16 @@ impl DiskWatch {
// Page order, titles, new and removed pages // Page order, titles, new and removed pages
let mut fresh_pages: HashSet<u64> = HashSet::new(); let mut fresh_pages: HashSet<u64> = HashSet::new();
if changed("manifest.json") { if changed("manifest.json") {
match storage::load_manifest(&self.dir) { match storage::load_manifest_as_base(&self.dir) {
Ok(theirs) => match self.apply_manifest(notebook, manifest, theirs) { Ok((theirs, base)) => match self.apply_manifest(notebook, manifest, theirs) {
// Taken over: from now on our saves build on this manifest
Ok(Some(new_ids)) => { Ok(Some(new_ids)) => {
base.adopt();
fresh_pages = new_ids; fresh_pages = new_ids;
outcome.structure_changed = true; outcome.structure_changed = true;
} }
// Nothing to take over for the pages. Whatever else differs is merged
// at the next save, so the base stays as it is.
Ok(None) => {} Ok(None) => {}
Err(()) => { Err(()) => {
retry.insert("manifest.json".to_string()); retry.insert("manifest.json".to_string());
@@ -557,6 +561,107 @@ mod tests {
assert_eq!(conflict_copies(dir.path()).len(), 1); assert_eq!(conflict_copies(dir.path()).len(), 1);
} }
/// Writes the manifest the way a sync client does: behind the back of `storage`.
fn write_manifest_from_another_device(dir: &Path, manifest: &NotebookManifest) {
fs::write(
dir.join("manifest.json"),
serde_json::to_vec(manifest).unwrap(),
)
.unwrap();
}
fn page_ids_on_disk(dir: &Path) -> Vec<u64> {
let bytes = fs::read(dir.join("manifest.json")).unwrap();
let manifest: NotebookManifest = serde_json::from_slice(&bytes).unwrap();
manifest.pages.iter().map(|p| p.id).collect()
}
#[test]
fn test_saving_the_manifest_keeps_a_page_added_on_another_device() {
let dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest) = saved_notebook(dir.path());
let now = Instant::now();
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
// The other device adds a second page
let mut theirs = notebook.clone();
let mut their_manifest = manifest.clone();
let index = theirs.add_page(PageBackground::Lined);
theirs.pages[index].add_stroke(stroke(6, 400.0));
theirs.next_stroke_id = 7;
their_manifest.sync_from_notebook(&theirs);
write_from_another_device(dir.path(), &theirs.pages[index]);
write_manifest_from_another_device(dir.path(), &their_manifest);
let their_page = theirs.pages[index].id;
// Before the check ran, a stroke is drawn and saved here, twice
for id in [2, 3] {
notebook.current_page_mut().add_stroke(stroke(id, 200.0));
notebook.next_stroke_id = id + 1;
manifest.sync_from_notebook(&notebook);
storage::save_page(dir.path(), &PageData::from(notebook.current_page())).unwrap();
storage::save_manifest(dir.path(), &manifest).unwrap();
assert_eq!(page_ids_on_disk(dir.path()), vec![1, their_page]);
}
// The check takes their page over, and saving stays an ordinary save afterwards
let outcome = watch.check(&mut notebook, &mut manifest, now);
assert!(outcome.structure_changed);
assert_eq!(notebook.pages.len(), 2);
assert_eq!(notebook.pages[0].strokes.len(), 3);
assert!(notebook.next_stroke_id >= 7);
manifest.sync_from_notebook(&notebook);
storage::save_manifest(dir.path(), &manifest).unwrap();
assert_eq!(page_ids_on_disk(dir.path()), vec![1, their_page]);
// A page removed here afterwards is really gone
notebook.pages.pop();
notebook.current_page_index = 0;
manifest.sync_from_notebook(&notebook);
storage::save_manifest(dir.path(), &manifest).unwrap();
assert_eq!(page_ids_on_disk(dir.path()), vec![1]);
}
#[test]
fn test_saving_an_unchanged_manifest_keeps_the_one_from_another_device() {
let dir = tempfile::tempdir().unwrap();
let (notebook, manifest) = saved_notebook(dir.path());
let mut their_manifest = manifest.clone();
their_manifest.title = "Physik".to_string();
write_manifest_from_another_device(dir.path(), &their_manifest);
storage::save_manifest(dir.path(), &manifest).unwrap();
let bytes = fs::read(dir.path().join("manifest.json")).unwrap();
let on_disk: NotebookManifest = serde_json::from_slice(&bytes).unwrap();
assert_eq!(on_disk.title, "Physik");
drop(notebook);
}
#[test]
fn test_manifest_changed_on_both_sides_is_merged() {
let dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest) = saved_notebook(dir.path());
let ours_new = notebook.add_page(PageBackground::Blank);
let ours_new = notebook.pages[ours_new].id;
manifest.sync_from_notebook(&notebook);
storage::save_manifest(dir.path(), &manifest).unwrap();
// They rename the notebook; we remove our new page again
let mut their_manifest = manifest.clone();
their_manifest.title = "Physik".to_string();
write_manifest_from_another_device(dir.path(), &their_manifest);
notebook.pages.pop();
notebook.current_page_index = 0;
manifest.sync_from_notebook(&notebook);
storage::save_manifest(dir.path(), &manifest).unwrap();
let bytes = fs::read(dir.path().join("manifest.json")).unwrap();
let on_disk: NotebookManifest = serde_json::from_slice(&bytes).unwrap();
assert_eq!(on_disk.title, "Physik");
assert!(!on_disk.pages.iter().any(|p| p.id == ours_new));
}
#[test] #[test]
fn test_page_still_being_synced_is_tried_again() { fn test_page_still_being_synced_is_tried_again() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+85 -13
View File
@@ -590,16 +590,32 @@ pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), Sto
/// Speichert das Manifest atomar in `manifest.json`. /// Speichert das Manifest atomar in `manifest.json`.
/// Sichert das bisherige Manifest als `manifest.json.bak` vor dem Überschreiben. /// Sichert das bisherige Manifest als `manifest.json.bak` vor dem Überschreiben.
///
/// Like a page (see [`save_page`]), the manifest may have been changed behind our back by a
/// sync before `disk_watch` took the change over, e.g. the other device added a page.
/// Overwriting it would drop that page from the list. The manifest on disk is therefore
/// merged with ours, with the manifest the notebook in memory is based on as common ancestor
/// ([`ManifestBase`]); if we have nothing new ourselves, theirs simply stays.
pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result<(), StorageError> { pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result<(), StorageError> {
let path = notebook_dir.join("manifest.json"); let path = notebook_dir.join("manifest.json");
if path.exists() { let ours = serde_json::to_vec(manifest)?;
let key = seen_key(&path);
let mut merged = None;
if let Ok(theirs) = fs::read(&path) {
let base = manifest_bases().get(&key).cloned();
if base.as_ref() != Some(&theirs) && theirs != ours {
if base.as_ref() == Some(&ours) {
// Nothing changed here since the notebook was loaded or last saved
return Ok(());
}
// Theirs unreadable (e.g. half written): ours replaces it
merged = merge_manifests(base.as_deref(), &ours, &theirs);
}
let bak_path = notebook_dir.join("manifest.json.v0.bak"); let bak_path = notebook_dir.join("manifest.json.v0.bak");
if !bak_path.exists() { if !bak_path.exists() {
if let Ok(content) = fs::read_to_string(&path) { if let Ok(old_m) = serde_json::from_slice::<NotebookManifest>(&theirs) {
if let Ok(old_m) = serde_json::from_str::<NotebookManifest>(&content) { if old_m.format_version == 0 {
if old_m.format_version == 0 { let _ = fs::copy(&path, &bak_path);
let _ = fs::copy(&path, &bak_path);
}
} }
} }
} }
@@ -607,10 +623,51 @@ pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result
let rolling_bak = notebook_dir.join("manifest.json.bak"); let rolling_bak = notebook_dir.join("manifest.json.bak");
let _ = fs::copy(&path, &rolling_bak); let _ = fs::copy(&path, &rolling_bak);
} }
atomic_write_json(&path, manifest) atomic_write_file(&path, merged.as_ref().unwrap_or(&ours))?;
// Ours, not the merged file: the notebook in memory does not contain their part until
// `disk_watch` has taken it over, so the next save has to merge it in again
manifest_bases().insert(key, ours);
Ok(())
} }
fn load_manifest_from_file(path: &Path) -> Result<NotebookManifest, StorageError> { /// Three-way merge of manifests as JSON (page lists by page id, see
/// `cloud_sync::merge_json`); `None` if one of them can't be read or the result is no
/// valid manifest.
fn merge_manifests(base: Option<&[u8]>, ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> {
let ours: serde_json::Value = serde_json::from_slice(ours).ok()?;
let theirs: serde_json::Value = serde_json::from_slice(theirs).ok()?;
let base: Option<serde_json::Value> = base.and_then(|b| serde_json::from_slice(b).ok());
let merged = crate::cloud_sync::merge_json(base.as_ref(), &ours, &theirs);
serde_json::from_value::<NotebookManifest>(merged.clone()).ok()?;
serde_json::to_vec(&merged).ok()
}
/// Content of `manifest.json` that the notebooks in memory are based on, by file.
fn manifest_bases() -> std::sync::MutexGuard<'static, std::collections::HashMap<PathBuf, Vec<u8>>> {
static BASES: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<PathBuf, Vec<u8>>>,
> = std::sync::OnceLock::new();
BASES
.get_or_init(Default::default)
.lock()
.unwrap_or_else(|e| e.into_inner())
}
/// A manifest as read from disk, to be declared the state the notebook in memory is based on
/// once it has been taken over (see [`save_manifest`]). Reading a manifest alone, e.g. for
/// the list of notebooks, must not do that.
pub struct ManifestBase {
key: PathBuf,
bytes: Vec<u8>,
}
impl ManifestBase {
pub fn adopt(self) {
manifest_bases().insert(self.key, self.bytes);
}
}
fn load_manifest_from_file(path: &Path) -> Result<(NotebookManifest, Vec<u8>), StorageError> {
let mut file = File::open(path)?; let mut file = File::open(path)?;
let mut json_str = String::new(); let mut json_str = String::new();
file.read_to_string(&mut json_str)?; file.read_to_string(&mut json_str)?;
@@ -630,7 +687,7 @@ fn load_manifest_from_file(path: &Path) -> Result<NotebookManifest, StorageError
.map(|&id| PageMeta::new(id, None)) .map(|&id| PageMeta::new(id, None))
.collect(); .collect();
} }
Ok(manifest) Ok((manifest, json_str.into_bytes()))
} }
/// Lädt das Manifest aus `manifest.json`. /// Lädt das Manifest aus `manifest.json`.
@@ -640,7 +697,15 @@ fn load_manifest_from_file(path: &Path) -> Result<NotebookManifest, StorageError
/// `<base>/<id>`. A copied or renamed folder still carries the id of the original in its /// `<base>/<id>`. A copied or renamed folder still carries the id of the original in its
/// manifest, so the id is always taken from the folder. /// manifest, so the id is always taken from the folder.
pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> { pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> {
let mut manifest = load_manifest_or_backup(notebook_dir)?; load_manifest_as_base(notebook_dir).map(|(manifest, _)| manifest)
}
/// Like [`load_manifest`], for a manifest that is taken over into the open notebook: call
/// [`ManifestBase::adopt`] once that has happened.
pub fn load_manifest_as_base(
notebook_dir: &Path,
) -> Result<(NotebookManifest, ManifestBase), StorageError> {
let (mut manifest, bytes) = load_manifest_or_backup(notebook_dir)?;
// Canonical, so that a path like `.` still yields the folder name // Canonical, so that a path like `.` still yields the folder name
let dir = notebook_dir.canonicalize(); let dir = notebook_dir.canonicalize();
let name = dir let name = dir
@@ -653,10 +718,16 @@ pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageErr
manifest.id = name.to_string(); manifest.id = name.to_string();
} }
} }
Ok(manifest) let base = ManifestBase {
key: seen_key(&notebook_dir.join("manifest.json")),
bytes,
};
Ok((manifest, base))
} }
fn load_manifest_or_backup(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> { fn load_manifest_or_backup(
notebook_dir: &Path,
) -> Result<(NotebookManifest, Vec<u8>), StorageError> {
let path = notebook_dir.join("manifest.json"); let path = notebook_dir.join("manifest.json");
if !path.exists() { if !path.exists() {
let bak_path = notebook_dir.join("manifest.json.bak"); let bak_path = notebook_dir.join("manifest.json.bak");
@@ -871,7 +942,8 @@ pub fn save_notebook(
/// Lädt ein Notizbuch aus einem Ordner (erste Seite sofort, Folgeseiten als Platzhalter für Lazy Loading). /// Lädt ein Notizbuch aus einem Ordner (erste Seite sofort, Folgeseiten als Platzhalter für Lazy Loading).
pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest), StorageError> { pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest), StorageError> {
let _ = cleanup_orphaned_temp_files(notebook_dir, std::time::Duration::from_secs(3600)); let _ = cleanup_orphaned_temp_files(notebook_dir, std::time::Duration::from_secs(3600));
let manifest = load_manifest(notebook_dir)?; let (manifest, base) = load_manifest_as_base(notebook_dir)?;
base.adopt();
let page_ids = manifest.page_ids(); let page_ids = manifest.page_ids();
if page_ids.is_empty() { if page_ids.is_empty() {
let nb = Notebook::new(); let nb = Notebook::new();