From 1ff456b04fe57cc6f13aa1b6c8cbf0c275aefb52 Mon Sep 17 00:00:00 2001 From: Eszter Date: Sun, 25 Oct 2026 11:28:24 +0100 Subject: [PATCH 1/4] feat: set moods by hand, like genres PUT /documents/{document}/info takes moods, up to 12 names. Like manual genres they stick over Hardcover's lookup, null hands them back, and a merge keeps them over the other copy's. Both lists share one check. --- docs/API.md | 11 +++--- migrations/0036_manual_moods.sql | 2 + src/models/cover.ts | 5 ++- src/models/merge.ts | 8 +++- src/routes/v1/documents.ts | 31 +++++++++------ test/manual-moods.test.ts | 66 ++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 migrations/0036_manual_moods.sql create mode 100644 test/manual-moods.test.ts diff --git a/docs/API.md b/docs/API.md index 8c293f5..4d80e55 100644 --- a/docs/API.md +++ b/docs/API.md @@ -669,11 +669,12 @@ 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", "dark"]}` (every field optional). Manual values stick, +since lookups only fill blanks and Hardcover's lookup leaves manual genres and moods alone; `null` clears a +field so it's looked up again. `genres` takes up to 10 names and `moods` up to 12, each at most 64 +characters; a list replaces the stored one, so to add one send the current list with it. `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}`. #### GET /api/v1/documents/{document}/cover/candidates?q= diff --git a/migrations/0036_manual_moods.sql b/migrations/0036_manual_moods.sql new file mode 100644 index 0000000..8fe7c5c --- /dev/null +++ b/migrations/0036_manual_moods.sql @@ -0,0 +1,2 @@ +-- Moods set through PUT /api/v1/documents/:document/info stick over Hardcover lookups. +ALTER TABLE documents ADD COLUMN moods_manual INTEGER NOT NULL DEFAULT 0; diff --git a/src/models/cover.ts b/src/models/cover.ts index 9a29bbf..a788bf2 100644 --- a/src/models/cover.ts +++ b/src/models/cover.ts @@ -215,11 +215,12 @@ 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). Genres and moods 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 = ?, + `UPDATE documents SET hc_id = ?, hc_slug = ?, moods = CASE WHEN moods_manual THEN moods ELSE ? END, + genres = CASE WHEN genres_manual THEN genres ELSE ? END, content_warnings = ?, rating = ?, series = ?, hc_series_id = ?, series_position = ?, release_year = ?, description = ?, hc_checked_at = ? WHERE user_id = ? AND document = ?` ).run( diff --git a/src/models/merge.ts b/src/models/merge.ts index 048de77..edeb07a 100644 --- a/src/models/merge.ts +++ b/src/models/merge.ts @@ -148,8 +148,8 @@ export function mergeDocuments( // documents: canonical metadata wins, alias fills the gaps. 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, genres, genres_manual, moods, moods_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, moods, moods_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), @@ -167,6 +167,10 @@ export function mergeDocuments( 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), + -- Moods the same way. + moods = CASE WHEN excluded.moods_manual AND NOT documents.moods_manual THEN excluded.moods + ELSE COALESCE(documents.moods, excluded.moods) END, + moods_manual = MAX(documents.moods_manual, excluded.moods_manual), 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..920d434 100644 --- a/src/routes/v1/documents.ts +++ b/src/routes/v1/documents.ts @@ -290,17 +290,22 @@ 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 and moods: the shape Hardcover's are stored in, a short list of short names, trimmed and + // de-duplicated. null clears a list; undefined leaves it alone. + const tags = (v: unknown, max: number) => { + const list = Array.isArray(v) ? [...new Set(v.map(text))] : v; + const ok = list === undefined || list === null || + (Array.isArray(list) && list.length <= max && list.every((g) => typeof g === 'string' && g.length > 0 && g.length <= 64)); + return { list: list as string[] | null | undefined, ok }; + }; + const genres = tags(o.genres, 10); + const moods = tags(o.moods, 12); // 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 || !genres.ok || !moods.ok || (language === null && o.language !== null)) { return kosyncError(c, 403, 2003, 'Invalid request'); } const user = c.get('user'); @@ -316,11 +321,12 @@ 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 [column, { list }] of [['genres', genres], ['moods', moods]] as const) { + if (list === undefined) continue; + // A list set here sticks over Hardcover's; clearing hands it back: the next enrichment pass looks + // the book up again. `column` is one of the two names above, never request data. db.prepare( - `UPDATE documents SET genres = ?, genres_manual = ?, hc_checked_at = CASE WHEN ? THEN hc_checked_at ELSE NULL END + `UPDATE documents SET ${column} = ?, ${column}_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); } @@ -335,11 +341,12 @@ 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 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; }; 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: row.genres ? (JSON.parse(row.genres) as string[]) : [], moods: row.moods ? (JSON.parse(row.moods) as string[]) : [] }); }); app.get('/documents', (c) => { diff --git a/test/manual-moods.test.ts b/test/manual-moods.test.ts new file mode 100644 index 0000000..f166800 --- /dev/null +++ b/test/manual-moods.test.ts @@ -0,0 +1,66 @@ +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); + + // Trimmed and de-duplicated, returned, and in the book list. + 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 }); + }); +}); From 69f38807e70c1a43015ae33821cd2f3dc8ad1070 Mon Sep 17 00:00:00 2001 From: Eszter Date: Sun, 25 Oct 2026 11:31:23 +0100 Subject: [PATCH 2/4] fix: spell manual genres and moods the way Hardcover's are Manual lists go through the same tidy() as Hardcover's: trimmed, one entry per name whatever the case, first letter capitalized. Otherwise the web app's mood and genre counts, which compare names exactly, would list a manual "dark" and Hardcover's "Dark" apart. --- docs/API.md | 4 +++- src/models/hardcover-catalog.ts | 2 +- src/routes/v1/documents.ts | 15 ++++++++------- test/manual-moods.test.ts | 13 +++++++------ 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/API.md b/docs/API.md index 4d80e55..22be520 100644 --- a/docs/API.md +++ b/docs/API.md @@ -672,7 +672,9 @@ Manual fixes when the lookup got it wrong: `{"cover_url": "https://...", "page_c "genres": ["Fantasy", "Heist"], "moods": ["tense", "dark"]}` (every field optional). Manual values stick, since lookups only fill blanks and Hardcover's lookup leaves manual genres and moods alone; `null` clears a field so it's looked up again. `genres` takes up to 10 names and `moods` up to 12, each at most 64 -characters; a list replaces the stored one, so to add one send the current list with it. `language` (a BCP 47 +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. `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}`. 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/routes/v1/documents.ts b/src/routes/v1/documents.ts index 920d434..e770cd4 100644 --- a/src/routes/v1/documents.ts +++ b/src/routes/v1/documents.ts @@ -5,7 +5,7 @@ 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'; @@ -290,13 +290,14 @@ 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); - // Genres and moods: the shape Hardcover's are stored in, a short list of short names, trimmed and - // de-duplicated. null clears a list; undefined leaves it alone. + // Genres and moods: 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) => { - const list = Array.isArray(v) ? [...new Set(v.map(text))] : v; - const ok = list === undefined || list === null || - (Array.isArray(list) && list.length <= max && list.every((g) => typeof g === 'string' && g.length > 0 && g.length <= 64)); - return { list: list as string[] | null | undefined, ok }; + 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 }; }; const genres = tags(o.genres, 10); const moods = tags(o.moods, 12); diff --git a/test/manual-moods.test.ts b/test/manual-moods.test.ts index f166800..6ced5a2 100644 --- a/test/manual-moods.test.ts +++ b/test/manual-moods.test.ts @@ -34,13 +34,14 @@ describe('manual moods', () => { expect((await put({ moods: [''] })).status).toBe(403); expect((await put({ moods: Array.from({ length: 13 }, (_, i) => `m${i}`) })).status).toBe(403); - // Trimmed and de-duplicated, returned, and in the book list. - expect(await (await put({ moods: [' tense ', 'tense', 'dark'] })).json()).toMatchObject({ moods: ['tense', 'dark'] }); - expect(await listed()).toEqual(['tense', 'dark']); + // 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 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 @@ -48,7 +49,7 @@ describe('manual moods', () => { // Genres and moods are set apart: one never touches the other. await put({ moods: ['dark'] }); await put({ genres: ['Heist'] }); - expect(await listed()).toEqual(['dark']); + expect(await listed()).toEqual(['Dark']); }); it('keep manual moods through a merge, over Hardcover\'s', async () => { @@ -61,6 +62,6 @@ describe('manual moods', () => { 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 }); + expect(db.prepare('SELECT moods, moods_manual FROM documents WHERE document = ?').get(DOC)).toEqual({ moods: '["Dark"]', moods_manual: 1 }); }); }); From 802157cc84aa018a92d73968ca32234d4df0348d Mon Sep 17 00:00:00 2001 From: Eszter Date: Sun, 25 Oct 2026 11:38:13 +0100 Subject: [PATCH 3/4] feat: set the other Hardcover details by hand too Content warnings, description, series with its number, and release year can be set through PUT /documents/{document}/info like genres and moods: set by hand they stick over Hardcover's lookup, null hands them back, and a merge keeps them. One table of these details drives the route, the lookup and the merge. A series set by hand drops Hardcover's series id, which points at the series Hardcover found. --- docs/API.md | 27 +++++++--- migrations/0036_manual_details.sql | 7 +++ migrations/0036_manual_moods.sql | 2 - src/models/cover.ts | 9 ++-- src/models/manual-details.ts | 46 +++++++++++++++++ src/models/merge.ts | 15 ++---- src/routes/v1/documents.ts | 64 ++++++++++++++++++----- test/manual-details.test.ts | 81 ++++++++++++++++++++++++++++++ 8 files changed, 214 insertions(+), 37 deletions(-) create mode 100644 migrations/0036_manual_details.sql delete mode 100644 migrations/0036_manual_moods.sql create mode 100644 src/models/manual-details.ts create mode 100644 test/manual-details.test.ts diff --git a/docs/API.md b/docs/API.md index 22be520..eabe6a4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -669,14 +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"], "moods": ["tense", "dark"]}` (every field optional). Manual values stick, -since lookups only fill blanks and Hardcover's lookup leaves manual genres and moods alone; `null` clears a -field so it's looked up again. `genres` takes up to 10 names and `moods` up to 12, each 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. `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}`. +"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/migrations/0036_manual_moods.sql b/migrations/0036_manual_moods.sql deleted file mode 100644 index 8fe7c5c..0000000 --- a/migrations/0036_manual_moods.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Moods set through PUT /api/v1/documents/:document/info stick over Hardcover lookups. -ALTER TABLE documents ADD COLUMN moods_manual INTEGER NOT NULL DEFAULT 0; diff --git a/src/models/cover.ts b/src/models/cover.ts index a788bf2..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,13 +216,13 @@ async function sharedBookInfo( return info; } -/** Store Hardcover's details on a document (a miss just records the check). Genres and moods 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 = CASE WHEN moods_manual THEN moods ELSE ? END, - 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/manual-details.ts b/src/models/manual-details.ts new file mode 100644 index 0000000..dff9576 --- /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; otherwise the kept document's wins. + * A group (series) moves together, so a series and the number from the other copy never mix. + */ +export function mergeManualSql(): string { + return MANUAL_DETAILS.flatMap((d) => { + const fromAlias = `excluded.${d.flag}_manual AND NOT documents.${d.flag}_manual`; + // The kept document's group whole when it has one, else the merged-away one's. + const keptHas = `documents.${d.columns[0]} IS NOT NULL`; + return [ + ...d.columns.map((c) => + `${c} = CASE WHEN ${fromAlias} THEN excluded.${c} WHEN ${keptHas} 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 edeb07a..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, moods, moods_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, moods, moods_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,14 +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), - -- Moods the same way. - moods = CASE WHEN excluded.moods_manual AND NOT documents.moods_manual THEN excluded.moods - ELSE COALESCE(documents.moods, excluded.moods) END, - moods_manual = MAX(documents.moods_manual, excluded.moods_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 e770cd4..adf69fe 100644 --- a/src/routes/v1/documents.ts +++ b/src/routes/v1/documents.ts @@ -11,6 +11,7 @@ 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,7 +291,7 @@ 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); - // Genres and moods: a short list of short names, spelled the way Hardcover's are stored (tidy(): + // 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) => { @@ -299,14 +300,43 @@ export function documentRoutes(db: DB, http?: HttpTransport): Hono { 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 }; }; - const genres = tags(o.genres, 10); - const moods = tags(o.moods, 12); + // 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 || !genres.ok || !moods.ok || (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'); @@ -322,14 +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); } - for (const [column, { list }] of [['genres', genres], ['moods', moods]] as const) { - if (list === undefined) continue; - // A list set here sticks over Hardcover's; clearing hands it back: the next enrichment pass looks - // the book up again. `column` is one of the two names above, never request data. + 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 ${column} = ?, ${column}_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 @@ -342,12 +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, moods 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; + 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[]) : [], moods: row.moods ? (JSON.parse(row.moods) 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..107bba2 --- /dev/null +++ b/test/manual-details.test.ts @@ -0,0 +1,81 @@ +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 }); + }); +}); From b426de39e45879b24e28d0bbe17786dc5cb62af1 Mon Sep 17 00:00:00 2001 From: Eszter Date: Sun, 25 Oct 2026 11:40:05 +0100 Subject: [PATCH 4/4] fix: a detail cleared by hand stays cleared through a merge The kept copy's value now wins whenever it was set by hand, even empty. Before, a list cleared on purpose took the other copy's Hardcover list and kept the manual flag, so the lookup's values ended up frozen. The genre merge from #35 had the same gap. --- src/models/manual-details.ts | 10 +++++----- test/manual-details.test.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/models/manual-details.ts b/src/models/manual-details.ts index dff9576..655037a 100644 --- a/src/models/manual-details.ts +++ b/src/models/manual-details.ts @@ -29,17 +29,17 @@ export function keepManual(column: string): string { 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; otherwise the kept document's wins. - * A group (series) moves together, so a series and the number from the other copy never mix. + * 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`; - // The kept document's group whole when it has one, else the merged-away one's. - const keptHas = `documents.${d.columns[0]} IS NOT NULL`; + 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 ${keptHas} THEN documents.${c} ELSE excluded.${c} END`), + `${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/test/manual-details.test.ts b/test/manual-details.test.ts index 107bba2..ea4f6d0 100644 --- a/test/manual-details.test.ts +++ b/test/manual-details.test.ts @@ -78,4 +78,18 @@ describe('book details set by hand', () => { 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 }); + }); }); +