Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
7 changes: 7 additions & 0 deletions migrations/0036_manual_details.sql
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 5 additions & 3 deletions src/models/cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/models/hardcover-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
for (const v of Array.isArray(list) ? list : []) {
Expand Down
46 changes: 46 additions & 0 deletions src/models/manual-details.ts
Original file line number Diff line number Diff line change
@@ -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 (`<flag>_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<string, string>(MANUAL_DETAILS.flatMap((d) => d.columns.map((c) => [c, d.flag] as const)));

/** `column = <the lookup's value unless set by hand>`, 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 ');
}
11 changes: 5 additions & 6 deletions src/models/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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);
Expand Down
74 changes: 60 additions & 14 deletions src/routes/v1/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -290,17 +291,52 @@ export function documentRoutes(db: DB, http?: HttpTransport): Hono<AppEnv> {
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<ManualFlag, (string | number | null)[] | null>();
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');
Expand All @@ -316,13 +352,16 @@ export function documentRoutes(db: DB, http?: HttpTransport): Hono<AppEnv> {
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
Expand All @@ -335,11 +374,18 @@ export function documentRoutes(db: DB, http?: HttpTransport): Hono<AppEnv> {
).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) => {
Expand Down
Loading
Loading