From 5c8559c8e9b5db7afb50863de02d6091a3975760 Mon Sep 17 00:00:00 2001
From: Anas Rashid
Date: Sat, 10 Oct 2026 00:42:26 +0200
Subject: [PATCH] Version history (#51): compare any two versions of a work,
arrangement or tags (0 = the Wikisource text), bring an old version back as a
draft through the pipeline, filter the moderation log by person, kind and
step; one history table for the three pages
Co-Authored-By: Claude Opus 5.5
---
api/src/moderation.test.ts | 19 ++++++
api/src/moderation.ts | 67 +++++++++++++++++--
web/src/components/History.astro | 36 ++++++++++
web/src/lib/mod.ts | 2 +-
web/src/pages/mod/compare/[entity]/[id].astro | 43 ++++++++++++
web/src/pages/mod/index.astro | 19 +++++-
web/src/pages/mod/order/[id].astro | 20 +-----
web/src/pages/mod/tags/[kind]/[id].astro | 19 +-----
web/src/pages/mod/work/[id].astro | 20 +-----
web/src/styles/global.css | 4 ++
10 files changed, 188 insertions(+), 61 deletions(-)
create mode 100644 web/src/components/History.astro
create mode 100644 web/src/pages/mod/compare/[entity]/[id].astro
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 && {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 && {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 && {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; }
--
2.47.3