diff --git a/src/connectors/bookorbit.ts b/src/connectors/bookorbit.ts index 01b7e8c..a7ab9d6 100644 --- a/src/connectors/bookorbit.ts +++ b/src/connectors/bookorbit.ts @@ -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>): Promise { + 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 { const c = parseCred(cred); @@ -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 { - 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); + 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); @@ -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 = { 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 }; } - 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) { diff --git a/test/bookfusion-position.test.ts b/test/bookfusion-position.test.ts index c17d744..441e7f4 100644 --- a/test/bookfusion-position.test.ts +++ b/test/bookfusion-position.test.ts @@ -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): Buffer { @@ -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 }; @@ -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 () => { @@ -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) => { 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; @@ -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); diff --git a/test/bookorbit-progress.test.ts b/test/bookorbit-progress.test.ts index f9b8ede..9f457bb 100644 --- a/test/bookorbit-progress.test.ts +++ b/test/bookorbit-progress.test.ts @@ -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(); }); @@ -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) }; @@ -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, diff --git a/test/bookorbit-pull.test.ts b/test/bookorbit-pull.test.ts index d4420bb..a6d9f6e 100644 --- a/test/bookorbit-pull.test.ts +++ b/test/bookorbit-pull.test.ts @@ -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(); }); @@ -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) }; diff --git a/test/connectors-more.test.ts b/test/connectors-more.test.ts index 08fb779..a25cb00 100644 --- a/test/connectors-more.test.ts +++ b/test/connectors-more.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { DOC, makeTestApp, registerUser } from './helpers.js'; +import { DOC, makeTestApp, orbitProgressList, orbitWrite, registerUser } from './helpers.js'; import { resetEncryptionKeyCache } from '../src/crypto/secrets.js'; import { SAVE_CREDENTIAL, type HttpTransport } from '../src/connectors/types.js'; import { drainQueue } from '../src/connectors/runner.js'; @@ -972,13 +972,14 @@ describe('bookorbit connector', () => { it('pushes percentage and XPointer, and marks finished books read', async () => { const fake = fakeTransport(); fake.on('/auth/login', 200, LOGIN); - fake.on('/books/files/71/progress', 200, { percentage: 0, updatedAt: null }); + fake.on('/reading-progress?fileId=71', 200, orbitProgressList({ percentage: 0, updatedAt: null })); const m = { externalId: '12', externalEdition: '71', confidence: 1 }; const r1 = await bookorbitConnector.push(cred('p'), m, { kind: 'progress', document: 'd', percentage: 0.4237, progress: '/body/DocFragment[3]/body/p[2]', timestamp: 1 }, fake.transport); expect(r1.ok).toBe(true); - const save = fake.calls.find((c) => c.method === 'POST' && c.url.endsWith('/api/v1/books/files/71/progress'))!; - expect(JSON.parse(save.body!)).toEqual({ percentage: 42.37, koreaderProgress: '/body/DocFragment[3]/body/p[2]' }); + const save = fake.calls.find((c) => c.method === 'PUT' && c.url.endsWith('/api/v1/reading-progress'))!; + expect(orbitWrite(save.body)).toEqual({ fileId: 71, percentage: 42.37, koreader: '/body/DocFragment[3]/body/p[2]', editedAt: '1970-01-01T00:00:01.999Z' }); + expect(fake.calls.some((c) => c.url.includes('/books/files/71/progress'))).toBe(false); expect(fake.calls.some((c) => c.url.endsWith('/status'))).toBe(false); const r2 = await bookorbitConnector.push(cred('p'), m, { kind: 'finished', document: 'd', percentage: 1, timestamp: 2 }, fake.transport); @@ -993,13 +994,13 @@ describe('bookorbit connector', () => { it('logs in again when the cached token is rejected', async () => { const fake = fakeTransport(); fake.on('/auth/login', 200, LOGIN); - fake.on('/books/files/71/progress', 200, { percentage: 0, updatedAt: null }); + fake.on('/reading-progress?fileId=71', 200, orbitProgressList({ percentage: 0, updatedAt: null })); const m = { externalId: '12', externalEdition: '71', confidence: 1 }; const ev = { kind: 'progress' as const, document: 'd', percentage: 0.1, timestamp: 1 }; await bookorbitConnector.push(cred('r'), m, ev, fake.transport); let rejected = false; const t: HttpTransport = async (url, init) => { - if (url.includes('/progress') && !rejected) { rejected = true; return { status: 401, text: async () => '', json: async () => ({}) }; } + if (url.includes('/reading-progress') && !rejected) { rejected = true; return { status: 401, text: async () => '', json: async () => ({}) }; } return fake.transport(url, init); }; const r = await bookorbitConnector.push(cred('r'), m, ev, t); @@ -1010,7 +1011,7 @@ describe('bookorbit connector', () => { it.each(['progress', 'finished'] as const)('does not overwrite newer BookOrbit progress with a queued %s event', async (kind) => { const fake = fakeTransport(); fake.on('/auth/login', 200, LOGIN); - fake.on('/books/files/71/progress', 200, { percentage: 8.42, updatedAt: '2026-10-22T21:58:01Z' }); + fake.on('/reading-progress?fileId=71', 200, orbitProgressList({ percentage: 8.42, updatedAt: '2026-10-22T21:58:01Z' })); const result = await bookorbitConnector.push(cred(`stale-${kind}`), { externalId: '12', externalEdition: '71', confidence: 1 }, { kind, document: 'd', percentage: kind === 'finished' ? 1 : 0.06734, timestamp: Date.parse('2026-10-22T21:27:32Z') / 1000 }, fake.transport); expect(result.ok).toBe(true); @@ -1020,7 +1021,7 @@ describe('bookorbit connector', () => { it('retries without writing when remote progress cannot be checked', async () => { const fake = fakeTransport(); fake.on('/auth/login', 200, LOGIN); - fake.on('/books/files/71/progress', 503, {}); + fake.on('/reading-progress?fileId=71', 503, {}); const result = await bookorbitConnector.push(cred('unavailable'), { externalId: '12', externalEdition: '71', confidence: 1 }, { kind: 'progress', document: 'd', percentage: 0.1, timestamp: 1 }, fake.transport); expect(result).toMatchObject({ ok: false, retryable: true }); @@ -1030,17 +1031,51 @@ describe('bookorbit connector', () => { it('allows a newer local position even when it moves backwards', async () => { const fake = fakeTransport(); fake.on('/auth/login', 200, LOGIN); - fake.on('/books/files/71/progress', 200, { percentage: 8.42, updatedAt: '2026-10-22T21:58:01Z' }); + fake.on('/reading-progress?fileId=71', 200, orbitProgressList({ percentage: 8.42, updatedAt: '2026-10-22T21:58:01Z' })); expect(await bookorbitConnector.push(cred('newer-local'), { externalId: '12', externalEdition: '71', confidence: 1 }, { kind: 'progress', document: 'd', percentage: 0.05, timestamp: Date.parse('2026-10-22T22:00:00Z') / 1000 }, fake.transport)) .toEqual({ ok: true }); - expect(JSON.parse(fake.calls.find((c) => c.method === 'POST' && c.url.endsWith('/books/files/71/progress'))!.body!)).toEqual({ percentage: 5 }); + expect(orbitWrite(fake.calls.find((c) => c.method === 'PUT' && c.url.endsWith('/reading-progress'))!.body)).toEqual({ fileId: 71, percentage: 5, editedAt: '2026-10-22T22:00:00.999Z' }); + }); + + it('treats a write BookOrbit supersedes with a newer position as done, without marking the book read', async () => { + const fake = fakeTransport(); + fake.on('/auth/login', 200, LOGIN); + fake.on('/reading-progress?fileId=71', 200, orbitProgressList({ percentage: 0, updatedAt: null })); + fake.on('"writes"', 200, { results: [{ fileId: 71, status: 'superseded', progress: {} }] }); + expect(await bookorbitConnector.push(cred('race'), { externalId: '12', externalEdition: '71', confidence: 1 }, + { kind: 'finished', document: 'd', percentage: 1, timestamp: 1 }, fake.transport)).toEqual({ ok: true }); + expect(fake.calls.some((c) => c.url.endsWith('/status'))).toBe(false); + }); + + it('falls back to the per-file progress routes on servers without /reading-progress', async () => { + const fake = fakeTransport(); + fake.on('/auth/login', 200, LOGIN); + fake.on('/books/files/71/progress', 200, { percentage: 0, updatedAt: null }); + fake.on('/reading-progress', 404, { statusCode: 404, message: 'Cannot GET /api/v1/reading-progress' }); + const m = { externalId: '12', externalEdition: '71', confidence: 1 }; + expect(await bookorbitConnector.push(cred('legacy'), m, + { kind: 'progress', document: 'd', percentage: 0.25, progress: '/body/p[1]', timestamp: 1 }, fake.transport)).toEqual({ ok: true }); + const save = fake.calls.find((c) => c.method === 'POST' && c.url.endsWith('/api/v1/books/files/71/progress'))!; + expect(JSON.parse(save.body!)).toEqual({ percentage: 25, koreaderProgress: '/body/p[1]' }); + }); + + it.each(['GET', 'PUT'])('does not fall back or ask for reauth when %s /reading-progress 404s for a missing file', async (method) => { + const fake = fakeTransport(); + fake.on('/auth/login', 200, LOGIN); + fake.on('/reading-progress?fileId=71', 200, orbitProgressList({ percentage: 0, updatedAt: null })); + fake.on(method === 'GET' ? '/reading-progress?fileId=71' : '"writes"', 404, { statusCode: 404, message: 'No file with id 71' }); + const r = await bookorbitConnector.push(cred(`gone-${method}`), { externalId: '12', externalEdition: '71', confidence: 1 }, + { kind: 'progress', document: 'd', percentage: 0.1, timestamp: 1 }, fake.transport); + expect(r).toMatchObject({ ok: false, retryable: false }); + expect(r.needsReauth).toBeFalsy(); + expect(fake.calls.some((c) => c.url.includes('/books/files/71/progress'))).toBe(false); }); it('pulls newer progress for a matched book', async () => { const fake = fakeTransport(); fake.on('/auth/login', 200, LOGIN); - fake.on('/books/files/71/progress', 200, { percentage: 55, updatedAt: '2026-09-01T00:00:00Z' }); + fake.on('/reading-progress?fileId=71', 200, orbitProgressList({ percentage: 55, updatedAt: '2026-09-01T00:00:00Z' })); const m = { externalId: '12', externalEdition: '71', confidence: 1 }; const since = Date.parse('2026-08-31T00:00:00Z'); const ch = await bookorbitConnector.pullProgress!(cred('pull'), m, fake.transport, since); diff --git a/test/helpers.ts b/test/helpers.ts index 782c791..a7ecdd1 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -59,3 +59,22 @@ export async function registerUser( } export const DOC = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; + +/** A BookOrbit `GET /reading-progress?fileId=71` response holding the given file progress. */ +export function orbitProgressList(row: { + percentage: number; updatedAt?: string | null; textUpdatedAt?: string | null; + cfi?: string | null; koreaderProgress?: string | null; +}) { + return { items: [{ + fileId: 71, percentage: row.percentage, updatedAt: row.updatedAt ?? null, + text: { cfi: row.cfi ?? null, page: null, updatedAt: row.textUpdatedAt ?? null }, + narration: { seconds: null, fragment: null, sectionIndex: null, percentage: null, updatedAt: null }, + kobo: { source: null, type: null, value: null, contentPercent: null }, + koreader: row.koreaderProgress ?? null, + }] }; +} + +/** The single write in a BookOrbit `PUT /reading-progress` request body. */ +export function orbitWrite(body: string | undefined): Record { + return JSON.parse(body!).writes[0]; +}