import hashlib import json from pathlib import Path from .config import FILES_DIR # Dateien der PCs (Claude-Code-Sitzungen und Memory) liegen auf der Platte unter FILES_DIR, # in SQLite steht nur noch der Steckbrief: id, updated, size, deleted. # Sitzungen wachsen nur am Ende. Deshalb schickt der Client meist nur den neuen Rest ("append") # und sagt dazu, welchen Stand er beim Server vermutet (base: size und Hash der letzten 64 KB). # Stimmt das, wird angehängt. Sonst bekommt er den Server-Stand zurück und schickt beim # nächsten Mal die ganze Datei oder holt sich die neuere. TAIL = 64 * 1024 def path(file_id): return FILES_DIR / file_id def tail_hash(data: bytes) -> str: return hashlib.sha256(data[-TAIL:]).hexdigest() # Hash der letzten 64 KB vor Position size, ohne die ganze Datei zu lesen def tail_hash_at(file_id, size) -> str: with open(path(file_id), "rb") as f: start = max(0, size - TAIL) f.seek(start) return hashlib.sha256(f.read(size - start)).hexdigest() def meta(conn, file_id): row = conn.execute("SELECT data FROM items WHERE collection = 'files' AND id = ?", (file_id,)).fetchone() return json.loads(row["data"]) if row else None def _store(conn, item): from .db import cursor conn.execute( "INSERT OR REPLACE INTO items (collection, id, updated, deleted, data, seq) VALUES ('files', ?, ?, ?, ?, ?)", (item["id"], item["updated"], 1 if item.get("deleted") else 0, json.dumps(item, ensure_ascii=False), cursor(conn) + 1), ) # Server-Stand für den Client: Steckbrief plus Hash, damit er den Anschluss findet def state(conn, file_id): m = meta(conn, file_id) if m and not m.get("deleted") and path(file_id).exists(): m = {**m, "hash": tail_hash_at(file_id, m["size"])} return m def merge(conn, item): """Nimmt eine Datei an: ganz (content), als Anhang (base + append) oder als Tombstone. Gibt (True, None) zurück, wenn übernommen, sonst (False, Server-Stand). """ file_id = item["id"] current = meta(conn, file_id) if current and current["updated"] >= item["updated"]: return False, state(conn, file_id) target = path(file_id) if item.get("deleted"): target.unlink(missing_ok=True) _store(conn, {"id": file_id, "updated": item["updated"], "deleted": True}) return True, None if "append" in item: base = item.get("base") or {} if not current or current.get("deleted") or not target.exists() or current["size"] != base.get("size") \ or tail_hash_at(file_id, current["size"]) != base.get("hash"): return False, state(conn, file_id) data = item["append"].encode() with open(target, "ab") as f: f.write(data) _store(conn, {"id": file_id, "updated": item["updated"], "size": current["size"] + len(data)}) return True, None data = item["content"].encode() target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) _store(conn, {"id": file_id, "updated": item["updated"], "size": len(data)}) return True, None # Inhalt ab Position: passt der Anfang des Clients (size, hash), kommt nur der Rest, sonst alles. def read_from(file_id, size, hash_) -> tuple[bytes, int]: target = path(file_id) total = target.stat().st_size start = size if 0 < size <= total and tail_hash_at(file_id, size) == hash_ else 0 with open(target, "rb") as f: f.seek(start) return f.read(), start # Ältere Stände hielten den Inhalt in der Datenbank. Einmal auf die Platte schreiben. def migrate(conn): rows = conn.execute("SELECT id, data FROM items WHERE collection = 'files' AND deleted = 0").fetchall() moved = 0 for row in rows: item = json.loads(row["data"]) if "content" not in item: continue data = item.pop("content").encode() target = path(row["id"]) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) item["size"] = len(data) conn.execute("UPDATE items SET data = ? WHERE collection = 'files' AND id = ?", (json.dumps(item, ensure_ascii=False), row["id"])) moved += 1 return moved