From ed2973dea8c4fdb7b6c9493faed756a11786125c Mon Sep 17 00:00:00 2001 From: Anas Rashid Date: Fri, 9 Oct 2026 01:20:05 +0200 Subject: [PATCH] =?UTF-8?q?Editor:=20=DA=A9=D9=88=DA=88=20shows=20the=20Di?= =?UTF-8?q?van=20text=20beside=20the=20editor,=20live;=20unchanged=20draft?= =?UTF-8?q?s=20are=20not=20kept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - کوڈ in the toolbar: the Divan text next to the editor (stacked on phones), updated as you type; the سورس tab still edits it. - A draft is only listed (my drafts, history, activity) once its text differs from the text it started from; saving it unchanged drops it (compared as Divan text, so layout-only differences don't count) and says so; submitting an unchanged returned draft is refused. Co-Authored-By: Claude Opus 5.5 --- api/src/moderation.test.ts | 8 ++++++++ api/src/moderation.ts | 17 ++++++++++++++--- web/src/pages/mod/index.astro | 1 + web/src/pages/mod/rev/[id].astro | 16 ++++++++++++++-- web/src/styles/global.css | 5 +++++ 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/api/src/moderation.test.ts b/api/src/moderation.test.ts index ce716144..a1af5b62 100644 --- a/api/src/moderation.test.ts +++ b/api/src/moderation.test.ts @@ -50,6 +50,13 @@ test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permi 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 }); + // opening the editor is not a change: an unchanged draft is not listed, and saving it unchanged drops it + const blank = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json().id; + assert.equal((await call('GET', '/api/mod/queue', undefined, l2.token)).json().mine.some((r: any) => r.id === blank), false, 'unchanged draft not listed'); + const text = (await call('GET', `/api/mod/work/${poem.id}`, undefined, l2.token)).json().content; + assert.deepEqual((await call('POST', `/api/mod/revisions/${blank}/save`, { content: text.replace('}}\n', '}}\n\n') }, l2.token)).json(), { discarded: true }, 'layout-only difference is no change'); + assert.equal((await call('GET', `/api/mod/revisions/${blank}`, undefined, l2.token)).statusCode, 404, 'dropped'); + // 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); @@ -59,6 +66,7 @@ test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permi 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); + assert.equal((await call('GET', '/api/mod/queue', undefined, l2.token)).json().mine.some((r: any) => r.id === id), true, 'changed draft listed'); 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'); diff --git a/api/src/moderation.ts b/api/src/moderation.ts index 8dbbd8eb..92cabff9 100644 --- a/api/src/moderation.ts +++ b/api/src/moderation.ts @@ -22,7 +22,7 @@ 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 { parse, toVerses, toText } from './divantext.ts'; import { normalise } from './urdu.ts'; import { diffLines, changed } from './diff.ts'; import { commit, identity } from './git.ts'; @@ -33,6 +33,8 @@ 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']; +// 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)`; async function moderator(req: FastifyRequest, reply: FastifyReply) { const u = await sessionUser(req); @@ -131,7 +133,7 @@ export function moderationRoutes(app: FastifyInstance) { 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'))) + WHERE r.entity = 'work' 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 = []; @@ -149,7 +151,7 @@ export function moderationRoutes(app: FastifyInstance) { 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]); + FROM revisions r WHERE entity = 'work' AND entity_id = $1 AND ${CHANGED} 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) } }; }); @@ -192,6 +194,14 @@ export function moderationRoutes(app: FastifyInstance) { 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: 'متن بہت لمبا ہے' }); + // 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)); + if (same && r.status === 'draft') { + await pool.query('DELETE FROM revisions WHERE id = $1', [r.id]); + return { discarded: true }; + } + if (same) 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'); @@ -238,6 +248,7 @@ export function moderationRoutes(app: FastifyInstance) { 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} 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/web/src/pages/mod/index.astro b/web/src/pages/mod/index.astro index ce01cfc0..11e23b67 100644 --- a/web/src/pages/mod/index.astro +++ b/web/src/pages/mod/index.astro @@ -21,6 +21,7 @@ const groups = queue ? [

موڈریشن

+ {Astro.url.searchParams.has('unchanged') &&

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

} {groups.map(([title, items, empty]) => (

{title} {ud(items.length)}

diff --git a/web/src/pages/mod/rev/[id].astro b/web/src/pages/mod/rev/[id].astro index 6bfd1af3..a9e9fbca 100644 --- a/web/src/pages/mod/rev/[id].astro +++ b/web/src/pages/mod/rev/[id].astro @@ -15,6 +15,7 @@ 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 && r.data.discarded) return Astro.redirect('/mod?unchanged=1'); // nothing changed: no draft kept 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)}` : ''}`; @@ -64,8 +65,13 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج + + + +
+
+
-

Enter: اسی شعر یا بند میں نیا مصرع · خالی مصرع پر Enter: نیا شعر · لکھنے کا مکمل طریقہ