fix(storage): keep a page changed by another device when saving over it
A change from another device could reach the page file while a stroke was being drawn; the check that takes it over waits until everything is saved, so the next autosave overwrote it. save_page now notices that the file is not what this process last read or wrote: their version is kept as a conflict copy (which becomes a page of its own), or simply stays if there is nothing new to save here. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
41323950dc
commit
36bb77c299
6 files changed
+186
-12
No files matched your search
+1
-1
@@ -73,7 +73,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
|||||||
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
||||||
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
||||||
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
|
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
|
||||||
- **Speicherort** (☰-Menü, „Speicherort“): Liegen die Notizbücher in einem Ordner, den **Nextcloud, Google Drive, Syncthing oder Dropbox** abgleichen, sind sie auf allen Geräten verfügbar. Die Notizbücher liegen im Unterordner `ink-notes`; ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert (die alten bleiben erhalten). Änderungen von einem anderen Gerät erscheinen nach wenigen Sekunden ohne neu zu öffnen – aber nur, solange lokal alles gespeichert ist, damit nichts überschrieben wird. Legt der Sync-Client eine Konfliktkopie einer Seite an, wird sie als eigene Seite („Konfliktkopie: …“) übernommen statt verloren zu gehen. Ist ein Notizbuch gerade auf einem anderen Gerät geöffnet, warnt die App beim Öffnen.
|
- **Speicherort** (☰-Menü, „Speicherort“): Liegen die Notizbücher in einem Ordner, den **Nextcloud, Google Drive, Syncthing oder Dropbox** abgleichen, sind sie auf allen Geräten verfügbar. Die Notizbücher liegen im Unterordner `ink-notes`; ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert (die alten bleiben erhalten). Änderungen von einem anderen Gerät erscheinen nach wenigen Sekunden ohne neu zu öffnen – aber nur, solange lokal alles gespeichert ist, damit nichts überschrieben wird. Legt der Sync-Client eine Konfliktkopie einer Seite an, wird sie als eigene Seite („Konfliktkopie: …“) übernommen statt verloren zu gehen. Dasselbe passiert, wenn eine Änderung genau dann eintrifft, während du auf dieser Seite schreibst: Deine Fassung wird gespeichert, die andere bleibt als Konfliktkopie erhalten. Ist ein Notizbuch gerade auf einem anderen Gerät geöffnet, warnt die App beim Öffnen.
|
||||||
- **Nextcloud eingebaut** (☰-Menü, „Nextcloud“; auch unter Android und ohne Sync-Client): Adresse der eigenen Nextcloud eingeben – auch ein privater Server, in einem Unterordner (`example.org/nextcloud`) oder mit eigenem Port. Die Anmeldung läuft im Browser (auch mit Zwei-Faktor-Anmeldung); die App bekommt ein eigenes App-Passwort, das sich in Nextcloud unter Einstellungen → Sicherheit jederzeit widerrufen lässt, und sieht das echte Passwort nie. Die Notizbücher werden alle 30 Sekunden und kurz nach dem Speichern mit dem Ordner `ink-notes` in den Nextcloud-Dateien abgeglichen; offline arbeitet die App normal weiter. Wurde eine Seite auf zwei Geräten geändert, bleiben beide Fassungen erhalten (die andere als eigene Seite); gelöschte Notizbücher und Seiten landen im Papierkorb (lokal `.trash`, auf dem Server dessen Papierkorb). Einem selbst signierten Zertifikat vertraut die App, nachdem du seinen Fingerabdruck bestätigt hast; Adressen mit `http://` gehen nur mit Warnung, weil das App-Passwort dann im Netz lesbar wäre. „Trennen“ beendet den Abgleich und widerruft das App-Passwort; die Notizbücher bleiben erhalten. Bitte entweder den eingebauten Abgleich oder den Ordner eines Sync-Clients als Speicherort verwenden, nicht beides für dieselben Notizbücher.
|
- **Nextcloud eingebaut** (☰-Menü, „Nextcloud“; auch unter Android und ohne Sync-Client): Adresse der eigenen Nextcloud eingeben – auch ein privater Server, in einem Unterordner (`example.org/nextcloud`) oder mit eigenem Port. Die Anmeldung läuft im Browser (auch mit Zwei-Faktor-Anmeldung); die App bekommt ein eigenes App-Passwort, das sich in Nextcloud unter Einstellungen → Sicherheit jederzeit widerrufen lässt, und sieht das echte Passwort nie. Die Notizbücher werden alle 30 Sekunden und kurz nach dem Speichern mit dem Ordner `ink-notes` in den Nextcloud-Dateien abgeglichen; offline arbeitet die App normal weiter. Wurde eine Seite auf zwei Geräten geändert, bleiben beide Fassungen erhalten (die andere als eigene Seite); gelöschte Notizbücher und Seiten landen im Papierkorb (lokal `.trash`, auf dem Server dessen Papierkorb). Einem selbst signierten Zertifikat vertraut die App, nachdem du seinen Fingerabdruck bestätigt hast; Adressen mit `http://` gehen nur mit Warnung, weil das App-Passwort dann im Netz lesbar wäre. „Trennen“ beendet den Abgleich und widerruft das App-Passwort; die Notizbücher bleiben erhalten. Bitte entweder den eingebauten Abgleich oder den Ordner eines Sync-Clients als Speicherort verwenden, nicht beides für dieselben Notizbücher.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ The interface is available in English and German; ink-notes follows the system l
|
|||||||
- Every file is first written completely to a temporary file and then replaced – a crash while saving never leaves a half-written file.
|
- Every file is first written completely to a temporary file and then replaced – a crash while saving never leaves a half-written file.
|
||||||
- **When closing** (window or `Ctrl + Q`) and when switching notebooks, everything pending is written before continuing. If that fails, the app stays open and offers to retry or to make an emergency backup.
|
- **When closing** (window or `Ctrl + Q`) and when switching notebooks, everything pending is written before continuing. If that fails, the app stays open and offers to retry or to make an emergency backup.
|
||||||
- `Ctrl + S` saves immediately. The dot on the ☰ button shows the state: green = saved, orange = changes pending, yellow = saving, red = error.
|
- `Ctrl + S` saves immediately. The dot on the ☰ button shows the state: green = saved, orange = changes pending, yellow = saving, red = error.
|
||||||
- **Storage location** (☰ menu, "Storage location"): put the notebooks into a folder that **Nextcloud, Google Drive, Syncthing or Dropbox** keep in sync, and they are available on all your devices. The notebooks are kept in the subfolder `ink-notes`; if it is still empty, the existing notebooks are copied there (the old ones stay). Changes arriving from another device show up within a few seconds without reopening – but only while everything local is saved, so nothing gets overwritten. If the sync client makes a conflict copy of a page, it is added as a page of its own („Conflict copy: …“) instead of getting lost. Opening a notebook that is open on another device shows a warning.
|
- **Storage location** (☰ menu, "Storage location"): put the notebooks into a folder that **Nextcloud, Google Drive, Syncthing or Dropbox** keep in sync, and they are available on all your devices. The notebooks are kept in the subfolder `ink-notes`; if it is still empty, the existing notebooks are copied there (the old ones stay). Changes arriving from another device show up within a few seconds without reopening – but only while everything local is saved, so nothing gets overwritten. If the sync client makes a conflict copy of a page, it is added as a page of its own („Conflict copy: …“) instead of getting lost. The same happens when a change arrives just while you are writing on that page: your version is saved and theirs is kept as a conflict copy. Opening a notebook that is open on another device shows a warning.
|
||||||
- **Nextcloud built in** (☰ menu, "Nextcloud"; also on Android and without a sync client): enter the address of your Nextcloud – also a private server, in a subfolder (`example.org/nextcloud`) or with its own port. Signing in happens in the browser (also with two-factor sign-in); the app gets an app password of its own, which you can revoke in Nextcloud under Settings → Security at any time, and never sees your real password. The notebooks are synced with the folder `ink-notes` in your Nextcloud files every 30 seconds and shortly after saving; the app keeps working offline. If a page was changed on two devices, both versions are kept (the other one as a page of its own); deleted notebooks and pages go into the trash (locally `.trash`, on the server its trash bin). A self-signed certificate is trusted once you have confirmed its fingerprint; addresses with `http://` only work with a warning, since the app password would be readable on the network. "Disconnect" stops syncing and revokes the app password; the notebooks stay. Use either the built-in sync or a sync client's folder as storage location, not both for the same notebooks.
|
- **Nextcloud built in** (☰ menu, "Nextcloud"; also on Android and without a sync client): enter the address of your Nextcloud – also a private server, in a subfolder (`example.org/nextcloud`) or with its own port. Signing in happens in the browser (also with two-factor sign-in); the app gets an app password of its own, which you can revoke in Nextcloud under Settings → Security at any time, and never sees your real password. The notebooks are synced with the folder `ink-notes` in your Nextcloud files every 30 seconds and shortly after saving; the app keeps working offline. If a page was changed on two devices, both versions are kept (the other one as a page of its own); deleted notebooks and pages go into the trash (locally `.trash`, on the server its trash bin). A self-signed certificate is trusted once you have confirmed its fingerprint; addresses with `http://` only work with a warning, since the app password would be readable on the network. "Disconnect" stops syncing and revokes the app password; the notebooks stay. Use either the built-in sync or a sync client's folder as storage location, not both for the same notebooks.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ Liegen die Notizbücher in einem Ordner, den Nextcloud, Google Drive, Syncthing
|
|||||||
- `.opened_by.json` (`{"device": "<Rechnername>", "since": <Unix-Zeit>}`) wird beim Öffnen eines Notizbuchs geschrieben und beim Schließen entfernt. Eine Markierung eines anderen Geräts führt zu einer Warnung; die Sperrdatei `.lock` schützt nur vor einer zweiten Instanz auf demselben Rechner.
|
- `.opened_by.json` (`{"device": "<Rechnername>", "since": <Unix-Zeit>}`) wird beim Öffnen eines Notizbuchs geschrieben und beim Schließen entfernt. Eine Markierung eines anderen Geräts führt zu einer Warnung; die Sperrdatei `.lock` schützt nur vor einer zweiten Instanz auf demselben Rechner.
|
||||||
- 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).
|
||||||
|
|
||||||
### 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.
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ If the notebooks are kept in a folder synced by Nextcloud, Google Drive, Syncthi
|
|||||||
- `.opened_by.json` (`{"device": "<host name>", "since": <unix time>}`) is written when a notebook is opened and removed when it is closed. A marker from another device leads to a warning; the lock file `.lock` only protects against a second instance on the same computer.
|
- `.opened_by.json` (`{"device": "<host name>", "since": <unix time>}`) is written when a notebook is opened and removed when it is closed. A marker from another device leads to a warning; the lock file `.lock` only protects against a second instance on the same computer.
|
||||||
- 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).
|
||||||
|
|
||||||
### 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.
|
||||||
|
|||||||
@@ -461,6 +461,102 @@ mod tests {
|
|||||||
assert!(outcome.is_empty(), "{outcome:?}");
|
assert!(outcome.is_empty(), "{outcome:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes the page file the way a sync client does: behind the back of `storage`.
|
||||||
|
fn write_from_another_device(dir: &Path, page: &Page) {
|
||||||
|
let bytes = serde_json::to_vec(&PageData::from(page)).unwrap();
|
||||||
|
fs::write(dir.join(format!("page_{}.json", page.id)), bytes).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conflict_copies(dir: &Path) -> Vec<String> {
|
||||||
|
fs::read_dir(dir)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| conflict_copy_of(n).is_some())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_saving_over_a_change_from_another_device_keeps_both() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Their stroke arrives while a stroke is being drawn here, so the check has to wait
|
||||||
|
let mut theirs = notebook.current_page().clone();
|
||||||
|
theirs.add_stroke(stroke(5, 300.0));
|
||||||
|
write_from_another_device(dir.path(), &theirs);
|
||||||
|
|
||||||
|
// The stroke is finished and saved before the check ran
|
||||||
|
notebook.current_page_mut().add_stroke(stroke(2, 200.0));
|
||||||
|
notebook.next_stroke_id = 3;
|
||||||
|
storage::save_page(dir.path(), &PageData::from(notebook.current_page())).unwrap();
|
||||||
|
|
||||||
|
// Ours is in the page file, theirs next to it
|
||||||
|
let saved = storage::load_page(dir.path(), 1).unwrap();
|
||||||
|
let ids: Vec<u64> = saved.strokes.iter().map(|s| s.id).collect();
|
||||||
|
assert_eq!(ids, vec![1, 2]);
|
||||||
|
assert_eq!(conflict_copies(dir.path()).len(), 1);
|
||||||
|
|
||||||
|
// The check turns their version into a page of its own
|
||||||
|
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||||
|
assert_eq!(outcome.conflict_pages.len(), 1);
|
||||||
|
assert_eq!(notebook.pages.len(), 2);
|
||||||
|
assert_eq!(notebook.pages[0].strokes.len(), 2);
|
||||||
|
assert_eq!(notebook.pages[1].strokes.len(), 2);
|
||||||
|
assert!(conflict_copies(dir.path()).is_empty());
|
||||||
|
|
||||||
|
// Saving again is an ordinary save
|
||||||
|
notebook.pages[0].add_stroke(stroke(9, 500.0));
|
||||||
|
storage::save_page(dir.path(), &PageData::from(¬ebook.pages[0])).unwrap();
|
||||||
|
assert!(conflict_copies(dir.path()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_saving_an_unchanged_page_keeps_the_change_from_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);
|
||||||
|
|
||||||
|
let mut theirs = notebook.current_page().clone();
|
||||||
|
theirs.add_stroke(stroke(5, 300.0));
|
||||||
|
write_from_another_device(dir.path(), &theirs);
|
||||||
|
|
||||||
|
// E.g. turning pages marks the page for saving although nothing changed on it
|
||||||
|
storage::save_page(dir.path(), &PageData::from(notebook.current_page())).unwrap();
|
||||||
|
assert!(conflict_copies(dir.path()).is_empty());
|
||||||
|
|
||||||
|
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||||
|
assert_eq!(outcome.reloaded_pages, 1);
|
||||||
|
assert_eq!(notebook.pages.len(), 1);
|
||||||
|
assert_eq!(notebook.pages[0].strokes.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_saving_a_new_page_over_one_from_another_device_keeps_both() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (mut notebook, _) = saved_notebook(dir.path());
|
||||||
|
|
||||||
|
// Both devices add a second page; it gets the same id on both
|
||||||
|
let index = notebook.add_page(PageBackground::Blank);
|
||||||
|
let mut theirs = notebook.pages[index].clone();
|
||||||
|
theirs.add_stroke(stroke(7, 300.0));
|
||||||
|
write_from_another_device(dir.path(), &theirs);
|
||||||
|
|
||||||
|
notebook.pages[index].add_stroke(stroke(8, 400.0));
|
||||||
|
storage::save_page(dir.path(), &PageData::from(¬ebook.pages[index])).unwrap();
|
||||||
|
assert_eq!(conflict_copies(dir.path()).len(), 1);
|
||||||
|
|
||||||
|
// A file never read here but with the content to be saved is no conflict
|
||||||
|
let index = notebook.add_page(PageBackground::Blank);
|
||||||
|
notebook.pages[index].add_stroke(stroke(9, 300.0));
|
||||||
|
write_from_another_device(dir.path(), ¬ebook.pages[index]);
|
||||||
|
storage::save_page(dir.path(), &PageData::from(¬ebook.pages[index])).unwrap();
|
||||||
|
assert_eq!(conflict_copies(dir.path()).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[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();
|
||||||
|
|||||||
+86
-10
@@ -721,21 +721,96 @@ pub fn quarantine_corrupted_page(notebook_dir: &Path, page_id: u64) -> Result<Pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Speichert eine Seite atomar in `page_<id>.json`.
|
/// Speichert eine Seite atomar in `page_<id>.json`.
|
||||||
|
///
|
||||||
|
/// The file may have been changed behind our back since this process last read or wrote it:
|
||||||
|
/// by the built-in sync or a sync client that brought a change from another device, before
|
||||||
|
/// `disk_watch` got to take it over (it waits while something is being drawn or is unsaved).
|
||||||
|
/// Overwriting it would silently drop that change. So, as the sync does for a page changed on
|
||||||
|
/// both sides, their version is kept as a conflict copy next to the page (`disk_watch` turns
|
||||||
|
/// it into a page of its own); if we have nothing new ourselves, their version simply stays.
|
||||||
pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> {
|
pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> {
|
||||||
let path = notebook_dir.join(format!("page_{}.json", page.id));
|
let path = notebook_dir.join(format!("page_{}.json", page.id));
|
||||||
if path.exists() {
|
let bytes = serde_json::to_vec(page)?;
|
||||||
let bak_path = notebook_dir.join(format!("page_{}.json.v0.bak", page.id));
|
let ours = file_hash(&bytes);
|
||||||
if !bak_path.exists() {
|
let key = seen_key(&path);
|
||||||
if let Ok(content) = fs::read_to_string(&path) {
|
|
||||||
if let Ok(old_p) = serde_json::from_str::<PageData>(&content) {
|
if let Ok(theirs) = fs::read(&path) {
|
||||||
if old_p.format_version == 0 {
|
let seen = seen_files().get(&key).copied();
|
||||||
let _ = fs::copy(&path, &bak_path);
|
let their_page = serde_json::from_slice::<PageData>(&theirs).ok();
|
||||||
}
|
if seen != Some(file_hash(&theirs)) && theirs != bytes {
|
||||||
}
|
if seen == Some(ours) {
|
||||||
|
// Nothing changed here since we last read or wrote the page
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let differs = their_page.as_ref().is_some_and(|their_page| {
|
||||||
|
let mut their_page = their_page.clone();
|
||||||
|
their_page.format_version = page.format_version;
|
||||||
|
their_page != *page
|
||||||
|
});
|
||||||
|
if differs {
|
||||||
|
// Fails rather than overwrites: the caller tries again
|
||||||
|
write_conflict_copy(notebook_dir, page.id, &theirs)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let bak_path = notebook_dir.join(format!("page_{}.json.v0.bak", page.id));
|
||||||
|
if !bak_path.exists() && their_page.is_some_and(|p| p.format_version == 0) {
|
||||||
|
let _ = fs::copy(&path, &bak_path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
atomic_write_json(&path, page)
|
atomic_write_file(&path, &bytes)?;
|
||||||
|
seen_files().insert(key, ours);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileHash = [u8; 32];
|
||||||
|
|
||||||
|
fn file_hash(bytes: &[u8]) -> FileHash {
|
||||||
|
use sha2::Digest;
|
||||||
|
sha2::Sha256::digest(bytes).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content of the page files as this process last read or wrote them (see [`save_page`]).
|
||||||
|
fn seen_files() -> std::sync::MutexGuard<'static, std::collections::HashMap<PathBuf, FileHash>> {
|
||||||
|
static SEEN: std::sync::OnceLock<
|
||||||
|
std::sync::Mutex<std::collections::HashMap<PathBuf, FileHash>>,
|
||||||
|
> = std::sync::OnceLock::new();
|
||||||
|
SEEN.get_or_init(Default::default)
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same file must get the same key however its folder is spelled.
|
||||||
|
fn seen_key(path: &Path) -> PathBuf {
|
||||||
|
match (path.parent().map(Path::canonicalize), path.file_name()) {
|
||||||
|
(Some(Ok(dir)), Some(name)) => dir.join(name),
|
||||||
|
_ => path.to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps `content` as `page_<id> (conflict <unix time>).json`, the name the sync uses too.
|
||||||
|
fn write_conflict_copy(notebook_dir: &Path, page_id: u64, content: &[u8]) -> io::Result<PathBuf> {
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let mut n = 0;
|
||||||
|
let path = loop {
|
||||||
|
let name = match n {
|
||||||
|
0 => format!("page_{page_id} (conflict {ts}).json"),
|
||||||
|
n => format!("page_{page_id} (conflict {ts}-{n}).json"),
|
||||||
|
};
|
||||||
|
let path = notebook_dir.join(name);
|
||||||
|
if !path.exists() {
|
||||||
|
break path;
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
};
|
||||||
|
atomic_write_file(&path, content)?;
|
||||||
|
eprintln!(
|
||||||
|
"[Storage] Seite {page_id} wurde außerhalb geändert, Konfliktkopie: {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lädt eine Seite aus `page_<id>.json`.
|
/// Lädt eine Seite aus `page_<id>.json`.
|
||||||
@@ -767,6 +842,7 @@ pub fn load_page(notebook_dir: &Path, page_id: u64) -> Result<PageData, StorageE
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seen_files().insert(seen_key(&path), file_hash(json_str.as_bytes()));
|
||||||
Ok(page_data)
|
Ok(page_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user