diff --git a/static/js/app.js b/static/js/app.js deleted file mode 100644 index d64eb01..0000000 --- a/static/js/app.js +++ /dev/null @@ -1,6864 +0,0 @@ -/** - * diora — radio player - * Handles playback, SSE metadata, search, station management, and affiliate links. - */ - -'use strict'; - -// --------------------------------------------------------------------------- -// State -// --------------------------------------------------------------------------- - -// Hardcoded for now -- only creamfresh radio gets the vote buttons, since -// only its backend (the DJ) actually does anything with them. -const CREAMFRESH_RADIO_URL = 'https://diora.creamfresh.xyz/radio/creamfresh-stream/'; - -let currentStation = null; // { url, name, id } | null -let currentTrack = ''; -let sseSource = null; -let isPlaying = false; -let currentPlayId = null; - -const audio = new Audio(); - -// Reconnect audio when the output device changes (e.g. Bluetooth, USB DAC) -if (navigator.mediaDevices) { - let _deviceChangeDebounce = null; - navigator.mediaDevices.addEventListener('devicechange', () => { - clearTimeout(_deviceChangeDebounce); - _deviceChangeDebounce = setTimeout(() => { - if (!isPlaying || audio.src === '') return; - const savedTime = audio.currentTime; - const savedSrc = audio.src; - audio.src = savedSrc; - audio.load(); - if (savedTime > 0) { - audio.addEventListener('loadedmetadata', function onMeta() { - audio.currentTime = savedTime; - audio.removeEventListener('loadedmetadata', onMeta); - }); - } - audio.play().catch(() => {}); - }, 500); - }); -} - -// --------------------------------------------------------------------------- -// DOM helpers -// --------------------------------------------------------------------------- - -function $(id) { return document.getElementById(id); } - -function getCsrfToken() { - const cookie = document.cookie.split('; ').find(r => r.startsWith('csrftoken=')); - return cookie ? cookie.split('=')[1] : ''; -} - -function formatDateTime(iso) { - if (!iso) return ''; - const d = new Date(iso); - const pad = n => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ` - + `${pad(d.getHours())}:${pad(d.getMinutes())}`; -} - -function escapeHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -// --------------------------------------------------------------------------- -// Modal dialogs — styled replacements for native alert()/confirm()/prompt(), -// which look jarring against the rest of the custom dark UI and can't be -// styled or (in confirm/prompt's case) awaited without blocking the thread. -// All three return a Promise; callers must be async and `await` them. -// --------------------------------------------------------------------------- - -function _showModal({ message, showInput = false, inputValue = '', showCancel = false, okLabel = 'OK', danger = false }) { - return new Promise((resolve) => { - const overlay = $('modal-overlay'); - const dialog = $('modal-dialog'); - const msgEl = $('modal-message'); - const inputEl = $('modal-input'); - const okBtn = $('modal-ok-btn'); - const cancelBtn = $('modal-cancel-btn'); - if (!overlay || !dialog) { resolve(null); return; } - - msgEl.textContent = message; - inputEl.style.display = showInput ? '' : 'none'; - inputEl.value = inputValue; - cancelBtn.style.display = showCancel ? '' : 'none'; - okBtn.textContent = okLabel; - okBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary'; - - overlay.style.display = ''; - dialog.style.display = ''; - (showInput ? inputEl : okBtn).focus(); - if (showInput) inputEl.select(); - - function cleanup(result) { - overlay.style.display = 'none'; - dialog.style.display = 'none'; - overlay.removeEventListener('click', onCancel); - okBtn.removeEventListener('click', onOk); - cancelBtn.removeEventListener('click', onCancel); - document.removeEventListener('keydown', onKeydown); - resolve(result); - } - function onOk() { cleanup(showInput ? inputEl.value : true); } - function onCancel() { cleanup(showInput ? null : false); } - function onKeydown(e) { - if (e.key === 'Escape') onCancel(); - if (e.key === 'Enter' && showInput) onOk(); - } - - okBtn.addEventListener('click', onOk); - cancelBtn.addEventListener('click', onCancel); - overlay.addEventListener('click', onCancel); - document.addEventListener('keydown', onKeydown); - }); -} - -function customAlert(message) { - return _showModal({ message }); -} - -function customConfirm(message, { danger = false } = {}) { - return _showModal({ message, showCancel: true, danger }); -} - -// The server answers with short English error codes (an API contract, tests and -// other clients rely on them); what the page shows of them is German. -const _GERMAN_ERRORS = { - 'no stations found in file': 'In der Datei wurden keine Sender gefunden.', - 'file must be .m3u or .m3u8': 'Die Datei muss eine .m3u- oder .m3u8-Datei sein.', - 'no file uploaded': 'Keine Datei hochgeladen.', - 'only jpeg, png, or webp allowed': 'Nur JPEG, PNG oder WebP erlaubt.', - 'file too large (max 50 MB)': 'Datei zu groß (höchstens 50 MB).', - 'file too large (max 5 MB)': 'Datei zu groß (höchstens 5 MB).', - 'authentication required': 'Bitte anmelden.', - 'not found': 'Nicht gefunden.', - 'invalid JSON': 'Ungültige Anfrage.', - 'upstream unreachable': 'Der Server dahinter ist nicht erreichbar.', -}; -function _germanError(code) { - return _GERMAN_ERRORS[code] || code; -} - -function customPrompt(message, defaultValue = '') { - return _showModal({ message, showInput: true, inputValue: defaultValue, showCancel: true }); -} - -// --------------------------------------------------------------------------- -// Play / Stop -// --------------------------------------------------------------------------- - -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. - if (location.protocol === 'https:' && url.startsWith('http://') && !window.DIORA_PLAYS_HTTP_STREAMS) { - window.open(url, '_blank'); - return; - } - - currentStation = { url, name, id: stationId || null }; - isPlaying = true; - - audio.src = url; - const volSlider = document.getElementById('volume'); - if (volSlider) audio.volume = volSlider.value / 255; - audio.play().catch(() => { - // Browser may block autoplay; the user needs to interact first - console.warn('Audio play blocked by browser policy.'); - }); - - $('now-playing-station').textContent = name; - $('now-playing-track').textContent = ''; - $('play-stop-btn').style.display = ''; - $('play-stop-btn').textContent = '⏹ Stopp'; - $('play-stop-btn').classList.add('playing'); - $('save-station-btn').style.display = ''; - - const isCreamfresh = url === CREAMFRESH_RADIO_URL; - $('creamfresh-vote-up-btn').style.display = isCreamfresh ? '' : 'none'; - $('creamfresh-vote-down-btn').style.display = isCreamfresh ? '' : 'none'; - $('creamfresh-vote-up-btn').classList.remove('active'); - $('creamfresh-vote-down-btn').classList.remove('active'); - - startMetadataSSE(url); - startPlaySession(name, url); - maybeShowDonationHint(url, name); - - if ('mediaSession' in navigator) { - navigator.mediaSession.metadata = new MediaMetadata({title: name, artist: 'Radio'}); - navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; }); - navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; }); - navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true)); - try { navigator.mediaSession.setActionHandler('seekbackward', null); } catch (_) {} - try { navigator.mediaSession.setActionHandler('seekforward', null); } catch (_) {} - try { navigator.mediaSession.setActionHandler('nexttrack', null); } catch (_) {} - try { navigator.mediaSession.setActionHandler('previoustrack',null); } catch (_) {} - navigator.mediaSession.playbackState = 'playing'; - } -} - -function stopPlayback(clearStation = true) { - audio.pause(); - audio.src = ''; - isPlaying = false; - if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none'; - - if (sseSource) { - sseSource.close(); - sseSource = null; - } - - $('play-stop-btn').textContent = '▶ Abspielen'; - $('play-stop-btn').classList.remove('playing'); - $('save-station-btn').style.display = 'none'; - $('creamfresh-vote-up-btn').style.display = 'none'; - $('creamfresh-vote-down-btn').style.display = 'none'; - $('affiliate-section').style.display = 'none'; - - stopPlaySession(); - - const stationEl = $('now-playing-station'); - const trackEl = $('now-playing-track'); - - if (clearStation) { - currentStation = null; - currentTrack = ''; - stationEl.textContent = '— kein Sender —'; - trackEl.textContent = ''; - $('play-stop-btn').style.display = 'none'; - } -} - -function togglePlayStop() { - if (isPlaying) { - stopPlayback(true); - } else if (currentStation) { - playStation(currentStation.url, currentStation.name, currentStation.id); - } -} - -// --------------------------------------------------------------------------- -// Play session tracking -// --------------------------------------------------------------------------- - -async function startPlaySession(stationName, stationUrl) { - try { - const res = await fetch('/radio/play/start/', { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: JSON.stringify({station_name: stationName, station_url: stationUrl}) - }); - if (res.ok) { - const data = await res.json(); - currentPlayId = data.play_id; - } - } catch (e) {} -} - -async function stopPlaySession() { - // 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: playId}) - }); - } catch (e) {} -} - -window.addEventListener('beforeunload', () => { - if (currentPlayId) { - navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId})); - } - // Flush cached encrypted payloads for reader data (encryption is async so we - // use pre-computed blobs stored by the debounced savers) - // 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); -}); - -document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden' && currentBookId) { - if (bookmarksDirty) saveBookmarks(); - 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 -let _lastBookmarkBeacon = null; -let _lastHighlightBeacon = null; -let _lastProgressBeacon = null; - -// --------------------------------------------------------------------------- -// Recommendations -// --------------------------------------------------------------------------- - -async function loadRecommendations() { - const container = document.getElementById('recommendations'); - if (!container) return; - try { - const res = await fetch('/radio/recommendations/'); - const data = await res.json(); - if (!data.recommendations.length) { - container.innerHTML = '

Hör mehr Sender, dann gibt es hier Empfehlungen.

'; - return; - } - const label = data.context; - let html = `

Was du ${label} oft hörst:

'; - container.innerHTML = html; - } catch (e) {} -} - -function escapeAttr(s) { - return String(s).replace(/'/g, "\\'").replace(/"/g, '"'); -} - -// --------------------------------------------------------------------------- -// Volume -// --------------------------------------------------------------------------- - -function setVolume(val) { - val = Math.max(0, Math.min(255, Math.round(val))); - audio.volume = val / 255; - localStorage.setItem('diora_volume', val); - const slider = $('volume'); - const numInput = $('volume-num'); - if (slider) slider.value = val; - if (numInput) numInput.value = val; - _ambientVolume(val); -} - -const volSliderEl = document.getElementById('volume'); -if (volSliderEl) { - ['input', 'change', 'mousemove', 'touchmove'].forEach(evt => - volSliderEl.addEventListener(evt, function () { setVolume(this.value); }) - ); -} - -const volNumEl = document.getElementById('volume-num'); -if (volNumEl) { - volNumEl.addEventListener('change', function () { setVolume(this.value); }); - volNumEl.addEventListener('input', function () { setVolume(this.value); }); - volNumEl.addEventListener('click', function () { this.select(); }); -} - -const volSliderEl2 = document.getElementById('volume'); -const volWheelTarget = volSliderEl2 || volNumEl; -if (volWheelTarget) { - volWheelTarget.addEventListener('wheel', function (e) { - e.preventDefault(); - const current = parseInt(document.getElementById('volume').value, 10); - setVolume(current + (e.deltaY < 0 ? 4 : -4)); - }, { passive: false }); -} - -// --------------------------------------------------------------------------- -// SSE metadata -// --------------------------------------------------------------------------- - -function startMetadataSSE(streamUrl) { - if (sseSource) { sseSource.close(); sseSource = null; } - - const endpoint = '/radio/sse/?url=' + encodeURIComponent(streamUrl); - sseSource = new EventSource(endpoint); - - sseSource.onmessage = function (e) { - let data; - try { data = JSON.parse(e.data); } catch (_) { return; } - - if (data.error) { - console.warn('SSE stream ended:', data.error); - return; - } - - if (data.track && data.track !== currentTrack) { - currentTrack = data.track; - updateNowPlayingUI(data.track); - recordTrack(currentStation ? currentStation.name : '', data.track); - fetchAffiliateLinks(data.track); - } - }; - - sseSource.onerror = function () { - // Connection dropped; the browser will attempt to reconnect automatically - console.warn('SSE connection error, browser will retry.'); - }; -} - -function updateNowPlayingUI(track) { - $('now-playing-track').textContent = track; -} - -// --------------------------------------------------------------------------- -// Record track -// --------------------------------------------------------------------------- - -async function recordTrack(stationName, track) { - try { - const res = await fetch('/radio/record/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCsrfToken(), - }, - body: JSON.stringify({ station_name: stationName, track, scrobble: true }), - }); - if (res.ok) { - addHistoryRow(stationName, track); - } - } catch (err) { - console.error('recordTrack error:', err); - } -} - -function addHistoryRow(stationName, track) { - const tbody = $('history-tbody'); - if (!tbody) return; - - // Remove the "no history" placeholder row if present - const emptyRow = $('history-empty-row'); - if (emptyRow) emptyRow.remove(); - - const tr = document.createElement('tr'); - const now = new Date().toISOString(); - tr.innerHTML = ` - ${escapeHtml(formatDateTime(now))} - ${escapeHtml(stationName)} - ${escapeHtml(track)} - - - `; - tbody.insertBefore(tr, tbody.firstChild); -} - -async function deleteHistoryEntry(id, btn) { - const tr = btn.closest('tr'); - if (!id) { tr.remove(); return; } - try { - const res = await fetch(`/radio/history/${id}/delete/`, { - method: 'POST', - headers: {'X-CSRFToken': getCsrfToken()}, - }); - if (res.ok) tr.remove(); - } catch (e) {} -} - -// --------------------------------------------------------------------------- -// Affiliate links -// --------------------------------------------------------------------------- - -async function fetchAffiliateLinks(track) { - const section = $('affiliate-section'); - if (section && section.dataset.disabled) return; - try { - const res = await fetch('/radio/affiliate/?track=' + encodeURIComponent(track)); - if (!res.ok) return; - const data = await res.json(); - - const itunes = data.itunes_data || {}; - - $('affiliate-track-name').textContent = itunes.name || track; - $('affiliate-artist-name').textContent = itunes.artist || ''; - $('affiliate-album-name').textContent = itunes.album || ''; - - const artEl = $('affiliate-artwork'); - if (itunes.artwork) { - artEl.src = itunes.artwork; - artEl.style.display = ''; - } else { - artEl.style.display = 'none'; - } - - const amzLink = $('affiliate-amazon-link'); - if (data.amazon_url) { - amzLink.href = data.amazon_url; - amzLink.style.display = ''; - } else { - amzLink.style.display = 'none'; - } - - section.style.display = 'flex'; - } catch (err) { - console.error('fetchAffiliateLinks error:', err); - section.style.display = 'none'; - } -} - -// --------------------------------------------------------------------------- -// Search (radio-browser.info) -// --------------------------------------------------------------------------- - -async function doSearch() { - const query = $('search-input').value.trim(); - if (!query) return; - - const statusEl = $('search-status'); - const tableEl = $('search-results-table'); - const tbody = $('search-results-body'); - - 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 = 'Suche …'; - - 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); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const stations = await res.json(); - - if (!stations.length) { - statusEl.textContent = 'Keine Sender gefunden.'; - return; - } - - statusEl.textContent = stations.length === 1 ? '1 Treffer' : `${stations.length} Treffer`; - tableEl.style.display = ''; - const curated = document.getElementById('curated-lists'); - if (curated) curated.style.display = 'none'; - - stations.forEach(st => { - const tr = document.createElement('tr'); - const safeName = escapeHtml(st.name || ''); - const safeUrl = escapeHtml(st.url_resolved || st.url || ''); - const safeBr = escapeHtml(st.bitrate ? st.bitrate + ' kbps' : ''); - const safeCC = escapeHtml(st.countrycode || st.country || ''); - const safeTags = escapeHtml((st.tags || '').split(',').slice(0, 3).join(', ')); - - tr.innerHTML = ` - ${safeName} - ${safeBr} - ${safeCC} - ${safeTags} - - - - `; - tbody.appendChild(tr); - }); - } catch (err) { - statusEl.textContent = 'Suche fehlgeschlagen: ' + err.message; - } -} - -// 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-Adresse'; - 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 = '▶ Abspielen'; - play.addEventListener('click', () => searchPlay(url, name, data)); - const save = document.createElement('button'); - save.className = 'btn btn-sm'; - save.innerHTML = '★ Speichern'; - save.style.marginLeft = '4px'; - save.addEventListener('click', async e => { - e.stopPropagation(); - const chosen = await customPrompt('Name für diesen Sender', name); - if (chosen == null) return; - const finalName = chosen.trim() || name; - await saveStation({...data, name: finalName}); - statusEl.textContent = `Gespeichert als „${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; - playStation(url, name, null); -} - -// --------------------------------------------------------------------------- -// Save current station -// --------------------------------------------------------------------------- - -async function saveCurrentStation() { - if (!currentStation) return; - - // Use the rich data from search results if available, otherwise minimal data - const data = window._pendingStationData || { - name: currentStation.name, - url: currentStation.url, - bitrate: '', - country: '', - tags: '', - favicon_url: '', - }; - - await saveStation(data); -} - -// --------------------------------------------------------------------------- -// creamfresh radio: DJ feedback (hardcoded to this one station, see -// CREAMFRESH_RADIO_URL above) -// --------------------------------------------------------------------------- - -async function creamfreshVote(direction) { - const upBtn = $('creamfresh-vote-up-btn'); - const downBtn = $('creamfresh-vote-down-btn'); - upBtn.disabled = true; - downBtn.disabled = true; - try { - const res = await fetch('/radio/creamfresh-feedback/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCsrfToken(), - }, - body: JSON.stringify({ vote: direction }), - }); - if (!res.ok) throw new Error(`feedback returned ${res.status}`); - upBtn.classList.toggle('active', direction === 'up'); - downBtn.classList.toggle('active', direction === 'down'); - } catch (err) { - console.warn('creamfresh vote failed', err); - } finally { - upBtn.disabled = false; - downBtn.disabled = false; - } -} - -async function saveStation(station) { - try { - const res = await fetch('/radio/save/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCsrfToken(), - }, - body: JSON.stringify(station), - }); - - if (res.status === 401) { - await customAlert('Bitte anmelden, um Sender zu speichern.'); - return; - } - - const data = await res.json(); - if (data.ok) { - if (data.created) { - addSavedRow({ id: data.id, ...station, is_favorite: false }); - } - } - } catch (err) { - console.error('saveStation error:', err); - } -} - -function addSavedRow(station) { - const tbody = $('saved-tbody'); - if (!tbody) return; - - const emptyRow = $('saved-empty-row'); - if (emptyRow) emptyRow.remove(); - - // Check for duplicate - if (document.getElementById(`saved-row-${station.id}`)) return; - - const tr = document.createElement('tr'); - tr.id = `saved-row-${station.id}`; - tr.dataset.id = station.id; - tr.dataset.url = station.url; - tr.dataset.name = station.name; - - const safeName = escapeHtml(station.name || ''); - const safeBr = escapeHtml(station.bitrate || ''); - const safeCC = escapeHtml(station.country || ''); - const safeUrl = escapeHtml(station.url || ''); - - tr.innerHTML = ` - - - - ${safeName} - ${safeBr} - ${safeCC} - - - - - - - - `; - tbody.appendChild(tr); -} - -// --------------------------------------------------------------------------- -// Remove station -// --------------------------------------------------------------------------- - -// 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', - headers: { 'X-CSRFToken': getCsrfToken() }, - }); - if (!res.ok) return; - const data = await res.json(); - - // Flip the star button state - 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'); - if (!tbody) return; - const rows = Array.from(tbody.querySelectorAll('tr[data-id]')); - rows.sort((a, b) => { - const aFav = a.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1; - const bFav = b.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1; - if (aFav !== bFav) return aFav - bFav; - return a.dataset.name.localeCompare(b.dataset.name); - }); - rows.forEach(row => tbody.appendChild(row)); - } catch (err) { - console.error('toggleFav error', err); - } -} - -async function removeStation(pk) { - try { - const res = await fetch(`/radio/remove/${pk}/`, { - method: 'POST', - headers: { 'X-CSRFToken': getCsrfToken() }, - }); - if (res.ok) { - const row = $(`saved-row-${pk}`); - if (row) row.remove(); - - const tbody = $('saved-tbody'); - if (tbody && tbody.querySelectorAll('tr').length === 0) { - const tr = document.createElement('tr'); - tr.id = 'saved-empty-row'; - tr.innerHTML = 'Noch keine gespeicherten Sender.'; - tbody.appendChild(tr); - } - } - } catch (err) { - console.error('removeStation error:', err); - } -} - -// --------------------------------------------------------------------------- -// Focus Timer -// --------------------------------------------------------------------------- - -const TIMER_WORK = 25 * 60; -const TIMER_BREAK = 5 * 60; -let timerSeconds = TIMER_WORK; -let timerRunning = false; -let timerIsBreak = false; -let timerInterval = null; - -function timerTick() { - timerSeconds--; - renderTimer(); - if (timerSeconds <= 0) { - clearInterval(timerInterval); - timerInterval = null; - timerRunning = false; - if (!timerIsBreak) { - // work session ended → start break - timerIsBreak = true; - recordFocusSession(); - timerSeconds = TIMER_BREAK; - showTimerNotification('Pause! 5 Minuten.'); - // auto-pause playback during break - if (audio.src && !audio.paused) audio.pause(); - } else { - // break ended → reset to work - timerIsBreak = false; - timerSeconds = TIMER_WORK; - showTimerNotification('Pause vorbei. Weiter geht’s.'); - } - renderTimer(); - } -} - -function toggleTimer() { - if (timerRunning) { - clearInterval(timerInterval); - timerInterval = null; - timerRunning = false; - } else { - if ('Notification' in window && Notification.permission === 'default') { - Notification.requestPermission(); - } - timerRunning = true; - timerInterval = setInterval(timerTick, 1000); - } - renderTimer(); -} - -function resetTimer() { - clearInterval(timerInterval); - timerInterval = null; - timerRunning = false; - timerIsBreak = false; - timerSeconds = TIMER_WORK; - renderTimer(); -} - -function renderTimer() { - const m = String(Math.floor(timerSeconds / 60)).padStart(2, '0'); - const s = String(timerSeconds % 60).padStart(2, '0'); - const display = $('timer-display'); - const btn = $('timer-toggle-btn'); - const label = $('timer-phase-label'); - if (display) display.textContent = `${m}:${s}`; - if (btn) btn.textContent = timerRunning ? '⏸' : '▶'; - if (label) label.textContent = timerIsBreak ? 'Pause' : 'Fokus'; - // colour the display red when break - if (display) display.style.color = timerIsBreak ? '#e63946' : ''; -} - -function showTimerNotification(msg) { - if ('Notification' in window && Notification.permission === 'granted') { - new Notification('diora', { body: msg }); - } - // also flash in the timer label - const label = $('timer-phase-label'); - if (label) { label.textContent = msg; setTimeout(() => renderTimer(), 3000); } -} - -// --------------------------------------------------------------------------- -// Focus session recording -// --------------------------------------------------------------------------- - -async function recordFocusSession() { - try { - await fetch('/radio/focus/record/', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() }, - body: JSON.stringify({ - station_name: currentStation ? currentStation.name : '', - duration_minutes: 25, - }), - }); - loadFocusStats(); - } catch (e) {} -} - -async function loadFocusStats() { - try { - const res = await fetch('/radio/focus/stats/'); - const data = await res.json(); - const widget = document.getElementById('focus-today-widget'); - if (widget) { - if (data.today_sessions > 0) { - widget.textContent = `Heute: ${data.today_sessions} ${data.today_sessions !== 1 ? 'Sitzungen' : 'Sitzung'} · ${data.today_minutes} min`; - widget.style.display = ''; - } else { - widget.style.display = 'none'; - } - } - // populate focus tab - const tbody = document.getElementById('focus-tbody'); - if (!tbody) return; - tbody.innerHTML = ''; - if (!data.sessions.length) { - tbody.innerHTML = 'Noch keine Fokus-Sitzungen. Starte den Timer!'; - return; - } - data.sessions.forEach(s => { - const tr = document.createElement('tr'); - const dt = new Date(s.completed_at).toLocaleString([], {dateStyle: 'short', timeStyle: 'short'}); - tr.innerHTML = `${dt}${escapeHtml(s.station_name || '—')}${s.duration_minutes} min`; - tbody.appendChild(tr); - }); - } catch (e) {} -} - -// --------------------------------------------------------------------------- -// Do Not Disturb / focus mode -// --------------------------------------------------------------------------- - -let dndActive = false; - -function toggleDNDLight() { - document.body.classList.toggle('dnd-dark'); - const btn = $('dnd-light-btn'); - if (btn) btn.style.opacity = document.body.classList.contains('dnd-dark') ? '0.4' : '1'; -} - -function toggleDND() { - dndActive = !dndActive; - if (!dndActive) document.body.classList.remove('dnd-dark'); - document.body.classList.toggle('dnd-mode', dndActive); - const btn = $('dnd-btn'); - if (btn) btn.classList.toggle('active', dndActive); - if (dndActive) { - const el = document.documentElement; - if (el.requestFullscreen) el.requestFullscreen(); - else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); - } else { - if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen(); - else if (document.webkitFullscreenElement && document.webkitExitFullscreen) document.webkitExitFullscreen(); - } -} - -// Exit DND on Escape (browser also exits fullscreen on Escape, so sync state) -document.addEventListener('fullscreenchange', () => { - if (!document.fullscreenElement && dndActive) { - dndActive = false; - document.body.classList.remove('dnd-mode'); - const btn = $('dnd-btn'); - if (btn) btn.classList.remove('active'); - } -}); - -document.addEventListener('keydown', e => { - if (e.key === 'Escape' && dndActive) toggleDND(); -}); - -// --------------------------------------------------------------------------- -// Mood / genre tag filter -// --------------------------------------------------------------------------- - -const MOOD_TAGS = [ - { label: 'Fokus', tag: 'ambient' }, - { label: 'Lo-fi', tag: 'lofi' }, - { label: 'Jazz', tag: 'jazz' }, - { label: 'Klassik', tag: 'classical' }, - { label: 'Ambient', tag: 'ambient' }, - { label: 'Metal', tag: 'metal' }, - { label: 'Elektronisch', tag: 'electronic' }, - { label: 'Wort', tag: 'talk' }, -]; - -function initMoodChips() { - const container = $('mood-chips'); - if (!container) return; - MOOD_TAGS.forEach(({ label, tag }) => { - const btn = document.createElement('button'); - btn.className = 'mood-chip'; - btn.textContent = label; - btn.onclick = () => { - const input = $('search-input'); - if (input) input.value = tag; - doSearch(); - }; - container.appendChild(btn); - }); -} - -// --------------------------------------------------------------------------- -// Curated station lists -// --------------------------------------------------------------------------- - -const CURATED_LISTS = [ - { - id: 'focus', - label: 'Fokus', - stations: [ - { name: 'SomaFM Drone Zone', url: 'https://ice6.somafm.com/dronezone-256-mp3' }, - { name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac' }, - { name: 'Nightride FM', url: 'https://stream.nightride.fm/nightride.mp3' }, - { name: 'Nightride FM Chillsynth', url: 'https://stream.nightride.fm/chillsynth.mp3' }, - ], - }, - { - id: 'lofi', - label: 'Lo-fi / Chill', - stations: [ - { name: 'SomaFM Groove Salad Classic', url: 'https://ice6.somafm.com/gsclassic-128-mp3' }, - { name: 'SomaFM Secret Agent', url: 'https://ice4.somafm.com/secretagent-128-mp3' }, - { name: 'dublab DE', url: 'https://dublabde.out.airtime.pro/dublabde_a' }, - ], - }, - { - id: 'dark', - label: 'Dark / Industrial', - stations: [ - { name: 'SomaFM Doomed', url: 'https://ice2.somafm.com/doomed-256-mp3' }, - { name: 'Nightride FM Darksynth', url: 'https://stream.nightride.fm/darksynth.mp3' }, - { name: 'Radio Caprice Industrial', url: 'http://79.120.39.202:9095/' }, - ], - }, - { - id: 'classical', - label: 'Klassik', - stations: [ - { name: 'BR Klassik', url: 'https://dispatcher.rndfnk.com/br/brklassik/live/mp3/high' }, - { name: 'SWR Kultur', url: 'https://f111.rndfnk.com/ard/swr/swr2/live/mp3/256/stream.mp3?aggregator=web' }, - { name: 'Deutschlandfunk Kultur', url: 'https://st02.sslstream.dlf.de/dlf/02/high/aac/stream.aac?aggregator=web' }, - ], - }, -]; - -function initCuratedLists() { - const container = document.getElementById('curated-lists'); - if (!container) return; - - if (INITIAL_FEATURED && INITIAL_FEATURED.length) { - const section = document.createElement('div'); - section.className = 'curated-section'; - section.innerHTML = `
★ Empfohlen
`; - const ul = document.createElement('ul'); - ul.className = 'curated-stations'; - INITIAL_FEATURED.forEach(s => { - const li = document.createElement('li'); - li.innerHTML = ` - ${escapeHtml(s.name)} - ${s.description ? `${escapeHtml(s.description)}` : ''}`; - ul.appendChild(li); - }); - section.appendChild(ul); - container.appendChild(section); - } - - CURATED_LISTS.forEach(list => { - const section = document.createElement('div'); - section.className = 'curated-section'; - section.innerHTML = `
${list.label}
`; - const ul = document.createElement('ul'); - ul.className = 'curated-stations'; - list.stations.forEach(s => { - const li = document.createElement('li'); - li.innerHTML = ` - ${escapeHtml(s.name)}`; - ul.appendChild(li); - }); - section.appendChild(ul); - container.appendChild(section); - }); -} - -// --------------------------------------------------------------------------- -// Donation hint -// --------------------------------------------------------------------------- - -const DONATION_HINT_THRESHOLD = 10; -const DONATION_HINT_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days - -function maybeShowDonationHint(stationUrl, stationName) { - const station = INITIAL_SAVED.find(s => s.url === stationUrl); - if (!station || station.play_count < DONATION_HINT_THRESHOLD) return; - - const key = `diora_donation_hint_${stationUrl}`; - const last = parseInt(localStorage.getItem(key) || '0', 10); - if (Date.now() - last < DONATION_HINT_COOLDOWN_MS) return; - - // Past the threshold just means "this station is a regular" — from then on - // every single play would otherwise qualify, so only actually show the hint - // on roughly 1 in 10 of those plays instead of every time. - if (Math.random() >= 0.1) return; - - const existing = document.getElementById('donation-hint'); - if (existing) existing.remove(); - - const el = document.createElement('div'); - el.id = 'donation-hint'; - el.innerHTML = ` - Du hörst ${escapeHtml(stationName)} oft – vielleicht magst du den Sender unterstützen ♥ - - `; - document.body.appendChild(el); - - setTimeout(() => dismissDonationHint(stationUrl), 12000); -} - -function dismissDonationHint(stationUrl) { - localStorage.setItem(`diora_donation_hint_${stationUrl}`, Date.now()); - const el = document.getElementById('donation-hint'); - if (el) { el.classList.add('hiding'); setTimeout(() => el.remove(), 400); } -} - -// --------------------------------------------------------------------------- -// Station notes -// --------------------------------------------------------------------------- - -async function editNotes(pk, current) { - const note = await customPrompt('Notiz zum Sender:', current || ''); - if (note === null) return; // cancelled - fetch(`/radio/notes/${pk}/`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() }, - body: JSON.stringify({ notes: note }), - }).then(r => { - if (r.ok) { - const cell = document.querySelector(`#saved-row-${pk} .notes-cell`); - if (cell) cell.textContent = note; - } - }); -} - -// --------------------------------------------------------------------------- -// Tabs -// --------------------------------------------------------------------------- - -const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient']; -const RADIO_SUB_TABS = ['search', 'saved', 'history']; - -// --------------------------------------------------------------------------- -// URL hash routing — makes tabs deep-linkable/shareable and lets the browser -// back/forward buttons move between them. Deliberately hash-based rather -// than real paths: /podcasts/ and /books/ etc. are already taken by the -// JSON API, so this avoids touching Django's urls.py at all. -// --------------------------------------------------------------------------- -let _routingFromHash = false; -let _currentTopTab = 'radio'; - -function _syncHash() { - if (_routingFromHash) return; - let hash = _currentTopTab; - if (_currentTopTab === 'radio') { - hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved'); - } - if (location.hash.slice(1) !== hash) history.pushState(null, '', '#' + hash); -} - -function _routeFromHash() { - const [tab, sub] = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean); - if (!TOP_TABS.includes(tab)) return; - _routingFromHash = true; - try { - showTab(tab); - if (tab === 'radio' && RADIO_SUB_TABS.includes(sub)) showRadioTab(sub); - } finally { - _routingFromHash = false; - } -} - -window.addEventListener('popstate', _routeFromHash); -window.addEventListener('hashchange', _routeFromHash); - -function showOfflineBanner(offline) { - document.body.classList.toggle('app-offline', offline); - const uploadArea = $('book-upload-area'); - if (uploadArea) uploadArea.style.display = offline ? 'none' : ''; -} - -function showTab(name) { - TOP_TABS.forEach(p => { - const panel = $(`tab-${p}`); - if (panel) panel.style.display = (p === name) ? '' : 'none'; - }); - - document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => { - btn.classList.toggle('active', TOP_TABS[i] === name); - }); - - localStorage.setItem('diora_active_tab', name); - _currentTopTab = name; - _syncHash(); - - if (name === 'books') loadBookList(); - if (name === 'ambient') _ambientLoad(); - if (name === 'home' && window.__dioraHomeShow) window.__dioraHomeShow(); // static/js/home.js - if (name === 'feeds' && window.__dioraFeedsShow) window.__dioraFeedsShow(); // static/js/feeds.js -} - -// --------------------------------------------------------------------------- -// 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; -let _ambientLast = false; // Ambient was started last: the player bar shows it (native-podcast.js) - -function _ambientFrame() { - return $('ambient-frame'); -} - -function _ambientLoad() { - const f = _ambientFrame(); - if (!f) return; - if (!f.getAttribute('src')) { - // Drift takes the page's volume before its first sound; the bar follows its sound world. - f.addEventListener('load', () => { - _ambientVolume(); - try { - const sel = f.contentDocument.getElementById('mode'); - // The sound world chosen last comes back (Drift itself starts with its default). - let last = ''; - try { last = localStorage.getItem('diora_ambient_mode') || ''; } catch (e) {} - if (last && last !== sel.value && [...sel.options].some(o => o.value === last)) { - f.contentWindow.postMessage({type: 'ambient', cmd: 'mode', mode: last}, '*'); - } - sel.addEventListener('change', () => { - try { - localStorage.setItem('diora_ambient_mode', sel.value); - localStorage.setItem('diora_ambient_mode_label', sel.selectedOptions[0] ? sel.selectedOptions[0].text : ''); - } catch (e) {} - if (window.__dioraAmbientChanged) window.__dioraAmbientChanged(); - }); - } catch (e) {} - }); - 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 bar at the bottom grows or shrinks (the player appears, a second line …): its height -// comes as --nui-dock-h / --dpw-bar-h on , set inline — measure again then. -new MutationObserver(() => requestAnimationFrame(_ambientSize)) - .observe(document.documentElement, {attributes: true, attributeFilter: ['style']}); - -// A command for Drift ('start' | 'stop'): the app's notification, the sleep timer. -window.__dioraAmbientCmd = function (cmd, value) { - const f = _ambientFrame(); - if (f && f.contentWindow && f.getAttribute('src')) f.contentWindow.postMessage({type: 'ambient', cmd, value}, '*'); -}; - -// Start Drift from elsewhere (Home): load the frame if needed, then start it — with the -// sound world chosen last, which the frame's load handler above sets. -window.__dioraAmbientStart = function () { - const f = _ambientFrame(); - if (!f) return; - const go = () => window.__dioraAmbientCmd('start'); - let ready = false; - try { ready = !!f.getAttribute('src') && f.contentDocument.readyState === 'complete' && !!f.contentDocument.getElementById('mode'); } catch (e) {} - if (ready) { go(); return; } - f.addEventListener('load', () => setTimeout(go, 0), {once: true}); - _ambientLoad(); -}; - -// The radio or a podcast starts: Ambient stops, and the player bar is theirs again. -window.__dioraAmbientStop = function () { - if (_ambientPlaying) window.__dioraAmbientCmd('stop'); - if (_ambientLast) { - _ambientLast = false; - if (window.__dioraAmbientChanged) window.__dioraAmbientChanged(); - } -}; -window.__dioraAmbientPlaying = () => _ambientPlaying; - -// For the player bar: playing, started last, the sound world's name (the frame is ours). -window.__dioraAmbientInfo = function () { - const f = _ambientFrame(); - let mode = ''; - try { - const sel = f && f.contentDocument && f.contentDocument.getElementById('mode'); - if (sel && sel.selectedOptions[0]) mode = sel.selectedOptions[0].text; - } catch (e) {} - return {playing: _ambientPlaying, last: _ambientLast, mode}; -}; - -// The page's volume (0–255, the bar's slider) is Drift's too; its own slider hides when -// embedded. Not in the Android app: there the volume keys set it, as for everything. -function _ambientVolume(val) { - if (window.DioraNative && DioraNative.ambientState) return; - if (val == null) { - const s = $('volume'); - val = s ? +s.value : +(localStorage.getItem('diora_volume') || 102); - } - window.__dioraAmbientCmd && window.__dioraAmbientCmd('volume', val / 255); -} - -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) _ambientLast = true; - if (window.__dioraAmbientChanged) window.__dioraAmbientChanged(); - // The app keeps it going with the screen off (AmbientService); the browser's sleep timer counts it. - if (window.DioraNative && DioraNative.ambientState) DioraNative.ambientState(_ambientPlaying); - if (window.__dioraOnAmbient) window.__dioraOnAmbient(_ambientPlaying); - 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}`); - if (panel) panel.style.display = (p === name) ? '' : 'none'; - }); - - document.querySelectorAll('#radio-sub-tabs .tab-btn').forEach((btn, i) => { - btn.classList.toggle('active', RADIO_SUB_TABS[i] === name); - }); - - localStorage.setItem('diora_active_radio_tab', name); - _syncHash(); - - if (name === 'saved') loadRecommendations(); -} - -// --------------------------------------------------------------------------- -// Sidebar -// --------------------------------------------------------------------------- - -function openSidebar(title, htmlContent) { - $('sidebar-title').textContent = title; - $('sidebar-body').innerHTML = sanitizeSidebarHtml(htmlContent); - $('sidebar-overlay').style.display = ''; - $('sidebar').classList.add('open'); -} - -function closeSidebar() { - $('sidebar').classList.remove('open'); - $('sidebar-overlay').style.display = 'none'; -} - -document.addEventListener('keydown', e => { - const overlay = $('reader-overlay'); - const readerOpen = overlay && overlay.style.display !== 'none'; - - if (e.key === 'Escape') { - if (readerOpen) { - closeReader(); - } else { - closeSidebar(); - } - } - - if (readerOpen) { - if ((e.ctrlKey && e.key === 'f') || e.key === 'F3') { - e.preventDefault(); - toggleReaderSearch(); - } - if (readerSearchOpen) { - if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); } - if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); } - } - if (readerSettings.pdfPaginated && currentPdfDoc) { - if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); } - if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); } - } - - // Vim-style scroll — ignore when typing in an input - const tag = document.activeElement?.tagName; - if (tag !== 'INPUT' && tag !== 'TEXTAREA') { - const contentEl = $('reader-content'); - if (contentEl) { - const large = Math.round(contentEl.clientHeight * 0.85); - const continuousKeys = {'j': 1, 'k': -1}; - if (continuousKeys[e.key] !== undefined && !e.repeat) { - e.preventDefault(); - _readerScrollDir = continuousKeys[e.key]; - if (!_readerScrollInterval) { - _readerScrollLastTs = performance.now(); - _readerScrollInterval = setInterval(_readerScrollTick, READER_SCROLL_TICK_MS); - } - } - if (e.key === 'd') { e.preventDefault(); contentEl.scrollBy({top: large / 2, behavior: 'smooth'}); } - if (e.key === 'u') { e.preventDefault(); contentEl.scrollBy({top: -large / 2, behavior: 'smooth'}); } - if (e.key === 'f') { e.preventDefault(); contentEl.scrollBy({top: large, behavior: 'smooth'}); } - if (e.key === 'b') { e.preventDefault(); contentEl.scrollBy({top: -large, behavior: 'smooth'}); } - if (e.key === 'g') { e.preventDefault(); contentEl.scrollTop = 0; } - if (e.key === 'G') { e.preventDefault(); contentEl.scrollTop = contentEl.scrollHeight; } - if (currentPdfDoc && !readerSettings.pdfPaginated) { - if (e.key === 'n') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); } - if (e.key === 'p') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); } - } - } - } - } -}); - -document.addEventListener('keyup', e => { - if (e.key === 'j' || e.key === 'k') { - clearInterval(_readerScrollInterval); - _readerScrollInterval = null; - _readerScrollDir = 0; - } -}); - -function sanitizeSidebarHtml(html) { - if (!html) return '

Keine Shownotes vorhanden.

'; - const div = document.createElement('div'); - div.innerHTML = html; - div.querySelectorAll('script, iframe, object, embed, style').forEach(el => el.remove()); - div.querySelectorAll('*').forEach(el => { - Array.from(el.attributes).forEach(attr => { - if (attr.name.startsWith('on')) el.removeAttribute(attr.name); - }); - if (el.tagName === 'A') { - el.setAttribute('target', '_blank'); - el.setAttribute('rel', 'noopener noreferrer'); - } - }); - return div.innerHTML; -} - -// --------------------------------------------------------------------------- -// Service Worker -// --------------------------------------------------------------------------- - -if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - // Registered from '/sw.js' (root), not '/static/js/sw.js': a service - // worker's default scope is its own script's directory, so registering - // it from under /static/js/ would only ever let it control requests - // under that path — page navigations to '/', '/books/' etc. would never - // be intercepted, breaking offline entirely. - navigator.serviceWorker.register('/sw.js', {scope: '/'}).catch(err => { - console.warn('Service worker registration failed:', err); - }); - // Drop any leftover registration from the old, wrongly-scoped '/static/js/sw.js' - // path so it doesn't keep running or fight the new root-scoped worker. - navigator.serviceWorker.getRegistrations().then(regs => { - regs.forEach(r => { if (r.scope.endsWith('/static/js/')) r.unregister(); }); - }); - }); - // When a new SW activates it sends SW_ACTIVATED — reload to get fresh assets, - // but never in the middle of something: not while a book is open, radio or a - // podcast plays, or a text field has the focus — then as soon as that's over. - let reloadPending = false; - const busy = () => { - if (currentBookId || isPlaying) return true; - const host = window.DioraPodcastHost; - if (host && host.playerState) { - try { if (JSON.parse(host.playerState()).playing) return true; } catch (_) {} - } - const el = document.activeElement; - return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)); - }; - const reloadWhenIdle = () => { - if (busy()) setTimeout(reloadWhenIdle, 5000); - else window.location.reload(); - }; - navigator.serviceWorker.addEventListener('message', e => { - if (e.data?.type === 'SW_ACTIVATED' && !reloadPending) { - reloadPending = true; - reloadWhenIdle(); - } - }); -} - -// --------------------------------------------------------------------------- -// M3U import -// --------------------------------------------------------------------------- - -async function importM3U(input) { - const file = input.files[0]; - if (!file) return; - const status = document.getElementById('import-status'); - status.textContent = 'Importiere …'; - - const form = new FormData(); - form.append('file', file); - form.append('csrfmiddlewaretoken', getCsrfToken()); - - try { - const res = await fetch('/radio/import/', { method: 'POST', body: form }); - const data = await res.json(); - if (data.ok) { - status.textContent = `✓ ${data.added} hinzugefügt, ${data.skipped} schon gespeichert`; - if (data.added > 0) location.reload(); - } else { - status.textContent = `Fehler: ${_germanError(data.error)}`; - } - } catch (e) { - status.textContent = 'Hochladen fehlgeschlagen'; - } - input.value = ''; -} - -// --------------------------------------------------------------------------- -// Contrast scheme -// --------------------------------------------------------------------------- - -// Accent palette — ordered by preference. Algorithm picks the one with the -// highest WCAG contrast ratio against the detected background luminance. -const ACCENT_PALETTE = [ - { base: '#e63946', hover: '#ff4d58' }, // red - { base: '#ff9500', hover: '#ffaa33' }, // orange - { base: '#f1c40f', hover: '#f9d439' }, // yellow - { base: '#2ecc71', hover: '#4ee88a' }, // green - { base: '#00b4d8', hover: '#33c7e5' }, // cyan - { base: '#4361ee', hover: '#6d84f4' }, // blue - { base: '#c77dff', hover: '#d89fff' }, // purple - { base: '#ff6b9d', hover: '#ff8fb5' }, // pink - { base: '#ffffff', hover: '#cccccc' }, // white (last resort) -]; - -function _linearise(c) { - c /= 255; - return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); -} -function _luminance(r, g, b) { - return 0.2126 * _linearise(r) + 0.7152 * _linearise(g) + 0.0722 * _linearise(b); -} -function _contrast(l1, l2) { - return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); -} -function _hexRgb(hex) { - return [parseInt(hex.slice(1,3),16), parseInt(hex.slice(3,5),16), parseInt(hex.slice(5,7),16)]; -} - -function analyzeBackground(url) { - return new Promise(resolve => { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = 64; canvas.height = 64; - const ctx = canvas.getContext('2d'); - ctx.drawImage(img, 0, 0, 64, 64); - const data = ctx.getImageData(0, 0, 64, 64).data; - let tR = 0, tG = 0, tB = 0, tBt601 = 0; - const n = data.length / 4; - for (let i = 0; i < data.length; i += 4) { - tR += data[i]; tG += data[i+1]; tB += data[i+2]; - tBt601 += 0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2]; - } - resolve({ - bright: (tBt601 / n) > 127, - bgLuminance: _luminance(tR/n, tG/n, tB/n), - }); - }; - img.onerror = () => resolve({ bright: false, bgLuminance: 0 }); - img.src = url; - }); -} - -function pickBestAccent(bgLuminance) { - let best = ACCENT_PALETTE[0], bestRatio = 0; - for (const entry of ACCENT_PALETTE) { - const [r, g, b] = _hexRgb(entry.base); - const ratio = _contrast(bgLuminance, _luminance(r, g, b)); - if (ratio > bestRatio) { bestRatio = ratio; best = entry; } - } - return best; -} - -function applyAccent(entry) { - const root = document.documentElement; - root.style.setProperty('--accent', entry.base); - 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'); - if (btn) btn.style.opacity = bright ? '1' : '0.5'; -} - -function toggleContrast() { - setScheme(!document.body.classList.contains('bright-bg')); -} - -// --------------------------------------------------------------------------- -// E2E Encryption utilities (Web Crypto API) -// --------------------------------------------------------------------------- - -let _encKey = null; - -function bytesToBase64(buf) { - const bytes = new Uint8Array(buf); - let str = ''; - for (const b of bytes) str += String.fromCharCode(b); - return btoa(str); -} - -function base64ToBytes(b64) { - const str = atob(b64); - const buf = new Uint8Array(str.length); - for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i); - return buf; -} - -function bytesToHex(buf) { - return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); -} - -function hexToBytes(hex) { - const arr = new Uint8Array(hex.length / 2); - for (let i = 0; i < arr.length; i++) arr[i] = parseInt(hex.slice(i*2, i*2+2), 16); - return arr; -} - -async function getOrCreateEncKey() { - if (_encKey) return _encKey; - const storageKey = `diora_enc_key_${window.USER_ID || 0}`; - const stored = localStorage.getItem(storageKey); - if (stored) { - try { - const raw = base64ToBytes(stored); - _encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']); - return _encKey; - } catch (e) { /* fall through, generate new */ } - } - // No key found — generate one and store it - _encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); - const raw = await crypto.subtle.exportKey('raw', _encKey); - localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw))); - return _encKey; -} - -async function encryptBytes(key, plainBytes) { - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, plainBytes); - return {iv: bytesToHex(iv), ciphertext: bytesToBase64(ct)}; -} - -async function decryptBytes(key, ivHex, ctB64) { - const iv = hexToBytes(ivHex); - const ct = base64ToBytes(ctB64); - return crypto.subtle.decrypt({name: 'AES-GCM', iv}, key, ct); -} - -// --------------------------------------------------------------------------- -// Encrypted wallpaper -// --------------------------------------------------------------------------- - -async function uploadBackground(file) { - if (!file) return; - const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; - if (!allowedTypes.includes(file.type)) { - await customAlert('Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.'); - return; - } - if (file.size > DIORA_CONFIG.bgMaxBytes) { - await customAlert(`Das Bild darf höchstens ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB groß sein.`); - return; - } - - const key = await getOrCreateEncKey(); - const buf = await file.arrayBuffer(); - const {iv, ciphertext} = await encryptBytes(key, buf); - - const res = await fetch('/accounts/background/upload/', { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: JSON.stringify({iv, ciphertext, mime_type: file.type, file_size: file.size}), - }); - const data = await res.json(); - if (!data.ok) throw new Error(data.error || 'upload failed'); -} - -async function applyEncryptedBackground() { - if (typeof ENCRYPTED_BG === 'undefined' || !ENCRYPTED_BG.ciphertext) return; - try { - const key = await getOrCreateEncKey(); - const plain = await decryptBytes(key, ENCRYPTED_BG.iv, ENCRYPTED_BG.ciphertext); - 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'; - - // Analyze brightness for accent/scheme - analyzeBackground(url).then(({bright, bgLuminance}) => { - setScheme(bright); - applyAccent(pickBestAccent(bgLuminance)); - }); - } catch (e) { - console.warn('Could not decrypt background:', e); - } -} - -// --------------------------------------------------------------------------- -// EPUB parser (requires JSZip) -// --------------------------------------------------------------------------- - -function resolveEpubPath(base, relative) { - if (!relative) return ''; - if (relative.startsWith('/')) return relative.slice(1); - const hashIdx = relative.indexOf('#'); - const frag = hashIdx >= 0 ? relative.slice(hashIdx) : ''; - const rel = hashIdx >= 0 ? relative.slice(0, hashIdx) : relative; - const parts = (base + rel).split('/'); - const resolved = []; - for (const p of parts) { - if (p === '..') resolved.pop(); - else if (p !== '.') resolved.push(p); - } - return resolved.join('/') + frag; -} - -async function parseEpub(arrayBuffer) { - const zip = await JSZip.loadAsync(arrayBuffer); - - // 1. Find OPF via container.xml - const containerXml = await zip.file('META-INF/container.xml').async('text'); - const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml'); - const rootfileEl = containerDoc.querySelector('rootfile'); - if (!rootfileEl) throw new Error('Ungültige EPUB-Datei (container.xml ohne rootfile)'); - const opfPath = rootfileEl.getAttribute('full-path'); - const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ''; - - // 2. Parse OPF - const opfText = await zip.file(opfPath).async('text'); - const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); - - const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unbekannter Titel'; - const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unbekannter Autor'; - - // 3. Build manifest: id → {href, mediaType, properties} - const manifest = {}; - opfDoc.querySelectorAll('manifest > item').forEach(item => { - manifest[item.getAttribute('id')] = { - href: opfDir + item.getAttribute('href'), - mediaType: item.getAttribute('media-type') || '', - properties: item.getAttribute('properties') || '', - }; - }); - - // 4. Build image map: abs zip path → blob URL (parallel) - const imageMap = {}; - await Promise.all( - Object.values(manifest) - .filter(({mediaType}) => mediaType.startsWith('image/')) - .map(async ({href, mediaType}) => { - try { - const buf = await zip.file(href).async('arraybuffer'); - imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType})); - } catch (e) { /* missing asset */ } - }) - ); - - // 5. Parse TOC - const toc = await _parseEpubToc(zip, opfDoc, manifest); - - // 6. Get spine items; chapters are returned raw for progressive DOM injection - const spineItems = Array.from(opfDoc.querySelectorAll('spine > itemref')) - .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 = 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, ''); - chapterText = chapterText.replace(/]*>[\s\S]*?<\/script>/gi, ''); - const chapterDir = href.includes('/') ? href.substring(0, href.lastIndexOf('/') + 1) : ''; - const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap); - const sanitized = sanitizeEpubHtml(withBlobs); - chapters.push(`
${sanitized}
`); - // 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}; -} - -async function _parseEpubToc(zip, opfDoc, manifest) { - // Try EPUB3 nav document - const navItem = Object.values(manifest).find(m => m.properties.includes('nav')); - if (navItem) { - try { - const navText = await zip.file(navItem.href).async('text'); - const navDoc = new DOMParser().parseFromString(navText, 'application/xhtml+xml'); - const tocNav = navDoc.querySelector('nav[epub\\:type="toc"]') || navDoc.querySelector('nav'); - if (tocNav) { - const ol = tocNav.querySelector('ol'); - if (ol) return _parseTocOl(ol, navItem.href, 0); - } - } catch (e) {} - } - - // Fall back to EPUB2 NCX - const ncxItem = Object.values(manifest).find(m => m.mediaType === 'application/x-dtbncx+xml'); - if (ncxItem) { - try { - const ncxText = await zip.file(ncxItem.href).async('text'); - const ncxDoc = new DOMParser().parseFromString(ncxText, 'application/xml'); - const ncxDir = ncxItem.href.includes('/') ? ncxItem.href.substring(0, ncxItem.href.lastIndexOf('/') + 1) : ''; - return _parseNcxNavMap(ncxDoc.querySelector('navMap'), ncxDir, 0); - } catch (e) {} - } - - return []; -} - -function _parseTocOl(ol, navHref, depth) { - if (!ol || depth > 5) return []; - const navDir = navHref.includes('/') ? navHref.substring(0, navHref.lastIndexOf('/') + 1) : ''; - const items = []; - for (const li of Array.from(ol.children)) { - const a = li.querySelector(':scope > a') || li.querySelector(':scope > span'); - if (a) { - const rawHref = a.getAttribute('href') || ''; - items.push({ - label: a.textContent.trim(), - href: rawHref ? resolveEpubPath(navDir, rawHref) : '', - depth, - }); - } - const childOl = li.querySelector(':scope > ol'); - if (childOl) items.push(..._parseTocOl(childOl, navHref, depth + 1)); - } - return items; -} - -function _parseNcxNavMap(navMap, ncxDir, depth) { - if (!navMap || depth > 5) return []; - const items = []; - for (const navPoint of Array.from(navMap.children)) { - if (navPoint.tagName !== 'navPoint') continue; - const label = navPoint.querySelector('navLabel > text')?.textContent?.trim() || ''; - const src = navPoint.querySelector('content')?.getAttribute('src') || ''; - items.push({label, href: src ? resolveEpubPath(ncxDir, src) : '', depth}); - items.push(..._parseNcxNavMap(navPoint, ncxDir, depth + 1)); - } - return items; -} - -function _resolveImageBlob(imageMap, absPath) { - if (imageMap[absPath]) return imageMap[absPath]; - // Fallback: match by decoded filename only - const name = decodeURIComponent(absPath.split('/').pop()).toLowerCase(); - for (const [k, v] of Object.entries(imageMap)) { - if (decodeURIComponent(k.split('/').pop()).toLowerCase() === name) return v; - } - return null; -} - -// Replace image src/href in raw chapter HTML text with blob URLs before DOMParser sees it. -// This avoids relying on innerHTML serialisation preserving attributes we set on DOM nodes. -function _injectImageBlobs(html, chapterDir, imageMap) { - function subst(src) { - if (!src || src.startsWith('blob:') || src.startsWith('data:') || src.startsWith('http')) return src; - return _resolveImageBlob(imageMap, resolveEpubPath(chapterDir, src)) || ''; - } - // - html = html.replace(/(]*?)\bsrc="([^"]*)"/gi, - (_, pre, src) => { const b = subst(src); return b ? `${pre}src="${b}"` : pre; }); - // SVG - html = html.replace(/(]*?)\bxlink:href="([^"]*)"/gi, - (_, pre, src) => { const b = subst(src); return b ? `${pre}xlink:href="${b}"` : pre; }); - // SVG - html = html.replace(/(]*?)\bhref="([^"]*)"/gi, - (_, pre, src) => { const b = subst(src); return b ? `${pre}href="${b}"` : pre; }); - return html; -} - -// EPUBs mark footnote/endnote reference links very inconsistently across -// editions/tools — some use class="footnote" (same-file '#frag' href), some -// use the EPUB3 epub:type="noteref" attribute, and some (e.g. older -// Calibre/zeno.org conversions) use neither and just wrap a plain link to -// another chapter file's fragment in a — in either direction -// (… or …). Recognize all of these -// rather than only the single hardcoded class name. -function _looksLikeFootnoteLink(el, href) { - if (!href.includes('#')) return false; - const cls = (el.getAttribute('class') || '').toLowerCase(); - if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true; - const epubType = (el.getAttribute('epub:type') || '').toLowerCase(); - if (epubType.includes('noteref')) return true; - 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()); - - doc.querySelectorAll('*').forEach(el => { - Array.from(el.attributes).forEach(attr => { - if (attr.name.startsWith('on') || attr.name === 'title') el.removeAttribute(attr.name); - }); - const tag = el.tagName.toLowerCase(); - if (tag === 'img') { - const src = el.getAttribute('src') || ''; - // 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://')) { - el.setAttribute('target', '_blank'); - el.setAttribute('rel', 'noopener noreferrer'); - } else if (_looksLikeFootnoteLink(el, href)) { - // The whole book is one concatenated DOM, so a fragment id resolves - // via getElementById regardless of which chapter file it originally - // pointed into — strip any "../Text/other.html" path prefix and keep - // just the id after the last '#'. - el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1); - el.removeAttribute('href'); - el.style.cursor = 'pointer'; - } else { - el.removeAttribute('href'); - el.style.cursor = 'default'; - } - } - }); - - return doc.body ? doc.body.innerHTML : doc.documentElement.innerHTML; -} - -// --------------------------------------------------------------------------- -// Books -// --------------------------------------------------------------------------- - -let currentBookId = null; -let currentBookToc = []; -let currentImageMap = {}; -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" -// coordinates): a whole percent is fine for a normal book, but on a long one -// (~600-700 physical pages and up) it barely moves during a whole scrolling -// session, which reads as broken. Past that length, show one decimal — -// formatted with the browser locale's own decimal separator. -const LONG_BOOK_CHAR_THRESHOLD = 1000000; - -function _formatBookPercent(fraction) { - const pct = Math.max(0, Math.min(100, fraction * 100)); - const decimals = _currentBookCharCount > LONG_BOOK_CHAR_THRESHOLD ? 1 : 0; - return pct.toLocaleString(undefined, {minimumFractionDigits: decimals, maximumFractionDigits: decimals}); -} - -// Accepts both '45.3' and '45,3' (locale-typed input) -function _parseLocalePercent(str) { - const v = parseFloat(String(str).trim().replace(',', '.')); - return isNaN(v) ? 0 : v; -} - -// --------------------------------------------------------------------------- -// Reader "scrolled too far" safety net — offers a toast to jump back after a -// sudden, large scroll (accidental fling, fat-fingered keybind, etc.). Only -// watches organic scrolling; explicit destination jumps (TOC, bookmarks, the -// progress input, search) call _suppressScrollJumpDetect() first so they -// don't trigger it. -// --------------------------------------------------------------------------- -let _scrollRestTop = null; // scrollTop last "settled" at, before the current burst -let _scrollRestDebounce = null; -let _scrollJumpSuppressed = false; // true while an explicit/programmatic jump is in flight -let _scrollJumpToastEl = null; - -// Marks the current (and any immediately-following, possibly animated) scroll -// as deliberate, so _checkScrollJump ignores it. Suppression lifts itself -// once scrolling actually settles (see _armScrollJumpSettle) — not after a -// fixed delay — so it stays active for however long a `smooth` scroll -// animation to a far-away target actually takes, and calling this again -// (e.g. from repeated resize-triggered corrections while a book settles) -// simply pushes that settle point further out instead of racing it. -function _suppressScrollJumpDetect() { - _scrollJumpSuppressed = true; - _armScrollJumpSettle(); -} - -function _armScrollJumpSettle() { - clearTimeout(_scrollRestDebounce); - _scrollRestDebounce = setTimeout(() => { - const contentEl = $('reader-content'); - if (contentEl) _scrollRestTop = contentEl.scrollTop; - _scrollJumpSuppressed = false; - }, 500); -} - -function _dismissScrollJumpToast() { - if (_scrollJumpToastEl) { - _scrollJumpToastEl.remove(); - _scrollJumpToastEl = null; - } - if (_readPos && _readPos.hold === 'jump') _readPos.hold = false; -} - -function _showScrollJumpToast(backTop) { - _dismissScrollJumpToast(); - const toast = document.createElement('div'); - toast.className = 'reader-toast reader-toast-action'; - 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) { - _suppressScrollJumpDetect(); - contentEl.scrollTo({top: backTop, behavior: 'smooth'}); - } - _dismissScrollJumpToast(); - // Back where it was: that's no new reading. - _readerInteractedAt = 0; - }); - 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) { - const top = contentEl.scrollTop; - if (_scrollRestTop === null) _scrollRestTop = top; // first sample this session - - if (!_scrollJumpSuppressed && !_scrollJumpToastEl) { - const screen = contentEl.clientHeight || 1; - if (Math.abs(top - _scrollRestTop) > screen * 3.5) { - _showScrollJumpToast(_scrollRestTop); - } - } - - // Only adopt the new position as the "settled" baseline — and lift any - // suppression — once scrolling has paused for a bit. That's what lets a - // fast, still-in-progress fling (or a long smooth-scroll animation) be - // compared against/protected until it actually finishes, not mid-flight. - _armScrollJumpSettle(); -} - -const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))'; - -// 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; - // 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++) { - 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)}`; -} - -// 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(':'); - if (parts.length !== 2) return false; - const idx = parseInt(parts[0], 10); - const innerFraction = parseFloat(parts[1]); - if (isNaN(idx) || isNaN(innerFraction)) return false; - const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); - if (idx >= blocks.length) return false; - const el = blocks[idx]; - contentEl.scrollTop = el.offsetTop + Math.round(innerFraction * el.offsetHeight); - return true; -} - -// Split 'blockIndex:innerFraction' into [block, inner]; [-1, 0] if invalid/empty. -function _anchorParts(anchor) { - if (typeof anchor !== 'string') return [-1, 0]; - const c = anchor.indexOf(':'); - if (c < 0) return [-1, 0]; - const b = parseInt(anchor.slice(0, c), 10); - const f = parseFloat(anchor.slice(c + 1)); - return [Number.isFinite(b) ? b : -1, Number.isFinite(f) ? f : 0]; -} - -// >0 if position A is further into the book than B, <0 if behind, 0 if equal. -// Compares by anchor block index (precise); falls back to scroll_fraction only -// when an anchor is missing (e.g. PDFs), matching the server's _progress_is_further. -function _cmpProgress(anchorA, fracA, anchorB, fracB) { - const [ba, ia] = _anchorParts(anchorA); - const [bb, ib] = _anchorParts(anchorB); - if (ba >= 0 && bb >= 0) return ba !== bb ? ba - bb : ia - ib; - 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 -// --------------------------------------------------------------------------- -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', 3); - req.onupgradeneeded = e => { - const db = e.target.result; - if (!db.objectStoreNames.contains(_BOOK_CACHE_STORE)) - db.createObjectStore(_BOOK_CACHE_STORE, {keyPath: 'id'}); - if (!db.objectStoreNames.contains(_BOOK_META_STORE)) - 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(); - return await new Promise((resolve) => { - const req = db.transaction(_BOOK_CACHE_STORE).objectStore(_BOOK_CACHE_STORE).get(bookId); - req.onsuccess = e => resolve(e.target.result || null); - req.onerror = () => resolve(null); - }); - } catch { return null; } -} - -async function _setCachedBook(bookId, data_ct, data_iv) { - try { - const db = await _openBookCacheDb(); - await new Promise((resolve) => { - const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); - tx.objectStore(_BOOK_CACHE_STORE).put({id: bookId, data_ct, data_iv, cached_at: Date.now()}); - tx.oncomplete = resolve; - tx.onerror = resolve; - }); - // Request persistent storage so the browser doesn't evict cached books - // under storage pressure. Chrome grants this silently for installed PWAs; - // Firefox may show a permission prompt. - if (navigator.storage?.persist) navigator.storage.persist(); - } catch {} -} - -async function _evictCachedBook(bookId) { - try { - const db = await _openBookCacheDb(); - await new Promise((resolve) => { - const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); - tx.objectStore(_BOOK_CACHE_STORE).delete(bookId); - tx.oncomplete = resolve; - tx.onerror = resolve; - }); - } catch {} -} - -async function _evictBookCache(bookList) { - try { - const db = await _openBookCacheDb(); - const now = Date.now(); - const metaById = Object.fromEntries(bookList.map(b => [b.id, b])); - await new Promise((resolve) => { - const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); - const store = tx.objectStore(_BOOK_CACHE_STORE); - store.openCursor().onsuccess = e => { - const cursor = e.target.result; - if (!cursor) { resolve(); return; } - const meta = metaById[cursor.value.id]; - const lastRead = meta?.last_read ? new Date(meta.last_read).getTime() : 0; - if (!meta || (now - lastRead) > _BOOK_CACHE_EVICT_MS) cursor.delete(); - cursor.continue(); - }; - tx.onerror = resolve; - }); - } catch {} -} - -async function _saveBookMeta(books) { - try { - const db = await _openBookCacheDb(); - const ids = new Set(books.map(b => String(b.id))); - await new Promise(resolve => { - const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); - const store = tx.objectStore(_BOOK_META_STORE); - for (const b of books) store.put(b); - store.openCursor().onsuccess = e => { - const cursor = e.target.result; - if (!cursor) return; - if (!ids.has(String(cursor.value.id))) cursor.delete(); - cursor.continue(); - }; - tx.oncomplete = resolve; - tx.onerror = resolve; - }); - } catch {} -} - -async function _loadCachedBookMeta() { - try { - const db = await _openBookCacheDb(); - return await new Promise(resolve => { - const req = db.transaction(_BOOK_META_STORE).objectStore(_BOOK_META_STORE).getAll(); - req.onsuccess = e => resolve(e.target.result || []); - req.onerror = () => resolve([]); - }); - } catch { return []; } -} - -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 => { - const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); - const store = tx.objectStore(_BOOK_META_STORE); - const req = store.get(bookId); - req.onsuccess = e => { - const entry = e.target.result; - // 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.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); - } - }; - tx.oncomplete = resolve; - tx.onerror = resolve; - }); - } catch {} -} - -async function _saveAnnotationsCache(bookId, type, ct, iv) { - try { - const db = await _openBookCacheDb(); - await new Promise(resolve => { - const tx = db.transaction(_BOOK_ANNOTATIONS_STORE, 'readwrite'); - tx.objectStore(_BOOK_ANNOTATIONS_STORE).put({id: `${bookId}_${type}`, ct, iv}); - tx.oncomplete = resolve; - tx.onerror = resolve; - }); - } catch {} -} - -async function _loadAnnotationsCache(bookId, type) { - try { - const db = await _openBookCacheDb(); - return await new Promise(resolve => { - const req = db.transaction(_BOOK_ANNOTATIONS_STORE).objectStore(_BOOK_ANNOTATIONS_STORE).get(`${bookId}_${type}`); - req.onsuccess = e => resolve(e.target.result || null); - req.onerror = () => resolve(null); - }); - } catch { return null; } -} - -const _PENDING_SYNC_KEY = 'diora_pending_sync'; - -function _markPendingSync(bookId, type) { - try { - const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]'); - const key = `${bookId}:${type}`; - if (!pending.includes(key)) pending.push(key); - localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(pending)); - } catch {} -} - -async function _syncPendingAnnotations() { - try { - const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]'); - if (!pending.length) return; - const remaining = []; - for (const key of pending) { - const sep = key.indexOf(':'); - const bookId = key.slice(0, sep); - const type = key.slice(sep + 1); - const cached = await _loadAnnotationsCache(bookId, type); - if (!cached?.ct) continue; - const url = type === 'bookmarks' - ? `/books/${bookId}/bookmarks/` - : `/books/${bookId}/highlights/`; - try { - await fetch(url, { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: JSON.stringify({ct: cached.ct, iv: cached.iv}), - }); - } catch { remaining.push(key); } - } - localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(remaining)); - } catch {} -} - -// Reader settings -let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', - fontFamily: 'serif', noBold: false, fontWeight: null, - pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }; -let readerSettingsPanelOpen = false; -let currentPdfDoc = null; -let currentPdfBuffer = null; - -// Bookmarks -let currentBookmarks = []; -let bookmarksDirty = false; - -// Highlights -let currentHighlights = []; -let highlightsDirty = false; -let currentHighlightPopover = null; - -// Search -let searchMatches = []; -let searchMatchIndex = -1; -let searchOriginalContent = null; -let readerSearchOpen = false; - -// PDF paginated -let pdfCurrentPage = 1; -let pdfTotalPages = 0; -let _pdfPageTextBoxCache = {}; -let _pdfRenderGen = 0; -let _touchStartX = 0; -let _readerScrollInterval = null; -let _readerScrollDir = 0; -let _readerScrollLastTs = 0; -const READER_SCROLL_SPEED = 1200; // constant px/s, no ramp-up -const READER_SCROLL_TICK_MS = 20; // fixed cadence, independent of the - // display's refresh rate — rAF runs at - // that rate (120/144/165Hz on plenty of - // laptops), which was driving 2-3x more - // scrollBy + scroll-listener work per - // second than intended and read as lag. -function _readerScrollTick() { - const now = performance.now(); - const dt = now - _readerScrollLastTs; - _readerScrollLastTs = now; - $('reader-content')?.scrollBy({top: Math.round(_readerScrollDir * READER_SCROLL_SPEED * dt / 1000)}); -} -let _pinchStartDist = 0; -let _pinchStartZoom = 100; -let _isPinching = false; - -if (typeof pdfjsLib !== 'undefined') { - pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js'; -} - -async function loadBookList() { - if (!IS_AUTHENTICATED) return; - const listEl = $('book-list'); - if (!listEl) return; - listEl.innerHTML = '

Bücher werden geladen …

'; - - 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; - } - const books = await res.json(); - _evictBookCache(books); // fire-and-forget - if (!Array.isArray(books)) { - listEl.innerHTML = `

Unerwartete Antwort vom Server.

`; - return; - } - if (!books.length) { - listEl.innerHTML = '

Noch keine Bücher. Zieh oben eine .epub- oder .pdf-Datei hinein.

'; - return; - } - - listEl.innerHTML = `

${books.length === 1 ? '1 Buch' : books.length + ' Bücher'} auf dem Server. Entschlüssele …

`; - - let key; - try { - key = await getOrCreateEncKey(); - } catch (e) { - listEl.innerHTML = `

Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.

`; - return; - } - - const decrypted = []; - for (const b of books) { - 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 || '', 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 || '', 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 || '', 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) => (_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()).map(_withPendingProgress); - if (cachedBooks.length) { - 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'; - renderBookList(cachedBooks); - const notice = document.createElement('p'); - notice.className = 'muted offline-notice'; - notice.textContent = 'Offline — gespeicherte Bücher. Fortschritt wird beim nächsten Öffnen synchronisiert.'; - listEl.prepend(notice); - } else { - if (listEl) listEl.innerHTML = `

Fehler beim Laden der Bücher: ${e.message}

`; - } - } -} - -const _brokenBooks = new Set(); - -function markBookBroken(bookId) { - _brokenBooks.add(bookId); - const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`); - if (!item) return; - if (!item.querySelector('.book-broken-btn')) { - const btn = document.createElement('button'); - btn.className = 'btn btn-sm btn-danger book-broken-btn'; - btn.title = 'Buch konnte nicht geöffnet werden — Datei erneut hochladen'; - btn.textContent = '!'; - btn.onclick = () => repairBook(bookId); - item.querySelector('.book-item-actions').prepend(btn); - } -} - -function _extractIsbnFromOpf(opfDoc) { - const identifiers = opfDoc.querySelectorAll('metadata > identifier, metadata > *|identifier'); - for (const el of identifiers) { - const digits = (el.textContent || '').replace(/[^0-9Xx]/g, ''); - if (digits.length === 10 || digits.length === 13) return digits; - } - return ''; -} - -function repairBook(bookId) { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.epub,.pdf'; - input.onchange = async () => { - const file = input.files[0]; - if (!file) return; - const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`); - const btn = item?.querySelector('.book-broken-btn'); - if (btn) btn.textContent = '…'; - try { - const key = await getOrCreateEncKey(); - const buf = await file.arrayBuffer(); - const isPdf = /\.pdf$/i.test(file.name); - const type = isPdf ? 'pdf' : 'epub'; - - let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = ''; - try { - if (isPdf) { - const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; - const meta = await pdfDoc.getMetadata(); - title = meta.info?.Title?.trim() || title; - author = meta.info?.Author?.trim() || ''; - } else { - const zip = await JSZip.loadAsync(buf.slice(0)); - const containerXml = await zip.file('META-INF/container.xml').async('text'); - const opfPath = new DOMParser() - .parseFromString(containerXml, 'application/xml') - .querySelector('rootfile')?.getAttribute('full-path'); - if (opfPath) { - const opfDoc = new DOMParser().parseFromString( - await zip.file(opfPath).async('text'), 'application/xml'); - title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; - author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; - isbn = _extractIsbnFromOpf(opfDoc); - } - } - } catch (e) { /* keep filename as title */ } - - // Preserve folder/shelfTag assigned before this repair — replacing the book's - // data shouldn't silently wipe organization the user already set up. - const prevMeta = bookMetaCache[bookId] || {}; - const metaJson = new TextEncoder().encode(JSON.stringify({ - title, author, filename: file.name, type, - isbn: isbn || prevMeta.isbn || '', - folder: prevMeta.folder || '', - shelfTag: prevMeta.shelfTag || '', - })); - const [metaEnc, dataEnc] = await Promise.all([ - encryptBytes(key, metaJson), - encryptBytes(key, buf), - ]); - const res = await fetch(`/books/${bookId}/replace-data/`, { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: JSON.stringify({ - data_ct: dataEnc.ciphertext, data_iv: dataEnc.iv, - meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv, - }), - }); - if (!(await res.json()).ok) throw new Error('Serverfehler'); - await _evictCachedBook(bookId); - _brokenBooks.delete(bookId); - if (btn) btn.remove(); - openBook(bookId); - } catch (e) { - if (btn) btn.textContent = '!'; - await customAlert('Reparatur fehlgeschlagen: ' + e.message); - } - }; - input.click(); -} - -let _lastBookListData = []; -const _bookShowReadKey = 'diora_book_show_read'; -let _bookShowRead = localStorage.getItem(_bookShowReadKey) === '1'; - -function _onBookShowReadToggle(checked) { - _bookShowRead = checked; - localStorage.setItem(_bookShowReadKey, checked ? '1' : '0'); - renderBookList(_lastBookListData); -} - -async function toggleBookRead(bookId, currentlyRead) { - const verb = currentlyRead ? 'als ungelesen markieren' : 'als gelesen markieren'; - if (!await customConfirm(`Dieses Buch ${verb}?`)) return; - try { - const res = await fetch(`/books/${bookId}/read/`, { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: JSON.stringify({is_read: !currentlyRead}), - }); - const data = await res.json(); - if (!data.ok) throw new Error('Serverfehler'); - const book = _lastBookListData.find(b => b.id === bookId); - if (book) book.is_read = data.is_read; - renderBookList(_lastBookListData); - } catch (e) { - await customAlert('Konnte Lesestatus nicht ändern: ' + e.message); - } -} - -// Folder assigned to a book lives inside its encrypted meta blob (like title/author), so the -// server never sees plaintext folder names — no schema change needed, and it rides along for -// free in /api/sync/. One level only: folders don't nest. -let _currentBookFolder = null; // null = root view (folder tiles + unfiled books) - -function _recentlyReadBooks(books) { - return books - .filter(b => b.last_read) - .sort((a, b) => b.last_read.localeCompare(a.last_read)) - .slice(0, 7); -} - -function _renderBookItemHtml(b) { - const pct = Math.round((b.scroll_fraction || 0) * 100); - const keyWarning = b.keyOk === false ? '⚠ falscher Schlüssel' : ''; - const broken = _brokenBooks.has(b.id); - return `
-
- ${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' ✓ gelesen' : ''} - ${escapeHtml(b.author)} - - ${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)}` : ''} - -
-
- -
- -
- ${broken ? `` : ''} - ${b.has_highlights ? `` : ''} - - - - -
-
-
-
`; -} - -function toggleBookMenu(toggleBtn) { - // Looked up relative to the clicked button (not by id) because the same book — and - // thus the same rendered menu — can appear twice at once: once in the "Zuletzt - // gelesen" section and again further down in the folder/unfiled list. - const menu = toggleBtn.closest('.book-item-menu')?.querySelector('.book-item-menu-list'); - if (!menu) return; - const willOpen = !menu.classList.contains('open'); - document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open')); - if (willOpen) menu.classList.add('open'); -} - -// Close any open book menu on an outside click, or right after an item inside it is -// clicked (the toggle button manages its own open/close state above, so it's excluded). -document.addEventListener('click', (e) => { - if (e.target.closest('.book-menu-toggle')) return; - document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open')); -}); - -function _openBookFolder(name) { - _currentBookFolder = name; - renderBookList(_lastBookListData); -} - -function renderBookList(books) { - const listEl = $('book-list'); - if (!listEl) return; - _lastBookListData = books; - - if (!books.length) { - listEl.innerHTML = ''; - return; - } - - let html = ''; - - // Always-visible "recently read" section, independent of folder navigation and the - // read/unread filter below — it's a shortcut back into whatever was open last. Same - // vertical list-item look as the rest of the list (not a card shelf). - const recent = _recentlyReadBooks(books); - if (recent.length) { - html += '
' - + '

Zuletzt gelesen

' - + recent.map(_renderBookItemHtml).join('') - + '
'; - } - - const visible = _bookShowRead ? books : books.filter(b => !b.is_read); - if (!visible.length) { - html += books.length - ? '

Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.

' - : ''; - listEl.innerHTML = html; - return; - } - - const grouped = new Map(); - for (const b of visible) { - const key = b.folder || ''; - if (!grouped.has(key)) grouped.set(key, []); - grouped.get(key).push(b); - } - // Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root. - if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) { - _currentBookFolder = null; - } - - if (_currentBookFolder === null) { - const folderNames = [...grouped.keys()].filter(k => k !== '').sort((a, b) => a.localeCompare(b, 'de')); - if (folderNames.length) { - html += '
' - + folderNames.map(name => ``).join('') - + '
'; - } - html += (grouped.get('') || []).map(_renderBookItemHtml).join(''); - } else { - html += `
- - ${escapeHtml(_currentBookFolder)} -
`; - html += grouped.get(_currentBookFolder).map(_renderBookItemHtml).join(''); - } - - listEl.innerHTML = html; - listEl.querySelectorAll('.book-folder-tile').forEach(tile => { - tile.addEventListener('click', () => _openBookFolder(tile.dataset.folder)); - }); -} - -// Shared by assignBookFolder/lookupBookMetadata: re-encrypts the meta blob with the given -// field overrides applied on top of whatever is already cached, and pushes it to the server. -async function _updateBookMeta(bookId, overrides) { - const book = _lastBookListData.find(b => b.id === bookId); - if (!book) return false; - const cached = bookMetaCache[bookId] || {}; - const merged = { - title: cached.title || book.title, - author: cached.author || book.author, - filename: cached.filename || '', - type: cached.type || book.type || 'epub', - 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(); - const metaJson = new TextEncoder().encode(JSON.stringify(merged)); - const metaEnc = await encryptBytes(key, metaJson); - const res = await fetch(`/books/${bookId}/meta/`, { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}), - }); - const data = await res.json(); - if (!data.ok) throw new Error('Serverfehler'); - Object.assign(book, overrides); - bookMetaCache[bookId] = merged; - _saveBookMeta(_lastBookListData); - return true; -} - -async function assignBookFolder(bookId) { - const book = _lastBookListData.find(b => b.id === bookId); - if (!book) return; - const folders = [...new Set(_lastBookListData.map(b => b.folder).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de')); - const hint = folders.length ? ` Vorhandene Ordner: ${folders.join(', ')}.` : ''; - const result = await customPrompt(`Ordner für „${book.title}“ (leer = kein Ordner).${hint}`, book.folder || ''); - if (result === null) return; - const folder = result.trim(); - if (folder === (book.folder || '')) return; - - try { - await _updateBookMeta(bookId, {folder}); - if (folder) _currentBookFolder = folder; - renderBookList(_lastBookListData); - } catch (e) { - await customAlert('Konnte Ordner nicht ändern: ' + e.message); - } -} - -// Fallback for books uploaded before ISBN extraction existed (or repaired/re-encrypted -// without it): pulls the already-accessible encrypted book bytes, decrypts and unzips just -// far enough to read the OPF identifiers — same source openBook() itself parses — without -// doing a full reader render. EPUB only; PDFs never carried a reliable ISBN via pdf.js. -async function _extractIsbnFromBookFile(bookId) { - try { - const key = await getOrCreateEncKey(); - let data_ct, data_iv; - const cached = await _getCachedBook(bookId); - if (cached) { - ({data_ct, data_iv} = cached); - } else { - const res = await fetch(`/books/${bookId}/data/`); - ({data_ct, data_iv} = await res.json()); - } - const plain = await decryptBytes(key, data_iv, data_ct); - const zip = await JSZip.loadAsync(plain); - const containerXml = await zip.file('META-INF/container.xml').async('text'); - const opfPath = new DOMParser() - .parseFromString(containerXml, 'application/xml') - .querySelector('rootfile')?.getAttribute('full-path'); - if (!opfPath) return ''; - const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml'); - return _extractIsbnFromOpf(opfDoc); - } catch (e) { - return ''; - } -} - -async function _fetchShelfLookup(isbn, title, author) { - const params = new URLSearchParams(); - if (isbn) params.set('isbn', isbn); - if (title) params.set('title', title); - if (author) params.set('author', author); - const res = await fetch(`/books/metadata-lookup/?${params.toString()}`); - return res.json(); -} - -// Manually triggered (never automatic) — looks up which library shelf/DDC category this -// book falls under via the server-side DNB→Open Library proxy, purely as an organizational -// hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is -// allowed to briefly touch the server with a plaintext ISBN/title/author. -// -// Fallback chain when there's no ISBN (older upload, or a book that never had one — e.g. -// every PDF, since pdf.js doesn't expose one): (1) extract ISBN from the file itself, for -// EPUBs, (2) server-side title/author text search (less precise — could match the wrong -// edition/translation), (3) if that also comes up empty, ask the user to type in an ISBN -// by hand (from the book's cover/an online store) and retry once. -async function lookupBookMetadata(bookId) { - const book = _lastBookListData.find(b => b.id === bookId); - if (!book) return; - const cached = bookMetaCache[bookId] || {}; - let isbn = cached.isbn || book.isbn || ''; - const isPdf = (cached.type || book.type) === 'pdf'; - const title = cached.title || book.title || ''; - const author = cached.author || book.author || ''; - - if (!isbn && !isPdf) { - // Older upload without a stored ISBN — extract it from the book file itself (may take - // a moment for large books, since it downloads/decrypts the full file if not cached). - isbn = await _extractIsbnFromBookFile(bookId); - } - - try { - let data = await _fetchShelfLookup(isbn, title, author); - - if (!data.label) { - const manual = await customPrompt( - 'Keine Regal-Kategorie per ISBN/Titel/Autor gefunden. ISBN manuell eingeben (z. B. vom Buchrücken oder Online-Shop), oder leer lassen zum Abbrechen:', '' - ); - const manualIsbn = (manual || '').replace(/[^0-9Xx]/g, ''); - if (manualIsbn) { - isbn = manualIsbn; - data = await _fetchShelfLookup(isbn, title, author); - } - } - - // Persist whatever we ended up with either way, so a repeat lookup never needs to - // re-extract/re-search from scratch. - const overrides = {}; - if (isbn) overrides.isbn = isbn; - if (data.label) overrides.shelfTag = data.label; - if (Object.keys(overrides).length) { - await _updateBookMeta(bookId, overrides); - renderBookList(_lastBookListData); - } - - if (!data.label) { - await customAlert('Keine Regal-Kategorie gefunden (weder DNB noch Open Library, per ISBN oder Titel/Autor).'); - } - } catch (e) { - await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message); - } -} - -function bookFileSelected(input) { - const file = input.files[0]; - if (!file) return; - uploadEbook(file); - input.value = ''; -} - -function initBookDropZone() { - // Prevent Firefox from opening dragged files when dropped outside the zone - document.addEventListener('dragover', e => e.preventDefault()); - document.addEventListener('drop', e => { - const zone = $('book-drop-zone'); - if (!zone || !zone.contains(e.target)) e.preventDefault(); - }); - - const zone = $('book-drop-zone'); - if (!zone) return; - - zone.addEventListener('dragover', e => { - e.preventDefault(); - zone.classList.add('drag-over'); - }); - zone.addEventListener('dragleave', () => zone.classList.remove('drag-over')); - zone.addEventListener('drop', e => { - e.preventDefault(); - zone.classList.remove('drag-over'); - const file = e.dataTransfer.files[0]; - if (file) uploadEbook(file); - }); -} - -async function deriveAndStoreKey() { - const pwInput = document.getElementById('enc-key-password'); - const statusEl = $('enc-key-status'); - const pw = pwInput ? pwInput.value : ''; - if (!pw) { if (statusEl) statusEl.textContent = 'Bitte gib dein Passwort ein.'; return; } - - if (statusEl) statusEl.textContent = 'Schlüssel wird abgeleitet …'; - try { - const enc = new TextEncoder(); - const username = document.querySelector('meta[name="username"]')?.content || ''; - const mat = await crypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveKey']); - const key = await crypto.subtle.deriveKey( - {name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'}, - mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt'] - ); - const raw = await crypto.subtle.exportKey('raw', key); - const storageKey = `diora_enc_key_${window.USER_ID || 0}`; - localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw))); - _encKey = null; // reset cached key - if (statusEl) statusEl.textContent = '✓ Entsperrt'; - const prompt = $('enc-key-prompt'); - const uploadArea = $('book-upload-area'); - if (prompt) prompt.style.display = 'none'; - if (uploadArea) uploadArea.style.display = ''; - loadBookList(); - } catch (err) { - if (statusEl) statusEl.textContent = 'Fehler: ' + err.message; - } -} - -async function uploadEbook(file) { - const statusEl = $('book-upload-status'); - if (file.size > DIORA_CONFIG.ebookMaxBytes) { - if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`; - return false; - } - return _importEbookBuffer(await file.arrayBuffer(), file.name, statusEl); -} - -// Shared tail of every import path (local file, cloud): parse metadata, encrypt -// with the user's key and POST ciphertext. Plaintext never leaves the browser. -async function _importEbookBuffer(buf, filename, statusEl) { - const isPdf = /\.pdf$/i.test(filename); - const isEpub = /\.epub$/i.test(filename); - if (!isPdf && !isEpub) { - if (statusEl) statusEl.textContent = 'Nur .epub- und .pdf-Dateien werden unterstützt.'; - return false; - } - if (buf.byteLength > DIORA_CONFIG.ebookMaxBytes) { - if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`; - return false; - } - - if (statusEl) statusEl.textContent = 'Verschlüssele …'; - - try { - let title = filename.replace(/\.(epub|pdf)$/i, ''); - let author = ''; - let isbn = ''; - const type = isPdf ? 'pdf' : 'epub'; - - if (isPdf) { - try { - const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; - const meta = await pdfDoc.getMetadata(); - title = meta.info?.Title?.trim() || title; - author = meta.info?.Author?.trim() || ''; - } catch (e) { /* use filename as title */ } - } else { - try { - const zip = await JSZip.loadAsync(buf.slice(0)); - const containerXml = await zip.file('META-INF/container.xml').async('text'); - const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml'); - const opfPath = containerDoc.querySelector('rootfile')?.getAttribute('full-path'); - if (opfPath) { - const opfText = await zip.file(opfPath).async('text'); - const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); - title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; - author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; - isbn = _extractIsbnFromOpf(opfDoc); - } - } catch (e) { /* use filename as title */ } - } - - const key = await getOrCreateEncKey(); - const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename, type, isbn, folder: '', shelfTag: ''})); - const [metaEnc, dataEnc] = await Promise.all([ - encryptBytes(key, metaJson), - encryptBytes(key, buf), - ]); - - if (statusEl) statusEl.textContent = 'Lade hoch …'; - - const res = await fetch('/books/upload/', { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body: JSON.stringify({ - meta_ct: metaEnc.ciphertext, - meta_iv: metaEnc.iv, - data_ct: dataEnc.ciphertext, - data_iv: dataEnc.iv, - }), - }); - const data = await res.json(); - if (data.ok) { - if (statusEl) statusEl.textContent = `✓ „${title}“ hochgeladen`; - loadBookList(); - return true; - } - if (statusEl) statusEl.textContent = 'Fehler: ' + (data.error ? _germanError(data.error) : 'Hochladen fehlgeschlagen'); - return false; - } catch (e) { - if (statusEl) statusEl.textContent = 'Hochladen fehlgeschlagen: ' + e.message; - return false; - } -} - -// --------------------------------------------------------------------------- -// Cloud import — browse a user-configured WebDAV server and pull a book in. -// -// The download is proxied by the server because the remote host is a different -// origin and sends no CORS headers. It still lands here as raw bytes and goes -// through _importEbookBuffer like any local file, so what gets stored is -// ciphertext the server cannot read. -// --------------------------------------------------------------------------- - -let _cloudSources = []; -let _cloudSourceId = null; -let _cloudPath = ''; - -async function toggleCloudImport() { - const panel = $('cloud-browser'); - if (!panel) return; - if (panel.style.display !== 'none') { closeCloudImport(); return; } - - try { - const res = await fetch('/books/cloud/sources/'); - _cloudSources = (await res.json()).sources || []; - } catch (e) { - await customAlert('Cloud-Verbindungen konnten nicht geladen werden: ' + e.message); - return; - } - - if (!_cloudSources.length) { - await customAlert( - 'Noch keine Cloud-Verbindung eingerichtet. Unter Einstellungen → ' - + '„Cloud-Verbindungen für Bücher“ kannst du eine hinzufügen.' - ); - return; - } - - const select = $('cloud-source-select'); - if (select) { - select.innerHTML = ''; - for (const source of _cloudSources) { - const option = document.createElement('option'); - option.value = source.id; - option.textContent = source.label; - select.appendChild(option); - } - select.style.display = _cloudSources.length > 1 ? '' : 'none'; - } - - panel.style.display = ''; - _cloudSourceId = _cloudSources[0].id; - _cloudBrowse(''); -} - -function closeCloudImport() { - const panel = $('cloud-browser'); - if (panel) panel.style.display = 'none'; -} - -function _onCloudSourceChange(value) { - _cloudSourceId = parseInt(value, 10); - _cloudBrowse(''); -} - -function _cloudParentPath(path) { - const parts = path.split('/').filter(Boolean); - parts.pop(); - return parts.join('/'); -} - -function _formatBytes(n) { - if (!n) return ''; - if (n < 1024) return n + ' B'; - if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB'; - return (n / 1024 / 1024).toFixed(1) + ' MB'; -} - -function _cloudEntryRow(label, onClick, sizeText) { - const li = document.createElement('li'); - li.className = 'cloud-entry'; - - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'cloud-entry-name'; - button.textContent = label; - button.addEventListener('click', onClick); - li.appendChild(button); - - if (sizeText) { - const size = document.createElement('span'); - size.className = 'cloud-entry-size'; - size.textContent = sizeText; - li.appendChild(size); - } - return li; -} - -async function _cloudBrowse(path) { - const listEl = $('cloud-entries'); - const pathEl = $('cloud-path'); - const statusEl = $('cloud-status'); - if (!listEl) return; - - if (statusEl) statusEl.textContent = 'Lade…'; - listEl.innerHTML = ''; - - let data; - try { - const res = await fetch(`/books/cloud/${_cloudSourceId}/browse/?path=${encodeURIComponent(path)}`); - data = await res.json(); - if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); - } catch (e) { - if (statusEl) statusEl.textContent = 'Fehler: ' + e.message; - return; - } - - _cloudPath = data.path || ''; - if (pathEl) pathEl.textContent = '/' + _cloudPath; - if (statusEl) statusEl.textContent = ''; - - if (_cloudPath) { - listEl.appendChild(_cloudEntryRow('⬑ ..', () => _cloudBrowse(_cloudParentPath(_cloudPath)))); - } - - const entries = (data.entries || []).filter(e => e.is_dir || e.is_book); - if (!entries.length && statusEl) { - statusEl.textContent = 'Keine Bücher oder Ordner in diesem Verzeichnis.'; - } - - for (const entry of entries) { - listEl.appendChild(entry.is_dir - ? _cloudEntryRow('📁 ' + entry.name, () => _cloudBrowse(entry.path)) - : _cloudEntryRow('📖 ' + entry.name, () => _cloudImport(entry), _formatBytes(entry.size))); - } -} - -async function _cloudImport(entry) { - const statusEl = $('cloud-status'); - if (statusEl) statusEl.textContent = `Lade „${entry.name}“…`; - - try { - const res = await fetch(`/books/cloud/${_cloudSourceId}/fetch/?path=${encodeURIComponent(entry.path)}`); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.error || `HTTP ${res.status}`); - } - const buf = await res.arrayBuffer(); - if (await _importEbookBuffer(buf, entry.name, statusEl) && statusEl) { - statusEl.textContent = `✓ „${entry.name}“ importiert`; - } - } catch (e) { - if (statusEl) statusEl.textContent = 'Import fehlgeschlagen: ' + e.message; - } -} - -async function _parsePdfOutline(pdf, items, depth) { - depth = depth || 0; - const result = []; - for (const item of items) { - let href = ''; - if (item.dest) { - try { - const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest; - if (dest) { - const pageIndex = await pdf.getPageIndex(dest[0]); - href = `#pdf-page-${pageIndex + 1}`; - } - } catch (e) {} - } - result.push({label: item.title || '(untitled)', href, depth}); - if (item.items && item.items.length) { - result.push(...await _parsePdfOutline(pdf, item.items, depth + 1)); - } - } - return result; -} - -async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { - const myGen = ++_pdfRenderGen; - const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}).promise; - if (_pdfRenderGen !== myGen) return null; - currentPdfDoc = pdf; - - let pdfTitle = '', pdfAuthor = ''; - try { - const meta = await pdf.getMetadata(); - pdfTitle = meta.info?.Title?.trim() || ''; - pdfAuthor = meta.info?.Author?.trim() || ''; - } catch (e) {} - - let toc = []; - try { - const outline = await pdf.getOutline(); - if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline); - } catch (e) {} - - contentEl.innerHTML = ''; - - const pdfVp = document.createElement('div'); - pdfVp.id = 'pdf-viewport'; - contentEl.appendChild(pdfVp); - - const containerWidth = readerSettings.pdfSpread - ? contentEl.clientWidth - 32 - : Math.min(contentEl.clientWidth - 32, 900); - const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth; - const dpr = window.devicePixelRatio || 1; - - // Pass 1: build DOM structure and size all canvases (no pixel rendering yet) - const pageEntries = []; - let spreadContainer = null; - - for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { - if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; } - const page = await pdf.getPage(pageNum); - const naturalVp = page.getViewport({scale: 1}); - const scale = scaleOverride != null ? scaleOverride - : Math.max(0.5, pageWidth / naturalVp.width) * (readerSettings.pdfZoom / 100); - const viewport = page.getViewport({scale}); - - const wrapper = document.createElement('div'); - wrapper.className = 'pdf-page-wrapper'; - wrapper.id = `pdf-page-${pageNum}`; - - const inner = document.createElement('div'); - inner.className = 'pdf-page-inner'; - - const canvas = document.createElement('canvas'); - canvas.className = 'pdf-page'; - canvas.width = Math.round(viewport.width * dpr); - canvas.height = Math.round(viewport.height * dpr); - canvas.style.width = viewport.width + 'px'; - canvas.style.height = viewport.height + 'px'; - inner.appendChild(canvas); - wrapper.appendChild(inner); - - if (!readerSettings.pdfSpread) { - pdfVp.appendChild(wrapper); - } else if (pageNum === 1) { - wrapper.classList.add('pdf-spread-cover'); - pdfVp.appendChild(wrapper); - spreadContainer = null; - } else { - if (pageNum % 2 === 0) { - spreadContainer = document.createElement('div'); - spreadContainer.className = 'pdf-spread-wrapper'; - pdfVp.appendChild(spreadContainer); - } - if (spreadContainer) spreadContainer.appendChild(wrapper); - else pdfVp.appendChild(wrapper); - } - - pageEntries.push({page, canvas, viewport}); - } - - pdfTotalPages = pdf.numPages; - - // Scroll to pivot page immediately — DOM and canvas sizes are set - const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1)); - const pivotEl = document.getElementById(`pdf-page-${pivot}`); - if (pivotEl && pivot > 1) pivotEl.scrollIntoView({block: 'start'}); - - // Pass 2: render pixels from pivot outward (pivot→end, then pivot-1→start) - const order = []; - for (let i = pivot - 1; i < pdf.numPages; i++) order.push(i); - for (let i = pivot - 2; i >= 0; i--) order.push(i); - - for (const idx of order) { - if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; } - const {page, canvas, viewport} = pageEntries[idx]; - const ctx = canvas.getContext('2d'); - ctx.scale(dpr, dpr); - await page.render({canvasContext: ctx, viewport}).promise; - } - - return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; -} - -// --------------------------------------------------------------------------- -// Read-aloud (TTS) — server-side Piper synthesis, one sentence per request. -// EPUB only (no block model for PDFs). Highlights the sentence currently -// playing and scrolls it into view; playback advances sentence-by-sentence, -// then block-by-block, until stopped or the book ends. -// --------------------------------------------------------------------------- -const TTS_MAX_CHARS = 480; // stay under the server's 500-char cap with margin - -let ttsActive = false; -let ttsPaused = false; -let ttsRunToken = 0; -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; - localStorage.setItem('diora_tts_lang', lang); -} - -function _ttsSplitSentences(text) { - const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean); - const pieces = raw.length ? raw : [text]; - const out = []; - for (const piece of pieces) { - let rest = piece; - while (rest.length > TTS_MAX_CHARS) { - let cut = rest.lastIndexOf(' ', TTS_MAX_CHARS); - if (cut <= 0) cut = TTS_MAX_CHARS; - out.push(rest.slice(0, cut).trim()); - rest = rest.slice(cut).trim(); - } - if (rest) out.push(rest); - } - return out; -} - -async function _ttsFetchAudio(sentence) { - const resp = await fetch('/tts/synthesize/', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({text: sentence, lang: ttsLang}), - }); - if (!resp.ok) throw new Error('tts request failed'); - return await resp.blob(); -} - -function _ttsClearHighlight() { - if (ttsCurrentMark && ttsCurrentMark.parentNode) { - const parent = ttsCurrentMark.parentNode; - while (ttsCurrentMark.firstChild) parent.insertBefore(ttsCurrentMark.firstChild, ttsCurrentMark); - parent.removeChild(ttsCurrentMark); - parent.normalize(); - } - ttsCurrentMark = null; -} - -// Finds `sentence` as a substring of block.textContent starting at fromOffset -// (so repeated sentence text earlier in the block isn't matched again), turns -// 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); - if (idx === -1) return fromOffset; - const start = _nodeAtCharOffset(block, idx); - const end = _nodeAtCharOffset(block, idx + sentence.length); - if (!start || !end) return idx + sentence.length; - try { - const range = document.createRange(); - range.setStart(start.node, start.offset); - range.setEnd(end.node, end.offset); - const mark = document.createElement('mark'); - mark.className = 'tts-current'; - range.surroundContents(mark); - ttsCurrentMark = mark; - _ttsScrollIntoView(mark); - } catch (e) {} - return idx + sentence.length; -} - -function _ttsScrollIntoView(el) { - const contentEl = $('reader-content'); - if (!contentEl) return; - const top = el.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; - if (top < 40 || top > contentEl.clientHeight - 80) { - _suppressScrollJumpDetect(); - contentEl.scrollBy({top: top - contentEl.clientHeight * 0.3, behavior: 'smooth'}); - } -} - -function _ttsPlayBlob(blob) { - return new Promise((resolve) => { - const url = URL.createObjectURL(blob); - const audio = new Audio(url); - ttsAudio = audio; - const done = () => { URL.revokeObjectURL(url); resolve(); }; - audio.addEventListener('ended', done); - audio.addEventListener('error', done); - if (!ttsPaused) audio.play().catch(done); - }); -} - -// 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; - const sentences = _ttsSplitSentences(text); - for (let si = bi === startBlockIndex ? startSentence : 0; si < sentences.length; si++) { - yield {block, sentence: sentences[si], bi, si}; - } - } - } - - const iter = sentenceStream(); - let cur = iter.next(); - if (cur.done) { stopReadAloud(); return; } - let curFetch = _ttsFetchAudio(cur.value.sentence); - let lastBlock = null; - let blockSearchOffset = 0; - - while (!cur.done) { - if (runToken !== ttsRunToken) return; - const {block, sentence} = cur.value; - const next = iter.next(); - const nextFetch = next.done ? null : _ttsFetchAudio(next.value.sentence).catch(() => null); - - let audioBlob; - try { - audioBlob = await curFetch; - } catch (e) { - stopReadAloud(); - return; - } - if (runToken !== ttsRunToken) return; - - 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; - - cur = next; - curFetch = nextFetch; - } - if (runToken === ttsRunToken) stopReadAloud(); -} - -function _ttsTogglePause() { - ttsPaused = !ttsPaused; - if (ttsAudio) { - if (ttsPaused) ttsAudio.pause(); - 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'); - if (btn) btn.textContent = ttsPaused ? '▶' : '⏸'; -} - -function _ttsShowBar() { - _ttsRemoveBar(); - if (window.__dioraTtsDock) return; // the player bar shows it - const bar = document.createElement('div'); - bar.className = 'tts-bar'; - bar.innerHTML = ` - - - `; - document.body.appendChild(bar); - ttsBarEl = bar; - bar.querySelector('.tts-pause-btn').addEventListener('click', _ttsTogglePause); - bar.querySelector('.tts-stop-btn').addEventListener('click', stopReadAloud); -} - -function _ttsRemoveBar() { - if (ttsBarEl) { ttsBarEl.remove(); ttsBarEl = null; } -} - -function toggleReadAloud() { - if (ttsActive) stopReadAloud(); - else startReadAloud(); -} - -function startReadAloud() { - if (ttsActive) return; - if (currentPdfDoc) { - const toast = document.createElement('div'); - toast.className = 'reader-toast'; - toast.textContent = 'Vorlesen ist aktuell nur für EPUB-Bücher verfügbar.'; - document.body.appendChild(toast); - setTimeout(() => toast.remove(), 2200); - return; - } - const contentEl = $('reader-content'); - if (!contentEl) return; - const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); - if (!blocks.length) return; - - const [anchorBlock] = _anchorParts(getPositionAnchor(contentEl)); - const startIndex = (anchorBlock >= 0 && anchorBlock < blocks.length) ? anchorBlock : 0; - - ttsActive = true; - 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); -} - -function stopReadAloud() { - ttsRunToken++; - ttsActive = false; - ttsPaused = false; - if (ttsAudio) { - try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {} - ttsAudio = null; - } - _ttsClearHighlight(); - _ttsRemoveBar(); - _ttsBlocks = null; - _ttsCur = null; - const btn = $('reader-tts-btn'); - if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; } - _ttsNotify(); -} - -// --------------------------------------------------------------------------- -// Immersive reader mode — tap centre of screen to toggle bars -// --------------------------------------------------------------------------- -let _immBarsVisible = true; - -function _immHandleTap(e) { - // Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover) - if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet, .tts-bar')) return; - // In marker mode, taps have a dedicated meaning (highlight/create a note) — - // don't also toggle the immersive bars underneath. - if (markerModeActive) return; - // Don't toggle bars while the user has just finished selecting text (e.g. to highlight it) - const sel = window.getSelection(); - if (sel && !sel.isCollapsed) return; - // If a footnote popover is open, dismiss it instead of toggling bars - if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) { - dismissFootnotePopover(); - return; - } - _immBarsVisible = !_immBarsVisible; - document.body.classList.toggle('reader-immersive', !_immBarsVisible); - // Close settings panel when bars disappear - if (!_immBarsVisible && readerSettingsPanelOpen) { - const sp = document.getElementById('reader-settings-panel'); - if (sp) sp.remove(); - readerSettingsPanelOpen = false; - } -} - -function enterReaderImmersiveMode() { - _immBarsVisible = true; - document.body.classList.remove('reader-immersive'); - document.addEventListener('click', _immHandleTap); -} - -function exitReaderImmersiveMode() { - _immBarsVisible = true; - document.body.classList.remove('reader-immersive'); - 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; - -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) { - // 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'); - if (!overlay || !contentEl) return; - - titleEl.textContent = 'Lade …'; - contentEl.innerHTML = ''; - overlay.style.display = ''; - - try { - loadReaderSettings(bookId); - _charIndex = null; - const key = await getOrCreateEncKey(); - let data_ct, data_iv; - const cached = await _getCachedBook(bookId); - if (cached) { - ({data_ct, data_iv} = cached); - } else { - let res; - try { - res = await fetch(`/books/${bookId}/data/`); - } catch { - // offline and book not cached yet - overlay.style.display = 'none'; - const toast = document.createElement('div'); - toast.className = 'reader-toast'; - toast.textContent = 'Buch nicht offline verfügbar — einmal online öffnen zum Cachen.'; - document.body.appendChild(toast); - setTimeout(() => toast.remove(), 3500); - return; - } - ({data_ct, data_iv} = await res.json()); - _setCachedBook(bookId, data_ct, data_iv); // fire-and-forget - } - const plain = await decryptBytes(key, data_iv, data_ct); - - // Revoke any previous image blob URLs - for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); - currentImageMap = {}; - - const cachedMeta = bookMetaCache[bookId] || {}; - let title = cachedMeta.title || ''; - let author = cachedMeta.author || ''; - let toc = []; - let numPages = 0; - const isPdfBook = cachedMeta.type === 'pdf'; - - if (isPdfBook) { - currentPdfDoc = null; // reset so renderPdf creates fresh doc - const loadingEl = document.createElement('div'); - loadingEl.className = 'pdf-loading-overlay'; - loadingEl.innerHTML = ''; - overlay.appendChild(loadingEl); - const result = await renderPdf(plain, contentEl); - loadingEl.remove(); - title = result.title || title; - author = result.author || author; - toc = result.toc; - numPages = result.numPages; - currentPdfBuffer = plain; - } else { - currentPdfBuffer = null; - // 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: - // 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 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); - 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(). - _currentBookCharCount = contentEl.textContent.length; - } - - currentBookToc = toc; - titleEl.textContent = title + (author ? ` — ${author}` : ''); - - 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), - loadHighlights(bookId), - ]); - - // Apply reader settings (theme, font size, etc.) - applyReaderSettings(isPdfBook); - - // If this book already has notes/highlights, surface them right away - // instead of making the reader hide them behind a manual toggle. - // Not on phones: there the margin covers the text; it stays one tap away (✎). - if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen && !_isMobileReader()) { - toggleAnnotationsMargin(); - } - - // Touch: swipe (paginated) + pinch-to-zoom - contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true}); - contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false}); - contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true}); - contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true}); - - // Set up progress input - const progressInput = $('reader-progress-input'); - const progressSuffix = $('reader-progress-suffix'); - const isPdf = isPdfBook; - - if (progressInput) { - progressInput.style.display = ''; - if (isPdf) { - progressInput.value = 1; - if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`; - } else { - progressInput.value = _formatBookPercent(0); - if (progressSuffix) progressSuffix.textContent = '%'; - } - - progressInput.addEventListener('change', function () { - _suppressScrollJumpDetect(); - if (isPdf) { - const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1)); - this.value = page; - const target = contentEl.querySelector(`#pdf-page-${page}`); - if (target) { - const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; - contentEl.scrollBy({top: top - 8, behavior: 'smooth'}); - } - } else { - const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value))); - this.value = _formatBookPercent(pct / 100); - restoreFromAnchor(contentEl, _anchorForFraction(contentEl, pct / 100)); - } - }); - - progressInput.addEventListener('click', function () { this.select(); }); - } - - // 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/', {cache: 'no-store'}); - _noteServerNowHeader(progressRes); - const allBooks = await progressRes.json(); - bookData = allBooks.find(b => b.id === bookId) || null; - } catch { - // offline — use cached meta - const cached = await _loadCachedBookMeta(); - 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) - pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); - enterPdfPaginatedMode(); - } else if (isPdf) { - // PDF scroll mode - await new Promise(r => requestAnimationFrame(r)); - if (fraction > 0) - contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); - } else { - // EPUB — wait for images so scrollHeight is final - const imgs = Array.from(contentEl.querySelectorAll('img')); - if (imgs.length) { - await Promise.all(imgs.map(img => - img.complete ? Promise.resolve() - : new Promise(r => { img.onload = r; img.onerror = r; }) - )); - } - await new Promise(r => requestAnimationFrame(r)); - if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) { - restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction)); - } - } - } 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. - _scrollRestTop = contentEl.scrollTop; - - // Update progress input on scroll - contentEl.addEventListener('scroll', () => { - if (!progressInput) return; - if (isPdf) { - const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); - const cTop = contentEl.getBoundingClientRect().top; - let currentPage = 1; - for (const w of wrappers) { - if (w.getBoundingClientRect().bottom > cTop + 20) { - currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1; - break; - } - } - progressInput.value = currentPage; - } - // EPUB: shown from the position anchor, see the anchor tracking below. - }); - - // Auto-save progress every 10s and on scroll (debounced 2s) - // Started AFTER restore so an early visibilitychange can't overwrite with position 0 - readerScrollSaveTimer = setInterval(saveReaderProgress, 10000); - let _scrollDebounce = null; - contentEl.addEventListener('scroll', () => { - clearTimeout(_scrollDebounce); - _scrollDebounce = setTimeout(saveReaderProgress, 2000); - }, {passive: true}); - - // Keep _currentPositionAnchor fresh independent of the slower save debounce - // above: the resize observer below (or an immersive-bars toggle right after - // scrolling) would otherwise restore to a stale, pre-scroll anchor. - if (!isPdf) { - let _anchorTrackDebounce = null; - contentEl.addEventListener('scroll', () => { - clearTimeout(_anchorTrackDebounce); - _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); - } - }, 150); - }, {passive: true}); - } - - // Restore anchor on viewport resize (e.g. screen rotation, font zoom) - if (!isPdf) { - _resizeObserver = new ResizeObserver(() => { - if (_currentPositionAnchor) { - _suppressScrollJumpDetect(); - requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor)); - } - _repositionMarginMarkers(); - }); - _resizeObserver.observe(contentEl); - } - - // Offer to jump back after a sudden, large scroll (accidental fling, - // fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above. - _scrollRestTop = null; - let _scrollJumpRaf = null; - contentEl.addEventListener('scroll', () => { - if (_scrollJumpRaf) return; - _scrollJumpRaf = requestAnimationFrame(() => { - _scrollJumpRaf = null; - _checkScrollJump(contentEl); - _repositionMarginMarkers(); - }); - }, {passive: true}); - - enterReaderImmersiveMode(); - - } catch (e) { - console.error('openBook failed:', e); - overlay.style.display = 'none'; - markBookBroken(bookId); - } -} - -async function exportEncKey() { - const statusEl = $('book-key-status'); - try { - const key = await getOrCreateEncKey(); - const raw = await crypto.subtle.exportKey('raw', key); - const b64 = bytesToBase64(raw); - await navigator.clipboard.writeText(b64); - if (statusEl) statusEl.textContent = '✓ Schlüssel in die Zwischenablage kopiert'; - setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000); - } catch (e) { - if (statusEl) statusEl.textContent = 'Export fehlgeschlagen: ' + e.message; - } -} - -function showImportKey() { - const body = $('sidebar-body'); - openSidebar('Schlüssel importieren', ` -

Füge den Schlüssel ein, den du im anderen Browser exportiert hast:

- - -

Das ersetzt den Schlüssel in diesem Browser. Hier hochgeladene Bücher sind erst wieder lesbar, wenn du den Schlüssel zurück abgleichst.

- `); - body.addEventListener('click', async function _importClick(e) { - if (!e.target.closest('[data-import-key-apply]')) return; - body.removeEventListener('click', _importClick); - const b64 = (body.querySelector('#import-key-input')?.value || '').trim(); - const statusEl = $('book-key-status'); - try { - const raw = base64ToBytes(b64); - const importedKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); - const re_exported = await crypto.subtle.exportKey('raw', importedKey); - localStorage.setItem(`diora_enc_key_${window.USER_ID}`, bytesToBase64(re_exported)); - closeSidebar(); - if (statusEl) statusEl.textContent = '✓ Schlüssel importiert – Bücher werden neu geladen …'; - await loadBookList(); - if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000); - } catch (e) { - if ($('book-key-status')) $('book-key-status').textContent = 'Import fehlgeschlagen: ungültiger Schlüssel'; - } - }); -} - -async function deleteBook(bookId) { - if (!await customConfirm('Dieses Buch löschen? Das lässt sich nicht rückgängig machen.', { danger: true })) return; - try { - const res = await fetch(`/books/${bookId}/delete/`, { - method: 'POST', - headers: {'X-CSRFToken': getCsrfToken()}, - }); - const data = await res.json(); - if (data.ok) loadBookList(); - } 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'); - if (!contentEl) return; - - let fraction; - if (readerSettings.pdfPaginated && currentPdfDoc && pdfTotalPages > 1) { - fraction = (pdfCurrentPage - 1) / (pdfTotalPages - 1); - } else { - 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; - } - - // 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); - _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)); - - _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 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() { - exitReaderImmersiveMode(); - 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(); - - const overlay = $('reader-overlay'); - if (overlay) overlay.style.display = 'none'; - if (readerScrollSaveTimer) { - clearInterval(readerScrollSaveTimer); - readerScrollSaveTimer = null; - } - if (_resizeObserver) { - _resizeObserver.disconnect(); - _resizeObserver = null; - } - _currentPositionAnchor = ''; - - // Close the margin panel - readerAnnotationsMarginOpen = false; - const marginEl = $('reader-margin'); - if (marginEl) marginEl.classList.remove('open'); - const marginMarkersEl = $('reader-margin-markers'); - if (marginMarkersEl) marginMarkersEl.innerHTML = ''; - - // Disarm marker mode - markerModeActive = false; - document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.remove('active')); - dismissNoteBottomSheet(); - - // Clear search before wiping content - clearReaderSearch(); - - // Close settings panel if open - readerSettingsPanelOpen = false; - const sp = document.getElementById('reader-settings-panel'); - if (sp) sp.remove(); - - // Reset progress input - const progressInput = $('reader-progress-input'); - if (progressInput) { progressInput.style.display = 'none'; progressInput.value = 0; } - const progressSuffix = $('reader-progress-suffix'); - if (progressSuffix) progressSuffix.textContent = ''; - - // Remove touch handlers and free image blob URLs - const contentEl = $('reader-content'); - if (contentEl) { - contentEl.removeEventListener('touchstart', _pdfTouchStart); - contentEl.removeEventListener('touchmove', _pdfTouchMove); - contentEl.removeEventListener('touchend', _pdfTouchEnd); - contentEl.removeEventListener('touchcancel', _pdfTouchEnd); - contentEl.classList.remove('pinch-active'); - contentEl.innerHTML = ''; - } - for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); - currentImageMap = {}; - - // Reset all state - currentBookId = null; - currentBookToc = []; - _currentBookCharCount = 0; - currentPdfDoc = null; - currentPdfBuffer = null; - currentBookmarks = []; - bookmarksDirty = false; - currentHighlights = []; - highlightsDirty = false; - _lastProgressBeacon = null; - _lastBookmarkBeacon = null; - _lastHighlightBeacon = null; - dismissHighlightPopover(); - pdfCurrentPage = 1; - pdfTotalPages = 0; - _pdfPageTextBoxCache = {}; - - // Remove PDF invert class - if (overlay) overlay.classList.remove('pdf-inverted'); - -} - -function openTocSidebar() { - if (!currentBookToc.length) { - openSidebar('Inhaltsverzeichnis', '

Dieses Buch hat kein Inhaltsverzeichnis.

'); - return; - } - let html = '
    '; - for (const entry of currentBookToc) { - const indent = entry.depth * 14; - // Use data-toc-href — onclick would be stripped by sanitizeSidebarHtml - html += `
  • - -
  • `; - } - html += '
'; - openSidebar('Inhaltsverzeichnis', html); - // Attach delegated listener after sidebar body is populated - const body = $('sidebar-body'); - body.addEventListener('click', function _tocClick(e) { - const btn = e.target.closest('.toc-entry'); - if (btn) { - body.removeEventListener('click', _tocClick); - jumpToTocEntry(btn.getAttribute('data-toc-href') || ''); - } - }); -} - -function jumpToTocEntry(href) { - closeSidebar(); - _suppressScrollJumpDetect(); - setTimeout(() => { - const contentEl = $('reader-content'); - if (!contentEl) return; - - // PDF page jump - if (href.startsWith('#pdf-page-')) { - const target = contentEl.querySelector(href); - if (target) { - const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; - contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); - } - return; - } - - const hashIdx = href.indexOf('#'); - const fragment = hashIdx >= 0 ? href.slice(hashIdx + 1) : ''; - const filePath = hashIdx >= 0 ? href.slice(0, hashIdx) : href; - - let target = null; - if (fragment) { - target = contentEl.querySelector(`#${CSS.escape(fragment)}`); - } - if (!target && filePath) { - target = Array.from(contentEl.querySelectorAll('[data-epub-src]')) - .find(el => el.getAttribute('data-epub-src') === filePath) || null; - } - if (target) { - const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; - contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); - } - }, 50); -} - -// --------------------------------------------------------------------------- -// Reader Settings -// --------------------------------------------------------------------------- - -// Typography defaults. Phones keep the long-standing ones; a wide screen -// (desktop browser, diora desktop) starts bigger and wider. -const _READER_TYPE_DEFAULTS_NARROW = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, fontWeight: null }; -const _READER_TYPE_DEFAULTS_WIDE = { fontSize: 20, lineHeight: 1.6, maxWidth: 90, fontWeight: 375 }; -const _READER_WIDTH_MAX = 140; // ch, the width slider's upper end ("Full" stays separate) - -function _readerTypeDefaults() { - return window.matchMedia('(min-width: 900px)').matches - ? _READER_TYPE_DEFAULTS_WIDE : _READER_TYPE_DEFAULTS_NARROW; -} - -function loadReaderSettings(bookId) { - // Reset to defaults, then apply per-book overrides - Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false, - pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }, - _readerTypeDefaults()); - try { - const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}'); - // Settings saved before v2 hold every value, changed or not. A value equal - // to the old default was never chosen, so it gives way to today's default. - if (!saved.v) { - for (const [key, old] of Object.entries(_READER_TYPE_DEFAULTS_NARROW)) { - 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) { - readerSettings.pdfPaginated = window.innerWidth < 768; - } - } catch (e) {} -} - -function saveReaderSettings() { - if (currentBookId) { - localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 3 })); - } -} - -function applyReaderSettings(isPdf) { - const overlay = $('reader-overlay'); - const contentEl = $('reader-content'); - if (!overlay || !contentEl) return; - - if (!isPdf) { - contentEl.style.fontSize = readerSettings.fontSize + 'px'; - contentEl.style.lineHeight = readerSettings.lineHeight; - contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch'); - const fontMap = { - serif: "'Source Serif 4', Georgia, 'Times New Roman', serif", - sans: 'system-ui, -apple-system, sans-serif', - humanist: 'Verdana, Geneva, sans-serif', - mono: "'Courier New', monospace", - }; - 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)); - } - } - - // Theme - overlay.classList.remove('reader-theme-sepia', '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'); - else overlay.classList.remove('pdf-inverted'); - - // Annotations (marker mode + margin panel) are EPUB-only for now — PDFs have no text layer yet - const marginBtn = $('reader-margin-btn'); - if (marginBtn) marginBtn.style.display = isPdf ? 'none' : ''; - if (isPdf && readerAnnotationsMarginOpen) toggleAnnotationsMargin(); - document.querySelectorAll('.marker-mode-btn').forEach(b => { b.style.display = isPdf ? 'none' : ''; }); - 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'); - if (!overlay || !contentEl) return; - - const existing = document.getElementById('reader-settings-panel'); - if (existing) { - existing.remove(); - readerSettingsPanelOpen = false; - return; - } - - readerSettingsPanelOpen = true; - const isPdf = !!currentPdfDoc; - - const panel = document.createElement('div'); - panel.id = 'reader-settings-panel'; - panel.className = 'reader-settings-panel'; - - if (!isPdf) { - panel.innerHTML = ` - - - - - - - - - - - - - - - `; - } else { - panel.innerHTML = ` - - - - `; - } - - overlay.insertBefore(panel, $('reader-body-row')); - - if (!isPdf) { - const fontRange = panel.querySelector('#rs-font'); - const fontVal = panel.querySelector('#rs-font-val'); - fontRange.addEventListener('input', () => { - readerSettings.fontSize = parseInt(fontRange.value, 10); - fontVal.textContent = readerSettings.fontSize + 'px'; - applyReaderSettings(false); - saveReaderSettings(); - }); - panel.querySelector('#rs-font-minus').addEventListener('click', () => { - fontRange.value = Math.max(+fontRange.min, parseInt(fontRange.value, 10) - 1); - fontRange.dispatchEvent(new Event('input')); - }); - panel.querySelector('#rs-font-plus').addEventListener('click', () => { - fontRange.value = Math.min(+fontRange.max, parseInt(fontRange.value, 10) + 1); - fontRange.dispatchEvent(new Event('input')); - }); - - const lineRange = panel.querySelector('#rs-line'); - const lineVal = panel.querySelector('#rs-line-val'); - lineRange.addEventListener('input', () => { - readerSettings.lineHeight = (parseInt(lineRange.value, 10) / 10).toFixed(1); - lineVal.textContent = readerSettings.lineHeight; - applyReaderSettings(false); - saveReaderSettings(); - }); - panel.querySelector('#rs-line-minus').addEventListener('click', () => { - lineRange.value = Math.max(+lineRange.min, parseInt(lineRange.value, 10) - 1); - lineRange.dispatchEvent(new Event('input')); - }); - panel.querySelector('#rs-line-plus').addEventListener('click', () => { - lineRange.value = Math.min(+lineRange.max, parseInt(lineRange.value, 10) + 1); - lineRange.dispatchEvent(new Event('input')); - }); - - const widthRange = panel.querySelector('#rs-width'); - const widthVal = panel.querySelector('#rs-width-val'); - widthRange.addEventListener('input', () => { - readerSettings.maxWidth = parseInt(widthRange.value, 10); - widthVal.textContent = readerSettings.maxWidth + 'ch'; - applyReaderSettings(false); - saveReaderSettings(); - }); - panel.querySelector('#rs-width-minus').addEventListener('click', () => { - widthRange.value = Math.max(+widthRange.min, parseInt(widthRange.value, 10) - 5); - widthRange.dispatchEvent(new Event('input')); - }); - panel.querySelector('#rs-width-plus').addEventListener('click', () => { - widthRange.value = Math.min(+widthRange.max, parseInt(widthRange.value, 10) + 5); - widthRange.dispatchEvent(new Event('input')); - }); - - panel.querySelector('#rs-width-full').addEventListener('click', () => { - readerSettings.maxWidth = 999; - widthRange.value = 90; - widthVal.textContent = 'full'; - applyReaderSettings(false); - 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(); - }); - }); - - panel.querySelector('#rs-no-bold').addEventListener('click', function () { - readerSettings.noBold = !readerSettings.noBold; - this.classList.toggle('active', readerSettings.noBold); - applyReaderSettings(false); - saveReaderSettings(); - }); - - panel.querySelectorAll('[data-rs-theme]').forEach(btn => { - btn.addEventListener('click', () => { - readerSettings.theme = btn.dataset.rsTheme; - panel.querySelectorAll('[data-rs-theme]').forEach(b => b.classList.toggle('active', b === btn)); - applyReaderSettings(false); - saveReaderSettings(); - }); - }); - } else { - const zoomRange = panel.querySelector('#rs-zoom'); - - zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10))); - panel.querySelector('#rs-zoom-minus').addEventListener('click', () => - applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10))); - panel.querySelector('#rs-zoom-plus').addEventListener('click', () => - applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10))); - - panel.querySelector('#rs-invert').addEventListener('click', function () { - readerSettings.pdfInverted = !readerSettings.pdfInverted; - this.classList.toggle('active', readerSettings.pdfInverted); - applyReaderSettings(true); - saveReaderSettings(); - }); - - panel.querySelector('#rs-spread').addEventListener('click', function () { - readerSettings.pdfSpread = !readerSettings.pdfSpread; - this.classList.toggle('active', readerSettings.pdfSpread); - saveReaderSettings(); - reRenderPdf(); - }); - - } -} - -async function applyPdfZoom(newZoom) { - readerSettings.pdfZoom = newZoom; - const zoomRange = document.getElementById('rs-zoom'); - const zoomVal = document.getElementById('rs-zoom-val'); - if (zoomRange) zoomRange.value = newZoom; - if (zoomVal) zoomVal.textContent = newZoom + '%'; - saveReaderSettings(); - if (readerSettings.pdfPaginated) { - pdfSmartZoomPage(pdfCurrentPage); - } else { - await reRenderPdf(); - } -} - -function _currentScrollPage(contentEl) { - const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); - const viewTop = contentEl.scrollTop; - for (const w of wrappers) { - if (w.offsetTop + w.offsetHeight > viewTop) { - const n = parseInt(w.id.replace('pdf-page-', ''), 10); - if (n) return n; - } - } - return pdfCurrentPage || 1; -} - -async function reRenderPdf() { - if (!currentPdfBuffer) return; - const contentEl = $('reader-content'); - if (!contentEl) return; - const pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _currentScrollPage(contentEl); - await renderPdf(currentPdfBuffer, contentEl, undefined, pivot); - if (readerSettings.pdfPaginated) enterPdfPaginatedMode(); -} - -// --------------------------------------------------------------------------- -// PDF Paginated Mode -// --------------------------------------------------------------------------- - -function enterPdfPaginatedMode() { - const contentEl = $('reader-content'); - if (!contentEl) return; - contentEl.classList.add('pdf-paginated'); - contentEl.style.overflow = 'hidden'; - const pdfVp = document.getElementById('pdf-viewport'); - if (pdfVp) pdfVp.style.zoom = 1; - - const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); - wrappers.forEach((w, i) => { - w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none'; - }); - - pdfSmartZoomPage(pdfCurrentPage); - - // Tap left/right to navigate - contentEl.addEventListener('click', _pdfPaginatedClick); -} - -function exitPdfPaginatedMode() { - const contentEl = $('reader-content'); - if (!contentEl) return; - contentEl.classList.remove('pdf-paginated'); - contentEl.style.overflow = ''; - contentEl.removeEventListener('click', _pdfPaginatedClick); - reRenderPdf(); -} - -function _pdfPaginatedClick(e) { - const w = e.currentTarget.clientWidth; - if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1); - else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1); -} - -function _pdfTouchStart(e) { - if (e.touches.length === 2) { - const dx = e.touches[0].clientX - e.touches[1].clientX; - const dy = e.touches[0].clientY - e.touches[1].clientY; - _pinchStartDist = Math.hypot(dx, dy); - _pinchStartZoom = readerSettings.pdfZoom; - _isPinching = true; - const ce = $('reader-content'); - if (ce) ce.classList.add('pinch-active'); - } else { - _touchStartX = e.touches[0].clientX; - _isPinching = false; - } -} - -function _pdfTouchMove(e) { - if (e.touches.length !== 2 || !_isPinching) return; - e.preventDefault(); - const dx = e.touches[0].clientX - e.touches[1].clientX; - const dy = e.touches[0].clientY - e.touches[1].clientY; - const dist = Math.hypot(dx, dy); - if (_pinchStartDist === 0) return; - const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist))); - const vp = document.getElementById('pdf-viewport'); - if (vp) vp.style.zoom = liveZoom / 100; -} - -function _pdfTouchEnd(e) { - if (_isPinching) { - _isPinching = false; - const ce = $('reader-content'); - if (ce) ce.classList.remove('pinch-active'); - const vp = document.getElementById('pdf-viewport'); - const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom; - const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10)); - applyPdfZoom(snapped); - return; - } - if (!readerSettings.pdfPaginated) return; - const delta = e.changedTouches[0].clientX - _touchStartX; - if (delta > 50) pdfGoToPage(pdfCurrentPage - 1); - else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1); -} - -function pdfGoToPage(n) { - if (!currentPdfDoc) return; - n = Math.max(1, Math.min(pdfTotalPages, n)); - if (n === pdfCurrentPage) return; - const contentEl = $('reader-content'); - if (!contentEl) return; - - const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); - if (oldWrapper) oldWrapper.style.display = 'none'; - - pdfCurrentPage = n; - const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); - if (newWrapper) newWrapper.style.display = ''; - - pdfSmartZoomPage(pdfCurrentPage); - - const progressInput = $('reader-progress-input'); - if (progressInput) progressInput.value = pdfCurrentPage; -} - -async function pdfSmartZoomPage(pageNum) { - if (!currentPdfDoc) return; - const contentEl = $('reader-content'); - if (!contentEl) return; - const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`); - if (!wrapper) return; - const canvas = wrapper.querySelector('canvas'); - if (!canvas) return; - - const page = await currentPdfDoc.getPage(pageNum); - const naturalVp = page.getViewport({scale: 1}); - const pageW = naturalVp.width; - const pageH = naturalVp.height; - - let bbox = _pdfPageTextBoxCache[pageNum]; - if (!bbox) { - bbox = await _computePdfTextBox(page, pageW, pageH); - _pdfPageTextBoxCache[pageNum] = bbox; - } - - const containerW = contentEl.clientWidth; - const containerH = contentEl.clientHeight; - const contentW = bbox.x2 - bbox.x1; - const contentH = bbox.y2 - bbox.y1; - const pad = 12; - const scale = Math.min( - (containerW - pad * 2) / contentW, - (containerH - pad * 2) / contentH - ) * (readerSettings.pdfZoom / 100); - - // Re-render canvas at new scale if significantly different - const currentScale = canvas.width / naturalVp.width; - if (Math.abs(scale - currentScale) / currentScale > 0.05) { - const vp = page.getViewport({scale}); - canvas.width = vp.width; - canvas.height = vp.height; - await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise; - } - - // Position canvas to center the text bounding box - const renderedScale = canvas.width / naturalVp.width; - const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2; - // PDF y-axis is bottom-up; canvas is top-down - const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2; - canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`; - wrapper.style.overflow = 'hidden'; - wrapper.style.width = containerW + 'px'; - wrapper.style.height = containerH + 'px'; -} - -async function _computePdfTextBox(page, pageW, pageH) { - // Tier 1: text-based - try { - const tc = await page.getTextContent(); - if (tc.items && tc.items.length) { - let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; - for (const item of tc.items) { - if (!item.transform) continue; - const tx = item.transform[4], ty = item.transform[5]; - const iw = item.width || 0, ih = item.height || 0; - if (tx < x1) x1 = tx; - if (ty < y1) y1 = ty; - if (tx + iw > x2) x2 = tx + iw; - if (ty + ih > y2) y2 = ty + ih; - } - const area = (x2 - x1) * (y2 - y1); - if (isFinite(x1) && area > pageW * pageH * 0.25) { - return {x1, y1, x2, y2}; - } - } - } catch (e) {} - - // Tier 2: pixel analysis at scale 0.3 - try { - const lowScale = 0.3; - const vp = page.getViewport({scale: lowScale}); - const offCanvas = document.createElement('canvas'); - offCanvas.width = vp.width; - offCanvas.height = vp.height; - const ctx = offCanvas.getContext('2d'); - await page.render({canvasContext: ctx, viewport: vp}).promise; - const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height); - - let rMin = height, rMax = 0, cMin = width, cMax = 0; - for (let r = 0; r < height; r++) { - for (let c = 0; c < width; c++) { - const idx = (r * width + c) * 4; - if (data[idx] + data[idx+1] + data[idx+2] < 720) { - if (r < rMin) rMin = r; - if (r > rMax) rMax = r; - if (c < cMin) cMin = c; - if (c > cMax) cMax = c; - } - } - } - if (rMin < rMax && cMin < cMax) { - return { - x1: cMin / lowScale, - y1: (height - rMax) / lowScale, - x2: cMax / lowScale, - y2: (height - rMin) / lowScale, - }; - } - } catch (e) {} - - // Fallback: full page - return {x1: 0, y1: 0, x2: pageW, y2: pageH}; -} - -// --------------------------------------------------------------------------- -// Bookmarks -// --------------------------------------------------------------------------- - -async function loadBookmarks(bookId) { - try { - const res = await fetch(`/books/${bookId}/bookmarks/`); - const {ct, iv} = await res.json(); - if (ct) { - _saveAnnotationsCache(bookId, 'bookmarks', ct, iv); // persist for offline - const key = await getOrCreateEncKey(); - const plain = await decryptBytes(key, iv, ct); - currentBookmarks = JSON.parse(new TextDecoder().decode(plain)); - } else { - currentBookmarks = []; - } - } catch (e) { - // offline fallback - const cached = await _loadAnnotationsCache(bookId, 'bookmarks'); - if (cached?.ct) { - try { - const key = await getOrCreateEncKey(); - const plain = await decryptBytes(key, cached.iv, cached.ct); - currentBookmarks = JSON.parse(new TextDecoder().decode(plain)); - } catch { currentBookmarks = []; } - } else { - currentBookmarks = []; - } - } -} - -async function saveBookmarks() { - if (!currentBookId) return; - try { - const key = await getOrCreateEncKey(); - const plain = new TextEncoder().encode(JSON.stringify(currentBookmarks)); - const {iv, ciphertext} = await encryptBytes(key, plain); - const body = JSON.stringify({ct: ciphertext, iv}); - const url = `/books/${currentBookId}/bookmarks/`; - _lastBookmarkBeacon = {url, body}; - _saveAnnotationsCache(currentBookId, 'bookmarks', ciphertext, iv); // keep local cache current - await fetch(url, { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body, - }); - bookmarksDirty = false; - } catch (e) { - _markPendingSync(currentBookId, 'bookmarks'); - } -} - -function addBookmark() { - const contentEl = $('reader-content'); - if (!contentEl || !currentBookId) return; - - let label, anchor, scrollFraction; - - if (currentPdfDoc) { - const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1; - label = `Seite ${page}`; - anchor = `pdf-page-${page}`; - scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1); - } else { - // Find first visible chapter div - const chapters = contentEl.querySelectorAll('[data-epub-src]'); - let visibleChapter = null; - for (const ch of chapters) { - const rect = ch.getBoundingClientRect(); - if (rect.bottom > 0 && rect.top < window.innerHeight) { - visibleChapter = ch; - break; - } - } - const src = visibleChapter?.getAttribute('data-epub-src') || ''; - label = src.split('/').pop().replace(/\.x?html?$/i, '') || 'Bookmark'; - anchor = src; - scrollFraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); - } - - const bm = { - id: crypto.randomUUID(), - label, - anchor, - scrollFraction, - createdAt: new Date().toISOString(), - }; - currentBookmarks.unshift(bm); - bookmarksDirty = true; - saveBookmarks(); - - // Toast - const toast = document.createElement('div'); - toast.className = 'reader-toast'; - toast.textContent = `★ Lesezeichen: ${label}`; - document.body.appendChild(toast); - setTimeout(() => toast.remove(), 2200); -} - -function openBookmarksSidebar() { - if (!currentBookmarks.length) { - openSidebar('Lesezeichen', '

Noch keine Lesezeichen. Tippe beim Lesen auf ★.

'); - return; - } - let html = '
    '; - for (const bm of currentBookmarks) { - html += `
  • - - -
  • `; - } - html += '
'; - openSidebar('Lesezeichen', html); - - const body = $('sidebar-body'); - body.addEventListener('click', function _bmClick(e) { - const jumpBtn = e.target.closest('[data-jump-bookmark]'); - const delBtn = e.target.closest('[data-delete-bookmark]'); - if (jumpBtn) { - body.removeEventListener('click', _bmClick); - jumpToBookmark(jumpBtn.dataset.jumpBookmark); - } - if (delBtn) { - const id = delBtn.dataset.deleteBookmark; - currentBookmarks = currentBookmarks.filter(b => b.id !== id); - bookmarksDirty = true; - saveBookmarks(); - openBookmarksSidebar(); // re-render - } - }); -} - -function jumpToBookmark(id) { - const bm = currentBookmarks.find(b => b.id === id); - if (!bm) return; - closeSidebar(); - _suppressScrollJumpDetect(); - setTimeout(() => { - const contentEl = $('reader-content'); - if (!contentEl) return; - if (bm.anchor.startsWith('pdf-page-')) { - if (readerSettings.pdfPaginated) { - pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1); - } else { - const target = contentEl.querySelector('#' + bm.anchor); - if (target) { - const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; - contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); - } - } - } else { - const target = Array.from(contentEl.querySelectorAll('[data-epub-src]')) - .find(el => el.getAttribute('data-epub-src') === bm.anchor); - if (target) { - const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; - contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); - } else { - contentEl.scrollTop = bm.scrollFraction * (contentEl.scrollHeight - contentEl.clientHeight); - } - } - }, 50); -} - -// --------------------------------------------------------------------------- -// Reader Search -// --------------------------------------------------------------------------- - -let _readerSearchDebounce = null; - -function toggleReaderSearch() { - const overlay = $('reader-overlay'); - const contentEl = $('reader-content'); - if (!overlay || !contentEl) return; - - const existing = document.getElementById('reader-search-bar'); - if (existing) { - existing.remove(); - readerSearchOpen = false; - clearReaderSearch(); - return; - } - - readerSearchOpen = true; - const bar = document.createElement('div'); - bar.id = 'reader-search-bar'; - bar.className = 'reader-search-bar'; - bar.innerHTML = ` - - - - - - `; - overlay.insertBefore(bar, $('reader-body-row')); - - const input = bar.querySelector('#reader-search-input'); - input.focus(); - input.addEventListener('input', () => { - clearTimeout(_readerSearchDebounce); - _readerSearchDebounce = setTimeout(() => doReaderSearch(input.value.trim()), 300); - }); - input.addEventListener('keydown', e => { - if (e.key === 'Enter') { e.shiftKey ? readerSearchPrev() : readerSearchNext(); } - if (e.key === 'Escape') { toggleReaderSearch(); } - }); - bar.querySelector('#rs-search-prev').addEventListener('click', readerSearchPrev); - bar.querySelector('#rs-search-next').addEventListener('click', readerSearchNext); - bar.querySelector('#rs-search-clear').addEventListener('click', toggleReaderSearch); -} - -async function doReaderSearch(query) { - const contentEl = $('reader-content'); - if (!contentEl) return; - const countEl = document.getElementById('rs-search-count'); - - clearReaderSearchHighlights(); - searchMatches = []; - searchMatchIndex = -1; - - if (!query) { if (countEl) countEl.textContent = ''; return; } - - if (!currentPdfDoc) { - // EPUB: snapshot original content - if (!searchOriginalContent) { - searchOriginalContent = contentEl.innerHTML; - } else { - contentEl.innerHTML = searchOriginalContent; - applyHighlightsToContent(); - } - - const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); - const lq = query.toLowerCase(); - const ranges = []; - let node; - while ((node = walker.nextNode())) { - const text = node.textContent; - const lt = text.toLowerCase(); - let idx = 0; - while ((idx = lt.indexOf(lq, idx)) !== -1) { - const range = document.createRange(); - range.setStart(node, idx); - range.setEnd(node, idx + query.length); - ranges.push(range); - idx += query.length; - } - } - - // Insert marks in reverse to preserve range validity - for (let i = ranges.length - 1; i >= 0; i--) { - try { - const mark = document.createElement('mark'); - mark.className = 'reader-search-match'; - ranges[i].surroundContents(mark); - searchMatches.unshift(mark); - } catch (e) {} - } - } else { - // PDF: collect text layer spans - const spans = contentEl.querySelectorAll('.pdf-text-layer > span'); - const lq = query.toLowerCase(); - for (const span of spans) { - if (span.textContent.toLowerCase().includes(lq)) { - span.classList.add('reader-search-match'); - searchMatches.push(span); - } - } - } - - if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0'; - if (searchMatches.length) { - searchMatchIndex = 0; - scrollToSearchMatch(0); - } -} - -function clearReaderSearchHighlights() { - if (!currentPdfDoc) { - // EPUB: restore from snapshot - if (searchOriginalContent !== null) { - const contentEl = $('reader-content'); - if (contentEl) { - contentEl.innerHTML = searchOriginalContent; - applyHighlightsToContent(); - } - searchOriginalContent = null; - } else { - // Just remove marks without full restore - document.querySelectorAll('mark.reader-search-match').forEach(m => { - const parent = m.parentNode; - while (m.firstChild) parent.insertBefore(m.firstChild, m); - parent.removeChild(m); - }); - } - } else { - // PDF: remove highlight class from spans - document.querySelectorAll('.reader-search-match').forEach(el => { - el.classList.remove('reader-search-match', 'active'); - }); - } - searchMatches = []; - searchMatchIndex = -1; -} - -function clearReaderSearch() { - clearTimeout(_readerSearchDebounce); - clearReaderSearchHighlights(); - readerSearchOpen = false; - const countEl = document.getElementById('rs-search-count'); - if (countEl) countEl.textContent = ''; -} - -function scrollToSearchMatch(idx) { - if (!searchMatches.length) return; - _suppressScrollJumpDetect(); - searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx)); - searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'}); - const countEl = document.getElementById('rs-search-count'); - if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`; -} - -function readerSearchNext() { - if (!searchMatches.length) return; - searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length; - scrollToSearchMatch(searchMatchIndex); -} - -function readerSearchPrev() { - if (!searchMatches.length) return; - searchMatchIndex = (searchMatchIndex - 1 + searchMatches.length) % searchMatches.length; - scrollToSearchMatch(searchMatchIndex); -} - -// --------------------------------------------------------------------------- -// Highlights -// --------------------------------------------------------------------------- - -async function loadHighlights(bookId) { - try { - const res = await fetch(`/books/${bookId}/highlights/`); - const {ct, iv} = await res.json(); - if (ct) { - _saveAnnotationsCache(bookId, 'highlights', ct, iv); // persist for offline - const key = await getOrCreateEncKey(); - const plain = await decryptBytes(key, iv, ct); - currentHighlights = JSON.parse(new TextDecoder().decode(plain)); - } else { - currentHighlights = []; - } - applyHighlightsToContent(); - } catch (e) { - // offline fallback - const cached = await _loadAnnotationsCache(bookId, 'highlights'); - if (cached?.ct) { - try { - const key = await getOrCreateEncKey(); - const plain = await decryptBytes(key, cached.iv, cached.ct); - currentHighlights = JSON.parse(new TextDecoder().decode(plain)); - } catch { currentHighlights = []; } - } else { - currentHighlights = []; - } - applyHighlightsToContent(); - } -} - -async function saveHighlights() { - if (!currentBookId) return; - try { - const key = await getOrCreateEncKey(); - const plain = new TextEncoder().encode(JSON.stringify(currentHighlights)); - const {iv, ciphertext} = await encryptBytes(key, plain); - const body = JSON.stringify({ct: ciphertext, iv}); - const url = `/books/${currentBookId}/highlights/`; - _lastHighlightBeacon = {url, body}; - _saveAnnotationsCache(currentBookId, 'highlights', ciphertext, iv); // keep local cache current - await fetch(url, { - method: 'POST', - headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, - body, - }); - highlightsDirty = false; - } catch (e) { - _markPendingSync(currentBookId, 'highlights'); - } -} - -let _highlightSaveDebounce = null; -function debounceSaveHighlights() { - clearTimeout(_highlightSaveDebounce); - _highlightSaveDebounce = setTimeout(saveHighlights, 2000); -} - -function applyHighlightsToContent() { - const contentEl = $('reader-content'); - if (!contentEl || currentPdfDoc) return; - for (const h of currentHighlights) { - try { renderHighlight(h); } catch (e) {} - } - _repositionMarginMarkers(); -} - -// Resolves a highlight/note's stored anchor back to a live DOM Range. Shared by -// rendering, margin-marker positioning and export ordering — all three need the -// same "where in the current DOM is this annotation" answer. -function resolveAnchorRange(h) { - const contentEl = $('reader-content'); - if (!contentEl || !h.anchor) return null; - - let range = null; - try { - const {blocks} = _epubBlocks(contentEl); - const startBlock = blocks[h.anchor.startBlockIndex]; - const endBlock = blocks[h.anchor.endBlockIndex] ?? startBlock; - if (startBlock && endBlock) { - const start = _nodeAtCharOffset(startBlock, h.anchor.startChar); - const end = _nodeAtCharOffset(endBlock, h.anchor.endChar); - if (start && end) { - range = document.createRange(); - range.setStart(start.node, start.offset); - range.setEnd(end.node, end.offset); - } - } - } catch (e) {} - - // Fallback: quote substring search within the chapter - if (!range && h.anchor.quote) { - const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`) - || contentEl; - const walker = document.createTreeWalker(chapterEl, NodeFilter.SHOW_TEXT); - let node; - while ((node = walker.nextNode())) { - const idx = node.textContent.indexOf(h.anchor.quote); - if (idx !== -1) { - range = document.createRange(); - range.setStart(node, idx); - range.setEnd(node, idx + h.anchor.quote.length); - break; - } - } - } - - return range; -} - -function renderHighlight(h) { - // Freeform notes carry no in-text mark — their anchor exists purely to - // position the note card in the margin (resolveAnchorRange handles that - // directly from the stored xpath, no DOM wrapper needed). - if (h.type === 'note') return; - - const range = resolveAnchorRange(h); - if (!range) return; - - try { - const mark = document.createElement('mark'); - mark.dataset.highlightId = h.id; - mark.className = 'epub-highlight'; - mark.dataset.color = h.color || 'yellow'; - range.surroundContents(mark); - } catch (e) {} -} - -// Character-offset addressing within a block element (p, li, ...), rather -// than XPath-to-a-specific-text-node: XPath sibling indices shift whenever -// ANY highlight in the same block gets wrapped/unwrapped in a (that -// splits/merges text nodes), silently corrupting OTHER highlights' anchors -// in the same block. A block's overall textContent length/order is never -// touched by mark-wrapping, so a character offset into it stays valid no -// matter how many other highlights come and go around it. -function _charOffsetInBlock(block, node, nodeOffset) { - const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); - let count = 0, n; - while ((n = walker.nextNode())) { - if (n === node) return count + nodeOffset; - count += n.textContent.length; - } - return count; -} - -function _nodeAtCharOffset(block, targetOffset) { - const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); - let count = 0, lastNode = null, n; - while ((n = walker.nextNode())) { - lastNode = n; - const len = n.textContent.length; - if (targetOffset <= count + len) { - return {node: n, offset: Math.max(0, targetOffset - count)}; - } - count += len; - } - return lastNode ? {node: lastNode, offset: lastNode.textContent.length} : null; -} - -function _blockAncestor(node) { - const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; - return el ? el.closest(EPUB_BLOCK_SELECTOR) : null; -} - -function buildEpubAnchor(range) { - const contentEl = $('reader-content'); - const chapterEl = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE - ? range.commonAncestorContainer.closest('[data-epub-src]') - : range.commonAncestorContainer.parentElement?.closest('[data-epub-src]'); - - const startBlock = _blockAncestor(range.startContainer); - const endBlock = _blockAncestor(range.endContainer) || startBlock; - const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); - - return { - type: 'epub', - chapterSrc: chapterEl?.getAttribute('data-epub-src') || '', - startBlockIndex: startBlock ? blocks.indexOf(startBlock) : -1, - startChar: startBlock ? _charOffsetInBlock(startBlock, range.startContainer, range.startOffset) : 0, - endBlockIndex: endBlock ? blocks.indexOf(endBlock) : -1, - endChar: endBlock ? _charOffsetInBlock(endBlock, range.endContainer, range.endOffset) : 0, - quote: range.toString().slice(0, 200), - }; -} - -// Marker mode — armed via the pen button in the reader header. Text selection -// only opens the highlight popover while this is active, so normal selection -// (e.g. to copy text) isn't hijacked, and the feature has a discoverable -// on/off switch instead of being an invisible "just select text" gesture. -let markerModeActive = false; - -function toggleMarkerMode() { - markerModeActive = !markerModeActive; - document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.toggle('active', markerModeActive)); -} - -// Note editing/creation happens in the margin column on desktop, but a note -// bottom sheet (see showNoteBottomSheet) on narrow viewports — typing into a -// 150px margin strip on a phone keyboard is unpleasant, and opening that -// column at all is exactly the friction we're trying to remove for mobile -// note-taking. Reviewing existing notes in depth is expected to stay a -// desktop activity; this only affects where the editor for a note appears. -function _isMobileReader() { - return window.innerWidth <= 600; -} - -function handleReaderSelection(e) { - // Clicking an existing highlight always shows its tooltip, regardless of marker mode - const hlMark = e.target.closest('.epub-highlight'); - if (hlMark) { - dismissHighlightPopover(); - const id = hlMark.dataset.highlightId; - const h = currentHighlights.find(x => x.id === id); - showHighlightTooltip(hlMark, h); - return; - } - - if (!markerModeActive) return; - // Don't hijack taps on real interactive content (footnote refs, external - // links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless - //
used only as an internal jump target (sanitizeEpubHtml - // strips its href but the tag itself stays); those aren't actually - // clickable and must not block note-taking, so check for a surviving - // href/footnote-ref rather than just "is inside some ". - if (e.target.closest('button, [data-footnote-ref], a[href]')) return; - - dismissHighlightPopover(); - const sel = window.getSelection(); - if (sel && !sel.isCollapsed && sel.rangeCount) { - const range = sel.getRangeAt(0); - const contentEl = $('reader-content'); - if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) return; - if (range.toString().trim().length === 0) return; - showHighlightPopover(range); - return; - } - - // A plain tap with nothing selected — in marker mode this means "drop a - // freeform note here" instead of doing nothing, so a note-only annotation - // never depends on finding the margin's precise click target (especially - // hard on a narrow phone screen — see createFreeformNote/_openNoteEditor). - if (typeof e.clientX === 'number' && typeof e.clientY === 'number') { - const range = _wordRangeAtPoint(e.clientX, e.clientY); - if (range) createFreeformNote(range); - } -} - -function showHighlightPopover(range) { - const rect = range.getBoundingClientRect(); - const popover = document.createElement('div'); - popover.id = 'highlight-popover'; - popover.className = 'highlight-popover'; - popover.innerHTML = ` - - - - - - `; - popover.style.visibility = 'hidden'; // measure before showing, to avoid a flash at the wrong spot - document.body.appendChild(popover); - currentHighlightPopover = popover; - - // Clamp within the viewport — a selection near the top/edge of the page must - // not produce an invisible, off-screen popover (flip below when there's no - // room above; keep it horizontally on-screen). - const pRect = popover.getBoundingClientRect(); - const minTop = window.scrollY + 8; - const maxTop = window.scrollY + window.innerHeight - pRect.height - 8; - let top = rect.top + window.scrollY - 44; - if (top < minTop) top = rect.bottom + window.scrollY + 8; // no room above -> try below - top = Math.max(minTop, Math.min(top, maxTop)); - let left = rect.left + window.scrollX + rect.width / 2 - pRect.width / 2; - left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8)); - popover.style.top = top + 'px'; - popover.style.left = left + 'px'; - popover.style.visibility = ''; - - // Store range info before selection is cleared - const savedRange = range.cloneRange(); - - popover.addEventListener('click', e => { - const colorBtn = e.target.closest('.hl-color-btn'); - const noteBtn = e.target.closest('.hl-note-btn'); - if (colorBtn) { - createHighlight(colorBtn.dataset.hlColor, savedRange); - } else if (noteBtn) { - createHighlightWithNote(savedRange); - } - }); -} - -function showHighlightTooltip(markEl, h) { - const rect = markEl.getBoundingClientRect(); - const popover = document.createElement('div'); - popover.id = 'highlight-popover'; - popover.className = 'highlight-popover'; - popover.style.flexDirection = 'column'; - popover.style.maxWidth = '220px'; - const noteText = h?.note ? escapeHtml(h.note) : 'Keine Notiz'; - popover.innerHTML = ` -
${noteText}
-
- - -
- `; - popover.style.visibility = 'hidden'; - document.body.appendChild(popover); - currentHighlightPopover = popover; - - const pRect = popover.getBoundingClientRect(); - const minTop = window.scrollY + 8; - const maxTop = window.scrollY + window.innerHeight - pRect.height - 8; - let top = rect.bottom + window.scrollY + 4; - if (top > maxTop) top = rect.top + window.scrollY - pRect.height - 4; // flip above if no room below - top = Math.max(minTop, Math.min(top, maxTop)); - let left = rect.left + window.scrollX; - left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8)); - popover.style.top = top + 'px'; - popover.style.left = left + 'px'; - popover.style.visibility = ''; - - popover.addEventListener('click', ev => { - const editBtn = ev.target.closest('[data-hl-edit-note]'); - const delBtn = ev.target.closest('[data-hl-delete]'); - if (editBtn && h) { - dismissHighlightPopover(); - _openNoteEditor(h); - } - if (delBtn && h) { - dismissHighlightPopover(); - deleteHighlight(h.id); - } - }); - - // Close on outside click - setTimeout(() => { - document.addEventListener('click', dismissHighlightPopover, {once: true}); - }, 0); -} - -function createHighlight(color, range) { - const anchor = buildEpubAnchor(range); - const h = { - id: crypto.randomUUID(), - type: 'highlight', - anchor, - color, - note: '', - createdAt: new Date().toISOString(), - }; - currentHighlights.push(h); - highlightsDirty = true; - window.getSelection()?.removeAllRanges(); - dismissHighlightPopover(); - renderHighlight(h); - _repositionMarginMarkers(); - debounceSaveHighlights(); -} - -function createHighlightWithNote(range) { - const anchor = buildEpubAnchor(range); - const h = { - id: crypto.randomUUID(), - type: 'highlight', - anchor, - color: 'yellow', - note: '', - createdAt: new Date().toISOString(), - }; - currentHighlights.push(h); - highlightsDirty = true; - window.getSelection()?.removeAllRanges(); - dismissHighlightPopover(); - renderHighlight(h); - _repositionMarginMarkers(); - _openNoteEditor(h); -} - -// Every "add/edit a note" entry point routes here — notes are always edited -// right where they conceptually live (the margin), never in the separate -// right-hand sidebar, EXCEPT on narrow viewports, where the margin itself is -// too cramped to type into and a full-width bottom sheet takes over instead. -// `pending` is only set for a not-yet-created freeform note (see -// createFreeformNote); h is null in that case until the user actually saves -// text, so an empty tap doesn't leave a permanent ghost entry behind. -function _openNoteEditor(h, pending) { - if (_isMobileReader()) { - showNoteBottomSheet(h, pending); - } else { - if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin(); - editNoteInlineInMargin(h, pending); - } -} - -function deleteHighlight(id) { - currentHighlights = currentHighlights.filter(h => h.id !== id); - highlightsDirty = true; - // Re-apply all highlights after removing the deleted one - const contentEl = $('reader-content'); - if (contentEl && !currentPdfDoc) { - // Snapshot restore not available mid-session, so remove the mark manually - const mark = contentEl.querySelector(`mark[data-highlight-id="${id}"]`); - if (mark) { - const parent = mark.parentNode; - while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); - parent.removeChild(mark); - } - } - _repositionMarginMarkers(); - debounceSaveHighlights(); -} - -function dismissHighlightPopover() { - if (currentHighlightPopover) { - currentHighlightPopover.remove(); - currentHighlightPopover = null; - } -} - -// --------------------------------------------------------------------------- -// Margin panel — a "page margin" strip left of the reader content. Shows small -// markers next to the on-screen highlights/notes, and lets you click empty -// space to anchor a freeform note at that line's height (EPUB only). -// --------------------------------------------------------------------------- -let readerAnnotationsMarginOpen = false; - -function toggleAnnotationsMargin() { - const el = $('reader-margin'); - const markersEl = $('reader-margin-markers'); - if (!el || !markersEl || currentPdfDoc) return; - readerAnnotationsMarginOpen = !readerAnnotationsMarginOpen; - el.classList.toggle('open', readerAnnotationsMarginOpen); - if (readerAnnotationsMarginOpen) { - // Reposition once now (pre-transition) and once more after the width - // transition settles, since content reflow can shift line heights. - _repositionMarginMarkers(); - el.addEventListener('transitionend', _repositionMarginMarkers, {once: true}); - } else { - markersEl.innerHTML = ''; - } -} - -// Renders each highlight/note at its text position in the margin. Entries -// with note text become a readable "post-it" card (the whole point of the -// margin — you can read notes there, not just see that one exists); a plain -// highlight with no note is just a small color dot. Cards are stacked -// top-to-bottom so overlapping anchors don't overlap visually. -// Where each highlight sits in the book's scroll coordinates. Finding a range -// and measuring it is the expensive part, and scrolling doesn't change it: -// kept until the layout does (content, width, height) or the anchor changes. -let _marginPosCache = null; -const _marginCardHeights = new Map(); // `${stripWidth}|${note}` (or 'dot') → px - -function _marginDocY(h, contentEl) { - const {blocks} = _epubBlocks(contentEl); - const key = `${contentEl.clientWidth}x${contentEl.scrollHeight}`; - if (!_marginPosCache || _marginPosCache.blocks !== blocks || _marginPosCache.key !== key) { - _marginPosCache = {blocks, key, byId: new Map()}; - } - const hit = _marginPosCache.byId.get(h.id); - if (hit && hit.anchor === h.anchor) return hit.y; - let y = null; - try { - const range = resolveAnchorRange(h); - const rect = range && range.getBoundingClientRect(); - if (rect && (rect.width || rect.height)) { - y = rect.top + rect.height / 2 - contentEl.getBoundingClientRect().top + contentEl.scrollTop; - } - } catch (e) {} - _marginPosCache.byId.set(h.id, {anchor: h.anchor, y}); - return y; -} - -// The cards live on one layer in the book's coordinates, built when the -// highlights or the layout change; scrolling only moves the layer (a transform, -// no layout). Rebuilding and measuring the cards on every scroll frame made a -// long book with the margin open stutter. -let _marginLayerKey = null; - -function _repositionMarginMarkers() { - const markersEl = $('reader-margin-markers'); - if (!markersEl) return; - if (!readerAnnotationsMarginOpen || currentPdfDoc) { - markersEl.innerHTML = ''; - _marginLayerKey = null; - return; - } - if (markersEl.querySelector('.margin-note-editor')) return; // don't blow away an active inline edit - const contentEl = $('reader-content'); - if (!contentEl) return; - - const {blocks} = _epubBlocks(contentEl); - const areaRect = markersEl.getBoundingClientRect(); - const width = Math.round(areaRect.width); - const key = `${width}x${contentEl.clientWidth}x${contentEl.scrollHeight}|` + - currentHighlights.map(h => `${h.id}:${h.color || ''}:${h.note || ''}`).join('|'); - let layer = markersEl.querySelector('.margin-layer'); - if (!layer || key !== _marginLayerKey || !_marginPosCache || _marginPosCache.blocks !== blocks) { - markersEl.innerHTML = ''; - layer = document.createElement('div'); - layer.className = 'margin-layer'; - markersEl.appendChild(layer); - _renderMarginLayer(layer, contentEl, width); - _marginLayerKey = key; - } - // Book coordinates → this strip's. - const shift = contentEl.getBoundingClientRect().top - contentEl.scrollTop - areaRect.top; - layer.style.transform = `translateY(${Math.round(shift)}px)`; -} - -// Renders each highlight/note at its text position, in book coordinates. -// Entries with note text become a readable "post-it" card (the whole point of -// the margin — you can read notes there, not just see that one exists); a -// plain highlight with no note is a small colour dot. Cards are stacked top to -// bottom so overlapping anchors don't overlap visually. -function _renderMarginLayer(layer, contentEl, width) { - const entries = []; - for (const h of currentHighlights) { - const y = _marginDocY(h, contentEl); - if (y !== null) entries.push({h, y}); - } - entries.sort((a, b) => a.y - b.y); - - // All cards first, then their heights in one go (each measured right after - // adding it, the browser laid out the whole book once per card). Heights are - // kept per note text and strip width. - const cards = entries.map(({h, y}) => { - const hasText = !!h.note; - const el = document.createElement('div'); - el.className = 'margin-note ' + (hasText ? 'margin-note-text' : 'margin-note-dot'); - el.dataset.highlightId = h.id; - if (h.type === 'note') { - if (hasText) el.classList.add('margin-note-freeform'); - } else { - el.dataset.color = h.color || 'yellow'; - } - if (hasText) el.textContent = h.note; - layer.appendChild(el); - return {el, y, hasText, key: hasText ? `${width}|${h.note}` : 'dot'}; - }); - if (_marginCardHeights.size > 500) _marginCardHeights.clear(); - const unmeasured = cards.filter(c => !_marginCardHeights.has(c.key)); - for (const c of unmeasured) _marginCardHeights.set(c.key, c.el.getBoundingClientRect().height || (c.hasText ? 20 : 12)); - - let nextTop = -Infinity; - const GAP = 4; - for (const {el, y, hasText, key} of cards) { - const top = Math.max(hasText ? y - 8 : y - 6, nextTop); - el.style.top = top + 'px'; - nextTop = top + _marginCardHeights.get(key) + GAP; - } -} - -function handleMarginClick(e) { - const markerBtn = e.target.closest('.margin-note'); - if (markerBtn) { - const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId); - if (h) _openNoteEditor(h); - return; - } - - // Empty space in the markers area → place a new freeform note anchored to - // the first word of whatever line sits at this click height. - if (!readerAnnotationsMarginOpen || currentPdfDoc) return; - const markersEl = $('reader-margin-markers'); - if (!markersEl || !markersEl.contains(e.target)) return; - // If an editor is already open, this click just blurs/commits it (see its - // own blur handler) — it shouldn't also create a second, unrelated note. - if (markersEl.querySelector('.margin-note-editor')) return; - const contentEl = $('reader-content'); - if (!contentEl) return; - - const x = contentEl.getBoundingClientRect().left + 4; - const range = _wordRangeAtPoint(x, e.clientY); - if (range) createFreeformNote(range); -} - -// Finds the word starting at (or just after) the given viewport point, by -// probing near the left edge of the reader content at that height — since -// text is left-aligned, that lands at (or very near) the start of the line. -function _wordRangeAtPoint(x, y) { - let caret = null; - if (document.caretRangeFromPoint) { - caret = document.caretRangeFromPoint(x, y); - } else if (document.caretPositionFromPoint) { - const pos = document.caretPositionFromPoint(x, y); - if (pos) { - caret = document.createRange(); - caret.setStart(pos.offsetNode, pos.offset); - } - } - if (!caret) return null; - - const contentEl = $('reader-content'); - if (!contentEl || !contentEl.contains(caret.startContainer)) return null; - - let node = caret.startContainer; - let offset = caret.startOffset; - if (node.nodeType !== Node.TEXT_NODE) { - const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); - walker.currentNode = node; - node = walker.nextNode(); - if (!node) return null; - offset = 0; - } - - const text = node.textContent; - let start = offset; - while (start < text.length && /\s/.test(text[start])) start++; - if (start >= text.length) return null; - let wordStart = start; - while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--; - let wordEnd = start; - while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++; - if (wordEnd <= wordStart) return null; - - const range = document.createRange(); - range.setStart(node, wordStart); - range.setEnd(node, wordEnd); - return range; -} - -function createFreeformNote(range) { - const anchor = buildEpubAnchor(range); - // Where the note's card/dot would sit in the margin, computed from the live - // range right now (before it goes stale) — used to position the editor, - // whether or not the margin is actually open. - let top = 0; - const markersEl = $('reader-margin-markers'); - if (markersEl) { - const areaRect = markersEl.getBoundingClientRect(); - const rangeRect = range.getBoundingClientRect(); - top = Math.max(0, rangeRect.top + rangeRect.height / 2 - areaRect.top - 8); - } - // The highlight object itself isn't created yet — see _openNoteEditor's - // `pending` param — so a tap that's immediately cancelled or left empty - // never leaves a ghost entry in currentHighlights. - _openNoteEditor(null, {anchor, top}); -} - -// Commits an edit for both note-editor UIs (inline margin + mobile bottom -// sheet): updates an existing note in place, or — only if there's actual -// text — creates and persists a not-yet-existing freeform note. An empty -// tap/cancel on a pending note must never leave a permanent ghost entry. -function _commitNoteEdit(h, pending, text) { - if (h) { - if (h.note !== text) { - h.note = text; - highlightsDirty = true; - debounceSaveHighlights(); - } - return h; - } - if (!text) return null; - const newH = { - id: crypto.randomUUID(), type: 'note', anchor: pending.anchor, - color: null, note: text, createdAt: new Date().toISOString(), - }; - currentHighlights.push(newH); - highlightsDirty = true; - debounceSaveHighlights(); - return newH; -} - -// Edit a note's text right where it lives in the margin, instead of jumping -// to the separate right-hand sidebar — the whole point of setting a note in -// the margin is that you shouldn't have to look away from it to write it. -// `h` is null for a not-yet-created freeform note (see createFreeformNote); -// `pending` then carries its anchor + intended margin position instead. -function editNoteInlineInMargin(h, pending) { - const markersEl = $('reader-margin-markers'); - if (!markersEl) return; - markersEl.querySelector('.margin-note-editor')?.remove(); - - let top = pending?.top || 0; - if (h) { - const existingCard = markersEl.querySelector(`[data-highlight-id="${h.id}"]`); - // The card sits on the moving layer: where it shows in the strip right now. - top = existingCard ? existingCard.getBoundingClientRect().top - markersEl.getBoundingClientRect().top : 0; - if (existingCard) existingCard.style.display = 'none'; - } - - const editor = document.createElement('div'); - editor.className = 'margin-note-editor'; - editor.style.top = top + 'px'; - editor.innerHTML = ''; - markersEl.appendChild(editor); - const textarea = editor.querySelector('textarea'); - textarea.value = h?.note || ''; - - function commit() { - _commitNoteEdit(h, pending, textarea.value.trim()); - editor.remove(); - _marginLayerKey = null; // the edited card was hidden: draw the layer anew - _repositionMarginMarkers(); - } - - textarea.addEventListener('blur', commit); - textarea.addEventListener('keydown', e => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - textarea.blur(); // triggers commit() - } else if (e.key === 'Escape') { - // Stop this from bubbling to the document-level Escape handler, which - // would otherwise close the whole reader (it doesn't know we just want - // to cancel this one edit) — see the global keydown listener. - e.stopPropagation(); - textarea.removeEventListener('blur', commit); // discard, don't save - editor.remove(); - _marginLayerKey = null; - _repositionMarginMarkers(); - } - }); - - textarea.focus(); - textarea.select(); -} - -// Mobile alternative to editNoteInlineInMargin — a full-width sheet instead -// of squeezing a textarea into the 150px margin column (see _openNoteEditor). -let currentNoteBottomSheet = null; - -function dismissNoteBottomSheet() { - currentNoteBottomSheet?.remove(); - currentNoteBottomSheet = null; -} - -function showNoteBottomSheet(h, pending) { - dismissNoteBottomSheet(); - - const sheet = document.createElement('div'); - sheet.className = 'note-bottom-sheet'; - sheet.innerHTML = ` -
-
- -
- - -
-
- `; - document.body.appendChild(sheet); - currentNoteBottomSheet = sheet; - - const textarea = sheet.querySelector('.note-bottom-sheet-textarea'); - textarea.value = h?.note || ''; - - function commit() { - _commitNoteEdit(h, pending, textarea.value.trim()); - dismissNoteBottomSheet(); - if (readerAnnotationsMarginOpen) _repositionMarginMarkers(); - } - function cancel() { dismissNoteBottomSheet(); } - - sheet.querySelector('[data-nbs-save]').addEventListener('click', commit); - sheet.querySelector('[data-nbs-cancel]').addEventListener('click', cancel); - sheet.querySelector('.note-bottom-sheet-backdrop').addEventListener('click', cancel); - textarea.addEventListener('keydown', e => { - if (e.key === 'Escape') { e.stopPropagation(); cancel(); } - }); - - requestAnimationFrame(() => { - sheet.classList.add('open'); - textarea.focus(); - }); -} - -// Same plain-text export as exportAnnotations(), but callable straight from the -// book list (no open reader, so no live DOM/TOC to resolve percent position or -// chapter titles against — falls back to the raw chapterSrc path and orders by -// the anchor's block index, which is already a global reading-order index). -async function downloadBookAnnotationsFromList(bookId, btnEl) { - if (btnEl) btnEl.disabled = true; - try { - const res = await fetch(`/books/${bookId}/highlights/`); - const {ct, iv} = await res.json(); - if (!ct) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; } - const key = await getOrCreateEncKey(); - const plain = await decryptBytes(key, iv, ct); - const highlights = JSON.parse(new TextDecoder().decode(plain)); - if (!highlights.length) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; } - - highlights.sort((a, b) => { - const ab = a.anchor?.startBlockIndex ?? 0, bb = b.anchor?.startBlockIndex ?? 0; - if (ab !== bb) return ab - bb; - return (a.anchor?.startChar ?? 0) - (b.anchor?.startChar ?? 0); - }); - - const bookTitle = bookMetaCache[bookId]?.title || `Buch #${bookId}`; - const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, '']; - - let lastChapter = null; - for (const h of highlights) { - const chapterTitle = h.anchor?.chapterSrc || ''; - if (chapterTitle !== lastChapter) { - lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`); - lastChapter = chapterTitle; - } - if (h.type === 'note') { - lines.push(`[Notiz] bei: „${(h.anchor?.quote || '').trim()}“`); - if (h.note) lines.push(` ${h.note}`); - } else { - lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}] „${(h.anchor?.quote || '').trim()}“`); - if (h.note) lines.push(` Notiz: ${h.note}`); - } - lines.push(''); - } - - const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'}); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } catch (e) { - await customAlert('Export fehlgeschlagen: ' + e.message); - } finally { - if (btnEl) btnEl.disabled = false; - } -} - -// --------------------------------------------------------------------------- -// Export annotations — walks all highlights/notes of the current book in -// reading order and offers them as a downloadable plain-text file. -// --------------------------------------------------------------------------- -function _tocTitleForChapterSrc(chapterSrc) { - if (!chapterSrc) return ''; - const entry = currentBookToc.find(t => (t.href || '').split('#')[0] === chapterSrc); - return entry ? entry.label : ''; -} - -// Rough "how far through the book" position for a resolved range — there's no -// real page concept for reflowable EPUB text, so this is a coordinate of last -// resort: percent of total scrollable content height, good enough to roughly -// relocate a highlight/note later. -function _percentFromRange(range) { - const contentEl = $('reader-content'); - 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; - const offsetWithinScroll = (rangeRect.top - contentRect.top) + contentEl.scrollTop; - return Math.max(0, Math.min(100, (offsetWithinScroll / contentEl.scrollHeight) * 100)); -} - -function _sortedAnnotations() { - const resolved = currentHighlights.map(h => { - let range; - try { range = resolveAnchorRange(h); } catch (e) { range = null; } - return {h, range}; - }); - resolved.sort((a, b) => { - if (!a.range || !b.range) return 0; - return a.range.compareBoundaryPoints(Range.START_TO_START, b.range); - }); - return resolved; -} - -// Highlight colours as the export and the list name them. -function _hlColorName(color) { - return ({yellow: 'gelb', green: 'grün', blue: 'blau', red: 'rot'})[color] || color; -} - -function exportAnnotations() { - if (!currentHighlights.length) { - customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); - return; - } - - const resolved = _sortedAnnotations(); - - const titleEl = $('reader-title'); - const bookTitle = (titleEl?.textContent || 'Buch').split(' — ')[0]; - const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, '']; - - let lastChapter = null; - for (const {h, range} of resolved) { - const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || ''; - if (chapterTitle !== lastChapter) { - lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`); - lastChapter = chapterTitle; - } - const pct = _percentFromRange(range); - const coord = pct !== null ? ` (≈${_formatBookPercent(pct / 100)}% im Buch)` : ''; - if (h.type === 'note') { - lines.push(`[Notiz]${coord} bei: „${(h.anchor?.quote || '').trim()}“`); - if (h.note) lines.push(` ${h.note}`); - } else { - lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}]${coord} „${(h.anchor?.quote || '').trim()}“`); - if (h.note) lines.push(` Notiz: ${h.note}`); - } - lines.push(''); - } - - const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'}); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); -} - -// List of every highlight/note in the book, in reading order, each jumpable — -// the same pattern as the bookmarks sidebar, but for annotations. Unlike the -// margin panel (which only shows what's currently on-screen), this is a full -// overview, useful once a book has annotations scattered throughout. -function openAnnotationsSidebar() { - if (!currentHighlights.length) { - openSidebar('Markierungen & Notizen', '

Noch keine Markierungen oder Notizen.

'); - return; - } - - const resolved = _sortedAnnotations(); - let html = '
    '; - for (const {h, range} of resolved) { - const pct = _percentFromRange(range); - const pctLabel = pct !== null ? `≈${_formatBookPercent(pct / 100)}%` : ''; - const kind = h.type === 'note' ? 'Notiz' : `Markierung${h.color ? ' · ' + _hlColorName(h.color) : ''}`; - const preview = h.note || h.anchor?.quote || '(kein Text)'; - html += `
  • - - -
  • `; - } - html += '
'; - openSidebar('Markierungen & Notizen', html); - - const body = $('sidebar-body'); - body.addEventListener('click', function _annClick(e) { - const jumpBtn = e.target.closest('[data-jump-annotation]'); - const delBtn = e.target.closest('[data-delete-annotation]'); - if (jumpBtn) { - body.removeEventListener('click', _annClick); - jumpToAnnotation(jumpBtn.dataset.jumpAnnotation); - } - if (delBtn) { - deleteHighlight(delBtn.dataset.deleteAnnotation); - openAnnotationsSidebar(); // re-render - } - }); -} - -function jumpToAnnotation(id) { - const h = currentHighlights.find(x => x.id === id); - if (!h) return; - closeSidebar(); - _suppressScrollJumpDetect(); - setTimeout(() => { - const contentEl = $('reader-content'); - if (!contentEl) return; - let range; - try { range = resolveAnchorRange(h); } catch (e) { range = null; } - if (range) { - const top = range.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; - contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); - } - }, 50); -} - -// --------------------------------------------------------------------------- -// Footnote popover -// --------------------------------------------------------------------------- - -let currentFootnotePopover = null; -let _footnotePopoverTimer = null; - -function showFootnotePopover(link) { - dismissFootnotePopover(); - const targetEl = document.getElementById(link.dataset.footnoteRef); - if (!targetEl) return; - // 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'; - const _overlay = document.getElementById('reader-overlay'); - ['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => { - if (_overlay?.classList.contains(cls)) popover.classList.add(cls); - }); - popover.innerHTML = clone.innerHTML; - - const closeBtn = document.createElement('button'); - closeBtn.className = 'footnote-popover-close'; - closeBtn.textContent = '✕'; - closeBtn.addEventListener('click', dismissFootnotePopover); - popover.appendChild(closeBtn); - - const rect = link.getBoundingClientRect(); - popover.style.top = (rect.bottom + 8) + 'px'; - popover.style.left = Math.max(8, rect.left - 8) + 'px'; - - popover.addEventListener('mouseenter', () => clearTimeout(_footnotePopoverTimer)); - popover.addEventListener('mouseleave', () => { - _footnotePopoverTimer = setTimeout(dismissFootnotePopover, 250); - }); - - document.body.appendChild(popover); - currentFootnotePopover = popover; - currentFootnotePopover._refLink = link; - - const pr = popover.getBoundingClientRect(); - if (pr.right > window.innerWidth - 8) popover.style.left = (window.innerWidth - pr.width - 8) + 'px'; - if (pr.bottom > window.innerHeight - 8) popover.style.top = (rect.top - pr.height - 8) + 'px'; -} - -function dismissFootnotePopover() { - clearTimeout(_footnotePopoverTimer); - currentFootnotePopover?.remove(); - currentFootnotePopover = null; -} - -// --------------------------------------------------------------------------- -// Radio sidebar (compact player) -// --------------------------------------------------------------------------- - -function openRadioSidebar() { - const stationName = currentStation ? escapeHtml(currentStation.name) : '— kein Sender —'; - const track = currentTrack ? escapeHtml(currentTrack) : ''; - const vol = document.getElementById('volume')?.value ?? 204; - - const rows = [...document.querySelectorAll('#saved-tbody tr[data-id]')]; - const stationsHtml = rows.length - ? rows.map(r => `
  • `).join('') - : `
  • Keine gespeicherten Sender.
  • `; - - const html = ` -
    -
    ${stationName}
    - ${track ? `
    ${track}
    ` : ''} -
    -
    - - -
    -
      ${stationsHtml}
    - `; - - openSidebar('Radio', html); - - const body = $('sidebar-body'); - body.querySelector('#rsb-playstop').addEventListener('click', togglePlayStop); - body.querySelector('#rsb-volume').addEventListener('input', function () { - const v = this.value; - const mainSlider = document.getElementById('volume'); - const mainNum = document.getElementById('volume-num'); - if (mainSlider) mainSlider.value = v; - if (mainNum) mainNum.value = v; - audio.volume = v / 255; - }); - body.querySelectorAll('.rsb-play-btn').forEach(btn => { - btn.addEventListener('click', () => playStation(btn.dataset.url, btn.dataset.name, null)); - }); -} - -// --------------------------------------------------------------------------- -// Init -// --------------------------------------------------------------------------- - -(function init() { - // Migrate PBKDF2-derived key stored by login/register form - if (window.USER_ID) { - const pending = localStorage.getItem('diora_pending_enc_key'); - if (pending) { - localStorage.setItem(`diora_enc_key_${window.USER_ID}`, pending); - localStorage.removeItem('diora_pending_enc_key'); - } - } - - // Populate saved stations from server-side context if available - if (typeof INITIAL_SAVED !== 'undefined' && Array.isArray(INITIAL_SAVED)) { - // The server already renders saved stations in the template; nothing extra needed. - // But if JS-rendered saved tab were needed we'd call addSavedRow here. - } - - // Restore persisted volume, fall back to slider default - const volSlider = $('volume'); - if (volSlider) { - const saved = localStorage.getItem('diora_volume'); - const vol = saved !== null ? parseInt(saved, 10) : parseInt(volSlider.value, 10); - setVolume(vol); - } - - // Restore persisted read-aloud language - const ttsLangSelect = $('reader-tts-lang'); - if (ttsLangSelect) ttsLangSelect.value = ttsLang; - - // Load recommendations on page load - loadRecommendations(); - - // Initialise focus timer display - renderTimer(); - - // Initialise mood/genre chips - initMoodChips(); - - // Initialise curated station lists - initCuratedLists(); - - // Show curated lists again when search input is cleared - const searchInput = document.getElementById('search-input'); - if (searchInput) { - searchInput.addEventListener('input', function () { - if (this.value === '') { - const curated = document.getElementById('curated-lists'); - if (curated) curated.style.display = ''; - } - }); - } - - // Load focus session stats - loadFocusStats(); - - // Apply encrypted wallpaper (if set) - applyEncryptedBackground(); - - // Init book drop zone - initBookDropZone(); - const showReadToggle = $('book-show-read-toggle'); - if (showReadToggle) showReadToggle.checked = _bookShowRead; - - // Restore active tab: a URL hash (shared/bookmarked link, or browser - // back/forward) wins over the last-used tab remembered in localStorage — - // except when offline, where books (usable offline) always takes over. - const offlineOverride = !navigator.onLine && IS_AUTHENTICATED; - const hashTab = (location.hash || '').replace(/^#/, '').split('/')[0]; - if (!offlineOverride && TOP_TABS.includes(hashTab)) { - _routeFromHash(); - } else { - const savedTab = localStorage.getItem('diora_active_tab') || 'radio'; - const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved'; - showTab(offlineOverride ? 'books' : savedTab); - showRadioTab(savedRadioTab); - } - - // React to connectivity changes - window.addEventListener('offline', () => { - showOfflineBanner(true); - if (IS_AUTHENTICATED) showTab('books'); - }); - window.addEventListener('online', async () => { - showOfflineBanner(false); - await _syncPendingAnnotations(); - 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) { - _readerContentEl.addEventListener('mouseover', e => { - const link = e.target.closest('[data-footnote-ref]'); - if (!link) return; - clearTimeout(_footnotePopoverTimer); - _footnotePopoverTimer = setTimeout(() => showFootnotePopover(link), 120); - }); - _readerContentEl.addEventListener('mouseout', e => { - if (!e.target.closest('[data-footnote-ref]')) return; - _footnotePopoverTimer = setTimeout(dismissFootnotePopover, 250); - }); - _readerContentEl.addEventListener('click', e => { - const link = e.target.closest('[data-footnote-ref]'); - if (!link) return; - e.preventDefault(); - if (currentFootnotePopover?._refLink === link) { - dismissFootnotePopover(); - } else { - showFootnotePopover(link); - } - }); - - // Text selection → highlight color/note popover - _readerContentEl.addEventListener('mouseup', handleReaderSelection); - _readerContentEl.addEventListener('touchend', e => { - const target = e.target; - const touch = e.changedTouches && e.changedTouches[0]; - const clientX = touch ? touch.clientX : undefined; - const clientY = touch ? touch.clientY : undefined; - setTimeout(() => handleReaderSelection({target, clientX, clientY}), 50); - }); - } - - // Margin ("page margin") panel — click a marker to view/edit, click empty - // space to anchor a new freeform note at that line's height - const _readerMarginEl = $('reader-margin'); - if (_readerMarginEl) { - _readerMarginEl.addEventListener('click', handleMarginClick); - } -})(); diff --git a/static/js/app/01-core.js b/static/js/app/01-core.js new file mode 100644 index 0000000..4dbbfef --- /dev/null +++ b/static/js/app/01-core.js @@ -0,0 +1,153 @@ +/** + * diora — radio player + * Handles playback, SSE metadata, search, station management, and affiliate links. + */ + +'use strict'; + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +// Hardcoded for now -- only creamfresh radio gets the vote buttons, since +// only its backend (the DJ) actually does anything with them. +const CREAMFRESH_RADIO_URL = 'https://diora.creamfresh.xyz/radio/creamfresh-stream/'; + +let currentStation = null; // { url, name, id } | null +let currentTrack = ''; +let sseSource = null; +let isPlaying = false; +let currentPlayId = null; + +const audio = new Audio(); + +// Reconnect audio when the output device changes (e.g. Bluetooth, USB DAC) +if (navigator.mediaDevices) { + let _deviceChangeDebounce = null; + navigator.mediaDevices.addEventListener('devicechange', () => { + clearTimeout(_deviceChangeDebounce); + _deviceChangeDebounce = setTimeout(() => { + if (!isPlaying || audio.src === '') return; + const savedTime = audio.currentTime; + const savedSrc = audio.src; + audio.src = savedSrc; + audio.load(); + if (savedTime > 0) { + audio.addEventListener('loadedmetadata', function onMeta() { + audio.currentTime = savedTime; + audio.removeEventListener('loadedmetadata', onMeta); + }); + } + audio.play().catch(() => {}); + }, 500); + }); +} + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +function $(id) { return document.getElementById(id); } + +function getCsrfToken() { + const cookie = document.cookie.split('; ').find(r => r.startsWith('csrftoken=')); + return cookie ? cookie.split('=')[1] : ''; +} + +function formatDateTime(iso) { + if (!iso) return ''; + const d = new Date(iso); + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// --------------------------------------------------------------------------- +// Modal dialogs — styled replacements for native alert()/confirm()/prompt(), +// which look jarring against the rest of the custom dark UI and can't be +// styled or (in confirm/prompt's case) awaited without blocking the thread. +// All three return a Promise; callers must be async and `await` them. +// --------------------------------------------------------------------------- + +function _showModal({ message, showInput = false, inputValue = '', showCancel = false, okLabel = 'OK', danger = false }) { + return new Promise((resolve) => { + const overlay = $('modal-overlay'); + const dialog = $('modal-dialog'); + const msgEl = $('modal-message'); + const inputEl = $('modal-input'); + const okBtn = $('modal-ok-btn'); + const cancelBtn = $('modal-cancel-btn'); + if (!overlay || !dialog) { resolve(null); return; } + + msgEl.textContent = message; + inputEl.style.display = showInput ? '' : 'none'; + inputEl.value = inputValue; + cancelBtn.style.display = showCancel ? '' : 'none'; + okBtn.textContent = okLabel; + okBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary'; + + overlay.style.display = ''; + dialog.style.display = ''; + (showInput ? inputEl : okBtn).focus(); + if (showInput) inputEl.select(); + + function cleanup(result) { + overlay.style.display = 'none'; + dialog.style.display = 'none'; + overlay.removeEventListener('click', onCancel); + okBtn.removeEventListener('click', onOk); + cancelBtn.removeEventListener('click', onCancel); + document.removeEventListener('keydown', onKeydown); + resolve(result); + } + function onOk() { cleanup(showInput ? inputEl.value : true); } + function onCancel() { cleanup(showInput ? null : false); } + function onKeydown(e) { + if (e.key === 'Escape') onCancel(); + if (e.key === 'Enter' && showInput) onOk(); + } + + okBtn.addEventListener('click', onOk); + cancelBtn.addEventListener('click', onCancel); + overlay.addEventListener('click', onCancel); + document.addEventListener('keydown', onKeydown); + }); +} + +function customAlert(message) { + return _showModal({ message }); +} + +function customConfirm(message, { danger = false } = {}) { + return _showModal({ message, showCancel: true, danger }); +} + +// The server answers with short English error codes (an API contract, tests and +// other clients rely on them); what the page shows of them is German. +const _GERMAN_ERRORS = { + 'no stations found in file': 'In der Datei wurden keine Sender gefunden.', + 'file must be .m3u or .m3u8': 'Die Datei muss eine .m3u- oder .m3u8-Datei sein.', + 'no file uploaded': 'Keine Datei hochgeladen.', + 'only jpeg, png, or webp allowed': 'Nur JPEG, PNG oder WebP erlaubt.', + 'file too large (max 50 MB)': 'Datei zu groß (höchstens 50 MB).', + 'file too large (max 5 MB)': 'Datei zu groß (höchstens 5 MB).', + 'authentication required': 'Bitte anmelden.', + 'not found': 'Nicht gefunden.', + 'invalid JSON': 'Ungültige Anfrage.', + 'upstream unreachable': 'Der Server dahinter ist nicht erreichbar.', +}; +function _germanError(code) { + return _GERMAN_ERRORS[code] || code; +} + +function customPrompt(message, defaultValue = '') { + return _showModal({ message, showInput: true, inputValue: defaultValue, showCancel: true }); +} diff --git a/static/js/app/10-radio.js b/static/js/app/10-radio.js new file mode 100644 index 0000000..6a2cf67 --- /dev/null +++ b/static/js/app/10-radio.js @@ -0,0 +1,683 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Play / Stop +// --------------------------------------------------------------------------- + +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. + if (location.protocol === 'https:' && url.startsWith('http://') && !window.DIORA_PLAYS_HTTP_STREAMS) { + window.open(url, '_blank'); + return; + } + + currentStation = { url, name, id: stationId || null }; + isPlaying = true; + + audio.src = url; + const volSlider = document.getElementById('volume'); + if (volSlider) audio.volume = volSlider.value / 255; + audio.play().catch(() => { + // Browser may block autoplay; the user needs to interact first + console.warn('Audio play blocked by browser policy.'); + }); + + $('now-playing-station').textContent = name; + $('now-playing-track').textContent = ''; + $('play-stop-btn').style.display = ''; + $('play-stop-btn').textContent = '⏹ Stopp'; + $('play-stop-btn').classList.add('playing'); + $('save-station-btn').style.display = ''; + + const isCreamfresh = url === CREAMFRESH_RADIO_URL; + $('creamfresh-vote-up-btn').style.display = isCreamfresh ? '' : 'none'; + $('creamfresh-vote-down-btn').style.display = isCreamfresh ? '' : 'none'; + $('creamfresh-vote-up-btn').classList.remove('active'); + $('creamfresh-vote-down-btn').classList.remove('active'); + + startMetadataSSE(url); + startPlaySession(name, url); + maybeShowDonationHint(url, name); + + if ('mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({title: name, artist: 'Radio'}); + navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; }); + navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; }); + navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true)); + try { navigator.mediaSession.setActionHandler('seekbackward', null); } catch (_) {} + try { navigator.mediaSession.setActionHandler('seekforward', null); } catch (_) {} + try { navigator.mediaSession.setActionHandler('nexttrack', null); } catch (_) {} + try { navigator.mediaSession.setActionHandler('previoustrack',null); } catch (_) {} + navigator.mediaSession.playbackState = 'playing'; + } +} + +function stopPlayback(clearStation = true) { + audio.pause(); + audio.src = ''; + isPlaying = false; + if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none'; + + if (sseSource) { + sseSource.close(); + sseSource = null; + } + + $('play-stop-btn').textContent = '▶ Abspielen'; + $('play-stop-btn').classList.remove('playing'); + $('save-station-btn').style.display = 'none'; + $('creamfresh-vote-up-btn').style.display = 'none'; + $('creamfresh-vote-down-btn').style.display = 'none'; + $('affiliate-section').style.display = 'none'; + + stopPlaySession(); + + const stationEl = $('now-playing-station'); + const trackEl = $('now-playing-track'); + + if (clearStation) { + currentStation = null; + currentTrack = ''; + stationEl.textContent = '— kein Sender —'; + trackEl.textContent = ''; + $('play-stop-btn').style.display = 'none'; + } +} + +function togglePlayStop() { + if (isPlaying) { + stopPlayback(true); + } else if (currentStation) { + playStation(currentStation.url, currentStation.name, currentStation.id); + } +} + +// --------------------------------------------------------------------------- +// Play session tracking +// --------------------------------------------------------------------------- + +async function startPlaySession(stationName, stationUrl) { + try { + const res = await fetch('/radio/play/start/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({station_name: stationName, station_url: stationUrl}) + }); + if (res.ok) { + const data = await res.json(); + currentPlayId = data.play_id; + } + } catch (e) {} +} + +async function stopPlaySession() { + // 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: playId}) + }); + } catch (e) {} +} + +window.addEventListener('beforeunload', () => { + if (currentPlayId) { + navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId})); + } + // Flush cached encrypted payloads for reader data (encryption is async so we + // use pre-computed blobs stored by the debounced savers) + // 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); +}); + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden' && currentBookId) { + if (bookmarksDirty) saveBookmarks(); + 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 +let _lastBookmarkBeacon = null; +let _lastHighlightBeacon = null; +let _lastProgressBeacon = null; + +// --------------------------------------------------------------------------- +// Recommendations +// --------------------------------------------------------------------------- + +async function loadRecommendations() { + const container = document.getElementById('recommendations'); + if (!container) return; + try { + const res = await fetch('/radio/recommendations/'); + const data = await res.json(); + if (!data.recommendations.length) { + container.innerHTML = '

    Hör mehr Sender, dann gibt es hier Empfehlungen.

    '; + return; + } + const label = data.context; + let html = `

    Was du ${label} oft hörst:

      `; + for (const r of data.recommendations) { + html += `
    • + + ${r.play_count}× +
    • `; + } + html += '
    '; + container.innerHTML = html; + } catch (e) {} +} + +function escapeAttr(s) { + return String(s).replace(/'/g, "\\'").replace(/"/g, '"'); +} + +// --------------------------------------------------------------------------- +// Volume +// --------------------------------------------------------------------------- + +function setVolume(val) { + val = Math.max(0, Math.min(255, Math.round(val))); + audio.volume = val / 255; + localStorage.setItem('diora_volume', val); + const slider = $('volume'); + const numInput = $('volume-num'); + if (slider) slider.value = val; + if (numInput) numInput.value = val; + _ambientVolume(val); +} + +const volSliderEl = document.getElementById('volume'); +if (volSliderEl) { + ['input', 'change', 'mousemove', 'touchmove'].forEach(evt => + volSliderEl.addEventListener(evt, function () { setVolume(this.value); }) + ); +} + +const volNumEl = document.getElementById('volume-num'); +if (volNumEl) { + volNumEl.addEventListener('change', function () { setVolume(this.value); }); + volNumEl.addEventListener('input', function () { setVolume(this.value); }); + volNumEl.addEventListener('click', function () { this.select(); }); +} + +const volSliderEl2 = document.getElementById('volume'); +const volWheelTarget = volSliderEl2 || volNumEl; +if (volWheelTarget) { + volWheelTarget.addEventListener('wheel', function (e) { + e.preventDefault(); + const current = parseInt(document.getElementById('volume').value, 10); + setVolume(current + (e.deltaY < 0 ? 4 : -4)); + }, { passive: false }); +} + +// --------------------------------------------------------------------------- +// SSE metadata +// --------------------------------------------------------------------------- + +function startMetadataSSE(streamUrl) { + if (sseSource) { sseSource.close(); sseSource = null; } + + const endpoint = '/radio/sse/?url=' + encodeURIComponent(streamUrl); + sseSource = new EventSource(endpoint); + + sseSource.onmessage = function (e) { + let data; + try { data = JSON.parse(e.data); } catch (_) { return; } + + if (data.error) { + console.warn('SSE stream ended:', data.error); + return; + } + + if (data.track && data.track !== currentTrack) { + currentTrack = data.track; + updateNowPlayingUI(data.track); + recordTrack(currentStation ? currentStation.name : '', data.track); + fetchAffiliateLinks(data.track); + } + }; + + sseSource.onerror = function () { + // Connection dropped; the browser will attempt to reconnect automatically + console.warn('SSE connection error, browser will retry.'); + }; +} + +function updateNowPlayingUI(track) { + $('now-playing-track').textContent = track; +} + +// --------------------------------------------------------------------------- +// Record track +// --------------------------------------------------------------------------- + +async function recordTrack(stationName, track) { + try { + const res = await fetch('/radio/record/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken(), + }, + body: JSON.stringify({ station_name: stationName, track, scrobble: true }), + }); + if (res.ok) { + addHistoryRow(stationName, track); + } + } catch (err) { + console.error('recordTrack error:', err); + } +} + +function addHistoryRow(stationName, track) { + const tbody = $('history-tbody'); + if (!tbody) return; + + // Remove the "no history" placeholder row if present + const emptyRow = $('history-empty-row'); + if (emptyRow) emptyRow.remove(); + + const tr = document.createElement('tr'); + const now = new Date().toISOString(); + tr.innerHTML = ` + ${escapeHtml(formatDateTime(now))} + ${escapeHtml(stationName)} + ${escapeHtml(track)} + + + `; + tbody.insertBefore(tr, tbody.firstChild); +} + +async function deleteHistoryEntry(id, btn) { + const tr = btn.closest('tr'); + if (!id) { tr.remove(); return; } + try { + const res = await fetch(`/radio/history/${id}/delete/`, { + method: 'POST', + headers: {'X-CSRFToken': getCsrfToken()}, + }); + if (res.ok) tr.remove(); + } catch (e) {} +} + +// --------------------------------------------------------------------------- +// Affiliate links +// --------------------------------------------------------------------------- + +async function fetchAffiliateLinks(track) { + const section = $('affiliate-section'); + if (section && section.dataset.disabled) return; + try { + const res = await fetch('/radio/affiliate/?track=' + encodeURIComponent(track)); + if (!res.ok) return; + const data = await res.json(); + + const itunes = data.itunes_data || {}; + + $('affiliate-track-name').textContent = itunes.name || track; + $('affiliate-artist-name').textContent = itunes.artist || ''; + $('affiliate-album-name').textContent = itunes.album || ''; + + const artEl = $('affiliate-artwork'); + if (itunes.artwork) { + artEl.src = itunes.artwork; + artEl.style.display = ''; + } else { + artEl.style.display = 'none'; + } + + const amzLink = $('affiliate-amazon-link'); + if (data.amazon_url) { + amzLink.href = data.amazon_url; + amzLink.style.display = ''; + } else { + amzLink.style.display = 'none'; + } + + section.style.display = 'flex'; + } catch (err) { + console.error('fetchAffiliateLinks error:', err); + section.style.display = 'none'; + } +} + +// --------------------------------------------------------------------------- +// Search (radio-browser.info) +// --------------------------------------------------------------------------- + +async function doSearch() { + const query = $('search-input').value.trim(); + if (!query) return; + + const statusEl = $('search-status'); + const tableEl = $('search-results-table'); + const tbody = $('search-results-body'); + + 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 = 'Suche …'; + + 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); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const stations = await res.json(); + + if (!stations.length) { + statusEl.textContent = 'Keine Sender gefunden.'; + return; + } + + statusEl.textContent = stations.length === 1 ? '1 Treffer' : `${stations.length} Treffer`; + tableEl.style.display = ''; + const curated = document.getElementById('curated-lists'); + if (curated) curated.style.display = 'none'; + + stations.forEach(st => { + const tr = document.createElement('tr'); + const safeName = escapeHtml(st.name || ''); + const safeUrl = escapeHtml(st.url_resolved || st.url || ''); + const safeBr = escapeHtml(st.bitrate ? st.bitrate + ' kbps' : ''); + const safeCC = escapeHtml(st.countrycode || st.country || ''); + const safeTags = escapeHtml((st.tags || '').split(',').slice(0, 3).join(', ')); + + tr.innerHTML = ` + ${safeName} + ${safeBr} + ${safeCC} + ${safeTags} + + + + `; + tbody.appendChild(tr); + }); + } catch (err) { + statusEl.textContent = 'Suche fehlgeschlagen: ' + err.message; + } +} + +// 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-Adresse'; + 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 = '▶ Abspielen'; + play.addEventListener('click', () => searchPlay(url, name, data)); + const save = document.createElement('button'); + save.className = 'btn btn-sm'; + save.innerHTML = '★ Speichern'; + save.style.marginLeft = '4px'; + save.addEventListener('click', async e => { + e.stopPropagation(); + const chosen = await customPrompt('Name für diesen Sender', name); + if (chosen == null) return; + const finalName = chosen.trim() || name; + await saveStation({...data, name: finalName}); + statusEl.textContent = `Gespeichert als „${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; + playStation(url, name, null); +} + +// --------------------------------------------------------------------------- +// Save current station +// --------------------------------------------------------------------------- + +async function saveCurrentStation() { + if (!currentStation) return; + + // Use the rich data from search results if available, otherwise minimal data + const data = window._pendingStationData || { + name: currentStation.name, + url: currentStation.url, + bitrate: '', + country: '', + tags: '', + favicon_url: '', + }; + + await saveStation(data); +} + +// --------------------------------------------------------------------------- +// creamfresh radio: DJ feedback (hardcoded to this one station, see +// CREAMFRESH_RADIO_URL above) +// --------------------------------------------------------------------------- + +async function creamfreshVote(direction) { + const upBtn = $('creamfresh-vote-up-btn'); + const downBtn = $('creamfresh-vote-down-btn'); + upBtn.disabled = true; + downBtn.disabled = true; + try { + const res = await fetch('/radio/creamfresh-feedback/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken(), + }, + body: JSON.stringify({ vote: direction }), + }); + if (!res.ok) throw new Error(`feedback returned ${res.status}`); + upBtn.classList.toggle('active', direction === 'up'); + downBtn.classList.toggle('active', direction === 'down'); + } catch (err) { + console.warn('creamfresh vote failed', err); + } finally { + upBtn.disabled = false; + downBtn.disabled = false; + } +} + +async function saveStation(station) { + try { + const res = await fetch('/radio/save/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken(), + }, + body: JSON.stringify(station), + }); + + if (res.status === 401) { + await customAlert('Bitte anmelden, um Sender zu speichern.'); + return; + } + + const data = await res.json(); + if (data.ok) { + if (data.created) { + addSavedRow({ id: data.id, ...station, is_favorite: false }); + } + } + } catch (err) { + console.error('saveStation error:', err); + } +} + +function addSavedRow(station) { + const tbody = $('saved-tbody'); + if (!tbody) return; + + const emptyRow = $('saved-empty-row'); + if (emptyRow) emptyRow.remove(); + + // Check for duplicate + if (document.getElementById(`saved-row-${station.id}`)) return; + + const tr = document.createElement('tr'); + tr.id = `saved-row-${station.id}`; + tr.dataset.id = station.id; + tr.dataset.url = station.url; + tr.dataset.name = station.name; + + const safeName = escapeHtml(station.name || ''); + const safeBr = escapeHtml(station.bitrate || ''); + const safeCC = escapeHtml(station.country || ''); + const safeUrl = escapeHtml(station.url || ''); + + tr.innerHTML = ` + + + + ${safeName} + ${safeBr} + ${safeCC} + + + + + + + + `; + tbody.appendChild(tr); +} + +// --------------------------------------------------------------------------- +// Remove station +// --------------------------------------------------------------------------- + +// 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', + headers: { 'X-CSRFToken': getCsrfToken() }, + }); + if (!res.ok) return; + const data = await res.json(); + + // Flip the star button state + 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'); + if (!tbody) return; + const rows = Array.from(tbody.querySelectorAll('tr[data-id]')); + rows.sort((a, b) => { + const aFav = a.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1; + const bFav = b.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1; + if (aFav !== bFav) return aFav - bFav; + return a.dataset.name.localeCompare(b.dataset.name); + }); + rows.forEach(row => tbody.appendChild(row)); + } catch (err) { + console.error('toggleFav error', err); + } +} + +async function removeStation(pk) { + try { + const res = await fetch(`/radio/remove/${pk}/`, { + method: 'POST', + headers: { 'X-CSRFToken': getCsrfToken() }, + }); + if (res.ok) { + const row = $(`saved-row-${pk}`); + if (row) row.remove(); + + const tbody = $('saved-tbody'); + if (tbody && tbody.querySelectorAll('tr').length === 0) { + const tr = document.createElement('tr'); + tr.id = 'saved-empty-row'; + tr.innerHTML = 'Noch keine gespeicherten Sender.'; + tbody.appendChild(tr); + } + } + } catch (err) { + console.error('removeStation error:', err); + } +} diff --git a/static/js/app/14-focus.js b/static/js/app/14-focus.js new file mode 100644 index 0000000..8d2b922 --- /dev/null +++ b/static/js/app/14-focus.js @@ -0,0 +1,173 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Focus Timer +// --------------------------------------------------------------------------- + +const TIMER_WORK = 25 * 60; +const TIMER_BREAK = 5 * 60; +let timerSeconds = TIMER_WORK; +let timerRunning = false; +let timerIsBreak = false; +let timerInterval = null; + +function timerTick() { + timerSeconds--; + renderTimer(); + if (timerSeconds <= 0) { + clearInterval(timerInterval); + timerInterval = null; + timerRunning = false; + if (!timerIsBreak) { + // work session ended → start break + timerIsBreak = true; + recordFocusSession(); + timerSeconds = TIMER_BREAK; + showTimerNotification('Pause! 5 Minuten.'); + // auto-pause playback during break + if (audio.src && !audio.paused) audio.pause(); + } else { + // break ended → reset to work + timerIsBreak = false; + timerSeconds = TIMER_WORK; + showTimerNotification('Pause vorbei. Weiter geht’s.'); + } + renderTimer(); + } +} + +function toggleTimer() { + if (timerRunning) { + clearInterval(timerInterval); + timerInterval = null; + timerRunning = false; + } else { + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission(); + } + timerRunning = true; + timerInterval = setInterval(timerTick, 1000); + } + renderTimer(); +} + +function resetTimer() { + clearInterval(timerInterval); + timerInterval = null; + timerRunning = false; + timerIsBreak = false; + timerSeconds = TIMER_WORK; + renderTimer(); +} + +function renderTimer() { + const m = String(Math.floor(timerSeconds / 60)).padStart(2, '0'); + const s = String(timerSeconds % 60).padStart(2, '0'); + const display = $('timer-display'); + const btn = $('timer-toggle-btn'); + const label = $('timer-phase-label'); + if (display) display.textContent = `${m}:${s}`; + if (btn) btn.textContent = timerRunning ? '⏸' : '▶'; + if (label) label.textContent = timerIsBreak ? 'Pause' : 'Fokus'; + // colour the display red when break + if (display) display.style.color = timerIsBreak ? '#e63946' : ''; +} + +function showTimerNotification(msg) { + if ('Notification' in window && Notification.permission === 'granted') { + new Notification('diora', { body: msg }); + } + // also flash in the timer label + const label = $('timer-phase-label'); + if (label) { label.textContent = msg; setTimeout(() => renderTimer(), 3000); } +} + +// --------------------------------------------------------------------------- +// Focus session recording +// --------------------------------------------------------------------------- + +async function recordFocusSession() { + try { + await fetch('/radio/focus/record/', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() }, + body: JSON.stringify({ + station_name: currentStation ? currentStation.name : '', + duration_minutes: 25, + }), + }); + loadFocusStats(); + } catch (e) {} +} + +async function loadFocusStats() { + try { + const res = await fetch('/radio/focus/stats/'); + const data = await res.json(); + const widget = document.getElementById('focus-today-widget'); + if (widget) { + if (data.today_sessions > 0) { + widget.textContent = `Heute: ${data.today_sessions} ${data.today_sessions !== 1 ? 'Sitzungen' : 'Sitzung'} · ${data.today_minutes} min`; + widget.style.display = ''; + } else { + widget.style.display = 'none'; + } + } + // populate focus tab + const tbody = document.getElementById('focus-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + if (!data.sessions.length) { + tbody.innerHTML = 'Noch keine Fokus-Sitzungen. Starte den Timer!'; + return; + } + data.sessions.forEach(s => { + const tr = document.createElement('tr'); + const dt = new Date(s.completed_at).toLocaleString([], {dateStyle: 'short', timeStyle: 'short'}); + tr.innerHTML = `${dt}${escapeHtml(s.station_name || '—')}${s.duration_minutes} min`; + tbody.appendChild(tr); + }); + } catch (e) {} +} + +// --------------------------------------------------------------------------- +// Do Not Disturb / focus mode +// --------------------------------------------------------------------------- + +let dndActive = false; + +function toggleDNDLight() { + document.body.classList.toggle('dnd-dark'); + const btn = $('dnd-light-btn'); + if (btn) btn.style.opacity = document.body.classList.contains('dnd-dark') ? '0.4' : '1'; +} + +function toggleDND() { + dndActive = !dndActive; + if (!dndActive) document.body.classList.remove('dnd-dark'); + document.body.classList.toggle('dnd-mode', dndActive); + const btn = $('dnd-btn'); + if (btn) btn.classList.toggle('active', dndActive); + if (dndActive) { + const el = document.documentElement; + if (el.requestFullscreen) el.requestFullscreen(); + else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); + } else { + if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen(); + else if (document.webkitFullscreenElement && document.webkitExitFullscreen) document.webkitExitFullscreen(); + } +} + +// Exit DND on Escape (browser also exits fullscreen on Escape, so sync state) +document.addEventListener('fullscreenchange', () => { + if (!document.fullscreenElement && dndActive) { + dndActive = false; + document.body.classList.remove('dnd-mode'); + const btn = $('dnd-btn'); + if (btn) btn.classList.remove('active'); + } +}); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && dndActive) toggleDND(); +}); diff --git a/static/js/app/16-stations.js b/static/js/app/16-stations.js new file mode 100644 index 0000000..254f5c8 --- /dev/null +++ b/static/js/app/16-stations.js @@ -0,0 +1,173 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Mood / genre tag filter +// --------------------------------------------------------------------------- + +const MOOD_TAGS = [ + { label: 'Fokus', tag: 'ambient' }, + { label: 'Lo-fi', tag: 'lofi' }, + { label: 'Jazz', tag: 'jazz' }, + { label: 'Klassik', tag: 'classical' }, + { label: 'Ambient', tag: 'ambient' }, + { label: 'Metal', tag: 'metal' }, + { label: 'Elektronisch', tag: 'electronic' }, + { label: 'Wort', tag: 'talk' }, +]; + +function initMoodChips() { + const container = $('mood-chips'); + if (!container) return; + MOOD_TAGS.forEach(({ label, tag }) => { + const btn = document.createElement('button'); + btn.className = 'mood-chip'; + btn.textContent = label; + btn.onclick = () => { + const input = $('search-input'); + if (input) input.value = tag; + doSearch(); + }; + container.appendChild(btn); + }); +} + +// --------------------------------------------------------------------------- +// Curated station lists +// --------------------------------------------------------------------------- + +const CURATED_LISTS = [ + { + id: 'focus', + label: 'Fokus', + stations: [ + { name: 'SomaFM Drone Zone', url: 'https://ice6.somafm.com/dronezone-256-mp3' }, + { name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac' }, + { name: 'Nightride FM', url: 'https://stream.nightride.fm/nightride.mp3' }, + { name: 'Nightride FM Chillsynth', url: 'https://stream.nightride.fm/chillsynth.mp3' }, + ], + }, + { + id: 'lofi', + label: 'Lo-fi / Chill', + stations: [ + { name: 'SomaFM Groove Salad Classic', url: 'https://ice6.somafm.com/gsclassic-128-mp3' }, + { name: 'SomaFM Secret Agent', url: 'https://ice4.somafm.com/secretagent-128-mp3' }, + { name: 'dublab DE', url: 'https://dublabde.out.airtime.pro/dublabde_a' }, + ], + }, + { + id: 'dark', + label: 'Dark / Industrial', + stations: [ + { name: 'SomaFM Doomed', url: 'https://ice2.somafm.com/doomed-256-mp3' }, + { name: 'Nightride FM Darksynth', url: 'https://stream.nightride.fm/darksynth.mp3' }, + { name: 'Radio Caprice Industrial', url: 'http://79.120.39.202:9095/' }, + ], + }, + { + id: 'classical', + label: 'Klassik', + stations: [ + { name: 'BR Klassik', url: 'https://dispatcher.rndfnk.com/br/brklassik/live/mp3/high' }, + { name: 'SWR Kultur', url: 'https://f111.rndfnk.com/ard/swr/swr2/live/mp3/256/stream.mp3?aggregator=web' }, + { name: 'Deutschlandfunk Kultur', url: 'https://st02.sslstream.dlf.de/dlf/02/high/aac/stream.aac?aggregator=web' }, + ], + }, +]; + +function initCuratedLists() { + const container = document.getElementById('curated-lists'); + if (!container) return; + + if (INITIAL_FEATURED && INITIAL_FEATURED.length) { + const section = document.createElement('div'); + section.className = 'curated-section'; + section.innerHTML = `
    ★ Empfohlen
    `; + const ul = document.createElement('ul'); + ul.className = 'curated-stations'; + INITIAL_FEATURED.forEach(s => { + const li = document.createElement('li'); + li.innerHTML = ` + ${escapeHtml(s.name)} + ${s.description ? `${escapeHtml(s.description)}` : ''}`; + ul.appendChild(li); + }); + section.appendChild(ul); + container.appendChild(section); + } + + CURATED_LISTS.forEach(list => { + const section = document.createElement('div'); + section.className = 'curated-section'; + section.innerHTML = `
    ${list.label}
    `; + const ul = document.createElement('ul'); + ul.className = 'curated-stations'; + list.stations.forEach(s => { + const li = document.createElement('li'); + li.innerHTML = ` + ${escapeHtml(s.name)}`; + ul.appendChild(li); + }); + section.appendChild(ul); + container.appendChild(section); + }); +} + +// --------------------------------------------------------------------------- +// Donation hint +// --------------------------------------------------------------------------- + +const DONATION_HINT_THRESHOLD = 10; +const DONATION_HINT_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +function maybeShowDonationHint(stationUrl, stationName) { + const station = INITIAL_SAVED.find(s => s.url === stationUrl); + if (!station || station.play_count < DONATION_HINT_THRESHOLD) return; + + const key = `diora_donation_hint_${stationUrl}`; + const last = parseInt(localStorage.getItem(key) || '0', 10); + if (Date.now() - last < DONATION_HINT_COOLDOWN_MS) return; + + // Past the threshold just means "this station is a regular" — from then on + // every single play would otherwise qualify, so only actually show the hint + // on roughly 1 in 10 of those plays instead of every time. + if (Math.random() >= 0.1) return; + + const existing = document.getElementById('donation-hint'); + if (existing) existing.remove(); + + const el = document.createElement('div'); + el.id = 'donation-hint'; + el.innerHTML = ` + Du hörst ${escapeHtml(stationName)} oft – vielleicht magst du den Sender unterstützen ♥ + + `; + document.body.appendChild(el); + + setTimeout(() => dismissDonationHint(stationUrl), 12000); +} + +function dismissDonationHint(stationUrl) { + localStorage.setItem(`diora_donation_hint_${stationUrl}`, Date.now()); + const el = document.getElementById('donation-hint'); + if (el) { el.classList.add('hiding'); setTimeout(() => el.remove(), 400); } +} + +// --------------------------------------------------------------------------- +// Station notes +// --------------------------------------------------------------------------- + +async function editNotes(pk, current) { + const note = await customPrompt('Notiz zum Sender:', current || ''); + if (note === null) return; // cancelled + fetch(`/radio/notes/${pk}/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() }, + body: JSON.stringify({ notes: note }), + }).then(r => { + if (r.ok) { + const cell = document.querySelector(`#saved-row-${pk} .notes-cell`); + if (cell) cell.textContent = note; + } + }); +} diff --git a/static/js/app/20-tabs.js b/static/js/app/20-tabs.js new file mode 100644 index 0000000..a0facca --- /dev/null +++ b/static/js/app/20-tabs.js @@ -0,0 +1,67 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Tabs +// --------------------------------------------------------------------------- + +const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient']; +const RADIO_SUB_TABS = ['search', 'saved', 'history']; + +// --------------------------------------------------------------------------- +// URL hash routing — makes tabs deep-linkable/shareable and lets the browser +// back/forward buttons move between them. Deliberately hash-based rather +// than real paths: /podcasts/ and /books/ etc. are already taken by the +// JSON API, so this avoids touching Django's urls.py at all. +// --------------------------------------------------------------------------- +let _routingFromHash = false; +let _currentTopTab = 'radio'; + +function _syncHash() { + if (_routingFromHash) return; + let hash = _currentTopTab; + if (_currentTopTab === 'radio') { + hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved'); + } + if (location.hash.slice(1) !== hash) history.pushState(null, '', '#' + hash); +} + +function _routeFromHash() { + const [tab, sub] = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean); + if (!TOP_TABS.includes(tab)) return; + _routingFromHash = true; + try { + showTab(tab); + if (tab === 'radio' && RADIO_SUB_TABS.includes(sub)) showRadioTab(sub); + } finally { + _routingFromHash = false; + } +} + +window.addEventListener('popstate', _routeFromHash); +window.addEventListener('hashchange', _routeFromHash); + +function showOfflineBanner(offline) { + document.body.classList.toggle('app-offline', offline); + const uploadArea = $('book-upload-area'); + if (uploadArea) uploadArea.style.display = offline ? 'none' : ''; +} + +function showTab(name) { + TOP_TABS.forEach(p => { + const panel = $(`tab-${p}`); + if (panel) panel.style.display = (p === name) ? '' : 'none'; + }); + + document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => { + btn.classList.toggle('active', TOP_TABS[i] === name); + }); + + localStorage.setItem('diora_active_tab', name); + _currentTopTab = name; + _syncHash(); + + if (name === 'books') loadBookList(); + if (name === 'ambient') _ambientLoad(); + if (name === 'home' && window.__dioraHomeShow) window.__dioraHomeShow(); // static/js/home.js + if (name === 'feeds' && window.__dioraFeedsShow) window.__dioraFeedsShow(); // static/js/feeds.js +} diff --git a/static/js/app/24-ambient.js b/static/js/app/24-ambient.js new file mode 100644 index 0000000..ef11617 --- /dev/null +++ b/static/js/app/24-ambient.js @@ -0,0 +1,141 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// 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; +let _ambientLast = false; // Ambient was started last: the player bar shows it (native-podcast.js) + +function _ambientFrame() { + return $('ambient-frame'); +} + +function _ambientLoad() { + const f = _ambientFrame(); + if (!f) return; + if (!f.getAttribute('src')) { + // Drift takes the page's volume before its first sound; the bar follows its sound world. + f.addEventListener('load', () => { + _ambientVolume(); + try { + const sel = f.contentDocument.getElementById('mode'); + // The sound world chosen last comes back (Drift itself starts with its default). + let last = ''; + try { last = localStorage.getItem('diora_ambient_mode') || ''; } catch (e) {} + if (last && last !== sel.value && [...sel.options].some(o => o.value === last)) { + f.contentWindow.postMessage({type: 'ambient', cmd: 'mode', mode: last}, '*'); + } + sel.addEventListener('change', () => { + try { + localStorage.setItem('diora_ambient_mode', sel.value); + localStorage.setItem('diora_ambient_mode_label', sel.selectedOptions[0] ? sel.selectedOptions[0].text : ''); + } catch (e) {} + if (window.__dioraAmbientChanged) window.__dioraAmbientChanged(); + }); + } catch (e) {} + }); + 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 bar at the bottom grows or shrinks (the player appears, a second line …): its height +// comes as --nui-dock-h / --dpw-bar-h on , set inline — measure again then. +new MutationObserver(() => requestAnimationFrame(_ambientSize)) + .observe(document.documentElement, {attributes: true, attributeFilter: ['style']}); + +// A command for Drift ('start' | 'stop'): the app's notification, the sleep timer. +window.__dioraAmbientCmd = function (cmd, value) { + const f = _ambientFrame(); + if (f && f.contentWindow && f.getAttribute('src')) f.contentWindow.postMessage({type: 'ambient', cmd, value}, '*'); +}; + +// Start Drift from elsewhere (Home): load the frame if needed, then start it — with the +// sound world chosen last, which the frame's load handler above sets. +window.__dioraAmbientStart = function () { + const f = _ambientFrame(); + if (!f) return; + const go = () => window.__dioraAmbientCmd('start'); + let ready = false; + try { ready = !!f.getAttribute('src') && f.contentDocument.readyState === 'complete' && !!f.contentDocument.getElementById('mode'); } catch (e) {} + if (ready) { go(); return; } + f.addEventListener('load', () => setTimeout(go, 0), {once: true}); + _ambientLoad(); +}; + +// The radio or a podcast starts: Ambient stops, and the player bar is theirs again. +window.__dioraAmbientStop = function () { + if (_ambientPlaying) window.__dioraAmbientCmd('stop'); + if (_ambientLast) { + _ambientLast = false; + if (window.__dioraAmbientChanged) window.__dioraAmbientChanged(); + } +}; +window.__dioraAmbientPlaying = () => _ambientPlaying; + +// For the player bar: playing, started last, the sound world's name (the frame is ours). +window.__dioraAmbientInfo = function () { + const f = _ambientFrame(); + let mode = ''; + try { + const sel = f && f.contentDocument && f.contentDocument.getElementById('mode'); + if (sel && sel.selectedOptions[0]) mode = sel.selectedOptions[0].text; + } catch (e) {} + return {playing: _ambientPlaying, last: _ambientLast, mode}; +}; + +// The page's volume (0–255, the bar's slider) is Drift's too; its own slider hides when +// embedded. Not in the Android app: there the volume keys set it, as for everything. +function _ambientVolume(val) { + if (window.DioraNative && DioraNative.ambientState) return; + if (val == null) { + const s = $('volume'); + val = s ? +s.value : +(localStorage.getItem('diora_volume') || 102); + } + window.__dioraAmbientCmd && window.__dioraAmbientCmd('volume', val / 255); +} + +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) _ambientLast = true; + if (window.__dioraAmbientChanged) window.__dioraAmbientChanged(); + // The app keeps it going with the screen off (AmbientService); the browser's sleep timer counts it. + if (window.DioraNative && DioraNative.ambientState) DioraNative.ambientState(_ambientPlaying); + if (window.__dioraOnAmbient) window.__dioraOnAmbient(_ambientPlaying); + 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}`); + if (panel) panel.style.display = (p === name) ? '' : 'none'; + }); + + document.querySelectorAll('#radio-sub-tabs .tab-btn').forEach((btn, i) => { + btn.classList.toggle('active', RADIO_SUB_TABS[i] === name); + }); + + localStorage.setItem('diora_active_radio_tab', name); + _syncHash(); + + if (name === 'saved') loadRecommendations(); +} diff --git a/static/js/app/26-sidebar.js b/static/js/app/26-sidebar.js new file mode 100644 index 0000000..7d9de86 --- /dev/null +++ b/static/js/app/26-sidebar.js @@ -0,0 +1,98 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- + +function openSidebar(title, htmlContent) { + $('sidebar-title').textContent = title; + $('sidebar-body').innerHTML = sanitizeSidebarHtml(htmlContent); + $('sidebar-overlay').style.display = ''; + $('sidebar').classList.add('open'); +} + +function closeSidebar() { + $('sidebar').classList.remove('open'); + $('sidebar-overlay').style.display = 'none'; +} + +document.addEventListener('keydown', e => { + const overlay = $('reader-overlay'); + const readerOpen = overlay && overlay.style.display !== 'none'; + + if (e.key === 'Escape') { + if (readerOpen) { + closeReader(); + } else { + closeSidebar(); + } + } + + if (readerOpen) { + if ((e.ctrlKey && e.key === 'f') || e.key === 'F3') { + e.preventDefault(); + toggleReaderSearch(); + } + if (readerSearchOpen) { + if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); } + if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); } + } + if (readerSettings.pdfPaginated && currentPdfDoc) { + if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); } + if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); } + } + + // Vim-style scroll — ignore when typing in an input + const tag = document.activeElement?.tagName; + if (tag !== 'INPUT' && tag !== 'TEXTAREA') { + const contentEl = $('reader-content'); + if (contentEl) { + const large = Math.round(contentEl.clientHeight * 0.85); + const continuousKeys = {'j': 1, 'k': -1}; + if (continuousKeys[e.key] !== undefined && !e.repeat) { + e.preventDefault(); + _readerScrollDir = continuousKeys[e.key]; + if (!_readerScrollInterval) { + _readerScrollLastTs = performance.now(); + _readerScrollInterval = setInterval(_readerScrollTick, READER_SCROLL_TICK_MS); + } + } + if (e.key === 'd') { e.preventDefault(); contentEl.scrollBy({top: large / 2, behavior: 'smooth'}); } + if (e.key === 'u') { e.preventDefault(); contentEl.scrollBy({top: -large / 2, behavior: 'smooth'}); } + if (e.key === 'f') { e.preventDefault(); contentEl.scrollBy({top: large, behavior: 'smooth'}); } + if (e.key === 'b') { e.preventDefault(); contentEl.scrollBy({top: -large, behavior: 'smooth'}); } + if (e.key === 'g') { e.preventDefault(); contentEl.scrollTop = 0; } + if (e.key === 'G') { e.preventDefault(); contentEl.scrollTop = contentEl.scrollHeight; } + if (currentPdfDoc && !readerSettings.pdfPaginated) { + if (e.key === 'n') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); } + if (e.key === 'p') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); } + } + } + } + } +}); + +document.addEventListener('keyup', e => { + if (e.key === 'j' || e.key === 'k') { + clearInterval(_readerScrollInterval); + _readerScrollInterval = null; + _readerScrollDir = 0; + } +}); + +function sanitizeSidebarHtml(html) { + if (!html) return '

    Keine Shownotes vorhanden.

    '; + const div = document.createElement('div'); + div.innerHTML = html; + div.querySelectorAll('script, iframe, object, embed, style').forEach(el => el.remove()); + div.querySelectorAll('*').forEach(el => { + Array.from(el.attributes).forEach(attr => { + if (attr.name.startsWith('on')) el.removeAttribute(attr.name); + }); + if (el.tagName === 'A') { + el.setAttribute('target', '_blank'); + el.setAttribute('rel', 'noopener noreferrer'); + } + }); + return div.innerHTML; +} diff --git a/static/js/app/28-serviceworker.js b/static/js/app/28-serviceworker.js new file mode 100644 index 0000000..82742dd --- /dev/null +++ b/static/js/app/28-serviceworker.js @@ -0,0 +1,46 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Service Worker +// --------------------------------------------------------------------------- + +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + // Registered from '/sw.js' (root), not '/static/js/sw.js': a service + // worker's default scope is its own script's directory, so registering + // it from under /static/js/ would only ever let it control requests + // under that path — page navigations to '/', '/books/' etc. would never + // be intercepted, breaking offline entirely. + navigator.serviceWorker.register('/sw.js', {scope: '/'}).catch(err => { + console.warn('Service worker registration failed:', err); + }); + // Drop any leftover registration from the old, wrongly-scoped '/static/js/sw.js' + // path so it doesn't keep running or fight the new root-scoped worker. + navigator.serviceWorker.getRegistrations().then(regs => { + regs.forEach(r => { if (r.scope.endsWith('/static/js/')) r.unregister(); }); + }); + }); + // When a new SW activates it sends SW_ACTIVATED — reload to get fresh assets, + // but never in the middle of something: not while a book is open, radio or a + // podcast plays, or a text field has the focus — then as soon as that's over. + let reloadPending = false; + const busy = () => { + if (currentBookId || isPlaying) return true; + const host = window.DioraPodcastHost; + if (host && host.playerState) { + try { if (JSON.parse(host.playerState()).playing) return true; } catch (_) {} + } + const el = document.activeElement; + return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)); + }; + const reloadWhenIdle = () => { + if (busy()) setTimeout(reloadWhenIdle, 5000); + else window.location.reload(); + }; + navigator.serviceWorker.addEventListener('message', e => { + if (e.data?.type === 'SW_ACTIVATED' && !reloadPending) { + reloadPending = true; + reloadWhenIdle(); + } + }); +} diff --git a/static/js/app/29-m3u.js b/static/js/app/29-m3u.js new file mode 100644 index 0000000..9937951 --- /dev/null +++ b/static/js/app/29-m3u.js @@ -0,0 +1,30 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// M3U import +// --------------------------------------------------------------------------- + +async function importM3U(input) { + const file = input.files[0]; + if (!file) return; + const status = document.getElementById('import-status'); + status.textContent = 'Importiere …'; + + const form = new FormData(); + form.append('file', file); + form.append('csrfmiddlewaretoken', getCsrfToken()); + + try { + const res = await fetch('/radio/import/', { method: 'POST', body: form }); + const data = await res.json(); + if (data.ok) { + status.textContent = `✓ ${data.added} hinzugefügt, ${data.skipped} schon gespeichert`; + if (data.added > 0) location.reload(); + } else { + status.textContent = `Fehler: ${_germanError(data.error)}`; + } + } catch (e) { + status.textContent = 'Hochladen fehlgeschlagen'; + } + input.value = ''; +} diff --git a/static/js/app/30-contrast.js b/static/js/app/30-contrast.js new file mode 100644 index 0000000..8afa5cd --- /dev/null +++ b/static/js/app/30-contrast.js @@ -0,0 +1,91 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Contrast scheme +// --------------------------------------------------------------------------- + +// Accent palette — ordered by preference. Algorithm picks the one with the +// highest WCAG contrast ratio against the detected background luminance. +const ACCENT_PALETTE = [ + { base: '#e63946', hover: '#ff4d58' }, // red + { base: '#ff9500', hover: '#ffaa33' }, // orange + { base: '#f1c40f', hover: '#f9d439' }, // yellow + { base: '#2ecc71', hover: '#4ee88a' }, // green + { base: '#00b4d8', hover: '#33c7e5' }, // cyan + { base: '#4361ee', hover: '#6d84f4' }, // blue + { base: '#c77dff', hover: '#d89fff' }, // purple + { base: '#ff6b9d', hover: '#ff8fb5' }, // pink + { base: '#ffffff', hover: '#cccccc' }, // white (last resort) +]; + +function _linearise(c) { + c /= 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} +function _luminance(r, g, b) { + return 0.2126 * _linearise(r) + 0.7152 * _linearise(g) + 0.0722 * _linearise(b); +} +function _contrast(l1, l2) { + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +} +function _hexRgb(hex) { + return [parseInt(hex.slice(1,3),16), parseInt(hex.slice(3,5),16), parseInt(hex.slice(5,7),16)]; +} + +function analyzeBackground(url) { + return new Promise(resolve => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = 64; canvas.height = 64; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, 64, 64); + const data = ctx.getImageData(0, 0, 64, 64).data; + let tR = 0, tG = 0, tB = 0, tBt601 = 0; + const n = data.length / 4; + for (let i = 0; i < data.length; i += 4) { + tR += data[i]; tG += data[i+1]; tB += data[i+2]; + tBt601 += 0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2]; + } + resolve({ + bright: (tBt601 / n) > 127, + bgLuminance: _luminance(tR/n, tG/n, tB/n), + }); + }; + img.onerror = () => resolve({ bright: false, bgLuminance: 0 }); + img.src = url; + }); +} + +function pickBestAccent(bgLuminance) { + let best = ACCENT_PALETTE[0], bestRatio = 0; + for (const entry of ACCENT_PALETTE) { + const [r, g, b] = _hexRgb(entry.base); + const ratio = _contrast(bgLuminance, _luminance(r, g, b)); + if (ratio > bestRatio) { bestRatio = ratio; best = entry; } + } + return best; +} + +function applyAccent(entry) { + const root = document.documentElement; + root.style.setProperty('--accent', entry.base); + 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'); + if (btn) btn.style.opacity = bright ? '1' : '0.5'; +} + +function toggleContrast() { + setScheme(!document.body.classList.contains('bright-bg')); +} diff --git a/static/js/app/32-crypto.js b/static/js/app/32-crypto.js new file mode 100644 index 0000000..53ff499 --- /dev/null +++ b/static/js/app/32-crypto.js @@ -0,0 +1,61 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// E2E Encryption utilities (Web Crypto API) +// --------------------------------------------------------------------------- + +let _encKey = null; + +function bytesToBase64(buf) { + const bytes = new Uint8Array(buf); + let str = ''; + for (const b of bytes) str += String.fromCharCode(b); + return btoa(str); +} + +function base64ToBytes(b64) { + const str = atob(b64); + const buf = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i); + return buf; +} + +function bytesToHex(buf) { + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function hexToBytes(hex) { + const arr = new Uint8Array(hex.length / 2); + for (let i = 0; i < arr.length; i++) arr[i] = parseInt(hex.slice(i*2, i*2+2), 16); + return arr; +} + +async function getOrCreateEncKey() { + if (_encKey) return _encKey; + const storageKey = `diora_enc_key_${window.USER_ID || 0}`; + const stored = localStorage.getItem(storageKey); + if (stored) { + try { + const raw = base64ToBytes(stored); + _encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']); + return _encKey; + } catch (e) { /* fall through, generate new */ } + } + // No key found — generate one and store it + _encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); + const raw = await crypto.subtle.exportKey('raw', _encKey); + localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw))); + return _encKey; +} + +async function encryptBytes(key, plainBytes) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, plainBytes); + return {iv: bytesToHex(iv), ciphertext: bytesToBase64(ct)}; +} + +async function decryptBytes(key, ivHex, ctB64) { + const iv = hexToBytes(ivHex); + const ct = base64ToBytes(ctB64); + return crypto.subtle.decrypt({name: 'AES-GCM', iv}, key, ct); +} diff --git a/static/js/app/34-wallpaper.js b/static/js/app/34-wallpaper.js new file mode 100644 index 0000000..c098ab8 --- /dev/null +++ b/static/js/app/34-wallpaper.js @@ -0,0 +1,54 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Encrypted wallpaper +// --------------------------------------------------------------------------- + +async function uploadBackground(file) { + if (!file) return; + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; + if (!allowedTypes.includes(file.type)) { + await customAlert('Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.'); + return; + } + if (file.size > DIORA_CONFIG.bgMaxBytes) { + await customAlert(`Das Bild darf höchstens ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB groß sein.`); + return; + } + + const key = await getOrCreateEncKey(); + const buf = await file.arrayBuffer(); + const {iv, ciphertext} = await encryptBytes(key, buf); + + const res = await fetch('/accounts/background/upload/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({iv, ciphertext, mime_type: file.type, file_size: file.size}), + }); + const data = await res.json(); + if (!data.ok) throw new Error(data.error || 'upload failed'); +} + +async function applyEncryptedBackground() { + if (typeof ENCRYPTED_BG === 'undefined' || !ENCRYPTED_BG.ciphertext) return; + try { + const key = await getOrCreateEncKey(); + const plain = await decryptBytes(key, ENCRYPTED_BG.iv, ENCRYPTED_BG.ciphertext); + 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'; + + // Analyze brightness for accent/scheme + analyzeBackground(url).then(({bright, bgLuminance}) => { + setScheme(bright); + applyAccent(pickBestAccent(bgLuminance)); + }); + } catch (e) { + console.warn('Could not decrypt background:', e); + } +} diff --git a/static/js/app/40-epub.js b/static/js/app/40-epub.js new file mode 100644 index 0000000..6e83b48 --- /dev/null +++ b/static/js/app/40-epub.js @@ -0,0 +1,262 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// EPUB parser (requires JSZip) +// --------------------------------------------------------------------------- + +function resolveEpubPath(base, relative) { + if (!relative) return ''; + if (relative.startsWith('/')) return relative.slice(1); + const hashIdx = relative.indexOf('#'); + const frag = hashIdx >= 0 ? relative.slice(hashIdx) : ''; + const rel = hashIdx >= 0 ? relative.slice(0, hashIdx) : relative; + const parts = (base + rel).split('/'); + const resolved = []; + for (const p of parts) { + if (p === '..') resolved.pop(); + else if (p !== '.') resolved.push(p); + } + return resolved.join('/') + frag; +} + +async function parseEpub(arrayBuffer) { + const zip = await JSZip.loadAsync(arrayBuffer); + + // 1. Find OPF via container.xml + const containerXml = await zip.file('META-INF/container.xml').async('text'); + const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml'); + const rootfileEl = containerDoc.querySelector('rootfile'); + if (!rootfileEl) throw new Error('Ungültige EPUB-Datei (container.xml ohne rootfile)'); + const opfPath = rootfileEl.getAttribute('full-path'); + const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ''; + + // 2. Parse OPF + const opfText = await zip.file(opfPath).async('text'); + const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); + + const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unbekannter Titel'; + const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unbekannter Autor'; + + // 3. Build manifest: id → {href, mediaType, properties} + const manifest = {}; + opfDoc.querySelectorAll('manifest > item').forEach(item => { + manifest[item.getAttribute('id')] = { + href: opfDir + item.getAttribute('href'), + mediaType: item.getAttribute('media-type') || '', + properties: item.getAttribute('properties') || '', + }; + }); + + // 4. Build image map: abs zip path → blob URL (parallel) + const imageMap = {}; + await Promise.all( + Object.values(manifest) + .filter(({mediaType}) => mediaType.startsWith('image/')) + .map(async ({href, mediaType}) => { + try { + const buf = await zip.file(href).async('arraybuffer'); + imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType})); + } catch (e) { /* missing asset */ } + }) + ); + + // 5. Parse TOC + const toc = await _parseEpubToc(zip, opfDoc, manifest); + + // 6. Get spine items; chapters are returned raw for progressive DOM injection + const spineItems = Array.from(opfDoc.querySelectorAll('spine > itemref')) + .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 = 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, ''); + chapterText = chapterText.replace(/]*>[\s\S]*?<\/script>/gi, ''); + const chapterDir = href.includes('/') ? href.substring(0, href.lastIndexOf('/') + 1) : ''; + const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap); + const sanitized = sanitizeEpubHtml(withBlobs); + chapters.push(`
    ${sanitized}
    `); + // 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}; +} + +async function _parseEpubToc(zip, opfDoc, manifest) { + // Try EPUB3 nav document + const navItem = Object.values(manifest).find(m => m.properties.includes('nav')); + if (navItem) { + try { + const navText = await zip.file(navItem.href).async('text'); + const navDoc = new DOMParser().parseFromString(navText, 'application/xhtml+xml'); + const tocNav = navDoc.querySelector('nav[epub\\:type="toc"]') || navDoc.querySelector('nav'); + if (tocNav) { + const ol = tocNav.querySelector('ol'); + if (ol) return _parseTocOl(ol, navItem.href, 0); + } + } catch (e) {} + } + + // Fall back to EPUB2 NCX + const ncxItem = Object.values(manifest).find(m => m.mediaType === 'application/x-dtbncx+xml'); + if (ncxItem) { + try { + const ncxText = await zip.file(ncxItem.href).async('text'); + const ncxDoc = new DOMParser().parseFromString(ncxText, 'application/xml'); + const ncxDir = ncxItem.href.includes('/') ? ncxItem.href.substring(0, ncxItem.href.lastIndexOf('/') + 1) : ''; + return _parseNcxNavMap(ncxDoc.querySelector('navMap'), ncxDir, 0); + } catch (e) {} + } + + return []; +} + +function _parseTocOl(ol, navHref, depth) { + if (!ol || depth > 5) return []; + const navDir = navHref.includes('/') ? navHref.substring(0, navHref.lastIndexOf('/') + 1) : ''; + const items = []; + for (const li of Array.from(ol.children)) { + const a = li.querySelector(':scope > a') || li.querySelector(':scope > span'); + if (a) { + const rawHref = a.getAttribute('href') || ''; + items.push({ + label: a.textContent.trim(), + href: rawHref ? resolveEpubPath(navDir, rawHref) : '', + depth, + }); + } + const childOl = li.querySelector(':scope > ol'); + if (childOl) items.push(..._parseTocOl(childOl, navHref, depth + 1)); + } + return items; +} + +function _parseNcxNavMap(navMap, ncxDir, depth) { + if (!navMap || depth > 5) return []; + const items = []; + for (const navPoint of Array.from(navMap.children)) { + if (navPoint.tagName !== 'navPoint') continue; + const label = navPoint.querySelector('navLabel > text')?.textContent?.trim() || ''; + const src = navPoint.querySelector('content')?.getAttribute('src') || ''; + items.push({label, href: src ? resolveEpubPath(ncxDir, src) : '', depth}); + items.push(..._parseNcxNavMap(navPoint, ncxDir, depth + 1)); + } + return items; +} + +function _resolveImageBlob(imageMap, absPath) { + if (imageMap[absPath]) return imageMap[absPath]; + // Fallback: match by decoded filename only + const name = decodeURIComponent(absPath.split('/').pop()).toLowerCase(); + for (const [k, v] of Object.entries(imageMap)) { + if (decodeURIComponent(k.split('/').pop()).toLowerCase() === name) return v; + } + return null; +} + +// Replace image src/href in raw chapter HTML text with blob URLs before DOMParser sees it. +// This avoids relying on innerHTML serialisation preserving attributes we set on DOM nodes. +function _injectImageBlobs(html, chapterDir, imageMap) { + function subst(src) { + if (!src || src.startsWith('blob:') || src.startsWith('data:') || src.startsWith('http')) return src; + return _resolveImageBlob(imageMap, resolveEpubPath(chapterDir, src)) || ''; + } + // + html = html.replace(/(]*?)\bsrc="([^"]*)"/gi, + (_, pre, src) => { const b = subst(src); return b ? `${pre}src="${b}"` : pre; }); + // SVG + html = html.replace(/(]*?)\bxlink:href="([^"]*)"/gi, + (_, pre, src) => { const b = subst(src); return b ? `${pre}xlink:href="${b}"` : pre; }); + // SVG + html = html.replace(/(]*?)\bhref="([^"]*)"/gi, + (_, pre, src) => { const b = subst(src); return b ? `${pre}href="${b}"` : pre; }); + return html; +} + +// EPUBs mark footnote/endnote reference links very inconsistently across +// editions/tools — some use class="footnote" (same-file '#frag' href), some +// use the EPUB3 epub:type="noteref" attribute, and some (e.g. older +// Calibre/zeno.org conversions) use neither and just wrap a plain link to +// another chapter file's fragment in a — in either direction +// (… or …). Recognize all of these +// rather than only the single hardcoded class name. +function _looksLikeFootnoteLink(el, href) { + if (!href.includes('#')) return false; + const cls = (el.getAttribute('class') || '').toLowerCase(); + if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true; + const epubType = (el.getAttribute('epub:type') || '').toLowerCase(); + if (epubType.includes('noteref')) return true; + 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()); + + doc.querySelectorAll('*').forEach(el => { + Array.from(el.attributes).forEach(attr => { + if (attr.name.startsWith('on') || attr.name === 'title') el.removeAttribute(attr.name); + }); + const tag = el.tagName.toLowerCase(); + if (tag === 'img') { + const src = el.getAttribute('src') || ''; + // 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://')) { + el.setAttribute('target', '_blank'); + el.setAttribute('rel', 'noopener noreferrer'); + } else if (_looksLikeFootnoteLink(el, href)) { + // The whole book is one concatenated DOM, so a fragment id resolves + // via getElementById regardless of which chapter file it originally + // pointed into — strip any "../Text/other.html" path prefix and keep + // just the id after the last '#'. + el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1); + el.removeAttribute('href'); + el.style.cursor = 'pointer'; + } else { + el.removeAttribute('href'); + el.style.cursor = 'default'; + } + } + }); + + return doc.body ? doc.body.innerHTML : doc.documentElement.innerHTML; +} diff --git a/static/js/app/44-books.js b/static/js/app/44-books.js new file mode 100644 index 0000000..744f895 --- /dev/null +++ b/static/js/app/44-books.js @@ -0,0 +1,298 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Books +// --------------------------------------------------------------------------- + +let currentBookId = null; +let currentBookToc = []; +let currentImageMap = {}; +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" +// coordinates): a whole percent is fine for a normal book, but on a long one +// (~600-700 physical pages and up) it barely moves during a whole scrolling +// session, which reads as broken. Past that length, show one decimal — +// formatted with the browser locale's own decimal separator. +const LONG_BOOK_CHAR_THRESHOLD = 1000000; + +function _formatBookPercent(fraction) { + const pct = Math.max(0, Math.min(100, fraction * 100)); + const decimals = _currentBookCharCount > LONG_BOOK_CHAR_THRESHOLD ? 1 : 0; + return pct.toLocaleString(undefined, {minimumFractionDigits: decimals, maximumFractionDigits: decimals}); +} + +// Accepts both '45.3' and '45,3' (locale-typed input) +function _parseLocalePercent(str) { + const v = parseFloat(String(str).trim().replace(',', '.')); + return isNaN(v) ? 0 : v; +} + +// --------------------------------------------------------------------------- +// Reader "scrolled too far" safety net — offers a toast to jump back after a +// sudden, large scroll (accidental fling, fat-fingered keybind, etc.). Only +// watches organic scrolling; explicit destination jumps (TOC, bookmarks, the +// progress input, search) call _suppressScrollJumpDetect() first so they +// don't trigger it. +// --------------------------------------------------------------------------- +let _scrollRestTop = null; // scrollTop last "settled" at, before the current burst +let _scrollRestDebounce = null; +let _scrollJumpSuppressed = false; // true while an explicit/programmatic jump is in flight +let _scrollJumpToastEl = null; + +// Marks the current (and any immediately-following, possibly animated) scroll +// as deliberate, so _checkScrollJump ignores it. Suppression lifts itself +// once scrolling actually settles (see _armScrollJumpSettle) — not after a +// fixed delay — so it stays active for however long a `smooth` scroll +// animation to a far-away target actually takes, and calling this again +// (e.g. from repeated resize-triggered corrections while a book settles) +// simply pushes that settle point further out instead of racing it. +function _suppressScrollJumpDetect() { + _scrollJumpSuppressed = true; + _armScrollJumpSettle(); +} + +function _armScrollJumpSettle() { + clearTimeout(_scrollRestDebounce); + _scrollRestDebounce = setTimeout(() => { + const contentEl = $('reader-content'); + if (contentEl) _scrollRestTop = contentEl.scrollTop; + _scrollJumpSuppressed = false; + }, 500); +} + +function _dismissScrollJumpToast() { + if (_scrollJumpToastEl) { + _scrollJumpToastEl.remove(); + _scrollJumpToastEl = null; + } + if (_readPos && _readPos.hold === 'jump') _readPos.hold = false; +} + +function _showScrollJumpToast(backTop) { + _dismissScrollJumpToast(); + const toast = document.createElement('div'); + toast.className = 'reader-toast reader-toast-action'; + 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) { + _suppressScrollJumpDetect(); + contentEl.scrollTo({top: backTop, behavior: 'smooth'}); + } + _dismissScrollJumpToast(); + // Back where it was: that's no new reading. + _readerInteractedAt = 0; + }); + 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) { + const top = contentEl.scrollTop; + if (_scrollRestTop === null) _scrollRestTop = top; // first sample this session + + if (!_scrollJumpSuppressed && !_scrollJumpToastEl) { + const screen = contentEl.clientHeight || 1; + if (Math.abs(top - _scrollRestTop) > screen * 3.5) { + _showScrollJumpToast(_scrollRestTop); + } + } + + // Only adopt the new position as the "settled" baseline — and lift any + // suppression — once scrolling has paused for a bit. That's what lets a + // fast, still-in-progress fling (or a long smooth-scroll animation) be + // compared against/protected until it actually finishes, not mid-flight. + _armScrollJumpSettle(); +} + +const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))'; + +// 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; + // 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++) { + 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)}`; +} + +// 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(':'); + if (parts.length !== 2) return false; + const idx = parseInt(parts[0], 10); + const innerFraction = parseFloat(parts[1]); + if (isNaN(idx) || isNaN(innerFraction)) return false; + const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); + if (idx >= blocks.length) return false; + const el = blocks[idx]; + contentEl.scrollTop = el.offsetTop + Math.round(innerFraction * el.offsetHeight); + return true; +} + +// Split 'blockIndex:innerFraction' into [block, inner]; [-1, 0] if invalid/empty. +function _anchorParts(anchor) { + if (typeof anchor !== 'string') return [-1, 0]; + const c = anchor.indexOf(':'); + if (c < 0) return [-1, 0]; + const b = parseInt(anchor.slice(0, c), 10); + const f = parseFloat(anchor.slice(c + 1)); + return [Number.isFinite(b) ? b : -1, Number.isFinite(f) ? f : 0]; +} + +// >0 if position A is further into the book than B, <0 if behind, 0 if equal. +// Compares by anchor block index (precise); falls back to scroll_fraction only +// when an anchor is missing (e.g. PDFs), matching the server's _progress_is_further. +function _cmpProgress(anchorA, fracA, anchorB, fracB) { + const [ba, ia] = _anchorParts(anchorA); + const [bb, ib] = _anchorParts(anchorB); + if (ba >= 0 && bb >= 0) return ba !== bb ? ba - bb : ia - ib; + return (fracA || 0) - (fracB || 0); +} diff --git a/static/js/app/46-readpos.js b/static/js/app/46-readpos.js new file mode 100644 index 0000000..c53aeef --- /dev/null +++ b/static/js/app/46-readpos.js @@ -0,0 +1,198 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// 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; diff --git a/static/js/app/48-book-cache.js b/static/js/app/48-book-cache.js new file mode 100644 index 0000000..84cf793 --- /dev/null +++ b/static/js/app/48-book-cache.js @@ -0,0 +1,294 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Book cache — IndexedDB, evict after 4 weeks of inactivity +// --------------------------------------------------------------------------- +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', 3); + req.onupgradeneeded = e => { + const db = e.target.result; + if (!db.objectStoreNames.contains(_BOOK_CACHE_STORE)) + db.createObjectStore(_BOOK_CACHE_STORE, {keyPath: 'id'}); + if (!db.objectStoreNames.contains(_BOOK_META_STORE)) + 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(); + return await new Promise((resolve) => { + const req = db.transaction(_BOOK_CACHE_STORE).objectStore(_BOOK_CACHE_STORE).get(bookId); + req.onsuccess = e => resolve(e.target.result || null); + req.onerror = () => resolve(null); + }); + } catch { return null; } +} + +async function _setCachedBook(bookId, data_ct, data_iv) { + try { + const db = await _openBookCacheDb(); + await new Promise((resolve) => { + const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); + tx.objectStore(_BOOK_CACHE_STORE).put({id: bookId, data_ct, data_iv, cached_at: Date.now()}); + tx.oncomplete = resolve; + tx.onerror = resolve; + }); + // Request persistent storage so the browser doesn't evict cached books + // under storage pressure. Chrome grants this silently for installed PWAs; + // Firefox may show a permission prompt. + if (navigator.storage?.persist) navigator.storage.persist(); + } catch {} +} + +async function _evictCachedBook(bookId) { + try { + const db = await _openBookCacheDb(); + await new Promise((resolve) => { + const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); + tx.objectStore(_BOOK_CACHE_STORE).delete(bookId); + tx.oncomplete = resolve; + tx.onerror = resolve; + }); + } catch {} +} + +async function _evictBookCache(bookList) { + try { + const db = await _openBookCacheDb(); + const now = Date.now(); + const metaById = Object.fromEntries(bookList.map(b => [b.id, b])); + await new Promise((resolve) => { + const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite'); + const store = tx.objectStore(_BOOK_CACHE_STORE); + store.openCursor().onsuccess = e => { + const cursor = e.target.result; + if (!cursor) { resolve(); return; } + const meta = metaById[cursor.value.id]; + const lastRead = meta?.last_read ? new Date(meta.last_read).getTime() : 0; + if (!meta || (now - lastRead) > _BOOK_CACHE_EVICT_MS) cursor.delete(); + cursor.continue(); + }; + tx.onerror = resolve; + }); + } catch {} +} + +async function _saveBookMeta(books) { + try { + const db = await _openBookCacheDb(); + const ids = new Set(books.map(b => String(b.id))); + await new Promise(resolve => { + const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); + const store = tx.objectStore(_BOOK_META_STORE); + for (const b of books) store.put(b); + store.openCursor().onsuccess = e => { + const cursor = e.target.result; + if (!cursor) return; + if (!ids.has(String(cursor.value.id))) cursor.delete(); + cursor.continue(); + }; + tx.oncomplete = resolve; + tx.onerror = resolve; + }); + } catch {} +} + +async function _loadCachedBookMeta() { + try { + const db = await _openBookCacheDb(); + return await new Promise(resolve => { + const req = db.transaction(_BOOK_META_STORE).objectStore(_BOOK_META_STORE).getAll(); + req.onsuccess = e => resolve(e.target.result || []); + req.onerror = () => resolve([]); + }); + } catch { return []; } +} + +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 => { + const tx = db.transaction(_BOOK_META_STORE, 'readwrite'); + const store = tx.objectStore(_BOOK_META_STORE); + const req = store.get(bookId); + req.onsuccess = e => { + const entry = e.target.result; + // 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.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); + } + }; + tx.oncomplete = resolve; + tx.onerror = resolve; + }); + } catch {} +} + +async function _saveAnnotationsCache(bookId, type, ct, iv) { + try { + const db = await _openBookCacheDb(); + await new Promise(resolve => { + const tx = db.transaction(_BOOK_ANNOTATIONS_STORE, 'readwrite'); + tx.objectStore(_BOOK_ANNOTATIONS_STORE).put({id: `${bookId}_${type}`, ct, iv}); + tx.oncomplete = resolve; + tx.onerror = resolve; + }); + } catch {} +} + +async function _loadAnnotationsCache(bookId, type) { + try { + const db = await _openBookCacheDb(); + return await new Promise(resolve => { + const req = db.transaction(_BOOK_ANNOTATIONS_STORE).objectStore(_BOOK_ANNOTATIONS_STORE).get(`${bookId}_${type}`); + req.onsuccess = e => resolve(e.target.result || null); + req.onerror = () => resolve(null); + }); + } catch { return null; } +} + +const _PENDING_SYNC_KEY = 'diora_pending_sync'; + +function _markPendingSync(bookId, type) { + try { + const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]'); + const key = `${bookId}:${type}`; + if (!pending.includes(key)) pending.push(key); + localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(pending)); + } catch {} +} + +async function _syncPendingAnnotations() { + try { + const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]'); + if (!pending.length) return; + const remaining = []; + for (const key of pending) { + const sep = key.indexOf(':'); + const bookId = key.slice(0, sep); + const type = key.slice(sep + 1); + const cached = await _loadAnnotationsCache(bookId, type); + if (!cached?.ct) continue; + const url = type === 'bookmarks' + ? `/books/${bookId}/bookmarks/` + : `/books/${bookId}/highlights/`; + try { + await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({ct: cached.ct, iv: cached.iv}), + }); + } catch { remaining.push(key); } + } + localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(remaining)); + } catch {} +} diff --git a/static/js/app/50-reader-state.js b/static/js/app/50-reader-state.js new file mode 100644 index 0000000..2ba6b3c --- /dev/null +++ b/static/js/app/50-reader-state.js @@ -0,0 +1,54 @@ +'use strict'; + +// Reader settings +let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', + fontFamily: 'serif', noBold: false, fontWeight: null, + pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }; +let readerSettingsPanelOpen = false; +let currentPdfDoc = null; +let currentPdfBuffer = null; + +// Bookmarks +let currentBookmarks = []; +let bookmarksDirty = false; + +// Highlights +let currentHighlights = []; +let highlightsDirty = false; +let currentHighlightPopover = null; + +// Search +let searchMatches = []; +let searchMatchIndex = -1; +let searchOriginalContent = null; +let readerSearchOpen = false; + +// PDF paginated +let pdfCurrentPage = 1; +let pdfTotalPages = 0; +let _pdfPageTextBoxCache = {}; +let _pdfRenderGen = 0; +let _touchStartX = 0; +let _readerScrollInterval = null; +let _readerScrollDir = 0; +let _readerScrollLastTs = 0; +const READER_SCROLL_SPEED = 1200; // constant px/s, no ramp-up +const READER_SCROLL_TICK_MS = 20; // fixed cadence, independent of the + // display's refresh rate — rAF runs at + // that rate (120/144/165Hz on plenty of + // laptops), which was driving 2-3x more + // scrollBy + scroll-listener work per + // second than intended and read as lag. +function _readerScrollTick() { + const now = performance.now(); + const dt = now - _readerScrollLastTs; + _readerScrollLastTs = now; + $('reader-content')?.scrollBy({top: Math.round(_readerScrollDir * READER_SCROLL_SPEED * dt / 1000)}); +} +let _pinchStartDist = 0; +let _pinchStartZoom = 100; +let _isPinching = false; + +if (typeof pdfjsLib !== 'undefined') { + pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js'; +} diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js new file mode 100644 index 0000000..3205b2a --- /dev/null +++ b/static/js/app/52-book-list.js @@ -0,0 +1,630 @@ +'use strict'; + +async function loadBookList() { + if (!IS_AUTHENTICATED) return; + const listEl = $('book-list'); + if (!listEl) return; + listEl.innerHTML = '

    Bücher werden geladen …

    '; + + 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; + } + const books = await res.json(); + _evictBookCache(books); // fire-and-forget + if (!Array.isArray(books)) { + listEl.innerHTML = `

    Unerwartete Antwort vom Server.

    `; + return; + } + if (!books.length) { + listEl.innerHTML = '

    Noch keine Bücher. Zieh oben eine .epub- oder .pdf-Datei hinein.

    '; + return; + } + + listEl.innerHTML = `

    ${books.length === 1 ? '1 Buch' : books.length + ' Bücher'} auf dem Server. Entschlüssele …

    `; + + let key; + try { + key = await getOrCreateEncKey(); + } catch (e) { + listEl.innerHTML = `

    Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.

    `; + return; + } + + const decrypted = []; + for (const b of books) { + 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 || '', 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 || '', 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 || '', 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) => (_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()).map(_withPendingProgress); + if (cachedBooks.length) { + 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'; + renderBookList(cachedBooks); + const notice = document.createElement('p'); + notice.className = 'muted offline-notice'; + notice.textContent = 'Offline — gespeicherte Bücher. Fortschritt wird beim nächsten Öffnen synchronisiert.'; + listEl.prepend(notice); + } else { + if (listEl) listEl.innerHTML = `

    Fehler beim Laden der Bücher: ${e.message}

    `; + } + } +} + +const _brokenBooks = new Set(); + +function markBookBroken(bookId) { + _brokenBooks.add(bookId); + const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`); + if (!item) return; + if (!item.querySelector('.book-broken-btn')) { + const btn = document.createElement('button'); + btn.className = 'btn btn-sm btn-danger book-broken-btn'; + btn.title = 'Buch konnte nicht geöffnet werden — Datei erneut hochladen'; + btn.textContent = '!'; + btn.onclick = () => repairBook(bookId); + item.querySelector('.book-item-actions').prepend(btn); + } +} + +function _extractIsbnFromOpf(opfDoc) { + const identifiers = opfDoc.querySelectorAll('metadata > identifier, metadata > *|identifier'); + for (const el of identifiers) { + const digits = (el.textContent || '').replace(/[^0-9Xx]/g, ''); + if (digits.length === 10 || digits.length === 13) return digits; + } + return ''; +} + +function repairBook(bookId) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.epub,.pdf'; + input.onchange = async () => { + const file = input.files[0]; + if (!file) return; + const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`); + const btn = item?.querySelector('.book-broken-btn'); + if (btn) btn.textContent = '…'; + try { + const key = await getOrCreateEncKey(); + const buf = await file.arrayBuffer(); + const isPdf = /\.pdf$/i.test(file.name); + const type = isPdf ? 'pdf' : 'epub'; + + let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = ''; + try { + if (isPdf) { + const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; + const meta = await pdfDoc.getMetadata(); + title = meta.info?.Title?.trim() || title; + author = meta.info?.Author?.trim() || ''; + } else { + const zip = await JSZip.loadAsync(buf.slice(0)); + const containerXml = await zip.file('META-INF/container.xml').async('text'); + const opfPath = new DOMParser() + .parseFromString(containerXml, 'application/xml') + .querySelector('rootfile')?.getAttribute('full-path'); + if (opfPath) { + const opfDoc = new DOMParser().parseFromString( + await zip.file(opfPath).async('text'), 'application/xml'); + title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; + author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; + isbn = _extractIsbnFromOpf(opfDoc); + } + } + } catch (e) { /* keep filename as title */ } + + // Preserve folder/shelfTag assigned before this repair — replacing the book's + // data shouldn't silently wipe organization the user already set up. + const prevMeta = bookMetaCache[bookId] || {}; + const metaJson = new TextEncoder().encode(JSON.stringify({ + title, author, filename: file.name, type, + isbn: isbn || prevMeta.isbn || '', + folder: prevMeta.folder || '', + shelfTag: prevMeta.shelfTag || '', + })); + const [metaEnc, dataEnc] = await Promise.all([ + encryptBytes(key, metaJson), + encryptBytes(key, buf), + ]); + const res = await fetch(`/books/${bookId}/replace-data/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({ + data_ct: dataEnc.ciphertext, data_iv: dataEnc.iv, + meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv, + }), + }); + if (!(await res.json()).ok) throw new Error('Serverfehler'); + await _evictCachedBook(bookId); + _brokenBooks.delete(bookId); + if (btn) btn.remove(); + openBook(bookId); + } catch (e) { + if (btn) btn.textContent = '!'; + await customAlert('Reparatur fehlgeschlagen: ' + e.message); + } + }; + input.click(); +} + +let _lastBookListData = []; +const _bookShowReadKey = 'diora_book_show_read'; +let _bookShowRead = localStorage.getItem(_bookShowReadKey) === '1'; + +function _onBookShowReadToggle(checked) { + _bookShowRead = checked; + localStorage.setItem(_bookShowReadKey, checked ? '1' : '0'); + renderBookList(_lastBookListData); +} + +async function toggleBookRead(bookId, currentlyRead) { + const verb = currentlyRead ? 'als ungelesen markieren' : 'als gelesen markieren'; + if (!await customConfirm(`Dieses Buch ${verb}?`)) return; + try { + const res = await fetch(`/books/${bookId}/read/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({is_read: !currentlyRead}), + }); + const data = await res.json(); + if (!data.ok) throw new Error('Serverfehler'); + const book = _lastBookListData.find(b => b.id === bookId); + if (book) book.is_read = data.is_read; + renderBookList(_lastBookListData); + } catch (e) { + await customAlert('Konnte Lesestatus nicht ändern: ' + e.message); + } +} + +// Folder assigned to a book lives inside its encrypted meta blob (like title/author), so the +// server never sees plaintext folder names — no schema change needed, and it rides along for +// free in /api/sync/. One level only: folders don't nest. +let _currentBookFolder = null; // null = root view (folder tiles + unfiled books) + +function _recentlyReadBooks(books) { + return books + .filter(b => b.last_read) + .sort((a, b) => b.last_read.localeCompare(a.last_read)) + .slice(0, 7); +} + +function _renderBookItemHtml(b) { + const pct = Math.round((b.scroll_fraction || 0) * 100); + const keyWarning = b.keyOk === false ? '⚠ falscher Schlüssel' : ''; + const broken = _brokenBooks.has(b.id); + return `
    +
    + ${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' ✓ gelesen' : ''} + ${escapeHtml(b.author)} + + ${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)}` : ''} + +
    +
    + +
    + +
    + ${broken ? `` : ''} + ${b.has_highlights ? `` : ''} + + + + +
    +
    +
    +
    `; +} + +function toggleBookMenu(toggleBtn) { + // Looked up relative to the clicked button (not by id) because the same book — and + // thus the same rendered menu — can appear twice at once: once in the "Zuletzt + // gelesen" section and again further down in the folder/unfiled list. + const menu = toggleBtn.closest('.book-item-menu')?.querySelector('.book-item-menu-list'); + if (!menu) return; + const willOpen = !menu.classList.contains('open'); + document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open')); + if (willOpen) menu.classList.add('open'); +} + +// Close any open book menu on an outside click, or right after an item inside it is +// clicked (the toggle button manages its own open/close state above, so it's excluded). +document.addEventListener('click', (e) => { + if (e.target.closest('.book-menu-toggle')) return; + document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open')); +}); + +function _openBookFolder(name) { + _currentBookFolder = name; + renderBookList(_lastBookListData); +} + +function renderBookList(books) { + const listEl = $('book-list'); + if (!listEl) return; + _lastBookListData = books; + + if (!books.length) { + listEl.innerHTML = ''; + return; + } + + let html = ''; + + // Always-visible "recently read" section, independent of folder navigation and the + // read/unread filter below — it's a shortcut back into whatever was open last. Same + // vertical list-item look as the rest of the list (not a card shelf). + const recent = _recentlyReadBooks(books); + if (recent.length) { + html += '
    ' + + '

    Zuletzt gelesen

    ' + + recent.map(_renderBookItemHtml).join('') + + '
    '; + } + + const visible = _bookShowRead ? books : books.filter(b => !b.is_read); + if (!visible.length) { + html += books.length + ? '

    Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.

    ' + : ''; + listEl.innerHTML = html; + return; + } + + const grouped = new Map(); + for (const b of visible) { + const key = b.folder || ''; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(b); + } + // Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root. + if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) { + _currentBookFolder = null; + } + + if (_currentBookFolder === null) { + const folderNames = [...grouped.keys()].filter(k => k !== '').sort((a, b) => a.localeCompare(b, 'de')); + if (folderNames.length) { + html += '
    ' + + folderNames.map(name => ``).join('') + + '
    '; + } + html += (grouped.get('') || []).map(_renderBookItemHtml).join(''); + } else { + html += `
    + + ${escapeHtml(_currentBookFolder)} +
    `; + html += grouped.get(_currentBookFolder).map(_renderBookItemHtml).join(''); + } + + listEl.innerHTML = html; + listEl.querySelectorAll('.book-folder-tile').forEach(tile => { + tile.addEventListener('click', () => _openBookFolder(tile.dataset.folder)); + }); +} + +// Shared by assignBookFolder/lookupBookMetadata: re-encrypts the meta blob with the given +// field overrides applied on top of whatever is already cached, and pushes it to the server. +async function _updateBookMeta(bookId, overrides) { + const book = _lastBookListData.find(b => b.id === bookId); + if (!book) return false; + const cached = bookMetaCache[bookId] || {}; + const merged = { + title: cached.title || book.title, + author: cached.author || book.author, + filename: cached.filename || '', + type: cached.type || book.type || 'epub', + 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(); + const metaJson = new TextEncoder().encode(JSON.stringify(merged)); + const metaEnc = await encryptBytes(key, metaJson); + const res = await fetch(`/books/${bookId}/meta/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}), + }); + const data = await res.json(); + if (!data.ok) throw new Error('Serverfehler'); + Object.assign(book, overrides); + bookMetaCache[bookId] = merged; + _saveBookMeta(_lastBookListData); + return true; +} + +async function assignBookFolder(bookId) { + const book = _lastBookListData.find(b => b.id === bookId); + if (!book) return; + const folders = [...new Set(_lastBookListData.map(b => b.folder).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de')); + const hint = folders.length ? ` Vorhandene Ordner: ${folders.join(', ')}.` : ''; + const result = await customPrompt(`Ordner für „${book.title}“ (leer = kein Ordner).${hint}`, book.folder || ''); + if (result === null) return; + const folder = result.trim(); + if (folder === (book.folder || '')) return; + + try { + await _updateBookMeta(bookId, {folder}); + if (folder) _currentBookFolder = folder; + renderBookList(_lastBookListData); + } catch (e) { + await customAlert('Konnte Ordner nicht ändern: ' + e.message); + } +} + +// Fallback for books uploaded before ISBN extraction existed (or repaired/re-encrypted +// without it): pulls the already-accessible encrypted book bytes, decrypts and unzips just +// far enough to read the OPF identifiers — same source openBook() itself parses — without +// doing a full reader render. EPUB only; PDFs never carried a reliable ISBN via pdf.js. +async function _extractIsbnFromBookFile(bookId) { + try { + const key = await getOrCreateEncKey(); + let data_ct, data_iv; + const cached = await _getCachedBook(bookId); + if (cached) { + ({data_ct, data_iv} = cached); + } else { + const res = await fetch(`/books/${bookId}/data/`); + ({data_ct, data_iv} = await res.json()); + } + const plain = await decryptBytes(key, data_iv, data_ct); + const zip = await JSZip.loadAsync(plain); + const containerXml = await zip.file('META-INF/container.xml').async('text'); + const opfPath = new DOMParser() + .parseFromString(containerXml, 'application/xml') + .querySelector('rootfile')?.getAttribute('full-path'); + if (!opfPath) return ''; + const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml'); + return _extractIsbnFromOpf(opfDoc); + } catch (e) { + return ''; + } +} + +async function _fetchShelfLookup(isbn, title, author) { + const params = new URLSearchParams(); + if (isbn) params.set('isbn', isbn); + if (title) params.set('title', title); + if (author) params.set('author', author); + const res = await fetch(`/books/metadata-lookup/?${params.toString()}`); + return res.json(); +} + +// Manually triggered (never automatic) — looks up which library shelf/DDC category this +// book falls under via the server-side DNB→Open Library proxy, purely as an organizational +// hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is +// allowed to briefly touch the server with a plaintext ISBN/title/author. +// +// Fallback chain when there's no ISBN (older upload, or a book that never had one — e.g. +// every PDF, since pdf.js doesn't expose one): (1) extract ISBN from the file itself, for +// EPUBs, (2) server-side title/author text search (less precise — could match the wrong +// edition/translation), (3) if that also comes up empty, ask the user to type in an ISBN +// by hand (from the book's cover/an online store) and retry once. +async function lookupBookMetadata(bookId) { + const book = _lastBookListData.find(b => b.id === bookId); + if (!book) return; + const cached = bookMetaCache[bookId] || {}; + let isbn = cached.isbn || book.isbn || ''; + const isPdf = (cached.type || book.type) === 'pdf'; + const title = cached.title || book.title || ''; + const author = cached.author || book.author || ''; + + if (!isbn && !isPdf) { + // Older upload without a stored ISBN — extract it from the book file itself (may take + // a moment for large books, since it downloads/decrypts the full file if not cached). + isbn = await _extractIsbnFromBookFile(bookId); + } + + try { + let data = await _fetchShelfLookup(isbn, title, author); + + if (!data.label) { + const manual = await customPrompt( + 'Keine Regal-Kategorie per ISBN/Titel/Autor gefunden. ISBN manuell eingeben (z. B. vom Buchrücken oder Online-Shop), oder leer lassen zum Abbrechen:', '' + ); + const manualIsbn = (manual || '').replace(/[^0-9Xx]/g, ''); + if (manualIsbn) { + isbn = manualIsbn; + data = await _fetchShelfLookup(isbn, title, author); + } + } + + // Persist whatever we ended up with either way, so a repeat lookup never needs to + // re-extract/re-search from scratch. + const overrides = {}; + if (isbn) overrides.isbn = isbn; + if (data.label) overrides.shelfTag = data.label; + if (Object.keys(overrides).length) { + await _updateBookMeta(bookId, overrides); + renderBookList(_lastBookListData); + } + + if (!data.label) { + await customAlert('Keine Regal-Kategorie gefunden (weder DNB noch Open Library, per ISBN oder Titel/Autor).'); + } + } catch (e) { + await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message); + } +} + +function bookFileSelected(input) { + const file = input.files[0]; + if (!file) return; + uploadEbook(file); + input.value = ''; +} + +function initBookDropZone() { + // Prevent Firefox from opening dragged files when dropped outside the zone + document.addEventListener('dragover', e => e.preventDefault()); + document.addEventListener('drop', e => { + const zone = $('book-drop-zone'); + if (!zone || !zone.contains(e.target)) e.preventDefault(); + }); + + const zone = $('book-drop-zone'); + if (!zone) return; + + zone.addEventListener('dragover', e => { + e.preventDefault(); + zone.classList.add('drag-over'); + }); + zone.addEventListener('dragleave', () => zone.classList.remove('drag-over')); + zone.addEventListener('drop', e => { + e.preventDefault(); + zone.classList.remove('drag-over'); + const file = e.dataTransfer.files[0]; + if (file) uploadEbook(file); + }); +} + +async function deriveAndStoreKey() { + const pwInput = document.getElementById('enc-key-password'); + const statusEl = $('enc-key-status'); + const pw = pwInput ? pwInput.value : ''; + if (!pw) { if (statusEl) statusEl.textContent = 'Bitte gib dein Passwort ein.'; return; } + + if (statusEl) statusEl.textContent = 'Schlüssel wird abgeleitet …'; + try { + const enc = new TextEncoder(); + const username = document.querySelector('meta[name="username"]')?.content || ''; + const mat = await crypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveKey']); + const key = await crypto.subtle.deriveKey( + {name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'}, + mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt'] + ); + const raw = await crypto.subtle.exportKey('raw', key); + const storageKey = `diora_enc_key_${window.USER_ID || 0}`; + localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw))); + _encKey = null; // reset cached key + if (statusEl) statusEl.textContent = '✓ Entsperrt'; + const prompt = $('enc-key-prompt'); + const uploadArea = $('book-upload-area'); + if (prompt) prompt.style.display = 'none'; + if (uploadArea) uploadArea.style.display = ''; + loadBookList(); + } catch (err) { + if (statusEl) statusEl.textContent = 'Fehler: ' + err.message; + } +} + +async function uploadEbook(file) { + const statusEl = $('book-upload-status'); + if (file.size > DIORA_CONFIG.ebookMaxBytes) { + if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`; + return false; + } + return _importEbookBuffer(await file.arrayBuffer(), file.name, statusEl); +} + +// Shared tail of every import path (local file, cloud): parse metadata, encrypt +// with the user's key and POST ciphertext. Plaintext never leaves the browser. +async function _importEbookBuffer(buf, filename, statusEl) { + const isPdf = /\.pdf$/i.test(filename); + const isEpub = /\.epub$/i.test(filename); + if (!isPdf && !isEpub) { + if (statusEl) statusEl.textContent = 'Nur .epub- und .pdf-Dateien werden unterstützt.'; + return false; + } + if (buf.byteLength > DIORA_CONFIG.ebookMaxBytes) { + if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`; + return false; + } + + if (statusEl) statusEl.textContent = 'Verschlüssele …'; + + try { + let title = filename.replace(/\.(epub|pdf)$/i, ''); + let author = ''; + let isbn = ''; + const type = isPdf ? 'pdf' : 'epub'; + + if (isPdf) { + try { + const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise; + const meta = await pdfDoc.getMetadata(); + title = meta.info?.Title?.trim() || title; + author = meta.info?.Author?.trim() || ''; + } catch (e) { /* use filename as title */ } + } else { + try { + const zip = await JSZip.loadAsync(buf.slice(0)); + const containerXml = await zip.file('META-INF/container.xml').async('text'); + const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml'); + const opfPath = containerDoc.querySelector('rootfile')?.getAttribute('full-path'); + if (opfPath) { + const opfText = await zip.file(opfPath).async('text'); + const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml'); + title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; + author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; + isbn = _extractIsbnFromOpf(opfDoc); + } + } catch (e) { /* use filename as title */ } + } + + const key = await getOrCreateEncKey(); + const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename, type, isbn, folder: '', shelfTag: ''})); + const [metaEnc, dataEnc] = await Promise.all([ + encryptBytes(key, metaJson), + encryptBytes(key, buf), + ]); + + if (statusEl) statusEl.textContent = 'Lade hoch …'; + + const res = await fetch('/books/upload/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({ + meta_ct: metaEnc.ciphertext, + meta_iv: metaEnc.iv, + data_ct: dataEnc.ciphertext, + data_iv: dataEnc.iv, + }), + }); + const data = await res.json(); + if (data.ok) { + if (statusEl) statusEl.textContent = `✓ „${title}“ hochgeladen`; + loadBookList(); + return true; + } + if (statusEl) statusEl.textContent = 'Fehler: ' + (data.error ? _germanError(data.error) : 'Hochladen fehlgeschlagen'); + return false; + } catch (e) { + if (statusEl) statusEl.textContent = 'Hochladen fehlgeschlagen: ' + e.message; + return false; + } +} diff --git a/static/js/app/56-cloud-import.js b/static/js/app/56-cloud-import.js new file mode 100644 index 0000000..aa582b9 --- /dev/null +++ b/static/js/app/56-cloud-import.js @@ -0,0 +1,153 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Cloud import — browse a user-configured WebDAV server and pull a book in. +// +// The download is proxied by the server because the remote host is a different +// origin and sends no CORS headers. It still lands here as raw bytes and goes +// through _importEbookBuffer like any local file, so what gets stored is +// ciphertext the server cannot read. +// --------------------------------------------------------------------------- + +let _cloudSources = []; +let _cloudSourceId = null; +let _cloudPath = ''; + +async function toggleCloudImport() { + const panel = $('cloud-browser'); + if (!panel) return; + if (panel.style.display !== 'none') { closeCloudImport(); return; } + + try { + const res = await fetch('/books/cloud/sources/'); + _cloudSources = (await res.json()).sources || []; + } catch (e) { + await customAlert('Cloud-Verbindungen konnten nicht geladen werden: ' + e.message); + return; + } + + if (!_cloudSources.length) { + await customAlert( + 'Noch keine Cloud-Verbindung eingerichtet. Unter Einstellungen → ' + + '„Cloud-Verbindungen für Bücher“ kannst du eine hinzufügen.' + ); + return; + } + + const select = $('cloud-source-select'); + if (select) { + select.innerHTML = ''; + for (const source of _cloudSources) { + const option = document.createElement('option'); + option.value = source.id; + option.textContent = source.label; + select.appendChild(option); + } + select.style.display = _cloudSources.length > 1 ? '' : 'none'; + } + + panel.style.display = ''; + _cloudSourceId = _cloudSources[0].id; + _cloudBrowse(''); +} + +function closeCloudImport() { + const panel = $('cloud-browser'); + if (panel) panel.style.display = 'none'; +} + +function _onCloudSourceChange(value) { + _cloudSourceId = parseInt(value, 10); + _cloudBrowse(''); +} + +function _cloudParentPath(path) { + const parts = path.split('/').filter(Boolean); + parts.pop(); + return parts.join('/'); +} + +function _formatBytes(n) { + if (!n) return ''; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB'; + return (n / 1024 / 1024).toFixed(1) + ' MB'; +} + +function _cloudEntryRow(label, onClick, sizeText) { + const li = document.createElement('li'); + li.className = 'cloud-entry'; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'cloud-entry-name'; + button.textContent = label; + button.addEventListener('click', onClick); + li.appendChild(button); + + if (sizeText) { + const size = document.createElement('span'); + size.className = 'cloud-entry-size'; + size.textContent = sizeText; + li.appendChild(size); + } + return li; +} + +async function _cloudBrowse(path) { + const listEl = $('cloud-entries'); + const pathEl = $('cloud-path'); + const statusEl = $('cloud-status'); + if (!listEl) return; + + if (statusEl) statusEl.textContent = 'Lade…'; + listEl.innerHTML = ''; + + let data; + try { + const res = await fetch(`/books/cloud/${_cloudSourceId}/browse/?path=${encodeURIComponent(path)}`); + data = await res.json(); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + } catch (e) { + if (statusEl) statusEl.textContent = 'Fehler: ' + e.message; + return; + } + + _cloudPath = data.path || ''; + if (pathEl) pathEl.textContent = '/' + _cloudPath; + if (statusEl) statusEl.textContent = ''; + + if (_cloudPath) { + listEl.appendChild(_cloudEntryRow('⬑ ..', () => _cloudBrowse(_cloudParentPath(_cloudPath)))); + } + + const entries = (data.entries || []).filter(e => e.is_dir || e.is_book); + if (!entries.length && statusEl) { + statusEl.textContent = 'Keine Bücher oder Ordner in diesem Verzeichnis.'; + } + + for (const entry of entries) { + listEl.appendChild(entry.is_dir + ? _cloudEntryRow('📁 ' + entry.name, () => _cloudBrowse(entry.path)) + : _cloudEntryRow('📖 ' + entry.name, () => _cloudImport(entry), _formatBytes(entry.size))); + } +} + +async function _cloudImport(entry) { + const statusEl = $('cloud-status'); + if (statusEl) statusEl.textContent = `Lade „${entry.name}“…`; + + try { + const res = await fetch(`/books/cloud/${_cloudSourceId}/fetch/?path=${encodeURIComponent(entry.path)}`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `HTTP ${res.status}`); + } + const buf = await res.arrayBuffer(); + if (await _importEbookBuffer(buf, entry.name, statusEl) && statusEl) { + statusEl.textContent = `✓ „${entry.name}“ importiert`; + } + } catch (e) { + if (statusEl) statusEl.textContent = 'Import fehlgeschlagen: ' + e.message; + } +} diff --git a/static/js/app/58-pdf.js b/static/js/app/58-pdf.js new file mode 100644 index 0000000..61138d9 --- /dev/null +++ b/static/js/app/58-pdf.js @@ -0,0 +1,124 @@ +'use strict'; + +async function _parsePdfOutline(pdf, items, depth) { + depth = depth || 0; + const result = []; + for (const item of items) { + let href = ''; + if (item.dest) { + try { + const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest; + if (dest) { + const pageIndex = await pdf.getPageIndex(dest[0]); + href = `#pdf-page-${pageIndex + 1}`; + } + } catch (e) {} + } + result.push({label: item.title || '(untitled)', href, depth}); + if (item.items && item.items.length) { + result.push(...await _parsePdfOutline(pdf, item.items, depth + 1)); + } + } + return result; +} + +async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { + const myGen = ++_pdfRenderGen; + const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}).promise; + if (_pdfRenderGen !== myGen) return null; + currentPdfDoc = pdf; + + let pdfTitle = '', pdfAuthor = ''; + try { + const meta = await pdf.getMetadata(); + pdfTitle = meta.info?.Title?.trim() || ''; + pdfAuthor = meta.info?.Author?.trim() || ''; + } catch (e) {} + + let toc = []; + try { + const outline = await pdf.getOutline(); + if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline); + } catch (e) {} + + contentEl.innerHTML = ''; + + const pdfVp = document.createElement('div'); + pdfVp.id = 'pdf-viewport'; + contentEl.appendChild(pdfVp); + + const containerWidth = readerSettings.pdfSpread + ? contentEl.clientWidth - 32 + : Math.min(contentEl.clientWidth - 32, 900); + const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth; + const dpr = window.devicePixelRatio || 1; + + // Pass 1: build DOM structure and size all canvases (no pixel rendering yet) + const pageEntries = []; + let spreadContainer = null; + + for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { + if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; } + const page = await pdf.getPage(pageNum); + const naturalVp = page.getViewport({scale: 1}); + const scale = scaleOverride != null ? scaleOverride + : Math.max(0.5, pageWidth / naturalVp.width) * (readerSettings.pdfZoom / 100); + const viewport = page.getViewport({scale}); + + const wrapper = document.createElement('div'); + wrapper.className = 'pdf-page-wrapper'; + wrapper.id = `pdf-page-${pageNum}`; + + const inner = document.createElement('div'); + inner.className = 'pdf-page-inner'; + + const canvas = document.createElement('canvas'); + canvas.className = 'pdf-page'; + canvas.width = Math.round(viewport.width * dpr); + canvas.height = Math.round(viewport.height * dpr); + canvas.style.width = viewport.width + 'px'; + canvas.style.height = viewport.height + 'px'; + inner.appendChild(canvas); + wrapper.appendChild(inner); + + if (!readerSettings.pdfSpread) { + pdfVp.appendChild(wrapper); + } else if (pageNum === 1) { + wrapper.classList.add('pdf-spread-cover'); + pdfVp.appendChild(wrapper); + spreadContainer = null; + } else { + if (pageNum % 2 === 0) { + spreadContainer = document.createElement('div'); + spreadContainer.className = 'pdf-spread-wrapper'; + pdfVp.appendChild(spreadContainer); + } + if (spreadContainer) spreadContainer.appendChild(wrapper); + else pdfVp.appendChild(wrapper); + } + + pageEntries.push({page, canvas, viewport}); + } + + pdfTotalPages = pdf.numPages; + + // Scroll to pivot page immediately — DOM and canvas sizes are set + const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1)); + const pivotEl = document.getElementById(`pdf-page-${pivot}`); + if (pivotEl && pivot > 1) pivotEl.scrollIntoView({block: 'start'}); + + // Pass 2: render pixels from pivot outward (pivot→end, then pivot-1→start) + const order = []; + for (let i = pivot - 1; i < pdf.numPages; i++) order.push(i); + for (let i = pivot - 2; i >= 0; i--) order.push(i); + + for (const idx of order) { + if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; } + const {page, canvas, viewport} = pageEntries[idx]; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + await page.render({canvasContext: ctx, viewport}).promise; + } + + return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; +} diff --git a/static/js/app/60-tts.js b/static/js/app/60-tts.js new file mode 100644 index 0000000..41d49be --- /dev/null +++ b/static/js/app/60-tts.js @@ -0,0 +1,337 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Read-aloud (TTS) — server-side Piper synthesis, one sentence per request. +// EPUB only (no block model for PDFs). Highlights the sentence currently +// playing and scrolls it into view; playback advances sentence-by-sentence, +// then block-by-block, until stopped or the book ends. +// --------------------------------------------------------------------------- +const TTS_MAX_CHARS = 480; // stay under the server's 500-char cap with margin + +let ttsActive = false; +let ttsPaused = false; +let ttsRunToken = 0; +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; + localStorage.setItem('diora_tts_lang', lang); +} + +function _ttsSplitSentences(text) { + const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean); + const pieces = raw.length ? raw : [text]; + const out = []; + for (const piece of pieces) { + let rest = piece; + while (rest.length > TTS_MAX_CHARS) { + let cut = rest.lastIndexOf(' ', TTS_MAX_CHARS); + if (cut <= 0) cut = TTS_MAX_CHARS; + out.push(rest.slice(0, cut).trim()); + rest = rest.slice(cut).trim(); + } + if (rest) out.push(rest); + } + return out; +} + +async function _ttsFetchAudio(sentence) { + const resp = await fetch('/tts/synthesize/', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({text: sentence, lang: ttsLang}), + }); + if (!resp.ok) throw new Error('tts request failed'); + return await resp.blob(); +} + +function _ttsClearHighlight() { + if (ttsCurrentMark && ttsCurrentMark.parentNode) { + const parent = ttsCurrentMark.parentNode; + while (ttsCurrentMark.firstChild) parent.insertBefore(ttsCurrentMark.firstChild, ttsCurrentMark); + parent.removeChild(ttsCurrentMark); + parent.normalize(); + } + ttsCurrentMark = null; +} + +// Finds `sentence` as a substring of block.textContent starting at fromOffset +// (so repeated sentence text earlier in the block isn't matched again), turns +// 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); + if (idx === -1) return fromOffset; + const start = _nodeAtCharOffset(block, idx); + const end = _nodeAtCharOffset(block, idx + sentence.length); + if (!start || !end) return idx + sentence.length; + try { + const range = document.createRange(); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + const mark = document.createElement('mark'); + mark.className = 'tts-current'; + range.surroundContents(mark); + ttsCurrentMark = mark; + _ttsScrollIntoView(mark); + } catch (e) {} + return idx + sentence.length; +} + +function _ttsScrollIntoView(el) { + const contentEl = $('reader-content'); + if (!contentEl) return; + const top = el.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + if (top < 40 || top > contentEl.clientHeight - 80) { + _suppressScrollJumpDetect(); + contentEl.scrollBy({top: top - contentEl.clientHeight * 0.3, behavior: 'smooth'}); + } +} + +function _ttsPlayBlob(blob) { + return new Promise((resolve) => { + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + ttsAudio = audio; + const done = () => { URL.revokeObjectURL(url); resolve(); }; + audio.addEventListener('ended', done); + audio.addEventListener('error', done); + if (!ttsPaused) audio.play().catch(done); + }); +} + +// 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; + const sentences = _ttsSplitSentences(text); + for (let si = bi === startBlockIndex ? startSentence : 0; si < sentences.length; si++) { + yield {block, sentence: sentences[si], bi, si}; + } + } + } + + const iter = sentenceStream(); + let cur = iter.next(); + if (cur.done) { stopReadAloud(); return; } + let curFetch = _ttsFetchAudio(cur.value.sentence); + let lastBlock = null; + let blockSearchOffset = 0; + + while (!cur.done) { + if (runToken !== ttsRunToken) return; + const {block, sentence} = cur.value; + const next = iter.next(); + const nextFetch = next.done ? null : _ttsFetchAudio(next.value.sentence).catch(() => null); + + let audioBlob; + try { + audioBlob = await curFetch; + } catch (e) { + stopReadAloud(); + return; + } + if (runToken !== ttsRunToken) return; + + 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; + + cur = next; + curFetch = nextFetch; + } + if (runToken === ttsRunToken) stopReadAloud(); +} + +function _ttsTogglePause() { + ttsPaused = !ttsPaused; + if (ttsAudio) { + if (ttsPaused) ttsAudio.pause(); + 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'); + if (btn) btn.textContent = ttsPaused ? '▶' : '⏸'; +} + +function _ttsShowBar() { + _ttsRemoveBar(); + if (window.__dioraTtsDock) return; // the player bar shows it + const bar = document.createElement('div'); + bar.className = 'tts-bar'; + bar.innerHTML = ` + + + `; + document.body.appendChild(bar); + ttsBarEl = bar; + bar.querySelector('.tts-pause-btn').addEventListener('click', _ttsTogglePause); + bar.querySelector('.tts-stop-btn').addEventListener('click', stopReadAloud); +} + +function _ttsRemoveBar() { + if (ttsBarEl) { ttsBarEl.remove(); ttsBarEl = null; } +} + +function toggleReadAloud() { + if (ttsActive) stopReadAloud(); + else startReadAloud(); +} + +function startReadAloud() { + if (ttsActive) return; + if (currentPdfDoc) { + const toast = document.createElement('div'); + toast.className = 'reader-toast'; + toast.textContent = 'Vorlesen ist aktuell nur für EPUB-Bücher verfügbar.'; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2200); + return; + } + const contentEl = $('reader-content'); + if (!contentEl) return; + const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); + if (!blocks.length) return; + + const [anchorBlock] = _anchorParts(getPositionAnchor(contentEl)); + const startIndex = (anchorBlock >= 0 && anchorBlock < blocks.length) ? anchorBlock : 0; + + ttsActive = true; + 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); +} + +function stopReadAloud() { + ttsRunToken++; + ttsActive = false; + ttsPaused = false; + if (ttsAudio) { + try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {} + ttsAudio = null; + } + _ttsClearHighlight(); + _ttsRemoveBar(); + _ttsBlocks = null; + _ttsCur = null; + const btn = $('reader-tts-btn'); + if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; } + _ttsNotify(); +} diff --git a/static/js/app/62-reader.js b/static/js/app/62-reader.js new file mode 100644 index 0000000..a78aecd --- /dev/null +++ b/static/js/app/62-reader.js @@ -0,0 +1,641 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Immersive reader mode — tap centre of screen to toggle bars +// --------------------------------------------------------------------------- +let _immBarsVisible = true; + +function _immHandleTap(e) { + // Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover) + if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet, .tts-bar')) return; + // In marker mode, taps have a dedicated meaning (highlight/create a note) — + // don't also toggle the immersive bars underneath. + if (markerModeActive) return; + // Don't toggle bars while the user has just finished selecting text (e.g. to highlight it) + const sel = window.getSelection(); + if (sel && !sel.isCollapsed) return; + // If a footnote popover is open, dismiss it instead of toggling bars + if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) { + dismissFootnotePopover(); + return; + } + _immBarsVisible = !_immBarsVisible; + document.body.classList.toggle('reader-immersive', !_immBarsVisible); + // Close settings panel when bars disappear + if (!_immBarsVisible && readerSettingsPanelOpen) { + const sp = document.getElementById('reader-settings-panel'); + if (sp) sp.remove(); + readerSettingsPanelOpen = false; + } +} + +function enterReaderImmersiveMode() { + _immBarsVisible = true; + document.body.classList.remove('reader-immersive'); + document.addEventListener('click', _immHandleTap); +} + +function exitReaderImmersiveMode() { + _immBarsVisible = true; + document.body.classList.remove('reader-immersive'); + 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; + +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) { + // 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'); + if (!overlay || !contentEl) return; + + titleEl.textContent = 'Lade …'; + contentEl.innerHTML = ''; + overlay.style.display = ''; + + try { + loadReaderSettings(bookId); + _charIndex = null; + const key = await getOrCreateEncKey(); + let data_ct, data_iv; + const cached = await _getCachedBook(bookId); + if (cached) { + ({data_ct, data_iv} = cached); + } else { + let res; + try { + res = await fetch(`/books/${bookId}/data/`); + } catch { + // offline and book not cached yet + overlay.style.display = 'none'; + const toast = document.createElement('div'); + toast.className = 'reader-toast'; + toast.textContent = 'Buch nicht offline verfügbar — einmal online öffnen zum Cachen.'; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 3500); + return; + } + ({data_ct, data_iv} = await res.json()); + _setCachedBook(bookId, data_ct, data_iv); // fire-and-forget + } + const plain = await decryptBytes(key, data_iv, data_ct); + + // Revoke any previous image blob URLs + for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); + currentImageMap = {}; + + const cachedMeta = bookMetaCache[bookId] || {}; + let title = cachedMeta.title || ''; + let author = cachedMeta.author || ''; + let toc = []; + let numPages = 0; + const isPdfBook = cachedMeta.type === 'pdf'; + + if (isPdfBook) { + currentPdfDoc = null; // reset so renderPdf creates fresh doc + const loadingEl = document.createElement('div'); + loadingEl.className = 'pdf-loading-overlay'; + loadingEl.innerHTML = ''; + overlay.appendChild(loadingEl); + const result = await renderPdf(plain, contentEl); + loadingEl.remove(); + title = result.title || title; + author = result.author || author; + toc = result.toc; + numPages = result.numPages; + currentPdfBuffer = plain; + } else { + currentPdfBuffer = null; + // 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: + // 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 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); + 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(). + _currentBookCharCount = contentEl.textContent.length; + } + + currentBookToc = toc; + titleEl.textContent = title + (author ? ` — ${author}` : ''); + + 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), + loadHighlights(bookId), + ]); + + // Apply reader settings (theme, font size, etc.) + applyReaderSettings(isPdfBook); + + // If this book already has notes/highlights, surface them right away + // instead of making the reader hide them behind a manual toggle. + // Not on phones: there the margin covers the text; it stays one tap away (✎). + if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen && !_isMobileReader()) { + toggleAnnotationsMargin(); + } + + // Touch: swipe (paginated) + pinch-to-zoom + contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true}); + contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false}); + contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true}); + contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true}); + + // Set up progress input + const progressInput = $('reader-progress-input'); + const progressSuffix = $('reader-progress-suffix'); + const isPdf = isPdfBook; + + if (progressInput) { + progressInput.style.display = ''; + if (isPdf) { + progressInput.value = 1; + if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`; + } else { + progressInput.value = _formatBookPercent(0); + if (progressSuffix) progressSuffix.textContent = '%'; + } + + progressInput.addEventListener('change', function () { + _suppressScrollJumpDetect(); + if (isPdf) { + const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1)); + this.value = page; + const target = contentEl.querySelector(`#pdf-page-${page}`); + if (target) { + const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 8, behavior: 'smooth'}); + } + } else { + const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value))); + this.value = _formatBookPercent(pct / 100); + restoreFromAnchor(contentEl, _anchorForFraction(contentEl, pct / 100)); + } + }); + + progressInput.addEventListener('click', function () { this.select(); }); + } + + // 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/', {cache: 'no-store'}); + _noteServerNowHeader(progressRes); + const allBooks = await progressRes.json(); + bookData = allBooks.find(b => b.id === bookId) || null; + } catch { + // offline — use cached meta + const cached = await _loadCachedBookMeta(); + 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) + pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); + enterPdfPaginatedMode(); + } else if (isPdf) { + // PDF scroll mode + await new Promise(r => requestAnimationFrame(r)); + if (fraction > 0) + contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); + } else { + // EPUB — wait for images so scrollHeight is final + const imgs = Array.from(contentEl.querySelectorAll('img')); + if (imgs.length) { + await Promise.all(imgs.map(img => + img.complete ? Promise.resolve() + : new Promise(r => { img.onload = r; img.onerror = r; }) + )); + } + await new Promise(r => requestAnimationFrame(r)); + if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) { + restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction)); + } + } + } 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. + _scrollRestTop = contentEl.scrollTop; + + // Update progress input on scroll + contentEl.addEventListener('scroll', () => { + if (!progressInput) return; + if (isPdf) { + const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); + const cTop = contentEl.getBoundingClientRect().top; + let currentPage = 1; + for (const w of wrappers) { + if (w.getBoundingClientRect().bottom > cTop + 20) { + currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1; + break; + } + } + progressInput.value = currentPage; + } + // EPUB: shown from the position anchor, see the anchor tracking below. + }); + + // Auto-save progress every 10s and on scroll (debounced 2s) + // Started AFTER restore so an early visibilitychange can't overwrite with position 0 + readerScrollSaveTimer = setInterval(saveReaderProgress, 10000); + let _scrollDebounce = null; + contentEl.addEventListener('scroll', () => { + clearTimeout(_scrollDebounce); + _scrollDebounce = setTimeout(saveReaderProgress, 2000); + }, {passive: true}); + + // Keep _currentPositionAnchor fresh independent of the slower save debounce + // above: the resize observer below (or an immersive-bars toggle right after + // scrolling) would otherwise restore to a stale, pre-scroll anchor. + if (!isPdf) { + let _anchorTrackDebounce = null; + contentEl.addEventListener('scroll', () => { + clearTimeout(_anchorTrackDebounce); + _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); + } + }, 150); + }, {passive: true}); + } + + // Restore anchor on viewport resize (e.g. screen rotation, font zoom) + if (!isPdf) { + _resizeObserver = new ResizeObserver(() => { + if (_currentPositionAnchor) { + _suppressScrollJumpDetect(); + requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor)); + } + _repositionMarginMarkers(); + }); + _resizeObserver.observe(contentEl); + } + + // Offer to jump back after a sudden, large scroll (accidental fling, + // fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above. + _scrollRestTop = null; + let _scrollJumpRaf = null; + contentEl.addEventListener('scroll', () => { + if (_scrollJumpRaf) return; + _scrollJumpRaf = requestAnimationFrame(() => { + _scrollJumpRaf = null; + _checkScrollJump(contentEl); + _repositionMarginMarkers(); + }); + }, {passive: true}); + + enterReaderImmersiveMode(); + + } catch (e) { + console.error('openBook failed:', e); + overlay.style.display = 'none'; + markBookBroken(bookId); + } +} + +async function exportEncKey() { + const statusEl = $('book-key-status'); + try { + const key = await getOrCreateEncKey(); + const raw = await crypto.subtle.exportKey('raw', key); + const b64 = bytesToBase64(raw); + await navigator.clipboard.writeText(b64); + if (statusEl) statusEl.textContent = '✓ Schlüssel in die Zwischenablage kopiert'; + setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000); + } catch (e) { + if (statusEl) statusEl.textContent = 'Export fehlgeschlagen: ' + e.message; + } +} + +function showImportKey() { + const body = $('sidebar-body'); + openSidebar('Schlüssel importieren', ` +

    Füge den Schlüssel ein, den du im anderen Browser exportiert hast:

    + + +

    Das ersetzt den Schlüssel in diesem Browser. Hier hochgeladene Bücher sind erst wieder lesbar, wenn du den Schlüssel zurück abgleichst.

    + `); + body.addEventListener('click', async function _importClick(e) { + if (!e.target.closest('[data-import-key-apply]')) return; + body.removeEventListener('click', _importClick); + const b64 = (body.querySelector('#import-key-input')?.value || '').trim(); + const statusEl = $('book-key-status'); + try { + const raw = base64ToBytes(b64); + const importedKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); + const re_exported = await crypto.subtle.exportKey('raw', importedKey); + localStorage.setItem(`diora_enc_key_${window.USER_ID}`, bytesToBase64(re_exported)); + closeSidebar(); + if (statusEl) statusEl.textContent = '✓ Schlüssel importiert – Bücher werden neu geladen …'; + await loadBookList(); + if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000); + } catch (e) { + if ($('book-key-status')) $('book-key-status').textContent = 'Import fehlgeschlagen: ungültiger Schlüssel'; + } + }); +} + +async function deleteBook(bookId) { + if (!await customConfirm('Dieses Buch löschen? Das lässt sich nicht rückgängig machen.', { danger: true })) return; + try { + const res = await fetch(`/books/${bookId}/delete/`, { + method: 'POST', + headers: {'X-CSRFToken': getCsrfToken()}, + }); + const data = await res.json(); + if (data.ok) loadBookList(); + } 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'); + if (!contentEl) return; + + let fraction; + if (readerSettings.pdfPaginated && currentPdfDoc && pdfTotalPages > 1) { + fraction = (pdfCurrentPage - 1) / (pdfTotalPages - 1); + } else { + 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; + } + + // 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); + _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)); + + _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 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() { + exitReaderImmersiveMode(); + 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(); + + const overlay = $('reader-overlay'); + if (overlay) overlay.style.display = 'none'; + if (readerScrollSaveTimer) { + clearInterval(readerScrollSaveTimer); + readerScrollSaveTimer = null; + } + if (_resizeObserver) { + _resizeObserver.disconnect(); + _resizeObserver = null; + } + _currentPositionAnchor = ''; + + // Close the margin panel + readerAnnotationsMarginOpen = false; + const marginEl = $('reader-margin'); + if (marginEl) marginEl.classList.remove('open'); + const marginMarkersEl = $('reader-margin-markers'); + if (marginMarkersEl) marginMarkersEl.innerHTML = ''; + + // Disarm marker mode + markerModeActive = false; + document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.remove('active')); + dismissNoteBottomSheet(); + + // Clear search before wiping content + clearReaderSearch(); + + // Close settings panel if open + readerSettingsPanelOpen = false; + const sp = document.getElementById('reader-settings-panel'); + if (sp) sp.remove(); + + // Reset progress input + const progressInput = $('reader-progress-input'); + if (progressInput) { progressInput.style.display = 'none'; progressInput.value = 0; } + const progressSuffix = $('reader-progress-suffix'); + if (progressSuffix) progressSuffix.textContent = ''; + + // Remove touch handlers and free image blob URLs + const contentEl = $('reader-content'); + if (contentEl) { + contentEl.removeEventListener('touchstart', _pdfTouchStart); + contentEl.removeEventListener('touchmove', _pdfTouchMove); + contentEl.removeEventListener('touchend', _pdfTouchEnd); + contentEl.removeEventListener('touchcancel', _pdfTouchEnd); + contentEl.classList.remove('pinch-active'); + contentEl.innerHTML = ''; + } + for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url); + currentImageMap = {}; + + // Reset all state + currentBookId = null; + currentBookToc = []; + _currentBookCharCount = 0; + currentPdfDoc = null; + currentPdfBuffer = null; + currentBookmarks = []; + bookmarksDirty = false; + currentHighlights = []; + highlightsDirty = false; + _lastProgressBeacon = null; + _lastBookmarkBeacon = null; + _lastHighlightBeacon = null; + dismissHighlightPopover(); + pdfCurrentPage = 1; + pdfTotalPages = 0; + _pdfPageTextBoxCache = {}; + + // Remove PDF invert class + if (overlay) overlay.classList.remove('pdf-inverted'); + +} + +function openTocSidebar() { + if (!currentBookToc.length) { + openSidebar('Inhaltsverzeichnis', '

    Dieses Buch hat kein Inhaltsverzeichnis.

    '); + return; + } + let html = '
      '; + for (const entry of currentBookToc) { + const indent = entry.depth * 14; + // Use data-toc-href — onclick would be stripped by sanitizeSidebarHtml + html += `
    • + +
    • `; + } + html += '
    '; + openSidebar('Inhaltsverzeichnis', html); + // Attach delegated listener after sidebar body is populated + const body = $('sidebar-body'); + body.addEventListener('click', function _tocClick(e) { + const btn = e.target.closest('.toc-entry'); + if (btn) { + body.removeEventListener('click', _tocClick); + jumpToTocEntry(btn.getAttribute('data-toc-href') || ''); + } + }); +} + +function jumpToTocEntry(href) { + closeSidebar(); + _suppressScrollJumpDetect(); + setTimeout(() => { + const contentEl = $('reader-content'); + if (!contentEl) return; + + // PDF page jump + if (href.startsWith('#pdf-page-')) { + const target = contentEl.querySelector(href); + if (target) { + const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); + } + return; + } + + const hashIdx = href.indexOf('#'); + const fragment = hashIdx >= 0 ? href.slice(hashIdx + 1) : ''; + const filePath = hashIdx >= 0 ? href.slice(0, hashIdx) : href; + + let target = null; + if (fragment) { + target = contentEl.querySelector(`#${CSS.escape(fragment)}`); + } + if (!target && filePath) { + target = Array.from(contentEl.querySelectorAll('[data-epub-src]')) + .find(el => el.getAttribute('data-epub-src') === filePath) || null; + } + if (target) { + const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); + } + }, 50); +} diff --git a/static/js/app/64-reader-settings.js b/static/js/app/64-reader-settings.js new file mode 100644 index 0000000..f9c604e --- /dev/null +++ b/static/js/app/64-reader-settings.js @@ -0,0 +1,327 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Reader Settings +// --------------------------------------------------------------------------- + +// Typography defaults. Phones keep the long-standing ones; a wide screen +// (desktop browser, diora desktop) starts bigger and wider. +const _READER_TYPE_DEFAULTS_NARROW = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, fontWeight: null }; +const _READER_TYPE_DEFAULTS_WIDE = { fontSize: 20, lineHeight: 1.6, maxWidth: 90, fontWeight: 375 }; +const _READER_WIDTH_MAX = 140; // ch, the width slider's upper end ("Full" stays separate) + +function _readerTypeDefaults() { + return window.matchMedia('(min-width: 900px)').matches + ? _READER_TYPE_DEFAULTS_WIDE : _READER_TYPE_DEFAULTS_NARROW; +} + +function loadReaderSettings(bookId) { + // Reset to defaults, then apply per-book overrides + Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false, + pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false }, + _readerTypeDefaults()); + try { + const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}'); + // Settings saved before v2 hold every value, changed or not. A value equal + // to the old default was never chosen, so it gives way to today's default. + if (!saved.v) { + for (const [key, old] of Object.entries(_READER_TYPE_DEFAULTS_NARROW)) { + 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) { + readerSettings.pdfPaginated = window.innerWidth < 768; + } + } catch (e) {} +} + +function saveReaderSettings() { + if (currentBookId) { + localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 3 })); + } +} + +function applyReaderSettings(isPdf) { + const overlay = $('reader-overlay'); + const contentEl = $('reader-content'); + if (!overlay || !contentEl) return; + + if (!isPdf) { + contentEl.style.fontSize = readerSettings.fontSize + 'px'; + contentEl.style.lineHeight = readerSettings.lineHeight; + contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch'); + const fontMap = { + serif: "'Source Serif 4', Georgia, 'Times New Roman', serif", + sans: 'system-ui, -apple-system, sans-serif', + humanist: 'Verdana, Geneva, sans-serif', + mono: "'Courier New', monospace", + }; + 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)); + } + } + + // Theme + overlay.classList.remove('reader-theme-sepia', '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'); + else overlay.classList.remove('pdf-inverted'); + + // Annotations (marker mode + margin panel) are EPUB-only for now — PDFs have no text layer yet + const marginBtn = $('reader-margin-btn'); + if (marginBtn) marginBtn.style.display = isPdf ? 'none' : ''; + if (isPdf && readerAnnotationsMarginOpen) toggleAnnotationsMargin(); + document.querySelectorAll('.marker-mode-btn').forEach(b => { b.style.display = isPdf ? 'none' : ''; }); + 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'); + if (!overlay || !contentEl) return; + + const existing = document.getElementById('reader-settings-panel'); + if (existing) { + existing.remove(); + readerSettingsPanelOpen = false; + return; + } + + readerSettingsPanelOpen = true; + const isPdf = !!currentPdfDoc; + + const panel = document.createElement('div'); + panel.id = 'reader-settings-panel'; + panel.className = 'reader-settings-panel'; + + if (!isPdf) { + panel.innerHTML = ` + + + + + + + + + + + + + + + `; + } else { + panel.innerHTML = ` + + + + `; + } + + overlay.insertBefore(panel, $('reader-body-row')); + + if (!isPdf) { + const fontRange = panel.querySelector('#rs-font'); + const fontVal = panel.querySelector('#rs-font-val'); + fontRange.addEventListener('input', () => { + readerSettings.fontSize = parseInt(fontRange.value, 10); + fontVal.textContent = readerSettings.fontSize + 'px'; + applyReaderSettings(false); + saveReaderSettings(); + }); + panel.querySelector('#rs-font-minus').addEventListener('click', () => { + fontRange.value = Math.max(+fontRange.min, parseInt(fontRange.value, 10) - 1); + fontRange.dispatchEvent(new Event('input')); + }); + panel.querySelector('#rs-font-plus').addEventListener('click', () => { + fontRange.value = Math.min(+fontRange.max, parseInt(fontRange.value, 10) + 1); + fontRange.dispatchEvent(new Event('input')); + }); + + const lineRange = panel.querySelector('#rs-line'); + const lineVal = panel.querySelector('#rs-line-val'); + lineRange.addEventListener('input', () => { + readerSettings.lineHeight = (parseInt(lineRange.value, 10) / 10).toFixed(1); + lineVal.textContent = readerSettings.lineHeight; + applyReaderSettings(false); + saveReaderSettings(); + }); + panel.querySelector('#rs-line-minus').addEventListener('click', () => { + lineRange.value = Math.max(+lineRange.min, parseInt(lineRange.value, 10) - 1); + lineRange.dispatchEvent(new Event('input')); + }); + panel.querySelector('#rs-line-plus').addEventListener('click', () => { + lineRange.value = Math.min(+lineRange.max, parseInt(lineRange.value, 10) + 1); + lineRange.dispatchEvent(new Event('input')); + }); + + const widthRange = panel.querySelector('#rs-width'); + const widthVal = panel.querySelector('#rs-width-val'); + widthRange.addEventListener('input', () => { + readerSettings.maxWidth = parseInt(widthRange.value, 10); + widthVal.textContent = readerSettings.maxWidth + 'ch'; + applyReaderSettings(false); + saveReaderSettings(); + }); + panel.querySelector('#rs-width-minus').addEventListener('click', () => { + widthRange.value = Math.max(+widthRange.min, parseInt(widthRange.value, 10) - 5); + widthRange.dispatchEvent(new Event('input')); + }); + panel.querySelector('#rs-width-plus').addEventListener('click', () => { + widthRange.value = Math.min(+widthRange.max, parseInt(widthRange.value, 10) + 5); + widthRange.dispatchEvent(new Event('input')); + }); + + panel.querySelector('#rs-width-full').addEventListener('click', () => { + readerSettings.maxWidth = 999; + widthRange.value = 90; + widthVal.textContent = 'full'; + applyReaderSettings(false); + 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(); + }); + }); + + panel.querySelector('#rs-no-bold').addEventListener('click', function () { + readerSettings.noBold = !readerSettings.noBold; + this.classList.toggle('active', readerSettings.noBold); + applyReaderSettings(false); + saveReaderSettings(); + }); + + panel.querySelectorAll('[data-rs-theme]').forEach(btn => { + btn.addEventListener('click', () => { + readerSettings.theme = btn.dataset.rsTheme; + panel.querySelectorAll('[data-rs-theme]').forEach(b => b.classList.toggle('active', b === btn)); + applyReaderSettings(false); + saveReaderSettings(); + }); + }); + } else { + const zoomRange = panel.querySelector('#rs-zoom'); + + zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10))); + panel.querySelector('#rs-zoom-minus').addEventListener('click', () => + applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10))); + panel.querySelector('#rs-zoom-plus').addEventListener('click', () => + applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10))); + + panel.querySelector('#rs-invert').addEventListener('click', function () { + readerSettings.pdfInverted = !readerSettings.pdfInverted; + this.classList.toggle('active', readerSettings.pdfInverted); + applyReaderSettings(true); + saveReaderSettings(); + }); + + panel.querySelector('#rs-spread').addEventListener('click', function () { + readerSettings.pdfSpread = !readerSettings.pdfSpread; + this.classList.toggle('active', readerSettings.pdfSpread); + saveReaderSettings(); + reRenderPdf(); + }); + + } +} + +async function applyPdfZoom(newZoom) { + readerSettings.pdfZoom = newZoom; + const zoomRange = document.getElementById('rs-zoom'); + const zoomVal = document.getElementById('rs-zoom-val'); + if (zoomRange) zoomRange.value = newZoom; + if (zoomVal) zoomVal.textContent = newZoom + '%'; + saveReaderSettings(); + if (readerSettings.pdfPaginated) { + pdfSmartZoomPage(pdfCurrentPage); + } else { + await reRenderPdf(); + } +} + +function _currentScrollPage(contentEl) { + const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); + const viewTop = contentEl.scrollTop; + for (const w of wrappers) { + if (w.offsetTop + w.offsetHeight > viewTop) { + const n = parseInt(w.id.replace('pdf-page-', ''), 10); + if (n) return n; + } + } + return pdfCurrentPage || 1; +} + +async function reRenderPdf() { + if (!currentPdfBuffer) return; + const contentEl = $('reader-content'); + if (!contentEl) return; + const pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _currentScrollPage(contentEl); + await renderPdf(currentPdfBuffer, contentEl, undefined, pivot); + if (readerSettings.pdfPaginated) enterPdfPaginatedMode(); +} diff --git a/static/js/app/66-pdf-paginated.js b/static/js/app/66-pdf-paginated.js new file mode 100644 index 0000000..acfd020 --- /dev/null +++ b/static/js/app/66-pdf-paginated.js @@ -0,0 +1,212 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// PDF Paginated Mode +// --------------------------------------------------------------------------- + +function enterPdfPaginatedMode() { + const contentEl = $('reader-content'); + if (!contentEl) return; + contentEl.classList.add('pdf-paginated'); + contentEl.style.overflow = 'hidden'; + const pdfVp = document.getElementById('pdf-viewport'); + if (pdfVp) pdfVp.style.zoom = 1; + + const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper'); + wrappers.forEach((w, i) => { + w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none'; + }); + + pdfSmartZoomPage(pdfCurrentPage); + + // Tap left/right to navigate + contentEl.addEventListener('click', _pdfPaginatedClick); +} + +function exitPdfPaginatedMode() { + const contentEl = $('reader-content'); + if (!contentEl) return; + contentEl.classList.remove('pdf-paginated'); + contentEl.style.overflow = ''; + contentEl.removeEventListener('click', _pdfPaginatedClick); + reRenderPdf(); +} + +function _pdfPaginatedClick(e) { + const w = e.currentTarget.clientWidth; + if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1); + else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1); +} + +function _pdfTouchStart(e) { + if (e.touches.length === 2) { + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + _pinchStartDist = Math.hypot(dx, dy); + _pinchStartZoom = readerSettings.pdfZoom; + _isPinching = true; + const ce = $('reader-content'); + if (ce) ce.classList.add('pinch-active'); + } else { + _touchStartX = e.touches[0].clientX; + _isPinching = false; + } +} + +function _pdfTouchMove(e) { + if (e.touches.length !== 2 || !_isPinching) return; + e.preventDefault(); + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const dist = Math.hypot(dx, dy); + if (_pinchStartDist === 0) return; + const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist))); + const vp = document.getElementById('pdf-viewport'); + if (vp) vp.style.zoom = liveZoom / 100; +} + +function _pdfTouchEnd(e) { + if (_isPinching) { + _isPinching = false; + const ce = $('reader-content'); + if (ce) ce.classList.remove('pinch-active'); + const vp = document.getElementById('pdf-viewport'); + const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom; + const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10)); + applyPdfZoom(snapped); + return; + } + if (!readerSettings.pdfPaginated) return; + const delta = e.changedTouches[0].clientX - _touchStartX; + if (delta > 50) pdfGoToPage(pdfCurrentPage - 1); + else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1); +} + +function pdfGoToPage(n) { + if (!currentPdfDoc) return; + n = Math.max(1, Math.min(pdfTotalPages, n)); + if (n === pdfCurrentPage) return; + const contentEl = $('reader-content'); + if (!contentEl) return; + + const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); + if (oldWrapper) oldWrapper.style.display = 'none'; + + pdfCurrentPage = n; + const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`); + if (newWrapper) newWrapper.style.display = ''; + + pdfSmartZoomPage(pdfCurrentPage); + + const progressInput = $('reader-progress-input'); + if (progressInput) progressInput.value = pdfCurrentPage; +} + +async function pdfSmartZoomPage(pageNum) { + if (!currentPdfDoc) return; + const contentEl = $('reader-content'); + if (!contentEl) return; + const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`); + if (!wrapper) return; + const canvas = wrapper.querySelector('canvas'); + if (!canvas) return; + + const page = await currentPdfDoc.getPage(pageNum); + const naturalVp = page.getViewport({scale: 1}); + const pageW = naturalVp.width; + const pageH = naturalVp.height; + + let bbox = _pdfPageTextBoxCache[pageNum]; + if (!bbox) { + bbox = await _computePdfTextBox(page, pageW, pageH); + _pdfPageTextBoxCache[pageNum] = bbox; + } + + const containerW = contentEl.clientWidth; + const containerH = contentEl.clientHeight; + const contentW = bbox.x2 - bbox.x1; + const contentH = bbox.y2 - bbox.y1; + const pad = 12; + const scale = Math.min( + (containerW - pad * 2) / contentW, + (containerH - pad * 2) / contentH + ) * (readerSettings.pdfZoom / 100); + + // Re-render canvas at new scale if significantly different + const currentScale = canvas.width / naturalVp.width; + if (Math.abs(scale - currentScale) / currentScale > 0.05) { + const vp = page.getViewport({scale}); + canvas.width = vp.width; + canvas.height = vp.height; + await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise; + } + + // Position canvas to center the text bounding box + const renderedScale = canvas.width / naturalVp.width; + const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2; + // PDF y-axis is bottom-up; canvas is top-down + const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2; + canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`; + wrapper.style.overflow = 'hidden'; + wrapper.style.width = containerW + 'px'; + wrapper.style.height = containerH + 'px'; +} + +async function _computePdfTextBox(page, pageW, pageH) { + // Tier 1: text-based + try { + const tc = await page.getTextContent(); + if (tc.items && tc.items.length) { + let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity; + for (const item of tc.items) { + if (!item.transform) continue; + const tx = item.transform[4], ty = item.transform[5]; + const iw = item.width || 0, ih = item.height || 0; + if (tx < x1) x1 = tx; + if (ty < y1) y1 = ty; + if (tx + iw > x2) x2 = tx + iw; + if (ty + ih > y2) y2 = ty + ih; + } + const area = (x2 - x1) * (y2 - y1); + if (isFinite(x1) && area > pageW * pageH * 0.25) { + return {x1, y1, x2, y2}; + } + } + } catch (e) {} + + // Tier 2: pixel analysis at scale 0.3 + try { + const lowScale = 0.3; + const vp = page.getViewport({scale: lowScale}); + const offCanvas = document.createElement('canvas'); + offCanvas.width = vp.width; + offCanvas.height = vp.height; + const ctx = offCanvas.getContext('2d'); + await page.render({canvasContext: ctx, viewport: vp}).promise; + const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height); + + let rMin = height, rMax = 0, cMin = width, cMax = 0; + for (let r = 0; r < height; r++) { + for (let c = 0; c < width; c++) { + const idx = (r * width + c) * 4; + if (data[idx] + data[idx+1] + data[idx+2] < 720) { + if (r < rMin) rMin = r; + if (r > rMax) rMax = r; + if (c < cMin) cMin = c; + if (c > cMax) cMax = c; + } + } + } + if (rMin < rMax && cMin < cMax) { + return { + x1: cMin / lowScale, + y1: (height - rMax) / lowScale, + x2: cMax / lowScale, + y2: (height - rMin) / lowScale, + }; + } + } catch (e) {} + + // Fallback: full page + return {x1: 0, y1: 0, x2: pageW, y2: pageH}; +} diff --git a/static/js/app/68-bookmarks.js b/static/js/app/68-bookmarks.js new file mode 100644 index 0000000..783b369 --- /dev/null +++ b/static/js/app/68-bookmarks.js @@ -0,0 +1,166 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Bookmarks +// --------------------------------------------------------------------------- + +async function loadBookmarks(bookId) { + try { + const res = await fetch(`/books/${bookId}/bookmarks/`); + const {ct, iv} = await res.json(); + if (ct) { + _saveAnnotationsCache(bookId, 'bookmarks', ct, iv); // persist for offline + const key = await getOrCreateEncKey(); + const plain = await decryptBytes(key, iv, ct); + currentBookmarks = JSON.parse(new TextDecoder().decode(plain)); + } else { + currentBookmarks = []; + } + } catch (e) { + // offline fallback + const cached = await _loadAnnotationsCache(bookId, 'bookmarks'); + if (cached?.ct) { + try { + const key = await getOrCreateEncKey(); + const plain = await decryptBytes(key, cached.iv, cached.ct); + currentBookmarks = JSON.parse(new TextDecoder().decode(plain)); + } catch { currentBookmarks = []; } + } else { + currentBookmarks = []; + } + } +} + +async function saveBookmarks() { + if (!currentBookId) return; + try { + const key = await getOrCreateEncKey(); + const plain = new TextEncoder().encode(JSON.stringify(currentBookmarks)); + const {iv, ciphertext} = await encryptBytes(key, plain); + const body = JSON.stringify({ct: ciphertext, iv}); + const url = `/books/${currentBookId}/bookmarks/`; + _lastBookmarkBeacon = {url, body}; + _saveAnnotationsCache(currentBookId, 'bookmarks', ciphertext, iv); // keep local cache current + await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body, + }); + bookmarksDirty = false; + } catch (e) { + _markPendingSync(currentBookId, 'bookmarks'); + } +} + +function addBookmark() { + const contentEl = $('reader-content'); + if (!contentEl || !currentBookId) return; + + let label, anchor, scrollFraction; + + if (currentPdfDoc) { + const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1; + label = `Seite ${page}`; + anchor = `pdf-page-${page}`; + scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1); + } else { + // Find first visible chapter div + const chapters = contentEl.querySelectorAll('[data-epub-src]'); + let visibleChapter = null; + for (const ch of chapters) { + const rect = ch.getBoundingClientRect(); + if (rect.bottom > 0 && rect.top < window.innerHeight) { + visibleChapter = ch; + break; + } + } + const src = visibleChapter?.getAttribute('data-epub-src') || ''; + label = src.split('/').pop().replace(/\.x?html?$/i, '') || 'Bookmark'; + anchor = src; + scrollFraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); + } + + const bm = { + id: crypto.randomUUID(), + label, + anchor, + scrollFraction, + createdAt: new Date().toISOString(), + }; + currentBookmarks.unshift(bm); + bookmarksDirty = true; + saveBookmarks(); + + // Toast + const toast = document.createElement('div'); + toast.className = 'reader-toast'; + toast.textContent = `★ Lesezeichen: ${label}`; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2200); +} + +function openBookmarksSidebar() { + if (!currentBookmarks.length) { + openSidebar('Lesezeichen', '

    Noch keine Lesezeichen. Tippe beim Lesen auf ★.

    '); + return; + } + let html = '
      '; + for (const bm of currentBookmarks) { + html += `
    • + + +
    • `; + } + html += '
    '; + openSidebar('Lesezeichen', html); + + const body = $('sidebar-body'); + body.addEventListener('click', function _bmClick(e) { + const jumpBtn = e.target.closest('[data-jump-bookmark]'); + const delBtn = e.target.closest('[data-delete-bookmark]'); + if (jumpBtn) { + body.removeEventListener('click', _bmClick); + jumpToBookmark(jumpBtn.dataset.jumpBookmark); + } + if (delBtn) { + const id = delBtn.dataset.deleteBookmark; + currentBookmarks = currentBookmarks.filter(b => b.id !== id); + bookmarksDirty = true; + saveBookmarks(); + openBookmarksSidebar(); // re-render + } + }); +} + +function jumpToBookmark(id) { + const bm = currentBookmarks.find(b => b.id === id); + if (!bm) return; + closeSidebar(); + _suppressScrollJumpDetect(); + setTimeout(() => { + const contentEl = $('reader-content'); + if (!contentEl) return; + if (bm.anchor.startsWith('pdf-page-')) { + if (readerSettings.pdfPaginated) { + pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1); + } else { + const target = contentEl.querySelector('#' + bm.anchor); + if (target) { + const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); + } + } + } else { + const target = Array.from(contentEl.querySelectorAll('[data-epub-src]')) + .find(el => el.getAttribute('data-epub-src') === bm.anchor); + if (target) { + const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); + } else { + contentEl.scrollTop = bm.scrollFraction * (contentEl.scrollHeight - contentEl.clientHeight); + } + } + }, 50); +} diff --git a/static/js/app/70-search.js b/static/js/app/70-search.js new file mode 100644 index 0000000..59d1c68 --- /dev/null +++ b/static/js/app/70-search.js @@ -0,0 +1,170 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Reader Search +// --------------------------------------------------------------------------- + +let _readerSearchDebounce = null; + +function toggleReaderSearch() { + const overlay = $('reader-overlay'); + const contentEl = $('reader-content'); + if (!overlay || !contentEl) return; + + const existing = document.getElementById('reader-search-bar'); + if (existing) { + existing.remove(); + readerSearchOpen = false; + clearReaderSearch(); + return; + } + + readerSearchOpen = true; + const bar = document.createElement('div'); + bar.id = 'reader-search-bar'; + bar.className = 'reader-search-bar'; + bar.innerHTML = ` + + + + + + `; + overlay.insertBefore(bar, $('reader-body-row')); + + const input = bar.querySelector('#reader-search-input'); + input.focus(); + input.addEventListener('input', () => { + clearTimeout(_readerSearchDebounce); + _readerSearchDebounce = setTimeout(() => doReaderSearch(input.value.trim()), 300); + }); + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { e.shiftKey ? readerSearchPrev() : readerSearchNext(); } + if (e.key === 'Escape') { toggleReaderSearch(); } + }); + bar.querySelector('#rs-search-prev').addEventListener('click', readerSearchPrev); + bar.querySelector('#rs-search-next').addEventListener('click', readerSearchNext); + bar.querySelector('#rs-search-clear').addEventListener('click', toggleReaderSearch); +} + +async function doReaderSearch(query) { + const contentEl = $('reader-content'); + if (!contentEl) return; + const countEl = document.getElementById('rs-search-count'); + + clearReaderSearchHighlights(); + searchMatches = []; + searchMatchIndex = -1; + + if (!query) { if (countEl) countEl.textContent = ''; return; } + + if (!currentPdfDoc) { + // EPUB: snapshot original content + if (!searchOriginalContent) { + searchOriginalContent = contentEl.innerHTML; + } else { + contentEl.innerHTML = searchOriginalContent; + applyHighlightsToContent(); + } + + const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); + const lq = query.toLowerCase(); + const ranges = []; + let node; + while ((node = walker.nextNode())) { + const text = node.textContent; + const lt = text.toLowerCase(); + let idx = 0; + while ((idx = lt.indexOf(lq, idx)) !== -1) { + const range = document.createRange(); + range.setStart(node, idx); + range.setEnd(node, idx + query.length); + ranges.push(range); + idx += query.length; + } + } + + // Insert marks in reverse to preserve range validity + for (let i = ranges.length - 1; i >= 0; i--) { + try { + const mark = document.createElement('mark'); + mark.className = 'reader-search-match'; + ranges[i].surroundContents(mark); + searchMatches.unshift(mark); + } catch (e) {} + } + } else { + // PDF: collect text layer spans + const spans = contentEl.querySelectorAll('.pdf-text-layer > span'); + const lq = query.toLowerCase(); + for (const span of spans) { + if (span.textContent.toLowerCase().includes(lq)) { + span.classList.add('reader-search-match'); + searchMatches.push(span); + } + } + } + + if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0'; + if (searchMatches.length) { + searchMatchIndex = 0; + scrollToSearchMatch(0); + } +} + +function clearReaderSearchHighlights() { + if (!currentPdfDoc) { + // EPUB: restore from snapshot + if (searchOriginalContent !== null) { + const contentEl = $('reader-content'); + if (contentEl) { + contentEl.innerHTML = searchOriginalContent; + applyHighlightsToContent(); + } + searchOriginalContent = null; + } else { + // Just remove marks without full restore + document.querySelectorAll('mark.reader-search-match').forEach(m => { + const parent = m.parentNode; + while (m.firstChild) parent.insertBefore(m.firstChild, m); + parent.removeChild(m); + }); + } + } else { + // PDF: remove highlight class from spans + document.querySelectorAll('.reader-search-match').forEach(el => { + el.classList.remove('reader-search-match', 'active'); + }); + } + searchMatches = []; + searchMatchIndex = -1; +} + +function clearReaderSearch() { + clearTimeout(_readerSearchDebounce); + clearReaderSearchHighlights(); + readerSearchOpen = false; + const countEl = document.getElementById('rs-search-count'); + if (countEl) countEl.textContent = ''; +} + +function scrollToSearchMatch(idx) { + if (!searchMatches.length) return; + _suppressScrollJumpDetect(); + searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx)); + searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'}); + const countEl = document.getElementById('rs-search-count'); + if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`; +} + +function readerSearchNext() { + if (!searchMatches.length) return; + searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length; + scrollToSearchMatch(searchMatchIndex); +} + +function readerSearchPrev() { + if (!searchMatches.length) return; + searchMatchIndex = (searchMatchIndex - 1 + searchMatches.length) % searchMatches.length; + scrollToSearchMatch(searchMatchIndex); +} diff --git a/static/js/app/72-highlights.js b/static/js/app/72-highlights.js new file mode 100644 index 0000000..4e950f7 --- /dev/null +++ b/static/js/app/72-highlights.js @@ -0,0 +1,424 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Highlights +// --------------------------------------------------------------------------- + +async function loadHighlights(bookId) { + try { + const res = await fetch(`/books/${bookId}/highlights/`); + const {ct, iv} = await res.json(); + if (ct) { + _saveAnnotationsCache(bookId, 'highlights', ct, iv); // persist for offline + const key = await getOrCreateEncKey(); + const plain = await decryptBytes(key, iv, ct); + currentHighlights = JSON.parse(new TextDecoder().decode(plain)); + } else { + currentHighlights = []; + } + applyHighlightsToContent(); + } catch (e) { + // offline fallback + const cached = await _loadAnnotationsCache(bookId, 'highlights'); + if (cached?.ct) { + try { + const key = await getOrCreateEncKey(); + const plain = await decryptBytes(key, cached.iv, cached.ct); + currentHighlights = JSON.parse(new TextDecoder().decode(plain)); + } catch { currentHighlights = []; } + } else { + currentHighlights = []; + } + applyHighlightsToContent(); + } +} + +async function saveHighlights() { + if (!currentBookId) return; + try { + const key = await getOrCreateEncKey(); + const plain = new TextEncoder().encode(JSON.stringify(currentHighlights)); + const {iv, ciphertext} = await encryptBytes(key, plain); + const body = JSON.stringify({ct: ciphertext, iv}); + const url = `/books/${currentBookId}/highlights/`; + _lastHighlightBeacon = {url, body}; + _saveAnnotationsCache(currentBookId, 'highlights', ciphertext, iv); // keep local cache current + await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body, + }); + highlightsDirty = false; + } catch (e) { + _markPendingSync(currentBookId, 'highlights'); + } +} + +let _highlightSaveDebounce = null; +function debounceSaveHighlights() { + clearTimeout(_highlightSaveDebounce); + _highlightSaveDebounce = setTimeout(saveHighlights, 2000); +} + +function applyHighlightsToContent() { + const contentEl = $('reader-content'); + if (!contentEl || currentPdfDoc) return; + for (const h of currentHighlights) { + try { renderHighlight(h); } catch (e) {} + } + _repositionMarginMarkers(); +} + +// Resolves a highlight/note's stored anchor back to a live DOM Range. Shared by +// rendering, margin-marker positioning and export ordering — all three need the +// same "where in the current DOM is this annotation" answer. +function resolveAnchorRange(h) { + const contentEl = $('reader-content'); + if (!contentEl || !h.anchor) return null; + + let range = null; + try { + const {blocks} = _epubBlocks(contentEl); + const startBlock = blocks[h.anchor.startBlockIndex]; + const endBlock = blocks[h.anchor.endBlockIndex] ?? startBlock; + if (startBlock && endBlock) { + const start = _nodeAtCharOffset(startBlock, h.anchor.startChar); + const end = _nodeAtCharOffset(endBlock, h.anchor.endChar); + if (start && end) { + range = document.createRange(); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + } + } + } catch (e) {} + + // Fallback: quote substring search within the chapter + if (!range && h.anchor.quote) { + const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`) + || contentEl; + const walker = document.createTreeWalker(chapterEl, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + const idx = node.textContent.indexOf(h.anchor.quote); + if (idx !== -1) { + range = document.createRange(); + range.setStart(node, idx); + range.setEnd(node, idx + h.anchor.quote.length); + break; + } + } + } + + return range; +} + +function renderHighlight(h) { + // Freeform notes carry no in-text mark — their anchor exists purely to + // position the note card in the margin (resolveAnchorRange handles that + // directly from the stored xpath, no DOM wrapper needed). + if (h.type === 'note') return; + + const range = resolveAnchorRange(h); + if (!range) return; + + try { + const mark = document.createElement('mark'); + mark.dataset.highlightId = h.id; + mark.className = 'epub-highlight'; + mark.dataset.color = h.color || 'yellow'; + range.surroundContents(mark); + } catch (e) {} +} + +// Character-offset addressing within a block element (p, li, ...), rather +// than XPath-to-a-specific-text-node: XPath sibling indices shift whenever +// ANY highlight in the same block gets wrapped/unwrapped in a (that +// splits/merges text nodes), silently corrupting OTHER highlights' anchors +// in the same block. A block's overall textContent length/order is never +// touched by mark-wrapping, so a character offset into it stays valid no +// matter how many other highlights come and go around it. +function _charOffsetInBlock(block, node, nodeOffset) { + const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + let count = 0, n; + while ((n = walker.nextNode())) { + if (n === node) return count + nodeOffset; + count += n.textContent.length; + } + return count; +} + +function _nodeAtCharOffset(block, targetOffset) { + const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + let count = 0, lastNode = null, n; + while ((n = walker.nextNode())) { + lastNode = n; + const len = n.textContent.length; + if (targetOffset <= count + len) { + return {node: n, offset: Math.max(0, targetOffset - count)}; + } + count += len; + } + return lastNode ? {node: lastNode, offset: lastNode.textContent.length} : null; +} + +function _blockAncestor(node) { + const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; + return el ? el.closest(EPUB_BLOCK_SELECTOR) : null; +} + +function buildEpubAnchor(range) { + const contentEl = $('reader-content'); + const chapterEl = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE + ? range.commonAncestorContainer.closest('[data-epub-src]') + : range.commonAncestorContainer.parentElement?.closest('[data-epub-src]'); + + const startBlock = _blockAncestor(range.startContainer); + const endBlock = _blockAncestor(range.endContainer) || startBlock; + const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); + + return { + type: 'epub', + chapterSrc: chapterEl?.getAttribute('data-epub-src') || '', + startBlockIndex: startBlock ? blocks.indexOf(startBlock) : -1, + startChar: startBlock ? _charOffsetInBlock(startBlock, range.startContainer, range.startOffset) : 0, + endBlockIndex: endBlock ? blocks.indexOf(endBlock) : -1, + endChar: endBlock ? _charOffsetInBlock(endBlock, range.endContainer, range.endOffset) : 0, + quote: range.toString().slice(0, 200), + }; +} + +// Marker mode — armed via the pen button in the reader header. Text selection +// only opens the highlight popover while this is active, so normal selection +// (e.g. to copy text) isn't hijacked, and the feature has a discoverable +// on/off switch instead of being an invisible "just select text" gesture. +let markerModeActive = false; + +function toggleMarkerMode() { + markerModeActive = !markerModeActive; + document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.toggle('active', markerModeActive)); +} + +// Note editing/creation happens in the margin column on desktop, but a note +// bottom sheet (see showNoteBottomSheet) on narrow viewports — typing into a +// 150px margin strip on a phone keyboard is unpleasant, and opening that +// column at all is exactly the friction we're trying to remove for mobile +// note-taking. Reviewing existing notes in depth is expected to stay a +// desktop activity; this only affects where the editor for a note appears. +function _isMobileReader() { + return window.innerWidth <= 600; +} + +function handleReaderSelection(e) { + // Clicking an existing highlight always shows its tooltip, regardless of marker mode + const hlMark = e.target.closest('.epub-highlight'); + if (hlMark) { + dismissHighlightPopover(); + const id = hlMark.dataset.highlightId; + const h = currentHighlights.find(x => x.id === id); + showHighlightTooltip(hlMark, h); + return; + } + + if (!markerModeActive) return; + // Don't hijack taps on real interactive content (footnote refs, external + // links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless + //
    used only as an internal jump target (sanitizeEpubHtml + // strips its href but the tag itself stays); those aren't actually + // clickable and must not block note-taking, so check for a surviving + // href/footnote-ref rather than just "is inside some ". + if (e.target.closest('button, [data-footnote-ref], a[href]')) return; + + dismissHighlightPopover(); + const sel = window.getSelection(); + if (sel && !sel.isCollapsed && sel.rangeCount) { + const range = sel.getRangeAt(0); + const contentEl = $('reader-content'); + if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) return; + if (range.toString().trim().length === 0) return; + showHighlightPopover(range); + return; + } + + // A plain tap with nothing selected — in marker mode this means "drop a + // freeform note here" instead of doing nothing, so a note-only annotation + // never depends on finding the margin's precise click target (especially + // hard on a narrow phone screen — see createFreeformNote/_openNoteEditor). + if (typeof e.clientX === 'number' && typeof e.clientY === 'number') { + const range = _wordRangeAtPoint(e.clientX, e.clientY); + if (range) createFreeformNote(range); + } +} + +function showHighlightPopover(range) { + const rect = range.getBoundingClientRect(); + const popover = document.createElement('div'); + popover.id = 'highlight-popover'; + popover.className = 'highlight-popover'; + popover.innerHTML = ` + + + + + + `; + popover.style.visibility = 'hidden'; // measure before showing, to avoid a flash at the wrong spot + document.body.appendChild(popover); + currentHighlightPopover = popover; + + // Clamp within the viewport — a selection near the top/edge of the page must + // not produce an invisible, off-screen popover (flip below when there's no + // room above; keep it horizontally on-screen). + const pRect = popover.getBoundingClientRect(); + const minTop = window.scrollY + 8; + const maxTop = window.scrollY + window.innerHeight - pRect.height - 8; + let top = rect.top + window.scrollY - 44; + if (top < minTop) top = rect.bottom + window.scrollY + 8; // no room above -> try below + top = Math.max(minTop, Math.min(top, maxTop)); + let left = rect.left + window.scrollX + rect.width / 2 - pRect.width / 2; + left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8)); + popover.style.top = top + 'px'; + popover.style.left = left + 'px'; + popover.style.visibility = ''; + + // Store range info before selection is cleared + const savedRange = range.cloneRange(); + + popover.addEventListener('click', e => { + const colorBtn = e.target.closest('.hl-color-btn'); + const noteBtn = e.target.closest('.hl-note-btn'); + if (colorBtn) { + createHighlight(colorBtn.dataset.hlColor, savedRange); + } else if (noteBtn) { + createHighlightWithNote(savedRange); + } + }); +} + +function showHighlightTooltip(markEl, h) { + const rect = markEl.getBoundingClientRect(); + const popover = document.createElement('div'); + popover.id = 'highlight-popover'; + popover.className = 'highlight-popover'; + popover.style.flexDirection = 'column'; + popover.style.maxWidth = '220px'; + const noteText = h?.note ? escapeHtml(h.note) : 'Keine Notiz'; + popover.innerHTML = ` +
    ${noteText}
    +
    + + +
    + `; + popover.style.visibility = 'hidden'; + document.body.appendChild(popover); + currentHighlightPopover = popover; + + const pRect = popover.getBoundingClientRect(); + const minTop = window.scrollY + 8; + const maxTop = window.scrollY + window.innerHeight - pRect.height - 8; + let top = rect.bottom + window.scrollY + 4; + if (top > maxTop) top = rect.top + window.scrollY - pRect.height - 4; // flip above if no room below + top = Math.max(minTop, Math.min(top, maxTop)); + let left = rect.left + window.scrollX; + left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8)); + popover.style.top = top + 'px'; + popover.style.left = left + 'px'; + popover.style.visibility = ''; + + popover.addEventListener('click', ev => { + const editBtn = ev.target.closest('[data-hl-edit-note]'); + const delBtn = ev.target.closest('[data-hl-delete]'); + if (editBtn && h) { + dismissHighlightPopover(); + _openNoteEditor(h); + } + if (delBtn && h) { + dismissHighlightPopover(); + deleteHighlight(h.id); + } + }); + + // Close on outside click + setTimeout(() => { + document.addEventListener('click', dismissHighlightPopover, {once: true}); + }, 0); +} + +function createHighlight(color, range) { + const anchor = buildEpubAnchor(range); + const h = { + id: crypto.randomUUID(), + type: 'highlight', + anchor, + color, + note: '', + createdAt: new Date().toISOString(), + }; + currentHighlights.push(h); + highlightsDirty = true; + window.getSelection()?.removeAllRanges(); + dismissHighlightPopover(); + renderHighlight(h); + _repositionMarginMarkers(); + debounceSaveHighlights(); +} + +function createHighlightWithNote(range) { + const anchor = buildEpubAnchor(range); + const h = { + id: crypto.randomUUID(), + type: 'highlight', + anchor, + color: 'yellow', + note: '', + createdAt: new Date().toISOString(), + }; + currentHighlights.push(h); + highlightsDirty = true; + window.getSelection()?.removeAllRanges(); + dismissHighlightPopover(); + renderHighlight(h); + _repositionMarginMarkers(); + _openNoteEditor(h); +} + +// Every "add/edit a note" entry point routes here — notes are always edited +// right where they conceptually live (the margin), never in the separate +// right-hand sidebar, EXCEPT on narrow viewports, where the margin itself is +// too cramped to type into and a full-width bottom sheet takes over instead. +// `pending` is only set for a not-yet-created freeform note (see +// createFreeformNote); h is null in that case until the user actually saves +// text, so an empty tap doesn't leave a permanent ghost entry behind. +function _openNoteEditor(h, pending) { + if (_isMobileReader()) { + showNoteBottomSheet(h, pending); + } else { + if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin(); + editNoteInlineInMargin(h, pending); + } +} + +function deleteHighlight(id) { + currentHighlights = currentHighlights.filter(h => h.id !== id); + highlightsDirty = true; + // Re-apply all highlights after removing the deleted one + const contentEl = $('reader-content'); + if (contentEl && !currentPdfDoc) { + // Snapshot restore not available mid-session, so remove the mark manually + const mark = contentEl.querySelector(`mark[data-highlight-id="${id}"]`); + if (mark) { + const parent = mark.parentNode; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + } + } + _repositionMarginMarkers(); + debounceSaveHighlights(); +} + +function dismissHighlightPopover() { + if (currentHighlightPopover) { + currentHighlightPopover.remove(); + currentHighlightPopover = null; + } +} diff --git a/static/js/app/74-margin.js b/static/js/app/74-margin.js new file mode 100644 index 0000000..60f0c6b --- /dev/null +++ b/static/js/app/74-margin.js @@ -0,0 +1,408 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Margin panel — a "page margin" strip left of the reader content. Shows small +// markers next to the on-screen highlights/notes, and lets you click empty +// space to anchor a freeform note at that line's height (EPUB only). +// --------------------------------------------------------------------------- +let readerAnnotationsMarginOpen = false; + +function toggleAnnotationsMargin() { + const el = $('reader-margin'); + const markersEl = $('reader-margin-markers'); + if (!el || !markersEl || currentPdfDoc) return; + readerAnnotationsMarginOpen = !readerAnnotationsMarginOpen; + el.classList.toggle('open', readerAnnotationsMarginOpen); + if (readerAnnotationsMarginOpen) { + // Reposition once now (pre-transition) and once more after the width + // transition settles, since content reflow can shift line heights. + _repositionMarginMarkers(); + el.addEventListener('transitionend', _repositionMarginMarkers, {once: true}); + } else { + markersEl.innerHTML = ''; + } +} + +// Renders each highlight/note at its text position in the margin. Entries +// with note text become a readable "post-it" card (the whole point of the +// margin — you can read notes there, not just see that one exists); a plain +// highlight with no note is just a small color dot. Cards are stacked +// top-to-bottom so overlapping anchors don't overlap visually. +// Where each highlight sits in the book's scroll coordinates. Finding a range +// and measuring it is the expensive part, and scrolling doesn't change it: +// kept until the layout does (content, width, height) or the anchor changes. +let _marginPosCache = null; +const _marginCardHeights = new Map(); // `${stripWidth}|${note}` (or 'dot') → px + +function _marginDocY(h, contentEl) { + const {blocks} = _epubBlocks(contentEl); + const key = `${contentEl.clientWidth}x${contentEl.scrollHeight}`; + if (!_marginPosCache || _marginPosCache.blocks !== blocks || _marginPosCache.key !== key) { + _marginPosCache = {blocks, key, byId: new Map()}; + } + const hit = _marginPosCache.byId.get(h.id); + if (hit && hit.anchor === h.anchor) return hit.y; + let y = null; + try { + const range = resolveAnchorRange(h); + const rect = range && range.getBoundingClientRect(); + if (rect && (rect.width || rect.height)) { + y = rect.top + rect.height / 2 - contentEl.getBoundingClientRect().top + contentEl.scrollTop; + } + } catch (e) {} + _marginPosCache.byId.set(h.id, {anchor: h.anchor, y}); + return y; +} + +// The cards live on one layer in the book's coordinates, built when the +// highlights or the layout change; scrolling only moves the layer (a transform, +// no layout). Rebuilding and measuring the cards on every scroll frame made a +// long book with the margin open stutter. +let _marginLayerKey = null; + +function _repositionMarginMarkers() { + const markersEl = $('reader-margin-markers'); + if (!markersEl) return; + if (!readerAnnotationsMarginOpen || currentPdfDoc) { + markersEl.innerHTML = ''; + _marginLayerKey = null; + return; + } + if (markersEl.querySelector('.margin-note-editor')) return; // don't blow away an active inline edit + const contentEl = $('reader-content'); + if (!contentEl) return; + + const {blocks} = _epubBlocks(contentEl); + const areaRect = markersEl.getBoundingClientRect(); + const width = Math.round(areaRect.width); + const key = `${width}x${contentEl.clientWidth}x${contentEl.scrollHeight}|` + + currentHighlights.map(h => `${h.id}:${h.color || ''}:${h.note || ''}`).join('|'); + let layer = markersEl.querySelector('.margin-layer'); + if (!layer || key !== _marginLayerKey || !_marginPosCache || _marginPosCache.blocks !== blocks) { + markersEl.innerHTML = ''; + layer = document.createElement('div'); + layer.className = 'margin-layer'; + markersEl.appendChild(layer); + _renderMarginLayer(layer, contentEl, width); + _marginLayerKey = key; + } + // Book coordinates → this strip's. + const shift = contentEl.getBoundingClientRect().top - contentEl.scrollTop - areaRect.top; + layer.style.transform = `translateY(${Math.round(shift)}px)`; +} + +// Renders each highlight/note at its text position, in book coordinates. +// Entries with note text become a readable "post-it" card (the whole point of +// the margin — you can read notes there, not just see that one exists); a +// plain highlight with no note is a small colour dot. Cards are stacked top to +// bottom so overlapping anchors don't overlap visually. +function _renderMarginLayer(layer, contentEl, width) { + const entries = []; + for (const h of currentHighlights) { + const y = _marginDocY(h, contentEl); + if (y !== null) entries.push({h, y}); + } + entries.sort((a, b) => a.y - b.y); + + // All cards first, then their heights in one go (each measured right after + // adding it, the browser laid out the whole book once per card). Heights are + // kept per note text and strip width. + const cards = entries.map(({h, y}) => { + const hasText = !!h.note; + const el = document.createElement('div'); + el.className = 'margin-note ' + (hasText ? 'margin-note-text' : 'margin-note-dot'); + el.dataset.highlightId = h.id; + if (h.type === 'note') { + if (hasText) el.classList.add('margin-note-freeform'); + } else { + el.dataset.color = h.color || 'yellow'; + } + if (hasText) el.textContent = h.note; + layer.appendChild(el); + return {el, y, hasText, key: hasText ? `${width}|${h.note}` : 'dot'}; + }); + if (_marginCardHeights.size > 500) _marginCardHeights.clear(); + const unmeasured = cards.filter(c => !_marginCardHeights.has(c.key)); + for (const c of unmeasured) _marginCardHeights.set(c.key, c.el.getBoundingClientRect().height || (c.hasText ? 20 : 12)); + + let nextTop = -Infinity; + const GAP = 4; + for (const {el, y, hasText, key} of cards) { + const top = Math.max(hasText ? y - 8 : y - 6, nextTop); + el.style.top = top + 'px'; + nextTop = top + _marginCardHeights.get(key) + GAP; + } +} + +function handleMarginClick(e) { + const markerBtn = e.target.closest('.margin-note'); + if (markerBtn) { + const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId); + if (h) _openNoteEditor(h); + return; + } + + // Empty space in the markers area → place a new freeform note anchored to + // the first word of whatever line sits at this click height. + if (!readerAnnotationsMarginOpen || currentPdfDoc) return; + const markersEl = $('reader-margin-markers'); + if (!markersEl || !markersEl.contains(e.target)) return; + // If an editor is already open, this click just blurs/commits it (see its + // own blur handler) — it shouldn't also create a second, unrelated note. + if (markersEl.querySelector('.margin-note-editor')) return; + const contentEl = $('reader-content'); + if (!contentEl) return; + + const x = contentEl.getBoundingClientRect().left + 4; + const range = _wordRangeAtPoint(x, e.clientY); + if (range) createFreeformNote(range); +} + +// Finds the word starting at (or just after) the given viewport point, by +// probing near the left edge of the reader content at that height — since +// text is left-aligned, that lands at (or very near) the start of the line. +function _wordRangeAtPoint(x, y) { + let caret = null; + if (document.caretRangeFromPoint) { + caret = document.caretRangeFromPoint(x, y); + } else if (document.caretPositionFromPoint) { + const pos = document.caretPositionFromPoint(x, y); + if (pos) { + caret = document.createRange(); + caret.setStart(pos.offsetNode, pos.offset); + } + } + if (!caret) return null; + + const contentEl = $('reader-content'); + if (!contentEl || !contentEl.contains(caret.startContainer)) return null; + + let node = caret.startContainer; + let offset = caret.startOffset; + if (node.nodeType !== Node.TEXT_NODE) { + const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); + walker.currentNode = node; + node = walker.nextNode(); + if (!node) return null; + offset = 0; + } + + const text = node.textContent; + let start = offset; + while (start < text.length && /\s/.test(text[start])) start++; + if (start >= text.length) return null; + let wordStart = start; + while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--; + let wordEnd = start; + while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++; + if (wordEnd <= wordStart) return null; + + const range = document.createRange(); + range.setStart(node, wordStart); + range.setEnd(node, wordEnd); + return range; +} + +function createFreeformNote(range) { + const anchor = buildEpubAnchor(range); + // Where the note's card/dot would sit in the margin, computed from the live + // range right now (before it goes stale) — used to position the editor, + // whether or not the margin is actually open. + let top = 0; + const markersEl = $('reader-margin-markers'); + if (markersEl) { + const areaRect = markersEl.getBoundingClientRect(); + const rangeRect = range.getBoundingClientRect(); + top = Math.max(0, rangeRect.top + rangeRect.height / 2 - areaRect.top - 8); + } + // The highlight object itself isn't created yet — see _openNoteEditor's + // `pending` param — so a tap that's immediately cancelled or left empty + // never leaves a ghost entry in currentHighlights. + _openNoteEditor(null, {anchor, top}); +} + +// Commits an edit for both note-editor UIs (inline margin + mobile bottom +// sheet): updates an existing note in place, or — only if there's actual +// text — creates and persists a not-yet-existing freeform note. An empty +// tap/cancel on a pending note must never leave a permanent ghost entry. +function _commitNoteEdit(h, pending, text) { + if (h) { + if (h.note !== text) { + h.note = text; + highlightsDirty = true; + debounceSaveHighlights(); + } + return h; + } + if (!text) return null; + const newH = { + id: crypto.randomUUID(), type: 'note', anchor: pending.anchor, + color: null, note: text, createdAt: new Date().toISOString(), + }; + currentHighlights.push(newH); + highlightsDirty = true; + debounceSaveHighlights(); + return newH; +} + +// Edit a note's text right where it lives in the margin, instead of jumping +// to the separate right-hand sidebar — the whole point of setting a note in +// the margin is that you shouldn't have to look away from it to write it. +// `h` is null for a not-yet-created freeform note (see createFreeformNote); +// `pending` then carries its anchor + intended margin position instead. +function editNoteInlineInMargin(h, pending) { + const markersEl = $('reader-margin-markers'); + if (!markersEl) return; + markersEl.querySelector('.margin-note-editor')?.remove(); + + let top = pending?.top || 0; + if (h) { + const existingCard = markersEl.querySelector(`[data-highlight-id="${h.id}"]`); + // The card sits on the moving layer: where it shows in the strip right now. + top = existingCard ? existingCard.getBoundingClientRect().top - markersEl.getBoundingClientRect().top : 0; + if (existingCard) existingCard.style.display = 'none'; + } + + const editor = document.createElement('div'); + editor.className = 'margin-note-editor'; + editor.style.top = top + 'px'; + editor.innerHTML = ''; + markersEl.appendChild(editor); + const textarea = editor.querySelector('textarea'); + textarea.value = h?.note || ''; + + function commit() { + _commitNoteEdit(h, pending, textarea.value.trim()); + editor.remove(); + _marginLayerKey = null; // the edited card was hidden: draw the layer anew + _repositionMarginMarkers(); + } + + textarea.addEventListener('blur', commit); + textarea.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + textarea.blur(); // triggers commit() + } else if (e.key === 'Escape') { + // Stop this from bubbling to the document-level Escape handler, which + // would otherwise close the whole reader (it doesn't know we just want + // to cancel this one edit) — see the global keydown listener. + e.stopPropagation(); + textarea.removeEventListener('blur', commit); // discard, don't save + editor.remove(); + _marginLayerKey = null; + _repositionMarginMarkers(); + } + }); + + textarea.focus(); + textarea.select(); +} + +// Mobile alternative to editNoteInlineInMargin — a full-width sheet instead +// of squeezing a textarea into the 150px margin column (see _openNoteEditor). +let currentNoteBottomSheet = null; + +function dismissNoteBottomSheet() { + currentNoteBottomSheet?.remove(); + currentNoteBottomSheet = null; +} + +function showNoteBottomSheet(h, pending) { + dismissNoteBottomSheet(); + + const sheet = document.createElement('div'); + sheet.className = 'note-bottom-sheet'; + sheet.innerHTML = ` +
    +
    + +
    + + +
    +
    + `; + document.body.appendChild(sheet); + currentNoteBottomSheet = sheet; + + const textarea = sheet.querySelector('.note-bottom-sheet-textarea'); + textarea.value = h?.note || ''; + + function commit() { + _commitNoteEdit(h, pending, textarea.value.trim()); + dismissNoteBottomSheet(); + if (readerAnnotationsMarginOpen) _repositionMarginMarkers(); + } + function cancel() { dismissNoteBottomSheet(); } + + sheet.querySelector('[data-nbs-save]').addEventListener('click', commit); + sheet.querySelector('[data-nbs-cancel]').addEventListener('click', cancel); + sheet.querySelector('.note-bottom-sheet-backdrop').addEventListener('click', cancel); + textarea.addEventListener('keydown', e => { + if (e.key === 'Escape') { e.stopPropagation(); cancel(); } + }); + + requestAnimationFrame(() => { + sheet.classList.add('open'); + textarea.focus(); + }); +} + +// Same plain-text export as exportAnnotations(), but callable straight from the +// book list (no open reader, so no live DOM/TOC to resolve percent position or +// chapter titles against — falls back to the raw chapterSrc path and orders by +// the anchor's block index, which is already a global reading-order index). +async function downloadBookAnnotationsFromList(bookId, btnEl) { + if (btnEl) btnEl.disabled = true; + try { + const res = await fetch(`/books/${bookId}/highlights/`); + const {ct, iv} = await res.json(); + if (!ct) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; } + const key = await getOrCreateEncKey(); + const plain = await decryptBytes(key, iv, ct); + const highlights = JSON.parse(new TextDecoder().decode(plain)); + if (!highlights.length) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; } + + highlights.sort((a, b) => { + const ab = a.anchor?.startBlockIndex ?? 0, bb = b.anchor?.startBlockIndex ?? 0; + if (ab !== bb) return ab - bb; + return (a.anchor?.startChar ?? 0) - (b.anchor?.startChar ?? 0); + }); + + const bookTitle = bookMetaCache[bookId]?.title || `Buch #${bookId}`; + const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, '']; + + let lastChapter = null; + for (const h of highlights) { + const chapterTitle = h.anchor?.chapterSrc || ''; + if (chapterTitle !== lastChapter) { + lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`); + lastChapter = chapterTitle; + } + if (h.type === 'note') { + lines.push(`[Notiz] bei: „${(h.anchor?.quote || '').trim()}“`); + if (h.note) lines.push(` ${h.note}`); + } else { + lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}] „${(h.anchor?.quote || '').trim()}“`); + if (h.note) lines.push(` Notiz: ${h.note}`); + } + lines.push(''); + } + + const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + await customAlert('Export fehlgeschlagen: ' + e.message); + } finally { + if (btnEl) btnEl.disabled = false; + } +} diff --git a/static/js/app/76-export.js b/static/js/app/76-export.js new file mode 100644 index 0000000..06f7287 --- /dev/null +++ b/static/js/app/76-export.js @@ -0,0 +1,157 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Export annotations — walks all highlights/notes of the current book in +// reading order and offers them as a downloadable plain-text file. +// --------------------------------------------------------------------------- +function _tocTitleForChapterSrc(chapterSrc) { + if (!chapterSrc) return ''; + const entry = currentBookToc.find(t => (t.href || '').split('#')[0] === chapterSrc); + return entry ? entry.label : ''; +} + +// Rough "how far through the book" position for a resolved range — there's no +// real page concept for reflowable EPUB text, so this is a coordinate of last +// resort: percent of total scrollable content height, good enough to roughly +// relocate a highlight/note later. +function _percentFromRange(range) { + const contentEl = $('reader-content'); + 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; + const offsetWithinScroll = (rangeRect.top - contentRect.top) + contentEl.scrollTop; + return Math.max(0, Math.min(100, (offsetWithinScroll / contentEl.scrollHeight) * 100)); +} + +function _sortedAnnotations() { + const resolved = currentHighlights.map(h => { + let range; + try { range = resolveAnchorRange(h); } catch (e) { range = null; } + return {h, range}; + }); + resolved.sort((a, b) => { + if (!a.range || !b.range) return 0; + return a.range.compareBoundaryPoints(Range.START_TO_START, b.range); + }); + return resolved; +} + +// Highlight colours as the export and the list name them. +function _hlColorName(color) { + return ({yellow: 'gelb', green: 'grün', blue: 'blau', red: 'rot'})[color] || color; +} + +function exportAnnotations() { + if (!currentHighlights.length) { + customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); + return; + } + + const resolved = _sortedAnnotations(); + + const titleEl = $('reader-title'); + const bookTitle = (titleEl?.textContent || 'Buch').split(' — ')[0]; + const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, '']; + + let lastChapter = null; + for (const {h, range} of resolved) { + const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || ''; + if (chapterTitle !== lastChapter) { + lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`); + lastChapter = chapterTitle; + } + const pct = _percentFromRange(range); + const coord = pct !== null ? ` (≈${_formatBookPercent(pct / 100)}% im Buch)` : ''; + if (h.type === 'note') { + lines.push(`[Notiz]${coord} bei: „${(h.anchor?.quote || '').trim()}“`); + if (h.note) lines.push(` ${h.note}`); + } else { + lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}]${coord} „${(h.anchor?.quote || '').trim()}“`); + if (h.note) lines.push(` Notiz: ${h.note}`); + } + lines.push(''); + } + + const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +// List of every highlight/note in the book, in reading order, each jumpable — +// the same pattern as the bookmarks sidebar, but for annotations. Unlike the +// margin panel (which only shows what's currently on-screen), this is a full +// overview, useful once a book has annotations scattered throughout. +function openAnnotationsSidebar() { + if (!currentHighlights.length) { + openSidebar('Markierungen & Notizen', '

    Noch keine Markierungen oder Notizen.

    '); + return; + } + + const resolved = _sortedAnnotations(); + let html = '
      '; + for (const {h, range} of resolved) { + const pct = _percentFromRange(range); + const pctLabel = pct !== null ? `≈${_formatBookPercent(pct / 100)}%` : ''; + const kind = h.type === 'note' ? 'Notiz' : `Markierung${h.color ? ' · ' + _hlColorName(h.color) : ''}`; + const preview = h.note || h.anchor?.quote || '(kein Text)'; + html += `
    • + + +
    • `; + } + html += '
    '; + openSidebar('Markierungen & Notizen', html); + + const body = $('sidebar-body'); + body.addEventListener('click', function _annClick(e) { + const jumpBtn = e.target.closest('[data-jump-annotation]'); + const delBtn = e.target.closest('[data-delete-annotation]'); + if (jumpBtn) { + body.removeEventListener('click', _annClick); + jumpToAnnotation(jumpBtn.dataset.jumpAnnotation); + } + if (delBtn) { + deleteHighlight(delBtn.dataset.deleteAnnotation); + openAnnotationsSidebar(); // re-render + } + }); +} + +function jumpToAnnotation(id) { + const h = currentHighlights.find(x => x.id === id); + if (!h) return; + closeSidebar(); + _suppressScrollJumpDetect(); + setTimeout(() => { + const contentEl = $('reader-content'); + if (!contentEl) return; + let range; + try { range = resolveAnchorRange(h); } catch (e) { range = null; } + if (range) { + const top = range.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + contentEl.scrollBy({top: top - 16, behavior: 'smooth'}); + } + }, 50); +} diff --git a/static/js/app/78-footnotes.js b/static/js/app/78-footnotes.js new file mode 100644 index 0000000..727efc2 --- /dev/null +++ b/static/js/app/78-footnotes.js @@ -0,0 +1,76 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Footnote popover +// --------------------------------------------------------------------------- + +let currentFootnotePopover = null; +let _footnotePopoverTimer = null; + +function showFootnotePopover(link) { + dismissFootnotePopover(); + const targetEl = document.getElementById(link.dataset.footnoteRef); + if (!targetEl) return; + // 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'; + const _overlay = document.getElementById('reader-overlay'); + ['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => { + if (_overlay?.classList.contains(cls)) popover.classList.add(cls); + }); + popover.innerHTML = clone.innerHTML; + + const closeBtn = document.createElement('button'); + closeBtn.className = 'footnote-popover-close'; + closeBtn.textContent = '✕'; + closeBtn.addEventListener('click', dismissFootnotePopover); + popover.appendChild(closeBtn); + + const rect = link.getBoundingClientRect(); + popover.style.top = (rect.bottom + 8) + 'px'; + popover.style.left = Math.max(8, rect.left - 8) + 'px'; + + popover.addEventListener('mouseenter', () => clearTimeout(_footnotePopoverTimer)); + popover.addEventListener('mouseleave', () => { + _footnotePopoverTimer = setTimeout(dismissFootnotePopover, 250); + }); + + document.body.appendChild(popover); + currentFootnotePopover = popover; + currentFootnotePopover._refLink = link; + + const pr = popover.getBoundingClientRect(); + if (pr.right > window.innerWidth - 8) popover.style.left = (window.innerWidth - pr.width - 8) + 'px'; + if (pr.bottom > window.innerHeight - 8) popover.style.top = (rect.top - pr.height - 8) + 'px'; +} + +function dismissFootnotePopover() { + clearTimeout(_footnotePopoverTimer); + currentFootnotePopover?.remove(); + currentFootnotePopover = null; +} diff --git a/static/js/app/80-radio-sidebar.js b/static/js/app/80-radio-sidebar.js new file mode 100644 index 0000000..65d4b3e --- /dev/null +++ b/static/js/app/80-radio-sidebar.js @@ -0,0 +1,44 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Radio sidebar (compact player) +// --------------------------------------------------------------------------- + +function openRadioSidebar() { + const stationName = currentStation ? escapeHtml(currentStation.name) : '— kein Sender —'; + const track = currentTrack ? escapeHtml(currentTrack) : ''; + const vol = document.getElementById('volume')?.value ?? 204; + + const rows = [...document.querySelectorAll('#saved-tbody tr[data-id]')]; + const stationsHtml = rows.length + ? rows.map(r => `
  • `).join('') + : `
  • Keine gespeicherten Sender.
  • `; + + const html = ` +
    +
    ${stationName}
    + ${track ? `
    ${track}
    ` : ''} +
    +
    + + +
    +
      ${stationsHtml}
    + `; + + openSidebar('Radio', html); + + const body = $('sidebar-body'); + body.querySelector('#rsb-playstop').addEventListener('click', togglePlayStop); + body.querySelector('#rsb-volume').addEventListener('input', function () { + const v = this.value; + const mainSlider = document.getElementById('volume'); + const mainNum = document.getElementById('volume-num'); + if (mainSlider) mainSlider.value = v; + if (mainNum) mainNum.value = v; + audio.volume = v / 255; + }); + body.querySelectorAll('.rsb-play-btn').forEach(btn => { + btn.addEventListener('click', () => playStation(btn.dataset.url, btn.dataset.name, null)); + }); +} diff --git a/static/js/app/99-init.js b/static/js/app/99-init.js new file mode 100644 index 0000000..de390d6 --- /dev/null +++ b/static/js/app/99-init.js @@ -0,0 +1,150 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +(function init() { + // Migrate PBKDF2-derived key stored by login/register form + if (window.USER_ID) { + const pending = localStorage.getItem('diora_pending_enc_key'); + if (pending) { + localStorage.setItem(`diora_enc_key_${window.USER_ID}`, pending); + localStorage.removeItem('diora_pending_enc_key'); + } + } + + // Populate saved stations from server-side context if available + if (typeof INITIAL_SAVED !== 'undefined' && Array.isArray(INITIAL_SAVED)) { + // The server already renders saved stations in the template; nothing extra needed. + // But if JS-rendered saved tab were needed we'd call addSavedRow here. + } + + // Restore persisted volume, fall back to slider default + const volSlider = $('volume'); + if (volSlider) { + const saved = localStorage.getItem('diora_volume'); + const vol = saved !== null ? parseInt(saved, 10) : parseInt(volSlider.value, 10); + setVolume(vol); + } + + // Restore persisted read-aloud language + const ttsLangSelect = $('reader-tts-lang'); + if (ttsLangSelect) ttsLangSelect.value = ttsLang; + + // Load recommendations on page load + loadRecommendations(); + + // Initialise focus timer display + renderTimer(); + + // Initialise mood/genre chips + initMoodChips(); + + // Initialise curated station lists + initCuratedLists(); + + // Show curated lists again when search input is cleared + const searchInput = document.getElementById('search-input'); + if (searchInput) { + searchInput.addEventListener('input', function () { + if (this.value === '') { + const curated = document.getElementById('curated-lists'); + if (curated) curated.style.display = ''; + } + }); + } + + // Load focus session stats + loadFocusStats(); + + // Apply encrypted wallpaper (if set) + applyEncryptedBackground(); + + // Init book drop zone + initBookDropZone(); + const showReadToggle = $('book-show-read-toggle'); + if (showReadToggle) showReadToggle.checked = _bookShowRead; + + // Restore active tab: a URL hash (shared/bookmarked link, or browser + // back/forward) wins over the last-used tab remembered in localStorage — + // except when offline, where books (usable offline) always takes over. + const offlineOverride = !navigator.onLine && IS_AUTHENTICATED; + const hashTab = (location.hash || '').replace(/^#/, '').split('/')[0]; + if (!offlineOverride && TOP_TABS.includes(hashTab)) { + _routeFromHash(); + } else { + const savedTab = localStorage.getItem('diora_active_tab') || 'radio'; + const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved'; + showTab(offlineOverride ? 'books' : savedTab); + showRadioTab(savedRadioTab); + } + + // React to connectivity changes + window.addEventListener('offline', () => { + showOfflineBanner(true); + if (IS_AUTHENTICATED) showTab('books'); + }); + window.addEventListener('online', async () => { + showOfflineBanner(false); + await _syncPendingAnnotations(); + 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) { + _readerContentEl.addEventListener('mouseover', e => { + const link = e.target.closest('[data-footnote-ref]'); + if (!link) return; + clearTimeout(_footnotePopoverTimer); + _footnotePopoverTimer = setTimeout(() => showFootnotePopover(link), 120); + }); + _readerContentEl.addEventListener('mouseout', e => { + if (!e.target.closest('[data-footnote-ref]')) return; + _footnotePopoverTimer = setTimeout(dismissFootnotePopover, 250); + }); + _readerContentEl.addEventListener('click', e => { + const link = e.target.closest('[data-footnote-ref]'); + if (!link) return; + e.preventDefault(); + if (currentFootnotePopover?._refLink === link) { + dismissFootnotePopover(); + } else { + showFootnotePopover(link); + } + }); + + // Text selection → highlight color/note popover + _readerContentEl.addEventListener('mouseup', handleReaderSelection); + _readerContentEl.addEventListener('touchend', e => { + const target = e.target; + const touch = e.changedTouches && e.changedTouches[0]; + const clientX = touch ? touch.clientX : undefined; + const clientY = touch ? touch.clientY : undefined; + setTimeout(() => handleReaderSelection({target, clientX, clientY}), 50); + }); + } + + // Margin ("page margin") panel — click a marker to view/edit, click empty + // space to anchor a new freeform note at that line's height + const _readerMarginEl = $('reader-margin'); + if (_readerMarginEl) { + _readerMarginEl.addEventListener('click', handleMarginClick); + } +})(); diff --git a/static/js/sw.js b/static/js/sw.js index 9136067..cd485c4 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,10 +2,41 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v105'; +const CACHE = 'diora-v106'; const SHELL = [ '/static/css/app.css', - '/static/js/app.js', + '/static/js/app/01-core.js', + '/static/js/app/10-radio.js', + '/static/js/app/14-focus.js', + '/static/js/app/16-stations.js', + '/static/js/app/20-tabs.js', + '/static/js/app/24-ambient.js', + '/static/js/app/26-sidebar.js', + '/static/js/app/28-serviceworker.js', + '/static/js/app/29-m3u.js', + '/static/js/app/30-contrast.js', + '/static/js/app/32-crypto.js', + '/static/js/app/34-wallpaper.js', + '/static/js/app/40-epub.js', + '/static/js/app/44-books.js', + '/static/js/app/46-readpos.js', + '/static/js/app/48-book-cache.js', + '/static/js/app/50-reader-state.js', + '/static/js/app/52-book-list.js', + '/static/js/app/56-cloud-import.js', + '/static/js/app/58-pdf.js', + '/static/js/app/60-tts.js', + '/static/js/app/62-reader.js', + '/static/js/app/64-reader-settings.js', + '/static/js/app/66-pdf-paginated.js', + '/static/js/app/68-bookmarks.js', + '/static/js/app/70-search.js', + '/static/js/app/72-highlights.js', + '/static/js/app/74-margin.js', + '/static/js/app/76-export.js', + '/static/js/app/78-footnotes.js', + '/static/js/app/80-radio-sidebar.js', + '/static/js/app/99-init.js', '/static/js/podcasts-web.js', '/static/js/home.js', '/static/js/feeds.js', diff --git a/templates/radio/player.html b/templates/radio/player.html index 07474ad..d87d291 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -47,7 +47,7 @@
    - {# Order = app.js TOP_TABS. Home needs an account: hidden, but kept in place for the order. #} + {# Order = TOP_TABS (static/js/app/20-tabs.js). Home needs an account: hidden, but kept in place for the order. #} @@ -347,7 +347,39 @@ window.USER_ID = {{ user.id|default:"null" }}; let USER_FOCUS_STATION = {{ focus_station_json|safe }}; - +{# app.js, split by area (docs/refactoring.md): classic scripts sharing one global scope, in this order. #} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +