Files
desktop-planer/desktop@marha.de/sync.js
2026-09-19 21:20:17 +02:00

164 lines
6.5 KiB
JavaScript

import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Soup from 'gi://Soup';
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.
// sync-state.json merkt sich den Server-Stand (cursor) und was zuletzt übertragen wurde (pushed).
const DEFAULT_INTERVAL = 5;
export class Sync {
constructor(store) {
this._store = store;
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 ??= {};
this._session = new Soup.Session({timeout: 10});
this._busy = false;
this._failing = false;
this._timerId = null;
}
get active() {
return Boolean(this._config?.url && this._config?.token);
}
start() {
if (!this.active)
return;
const interval = this._config.interval ?? DEFAULT_INTERVAL;
this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => {
this._cycle().catch(e => this._fail(e.message));
return GLib.SOURCE_CONTINUE;
});
this._cycle().catch(e => this._fail(e.message));
}
stop() {
if (this._timerId) {
GLib.source_remove(this._timerId);
this._timerId = null;
}
// Laufende Anfrage abbrechen, ohne das als Verbindungsfehler zu melden
this._stopped = true;
this._session.abort();
}
async _cycle() {
if (this._busy)
return;
this._busy = true;
try {
const payload = this._collect();
const response = await this._post(payload);
this._apply(response, payload);
if (this._failing) {
this._failing = false;
console.log('desktop-app sync: Verbindung wieder da');
}
} finally {
this._busy = false;
}
}
// Alles, was seit dem letzten Lauf lokal geändert wurde.
_collect() {
const pushed = this._state.pushed;
const store = this._store;
const changed = (collection, items) => items.filter(item => pushed[collection][item.id] !== item.updated);
const notes = changed('notes', store.listNotes()).map(({id, updated}) => ({id, updated, content: store.readNote(store.absolute(id))}));
for (const [id, updated] of Object.entries(store.deletedNotes)) {
if (pushed.notes[id] !== updated)
notes.push({id, updated, deleted: true});
}
return {
since: this._state.cursor,
events: changed('events', store.events),
todos: changed('todos', store.todos),
notes,
folders: changed('folders', store.listFolders()),
};
}
_post(payload) {
const message = Soup.Message.new('POST', `${this._config.url}/api/sync`);
message.request_headers.append('Authorization', `Bearer ${this._config.token}`);
message.set_request_body_from_bytes('application/json', new GLib.Bytes(new TextEncoder().encode(JSON.stringify(payload))));
return new Promise((resolve, reject) => {
this._session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (session, result) => {
try {
const bytes = session.send_and_read_finish(result);
if (message.get_status() !== Soup.Status.OK)
throw new Error(`HTTP ${message.get_status()} ${message.get_reason_phrase()}`);
resolve(JSON.parse(new TextDecoder().decode(bytes.get_data())));
} catch (e) {
reject(e);
}
});
});
}
// Antwort einarbeiten. Was der Server zurückschickt und wir gerade hochgeladen haben, kennt er jetzt.
_apply(response, payload) {
const store = this._store;
const pushed = this._state.pushed;
for (const collection of ['events', 'todos']) {
for (const item of payload[collection])
pushed[collection][item.id] = item.updated;
store.applyRemote(collection, response[collection]);
for (const item of response[collection])
pushed[collection][item.id] = item.updated;
}
for (const note of payload.notes)
pushed.notes[note.id] = note.updated;
for (const folder of payload.folders)
pushed.folders[folder.id] = folder.updated;
// Ordner: neuester Eintrag gewinnt, Anlegen und Löschen wirken auf das Dateisystem.
for (const folder of response.folders ?? []) {
const mine = store.folders[folder.id];
if (!mine || mine.updated < folder.updated)
store.applyRemoteFolder(folder);
pushed.folders[folder.id] = store.folders[folder.id].updated;
}
// Notizen: ganze Datei, neuere Änderungszeit gewinnt. Eine offene Notiz bleibt unangetastet,
// dann rückt der Stand nicht vor und der Server liefert sie beim nächsten Lauf erneut.
const local = new Map(store.listNotes().map(n => [n.id, n.updated]));
let skipped = false;
for (const note of response.notes) {
if (store.lockedNote === store.absolute(note.id)) {
skipped = true;
continue;
}
const mine = local.get(note.id) ?? store.deletedNotes[note.id];
if (mine && mine >= note.updated) {
pushed.notes[note.id] = mine;
continue;
}
if (note.deleted)
store.deleteRemoteNote(note.id);
else
store.writeRemoteNote(note.id, note.content, note.updated);
pushed.notes[note.id] = note.updated;
}
if (!skipped)
this._state.cursor = response.cursor;
writeJson(this._statePath, this._state);
}
_fail(message) {
if (this._stopped)
return;
if (!this._failing)
console.log(`desktop-app sync: ${message}`);
this._failing = true;
}
}