Commit graph

327 commits

Author SHA1 Message Date
578b0e478b Merge: Kapitel aus der Audiodatei (ID3) und Transkripte
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Failing after 30s
Migration als 0009 hinter 0008_feed_http_cache eingereiht; Tests an die
neue _fetch_feed-Schnittstelle (FeedFetch) angepasst. 202 Tests grün.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:59:33 +02:00
38d7f6c643 Podcasts: Kapitel aus dem ID3-Tag der Audiodatei, Transkripte
- Ohne Kapitel im Feed (kein PSC, keine chapters_url oder dort nichts)
  liest die Folgen-Detailansicht einmalig den ID3v2-Tag am Anfang der
  Audiodatei (Range-Anfrage, höchstens 4 MB, Weiterleitungen mit
  SSRF-Prüfung) und übernimmt dessen CHAP-Frames (v2.3/v2.4,
  Unsynchronisation, erweiterter Header, Synchsafe-Größen, CTOC-Reihenfolge).
  Eigener Parser nach der ID3-Spezifikation; Ergebnis wird wie die
  JSON-Kapitel zwischengespeichert, vorübergehende Fehler nicht.
- Neu: GET /podcasts/episodes/<id>/transcript/ holt das beste Transkript
  (JSON vor VTT vor SRT, Deutsch bzw. ohne Sprachangabe vor Englisch),
  zerlegt es in Abschnitte {start, end, speaker, text}, fasst kurze
  Schnipsel desselben Sprechers bis zum Satzende bzw. ~30 s zusammen und
  speichert das Ergebnis (eigene Tabelle EpisodeTranscript). HTML: 404
  {"error": "unsupported"}.
- Folgen-Detail liefert zusätzlich has_transcript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:54:03 +02:00
9b0848c30c Merge: Podcast-Einstellungen pro Podcast und robusteres Feed-Laden
Migrationen hintereinander statt nebeneinander: 0007_feed_settings,
dann 0008_feed_http_cache. Tests der Podcast-Regeln an die neue
_fetch_feed-Schnittstelle (FeedFetch) angepasst; Feeds-Liste trägt
Einstellungen und Fehlerfelder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:52:31 +02:00
7fdcbb4a3d Podcasts: Einstellungen pro Podcast auf dem Server, Regeln für neue Folgen, Warteschlange sortiert halten
- PodcastFeed (Migration 0007_feed_settings): playback_speed (null = Tempo
  des Geräts), skip_intro, skip_outro, volume (0.2/0.5/1.0/1.5/2.0/2.5),
  new_episodes (inbox/queue/ignore), include_words, exclude_words,
  min_duration. Abos mit auto_queue bekommen new_episodes='queue';
  auto_queue bleibt gleich (new_episodes == 'queue'), auch über
  set-auto-queue/ und beim Speichern im Code.
- POST feeds/<id>/settings/: beliebige Teilmenge, prüft jeden Wert (400 mit
  Feldname, dann wird nichts geändert), nur eigene Abos (sonst 404);
  Antwort sind alle Einstellungen des Abos. feeds/ und state/ liefern sie
  für jedes Abo mit.
- Neue Folgen beim Aktualisieren: was am Filter scheitert (keins der
  Wörter, ein Ausschlusswort im Titel, kürzer als die Mindestlänge) und
  bei 'ignore' alles verlässt sofort „Neu“ (nicht gehört); 'queue' reiht
  an der auto_queue_position ein; 'inbox' wie bisher. Beim ersten Abruf
  zählt weiter nur die neueste Folge.
- PodcastSettings.keep_sorted ('', new, old, short, long, feed): danach
  wird die ganze Warteschlange nach jedem Einreihen, jedem Umsortieren und
  beim Einschalten neu sortiert; settings/ und state/ liefern den Wert.
- Tests für alles davon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:47:49 +02:00
bbe5f6c5c2 Podcasts: Feeds bedingt abrufen, Fehler speichern, Umzüge folgen, Backoff
- Bedingter Abruf: ETag/Last-Modified werden nach einer 200 gespeichert und
  als If-None-Match/If-Modified-Since mitgeschickt. Bei 304 wird nichts
  geparst und keine Folge geschrieben, nur last_refreshed_at aktualisiert und
  der Fehler gelöscht. Teilen sich mehrere Abos eine URL, gehen die Validatoren
  nur mit, wenn alle schon einmal abgerufen wurden und dieselben haben.
- Fehler (Netz, HTTP >= 400, kein Feed) landen in last_error, last_error_at
  und error_count; ein erfolgreicher Abruf setzt sie zurück. Feed-Liste und
  /podcasts/state/ liefern die drei Felder mit.
- Dauerhafte Weiterleitungen (301/308 am Anfang der Kette) ändern rss_url,
  außer der Nutzer hat die neue URL schon abonniert; 302/307 ändern nichts.
- Backoff im stündlichen Job: ab 3 Fehlern in Folge nur alle 2, 4, 8, 16,
  höchstens 24 Stunden. „Alle aktualisieren“ und --feed-id versuchen es
  trotzdem (max_age gilt weiter).
- _fetch_feed liefert jetzt ein FeedFetch-Objekt bzw. wirft FeedFetchError.
- Migration 0007_feed_http_cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:38 +02:00
f9b3a0ce04 Podcasts: Inbox und Gehört wie in AntennaPod
Some checks failed
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Failing after 28s
- Eingereihte und angefangene Folgen verlassen die Inbox (ausgeblendet,
  nicht gehört), statt dort neben der Warteschlange zu stehen.
- Gehört heißt: höchstens 30 s vor dem Ende, wie in den Apps, statt bei
  90 % (bei 2 h waren das 12 Minuten vor Schluss). Gilt auch für den
  gPodder-Sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:26:28 +02:00
c335ab4094 gPodder-Sync: Hörstelle auf die eigene Folge schreiben
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Failing after 28s
Die Folge wurde nur über die Audio-Adresse gesucht, über alle Nutzer
hinweg. Haben zwei Nutzer denselben Podcast, landete die Stelle bei der
Folgen-Zeile des anderen (der eigene Fortschritt, aber an der falschen
Folge) und kam beim eigenen Abo nie an. Jetzt nur unter den eigenen
Abos gesucht; mit Test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:18:23 +02:00
f30bf2fcdc Podcasts: Favoriten, Folgenliste mit Filtern und Verlauf, Hörstatistik, OPML-Export
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Failing after 27s
- EpisodeProgress.favorite (Migration 0006) und POST episodes/<id>/favorite/
  (setzen oder umschalten; ändert updated_at nicht, damit Verlauf und
  Statistik nicht verfälscht werden)
- GET episodes/: alle Folgen aller Abos, Filter all/unplayed/in_progress/
  played/favorites/history, feed_id, q, offset/limit/total; zwei Abfragen
  pro Seite, ohne Shownotes
- Folgendetails liefern zusätzlich favorite und last_played_at
- GET stats/: Hörzeit gesamt und pro Podcast, optional nur die letzten N Tage
- GET feeds/export.opml: Abos als OPML 2.0 (lässt sich wieder importieren)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:12:27 +02:00
fd0a103fb2 Podcasts: state/ ohne Shownotes in der Inbox
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Failing after 28s
Seit die Shownotes content:encoded nehmen (bis 50 KB pro Folge), würde
die eine Anfrage der Apps mit 200 Inbox-Folgen sehr groß. state/ lässt
sie weg; die Apps holen sie pro Folge über episodes/<id>/. inbox/ für
das Web liefert sie weiter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:01:34 +02:00
f4b8bcc891 Podcasts: Kapitel, Shownotes und Transkripte für die Apps
- Feeds liefern jetzt Podlove-Kapitel (psc:chapters), Podcasting-2.0-Kapitel
  (podcast:chapters, JSON-URL) und Transkripte (podcast:transcript, auch
  mehrere); als Beschreibung wird content:encoded bevorzugt.
- Bekannte Folgen werden beim Aktualisieren nachgezogen, aber nur die Zeilen,
  die sich wirklich geändert haben.
- Neuer Endpunkt GET /podcasts/episodes/<id>/ mit bereinigten Shownotes,
  Zeitmarken, Kapiteln, Transkripten und Fortschritt. JSON-Kapitel werden
  einmal serverseitig geholt (nur öffentliche Adressen, max. 1 MB) und an
  der Folge gespeichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:56:44 +02:00
a1e17081c5 Podcasts: neues Abo bringt nur die neueste Folge in die Inbox
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 2m3s
Beim ersten Abruf eines Feeds (Abonnieren, OPML-Import, gpodder) kam der
ganze Rückkatalog als „neu“ in die Inbox (bei Lage der Nation 200
Folgen). Jetzt zählt nur die neueste; die älteren sind ausgeblendet,
nicht gehört, und stehen weiter in der Folgenliste des Podcasts. Auch
automatisches Einreihen nimmt beim ersten Abruf nur diese eine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:48:45 +02:00
176771a407 Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
All checks were successful
Build and push Docker image / build (push) Successful in 19s
Test / test (push) Successful in 2m0s
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
  Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
  für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
  nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
  automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
  (where) und die laufende Folge (current_episode_id); ohne Angabe gilt
  die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.

Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
  auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
  über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
  übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
  Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
  Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
  selbst anzustoßen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
3717640a4e Radio: Stream-Adresse ins Suchfeld einfügen; Favoriten-Stern reparieren (SW v50)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 1m35s
- Eine http(s)-Adresse im Suchfeld wird nicht gesucht, sondern als Treffer
  mit Play und Save (Name wählbar) angeboten. Der Query-String (oft ein
  Zugangstoken) wird nicht angezeigt, aber mitgespeichert.
- toggleFav gab es zweimal; die zweite Fassung verdeckte die erste und
  brauchte den Button als Argument, den die serverseitig gerenderten Zeilen
  nicht übergeben. Folge: Favorit auf dem Server gesetzt, Stern blieb leer,
  der nächste Klick nahm ihn wieder weg. Jetzt eine Fassung für beide Fälle,
  inklusive Umsortieren (Favoriten zuerst, wie der Server).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:33:34 +02:00
bca9d6b5e9 Radio: http-Streams in nativen Hüllen abspielen lassen; Play-Session nicht mehr verlieren (SW v49)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 1m34s
playStation() öffnet http-Sender auf einer https-Seite in einem neuen
Fenster. Native Hüllen (diora Android, diora desktop), die den Stream
selbst abspielen, setzen window.DIORA_PLAYS_HTTP_STREAMS und überspringen
das.

stopPlaySession() wurde von playStation() ohne await aufgerufen und
setzte currentPlayId erst nach dem fetch auf null – beim Senderwechsel
also die frisch gesetzte ID der neuen Session. Die ID wird jetzt vor dem
await übernommen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:00:42 +02:00
77d662a9cb API-Token: Anfragen mit Bearer-Token brauchen kein CSRF-Token
All checks were successful
Build and push Docker image / build (push) Successful in 18s
Test / test (push) Successful in 1m36s
Native Clients (diora desktop) haben kein CSRF-Cookie; ein Browser hängt den
Authorization-Header nie von selbst an, daher ist das sicher. Nötig z. B. für
den M3U-Import, der nicht csrf_exempt ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:33:18 +02:00
086ce29020 Reader: Buch nie doppelt öffnen; ungültige Anker selbst reparieren (SW v48)
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 1m34s
openBook leert #reader-content und hängt die Kapitel über mehrere awaits
an. Ein zweiter Aufruf dazwischen (zweites Tippen, während ein langsamer
E-Reader noch entschlüsselt) hing das Buch doppelt an. Daraus gespeicherte
Anker lagen hinter dem echten Buchende (tolino: Block 1491 bei 1206
Blöcken) und schlugen beim Server-Vergleich "weitester Stand gewinnt"
jede echte Position: Fortschritt wurde nicht mehr gespeichert.

- openBook läuft nur einmal gleichzeitig (gleiches Buch: auf den laufenden
  Aufruf warten; anderes Buch: danach öffnen).
- Aus einem doppelt gerenderten Dokument wird kein Fortschritt gespeichert.
- Ein gespeicherter Anker, der nicht zum Buch passt, führt zum Textanteil
  (scroll_fraction) statt an den Anfang, und die nächste Speicherung
  überschreibt ihn (force), damit der Fortschritt wieder läuft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:19:05 +02:00
7ee44622ee Reader: Schriftgewicht einstellbar (SW v47)
All checks were successful
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 1m36s
Neuer Regler im Einstellungs-Panel, 200-800: bei Source Serif 4 (variabel) in
25er-Schritten, bei Systemschriften in 100ern. null behält das Gewicht aus dem
Stylesheet. --reader-weight erlaubt alten WebViews ohne variable Schriften,
einen statischen Schnitt zu wählen. "Kein Fett" erbt jetzt das eingestellte
Gewicht statt fest normal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:25:59 +02:00
09055b9874 Reader: Lesefortschritt nach Textanteil statt Pixeln (SW v46)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 1m33s
Der Fortschritt (Speichern/Sync, Prozentanzeige im Reader-Kopf, Sprung
über das Prozentfeld, "≈ x % im Buch" bei Markierungen) wird jetzt aus
dem Positions-Anker als Anteil des Buchtexts in Zeichen berechnet statt
aus scrollTop/scrollHeight. Das ist unabhängig vom Layout (Schriftgröße,
Breite, welche Kapitel gerendert sind) und damit auf allen Clients gleich
— die Android-App rendert im E-Reader-Modus nur ein Kapitel und kennt die
Pixelhöhe des Buchs nicht. Pixel zählten außerdem Ränder, Überschriften
und Bilder mit. Anker und Server-Logik bleiben unverändert; PDFs weiter
nach Seiten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:57:10 +02:00
marwin
9d097f5fba Reader: Notiz-Randspalte auf dem Handy nicht automatisch öffnen (SW v45)
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 1m33s
Beim Öffnen eines Buchs mit Markierungen klappte die Randspalte auch auf
schmalen Bildschirmen auf und verdeckte den Text. Auf Mobil (_isMobileReader)
bleibt sie jetzt zu und ist weiter über ✎ erreichbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:49:09 +00:00
marwin
149b64b67c Reader: Source Serif 4 etwas leichter (400 -> 380)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 1m31s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mxq5zi49Qs9FyQEQAeR8xc
2026-09-12 15:11:50 +02:00
marwin
51fb6258b8 Serif-Schriftart im Reader auf Source Serif 4 umstellen
All checks were successful
Build and push Docker image / build (push) Successful in 18s
Test / test (push) Successful in 1m32s
Selbst gehostet (Latin + Latin Extended, normal/italic, variable
Weight 200-900) statt Google-Fonts-Request, damit der Reader offline
über den Service-Worker-App-Shell-Cache weiter funktioniert. SW-Cache
auf v44 gebumpt, damit bestehende Clients die neuen Font-Dateien
nachladen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mxq5zi49Qs9FyQEQAeR8xc
2026-09-12 15:05:08 +02:00
marwin
26f983bbaa Default-Lautstärke auf 40% senken
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 1m31s
Betrifft alle Sender, insbesondere creamfresh radio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drq8o4HfFmwkXz2AgvaQ4p
2026-09-04 12:30:58 +02:00
marwin
171560c71e Add thumbs up/down for creamfresh radio, hardcoded to that station
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 1m35s
Relays to the DJ's own /dj/feedback via the same server-side-auth
proxy pattern as the stream itself, so the browser never needs the
station's credentials. Buttons only show when the currently playing
station is creamfresh radio (matched by URL against a hardcoded
constant) -- no other station's backend does anything with a vote,
so no other station gets the buttons yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drq8o4HfFmwkXz2AgvaQ4p
2026-09-04 11:38:34 +02:00
marwin
9228906db8 Add a transparent stream proxy for creamfresh radio
All checks were successful
Build and push Docker image / build (push) Successful in 19s
Test / test (push) Successful in 1m34s
A plain <audio src="https://user:pass@host/..."> doesn't get its
embedded Basic Auth credentials honoured consistently across
browsers (the user hit a native auth prompt on play) -- this instead
holds the credentials server-side and relays the request. Forwards
the client's own Icy-MetaData header and the upstream's icy-* response
headers unchanged, so the existing icy.py-based metadata SSE keeps
working when pointed at this URL instead of the direct one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drq8o4HfFmwkXz2AgvaQ4p
2026-09-04 11:29:52 +02:00
marwin
0fd5461daa Vorlesen: Sprachauswahl DE/EN mit englischer Piper-Stimme (SW v43)
All checks were successful
Build and push Docker image / build (push) Successful in 1m7s
Test / test (push) Successful in 1m32s
Reader-Header bekommt ein #reader-tts-lang-Dropdown (Auswahl, keine
Sprach-Erkennung — persistiert in localStorage); /tts/synthesize/ nimmt jetzt
{"lang": "de"|"en"} entgegen. tts/piper_engine.py lädt pro Sprache eine eigene
PiperVoice lazy (settings.TTS_VOICES), Docker-Image lädt zusätzlich
en_US-lessac-medium beim Build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6yX2S9aTGepYA9JFba9x
2026-09-01 07:04:27 +02:00
marwin
05216a8613 Vorlesen-Funktion im Reader: Piper/Thorsten-TTS (SW v42)
All checks were successful
Build and push Docker image / build (push) Successful in 1m3s
Test / test (push) Successful in 1m32s
Serverseitige Sprachsynthese (deutsche Thorsten-Stimme, Piper) für den
EPUB-Reader: /tts/synthesize/ nimmt einen Satz (max. 500 Zeichen) entgegen,
synthetisiert und streamt WAV zurück, ohne zu persistieren, zu loggen oder zu
cachen — eine bewusste, eng begrenzte Ausnahme vom "Server sieht nie
Klartext"-Prinzip (siehe CLAUDE.md). Der neue ▶-Button im Reader-Header liest
ab der aktuellen Position satzweise vor, hebt den gerade gesprochenen Satz
hervor und scrollt mit; eine kleine Leiste bietet Pause/Stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6yX2S9aTGepYA9JFba9x
2026-08-31 22:29:13 +02:00
marwin
58fad47d90 CI: auf Python 3.12 heben, damit gegen das getestet wird, was ausgeliefert wird
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 1m16s
Der Testlauf lief in catthehacker/ubuntu:act-22.04 mit Python 3.10. Django 6.1
verlangt >= 3.12, ließ sich dort also gar nicht installieren — was erst durch
das Pinnen aufgefallen ist: vorher stand "django>=4.2" in requirements.txt und
pip hat in CI still ein 4.x aufgelöst, während das Image auf python:3.12-slim
ein 6.1 bekam. Die Suite hat damit eine andere Django-Version getestet als die
ausgelieferte.

24.04 bringt Python 3.12 mit und markiert sein System-Python als extern
verwaltet (PEP 668), deshalb laufen die Schritte jetzt über ein venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:56:37 +00:00
marwin
23798cda01 Alle Abhängigkeiten pinnen; STATICFILES_STORAGE auf STORAGES umstellen
Some checks failed
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Failing after 6s
requirements.txt stand durchgehend auf ">=", die gebaute Version hing also am
Kalendertag statt am Repo. Genau daran ist der letzte Deploy gescheitert.
Jetzt exakt die Versionen, gegen die die Suite grün ist und die produktiv
laufen — Updates werden damit zu einer bewussten Änderung mit CI-Lauf.

Django bleibt auf 6.1: dorthin ist die Instanz ohnehin schon gedriftet, es
läuft, und 4.2 LTS ist seit April 2026 aus dem Support.

Dabei ist aufgefallen, dass Django 5.1 STATICFILES_STORAGE entfernt hat. Die
Einstellung stand noch da und wurde stillschweigend ignoriert, womit
whitenoise auf StaticFilesStorage zurückfiel und Assets unkomprimiert
auslieferte — app.js mit 251 KB statt 62 KB bei jedem kalten Laden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:50:47 +00:00
marwin
0b62fbc0de Hotfix: gunicorn/packaging pinnen — gevent-Worker startete nicht mehr
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 53s
Der erste Rebuild seit dem 17.08. zog gunicorn 26.2.0, dessen ggevent.py
packaging.version importiert, während das Paket selbst überhaupt keine
Dependencies deklariert ("Requires:" ist leer). Damit fehlte packaging im
Image und gunicorn brach beim Start ab:

    Error: class uri 'gevent' invalid or not found
    ModuleNotFoundError: No module named 'packaging'

Beides wandert nach requirements.txt, und das Dockerfile installiert
gunicorn nicht mehr ungepinnt nebenher — sonst kann der nächste Rebuild den
Worker wieder unter uns austauschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:34:27 +00:00
marwin
b205625e21 Bücher: Cloud-Import aus WebDAV/Nextcloud (SW v41)
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 52s
Verbindungen werden pro Nutzer in den Einstellungen angelegt (Nextcloud,
ownCloud, Synology oder generisches WebDAV) — der Server ist bewusst nicht
auf eine feste Instanz verdrahtet. Im Bücher-Tab lässt sich der entfernte
Ordner durchblättern und eine .epub/.pdf direkt in die Bibliothek ziehen.

Der Download läuft über den Server, weil der WebDAV-Host cross-origin ist
und keine CORS-Header schickt. Verschlüsselt wird trotzdem erst im Browser:
uploadEbook ist in _importEbookBuffer aufgeteilt, das sich lokaler Upload
und Cloud-Import teilen. Gespeichert wird wie bisher nur Geheimtext.

Weil jeder registrierte Nutzer die Ziel-URL bestimmt und diora im Docker-Netz
neben anderen Diensten läuft, ist der Import eine SSRF-Fläche. Dagegen:

  - assert_safe_url weist Hosts ab, die auf nicht-öffentliche Adressen
    auflösen (inkl. NAT64 und IPv4-kompatibler v6-Adressen, die is_global
    durchlässt)
  - _assert_peer_is_safe prüft die tatsächliche Peer-Adresse nach dem
    Verbinden — requests löst den Namen ein zweites Mal auf, sonst wäre der
    Guard per DNS-Rebinding umgehbar
  - Redirects werden abgelehnt statt verfolgt
  - identische Fehlermeldung für "nicht auflösbar" und "privat", ohne die
    IP zu nennen, damit der Endpunkt kein Scanner für interne Dienste wird

Antwort-Bodies laufen durch _read_capped, und DTDs werden vor dem Parsen
abgewiesen: ElementTree expandiert interne Entities, und seit Python 3.12
gibt es XMLParser.parser nicht mehr, um einen Handler zu setzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:26:22 +00:00
marwin
0de6c186fb Bücher: "Zuletzt gelesen" wieder als normale Listenansicht statt Karten-Leiste (SW v40)
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 24s
Bleibt an der gleichen Stelle (fest oben, unabhängig von Ordner-Navigation und
Lesefilter), nutzt jetzt aber dieselben Listeneinträge wie der Rest der Ansicht
(inkl. Drei-Punkte-Menü) statt der horizontal scrollbaren Karten.

Da ein Buch dadurch gleichzeitig oben bei "Zuletzt gelesen" und unten in der
Ordner-/Gesamtliste gerendert werden kann, darf das Drei-Punkte-Menü nicht mehr
über eine (dann doppelt vergebene) id gesucht werden — toggleBookMenu bekommt
jetzt den geklickten Button und findet sein Menü relativ dazu im DOM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 15:37:09 +02:00
marwin
635fd7ef1c Bücher: Titel/Autor-Fallback + manuelle ISBN-Eingabe für Regal-Lookup (SW v39)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 25s
Bisher scheiterte "Metadaten abrufen" komplett, wenn im EPUB keine ISBN steckte
(oder bei PDFs, die nie eine liefern). Der Server versucht jetzt bei fehlendem/
erfolglosem ISBN-Treffer automatisch eine DNB- bzw. Open-Library-Textsuche nach
Titel+Autor (weniger präzise, daher als "dnb-title"/"openlibrary-title" markiert).
Bleibt auch das ohne Treffer, fragt der Client einmalig nach einer manuell
eingetippten ISBN (z.B. vom Buchrücken) und versucht es damit erneut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 12:33:54 +02:00
marwin
e6b3ee620e Bücher: Titel in "Zuletzt gelesen"/Ordner-Kacheln unsichtbar (Button-Farbvererbung); Regal-Lookup jetzt auch für Altbestand (SW v38)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 24s
<button> erbt in den meisten Browsern nicht automatisch color von der Seite —
.book-recent-item und .book-folder-tile hatten keine explizite Farbe gesetzt,
wodurch der Titeltext (kein separates color wie beim gedimmten Autor-Span)
unsichtbar auf dunklem Grund landete.

Der Metadaten-Button funktionierte bisher nur für Bücher, die nach der letzten
Änderung hochgeladen wurden (ISBN wurde nur dort extrahiert). Fehlt die ISBN im
Meta-Blob, wird sie jetzt bei Bedarf direkt aus der bereits clientseitig
entschlüsselbaren Buchdatei nachgeholt (gleiche OPF-Quelle wie beim Öffnen) und
für künftige Abrufe mitgespeichert — kein erneuter Upload nötig. Nur EPUB, PDFs
liefern über pdf.js keine verlässliche ISBN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 12:12:17 +02:00
marwin
3e301b6f61 Bücher: Regal-Metadaten-Lookup (DNB → Open Library) + Drei-Punkte-Menü (SW v37)
All checks were successful
Build and push Docker image / build (push) Successful in 20s
Test / test (push) Successful in 34s
Manuell per neuem "Metadaten"-Menüpunkt ausgelöst (nie automatisch): der Server
schlägt die aus dem EPUB extrahierte ISBN zuerst bei der DNB (SRU, DDC-Sachgruppe),
dann bei Open Library nach und gibt nur ein kurzes Label zurück, ohne etwas zu
speichern — das Label landet als Badge nur im ohnehin verschlüsselten Meta-Blob des
Clients. Das ist eine bewusste, eng begrenzte Ausnahme vom "Server sieht nie
Buchinhalte"-Prinzip: die Verschlüsselung dient vor allem der Absicherung gegen
Piraterie-Vorwürfe, nicht striktem Zero-Knowledge gegenüber dem eigenen Server.

Da pro Buch jetzt Reparieren/Herunterladen/Ordner/Metadaten/Lesestatus/Löschen
zusammenkommen, wandern alle Aktionen außer "Open" in ein Drei-Punkte-Menü.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 11:27:16 +02:00
marwin
7d99e58286 Bücher: Ordner (eine Ebene) + "Zuletzt gelesen"-Leiste (SW v36)
All checks were successful
Build and push Docker image / build (push) Successful in 22s
Test / test (push) Successful in 34s
Ordnername lebt im bereits verschlüsselten meta-Blob (wie Titel/Autor),
daher keine Schema-Änderung nötig — nur ein schlanker Endpoint zum
Aktualisieren von meta_ct/meta_iv ohne die Buchdaten neu hochzuladen.
Die Bücheransicht zeigt jetzt immer oben die letzten 7 gelesenen Titel
als Shortcut-Leiste, darunter Ordner-Kacheln (root) bzw. den Inhalt
eines geöffneten Ordners (kein Verschachteln).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
2026-08-17 10:58:53 +02:00
marwin
e9683fbdbd TUI: Zeichen am rechten Rand bei schmalen Terminals nicht mehr abgeschnitten
ContinuousBookViews vertikale Scrollbar reserviert 2 Spalten, die vorher
nicht aus der Wrap-Breite herausgerechnet wurden — der Text wurde also
2 Zeichen breiter gewrappt, als tatsächlich sichtbar war, wodurch die
Scrollbar die letzten 1-2 Buchstaben jeder Zeile überdeckt hat. Bei
schmalen Terminals (großer Font, wenig Spalten) war das besonders
auffällig, betraf strukturell aber jede Breite.

Fix: overflow-x: hidden (eine ungewollte horizontale Scrollbar hat
zusätzlich eine Zeile unten geklaut) + overflow-y: scroll (hält die
Scrollbar-Breite von Anfang an konstant, kein Rätselraten je nach
Inhaltsgröße) in book_view.py. reader_screen.py misst die Wrap-Breite
jetzt am Container statt an book_view selbst (vermeidet einen
Miss-nach-Einschränken-Zirkelbezug bei wiederholten Resizes) und rechnet
die feste Scrollbar-Breite (SCROLLBAR_GUTTER=2) heraus; beim Anwenden
des Layouts wird sie wieder daraufgerechnet, damit book_view.styles.width
weiterhin fürs Zentrieren passt.

Getestet: content_region-Breite stimmt jetzt exakt mit der Wrap-Breite
über sechs verschiedene Terminalbreiten (60-200 Spalten, inkl. des
120-Zeichen-Cap-Bereichs) überein — vorher lag sie durchgehend 2 Spalten
darunter. Voller Regressionstest (Scroll, Fußnote, Resize) läuft weiter
fehlerfrei.
2026-08-15 21:07:41 +02:00
marwin
20d04361bc TUI: Statusleiste mit Akkustand und Uhrzeit
Neues diora_tui/statusbar.py: StatusBar-Widget, unten rechts auf Bibliotheks-
und Reader-Screen, zeigt Akkustand (psutil.sensors_battery(), degradiert
sauber zu reiner Uhrzeit auf Geräten ohne Akku) + aktuelle Uhrzeit,
sekündlich aktualisiert.

Getestet: Anzeige und Aktualisierung auf beiden Screens verifiziert.
2026-08-15 20:36:54 +02:00
marwin
b1a04d2a65 TUI: Auto-Sync bei Start/Beenden, Sortierung nach zuletzt geöffnet, Gelesen-Filter
Library-Screen synct jetzt selbstständig statt den expliziten `sync`-Befehl
vorauszusetzen: einmal im Hintergrund direkt nach dem Start (neue Bücher/
Fortschritt erscheinen, sobald fertig), einmal abgewartet beim Beenden über
q (DioraTuiApp.action_quit). Beides best-effort — ohne gespeicherte
Zugangsdaten oder bei Netzwerkfehlern bleibt die lokale Bibliothek
unangetastet nutzbar.

Bibliotheksliste sortiert jetzt nach zuletzt geöffnetem Buch (progress.json
updated_at, neueste zuerst) und blendet gelesene Bücher standardmäßig aus
(EBook.is_read aus dem Sync-Snapshot, lokal in library.json gespiegelt) —
r-Taste zeigt sie für die Sitzung wieder an.

Getestet: vollständig isoliert (Pfad-Konstanten gemonkeypatcht statt echter
~/.config-/~/.local/share-Dateien) gegen einen echten Dev-Server mit zwei
Büchern unterschiedlichen Gelesen-Status und Fortschritts-Zeitstempeln —
Auto-Sync, Filter-Default, Toggle und Sortierreihenfolge korrekt bestätigt;
Sync-vor-dem-Beenden separat verifiziert (kein echter Hänger, nur ein
Test-Timing-Artefakt ohne pilot.pause()).
2026-08-15 20:30:09 +02:00
marwin
db3f520632 TUI: durchgehende Leseansicht mit web-kompatiblen Anchors, Fußnoten, bidirektionalem Progress-Sync
Reader zeigt Bücher jetzt als eine fortlaufende Ansicht über alle Kapitel
statt Kapitel für Kapitel (diora_tui/blocks.py, layout.py, book_view.py,
reader_screen.py). blockIndex wird exakt wie app.js' EPUB_BLOCK_SELECTOR
gezählt (p/h1-6/li/blockquote/dt/dd/figcaption + kindlose divs), inklusive
linear="no"-Spine-Einträgen — app.js filtert die nicht, und Skippen hätte
sowohl Fußnoten-Ziele verfehlt als auch alle folgenden Blockindizes gegen
den Web-Reader verschoben. innerFraction ist eine zeilenbasierte Näherung
(Terminal hat keine Pixel-Geometrie), was funktioniert, weil der
Furthest-Wins-Vergleich primär nach blockIndex sortiert.

Rendering nutzt Textuals Line-API (ContinuousBookView.render_line) statt
eines einzelnen riesigen Static — bei großen Büchern (mehrere reale
heruntergeladene Bücher haben zehntausende Blocks) hätte ein Static den
Layout/Paint-Pass auf über eine Minute gebracht. Zusätzlich cached
diora_tui/cache.py das (Buch, Layout)-Paar pro (Datei, Breite) auf Platte
für schnelles Wiederöffnen. Text ist auf 120 Zeichen begrenzt und
zentriert.

Fußnoten (f-Taste, FootnoteScreen): Erkennung wie app.js'
_looksLikeFootnoteLink; Ziel-Auflösung sammelt IDs aus dem ganzen
Block-Teilbaum (nicht nur vom Block-Tag selbst), weil Fußnoten-Ziele
häufig auf einem inneren <a> statt dem umschließenden <p> sitzen.

Progress-Sync ist jetzt bidirektional, ohne Übersetzungsschicht nötig, da
beide Seiten dasselbe Anchor-Format nutzen: sync zieht book_progress aus
dem Snapshot in den lokalen Store (furthest-wins); der Reader schickt bei
offenen server-verknüpften Büchern Updates zurück (force: false, im
Hintergrund-Worker).

Verschlüsselungs-Key-Beschaffung ergänzt um den Fallback
localStorage.getItem(...) falls die Clipboard-API in der Konsole
verweigert wird.

Getestet: Blockindex-/Fußnoten-Korrektheit gegen reale Bücher (u.a.
3686/3686 aufgelöste Fußnoten bei einem Zizek-Band), Anchor-Mathematik
per Unit-Test, vollständiger Pilot-Test (Navigation, Scroll,
Kapitelsprung, Fußnoten-Peek, Resize), Performance-Messung über mehrere
Buchgrößen inkl. Cache-Effekt (größtes Buch: ~34k Blocks, kalt ~20-30s,
warm ~5s), Save/Restore-Round-Trip 5x wiederholt gegen eine
Race-Condition beim ersten Post-Load-Scroll, und Progress-Push
End-to-End gegen einen echten Dev-Server verifiziert.
2026-08-15 17:59:46 +02:00
marwin
50f711831c TUI: Verschlüsselungs-Key per Benutzername+Passwort ableiten statt Konsolen-Export
diora_tui/crypto.py:derive_key_b64 reproduziert app.js' deriveAndStoreKey()
(PBKDF2-HMAC-SHA256, 200000 Iterationen, Salt "diora:"+username) — der
`sync`-Prompt bietet das jetzt als Standardweg zum Schlüssel an, als
Alternative zum bisherigen Weg über die Browser-Konsole (await
exportEncKey()). Funktioniert nur für Accounts, die den Key je über
diora's "Unlock with password"-Formular abgeleitet haben; ein falsches
Passwort/Account führt zu einem abgefangenen DecryptError ("falscher
Key?"), nie zu stillem Fehlverhalten.

Getestet: zwei unabhängige PBKDF2-Implementierungen (hashlib,
cryptography) stimmen für die verwendeten Parameter byte-genau überein;
vollständiger sync-Lauf gegen einen echten Dev-Server mit einem Buch, das
unter einem exakt so abgeleiteten Key verschlüsselt wurde, entschlüsselt
korrekt; falsches Passwort wird sauber als Fehler gemeldet statt
abzustürzen.
2026-08-15 16:55:03 +02:00
marwin
a4b10ae265 TUI: Bücher per diora-tui sync vom diora-Server holen und entschlüsseln
Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/data/, entschlüsselt Metadaten und
Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu
static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale
Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten
(Server-URL, Token, Base64-Key) landen auf Wunsch in
~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf.

Lesefortschritt wird bewusst noch nicht synced: der Server verankert
Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des
Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser
TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern.

Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger
sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download,
Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem
Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt).
2026-08-15 16:41:50 +02:00
marwin
3fcb74631c Merge master (Sync-API) into worktree-tui-reader 2026-08-15 16:22:54 +02:00
marwin
30a6d29ca8 Sync-API für lokale Clients: Personal-Access-Token + GET /api/sync/
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 23s
Ermöglicht Apps außerhalb des Browsers (z.B. den TUI-Client), sich per
Authorization: Bearer <token> zu authentifizieren statt per Session-Cookie.
Die neue ApiTokenAuthMiddleware setzt request.user genau wie ein Login,
wodurch alle bestehenden books/podcasts/radio-Endpunkte ohne Änderungen
token-fähig werden. GET /api/sync/ liefert zusätzlich den kompletten
Nutzerzustand (Bücher-Metadaten, Lesefortschritt, Notizen, Podcasts,
Sender) in einem Request; Schreiben läuft weiter über die bestehenden
Endpunkte, um deren Merge-Semantik (furthest-wins Progress, Notes-Upsert)
wiederzuverwenden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 16:20:54 +02:00
marwin
f63bd1f879 TUI: lokaler EPUB-Reader als erste Stufe eines diora-TUI-Clients
Python + Textual, unter tui/. Liest EPUBs aus einem Bibliotheksordner,
zeigt Kapitel als Fließtext, j/k/Pfeiltasten zum Scrollen, n/p für
Kapitelwechsel. Fortschritt wird lokal (progress.json via platformdirs)
nach demselben "furthest wins"-Prinzip wie diora's Web-Reader gespeichert,
damit ein Sync-Layer gegen die künftige diora-API später ohne Rewrite
andocken kann. Vorerst rein lokal, kein Server-Kontakt, keine
Verschlüsselung.
2026-08-15 16:04:18 +02:00
marwin
bb143a249b Reader: Toast-Optik repariert — Button-Font und doppelter Textschatten (SW v35)
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 15s
.reader-toast-btn hatte keine font-family gesetzt; Buttons erben das
anders als normaler Text nicht vom body, dadurch fiel "Zurückspringen"
auf die System-Schrift statt Courier New zurück.

.reader-toast/.reader-toast-action erbten außerdem den globalen
Halo-Textschatten (--outline), der eigentlich Text gegen wechselnde
Hintergründe absetzen soll. Die Toasts tauschen Vorder-/Hintergrund
aber bewusst (weiße Pille, schwarzer Text) und sitzen bereits auf
eigenem, blickdichtem Hintergrund — der geerbte Schatten hatte
dadurch exakt die Textfarbe und verdickte die Schrift nur unnötig,
statt einen Kontrast-Effekt zu erzielen. Jetzt wie die übrigen
Elemente mit eigenem farbigen Hintergrund (.btn-play etc.) explizit
mit text-shadow:none von diesem Effekt ausgenommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:02:05 +02:00
marwin
5053d0036a Reader: j/k-Scrollen ohne Ramp-up, an feste Kadenz statt Display-Hz gekoppelt (SW v34)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 17s
Die requestAnimationFrame-Schleife aus dem letzten Fix lief mit der tatsächlichen
Bildwiederholrate des Displays statt einer festen Rate — auf Laptops mit
120/144/165Hz-Panels bedeutete das 2-3x mehr scrollBy-Aufrufe (und damit
Scroll-Listener-Durchläufe) pro Sekunde als beabsichtigt, was sich als Lag
statt als Beschleunigung äußerte. Zurück zu setInterval, aber mit fester
20ms-Kadenz unabhängig vom Display und dt-kompensierter, konstanter
Geschwindigkeit (~1200px/s, kein Ramp-up mehr).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:50:23 +02:00
marwin
e70bb881cc Reader: schnelleres j/k-Scrollen; Offline-Modus auf Desktop repariert (SW v33)
All checks were successful
Build and push Docker image / build (push) Successful in 50s
Test / test (push) Successful in 16s
j/k nutzte ein starres 8px/16ms-setInterval (~500px/s, unsauber getaktet
gegenüber dem Frame-Takt) — ersetzt durch eine requestAnimationFrame-Schleife
mit sanftem Ramp-up (700 -> 1800px/s über 300ms), das behebt sowohl die zu
niedrige Geschwindigkeit als auch das Ruckeln kurz nach Scrollbeginn.

Der Service Worker war unter /static/js/sw.js registriert, wodurch sein
Scope (mangels Service-Worker-Allowed-Header) automatisch auf /static/js/
beschränkt war statt die ganze App abzudecken — Navigationen zu z.B. /
liefen nie durch den fetch-Handler, weshalb offline nur die Browser-eigene
Fehlerseite statt der App-Shell erschien. Jetzt wird sw.js zusätzlich unter
der Root /sw.js ausgeliefert (diora/urls.py) und dort mit explizitem
scope:'/' registriert; alte Registrierungen mit dem falschen Scope werden
beim Laden automatisch entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:31:01 +02:00
marwin
d3c435dd59 Reader: Notizen auf Mobile schnell setzbar statt über Randspalte (SW v32)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 15s
Auf schmalen Screens saß der Marker-Modus-Button bisher nur in der 150px-
Randspalte — man musste sie erst öffnen (und damit den ohnehin knappen
Lesebereich verkleinern), um überhaupt markieren zu können. Jetzt zusätzlich
ein Marker-Button direkt im Haupt-Header (nur <=600px sichtbar, Desktop
bleibt bei der bisherigen Anordnung in der Notes-Leiste).

Im Marker-Modus legt ein einfacher Tap auf reinen Lesetext (ohne Auswahl)
jetzt direkt eine freie Notiz an der Wortposition an — man muss nicht mehr
den exakten Klick-Punkt in der Randspalte treffen. Das Bearbeiten der
Notiz läuft auf schmalen Viewports über ein neues Bottom-Sheet (volle
Breite) statt über das Inline-Textfeld in der 150px-Spalte, das auf dem
Handy-Keyboard unangenehm zu bedienen ist.

Notiz-Objekte werden jetzt erst bei tatsächlich eingegebenem Text angelegt
und gespeichert (vorher: sofort beim Setzen des Ankers) — verhindert, dass
ein abgebrochener/leerer Tap eine Ghost-Notiz in currentHighlights
hinterlässt. Gilt für alle Eingabewege (Bottom-Sheet UND die bestehende
Inline-Randspalten-Bearbeitung), per Playwright verifiziert inkl. Escape-
Cancel auf Desktop.

Priorität laut Nutzer: Notizen-Erfassung muss auf Mobile gut funktionieren;
dass bestehende Notizen dort weniger prominent sichtbar sind, ist explizit
in Ordnung — die vertiefte Auseinandersetzung mit Notizen bleibt eine
Desktop-Aufgabe.

Verifiziert per Playwright (390×844 mobil + 1280×900 Desktop-Regression):
Marker-Button-Sichtbarkeit, Bottom-Sheet-Flow (Erstellen/Abbrechen/Speichern,
kein Ghost-Eintrag), reale Text-Selektion + Highlight+Notiz weiterhin
funktionsfähig auf Mobile, Desktop-Workflow (Randspalte, Inline-Editor,
Escape-Cancel, Blur-Commit) unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 20:13:55 +02:00
marwin
76068f6ed8 Reader: Fußnoten-Popover erkennt jetzt mehr EPUB-Konventionen (SW v31)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 14s
Die Erkennung von Fußnoten-Links war auf class="footnote" + gleiche-Datei-
Anker (href startsWith '#') hartkodiert. Andere Editionen markieren Fußnoten
anders — z.B. die e-artnow-Ausgabe von Marx' "Das Kapital" (zeno.org-Konvertierung)
nutzt gar keine Klasse und verlinkt mit einem <sup> als Kind (statt Vorfahre)
des <a> auf eine ANDERE Kapiteldatei ("../Text/Karl0045.html#F537") — dadurch
griff weder die Klassen- noch die Hash-Prüfung, das Popover blieb tot.

_looksLikeFootnoteLink() erkennt jetzt zusätzlich epub:type="noteref" (EPUB3-
Standard), jede Klasse mit "note"/"footnote"/"fn" im Namen, und als Fallback
jeden Link mit Fragment-Anker, der irgendwo mit einem <sup> verschachtelt ist
(in beide Richtungen) — der in EPUBs praktisch universelle Marker für
Fußnotenziffern. Die Fragment-Extraktion nimmt jetzt alles nach dem letzten
'#' statt nur bei href.startsWith('#'), da das Buch als eine zusammenhängende
DOM gerendert wird und getElementById dateiübergreifend funktioniert.

Verifiziert per Playwright: Marx-Edition zeigt jetzt beim Hover 1326 erkannte
Fußnoten-Links mit korrektem Popover-Inhalt; Žižeks "Weniger als nichts"
(vorher funktionierend, class="footnote"-basiert) weiterhin unverändert
funktionsfähig — keine Regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 18:55:48 +02:00
marwin
42bc090a28 Reader: Marker-Farben mit color:inherit statt UA-Default-Schwarz (SW v30)
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 15s
mark-Elemente haben browserseitig einen UA-Default (color:black), der bislang
nicht überschrieben wurde — beim Markieren von Text im dunklen Standard-Theme
(weißer Fließtext) sprang die Markierung dadurch abrupt auf schwarzen Text.
Jetzt color:inherit, damit markierter Text immer die Farbe des umgebenden
Texts behält; die vier Hintergrundfarben sind dafür kräftiger/dunkler gewählt,
sodass sie in allen drei Themes (dunkel/weißer Text, Sepia, Hell) ausreichend
Kontrast zum inherited Text bieten (WCAG-Kontrastwerte 4.8:1–11.7:1, per
Playwright-Screenshot in allen drei Themes gegengeprüft).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 18:44:09 +02:00