diff --git a/api/src/library.test.ts b/api/src/library.test.ts index 80a6acc4..4c4cb215 100644 --- a/api/src/library.test.ts +++ b/api/src/library.test.ts @@ -55,10 +55,39 @@ test('library: save and unsave poets, works, couplets and words; full paths; not 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&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: 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?poet=266&category=${ghazals}&poem=${poem}`, undefined, t)).json(), { poet: true, category: true, poem: true, couplets: [0, 2], phrases: [{ couplet: 0, phrase: lines0[0].split(' ').slice(0, 3).join(' ') }, { couplet: 0, phrase: across }], notes: [] }); 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 }); + // notes on a couplet and on a phrase are separate from bookmarks: a note never bookmarks; un-bookmarking keeps the note + const note = (body: object, tk = t) => call('POST', '/api/library/note', body, tk); + const st = () => call('GET', `/api/library/state?poem=${poem}`, undefined, t).then((r) => r.json()); + assert.deepEqual((await note({ poemId: poem, couplet: 2, note: ' پہلا نوٹ ' })).json(), { note: 'پہلا نوٹ' }, 'on a bookmarked couplet'); + assert.deepEqual((await note({ poemId: poem, couplet: 1, note: 'نیا' })).json(), { note: 'نیا' }); + assert.ok(!(await st()).couplets.includes(1), 'a note does not bookmark'); + const ph = lines0[0].split(' ').slice(1, 3).join(' '); + assert.deepEqual((await note({ poemId: poem, couplet: 0, phrase: ph, note: 'عبارت پر' })).json(), { note: 'عبارت پر' }); + assert.ok(!(await st()).phrases.some((p: any) => p.phrase === ph), 'nor a phrase'); + assert.equal((await note({ poemId: poem, couplet: 0, phrase: 'یہ عبارت یہاں نہیں', note: 'x' })).statusCode, 400); + assert.equal((await note({ poemId: poem, couplet: 9999, note: 'x' })).statusCode, 404); + assert.equal((await note({ poemId: poem, couplet: 1, note: 'x' }, other.token)).statusCode, 200, "another reader's note is their own"); + assert.deepEqual((await st()).notes.map((n: any) => [n.couplet, n.phrase, n.note]).sort(), [[0, ph, 'عبارت پر'], [1, null, 'نیا'], [2, null, 'پہلا نوٹ']].sort()); + // bookmarking a noted couplet keeps the note; un-bookmarking keeps it too + assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, true); + assert.ok((await st()).couplets.includes(1)); + assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, false); + assert.ok(!(await st()).couplets.includes(1) && (await st()).notes.some((n: any) => n.couplet === 1), 'the note stays'); + // clearing a note that has no bookmark removes it; on a bookmark it keeps the bookmark + await note({ poemId: poem, couplet: 1, note: '' }); + assert.ok(!(await st()).notes.some((n: any) => n.couplet === 1)); + await note({ poemId: poem, couplet: 0, phrase: ph, note: '' }); + await note({ poemId: poem, couplet: 2, note: '' }); + assert.ok((await st()).couplets.includes(2) && !(await st()).notes.length, 'bookmark kept, notes gone'); + // the notes list (the notes page) holds notes only; the saved lists hold bookmarks only + await note({ poemId: poem, couplet: 3, note: 'صرف نوٹ' }).catch(() => {}); + const listed = (await call('GET', '/api/library', undefined, t)).json(); + assert.ok(listed.notes.every((n: any) => n.note) && !listed.couplets.some((c: any) => c.poem?.couplet === 3)); + await note({ poemId: poem, couplet: 3, note: '' }); // toggling again removes assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 2 })).json().saved, false); diff --git a/api/src/library.ts b/api/src/library.ts index 6b5b7f67..944c1281 100644 --- a/api/src/library.ts +++ b/api/src/library.ts @@ -6,7 +6,11 @@ // GET /api/library/marks which works, books/sections and poets hold the reader's saved items // POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?} // POST /api/library/:id/note {note} -// POST /api/library/:id/delete +// POST /api/library/note {poemId, couplet, phrase?, note} a note on a couplet or a phrase in it; never bookmarks it (a +// note and a bookmark are separate); an empty note clears it -> {note} +// notes and bookmarks share a row: bookmarked false is a note only; un-bookmarking keeps the note, clearing the note of a +// row that is not bookmarked removes the row +// POST /api/library/:id/delete (a couplet or phrase with a note keeps the note) // kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a // couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) | page {ebookId, page} (a page of an e-book) import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; @@ -22,6 +26,25 @@ export const cleanPhrase = (p: unknown) => { return s.length >= 2 && s.length <= 300 ? s : null; }; +// a phrase must be in its couplet or paragraph (its lines, or across its two misras); null when it is +async function phraseIn(poemId: number, couplet: number, phrase: string) { + 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 { status: 404, error: 'نہیں ملا' }; + if (!text.normalize('NFC').replace(/\u0614/g, '').replace(/\s+/g, ' ').includes(phrase)) return { status: 400, error: 'یہ عبارت اس شعر میں نہیں' }; + return null; +} + +// an existing couplet or phrase row: a bookmark goes (the row stays if it holds a note), a note only becomes a bookmark; +// null when there is no row +async function flipBookmark(match: string, args: unknown[]) { + const row = (await pool.query(`SELECT id, bookmarked, note FROM library WHERE user_id = $1 AND ${match}`, args)).rows[0]; + if (!row) return null; + if (!row.bookmarked) { await pool.query('UPDATE library SET bookmarked = true WHERE id = $1', [row.id]); return { saved: true, id: Number(row.id) }; } + if (row.note) await pool.query('UPDATE library SET bookmarked = false WHERE id = $1', [row.id]); + else await pool.query('DELETE FROM library WHERE id = $1', [row.id]); + return { saved: false }; +} + async function reader(req: FastifyRequest, reply: FastifyReply) { const u = await sessionUser(req); if (!u) reply.code(401).send({ error: 'لاگ ان کریں' }); @@ -66,7 +89,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.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, l.ebook_id, l.page, + `SELECT l.id, l.kind, l.poet_id, l.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, l.ebook_id, l.page, l.bookmarked, 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, eb.title AS ebook_title, ep.nickname AS ebook_poet, ep.url AS ebook_poet_url FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id @@ -87,8 +110,11 @@ 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 } })), categories: rows.filter((r) => r.kind === 'category').map((r) => ({ ...item(r), path: chapters.get(r.category_id) ?? [] })), 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) })), + couplets: rows.filter((r) => r.kind === 'couplet' && r.bookmarked).map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })), + phrases: rows.filter((r) => r.kind === 'phrase' && r.bookmarked).map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })), + // every note on a couplet or phrase, bookmarked or not (the notes page) + notes: rows.filter((r) => (r.kind === 'couplet' || r.kind === 'phrase') && r.note).map((r) => ({ + ...item(r), phrase: r.phrase, bookmarked: r.bookmarked, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })), pages: rows.filter((r) => r.kind === 'page' && r.ebook_title).map((r) => ({ ...item(r), page: r.page, ebook: { id: r.ebook_id, title: r.ebook_title, poet: r.ebook_poet, poet_url: r.ebook_poet_url } })), words: rows.filter((r) => r.kind === 'word').map((r) => ({ @@ -107,13 +133,14 @@ export function libraryRoutes(app: FastifyInstance) { } const poet = Number(req.query.poet) || 0, category = Number(req.query.category) || 0, poem = Number(req.query.poem) || 0; const { rows } = await pool.query( - `SELECT kind, couplet, phrase FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) + `SELECT kind, couplet, phrase, note, bookmarked FROM library WHERE user_id = $1 AND ((kind = 'poet' AND poet_id = $2) OR (kind = 'category' AND category_id = $3) OR (kind IN ('poem', 'couplet', 'phrase') AND poem_id = $4))`, [u.id, poet, category, poem]); return { poet: rows.some((r) => r.kind === 'poet'), category: rows.some((r) => r.kind === 'category'), 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 })), + couplets: rows.filter((r) => r.kind === 'couplet' && r.bookmarked).map((r) => r.couplet), + phrases: rows.filter((r) => r.kind === 'phrase' && r.bookmarked).map((r) => ({ couplet: r.couplet, phrase: r.phrase })), + notes: rows.filter((r) => r.note && (r.kind === 'couplet' || r.kind === 'phrase')).map((r) => ({ couplet: r.couplet, phrase: r.phrase, note: r.note })), }; }); @@ -123,7 +150,7 @@ export function libraryRoutes(app: FastifyInstance) { 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]); + FROM library WHERE user_id = $1 AND kind IN ('poem', 'couplet', 'phrase') AND bookmarked 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 ( @@ -170,12 +197,11 @@ export function libraryRoutes(app: FastifyInstance) { 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 where = await phraseIn(poemId, couplet, phrase); + if (where) return reply.code(where.status).send({ error: where.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 flip = await flipBookmark(`kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4`, args); + if (flip) return flip; 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) }; } @@ -190,22 +216,51 @@ export function libraryRoutes(app: FastifyInstance) { const match = `kind = $2 AND coalesce(poet_id, 0) = coalesce($3::int, 0) AND coalesce(category_id, 0) = coalesce($4::int, 0) AND coalesce(poem_id, 0) = coalesce($5::int, 0) AND coalesce(couplet, -1) = coalesce($6::int, -1)`; const args = [u.id, kind, ...cols]; - if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND ${match} RETURNING id`, args)).rowCount) return { saved: false }; + const flip = await flipBookmark(match, args); + if (flip) return flip; const { rows } = await pool.query( 'INSERT INTO library (user_id, kind, poet_id, category_id, poem_id, couplet) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING RETURNING id', args); return { saved: true, id: rows[0] ? Number(rows[0].id) : undefined }; }); + app.post<{ Body: { poemId?: number; couplet?: number; phrase?: string; note?: string } }>('/api/library/note', async (req, reply) => { + const u = await reader(req, reply); if (!u) return; + const b = req.body ?? {}, poemId = Number(b.poemId) || 0, couplet = Number.isInteger(b.couplet) ? b.couplet! : null; + const note = String(b.note ?? '').normalize('NFC').trim().slice(0, 1000) || null; + if (!poemId || couplet == null) return reply.code(400).send({ error: 'شعر منتخب کریں' }); + let kind = 'couplet', match = `kind = 'couplet' AND poem_id = $2 AND couplet = $3`, args: unknown[] = [u.id, poemId, couplet]; + if (b.phrase != null) { + const phrase = cleanPhrase(b.phrase); + if (!phrase) return reply.code(400).send({ error: 'عبارت منتخب کریں' }); + const where = await phraseIn(poemId, couplet, phrase); + if (where) return reply.code(where.status).send({ error: where.error }); + kind = 'phrase'; match = `kind = 'phrase' AND poem_id = $2 AND couplet = $3 AND phrase = $4`; args.push(phrase); + } else if (!(await pool.query('SELECT 1 FROM verses WHERE poem_id = $1 AND couplet = $2 LIMIT 1', [poemId, couplet])).rowCount) { + return reply.code(404).send({ error: 'نہیں ملا' }); + } + const n = args.length + 1; + const row = (await pool.query(`UPDATE library SET note = $${n} WHERE user_id = $1 AND ${match} RETURNING id, bookmarked`, [...args, note])).rows[0]; + if (row) { if (!note && !row.bookmarked) await pool.query('DELETE FROM library WHERE id = $1', [row.id]); return { note }; } + if (note) await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase, note, bookmarked) + VALUES ($1, '${kind}', $2, $3, ${kind === 'phrase' ? '$4' : 'NULL'}, $${n}, false)`, [...args, note]); + return { note }; + }); + 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: 'نہیں ملا' }); + const r = await pool.query('UPDATE library SET note = $1 WHERE id = $2 AND user_id = $3 RETURNING bookmarked', [note, Number(req.params.id) || 0, u.id]); + if (!r.rowCount) return reply.code(404).send({ error: 'نہیں ملا' }); + if (!note && !r.rows[0].bookmarked) await pool.query('DELETE FROM library WHERE id = $1', [Number(req.params.id)]); // a note only, now empty + return { ok: true }; }); 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]); + // removing a bookmarked couplet or phrase keeps its note (the row stays as a note only) + const id = Number(req.params.id) || 0; + const kept = await pool.query(`UPDATE library SET bookmarked = false WHERE id = $1 AND user_id = $2 AND kind IN ('couplet', 'phrase') AND note IS NOT NULL AND bookmarked`, [id, u.id]); + const r = kept.rowCount ? kept : await pool.query('DELETE FROM library WHERE id = $1 AND user_id = $2', [id, u.id]); return r.rowCount ? { ok: true } : reply.code(404).send({ error: 'نہیں ملا' }); }); } diff --git a/db/schema.sql b/db/schema.sql index b6eba6e3..d26844ba 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -239,7 +239,9 @@ CREATE INDEX IF NOT EXISTS ebooks_search ON ebooks USING gin (search_text gin_tr ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS cover text; -- cover image '.jpg|png|webp' in the file store -- library: bookmarked pages of e-books (kind 'page': ebook_id, page) ALTER TABLE library ADD COLUMN IF NOT EXISTS ebook_id integer; -ALTER TABLE library ADD COLUMN IF NOT EXISTS page integer; -- kinds: see library_kind_check above +ALTER TABLE library ADD COLUMN IF NOT EXISTS page integer; +-- a couplet or phrase can hold a note without being bookmarked (bookmarked false: a note only) +ALTER TABLE library ADD COLUMN IF NOT EXISTS bookmarked boolean NOT NULL DEFAULT true; -- kinds: see library_kind_check above -- e-book details (owner request): language, reading direction, the writer's name as printed, co-authors (names; -- coauthor_ids = those who are poets on the site, so the book is listed on their pages too). Extra tags are -- entity_tags with entity 'ebook'. diff --git a/web/src/components/LibraryNav.astro b/web/src/components/LibraryNav.astro index 47f30db2..bc6d3c4f 100644 --- a/web/src/components/LibraryNav.astro +++ b/web/src/components/LibraryNav.astro @@ -1,9 +1,10 @@ --- // tabs of the reader's own space: saved texts, favourite writers, the word book (kept separate), profile -const { current } = Astro.props as { current: 'bookmarks' | 'writers' | 'words' }; +const { current } = Astro.props as { current: 'bookmarks' | 'notes' | 'writers' | 'words' }; ---