feat: sync notebooks between devices via a synced folder (Issue #49, stage 1)
- Storage location setting: the notebooks can be moved into a folder kept in sync by Nextcloud, Google Drive, Syncthing etc. (subfolder `ink-notes`); existing notebooks are copied there if it is still empty, the originals stay. - Changes from other devices are taken over while everything local is saved: changed pages are reloaded, new/removed/reordered pages and titles come in through the manifest. A page file still being synced is retried later instead of being set aside as damaged. - Conflict copies made by sync clients become pages of their own. - `.opened_by.json` marks the device that has a notebook open; opening a notebook open elsewhere shows a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41ee32e1f7
commit
9b34e30475
13 files changed
+976
-9
No files matched your search
+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));
|
||||
|
||||
Reference in new issue
Block a user