From 542c4672c9bcd17cc73d2d6e579693e756e12310 Mon Sep 17 00:00:00 2001
From: Anas Rashid
Date: Fri, 9 Oct 2026 14:42:19 +0200
Subject: [PATCH] Tags (#52): typed tags on poets, books/chapters, works and
couplets; tag pages; tag: in search
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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/.tags, commits it and updates the site; the import reloads them.
- Pages show tag chips (couplet tags under the couplet); /tag// 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
---
api/package.json | 2 +-
api/src/import.ts | 3 +
api/src/library.test.ts | 4 +-
api/src/moderation.test.ts | 2 +-
api/src/moderation.ts | 80 +++++++++---
api/src/order.test.ts | 4 +-
api/src/permissions.ts | 2 +-
api/src/server.ts | 46 +++++--
api/src/tags.test.ts | 101 +++++++++++++++
api/src/tags.ts | 154 +++++++++++++++++++++++
db/schema.sql | 16 +++
web/src/components/Tags.astro | 8 ++
web/src/layouts/Base.astro | 2 +-
web/src/lib/roles.ts | 2 +-
web/src/pages/[...path].astro | 27 ++--
web/src/pages/mod/rev/[id].astro | 72 ++++++++++-
web/src/pages/mod/tags/[kind]/[id].astro | 47 +++++++
web/src/pages/search.astro | 10 +-
web/src/pages/tag/[type]/[name].astro | 37 ++++++
web/src/pages/tags.astro | 20 +++
web/src/styles/global.css | 11 ++
21 files changed, 598 insertions(+), 52 deletions(-)
create mode 100644 api/src/tags.test.ts
create mode 100644 api/src/tags.ts
create mode 100644 web/src/components/Tags.astro
create mode 100644 web/src/pages/mod/tags/[kind]/[id].astro
create mode 100644 web/src/pages/tag/[type]/[name].astro
create mode 100644 web/src/pages/tags.astro
diff --git a/api/package.json b/api/package.json
index c5de0b38..1be1d0da 100644
--- a/api/package.json
+++ b/api/package.json
@@ -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"
},
diff --git a/api/src/import.ts b/api/src/import.ts
index 3b5c8e72..031e100b 100644
--- a/api/src/import.ts
+++ b/api/src/import.ts
@@ -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();
diff --git a/api/src/library.test.ts b/api/src/library.test.ts
index 9eccc0c3..80a6acc4 100644
--- a/api/src/library.test.ts
+++ b/api/src/library.test.ts
@@ -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);
diff --git a/api/src/moderation.test.ts b/api/src/moderation.test.ts
index a1af5b62..56157dea 100644
--- a/api/src/moderation.test.ts
+++ b/api/src/moderation.test.ts
@@ -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;
diff --git a/api/src/moderation.ts b/api/src/moderation.ts
index aca23bec..4badb431 100644
--- a/api/src/moderation.ts
+++ b/api/src/moderation.ts
@@ -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) })) };
});
diff --git a/api/src/order.test.ts b/api/src/order.test.ts
index bd68a2a7..cbbcdd3e 100644
--- a/api/src/order.test.ts
+++ b/api/src/order.test.ts
@@ -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();
diff --git a/api/src/permissions.ts b/api/src/permissions.ts
index fbb3759f..bce55d76 100644
--- a/api/src/permissions.ts
+++ b/api/src/permissions.ts
@@ -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];
diff --git a/api/src/server.ts b/api/src/server.ts
index 39f8922e..470f8045 100644
--- a/api/src/server.ts
+++ b/api/src/server.ts
@@ -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' });
diff --git a/api/src/tags.test.ts b/api/src/tags.test.ts
new file mode 100644
index 00000000..987f1f4c
--- /dev/null
+++ b/api/src/tags.test.ts
@@ -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();
+ }
+});
diff --git a/api/src/tags.ts b/api/src/tags.ts
new file mode 100644
index 00000000..73117a66
--- /dev/null
+++ b/api/src/tags.ts
@@ -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/.tags.
+// The text is one tag per line, ": ", or for one couplet of a work "شعر › : " (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/.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));
+}
diff --git a/db/schema.sql b/db/schema.sql
index 22411f0d..ecf65727 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -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/.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);
diff --git a/web/src/components/Tags.astro b/web/src/components/Tags.astro
new file mode 100644
index 00000000..9dc28ad2
--- /dev/null
+++ b/web/src/components/Tags.astro
@@ -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 && (
+ {tags.map((t) => {t.type} {t.name})}
+)}
diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro
index ca5f62ef..ab9d99e6 100644
--- a/web/src/layouts/Base.astro
+++ b/web/src/layouts/Base.astro
@@ -73,7 +73,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
diff --git a/web/src/lib/roles.ts b/web/src/lib/roles.ts
index f98f1e76..80652f91 100644
--- a/web/src/lib/roles.ts
+++ b/web/src/lib/roles.ts
@@ -1,5 +1,5 @@
// Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts)
export const ROLE: Record = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' };
export const SCOPE: Record = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' };
-export const CONTENT: Record = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)' };
+export const CONTENT: Record = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)', tags: 'ٹیگ' };
export const ACTION: Record = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' };
diff --git a/web/src/pages/[...path].astro b/web/src/pages/[...path].astro
index 38370b01..58849caf 100644
--- a/web/src/pages/[...path].astro
+++ b/web/src/pages/[...path].astro
@@ -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' && (
<>
{page.poet.name}
- {arrange && <>
- ترتیب کا تاریخچہ>}
+
{years(page.poet)}
+
{page.poet.description && {page.poet.description}
}
>
)}
{page.type === 'category' && (
<>
{page.category.title}
- {arrange && <>
- ترتیب کا تاریخچہ>}
+
+
>
)}
@@ -123,15 +131,16 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
«{hlq}» نمایاں · تلاش کے نتائج · نشان ہٹائیں
)}
-
+
{modCan.edit && <>
-
تاریخچہ>}
+
تاریخچہ>}{tagCan && <>
>}
+
{page.poem.radif != null && {page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}
}
{blocks.map((b) => {
const bm = ;
const lm = lineMarks(b.c, b.lines);
- return b.kind === 'couplet' ? {bm}{b.lines.map((l, i) =>
)}
+ return b.kind === 'couplet' ? {bm}{b.lines.map((l, i) =>
)}
: b.kind === 'para' ? {bm}
: {bm}
;
})}
diff --git a/web/src/pages/mod/rev/[id].astro b/web/src/pages/mod/rev/[id].astro
index 797cf1c9..7915b6f7 100644
--- a/web/src/pages/mod/rev/[id].astro
+++ b/web/src/pages/mod/rev/[id].astro
@@ -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: " "
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]);
---
- {order && 'ترتیب: '}{rev.work_title}
+ {order && 'ترتیب: '}{tagging && 'ٹیگ: '}{rev.work_title}
{STATUS[rev.status]}
· {rev.author_email}
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
- · {order ? 'حصہ' : 'کلام'} · تاریخچہ
+ · {order ? 'حصہ' : tagging ? 'صفحہ' : 'کلام'} · تاریخچہ
{error && {error}
}
{done && {done}
}
- {may.save && order ? (
+ {may.save && tagging ? (
+
+ ) : may.save && order ? (