From 55cc60281574362d8f6d6aaf9658f7efafdaf4b6 Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Thu, 8 Oct 2026 23:48:42 +0200 Subject: [PATCH] Library: bookmarked phrases, and glyphs where the reader's marks are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Select any part of a couplet or paragraph and press نشان (selection menu): the phrase is bookmarked and shown underlined with a small 🔖, also across the two misras; clicking it removes the bookmark (with a confirmation). Listed in the library as نشان زدہ عبارتیں. - Contents lists show ♥ on favourite works and 🔖 on works holding bookmarks; book/section cards and poet cards (home) show 🔖 when they hold any. API: GET /api/library/marks; phrases in state and library; phrase kind validated against the couplet's text. Co-Authored-By: Claude Opus 5.5 --- api/src/library.test.ts | 19 ++++++++++-- api/src/library.ts | 56 ++++++++++++++++++++++++++++++----- api/src/server.ts | 4 +-- db/schema.sql | 14 ++++++--- web/src/components/Seg.astro | 7 +++-- web/src/layouts/Base.astro | 13 ++++++-- web/src/lib/phrases.test.mjs | 10 +++++++ web/src/lib/phrases.ts | 18 +++++++++++ web/src/pages/[...path].astro | 38 +++++++++++++++++++----- web/src/pages/index.astro | 9 ++++-- web/src/pages/library.astro | 33 +++++++++++++++++++-- web/src/styles/global.css | 8 +++++ 12 files changed, 196 insertions(+), 33 deletions(-) create mode 100644 web/src/lib/phrases.test.mjs create mode 100644 web/src/lib/phrases.ts diff --git a/api/src/library.test.ts b/api/src/library.test.ts index adfaa4f6..d5d4cfe1 100644 --- a/api/src/library.test.ts +++ b/api/src/library.test.ts @@ -2,7 +2,7 @@ import { test, after } from 'node:test'; import assert from 'node:assert/strict'; import Fastify from 'fastify'; import { authRoutes } from './auth.ts'; -import { libraryRoutes, cleanWord } from './library.ts'; +import { libraryRoutes, cleanWord, cleanPhrase } from './library.ts'; import { pool } from './db.ts'; after(() => pool.end()); @@ -11,6 +11,8 @@ test('saved words are cleaned like the sidebar does', () => { assert.equal(cleanWord(' دل، '), 'دل'); assert.equal(cleanWord('غالبؔ'), 'غالب'); assert.equal(cleanWord('love'), null); + assert.equal(cleanPhrase(' وصال یار '), 'وصال یار'); + assert.equal(cleanPhrase('ی'), null); assert.equal(cleanWord(''), null); }); @@ -36,8 +38,19 @@ test('library: save and unsave poets, works, couplets and words; full paths; not assert.equal((await toggle({ kind: 'poet', poetId: 999999 })).statusCode, 404); assert.equal((await toggle({ kind: 'word', word: 'hello' })).statusCode, 400); + // phrases: part of a couplet (also across its two misras), must be in that couplet + const lines0 = (await pool.query('SELECT text FROM verses WHERE poem_id = $1 AND couplet = 0 ORDER BY vorder', [poem])).rows.map((r) => r.text); + const across = lines0[0].split(' ').slice(-2).join(' ') + ' ' + lines0[1].split(' ')[0]; + assert.equal((await toggle({ kind: 'phrase', poemId: poem, couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') })).json().saved, true); + assert.equal((await toggle({ kind: 'phrase', poemId: poem, couplet: 0, phrase: across })).json().saved, true, 'across the two misras'); + assert.equal((await toggle({ kind: 'phrase', poemId: poem, couplet: 0, phrase: 'یہ عبارت یہاں نہیں' })).statusCode, 400); + // marks for contents lists: the work, its book and its poet + const marks = (await call('GET', '/api/library/marks', undefined, t)).json(); + assert.deepEqual(marks.poems[poem], { fav: true, bm: 4 }, 'two couplets and two phrases'); + const book = (await pool.query(`SELECT id FROM categories WHERE url = '/p266/ghazal'`)).rows[0].id; + assert.ok(marks.categories.includes(book) && marks.poets.includes(266)); // state for a page - assert.deepEqual((await call('GET', `/api/library/state?poet=266&poem=${poem}`, undefined, t)).json(), { poet: true, poem: true, couplets: [0, 2] }); + assert.deepEqual((await call('GET', `/api/library/state?poet=266&poem=${poem}`, undefined, t)).json(), { poet: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }] }); assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true }); assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false }); @@ -48,6 +61,8 @@ test('library: save and unsave poets, works, couplets and words; full paths; not assert.equal(lib.poets[0].poet.url, '/p266'); assert.deepEqual(lib.poems[0].poem.path.map((c: any) => c.url), ['/p266', '/p266/ghazal'], 'poet » book path'); assert.equal(lib.couplets.length, 1); + assert.equal(lib.phrases.length, 2); + assert.deepEqual(lib.phrases[0].poem.path.map((c: any) => c.url), ['/p266', '/p266/ghazal']); assert.equal(lib.couplets[0].lines.length, 2, 'both misras'); assert.equal(lib.words[0].word, 'وصال'); assert.equal(lib.words[0].source.couplet, 0); diff --git a/api/src/library.ts b/api/src/library.ts index a85100bf..b219ba7e 100644 --- a/api/src/library.ts +++ b/api/src/library.ts @@ -1,17 +1,25 @@ -// Personal library (#24, #25): a reader's saved poets and works, bookmarked couplets and saved words. +// Personal library (#24, #25): a reader's saved poets and works, bookmarked couplets and phrases, saved words. // JSON in and out with Bearer tokens, so the site and mobile apps (#44) use the same endpoints. // GET /api/library everything, each item with its path in the site -// GET /api/library/state?poet=&poem=&word= what is saved on one page (poet, poem, couplets) or a word -// POST /api/library/toggle {kind, poetId?, poemId?, couplet?, word?} -> {saved, id?} +// GET /api/library/state?poet=&poem=&word= what is saved on one page (poet, poem, couplets, phrases) or a word +// GET /api/library/marks which works, books/sections and poets hold the reader's saved items +// POST /api/library/toggle {kind, poetId?, poemId?, couplet?, phrase?, word?} -> {saved, id?} // POST /api/library/:id/note {note} // POST /api/library/:id/delete -// kind: poet {poetId} | poem {poemId} | couplet {poemId, couplet} | word {word, poemId?, couplet?} +// kind: poet {poetId} | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a +// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { pool } from './db.ts'; import { sessionUser } from './auth.ts'; import { PUNCT } from './dictionary.ts'; -const KINDS = ['poet', 'poem', 'couplet', 'word'] as const; +const KINDS = ['poet', 'poem', 'couplet', 'phrase', 'word'] as const; + +// a bookmarked phrase: spaces collapsed, 2 to 300 characters +export const cleanPhrase = (p: unknown) => { + const s = String(p ?? '').normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').trim(); + return s.length >= 2 && s.length <= 300 ? s : null; +}; async function reader(req: FastifyRequest, reply: FastifyReply) { const u = await sessionUser(req); @@ -44,7 +52,7 @@ export function libraryRoutes(app: FastifyInstance) { app.get('/api/library', async (req, reply) => { const u = await reader(req, reply); if (!u) return; const { rows } = await pool.query( - `SELECT l.id, l.kind, l.poet_id, l.poem_id, l.couplet, l.word, l.note, l.created_at, + `SELECT l.id, l.kind, l.poet_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id WHERE l.user_id = $1 ORDER BY l.created_at DESC, l.id DESC`, [u.id]); @@ -62,6 +70,7 @@ export function libraryRoutes(app: FastifyInstance) { poets: rows.filter((r) => r.kind === 'poet').map((r) => ({ ...item(r), poet: r.poet_url && { url: r.poet_url, name: r.poet_name } })), poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })), couplets: rows.filter((r) => r.kind === 'couplet').map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })), + phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })), words: rows.filter((r) => r.kind === 'word').map((r) => ({ ...item(r), word: r.word, source: r.poem_url ? { ...where(r), lines: r.couplet != null ? lines(r.poem_id, r.couplet) : [] } : null, })), @@ -76,11 +85,31 @@ export function libraryRoutes(app: FastifyInstance) { } const poet = Number(req.query.poet) || 0, poem = Number(req.query.poem) || 0; const { rows } = await pool.query( - `SELECT kind, couplet FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) OR (kind IN ('poem', 'couplet') AND poem_id = $3))`, + `SELECT kind, couplet, phrase FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $3))`, [u.id, poet, poem]); return { poet: rows.some((r) => r.kind === 'poet'), poem: rows.some((r) => r.kind === 'poem'), couplets: rows.filter((r) => r.kind === 'couplet').map((r) => r.couplet), + phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ couplet: r.couplet, phrase: r.phrase })), + }; + }); + + // for contents lists and cards: works that are favourites (fav) or hold bookmarks (bm), and the books, + // sections and poets containing them + app.get('/api/library/marks', async (req, reply) => { + const u = await reader(req, reply); if (!u) return; + const { rows } = await pool.query( + `SELECT poem_id, bool_or(kind = 'poem') AS fav, count(*) FILTER (WHERE kind IN ('couplet', 'phrase'))::int AS bm + FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') GROUP BY poem_id`, [u.id]); + const ids = rows.map((r) => r.poem_id); + const [cats, poets] = await Promise.all([ + pool.query(`WITH RECURSIVE up AS (SELECT c.id, c.parent_id FROM poems p JOIN categories c ON c.id = p.category_id WHERE p.id = ANY($1) + UNION SELECT c.id, c.parent_id FROM categories c JOIN up ON c.id = up.parent_id) SELECT id FROM up`, [ids]), + pool.query('SELECT DISTINCT poet_id FROM poems WHERE id = ANY($1) UNION SELECT poet_id FROM library WHERE user_id = $2 AND kind = $3', [ids, u.id, 'poet']), + ]); + return { + poems: Object.fromEntries(rows.map((r) => [r.poem_id, { fav: r.fav, bm: r.bm }])), + categories: cats.rows.map((r) => r.id), poets: poets.rows.map((r) => r.poet_id), }; }); @@ -102,6 +131,19 @@ export function libraryRoutes(app: FastifyInstance) { const { rows } = await pool.query(`INSERT INTO library (user_id, kind, word, poem_id, couplet) VALUES ($1, 'word', $2, $3, $4) RETURNING id`, [u.id, word, ...src]); return { saved: true, id: Number(rows[0].id) }; } + if (kind === 'phrase') { + const phrase = cleanPhrase(b.phrase); + if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' }); + // the phrase must be in that couplet or paragraph (its lines, or across its two misras) + const text = (await pool.query('SELECT string_agg(text, $3 ORDER BY vorder) AS t FROM verses WHERE poem_id = $1 AND couplet = $2', [poemId, couplet, ' '])).rows[0]?.t; + if (!text) return reply.code(404).send({ error: 'نہیں ملا' }); + if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return reply.code(400).send({ error: 'یہ عبارت اس شعر میں نہیں' }); + const args = [u.id, poemId, couplet, phrase]; + if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4 RETURNING id`, args)).rowCount) + return { saved: false }; + const { rows } = await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase) VALUES ($1, 'phrase', $2, $3, $4) RETURNING id`, args); + return { saved: true, id: Number(rows[0].id) }; + } // poet, poem or couplet: the target must exist const exists = kind === 'poet' ? await pool.query('SELECT 1 FROM poets WHERE id = $1', [poetId]) : kind === 'poem' ? await pool.query('SELECT 1 FROM poems WHERE id = $1', [poemId]) diff --git a/api/src/server.ts b/api/src/server.ts index ad9bf30c..50212271 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -74,12 +74,12 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => { SELECT id AS root, id FROM categories WHERE parent_id = $1 UNION ALL SELECT tree.root, c.id FROM categories c JOIN tree ON c.parent_id = tree.id) - SELECT c.url, c.title, count(p.id)::int AS poems + SELECT c.id, c.url, c.title, count(p.id)::int AS poems FROM categories c JOIN tree ON tree.root = c.id LEFT JOIN poems p ON p.category_id = tree.id GROUP BY c.id ORDER BY c.position, c.id`, [cat.id], ), - pool.query('SELECT url, title, radif_letter FROM poems WHERE category_id = $1 ORDER BY position, id', [cat.id]), + pool.query('SELECT id, url, title, radif_letter FROM poems WHERE category_id = $1 ORDER BY position, id', [cat.id]), ]); return { type: cat.parent_id === null ? 'poet' : 'category', diff --git a/db/schema.sql b/db/schema.sql index 354dc0a9..269710f4 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -126,17 +126,23 @@ CREATE INDEX IF NOT EXISTS grants_user ON grants(user_id); -- profile (owner request): full name and bio, usually in Urdu ALTER TABLE users ADD COLUMN IF NOT EXISTS full_name text; -- up to 100 characters ALTER TABLE users ADD COLUMN IF NOT EXISTS bio text; -- up to 1,000 characters --- personal library (api/src/library.ts): saved poets and works, bookmarked couplets, saved words +-- personal library (api/src/library.ts): saved poets and works, bookmarked couplets and phrases, saved words CREATE TABLE IF NOT EXISTS library ( id bigserial PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, - kind text NOT NULL CHECK (kind IN ('poet', 'poem', 'couplet', 'word')), + kind text NOT NULL, -- poet | poem | couplet | phrase | word poet_id integer, -- poet - poem_id integer, -- poem, couplet, or a word's source work + poem_id integer, -- poem, couplet, phrase, or a word's source work couplet integer, -- couplet, or a word's source couplet word text, -- word + phrase text, -- phrase: the bookmarked part of a couplet or paragraph note text, created_at timestamptz NOT NULL DEFAULT now() ); -CREATE UNIQUE INDEX IF NOT EXISTS library_items ON library (user_id, kind, coalesce(poet_id, 0), coalesce(poem_id, 0), coalesce(couplet, -1)) WHERE kind <> 'word'; +ALTER TABLE library ADD COLUMN IF NOT EXISTS phrase text; +ALTER TABLE library DROP CONSTRAINT IF EXISTS library_kind_check; +ALTER TABLE library ADD CONSTRAINT library_kind_check CHECK (kind IN ('poet', 'poem', 'couplet', 'phrase', 'word')); +DROP INDEX IF EXISTS library_items; +CREATE UNIQUE INDEX IF NOT EXISTS library_places ON library (user_id, kind, coalesce(poet_id, 0), coalesce(poem_id, 0), coalesce(couplet, -1)) WHERE kind IN ('poet', 'poem', 'couplet'); +CREATE UNIQUE INDEX IF NOT EXISTS library_phrases ON library (user_id, poem_id, couplet, phrase) WHERE kind = 'phrase'; CREATE UNIQUE INDEX IF NOT EXISTS library_words ON library (user_id, word) WHERE kind = 'word'; diff --git a/web/src/components/Seg.astro b/web/src/components/Seg.astro index 42fffac3..95275948 100644 --- a/web/src/components/Seg.astro +++ b/web/src/components/Seg.astro @@ -1,5 +1,6 @@ --- -// renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | ''] -const { s } = Astro.props as { s: [string, string][] }; +// renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?] +const { s } = Astro.props as { s: [string, string, string?][] }; --- -{s.map(([t, k]) => (k === 'mark' ? {t} : k === 'pen' ? {t} : t))} +{s.map(([t, k, d]) => (k === 'mark' ? {t} : k === 'pen' ? {t} + : k === 'bmk' ? {t} : t))} diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 949e2096..f75312dc 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -55,7 +55,8 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک