495 lines
25 KiB
JavaScript
495 lines
25 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { randomInt } from 'node:crypto';
|
|
import { nowIso, randomId, randomToken, tokenHash, safeEqualHex } from './security.mjs';
|
|
|
|
const dbTimeZones = new WeakMap();
|
|
|
|
export function resolveTimeZone(value) {
|
|
const timeZone = String(value || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC').trim();
|
|
try { new Intl.DateTimeFormat('en-US', { timeZone }).format(0); }
|
|
catch { throw new Error(`Invalid TZ value: ${timeZone}. Use an IANA time zone such as Europe/Rome.`); }
|
|
return timeZone;
|
|
}
|
|
|
|
function zonedDateFormatter(timeZone) {
|
|
const formatter = new Intl.DateTimeFormat('en-US', { timeZone, year:'numeric', month:'2-digit', day:'2-digit' });
|
|
return epochSeconds => {
|
|
const parts = Object.fromEntries(formatter.formatToParts(new Date(Number(epochSeconds) * 1000)).map(part => [part.type, part.value]));
|
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
|
};
|
|
}
|
|
|
|
function tableColumns(db, table) {
|
|
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(r => r.name));
|
|
}
|
|
|
|
function ensureColumn(db, table, column, definition) {
|
|
if (!tableColumns(db, table).has(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
}
|
|
|
|
export function openAppDb(dataDir, { timeZone } = {}) {
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
const db = new DatabaseSync(path.join(dataDir, 'kovi.sqlite'));
|
|
const resolvedTimeZone = resolveTimeZone(timeZone);
|
|
const localDay = zonedDateFormatter(resolvedTimeZone);
|
|
db.function('kovi_local_day', { deterministic:true }, localDay);
|
|
dbTimeZones.set(db, resolvedTimeZone);
|
|
db.exec(`
|
|
PRAGMA journal_mode=WAL;
|
|
PRAGMA foreign_keys=ON;
|
|
PRAGMA busy_timeout=5000;
|
|
|
|
CREATE TABLE IF NOT EXISTS books (
|
|
id TEXT PRIMARY KEY,
|
|
source_md5 TEXT NOT NULL UNIQUE,
|
|
title TEXT NOT NULL,
|
|
authors TEXT,
|
|
notes INTEGER NOT NULL DEFAULT 0,
|
|
last_open INTEGER NOT NULL DEFAULT 0,
|
|
highlights INTEGER NOT NULL DEFAULT 0,
|
|
pages INTEGER NOT NULL DEFAULT 0,
|
|
series TEXT,
|
|
language TEXT,
|
|
identifiers TEXT,
|
|
isbn TEXT,
|
|
total_read_time INTEGER NOT NULL DEFAULT 0,
|
|
total_read_pages INTEGER NOT NULL DEFAULT 0,
|
|
koreader_status TEXT CHECK(koreader_status IN ('reading','abandoned','complete')),
|
|
koreader_status_modified TEXT,
|
|
read_override INTEGER CHECK(read_override IN (0,1)),
|
|
read_override_at INTEGER,
|
|
cover_status TEXT NOT NULL DEFAULT 'pending' CHECK(cover_status IN ('pending','matched','none','error','manual')),
|
|
cover_path TEXT,
|
|
cover_source TEXT,
|
|
cover_retry_mode TEXT,
|
|
cover_checked_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS reading_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
fingerprint TEXT NOT NULL UNIQUE,
|
|
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
|
device_id TEXT,
|
|
page INTEGER NOT NULL DEFAULT 0,
|
|
start_time INTEGER NOT NULL,
|
|
duration INTEGER NOT NULL,
|
|
total_pages INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_book_start ON reading_sessions(book_id, start_time DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_start ON reading_sessions(start_time);
|
|
|
|
CREATE TABLE IF NOT EXISTS annotations (
|
|
id TEXT PRIMARY KEY,
|
|
fingerprint TEXT NOT NULL,
|
|
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
|
device_id TEXT NOT NULL,
|
|
annotation_type TEXT NOT NULL CHECK(annotation_type IN ('highlight','note','bookmark')),
|
|
text TEXT,
|
|
note TEXT,
|
|
chapter TEXT,
|
|
page TEXT,
|
|
pageno INTEGER,
|
|
total_pages INTEGER,
|
|
annotation_datetime TEXT,
|
|
color TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_annotations_source ON annotations(book_id, device_id, fingerprint);
|
|
CREATE INDEX IF NOT EXISTS idx_annotations_book_date ON annotations(book_id, annotation_datetime DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS imports (
|
|
id TEXT PRIMARY KEY,
|
|
source_hash TEXT NOT NULL UNIQUE,
|
|
kind TEXT NOT NULL,
|
|
filename TEXT,
|
|
file_size INTEGER NOT NULL DEFAULT 0,
|
|
books_seen INTEGER NOT NULL DEFAULT 0,
|
|
new_books INTEGER NOT NULL DEFAULT 0,
|
|
sessions_seen INTEGER NOT NULL DEFAULT 0,
|
|
new_sessions INTEGER NOT NULL DEFAULT 0,
|
|
warnings_json TEXT NOT NULL DEFAULT '[]',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS devices (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT,
|
|
model TEXT,
|
|
plugin_version TEXT,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
created_at TEXT NOT NULL,
|
|
last_seen_at TEXT,
|
|
last_sync_at TEXT,
|
|
sync_cursor INTEGER NOT NULL DEFAULT 0,
|
|
last_sync_mode TEXT,
|
|
last_sync_books INTEGER NOT NULL DEFAULT 0,
|
|
last_sync_stats INTEGER NOT NULL DEFAULT 0,
|
|
last_sync_new_sessions INTEGER NOT NULL DEFAULT 0,
|
|
last_sync_annotation_sets INTEGER NOT NULL DEFAULT 0,
|
|
last_sync_annotations INTEGER NOT NULL DEFAULT 0,
|
|
revoked_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
id TEXT PRIMARY KEY,
|
|
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
|
mode TEXT NOT NULL,
|
|
cursor_before INTEGER NOT NULL DEFAULT 0,
|
|
cursor_after INTEGER NOT NULL DEFAULT 0,
|
|
books_seen INTEGER NOT NULL DEFAULT 0,
|
|
sessions_seen INTEGER NOT NULL DEFAULT 0,
|
|
new_sessions INTEGER NOT NULL DEFAULT 0,
|
|
annotation_sets INTEGER NOT NULL DEFAULT 0,
|
|
annotations_stored INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_sync_runs_device_time ON sync_runs(device_id,created_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS cover_jobs (
|
|
book_id TEXT PRIMARY KEY REFERENCES books(id) ON DELETE CASCADE,
|
|
state TEXT NOT NULL CHECK(state IN ('queued','running','done','error')),
|
|
reason TEXT NOT NULL,
|
|
force_online INTEGER NOT NULL DEFAULT 0,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
requested_at TEXT NOT NULL,
|
|
started_at TEXT,
|
|
finished_at TEXT,
|
|
last_error TEXT,
|
|
result_status TEXT,
|
|
result_source TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cover_jobs_state_time ON cover_jobs(state,requested_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS book_covers (
|
|
id TEXT PRIMARY KEY,
|
|
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
|
path TEXT NOT NULL,
|
|
source TEXT,
|
|
strategy TEXT,
|
|
score REAL,
|
|
matched_title TEXT,
|
|
cover_status TEXT NOT NULL,
|
|
selected INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_book_covers_book_time ON book_covers(book_id,created_at DESC);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_book_covers_selected ON book_covers(book_id) WHERE selected=1;
|
|
|
|
CREATE TABLE IF NOT EXISTS cover_candidates (
|
|
id TEXT PRIMARY KEY,
|
|
book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE,
|
|
path TEXT NOT NULL,
|
|
source TEXT NOT NULL,
|
|
strategy TEXT,
|
|
score REAL NOT NULL DEFAULT 0,
|
|
title TEXT,
|
|
authors TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cover_candidates_book_time ON cover_candidates(book_id,created_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS pairing_codes (
|
|
code_hash TEXT PRIMARY KEY,
|
|
request_id TEXT,
|
|
label TEXT,
|
|
expires_at INTEGER NOT NULL,
|
|
used_at INTEGER,
|
|
paired_device_id TEXT
|
|
);
|
|
`);
|
|
|
|
// Additive in-place schema upgrades for existing databases.
|
|
ensureColumn(db, 'pairing_codes', 'request_id', 'TEXT');
|
|
ensureColumn(db, 'pairing_codes', 'paired_device_id', 'TEXT');
|
|
ensureColumn(db, 'books', 'identifiers', 'TEXT');
|
|
ensureColumn(db, 'books', 'isbn', 'TEXT');
|
|
ensureColumn(db, 'books', 'cover_retry_mode', 'TEXT');
|
|
ensureColumn(db, 'books', 'koreader_status', `TEXT CHECK(koreader_status IN ('reading','abandoned','complete'))`);
|
|
ensureColumn(db, 'books', 'koreader_status_modified', 'TEXT');
|
|
ensureColumn(db, 'books', 'read_override', 'INTEGER CHECK(read_override IN (0,1))');
|
|
ensureColumn(db, 'books', 'read_override_at', 'INTEGER');
|
|
ensureColumn(db, 'devices', 'last_sync_at', 'TEXT');
|
|
ensureColumn(db, 'devices', 'sync_cursor', 'INTEGER NOT NULL DEFAULT 0');
|
|
ensureColumn(db, 'devices', 'last_sync_mode', 'TEXT');
|
|
ensureColumn(db, 'devices', 'last_sync_books', 'INTEGER NOT NULL DEFAULT 0');
|
|
ensureColumn(db, 'devices', 'last_sync_stats', 'INTEGER NOT NULL DEFAULT 0');
|
|
ensureColumn(db, 'devices', 'last_sync_new_sessions', 'INTEGER NOT NULL DEFAULT 0');
|
|
ensureColumn(db, 'devices', 'last_sync_annotation_sets', 'INTEGER NOT NULL DEFAULT 0');
|
|
ensureColumn(db, 'devices', 'last_sync_annotations', 'INTEGER NOT NULL DEFAULT 0');
|
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_pairing_request_id ON pairing_codes(request_id) WHERE request_id IS NOT NULL;`);
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_books_isbn ON books(isbn) WHERE isbn IS NOT NULL;`);
|
|
|
|
// The same KOReader page-stat row can arrive via manual upload and via a paired
|
|
// device. Fingerprints differ by ingestion path, which would double-count identical
|
|
// reading sessions. Normalize once by the event's stable fields, then let SQLite
|
|
// enforce uniqueness forever.
|
|
const hasSessionEventIndex = db.prepare(`SELECT 1 ok FROM sqlite_master WHERE type='index' AND name='idx_sessions_event'`).get();
|
|
if (!hasSessionEventIndex) {
|
|
db.exec('BEGIN IMMEDIATE');
|
|
try {
|
|
db.exec(`DELETE FROM reading_sessions
|
|
WHERE rowid NOT IN (
|
|
SELECT MIN(rowid) FROM reading_sessions
|
|
GROUP BY book_id,page,start_time,duration,total_pages
|
|
)`);
|
|
db.exec(`CREATE UNIQUE INDEX idx_sessions_event ON reading_sessions(book_id,page,start_time,duration,total_pages);`);
|
|
db.exec('COMMIT');
|
|
} catch (e) { db.exec('ROLLBACK'); throw e; }
|
|
}
|
|
|
|
// Backfill a single selected history entry when no cover history is recorded.
|
|
const missingHistory=db.prepare(`SELECT id,cover_path,cover_source,cover_status,cover_checked_at,updated_at FROM books WHERE cover_path IS NOT NULL AND NOT EXISTS (SELECT 1 FROM book_covers c WHERE c.book_id=books.id)`).all();
|
|
const addHistory=db.prepare(`INSERT INTO book_covers(id,book_id,path,source,strategy,score,matched_title,cover_status,selected,created_at) VALUES(?,?,?,?,NULL,NULL,NULL,?,1,?)`);
|
|
for(const row of missingHistory) addHistory.run(randomId(16),row.id,row.cover_path,row.cover_source,row.cover_status,row.cover_checked_at||row.updated_at||nowIso());
|
|
return db;
|
|
}
|
|
|
|
function completionView(book) {
|
|
const total=Number(book.last_total_pages||book.pages||0),page=Number(book.last_page||0);
|
|
const inferred=total>0 && Math.round(page/total*100)>=100;
|
|
const isRead=book.read_override == null ? book.koreader_status==='complete'||inferred : Boolean(book.read_override);
|
|
const readSource=book.read_override != null?'manual':book.koreader_status==='complete'?'koreader':inferred?'progress':null;
|
|
return {...book,is_read:isRead?1:0,read_source:readSource};
|
|
}
|
|
|
|
export function listBooks(db) {
|
|
return db.prepare(`
|
|
SELECT b.*,
|
|
COALESCE((SELECT MAX(start_time) FROM reading_sessions s WHERE s.book_id=b.id), b.last_open) AS recent_read,
|
|
COALESCE((SELECT SUM(duration) FROM reading_sessions s WHERE s.book_id=b.id), 0) AS session_read_time,
|
|
(SELECT page FROM reading_sessions s WHERE s.book_id=b.id ORDER BY start_time DESC LIMIT 1) AS last_page,
|
|
(SELECT total_pages FROM reading_sessions s WHERE s.book_id=b.id ORDER BY start_time DESC LIMIT 1) AS last_total_pages,
|
|
(SELECT COUNT(*) FROM (SELECT fingerprint FROM annotations a WHERE a.book_id=b.id AND a.annotation_type IN ('highlight','note') GROUP BY fingerprint)) AS synced_highlights
|
|
FROM books b
|
|
ORDER BY recent_read DESC, title COLLATE NOCASE ASC
|
|
`).all().map(completionView);
|
|
}
|
|
|
|
export function getBook(db, id) {
|
|
const book = db.prepare(`
|
|
SELECT b.*,
|
|
(SELECT page FROM reading_sessions s WHERE s.book_id=b.id ORDER BY start_time DESC LIMIT 1) AS last_page,
|
|
(SELECT total_pages FROM reading_sessions s WHERE s.book_id=b.id ORDER BY start_time DESC LIMIT 1) AS last_total_pages,
|
|
(SELECT MAX(start_time) FROM reading_sessions s WHERE s.book_id=b.id) AS recent_read
|
|
FROM books b WHERE b.id=?
|
|
`).get(id);
|
|
if (!book) return null;
|
|
const annotations = db.prepare(`
|
|
SELECT fingerprint, annotation_type, text, note, chapter, page, pageno, total_pages, annotation_datetime, color,
|
|
MAX(created_at) AS synced_at
|
|
FROM annotations
|
|
WHERE book_id=? AND annotation_type IN ('highlight','note')
|
|
GROUP BY fingerprint
|
|
ORDER BY COALESCE(annotation_datetime,'') DESC, synced_at DESC
|
|
LIMIT 500
|
|
`).all(id);
|
|
return { ...completionView(book), annotations };
|
|
}
|
|
|
|
export function setBookReadOverride(db, id, read, nowEpoch=Math.floor(Date.now()/1000)) {
|
|
const value=read == null?null:read?1:0;
|
|
const completedAt=value===1?Math.max(0,Math.floor(Number(nowEpoch)||0)):null;
|
|
const result=db.prepare(`UPDATE books SET read_override=?,read_override_at=?,updated_at=? WHERE id=?`).run(value,completedAt,nowIso(),id);
|
|
return result.changes?getBook(db,id):null;
|
|
}
|
|
|
|
function dateKey(date) {
|
|
return `${date.getUTCFullYear()}-${String(date.getUTCMonth()+1).padStart(2,'0')}-${String(date.getUTCDate()).padStart(2,'0')}`;
|
|
}
|
|
|
|
function parseDateKey(value) {
|
|
const m=/^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value||''));
|
|
if(!m) return null;
|
|
const date=new Date(Date.UTC(Number(m[1]),Number(m[2])-1,Number(m[3]),12,0,0,0));
|
|
if(date.getUTCFullYear()!==Number(m[1]) || date.getUTCMonth()!==Number(m[2])-1 || date.getUTCDate()!==Number(m[3])) return null;
|
|
return date;
|
|
}
|
|
|
|
export function dashboardRange({from,to}={}, now=new Date(), timeZone=resolveTimeZone()) {
|
|
const today=parseDateKey(zonedDateFormatter(resolveTimeZone(timeZone))(now.getTime()/1000));
|
|
let end=parseDateKey(to) || today;
|
|
let start=parseDateKey(from);
|
|
if(!start){start=new Date(end);start.setUTCDate(start.getUTCDate()-364)}
|
|
if(start>end) [start,end]=[end,start];
|
|
return {from:dateKey(start),to:dateKey(end)};
|
|
}
|
|
|
|
function queryEpochBounds(start, end) {
|
|
// IANA UTC offsets are within one day; pad the indexed scan, then filter by local day.
|
|
const startEpoch=Date.UTC(start.getUTCFullYear(),start.getUTCMonth(),start.getUTCDate()-1)/1000;
|
|
const endExclusive=Date.UTC(end.getUTCFullYear(),end.getUTCMonth(),end.getUTCDate()+2)/1000;
|
|
return {startEpoch,endExclusive};
|
|
}
|
|
|
|
export function getDashboard(db, range={}) {
|
|
const selected=dashboardRange(range,new Date(),dbTimeZones.get(db));
|
|
const start=parseDateKey(selected.from);
|
|
const end=parseDateKey(selected.to);
|
|
const {startEpoch,endExclusive}=queryEpochBounds(start,end);
|
|
const totals = db.prepare(`SELECT COUNT(*) books, COALESCE(SUM(total_read_time),0) total_read_time, COALESCE(SUM(total_read_pages),0) total_read_pages, COALESCE(SUM(highlights),0) highlights FROM books`).get();
|
|
const sessions = db.prepare(`SELECT COUNT(*) sessions, COALESCE(SUM(duration),0) session_seconds FROM reading_sessions`).get();
|
|
const days = db.prepare(`
|
|
SELECT kovi_local_day(start_time) day, SUM(duration) seconds
|
|
FROM reading_sessions
|
|
WHERE start_time >= ? AND start_time < ?
|
|
GROUP BY day HAVING day >= ? AND day <= ? ORDER BY day
|
|
`).all(startEpoch,endExclusive,selected.from,selected.to);
|
|
const rangeSeconds=days.reduce((sum,row)=>sum+Number(row.seconds||0),0);
|
|
const allTimeReadingSeconds=Number(totals.total_read_time||0)>0 ? Number(totals.total_read_time) : Number(sessions.session_seconds||0);
|
|
const completionRows=db.prepare(`SELECT read_override,koreader_status,pages,
|
|
(SELECT page FROM reading_sessions s WHERE s.book_id=books.id ORDER BY start_time DESC LIMIT 1) last_page,
|
|
(SELECT total_pages FROM reading_sessions s WHERE s.book_id=books.id ORDER BY start_time DESC LIMIT 1) last_total_pages
|
|
FROM books`).all();
|
|
const readBooks=completionRows.reduce((count,book)=>count+completionView(book).is_read,0);
|
|
const rangedReadBooks=db.prepare(`
|
|
WITH book_dates AS (
|
|
SELECT b.id,
|
|
MIN(kovi_local_day(s.start_time)) AS started_on,
|
|
CASE
|
|
WHEN b.read_override=1 AND b.read_override_at IS NOT NULL THEN kovi_local_day(b.read_override_at)
|
|
WHEN b.read_override=1 THEN MAX(kovi_local_day(s.start_time))
|
|
WHEN b.read_override IS NOT NULL THEN NULL
|
|
WHEN b.koreader_status='complete' AND b.koreader_status_modified IS NOT NULL THEN b.koreader_status_modified
|
|
WHEN COALESCE((
|
|
SELECT ROUND(100.0*c.page/COALESCE(NULLIF(c.total_pages,0),NULLIF(b.pages,0)))
|
|
FROM reading_sessions c WHERE c.book_id=b.id ORDER BY c.start_time DESC LIMIT 1
|
|
),0)>=100 THEN (
|
|
SELECT MIN(kovi_local_day(c.start_time)) FROM reading_sessions c
|
|
WHERE c.book_id=b.id
|
|
AND COALESCE(NULLIF(c.total_pages,0),NULLIF(b.pages,0)) IS NOT NULL
|
|
AND ROUND(100.0*c.page/COALESCE(NULLIF(c.total_pages,0),NULLIF(b.pages,0)))>=100
|
|
)
|
|
END AS completed_on
|
|
FROM books b LEFT JOIN reading_sessions s ON s.book_id=b.id
|
|
GROUP BY b.id
|
|
)
|
|
SELECT COUNT(*) count FROM book_dates
|
|
WHERE started_on BETWEEN ? AND ? AND completed_on BETWEEN ? AND ? AND completed_on>=started_on
|
|
`).get(selected.from,selected.to,selected.from,selected.to).count;
|
|
return {
|
|
...totals,
|
|
read_books:readBooks,
|
|
...sessions,
|
|
all_time_reading_seconds:allTimeReadingSeconds,
|
|
reading_time_source:Number(totals.total_read_time||0)>0?'book_totals':'sessions',
|
|
range:{...selected,seconds:rangeSeconds,reading_days:days.filter(row=>Number(row.seconds||0)>0).length,read_books:Number(rangedReadBooks||0)},
|
|
days,
|
|
};
|
|
}
|
|
|
|
export function getCalendar(db, range={}) {
|
|
const selected=dashboardRange(range,new Date(),dbTimeZones.get(db));
|
|
const start=parseDateKey(selected.from),end=parseDateKey(selected.to);
|
|
const {startEpoch,endExclusive}=queryEpochBounds(start,end);
|
|
const rows=db.prepare(`
|
|
SELECT kovi_local_day(s.start_time) day,
|
|
b.id book_id,b.title,b.authors,b.cover_path,b.updated_at,
|
|
COALESCE(SUM(s.duration),0) seconds,COUNT(*) sessions
|
|
FROM reading_sessions s
|
|
JOIN books b ON b.id=s.book_id
|
|
WHERE s.start_time >= ? AND s.start_time < ?
|
|
GROUP BY day,b.id HAVING day >= ? AND day <= ?
|
|
ORDER BY day ASC,seconds DESC,b.title COLLATE NOCASE ASC
|
|
`).all(startEpoch,endExclusive,selected.from,selected.to);
|
|
const dayMap=new Map();
|
|
for(const row of rows){
|
|
let day=dayMap.get(row.day);
|
|
if(!day){day={day:row.day,seconds:0,sessions:0,books:[]};dayMap.set(row.day,day)}
|
|
const seconds=Number(row.seconds||0),sessions=Number(row.sessions||0);day.seconds+=seconds;day.sessions+=sessions;
|
|
day.books.push({id:row.book_id,title:row.title,authors:row.authors,cover_path:row.cover_path,updated_at:row.updated_at,seconds,sessions});
|
|
}
|
|
return {range:selected,days:[...dayMap.values()]};
|
|
}
|
|
|
|
export function createPairingCode(db, label = 'KOReader') {
|
|
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
let code = '';
|
|
for (let i = 0; i < 8; i++) code += alphabet[randomInt(alphabet.length)];
|
|
const hash = tokenHash(code);
|
|
const requestId = randomId(16);
|
|
const expiresAt = Date.now() + 10 * 60_000;
|
|
db.prepare(`INSERT INTO pairing_codes(code_hash,request_id,label,expires_at,used_at,paired_device_id) VALUES(?,?,?,?,NULL,NULL)`)
|
|
.run(hash, requestId, String(label).slice(0,80), expiresAt);
|
|
return { code, requestId, expiresAt };
|
|
}
|
|
|
|
export function getPairingStatus(db, requestId) {
|
|
const row = db.prepare(`SELECT request_id,label,expires_at,used_at,paired_device_id FROM pairing_codes WHERE request_id=?`).get(String(requestId || '').slice(0,80));
|
|
if (!row) return null;
|
|
if (!row.used_at && row.expires_at < Date.now()) return { status:'expired', expiresAt:row.expires_at };
|
|
if (!row.used_at) return { status:'pending', expiresAt:row.expires_at };
|
|
const device = row.paired_device_id ? db.prepare(`SELECT id,name,model,plugin_version,last_seen_at FROM devices WHERE id=?`).get(row.paired_device_id) : null;
|
|
return { status:'paired', expiresAt:row.expires_at, device:device || null };
|
|
}
|
|
|
|
export function consumePairingCode(db, code, { deviceId, model, version }) {
|
|
const hash = tokenHash(String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, ''));
|
|
const row = db.prepare(`SELECT * FROM pairing_codes WHERE code_hash=?`).get(hash);
|
|
if (!row || row.used_at || row.expires_at < Date.now()) return null;
|
|
const token = randomToken();
|
|
const tHash = tokenHash(token);
|
|
const id = String(deviceId || randomId(8)).slice(0,128);
|
|
const ts = nowIso();
|
|
db.exec('BEGIN IMMEDIATE');
|
|
try {
|
|
db.prepare(`UPDATE pairing_codes SET used_at=?,paired_device_id=? WHERE code_hash=?`).run(Date.now(), id, hash);
|
|
db.prepare(`INSERT INTO devices(id,name,model,plugin_version,token_hash,created_at,last_seen_at,revoked_at)
|
|
VALUES(?,?,?,?,?,?,?,NULL)
|
|
ON CONFLICT(id) DO UPDATE SET model=excluded.model, plugin_version=excluded.plugin_version, token_hash=excluded.token_hash, last_seen_at=excluded.last_seen_at, revoked_at=NULL`
|
|
).run(id, row.label, model || null, version || null, tHash, ts, ts);
|
|
db.exec('COMMIT');
|
|
} catch (e) { db.exec('ROLLBACK'); throw e; }
|
|
return { token, deviceId: id, requestId: row.request_id };
|
|
}
|
|
|
|
export function authenticateDevice(db, authorization) {
|
|
const m = /^Bearer\s+(.+)$/i.exec(authorization || '');
|
|
if (!m) return null;
|
|
const hash = tokenHash(m[1]);
|
|
const row = db.prepare(`SELECT * FROM devices WHERE token_hash=? AND revoked_at IS NULL`).get(hash);
|
|
if (!row || !safeEqualHex(hash, row.token_hash)) return null;
|
|
db.prepare(`UPDATE devices SET last_seen_at=? WHERE id=?`).run(nowIso(), row.id);
|
|
return row;
|
|
}
|
|
|
|
export function updateDevicePluginVersion(db, id, version) {
|
|
const v=String(version||'').trim().slice(0,40);
|
|
if(!id || !v) return false;
|
|
return db.prepare(`UPDATE devices SET plugin_version=?, last_seen_at=? WHERE id=?`).run(v,nowIso(),id).changes>0;
|
|
}
|
|
|
|
export function listDevices(db) {
|
|
return db.prepare(`SELECT id,name,model,plugin_version,created_at,last_seen_at,last_sync_at,sync_cursor,last_sync_mode,last_sync_books,last_sync_stats,last_sync_new_sessions,last_sync_annotation_sets,last_sync_annotations,revoked_at FROM devices ORDER BY COALESCE(last_seen_at,created_at) DESC`).all();
|
|
}
|
|
|
|
export function recordDeviceSync(db, deviceId, result, {mode='full',cursorBefore=0,cursorAfter=0}={}) {
|
|
const ts=nowIso();
|
|
const values=[ts,Math.max(0,Number(cursorAfter)||0),String(mode||'full').slice(0,24),Number(result.booksSeen||0),Number(result.sessionsSeen||0),Number(result.newSessions||0),Number(result.annotationBooks||0),Number(result.annotationsStored||0),deviceId];
|
|
db.prepare(`UPDATE devices SET last_sync_at=?,sync_cursor=?,last_sync_mode=?,last_sync_books=?,last_sync_stats=?,last_sync_new_sessions=?,last_sync_annotation_sets=?,last_sync_annotations=?,last_seen_at=? WHERE id=?`)
|
|
.run(...values.slice(0,8),ts,deviceId);
|
|
db.prepare(`INSERT INTO sync_runs(id,device_id,mode,cursor_before,cursor_after,books_seen,sessions_seen,new_sessions,annotation_sets,annotations_stored,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`)
|
|
.run(randomId(16),deviceId,String(mode||'full').slice(0,24),Math.max(0,Number(cursorBefore)||0),Math.max(0,Number(cursorAfter)||0),Number(result.booksSeen||0),Number(result.sessionsSeen||0),Number(result.newSessions||0),Number(result.annotationBooks||0),Number(result.annotationsStored||0),ts);
|
|
return {syncCursor:Math.max(0,Number(cursorAfter)||0),syncAt:ts};
|
|
}
|
|
|
|
export function getStatus(db) {
|
|
const library=db.prepare(`SELECT COUNT(*) books,COALESCE(SUM(total_read_time),0) reading_seconds,COALESCE(SUM(highlights),0) highlights FROM books`).get();
|
|
const sessions=db.prepare(`SELECT COUNT(*) sessions FROM reading_sessions`).get();
|
|
const annotations=db.prepare(`SELECT COUNT(*) annotations FROM annotations WHERE annotation_type IN ('highlight','note')`).get();
|
|
const covers=db.prepare(`SELECT cover_status status,COUNT(*) count FROM books GROUP BY cover_status`).all();
|
|
const sources=db.prepare(`SELECT COALESCE(cover_source,'none') source,COUNT(*) count FROM books GROUP BY COALESCE(cover_source,'none') ORDER BY count DESC`).all();
|
|
const coverJobs=db.prepare(`SELECT state,COUNT(*) count FROM cover_jobs GROUP BY state`).all();
|
|
const recentImports=db.prepare(`SELECT id,kind,filename,file_size,books_seen,new_books,sessions_seen,new_sessions,warnings_json,created_at FROM imports ORDER BY created_at DESC LIMIT 8`).all().map(r=>({...r,warnings:JSON.parse(r.warnings_json||'[]'),warnings_json:undefined}));
|
|
const recentSyncs=db.prepare(`SELECT s.*,d.name device_name,d.model device_model FROM sync_runs s LEFT JOIN devices d ON d.id=s.device_id ORDER BY s.created_at DESC LIMIT 12`).all();
|
|
return {library:{...library,...sessions,...annotations},covers:{statuses:covers,sources,jobs:coverJobs},devices:listDevices(db),recentImports,recentSyncs};
|
|
}
|
|
|
|
export function revokeDevice(db, id) {
|
|
return db.prepare(`UPDATE devices SET revoked_at=? WHERE id=?`).run(nowIso(), id).changes > 0;
|
|
}
|