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 && <>

- ترتیب کا تاریخچہ}

+
{arrange && <>
+ ترتیب کا تاریخچہ}{tagCan && <>
}

{years(page.poet)}

+ {page.poet.description &&
{page.poet.description}
} )} {page.type === 'category' && ( <>

{page.category.title}

-

{arrange && <>

- ترتیب کا تاریخچہ}

+ +
{arrange && <>
+ ترتیب کا تاریخچہ}{tagCan && <>
}
)} @@ -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 =