Arranging content (#41): reorder a book or section's works and sub-sections, a poet's books

- 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/<url>.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 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-09 14:02:26 +02:00
parent 0a4d1f7b0f
commit cde754412e
7 changed files with 342 additions and 36 deletions

View File

@ -9,11 +9,15 @@
// Publishing numbers the version, writes it to divan-data as Divan-owned content (owned.ts), commits it there (git.ts) // Publishing numbers the version, writes it to divan-data as Divan-owned content (owned.ts), commits it there (git.ts)
// and updates the site. // and updates the site.
// If another version was published after the draft started, publishing is refused until the draft is redone. // 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/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/queue my drafts, drafts to review, drafts to publish
// GET /api/mod/work/:id a work's current text and its history // GET /api/mod/work/:id a work's current text and its history
// POST /api/mod/work/:id/draft start (or reopen) my draft // 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/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/save {content, summary}
// POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment} // 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 did what, newest first
@ -26,12 +30,17 @@ import { parse, toVerses, toText } from './divantext.ts';
import { normalise } from './urdu.ts'; import { normalise } from './urdu.ts';
import { diffLines, changed } from './diff.ts'; import { diffLines, changed } from './diff.ts';
import { commit, identity } from './git.ts'; import { commit, identity } from './git.ts';
import { currentOrder, checkOrder, applyOrder, writeOrder, orderSlugs } from './order.ts';
import { publicName } from './auth.ts'; import { publicName } from './auth.ts';
const dataDir = () => process.env.DIVAN_DATA_DIR ?? new URL('../../../divan-data', import.meta.url).pathname; 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 isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role);
const mayEdit = (u: any, poemId: number) => can(u, 'edit', 'works', { poemId }); 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']; 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) // 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)`; 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 }) }; 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) { async function revision(id: number) {
return (await pool.query( 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];
`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 // what this person may do with this revision now
async function actions(u: any, r: any) { 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 { return {
save: mine && OPEN.includes(r.status), save: mine && OPEN.includes(r.status),
submit: 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) { 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) throw Object.assign(new Error('کلام نہیں ملا'), { code: 404 });
if (cur.version !== r.base_version) if (cur.version !== r.base_version)
throw Object.assign(new Error('اس دوران اس کلام کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 }); throw Object.assign(new Error('اس دوران اس کا نیا ورژن شائع ہو چکا ہے۔ مسودہ واپس بھیج کر تازہ متن پر دوبارہ بنوائیں۔'), { code: 409 });
const doc = parse(r.content), verses = toVerses(doc); // the section's contents may have changed since (a new work from the sync): the arrangement must be redone
const title = doc.meta['عنوان'] || cur.poem.title, version = cur.version + 1, at = new Date().toISOString(); 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) // 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 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 [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 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) }; 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 // 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}`, const trailers = [`Divan-Revision: ${r.id}`, `Divan-Version: ${version}`,
...(reviewer ? [`Reviewed-by: ${identityOf(reviewer)}`] : []), `Approved-by: ${identityOf(u)}`]; ...(reviewer ? [`Reviewed-by: ${identityOf(reviewer)}`] : []), `Approved-by: ${identityOf(u)}`];
const sha = await commit(dataDir(), files.map((f) => f.slice(dataDir().replace(/\/$/, '').length + 1)), 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('BEGIN');
await client.query(`UPDATE revisions SET status = 'published', version = $2, publisher_email = $3, published_at = $4, commit = $5, credits = $6, updated_at = now() 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]); WHERE id = $1`, [r.id, version, u.email, at, sha, credits]);
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', 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(' '))]); [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]); await client.query('DELETE FROM verses WHERE poem_id = $1', [r.entity_id]);
for (const v of verses) for (const v of verses)
await client.query('INSERT INTO verses (poem_id, vorder, position, couplet, text) VALUES ($1, $2, $3, $4, $5)', 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]); [r.entity_id, v.VOrder, v.Position, v.CoupletIndex, v.Text]);
}
await client.query('COMMIT'); await client.query('COMMIT');
} catch (e) { } catch (e) {
await client.query('ROLLBACK'); 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) }); const identityOf = (p: any) => identity({ id: Number(p.id), name: publicName(p) });
export function moderationRoutes(app: FastifyInstance) { export function moderationRoutes(app: FastifyInstance) {
app.get<{ Querystring: { poem?: string } }>('/api/mod/can', async (req) => { // ?poem= on a work page; ?category= on a poet or book/section page (arrange)
const u = await sessionUser(req), poemId = Number(req.query.poem) || 0; app.get<{ Querystring: { poem?: string; category?: string } }>('/api/mod/can', async (req) => {
if (!isModerator(u)) return { edit: false, review: false, publish: false }; const u = await sessionUser(req), poemId = Number(req.query.poem) || 0, categoryId = Number(req.query.category) || 0;
return { edit: await mayEdit(u, poemId), review: await mayReview(u, poemId), publish: u.role === 'admin' }; 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) => { app.get('/api/mod/queue', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return; const u = await moderator(req, reply); if (!u) return;
const { rows } = await pool.query( 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 `SELECT r.id, r.entity, r.entity_id, r.status, r.summary, r.author_id, r.author_email, r.reviewer_email, r.updated_at,
FROM revisions r JOIN poems p ON p.id = r.entity_id coalesce(p.title, c.title) AS title, coalesce(p.url, c.url) AS url
WHERE r.entity = 'work' AND (r.status IN ('submitted', 'approved') OR (r.author_id = $1 AND r.status IN ('draft', 'returned') AND ${CHANGED})) 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]); ORDER BY r.updated_at DESC LIMIT 300`, [u.id]);
const strip = ({ author_id, ...r }: any) => ({ ...r, id: Number(r.id) }); const strip = ({ author_id, ...r }: any) => ({ ...r, id: Number(r.id) });
const review = []; 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 { return {
mine: rows.filter((r) => Number(r.author_id) === Number(u.id)).map(strip), mine: rows.filter((r) => Number(r.author_id) === Number(u.id)).map(strip),
review, review,
@ -173,12 +197,41 @@ export function moderationRoutes(app: FastifyInstance) {
return { id: Number(rows[0].id) }; 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) => { app.get<{ Params: { id: string } }>('/api/mod/revisions/:id', async (req, reply) => {
const u = await moderator(req, reply); if (!u) return; const u = await moderator(req, reply); if (!u) return;
const r = await revision(Number(req.params.id) || 0); const r = await revision(Number(req.params.id) || 0);
if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' }); if (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
const may = await actions(u, r); 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: 'یہ مسودہ دیکھنے کی اجازت نہیں' }); return reply.code(403).send({ error: 'یہ مسودہ دیکھنے کی اجازت نہیں' });
const diff = diffLines(r.base_content, r.content); // against the text the draft started from 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 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 (!r) return reply.code(404).send({ error: 'مسودہ نہیں ملا' });
if (!(await actions(u, r)).save) return reply.code(403).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'); 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: 'متن بہت لمبا ہے' }); 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): // 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 // 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') { if (same && r.status === 'draft') {
await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]); await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]);
return { discarded: true }; return { discarded: true };
@ -246,9 +303,9 @@ export function moderationRoutes(app: FastifyInstance) {
const u = await moderator(req, reply); if (!u) return; 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);
const { rows } = await pool.query( 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 `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 JOIN poems p ON p.id = r.entity_id FROM revision_events e JOIN revisions r ON r.id = e.revision_id ${TARGET}
WHERE ${CHANGED} WHERE r.entity IN ('work', 'order') AND ${CHANGED}
ORDER BY e.at DESC, e.id DESC LIMIT 50 OFFSET ${(page - 1) * 50}`); 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) })) }; return { page, entries: rows.map((r) => ({ ...r, revision: Number(r.revision) })) };
}); });

77
api/src/order.test.ts Normal file
View File

@ -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: "<url part> <title>"');
// 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();
}
});

61
api/src/order.ts Normal file
View File

@ -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, "<last part of the URL> <title>", e.g.
// c641 بانگ درا
// sh6556 ہمالہ
// Only the order of the lines counts; titles are for reading. Published orders live in divan-data as
// divan/<category url>.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;
}

View File

@ -69,6 +69,9 @@ const label = (on: unknown) => (on ? 'محفوظ شدہ' : 'محفوظ کریں'
// moderators who may edit this work get edit and history links // 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 ?? '') 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 }; ? (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 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; 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.type === 'poet' && (
<> <>
<h1>{page.poet.name}</h1> <h1>{page.poet.name}</h1>
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button></p> <p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn">ترتیب دیں</button></form>
<a class="mod-history" href={`/mod/order/${page.category.id}`}>ترتیب کا تاریخچہ</a></>}</p>
<p class="muted">{years(page.poet)}</p> <p class="muted">{years(page.poet)}</p>
{page.poet.description && <div class="intro">{page.poet.description}</div>} {page.poet.description && <div class="intro">{page.poet.description}</div>}
</> </>
@ -92,7 +96,8 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
{page.type === 'category' && ( {page.type === 'category' && (
<> <>
<h1>{page.category.title}</h1> <h1>{page.category.title}</h1>
<p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button></p> <p class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'category', categoryId: page.category.id })} aria-pressed={String(!!saved.category)} title="یہ کتاب یا باب محفوظ کریں">{label(saved.category)}</button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn">ترتیب دیں</button></form>
<a class="mod-history" href={`/mod/order/${page.category.id}`}>ترتیب کا تاریخچہ</a></>}</p>
</> </>
)} )}

View File

@ -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;
---
<Base title={`ترتیب: ${section.title}`}>
<h1>ترتیب: {section.title}</h1>
<ModNav />
<p class="center"><a href={section.url}>{section.parent_id ? 'کتاب یا باب' : 'شاعر کا صفحہ'}</a> · {ud(count)} چیزیں ·
{version ? `موجودہ ترتیب: ورژن ${ud(version)}` : 'ویکی ماخذ کی ترتیب (ابھی دیوان کی کوئی ترتیب نہیں)'}</p>
{error && <p class="form-error" role="alert">{error}</p>}
{may.arrange && count > 1 && <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>

View File

@ -23,21 +23,47 @@ if (Astro.request.method === 'POST') {
const res = await asUser(token, `/api/mod/revisions/${id}`); const res = await asUser(token, `/api/mod/revisions/${id}`);
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status }); if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
const { revision: rev, diff, changes, events, may } = res.data; const { revision: rev, diff, changes, events, may } = res.data;
// an arrangement (#41) lists a book/section's items, one per line: "<url part> <title>"
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]); const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]);
--- ---
<Base title={`مسودہ: ${rev.work_title}`}> <Base title={`مسودہ: ${rev.work_title}`}>
<h1>{rev.work_title}</h1> <h1>{order && 'ترتیب: '}{rev.work_title}</h1>
<ModNav /> <ModNav />
<p class="center"> <p class="center">
<span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span> <span class={`status s-${rev.status}`}>{STATUS[rev.status]}</span>
· <bdi dir="ltr">{rev.author_email}</bdi> · <bdi dir="ltr">{rev.author_email}</bdi>
· {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'} · {rev.base_version ? `ورژن ${ud(rev.base_version)} سے` : 'ویکی ماخذ کے متن سے'}
· <a href={rev.work_url}>کلام</a> · <a href={`/mod/work/${rev.entity_id}`}>تاریخچہ</a> · <a href={rev.work_url}>{order ? 'حصہ' : 'کلام'}</a> · <a href={`/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a>
</p> </p>
{error && <p class="form-error" role="alert">{error}</p>} {error && <p class="form-error" role="alert">{error}</p>}
{done && <p class="form-done" role="status">{done}</p>} {done && <p class="form-done" role="status">{done}</p>}
{may.save ? ( {may.save && order ? (
<form method="post" class="mod-edit">
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: غزلیں مطبوعہ دیوان کی ترتیب میں" /></label>
<p class="muted note">کسی چیز کو کھینچ کر اس کی جگہ رکھیں، یا ↑ ↓ سے ایک جگہ اوپر نیچے کریں؛ «اول» سب سے اوپر، «آخر» سب سے نیچے۔</p>
<ol class="order-list" id="order-list">
{items.map((it: any) => (
<li draggable="true" data-line={it.line}>
<span class="order-title">{it.title}</span>
<span class="order-tools">
<button type="button" class="tool" data-move="top" title="سب سے اوپر">اول</button>
<button type="button" class="tool" data-move="up" title="اوپر">↑</button>
<button type="button" class="tool" data-move="down" title="نیچے">↓</button>
<button type="button" class="tool" data-move="bottom" title="سب سے نیچے">آخر</button>
</span>
</li>
))}
</ol>
<textarea name="content" id="order-content" hidden>{rev.content}</textarea>
<div class="filter-actions">
<button name="act" value="save">محفوظ کریں</button>
<button name="act" value="submit">{me.role === 'admin' ? 'محفوظ کر کے شائع کریں' : me.role === 'mod-l1' ? 'محفوظ کر کے اشاعت کے لیے بھیجیں' : 'محفوظ کر کے جائزے کے لیے بھیجیں'}</button>
</div>
</form>
) : may.save ? (
<form method="post" class="mod-edit"> <form method="post" class="mod-edit">
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: دوسرے شعر کا مصرع درست کیا" /></label> <label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu placeholder="مثلاً: دوسرے شعر کا مصرع درست کیا" /></label>
<nav class="admin-nav editor-tabs" role="tablist"> <nav class="admin-nav editor-tabs" role="tablist">
@ -95,7 +121,7 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
<section class="lib-section"> <section class="lib-section">
<h2>پیش نظارہ</h2> <h2>پیش نظارہ</h2>
<DivanPreview text={rev.content} /> {order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol> : <DivanPreview text={rev.content} />}
</section> </section>
{acts.length > 0 && ( {acts.length > 0 && (
@ -114,6 +140,33 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
</ol> </ol>
</section> </section>
<script> <script>
// arranging: drag an item, or move it with the buttons; the hidden text follows the list
const list = document.getElementById('order-list');
const text = document.getElementById('order-content') as HTMLTextAreaElement | null;
if (list && text) {
const sync = () => { text.value = [...list.children].map((li) => (li as HTMLElement).dataset.line).join('\n'); };
const flash = (li: Element) => { li.classList.add('moved'); li.scrollIntoView({ block: 'nearest' }); setTimeout(() => li.classList.remove('moved'), 600); };
list.addEventListener('click', (e) => {
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-move]'); if (!b) return;
const li = b.closest('li')!, m = b.dataset.move;
if (m === 'up' && li.previousElementSibling) li.previousElementSibling.before(li);
else if (m === 'down' && li.nextElementSibling) li.nextElementSibling.after(li);
else if (m === 'top') list.prepend(li);
else if (m === 'bottom') list.append(li);
sync(); flash(li); b.focus();
});
let dragged: HTMLElement | null = null;
list.addEventListener('dragstart', (e) => { dragged = (e.target as HTMLElement).closest('li'); dragged?.classList.add('dragging'); });
list.addEventListener('dragend', () => { dragged?.classList.remove('dragging'); dragged = null; sync(); });
list.addEventListener('dragover', (e) => {
e.preventDefault();
const over = (e.target as HTMLElement).closest('li');
if (!dragged || !over || over === dragged) return;
const r = over.getBoundingClientRect();
e.clientY > r.top + r.height / 2 ? over.after(dragged) : over.before(dragged);
});
}
// the structured editor (Tiptap) and the source view edit the same Divan text; saving always sends the text // the structured editor (Tiptap) and the source view edit the same Divan text; saving always sends the text
import { createEditor, fromEditor, toEditor, actions } from '../../../lib/editor'; import { createEditor, fromEditor, toEditor, actions } from '../../../lib/editor';
import { parse } from '../../../../../api/src/divantext.ts'; import { parse } from '../../../../../api/src/divantext.ts';

View File

@ -365,6 +365,13 @@ h1 + .muted { text-align: center; margin-top: 0; }
.tool { font: inherit; font-size: .85rem; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); cursor: pointer; } .tool { font: inherit; font-size: .85rem; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--inner); color: var(--ink); cursor: pointer; }
.tool:hover, .tool[aria-pressed='true'] { border-color: var(--brand); color: var(--brand); } .tool:hover, .tool[aria-pressed='true'] { border-color: var(--brand); color: var(--brand); }
.dv-editor { min-height: 20em; outline: none; margin: 8px 0; } .dv-editor { min-height: 20em; outline: none; margin: 8px 0; }
/* arranging (#41): one row per item, moved by dragging or the ↑ ↓ buttons */
.order-list { list-style: urdu; padding-inline-start: 2.5em; margin: 8px 0; }
.order-list li { display: flex; align-items: center; gap: 8px; padding: 2px 8px; margin: 3px 0; background: var(--paper); border: 1px solid var(--border); border-radius: 8px; cursor: grab; display: list-item; }
.order-list li .order-tools { float: left; display: inline-flex; gap: 2px; }
.order-list li .tool { min-width: 1.8em; padding: 0 4px; font-size: .9rem; }
.order-list li.dragging { opacity: .5; }
.order-list li.moved { border-color: var(--brand); }
/* کوڈ: Divan text beside the editor (stacked on phones) */ /* کوڈ: Divan text beside the editor (stacked on phones) */
.editor-split.with-code { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; align-items: start; } .editor-split.with-code { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; align-items: start; }
@media (max-width: 800px) { .editor-split.with-code { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 800px) { .editor-split.with-code { grid-template-columns: minmax(0, 1fr); } }