Compare commits

..
12 Commits
Author SHA1 Message Date
haraldandClaude Fable 5.2 07f28df03f chore(release): bump version to 0.45.5
Build & Publish Release / Android APK (push) Successful in 7m2s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m40s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 18:39:09 +01:00
haraldandClaude Fable 5.2 cde1fc8e91 fix(canvas): draw very full pages from several vertex buffers
The finished strokes of a page went into one vertex buffer. Beyond the
buffer size limit of the device (256 MiB, about 11 million vertices,
reachable on an endless page) creating it is a validation error that ends
the app. The strokes are now split into as many buffers as needed, without
cutting a stroke; the neighbor pages of the continuous view too.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 18:31:05 +01:00
haraldandClaude Fable 5.2 b1e725ed52 chore(release): bump version to 0.45.4
Build & Publish Release / Android APK (push) Successful in 7m0s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 14m59s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 18:00:08 +01:00
haraldandClaude Fable 5.2 406e7701ce fix(storage): merge the manifest instead of overwriting a change from another device
A manifest changed by a sync (e.g. a page added on another device) could
be overwritten by the next autosave before disk_watch took it over, which
dropped that page from the list. save_manifest now notices that the file
is not the one the notebook in memory is based on and merges the two, with
that base as common ancestor; if there is nothing new to save here, the
other version simply stays.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 17:56:45 +01:00
haraldandClaude Fable 5.2 6e67303ad3 chore(release): bump version to 0.45.3
Build & Publish Release / Android APK (push) Successful in 7m24s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m4s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 17:29:06 +01:00
haraldandClaude Fable 5.2 a8b0e070b0 fix(history): no duplicate strokes from undo after make-space
Making space moves the strokes of the following pages, some onto another
page, but those pages kept their undo histories. Undoing and redoing
"stroke drawn" there added the stroke a second time while it already sat
on the other page. The histories of the other pages are now set aside
while the shift is applied and come back when it is undone.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 17:26:03 +01:00
haraldandClaude Fable 5.2 544e004106 chore(release): bump version to 0.45.2
Build & Publish Release / Android APK (push) Successful in 7m14s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 13m13s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 16:51:10 +01:00
haraldandClaude Fable 5.2 36bb77c299 fix(storage): keep a page changed by another device when saving over it
A change from another device could reach the page file while a stroke was
being drawn; the check that takes it over waits until everything is saved,
so the next autosave overwrote it. save_page now notices that the file is
not what this process last read or wrote: their version is kept as a
conflict copy (which becomes a page of its own), or simply stays if there
is nothing new to save here.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 16:46:06 +01:00
haraldandClaude Fable 5.2 41323950dc chore(release): bump version to 0.45.1
Build & Publish Release / Android APK (push) Successful in 7m25s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 14m9s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 16:22:47 +01:00
haraldandClaude Fable 5.2 e95b03e316 fix: smaller fixes from the project review
- settings: one invalid value in ui.json no longer resets all settings
  (including the storage location), only that value
- sync: a worker whose pass outlives a stop/start ends afterwards
- updater: only install packages whose signed file name carries a newer
  version than the running one
- canvas: grow the stroke vertex buffer at most up to the device limit

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 15:49:11 +01:00
haraldandClaude Fable 5.2 a22f081115 fix: robustness fixes from the project review
- autosave: a manifest that failed to save is retried with backoff instead
  of waiting for the next edit
- import: NaN and infinite numbers from broken files no longer reach the
  page file (they were stored as null and made the page unreadable)
- import: SVG images cannot load local files, Rnote page count is capped
- pdf: a panic in the PDF library fails one page or import instead of
  ending the render thread or the app
- storage: remove the temporary file when writing it fails

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 15:44:51 +01:00
haraldandClaude Fable 5.2 c2652ea05b fix: data-safety fixes from the project review
- autosave: pages a failed flush could not write no longer end up in the
  notebook that is opened next; they go to the recovery folder
- app: finish an open text edit and save before switching notebooks, and
  stay in the notebook when saving fails
- trash: purge by the deletion time in the entry name, not by the folder's
  modification time, which survives the move into .trash
- notebooks are identified by their folder name, so a copied folder is a
  notebook of its own instead of an alias of the original
- manifest: fall back to manifest.json.bak only for a damaged file, not for
  a newer format version or an I/O error
- strokes keep their width after scaling and a restart
- PDF export draws shapes with their stroke width instead of a fixed 1.5 pt
- a first page that fails to load shows the warning; a first page that was
  never saved is an empty page; make-space refuses read-only pages

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 15:39:54 +01:00
23 changed files with 1755 additions and 264 deletions

No files matched your search

Generated
+1 -1
View File
@@ -1540,7 +1540,7 @@ dependencies = [
[[package]]
name = "ink-notes"
version = "0.45.0"
version = "0.45.5"
dependencies = [
"ab_glyph",
"android_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ink-notes"
version = "0.45.0"
version = "0.45.5"
edition = "2021"
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes"
+1 -1
View File
@@ -73,7 +73,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
- **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.
- **Speicherort** (☰-Menü, „Speicherort“): Liegen die Notizbücher in einem Ordner, den **Nextcloud, Google Drive, Syncthing oder Dropbox** abgleichen, sind sie auf allen Geräten verfügbar. Die Notizbücher liegen im Unterordner `ink-notes`; ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert (die alten bleiben erhalten). Änderungen von einem anderen Gerät erscheinen nach wenigen Sekunden ohne neu zu öffnen – aber nur, solange lokal alles gespeichert ist, damit nichts überschrieben wird. Legt der Sync-Client eine Konfliktkopie einer Seite an, wird sie als eigene Seite („Konfliktkopie: …“) übernommen statt verloren zu gehen. Dasselbe passiert, wenn eine Änderung genau dann eintrifft, während du auf dieser Seite schreibst: Deine Fassung wird gespeichert, die andere bleibt als Konfliktkopie erhalten. Ist ein Notizbuch gerade auf einem anderen Gerät geöffnet, warnt die App beim Öffnen.
- **Nextcloud eingebaut** (☰-Menü, „Nextcloud“; auch unter Android und ohne Sync-Client): Adresse der eigenen Nextcloud eingeben – auch ein privater Server, in einem Unterordner (`example.org/nextcloud`) oder mit eigenem Port. Die Anmeldung läuft im Browser (auch mit Zwei-Faktor-Anmeldung); die App bekommt ein eigenes App-Passwort, das sich in Nextcloud unter Einstellungen → Sicherheit jederzeit widerrufen lässt, und sieht das echte Passwort nie. Die Notizbücher werden alle 30 Sekunden und kurz nach dem Speichern mit dem Ordner `ink-notes` in den Nextcloud-Dateien abgeglichen; offline arbeitet die App normal weiter. Wurde eine Seite auf zwei Geräten geändert, bleiben beide Fassungen erhalten (die andere als eigene Seite); gelöschte Notizbücher und Seiten landen im Papierkorb (lokal `.trash`, auf dem Server dessen Papierkorb). Einem selbst signierten Zertifikat vertraut die App, nachdem du seinen Fingerabdruck bestätigt hast; Adressen mit `http://` gehen nur mit Warnung, weil das App-Passwort dann im Netz lesbar wäre. „Trennen“ beendet den Abgleich und widerruft das App-Passwort; die Notizbücher bleiben erhalten. Bitte entweder den eingebauten Abgleich oder den Ordner eines Sync-Clients als Speicherort verwenden, nicht beides für dieselben Notizbücher.
### Export
+1 -1
View File
@@ -73,7 +73,7 @@ The interface is available in English and German; ink-notes follows the system l
- Every file is first written completely to a temporary file and then replaced – a crash while saving never leaves a half-written file.
- **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.
- **Storage location** (☰ menu, "Storage location"): put the notebooks into a folder that **Nextcloud, Google Drive, Syncthing or Dropbox** keep in sync, and they are available on all your devices. The notebooks are kept in the subfolder `ink-notes`; if it is still empty, the existing notebooks are copied there (the old ones stay). Changes arriving from another device show up within a few seconds without reopening – but only while everything local is saved, so nothing gets overwritten. If the sync client makes a conflict copy of a page, it is added as a page of its own („Conflict copy: …“) instead of getting lost. The same happens when a change arrives just while you are writing on that page: your version is saved and theirs is kept as a conflict copy. Opening a notebook that is open on another device shows a warning.
- **Nextcloud built in** (☰ menu, "Nextcloud"; also on Android and without a sync client): enter the address of your Nextcloud – also a private server, in a subfolder (`example.org/nextcloud`) or with its own port. Signing in happens in the browser (also with two-factor sign-in); the app gets an app password of its own, which you can revoke in Nextcloud under Settings → Security at any time, and never sees your real password. The notebooks are synced with the folder `ink-notes` in your Nextcloud files every 30 seconds and shortly after saving; the app keeps working offline. If a page was changed on two devices, both versions are kept (the other one as a page of its own); deleted notebooks and pages go into the trash (locally `.trash`, on the server its trash bin). A self-signed certificate is trusted once you have confirmed its fingerprint; addresses with `http://` only work with a warning, since the app password would be readable on the network. "Disconnect" stops syncing and revokes the app password; the notebooks stay. Use either the built-in sync or a sync client's folder as storage location, not both for the same notebooks.
### Export
+2
View File
@@ -177,6 +177,8 @@ Liegen die Notizbücher in einem Ordner, den Nextcloud, Google Drive, Syncthing
- `.opened_by.json` (`{"device": "<Rechnername>", "since": <Unix-Zeit>}`) wird beim Öffnen eines Notizbuchs geschrieben und beim Schließen entfernt. Eine Markierung eines anderen Geräts führt zu einer Warnung; die Sperrdatei `.lock` schützt nur vor einer zweiten Instanz auf demselben Rechner.
- 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.
- Bevor die App eine Seitendatei überschreibt, die sich seit ihrem letzten Lesen oder Schreiben geändert hat, sichert sie den vorgefundenen Inhalt als `page_<id> (conflict <Unix-Zeit>).json` – außer sie hat selbst nichts geändert (dann bleibt die Datei, wie sie ist).
- Dasselbe gilt für `manifest.json`, nur werden die beiden Fassungen zusammengeführt statt kopiert: Auf beiden Seiten hinzugefügte oder entfernte Seiten, Titel und Zähler werden kombiniert, mit dem zuletzt von der App gelesenen oder geschriebenen Manifest als gemeinsamem Vorgänger.
### Neuere Versionen
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.
+2
View File
@@ -177,6 +177,8 @@ If the notebooks are kept in a folder synced by Nextcloud, Google Drive, Syncthi
- `.opened_by.json` (`{"device": "<host name>", "since": <unix time>}`) is written when a notebook is opened and removed when it is closed. A marker from another device leads to a warning; the lock file `.lock` only protects against a second instance on the same computer.
- 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`.
- Before the app overwrites a page file that changed since it last read or wrote it, it keeps the content found there as `page_<id> (conflict <unix time>).json`, unless it has no changes of its own (then the file is left as it is).
- The same goes for `manifest.json`, except that the two versions are merged instead of copied: pages added or removed on either side, titles and counters are combined, with the manifest the app last read or wrote as common ancestor.
### Newer versions
Files with a higher `format_version` than supported are not opened – this way an older app version cannot lose content it does not know.
+25
View File
@@ -84,6 +84,31 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.45.5" date="2026-11-14">
<description>
<p>Extremely full pages, for example on an endless notebook, no longer end the app when they are opened.</p>
</description>
</release>
<release version="0.45.4" date="2026-11-14">
<description>
<p>A page added on another device is no longer dropped from the notebook when a change is saved here at the same moment.</p>
</description>
</release>
<release version="0.45.3" date="2026-11-14">
<description>
<p>Undo on a following page after "make space" no longer adds a stroke a second time.</p>
</description>
</release>
<release version="0.45.2" date="2026-11-14">
<description>
<p>A change from another device that arrives while you are writing on the same page is no longer overwritten; it is kept as a conflict copy.</p>
</description>
</release>
<release version="0.45.1" date="2026-11-14">
<description>
<p>Fixes from a review of the whole project, mainly for the safety of your notes: switching notebooks, the trash, copied notebook folders, scaled strokes and imports of broken files.</p>
</description>
</release>
<release version="0.45.0" date="2026-11-14">
<description>
<p>Own colors can be saved under the presets of the color menus, and copy and paste in text fields works with other apps.</p>
+25
View File
@@ -84,6 +84,31 @@
</branding>
<content_rating type="oars-1.1" />
<releases>
<release version="0.45.5" date="2026-11-14">
<description>
<p>Extremely full pages, for example on an endless notebook, no longer end the app when they are opened.</p>
</description>
</release>
<release version="0.45.4" date="2026-11-14">
<description>
<p>A page added on another device is no longer dropped from the notebook when a change is saved here at the same moment.</p>
</description>
</release>
<release version="0.45.3" date="2026-11-14">
<description>
<p>Undo on a following page after "make space" no longer adds a stroke a second time.</p>
</description>
</release>
<release version="0.45.2" date="2026-11-14">
<description>
<p>A change from another device that arrives while you are writing on the same page is no longer overwritten; it is kept as a conflict copy.</p>
</description>
</release>
<release version="0.45.1" date="2026-11-14">
<description>
<p>Fixes from a review of the whole project, mainly for the safety of your notes: switching notebooks, the trash, copied notebook folders, scaled strokes and imports of broken files.</p>
</description>
</release>
<release version="0.45.0" date="2026-11-14">
<description>
<p>Own colors can be saved under the presets of the color menus, and copy and paste in text fields works with other apps.</p>
+5
View File
@@ -3,6 +3,11 @@
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
### Highlights:
- **Extremely full pages**: A page with so many strokes that they no longer fit into one graphics buffer (possible on an endless notebook) ended the app when it was opened. Such pages are now drawn from several buffers
- **Sync while writing, page list**: If another device added a page just while you were writing, saving here could drop that page from the notebook's page list. The two page lists are now merged: pages added or removed on either device, titles and counters are combined
- **Undo after "make space"**: Making space moves the content of the following pages, sometimes onto the next page. Undoing and redoing an older step on such a page could then add a stroke a second time. The older steps of the following pages are now set aside while the space is there and come back when making space is undone
- **Sync while writing**: If a change from another device reached a page just while you were writing on it, saving your stroke overwrote that change. Now your version is saved and theirs is kept as a page of its own ("Conflict copy: …"), as for other sync conflicts; if you changed nothing on the page, their version is simply taken over
- **Safer notes**: A review of the whole project fixed a number of rare errors. Notebooks deleted after not being edited for 30 days were removed from the trash at the next start instead of 30 days later. If saving failed while switching notebooks, pages could end up in the other notebook, and an open text field moved along; the app now saves first and stays in the notebook if that fails. A copied notebook folder is a notebook of its own instead of opening the original. Strokes keep their width after scaling and a restart, shapes are exported to PDF with their line width, and a page that could not be loaded always shows its warning. Importing broken Rnote, Saber, SVG or PDF files can no longer make a page unreadable or stop the PDF display. One invalid value in the settings file no longer resets all settings, and updates are only installed if the signed package is newer than the running version
- **My colors**: Under the presets of the color menus (pen, highlighter, text), "➕ Save color" keeps the current shade in a row of your own colors – up to 12, shared by all three tools and kept across restarts; 🗑 removes the selected one (Issue #56)
- **Copy and paste in text fields**: Ctrl+C, Ctrl+X and Ctrl+V in a text field now use the system clipboard, so text moves between ink-notes and other apps. Before, they only worked within the app (Issue #57)
- **Seven pens**: The toolbar now has seven pens instead of four (new: violet, orange, gray). Each remembers its color and width; tap the selected pen again to change them. Older versions keep reading the first four pens (Issue #56)
+191 -113
View File
@@ -125,9 +125,16 @@ fn space_release(
return;
};
if let Some(run) = notebook.space_run(drag.page_index) {
for index in run {
for index in run.clone() {
let _ = storage::ensure_page_loaded(storage_dir, notebook, index);
}
if notebook.pages[run].iter().any(|p| p.is_readonly()) {
ui_state.set_export_message(
t!("Platz schaffen nicht möglich: Eine der Seiten konnte nicht geladen werden")
.to_string(),
);
return;
}
}
let dy = drag.clamped_dy();
if let Some(changed) = notebook.make_space(drag.page_index, drag.y, dy) {
@@ -1017,6 +1024,32 @@ fn finish_text_edit(
canvas.sync_page(notebook.current_page_mut());
}
/// Before another notebook takes the place of the open one: finishes a running text edit
/// (it belongs to this notebook, page ids repeat in every notebook) and saves everything.
/// Returns `false` and tells the user when saving failed; the notebook then has to stay open,
/// otherwise the unsaved changes would be lost.
fn save_before_switch(
canvas: &mut CanvasState,
notebook: &mut Notebook,
manifest: &mut NotebookManifest,
autosave: &mut AutosaveController,
ui_state: &mut UiState,
) -> bool {
finish_text_edit(canvas, notebook, manifest, autosave, ui_state);
match autosave.flush() {
Ok(()) => true,
Err(err) => {
let msg = err.messages.join(", ");
eprintln!("[Autosave] Notebook not switched, saving failed: {msg}");
ui_state.set_export_message(t!(
"Nicht gewechselt, Speichern fehlgeschlagen: {err}",
err = msg
));
false
}
}
}
/// Text-Werkzeug: Tippen auf das Blatt beendet eine laufende Bearbeitung und öffnet das
/// Textfeld unter `page_pt` zum Bearbeiten – oder ein neues an dieser Stelle.
fn text_tool_click(
@@ -4853,11 +4886,18 @@ pub fn run_with(
if id == manifest.id {
ui_state.is_document_menu_open = false;
window.request_redraw();
} else if !save_before_switch(
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
&mut ui_state,
) {
window.request_redraw();
} else {
let new_notebook_dir = storage_base_dir.join(&id);
match storage::load_notebook(&new_notebook_dir) {
Ok((loaded_nb, loaded_manifest)) => {
let _ = autosave.flush();
match storage::NotebookLock::acquire(
&new_notebook_dir,
) {
@@ -4920,71 +4960,78 @@ pub fn run_with(
}
}
UiAction::ImportFile(path) => {
// Neben das gerade geöffnete Notizbuch
let folder =
ui_state.library.folder_of(&manifest.id).to_string();
let title = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "Import".to_string());
match crate::import::import_file(
&storage_base_dir,
&title,
&path,
if save_before_switch(
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
&mut ui_state,
) {
Ok((new_nb, new_manifest, new_dir, report)) => {
let id = new_manifest.id.clone();
change_library(
&storage_base_dir,
&mut ui_state,
|library| library.move_notebook(&id, &folder),
);
let _ = autosave.flush();
_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.reset_view();
canvas.sync_page(notebook.current_page_mut());
update_window_title(
&window,
&notebook,
&manifest,
ui_state.active_tool,
autosave.status(),
);
let mut message = t!(
"'{title}' importiert ({count} Seiten)",
title = manifest.title,
count = notebook.pages.len()
);
if let Some(pdf) = &report.missing_pdf {
message.push_str(&t!(
", PDF-Hintergrund nicht gefunden: {file}",
file = pdf
// Neben das gerade geöffnete Notizbuch
let folder =
ui_state.library.folder_of(&manifest.id).to_string();
let title = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "Import".to_string());
match crate::import::import_file(
&storage_base_dir,
&title,
&path,
) {
Ok((new_nb, new_manifest, new_dir, report)) => {
let id = new_manifest.id.clone();
change_library(
&storage_base_dir,
&mut ui_state,
|library| library.move_notebook(&id, &folder),
);
_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.reset_view();
canvas.sync_page(notebook.current_page_mut());
update_window_title(
&window,
&notebook,
&manifest,
ui_state.active_tool,
autosave.status(),
);
let mut message = t!(
"'{title}' importiert ({count} Seiten)",
title = manifest.title,
count = notebook.pages.len()
);
if let Some(pdf) = &report.missing_pdf {
message.push_str(&t!(
", PDF-Hintergrund nicht gefunden: {file}",
file = pdf
));
}
if report.skipped > 0 {
message.push_str(&t!(
", {count} Elemente übersprungen",
count = report.skipped
));
}
ui_state.set_export_message(message);
}
Err(err) => {
eprintln!("[Import] '{}': {err}", path.display());
ui_state.set_export_message(t!(
"Nicht importiert: {err}",
err = err
));
}
if report.skipped > 0 {
message.push_str(&t!(
", {count} Elemente übersprungen",
count = report.skipped
));
}
ui_state.set_export_message(message);
}
Err(err) => {
eprintln!("[Import] '{}': {err}", path.display());
ui_state.set_export_message(t!(
"Nicht importiert: {err}",
err = err
));
}
}
window.request_redraw();
@@ -4994,54 +5041,66 @@ pub fn run_with(
folder,
layout,
} => {
let _ = autosave.flush();
match storage::create_new_notebook(&storage_base_dir, &title) {
Ok((mut new_nb, mut new_manifest, new_dir)) => {
let id = new_manifest.id.clone();
new_manifest.layout = Some(layout);
if layout == NotebookLayout::Endless {
new_nb.make_endless();
if save_before_switch(
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
&mut ui_state,
) {
match storage::create_new_notebook(
&storage_base_dir,
&title,
) {
Ok((mut new_nb, mut new_manifest, new_dir)) => {
let id = new_manifest.id.clone();
new_manifest.layout = Some(layout);
if layout == NotebookLayout::Endless {
new_nb.make_endless();
}
change_library(
&storage_base_dir,
&mut ui_state,
|library| library.move_notebook(&id, &folder),
);
_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;
autosave.mark_dirty(&notebook, &mut manifest);
canvas.reset_view();
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);
ui_state.is_document_menu_open = false;
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!(
"Dokument '{title}' erstellt",
title = manifest.title
));
window.request_redraw();
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Dokument '{title}' nicht erstellen: {err}");
ui_state.set_export_message(t!(
"Fehler beim Erstellen: {err}",
err = err
));
}
change_library(
&storage_base_dir,
&mut ui_state,
|library| library.move_notebook(&id, &folder),
);
_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;
autosave.mark_dirty(&notebook, &mut manifest);
canvas.reset_view();
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);
ui_state.is_document_menu_open = false;
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!(
"Dokument '{title}' erstellt",
title = manifest.title
));
window.request_redraw();
}
Err(err) => {
eprintln!("[Dokument-Fehler] Konnte Dokument '{title}' nicht erstellen: {err}");
ui_state.set_export_message(t!(
"Fehler beim Erstellen: {err}",
err = err
));
}
} else {
window.request_redraw();
}
}
UiAction::RenameNotebook { id, new_title } => {
@@ -5111,7 +5170,21 @@ pub fn run_with(
.unwrap_or_else(|| id.clone());
if is_current {
let _ = autosave.flush();
finish_text_edit(
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
&mut ui_state,
);
if let Err(err) = autosave.flush() {
eprintln!(
"[Autosave] Not saved before deleting: {}",
err.messages.join(", ")
);
}
// Nothing may be written into the folder once it is in the trash
autosave.abandon_pending();
drop(_notebook_lock.take());
}
@@ -5423,8 +5496,13 @@ pub fn run_with(
ui_state.set_export_message(
t!("Die Notizbücher liegen bereits dort").to_string(),
);
} else {
let _ = autosave.flush();
} else if save_before_switch(
&mut canvas,
&mut notebook,
&mut manifest,
&mut autosave,
&mut ui_state,
) {
match storage::copy_storage(&storage_base_dir, &new_base) {
Ok(copied) => {
// Release lock and open marker in the old location
+137 -6
View File
@@ -228,6 +228,8 @@ pub struct AutosaveController {
pending_pages: HashMap<u64, PageData>,
pending_manifest: Option<NotebookManifest>,
retries: HashMap<u64, PageRetry>,
/// Backoff of the manifest after a failed write
manifest_retry: Option<PageRetry>,
last_errors: Vec<String>,
status: AutosaveStatus,
in_flight_count: usize,
@@ -281,6 +283,7 @@ impl AutosaveController {
pending_pages: HashMap::new(),
pending_manifest: None,
retries: HashMap::new(),
manifest_retry: None,
last_errors: Vec::new(),
status: AutosaveStatus::Saved,
in_flight_count: 0,
@@ -428,16 +431,25 @@ impl AutosaveController {
retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30));
}
},
AutosaveResponse::ManifestSaved { result } => {
if let Err((err_msg, failed_manifest)) = result {
AutosaveResponse::ManifestSaved { result } => match result {
Ok(()) => self.manifest_retry = None,
Err((err_msg, failed_manifest)) => {
eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}");
self.status = AutosaveStatus::Error;
self.last_errors.push(t!("Manifest: {msg}", msg = err_msg));
if self.pending_manifest.is_none() {
self.pending_manifest = Some(*failed_manifest);
}
// Dispatching reset the debounce timers, so without a retry time of
// its own the manifest would wait for the next edit
let retry = self.manifest_retry.get_or_insert(PageRetry {
next_retry: now,
backoff: Duration::from_secs(1),
});
retry.next_retry = now + retry.backoff;
retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30));
}
}
},
}
if self.in_flight_count == 0
@@ -468,7 +480,12 @@ impl AutosaveController {
.get(id)
.map(|r| now >= r.next_retry)
.unwrap_or(false)
});
})
|| (self.pending_manifest.is_some()
&& self
.manifest_retry
.as_ref()
.is_some_and(|r| now >= r.next_retry));
if max_wait_hit || debounce_hit || retry_ready {
self.dispatch_pending();
@@ -515,7 +532,11 @@ impl AutosaveController {
}
}
if let Some(manifest) = self.pending_manifest.take() {
let manifest_ready = self
.manifest_retry
.as_ref()
.is_none_or(|r| now >= r.next_retry);
if let Some(manifest) = self.pending_manifest.take_if(|_| manifest_ready) {
let cmd = AutosaveCommand::SaveManifest {
notebook_dir: self.notebook_dir.clone(),
manifest: manifest.clone(),
@@ -546,6 +567,7 @@ impl AutosaveController {
}
// Backoff für den Flush ignorieren, damit sofort geschrieben wird
self.retries.clear();
self.manifest_retry = None;
if !self.pending_pages.is_empty() || self.pending_manifest.is_some() {
self.dispatch_pending();
@@ -618,11 +640,45 @@ impl AutosaveController {
}
/// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel).
///
/// Whatever the flush could not write belongs to the old notebook and must never be
/// written into the new folder, see `abandon_pending`.
pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) {
let _ = self.flush();
if let Err(err) = self.flush() {
eprintln!(
"[Autosave] Unsaved changes of '{}' dropped on notebook switch: {}",
self.notebook_dir.display(),
err.messages.join(", ")
);
}
self.abandon_pending();
self.notebook_dir = new_notebook_dir;
}
/// Gives up on everything still waiting to be written: it goes into the recovery folder
/// and leaves the queue. For the moment the notebook folder stops being the target
/// (switch to another notebook, notebook moved to the trash).
pub fn abandon_pending(&mut self) {
if !self.pending_pages.is_empty() {
match self.export_recovery(None) {
Ok(files) => {
for file in files {
eprintln!("[Autosave] Recovery copy: {}", file.display());
}
}
Err(e) => eprintln!("[Autosave] Recovery copy failed: {e}"),
}
}
self.pending_pages.clear();
self.pending_manifest = None;
self.retries.clear();
self.manifest_retry = None;
self.last_errors.clear();
self.first_change_time = None;
self.last_change_time = None;
self.status = AutosaveStatus::Saved;
}
/// Beendet den Hintergrund-Worker ordnungsgemäß nach einem Flush.
pub fn shutdown(&mut self) -> Result<(), AutosaveError> {
let res = self.flush();
@@ -739,6 +795,7 @@ pub mod tests {
/// Fake-PageStore zum Simulieren von I/O-Fehlern und Panics.
pub struct FakePageStore {
pub fail_page_writes: Mutex<usize>,
pub fail_manifest_writes: Mutex<usize>,
pub panic_on_page: Mutex<bool>,
pub page_writes: Arc<AtomicUsize>,
pub manifest_writes: Arc<AtomicUsize>,
@@ -748,6 +805,7 @@ pub mod tests {
pub fn new() -> Self {
Self {
fail_page_writes: Mutex::new(0),
fail_manifest_writes: Mutex::new(0),
panic_on_page: Mutex::new(false),
page_writes: Arc::new(AtomicUsize::new(0)),
manifest_writes: Arc::new(AtomicUsize::new(0)),
@@ -782,11 +840,84 @@ pub mod tests {
_dir: &Path,
_manifest: &NotebookManifest,
) -> Result<(), StorageError> {
let mut fails = self.fail_manifest_writes.lock().unwrap();
if *fails > 0 {
*fails -= 1;
return Err(StorageError::InvalidManifest(
"Simulierter Store-Fehler".to_string(),
));
}
self.manifest_writes.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[test]
fn test_switch_drops_pages_the_flush_could_not_write() {
let clock = Arc::new(MockClock::new(Instant::now()));
let store = Arc::new(FakePageStore::new());
*store.fail_page_writes.lock().unwrap() = 1;
let recovery = tempfile::tempdir().unwrap();
std::env::set_var("XDG_DATA_HOME", recovery.path());
let mut controller = AutosaveController::with_options(
PathBuf::from("/tmp/old_nb"),
Duration::from_millis(100),
Duration::from_secs(10),
Arc::clone(&store) as Arc<dyn PageStore>,
Arc::clone(&clock) as Arc<dyn Clock>,
);
let page = Page::new(7, PageBackground::Grid5mm);
let manifest = NotebookManifest::new("old_nb", "Old");
controller.mark_page_dirty(&page, &manifest);
controller.switch_notebook_dir(PathBuf::from("/tmp/new_nb"));
let manifests_before = store.manifest_writes.load(Ordering::SeqCst);
// Nothing of the old notebook may reach the new folder
assert!(controller.flush().is_ok());
assert_eq!(store.page_writes.load(Ordering::SeqCst), 0);
assert_eq!(
store.manifest_writes.load(Ordering::SeqCst),
manifests_before
);
assert_eq!(controller.status(), AutosaveStatus::Saved);
}
#[test]
fn test_failed_manifest_is_retried_without_another_edit() {
let clock = Arc::new(MockClock::new(Instant::now()));
let store = Arc::new(FakePageStore::new());
*store.fail_manifest_writes.lock().unwrap() = 1;
let mut controller = AutosaveController::with_options(
PathBuf::from("/tmp/manifest_retry"),
Duration::from_millis(100),
Duration::from_secs(10),
Arc::clone(&store) as Arc<dyn PageStore>,
Arc::clone(&clock) as Arc<dyn Clock>,
);
let manifest = NotebookManifest::new("nb", "Retry");
controller.mark_manifest_dirty(&manifest);
clock.advance(Duration::from_millis(200));
controller.update();
controller.sync_worker();
assert_eq!(controller.update(), AutosaveStatus::Error);
assert_eq!(store.manifest_writes.load(Ordering::SeqCst), 0);
// Not before the backoff has passed
controller.update();
controller.sync_worker();
controller.update();
assert_eq!(store.manifest_writes.load(Ordering::SeqCst), 0);
clock.advance(Duration::from_secs(2));
controller.update();
controller.sync_worker();
assert_eq!(controller.update(), AutosaveStatus::Saved);
assert_eq!(store.manifest_writes.load(Ordering::SeqCst), 1);
}
#[test]
fn test_debounce_bundles_changes() {
let clock = Arc::new(MockClock::new(Instant::now()));
+349 -69
View File
@@ -160,8 +160,120 @@ struct NeighborPage {
/// Papier (Dreiecke) und Muster (Rechteck für `fs_pattern`)
paper_buffer: wgpu::Buffer,
pattern_buffer: Option<wgpu::Buffer>,
vertex_buffer: Option<wgpu::Buffer>,
stroke_ranges: Vec<Range<u32>>,
strokes: StrokeBuffers,
}
/// Part of a page's stroke vertices that goes into one GPU buffer.
#[derive(Debug, Clone, PartialEq)]
struct ChunkPlan {
/// Position in the page's vertex cache
vertices: Range<usize>,
/// The strokes in it, relative to the start of the chunk
strokes: Vec<Range<u32>>,
}
/// Splits the strokes of a page (ranges in its vertex cache) into runs of at most
/// `max_vertices`, without cutting a stroke. Nearly every page is a single run; only a huge
/// one, in practice an endless page, needs more than one buffer. A single stroke larger than
/// a buffer cannot be drawn and is left out.
fn plan_stroke_chunks(stroke_ranges: &[Range<u32>], max_vertices: usize) -> Vec<ChunkPlan> {
let mut chunks: Vec<ChunkPlan> = Vec::new();
for range in stroke_ranges {
let (start, end) = (range.start as usize, range.end as usize);
if end <= start || end - start > max_vertices {
continue;
}
let fits = chunks
.last()
.is_some_and(|c| c.vertices.end == start && end - c.vertices.start <= max_vertices);
if !fits {
chunks.push(ChunkPlan {
vertices: start..start,
strokes: Vec::new(),
});
}
if let Some(chunk) = chunks.last_mut() {
let offset = (start - chunk.vertices.start) as u32;
chunk.strokes.push(offset..offset + (end - start) as u32);
chunk.vertices.end = end;
}
}
chunks
}
struct StrokeChunk {
buffer: wgpu::Buffer,
/// In vertices
capacity: usize,
strokes: Vec<Range<u32>>,
}
/// The finished strokes of a page on the GPU, in as many vertex buffers as the size limit
/// of the device requires (one buffer beyond the limit is a validation error that ends the
/// app).
#[derive(Default)]
struct StrokeBuffers {
chunks: Vec<StrokeChunk>,
/// Chunks holding the current content; further ones are kept for their capacity
used: usize,
}
impl StrokeBuffers {
/// Uploads the vertex cache of a page. `spare` leaves room for more strokes (at least
/// that many vertices and twice the need), so drawing does not reallocate every time.
fn upload(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
vertices: &[InkVertex],
stroke_ranges: &[Range<u32>],
spare: Option<usize>,
) {
let vertex_size = std::mem::size_of::<InkVertex>();
let max_vertices = (device.limits().max_buffer_size / vertex_size as u64) as usize;
let mut used = 0;
for part in plan_stroke_chunks(stroke_ranges, max_vertices) {
let Some(data) = vertices.get(part.vertices.clone()) else {
continue;
};
if self
.chunks
.get(used)
.is_none_or(|c| c.capacity < data.len())
{
let capacity = match spare {
Some(initial) => grown_vertex_capacity(data.len(), initial, max_vertices),
None => data.len(),
};
let chunk = StrokeChunk {
buffer: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Strokes Vertex Buffer"),
size: (capacity * vertex_size) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
capacity,
strokes: Vec::new(),
};
if used < self.chunks.len() {
self.chunks[used] = chunk;
} else {
self.chunks.push(chunk);
}
}
let chunk = &mut self.chunks[used];
queue.write_buffer(&chunk.buffer, 0, bytemuck::cast_slice(data));
chunk.strokes = part.strokes;
used += 1;
}
self.used = used;
// Keep the first buffer for the next page, give the memory of the others back
self.chunks.truncate(used.max(1));
}
fn in_use(&self) -> &[StrokeChunk] {
&self.chunks[..self.used.min(self.chunks.len())]
}
}
pub struct CanvasState {
@@ -238,10 +350,7 @@ pub struct CanvasState {
pub spline_config: SplineConfig,
// Pipeline-Trennung: Abgeschlossene Striche der aktuellen Seite
pub committed_vertex_buffer: wgpu::Buffer,
pub committed_vertex_capacity: usize,
pub committed_vertex_count: u32,
pub committed_strokes: Vec<Range<u32>>,
committed: StrokeBuffers,
// Eingefügte Bilder der aktuellen Seite
image_layer: crate::image_layer::ImageLayer,
@@ -575,13 +684,6 @@ impl CanvasState {
mapped_at_creation: false,
});
let committed_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Committed Strokes Vertex Buffer"),
size: (Self::INITIAL_COMMITTED_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let image_layer = crate::image_layer::ImageLayer::new(
&device,
&shader,
@@ -641,10 +743,7 @@ impl CanvasState {
max_radius: 3.75, // 0.375 mm bei vollem Druck
samples_per_segment: 16,
},
committed_vertex_buffer,
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
committed_vertex_count: 0,
committed_strokes: Vec::with_capacity(128),
committed: StrokeBuffers::default(),
image_layer,
neighbors: Vec::new(),
camera_layout: bind_group_layout,
@@ -1013,28 +1112,12 @@ impl CanvasState {
let quads = self.page_image_quads(page, self.hidden_text);
self.image_layer.set_quads(quads);
self.committed_vertex_count = page.vertex_cache.len() as u32;
self.committed_strokes = page.stroke_ranges.clone();
if page.vertex_cache.is_empty() {
return;
}
if page.vertex_cache.len() > self.committed_vertex_capacity {
self.committed_vertex_capacity =
(page.vertex_cache.len() * 2).max(Self::INITIAL_COMMITTED_CAPACITY);
self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Committed Strokes Vertex Buffer (Reallocated)"),
size: (self.committed_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
self.queue.write_buffer(
&self.committed_vertex_buffer,
0,
bytemuck::cast_slice(&page.vertex_cache),
self.committed.upload(
&self.device,
&self.queue,
&page.vertex_cache,
&page.stroke_ranges,
Some(Self::INITIAL_COMMITTED_CAPACITY),
);
}
@@ -1079,19 +1162,14 @@ impl CanvasState {
resource: camera_buffer.as_entire_binding(),
}],
});
let vertex_buffer_with = |label: &str, vertices: &[InkVertex]| {
(!vertices.is_empty()).then(|| {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: std::mem::size_of_val(vertices) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.queue
.write_buffer(&buffer, 0, bytemuck::cast_slice(vertices));
buffer
})
};
let mut strokes = StrokeBuffers::default();
strokes.upload(
&self.device,
&self.queue,
&page.vertex_cache,
&page.stroke_ranges,
None,
);
// Eigenes Papier und Muster (Nachbarseiten können anders groß sein)
let paper = generate_paper_quad(self.paper_theme.paper_color(), page.size);
let pattern = generate_pattern_quad(
@@ -1125,8 +1203,7 @@ impl CanvasState {
camera_bind_group,
paper_buffer,
pattern_buffer,
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
stroke_ranges: page.stroke_ranges.clone(),
strokes,
}
}
@@ -1487,14 +1564,12 @@ impl CanvasState {
render_pass.draw(0..6, 0..1);
}
self.image_layer.draw(&mut render_pass, i + 1);
if let Some(buffer) = &neighbor.vertex_buffer {
render_pass.set_pipeline(&self.strip_pipeline);
render_pass.set_vertex_buffer(0, buffer.slice(..));
for range in &neighbor.stroke_ranges {
if range.end > range.start {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
render_pass.set_pipeline(&self.strip_pipeline);
for chunk in neighbor.strokes.in_use() {
render_pass.set_vertex_buffer(0, chunk.buffer.slice(..));
for range in &chunk.strokes {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
}
}
@@ -1520,13 +1595,11 @@ impl CanvasState {
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
render_pass.set_pipeline(&self.strip_pipeline);
if self.committed_vertex_count > 0 {
render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..));
for range in &self.committed_strokes {
if range.end > range.start {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
for chunk in self.committed.in_use() {
render_pass.set_vertex_buffer(0, chunk.buffer.slice(..));
for range in &chunk.strokes {
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
render_pass.draw(range.clone(), 0..1);
}
}
@@ -1735,10 +1808,217 @@ fn text_resolution(screen_px_per_unit: f32) -> f32 {
2.0_f32.powf(steps / 4.0).clamp(0.125, 8.0)
}
/// Capacity for a vertex buffer that has to hold `needed` vertices: twice as much, so that
/// it is not reallocated with every stroke, but no more than the device allows.
fn grown_vertex_capacity(needed: usize, initial: usize, max_vertices: usize) -> usize {
needed
.saturating_mul(2)
.max(initial)
.min(max_vertices)
.max(needed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(clippy::single_range_in_vec_init)]
fn test_strokes_are_split_into_buffers_within_the_limit() {
// The usual case: one buffer, ranges as in the vertex cache
let ranges = vec![0..10, 10..10, 10..25, 25..40];
let plan = plan_stroke_chunks(&ranges, 1000);
assert_eq!(
plan,
vec![ChunkPlan {
vertices: 0..40,
strokes: vec![0..10, 10..25, 25..40],
}]
);
// More than fits: several buffers, no stroke cut, ranges relative to their buffer
let plan = plan_stroke_chunks(&ranges, 30);
assert_eq!(
plan,
vec![
ChunkPlan {
vertices: 0..25,
strokes: vec![0..10, 10..25],
},
ChunkPlan {
vertices: 25..40,
strokes: vec![0..15],
},
]
);
assert!(plan.iter().all(|c| c.vertices.len() <= 30));
// A stroke larger than a buffer is left out, the others are still drawn
let plan = plan_stroke_chunks(&[0..10, 10..60, 60..70], 30);
assert_eq!(
plan,
vec![
ChunkPlan {
vertices: 0..10,
strokes: vec![0..10],
},
ChunkPlan {
vertices: 60..70,
strokes: vec![0..10],
},
]
);
assert!(plan_stroke_chunks(&[], 30).is_empty());
}
/// Uploads and draws a page whose strokes exceed the buffer limit of a real device (the
/// limit is set very low for the test). Skipped where no graphics adapter is available.
#[test]
fn test_page_beyond_the_buffer_limit_is_drawn_from_several_buffers() {
let vertex_size = std::mem::size_of::<InkVertex>() as u64;
// 40 vertices per buffer
let limits = wgpu::Limits {
max_buffer_size: 40 * vertex_size,
..wgpu::Limits::default()
};
let instance = wgpu::Instance::default();
let mut found = None;
for force_fallback_adapter in [false, true] {
let options = wgpu::RequestAdapterOptions {
force_fallback_adapter,
..Default::default()
};
if let Some(adapter) = pollster::block_on(instance.request_adapter(&options)) {
let descriptor = wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::empty(),
required_limits: limits.clone(),
};
if let Ok(pair) = pollster::block_on(adapter.request_device(&descriptor, None)) {
found = Some(pair);
break;
}
}
}
let Some((device, queue)) = found else {
eprintln!("no graphics adapter, test skipped");
return;
};
// 10 strokes of 12 vertices: 120 vertices, three times the limit
let vertices = vec![InkVertex::new(0.0, 0.0, [0.0, 0.0, 0.0, 1.0]); 120];
let ranges: Vec<Range<u32>> = (0..10).map(|i| i * 12..(i + 1) * 12).collect();
// The limit is real: one buffer for everything is refused
device.push_error_scope(wgpu::ErrorFilter::Validation);
let _ = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: vertices.len() as u64 * vertex_size,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
assert!(pollster::block_on(device.pop_error_scope()).is_some());
device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut strokes = StrokeBuffers::default();
strokes.upload(&device, &queue, &vertices, &ranges, Some(16));
assert_eq!(strokes.in_use().len(), 4);
let drawn: usize = strokes.in_use().iter().map(|c| c.strokes.len()).sum();
assert_eq!(drawn, 10);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs(@location(0) p: vec2<f32>, @location(1) c: vec4<f32>)
-> @builtin(position) vec4<f32> { return vec4<f32>(p, 0.0, 1.0) + c * 0.0; }
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }"
.into(),
),
});
let format = wgpu::TextureFormat::Rgba8Unorm;
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs",
buffers: &[InkVertex::desc()],
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: "fs",
targets: &[Some(format.into())],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
});
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&pipeline);
for chunk in strokes.in_use() {
pass.set_vertex_buffer(0, chunk.buffer.slice(..));
for range in &chunk.strokes {
pass.draw(range.clone(), 0..1);
}
}
}
queue.submit(std::iter::once(encoder.finish()));
device.poll(wgpu::Maintain::Wait);
let error = pollster::block_on(device.pop_error_scope());
assert!(error.is_none(), "{error:?}");
// A smaller page afterwards uses one buffer again and frees the others
strokes.upload(&device, &queue, &vertices[..24], &ranges[..2], Some(16));
assert_eq!(strokes.in_use().len(), 1);
assert_eq!(strokes.chunks.len(), 1);
strokes.upload(&device, &queue, &[], &[], Some(16));
assert!(strokes.in_use().is_empty());
}
#[test]
fn test_vertex_capacity_doubles_up_to_the_device_limit() {
assert_eq!(grown_vertex_capacity(10, 1000, 1_000_000), 1000);
assert_eq!(grown_vertex_capacity(5000, 1000, 1_000_000), 10_000);
// More than half the limit: the limit, not twice the need
assert_eq!(grown_vertex_capacity(600_000, 1000, 1_000_000), 1_000_000);
// Never less than what is needed
assert_eq!(grown_vertex_capacity(2_000_000, 1000, 1_000_000), 2_000_000);
}
#[test]
fn test_text_resolution_is_quantized_and_never_below_screen() {
for px in [0.2_f32, 0.27, 0.5, 1.3, 3.9] {
+42 -6
View File
@@ -953,6 +953,16 @@ struct Shared {
/// Finished sign-in, to be stored by the app
signed_in: Option<Account>,
stop: bool,
/// Counts the started workers. A worker whose pass was still running when the next one
/// was started must end afterwards, although `stop` is `false` again by then.
generation: u64,
}
impl Shared {
/// Is the worker of `generation` to end?
fn retired(&self, generation: u64) -> bool {
self.stop || self.generation != generation
}
}
type SharedState = Arc<(Mutex<Shared>, Condvar)>;
@@ -986,6 +996,7 @@ impl CloudSync {
changed: false,
signed_in: None,
stop: false,
generation: 0,
}),
Condvar::new(),
)),
@@ -1009,15 +1020,19 @@ impl CloudSync {
fn start(&mut self, account: Account) {
self.stop_worker();
{
let generation = {
let mut shared = lock(&self.shared);
shared.stop = false;
shared.generation += 1;
shared.status = Status::Idle { last: None };
shared.next_pass = Instant::now();
}
shared.generation
};
let shared = self.shared.clone();
let worker_account = account.clone();
self.worker = Some(std::thread::spawn(move || worker(shared, worker_account)));
self.worker = Some(std::thread::spawn(move || {
worker(shared, worker_account, generation)
}));
self.account = Some(account);
}
@@ -1211,7 +1226,7 @@ fn sign_in(
}
}
fn worker(shared: SharedState, account: Account) {
fn worker(shared: SharedState, account: Account, generation: u64) {
let Some(state_path) = SyncState::file_path() else {
set_status(&shared, Status::Failed(t!("Kein Datenordner").to_string()));
return;
@@ -1228,7 +1243,7 @@ fn worker(shared: SharedState, account: Account) {
let (dir, ctx) = {
let mut guard = lock(&shared);
loop {
if guard.stop {
if guard.retired(generation) {
return;
}
let now = Instant::now();
@@ -1252,7 +1267,7 @@ fn worker(shared: SharedState, account: Account) {
eprintln!("[Nextcloud] Sync-Stand nicht gespeichert: {err}");
}
let mut guard = lock(&shared);
if guard.stop {
if guard.retired(generation) {
return;
}
guard.status = match result {
@@ -1282,6 +1297,27 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn worker_of_an_earlier_start_is_retired() {
let mut shared = Shared {
status: Status::Off,
local_dir: PathBuf::new(),
ctx: Context::default(),
next_pass: Instant::now(),
changed: false,
signed_in: None,
stop: false,
generation: 1,
};
assert!(!shared.retired(1));
// Stopped and started again while the pass of worker 1 was still running
shared.generation = 2;
assert!(shared.retired(1));
assert!(!shared.retired(2));
shared.stop = true;
assert!(shared.retired(2));
}
/// Server folder in memory; folder ETags change with anything inside, like Nextcloud's.
#[derive(Default)]
struct FakeRemote {
+203 -2
View File
@@ -166,12 +166,16 @@ impl DiskWatch {
// 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) {
match storage::load_manifest_as_base(&self.dir) {
Ok((theirs, base)) => match self.apply_manifest(notebook, manifest, theirs) {
// Taken over: from now on our saves build on this manifest
Ok(Some(new_ids)) => {
base.adopt();
fresh_pages = new_ids;
outcome.structure_changed = true;
}
// Nothing to take over for the pages. Whatever else differs is merged
// at the next save, so the base stays as it is.
Ok(None) => {}
Err(()) => {
retry.insert("manifest.json".to_string());
@@ -461,6 +465,203 @@ mod tests {
assert!(outcome.is_empty(), "{outcome:?}");
}
/// Writes the page file the way a sync client does: behind the back of `storage`.
fn write_from_another_device(dir: &Path, page: &Page) {
let bytes = serde_json::to_vec(&PageData::from(page)).unwrap();
fs::write(dir.join(format!("page_{}.json", page.id)), bytes).unwrap();
}
fn conflict_copies(dir: &Path) -> Vec<String> {
fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| conflict_copy_of(n).is_some())
.collect()
}
#[test]
fn test_saving_over_a_change_from_another_device_keeps_both() {
let dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest) = saved_notebook(dir.path());
let now = Instant::now();
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
// Their stroke arrives while a stroke is being drawn here, so the check has to wait
let mut theirs = notebook.current_page().clone();
theirs.add_stroke(stroke(5, 300.0));
write_from_another_device(dir.path(), &theirs);
// The stroke is finished and saved before the check ran
notebook.current_page_mut().add_stroke(stroke(2, 200.0));
notebook.next_stroke_id = 3;
storage::save_page(dir.path(), &PageData::from(notebook.current_page())).unwrap();
// Ours is in the page file, theirs next to it
let saved = storage::load_page(dir.path(), 1).unwrap();
let ids: Vec<u64> = saved.strokes.iter().map(|s| s.id).collect();
assert_eq!(ids, vec![1, 2]);
assert_eq!(conflict_copies(dir.path()).len(), 1);
// The check turns their version into a page of its own
let outcome = watch.check(&mut notebook, &mut manifest, now);
assert_eq!(outcome.conflict_pages.len(), 1);
assert_eq!(notebook.pages.len(), 2);
assert_eq!(notebook.pages[0].strokes.len(), 2);
assert_eq!(notebook.pages[1].strokes.len(), 2);
assert!(conflict_copies(dir.path()).is_empty());
// Saving again is an ordinary save
notebook.pages[0].add_stroke(stroke(9, 500.0));
storage::save_page(dir.path(), &PageData::from(&notebook.pages[0])).unwrap();
assert!(conflict_copies(dir.path()).is_empty());
}
#[test]
fn test_saving_an_unchanged_page_keeps_the_change_from_another_device() {
let dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest) = saved_notebook(dir.path());
let now = Instant::now();
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
let mut theirs = notebook.current_page().clone();
theirs.add_stroke(stroke(5, 300.0));
write_from_another_device(dir.path(), &theirs);
// E.g. turning pages marks the page for saving although nothing changed on it
storage::save_page(dir.path(), &PageData::from(notebook.current_page())).unwrap();
assert!(conflict_copies(dir.path()).is_empty());
let outcome = watch.check(&mut notebook, &mut manifest, now);
assert_eq!(outcome.reloaded_pages, 1);
assert_eq!(notebook.pages.len(), 1);
assert_eq!(notebook.pages[0].strokes.len(), 2);
}
#[test]
fn test_saving_a_new_page_over_one_from_another_device_keeps_both() {
let dir = tempfile::tempdir().unwrap();
let (mut notebook, _) = saved_notebook(dir.path());
// Both devices add a second page; it gets the same id on both
let index = notebook.add_page(PageBackground::Blank);
let mut theirs = notebook.pages[index].clone();
theirs.add_stroke(stroke(7, 300.0));
write_from_another_device(dir.path(), &theirs);
notebook.pages[index].add_stroke(stroke(8, 400.0));
storage::save_page(dir.path(), &PageData::from(&notebook.pages[index])).unwrap();
assert_eq!(conflict_copies(dir.path()).len(), 1);
// A file never read here but with the content to be saved is no conflict
let index = notebook.add_page(PageBackground::Blank);
notebook.pages[index].add_stroke(stroke(9, 300.0));
write_from_another_device(dir.path(), &notebook.pages[index]);
storage::save_page(dir.path(), &PageData::from(&notebook.pages[index])).unwrap();
assert_eq!(conflict_copies(dir.path()).len(), 1);
}
/// Writes the manifest the way a sync client does: behind the back of `storage`.
fn write_manifest_from_another_device(dir: &Path, manifest: &NotebookManifest) {
fs::write(
dir.join("manifest.json"),
serde_json::to_vec(manifest).unwrap(),
)
.unwrap();
}
fn page_ids_on_disk(dir: &Path) -> Vec<u64> {
let bytes = fs::read(dir.join("manifest.json")).unwrap();
let manifest: NotebookManifest = serde_json::from_slice(&bytes).unwrap();
manifest.pages.iter().map(|p| p.id).collect()
}
#[test]
fn test_saving_the_manifest_keeps_a_page_added_on_another_device() {
let dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest) = saved_notebook(dir.path());
let now = Instant::now();
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
// The other device adds a second page
let mut theirs = notebook.clone();
let mut their_manifest = manifest.clone();
let index = theirs.add_page(PageBackground::Lined);
theirs.pages[index].add_stroke(stroke(6, 400.0));
theirs.next_stroke_id = 7;
their_manifest.sync_from_notebook(&theirs);
write_from_another_device(dir.path(), &theirs.pages[index]);
write_manifest_from_another_device(dir.path(), &their_manifest);
let their_page = theirs.pages[index].id;
// Before the check ran, a stroke is drawn and saved here, twice
for id in [2, 3] {
notebook.current_page_mut().add_stroke(stroke(id, 200.0));
notebook.next_stroke_id = id + 1;
manifest.sync_from_notebook(&notebook);
storage::save_page(dir.path(), &PageData::from(notebook.current_page())).unwrap();
storage::save_manifest(dir.path(), &manifest).unwrap();
assert_eq!(page_ids_on_disk(dir.path()), vec![1, their_page]);
}
// The check takes their page over, and saving stays an ordinary save afterwards
let outcome = watch.check(&mut notebook, &mut manifest, now);
assert!(outcome.structure_changed);
assert_eq!(notebook.pages.len(), 2);
assert_eq!(notebook.pages[0].strokes.len(), 3);
assert!(notebook.next_stroke_id >= 7);
manifest.sync_from_notebook(&notebook);
storage::save_manifest(dir.path(), &manifest).unwrap();
assert_eq!(page_ids_on_disk(dir.path()), vec![1, their_page]);
// A page removed here afterwards is really gone
notebook.pages.pop();
notebook.current_page_index = 0;
manifest.sync_from_notebook(&notebook);
storage::save_manifest(dir.path(), &manifest).unwrap();
assert_eq!(page_ids_on_disk(dir.path()), vec![1]);
}
#[test]
fn test_saving_an_unchanged_manifest_keeps_the_one_from_another_device() {
let dir = tempfile::tempdir().unwrap();
let (notebook, manifest) = saved_notebook(dir.path());
let mut their_manifest = manifest.clone();
their_manifest.title = "Physik".to_string();
write_manifest_from_another_device(dir.path(), &their_manifest);
storage::save_manifest(dir.path(), &manifest).unwrap();
let bytes = fs::read(dir.path().join("manifest.json")).unwrap();
let on_disk: NotebookManifest = serde_json::from_slice(&bytes).unwrap();
assert_eq!(on_disk.title, "Physik");
drop(notebook);
}
#[test]
fn test_manifest_changed_on_both_sides_is_merged() {
let dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest) = saved_notebook(dir.path());
let ours_new = notebook.add_page(PageBackground::Blank);
let ours_new = notebook.pages[ours_new].id;
manifest.sync_from_notebook(&notebook);
storage::save_manifest(dir.path(), &manifest).unwrap();
// They rename the notebook; we remove our new page again
let mut their_manifest = manifest.clone();
their_manifest.title = "Physik".to_string();
write_manifest_from_another_device(dir.path(), &their_manifest);
notebook.pages.pop();
notebook.current_page_index = 0;
manifest.sync_from_notebook(&notebook);
storage::save_manifest(dir.path(), &manifest).unwrap();
let bytes = fs::read(dir.path().join("manifest.json")).unwrap();
let on_disk: NotebookManifest = serde_json::from_slice(&bytes).unwrap();
assert_eq!(on_disk.title, "Physik");
assert!(!on_disk.pages.iter().any(|p| p.id == ours_new));
}
#[test]
fn test_page_still_being_synced_is_tried_again() {
let dir = tempfile::tempdir().unwrap();
+117 -4
View File
@@ -691,6 +691,15 @@ impl Stroke {
if let Some(ref mut shape) = self.shape {
shape.scale(origin, factors);
}
// The line width grows with the stroke. It is stored and the mesh is rebuilt from it
// on load; without this a scaled stroke would be back at its old width after a restart.
// Images and text fields have no line width.
if !self.is_object() {
let factor = (factors.x * factors.y).abs().sqrt();
if factor.is_finite() && factor > 0.0 {
self.width *= factor;
}
}
self.recompute_aabb();
}
@@ -1533,6 +1542,11 @@ pub struct AddedPage {
pub struct SpaceShift {
pub moves: Vec<StrokeMove>,
pub added_pages: Vec<AddedPage>,
/// Undo histories of the other pages (by page id) as they were before the shift, set
/// aside while it is applied. Their steps describe strokes at their old place and page:
/// redoing "stroke drawn" there would put a second copy of a stroke that now sits on
/// another page. They come back when the shift is undone.
pub set_aside: Vec<(u64, HistoryStack)>,
}
impl Notebook {
@@ -1574,7 +1588,12 @@ impl Notebook {
/// der Seite und gibt die Kennungen der geänderten Seiten zurück (`None`: nichts zu tun).
pub fn make_space(&mut self, page_index: usize, y: f32, dy: f32) -> Option<Vec<u64>> {
let run = self.space_run(page_index)?;
if self.pages[run.clone()].iter().any(|p| !p.is_loaded) {
// A page that could not be loaded is never saved: strokes pushed onto it would be gone
// after a restart, and its own content is unknown
if self.pages[run.clone()]
.iter()
.any(|p| !p.is_loaded || p.is_readonly())
{
return None;
}
let dy = if dy < 0.0 {
@@ -1691,8 +1710,13 @@ impl Notebook {
after,
});
}
let shift = SpaceShift { moves, added_pages };
let mut shift = SpaceShift {
moves,
added_pages,
set_aside: Vec::new(),
};
let changed = self.apply_space_moves(&shift, false);
shift.set_aside = self.set_histories_aside(&changed, self.pages[page_index].id);
self.pages[page_index]
.history
.push(HistoryAction::SpaceShift(Box::new(shift)));
@@ -1734,16 +1758,37 @@ impl Notebook {
changed
}
/// Takes the histories of the pages a shift changed, except the page that owns the
/// step (see [`SpaceShift::set_aside`]).
fn set_histories_aside(&mut self, changed: &[u64], owner: u64) -> Vec<(u64, HistoryStack)> {
self.pages
.iter_mut()
.filter(|p| p.id != owner && changed.contains(&p.id))
.filter(|p| p.history.can_undo() || p.history.can_redo())
.map(|p| (p.id, std::mem::take(&mut p.history)))
.collect()
}
/// Macht die letzte Aktion der aktuellen Seite rückgängig; ein Platzschaffen-Schritt
/// betrifft dabei alle beteiligten Seiten. Gibt die geänderten Seiten zurück.
pub fn undo(&mut self) -> Option<Vec<u64>> {
let index = self.current_page_index;
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.undo_stack.last() {
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.undo_stack.pop()
let Some(HistoryAction::SpaceShift(mut shift)) =
self.pages[index].history.undo_stack.pop()
else {
unreachable!()
};
let mut changed = self.apply_space_moves(&shift, true);
// The pages are as before the shift again, and so their histories apply again.
// A page that got steps of its own in the meantime keeps those instead.
for (id, history) in shift.set_aside.drain(..) {
if let Some(page) = self.pages.iter_mut().find(|p| p.id == id) {
if !page.history.can_undo() && !page.history.can_redo() {
page.history = history;
}
}
}
// Dafür angelegte Seiten wieder entfernen, sofern sie leer geblieben sind
for added in shift.added_pages.iter().rev() {
if let Some(i) = self.pages.iter().position(|p| p.id == added.id) {
@@ -1772,7 +1817,8 @@ impl Notebook {
pub fn redo(&mut self) -> Option<Vec<u64>> {
let index = self.current_page_index;
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.redo_stack.last() {
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.redo_stack.pop()
let Some(HistoryAction::SpaceShift(mut shift)) =
self.pages[index].history.redo_stack.pop()
else {
unreachable!()
};
@@ -1796,6 +1842,7 @@ impl Notebook {
}
let changed = self.apply_space_moves(&shift, false);
let owner = self.current_page_index;
shift.set_aside = self.set_histories_aside(&changed, self.pages[owner].id);
self.pages[owner]
.history
.undo_stack
@@ -2680,6 +2727,72 @@ mod tests {
);
}
#[test]
fn test_undo_on_a_following_page_after_make_space_does_not_duplicate() {
let a4 = A4_SIZE.y;
let count = |nb: &Notebook, id: u64| {
nb.pages
.iter()
.flat_map(|p| &p.strokes)
.filter(|s| s.id == id)
.count()
};
let mut nb = Notebook::new();
nb.add_page(PageBackground::Blank);
nb.pages[0].strokes = vec![block(1, 1000.0, 100.0)];
// Drawn on the second page, so it is in that page's history
nb.pages[1].add_stroke(block(3, a4 - 150.0, 100.0));
// Pushes stroke 3 over the end of its page onto a new third page
nb.make_space(0, 900.0, 300.0).unwrap();
assert_eq!(nb.pages.len(), 3);
assert!(top_of(&nb, 2, 3).is_some());
// The step that drew it belongs to the state before the shift: not available now
nb.current_page_index = 1;
assert!(nb.undo().is_none());
assert!(nb.redo().is_none());
assert_eq!(count(&nb, 3), 1);
// With the shift undone it is back and works as before
nb.current_page_index = 0;
assert!(nb.undo().is_some());
assert_eq!(top_of(&nb, 1, 3), Some(a4 - 150.0));
nb.current_page_index = 1;
assert!(nb.undo().is_some());
assert_eq!(count(&nb, 3), 0);
assert!(nb.redo().is_some());
assert_eq!(count(&nb, 3), 1);
// Shifted again and something new drawn on the page: that step can be undone, and
// undoing the shift then leaves the page's newer history alone
nb.current_page_index = 0;
assert!(nb.redo().is_some());
assert!(top_of(&nb, 2, 3).is_some());
nb.pages[1].add_stroke(block(4, 200.0, 100.0));
assert!(nb.undo().is_some());
nb.current_page_index = 1;
assert!(nb.undo().is_some());
assert_eq!(count(&nb, 4), 0);
assert!(
nb.undo().is_none(),
"the older history is not restored on top"
);
assert_eq!(count(&nb, 3), 1);
}
#[test]
fn test_make_space_refuses_pages_that_failed_to_load() {
let mut nb = Notebook::new();
nb.add_page(PageBackground::Blank);
nb.pages[0].strokes = vec![block(1, 1000.0, 100.0)];
nb.pages[1].persist = PersistState::ReadOnlyAfterLoadError;
assert!(nb.make_space(0, 900.0, 300.0).is_none());
assert_eq!(top_of(&nb, 0, 1), Some(1000.0));
nb.pages[1].persist = PersistState::Normal;
assert!(nb.make_space(0, 900.0, 300.0).is_some());
}
#[test]
fn test_remove_and_restore_current_page() {
let mut nb = Notebook::new();
+27 -4
View File
@@ -553,7 +553,7 @@ fn render_ink_layer(
));
}
if let Some(ref shape) = stroke.shape {
render_recognized_shape(layer, shape, stroke.color);
render_recognized_shape(layer, shape, stroke.color, stroke.width);
} else {
match strategy {
ExportStrategy::FilledPolygon => {
@@ -831,20 +831,31 @@ fn render_pdf_raster(
draw_image_xobject(layer, xobject, &pdf.corners(page.size));
}
/// Line width of a shape in PDF points, as wide as its mesh on screen
/// (`geometry::shape_to_mesh`: the stroke width in page units, at least 2). Shapes without a
/// stored width get the former fixed 1.5 pt (about 0.5 mm).
fn shape_outline_pt(width_units: f32) -> f32 {
if width_units <= 0.0 || !width_units.is_finite() {
return 1.5;
}
width_units.max(2.0) * (A4_WIDTH_MM / PAGE_WIDTH) * (72.0 / 25.4)
}
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
/// als verlustfreien Vektorpfad im PDF.
fn render_recognized_shape(
layer: &crate::pdf_writer::PdfLayerReference,
shape: &crate::geometry::RecognizedShape,
color: [f32; 4],
width_units: f32,
) {
use crate::geometry::RecognizedShape;
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
layer.set_outline_color(stroke_color);
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
layer.set_outline_thickness(shape_outline_pt(width_units));
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
layer.set_line_cap_style(crate::pdf_writer::LineCapStyle::Butt);
layer.set_line_join_style(crate::pdf_writer::LineJoinStyle::Miter);
@@ -1588,6 +1599,18 @@ mod tests {
assert!(bytes.starts_with(b"%PDF-1."));
}
#[test]
fn test_shape_outline_follows_stroke_width() {
let mm = |pt: f32| pt * 25.4 / 72.0;
// 5 page units are 0.5 mm, a 100 unit highlighter line is 10 mm wide
assert!((mm(shape_outline_pt(5.0)) - 0.5).abs() < 1e-3);
assert!((mm(shape_outline_pt(100.0)) - 10.0).abs() < 1e-3);
// The mesh is never thinner than 2 units
assert!((mm(shape_outline_pt(0.5)) - 0.2).abs() < 1e-3);
assert_eq!(shape_outline_pt(0.0), 1.5);
assert_eq!(shape_outline_pt(f32::NAN), 1.5);
}
#[test]
fn test_image_matrix_maps_unit_square_to_corners() {
// Bild über die volle Seitenbreite, oberes Viertel der Seite
+16
View File
@@ -133,6 +133,10 @@ pub const EN: &[(&str, &str)] = &[
("Fehler beim Schreiben auf die Festplatte: {e}", "Error writing to disk: {e}"),
("Fehler beim Umbenennen: {err}", "Error while renaming: {err}"),
("Fehler beim Wiederherstellen: {err}", "Error while restoring: {err}"),
(
"Das PDF kann nicht verarbeitet werden (interner Fehler)",
"The PDF cannot be processed (internal error)",
),
("Fehler beim Öffnen: {err}", "Error while opening: {err}"),
("Fehler: {err}", "Error: {err}"),
("Fehlermeldung: {message}", "Error message: {message}"),
@@ -175,6 +179,10 @@ pub const EN: &[(&str, &str)] = &[
("Kariert", "Grid"),
("KAROS, LINIEN UND PUNKTE", "GRID, LINES AND DOTS"),
("kein Asset-Ordner", "no asset folder"),
(
"'{asset_name}' ist keine neuere Version und wird nicht installiert.",
"'{asset_name}' is not a newer version and will not be installed.",
),
("Kein Eintrag für '{asset_name}' in SHA256SUMS.txt gefunden. Heruntergeladene Datei wurde gelöscht.", "No entry for '{asset_name}' found in SHA256SUMS.txt. The downloaded file was deleted."),
("Kein Release ausgewählt. Download abgelehnt.", "No release selected. Download refused."),
("Keine manifest.json vorhanden", "No manifest.json present"),
@@ -494,6 +502,14 @@ pub const EN: &[(&str, &str)] = &[
("🚀 Updates & Versionen", "🚀 Updates & versions"),
(", PDF-Hintergrund nicht gefunden: {file}", ", PDF background not found: {file}"),
(", {count} Elemente übersprungen", ", {count} elements skipped"),
(
"Platz schaffen nicht möglich: Eine der Seiten konnte nicht geladen werden",
"Cannot make space: one of the pages could not be loaded",
),
(
"Nicht gewechselt, Speichern fehlgeschlagen: {err}",
"Not switched, saving failed: {err}",
),
("Nicht importiert: {err}", "Not imported: {err}"),
("Importieren", "Import"),
("📄 Importieren", "📄 Import"),
+74 -4
View File
@@ -125,11 +125,24 @@ pub fn page(
/// we draw a diameter of `width · (0.5 + p)`, so the widest point gets p = 1 and thinner
/// ones less (down to a third of the widest).
pub fn fit_widths(widths: &[f32]) -> (f32, Vec<f32>) {
let widest = widths.iter().copied().fold(0.0, f32::max).max(0.01);
let widest = widths
.iter()
.copied()
.filter(|w| w.is_finite())
.fold(0.0, f32::max)
.max(0.01);
let width = widest / 1.5;
let pressures = widths
.iter()
.map(|w| (w / width - 0.5).clamp(0.0, 1.0))
.map(|w| {
let p = w / width - 0.5;
// NaN survives `clamp`, is stored as `null` and makes the page unreadable
if p.is_finite() {
p.clamp(0.0, 1.0)
} else {
0.5
}
})
.collect();
(width, pressures)
}
@@ -153,17 +166,29 @@ pub fn ink_stroke(
color[3] = 1.0;
StrokeTool::Pen
};
// Broken files may carry NaN or infinite numbers. They would be written as `null` and
// the page could not be read again, so such points are left out.
let samples = points
.iter()
.zip(pressures)
.filter(|(p, _)| p.is_finite())
.enumerate()
.map(|(i, (p, &pressure))| SampleData {
x: p.x,
y: p.y,
p: pressure,
p: if pressure.is_finite() {
pressure.clamp(0.0, 1.0)
} else {
0.5
},
t: i as f64 * 0.005,
})
.collect();
let width = if width.is_finite() && width > 0.0 {
width
} else {
5.0
};
StrokeData {
id,
color,
@@ -246,7 +271,12 @@ pub fn unpremultiply(pixels: &mut [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()?;
// By default usvg loads `<image href="/some/local/file">` from disk. An imported note
// must not be able to pull files of this device into the notebook; embedded data URLs
// still work.
let mut options = usvg::Options::default();
options.image_href_resolver.resolve_string = Box::new(|_, _| None);
let tree = usvg::Tree::from_str(svg, &options).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;
@@ -288,4 +318,44 @@ mod tests {
// Thinner than a third of the widest point is not possible
assert!((drawn[3] - 10.0).abs() < 1e-3);
}
#[test]
fn test_broken_numbers_never_reach_the_page_file() {
let (width, pressures) = fit_widths(&[f32::NAN, 10.0, f32::INFINITY]);
assert!(width.is_finite() && pressures.iter().all(|p| p.is_finite()));
let points = [
glam::Vec2::new(10.0, 10.0),
glam::Vec2::new(f32::NAN, 20.0),
glam::Vec2::new(30.0, 30.0),
];
let stroke = ink_stroke(
1,
[0.0, 0.0, 0.0, 1.0],
false,
&points,
f32::NAN,
&[f32::NAN, 0.5, 2.0],
);
assert_eq!(stroke.samples.len(), 2);
assert!(stroke.width.is_finite());
let json = serde_json::to_string(&stroke).unwrap();
assert!(!json.contains("null"), "{json}");
assert!(serde_json::from_str::<StrokeData>(&json).is_ok());
}
#[test]
fn test_svg_cannot_embed_local_files() {
let dir = tempfile::tempdir().unwrap();
let secret = dir.path().join("secret.png");
image::RgbaImage::from_pixel(8, 8, image::Rgba([255, 0, 0, 255]))
.save(&secret)
.unwrap();
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="8"><image width="8" height="8" xlink:href="{}"/></svg>"#,
secret.display()
);
let (_, _, pixels) = rasterize_svg(&svg, glam::Vec2::new(80.0, 80.0)).unwrap();
assert!(pixels.iter().all(|&b| b == 0), "the local image was drawn");
}
}
+38 -10
View File
@@ -40,8 +40,12 @@ pub const MAX_IMPORT_PAGES: usize = 2000;
/// Öffnet ein PDF und liefert die angezeigte Größe jeder Seite in Punkt (Drehung
/// berücksichtigt).
pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
let pdf = open(bytes)?;
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
// Runs on the main thread during import: a panic in the PDF library must not take the
// app down
let sizes: Vec<(f32, f32)> = guarded(|| {
let pdf = open(bytes)?;
Ok(pdf.pages().iter().map(|p| p.render_dimensions()).collect())
})?;
if sizes.is_empty() {
return Err(t!("Das PDF enthält keine Seiten").into());
}
@@ -55,6 +59,13 @@ pub fn page_sizes(bytes: Vec<u8>) -> Result<Vec<(f32, f32)>, String> {
Ok(sizes)
}
/// Runs PDF code and turns a panic of the PDF library (malformed files) into an error.
fn guarded<T>(work: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).unwrap_or_else(|_| {
Err(t!("Das PDF kann nicht verarbeitet werden (interner Fehler)").to_string())
})
}
fn open(bytes: Vec<u8>) -> Result<Pdf, String> {
Pdf::new(bytes).map_err(|e| match e {
LoadPdfError::Decryption(_) => {
@@ -209,21 +220,31 @@ impl PdfRenderService {
queue = ready.wait(queue).unwrap_or_else(|e| e.into_inner());
}
};
// A panic here would end the thread and with it every PDF page until
// the next start, so it only fails the page at hand
if open_pdf.as_ref().map(|(p, _)| p) != Some(&request.path) {
open_pdf = std::fs::read(&request.path)
.map_err(|e| e.to_string())
.and_then(open)
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
.ok()
.map(|pdf| (request.path.clone(), pdf));
open_pdf = guarded(|| {
std::fs::read(&request.path)
.map_err(|e| e.to_string())
.and_then(open)
})
.map_err(|e| eprintln!("[PDF] '{}': {e}", request.path.display()))
.ok()
.map(|pdf| (request.path.clone(), pdf));
}
let result = match &open_pdf {
// Mipmaps hier statt im Haupt-Thread: sonst stockt das Scrollen bei
// jeder neu gerenderten Seite (Issue #28)
Some((_, pdf)) => render_page(pdf, request.page, request.max_pixels)
.map(crate::image_layer::mip_chain),
Some((_, pdf)) => guarded(|| {
render_page(pdf, request.page, request.max_pixels)
.map(crate::image_layer::mip_chain)
}),
None => Err(t!("PDF kann nicht geöffnet werden").into()),
};
if result.is_err() {
// The document may be in a broken state after a panic
open_pdf = None;
}
if res_tx.send((request.key, result)).is_err() {
return;
}
@@ -356,6 +377,13 @@ impl Drop for PdfRenderService {
pub(crate) mod tests {
use super::*;
#[test]
fn test_panic_in_pdf_code_becomes_an_error() {
let result: Result<(), String> = guarded(|| panic!("simulated panic in the PDF library"));
assert!(result.is_err());
assert_eq!(guarded(|| Ok(7)), Ok(7));
}
/// Minimales gültiges PDF mit Seiten der angegebenen Größen (Punkt); jede Seite zeigt ein
/// rotes Rechteck in der linken oberen Ecke. Optional mit `/Rotate`.
pub(crate) fn sample_pdf(pages: &[(f32, f32, i32)]) -> Vec<u8> {
+18 -1
View File
@@ -561,7 +561,8 @@ fn convert(doc: &RnoteDocument, notebook_dir: &Path) -> Imported {
let scale = scale_pt * (72.0 / doc.dpi) as f32;
// Rnote grows documents page by page; tolerate rounding in the stored size
let count = |length: f64, page: f64| ((length / page - 0.01).ceil() as usize).max(1);
let columns = count(doc.size.x, doc.format.x);
// A broken document size must not ask for millions of pages
let columns = count(doc.size.x, doc.format.x).min(MAX_PAGES);
let rows = count(doc.size.y, doc.format.y);
let rows = rows.min((MAX_PAGES / columns).max(1));
let targets = (0..rows * columns)
@@ -889,6 +890,22 @@ mod tests {
assert!(second.strokes[1].is_image());
}
#[test]
fn test_broken_document_size_is_capped() {
let dir = tempfile::tempdir().unwrap();
let doc = RnoteDocument {
format: DVec2::new(800.0, 1100.0),
dpi: 96.0,
origin: DVec2::ZERO,
size: DVec2::new(1e18, 1e18),
endless: false,
background: PageBackground::Blank,
items: Vec::new(),
};
let imported = convert(&doc, dir.path());
assert!(imported.pages.len() <= MAX_PAGES);
}
#[test]
fn test_infinite_documents_become_endless() {
let temp_dir = tempfile::tempdir().unwrap();
+44 -3
View File
@@ -302,15 +302,35 @@ impl UiSettings {
crate::paths::config_dir().map(|d| d.join("ui.json"))
}
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
/// Keeps every setting of a partly invalid file that can still be read; only the
/// unreadable ones get their default. One bad value must not reset everything, least of
/// all the storage location.
fn salvage(content: &str) -> Self {
let Ok(serde_json::Value::Object(file)) = serde_json::from_str(content) else {
return Self::default();
};
let mut good = serde_json::Map::new();
for (key, value) in file {
good.insert(key.clone(), value);
let candidate = serde_json::Value::Object(good.clone());
if serde_json::from_value::<Self>(candidate).is_err() {
eprintln!("[Einstellungen] '{key}' ist ungültig, verwende Standardwert.");
good.remove(&key);
}
}
serde_json::from_value(serde_json::Value::Object(good)).unwrap_or_default()
}
/// Lädt die Einstellungen; bei fehlender Datei gelten Standardwerte, bei einer teilweise
/// ungültigen Datei nur für die unlesbaren Werte.
pub fn load_from(path: &Path) -> Self {
let mut settings: Self = match std::fs::read_to_string(path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
eprintln!(
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
"[Einstellungen] '{}' ist ungültig ({e}), unlesbare Werte werden ersetzt.",
path.display()
);
Self::default()
Self::salvage(&content)
}),
Err(_) => Self::default(),
};
@@ -437,6 +457,27 @@ mod tests {
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
}
#[test]
fn test_one_invalid_value_does_not_reset_the_other_settings() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("ui.json");
std::fs::write(
&path,
br#"{"storage_dir": "/home/pia/Nextcloud/ink-notes", "eraser_radius_mm": "big", "math_assist": true}"#,
)
.unwrap();
let loaded = UiSettings::load_from(&path);
assert_eq!(
loaded.storage_dir,
Some(PathBuf::from("/home/pia/Nextcloud/ink-notes"))
);
assert!(loaded.math_assist);
assert_eq!(
loaded.eraser_radius_mm,
UiSettings::default().eraser_radius_mm
);
}
#[test]
fn test_seven_pens_stay_readable_for_older_versions() {
// Versions before 0.44 read `pen_presets` as exactly four pens (Issue #56)
+395 -38
View File
@@ -555,8 +555,12 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> {
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600));
}
file.write_all(content)?;
file.sync_all()?;
// On a full disk every retry would otherwise leave another partial file behind
if let Err(e) = file.write_all(content).and_then(|_| file.sync_all()) {
drop(file);
let _ = fs::remove_file(&temp_path);
return Err(e);
}
}
// Atomares Rename
@@ -586,16 +590,32 @@ pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), Sto
/// Speichert das Manifest atomar in `manifest.json`.
/// Sichert das bisherige Manifest als `manifest.json.bak` vor dem Überschreiben.
///
/// Like a page (see [`save_page`]), the manifest may have been changed behind our back by a
/// sync before `disk_watch` took the change over, e.g. the other device added a page.
/// Overwriting it would drop that page from the list. The manifest on disk is therefore
/// merged with ours, with the manifest the notebook in memory is based on as common ancestor
/// ([`ManifestBase`]); if we have nothing new ourselves, theirs simply stays.
pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result<(), StorageError> {
let path = notebook_dir.join("manifest.json");
if path.exists() {
let ours = serde_json::to_vec(manifest)?;
let key = seen_key(&path);
let mut merged = None;
if let Ok(theirs) = fs::read(&path) {
let base = manifest_bases().get(&key).cloned();
if base.as_ref() != Some(&theirs) && theirs != ours {
if base.as_ref() == Some(&ours) {
// Nothing changed here since the notebook was loaded or last saved
return Ok(());
}
// Theirs unreadable (e.g. half written): ours replaces it
merged = merge_manifests(base.as_deref(), &ours, &theirs);
}
let bak_path = notebook_dir.join("manifest.json.v0.bak");
if !bak_path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(old_m) = serde_json::from_str::<NotebookManifest>(&content) {
if old_m.format_version == 0 {
let _ = fs::copy(&path, &bak_path);
}
if let Ok(old_m) = serde_json::from_slice::<NotebookManifest>(&theirs) {
if old_m.format_version == 0 {
let _ = fs::copy(&path, &bak_path);
}
}
}
@@ -603,10 +623,51 @@ pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result
let rolling_bak = notebook_dir.join("manifest.json.bak");
let _ = fs::copy(&path, &rolling_bak);
}
atomic_write_json(&path, manifest)
atomic_write_file(&path, merged.as_ref().unwrap_or(&ours))?;
// Ours, not the merged file: the notebook in memory does not contain their part until
// `disk_watch` has taken it over, so the next save has to merge it in again
manifest_bases().insert(key, ours);
Ok(())
}
fn load_manifest_from_file(path: &Path) -> Result<NotebookManifest, StorageError> {
/// Three-way merge of manifests as JSON (page lists by page id, see
/// `cloud_sync::merge_json`); `None` if one of them can't be read or the result is no
/// valid manifest.
fn merge_manifests(base: Option<&[u8]>, ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> {
let ours: serde_json::Value = serde_json::from_slice(ours).ok()?;
let theirs: serde_json::Value = serde_json::from_slice(theirs).ok()?;
let base: Option<serde_json::Value> = base.and_then(|b| serde_json::from_slice(b).ok());
let merged = crate::cloud_sync::merge_json(base.as_ref(), &ours, &theirs);
serde_json::from_value::<NotebookManifest>(merged.clone()).ok()?;
serde_json::to_vec(&merged).ok()
}
/// Content of `manifest.json` that the notebooks in memory are based on, by file.
fn manifest_bases() -> std::sync::MutexGuard<'static, std::collections::HashMap<PathBuf, Vec<u8>>> {
static BASES: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<PathBuf, Vec<u8>>>,
> = std::sync::OnceLock::new();
BASES
.get_or_init(Default::default)
.lock()
.unwrap_or_else(|e| e.into_inner())
}
/// A manifest as read from disk, to be declared the state the notebook in memory is based on
/// once it has been taken over (see [`save_manifest`]). Reading a manifest alone, e.g. for
/// the list of notebooks, must not do that.
pub struct ManifestBase {
key: PathBuf,
bytes: Vec<u8>,
}
impl ManifestBase {
pub fn adopt(self) {
manifest_bases().insert(self.key, self.bytes);
}
}
fn load_manifest_from_file(path: &Path) -> Result<(NotebookManifest, Vec<u8>), StorageError> {
let mut file = File::open(path)?;
let mut json_str = String::new();
file.read_to_string(&mut json_str)?;
@@ -626,12 +687,47 @@ fn load_manifest_from_file(path: &Path) -> Result<NotebookManifest, StorageError
.map(|&id| PageMeta::new(id, None))
.collect();
}
Ok(manifest)
Ok((manifest, json_str.into_bytes()))
}
/// Lädt das Manifest aus `manifest.json`.
/// Fällt bei Fehlern/Beschädigung automatisch auf `manifest.json.bak` zurück.
/// Fällt bei Beschädigung automatisch auf `manifest.json.bak` zurück.
///
/// The folder name is the identity of a notebook: everything opens, renames and deletes
/// `<base>/<id>`. A copied or renamed folder still carries the id of the original in its
/// manifest, so the id is always taken from the folder.
pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> {
load_manifest_as_base(notebook_dir).map(|(manifest, _)| manifest)
}
/// Like [`load_manifest`], for a manifest that is taken over into the open notebook: call
/// [`ManifestBase::adopt`] once that has happened.
pub fn load_manifest_as_base(
notebook_dir: &Path,
) -> Result<(NotebookManifest, ManifestBase), StorageError> {
let (mut manifest, bytes) = load_manifest_or_backup(notebook_dir)?;
// Canonical, so that a path like `.` still yields the folder name
let dir = notebook_dir.canonicalize();
let name = dir
.as_deref()
.unwrap_or(notebook_dir)
.file_name()
.and_then(|n| n.to_str());
if let Some(name) = name {
if manifest.id != name {
manifest.id = name.to_string();
}
}
let base = ManifestBase {
key: seen_key(&notebook_dir.join("manifest.json")),
bytes,
};
Ok((manifest, base))
}
fn load_manifest_or_backup(
notebook_dir: &Path,
) -> Result<(NotebookManifest, Vec<u8>), StorageError> {
let path = notebook_dir.join("manifest.json");
if !path.exists() {
let bak_path = notebook_dir.join("manifest.json.bak");
@@ -647,6 +743,13 @@ pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageErr
match load_manifest_from_file(&path) {
Ok(manifest) => Ok(manifest),
// The backup only stands in for a damaged file. A manifest of a newer app version or
// one that cannot be read right now is intact: opening the older backup instead would
// let the next save overwrite it with stale content.
Err(e @ StorageError::UnsupportedVersion { .. }) => Err(e),
Err(StorageError::Io(e)) if e.kind() != io::ErrorKind::InvalidData => {
Err(StorageError::Io(e))
}
Err(e) => {
let bak_path = notebook_dir.join("manifest.json.bak");
if bak_path.exists() {
@@ -689,21 +792,96 @@ pub fn quarantine_corrupted_page(notebook_dir: &Path, page_id: u64) -> Result<Pa
}
/// Speichert eine Seite atomar in `page_<id>.json`.
///
/// The file may have been changed behind our back since this process last read or wrote it:
/// by the built-in sync or a sync client that brought a change from another device, before
/// `disk_watch` got to take it over (it waits while something is being drawn or is unsaved).
/// Overwriting it would silently drop that change. So, as the sync does for a page changed on
/// both sides, their version is kept as a conflict copy next to the page (`disk_watch` turns
/// it into a page of its own); if we have nothing new ourselves, their version simply stays.
pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> {
let path = notebook_dir.join(format!("page_{}.json", page.id));
if path.exists() {
let bak_path = notebook_dir.join(format!("page_{}.json.v0.bak", page.id));
if !bak_path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(old_p) = serde_json::from_str::<PageData>(&content) {
if old_p.format_version == 0 {
let _ = fs::copy(&path, &bak_path);
}
}
let bytes = serde_json::to_vec(page)?;
let ours = file_hash(&bytes);
let key = seen_key(&path);
if let Ok(theirs) = fs::read(&path) {
let seen = seen_files().get(&key).copied();
let their_page = serde_json::from_slice::<PageData>(&theirs).ok();
if seen != Some(file_hash(&theirs)) && theirs != bytes {
if seen == Some(ours) {
// Nothing changed here since we last read or wrote the page
return Ok(());
}
let differs = their_page.as_ref().is_some_and(|their_page| {
let mut their_page = their_page.clone();
their_page.format_version = page.format_version;
their_page != *page
});
if differs {
// Fails rather than overwrites: the caller tries again
write_conflict_copy(notebook_dir, page.id, &theirs)?;
}
}
let bak_path = notebook_dir.join(format!("page_{}.json.v0.bak", page.id));
if !bak_path.exists() && their_page.is_some_and(|p| p.format_version == 0) {
let _ = fs::copy(&path, &bak_path);
}
}
atomic_write_json(&path, page)
atomic_write_file(&path, &bytes)?;
seen_files().insert(key, ours);
Ok(())
}
type FileHash = [u8; 32];
fn file_hash(bytes: &[u8]) -> FileHash {
use sha2::Digest;
sha2::Sha256::digest(bytes).into()
}
/// Content of the page files as this process last read or wrote them (see [`save_page`]).
fn seen_files() -> std::sync::MutexGuard<'static, std::collections::HashMap<PathBuf, FileHash>> {
static SEEN: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<PathBuf, FileHash>>,
> = std::sync::OnceLock::new();
SEEN.get_or_init(Default::default)
.lock()
.unwrap_or_else(|e| e.into_inner())
}
/// The same file must get the same key however its folder is spelled.
fn seen_key(path: &Path) -> PathBuf {
match (path.parent().map(Path::canonicalize), path.file_name()) {
(Some(Ok(dir)), Some(name)) => dir.join(name),
_ => path.to_path_buf(),
}
}
/// Keeps `content` as `page_<id> (conflict <unix time>).json`, the name the sync uses too.
fn write_conflict_copy(notebook_dir: &Path, page_id: u64, content: &[u8]) -> io::Result<PathBuf> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut n = 0;
let path = loop {
let name = match n {
0 => format!("page_{page_id} (conflict {ts}).json"),
n => format!("page_{page_id} (conflict {ts}-{n}).json"),
};
let path = notebook_dir.join(name);
if !path.exists() {
break path;
}
n += 1;
};
atomic_write_file(&path, content)?;
eprintln!(
"[Storage] Seite {page_id} wurde außerhalb geändert, Konfliktkopie: {}",
path.display()
);
Ok(path)
}
/// Lädt eine Seite aus `page_<id>.json`.
@@ -735,6 +913,7 @@ pub fn load_page(notebook_dir: &Path, page_id: u64) -> Result<PageData, StorageE
});
}
seen_files().insert(seen_key(&path), file_hash(json_str.as_bytes()));
Ok(page_data)
}
@@ -763,7 +942,8 @@ pub fn save_notebook(
/// Lädt ein Notizbuch aus einem Ordner (erste Seite sofort, Folgeseiten als Platzhalter für Lazy Loading).
pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest), StorageError> {
let _ = cleanup_orphaned_temp_files(notebook_dir, std::time::Duration::from_secs(3600));
let manifest = load_manifest(notebook_dir)?;
let (manifest, base) = load_manifest_as_base(notebook_dir)?;
base.adopt();
let page_ids = manifest.page_ids();
if page_ids.is_empty() {
let nb = Notebook::new();
@@ -817,8 +997,22 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
page.persist = PersistState::ReadOnlyAfterLoadError;
page
}
// Listed but never saved (still empty): an empty page, as with lazy loading. Nothing
// can be overwritten, there is no file.
Err(StorageError::PageNotFound(_)) => {
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id))
}
Err(e) => {
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
// Without the warning the page looks writable while nothing on it is saved
load_warnings.insert(
first_id,
PageLoadWarning {
page_id: first_id,
message: e.to_string(),
quarantine_path: None,
},
);
let mut page =
Page::new(first_id, first_bg).with_size(manifest.size_for_page(first_id));
page.persist = PersistState::ReadOnlyAfterLoadError;
@@ -931,6 +1125,14 @@ pub fn ensure_page_loaded(
}
Err(e) => {
eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}");
notebook.load_warnings.insert(
page_id,
PageLoadWarning {
page_id,
message: e.to_string(),
quarantine_path: None,
},
);
notebook.pages[page_idx].is_loaded = true;
notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError;
notebook.pages[page_idx].rebuild_cache_if_dirty();
@@ -1502,7 +1704,23 @@ pub fn restore_manifest_from_backup(notebook_dir: &Path) -> Result<(), StorageEr
}
}
/// Time an entry was moved into `.trash`, read from the `_<unix seconds>` suffix of its name
/// (`<id>_<ts>`, `sync_<ts>`).
fn trashed_at(name: &str) -> Option<std::time::SystemTime> {
let (_, ts) = name.rsplit_once('_')?;
if ts.is_empty() || !ts.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let secs = ts.parse::<u64>().ok()?;
std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
}
/// Entfernt Einträge aus `.trash`, die älter als `max_age` sind.
///
/// The age counts from the moment of deletion, which is the timestamp in the entry name.
/// The modification time of a folder survives the move into `.trash`, so it would purge a
/// notebook that had not been edited for a while right after it was deleted. It is only the
/// fallback for entries without a timestamp in the name.
pub fn cleanup_trash(base_dir: &Path, max_age: std::time::Duration) -> io::Result<usize> {
let trash_dir = base_dir.join(".trash");
if !trash_dir.exists() {
@@ -1513,20 +1731,25 @@ pub fn cleanup_trash(base_dir: &Path, max_age: std::time::Duration) -> io::Resul
if let Ok(entries) = fs::read_dir(&trash_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Ok(metadata) = entry.metadata() {
if let Ok(modified) = metadata.modified() {
if let Ok(age) = now.duration_since(modified) {
if age >= max_age {
if path.is_dir() {
if fs::remove_dir_all(&path).is_ok() {
removed += 1;
}
} else if fs::remove_file(&path).is_ok() {
removed += 1;
}
}
}
}
let since = entry
.file_name()
.to_str()
.and_then(trashed_at)
.or_else(|| entry.metadata().ok()?.modified().ok());
let expired = since
.and_then(|t| now.duration_since(t).ok())
.is_some_and(|age| age >= max_age);
if !expired {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
let gone = if is_dir {
fs::remove_dir_all(&path)
} else {
fs::remove_file(&path)
};
if gone.is_ok() {
removed += 1;
}
}
}
@@ -1566,6 +1789,77 @@ mod tests {
use std::sync::Arc;
use std::time::{Duration, Instant};
#[test]
fn test_scaled_stroke_keeps_its_width_after_reload() {
let temp_dir = tempfile::tempdir().unwrap();
let color = [0.0, 0.0, 0.0, 1.0];
let samples: Vec<StylusSample> = (0..20)
.map(|i| StylusSample {
position: glam::Vec2::new(200.0 + 10.0 * i as f32, 500.0),
pressure: 0.5,
timestamp: i as f64 * 0.01,
})
.collect();
let tool = crate::document::StrokeTool::default();
let vertices = reconstruct_stroke_vertices(&samples, color, 5.0, tool, true);
let mut stroke = Stroke::new(1, samples, vertices, color);
stroke.width = 5.0;
stroke.finalized = true;
let thickness = |s: &Stroke| {
let ys = s.vertices.iter().map(|v| v.position[1]);
ys.clone().fold(f32::MIN, f32::max) - ys.fold(f32::MAX, f32::min)
};
let before = thickness(&stroke);
stroke.scale(glam::Vec2::new(200.0, 500.0), glam::Vec2::splat(3.0));
assert!((stroke.width - 15.0).abs() < 1e-4);
let scaled = thickness(&stroke);
assert!((scaled - 3.0 * before).abs() < 0.01);
let mut page = Page::new(1, PageBackground::Blank);
page.add_stroke(stroke);
save_page(temp_dir.path(), &PageData::from(&page)).unwrap();
let loaded = load_page(temp_dir.path(), 1).unwrap().to_page();
assert!((loaded.strokes[0].width - 15.0).abs() < 1e-4);
let reloaded = thickness(&loaded.strokes[0]);
assert!(
(reloaded - scaled).abs() < 0.05 * scaled,
"scaled {scaled}, after reload {reloaded}"
);
}
#[test]
fn test_cleanup_trash_counts_from_deletion_not_from_last_edit() {
let base = tempfile::tempdir().unwrap();
let trash = base.path().join(".trash");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let day = 24 * 3600;
let long_ago = std::time::SystemTime::now() - Duration::from_secs(90 * day);
// Deleted just now, last edited 90 days ago: stays
let fresh = trash.join(format!("nb_a_{now}"));
// Deleted 40 days ago: goes
let expired = trash.join(format!("nb_b_{}", now - 40 * day));
// No timestamp in the name: the modification time decides
let unnamed = trash.join("stray");
for dir in [&fresh, &expired, &unnamed] {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join("manifest.json"), "{}").unwrap();
}
for dir in [&fresh, &unnamed] {
fs::File::open(dir).unwrap().set_modified(long_ago).unwrap();
}
let removed = cleanup_trash(base.path(), Duration::from_secs(30 * day)).unwrap();
assert_eq!(removed, 2);
assert!(fresh.exists());
assert!(!expired.exists());
assert!(!unnamed.exists());
}
#[test]
fn test_atomic_save_and_reload() {
let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen");
@@ -2111,10 +2405,73 @@ mod tests {
fs::write(&broken_manifest_path, b"{ broken json ... ").unwrap();
let loaded = load_manifest(nb_dir).expect("Muss aus .bak geladen werden");
assert_eq!(loaded.id, "nb_test");
assert_eq!(loaded.title, "Backup Test");
}
#[test]
fn test_first_page_that_fails_to_load_warns_and_a_missing_one_is_empty() {
let base = tempfile::tempdir().unwrap();
let (_, _, dir) = create_new_notebook(base.path(), "First page").unwrap();
let first = load_manifest(&dir).unwrap().page_ids()[0];
let file = dir.join(format!("page_{first}.json"));
// Never saved: an ordinary empty page
let _ = fs::remove_file(&file);
let (nb, _) = load_notebook(&dir).unwrap();
assert!(!nb.pages[0].is_readonly());
assert!(nb.load_warnings.is_empty());
// Written by a newer version: read-only, and the user is told
let mut data = PageData::from(&Page::new(first, PageBackground::Blank));
data.format_version = CURRENT_FORMAT_VERSION + 1;
fs::write(&file, serde_json::to_vec(&data).unwrap()).unwrap();
let (nb, _) = load_notebook(&dir).unwrap();
assert!(nb.pages[0].is_readonly());
assert!(nb.load_warnings.contains_key(&first));
}
#[test]
fn test_newer_manifest_is_not_replaced_by_its_backup() {
let temp_dir = tempfile::tempdir().unwrap();
let nb_dir = temp_dir.path();
let old = NotebookManifest::new("nb", "Old state");
atomic_write_json(&nb_dir.join("manifest.json.bak"), &old).unwrap();
let mut newer = NotebookManifest::new("nb", "New state");
newer.format_version = CURRENT_FORMAT_VERSION + 1;
atomic_write_json(&nb_dir.join("manifest.json"), &newer).unwrap();
assert!(matches!(
load_manifest(nb_dir),
Err(StorageError::UnsupportedVersion { .. })
));
}
#[test]
fn test_copied_notebook_folder_is_a_notebook_of_its_own() {
let base = tempfile::tempdir().unwrap();
let (_, manifest, dir) = create_new_notebook(base.path(), "Original").unwrap();
let copy = base.path().join("copy_of_it");
fs::create_dir(&copy).unwrap();
fs::copy(dir.join("manifest.json"), copy.join("manifest.json")).unwrap();
let mut ids: Vec<String> = list_notebooks(base.path())
.into_iter()
.map(|s| s.id)
.collect();
ids.sort();
let mut expected = vec!["copy_of_it".to_string(), manifest.id.clone()];
expected.sort();
assert_eq!(ids, expected);
// Opened by its folder name, and the id stays that way once saved
let (_, mut loaded) = load_notebook(&copy).unwrap();
assert_eq!(loaded.id, "copy_of_it");
loaded.title = "Copy".to_string();
save_manifest(&copy, &loaded).unwrap();
assert_eq!(load_manifest(&dir).unwrap().title, "Original");
assert_eq!(load_manifest(&copy).unwrap().title, "Copy");
}
#[test]
fn test_create_notebook_collision_generates_new_id() {
let temp_dir = tempfile::tempdir().unwrap();
+41
View File
@@ -272,6 +272,22 @@ pub fn is_newer_version(remote_tag: &str, current_tag: &str, include_prereleases
}
}
/// Version in a release file name (`ink-notes_<version>_<arch>.<ext>`).
pub fn asset_version(asset_name: &str) -> Option<&str> {
let rest = asset_name.strip_prefix("ink-notes_")?;
let (version, _) = rest.split_once('_')?;
parse_semver(version).map(|_| version)
}
/// Is the package named `asset_name` newer than the running version `current`?
///
/// The signature only covers file names and hashes, not the release they are offered in. A
/// server could therefore present an old, correctly signed package under a new version
/// number. The version in the signed file name closes that gap.
pub fn asset_is_upgrade(asset_name: &str, current: &str) -> bool {
asset_version(asset_name).is_some_and(|version| is_newer_version(version, current, true))
}
/// Berechnet den hex-kodierten SHA-256 Hash einer Datei.
pub fn compute_sha256(path: &Path) -> Result<String, String> {
let mut file = std::fs::File::open(path).map_err(|e| {
@@ -712,6 +728,14 @@ impl UpdaterController {
}
};
if !asset_is_upgrade(&safe_asset_name, env!("CARGO_PKG_VERSION")) {
self.status = UpdaterStatus::Error(t!(
"'{asset_name}' ist keine neuere Version und wird nicht installiert.",
asset_name = safe_asset_name
));
return;
}
self.status = UpdaterStatus::Downloading {
asset_name: safe_asset_name.clone(),
percent: 0.0,
@@ -1036,6 +1060,23 @@ mod tests {
assert!(is_newer_version("1.0.0-rc1", "0.9.0", true));
}
#[test]
fn test_only_packages_named_as_newer_are_upgrades() {
assert_eq!(asset_version("ink-notes_0.46.0_amd64.deb"), Some("0.46.0"));
assert_eq!(
asset_version("ink-notes_0.46.0_x86_64.AppImage"),
Some("0.46.0")
);
assert_eq!(asset_version("SHA256SUMS.txt"), None);
assert_eq!(asset_version("ink-notes_latest_amd64.deb"), None);
assert!(asset_is_upgrade("ink-notes_0.46.0_arm64.apk", "0.45.0"));
// An old release offered again under a new tag
assert!(!asset_is_upgrade("ink-notes_0.30.0_amd64.deb", "0.45.0"));
assert!(!asset_is_upgrade("ink-notes_0.45.0_x86_64.exe", "0.45.0"));
assert!(!asset_is_upgrade("something_else.deb", "0.45.0"));
}
#[test]
fn test_sanitize_asset_name() {
assert_eq!(