diff --git a/packages/library-server/src/db.ts b/packages/library-server/src/db.ts index ddad91c..d825058 100644 --- a/packages/library-server/src/db.ts +++ b/packages/library-server/src/db.ts @@ -80,6 +80,11 @@ ALTER TABLE locations ADD COLUMN IF NOT EXISTS missing boolean NOT NULL DEFAULT INSERT INTO chalk_library_schema (version) VALUES (4);`); // step 5: the highlights of books (registry-api §11), editable records: the lines of a book and the questions linked to them if (!done.has(5)) await exec(`${HIGHLIGHTS_SQL}\nINSERT INTO chalk_library_schema (version) VALUES (5);`); + // step 6: a line's boxes as a person drew them (registry-api §11.3, lines/:id/rects): shown instead of the computed ones, and kept by a sync + if (!done.has(6)) await exec(`ALTER TABLE book_lines ADD COLUMN IF NOT EXISTS rects_manual jsonb; +ALTER TABLE book_lines ADD COLUMN IF NOT EXISTS rects_by text; +ALTER TABLE book_lines ADD COLUMN IF NOT EXISTS rects_at timestamptz; +INSERT INTO chalk_library_schema (version) VALUES (6);`); } /** Step 5: book highlights (spec/1.2/registry-api.md §11). Questions are named by their lasting id only (no foreign key: they are manifests). */ diff --git a/packages/library-server/src/highlights.test.ts b/packages/library-server/src/highlights.test.ts index 5eae14e..a04de59 100644 --- a/packages/library-server/src/highlights.test.ts +++ b/packages/library-server/src/highlights.test.ts @@ -48,6 +48,38 @@ describe('book highlights (registry-api §11)', () => { expect(l).toMatchObject({ text: 'corrected words', textHash: 'h-new' }); }); + it('a person\'s boxes of a line are read instead of the computed ones, kept by a sync, dropped when the words change, and reset by null', async () => { + const L = await ids(); + const drawnBox = [{ x: 140, y: 100, w: 380, fs: 18 }]; + const put = await call('PUT', b(`/lines/${L['p6/0/0']}/rects`), { rects: drawnBox, by: 'rafi' }); + expect(put.status).toBe(200); + expect(put.body.line).toMatchObject({ rects: drawnBox, rectsManual: true, rectsBy: 'rafi' }); + const page = async () => ((await call('GET', b('/lines?pages=6'))).body.pages['6'] as { key: string; rects: unknown; rectsManual: boolean }[]).find((x) => x.key === 'p6/0/0')!; + expect(await page()).toMatchObject({ rects: drawnBox, rectsManual: true }); + // a sync with other computed boxes keeps the person's, and does not count the line as changed for them + const again = await call('POST', b('/lines/sync'), { lines: [line(6, 0), line(6, 1), line(6, 2, 1), line(7, 0)] }); + expect(again.body).toMatchObject({ added: 0, changed: 0 }); + await call('POST', b('/lines/sync'), { lines: [{ ...line(6, 0), rects: [{ x: 90, y: 100, w: 510, fs: 18 }] }, line(6, 1), line(6, 2, 1), line(7, 0)] }); + expect(await page()).toMatchObject({ rects: drawnBox, rectsManual: true }); + // null: the computed boxes again + expect((await call('PUT', b(`/lines/${L['p6/0/0']}/rects`), { rects: null, by: 'rafi' })).body.line).toMatchObject({ rects: [{ x: 90, y: 100, w: 510, fs: 18 }], rectsManual: false, rectsBy: null }); + // drawn again, then the line's words change under the same key: the drawing belonged to the old words + await call('PUT', b(`/lines/${L['p6/0/0']}/rects`), { rects: drawnBox, by: 'rafi' }); + await call('POST', b('/lines/sync'), { lines: [{ ...line(6, 0), text: 'other words', textHash: 'h-other' }, line(6, 1), line(6, 2, 1), line(7, 0)] }); + expect(await page()).toMatchObject({ rects: line(6, 0).rects, rectsManual: false }); + }); + + it('drawing a line\'s boxes needs author, a line of the book that is not retired, and boxes on the page', async () => { + const L = await ids(); + expect((await call('PUT', b(`/lines/${L['p6/0/0']}/rects`), { rects: [{ x: 1, y: 1, w: 1, fs: 1 }], by: 'x' }, '')).status).toBe(401); + expect((await call('PUT', b('/lines/33333333-3333-4333-8333-333333333333/rects'), { rects: [{ x: 1, y: 1, w: 1, fs: 1 }], by: 'x' })).status).toBe(404); + expect((await call('PUT', b(`/lines/${L['p6/0/0']}/rects`), { rects: [{ x: 1, y: 1, w: 0, fs: 1 }], by: 'x' })).status).toBe(400); + expect((await call('PUT', b(`/lines/${L['p6/0/0']}/rects`), { rects: [], by: 'x' })).status).toBe(400); + expect((await call('PUT', b(`/lines/${L['p6/0/0']}/rects`), { rects: [{ x: -5, y: 1, w: 1, fs: 1 }], by: 'x' })).status).toBe(400); + await call('POST', b('/lines/sync'), { lines: [line(6, 1), line(6, 2, 1), line(7, 0)] }); + expect((await call('PUT', b(`/lines/${L['p6/0/0']}/rects`), { rects: [{ x: 1, y: 1, w: 1, fs: 1 }], by: 'x' })).status).toBe(409); + }); + it('a question\'s lock is the same whatever the case of its id: an upper-case id is serialised with the lower-case one', async () => { const L = await ids(); const add = await call('POST', b('/links'), { questionId: Q1.toUpperCase(), lineId: L['p6/0/0'], by: 'rafi' }); diff --git a/packages/library-server/src/highlights.ts b/packages/library-server/src/highlights.ts index 0483ae1..6425b8f 100644 --- a/packages/library-server/src/highlights.ts +++ b/packages/library-server/src/highlights.ts @@ -60,11 +60,20 @@ const importSchema = z.object({ })).max(500_000), }); const questionsSchema = z.object({ questionIds: z.array(uuid).max(5000) }); +/** A box a person drew: on the page (page units), with a width and a type size. */ +const drawn = z.object({ x: z.number().finite().min(0).max(100_000), y: z.number().finite().min(0).max(100_000), w: z.number().finite().positive().max(100_000), fs: z.number().finite().positive().max(1_000) }); +/** `rects`: the line's boxes as a person drew them; null goes back to the computed ones. */ +const rectsSchema = z.object({ rects: z.array(drawn).min(1).max(200).nullable(), by }); /** A person's decision, on the links table as `k`. */ const HUMAN = `(k.method = 'human' OR k.reviewed_by IS NOT NULL OR k.status = 'confirmed')`; +/** A line as readers get it: the boxes a person drew when there are any (`rectsManual`), else the computed ones. */ const LINE_COLS = `l.id, l.key, l.page_id AS "pageId", l.page_no AS "pageNo", l.idx, l.block, l.kind, l.text, l.text_hash AS "textHash", + l.chapter_no AS "chapterNo", l.chapter_title AS "chapterTitle", l.section, coalesce(l.rects_manual, l.rects) AS rects, + l.rects_manual IS NOT NULL AS "rectsManual", l.rects_by AS "rectsBy", l.retired_at AS "retiredAt"`; +/** A line as a sync compares it: the computed boxes (a person's are never replaced by a sync). */ +const SYNC_COLS = `l.id, l.key, l.page_id AS "pageId", l.page_no AS "pageNo", l.idx, l.block, l.kind, l.text, l.text_hash AS "textHash", l.chapter_no AS "chapterNo", l.chapter_title AS "chapterTitle", l.section, l.rects, l.retired_at AS "retiredAt"`; const LINK_COLS = `k.id, k.question_id AS "questionId", k.line_id AS "lineId", k.role, k.scope, k.method, k.status, k.score, k.rationale, k.question_type AS "questionType", k.reviewed_by AS "reviewedBy", k.reviewed_at AS "reviewedAt", k.created_at AS "createdAt", @@ -286,14 +295,17 @@ export function registerHighlights(app: Hono<{ Variables: AuthVariables }>, o: { const result = await db.begin(async (q) => { // one sync of a book at a time await q(`SELECT pg_advisory_xact_lock(hashtext($1))`, [`chalk-highlights:sync:${book}`]); - const stored = await q(`SELECT ${LINE_COLS} FROM book_lines l WHERE l.book = $1`, [book]); + const stored = await q(`SELECT ${SYNC_COLS} FROM book_lines l WHERE l.book = $1`, [book]); const plan = planLines(stored, lines); for (const part of chunks(plan.write)) { await q(`INSERT INTO book_lines (book, key, page_id, page_no, idx, block, kind, text, text_hash, chapter_no, chapter_title, section, rects) SELECT $1, x.key, x."pageId", x."pageNo", x.idx, x.block, x.kind, x.text, x."textHash", x."chapterNo", x."chapterTitle", x.section, coalesce(x.rects, '[]') FROM jsonb_to_recordset($2::text::jsonb) AS ${LINE_RECORDSET} ON CONFLICT (book, key) DO UPDATE SET page_id = EXCLUDED.page_id, page_no = EXCLUDED.page_no, idx = EXCLUDED.idx, block = EXCLUDED.block, kind = EXCLUDED.kind, - text = EXCLUDED.text, text_hash = EXCLUDED.text_hash, chapter_no = EXCLUDED.chapter_no, chapter_title = EXCLUDED.chapter_title, section = EXCLUDED.section, rects = EXCLUDED.rects, retired_at = NULL, updated_at = now()`, + text = EXCLUDED.text, text_hash = EXCLUDED.text_hash, chapter_no = EXCLUDED.chapter_no, chapter_title = EXCLUDED.chapter_title, section = EXCLUDED.section, rects = EXCLUDED.rects, + rects_manual = CASE WHEN book_lines.text_hash = EXCLUDED.text_hash THEN book_lines.rects_manual END, + rects_by = CASE WHEN book_lines.text_hash = EXCLUDED.text_hash THEN book_lines.rects_by END, + rects_at = CASE WHEN book_lines.text_hash = EXCLUDED.text_hash THEN book_lines.rects_at END, retired_at = NULL, updated_at = now()`, [book, JSON.stringify(part)]); } for (const part of chunks(plan.retire)) await q(`UPDATE book_lines SET retired_at = now(), updated_at = now() WHERE book = $1 AND retired_at IS NULL AND key = ANY($2::text[])`, [book, part]); @@ -385,6 +397,21 @@ export function registerHighlights(app: Hono<{ Variables: AuthVariables }>, o: { return c.json({ removed }); })); + // a line's boxes as a person drew them (the computed ones are often a few letters off where a sentence starts or ends inside a printed + // line): shown instead of the computed ones from now on, and kept by every sync; null goes back to the computed ones + app.put('/v1/books/:slug/lines/:id/rects', (c) => run(c, 'author', async (book) => { + const b = await parse(c, rectsSchema); + const id = idOf(c.req.param('id'))!; + if (!uuid.safeParse(id).success) throw notFound('that line is not in this book'); + const line = await db.begin(async (q) => { + await lineOf(q, book, id, true); + await q(`UPDATE book_lines SET rects_manual = $3::text::jsonb, rects_by = $4, rects_at = CASE WHEN $3::text IS NULL THEN NULL ELSE now() END, updated_at = now() WHERE id = $1 AND book = $2`, + [id, book, b.rects === null ? null : JSON.stringify(b.rects), b.rects === null ? null : b.by]); + return (await q(`SELECT ${LINE_COLS} FROM book_lines l WHERE l.id = $1`, [id]))[0]; + }); + return c.json({ line }); + })); + app.post('/v1/books/:slug/lines/:id/links/move', (c) => run(c, 'author', async (book) => { const b = await parse(c, moveSchema); const from = idOf(c.req.param('id'))!; diff --git a/spec/1.2/registry-api.md b/spec/1.2/registry-api.md index 1ee6f24..c9e2912 100644 --- a/spec/1.2/registry-api.md +++ b/spec/1.2/registry-api.md @@ -325,13 +325,18 @@ A **line** (one sentence of the text layer): ```json { "id": "…uuid…", "key": "p12/3b0fbd6c9c/0", "pageId": "p12", "pageNo": 12, "idx": 4, "block": 1, "kind": "body", "text": "…", "textHash": "…", "chapterNo": 1, "chapterTitle": "…", "section": "…", - "rects": [{ "x": 120, "y": 530, "w": 610, "fs": 19 }], "retiredAt": null } + "rects": [{ "x": 120, "y": 530, "w": 610, "fs": 19 }], "rectsManual": false, "rectsBy": null, "retiredAt": null } ``` `key` is the line's lasting name (unique in the book; the indexer derives it from the page and the words), `rects` one box per printed line in page units (`y` the top; a box is `fs × 1.25` high). A line the text layer no longer has is **retired** (`retiredAt` set), never deleted, so the links on it are kept. +`rects` are computed by the indexer and can be a few letters off where a line starts or ends inside a printed line. A person +can draw them (`PUT lines/{id}/rects`): those boxes are then the line's `rects` for every reader (`rectsManual: true`, +`rectsBy` who drew them), a sync keeps them while the line's words stay (`textHash`), and `null` goes back to the computed +ones. A registry without this sends neither field; a reader treats a missing `rectsManual` as `false`. + A **link** (a question tied to a line): ```json @@ -379,6 +384,7 @@ Every write names the person (or job) in `by`, kept as `reviewedBy`. Each reques | `PATCH links/{id}` `{ status?, role?, scope?, lineId?, by }` | Changes one link. Changing role, scope or line, or rejecting, makes it `human`; a move confirms it unless `status` is given. Moving onto a pair that exists merges into that row (confirmed, `human`) and drops this one. → the link kept | | `DELETE links/{id}` `{ by }` | Takes it off: the row stays, `rejected`, `human`. → `{ "removed": 1 }` | | `POST lines/{id}/links/remove` `{ paragraph?, by }` | Takes off every live link of the line (or its paragraph). → `{ "removed": n }` | +| `PUT lines/{id}/rects` `{ rects, by }` | The line's boxes as a person drew them (`[{x, y, w, fs}]`, 1 to 200, on the page: `x`, `y` ≥ 0, `w`, `fs` > 0), or `null` for the computed ones again. A live line of the book only (404, 409 for a retired one). → `{ "line" }` | | `POST lines/{id}/links/move` `{ to, by }` | Moves the live links of a line to another: a pair the other line has is kept (confirmed if it was rejected, `human`) and this one dropped; rejected rows stay. → `{ "moved": n }` | | `PUT questions/{questionId}/lines` `{ role, add: [lineId], remove: [lineId], scope?, questionType?, by }` | A question's highlight under one role (≤ 200 lines named): `add`ed lines become the person's confirmed links, `remove`d ones rejected rows (whatever they were), the kept ones the person's. → `{ "items": [link + line] }` as `GET links?question=` | | `POST links/place` `{ questionId, questionType?, proposals: [{ lineId, role, scope?, score?, rationale? }] }` | A matcher's suggestions, written only if the question still has no live link and no person's decision in any book; lines retired since, and lines the question already has a row on, are skipped. → `{ "placed": n }` (0: not placed) |