diff --git a/api/src/details.test.ts b/api/src/details.test.ts new file mode 100644 index 00000000..2eb31de9 --- /dev/null +++ b/api/src/details.test.ts @@ -0,0 +1,91 @@ +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 { adminRoutes } from './admin.ts'; +import { permissionRoutes } from './permissions.ts'; +import { moderationRoutes } from './moderation.ts'; +import { checkDetails, parseDetails } from './details.ts'; +import { pool } from './db.ts'; + +after(() => pool.end()); + +test('details text: fields, the intro to the end, checks', () => { + const d = parseDetails('نام: محمد اقبال\nتخلص: اقبال\nپیدائش: ۱۸۷۷\nوفات: 1938\nتعارف:\nپہلی سطر\n\nدوسری: سطر'); + assert.equal(d['تعارف'], 'پہلی سطر\n\nدوسری: سطر', 'a colon in the intro is text'); + assert.equal(checkDetails('poet', 'نام: محمد اقبال\nتخلص: اقبال\nپیدائش: ۱۸۷۷\nوفات: 1938\nتعارف:\nx'), null, 'Urdu digits are years'); + assert.match(checkDetails('poet', 'نام: \nتخلص: اقبال')!, /ضروری/); + assert.match(checkDetails('poet', 'نام: الف\nتخلص: ب\nپیدائش: 1900\nوفات: 1800')!, /پہلے/); + assert.match(checkDetails('poet', 'نام: الف\nتخلص: ب\nپیدائش: سن')!, /عیسوی/); + assert.match(checkDetails('book', 'عنوان:')!, /ضروری/); +}); + +test('poet and book editors: permissions, publishing to divan-data and the site, history and revert', 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); adminRoutes(app); permissionRoutes(app); moderationRoutes(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': `det-${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'), l2 = await person('l2', 'mod-l2'); + const poet = (await pool.query(`SELECT * FROM poets WHERE url = '/p266'`)).rows[0]; + const root = (await pool.query(`SELECT id, title FROM categories WHERE poet_id = $1 AND parent_id IS NULL`, [poet.id])).rows[0]; + const book = (await pool.query(`SELECT id, url, title FROM categories WHERE url = '/p266/ghazal'`)).rows[0]; + try { + assert.equal((await call('POST', `/api/mod/details/poet/${poet.id}/draft`, undefined, l2.token)).statusCode, 403, 'no grant yet'); + await call('POST', `/api/admin/users/${l2.id}/grants`, { scope: 'poet', target: '/p266', content: ['poets', 'books'], actions: ['edit'] }, admin.token); + const can = (await call('GET', `/api/mod/can?category=${book.id}`, undefined, l2.token)).json(); + assert.deepEqual([can.poet, can.book], [true, true]); + + // the poet: L2 drafts a new intro and years; the admin publishes + const cur = (await call('GET', `/api/mod/details/poet/${poet.id}`, undefined, l2.token)).json(); + assert.equal(cur.fields['تخلص'], poet.nickname); + const id = (await call('POST', `/api/mod/details/poet/${poet.id}/draft`, undefined, l2.token)).json().id; + const text = `نام: ${poet.name}\nتخلص: ${poet.nickname}\nپیدائش: 1797\nوفات: 1869\nتعارف:\nنیا تعارف\nدوسرا پیراگراف`; + assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: 'نام:\nتخلص: x' }, l2.token)).statusCode, 400); + assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: text, summary: 'تعارف' }, l2.token)).statusCode, 200); + await pool.query(`UPDATE revisions SET status = 'approved' WHERE id = $1`, [id]); // the L1 step is tested in moderation.test.ts + assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 }); + const now = (await pool.query('SELECT description, birth_year_ce, death_year_ce, birth_year_ah FROM poets WHERE id = $1', [poet.id])).rows[0]; + assert.deepEqual([now.description, now.birth_year_ce, now.death_year_ce, now.birth_year_ah], ['نیا تعارف\nدوسرا پیراگراف', 1797, 1869, 1212]); + assert.match(await readFile(join(data, 'divan', 'p266.poet'), 'utf8'), /^نام: .+\nتخلص: .+\nپیدائش: 1797\nوفات: 1869\nتعارف:\nنیا تعارف/); + assert.match(git('log', '-1', '--format=%s'), new RegExp(`^شاعر: ${poet.nickname}: تعارف \\(ورژن 1\\)`)); + + // the book's title, by the admin directly + const b = (await call('POST', `/api/mod/details/book/${book.id}/draft`, undefined, admin.token)).json().id; + await call('POST', `/api/mod/revisions/${b}/save`, { content: 'عنوان: غزلیات (نیا)' }, admin.token); + assert.equal((await call('POST', `/api/mod/revisions/${b}/submit`, {}, admin.token)).json().version, 1); + assert.equal((await pool.query('SELECT title FROM categories WHERE id = $1', [book.id])).rows[0].title, 'غزلیات (نیا)'); + assert.equal((await readFile(join(data, 'divan', 'p266', 'ghazal.book'), 'utf8')), 'عنوان: غزلیات (نیا)\n'); + + // history: compare with the text before Divan's version, and bring it back as a draft + const cmp = (await call('GET', `/api/mod/compare/poet/${poet.id}?a=0&b=1`, undefined, l2.token)).json(); + assert.ok(cmp.diff.some((d: any) => d.op === '+' && d.text === 'نیا تعارف')); + const back = (await call('POST', `/api/mod/revert/book/${book.id}`, { version: 0 }, l2.token)).json().id; + assert.equal((await call('GET', `/api/mod/revisions/${back}`, undefined, l2.token)).json().revision.content, `عنوان: ${book.title}`); + const log = (await call('GET', `/api/mod/log?who=l2-${run}`, undefined, admin.token)).json().entries; + assert.ok(log.some((e: any) => e.entity === 'poet' && e.title === poet.nickname && e.url === '/p266'), 'the log names the poet'); + } finally { + await pool.query(`UPDATE poets SET name = $2, nickname = $3, description = $4, birth_year_ce = $5, death_year_ce = $6, birth_year_ah = $7, death_year_ah = $8 WHERE id = $1`, + [poet.id, poet.name, poet.nickname, poet.description, poet.birth_year_ce, poet.death_year_ce, poet.birth_year_ah, poet.death_year_ah]); + await pool.query('UPDATE categories SET title = $2 WHERE id = $1', [root.id, root.title]); + await pool.query('UPDATE categories SET title = $2 WHERE id = $1', [book.id, book.title]); + await pool.query(`DELETE FROM revisions WHERE entity IN ('poet', 'book') AND author_email LIKE $1`, [`%-${run}@divan.test`]); + await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]); + await pool.query('DELETE FROM audit_log WHERE actor_email LIKE $1 OR target_email LIKE $1', [`%-${run}@divan.test`]); + await rm(data, { recursive: true, force: true }); + await app.close(); + } +}); diff --git a/api/src/details.ts b/api/src/details.ts new file mode 100644 index 00000000..d66d78b4 --- /dev/null +++ b/api/src/details.ts @@ -0,0 +1,84 @@ +// Poet and book details (#32): a poet's name, pen name, years and intro, and a book or section's title, edited in +// Divan. Each is a revision (entity 'poet', entity_id = poets.id; 'book', entity_id = categories.id) through the same +// pipeline as a work. The text is one "label: value" per line; a poet's intro follows "تعارف:" to the end: +// نام: محمد اقبال +// تخلص: اقبال +// پیدائش: 1877 +// وفات: 1938 +// تعارف: +// ڈاکٹر سر علامہ محمد اقبال … +// Published details live in divan-data as divan/.poet and divan/.book, which the export +// (export_divan.py) prefers to Wikisource's and Wikipedia's, so the daily sync keeps them. +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import type { PoolClient } from 'pg'; +import { pool } from './db.ts'; + +export type DetailKind = 'poet' | 'book'; +export const isDetail = (entity: string): entity is DetailKind => entity === 'poet' || entity === 'book'; +const POET = ['نام', 'تخلص', 'پیدائش', 'وفات']; +const hijri = (ce: number | null) => (ce ? Math.round(((ce - 622) * 33) / 32) : null); // as export_divan.py + +export function parseDetails(text: string) { + const lines = text.replace(/\r\n?/g, '\n').split('\n'), out: Record = {}; + const intro = lines.findIndex((l) => /^تعارف\s*:/.test(l)); + for (const l of intro < 0 ? lines : lines.slice(0, intro)) { + const m = l.match(/^([^:]+):\s*(.*)$/); + if (m) out[m[1].trim()] = m[2].trim(); + } + if (intro >= 0) out['تعارف'] = [lines[intro].replace(/^تعارف\s*:\s*/, ''), ...lines.slice(intro + 1)].join('\n').trim(); + return out; +} + +export const detailsText = (kind: DetailKind, d: Record) => kind === 'book' ? `عنوان: ${d['عنوان'] ?? ''}` + : `${POET.map((l) => `${l}: ${d[l] ?? ''}`).join('\n')}\nتعارف:\n${d['تعارف'] ?? ''}`; + +// what the details are now (the site's values, which follow the latest published version) and that version +export async function currentDetails(kind: DetailKind, id: number) { + const row = kind === 'poet' + ? (await pool.query('SELECT id, url, nickname AS title, name, nickname, description, birth_year_ce, death_year_ce FROM poets WHERE id = $1', [id])).rows[0] + : (await pool.query('SELECT id, url, title FROM categories WHERE id = $1', [id])).rows[0]; + if (!row) return null; + const last = (await pool.query( + `SELECT max(version) AS v FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published'`, [kind, id])).rows[0]; + const content = detailsText(kind, kind === 'book' ? { عنوان: row.title } : { + نام: row.name, تخلص: row.nickname, پیدائش: row.birth_year_ce ?? '', وفات: row.death_year_ce ?? '', تعارف: row.description ?? '' }); + return { details: { kind, id: row.id, url: row.url, title: row.title }, version: Number(last?.v ?? 0), content }; +} + +const year = (s: string | undefined) => (s ? Number(s.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x6f0))) : null); + +// what is wrong with the text, or null +export function checkDetails(kind: DetailKind, text: string) { + const d = parseDetails(text); + if (kind === 'book') { + if (!d['عنوان']) return 'عنوان ضروری ہے'; + return d['عنوان'].length > 200 ? 'عنوان بہت لمبا ہے' : null; + } + if (!d['نام'] || !d['تخلص']) return 'نام اور تخلص ضروری ہیں'; + if (d['نام'].length > 100 || d['تخلص'].length > 100) return 'نام بہت لمبا ہے'; + const [born, died] = [year(d['پیدائش']), year(d['وفات'])]; + for (const y of [born, died]) if (y !== null && !(Number.isInteger(y) && y >= 500 && y <= 2100)) return 'سال عیسوی میں لکھیں، جیسے ۱۸۷۷'; + if (born && died && died < born) return 'وفات کا سال پیدائش سے پہلے ہے'; + return (d['تعارف'] ?? '').length > 5000 ? 'تعارف بہت لمبا ہے' : null; +} + +export async function applyDetails(client: PoolClient, kind: DetailKind, id: number, text: string) { + const d = parseDetails(text); + if (kind === 'book') return void (await client.query('UPDATE categories SET title = $2 WHERE id = $1', [id, d['عنوان']])); + const [born, died] = [year(d['پیدائش']), year(d['وفات'])]; + await client.query(`UPDATE poets SET name = $2, nickname = $3, description = $4, birth_year_ce = $5, death_year_ce = $6, + birth_year_ah = $7, death_year_ah = $8 WHERE id = $1`, + [id, d['نام'], d['تخلص'], d['تعارف'] || null, born, died, hijri(born), hijri(died)]); + // the poet's top level carries their name (as the export makes it) + await client.query('UPDATE categories SET title = $2 WHERE poet_id = $1 AND parent_id IS NULL', [id, d['نام']]); +} + +export async function writeDetails(dataDir: string, kind: DetailKind, url: string, text: string) { + const path = join(dataDir, 'divan', url.replace(/^\//, '') + '.' + kind); + await mkdir(dirname(path), { recursive: true }); + const d = parseDetails(text); + for (const k of ['پیدائش', 'وفات']) if (d[k]) d[k] = String(year(d[k])); // plain digits for the export + await writeFile(path, detailsText(kind, d).trim() + '\n'); + return path; +} diff --git a/api/src/moderation.ts b/api/src/moderation.ts index e1f75dbc..89dd48d8 100644 --- a/api/src/moderation.ts +++ b/api/src/moderation.ts @@ -11,7 +11,9 @@ // If another version was published after the draft started, publishing is refused until the draft is redone. // Arranging a book or section (order.ts, entity 'order') uses the same revisions, steps and publishing; it needs // the separate 'arrange' permission. Tagging (tags.ts, entity 'tags-category' / 'tags-work') likewise, with the 'tags' -// permission. A new e-book's details (ebooks.ts, entity 'ebook') likewise, with the 'ebooks' permission. +// permission. A new e-book's details (ebooks.ts, entity 'ebook') likewise, with the 'ebooks' permission. A poet's details +// (name, pen name, years, intro) and a book or section's title (details.ts, entities 'poet' and 'book') likewise, with +// the edit permission on poets or books (#32). // GET /api/mod/can?poem= what the reader may do on a work // GET /api/mod/queue my drafts, drafts to review, drafts to publish // GET /api/mod/work/:id a work's current text and its history @@ -20,6 +22,8 @@ // GET /api/mod/order/:id a book/section's current order and its history // POST /api/mod/order/:id/draft start (or reopen) my arrangement draft // GET /api/mod/tags/:kind/:id a book/section's (kind category) or work's tags and their history +// GET /api/mod/details/:kind/:id a poet's details (kind poet) or a book/section's title (kind book), history +// POST /api/mod/details/:kind/:id/draft start (or reopen) my draft of them // POST /api/mod/tags/:kind/:id/draft start (or reopen) my tagging draft // POST /api/mod/revisions/:id/save {content, summary} // POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment} @@ -40,6 +44,7 @@ import { currentOrder, checkOrder, applyOrder, writeOrder, orderSlugs } from './ import { currentTags, checkTags, applyTags, writeTags, parseTags, tagsText, type Tag, type TagTarget } from './tags.ts'; import { currentEbook, checkEbook, applyEbook, writeEbook, mayUpload } from './ebooks.ts'; import { publicName } from './auth.ts'; +import { isDetail, currentDetails, checkDetails, applyDetails, writeDetails, parseDetails, detailsText, type DetailKind } from './details.ts'; const dataDir = () => process.env.DIVAN_DATA_DIR ?? new URL('../../../divan-data', import.meta.url).pathname; const isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role); @@ -50,11 +55,13 @@ const mayTag = (u: any, kind: TagTarget, id: number) => can(u, 'edit', 'tags', k // may change what this revision changes (a work's text, a book/section's order, tags); reviewing needs L1 or admin const mayEbook = async (u: any, ebookId: number) => mayUpload(u, (await pool.query('SELECT poet_id FROM ebooks WHERE id = $1', [ebookId])).rows[0]?.poet_id ?? 0); -const mayChange = (u: any, r: { entity: string; entity_id: number }) => +const mayDetails = (u: any, kind: DetailKind, id: number) => + kind === 'poet' ? can(u, 'edit', 'poets', { poetId: id }) : can(u, 'edit', 'books', { categoryId: id }); +const mayChange = (u: any, r: { entity: string; entity_id: number }) => isDetail(r.entity) ? mayDetails(u, r.entity, r.entity_id) : r.entity === 'order' ? mayArrange(u, r.entity_id) : r.entity.startsWith('tags-') ? mayTag(u, tagKind(r), r.entity_id) : r.entity === 'ebook' ? mayEbook(u, 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', 'ebook')`; +const ENTITIES = `('work', 'order', 'tags-category', 'tags-work', 'ebook', 'poet', 'book')`; const mayReview = async (u: any, r: { entity: string; entity_id: number }) => ['mod-l1', 'admin'].includes(u?.role) && (await mayChange(u, r)); const OPEN = ['draft', 'returned']; // a draft whose text is still the text it started from is not shown anywhere (opening the editor is not a change) @@ -85,10 +92,11 @@ async function current(poemId: number) { // what a revision changes: a work, or a book/section's order (work_title/work_url name either) const TARGET = `LEFT JOIN poems p ON r.entity IN ('work', 'tags-work') AND p.id = r.entity_id - LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id - LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id`; + LEFT JOIN categories c ON r.entity IN ('order', 'tags-category', 'book') AND c.id = r.entity_id + LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id + LEFT JOIN poets pt ON r.entity = 'poet' AND pt.id = r.entity_id`; // (not named URL: that would hide the global URL used for the default divan-data folder) -const TARGET_TITLE = `coalesce(p.title, c.title, b.title)`, TARGET_URL = `coalesce(p.url, c.url, '/ebook/' || b.id)`; +const TARGET_TITLE = `coalesce(p.title, c.title, b.title, pt.nickname)`, TARGET_URL = `coalesce(p.url, c.url, '/ebook/' || b.id, pt.url)`; const TARGET_COLS = `${TARGET_TITLE} AS work_title, ${TARGET_URL} AS work_url`; async function revision(id: number) { @@ -115,11 +123,13 @@ async function publish(r: any, u: any, comment?: string) { if (cur.version !== r.base_version) throw Object.assign(new Error('اس دوران اس کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 }); // the section's contents may have changed since (a new work from the sync): the arrangement must be redone + const details = isDetail(r.entity); const stale = order ? await checkOrder(r.entity_id, r.content) : tags ? await checkTags(tagKind(r), r.entity_id, r.content) - : ebook ? checkEbook(r.content) : null; + : ebook ? checkEbook(r.content) : details ? checkDetails(r.entity, r.content) : null; if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 }); - const doc = order || tags || ebook ? null : parse(r.content), verses = doc ? toVerses(doc) : []; - const title = 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}` + const doc = order || tags || ebook || details ? null : parse(r.content), verses = doc ? toVerses(doc) : []; + const title = 'details' in cur ? `${r.entity === 'poet' ? 'شاعر' : 'عنوان'}: ${parseDetails(r.content)[r.entity === 'poet' ? 'تخلص' : 'عنوان']}` + : 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}` : 'ebook' in cur ? `ای بک: ${cur.ebook.poet}، ${cur.ebook.title}` : doc!.meta['عنوان'] || cur.poem.title; const version = cur.version + 1, at = new Date().toISOString(); // who did it, by public name (divan-data is public: never email addresses) @@ -128,7 +138,8 @@ async function publish(r: any, u: any, comment?: string) { const who = (p: any) => p && { id: Number(p.id), name: publicName(p) }; const credits = { by: who(author)?.name ?? 'موڈریٹر', reviewedBy: who(reviewer)?.name ?? null, publishedBy: publicName(u) }; // divan-data first (the published record, committed to git), then the site's database - const files = 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)] + const files = 'details' in cur ? [await writeDetails(dataDir(), r.entity, cur.details.url, r.content)] + : 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)] : 'target' in cur ? [await writeTags(dataDir(), cur.target.url, r.content)] : 'ebook' in cur ? [await writeEbook(dataDir(), cur.ebook, r.content)] : await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) }); @@ -144,6 +155,7 @@ async function publish(r: any, u: any, comment?: string) { if (order) await applyOrder(client, r.entity_id, r.content); else if (tags) await applyTags(client, tagKind(r), r.entity_id, r.content); else if (ebook) await applyEbook(client, r.entity_id, r.content); + else if (details) await applyDetails(client, r.entity, r.entity_id, r.content); else { await client.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1', [r.entity_id, title, normalise([title, ...verses.map((v) => v.Text)].join(' '))]); @@ -166,9 +178,10 @@ async function publish(r: any, u: any, comment?: string) { // what an entity is now (its latest published version), for any kind of revision const currentOf = (entity: string, id: number): Promise => entity === 'order' ? currentOrder(id) - : entity.startsWith('tags-') ? currentTags(tagKind({ entity }), id) : entity === 'ebook' ? currentEbook(id) : current(id); + : entity.startsWith('tags-') ? currentTags(tagKind({ entity }), id) : entity === 'ebook' ? currentEbook(id) + : isDetail(entity) ? currentDetails(entity, id) : current(id); // the kinds of change with versions to compare and bring back (an e-book's details are a one-off) -const VERSIONED = ['work', 'order', 'tags-work', 'tags-category']; +const VERSIONED = ['work', 'order', 'tags-work', 'tags-category', 'poet', 'book']; // a published version's content; version 0 is what the first Divan draft started from (the Wikisource text) async function versionText(entity: string, id: number, v: number, cur: { version: number; content: string }) { if (v === cur.version) return cur.content; @@ -187,7 +200,8 @@ export function moderationRoutes(app: FastifyInstance) { if (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false, tags: false, ebooks: false }; if (categoryId) { const poetId = (await pool.query('SELECT poet_id FROM categories WHERE id = $1', [categoryId])).rows[0]?.poet_id ?? 0; - return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId), ebooks: await mayUpload(u, poetId) }; + return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId), ebooks: await mayUpload(u, poetId), + book: await mayDetails(u, 'book', categoryId), poet: await mayDetails(u, 'poet', poetId) }; } 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) }; @@ -299,6 +313,34 @@ export function moderationRoutes(app: FastifyInstance) { return { id: Number(rows[0].id) }; }); + app.get<{ Params: { kind: string; id: string } }>('/api/mod/details/:kind/:id', async (req, reply) => { + const u = await moderator(req, reply); if (!u) return; + const { kind } = req.params, cur = isDetail(kind) ? await currentDetails(kind, Number(req.params.id) || 0) : null; + 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`, [kind, cur.details.id]); + return { kind, target: cur.details, version: cur.version, content: cur.content, fields: parseDetails(cur.content), + history: rows.map((r) => ({ ...r, id: Number(r.id) })), may: { edit: await mayDetails(u, kind as DetailKind, cur.details.id) } }; + }); + + app.post<{ Params: { kind: string; id: string } }>('/api/mod/details/:kind/:id/draft', async (req, reply) => { + const u = await moderator(req, reply); if (!u) return; + const { kind } = req.params, id = Number(req.params.id) || 0; + if (!isDetail(kind)) return reply.code(404).send({ error: 'نہیں ملا' }); + if (!(await mayDetails(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`, [kind, id, u.id])).rows[0]; + if (open) return { id: Number(open.id) }; + const cur = await currentDetails(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`, [kind, id, cur.version, cur.content, u.id, u.email]); + await event(rows[0].id, u, 'created'); + return { id: Number(rows[0].id) }; + }); + app.get<{ Params: { id: string } }>('/api/mod/revisions/:id', async (req, reply) => { const u = await moderator(req, reply); if (!u) return; const r = await revision(Number(req.params.id) || 0); @@ -318,8 +360,9 @@ export function moderationRoutes(app: FastifyInstance) { if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' }); if (!(await actions(u, r)).save) return reply.code(403).send({ error: 'یہ مسودہ اب محفوظ نہیں کیا جا سکتا' }); const content = String(req.body?.content ?? '').replace(/\r\n?/g, '\n'); - if (r.entity === 'order' || r.entity.startsWith('tags-') || r.entity === 'ebook') { + if (r.entity === 'order' || r.entity.startsWith('tags-') || r.entity === 'ebook' || isDetail(r.entity)) { const bad = r.entity === 'order' ? await checkOrder(r.entity_id, content) : r.entity === 'ebook' ? checkEbook(content) + : isDetail(r.entity) ? checkDetails(r.entity, content) : await checkTags(tagKind(r), r.entity_id, content); if (bad) return reply.code(400).send({ error: bad }); } else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' }); @@ -327,7 +370,7 @@ export function moderationRoutes(app: FastifyInstance) { // no change from the text it started from (compared as Divan text, so layout-only differences don't count): // a plain draft is dropped rather than kept const norm = (t: string) => (r.entity === 'order' ? orderSlugs(t).join('\n') : r.entity.startsWith('tags-') ? tagsText(parseTags(t) as Tag[]) - : r.entity === 'ebook' ? t.trim() : toText(parse(t))); + : r.entity === 'ebook' ? t.trim() : isDetail(r.entity) ? detailsText(r.entity, parseDetails(t)) : toText(parse(t))); const same = norm(content) === norm(r.base_content); if (same && r.status === 'draft') { await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]); @@ -396,7 +439,7 @@ export function moderationRoutes(app: FastifyInstance) { const a = pick(req.query.a, Math.max(0, cur.version - 1)), b = pick(req.query.b, cur.version); const [ta, tb] = await Promise.all([a, b].map((v) => Number.isInteger(v) && v >= 0 && v <= cur.version ? versionText(entity, id, v, cur) : undefined)); if (ta === undefined || tb === undefined) return reply.code(404).send({ error: 'یہ ورژن نہیں ملا' }); - const target = cur.poem ?? cur.cat ?? cur.target, diff = diffLines(ta, tb); + const target = cur.poem ?? cur.cat ?? cur.target ?? cur.details, diff = diffLines(ta, tb); return { entity, id, title: target.title, url: target.url, version: cur.version, a, b, diff, changes: changed(diff), may: { revert: await mayChange(u, { entity, entity_id: id }) } }; }); diff --git a/api/src/order.test.ts b/api/src/order.test.ts index 969a531a..41c00171 100644 --- a/api/src/order.test.ts +++ b/api/src/order.test.ts @@ -36,10 +36,10 @@ test('arranging: its own permission; L2 reorders, L1 approves, admin publishes t const grant = (m: any, actions: string[]) => pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'category', $2, '{works}', $3)`, [m.id, cat.id, actions]); await grant(l2, ['edit']); - assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false, ebooks: false }, 'editing is not arranging'); + assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false, ebooks: false, book: false, poet: false }, 'editing is not arranging'); assert.equal((await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).statusCode, 403); await grant(l2, ['arrange']); await grant(l1, ['arrange']); - assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true, tags: false, ebooks: false }); + assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true, tags: false, ebooks: false, book: false, poet: false }); const { id } = (await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).json(); const cur = (await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json(); diff --git a/web/src/pages/[...path].astro b/web/src/pages/[...path].astro index 19a3c05a..aaef5166 100644 --- a/web/src/pages/[...path].astro +++ b/web/src/pages/[...path].astro @@ -76,7 +76,8 @@ const label = (on: unknown) => (on ? 'محفوظ شدہ' : 'محفوظ کریں' // moderators who may edit this work get edit and history links const modCan = token && poemId && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') ? (await asUser(token, `/api/mod/can?poem=${poemId}`)).data : { edit: false }; -// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41) +// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41); with the edit permission +// on poets or books, "تفصیل" (a poet's name, years, intro) or "عنوان" (a book or section's title) (#32) const catCan = token && page.type !== 'poem' && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') ? (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data : {}; const arrange = catCan.arrange, tagCan = page.type === 'poem' ? modCan.tags : catCan.tags; @@ -115,16 +116,16 @@ import TagsBox from '../components/TagsBox.astro'; <>

{page.poet.name}

{arrange && <>
- }{tagCan && <>
}
+ }{tagCan && <>
}{catCan.poet && <>
}

{years(page.poet)}

- {page.poet.description &&
{page.poet.description}
} + {page.poet.description &&
{page.poet.description.split(/\n\s*\n/).map((p: string) =>

{p}

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

{page.category.title}

{arrange && <>
- }{tagCan && <>
}
+ }{tagCan && <>
}{catCan.book && <>
} )} diff --git a/web/src/pages/help.astro b/web/src/pages/help.astro index 7b6ff987..1d7ee2c7 100644 --- a/web/src/pages/help.astro +++ b/web/src/pages/help.astro @@ -62,6 +62,9 @@ const modParts: [any, string, string[]][] = [ 'ہر کلام، ترتیب اور ٹیگ کا تاریخچہ: ہر ورژن، کس نے لکھا، کس نے منظور اور شائع کیا۔ «فرق» سے ہر ورژن کی تبدیلی، «موازنہ کریں» سے کوئی دو ورژن۔', 'کوئی پرانا ورژن «واپس لائیں» سے مسودہ بن جاتا ہے، اور عام ترمیم کی طرح جائزے اور اشاعت سے گزرتا ہے۔', ]], + ['user', 'شاعر اور کتابیں', [ + 'شاعر کے صفحے پر «تفصیل»: نام، تخلص، پیدائش اور وفات کے سال (عیسوی) اور تعارف۔ کتاب یا باب کے صفحے پر «عنوان»۔ یہ بھی مسودہ بن کر جائزے اور اشاعت سے گزرتے ہیں، اور ان کا بھی تاریخچہ رہتا ہے۔', + ]], ['arrange', 'ترتیب', [ 'شاعر یا کتاب کے صفحے پر «ترتیب دیں»: کلام، باب اور کتابیں اوپر نیچے کریں، یا کلام ایک حصے سے دوسرے میں لے جائیں۔ اس کی اجازت ترمیم سے الگ ہے۔', ]], diff --git a/web/src/pages/mod/compare/[entity]/[id].astro b/web/src/pages/mod/compare/[entity]/[id].astro index dbccbc6d..6b286c74 100644 --- a/web/src/pages/mod/compare/[entity]/[id].astro +++ b/web/src/pages/mod/compare/[entity]/[id].astro @@ -19,7 +19,8 @@ const res = await asUser(token, `/api/mod/compare/${entity}/${id}${Astro.url.sea if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status }); const { title, url, version, a, b, diff, changes, may } = res.data; const label = (v: number) => (v ? `ورژن ${ud(v)}` : 'ویکی ماخذ کا متن'); -const HISTORY: Record = { work: `/mod/work/${id}`, order: `/mod/order/${id}`, 'tags-work': `/mod/tags/work/${id}`, 'tags-category': `/mod/tags/category/${id}` }; +const HISTORY: Record = { work: `/mod/work/${id}`, order: `/mod/order/${id}`, 'tags-work': `/mod/tags/work/${id}`, 'tags-category': `/mod/tags/category/${id}`, + poet: `/mod/details/poet/${id}`, book: `/mod/details/book/${id}` }; const older = Math.min(a, b); --- diff --git a/web/src/pages/mod/details/[kind]/[id].astro b/web/src/pages/mod/details/[kind]/[id].astro new file mode 100644 index 00000000..275e741e --- /dev/null +++ b/web/src/pages/mod/details/[kind]/[id].astro @@ -0,0 +1,39 @@ +--- +// A poet's details or a book/section's title (#32): what they are now, their versions and drafts; "ترمیم کریں" starts a +// draft (also reached by POST from the poet or book page) +import Base from '../../../../layouts/Base.astro'; +import ModNav from '../../../../components/ModNav.astro'; +import History from '../../../../components/History.astro'; +import { asUser, COOKIE } from '../../../../lib/auth'; +import { ud } from '../../../../lib/urdu'; +import Icon from '../../../../components/Icon.astro'; + +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/details/${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/details/${kind}/${id}`); +if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status }); +const { target, version, fields, history, may } = res.data; +const poet = kind === 'poet'; +--- + +

{poet ? 'شاعر' : 'عنوان'}: {target.title}

+ +

صفحہ · {version ? `موجودہ: ورژن ${ud(version)}` : 'ویکی ماخذ اور ویکیپیڈیا سے (ابھی دیوان کا کوئی ورژن نہیں)'}

+
+ {poet ? <> +
نام
{fields['نام']}
تخلص
{fields['تخلص']}
+
پیدائش
{fields['پیدائش'] ? ud(fields['پیدائش']) : '—'}
وفات
{fields['وفات'] ? ud(fields['وفات']) : '—'}
+ : <>
عنوان
{fields['عنوان']}
} +
+ {poet && fields['تعارف'] &&
{fields['تعارف'].split(/\n\s*\n/).map((p: string) =>

{p}

)}
} + {error && } + {may.edit &&
} + + diff --git a/web/src/pages/mod/index.astro b/web/src/pages/mod/index.astro index 76ca1906..37a07531 100644 --- a/web/src/pages/mod/index.astro +++ b/web/src/pages/mod/index.astro @@ -17,9 +17,9 @@ const queue = showLog ? null : (await asUser(token, '/api/mod/queue')).data; const f = Object.fromEntries(['who', 'kind', 'action'].map((k) => [k, Astro.url.searchParams.get(k) ?? ''])); const log = showLog ? (await asUser(token, `/api/mod/log?${new URLSearchParams(f)}`)).data : null; // what kind of change a row is (a work's text needs no label) -const KIND: Record = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ' }; +const KIND: Record = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ', poet: 'شاعر: ', book: 'عنوان: ' }; // each kind of change has its icon; each event its tone on the timeline -const ICON: Record = { order: 'arrange', 'tags-category': 'tag', 'tags-work': 'tag', ebook: 'upload' }; +const ICON: Record = { order: 'arrange', 'tags-category': 'tag', 'tags-work': 'tag', ebook: 'upload', poet: 'user', book: 'edit' }; const TONE: Record = { published: 'ok', approved: 'action', submitted: 'accent', returned: 'danger', rejected: 'danger' }; const groups = queue ? [ ['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'], @@ -59,7 +59,7 @@ const groups = queue ? [ + + {details === 'book' ? ( + + ) : <> + + +
+ + +
+ + } +
+ + +
+ + ) : may.save && ebook ? (