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 @@