diff --git a/public/index.html b/public/index.html index bc07a5a..f18e6a3 100644 --- a/public/index.html +++ b/public/index.html @@ -169,6 +169,16 @@

Sinhronizacija KOReader

+
+ +
+
Sync reading stats to BookOrbit KOReader Stats
+
When enabled, Codexa sends reading time and page sessions to your KOReader sync server (BookOrbit PR #123+). Silently skipped if the server does not support stats.
+
+
diff --git a/public/js/reader_v4.js b/public/js/reader_v4.js index 7aa6bc7..661e6ea 100644 --- a/public/js/reader_v4.js +++ b/public/js/reader_v4.js @@ -4,7 +4,7 @@ import { t, initI18n, applyTranslations, getCurrentLang } from './i18n.js'; import ePub from './flow/index.js'; import { isBookDownloaded, downloadBook, fetchOfflineBookFile, getBookMeta, saveBookMeta } from './offline.js'; -const READER_BUILD = 'br-v51'; +const READER_BUILD = 'br-v56'; const _i18nReady = initI18n(); console.log('[codexa] reader build', READER_BUILD); @@ -26,9 +26,9 @@ if (!Promise.allSettled) { if (!requireAuth()) throw new Error('not authenticated'); const params = new URLSearchParams(window.location.search); -const isPeekMode = params.get('peek') === '1'; const bookId = params.get('id'); if (!bookId) { window.location.href = '/'; throw new Error(); } +const isPeekMode = params.get('peek') === '1'; const BIONIC_RELOAD_KEY = 'br_bionic_reload_state_v1'; const SESSION_KEY = 'br_interrupted_session_v1'; const RESUME_STATE_KEY = 'br_resume_state_v1'; @@ -199,6 +199,7 @@ function getStatusStats() { { id: 'bookAuthor', icon: '/images/book_author.svg', label: t('reader.sb_book_author') }, { id: 'chapterTitle', icon: '/images/chapter_title.svg', label: t('reader.sb_chap_title') }, { id: 'battery', icon: '/images/battery.svg', label: t('reader.sb_battery') }, + { id: 'syncedAgo', icon: '/images/sync.svg', label: t('reader.sb_synced_ago') }, ]; } @@ -297,6 +298,10 @@ let _clearHlTimer = null; // Reading statistics tracking let statsSessionId = null; // active reading_sessions.id let sessionPageCount = 0; // page navigation events in current session +let activeReadingSeconds = 0; // active time since last BookOrbit stats push +let statsSessionStartTs = 0; // unix seconds — start of current reading slice +let statsPagesDelta = 0; // page turns since last BookOrbit stats push +let statsTimer = null; // 1s interval for active reading time // ── Fork sync policy (juliefuller fork; extends upstream thehijacker/codexa) ── // Upstream syncs KOReader progress only on chapter boundaries and book close. @@ -313,12 +318,15 @@ let sessionPageCount = 0; // page navigation events in current sessi // Guards (not in upstream): // lastSyncedCfi — skip kosync when CFI unchanged since last successful push // bestKnownRemotePct — never push backwards unless user confirms manual sync (forced) +// BookOrbit stats — optional kosync_stats_enabled; piggybacks debounced/periodic/manual path const SYNC_DEBOUNCE_MS = 60000; // inactivity debounce — resets on every page turn const SYNC_INTERVAL_MS = 240000; // 4-minute heartbeat — always fires regardless of activity let syncDebounceTimer = null; let syncIntervalTimer = null; let lastSyncedCfi = ''; // CFI at last successful remote push — used to skip duplicate syncs +let lastSyncedAt = 0; // ms timestamp of last successful remote kosync push let bestKnownRemotePct = 0; // high-water mark from all sources — kosync is never pushed below this +let kosyncStatsEnabled = false; // user setting — sync reading stats to external KOReader server // ── Status bar state ────────────────────────────────────────────────────────── let currentHref = ''; // current spine href (updated in updateProgress) @@ -2031,6 +2039,15 @@ function computeStatValue(id) { const pct = Math.round(_batteryMgr.level * 100); return (_batteryMgr.charging ? '⚡\u202F' : '') + pct + '%'; } + case 'syncedAgo': { + if (!lastSyncedAt) return ''; + const sec = Math.floor((Date.now() - lastSyncedAt) / 1000); + if (sec < 0) return ''; + if (sec < 60) return t('reader.sb_synced_ago_secs', { n: sec }); + const min = Math.floor(sec / 60); + if (min < 60) return t('reader.sb_synced_ago_mins', { n: min }); + return t('reader.sb_synced_ago_hours', { n: Math.floor(min / 60) }); + } default: return ''; } @@ -2116,16 +2133,17 @@ function updateStatusBar(location) { updateBookProgressBar(); } -// Re-render only the slots that contain 'currentTime' (called by setInterval) -function refreshStatusBarTime() { +// Re-render slots with time-sensitive stats (currentTime, syncedAgo) +function refreshStatusBarDynamic() { const pos = prefs.statusBar.positions; + const dynamicIds = new Set(['currentTime', 'syncedAgo']); const pairs = [[sbTl, pos.tl], [sbTc, pos.tc], [sbTr, pos.tr], [sbBl, pos.bl], [sbBc, pos.bc], [sbBr, pos.br]]; pairs.forEach(([el, ids]) => { - if (ids.includes('currentTime')) el.innerHTML = computeSlot(ids); + if (ids.some(id => dynamicIds.has(id))) el.innerHTML = computeSlot(ids); }); } -setInterval(refreshStatusBarTime, 30000); +setInterval(refreshStatusBarDynamic, 1000); function getHeaderRevealZonePct() { return prefs.headerRevealZonePct ?? 12; @@ -2698,6 +2716,7 @@ async function returnToLibrary() { clearInterruptedSession(); cancelDebouncedSync(); stopPeriodicSync(); + stopBookStatsTracking(); if (!prefs.skipSaveOnClose) { await saveProgress(); // await so updated_at is committed before library reloads } @@ -4159,6 +4178,136 @@ function pushInternalProgress(docKey, xpointer, pct, force = false) { }).catch(() => {}); } +function bookStatsStorageKey(hash, field) { + return `br_bo_stats_${field}_${hash || ''}`; +} + +function loadBookStatsTotal(hash, field) { + try { return Math.max(0, parseInt(localStorage.getItem(bookStatsStorageKey(hash, field)) || '0', 10) || 0); } + catch { return 0; } +} + +function saveBookStatsTotal(hash, field, value) { + try { localStorage.setItem(bookStatsStorageKey(hash, field), String(Math.max(0, value))); } catch { /* ignore */ } +} + +function startBookStatsTracking() { + const docKey = currentBook ? externalDocKey() : ''; + statsSessionStartTs = Math.floor(Date.now() / 1000); + activeReadingSeconds = 0; + statsPagesDelta = 0; + stopBookStatsTracking(); + statsTimer = setInterval(() => { + if (!document.hidden && isReady && currentBook) activeReadingSeconds++; + }, 1000); + if (docKey) { + // Ensure local cumulative counters exist for GREATEST-based BookOrbit upserts. + loadBookStatsTotal(docKey, 'secs'); + loadBookStatsTotal(docKey, 'pages'); + } +} + +function stopBookStatsTracking() { + if (statsTimer) { + clearInterval(statsTimer); + statsTimer = null; + } +} + +function estimateBookPageCount() { + const locLen = book?.locations?.length?.() || 0; + if (locLen > 0) return Math.max(1, Math.round(currentPct * locLen) || 1); + return Math.max(1, statsPagesDelta || sessionPageCount || 1); +} + +function estimateBookTotalPages() { + const locLen = book?.locations?.length?.() || 0; + return Math.max(estimateBookPageCount(), locLen || 100); +} + +function buildBookOrbitStatsPayload() { + if (!currentBook || activeReadingSeconds <= 0) return null; + const docKey = externalDocKey(); + const now = Math.floor(Date.now() / 1000); + const sessionStart = statsSessionStartTs || now; + const duration = Math.max(1, activeReadingSeconds); + const totalReadSecs = loadBookStatsTotal(docKey, 'secs') + duration; + const totalReadPages = loadBookStatsTotal(docKey, 'pages') + Math.max(statsPagesDelta, 0); + return { + books: [{ + document: docKey, + md5: docKey, + title: currentBook.title || '', + authors: currentBook.author || '', + total_read_secs: totalReadSecs, + total_read_pages: totalReadPages, + last_open: now, + page_sessions: [{ + page: estimateBookPageCount(), + start_time: sessionStart, + duration, + total_pages: estimateBookTotalPages(), + }], + }], + timestamp: now, + device: 'web', + device_id: 'codexa-web', + }; +} + +function commitBookOrbitStats(payload) { + const bookEntry = payload?.books?.[0]; + if (!bookEntry || !currentBook) return; + const docKey = externalDocKey(); + if (typeof bookEntry.total_read_secs === 'number') saveBookStatsTotal(docKey, 'secs', bookEntry.total_read_secs); + if (typeof bookEntry.total_read_pages === 'number') saveBookStatsTotal(docKey, 'pages', bookEntry.total_read_pages); + activeReadingSeconds = 0; + statsPagesDelta = 0; + statsSessionStartTs = Math.floor(Date.now() / 1000); +} + +function pushRemoteStats(payload) { + return apiFetch('/kosync/remote/stats', { + method: 'POST', + body: JSON.stringify(payload), + }).catch((e) => { + console.warn('[stats] BookOrbit stats push failed:', e.message); + return null; + }); +} + +function pushRemoteStatsBackground(payload) { + const token = getToken(); + const headers = { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + return fetch('/api/kosync/remote/stats', { + method: 'POST', + headers, + body: JSON.stringify(payload), + keepalive: true, + }).then(async (r) => { + let data = { pushed: r.ok, status: r.status }; + try { data = { ...data, ...(await r.json()) }; } catch { /* ignore */ } + return data; + }).catch(() => null); +} + +function markRemoteSynced() { + lastSyncedAt = Date.now(); + refreshStatusBarDynamic(); +} + +async function loadKosyncSettings() { + try { + const s = await apiFetch('/settings'); + kosyncStatsEnabled = !!s.kosync_stats_enabled; + } catch { + kosyncStatsEnabled = false; + } +} + function cancelDebouncedSync() { if (syncDebounceTimer) { clearTimeout(syncDebounceTimer); @@ -4245,10 +4394,20 @@ async function saveProgress({ forceRemote = false, allowRemote = true, inSession saves.push(pushRemoteProgress(docKey, koReaderXPointer(), pct)); saves.push(pushInternalProgress(docKey, koReaderXPointer(), pct, forced)); } + const statsPayload = kosyncStatsEnabled && (inSession || forceRemote) ? buildBookOrbitStatsPayload() : null; + if (statsPayload) { + saves.push(pushRemoteStats(statsPayload).then((res) => { + if (res?.pushed) { + commitBookOrbitStats(statsPayload); + markRemoteSynced(); + } + })); + } await Promise.allSettled(saves); if (shouldPushKosync) { lastSyncedCfi = cfi; // record what we just synced bestKnownRemotePct = pct; // update high-water mark (may go down if user confirmed backwards) + markRemoteSynced(); } } @@ -4277,6 +4436,14 @@ function saveProgressBackground({ inSession = false } = {}) { if (!alreadySynced && pct > 0 && (inSession || !prefs.skipSaveOnClose) && (posChanged || inSession) && pct >= bestKnownRemotePct - 0.005) { fetch(`/api/kosync/remote/${encodeURIComponent(docKey)}`, opts({ document: docKey, progress: xp, percentage: pct, device: 'web', device_id: 'codexa-web' })).catch(() => {}); fetch(`/api/kosync/internal/${encodeURIComponent(docKey)}`, opts({ progress: xp, percentage: pct, device: 'web', device_id: 'codexa-web' })).catch(() => {}); + if (kosyncStatsEnabled) { + const statsPayload = buildBookOrbitStatsPayload(); + if (statsPayload) { + pushRemoteStatsBackground(statsPayload).then((res) => { + if (res?.pushed) commitBookOrbitStats(statsPayload); + }); + } + } if (pct > bestKnownRemotePct) bestKnownRemotePct = pct; } } @@ -4563,6 +4730,7 @@ function goNext() { pendingNavDirection = 'next'; sessionPageCount++; + statsPagesDelta++; // Capture whether we are truly at the last page at call-time. The library can // advance the chapter prematurely if a CSS expand shrinks scrollWidth between // this call and when manager.next() actually runs (async via rAF queue). @@ -4583,6 +4751,7 @@ function goPrev() { console.log(`[nav] PREV | page=${currentChapPage}/${currentChapTotal}`); pendingNavDirection = 'prev'; sessionPageCount++; + statsPagesDelta++; rendition?.prev(); } @@ -5518,11 +5687,12 @@ document.querySelector('.nav-zone-next')?.addEventListener('click', e => { e.sto document.getElementById('btn-prev').addEventListener('keydown', e => { if (e.key === 'Enter') goPrev(); }); document.getElementById('btn-next').addEventListener('keydown', e => { if (e.key === 'Enter') goNext(); }); window.addEventListener('beforeunload', () => { + cancelDebouncedSync(); + stopPeriodicSync(); + stopBookStatsTracking(); if (isPeekMode && currentBook) { try { sessionStorage.setItem('br_last_peek_book_id', String(currentBook.id)); } catch { /* ignore */ } } - cancelDebouncedSync(); - stopPeriodicSync(); if (!prefs.skipSaveOnClose) saveProgressBackground(); endStatsSessionBackground(); }); @@ -5572,6 +5742,7 @@ async function init() { }, 600); } acquireWakeLock(); + await loadKosyncSettings(); try { loadingMsg.textContent = t('reader.loading_book'); @@ -5689,9 +5860,11 @@ async function init() { // Seed the high-water mark so we never push below what the server already has if (localProgress.percentage > bestKnownRemotePct) bestKnownRemotePct = localProgress.percentage; // Keep the offline metadata in sync so "Currently Reading" is correct offline - getBookMeta(Number(bookId)).then(meta => { - if (meta) saveBookMeta({ ...meta, percentage: localProgress.percentage }).catch(() => {}); - }).catch(() => {}); + if (!isPeekMode) { + getBookMeta(Number(bookId)).then(meta => { + if (meta) saveBookMeta({ ...meta, percentage: localProgress.percentage }).catch(() => {}); + }).catch(() => {}); + } } if (!startCfi && localProgress?.cfi_position) startCfi = localProgress.cfi_position; // Pre-load locations from cache so seekToPercentage works immediately after startRendition @@ -5847,7 +6020,8 @@ async function init() { void loadBookmarks(currentBook.id); void loadAnnotations(currentBook.id); // Start a stats session (non-blocking) - void startStatsSession(currentBook.id); + void startStatsSession(currentBook.id); + if (kosyncStatsEnabled) startBookStatsTracking(); // Final chapter-name refresh — by now TOC and relocated have both fired if (lastChapterHref) { chapterTitleEl.textContent = chapterLabelFromHref(lastChapterHref); @@ -5872,4 +6046,5 @@ document.addEventListener('langchange', () => { renderSbItems(); renderDictSettings(); populateSbFontSelect(); + refreshStatusBarDynamic(); }); diff --git a/public/js/settings.js b/public/js/settings.js index c7a695b..f0e8558 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -19,6 +19,7 @@ const kosyncStatus = document.getElementById('kosync-status'); const btnTestKosync = document.getElementById('btn-test-kosync'); const btnSaveKosync = document.getElementById('btn-save-kosync'); const btnClearKosync = document.getElementById('btn-clear-kosync'); +const kosyncStatsEnabled = document.getElementById('kosync-stats-enabled'); const kosyncInternalEnabled = document.getElementById('kosync-internal-enabled'); const kosyncInternalUrlBox = document.getElementById('kosync-internal-url-box'); const kosyncInternalUrlVal = document.getElementById('kosync-internal-url-val'); @@ -33,6 +34,7 @@ async function loadSettings() { // password is never returned; show placeholder when set kosyncPassword.placeholder = s.has_kosync_password ? t('settings.kosync_pass_saved') : t('settings.kosync_pass_ph'); updateStatusBadge(s.kosync_url ? null : 'not_configured'); + kosyncStatsEnabled.checked = s.kosync_stats_enabled || false; kosyncInternalEnabled.checked = s.kosync_internal_enabled || false; updateInternalUrlBox(); } catch (err) { @@ -82,7 +84,7 @@ btnTestKosync.addEventListener('click', async () => { setButtonLoading(btnTestKosync, true, t('settings.btn_testing')); try { // Save current form values first so the server-side test uses them - const body = { kosync_url: url, kosync_username: username }; + const body = { kosync_url: url, kosync_username: username, kosync_stats_enabled: kosyncStatsEnabled.checked }; if (password) body.kosync_password = password; await apiFetch('/settings', { method: 'PUT', body: JSON.stringify(body) }); if (password) { @@ -118,7 +120,7 @@ btnSaveKosync.addEventListener('click', async () => { setButtonLoading(btnSaveKosync, true, t('settings.btn_saving')); try { - const body = { kosync_url: url, kosync_username: username }; + const body = { kosync_url: url, kosync_username: username, kosync_stats_enabled: kosyncStatsEnabled.checked }; // Only send password if user typed something new if (password) body.kosync_password = password; @@ -143,7 +145,7 @@ btnClearKosync.addEventListener('click', () => { try { await apiFetch('/settings', { method: 'PUT', - body: JSON.stringify({ kosync_url: '', kosync_username: '', kosync_password: '' }), + body: JSON.stringify({ kosync_url: '', kosync_username: '', kosync_password: '', kosync_stats_enabled: false }), }); kosyncUrl.value = ''; kosyncUsername.value = ''; diff --git a/public/locales/en.json b/public/locales/en.json index 010e080..fce57be 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -246,6 +246,10 @@ "reader.sb_time_left_chap": "Time to end of chapter (HH:MM)", "reader.sb_time_left_book": "Time to end of book (HH:MM)", "reader.sb_current_time": "Current time", + "reader.sb_synced_ago": "Last sync", + "reader.sb_synced_ago_secs": "Synced {n}s ago", + "reader.sb_synced_ago_mins": "Synced {n}m ago", + "reader.sb_synced_ago_hours": "Synced {n}h ago", "reader.sb_book_title": "Book title", "reader.sb_book_author": "Book author", "reader.sb_chap_title": "Chapter title", @@ -290,6 +294,8 @@ "settings.kosync_pass_label": "Password", "settings.kosync_pass_ph": "Enter password…", "settings.kosync_pass_saved": "(password saved)", + "settings.kosync_stats_title": "Sync reading stats to BookOrbit KOReader Stats", + "settings.kosync_stats_hint": "When enabled, Codexa sends reading time and page sessions to your KOReader sync server (BookOrbit with stats support). Silently skipped if the server does not support stats.", "settings.btn_save_kosync": "Save settings", "settings.btn_test_kosync": "Test connection", "settings.btn_clear_kosync": "Remove", diff --git a/public/readerv4.html b/public/readerv4.html index a1a9fc1..497effc 100644 --- a/public/readerv4.html +++ b/public/readerv4.html @@ -988,7 +988,7 @@

Nastavitve branja

- + diff --git a/public/sw.js b/public/sw.js index 39c7558..c1f38c5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,7 +1,7 @@ // Codexa Service Worker // Caches app shell for offline use. EPUBs are cached on demand in BOOKS_CACHE. -const CACHE_VERSION = 'br-v32'; +const CACHE_VERSION = 'br-v30'; const BOOKS_CACHE = 'codexa-books-v2'; const APP_SHELL = [ '/', @@ -22,6 +22,7 @@ const APP_SHELL = [ '/js/library.js', '/js/sidebar.js', '/js/i18n.js', + '/js/sw-register.js', '/js/opds.js', '/js/reader_v4.js', '/js/flow/index.js', @@ -228,18 +229,16 @@ self.addEventListener('fetch', (e) => { return; } - // Cache-first for app shell assets + // Network-first for app shell — always fetch fresh HTML/JS/CSS when online. + // Cached copies are kept only for offline fallback (fixes stale reader after home refresh). e.respondWith( - caches.match(e.request).then(cached => { - const networkFetch = fetch(e.request).then(response => { - if (response.ok && e.request.method === 'GET') { - const clone = response.clone(); - caches.open(CACHE_VERSION).then(cache => cache.put(e.request, clone)); - } - return response; - }).catch(() => cached); - return cached || networkFetch; - }) + fetch(e.request).then((response) => { + if (response.ok && e.request.method === 'GET') { + const clone = response.clone(); + caches.open(CACHE_VERSION).then((cache) => cache.put(e.request, clone)); + } + return response; + }).catch(() => caches.match(e.request).then((r) => r || Response.error())) ); }); diff --git a/server/db.js b/server/db.js index 675e1df..c03ec45 100644 --- a/server/db.js +++ b/server/db.js @@ -45,6 +45,7 @@ function initDb() { kosync_username TEXT DEFAULT '', kosync_password_enc TEXT DEFAULT '', kosync_internal_enabled INTEGER DEFAULT 0, + kosync_stats_enabled INTEGER DEFAULT 0, reader_prefs TEXT DEFAULT '{}', FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); @@ -178,6 +179,7 @@ function initDb() { [`ALTER TABLE books ADD COLUMN pages TEXT DEFAULT ''`, 'books.pages'], [`ALTER TABLE user_settings ADD COLUMN kosync_internal_enabled INTEGER DEFAULT 0`, 'user_settings.kosync_internal_enabled'], [`ALTER TABLE books ADD COLUMN last_opened_at INTEGER`, 'books.last_opened_at'], + [`ALTER TABLE user_settings ADD COLUMN kosync_stats_enabled INTEGER DEFAULT 0`, 'user_settings.kosync_stats_enabled'], ]; for (const [sql, label] of migrations) { try { diff --git a/server/routes/kosync.js b/server/routes/kosync.js index 08247b0..e659e8b 100644 --- a/server/routes/kosync.js +++ b/server/routes/kosync.js @@ -160,7 +160,7 @@ proxyRouter.use(authenticateToken); function getExternalSettings(userId) { const db = getDb(); return db.prepare( - 'SELECT kosync_url, kosync_username, kosync_password_enc, kosync_internal_enabled FROM user_settings WHERE user_id = ?' + 'SELECT kosync_url, kosync_username, kosync_password_enc, kosync_internal_enabled, kosync_stats_enabled FROM user_settings WHERE user_id = ?' ).get(userId); } @@ -203,6 +203,40 @@ proxyRouter.get('/test', async (req, res) => { } }); +// POST /api/kosync/remote/stats +// Push reading statistics to BookOrbit (KOReader stats ingest — issue #28). +proxyRouter.post('/remote/stats', async (req, res) => { + const s = getExternalSettings(req.user.id); + if (!s?.kosync_stats_enabled) { + return res.json({ skipped: true, reason: 'disabled' }); + } + if (!s?.kosync_url) { + console.log('[kosync] remote stats POST: skipped — no kosync_url configured'); + return res.json({ skipped: true, reason: 'not_configured' }); + } + + const url = `${s.kosync_url.replace(/\/$/, '')}/stats`; + console.log('[kosync] remote stats POST:', url); + try { + const r = await fetch(url, { + method: 'POST', + headers: buildKoreaderHeaders(s.kosync_username, s.kosync_password_enc), + body: JSON.stringify(req.body), + signal: AbortSignal.timeout(8000), + }); + console.log('[kosync] remote stats POST response:', r.status); + if (r.status === 404) { + return res.json({ pushed: false, status: 404, unsupported: true }); + } + let data = { pushed: r.ok, status: r.status }; + try { data = { ...data, ...(await r.json()) }; } catch { /* ignore */ } + res.json(data); + } catch (err) { + console.warn('[kosync] stats push to external failed:', err.message); + res.json({ pushed: false, error: err.message }); + } +}); + // GET /api/kosync/remote/:document // Fetch progress from external kosync server for a given document hash. // Returns null (not an error) when external server is not configured or unreachable. diff --git a/server/routes/settings.js b/server/routes/settings.js index 69406c5..b19d37c 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -28,6 +28,7 @@ router.get('/', (req, res) => { kosync_username: row.kosync_username, has_kosync_password: row.kosync_password_enc !== '', kosync_internal_enabled: row.kosync_internal_enabled === 1, + kosync_stats_enabled: row.kosync_stats_enabled === 1, reader_prefs: JSON.parse(row.reader_prefs || '{}'), }); }); @@ -39,7 +40,7 @@ router.put('/', (req, res) => { if (!row) return res.status(404).json({ error: 'Settings not found' }); - const { opds_servers, kosync_url, kosync_username, kosync_password, kosync_internal_enabled, reader_prefs } = req.body; + const { opds_servers, kosync_url, kosync_username, kosync_password, kosync_internal_enabled, kosync_stats_enabled, reader_prefs } = req.body; // Only update fields that were explicitly provided const next = { @@ -49,6 +50,7 @@ router.put('/', (req, res) => { // Empty string means "clear password"; undefined means "keep existing" kosync_password_enc: kosync_password !== undefined ? String(kosync_password) : row.kosync_password_enc, kosync_internal_enabled: kosync_internal_enabled !== undefined ? (kosync_internal_enabled ? 1 : 0) : row.kosync_internal_enabled, + kosync_stats_enabled: kosync_stats_enabled !== undefined ? (kosync_stats_enabled ? 1 : 0) : row.kosync_stats_enabled, reader_prefs: reader_prefs !== undefined ? JSON.stringify(reader_prefs) : row.reader_prefs, }; @@ -59,6 +61,7 @@ router.put('/', (req, res) => { kosync_username = ?, kosync_password_enc = ?, kosync_internal_enabled = ?, + kosync_stats_enabled = ?, reader_prefs = ? WHERE user_id = ? `).run( @@ -67,6 +70,7 @@ router.put('/', (req, res) => { next.kosync_username, next.kosync_password_enc, next.kosync_internal_enabled, + next.kosync_stats_enabled, next.reader_prefs, req.user.id );