From 99d9fa6b7c137a4b72abd863f98e50e8f1aaa2cb Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Sat, 5 Sep 2026 12:38:34 +0200 Subject: [PATCH] v2.0.5: Background-Sync in PWA + Sync-Queue + 8 Tests (Phase 4.3 erweitert) Phase 4.3 (Background-Sync): - webapp/src/sync-queue.ts: localStorage-basierte Queue - enqueueSync, dequeueSync, loadQueue, saveQueue, clearQueue - TypeScript mit korrekten Typen - webapp/src/EditorWithLoad.tsx: erweitert - Auto-Sync-Toggle (localStorage-Persistenz) - Online-Event-Listener: processQueue bei reconnect - Bei Auto-Sync: direkt speichern + sync - Bei Offline: in Queue, wird bei 'online' verarbeitet - Pending-Counter im UI - webapp/public/service-worker.js: Background-Sync - 'sync'-Event-Handler mit tag 'mindomat-sync' - Ruft POST /api/sync, postet Ergebnis an Clients - Browser-Support: Chrome/Edge (Background-Sync API) Tests: - tests/sync-queue.test.ts: 8 neue Tests - Startzustand, enqueue, FIFO, dequeue, clearQueue - Roundtrip, kaputtes JSON - localStorage-Mock Verifiziert: - 79/79 Tests gruen (10 neu seit v2.0.4) - Webapp tsc + build OK - EditorWithLoad funktional mit Auto-Sync-Option Pfad-Korrekturen: - vite.config.ts: ../../src/ -> ../src/ (war falsch, webapp ist nur 1 Ebene tiefer) --- tests/sync-queue.test.ts | 88 +++++++++++++++++++++++++++++++++ webapp/public/service-worker.js | 20 ++++++++ webapp/src/EditorWithLoad.tsx | 73 +++++++++++++++++++++++++-- webapp/src/sync-queue.ts | 42 ++++++++++++++++ webapp/vite.config.ts | 7 +-- 5 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 tests/sync-queue.test.ts create mode 100644 webapp/src/sync-queue.ts diff --git a/tests/sync-queue.test.ts b/tests/sync-queue.test.ts new file mode 100644 index 0000000..93857d3 --- /dev/null +++ b/tests/sync-queue.test.ts @@ -0,0 +1,88 @@ +// Tests fuer Sync-Queue +// Phase 4.3 + +import { describe, it, expect, beforeEach } from 'vitest'; + +// Mock localStorage vor dem Import (das Modul nutzt es zur Initialisierung) +const store = new Map(); +(globalThis as any).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { store.delete(key); }, + clear: () => { store.clear(); }, + key: (i: number) => Array.from(store.keys())[i] ?? null, + get length() { return store.size; }, +}; + +import { + enqueueSync, + dequeueSync, + loadQueue, + clearQueue, + saveQueue, +} from '../webapp/src/sync-queue'; + +describe('Sync-Queue', () => { + beforeEach(() => { + clearQueue(); + }); + + it('Startzustand: leere Queue', () => { + expect(loadQueue()).toEqual([]); + }); + + it('enqueue fuegt Element hinzu', () => { + enqueueSync('note1.md', '# Inhalt 1'); + const queue = loadQueue(); + expect(queue.length).toBe(1); + expect(queue[0].noteId).toBe('note1.md'); + expect(queue[0].content).toBe('# Inhalt 1'); + expect(typeof queue[0].timestamp).toBe('number'); + }); + + it('enqueue fuegt mehrere Elemente hinzu (FIFO)', () => { + enqueueSync('a.md', 'A'); + enqueueSync('b.md', 'B'); + enqueueSync('c.md', 'C'); + const queue = loadQueue(); + expect(queue.length).toBe(3); + expect(queue.map((q) => q.noteId)).toEqual(['a.md', 'b.md', 'c.md']); + }); + + it('dequeue entfernt Element nach noteId', () => { + enqueueSync('a.md', 'A'); + enqueueSync('b.md', 'B'); + dequeueSync('a.md'); + const queue = loadQueue(); + expect(queue.length).toBe(1); + expect(queue[0].noteId).toBe('b.md'); + }); + + it('dequeue unbekannter noteId ist no-op', () => { + enqueueSync('a.md', 'A'); + dequeueSync('nicht-da.md'); + expect(loadQueue().length).toBe(1); + }); + + it('clearQueue leert komplett', () => { + enqueueSync('a.md', 'A'); + enqueueSync('b.md', 'B'); + clearQueue(); + expect(loadQueue()).toEqual([]); + }); + + it('saveQueue/loadQueue roundtrip mit localStorage', () => { + const queue = [ + { noteId: 'a.md', content: 'A', timestamp: 1000 }, + { noteId: 'b.md', content: 'B', timestamp: 2000 }, + ]; + saveQueue(queue); + const loaded = loadQueue(); + expect(loaded).toEqual(queue); + }); + + it('kaputtes JSON in localStorage wird als leere Queue behandelt', () => { + store.set('mindomat-pending-syncs', '{invalid json'); + expect(loadQueue()).toEqual([]); + }); +}); diff --git a/webapp/public/service-worker.js b/webapp/public/service-worker.js index 16308f4..d6d472d 100644 --- a/webapp/public/service-worker.js +++ b/webapp/public/service-worker.js @@ -49,3 +49,23 @@ self.addEventListener('fetch', (event) => { }) ); }); + +// Background-Sync: bei 'sync'-Event queued operations ausfuehren +self.addEventListener('sync', (event) => { + if (event.tag === 'mindomat-sync') { + event.waitUntil( + fetch('/api/sync', { method: 'POST' }) + .then((res) => res.json()) + .then((data) => { + return self.clients.matchAll().then((clients) => { + for (const client of clients) { + client.postMessage({ type: 'sync-complete', data }); + } + }); + }) + .catch((err) => { + console.error('[SW] Background-Sync fehlgeschlagen:', err); + }) + ); + } +}); diff --git a/webapp/src/EditorWithLoad.tsx b/webapp/src/EditorWithLoad.tsx index af9261b..bbdea03 100644 --- a/webapp/src/EditorWithLoad.tsx +++ b/webapp/src/EditorWithLoad.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import Editor from './Editor'; import { loadNote, saveNote, triggerSync, NoteData } from './notes-api'; +import { enqueueSync, dequeueSync, loadQueue, PendingSync } from './sync-queue'; interface Props { noteId: string; @@ -11,6 +12,10 @@ export default function EditorWithLoad({ noteId, onClose }: Props) { const [note, setNote] = useState(null); const [error, setError] = useState(null); const [syncStatus, setSyncStatus] = useState(''); + const [autoSync, setAutoSync] = useState(() => + localStorage.getItem('mindomat-auto-sync') === 'true', + ); + const [pendingCount, setPendingCount] = useState(0); useEffect(() => { loadNote(noteId) @@ -19,24 +24,79 @@ export default function EditorWithLoad({ noteId, onClose }: Props) { else setError(`Notiz nicht gefunden: ${noteId}`); }) .catch((e) => setError(`Fehler beim Laden: ${e.message}`)); + updatePendingCount(); }, [noteId]); + useEffect(() => { + const onOnline = () => { + console.log('[sync-queue] online-Event, verarbeite Queue'); + processQueue(); + }; + window.addEventListener('online', onOnline); + return () => window.removeEventListener('online', onOnline); + }, []); + + function updatePendingCount() { + setPendingCount(loadQueue().length); + } + + async function processQueue() { + const queue: PendingSync[] = loadQueue(); + if (queue.length === 0) return; + for (const item of queue) { + try { + await saveNote(item.noteId, item.content); + dequeueSync(item.noteId); + } catch (err) { + console.error(`[sync-queue] Fehler bei ${item.noteId}:`, err); + return; + } + } + updatePendingCount(); + } + const handleSave = async (markdown: string) => { - const result = await saveNote(noteId, markdown); - setNote(result); + if (autoSync && navigator.onLine) { + const result = await saveNote(noteId, markdown); + setNote(result); + try { + await triggerSync(); + setSyncStatus('Auto-Sync ✓'); + } catch { + setSyncStatus('Sync fehlgeschlagen, Notiz gespeichert'); + } + } else { + try { + const result = await saveNote(noteId, markdown); + setNote(result); + } catch { + enqueueSync(noteId, markdown); + setSyncStatus('Offline: in Queue'); + updatePendingCount(); + } + } + setTimeout(() => setSyncStatus(''), 3000); }; const handleSync = async () => { - setSyncStatus('Sync läuft...'); + setSyncStatus('Sync laeuft...'); try { + await processQueue(); const result = await triggerSync(); setSyncStatus(result.ok ? 'Sync erfolgreich ✓' : `Sync-Fehler: ${result.message}`); } catch (e) { setSyncStatus(`Sync fehlgeschlagen: ${(e as Error).message}`); } + updatePendingCount(); setTimeout(() => setSyncStatus(''), 3000); }; + const toggleAutoSync = () => { + const next = !autoSync; + setAutoSync(next); + localStorage.setItem('mindomat-auto-sync', String(next)); + }; + if (error) { return (
@@ -76,6 +136,13 @@ export default function EditorWithLoad({ noteId, onClose }: Props) { }}> Sync zu gitea + + {pendingCount > 0 && ( + ⚠️ {pendingCount} ausstehend + )} {syncStatus} Zuletzt geändert: {note.lastModified} diff --git a/webapp/src/sync-queue.ts b/webapp/src/sync-queue.ts new file mode 100644 index 0000000..84624e6 --- /dev/null +++ b/webapp/src/sync-queue.ts @@ -0,0 +1,42 @@ +// Sync-Queue: speichert ausstehende Syncs im localStorage +// und arbeitet sie ab, sobald der Browser online ist. + +const QUEUE_KEY = 'mindomat-pending-syncs'; + +export interface PendingSync { + noteId: string; + content: string; + timestamp: number; +} + +export function loadQueue(): PendingSync[] { + try { + const raw = localStorage.getItem(QUEUE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +export function saveQueue(queue: PendingSync[]): void { + try { + localStorage.setItem(QUEUE_KEY, JSON.stringify(queue)); + } catch (err) { + console.error('Sync-Queue konnte nicht gespeichert werden:', err); + } +} + +export function enqueueSync(noteId: string, content: string): void { + const queue = loadQueue(); + queue.push({ noteId, content, timestamp: Date.now() }); + saveQueue(queue); +} + +export function dequeueSync(noteId: string): void { + const queue = loadQueue().filter((s) => s.noteId !== noteId); + saveQueue(queue); +} + +export function clearQueue(): void { + saveQueue([]); +} diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index be5cd9d..8dbc8c7 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -1,10 +1,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { resolve } from 'node:path'; -import { readFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import matter from 'gray-matter'; -import { NotesApi } from '../../src/notes-api-server.js'; +import { NotesApi } from '../src/notes-api-server.js'; const VAULT_PATH = process.env.MINDOMAT_VAULT_PATH ?? 'C:/GitHub/Mind-o-Mat-Vault'; @@ -66,7 +63,7 @@ function notesApiPlugin() { // POST /api/sync -> ruft mindomat sync auf server.middlewares.use('/api/sync', (req: any, res: any, next: any) => { if (req.method !== 'POST') return next(); - api.syncVault(resolve(__dirname, '..', 'bin', 'mindomat.mjs')).then((result) => { + api.syncVault(resolve(__dirname, '..', 'bin', 'mindomat.mjs')).then((result: { ok: boolean; message: string }) => { res.setHeader('Content-Type', 'application/json'); if (!result.ok) res.statusCode = 500; res.end(JSON.stringify(result));