diff --git a/README.md b/README.md index 816cde6..3f5d687 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ and any KOReader device. only the sync-server URL. Same accounts, same auth, same endpoints as `sync.koreader.rocks`. - **Better multi-device sync.** Progress is stored per device and the newest position wins, fixing the ping-pong you get with stock kosync servers. -- **Server-side service connectors.** Link services such as Hardcover, Micro.blog, Audiobookshelf, BookOrbit, and Readwise Reader once; readers continue speaking standard KOSync while the server updates external reading state. +- **Server-side service connectors.** Link services such as Hardcover, Micro.blog, Audiobookshelf, BookOrbit, Readwise Reader, and Seekquel once; readers continue speaking standard KOSync while the server updates external reading state. - **Lossless CrossPoint sync.** An extended API carries the full CrossPoint position (spine, paragraph, anchor, page hints), not just a lossy xpath + percentage. - **Bookmarks, clippings, and reading stats.** Delta sync with tombstones for bookmarks and @@ -96,6 +96,7 @@ DATABASE_PATH=./data/crosspoint.db PORT=8080 node dist/index.js | `LEGACY_WEB` | _(unset)_ | The website is the CrossPoint Sync app at `/app/` (the old account pages redirect there; Kindle setup keeps its pages). Set `1` to keep the old server-rendered website instead. | | `HARDCOVER_CLIENT_ID` | CrossPoint Sync's app | Optional. Hardcover OAuth client id for "Connect Hardcover" sign-in. The built-in id is CrossPoint Sync's public Hardcover app and works for self-hosted servers too; set this only to use your own Hardcover developer app. | | `SEARCHAPI_KEY` | _(unset)_ | Optional, paid ([searchapi.io](https://www.searchapi.io)). Last fallback for print page counts: Amazon search + product "Print length", 2 requests per book, only for books the free sources miss. Cached forever. | +| `SEEKQUEL_URL` | `https://api.seekquel.app` | Optional. The Seekquel API the Seekquel connector links to and sends to. Only for testing against a local Seekquel. | | `CORS_ORIGINS` | `*` | Origins allowed to call the sync API from browsers (comma-separated). The default wildcard is safe: the API authenticates with headers, not cookies, and the web UI's cookie routes never get CORS headers. | ### Link Micro.blog diff --git a/app/src/Settings.jsx b/app/src/Settings.jsx index 228584e..3f89521 100644 --- a/app/src/Settings.jsx +++ b/app/src/Settings.jsx @@ -44,6 +44,7 @@ const HINTS = { bookfusion: 'Syncs reading progress with BookFusion. You approve the request on bookfusion.com.', audiobookshelf: 'Keeps your place between the ebook and the audiobook. Create an API key in Audiobookshelf under Settings, Users, API Keys.', bookorbit: 'Syncs progress both ways with your BookOrbit server, and adds your clippings as highlights.', + seekquel: 'Logs your reading in your Seekquel library: where you are in each book, the books you finish, and your highlights. You approve the request in the Seekquel app.', spotify: 'Moves your progress forward when you listen ahead in the Spotify audiobook, and resumes it there. Audiobooks are in the US, UK, Canada, Ireland, Australia and New Zealand.', kindle: 'Linking needs the CrossPoint Kindle Link browser extension.', } diff --git a/assets/icons/seekquel.png b/assets/icons/seekquel.png new file mode 100644 index 0000000..1c60c3f Binary files /dev/null and b/assets/icons/seekquel.png differ diff --git a/src/connectors/registry.ts b/src/connectors/registry.ts index 573b148..b4a81d5 100644 --- a/src/connectors/registry.ts +++ b/src/connectors/registry.ts @@ -9,6 +9,7 @@ import { microblogConnector } from './microblog.js'; import { kindleConnector } from './kindle.js'; import { bookorbitConnector } from './bookorbit.js'; import { spotifyConnector } from './spotify.js'; +import { seekquelConnector } from './seekquel.js'; /** All connectors known to this build. */ const CONNECTORS: Connector[] = [ @@ -20,6 +21,7 @@ const CONNECTORS: Connector[] = [ audiobookshelfConnector, bookorbitConnector, spotifyConnector, + seekquelConnector, microblogConnector, kindleConnector, ]; diff --git a/src/connectors/seekquel.ts b/src/connectors/seekquel.ts new file mode 100644 index 0000000..5f4ad7b --- /dev/null +++ b/src/connectors/seekquel.ts @@ -0,0 +1,324 @@ +import crypto from 'node:crypto'; +import { documentMeta, getMatchSnapshot, setMatchSnapshot } from './store.js'; +import type { + Connector, + ConnectorContext, + Credential, + DeviceLinkPoll, + DeviceLinkStart, + DocumentMeta, + HttpTransport, + Match, + OutboundEvent, + PushResult, + ValidateResult, +} from './types.js'; + +/** + * Seekquel connector (Tier 1). Sends reading progress, finished books and + * highlights to a Seekquel account (seekquel.app), a book tracker. + * + * Seekquel speaks KOSync under /koreader and keys books by the same document + * hash we store, so there is no book matching here: the document id is sent as + * is, with the title, author and filename we know, and Seekquel matches the + * file to a book on its side. A file it cannot place waits in the reader's + * Seekquel library until they say which book it is. + * + * Linking is Seekquel's device-code pairing: the user approves a code in the + * Seekquel app and the poll returns a per-link key, sent as x-auth-user. The + * key is the whole credential; Seekquel never reads the KOSync password. + * + * Highlights and the finished status attach to a book, so Seekquel answers 409 + * until the file is linked to one. Seekquel matches a file a few seconds after + * the first progress push that carries its title, so a 409 is retried on the + * queue's backoff; a file nobody links dead-letters with a message saying so, + * and "Sync now" sends it again once the reader picks the book in Seekquel. + * + * A finished book is marked Read once, then not again until the device has gone + * back below the end. Every sync of a book at 100% fans out as 'finished', and + * re-sending Read each time would undo a re-read the reader started in Seekquel + * while the device still shows the old copy at the end. + */ + +const DEFAULT_BASE = 'https://api.seekquel.app'; +const CLIENT = 'crosspoint-sync'; +const DEVICE_NAME = 'CrossPoint Sync'; +const ACCEPT = 'application/vnd.koreader.v1+json'; +const MAX_TEXT = 500; +const MAX_XPATH = 120; +const NOT_LINKED = 409; +const CONNECTOR_ID = 'seekquel'; +const NOT_LINKED_ERROR = 'not linked to a book in Seekquel yet; choose the book in Seekquel, then Sync now'; + +/** + * Per-book state kept on the match: whether Read has been sent since the device + * last went below the end, and to which link. Matches outlive an unlink, so a + * relink (perhaps to another Seekquel account) must not inherit the old answer. + */ +interface FinishSnapshot { + readSent: boolean; + link: string; +} + +interface SeekquelCred extends Credential { + server: string; + key: string; +} + +/** Seekquel's API origin. SEEKQUEL_URL points a test install at a local Seekquel. */ +export function seekquelBase(env: NodeJS.ProcessEnv = process.env): string { + let url = env.SEEKQUEL_URL?.trim() || DEFAULT_BASE; + while (url.endsWith('/')) url = url.slice(0, -1); + return url; +} + +function parseCred(cred: Credential): SeekquelCred | null { + const server = typeof cred.server === 'string' ? cred.server.trim() : ''; + const key = typeof cred.key === 'string' ? cred.key.trim() : ''; + if (!server || !key) return null; + return { server, key }; +} + +function headers(c: SeekquelCred): Record { + return { 'x-auth-user': c.key, accept: ACCEPT, 'content-type': 'application/json' }; +} + +function clip(value: string | null | undefined, max = MAX_TEXT): string | null { + if (!value) return null; + const chars = Array.from(value.trim()); + return chars.length === 0 ? null : chars.slice(0, max).join(''); +} + +async function readJson(res: { json(): Promise }): Promise> { + try { + const body = await res.json(); + return body && typeof body === 'object' ? (body as Record) : {}; + } catch { + return {}; + } +} + +function failure(status: number, what: string): PushResult { + if (status === 401) return { ok: false, retryable: false, needsReauth: true, error: 'unauthorized' }; + if (status === 403) return { ok: false, retryable: false, error: `${what}: forbidden` }; + if (status === 429) return { ok: false, retryable: true, error: 'rate limited' }; + if (status >= 500) return { ok: false, retryable: true, error: `server ${status}` }; + return { ok: false, retryable: false, error: `${what}: unexpected status ${status}` }; +} + +/** + * Only a 401 means Seekquel refused the key. The key being checked was issued by + * the pairing poll a moment ago, so a rate limit, a server error or a dropped + * connection here is no reason to throw away a link the reader just approved: + * the code is spent, and they would have to start again. + */ +async function validate(cred: Credential, http: HttpTransport): Promise { + const c = parseCred(cred); + if (!c) return { ok: false, error: 'server and key are required' }; + try { + const res = await http(`${c.server}/users/auth`, { method: 'GET', headers: headers(c) }); + if (res.status === 401) return { ok: false, error: 'Seekquel no longer accepts this link. Link it again.' }; + return { ok: true, accountLabel: 'Seekquel' }; + } catch { + return { ok: true, accountLabel: 'Seekquel' }; + } +} + +// Seekquel keys books by our document hash: matching is identity, no network. +async function match(_cred: Credential, doc: DocumentMeta): Promise { + return { externalId: doc.document, confidence: 1, title: doc.title, author: doc.author }; +} + +/** The freshest metadata we hold: a document's title often arrives after its first sync. */ +function metadataFor(m: Match, ev: OutboundEvent, ctx?: ConnectorContext): Record | null { + const meta = ctx ? documentMeta(ctx.db, ctx.userId, ev.document) : null; + const fields: Record = { + title: clip(meta?.title ?? m.title), + authors: clip(meta?.author ?? m.author), + filename: clip(meta?.filename), + }; + const present = Object.entries(fields).filter((entry): entry is [string, string] => entry[1] !== null); + return present.length > 0 ? Object.fromEntries(present) : null; +} + +/** CrossPoint's position object, minus an xpath longer than Seekquel stores. */ +function positionFor(ev: OutboundEvent): Record | null { + if (!ev.position) return null; + const position = { ...ev.position }; + if (typeof position.xpath === 'string' && position.xpath.length > MAX_XPATH) delete position.xpath; + return position; +} + +async function pushProgress( + c: SeekquelCred, + m: Match, + ev: OutboundEvent, + http: HttpTransport, + ctx?: ConnectorContext +): Promise { + const percentage = Math.max(0, Math.min(1, ev.percentage ?? 0)); + const body: Record = { + document: m.externalId, + // KOSync refuses an empty progress string; fall back to the percentage. + progress: ev.progress && ev.progress.length > 0 ? ev.progress : String(percentage), + percentage, + device: DEVICE_NAME, + device_id: CLIENT, + }; + const position = positionFor(ev); + if (position) body.position = position; + const metadata = metadataFor(m, ev, ctx); + if (metadata) body.metadata = metadata; + + const res = await http(`${c.server}/syncs/progress`, { method: 'PUT', headers: headers(c), body: JSON.stringify(body) }); + return res.status >= 200 && res.status < 300 ? { ok: true } : failure(res.status, 'progress'); +} + +function linkId(c: SeekquelCred): string { + return crypto.createHash('sha256').update(`${c.server} +${c.key}`).digest('hex').slice(0, 16); +} + +function readAlreadySent(c: SeekquelCred, m: Match, ctx?: ConnectorContext): boolean { + if (!ctx) return false; + const snapshot = getMatchSnapshot(ctx.db, ctx.userId, CONNECTOR_ID, m.externalId); + return snapshot?.readSent === true && snapshot.link === linkId(c); +} + +function rememberReadSent(c: SeekquelCred, m: Match, readSent: boolean, ctx?: ConnectorContext): void { + if (!ctx) return; + const snapshot: FinishSnapshot = { readSent, link: linkId(c) }; + setMatchSnapshot(ctx.db, ctx.userId, CONNECTOR_ID, m.externalId, snapshot); +} + +async function pushFinished(c: SeekquelCred, m: Match, http: HttpTransport, ctx?: ConnectorContext): Promise { + if (readAlreadySent(c, m, ctx)) return { ok: true }; + const res = await http(`${c.server}/documents/${encodeURIComponent(m.externalId)}/status`, { + method: 'PUT', + headers: headers(c), + body: JSON.stringify({ status: 'read' }), + }); + if (res.status === NOT_LINKED) return { ok: false, retryable: true, error: NOT_LINKED_ERROR }; + if (res.status < 200 || res.status >= 300) return failure(res.status, 'status'); + rememberReadSent(c, m, true, ctx); + return { ok: true }; +} + +/** Stable per highlight, so a re-sent or edited highlight updates rather than duplicates. */ +function highlightId(document: string, h: NonNullable): string { + const anchor = [document, h.spine ?? '', h.startOffset ?? '', h.endOffset ?? '', h.text].join('\n'); + return `crosspoint-sync:${crypto.createHash('sha1').update(anchor).digest('hex')}`; +} + +async function pushHighlight(c: SeekquelCred, m: Match, ev: OutboundEvent, http: HttpTransport): Promise { + const h = ev.highlight; + if (!h || !h.text.trim()) return { ok: false, retryable: false, error: 'empty highlight' }; + const createdAt = h.highlightedAt ?? ev.timestamp; + const highlight: Record = { + external_id: highlightId(m.externalId, h), + text: h.text, + created_at: new Date(createdAt * 1000).toISOString(), + }; + if (h.note) highlight.note = h.note; + const chapter = clip(h.chapter); + if (chapter) highlight.chapter = chapter; + + const res = await http(`${c.server}/highlights`, { + method: 'POST', + headers: headers(c), + body: JSON.stringify({ document: m.externalId, highlights: [highlight] }), + }); + if (res.status === NOT_LINKED) return { ok: false, retryable: true, error: NOT_LINKED_ERROR }; + return res.status >= 200 && res.status < 300 ? { ok: true } : failure(res.status, 'highlight'); +} + +async function push( + cred: Credential, + m: Match, + ev: OutboundEvent, + http: HttpTransport, + ctx?: ConnectorContext +): Promise { + const c = parseCred(cred); + if (!c) return { ok: false, retryable: false, needsReauth: true, error: 'bad credential' }; + try { + if (ev.kind === 'highlight') return await pushHighlight(c, m, ev, http); + const progress = await pushProgress(c, m, ev, http, ctx); + if (!progress.ok) return progress; + if (ev.kind !== 'finished') { + if (readAlreadySent(c, m, ctx)) rememberReadSent(c, m, false, ctx); + return progress; + } + return await pushFinished(c, m, http, ctx); + } catch (err) { + return { ok: false, retryable: true, error: err instanceof Error ? err.message : String(err) }; + } +} + +// --- Device-code linking flow ------------------------------------------------- + +async function beginLink(http: HttpTransport): Promise { + const res = await http(`${seekquelBase()}/koreader/pair/start`, { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ device_name: DEVICE_NAME, client: CLIENT }), + }); + const body = await readJson(res); + if (res.status !== 201 || typeof body.device_code !== 'string' || typeof body.user_code !== 'string') { + const message = typeof body.message === 'string' ? body.message : `status ${res.status}`; + throw new Error(`Seekquel did not start the link: ${message}`); + } + const complete = typeof body.verification_uri_complete === 'string' ? body.verification_uri_complete : null; + return { + deviceCode: body.device_code, + userCode: body.user_code, + verificationUri: complete ? complete.split('?')[0] : 'https://seekquel.app/settings/integrations', + verificationUriComplete: complete ?? undefined, + interval: Number(body.interval) || 5, + expiresIn: Number(body.expires_in) || 900, + }; +} + +async function pollLink(deviceCode: string, http: HttpTransport): Promise { + const base = seekquelBase(); + const res = await http(`${base}/koreader/pair/poll`, { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ device_code: deviceCode }), + }); + const body = await readJson(res); + if (res.status === 200 && typeof body.key === 'string' && body.key) { + return { status: 'ok', credential: { server: `${base}/koreader`, key: body.key }, accountLabel: 'Seekquel' }; + } + switch (body.error?.type) { + case 'authorization_pending': + case 'slow_down': + return { status: 'pending' }; + case 'access_denied': + return { status: 'denied', error: 'The request was declined in Seekquel.' }; + case 'expired_token': + case 'invalid_grant': + return { status: 'expired', error: 'The code expired. Start again.' }; + default: + return res.status >= 500 || res.status === 429 + ? { status: 'pending' } + : { status: 'error', error: body.error?.type ?? `status ${res.status}` }; + } +} + +export const seekquelConnector: Connector = { + id: CONNECTOR_ID, + displayName: 'Seekquel', + tier: 1, + capabilities: { read: false, write: true }, + carries: ['progress', 'finished', 'highlight'], + credentialKind: 'device_code', + beta: true, + // Keyed by our document hash: Seekquel matches the file itself, so always send. + matchBy: 'document', + validate, + match, + push, + beginLink, + pollLink, +}; diff --git a/src/routes/landing.ts b/src/routes/landing.ts index 625b40b..342f4ce 100644 --- a/src/routes/landing.ts +++ b/src/routes/landing.ts @@ -47,6 +47,7 @@ const SERVICES: [string, string, string, 'ready' | 'beta'][] = [ ['microblog', 'Micro.blog', 'Keep your Currently reading and Finished shelves current.', 'ready'], ['audiobookshelf', 'Audiobookshelf', 'Switch between the ebook and the audiobook without losing your place.', 'ready'], ['bookorbit', 'BookOrbit', 'Two-way progress with your own library, clippings as highlights.', 'beta'], + ['seekquel', 'Seekquel', 'Log your progress, finished books and highlights in your Seekquel library.', 'beta'], ]; const STYLE = ` diff --git a/src/routes/v1/connectors.ts b/src/routes/v1/connectors.ts index 598b97e..7bed5ed 100644 --- a/src/routes/v1/connectors.ts +++ b/src/routes/v1/connectors.ts @@ -283,6 +283,7 @@ export function connectorRoutes( const user = c.get('user'); // Confirm the freshly minted credential works, then store it. const v = await conn.validate(result.credential, transport); + if (!v.ok) return c.json({ status: 'error', error: v.error ?? 'Credential rejected' }, 400); upsertAccount(db, user.id, conn.id, result.credential, v.accountLabel ?? result.accountLabel ?? null); return c.json({ status: 'ok', linked: true }); } diff --git a/src/routes/web.ts b/src/routes/web.ts index 712bebc..b9d224f 100644 --- a/src/routes/web.ts +++ b/src/routes/web.ts @@ -25,7 +25,7 @@ const OG_IMAGE = fs.readFileSync(path.join(ASSETS_DIR, 'og.jpg')); // Service app icons, served at /icons/:id.png. Loaded once at boot; a missing // file just means no icon for that service (the UI falls back gracefully). const SERVICE_ICONS = new Map(); -for (const id of ['kosync', 'hardcover', 'audiobookshelf', 'bookfusion', 'readwise', 'microblog', 'bookorbit', 'spotify', 'kindle']) { +for (const id of ['kosync', 'hardcover', 'audiobookshelf', 'bookfusion', 'readwise', 'microblog', 'bookorbit', 'seekquel', 'spotify', 'kindle']) { try { SERVICE_ICONS.set(id, fs.readFileSync(path.join(ASSETS_DIR, 'icons', `${id}.png`))); } catch { @@ -286,6 +286,9 @@ const LANDING = shell(
BookOrbit
Keep your place in sync, both ways, with your own BookOrbit library. Clippings show up as highlights, and finished books are marked Read.
beta
+
Seekquel
+
Log your reading in Seekquel, a book tracker: where you are in each book, the books you finish, and your highlights.
+ beta

more on the way, and it's all open source

@@ -606,7 +609,8 @@ const HINTS = { kosync: 'Mirror your reading progress to another KOReader-compatible (KOSync) server, so your other devices see it too.', bookfusion: 'Connect your BookFusion account to sync reading progress. You will approve the request on bookfusion.com.', audiobookshelf: 'Sync your reading position to the matching audiobook on your Audiobookshelf server. Create an API key in Audiobookshelf under Settings, Users, API Keys.', - bookorbit: 'Sync reading progress both ways with your BookOrbit server. Books are matched by title and author, finishing a book marks it Read, and your clippings appear as highlights in the right spot. Sign in with your BookOrbit account.' + bookorbit: 'Sync reading progress both ways with your BookOrbit server. Books are matched by title and author, finishing a book marks it Read, and your clippings appear as highlights in the right spot. Sign in with your BookOrbit account.', + seekquel: 'Log your reading in your Seekquel library: where you are in each book, the books you finish, and your highlights. You will approve the request in the Seekquel app.' }; const TOKEN_HELP = { diff --git a/test/connectors.test.ts b/test/connectors.test.ts index c84f960..9a6c40e 100644 --- a/test/connectors.test.ts +++ b/test/connectors.test.ts @@ -57,7 +57,7 @@ describe('connector management API', () => { const ids = body.connectors.map((c: { id: string }) => c.id).sort(); // The classic (highlights-only) readwise connector is hidden; still // registered but not listed. Kindle is stealth: hidden until revealed. - expect(ids).toEqual(['audiobookshelf', 'bookfusion', 'bookorbit', 'hardcover', 'kosync', 'microblog', 'readwise-reader', 'spotify']); + expect(ids).toEqual(['audiobookshelf', 'bookfusion', 'bookorbit', 'hardcover', 'kosync', 'microblog', 'readwise-reader', 'seekquel', 'spotify']); expect(body.connectors.every((c: { linked: boolean }) => !c.linked)).toBe(true); }); diff --git a/test/seekquel.test.ts b/test/seekquel.test.ts new file mode 100644 index 0000000..cece2fb --- /dev/null +++ b/test/seekquel.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DOC, makeTestApp, registerUser } from './helpers.js'; +import { resetEncryptionKeyCache } from '../src/crypto/secrets.js'; +import type { HttpTransport, OutboundEvent } from '../src/connectors/types.js'; +import { drainQueue } from '../src/connectors/runner.js'; +import { claimReady } from '../src/connectors/queue.js'; +import { seekquelBase, seekquelConnector } from '../src/connectors/seekquel.js'; + +function fakeTransport() { + const calls: { url: string; method: string; headers?: Record; body?: string }[] = []; + const handlers: { match: string; status: number; body: unknown }[] = []; + const t: HttpTransport = async (url, init) => { + calls.push({ url, method: init.method, headers: init.headers, body: init.body }); + const h = [...handlers].reverse().find((x) => url.includes(x.match)); + const status = h?.status ?? 200; + const body = h?.body ?? {}; + return { status, text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), json: async () => body }; + }; + return { transport: t, calls, on: (m: string, s: number, b: unknown) => handlers.push({ match: m, status: s, body: b }) }; +} + +const SERVER = 'https://api.seekquel.app/koreader'; +const CRED = { server: SERVER, key: 'ABCD-EFGH-IJKL-MNOP' }; +const MATCH = { externalId: DOC, confidence: 1, title: 'Piranesi', author: 'Susanna Clarke' }; +const progressEvent = (over: Partial = {}): OutboundEvent => ({ + kind: 'progress', + document: DOC, + percentage: 0.42, + progress: '/body/DocFragment[8]/body/p[4]/text()[1].96', + timestamp: 1_760_000_000, + ...over, +}); +const body = (call: { body?: string }) => JSON.parse(call.body ?? '{}'); + +const KEY = { TOKEN_ENC_KEY: 'a'.repeat(64) }; +beforeEach(() => { Object.assign(process.env, KEY); resetEncryptionKeyCache(); }); +afterEach(() => { delete process.env.TOKEN_ENC_KEY; delete process.env.SEEKQUEL_URL; resetEncryptionKeyCache(); }); + +describe('seekquel connector (unit)', () => { + it('points at Seekquel unless SEEKQUEL_URL says otherwise', () => { + expect(seekquelBase({})).toBe('https://api.seekquel.app'); + expect(seekquelBase({ SEEKQUEL_URL: 'http://localhost:8000/' })).toBe('http://localhost:8000'); + }); + + it('starts a device-code link as the crosspoint-sync client', async () => { + const fake = fakeTransport(); + fake.on('/koreader/pair/start', 201, { + device_code: 'd'.repeat(64), + user_code: 'WXYZ-2345', + verification_uri_complete: 'https://seekquel.app/settings/integrations/crosspoint-sync?pair=WXYZ-2345', + expires_in: 900, + interval: 5, + }); + const start = await seekquelConnector.beginLink!(fake.transport); + expect(body(fake.calls[0])).toEqual({ device_name: 'CrossPoint Sync', client: 'crosspoint-sync' }); + expect(start.userCode).toBe('WXYZ-2345'); + expect(start.verificationUri).toBe('https://seekquel.app/settings/integrations/crosspoint-sync'); + expect(start.verificationUriComplete).toContain('?pair=WXYZ-2345'); + }); + + it('says why when Seekquel refuses to start a link', async () => { + const fake = fakeTransport(); + fake.on('/koreader/pair/start', 422, { message: 'The selected client is invalid.' }); + await expect(seekquelConnector.beginLink!(fake.transport)).rejects.toThrow('The selected client is invalid.'); + }); + + it('maps each poll answer, and keeps the key once approved', async () => { + const fake = fakeTransport(); + fake.on('/koreader/pair/poll', 400, { error: { type: 'authorization_pending' } }); + expect((await seekquelConnector.pollLink!('dc', fake.transport)).status).toBe('pending'); + fake.on('/koreader/pair/poll', 400, { error: { type: 'access_denied' } }); + expect((await seekquelConnector.pollLink!('dc', fake.transport)).status).toBe('denied'); + fake.on('/koreader/pair/poll', 400, { error: { type: 'expired_token' } }); + expect((await seekquelConnector.pollLink!('dc', fake.transport)).status).toBe('expired'); + fake.on('/koreader/pair/poll', 200, { key: CRED.key, sync_url: SERVER, device_id: '01J' }); + const ok = await seekquelConnector.pollLink!('dc', fake.transport); + expect(ok).toMatchObject({ status: 'ok', credential: CRED }); + }); + + it('validates the key against /users/auth', async () => { + const fake = fakeTransport(); + fake.on('/users/auth', 200, { authorized: 'OK' }); + expect((await seekquelConnector.validate(CRED, fake.transport)).ok).toBe(true); + expect(fake.calls[0].headers?.['x-auth-user']).toBe(CRED.key); + fake.on('/users/auth', 401, {}); + expect((await seekquelConnector.validate(CRED, fake.transport)).ok).toBe(false); + }); + + it('keeps a just-approved link through a rate limit or server error on the check', async () => { + const fake = fakeTransport(); + fake.on('/users/auth', 429, {}); + expect((await seekquelConnector.validate(CRED, fake.transport)).ok).toBe(true); + fake.on('/users/auth', 503, {}); + expect((await seekquelConnector.validate(CRED, fake.transport)).ok).toBe(true); + }); + + it('matches by document id, with no network', async () => { + const fake = fakeTransport(); + const m = await seekquelConnector.match(CRED, { document: DOC, title: 'Piranesi', author: 'Susanna Clarke', filename: null }, fake.transport); + expect(m).toMatchObject({ externalId: DOC, confidence: 1, title: 'Piranesi' }); + expect(fake.calls).toHaveLength(0); + }); + + it('sends progress with the book metadata and the position, minus an xpath Seekquel cannot store', async () => { + const fake = fakeTransport(); + const r = await seekquelConnector.push( + CRED, + MATCH, + progressEvent({ position: { pctQ: 420000, spine: 7, page: 3, pages: 20, xpath: `/body/${'p'.repeat(200)}` } }), + fake.transport + ); + expect(r.ok).toBe(true); + const call = fake.calls[0]; + expect(call.method).toBe('PUT'); + expect(call.url).toBe(`${SERVER}/syncs/progress`); + expect(call.headers?.['x-auth-user']).toBe(CRED.key); + expect(body(call)).toEqual({ + document: DOC, + progress: '/body/DocFragment[8]/body/p[4]/text()[1].96', + percentage: 0.42, + device: 'CrossPoint Sync', + device_id: 'crosspoint-sync', + position: { pctQ: 420000, spine: 7, page: 3, pages: 20 }, + metadata: { title: 'Piranesi', authors: 'Susanna Clarke' }, + }); + }); + + it('marks a finished book Read after sending its place', async () => { + const fake = fakeTransport(); + const r = await seekquelConnector.push(CRED, MATCH, progressEvent({ kind: 'finished', percentage: 1 }), fake.transport); + expect(r.ok).toBe(true); + expect(fake.calls.map((c) => `${c.method} ${c.url}`)).toEqual([ + `PUT ${SERVER}/syncs/progress`, + `PUT ${SERVER}/documents/${DOC}/status`, + ]); + expect(body(fake.calls[1])).toEqual({ status: 'read' }); + }); + + it('sends a highlight with an id that stays the same when it is sent again', async () => { + const fake = fakeTransport(); + const ev: OutboundEvent = { + kind: 'highlight', + document: DOC, + timestamp: 1_760_000_000, + highlight: { text: 'The Beauty of the House is immeasurable', note: 'yes', chapter: 'Part 1', spine: 2, startOffset: 10, endOffset: 49, highlightedAt: 1_759_000_000 }, + }; + await seekquelConnector.push(CRED, MATCH, ev, fake.transport); + await seekquelConnector.push(CRED, MATCH, { ...ev, highlight: { ...ev.highlight!, note: 'edited' } }, fake.transport); + const [first, second] = fake.calls.map(body); + expect(fake.calls[0].url).toBe(`${SERVER}/highlights`); + expect(first.document).toBe(DOC); + expect(first.highlights[0]).toMatchObject({ + text: 'The Beauty of the House is immeasurable', + note: 'yes', + chapter: 'Part 1', + created_at: new Date(1_759_000_000 * 1000).toISOString(), + }); + expect(second.highlights[0].external_id).toBe(first.highlights[0].external_id); + }); + + it('retries highlights and the finished mark while Seekquel is still placing the book', async () => { + const fake = fakeTransport(); + fake.on('/highlights', 409, { error: { code: 'DOCUMENT_NOT_LINKED' } }); + fake.on('/status', 409, { error: { code: 'DOCUMENT_NOT_LINKED' } }); + const highlight = await seekquelConnector.push( + CRED, + MATCH, + { kind: 'highlight', document: DOC, timestamp: 1, highlight: { text: 'quote' } }, + fake.transport + ); + const finished = await seekquelConnector.push(CRED, MATCH, progressEvent({ kind: 'finished', percentage: 1 }), fake.transport); + expect(highlight).toMatchObject({ ok: false, retryable: true, error: expect.stringContaining('Seekquel') }); + expect(finished).toMatchObject({ ok: false, retryable: true, error: expect.stringContaining('Seekquel') }); + }); + + it('asks to link again on 401, gives up on 403 without asking, and retries on 429 and server errors', async () => { + const fake = fakeTransport(); + fake.on('/syncs/progress', 401, {}); + expect(await seekquelConnector.push(CRED, MATCH, progressEvent(), fake.transport)).toMatchObject({ ok: false, needsReauth: true }); + fake.on('/syncs/progress', 403, {}); + const forbidden = await seekquelConnector.push(CRED, MATCH, progressEvent(), fake.transport); + expect(forbidden).toMatchObject({ ok: false, retryable: false }); + expect(forbidden).not.toHaveProperty('needsReauth'); + fake.on('/syncs/progress', 429, {}); + expect(await seekquelConnector.push(CRED, MATCH, progressEvent(), fake.transport)).toMatchObject({ ok: false, retryable: true }); + fake.on('/syncs/progress', 503, {}); + expect(await seekquelConnector.push(CRED, MATCH, progressEvent(), fake.transport)).toMatchObject({ ok: false, retryable: true }); + }); +}); + +async function linkSeekquel(fake: ReturnType) { + const { app, db } = makeTestApp({}, { connectorTransport: fake.transport }); + const { headers } = await registerUser(app); + fake.on('/koreader/pair/start', 201, { device_code: 'd'.repeat(64), user_code: 'WXYZ-2345', verification_uri_complete: 'https://seekquel.app/settings/integrations/crosspoint-sync?pair=WXYZ-2345' }); + const begin = await app.request('/api/v1/connectors/seekquel/link/begin', { method: 'POST', headers }); + const { device_code } = await begin.json(); + fake.on('/koreader/pair/poll', 200, { key: CRED.key, sync_url: SERVER, device_id: '01J' }); + fake.on('/users/auth', 200, { authorized: 'OK' }); + const poll = await app.request('/api/v1/connectors/seekquel/link/poll', { method: 'POST', headers, body: JSON.stringify({ device_code }) }); + return { app, db, headers, linked: (await poll.json()).linked as boolean }; +} + +describe('seekquel connector (end to end)', () => { + it('links by device code, then delivers a device sync with the title the device sent', async () => { + const fake = fakeTransport(); + const { app, db } = makeTestApp({}, { connectorTransport: fake.transport }); + const { headers } = await registerUser(app); + + fake.on('/koreader/pair/start', 201, { device_code: 'd'.repeat(64), user_code: 'WXYZ-2345', verification_uri_complete: 'https://seekquel.app/settings/integrations/crosspoint-sync?pair=WXYZ-2345' }); + const begin = await app.request('/api/v1/connectors/seekquel/link/begin', { method: 'POST', headers }); + expect(begin.status).toBe(200); + const { device_code } = await begin.json(); + + fake.on('/koreader/pair/poll', 200, { key: CRED.key, sync_url: SERVER, device_id: '01J' }); + fake.on('/users/auth', 200, { authorized: 'OK' }); + const poll = await app.request('/api/v1/connectors/seekquel/link/poll', { method: 'POST', headers, body: JSON.stringify({ device_code }) }); + expect((await poll.json()).linked).toBe(true); + + await app.request('/syncs/progress', { + method: 'PUT', + headers, + body: JSON.stringify({ + document: DOC, + progress: '/body/p[2]', + percentage: 0.5, + device_id: 'd1', + metadata: { title: 'Piranesi', authors: 'Susanna Clarke', filename: 'piranesi.epub' }, + }), + }); + expect(claimReady(db, 10).length).toBeGreaterThan(0); + await drainQueue(db, fake.transport, 10); + + const sent = fake.calls.find((c) => c.url === `${SERVER}/syncs/progress`); + expect(sent?.method).toBe('PUT'); + expect(body(sent!)).toMatchObject({ + document: DOC, + percentage: 0.5, + device_id: 'crosspoint-sync', + metadata: { title: 'Piranesi', authors: 'Susanna Clarke', filename: 'piranesi.epub' }, + }); + }); + + it('marks a book Read once, and again only after the device has gone back below the end', async () => { + const fake = fakeTransport(); + const { app, db, headers, linked } = await linkSeekquel(fake); + expect(linked).toBe(true); + const sync = async (percentage: number) => { + await app.request('/syncs/progress', { + method: 'PUT', + headers, + body: JSON.stringify({ document: DOC, progress: '/body/p[9]', percentage, device_id: 'd1', metadata: { title: 'Piranesi', authors: 'Susanna Clarke' } }), + }); + await drainQueue(db, fake.transport, 10); + }; + const statusCalls = () => fake.calls.filter((c) => c.url.endsWith('/status')).length; + + await sync(1); + expect(statusCalls()).toBe(1); + await sync(1); + expect(statusCalls()).toBe(1); + await sync(0.1); + expect(statusCalls()).toBe(1); + await sync(1); + expect(statusCalls()).toBe(2); + }); + + it('does not store a link whose key Seekquel refuses', async () => { + const fake = fakeTransport(); + const { app } = makeTestApp({}, { connectorTransport: fake.transport }); + const { headers } = await registerUser(app); + fake.on('/koreader/pair/start', 201, { device_code: 'd'.repeat(64), user_code: 'WXYZ-2345' }); + const begin = await app.request('/api/v1/connectors/seekquel/link/begin', { method: 'POST', headers }); + const { device_code } = await begin.json(); + fake.on('/koreader/pair/poll', 200, { key: CRED.key }); + fake.on('/users/auth', 401, {}); + const poll = await app.request('/api/v1/connectors/seekquel/link/poll', { method: 'POST', headers, body: JSON.stringify({ device_code }) }); + expect(poll.status).toBe(400); + expect((await poll.json()).status).toBe('error'); + const list = await (await app.request('/api/v1/connectors', { headers })).json(); + expect(list.connectors.find((c: { id: string }) => c.id === 'seekquel').linked).toBe(false); + }); +});