From 453659f5d1b8f73646251b5280794164a7322b7b Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Fri, 9 Oct 2026 00:14:26 +0200 Subject: [PATCH] Search: poets/writers and books separate from content; numbered pages; filter by authors with Urdu input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Results in groups: شعرا و ادبا (by name or pen name, with years, work counts and a 'search in their works' link), کتابیں اور ابواب (books/chapters by title), and کلام (content). - Content pages: numbered pagination (first, last, two either side, gaps) and 'نتائج ۴۱–۶۰ از ۸۱۱۶'. - Author filter: chips for the authors the results come from, with counts; tick one or several to narrow; a name box finds any other poet/writer, with the Urdu keyboard and Roman-to-Urdu typing. The keyboard and Roman typing now work on any input marked data-urdu, not only the search box. - API: poet=1,2 (several), authors with counts, poets and books on page 1 (api/src/search.ts). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- api/src/search.test.ts | 20 +++++ api/src/search.ts | 19 +++++ api/src/server.ts | 24 ++++-- web/src/layouts/Base.astro | 29 ++++++-- web/src/pages/search.astro | 145 +++++++++++++++++++++++++++++++------ web/src/styles/global.css | 33 +++++++++ 7 files changed, 236 insertions(+), 36 deletions(-) create mode 100644 api/src/search.test.ts create mode 100644 api/src/search.ts diff --git a/README.md b/README.md index 73448b71..3446011c 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Settings: `DIVAN_DATA_DIR` (default `/opt/divan-data`, cloned on first run), `DI |---|---| | `GET /api/poets` | all poets | | `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/search?q=&poet=1,2&page=` | poems containing all words (or a `"quoted phrase"`), Urdu-normalised; exact phrase first; each with the best-matching couplet or paragraph (`snippet`); optionally only some poets/writers; plus `authors` (who the results come from, with counts), and on page 1 `poets` (by name) and `books` (books/chapters by title) | | `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 | diff --git a/api/src/search.test.ts b/api/src/search.test.ts new file mode 100644 index 00000000..2439c807 --- /dev/null +++ b/api/src/search.test.ts @@ -0,0 +1,20 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { nameMatches } from './search.ts'; +import { terms } from './urdu.ts'; +import { pool } from './db.ts'; + +after(() => pool.end()); + +test('poets/writers by name or pen name; books/chapters by title; Urdu variants match', async () => { + const ghalib = await nameMatches(terms('غالب')); + assert.ok(ghalib.poets.some((p) => p.url === '/p266'), 'Ghalib by pen name'); + const iqbal = await nameMatches(terms('اقبال')); + assert.equal(iqbal.poets[0].url, '/p238', 'most works first'); + const book = await nameMatches(terms('بانگ درا')); + assert.ok(book.books.some((b) => b.url.startsWith('/p238/')), 'Iqbal\'s book by title'); + assert.ok(book.books[0].poet && book.books[0].works > 0); + const arabic = await nameMatches(terms('مير تقي مير')); // Arabic yeh + assert.ok(arabic.poets.some((p) => p.url === '/p303'), 'Arabic ي/ی variants'); + assert.deepEqual(await nameMatches(terms('ژژژژژ')), { poets: [], books: [] }); +}); diff --git a/api/src/search.ts b/api/src/search.ts new file mode 100644 index 00000000..8616b633 --- /dev/null +++ b/api/src/search.ts @@ -0,0 +1,19 @@ +import { pool } from './db.ts'; +import { normalise } from './urdu.ts'; + +// poets/writers by name or pen name, and books/chapters by title, matched with the same Urdu normalisation +// as the content; ponytail: ~1,400 rows filtered in memory per search, add normalised columns + trigram index if it grows +export async function nameMatches(terms: string[]) { + const has = (text: string) => { const n = normalise(text); return terms.every((t) => n.includes(t)); }; + const [poets, cats] = await Promise.all([ + pool.query(`SELECT p.id, p.url, p.name, p.nickname, p.birth_year_ah, p.death_year_ah, p.birth_year_ce, p.death_year_ce, + (SELECT count(*)::int FROM poems w WHERE w.poet_id = p.id) AS works FROM poets p`), + pool.query(`SELECT c.id, c.url, c.title, t.nickname AS poet, t.url AS poet_url, + (SELECT count(*)::int FROM poems w WHERE w.category_id = c.id) AS works + FROM categories c JOIN poets t ON t.id = c.poet_id WHERE c.parent_id IS NOT NULL`), + ]); + return { + poets: poets.rows.filter((p) => has(`${p.name} ${p.nickname}`)).sort((a, b) => b.works - a.works).slice(0, 24), + books: cats.rows.filter((c) => has(c.title)).sort((a, b) => b.works - a.works).slice(0, 12), + }; +} diff --git a/api/src/server.ts b/api/src/server.ts index 50212271..65b20667 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -1,7 +1,8 @@ // Divan API (Fastify + PostgreSQL) // GET /api/poets all poets // GET /api/page?url=/p238/... poet, category or poem at that URL -// GET /api/search?q=&poet=&page= +// GET /api/search?q=&poet=1,2&page= content results (paged, optionally only these poets/writers), the poets/writers +// the results come from (with counts), poets by name and books/chapters by title // GET /api/word?w= Wiktionary meanings and pronunciation (sidebar) // /api/auth/* accounts (see auth.ts) // /api/admin/* admin panel (see admin.ts) @@ -10,6 +11,7 @@ import Fastify from 'fastify'; import { pool } from './db.ts'; import { likePatterns, normalise, terms } from './urdu.ts'; +import { nameMatches } from './search.ts'; import { lookup, PUNCT } from './dictionary.ts'; import { authRoutes } from './auth.ts'; import { adminRoutes } from './admin.ts'; @@ -91,13 +93,15 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => { app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/search', async (req) => { const patterns = likePatterns(req.query.q ?? ''); - if (!patterns.length) return { total: 0, page: 1, results: [] }; + if (!patterns.length) return { total: 0, page: 1, pageSize: PAGE_SIZE, results: [], poets: [], books: [] }; const page = Math.max(1, Number(req.query.page) || 1); const params: unknown[] = [...patterns]; const where = patterns.map((_, i) => `p.search_text ILIKE $${i + 1}`); - if (req.query.poet) { - params.push(Number(req.query.poet)); - where.push(`p.poet_id = $${params.length}`); + const textWhere = where.join(' AND '); + const poetIds = [...new Set(String(req.query.poet ?? '').split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0))].slice(0, 50); + if (poetIds.length) { + params.push(poetIds); + where.push(`p.poet_id = ANY($${params.length})`); } const sql = `FROM poems p JOIN poets t ON t.id = p.poet_id WHERE ${where.join(' AND ')}`; // several words: poems with them together as a phrase come first @@ -130,7 +134,15 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea : vs.filter((v) => v.couplet === best.couplet && (v.position === 'Right' || v.position === 'Left')).map((v) => v.text); return { ...r, snippet: lines, prose: best?.position === 'Paragraph' }; }); - return { total: count.rows[0].n, page, pageSize: PAGE_SIZE, results }; + // names and titles on the first page: poets/writers whose name has every word, books/chapters likewise + const names = page === 1 && !poetIds.length ? await nameMatches(ts) : { poets: [], books: [] }; + // which poets/writers the matching content comes from (whatever the poet filter), for narrowing down + const [authors, selected] = await Promise.all([ + pool.query(`SELECT t.id, t.url, t.nickname, count(*)::int AS n FROM poems p JOIN poets t ON t.id = p.poet_id + WHERE ${textWhere} GROUP BY t.id ORDER BY n DESC, t.nickname LIMIT 40`, patterns), + poetIds.length ? pool.query('SELECT id, url, nickname FROM poets WHERE id = ANY($1) ORDER BY nickname', [poetIds]) : { rows: [] }, + ]); + return { total: count.rows[0].n, page, pageSize: PAGE_SIZE, results, ...names, authors: authors.rows, selected: selected.rows }; }); // one word in Arabic script (Urdu, Persian, Arabic), as selected by a reader diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 79703140..7ae005f2 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -34,7 +34,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
دیوان
`; }; render(); + // the Urdu keyboard and Roman typing work on the search box and on any input marked data-urdu + // (e.g. the author filter): whichever was focused last + let target = q; + document.addEventListener('focusin', (e) => { if (e.target.matches?.('[data-urdu]')) target = e.target; }); + const typed = () => target.dispatchEvent(new Event('input', { bubbles: true })); // so suggestions update const insert = (text) => { - const s = q.selectionStart ?? q.value.length, e = q.selectionEnd ?? q.value.length; - q.setRangeText(text, s, e, 'end'); q.focus(); + const s = target.selectionStart ?? target.value.length, e = target.selectionEnd ?? target.value.length; + target.setRangeText(text, s, e, 'end'); target.focus(); typed(); }; kb.onmousedown = (ev) => ev.preventDefault(); // keep focus/caret in the search box kb.onclick = (ev) => { const b = ev.target.closest('button'); if (!b) return; if (b.dataset.act === 'shift') { shift = !shift; render(); return; } if (b.dataset.act === 'bs') { - const s = q.selectionStart, e = q.selectionEnd; - q.setRangeText('', s === e ? Math.max(0, s - 1) : s, e, 'end'); q.focus(); + const s = target.selectionStart, e = target.selectionEnd; + target.setRangeText('', s === e ? Math.max(0, s - 1) : s, e, 'end'); target.focus(); typed(); return; } insert(b.dataset.ch); if (shift) { shift = false; render(); } // one-shot shift, as on Gboard }; kbBtn.onclick = () => { - kb.hidden = !kb.hidden; kbBtn.setAttribute('aria-pressed', String(!kb.hidden)); q.focus(); + kb.hidden = !kb.hidden; kbBtn.setAttribute('aria-pressed', String(!kb.hidden)); target.focus(); }; - romBtn.onclick = () => { romBtn.setAttribute('aria-pressed', String(romBtn.getAttribute('aria-pressed') !== 'true')); q.focus(); }; - q.addEventListener('keydown', (ev) => { + romBtn.onclick = () => { romBtn.setAttribute('aria-pressed', String(romBtn.getAttribute('aria-pressed') !== 'true')); target.focus(); }; + // other inputs can open the same keyboard / Roman typing with their own buttons + document.addEventListener('click', (e) => { + const b = e.target.closest('[data-urdu-kb], [data-urdu-rom]'); if (!b) return; + target = document.getElementById(b.dataset.urduKb ?? b.dataset.urduRom); + (b.dataset.urduKb ? kbBtn : romBtn).click(); + b.setAttribute('aria-pressed', (b.dataset.urduKb ? kbBtn : romBtn).getAttribute('aria-pressed')); + }); + document.addEventListener('keydown', (ev) => { + if (!ev.target.matches?.('[data-urdu]')) return; if (romBtn.getAttribute('aria-pressed') !== 'true' || ev.ctrlKey || ev.metaKey || ev.altKey) return; const ch = PHONETIC[ev.key]; if (ch) { ev.preventDefault(); insert(ch); } diff --git a/web/src/pages/search.astro b/web/src/pages/search.astro index 08a514e4..d5204651 100644 --- a/web/src/pages/search.astro +++ b/web/src/pages/search.astro @@ -1,41 +1,144 @@ --- +// Search: poets/writers by name and books/chapters by title (first page), then the content results, paged import Base from '../layouts/Base.astro'; import Seg from '../components/Seg.astro'; import { api } from '../lib/api'; -import { ud, highlighter, parts, excerpt } from '../lib/urdu'; +import { ud, years, highlighter, parts, excerpt } from '../lib/urdu'; const q = Astro.url.searchParams.get('q')?.trim() ?? ''; +// one or more poets/writers to search within (?poet=1&poet=2, or ?poet=1,2) +const poetIds = [...new Set(Astro.url.searchParams.getAll('poet').flatMap((v) => v.split(',')).map(Number).filter((n) => n > 0))]; const pageNo = Math.max(1, Number(Astro.url.searchParams.get('page')) || 1); -const res = q ? await api(`/api/search?q=${encodeURIComponent(q)}&page=${pageNo}`) : null; +const res = q ? await api(`/api/search?q=${encodeURIComponent(q)}&page=${pageNo}${poetIds.length ? `&poet=${poetIds.join(',')}` : ''}`) : null; +// every poet/writer for the name box (382; filtered in the browser) +const allPoets = res ? ((await api('/api/poets')) ?? []).map((p: any) => ({ id: p.id, name: p.nickname })) : []; const pages = res ? Math.ceil(res.total / res.pageSize) : 0; const re = highlighter(q); const hl = (t: string) => parts(t, re).map((s, i): [string, string] => [s, i % 2 ? 'mark' : '']); const open = (url: string) => `${url}?hl=${encodeURIComponent(q)}`; -const link = (n: number) => `/search?q=${encodeURIComponent(q)}&page=${n}`; +const link = (n: number, poets: number[] = poetIds) => `/search?q=${encodeURIComponent(q)}${poets.length ? `&poet=${poets.join(',')}` : ''}${n > 1 ? `&page=${n}` : ''}`; +// author chips: the selected ones first, then the authors the results come from +const chips = res ? [...res.selected.map((p: any) => ({ ...p, n: res.authors.find((a: any) => a.id === p.id)?.n ?? 0 })), + ...res.authors.filter((a: any) => !poetIds.includes(a.id))] : []; +// numbered pages: first, last, and two either side of the current one; gaps shown as … +const numbers = (() => { + const keep = new Set([1, pages, pageNo - 2, pageNo - 1, pageNo, pageNo + 1, pageNo + 2].filter((n) => n >= 1 && n <= pages)); + const sorted = [...keep].sort((a, b) => a - b), out: (number | null)[] = []; + sorted.forEach((n, i) => { if (i && n - sorted[i - 1] > 1) out.push(null); out.push(n); }); + return out; +})(); +const from = res ? (pageNo - 1) * res.pageSize + 1 : 0, to = res ? Math.min(res.total, pageNo * res.pageSize) : 0; --- - {!q &&

اوپر کے خانے میں شعر، لفظ یا عبارت لکھیں۔ پوری عبارت کے لیے اسے "…" میں لکھیں۔

} + {!q &&

اوپر کے خانے میں شاعر کا نام، شعر، لفظ یا عبارت لکھیں۔ پوری عبارت کے لیے اسے "…" میں لکھیں۔

} {res && ( <>

تلاش: {q}

-

{res.total ? `${ud(res.total)} نتائج` : 'کوئی نتیجہ نہیں ملا۔ کم الفاظ یا کلیدی لفظ آزمائیں۔'}

- {res.results.map((r: any) => ( -
- · {r.poet} - {r.snippet.length > 0 && ( - - {r.prose ?

: r.snippet.map((l: string) =>

)} -
- )} -
- ))} - {pages > 1 && ( - + + {res.poets.length > 0 && ( +
+

شعرا و ادبا {ud(res.poets.length)}

+
+ {res.poets.map((p: any) => ( + + ))} +
+
)} + + {res.books.length > 0 && ( +
+

کتابیں اور ابواب {ud(res.books.length)}

+
    + {res.books.map((b: any) => ( +
  • · {b.poet} ({ud(b.works)})
  • + ))} +
+
+ )} + +
+

کلام {ud(res.total)}

+ {chips.length > 0 && ( + + +

شاعر یا ادیب کے لحاظ سے محدود کریں:

+
+ {chips.map((a: any, i: number) => ( + + ))} + {chips.length > 12 && } +
+
+ + + + +
+

+ + {poetIds.length > 0 && سب شعرا میں تلاش} +

+ diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 218ac768..b30feb97 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -256,3 +256,36 @@ h1 + .muted { text-align: center; margin-top: 0; } .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, .mark-bm::after { content: '🔖'; } + +/* search: poets/writers, books/chapters, content (paged) */ +.search-group { margin: 18px 0 26px; } +.search-group h2 { font-size: 1.1rem; text-align: center; color: var(--brand); } +.search-group h2 small { font-size: .8rem; font-weight: 400; } +.poet-result { display: flex; flex-direction: column; align-items: center; gap: 2px; } +.poet-result .small { font-size: .75rem; } +.book-results { list-style: none; padding: 0; max-width: 700px; margin: 0 auto; } +.book-results li { padding: 5px 4px; border-bottom: 1px dashed var(--gold-light); text-align: center; } +.pager .pages { display: flex; gap: 4px; flex-wrap: wrap; justify-content: center; } +.pager .pages a, .pager .pages .current { min-width: 2em; text-align: center; padding: 0 8px; border: 1px solid var(--border); border-radius: 8px; text-decoration: none; } +.pager .pages .current { border-color: var(--brand); color: var(--brand); font-weight: 700; } +.pager .gap { padding: 0 2px; color: var(--muted); } + +/* author filter on search */ +.author-filter { max-width: 760px; margin: 0 auto 14px; padding: 10px 14px; border: 1px dashed var(--gold-light); border-radius: 12px; text-align: center; } +.author-filter p { margin: 2px 0 8px; } +.author-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; } +.author-chips .chip { display: inline-flex; align-items: center; gap: 4px; padding: 1px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--inner); cursor: pointer; font: inherit; font-size: .88rem; color: var(--ink); } +.author-chips .chip small { color: var(--muted); } +.author-chips .chip input { accent-color: var(--brand); margin: 0; } +.author-chips .chip:has(input:checked) { border-color: var(--brand); color: var(--brand); font-weight: 700; } +.author-chips .chip.extra { display: none; } +.author-chips.all .chip.extra { display: inline-flex; } +.author-find { position: relative; display: flex; gap: 4px; max-width: 420px; margin: 10px auto 0; } +.author-find input { flex: 1; font: inherit; padding: 3px 12px; border: 1.5px solid var(--border); border-radius: 10px; background: var(--paper); color: var(--ink); } +.in-box-btn { display: inline-flex; align-items: center; font: inherit; font-size: .85rem; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--muted); cursor: pointer; } +.in-box-btn[aria-pressed='true'] { color: var(--brand); border-color: var(--brand); } +.author-suggest { position: absolute; top: 100%; inset-inline: 0; z-index: 10; display: flex; flex-wrap: wrap; gap: 4px; padding: 6px; margin-top: 4px; background: var(--paper); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 4px 14px rgb(0 0 0 / .15); } +.author-suggest button { font: inherit; font-size: .88rem; padding: 0 10px; border: 1px solid var(--gold-light); border-radius: 999px; background: var(--inner); color: var(--ink); cursor: pointer; } +.author-suggest button:hover { border-color: var(--brand); color: var(--brand); } +.filter-actions { display: flex; gap: 12px; justify-content: center; align-items: center; margin-top: 10px !important; } +.filter-actions button { font: inherit; font-size: .85rem; padding: 1px 14px; border: 1.5px solid var(--gold); border-radius: 10px; background: var(--inner); color: var(--ink); cursor: pointer; } -- 2.47.3