This commit is contained in:
team3
2026-09-19 21:37:34 +02:00
parent a5b92054dc
commit 736437f609
3 changed files with 44 additions and 6 deletions

View File

@@ -49,8 +49,8 @@ Gelöschte Termine und Todos bleiben mit `deleted: true` in der Datei und werden
## Sync ## Sync
Der Server (`backend/`, FastAPI + SQLite) hält den Kern. Jedes Gerät gleicht alle paar Sekunden ab: 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 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 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. Wiederverbinden wird nachgeliefert. Eine gerade offene Notiz wird nicht von außen überschrieben.

View File

@@ -6,11 +6,13 @@ import {readJson, writeJson} from './store.js';
// Abgleich mit dem Server. Zugang steht in sync.json im Datenordner: // Abgleich mit dem Server. Zugang steht in sync.json im Datenordner:
// {"url": "https://planer.marha.de", "token": "...", "interval": 5} // {"url": "https://planer.marha.de", "token": "...", "interval": 5}
// Alle paar Sekunden: eigene Änderungen seit dem letzten Lauf hochladen, Änderungen des Servers // Alle paar Sekunden und kurz nach jeder eigenen Änderung: eigene Änderungen hochladen,
// seit dem letzten Stand holen. Bei Konflikten gewinnt der Eintrag mit dem neueren updated. // Ä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). // sync-state.json merkt sich den Server-Stand (cursor) und was zuletzt übertragen wurde (pushed).
const DEFAULT_INTERVAL = 5; const DEFAULT_INTERVAL = 5;
// Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen
const SOON_MS = 500;
export class Sync { export class Sync {
constructor(store) { constructor(store) {
@@ -18,11 +20,18 @@ export class Sync {
this._config = readJson(GLib.build_filenamev([store.dir, 'sync.json']), null); this._config = readJson(GLib.build_filenamev([store.dir, 'sync.json']), null);
this._statePath = GLib.build_filenamev([store.dir, 'sync-state.json']); this._statePath = GLib.build_filenamev([store.dir, 'sync-state.json']);
this._state = readJson(this._statePath, {cursor: 0, pushed: {events: {}, todos: {}, notes: {}, folders: {}}}); 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._session = new Soup.Session({timeout: 10});
this._busy = false; this._busy = false;
this._failing = false; this._failing = false;
this._timerId = null; this._timerId = null;
this._soonId = null;
this._applying = false;
} }
get active() { get active() {
@@ -37,14 +46,30 @@ export class Sync {
this._cycle().catch(e => this._fail(e.message)); this._cycle().catch(e => this._fail(e.message));
return GLib.SOURCE_CONTINUE; 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)); 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() { stop() {
if (this._timerId) { if (this._timerId) {
GLib.source_remove(this._timerId); GLib.source_remove(this._timerId);
this._timerId = null; this._timerId = null;
} }
if (this._soonId) {
GLib.source_remove(this._soonId);
this._soonId = null;
}
// Laufende Anfrage abbrechen, ohne das als Verbindungsfehler zu melden // Laufende Anfrage abbrechen, ohne das als Verbindungsfehler zu melden
this._stopped = true; this._stopped = true;
this._session.abort(); this._session.abort();
@@ -57,7 +82,13 @@ export class Sync {
try { try {
const payload = this._collect(); const payload = this._collect();
const response = await this._post(payload); 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) { if (this._failing) {
this._failing = false; this._failing = false;
console.log('desktop-app sync: Verbindung wieder da'); console.log('desktop-app sync: Verbindung wieder da');

View File

@@ -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 "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 "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" 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" 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"}]}' 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"}]}'