Files
desktop-planer/backend/app/files.py
2026-10-05 21:15:41 +02:00

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