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>
64 lines
1.8 KiB
Python
64 lines
1.8 KiB
Python
"""The account the TUI syncs with: server, API token and the book key.
|
|
|
|
Token and key are stored protected (secretstore.py), the server and user name in the clear.
|
|
A config from before 2026-10 (token and key as plain JSON fields) is still read and is
|
|
rewritten protected at once.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import stat
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from . import paths, secretstore
|
|
|
|
_CONFIG_DIR = paths.config_dir()
|
|
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
|
|
|
|
|
@dataclass
|
|
class RemoteConfig:
|
|
server_url: str
|
|
api_token: str
|
|
enc_key_b64: str
|
|
username: str = ""
|
|
|
|
|
|
def load() -> RemoteConfig | None:
|
|
try:
|
|
data = json.loads(_CONFIG_FILE.read_text())
|
|
except (OSError, ValueError):
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
try:
|
|
if "secret" in data:
|
|
secret = json.loads(secretstore.unprotect(data["secret"]))
|
|
return RemoteConfig(data["server_url"], secret["token"], secret["key"], data.get("username", ""))
|
|
cfg = RemoteConfig(data["server_url"], data["api_token"], data["enc_key_b64"], data.get("username", ""))
|
|
except (KeyError, ValueError, OSError):
|
|
return None
|
|
save(cfg) # a legacy plaintext config: protect it now
|
|
return cfg
|
|
|
|
|
|
def save(cfg: RemoteConfig) -> None:
|
|
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
secret = json.dumps({"token": cfg.api_token, "key": cfg.enc_key_b64}).encode()
|
|
_CONFIG_FILE.write_text(json.dumps({
|
|
"server_url": cfg.server_url, "username": cfg.username, "secret": secretstore.protect(secret),
|
|
}, indent=2))
|
|
try:
|
|
_CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def forget() -> bool:
|
|
try:
|
|
_CONFIG_FILE.unlink()
|
|
return True
|
|
except OSError:
|
|
return False
|