112 lines
4.1 KiB
Python
112 lines
4.1 KiB
Python
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
|