diff --git a/docs/API.md b/docs/API.md index 8c293f5..eabe6a4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -669,11 +669,25 @@ the server has `GOOGLE_BOOKS_API_KEY`, then Amazon's "Print length" via SearchAP #### PUT /api/v1/documents/{document}/info Manual fixes when the lookup got it wrong: `{"cover_url": "https://...", "page_count": 433, -"genres": ["Fantasy", "Heist"]}` (every field optional). Manual values stick, since lookups only fill -blanks and Hardcover's lookup leaves manual genres alone; `null` clears a field so it's looked up -again. `genres` takes up to 10 names of at most 64 characters. `language` (a BCP 47 tag) can be set -the same way and then stays over device syncs; `null` clears it so the next device sync fills it -again. Returns the stored `{document, cover_url, page_count, title, author, language, genres}`. +"genres": ["Fantasy", "Heist"], "moods": ["Tense"], "series": "The Founders", "series_position": 1}` (every +field optional). Manual values stick: lookups only fill blanks, and Hardcover's lookup leaves details set here +alone. `null` clears a field so it's looked up again. + +The details Hardcover fills in can all be set this way: + +- `genres` (up to 10), `moods` (up to 12) and `content_warnings` (up to 12): lists of names of at most 64 + characters. A list replaces the stored one, so to add one send the current list with it. Names are stored + the way Hardcover's are (trimmed, one per name whatever the case, first letter capitalized), so "dark" set + here and Hardcover's "Dark" count as one. +- `description`: up to 6000 characters. +- `release_year`: a whole number from 1 to 9999. +- `series` with an optional `series_position` (a number from 0 to 10000). They go together: a series set here + replaces Hardcover's series and number, and next-in-series lookups stop for the book, since they follow + Hardcover's series. `series_position` without `series` is rejected; `series: null` hands both back. + +`language` (a BCP 47 tag) can be set the same way and then stays over device syncs; `null` clears it so the +next device sync fills it again. Returns the stored `{document, cover_url, page_count, title, author, language, +genres, moods, content_warnings, description, series, series_position, release_year}`. #### GET /api/v1/documents/{document}/cover/candidates?q= diff --git a/migrations/0036_manual_details.sql b/migrations/0036_manual_details.sql new file mode 100644 index 0000000..da024b8 --- /dev/null +++ b/migrations/0036_manual_details.sql @@ -0,0 +1,7 @@ +-- Book details set through PUT /api/v1/documents/:document/info stick over Hardcover lookups, as genres +-- do since 0033. series_manual covers series, series_position and hc_series_id together. +ALTER TABLE documents ADD COLUMN moods_manual INTEGER NOT NULL DEFAULT 0; +ALTER TABLE documents ADD COLUMN content_warnings_manual INTEGER NOT NULL DEFAULT 0; +ALTER TABLE documents ADD COLUMN description_manual INTEGER NOT NULL DEFAULT 0; +ALTER TABLE documents ADD COLUMN series_manual INTEGER NOT NULL DEFAULT 0; +ALTER TABLE documents ADD COLUMN release_year_manual INTEGER NOT NULL DEFAULT 0; diff --git a/src/models/cover.ts b/src/models/cover.ts index 9a29bbf..6fe8f2f 100644 --- a/src/models/cover.ts +++ b/src/models/cover.ts @@ -5,6 +5,7 @@ import { documentMeta } from '../connectors/store.js'; import { extractTitleAuthor, scoreCandidate, type Candidate } from '../connectors/matching.js'; import { nowSeconds } from './sync.js'; import { cacheKey, hardcoverBook, type HardcoverBook } from './hardcover-catalog.js'; +import { keepManual } from './manual-details.js'; /** * Cover art and print page count from title/author. Covers use the same @@ -215,12 +216,13 @@ async function sharedBookInfo( return info; } -/** Store Hardcover's details on a document (a miss just records the check). Genres set by hand stay. */ +/** Store Hardcover's details on a document (a miss just records the check). Details set by hand stay. */ function saveHardcover(db: DB, userId: number, document: string, hc: HardcoverBook | null): void { const json = (v: string[] | undefined) => (v?.length ? JSON.stringify(v) : null); db.prepare( - `UPDATE documents SET hc_id = ?, hc_slug = ?, moods = ?, genres = CASE WHEN genres_manual THEN genres ELSE ? END, content_warnings = ?, rating = ?, - series = ?, hc_series_id = ?, series_position = ?, release_year = ?, description = ?, hc_checked_at = ? + `UPDATE documents SET hc_id = ?, hc_slug = ?, ${keepManual('moods')}, ${keepManual('genres')}, + ${keepManual('content_warnings')}, rating = ?, ${keepManual('series')}, ${keepManual('hc_series_id')}, + ${keepManual('series_position')}, ${keepManual('release_year')}, ${keepManual('description')}, hc_checked_at = ? WHERE user_id = ? AND document = ?` ).run( hc?.id ?? null, hc?.slug ?? null, json(hc?.moods), json(hc?.genres), json(hc?.content_warnings), hc?.rating ?? null, diff --git a/src/models/hardcover-catalog.ts b/src/models/hardcover-catalog.ts index 7e5b40f..1901dba 100644 --- a/src/models/hardcover-catalog.ts +++ b/src/models/hardcover-catalog.ts @@ -67,7 +67,7 @@ export function resetHardcoverThrottle(): void { } // Hardcover tags are user-entered: tidy case and drop duplicates ("Dark"/"dark"). -function tidy(list: unknown, max: number): string[] { +export function tidy(list: unknown, max: number): string[] { const out: string[] = []; const seen = new Set(); for (const v of Array.isArray(list) ? list : []) { diff --git a/src/models/manual-details.ts b/src/models/manual-details.ts new file mode 100644 index 0000000..655037a --- /dev/null +++ b/src/models/manual-details.ts @@ -0,0 +1,46 @@ +/** + * Book details Hardcover fills in that a user can also set by hand. A value set by hand sticks over the + * lookup (`_manual = 1`); clearing it hands the field back to Hardcover. The names here are + * trusted constants and the only ones ever put into SQL. + */ +export const MANUAL_DETAILS = [ + { flag: 'genres', columns: ['genres'] }, + { flag: 'moods', columns: ['moods'] }, + { flag: 'content_warnings', columns: ['content_warnings'] }, + { flag: 'description', columns: ['description'] }, + // A series named by hand also takes its number and drops Hardcover's series id, which would point at + // the series Hardcover found. + { flag: 'series', columns: ['series', 'series_position', 'hc_series_id'] }, + { flag: 'release_year', columns: ['release_year'] }, +] as const; + +export type ManualFlag = (typeof MANUAL_DETAILS)[number]['flag']; + +const flagOf = new Map(MANUAL_DETAILS.flatMap((d) => d.columns.map((c) => [c, d.flag] as const))); + +/** `column = `, for an UPDATE that takes the lookup's value as `?`. */ +export function keepManual(column: string): string { + const flag = flagOf.get(column); + if (!flag) throw new Error(`${column} is not a manual detail`); + return `${column} = CASE WHEN ${flag}_manual THEN ${column} ELSE ? END`; +} + +/** Every column and flag a merge carries over. */ +export const MANUAL_COLUMNS = MANUAL_DETAILS.flatMap((d) => [...d.columns, `${d.flag}_manual`]); + +/** + * The merge's SET clauses: a detail set by hand beats the lookup's, the kept document's when both are; else + * the kept document's when it has one. A group (series) moves together, so a series and the number from the + * other copy never mix. Set by hand counts even when empty: a list cleared on purpose stays cleared. + */ +export function mergeManualSql(): string { + return MANUAL_DETAILS.flatMap((d) => { + const fromAlias = `excluded.${d.flag}_manual AND NOT documents.${d.flag}_manual`; + const keepOwn = `documents.${d.flag}_manual OR documents.${d.columns[0]} IS NOT NULL`; + return [ + ...d.columns.map((c) => + `${c} = CASE WHEN ${fromAlias} THEN excluded.${c} WHEN ${keepOwn} THEN documents.${c} ELSE excluded.${c} END`), + `${d.flag}_manual = MAX(documents.${d.flag}_manual, excluded.${d.flag}_manual)`, + ]; + }).join(',\n '); +} diff --git a/src/models/merge.ts b/src/models/merge.ts index 048de77..1c5bf82 100644 --- a/src/models/merge.ts +++ b/src/models/merge.ts @@ -2,6 +2,7 @@ import { withTransaction, type DB } from '../db/db.js'; import { nextChangeSeq } from './changes.js'; import { md5Hex } from '../auth/password.js'; import { autoUnpause } from './pause.js'; +import { MANUAL_COLUMNS, mergeManualSql } from './manual-details.js'; /** * Manual document merges. The kosync document key is client-computed and @@ -147,9 +148,10 @@ export function mergeDocuments( db.prepare('UPDATE progress_log SET document = ? WHERE user_id = ? AND document = ?').run(into, userId, from); // documents: canonical metadata wins, alias fills the gaps. + const manual = MANUAL_COLUMNS.join(', '); db.prepare( - `INSERT INTO documents (user_id, document, title, author, filename, filesize, status, status_at, pause_reason, start_date, finished_date, cover_url, page_count, language, genres, genres_manual, updated_at) - SELECT user_id, ?, title, author, filename, filesize, status, status_at, pause_reason, start_date, finished_date, cover_url, page_count, language, genres, genres_manual, ? FROM documents WHERE user_id = ? AND document = ? + `INSERT INTO documents (user_id, document, title, author, filename, filesize, status, status_at, pause_reason, start_date, finished_date, cover_url, page_count, language, ${manual}, updated_at) + SELECT user_id, ?, title, author, filename, filesize, status, status_at, pause_reason, start_date, finished_date, cover_url, page_count, language, ${manual}, ? FROM documents WHERE user_id = ? AND document = ? ON CONFLICT(user_id, document) DO UPDATE SET title = COALESCE(documents.title, excluded.title), author = COALESCE(documents.author, excluded.author), @@ -163,10 +165,7 @@ export function mergeDocuments( cover_url = COALESCE(documents.cover_url, excluded.cover_url), page_count = COALESCE(documents.page_count, excluded.page_count), language = COALESCE(documents.language, excluded.language), - -- Genres set by hand beat Hardcover's; otherwise the kept document's win. - genres = CASE WHEN excluded.genres_manual AND NOT documents.genres_manual THEN excluded.genres - ELSE COALESCE(documents.genres, excluded.genres) END, - genres_manual = MAX(documents.genres_manual, excluded.genres_manual), + ${mergeManualSql()}, updated_at = excluded.updated_at` ).run(into, now, userId, from); db.prepare('DELETE FROM documents WHERE user_id = ? AND document = ?').run(userId, from); diff --git a/src/routes/v1/documents.ts b/src/routes/v1/documents.ts index ca59ced..adf69fe 100644 --- a/src/routes/v1/documents.ts +++ b/src/routes/v1/documents.ts @@ -5,12 +5,13 @@ import { isValidDocument, languageTag } from '../kosync.js'; import { nowSeconds } from '../../models/sync.js'; import { mergeDocuments, resolveDocument, unmergeDocument } from '../../models/merge.js'; import { coverCandidates, documentInfo } from '../../models/cover.js'; -import { nextAfter, seriesBooks } from '../../models/hardcover-catalog.js'; +import { nextAfter, seriesBooks, tidy } from '../../models/hardcover-catalog.js'; import { extractTitleAuthor } from '../../connectors/matching.js'; import { documentMeta } from '../../connectors/store.js'; import { fanOutProgress } from '../../connectors/fanout.js'; import type { HttpTransport } from '../../connectors/types.js'; import { fetchTransport } from '../../connectors/registry.js'; +import { MANUAL_DETAILS, type ManualFlag } from '../../models/manual-details.js'; const MAX_BATCH = 50; export const STATUSES = ['reading', 'finished', 'dnf', 'paused'] as const; @@ -290,17 +291,52 @@ export function documentRoutes(db: DB, http?: HttpTransport): Hono { const title = text(o.title); const author = text(o.author); const language = o.language === undefined || o.language === null ? o.language : languageTag(o.language); - // Same shape Hardcover's genres are stored in: a short list of short names. - const genres = Array.isArray(o.genres) ? [...new Set(o.genres.map(text))] : o.genres; - const genresOk = - genres === undefined || genres === null || - (Array.isArray(genres) && genres.length <= 10 && genres.every((g) => typeof g === 'string' && g.length > 0 && g.length <= 64)); + // Genres, moods and content warnings: a short list of short names, spelled the way Hardcover's are stored (tidy(): + // trimmed, de-duplicated ignoring case, first letter capitalized), so the two sources count as one. + // null clears a list; undefined leaves it alone. + const tags = (v: unknown, max: number) => { + if (v === undefined || v === null) return { list: v as null | undefined, ok: true }; + const ok = Array.isArray(v) && + v.every((g) => typeof g === 'string' && g.trim().length > 0 && g.trim().length <= 64) && tidy(v, max + 1).length <= max; + return { list: ok ? tidy(v, max) : undefined, ok }; + }; + // Each detail set by hand (MANUAL_DETAILS): its columns' values, or null to hand it back to Hardcover. + const details = new Map(); + let detailsOk = true; + const take = (flag: ManualFlag, raw: unknown, parse: (v: unknown) => (string | number | null)[] | undefined) => { + if (raw === undefined) return; + const values = raw === null ? null : parse(raw); + if (values === undefined) detailsOk = false; + else details.set(flag, values); + }; + const list = (max: number) => (v: unknown) => { + const { list: l, ok } = tags(v, max); + return ok && l ? [l.length ? JSON.stringify(l) : null] : undefined; + }; + take('genres', o.genres, list(10)); + take('moods', o.moods, list(12)); + take('content_warnings', o.content_warnings, list(12)); + take('description', o.description, (v) => { + // Not text(): its 500-character cut would shorten a description silently. + const d = typeof v === 'string' ? v.trim() : v; + return typeof d === 'string' && d.length <= 6000 ? [d || null] : undefined; + }); + take('release_year', o.release_year, (v) => (Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 9999 ? [v as number] : undefined)); + // A series name, with its number when given; the number alone is not taken, so it never lands on a + // series Hardcover named. + take('series', o.series, (v) => { + const name = text(v); + const pos = o.series_position; + const posOk = pos === undefined || pos === null || (typeof pos === 'number' && Number.isFinite(pos) && pos >= 0 && pos <= 10000); + return typeof name === 'string' && name.length > 0 && name.length <= 200 && posOk ? [name, (pos as number | undefined) ?? null, null] : undefined; + }); + if (o.series_position !== undefined && o.series === undefined) detailsOk = false; // A title is required once edited; an empty author means "no author". const titleOk = title === undefined || (typeof title === 'string' && title.length > 0); const authorOk = author === undefined || typeof author === 'string'; const coverOk = cover === undefined || cover === null || (typeof cover === 'string' && /^https?:\/\/\S{1,2000}$/.test(cover)); const pagesOk = pages === undefined || pages === null || (Number.isInteger(pages) && (pages as number) > 0 && (pages as number) <= 100000); - if (!isValidDocument(param) || !coverOk || !pagesOk || !titleOk || !authorOk || !genresOk || (language === null && o.language !== null)) { + if (!isValidDocument(param) || !coverOk || !pagesOk || !titleOk || !authorOk || !detailsOk || (language === null && o.language !== null)) { return kosyncError(c, 403, 2003, 'Invalid request'); } const user = c.get('user'); @@ -316,13 +352,16 @@ export function documentRoutes(db: DB, http?: HttpTransport): Hono { if (language !== undefined) { db.prepare('UPDATE documents SET language = ? WHERE user_id = ? AND document = ?').run(language as string | null, user.id, document); } - if (genres !== undefined) { - const list = genres as string[] | null; - // Clearing hands genres back to Hardcover: the next enrichment pass looks the book up again. + for (const d of MANUAL_DETAILS) { + if (!details.has(d.flag)) continue; + const values = details.get(d.flag) ?? null; + // Set here, it sticks over Hardcover's; cleared, it is handed back: the next enrichment pass looks the + // book up again. Column names come from MANUAL_DETAILS, never from the request. db.prepare( - `UPDATE documents SET genres = ?, genres_manual = ?, hc_checked_at = CASE WHEN ? THEN hc_checked_at ELSE NULL END + `UPDATE documents SET ${d.columns.map((c) => `${c} = ?`).join(', ')}, ${d.flag}_manual = ?, + hc_checked_at = CASE WHEN ? THEN hc_checked_at ELSE NULL END WHERE user_id = ? AND document = ?` - ).run(list?.length ? JSON.stringify(list) : null, list ? 1 : 0, list ? 1 : 0, user.id, document); + ).run(...(values ?? d.columns.map(() => null)), values ? 1 : 0, values ? 1 : 0, user.id, document); } if (title !== undefined || author !== undefined) { // New title/author: everything found with the old one is looked up again @@ -335,11 +374,18 @@ export function documentRoutes(db: DB, http?: HttpTransport): Hono { ).run((title as string | undefined) ?? null, author !== undefined ? 1 : 0, (author as string | undefined) ?? '', user.id, document); db.prepare("DELETE FROM connector_matches WHERE user_id = ? AND document = ? AND source IN ('auto', 'none')").run(user.id, document); } - const row = db.prepare('SELECT cover_url, page_count, title, author, language, genres FROM documents WHERE user_id = ? AND document = ?').get(user.id, document) as { + const row = db.prepare( + `SELECT cover_url, page_count, title, author, language, genres, moods, content_warnings, description, series, + series_position, release_year FROM documents WHERE user_id = ? AND document = ?` + ).get(user.id, document) as { cover_url: string | null; page_count: number | null; title: string | null; author: string | null; language: string | null; genres: string | null; + moods: string | null; content_warnings: string | null; description: string | null; series: string | null; + series_position: number | null; release_year: number | null; }; + const parsed = (v: string | null) => (v ? (JSON.parse(v) as string[]) : []); return c.json({ document, cover_url: row.cover_url, page_count: row.page_count, title: row.title, author: row.author, language: row.language, - genres: row.genres ? (JSON.parse(row.genres) as string[]) : [] }); + genres: parsed(row.genres), moods: parsed(row.moods), content_warnings: parsed(row.content_warnings), description: row.description, + series: row.series, series_position: row.series_position, release_year: row.release_year }); }); app.get('/documents', (c) => { diff --git a/test/manual-details.test.ts b/test/manual-details.test.ts new file mode 100644 index 0000000..ea4f6d0 --- /dev/null +++ b/test/manual-details.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { HttpTransport } from '../src/connectors/types.js'; +import { documentInfo } from '../src/models/cover.js'; +import { resetHardcoverThrottle } from '../src/models/hardcover-catalog.js'; +import { DOC, makeTestApp, registerUser } from './helpers.js'; + +// Hardcover finds the book with its own details; the free cover sources find nothing. +const hardcover: HttpTransport = async (url) => { + const hit = { + id: '41398', slug: 'foundryside', title: 'Foundryside', author_names: ['Robert Jackson Bennett'], + content_warnings: ['Violence'], description: 'A thief in Tevanne.', release_year: 2018, + series_names: ['The Founders'], featured_series: { series: { id: 77 } }, featured_series_position: 1, + }; + const body = url.includes('hardcover') ? { data: { search: { results: { hits: [{ document: hit }] } } } } : {}; + return { status: 200, text: async () => JSON.stringify(body), json: async () => body }; +}; + +beforeEach(() => resetHardcoverThrottle()); + +const progress = (document: string) => ({ + method: 'PUT', + body: JSON.stringify({ document, progress: '/body/DocFragment[2]', percentage: 0.2, device: 'X4', device_id: 'x4', + metadata: { title: 'Foundryside', authors: 'Robert Jackson Bennett' } }), +}); +const COLUMNS = 'content_warnings, description, release_year, series, series_position, hc_series_id'; + +describe('book details set by hand', () => { + it('stick over Hardcover lookups and clear back to them', async () => { + const { app, db } = makeTestApp(); + const { username, headers } = await registerUser(app); + await app.request('/syncs/progress', { ...progress(DOC), headers }); + const { id } = db.prepare('SELECT id FROM users WHERE username = ?').get(username) as { id: number }; + const put = (body: unknown) => app.request(`/api/v1/documents/${DOC}/info`, { method: 'PUT', headers, body: JSON.stringify(body) }); + const row = () => db.prepare(`SELECT ${COLUMNS} FROM documents WHERE document = ?`).get(DOC); + const enrich = () => documentInfo(db, id, DOC, hardcover, { HARDCOVER_API_KEY: 'k' }, { hardcover: true }); + + await enrich(); + expect(row()).toEqual({ content_warnings: '["Violence"]', description: 'A thief in Tevanne.', release_year: 2018, + series: 'The Founders', series_position: 1, hc_series_id: 77 }); + + expect((await put({ release_year: 0 })).status).toBe(403); + expect((await put({ release_year: '2018' })).status).toBe(403); + expect((await put({ description: 'x'.repeat(6001) })).status).toBe(403); + expect((await put({ series: '' })).status).toBe(403); + expect((await put({ series_position: 2 })).status).toBe(403); // a number needs its series + expect((await put({ series: 'Tevanne', series_position: -1 })).status).toBe(403); + + const res = await put({ content_warnings: ['violence', 'Death'], description: ' Another one. ', release_year: 2020, + series: 'Tevanne', series_position: 2.5 }); + expect(await res.json()).toMatchObject({ content_warnings: ['Violence', 'Death'], description: 'Another one.', + release_year: 2020, series: 'Tevanne', series_position: 2.5 }); + // Hardcover's series id goes with the series it named. + const set = { content_warnings: '["Violence","Death"]', description: 'Another one.', release_year: 2020, + series: 'Tevanne', series_position: 2.5, hc_series_id: null }; + expect(row()).toEqual(set); + + // A lookup leaves them all alone. + db.prepare('UPDATE documents SET hc_checked_at = NULL WHERE document = ?').run(DOC); + await enrich(); + expect(row()).toEqual(set); + + // null hands one back; the others stay. + await put({ series: null, description: null }); + await enrich(); + expect(row()).toEqual({ ...set, description: 'A thief in Tevanne.', series: 'The Founders', series_position: 1, hc_series_id: 77 }); + }); + + it('keep a series set by hand together through a merge', async () => { + const { app, db } = makeTestApp(); + const { headers } = await registerUser(app); + const OTHER = 'ffffffffffffffffffffffffffffffff'; + for (const document of [DOC, OTHER]) await app.request('/syncs/progress', { ...progress(document), headers }); + db.prepare('UPDATE documents SET series = ?, series_position = 1, hc_series_id = 77 WHERE document = ?').run('The Founders', DOC); + await app.request(`/api/v1/documents/${OTHER}/info`, { method: 'PUT', headers, body: JSON.stringify({ series: 'Tevanne', series_position: 3 }) }); + + const merge = await app.request('/api/v1/documents/merge', { method: 'POST', headers, body: JSON.stringify({ document: OTHER, into: DOC }) }); + expect(merge.status).toBe(200); + expect(db.prepare('SELECT series, series_position, hc_series_id, series_manual FROM documents WHERE document = ?').get(DOC)) + .toEqual({ series: 'Tevanne', series_position: 3, hc_series_id: null, series_manual: 1 }); + }); + + it('keep a detail cleared by hand cleared through a merge', async () => { + const { app, db } = makeTestApp(); + const { headers } = await registerUser(app); + const OTHER = 'ffffffffffffffffffffffffffffffff'; + for (const document of [DOC, OTHER]) await app.request('/syncs/progress', { ...progress(document), headers }); + // No moods on purpose on the kept copy; Hardcover's on the other. + await app.request(`/api/v1/documents/${DOC}/info`, { method: 'PUT', headers, body: JSON.stringify({ moods: [] }) }); + db.prepare('UPDATE documents SET moods = ? WHERE document = ?').run('["Funny"]', OTHER); + + await app.request('/api/v1/documents/merge', { method: 'POST', headers, body: JSON.stringify({ document: OTHER, into: DOC }) }); + expect(db.prepare('SELECT moods, moods_manual FROM documents WHERE document = ?').get(DOC)).toEqual({ moods: null, moods_manual: 1 }); + }); +}); + diff --git a/test/manual-moods.test.ts b/test/manual-moods.test.ts new file mode 100644 index 0000000..6ced5a2 --- /dev/null +++ b/test/manual-moods.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { HttpTransport } from '../src/connectors/types.js'; +import { documentInfo } from '../src/models/cover.js'; +import { resetHardcoverThrottle } from '../src/models/hardcover-catalog.js'; +import { DOC, makeTestApp, registerUser } from './helpers.js'; + +// Hardcover finds the book with its own moods; the free cover sources find nothing. +const hardcover: HttpTransport = async (url) => { + const hit = { id: '41398', slug: 'foundryside', title: 'Foundryside', author_names: ['Robert Jackson Bennett'], + moods: ['adventurous', 'funny'] }; + const body = url.includes('hardcover') ? { data: { search: { results: { hits: [{ document: hit }] } } } } : {}; + return { status: 200, text: async () => JSON.stringify(body), json: async () => body }; +}; + +beforeEach(() => resetHardcoverThrottle()); + +const progress = (document: string) => ({ + method: 'PUT', + body: JSON.stringify({ document, progress: '/body/DocFragment[2]', percentage: 0.2, device: 'X4', device_id: 'x4', + metadata: { title: 'Foundryside', authors: 'Robert Jackson Bennett' } }), +}); + +describe('manual moods', () => { + it('stick over Hardcover lookups and clear back to them', async () => { + const { app, db } = makeTestApp(); + const { username, headers } = await registerUser(app); + await app.request('/syncs/progress', { ...progress(DOC), headers }); + const { id } = db.prepare('SELECT id FROM users WHERE username = ?').get(username) as { id: number }; + const put = (body: unknown) => app.request(`/api/v1/documents/${DOC}/info`, { method: 'PUT', headers, body: JSON.stringify(body) }); + const listed = async () => ((await (await app.request('/api/v1/progress', { headers })).json()) as { items: { moods: string[] }[] }).items[0].moods; + const enrich = () => documentInfo(db, id, DOC, hardcover, { HARDCOVER_API_KEY: 'k' }, { hardcover: true }); + + expect((await put({ moods: 'tense' })).status).toBe(403); + expect((await put({ moods: [''] })).status).toBe(403); + expect((await put({ moods: Array.from({ length: 13 }, (_, i) => `m${i}`) })).status).toBe(403); + + // Spelled the way Hardcover's are (trimmed, one per name whatever the case, first letter capitalized), so + // "dark" set here and Hardcover's "Dark" count as one mood. + expect(await (await put({ moods: [' tense ', 'Tense', 'dark'] })).json()).toMatchObject({ moods: ['Tense', 'Dark'] }); + expect(await listed()).toEqual(['Tense', 'Dark']); + + // Hardcover's lookup leaves them alone; null hands them back. + await enrich(); + expect(await listed()).toEqual(['Tense', 'Dark']); + expect(await (await put({ moods: null })).json()).toMatchObject({ moods: [] }); + await enrich(); + expect(await listed()).toEqual(['Adventurous', 'Funny']); // as Hardcover stores them + + // Genres and moods are set apart: one never touches the other. + await put({ moods: ['dark'] }); + await put({ genres: ['Heist'] }); + expect(await listed()).toEqual(['Dark']); + }); + + it('keep manual moods through a merge, over Hardcover\'s', async () => { + const { app, db } = makeTestApp(); + const { headers } = await registerUser(app); + const OTHER = 'ffffffffffffffffffffffffffffffff'; + for (const document of [DOC, OTHER]) await app.request('/syncs/progress', { ...progress(document), headers }); + db.prepare('UPDATE documents SET moods = ? WHERE document = ?').run('["funny"]', DOC); + await app.request(`/api/v1/documents/${OTHER}/info`, { method: 'PUT', headers, body: JSON.stringify({ moods: ['dark'] }) }); + + const merge = await app.request('/api/v1/documents/merge', { method: 'POST', headers, body: JSON.stringify({ document: OTHER, into: DOC }) }); + expect(merge.status).toBe(200); + expect(db.prepare('SELECT moods, moods_manual FROM documents WHERE document = ?').get(DOC)).toEqual({ moods: '["Dark"]', moods_manual: 1 }); + }); +});