diff --git a/api/src/moderation.test.ts b/api/src/moderation.test.ts index 56157dea..c0469b5b 100644 --- a/api/src/moderation.test.ts +++ b/api/src/moderation.test.ts @@ -124,6 +124,25 @@ test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permi const hist = (await call('GET', `/api/mod/work/${poem.id}`, undefined, l1.token)).json(); assert.deepEqual(hist.history.filter((h: any) => h.version).map((h: any) => h.version), [2, 1]); assert.ok((await call('GET', '/api/mod/log', undefined, l2.token)).json().entries.length >= 10); + + // version history (#51): compare any two versions, bring an old one back through the pipeline, filter the log + const cmp = (await call('GET', `/api/mod/compare/work/${poem.id}`, undefined, l2.token)).json(); + assert.deepEqual([cmp.a, cmp.b, cmp.changes], [1, 2, 2], 'by default the latest version against the one before'); + assert.ok(cmp.diff.some((d: any) => d.op === '+' && d.text.includes('(ترمیم دوم)'))); + const v0 = (await call('GET', `/api/mod/compare/work/${poem.id}?a=0&b=2`, undefined, l2.token)).json(); + assert.ok(v0.diff.some((d: any) => d.op === '-' && d.text === misra), 'version 0 is the Wikisource text'); + assert.equal((await call('GET', `/api/mod/compare/work/${poem.id}?a=7`, undefined, l2.token)).statusCode, 404); + assert.equal((await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 0 }, other.token)).statusCode, 403, 'outside the grant'); + assert.equal((await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 2 }, l2.token)).statusCode, 400, 'already the current version'); + const back = (await call('POST', `/api/mod/revert/work/${poem.id}`, { version: 0 }, l2.token)).json().id; + const br = (await call('GET', `/api/mod/revisions/${back}`, undefined, l2.token)).json(); + assert.deepEqual([br.revision.status, br.revision.base_version, br.revision.summary], ['draft', 2, 'ویکی ماخذ کا متن واپس لایا']); + assert.ok(br.diff.some((d: any) => d.op === '+' && d.text === misra), 'the draft brings the old line back'); + assert.deepEqual(br.events.map((e: any) => e.action), ['created', 'reverted']); + assert.deepEqual((await call('POST', `/api/mod/revisions/${back}/submit`, {}, l2.token)).json().status, 'submitted', 'then reviewed like any edit'); + const mine = (await call('GET', `/api/mod/log?who=l2-${run}&action=reverted`, undefined, l1.token)).json().entries; + assert.deepEqual(mine.map((e: any) => e.revision), [back], 'log filtered by person and step'); + assert.equal((await call('GET', `/api/mod/log?who=l2-${run}&kind=order`, undefined, l1.token)).json().entries.length, 0); } finally { // restore the work and remove the test people and their revisions await pool.query('DELETE FROM verses WHERE poem_id = $1', [poem.id]); diff --git a/api/src/moderation.ts b/api/src/moderation.ts index 89d4408f..e1f75dbc 100644 --- a/api/src/moderation.ts +++ b/api/src/moderation.ts @@ -23,7 +23,10 @@ // POST /api/mod/tags/:kind/:id/draft start (or reopen) my tagging draft // POST /api/mod/revisions/:id/save {content, summary} // POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment} -// GET /api/mod/log?page= who did what, newest first +// GET /api/mod/log?page=&who=&kind=&action= who did what, newest first; filtered by person, kind of change, step +// GET /api/mod/compare/:entity/:id?a=&b= two published versions of a work, order or tags and their diff (0 = the +// Wikisource text before Divan's first version) +// POST /api/mod/revert/:entity/:id {version}: a draft that brings back that version, through the pipeline import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { pool } from './db.ts'; import { sessionUser } from './auth.ts'; @@ -107,8 +110,7 @@ async function actions(u: any, r: any) { async function publish(r: any, u: any, comment?: string) { const order = r.entity === 'order', tags = r.entity.startsWith('tags-'), ebook = r.entity === 'ebook'; - const cur = order ? await currentOrder(r.entity_id) : tags ? await currentTags(tagKind(r), r.entity_id) - : ebook ? await currentEbook(r.entity_id) : await current(r.entity_id); + const cur = await currentOf(r.entity, r.entity_id); if (!cur) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 }); if (cur.version !== r.base_version) throw Object.assign(new Error('اس دوران اس کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 }); @@ -162,6 +164,20 @@ async function publish(r: any, u: any, comment?: string) { return version; } +// 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); +// 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']; +// 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; + const { rows } = await pool.query(v + ? `SELECT content FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published' AND version = $3` + : `SELECT base_content AS content FROM revisions WHERE entity = $1 AND entity_id = $2 AND base_version = 0 AND $3 = 0 ORDER BY id LIMIT 1`, [entity, id, v]); + return rows[0]?.content as string | undefined; +} + const identityOf = (p: any) => identity({ id: Number(p.id), name: publicName(p) }); export function moderationRoutes(app: FastifyInstance) { @@ -358,14 +374,53 @@ export function moderationRoutes(app: FastifyInstance) { } }); - app.get<{ Querystring: { page?: string } }>('/api/mod/log', async (req, reply) => { + app.get<{ Querystring: { page?: string; who?: string; kind?: string; action?: string } }>('/api/mod/log', async (req, reply) => { const u = await moderator(req, reply); if (!u) return; - const page = Math.max(1, Number(req.query.page) || 1); + const page = Math.max(1, Number(req.query.page) || 1), { who, kind, action } = req.query; + // filters: a person (part of their address), a kind of change (tags covers both), a step; empty = all const { rows } = await pool.query( `SELECT e.at, e.actor_email, e.action, e.comment, r.id AS revision, r.entity, r.version, ${TARGET_TITLE} AS title, ${TARGET_URL} AS url FROM revision_events e JOIN revisions r ON r.id = e.revision_id ${TARGET} WHERE r.entity IN ${ENTITIES} AND ${CHANGED} - ORDER BY e.at DESC, e.id DESC LIMIT 50 OFFSET ${(page - 1) * 50}`); + AND ($1 = '' OR e.actor_email ILIKE '%' || $1 || '%') AND ($2 = '' OR r.entity = $2 OR r.entity LIKE $2 || '-%') AND ($3 = '' OR e.action = $3) + ORDER BY e.at DESC, e.id DESC LIMIT 50 OFFSET ${(page - 1) * 50}`, [who?.trim() ?? '', kind ?? '', action ?? '']); return { page, entries: rows.map((r) => ({ ...r, revision: Number(r.revision) })) }; }); + + app.get<{ Params: { entity: string; id: string }; Querystring: { a?: string; b?: string } }>('/api/mod/compare/:entity/:id', async (req, reply) => { + const u = await moderator(req, reply); if (!u) return; + const { entity } = req.params, id = Number(req.params.id) || 0; + const cur = VERSIONED.includes(entity) ? await currentOf(entity, id) : null; + if (!cur) return reply.code(404).send({ error: 'نہیں ملا' }); + const pick = (q: string | undefined, d: number) => (q === undefined || q === '' ? d : Number(q)); + 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); + 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 }) } }; + }); + + app.post<{ Params: { entity: string; id: string }; Body: { version?: number } }>('/api/mod/revert/:entity/:id', async (req, reply) => { + const u = await moderator(req, reply); if (!u) return; + const { entity } = req.params, id = Number(req.params.id) || 0, v = Number(req.body?.version); + const cur = VERSIONED.includes(entity) ? await currentOf(entity, id) : null; + if (!cur) return reply.code(404).send({ error: 'نہیں ملا' }); + if (!(await mayChange(u, { entity, entity_id: id }))) return reply.code(403).send({ error: 'اس میں تبدیلی کی اجازت نہیں' }); + const content = Number.isInteger(v) && v >= 0 && v < cur.version ? await versionText(entity, id, v, cur) : undefined; + if (content === undefined) return reply.code(400).send({ error: 'یہ ورژن واپس نہیں لایا جا سکتا' }); + if (content === cur.content) return reply.code(400).send({ error: 'یہ ورژن موجودہ ورژن جیسا ہی ہے' }); + // the reverted text goes into my open draft (or a new one) and then through review like any edit + const summary = v ? `ورژن ${v} واپس لایا` : 'ویکی ماخذ کا متن واپس لایا'; + 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`, [entity, id, u.id])).rows[0]; + const revId = open ? Number(open.id) : Number((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`, [entity, id, cur.version, cur.content, u.id, u.email])).rows[0].id); + if (!open) await event(revId, u, 'created'); + await pool.query(`UPDATE revisions SET content = $2, summary = $3, base_version = $4, base_content = $5, updated_at = now() WHERE id = $1`, + [revId, content, summary, cur.version, cur.content]); + await event(revId, u, 'reverted', summary); + return { id: revId }; + }); } diff --git a/web/src/components/History.astro b/web/src/components/History.astro new file mode 100644 index 00000000..8356b86f --- /dev/null +++ b/web/src/components/History.astro @@ -0,0 +1,36 @@ +--- +// A work's, arrangement's or tags' versions (#51): compare any two published versions, see what each one changed +// (فرق), and from there bring an old one back. Version 0 is the Wikisource text before Divan's first version. +import { STATUS, when } from '../lib/mod'; +import { ud } from '../lib/urdu'; +interface Props { entity: string; id: number; history: any[]; version: number; empty: string } +const { entity, id, history, version, empty } = Astro.props; +const base = `/mod/compare/${entity}/${id}`; +const versions = Array.from({ length: version + 1 }, (_, v) => v); +const label = (v: number) => (v ? `ورژن ${ud(v)}` : 'ویکی ماخذ'); +--- +{version > 0 && ( +
+ + + +
+)} + + + + {history.map((h: any) => ( + + + + + + + + + + + ))} + +
ورژنحالتخلاصہلکھنے والےجائزہاشاعتتاریخ
{h.version ? ud(h.version) : '—'}{STATUS[h.status]}{h.summary}{h.author_email}{h.reviewer_email ?? ''}{h.publisher_email ?? ''}{when(h.published_at ?? h.created_at)}{h.version && فرق}
+{history.length === 0 &&

{empty}

} diff --git a/web/src/lib/mod.ts b/web/src/lib/mod.ts index 9bc70a4c..d486bee0 100644 --- a/web/src/lib/mod.ts +++ b/web/src/lib/mod.ts @@ -4,6 +4,6 @@ export const STATUS: Record = { }; export const EVENT: Record = { created: 'مسودہ بنایا', saved: 'محفوظ کیا', submitted: 'جائزے کے لیے بھیجا', approved: 'منظور کیا', returned: 'واپس بھیجا', - rejected: 'مسترد کیا', published: 'شائع کیا', + rejected: 'مسترد کیا', published: 'شائع کیا', reverted: 'پرانا ورژن واپس لایا', }; export const when = (d: string) => new Date(d).toISOString().slice(0, 16).replace('T', ' '); diff --git a/web/src/pages/mod/compare/[entity]/[id].astro b/web/src/pages/mod/compare/[entity]/[id].astro new file mode 100644 index 00000000..dbccbc6d --- /dev/null +++ b/web/src/pages/mod/compare/[entity]/[id].astro @@ -0,0 +1,43 @@ +--- +// Two published versions side by side as a diff (#51); "یہ ورژن واپس لائیں" makes a draft of the older one, which then +// goes through review like any edit +import Base from '../../../../layouts/Base.astro'; +import ModNav from '../../../../components/ModNav.astro'; +import { asUser, COOKIE } from '../../../../lib/auth'; +import { ud } from '../../../../lib/urdu'; + +const me = Astro.locals.user; +if (!me) return Astro.redirect(`/signin?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`); +const token = Astro.cookies.get(COOKIE)!.value, { entity, id } = Astro.params; +let error = ''; +if (Astro.request.method === 'POST') { + const r = await asUser(token, `/api/mod/revert/${entity}/${id}`, { version: Number((await Astro.request.formData()).get('version')) }); + if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`); + error = r.data.error ?? 'کچھ غلط ہو گیا'; +} +const res = await asUser(token, `/api/mod/compare/${entity}/${id}${Astro.url.search}`); +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 older = Math.min(a, b); +--- + +

{title}

+ +

صفحہ · تاریخچہ · موجودہ: {label(version)}

+

− {label(a)} + {label(b)} · {ud(changes)} سطریں بدلیں

+ {error && } + {changes === 0 ?

دونوں ورژن ایک جیسے ہیں۔

: ( +
+ {diff.map((d: any) => d.op === '=' ? null :
{d.op === '+' ? '+' : '−'}{d.text || ' '}
)} +
+ )} + {may.revert && older < version && ( +
+ + +

ایک مسودہ بنے گا جو عام ترمیم کی طرح جائزے اور اشاعت سے گزرے گا۔

+
+ )} + diff --git a/web/src/pages/mod/index.astro b/web/src/pages/mod/index.astro index 48a486d1..76ca1906 100644 --- a/web/src/pages/mod/index.astro +++ b/web/src/pages/mod/index.astro @@ -13,7 +13,9 @@ if (!me) return Astro.redirect('/signin?next=/mod'); if (!['mod-l2', 'mod-l1', 'admin'].includes(me.role)) return new Response('صرف موڈریٹرز کے لیے', { status: 403 }); const token = Astro.cookies.get(COOKIE)!.value, showLog = Astro.url.searchParams.has('log'); const queue = showLog ? null : (await asUser(token, '/api/mod/queue')).data; -const log = showLog ? (await asUser(token, '/api/mod/log')).data : null; +// the log filtered by person, kind of change and step (#51) +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: 'ای بک: ' }; // each kind of change has its icon; each event its tone on the timeline @@ -52,6 +54,21 @@ const groups = queue ? [ )} ))} + {log && ( +
+ + + + + +
+ )} + {log && log.entries.length === 0 &&

اس چھانٹی میں کچھ نہیں۔

} {log && (
    {log.entries.map((e: any) => ( diff --git a/web/src/pages/mod/order/[id].astro b/web/src/pages/mod/order/[id].astro index 423e0a2b..5b182977 100644 --- a/web/src/pages/mod/order/[id].astro +++ b/web/src/pages/mod/order/[id].astro @@ -1,9 +1,9 @@ --- // A book/section's arrangements (#41): its current order, published versions and drafts; "ترتیب دیں" starts a draft import Base from '../../../layouts/Base.astro'; +import History from '../../../components/History.astro'; import ModNav from '../../../components/ModNav.astro'; import { asUser, COOKIE } from '../../../lib/auth'; -import { STATUS, when } from '../../../lib/mod'; import { ud } from '../../../lib/urdu'; const me = Astro.locals.user; @@ -26,21 +26,5 @@ const { section, version, history, count, may } = res.data; {version ? `موجودہ ترتیب: ورژن ${ud(version)}` : 'ویکی ماخذ کی ترتیب (ابھی دیوان کی کوئی ترتیب نہیں)'}

    {error && } {may.arrange && count > 1 &&
    } - - - - {history.map((h: any) => ( - - - - - - - - - - ))} - -
    ورژنحالتخلاصہلکھنے والےجائزہاشاعتتاریخ
    {h.version ? ud(h.version) : '—'}{STATUS[h.status]}{h.summary}{h.author_email}{h.reviewer_email ?? ''}{h.publisher_email ?? ''}{when(h.published_at ?? h.created_at)}
    - {history.length === 0 &&

    ابھی ترتیب میں کوئی تبدیلی نہیں۔

    } + diff --git a/web/src/pages/mod/tags/[kind]/[id].astro b/web/src/pages/mod/tags/[kind]/[id].astro index bc788a61..6f5aa6a9 100644 --- a/web/src/pages/mod/tags/[kind]/[id].astro +++ b/web/src/pages/mod/tags/[kind]/[id].astro @@ -1,10 +1,10 @@ --- // A page's tags (#52): its current tags, published versions and drafts; "ٹیگ لگائیں" starts a draft import Base from '../../../../layouts/Base.astro'; +import History from '../../../../components/History.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; @@ -28,20 +28,5 @@ const current = content.split('\n').filter(Boolean).map((l: string) => { const [ {error && } {may.tags &&
    } - - - - {history.map((h: any) => ( - - - - - - - - - - ))} - -
    ورژنحالتخلاصہلکھنے والےجائزہاشاعتتاریخ
    {h.version ? ud(h.version) : '—'}{STATUS[h.status]}{h.summary}{h.author_email}{h.reviewer_email ?? ''}{h.publisher_email ?? ''}{when(h.published_at ?? h.created_at)}
    + diff --git a/web/src/pages/mod/work/[id].astro b/web/src/pages/mod/work/[id].astro index 33f519dc..8c613ef0 100644 --- a/web/src/pages/mod/work/[id].astro +++ b/web/src/pages/mod/work/[id].astro @@ -1,9 +1,9 @@ --- // A work's versions: published versions and drafts, with who, when, summary and status import Base from '../../../layouts/Base.astro'; +import History from '../../../components/History.astro'; import ModNav from '../../../components/ModNav.astro'; import { asUser, COOKIE } from '../../../lib/auth'; -import { STATUS, when } from '../../../lib/mod'; import { ud } from '../../../lib/urdu'; const me = Astro.locals.user; @@ -22,21 +22,5 @@ const { work, version, history, may } = res.data;

    کلام · {version ? `موجودہ ورژن ${ud(version)}` : 'ویکی ماخذ کا متن (ابھی دیوان کا کوئی ورژن نہیں)'}

    {may.edit &&
    } - - - - {history.map((h: any) => ( - - - - - - - - - - ))} - -
    ورژنحالتخلاصہلکھنے والےجائزہاشاعتتاریخ
    {h.version ? ud(h.version) : '—'}{STATUS[h.status]}{h.summary}{h.author_email}{h.reviewer_email ?? ''}{h.publisher_email ?? ''}{when(h.published_at ?? h.created_at)}
    - {history.length === 0 &&

    ابھی کوئی ترمیم نہیں۔

    } + diff --git a/web/src/styles/global.css b/web/src/styles/global.css index dda64e75..19448e38 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -412,6 +412,10 @@ h1 + .muted { text-align: center; margin-top: 0; } .timeline li { margin: 4px 0; } .mod-h { text-align: center; color: var(--brand); } .inline-form { display: inline; } +.version-tools { display: flex; flex-wrap: wrap; gap: 8px 12px; justify-content: center; align-items: center; margin: 10px 0; } +.add-key, .del-key { font-weight: 700; padding: 0 6px; border-radius: 4px; } +.add-key { background: color-mix(in srgb, var(--ok) 14%, transparent); } +.del-key { background: color-mix(in srgb, var(--danger) 12%, transparent); } /* a page's action row (save, edit, history, tags): spaced as one row of buttons, wrapping on phones */ .center:has(> .save) { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 8px; } .mod-history { font-size: .85rem; margin-inline-start: 6px; }