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 ? [
متن میں کوئی تبدیلی نہیں تھی، اس لیے مسودہ نہیں رکھا گیا۔
} {groups.map(([title, items, empty]) => (Enter: اسی شعر یا بند میں نیا مصرع · خالی مصرع پر Enter: نیا شعر · لکھنے کا مکمل طریقہ