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 <noreply@anthropic.com>
This commit is contained in:
1 parent
41ee32e1f7
commit
9b34e30475
13 files changed
+973
-6
No files matched your search
+3
-1
@@ -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.
|
- 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.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
|
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.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
|
├── saber.rs # Import von Saber-Notizbüchern
|
||||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||||
├── history.rs # Rückgängig/Wiederholen
|
├── 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)
|
├── library.rs # Ordner der Notizbücher (library.json)
|
||||||
├── autosave.rs # Speichern im Hintergrund
|
├── autosave.rs # Speichern im Hintergrund
|
||||||
├── export.rs # PDF-Export (printpdf)
|
├── export.rs # PDF-Export (printpdf)
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
- **PDF** (`Ctrl + E`) to `~/Documents/<title>.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks.
|
- **PDF** (`Ctrl + E`) to `~/Documents/<title>.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
|
├── saber.rs # Import of Saber notebooks
|
||||||
├── document.rs # Data model: notebook, pages, strokes, page sizes
|
├── document.rs # Data model: notebook, pages, strokes, page sizes
|
||||||
├── history.rs # Undo/redo
|
├── 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)
|
├── library.rs # Notebook folders (library.json)
|
||||||
├── autosave.rs # Saving in the background
|
├── autosave.rs # Saving in the background
|
||||||
├── export.rs # PDF export (printpdf)
|
├── export.rs # PDF export (printpdf)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
|||||||
│ │ ├── assets/ eingefügte Bilder und importierte PDFs
|
│ │ ├── assets/ eingefügte Bilder und importierte PDFs
|
||||||
│ │ │ ├── <sha256>.png
|
│ │ │ ├── <sha256>.png
|
||||||
│ │ │ └── <sha256>.pdf
|
│ │ │ └── <sha256>.pdf
|
||||||
|
│ │ ├── .opened_by.json Gerät, auf dem das Notizbuch geöffnet ist (siehe unten)
|
||||||
│ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist
|
│ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist
|
||||||
│ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>)
|
│ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>)
|
||||||
└── recovery/ Notfall-Sicherungen ungespeicherter Seiten
|
└── 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
|
~/.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`.
|
- **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.
|
- **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.
|
- 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
|
### 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.
|
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
|
### 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.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ This document describes where and how ink-notes stores notebooks, and the versio
|
|||||||
│ │ ├── assets/ inserted images and imported PDFs
|
│ │ ├── assets/ inserted images and imported PDFs
|
||||||
│ │ │ ├── <sha256>.png
|
│ │ │ ├── <sha256>.png
|
||||||
│ │ │ └── <sha256>.pdf
|
│ │ │ └── <sha256>.pdf
|
||||||
|
│ │ ├── .opened_by.json device that has the notebook open (see below)
|
||||||
│ │ └── .lock lock file while the notebook is open
|
│ │ └── .lock lock file while the notebook is open
|
||||||
│ └── .trash/ deleted notebooks (<id>_<timestamp>)
|
│ └── .trash/ deleted notebooks (<id>_<timestamp>)
|
||||||
└── recovery/ emergency backups of unsaved pages
|
└── 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
|
~/.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`.
|
- **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.
|
- **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`.
|
- 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
|
### 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.
|
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
|
### 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.
|
||||||
|
|
||||||
|
|||||||
+145
-2
@@ -22,6 +22,7 @@ use crate::assets::{AssetStore, ImportedImage};
|
|||||||
use crate::autosave::{AutosaveController, AutosaveStatus};
|
use crate::autosave::{AutosaveController, AutosaveStatus};
|
||||||
use crate::background::PatternColor;
|
use crate::background::PatternColor;
|
||||||
use crate::canvas::CanvasState;
|
use crate::canvas::CanvasState;
|
||||||
|
use crate::disk_watch::DiskWatch;
|
||||||
use crate::document::{Notebook, PageBackground};
|
use crate::document::{Notebook, PageBackground};
|
||||||
use crate::export::{self, export_notebook_to_pdf, ExportStrategy};
|
use crate::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||||
use crate::geometry::{self, RecognizedShape};
|
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(
|
fn update_window_title(
|
||||||
window: &Window,
|
window: &Window,
|
||||||
notebook: &Notebook,
|
notebook: &Notebook,
|
||||||
@@ -1308,7 +1319,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut ime_composing = false;
|
let mut ime_composing = false;
|
||||||
|
|
||||||
// 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung)
|
// 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(
|
let _ = storage::cleanup_trash(
|
||||||
&storage_base_dir,
|
&storage_base_dir,
|
||||||
std::time::Duration::from_secs(30 * 24 * 3600),
|
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.document_title = manifest.title.clone();
|
||||||
ui_state.available_notebooks = available_notebooks;
|
ui_state.available_notebooks = available_notebooks;
|
||||||
ui_state.library = Library::load(&storage_base_dir);
|
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 {
|
if let Some(warning) = startup_warning {
|
||||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||||
ui_state.set_export_message(warning);
|
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) {
|
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
|
||||||
Ok(lock) => {
|
Ok(lock) => {
|
||||||
autosave.set_read_only(false);
|
autosave.set_read_only(false);
|
||||||
|
warn_if_open_elsewhere(Some(&lock), &mut ui_state);
|
||||||
Some(lock)
|
Some(lock)
|
||||||
}
|
}
|
||||||
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
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
|
// Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren
|
||||||
canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors);
|
canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors);
|
||||||
canvas.set_pattern_color(ui_state.pattern_color);
|
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);
|
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() {
|
while let Ok(result) = export_rx.try_recv() {
|
||||||
match result {
|
match result {
|
||||||
Ok(path) => ui_state.set_export_message(t!("Gespeichert: {path}", path = path.display())),
|
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()
|
|| fling.is_active()
|
||||||
{
|
{
|
||||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
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()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.min()
|
.min()
|
||||||
@@ -2234,6 +2298,12 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window.request_redraw();
|
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) => {
|
WindowEvent::Focused(false) => {
|
||||||
// Bei Fokusverlust Strich abbrechen und 1€-Filter resetten
|
// Bei Fokusverlust Strich abbrechen und 1€-Filter resetten
|
||||||
if is_drawing {
|
if is_drawing {
|
||||||
@@ -3828,6 +3898,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if _notebook_lock.is_some() {
|
if _notebook_lock.is_some() {
|
||||||
ui_state.set_export_message(t!("Dokument '{title}' geöffnet", title = manifest.title));
|
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();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -3991,6 +4062,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
canvas.sync_page(notebook.current_page_mut());
|
canvas.sync_page(notebook.current_page_mut());
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
ui_state.set_export_message(t!("Gewechselt zu '{title}'", title = manifest.title));
|
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) => {
|
Err(err) => {
|
||||||
eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id);
|
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);
|
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||||
window.request_redraw();
|
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) => {
|
UiAction::SetLanguage(language) => {
|
||||||
ui_settings.language = language;
|
ui_settings.language = language;
|
||||||
i18n::set_language(language.unwrap_or_else(Language::system));
|
i18n::set_language(language.unwrap_or_else(Language::system));
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ pub enum PickerKind {
|
|||||||
Image,
|
Image,
|
||||||
/// PDF, Xournal++, Rnote or Saber file to import as a new notebook
|
/// PDF, Xournal++, Rnote or Saber file to import as a new notebook
|
||||||
Import,
|
Import,
|
||||||
|
/// Folder for the notebooks (Issue #49): lists folders only
|
||||||
|
Folder,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PickerKind {
|
impl PickerKind {
|
||||||
@@ -32,6 +34,7 @@ impl PickerKind {
|
|||||||
match self {
|
match self {
|
||||||
PickerKind::Image => is_supported_image_path(path),
|
PickerKind::Image => is_supported_image_path(path),
|
||||||
PickerKind::Import => crate::import::is_importable_path(path),
|
PickerKind::Import => crate::import::is_importable_path(path),
|
||||||
|
PickerKind::Folder => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,7 @@ impl FilePicker {
|
|||||||
let preferred = match kind {
|
let preferred = match kind {
|
||||||
PickerKind::Image => tn!("🖼 Bilder"),
|
PickerKind::Image => tn!("🖼 Bilder"),
|
||||||
PickerKind::Import => tn!("⬇ Downloads"),
|
PickerKind::Import => tn!("⬇ Downloads"),
|
||||||
|
PickerKind::Folder => tn!("🏠 Persönlicher Ordner"),
|
||||||
};
|
};
|
||||||
let all = shortcuts();
|
let all = shortcuts();
|
||||||
all.iter()
|
all.iter()
|
||||||
@@ -225,6 +229,7 @@ mod tests {
|
|||||||
"Uni.sbn2"
|
"Uni.sbn2"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
assert_eq!(names(PickerKind::Folder), ["aOrdner", "zOrdner"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ mod tests {
|
|||||||
("assets.rs", include_str!("assets.rs")),
|
("assets.rs", include_str!("assets.rs")),
|
||||||
("autosave.rs", include_str!("autosave.rs")),
|
("autosave.rs", include_str!("autosave.rs")),
|
||||||
("background.rs", include_str!("background.rs")),
|
("background.rs", include_str!("background.rs")),
|
||||||
|
("disk_watch.rs", include_str!("disk_watch.rs")),
|
||||||
("document.rs", include_str!("document.rs")),
|
("document.rs", include_str!("document.rs")),
|
||||||
("export.rs", include_str!("export.rs")),
|
("export.rs", include_str!("export.rs")),
|
||||||
("file_picker.rs", include_str!("file_picker.rs")),
|
("file_picker.rs", include_str!("file_picker.rs")),
|
||||||
|
|||||||
@@ -464,4 +464,24 @@ pub const EN: &[(&str, &str)] = &[
|
|||||||
("📄 Importieren (PDF, Xournal++, Rnote, Saber) …", "📄 Import (PDF, Xournal++, Rnote, Saber) …"),
|
("📄 Importieren (PDF, Xournal++, Rnote, Saber) …", "📄 Import (PDF, Xournal++, Rnote, Saber) …"),
|
||||||
("Keine Saber-Datei", "Not a Saber file"),
|
("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"),
|
("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"),
|
||||||
];
|
];
|
||||||
@@ -5,6 +5,7 @@ pub mod assets;
|
|||||||
pub mod autosave;
|
pub mod autosave;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod canvas;
|
pub mod canvas;
|
||||||
|
pub mod disk_watch;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod file_picker;
|
pub mod file_picker;
|
||||||
|
|||||||
@@ -229,6 +229,9 @@ pub struct UiSettings {
|
|||||||
pub language: Option<Language>,
|
pub language: Option<Language>,
|
||||||
/// Eraser radius in mm (Issue #38)
|
/// Eraser radius in mm (Issue #38)
|
||||||
pub eraser_radius_mm: f32,
|
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 {
|
impl Default for UiSettings {
|
||||||
@@ -249,6 +252,7 @@ impl Default for UiSettings {
|
|||||||
stylus_buttons: Vec::new(),
|
stylus_buttons: Vec::new(),
|
||||||
language: None,
|
language: None,
|
||||||
eraser_radius_mm: 3.0,
|
eraser_radius_mm: 3.0,
|
||||||
|
storage_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,6 +353,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
language: Some(Language::English),
|
language: Some(Language::English),
|
||||||
eraser_radius_mm: 6.0,
|
eraser_radius_mm: 6.0,
|
||||||
|
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
|
||||||
};
|
};
|
||||||
settings.save_to(&path).unwrap();
|
settings.save_to(&path).unwrap();
|
||||||
assert_eq!(UiSettings::load_from(&path), settings);
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
|
|||||||
+150
-2
@@ -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).
|
/// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation).
|
||||||
pub fn default_storage_dir() -> PathBuf {
|
pub fn default_storage_dir() -> PathBuf {
|
||||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||||
@@ -984,6 +1037,49 @@ pub struct NotebookSummary {
|
|||||||
pub struct NotebookLock {
|
pub struct NotebookLock {
|
||||||
_file: File,
|
_file: File,
|
||||||
pub path: PathBuf,
|
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 {
|
impl NotebookLock {
|
||||||
@@ -1007,10 +1103,31 @@ impl NotebookLock {
|
|||||||
|
|
||||||
use fs2::FileExt;
|
use fs2::FileExt;
|
||||||
match file.try_lock_exclusive() {
|
match file.try_lock_exclusive() {
|
||||||
Ok(()) => Ok(Self {
|
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,
|
_file: file,
|
||||||
path: lock_path,
|
path: lock_path,
|
||||||
}),
|
open_elsewhere,
|
||||||
|
marker,
|
||||||
|
})
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let kind = e.kind();
|
let kind = e.kind();
|
||||||
if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) {
|
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);
|
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]
|
#[test]
|
||||||
fn test_notebook_instance_lock_prevents_concurrent_open() {
|
fn test_notebook_instance_lock_prevents_concurrent_open() {
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -339,6 +339,8 @@ pub enum UiAction {
|
|||||||
InsertImageFile(std::path::PathBuf),
|
InsertImageFile(std::path::PathBuf),
|
||||||
/// PDF als neues Notizbuch importieren
|
/// PDF als neues Notizbuch importieren
|
||||||
ImportFile(std::path::PathBuf),
|
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
|
/// Aktuelle Seite als Bild oder SVG exportieren
|
||||||
ExportPage(PageExportKind),
|
ExportPage(PageExportKind),
|
||||||
/// Titel einer Seite setzen (leer = entfernen)
|
/// Titel einer Seite setzen (leer = entfernen)
|
||||||
@@ -503,6 +505,9 @@ pub struct UiState {
|
|||||||
pub queued_actions: Vec<UiAction>,
|
pub queued_actions: Vec<UiAction>,
|
||||||
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
|
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
|
||||||
pub last_picker_dir: Option<std::path::PathBuf>,
|
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 {
|
impl Default for UiState {
|
||||||
@@ -588,6 +593,8 @@ impl Default for UiState {
|
|||||||
page_title_for: None,
|
page_title_for: None,
|
||||||
page_picker_list: false,
|
page_picker_list: false,
|
||||||
last_picker_dir: None,
|
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);
|
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) {
|
fn open_file_picker(&mut self, kind: PickerKind) {
|
||||||
let dir = self.last_picker_dir.clone();
|
let dir = self.last_picker_dir.clone();
|
||||||
self.close_overlays();
|
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."
|
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| {
|
dialog_window(title, state, false).show(ctx, |ui| {
|
||||||
ui.set_width(460.0);
|
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 {
|
if let Some(err) = &picker.error {
|
||||||
ui.colored_label(egui::Color32::from_rgb(255, 120, 120), err);
|
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);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
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 {
|
if let Some(path) = chosen {
|
||||||
|
if picker_kind != PickerKind::Folder {
|
||||||
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
||||||
|
}
|
||||||
actions.push(match picker_kind {
|
actions.push(match picker_kind {
|
||||||
PickerKind::Image => UiAction::InsertImageFile(path),
|
PickerKind::Image => UiAction::InsertImageFile(path),
|
||||||
PickerKind::Import => UiAction::ImportFile(path),
|
PickerKind::Import => UiAction::ImportFile(path),
|
||||||
|
PickerKind::Folder => UiAction::SetStorageDir(Some(path)),
|
||||||
});
|
});
|
||||||
close = true;
|
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"));
|
section(ui, t!("STATUS"));
|
||||||
let (dot_color, status_text) = autosave_status_style(autosave_status);
|
let (dot_color, status_text) = autosave_status_style(autosave_status);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|||||||
Reference in new issue
Block a user