Compare commits

...
4 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 beb780b420 chore(release): bump version to 0.38.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:51:37 +01:00
haraldandClaude Opus 5.5 9b34e30475 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>
2026-10-27 13:09:39 +01:00
haraldandClaude Opus 5.5 41ee32e1f7 chore(release): bump version to 0.37.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 7m51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:27:44 +01:00
haraldandClaude Opus 5.5 397bf3bfe4 feat: import Saber notebooks (Issue #47)
The import now also accepts Saber files: .sbn2 (BSON) with its asset
files next to it, .sba archives (zip with main.sbn2 and assets) and the
old JSON .sbn, including the layout before format version 8 with
strokes outside the pages. Taken over: strokes with pen pressure and
perfect_freehand's thinning, highlighters, circle and rectangle shapes,
typed text (Quill delta as one text field), images (SVG rasterized),
background images and PDF pages as PDF backgrounds, paper patterns.

BSON and zip are read by small built-in readers, so no new
dependencies. The SVG rasterizer moved to src/import.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:25:29 +01:00
21 changed files with 1970 additions and 63 deletions

No files matched your search

Generated
+1 -1
View File
@@ -1411,7 +1411,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.36.0"
version = "0.38.0"
dependencies = [
"ab_glyph",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.36.0"
version = "0.38.0"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
+6 -2
View File
@@ -11,7 +11,7 @@
---
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften sowie Notizbücher aus Xournal++ und Rnote importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
ink-notes speichert Handschrift als Vektoren, erkennt auf Wunsch Formen, kann PDFs zum Beschriften sowie Notizbücher aus Xournal++, Rnote und Saber importieren, Bilder und Textfelder einfügen und alles wieder als PDF, PNG oder SVG exportieren. Gespeichert wird automatisch im Hintergrund.
Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11).
@@ -50,6 +50,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- **Importieren** (☰-Menü oder Datei ins Fenster ziehen) – **PDF**: Jede PDF-Seite wird eine Notizbuchseite mit der PDF-Seite als Hintergrund. Hochformat-Seiten werden DIN A4, Querformat-Seiten (z. B. Folien) bekommen eine Querformat-Seite ohne leeren Streifen darunter. Beim Hineinzoomen werden PDF-Seiten schärfer nachgerendert.
**Xournal++** (`.xopp`, auch `.xoj` aus Xournal): Striche mit Stiftdruck, Textmarker, Texte, Bilder, liniertes/kariertes/gepunktetes Papier und PDF-Hintergründe werden übernommen (das PDF wird am alten Ort und neben der Datei gesucht). LaTeX-Formeln werden übersprungen.
**Rnote** (`.rnote`, ab Rnote 0.5.10): Striche mit Stiftdruck, Formen, Texte und Bilder; in Rnote importierte PDF-Seiten werden zu Bildern. Seiten bleiben Seiten, unendliche Dokumente werden ein endloses Notizbuch.
**Saber** (`.sbn2` mit den Asset-Dateien `.sbn2.0`, `.sbn2.1`, … daneben, `.sba`-Archive, altes `.sbn`): Striche mit Stiftdruck, Textmarker, Formen, getippter Text, Bilder und PDF-Hintergründe.
### Seiten und Ansicht
- **Hintergründe:** blanko, kariert (5 mm), liniert (7,5 mm), Punktraster (5 mm). Die **Farbe von Karos, Linien und Punkten** ist wählbar (hell, mittel, kräftig, blau, grün) und gilt auch im Export.
@@ -67,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/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
@@ -174,9 +176,11 @@ src/
├── import.rs # Import als neues Notizbuch (Gemeinsames)
├── xopp.rs # Import von Xournal++-/Xournal-Notizbüchern
├── rnote.rs # Import von Rnote-Notizbüchern
├── 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)
+6 -2
View File
@@ -11,7 +11,7 @@
---
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation as well as Xournal++ and Rnote notebooks, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
ink-notes stores handwriting as vectors, can recognize shapes, imports PDFs for annotation as well as Xournal++, Rnote and Saber notebooks, inserts images and text boxes, and exports everything again as PDF, PNG or SVG. Saving happens automatically in the background.
It is mainly tested on Zorin OS 18 / Ubuntu 24.04 (Wayland and X11).
@@ -50,6 +50,7 @@ The interface is available in English and German; ink-notes follows the system l
- **Import** (☰ menu, or drag the file into the window) – **PDF**: Every PDF page becomes a notebook page with the PDF page as background. Portrait pages become A4; landscape pages (e.g. slides) get a landscape page without an empty strip below. When you zoom in, PDF pages are re-rendered more sharply.
**Xournal++** (`.xopp`, also `.xoj` from Xournal): strokes with pen pressure, highlighters, texts, images, lined/squared/dotted paper and PDF backgrounds are taken over (the PDF is looked for at its old place and next to the file). LaTeX formulas are skipped.
**Rnote** (`.rnote`, from Rnote 0.5.10): strokes with pen pressure, shapes, texts and images; PDF pages imported in Rnote become images. Pages stay pages, infinite documents become an endless notebook.
**Saber** (`.sbn2` with its asset files `.sbn2.0`, `.sbn2.1`, … next to it, `.sba` archives, old `.sbn`): strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.
### Pages and view
- **Backgrounds:** blank, squared (5 mm), ruled (7.5 mm), dot grid (5 mm). The **color of squares, lines and dots** is selectable (light, medium, strong, blue, green) and also applies to exports.
@@ -67,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/<title>.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks.
@@ -176,9 +178,11 @@ src/
├── import.rs # Import as a new notebook (shared parts)
├── xopp.rs # Import of Xournal++/Xournal notebooks
├── rnote.rs # Import of Rnote notebooks
├── 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)
+9
View File
@@ -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.
+9
View File
@@ -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.
+10
View File
@@ -84,6 +84,16 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.38.0" date="2026-10-27">
<description>
<p>The notebooks can be stored in a folder synced by Nextcloud, Google Drive, Syncthing or Dropbox. Changes from other devices are taken over automatically, conflict copies become pages of their own, and a warning appears when a notebook is open on another device.</p>
</description>
</release>
<release version="0.37.0" date="2026-10-27">
<description>
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
</description>
</release>
<release version="0.36.0" date="2026-10-27">
<description>
<p>Notebooks from Xournal++ (.xopp, .xoj) and Rnote (.rnote) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, texts, images and backgrounds.</p>
+10
View File
@@ -84,6 +84,16 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.38.0" date="2026-10-27">
<description>
<p>The notebooks can be stored in a folder synced by Nextcloud, Google Drive, Syncthing or Dropbox. Changes from other devices are taken over automatically, conflict copies become pages of their own, and a warning appears when a notebook is open on another device.</p>
</description>
</release>
<release version="0.37.0" date="2026-10-27">
<description>
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
</description>
</release>
<release version="0.36.0" date="2026-10-27">
<description>
<p>Notebooks from Xournal++ (.xopp, .xoj) and Rnote (.rnote) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, texts, images and backgrounds.</p>
+2
View File
@@ -3,6 +3,8 @@
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Sync between devices**: ☰ → Storage location puts the notebooks into a folder synced by Nextcloud, Google Drive, Syncthing or Dropbox (existing notebooks are copied, the originals stay). Changes from other devices show up within seconds while everything local is saved, conflict copies of the sync client become pages of their own, and opening a notebook that is open on another device shows a warning
- **Import from Saber**: ☰ → Import (or dragging the file into the window) now also opens Saber notebooks (`.sbn2` with its asset files next to it, `.sba` archives, old `.sbn`) – strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds
- **Import from Xournal++ and Rnote**: ☰ → Import (or dragging the file into the window) now also opens Xournal++ (`.xopp`, `.xoj`) and Rnote (`.rnote`) notebooks as new notebooks – strokes with pen pressure, highlighters, shapes, texts, images, paper patterns and Xournal++ PDF backgrounds; PDF pages imported in Rnote become images, infinite Rnote documents an endless notebook
- **Faster export**: PDF and PNG export and copying a selection as an image are about six times faster and PDFs are up to eight times smaller, because stroke outlines are simplified before export (at most 0.01 mm off; `INK_NOTES_EXACT_OUTLINES=1` exports every point as before)
- **Fling timing**: The swipe speed for gliding is measured with the touch screen's own event times (Wayland), so a fast swipe glides even when the lift is handled late; the fling diagnostics also report slow event handling
+146 -3
View File
@@ -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, &notebook, &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, &notebook, &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()
@@ -2216,7 +2280,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
}
}
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++/Rnote: importieren
// Bilddatei ins Fenster gezogen: an der Ablagestelle einfügen; PDF/Xournal++/Rnote/Saber: importieren
WindowEvent::DroppedFile(path) => {
if crate::import::is_importable_path(&path) {
ui_state.queued_actions.push(UiAction::ImportFile(path));
@@ -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, &notebook, &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, &notebook, &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));
+555
View File
@@ -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, &notebook, &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(), &notebook, &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(), &notebook, &mut manifest).unwrap();
let now = Instant::now();
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
let mut copy = PageData::from(&notebook.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(&copy).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 = &notebook.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());
}
}
+11 -3
View File
@@ -1,4 +1,4 @@
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und importierbare Dateien (PDF, Xournal++, Rnote).
//! Eingebaute, stift- und fingertaugliche Dateiauswahl für Bilder und importierbare Dateien (PDF, Xournal++, Rnote, Saber).
//!
//! Bewusst ohne Systemdialog: der bräuchte GTK oder das Desktop-Portal, die unter WSL und auf
//! schlanken Systemen oft fehlen. Die Auswahl zeigt Ordner und die passenden Dateien des
@@ -22,8 +22,10 @@ pub struct PickerEntry {
pub enum PickerKind {
/// Bilder zum Einfügen auf der aktuellen Seite
Image,
/// PDF, Xournal++ or Rnote file to import as a new notebook
/// 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()
@@ -194,6 +198,8 @@ mod tests {
"Skript.PDF",
"mathe.xopp",
"skizze.rnote",
"Uni.sbn2",
"Uni.sbn2.0",
] {
std::fs::write(dir.path().join(name), b"x").unwrap();
}
@@ -219,9 +225,11 @@ mod tests {
"zOrdner",
"mathe.xopp",
"skizze.rnote",
"Skript.PDF"
"Skript.PDF",
"Uni.sbn2"
]
);
assert_eq!(names(PickerKind::Folder), ["aOrdner", "zOrdner"]);
}
#[test]
+2
View File
@@ -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")),
@@ -169,6 +170,7 @@ mod tests {
("updater.rs", include_str!("updater.rs")),
("import.rs", include_str!("import.rs")),
("rnote.rs", include_str!("rnote.rs")),
("saber.rs", include_str!("saber.rs")),
("xopp.rs", include_str!("xopp.rs")),
];
+26 -4
View File
@@ -456,10 +456,32 @@ pub const EN: &[(&str, &str)] = &[
("Datei nicht lesbar: {err}", "File not readable: {err}"),
("Die Datei enthält keine Seiten", "The file contains no pages"),
("Keine Xournal-Datei", "Not a Xournal file"),
("Keine Ordner, PDF-, Xournal++- oder Rnote-Dateien", "No folders, PDF, Xournal++ or Rnote files"),
("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder werden übernommen, bei Xournal++ auch PDF-Hintergründe.", "The file becomes a new notebook. PDF: one page per PDF page, when exporting your notes lie on top of the original pages. Xournal++ (.xopp, .xoj) and Rnote (.rnote): strokes, shapes, texts and images are taken over, for Xournal++ also PDF backgrounds."),
("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++ und Rnote als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or notebooks from Xournal++ and Rnote as a new notebook and write on them"),
("📄 Importieren (PDF, Xournal++, Rnote) …", "📄 Import (PDF, Xournal++, Rnote) …"),
("Keine Rnote-Datei", "Not an Rnote file"),
("Rnote-Datei zu alt ({version}): bitte in einem aktuellen Rnote öffnen und neu speichern", "Rnote file too old ({version}): please open it in a current Rnote and save it again"),
("Keine Ordner oder importierbaren Dateien (PDF, Xournal++, Rnote, Saber)", "No folders or importable files (PDF, Xournal++, Rnote, Saber)"),
("Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, beim Export liegen deine Notizen über den Originalseiten. Xournal++ (.xopp, .xoj), Rnote (.rnote) und Saber (.sbn2, .sba): Striche, Formen, Texte und Bilder werden übernommen, bei Xournal++ und Saber auch PDF-Hintergründe.", "The file becomes a new notebook. PDF: one page per PDF page, when exporting your notes lie on top of the original pages. Xournal++ (.xopp, .xoj), Rnote (.rnote) and Saber (.sbn2, .sba): strokes, shapes, texts and images are taken over, for Xournal++ and Saber also PDF backgrounds."),
("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++, Rnote und Saber als neues Notizbuch öffnen und darauf schreiben", "Open lecture slides, worksheets, forms or notebooks from Xournal++, Rnote and Saber as a new notebook and write on them"),
("📄 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"),
];
+37 -3
View File
@@ -1,6 +1,6 @@
//! Import of other formats as a new notebook (Issue #47): PDF (`storage`), Xournal++
//! (`xopp`) and Rnote (`rnote`). This module holds what the importers share: building
//! strokes, texts and images in the stored format and creating the notebook.
//! (`xopp`), Rnote (`rnote`) and Saber (`saber`). This module holds what the importers
//! share: building strokes, texts and images in the stored format and creating the notebook.
use std::io::Read;
use std::path::{Path, PathBuf};
@@ -37,9 +37,10 @@ pub fn is_importable_path(path: &Path) -> bool {
crate::file_picker::is_pdf_path(path)
|| crate::xopp::is_xopp_path(path)
|| crate::rnote::is_rnote_path(path)
|| crate::saber::is_saber_path(path)
}
/// Imports a PDF, Xournal++ or Rnote file as a new notebook titled `title`.
/// Imports a PDF, Xournal++, Rnote or Saber file as a new notebook titled `title`.
pub fn import_file(
base_dir: &Path,
title: &str,
@@ -49,6 +50,8 @@ pub fn import_file(
crate::xopp::create_notebook_from_xopp(base_dir, title, path)
} else if crate::rnote::is_rnote_path(path) {
crate::rnote::create_notebook_from_rnote(base_dir, title, path)
} else if crate::saber::is_saber_path(path) {
crate::saber::create_notebook_from_saber(base_dir, title, path)
} else {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
storage::create_notebook_from_pdf(base_dir, title, bytes)
@@ -228,6 +231,37 @@ pub fn image_stroke(id: u64, asset: String, corners: [glam::Vec2; 4]) -> StrokeD
}
}
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
pub fn unpremultiply(pixels: &mut [u8]) {
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a > 0 && a < 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
}
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
pub fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
use resvg::{tiny_skia, usvg};
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
let px_per_unit = 250.0 / 254.0;
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
let height = (size.y * px_per_unit).round().clamp(1.0, max) as u32;
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
let transform = tiny_skia::Transform::from_scale(
width as f32 / tree.size().width(),
height as f32 / tree.size().height(),
);
resvg::render(&tree, transform, &mut pixmap.as_mut());
let mut pixels = pixmap.take();
unpremultiply(&mut pixels);
Some((width, height, pixels))
}
fn corner_samples(corners: [glam::Vec2; 4]) -> Vec<SampleData> {
corners
.iter()
+2
View File
@@ -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;
@@ -22,6 +23,7 @@ pub mod pdf_merge;
pub mod platform;
pub mod rnote;
pub mod ruler;
pub mod saber;
pub mod selection;
pub mod settings;
pub mod spline;
+2 -33
View File
@@ -380,7 +380,7 @@ fn bitmapimage(value: &Value) -> Option<RnoteItem> {
if pixels.len() != width as usize * height as usize * 4 {
return None;
}
unpremultiply(&mut pixels);
import::unpremultiply(&mut pixels);
Some(RnoteItem::Bitmap {
width,
height,
@@ -407,37 +407,6 @@ fn rectangle_corners(rectangle: &Value) -> Option<[DVec2; 4]> {
)
}
/// Premultiplied RGBA (Rnote, tiny-skia) to straight RGBA.
fn unpremultiply(pixels: &mut [u8]) {
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a > 0 && a < 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
}
/// Rasterizes an SVG for a box of `size` page units (about 250 dpi).
fn rasterize_svg(svg: &str, size: glam::Vec2) -> Option<(u32, u32, Vec<u8>)> {
use resvg::{tiny_skia, usvg};
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
let max = crate::assets::MAX_IMPORT_DIMENSION as f32;
let px_per_unit = 250.0 / 254.0;
let width = (size.x * px_per_unit).round().clamp(1.0, max) as u32;
let height = (size.y * px_per_unit).round().clamp(1.0, max) as u32;
let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
let transform = tiny_skia::Transform::from_scale(
width as f32 / tree.size().width(),
height as f32 / tree.size().height(),
);
resvg::render(&tree, transform, &mut pixmap.as_mut());
let mut pixels = pixmap.take();
unpremultiply(&mut pixels);
Some((width, height, pixels))
}
/// Rnote's version as (major, minor, patch).
fn version(text: &str) -> Option<(u64, u64, u64)> {
let v = semver::Version::parse(text).ok()?;
@@ -680,7 +649,7 @@ fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
corners[0].distance(corners[1]),
corners[0].distance(corners[3]),
);
let image = rasterize_svg(svg, size).and_then(|(width, height, pixels)| {
let image = import::rasterize_svg(svg, size).and_then(|(width, height, pixels)| {
assets.import_rgba(width, height, pixels).ok()
});
match image {
+904
View File
@@ -0,0 +1,904 @@
//! Import of Saber notebooks (.sbn2, .sba, old .sbn) (Issue #47).
//!
//! A `.sbn2` file is a BSON document; images and PDFs lie next to it as `<file>.sbn2.0`,
//! `<file>.sbn2.1`, …; a `.sba` archive is a zip with `main.sbn2` and its assets, and the old
//! `.sbn` is the same document as JSON. Pages (`z`) hold strokes (`s`), images (`i`), typed
//! text as a Quill delta (`q`) and an optional background image or PDF page (`b`). Points are
//! three little-endian `f32` (x, y, pressure) since format version 13.
//!
//! Stroke widths follow perfect_freehand: the diameter is `size · (1 − thinning · (1 − 2p))`.
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::document::{Notebook, PageBackground, PdfBackground};
use crate::import::{self, ImportReport, Imported};
use crate::storage::{NotebookManifest, StrokeData};
use crate::t;
use crate::text::TextFont;
/// Saber's default page size, used when a page has none.
const DEFAULT_PAGE: glam::Vec2 = glam::Vec2::new(1000.0, 1400.0);
/// Is the file by its name a Saber notebook?
pub fn is_saber_path(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
["sbn2", "sba", "sbn"]
.iter()
.any(|x| e.eq_ignore_ascii_case(x))
})
}
/// A BSON (or JSON) value as far as Saber uses it.
#[derive(Debug, Clone, PartialEq)]
pub enum Bson {
Null,
Bool(bool),
Int(i64),
Double(f64),
Str(String),
Binary(Vec<u8>),
Array(Vec<Bson>),
Doc(Vec<(String, Bson)>),
}
impl Bson {
pub fn get(&self, key: &str) -> Option<&Bson> {
match self {
Bson::Doc(fields) => fields
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v)
.filter(|v| **v != Bson::Null),
_ => None,
}
}
pub fn as_f64(&self) -> Option<f64> {
match *self {
Bson::Double(v) if v.is_finite() => Some(v),
Bson::Int(v) => Some(v as f64),
_ => None,
}
}
pub fn as_i64(&self) -> Option<i64> {
match *self {
Bson::Int(v) => Some(v),
Bson::Double(v) if v.is_finite() => Some(v as i64),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Bson::Str(s) => Some(s),
_ => None,
}
}
pub fn as_array(&self) -> &[Bson] {
match self {
Bson::Array(items) => items,
_ => &[],
}
}
fn num(&self, key: &str) -> Option<f64> {
self.get(key)?.as_f64()
}
fn bool(&self, key: &str) -> Option<bool> {
match self.get(key)? {
Bson::Bool(b) => Some(*b),
_ => None,
}
}
fn from_json(value: serde_json::Value) -> Bson {
use serde_json::Value;
match value {
Value::Null => Bson::Null,
Value::Bool(b) => Bson::Bool(b),
Value::Number(n) => n
.as_i64()
.map(Bson::Int)
.unwrap_or_else(|| Bson::Double(n.as_f64().unwrap_or(0.0))),
Value::String(s) => Bson::Str(s),
Value::Array(items) => Bson::Array(items.into_iter().map(Bson::from_json).collect()),
Value::Object(map) => Bson::Doc(
map.into_iter()
.map(|(k, v)| (k, Bson::from_json(v)))
.collect(),
),
}
}
}
/// Reads a BSON document.
pub fn parse_bson(bytes: &[u8]) -> Result<Bson, String> {
let mut pos = 0;
document(bytes, &mut pos, 0).ok_or_else(|| t!("Keine Saber-Datei").to_string())
}
fn take<'a>(bytes: &'a [u8], pos: &mut usize, n: usize) -> Option<&'a [u8]> {
let slice = bytes.get(*pos..pos.checked_add(n)?)?;
*pos += n;
Some(slice)
}
fn int32(bytes: &[u8], pos: &mut usize) -> Option<i32> {
Some(i32::from_le_bytes(take(bytes, pos, 4)?.try_into().ok()?))
}
fn cstring(bytes: &[u8], pos: &mut usize) -> Option<String> {
let len = bytes.get(*pos..)?.iter().position(|&b| b == 0)?;
let s = String::from_utf8_lossy(take(bytes, pos, len)?).into_owned();
*pos += 1;
Some(s)
}
/// A document (or array, which is a document with the keys "0", "1", …).
fn document(bytes: &[u8], pos: &mut usize, depth: usize) -> Option<Bson> {
if depth > 64 {
return None;
}
let start = *pos;
let len = usize::try_from(int32(bytes, pos)?).ok()?;
let end = start.checked_add(len)?;
if end > bytes.len() || len < 5 {
return None;
}
let mut fields = Vec::new();
loop {
let kind = *bytes.get(*pos)?;
*pos += 1;
if kind == 0 {
break;
}
let key = cstring(bytes, pos)?;
let value = match kind {
0x01 => Bson::Double(f64::from_le_bytes(take(bytes, pos, 8)?.try_into().ok()?)),
0x02 => {
let n = usize::try_from(int32(bytes, pos)?).ok()?;
let s = take(bytes, pos, n)?;
Bson::Str(String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned())
}
0x03 => document(bytes, pos, depth + 1)?,
0x04 => match document(bytes, pos, depth + 1)? {
Bson::Doc(items) => Bson::Array(items.into_iter().map(|(_, v)| v).collect()),
other => other,
},
0x05 => {
let n = usize::try_from(int32(bytes, pos)?).ok()?;
*pos += 1; // subtype
Bson::Binary(take(bytes, pos, n)?.to_vec())
}
0x07 => {
take(bytes, pos, 12)?;
Bson::Null
}
0x08 => Bson::Bool(*take(bytes, pos, 1)?.first()? != 0),
0x09 | 0x11 | 0x12 => {
Bson::Int(i64::from_le_bytes(take(bytes, pos, 8)?.try_into().ok()?))
}
0x0A | 0x06 | 0x7F | 0xFF => Bson::Null,
0x10 => Bson::Int(int32(bytes, pos)? as i64),
_ => return None,
};
fields.push((key, value));
}
(*pos == end).then_some(Bson::Doc(fields))
}
/// Reads the files of a zip archive (stored or deflated), at most `limit` bytes in total.
fn unzip(bytes: &[u8], limit: usize) -> Result<HashMap<String, Vec<u8>>, String> {
let broken = || t!("Datei beschädigt: {err}", err = "zip").to_string();
let u16_at = |at: usize| -> Option<usize> {
Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?) as usize)
};
let u32_at = |at: usize| -> Option<usize> {
Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?) as usize)
};
// End of central directory: the last record with its signature
let eocd = (0..bytes.len().saturating_sub(21))
.rev()
.find(|&i| bytes[i..].starts_with(&[0x50, 0x4b, 0x05, 0x06]))
.ok_or_else(broken)?;
let count = u16_at(eocd + 10).ok_or_else(broken)?;
let mut at = u32_at(eocd + 16).ok_or_else(broken)?;
let mut files = HashMap::new();
let mut total = 0;
for _ in 0..count {
let entry = (|| {
if !bytes.get(at..)?.starts_with(&[0x50, 0x4b, 0x01, 0x02]) {
return None;
}
let method = u16_at(at + 10)?;
let compressed = u32_at(at + 20)?;
let size = u32_at(at + 24)?;
let name_len = u16_at(at + 28)?;
let skip = name_len + u16_at(at + 30)? + u16_at(at + 32)?;
let local = u32_at(at + 42)?;
let name = String::from_utf8_lossy(bytes.get(at + 46..at + 46 + name_len)?);
at += 46 + skip;
let data_start = local + 30 + u16_at(local + 26)? + u16_at(local + 28)?;
let data = bytes.get(data_start..data_start.checked_add(compressed)?)?;
Some((name.into_owned(), method, size, data))
})();
let (name, method, size, data) = entry.ok_or_else(broken)?;
total += size;
if total > limit {
return Err(broken());
}
let content = match method {
0 => data.to_vec(),
8 => {
let mut out = Vec::with_capacity(size);
flate2::read::DeflateDecoder::new(data)
.take(size as u64)
.read_to_end(&mut out)
.map_err(|_| broken())?;
out
}
_ => return Err(broken()),
};
files.insert(name, content);
}
Ok(files)
}
/// The document and a way to get its assets (by index).
struct Source {
doc: Bson,
assets: Box<dyn Fn(usize) -> Option<Vec<u8>>>,
}
fn load(path: &Path) -> Result<Source, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
if bytes.starts_with(&[0x50, 0x4b, 0x03, 0x04]) {
let files = unzip(&bytes, 1024 * 1024 * 1024)?;
let main = files
.iter()
.find(|(name, _)| name.ends_with(".sbn2"))
.map(|(name, data)| (name.clone(), data))
.ok_or_else(|| t!("Keine Saber-Datei").to_string())?;
let doc = parse_bson(main.1)?;
let prefix = main.0;
return Ok(Source {
doc,
assets: Box::new(move |i| files.get(&format!("{prefix}.{i}")).cloned()),
});
}
// .sbn2 is BSON, the old .sbn JSON
let doc = match parse_bson(&bytes) {
Ok(doc) => doc,
Err(err) => serde_json::from_slice(&bytes)
.map(Bson::from_json)
.map_err(|_| err)?,
};
let base = path.to_path_buf();
Ok(Source {
doc,
assets: Box::new(move |i| {
std::fs::read(PathBuf::from(format!("{}.{i}", base.display()))).ok()
}),
})
}
/// Color stored as ARGB integer.
fn argb(value: Option<&Bson>) -> [f32; 4] {
let v = value.and_then(Bson::as_i64).unwrap_or(0xff000000) as u32;
[
((v >> 16) & 0xff) as f32 / 255.0,
((v >> 8) & 0xff) as f32 / 255.0,
(v & 0xff) as f32 / 255.0,
(v >> 24) as f32 / 255.0,
]
}
/// Points of a stroke: `f32` triples (version 13 on) or `{x, y, p}` documents.
fn points(stroke: &Bson) -> Vec<(glam::Vec2, Option<f32>)> {
let offset = glam::Vec2::new(
stroke.num("ox").unwrap_or(0.0) as f32,
stroke.num("oy").unwrap_or(0.0) as f32,
);
let Some(list) = stroke.get("p") else {
return Vec::new();
};
list.as_array()
.iter()
.filter_map(|point| match point {
Bson::Binary(data) => {
let values: Vec<f32> = data
.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect();
match values[..] {
[x, y] => Some((glam::Vec2::new(x, y), None)),
[x, y, p, ..] => Some((glam::Vec2::new(x, y), Some(p))),
_ => None,
}
}
doc @ Bson::Doc(_) => Some((
glam::Vec2::new(doc.num("x")? as f32, doc.num("y")? as f32),
doc.num("p").map(|p| p as f32),
)),
_ => None,
})
.filter(|(p, _)| p.is_finite())
.map(|(p, pressure)| (p + offset, pressure))
.collect()
}
/// Outline points of a shape stroke (circle or rectangle).
fn shape_points(stroke: &Bson, shape: &str) -> Vec<glam::Vec2> {
let n = |k| stroke.num(k).unwrap_or(0.0) as f32;
match shape {
"circle" => {
let (center, r) = (glam::Vec2::new(n("cx"), n("cy")), n("r"));
(0..=64)
.map(|i| {
let a = i as f32 / 64.0 * std::f32::consts::TAU;
center + glam::Vec2::new(a.cos(), a.sin()) * r
})
.collect()
}
"rect" => {
let (l, t, w, h) = (n("rl"), n("rt"), n("rw"), n("rh"));
[(l, t), (l + w, t), (l + w, t + h), (l, t + h), (l, t)]
.map(|(x, y)| glam::Vec2::new(x, y))
.to_vec()
}
_ => Vec::new(),
}
}
/// Typed text of a page: the inserted strings of its Quill delta.
fn quill_text(delta: &Bson) -> String {
let text: String = delta
.as_array()
.iter()
.filter_map(|op| op.get("insert")?.as_str())
.collect();
text.trim_end().to_string()
}
/// Creates a new notebook from a Saber file.
pub fn create_notebook_from_saber(
base_dir: &Path,
title: &str,
path: &Path,
) -> Result<(Notebook, NotebookManifest, PathBuf, ImportReport), String> {
let source = load(path)?;
// The very first files were only a list of strokes; versions up to 7 kept strokes and
// images outside the pages, which is read as well
if !matches!(source.doc, Bson::Doc(_)) {
return Err(t!(
"Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern"
)
.to_string());
}
import::create_notebook(base_dir, title, |dir| Ok(convert(&source, dir)))
}
/// A PDF of the notebook in our asset folder.
struct PdfSource {
asset: String,
sizes: Vec<(f32, f32)>,
}
fn convert(source: &Source, notebook_dir: &Path) -> Imported {
let doc = &source.doc;
let assets = crate::assets::AssetStore::new(notebook_dir);
let mut report = ImportReport::default();
let mut pdfs: HashMap<usize, Option<PdfSource>> = HashMap::new();
let background = match doc.get("p").and_then(Bson::as_str) {
Some("lined" | "college" | "college-rtl" | "cornell") => PageBackground::Lined,
Some("grid") => PageBackground::Grid5mm,
Some("dots") => PageBackground::DotGrid,
_ => PageBackground::Blank,
};
let line_height = doc.num("l").unwrap_or(40.0) as f32;
let mut xpages: Vec<&Bson> = doc
.get("z")
.map(Bson::as_array)
.unwrap_or_default()
.iter()
.collect();
let empty = Bson::Doc(Vec::new());
if xpages.is_empty() {
xpages.push(&empty);
}
// Before version 8 strokes and images were stored outside the pages, with a page index
let old_items = |key: &str, index: usize| -> Vec<&Bson> {
doc.get(key)
.map(Bson::as_array)
.unwrap_or_default()
.iter()
.filter(|item| item.num("i").unwrap_or(0.0) as usize == index)
.collect()
};
let mut next_id: u64 = 1;
let mut new_id = || {
next_id += 1;
next_id - 1
};
let mut pages = Vec::with_capacity(xpages.len());
for (index, xpage) in xpages.iter().enumerate() {
// Pages stored as [width, height] in very old files
let page_size = match xpage {
Bson::Array(wh) => glam::Vec2::new(
wh.first().and_then(Bson::as_f64).unwrap_or(1000.0) as f32,
wh.get(1).and_then(Bson::as_f64).unwrap_or(1400.0) as f32,
),
_ => glam::Vec2::new(
xpage.num("w").map_or(DEFAULT_PAGE.x, |w| w as f32),
xpage.num("h").map_or(DEFAULT_PAGE.y, |h| h as f32),
),
}
.max(glam::Vec2::ONE);
// Same rules as for imported PDFs: portrait pages become A4, the content keeps its
// aspect ratio, centred horizontally and aligned at the top
let size = crate::document::pdf_page_size(page_size.x, page_size.y);
let placement = crate::document::pdf_placement(page_size.x, page_size.y, size);
let scale = (placement[1].x - placement[0].x) / page_size.x;
let map = |p: glam::Vec2| placement[0] + p * scale;
let mut strokes: Vec<StrokeData> = Vec::new();
let mut pdf_background = None;
if let Some(image) = xpage.get("b") {
let natural = glam::Vec2::new(
image.num("nw").unwrap_or(0.0) as f32,
image.num("nh").unwrap_or(0.0) as f32,
);
let corners = if natural.min_element() > 0.0 {
crate::document::pdf_placement(natural.x, natural.y, size)
} else {
placement
};
let asset = image.num("a").map(|a| a as usize);
if image.get("e").and_then(Bson::as_str) == Some(".pdf") {
let pdf = asset.and_then(|a| {
pdfs.entry(a)
.or_insert_with(|| {
let bytes = (source.assets)(a)?;
let sizes = crate::pdf_doc::page_sizes(bytes.clone()).ok()?;
let asset = assets.import_pdf(&bytes).ok()?;
Some(PdfSource { asset, sizes })
})
.as_ref()
});
let page = image.num("pdfi").unwrap_or(0.0) as u32;
match pdf.and_then(|pdf| Some((pdf, *pdf.sizes.get(page as usize)?))) {
Some((pdf, (width_pt, height_pt))) => {
pdf_background = Some(PdfBackground {
asset: pdf.asset.clone(),
page,
width_pt,
height_pt,
})
}
None => report.skipped += 1,
}
} else {
match image_asset(image, source, &assets, corners) {
Some(asset) => strokes.push(import::image_stroke(new_id(), asset, corners)),
None => report.skipped += 1,
}
}
}
let images = xpage.get("i").map(Bson::as_array).unwrap_or_default();
for image in images.iter().chain(old_items("i", index)) {
let rect = ["x", "y", "w", "h"].map(|k| image.num(k).unwrap_or(0.0) as f32);
let corners = [
glam::Vec2::new(rect[0], rect[1]),
glam::Vec2::new(rect[0] + rect[2], rect[1]),
glam::Vec2::new(rect[0] + rect[2], rect[1] + rect[3]),
glam::Vec2::new(rect[0], rect[1] + rect[3]),
]
.map(map);
match image_asset(image, source, &assets, corners) {
Some(asset) => strokes.push(import::image_stroke(new_id(), asset, corners)),
None => report.skipped += 1,
}
}
if let Some(delta) = xpage.get("q") {
let text = quill_text(delta);
if !text.is_empty() {
// Saber's text area starts 1.2 lines down, half a line in from the sides
let origin = glam::Vec2::new(line_height * 0.5, line_height * 1.2);
strokes.push(import::text_stroke(
new_id(),
&text,
line_height * 0.8 * scale,
TextFont::Sans,
map(origin),
[glam::Vec2::X, glam::Vec2::Y],
Some((page_size.x - line_height) * scale),
[0.0, 0.0, 0.0, 1.0],
));
}
}
let page_strokes = xpage.get("s").map(Bson::as_array).unwrap_or_default();
for stroke in page_strokes.iter().chain(old_items("s", index)) {
let tool = stroke
.get("ty")
.and_then(Bson::as_str)
.unwrap_or("fountainPen");
if tool == "Eraser" || tool == "LaserPointer" {
continue;
}
let size = stroke.num("s").unwrap_or(10.0) as f32;
let thinning = stroke.num("t").unwrap_or(0.5) as f32;
let pressure_enabled = stroke.bool("pe").unwrap_or(true);
let (points, pressures): (Vec<glam::Vec2>, Vec<Option<f32>>) =
match stroke.get("shape").and_then(Bson::as_str) {
Some(shape) => shape_points(stroke, shape)
.into_iter()
.map(|p| (p, None))
.unzip(),
None => points(stroke).into_iter().unzip(),
};
if points.is_empty() {
report.skipped += 1;
continue;
}
let widths: Vec<f32> = pressures
.iter()
.map(|p| {
let p = p
.filter(|_| pressure_enabled)
.unwrap_or(0.5)
.clamp(0.0, 1.0);
(size * (1.0 - thinning * (1.0 - 2.0 * p))).max(size * 0.05) * scale
})
.collect();
let (width, pressures) = import::fit_widths(&widths);
let points: Vec<glam::Vec2> = points.into_iter().map(map).collect();
strokes.push(import::ink_stroke(
new_id(),
argb(stroke.get("c")),
tool == "Highlighter",
&points,
width,
&pressures,
));
}
pages.push(import::page(
index as u64 + 1,
size,
background,
pdf_background,
strokes,
));
}
Imported {
pages,
next_stroke_id: next_id,
layout: None,
report,
}
}
/// Stores the image of `image` (asset file, inline bytes or SVG text) in our asset folder.
fn image_asset(
image: &Bson,
source: &Source,
assets: &crate::assets::AssetStore,
corners: [glam::Vec2; 4],
) -> Option<String> {
let bytes = match (image.num("a"), image.get("b")) {
(Some(a), _) => (source.assets)(a as usize)?,
(None, Some(Bson::Binary(b))) => b.clone(),
(None, Some(Bson::Array(b))) => b.iter().filter_map(|v| Some(v.as_i64()? as u8)).collect(),
(None, Some(Bson::Str(svg))) => svg.clone().into_bytes(),
_ => return None,
};
if image.get("e").and_then(Bson::as_str) == Some(".svg") {
let size = glam::Vec2::new(
corners[0].distance(corners[1]),
corners[0].distance(corners[3]),
);
let (w, h, pixels) = import::rasterize_svg(std::str::from_utf8(&bytes).ok()?, size)?;
return assets.import_rgba(w, h, pixels).ok().map(|i| i.asset);
}
assets.import_bytes(&bytes).ok().map(|i| i.asset)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::StrokeTool;
/// BSON encoder for test files.
fn encode(value: &Bson) -> Vec<u8> {
fn doc(fields: &[(String, Bson)]) -> Vec<u8> {
let mut body = Vec::new();
for (key, value) in fields {
let (kind, data): (u8, Vec<u8>) = match value {
Bson::Null => (0x0A, Vec::new()),
Bson::Bool(b) => (0x08, vec![*b as u8]),
Bson::Int(i) => (0x12, i.to_le_bytes().to_vec()),
Bson::Double(d) => (0x01, d.to_le_bytes().to_vec()),
Bson::Str(s) => {
let mut d = ((s.len() + 1) as i32).to_le_bytes().to_vec();
d.extend(s.as_bytes());
d.push(0);
(0x02, d)
}
Bson::Binary(b) => {
let mut d = (b.len() as i32).to_le_bytes().to_vec();
d.push(0);
d.extend(b);
(0x05, d)
}
Bson::Array(items) => {
let fields: Vec<(String, Bson)> = items
.iter()
.enumerate()
.map(|(i, v)| (i.to_string(), v.clone()))
.collect();
(0x04, doc(&fields))
}
Bson::Doc(fields) => (0x03, doc(fields)),
};
body.push(kind);
body.extend(key.as_bytes());
body.push(0);
body.extend(data);
}
let mut out = ((body.len() + 5) as i32).to_le_bytes().to_vec();
out.extend(body);
out.push(0);
out
}
match value {
Bson::Doc(fields) => doc(fields),
_ => panic!("only documents"),
}
}
fn d(fields: &[(&str, Bson)]) -> Bson {
Bson::Doc(
fields
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect(),
)
}
fn point(x: f32, y: f32, p: f32) -> Bson {
Bson::Binary([x, y, p].iter().flat_map(|v| v.to_le_bytes()).collect())
}
fn png() -> Vec<u8> {
let image = image::RgbaImage::from_pixel(4, 2, image::Rgba([200, 30, 30, 255]));
let mut png = Vec::new();
image
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
png
}
/// Version 19 document: page 1 with ink, highlighter, a circle, an image and text;
/// page 2 with a PDF page as background. Assets: 0 = PNG, 1 = PDF.
fn sample() -> Bson {
let pen = d(&[
(
"p",
Bson::Array(vec![point(100.0, 100.0, 1.0), point(200.0, 100.0, 0.25)]),
),
("ty", Bson::Str("fountainPen".into())),
("pe", Bson::Bool(true)),
("c", Bson::Int(0xff0000ff)),
("s", Bson::Double(10.0)),
("t", Bson::Double(0.5)),
("ox", Bson::Double(10.0)),
]);
let marker = d(&[
(
"p",
Bson::Array(vec![point(0.0, 300.0, 0.5), point(300.0, 300.0, 0.5)]),
),
("ty", Bson::Str("Highlighter".into())),
("c", Bson::Int(0x80ffff00)),
("s", Bson::Double(40.0)),
]);
let circle = d(&[
("shape", Bson::Str("circle".into())),
("cx", Bson::Double(500.0)),
("cy", Bson::Double(500.0)),
("r", Bson::Double(50.0)),
("c", Bson::Int(0xff00ff00)),
("s", Bson::Double(5.0)),
]);
let image = d(&[
("e", Bson::Str(".png".into())),
("a", Bson::Int(0)),
("x", Bson::Double(600.0)),
("y", Bson::Double(100.0)),
("w", Bson::Double(200.0)),
("h", Bson::Double(100.0)),
]);
let text = Bson::Array(vec![
d(&[("insert", Bson::Str("Hallo ".into()))]),
d(&[("insert", Bson::Str("Welt\n".into()))]),
]);
let page1 = d(&[
("w", Bson::Double(1000.0)),
("h", Bson::Double(1400.0)),
("s", Bson::Array(vec![pen, marker, circle])),
("i", Bson::Array(vec![image])),
("q", text),
]);
let pdf = d(&[
("e", Bson::Str(".pdf".into())),
("a", Bson::Int(1)),
("pdfi", Bson::Int(1)),
("nw", Bson::Double(842.0)),
("nh", Bson::Double(595.0)),
]);
let page2 = d(&[
("w", Bson::Double(1000.0)),
("h", Bson::Double(707.0)),
("b", pdf),
]);
d(&[
("v", Bson::Int(19)),
("p", Bson::Str("dots".into())),
("l", Bson::Int(40)),
("z", Bson::Array(vec![page1, page2])),
])
}
fn sample_pdf() -> Vec<u8> {
crate::pdf_doc::tests::sample_pdf(&[(842.0, 595.0, 0), (842.0, 595.0, 0)])
}
fn check(notebook: &Notebook, report: &ImportReport) {
assert_eq!(*report, ImportReport::default());
assert_eq!(notebook.pages.len(), 2);
let first = &notebook.pages[0];
assert_eq!(first.background, PageBackground::DotGrid);
// Image, text, then the strokes
assert_eq!(first.strokes.len(), 5);
assert!(first.strokes[0].is_image());
assert_eq!(
first.strokes[1].text.as_ref().unwrap().content,
"Hallo Welt"
);
let pen = &first.strokes[2];
assert_eq!(pen.tool, StrokeTool::Pen);
assert_eq!(pen.color, [0.0, 0.0, 1.0, 1.0]);
// 1000 Saber units = 210 mm; the offset moves the stroke by 10
assert!((pen.points[0].position.x - 231.0).abs() < 0.1);
// perfect_freehand: size 10, thinning 0.5 gives 15 at full pressure and 7.5 at a
// quarter, scaled by 2.1
let drawn: Vec<f32> = pen
.points
.iter()
.map(|s| pen.width * (0.5 + s.pressure))
.collect();
assert!((drawn[0] - 31.5).abs() < 0.01 && (drawn[1] - 15.75).abs() < 0.01);
assert_eq!(first.strokes[3].tool, StrokeTool::Highlighter);
assert!((first.strokes[3].color[3] - 128.0 / 255.0).abs() < 1e-3);
assert_eq!(first.strokes[4].points.len(), 65);
let bg = notebook.pages[1].pdf_background.as_ref().unwrap();
assert_eq!(bg.page, 1);
assert!(notebook.pages[1].size.x > notebook.pages[1].size.y);
}
#[test]
fn test_parse_bson() {
let value = sample();
assert_eq!(parse_bson(&encode(&value)).unwrap(), value);
assert!(parse_bson(b"\x05\x00\x00").is_err());
let mut broken = encode(&value);
broken.truncate(broken.len() - 3);
assert!(parse_bson(&broken).is_err());
}
#[test]
fn test_import_sbn2_with_assets_next_to_it() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = source_dir.path().join("Mathe.sbn2");
std::fs::write(&path, encode(&sample())).unwrap();
std::fs::write(source_dir.path().join("Mathe.sbn2.0"), png()).unwrap();
std::fs::write(source_dir.path().join("Mathe.sbn2.1"), sample_pdf()).unwrap();
let (notebook, _, dir, report) =
create_notebook_from_saber(temp_dir.path(), "Mathe", &path).unwrap();
check(&notebook, &report);
// Saved and loaded again
let (mut reloaded, _) = crate::storage::load_notebook(&dir).unwrap();
crate::storage::load_all_pages(&dir, &mut reloaded).unwrap();
check(&reloaded, &report);
}
/// Zip archive with stored (uncompressed) files.
fn zip(files: &[(&str, Vec<u8>)]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in files {
let offset = out.len() as u32;
let header = |sig: &[u8], central: bool| {
let mut h = sig.to_vec();
if central {
h.extend([20, 0]);
}
h.extend([20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
h.extend((data.len() as u32).to_le_bytes());
h.extend((data.len() as u32).to_le_bytes());
h.extend((name.len() as u16).to_le_bytes());
h.extend([0, 0]);
if central {
h.extend([0; 10]);
h.extend(offset.to_le_bytes());
}
h.extend(name.as_bytes());
h
};
out.extend(header(&[0x50, 0x4b, 0x03, 0x04], false));
out.extend(data);
central.extend(header(&[0x50, 0x4b, 0x01, 0x02], true));
}
let start = out.len() as u32;
out.extend(&central);
out.extend([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]);
out.extend((files.len() as u16).to_le_bytes());
out.extend((files.len() as u16).to_le_bytes());
out.extend((central.len() as u32).to_le_bytes());
out.extend(start.to_le_bytes());
out.extend([0, 0]);
out
}
#[test]
fn test_import_sba_archive() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
let path = source_dir.path().join("Mathe.sba");
let archive = zip(&[
("main.sbn2", encode(&sample())),
("main.sbn2.0", png()),
("main.sbn2.1", sample_pdf()),
]);
std::fs::write(&path, archive).unwrap();
let (notebook, _, _, report) =
create_notebook_from_saber(temp_dir.path(), "Mathe", &path).unwrap();
check(&notebook, &report);
}
#[test]
fn test_old_json_files() {
let temp_dir = tempfile::tempdir().unwrap();
let source_dir = tempfile::tempdir().unwrap();
// Version 7: strokes outside the pages, points as {x, y, p}
let path = source_dir.path().join("Alt.sbn");
std::fs::write(
&path,
r#"{"v": 7, "z": [{"w": 1000, "h": 1400}, {"w": 1000, "h": 1400}],
"s": [{"i": 1, "p": [{"x": 0, "y": 0, "p": 0.5}, {"x": 100, "y": 0, "p": 0.5}]}]}"#,
)
.unwrap();
let (notebook, _, _, _) =
create_notebook_from_saber(temp_dir.path(), "Alt", &path).unwrap();
assert_eq!(notebook.pages[0].strokes.len(), 0);
assert_eq!(notebook.pages[1].strokes.len(), 1);
// The very first format: just a list of strokes
let path = source_dir.path().join("Uralt.sbn");
std::fs::write(&path, "[]").unwrap();
assert!(create_notebook_from_saber(temp_dir.path(), "Uralt", &path).is_err());
}
}
+5
View File
@@ -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);
+152 -4
View File
@@ -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, &notebook, &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();
+74 -7
View File
@@ -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,
}
}
}
@@ -669,11 +676,17 @@ impl UiState {
self.open_file_picker(PickerKind::Image);
}
/// Opens the file picker "Import" (PDF, Xournal++, Rnote) in the last used folder.
/// Opens the file picker "Import" (PDF, Xournal++, Rnote, Saber) in the last used folder.
pub fn open_import_picker(&mut self) {
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();
@@ -2831,12 +2844,23 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
t!("Importieren"),
t!("📄 Importieren"),
"📄",
t!("Keine Ordner, PDF-, Xournal++- oder Rnote-Dateien"),
t!("Keine Ordner oder importierbaren Dateien (PDF, Xournal++, Rnote, Saber)"),
t!(
"Die Datei wird als neues Notizbuch angelegt. PDF: eine Seite je PDF-Seite, \
beim Export liegen deine Notizen über den Originalseiten. \
Xournal++ (.xopp, .xoj) und Rnote (.rnote): Striche, Formen, Texte und Bilder \
werden übernommen, bei Xournal++ auch PDF-Hintergründe."
Xournal++ (.xopp, .xoj), Rnote (.rnote) und Saber (.sbn2, .sba): Striche, Formen, \
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."
),
),
};
@@ -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;
}
@@ -4197,8 +4234,8 @@ pub fn render_ui(
state.open_image_picker();
}
if ui
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++, Rnote) …")))
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++ und Rnote als neues Notizbuch öffnen und darauf schreiben"))
.add_sized([ui.available_width(), 30.0], egui::Button::new(t!("📄 Importieren (PDF, Xournal++, Rnote, Saber) …")))
.on_hover_text(t!("Vorlesungsfolien, Übungsblätter, Formulare oder Notizbücher aus Xournal++, Rnote und Saber als neues Notizbuch öffnen und darauf schreiben"))
.clicked()
{
state.open_import_picker();
@@ -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| {