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
5 changes: 5 additions & 0 deletions packages/library-server/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
32 changes: 32 additions & 0 deletions packages/library-server/src/highlights.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
31 changes: 29 additions & 2 deletions packages/library-server/src/highlights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<LineRow>(`SELECT ${LINE_COLS} FROM book_lines l WHERE l.book = $1`, [book]);
const stored = await q<LineRow>(`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]);
Expand Down Expand Up @@ -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'))!;
Expand Down
8 changes: 7 additions & 1 deletion spec/1.2/registry-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
Loading