diff --git a/api/src/library.test.ts b/api/src/library.test.ts index 80a6acc4..bfcd7328 100644 --- a/api/src/library.test.ts +++ b/api/src/library.test.ts @@ -55,10 +55,24 @@ 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: saving a note bookmarks it; an empty note keeps the bookmark + const note = (body: object, tk = t) => call('POST', '/api/library/note', body, tk); + assert.deepEqual((await note({ poemId: poem, couplet: 2, note: ' پہلا نوٹ ' })).json(), { saved: true, note: 'پہلا نوٹ' }); + assert.deepEqual((await note({ poemId: poem, couplet: 1, note: 'نیا' })).json(), { saved: true, note: 'نیا' }, 'not bookmarked yet: bookmarked with the note'); + const ph = lines0[0].split(' ').slice(0, 3).join(' '); + assert.equal((await note({ poemId: poem, couplet: 0, phrase: ph, note: 'عبارت پر' })).json().saved, true); + 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)).json().saved, true, "another reader's note is their own"); + const st = (await call('GET', `/api/library/state?poem=${poem}`, undefined, t)).json(); + assert.deepEqual(st.notes.map((n: any) => [n.couplet, n.phrase, n.note]).sort(), [[0, ph, 'عبارت پر'], [1, null, 'نیا'], [2, null, 'پہلا نوٹ']].sort()); + assert.deepEqual((await note({ poemId: poem, couplet: 1, note: '' })).json(), { saved: true, note: null }, 'cleared, still bookmarked'); + assert.equal((await toggle({ kind: 'couplet', poemId: poem, couplet: 1 })).json().saved, false); + assert.deepEqual((await note({ poemId: poem, couplet: 1, note: '' })).json(), { saved: false, note: null }, 'nothing to clear'); // 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..6af387f6 100644 --- a/api/src/library.ts +++ b/api/src/library.ts @@ -6,6 +6,8 @@ // 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/note {poemId, couplet, phrase?, note} a note on a couplet or a phrase in it: bookmarks it if needed; an empty +// note clears the note and keeps the bookmark -> {saved, note} // POST /api/library/:id/delete // 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) @@ -22,6 +24,14 @@ 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; +} + async function reader(req: FastifyRequest, reply: FastifyReply) { const u = await sessionUser(req); if (!u) reply.code(401).send({ error: 'لاگ ان کریں' }); @@ -107,13 +117,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 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 })), + notes: rows.filter((r) => r.note && (r.kind === 'couplet' || r.kind === 'phrase')).map((r) => ({ couplet: r.couplet, phrase: r.phrase, note: r.note })), }; }); @@ -170,9 +181,8 @@ 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 }; @@ -196,6 +206,28 @@ export function libraryRoutes(app: FastifyInstance) { 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; + if ((await pool.query(`UPDATE library SET note = $${n} WHERE user_id = $1 AND ${match} RETURNING id`, [...args, note])).rowCount) return { saved: true, note }; + if (!note) return { saved: false, note: null }; // nothing saved and nothing to write + await pool.query(`INSERT INTO library (user_id, kind, poem_id, couplet, phrase, note) VALUES ($1, '${kind}', $2, $3, ${kind === 'phrase' ? '$4' : 'NULL'}, $${n})`, [...args, note]); + return { saved: true, 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; diff --git a/web/src/components/Seg.astro b/web/src/components/Seg.astro index 4576db74..1ac78d26 100644 --- a/web/src/components/Seg.astro +++ b/web/src/components/Seg.astro @@ -1,6 +1,9 @@ --- // renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?] +// a bookmarked phrase opens its note when pressed (the poem page's note box), where it can also be removed const { s } = Astro.props as { s: [string, string, string?][] }; +const noteOf = (d?: string) => (d ? (JSON.parse(d).note as string | undefined) : undefined); --- {s.map(([t, k, d]) => (k === 'mark' ? {t} : k === 'pen' ? {t} - : k === 'bmk' ? {t} : t))} + : k === 'bmk' ? {t} : t))} diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 19e81f0f..3cacf0fc 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -110,6 +110,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can + @@ -277,7 +278,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can document.addEventListener('click', async (e) => { const b = e.target.closest('[data-save]'); if (!b) return; if (getSelection()?.toString().trim()) return; // selecting text, not pressing - if (b.dataset.confirm && !confirm(b.dataset.confirm)) return; + if (b.dataset.confirm && !(await ask(b.dataset.confirm, { danger: true, ok: 'ہٹائیں' }))) return; const item = JSON.parse(b.dataset.save), d = await save(item); if (d && item.kind === 'phrase') location.reload(); // the highlight is drawn by the page else if (d) { @@ -441,7 +442,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can const block = sel.anchorNode.parentElement?.closest('[data-c]'); source = block ? { poemId: Number(block.closest('[data-poem]')?.dataset.poem) || undefined, couplet: Number(block.dataset.c) } : {}; menu.querySelector('[data-act=saveword]').hidden = !w; - menu.querySelector('[data-act=bookmark]').hidden = !(source.poemId && Number.isInteger(source.couplet)); + menu.querySelector('[data-act=bookmark]').hidden = menu.querySelector('[data-act=note]').hidden = !(source.poemId && Number.isInteger(source.couplet)); menu.querySelector('[data-act=meaning]').hidden = !w; menu.hidden = false; // below the selection, centred, kept inside the viewport (absolute: moves with the page) @@ -464,6 +465,7 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can case 'meaning': showWord(oneWord(selText)); break; case 'bookmark': save({ kind: 'phrase', poemId: source.poemId, couplet: source.couplet, phrase: selText }) .then((d) => d && (d.saved ? location.reload() : flash(b, 'پہلے سے محفوظ تھی، ہٹا دی'))); break; + case 'note': hideMenu(); document.dispatchEvent(new CustomEvent('divan:note', { detail: { couplet: source.couplet, phrase: selText } })); break; // the poem page opens its note box case 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? 'ذخیرۂ الفاظ میں ڈال دیا' : 'ذخیرۂ الفاظ سے نکال دیا')); break; case 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break; case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break; @@ -485,5 +487,46 @@ import logo from '../../public/logo/turanj.svg?raw'; // inline, so the page can }); + + +
+