Poet and book editors (#32): a poet's name, pen name, years and intro, and a book or section's title, as drafts through the pipeline; published to divan-data (divan/<url>.poet, .book) and the site; history, compare and revert like works; intros in paragraphs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
77c07c1552
commit
6874a4e4a2
91
api/src/details.test.ts
Normal file
91
api/src/details.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
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 { adminRoutes } from './admin.ts';
|
||||
import { permissionRoutes } from './permissions.ts';
|
||||
import { moderationRoutes } from './moderation.ts';
|
||||
import { checkDetails, parseDetails } from './details.ts';
|
||||
import { pool } from './db.ts';
|
||||
|
||||
after(() => pool.end());
|
||||
|
||||
test('details text: fields, the intro to the end, checks', () => {
|
||||
const d = parseDetails('نام: محمد اقبال\nتخلص: اقبال\nپیدائش: ۱۸۷۷\nوفات: 1938\nتعارف:\nپہلی سطر\n\nدوسری: سطر');
|
||||
assert.equal(d['تعارف'], 'پہلی سطر\n\nدوسری: سطر', 'a colon in the intro is text');
|
||||
assert.equal(checkDetails('poet', 'نام: محمد اقبال\nتخلص: اقبال\nپیدائش: ۱۸۷۷\nوفات: 1938\nتعارف:\nx'), null, 'Urdu digits are years');
|
||||
assert.match(checkDetails('poet', 'نام: \nتخلص: اقبال')!, /ضروری/);
|
||||
assert.match(checkDetails('poet', 'نام: الف\nتخلص: ب\nپیدائش: 1900\nوفات: 1800')!, /پہلے/);
|
||||
assert.match(checkDetails('poet', 'نام: الف\nتخلص: ب\nپیدائش: سن')!, /عیسوی/);
|
||||
assert.match(checkDetails('book', 'عنوان:')!, /ضروری/);
|
||||
});
|
||||
|
||||
test('poet and book editors: permissions, publishing to divan-data and the site, history and revert', 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); 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': `det-${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'), l2 = await person('l2', 'mod-l2');
|
||||
const poet = (await pool.query(`SELECT * FROM poets WHERE url = '/p266'`)).rows[0];
|
||||
const root = (await pool.query(`SELECT id, title FROM categories WHERE poet_id = $1 AND parent_id IS NULL`, [poet.id])).rows[0];
|
||||
const book = (await pool.query(`SELECT id, url, title FROM categories WHERE url = '/p266/ghazal'`)).rows[0];
|
||||
try {
|
||||
assert.equal((await call('POST', `/api/mod/details/poet/${poet.id}/draft`, undefined, l2.token)).statusCode, 403, 'no grant yet');
|
||||
await call('POST', `/api/admin/users/${l2.id}/grants`, { scope: 'poet', target: '/p266', content: ['poets', 'books'], actions: ['edit'] }, admin.token);
|
||||
const can = (await call('GET', `/api/mod/can?category=${book.id}`, undefined, l2.token)).json();
|
||||
assert.deepEqual([can.poet, can.book], [true, true]);
|
||||
|
||||
// the poet: L2 drafts a new intro and years; the admin publishes
|
||||
const cur = (await call('GET', `/api/mod/details/poet/${poet.id}`, undefined, l2.token)).json();
|
||||
assert.equal(cur.fields['تخلص'], poet.nickname);
|
||||
const id = (await call('POST', `/api/mod/details/poet/${poet.id}/draft`, undefined, l2.token)).json().id;
|
||||
const text = `نام: ${poet.name}\nتخلص: ${poet.nickname}\nپیدائش: 1797\nوفات: 1869\nتعارف:\nنیا تعارف\nدوسرا پیراگراف`;
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: 'نام:\nتخلص: x' }, l2.token)).statusCode, 400);
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: text, summary: 'تعارف' }, l2.token)).statusCode, 200);
|
||||
await pool.query(`UPDATE revisions SET status = 'approved' WHERE id = $1`, [id]); // the L1 step is tested in moderation.test.ts
|
||||
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
|
||||
const now = (await pool.query('SELECT description, birth_year_ce, death_year_ce, birth_year_ah FROM poets WHERE id = $1', [poet.id])).rows[0];
|
||||
assert.deepEqual([now.description, now.birth_year_ce, now.death_year_ce, now.birth_year_ah], ['نیا تعارف\nدوسرا پیراگراف', 1797, 1869, 1212]);
|
||||
assert.match(await readFile(join(data, 'divan', 'p266.poet'), 'utf8'), /^نام: .+\nتخلص: .+\nپیدائش: 1797\nوفات: 1869\nتعارف:\nنیا تعارف/);
|
||||
assert.match(git('log', '-1', '--format=%s'), new RegExp(`^شاعر: ${poet.nickname}: تعارف \\(ورژن 1\\)`));
|
||||
|
||||
// the book's title, by the admin directly
|
||||
const b = (await call('POST', `/api/mod/details/book/${book.id}/draft`, undefined, admin.token)).json().id;
|
||||
await call('POST', `/api/mod/revisions/${b}/save`, { content: 'عنوان: غزلیات (نیا)' }, admin.token);
|
||||
assert.equal((await call('POST', `/api/mod/revisions/${b}/submit`, {}, admin.token)).json().version, 1);
|
||||
assert.equal((await pool.query('SELECT title FROM categories WHERE id = $1', [book.id])).rows[0].title, 'غزلیات (نیا)');
|
||||
assert.equal((await readFile(join(data, 'divan', 'p266', 'ghazal.book'), 'utf8')), 'عنوان: غزلیات (نیا)\n');
|
||||
|
||||
// history: compare with the text before Divan's version, and bring it back as a draft
|
||||
const cmp = (await call('GET', `/api/mod/compare/poet/${poet.id}?a=0&b=1`, undefined, l2.token)).json();
|
||||
assert.ok(cmp.diff.some((d: any) => d.op === '+' && d.text === 'نیا تعارف'));
|
||||
const back = (await call('POST', `/api/mod/revert/book/${book.id}`, { version: 0 }, l2.token)).json().id;
|
||||
assert.equal((await call('GET', `/api/mod/revisions/${back}`, undefined, l2.token)).json().revision.content, `عنوان: ${book.title}`);
|
||||
const log = (await call('GET', `/api/mod/log?who=l2-${run}`, undefined, admin.token)).json().entries;
|
||||
assert.ok(log.some((e: any) => e.entity === 'poet' && e.title === poet.nickname && e.url === '/p266'), 'the log names the poet');
|
||||
} finally {
|
||||
await pool.query(`UPDATE poets SET name = $2, nickname = $3, description = $4, birth_year_ce = $5, death_year_ce = $6, birth_year_ah = $7, death_year_ah = $8 WHERE id = $1`,
|
||||
[poet.id, poet.name, poet.nickname, poet.description, poet.birth_year_ce, poet.death_year_ce, poet.birth_year_ah, poet.death_year_ah]);
|
||||
await pool.query('UPDATE categories SET title = $2 WHERE id = $1', [root.id, root.title]);
|
||||
await pool.query('UPDATE categories SET title = $2 WHERE id = $1', [book.id, book.title]);
|
||||
await pool.query(`DELETE FROM revisions WHERE entity IN ('poet', 'book') AND 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();
|
||||
}
|
||||
});
|
||||
84
api/src/details.ts
Normal file
84
api/src/details.ts
Normal file
@ -0,0 +1,84 @@
|
||||
// Poet and book details (#32): a poet's name, pen name, years and intro, and a book or section's title, edited in
|
||||
// Divan. Each is a revision (entity 'poet', entity_id = poets.id; 'book', entity_id = categories.id) through the same
|
||||
// pipeline as a work. The text is one "label: value" per line; a poet's intro follows "تعارف:" to the end:
|
||||
// نام: محمد اقبال
|
||||
// تخلص: اقبال
|
||||
// پیدائش: 1877
|
||||
// وفات: 1938
|
||||
// تعارف:
|
||||
// ڈاکٹر سر علامہ محمد اقبال …
|
||||
// Published details live in divan-data as divan/<url>.poet and divan/<url>.book, which the export
|
||||
// (export_divan.py) prefers to Wikisource's and Wikipedia's, so the daily sync keeps them.
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { pool } from './db.ts';
|
||||
|
||||
export type DetailKind = 'poet' | 'book';
|
||||
export const isDetail = (entity: string): entity is DetailKind => entity === 'poet' || entity === 'book';
|
||||
const POET = ['نام', 'تخلص', 'پیدائش', 'وفات'];
|
||||
const hijri = (ce: number | null) => (ce ? Math.round(((ce - 622) * 33) / 32) : null); // as export_divan.py
|
||||
|
||||
export function parseDetails(text: string) {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n'), out: Record<string, string> = {};
|
||||
const intro = lines.findIndex((l) => /^تعارف\s*:/.test(l));
|
||||
for (const l of intro < 0 ? lines : lines.slice(0, intro)) {
|
||||
const m = l.match(/^([^:]+):\s*(.*)$/);
|
||||
if (m) out[m[1].trim()] = m[2].trim();
|
||||
}
|
||||
if (intro >= 0) out['تعارف'] = [lines[intro].replace(/^تعارف\s*:\s*/, ''), ...lines.slice(intro + 1)].join('\n').trim();
|
||||
return out;
|
||||
}
|
||||
|
||||
export const detailsText = (kind: DetailKind, d: Record<string, string>) => kind === 'book' ? `عنوان: ${d['عنوان'] ?? ''}`
|
||||
: `${POET.map((l) => `${l}: ${d[l] ?? ''}`).join('\n')}\nتعارف:\n${d['تعارف'] ?? ''}`;
|
||||
|
||||
// what the details are now (the site's values, which follow the latest published version) and that version
|
||||
export async function currentDetails(kind: DetailKind, id: number) {
|
||||
const row = kind === 'poet'
|
||||
? (await pool.query('SELECT id, url, nickname AS title, name, nickname, description, birth_year_ce, death_year_ce FROM poets WHERE id = $1', [id])).rows[0]
|
||||
: (await pool.query('SELECT id, url, title FROM categories WHERE id = $1', [id])).rows[0];
|
||||
if (!row) return null;
|
||||
const last = (await pool.query(
|
||||
`SELECT max(version) AS v FROM revisions WHERE entity = $1 AND entity_id = $2 AND status = 'published'`, [kind, id])).rows[0];
|
||||
const content = detailsText(kind, kind === 'book' ? { عنوان: row.title } : {
|
||||
نام: row.name, تخلص: row.nickname, پیدائش: row.birth_year_ce ?? '', وفات: row.death_year_ce ?? '', تعارف: row.description ?? '' });
|
||||
return { details: { kind, id: row.id, url: row.url, title: row.title }, version: Number(last?.v ?? 0), content };
|
||||
}
|
||||
|
||||
const year = (s: string | undefined) => (s ? Number(s.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x6f0))) : null);
|
||||
|
||||
// what is wrong with the text, or null
|
||||
export function checkDetails(kind: DetailKind, text: string) {
|
||||
const d = parseDetails(text);
|
||||
if (kind === 'book') {
|
||||
if (!d['عنوان']) return 'عنوان ضروری ہے';
|
||||
return d['عنوان'].length > 200 ? 'عنوان بہت لمبا ہے' : null;
|
||||
}
|
||||
if (!d['نام'] || !d['تخلص']) return 'نام اور تخلص ضروری ہیں';
|
||||
if (d['نام'].length > 100 || d['تخلص'].length > 100) return 'نام بہت لمبا ہے';
|
||||
const [born, died] = [year(d['پیدائش']), year(d['وفات'])];
|
||||
for (const y of [born, died]) if (y !== null && !(Number.isInteger(y) && y >= 500 && y <= 2100)) return 'سال عیسوی میں لکھیں، جیسے ۱۸۷۷';
|
||||
if (born && died && died < born) return 'وفات کا سال پیدائش سے پہلے ہے';
|
||||
return (d['تعارف'] ?? '').length > 5000 ? 'تعارف بہت لمبا ہے' : null;
|
||||
}
|
||||
|
||||
export async function applyDetails(client: PoolClient, kind: DetailKind, id: number, text: string) {
|
||||
const d = parseDetails(text);
|
||||
if (kind === 'book') return void (await client.query('UPDATE categories SET title = $2 WHERE id = $1', [id, d['عنوان']]));
|
||||
const [born, died] = [year(d['پیدائش']), year(d['وفات'])];
|
||||
await client.query(`UPDATE poets SET name = $2, nickname = $3, description = $4, birth_year_ce = $5, death_year_ce = $6,
|
||||
birth_year_ah = $7, death_year_ah = $8 WHERE id = $1`,
|
||||
[id, d['نام'], d['تخلص'], d['تعارف'] || null, born, died, hijri(born), hijri(died)]);
|
||||
// the poet's top level carries their name (as the export makes it)
|
||||
await client.query('UPDATE categories SET title = $2 WHERE poet_id = $1 AND parent_id IS NULL', [id, d['نام']]);
|
||||
}
|
||||
|
||||
export async function writeDetails(dataDir: string, kind: DetailKind, url: string, text: string) {
|
||||
const path = join(dataDir, 'divan', url.replace(/^\//, '') + '.' + kind);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const d = parseDetails(text);
|
||||
for (const k of ['پیدائش', 'وفات']) if (d[k]) d[k] = String(year(d[k])); // plain digits for the export
|
||||
await writeFile(path, detailsText(kind, d).trim() + '\n');
|
||||
return path;
|
||||
}
|
||||
@ -11,7 +11,9 @@
|
||||
// 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. Tagging (tags.ts, entity 'tags-category' / 'tags-work') likewise, with the 'tags'
|
||||
// permission. A new e-book's details (ebooks.ts, entity 'ebook') likewise, with the 'ebooks' permission.
|
||||
// permission. A new e-book's details (ebooks.ts, entity 'ebook') likewise, with the 'ebooks' permission. A poet's details
|
||||
// (name, pen name, years, intro) and a book or section's title (details.ts, entities 'poet' and 'book') likewise, with
|
||||
// the edit permission on poets or books (#32).
|
||||
// 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
|
||||
@ -20,6 +22,8 @@
|
||||
// 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
|
||||
// GET /api/mod/tags/:kind/:id a book/section's (kind category) or work's tags and their history
|
||||
// GET /api/mod/details/:kind/:id a poet's details (kind poet) or a book/section's title (kind book), history
|
||||
// POST /api/mod/details/:kind/:id/draft start (or reopen) my draft of them
|
||||
// POST /api/mod/tags/:kind/:id/draft start (or reopen) my tagging draft
|
||||
// POST /api/mod/revisions/:id/save {content, summary}
|
||||
// POST /api/mod/revisions/:id/:action submit | approve | return | reject | publish {comment}
|
||||
@ -40,6 +44,7 @@ import { currentOrder, checkOrder, applyOrder, writeOrder, orderSlugs } from './
|
||||
import { currentTags, checkTags, applyTags, writeTags, parseTags, tagsText, type Tag, type TagTarget } from './tags.ts';
|
||||
import { currentEbook, checkEbook, applyEbook, writeEbook, mayUpload } from './ebooks.ts';
|
||||
import { publicName } from './auth.ts';
|
||||
import { isDetail, currentDetails, checkDetails, applyDetails, writeDetails, parseDetails, detailsText, type DetailKind } from './details.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);
|
||||
@ -50,11 +55,13 @@ const mayTag = (u: any, kind: TagTarget, id: number) => can(u, 'edit', 'tags', k
|
||||
// may change what this revision changes (a work's text, a book/section's order, tags); reviewing needs L1 or admin
|
||||
const mayEbook = async (u: any, ebookId: number) =>
|
||||
mayUpload(u, (await pool.query('SELECT poet_id FROM ebooks WHERE id = $1', [ebookId])).rows[0]?.poet_id ?? 0);
|
||||
const mayChange = (u: any, r: { entity: string; entity_id: number }) =>
|
||||
const mayDetails = (u: any, kind: DetailKind, id: number) =>
|
||||
kind === 'poet' ? can(u, 'edit', 'poets', { poetId: id }) : can(u, 'edit', 'books', { categoryId: id });
|
||||
const mayChange = (u: any, r: { entity: string; entity_id: number }) => isDetail(r.entity) ? mayDetails(u, r.entity, r.entity_id) :
|
||||
r.entity === 'order' ? mayArrange(u, r.entity_id) : r.entity.startsWith('tags-') ? mayTag(u, tagKind(r), r.entity_id)
|
||||
: r.entity === 'ebook' ? mayEbook(u, r.entity_id) : mayEdit(u, r.entity_id);
|
||||
const tagKind = (r: { entity: string }) => r.entity.slice('tags-'.length) as TagTarget;
|
||||
const ENTITIES = `('work', 'order', 'tags-category', 'tags-work', 'ebook')`;
|
||||
const ENTITIES = `('work', 'order', 'tags-category', 'tags-work', 'ebook', 'poet', 'book')`;
|
||||
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)
|
||||
@ -85,10 +92,11 @@ async function current(poemId: number) {
|
||||
|
||||
// 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 IN ('work', 'tags-work') AND p.id = r.entity_id
|
||||
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category') AND c.id = r.entity_id
|
||||
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id`;
|
||||
LEFT JOIN categories c ON r.entity IN ('order', 'tags-category', 'book') AND c.id = r.entity_id
|
||||
LEFT JOIN ebooks b ON r.entity = 'ebook' AND b.id = r.entity_id
|
||||
LEFT JOIN poets pt ON r.entity = 'poet' AND pt.id = r.entity_id`;
|
||||
// (not named URL: that would hide the global URL used for the default divan-data folder)
|
||||
const TARGET_TITLE = `coalesce(p.title, c.title, b.title)`, TARGET_URL = `coalesce(p.url, c.url, '/ebook/' || b.id)`;
|
||||
const TARGET_TITLE = `coalesce(p.title, c.title, b.title, pt.nickname)`, TARGET_URL = `coalesce(p.url, c.url, '/ebook/' || b.id, pt.url)`;
|
||||
const TARGET_COLS = `${TARGET_TITLE} AS work_title, ${TARGET_URL} AS work_url`;
|
||||
|
||||
async function revision(id: number) {
|
||||
@ -115,11 +123,13 @@ async function publish(r: any, u: any, comment?: string) {
|
||||
if (cur.version !== r.base_version)
|
||||
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 details = isDetail(r.entity);
|
||||
const stale = order ? await checkOrder(r.entity_id, r.content) : tags ? await checkTags(tagKind(r), r.entity_id, r.content)
|
||||
: ebook ? checkEbook(r.content) : null;
|
||||
: ebook ? checkEbook(r.content) : details ? checkDetails(r.entity, r.content) : null;
|
||||
if (stale) throw Object.assign(new Error(`اس دوران اس حصے کی چیزیں بدل گئی ہیں: ${stale}`), { code: 409 });
|
||||
const doc = order || tags || ebook ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
|
||||
const title = 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}`
|
||||
const doc = order || tags || ebook || details ? null : parse(r.content), verses = doc ? toVerses(doc) : [];
|
||||
const title = 'details' in cur ? `${r.entity === 'poet' ? 'شاعر' : 'عنوان'}: ${parseDetails(r.content)[r.entity === 'poet' ? 'تخلص' : 'عنوان']}`
|
||||
: 'cat' in cur ? `ترتیب: ${cur.cat.title}` : 'target' in cur ? `ٹیگ: ${cur.target.title}`
|
||||
: 'ebook' in cur ? `ای بک: ${cur.ebook.poet}، ${cur.ebook.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)
|
||||
@ -128,7 +138,8 @@ async function publish(r: any, u: any, comment?: string) {
|
||||
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 = 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)]
|
||||
const files = 'details' in cur ? [await writeDetails(dataDir(), r.entity, cur.details.url, r.content)]
|
||||
: 'cat' in cur ? [await writeOrder(dataDir(), cur.cat.url, r.content)]
|
||||
: 'target' in cur ? [await writeTags(dataDir(), cur.target.url, r.content)]
|
||||
: 'ebook' in cur ? [await writeEbook(dataDir(), cur.ebook, r.content)]
|
||||
: await writeOwned(dataDir(), cur.poem.url, r.content, { ...credits, at, version, revision: Number(r.id) });
|
||||
@ -144,6 +155,7 @@ async function publish(r: any, u: any, comment?: string) {
|
||||
if (order) await applyOrder(client, r.entity_id, r.content);
|
||||
else if (tags) await applyTags(client, tagKind(r), r.entity_id, r.content);
|
||||
else if (ebook) await applyEbook(client, r.entity_id, r.content);
|
||||
else if (details) await applyDetails(client, r.entity, 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(' '))]);
|
||||
@ -166,9 +178,10 @@ async function publish(r: any, u: any, comment?: string) {
|
||||
|
||||
// what an entity is now (its latest published version), for any kind of revision
|
||||
const currentOf = (entity: string, id: number): Promise<any> => entity === 'order' ? currentOrder(id)
|
||||
: entity.startsWith('tags-') ? currentTags(tagKind({ entity }), id) : entity === 'ebook' ? currentEbook(id) : current(id);
|
||||
: entity.startsWith('tags-') ? currentTags(tagKind({ entity }), id) : entity === 'ebook' ? currentEbook(id)
|
||||
: isDetail(entity) ? currentDetails(entity, id) : current(id);
|
||||
// the kinds of change with versions to compare and bring back (an e-book's details are a one-off)
|
||||
const VERSIONED = ['work', 'order', 'tags-work', 'tags-category'];
|
||||
const VERSIONED = ['work', 'order', 'tags-work', 'tags-category', 'poet', 'book'];
|
||||
// a published version's content; version 0 is what the first Divan draft started from (the Wikisource text)
|
||||
async function versionText(entity: string, id: number, v: number, cur: { version: number; content: string }) {
|
||||
if (v === cur.version) return cur.content;
|
||||
@ -187,7 +200,8 @@ export function moderationRoutes(app: FastifyInstance) {
|
||||
if (!isModerator(u)) return { edit: false, review: false, publish: false, arrange: false, tags: false, ebooks: false };
|
||||
if (categoryId) {
|
||||
const poetId = (await pool.query('SELECT poet_id FROM categories WHERE id = $1', [categoryId])).rows[0]?.poet_id ?? 0;
|
||||
return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId), ebooks: await mayUpload(u, poetId) };
|
||||
return { arrange: await mayArrange(u, categoryId), tags: await mayTag(u, 'category', categoryId), ebooks: await mayUpload(u, poetId),
|
||||
book: await mayDetails(u, 'book', categoryId), poet: await mayDetails(u, 'poet', poetId) };
|
||||
}
|
||||
return { edit: await mayEdit(u, poemId), review: await mayReview(u, { entity: 'work', entity_id: poemId }), publish: u.role === 'admin',
|
||||
tags: await mayTag(u, 'work', poemId) };
|
||||
@ -299,6 +313,34 @@ export function moderationRoutes(app: FastifyInstance) {
|
||||
return { id: Number(rows[0].id) };
|
||||
});
|
||||
|
||||
app.get<{ Params: { kind: string; id: string } }>('/api/mod/details/:kind/:id', async (req, reply) => {
|
||||
const u = await moderator(req, reply); if (!u) return;
|
||||
const { kind } = req.params, cur = isDetail(kind) ? await currentDetails(kind, Number(req.params.id) || 0) : null;
|
||||
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 = $1 AND entity_id = $2 AND ${CHANGED} ORDER BY coalesce(published_at, created_at) DESC`, [kind, cur.details.id]);
|
||||
return { kind, target: cur.details, version: cur.version, content: cur.content, fields: parseDetails(cur.content),
|
||||
history: rows.map((r) => ({ ...r, id: Number(r.id) })), may: { edit: await mayDetails(u, kind as DetailKind, cur.details.id) } };
|
||||
});
|
||||
|
||||
app.post<{ Params: { kind: string; id: string } }>('/api/mod/details/:kind/:id/draft', async (req, reply) => {
|
||||
const u = await moderator(req, reply); if (!u) return;
|
||||
const { kind } = req.params, id = Number(req.params.id) || 0;
|
||||
if (!isDetail(kind)) return reply.code(404).send({ error: 'نہیں ملا' });
|
||||
if (!(await mayDetails(u, kind, id))) return reply.code(403).send({ error: 'اس میں ترمیم کی اجازت نہیں' });
|
||||
const open = (await pool.query(
|
||||
`SELECT id FROM revisions WHERE entity = $1 AND entity_id = $2 AND author_id = $3 AND status IN ('draft', 'returned') LIMIT 1`, [kind, id, u.id])).rows[0];
|
||||
if (open) return { id: Number(open.id) };
|
||||
const cur = await currentDetails(kind, id);
|
||||
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 ($1, $2, $3, $4, $4, 'draft', $5, $6) RETURNING id`, [kind, id, 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);
|
||||
@ -318,8 +360,9 @@ 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 (r.entity === 'order' || r.entity.startsWith('tags-') || r.entity === 'ebook') {
|
||||
if (r.entity === 'order' || r.entity.startsWith('tags-') || r.entity === 'ebook' || isDetail(r.entity)) {
|
||||
const bad = r.entity === 'order' ? await checkOrder(r.entity_id, content) : r.entity === 'ebook' ? checkEbook(content)
|
||||
: isDetail(r.entity) ? checkDetails(r.entity, content)
|
||||
: await checkTags(tagKind(r), 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: 'متن میں کوئی شعر یا پیراگراف نہیں' });
|
||||
@ -327,7 +370,7 @@ export function moderationRoutes(app: FastifyInstance) {
|
||||
// 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 norm = (t: string) => (r.entity === 'order' ? orderSlugs(t).join('\n') : r.entity.startsWith('tags-') ? tagsText(parseTags(t) as Tag[])
|
||||
: r.entity === 'ebook' ? t.trim() : toText(parse(t)));
|
||||
: r.entity === 'ebook' ? t.trim() : isDetail(r.entity) ? detailsText(r.entity, parseDetails(t)) : 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]);
|
||||
@ -396,7 +439,7 @@ export function moderationRoutes(app: FastifyInstance) {
|
||||
const a = pick(req.query.a, Math.max(0, cur.version - 1)), b = pick(req.query.b, cur.version);
|
||||
const [ta, tb] = await Promise.all([a, b].map((v) => Number.isInteger(v) && v >= 0 && v <= cur.version ? versionText(entity, id, v, cur) : undefined));
|
||||
if (ta === undefined || tb === undefined) return reply.code(404).send({ error: 'یہ ورژن نہیں ملا' });
|
||||
const target = cur.poem ?? cur.cat ?? cur.target, diff = diffLines(ta, tb);
|
||||
const target = cur.poem ?? cur.cat ?? cur.target ?? cur.details, diff = diffLines(ta, tb);
|
||||
return { entity, id, title: target.title, url: target.url, version: cur.version, a, b, diff, changes: changed(diff),
|
||||
may: { revert: await mayChange(u, { entity, entity_id: id }) } };
|
||||
});
|
||||
|
||||
@ -36,10 +36,10 @@ test('arranging: its own permission; L2 reorders, L1 approves, admin publishes t
|
||||
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, tags: false, ebooks: false }, 'editing is not arranging');
|
||||
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: false, tags: false, ebooks: false, book: false, poet: 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, tags: false, ebooks: false });
|
||||
assert.deepEqual((await call('GET', `/api/mod/can?category=${cat.id}`, undefined, l2.token)).json(), { arrange: true, tags: false, ebooks: false, book: false, poet: false });
|
||||
|
||||
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();
|
||||
|
||||
@ -76,7 +76,8 @@ 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)
|
||||
// on poet and book/section pages, moderators with the arrange permission get "ترتیب دیں" (#41); with the edit permission
|
||||
// on poets or books, "تفصیل" (a poet's name, years, intro) or "عنوان" (a book or section's title) (#32)
|
||||
const catCan = 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 : {};
|
||||
const arrange = catCan.arrange, tagCan = page.type === 'poem' ? modCan.tags : catCan.tags;
|
||||
@ -115,16 +116,16 @@ import TagsBox from '../components/TagsBox.astro';
|
||||
<>
|
||||
<h1 data-readable>{page.poet.name}</h1>
|
||||
<div class="center"><button type="button" class="save" data-save={JSON.stringify({ kind: 'poet', poetId })} aria-pressed={String(!!saved.poet)} title="یہ شاعر محفوظ کریں">{label(saved.poet)}</button><button type="button" class="icon-btn small" data-reading-toggle title="مطالعے کا انداز: صرف متن" aria-label="مطالعے کا انداز"><Icon name="dict" size={17} /></button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
|
||||
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
|
||||
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}{catCan.poet && <> <form method="post" action={`/mod/details/poet/${poetId}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> تفصیل</button></form></>}</div>
|
||||
<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.split(/\n\s*\n/).map((p: string) => <p>{p}</p>)}</div>}
|
||||
</>
|
||||
)}
|
||||
{page.type === 'category' && (
|
||||
<>
|
||||
<h1 data-readable>{page.category.title}</h1>
|
||||
<div 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><button type="button" class="icon-btn small" data-reading-toggle title="مطالعے کا انداز: صرف متن" aria-label="مطالعے کا انداز"><Icon name="dict" size={17} /></button>{arrange && <> <form method="post" action={`/mod/order/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="arrange" size={17} /> ترتیب دیں</button></form>
|
||||
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}</div>
|
||||
<a class="icon-btn small" href={`/mod/order/${page.category.id}`} title="ترتیب کا تاریخچہ" aria-label="ترتیب کا تاریخچہ"><Icon name="history" size={17} /></a></>}{tagCan && <> <form method="post" action={`/mod/tags/${tagTarget}`} class="inline-form"><button class="mod-edit-btn"><Icon name="tag" size={17} /> ٹیگ</button></form></>}{catCan.book && <> <form method="post" action={`/mod/details/book/${page.category.id}`} class="inline-form"><button class="mod-edit-btn"><Icon name="edit" size={17} /> عنوان</button></form></>}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@ -62,6 +62,9 @@ const modParts: [any, string, string[]][] = [
|
||||
'ہر کلام، ترتیب اور ٹیگ کا تاریخچہ: ہر ورژن، کس نے لکھا، کس نے منظور اور شائع کیا۔ «فرق» سے ہر ورژن کی تبدیلی، «موازنہ کریں» سے کوئی دو ورژن۔',
|
||||
'کوئی پرانا ورژن «واپس لائیں» سے مسودہ بن جاتا ہے، اور عام ترمیم کی طرح جائزے اور اشاعت سے گزرتا ہے۔',
|
||||
]],
|
||||
['user', 'شاعر اور کتابیں', [
|
||||
'شاعر کے صفحے پر «تفصیل»: نام، تخلص، پیدائش اور وفات کے سال (عیسوی) اور تعارف۔ کتاب یا باب کے صفحے پر «عنوان»۔ یہ بھی مسودہ بن کر جائزے اور اشاعت سے گزرتے ہیں، اور ان کا بھی تاریخچہ رہتا ہے۔',
|
||||
]],
|
||||
['arrange', 'ترتیب', [
|
||||
'شاعر یا کتاب کے صفحے پر «ترتیب دیں»: کلام، باب اور کتابیں اوپر نیچے کریں، یا کلام ایک حصے سے دوسرے میں لے جائیں۔ اس کی اجازت ترمیم سے الگ ہے۔',
|
||||
]],
|
||||
|
||||
@ -19,7 +19,8 @@ const res = await asUser(token, `/api/mod/compare/${entity}/${id}${Astro.url.sea
|
||||
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
|
||||
const { title, url, version, a, b, diff, changes, may } = res.data;
|
||||
const label = (v: number) => (v ? `ورژن ${ud(v)}` : 'ویکی ماخذ کا متن');
|
||||
const HISTORY: Record<string, string> = { work: `/mod/work/${id}`, order: `/mod/order/${id}`, 'tags-work': `/mod/tags/work/${id}`, 'tags-category': `/mod/tags/category/${id}` };
|
||||
const HISTORY: Record<string, string> = { work: `/mod/work/${id}`, order: `/mod/order/${id}`, 'tags-work': `/mod/tags/work/${id}`, 'tags-category': `/mod/tags/category/${id}`,
|
||||
poet: `/mod/details/poet/${id}`, book: `/mod/details/book/${id}` };
|
||||
const older = Math.min(a, b);
|
||||
---
|
||||
<Base title={`موازنہ: ${title}`}>
|
||||
|
||||
39
web/src/pages/mod/details/[kind]/[id].astro
Normal file
39
web/src/pages/mod/details/[kind]/[id].astro
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
// A poet's details or a book/section's title (#32): what they are now, their versions and drafts; "ترمیم کریں" starts a
|
||||
// draft (also reached by POST from the poet or book page)
|
||||
import Base from '../../../../layouts/Base.astro';
|
||||
import ModNav from '../../../../components/ModNav.astro';
|
||||
import History from '../../../../components/History.astro';
|
||||
import { asUser, COOKIE } from '../../../../lib/auth';
|
||||
import { ud } from '../../../../lib/urdu';
|
||||
import Icon from '../../../../components/Icon.astro';
|
||||
|
||||
const me = Astro.locals.user;
|
||||
if (!me) return Astro.redirect(`/signin?next=${Astro.url.pathname}`);
|
||||
const token = Astro.cookies.get(COOKIE)!.value, { kind, id } = Astro.params;
|
||||
let error = '';
|
||||
if (Astro.request.method === 'POST') {
|
||||
const r = await asUser(token, `/api/mod/details/${kind}/${id}/draft`, {});
|
||||
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.id}`);
|
||||
error = r.data.error ?? 'کچھ غلط ہو گیا';
|
||||
}
|
||||
const res = await asUser(token, `/api/mod/details/${kind}/${id}`);
|
||||
if (!res.ok) return new Response(res.data.error ?? 'نہیں ملا', { status: res.status });
|
||||
const { target, version, fields, history, may } = res.data;
|
||||
const poet = kind === 'poet';
|
||||
---
|
||||
<Base title={`${poet ? 'شاعر' : 'عنوان'}: ${target.title}`}>
|
||||
<h1>{poet ? 'شاعر' : 'عنوان'}: {target.title}</h1>
|
||||
<ModNav />
|
||||
<p class="center"><a href={target.url}>صفحہ</a> · {version ? `موجودہ: ورژن ${ud(version)}` : 'ویکی ماخذ اور ویکیپیڈیا سے (ابھی دیوان کا کوئی ورژن نہیں)'}</p>
|
||||
<dl class="eb-meta">
|
||||
{poet ? <>
|
||||
<dt>نام</dt><dd>{fields['نام']}</dd><dt>تخلص</dt><dd>{fields['تخلص']}</dd>
|
||||
<dt>پیدائش</dt><dd>{fields['پیدائش'] ? ud(fields['پیدائش']) : '—'}</dd><dt>وفات</dt><dd>{fields['وفات'] ? ud(fields['وفات']) : '—'}</dd>
|
||||
</> : <><dt>عنوان</dt><dd>{fields['عنوان']}</dd></>}
|
||||
</dl>
|
||||
{poet && fields['تعارف'] && <div class="poet-intro">{fields['تعارف'].split(/\n\s*\n/).map((p: string) => <p>{p}</p>)}</div>}
|
||||
{error && <p class="form-error" role="alert">{error}</p>}
|
||||
{may.edit && <form method="post" class="center"><button class="save-like"><Icon name="edit" size={17} /> ترمیم کریں</button></form>}
|
||||
<History entity={kind!} id={target.id} history={history} version={version} empty="ابھی کوئی ترمیم نہیں۔" />
|
||||
</Base>
|
||||
@ -17,9 +17,9 @@ const queue = showLog ? null : (await asUser(token, '/api/mod/queue')).data;
|
||||
const f = Object.fromEntries(['who', 'kind', 'action'].map((k) => [k, Astro.url.searchParams.get(k) ?? '']));
|
||||
const log = showLog ? (await asUser(token, `/api/mod/log?${new URLSearchParams(f)}`)).data : null;
|
||||
// what kind of change a row is (a work's text needs no label)
|
||||
const KIND: Record<string, string> = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ' };
|
||||
const KIND: Record<string, string> = { order: 'ترتیب: ', 'tags-category': 'ٹیگ: ', 'tags-work': 'ٹیگ: ', ebook: 'ای بک: ', poet: 'شاعر: ', book: 'عنوان: ' };
|
||||
// each kind of change has its icon; each event its tone on the timeline
|
||||
const ICON: Record<string, any> = { order: 'arrange', 'tags-category': 'tag', 'tags-work': 'tag', ebook: 'upload' };
|
||||
const ICON: Record<string, any> = { order: 'arrange', 'tags-category': 'tag', 'tags-work': 'tag', ebook: 'upload', poet: 'user', book: 'edit' };
|
||||
const TONE: Record<string, string> = { published: 'ok', approved: 'action', submitted: 'accent', returned: 'danger', rejected: 'danger' };
|
||||
const groups = queue ? [
|
||||
['میرے مسودے', queue.mine, 'ابھی کوئی مسودہ نہیں۔ کسی کلام کے صفحے پر "ترمیم کریں" سے شروع کریں۔'],
|
||||
@ -59,7 +59,7 @@ const groups = queue ? [
|
||||
<input type="hidden" name="log" />
|
||||
<input type="search" name="who" value={f.who} placeholder="کس نے (ای میل)" aria-label="کس نے" dir="auto" />
|
||||
<select name="kind" aria-label="کس چیز میں">
|
||||
{[['', 'سب تبدیلیاں'], ['work', 'متن'], ['order', 'ترتیب'], ['tags', 'ٹیگ'], ['ebook', 'ای بکس']].map(([v, t]) => <option value={v} selected={f.kind === v}>{t}</option>)}
|
||||
{[['', 'سب تبدیلیاں'], ['work', 'متن'], ['order', 'ترتیب'], ['tags', 'ٹیگ'], ['ebook', 'ای بکس'], ['poet', 'شاعر'], ['book', 'کتاب کا عنوان']].map(([v, t]) => <option value={v} selected={f.kind === v}>{t}</option>)}
|
||||
</select>
|
||||
<select name="action" aria-label="کیا کیا">
|
||||
<option value="">ہر قدم</option>
|
||||
|
||||
@ -17,7 +17,12 @@ 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') });
|
||||
// a poet's or book's details come as fields (#32): one "label: value" per line, the intro last
|
||||
const clean = (k: string) => String(f.get(k) ?? '').replace(/\s+/g, ' ').trim();
|
||||
const content = f.get('details') === 'book' ? `عنوان: ${clean('عنوان')}`
|
||||
: f.get('details') === 'poet' ? `${['نام', 'تخلص', 'پیدائش', 'وفات'].map((k) => `${k}: ${clean(k)}`).join('\n')}\nتعارف:\n${String(f.get('تعارف') ?? '').trim()}`
|
||||
: f.get('content');
|
||||
if (act === 'save' || act === 'submit') r = await asUser(token, `/api/mod/revisions/${id}/save`, { 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 ?? 'کچھ غلط ہو گیا';
|
||||
@ -39,6 +44,15 @@ const couplets = tagging && tagKind === 'work' ? (await asUser(token, `/api/mod/
|
||||
const knownTags = tagging ? ((await api<{ name: string }[]>('/api/tags')) ?? []).map((t) => t.name) : [];
|
||||
// an e-book's details: title, source, copyright, note
|
||||
const ebook = rev.entity === 'ebook';
|
||||
// a poet's details or a book/section's title (#32)
|
||||
const details = ['poet', 'book'].includes(rev.entity) ? rev.entity : '';
|
||||
const dv: Record<string, string> = {};
|
||||
if (details) {
|
||||
const lines = rev.content.split('\n'), intro = lines.findIndex((l: string) => /^تعارف\s*:/.test(l));
|
||||
for (const l of intro < 0 ? lines : lines.slice(0, intro)) { const m = l.match(/^([^:]+):\s*(.*)$/); if (m) dv[m[1].trim()] = m[2].trim(); }
|
||||
if (intro >= 0) dv['تعارف'] = [lines[intro].replace(/^تعارف\s*:\s*/, ''), ...lines.slice(intro + 1)].join('\n').trim();
|
||||
}
|
||||
const keys = (i: string) => `<button type="button" class="in-box rom" data-urdu-rom="${i}" aria-pressed="false" aria-label="رومن حروف سے اردو" title="رومن حروف سے اردو">اب</button><button type="button" class="in-box" data-urdu-kb="${i}" aria-pressed="false" aria-label="اردو کی بورڈ" title="اردو کی بورڈ"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg></button>`;
|
||||
const EB: [string, string][] = [['title', 'عنوان'], ['writer', 'مصنف'], ['coauthors', 'شریک مصنفین'], ['language', 'زبان'], ['direction', 'سمت'],
|
||||
['tags', 'ٹیگ'], ['source', 'ماخذ'], ['licence', 'اجازت'], ['note', 'تفصیل']];
|
||||
const ebMeta = ebook ? Object.fromEntries(EB.map(([k, label]) => [k, (rev.content.split('\n').find((l: string) => l.startsWith(label + ':')) ?? '').slice(label.length + 1).trim()])) : {};
|
||||
@ -48,18 +62,38 @@ const acts = ([['approve', 'منظور کریں', 'ok', 'check'], ['return', 'و
|
||||
import Icon from '../../../components/Icon.astro';
|
||||
---
|
||||
<Base title={`مسودہ: ${rev.work_title}`}>
|
||||
<h1>{order && 'ترتیب: '}{tagging && 'ٹیگ: '}{ebook && 'ای بک: '}{rev.work_title}</h1>
|
||||
<h1>{order && 'ترتیب: '}{tagging && 'ٹیگ: '}{ebook && 'ای بک: '}{details === 'poet' && 'شاعر: '}{details === 'book' && 'عنوان: '}{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}>{order ? 'حصہ' : tagging ? 'صفحہ' : ebook ? 'کتاب دیکھیں' : 'کلام'}</a>{!ebook && <> · <a href={tagging ? `/mod/tags/${tagKind}/${rev.entity_id}` : `/mod/${order ? 'order' : 'work'}/${rev.entity_id}`}>تاریخچہ</a></>}
|
||||
· <a href={rev.work_url}>{order ? 'حصہ' : tagging || details ? 'صفحہ' : ebook ? 'کتاب دیکھیں' : 'کلام'}</a>{!ebook && <> · <a href={details ? `/mod/details/${details}/${rev.entity_id}` : tagging ? `/mod/tags/${tagKind}/${rev.entity_id}` : `/mod/${order ? 'order' : '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 && ebook ? (
|
||||
{may.save && details ? (
|
||||
<form method="post" class="mod-edit account-form">
|
||||
<input type="hidden" name="details" value={details} />
|
||||
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu /></label>
|
||||
{details === 'book' ? (
|
||||
<label>عنوان<span class="in-box-field"><input id="d-title" name="عنوان" value={dv['عنوان'] ?? ''} required maxlength="200" data-urdu /><Fragment set:html={keys('d-title')} /></span></label>
|
||||
) : <>
|
||||
<label>نام<span class="in-box-field"><input id="d-name" name="نام" value={dv['نام'] ?? ''} required maxlength="100" data-urdu /><Fragment set:html={keys('d-name')} /></span></label>
|
||||
<label>تخلص (جو سائٹ پر ہر جگہ دکھتا ہے)<span class="in-box-field"><input id="d-nick" name="تخلص" value={dv['تخلص'] ?? ''} required maxlength="100" data-urdu /><Fragment set:html={keys('d-nick')} /></span></label>
|
||||
<div class="year-pair">
|
||||
<label>پیدائش (سنِ عیسوی)<input name="پیدائش" value={dv['پیدائش'] ?? ''} inputmode="numeric" maxlength="4" placeholder="۱۸۷۷" /></label>
|
||||
<label>وفات (سنِ عیسوی)<input name="وفات" value={dv['وفات'] ?? ''} inputmode="numeric" maxlength="4" placeholder="۱۹۳۸" /></label>
|
||||
</div>
|
||||
<label>تعارف (پیراگراف خالی سطر سے الگ کریں)<span class="in-box-field note-field"><textarea id="d-intro" name="تعارف" rows="10" maxlength="5000" dir="auto" data-urdu>{dv['تعارف'] ?? ''}</textarea><Fragment set:html={keys('d-intro')} /></span></label>
|
||||
</>}
|
||||
<div class="filter-actions">
|
||||
<button name="act" value="save" class="btn primary"><Icon name="save" size={18} /> محفوظ کریں</button>
|
||||
<button name="act" value="submit" class="btn primary"><Icon name={me.role === 'admin' ? 'upload' : 'send'} size={18} /> {me.role === 'admin' ? 'محفوظ کر کے شائع کریں' : me.role === 'mod-l1' ? 'محفوظ کر کے اشاعت کے لیے بھیجیں' : 'محفوظ کر کے جائزے کے لیے بھیجیں'}</button>
|
||||
</div>
|
||||
</form>
|
||||
) : may.save && ebook ? (
|
||||
<form method="post" class="mod-edit account-form">
|
||||
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu /></label>
|
||||
<EbookFields v={ebMeta} />
|
||||
@ -181,6 +215,8 @@ import Icon from '../../../components/Icon.astro';
|
||||
<section class="lib-section">
|
||||
<h2>پیش نظارہ</h2>
|
||||
{order ? <ol class="toc">{items.map((it: any) => <li>{it.title}</li>)}</ol>
|
||||
: details ? <><dl class="eb-meta">{Object.entries(dv).filter(([k, v]) => k !== 'تعارف' && v).map(([k, v]) => <><dt>{k}</dt><dd>{ud(v)}</dd></>)}</dl>
|
||||
{dv['تعارف'] && <div class="poet-intro">{dv['تعارف'].split(/\n\s*\n/).map((p) => <p>{p}</p>)}</div>}</>
|
||||
: ebook ? <dl class="eb-meta">{EB.map(([k, label]) => ebMeta[k] && <><dt>{label}</dt><dd>{ebMeta[k]}</dd></>)}</dl>
|
||||
: tagging ? (tagRows.length ? tagRows.map((t: any) => <div class="tag-preview">{t.couplet > 0 && <small class="muted">شعر {ud(t.couplet)}:</small>}<Tags tags={[t]} /></div>) : <p class="muted center">کوئی ٹیگ نہیں</p>)
|
||||
: <DivanPreview text={rev.content} />}
|
||||
|
||||
@ -506,6 +506,12 @@ h1 + .muted { text-align: center; margin-top: 0; }
|
||||
.eb-meta { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; max-width: 700px; margin: 0 auto; }
|
||||
.eb-meta dt { color: var(--text-muted); }
|
||||
.eb-meta dd { margin: 0; }
|
||||
/* poet details (#32): two years side by side; an intro in paragraphs */
|
||||
.year-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.year-pair label { min-width: 0; }
|
||||
.year-pair input { width: 100%; box-sizing: border-box; }
|
||||
.poet-intro { max-width: 700px; margin: 10px auto; line-height: 2; }
|
||||
.intro p { margin: 0 0 .6em; }
|
||||
/* e-books: tabs, cover grid, right-to-left PDF reader */
|
||||
.ebook-tabs a span[lang] { font-size: .8em; }
|
||||
.ebook-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 18px; margin: 16px 0; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user