// Dateiabgleich für ~/.claude/projects (Claude-Code-Sitzungen und Memory), nur zwischen PCs. // Reine Logik ohne Dateisystem, damit sie sich testen lässt. sync.js liest und schreibt die Dateien. // // Regeln: Eine Datei geht hoch, sobald sie sich geändert hat. Sitzungen wachsen nur am Ende, deshalb geht meist nur der neue Rest ("append"), zusammen mit dem // Stand, den der Server haben müsste (base: Größe und Hash der letzten 64 KB). Vom Server kommt eine // Datei, wenn sie dort neuer ist, ebenfalls nur ab dem eigenen Stand. Bei Konflikten gewinnt die neuere // Änderungszeit. Verschwundene Dateien werden als Tombstone gemeldet, gelöschte vom Server lokal entfernt. import GLib from 'gi://GLib'; export const TAIL = 64 * 1024; // Zeitstempel aus der Änderungszeit der Datei, gleiche Form wie updated bei Terminen export function fileStamp(mtimeSeconds) { return new Date(mtimeSeconds * 1000).toISOString(); } export function stampToSeconds(stamp) { return Math.floor(Date.parse(stamp) / 1000); } // Hash der letzten 64 KB, gleiche Rechnung wie auf dem Server export function tailHash(bytes) { const tail = bytes.length > TAIL ? bytes.subarray(bytes.length - TAIL) : bytes; return GLib.compute_checksum_for_bytes(GLib.ChecksumType.SHA256, new GLib.Bytes(tail)); } // pushed[id] ist der Stand auf dem Server: {updated, size, hash}. Ältere Stände sind nur der // Zeitstempel als String, null heißt als gelöscht gemeldet. export function knownUpdated(known) { return known && typeof known === 'object' ? known.updated : known; } // Was hochgeladen wird. local: Map id → mtime in Sekunden. // upload: Einträge ohne Inhalt, remove: IDs, die es lokal nicht mehr gibt. export function planPush(local, pushed, nowSeconds, idleSeconds) { const upload = []; for (const [id, mtime] of local) { if (nowSeconds - mtime < idleSeconds) continue; const updated = fileStamp(mtime); if (knownUpdated(pushed[id]) !== updated) upload.push({id, updated}); } const remove = Object.keys(pushed).filter(id => pushed[id] !== null && !local.has(id)); return {upload, remove}; } // Eintrag für den Server aus den Bytes der Datei: nur der Rest, wenn der bekannte Anfang noch stimmt, // sonst die ganze Datei. null, wenn sie kein UTF-8 ist. export function uploadItem(id, updated, bytes, known) { const decoder = new TextDecoder('utf-8', {fatal: true}); if (known && typeof known === 'object' && known.size < bytes.length && tailHash(bytes.subarray(0, known.size)) === known.hash) { try { return {id, updated, base: {size: known.size, hash: known.hash}, append: decoder.decode(bytes.subarray(known.size))}; } catch { // Schnitt mitten im Zeichen: dann eben ganz } } try { return {id, updated, content: decoder.decode(bytes)}; } catch { return null; } } // Was mit Einträgen vom Server passiert: write, delete oder skip. // mine: der lokale Zeitstempel oder null, damit sync.js den Stand nachhalten kann. export function planPull(remote, local) { return remote.map(file => { const mtime = local.get(file.id); const mine = mtime === undefined ? null : fileStamp(mtime); let action = 'write'; if (mine !== null && mine >= file.updated) action = 'skip'; else if (file.deleted) action = mine === null ? 'skip' : 'delete'; return {file, action, mine}; }); } // Uploads in Pakete teilen, damit eine Anfrage nicht beliebig groß wird export function batches(items, sizeOf, maxBytes) { const out = []; let current = []; let size = 0; for (const item of items) { const bytes = sizeOf(item); if (current.length && size + bytes > maxBytes) { out.push(current); current = []; size = 0; } current.push(item); size += bytes; } if (current.length) out.push(current); return out; } export function concat(a, b) { const out = new Uint8Array(a.length + b.length); out.set(a, 0); out.set(b, a.length); return out; }