From 8f4a71530d64298230dfc4f766b4d0b887fef47d Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Thu, 8 Oct 2026 23:35:34 +0200 Subject: [PATCH] Personal library and word collection (#24, #25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Save (♡) poets and works, bookmark (🔖) couplets and paragraphs, save words from the word sidebar or the selection menu (with the couplet they came from); notes on items. - /library (میری لائبریری): the reader's name and bio, then favourite poets, works, bookmarked couplets and saved words, each with its full path (poet » book » section » work) and a link to the couplet (#cN anchors on poem pages); saved words open the word sidebar. - API (api/src/library.ts), JSON + Bearer tokens so mobile apps (#44) can use it; deleting an account deletes its library. Signed-out readers are sent to sign-in. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + api/src/library.test.ts | 69 ++++++++++++++++++ api/src/library.ts | 131 ++++++++++++++++++++++++++++++++++ api/src/server.ts | 3 + db/schema.sql | 14 ++++ web/src/layouts/Base.astro | 29 +++++++- web/src/pages/[...path].astro | 29 +++++--- web/src/pages/api/library.ts | 22 ++++++ web/src/pages/library.astro | 104 +++++++++++++++++++++++++++ web/src/styles/global.css | 33 +++++++++ 10 files changed, 424 insertions(+), 12 deletions(-) create mode 100644 api/src/library.test.ts create mode 100644 api/src/library.ts create mode 100644 web/src/pages/api/library.ts create mode 100644 web/src/pages/library.astro diff --git a/README.md b/README.md index 29a550b8..73448b71 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ Settings: `DIVAN_DATA_DIR` (default `/opt/divan-data`, cloned on first run), `DI | `GET /api/page?url=/p238/...` | the poet, category or poem at a site URL (breadcrumbs, children, verses, prev/next) | | `GET /api/search?q=&poet=&page=` | poems containing all words (or a `"quoted phrase"`), Urdu-normalised; exact phrase first; each with the best-matching couplet or paragraph (`snippet`) | | `GET /api/word?w=` | one word's meanings and pronunciation from the local Wiktionary data (Urdu, Persian, Arabic in that order; English meanings; Urdu equivalents via English when Urdu Wiktionary has none) | +| `/api/auth/*` | accounts: sign-up, sign-in (returns a Bearer token), profile, password, delete | +| `/api/library/*` | the signed-in reader's library: toggle poets, works, couplets and words; list with full paths; notes | | `GET /health` | database check | Search normalises both stored text and queries: Arabic ي/ك/ه → Urdu ی/ک/ہ, ۂ/ۓ, diacritics and the Urdu full stop removed; do-chashmi ھ stays distinct. diff --git a/api/src/library.test.ts b/api/src/library.test.ts new file mode 100644 index 00000000..adfaa4f6 --- /dev/null +++ b/api/src/library.test.ts @@ -0,0 +1,69 @@ +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 { pool } from './db.ts'; + +after(() => pool.end()); + +test('saved words are cleaned like the sidebar does', () => { + assert.equal(cleanWord(' دل، '), 'دل'); + assert.equal(cleanWord('غالبؔ'), 'غالب'); + assert.equal(cleanWord('love'), null); + assert.equal(cleanWord(''), null); +}); + +test('library: save and unsave poets, works, couplets and words; full paths; notes; isolation', async () => { + const app = Fastify(); + authRoutes(app); libraryRoutes(app); + const run = Date.now(); + const call = (method: string, url: string, body?: object, token?: string) => + app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `lib-${run}` } }); + const me = (await call('POST', '/api/auth/signup', { email: `lib-${run}@divan.test`, password: 'pass-word-1' })).json(); + const other = (await call('POST', '/api/auth/signup', { email: `lib2-${run}@divan.test`, password: 'pass-word-1' })).json(); + const t = me.token, toggle = (body: object, tk = t) => call('POST', '/api/library/toggle', body, tk); + + const poem = (await pool.query(`SELECT p.id FROM poems p JOIN categories c ON c.id = p.category_id WHERE c.url = '/p266/ghazal' LIMIT 1`)).rows[0].id; + + assert.equal((await call('GET', '/api/library')).statusCode, 401, 'signed out'); + assert.deepEqual((await toggle({ kind: 'poet', poetId: 266 })).json().saved, true); + assert.deepEqual((await toggle({ kind: 'poem', poemId: poem })).json().saved, true); + assert.deepEqual((await toggle({ kind: 'couplet', poemId: poem, couplet: 0 })).json().saved, true); + assert.deepEqual((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, true); + assert.deepEqual((await toggle({ kind: 'word', word: 'وصال،', poemId: poem, couplet: 0 })).json().saved, true); + assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 9999 })).statusCode, 404); + assert.equal((await toggle({ kind: 'poet', poetId: 999999 })).statusCode, 404); + assert.equal((await toggle({ kind: 'word', word: 'hello' })).statusCode, 400); + + // 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?word=' + encodeURIComponent('وصال'), undefined, t)).json(), { word: true }); + assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false }); + // toggling again removes + assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false); + + const lib = (await call('GET', '/api/library', undefined, t)).json(); + 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.couplets[0].lines.length, 2, 'both misras'); + assert.equal(lib.words[0].word, 'وصال'); + assert.equal(lib.words[0].source.couplet, 0); + + // notes, deletion, and readers cannot touch each other's items + const cid = lib.couplets[0].id; + assert.equal((await call('POST', `/api/library/${cid}/note`, { note: 'مطلع' }, other.token)).statusCode, 404); + assert.equal((await call('POST', `/api/library/${cid}/note`, { note: ' مطلع ' }, t)).statusCode, 200); + assert.equal((await call('GET', '/api/library', undefined, t)).json().couplets[0].note, 'مطلع'); + assert.equal((await call('POST', `/api/library/${cid}/delete`, undefined, other.token)).statusCode, 404); + assert.equal((await call('GET', '/api/library', undefined, other.token)).json().poets.length, 0); + assert.equal((await call('POST', `/api/library/${cid}/delete`, undefined, t)).statusCode, 200); + + // deleting the account deletes the library + await call('POST', '/api/auth/delete', { password: 'pass-word-1' }, t); + assert.equal((await pool.query('SELECT count(*)::int AS n FROM library WHERE user_id = $1', [me.user.id])).rows[0].n, 0); + await pool.query('DELETE FROM users WHERE id = $1', [other.user.id]); + await app.close(); +}); diff --git a/api/src/library.ts b/api/src/library.ts new file mode 100644 index 00000000..a85100bf --- /dev/null +++ b/api/src/library.ts @@ -0,0 +1,131 @@ +// Personal library (#24, #25): a reader's saved poets and works, bookmarked couplets and 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?} +// POST /api/library/:id/note {note} +// POST /api/library/:id/delete +// kind: poet {poetId} | poem {poemId} | couplet {poemId, couplet} | word {word, poemId?, couplet?} +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; + +async function reader(req: FastifyRequest, reply: FastifyReply) { + const u = await sessionUser(req); + if (!u) reply.code(401).send({ error: 'لاگ ان کریں' }); + return u; +} + +// one saved word: punctuation and spaces dropped (as in the word sidebar); Arabic script only +export const cleanWord = (w: unknown) => { + const s = String(w ?? '').normalize('NFC').replace(PUNCT, '').replace(/ؔ/g, ''); + return /^[\p{Script=Arabic}\p{M}‌]{1,40}$/u.test(s) ? s : null; +}; + +// the full path of each work: poet » book » section » … (the poet's root category carries the poet's name) +export async function paths(poemIds: number[]) { + if (!poemIds.length) return new Map(); + const { rows } = await pool.query( + `WITH RECURSIVE up AS ( + SELECT p.id AS poem_id, c.id, c.parent_id, c.title, c.url, 0 AS depth + FROM poems p JOIN categories c ON c.id = p.category_id WHERE p.id = ANY($1) + UNION ALL + SELECT up.poem_id, c.id, c.parent_id, c.title, c.url, up.depth + 1 FROM categories c JOIN up ON c.id = up.parent_id) + SELECT poem_id, title, url FROM up ORDER BY poem_id, depth DESC`, [poemIds]); + const out = new Map(); + for (const r of rows) (out.get(r.poem_id) ?? out.set(r.poem_id, []).get(r.poem_id)!).push({ title: r.title, url: r.url }); + return out; +} + +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, + 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]); + const poemIds = [...new Set(rows.filter((r) => r.poem_id).map((r) => r.poem_id))]; + const [crumbs, verses] = await Promise.all([ + paths(poemIds), + pool.query(`SELECT poem_id, couplet, text FROM verses WHERE (poem_id, couplet) IN + (SELECT poem_id, couplet FROM library WHERE user_id = $1 AND poem_id IS NOT NULL AND couplet IS NOT NULL) ORDER BY vorder`, [u.id]), + ]); + const lines = (p: number, c: number) => verses.rows.filter((v) => v.poem_id === p && v.couplet === c).map((v) => v.text); + const where = (r: any) => r.poem_id && { url: r.poem_url, title: r.poem_title, path: crumbs.get(r.poem_id) ?? [], couplet: r.couplet }; + const item = (r: any) => ({ id: Number(r.id), note: r.note, saved_at: r.created_at }); + return { + user: { full_name: u.full_name ?? '', bio: u.bio ?? '' }, + 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) })), + 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, + })), + }; + }); + + app.get<{ Querystring: { poet?: string; poem?: string; word?: string } }>('/api/library/state', async (req, reply) => { + const u = await reader(req, reply); if (!u) return; + if (req.query.word != null) { + const w = cleanWord(req.query.word); + return { word: !!w && !!(await pool.query(`SELECT 1 FROM library WHERE user_id = $1 AND kind = 'word' AND word = $2`, [u.id, w])).rowCount }; + } + 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))`, + [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), + }; + }); + + app.post<{ Body: { kind?: string; poetId?: number; poemId?: number; couplet?: number; word?: string } }>('/api/library/toggle', async (req, reply) => { + const u = await reader(req, reply); if (!u) return; + const b = req.body ?? {}, kind = String(b.kind); + if (!(KINDS as readonly string[]).includes(kind)) return reply.code(400).send({ error: 'نامعلوم قسم' }); + const poetId = Number(b.poetId) || null, poemId = Number(b.poemId) || null; + const couplet = Number.isInteger(b.couplet) ? b.couplet! : null; + let key: [string, unknown[]]; + if (kind === 'word') { + const word = cleanWord(b.word); + if (!word) return reply.code(400).send({ error: 'ایک لفظ منتخب کریں' }); + key = [`kind = 'word' AND word = $2`, [word]]; + const hit = (await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${key[0]} RETURNING id`, [u.id, ...key[1]])).rows[0]; + if (hit) return { saved: false }; + // the source couplet is kept only if it exists + const src = poemId && couplet != null && (await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet])).rowCount ? [poemId, couplet] : [null, null]; + 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) }; + } + // 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]) + : await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet]); + if (!exists.rowCount) return reply.code(404).send({ error: 'نہیں ملا' }); + const cols = kind === 'poet' ? { poet_id: poetId, poem_id: null, couplet: null } : { poet_id: null, poem_id: poemId, couplet: kind === 'couplet' ? couplet : null }; + const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(poem_id, 0) = coalesce($4::int, 0) AND coalesce(couplet, -1) = coalesce($5::int, -1)`; + const args = [u.id, kind, cols.poet_id, cols.poem_id, cols.couplet]; + if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${match} RETURNING id`, args)).rowCount) return { saved: false }; + const { rows } = await pool.query( + 'INSERT INTO library (user_id, kind, poet_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING RETURNING id', args); + return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined }; + }); + + app.post<{ Params: { id: string }; Body: { note?: string } }>('/api/library/:id/note', async (req, reply) => { + const u = await reader(req, reply); if (!u) return; + const note = String(req.body?.note ?? '').normalize('NFC').trim().slice(0, 1000) || null; + const r = await pool.query('UPDATE library SET note = $1 WHERE id = $2 AND user_id = $3', [note, Number(req.params.id) || 0, u.id]); + return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' }); + }); + + app.post<{ Params: { id: string } }>('/api/library/:id/delete', async (req, reply) => { + const u = await reader(req, reply); if (!u) return; + const r = await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [Number(req.params.id) || 0, u.id]); + return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' }); + }); +} diff --git a/api/src/server.ts b/api/src/server.ts index aa543462..ad9bf30c 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -5,6 +5,7 @@ // GET /api/word?w= Wiktionary meanings and pronunciation (sidebar) // /api/auth/* accounts (see auth.ts) // /api/admin/* admin panel (see admin.ts) +// /api/library/* a reader's saved poets, works, couplets and words (see library.ts) // GET /health import Fastify from 'fastify'; import { pool } from './db.ts'; @@ -13,6 +14,7 @@ import { lookup, PUNCT } from './dictionary.ts'; import { authRoutes } from './auth.ts'; import { adminRoutes } from './admin.ts'; import { permissionRoutes } from './permissions.ts'; +import { libraryRoutes } from './library.ts'; const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } }); const PAGE_SIZE = 20; @@ -141,6 +143,7 @@ app.get<{ Querystring: { w?: string } }>('/api/word', async (req, reply) => { authRoutes(app); adminRoutes(app); permissionRoutes(app); +libraryRoutes(app); const port = Number(process.env.PORT ?? 4100); await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' }); diff --git a/db/schema.sql b/db/schema.sql index cf7efce8..354dc0a9 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -126,3 +126,17 @@ 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 +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')), + poet_id integer, -- poet + poem_id integer, -- poem, couplet, or a word's source work + couplet integer, -- couplet, or a word's source couplet + word text, -- word + 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'; +CREATE UNIQUE INDEX IF NOT EXISTS library_words ON library (user_id, word) WHERE kind = 'word'; diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 32148a46..949e2096 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -29,7 +29,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک } catch (e) {} - +