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: {}}}); 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, }; } _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; // 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; } }