Compare commits

...

2 Commits

Author SHA1 Message Date
c2293f9794 Merge pull request 'Tags: typed tags on poets, books, works and couplets; tag pages; tag: in search' (#65) from feature/tags into main
Reviewed-on: #65
2026-10-09 12:43:56 +00:00
Anas Rashid
542c4672c9 Tags (#52): typed tags on poets, books/chapters, works and couplets; tag pages; tag: in search
- Types موضوع، صنف، بحر، شخصیت، مقام، دور and free ٹیگ; a work's tag may be on one couplet (شعر n, from 1).
- Tagging is a revision (tags-category / tags-work) through the L2 -> L1 -> admin pipeline with the new 'tags'
  permission; publishing writes divan-data/divan/<url>.tags, commits it and updates the site; the import reloads them.
- Pages show tag chips (couplet tags under the couplet); /tag/<type>/<name> lists what carries a tag; /tags lists all.
- Search: tag:عشق / ٹیگ:"حب الوطنی" filters works (tagged work, couplet or book), matching tags shown as a group.
- Tag editor rows with suggestions, Urdu keyboard and Roman typing.
- Tests run one file at a time (they share Ghalib's ghazals); the library test picks a ghazal with three couplets.
- Mod buttons sit in a div (a form closes a p, which broke the row).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:42:19 +02:00
21 changed files with 598 additions and 52 deletions

View File

@ -9,7 +9,7 @@
"scripts": {
"start": "node src/server.ts",
"import": "node src/import.ts",
"test": "node --test src/*.test.ts",
"test": "node --test --test-concurrency=1 src/*.test.ts",
"dict-sync": "node src/dict-sync.ts",
"make-admin": "node src/admin-cli.ts"
},

View File

@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { pool } from './db.ts';
import { normalise } from './urdu.ts';
import { loadTags } from './tags.ts';
const SOURCE = process.argv[2] ?? new URL('../../../divan-data', import.meta.url).pathname;
const isHttp = /^https?:/.test(SOURCE);
@ -85,5 +86,7 @@ for (const entry of manifest.Poets) {
if (process.stdout.isTTY) process.stdout.write(`\r${poems} poems`); // progress only when interactive (cron logs stay clean)
}
await pool.query(await readFile(new URL('../../db/featured.sql', import.meta.url), 'utf8'));
// published tags (divan-data/divan/*.tags) back onto the pages they belong to
if (!isHttp) console.log(`\ntags on ${await loadTags(SOURCE)} pages`);
console.log(`\nimported ${manifest.Poets.length} poets, ${poems} poems`);
await pool.end();

View File

@ -26,7 +26,9 @@ test('library: save and unsave poets, works, couplets and words; full paths; not
const other = (await call('POST', '/api/auth/signup', { email: `lib2-${run}@divan.test`, password: 'pass-word-1' })).json();
const t = me.token, toggle = (body: object, tk = t) => call('POST', '/api/library/toggle', body, tk);
const poem = (await pool.query(`SELECT p.id FROM poems p JOIN categories c ON c.id = p.category_id WHERE c.url = '/p266/ghazal' LIMIT 1`)).rows[0].id;
// a Ghalib ghazal with at least three couplets (the test bookmarks couplet 2)
const poem = (await pool.query(`SELECT p.id FROM poems p JOIN categories c ON c.id = p.category_id WHERE c.url = '/p266/ghazal'
AND EXISTS (SELECT 1 FROM verses v WHERE v.poem_id = p.id AND v.couplet >= 2) ORDER BY p.id LIMIT 1`)).rows[0].id;
assert.equal((await call('GET', '/api/library')).statusCode, 401, 'signed out');
assert.deepEqual((await toggle({ kind: 'poet', poetId: 266 })).json().saved, true);

View File

@ -48,7 +48,7 @@ test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permi
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, reader.token)).statusCode, 403, 'readers cannot moderate');
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, other.token)).statusCode, 403, 'outside the grant');
assert.deepEqual((await call('GET', `/api/mod/can?poem=${poem.id}`, undefined, l2.token)).json(), { edit: true, review: false, publish: false });
assert.deepEqual((await call('GET', `/api/mod/can?poem=${poem.id}`, undefined, l2.token)).json(), { edit: true, review: false, publish: false, tags: false });
// opening the editor is not a change: an unchanged draft is not listed, and saving it unchanged drops it
const blank = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json().id;

View File

@ -10,7 +10,8 @@
// and updates the site.
// If another version was published after the draft started, publishing is refused until the draft is redone.
// Arranging a book or section (order.ts, entity 'order') uses the same revisions, steps and publishing; it needs
// the separate 'arrange' permission.
// the separate 'arrange' permission. Tagging (tags.ts, entity 'tags-category' / 'tags-work') likewise, with the 'tags'
// permission.
// GET /api/mod/can?poem= what the reader may do on a work
// GET /api/mod/queue my drafts, drafts to review, drafts to publish
// GET /api/mod/work/:id a work's current text and its history
@ -18,6 +19,8 @@
// GET /api/mod/revisions/:id a revision, its diff against the version it started from, its events
// GET /api/mod/order/:id a book/section's current order and its history
// POST /api/mod/order/:id/draft start (or reopen) my arrangement draft
// GET /api/mod/tags/:kind/:id a book/section's (kind category) or work's tags and their history
// POST /api/mod/tags/:kind/:id/draft start (or reopen) my tagging draft
// POST /api/mod/revisions/:id/save {content, summary}
// POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment}
// GET /api/mod/log?page= who did what, newest first
@ -31,6 +34,7 @@ import { normalise } from './urdu.ts';
import { diffLines, changed } from './diff.ts';
import { commit, identity } from './git.ts';
import { currentOrder, checkOrder, applyOrder, writeOrder, orderSlugs } from './order.ts';
import { currentTags, checkTags, applyTags, writeTags, parseTags, tagsText, type Tag, type TagTarget } from './tags.ts';
import { publicName } from './auth.ts';
const dataDir = () => process.env.DIVAN_DATA_DIR ?? new URL('../../../divan-data', import.meta.url).pathname;
@ -38,8 +42,12 @@ const isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role);
const mayEdit = (u: any, poemId: number) => can(u, 'edit', 'works', { poemId });
const mayArrange = async (u: any, categoryId: number) =>
(await can(u, 'arrange', 'works', { categoryId })) || (await can(u, 'arrange', 'books', { categoryId }));
// may change what this revision changes (a work's text, or a book/section's order); reviewing needs L1 or admin
const mayChange = (u: any, r: { entity: string; entity_id: number }) => (r.entity === 'order' ? mayArrange : mayEdit)(u, r.entity_id);
const mayTag = (u: any, kind: TagTarget, id: number) => can(u, 'edit', 'tags', kind === 'work' ? { poemId: id } : { categoryId: id });
// may change what this revision changes (a work's text, a book/section's order, tags); reviewing needs L1 or admin
const mayChange = (u: any, r: { entity: string; entity_id: number }) =>
r.entity === 'order' ? mayArrange(u, r.entity_id) : r.entity.startsWith('tags-') ? mayTag(u, tagKind(r), r.entity_id) : mayEdit(u, r.entity_id);
const tagKind = (r: { entity: string }) => r.entity.slice('tags-'.length) as TagTarget;
const ENTITIES = `('work', 'order', 'tags-category', 'tags-work')`;
const mayReview = async (u: any, r: { entity: string; entity_id: number }) => ['mod-l1', 'admin'].includes(u?.role) && (await mayChange(u, r));
const OPEN = ['draft', 'returned'];
// a draft whose text is still the text it started from is not shown anywhere (opening the editor is not a change)
@ -69,12 +77,12 @@ async function current(poemId: number) {
}
// what a revision changes: a work, or a book/section's order (work_title/work_url name either)
const TARGET = `LEFT JOIN poems p ON r.entity = 'work' AND p.id = r.entity_id
LEFT JOIN categories c ON r.entity = 'order' AND c.id = r.entity_id`;
const TARGET = `LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id`;
const TARGET_COLS = `coalesce(p.title, c.title) AS work_title, coalesce(p.url, c.url) AS work_url`;
async function revision(id: number) {
return (await pool.query(`SELECT r.*, ${TARGET_COLS} FROM revisions r ${TARGET} WHERE r.id = $1 AND r.entity IN ('work', 'order')`, [id])).rows[0];
return (await pool.query(`SELECT r.*, ${TARGET_COLS} FROM revisions r ${TARGET} WHERE r.id = $1 AND r.entity IN ${ENTITIES}`, [id])).rows[0];
}
// what this person may do with this revision now
@ -91,16 +99,16 @@ async function actions(u: any, r: any) {
}
async function publish(r: any, u: any, comment?: string) {
const order = r.entity === 'order';
const cur = order ? await currentOrder(r.entity_id) : await current(r.entity_id);
const order = r.entity === 'order', tags = r.entity.startsWith('tags-');
const cur = order ? await currentOrder(r.entity_id) : tags ? await currentTags(tagKind(r), r.entity_id) : await current(r.entity_id);
if (!cur) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 });
if (cur.version !== r.base_version)
throw Object.assign(new Error('اس دوران اس کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 });
// the section's contents may have changed since (a new work from the sync): the arrangement must be redone
const stale = order && (await checkOrder(r.entity_id, r.content));
const stale = order ? await checkOrder(r.entity_id, r.content) : tags ? await checkTags(tagKind(r), r.entity_id, r.content) : null;
if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 });
const doc = order ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
const title = 'cat' in cur ? `ترتیب: ${cur.cat.title}` : doc!.meta['عنوان'] || cur.poem.title;
const doc = order || tags ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
const title = 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}` : doc!.meta['عنوان'] || cur.poem.title;
const version = cur.version + 1, at = new Date().toISOString();
// who did it, by public name (divan-data is public: never email addresses)
const people = async (id: unknown) => id ? (await pool.query('SELECT id, full_name FROM users WHERE id = $1', [id])).rows[0] ?? null : null;
@ -109,6 +117,7 @@ async function publish(r: any, u: any, comment?: string) {
const credits = { by: who(author)?.name ?? 'موڈریٹر', reviewedBy: who(reviewer)?.name ?? null, publishedBy: publicName(u) };
// divan-data first (the published record, committed to git), then the site's database
const files = 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)]
: 'target' in cur ? [await writeTags(dataDir(), cur.target.url, r.content)]
: await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) });
const trailers = [`Divan-Revision: ${r.id}`, `Divan-Version: ${version}`,
...(reviewer ? [`Reviewed-by: ${identityOf(reviewer)}`] : []), `Approved-by: ${identityOf(u)}`];
@ -120,6 +129,7 @@ async function publish(r: any, u: any, comment?: string) {
await client.query(`UPDATE revisions SET status = 'published', version = $2, publisher_email = $3, published_at = $4, commit = $5, credits = $6, updated_at = now()
WHERE id = $1`, [r.id, version, u.email, at, sha, credits]);
if (order) await applyOrder(client, r.entity_id, r.content);
else if (tags) await applyTags(client, tagKind(r), r.entity_id, r.content);
else {
await client.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1',
[r.entity_id, title, normalise([title, ...verses.map((v) => v.Text)].join(' '))]);
@ -146,9 +156,10 @@ export function moderationRoutes(app: FastifyInstance) {
// ?poem= on a work page; ?category= on a poet or book/section page (arrange)
app.get<{ Querystring: { poem?: string; category?: string } }>('/api/mod/can', async (req) => {
const u = await sessionUser(req), poemId = Number(req.query.poem) || 0, categoryId = Number(req.query.category) || 0;
if (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false };
if (categoryId) return { arrange: await mayArrange(u, categoryId) };
return { edit: await mayEdit(u, poemId), review: await mayReview(u, { entity: 'work', entity_id: poemId }), publish: u.role === 'admin' };
if (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false, tags: false };
if (categoryId) return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId) };
return { edit: await mayEdit(u, poemId), review: await mayReview(u, { entity: 'work', entity_id: poemId }), publish: u.role === 'admin',
tags: await mayTag(u, 'work', poemId) };
});
app.get('/api/mod/queue', async (req, reply) => {
@ -157,7 +168,7 @@ export function moderationRoutes(app: FastifyInstance) {
`SELECT r.id, r.entity, r.entity_id, r.status, r.summary, r.author_id, r.author_email, r.reviewer_email, r.updated_at,
coalesce(p.title, c.title) AS title, coalesce(p.url, c.url) AS url
FROM revisions r ${TARGET}
WHERE r.entity IN ('work', 'order') AND (r.status IN ('submitted', 'approved') OR (r.author_id = $1 AND r.status IN ('draft', 'returned') AND ${CHANGED}))
WHERE r.entity IN ${ENTITIES} AND (r.status IN ('submitted', 'approved') OR (r.author_id = $1 AND r.status IN ('draft', 'returned') AND ${CHANGED}))
ORDER BY r.updated_at DESC LIMIT 300`, [u.id]);
const strip = ({ author_id, ...r }: any) => ({ ...r, id: Number(r.id) });
const review = [];
@ -226,6 +237,37 @@ export function moderationRoutes(app: FastifyInstance) {
return { id: Number(rows[0].id) };
});
app.get<{ Params: { kind: string; id: string } }>('/api/mod/tags/:kind/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const kind = req.params.kind as TagTarget;
if (kind !== 'work' && kind !== 'category') return reply.code(404).send({ error: 'نہیں ملا' });
const cur = await currentTags(kind, Number(req.params.id) || 0);
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
const { rows } = await pool.query(
`SELECT id, version, base_version, status, summary, author_email, reviewer_email, publisher_email, created_at, published_at
FROM revisions r WHERE entity = $1 AND entity_id = $2 AND ${CHANGED} ORDER BY coalesce(published_at, created_at) DESC`, [`tags-${kind}`, cur.target.id]);
return { kind, target: cur.target, version: cur.version, content: cur.content, couplets: cur.couplets,
history: rows.map((r) => ({ ...r, id: Number(r.id) })), may: { tags: await mayTag(u, kind, cur.target.id) } };
});
app.post<{ Params: { kind: string; id: string } }>('/api/mod/tags/:kind/:id/draft', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const kind = req.params.kind as TagTarget, id = Number(req.params.id) || 0;
if (kind !== 'work' && kind !== 'category') return reply.code(404).send({ error: 'نہیں ملا' });
if (!(await mayTag(u, kind, id))) return reply.code(403).send({ error: 'یہاں ٹیگ لگانے کی اجازت نہیں' });
const open = (await pool.query(
`SELECT id FROM revisions WHERE entity = $1 AND entity_id = $2 AND author_id = $3 AND status IN ('draft', 'returned') LIMIT 1`, [`tags-${kind}`, id, u.id])).rows[0];
if (open) return { id: Number(open.id) };
const cur = await currentTags(kind, id);
if (!cur) return reply.code(404).send({ error: 'نہیں ملا' });
const { rows } = await pool.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
VALUES ($1, $2, $3, $4, $4, 'draft', $5, $6) RETURNING id`,
[`tags-${kind}`, id, cur.version, cur.content, u.id, u.email]);
await event(rows[0].id, u, 'created');
return { id: Number(rows[0].id) };
});
app.get<{ Params: { id: string } }>('/api/mod/revisions/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return;
const r = await revision(Number(req.params.id) || 0);
@ -245,14 +287,14 @@ export function moderationRoutes(app: FastifyInstance) {
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
if (!(await actions(u, r)).save) return reply.code(403).send({ error: 'یہ مسودہ اب محفوظ نہیں کیا جا سکتا' });
const content = String(req.body?.content ?? '').replace(/\r\n?/g, '\n');
if (r.entity === 'order') {
const bad = await checkOrder(r.entity_id, content);
if (r.entity === 'order' || r.entity.startsWith('tags-')) {
const bad = r.entity === 'order' ? await checkOrder(r.entity_id, content) : await checkTags(tagKind(r), r.entity_id, content);
if (bad) return reply.code(400).send({ error: bad });
} else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
if (content.length > 500_000) return reply.code(400).send({ error: 'متن بہت لمبا ہے' });
// no change from the text it started from (compared as Divan text, so layout-only differences don't count):
// a plain draft is dropped rather than kept
const norm = (t: string) => (r.entity === 'order' ? orderSlugs(t).join('\n') : toText(parse(t)));
const norm = (t: string) => (r.entity === 'order' ? orderSlugs(t).join('\n') : r.entity.startsWith('tags-') ? tagsText(parseTags(t) as Tag[]) : toText(parse(t)));
const same = norm(content) === norm(r.base_content);
if (same && r.status === 'draft') {
await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]);
@ -305,7 +347,7 @@ export function moderationRoutes(app: FastifyInstance) {
const { rows } = await pool.query(
`SELECT e.at, e.actor_email, e.action, e.comment, r.id AS revision, r.entity, r.version, coalesce(p.title, c.title) AS title, coalesce(p.url, c.url) AS url
FROM revision_events e JOIN revisions r ON r.id = e.revision_id ${TARGET}
WHERE r.entity IN ('work', 'order') AND ${CHANGED}
WHERE r.entity IN ${ENTITIES} AND ${CHANGED}
ORDER BY e.at DESC, e.id DESC LIMIT 50 OFFSET ${(page - 1) * 50}`);
return { page, entries: rows.map((r) => ({ ...r, revision: Number(r.revision) })) };
});

View File

@ -36,10 +36,10 @@ test('arranging: its own permission; L2 reorders, L1 approves, admin publishes t
const grant = (m: any, actions: string[]) =>
pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'category', $2, '{works}', $3)`, [m.id, cat.id, actions]);
await grant(l2, ['edit']);
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false }, 'editing is not arranging');
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false }, 'editing is not arranging');
assert.equal((await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).statusCode, 403);
await grant(l2, ['arrange']); await grant(l1, ['arrange']);
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true });
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true, tags: false });
const { id } = (await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).json();
const cur = (await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json();

View File

@ -13,7 +13,7 @@ import { audit, requireAdmin } from './admin.ts';
export const MODERATORS = ['mod-l2', 'mod-l1'] as const;
export const SCOPES = ['all', 'poet', 'category', 'poem'] as const;
export const CONTENT = ['poets', 'books', 'works', 'dictionary', 'site'] as const; // site: the home page sections (site.ts)
export const CONTENT = ['poets', 'books', 'works', 'dictionary', 'site', 'tags'] as const; // site: the home page sections (site.ts); tags (tags.ts)
export const ACTIONS = ['create', 'edit', 'delete', 'arrange'] as const;
export type Action = (typeof ACTIONS)[number];
export type Content = (typeof CONTENT)[number];

View File

@ -20,6 +20,7 @@ import { permissionRoutes } from './permissions.ts';
import { libraryRoutes } from './library.ts';
import { moderationRoutes } from './moderation.ts';
import { siteRoutes } from './site.ts';
import { tagRoutes, pageTags } from './tags.ts';
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
const PAGE_SIZE = 20;
@ -71,7 +72,8 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => {
const divan = e && { version: e.version, at: e.published_at, ...e.credits,
commit_url: e.commit ? (process.env.DIVAN_DATA_COMMIT_URL ?? 'https://git.anasrashid.net/anas/divan-data/commit/{sha}').replace('{sha}', e.commit) : null };
const { search_text, ...rest } = poem;
return { type: 'poem', poem: rest, poet: poet.rows[0], breadcrumbs: crumbs, verses: verses.rows, ...siblings.rows[0], divan };
return { type: 'poem', poem: rest, poet: poet.rows[0], breadcrumbs: crumbs, verses: verses.rows, ...siblings.rows[0], divan,
tags: await pageTags('work', poem.id) };
}
const cat = (await pool.query('SELECT * FROM categories WHERE url = $1', [url])).rows[0];
@ -94,19 +96,29 @@ app.get<{ Querystring: { url?: string } }>('/api/page', async (req, reply) => {
]);
return {
type: cat.parent_id === null ? 'poet' : 'category',
category: cat, poet: poet.rows[0], breadcrumbs: crumbs, children: children.rows, poems: poems.rows,
category: cat, poet: poet.rows[0], breadcrumbs: crumbs, children: children.rows, poems: poems.rows, tags: await pageTags('category', cat.id),
};
}
return reply.code(404).send({ error: 'not found' });
});
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, pageSize: PAGE_SIZE, results: [], poets: [], books: [] };
// tag:عشق or ٹیگ:عشق (quotes for names with spaces) keeps works carrying the tag: on the work, one of its couplets,
// or its book/chapter; the rest of the query is text as before
const TAG = /(?:tag|ٹیگ):(?:"([^"]+)"|(\S+))/g;
const tagNames = [...new Set([...(req.query.q ?? '').matchAll(TAG)].map((m) => (m[1] ?? m[2]).trim()).filter(Boolean))].slice(0, 5);
const q = (req.query.q ?? '').replace(TAG, ' ').trim();
const patterns = likePatterns(q);
if (!patterns.length && !tagNames.length) return { total: 0, page: 1, pageSize: PAGE_SIZE, results: [], poets: [], books: [], tags: [] };
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}`);
const textWhere = where.join(' AND ');
for (const name of tagNames) {
params.push(name);
where.push(`EXISTS (SELECT 1 FROM entity_tags e JOIN tags g ON g.id = e.tag_id WHERE g.name = $${params.length}
AND ((e.entity = 'work' AND e.entity_id = p.id) OR (e.entity = 'category' AND e.entity_id = p.category_id)))`);
}
const textWhere = where.join(' AND '), textParams = [...params];
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);
@ -114,7 +126,7 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea
}
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
const phrase = likePatterns(`"${req.query.q}"`)[0];
const phrase = likePatterns(`"${q}"`)[0] ?? '%';
const [count, rows] = await Promise.all([
pool.query(`SELECT count(*)::int AS n ${sql}`, params),
pool.query(
@ -125,15 +137,21 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea
),
]);
// snippet: the verse holding most of the terms, best the whole phrase (with its couplet partner), else the first line
const ts = terms(req.query.q ?? ''), whole = ts.join(' '), top = ts.length + (ts.length > 1 ? 1 : 0);
const ts = terms(q), whole = ts.join(' '), top = ts.length + (ts.length > 1 ? 1 : 0);
const verses = await pool.query(
'SELECT poem_id, position, couplet, text FROM verses WHERE poem_id = ANY($1) ORDER BY poem_id, vorder',
[rows.rows.map((r) => r.id)],
);
const byPoem = Map.groupBy(verses.rows, (v) => v.poem_id);
// with tags and no words, a work's snippet is its tagged couplet
const tagged = tagNames.length && !ts.length ? (await pool.query(
`SELECT e.entity_id AS poem_id, min(e.couplet) AS couplet FROM entity_tags e JOIN tags g ON g.id = e.tag_id
WHERE e.entity = 'work' AND e.couplet > 0 AND g.name = ANY($1) AND e.entity_id = ANY($2) GROUP BY e.entity_id`,
[tagNames, rows.rows.map((r) => r.id)])).rows : [];
const results = rows.rows.map(({ id, ...r }) => {
const vs = byPoem.get(id) ?? [];
let best = vs[0], score = 0;
const tc = tagged.find((t) => t.poem_id === id)?.couplet;
let best = (tc && vs.find((v) => v.couplet === tc - 1)) || vs[0], score = 0; // tags count couplets from 1
for (const v of vs) {
const n = normalise(v.text), k = ts.filter((t) => n.includes(t)).length + (ts.length > 1 && n.includes(whole) ? 1 : 0);
if (k > score) [best, score] = [v, k];
@ -144,14 +162,19 @@ app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/sea
return { ...r, snippet: lines, prose: best?.position === 'Paragraph' };
});
// 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: [] };
const names = page === 1 && !poetIds.length && ts.length ? await nameMatches(ts) : { poets: [], books: [] };
// tags whose name has the words (first page), and the tags searched for
const tagRows = page === 1 && (ts.length || tagNames.length) ? (await pool.query(
`SELECT g.type, g.name, count(*)::int AS n FROM tags g JOIN entity_tags e ON e.tag_id = g.id GROUP BY g.id ORDER BY n DESC`)).rows
.filter((t) => tagNames.includes(t.name) || (ts.length && ts.every((w) => normalise(t.name).includes(w)))).slice(0, 20) : [];
// 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),
WHERE ${textWhere} GROUP BY t.id ORDER BY n DESC, t.nickname LIMIT 40`, textParams),
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 };
return { total: count.rows[0].n, page, pageSize: PAGE_SIZE, results, ...names, tags: tagRows, searchedTags: tagNames,
authors: authors.rows, selected: selected.rows };
});
// one word in Arabic script (Urdu, Persian, Arabic), as selected by a reader
@ -167,6 +190,7 @@ permissionRoutes(app);
libraryRoutes(app);
moderationRoutes(app);
siteRoutes(app);
tagRoutes(app);
const port = Number(process.env.PORT ?? 4100);
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });

101
api/src/tags.test.ts Normal file
View File

@ -0,0 +1,101 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import Fastify from 'fastify';
import { authRoutes } from './auth.ts';
import { moderationRoutes } from './moderation.ts';
import { tagRoutes, parseTags, tagsText, loadTags } from './tags.ts';
import { pool } from './db.ts';
after(() => pool.end());
test('tag text: types, couplets, Urdu digits, repeats, order', () => {
assert.deepEqual(parseTags('موضوع: عشق\nشعر ۳ › شخصیت: مجنوں\n\nموضوع: عشق'),
[{ couplet: 0, type: 'موضوع', name: 'عشق' }, { couplet: 3, type: 'شخصیت', name: 'مجنوں' }]);
assert.match(parseTags('رنگ: سرخ') as string, /قسم/);
assert.match(parseTags('بس ایک لفظ') as string, /سمجھ/);
assert.equal(tagsText(parseTags('شعر 2 › مقام: دلی\nٹیگ: مشہور\nموضوع: تصوف') as any), 'موضوع: تصوف\nٹیگ: مشہور\nشعر 2 › مقام: دلی');
});
test('tagging: tags permission, pipeline, published to divan-data and the site, tag pages, reloaded by the import', async () => {
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
process.env.DIVAN_DATA_DIR = data;
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
const app = Fastify();
authRoutes(app); moderationRoutes(app); tagRoutes(app);
const run = Date.now();
const call = (method: string, url: string, body?: object, token?: string) =>
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `tag-${run}` } });
const person = async (name: string, role: string) => {
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
return { ...s, id: s.user.id };
};
const admin = await person('admin', 'admin'), l1 = await person('l1', 'mod-l1'), l2 = await person('l2', 'mod-l2');
const cat = (await pool.query(`SELECT id, url FROM categories WHERE url = '/p266/ghazal'`)).rows[0];
const work = (await pool.query(`SELECT p.id, p.url FROM poems p WHERE p.category_id = $1
AND EXISTS (SELECT 1 FROM verses v WHERE v.poem_id = p.id AND v.couplet >= 2) ORDER BY p.id LIMIT 1`, [cat.id])).rows[0];
const saved = (await pool.query('SELECT t.type, t.name, e.entity, e.entity_id, e.couplet FROM entity_tags e JOIN tags t ON t.id = e.tag_id')).rows;
try {
const grant = (m: any, content: string) =>
pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'category', $2, $3, '{edit}')`, [m.id, cat.id, [content]]);
await grant(l2, 'works');
assert.equal((await call('GET', `/api/mod/can?poem=${work.id}`, undefined, l2.token)).json().tags, false, 'editing text is not tagging');
assert.equal((await call('POST', `/api/mod/tags/work/${work.id}/draft`, undefined, l2.token)).statusCode, 403);
await grant(l2, 'tags'); await grant(l1, 'tags');
// L2 tags the work (whole and its third couplet) and the ghazals' section
const w = (await call('POST', `/api/mod/tags/work/${work.id}/draft`, undefined, l2.token)).json().id;
assert.equal((await call('POST', `/api/mod/revisions/${w}/save`, { content: 'رنگ: سرخ' }, l2.token)).statusCode, 400, 'unknown type');
assert.equal((await call('POST', `/api/mod/revisions/${w}/save`, { content: 'شعر 999 › موضوع: عشق' }, l2.token)).statusCode, 400, 'no such couplet');
const tagText = `موضوع: عشق-${run}\nشعر 2 › شخصیت: مجنوں-${run}`;
assert.equal((await call('POST', `/api/mod/revisions/${w}/save`, { content: tagText, summary: 'ٹیگ' }, l2.token)).statusCode, 200);
const c = (await call('POST', `/api/mod/tags/category/${cat.id}/draft`, undefined, l2.token)).json().id;
assert.equal((await call('POST', `/api/mod/revisions/${c}/save`, { content: 'شعر 1 › موضوع: عشق' }, l2.token)).statusCode, 400, 'no couplets on a section');
assert.equal((await call('POST', `/api/mod/revisions/${c}/save`, { content: `صنف: غزل-${run}` }, l2.token)).statusCode, 200);
for (const id of [w, c]) {
assert.equal((await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token)).json().status, 'submitted');
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, {}, l1.token)).json().status, 'approved');
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
}
// divan-data holds the tags next to the work; the tag pages list what carries them
assert.equal(await readFile(join(data, 'divan', work.url.slice(1) + '.tags'), 'utf8'), tagsText(parseTags(tagText) as any) + '\n');
assert.match(git('log', '-1', '--format=%s'), /^ٹیگ: /);
const love = (await call('GET', `/api/tag?type=${encodeURIComponent('موضوع')}&name=${encodeURIComponent(`عشق-${run}`)}`)).json();
assert.deepEqual(love.works.map((x: any) => [x.id, x.whole]), [[work.id, true]]);
const majnun = (await call('GET', `/api/tag?type=${encodeURIComponent('شخصیت')}&name=${encodeURIComponent(`مجنوں-${run}`)}`)).json();
assert.equal(majnun.works[0].couplets[0].couplet, 2);
assert.ok(majnun.works[0].couplets[0].lines.length >= 1, 'the couplet\'s lines');
const genre = (await call('GET', `/api/tag?type=${encodeURIComponent('صنف')}&name=${encodeURIComponent(`غزل-${run}`)}`)).json();
assert.deepEqual(genre.categories.map((x: any) => x.id), [cat.id]);
assert.ok((await call('GET', '/api/tags')).json().some((t: any) => t.name === `عشق-${run}` && t.n === 1));
// the import rebuilds the site's tags from divan-data
await pool.query('DELETE FROM entity_tags');
assert.equal(await loadTags(data), 2);
assert.equal((await call('GET', `/api/tag?type=${encodeURIComponent('صنف')}&name=${encodeURIComponent(`غزل-${run}`)}`)).json().categories.length, 1);
// removing every tag deletes the file
const again = (await call('POST', `/api/mod/tags/work/${work.id}/draft`, undefined, admin.token)).json().id;
await call('POST', `/api/mod/revisions/${again}/save`, { content: '' }, admin.token);
assert.deepEqual((await call('POST', `/api/mod/revisions/${again}/submit`, {}, admin.token)).json(), { status: 'published', version: 2 });
await assert.rejects(readFile(join(data, 'divan', work.url.slice(1) + '.tags'), 'utf8'));
} finally {
// the site's tags as they were
await pool.query('DELETE FROM entity_tags');
for (const t of saved) {
const id = (await pool.query('INSERT INTO tags (type, name) VALUES ($1, $2) ON CONFLICT (type, name) DO UPDATE SET name = EXCLUDED.name RETURNING id', [t.type, t.name])).rows[0].id;
await pool.query('INSERT INTO entity_tags (tag_id, entity, entity_id, couplet) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', [id, t.entity, t.entity_id, t.couplet]);
}
await pool.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
await pool.query(`DELETE FROM revisions WHERE author_email LIKE $1`, [`%-${run}@divan.test`]);
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
await rm(data, { recursive: true, force: true });
await app.close();
}
});

154
api/src/tags.ts Normal file
View File

@ -0,0 +1,154 @@
// Tags (#52): typed labels on books/chapters (a poet's page is their root category), works and a work's couplets.
// Tagging is a revision like any other change (entity 'tags-category' or 'tags-work', entity_id = the target), so it
// goes through the pipeline with the 'tags' permission and is published to divan-data as divan/<url>.tags.
// The text is one tag per line, "<type>: <name>", or for one couplet of a work "شعر <n> › <type>: <name>" (n counts
// from 1 as readers do; verses.couplet counts from 0, and couplet 0 here means the whole work):
// موضوع: عشق
// شعر 3 › شخصیت: مجنوں
// GET /api/tags every tag with how often it is used, by type
// GET /api/tag?type=&name= what carries a tag: books/chapters, works, couplets
import { readdir, readFile, rm, writeFile, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { FastifyInstance } from 'fastify';
import type { PoolClient } from 'pg';
import { pool } from './db.ts';
export const TAG_TYPES = ['موضوع', 'صنف', 'بحر', 'شخصیت', 'مقام', 'دور', 'ٹیگ'];
export type Tag = { couplet: number; type: string; name: string };
export type TagTarget = 'category' | 'work';
const LINE = /^(?:شعر\s*(\d+)\s*›\s*)?([^:]+):\s*(.+)$/;
const ud2en = (s: string) => s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)));
const key = (t: Tag) => `${t.couplet}|${t.type}|${t.name}`;
// the text as tags; an error message for a bad line
export function parseTags(text: string): Tag[] | string {
const out: Tag[] = [];
for (const raw of text.split('\n')) {
const line = raw.trim().replace(/\s+/g, ' ');
if (!line) continue;
const m = ud2en(line).match(LINE);
if (!m) return `سمجھ نہیں آیا: «${line}»`;
const [type, name] = [m[2].trim(), m[3].trim()];
if (!TAG_TYPES.includes(type)) return `ٹیگ کی قسم ${TAG_TYPES.join('، ')} میں سے ہو: «${type}»`;
if (name.length > 60) return `ٹیگ کا نام بہت لمبا ہے: «${name}»`;
const t = { couplet: Number(m[1] ?? 0), type, name };
if (!out.some((x) => key(x) === key(t))) out.push(t);
}
return out;
}
// tags in a fixed order (whole first, then by couplet; by type, then name), so equal sets give equal text
export const tagsText = (tags: Tag[]) => [...tags]
.sort((a, b) => a.couplet - b.couplet || TAG_TYPES.indexOf(a.type) - TAG_TYPES.indexOf(b.type) || a.name.localeCompare(b.name, 'ur'))
.map((t) => `${t.couplet ? `شعر ${t.couplet} › ` : ''}${t.type}: ${t.name}`).join('\n');
const tagsOf = async (entity: TagTarget, id: number, db: { query: PoolClient['query'] } = pool) => (await db.query(
`SELECT et.couplet, t.type, t.name FROM entity_tags et JOIN tags t ON t.id = et.tag_id WHERE et.entity = $1 AND et.entity_id = $2`,
[entity, id])).rows as Tag[];
// a target's published tags as text, and the latest published version of its tags
export async function currentTags(entity: TagTarget, id: number) {
const target = (await pool.query(entity === 'work'
? 'SELECT id, url, title, category_id FROM poems WHERE id = $1' : 'SELECT id, url, title, parent_id FROM categories WHERE id = $1', [id])).rows[0];
if (!target) return null;
const last = (await pool.query(
`SELECT max(version) AS v FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published'`, [`tags-${entity}`, id])).rows[0];
const couplets = entity === 'work'
? Number((await pool.query('SELECT coalesce(max(couplet) + 1, 0) AS n FROM verses WHERE poem_id = $1', [id])).rows[0].n) : 0;
return { target, couplets, version: Number(last?.v ?? 0), content: tagsText(await tagsOf(entity, id)) };
}
// the text is valid tags, on couplets the work has
export async function checkTags(entity: TagTarget, id: number, text: string) {
const tags = parseTags(text);
if (typeof tags === 'string') return tags;
if (entity === 'category' && tags.some((t) => t.couplet)) return 'کتاب یا باب کے ٹیگ کسی شعر پر نہیں لگتے';
const cur = await currentTags(entity, id);
const bad = tags.find((t) => t.couplet > (cur?.couplets ?? 0));
return bad ? `اس کلام میں شعر ${bad.couplet} نہیں` : null;
}
// the site's tags for the target become the text's
export async function applyTags(client: PoolClient, entity: TagTarget, id: number, text: string) {
const tags = parseTags(text) as Tag[];
await client.query('DELETE FROM entity_tags WHERE entity = $1 AND entity_id = $2', [entity, id]);
for (const t of tags) {
const tagId = (await client.query(
`INSERT INTO tags (type, name) VALUES ($1, $2) ON CONFLICT (type, name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [t.type, t.name])).rows[0].id;
await client.query('INSERT INTO entity_tags (tag_id, entity, entity_id, couplet) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING',
[tagId, entity, id, t.couplet]);
}
await client.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
}
// divan-data/divan/<url>.tags (removed when the last tag goes)
export async function writeTags(dataDir: string, url: string, text: string) {
const path = join(dataDir, 'divan', url.replace(/^\//, '') + '.tags');
if (!text.trim()) { await rm(path, { force: true }); return path; }
await mkdir(dirname(path), { recursive: true });
await writeFile(path, text.trim() + '\n');
return path;
}
// the import: every published .tags file in divan-data back into the site's tags (divan-data is the record)
export async function loadTags(dataDir: string) {
const root = join(dataDir, 'divan');
const files = await readdir(root, { recursive: true }).catch(() => [] as string[]);
const client = await pool.connect();
let n = 0;
try {
await client.query('BEGIN');
await client.query('DELETE FROM entity_tags');
for (const f of files.filter((f) => f.endsWith('.tags'))) {
const url = '/' + f.slice(0, -'.tags'.length);
const work = (await client.query('SELECT id FROM poems WHERE url = $1', [url])).rows[0];
const cat = work ? null : (await client.query('SELECT id FROM categories WHERE url = $1', [url])).rows[0];
if (!work && !cat) { console.warn(`tags for an unknown page: ${url}`); continue; }
const text = await readFile(join(root, f), 'utf8');
if (typeof parseTags(text) === 'string') { console.warn(`bad tags in ${f}`); continue; }
await applyTags(client, work ? 'work' : 'category', (work ?? cat).id, text);
n++;
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
return n;
}
export function tagRoutes(app: FastifyInstance) {
app.get('/api/tags', async () => (await pool.query(
`SELECT t.type, t.name, count(*)::int AS n FROM tags t JOIN entity_tags e ON e.tag_id = t.id GROUP BY t.id ORDER BY n DESC, t.name`)).rows);
app.get<{ Querystring: { type?: string; name?: string } }>('/api/tag', async (req, reply) => {
const tag = (await pool.query('SELECT id, type, name FROM tags WHERE type = $1 AND name = $2', [req.query.type ?? '', req.query.name ?? ''])).rows[0];
if (!tag) return reply.code(404).send({ error: 'ٹیگ نہیں ملا' });
const [cats, works] = await Promise.all([
pool.query(`SELECT c.id, c.url, c.title, t.nickname AS poet, t.url AS poet_url, c.parent_id IS NULL AS is_poet
FROM entity_tags e JOIN categories c ON c.id = e.entity_id JOIN poets t ON t.id = c.poet_id
WHERE e.tag_id = $1 AND e.entity = 'category' ORDER BY t.birth_year_ah NULLS LAST, c.title`, [tag.id]),
pool.query(`SELECT p.id, p.url, p.title, t.nickname AS poet, t.url AS poet_url, array_agg(e.couplet ORDER BY e.couplet) AS couplets
FROM entity_tags e JOIN poems p ON p.id = e.entity_id JOIN poets t ON t.id = p.poet_id
WHERE e.tag_id = $1 AND e.entity = 'work' GROUP BY p.id, t.id ORDER BY t.birth_year_ah NULLS LAST, p.title LIMIT 500`, [tag.id]),
]);
// the tagged couplets' lines
const pairs = works.rows.flatMap((w) => w.couplets.filter((c: number) => c > 0).map((c: number) => [w.id, c]));
const lines = pairs.length ? (await pool.query(
`SELECT poem_id, couplet, array_agg(text ORDER BY vorder) AS lines FROM verses
WHERE (poem_id, couplet + 1) IN (SELECT * FROM unnest($1::int[], $2::int[])) GROUP BY poem_id, couplet`,
[pairs.map((p) => p[0]), pairs.map((p) => p[1])])).rows : [];
return {
tag, categories: cats.rows,
works: works.rows.map((w) => ({ ...w, whole: w.couplets.includes(0),
couplets: lines.filter((l) => l.poem_id === w.id).map((l) => ({ couplet: l.couplet + 1, anchor: l.couplet, lines: l.lines })) })),
};
});
}
// tags shown on a page: the target's own, by couplet (0 = the whole page)
export async function pageTags(entity: TagTarget, id: number) {
return (await tagsOf(entity, id)).sort((a, b) => TAG_TYPES.indexOf(a.type) - TAG_TYPES.indexOf(b.type));
}

View File

@ -199,3 +199,19 @@ CREATE TABLE IF NOT EXISTS home_sections (
sort text NOT NULL DEFAULT 'manual' CHECK (sort IN ('manual', 'alpha', 'timeline')),
poets integer[] NOT NULL DEFAULT '{}'
);
-- tags (#52, api/src/tags.ts): typed labels on books/chapters (a poet's root category = the poet), works and
-- couplets (couplet > 0). The published record is divan-data/divan/<url>.tags; the import reloads it.
CREATE TABLE IF NOT EXISTS tags (
id serial PRIMARY KEY,
type text NOT NULL, -- موضوع، صنف، بحر، شخصیت، مقام، دور، ٹیگ (free)
name text NOT NULL,
UNIQUE (type, name)
);
CREATE TABLE IF NOT EXISTS entity_tags (
tag_id integer NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
entity text NOT NULL CHECK (entity IN ('category', 'work')),
entity_id integer NOT NULL, -- categories.id / poems.id
couplet integer NOT NULL DEFAULT 0, -- a work's couplet; 0 = the whole work or category
PRIMARY KEY (tag_id, entity, entity_id, couplet)
);
CREATE INDEX IF NOT EXISTS entity_tags_target ON entity_tags(entity, entity_id);

View File

@ -0,0 +1,8 @@
---
// tag chips (#52): each links to its tag page
const { tags } = Astro.props as { tags: { type: string; name: string }[] };
const href = (t: { type: string; name: string }) => `/tag/${encodeURIComponent(t.type)}/${encodeURIComponent(t.name)}`;
---
{tags.length > 0 && (
<p class="tag-chips">{tags.map((t) => <a class="tag-chip" href={href(t)}><small>{t.type}</small> {t.name}</a>)}</p>
)}

View File

@ -73,7 +73,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
</aside>
<footer class="site-footer">
<div class="wrap">
<a href="/about">دیوان کے بارے میں</a> · متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
<a href="/about">دیوان کے بارے میں</a> · <a href="/tags">ٹیگ</a> · متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
<a href="https://github.com/anas-rashid/divan-data">ڈیٹا</a> · <a href="https://github.com/anas-rashid/divan">کوڈ</a>
</div>
</footer>

View File

@ -1,5 +1,5 @@
// Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts)
export const ROLE: Record<string, string> = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' };
export const SCOPE: Record<string, string> = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' };
export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)' };
export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)', tags: 'ٹیگ' };
export const ACTION: Record<string, string> = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' };

View File

@ -2,6 +2,7 @@
// Poet (/p238), category (/p238/shaeri/c641) and poem (/p238/shaeri/c641/sh6556) pages
import Base from '../layouts/Base.astro';
import Seg from '../components/Seg.astro';
import Tags from '../components/Tags.astro';
import { api } from '../lib/api';
import { asUser, COOKIE } from '../lib/auth';
import { phraseParts, phraseRe } from '../lib/phrases';
@ -70,8 +71,13 @@ const label = (on: unknown) => (on ? 'محفوظ شدہ' : 'محفوظ کریں'
const modCan = token && poemId && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
? (await asUser(token, `/api/mod/can?poem=${poemId}`)).data : { edit: false };
// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41)
const arrange = token && page.type !== 'poem' && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
&& (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data.arrange;
const catCan = token && page.type !== 'poem' && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
? (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data : {};
const arrange = catCan.arrange, tagCan = page.type === 'poem' ? modCan.tags : catCan.tags;
// tags (#52): the page's own, and each couplet's (tags count couplets from 1, verses from 0)
const pageTags = (page.tags ?? []).filter((t: any) => !t.couplet);
const coupletTags = (c: number) => (page.tags ?? []).filter((t: any) => t.couplet === c + 1);
const tagTarget = page.type === 'poem' ? `work/${poemId}` : `category/${page.category?.id}`;
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;
---
@ -87,17 +93,19 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
{page.type === 'poet' && (
<>
<h1>{page.poet.name}</h1>
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn">ترتیب دیں</button></form>
<a class="mod-history" href={`/mod/order/${page.category.id}`}>ترتیب کا تاریخچہ</a></>}</p>
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn">ترتیب دیں</button></form>
<a class="mod-history" href={`/mod/order/${page.category.id}`}>ترتیب کا تاریخچہ</a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn">ٹیگ</button></form></>}</div>
<p class="muted">{years(page.poet)}</p>
<Tags tags={pageTags} />
{page.poet.description && <div class="intro">{page.poet.description}</div>}
</>
)}
{page.type === 'category' && (
<>
<h1>{page.category.title}</h1>
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn">ترتیب دیں</button></form>
<a class="mod-history" href={`/mod/order/${page.category.id}`}>ترتیب کا تاریخچہ</a></>}</p>
<Tags tags={pageTags} />
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn">ترتیب دیں</button></form>
<a class="mod-history" href={`/mod/order/${page.category.id}`}>ترتیب کا تاریخچہ</a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn">ٹیگ</button></form></>}</div>
</>
)}
@ -123,15 +131,16 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
</p>
)}
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button>
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button>
{modCan.edit && <> <form method="post" action={`/mod/work/${poemId}`} class="inline-form"><button class="mod-edit-btn">ترمیم کریں</button></form>
<a class="mod-history" href={`/mod/work/${poemId}`}>تاریخچہ</a></>}</p>
<a class="mod-history" href={`/mod/work/${poemId}`}>تاریخچہ</a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn">ٹیگ</button></form></>}</div>
<Tags tags={pageTags} />
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
{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 lm = lineMarks(b.c, b.lines);
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>
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>)}<Tags tags={coupletTags(b.c)} /></div>
: 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>;
})}

View File

@ -6,6 +6,8 @@ import { asUser, COOKIE } from '../../../lib/auth';
import { STATUS, EVENT, when } from '../../../lib/mod';
import { ud } from '../../../lib/urdu';
import DivanPreview from '../../../components/DivanPreview.astro';
import Tags from '../../../components/Tags.astro';
import { api } from '../../../lib/api';
const me = Astro.locals.user;
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
@ -26,21 +28,59 @@ const { revision: rev, diff, changes, events, may } = res.data;
// an arrangement (#41) lists a book/section's items, one per line: "<url part> <title>"
const order = rev.entity === 'order';
const items = order ? rev.content.split('\n').filter(Boolean).map((l: string) => ({ line: l, title: l.slice(l.indexOf(' ') + 1) })) : [];
// tagging (#52): one row per tag; a work's tags may be on one couplet (counted from 1)
const tagging = rev.entity.startsWith('tags-'), tagKind = rev.entity.slice(5);
const TAG_TYPES = ['موضوع', 'صنف', 'بحر', 'شخصیت', 'مقام', 'دور', 'ٹیگ'];
const tagRows = tagging ? rev.content.split('\n').filter(Boolean).map((l: string) => {
const m = l.match(/^(?:شعر (\d+) › )?([^:]+):\s*(.+)$/); return { couplet: Number(m?.[1] ?? 0), type: m?.[2] ?? 'ٹیگ', name: m?.[3] ?? l };
}) : [];
const couplets = tagging && tagKind === 'work' ? (await asUser(token, `/api/mod/tags/work/${rev.entity_id}`)).data.couplets ?? 0 : 0;
const knownTags = tagging ? ((await api<{ name: string }[]>('/api/tags')) ?? []).map((t) => t.name) : [];
const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]);
---
<Base title={`مسودہ: ${rev.work_title}`}>
<h1>{order && 'ترتیب: '}{rev.work_title}</h1>
<h1>{order && 'ترتیب: '}{tagging && 'ٹیگ: '}{rev.work_title}</h1>
<ModNav />
<p class="center">
<span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span>
· <bdi dir="ltr">{rev.author_email}</bdi>
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
· <a href={rev.work_url}>{order ? 'حصہ' : 'کلام'}</a> · <a href={`/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a>
· <a href={rev.work_url}>{order ? 'حصہ' : tagging ? 'صفحہ' : 'کلام'}</a> · <a href={tagging ? `/mod/tags/${tagKind}/${rev.entity_id}` : `/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a>
</p>
{error && <p class="form-error" role="alert">{error}</p>}
{done && <p class="form-done" role="status">{done}</p>}
{may.save && order ? (
{may.save && tagging ? (
<form method="post" class="mod-edit">
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: موضوعات اور شخصیات کے ٹیگ" /></label>
<p class="muted note">ہر ٹیگ کی قسم چنیں اور نام لکھیں۔{couplets > 0 && ' ٹیگ پورے کلام پر یا کسی ایک شعر پر لگ سکتا ہے۔'} پہلے سے موجود نام تجویز ہوتے ہیں۔</p>
<div id="tag-rows">
{tagRows.map((t: any, i: number) => <div class="tag-row">
{couplets > 0 && <select class="tool tag-couplet" aria-label="کہاں"><option value="0">پورا کلام</option>{Array.from({ length: couplets }, (_, i) => <option value={i + 1} selected={t.couplet === i + 1}>شعر {ud(i + 1)}</option>)}</select>}
<select class="tool tag-type" aria-label="قسم">{TAG_TYPES.map((x) => <option selected={x === t.type}>{x}</option>)}</select>
<span class="in-box-field tag-name-box"><input class="tag-name" id={`tag-name-${i}`} list="tag-names" value={t.name} maxlength="60" data-urdu aria-label="ٹیگ کا نام" placeholder="ٹیگ کا نام" />
<button type="button" class="in-box rom" data-urdu-rom={`tag-name-${i}`} aria-pressed="false" title="رومن حروف سے اردو">اب</button>
<button type="button" class="in-box" data-urdu-kb={`tag-name-${i}`} aria-pressed="false" title="اردو کی بورڈ" aria-label="اردو کی بورڈ"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" stroke-linecap="round" stroke-width="2"/></svg></button></span>
<button type="button" class="tool" data-remove title="ہٹائیں">✕</button>
</div>)}
</div>
<template id="tag-row">{[{ couplet: 0, type: 'موضوع', name: '' }].map((t, i) => <div class="tag-row">
{couplets > 0 && <select class="tool tag-couplet" aria-label="کہاں"><option value="0">پورا کلام</option>{Array.from({ length: couplets }, (_, i) => <option value={i + 1} selected={t.couplet === i + 1}>شعر {ud(i + 1)}</option>)}</select>}
<select class="tool tag-type" aria-label="قسم">{TAG_TYPES.map((x) => <option selected={x === t.type}>{x}</option>)}</select>
<span class="in-box-field tag-name-box"><input class="tag-name" id={`tag-name-${i}`} list="tag-names" value={t.name} maxlength="60" data-urdu aria-label="ٹیگ کا نام" placeholder="ٹیگ کا نام" />
<button type="button" class="in-box rom" data-urdu-rom={`tag-name-${i}`} aria-pressed="false" title="رومن حروف سے اردو">اب</button>
<button type="button" class="in-box" data-urdu-kb={`tag-name-${i}`} aria-pressed="false" title="اردو کی بورڈ" aria-label="اردو کی بورڈ"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" stroke-linecap="round" stroke-width="2"/></svg></button></span>
<button type="button" class="tool" data-remove title="ہٹائیں">✕</button>
</div>)}</template>
<datalist id="tag-names">{knownTags.map((n) => <option value={n} />)}</datalist>
<p><button type="button" class="tool" id="tag-add">+ ٹیگ</button></p>
<textarea name="content" id="tag-content" hidden>{rev.content}</textarea>
<div class="filter-actions">
<button name="act" value="save">محفوظ کریں</button>
<button name="act" value="submit">{me.role === 'admin' ? 'محفوظ کر کے شائع کریں' : me.role === 'mod-l1' ? 'محفوظ کر کے اشاعت کے لیے بھیجیں' : 'محفوظ کر کے جائزے کے لیے بھیجیں'}</button>
</div>
</form>
) : may.save && order ? (
<form method="post" class="mod-edit">
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: غزلیں مطبوعہ دیوان کی ترتیب میں" /></label>
<p class="muted note">کسی چیز کو کھینچ کر اس کی جگہ رکھیں، یا ↑ ↓ سے ایک جگہ اوپر نیچے کریں؛ «اول» سب سے اوپر، «آخر» سب سے نیچے۔</p>
@ -121,7 +161,9 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
<section class="lib-section">
<h2>پیش نظارہ</h2>
{order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol> : <DivanPreview text={rev.content} />}
{order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol>
: tagging ? (tagRows.length ? tagRows.map((t: any) => <div class="tag-preview">{t.couplet > 0 && <small class="muted">شعر {ud(t.couplet)}:</small>}<Tags tags={[t]} /></div>) : <p class="muted center">کوئی ٹیگ نہیں</p>)
: <DivanPreview text={rev.content} />}
</section>
{acts.length > 0 && (
@ -140,6 +182,28 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
</ol>
</section>
<script>
// tagging: rows -> the hidden text ("شعر 3 › موضوع: عشق"), on every change and before saving
const rowsBox = document.getElementById('tag-rows');
const tagText = document.getElementById('tag-content') as HTMLTextAreaElement | null;
if (rowsBox && tagText) {
const sync = () => {
tagText.value = [...rowsBox.querySelectorAll('.tag-row')].map((r) => {
const c = Number((r.querySelector('.tag-couplet') as HTMLSelectElement | null)?.value ?? 0);
const type = (r.querySelector('.tag-type') as HTMLSelectElement).value, name = (r.querySelector('.tag-name') as HTMLInputElement).value.trim();
return name ? `${c ? `شعر ${c} › ` : ''}${type}: ${name}` : '';
}).filter(Boolean).join('\n');
};
rowsBox.addEventListener('input', sync); rowsBox.addEventListener('change', sync);
rowsBox.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('[data-remove]')) { (e.target as HTMLElement).closest('.tag-row')!.remove(); sync(); } });
document.getElementById('tag-add')!.onclick = () => {
const row = (document.getElementById('tag-row') as HTMLTemplateElement).content.firstElementChild!.cloneNode(true) as HTMLElement;
// a fresh id for the new name box, so its keyboard buttons point at it
const nid = `tag-name-new-${Date.now()}`;
row.querySelector('.tag-name')!.id = nid; row.querySelectorAll('[data-urdu-rom], [data-urdu-kb]').forEach((b) => b.setAttribute(b.hasAttribute('data-urdu-rom') ? 'data-urdu-rom' : 'data-urdu-kb', nid));
rowsBox.append(row); (row.querySelector('.tag-name') as HTMLInputElement).focus();
};
tagText.form!.addEventListener('submit', sync);
}
// arranging: drag an item, or move it with the buttons; the hidden text follows the list
const list = document.getElementById('order-list');
const text = document.getElementById('order-content') as HTMLTextAreaElement | null;

View File

@ -0,0 +1,47 @@
---
// A page's tags (#52): its current tags, published versions and drafts; "ٹیگ لگائیں" starts a draft
import Base from '../../../../layouts/Base.astro';
import ModNav from '../../../../components/ModNav.astro';
import Tags from '../../../../components/Tags.astro';
import { asUser, COOKIE } from '../../../../lib/auth';
import { STATUS, when } from '../../../../lib/mod';
import { ud } from '../../../../lib/urdu';
const me = Astro.locals.user;
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
const token = Astro.cookies.get(COOKIE)!.value, { kind, id } = Astro.params;
let error = '';
if (Astro.request.method === 'POST') {
const r = await asUser(token, `/api/mod/tags/${kind}/${id}/draft`, {});
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`);
error = r.data.error ?? 'کچھ غلط ہو گیا';
}
const res = await asUser(token, `/api/mod/tags/${kind}/${id}`);
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
const { target, version, content, history, may } = res.data;
const current = content.split('\n').filter(Boolean).map((l: string) => { const [, type, name] = l.replace(/^شعر \d+ › /, '').match(/^([^:]+):\s*(.+)$/) ?? []; return { type, name }; });
---
<Base title={`ٹیگ: ${target.title}`}>
<h1>ٹیگ: {target.title}</h1>
<ModNav />
<p class="center"><a href={target.url}>صفحہ</a> · {version ? `موجودہ ٹیگ: ورژن ${ud(version)}` : 'ابھی کوئی شائع شدہ ٹیگ نہیں'}</p>
<Tags tags={current} />
{error && <p class="form-error" role="alert">{error}</p>}
{may.tags && <form method="post" class="center"><button class="save-like">ٹیگ لگائیں</button></form>}
<table class="admin-table">
<thead><tr><th>ورژن</th><th>حالت</th><th>خلاصہ</th><th>لکھنے والے</th><th>جائزہ</th><th>اشاعت</th><th>تاریخ</th></tr></thead>
<tbody>
{history.map((h: any) => (
<tr>
<td><a href={`/mod/rev/${h.id}`}>{h.version ? ud(h.version) : '—'}</a></td>
<td><span class={`status s-${h.status}`}>{STATUS[h.status]}</span></td>
<td>{h.summary}</td>
<td><bdi dir="ltr">{h.author_email}</bdi></td>
<td><bdi dir="ltr">{h.reviewer_email ?? ''}</bdi></td>
<td><bdi dir="ltr">{h.publisher_email ?? ''}</bdi></td>
<td><bdi dir="ltr">{when(h.published_at ?? h.created_at)}</bdi></td>
</tr>
))}
</tbody>
</table>
</Base>

View File

@ -2,6 +2,7 @@
// 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 Tags from '../components/Tags.astro';
import { api } from '../lib/api';
import { ud, years, highlighter, parts, excerpt } from '../lib/urdu';
@ -30,7 +31,7 @@ const numbers = (() => {
const from = res ? (pageNo - 1) * res.pageSize + 1 : 0, to = res ? Math.min(res.total, pageNo * res.pageSize) : 0;
---
<Base title={q ? `تلاش: ${q}` : 'تلاش'} q={q}>
{!q && <p class="muted">اوپر کے خانے میں شاعر کا نام، شعر، لفظ یا عبارت لکھیں۔ پوری عبارت کے لیے اسے "…" میں لکھیں۔</p>}
{!q && <p class="muted">اوپر کے خانے میں شاعر کا نام، شعر، لفظ یا عبارت لکھیں۔ پوری عبارت کے لیے اسے "…" میں لکھیں۔ کسی ٹیگ والا کلام: <bdi>ٹیگ:عشق</bdi> (نام میں فاصلہ ہو تو <bdi>ٹیگ:"حب الوطنی"</bdi>)۔ <a href="/tags">تمام ٹیگ</a></p>}
{res && (
<>
<h1>تلاش: {q}</h1>
@ -60,6 +61,13 @@ const from = res ? (pageNo - 1) * res.pageSize + 1 : 0, to = res ? Math.min(res.
</section>
)}
{res.tags?.length > 0 && (
<section class="search-group">
<h2>ٹیگ <small class="muted">{ud(res.tags.length)}</small></h2>
<Tags tags={res.tags} />
</section>
)}
<section class="search-group">
<h2>کلام <small class="muted">{ud(res.total)}</small></h2>
{chips.length > 0 && (

View File

@ -0,0 +1,37 @@
---
// A tag's page (#52): the poets, books/chapters, works and couplets carrying it
import Base from '../../../layouts/Base.astro';
import { api } from '../../../lib/api';
import { ud } from '../../../lib/urdu';
const { type = '', name = '' } = Astro.params;
const res = await api(`/api/tag?type=${encodeURIComponent(type)}&name=${encodeURIComponent(name)}`);
if (!res) return new Response(null, { status: 404, statusText: 'Not found' });
const { tag, categories, works } = res;
const count = categories.length + works.length;
---
<Base title={`${tag.type}: ${tag.name}`}>
<h1><small class="muted">{tag.type}:</small> {tag.name}</h1>
<p class="center muted">{ud(count)} جگہ · <a href={`/search?q=${encodeURIComponent(`tag:"${tag.name}"`)}`}>تلاش میں دیکھیں</a> · <a href="/tags">تمام ٹیگ</a></p>
{categories.length > 0 && (
<section class="search-group">
<h2>شعرا، کتابیں اور ابواب</h2>
<ul class="book-results">
{categories.map((c: any) => <li><a href={c.url}>{c.is_poet ? c.poet : c.title}</a>{!c.is_poet && <> · <a class="muted" href={c.poet_url}>{c.poet}</a></>}</li>)}
</ul>
</section>
)}
{works.length > 0 && (
<section class="search-group">
<h2>کلام</h2>
{works.map((w: any) => (
<div class="result">
<a href={w.url}>{w.title}</a> · <a class="muted" href={w.poet_url}>{w.poet}</a>
{w.couplets.map((c: any) => (
<a class="snippet" href={`${w.url}#c${c.anchor}`}>{c.lines.map((l: string) => <p>{l}</p>)}</a>
))}
</div>
))}
</section>
)}
</Base>

20
web/src/pages/tags.astro Normal file
View File

@ -0,0 +1,20 @@
---
// Every tag (#52), by type, most used first
import Base from '../layouts/Base.astro';
import { api } from '../lib/api';
import { ud } from '../lib/urdu';
const TYPES = ['موضوع', 'صنف', 'بحر', 'شخصیت', 'مقام', 'دور', 'ٹیگ'];
const tags = ((await api<{ type: string; name: string; n: number }[]>('/api/tags')) ?? []);
const groups = TYPES.map((t) => [t, tags.filter((x) => x.type === t)] as const).filter(([, xs]) => xs.length);
---
<Base title="ٹیگ">
<h1>ٹیگ</h1>
{groups.length === 0 && <p class="muted center">ابھی کوئی ٹیگ نہیں۔</p>}
{groups.map(([type, xs]) => (
<section class="search-group">
<h2>{type} <small class="muted">{ud(xs.length)}</small></h2>
<p class="tag-chips">{xs.map((t) => <a class="tag-chip" href={`/tag/${encodeURIComponent(t.type)}/${encodeURIComponent(t.name)}`}>{t.name} <small>{ud(t.n)}</small></a>)}</p>
</section>
))}
</Base>

View File

@ -404,3 +404,14 @@ h1 + .muted { text-align: center; margin-top: 0; }
.site-poets .tool { min-width: 1.6em; padding: 0 4px; margin-inline-start: 4px; font-size: .85rem; }
.site-section .author-find { position: relative; max-width: 26em; }
.site-section .author-find input { width: 100%; font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); }
/* tags (#52) */
.tag-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 6px 0; }
.tag-chip { font-size: .85rem; padding: 0 10px; border: 1px solid var(--gold-light); border-radius: 999px; background: var(--inner); color: var(--ink); text-decoration: none; }
.tag-chip small { color: var(--muted); }
.tag-chip:hover { border-color: var(--brand); color: var(--brand); }
.couplet .tag-chips { grid-column: 1 / -1; justify-content: center; margin: 2px 0 0; }
.couplet .tag-chip { font-size: .75rem; }
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0; }
.tag-row .tag-name-box { flex: 1 1 12em; }
.tag-row .tag-name { width: 100%; font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); }
.tag-preview { display: flex; align-items: center; justify-content: center; gap: 6px; }