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
60 changes: 50 additions & 10 deletions src/connectors/bookorbit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,21 @@ export async function bookorbitStatsJson(cred: Credential, http: HttpTransport,
const c = parseCred(cred);
if (!c) throw new ConnectorOperationError('invalid BookOrbit credentials', false, true);
const res = await call(http, c, 'GET', path);
if (res.status < 200 || res.status >= 300) {
throw new ConnectorOperationError(`BookOrbit request failed (${res.status})`,
res.status === 429 || res.status >= 500, res.status === 401 || res.status === 403);
}
if (res.status < 200 || res.status >= 300) throw requestFailed(res.status);
return res.json();
}

async function routeMissing(res: Awaited<ReturnType<HttpTransport>>): Promise<boolean> {
if (res.status !== 404) return false;
const body = await res.json().catch(() => null) as { message?: unknown } | null;
return typeof body?.message === 'string' && body.message.startsWith('Cannot ');
}

function requestFailed(status: number): ConnectorOperationError {
return new ConnectorOperationError(`BookOrbit request failed (${status})`,
status === 429 || status >= 500, status === 401 || status === 403);
}

/** Save a cumulative session checkpoint with a provider-side retry identity. */
export async function saveBookorbitSession(cred: Credential, http: HttpTransport, file: string, body: unknown): Promise<void> {
const c = parseCred(cred);
Expand Down Expand Up @@ -226,8 +234,26 @@ interface FileProgress {

// The book-level endpoint only exposes the web reader's updatedAt. Plugin
// writes preserve that timestamp; the file endpoint also exposes textUpdatedAt.
// BookOrbit 3.7 limits /books/files/:id/progress to its iOS app, so use
// /reading-progress (3.6+) and fall back for servers without it.
async function fileProgress(cred: Credential, http: HttpTransport, file: string): Promise<FileProgress> {
const row = await bookorbitStatsJson(cred, http, `/books/files/${encodeURIComponent(file)}/progress`);
const c = parseCred(cred);
if (!c) throw new ConnectorOperationError('invalid BookOrbit credentials', false, true);
const res = await call(http, c, 'GET', `/reading-progress?fileId=${encodeURIComponent(file)}`);
let row;
if (await routeMissing(res)) {
row = await bookorbitStatsJson(cred, http, `/books/files/${encodeURIComponent(file)}/progress`);
} else {
if (res.status < 200 || res.status >= 300) throw requestFailed(res.status);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const entry = (await res.json().catch(() => null) as any)?.items?.[0];
row = entry && {
percentage: entry.percentage,
updatedAt: entry.updatedAt,
textUpdatedAt: entry.text?.updatedAt,
koreaderProgress: entry.koreader,
cfi: entry.text?.cfi,
};
}
if (!row || typeof row !== 'object' || Array.isArray(row) ||
typeof row.percentage !== 'number' || !Number.isFinite(row.percentage)) {
throw new ConnectorOperationError('BookOrbit returned invalid progress', true);
Expand Down Expand Up @@ -381,16 +407,30 @@ async function push(
if (Number.isFinite(updated) && Math.floor(updated / 1000) > ev.timestamp) return { ok: true };

// Enough precision that pulling our own write back reads as the same position, not an edit.
const body: Record<string, unknown> = { percentage: Math.round((finished ? 1 : pct) * 1_000_000) / 10_000 };
const percentage = Math.round((finished ? 1 : pct) * 1_000_000) / 10_000;
// KOReader-style XPointers are what BookOrbit stores for its KOReader devices;
// the CFI is what its web reader resumes from. A CFI miss still saves the rest.
let koreader: string | undefined;
let cfi: string | undefined;
if (ev.progress?.startsWith('/body')) {
const xpath = ev.progress;
body.koreaderProgress = xpath;
const at = await positionMap(http, c, m, file, ctx, (b) => epubPosition(b, xpath)).catch(() => null);
if (at) body.cfi = at.cfi;
koreader = xpath;
cfi = (await positionMap(http, c, m, file, ctx, (b) => epubPosition(b, xpath)).catch(() => null))?.cfi;
}
// editedAt makes BookOrbit itself reject the write if a newer position lands after the check
// above. The check compares whole seconds, so the event counts as the end of its second.
let res = await call(http, c, 'PUT', '/reading-progress', { writes: [{
fileId: Number(file), percentage, editedAt: new Date(ev.timestamp * 1000 + 999).toISOString(),
...(koreader && { koreader }), ...(cfi && { text: { cfi } }),
}] });
if (await routeMissing(res)) {
res = await call(http, c, 'POST', `/books/files/${encodeURIComponent(file)}/progress`,
{ percentage, ...(koreader && { koreaderProgress: koreader }), ...(cfi && { cfi }) });
} else if (res.status >= 200 && res.status < 300) {
const reply = await res.json().catch(() => null) as { results?: { status?: string }[] } | null;
if (reply?.results?.[0]?.status === 'superseded') return { ok: true };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
const saved = result((await call(http, c, 'POST', `/books/files/${encodeURIComponent(file)}/progress`, body)).status);
const saved = result(res.status);
if (!saved.ok || !finished) return saved;
return result((await call(http, c, 'PATCH', `/books/${encodeURIComponent(m.externalId)}/status`, { status: 'read' })).status);
} catch (err) {
Expand Down
25 changes: 13 additions & 12 deletions test/bookfusion-position.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { saveMatch, upsertAccount } from '../src/connectors/store.js';
import { pollAll, pollConnector } from '../src/connectors/fanin.js';
import { drainQueue } from '../src/connectors/runner.js';
import type { HttpTransport } from '../src/connectors/types.js';
import { DOC, makeTestApp, registerUser } from './helpers.js';
import { DOC, makeTestApp, orbitProgressList, registerUser } from './helpers.js';

// Small stored ZIP fixtures keep the EPUB source visible in the tests.
function archive(files: Record<string, string>): Buffer {
Expand Down Expand Up @@ -638,7 +638,7 @@ describe('bookorbit highlights', () => {
body: new ReadableStream({ start(controller) { controller.enqueue(epub()); controller.close(); } }),
};
if (url.endsWith('/books/12/annotations') && init.method === 'GET') return json(existing);
if (url.endsWith('/books/files/71/progress') && init.method === 'GET') return json({ percentage: 0, updatedAt: null });
if (url.endsWith('/reading-progress?fileId=71') && init.method === 'GET') return json(orbitProgressList({ percentage: 0, updatedAt: null }));
return json({});
};
return { http, calls };
Expand Down Expand Up @@ -691,8 +691,8 @@ describe('bookorbit highlights', () => {
const o = orbit();
const r = await bookorbitConnector.push(CRED, MATCH, { kind: 'progress', document: 'd', percentage: 0.423712, progress: XPATH, timestamp: 1 }, o.http);
expect(r.ok).toBe(true);
const save = o.calls.find((c) => c.method === 'POST' && c.url.endsWith('/books/files/71/progress'))!;
expect(JSON.parse(save.body!)).toEqual({ percentage: 42.3712, koreaderProgress: XPATH, cfi: (await epubPosition(epub(), XPATH)).cfi });
const save = o.calls.find((c) => c.method === 'PUT' && c.url.endsWith('/reading-progress'))!;
expect(JSON.parse(save.body!).writes[0]).toEqual({ fileId: 71, percentage: 42.3712, koreader: XPATH, editedAt: '1970-01-01T00:00:01.999Z', text: { cfi: (await epubPosition(epub(), XPATH)).cfi } });
});

it('keeps a redacted map so later pushes skip the download', async () => {
Expand All @@ -706,16 +706,17 @@ describe('bookorbit highlights', () => {
clearBookFusionEpubCache();
await bookorbitConnector.push(CRED, MATCH, ev, o.http, ctx);
expect(o.calls.filter((c) => c.url.endsWith('/serve'))).toHaveLength(1);
const saves = o.calls.filter((c) => c.method === 'POST' && c.url.endsWith('/books/files/71/progress'));
expect(JSON.parse(saves[1].body!).cfi).toBe((await epubPosition(epub(), XPATH)).cfi);
const saves = o.calls.filter((c) => c.method === 'PUT' && c.url.endsWith('/reading-progress'));
expect(JSON.parse(saves[1].body!).writes[0].text.cfi).toBe((await epubPosition(epub(), XPATH)).cfi);
});

it('pulls an exact position from the web reader\'s CFI or a KOReader XPath', async () => {
const row = (extra: object) => ({ percentage: 60, updatedAt: '2026-09-02T00:00:00Z', ...extra });
const pull = (rows: unknown) => {
const row = (extra: { cfi: string | null; koreaderProgress: string | null }) =>
({ percentage: 60, updatedAt: '2026-09-02T00:00:00Z', ...extra });
const pull = (rows: ReturnType<typeof row>) => {
const o = orbit();
const http: HttpTransport = async (url, init) => url.endsWith('/books/files/71/progress')
? { status: 200, text: async () => '', json: async () => rows } : o.http(url, init);
const http: HttpTransport = async (url, init) => url.endsWith('/reading-progress?fileId=71')
? { status: 200, text: async () => '', json: async () => orbitProgressList(rows) } : o.http(url, init);
return bookorbitConnector.pullProgress!(CRED, MATCH, http, 0);
};
const cfi = (await epubPosition(epub(), XPATH)).cfi;
Expand All @@ -730,8 +731,8 @@ describe('bookorbit highlights', () => {
it('is pulled when a reader asks for progress', async () => {
const o = orbit([{ id: 5, cfi: 'epubcfi(/8/4!/6/4,/2/1:0,/3:5)', text: 'bold tail' }]);
const cfi = (await epubPosition(epub(), XPATH)).cfi;
const http: HttpTransport = async (url, init) => url.endsWith('/books/files/71/progress')
? { status: 200, text: async () => '', json: async () => ({ percentage: 60, cfi, updatedAt: new Date().toISOString() }) }
const http: HttpTransport = async (url, init) => url.endsWith('/reading-progress?fileId=71')
? { status: 200, text: async () => '', json: async () => orbitProgressList({ percentage: 60, cfi, updatedAt: new Date().toISOString() }) }
: o.http(url, init);
const { app, db } = makeTestApp({}, { connectorTransport: http });
const { headers } = await registerUser(app);
Expand Down
12 changes: 6 additions & 6 deletions test/bookorbit-progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { bookorbitConnector } from '../src/connectors/bookorbit.js';
import { saveMatch, upsertAccount } from '../src/connectors/store.js';
import { upsertProgress } from '../src/routes/kosync.js';
import type { HttpTransport } from '../src/connectors/types.js';
import { DOC, makeTestApp, registerUser } from './helpers.js';
import { DOC, makeTestApp, orbitProgressList, registerUser } from './helpers.js';

beforeEach(() => { process.env.TOKEN_ENC_KEY = 'c'.repeat(64); resetEncryptionKeyCache(); });
afterEach(() => { delete process.env.TOKEN_ENC_KEY; resetEncryptionKeyCache(); });
Expand All @@ -26,9 +26,9 @@ function fixture(extra: object = {}) {
else if (url.endsWith('/auth/me')) body = { settings: { timezone: 'UTC' } };
else if (url.includes('/sessions?')) body = { items: [], total: 0, page: 1, pageSize: 100 };
else if (url.endsWith('/books/12')) body = { files: [{ id: 71, format: 'epub', role: 'primary' }] };
else if (url.endsWith('/books/files/71/progress') && init.method === 'GET') {
body = { percentage: 60, koreaderProgress: XPATH, updatedAt: new Date(WEB_TIME).toISOString(),
textUpdatedAt: new Date(PLUGIN_TIME).toISOString(), ...extra };
else if (url.endsWith('/reading-progress?fileId=71') && init.method === 'GET') {
body = orbitProgressList({ percentage: 60, koreaderProgress: XPATH, updatedAt: new Date(WEB_TIME).toISOString(),
textUpdatedAt: new Date(PLUGIN_TIME).toISOString(), ...extra });
} else if (url.endsWith('/annotations')) body = [];
else throw new Error(`Unexpected request: ${init.method} ${url}`);
return { status: 200, json: async () => body, text: async () => JSON.stringify(body) };
Expand Down Expand Up @@ -76,9 +76,9 @@ it('does not import an older text position because a newer non-text save changed
expect(await bookorbitConnector.pullProgress!(f.cred, MATCH, f.http, DEVICE_TIME)).toBeNull();
});

it.each([null, [], {}, { percentage: '60' }])('does not push when file progress is malformed: %j', async (body) => {
it.each([null, [], {}, { percentage: '60' }, { items: [] }, { items: [{ percentage: '60' }] }])('does not push when file progress is malformed: %j', async (body) => {
const f = fixture();
const http: HttpTransport = (url, init) => url.endsWith('/progress')
const http: HttpTransport = (url, init) => url.includes('/reading-progress')
? Promise.resolve({ status: 200, json: async () => body, text: async () => JSON.stringify(body) })
: f.http(url, init);
expect(await bookorbitConnector.push(f.cred, MATCH, { kind: 'progress', document: DOC, percentage: 0.1,
Expand Down
6 changes: 3 additions & 3 deletions test/bookorbit-pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { scheduleMicroblogClippings } from '../src/connectors/microblog-clipping
import { resetEncryptionKeyCache } from '../src/crypto/secrets.js';
import { upsertProgress } from '../src/routes/kosync.js';
import type { HttpTransport, InboundHighlight } from '../src/connectors/types.js';
import { DOC, makeTestApp, registerUser } from './helpers.js';
import { DOC, makeTestApp, orbitProgressList, registerUser } from './helpers.js';

beforeEach(() => { process.env.TOKEN_ENC_KEY = 'c'.repeat(64); resetEncryptionKeyCache(); });
afterEach(() => { delete process.env.TOKEN_ENC_KEY; resetEncryptionKeyCache(); vi.restoreAllMocks(); });
Expand All @@ -23,8 +23,8 @@ async function fixture() {
if (url.endsWith('/auth/login')) body = { accessToken: 'token' };
else if (url.endsWith('/auth/me')) body = { settings: { timezone: 'UTC' } };
else if (url.includes('/sessions?')) body = { items: [], total: 0, page: 1, pageSize: 100 };
else if (url.endsWith('/books/files/71/progress')) body = { percentage: 8.42,
koreaderProgress: '/body/DocFragment[17]/body/p[2]', updatedAt: new Date(REMOTE_TIME * 1000).toISOString() };
else if (url.endsWith('/reading-progress?fileId=71')) body = orbitProgressList({ percentage: 8.42,
koreaderProgress: '/body/DocFragment[17]/body/p[2]', updatedAt: new Date(REMOTE_TIME * 1000).toISOString() });
else if (url.endsWith('/annotations')) body = [];
else throw new Error(`Unexpected request: ${url}`);
return { status: 200, json: async () => body, text: async () => JSON.stringify(body) };
Expand Down
Loading