feat(sync): built-in Nextcloud sync (Issue #49, stage 2)

Sign in to any Nextcloud server (also private ones in a subfolder, with
their own port or a self-signed certificate) through the browser with
Login Flow v2; the app gets an app password of its own, which
"Disconnect" revokes again.

A background thread syncs the storage folder with the folder ink-notes
on the server every 30 seconds and shortly after saving, three-way
against the state after the last pass: one-sided changes are copied,
manifests and library.json are merged, a page changed on both sides is
kept twice (the server's version as conflict copy, which disk_watch
turns into a page), empty pages give way, deletions go into the trash
on both sides and lose against changes. Changes to the open notebook
wait while it has unsaved changes. Folder ETags spare listing unchanged
notebooks; the pass stops instead of deleting when the storage folder
or the server folder is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
haraldandClaude Opus 5.5 committed 2026-10-28 19:24:49 +01:00
1 parent 8c0ccdd394
commit b8748c56d4
12 files changed
+3075 -1

No files matched your search

+41
View File
@@ -1483,6 +1483,8 @@ pub fn run_with(
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();
// Built-in Nextcloud sync of the storage folder (Issue #49, stage 2)
let mut cloud = crate::cloud_sync::CloudSync::new(storage_base_dir.clone());
if let Some(warning) = startup_warning {
refresh_notebooks(&storage_base_dir, &mut ui_state);
ui_state.set_export_message(warning);
@@ -1732,6 +1734,21 @@ pub fn run_with(
);
}
// Built-in sync: changes from the server to the open notebook wait while it
// has unsaved changes; a pass soon after saving
let unsaved = new_status != AutosaveStatus::Saved
|| is_drawing
|| is_stylus_drawing
|| ui_state.text_edit.is_some();
if cloud.update(&manifest.id, unsaved) {
disk_watch.check_soon(Instant::now());
refresh_notebooks(&storage_base_dir, &mut ui_state);
window.request_redraw();
}
if prev_status != AutosaveStatus::Saved && new_status == AutosaveStatus::Saved {
cloud.sync_soon();
}
// 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 {
@@ -2461,6 +2478,7 @@ pub fn run_with(
long_press.deadline(),
ui_state.math.deadline(),
Some(disk_watch.deadline()),
cloud.deadline(),
// The updater thread does not wake the loop: look for its results now and then
matches!(
updater.status(),
@@ -2578,6 +2596,7 @@ pub fn run_with(
WindowEvent::Focused(true) => {
// Coming back from another app: look for changes right away
disk_watch.check_soon(Instant::now());
cloud.sync_soon();
window.request_redraw();
}
@@ -4180,6 +4199,7 @@ pub fn run_with(
.as_ref()
.filter(|(id, _, _)| *id == manifest.id)
.map(|(_, index, _)| index + 1);
ui_state.cloud = cloud.view();
let actions = ui::render_ui(
&egui_ctx,
&mut ui_state,
@@ -5241,6 +5261,26 @@ pub fn run_with(
}
window.request_redraw();
}
UiAction::CloudConnect { server, trusted } => {
cloud.connect(&server, trusted);
window.request_redraw();
}
UiAction::CloudCancel => {
cloud.cancel_login();
window.request_redraw();
}
UiAction::CloudSyncNow => {
let _ = autosave.flush();
cloud.sync_now();
window.request_redraw();
}
UiAction::CloudDisconnect => {
cloud.disconnect();
ui_state.set_export_message(
t!("Nextcloud getrennt – die Notizbücher bleiben auf diesem Gerät").to_string(),
);
window.request_redraw();
}
UiAction::OpenUrlInBrowser(url) => {
let _ = webbrowser::open(&url);
}
@@ -5343,6 +5383,7 @@ pub fn run_with(
ui_state.storage_dir = storage_base_dir.clone();
ui_state.storage_dir_custom =
ui_settings.storage_dir.is_some();
cloud.set_local_dir(storage_base_dir.clone());
refresh_notebooks(&storage_base_dir, &mut ui_state);
// Same notebook if it exists there, otherwise the first one
let open_id = ui_state