Library: bookmarked phrases, and glyphs where the reader's marks are

- 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 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-08 23:48:42 +02:00
parent 8f4a71530d
commit 55cc602815
12 changed files with 196 additions and 33 deletions

View File

@ -2,7 +2,7 @@ import { test, after } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import Fastify from 'fastify'; import Fastify from 'fastify';
import { authRoutes } from './auth.ts'; import { authRoutes } from './auth.ts';
import { libraryRoutes, cleanWord } from './library.ts'; import { libraryRoutes, cleanWord, cleanPhrase } from './library.ts';
import { pool } from './db.ts'; import { pool } from './db.ts';
after(() => pool.end()); 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('غالبؔ'), 'غالب'); assert.equal(cleanWord('غالبؔ'), 'غالب');
assert.equal(cleanWord('love'), null); assert.equal(cleanWord('love'), null);
assert.equal(cleanPhrase(' وصال یار '), 'وصال یار');
assert.equal(cleanPhrase('ی'), null);
assert.equal(cleanWord(''), 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: 'poet', poetId: 999999 })).statusCode, 404);
assert.equal((await toggle({ kind: 'word', word: 'hello' })).statusCode, 400); 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 // 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: true });
assert.deepEqual((await call('GET', '/api/library/state?word=' + encodeURIComponent('ہجر'), undefined, t)).json(), { word: false }); 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.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.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.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.couplets[0].lines.length, 2, 'both misras');
assert.equal(lib.words[0].word, 'وصال'); assert.equal(lib.words[0].word, 'وصال');
assert.equal(lib.words[0].source.couplet, 0); assert.equal(lib.words[0].source.couplet, 0);

View File

@ -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. // 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 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 // GET /api/library/state?poet=&poem=&word= what is saved on one page (poet, poem, couplets, phrases) or a word
// POST /api/library/toggle {kind, poetId?, poemId?, couplet?, word?} -> {saved, id?} // 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/note {note}
// POST /api/library/:id/delete // 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 type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { pool } from './db.ts'; import { pool } from './db.ts';
import { sessionUser } from './auth.ts'; import { sessionUser } from './auth.ts';
import { PUNCT } from './dictionary.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) { async function reader(req: FastifyRequest, reply: FastifyReply) {
const u = await sessionUser(req); const u = await sessionUser(req);
@ -44,7 +52,7 @@ export function libraryRoutes(app: FastifyInstance) {
app.get('/api/library', async (req, reply) => { app.get('/api/library', async (req, reply) => {
const u = await reader(req, reply); if (!u) return; const u = await reader(req, reply); if (!u) return;
const { rows } = await pool.query( 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 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 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]); 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 } })), 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) })), 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) })), 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) => ({ 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, ...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 poet = Number(req.query.poet) || 0, poem = Number(req.query.poem) || 0;
const { rows } = await pool.query( 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]); [u.id, poet, poem]);
return { return {
poet: rows.some((r) => r.kind === 'poet'), poem: rows.some((r) => r.kind === 'poem'), 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), 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]); 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) }; 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 // poet, poem or couplet: the target must exist
const exists = kind === 'poet' ? await pool.query('SELECT 1 FROM poets WHERE id = $1', [poetId]) 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]) : kind === 'poem' ? await pool.query('SELECT 1 FROM poems WHERE id = $1', [poemId])

View File

@ -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 SELECT id AS root, id FROM categories WHERE parent_id = $1
UNION ALL UNION ALL
SELECT tree.root, c.id FROM categories c JOIN tree ON c.parent_id = tree.id) 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 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`, GROUP BY c.id ORDER BY c.position, c.id`,
[cat.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 { return {
type: cat.parent_id === null ? 'poet' : 'category', type: cat.parent_id === null ? 'poet' : 'category',

View File

@ -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 -- 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 full_name text; -- up to 100 characters
ALTER TABLE users ADD COLUMN IF NOT EXISTS bio text; -- up to 1,000 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 ( CREATE TABLE IF NOT EXISTS library (
id bigserial PRIMARY KEY, id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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 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 couplet integer, -- couplet, or a word's source couplet
word text, -- word word text, -- word
phrase text, -- phrase: the bookmarked part of a couplet or paragraph
note text, note text,
created_at timestamptz NOT NULL DEFAULT now() 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'; CREATE UNIQUE INDEX IF NOT EXISTS library_words ON library (user_id, word) WHERE kind = 'word';

View File

@ -1,5 +1,6 @@
--- ---
// renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | ''] // renders text segments: [text, 'mark' (search hit) | 'pen' (takhallus) | 'bmk' (bookmarked phrase) | '', data?]
const { s } = Astro.props as { s: [string, string][] }; const { s } = Astro.props as { s: [string, string, string?][] };
--- ---
{s.map(([t, k]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span> : t))} {s.map(([t, k, d]) => (k === 'mark' ? <mark>{t}</mark> : k === 'pen' ? <span class="takhallus">{t}</span>
: k === 'bmk' ? <span class="bmk" data-save={d} data-confirm="یہ نشان ہٹائیں؟" title="نشان زدہ عبارت (ہٹانے کے لیے دبائیں)">{t}</span> : t))}

View File

@ -55,7 +55,8 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
<main class="wrap"><slot /></main> <main class="wrap"><slot /></main>
<div id="selmenu" class="selmenu" role="toolbar" aria-label="منتخب عبارت" hidden> <div id="selmenu" class="selmenu" role="toolbar" aria-label="منتخب عبارت" hidden>
<button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button> <button type="button" data-act="meaning" title="معنی اور تلفظ">معنی</button>
<button type="button" data-act="saveword" title="میری لائبریری میں محفوظ کریں">محفوظ</button> <button type="button" data-act="saveword" title="لفظ لغت کے ساتھ محفوظ کریں">محفوظ</button>
<button type="button" data-act="bookmark" title="اس عبارت پر نشان لگائیں">نشان</button>
<button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button> <button type="button" data-act="search" title="دیوان میں تلاش">تلاش</button>
<button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button> <button type="button" data-act="copy" title="حوالے کے ساتھ کاپی">کاپی</button>
<button type="button" data-act="share" title="اس عبارت کا لنک">شیئر</button> <button type="button" data-act="share" title="اس عبارت کا لنک">شیئر</button>
@ -153,8 +154,11 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
} }
document.addEventListener('click', async (e) => { document.addEventListener('click', async (e) => {
const b = e.target.closest('[data-save]'); if (!b) return; const b = e.target.closest('[data-save]'); if (!b) return;
const d = await save(JSON.parse(b.dataset.save)); if (getSelection()?.toString().trim()) return; // selecting text, not pressing
if (d) b.setAttribute('aria-pressed', String(d.saved)); if (b.dataset.confirm && !confirm(b.dataset.confirm)) 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) b.setAttribute('aria-pressed', String(d.saved));
}); });
const wordSave = (w) => { // sidebar button, with the word's saved state const wordSave = (w) => { // sidebar button, with the word's saved state
const b = el('button', 'save', 'لفظ محفوظ کریں', { type: 'button', 'aria-pressed': 'false' }); const b = el('button', 'save', 'لفظ محفوظ کریں', { type: 'button', 'aria-pressed': 'false' });
@ -306,6 +310,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
const block = sel.anchorNode.parentElement?.closest('[data-c]'); const block = sel.anchorNode.parentElement?.closest('[data-c]');
source = block ? { poemId: Number(block.closest('[data-poem]')?.dataset.poem) || undefined, couplet: Number(block.dataset.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=saveword]').hidden = !w;
menu.querySelector('[data-act=bookmark]').hidden = !(source.poemId && Number.isInteger(source.couplet));
menu.querySelector('[data-act=meaning]').hidden = !w; menu.querySelector('[data-act=meaning]').hidden = !w;
menu.hidden = false; menu.hidden = false;
// below the selection, centred, kept inside the viewport (absolute: moves with the page) // below the selection, centred, kept inside the viewport (absolute: moves with the page)
@ -326,6 +331,8 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
const q = /\s/.test(selText) ? `"${selText}"` : selText, enc = encodeURIComponent(selText); const q = /\s/.test(selText) ? `"${selText}"` : selText, enc = encodeURIComponent(selText);
switch (b.dataset.act) { switch (b.dataset.act) {
case 'meaning': showWord(oneWord(selText)); break; 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 'saveword': save({ kind: 'word', word: oneWord(selText), ...source }).then((d) => d && flash(b, d.saved ? 'محفوظ ہو گیا' : 'ہٹا دیا گیا')); break; 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 'search': location.href = `/search?q=${encodeURIComponent(q)}`; break;
case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break; case 'google': open(`https://www.google.com/search?q=${enc}`, '_blank', 'noopener'); break;

View File

@ -0,0 +1,10 @@
// node web/src/lib/phrases.test.mjs (plain assert; the API has the main test suite)
import assert from 'node:assert/strict';
const { phraseParts, phraseRe } = await import('./phrases.ts');
const lines = ['یہ مسائل تصوف یہ ترا بیان غالبؔ', 'تجھے ہم ولی سمجھتے جو نہ بادہ خوار ہوتا'];
assert.deepEqual(phraseParts(lines, 'مسائل تصوف'), ['مسائل تصوف', null]);
assert.deepEqual(phraseParts(lines, 'بیان غالب'), ['بیان غالب', null], 'takhallus sign ignored');
assert.ok(phraseRe('بیان غالب').test(lines[0]));
assert.deepEqual(phraseParts(lines, 'بیان غالب تجھے ہم'), ['بیان غالب', 'تجھے ہم'], 'across the misras');
assert.deepEqual(phraseParts(lines, 'کہیں نہیں'), [null, null]);
console.log('ok');

18
web/src/lib/phrases.ts Normal file
View File

@ -0,0 +1,18 @@
// Where a bookmarked phrase sits in a couplet's lines: the part of each line to mark. A phrase can run
// across the two misras (end of the first + start of the second). The pen-name sign ؔ is ignored.
const bare = (s: string) => s.replace(/ؔ/g, '');
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// the phrase as a regex over original text: ؔ allowed after any letter, any run of spaces between words
export const phraseRe = (p: string) => new RegExp([...p].map((c) => (c === ' ' ? '\\s+' : esc(c) + '\\u0614?')).join(''), 'u');
export function phraseParts(lines: string[], phrase: string): (string | null)[] {
const parts: (string | null)[] = lines.map(() => null);
const i = lines.findIndex((l) => phraseRe(phrase).test(l));
if (i >= 0) { parts[i] = phrase; return parts; }
const words = phrase.split(' ');
for (let k = words.length - 1; k > 0 && lines.length > 1; k--) { // first k words end line 0, the rest start line 1
const head = words.slice(0, k).join(' '), tail = words.slice(k).join(' ');
if (bare(lines[0]).trimEnd().endsWith(head) && bare(lines[1]).trimStart().startsWith(tail)) return [head, tail];
}
return parts;
}

View File

@ -4,6 +4,7 @@ import Base from '../layouts/Base.astro';
import Seg from '../components/Seg.astro'; import Seg from '../components/Seg.astro';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { asUser, COOKIE } from '../lib/auth'; import { asUser, COOKIE } from '../lib/auth';
import { phraseParts, phraseRe } from '../lib/phrases';
import { ud, years, highlighter, parts } from '../lib/urdu'; import { ud, years, highlighter, parts } from '../lib/urdu';
const url = '/' + (Astro.params.path ?? ''); const url = '/' + (Astro.params.path ?? '');
@ -27,8 +28,16 @@ if (page.type === 'poem') {
// ?hl= (from search results) marks the searched words on the page; segments: [text, 'mark' | 'pen' | ''] // ?hl= (from search results) marks the searched words on the page; segments: [text, 'mark' | 'pen' | '']
const hlq = Astro.url.searchParams.get('hl') ?? ''; const hlq = Astro.url.searchParams.get('hl') ?? '';
const re = highlighter(hlq); const re = highlighter(hlq);
const show = (line: string) => parts(line, re).flatMap((s, i): [string, string][] => // bookmarked phrases first ('bmk', carrying what to un-bookmark), then search hits, then the pen name
i % 2 ? [[s, 'mark']] : s.split(/(\S*\u0614\S*)/).map((t, j) => [t, j % 2 ? 'pen' : ''])); type Mark = { part: string; save: string };
const show = (line: string, marks: Mark[] = []): [string, string, string?][] => {
const plain = (text: string) => parts(text, re).flatMap((s, i): [string, string][] =>
i % 2 ? [[s, 'mark']] : s.split(/(\S*\u0614\S*)/).map((t, j) => [t, j % 2 ? 'pen' : '']));
if (!marks.length) return plain(line);
const bm = new RegExp(`(${marks.map((m) => phraseRe(m.part).source).join('|')})`, 'u');
return line.split(bm).flatMap((s, i): [string, string, string?][] =>
i % 2 ? [[s, 'bmk', marks.find((m) => phraseRe(m.part).test(s))?.save]] : plain(s));
};
// contents of a divan grouped by radif letter ("ردیف الف … ی"); ے is filed under ی as in printed divans // contents of a divan grouped by radif letter ("ردیف الف … ی"); ے is filed under ی as in printed divans
const LETTER = (l: string) => (l === 'ا' ? 'الف' : l === 'ے' ? 'ی' : l); const LETTER = (l: string) => (l === 'ا' ? 'الف' : l === 'ے' ? 'ی' : l);
const groups: { letter: string | null; poems: any[] }[] = []; const groups: { letter: string | null; poems: any[] }[] = [];
@ -41,7 +50,19 @@ const crumbs = page.breadcrumbs ?? [];
// what the signed-in reader has saved on this page (♡ poet/work, 🔖 couplets) // what the signed-in reader has saved on this page (♡ poet/work, 🔖 couplets)
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined; const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined; const poetId = page.poet?.id, poemId = page.type === 'poem' ? page.poem.id : undefined;
const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&poem=${poemId ?? ''}`)).data : { poet: false, poem: false, couplets: [] }; const saved = token ? (await asUser(token, `/api/library/state?poet=${poetId ?? ''}&poem=${poemId ?? ''}`)).data : { poet: false, poem: false, couplets: [], phrases: [] };
// on contents pages: which works are favourites (♥) or hold bookmarks (🔖), and which books/sections hold them
const marks = token && page.type !== 'poem' ? (await asUser(token, '/api/library/marks')).data : { poems: {}, categories: [] };
const glyph = (poemId: number) => (marks.poems[poemId]?.fav ? ' mark-fav' : '') + (marks.poems[poemId]?.bm ? ' mark-bm' : '');
// the marked part of each line of a block, for its bookmarked phrases
const lineMarks = (c: number, lines: string[]) => {
const out: Mark[][] = lines.map(() => []);
for (const p of saved.phrases.filter((x: any) => x.couplet === c)) {
const save = JSON.stringify({ kind: 'phrase', poemId, couplet: c, phrase: p.phrase });
phraseParts(lines, p.phrase).forEach((part, i) => part && out[i].push({ part, save }));
}
return out;
};
const bookmark = (c: number) => JSON.stringify({ kind: 'couplet', poemId, couplet: c }); const bookmark = (c: number) => JSON.stringify({ kind: 'couplet', poemId, couplet: c });
const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title; const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title;
--- ---
@ -67,14 +88,14 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
{page.type !== 'poem' && page.children.length > 0 && ( {page.type !== 'poem' && page.children.length > 0 && (
<div class="cards"> <div class="cards">
{page.children.map((c: any) => ( {page.children.map((c: any) => (
<a class="card" href={c.url}>{c.title}{c.poems > 0 && <small>{ud(c.poems)}</small>}</a> <a class={`card${marks.categories.includes(c.id) ? ' mark-bm' : ''}`} href={c.url}>{c.title}{c.poems > 0 && <small>{ud(c.poems)}</small>}</a>
))} ))}
</div> </div>
)} )}
{page.type !== 'poem' && groups.map((g) => ( {page.type !== 'poem' && groups.map((g) => (
<> <>
{g.letter && <h2 class="radif-head">ردیف {g.letter}</h2>} {g.letter && <h2 class="radif-head">ردیف {g.letter}</h2>}
<ol class="toc">{g.poems.map((p: any) => <li><a href={p.url}>{p.title}</a></li>)}</ol> <ol class="toc">{g.poems.map((p: any) => <li><a class={glyph(p.id)} href={p.url}>{p.title}</a></li>)}</ol>
</> </>
))} ))}
@ -91,9 +112,10 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}> <article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
{blocks.map((b) => { {blocks.map((b) => {
const bm = <button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="نشان لگائیں" />; const bm = <button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="نشان لگائیں" />;
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l) => <p><Seg s={show(l)} /></p>)}</div> const lm = lineMarks(b.c, b.lines);
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0])} /></p> return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}</div>
: <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0])} /></p>; : b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
: <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>;
})} })}
</article> </article>
<p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے۔</p> <p class="muted hint">کسی لفظ کو منتخب کریں تو اس کے معنی اور تلفظ ساتھ نظر آئیں گے۔</p>

View File

@ -1,6 +1,7 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { asUser, COOKIE } from '../lib/auth';
import { centuryOf, centuryName, years, ud } from '../lib/urdu'; import { centuryOf, centuryName, years, ud } from '../lib/urdu';
type Poet = { id: number; url: string; nickname: string; birth_year_ah: number | null; death_year_ah: number | null; pin_order: number | null }; type Poet = { id: number; url: string; nickname: string; birth_year_ah: number | null; death_year_ah: number | null; pin_order: number | null };
@ -15,6 +16,10 @@ for (const p of poets) {
} }
const ordered = [...groups.entries()].sort(([a], [b]) => (a ?? 99) - (b ?? 99)); const ordered = [...groups.entries()].sort(([a], [b]) => (a ?? 99) - (b ?? 99));
const pinned = poets.filter((p) => p.pin_order).sort((a, b) => a.pin_order! - b.pin_order!); const pinned = poets.filter((p) => p.pin_order).sort((a, b) => a.pin_order! - b.pin_order!);
// poets whose works hold the signed-in reader's favourites or bookmarks
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
const marked: number[] = token ? (await asUser(token, '/api/library/marks')).data.poets ?? [] : [];
const card = (p: Poet) => `poet-card${marked.includes(p.id) ? ' mark-bm' : ''}`;
--- ---
<Base> <Base>
<section class="hero"> <section class="hero">
@ -29,7 +34,7 @@ const pinned = poets.filter((p) => p.pin_order).sort((a, b) => a.pin_order! - b.
<h2>مقبول شعرا</h2> <h2>مقبول شعرا</h2>
<div class="poets"> <div class="poets">
{pinned.map((p) => ( {pinned.map((p) => (
<a class="poet-card" href={p.url}>{p.nickname}<small>{years(p)}</small></a> <a class={card(p)} href={p.url}>{p.nickname}<small>{years(p)}</small></a>
))} ))}
</div> </div>
</section> </section>
@ -40,7 +45,7 @@ const pinned = poets.filter((p) => p.pin_order).sort((a, b) => a.pin_order! - b.
<h2>{centuryName(century)}</h2> <h2>{centuryName(century)}</h2>
<div class="poets"> <div class="poets">
{list.map((p) => ( {list.map((p) => (
<a class="poet-card" href={p.url}> <a class={card(p)} href={p.url}>
{p.nickname} {p.nickname}
<small>{years(p)}</small> <small>{years(p)}</small>
</a> </a>

View File

@ -4,6 +4,7 @@
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { asUser, COOKIE } from '../lib/auth'; import { asUser, COOKIE } from '../lib/auth';
import { ud } from '../lib/urdu'; import { ud } from '../lib/urdu';
import { phraseParts, phraseRe } from '../lib/phrases';
const me = Astro.locals.user; const me = Astro.locals.user;
if (!me) return Astro.redirect('/signin?next=/library'); if (!me) return Astro.redirect('/signin?next=/library');
@ -15,7 +16,13 @@ if (Astro.request.method === 'POST') {
return Astro.redirect(`/library#i${id}`, 303); return Astro.redirect(`/library#i${id}`, 303);
} }
const lib = (await asUser(token, '/api/library')).data; const lib = (await asUser(token, '/api/library')).data;
const total = lib.poets.length + lib.poems.length + lib.couplets.length + lib.words.length; const total = lib.poets.length + lib.poems.length + lib.couplets.length + lib.phrases.length + lib.words.length;
// a bookmarked phrase shown inside its couplet: [before, phrase, after] per line
const marked = (lines: string[], phrase: string) => phraseParts(lines, phrase).map((part, i) => {
if (!part) return [lines[i], '', ''];
const m = lines[i].match(phraseRe(part))!;
return [lines[i].slice(0, m.index), m[0], lines[i].slice(m.index! + m[0].length)];
});
const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != null ? `#c${item.poem.couplet}` : ''}` : '#'); const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != null ? `#c${item.poem.couplet}` : ''}` : '#');
--- ---
<Base title="میری لائبریری"> <Base title="میری لائبریری">
@ -24,7 +31,7 @@ const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != n
<p class="muted center">میری لائبریری · {ud(total)} اندراجات · <a href="/account">پروفائل</a></p> <p class="muted center">میری لائبریری · {ud(total)} اندراجات · <a href="/account">پروفائل</a></p>
{total === 0 && ( {total === 0 && (
<p class="muted center">ابھی کچھ محفوظ نہیں۔ کسی شاعر یا کلام پر "پسندیدہ" دبائیں، کسی شعر کے ساتھ 🔖 لگائیں، یا لفظ منتخب کر کے "محفوظ" کریں۔</p> <p class="muted center">ابھی کچھ محفوظ نہیں۔ کسی شاعر یا کلام پر "پسندیدہ" دبائیں، کسی شعر کے ساتھ 🔖 لگائیں، کوئی عبارت منتخب کر کے "نشان" لگائیں، یا لفظ منتخب کر کے "محفوظ" کریں۔</p>
)} )}
{lib.poets.length > 0 && ( {lib.poets.length > 0 && (
@ -75,6 +82,28 @@ const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != n
</section> </section>
)} )}
{lib.phrases.length > 0 && (
<section class="lib-section">
<h2>نشان زدہ عبارتیں</h2>
<ul class="lib-list">
{lib.phrases.map((p: any) => (
<li id={`i${p.id}`}>
{p.poem ? <>
<nav class="crumbs">{[...p.poem.path, { title: p.poem.title, url: p.poem.url }].map((x: any) => <span><a href={x.url}>{x.title}</a></span>)}</nav>
<a class="lib-couplet" href={at(p)}>{marked(p.lines, p.phrase).map(([a, m, z]) => <p>{a}{m && <span class="bmk">{m}</span>}{z}</p>)}</a>
</> : <span class="muted">یہ عبارت اب دستیاب نہیں</span>}
<form method="post" class="lib-note">
<input type="hidden" name="id" value={p.id} />
<input name="note" value={p.note ?? ''} placeholder="نوٹ" maxlength="1000" dir="auto" />
<button name="act" value="note">محفوظ</button>
</form>
<form method="post" class="lib-remove"><input type="hidden" name="id" value={p.id} /><button name="act" value="delete" aria-label="ہٹائیں">✕</button></form>
</li>
))}
</ul>
</section>
)}
{lib.words.length > 0 && ( {lib.words.length > 0 && (
<section class="lib-section"> <section class="lib-section">
<h2>محفوظ الفاظ</h2> <h2>محفوظ الفاظ</h2>

View File

@ -245,3 +245,11 @@ h1 + .muted { text-align: center; margin-top: 0; }
.lib-note button, .lib-remove button { font: inherit; font-size: .8rem; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); cursor: pointer; } .lib-note button, .lib-remove button { font: inherit; font-size: .8rem; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); cursor: pointer; }
.lib-remove { position: absolute; left: 8px; top: 8px; margin: 0; } .lib-remove { position: absolute; left: 8px; top: 8px; margin: 0; }
.lib-remove button { color: var(--brand); } .lib-remove button { color: var(--brand); }
/* bookmarked phrases, and glyphs where the reader's favourites (♥) and bookmarks (🔖) are */
.bmk { text-decoration: underline dotted var(--brand) 2px; text-underline-offset: 6px; cursor: pointer; background: var(--gold-subtle); border-radius: 4px; }
.bmk::after { content: '🔖'; font-size: .55em; vertical-align: super; margin-inline-start: 1px; user-select: none; }
.mark-fav::after, .mark-bm::after { font-size: .65em; margin-inline-start: 6px; vertical-align: middle; user-select: none; }
.mark-fav::after { content: '♥'; color: var(--brand); }
.mark-bm::after { content: '🔖'; }
.mark-fav.mark-bm::after { content: '♥ 🔖'; }