Tags (#52): typed tags on poets, books/chapters, works and couplets; tag pages; tag: in search
- Types موضوع، صنف، بحر، شخصیت، مقام، دور and free ٹیگ; a work's tag may be on one couplet (شعر n, from 1). - Tagging is a revision (tags-category / tags-work) through the L2 -> L1 -> admin pipeline with the new 'tags' permission; publishing writes divan-data/divan/<url>.tags, commits it and updates the site; the import reloads them. - Pages show tag chips (couplet tags under the couplet); /tag/<type>/<name> lists what carries a tag; /tags lists all. - Search: tag:عشق / ٹیگ:"حب الوطنی" filters works (tagged work, couplet or book), matching tags shown as a group. - Tag editor rows with suggestions, Urdu keyboard and Roman typing. - Tests run one file at a time (they share Ghalib's ghazals); the library test picks a ghazal with three couplets. - Mod buttons sit in a div (a form closes a p, which broke the row). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1494c28776
commit
542c4672c9
@ -9,7 +9,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.ts",
|
"start": "node src/server.ts",
|
||||||
"import": "node src/import.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",
|
"dict-sync": "node src/dict-sync.ts",
|
||||||
"make-admin": "node src/admin-cli.ts"
|
"make-admin": "node src/admin-cli.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { pool } from './db.ts';
|
import { pool } from './db.ts';
|
||||||
import { normalise } from './urdu.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 SOURCE = process.argv[2] ?? new URL('../../../divan-data', import.meta.url).pathname;
|
||||||
const isHttp = /^https?:/.test(SOURCE);
|
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)
|
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'));
|
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`);
|
console.log(`\nimported ${manifest.Poets.length} poets, ${poems} poems`);
|
||||||
await pool.end();
|
await pool.end();
|
||||||
|
|||||||
@ -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 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 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.equal((await call('GET', '/api/library')).statusCode, 401, 'signed out');
|
||||||
assert.deepEqual((await toggle({ kind: 'poet', poetId: 266 })).json().saved, true);
|
assert.deepEqual((await toggle({ kind: 'poet', poetId: 266 })).json().saved, true);
|
||||||
|
|||||||
@ -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, 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.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
|
// 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;
|
const blank = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json().id;
|
||||||
|
|||||||
@ -10,7 +10,8 @@
|
|||||||
// and updates the site.
|
// and updates the site.
|
||||||
// If another version was published after the draft started, publishing is refused until the draft is redone.
|
// 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
|
// 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/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/queue my drafts, drafts to review, drafts to publish
|
||||||
// GET /api/mod/work/:id a work's current text and its history
|
// 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/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
|
// 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
|
// 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/save {content, summary}
|
||||||
// POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment}
|
// POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment}
|
||||||
// GET /api/mod/log?page= who did what, newest first
|
// 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 { diffLines, changed } from './diff.ts';
|
||||||
import { commit, identity } from './git.ts';
|
import { commit, identity } from './git.ts';
|
||||||
import { currentOrder, checkOrder, applyOrder, writeOrder, orderSlugs } from './order.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';
|
import { publicName } from './auth.ts';
|
||||||
|
|
||||||
const dataDir = () => process.env.DIVAN_DATA_DIR ?? new URL('../../../divan-data', import.meta.url).pathname;
|
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 mayEdit = (u: any, poemId: number) => can(u, 'edit', 'works', { poemId });
|
||||||
const mayArrange = async (u: any, categoryId: number) =>
|
const mayArrange = async (u: any, categoryId: number) =>
|
||||||
(await can(u, 'arrange', 'works', { categoryId })) || (await can(u, 'arrange', 'books', { categoryId }));
|
(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 mayTag = (u: any, kind: TagTarget, id: number) => can(u, 'edit', 'tags', kind === 'work' ? { poemId: id } : { categoryId: id });
|
||||||
const mayChange = (u: any, r: { entity: string; entity_id: number }) => (r.entity === 'order' ? mayArrange : mayEdit)(u, r.entity_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 mayReview = async (u: any, r: { entity: string; entity_id: number }) => ['mod-l1', 'admin'].includes(u?.role) && (await mayChange(u, r));
|
||||||
const OPEN = ['draft', 'returned'];
|
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)
|
// 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)
|
// 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
|
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 = 'order' AND c.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`;
|
const TARGET_COLS = `coalesce(p.title, c.title) AS work_title, coalesce(p.url, c.url) AS work_url`;
|
||||||
|
|
||||||
async function revision(id: number) {
|
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
|
// 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) {
|
async function publish(r: any, u: any, comment?: string) {
|
||||||
const order = r.entity === 'order';
|
const order = r.entity === 'order', tags = r.entity.startsWith('tags-');
|
||||||
const cur = order ? await currentOrder(r.entity_id) : await current(r.entity_id);
|
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) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 });
|
||||||
if (cur.version !== r.base_version)
|
if (cur.version !== r.base_version)
|
||||||
throw Object.assign(new Error('اس دوران اس کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 });
|
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
|
// 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 });
|
if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 });
|
||||||
const doc = order ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
|
const doc = order || tags ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
|
||||||
const title = 'cat' in cur ? `ترتیب: ${cur.cat.title}` : doc!.meta['عنوان'] || cur.poem.title;
|
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();
|
const version = cur.version + 1, at = new Date().toISOString();
|
||||||
// who did it, by public name (divan-data is public: never email addresses)
|
// 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;
|
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) };
|
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
|
// 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)]
|
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) });
|
: await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) });
|
||||||
const trailers = [`Divan-Revision: ${r.id}`, `Divan-Version: ${version}`,
|
const trailers = [`Divan-Revision: ${r.id}`, `Divan-Version: ${version}`,
|
||||||
...(reviewer ? [`Reviewed-by: ${identityOf(reviewer)}`] : []), `Approved-by: ${identityOf(u)}`];
|
...(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()
|
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]);
|
WHERE id = $1`, [r.id, version, u.email, at, sha, credits]);
|
||||||
if (order) await applyOrder(client, r.entity_id, r.content);
|
if (order) await applyOrder(client, r.entity_id, r.content);
|
||||||
|
else if (tags) await applyTags(client, tagKind(r), r.entity_id, r.content);
|
||||||
else {
|
else {
|
||||||
await client.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1',
|
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(' '))]);
|
[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)
|
// ?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) => {
|
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;
|
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 (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false, tags: false };
|
||||||
if (categoryId) return { arrange: await mayArrange(u, categoryId) };
|
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' };
|
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) => {
|
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,
|
`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
|
coalesce(p.title, c.title) AS title, coalesce(p.url, c.url) AS url
|
||||||
FROM revisions r ${TARGET}
|
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]);
|
ORDER BY r.updated_at DESC LIMIT 300`, [u.id]);
|
||||||
const strip = ({ author_id, ...r }: any) => ({ ...r, id: Number(r.id) });
|
const strip = ({ author_id, ...r }: any) => ({ ...r, id: Number(r.id) });
|
||||||
const review = [];
|
const review = [];
|
||||||
@ -226,6 +237,37 @@ export function moderationRoutes(app: FastifyInstance) {
|
|||||||
return { id: Number(rows[0].id) };
|
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) => {
|
app.get<{ Params: { id: string } }>('/api/mod/revisions/:id', async (req, reply) => {
|
||||||
const u = await moderator(req, reply); if (!u) return;
|
const u = await moderator(req, reply); if (!u) return;
|
||||||
const r = await revision(Number(req.params.id) || 0);
|
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 (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
|
||||||
if (!(await actions(u, r)).save) return reply.code(403).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');
|
const content = String(req.body?.content ?? '').replace(/\r\n?/g, '\n');
|
||||||
if (r.entity === 'order') {
|
if (r.entity === 'order' || r.entity.startsWith('tags-')) {
|
||||||
const bad = await checkOrder(r.entity_id, content);
|
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 });
|
if (bad) return reply.code(400).send({ error: bad });
|
||||||
} else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
|
} else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
|
||||||
if (content.length > 500_000) 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):
|
// 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
|
// 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);
|
const same = norm(content) === norm(r.base_content);
|
||||||
if (same && r.status === 'draft') {
|
if (same && r.status === 'draft') {
|
||||||
await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]);
|
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(
|
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
|
`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}
|
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}`);
|
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) })) };
|
return { page, entries: rows.map((r) => ({ ...r, revision: Number(r.revision) })) };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,10 +36,10 @@ test('arranging: its own permission; L2 reorders, L1 approves, admin publishes t
|
|||||||
const grant = (m: any, actions: string[]) =>
|
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]);
|
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']);
|
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);
|
assert.equal((await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).statusCode, 403);
|
||||||
await grant(l2, ['arrange']); await grant(l1, ['arrange']);
|
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 { 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();
|
const cur = (await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json();
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { audit, requireAdmin } from './admin.ts';
|
|||||||
|
|
||||||
export const MODERATORS = ['mod-l2', 'mod-l1'] as const;
|
export const MODERATORS = ['mod-l2', 'mod-l1'] as const;
|
||||||
export const SCOPES = ['all', 'poet', 'category', 'poem'] 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 const ACTIONS = ['create', 'edit', 'delete', 'arrange'] as const;
|
||||||
export type Action = (typeof ACTIONS)[number];
|
export type Action = (typeof ACTIONS)[number];
|
||||||
export type Content = (typeof CONTENT)[number];
|
export type Content = (typeof CONTENT)[number];
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import { permissionRoutes } from './permissions.ts';
|
|||||||
import { libraryRoutes } from './library.ts';
|
import { libraryRoutes } from './library.ts';
|
||||||
import { moderationRoutes } from './moderation.ts';
|
import { moderationRoutes } from './moderation.ts';
|
||||||
import { siteRoutes } from './site.ts';
|
import { siteRoutes } from './site.ts';
|
||||||
|
import { tagRoutes, pageTags } from './tags.ts';
|
||||||
|
|
||||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
||||||
const PAGE_SIZE = 20;
|
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,
|
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 };
|
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;
|
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];
|
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 {
|
return {
|
||||||
type: cat.parent_id === null ? 'poet' : 'category',
|
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' });
|
return reply.code(404).send({ error: 'not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/search', async (req) => {
|
app.get<{ Querystring: { q?: string; poet?: string; page?: string } }>('/api/search', async (req) => {
|
||||||
const patterns = likePatterns(req.query.q ?? '');
|
// tag:عشق or ٹیگ:عشق (quotes for names with spaces) keeps works carrying the tag: on the work, one of its couplets,
|
||||||
if (!patterns.length) return { total: 0, page: 1, pageSize: PAGE_SIZE, results: [], poets: [], books: [] };
|
// 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 page = Math.max(1, Number(req.query.page) || 1);
|
||||||
const params: unknown[] = [...patterns];
|
const params: unknown[] = [...patterns];
|
||||||
const where = patterns.map((_, i) => `p.search_text ILIKE $${i + 1}`);
|
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);
|
const poetIds = [...new Set(String(req.query.poet ?? '').split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0))].slice(0, 50);
|
||||||
if (poetIds.length) {
|
if (poetIds.length) {
|
||||||
params.push(poetIds);
|
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 ')}`;
|
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
|
// 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([
|
const [count, rows] = await Promise.all([
|
||||||
pool.query(`SELECT count(*)::int AS n ${sql}`, params),
|
pool.query(`SELECT count(*)::int AS n ${sql}`, params),
|
||||||
pool.query(
|
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
|
// 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(
|
const verses = await pool.query(
|
||||||
'SELECT poem_id, position, couplet, text FROM verses WHERE poem_id = ANY($1) ORDER BY poem_id, vorder',
|
'SELECT poem_id, position, couplet, text FROM verses WHERE poem_id = ANY($1) ORDER BY poem_id, vorder',
|
||||||
[rows.rows.map((r) => r.id)],
|
[rows.rows.map((r) => r.id)],
|
||||||
);
|
);
|
||||||
const byPoem = Map.groupBy(verses.rows, (v) => v.poem_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 results = rows.rows.map(({ id, ...r }) => {
|
||||||
const vs = byPoem.get(id) ?? [];
|
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) {
|
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);
|
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];
|
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' };
|
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
|
// 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
|
// which poets/writers the matching content comes from (whatever the poet filter), for narrowing down
|
||||||
const [authors, selected] = await Promise.all([
|
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
|
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: [] },
|
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
|
// one word in Arabic script (Urdu, Persian, Arabic), as selected by a reader
|
||||||
@ -167,6 +190,7 @@ permissionRoutes(app);
|
|||||||
libraryRoutes(app);
|
libraryRoutes(app);
|
||||||
moderationRoutes(app);
|
moderationRoutes(app);
|
||||||
siteRoutes(app);
|
siteRoutes(app);
|
||||||
|
tagRoutes(app);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 4100);
|
const port = Number(process.env.PORT ?? 4100);
|
||||||
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
||||||
|
|||||||
101
api/src/tags.test.ts
Normal file
101
api/src/tags.test.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { test, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import { authRoutes } from './auth.ts';
|
||||||
|
import { moderationRoutes } from './moderation.ts';
|
||||||
|
import { tagRoutes, parseTags, tagsText, loadTags } from './tags.ts';
|
||||||
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
|
after(() => pool.end());
|
||||||
|
|
||||||
|
test('tag text: types, couplets, Urdu digits, repeats, order', () => {
|
||||||
|
assert.deepEqual(parseTags('موضوع: عشق\nشعر ۳ › شخصیت: مجنوں\n\nموضوع: عشق'),
|
||||||
|
[{ couplet: 0, type: 'موضوع', name: 'عشق' }, { couplet: 3, type: 'شخصیت', name: 'مجنوں' }]);
|
||||||
|
assert.match(parseTags('رنگ: سرخ') as string, /قسم/);
|
||||||
|
assert.match(parseTags('بس ایک لفظ') as string, /سمجھ/);
|
||||||
|
assert.equal(tagsText(parseTags('شعر 2 › مقام: دلی\nٹیگ: مشہور\nموضوع: تصوف') as any), 'موضوع: تصوف\nٹیگ: مشہور\nشعر 2 › مقام: دلی');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tagging: tags permission, pipeline, published to divan-data and the site, tag pages, reloaded by the import', async () => {
|
||||||
|
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
|
||||||
|
process.env.DIVAN_DATA_DIR = data;
|
||||||
|
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
|
||||||
|
git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
|
||||||
|
const app = Fastify();
|
||||||
|
authRoutes(app); moderationRoutes(app); tagRoutes(app);
|
||||||
|
const run = Date.now();
|
||||||
|
const call = (method: string, url: string, body?: object, token?: string) =>
|
||||||
|
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `tag-${run}` } });
|
||||||
|
const person = async (name: string, role: string) => {
|
||||||
|
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
|
||||||
|
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
|
||||||
|
return { ...s, id: s.user.id };
|
||||||
|
};
|
||||||
|
const admin = await person('admin', 'admin'), l1 = await person('l1', 'mod-l1'), l2 = await person('l2', 'mod-l2');
|
||||||
|
const cat = (await pool.query(`SELECT id, url FROM categories WHERE url = '/p266/ghazal'`)).rows[0];
|
||||||
|
const work = (await pool.query(`SELECT p.id, p.url FROM poems p WHERE p.category_id = $1
|
||||||
|
AND EXISTS (SELECT 1 FROM verses v WHERE v.poem_id = p.id AND v.couplet >= 2) ORDER BY p.id LIMIT 1`, [cat.id])).rows[0];
|
||||||
|
const saved = (await pool.query('SELECT t.type, t.name, e.entity, e.entity_id, e.couplet FROM entity_tags e JOIN tags t ON t.id = e.tag_id')).rows;
|
||||||
|
try {
|
||||||
|
const grant = (m: any, content: string) =>
|
||||||
|
pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'category', $2, $3, '{edit}')`, [m.id, cat.id, [content]]);
|
||||||
|
await grant(l2, 'works');
|
||||||
|
assert.equal((await call('GET', `/api/mod/can?poem=${work.id}`, undefined, l2.token)).json().tags, false, 'editing text is not tagging');
|
||||||
|
assert.equal((await call('POST', `/api/mod/tags/work/${work.id}/draft`, undefined, l2.token)).statusCode, 403);
|
||||||
|
await grant(l2, 'tags'); await grant(l1, 'tags');
|
||||||
|
|
||||||
|
// L2 tags the work (whole and its third couplet) and the ghazals' section
|
||||||
|
const w = (await call('POST', `/api/mod/tags/work/${work.id}/draft`, undefined, l2.token)).json().id;
|
||||||
|
assert.equal((await call('POST', `/api/mod/revisions/${w}/save`, { content: 'رنگ: سرخ' }, l2.token)).statusCode, 400, 'unknown type');
|
||||||
|
assert.equal((await call('POST', `/api/mod/revisions/${w}/save`, { content: 'شعر 999 › موضوع: عشق' }, l2.token)).statusCode, 400, 'no such couplet');
|
||||||
|
const tagText = `موضوع: عشق-${run}\nشعر 2 › شخصیت: مجنوں-${run}`;
|
||||||
|
assert.equal((await call('POST', `/api/mod/revisions/${w}/save`, { content: tagText, summary: 'ٹیگ' }, l2.token)).statusCode, 200);
|
||||||
|
const c = (await call('POST', `/api/mod/tags/category/${cat.id}/draft`, undefined, l2.token)).json().id;
|
||||||
|
assert.equal((await call('POST', `/api/mod/revisions/${c}/save`, { content: 'شعر 1 › موضوع: عشق' }, l2.token)).statusCode, 400, 'no couplets on a section');
|
||||||
|
assert.equal((await call('POST', `/api/mod/revisions/${c}/save`, { content: `صنف: غزل-${run}` }, l2.token)).statusCode, 200);
|
||||||
|
for (const id of [w, c]) {
|
||||||
|
assert.equal((await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token)).json().status, 'submitted');
|
||||||
|
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, {}, l1.token)).json().status, 'approved');
|
||||||
|
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// divan-data holds the tags next to the work; the tag pages list what carries them
|
||||||
|
assert.equal(await readFile(join(data, 'divan', work.url.slice(1) + '.tags'), 'utf8'), tagsText(parseTags(tagText) as any) + '\n');
|
||||||
|
assert.match(git('log', '-1', '--format=%s'), /^ٹیگ: /);
|
||||||
|
const love = (await call('GET', `/api/tag?type=${encodeURIComponent('موضوع')}&name=${encodeURIComponent(`عشق-${run}`)}`)).json();
|
||||||
|
assert.deepEqual(love.works.map((x: any) => [x.id, x.whole]), [[work.id, true]]);
|
||||||
|
const majnun = (await call('GET', `/api/tag?type=${encodeURIComponent('شخصیت')}&name=${encodeURIComponent(`مجنوں-${run}`)}`)).json();
|
||||||
|
assert.equal(majnun.works[0].couplets[0].couplet, 2);
|
||||||
|
assert.ok(majnun.works[0].couplets[0].lines.length >= 1, 'the couplet\'s lines');
|
||||||
|
const genre = (await call('GET', `/api/tag?type=${encodeURIComponent('صنف')}&name=${encodeURIComponent(`غزل-${run}`)}`)).json();
|
||||||
|
assert.deepEqual(genre.categories.map((x: any) => x.id), [cat.id]);
|
||||||
|
assert.ok((await call('GET', '/api/tags')).json().some((t: any) => t.name === `عشق-${run}` && t.n === 1));
|
||||||
|
|
||||||
|
// the import rebuilds the site's tags from divan-data
|
||||||
|
await pool.query('DELETE FROM entity_tags');
|
||||||
|
assert.equal(await loadTags(data), 2);
|
||||||
|
assert.equal((await call('GET', `/api/tag?type=${encodeURIComponent('صنف')}&name=${encodeURIComponent(`غزل-${run}`)}`)).json().categories.length, 1);
|
||||||
|
|
||||||
|
// removing every tag deletes the file
|
||||||
|
const again = (await call('POST', `/api/mod/tags/work/${work.id}/draft`, undefined, admin.token)).json().id;
|
||||||
|
await call('POST', `/api/mod/revisions/${again}/save`, { content: '' }, admin.token);
|
||||||
|
assert.deepEqual((await call('POST', `/api/mod/revisions/${again}/submit`, {}, admin.token)).json(), { status: 'published', version: 2 });
|
||||||
|
await assert.rejects(readFile(join(data, 'divan', work.url.slice(1) + '.tags'), 'utf8'));
|
||||||
|
} finally {
|
||||||
|
// the site's tags as they were
|
||||||
|
await pool.query('DELETE FROM entity_tags');
|
||||||
|
for (const t of saved) {
|
||||||
|
const id = (await pool.query('INSERT INTO tags (type, name) VALUES ($1, $2) ON CONFLICT (type, name) DO UPDATE SET name = EXCLUDED.name RETURNING id', [t.type, t.name])).rows[0].id;
|
||||||
|
await pool.query('INSERT INTO entity_tags (tag_id, entity, entity_id, couplet) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', [id, t.entity, t.entity_id, t.couplet]);
|
||||||
|
}
|
||||||
|
await pool.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
|
||||||
|
await pool.query(`DELETE FROM revisions WHERE author_email LIKE $1`, [`%-${run}@divan.test`]);
|
||||||
|
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
|
||||||
|
await rm(data, { recursive: true, force: true });
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
154
api/src/tags.ts
Normal file
154
api/src/tags.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
// Tags (#52): typed labels on books/chapters (a poet's page is their root category), works and a work's couplets.
|
||||||
|
// Tagging is a revision like any other change (entity 'tags-category' or 'tags-work', entity_id = the target), so it
|
||||||
|
// goes through the pipeline with the 'tags' permission and is published to divan-data as divan/<url>.tags.
|
||||||
|
// The text is one tag per line, "<type>: <name>", or for one couplet of a work "شعر <n> › <type>: <name>" (n counts
|
||||||
|
// from 1 as readers do; verses.couplet counts from 0, and couplet 0 here means the whole work):
|
||||||
|
// موضوع: عشق
|
||||||
|
// شعر 3 › شخصیت: مجنوں
|
||||||
|
// GET /api/tags every tag with how often it is used, by type
|
||||||
|
// GET /api/tag?type=&name= what carries a tag: books/chapters, works, couplets
|
||||||
|
import { readdir, readFile, rm, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
import { pool } from './db.ts';
|
||||||
|
|
||||||
|
export const TAG_TYPES = ['موضوع', 'صنف', 'بحر', 'شخصیت', 'مقام', 'دور', 'ٹیگ'];
|
||||||
|
export type Tag = { couplet: number; type: string; name: string };
|
||||||
|
export type TagTarget = 'category' | 'work';
|
||||||
|
const LINE = /^(?:شعر\s*(\d+)\s*›\s*)?([^:]+):\s*(.+)$/;
|
||||||
|
const ud2en = (s: string) => s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)));
|
||||||
|
const key = (t: Tag) => `${t.couplet}|${t.type}|${t.name}`;
|
||||||
|
|
||||||
|
// the text as tags; an error message for a bad line
|
||||||
|
export function parseTags(text: string): Tag[] | string {
|
||||||
|
const out: Tag[] = [];
|
||||||
|
for (const raw of text.split('\n')) {
|
||||||
|
const line = raw.trim().replace(/\s+/g, ' ');
|
||||||
|
if (!line) continue;
|
||||||
|
const m = ud2en(line).match(LINE);
|
||||||
|
if (!m) return `سمجھ نہیں آیا: «${line}»`;
|
||||||
|
const [type, name] = [m[2].trim(), m[3].trim()];
|
||||||
|
if (!TAG_TYPES.includes(type)) return `ٹیگ کی قسم ${TAG_TYPES.join('، ')} میں سے ہو: «${type}»`;
|
||||||
|
if (name.length > 60) return `ٹیگ کا نام بہت لمبا ہے: «${name}»`;
|
||||||
|
const t = { couplet: Number(m[1] ?? 0), type, name };
|
||||||
|
if (!out.some((x) => key(x) === key(t))) out.push(t);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tags in a fixed order (whole first, then by couplet; by type, then name), so equal sets give equal text
|
||||||
|
export const tagsText = (tags: Tag[]) => [...tags]
|
||||||
|
.sort((a, b) => a.couplet - b.couplet || TAG_TYPES.indexOf(a.type) - TAG_TYPES.indexOf(b.type) || a.name.localeCompare(b.name, 'ur'))
|
||||||
|
.map((t) => `${t.couplet ? `شعر ${t.couplet} › ` : ''}${t.type}: ${t.name}`).join('\n');
|
||||||
|
|
||||||
|
const tagsOf = async (entity: TagTarget, id: number, db: { query: PoolClient['query'] } = pool) => (await db.query(
|
||||||
|
`SELECT et.couplet, t.type, t.name FROM entity_tags et JOIN tags t ON t.id = et.tag_id WHERE et.entity = $1 AND et.entity_id = $2`,
|
||||||
|
[entity, id])).rows as Tag[];
|
||||||
|
|
||||||
|
// a target's published tags as text, and the latest published version of its tags
|
||||||
|
export async function currentTags(entity: TagTarget, id: number) {
|
||||||
|
const target = (await pool.query(entity === 'work'
|
||||||
|
? 'SELECT id, url, title, category_id FROM poems WHERE id = $1' : 'SELECT id, url, title, parent_id FROM categories WHERE id = $1', [id])).rows[0];
|
||||||
|
if (!target) return null;
|
||||||
|
const last = (await pool.query(
|
||||||
|
`SELECT max(version) AS v FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published'`, [`tags-${entity}`, id])).rows[0];
|
||||||
|
const couplets = entity === 'work'
|
||||||
|
? Number((await pool.query('SELECT coalesce(max(couplet) + 1, 0) AS n FROM verses WHERE poem_id = $1', [id])).rows[0].n) : 0;
|
||||||
|
return { target, couplets, version: Number(last?.v ?? 0), content: tagsText(await tagsOf(entity, id)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// the text is valid tags, on couplets the work has
|
||||||
|
export async function checkTags(entity: TagTarget, id: number, text: string) {
|
||||||
|
const tags = parseTags(text);
|
||||||
|
if (typeof tags === 'string') return tags;
|
||||||
|
if (entity === 'category' && tags.some((t) => t.couplet)) return 'کتاب یا باب کے ٹیگ کسی شعر پر نہیں لگتے';
|
||||||
|
const cur = await currentTags(entity, id);
|
||||||
|
const bad = tags.find((t) => t.couplet > (cur?.couplets ?? 0));
|
||||||
|
return bad ? `اس کلام میں شعر ${bad.couplet} نہیں` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the site's tags for the target become the text's
|
||||||
|
export async function applyTags(client: PoolClient, entity: TagTarget, id: number, text: string) {
|
||||||
|
const tags = parseTags(text) as Tag[];
|
||||||
|
await client.query('DELETE FROM entity_tags WHERE entity = $1 AND entity_id = $2', [entity, id]);
|
||||||
|
for (const t of tags) {
|
||||||
|
const tagId = (await client.query(
|
||||||
|
`INSERT INTO tags (type, name) VALUES ($1, $2) ON CONFLICT (type, name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [t.type, t.name])).rows[0].id;
|
||||||
|
await client.query('INSERT INTO entity_tags (tag_id, entity, entity_id, couplet) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING',
|
||||||
|
[tagId, entity, id, t.couplet]);
|
||||||
|
}
|
||||||
|
await client.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// divan-data/divan/<url>.tags (removed when the last tag goes)
|
||||||
|
export async function writeTags(dataDir: string, url: string, text: string) {
|
||||||
|
const path = join(dataDir, 'divan', url.replace(/^\//, '') + '.tags');
|
||||||
|
if (!text.trim()) { await rm(path, { force: true }); return path; }
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await writeFile(path, text.trim() + '\n');
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the import: every published .tags file in divan-data back into the site's tags (divan-data is the record)
|
||||||
|
export async function loadTags(dataDir: string) {
|
||||||
|
const root = join(dataDir, 'divan');
|
||||||
|
const files = await readdir(root, { recursive: true }).catch(() => [] as string[]);
|
||||||
|
const client = await pool.connect();
|
||||||
|
let n = 0;
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
await client.query('DELETE FROM entity_tags');
|
||||||
|
for (const f of files.filter((f) => f.endsWith('.tags'))) {
|
||||||
|
const url = '/' + f.slice(0, -'.tags'.length);
|
||||||
|
const work = (await client.query('SELECT id FROM poems WHERE url = $1', [url])).rows[0];
|
||||||
|
const cat = work ? null : (await client.query('SELECT id FROM categories WHERE url = $1', [url])).rows[0];
|
||||||
|
if (!work && !cat) { console.warn(`tags for an unknown page: ${url}`); continue; }
|
||||||
|
const text = await readFile(join(root, f), 'utf8');
|
||||||
|
if (typeof parseTags(text) === 'string') { console.warn(`bad tags in ${f}`); continue; }
|
||||||
|
await applyTags(client, work ? 'work' : 'category', (work ?? cat).id, text);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/api/tags', async () => (await pool.query(
|
||||||
|
`SELECT t.type, t.name, count(*)::int AS n FROM tags t JOIN entity_tags e ON e.tag_id = t.id GROUP BY t.id ORDER BY n DESC, t.name`)).rows);
|
||||||
|
|
||||||
|
app.get<{ Querystring: { type?: string; name?: string } }>('/api/tag', async (req, reply) => {
|
||||||
|
const tag = (await pool.query('SELECT id, type, name FROM tags WHERE type = $1 AND name = $2', [req.query.type ?? '', req.query.name ?? ''])).rows[0];
|
||||||
|
if (!tag) return reply.code(404).send({ error: 'ٹیگ نہیں ملا' });
|
||||||
|
const [cats, works] = await Promise.all([
|
||||||
|
pool.query(`SELECT c.id, c.url, c.title, t.nickname AS poet, t.url AS poet_url, c.parent_id IS NULL AS is_poet
|
||||||
|
FROM entity_tags e JOIN categories c ON c.id = e.entity_id JOIN poets t ON t.id = c.poet_id
|
||||||
|
WHERE e.tag_id = $1 AND e.entity = 'category' ORDER BY t.birth_year_ah NULLS LAST, c.title`, [tag.id]),
|
||||||
|
pool.query(`SELECT p.id, p.url, p.title, t.nickname AS poet, t.url AS poet_url, array_agg(e.couplet ORDER BY e.couplet) AS couplets
|
||||||
|
FROM entity_tags e JOIN poems p ON p.id = e.entity_id JOIN poets t ON t.id = p.poet_id
|
||||||
|
WHERE e.tag_id = $1 AND e.entity = 'work' GROUP BY p.id, t.id ORDER BY t.birth_year_ah NULLS LAST, p.title LIMIT 500`, [tag.id]),
|
||||||
|
]);
|
||||||
|
// the tagged couplets' lines
|
||||||
|
const pairs = works.rows.flatMap((w) => w.couplets.filter((c: number) => c > 0).map((c: number) => [w.id, c]));
|
||||||
|
const lines = pairs.length ? (await pool.query(
|
||||||
|
`SELECT poem_id, couplet, array_agg(text ORDER BY vorder) AS lines FROM verses
|
||||||
|
WHERE (poem_id, couplet + 1) IN (SELECT * FROM unnest($1::int[], $2::int[])) GROUP BY poem_id, couplet`,
|
||||||
|
[pairs.map((p) => p[0]), pairs.map((p) => p[1])])).rows : [];
|
||||||
|
return {
|
||||||
|
tag, categories: cats.rows,
|
||||||
|
works: works.rows.map((w) => ({ ...w, whole: w.couplets.includes(0),
|
||||||
|
couplets: lines.filter((l) => l.poem_id === w.id).map((l) => ({ couplet: l.couplet + 1, anchor: l.couplet, lines: l.lines })) })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// tags shown on a page: the target's own, by couplet (0 = the whole page)
|
||||||
|
export async function pageTags(entity: TagTarget, id: number) {
|
||||||
|
return (await tagsOf(entity, id)).sort((a, b) => TAG_TYPES.indexOf(a.type) - TAG_TYPES.indexOf(b.type));
|
||||||
|
}
|
||||||
@ -199,3 +199,19 @@ CREATE TABLE IF NOT EXISTS home_sections (
|
|||||||
sort text NOT NULL DEFAULT 'manual' CHECK (sort IN ('manual', 'alpha', 'timeline')),
|
sort text NOT NULL DEFAULT 'manual' CHECK (sort IN ('manual', 'alpha', 'timeline')),
|
||||||
poets integer[] NOT NULL DEFAULT '{}'
|
poets integer[] NOT NULL DEFAULT '{}'
|
||||||
);
|
);
|
||||||
|
-- tags (#52, api/src/tags.ts): typed labels on books/chapters (a poet's root category = the poet), works and
|
||||||
|
-- couplets (couplet > 0). The published record is divan-data/divan/<url>.tags; the import reloads it.
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
type text NOT NULL, -- موضوع، صنف، بحر، شخصیت، مقام، دور، ٹیگ (free)
|
||||||
|
name text NOT NULL,
|
||||||
|
UNIQUE (type, name)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS entity_tags (
|
||||||
|
tag_id integer NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
entity text NOT NULL CHECK (entity IN ('category', 'work')),
|
||||||
|
entity_id integer NOT NULL, -- categories.id / poems.id
|
||||||
|
couplet integer NOT NULL DEFAULT 0, -- a work's couplet; 0 = the whole work or category
|
||||||
|
PRIMARY KEY (tag_id, entity, entity_id, couplet)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS entity_tags_target ON entity_tags(entity, entity_id);
|
||||||
|
|||||||
8
web/src/components/Tags.astro
Normal file
8
web/src/components/Tags.astro
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
// tag chips (#52): each links to its tag page
|
||||||
|
const { tags } = Astro.props as { tags: { type: string; name: string }[] };
|
||||||
|
const href = (t: { type: string; name: string }) => `/tag/${encodeURIComponent(t.type)}/${encodeURIComponent(t.name)}`;
|
||||||
|
---
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<p class="tag-chips">{tags.map((t) => <a class="tag-chip" href={href(t)}><small>{t.type}</small> {t.name}</a>)}</p>
|
||||||
|
)}
|
||||||
@ -73,7 +73,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
|||||||
</aside>
|
</aside>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<a href="/about">دیوان کے بارے میں</a> · متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
|
<a href="/about">دیوان کے بارے میں</a> · <a href="/tags">ٹیگ</a> · متن: <a href="https://ur.wikisource.org">ویکی ماخذ</a> (عوامی ملکیت) · تعارف: <a href="https://ur.wikipedia.org">ویکیپیڈیا</a> ·
|
||||||
<a href="https://github.com/anas-rashid/divan-data">ڈیٹا</a> · <a href="https://github.com/anas-rashid/divan">کوڈ</a>
|
<a href="https://github.com/anas-rashid/divan-data">ڈیٹا</a> · <a href="https://github.com/anas-rashid/divan">کوڈ</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts)
|
// Urdu labels for roles and permissions (api/src/admin.ts, api/src/permissions.ts)
|
||||||
export const ROLE: Record<string, string> = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' };
|
export const ROLE: Record<string, string> = { reader: 'قاری', 'mod-l2': 'موڈریٹر (L2)', 'mod-l1': 'سینئر موڈریٹر (L1)', admin: 'ایڈمن' };
|
||||||
export const SCOPE: Record<string, string> = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' };
|
export const SCOPE: Record<string, string> = { all: 'تمام شعرا', poet: 'شاعر', category: 'کتاب / حصہ', poem: 'ایک کلام' };
|
||||||
export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)' };
|
export const CONTENT: Record<string, string> = { poets: 'شعرا', books: 'کتابیں', works: 'کلام', dictionary: 'لغت', site: 'سائٹ (سرورق کے حصے)', tags: 'ٹیگ' };
|
||||||
export const ACTION: Record<string, string> = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' };
|
export const ACTION: Record<string, string> = { create: 'نیا', edit: 'ترمیم', delete: 'حذف', arrange: 'ترتیب' };
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
// Poet (/p238), category (/p238/shaeri/c641) and poem (/p238/shaeri/c641/sh6556) pages
|
// Poet (/p238), category (/p238/shaeri/c641) and poem (/p238/shaeri/c641/sh6556) pages
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import Seg from '../components/Seg.astro';
|
import Seg from '../components/Seg.astro';
|
||||||
|
import Tags from '../components/Tags.astro';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { asUser, COOKIE } from '../lib/auth';
|
import { asUser, COOKIE } from '../lib/auth';
|
||||||
import { phraseParts, phraseRe } from '../lib/phrases';
|
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 ?? '')
|
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 };
|
? (await asUser(token, `/api/mod/can?poem=${poemId}`)).data : { edit: false };
|
||||||
// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41)
|
// 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 ?? '')
|
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.arrange;
|
? (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 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;
|
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.type === 'poet' && (
|
||||||
<>
|
<>
|
||||||
<h1>{page.poet.name}</h1>
|
<h1>{page.poet.name}</h1>
|
||||||
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn">ترتیب دیں</button></form>
|
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn">ترتیب دیں</button></form>
|
||||||
<a class="mod-history" href={`/mod/order/${page.category.id}`}>ترتیب کا تاریخچہ</a></>}</p>
|
<a class="mod-history" href={`/mod/order/${page.category.id}`}>ترتیب کا تاریخچہ</a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn">ٹیگ</button></form></>}</div>
|
||||||
<p class="muted">{years(page.poet)}</p>
|
<p class="muted">{years(page.poet)}</p>
|
||||||
|
<Tags tags={pageTags} />
|
||||||
{page.poet.description && <div class="intro">{page.poet.description}</div>}
|
{page.poet.description && <div class="intro">{page.poet.description}</div>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{page.type === 'category' && (
|
{page.type === 'category' && (
|
||||||
<>
|
<>
|
||||||
<h1>{page.category.title}</h1>
|
<h1>{page.category.title}</h1>
|
||||||
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn">ترتیب دیں</button></form>
|
<Tags tags={pageTags} />
|
||||||
<a class="mod-history" href={`/mod/order/${page.category.id}`}>ترتیب کا تاریخچہ</a></>}</p>
|
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn">ترتیب دیں</button></form>
|
||||||
|
<a class="mod-history" href={`/mod/order/${page.category.id}`}>ترتیب کا تاریخچہ</a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn">ٹیگ</button></form></>}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -123,15 +131,16 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
|
|||||||
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
|
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button>
|
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poem', poemId })} aria-pressed={String(!!saved.poem)} title="یہ کلام محفوظ کریں">{label(saved.poem)}</button>
|
||||||
{modCan.edit && <> <form method="post" action={`/mod/work/${poemId}`} class="inline-form"><button class="mod-edit-btn">ترمیم کریں</button></form>
|
{modCan.edit && <> <form method="post" action={`/mod/work/${poemId}`} class="inline-form"><button class="mod-edit-btn">ترمیم کریں</button></form>
|
||||||
<a class="mod-history" href={`/mod/work/${poemId}`}>تاریخچہ</a></>}</p>
|
<a class="mod-history" href={`/mod/work/${poemId}`}>تاریخچہ</a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn">ٹیگ</button></form></>}</div>
|
||||||
|
<Tags tags={pageTags} />
|
||||||
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
|
{page.poem.radif != null && <p class="muted radif">{page.poem.radif ? `ردیف: ${page.poem.radif}` : 'غیر مردف'}</p>}
|
||||||
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
|
<article class="poem" data-cite={`${page.poet.nickname}، ${page.poem.title}`} data-poem={poemId}>
|
||||||
{blocks.map((b) => {
|
{blocks.map((b) => {
|
||||||
const bm = <button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />;
|
const bm = <button type="button" class="bm" data-save={bookmark(b.c)} aria-pressed={String(saved.couplets.includes(b.c))} aria-label="شعر محفوظ کریں" />;
|
||||||
const lm = lineMarks(b.c, b.lines);
|
const lm = lineMarks(b.c, b.lines);
|
||||||
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}</div>
|
return b.kind === 'couplet' ? <div class="couplet" id={`c${b.c}`} data-c={b.c} data-mark={b.mark}>{bm}{b.lines.map((l, i) => <p><Seg s={show(l, lm[i])} /></p>)}<Tags tags={coupletTags(b.c)} /></div>
|
||||||
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
|
: b.kind === 'para' ? <p class="para" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>
|
||||||
: <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>;
|
: <p class="single" id={`c${b.c}`} data-c={b.c}>{bm}<Seg s={show(b.lines[0], lm[0])} /></p>;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { asUser, COOKIE } from '../../../lib/auth';
|
|||||||
import { STATUS, EVENT, when } from '../../../lib/mod';
|
import { STATUS, EVENT, when } from '../../../lib/mod';
|
||||||
import { ud } from '../../../lib/urdu';
|
import { ud } from '../../../lib/urdu';
|
||||||
import DivanPreview from '../../../components/DivanPreview.astro';
|
import DivanPreview from '../../../components/DivanPreview.astro';
|
||||||
|
import Tags from '../../../components/Tags.astro';
|
||||||
|
import { api } from '../../../lib/api';
|
||||||
|
|
||||||
const me = Astro.locals.user;
|
const me = Astro.locals.user;
|
||||||
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
|
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
|
||||||
@ -26,21 +28,59 @@ const { revision: rev, diff, changes, events, may } = res.data;
|
|||||||
// an arrangement (#41) lists a book/section's items, one per line: "<url part> <title>"
|
// an arrangement (#41) lists a book/section's items, one per line: "<url part> <title>"
|
||||||
const order = rev.entity === 'order';
|
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) })) : [];
|
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]);
|
const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]);
|
||||||
---
|
---
|
||||||
<Base title={`مسودہ: ${rev.work_title}`}>
|
<Base title={`مسودہ: ${rev.work_title}`}>
|
||||||
<h1>{order && 'ترتیب: '}{rev.work_title}</h1>
|
<h1>{order && 'ترتیب: '}{tagging && 'ٹیگ: '}{rev.work_title}</h1>
|
||||||
<ModNav />
|
<ModNav />
|
||||||
<p class="center">
|
<p class="center">
|
||||||
<span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span>
|
<span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span>
|
||||||
· <bdi dir="ltr">{rev.author_email}</bdi>
|
· <bdi dir="ltr">{rev.author_email}</bdi>
|
||||||
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
|
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
|
||||||
· <a href={rev.work_url}>{order ? 'حصہ' : 'کلام'}</a> · <a href={`/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a>
|
· <a href={rev.work_url}>{order ? 'حصہ' : tagging ? 'صفحہ' : 'کلام'}</a> · <a href={tagging ? `/mod/tags/${tagKind}/${rev.entity_id}` : `/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a>
|
||||||
</p>
|
</p>
|
||||||
{error && <p class="form-error" role="alert">{error}</p>}
|
{error && <p class="form-error" role="alert">{error}</p>}
|
||||||
{done && <p class="form-done" role="status">{done}</p>}
|
{done && <p class="form-done" role="status">{done}</p>}
|
||||||
|
|
||||||
{may.save && order ? (
|
{may.save && tagging ? (
|
||||||
|
<form method="post" class="mod-edit">
|
||||||
|
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: موضوعات اور شخصیات کے ٹیگ" /></label>
|
||||||
|
<p class="muted note">ہر ٹیگ کی قسم چنیں اور نام لکھیں۔{couplets > 0 && ' ٹیگ پورے کلام پر یا کسی ایک شعر پر لگ سکتا ہے۔'} پہلے سے موجود نام تجویز ہوتے ہیں۔</p>
|
||||||
|
<div id="tag-rows">
|
||||||
|
{tagRows.map((t: any, i: number) => <div class="tag-row">
|
||||||
|
{couplets > 0 && <select class="tool tag-couplet" aria-label="کہاں"><option value="0">پورا کلام</option>{Array.from({ length: couplets }, (_, i) => <option value={i + 1} selected={t.couplet === i + 1}>شعر {ud(i + 1)}</option>)}</select>}
|
||||||
|
<select class="tool tag-type" aria-label="قسم">{TAG_TYPES.map((x) => <option selected={x === t.type}>{x}</option>)}</select>
|
||||||
|
<span class="in-box-field tag-name-box"><input class="tag-name" id={`tag-name-${i}`} list="tag-names" value={t.name} maxlength="60" data-urdu aria-label="ٹیگ کا نام" placeholder="ٹیگ کا نام" />
|
||||||
|
<button type="button" class="in-box rom" data-urdu-rom={`tag-name-${i}`} aria-pressed="false" title="رومن حروف سے اردو">اب</button>
|
||||||
|
<button type="button" class="in-box" data-urdu-kb={`tag-name-${i}`} aria-pressed="false" title="اردو کی بورڈ" aria-label="اردو کی بورڈ"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" stroke-linecap="round" stroke-width="2"/></svg></button></span>
|
||||||
|
<button type="button" class="tool" data-remove title="ہٹائیں">✕</button>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
<template id="tag-row">{[{ couplet: 0, type: 'موضوع', name: '' }].map((t, i) => <div class="tag-row">
|
||||||
|
{couplets > 0 && <select class="tool tag-couplet" aria-label="کہاں"><option value="0">پورا کلام</option>{Array.from({ length: couplets }, (_, i) => <option value={i + 1} selected={t.couplet === i + 1}>شعر {ud(i + 1)}</option>)}</select>}
|
||||||
|
<select class="tool tag-type" aria-label="قسم">{TAG_TYPES.map((x) => <option selected={x === t.type}>{x}</option>)}</select>
|
||||||
|
<span class="in-box-field tag-name-box"><input class="tag-name" id={`tag-name-${i}`} list="tag-names" value={t.name} maxlength="60" data-urdu aria-label="ٹیگ کا نام" placeholder="ٹیگ کا نام" />
|
||||||
|
<button type="button" class="in-box rom" data-urdu-rom={`tag-name-${i}`} aria-pressed="false" title="رومن حروف سے اردو">اب</button>
|
||||||
|
<button type="button" class="in-box" data-urdu-kb={`tag-name-${i}`} aria-pressed="false" title="اردو کی بورڈ" aria-label="اردو کی بورڈ"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10" stroke-linecap="round" stroke-width="2"/></svg></button></span>
|
||||||
|
<button type="button" class="tool" data-remove title="ہٹائیں">✕</button>
|
||||||
|
</div>)}</template>
|
||||||
|
<datalist id="tag-names">{knownTags.map((n) => <option value={n} />)}</datalist>
|
||||||
|
<p><button type="button" class="tool" id="tag-add">+ ٹیگ</button></p>
|
||||||
|
<textarea name="content" id="tag-content" hidden>{rev.content}</textarea>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button name="act" value="save">محفوظ کریں</button>
|
||||||
|
<button name="act" value="submit">{me.role === 'admin' ? 'محفوظ کر کے شائع کریں' : me.role === 'mod-l1' ? 'محفوظ کر کے اشاعت کے لیے بھیجیں' : 'محفوظ کر کے جائزے کے لیے بھیجیں'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : may.save && order ? (
|
||||||
<form method="post" class="mod-edit">
|
<form method="post" class="mod-edit">
|
||||||
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: غزلیں مطبوعہ دیوان کی ترتیب میں" /></label>
|
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: غزلیں مطبوعہ دیوان کی ترتیب میں" /></label>
|
||||||
<p class="muted note">کسی چیز کو کھینچ کر اس کی جگہ رکھیں، یا ↑ ↓ سے ایک جگہ اوپر نیچے کریں؛ «اول» سب سے اوپر، «آخر» سب سے نیچے۔</p>
|
<p class="muted note">کسی چیز کو کھینچ کر اس کی جگہ رکھیں، یا ↑ ↓ سے ایک جگہ اوپر نیچے کریں؛ «اول» سب سے اوپر، «آخر» سب سے نیچے۔</p>
|
||||||
@ -121,7 +161,9 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
|
|||||||
|
|
||||||
<section class="lib-section">
|
<section class="lib-section">
|
||||||
<h2>پیش نظارہ</h2>
|
<h2>پیش نظارہ</h2>
|
||||||
{order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol> : <DivanPreview text={rev.content} />}
|
{order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol>
|
||||||
|
: tagging ? (tagRows.length ? tagRows.map((t: any) => <div class="tag-preview">{t.couplet > 0 && <small class="muted">شعر {ud(t.couplet)}:</small>}<Tags tags={[t]} /></div>) : <p class="muted center">کوئی ٹیگ نہیں</p>)
|
||||||
|
: <DivanPreview text={rev.content} />}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{acts.length > 0 && (
|
{acts.length > 0 && (
|
||||||
@ -140,6 +182,28 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
<script>
|
<script>
|
||||||
|
// tagging: rows -> the hidden text ("شعر 3 › موضوع: عشق"), on every change and before saving
|
||||||
|
const rowsBox = document.getElementById('tag-rows');
|
||||||
|
const tagText = document.getElementById('tag-content') as HTMLTextAreaElement | null;
|
||||||
|
if (rowsBox && tagText) {
|
||||||
|
const sync = () => {
|
||||||
|
tagText.value = [...rowsBox.querySelectorAll('.tag-row')].map((r) => {
|
||||||
|
const c = Number((r.querySelector('.tag-couplet') as HTMLSelectElement | null)?.value ?? 0);
|
||||||
|
const type = (r.querySelector('.tag-type') as HTMLSelectElement).value, name = (r.querySelector('.tag-name') as HTMLInputElement).value.trim();
|
||||||
|
return name ? `${c ? `شعر ${c} › ` : ''}${type}: ${name}` : '';
|
||||||
|
}).filter(Boolean).join('\n');
|
||||||
|
};
|
||||||
|
rowsBox.addEventListener('input', sync); rowsBox.addEventListener('change', sync);
|
||||||
|
rowsBox.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('[data-remove]')) { (e.target as HTMLElement).closest('.tag-row')!.remove(); sync(); } });
|
||||||
|
document.getElementById('tag-add')!.onclick = () => {
|
||||||
|
const row = (document.getElementById('tag-row') as HTMLTemplateElement).content.firstElementChild!.cloneNode(true) as HTMLElement;
|
||||||
|
// a fresh id for the new name box, so its keyboard buttons point at it
|
||||||
|
const nid = `tag-name-new-${Date.now()}`;
|
||||||
|
row.querySelector('.tag-name')!.id = nid; row.querySelectorAll('[data-urdu-rom], [data-urdu-kb]').forEach((b) => b.setAttribute(b.hasAttribute('data-urdu-rom') ? 'data-urdu-rom' : 'data-urdu-kb', nid));
|
||||||
|
rowsBox.append(row); (row.querySelector('.tag-name') as HTMLInputElement).focus();
|
||||||
|
};
|
||||||
|
tagText.form!.addEventListener('submit', sync);
|
||||||
|
}
|
||||||
// arranging: drag an item, or move it with the buttons; the hidden text follows the list
|
// arranging: drag an item, or move it with the buttons; the hidden text follows the list
|
||||||
const list = document.getElementById('order-list');
|
const list = document.getElementById('order-list');
|
||||||
const text = document.getElementById('order-content') as HTMLTextAreaElement | null;
|
const text = document.getElementById('order-content') as HTMLTextAreaElement | null;
|
||||||
|
|||||||
47
web/src/pages/mod/tags/[kind]/[id].astro
Normal file
47
web/src/pages/mod/tags/[kind]/[id].astro
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
// A page's tags (#52): its current tags, published versions and drafts; "ٹیگ لگائیں" starts a draft
|
||||||
|
import Base from '../../../../layouts/Base.astro';
|
||||||
|
import ModNav from '../../../../components/ModNav.astro';
|
||||||
|
import Tags from '../../../../components/Tags.astro';
|
||||||
|
import { asUser, COOKIE } from '../../../../lib/auth';
|
||||||
|
import { STATUS, when } from '../../../../lib/mod';
|
||||||
|
import { ud } from '../../../../lib/urdu';
|
||||||
|
|
||||||
|
const me = Astro.locals.user;
|
||||||
|
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
|
||||||
|
const token = Astro.cookies.get(COOKIE)!.value, { kind, id } = Astro.params;
|
||||||
|
let error = '';
|
||||||
|
if (Astro.request.method === 'POST') {
|
||||||
|
const r = await asUser(token, `/api/mod/tags/${kind}/${id}/draft`, {});
|
||||||
|
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`);
|
||||||
|
error = r.data.error ?? 'کچھ غلط ہو گیا';
|
||||||
|
}
|
||||||
|
const res = await asUser(token, `/api/mod/tags/${kind}/${id}`);
|
||||||
|
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
|
||||||
|
const { target, version, content, history, may } = res.data;
|
||||||
|
const current = content.split('\n').filter(Boolean).map((l: string) => { const [, type, name] = l.replace(/^شعر \d+ › /, '').match(/^([^:]+):\s*(.+)$/) ?? []; return { type, name }; });
|
||||||
|
---
|
||||||
|
<Base title={`ٹیگ: ${target.title}`}>
|
||||||
|
<h1>ٹیگ: {target.title}</h1>
|
||||||
|
<ModNav />
|
||||||
|
<p class="center"><a href={target.url}>صفحہ</a> · {version ? `موجودہ ٹیگ: ورژن ${ud(version)}` : 'ابھی کوئی شائع شدہ ٹیگ نہیں'}</p>
|
||||||
|
<Tags tags={current} />
|
||||||
|
{error && <p class="form-error" role="alert">{error}</p>}
|
||||||
|
{may.tags && <form method="post" class="center"><button class="save-like">ٹیگ لگائیں</button></form>}
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>ورژن</th><th>حالت</th><th>خلاصہ</th><th>لکھنے والے</th><th>جائزہ</th><th>اشاعت</th><th>تاریخ</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{history.map((h: any) => (
|
||||||
|
<tr>
|
||||||
|
<td><a href={`/mod/rev/${h.id}`}>{h.version ? ud(h.version) : '—'}</a></td>
|
||||||
|
<td><span class={`status s-${h.status}`}>{STATUS[h.status]}</span></td>
|
||||||
|
<td>{h.summary}</td>
|
||||||
|
<td><bdi dir="ltr">{h.author_email}</bdi></td>
|
||||||
|
<td><bdi dir="ltr">{h.reviewer_email ?? ''}</bdi></td>
|
||||||
|
<td><bdi dir="ltr">{h.publisher_email ?? ''}</bdi></td>
|
||||||
|
<td><bdi dir="ltr">{when(h.published_at ?? h.created_at)}</bdi></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Base>
|
||||||
@ -2,6 +2,7 @@
|
|||||||
// Search: poets/writers by name and books/chapters by title (first page), then the content results, paged
|
// Search: poets/writers by name and books/chapters by title (first page), then the content results, paged
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
import Seg from '../components/Seg.astro';
|
import Seg from '../components/Seg.astro';
|
||||||
|
import Tags from '../components/Tags.astro';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { ud, years, highlighter, parts, excerpt } from '../lib/urdu';
|
import { ud, years, highlighter, parts, excerpt } from '../lib/urdu';
|
||||||
|
|
||||||
@ -30,7 +31,7 @@ const numbers = (() => {
|
|||||||
const from = res ? (pageNo - 1) * res.pageSize + 1 : 0, to = res ? Math.min(res.total, pageNo * res.pageSize) : 0;
|
const from = res ? (pageNo - 1) * res.pageSize + 1 : 0, to = res ? Math.min(res.total, pageNo * res.pageSize) : 0;
|
||||||
---
|
---
|
||||||
<Base title={q ? `تلاش: ${q}` : 'تلاش'} q={q}>
|
<Base title={q ? `تلاش: ${q}` : 'تلاش'} q={q}>
|
||||||
{!q && <p class="muted">اوپر کے خانے میں شاعر کا نام، شعر، لفظ یا عبارت لکھیں۔ پوری عبارت کے لیے اسے "…" میں لکھیں۔</p>}
|
{!q && <p class="muted">اوپر کے خانے میں شاعر کا نام، شعر، لفظ یا عبارت لکھیں۔ پوری عبارت کے لیے اسے "…" میں لکھیں۔ کسی ٹیگ والا کلام: <bdi>ٹیگ:عشق</bdi> (نام میں فاصلہ ہو تو <bdi>ٹیگ:"حب الوطنی"</bdi>)۔ <a href="/tags">تمام ٹیگ</a></p>}
|
||||||
{res && (
|
{res && (
|
||||||
<>
|
<>
|
||||||
<h1>تلاش: {q}</h1>
|
<h1>تلاش: {q}</h1>
|
||||||
@ -60,6 +61,13 @@ const from = res ? (pageNo - 1) * res.pageSize + 1 : 0, to = res ? Math.min(res.
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{res.tags?.length > 0 && (
|
||||||
|
<section class="search-group">
|
||||||
|
<h2>ٹیگ <small class="muted">{ud(res.tags.length)}</small></h2>
|
||||||
|
<Tags tags={res.tags} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section class="search-group">
|
<section class="search-group">
|
||||||
<h2>کلام <small class="muted">{ud(res.total)}</small></h2>
|
<h2>کلام <small class="muted">{ud(res.total)}</small></h2>
|
||||||
{chips.length > 0 && (
|
{chips.length > 0 && (
|
||||||
|
|||||||
37
web/src/pages/tag/[type]/[name].astro
Normal file
37
web/src/pages/tag/[type]/[name].astro
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
// A tag's page (#52): the poets, books/chapters, works and couplets carrying it
|
||||||
|
import Base from '../../../layouts/Base.astro';
|
||||||
|
import { api } from '../../../lib/api';
|
||||||
|
import { ud } from '../../../lib/urdu';
|
||||||
|
|
||||||
|
const { type = '', name = '' } = Astro.params;
|
||||||
|
const res = await api(`/api/tag?type=${encodeURIComponent(type)}&name=${encodeURIComponent(name)}`);
|
||||||
|
if (!res) return new Response(null, { status: 404, statusText: 'Not found' });
|
||||||
|
const { tag, categories, works } = res;
|
||||||
|
const count = categories.length + works.length;
|
||||||
|
---
|
||||||
|
<Base title={`${tag.type}: ${tag.name}`}>
|
||||||
|
<h1><small class="muted">{tag.type}:</small> {tag.name}</h1>
|
||||||
|
<p class="center muted">{ud(count)} جگہ · <a href={`/search?q=${encodeURIComponent(`tag:"${tag.name}"`)}`}>تلاش میں دیکھیں</a> · <a href="/tags">تمام ٹیگ</a></p>
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<section class="search-group">
|
||||||
|
<h2>شعرا، کتابیں اور ابواب</h2>
|
||||||
|
<ul class="book-results">
|
||||||
|
{categories.map((c: any) => <li><a href={c.url}>{c.is_poet ? c.poet : c.title}</a>{!c.is_poet && <> · <a class="muted" href={c.poet_url}>{c.poet}</a></>}</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{works.length > 0 && (
|
||||||
|
<section class="search-group">
|
||||||
|
<h2>کلام</h2>
|
||||||
|
{works.map((w: any) => (
|
||||||
|
<div class="result">
|
||||||
|
<a href={w.url}>{w.title}</a> · <a class="muted" href={w.poet_url}>{w.poet}</a>
|
||||||
|
{w.couplets.map((c: any) => (
|
||||||
|
<a class="snippet" href={`${w.url}#c${c.anchor}`}>{c.lines.map((l: string) => <p>{l}</p>)}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</Base>
|
||||||
20
web/src/pages/tags.astro
Normal file
20
web/src/pages/tags.astro
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
// Every tag (#52), by type, most used first
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { ud } from '../lib/urdu';
|
||||||
|
|
||||||
|
const TYPES = ['موضوع', 'صنف', 'بحر', 'شخصیت', 'مقام', 'دور', 'ٹیگ'];
|
||||||
|
const tags = ((await api<{ type: string; name: string; n: number }[]>('/api/tags')) ?? []);
|
||||||
|
const groups = TYPES.map((t) => [t, tags.filter((x) => x.type === t)] as const).filter(([, xs]) => xs.length);
|
||||||
|
---
|
||||||
|
<Base title="ٹیگ">
|
||||||
|
<h1>ٹیگ</h1>
|
||||||
|
{groups.length === 0 && <p class="muted center">ابھی کوئی ٹیگ نہیں۔</p>}
|
||||||
|
{groups.map(([type, xs]) => (
|
||||||
|
<section class="search-group">
|
||||||
|
<h2>{type} <small class="muted">{ud(xs.length)}</small></h2>
|
||||||
|
<p class="tag-chips">{xs.map((t) => <a class="tag-chip" href={`/tag/${encodeURIComponent(t.type)}/${encodeURIComponent(t.name)}`}>{t.name} <small>{ud(t.n)}</small></a>)}</p>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</Base>
|
||||||
@ -404,3 +404,14 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
|||||||
.site-poets .tool { min-width: 1.6em; padding: 0 4px; margin-inline-start: 4px; font-size: .85rem; }
|
.site-poets .tool { min-width: 1.6em; padding: 0 4px; margin-inline-start: 4px; font-size: .85rem; }
|
||||||
.site-section .author-find { position: relative; max-width: 26em; }
|
.site-section .author-find { position: relative; max-width: 26em; }
|
||||||
.site-section .author-find input { width: 100%; font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); }
|
.site-section .author-find input { width: 100%; font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); }
|
||||||
|
/* tags (#52) */
|
||||||
|
.tag-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 6px 0; }
|
||||||
|
.tag-chip { font-size: .85rem; padding: 0 10px; border: 1px solid var(--gold-light); border-radius: 999px; background: var(--inner); color: var(--ink); text-decoration: none; }
|
||||||
|
.tag-chip small { color: var(--muted); }
|
||||||
|
.tag-chip:hover { border-color: var(--brand); color: var(--brand); }
|
||||||
|
.couplet .tag-chips { grid-column: 1 / -1; justify-content: center; margin: 2px 0 0; }
|
||||||
|
.couplet .tag-chip { font-size: .75rem; }
|
||||||
|
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0; }
|
||||||
|
.tag-row .tag-name-box { flex: 1 1 12em; }
|
||||||
|
.tag-row .tag-name { width: 100%; font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); }
|
||||||
|
.tag-preview { display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user