Radif per ghazal, matla/maqta labels, pen name set apart; contents grouped by radif letter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-08 21:27:43 +02:00
parent 4c7e0559a7
commit 9a2db49eab
5 changed files with 39 additions and 11 deletions

View File

@ -51,11 +51,13 @@ for (const entry of manifest.Poets) {
const p = await read(`poets${ref.FullUrl}.json`);
const searchText = normalise([p.Title, ...p.Verses.map((v: any) => v.Text)].join(' '));
await client.query(
`INSERT INTO poems (id, category_id, poet_id, url, title, full_title, source_url, position, search_text)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
`INSERT INTO poems (id, category_id, poet_id, url, title, full_title, source_url, position, search_text,
radif, matla, maqta, radif_letter)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (id) DO UPDATE SET category_id=$2, poet_id=$3, url=$4, title=$5, full_title=$6,
source_url=$7, position=$8, search_text=$9`,
[p.Id, cat.Id, poet.Id, p.FullUrl, p.Title, p.FullTitle, p.SourceUrl ?? null, i, searchText],
source_url=$7, position=$8, search_text=$9, radif=$10, matla=$11, maqta=$12, radif_letter=$13`,
[p.Id, cat.Id, poet.Id, p.FullUrl, p.Title, p.FullTitle, p.SourceUrl ?? null, i, searchText,
p.Radif ?? null, p.Matla ?? null, p.Maqta ?? null, ref.RadifLetter ?? null],
);
await client.query('DELETE FROM verses WHERE poem_id = $1', [p.Id]);
if (p.Verses.length) {

View File

@ -70,7 +70,7 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => {
GROUP BY c.id ORDER BY c.position, c.id`,
[cat.id],
),
pool.query('SELECT url, title FROM poems WHERE category_id = $1 ORDER BY position, id', [cat.id]),
pool.query('SELECT url, title, radif_letter FROM poems WHERE category_id = $1 ORDER BY position, id', [cat.id]),
]);
return {
type: cat.parent_id === null ? 'poet' : 'category',

View File

@ -40,6 +40,12 @@ CREATE TABLE IF NOT EXISTS poems (
position integer NOT NULL DEFAULT 0,
search_text text NOT NULL DEFAULT '' -- normalised title + verses
);
-- ghazal marks (divan-data extension): radif ('' = rhyme only), matla/maqta couplet numbers, and the
-- radif letter that groups a divan's contents ("ردیف الف … ی"; NULL outside radif-ordered lists)
ALTER TABLE poems ADD COLUMN IF NOT EXISTS radif text;
ALTER TABLE poems ADD COLUMN IF NOT EXISTS matla integer;
ALTER TABLE poems ADD COLUMN IF NOT EXISTS maqta integer;
ALTER TABLE poems ADD COLUMN IF NOT EXISTS radif_letter text;
CREATE INDEX IF NOT EXISTS poems_category ON poems(category_id);
CREATE INDEX IF NOT EXISTS poems_search ON poems USING gin (search_text gin_trgm_ops);

View File

@ -10,15 +10,27 @@ if (!page) return new Response(null, { status: 404, statusText: 'Not found' });
// group verses into couplets / single lines / paragraphs for rendering
type Verse = { position: string; couplet: number; text: string };
const blocks: { kind: 'couplet' | 'single' | 'para'; lines: string[] }[] = [];
const blocks: { kind: 'couplet' | 'single' | 'para'; lines: string[]; mark?: string }[] = [];
if (page.type === 'poem') {
const { matla, maqta } = page.poem;
for (const v of page.verses as Verse[]) {
if (v.position === 'Right') blocks.push({ kind: 'couplet', lines: [v.text] });
if (v.position === 'Right') blocks.push({ kind: 'couplet', lines: [v.text],
mark: v.couplet === matla ? 'مطلع' : v.couplet === maqta ? 'مقطع' : undefined });
else if (v.position === 'Left' && blocks.at(-1)?.kind === 'couplet' && blocks.at(-1)!.lines.length === 1) blocks.at(-1)!.lines.push(v.text);
else if (v.position === 'Paragraph') blocks.push({ kind: 'para', lines: [v.text] });
else blocks.push({ kind: 'single', lines: [v.text] });
}
}
// pen name (marked ؔ in the text) set apart; split() with a capture group puts matches at odd indexes
const verse = (line: string) => line.split(/(\S*\u0614\S*)/);
// contents of a divan grouped by radif letter ("ردیف الف … ی"); ے is filed under ی as in printed divans
const LETTER = (l: string) => (l === 'ا' ? 'الف' : l === 'ے' ? 'ی' : l);
const groups: { letter: string | null; poems: any[] }[] = [];
for (const p of page.poems ?? []) {
const l = p.radif_letter ? LETTER(p.radif_letter) : null;
if (groups.at(-1)?.letter !== l || !groups.length) groups.push({ letter: l, poems: [] });
groups.at(-1)!.poems.push(p);
}
const crumbs = page.breadcrumbs ?? [];
const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? page.poet.nickname : page.category.title;
---
@ -47,16 +59,20 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
))}
</div>
)}
{page.type !== 'poem' && page.poems.length > 0 && (
<ol class="toc">{page.poems.map((p: any) => <li><a href={p.url}>{p.title}</a></li>)}</ol>
)}
{page.type !== 'poem' && groups.map((g) => (
<>
{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>
</>
))}
{page.type === 'poem' && (
<>
<h1>{page.poem.title}</h1>
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
<article class="poem">
{blocks.map((b) =>
b.kind === 'couplet' ? <div class="couplet">{b.lines.map((l) => <p>{l}</p>)}</div>
b.kind === 'couplet' ? <div class="couplet" data-mark={b.mark}>{b.lines.map((l) => <p>{verse(l).map((s, i) => i % 2 ? <span class="takhallus">{s}</span> : s)}</p>)}</div>
: b.kind === 'para' ? <p class="para">{b.lines[0]}</p>
: <p class="single">{b.lines[0]}</p>
)}

View File

@ -86,6 +86,10 @@ h1::after { content: ''; display: block; width: 140px; height: 2px; background:
.couplet { display: grid; grid-template-columns: 1fr 1fr; column-gap: 10%; margin: 0 0 6px; border-radius: 6px; font-size: 1.1rem; line-height: var(--verse-lh); }
.couplet:hover { background: linear-gradient(90deg, transparent, var(--gold-subtle) 15%, var(--gold-subtle) 85%, transparent); }
.couplet p { margin: 0; text-align: left; }
.couplet[data-mark]::before { content: attr(data-mark); grid-column: 1 / -1; justify-self: center; font-size: .72rem; line-height: 1.6; color: var(--brand); border: 1px solid var(--gold-light); border-radius: 999px; padding: 0 10px; margin-bottom: 2px; }
.takhallus { color: var(--brand); }
.radif { text-align: center; margin-top: -6px; }
.radif-head { text-align: center; font-size: 1.05rem; color: var(--brand); margin: 22px 0 4px; }
.couplet p + p { text-align: right; }
.couplet::after { content: '✤'; grid-column: 1 / -1; text-align: center; color: var(--brand); font-size: .72rem; opacity: .75; line-height: 1.2; }
.single { text-align: center; margin: 0 0 6px; font-size: 1.1rem; line-height: var(--verse-lh); }