From 9b34e304757038c2b59c4c45deb3c556b8aa29ec Mon Sep 17 00:00:00 2001 From: Harald Date: Tue, 27 Oct 2026 13:09:39 +0100 Subject: [PATCH] feat: sync notebooks between devices via a synced folder (Issue #49, stage 1) - Storage location setting: the notebooks can be moved into a folder kept in sync by Nextcloud, Google Drive, Syncthing etc. (subfolder `ink-notes`); existing notebooks are copied there if it is still empty, the originals stay. - Changes from other devices are taken over while everything local is saved: changed pages are reloaded, new/removed/reordered pages and titles come in through the manifest. A page file still being synced is retried later instead of being set aside as damaged. - Conflict copies made by sync clients become pages of their own. - `.opened_by.json` marks the device that has a notebook open; opening a notebook open elsewhere shows a warning. Co-Authored-By: Claude Opus 5.5 --- README.de.md | 4 +- README.md | 4 +- docs/FILE_FORMAT.de.md | 9 + docs/FILE_FORMAT.md | 9 + src/app.rs | 147 ++++++++++- src/disk_watch.rs | 555 +++++++++++++++++++++++++++++++++++++++++ src/file_picker.rs | 5 + src/i18n.rs | 1 + src/i18n/en.rs | 20 ++ src/lib.rs | 1 + src/settings.rs | 5 + src/storage.rs | 156 +++++++++++- src/ui.rs | 69 ++++- 13 files changed, 976 insertions(+), 9 deletions(-) create mode 100644 src/disk_watch.rs diff --git a/README.de.md b/README.de.md index b26348a..c7de3b7 100644 --- a/README.de.md +++ b/README.de.md @@ -68,6 +68,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. - **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. +- **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. ### Export - **PDF** (`Strg + E`) nach `~/Documents/.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen. @@ -178,7 +179,8 @@ src/ ├── saber.rs # Import von Saber-Notizbüchern ├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen ├── history.rs # Rückgängig/Wiederholen -├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb +├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort +├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients) ├── library.rs # Ordner der Notizbücher (library.json) ├── autosave.rs # Speichern im Hintergrund ├── export.rs # PDF-Export (printpdf) diff --git a/README.md b/README.md index a2bb72e..47d6182 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,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. - **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. +- **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. ### Export - **PDF** (`Ctrl + E`) to `~/Documents/.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks. @@ -180,7 +181,8 @@ src/ ├── saber.rs # Import of Saber notebooks ├── document.rs # Data model: notebook, pages, strokes, page sizes ├── history.rs # Undo/redo -├── storage.rs # Loading/saving (atomic), notebooks, trash +├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location +├── disk_watch.rs # Taking over changes from other devices (sync clients) ├── library.rs # Notebook folders (library.json) ├── autosave.rs # Saving in the background ├── export.rs # PDF export (printpdf) diff --git a/docs/FILE_FORMAT.de.md b/docs/FILE_FORMAT.de.md index 35c5a5d..703534d 100644 --- a/docs/FILE_FORMAT.de.md +++ b/docs/FILE_FORMAT.de.md @@ -21,6 +21,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die │ │ ├── assets/ eingefügte Bilder und importierte PDFs │ │ │ ├── <sha256>.png │ │ │ └── <sha256>.pdf +│ │ ├── .opened_by.json Gerät, auf dem das Notizbuch geöffnet ist (siehe unten) │ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist │ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>) └── recovery/ Notfall-Sicherungen ungespeicherter Seiten @@ -28,6 +29,8 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die ~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) Einstellungen der Oberfläche ``` +Der Ordner `notebooks/` lässt sich in den Einstellungen verlegen (`storage_dir` in `ui.json`), z. B. nach `~/Nextcloud/ink-notes`; `recovery/` bleibt, wo es ist. + - **Notizbuch-IDs** werden aus dem Titel und dem Zeitpunkt gebildet (z. B. `mathematik_1775460000`); das erste Notizbuch heißt `default`. - **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert. - Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt. @@ -167,6 +170,12 @@ Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder ### Gelöschte Seiten Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu. +### Abgleich zwischen Geräten +Liegen die Notizbücher in einem Ordner, den Nextcloud, Google Drive, Syncthing o. Ä. abgleichen: +- `.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. +- 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. + ### 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. diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md index 9fc49bf..56c687f 100644 --- a/docs/FILE_FORMAT.md +++ b/docs/FILE_FORMAT.md @@ -21,6 +21,7 @@ This document describes where and how ink-notes stores notebooks, and the versio │ │ ├── assets/ inserted images and imported PDFs │ │ │ ├── <sha256>.png │ │ │ └── <sha256>.pdf +│ │ ├── .opened_by.json device that has the notebook open (see below) │ │ └── .lock lock file while the notebook is open │ └── .trash/ deleted notebooks (<id>_<timestamp>) └── recovery/ emergency backups of unsaved pages @@ -28,6 +29,8 @@ This document describes where and how ink-notes stores notebooks, and the versio ~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) interface settings ``` +The folder `notebooks/` can be moved elsewhere in the settings (`storage_dir` in `ui.json`), e.g. to `~/Nextcloud/ink-notes`; `recovery/` stays where it is. + - **Notebook IDs** are built from the title and the time (e.g. `mathematik_1775460000`); the first notebook is called `default`. - **Assets** are named after the SHA-256 hash of their content; the same image is stored only once. Images are stored as PNG or (unchanged) JPEG, PDFs unchanged. - All files are created with permissions `0600`, folders with `0700`. @@ -167,6 +170,12 @@ A crash while saving therefore leaves either the old or the new file, never a ha ### Deleted pages A deleted page disappears from `manifest.json`; its file `page_<id>.json` stays in the folder. Page IDs are never reused (`next_page_id`), so the file cannot collide with a new page. If the page is restored in the same session, the app writes it again. +### Syncing between devices +If the notebooks are kept in a folder synced by Nextcloud, Google Drive, Syncthing or similar: +- `.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. +- 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`. + ### 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. diff --git a/src/app.rs b/src/app.rs index 24cddf5..1c8b5ea 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,6 +22,7 @@ use crate::assets::{AssetStore, ImportedImage}; use crate::autosave::{AutosaveController, AutosaveStatus}; use crate::background::PatternColor; use crate::canvas::CanvasState; +use crate::disk_watch::DiskWatch; use crate::document::{Notebook, PageBackground}; use crate::export::{self, export_notebook_to_pdf, ExportStrategy}; use crate::geometry::{self, RecognizedShape}; @@ -214,6 +215,16 @@ fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_st } } +/// Warns when the notebook is still marked as open on another device (Issue #49). +fn warn_if_open_elsewhere(lock: Option<&storage::NotebookLock>, ui_state: &mut UiState) { + if let Some(marker) = lock.and_then(|l| l.open_elsewhere.as_ref()) { + ui_state.set_export_message(t!( + "⚠ Auch auf '{device}' geöffnet – Änderungen dort können deine überschreiben", + device = marker.device + )); + } +} + fn update_window_title( window: &Window, notebook: &Notebook, @@ -1308,7 +1319,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { let mut ime_composing = false; // 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung) - let storage_base_dir = storage::default_storage_dir(); + let mut storage_base_dir = ui_settings + .storage_dir + .clone() + .unwrap_or_else(storage::default_storage_dir); let _ = storage::cleanup_trash( &storage_base_dir, std::time::Duration::from_secs(30 * 24 * 3600), @@ -1408,6 +1422,8 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { ui_state.document_title = manifest.title.clone(); ui_state.available_notebooks = available_notebooks; ui_state.library = Library::load(&storage_base_dir); + ui_state.storage_dir = storage_base_dir.clone(); + ui_state.storage_dir_custom = ui_settings.storage_dir.is_some(); if let Some(warning) = startup_warning { refresh_notebooks(&storage_base_dir, &mut ui_state); ui_state.set_export_message(warning); @@ -1426,6 +1442,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) { Ok(lock) => { autosave.set_read_only(false); + warn_if_open_elsewhere(Some(&lock), &mut ui_state); Some(lock) } Err(storage::StorageError::AlreadyLocked(msg)) => { @@ -1441,6 +1458,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { } }; + // Notices changes made on other devices through a sync client (Issue #49) + let mut disk_watch = DiskWatch::new(storage_dir.clone(), Instant::now()); + // Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors); canvas.set_pattern_color(ui_state.pattern_color); @@ -1616,6 +1636,50 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, new_status); } + // Take over changes from other devices (Issue #49), but only while everything + // local is saved and nothing is being drawn or typed + if disk_watch.dir != storage_dir { + disk_watch = DiskWatch::new(storage_dir.clone(), Instant::now()); + } + let now = Instant::now(); + if disk_watch.is_due(now) { + let idle = new_status == AutosaveStatus::Saved + && !is_drawing + && !is_stylus_drawing + && ui_state.text_edit.is_none(); + if !idle { + disk_watch.postpone(now); + } else { + let outcome = disk_watch.check(&mut notebook, &mut manifest, now); + if !outcome.is_empty() { + if outcome.reloaded_pages > 0 || outcome.structure_changed || !outcome.conflict_pages.is_empty() { + selection_state.clear(); + canvas.sync_page(notebook.current_page_mut()); + ui_state.document_title = manifest.title.clone(); + ui_state.set_export_message(t!("Änderungen von einem anderen Gerät übernommen").to_string()); + } + if !outcome.conflict_pages.is_empty() { + for page in notebook.pages.iter().filter(|p| outcome.conflict_pages.contains(&p.id)) { + autosave.mark_page_dirty(page, &manifest); + } + autosave.mark_manifest_dirty(&manifest); + ui_state.set_export_message(t!( + "Konfliktkopien als eigene Seiten übernommen: {count}", + count = outcome.conflict_pages.len() + )); + } + if let Some(marker) = &outcome.opened_elsewhere { + ui_state.set_export_message(t!( + "⚠ Gerade auf '{device}' geöffnet – Änderungen dort können deine überschreiben", + device = marker.device + )); + } + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + window.request_redraw(); + } + } + } + while let Ok(result) = export_rx.try_recv() { match result { Ok(path) => ui_state.set_export_message(t!("Gespeichert: {path}", path = path.display())), @@ -2149,7 +2213,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { || fling.is_active() { target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8))); - } else if let Some(at) = [egui_repaint_at, long_press.deadline()] + } else if let Some(at) = [egui_repaint_at, long_press.deadline(), Some(disk_watch.deadline())] .into_iter() .flatten() .min() @@ -2234,6 +2298,12 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { window.request_redraw(); } + WindowEvent::Focused(true) => { + // Coming back from another app: look for changes right away + disk_watch.check_soon(Instant::now()); + window.request_redraw(); + } + WindowEvent::Focused(false) => { // Bei Fokusverlust Strich abbrechen und 1€-Filter resetten if is_drawing { @@ -3828,6 +3898,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { if _notebook_lock.is_some() { ui_state.set_export_message(t!("Dokument '{title}' geöffnet", title = manifest.title)); } + warn_if_open_elsewhere(_notebook_lock.as_ref(), &mut ui_state); window.request_redraw(); } Err(err) => { @@ -3991,6 +4062,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); ui_state.set_export_message(t!("Gewechselt zu '{title}'", title = manifest.title)); + warn_if_open_elsewhere(_notebook_lock.as_ref(), &mut ui_state); } Err(err) => { eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id); @@ -4127,6 +4199,77 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> { save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); window.request_redraw(); } + UiAction::SetStorageDir(chosen) => { + let new_base = match &chosen { + Some(dir) => storage::storage_dir_in(dir), + None => storage::default_storage_dir(), + }; + if new_base == storage_base_dir { + ui_state.set_export_message(t!("Die Notizbücher liegen bereits dort").to_string()); + } else { + let _ = autosave.flush(); + match storage::copy_storage(&storage_base_dir, &new_base) { + Ok(copied) => { + // Release lock and open marker in the old location + _notebook_lock = None; + storage_base_dir = new_base; + ui_settings.storage_dir = chosen.map(|_| storage_base_dir.clone()); + save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state); + ui_state.storage_dir = storage_base_dir.clone(); + ui_state.storage_dir_custom = ui_settings.storage_dir.is_some(); + refresh_notebooks(&storage_base_dir, &mut ui_state); + // Same notebook if it exists there, otherwise the first one + let open_id = ui_state + .available_notebooks + .iter() + .filter(|s| s.status == storage::NotebookStatus::Normal) + .find(|s| s.id == manifest.id) + .or_else(|| ui_state.available_notebooks.iter().find(|s| s.status == storage::NotebookStatus::Normal)) + .map(|s| s.id.clone()); + let loaded = match open_id { + Some(id) => { + let dir = storage_base_dir.join(&id); + storage::load_notebook(&dir).map(|(nb, m)| (nb, m, dir)) + } + None => storage::create_new_notebook(&storage_base_dir, t!("Standard-Notizbuch")), + }; + match loaded { + Ok((new_nb, new_manifest, new_dir)) => { + _notebook_lock = storage::NotebookLock::acquire(&new_dir).ok(); + autosave.set_read_only(_notebook_lock.is_none()); + autosave.switch_notebook_dir(new_dir.clone()); + notebook = new_nb; + manifest = new_manifest; + storage_dir = new_dir; + selection_state.clear(); + ui_state.current_notebook_id = manifest.id.clone(); + ui_state.document_title = manifest.title.clone(); + refresh_notebooks(&storage_base_dir, &mut ui_state); + canvas.sync_page(notebook.current_page_mut()); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + ui_state.set_export_message(if copied > 0 { + t!("Speicherort geändert, {count} Notizbücher kopiert", count = copied) + } else { + t!("Speicherort geändert: {path}", path = storage_base_dir.display()) + }); + warn_if_open_elsewhere(_notebook_lock.as_ref(), &mut ui_state); + } + Err(err) => { + // The old notebook stays open but is saved nowhere + autosave.set_read_only(true); + eprintln!("[Storage] Notizbuch am neuen Speicherort nicht geöffnet: {err}"); + ui_state.set_export_message(t!("Fehler beim Öffnen: {err}", err = err)); + } + } + } + Err(err) => { + eprintln!("[Storage] Speicherort '{}' nicht nutzbar: {err}", new_base.display()); + ui_state.set_export_message(t!("Speicherort nicht nutzbar: {err}", err = err)); + } + } + } + window.request_redraw(); + } UiAction::SetLanguage(language) => { ui_settings.language = language; i18n::set_language(language.unwrap_or_else(Language::system)); diff --git a/src/disk_watch.rs b/src/disk_watch.rs new file mode 100644 index 0000000..5449c2e --- /dev/null +++ b/src/disk_watch.rs @@ -0,0 +1,555 @@ +//! Notices changes that another device made to the open notebook through a sync client +//! such as Nextcloud or Google Drive (Issue #49). +//! +//! Every few seconds the notebook folder is scanned (only file times and sizes). Pages whose +//! file changed are compared with the page in memory and reloaded if they differ; a changed +//! page order, new or removed pages and titles from the manifest are taken over; conflict +//! copies made by sync clients (`page_3 (conflicted copy …).json`, `page_3 (1).json`, +//! `page_3.sync-conflict-….json`) become pages of their own, so nothing is lost. The check +//! only runs while all local changes are saved, so it never overwrites anything unsaved. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; + +use crate::document::{Notebook, Page, PageBackground}; +use crate::storage::{self, NotebookManifest, OpenMarker, PageData}; + +/// Time between two checks. +pub const INTERVAL: Duration = Duration::from_secs(3); + +type Fingerprint = (Option<SystemTime>, u64); + +/// What a check found and took over. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Outcome { + /// Pages reloaded because their file changed + pub reloaded_pages: usize, + /// Pages added, removed, reordered or retitled through the manifest + pub structure_changed: bool, + /// Ids of the conflict copies added as pages (they need saving) + pub conflict_pages: Vec<u64>, + /// Another device has just opened the notebook + pub opened_elsewhere: Option<OpenMarker>, +} + +impl Outcome { + pub fn is_empty(&self) -> bool { + *self == Outcome::default() + } +} + +pub struct DiskWatch { + pub dir: PathBuf, + known: HashMap<String, Fingerprint>, + /// Files that are neither pages nor conflict copies we could read + ignored: HashSet<String>, + next_check: Instant, +} + +/// Fingerprints of the JSON files in the notebook folder. +fn scan(dir: &Path) -> HashMap<String, Fingerprint> { + let Ok(entries) = fs::read_dir(dir) else { + return HashMap::new(); + }; + entries + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.ends_with(".json") { + return None; + } + let meta = entry.metadata().ok()?; + meta.is_file() + .then(|| (name, (meta.modified().ok(), meta.len()))) + }) + .collect() +} + +/// Page id of a regular page file (`page_<id>.json`). +fn page_file_id(name: &str) -> Option<u64> { + name.strip_prefix("page_")? + .strip_suffix(".json")? + .parse() + .ok() +} + +/// Page id a conflict copy belongs to: `page_<id>` followed by something else. +pub fn conflict_copy_of(name: &str) -> Option<u64> { + if page_file_id(name).is_some() { + return None; + } + let rest = name.strip_prefix("page_")?.strip_suffix(".json")?; + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + let tail = &rest[digits.len()..]; + let marked = + tail.starts_with(" (") || tail.starts_with(".sync-conflict") || tail.starts_with('_'); + (marked && !digits.is_empty()) + .then(|| digits.parse().ok()) + .flatten() +} + +/// Page content without the format version (that differs between app versions). +fn content(mut data: PageData) -> PageData { + data.format_version = 0; + data +} + +impl DiskWatch { + pub fn new(dir: PathBuf, now: Instant) -> Self { + Self { + known: scan(&dir), + dir, + ignored: HashSet::new(), + next_check: now + INTERVAL, + } + } + + pub fn deadline(&self) -> Instant { + self.next_check + } + + pub fn is_due(&self, now: Instant) -> bool { + now >= self.next_check + } + + /// Skips the check that is due now (e.g. while drawing). + pub fn postpone(&mut self, now: Instant) { + self.next_check = now + INTERVAL; + } + + /// Checks right away at the next opportunity (e.g. when the window gets the focus). + pub fn check_soon(&mut self, now: Instant) { + self.next_check = now; + } + + /// Takes over what changed on disk. Call only while all local changes are saved. + pub fn check( + &mut self, + notebook: &mut Notebook, + manifest: &mut NotebookManifest, + now: Instant, + ) -> Outcome { + self.next_check = now + INTERVAL; + let mut outcome = Outcome::default(); + let current = scan(&self.dir); + let changed = |name: &str| current.get(name) != self.known.get(name); + let mut retry: HashSet<String> = HashSet::new(); + + if changed(storage::OPEN_MARKER_FILE) { + outcome.opened_elsewhere = fs::read(self.dir.join(storage::OPEN_MARKER_FILE)) + .ok() + .and_then(|b| serde_json::from_slice::<OpenMarker>(&b).ok()) + .filter(|m| m.device != storage::device_name()); + } + + // Page order, titles, new and removed pages + let mut fresh_pages: HashSet<u64> = HashSet::new(); + if changed("manifest.json") { + match storage::load_manifest(&self.dir) { + Ok(theirs) => match self.apply_manifest(notebook, manifest, theirs) { + Ok(Some(new_ids)) => { + fresh_pages = new_ids; + outcome.structure_changed = true; + } + Ok(None) => {} + Err(()) => { + retry.insert("manifest.json".to_string()); + } + }, + Err(_) => { + retry.insert("manifest.json".to_string()); + } + } + } + + // Changed page files + for name in current.keys().filter(|n| changed(n)) { + let Some(id) = page_file_id(name) else { + continue; + }; + let Some(page) = notebook.pages.iter_mut().find(|p| p.id == id) else { + continue; + }; + if !page.is_loaded || fresh_pages.contains(&id) { + continue; + } + match storage::load_page(&self.dir, id) { + Ok(data) => { + if content(PageData::from(&*page)) != content(data.clone()) { + let mut reloaded = data.to_page(); + reloaded.title = reloaded.title.or_else(|| page.title.clone()); + *page = reloaded; + outcome.reloaded_pages += 1; + } + } + // Possibly still being written by the sync client: try again next time + Err(_) => { + retry.insert(name.clone()); + } + } + } + + // Conflict copies of sync clients become pages of their own + let mut copies: Vec<&String> = current + .keys() + .filter(|n| !self.ignored.contains(*n)) + .filter(|n| conflict_copy_of(n).is_some()) + .collect(); + copies.sort(); + for name in copies { + let path = self.dir.join(name); + let data = fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice::<PageData>(&b).ok()); + match data { + Some(data) => { + let of = conflict_copy_of(name).unwrap_or(0); + outcome + .conflict_pages + .push(add_conflict_page(notebook, data, of)); + // Keep the file, but don't take it over again + let _ = fs::rename(&path, path.with_extension("json.imported")); + } + None => { + self.ignored.insert(name.clone()); + } + } + } + if !outcome.conflict_pages.is_empty() { + manifest.sync_from_notebook(notebook); + } + + // Remember what we have seen, except files to look at again + let mut known = current; + for name in retry { + match self.known.get(&name) { + Some(old) => known.insert(name, *old), + None => known.remove(&name), + }; + } + self.known = known; + outcome + } + + /// Takes over the page list of a manifest written elsewhere. Returns the ids of pages + /// that are now unloaded placeholders, or `None` if nothing about the pages changed. + /// Fails (to be tried again) while the page to show can't be read yet. + fn apply_manifest( + &self, + notebook: &mut Notebook, + manifest: &mut NotebookManifest, + theirs: NotebookManifest, + ) -> Result<Option<HashSet<u64>>, ()> { + let mut ours = manifest.clone(); + ours.sync_from_notebook(notebook); + if ours.pages == theirs.pages && ours.title == theirs.title { + return Ok(None); + } + let current_id = notebook.current_page().id; + + // The page shown afterwards must be loaded. Read it before changing anything: a file + // the sync client is still writing must not end up set aside as damaged. + let shown = theirs + .pages + .iter() + .find(|m| m.id == current_id) + .or(theirs.pages.first()) + .map(|m| m.id); + let mut shown_data = None; + if let Some(id) = shown { + if !notebook.pages.iter().any(|p| p.id == id && p.is_loaded) { + match storage::load_page(&self.dir, id) { + Ok(data) => shown_data = Some(data), + // Never saved because still empty + Err(storage::StorageError::PageNotFound(_)) => {} + Err(_) => return Err(()), + } + } + } + + let mut old: HashMap<u64, Page> = notebook.pages.drain(..).map(|p| (p.id, p)).collect(); + let mut placeholders = HashSet::new(); + for meta in &theirs.pages { + let background = meta.background.unwrap_or(PageBackground::Grid5mm); + let size = storage::page_size_from(meta.size); + let mut page = match old.remove(&meta.id) { + Some(mut page) => { + page.background = background; + page.size = size; + page + } + None => { + placeholders.insert(meta.id); + Page::new_unloaded(meta.id, background).with_size(size) + } + }; + page.title = meta.title.clone(); + notebook.pages.push(page); + } + if notebook.pages.is_empty() { + notebook.pages.push(Page::new(1, PageBackground::Grid5mm)); + } + notebook.current_page_index = notebook + .pages + .iter() + .position(|p| p.id == current_id) + .unwrap_or(0) + .min(notebook.pages.len() - 1); + notebook.next_page_id = notebook.next_page_id.max(theirs.next_page_id); + notebook.next_stroke_id = notebook.next_stroke_id.max(theirs.next_stroke_id); + let index = notebook.current_page_index; + if !notebook.pages[index].is_loaded { + let page = &mut notebook.pages[index]; + let mut loaded = match shown_data { + Some(data) => data.to_page(), + None => Page::new(page.id, page.background).with_size(page.size), + }; + loaded.title = loaded.title.or(page.title.take()); + *page = loaded; + placeholders.remove(&page.id); + } + let layout = manifest.layout; + *manifest = theirs; + manifest.layout = manifest.layout.or(layout); + manifest.sync_from_notebook(notebook); + Ok(Some(placeholders)) + } +} + +/// Adds a conflict copy as a new page after page `of` (or at the end), with fresh ids. +/// Returns the id of the new page. +fn add_conflict_page(notebook: &mut Notebook, mut data: PageData, of: u64) -> u64 { + let id = notebook.next_page_id; + data.id = id; + notebook.next_page_id += 1; + for stroke in &mut data.strokes { + stroke.id = notebook.next_stroke_id; + notebook.next_stroke_id += 1; + } + let original = notebook.pages.iter().position(|p| p.id == of); + let title = original + .and_then(|i| notebook.pages[i].title.clone()) + .or(data.title.take()); + data.title = Some(match title { + Some(title) => crate::t!("Konfliktkopie: {title}", title = title).to_string(), + None => crate::t!("Konfliktkopie").to_string(), + }); + let page = data.to_page(); + let at = original.map_or(notebook.pages.len(), |i| i + 1); + notebook.pages.insert(at, page); + if at <= notebook.current_page_index { + notebook.current_page_index += 1; + } + id +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::{Stroke, StylusSample}; + + fn stroke(id: u64, y: f32) -> Stroke { + let points = (0..5) + .map(|i| StylusSample { + position: glam::Vec2::new(100.0 + 50.0 * i as f32, y), + pressure: 0.5, + timestamp: 0.0, + }) + .collect(); + Stroke::new(id, points, Vec::new(), [0.0, 0.0, 0.0, 1.0]) + } + + /// Saved notebook with one stroke on its only page. + fn saved_notebook(dir: &Path) -> (Notebook, NotebookManifest) { + let mut notebook = Notebook::new(); + notebook.current_page_mut().add_stroke(stroke(1, 100.0)); + notebook.next_stroke_id = 2; + let mut manifest = NotebookManifest::new("nb", "Mathe"); + storage::save_notebook(dir, ¬ebook, &mut manifest).unwrap(); + (notebook, manifest) + } + + #[test] + fn test_conflict_copy_names() { + assert_eq!( + conflict_copy_of("page_3 (conflicted copy 2026-10-27).json"), + Some(3) + ); + assert_eq!(conflict_copy_of("page_12 (1).json"), Some(12)); + assert_eq!( + conflict_copy_of("page_3.sync-conflict-20261027-1200-ABC.json"), + Some(3) + ); + assert_eq!(conflict_copy_of("page_3_conflict.json"), Some(3)); + assert_eq!(conflict_copy_of("page_3.json"), None); + assert_eq!(conflict_copy_of("page_x (1).json"), None); + assert_eq!(conflict_copy_of("manifest (1).json"), None); + assert_eq!(conflict_copy_of("page_3.json.imported"), None); + } + + #[test] + fn test_own_saves_are_not_reported() { + 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); + + notebook.current_page_mut().add_stroke(stroke(2, 200.0)); + notebook.add_page(PageBackground::Blank); + storage::save_notebook(dir.path(), ¬ebook, &mut manifest).unwrap(); + + let outcome = watch.check(&mut notebook, &mut manifest, now); + assert!(outcome.is_empty(), "{outcome:?}"); + assert_eq!(notebook.pages.len(), 2); + } + + #[test] + fn test_changes_from_another_device_are_taken_over() { + 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); + assert!(!watch.is_due(now)); + + // The other device adds a stroke on page 1 and a second page with a title + let (mut theirs, mut their_manifest) = storage::load_notebook(dir.path()).unwrap(); + theirs.current_page_mut().add_stroke(stroke(5, 300.0)); + let index = theirs.add_page(PageBackground::Lined); + theirs.pages[index].add_stroke(stroke(6, 400.0)); + theirs.pages[index].title = Some("Übung".to_string()); + theirs.next_stroke_id = 7; + storage::save_notebook(dir.path(), &theirs, &mut their_manifest).unwrap(); + + let outcome = watch.check(&mut notebook, &mut manifest, now); + assert_eq!(outcome.reloaded_pages, 1); + assert!(outcome.structure_changed); + assert!(outcome.conflict_pages.is_empty()); + assert_eq!(notebook.pages.len(), 2); + assert_eq!(notebook.pages[0].strokes.len(), 2); + assert_eq!(notebook.pages[1].title.as_deref(), Some("Übung")); + assert_eq!(notebook.pages[1].background, PageBackground::Lined); + assert!(notebook.next_stroke_id >= 7); + assert_eq!(manifest.pages.len(), 2); + + // The new page loads from disk when needed + storage::ensure_page_loaded(dir.path(), &mut notebook, 1).unwrap(); + assert_eq!(notebook.pages[1].strokes.len(), 1); + + // Nothing new: nothing to report + let outcome = watch.check(&mut notebook, &mut manifest, now); + assert!(outcome.is_empty(), "{outcome:?}"); + } + + #[test] + fn test_page_still_being_synced_is_tried_again() { + 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); + + // Elsewhere page 1 was deleted and page 2 added; its file is still half written + let (mut theirs, mut their_manifest) = storage::load_notebook(dir.path()).unwrap(); + let index = theirs.add_page(PageBackground::Blank); + theirs.pages[index].add_stroke(stroke(9, 100.0)); + storage::save_notebook(dir.path(), &theirs, &mut their_manifest).unwrap(); + their_manifest.pages.remove(0); + storage::save_manifest(dir.path(), &their_manifest).unwrap(); + let page_file = dir.path().join("page_2.json"); + let complete = fs::read(&page_file).unwrap(); + fs::write(&page_file, &complete[..complete.len() / 2]).unwrap(); + + let outcome = watch.check(&mut notebook, &mut manifest, now); + assert!(outcome.is_empty(), "{outcome:?}"); + assert_eq!(notebook.pages.len(), 1); + assert_eq!(notebook.pages[0].id, 1); + assert!(page_file.exists(), "must not be set aside as damaged"); + + fs::write(&page_file, &complete).unwrap(); + let outcome = watch.check(&mut notebook, &mut manifest, now); + assert!(outcome.structure_changed); + assert_eq!(notebook.pages.len(), 1); + assert_eq!(notebook.current_page().id, 2); + assert!(notebook.current_page().is_loaded); + assert_eq!(notebook.current_page().strokes.len(), 1); + } + + #[test] + fn test_conflict_copy_becomes_a_page() { + let dir = tempfile::tempdir().unwrap(); + let (mut notebook, mut manifest) = saved_notebook(dir.path()); + notebook.add_page(PageBackground::Blank); + notebook.current_page_index = 1; + storage::save_notebook(dir.path(), ¬ebook, &mut manifest).unwrap(); + let now = Instant::now(); + let mut watch = DiskWatch::new(dir.path().to_path_buf(), now); + + let mut copy = PageData::from(¬ebook.pages[0]); + copy.strokes.push( + PageData::from(&{ + let mut page = Page::new(1, PageBackground::Blank); + page.add_stroke(stroke(1, 500.0)); + page + }) + .strokes[0] + .clone(), + ); + let name = "page_1 (conflicted copy 2026-10-27).json"; + fs::write(dir.path().join(name), serde_json::to_vec(©).unwrap()).unwrap(); + fs::write(dir.path().join("page_2 (1).json"), b"{ kaputt").unwrap(); + + let outcome = watch.check(&mut notebook, &mut manifest, now); + assert_eq!(outcome.conflict_pages.len(), 1); + assert_eq!(notebook.pages.len(), 3); + let added = ¬ebook.pages[1]; + assert_eq!(added.id, outcome.conflict_pages[0]); + assert_eq!(added.strokes.len(), 2); + assert!(added.title.is_some()); + // Fresh ids, no clash with the original page + assert!(added.strokes.iter().all(|s| s.id >= 2)); + assert_eq!(notebook.pages[0].strokes.len(), 1); + // The current page stays the same + assert_eq!(notebook.current_page_index, 2); + assert_eq!(manifest.pages.len(), 3); + assert!(!dir.path().join(name).exists()); + assert!(dir.path().join(format!("{name}.imported")).exists()); + + // Taken over only once; the unreadable file is left alone + let outcome = watch.check(&mut notebook, &mut manifest, now); + assert!(outcome.is_empty(), "{outcome:?}"); + assert!(dir.path().join("page_2 (1).json").exists()); + } + + #[test] + fn test_open_marker() { + let dir = tempfile::tempdir().unwrap(); + let (mut notebook, mut manifest) = saved_notebook(dir.path()); + let marker_path = dir.path().join(storage::OPEN_MARKER_FILE); + let foreign = OpenMarker { + device: "tablet-of-pia".to_string(), + since: 1, + }; + fs::write(&marker_path, serde_json::to_vec(&foreign).unwrap()).unwrap(); + + // Opening warns about the other device and puts our own marker in place + let lock = storage::NotebookLock::acquire(dir.path()).unwrap(); + assert_eq!(lock.open_elsewhere.as_ref(), Some(&foreign)); + let ours: OpenMarker = serde_json::from_slice(&fs::read(&marker_path).unwrap()).unwrap(); + assert_eq!(ours.device, storage::device_name()); + + // The other device opens it again + let now = Instant::now(); + let mut watch = DiskWatch::new(dir.path().to_path_buf(), now); + fs::write(&marker_path, serde_json::to_vec(&foreign).unwrap()).unwrap(); + let outcome = watch.check(&mut notebook, &mut manifest, now); + assert_eq!(outcome.opened_elsewhere, Some(foreign)); + + // A foreign marker is not removed on closing + drop(lock); + assert!(marker_path.exists()); + let lock = storage::NotebookLock::acquire(dir.path()).unwrap(); + drop(lock); + assert!(!marker_path.exists()); + } +} diff --git a/src/file_picker.rs b/src/file_picker.rs index 5d7aed6..4271f38 100644 --- a/src/file_picker.rs +++ b/src/file_picker.rs @@ -24,6 +24,8 @@ pub enum PickerKind { Image, /// PDF, Xournal++, Rnote or Saber file to import as a new notebook Import, + /// Folder for the notebooks (Issue #49): lists folders only + Folder, } impl PickerKind { @@ -32,6 +34,7 @@ impl PickerKind { match self { PickerKind::Image => is_supported_image_path(path), PickerKind::Import => crate::import::is_importable_path(path), + PickerKind::Folder => false, } } } @@ -64,6 +67,7 @@ impl FilePicker { let preferred = match kind { PickerKind::Image => tn!("🖼 Bilder"), PickerKind::Import => tn!("⬇ Downloads"), + PickerKind::Folder => tn!("🏠 Persönlicher Ordner"), }; let all = shortcuts(); all.iter() @@ -225,6 +229,7 @@ mod tests { "Uni.sbn2" ] ); + assert_eq!(names(PickerKind::Folder), ["aOrdner", "zOrdner"]); } #[test] diff --git a/src/i18n.rs b/src/i18n.rs index d83c49f..3db237c 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -151,6 +151,7 @@ mod tests { ("assets.rs", include_str!("assets.rs")), ("autosave.rs", include_str!("autosave.rs")), ("background.rs", include_str!("background.rs")), + ("disk_watch.rs", include_str!("disk_watch.rs")), ("document.rs", include_str!("document.rs")), ("export.rs", include_str!("export.rs")), ("file_picker.rs", include_str!("file_picker.rs")), diff --git a/src/i18n/en.rs b/src/i18n/en.rs index 490abe5..75a2cbc 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -464,4 +464,24 @@ pub const EN: &[(&str, &str)] = &[ ("📄 Importieren (PDF, Xournal++, Rnote, Saber) …", "📄 Import (PDF, Xournal++, Rnote, Saber) …"), ("Keine Saber-Datei", "Not a Saber file"), ("Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern", "Saber file too old: please open it in a current Saber and save it again"), + ("Die Notizbücher liegen bereits dort", "The notebooks are already there"), + ("Konfliktkopien als eigene Seiten übernommen: {count}", "Conflict copies added as separate pages: {count}"), + ("Speicherort geändert, {count} Notizbücher kopiert", "Storage location changed, {count} notebooks copied"), + ("Speicherort geändert: {path}", "Storage location changed: {path}"), + ("Speicherort nicht nutzbar: {err}", "Storage location not usable: {err}"), + ("Änderungen von einem anderen Gerät übernommen", "Changes from another device taken over"), + ("⚠ Auch auf '{device}' geöffnet – Änderungen dort können deine überschreiben", "⚠ Also open on '{device}' – changes there may overwrite yours"), + ("⚠ Gerade auf '{device}' geöffnet – Änderungen dort können deine überschreiben", "⚠ Just opened on '{device}' – changes there may overwrite yours"), + ("Konfliktkopie", "Conflict copy"), + ("Konfliktkopie: {title}", "Conflict copy: {title}"), + ("Die Notizbücher liegen dann im Unterordner „ink-notes“ des gewählten Ordners, z. B. im Nextcloud- oder Google-Drive-Ordner. Ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert; die alten bleiben erhalten.", "The notebooks are then kept in the subfolder “ink-notes” of the chosen folder, e.g. in your Nextcloud or Google Drive folder. If it is still empty, the existing notebooks are copied there; the old ones are kept."), + ("Keine Unterordner", "No subfolders"), + ("SPEICHERORT", "STORAGE LOCATION"), + ("Speicherort wählen", "Choose storage location"), + ("Standard", "Default"), + ("Z. B. einen Nextcloud- oder Google-Drive-Ordner wählen, um die Notizbücher mit anderen Geräten abzugleichen", "E.g. choose a Nextcloud or Google Drive folder to sync the notebooks with other devices"), + ("Zurück zum Standard-Speicherort", "Back to the default storage location"), + ("✔ Diesen Ordner wählen", "✔ Choose this folder"), + ("📁 Ändern …", "📁 Change …"), + ("📁 Speicherort wählen", "📁 Choose storage location"), ]; diff --git a/src/lib.rs b/src/lib.rs index a18bfab..bc63c29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod assets; pub mod autosave; pub mod background; pub mod canvas; +pub mod disk_watch; pub mod document; pub mod export; pub mod file_picker; diff --git a/src/settings.rs b/src/settings.rs index 03d2396..baa2f95 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -229,6 +229,9 @@ pub struct UiSettings { pub language: Option<Language>, /// Eraser radius in mm (Issue #38) pub eraser_radius_mm: f32, + /// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset + /// (Issue #49) + pub storage_dir: Option<PathBuf>, } impl Default for UiSettings { @@ -249,6 +252,7 @@ impl Default for UiSettings { stylus_buttons: Vec::new(), language: None, eraser_radius_mm: 3.0, + storage_dir: None, } } } @@ -349,6 +353,7 @@ mod tests { ], language: Some(Language::English), eraser_radius_mm: 6.0, + storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")), }; settings.save_to(&path).unwrap(); assert_eq!(UiSettings::load_from(&path), settings); diff --git a/src/storage.rs b/src/storage.rs index 6f382df..a60c34f 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -939,6 +939,59 @@ pub fn ensure_page_loaded( } } +/// Notebook folder inside a folder the user picked as storage location (Issue #49), e.g. the +/// Nextcloud folder: `<chosen>/ink-notes`, unless the folder already is called `ink-notes`. +pub fn storage_dir_in(chosen: &Path) -> PathBuf { + if chosen.file_name().is_some_and(|n| n == "ink-notes") { + chosen.to_path_buf() + } else { + chosen.join("ink-notes") + } +} + +/// Prepares `to` as new storage location. If it holds no notebooks yet, the notebooks and +/// `library.json` of `from` are copied there (lock files and open markers excluded); the +/// originals stay where they are. Returns the number of copied notebooks. +pub fn copy_storage(from: &Path, to: &Path) -> io::Result<usize> { + create_secure_dir_all(to)?; + if !list_notebooks(to).is_empty() { + return Ok(0); + } + let mut copied = 0; + for summary in list_notebooks(from) { + let target = to.join(&summary.id); + if target.exists() { + continue; + } + copy_dir(&from.join(&summary.id), &target)?; + copied += 1; + } + let library = from.join("library.json"); + if library.is_file() && !to.join("library.json").exists() { + fs::copy(&library, to.join("library.json"))?; + } + Ok(copied) +} + +/// Copies a notebook folder recursively, without lock file and open marker. +fn copy_dir(from: &Path, to: &Path) -> io::Result<()> { + create_secure_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let name = entry.file_name(); + if name == ".lock" || name == OPEN_MARKER_FILE { + continue; + } + let kind = entry.file_type()?; + if kind.is_dir() { + copy_dir(&entry.path(), &to.join(&name))?; + } else if kind.is_file() { + fs::copy(entry.path(), to.join(&name))?; + } + } + Ok(()) +} + /// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation). pub fn default_storage_dir() -> PathBuf { if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { @@ -984,6 +1037,49 @@ pub struct NotebookSummary { pub struct NotebookLock { _file: File, pub path: PathBuf, + /// Another device that has the notebook open according to its marker (Issue #49) + pub open_elsewhere: Option<OpenMarker>, + marker: Option<(PathBuf, OpenMarker)>, +} + +/// Which device has a notebook open, and since when (Unix seconds). The `.lock` file only +/// locks on this computer; this marker (`.opened_by.json`) travels with sync clients such as +/// Nextcloud, so another device can warn before both edit at the same time (Issue #49). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OpenMarker { + pub device: String, + pub since: u64, +} + +/// File name of the [`OpenMarker`] in the notebook folder. +pub const OPEN_MARKER_FILE: &str = ".opened_by.json"; + +/// Name of this device (host name). +pub fn device_name() -> String { + let from_file = |path: &str| fs::read_to_string(path).ok(); + std::env::var("INK_NOTES_DEVICE") + .ok() + .or_else(|| from_file("/proc/sys/kernel/hostname")) + .or_else(|| from_file("/etc/hostname")) + .or_else(|| std::env::var("COMPUTERNAME").ok()) + .or_else(|| std::env::var("HOSTNAME").ok()) + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "?".to_string()) +} + +impl Drop for NotebookLock { + fn drop(&mut self) { + // Remove our marker, unless another device has replaced it meanwhile + if let Some((path, ours)) = &self.marker { + let current = fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice::<OpenMarker>(&b).ok()); + if current.as_ref() == Some(ours) { + let _ = fs::remove_file(path); + } + } + } } impl NotebookLock { @@ -1007,10 +1103,31 @@ impl NotebookLock { use fs2::FileExt; match file.try_lock_exclusive() { - Ok(()) => Ok(Self { - _file: file, - path: lock_path, - }), + Ok(()) => { + let device = device_name(); + let marker_path = notebook_dir.join(OPEN_MARKER_FILE); + let open_elsewhere = fs::read(&marker_path) + .ok() + .and_then(|b| serde_json::from_slice::<OpenMarker>(&b).ok()) + .filter(|m| m.device != device); + let ours = OpenMarker { + device, + since: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + let marker = serde_json::to_vec(&ours) + .ok() + .filter(|json| atomic_write_file(&marker_path, json).is_ok()) + .map(|_| (marker_path, ours)); + Ok(Self { + _file: file, + path: lock_path, + open_elsewhere, + marker, + }) + } Err(e) => { let kind = e.kind(); if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) { @@ -2159,6 +2276,37 @@ mod tests { assert_eq!(damaged_summary.unwrap().status, NotebookStatus::Damaged); } + #[test] + fn test_copy_storage_to_new_location() { + let old = tempfile::tempdir().unwrap(); + let cloud = tempfile::tempdir().unwrap(); + let (notebook, mut manifest, dir) = create_new_notebook(old.path(), "Mathe").unwrap(); + save_notebook(&dir, ¬ebook, &mut manifest).unwrap(); + let _lock = NotebookLock::acquire(&dir).unwrap(); + fs::create_dir_all(dir.join("assets")).unwrap(); + fs::write(dir.join("assets").join("bild.png"), b"png").unwrap(); + fs::write(old.path().join("library.json"), b"{}").unwrap(); + + assert_eq!(storage_dir_in(cloud.path()), cloud.path().join("ink-notes")); + let target = storage_dir_in(cloud.path()); + assert_eq!(storage_dir_in(&target), target); + + assert_eq!(copy_storage(old.path(), &target).unwrap(), 1); + let copied = target.join(&manifest.id); + assert!(load_notebook(&copied).is_ok()); + assert!(copied.join("assets").join("bild.png").exists()); + assert!(target.join("library.json").exists()); + assert!(!copied.join(".lock").exists()); + assert!(!copied.join(OPEN_MARKER_FILE).exists()); + // The originals stay + assert!(load_notebook(&dir).is_ok()); + + // Notebooks already there (e.g. from another device): nothing is copied + create_new_notebook(old.path(), "Physik").unwrap(); + assert_eq!(copy_storage(old.path(), &target).unwrap(), 0); + assert_eq!(list_notebooks(&target).len(), 1); + } + #[test] fn test_notebook_instance_lock_prevents_concurrent_open() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/src/ui.rs b/src/ui.rs index 6e4527b..0c29fc6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -339,6 +339,8 @@ pub enum UiAction { InsertImageFile(std::path::PathBuf), /// PDF als neues Notizbuch importieren ImportFile(std::path::PathBuf), + /// Store the notebooks in this folder, `None` for the default location (Issue #49) + SetStorageDir(Option<std::path::PathBuf>), /// Aktuelle Seite als Bild oder SVG exportieren ExportPage(PageExportKind), /// Titel einer Seite setzen (leer = entfernen) @@ -503,6 +505,9 @@ pub struct UiState { pub queued_actions: Vec<UiAction>, /// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung) pub last_picker_dir: Option<std::path::PathBuf>, + /// Folder holding the notebooks and whether the user picked it (Issue #49) + pub storage_dir: std::path::PathBuf, + pub storage_dir_custom: bool, } impl Default for UiState { @@ -588,6 +593,8 @@ impl Default for UiState { page_title_for: None, page_picker_list: false, last_picker_dir: None, + storage_dir: std::path::PathBuf::new(), + storage_dir_custom: false, } } } @@ -674,6 +681,12 @@ impl UiState { self.open_file_picker(PickerKind::Import); } + /// Opens the folder picker for the storage location in the home folder. + pub fn open_storage_picker(&mut self) { + self.close_overlays(); + self.file_picker = Some(FilePicker::open(PickerKind::Folder, None)); + } + fn open_file_picker(&mut self, kind: PickerKind) { let dir = self.last_picker_dir.clone(); self.close_overlays(); @@ -2839,6 +2852,17 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve Texte und Bilder werden übernommen, bei Xournal++ und Saber auch PDF-Hintergründe." ), ), + PickerKind::Folder => ( + t!("Speicherort wählen"), + t!("📁 Speicherort wählen"), + "📁", + t!("Keine Unterordner"), + t!( + "Die Notizbücher liegen dann im Unterordner „ink-notes“ des gewählten Ordners, \ + z. B. im Nextcloud- oder Google-Drive-Ordner. Ist er noch leer, werden die \ + vorhandenen Notizbücher dorthin kopiert; die alten bleiben erhalten." + ), + ), }; dialog_window(title, state, false).show(ctx, |ui| { ui.set_width(460.0); @@ -2877,6 +2901,16 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve if let Some(err) = &picker.error { ui.colored_label(egui::Color32::from_rgb(255, 120, 120), err); } + if picker_kind == PickerKind::Folder + && ui + .add_sized( + [ui.available_width(), 30.0], + egui::Button::new(t!("✔ Diesen Ordner wählen")), + ) + .clicked() + { + chosen = Some(picker.dir.clone()); + } ui.add_space(4.0); egui::ScrollArea::vertical() @@ -2923,10 +2957,13 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve } } if let Some(path) = chosen { - state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone()); + if picker_kind != PickerKind::Folder { + state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone()); + } actions.push(match picker_kind { PickerKind::Image => UiAction::InsertImageFile(path), PickerKind::Import => UiAction::ImportFile(path), + PickerKind::Folder => UiAction::SetStorageDir(Some(path)), }); close = true; } @@ -4397,6 +4434,36 @@ pub fn render_ui( } }); + section(ui, t!("SPEICHERORT")); + ui.add( + egui::Label::new( + egui::RichText::new(state.storage_dir.display().to_string()) + .size(11.5) + .color(egui::Color32::from_gray(190)), + ) + .truncate(true), + ) + .on_hover_text(state.storage_dir.display().to_string()); + ui.horizontal(|ui| { + if ui + .add_sized([115.0, 28.0], egui::Button::new(t!("📁 Ändern …"))) + .on_hover_text(t!("Z. B. einen Nextcloud- oder Google-Drive-Ordner wählen, um die Notizbücher mit anderen Geräten abzugleichen")) + .clicked() + { + state.open_storage_picker(); + } + if ui + .add_enabled( + state.storage_dir_custom, + egui::Button::new(t!("Standard")).min_size(egui::vec2(115.0, 28.0)), + ) + .on_hover_text(t!("Zurück zum Standard-Speicherort")) + .clicked() + { + actions.push(UiAction::SetStorageDir(None)); + } + }); + section(ui, t!("STATUS")); let (dot_color, status_text) = autosave_status_style(autosave_status); ui.horizontal(|ui| {