Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beb780b420 | ||
|
|
9b34e30475 |
No files matched your search
Generated
+1
-1
@@ -1411,7 +1411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
+3
-1
@@ -68,6 +68,7 @@ Die Oberfläche gibt es auf Deutsch und Englisch; ink-notes folgt der Systemspra
|
||||
- Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei.
|
||||
- **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an.
|
||||
- `Strg + S` speichert sofort. Der Punkt am ☰-Knopf zeigt den Stand: grün = gespeichert, orange = Änderungen warten, gelb = speichert gerade, rot = Fehler.
|
||||
- **Speicherort** (☰-Menü, „Speicherort“): Liegen die Notizbücher in einem Ordner, den **Nextcloud, Google Drive, Syncthing oder Dropbox** abgleichen, sind sie auf allen Geräten verfügbar. Die Notizbücher liegen im Unterordner `ink-notes`; ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert (die alten bleiben erhalten). Änderungen von einem anderen Gerät erscheinen nach wenigen Sekunden ohne neu zu öffnen – aber nur, solange lokal alles gespeichert ist, damit nichts überschrieben wird. Legt der Sync-Client eine Konfliktkopie einer Seite an, wird sie als eigene Seite („Konfliktkopie: …“) übernommen statt verloren zu gehen. Ist ein Notizbuch gerade auf einem anderen Gerät geöffnet, warnt die App beim Öffnen.
|
||||
|
||||
### Export
|
||||
- **PDF** (`Strg + E`) nach `~/Documents/<Titel>.pdf`: Striche und Formen als Vektoren, Textfelder als echter, durchsuchbarer Text, importierte PDF-Seiten als Vektor-Unterlage, Seitentitel als Lesezeichen.
|
||||
@@ -178,7 +179,8 @@ src/
|
||||
├── saber.rs # Import von Saber-Notizbüchern
|
||||
├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen
|
||||
├── history.rs # Rückgängig/Wiederholen
|
||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb
|
||||
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
|
||||
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
|
||||
├── library.rs # Ordner der Notizbücher (library.json)
|
||||
├── autosave.rs # Speichern im Hintergrund
|
||||
├── export.rs # PDF-Export (printpdf)
|
||||
|
||||
@@ -68,6 +68,7 @@ The interface is available in English and German; ink-notes follows the system l
|
||||
- Every file is first written completely to a temporary file and then replaced – a crash while saving never leaves a half-written file.
|
||||
- **When closing** (window or `Ctrl + Q`) and when switching notebooks, everything pending is written before continuing. If that fails, the app stays open and offers to retry or to make an emergency backup.
|
||||
- `Ctrl + S` saves immediately. The dot on the ☰ button shows the state: green = saved, orange = changes pending, yellow = saving, red = error.
|
||||
- **Storage location** (☰ menu, "Storage location"): put the notebooks into a folder that **Nextcloud, Google Drive, Syncthing or Dropbox** keep in sync, and they are available on all your devices. The notebooks are kept in the subfolder `ink-notes`; if it is still empty, the existing notebooks are copied there (the old ones stay). Changes arriving from another device show up within a few seconds without reopening – but only while everything local is saved, so nothing gets overwritten. If the sync client makes a conflict copy of a page, it is added as a page of its own („Conflict copy: …“) instead of getting lost. Opening a notebook that is open on another device shows a warning.
|
||||
|
||||
### Export
|
||||
- **PDF** (`Ctrl + E`) to `~/Documents/<title>.pdf`: strokes and shapes as vectors, text boxes as real, searchable text, imported PDF pages as vector background, page titles as bookmarks.
|
||||
@@ -180,7 +181,8 @@ src/
|
||||
├── saber.rs # Import of Saber notebooks
|
||||
├── document.rs # Data model: notebook, pages, strokes, page sizes
|
||||
├── history.rs # Undo/redo
|
||||
├── storage.rs # Loading/saving (atomic), notebooks, trash
|
||||
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
|
||||
├── disk_watch.rs # Taking over changes from other devices (sync clients)
|
||||
├── library.rs # Notebook folders (library.json)
|
||||
├── autosave.rs # Saving in the background
|
||||
├── export.rs # PDF export (printpdf)
|
||||
|
||||
@@ -21,6 +21,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
||||
│ │ ├── assets/ eingefügte Bilder und importierte PDFs
|
||||
│ │ │ ├── <sha256>.png
|
||||
│ │ │ └── <sha256>.pdf
|
||||
│ │ ├── .opened_by.json Gerät, auf dem das Notizbuch geöffnet ist (siehe unten)
|
||||
│ │ └── .lock Sperrdatei, solange das Notizbuch geöffnet ist
|
||||
│ └── .trash/ gelöschte Notizbücher (<id>_<zeitstempel>)
|
||||
└── recovery/ Notfall-Sicherungen ungespeicherter Seiten
|
||||
@@ -28,6 +29,8 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die
|
||||
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) Einstellungen der Oberfläche
|
||||
```
|
||||
|
||||
Der Ordner `notebooks/` lässt sich in den Einstellungen verlegen (`storage_dir` in `ui.json`), z. B. nach `~/Nextcloud/ink-notes`; `recovery/` bleibt, wo es ist.
|
||||
|
||||
- **Notizbuch-IDs** werden aus dem Titel und dem Zeitpunkt gebildet (z. B. `mathematik_1775460000`); das erste Notizbuch heißt `default`.
|
||||
- **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert.
|
||||
- Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt.
|
||||
@@ -167,6 +170,12 @@ Ein Absturz während des Speicherns hinterlässt deshalb entweder die alte oder
|
||||
### Gelöschte Seiten
|
||||
Eine gelöschte Seite verschwindet aus `manifest.json`; ihre Datei `page_<id>.json` bleibt im Ordner liegen. Seitenkennungen werden nie wiederverwendet (`next_page_id`), die Datei kann also mit keiner neuen Seite kollidieren. Wird die Seite in derselben Sitzung wiederhergestellt, schreibt die App sie neu.
|
||||
|
||||
### Abgleich zwischen Geräten
|
||||
Liegen die Notizbücher in einem Ordner, den Nextcloud, Google Drive, Syncthing o. Ä. abgleichen:
|
||||
- `.opened_by.json` (`{"device": "<Rechnername>", "since": <Unix-Zeit>}`) wird beim Öffnen eines Notizbuchs geschrieben und beim Schließen entfernt. Eine Markierung eines anderen Geräts führt zu einer Warnung; die Sperrdatei `.lock` schützt nur vor einer zweiten Instanz auf demselben Rechner.
|
||||
- Solange alle lokalen Änderungen gespeichert sind, prüft die App den Ordner alle 3 Sekunden (und wenn das Fenster den Fokus bekommt). Geänderte Seitendateien werden neu geladen, ein geändertes `manifest.json` bringt neue, entfernte oder umsortierte Seiten und Titel mit.
|
||||
- Konfliktkopien der Sync-Clients (`page_<id> (….json`, `page_<id>_….json`, `page_<id>.sync-conflict-….json`) werden als neue Seiten hinter dem Original eingefügt und in `<name>.imported` umbenannt.
|
||||
|
||||
### Neuere Versionen
|
||||
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ This document describes where and how ink-notes stores notebooks, and the versio
|
||||
│ │ ├── assets/ inserted images and imported PDFs
|
||||
│ │ │ ├── <sha256>.png
|
||||
│ │ │ └── <sha256>.pdf
|
||||
│ │ ├── .opened_by.json device that has the notebook open (see below)
|
||||
│ │ └── .lock lock file while the notebook is open
|
||||
│ └── .trash/ deleted notebooks (<id>_<timestamp>)
|
||||
└── recovery/ emergency backups of unsaved pages
|
||||
@@ -28,6 +29,8 @@ This document describes where and how ink-notes stores notebooks, and the versio
|
||||
~/.config/ink-notes/ui.json ($XDG_CONFIG_HOME) interface settings
|
||||
```
|
||||
|
||||
The folder `notebooks/` can be moved elsewhere in the settings (`storage_dir` in `ui.json`), e.g. to `~/Nextcloud/ink-notes`; `recovery/` stays where it is.
|
||||
|
||||
- **Notebook IDs** are built from the title and the time (e.g. `mathematik_1775460000`); the first notebook is called `default`.
|
||||
- **Assets** are named after the SHA-256 hash of their content; the same image is stored only once. Images are stored as PNG or (unchanged) JPEG, PDFs unchanged.
|
||||
- All files are created with permissions `0600`, folders with `0700`.
|
||||
@@ -167,6 +170,12 @@ A crash while saving therefore leaves either the old or the new file, never a ha
|
||||
### Deleted pages
|
||||
A deleted page disappears from `manifest.json`; its file `page_<id>.json` stays in the folder. Page IDs are never reused (`next_page_id`), so the file cannot collide with a new page. If the page is restored in the same session, the app writes it again.
|
||||
|
||||
### Syncing between devices
|
||||
If the notebooks are kept in a folder synced by Nextcloud, Google Drive, Syncthing or similar:
|
||||
- `.opened_by.json` (`{"device": "<host name>", "since": <unix time>}`) is written when a notebook is opened and removed when it is closed. A marker from another device leads to a warning; the lock file `.lock` only protects against a second instance on the same computer.
|
||||
- While all local changes are saved, the app checks the folder every 3 seconds (and when the window gets the focus). Changed page files are reloaded, a changed `manifest.json` brings in new, removed or reordered pages and titles.
|
||||
- Conflict copies of sync clients (`page_<id> (….json`, `page_<id>_….json`, `page_<id>.sync-conflict-….json`) are added as new pages after the original and renamed to `<name>.imported`.
|
||||
|
||||
### Newer versions
|
||||
Files with a higher `format_version` than supported are not opened – this way an older app version cannot lose content it does not know.
|
||||
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.38.0" date="2026-10-27">
|
||||
<description>
|
||||
<p>The notebooks can be stored in a folder synced by Nextcloud, Google Drive, Syncthing or Dropbox. Changes from other devices are taken over automatically, conflict copies become pages of their own, and a warning appears when a notebook is open on another device.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.37.0" date="2026-10-27">
|
||||
<description>
|
||||
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.38.0" date="2026-10-27">
|
||||
<description>
|
||||
<p>The notebooks can be stored in a folder synced by Nextcloud, Google Drive, Syncthing or Dropbox. Changes from other devices are taken over automatically, conflict copies become pages of their own, and a warning appears when a notebook is open on another device.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.37.0" date="2026-10-27">
|
||||
<description>
|
||||
<p>Saber notebooks (.sbn2, .sba, .sbn) can be imported as new notebooks, with strokes, pen pressure, highlighters, shapes, typed text, images and PDF backgrounds.</p>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Sync between devices**: ☰ → Storage location puts the notebooks into a folder synced by Nextcloud, Google Drive, Syncthing or Dropbox (existing notebooks are copied, the originals stay). Changes from other devices show up within seconds while everything local is saved, conflict copies of the sync client become pages of their own, and opening a notebook that is open on another device shows a warning
|
||||
- **Import from Saber**: ☰ → Import (or dragging the file into the window) now also opens Saber notebooks (`.sbn2` with its asset files next to it, `.sba` archives, old `.sbn`) – strokes with pen pressure, highlighters, shapes, typed text, images and PDF backgrounds
|
||||
- **Import from Xournal++ and Rnote**: ☰ → Import (or dragging the file into the window) now also opens Xournal++ (`.xopp`, `.xoj`) and Rnote (`.rnote`) notebooks as new notebooks – strokes with pen pressure, highlighters, shapes, texts, images, paper patterns and Xournal++ PDF backgrounds; PDF pages imported in Rnote become images, infinite Rnote documents an endless notebook
|
||||
- **Faster export**: PDF and PNG export and copying a selection as an image are about six times faster and PDFs are up to eight times smaller, because stroke outlines are simplified before export (at most 0.01 mm off; `INK_NOTES_EXACT_OUTLINES=1` exports every point as before)
|
||||
|
||||
+145
-2
@@ -22,6 +22,7 @@ use crate::assets::{AssetStore, ImportedImage};
|
||||
use crate::autosave::{AutosaveController, AutosaveStatus};
|
||||
use crate::background::PatternColor;
|
||||
use crate::canvas::CanvasState;
|
||||
use crate::disk_watch::DiskWatch;
|
||||
use crate::document::{Notebook, PageBackground};
|
||||
use crate::export::{self, export_notebook_to_pdf, ExportStrategy};
|
||||
use crate::geometry::{self, RecognizedShape};
|
||||
@@ -214,6 +215,16 @@ fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_st
|
||||
}
|
||||
}
|
||||
|
||||
/// Warns when the notebook is still marked as open on another device (Issue #49).
|
||||
fn warn_if_open_elsewhere(lock: Option<&storage::NotebookLock>, ui_state: &mut UiState) {
|
||||
if let Some(marker) = lock.and_then(|l| l.open_elsewhere.as_ref()) {
|
||||
ui_state.set_export_message(t!(
|
||||
"⚠ Auch auf '{device}' geöffnet – Änderungen dort können deine überschreiben",
|
||||
device = marker.device
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn update_window_title(
|
||||
window: &Window,
|
||||
notebook: &Notebook,
|
||||
@@ -1308,7 +1319,10 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut ime_composing = false;
|
||||
|
||||
// 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung)
|
||||
let storage_base_dir = storage::default_storage_dir();
|
||||
let mut storage_base_dir = ui_settings
|
||||
.storage_dir
|
||||
.clone()
|
||||
.unwrap_or_else(storage::default_storage_dir);
|
||||
let _ = storage::cleanup_trash(
|
||||
&storage_base_dir,
|
||||
std::time::Duration::from_secs(30 * 24 * 3600),
|
||||
@@ -1408,6 +1422,8 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = available_notebooks;
|
||||
ui_state.library = Library::load(&storage_base_dir);
|
||||
ui_state.storage_dir = storage_base_dir.clone();
|
||||
ui_state.storage_dir_custom = ui_settings.storage_dir.is_some();
|
||||
if let Some(warning) = startup_warning {
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
ui_state.set_export_message(warning);
|
||||
@@ -1426,6 +1442,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
|
||||
Ok(lock) => {
|
||||
autosave.set_read_only(false);
|
||||
warn_if_open_elsewhere(Some(&lock), &mut ui_state);
|
||||
Some(lock)
|
||||
}
|
||||
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
||||
@@ -1441,6 +1458,9 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
};
|
||||
|
||||
// Notices changes made on other devices through a sync client (Issue #49)
|
||||
let mut disk_watch = DiskWatch::new(storage_dir.clone(), Instant::now());
|
||||
|
||||
// Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren
|
||||
canvas.set_paper_theme(ui_state.paper_theme, ui_state.adapt_ink_colors);
|
||||
canvas.set_pattern_color(ui_state.pattern_color);
|
||||
@@ -1616,6 +1636,50 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, new_status);
|
||||
}
|
||||
|
||||
// Take over changes from other devices (Issue #49), but only while everything
|
||||
// local is saved and nothing is being drawn or typed
|
||||
if disk_watch.dir != storage_dir {
|
||||
disk_watch = DiskWatch::new(storage_dir.clone(), Instant::now());
|
||||
}
|
||||
let now = Instant::now();
|
||||
if disk_watch.is_due(now) {
|
||||
let idle = new_status == AutosaveStatus::Saved
|
||||
&& !is_drawing
|
||||
&& !is_stylus_drawing
|
||||
&& ui_state.text_edit.is_none();
|
||||
if !idle {
|
||||
disk_watch.postpone(now);
|
||||
} else {
|
||||
let outcome = disk_watch.check(&mut notebook, &mut manifest, now);
|
||||
if !outcome.is_empty() {
|
||||
if outcome.reloaded_pages > 0 || outcome.structure_changed || !outcome.conflict_pages.is_empty() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.set_export_message(t!("Änderungen von einem anderen Gerät übernommen").to_string());
|
||||
}
|
||||
if !outcome.conflict_pages.is_empty() {
|
||||
for page in notebook.pages.iter().filter(|p| outcome.conflict_pages.contains(&p.id)) {
|
||||
autosave.mark_page_dirty(page, &manifest);
|
||||
}
|
||||
autosave.mark_manifest_dirty(&manifest);
|
||||
ui_state.set_export_message(t!(
|
||||
"Konfliktkopien als eigene Seiten übernommen: {count}",
|
||||
count = outcome.conflict_pages.len()
|
||||
));
|
||||
}
|
||||
if let Some(marker) = &outcome.opened_elsewhere {
|
||||
ui_state.set_export_message(t!(
|
||||
"⚠ Gerade auf '{device}' geöffnet – Änderungen dort können deine überschreiben",
|
||||
device = marker.device
|
||||
));
|
||||
}
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Ok(result) = export_rx.try_recv() {
|
||||
match result {
|
||||
Ok(path) => ui_state.set_export_message(t!("Gespeichert: {path}", path = path.display())),
|
||||
@@ -2149,7 +2213,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|| fling.is_active()
|
||||
{
|
||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||
} else if let Some(at) = [egui_repaint_at, long_press.deadline()]
|
||||
} else if let Some(at) = [egui_repaint_at, long_press.deadline(), Some(disk_watch.deadline())]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min()
|
||||
@@ -2234,6 +2298,12 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
WindowEvent::Focused(true) => {
|
||||
// Coming back from another app: look for changes right away
|
||||
disk_watch.check_soon(Instant::now());
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
WindowEvent::Focused(false) => {
|
||||
// Bei Fokusverlust Strich abbrechen und 1€-Filter resetten
|
||||
if is_drawing {
|
||||
@@ -3828,6 +3898,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if _notebook_lock.is_some() {
|
||||
ui_state.set_export_message(t!("Dokument '{title}' geöffnet", title = manifest.title));
|
||||
}
|
||||
warn_if_open_elsewhere(_notebook_lock.as_ref(), &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -3991,6 +4062,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message(t!("Gewechselt zu '{title}'", title = manifest.title));
|
||||
warn_if_open_elsewhere(_notebook_lock.as_ref(), &mut ui_state);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id);
|
||||
@@ -4127,6 +4199,77 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SetStorageDir(chosen) => {
|
||||
let new_base = match &chosen {
|
||||
Some(dir) => storage::storage_dir_in(dir),
|
||||
None => storage::default_storage_dir(),
|
||||
};
|
||||
if new_base == storage_base_dir {
|
||||
ui_state.set_export_message(t!("Die Notizbücher liegen bereits dort").to_string());
|
||||
} else {
|
||||
let _ = autosave.flush();
|
||||
match storage::copy_storage(&storage_base_dir, &new_base) {
|
||||
Ok(copied) => {
|
||||
// Release lock and open marker in the old location
|
||||
_notebook_lock = None;
|
||||
storage_base_dir = new_base;
|
||||
ui_settings.storage_dir = chosen.map(|_| storage_base_dir.clone());
|
||||
save_ui_settings(&ui_settings, ui_settings_path.as_deref(), &mut ui_state);
|
||||
ui_state.storage_dir = storage_base_dir.clone();
|
||||
ui_state.storage_dir_custom = ui_settings.storage_dir.is_some();
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
// Same notebook if it exists there, otherwise the first one
|
||||
let open_id = ui_state
|
||||
.available_notebooks
|
||||
.iter()
|
||||
.filter(|s| s.status == storage::NotebookStatus::Normal)
|
||||
.find(|s| s.id == manifest.id)
|
||||
.or_else(|| ui_state.available_notebooks.iter().find(|s| s.status == storage::NotebookStatus::Normal))
|
||||
.map(|s| s.id.clone());
|
||||
let loaded = match open_id {
|
||||
Some(id) => {
|
||||
let dir = storage_base_dir.join(&id);
|
||||
storage::load_notebook(&dir).map(|(nb, m)| (nb, m, dir))
|
||||
}
|
||||
None => storage::create_new_notebook(&storage_base_dir, t!("Standard-Notizbuch")),
|
||||
};
|
||||
match loaded {
|
||||
Ok((new_nb, new_manifest, new_dir)) => {
|
||||
_notebook_lock = storage::NotebookLock::acquire(&new_dir).ok();
|
||||
autosave.set_read_only(_notebook_lock.is_none());
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
refresh_notebooks(&storage_base_dir, &mut ui_state);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message(if copied > 0 {
|
||||
t!("Speicherort geändert, {count} Notizbücher kopiert", count = copied)
|
||||
} else {
|
||||
t!("Speicherort geändert: {path}", path = storage_base_dir.display())
|
||||
});
|
||||
warn_if_open_elsewhere(_notebook_lock.as_ref(), &mut ui_state);
|
||||
}
|
||||
Err(err) => {
|
||||
// The old notebook stays open but is saved nowhere
|
||||
autosave.set_read_only(true);
|
||||
eprintln!("[Storage] Notizbuch am neuen Speicherort nicht geöffnet: {err}");
|
||||
ui_state.set_export_message(t!("Fehler beim Öffnen: {err}", err = err));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Storage] Speicherort '{}' nicht nutzbar: {err}", new_base.display());
|
||||
ui_state.set_export_message(t!("Speicherort nicht nutzbar: {err}", err = err));
|
||||
}
|
||||
}
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::SetLanguage(language) => {
|
||||
ui_settings.language = language;
|
||||
i18n::set_language(language.unwrap_or_else(Language::system));
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
//! Notices changes that another device made to the open notebook through a sync client
|
||||
//! such as Nextcloud or Google Drive (Issue #49).
|
||||
//!
|
||||
//! Every few seconds the notebook folder is scanned (only file times and sizes). Pages whose
|
||||
//! file changed are compared with the page in memory and reloaded if they differ; a changed
|
||||
//! page order, new or removed pages and titles from the manifest are taken over; conflict
|
||||
//! copies made by sync clients (`page_3 (conflicted copy …).json`, `page_3 (1).json`,
|
||||
//! `page_3.sync-conflict-….json`) become pages of their own, so nothing is lost. The check
|
||||
//! only runs while all local changes are saved, so it never overwrites anything unsaved.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::document::{Notebook, Page, PageBackground};
|
||||
use crate::storage::{self, NotebookManifest, OpenMarker, PageData};
|
||||
|
||||
/// Time between two checks.
|
||||
pub const INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
type Fingerprint = (Option<SystemTime>, u64);
|
||||
|
||||
/// What a check found and took over.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Outcome {
|
||||
/// Pages reloaded because their file changed
|
||||
pub reloaded_pages: usize,
|
||||
/// Pages added, removed, reordered or retitled through the manifest
|
||||
pub structure_changed: bool,
|
||||
/// Ids of the conflict copies added as pages (they need saving)
|
||||
pub conflict_pages: Vec<u64>,
|
||||
/// Another device has just opened the notebook
|
||||
pub opened_elsewhere: Option<OpenMarker>,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == Outcome::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DiskWatch {
|
||||
pub dir: PathBuf,
|
||||
known: HashMap<String, Fingerprint>,
|
||||
/// Files that are neither pages nor conflict copies we could read
|
||||
ignored: HashSet<String>,
|
||||
next_check: Instant,
|
||||
}
|
||||
|
||||
/// Fingerprints of the JSON files in the notebook folder.
|
||||
fn scan(dir: &Path) -> HashMap<String, Fingerprint> {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !name.ends_with(".json") {
|
||||
return None;
|
||||
}
|
||||
let meta = entry.metadata().ok()?;
|
||||
meta.is_file()
|
||||
.then(|| (name, (meta.modified().ok(), meta.len())))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Page id of a regular page file (`page_<id>.json`).
|
||||
fn page_file_id(name: &str) -> Option<u64> {
|
||||
name.strip_prefix("page_")?
|
||||
.strip_suffix(".json")?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Page id a conflict copy belongs to: `page_<id>` followed by something else.
|
||||
pub fn conflict_copy_of(name: &str) -> Option<u64> {
|
||||
if page_file_id(name).is_some() {
|
||||
return None;
|
||||
}
|
||||
let rest = name.strip_prefix("page_")?.strip_suffix(".json")?;
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
let tail = &rest[digits.len()..];
|
||||
let marked =
|
||||
tail.starts_with(" (") || tail.starts_with(".sync-conflict") || tail.starts_with('_');
|
||||
(marked && !digits.is_empty())
|
||||
.then(|| digits.parse().ok())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Page content without the format version (that differs between app versions).
|
||||
fn content(mut data: PageData) -> PageData {
|
||||
data.format_version = 0;
|
||||
data
|
||||
}
|
||||
|
||||
impl DiskWatch {
|
||||
pub fn new(dir: PathBuf, now: Instant) -> Self {
|
||||
Self {
|
||||
known: scan(&dir),
|
||||
dir,
|
||||
ignored: HashSet::new(),
|
||||
next_check: now + INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deadline(&self) -> Instant {
|
||||
self.next_check
|
||||
}
|
||||
|
||||
pub fn is_due(&self, now: Instant) -> bool {
|
||||
now >= self.next_check
|
||||
}
|
||||
|
||||
/// Skips the check that is due now (e.g. while drawing).
|
||||
pub fn postpone(&mut self, now: Instant) {
|
||||
self.next_check = now + INTERVAL;
|
||||
}
|
||||
|
||||
/// Checks right away at the next opportunity (e.g. when the window gets the focus).
|
||||
pub fn check_soon(&mut self, now: Instant) {
|
||||
self.next_check = now;
|
||||
}
|
||||
|
||||
/// Takes over what changed on disk. Call only while all local changes are saved.
|
||||
pub fn check(
|
||||
&mut self,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
now: Instant,
|
||||
) -> Outcome {
|
||||
self.next_check = now + INTERVAL;
|
||||
let mut outcome = Outcome::default();
|
||||
let current = scan(&self.dir);
|
||||
let changed = |name: &str| current.get(name) != self.known.get(name);
|
||||
let mut retry: HashSet<String> = HashSet::new();
|
||||
|
||||
if changed(storage::OPEN_MARKER_FILE) {
|
||||
outcome.opened_elsewhere = fs::read(self.dir.join(storage::OPEN_MARKER_FILE))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<OpenMarker>(&b).ok())
|
||||
.filter(|m| m.device != storage::device_name());
|
||||
}
|
||||
|
||||
// Page order, titles, new and removed pages
|
||||
let mut fresh_pages: HashSet<u64> = HashSet::new();
|
||||
if changed("manifest.json") {
|
||||
match storage::load_manifest(&self.dir) {
|
||||
Ok(theirs) => match self.apply_manifest(notebook, manifest, theirs) {
|
||||
Ok(Some(new_ids)) => {
|
||||
fresh_pages = new_ids;
|
||||
outcome.structure_changed = true;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(()) => {
|
||||
retry.insert("manifest.json".to_string());
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
retry.insert("manifest.json".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Changed page files
|
||||
for name in current.keys().filter(|n| changed(n)) {
|
||||
let Some(id) = page_file_id(name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(page) = notebook.pages.iter_mut().find(|p| p.id == id) else {
|
||||
continue;
|
||||
};
|
||||
if !page.is_loaded || fresh_pages.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
match storage::load_page(&self.dir, id) {
|
||||
Ok(data) => {
|
||||
if content(PageData::from(&*page)) != content(data.clone()) {
|
||||
let mut reloaded = data.to_page();
|
||||
reloaded.title = reloaded.title.or_else(|| page.title.clone());
|
||||
*page = reloaded;
|
||||
outcome.reloaded_pages += 1;
|
||||
}
|
||||
}
|
||||
// Possibly still being written by the sync client: try again next time
|
||||
Err(_) => {
|
||||
retry.insert(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict copies of sync clients become pages of their own
|
||||
let mut copies: Vec<&String> = current
|
||||
.keys()
|
||||
.filter(|n| !self.ignored.contains(*n))
|
||||
.filter(|n| conflict_copy_of(n).is_some())
|
||||
.collect();
|
||||
copies.sort();
|
||||
for name in copies {
|
||||
let path = self.dir.join(name);
|
||||
let data = fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<PageData>(&b).ok());
|
||||
match data {
|
||||
Some(data) => {
|
||||
let of = conflict_copy_of(name).unwrap_or(0);
|
||||
outcome
|
||||
.conflict_pages
|
||||
.push(add_conflict_page(notebook, data, of));
|
||||
// Keep the file, but don't take it over again
|
||||
let _ = fs::rename(&path, path.with_extension("json.imported"));
|
||||
}
|
||||
None => {
|
||||
self.ignored.insert(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !outcome.conflict_pages.is_empty() {
|
||||
manifest.sync_from_notebook(notebook);
|
||||
}
|
||||
|
||||
// Remember what we have seen, except files to look at again
|
||||
let mut known = current;
|
||||
for name in retry {
|
||||
match self.known.get(&name) {
|
||||
Some(old) => known.insert(name, *old),
|
||||
None => known.remove(&name),
|
||||
};
|
||||
}
|
||||
self.known = known;
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Takes over the page list of a manifest written elsewhere. Returns the ids of pages
|
||||
/// that are now unloaded placeholders, or `None` if nothing about the pages changed.
|
||||
/// Fails (to be tried again) while the page to show can't be read yet.
|
||||
fn apply_manifest(
|
||||
&self,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
theirs: NotebookManifest,
|
||||
) -> Result<Option<HashSet<u64>>, ()> {
|
||||
let mut ours = manifest.clone();
|
||||
ours.sync_from_notebook(notebook);
|
||||
if ours.pages == theirs.pages && ours.title == theirs.title {
|
||||
return Ok(None);
|
||||
}
|
||||
let current_id = notebook.current_page().id;
|
||||
|
||||
// The page shown afterwards must be loaded. Read it before changing anything: a file
|
||||
// the sync client is still writing must not end up set aside as damaged.
|
||||
let shown = theirs
|
||||
.pages
|
||||
.iter()
|
||||
.find(|m| m.id == current_id)
|
||||
.or(theirs.pages.first())
|
||||
.map(|m| m.id);
|
||||
let mut shown_data = None;
|
||||
if let Some(id) = shown {
|
||||
if !notebook.pages.iter().any(|p| p.id == id && p.is_loaded) {
|
||||
match storage::load_page(&self.dir, id) {
|
||||
Ok(data) => shown_data = Some(data),
|
||||
// Never saved because still empty
|
||||
Err(storage::StorageError::PageNotFound(_)) => {}
|
||||
Err(_) => return Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut old: HashMap<u64, Page> = notebook.pages.drain(..).map(|p| (p.id, p)).collect();
|
||||
let mut placeholders = HashSet::new();
|
||||
for meta in &theirs.pages {
|
||||
let background = meta.background.unwrap_or(PageBackground::Grid5mm);
|
||||
let size = storage::page_size_from(meta.size);
|
||||
let mut page = match old.remove(&meta.id) {
|
||||
Some(mut page) => {
|
||||
page.background = background;
|
||||
page.size = size;
|
||||
page
|
||||
}
|
||||
None => {
|
||||
placeholders.insert(meta.id);
|
||||
Page::new_unloaded(meta.id, background).with_size(size)
|
||||
}
|
||||
};
|
||||
page.title = meta.title.clone();
|
||||
notebook.pages.push(page);
|
||||
}
|
||||
if notebook.pages.is_empty() {
|
||||
notebook.pages.push(Page::new(1, PageBackground::Grid5mm));
|
||||
}
|
||||
notebook.current_page_index = notebook
|
||||
.pages
|
||||
.iter()
|
||||
.position(|p| p.id == current_id)
|
||||
.unwrap_or(0)
|
||||
.min(notebook.pages.len() - 1);
|
||||
notebook.next_page_id = notebook.next_page_id.max(theirs.next_page_id);
|
||||
notebook.next_stroke_id = notebook.next_stroke_id.max(theirs.next_stroke_id);
|
||||
let index = notebook.current_page_index;
|
||||
if !notebook.pages[index].is_loaded {
|
||||
let page = &mut notebook.pages[index];
|
||||
let mut loaded = match shown_data {
|
||||
Some(data) => data.to_page(),
|
||||
None => Page::new(page.id, page.background).with_size(page.size),
|
||||
};
|
||||
loaded.title = loaded.title.or(page.title.take());
|
||||
*page = loaded;
|
||||
placeholders.remove(&page.id);
|
||||
}
|
||||
let layout = manifest.layout;
|
||||
*manifest = theirs;
|
||||
manifest.layout = manifest.layout.or(layout);
|
||||
manifest.sync_from_notebook(notebook);
|
||||
Ok(Some(placeholders))
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a conflict copy as a new page after page `of` (or at the end), with fresh ids.
|
||||
/// Returns the id of the new page.
|
||||
fn add_conflict_page(notebook: &mut Notebook, mut data: PageData, of: u64) -> u64 {
|
||||
let id = notebook.next_page_id;
|
||||
data.id = id;
|
||||
notebook.next_page_id += 1;
|
||||
for stroke in &mut data.strokes {
|
||||
stroke.id = notebook.next_stroke_id;
|
||||
notebook.next_stroke_id += 1;
|
||||
}
|
||||
let original = notebook.pages.iter().position(|p| p.id == of);
|
||||
let title = original
|
||||
.and_then(|i| notebook.pages[i].title.clone())
|
||||
.or(data.title.take());
|
||||
data.title = Some(match title {
|
||||
Some(title) => crate::t!("Konfliktkopie: {title}", title = title).to_string(),
|
||||
None => crate::t!("Konfliktkopie").to_string(),
|
||||
});
|
||||
let page = data.to_page();
|
||||
let at = original.map_or(notebook.pages.len(), |i| i + 1);
|
||||
notebook.pages.insert(at, page);
|
||||
if at <= notebook.current_page_index {
|
||||
notebook.current_page_index += 1;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::{Stroke, StylusSample};
|
||||
|
||||
fn stroke(id: u64, y: f32) -> Stroke {
|
||||
let points = (0..5)
|
||||
.map(|i| StylusSample {
|
||||
position: glam::Vec2::new(100.0 + 50.0 * i as f32, y),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
})
|
||||
.collect();
|
||||
Stroke::new(id, points, Vec::new(), [0.0, 0.0, 0.0, 1.0])
|
||||
}
|
||||
|
||||
/// Saved notebook with one stroke on its only page.
|
||||
fn saved_notebook(dir: &Path) -> (Notebook, NotebookManifest) {
|
||||
let mut notebook = Notebook::new();
|
||||
notebook.current_page_mut().add_stroke(stroke(1, 100.0));
|
||||
notebook.next_stroke_id = 2;
|
||||
let mut manifest = NotebookManifest::new("nb", "Mathe");
|
||||
storage::save_notebook(dir, ¬ebook, &mut manifest).unwrap();
|
||||
(notebook, manifest)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_copy_names() {
|
||||
assert_eq!(
|
||||
conflict_copy_of("page_3 (conflicted copy 2026-10-27).json"),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(conflict_copy_of("page_12 (1).json"), Some(12));
|
||||
assert_eq!(
|
||||
conflict_copy_of("page_3.sync-conflict-20261027-1200-ABC.json"),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(conflict_copy_of("page_3_conflict.json"), Some(3));
|
||||
assert_eq!(conflict_copy_of("page_3.json"), None);
|
||||
assert_eq!(conflict_copy_of("page_x (1).json"), None);
|
||||
assert_eq!(conflict_copy_of("manifest (1).json"), None);
|
||||
assert_eq!(conflict_copy_of("page_3.json.imported"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_own_saves_are_not_reported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
let now = Instant::now();
|
||||
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||
|
||||
notebook.current_page_mut().add_stroke(stroke(2, 200.0));
|
||||
notebook.add_page(PageBackground::Blank);
|
||||
storage::save_notebook(dir.path(), ¬ebook, &mut manifest).unwrap();
|
||||
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.is_empty(), "{outcome:?}");
|
||||
assert_eq!(notebook.pages.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_changes_from_another_device_are_taken_over() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
let now = Instant::now();
|
||||
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||
assert!(!watch.is_due(now));
|
||||
|
||||
// The other device adds a stroke on page 1 and a second page with a title
|
||||
let (mut theirs, mut their_manifest) = storage::load_notebook(dir.path()).unwrap();
|
||||
theirs.current_page_mut().add_stroke(stroke(5, 300.0));
|
||||
let index = theirs.add_page(PageBackground::Lined);
|
||||
theirs.pages[index].add_stroke(stroke(6, 400.0));
|
||||
theirs.pages[index].title = Some("Übung".to_string());
|
||||
theirs.next_stroke_id = 7;
|
||||
storage::save_notebook(dir.path(), &theirs, &mut their_manifest).unwrap();
|
||||
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert_eq!(outcome.reloaded_pages, 1);
|
||||
assert!(outcome.structure_changed);
|
||||
assert!(outcome.conflict_pages.is_empty());
|
||||
assert_eq!(notebook.pages.len(), 2);
|
||||
assert_eq!(notebook.pages[0].strokes.len(), 2);
|
||||
assert_eq!(notebook.pages[1].title.as_deref(), Some("Übung"));
|
||||
assert_eq!(notebook.pages[1].background, PageBackground::Lined);
|
||||
assert!(notebook.next_stroke_id >= 7);
|
||||
assert_eq!(manifest.pages.len(), 2);
|
||||
|
||||
// The new page loads from disk when needed
|
||||
storage::ensure_page_loaded(dir.path(), &mut notebook, 1).unwrap();
|
||||
assert_eq!(notebook.pages[1].strokes.len(), 1);
|
||||
|
||||
// Nothing new: nothing to report
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.is_empty(), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_still_being_synced_is_tried_again() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
let now = Instant::now();
|
||||
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||
|
||||
// Elsewhere page 1 was deleted and page 2 added; its file is still half written
|
||||
let (mut theirs, mut their_manifest) = storage::load_notebook(dir.path()).unwrap();
|
||||
let index = theirs.add_page(PageBackground::Blank);
|
||||
theirs.pages[index].add_stroke(stroke(9, 100.0));
|
||||
storage::save_notebook(dir.path(), &theirs, &mut their_manifest).unwrap();
|
||||
their_manifest.pages.remove(0);
|
||||
storage::save_manifest(dir.path(), &their_manifest).unwrap();
|
||||
let page_file = dir.path().join("page_2.json");
|
||||
let complete = fs::read(&page_file).unwrap();
|
||||
fs::write(&page_file, &complete[..complete.len() / 2]).unwrap();
|
||||
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.is_empty(), "{outcome:?}");
|
||||
assert_eq!(notebook.pages.len(), 1);
|
||||
assert_eq!(notebook.pages[0].id, 1);
|
||||
assert!(page_file.exists(), "must not be set aside as damaged");
|
||||
|
||||
fs::write(&page_file, &complete).unwrap();
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.structure_changed);
|
||||
assert_eq!(notebook.pages.len(), 1);
|
||||
assert_eq!(notebook.current_page().id, 2);
|
||||
assert!(notebook.current_page().is_loaded);
|
||||
assert_eq!(notebook.current_page().strokes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_copy_becomes_a_page() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
notebook.add_page(PageBackground::Blank);
|
||||
notebook.current_page_index = 1;
|
||||
storage::save_notebook(dir.path(), ¬ebook, &mut manifest).unwrap();
|
||||
let now = Instant::now();
|
||||
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||
|
||||
let mut copy = PageData::from(¬ebook.pages[0]);
|
||||
copy.strokes.push(
|
||||
PageData::from(&{
|
||||
let mut page = Page::new(1, PageBackground::Blank);
|
||||
page.add_stroke(stroke(1, 500.0));
|
||||
page
|
||||
})
|
||||
.strokes[0]
|
||||
.clone(),
|
||||
);
|
||||
let name = "page_1 (conflicted copy 2026-10-27).json";
|
||||
fs::write(dir.path().join(name), serde_json::to_vec(©).unwrap()).unwrap();
|
||||
fs::write(dir.path().join("page_2 (1).json"), b"{ kaputt").unwrap();
|
||||
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert_eq!(outcome.conflict_pages.len(), 1);
|
||||
assert_eq!(notebook.pages.len(), 3);
|
||||
let added = ¬ebook.pages[1];
|
||||
assert_eq!(added.id, outcome.conflict_pages[0]);
|
||||
assert_eq!(added.strokes.len(), 2);
|
||||
assert!(added.title.is_some());
|
||||
// Fresh ids, no clash with the original page
|
||||
assert!(added.strokes.iter().all(|s| s.id >= 2));
|
||||
assert_eq!(notebook.pages[0].strokes.len(), 1);
|
||||
// The current page stays the same
|
||||
assert_eq!(notebook.current_page_index, 2);
|
||||
assert_eq!(manifest.pages.len(), 3);
|
||||
assert!(!dir.path().join(name).exists());
|
||||
assert!(dir.path().join(format!("{name}.imported")).exists());
|
||||
|
||||
// Taken over only once; the unreadable file is left alone
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.is_empty(), "{outcome:?}");
|
||||
assert!(dir.path().join("page_2 (1).json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
let marker_path = dir.path().join(storage::OPEN_MARKER_FILE);
|
||||
let foreign = OpenMarker {
|
||||
device: "tablet-of-pia".to_string(),
|
||||
since: 1,
|
||||
};
|
||||
fs::write(&marker_path, serde_json::to_vec(&foreign).unwrap()).unwrap();
|
||||
|
||||
// Opening warns about the other device and puts our own marker in place
|
||||
let lock = storage::NotebookLock::acquire(dir.path()).unwrap();
|
||||
assert_eq!(lock.open_elsewhere.as_ref(), Some(&foreign));
|
||||
let ours: OpenMarker = serde_json::from_slice(&fs::read(&marker_path).unwrap()).unwrap();
|
||||
assert_eq!(ours.device, storage::device_name());
|
||||
|
||||
// The other device opens it again
|
||||
let now = Instant::now();
|
||||
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||
fs::write(&marker_path, serde_json::to_vec(&foreign).unwrap()).unwrap();
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert_eq!(outcome.opened_elsewhere, Some(foreign));
|
||||
|
||||
// A foreign marker is not removed on closing
|
||||
drop(lock);
|
||||
assert!(marker_path.exists());
|
||||
let lock = storage::NotebookLock::acquire(dir.path()).unwrap();
|
||||
drop(lock);
|
||||
assert!(!marker_path.exists());
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ pub enum PickerKind {
|
||||
Image,
|
||||
/// PDF, Xournal++, Rnote or Saber file to import as a new notebook
|
||||
Import,
|
||||
/// Folder for the notebooks (Issue #49): lists folders only
|
||||
Folder,
|
||||
}
|
||||
|
||||
impl PickerKind {
|
||||
@@ -32,6 +34,7 @@ impl PickerKind {
|
||||
match self {
|
||||
PickerKind::Image => is_supported_image_path(path),
|
||||
PickerKind::Import => crate::import::is_importable_path(path),
|
||||
PickerKind::Folder => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +67,7 @@ impl FilePicker {
|
||||
let preferred = match kind {
|
||||
PickerKind::Image => tn!("🖼 Bilder"),
|
||||
PickerKind::Import => tn!("⬇ Downloads"),
|
||||
PickerKind::Folder => tn!("🏠 Persönlicher Ordner"),
|
||||
};
|
||||
let all = shortcuts();
|
||||
all.iter()
|
||||
@@ -225,6 +229,7 @@ mod tests {
|
||||
"Uni.sbn2"
|
||||
]
|
||||
);
|
||||
assert_eq!(names(PickerKind::Folder), ["aOrdner", "zOrdner"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -151,6 +151,7 @@ mod tests {
|
||||
("assets.rs", include_str!("assets.rs")),
|
||||
("autosave.rs", include_str!("autosave.rs")),
|
||||
("background.rs", include_str!("background.rs")),
|
||||
("disk_watch.rs", include_str!("disk_watch.rs")),
|
||||
("document.rs", include_str!("document.rs")),
|
||||
("export.rs", include_str!("export.rs")),
|
||||
("file_picker.rs", include_str!("file_picker.rs")),
|
||||
|
||||
@@ -464,4 +464,24 @@ pub const EN: &[(&str, &str)] = &[
|
||||
("📄 Importieren (PDF, Xournal++, Rnote, Saber) …", "📄 Import (PDF, Xournal++, Rnote, Saber) …"),
|
||||
("Keine Saber-Datei", "Not a Saber file"),
|
||||
("Saber-Datei zu alt: bitte in einem aktuellen Saber öffnen und neu speichern", "Saber file too old: please open it in a current Saber and save it again"),
|
||||
("Die Notizbücher liegen bereits dort", "The notebooks are already there"),
|
||||
("Konfliktkopien als eigene Seiten übernommen: {count}", "Conflict copies added as separate pages: {count}"),
|
||||
("Speicherort geändert, {count} Notizbücher kopiert", "Storage location changed, {count} notebooks copied"),
|
||||
("Speicherort geändert: {path}", "Storage location changed: {path}"),
|
||||
("Speicherort nicht nutzbar: {err}", "Storage location not usable: {err}"),
|
||||
("Änderungen von einem anderen Gerät übernommen", "Changes from another device taken over"),
|
||||
("⚠ Auch auf '{device}' geöffnet – Änderungen dort können deine überschreiben", "⚠ Also open on '{device}' – changes there may overwrite yours"),
|
||||
("⚠ Gerade auf '{device}' geöffnet – Änderungen dort können deine überschreiben", "⚠ Just opened on '{device}' – changes there may overwrite yours"),
|
||||
("Konfliktkopie", "Conflict copy"),
|
||||
("Konfliktkopie: {title}", "Conflict copy: {title}"),
|
||||
("Die Notizbücher liegen dann im Unterordner „ink-notes“ des gewählten Ordners, z. B. im Nextcloud- oder Google-Drive-Ordner. Ist er noch leer, werden die vorhandenen Notizbücher dorthin kopiert; die alten bleiben erhalten.", "The notebooks are then kept in the subfolder “ink-notes” of the chosen folder, e.g. in your Nextcloud or Google Drive folder. If it is still empty, the existing notebooks are copied there; the old ones are kept."),
|
||||
("Keine Unterordner", "No subfolders"),
|
||||
("SPEICHERORT", "STORAGE LOCATION"),
|
||||
("Speicherort wählen", "Choose storage location"),
|
||||
("Standard", "Default"),
|
||||
("Z. B. einen Nextcloud- oder Google-Drive-Ordner wählen, um die Notizbücher mit anderen Geräten abzugleichen", "E.g. choose a Nextcloud or Google Drive folder to sync the notebooks with other devices"),
|
||||
("Zurück zum Standard-Speicherort", "Back to the default storage location"),
|
||||
("✔ Diesen Ordner wählen", "✔ Choose this folder"),
|
||||
("📁 Ändern …", "📁 Change …"),
|
||||
("📁 Speicherort wählen", "📁 Choose storage location"),
|
||||
];
|
||||
@@ -5,6 +5,7 @@ pub mod assets;
|
||||
pub mod autosave;
|
||||
pub mod background;
|
||||
pub mod canvas;
|
||||
pub mod disk_watch;
|
||||
pub mod document;
|
||||
pub mod export;
|
||||
pub mod file_picker;
|
||||
|
||||
@@ -229,6 +229,9 @@ pub struct UiSettings {
|
||||
pub language: Option<Language>,
|
||||
/// Eraser radius in mm (Issue #38)
|
||||
pub eraser_radius_mm: f32,
|
||||
/// Folder of the notebooks, e.g. inside a Nextcloud folder; default location if unset
|
||||
/// (Issue #49)
|
||||
pub storage_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for UiSettings {
|
||||
@@ -249,6 +252,7 @@ impl Default for UiSettings {
|
||||
stylus_buttons: Vec::new(),
|
||||
language: None,
|
||||
eraser_radius_mm: 3.0,
|
||||
storage_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +353,7 @@ mod tests {
|
||||
],
|
||||
language: Some(Language::English),
|
||||
eraser_radius_mm: 6.0,
|
||||
storage_dir: Some(PathBuf::from("/home/pia/Nextcloud/ink-notes")),
|
||||
};
|
||||
settings.save_to(&path).unwrap();
|
||||
assert_eq!(UiSettings::load_from(&path), settings);
|
||||
|
||||
+152
-4
@@ -939,6 +939,59 @@ pub fn ensure_page_loaded(
|
||||
}
|
||||
}
|
||||
|
||||
/// Notebook folder inside a folder the user picked as storage location (Issue #49), e.g. the
|
||||
/// Nextcloud folder: `<chosen>/ink-notes`, unless the folder already is called `ink-notes`.
|
||||
pub fn storage_dir_in(chosen: &Path) -> PathBuf {
|
||||
if chosen.file_name().is_some_and(|n| n == "ink-notes") {
|
||||
chosen.to_path_buf()
|
||||
} else {
|
||||
chosen.join("ink-notes")
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepares `to` as new storage location. If it holds no notebooks yet, the notebooks and
|
||||
/// `library.json` of `from` are copied there (lock files and open markers excluded); the
|
||||
/// originals stay where they are. Returns the number of copied notebooks.
|
||||
pub fn copy_storage(from: &Path, to: &Path) -> io::Result<usize> {
|
||||
create_secure_dir_all(to)?;
|
||||
if !list_notebooks(to).is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut copied = 0;
|
||||
for summary in list_notebooks(from) {
|
||||
let target = to.join(&summary.id);
|
||||
if target.exists() {
|
||||
continue;
|
||||
}
|
||||
copy_dir(&from.join(&summary.id), &target)?;
|
||||
copied += 1;
|
||||
}
|
||||
let library = from.join("library.json");
|
||||
if library.is_file() && !to.join("library.json").exists() {
|
||||
fs::copy(&library, to.join("library.json"))?;
|
||||
}
|
||||
Ok(copied)
|
||||
}
|
||||
|
||||
/// Copies a notebook folder recursively, without lock file and open marker.
|
||||
fn copy_dir(from: &Path, to: &Path) -> io::Result<()> {
|
||||
create_secure_dir_all(to)?;
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
if name == ".lock" || name == OPEN_MARKER_FILE {
|
||||
continue;
|
||||
}
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_dir() {
|
||||
copy_dir(&entry.path(), &to.join(&name))?;
|
||||
} else if kind.is_file() {
|
||||
fs::copy(entry.path(), to.join(&name))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation).
|
||||
pub fn default_storage_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
@@ -984,6 +1037,49 @@ pub struct NotebookSummary {
|
||||
pub struct NotebookLock {
|
||||
_file: File,
|
||||
pub path: PathBuf,
|
||||
/// Another device that has the notebook open according to its marker (Issue #49)
|
||||
pub open_elsewhere: Option<OpenMarker>,
|
||||
marker: Option<(PathBuf, OpenMarker)>,
|
||||
}
|
||||
|
||||
/// Which device has a notebook open, and since when (Unix seconds). The `.lock` file only
|
||||
/// locks on this computer; this marker (`.opened_by.json`) travels with sync clients such as
|
||||
/// Nextcloud, so another device can warn before both edit at the same time (Issue #49).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OpenMarker {
|
||||
pub device: String,
|
||||
pub since: u64,
|
||||
}
|
||||
|
||||
/// File name of the [`OpenMarker`] in the notebook folder.
|
||||
pub const OPEN_MARKER_FILE: &str = ".opened_by.json";
|
||||
|
||||
/// Name of this device (host name).
|
||||
pub fn device_name() -> String {
|
||||
let from_file = |path: &str| fs::read_to_string(path).ok();
|
||||
std::env::var("INK_NOTES_DEVICE")
|
||||
.ok()
|
||||
.or_else(|| from_file("/proc/sys/kernel/hostname"))
|
||||
.or_else(|| from_file("/etc/hostname"))
|
||||
.or_else(|| std::env::var("COMPUTERNAME").ok())
|
||||
.or_else(|| std::env::var("HOSTNAME").ok())
|
||||
.map(|name| name.trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
}
|
||||
|
||||
impl Drop for NotebookLock {
|
||||
fn drop(&mut self) {
|
||||
// Remove our marker, unless another device has replaced it meanwhile
|
||||
if let Some((path, ours)) = &self.marker {
|
||||
let current = fs::read(path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<OpenMarker>(&b).ok());
|
||||
if current.as_ref() == Some(ours) {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NotebookLock {
|
||||
@@ -1007,10 +1103,31 @@ impl NotebookLock {
|
||||
|
||||
use fs2::FileExt;
|
||||
match file.try_lock_exclusive() {
|
||||
Ok(()) => Ok(Self {
|
||||
_file: file,
|
||||
path: lock_path,
|
||||
}),
|
||||
Ok(()) => {
|
||||
let device = device_name();
|
||||
let marker_path = notebook_dir.join(OPEN_MARKER_FILE);
|
||||
let open_elsewhere = fs::read(&marker_path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<OpenMarker>(&b).ok())
|
||||
.filter(|m| m.device != device);
|
||||
let ours = OpenMarker {
|
||||
device,
|
||||
since: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
};
|
||||
let marker = serde_json::to_vec(&ours)
|
||||
.ok()
|
||||
.filter(|json| atomic_write_file(&marker_path, json).is_ok())
|
||||
.map(|_| (marker_path, ours));
|
||||
Ok(Self {
|
||||
_file: file,
|
||||
path: lock_path,
|
||||
open_elsewhere,
|
||||
marker,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let kind = e.kind();
|
||||
if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) {
|
||||
@@ -2159,6 +2276,37 @@ mod tests {
|
||||
assert_eq!(damaged_summary.unwrap().status, NotebookStatus::Damaged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_storage_to_new_location() {
|
||||
let old = tempfile::tempdir().unwrap();
|
||||
let cloud = tempfile::tempdir().unwrap();
|
||||
let (notebook, mut manifest, dir) = create_new_notebook(old.path(), "Mathe").unwrap();
|
||||
save_notebook(&dir, ¬ebook, &mut manifest).unwrap();
|
||||
let _lock = NotebookLock::acquire(&dir).unwrap();
|
||||
fs::create_dir_all(dir.join("assets")).unwrap();
|
||||
fs::write(dir.join("assets").join("bild.png"), b"png").unwrap();
|
||||
fs::write(old.path().join("library.json"), b"{}").unwrap();
|
||||
|
||||
assert_eq!(storage_dir_in(cloud.path()), cloud.path().join("ink-notes"));
|
||||
let target = storage_dir_in(cloud.path());
|
||||
assert_eq!(storage_dir_in(&target), target);
|
||||
|
||||
assert_eq!(copy_storage(old.path(), &target).unwrap(), 1);
|
||||
let copied = target.join(&manifest.id);
|
||||
assert!(load_notebook(&copied).is_ok());
|
||||
assert!(copied.join("assets").join("bild.png").exists());
|
||||
assert!(target.join("library.json").exists());
|
||||
assert!(!copied.join(".lock").exists());
|
||||
assert!(!copied.join(OPEN_MARKER_FILE).exists());
|
||||
// The originals stay
|
||||
assert!(load_notebook(&dir).is_ok());
|
||||
|
||||
// Notebooks already there (e.g. from another device): nothing is copied
|
||||
create_new_notebook(old.path(), "Physik").unwrap();
|
||||
assert_eq!(copy_storage(old.path(), &target).unwrap(), 0);
|
||||
assert_eq!(list_notebooks(&target).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notebook_instance_lock_prevents_concurrent_open() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -339,6 +339,8 @@ pub enum UiAction {
|
||||
InsertImageFile(std::path::PathBuf),
|
||||
/// PDF als neues Notizbuch importieren
|
||||
ImportFile(std::path::PathBuf),
|
||||
/// Store the notebooks in this folder, `None` for the default location (Issue #49)
|
||||
SetStorageDir(Option<std::path::PathBuf>),
|
||||
/// Aktuelle Seite als Bild oder SVG exportieren
|
||||
ExportPage(PageExportKind),
|
||||
/// Titel einer Seite setzen (leer = entfernen)
|
||||
@@ -503,6 +505,9 @@ pub struct UiState {
|
||||
pub queued_actions: Vec<UiAction>,
|
||||
/// Zuletzt in der Dateiauswahl benutzter Ordner (während der Sitzung)
|
||||
pub last_picker_dir: Option<std::path::PathBuf>,
|
||||
/// Folder holding the notebooks and whether the user picked it (Issue #49)
|
||||
pub storage_dir: std::path::PathBuf,
|
||||
pub storage_dir_custom: bool,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -588,6 +593,8 @@ impl Default for UiState {
|
||||
page_title_for: None,
|
||||
page_picker_list: false,
|
||||
last_picker_dir: None,
|
||||
storage_dir: std::path::PathBuf::new(),
|
||||
storage_dir_custom: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -674,6 +681,12 @@ impl UiState {
|
||||
self.open_file_picker(PickerKind::Import);
|
||||
}
|
||||
|
||||
/// Opens the folder picker for the storage location in the home folder.
|
||||
pub fn open_storage_picker(&mut self) {
|
||||
self.close_overlays();
|
||||
self.file_picker = Some(FilePicker::open(PickerKind::Folder, None));
|
||||
}
|
||||
|
||||
fn open_file_picker(&mut self, kind: PickerKind) {
|
||||
let dir = self.last_picker_dir.clone();
|
||||
self.close_overlays();
|
||||
@@ -2839,6 +2852,17 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
||||
Texte und Bilder werden übernommen, bei Xournal++ und Saber auch PDF-Hintergründe."
|
||||
),
|
||||
),
|
||||
PickerKind::Folder => (
|
||||
t!("Speicherort wählen"),
|
||||
t!("📁 Speicherort wählen"),
|
||||
"📁",
|
||||
t!("Keine Unterordner"),
|
||||
t!(
|
||||
"Die Notizbücher liegen dann im Unterordner „ink-notes“ des gewählten Ordners, \
|
||||
z. B. im Nextcloud- oder Google-Drive-Ordner. Ist er noch leer, werden die \
|
||||
vorhandenen Notizbücher dorthin kopiert; die alten bleiben erhalten."
|
||||
),
|
||||
),
|
||||
};
|
||||
dialog_window(title, state, false).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
@@ -2877,6 +2901,16 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
||||
if let Some(err) = &picker.error {
|
||||
ui.colored_label(egui::Color32::from_rgb(255, 120, 120), err);
|
||||
}
|
||||
if picker_kind == PickerKind::Folder
|
||||
&& ui
|
||||
.add_sized(
|
||||
[ui.available_width(), 30.0],
|
||||
egui::Button::new(t!("✔ Diesen Ordner wählen")),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
chosen = Some(picker.dir.clone());
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
@@ -2923,10 +2957,13 @@ fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Ve
|
||||
}
|
||||
}
|
||||
if let Some(path) = chosen {
|
||||
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
||||
if picker_kind != PickerKind::Folder {
|
||||
state.last_picker_dir = state.file_picker.as_ref().map(|p| p.dir.clone());
|
||||
}
|
||||
actions.push(match picker_kind {
|
||||
PickerKind::Image => UiAction::InsertImageFile(path),
|
||||
PickerKind::Import => UiAction::ImportFile(path),
|
||||
PickerKind::Folder => UiAction::SetStorageDir(Some(path)),
|
||||
});
|
||||
close = true;
|
||||
}
|
||||
@@ -4397,6 +4434,36 @@ pub fn render_ui(
|
||||
}
|
||||
});
|
||||
|
||||
section(ui, t!("SPEICHERORT"));
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(state.storage_dir.display().to_string())
|
||||
.size(11.5)
|
||||
.color(egui::Color32::from_gray(190)),
|
||||
)
|
||||
.truncate(true),
|
||||
)
|
||||
.on_hover_text(state.storage_dir.display().to_string());
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_sized([115.0, 28.0], egui::Button::new(t!("📁 Ändern …")))
|
||||
.on_hover_text(t!("Z. B. einen Nextcloud- oder Google-Drive-Ordner wählen, um die Notizbücher mit anderen Geräten abzugleichen"))
|
||||
.clicked()
|
||||
{
|
||||
state.open_storage_picker();
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
state.storage_dir_custom,
|
||||
egui::Button::new(t!("Standard")).min_size(egui::vec2(115.0, 28.0)),
|
||||
)
|
||||
.on_hover_text(t!("Zurück zum Standard-Speicherort"))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(UiAction::SetStorageDir(None));
|
||||
}
|
||||
});
|
||||
|
||||
section(ui, t!("STATUS"));
|
||||
let (dot_color, status_text) = autosave_status_style(autosave_status);
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
Reference in new issue
Block a user