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)
This commit is contained in:
1 parent
1590b633e5
commit
99d9fa6b7c
5 files changed
+220
-6
No files matched your search
@@ -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<string, string>();
|
||||
(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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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<NoteData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncStatus, setSyncStatus] = useState<string>('');
|
||||
const [autoSync, setAutoSync] = useState<boolean>(() =>
|
||||
localStorage.getItem('mindomat-auto-sync') === 'true',
|
||||
);
|
||||
const [pendingCount, setPendingCount] = useState<number>(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) => {
|
||||
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 (
|
||||
<div style={{ padding: 16 }}>
|
||||
@@ -76,6 +136,13 @@ export default function EditorWithLoad({ noteId, onClose }: Props) {
|
||||
}}>
|
||||
Sync zu gitea
|
||||
</button>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input type="checkbox" checked={autoSync} onChange={toggleAutoSync} />
|
||||
Auto-Sync
|
||||
</label>
|
||||
{pendingCount > 0 && (
|
||||
<span style={{ color: '#c80' }}>⚠️ {pendingCount} ausstehend</span>
|
||||
)}
|
||||
<span style={{ color: '#666' }}>{syncStatus}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ color: '#999' }}>Zuletzt geändert: {note.lastModified}</span>
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user