From 9d097f5fbaa9cc02d362313d3737754c445230dd Mon Sep 17 00:00:00 2001 From: marwin Date: Thu, 24 Sep 2026 10:49:09 +0000 Subject: [PATCH 001/160] =?UTF-8?q?Reader:=20Notiz-Randspalte=20auf=20dem?= =?UTF-8?q?=20Handy=20nicht=20automatisch=20=C3=B6ffnen=20(SW=20v45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/js/app.js | 3 ++- static/js/sw.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index ff4f1d6..751d2b1 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -4552,7 +4552,8 @@ async function openBook(bookId) { // If this book already has notes/highlights, surface them right away // instead of making the reader hide them behind a manual toggle. - if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen) { + // Not on phones: there the margin covers the text; it stays one tap away (✎). + if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen && !_isMobileReader()) { toggleAnnotationsMargin(); } diff --git a/static/js/sw.js b/static/js/sw.js index 72ac2ca..f99fe3a 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v44'; +const CACHE = 'diora-v45'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', From 09055b98742809b32ed7aeaeef951313f674ce0d Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 27 Sep 2026 19:57:10 +0200 Subject: [PATCH 002/160] Reader: Lesefortschritt nach Textanteil statt Pixeln (SW v46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/js/app.js | 83 ++++++++++++++++++++++++++++++++++++++++++++---- static/js/sw.js | 2 +- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index 751d2b1..64a27d3 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2966,6 +2966,59 @@ function getPositionAnchor(contentEl) { return `${bestIndex}:${innerFraction.toFixed(6)}`; } +// Reading progress as a share of the book's text (characters), from a +// position anchor. Independent of layout — font size, screen width, which +// chapters are rendered (the Android app's e-reader mode lays out one chapter +// at a time) — so every client shows the same percentage for the same place. +// The pixel share (scrollTop / scrollHeight) used before differed between +// layouts and counted margins, headings and images as reading. +let _charIndex = null; // {content, starts[], lens[], total} for the open book + +function _bookCharIndex(contentEl) { + if (_charIndex && _charIndex.content === contentEl) return _charIndex; + const offsets = new Map(); + let pos = 0; + const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + offsets.set(node, pos); + pos += node.nodeValue.length; + } + const blocks = contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR); + const starts = new Array(blocks.length); + const lens = new Array(blocks.length); + for (let i = 0; i < blocks.length; i++) { + const first = document.createTreeWalker(blocks[i], NodeFilter.SHOW_TEXT).nextNode(); + starts[i] = first ? offsets.get(first) : (i ? starts[i - 1] + lens[i - 1] : 0); + lens[i] = blocks[i].textContent.length; + } + _charIndex = {content: contentEl, starts, lens, total: pos || 1}; + return _charIndex; +} + +// Share of the text before the anchored place (0..1), or null. +function _fractionFromAnchor(contentEl, anchor) { + const [block, inner] = _anchorParts(anchor); + if (block < 0) return null; + const idx = _bookCharIndex(contentEl); + if (block >= idx.starts.length) return null; + return Math.max(0, Math.min(1, (idx.starts[block] + inner * idx.lens[block]) / idx.total)); +} + +// Anchor of the place where `fraction` of the text has been read. +function _anchorForFraction(contentEl, fraction) { + const idx = _bookCharIndex(contentEl); + if (!idx.starts.length) return ''; + const target = Math.max(0, Math.min(1, fraction)) * idx.total; + let lo = 0, hi = idx.starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (idx.starts[mid] <= target) lo = mid; else hi = mid - 1; + } + const inner = idx.lens[lo] ? Math.max(0, Math.min(1, (target - idx.starts[lo]) / idx.lens[lo])) : 0; + return `${lo}:${inner.toFixed(6)}`; +} + function restoreFromAnchor(contentEl, anchor) { if (!anchor) return false; const parts = anchor.split(':'); @@ -4464,6 +4517,7 @@ async function openBook(bookId) { try { loadReaderSettings(bookId); + _charIndex = null; const key = await getOrCreateEncKey(); let data_ct, data_iv; const cached = await _getCachedBook(bookId); @@ -4591,7 +4645,7 @@ async function openBook(bookId) { } else { const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value))); this.value = _formatBookPercent(pct / 100); - contentEl.scrollTop = (pct / 100) * contentEl.scrollHeight; + restoreFromAnchor(contentEl, _anchorForFraction(contentEl, pct / 100)); } }); @@ -4660,10 +4714,8 @@ async function openBook(bookId) { } } progressInput.value = currentPage; - } else { - const f = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); - progressInput.value = _formatBookPercent(f); } + // EPUB: shown from the position anchor, see the anchor tracking below. }); // Auto-save progress every 10s and on scroll (debounced 2s) @@ -4684,6 +4736,10 @@ async function openBook(bookId) { clearTimeout(_anchorTrackDebounce); _anchorTrackDebounce = setTimeout(() => { _currentPositionAnchor = getPositionAnchor(contentEl); + const f = _fractionFromAnchor(contentEl, _currentPositionAnchor); + if (progressInput && f !== null && document.activeElement !== progressInput) { + progressInput.value = _formatBookPercent(f); + } }, 150); }, {passive: true}); } @@ -4787,13 +4843,16 @@ async function saveReaderProgress(force = false) { } else { fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); } - fraction = Math.min(1.0, Math.max(0.0, fraction)); let anchor = ''; if (!currentPdfDoc) { anchor = getPositionAnchor(contentEl); _currentPositionAnchor = anchor; + // EPUB: share of the text, the same on every device and layout. + const textFraction = _fractionFromAnchor(contentEl, anchor); + if (textFraction !== null) fraction = textFraction; } + fraction = Math.min(1.0, Math.max(0.0, fraction)); _updateCachedBookProgress(currentBookId, fraction, anchor, force); @@ -6556,7 +6615,19 @@ function _tocTitleForChapterSrc(chapterSrc) { // relocate a highlight/note later. function _percentFromRange(range) { const contentEl = $('reader-content'); - if (!contentEl || !range || !contentEl.scrollHeight) return null; + if (!contentEl || !range) return null; + // Share of the text before the range, like the reading progress. + const block = _blockAncestor(range.startContainer); + if (block && contentEl.contains(block)) { + const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); + const i = blocks.indexOf(block); + if (i >= 0) { + const idx = _bookCharIndex(contentEl); + const at = idx.starts[i] + _charOffsetInBlock(block, range.startContainer, range.startOffset); + return Math.max(0, Math.min(100, (at / idx.total) * 100)); + } + } + if (!contentEl.scrollHeight) return null; const contentRect = contentEl.getBoundingClientRect(); const rangeRect = range.getBoundingClientRect(); if (!rangeRect.width && !rangeRect.height) return null; diff --git a/static/js/sw.js b/static/js/sw.js index f99fe3a..24a7b9c 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v45'; +const CACHE = 'diora-v46'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', From 7ee44622ee2de09e7720ac8f85409be41c8c93f0 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 28 Sep 2026 20:25:59 +0200 Subject: [PATCH 003/160] Reader: Schriftgewicht einstellbar (SW v47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/css/app.css | 2 +- static/js/app.js | 52 ++++++++++++++++++++++++++++++++++++++++++++-- static/js/sw.js | 2 +- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/static/css/app.css b/static/css/app.css index 5b200a9..fd2bcc6 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2130,7 +2130,7 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h) .footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; } /* Highlight marks */ -.reader-no-bold * { font-weight: normal !important; } +.reader-no-bold * { font-weight: inherit !important; } .reader-content img { max-width: 100%; height: auto; display: block; } /* color:inherit overrides the browser's UA-default `mark { color: black }` — without it, highlighted text abruptly flips to black regardless of the diff --git a/static/js/app.js b/static/js/app.js index 64a27d3..cf513db 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3254,7 +3254,7 @@ async function _syncPendingAnnotations() { // Reader settings let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', - fontFamily: 'serif', noBold: false, + fontFamily: 'serif', noBold: false, fontWeight: null, pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }; let readerSettingsPanelOpen = false; let currentPdfDoc = null; @@ -5026,7 +5026,7 @@ function jumpToTocEntry(href) { function loadReaderSettings(bookId) { // Reset to defaults, then apply per-book overrides Object.assign(readerSettings, { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', - fontFamily: 'serif', noBold: false, + fontFamily: 'serif', noBold: false, fontWeight: null, pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }); try { const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}'); @@ -5060,6 +5060,16 @@ function applyReaderSettings(isPdf) { }; contentEl.style.fontFamily = fontMap[readerSettings.fontFamily] || fontMap.serif; contentEl.classList.toggle('reader-no-bold', !!readerSettings.noBold); + // Weight: null keeps the stylesheet's. --reader-weight lets old WebViews + // without variable-font support (which drop weights like 425) pick a + // static cut instead. + contentEl.style.fontWeight = ''; + if (readerSettings.fontWeight) { + contentEl.style.fontWeight = String(readerSettings.fontWeight); + contentEl.style.setProperty('--reader-weight', String(readerSettings.fontWeight)); + } else { + contentEl.style.removeProperty('--reader-weight'); + } if (_currentPositionAnchor && currentBookId) { _suppressScrollJumpDetect(); requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor)); @@ -5083,6 +5093,19 @@ function applyReaderSettings(isPdf) { if (isPdf && markerModeActive) toggleMarkerMode(); } +// Font weight slider: the variable Source Serif 4 takes any weight, the +// system fonts only their fixed cuts (multiples of 100). +function _readerWeightStep() { + return readerSettings.fontFamily === 'serif' ? 25 : 100; +} + +function _readerWeight() { + const step = _readerWeightStep(); + const w = readerSettings.fontWeight + || parseInt(getComputedStyle($('reader-content')).fontWeight, 10) || 400; + return Math.round(w / step) * step; +} + function toggleSettingsPanel() { const overlay = $('reader-overlay'); const contentEl = $('reader-content'); @@ -5108,6 +5131,7 @@ function toggleSettingsPanel() { + @@ -5187,12 +5211,36 @@ function toggleSettingsPanel() { saveReaderSettings(); }); + const weightRange = panel.querySelector('#rs-weight'); + const weightVal = panel.querySelector('#rs-weight-val'); + weightRange.addEventListener('input', () => { + readerSettings.fontWeight = parseInt(weightRange.value, 10); + weightVal.textContent = readerSettings.fontWeight; + applyReaderSettings(false); + saveReaderSettings(); + }); + panel.querySelector('#rs-weight-minus').addEventListener('click', () => { + weightRange.value = Math.max(+weightRange.min, parseInt(weightRange.value, 10) - +weightRange.step); + weightRange.dispatchEvent(new Event('input')); + }); + panel.querySelector('#rs-weight-plus').addEventListener('click', () => { + weightRange.value = Math.min(+weightRange.max, parseInt(weightRange.value, 10) + +weightRange.step); + weightRange.dispatchEvent(new Event('input')); + }); + panel.querySelectorAll('[data-rs-font]').forEach(btn => { btn.addEventListener('click', () => { readerSettings.fontFamily = btn.dataset.rsFont; panel.querySelectorAll('[data-rs-font]').forEach(b => b.classList.toggle('active', b === btn)); + // The system fonts only have cuts at multiples of 100. + weightRange.step = _readerWeightStep(); + if (readerSettings.fontWeight) { + readerSettings.fontWeight = Math.round(readerSettings.fontWeight / weightRange.step) * weightRange.step; + } applyReaderSettings(false); saveReaderSettings(); + weightRange.value = _readerWeight(); + weightVal.textContent = _readerWeight(); }); }); diff --git a/static/js/sw.js b/static/js/sw.js index 24a7b9c..2b72b57 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v46'; +const CACHE = 'diora-v47'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', From 086ce29020022d39c8fe01b8729cf909dee66b30 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 28 Sep 2026 21:19:05 +0200 Subject: [PATCH 004/160] =?UTF-8?q?Reader:=20Buch=20nie=20doppelt=20=C3=B6?= =?UTF-8?q?ffnen;=20ung=C3=BCltige=20Anker=20selbst=20reparieren=20(SW=20v?= =?UTF-8?q?48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/js/app.js | 45 ++++++++++++++++++++++++++++++++++++++++++--- static/js/sw.js | 2 +- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index cf513db..21376a4 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -4505,7 +4505,30 @@ function exitReaderImmersiveMode() { document.removeEventListener('click', _immHandleTap); } +// Serializes openBook: it empties #reader-content, then appends the chapters +// across several awaits. A second call in between (a second tap while a slow +// e-reader is still decrypting) appended the whole book a second time — and +// progress saved from that doubled DOM had block indices beyond the real book, +// which then out-ranked every real position on the server. +let _openBookInFlight = null; +let _repairProgressAnchor = false; // stored anchor didn't fit this book + async function openBook(bookId) { + if (_openBookInFlight) { + if (_openBookInFlight.bookId === bookId) return _openBookInFlight.promise; + await _openBookInFlight.promise.catch(() => {}); + } + const promise = _openBookOnce(bookId); + _openBookInFlight = {bookId, promise}; + try { + return await promise; + } finally { + if (_openBookInFlight && _openBookInFlight.promise === promise) _openBookInFlight = null; + } +} + +async function _openBookOnce(bookId) { + _repairProgressAnchor = false; const overlay = $('reader-overlay'); const contentEl = $('reader-content'); const titleEl = $('reader-title'); @@ -4690,7 +4713,14 @@ async function openBook(bookId) { } await new Promise(r => requestAnimationFrame(r)); if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) { - contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); + restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction)); + } + // An anchor past the end of this book can only come from a broken + // render. The server keeps the furthest block index, so it would block + // every real position from now on: overwrite it with the next save. + const [storedBlock] = _anchorParts(anchor); + if (storedBlock >= contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR).length) { + _repairProgressAnchor = true; } } } catch (e) {} @@ -4844,6 +4874,13 @@ async function saveReaderProgress(force = false) { fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); } + // Chapter wrappers carry unique ids; seeing one twice means the book was + // rendered twice. Positions from that DOM are wrong — don't store them. + if (!currentPdfDoc && contentEl.querySelectorAll('#epub-chapter-0').length > 1) { + console.warn('Book rendered twice, progress not saved'); + return; + } + let anchor = ''; if (!currentPdfDoc) { anchor = getPositionAnchor(contentEl); @@ -4854,9 +4891,11 @@ async function saveReaderProgress(force = false) { } fraction = Math.min(1.0, Math.max(0.0, fraction)); - _updateCachedBookProgress(currentBookId, fraction, anchor, force); + _updateCachedBookProgress(currentBookId, fraction, anchor, force || (_repairProgressAnchor && !!anchor)); - const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...(force && {force: true})}); + const repair = _repairProgressAnchor && !!anchor; + if (repair) _repairProgressAnchor = false; + const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...((force || repair) && {force: true})}); _lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body}; try { diff --git a/static/js/sw.js b/static/js/sw.js index 2b72b57..c36b133 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v47'; +const CACHE = 'diora-v48'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', From 77d662a9cbdb326af24cc445d6c3eff20bcccf39 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Tue, 29 Sep 2026 19:33:18 +0200 Subject: [PATCH 005/160] API-Token: Anfragen mit Bearer-Token brauchen kein CSRF-Token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- accounts/middleware.py | 5 +++++ accounts/tests.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/accounts/middleware.py b/accounts/middleware.py index 3ceb447..addb990 100644 --- a/accounts/middleware.py +++ b/accounts/middleware.py @@ -22,4 +22,9 @@ class ApiTokenAuthMiddleware: api_token = ApiToken.objects.select_related('user').filter(token=token).first() if api_token is not None: request.user = api_token.user + # Browsers never attach this header on their own, so a + # token request can't be a cross-site forgery; native + # clients have no CSRF cookie to send. (CsrfViewMiddleware + # checks this flag in process_view, which runs after us.) + request._dont_enforce_csrf_checks = True return self.get_response(request) diff --git a/accounts/tests.py b/accounts/tests.py index 4d4e05a..638ec51 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -42,6 +42,20 @@ class ApiTokenAuthMiddlewareTests(TestCase): resp = self.client.get('/books/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}') self.assertEqual(resp.status_code, 200) + def test_bearer_token_needs_no_csrf_token(self): + # import_m3u is not csrf_exempt: without a token it must still be protected. + from django.test import Client + client = Client(enforce_csrf_checks=True) + m3u = '#EXTM3U\n#EXTINF:-1,Test FM\nhttps://example.com/stream\n' + resp = client.post('/radio/import/', data=m3u, content_type='text/plain') + self.assertIn(resp.status_code, (302, 401, 403)) + resp = client.post('/radio/import/', data=m3u, content_type='text/plain', + HTTP_AUTHORIZATION=f'Bearer {self.token.token}') + self.assertNotEqual(resp.status_code, 403) + resp = client.post('/radio/import/', data=m3u, content_type='text/plain', + HTTP_AUTHORIZATION='Bearer not-a-real-token') + self.assertIn(resp.status_code, (302, 401, 403)) + def test_regenerate_view_requires_login_and_rotates_token(self): resp = self.client.post('/accounts/api-token/regenerate/') self.assertEqual(resp.status_code, 302) From bca9d6b5e90d9ad8292a6eb694b5ed31d919099d Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Tue, 29 Sep 2026 20:00:42 +0200 Subject: [PATCH 006/160] =?UTF-8?q?Radio:=20http-Streams=20in=20nativen=20?= =?UTF-8?q?H=C3=BCllen=20abspielen=20lassen;=20Play-Session=20nicht=20mehr?= =?UTF-8?q?=20verlieren=20(SW=20v49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/js/app.js | 13 +++++++++---- static/js/sw.js | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index 21376a4..804775c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -157,7 +157,9 @@ function customPrompt(message, defaultValue = '') { function playStation(url, name, stationId) { stopPlayback(false); - if (location.protocol === 'https:' && url.startsWith('http://')) { + // A secure page can't load plain-http streams (mixed content). Native shells + // that play the stream themselves set window.DIORA_PLAYS_HTTP_STREAMS. + if (location.protocol === 'https:' && url.startsWith('http://') && !window.DIORA_PLAYS_HTTP_STREAMS) { window.open(url, '_blank'); return; } @@ -280,15 +282,18 @@ async function startPlaySession(stationName, stationUrl) { } async function stopPlaySession() { - if (!currentPlayId) return; + // Take the id before awaiting: playStation() calls this without waiting and + // starts the next session right away, whose id must not be cleared here. + const playId = currentPlayId; + if (!playId) return; + currentPlayId = null; try { await fetch('/radio/play/stop/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: JSON.stringify({play_id: currentPlayId}) + body: JSON.stringify({play_id: playId}) }); } catch (e) {} - currentPlayId = null; } window.addEventListener('beforeunload', () => { diff --git a/static/js/sw.js b/static/js/sw.js index c36b133..e20bf19 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v48'; +const CACHE = 'diora-v49'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', From 3717640a4e4d9f21701b27e7ae5724e45ab1e579 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Tue, 29 Sep 2026 20:33:34 +0200 Subject: [PATCH 007/160] =?UTF-8?q?Radio:=20Stream-Adresse=20ins=20Suchfel?= =?UTF-8?q?d=20einf=C3=BCgen;=20Favoriten-Stern=20reparieren=20(SW=20v50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- static/js/app.js | 101 ++++++++++++++++++++++++++---------- static/js/sw.js | 2 +- templates/radio/player.html | 2 +- 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index 804775c..3982b5d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -541,10 +541,17 @@ async function doSearch() { const tableEl = $('search-results-table'); const tbody = $('search-results-body'); - statusEl.textContent = 'Searching…'; tableEl.style.display = 'none'; tbody.innerHTML = ''; + // A stream address instead of a name: offer to play or save it directly. + if (/^https?:\/\/\S+$/i.test(query)) { + showStreamAddressResult(query); + return; + } + + statusEl.textContent = 'Searching…'; + try { const url = `https://de1.api.radio-browser.info/json/stations/search?name=${encodeURIComponent(query)}&limit=50&hidebroken=true&order=clickcount&reverse=true`; const res = await fetch(url); @@ -595,6 +602,65 @@ async function doSearch() { } } +// A name to start from for a pasted address: "klassik.mp3" on radio.example.org +// -> "Klassik (radio.example.org)". The query string is left out on purpose — +// it often carries an access token, which shouldn't end up on screen. +function _streamAddressName(url) { + try { + const u = new URL(url); + const file = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || ''); + const base = file.replace(/\.(mp3|aac|aacp|ogg|opus|m3u8?|pls|flac)$/i, '').replace(/[-_]+/g, ' ').trim(); + const nice = base ? base.charAt(0).toUpperCase() + base.slice(1) : ''; + return nice ? `${nice} (${u.host})` : u.host; + } catch (e) { + return url; + } +} + +function showStreamAddressResult(url) { + const statusEl = $('search-status'); + const tableEl = $('search-results-table'); + const tbody = $('search-results-body'); + const name = _streamAddressName(url); + const shown = url.split('?')[0]; + const data = {name, url, bitrate: '', country: '', tags: '', favicon_url: ''}; + + statusEl.textContent = 'Stream address'; + const curated = document.getElementById('curated-lists'); + if (curated) curated.style.display = 'none'; + + // Same five columns as a radio-browser result: name, bitrate, country, tags, actions. + const tr = document.createElement('tr'); + tr.className = 'stream-address-row'; + for (let i = 0; i < 4; i++) tr.appendChild(document.createElement('td')); + tr.children[0].textContent = name; + tr.children[0].title = shown; + tr.children[3].textContent = shown; + tr.children[3].title = shown; + + const actions = document.createElement('td'); + const play = document.createElement('button'); + play.className = 'btn btn-sm btn-play'; + play.innerHTML = '▶ Play'; + play.addEventListener('click', () => searchPlay(url, name, data)); + const save = document.createElement('button'); + save.className = 'btn btn-sm'; + save.innerHTML = '★ Save'; + save.style.marginLeft = '4px'; + save.addEventListener('click', async e => { + e.stopPropagation(); + const chosen = await customPrompt('Name for this station', name); + if (chosen == null) return; + const finalName = chosen.trim() || name; + await saveStation({...data, name: finalName}); + statusEl.textContent = `Saved as "${finalName}".`; + }); + actions.append(play, save); + tr.appendChild(actions); + tbody.appendChild(tr); + tableEl.style.display = ''; +} + function searchPlay(url, name, stationData) { // Store station data on the window so saveCurrentStation() can use it window._pendingStationData = stationData; @@ -729,7 +795,11 @@ function addSavedRow(station) { // Remove station // --------------------------------------------------------------------------- -async function toggleFav(pk) { +// The only toggleFav: a second definition further down (expecting the button +// as an argument) used to shadow this one, so the server-rendered rows — which +// pass no button — toggled the favourite on the server without the star ever +// changing, and a second click silently took it back. +async function toggleFav(pk, btnEl) { try { const res = await fetch(`/radio/favorite/${pk}/`, { method: 'POST', @@ -739,8 +809,8 @@ async function toggleFav(pk) { const data = await res.json(); // Flip the star button state - const btn = document.querySelector(`#saved-row-${pk} .fav-btn`); - if (btn) btn.classList.toggle('active', data.is_favorite); + const btn = btnEl || document.querySelector(`#saved-row-${pk} .fav-btn`); + if (btn) btn.classList.toggle('active', !!data.is_favorite); // Re-sort rows in the DOM: favorites first, then alphabetically const tbody = $('saved-tbody'); @@ -781,29 +851,6 @@ async function removeStation(pk) { } } -// --------------------------------------------------------------------------- -// Toggle favorite -// --------------------------------------------------------------------------- - -async function toggleFav(pk, btnEl) { - try { - const res = await fetch(`/radio/favorite/${pk}/`, { - method: 'POST', - headers: { 'X-CSRFToken': getCsrfToken() }, - }); - if (res.ok) { - const data = await res.json(); - if (data.is_favorite) { - btnEl.classList.add('active'); - } else { - btnEl.classList.remove('active'); - } - } - } catch (err) { - console.error('toggleFav error:', err); - } -} - // --------------------------------------------------------------------------- // Focus Timer // --------------------------------------------------------------------------- diff --git a/static/js/sw.js b/static/js/sw.js index e20bf19..0068104 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v49'; +const CACHE = 'diora-v50'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', diff --git a/templates/radio/player.html b/templates/radio/player.html index 9ac3540..b8dbc5c 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -81,7 +81,7 @@ -
focus 25:00 @@ -228,82 +211,8 @@
{% endblock %} diff --git a/templates/accounts/register.html b/templates/accounts/register.html index c72c298..a81773e 100644 --- a/templates/accounts/register.html +++ b/templates/accounts/register.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}Register — diora{% endblock %} +{% block title %}Registrieren — diora{% endblock %} {% block content %}
-

Create Account

+

Konto anlegen

{% csrf_token %} {% for field in form %} @@ -29,10 +29,10 @@ {% endfor %}
{% endif %} - +

- Already have an account? Login + Schon ein Konto? Anmelden

{% endblock %} diff --git a/templates/accounts/settings.html b/templates/accounts/settings.html index 69e373a..702cbcf 100644 --- a/templates/accounts/settings.html +++ b/templates/accounts/settings.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}Settings — diora{% endblock %} +{% block title %}Einstellungen — diora{% endblock %} {% block content %}
-

Settings

+

Einstellungen

{% if lastfm_error %}
{{ lastfm_error }}
@@ -15,7 +15,7 @@ {% if has_lastfm %}

- Connected as {{ profile.lastfm_username }} + Verbunden als {{ profile.lastfm_username }}

{% csrf_token %} @@ -23,46 +23,46 @@ - Scrobble tracks to Last.fm + Titel an Last.fm scrobbeln
{% csrf_token %} - +
{% else %}

- Connect your Last.fm account to automatically scrobble the tracks you listen to. + Verbinde dein Last.fm-Konto, damit gehörte Titel automatisch gescrobbelt werden.

- Connect Last.fm + Last.fm verbinden {% endif %}
-

Background

+

Hintergrund

{% if request.user.profile.background_encrypted %} -

Encrypted background is set. (Preview not available — decrypted in browser on main page.)

+

Ein verschlüsselter Hintergrund ist gesetzt. (Keine Vorschau – er wird erst auf der Hauptseite im Browser entschlüsselt.)

{% csrf_token %} - +
-

Upload a new image to replace it:

+

Neues Bild hochladen, um ihn zu ersetzen:

{% elif request.user.profile.background_image_data %}

- Your background + Dein Hintergrund

{% csrf_token %} - +
-

Upload a new image to replace it (will be stored encrypted):

+

Neues Bild hochladen, um ihn zu ersetzen (wird verschlüsselt gespeichert):

{% else %} -

Upload a custom background image (JPG, PNG or WebP, max 5 MB). Stored end-to-end encrypted.

+

Eigenes Hintergrundbild hochladen (JPG, PNG oder WebP, höchstens 5 MB). Es wird Ende-zu-Ende-verschlüsselt gespeichert.

{% endif %}
- +
@@ -70,11 +70,11 @@
-

Account

-

Logged in as {{ request.user.username }}

+

Konto

+

Angemeldet als {{ request.user.username }}

- Change password + Passwort ändern
{% csrf_token %} {% for field in password_form %} @@ -90,13 +90,13 @@
{{ password_form.non_field_errors|join:", " }}
{% endif %}
-
+
{% csrf_token %} - +
@@ -249,20 +249,20 @@ async function uploadBackground(input) { const status = document.getElementById('bg-upload-status'); const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { - status.textContent = 'Only JPEG, PNG, or WebP images are allowed.'; + status.textContent = 'Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.'; return; } if (file.size > 5 * 1024 * 1024) { - status.textContent = 'Image must be 5 MB or smaller.'; + status.textContent = 'Das Bild darf höchstens 5 MB groß sein.'; return; } - status.textContent = 'Encrypting…'; + status.textContent = 'Verschlüssele …'; try { const key = await _getOrCreateEncKey(); const buf = await file.arrayBuffer(); const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, buf); - status.textContent = 'Uploading…'; + status.textContent = 'Lade hoch …'; const res = await fetch('/accounts/background/upload/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, @@ -270,9 +270,10 @@ async function uploadBackground(input) { }); const data = await res.json(); if (data.ok) { location.reload(); } - else { status.textContent = data.error || 'Upload failed'; } + else { status.textContent = data.error === 'only jpeg, png, or webp allowed' ? 'Nur JPEG, PNG oder WebP erlaubt.' + : data.error === 'file too large (max 5 MB)' ? 'Datei zu groß (höchstens 5 MB).' : (data.error || 'Hochladen fehlgeschlagen'); } } catch (e) { - status.textContent = 'Upload failed: ' + e.message; + status.textContent = 'Hochladen fehlgeschlagen: ' + e.message; } input.value = ''; } diff --git a/templates/base.html b/templates/base.html index 509e3c3..64f4baa 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,5 +1,5 @@ - + @@ -7,7 +7,7 @@ - + {% if user.is_authenticated %}{% endif %} @@ -22,17 +22,17 @@ diff --git a/templates/radio/player.html b/templates/radio/player.html index e80683d..9d57b91 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -1,46 +1,46 @@ {% extends "base.html" %} -{% block title %}diora — radio player{% endblock %} +{% block title %}diora — Radio{% endblock %} {% block content %}
- — no station — + — kein Sender —
- + - + - +
- focus + Fokus 25:00 - - + + - +
@@ -48,24 +48,24 @@
- + - +
- - - + + +
@@ -215,8 +215,8 @@

Podcasts werden geladen …

{% else %}

- Log in or register - to subscribe to podcasts. + Anmelden oder registrieren, + um Podcasts zu abonnieren.

{% endif %}
@@ -226,7 +226,7 @@ {% if user.is_authenticated %}
- Drop .epub or .pdf here or + .epub oder .pdf hierher ziehen oder
@@ -248,8 +248,8 @@
{% else %}

- Log in or register - to use the encrypted ebook reader. + Anmelden oder registrieren, + um den verschlüsselten E-Book-Reader zu nutzen.

{% endif %} @@ -263,30 +263,30 @@ - - + + - - - - + + + + - +
diff --git a/templates/radio/stream_player.html b/templates/radio/stream_player.html index 97b1f04..a3e7177 100644 --- a/templates/radio/stream_player.html +++ b/templates/radio/stream_player.html @@ -1,5 +1,5 @@ - + @@ -80,15 +80,15 @@
- +
- vol + Lautst.
- + From 64e9d706477cc042e9d3cfc8ba8b7f1c1d0ede13 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sat, 3 Oct 2026 14:40:39 +0200 Subject: [PATCH 049/160] =?UTF-8?q?Podcasts:=20mehrere=20Folgen=20nacheina?= =?UTF-8?q?nder=20wegwischen,=20w=C3=A4hrend=20=E2=80=9ER=C3=BCckg=C3=A4ng?= =?UTF-8?q?ig=E2=80=9C=20steht;=20sw=20v72?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- static/js/sw.js | 2 +- static/podcasts/native-podcast.js | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/static/js/sw.js b/static/js/sw.js index bb22f5b..8c93abc 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v71'; +const CACHE = 'diora-v72'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index 27ca360..48023e6 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android abda34a 2026-10-03: web-native/static/native-podcast.js +// Copied from diora-android 370e653 2026-10-03: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. @@ -71,6 +71,8 @@ let preview = null; // {id, url, data | choices | error} while a feed is looked at before subscribing let previewSeq = 0; let refreshing = false; + let swipingSince = 0; // a row is being swiped (since then): render waits (rowGestures) + let renderAfterSwipe = false; let selected = null; // Set of episode ids while choosing several (queue or inbox) let locked = false; // queue locked: no dragging try { locked = localStorage.getItem('nui_pod_locked') === '1'; } catch (e) {} @@ -982,6 +984,9 @@ } function render() { + // A finger is swiping a row: redrawing now would pull the row away under it (and end + // the swipe). It redraws when the swipe ends — unless that never came (a lost pointer). + if (swipingSince && Date.now() - swipingSince < 8000) { renderAfterSwipe = true; return; } episodeScreenParts(); if (!root) return; const scroll = scrollTop(); @@ -1174,6 +1179,7 @@ function rowGestures(row, onLong, onSwipe, list) { let start = null; + let holding = false; // this row counts in swipingSince let timer = 0; let mode = ''; let dx = 0; @@ -1217,6 +1223,11 @@ delete other.dataset.act; }; const finish = done => { + if (holding) { + holding = false; + swipingSince = 0; + if (renderAfterSwipe) { renderAfterSwipe = false; setTimeout(render, done ? 200 : 220); } + } row.classList.remove('nui-pod-swiping'); const gone = bg; bg = null; @@ -1257,7 +1268,9 @@ if (Math.abs(mx) > 8 || Math.abs(my) > 8) { stopTimer(); start.moved = true; } if (onSwipe && start.touch && Math.abs(mx) > 12 && Math.abs(mx) > Math.abs(my) * 1.5) { mode = 'swipe'; - commitSwipe(); + // The swipe before stays undoable (its bar too) until this one is done (offerUndo). + holding = true; + swipingSince = Date.now(); try { row.setPointerCapture(ev.pointerId); } catch (e) {} bg = el('div', 'nui-pod-swipebg'); bg.append(el('span', 'nui-pod-swipe-l'), el('span', 'nui-pod-swipe-r')); From b7a81f3e26c83282220ce41f6a609015e58c4fdc Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sat, 3 Oct 2026 23:08:57 +0200 Subject: [PATCH 050/160] =?UTF-8?q?Neuer=20Tab=20=E2=80=9EAmbient=E2=80=9C?= =?UTF-8?q?:=20Drift,=20ein=20generativer=20Ambient-Synthesizer;=20sw=20v7?= =?UTF-8?q?3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - static/ambient/index.html: Drift (aus der Session "Ambient-Synthesizer-Webapp", eine Datei, Web Audio, offline), eingebettet per iframe (?embed=1&bg=#000) - app.js: Tab 'ambient', iframe lädt beim ersten Öffnen und füllt den Platz bis zur Leiste; Radio/Podcast und Ambient wechseln sich ab (postMessage {type:'ambient', cmd|state}) - sw.js: Ambient im Offline-Cache (ignoreSearch für die Shell-Dateien) Co-Authored-By: Claude Opus 5.5 --- static/ambient/index.html | 778 ++++++++++++++++++++++++++++++ static/css/app.css | 9 + static/js/app.js | 51 +- static/js/sw.js | 6 +- static/podcasts/native-podcast.js | 9 +- templates/radio/player.html | 9 + 6 files changed, 858 insertions(+), 4 deletions(-) create mode 100644 static/ambient/index.html diff --git a/static/ambient/index.html b/static/ambient/index.html new file mode 100644 index 0000000..fd541e0 --- /dev/null +++ b/static/ambient/index.html @@ -0,0 +1,778 @@ + + + + + +Drift – Ambient Synth + + + + +
+
+

Drift

+
Generativer Ambient-Synthesizer · alles wird live im Browser erzeugt
+
+ +
+ – + Noch still. +
+
+ +
+ + + + + + +
+ + + + + +
+
+ + + + diff --git a/static/css/app.css b/static/css/app.css index 050ed16..e6d8b7b 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -389,6 +389,15 @@ a:hover { min-height: 200px; } +/* Ambient: the synthesizer's frame fills the room down to the player bar (app.js _ambientSize). */ +.ambient-frame { + display: block; + width: 100%; + height: 70vh; + border: 0; + background: #000; +} + .sub-tabs { border-bottom-color: transparent; margin-bottom: 1rem; diff --git a/static/js/app.js b/static/js/app.js index 6016b1b..6e6a0cd 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -158,6 +158,7 @@ function customPrompt(message, defaultValue = '') { function playStation(url, name, stationId) { stopPlayback(false); + if (window.__dioraAmbientStop) window.__dioraAmbientStop(); // A secure page can't load plain-http streams (mixed content). Native shells // that play the stream themselves set window.DIORA_PLAYS_HTTP_STREAMS. @@ -1177,7 +1178,7 @@ async function editNotes(pk, current) { // Tabs // --------------------------------------------------------------------------- -const TOP_TABS = ['radio', 'focus', 'podcasts', 'books']; +const TOP_TABS = ['radio', 'focus', 'podcasts', 'books', 'ambient']; const RADIO_SUB_TABS = ['search', 'saved', 'history']; // --------------------------------------------------------------------------- @@ -1234,8 +1235,56 @@ function showTab(name) { _syncHash(); if (name === 'books') loadBookList(); + if (name === 'ambient') _ambientLoad(); } +// --------------------------------------------------------------------------- +// Ambient: "Drift" (static/ambient/), a generative synthesizer in a frame of its +// own. It and the radio / podcast player take turns — one starting stops the +// other — by postMessage: {type: 'ambient', cmd: 'start'|'stop'} to the frame, +// {type: 'ambient', state: 'playing'|'stopped'} from it. +// --------------------------------------------------------------------------- +let _ambientPlaying = false; + +function _ambientFrame() { + return $('ambient-frame'); +} + +function _ambientLoad() { + const f = _ambientFrame(); + if (!f) return; + if (!f.getAttribute('src')) f.src = f.dataset.src; + requestAnimationFrame(_ambientSize); +} + +// The frame fills the room between the tabs and the player bar at the bottom. +function _ambientSize() { + const f = _ambientFrame(); + if (!f || _currentTopTab !== 'ambient') return; + const root = getComputedStyle(document.documentElement); + const bar = parseFloat(root.getPropertyValue('--nui-dock-h')) || parseFloat(root.getPropertyValue('--dpw-bar-h')) || 0; + const top = f.getBoundingClientRect().top + window.scrollY; + f.style.height = Math.max(320, Math.floor(window.innerHeight - top - bar)) + 'px'; +} +window.addEventListener('resize', () => requestAnimationFrame(_ambientSize)); + +// The radio or a podcast starts: Ambient stops. +window.__dioraAmbientStop = function () { + const f = _ambientFrame(); + if (_ambientPlaying && f && f.contentWindow) f.contentWindow.postMessage({type: 'ambient', cmd: 'stop'}, '*'); +}; + +window.addEventListener('message', e => { + const f = _ambientFrame(); + const d = e.data; + if (!f || e.source !== f.contentWindow || !d || d.type !== 'ambient' || !d.state) return; + _ambientPlaying = d.state === 'playing'; + if (!_ambientPlaying) return; + // Ambient starts: the radio and a podcast here stop. + if (typeof isPlaying !== 'undefined' && isPlaying) stopPlayback(false); + if (window.__dioraPausePlayer) window.__dioraPausePlayer(); +}); + function showRadioTab(name) { RADIO_SUB_TABS.forEach(p => { const panel = $(`tab-${p}`); diff --git a/static/js/sw.js b/static/js/sw.js index 8c93abc..0f6ab15 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v72'; +const CACHE = 'diora-v73'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', @@ -11,6 +11,7 @@ const SHELL = [ '/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css', '/static/manifest.json', + '/static/ambient/index.html', '/static/fonts/SourceSerif4-normal-latin.woff2', '/static/fonts/SourceSerif4-normal-latin-ext.woff2', '/static/fonts/SourceSerif4-italic-latin.woff2', @@ -77,7 +78,8 @@ self.addEventListener('fetch', function (event) { const isShell = SHELL.some(function (s) { return url.pathname === s; }); if (isShell) { event.respondWith( - caches.match(event.request).then(function (cached) { + // ignoreSearch: the Ambient frame loads with ?embed=1&bg=… + caches.match(event.request, {ignoreSearch: true}).then(function (cached) { return cached || fetch(event.request); }) ); diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index 48023e6..0ad47fc 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 370e653 2026-10-03: web-native/static/native-podcast.js +// Copied from diora-android d323614 2026-10-03: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. @@ -4294,6 +4294,8 @@ window.__dioraPlayer = function (p) { const before = player; player = p || {kind: null}; + // Something starts playing here (an episode, the radio): Ambient stops (app.js). + if (player.playing && !before.playing && !player.remote && window.__dioraAmbientStop) window.__dioraAmbientStop(); renderMini(); renderPlayer(); // The current episode changed: the queue view marks it. @@ -4302,6 +4304,11 @@ if (epScreen && (before.episodeId !== player.episodeId || !!before.playing !== !!player.playing)) episodeScreenParts(); }; + // Ambient starts (app.js): an episode playing here pauses. + window.__dioraPausePlayer = function () { + if (player.kind === 'podcast' && player.playing && !player.remote && N.podcastToggle) N.podcastToggle(); + }; + // The app loads this in ; the browser may load it after the page is parsed. function start(fn) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); diff --git a/templates/radio/player.html b/templates/radio/player.html index 9d57b91..78ee240 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -51,6 +51,7 @@ + @@ -254,6 +255,14 @@ {% endif %} + +{% comment %}"Drift", a generative ambient synthesizer (static/ambient/), in a frame of its own: its + script and styles stay apart from the page's. Loaded the first time the tab opens.{% endcomment %} + + @@ -261,6 +262,10 @@ {% comment %}Filled by static/js/home.js: the book in reading, the next episode, stations, Ambient.{% endcomment %} + +{% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %} + + {% comment %}"Drift", a generative ambient synthesizer (static/ambient/), in a frame of its own: its script and styles stay apart from the page's. Loaded the first time the tab opens.{% endcomment %} @@ -345,4 +350,5 @@ + {% endblock %} From 1a17c75c1be8ee05f7776f0d7b43a699068c8509 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 4 Oct 2026 20:22:56 +0200 Subject: [PATCH 069/160] =?UTF-8?q?Home:=20Karte=20=E2=80=9ENachrichten?= =?UTF-8?q?=E2=80=9C=20mit=20den=20Top-Themen=20seit=20dem=20letzten=20Mal?= =?UTF-8?q?,=20neue=20Meldungen=20und=20Blogartikel;=20sw=20v90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- static/js/home.js | 73 ++++++++++++++++++++++++++++++++++++++++++++--- static/js/sw.js | 2 +- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/static/js/home.js b/static/js/home.js index a689d20..e50cf7f 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -1,11 +1,11 @@ /** * diora Home: what you were in the middle of, one click away — the book you're - * reading, the next episode, your stations, Ambient. + * reading, the next episode, the news, your stations, Ambient. * * Built only from what the page already has: the book list (app.js), the * podcast host (window.DioraPodcastHost in the browser and diora desktop, * window.DioraNative in the Android app), the saved stations in the radio tab - * and Drift (app.js __dioraAmbient*). Renders into #tab-home; the same file runs + * Drift (app.js __dioraAmbient*) and the feed reader (feeds.js __dioraFeeds*). Renders into #tab-home; the same file runs * in the browser, diora desktop and the Android app (copied with static/js/). * Written without ?. and ?? so old WebViews can still parse it. */ @@ -125,6 +125,56 @@ button + ''; } + // ---- Nachrichten -------------------------------------------------------------- + + // The briefing comes from the server, so it's fetched now and then, not every render. + let feeds = null; + let feedsAt = 0; + let feedsBusy = false; + + function refreshFeeds(maxAgeMs) { + if (typeof window.__dioraFeedsSummary !== 'function' || feedsBusy || !online()) return; + if (Date.now() - feedsAt < maxAgeMs) return; + feedsBusy = true; + window.__dioraFeedsSummary().then(r => { feeds = r; }, () => {}).then(() => { + feedsAt = Date.now(); + feedsBusy = false; + render(); + }); + } + + function feedsSection() { + if (!document.getElementById('tab-feeds') || !feeds || !feeds.state) return ''; + const s = feeds.state; + const sources = s.sources || []; + if (!sources.length) return ''; + const hasNews = sources.some(x => x.kind === 'news'); + const top = ((feeds.briefing && feeds.briefing.top) || []).slice(0, 4); + let body = ''; + if (top.length) { + body = '
' + top.map(x => { + const lead = x.lead || {}; + return ''; + }).join('') + '
'; + } else if (hasNews) { + body = '
' + (s.news_new ? 'Keine Top-Themen seit dem letzten Mal.' : 'Nichts Neues seit dem letzten Mal.') + '
'; + } + const chips = (s.news_new ? '' : '') + + (s.blogs_unread ? '' : ''); + if (!body && !chips) return ''; + return section(hasNews ? 'Nachrichten' : 'Blogs', body + (chips ? '
' + chips + '
' : ''), 'feeds'); + } + + function openFeeds(view) { + showTab('feeds'); + const b = view && document.querySelector('#tab-feeds [data-view="' + view + '"]:not(.on)'); + if (b) b.click(); + } + // ---- Radio ------------------------------------------------------------------ function stationRows() { @@ -205,7 +255,8 @@ tab.appendChild(host); } let html = '
diora
'; - for (const part of [booksSection, podcastSection, radioSection, ambientSection]) { + refreshFeeds(3 * 60000); + for (const part of [booksSection, podcastSection, feedsSection, radioSection, ambientSection]) { try { html += part(); } catch (e) { console.warn('home', e); } } if (html !== lastHtml) { @@ -230,6 +281,12 @@ const h = podHost(); if (act === 'tab') showTab(el.dataset.tab); else if (act === 'book') openBook(Number(el.dataset.id)); + else if (act === 'story') { + const id = Number(el.dataset.id); + const story = feeds && feeds.briefing && (feeds.briefing.top || []).find(x => x.id === id); + if (story && window.__dioraFeedsOpenStory) { window.__dioraFeedsOpenStory(story); feedsAt = 0; } + else openFeeds('news'); + } else if (act === 'feeds') { openFeeds(el.dataset.view); feedsAt = 0; } else if (act === 'station') playStationFromHome(el.dataset.url, el.dataset.name); else if (act === 'pod-queue' && h) { const q = (parse(h.podcastState(), {}).queue || [])[0]; @@ -250,7 +307,10 @@ }); // Fresh whenever it shows, and while it shows (players move on, the book list arrives). - window.__dioraHomeShow = render; + window.__dioraHomeShow = function () { + refreshFeeds(30000); + render(); + }; if (typeof window.renderBookList === 'function') { const renderBookList = window.renderBookList; window.renderBookList = function () { @@ -292,6 +352,11 @@ '#tab-home .home-row { display: flex; justify-content: space-between; gap: 12px; width: 100%; padding: 10px 14px; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 14px; text-align: left; }', '#tab-home .home-row-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }', '#tab-home .home-row-side { flex: none; color: var(--nui-muted, #9a9a9a); font-size: 12px; }', + '#tab-home .home-news { flex-direction: column; align-items: stretch; gap: 0; padding: 4px 0; }', + '#tab-home .home-story { align-items: baseline; }', + '#tab-home .home-news .home-story:last-child { border-bottom: none; }', + '#tab-home .home-story-title { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-height: 1.35; }', + '#tab-home .home-story .home-row-side { max-width: 35%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }', '#tab-home .home-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }', '#tab-home .home-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }', '#tab-home .home-stations { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }', diff --git a/static/js/sw.js b/static/js/sw.js index a35056d..98827c7 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v89'; +const CACHE = 'diora-v90'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', From c8e03f343e5e12eec554584cc92e2828bcaf1414 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 4 Oct 2026 20:54:29 +0200 Subject: [PATCH 070/160] =?UTF-8?q?Feeds:=20Ordner=20f=C3=BCr=20Blogs,=20B?= =?UTF-8?q?logs=20ohne=20Datum=20in=20Feed-Reihenfolge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Die Kategorie eines Blogs ist sein Ordner: Ordner-Chips mit Ungelesen-Zahl in der Blog-Ansicht, /feeds/blogs/?folder=, Ordner im OPML-Export und -Import - Neue Feeds ohne (brauchbare) Daten: die Reihenfolge des Feeds gilt als Zeit, nur die ersten 3 ungelesen; ein neuer Blog bringt 7 statt 14 Tage ungelesen - sw v91 Co-Authored-By: Claude Opus 5.5 --- feeds/fetch.py | 8 +++++--- feeds/refresh.py | 22 +++++++++++++------- feeds/tests.py | 23 +++++++++++++++++++-- feeds/views.py | 8 ++++++-- static/js/feeds.js | 51 +++++++++++++++++++++++++++++++++------------- static/js/sw.js | 2 +- 6 files changed, 85 insertions(+), 29 deletions(-) diff --git a/feeds/fetch.py b/feeds/fetch.py index 4b23c75..6963692 100644 --- a/feeds/fetch.py +++ b/feeds/fetch.py @@ -68,7 +68,7 @@ def _short(text, n=SUMMARY_CHARS): def _when(entry): - """The entry's time (published, else updated), never in the future.""" + """The entry's time (published, else updated), never in the future, or None.""" now = timezone.now() for key in ('published_parsed', 'updated_parsed', 'created_parsed'): t = entry.get(key) @@ -78,7 +78,7 @@ def _when(entry): except (OverflowError, ValueError, TypeError): continue return min(when, now) - return now + return None class _FirstImage(HTMLParser): @@ -131,12 +131,14 @@ def parse_entry(entry, feed_url): text = plain(content) summary = plain(summary_html) or text title = plain(html.unescape(entry.get('title') or '')) or _short(text, 80) or 'Ohne Titel' + when = _when(entry) return { 'guid_hash': guid_hash(entry), 'url': link[:2000], 'title': title[:500], 'author': plain(entry.get('author') or '')[:300], - 'published': _when(entry), + 'published': when or timezone.now(), + 'dated': when is not None, # not an Item field: refresh._apply uses and drops it 'summary': _short(summary), 'content': content, 'image_url': _image(entry, content_html, base)[:2000], diff --git a/feeds/refresh.py b/feeds/refresh.py index d82796f..ee2cf48 100644 --- a/feeds/refresh.py +++ b/feeds/refresh.py @@ -22,7 +22,8 @@ FEED_WORKERS = 12 ARTICLE_WORKERS = 8 ARTICLES_PER_RUN = 80 # articles fetched per refresh at most; the rest when opened BLOG_KEEP = 1000 # items kept per blog -BLOG_FIRST_UNREAD_DAYS = 14 # a new blog's older articles start out read +BLOG_FIRST_UNREAD_DAYS = 7 # a new blog's older articles start out read +UNDATED_FIRST_UNREAD = 3 # of a new feed without dates, only the first few GONE_KEEP_DAYS = 30 # expired news rows kept (without text) against re-adding @@ -68,11 +69,16 @@ def _apply(source, result, now, keep_days): entries = [fetch.parse_entry(e, source.url) for e in parsed.entries] # Some feeds (Deutschlandfunk) give every entry the time the feed was made: # then the dates say nothing, and the time an entry first shows up counts. - stamps = Counter(f['published'] for f in entries) - bogus_dates = len(entries) >= 5 and stamps.most_common(1)[0][1] >= 0.8 * len(entries) - for fields in entries: - if bogus_dates: - fields['published'] = now + stamps = Counter(f['published'] for f in entries if f['dated']) + bogus_dates = len(entries) >= 5 and bool(stamps) and stamps.most_common(1)[0][1] >= 0.8 * len(entries) + for n, fields in enumerate(entries): + dated = fields.pop('dated') + undated = bogus_dates or not dated + if undated: + # First time: the feed's order stands for the time (newest first), so the + # list doesn't fill up with "just now"; afterwards, when it showed up. + fields['published'] = now - timedelta(minutes=n) if first else now + fields['undated'] = first and n >= UNDATED_FIRST_UNREAD if fields['guid_hash'] in known: continue known.add(fields['guid_hash']) @@ -80,8 +86,10 @@ def _apply(source, result, now, keep_days): continue if oldest_kept and fields['published'] < oldest_kept: continue + old_undated = fields.pop('undated', False) item = Item(source=source, user=source.user, kind=source.kind, **fields) - if source.kind == Source.BLOG and first and fields['published'] < now - timedelta(days=BLOG_FIRST_UNREAD_DAYS): + if source.kind == Source.BLOG and first and ( + old_undated or fields['published'] < now - timedelta(days=BLOG_FIRST_UNREAD_DAYS)): item.read_at = now new.append(item) if new: diff --git a/feeds/tests.py b/feeds/tests.py index 3f0dbf8..e482caf 100644 --- a/feeds/tests.py +++ b/feeds/tests.py @@ -142,6 +142,16 @@ class ApiTest(TestCase): self.post(f'/feeds/items/{item.id}/star/', {'starred': True}) self.assertEqual(len(self.client.get('/feeds/starred/').json()['items']), 1) + def test_blog_folders(self): + with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss([{'guid': 'x', 'title': 'Essay', 'text': LONG}]))): + self.post('/feeds/sources/add/', {'url': 'https://a.example/feed', 'kind': 'blog', 'category': 'Philosophie'}) + with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss([{'guid': 'y', 'title': 'Code', 'text': LONG}]))): + self.post('/feeds/sources/add/', {'url': 'https://b.example/feed', 'kind': 'blog', 'category': ''}) + self.assertEqual(self.client.get('/feeds/state/').json()['folders'], ['Philosophie']) + self.assertEqual([i['title'] for i in self.client.get('/feeds/blogs/?folder=Philosophie').json()['items']], ['Essay']) + self.assertEqual([i['title'] for i in self.client.get('/feeds/blogs/?folder=').json()['items']], ['Code']) + self.assertIn('text="Blogs – Philosophie"', self.client.get('/feeds/export.opml').content.decode()) + def test_old_news_expires_and_is_not_taken_in_again(self): old = (timezone.now() - timedelta(days=3)).strftime('%a, %d %b %Y %H:%M:%S +0000') feed = rss([{'guid': 'new', 'title': 'Heute passiert', 'text': LONG}, @@ -161,7 +171,16 @@ class ApiTest(TestCase): stamp = (timezone.now() - timedelta(hours=5)).strftime('%a, %d %b %Y %H:%M:%S +0000') self.add('news', rss([{'guid': f'g{n}', 'title': f'Meldung {n}', 'text': LONG, 'date': stamp} for n in range(6)])) for i in Item.objects.filter(user=self.u): - self.assertLess((timezone.now() - i.published).total_seconds(), 60) + self.assertLess((timezone.now() - i.published).total_seconds(), 600) # first fetch: feed order, minutes apart + + def test_new_blog_without_dates_keeps_order_and_few_unread(self): + feed = (b'Undatiert' + + b''.join(b'u%dPost %dx' % (n, n) + for n in range(6)) + b'') + self.add('blog', feed) + items = list(Item.objects.filter(user=self.u).order_by('-published')) + self.assertEqual([i.title for i in items], [f'Post {n}' for n in range(6)]) + self.assertEqual(sum(1 for i in items if i.read_at is None), 3) def test_switching_kind_moves_items(self): src = self.add('news', rss([{'guid': 'a', 'title': 'Etwas', 'text': LONG}])) @@ -185,7 +204,7 @@ class ApiTest(TestCase): self.add('blog', rss([]), url='https://blog.example/feed') r = self.client.get('/feeds/export.opml') self.assertIn(b'xmlUrl="https://blog.example/feed"', r.content) - self.assertIn(b'text="Blogs"', r.content) + self.assertIn('text="Blogs – Politik"', r.content.decode()) def test_settings(self): self.post('/feeds/settings/', {'news_keep_days': 5}) diff --git a/feeds/views.py b/feeds/views.py index 5bb5471..31fccf3 100644 --- a/feeds/views.py +++ b/feeds/views.py @@ -12,7 +12,7 @@ News: GET /feeds/briefing/ what's new since the briefing was POST /feeds/briefing/seen/ seen: everything up to now counts as read GET /feeds/news/?before=&category= stories, newest first GET /feeds/stories// one story with all its items -Blogs: GET /feeds/blogs/?source=&unread=1&before= +Blogs: GET /feeds/blogs/?folder=&source=&unread=1&before= (a blog's category is its folder) Items: GET /feeds/items// the article (fetches the page if the feed had a teaser) POST /feeds/items/read/ {ids, read} POST /feeds/items//star/ {starred} @@ -121,6 +121,7 @@ def state(request): return JsonResponse({ 'sources': [_source_data(x, unread.get(x.id, 0)) for x in sources], 'categories': sorted({x.category for x in sources if x.kind == Source.NEWS and x.category}), + 'folders': sorted({x.category for x in sources if x.kind == Source.BLOG and x.category}), 'news_new': new_news, 'blogs_unread': sum(n for sid, n in unread.items() if any(x.id == sid and x.kind == Source.BLOG for x in sources)), 'settings': {'news_keep_days': s.news_keep_days, 'news_seen_at': _iso(s.news_seen_at)}, @@ -274,7 +275,8 @@ def export_opml(request): return err groups = {} for s in Source.objects.filter(user=request.user).order_by('kind', 'category', 'title'): - label = ('Nachrichten' + (f' – {s.category}' if s.category else '')) if s.kind == Source.NEWS else 'Blogs' + kind = 'Nachrichten' if s.kind == Source.NEWS else 'Blogs' + label = kind + (f' – {s.category}' if s.category else '') groups.setdefault(label, []).append(s) lines = ['', '', 'diora Feeds', ''] @@ -378,6 +380,8 @@ def blogs(request): qs = Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False).select_related('source') if request.GET.get('source', '').isdigit(): qs = qs.filter(source_id=int(request.GET['source'])) + elif 'folder' in request.GET: + qs = qs.filter(source__category=request.GET['folder']) if request.GET.get('unread') == '1': qs = qs.filter(read_at=None) before = parse_datetime(request.GET.get('before') or '') diff --git a/static/js/feeds.js b/static/js/feeds.js index c4d71ad..2d85db2 100644 --- a/static/js/feeds.js +++ b/static/js/feeds.js @@ -23,6 +23,7 @@ category: '', items: [], itemsMore: false, blogSource: '', blogUnread: true, + blogFolder: null, // null = all blogs, '' = blogs without a folder, else a folder name found: null, // discover results opml: null, // parsed OPML import editing: null, // source id being edited @@ -96,6 +97,7 @@ async function loadBlogs(more) { const q = []; if (st.blogSource) q.push('source=' + st.blogSource); + else if (st.blogFolder !== null) q.push('folder=' + encodeURIComponent(st.blogFolder)); if (st.blogUnread) q.push('unread=1'); if (more && st.items.length) q.push('before=' + encodeURIComponent(st.items[st.items.length - 1].published)); const data = await api('/feeds/blogs/' + (q.length ? '?' + q.join('&') : '')); @@ -227,9 +229,23 @@ return '

Noch keine Blogs abonniert.

' + '
'; } - let h = '
' + - '' + + shown.map(b => '').join('') + '' + '' + (st.items.some(i => !i.read) ? '' : '') + @@ -252,8 +268,8 @@ } function categoryInput(value) { - const cats = (st.state && st.state.categories) || []; - return '' + + const cats = ((st.state && st.state.categories) || []).concat((st.state && st.state.folders) || []); + return '' + '' + cats.map(c => ''; } @@ -270,7 +286,7 @@ (f.samples.length ? '
    ' + f.samples.slice(0, 3).map(t => '
  • ' + esc(t) + '
  • ').join('') + '
' : '') + (f.subscribed ? '

Schon abonniert.

' : '
' + - '
' + + '
' + '') + '').join(''); } @@ -283,12 +299,12 @@ list.map(x => x.id === st.editing ? editForm(x) : '').join('') + ''; h += group('Nachrichten', news.sort((a, b) => (a.category || '').localeCompare(b.category || '') || a.title.localeCompare(b.title))); - h += group('Blogs', blogs); + h += group('Blogs', blogs.sort((a, b) => (a.category || '~').localeCompare(b.category || '~') || a.title.localeCompare(b.title))); h += '

Import & Einstellungen

' + ' ' + @@ -307,7 +323,7 @@ '
' + esc(x.url) + '
' + (x.last_error ? '

Letzter Fehler: ' + esc(x.last_error) + '

' : '') + '
' + - '' + + '' + '
' + '
' + '' + - '
'; + '' + + loginForm(x); + } + + // A paywalled site (junge Welt …): the subscriber login, kept encrypted on the + // server, which then fetches whole articles. The password never comes back. + function loginForm(x) { + const l = x.login; + if (!l) return ''; + let h = ''; + h += '

Neu eingeben:

'; + } + return h + + '' + + '' + + '

Das Passwort liegt verschlüsselt auf dem diora-Server und wird nie wieder angezeigt. ' + + 'Damit holt der Server die ganzen Artikel statt nur des freien Teils.

' + + '
'; } function renderOpml() { @@ -449,7 +471,10 @@ const content = i.content || ''; if (i.image_url && content.indexOf(i.image_url) < 0) h += ''; h += '
' + (content || '

' + esc(i.summary) + '

') + '
'; - if (!i.content_full && i.url && (content.replace(/<[^>]+>/g, '').length < 700)) { + if (i.paywalled) { + h += '

Nur der freie Teil des Artikels. Mit deiner Abo-Anmeldung (⚙ → ' + esc(i.source_title) + + ') holt diora den ganzen.

'; + } else if (!i.content_full && i.url && (content.replace(/<[^>]+>/g, '').length < 700)) { h += '

Der Feed bringt nur einen Anriss, der ganze Artikel ließ sich nicht laden.

'; } if (story && story.items.length > 1) { @@ -591,6 +616,11 @@ await api('/feeds/items/read/', {ids, read: true}).catch(() => {}); return load(); } + if (act === 'login-delete') { + await api('/feeds/logins/delete/', {source: parseInt(t.dataset.id, 10)}).catch(() => {}); + await loadState().catch(() => {}); + return render(); + } if (act === 'cancel-edit') { st.editing = null; return render(); @@ -678,6 +708,14 @@ category: f.category.value}); found.subscribed = true; await loadState(); + } else if (f.dataset.form === 'login') { + if (btn) btn.textContent = 'Meldet an …'; + const r = await api('/feeds/logins/', {source: parseInt(f.dataset.id, 10), username: f.username.value, password: f.password.value}); + f.password.value = ''; + await loadState(); + if (typeof customAlert === 'function') { + customAlert('Angemeldet.' + (r.refetched ? ' ' + r.refetched + ' Artikel jetzt vollständig.' : '')); + } } else if (f.dataset.form === 'edit') { await api('/feeds/sources/' + f.dataset.id + '/', {title: f.title.value, kind: f.kind.value, category: f.category.value, fulltext: f.fulltext.value}); @@ -836,6 +874,8 @@ '#tab-feeds .fd-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; }', '#tab-feeds .fd-grid label, #tab-feeds .fd-edit > label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--nui-muted, #9a9a9a); }', '#tab-feeds .fd-buttons { display: flex; flex-wrap: wrap; gap: 8px; }', + '#tab-feeds .fd-login h4 { margin: 0; font-size: 13px; }', + '#tab-feeds .fd-login p { margin: 0; font-size: 13px; }', '#tab-feeds .fd-src { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }', '#tab-feeds .fd-opml { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }', '#tab-feeds .fd-opml-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }', diff --git a/static/js/sw.js b/static/js/sw.js index 725d6f1..c79b824 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v91'; +const CACHE = 'diora-v92'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', From b063e91113ef05155b5518126c6541fcb2743b85 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 4 Oct 2026 21:32:15 +0200 Subject: [PATCH 072/160] Blog-Feed nach Kino: Abschnitte, Favoriten, Seltene zuerst, Ausblenden, NEU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Abschnitte Heute/Gestern/Diese Woche/Diesen Monat/Älter; je Abschnitt bis zu 5 Posts je Favoriten-Blog zuerst, dann „Seltene zuerst“ (mittlerer Abstand der letzten 10 Posts, ein Post = 30 Tage) oder chronologisch - Blog „im Hauptfeed ausblenden“: nicht unter Alle und nicht gezählt, im Ordner da - NEU (jünger als 24 h, ungelesen) am Post, als Zahl am Tab, Punkt am Ordner - Chips Ungelesen/Alle, ★ Favoriten, Seltene zuerst; Schalter je Blog - Lesezeit (Wörter/230) an jedem Post; words wird beim Speichern gezählt - sw v93 Co-Authored-By: Claude Opus 5.5 --- feeds/fetch.py | 5 + feeds/migrations/0003_blog_flags_words.py | 42 ++++++ feeds/models.py | 6 + feeds/refresh.py | 6 +- feeds/tests.py | 43 ++++++ feeds/views.py | 56 +++++-- static/js/feeds.js | 170 +++++++++++++++++++--- static/js/sw.js | 2 +- 8 files changed, 294 insertions(+), 36 deletions(-) create mode 100644 feeds/migrations/0003_blog_flags_words.py diff --git a/feeds/fetch.py b/feeds/fetch.py index 7c3f050..fa25a1b 100644 --- a/feeds/fetch.py +++ b/feeds/fetch.py @@ -141,10 +141,15 @@ def parse_entry(entry, feed_url): 'dated': when is not None, # not an Item field: refresh._apply uses and drops it 'summary': _short(summary), 'content': content, + 'words': word_count(content), 'image_url': _image(entry, content_html, base)[:2000], } +def word_count(content_html): + return len(plain(content_html).split()) + + def is_teaser(content_html): return len(plain(content_html)) < TEASER_CHARS diff --git a/feeds/migrations/0003_blog_flags_words.py b/feeds/migrations/0003_blog_flags_words.py new file mode 100644 index 0000000..a3625cf --- /dev/null +++ b/feeds/migrations/0003_blog_flags_words.py @@ -0,0 +1,42 @@ +# Generated by Django 6.1 on 2026-10-04 19:28 + +from django.db import migrations, models + + +def count_words(apps, schema_editor): + """The reading time of the articles already there.""" + from feeds.fetch import word_count + Item = apps.get_model('feeds', 'Item') + for item in Item.objects.exclude(content='').only('id', 'content').iterator(): + Item.objects.filter(pk=item.pk).update(words=word_count(item.content)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('feeds', '0002_item_paywalled_sitelogin'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='words', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='source', + name='fav', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='source', + name='hidden', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='source', + name='notify', + field=models.BooleanField(default=False), + ), + migrations.RunPython(count_words, migrations.RunPython.noop), + ] diff --git a/feeds/models.py b/feeds/models.py index 1775467..7b81112 100644 --- a/feeds/models.py +++ b/feeds/models.py @@ -28,6 +28,11 @@ class Source(models.Model): category = models.CharField(max_length=100, blank=True) fulltext = models.CharField(max_length=10, choices=FULLTEXTS, default=FULLTEXT_AUTO) added_at = models.DateTimeField(auto_now_add=True) + # Blogs: favorites come first; a hidden blog stays out of "Alle" (and its + # counts) but shows in its folder; notify = a notification for new posts. + fav = models.BooleanField(default=False) + hidden = models.BooleanField(default=False) + notify = models.BooleanField(default=False) # Refresh bookkeeping, as for podcast feeds (see podcasts.views._fetch_feed). last_refreshed_at = models.DateTimeField(null=True, blank=True) @@ -82,6 +87,7 @@ class Item(models.Model): # a subscriber login for the site (logins.py). paywalled = models.BooleanField(default=False) image_url = models.URLField(max_length=2000, blank=True) + words = models.PositiveIntegerField(default=0) # of the content: the reading time read_at = models.DateTimeField(null=True, blank=True) starred = models.BooleanField(default=False) story = models.ForeignKey(Story, null=True, blank=True, on_delete=models.SET_NULL, related_name='items') diff --git a/feeds/refresh.py b/feeds/refresh.py index 6314e74..5f96656 100644 --- a/feeds/refresh.py +++ b/feeds/refresh.py @@ -146,20 +146,22 @@ def _store_article(item, got): item.paywalled = True if longer: item.content = content + item.words = fetch.word_count(content) if not item.image_url and image: item.image_url = image[:2000] - item.save(update_fields=['paywalled', 'content', 'image_url']) + item.save(update_fields=['paywalled', 'content', 'words', 'image_url']) return if not longer: item.fulltext_failed = True # the feed had more item.save(update_fields=['fulltext_failed']) return item.content = content + item.words = fetch.word_count(content) item.content_full = True item.paywalled = False if not item.image_url and image: item.image_url = image[:2000] - item.save(update_fields=['content', 'content_full', 'paywalled', 'image_url']) + item.save(update_fields=['content', 'words', 'content_full', 'paywalled', 'image_url']) def _articles(items, deadline): diff --git a/feeds/tests.py b/feeds/tests.py index b0d8e2f..4245fb8 100644 --- a/feeds/tests.py +++ b/feeds/tests.py @@ -291,3 +291,46 @@ class SiteLoginTest(TestCase): by_id = {s['id']: s for s in state['sources']} self.assertIsNone(by_id[other.id]['login']) self.assertIsNotNone(by_id[self.src.id]['login']) + + +class BlogFeedTest(TestCase): + def setUp(self): + self.u = User.objects.create_user('tester', password='pw12345678') + self.client.force_login(self.u) + now = timezone.now() + self.busy = Source.objects.create(user=self.u, url='https://busy/', title='Busy', kind='blog', category='Politik') + self.rare = Source.objects.create(user=self.u, url='https://rare/', title='Rare', kind='blog', category='Politik') + for n in range(10): # a post every two hours + Item.objects.create(source=self.busy, user=self.u, kind='blog', guid_hash=f'b{n}', title=f'Busy {n}', + content='

' + 'wort ' * 460 + '

', words=460, published=now - timedelta(hours=2 * n)) + Item.objects.create(source=self.rare, user=self.u, kind='blog', guid_hash='r', title='Rare essay', + published=now - timedelta(hours=5)) + + def post(self, url, data): + return self.client.post(url, json.dumps(data), content_type='application/json') + + def by_id(self): + return {s['id']: s for s in self.client.get('/feeds/state/').json()['sources']} + + def test_interval_new_and_words(self): + st = self.by_id() + self.assertLess(st[self.busy.id]['interval_days'], 1) + self.assertEqual(st[self.rare.id]['interval_days'], 30.0) # one post: counts as rare + self.assertEqual(st[self.busy.id]['new'], 10) + items = self.client.get('/feeds/blogs/?unread=1').json()['items'] + self.assertEqual(next(i for i in items if i['title'] == 'Busy 0')['words'], 460) + + def test_hidden_blog_only_in_its_folder_and_not_counted(self): + self.post(f'/feeds/sources/{self.busy.id}/', {'hidden': True}) + state = self.client.get('/feeds/state/').json() + self.assertEqual((state['blogs_unread'], state['blogs_new']), (1, 1)) + main = [i['title'] for i in self.client.get('/feeds/blogs/?unread=1').json()['items']] + self.assertEqual(main, ['Rare essay']) + folder = self.client.get('/feeds/blogs/?unread=1&folder=Politik').json()['items'] + self.assertEqual(len(folder), 11) + + def test_favorites_filter(self): + self.post(f'/feeds/sources/{self.rare.id}/', {'fav': True}) + self.assertTrue(self.by_id()[self.rare.id]['fav']) + favs = [i['title'] for i in self.client.get('/feeds/blogs/?fav=1').json()['items']] + self.assertEqual(favs, ['Rare essay']) diff --git a/feeds/views.py b/feeds/views.py index 6a676a4..de64433 100644 --- a/feeds/views.py +++ b/feeds/views.py @@ -12,7 +12,8 @@ News: GET /feeds/briefing/ what's new since the briefing was POST /feeds/briefing/seen/ seen: everything up to now counts as read GET /feeds/news/?before=&category= stories, newest first GET /feeds/stories// one story with all its items -Blogs: GET /feeds/blogs/?folder=&source=&unread=1&before= (a blog's category is its folder) +Blogs: GET /feeds/blogs/?folder=&source=&unread=1&fav=1&before= (a blog's category is its folder; + without folder/source the hidden blogs stay out) Items: GET /feeds/items// the article (fetches the page if the feed had a teaser) POST /feeds/items/read/ {ids, read} POST /feeds/items//star/ {starred} @@ -40,6 +41,10 @@ from . import fetch, logins, refresh from .models import Item, SiteLogin, Source, Story PAGE = 40 +BLOG_PAGE = 150 # blog posts per page (all unread ones come at once, up to BLOG_UNREAD_MAX) +BLOG_UNREAD_MAX = 600 +NEW_HOURS = 24 # a blog post this young and unread is "new" +RARE_POSTS = 10 # how often a blog posts: the mean gap over its last this many posts BRIEFING_TOP = 8 BRIEFING_PER_CATEGORY = 5 REFRESH_WAIT = 40 # seconds an explicit refresh may take @@ -63,10 +68,13 @@ def _iso(dt): return dt.isoformat() if dt else None -def _source_data(s, unread=0): +def _source_data(s, unread=0, new=0, interval=None): return { 'id': s.id, 'url': s.url, 'title': s.title, 'site_url': s.site_url, 'icon_url': s.icon_url, - 'kind': s.kind, 'category': s.category, 'fulltext': s.fulltext, 'unread': unread, + 'kind': s.kind, 'category': s.category, 'fulltext': s.fulltext, 'unread': unread, 'new': new, + 'fav': s.fav, 'hidden': s.hidden, 'notify': s.notify, + # Days between posts (blogs, for "Seltene zuerst"); a blog with one post counts as rare. + 'interval_days': interval, 'last_refreshed_at': _iso(s.last_refreshed_at), 'last_error': s.last_error, 'error_count': s.error_count, 'login': logins.info(s.user_id, s.url), } @@ -75,7 +83,7 @@ def _source_data(s, unread=0): def _item_data(i, content=False): d = { 'id': i.id, 'source': i.source_id, 'source_title': i.source.title, 'kind': i.kind, - 'url': i.url, 'title': i.title, 'author': i.author, 'published': _iso(i.published), + 'url': i.url, 'title': i.title, 'author': i.author, 'published': _iso(i.published), 'words': i.words, 'summary': i.summary, 'image_url': i.image_url, 'read': i.read_at is not None, 'starred': i.starred, 'story': i.story_id, } @@ -117,21 +125,40 @@ def state(request): return err unread = dict(Item.objects.filter(user=request.user, gone=False, read_at=None) .values_list('source').annotate(n=Count('id'))) + now = timezone.now() + new = dict(Item.objects.filter(user=request.user, kind=Source.BLOG, gone=False, read_at=None, + published__gte=now - timedelta(hours=NEW_HOURS)) + .values_list('source').annotate(n=Count('id'))) sources = list(Source.objects.filter(user=request.user)) + intervals = _intervals(request.user, [x for x in sources if x.kind == Source.BLOG], now) s = refresh.settings_for(request.user) since = s.news_seen_at or (timezone.now() - timedelta(days=1)) new_news = Story.objects.filter(user=request.user, last_at__gt=since, items__gone=False, items__read_at=None).distinct().count() return JsonResponse({ - 'sources': [_source_data(x, unread.get(x.id, 0)) for x in sources], + 'sources': [_source_data(x, unread.get(x.id, 0), new.get(x.id, 0), intervals.get(x.id)) for x in sources], 'categories': sorted({x.category for x in sources if x.kind == Source.NEWS and x.category}), 'folders': sorted({x.category for x in sources if x.kind == Source.BLOG and x.category}), 'news_new': new_news, - 'blogs_unread': sum(n for sid, n in unread.items() if any(x.id == sid and x.kind == Source.BLOG for x in sources)), + 'blogs_unread': sum(unread.get(x.id, 0) for x in sources if x.kind == Source.BLOG and not x.hidden), + 'blogs_new': sum(new.get(x.id, 0) for x in sources if x.kind == Source.BLOG and not x.hidden), 'settings': {'news_keep_days': s.news_keep_days, 'news_seen_at': _iso(s.news_seen_at)}, }) +def _intervals(user, blogs, now): + """Mean days between the last RARE_POSTS posts of each blog, up to now: + (now - the oldest of them) / their number; one post counts as 30 days.""" + out = {} + for s in blogs: + dates = list(s.items.filter(gone=False).order_by('-published').values_list('published', flat=True)[:RARE_POSTS]) + if not dates: + continue + days = (now - dates[-1]).total_seconds() / 86400 / len(dates) + out[s.id] = round(max(days, 30.0) if len(dates) == 1 else days, 2) + return out + + def _probe(url): """(feed url, title, entries) if `url` is a feed, else ([candidate feeds], icon).""" r = requests.get(url, timeout=fetch.PAGE_TIMEOUT, headers={'User-Agent': fetch.USER_AGENT}) @@ -248,6 +275,9 @@ def edit_source(request, pk): return JsonResponse({'error': 'not found'}, status=404) if 'title' in body and (body['title'] or '').strip(): source.title = body['title'].strip()[:300] + for flag in ('fav', 'hidden', 'notify'): + if isinstance(body.get(flag), bool): + setattr(source, flag, body[flag]) if 'category' in body: source.category = (body['category'] or '').strip()[:100] if body.get('fulltext') in dict(Source.FULLTEXTS): @@ -385,14 +415,22 @@ def blogs(request): if request.GET.get('source', '').isdigit(): qs = qs.filter(source_id=int(request.GET['source'])) elif 'folder' in request.GET: + # A folder shows all its blogs, the hidden ones too. qs = qs.filter(source__category=request.GET['folder']) - if request.GET.get('unread') == '1': + else: + qs = qs.filter(source__hidden=False) + if request.GET.get('fav') == '1': + qs = qs.filter(source__fav=True) + unread_only = request.GET.get('unread') == '1' + if unread_only: qs = qs.filter(read_at=None) before = parse_datetime(request.GET.get('before') or '') if before: qs = qs.filter(published__lt=before) - items = list(qs.order_by('-published')[:PAGE]) - return JsonResponse({'items': [_item_data(i) for i in items], 'more': len(items) == PAGE}) + # Unread: all of them at once (the page sorts them into days, favorites, rare blogs). + limit = BLOG_UNREAD_MAX if unread_only else BLOG_PAGE + items = list(qs.order_by('-published')[:limit]) + return JsonResponse({'items': [_item_data(i) for i in items], 'more': len(items) == limit}) @csrf_exempt diff --git a/static/js/feeds.js b/static/js/feeds.js index b591a33..35c7352 100644 --- a/static/js/feeds.js +++ b/static/js/feeds.js @@ -23,6 +23,8 @@ category: '', items: [], itemsMore: false, blogSource: '', blogUnread: true, + blogFav: false, // main feed: only the favorite blogs + rareFirst: true, // within a day: blogs that seldom post first blogFolder: null, // null = all blogs, '' = blogs without a folder, else a folder name found: null, // discover results opml: null, // parsed OPML import @@ -33,6 +35,8 @@ try { const saved = localStorage.getItem('diora_feeds_view'); if (saved === 'news' || saved === 'blogs') st.view = saved; + if (localStorage.getItem('diora_feeds_rare') === '0') st.rareFirst = false; + if (localStorage.getItem('diora_feeds_unread') === '0') st.blogUnread = false; } catch (e) {} const esc = s => String(s == null ? '' : s).replace(/&/g, '&').replace(/= 120 ? Math.max(1, Math.round(i.words / 230)) + ' Min.' : ''; + } + + function section(iso) { + const d = new Date(iso); + const label = dayLabel(d); + if (label === 'Heute' || label === 'Gestern') return label; + const days = (Date.now() - d.getTime()) / 86400000; + if (days < 7) return 'Diese Woche'; + if (days < 31) return 'Diesen Monat'; + return 'Älter'; + } + + // Posts into sections (newest first); in each, up to FAV_PER_DAY posts per + // favorite blog first, then the rest — blogs that post seldom ahead of the + // busy ones ("Seltene zuerst"), or simply by time. + function blogSections(items, byId) { + const order = ['Heute', 'Gestern', 'Diese Woche', 'Diesen Monat', 'Älter']; + const groups = {}; + items.forEach(i => { (groups[section(i.published)] = groups[section(i.published)] || []).push(i); }); + const newest = (a, b) => new Date(b.published) - new Date(a.published); + return order.filter(k => groups[k]).map(k => { + const list = groups[k].slice().sort(newest); + const favCount = {}; + const fav = []; + const rest = []; + list.forEach(i => { + const src = byId[i.source]; + if (src && src.fav && (favCount[i.source] || 0) < FAV_PER_DAY) { + favCount[i.source] = (favCount[i.source] || 0) + 1; + fav.push(i); + } else { + rest.push(i); + } + }); + if (st.rareFirst) { + const gap = i => { const src = byId[i.source]; return (src && src.interval_days) || 0; }; + rest.sort((a, b) => (gap(b) - gap(a)) || newest(a, b)); + } + return {label: k, items: fav.concat(rest)}; + }); + } + function renderBlogs() { const blogs = sources('blog'); if (!blogs.length) { return '

Noch keine Blogs abonniert.

' + '
'; } - // Folders (a blog's category): chips with their unread count. + const byId = {}; + blogs.forEach(b => { byId[b.id] = b; }); + // Folders (a blog's category): chips with their unread count, a dot when something is new. const folders = (st.state.folders || []); - const unreadIn = f => blogs.filter(b => (b.category || '') === f).reduce((n, b) => n + b.unread, 0); - const total = blogs.reduce((n, b) => n + b.unread, 0); + const inFolder = f => blogs.filter(b => (b.category || '') === f); + const sum = (list, key) => list.reduce((n, b) => n + b[key], 0); + const visible = blogs.filter(b => !b.hidden); let h = ''; if (folders.length) { - const chip = (value, label, n) => ''; - h += '
' + chip(null, 'Alle', total) + - folders.map(f => chip(f, f, unreadIn(f))).join('') + - (blogs.some(b => !b.category) ? chip('', 'Ohne Ordner', unreadIn('')) : '') + '
'; + const chip = (value, label, list) => ''; + h += '
' + chip(null, 'Alle', visible) + + folders.map(f => chip(f, f, inFolder(f))).join('') + + (blogs.some(b => !b.category) ? chip('', 'Ohne Ordner', inFolder('')) : '') + '
'; } - const shown = st.blogFolder === null ? blogs : blogs.filter(b => (b.category || '') === st.blogFolder); + const main = st.blogFolder === null; + const shown = main ? blogs : inFolder(st.blogFolder); + h += '
' + + '' + + '' + + (main ? '' : '') + + '' + + '
'; h += '
' + '' + - '' + + (b.fav ? '★ ' : '') + esc(b.title) + (b.unread ? ' (' + b.unread + ')' : '') + (b.hidden ? ' – ausgeblendet' : '') + + '').join('') + '' + (st.items.some(i => !i.read) ? '' : '') + '
'; + const one = st.blogSource ? byId[st.blogSource] : null; + if (one) { + // The selected blog's switches. + h += '
' + + '' + + '' + + '
'; + } if (!st.items.length) return h + '

' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '

'; - h += st.items.map(i => - '').join(''); + blogSections(st.items, byId).forEach(sec => { + h += '

' + esc(sec.label) + '

'; + h += sec.items.map(i => { + const src = byId[i.source]; + const rt = readingTime(i); + return ''; + }).join(''); + }); if (st.itemsMore) h += ''; return h; } @@ -298,7 +383,7 @@ '

' + title + ' ' + list.length + '

' + list.map(x => x.id === st.editing ? editForm(x) : '' + + '' + + '' + + ''; + sheet.addEventListener('click', e => { + const b = e.target.closest('[data-s]'); + if (!b && e.target !== sheet) return; + sheet.remove(); + const what = b ? b.dataset.s : 'cancel'; + if (what === 'open') { st.blogFolder = name; st.blogSource = ''; load(); } + if (what === 'rename') folderAction('folder-rename', name); + if (what === 'dissolve') folderAction('folder-dissolve', name); + }); + document.body.appendChild(sheet); + } + // ---- events tab.addEventListener('click', async e => { @@ -734,6 +862,7 @@ await api('/feeds/items/read/', {ids, read: true}).catch(() => {}); return load(); } + if (act === 'folder-rename' || act === 'folder-dissolve') return folderAction(act, t.dataset.name); if (act === 'login-delete') { await api('/feeds/logins/delete/', {source: parseInt(t.dataset.id, 10)}).catch(() => {}); await loadState().catch(() => {}); @@ -834,7 +963,12 @@ customAlert('Angemeldet.' + (r.refetched ? ' ' + r.refetched + ' Artikel jetzt vollständig.' : '')); } } else if (f.dataset.form === 'edit') { - const change = {title: f.title.value, kind: f.kind.value, category: f.category.value, fulltext: f.fulltext.value}; + const change = {title: f.title.value, kind: f.kind.value, fulltext: f.fulltext.value}; + if (f.category) change.category = f.category.value; + if (f.newfolder) { + change.folders = Array.prototype.filter.call(f.querySelectorAll('input[name=folder]'), c => c.checked).map(c => c.value); + if (f.newfolder.value.trim()) change.folders.push(f.newfolder.value.trim()); + } if (f.fav) change.fav = f.fav.checked; if (f.hidden) change.hidden = f.hidden.checked; if (f.notify) change.notify = f.notify.checked; @@ -982,6 +1116,19 @@ '#tab-feeds .fd-blogbar { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 8px; }', '#tab-feeds .fd-new { display: inline-block; padding: 1px 5px; border-radius: 3px; background: var(--accent, #e63946); color: #fff; font-size: 10px; letter-spacing: 0.05em; vertical-align: 2px; }', '#tab-feeds .fd-hidden { opacity: 0.6; }', + '#tab-feeds .fd-prog { display: block; height: 3px; margin-top: 4px; background: #2a2a2a; border-radius: 2px; overflow: hidden; }', + '#tab-feeds .fd-prog > span { display: block; height: 100%; background: var(--accent, #e63946); }', + '#tab-feeds .fd-folders { border: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; }', + '#tab-feeds .fd-folders legend { font-size: 12px; color: var(--nui-muted, #9a9a9a); margin-bottom: 4px; }', + '#tab-feeds .fd-folders input[name=newfolder] { flex-basis: 100%; }', + '#tab-feeds .fd-folder-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border, #2a2a2a); flex-wrap: wrap; }', + '#fd-sheet { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 995; background: rgba(0,0,0,0.5); display: flex; align-items: flex-end; justify-content: center; }', + '#fd-sheet .fd-sheet-box { width: 100%; max-width: 480px; background: var(--bg, #000); border: 1px solid var(--border, #2a2a2a); border-bottom: none; border-radius: 10px 10px 0 0; padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px)); }', + '#fd-sheet .fd-sheet-title { padding: 10px 18px; font-size: 13px; color: var(--nui-muted, #9a9a9a); }', + '#fd-sheet button { display: block; width: 100%; padding: 14px 18px; background: none; border: none; color: var(--fg, #fff); font: inherit; text-align: left; cursor: pointer; }', + 'body.eink #tab-feeds .fd-prog > span { background: #000; }', + 'body.eink #fd-sheet .fd-sheet-box { background: #fff; }', + 'body.eink #fd-sheet button { color: #000; }', 'body.eink #tab-feeds .fd-new, body.eink .fd-dot { background: #000; }', '#tab-feeds .fd-day { margin-top: 16px; }', '#tab-feeds .fd-card { display: flex; gap: 12px; width: 100%; padding: 12px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; text-align: left; cursor: pointer; }', diff --git a/static/js/sw.js b/static/js/sw.js index 73e0028..dda6cf9 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v94'; +const CACHE = 'diora-v95'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', From 3d659fc80971a3773e691c64376f44f8b6b0396e Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 5 Oct 2026 17:50:44 +0200 Subject: [PATCH 075/160] =?UTF-8?q?/api/sync/=3Fonly=3Dbooks:=20nur=20der?= =?UTF-8?q?=20B=C3=BCcher-Teil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Buch-Synchronisation der Apps braucht vom Schnappschuss nur Bücher, Fortschritt, Markierungen und Lesezeichen; der Podcast-Teil (jede je gespielte Folge) ist für ein Konto aus AntennaPod 11 MB groß und wurde bei jedem Abgleich mitgeladen – über mobile Daten Gigabytes. Co-Authored-By: Claude Opus 5.5 --- accounts/sync.py | 15 ++++++++++++++- accounts/tests.py | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/accounts/sync.py b/accounts/sync.py index dcd2422..5dccfb3 100644 --- a/accounts/sync.py +++ b/accounts/sync.py @@ -19,7 +19,11 @@ def sync_snapshot(request): endpoints (e.g. GET /books//data/), which this same token also unlocks. Writes (progress, highlights, bookmarks, ...) go through those existing endpoints too, to reuse their merge semantics rather than - duplicating it here.""" + duplicating it here. + + ?only=books: just the books' part. The apps' book sync needs nothing else, + and the podcast part is by far the biggest (every episode ever played: + megabytes for an account that moved over from AntennaPod).""" if not request.user.is_authenticated: return JsonResponse({'error': 'authentication required'}, status=401) @@ -43,6 +47,15 @@ def sync_snapshot(request): for bm in book_bookmarks: bm['updated_at'] = bm['updated_at'].isoformat() + if request.GET.get('only') == 'books': + return JsonResponse({ + 'server_time': timezone.now().isoformat(), + 'books': books, + 'book_progress': book_progress, + 'book_highlights': book_highlights, + 'book_bookmarks': book_bookmarks, + }) + podcast_feeds = list( PodcastFeed.objects.filter(user=user) .annotate(latest_episode_at=Max('episodes__pub_date')) diff --git a/accounts/tests.py b/accounts/tests.py index 638ec51..35d015e 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -85,6 +85,12 @@ class SyncSnapshotTests(TestCase): resp = self.client.get('/api/sync/') self.assertEqual(resp.status_code, 401) + def test_only_books(self): + resp = self.client.get('/api/sync/?only=books', HTTP_AUTHORIZATION=f'Bearer {self.token.token}') + data = resp.json() + self.assertEqual(set(data), {'server_time', 'books', 'book_progress', 'book_highlights', 'book_bookmarks'}) + self.assertEqual(data['book_highlights'][0]['ct'], 'hl-ct') + def test_snapshot_shape_and_data(self): resp = self.client.get('/api/sync/', HTTP_AUTHORIZATION=f'Bearer {self.token.token}') self.assertEqual(resp.status_code, 200) From aa4cf23b58412bb4c86997cdfb029cc2b58f6594 Mon Sep 17 00:00:00 2001 From: Marwin Schulz Date: Wed, 7 Oct 2026 08:50:07 +0200 Subject: [PATCH 076/160] =?UTF-8?q?B=C3=BCcherliste:=20Seitenzahl=20neben?= =?UTF-8?q?=20dem=20Lesefortschritt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDFs liefern die echte Seitenzahl, bei EPUBs wird sie aus der Zeichenzahl geschätzt (1800 Zeichen je Seite). Gespeichert in den verschlüsselten Metadaten beim Öffnen des Buchs. Co-Authored-By: Claude Sonnet 5.5 --- static/js/app.js | 18 ++++++++++++++---- static/js/sw.js | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index d12f1a5..5112828 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1991,6 +1991,8 @@ let readerScrollSaveTimer = null; let _resizeObserver = null; let _currentPositionAnchor = ''; let _currentBookCharCount = 0; +// Rough characters per printed book page, used to estimate a real page count for EPUBs. +const CHARS_PER_BOOK_PAGE = 1800; const bookMetaCache = {}; // id → {title, author, type} // Reading-position percentages (the progress readout, annotation "% im Buch" @@ -2493,8 +2495,8 @@ async function loadBookList() { try { const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const meta = JSON.parse(new TextDecoder().decode(metaBuf)); - bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || ''}; - decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); + bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0}; + decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } catch (e) { bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); @@ -2536,7 +2538,7 @@ async function loadBookList() { if (b.last_read) return 1; return (b.uploaded_at || '').localeCompare(a.uploaded_at || ''); }); - for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || ''}; + for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0}; const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = 'none'; renderBookList(cachedBooks); @@ -2698,7 +2700,7 @@ function _renderBookItemHtml(b) { ${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' ✓ gelesen' : ''} ${escapeHtml(b.author)} - ${pct > 0 ? `${pct} % gelesen` : ''} + ${pct > 0 ? `${pct} % gelesen${b.pages ? ` (≈ ${Math.max(1, Math.round(b.pages * pct / 100))} von ${b.pages} Seiten)` : ''}` : (b.pages ? `ca. ${b.pages} Seiten` : '')} ${b.shelfTag ? `${escapeHtml(b.shelfTag)}` : ''} @@ -2825,6 +2827,7 @@ async function _updateBookMeta(bookId, overrides) { isbn: cached.isbn || book.isbn || '', folder: cached.folder || book.folder || '', shelfTag: cached.shelfTag || book.shelfTag || '', + pages: cached.pages || book.pages || 0, ...overrides, }; const key = await getOrCreateEncKey(); @@ -3767,6 +3770,13 @@ async function _openBookOnce(bookId) { currentBookId = bookId; + // Remember the book's real (PDF) or estimated (EPUB) page count in its encrypted meta so + // the book list can show it; rewritten only when it changed. + const bookPages = isPdfBook ? numPages : Math.max(1, Math.round(_currentBookCharCount / CHARS_PER_BOOK_PAGE)); + if (bookPages > 0 && cachedMeta.pages !== bookPages) { + _updateBookMeta(bookId, {pages: bookPages}).catch(() => {}); + } + // Load bookmarks and highlights await Promise.all([ loadBookmarks(bookId), diff --git a/static/js/sw.js b/static/js/sw.js index dda6cf9..b6114eb 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v95'; +const CACHE = 'diora-v96'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', From 077612e7bb6c7315352ed4ee9972067296fa0fbc Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Thu, 8 Oct 2026 18:41:56 +0200 Subject: [PATCH 077/160] =?UTF-8?q?B=C3=BCcher=20schneller=20=C3=B6ffnen:?= =?UTF-8?q?=20Kapitel=20parallel=20entpacken,=20vorbereitete=20B=C3=BCcher?= =?UTF-8?q?=20merken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemessen mit Das Kapital (148 Kapitel, 26 000 Absätze) im diora Reader, CPU 6-fach gedrosselt wie auf einem E-Reader: Öffnen 2,4 s → 1,2 s. - parseEpub entpackt alle Kapitel auf einmal statt eines nach dem anderen (jedes wartete auf JSZips eigene Ticks) und pausiert nach ~50 ms Arbeit statt alle 8 Kapitel einen Frame; ebenso das Einfügen der Kapitel - Vorbereitete Bücher (entpackt und bereinigt) liegen verschlüsselt mit dem Buchschlüssel in IndexedDB („parsed“): gültig für eine Fassung der Buchdatei (IV) und des Vorbereitungs-Codes (Hash seines Quelltexts – eine Änderung am Sanitizer bereitet jedes Buch neu vor); Bilder bleiben in der Buchdatei, die Kapitel tragen „diora-img:“ statt Blob-URLs. Nach 4 Wochen ohne Öffnen weg. Geprüft: Text, Absätze, Bilder, Fußnotenlinks, Inhaltsverzeichnis identisch. Co-Authored-By: Claude Opus 5.5 --- static/js/app.js | 134 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 121 insertions(+), 13 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index d12f1a5..98f2c43 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1815,11 +1815,21 @@ async function parseEpub(arrayBuffer) { .map(ref => manifest[ref.getAttribute('idref')]?.href) .filter(Boolean); + // All chapters unpacked at once: one after the other, every chapter waits for + // JSZip's own ticks (a book like Das Kapital has 148 of them). + const texts = await Promise.all(spineItems.map(href => { + const f = zip.file(href); + return f ? f.async('text').catch(() => null) : null; + })); + const chapters = []; + let slice = performance.now(); for (let i = 0; i < spineItems.length; i++) { const href = spineItems[i]; + if (texts[i] == null) continue; // missing try { - let chapterText = await zip.file(href).async('text'); + let chapterText = texts[i]; + texts[i] = null; // Strip style/script blocks early — publisher EPUBs often embed large base64 fonts in CSS. // This shrinks the string before regex and DOMParser work on it. chapterText = chapterText.replace(/]*>[\s\S]*?<\/style>/gi, ''); @@ -1828,8 +1838,12 @@ async function parseEpub(arrayBuffer) { const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap); const sanitized = sanitizeEpubHtml(withBlobs); chapters.push(`
${sanitized}
`); - if (i % 8 === 0) await new Promise(r => requestAnimationFrame(r)); - } catch (e) { /* skip missing */ } + // Let the page breathe (spinner, taps) every ~50 ms of work, not after a fixed count. + if (performance.now() - slice > 50) { + await new Promise(r => setTimeout(r, 0)); + slice = performance.now(); + } + } catch (e) { /* skip unreadable */ } } return {title, author, chapters, toc, imageMap}; @@ -2208,11 +2222,12 @@ function _cmpProgress(anchorA, fracA, anchorB, fracB) { const _BOOK_CACHE_STORE = 'books'; const _BOOK_META_STORE = 'book_meta'; const _BOOK_ANNOTATIONS_STORE = 'book_annotations'; +const _BOOK_PARSED_STORE = 'parsed'; // prepared chapters, see _getParsedBook const _BOOK_CACHE_EVICT_MS = 4 * 7 * 24 * 60 * 60 * 1000; function _openBookCacheDb() { return new Promise((resolve, reject) => { - const req = indexedDB.open('diora_book_cache', 2); + const req = indexedDB.open('diora_book_cache', 3); req.onupgradeneeded = e => { const db = e.target.result; if (!db.objectStoreNames.contains(_BOOK_CACHE_STORE)) @@ -2221,12 +2236,98 @@ function _openBookCacheDb() { db.createObjectStore(_BOOK_META_STORE, {keyPath: 'id'}); if (!db.objectStoreNames.contains(_BOOK_ANNOTATIONS_STORE)) db.createObjectStore(_BOOK_ANNOTATIONS_STORE, {keyPath: 'id'}); + if (!db.objectStoreNames.contains(_BOOK_PARSED_STORE)) + db.createObjectStore(_BOOK_PARSED_STORE, {keyPath: 'id'}); }; req.onsuccess = e => resolve(e.target.result); req.onerror = () => reject(); }); } +// --------------------------------------------------------------------------- +// Prepared books: an EPUB's chapters after unpacking and sanitizing, so opening +// it again skips both (for a book like Das Kapital that's most of the time an +// e-reader needs to open it). Encrypted with the book key like everything else +// on the device. Valid for one version of the book file (its IV) and of the +// code that prepares chapters (a hash of its source: changing the sanitizer +// makes every book be prepared anew). Images stay in the book file: the +// chapters keep "diora-img:" in place of the session's blob URLs. +// --------------------------------------------------------------------------- +let _parseCodeSig = null; + +function _parsePreparationSig() { + if (_parseCodeSig) return _parseCodeSig; + const src = [parseEpub, sanitizeEpubHtml, _injectImageBlobs, _resolveImageBlob, _parseEpubToc] + .map(f => String(f)).join('\n'); + let h = 5381; + for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0; + return (_parseCodeSig = (h >>> 0).toString(36) + '.' + src.length); +} + +async function _getParsedBook(bookId, dataIv, key) { + try { + const db = await _openBookCacheDb(); + const row = await new Promise(resolve => { + const req = db.transaction(_BOOK_PARSED_STORE).objectStore(_BOOK_PARSED_STORE).get(bookId); + req.onsuccess = e => resolve(e.target.result || null); + req.onerror = () => resolve(null); + }); + if (!row || row.sig !== dataIv + ':' + _parsePreparationSig()) return null; + const plain = await crypto.subtle.decrypt({name: 'AES-GCM', iv: row.iv}, key, row.ct); + return JSON.parse(new TextDecoder().decode(plain)); + } catch (e) { return null; } +} + +async function _setParsedBook(bookId, dataIv, key, result) { + try { + // Blob URLs live as long as this page; the stored chapters name the image instead. + const pathOf = {}; + for (const [path, url] of Object.entries(result.imageMap || {})) pathOf[url] = path; + const blobRe = /blob:[^"'\s)]+/g; + const chapters = result.chapters.map(c => c.replace(blobRe, u => pathOf[u] ? 'diora-img:' + pathOf[u] : u)); + const doc = {title: result.title, author: result.author, toc: result.toc, chapters, + images: Object.keys(result.imageMap || {})}; + // Raw bytes, not base64: IndexedDB keeps an ArrayBuffer as it is (megabytes for a big book). + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, new TextEncoder().encode(JSON.stringify(doc))); + const db = await _openBookCacheDb(); + await new Promise(resolve => { + const tx = db.transaction(_BOOK_PARSED_STORE, 'readwrite'); + const store = tx.objectStore(_BOOK_PARSED_STORE); + store.put({id: bookId, sig: dataIv + ':' + _parsePreparationSig(), iv, ct, cached_at: Date.now()}); + // Prepared books not opened for a while go (they come back on the next open). + const all = store.getAll(); + all.onsuccess = () => all.result.forEach(r => { + if (r.id !== bookId && Date.now() - (r.cached_at || 0) > _BOOK_CACHE_EVICT_MS) store.delete(r.id); + }); + tx.oncomplete = resolve; + tx.onerror = resolve; + }); + } catch (e) {} +} + +/** A prepared book back to what parseEpub returns: fresh blob URLs for its images. */ +async function _restoreParsedBook(doc, epubBytes) { + const imageMap = {}; + if (doc.images && doc.images.length) { + const zip = await JSZip.loadAsync(epubBytes); + await Promise.all(doc.images.map(async path => { + const f = zip.file(path); + if (!f) return; + try { + const buf = await f.async('arraybuffer'); + const ext = path.split('.').pop().toLowerCase(); + const type = {jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', + svg: 'image/svg+xml', webp: 'image/webp'}[ext] || ''; + imageMap[path] = URL.createObjectURL(new Blob([buf], type ? {type} : {})); + } catch (e) {} + })); + } + const placeholder = /diora-img:([^"'\s)]+)/g; + const chapters = doc.chapters.map(c => c.replace(placeholder, (m, path) => imageMap[path] || '')); + return {title: doc.title, author: doc.author, toc: doc.toc, chapters, imageMap}; +} + async function _getCachedBook(bookId) { try { const db = await _openBookCacheDb(); @@ -3740,22 +3841,29 @@ async function _openBookOnce(bookId) { currentPdfBuffer = plain; } else { currentPdfBuffer = null; - const result = await parseEpub(plain); + // Prepared before (same book file, same preparation code): only the images to unpack. + const prepared = await _getParsedBook(bookId, data_iv, key); + const result = prepared ? await _restoreParsedBook(prepared, plain) : await parseEpub(plain); + if (!prepared) { + // Kept for the next open once the book is on screen (encrypting takes a moment). + setTimeout(() => _setParsedBook(bookId, data_iv, key, result), 3000); + } title = result.title || title; author = result.author || author; toc = result.toc; currentImageMap = result.imageMap; - // Inject chapters progressively so the browser stays responsive on large books - const BATCH = 8; - for (let i = 0; i < result.chapters.length; i += BATCH) { + // Inject chapters progressively so the browser stays responsive on large books: + // a batch per ~50 ms of work (a fixed count waited a frame every 8 chapters). + const tmp = document.createElement('div'); + for (let i = 0; i < result.chapters.length;) { const frag = document.createDocumentFragment(); - const tmp = document.createElement('div'); - for (let j = i; j < Math.min(i + BATCH, result.chapters.length); j++) { - tmp.innerHTML = result.chapters[j]; + const start = performance.now(); + do { + tmp.innerHTML = result.chapters[i++]; while (tmp.firstChild) frag.appendChild(tmp.firstChild); - } + } while (i < result.chapters.length && performance.now() - start < 50); contentEl.appendChild(frag); - await new Promise(r => requestAnimationFrame(r)); + if (i < result.chapters.length) await new Promise(r => setTimeout(r, 0)); } // Used to decide whole-percent vs one-decimal display further down — // see _formatBookPercent(). From 3f2e309c9ce8da86a8876e351d3bc4fca3db54ff Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Thu, 8 Oct 2026 18:51:05 +0200 Subject: [PATCH 078/160] =?UTF-8?q?Reader:=20Fu=C3=9Fnoten=20und=20Seitenz?= =?UTF-8?q?ahlen=20in=20Verlags-EPUBs;=20sw=20v96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - (XHTML) blieb beim Parsen als HTML offen und umschloss den folgenden Text, in jedem weiteren Absatz neu (Suhrkamp/transpect): die inline-Elemente werden vorher geschlossen. - Seitenzahlen der Druckausgabe (epub:type="pagebreak") als kleine graue Marke statt am Text klebend („3Anton Jäger“). - Fußnoten-Popover: Absatz der Notiz auch bei id auf einem inline-Element, ohne eigene Nummer und Seitenzahl, mehrabsätzige Noten (Gedichte). Co-Authored-By: Claude Opus 5.5 --- static/css/app.css | 4 ++++ static/js/app.js | 36 +++++++++++++++++++++++++++++++++++- static/js/sw.js | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/static/css/app.css b/static/css/app.css index e6d8b7b..a2b225f 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1814,6 +1814,10 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h) .footnote-popover.reader-theme-bright { background:#f0f0f0; color:#111; border-color:#bbb; } .footnote-popover-close { position:absolute; top:4px; right:6px; background:none; border:none; color:inherit; opacity:0.5; cursor:pointer; font-size:14px; padding:2px 4px; line-height:1; } +/* Page numbers of the printed edition (epub:type="pagebreak") */ +.reader-content .epub-pagenum { display:inline-block; font-size:0.62em; line-height:1.3; font-weight:400; font-style:normal; letter-spacing:0; text-transform:none; vertical-align:0.35em; padding:0 0.35em; margin-right:0.4em; border:1px solid currentColor; border-radius:3px; opacity:0.45; user-select:none; } +.reader-content .epub-pagenum:empty { display:none; } + /* Highlight marks */ .reader-no-bold * { font-weight: inherit !important; } .reader-content img { max-width: 100%; height: auto; display: block; } diff --git a/static/js/app.js b/static/js/app.js index d12f1a5..b5d9891 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1942,7 +1942,16 @@ function _looksLikeFootnoteLink(el, href) { return !!(el.closest('sup') || el.querySelector('sup')); } +// Chapters are XHTML, but parsed as HTML (forgiving with broken books). HTML +// knows no self-closing : the anchor stays open and swallows the +// text after it — and being a formatting element it is re-opened in every +// following paragraph (Suhrkamp/transpect books put one in each heading and +// note). Close the inline ones; block elements are left alone so the block +// indexes that highlights are anchored to stay the same. +const _SELF_CLOSING_INLINE = /<(a|span|b|i|em|strong|sup|sub|small|u|s|code|font|abbr|cite|q|mark|var)(\s(?:[^<>"']|"[^"]*"|'[^']*')*)?\/>/gi; + function sanitizeEpubHtml(html) { + html = html.replace(_SELF_CLOSING_INLINE, '<$1$2>'); const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('script, iframe, object, embed, style, head, meta, link').forEach(el => el.remove()); @@ -1957,6 +1966,11 @@ function sanitizeEpubHtml(html) { // Remove any non-blob, non-data src that slipped through (broken relative paths) if (src && !src.startsWith('blob:') && !src.startsWith('data:')) el.removeAttribute('src'); } + // The printed edition's page numbers ("[12]") as small marks, not glued to the text. + const etype = el.getAttribute('epub:type') || ''; + if (/\bpagebreak\b/.test(etype) || el.getAttribute('role') === 'doc-pagebreak') { + el.classList.add('epub-pagenum'); + } if (el.tagName === 'A') { const href = el.getAttribute('href') || ''; if (href.startsWith('http://') || href.startsWith('https://')) { @@ -6021,11 +6035,31 @@ function showFootnotePopover(link) { dismissFootnotePopover(); const targetEl = document.getElementById(link.dataset.footnoteRef); if (!targetEl) return; - const container = targetEl.closest('.footnote') || targetEl.parentElement; + // The note's paragraph, also when the id sits on an inline element in it + // (

143 Note…

). + const inline = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL)$/.test(targetEl.tagName); + const container = targetEl.closest('.footnote') || + (inline && targetEl.closest('p, li, dd, blockquote')) || targetEl.parentElement; if (!container) return; const clone = container.cloneNode(true); clone.querySelector('sup')?.remove(); + clone.querySelectorAll('.epub-pagenum').forEach(e => e.remove()); + // The note's own number at its start, a link back to the text ("1 Note…"). + const first = Array.from(clone.querySelectorAll('a')).find(a => a.textContent.trim()); + const num = first ? first.textContent.trim() : ''; + if (/^[\[(]?\d{1,4}[\])]?\.?$/.test(num) && clone.textContent.trim().startsWith(num)) first.remove(); + // Only the number: the note (a poem, a long quote) runs on in the next + // paragraphs, up to the next note's anchor. + if (!clone.textContent.trim()) { + let len = 0; + for (let next = container.nextElementSibling; next && len < 6000; next = next.nextElementSibling) { + if (/^H[1-6]$/.test(next.tagName) || next.querySelector('[id]')) break; + if (!next.textContent.trim()) continue; + clone.appendChild(next.cloneNode(true)); + len += next.textContent.length; + } + } const popover = document.createElement('div'); popover.className = 'footnote-popover'; diff --git a/static/js/sw.js b/static/js/sw.js index dda6cf9..b6114eb 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v95'; +const CACHE = 'diora-v96'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', From 3f2fcec3b87665498925a5cc17bd4d5cca91c36d Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Thu, 8 Oct 2026 18:52:16 +0200 Subject: [PATCH 079/160] =?UTF-8?q?sw=20v97=20(Fu=C3=9Fnoten-Fix=20und=20s?= =?UTF-8?q?chnelleres=20=C3=96ffnen=20nach=20v96=20aus=20aa4cf23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- static/js/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/sw.js b/static/js/sw.js index b6114eb..fba4890 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v96'; +const CACHE = 'diora-v97'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', From 53ca3b00024188476a3addd85d1e84c82da79768 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Thu, 8 Oct 2026 19:01:40 +0200 Subject: [PATCH 080/160] =?UTF-8?q?Leseposition:=20Bin=C3=A4rsuche=20?= =?UTF-8?q?=C3=BCber=20die=20Bl=C3=B6cke=20statt=20alle=20vom=20Buchanfang?= =?UTF-8?q?=20an=20messen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPositionAnchor maß jeden Block vom Buchanfang bis zur Leseposition und suchte die Blöcke bei jedem Aufruf neu – im E-Reader-Modus bei jedem Umblättern (die Prozentanzeige), beim Speichern des Fortschritts sowieso. In Das Kapital ab Kapitel 15: 0,8 s bei 6-fach gedrosselter CPU; auf dem tolino filtert compat61 das :has() dazu in JavaScript. - Blockliste samt Kapitel je Block einmal je geöffnetem Buch (verworfen bei jeder Änderung am Inhalt, MutationObserver) - Binärsuche nur über die angezeigten Kapitel; gleiche Regel wie vorher (letzter Block mit Höhe, der an/über der Oberkante beginnt, bei gleicher Höhe der erste; sonst der erste darunter) - Geprüft an 480 Stellen (Kapital, Hyperpolitik; E-Reader und Scrollen): gleiche Anker wie vorher; 53/281 ms → 4 ms; Umblättern 21–48 ms statt bis 0,9 s - sw v98 Co-Authored-By: Claude Opus 5.5 --- static/js/app.js | 88 +++++++++++++++++++++++++++++++++++++++++------- static/js/sw.js | 2 +- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index fe12f31..a094a04 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2120,26 +2120,88 @@ function _checkScrollJump(contentEl) { const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))'; -function getPositionAnchor(contentEl) { +// The open book's text blocks (EPUB_BLOCK_SELECTOR), with the chapter each one +// belongs to — found once and kept until the book's content changes (a +// highlight wrapping text can turn a leaf div into a non-leaf one). Selecting +// them is the expensive part on an e-reader: Chrome 61 has no :has(), so +// compat61.js filters every div in JavaScript. +let _blockCache = null; // {content, blocks, chapterOf: Int32Array, chapters: Element[]} + +function _epubBlocks(contentEl) { + if (_blockCache && _blockCache.content === contentEl) return _blockCache; const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); + const chapters = Array.from(contentEl.querySelectorAll('[data-epub-src]')); + const chapterOf = new Int32Array(blocks.length); + for (let i = 0, ch = -1; i < blocks.length; i++) { + while (ch + 1 < chapters.length && chapters[ch + 1].compareDocumentPosition(blocks[i]) & Node.DOCUMENT_POSITION_FOLLOWING) ch++; + chapterOf[i] = ch; + } + const observer = new MutationObserver(() => { + if (_blockCache && _blockCache.content === contentEl) _blockCache = null; + observer.disconnect(); + }); + observer.observe(contentEl, {childList: true, subtree: true}); + _blockCache = {content: contentEl, blocks, chapterOf, chapters}; + return _blockCache; +} + +// The anchor of the reading position: "block:inner" — the last block (with a +// height) that starts at or above the top of the view, the first of several +// starting at the same height; else the first block below. Blocks are in +// document order and so top to bottom: a binary search over the chapters on +// screen (the e-reader mode shows one), not every block from the book's start. +function getPositionAnchor(contentEl) { + const {blocks, chapterOf, chapters} = _epubBlocks(contentEl); if (!blocks.length) return ''; const containerTop = contentEl.getBoundingClientRect().top; const containerBottom = containerTop + contentEl.clientHeight; - let bestIndex = 0; - let bestDelta = Infinity; + // Index ranges of the blocks in chapters that are laid out (all, when reading by scrolling). + const shown = new Set(); + chapters.forEach((ch, i) => { if (ch.getClientRects().length) shown.add(i); }); + const order = []; for (let i = 0; i < blocks.length; i++) { - const rect = blocks[i].getBoundingClientRect(); - if (rect.height < 1) continue; - if (rect.top > containerBottom) break; - const delta = rect.top - containerTop; - if (delta <= 0 && Math.abs(delta) < Math.abs(bestDelta)) { - bestIndex = i; - bestDelta = delta; - } else if (delta > 0 && bestDelta === Infinity) { - bestIndex = i; - bestDelta = delta; + if (chapterOf[i] < 0 || shown.has(chapterOf[i])) { + let j = i; + while (j + 1 < blocks.length && chapterOf[j + 1] === chapterOf[i]) j++; + order.push([i, j]); + i = j; } } + const count = order.reduce((n, r) => n + r[1] - r[0] + 1, 0); + const at = k => { // k-th block on screen -> block index + for (const [a, b] of order) { if (k <= b - a) return a + k; k -= b - a + 1; } + return -1; + }; + const rectOf = k => blocks[at(k)].getBoundingClientRect(); + // A block without height says nothing about the position: use the next one with a height. + const measured = k => { + for (let n = k; n < count; n++) { const r = rectOf(n); if (r.height >= 1) return {k: n, top: r.top}; } + return null; + }; + // Binary search for the last block with a height whose top <= containerTop. + let lo = 0, hi = count - 1, best = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const m = measured(mid); + if (m && m.top <= containerTop) { best = m.k; lo = m.k + 1; } + else hi = mid - 1; + } + let bestIndex; + if (best >= 0) { + // Several blocks starting at the same height (a blockquote and its first paragraph): the first. + const top = rectOf(best).top; + let k = best; + for (let n = best - 1; n >= 0; n--) { + const r = rectOf(n); + if (r.height < 1) continue; + if (r.top !== top) break; + k = n; + } + bestIndex = at(k); + } else { + const first = measured(0); + bestIndex = first && first.top <= containerBottom ? at(first.k) : 0; + } const rect = blocks[bestIndex].getBoundingClientRect(); const innerFraction = Math.max(0, Math.min(1, (containerTop - rect.top) / (rect.height || 1))); return `${bestIndex}:${innerFraction.toFixed(6)}`; diff --git a/static/js/sw.js b/static/js/sw.js index fba4890..853f5fb 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v97'; +const CACHE = 'diora-v98'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', From fda6c0194351821eb5c7f4c94aa5fa43c11e6dd5 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Thu, 8 Oct 2026 19:22:04 +0200 Subject: [PATCH 081/160] Einstellungen: Cloud-Verbindung bearbeiten (Name, URL, Benutzer, Unterordner, Passwort); sw v99 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher ließ sich eine Verbindung nur anlegen und löschen, der Startordner also nicht nachträglich setzen. Das Passwort wird nicht angezeigt; leer lassen behält das gespeicherte. Co-Authored-By: Claude Opus 5.5 --- accounts/tests.py | 48 ++++++++++++++++++++++++++++++++ accounts/urls.py | 1 + accounts/views.py | 29 +++++++++++++++++++ static/css/app.css | 9 ++++++ static/js/sw.js | 2 +- templates/accounts/settings.html | 27 ++++++++++++++++++ 6 files changed, 115 insertions(+), 1 deletion(-) diff --git a/accounts/tests.py b/accounts/tests.py index 35d015e..8eb3201 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -546,3 +546,51 @@ class CloudFetchCachingTests(TestCase): with patch('books.webdav.fetch_file', return_value=b'EPUB-BYTES'): resp = self.client.get(f'/books/cloud/{self.source.pk}/fetch/?path=Dune.epub') self.assertEqual(resp['Cache-Control'], 'no-store') + + +class WebDAVEditTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.other = User.objects.create_user(username='bob', password='pw12345678') + self.source = WebDAVSource.objects.create( + user=self.user, label='cloud', base_url='https://cloud.example.com', + username='alice', password='app-pw', + ) + + def _edit(self, pk, **data): + form = {'label': 'cloud', 'base_url': 'https://cloud.example.com', 'username': 'alice', 'root_path': ''} + form.update(data) + with patch('accounts.views.list_directory', return_value=[]): + return self.client.post(f'/accounts/webdav/{pk}/edit/', form) + + def test_sets_start_folder_and_keeps_password(self): + self.client.force_login(self.user) + resp = self._edit(self.source.pk, root_path='Books', password='') + self.assertEqual(resp.status_code, 302) + self.source.refresh_from_db() + self.assertEqual(self.source.root_path, 'Books') + self.assertEqual(self.source.password, 'app-pw') + + def test_new_password_replaces_the_old_one(self): + self.client.force_login(self.user) + self._edit(self.source.pk, password='new-pw') + self.source.refresh_from_db() + self.assertEqual(self.source.password, 'new-pw') + + def test_name_and_url_are_required(self): + self.client.force_login(self.user) + self._edit(self.source.pk, label='', root_path='Books') + self.source.refresh_from_db() + self.assertEqual(self.source.root_path, '') + + def test_foreign_source_is_not_editable(self): + self.client.force_login(self.other) + self._edit(self.source.pk, root_path='x') + self.source.refresh_from_db() + self.assertEqual(self.source.root_path, '') + + def test_settings_page_does_not_show_the_password(self): + self.client.force_login(self.user) + html = self.client.get('/accounts/settings/').content.decode() + self.assertIn(f'/accounts/webdav/{self.source.pk}/edit/', html) + self.assertNotIn('app-pw', html) diff --git a/accounts/urls.py b/accounts/urls.py index 4bee71f..f569262 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path('change-password/', views.change_password, name='change_password'), path('api-token/regenerate/', views.regenerate_api_token, name='regenerate_api_token'), path('webdav/add/', views.webdav_add, name='webdav_add'), + path('webdav//edit/', views.webdav_edit, name='webdav_edit'), path('webdav//test/', views.webdav_test, name='webdav_test'), path('webdav//delete/', views.webdav_delete, name='webdav_delete'), ] diff --git a/accounts/views.py b/accounts/views.py index 30878ff..78ee9d7 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -290,6 +290,35 @@ def webdav_add(request): return redirect('settings') +@login_required +@require_http_methods(['POST']) +def webdav_edit(request, pk): + source = WebDAVSource.objects.filter(pk=pk, user=request.user).first() + if not source: + messages.error(request, 'Verbindung nicht gefunden.') + return redirect('settings') + + label = request.POST.get('label', '').strip() + base_url = request.POST.get('base_url', '').strip() + if not label or not base_url: + messages.error(request, 'Name und Server-URL sind erforderlich.') + return redirect('settings') + + source.label = label[:100] + source.base_url = base_url[:500] + source.username = request.POST.get('username', '').strip()[:200] + source.root_path = request.POST.get('root_path', '').strip()[:500] + # The stored password is never sent to the page: empty means keep it. + password = request.POST.get('password', '') + if password: + source.password = password[:500] + source.save() + + level, message = _probe_source(source) + messages.add_message(request, level, message) + return redirect('settings') + + @login_required @require_http_methods(['POST']) def webdav_test(request, pk): diff --git a/static/css/app.css b/static/css/app.css index a2b225f..36e4a4b 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -826,6 +826,15 @@ a:hover { align-self: flex-start; } +/* Editing a saved connection: a full row under its name and buttons */ +.webdav-edit { + flex-basis: 100%; + padding-top: 0.75rem; +} +.webdav-edit[hidden] { + display: none; +} + /* --- Cloud import browser (books tab) --- */ .cloud-browser { diff --git a/static/js/sw.js b/static/js/sw.js index 853f5fb..4d369e7 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v98'; +const CACHE = 'diora-v99'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', diff --git a/templates/accounts/settings.html b/templates/accounts/settings.html index 702cbcf..c2d17f7 100644 --- a/templates/accounts/settings.html +++ b/templates/accounts/settings.html @@ -150,6 +150,8 @@ {{ source.base_url }}{% if source.root_path %} · /{{ source.root_path }}{% endif %}
+
{% csrf_token %} @@ -160,6 +162,31 @@
+ {% endfor %} From 60dd4926cff2f16936a4891fb466a9afc0a0ce8d Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 12 Oct 2026 19:31:36 +0200 Subject: [PATCH 082/160] Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop. - Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht. Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben. /books/ mit X-Server-Now, /api/sync/ mit server_now. - Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert. Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat. Angebote "Auf weitergelesen" und "Weiteste Stelle". - Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste Stelle, Liste und Snapshot). Co-Authored-By: Claude Opus 5.5 --- accounts/sync.py | 12 +- accounts/tests.py | 2 +- .../0005_progress_last_read_wins.py | 43 ++ books/models.py | 7 + books/tests.py | 162 ++++++++ books/views.py | 114 +++-- docs/progress-sync.md | 71 ++++ static/js/app.js | 391 ++++++++++++++---- static/js/sw.js | 2 +- 9 files changed, 693 insertions(+), 111 deletions(-) create mode 100644 books/migrations/0005_progress_last_read_wins.py create mode 100644 books/tests.py create mode 100644 docs/progress-sync.md diff --git a/accounts/sync.py b/accounts/sync.py index 5dccfb3..38a17d6 100644 --- a/accounts/sync.py +++ b/accounts/sync.py @@ -4,6 +4,7 @@ from django.utils import timezone from django.views.decorators.http import require_http_methods from books.models import EBook, EBookProgress, EBookHighlights, EBookBookmarks +from books.views import _now_ms, progress_state from podcasts.models import PodcastFeed, EpisodeProgress, PodcastQueue from radio.models import SavedStation @@ -33,11 +34,10 @@ def sync_snapshot(request): for b in books: b['uploaded_at'] = b['uploaded_at'].isoformat() - book_progress = list( - EBookProgress.objects.filter(user=user).values('book_id', 'scroll_fraction', 'position_anchor', 'updated_at') - ) - for p in book_progress: - p['updated_at'] = p['updated_at'].isoformat() + book_progress = [ + {'book_id': p.book_id, 'updated_at': p.updated_at.isoformat(), **progress_state(p)} + for p in EBookProgress.objects.filter(user=user) + ] book_highlights = list(EBookHighlights.objects.filter(user=user).values('book_id', 'ct', 'iv', 'updated_at')) for h in book_highlights: @@ -50,6 +50,7 @@ def sync_snapshot(request): if request.GET.get('only') == 'books': return JsonResponse({ 'server_time': timezone.now().isoformat(), + 'server_now': _now_ms(), 'books': books, 'book_progress': book_progress, 'book_highlights': book_highlights, @@ -84,6 +85,7 @@ def sync_snapshot(request): return JsonResponse({ 'server_time': timezone.now().isoformat(), + 'server_now': _now_ms(), 'books': books, 'book_progress': book_progress, 'book_highlights': book_highlights, diff --git a/accounts/tests.py b/accounts/tests.py index 8eb3201..6a068b8 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -88,7 +88,7 @@ class SyncSnapshotTests(TestCase): def test_only_books(self): resp = self.client.get('/api/sync/?only=books', HTTP_AUTHORIZATION=f'Bearer {self.token.token}') data = resp.json() - self.assertEqual(set(data), {'server_time', 'books', 'book_progress', 'book_highlights', 'book_bookmarks'}) + self.assertEqual(set(data), {'server_time', 'server_now', 'books', 'book_progress', 'book_highlights', 'book_bookmarks'}) self.assertEqual(data['book_highlights'][0]['ct'], 'hl-ct') def test_snapshot_shape_and_data(self): diff --git a/books/migrations/0005_progress_last_read_wins.py b/books/migrations/0005_progress_last_read_wins.py new file mode 100644 index 0000000..baf8ff9 --- /dev/null +++ b/books/migrations/0005_progress_last_read_wins.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +def keep_what_we_know(apps, schema_editor): + """The continue point so far was the furthest place: it is that, and it was + last saved at updated_at.""" + EBookProgress = apps.get_model('books', 'EBookProgress') + for p in EBookProgress.objects.all().iterator(): + p.furthest_fraction = p.scroll_fraction + p.furthest_anchor = p.position_anchor + p.read_at = int(p.updated_at.timestamp() * 1000) + p.save(update_fields=['furthest_fraction', 'furthest_anchor', 'read_at']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('books', '0004_ebook_is_read'), + ] + + operations = [ + migrations.AddField( + model_name='ebookprogress', + name='read_at', + field=models.BigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='ebookprogress', + name='device', + field=models.CharField(blank=True, default='', max_length=40), + ), + migrations.AddField( + model_name='ebookprogress', + name='furthest_fraction', + field=models.FloatField(default=0.0), + ), + migrations.AddField( + model_name='ebookprogress', + name='furthest_anchor', + field=models.CharField(blank=True, default='', max_length=30), + ), + migrations.RunPython(keep_what_we_know, migrations.RunPython.noop), + ] diff --git a/books/models.py b/books/models.py index 9bc06c7..ee1a91e 100644 --- a/books/models.py +++ b/books/models.py @@ -19,10 +19,17 @@ class EBook(models.Model): class EBookProgress(models.Model): + """Where to continue a book: the place it was last read, on any device + (docs/progress-sync.md), plus the furthest place ever reached.""" user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress') book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress') scroll_fraction = models.FloatField(default=0.0) position_anchor = models.CharField(max_length=30, blank=True, default='') + # When the reader was at that place (ms since epoch, server clock); null before 2026-10-12. + read_at = models.BigIntegerField(null=True, blank=True) + device = models.CharField(max_length=40, blank=True, default='') + furthest_fraction = models.FloatField(default=0.0) + furthest_anchor = models.CharField(max_length=30, blank=True, default='') updated_at = models.DateTimeField(auto_now=True) class Meta: diff --git a/books/tests.py b/books/tests.py new file mode 100644 index 0000000..93d9ac4 --- /dev/null +++ b/books/tests.py @@ -0,0 +1,162 @@ +"""The reading position: the place last read wins (docs/progress-sync.md).""" +import json +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase + +from accounts.models import ApiToken +from .models import EBook, EBookProgress + +NOW = 1_760_270_000_000 # the server's clock in these tests (ms) +MIN = 60_000 + + +class ProgressTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + self.auth = {'HTTP_AUTHORIZATION': f'Bearer {ApiToken.objects.create(user=self.user).token}'} + self.book = EBook.objects.create(user=self.user, meta_ct='m', meta_iv='i', data_ct='d', data_iv='i') + self.now = NOW + clock = patch('books.views._now_ms', side_effect=lambda: self.now) + clock.start() + self.addCleanup(clock.stop) + + def save(self, anchor, frac=None, read_at=None, sent_at=None, device='Handy', **extra): + body = {'scroll_fraction': frac if frac is not None else 0.1, 'position_anchor': anchor, **extra} + if read_at is not None: + body.update(read_at=read_at, sent_at=sent_at if sent_at is not None else self.now, device=device) + resp = self.client.post(f'/books/{self.book.id}/progress/', json.dumps(body), + content_type='application/json', **self.auth) + self.assertEqual(resp.status_code, 200) + return resp.json() + + def state(self): + return EBookProgress.objects.get(book=self.book) + + # -- the new rule + + def test_newer_place_wins_even_if_it_is_earlier_in_the_book(self): + self.save('300:0.5', read_at=NOW - 10 * MIN, device='Handy') + r = self.save('12:0.0', read_at=NOW - 1 * MIN, device='diora Desktop') + self.assertTrue(r['stored']) + p = self.state() + self.assertEqual((p.position_anchor, p.device, p.read_at), ('12:0.0', 'diora Desktop', NOW - MIN)) + self.assertEqual(p.furthest_anchor, '300:0.5') + + def test_an_older_write_arriving_late_does_not_win(self): + # The phone read offline at 10:00 and syncs at 12:00; the desktop read at 11:00. + self.save('40:0.0', read_at=NOW - 60 * MIN, device='diora Desktop') + r = self.save('90:0.0', read_at=NOW - 120 * MIN, device='Handy') + self.assertFalse(r['stored']) + self.assertEqual(r['position_anchor'], '40:0.0') + p = self.state() + self.assertEqual((p.position_anchor, p.device), ('40:0.0', 'diora Desktop')) + self.assertEqual(p.furthest_anchor, '90:0.0', 'the late write still counts as reached') + + def test_resaving_an_unchanged_place_keeps_its_time_and_cannot_win(self): + self.save('50:0.0', read_at=NOW - 5 * MIN, device='Handy') + self.save('70:0.0', read_at=NOW - 2 * MIN, device='diora Desktop') + # The phone, idle since, saves its place again on going to the background. + self.now += MIN + r = self.save('50:0.0', read_at=NOW - 5 * MIN, device='Handy') + self.assertFalse(r['stored']) + self.assertEqual(self.state().position_anchor, '70:0.0') + + def test_the_device_clock_error_is_taken_out(self): + # A tolino 3 hours behind: it read one minute ago by its clock, and sends now by its clock. + behind = 3 * 60 * MIN + self.save('10:0.0', read_at=NOW - 30 * MIN, device='Handy') + r = self.save('20:0.0', read_at=NOW - behind - MIN, sent_at=NOW - behind, device='tolino') + self.assertTrue(r['stored']) + self.assertEqual(self.state().read_at, NOW - MIN) + + def test_nothing_wins_from_the_future(self): + self.save('10:0.0', read_at=NOW + 24 * 60 * MIN, sent_at=NOW) + self.assertEqual(self.state().read_at, NOW) + self.now += MIN + self.assertTrue(self.save('11:0.0', read_at=self.now, sent_at=self.now)['stored']) + + def test_equal_times_take_the_new_write(self): + self.save('10:0.0', read_at=NOW - MIN) + self.assertTrue(self.save('11:0.0', read_at=NOW - MIN)['stored']) + + def test_force_sets_the_place_now(self): + self.save('300:0.0', read_at=NOW - MIN) + self.assertTrue(self.save('0:0.0', frac=0.0, force=True)['stored']) + p = self.state() + self.assertEqual((p.position_anchor, p.read_at), ('0:0.0', NOW)) + self.assertEqual(p.furthest_anchor, '300:0.0') + + def test_furthest_only_grows(self): + self.save('80:0.0', read_at=NOW - 3 * MIN) + self.save('20:0.0', read_at=NOW - 2 * MIN) + self.save('81:0.0', read_at=NOW - 9 * MIN) + p = self.state() + self.assertEqual((p.position_anchor, p.furthest_anchor), ('20:0.0', '81:0.0')) + + def test_pdfs_compare_by_fraction(self): + self.save('', frac=0.7, read_at=NOW - 2 * MIN) + self.save('', frac=0.3, read_at=NOW - MIN) + p = self.state() + self.assertEqual((p.scroll_fraction, p.furthest_fraction), (0.3, 0.7)) + + def test_response_is_the_state_after_the_write(self): + r = self.save('5:0.25', frac=0.05, read_at=NOW - MIN, device='Browser') + self.assertEqual(r['server_now'], NOW) + for key in ('scroll_fraction', 'position_anchor', 'read_at', 'device', + 'furthest_fraction', 'furthest_anchor', 'last_read'): + self.assertIn(key, r) + self.assertEqual(r['read_at'], NOW - MIN) + + def test_implausible_times_count_as_an_old_app(self): + self.save('10:0.0', read_at=NOW - MIN) + # read_at=True or seconds instead of ms are not times: only forward, like an old app. + self.assertFalse(self.save('5:0.0', read_at=True)['stored']) + self.assertFalse(self.save('5:0.0', read_at=1_760_270_000)['stored']) + + # -- apps from before the change (no read_at) + + def test_old_apps_only_move_forward(self): + self.save('50:0.0', read_at=NOW - MIN) + self.assertFalse(self.save('20:0.0')['stored']) + self.assertTrue(self.save('60:0.0')['stored']) + self.assertEqual(self.state().read_at, NOW) + + def test_old_apps_resaving_the_same_place_change_nothing(self): + self.save('50:0.0', read_at=NOW - 5 * MIN, device='Handy') + self.now += MIN + self.assertFalse(self.save('50:0.0')['stored']) + p = self.state() + self.assertEqual((p.read_at, p.device), (NOW - 5 * MIN, 'Handy')) + + def test_first_write_of_an_old_app_is_stored(self): + self.assertTrue(self.save('0:0.0', frac=0.0)['stored']) + + def test_old_force_still_works(self): + self.save('50:0.0', read_at=NOW - MIN) + self.assertTrue(self.save('1:0.0', force=True)['stored']) + + # -- reading + + def test_book_list_and_snapshot_carry_the_state(self): + self.save('7:0.5', frac=0.2, read_at=NOW - MIN, device='Browser') + resp = self.client.get('/books/', **self.auth) + b = resp.json()[0] + self.assertEqual(resp['X-Server-Now'], str(NOW)) + self.assertEqual((b['position_anchor'], b['read_at'], b['device'], b['furthest_anchor']), + ('7:0.5', NOW - MIN, 'Browser', '7:0.5')) + self.assertTrue(b['last_read'].startswith('2025-10-12T')) + snap = self.client.get('/api/sync/?only=books', **self.auth).json() + p = snap['book_progress'][0] + self.assertEqual((p['book_id'], p['read_at'], p['device']), (self.book.id, NOW - MIN, 'Browser')) + self.assertIn('server_now', snap) + + def test_rows_from_before_have_their_save_time_as_read_at(self): + p = EBookProgress.objects.create(user=self.user, book=self.book, scroll_fraction=0.4, position_anchor='9:0.0') + b = self.client.get('/books/', **self.auth).json()[0] + self.assertEqual(b['read_at'], int(p.updated_at.timestamp() * 1000)) + + def test_a_never_opened_book(self): + b = self.client.get('/books/', **self.auth).json()[0] + self.assertEqual((b['read_at'], b['last_read'], b['position_anchor']), (None, None, '')) diff --git a/books/views.py b/books/views.py index 121a604..41865ad 100644 --- a/books/views.py +++ b/books/views.py @@ -1,10 +1,13 @@ import base64 import json import re +import time import xml.etree.ElementTree as ET +from datetime import datetime, timezone as dt_timezone import requests from django.conf import settings +from django.db import transaction from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods @@ -247,11 +250,40 @@ def _progress_is_further(new_anchor, new_frac, old_anchor, old_frac): """True if the new reading position is at least as far into the book as the old one. Compares by anchor block index (precise, decoupled from layout); falls back to scroll_fraction only when an anchor is missing (PDFs).""" - nb, ni = _anchor_parts(new_anchor) - ob, oi = _anchor_parts(old_anchor) - if nb >= 0 and ob >= 0: - return ni >= oi if nb == ob else nb >= ob - return new_frac >= old_frac + return _cmp_progress(new_anchor, new_frac, old_anchor, old_frac) >= 0 + + +def _cmp_progress(a_anchor, a_frac, b_anchor, b_frac): + """> 0 if A is further into the book than B, < 0 if behind, 0 if level.""" + ab, ai = _anchor_parts(a_anchor) + bb, bi = _anchor_parts(b_anchor) + if ab >= 0 and bb >= 0: + if ab != bb: + return ab - bb + return (ai > bi) - (ai < bi) + return (a_frac > b_frac) - (a_frac < b_frac) + + +def _now_ms(): + return int(time.time() * 1000) + + +def progress_state(p): + """A book's reading state as every client sees it (docs/progress-sync.md); + p may be None (never opened).""" + if p is None: + return {'scroll_fraction': 0.0, 'position_anchor': '', 'read_at': None, 'device': '', + 'furthest_fraction': 0.0, 'furthest_anchor': '', 'last_read': None} + read_at = p.read_at if p.read_at is not None else int(p.updated_at.timestamp() * 1000) + return { + 'scroll_fraction': p.scroll_fraction, + 'position_anchor': p.position_anchor, + 'read_at': read_at, + 'device': p.device, + 'furthest_fraction': p.furthest_fraction, + 'furthest_anchor': p.furthest_anchor, + 'last_read': datetime.fromtimestamp(read_at / 1000, tz=dt_timezone.utc).isoformat(), + } @require_http_methods(['GET']) @@ -264,21 +296,17 @@ def book_list(request): ) for b in books: b['uploaded_at'] = b['uploaded_at'].isoformat() - # Include saved scroll_fraction for each book - progress_map = { - p.book_id: (p.scroll_fraction, p.updated_at, p.position_anchor) - for p in EBookProgress.objects.filter(user=request.user) - } + progress_map = {p.book_id: p for p in EBookProgress.objects.filter(user=request.user)} highlighted_ids = set( EBookHighlights.objects.filter(user=request.user).values_list('book_id', flat=True) ) for b in books: - prog = progress_map.get(b['id']) - b['scroll_fraction'] = prog[0] if prog else 0.0 - b['last_read'] = prog[1].isoformat() if prog else None - b['position_anchor'] = prog[2] if prog else '' + b.update(progress_state(progress_map.get(b['id']))) b['has_highlights'] = b['id'] in highlighted_ids - return JsonResponse(books, safe=False) + response = JsonResponse(books, safe=False) + # The list is a bare array (old clients expect that): the server clock goes in a header. + response['X-Server-Now'] = str(_now_ms()) + return response @csrf_exempt @@ -497,22 +525,50 @@ def save_progress(request, pk): if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor): position_anchor = raw_anchor - force = bool(body.get('force', False)) + force = body.get('force') is True + device = body.get('device') if isinstance(body.get('device'), str) else '' + now = _now_ms() + read_at = _client_ms(body.get('read_at')) + sent_at = _client_ms(body.get('sent_at')) - progress, _ = EBookProgress.objects.get_or_create( - user=request.user, - book=book, - ) - # Always advance to the furthest-read position (by anchor block index, so a - # transiently wrong scroll_fraction can't freeze the position) unless the - # client explicitly forces a reset (e.g. "start over" button in the reader). - if force or _progress_is_further(position_anchor, scroll_fraction, - progress.position_anchor, progress.scroll_fraction): - progress.scroll_fraction = scroll_fraction - progress.position_anchor = position_anchor - progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at']) + with transaction.atomic(): + progress, created = EBookProgress.objects.select_for_update().get_or_create(user=request.user, book=book) + if created: + progress.read_at = None + if force: + when, store = now, True + elif read_at is not None: + # The device's clock may be off: move its time onto ours by the + # difference at sending, and nothing counts as later than now. + when = read_at + (now - sent_at) if sent_at is not None else read_at + when = min(when, now) + store = progress.read_at is None or when >= progress.read_at + else: + # An app from before 2026-10-12: only forward, as it used to be — but + # strictly, so re-saving the same place changes nothing. + when = now + store = created or _cmp_progress(position_anchor, scroll_fraction, + progress.position_anchor, progress.scroll_fraction) > 0 + if store: + progress.scroll_fraction = scroll_fraction + progress.position_anchor = position_anchor + progress.read_at = when + progress.device = device[:40] + if _cmp_progress(position_anchor, scroll_fraction, + progress.furthest_anchor, progress.furthest_fraction) > 0 or created: + progress.furthest_fraction = scroll_fraction + progress.furthest_anchor = position_anchor + progress.save() - return JsonResponse({'ok': True}) + return JsonResponse({'ok': True, 'stored': store, 'server_now': now, **progress_state(progress)}) + + +def _client_ms(value): + """A time from a client (ms since epoch) or None if it isn't a plausible one.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + value = int(value) + return value if 10 ** 12 <= value < 10 ** 14 else None @csrf_exempt diff --git a/docs/progress-sync.md b/docs/progress-sync.md new file mode 100644 index 0000000..1cefc30 --- /dev/null +++ b/docs/progress-sync.md @@ -0,0 +1,71 @@ +# Reading position: the last place read wins + +Since 2026-10-12 the place to continue a book is **where it was last read, on any device**, not the +furthest place ever reached. The furthest place is still kept, as a hint ("Weiteste Stelle"). +This file is the contract between the server (books/views.py `save_progress`), the web reader +(static/js/app.js), diora-android (Ops.kt / SyncEngine.kt / native-*.js) and diora-desktop +(src/main/ops.js / sync.js). They must apply the same rules. + +## The state per book + +| field | meaning | +|---|---| +| `scroll_fraction`, `position_anchor` | the continue point (anchor `"block:inner"`, empty for PDFs) | +| `read_at` | when the reader was **at** that place, ms since epoch, **server clock** | +| `device` | who read there (`"Browser"`, `"diora Desktop"`, `"Handy"`, `"tolino"` …), for "auf X weitergelesen" | +| `furthest_fraction`, `furthest_anchor` | the furthest place ever reported (only ever grows) | + +`last_read` in `/books/` is `read_at` as ISO time (it used to be the time of the last save attempt). + +## Writing: `POST /books//progress/` + +```json +{"scroll_fraction": 0.42, "position_anchor": "187:0.62", "read_at": 1760270000000, + "sent_at": 1760270004000, "device": "diora Desktop"} +``` + +* `read_at` is **the moment the reader arrived at this position** (the last time the visible position + changed because of the reader), in the **device's** clock, not the moment of saving. Saving an + unchanged position again — every 10 s, on hide, on close, on unload — therefore carries the old time + and can never beat a newer position from another device. A device that only *shows* a position it + got from the server (just opened, nothing moved) reports the server's `read_at` back unchanged. +* `sent_at` is the device clock **when the request leaves** (an outbox sets it at flush time, not + when queuing). The server corrects the device's clock error with it: + `t = read_at + (server_now − sent_at)`, then `t = min(t, server_now)`. + So a tolino whose clock is hours off still orders correctly, and nothing can win "from the future". +* The server stores the position if `t >= stored read_at` (or nothing is stored, or `force`). + Older writes (an offline device catching up late) are **not** stored as the continue point. +* Every write — stored or not — raises `furthest_*` if it is further (same comparison as before: + anchor block, then inner fraction; `scroll_fraction` when an anchor is missing). +* `force: true` (the explicit "Diese Stelle als Fortschritt setzen") stores regardless, with `t = server_now`. +* **Old clients** (no `read_at`): stored only if **strictly further** than the stored position + (the old rule, but `>` so an idle old client re-saving its place changes nothing), with `t = server_now`. + This keeps not-yet-updated apps from pulling the continue point back. +* The response is the state after the write: + `{"ok": true, "stored": bool, "scroll_fraction", "position_anchor", "read_at", "device", + "furthest_fraction", "furthest_anchor", "server_now"}`. + +## Reading + +`/books/` and `/api/sync/` (`book_progress`) carry all fields of the state, `read_at` in ms (server clock), +plus `server_now` (ms) so a client can estimate its clock offset `server_now − device_now`. + +## Clients + +* **Local apply / outbox** (Android, desktop, web cache): the same rule as the server, in server time: + a local write converts its `read_at` with the last known offset (0 if none), stores it if newer + than or equal to what the local list holds. Two queued writes for the same book: keep the one with + the later `read_at` (force stays sticky). After a pull, a pending write is reapplied with that rule: + newer than the server's → it shows (and is sent); older → the server's position shows (it's still sent, + for `furthest`). +* **Never save before the position is restored** after opening a book, and never save a position the + reader didn't reach by reading: while a jump is tentative ("Zurück zum Text" after a footnote or link, + "Weit gescrollt — Zurückspringen"), the previous position and its `read_at` are what gets saved, until + the reader returns (nothing changes) or carries on reading there (then the jump target counts, with + its own time). +* **Opening a book** uses the newer of server state and local (not yet sent) state, by `read_at`. +* **Coming back to an open book** (window/app in front again, or after a pull): if the server holds a + newer position from another device than the one shown, offer it: "Auf weitergelesen — + Springen". Do not move the page without asking. +* If `furthest_*` is clearly ahead of the continue point when a book opens, offer + "Weiteste Stelle: 63 % — Springen". diff --git a/static/js/app.js b/static/js/app.js index a094a04..e7ac60e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -287,7 +287,8 @@ window.addEventListener('beforeunload', () => { } // Flush cached encrypted payloads for reader data (encryption is async so we // use pre-computed blobs stored by the debounced savers) - if (_lastProgressBeacon) navigator.sendBeacon(_lastProgressBeacon.url, _lastProgressBeacon.body); + // The last position written (it's in the outbox too, in case the beacon is lost). + if (_lastProgressBeacon) navigator.sendBeacon(`/books/${_lastProgressBeacon.book}/progress/`, _progressBody(_lastProgressBeacon)); if (_lastBookmarkBeacon) navigator.sendBeacon(_lastBookmarkBeacon.url, _lastBookmarkBeacon.body); if (_lastHighlightBeacon) navigator.sendBeacon(_lastHighlightBeacon.url, _lastHighlightBeacon.body); }); @@ -298,6 +299,8 @@ document.addEventListener('visibilitychange', () => { if (highlightsDirty) saveHighlights(); saveReaderProgress(); } + // Back in front: maybe the book was read on another device meanwhile. + if (document.visibilityState === 'visible' && currentBookId) _checkNewerPositionElsewhere(); }); // Cached beacon payloads — updated after each successful encrypt in save functions @@ -2080,6 +2083,7 @@ function _dismissScrollJumpToast() { _scrollJumpToastEl.remove(); _scrollJumpToastEl = null; } + if (_readPos && _readPos.hold === 'jump') _readPos.hold = false; } function _showScrollJumpToast(backTop) { @@ -2089,6 +2093,8 @@ function _showScrollJumpToast(backTop) { toast.innerHTML = `Weit gescrollt`; document.body.appendChild(toast); _scrollJumpToastEl = toast; + // Until it's gone, the place before the jump stays the reading position. + if (_readPos && !_readPos.hold) _readPos.hold = 'jump'; toast.querySelector('.reader-toast-btn').addEventListener('click', () => { const contentEl = $('reader-content'); if (contentEl) { @@ -2096,8 +2102,16 @@ function _showScrollJumpToast(backTop) { contentEl.scrollTo({top: backTop, behavior: 'smooth'}); } _dismissScrollJumpToast(); + // Back where it was: that's no new reading. + _readerInteractedAt = 0; }); - setTimeout(_dismissScrollJumpToast, 6000); + setTimeout(() => { + if (_scrollJumpToastEl !== toast) return; + _dismissScrollJumpToast(); + // Stayed there: the far place is where the reading goes on. + _noteReaderInteraction(); + if (_readPos) _readPos.key = null; + }, 6000); } function _checkScrollJump(contentEl) { @@ -2294,6 +2308,203 @@ function _cmpProgress(anchorA, fracA, anchorB, fracB) { return (fracA || 0) - (fracB || 0); } +// --------------------------------------------------------------------------- +// Reading position: the place last read wins, on any device (docs/progress-sync.md). +// +// A position counts as read when the reader got there by reading (scrolling, +// turning pages, choosing a chapter); its time is that moment, not the time it +// is saved. So re-saving an unchanged place, on a timer or on closing, carries +// the old time and can never beat a newer place read on another device. A book +// just opened at the server's place has read nothing here yet: nothing is sent. +// Own writes wait in a small outbox (localStorage) until the server has them. +// --------------------------------------------------------------------------- +let _serverOffset = 0; // the server's clock minus this device's (ms) +let _readPos = null; // the open book: {bookId, ready, key, readAt (device ms), moved, hold} +let _readerInteractedAt = 0; + +function _progressDevice() { + return window.DIORA_DEVICE_NAME || 'Browser'; +} + +function _noteServerNow(ms) { + if (typeof ms === 'number' && ms > 1e12) _serverOffset = ms - Date.now(); +} + +function _noteServerNowHeader(res) { + try { _noteServerNow(Number(res.headers.get('X-Server-Now'))); } catch (e) {} +} + +// The reader did something (scroll wheel, touch, key, mouse): the next change +// of position is reading, not a layout settling. +function _noteReaderInteraction() { + _readerInteractedAt = Date.now(); + if (_readPos && _readPos.hold === 'until-moved') _readPos.hold = false; +} +window.__dioraReaderInteraction = _noteReaderInteraction; + +// While a jump is only a look (a footnote's place, "Zurück zum Text"), the +// place before it stays the reading position. true: until the next +// interaction; false: released now. +window.__dioraHoldProgress = function (on) { + if (_readPos) _readPos.hold = on ? 'until-moved' : false; +}; + +function _positionKey(anchor, fraction) { + return anchor || 'f' + (fraction || 0).toFixed(4); +} + +// Called with every computed position: a change that follows an interaction is reading. +function _noteReadingPosition(anchor, fraction) { + if (!_readPos || !_readPos.ready || _readPos.hold) return; + const key = _positionKey(anchor, fraction); + if (key === _readPos.key) return; + _readPos.key = key; + if (Date.now() - _readerInteractedAt < 10000) { + _readPos.readAt = Date.now(); + _readPos.moved = true; + } +} + +function _progressOutboxKey() { + return `diora_progress_outbox_${window.USER_ID || 0}`; +} + +function _progressOutbox() { + try { return JSON.parse(localStorage.getItem(_progressOutboxKey()) || '{}') || {}; } catch (e) { return {}; } +} + +function _progressOutboxPut(entry) { + try { + const box = _progressOutbox(); + const old = box[entry.book]; + if (old && !entry.force && old.read_at > entry.read_at) return; + box[entry.book] = entry; + localStorage.setItem(_progressOutboxKey(), JSON.stringify(box)); + } catch (e) {} +} + +function _progressOutboxDone(bookId, readAt) { + try { + const box = _progressOutbox(); + if (box[bookId] && box[bookId].read_at === readAt) { + delete box[bookId]; + localStorage.setItem(_progressOutboxKey(), JSON.stringify(box)); + } + } catch (e) {} +} + +function _progressBody(e) { + return JSON.stringify({ + scroll_fraction: e.fraction, position_anchor: e.anchor, read_at: e.read_at, + sent_at: Date.now(), device: e.device, ...(e.force && {force: true}), + }); +} + +// Send one queued position; true once the server has it (stored or not: it decides). +async function _sendProgress(e) { + try { + const res = await fetch(`/books/${e.book}/progress/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: _progressBody(e), + }); + if (!(res.ok || (res.status >= 400 && res.status < 500))) return false; + try { _noteServerNow((await res.json()).server_now); } catch (err) {} + _progressOutboxDone(e.book, e.read_at); + return true; + } catch (err) { + return false; + } +} + +let _progressFlushing = null; +function _flushProgressOutbox() { + if (!_progressFlushing) { + _progressFlushing = (async () => { + for (const e of Object.values(_progressOutbox())) await _sendProgress(e); + })().finally(() => { _progressFlushing = null; }); + } + return _progressFlushing; +} + +// A book's state from the server list, made newer by an own write the server doesn't have yet. +function _withPendingProgress(b) { + const e = _progressOutbox()[b.id]; + if (!e) return b; + const at = e.read_at + _serverOffset; + if (b.read_at != null && at < b.read_at && !e.force) return b; + return {...b, scroll_fraction: e.fraction, position_anchor: e.anchor, read_at: at, + device: e.device, last_read: new Date(at).toISOString()}; +} + +function _bookRecency(b) { + if (typeof b.read_at === 'number') return b.read_at; + const t = b.last_read ? Date.parse(b.last_read) : NaN; + return isNaN(t) ? 0 : t; +} + +function _readerToast(text, button, onClick, ms) { + const toast = document.createElement('div'); + toast.className = 'reader-toast reader-toast-action'; + toast.innerHTML = ''; + toast.querySelector('span').textContent = text; + toast.querySelector('button').textContent = button; + document.body.appendChild(toast); + const done = () => toast.remove(); + toast.querySelector('button').addEventListener('click', () => { done(); onClick(); }); + setTimeout(done, ms || 10000); + return toast; +} + +function _jumpToPosition(anchor, fraction) { + const contentEl = $('reader-content'); + if (!contentEl) return; + _suppressScrollJumpDetect(); + if (currentPdfDoc && readerSettings.pdfPaginated && pdfTotalPages > 1) { + pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); + enterPdfPaginatedMode(); + } else if (currentPdfDoc) { + contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); + } else if (!restoreFromAnchor(contentEl, anchor)) { + restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction)); + } +} + +// Another device read this book after the place shown here: offer to go there. +let _newerOfferShown = null; +async function _checkNewerPositionElsewhere() { + const pos = _readPos; + if (!pos || !pos.ready || !currentBookId || pos.bookId !== currentBookId) return; + let b; + try { + const res = await fetch('/books/', {cache: 'no-store'}); + _noteServerNowHeader(res); + b = (await res.json()).find(x => x.id === pos.bookId); + } catch (e) { return; } + if (!b || b.read_at == null || _readPos !== pos) return; + if (b.read_at <= pos.readAt + _serverOffset + 3000) return; + if (_positionKey(b.position_anchor || '', b.scroll_fraction) === pos.key) return; + const offerKey = `${pos.bookId}:${b.read_at}`; + if (_newerOfferShown === offerKey) return; + _newerOfferShown = offerKey; + const where = b.device ? `Auf ${b.device}` : 'Auf einem anderen Gerät'; + _readerToast(`${where} weitergelesen · ${_formatBookPercent(b.scroll_fraction || 0)} %`, 'Dorthin', () => { + if (_readPos !== pos) return; + _jumpToPosition(b.position_anchor || '', b.scroll_fraction || 0); + // That place and its time are the server's: nothing new to tell it. + pos.key = null; + pos.readAt = b.read_at - _serverOffset; + pos.moved = false; + setTimeout(() => { + const contentEl = $('reader-content'); + if (_readPos === pos && contentEl) { + pos.key = currentPdfDoc ? _positionKey('', b.scroll_fraction || 0) : _positionKey(getPositionAnchor(contentEl), 0); + } + }, 300); + }, 15000); +} +window.__dioraCheckNewerPosition = _checkNewerPositionElsewhere; + // --------------------------------------------------------------------------- // Book cache — IndexedDB, evict after 4 weeks of inactivity // --------------------------------------------------------------------------- @@ -2497,7 +2708,8 @@ async function _loadCachedBookMeta() { } catch { return []; } } -async function _updateCachedBookProgress(bookId, fraction, anchor, force = false) { +async function _updateCachedBookProgress(bookId, fraction, anchor, force = false, readAt = null) { + const at = (readAt == null ? Date.now() : readAt) + _serverOffset; try { const db = await _openBookCacheDb(); await new Promise(resolve => { @@ -2506,11 +2718,17 @@ async function _updateCachedBookProgress(bookId, fraction, anchor, force = false const req = store.get(bookId); req.onsuccess = e => { const entry = e.target.result; - // Only advance (or on force), so a transiently wrong save can't lose progress. - if (entry && (force || _cmpProgress(anchor, fraction, entry.position_anchor, entry.scroll_fraction) >= 0)) { + // The place read last, as on the server (docs/progress-sync.md). + if (entry && (force || entry.read_at == null || at >= entry.read_at)) { entry.scroll_fraction = fraction; entry.position_anchor = anchor; - entry.last_read = new Date().toISOString(); + entry.read_at = at; + entry.device = _progressDevice(); + entry.last_read = new Date(at).toISOString(); + if (_cmpProgress(anchor, fraction, entry.furthest_anchor || '', entry.furthest_fraction || 0) > 0) { + entry.furthest_anchor = anchor; + entry.furthest_fraction = fraction; + } store.put(entry); } }; @@ -2641,7 +2859,9 @@ async function loadBookList() { try { listEl.innerHTML = '

Bücherliste wird vom Server geholt …

'; + _flushProgressOutbox(); // own positions the server doesn't have yet const res = await fetch('/books/', {cache: 'no-store'}); + _noteServerNowHeader(res); if (!res.ok) { listEl.innerHTML = `

Serverfehler ${res.status} beim Laden der Bücher.

`; return; @@ -2673,48 +2893,23 @@ async function loadBookList() { const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const meta = JSON.parse(new TextDecoder().decode(metaBuf)); bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0}; - decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); + decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } catch (e) { bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; - decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); - } - } - // If local cache is further ahead than the server, push it and use it for display. - // Server uses max-semantics so this is always safe. - const _localMeta = await _loadCachedBookMeta(); - const _localById = Object.fromEntries(_localMeta.map(b => [b.id, b])); - for (const b of decrypted) { - const loc = _localById[b.id]; - if (loc && _cmpProgress(loc.position_anchor, loc.scroll_fraction, b.position_anchor, b.scroll_fraction) > 0) { - fetch(`/books/${b.id}/progress/`, { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: JSON.stringify({scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor || ''}), - }).catch(() => {}); - b.scroll_fraction = loc.scroll_fraction; - b.position_anchor = loc.position_anchor; - b.last_read = loc.last_read; + decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } } + // An own position the server doesn't have yet (sent above) shows if it's the newer one. + for (let i = 0; i < decrypted.length; i++) decrypted[i] = _withPendingProgress(decrypted[i]); - decrypted.sort((a, b) => { - if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read); - if (a.last_read) return -1; - if (b.last_read) return 1; - return b.uploaded_at.localeCompare(a.uploaded_at); - }); + decrypted.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || b.uploaded_at.localeCompare(a.uploaded_at)); _saveBookMeta(decrypted); // persist for offline renderBookList(decrypted); } catch (e) { // Network error — try offline fallback - const cachedBooks = await _loadCachedBookMeta(); + const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress); if (cachedBooks.length) { - cachedBooks.sort((a, b) => { - if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read); - if (a.last_read) return -1; - if (b.last_read) return 1; - return (b.uploaded_at || '').localeCompare(a.uploaded_at || ''); - }); + cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || '')); for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0}; const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = 'none'; @@ -3623,6 +3818,8 @@ function _ttsClearHighlight() { // it into a DOM Range via the same char-offset addressing highlights use, and // wraps it in a . Returns the offset to resume searching from. function _ttsHighlightSentence(block, sentence, fromOffset) { + // Listening is reading: where the voice is counts as the reading position. + _noteReaderInteraction(); _ttsClearHighlight(); const full = block.textContent; const idx = full.indexOf(sentence, fromOffset); @@ -3840,7 +4037,6 @@ function exitReaderImmersiveMode() { // progress saved from that doubled DOM had block indices beyond the real book, // which then out-ranked every real position on the server. let _openBookInFlight = null; -let _repairProgressAnchor = false; // stored anchor didn't fit this book async function openBook(bookId) { if (_openBookInFlight) { @@ -3857,7 +4053,8 @@ async function openBook(bookId) { } async function _openBookOnce(bookId) { - _repairProgressAnchor = false; + // Nothing is saved for this book until its place is restored. + _readPos = {bookId, ready: false, key: null, readAt: 0, moved: false, hold: false}; const overlay = $('reader-overlay'); const contentEl = $('reader-content'); const titleEl = $('reader-title'); @@ -4020,21 +4217,25 @@ async function _openBookOnce(bookId) { // Restore scroll position — must happen BEFORE auto-save timer is started _suppressScrollJumpDetect(); + let openedProgress = null; try { let fraction = 0, anchor = ''; + let bookData = null; try { - const progressRes = await fetch('/books/'); + const progressRes = await fetch('/books/', {cache: 'no-store'}); + _noteServerNowHeader(progressRes); const allBooks = await progressRes.json(); - const bookData = allBooks.find(b => b.id === bookId); - fraction = bookData ? (bookData.scroll_fraction || 0) : 0; - anchor = bookData ? (bookData.position_anchor || '') : ''; + bookData = allBooks.find(b => b.id === bookId) || null; } catch { // offline — use cached meta const cached = await _loadCachedBookMeta(); - const bookData = cached.find(b => b.id === bookId); - fraction = bookData ? (bookData.scroll_fraction || 0) : 0; - anchor = bookData ? (bookData.position_anchor || '') : ''; + bookData = cached.find(b => b.id === bookId) || null; } + // The newer of the server's place and an own one it doesn't have yet. + if (bookData) bookData = _withPendingProgress(bookData); + fraction = bookData ? (bookData.scroll_fraction || 0) : 0; + anchor = bookData ? (bookData.position_anchor || '') : ''; + openedProgress = bookData; _currentPositionAnchor = anchor; if (isPdf && readerSettings.pdfPaginated) { if (fraction > 0 && pdfTotalPages > 1) @@ -4058,16 +4259,20 @@ async function _openBookOnce(bookId) { if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) { restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction)); } - // An anchor past the end of this book can only come from a broken - // render. The server keeps the furthest block index, so it would block - // every real position from now on: overwrite it with the next save. - const [storedBlock] = _anchorParts(anchor); - if (storedBlock >= contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR).length) { - _repairProgressAnchor = true; - } } } catch (e) {} + // From here on, what the reader does is reading. The place shown has the + // time it was read at (elsewhere, or here earlier): nothing new to send yet. + if (_readPos && _readPos.bookId === bookId && currentBookId === bookId) { + const shownAnchor = isPdf ? '' : getPositionAnchor(contentEl); + const readAtServer = openedProgress && openedProgress.read_at != null ? openedProgress.read_at : null; + _readPos.key = _positionKey(shownAnchor, openedProgress ? (openedProgress.scroll_fraction || 0) : 0); + _readPos.readAt = readAtServer == null ? 0 : readAtServer - _serverOffset; + _readPos.ready = true; + _offerFurthestPosition(openedProgress); + } + // Prime the "settled" baseline to wherever we just restored to, rather // than waiting to lazily pick up whatever scroll event happens to fire // first — that could otherwise be a later, unrelated layout correction. @@ -4110,6 +4315,7 @@ async function _openBookOnce(bookId) { _anchorTrackDebounce = setTimeout(() => { _currentPositionAnchor = getPositionAnchor(contentEl); const f = _fractionFromAnchor(contentEl, _currentPositionAnchor); + _noteReadingPosition(_currentPositionAnchor, f === null ? 0 : f); if (progressInput && f !== null && document.activeElement !== progressInput) { progressInput.value = _formatBookPercent(f); } @@ -4205,6 +4411,19 @@ async function deleteBook(bookId) { } catch (e) {} } +// Opened somewhere before the furthest place reached: offer it. +function _offerFurthestPosition(p) { + if (!p) return; + const furthest = p.furthest_fraction || 0; + if (furthest - (p.scroll_fraction || 0) < 0.02 || furthest >= 0.999) return; + const pos = _readPos; + _readerToast(`Weiteste Stelle: ${_formatBookPercent(furthest)} %`, 'Dorthin', () => { + if (_readPos !== pos) return; + _noteReaderInteraction(); + _jumpToPosition(p.furthest_anchor || '', furthest); + }, 8000); +} + async function saveReaderProgress(force = false) { if (!currentBookId) return; const contentEl = $('reader-content'); @@ -4224,6 +4443,12 @@ async function saveReaderProgress(force = false) { return; } + // Not restored yet (or another book): what's shown isn't a place read. + const pos = _readPos; + if (!pos || !pos.ready || pos.bookId !== currentBookId) return; + // A jump that's only a look: the place before it stays. + if (pos.hold && !force) return; + let anchor = ''; if (!currentPdfDoc) { anchor = getPositionAnchor(contentEl); @@ -4234,27 +4459,30 @@ async function saveReaderProgress(force = false) { } fraction = Math.min(1.0, Math.max(0.0, fraction)); - _updateCachedBookProgress(currentBookId, fraction, anchor, force || (_repairProgressAnchor && !!anchor)); + _noteReadingPosition(anchor, fraction); + if (force) { + pos.readAt = Date.now(); + pos.moved = true; + } + // Nothing read here since opening: the server has this place already. + if (!pos.moved) return; - const repair = _repairProgressAnchor && !!anchor; - if (repair) _repairProgressAnchor = false; - const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...((force || repair) && {force: true})}); - _lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body}; - - try { - await fetch(`/books/${currentBookId}/progress/`, { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body, - }); - if (force) { - const toast = document.createElement('div'); - toast.className = 'reader-toast'; - toast.textContent = 'Position gesetzt'; - document.body.appendChild(toast); - setTimeout(() => toast.remove(), 1800); - } - } catch (e) {} + const entry = {book: currentBookId, fraction, anchor, read_at: pos.readAt, device: _progressDevice(), ...(force && {force: true})}; + const sig = `${entry.book}|${anchor}|${fraction.toFixed(5)}|${entry.read_at}`; + _lastProgressBeacon = entry; + if (!force && sig === pos.sentSig) return; // unchanged since the last save + pos.sentSig = sig; + _progressOutboxPut(entry); + _updateCachedBookProgress(currentBookId, fraction, anchor, force, pos.readAt); + const sent = await _sendProgress(entry); + if (!sent) pos.sentSig = null; // try again with the next save + if (sent && force) { + const toast = document.createElement('div'); + toast.className = 'reader-toast'; + toast.textContent = 'Position gesetzt'; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 1800); + } } function closeReader() { @@ -4262,6 +4490,7 @@ function closeReader() { stopReadAloud(); // Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none saveReaderProgress(); + _readPos = null; // nothing more is saved for this book if (bookmarksDirty) saveBookmarks(); if (highlightsDirty) saveHighlights(); @@ -6410,12 +6639,24 @@ function openRadioSidebar() { window.addEventListener('online', async () => { showOfflineBanner(false); await _syncPendingAnnotations(); - loadBookList(); // loadBookList pushes any locally-advanced progress automatically + loadBookList(); // sends the positions read offline first (the progress outbox) }); // Show banner immediately if already offline at startup if (!navigator.onLine) showOfflineBanner(true); + // Reading position: positions reached by the reader's own doing count (see _noteReadingPosition). + { + const content = $('reader-content'); + if (content) { + for (const type of ['wheel', 'touchstart', 'mousedown']) { + content.addEventListener(type, _noteReaderInteraction, {passive: true, capture: true}); + } + } + document.addEventListener('keydown', () => { if (currentBookId) _noteReaderInteraction(); }, true); + if (IS_AUTHENTICATED) _flushProgressOutbox(); + } + // Footnote hover (desktop) + tap (mobile) via event delegation const _readerContentEl = $('reader-content'); if (_readerContentEl) { diff --git a/static/js/sw.js b/static/js/sw.js index 4d369e7..97ced2b 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v99'; +const CACHE = 'diora-v100'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', From 080cdbde5afebb246b5c2b5b954f2c67b8bcdd88 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 12 Oct 2026 20:05:13 +0200 Subject: [PATCH 083/160] =?UTF-8?q?Podcast-Oberfl=C3=A4che=20aus=20diora-a?= =?UTF-8?q?ndroid=205821dfa=20(=5F=5FdioraPlayerNow=20f=C3=BCr=20den=20Bil?= =?UTF-8?q?dschirmschoner);=20sw=20v101?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- static/js/sw.js | 2 +- static/podcasts/native-podcast.js | 18 ++- static/podcasts/podcast-ui.css | 184 +++++++++++++++++++++++++++++- 3 files changed, 201 insertions(+), 3 deletions(-) diff --git a/static/js/sw.js b/static/js/sw.js index 97ced2b..2113fd9 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v100'; +const CACHE = 'diora-v101'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index 3a2ca2e..24ebfac 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 7dcc0c6 2026-10-04: web-native/static/native-podcast.js +// Copied from diora-android 5821dfa 2026-10-12: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. @@ -4405,6 +4405,22 @@ if (epScreen && (before.episodeId !== player.episodeId || !!before.playing !== !!player.playing)) episodeScreenParts(); }; + // What plays, for the screensaver (native-saver.js): the player's report and the chapter at its place. + const nowAsked = new Set(); + window.__dioraPlayerNow = function () { + if (!player || player.kind !== 'podcast') return player; + const d = details.get(player.episodeId); + if (!d && player.episodeId && !nowAsked.has(player.episodeId)) { + nowAsked.add(player.episodeId); + Promise.resolve(loadDetails(player.episodeId)).catch(() => {}); + } + let chapter = ''; + const chapters = (d && d.chapters) || []; + const t = (player.pos || 0) / 1000; + for (const c of chapters) if ((c.start || 0) <= t + 0.5) chapter = c.title || ''; + return Object.assign({}, player, {chapter: plain(chapter)}); + }; + // Ambient starts (app.js): an episode playing here pauses. window.__dioraPausePlayer = function () { if (player.kind !== 'podcast' || !player.playing || player.remote) return; diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 7422bc7..39a02cf 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -1,4 +1,4 @@ -/* Copied from diora-android 67a915e 2026-10-03: the podcast parts of web-native/static/native.css +/* Copied from diora-android 5821dfa 2026-10-12: the podcast parts of web-native/static/native.css (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here. The web's own additions are in podcasts-web.css. */ @@ -796,3 +796,185 @@ body.eink .nui-pod-facts a { text-decoration-color: #000; } background: rgba(0, 0, 0, 0.55); color: #ddd; } + +/* diora Reader (books only, native.js): no read-aloud, no cloud import. */ +.nui-reader-app #reader-tts-btn, +.nui-reader-app #reader-tts-lang, +.nui-reader-app button[onclick*="toggleCloudImport"], +.nui-reader-app #cloud-browser { display: none !important; } + +/* Datenverbrauch (native-ui.js showDataUsage) */ +.nui-usage { padding: 4px 16px 12px; } +.nui-usage-chips { display: flex; gap: 6px; margin-bottom: 12px; } +.nui-usage-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; } +.nui-usage-chip.on { border-color: var(--accent, #e63946); color: var(--accent, #e63946); } +.nui-usage-total { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; margin-bottom: 10px; } +.nui-usage-total b { color: var(--accent, #e63946); } +.nui-usage-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.nui-usage-table th { text-align: right; font-weight: 400; color: var(--nui-muted, #9a9a9a); padding: 2px 0 6px; } +.nui-usage-table td { padding: 7px 0; border-top: 1px solid var(--border, #2a2a2a); vertical-align: top; } +.nui-usage-table td + td { text-align: right; white-space: nowrap; padding-left: 10px; } +.nui-usage-bar { display: block; height: 3px; margin-top: 4px; background: #2a2a2a; border-radius: 2px; overflow: hidden; } +.nui-usage-bar > span { display: block; height: 100%; background: var(--accent, #e63946); } +.nui-usage-note { font-size: 12px; color: var(--nui-muted, #9a9a9a); margin: 10px 0 0; } +body.eink .nui-usage-bar > span { background: #000; } +body.eink .nui-usage-total b { color: #000; } + +/* ---- sidebar layout (html.nui-sidebar, native-radio.js): the menu as a rail on the left, + the player bars at the bottom of the page. Always on a TV, a choice in the app menu elsewhere. ---- */ + +.nui-rail-brand, .nui-rail-menu { display: none; } + +html.nui-sidebar { --nui-rail-w: 200px; } +html.nui-sidebar body { padding-left: var(--nui-rail-w); } +html.nui-sidebar.nui-reading body { padding-left: 0; } +html.nui-sidebar #nui-dock { left: var(--nui-rail-w); } +html.nui-sidebar #nui-tabbar { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: var(--nui-rail-w); + flex-direction: column; + padding: 18px 0 12px; + background: #0b0b0b; + border-right: 1px solid var(--border); + overflow-y: auto; +} +html.nui-sidebar #nui-tabbar button { + flex: 0 0 auto; + height: 46px; + flex-direction: row; + justify-content: flex-start; + gap: 14px; + padding: 0 22px; + font-size: 15px; + text-align: left; +} +html.nui-sidebar #nui-tabbar .nui-tab-icon { width: 22px; text-align: center; font-size: 19px; } +html.nui-sidebar #nui-tabbar button.nui-active { background: #181818; box-shadow: inset 3px 0 0 var(--accent); } +html.nui-sidebar .nui-rail-brand { + display: block; + padding: 0 22px 18px; + color: var(--accent); + font-weight: 700; + font-size: 22px; + letter-spacing: 0.08em; +} +html.nui-sidebar .nui-rail-menu { display: flex; margin-top: auto; } +/* Home's own wordmark: the rail has it already. */ +html.nui-sidebar #tab-home .home-brand { display: none; } +body.eink #nui-tabbar { background: #fff; } +/* A narrow screen (a phone upright): the rail shows its signs only. */ +@media (max-width: 640px) { + html.nui-sidebar { --nui-rail-w: 60px; } + html.nui-sidebar #nui-tabbar button { padding: 0; justify-content: center; font-size: 0; gap: 0; } + html.nui-sidebar #nui-tabbar .nui-tab-icon { font-size: 21px; } + html.nui-sidebar .nui-rail-brand { padding: 0 0 14px; text-align: center; font-size: 16px; letter-spacing: 0; overflow: hidden; } +} + +/* ---- television (html.nui-tv, native-tv.js): a focus that the remote moves ---- */ + +html.nui-tv { scroll-padding: 24px 0 calc(var(--nui-dock-h, 0px) + 24px); } +html.nui-tv .nui-tv-focus { + outline: 3px solid var(--accent) !important; + outline-offset: 2px; + border-radius: 6px; +} +html.nui-tv #nui-tabbar button.nui-tv-focus { outline-offset: -3px; background: #222; } +html.nui-tv :focus:not(.nui-tv-focus) { outline: none; } + +/* Books and feeds hidden on the TV (unless switched on in the app menu); Home's + sections of them: native-tv.js (:has, which the e-readers' old WebView lacks). */ +html.nui-noread #nui-tabbar button[data-tab="books"], +html.nui-noread #nui-tabbar button[data-tab="feeds"] { display: none; } + +/* ---- screensaver (native-saver.js): what plays, large, drifting slowly ---- */ + +#nui-saver { + position: fixed; + inset: 0; + z-index: 3000; + display: none; + overflow: hidden; + background: #000; + color: #fff; + cursor: none; +} +#nui-saver.nui-on { display: block; animation: nui-saver-in 1.2s ease-out; } +@keyframes nui-saver-in { from { opacity: 0; } to { opacity: 1; } } +.nui-saver-bg { + position: absolute; + inset: -10%; + background: center / cover no-repeat; + filter: blur(60px) brightness(0.32) saturate(1.3); +} +.nui-saver-stage { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 5vw; + padding: 8vh 10vw; + transition: transform 44s ease-in-out; +} +.nui-saver-art { + flex: 0 0 auto; + width: 34vmin; + height: 34vmin; + border-radius: 3vmin; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: #161616; + box-shadow: 0 2vmin 8vmin rgba(0, 0, 0, 0.6); +} +.nui-saver-art img { width: 100%; height: 100%; object-fit: cover; } +.nui-saver-art span { font-size: 16vmin; color: var(--accent); line-height: 1; } +.nui-saver-text { flex: 0 1 52vw; min-width: 0; display: flex; flex-direction: column; gap: 1.6vmin; } +.nui-saver-kicker, .nui-saver-sub, .nui-saver-chapter, .nui-saver-left { font-family: var(--nui-sans); letter-spacing: normal; } +.nui-saver-kicker { color: var(--accent); font-size: 2.6vmin; font-weight: 600; text-transform: uppercase; } +.nui-saver-title { + font-size: 5.4vmin; + font-weight: 700; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.nui-saver-sub { font-size: 3.4vmin; color: #ddd; line-height: 1.3; } +.nui-saver-chapter { font-size: 3vmin; color: var(--accent); } +.nui-saver-progress { height: 0.6vmin; border-radius: 1vmin; background: rgba(255, 255, 255, 0.18); overflow: hidden; margin-top: 1vmin; } +.nui-saver-progress span { display: block; height: 100%; background: var(--accent); transition: width 4s linear; } +.nui-saver-left { font-size: 2.6vmin; color: #bbb; } +#nui-saver [hidden] { display: none; } +.nui-saver-clock { + position: absolute; + right: 5vw; + bottom: 6vh; + font-size: 7vmin; + font-weight: 200; + color: rgba(255, 255, 255, 0.75); + font-variant-numeric: tabular-nums; + transition: transform 44s ease-in-out; +} +/* Ambient: its own picture full screen; the saver only adds the clock and the sound world. */ +html.nui-saver-ambient #tab-ambient { display: block !important; } +html.nui-saver-ambient #ambient-frame { + position: fixed !important; + inset: 0; + width: 100vw !important; + height: 100vh !important; + z-index: 2999; + border: 0; +} +html.nui-saver-ambient #nui-saver { background: transparent; } +html.nui-saver-ambient .nui-saver-bg, +html.nui-saver-ambient .nui-saver-art, +html.nui-saver-ambient .nui-saver-progress, +html.nui-saver-ambient .nui-saver-left { display: none; } +html.nui-saver-ambient .nui-saver-stage { align-items: flex-start; justify-content: flex-start; } +html.nui-saver-ambient .nui-saver-title { font-size: 3.4vmin; font-weight: 300; letter-spacing: 0.1em; } From 884a18e8ef7aa46b5d8ecd301fbc1486fc35fd20 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Wed, 14 Oct 2026 18:02:33 +0200 Subject: [PATCH 084/160] =?UTF-8?q?Hell=20und=20Dunkel=20(docs/theme.md),?= =?UTF-8?q?=20Vorlese-Schnittstelle=20f=C3=BCr=20die=20Playerleiste;=20sw?= =?UTF-8?q?=20v102?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Farbvariablen für beide Schemata in app.css (--bg --fg --muted --card --panel --hover --raised --border --border-strong --accent --on-accent --scrim --shadow), Hell als html[data-theme=light]. Umschalter im Kopf jeder Seite (base.html, vor dem ersten Zeichnen): Dunkel / Hell / Automatisch, ☾☀◐ in der Navigation; __dioraTheme / __dioraSetTheme / Ereignis diora-theme. - Reader: neues Standard-Theme "Wie die App" (Dunkel/Hell folgt der App). - ◑ (Kontrast zum Hintergrundbild) nur noch mit Hintergrundbild. - home.js auf die Variablen. - window.__dioraTts (state/toggle/stop/next/prev) und Ereignis diora-tts für die Playerleiste; mit __dioraTtsDock keine eigene Vorlese-Leiste mehr. - Reader-Hinweise über der Playerleiste, wenn sie im Reader steht. Co-Authored-By: Claude Opus 5.5 --- docs/theme.md | 44 +++++++++++++++ static/css/app.css | 100 +++++++++++++++++++++++---------- static/js/app.js | 133 +++++++++++++++++++++++++++++++++++++++++--- static/js/home.js | 24 ++++---- static/js/sw.js | 2 +- templates/base.html | 47 +++++++++++++++- 6 files changed, 298 insertions(+), 52 deletions(-) create mode 100644 docs/theme.md diff --git a/docs/theme.md b/docs/theme.md new file mode 100644 index 0000000..4126c77 --- /dev/null +++ b/docs/theme.md @@ -0,0 +1,44 @@ +# Light and dark + +diora has two colour schemes, **dark** (the old look) and **light**, chosen by the reader: +"Dunkel", "Hell" or "Automatisch" (as the operating system says). This file is the contract +between diora-web (app.css, home.js, templates), diora-android (native.css, native-*.js, +podcast-ui.css, feeds.js) and diora-desktop (desktop.css). + +## Switching + +* `` is the scheme in effect. Set as early as possible + (an inline script in base.html's ``, before any CSS paints) so nothing flashes. +* The choice is `localStorage.diora_theme` = `"dark"` (default) | `"light"` | `"auto"`. +* `window.__dioraTheme()` → the choice; `window.__dioraSetTheme(choice)` sets and applies it; + `window.__dioraThemeChanged` (event `diora-theme` on `window`, `detail` = the scheme in + effect) lets native code follow (Android status/navigation bars, Electron's window colour). +* "auto" follows `prefers-color-scheme` live. +* E-ink mode (`html.nui-eink`) keeps its own black-on-white rules and ignores this. +* The reader has its own themes; its default is now "Wie die App" (`readerSettings.theme = 'app'`): + dark → reader dark, light → reader bright. Sepia, Dunkel and Hell stay choosable. +* A wallpaper (the radio page's background image) keeps its own contrast handling + (`body.bright-bg`, the ◑ button); without a wallpaper the scheme decides. + +## Colours: only through these variables + +| variable | dark | light | for | +|---|---|---|---| +| `--bg` | `#000` | `#f6f5f1` | the page | +| `--fg` | `#fff` | `#17171a` | text | +| `--muted` | `#9a9a9a` | `#66666b` | secondary text, icons | +| `--card` | `#0b0b0b` | `#ffffff` | cards, rows that stand out | +| `--panel` | `#111` | `#ffffff` | sheets, menus, docks, bars | +| `--hover` | `#141414` | `#ecebe6` | hover / pressed | +| `--raised` | `#1a1a1a` | `#e4e2dc` | inputs, tracks of sliders, chips | +| `--border` | `#2a2a2a` | `#d9d6ce` | lines | +| `--border-strong` | `#444` | `#b4b0a6` | lines that must show (inputs, focus off) | +| `--accent` | `#e63946` | `#d0263a` | the red: brand, active, progress | +| `--on-accent` | `#fff` | `#fff` | text on the red | +| `--scrim` | `rgba(0,0,0,.6)` | `rgba(20,20,20,.35)` | behind sheets | +| `--shadow` | `none` | `0 1px 3px rgba(0,0,0,.08)` | cards in light (no lines needed in dark) | + +Old names stay as aliases: `--nui-muted: var(--muted)`, `--nui-panel: var(--panel)`, +`--text: var(--fg)`, `--text-muted: var(--muted)`, `--bg-card: var(--card)`. +Images, album art, covers and Ambient's own picture keep their colours. Status colours +(`--green`, `--yellow`, `--red`) may stay as they are. diff --git a/static/css/app.css b/static/css/app.css index 36e4a4b..5689138 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -47,13 +47,13 @@ html, body { height: 100%; overflow: hidden; - scrollbar-color: #333 #000; + scrollbar-color: var(--raised) var(--bg); } ::-webkit-scrollbar { width: 8px; height: 8px; } -::-webkit-scrollbar-track { background: #000; } -::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; } -::-webkit-scrollbar-thumb:hover { background: #555; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--raised); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--border-strong); } :root { --bg: #000; @@ -77,6 +77,42 @@ html, body { --outline: #000; --text: var(--fg); --text-muted: var(--fg); + + /* The colour scheme (docs/theme.md): dark here, light below. Use only these. */ + --muted: #9a9a9a; + --card: #0b0b0b; + --panel: #111; + --hover: #141414; + --raised: #1a1a1a; + --border-strong: #444; + --on-accent: #fff; + --scrim: rgba(0, 0, 0, 0.6); + --shadow: none; + --surface: var(--panel); + color-scheme: dark; +} + +html[data-theme="light"] { + --bg: #f6f5f1; + --fg: #17171a; + --fg-muted: #17171a; + --muted: #66666b; + --card: #fff; + --panel: #fff; + --hover: #ecebe6; + --raised: #e4e2dc; + --border: #d9d6ce; + --border-strong: #b4b0a6; + --accent: #d0263a; + --accent-hover: #b51f31; + --on-accent: #fff; + --scrim: rgba(20, 20, 20, 0.35); + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + --bg-card: var(--card); + --bg-alt: var(--hover); + /* Outlined text is for wallpapers (body.bright-bg / a dark picture); on plain light, none. */ + --outline: transparent; + color-scheme: light; } /* inverted: black on white */ @@ -395,7 +431,7 @@ a:hover { width: 100%; height: 70vh; border: 0; - background: #000; + background: var(--bg); } .sub-tabs { @@ -477,7 +513,7 @@ a:hover { .data-table tbody tr:nth-child(odd) { background: transparent; } .data-table tbody tr:nth-child(even) { background: transparent; } -.data-table tbody tr:hover { background: rgba(255, 255, 255, 0.05); } +.data-table tbody tr:hover { background: var(--hover); } .empty-msg { color: var(--text-muted); @@ -508,14 +544,14 @@ a:hover { white-space: nowrap; } .btn:hover { - background: rgba(255, 255, 255, 0.07); - border-color: #444; + background: var(--hover); + border-color: var(--border-strong); } .btn-play { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--on-accent); font-size: 0.9rem; padding: 0.4rem 1rem; } @@ -524,14 +560,14 @@ a:hover { border-color: var(--accent-hover); } .btn-play.playing { - background: #333; - border-color: #555; + background: var(--raised); + border-color: var(--border-strong); color: var(--text); } .btn-save { color: var(--yellow); - border-color: #444; + border-color: var(--border-strong); } .btn-save:hover { background: #2a2a00; @@ -546,7 +582,7 @@ a:hover { .btn-primary { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--on-accent); } .btn-primary:hover { background: var(--accent-hover); @@ -562,6 +598,9 @@ a:hover { border-color: #a00; color: #fff; } +html[data-theme="light"] .btn-danger { background: #fdecee; border-color: #e7a3aa; color: #a3172a; } +html[data-theme="light"] .btn-danger:hover { background: #a3172a; border-color: #a3172a; color: #fff; } +html[data-theme="light"] .btn-save:hover { background: #fbf3d3; } .btn-lastfm { background: #d51007; @@ -688,6 +727,7 @@ a:hover { border-radius: var(--radius); padding: 0.5rem 0.75rem; } +html[data-theme="light"] .form-errors { background: #fdecee; border-color: #e7a3aa; } .field-help { color: var(--text-muted); @@ -929,7 +969,7 @@ a:hover { .recommendations-section { padding: 12px 16px 4px; - border-bottom: 1px solid #222; + border-bottom: 1px solid var(--border); } .recommendations-context { font-size: 0.75rem; @@ -1009,7 +1049,7 @@ a:hover { display: flex; align-items: center; gap: 10px; - border-bottom: 1px solid #1a1a1a; + border-bottom: 1px solid var(--border); } /* ========================================================= @@ -1108,13 +1148,13 @@ body.dnd-mode .timer-display { flex-wrap: wrap; gap: 6px; padding: 8px 16px; - border-bottom: 1px solid #1a1a1a; + border-bottom: 1px solid var(--border); } .mood-chip { background: transparent; - border: 1px solid #333; - color: #ccc; + border: 1px solid var(--border); + color: var(--fg); border-radius: 999px; padding: 3px 12px; font-size: 0.78rem; @@ -1123,8 +1163,8 @@ body.dnd-mode .timer-display { } .mood-chip:hover { - background: rgba(255, 255, 255, 0.05); - border-color: #555; + background: var(--hover); + border-color: var(--border-strong); } .focus-today { @@ -1274,7 +1314,7 @@ body.dnd-mode .timer-display { .sidebar-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.45); + background: var(--scrim); z-index: 299; } @@ -1334,7 +1374,7 @@ body.dnd-mode .timer-display { .modal-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.55); + background: var(--scrim); z-index: 500; } @@ -1417,7 +1457,7 @@ body.dnd-mode .timer-display { .book-drop-zone:hover, .book-drop-zone.drag-over { border-color: var(--accent); - background: rgba(255,255,255,0.04); + background: var(--hover); } .book-drop-zone label { color: var(--accent); @@ -1791,13 +1831,13 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h) display: flex; align-items: center; justify-content: center; - background: rgba(20, 20, 20, 0.85); + background: var(--scrim); z-index: 200; } .pdf-loading-spinner { width: 36px; height: 36px; - border: 3px solid #333; + border: 3px solid var(--raised); border-top-color: var(--accent, #e63946); border-radius: 50%; animation: pdf-spin 0.7s linear infinite; @@ -1814,7 +1854,7 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h) /* Highlight popover */ .highlight-popover { position:fixed; z-index:500; display:flex; gap:6px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.5); } .hl-color-btn { width:24px; height:24px; border-radius:50%; border:2px solid transparent; cursor:pointer; font-weight:700; font-size:12px; color:#000; line-height:1; } -.hl-color-btn:hover { border-color:#fff; } +.hl-color-btn:hover { border-color:var(--fg); } .hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; } /* Footnote popover */ @@ -1908,7 +1948,7 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h) here. */ .note-bottom-sheet { position:fixed; inset:0; z-index:650; } .note-bottom-sheet-backdrop { - position:absolute; inset:0; background:rgba(0,0,0,.5); + position:absolute; inset:0; background:var(--scrim); opacity:0; transition:opacity .2s ease; pointer-events:none; } .note-bottom-sheet.open .note-bottom-sheet-backdrop { opacity:1; pointer-events:auto; } @@ -1951,7 +1991,9 @@ mark.tts-current { background:rgba(230,57,70,.55); color:inherit; border-radius: @keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} } .reader-toast-action { display:flex; align-items:center; gap:10px; padding:8px 8px 8px 14px; animation:toast-slide-up 0.2s ease forwards; pointer-events:auto; } -.reader-toast-btn { background:var(--accent,#e63946); color:#fff; border:none; border-radius:calc(var(--radius) - 2px); padding:5px 12px; font-family:var(--font); font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; } +/* The apps: the player bar sits in the reader, above its footer; notices go above both. */ +html.nui-reading .reader-toast { bottom:calc(var(--nui-dock-h, 0px) + 40px); } +.reader-toast-btn { background:var(--accent,#e63946); color:var(--on-accent); border:none; border-radius:calc(var(--radius) - 2px); padding:5px 12px; font-family:var(--font); font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; } .reader-toast-btn:hover { filter:brightness(1.1); } @keyframes toast-slide-up { 0%{opacity:0; transform:translate(-50%, 8px);} 100%{opacity:1; transform:translate(-50%, 0);} } @@ -1960,7 +2002,7 @@ mark.tts-current { background:rgba(230,57,70,.55); color:inherit; border-radius: bottom: 4px; right: 6px; font-size: 0.65rem; - color: #444; + color: var(--border-strong); pointer-events: none; z-index: 1; } diff --git a/static/js/app.js b/static/js/app.js index e7ac60e..1dad5a3 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1629,6 +1629,12 @@ function applyAccent(entry) { root.style.setProperty('--accent-hover', entry.hover); } +// Light and dark: the switch is in base.html (every page has it). The reader's +// "Wie die App" follows it. +window.addEventListener('diora-theme', () => { + if (currentBookId) applyReaderSettings(!!currentPdfDoc); +}); + function setScheme(bright) { document.body.classList.toggle('bright-bg', bright); const btn = document.getElementById('contrast-toggle'); @@ -1736,6 +1742,8 @@ async function applyEncryptedBackground() { const blob = new Blob([plain], {type: ENCRYPTED_BG.mime || 'image/jpeg'}); const url = URL.createObjectURL(blob); document.body.style.backgroundImage = `url('${url}')`; + const contrastBtn = document.getElementById('contrast-toggle'); + if (contrastBtn) contrastBtn.hidden = false; // only with a picture behind the page document.body.style.backgroundSize = 'cover'; document.body.style.backgroundPosition = 'center'; document.body.style.backgroundAttachment = 'fixed'; @@ -3770,6 +3778,8 @@ let ttsAudio = null; let ttsCurrentMark = null; let ttsBarEl = null; let ttsLang = localStorage.getItem('diora_tts_lang') || 'de'; +let _ttsBlocks = null; // the book's blocks while reading aloud +let _ttsCur = null; // what's being read: {bi, si, sentence, block} function setTtsLang(lang) { ttsLang = lang; @@ -3862,15 +3872,19 @@ function _ttsPlayBlob(blob) { }); } -// Walks blocks/sentences from startBlockIndex onward, fetching one sentence -// ahead while the current one plays so there's no gap between them. -async function _ttsPlayLoop(blocks, startBlockIndex, runToken) { +// Walks blocks/sentences from startBlockIndex (and its sentence startSentence) +// onward, fetching one sentence ahead while the current one plays so there's +// no gap between them. +async function _ttsPlayLoop(blocks, startBlockIndex, runToken, startSentence = 0) { function* sentenceStream() { for (let bi = startBlockIndex; bi < blocks.length; bi++) { const block = blocks[bi]; const text = (block.textContent || '').trim(); if (!text) continue; - for (const sentence of _ttsSplitSentences(text)) yield {block, sentence}; + const sentences = _ttsSplitSentences(text); + for (let si = bi === startBlockIndex ? startSentence : 0; si < sentences.length; si++) { + yield {block, sentence: sentences[si], bi, si}; + } } } @@ -3896,8 +3910,14 @@ async function _ttsPlayLoop(blocks, startBlockIndex, runToken) { } if (runToken !== ttsRunToken) return; - if (block !== lastBlock) { lastBlock = block; blockSearchOffset = 0; } + if (block !== lastBlock) { + lastBlock = block; + // Started in the middle of a block (previous/next sentence): find it from there. + blockSearchOffset = _ttsOffsetOf(block, cur.value.si); + } + _ttsCur = {bi: cur.value.bi, si: cur.value.si, sentence, block}; blockSearchOffset = _ttsHighlightSentence(block, sentence, blockSearchOffset); + _ttsNotify(); await _ttsPlayBlob(audioBlob); if (runToken !== ttsRunToken) return; @@ -3915,8 +3935,90 @@ function _ttsTogglePause() { else ttsAudio.play().catch(() => {}); } _ttsUpdateBar(); + _ttsNotify(); } +// ---- read-aloud for a player bar (the shared dock in native-podcast.js) ---------- +// window.__dioraTts: state(), toggle(), stop(), next(), prev(); a 'diora-tts' +// event on window after every change. A dock that shows it sets +// window.__dioraTtsDock = true, and the reader's own little bar stays away. + +function _ttsState() { + const meta = (currentBookId && bookMetaCache[currentBookId]) || {}; + const cur = _ttsCur; + const chapterEl = cur && cur.block.closest('[data-epub-src]'); + const blocks = _ttsBlocks || []; + return { + active: ttsActive, + paused: ttsPaused, + bookId: currentBookId, + title: meta.title || '', + author: meta.author || '', + chapter: chapterEl ? _tocTitleForChapterSrc(chapterEl.dataset.epubSrc) : '', + sentence: cur ? cur.sentence : '', + progress: cur && blocks.length ? cur.bi / blocks.length : 0, + }; +} + +function _ttsNotify() { + try { window.dispatchEvent(new CustomEvent('diora-tts', {detail: _ttsState()})); } catch (e) {} +} + +// Read from another sentence on: the one before/after the current one. +function _ttsRestartAt(bi, si) { + const blocks = _ttsBlocks; + if (!ttsActive || !blocks) return; + ttsRunToken++; + const runToken = ttsRunToken; + if (ttsAudio) { + try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {} + ttsAudio = null; + } + _ttsPlayLoop(blocks, bi, runToken, si); +} + +// Where sentence si of a block starts in its text (for the highlight). +function _ttsOffsetOf(block, si) { + const full = block.textContent || ''; + let off = 0; + _ttsSplitSentences(full.trim()).slice(0, si).forEach(s => { + const k = full.indexOf(s, off); + if (k >= 0) off = k + s.length; + }); + return off; +} + +function _ttsSentenceCount(block) { + const text = (block.textContent || '').trim(); + return text ? _ttsSplitSentences(text).length : 0; +} + +function _ttsStep(dir) { + const cur = _ttsCur; + const blocks = _ttsBlocks; + if (!cur || !blocks) return; + if (dir > 0) { + if (cur.si + 1 < _ttsSentenceCount(cur.block)) return _ttsRestartAt(cur.bi, cur.si + 1); + for (let bi = cur.bi + 1; bi < blocks.length; bi++) if (_ttsSentenceCount(blocks[bi])) return _ttsRestartAt(bi, 0); + stopReadAloud(); + } else { + if (cur.si > 0) return _ttsRestartAt(cur.bi, cur.si - 1); + for (let bi = cur.bi - 1; bi >= 0; bi--) { + const n = _ttsSentenceCount(blocks[bi]); + if (n) return _ttsRestartAt(bi, n - 1); + } + _ttsRestartAt(cur.bi, 0); + } +} + +window.__dioraTts = { + state: _ttsState, + toggle: () => (ttsActive ? _ttsTogglePause() : startReadAloud()), + stop: () => stopReadAloud(), + next: () => _ttsStep(1), + prev: () => _ttsStep(-1), +}; + function _ttsUpdateBar() { if (!ttsBarEl) return; const btn = ttsBarEl.querySelector('.tts-pause-btn'); @@ -3925,6 +4027,7 @@ function _ttsUpdateBar() { function _ttsShowBar() { _ttsRemoveBar(); + if (window.__dioraTtsDock) return; // the player bar shows it const bar = document.createElement('div'); bar.className = 'tts-bar'; bar.innerHTML = ` @@ -3968,10 +4071,13 @@ function startReadAloud() { ttsPaused = false; ttsRunToken++; const runToken = ttsRunToken; + _ttsBlocks = blocks; + _ttsCur = null; const btn = $('reader-tts-btn'); if (btn) { btn.classList.add('active'); btn.title = 'Vorlesen beenden'; } _ttsShowBar(); + _ttsNotify(); _ttsPlayLoop(blocks, startIndex, runToken); } @@ -3986,8 +4092,11 @@ function stopReadAloud() { } _ttsClearHighlight(); _ttsRemoveBar(); + _ttsBlocks = null; + _ttsCur = null; const btn = $('reader-tts-btn'); if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; } + _ttsNotify(); } // --------------------------------------------------------------------------- @@ -4647,7 +4756,7 @@ function _readerTypeDefaults() { function loadReaderSettings(bookId) { // Reset to defaults, then apply per-book overrides - Object.assign(readerSettings, { theme: 'dark', fontFamily: 'serif', noBold: false, + Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false, pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }, _readerTypeDefaults()); try { @@ -4659,6 +4768,8 @@ function loadReaderSettings(bookId) { if (saved[key] === old) delete saved[key]; } } + // Before v3 the reader's theme was always stored, "dark" by default: that now means "Wie die App". + if ((saved.v || 0) < 3 && saved.theme === 'dark') delete saved.theme; delete saved.v; Object.assign(readerSettings, saved); if (saved.pdfPaginated === undefined) { @@ -4669,7 +4780,7 @@ function loadReaderSettings(bookId) { function saveReaderSettings() { if (currentBookId) { - localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 2 })); + localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 3 })); } } @@ -4708,8 +4819,11 @@ function applyReaderSettings(isPdf) { // Theme overlay.classList.remove('reader-theme-sepia', 'reader-theme-bright'); - if (readerSettings.theme === 'sepia') overlay.classList.add('reader-theme-sepia'); - else if (readerSettings.theme === 'bright') overlay.classList.add('reader-theme-bright'); + const readerTheme = readerSettings.theme === 'app' + ? (document.documentElement.getAttribute('data-theme') === 'light' ? 'bright' : 'dark') + : readerSettings.theme; + if (readerTheme === 'sepia') overlay.classList.add('reader-theme-sepia'); + else if (readerTheme === 'bright') overlay.classList.add('reader-theme-bright'); // PDF invert if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted'); @@ -4767,6 +4881,7 @@ function toggleSettingsPanel() { + diff --git a/static/js/home.js b/static/js/home.js index e50cf7f..712ad66 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -331,27 +331,27 @@ '#tab-home .home-brand { font-size: 26px; font-weight: 700; color: var(--accent, #e63946); margin: 4px 0 18px; letter-spacing: 0.05em; }', '#tab-home .home-section { margin-bottom: 22px; }', '#tab-home .home-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }', - '#tab-home .home-head h2 { margin: 0; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }', - '#tab-home .home-more { background: none; border: none; color: var(--nui-muted, #9a9a9a); font: inherit; font-size: 12px; cursor: pointer; padding: 4px 0; }', - '#tab-home .home-card { display: flex; align-items: center; gap: 12px; width: 100%; box-sizing: border-box; padding: 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: #0b0b0b; color: inherit; font: inherit; text-align: left; }', + '#tab-home .home-head h2 { margin: 0; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }', + '#tab-home .home-more { background: none; border: none; color: var(--muted, #9a9a9a); font: inherit; font-size: 12px; cursor: pointer; padding: 4px 0; }', + '#tab-home .home-card { display: flex; align-items: center; gap: 12px; width: 100%; box-sizing: border-box; padding: 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: var(--card, #0b0b0b); box-shadow: var(--shadow, none); color: inherit; font: inherit; text-align: left; }', '#tab-home button.home-card, #tab-home .home-pod-text, #tab-home .home-row, #tab-home .home-station { cursor: pointer; }', '#tab-home .home-main { flex-wrap: nowrap; }', '#tab-home button.home-main { flex-direction: column; align-items: flex-start; gap: 3px; }', - '#tab-home .home-empty { color: var(--nui-muted, #9a9a9a); justify-content: space-between; }', + '#tab-home .home-empty { color: var(--muted, #9a9a9a); justify-content: space-between; }', '#tab-home .home-kicker { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent, #e63946); }', '#tab-home .home-title { font-size: 16px; font-weight: 700; line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }', - '#tab-home .home-sub { font-size: 13px; color: var(--nui-muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }', - '#tab-home .home-state { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--nui-muted, #9a9a9a); }', - '#tab-home .home-bar { display: inline-block; width: 160px; max-width: 40vw; height: 4px; border-radius: 2px; background: #2a2a2a; overflow: hidden; }', + '#tab-home .home-sub { font-size: 13px; color: var(--muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }', + '#tab-home .home-state { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--muted, #9a9a9a); }', + '#tab-home .home-bar { display: inline-block; width: 160px; max-width: 40vw; height: 4px; border-radius: 2px; background: var(--raised, #2a2a2a); overflow: hidden; }', '#tab-home .home-bar > span { display: block; height: 100%; background: var(--accent, #e63946); }', '#tab-home .home-pod-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }', - '#tab-home .home-art { width: 56px; height: 56px; flex: none; border-radius: 4px; object-fit: cover; background: #1a1a1a; }', + '#tab-home .home-art { width: 56px; height: 56px; flex: none; border-radius: 4px; object-fit: cover; background: var(--raised, #1a1a1a); }', '#tab-home .home-amb-icon { width: 56px; height: 56px; flex: none; border-radius: 4px; background: #14102a; color: #b9a7ff; display: inline-flex; align-items: center; justify-content: center; font-size: 26px; }', '#tab-home .home-play { flex: none; min-height: 36px; padding: 0 14px; }', '#tab-home .home-list { margin-top: 6px; }', '#tab-home .home-row { display: flex; justify-content: space-between; gap: 12px; width: 100%; padding: 10px 14px; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 14px; text-align: left; }', '#tab-home .home-row-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }', - '#tab-home .home-row-side { flex: none; color: var(--nui-muted, #9a9a9a); font-size: 12px; }', + '#tab-home .home-row-side { flex: none; color: var(--muted, #9a9a9a); font-size: 12px; }', '#tab-home .home-news { flex-direction: column; align-items: stretch; gap: 0; padding: 4px 0; }', '#tab-home .home-story { align-items: baseline; }', '#tab-home .home-news .home-story:last-child { border-bottom: none; }', @@ -360,12 +360,12 @@ '#tab-home .home-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }', '#tab-home .home-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }', '#tab-home .home-stations { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }', - '#tab-home .home-station { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 8px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: #0b0b0b; color: inherit; font: inherit; font-size: 14px; text-align: left; }', + '#tab-home .home-station { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 8px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: var(--card, #0b0b0b); box-shadow: var(--shadow, none); color: inherit; font: inherit; font-size: 14px; text-align: left; }', '#tab-home .home-station-icon { color: var(--accent, #e63946); flex: none; }', '#tab-home .home-station-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }', '#tab-home .home-on { border-color: var(--accent, #e63946); }', - '#tab-home .home-note { margin-top: 8px; font-size: 13px; color: var(--nui-muted, #9a9a9a); }', - '@media (hover: hover) { #tab-home .home-card:hover, #tab-home .home-station:hover, #tab-home .home-row:hover { background: #141414; } }', + '#tab-home .home-note { margin-top: 8px; font-size: 13px; color: var(--muted, #9a9a9a); }', + '@media (hover: hover) { #tab-home .home-card:hover, #tab-home .home-station:hover, #tab-home .home-row:hover { background: var(--hover, #141414); } }', '.reader-theme-bright #tab-home, body.eink #tab-home .home-card { background: #fff; }', ].join('\n'); document.head.appendChild(style); diff --git a/static/js/sw.js b/static/js/sw.js index 2113fd9..32bd9f3 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v101'; +const CACHE = 'diora-v102'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', diff --git a/templates/base.html b/templates/base.html index 64f4baa..9d26a8a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,6 +11,50 @@ {% if user.is_authenticated %}{% endif %} + {% block title %}diora{% endblock %} @@ -22,7 +66,8 @@