From cde754412e3d5c6a98fae27613311c045bff7433 Mon Sep 17 00:00:00 2001
From: Anas Rashid
Date: Fri, 9 Oct 2026 14:02:26 +0200
Subject: [PATCH] Arranging content (#41): reorder a book or section's works
and sub-sections, a poet's books
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Its own permission (arrange), separate from editing; same L2 -> L1 -> admin pipeline as a work's text
(revisions with entity 'order'), one line per item; only the order counts and every item stays listed.
- Publishing writes divan-data/divan/.order, commits it and updates the site's positions.
- ترتیب دیں on poet and book/section pages; a sortable list (drag, ↑ ↓, اول, آخر); /mod/order/:id history.
- An unchanged arrangement is not kept, as with texts.
Co-Authored-By: Claude Opus 5.5
---
api/src/moderation.ts | 117 +++++++++++++++++++++--------
api/src/order.test.ts | 77 +++++++++++++++++++
api/src/order.ts | 61 +++++++++++++++
web/src/pages/[...path].astro | 9 ++-
web/src/pages/mod/order/[id].astro | 46 ++++++++++++
web/src/pages/mod/rev/[id].astro | 61 ++++++++++++++-
web/src/styles/global.css | 7 ++
7 files changed, 342 insertions(+), 36 deletions(-)
create mode 100644 api/src/order.test.ts
create mode 100644 api/src/order.ts
create mode 100644 web/src/pages/mod/order/[id].astro
diff --git a/api/src/moderation.ts b/api/src/moderation.ts
index 92cabff9..aca23bec 100644
--- a/api/src/moderation.ts
+++ b/api/src/moderation.ts
@@ -9,11 +9,15 @@
// Publishing numbers the version, writes it to divan-data as Divan-owned content (owned.ts), commits it there (git.ts)
// and updates the site.
// If another version was published after the draft started, publishing is refused until the draft is redone.
+// Arranging a book or section (order.ts, entity 'order') uses the same revisions, steps and publishing; it needs
+// the separate 'arrange' permission.
// 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
+// GET /api/mod/order/:id a book/section's current order and its history
+// POST /api/mod/order/:id/draft start (or reopen) my arrangement draft
// POST /api/mod/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
@@ -26,12 +30,17 @@ import { parse, toVerses, toText } from './divantext.ts';
import { normalise } from './urdu.ts';
import { diffLines, changed } from './diff.ts';
import { commit, identity } from './git.ts';
+import { currentOrder, checkOrder, applyOrder, writeOrder, orderSlugs } from './order.ts';
import { publicName } from './auth.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 mayArrange = async (u: any, categoryId: number) =>
+ (await can(u, 'arrange', 'works', { categoryId })) || (await can(u, 'arrange', 'books', { categoryId }));
+// may change what this revision changes (a work's text, or a book/section's order); reviewing needs L1 or admin
+const mayChange = (u: any, r: { entity: string; entity_id: number }) => (r.entity === 'order' ? mayArrange : mayEdit)(u, r.entity_id);
+const mayReview = async (u: any, r: { entity: string; entity_id: number }) => ['mod-l1', 'admin'].includes(u?.role) && (await mayChange(u, r));
const OPEN = ['draft', 'returned'];
// a draft whose text is still the text it started from is not shown anywhere (opening the editor is not a change)
const CHANGED = `(r.status <> 'draft' OR r.content <> r.base_content)`;
@@ -59,15 +68,18 @@ async function current(poemId: number) {
return { poem, version: 0, content: fromPoem({ Title: poem.title, Verses: verses, SourceUrl: poem.source_url ?? undefined }, { شاعر: poem.poet }) };
}
+// what a revision changes: a work, or a book/section's order (work_title/work_url name either)
+const TARGET = `LEFT JOIN poems p ON r.entity = 'work' AND p.id = r.entity_id
+ LEFT JOIN categories c ON r.entity = 'order' AND c.id = r.entity_id`;
+const TARGET_COLS = `coalesce(p.title, c.title) AS work_title, coalesce(p.url, c.url) AS work_url`;
+
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];
+ return (await pool.query(`SELECT r.*, ${TARGET_COLS} FROM revisions r ${TARGET} WHERE r.id = $1 AND r.entity IN ('work', 'order')`, [id])).rows[0];
}
// 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';
+ const mine = Number(r.author_id) === Number(u.id), review = await mayReview(u, r), admin = u.role === 'admin';
return {
save: mine && OPEN.includes(r.status),
submit: mine && OPEN.includes(r.status),
@@ -79,19 +91,25 @@ async function actions(u: any, r: any) {
}
async function publish(r: any, u: any, comment?: string) {
- const cur = await current(r.entity_id);
+ const order = r.entity === 'order';
+ const cur = order ? await currentOrder(r.entity_id) : 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();
+ throw Object.assign(new Error('اس دوران اس کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 });
+ // the section's contents may have changed since (a new work from the sync): the arrangement must be redone
+ const stale = order && (await checkOrder(r.entity_id, r.content));
+ if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 });
+ const doc = order ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
+ const title = 'cat' in cur ? `ترتیب: ${cur.cat.title}` : doc!.meta['عنوان'] || cur.poem.title;
+ const version = cur.version + 1, at = new Date().toISOString();
// who did it, by public name (divan-data is public: never email addresses)
const people = async (id: unknown) => id ? (await pool.query('SELECT id, full_name FROM users WHERE id = $1', [id])).rows[0] ?? null : null;
const [author, reviewer] = await Promise.all([people(r.author_id), people(r.reviewer_id)]);
const who = (p: any) => p && { id: Number(p.id), name: publicName(p) };
const credits = { by: who(author)?.name ?? 'موڈریٹر', reviewedBy: who(reviewer)?.name ?? null, publishedBy: publicName(u) };
// divan-data first (the published record, committed to git), then the site's database
- const files = await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) });
+ const files = 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)]
+ : await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) });
const trailers = [`Divan-Revision: ${r.id}`, `Divan-Version: ${version}`,
...(reviewer ? [`Reviewed-by: ${identityOf(reviewer)}`] : []), `Approved-by: ${identityOf(u)}`];
const sha = await commit(dataDir(), files.map((f) => f.slice(dataDir().replace(/\/$/, '').length + 1)),
@@ -101,12 +119,15 @@ async function publish(r: any, u: any, comment?: string) {
await client.query('BEGIN');
await client.query(`UPDATE revisions SET status = 'published', version = $2, publisher_email = $3, published_at = $4, commit = $5, credits = $6, updated_at = now()
WHERE id = $1`, [r.id, version, u.email, at, sha, credits]);
- 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]);
+ if (order) await applyOrder(client, r.entity_id, r.content);
+ else {
+ await client.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1',
+ [r.entity_id, title, normalise([title, ...verses.map((v) => v.Text)].join(' '))]);
+ 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');
@@ -122,22 +143,25 @@ async function publish(r: any, u: any, comment?: string) {
const identityOf = (p: any) => identity({ id: Number(p.id), name: publicName(p) });
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' };
+ // ?poem= on a work page; ?category= on a poet or book/section page (arrange)
+ app.get<{ Querystring: { poem?: string; category?: string } }>('/api/mod/can', async (req) => {
+ const u = await sessionUser(req), poemId = Number(req.query.poem) || 0, categoryId = Number(req.query.category) || 0;
+ if (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false };
+ if (categoryId) return { arrange: await mayArrange(u, categoryId) };
+ return { edit: await mayEdit(u, poemId), review: await mayReview(u, { entity: 'work', entity_id: 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') AND ${CHANGED}))
+ `SELECT r.id, r.entity, r.entity_id, r.status, r.summary, r.author_id, r.author_email, r.reviewer_email, r.updated_at,
+ coalesce(p.title, c.title) AS title, coalesce(p.url, c.url) AS url
+ FROM revisions r ${TARGET}
+ WHERE r.entity IN ('work', 'order') AND (r.status IN ('submitted', 'approved') OR (r.author_id = $1 AND r.status IN ('draft', 'returned') AND ${CHANGED}))
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));
+ for (const r of rows) if (r.status === 'submitted' && Number(r.author_id) !== Number(u.id) && (await mayReview(u, r))) review.push(strip(r));
return {
mine: rows.filter((r) => Number(r.author_id) === Number(u.id)).map(strip),
review,
@@ -173,12 +197,41 @@ export function moderationRoutes(app: FastifyInstance) {
return { id: Number(rows[0].id) };
});
+ app.get<{ Params: { id: string } }>('/api/mod/order/:id', async (req, reply) => {
+ const u = await moderator(req, reply); if (!u) return;
+ const cur = await currentOrder(Number(req.params.id) || 0);
+ if (!cur) return reply.code(404).send({ error: 'حصہ نہیں ملا' });
+ const { rows } = await pool.query(
+ `SELECT id, version, base_version, status, summary, author_email, reviewer_email, publisher_email, created_at, published_at
+ FROM revisions r WHERE entity = 'order' AND entity_id = $1 AND ${CHANGED} ORDER BY coalesce(published_at, created_at) DESC`, [cur.cat.id]);
+ return { section: cur.cat, version: cur.version, content: cur.content, count: cur.count,
+ history: rows.map((r) => ({ ...r, id: Number(r.id) })), may: { arrange: await mayArrange(u, cur.cat.id) } };
+ });
+
+ app.post<{ Params: { id: string } }>('/api/mod/order/:id/draft', async (req, reply) => {
+ const u = await moderator(req, reply); if (!u) return;
+ const catId = Number(req.params.id) || 0;
+ if (!(await mayArrange(u, catId))) return reply.code(403).send({ error: 'اس حصے کی ترتیب بدلنے کی اجازت نہیں' });
+ const open = (await pool.query(
+ `SELECT id FROM revisions WHERE entity = 'order' AND entity_id = $1 AND author_id = $2 AND status IN ('draft', 'returned') LIMIT 1`, [catId, u.id])).rows[0];
+ if (open) return { id: Number(open.id) };
+ const cur = await currentOrder(catId);
+ if (!cur) return reply.code(404).send({ error: 'حصہ نہیں ملا' });
+ if (cur.count < 2) return reply.code(400).send({ error: 'اس حصے میں ترتیب دینے کو کچھ نہیں' });
+ const { rows } = await pool.query(
+ `INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email)
+ VALUES ('order', $1, $2, $3, $3, 'draft', $4, $5) RETURNING id`,
+ [catId, 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')
+ if (Number(r.author_id) !== Number(u.id) && !(await mayReview(u, r)) && 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;
@@ -192,11 +245,15 @@ export function moderationRoutes(app: FastifyInstance) {
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
if (!(await actions(u, r)).save) return reply.code(403).send({ error: 'یہ مسودہ اب محفوظ نہیں کیا جا سکتا' });
const content = String(req.body?.content ?? '').replace(/\r\n?/g, '\n');
- if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
+ if (r.entity === 'order') {
+ const bad = await checkOrder(r.entity_id, content);
+ if (bad) return reply.code(400).send({ error: bad });
+ } else if (!toVerses(parse(content)).length) return reply.code(400).send({ error: 'متن میں کوئی شعر یا پیراگراف نہیں' });
if (content.length > 500_000) return reply.code(400).send({ error: 'متن بہت لمبا ہے' });
// no change from the text it started from (compared as Divan text, so layout-only differences don't count):
// a plain draft is dropped rather than kept
- const same = toText(parse(content)) === toText(parse(r.base_content));
+ const norm = (t: string) => (r.entity === 'order' ? orderSlugs(t).join('\n') : toText(parse(t)));
+ const same = norm(content) === norm(r.base_content);
if (same && r.status === 'draft') {
await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]);
return { discarded: true };
@@ -246,9 +303,9 @@ export function moderationRoutes(app: FastifyInstance) {
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
- WHERE ${CHANGED}
+ `SELECT e.at, e.actor_email, e.action, e.comment, r.id AS revision, r.entity, r.version, coalesce(p.title, c.title) AS title, coalesce(p.url, c.url) AS url
+ FROM revision_events e JOIN revisions r ON r.id = e.revision_id ${TARGET}
+ WHERE r.entity IN ('work', 'order') AND ${CHANGED}
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) })) };
});
diff --git a/api/src/order.test.ts b/api/src/order.test.ts
new file mode 100644
index 00000000..bd68a2a7
--- /dev/null
+++ b/api/src/order.test.ts
@@ -0,0 +1,77 @@
+import { test, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtemp, readFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { execFileSync } from 'node:child_process';
+import Fastify from 'fastify';
+import { authRoutes } from './auth.ts';
+import { permissionRoutes } from './permissions.ts';
+import { moderationRoutes } from './moderation.ts';
+import { pool } from './db.ts';
+
+after(() => pool.end());
+
+test('arranging: its own permission; L2 reorders, L1 approves, admin publishes to divan-data and the site', async () => {
+ const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
+ process.env.DIVAN_DATA_DIR = data;
+ const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
+ git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
+ const app = Fastify();
+ authRoutes(app); 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': `ord-${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');
+
+ // Ghalib's ghazals; their order is restored at the end
+ const cat = (await pool.query(`SELECT id, url FROM categories WHERE url = '/p266/ghazal'`)).rows[0];
+ const before = (await pool.query('SELECT id, position FROM poems WHERE category_id = $1', [cat.id])).rows;
+ try {
+ const grant = (m: any, actions: string[]) =>
+ pool.query(`INSERT INTO grants (user_id, scope, scope_id, content, actions) VALUES ($1, 'category', $2, '{works}', $3)`, [m.id, cat.id, actions]);
+ await grant(l2, ['edit']);
+ assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false }, 'editing is not arranging');
+ assert.equal((await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).statusCode, 403);
+ await grant(l2, ['arrange']); await grant(l1, ['arrange']);
+ assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true });
+
+ const { id } = (await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).json();
+ const cur = (await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json();
+ const lines = cur.content.split('\n');
+ assert.ok(lines.length > 100 && /^sh\d+ /.test(lines[0]), 'one line per work: " "');
+ // the list must keep every item once
+ assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: lines.slice(1).join('\n') }, l2.token)).statusCode, 400, 'an item missing');
+ assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: [lines[0], ...lines].join('\n') }, l2.token)).statusCode, 400, 'an item twice');
+ // move the last ghazal to the top; titles may be anything (only the order counts)
+ const moved = [lines.at(-1), ...lines.slice(0, -1)].join('\n');
+ assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: moved, summary: 'آخری غزل پہلے' }, l2.token)).statusCode, 200);
+ assert.equal((await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token)).json().status, 'submitted');
+ assert.equal((await call('GET', '/api/mod/queue', undefined, l1.token)).json().review.some((r: any) => r.id === id && r.entity === 'order'), true);
+ assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, {}, l1.token)).json().status, 'approved');
+ assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
+
+ // the site follows the new order; divan-data holds it and its commit
+ const first = (await pool.query('SELECT url FROM poems WHERE category_id = $1 ORDER BY position, id LIMIT 1', [cat.id])).rows[0].url;
+ assert.equal(first.split('/').pop(), lines.at(-1).split(' ')[0]);
+ assert.equal(await readFile(join(data, 'divan', 'p266', 'ghazal.order'), 'utf8'), moved + '\n');
+ assert.match(git('log', '-1', '--format=%s').trim(), /^ترتیب: .+: آخری غزل پہلے \(ورژن 1\)$/);
+ assert.equal((await call('GET', `/api/mod/order/${cat.id}`, undefined, l2.token)).json().version, 1);
+
+ // a new draft saved unchanged is dropped
+ const again = (await call('POST', `/api/mod/order/${cat.id}/draft`, undefined, l2.token)).json().id;
+ assert.deepEqual((await call('POST', `/api/mod/revisions/${again}/save`, { content: moved }, l2.token)).json(), { discarded: true });
+ } finally {
+ for (const p of before) await pool.query('UPDATE poems SET position = $2 WHERE id = $1', [p.id, p.position]);
+ await pool.query(`DELETE FROM revisions WHERE author_email LIKE $1`, [`%-${run}@divan.test`]);
+ await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
+ await pool.query('DELETE FROM audit_log WHERE actor_email LIKE $1 OR target_email LIKE $1', [`%-${run}@divan.test`]);
+ await rm(data, { recursive: true, force: true });
+ await app.close();
+ }
+});
diff --git a/api/src/order.ts b/api/src/order.ts
new file mode 100644
index 00000000..ad5cd968
--- /dev/null
+++ b/api/src/order.ts
@@ -0,0 +1,61 @@
+// Arranging (#41): the order of a book or section's contents (its sub-sections, then its works); a poet's top level
+// lists their books. An arrangement is a revision (entity 'order', entity_id = categories.id) and goes through the
+// same pipeline as a work's text. Its text is one line per item, " ", e.g.
+// c641 بانگ درا
+// sh6556 ہمالہ
+// Only the order of the lines counts; titles are for reading. Published orders live in divan-data as
+// divan/.order, which the export follows (export_divan.py), so the daily sync keeps Divan's order.
+import { mkdir, writeFile } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import type { PoolClient } from 'pg';
+import { pool } from './db.ts';
+
+const slug = (url: string) => url.slice(url.lastIndexOf('/') + 1);
+
+// the category's items in their current order (the site's positions)
+async function items(catId: number) {
+ const [cats, poems] = await Promise.all([
+ pool.query('SELECT id, url, title FROM categories WHERE parent_id = $1 ORDER BY position, id', [catId]),
+ pool.query('SELECT id, url, title FROM poems WHERE category_id = $1 ORDER BY position, id', [catId]),
+ ]);
+ return { cats: cats.rows, poems: poems.rows };
+}
+
+const lines = (xs: { url: string; title: string }[]) => xs.map((x) => `${slug(x.url)} ${x.title}`);
+
+// a category's current order as text, and the latest published version of its order
+export async function currentOrder(catId: number) {
+ const cat = (await pool.query('SELECT id, url, title, parent_id FROM categories WHERE id = $1', [catId])).rows[0];
+ if (!cat) return null;
+ const { cats, poems } = await items(catId);
+ const last = (await pool.query(
+ `SELECT max(version) AS v FROM revisions WHERE entity = 'order' AND entity_id = $1 AND status = 'published'`, [catId])).rows[0];
+ return { cat, version: Number(last?.v ?? 0), content: [...lines(cats), ...lines(poems)].join('\n'), count: cats.length + poems.length };
+}
+
+export const orderSlugs = (text: string) => text.split('\n').map((l) => l.trim().split(/\s/)[0]).filter(Boolean);
+
+// the text must list every item of the category exactly once (nothing added, removed or repeated)
+export async function checkOrder(catId: number, text: string) {
+ const { cats, poems } = await items(catId);
+ const want = [...cats, ...poems].map((x) => slug(x.url)).sort(), got = orderSlugs(text);
+ if (new Set(got).size !== got.length) return 'ایک ہی چیز دو بار درج ہے';
+ if (got.length !== want.length || [...got].sort().some((s, i) => s !== want[i]))
+ return 'فہرست میں اس حصے کی تمام چیزیں ایک ایک بار ہونی چاہییں (نہ کوئی نئی، نہ کم)';
+ return null;
+}
+
+// positions on the site: sub-sections and works each numbered in the order of the text
+export async function applyOrder(client: PoolClient, catId: number, text: string) {
+ const at = new Map(orderSlugs(text).map((s, i) => [s, i]));
+ const { cats, poems } = await items(catId);
+ for (const [table, xs] of [['categories', cats], ['poems', poems]] as const)
+ for (const x of xs) await client.query(`UPDATE ${table} SET position = $2 WHERE id = $1`, [x.id, at.get(slug(x.url))]);
+}
+
+export async function writeOrder(dataDir: string, url: string, text: string) {
+ const path = join(dataDir, 'divan', url.replace(/^\//, '') + '.order');
+ await mkdir(dirname(path), { recursive: true });
+ await writeFile(path, text.trim() + '\n');
+ return path;
+}
diff --git a/web/src/pages/[...path].astro b/web/src/pages/[...path].astro
index 28f1b6ba..38370b01 100644
--- a/web/src/pages/[...path].astro
+++ b/web/src/pages/[...path].astro
@@ -69,6 +69,9 @@ const label = (on: unknown) => (on ? 'محفوظ شدہ' : 'محفوظ کریں'
// moderators who may edit this work get edit and history links
const modCan = token && poemId && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
? (await asUser(token, `/api/mod/can?poem=${poemId}`)).data : { edit: false };
+// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41)
+const arrange = token && page.type !== 'poem' && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
+ && (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data.arrange;
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;
---
@@ -84,7 +87,8 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
{page.type === 'poet' && (
<>
{page.poet.name}
-
+ {arrange && <>
+ ترتیب کا تاریخچہ>}
{years(page.poet)}
{page.poet.description && {page.poet.description}
}
>
@@ -92,7 +96,8 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
{page.type === 'category' && (
<>
{page.category.title}
-
+ {arrange && <>
+ ترتیب کا تاریخچہ>}
>
)}
diff --git a/web/src/pages/mod/order/[id].astro b/web/src/pages/mod/order/[id].astro
new file mode 100644
index 00000000..423e0a2b
--- /dev/null
+++ b/web/src/pages/mod/order/[id].astro
@@ -0,0 +1,46 @@
+---
+// A book/section's arrangements (#41): its current order, published versions and drafts; "ترتیب دیں" starts a draft
+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;
+let error = '';
+if (Astro.request.method === 'POST') {
+ const r = await asUser(token, `/api/mod/order/${id}/draft`, {});
+ if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`);
+ error = r.data.error ?? 'کچھ غلط ہو گیا';
+}
+const res = await asUser(token, `/api/mod/order/${id}`);
+if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
+const { section, version, history, count, may } = res.data;
+---
+
+ ترتیب: {section.title}
+
+ {section.parent_id ? 'کتاب یا باب' : 'شاعر کا صفحہ'} · {ud(count)} چیزیں ·
+ {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/rev/[id].astro b/web/src/pages/mod/rev/[id].astro
index a9e9fbca..797cf1c9 100644
--- a/web/src/pages/mod/rev/[id].astro
+++ b/web/src/pages/mod/rev/[id].astro
@@ -23,21 +23,47 @@ if (Astro.request.method === 'POST') {
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;
+// an arrangement (#41) lists a book/section's items, one per line: " "
+const order = rev.entity === 'order';
+const items = order ? rev.content.split('\n').filter(Boolean).map((l: string) => ({ line: l, title: l.slice(l.indexOf(' ') + 1) })) : [];
const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]);
---
- {rev.work_title}
+ {order && 'ترتیب: '}{rev.work_title}
{STATUS[rev.status]}
· {rev.author_email}
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
- · کلام · تاریخچہ
+ · {order ? 'حصہ' : 'کلام'} · تاریخچہ
{error && {error}
}
{done && {done}
}
- {may.save ? (
+ {may.save && order ? (
+
+ ) : may.save ? (