- revisions (a work's Divan text, summary, status, author/reviewer/publisher, the text it started from) and revision_events (who did what, with comments): the review thread and the moderation log. - API (api/src/moderation.ts): what a moderator may do on a work, queue, drafts (one open draft per person and work), save, submit, approve, return/reject (reason required), publish; history; log. Rules come from the IAM grants: L2 drafts, an L1 covering the work reviews, an admin publishes; L1 drafts go to the admin, admin drafts publish directly. Publishing numbers the version, writes it to divan-data (owned.ts) and updates the site; a draft based on an older version is refused (409). - Line diff (api/src/diff.ts) against the text a draft started from. - Site: ترمیم کریں / تاریخچہ on works, /mod (queue, activity log), /mod/rev/<id> (editor with the Urdu keyboard and Roman typing, diff, preview, actions, timeline), /mod/work/<id> (versions). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8d82527765
commit
19af91c594
@ -35,6 +35,8 @@ The import upserts, so re-running it after a divan-data sync applies the changes
|
||||
|
||||
**Accounts and admin.** Readers sign up with an email address and password (no email is sent). The first admin is made on the server: sign up on the site, then `npm run make-admin -- you@example.com` in `api/`. Admins manage users at `/admin` (search, password reset on a reader's request, disable, roles, delete) and see every admin action at `/admin/audit`. Moderators (L2 junior, L1 senior) get scoped permissions from admins: a scope (all poets, a poet, a book with everything in it, or one work), content types (poets, books, works, dictionary) and actions (create, edit, delete, arrange); `can()` in `api/src/permissions.ts` is the one check for moderation.
|
||||
|
||||
**Moderation.** Moderators open **ترمیم کریں** on a work they may edit, change its Divan text (see `docs/content-model.md`) with an edit summary and submit it. An L1 moderator covering that work approves, returns (with a reason) or rejects it; an admin publishes. L1 drafts go straight to the admin and an admin's own edits publish directly. Every step is recorded (`/mod`, the activity log, each work's history at `/mod/work/<id>`). Publishing numbers the version, writes it to divan-data's `divan/` folder (`DIVAN_DATA_DIR`, default `../divan-data`) and shows it on the site at once; a draft started before a newer version was published cannot be published.
|
||||
|
||||
## Daily content sync (server)
|
||||
|
||||
`deploy/sync.sh` keeps a server current: it updates a divan-data checkout, fetches new and edited works from Wikisource (incremental, about a minute), rebuilds the export and upserts it into PostgreSQL. The site shows new content immediately. Runs are locked so they never overlap.
|
||||
@ -57,6 +59,7 @@ Settings: `DIVAN_DATA_DIR` (default `/opt/divan-data`, cloned on first run), `DI
|
||||
| `GET /api/search?q=&poet=1,2&page=` | poems containing all words (or a `"quoted phrase"`), Urdu-normalised; exact phrase first; each with the best-matching couplet or paragraph (`snippet`); optionally only some poets/writers; plus `authors` (who the results come from, with counts), and on page 1 `poets` (by name) and `books` (books/chapters by title) |
|
||||
| `GET /api/word?w=` | one word's meanings and pronunciation from the local Wiktionary data (Urdu, Persian, Arabic in that order; English meanings; Urdu equivalents via English when Urdu Wiktionary has none) |
|
||||
| `/api/auth/*` | accounts: sign-up, sign-in (returns a Bearer token), profile, password, delete |
|
||||
| `/api/mod/*` | moderation: what a moderator may do, queue, drafts, save/submit/approve/return/reject/publish, a work's history, activity log |
|
||||
| `/api/library/*` | the signed-in reader's library: toggle poets, works, couplets and words; list with full paths; notes |
|
||||
| `GET /health` | database check |
|
||||
|
||||
|
||||
24
api/src/diff.ts
Normal file
24
api/src/diff.ts
Normal file
@ -0,0 +1,24 @@
|
||||
// Line diff for reviewing revisions of Divan text: each line kept ('='), removed ('-') or added ('+').
|
||||
// ponytail: longest-common-subsequence table, O(lines²); fine for a poem or a chapter (hundreds of lines),
|
||||
// switch to Myers' algorithm if whole books are ever diffed at once.
|
||||
export type DiffLine = { op: '=' | '-' | '+'; text: string };
|
||||
|
||||
export function diffLines(a: string, b: string): DiffLine[] {
|
||||
const x = a.split('\n'), y = b.split('\n');
|
||||
const n = x.length, m = y.length;
|
||||
const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
||||
for (let i = n - 1; i >= 0; i--)
|
||||
for (let j = m - 1; j >= 0; j--) lcs[i][j] = x[i] === y[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
const out: DiffLine[] = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (x[i] === y[j]) { out.push({ op: '=', text: x[i] }); i++; j++; }
|
||||
else if (lcs[i + 1][j] >= lcs[i][j + 1]) out.push({ op: '-', text: x[i++] });
|
||||
else out.push({ op: '+', text: y[j++] });
|
||||
}
|
||||
while (i < n) out.push({ op: '-', text: x[i++] });
|
||||
while (j < m) out.push({ op: '+', text: y[j++] });
|
||||
return out;
|
||||
}
|
||||
|
||||
export const changed = (d: DiffLine[]) => d.filter((l) => l.op !== '=').length;
|
||||
115
api/src/moderation.test.ts
Normal file
115
api/src/moderation.test.ts
Normal file
@ -0,0 +1,115 @@
|
||||
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 Fastify from 'fastify';
|
||||
import { authRoutes } from './auth.ts';
|
||||
import { adminRoutes } from './admin.ts';
|
||||
import { permissionRoutes } from './permissions.ts';
|
||||
import { moderationRoutes } from './moderation.ts';
|
||||
import { diffLines } from './diff.ts';
|
||||
import { pool } from './db.ts';
|
||||
|
||||
after(() => pool.end());
|
||||
|
||||
test('line diff: kept, removed and added lines', () => {
|
||||
assert.deepEqual(diffLines('ا\nب\nج', 'ا\nد\nج'), [{ op: '=', text: 'ا' }, { op: '-', text: 'ب' }, { op: '+', text: 'د' }, { op: '=', text: 'ج' }]);
|
||||
});
|
||||
|
||||
test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permissions, conflicts, history', async () => {
|
||||
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
|
||||
process.env.DIVAN_DATA_DIR = data;
|
||||
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': `mod-${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 other = await person('l1other', 'mod-l1'), reader = await person('reader', 'reader');
|
||||
|
||||
// a Ghalib ghazal; the work is restored at the end
|
||||
const poem = (await pool.query(`SELECT p.id, p.url, p.title FROM poems p JOIN categories c ON c.id = p.category_id WHERE c.url = '/p266/ghazal' ORDER BY p.position LIMIT 1`)).rows[0];
|
||||
const before = (await pool.query('SELECT vorder, position, couplet, text FROM verses WHERE poem_id = $1 ORDER BY vorder', [poem.id])).rows;
|
||||
const searchBefore = (await pool.query('SELECT search_text FROM poems WHERE id = $1', [poem.id])).rows[0].search_text;
|
||||
try {
|
||||
// grants: L2 and L1 on Ghalib's ghazals; the other L1 on Iqbal only
|
||||
for (const [m, target] of [[l2, '/p266/ghazal'], [l1, '/p266/ghazal'], [other, '/p238']] as const)
|
||||
await call('POST', `/api/admin/users/${m.id}/grants`, { scope: target === '/p238' ? 'poet' : 'category', target, content: ['works'], actions: ['edit'] }, admin.token);
|
||||
|
||||
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, reader.token)).statusCode, 403, 'readers cannot moderate');
|
||||
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, other.token)).statusCode, 403, 'outside the grant');
|
||||
assert.deepEqual((await call('GET', `/api/mod/can?poem=${poem.id}`, undefined, l2.token)).json(), { edit: true, review: false, publish: false });
|
||||
|
||||
// L2 drafts: the draft starts from the current text; reopening returns the same draft
|
||||
const { id } = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json();
|
||||
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json().id, id);
|
||||
let rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, l2.token)).json();
|
||||
assert.equal(rev.changes, 0);
|
||||
const misra = before.find((v: any) => v.couplet === 1 && v.position === 'Left').text;
|
||||
const edited = rev.revision.content.replace(misra, misra + ' (ترمیم)');
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: 'متن بغیر شعر کے {{', summary: 'x' }, l2.token)).statusCode, 200, 'prose is a paragraph');
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: edited, summary: 'ایک مصرع درست کیا' }, l2.token)).statusCode, 200);
|
||||
rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, l2.token)).json();
|
||||
assert.deepEqual(rev.diff.filter((d: any) => d.op !== '=').map((d: any) => d.op), ['-', '+'], 'one line changed');
|
||||
assert.equal(rev.may.approve, false, 'not my own draft');
|
||||
|
||||
// L2 submits; the out-of-scope L1 cannot see it; L1 returns it, L2 resubmits, L1 approves
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token)).json().status, 'submitted');
|
||||
assert.equal((await call('GET', `/api/mod/revisions/${id}`, undefined, other.token)).statusCode, 403);
|
||||
assert.equal((await call('GET', '/api/mod/queue', undefined, l1.token)).json().review.some((r: any) => r.id === id), true);
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/return`, {}, l1.token)).statusCode, 400, 'a reason is required');
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/return`, { comment: 'وزن دیکھیں' }, l1.token)).json().status, 'returned');
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).statusCode, 403, 'not yet approved');
|
||||
await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token);
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, {}, l2.token)).statusCode, 403, 'L2 cannot approve');
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, { comment: 'درست' }, l1.token)).json().status, 'approved');
|
||||
assert.equal((await call('GET', '/api/mod/queue', undefined, admin.token)).json().publish.some((r: any) => r.id === id), true);
|
||||
|
||||
// admin publishes: version 1 in divan-data and on the site
|
||||
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
|
||||
const file = JSON.parse(await readFile(join(data, 'divan', poem.url.slice(1) + '.json'), 'utf8'));
|
||||
assert.equal(file.Edited.by, `l2-${run}@divan.test`);
|
||||
assert.equal(file.Edited.reviewedBy, `l1-${run}@divan.test`);
|
||||
assert.equal(file.Edited.publishedBy, `admin-${run}@divan.test`);
|
||||
assert.ok((await readFile(join(data, 'divan', poem.url.slice(1) + '.dtx'), 'utf8')).includes(misra + ' (ترمیم)'));
|
||||
assert.equal((await pool.query('SELECT text FROM verses WHERE poem_id = $1 AND couplet = 1 AND position = $2', [poem.id, 'Left'])).rows[0].text, misra + ' (ترمیم)');
|
||||
|
||||
// the full event trail, in order
|
||||
rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, admin.token)).json();
|
||||
assert.deepEqual(rev.events.map((e: any) => e.action), ['created', 'saved', 'saved', 'submitted', 'returned', 'submitted', 'approved', 'published']);
|
||||
assert.equal(rev.events.find((e: any) => e.action === 'returned').comment, 'وزن دیکھیں');
|
||||
|
||||
// conflict: an L1 draft started from version 1, but an admin publishes version 2 first
|
||||
const d2 = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l1.token)).json().id;
|
||||
const d3 = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, admin.token)).json().id;
|
||||
const cur = (await call('GET', `/api/mod/work/${poem.id}`, undefined, admin.token)).json();
|
||||
assert.equal(cur.version, 1);
|
||||
await call('POST', `/api/mod/revisions/${d3}/save`, { content: cur.content.replace('(ترمیم)', '(ترمیم دوم)') }, admin.token);
|
||||
assert.deepEqual((await call('POST', `/api/mod/revisions/${d3}/submit`, {}, admin.token)).json(), { status: 'published', version: 2 }, "an admin's own edit publishes");
|
||||
await call('POST', `/api/mod/revisions/${d2}/save`, { content: cur.content.replace('(ترمیم)', '(ترمیم سوم)') }, l1.token);
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${d2}/submit`, {}, l1.token)).json().status, 'approved', "an L1's own draft goes to the admin");
|
||||
const conflict = await call('POST', `/api/mod/revisions/${d2}/publish`, {}, admin.token);
|
||||
assert.equal(conflict.statusCode, 409, 'a newer version was published meanwhile');
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${d2}/reject`, { comment: 'پرانا متن' }, admin.token)).json().status, 'rejected');
|
||||
|
||||
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);
|
||||
} finally {
|
||||
// restore the work and remove the test people and their revisions
|
||||
await pool.query('DELETE FROM verses WHERE poem_id = $1', [poem.id]);
|
||||
for (const v of before) await pool.query('INSERT INTO verses (poem_id, vorder, position, couplet, text) VALUES ($1, $2, $3, $4, $5)', [poem.id, v.vorder, v.position, v.couplet, v.text]);
|
||||
await pool.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1', [poem.id, poem.title, searchBefore]);
|
||||
await pool.query(`DELETE FROM revisions WHERE entity = 'work' AND entity_id = $1 AND author_email LIKE $2`, [poem.id, `%-${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();
|
||||
}
|
||||
});
|
||||
229
api/src/moderation.ts
Normal file
229
api/src/moderation.ts
Normal file
@ -0,0 +1,229 @@
|
||||
// Content moderation (#31, #51): versions of works and the L2 -> L1 -> admin pipeline.
|
||||
// A revision holds a work's Divan text, a summary and a status; every step taken on it is an event (who, what,
|
||||
// when, comment), which is both the review thread and the moderation log.
|
||||
// L2 moderator: drafts and submits -> submitted
|
||||
// L1 moderator (grant covering the work): approves, returns (with a comment) or rejects; an L1's own draft
|
||||
// goes straight to the admin step -> approved
|
||||
// admin: publishes (an admin's own draft publishes directly), returns or rejects
|
||||
// Publishing numbers the version, writes it to divan-data as Divan-owned content (owned.ts) and updates the site.
|
||||
// If another version was published after the draft started, publishing is refused until the draft is redone.
|
||||
// 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
|
||||
// POST /api/mod/work/:id/draft start (or reopen) my draft
|
||||
// GET /api/mod/revisions/:id a revision, its diff against the version it started from, its events
|
||||
// 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
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { pool } from './db.ts';
|
||||
import { sessionUser } from './auth.ts';
|
||||
import { can } from './permissions.ts';
|
||||
import { fromPoem, writeOwned } from './owned.ts';
|
||||
import { parse, toVerses } from './divantext.ts';
|
||||
import { normalise } from './urdu.ts';
|
||||
import { diffLines, changed } from './diff.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);
|
||||
const mayEdit = (u: any, poemId: number) => can(u, 'edit', 'works', { poemId });
|
||||
const mayReview = async (u: any, poemId: number) => ['mod-l1', 'admin'].includes(u?.role) && (await mayEdit(u, poemId));
|
||||
const OPEN = ['draft', 'returned'];
|
||||
|
||||
async function moderator(req: FastifyRequest, reply: FastifyReply) {
|
||||
const u = await sessionUser(req);
|
||||
if (!u) return void reply.code(401).send({ error: 'لاگ ان کریں' });
|
||||
if (!isModerator(u)) return void reply.code(403).send({ error: 'صرف موڈریٹرز کے لیے' });
|
||||
return u;
|
||||
}
|
||||
|
||||
const event = (revId: number, u: any, action: string, comment?: string | null) =>
|
||||
pool.query('INSERT INTO revision_events (revision_id, actor_id, actor_email, action, comment) VALUES ($1, $2, $3, $4, $5)',
|
||||
[revId, u?.id ?? null, u?.email ?? 'server', action, comment || null]);
|
||||
|
||||
// a work's current published text: its latest Divan version, or the Wikisource text as Divan text
|
||||
async function current(poemId: number) {
|
||||
const poem = (await pool.query(
|
||||
'SELECT p.id, p.url, p.title, p.source_url, t.nickname AS poet FROM poems p JOIN poets t ON t.id = p.poet_id WHERE p.id = $1', [poemId])).rows[0];
|
||||
if (!poem) return null;
|
||||
const last = (await pool.query(
|
||||
`SELECT version, content FROM revisions WHERE entity = 'work' AND entity_id = $1 AND status = 'published' ORDER BY version DESC LIMIT 1`, [poemId])).rows[0];
|
||||
if (last) return { poem, version: last.version as number, content: last.content as string };
|
||||
const verses = (await pool.query('SELECT position AS "Position", couplet AS "CoupletIndex", text AS "Text" FROM verses WHERE poem_id = $1 ORDER BY vorder', [poemId])).rows;
|
||||
return { poem, version: 0, content: fromPoem({ Title: poem.title, Verses: verses, SourceUrl: poem.source_url ?? undefined }, { شاعر: poem.poet }) };
|
||||
}
|
||||
|
||||
async function revision(id: number) {
|
||||
return (await pool.query(
|
||||
`SELECT r.*, p.title AS work_title, p.url AS work_url FROM revisions r JOIN poems p ON p.id = r.entity_id
|
||||
WHERE r.id = $1 AND r.entity = 'work'`, [id])).rows[0];
|
||||
}
|
||||
|
||||
// what this person may do with this revision now
|
||||
async function actions(u: any, r: any) {
|
||||
const mine = Number(r.author_id) === Number(u.id), review = await mayReview(u, r.entity_id), admin = u.role === 'admin';
|
||||
return {
|
||||
save: mine && OPEN.includes(r.status),
|
||||
submit: mine && OPEN.includes(r.status),
|
||||
approve: !mine && review && r.status === 'submitted',
|
||||
return: (review || admin) && ['submitted', 'approved'].includes(r.status) && !(mine && !admin),
|
||||
reject: (review || admin) && ['submitted', 'approved'].includes(r.status) && !(mine && !admin),
|
||||
publish: admin && r.status === 'approved',
|
||||
};
|
||||
}
|
||||
|
||||
async function publish(r: any, u: any, comment?: string) {
|
||||
const cur = await current(r.entity_id);
|
||||
if (!cur) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 });
|
||||
if (cur.version !== r.base_version)
|
||||
throw Object.assign(new Error('اس دوران اس کلام کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 });
|
||||
const doc = parse(r.content), verses = toVerses(doc);
|
||||
const title = doc.meta['عنوان'] || cur.poem.title, version = cur.version + 1, at = new Date().toISOString();
|
||||
// divan-data first (the published record), then the site's database
|
||||
await writeOwned(dataDir(), cur.poem.url, r.content, { by: r.author_email, at, version, reviewedBy: r.reviewer_email, publishedBy: u.email });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(`UPDATE revisions SET status = 'published', version = $2, publisher_email = $3, published_at = $4, updated_at = now() WHERE id = $1`,
|
||||
[r.id, version, u.email, at]);
|
||||
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(' '))]);
|
||||
await client.query('DELETE FROM verses WHERE poem_id = $1', [r.entity_id]);
|
||||
for (const v of verses)
|
||||
await client.query('INSERT INTO verses (poem_id, vorder, position, couplet, text) VALUES ($1, $2, $3, $4, $5)',
|
||||
[r.entity_id, v.VOrder, v.Position, v.CoupletIndex, v.Text]);
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
// ponytail: radif/matla/maqta and the contents order are recomputed by the next daily export + import
|
||||
await event(r.id, u, 'published', comment);
|
||||
return version;
|
||||
}
|
||||
|
||||
export function moderationRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: { poem?: string } }>('/api/mod/can', async (req) => {
|
||||
const u = await sessionUser(req), poemId = Number(req.query.poem) || 0;
|
||||
if (!isModerator(u)) return { edit: false, review: false, publish: false };
|
||||
return { edit: await mayEdit(u, poemId), review: await mayReview(u, poemId), publish: u.role === 'admin' };
|
||||
});
|
||||
|
||||
app.get('/api/mod/queue', async (req, reply) => {
|
||||
const u = await moderator(req, reply); if (!u) return;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT r.id, r.entity_id, r.status, r.summary, r.author_id, r.author_email, r.reviewer_email, r.updated_at, p.title, p.url
|
||||
FROM revisions r JOIN poems p ON p.id = r.entity_id
|
||||
WHERE r.entity = 'work' AND (r.status IN ('submitted', 'approved') OR (r.author_id = $1 AND r.status IN ('draft', 'returned')))
|
||||
ORDER BY r.updated_at DESC LIMIT 300`, [u.id]);
|
||||
const strip = ({ author_id, ...r }: any) => ({ ...r, id: Number(r.id) });
|
||||
const review = [];
|
||||
for (const r of rows) if (r.status === 'submitted' && Number(r.author_id) !== Number(u.id) && (await mayReview(u, r.entity_id))) review.push(strip(r));
|
||||
return {
|
||||
mine: rows.filter((r) => Number(r.author_id) === Number(u.id)).map(strip),
|
||||
review,
|
||||
publish: u.role === 'admin' ? rows.filter((r) => r.status === 'approved').map(strip) : [],
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/mod/work/:id', async (req, reply) => {
|
||||
const u = await moderator(req, reply); if (!u) return;
|
||||
const cur = await current(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 WHERE entity = 'work' AND entity_id = $1 ORDER BY coalesce(published_at, created_at) DESC`, [cur.poem.id]);
|
||||
return { work: cur.poem, version: cur.version, content: cur.content, history: rows.map((r) => ({ ...r, id: Number(r.id) })),
|
||||
may: { edit: await mayEdit(u, cur.poem.id) } };
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/mod/work/:id/draft', async (req, reply) => {
|
||||
const u = await moderator(req, reply); if (!u) return;
|
||||
const poemId = Number(req.params.id) || 0;
|
||||
if (!(await mayEdit(u, poemId))) return reply.code(403).send({ error: 'اس کلام میں ترمیم کی اجازت نہیں' });
|
||||
const open = (await pool.query(
|
||||
`SELECT id FROM revisions WHERE entity = 'work' AND entity_id = $1 AND author_id = $2 AND status IN ('draft', 'returned') LIMIT 1`, [poemId, u.id])).rows[0];
|
||||
if (open) return { id: Number(open.id) };
|
||||
const cur = await current(poemId);
|
||||
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 ('work', $1, $2, $3, $3, 'draft', $4, $5) RETURNING id`,
|
||||
[poemId, 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);
|
||||
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
|
||||
const may = await actions(u, r);
|
||||
if (Number(r.author_id) !== Number(u.id) && !(await mayReview(u, r.entity_id)) && u.role !== 'admin')
|
||||
return reply.code(403).send({ error: 'یہ مسودہ دیکھنے کی اجازت نہیں' });
|
||||
const diff = diffLines(r.base_content, r.content); // against the text the draft started from
|
||||
const events = (await pool.query('SELECT actor_email, action, comment, at FROM revision_events WHERE revision_id = $1 ORDER BY at, id', [r.id])).rows;
|
||||
const { author_id, base_content, ...rest } = r;
|
||||
return { revision: { ...rest, id: Number(r.id) }, diff, changes: changed(diff), events, may };
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { content?: string; summary?: string } }>('/api/mod/revisions/:id/save', async (req, reply) => {
|
||||
const u = await moderator(req, reply); if (!u) return;
|
||||
const r = await revision(Number(req.params.id) || 0);
|
||||
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 (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
|
||||
if (content.length > 500_000) return reply.code(400).send({ error: 'متن بہت لمبا ہے' });
|
||||
const summary = String(req.body?.summary ?? '').trim().slice(0, 500) || null;
|
||||
await pool.query('UPDATE revisions SET content = $2, summary = $3, updated_at = now() WHERE id = $1', [r.id, content, summary]);
|
||||
await event(r.id, u, 'saved');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string; action: string }; Body: { comment?: string } }>('/api/mod/revisions/:id/:action', async (req, reply) => {
|
||||
const u = await moderator(req, reply); if (!u) return;
|
||||
const r = await revision(Number(req.params.id) || 0);
|
||||
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
|
||||
const action = req.params.action as keyof Awaited<ReturnType<typeof actions>>, may = await actions(u, r);
|
||||
if (!['submit', 'approve', 'return', 'reject', 'publish'].includes(action)) return reply.code(404).send({ error: 'نامعلوم عمل' });
|
||||
if (!may[action]) return reply.code(403).send({ error: 'یہ عمل آپ کے لیے دستیاب نہیں' });
|
||||
const comment = String(req.body?.comment ?? '').trim().slice(0, 2000);
|
||||
if ((action === 'return' || action === 'reject') && !comment) return reply.code(400).send({ error: 'وجہ لکھیں' });
|
||||
const set = (status: string, extra = '') => pool.query(`UPDATE revisions SET status = $2, updated_at = now()${extra} WHERE id = $1`, [r.id, status]);
|
||||
try {
|
||||
if (action === 'submit') {
|
||||
// L2 -> L1 review; an L1's own draft goes to the admin; an admin's own draft publishes
|
||||
if (u.role === 'admin') { await set('approved'); await event(r.id, u, 'submitted', comment); return { status: 'published', version: await publish({ ...r, status: 'approved' }, u) }; }
|
||||
await set(u.role === 'mod-l1' ? 'approved' : 'submitted');
|
||||
await event(r.id, u, 'submitted', comment);
|
||||
return { status: u.role === 'mod-l1' ? 'approved' : 'submitted' };
|
||||
}
|
||||
if (action === 'approve') {
|
||||
await pool.query(`UPDATE revisions SET status = 'approved', reviewer_email = $2, updated_at = now() WHERE id = $1`, [r.id, u.email]);
|
||||
await event(r.id, u, 'approved', comment);
|
||||
return { status: 'approved' };
|
||||
}
|
||||
if (action === 'return' || action === 'reject') {
|
||||
await set(action === 'return' ? 'returned' : 'rejected');
|
||||
await event(r.id, u, action === 'return' ? 'returned' : 'rejected', comment);
|
||||
return { status: action === 'return' ? 'returned' : 'rejected' };
|
||||
}
|
||||
return { status: 'published', version: await publish(r, u, comment) };
|
||||
} catch (e: any) {
|
||||
return reply.code(e.code ?? 500).send({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { page?: 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 { rows } = await pool.query(
|
||||
`SELECT e.at, e.actor_email, e.action, e.comment, r.id AS revision, r.version, p.title, p.url
|
||||
FROM revision_events e JOIN revisions r ON r.id = e.revision_id JOIN poems p ON p.id = r.entity_id
|
||||
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) })) };
|
||||
});
|
||||
}
|
||||
@ -30,15 +30,16 @@ export function fromPoem(poem: { Title: string; Verses: Verse[]; SourceUrl?: str
|
||||
}
|
||||
|
||||
// write a Divan-owned work (the .dtx and the generated .json); returns the paths written
|
||||
export async function writeOwned(dataDir: string, url: string, dtx: string, edited: { by: string; at?: string }) {
|
||||
export async function writeOwned(dataDir: string, url: string, dtx: string,
|
||||
edited: { by: string; at?: string; version?: number; reviewedBy?: string | null; publishedBy?: string }) {
|
||||
const doc = parse(dtx);
|
||||
const verses = toVerses(doc);
|
||||
if (!verses.length) throw new Error('the text has no verses or paragraphs');
|
||||
const base = join(dataDir, 'divan', url.replace(/^\/+|\/+$/g, ''));
|
||||
if (!/^[\w/-]+$/.test(url) || url.includes('..')) throw new Error(`not a work url: ${url}`);
|
||||
const base = join(dataDir, 'divan', url.replace(/^\/+|\/+$/g, ''));
|
||||
await mkdir(dirname(base), { recursive: true });
|
||||
const json = { FullUrl: '/' + url.replace(/^\/+/, ''), Title: doc.meta['عنوان'] ?? '', Verses: verses.map(({ VOrder, ...v }) => ({ VOrder, ...v, SectionIndex1: 0 })),
|
||||
Edited: { by: edited.by, at: edited.at ?? new Date().toISOString() } };
|
||||
Edited: { ...edited, at: edited.at ?? new Date().toISOString() } };
|
||||
await writeFile(base + '.dtx', dtx.endsWith('\n') ? dtx : dtx + '\n');
|
||||
await writeFile(base + '.json', JSON.stringify(json, null, 1) + '\n');
|
||||
return [base + '.dtx', base + '.json'];
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
// /api/auth/* accounts (see auth.ts)
|
||||
// /api/admin/* admin panel (see admin.ts)
|
||||
// /api/library/* a reader's saved poets, works, couplets and words (see library.ts)
|
||||
// /api/mod/* content moderation: drafts, review, publishing, history (see moderation.ts)
|
||||
// GET /health
|
||||
import Fastify from 'fastify';
|
||||
import { pool } from './db.ts';
|
||||
@ -17,6 +18,7 @@ import { authRoutes } from './auth.ts';
|
||||
import { adminRoutes } from './admin.ts';
|
||||
import { permissionRoutes } from './permissions.ts';
|
||||
import { libraryRoutes } from './library.ts';
|
||||
import { moderationRoutes } from './moderation.ts';
|
||||
|
||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info' } });
|
||||
const PAGE_SIZE = 20;
|
||||
@ -156,6 +158,7 @@ authRoutes(app);
|
||||
adminRoutes(app);
|
||||
permissionRoutes(app);
|
||||
libraryRoutes(app);
|
||||
moderationRoutes(app);
|
||||
|
||||
const port = Number(process.env.PORT ?? 4100);
|
||||
await app.listen({ port, host: process.env.HOST ?? '127.0.0.1' });
|
||||
|
||||
@ -149,3 +149,38 @@ DROP INDEX IF EXISTS library_places;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS library_bookmarks ON library (user_id, kind, coalesce(poet_id, 0), coalesce(category_id, 0), coalesce(poem_id, 0), coalesce(couplet, -1)) WHERE kind IN ('poet', 'category', 'poem', 'couplet');
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS library_phrases ON library (user_id, poem_id, couplet, phrase) WHERE kind = 'phrase';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS library_words ON library (user_id, word) WHERE kind = 'word';
|
||||
|
||||
-- content moderation (api/src/moderation.ts): versions of content, and every step taken on them (#31, #51)
|
||||
CREATE TABLE IF NOT EXISTS revisions (
|
||||
id bigserial PRIMARY KEY,
|
||||
entity text NOT NULL, -- 'work' (later: poet, intro, book, chapter, dictionary, tag)
|
||||
entity_id integer NOT NULL, -- poems.id for works
|
||||
version integer, -- the published version number (NULL until published)
|
||||
base_version integer NOT NULL DEFAULT 0, -- the published version the draft started from (0 = Wikisource text)
|
||||
base_content text NOT NULL DEFAULT '', -- the text the draft started from (for an exact diff)
|
||||
content text NOT NULL, -- Divan text
|
||||
summary text, -- the edit summary
|
||||
status text NOT NULL CHECK (status IN ('draft', 'submitted', 'approved', 'published', 'returned', 'rejected')),
|
||||
author_id bigint REFERENCES users(id) ON DELETE SET NULL,
|
||||
author_email text NOT NULL, -- kept when accounts are deleted
|
||||
reviewer_email text, -- the L1 moderator who approved
|
||||
publisher_email text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
published_at timestamptz
|
||||
);
|
||||
ALTER TABLE revisions ADD COLUMN IF NOT EXISTS base_content text NOT NULL DEFAULT '';
|
||||
CREATE INDEX IF NOT EXISTS revisions_entity ON revisions(entity, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS revisions_status ON revisions(status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS revisions_version ON revisions(entity, entity_id, version) WHERE version IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS revision_events ( -- who did what: created, saved, submitted, approved, returned,
|
||||
id bigserial PRIMARY KEY, -- rejected, published, commented
|
||||
revision_id bigint NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||
actor_id bigint REFERENCES users(id) ON DELETE SET NULL,
|
||||
actor_email text NOT NULL,
|
||||
action text NOT NULL,
|
||||
comment text,
|
||||
at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS revision_events_revision ON revision_events(revision_id);
|
||||
CREATE INDEX IF NOT EXISTS revision_events_at ON revision_events(at DESC);
|
||||
|
||||
7
web/src/components/ModNav.astro
Normal file
7
web/src/components/ModNav.astro
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
const { current } = Astro.props as { current?: 'queue' | 'log' };
|
||||
---
|
||||
<nav class="admin-nav">
|
||||
<a href="/mod" aria-current={current === 'queue' ? 'page' : undefined}>موڈریشن</a>
|
||||
<a href="/mod?log=1" aria-current={current === 'log' ? 'page' : undefined}>سرگرمی</a>
|
||||
</nav>
|
||||
@ -42,6 +42,7 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
|
||||
</form>
|
||||
<div class="toggles">
|
||||
{Astro.locals.user?.role === 'admin' && <a class="account-link" href="/admin">ایڈمن</a>}
|
||||
{['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '') && <a class="account-link" href="/mod">موڈریشن</a>}
|
||||
{Astro.locals.user && <a class="account-link" href="/library">نشانات</a>}
|
||||
{Astro.locals.user && <a class="account-link" href="/words">ذخیرۂ الفاظ</a>}
|
||||
{Astro.locals.user
|
||||
|
||||
9
web/src/lib/mod.ts
Normal file
9
web/src/lib/mod.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// Urdu labels for moderation (api/src/moderation.ts)
|
||||
export const STATUS: Record<string, string> = {
|
||||
draft: 'مسودہ', submitted: 'جائزے کا منتظر', approved: 'منظور شدہ، اشاعت کا منتظر', published: 'شائع شدہ', returned: 'واپس بھیجا گیا', rejected: 'مسترد',
|
||||
};
|
||||
export const EVENT: Record<string, string> = {
|
||||
created: 'مسودہ بنایا', saved: 'محفوظ کیا', submitted: 'جائزے کے لیے بھیجا', approved: 'منظور کیا', returned: 'واپس بھیجا',
|
||||
rejected: 'مسترد کیا', published: 'شائع کیا',
|
||||
};
|
||||
export const when = (d: string) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
|
||||
@ -66,6 +66,9 @@ const lineMarks = (c: number, lines: string[]) => {
|
||||
return out;
|
||||
};
|
||||
const label = (on: unknown) => (on ? 'محفوظ شدہ' : 'محفوظ کریں'); // bookmark buttons
|
||||
// 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 };
|
||||
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;
|
||||
---
|
||||
@ -115,7 +118,9 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
|
||||
«{hlq}» نمایاں · <a href={`/search?q=${encodeURIComponent(hlq)}`}>تلاش کے نتائج</a> · <a href={url}>نشان ہٹائیں</a>
|
||||
</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></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>
|
||||
{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>
|
||||
{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}>
|
||||
{blocks.map((b) => {
|
||||
|
||||
56
web/src/pages/mod/index.astro
Normal file
56
web/src/pages/mod/index.astro
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
// Moderation: my drafts, drafts to review (L1, within grants), drafts to publish (admin); or the activity log
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import ModNav from '../../components/ModNav.astro';
|
||||
import { asUser, COOKIE } from '../../lib/auth';
|
||||
import { STATUS, EVENT, when } from '../../lib/mod';
|
||||
import { ud } from '../../lib/urdu';
|
||||
|
||||
const me = Astro.locals.user;
|
||||
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;
|
||||
const groups = queue ? [
|
||||
['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'],
|
||||
...(me.role !== 'mod-l2' ? [['جائزے کے منتظر', queue.review, 'جائزے کے لیے کچھ نہیں۔']] : []),
|
||||
...(me.role === 'admin' ? [['اشاعت کے منتظر', queue.publish, 'اشاعت کے لیے کچھ نہیں۔']] : []),
|
||||
] as [string, any[], string][] : [];
|
||||
---
|
||||
<Base title="موڈریشن">
|
||||
<h1>موڈریشن</h1>
|
||||
<ModNav current={showLog ? 'log' : 'queue'} />
|
||||
{groups.map(([title, items, empty]) => (
|
||||
<section class="lib-section">
|
||||
<h2>{title} <small class="muted">{ud(items.length)}</small></h2>
|
||||
{items.length === 0 ? <p class="muted center">{empty}</p> : (
|
||||
<ul class="lib-list">
|
||||
{items.map((r) => (
|
||||
<li>
|
||||
<a class="lib-title" href={`/mod/rev/${r.id}`}>{r.title}</a>
|
||||
<span class={`status s-${r.status}`}>{STATUS[r.status]}</span>
|
||||
<p class="muted small-line">{r.summary || 'بغیر خلاصہ'} · <bdi dir="ltr">{r.author_email}</bdi> · <bdi dir="ltr">{when(r.updated_at)}</bdi></p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{log && (
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>وقت (UTC)</th><th>کس نے</th><th>کیا</th><th>کلام</th><th>تبصرہ</th></tr></thead>
|
||||
<tbody>
|
||||
{log.entries.map((e: any) => (
|
||||
<tr>
|
||||
<td><bdi dir="ltr">{when(e.at)}</bdi></td>
|
||||
<td><bdi dir="ltr">{e.actor_email}</bdi></td>
|
||||
<td>{EVENT[e.action] ?? e.action}{e.version && e.action === 'published' ? ` (ورژن ${ud(e.version)})` : ''}</td>
|
||||
<td><a href={`/mod/rev/${e.revision}`}>{e.title}</a></td>
|
||||
<td>{e.comment}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Base>
|
||||
90
web/src/pages/mod/rev/[id].astro
Normal file
90
web/src/pages/mod/rev/[id].astro
Normal file
@ -0,0 +1,90 @@
|
||||
---
|
||||
// One revision: edit (author, while open), preview, diff against the text it started from, actions, timeline
|
||||
import Base from '../../../layouts/Base.astro';
|
||||
import ModNav from '../../../components/ModNav.astro';
|
||||
import { asUser, COOKIE } from '../../../lib/auth';
|
||||
import { STATUS, EVENT, when } from '../../../lib/mod';
|
||||
import { ud } from '../../../lib/urdu';
|
||||
import { parse } from '../../../../../api/src/divantext.ts';
|
||||
|
||||
const me = Astro.locals.user;
|
||||
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
|
||||
const token = Astro.cookies.get(COOKIE)!.value, id = Number(Astro.params.id) || 0;
|
||||
let error = '', done = '';
|
||||
if (Astro.request.method === 'POST') {
|
||||
const f = await Astro.request.formData(), act = String(f.get('act'));
|
||||
let r: any = { ok: true, data: {} };
|
||||
if (act === 'save' || act === 'submit') r = await asUser(token, `/api/mod/revisions/${id}/save`, { content: f.get('content'), summary: f.get('summary') });
|
||||
if (r.ok && act !== 'save') r = await asUser(token, `/api/mod/revisions/${id}/${act}`, { comment: f.get('comment') ?? '' });
|
||||
if (!r.ok) error = r.data.error ?? 'کچھ غلط ہو گیا';
|
||||
else done = act === 'save' ? 'محفوظ ہو گیا' : `${STATUS[r.data.status] ?? r.data.status}${r.data.version ? ` · ورژن ${ud(r.data.version)}` : ''}`;
|
||||
}
|
||||
const res = await asUser(token, `/api/mod/revisions/${id}`);
|
||||
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
|
||||
const { revision: rev, diff, changes, events, may } = res.data;
|
||||
const doc = parse(rev.content);
|
||||
const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]);
|
||||
---
|
||||
<Base title={`مسودہ: ${rev.work_title}`}>
|
||||
<h1>{rev.work_title}</h1>
|
||||
<ModNav />
|
||||
<p class="center">
|
||||
<span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span>
|
||||
· <bdi dir="ltr">{rev.author_email}</bdi>
|
||||
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
|
||||
· <a href={rev.work_url}>کلام</a> · <a href={`/mod/work/${rev.entity_id}`}>تاریخچہ</a>
|
||||
</p>
|
||||
{error && <p class="form-error" role="alert">{error}</p>}
|
||||
{done && <p class="form-done" role="status">{done}</p>}
|
||||
|
||||
{may.save ? (
|
||||
<form method="post" class="mod-edit">
|
||||
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: دوسرے شعر کا مصرع درست کیا" /></label>
|
||||
<label class="mod-text">متن (دیوان متن)
|
||||
<textarea name="content" id="mod-content" data-urdu rows="22" dir="rtl" spellcheck="false">{rev.content}</textarea>
|
||||
</label>
|
||||
<p class="muted note">شعر کے دو مصرعے الگ سطروں پر، اشعار کے درمیان خالی سطر۔ <code>== باب ==</code> عنوان، <code>[[لفظ]]</code> لغت کا ربط، <code><ref>…</ref></code> حاشیہ۔</p>
|
||||
<div class="filter-actions">
|
||||
<button type="button" class="in-box-btn" data-urdu-rom="mod-content" title="رومن حروف سے اردو">اب</button>
|
||||
<button type="button" class="in-box-btn" data-urdu-kb="mod-content" title="اردو کی بورڈ">کی بورڈ</button>
|
||||
<button name="act" value="save">محفوظ کریں</button>
|
||||
<button name="act" value="submit">{me.role === 'admin' ? 'محفوظ کر کے شائع کریں' : me.role === 'mod-l1' ? 'محفوظ کر کے اشاعت کے لیے بھیجیں' : 'محفوظ کر کے جائزے کے لیے بھیجیں'}</button>
|
||||
</div>
|
||||
</form>
|
||||
) : rev.summary && <p class="center">خلاصہ: {rev.summary}</p>}
|
||||
|
||||
<section class="lib-section">
|
||||
<h2>تبدیلیاں <small class="muted">{ud(changes)} سطریں</small></h2>
|
||||
{changes === 0 ? <p class="muted center">ابھی کوئی تبدیلی نہیں۔</p> : (
|
||||
<div class="diff" dir="rtl">
|
||||
{diff.map((d: any) => d.op === '=' ? null : <div class={d.op === '+' ? 'add' : 'del'}><span>{d.op === '+' ? '+' : '−'}</span>{d.text || ' '}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section class="lib-section">
|
||||
<h2>پیش نظارہ</h2>
|
||||
<article class="poem">
|
||||
{doc.blocks.map((b: any) => b.type === 'heading' ? <h3 class="mod-h">{b.text}</h3>
|
||||
: b.type === 'para' ? <p class="para">{b.line.text}</p>
|
||||
: b.type === 'couplet' ? <div class="couplet" data-mark={b.label}>{b.lines.map((l: any) => <p>{l.text}</p>)}</div>
|
||||
: b.lines.map((l: any) => <p class="single">{l.text}</p>))}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{acts.length > 0 && (
|
||||
<form method="post" class="account-form mod-actions">
|
||||
<label>تبصرہ (واپس بھیجنے یا مسترد کرنے کے لیے ضروری)<textarea name="comment" rows="2" data-urdu maxlength="2000"></textarea></label>
|
||||
<div class="filter-actions">{acts.map(([a, label]) => <button name="act" value={a} class={`act-${a}`}>{label}</button>)}</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<section class="lib-section">
|
||||
<h2>سرگرمی</h2>
|
||||
<ol class="timeline">
|
||||
{events.map((e: any) => (
|
||||
<li><bdi dir="ltr">{when(e.at)}</bdi> · <bdi dir="ltr">{e.actor_email}</bdi> · <strong>{EVENT[e.action] ?? e.action}</strong>{e.comment && <>: {e.comment}</>}</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</Base>
|
||||
42
web/src/pages/mod/work/[id].astro
Normal file
42
web/src/pages/mod/work/[id].astro
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
// A work's versions: published versions and drafts, with who, when, summary and status
|
||||
import Base from '../../../layouts/Base.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;
|
||||
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
|
||||
const token = Astro.cookies.get(COOKIE)!.value, id = Number(Astro.params.id) || 0;
|
||||
if (Astro.request.method === 'POST') {
|
||||
const r = await asUser(token, `/api/mod/work/${id}/draft`, {});
|
||||
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`);
|
||||
}
|
||||
const res = await asUser(token, `/api/mod/work/${id}`);
|
||||
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
|
||||
const { work, version, history, may } = res.data;
|
||||
---
|
||||
<Base title={`تاریخچہ: ${work.title}`}>
|
||||
<h1>{work.title}</h1>
|
||||
<ModNav />
|
||||
<p class="center"><a href={work.url}>کلام</a> · {version ? `موجودہ ورژن ${ud(version)}` : 'ویکی ماخذ کا متن (ابھی دیوان کا کوئی ورژن نہیں)'}</p>
|
||||
{may.edit && <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>
|
||||
{history.length === 0 && <p class="muted center">ابھی کوئی ترمیم نہیں۔</p>}
|
||||
</Base>
|
||||
@ -295,3 +295,30 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
||||
.author-suggest button:hover { border-color: var(--brand); color: var(--brand); }
|
||||
.filter-actions { display: flex; gap: 12px; justify-content: center; align-items: center; margin-top: 10px !important; }
|
||||
.filter-actions button { font: inherit; font-size: .85rem; padding: 1px 14px; border: 1.5px solid var(--gold); border-radius: 10px; background: var(--inner); color: var(--ink); cursor: pointer; }
|
||||
|
||||
/* moderation */
|
||||
.status { display: inline-block; font-size: .78rem; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border); margin-inline-start: 6px; }
|
||||
.s-submitted { border-color: var(--gold); color: var(--gold); }
|
||||
.s-approved { border-color: var(--lapis); color: var(--lapis); }
|
||||
.s-published { border-color: #3d8b4f; color: #3d8b4f; }
|
||||
.s-returned, .s-rejected { border-color: var(--brand); color: var(--brand); }
|
||||
.small-line { font-size: .8rem; margin: 2px 0 0; }
|
||||
.mod-edit { max-width: 900px; margin: 12px auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.mod-edit label { display: flex; flex-direction: column; gap: 4px; font-size: .9rem; }
|
||||
.mod-edit input, .mod-edit textarea { font: inherit; padding: 6px 12px; border: 1.5px solid var(--border); border-radius: 10px; background: var(--paper); color: var(--ink); }
|
||||
.mod-edit textarea { font-size: 1.05rem; line-height: 2; min-height: 26em; resize: vertical; }
|
||||
.mod-edit textarea:focus, .mod-edit input:focus { outline: none; border-color: var(--gold); }
|
||||
.mod-actions { max-width: 700px; }
|
||||
.mod-actions .act-publish { border-color: #3d8b4f; color: #3d8b4f; }
|
||||
.mod-actions .act-reject, .mod-actions .act-return { border-color: var(--brand); color: var(--brand); }
|
||||
.diff { max-width: 900px; margin: 0 auto; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.diff div { padding: 2px 12px; white-space: pre-wrap; line-height: 1.9; }
|
||||
.diff div span { display: inline-block; width: 1.2em; font-weight: 700; }
|
||||
.diff .add { background: rgb(61 139 79 / .14); }
|
||||
.diff .del { background: rgb(191 63 42 / .12); text-decoration: line-through; text-decoration-color: rgb(191 63 42 / .5); }
|
||||
.timeline { max-width: 760px; margin: 0 auto; padding-inline-start: 20px; font-size: .88rem; }
|
||||
.timeline li { margin: 4px 0; }
|
||||
.mod-h { text-align: center; color: var(--brand); }
|
||||
.inline-form { display: inline; }
|
||||
.mod-edit-btn, .save-like { font: inherit; font-size: .85rem; padding: 1px 14px; border: 1.5px solid var(--gold); border-radius: 999px; background: var(--inner); color: var(--ink); cursor: pointer; }
|
||||
.mod-history { font-size: .85rem; margin-inline-start: 6px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user