Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ <h2 data-i18n="settings.kosync_title">Sinhronizacija KOReader</h2>
<label for="kosync-password" data-i18n="settings.kosync_pass_label">Geslo</label>
<input type="password" id="kosync-password" data-i18n-placeholder="settings.kosync_pass_ph" placeholder="Vnesite geslo…" autocomplete="new-password" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" />
</div>
<div style="display:flex;align-items:flex-start;gap:.85rem;margin:.75rem 0">
<label class="toggle" style="flex-shrink:0;margin-top:.15rem">
<input type="checkbox" id="kosync-stats-enabled">
<span class="toggle-slider"></span>
</label>
<div>
<div style="font-size:.875rem;font-weight:600;margin-bottom:.2rem" data-i18n="settings.kosync_stats_title">Sync reading stats to BookOrbit KOReader Stats</div>
<div class="settings-hint" style="margin:0" data-i18n="settings.kosync_stats_hint">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.</div>
</div>
</div>
<div class="card-actions kosync-actions">
<button id="btn-save-kosync" class="btn btn-primary" data-i18n="settings.btn_save_kosync">Shrani nastavitve</button>
<button id="btn-test-kosync" class="btn btn-secondary" data-i18n="settings.btn_test_kosync">Testiraj povezavo</button>
Expand Down
199 changes: 187 additions & 12 deletions public/js/reader_v4.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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';
Expand Down Expand Up @@ -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') },
];
}

Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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 '';
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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).
Expand All @@ -4583,6 +4751,7 @@ function goPrev() {
console.log(`[nav] PREV | page=${currentChapPage}/${currentChapTotal}`);
pendingNavDirection = 'prev';
sessionPageCount++;
statsPagesDelta++;
rendition?.prev();
}

Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -5572,6 +5742,7 @@ async function init() {
}, 600);
}
acquireWakeLock();
await loadKosyncSettings();

try {
loadingMsg.textContent = t('reader.loading_book');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -5872,4 +6046,5 @@ document.addEventListener('langchange', () => {
renderSbItems();
renderDictSettings();
populateSbFontSelect();
refreshStatusBarDynamic();
});
Loading