update
This commit is contained in:
111
backend/app/files.py
Normal file
111
backend/app/files.py
Normal file
@@ -0,0 +1,111 @@
|
||||
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
|
||||
Reference in New Issue
Block a user