diff --git a/README.md b/README.md index 4b31d71..7e55742 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ Gelöschte Termine und Todos bleiben mit `deleted: true` in der Datei und werden ## Sync -Der Server (`backend/`, FastAPI + SQLite) hält den Kern. Jedes Gerät gleicht alle paar Sekunden ab: -eigene Änderungen hoch, fremde Änderungen runter. Bei Konflikten gewinnt der Eintrag mit dem +Der Server (`backend/`, FastAPI + SQLite) hält den Kern. Jedes Gerät gleicht alle paar Sekunden ab, +und sofort nach jeder eigenen Änderung: eigene Änderungen hoch, fremde Änderungen runter. Bei Konflikten gewinnt der Eintrag mit dem neueren `updated` (Notizen: Änderungszeit der Datei, ganze Datei). Offline geht weiter, beim Wiederverbinden wird nachgeliefert. Eine gerade offene Notiz wird nicht von außen überschrieben. diff --git a/desktop@marha.de/sync.js b/desktop@marha.de/sync.js index cae0a25..6e5e20a 100644 --- a/desktop@marha.de/sync.js +++ b/desktop@marha.de/sync.js @@ -6,11 +6,13 @@ import {readJson, writeJson} from './store.js'; // Abgleich mit dem Server. Zugang steht in sync.json im Datenordner: // {"url": "https://planer.marha.de", "token": "...", "interval": 5} -// Alle paar Sekunden: eigene Änderungen seit dem letzten Lauf hochladen, Änderungen des Servers -// seit dem letzten Stand holen. Bei Konflikten gewinnt der Eintrag mit dem neueren updated. +// Alle paar Sekunden und kurz nach jeder eigenen Änderung: eigene Änderungen hochladen, +// Änderungen des Servers seit dem letzten Stand holen. Bei Konflikten gewinnt der Eintrag mit dem neueren updated. // sync-state.json merkt sich den Server-Stand (cursor) und was zuletzt übertragen wurde (pushed). const DEFAULT_INTERVAL = 5; +// Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen +const SOON_MS = 500; export class Sync { constructor(store) { @@ -18,11 +20,18 @@ export class Sync { this._config = readJson(GLib.build_filenamev([store.dir, 'sync.json']), null); this._statePath = GLib.build_filenamev([store.dir, 'sync-state.json']); this._state = readJson(this._statePath, {cursor: 0, pushed: {events: {}, todos: {}, notes: {}, folders: {}}}); - this._state.pushed.folders ??= {}; + // Ältere Stände kennen keine Ordner. Einmal alles neu holen, sonst fehlen Ordner, + // die der Server schon vor dem Update verteilt hat. Bekanntes wird dabei übersprungen. + if (!this._state.pushed.folders) { + this._state.pushed.folders = {}; + this._state.cursor = 0; + } this._session = new Soup.Session({timeout: 10}); this._busy = false; this._failing = false; this._timerId = null; + this._soonId = null; + this._applying = false; } get active() { @@ -37,14 +46,30 @@ export class Sync { this._cycle().catch(e => this._fail(e.message)); return GLib.SOURCE_CONTINUE; }); + // Eigene Änderungen gehen sofort raus, nicht erst beim nächsten Takt + this._store.onChange(() => this._scheduleSoon()); this._cycle().catch(e => this._fail(e.message)); } + _scheduleSoon() { + if (this._stopped || this._applying || this._soonId) + return; + this._soonId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, SOON_MS, () => { + this._soonId = null; + this._cycle().catch(e => this._fail(e.message)); + return GLib.SOURCE_REMOVE; + }); + } + stop() { if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; } + if (this._soonId) { + GLib.source_remove(this._soonId); + this._soonId = null; + } // Laufende Anfrage abbrechen, ohne das als Verbindungsfehler zu melden this._stopped = true; this._session.abort(); @@ -57,7 +82,13 @@ export class Sync { try { const payload = this._collect(); const response = await this._post(payload); - this._apply(response, payload); + // Übernommene Änderungen lösen selbst onChange aus, das soll keinen neuen Lauf starten + this._applying = true; + try { + this._apply(response, payload); + } finally { + this._applying = false; + } if (this._failing) { this._failing = false; console.log('desktop-app sync: Verbindung wieder da'); diff --git a/dev/tests/sync.sh b/dev/tests/sync.sh index 75d0d45..67701cc 100755 --- a/dev/tests/sync.sh +++ b/dev/tests/sync.sh @@ -61,6 +61,13 @@ sleep 3 check "Termin auf dem Server" "$(api "$URL/api/events" | python3 -c 'import json,sys; print([e["title"] for e in json.load(sys.stdin)])')" "['Zahnarzt']" check "Todo auf dem Server" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print([t["title"] for t in json.load(sys.stdin)])')" "['Einkaufen']" check "Stand gemerkt" "$(val 'state().cursor > 0')" "true" +"$E" "$PRE store.saveTodo({title: 'Sofort', date: null, time: null}); 'ok'" >/dev/null +START=$(date +%s%N) +for i in $(seq 1 40); do api "$URL/api/todos" | grep -q '"Sofort"' && break; sleep 0.1; done +MS=$(( ($(date +%s%N) - START) / 1000000 )) +check "Eigene Änderung ist vor dem nächsten Takt (2 s) auf dem Server" "$([ $MS -lt 1500 ] && echo "schnell" || echo "langsam: ${MS}ms")" "schnell" +"$E" "$PRE store.removeTodo(store.todos.find(t => t.title === 'Sofort').id); 'ok'" >/dev/null +sleep 2 echo "=== Server → lokal" push '{"todos": [{"id": "r1", "updated": "2026-09-19T10:00:00.000Z", "title": "Vom Handy", "date": null, "time": null, "done": false}], "notes": [{"id": "Remote/Idee.md", "updated": "2026-09-19T10:00:00.000Z", "content": "# Vom Server"}]}'