TUI: Lesestand-Vertrag (zuletzt gelesen gewinnt), Login wie diora_client, Bücher beim Öffnen laden
progress.py setzt docs/progress-sync.md um (read_at in Serverzeit mit Uhrversatz, sent_at beim
Senden, force, Warteschlange je Buch, Neuanwendung nach dem Abgleich; dieselben Regeln wie
Ops.kt/ops.js). Der Reader speichert nur, was der Leser bewegt hat; wiederherstellen,
Umbruch und Sprünge schreiben nichts. Neuere Stellen anderer Geräte werden angeboten
("Auf X weitergelesen – springen?"), nie von allein angewendet; "w" springt zur weitesten
Stelle. Fortschritt als Textanteil in Zeichen statt 0.0.
Abgleich über GET /books/ (ohne Markierungs-Blobs), Bücher erst beim Öffnen geladen
(☁ in der Liste). `diora-tui login` meldet wie die Apps an, liest den vorhandenen Token,
leitet den Schlüssel aus dem Passwort ab und speichert beides geschützt (DPAPI).
DIORA_TUI_HOME legt alle Dateien in einen Ordner (platformdirs folgt unter Windows den
Umgebungsvariablen nicht). 35 Tests, tools/smoke_server.py gegen einen lokalen Server.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:08:59 +01:00
|
|
|
"""End-to-end check of the sync against a LOCAL diora server (never a real account):
|
|
|
|
|
login, protected credentials, book list, lazy download, a position going out, another device
|
|
|
|
|
reading later, an offline catch-up that must lose. Throwaway state in a temp folder.
|
|
|
|
|
|
|
|
|
|
python tools/smoke_server.py http://127.0.0.1:8001 <username> <password>
|
|
|
|
|
|
|
|
|
|
It can run again on the same database (the furthest place only ever grows).
|
|
|
|
|
|
|
|
|
|
The server is the diora-web checkout run with its own throwaway database, seeded like
|
|
|
|
|
diora-android's tools/seed_testserver.py does.
|
|
|
|
|
"""
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
import time
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
home = tempfile.mkdtemp(prefix="diora-tui-smoke-")
|
|
|
|
|
for var in ("LOCALAPPDATA", "APPDATA", "XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"):
|
|
|
|
|
os.environ[var] = home
|
|
|
|
|
os.environ["HOME"] = home
|
|
|
|
|
os.environ["DIORA_TUI_HOME"] = home
|
|
|
|
|
|
|
|
|
|
from diora_tui import api, blocks, config, crypto, epub, login, progress, remote # noqa: E402
|
|
|
|
|
|
|
|
|
|
server, username, password = sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3]
|
|
|
|
|
if "127.0.0.1" not in server and "localhost" not in server:
|
|
|
|
|
sys.exit("only against a local test server")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check(what, ok, detail=""):
|
|
|
|
|
print(("OK " if ok else "FAIL ") + what + (f" [{detail}]" if detail else ""))
|
|
|
|
|
if not ok:
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
token, name = login.web_login(server, username, password)
|
|
|
|
|
key_b64 = crypto.derive_key_b64(name, password)
|
|
|
|
|
cfg = config.RemoteConfig(server, token, key_b64, name)
|
|
|
|
|
config.save(cfg)
|
|
|
|
|
back = config.load()
|
|
|
|
|
check("credentials stored protected and read back",
|
|
|
|
|
back == cfg and (sys.platform != "win32" or "dpapi:" in Path(config._CONFIG_FILE).read_text()))
|
|
|
|
|
check("no token or key in the config file in the clear",
|
|
|
|
|
token not in Path(config._CONFIG_FILE).read_text() and key_b64 not in Path(config._CONFIG_FILE).read_text())
|
|
|
|
|
|
|
|
|
|
lib = Path(home) / "lib"
|
|
|
|
|
res = remote.sync_library(lib, cfg)
|
|
|
|
|
check("book list pulled, nothing downloaded", res.books >= 1 and not list(lib.glob("*.epub")), f"{res.books} books")
|
|
|
|
|
store = progress.default()
|
|
|
|
|
check("server clock offset learnt", abs(store.offset) < 5000, f"{store.offset} ms")
|
|
|
|
|
|
|
|
|
|
entry = sorted(__import__("diora_tui.library_meta", fromlist=["x"]).remote_entries().items())[0]
|
|
|
|
|
skey, info = entry
|
|
|
|
|
path = remote.download_book(lib, cfg, info["server_id"], info["title"])
|
|
|
|
|
check("book downloaded on demand and keyed by the server id", path.exists() and epub._book_id(path) == skey, path.name)
|
|
|
|
|
book = blocks.load_flat_book(path)
|
|
|
|
|
|
TUI: Bibliothek wie im Web (Weiterlesen-Karte, Status- und Ordner-Chips, Suche, Sortierung, Länge)
library.py hält die Regeln der Web-Bibliothek (53-library.js) als reine Funktionen:
Status Angefangen/Ungelesen/Gelesen, Ordner (mehrere je Buch, Reihenfolge aus den
verschlüsselten Bibliothekseinstellungen, "Ohne Ordner"), Suche ohne Akzente, sechs
Sortierungen mit natürlicher Zahlenordnung, Weiterlesen-Karte aus den zuletzt gelesenen,
Bücher/Zeitschriften. library_screen.py zeigt sie an (Tasten 1-4, o, /, s, m, c; Chips und
Karte klickbar). Metadaten (Ordner, Seitenzahl, Zeitschrift) und die Ordnerreihenfolge
kommen beim Abgleich; fehlt die Länge, rechnet die TUI sie für geladene Bücher aus dem Text
(1800 Zeichen je Seite). crypto.encrypt für Stufe 3. 54 Tests, Smoke prüft Ordner/Länge/Prefs
gegen den lokalen Server.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:18:49 +01:00
|
|
|
# folders, length and the folder order come from the encrypted meta and prefs
|
|
|
|
|
import json # noqa: E402
|
|
|
|
|
|
|
|
|
|
import requests # noqa: E402
|
|
|
|
|
|
|
|
|
|
H = {"Authorization": f"Bearer {token}"}
|
|
|
|
|
raw_books = {b["id"]: b for b in api.fetch_books(server, token)[0]}
|
|
|
|
|
b0 = raw_books[info["server_id"]]
|
|
|
|
|
meta = json.loads(crypto.decrypt(key_b64, b0["meta_iv"], b0["meta_ct"]))
|
|
|
|
|
meta.update(folders=["Smoke B", "Smoke A"], pages=123)
|
|
|
|
|
iv, ct = crypto.encrypt(key_b64, json.dumps(meta).encode())
|
|
|
|
|
check("meta written", requests.post(f"{server}/books/{info['server_id']}/meta/", json={"meta_ct": ct, "meta_iv": iv}, headers=H).ok)
|
|
|
|
|
iv, ct = crypto.encrypt(key_b64, json.dumps({"folder_order": ["Smoke B", "Smoke A"]}).encode())
|
|
|
|
|
check("prefs written", requests.post(f"{server}/books/prefs/", json={"prefs_ct": ct, "prefs_iv": iv}, headers=H).ok)
|
|
|
|
|
remote.sync_library(lib, cfg)
|
|
|
|
|
from diora_tui import library, library_meta # noqa: E402
|
|
|
|
|
|
|
|
|
|
e = library_meta.remote_entries()[skey]
|
|
|
|
|
check("folders and length pulled from the meta", e["folders"] == ["Smoke B", "Smoke A"] and e["pages"] == 123, str(e))
|
|
|
|
|
check("folder order pulled from the prefs", library_meta.get_extra("folder_order") == ["Smoke B", "Smoke A"])
|
|
|
|
|
rows = library.build_rows([path], library_meta.remote_entries(), {}, {}, epub._book_id)
|
|
|
|
|
check("a row carries them", [r for r in rows if r.key == skey][0].folders == ["Smoke B", "Smoke A"])
|
|
|
|
|
|
TUI: Lesestand-Vertrag (zuletzt gelesen gewinnt), Login wie diora_client, Bücher beim Öffnen laden
progress.py setzt docs/progress-sync.md um (read_at in Serverzeit mit Uhrversatz, sent_at beim
Senden, force, Warteschlange je Buch, Neuanwendung nach dem Abgleich; dieselben Regeln wie
Ops.kt/ops.js). Der Reader speichert nur, was der Leser bewegt hat; wiederherstellen,
Umbruch und Sprünge schreiben nichts. Neuere Stellen anderer Geräte werden angeboten
("Auf X weitergelesen – springen?"), nie von allein angewendet; "w" springt zur weitesten
Stelle. Fortschritt als Textanteil in Zeichen statt 0.0.
Abgleich über GET /books/ (ohne Markierungs-Blobs), Bücher erst beim Öffnen geladen
(☁ in der Liste). `diora-tui login` meldet wie die Apps an, liest den vorhandenen Token,
leitet den Schlüssel aus dem Passwort ab und speichert beides geschützt (DPAPI).
DIORA_TUI_HOME legt alle Dateien in einen Ordner (platformdirs folgt unter Windows den
Umgebungsvariablen nicht). 35 Tests, tools/smoke_server.py gegen einen lokalen Server.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:08:59 +01:00
|
|
|
# the reader arrives at block 12 now
|
|
|
|
|
now = progress.now_ms()
|
|
|
|
|
a1 = blocks.format_anchor(12, 0.5)
|
|
|
|
|
store.write(skey, a1, blocks.fraction_for_anchor(book, 12, 0.5), now)
|
|
|
|
|
check("position queued", store.get(skey).pending is not None)
|
|
|
|
|
check("flushed", remote.flush_pending(cfg) == 1 and store.get(skey).pending is None)
|
|
|
|
|
row = [b for b in api.fetch_books(server, token)[0] if b["id"] == info["server_id"]][0]
|
|
|
|
|
check("server holds it with device and a real fraction",
|
|
|
|
|
row["position_anchor"] == a1 and row["device"] == "diora TUI" and 0 < row["scroll_fraction"] < 1
|
|
|
|
|
and abs(row["read_at"] - now) < 10_000, f"fraction {row['scroll_fraction']:.4f}, read_at diff {row['read_at'] - now} ms")
|
|
|
|
|
|
|
|
|
|
# another device reads somewhere else, later
|
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
other = blocks.format_anchor(3, 0.0)
|
|
|
|
|
api.post_progress(server, token, info["server_id"], anchor=other, fraction=0.02, read_at=progress.now_ms(), device="Handy")
|
|
|
|
|
remote.sync_library(lib, cfg)
|
|
|
|
|
st = store.get(skey)
|
|
|
|
|
check("a pull shows the later position of the other device", st.anchor == other and st.device == "Handy")
|
|
|
|
|
check("the furthest place is kept (never behind what was read)",
|
|
|
|
|
progress.cmp_progress(st.furthest_anchor, 0, a1, 0) >= 0, st.furthest_anchor)
|
|
|
|
|
|
|
|
|
|
# an offline catch-up from before that must not become the continue point
|
|
|
|
|
store.write(skey, blocks.format_anchor(30, 0.0), 0.5, progress.now_ms() - 120_000)
|
|
|
|
|
remote.flush_pending(cfg)
|
|
|
|
|
row = [b for b in api.fetch_books(server, token)[0] if b["id"] == info["server_id"]][0]
|
|
|
|
|
check("an older write loses on the server", row["position_anchor"] == other and row["device"] == "Handy")
|
|
|
|
|
check("...and locally", store.get(skey).anchor == other and store.get(skey).pending is None)
|
|
|
|
|
check("...but raises the furthest place",
|
|
|
|
|
progress.cmp_progress(row["furthest_anchor"], 0, blocks.format_anchor(30, 0.0), 0) >= 0, row["furthest_anchor"])
|
|
|
|
|
print("all good")
|