E-books as a section of every poet, with tabs and covers; a right-to-left PDF reader; page bookmarks; book details

- ای بکس card beside the poet's books and genres on every poet page; /<poet>/ebooks with tabs
  پی ڈی ایف (PDF) / ای پب (ePUB) / یونیکوڈ متن (Unicode) (+ آرکائیو when present) and a grid of covers.
- Covers: made in the moderator's browser from a PDF's first page (pdf.js) or an EPUB's cover (epub.js); archive.org's
  thumbnail; a styled card for text; or the moderator's own image. سرورق بنائیں on existing books.
- PDF reader (pdf.js) reading right to left: next page to the left (button, ← key, left half), facing pages with the
  cover alone then 2|3; left to right for LTR books; full screen (F); opens in a new tab; byte-range requests.
- Page bookmarks in the reader's library (kind 'page'), listed in نشانات and opening at that page.
- Details: writer as printed, co-authors (linked to poets on the site, who list the book too; new names kept as text),
  language, reading direction (RTL/LTR), extra tags (on tag pages), source, copyright, note; in upload and review.
- Word lookup and selection menu only on reading text, not menus, buttons or tabs.
- Schema: the library kind check lists 'page' where it is defined (it was re-added without it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anas Rashid 2026-10-09 15:16:06 +02:00
parent cdfa5f9020
commit 0a507d1fdd
21 changed files with 864 additions and 68 deletions

View File

@ -8,14 +8,18 @@ import Fastify from 'fastify';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { authRoutes } from './auth.ts'; import { authRoutes } from './auth.ts';
import { moderationRoutes } from './moderation.ts'; import { moderationRoutes } from './moderation.ts';
import { libraryRoutes } from './library.ts';
import { ebookRoutes, archiveId, parseMeta, metaText } from './ebooks.ts'; import { ebookRoutes, archiveId, parseMeta, metaText } from './ebooks.ts';
import { pool } from './db.ts'; import { pool } from './db.ts';
after(() => pool.end()); after(() => pool.end());
test('e-book details and archive.org links', () => { test('e-book details and archive.org links', () => {
assert.deepEqual(parseMeta(metaText({ title: 'کلیات اقبال', source: 'ریختہ نہیں', licence: 'عوامی ملکیت' })), const m = parseMeta(metaText({ title: 'کلیات اقبال', source: 'archive.org', licence: 'عوامی ملکیت' })) as any;
{ title: 'کلیات اقبال', source: 'ریختہ نہیں', licence: 'عوامی ملکیت', note: '' }); assert.deepEqual([m.title, m.source, m.licence, m.language, m.direction], ['کلیات اقبال', 'archive.org', 'عوامی ملکیت', 'اردو', 'دائیں سے بائیں']);
assert.equal((parseMeta(metaText({ title: 'Kulliyat', language: 'انگریزی' })) as any).direction, 'بائیں سے دائیں', 'English reads left to right');
assert.match(parseMeta(metaText({ title: 'x', language: 'لاطینی' })) as string, /زبان/);
assert.match(parseMeta(metaText({ title: 'x', direction: 'اوپر سے نیچے' })) as string, /سمت/);
assert.match(parseMeta('ماخذ: کچھ') as string, /عنوان/); assert.match(parseMeta('ماخذ: کچھ') as string, /عنوان/);
assert.equal(archiveId('https://archive.org/details/BaangEDara/page/n5'), 'BaangEDara'); assert.equal(archiveId('https://archive.org/details/BaangEDara/page/n5'), 'BaangEDara');
assert.equal(archiveId('kulliyat-e-iqbal_202001'), 'kulliyat-e-iqbal_202001'); assert.equal(archiveId('kulliyat-e-iqbal_202001'), 'kulliyat-e-iqbal_202001');
@ -28,7 +32,7 @@ test('e-books: upload permission, file kinds, pipeline, published list and files
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' }); 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'); git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
const app = Fastify(); const app = Fastify();
authRoutes(app); moderationRoutes(app); ebookRoutes(app); authRoutes(app); moderationRoutes(app); ebookRoutes(app); libraryRoutes(app);
const run = Date.now(); const run = Date.now();
const call = (method: string, url: string, body?: any, token?: string, type?: string) => app.inject({ const call = (method: string, url: string, body?: any, token?: string, type?: string) => app.inject({
method: method as any, url, payload: body, method: method as any, url, payload: body,
@ -63,7 +67,8 @@ test('e-books: upload permission, file kinds, pipeline, published list and files
// the e-book and its draft; not listed and not served until approved // the e-book and its draft; not listed and not served until approved
assert.equal((await call('POST', '/api/mod/ebooks', { poet: 238, file: up.file }, l2.token)).statusCode, 400, 'a title is required'); assert.equal((await call('POST', '/api/mod/ebooks', { poet: 238, file: up.file }, l2.token)).statusCode, 400, 'a title is required');
const made = (await call('POST', '/api/mod/ebooks', { poet: 238, file: up.file, title: `بانگ درا ${run}`, source: 'archive.org', licence: 'عوامی ملکیت' }, l2.token)).json(); const made = (await call('POST', '/api/mod/ebooks', { poet: 238, file: up.file, title: `بانگ درا ${run}`, source: 'archive.org', licence: 'عوامی ملکیت',
writer: 'علامہ محمد اقبال', coauthors: ['مرزا غالب', `نیا شاعر ${run}`], tags: `کلاسیکی-${run}، قومی-${run}`, language: 'اردو', direction: 'rtl' }, l2.token)).json();
assert.ok(made.id && made.revision); assert.ok(made.id && made.revision);
assert.equal((await call('GET', `/api/ebook/${made.id}/file`)).statusCode, 404, 'not public yet'); assert.equal((await call('GET', `/api/ebook/${made.id}/file`)).statusCode, 404, 'not public yet');
assert.equal((await call('GET', `/api/ebook/${made.id}/file`, undefined, l1.token)).statusCode, 200, 'reviewers can open it'); assert.equal((await call('GET', `/api/ebook/${made.id}/file`, undefined, l1.token)).statusCode, 200, 'reviewers can open it');
@ -73,20 +78,50 @@ test('e-books: upload permission, file kinds, pipeline, published list and files
assert.deepEqual((await call('POST', `/api/mod/revisions/${made.revision}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 }); assert.deepEqual((await call('POST', `/api/mod/revisions/${made.revision}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
assert.deepEqual((await call('GET', '/api/ebooks?poet=238')).json().map((b: any) => b.title), [`بانگ درا ${run}`]); assert.deepEqual((await call('GET', '/api/ebooks?poet=238')).json().map((b: any) => b.title), [`بانگ درا ${run}`]);
// co-authors: Ghalib is on the site (linked, and the book is listed on his page too); the new name is plain text
const shown = (await call('GET', `/api/ebook/${made.id}`)).json();
assert.deepEqual(shown.coauthors, [{ name: 'مرزا غالب', url: '/p266' }, { name: `نیا شاعر ${run}`, url: null }]);
assert.deepEqual([shown.writer, shown.language, shown.direction], ['علامہ محمد اقبال', 'اردو', 'rtl']);
assert.deepEqual(shown.tags.map((t: any) => t.name).sort(), [`قومی-${run}`, `کلاسیکی-${run}`]);
assert.ok((await call('GET', '/api/ebooks?poet=266')).json().some((b: any) => b.id === made.id && b.co), 'listed for the co-author');
const file = await call('GET', `/api/ebook/${made.id}/file`); const file = await call('GET', `/api/ebook/${made.id}/file`);
assert.equal(file.statusCode, 200); assert.equal(file.headers['content-type'], 'application/pdf'); assert.deepEqual(file.rawPayload, pdf); assert.equal(file.statusCode, 200); assert.equal(file.headers['content-type'], 'application/pdf'); assert.deepEqual(file.rawPayload, pdf);
const part = await app.inject({ method: 'GET', url: `/api/ebook/${made.id}/file`, headers: { range: 'bytes=0-7' } });
assert.equal(part.statusCode, 206); assert.equal(part.headers['content-range'], `bytes 0-7/${pdf.length}`); assert.equal(part.body, '%PDF-1.4');
assert.equal((await app.inject({ method: 'GET', url: `/api/ebook/${made.id}/file`, headers: { range: 'bytes=999999-' } })).statusCode, 416);
const record = await readFile(join(data, 'divan', 'p238', 'ebooks', `${made.id}.txt`), 'utf8'); const record = await readFile(join(data, 'divan', 'p238', 'ebooks', `${made.id}.txt`), 'utf8');
assert.match(record, new RegExp(`عنوان: بانگ درا ${run}`)); assert.match(record, new RegExp(up.file)); assert.match(record, new RegExp(`عنوان: بانگ درا ${run}`)); assert.match(record, new RegExp(up.file));
assert.match(git('log', '-1', '--format=%s'), /^ای بک: /); assert.match(git('log', '-1', '--format=%s'), /^ای بک: /);
assert.ok((await pool.query(`SELECT 1 FROM ebooks WHERE id = $1 AND search_text LIKE '%بانگ درا%'`, [made.id])).rowCount, 'indexed for search'); assert.ok((await pool.query(`SELECT 1 FROM ebooks WHERE id = $1 AND search_text LIKE '%بانگ درا%'`, [made.id])).rowCount, 'indexed for search');
// a cover image (the site makes it from the PDF's first page); only images, only with the permission
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
assert.equal((await call('POST', `/api/mod/ebooks/${made.id}/cover`, Buffer.from('not an image'), l2.token, 'application/octet-stream')).statusCode, 415);
assert.match((await call('POST', `/api/mod/ebooks/${made.id}/cover`, png, l2.token, 'application/octet-stream')).json().cover, /\.png$/);
const cover = await call('GET', `/api/ebook/${made.id}/cover`);
assert.equal(cover.headers['content-type'], 'image/png'); assert.deepEqual(cover.rawPayload, png);
assert.equal((await call('GET', '/api/ebooks?poet=238')).json()[0].has_cover, true);
// readers bookmark pages of a published book in their library
const reader = await person('reader', 'reader');
const mark = (page: number, ebookId = made.id) => call('POST', '/api/library/toggle', { kind: 'page', ebookId, page }, reader.token);
assert.equal((await mark(12)).json().saved, true); assert.equal((await mark(3)).json().saved, true);
assert.deepEqual((await call('GET', `/api/library/state?ebook=${made.id}`, undefined, reader.token)).json(), { pages: [3, 12] });
assert.equal((await mark(3)).json().saved, false, 'pressed again: removed');
const lib = (await call('GET', '/api/library', undefined, reader.token)).json();
assert.deepEqual(lib.pages.map((p: any) => [p.page, p.ebook.title]), [[12, `بانگ درا ${run}`]]);
assert.equal((await mark(0)).statusCode, 400);
// a text book serves its text; an archive.org item needs a real id // a text book serves its text; an archive.org item needs a real id
const t = (await call('POST', '/api/mod/ebooks', { poet: 238, file: d.file, title: 'ٹیکسٹ' }, admin.token)).json(); const t = (await call('POST', '/api/mod/ebooks', { poet: 238, file: d.file, title: 'ٹیکسٹ' }, admin.token)).json();
assert.match((await call('GET', `/api/ebook/${t.id}`, undefined, admin.token)).json().text, /دل ہی تو ہے/); assert.match((await call('GET', `/api/ebook/${t.id}`, undefined, admin.token)).json().text, /دل ہی تو ہے/);
assert.equal((await call('POST', '/api/mod/ebooks', { poet: 238, archive: 'https://example.com/x', title: 'x' }, admin.token)).statusCode, 400); assert.equal((await call('POST', '/api/mod/ebooks', { poet: 238, archive: 'https://example.com/x', title: 'x' }, admin.token)).statusCode, 400);
const a = (await call('POST', '/api/mod/ebooks', { poet: 238, archive: 'https://archive.org/details/BaangEDara', title: 'آرکائیو' }, admin.token)).json(); const a = (await call('POST', '/api/mod/ebooks', { poet: 238, archive: 'https://archive.org/details/BaangEDara', title: 'آرکائیو' }, admin.token)).json();
assert.equal((await mark(1, a.id)).statusCode, 404, 'no bookmarks in a book under review');
assert.equal((await call('GET', `/api/ebook/${a.id}`, undefined, admin.token)).json().archive_id, 'BaangEDara'); assert.equal((await call('GET', `/api/ebook/${a.id}`, undefined, admin.token)).json().archive_id, 'BaangEDara');
} finally { } finally {
await pool.query(`DELETE FROM entity_tags WHERE entity = 'ebook' AND entity_id IN (SELECT id FROM ebooks WHERE created_by IN (SELECT id FROM users WHERE email LIKE $1))`, [`%-${run}@divan.test`]);
await pool.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
await pool.query(`DELETE FROM ebooks WHERE created_by IN (SELECT id FROM users WHERE email LIKE $1)`, [`%-${run}@divan.test`]); await pool.query(`DELETE FROM ebooks WHERE created_by IN (SELECT id FROM users WHERE email LIKE $1)`, [`%-${run}@divan.test`]);
await pool.query(`DELETE FROM revisions WHERE author_email LIKE $1`, [`%-${run}@divan.test`]); 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 users WHERE email LIKE $1', [`%-${run}@divan.test`]);

View File

@ -4,12 +4,17 @@
// Files live in the file store (DIVAN_FILES_DIR) under their content hash, ab/cd/<sha256>.<ext>, so a file uploaded // Files live in the file store (DIVAN_FILES_DIR) under their content hash, ab/cd/<sha256>.<ext>, so a file uploaded
// twice is kept once; the database holds the details, indexed by poet and (trigram) by title and the text of text // twice is kept once; the database holds the details, indexed by poet and (trigram) by title and the text of text
// books, so lists and search stay fast. An approved e-book's details (not the file) are committed to divan-data. // books, so lists and search stay fast. An approved e-book's details (not the file) are committed to divan-data.
// The details are a revision (entity 'ebook'), one per line: "عنوان: …", "ماخذ: …", "اجازت: …", "تفصیل: …". // The details are a revision (entity 'ebook'), one per line: عنوان، مصنف (as printed)، شریک مصنفین (names, comma
// separated; those who are poets on the site are linked and the book is listed on their pages too)، زبان، سمت
// (دائیں سے بائیں / بائیں سے دائیں: how the reader turns pages)، ٹیگ (extra tags, comma separated)، ماخذ، اجازت، تفصیل.
// POST /api/mod/ebooks/upload?poet=&name= the file as the request body (up to 100 MB): {file, kind, size} // POST /api/mod/ebooks/upload?poet=&name= the file as the request body (up to 100 MB): {file, kind, size}
// POST /api/mod/ebooks {poet, title, file | archive, source, licence, note}: the e-book and its draft // POST /api/mod/ebooks {poet, title, file | archive, source, licence, note}: the e-book and its draft
// GET /api/ebooks?poet= a poet's published e-books // GET /api/ebooks?poet= a poet's published e-books
// GET /api/ebook/:id one e-book (unpublished: moderators only); the text of a text book // GET /api/ebook/:id one e-book (unpublished: moderators only); the text of a text book
// GET /api/ebook/:id/file its file // GET /api/ebook/:id/file its file
// POST /api/mod/ebooks/:id/cover a cover image as the body (JPEG, PNG or WebP, up to 5 MB); the site makes
// one from a PDF's first page or an EPUB's own cover in the browser
// GET /api/ebook/:id/cover the cover
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs'; import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
@ -25,9 +30,18 @@ import { normalise } from './urdu.ts';
const MAX = 100 * 1024 * 1024; const MAX = 100 * 1024 * 1024;
const store = () => process.env.DIVAN_FILES_DIR ?? new URL('../../../divan-files', import.meta.url).pathname; const store = () => process.env.DIVAN_FILES_DIR ?? new URL('../../../divan-files', import.meta.url).pathname;
const pathOf = (file: string) => join(store(), file.slice(0, 2), file.slice(2, 4), file); const pathOf = (file: string) => join(store(), file.slice(0, 2), file.slice(2, 4), file);
const TYPES: Record<string, string> = { pdf: 'application/pdf', epub: 'application/epub+zip', txt: 'text/plain; charset=utf-8' }; const TYPES: Record<string, string> = { pdf: 'application/pdf', epub: 'application/epub+zip', txt: 'text/plain; charset=utf-8',
const KEYS = { title: 'عنوان', source: 'ماخذ', licence: 'اجازت', note: 'تفصیل' } as const; jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
type Meta = { title: string; source: string; licence: string; note: string }; // an image's format from its first bytes
const imageOf = (h: Buffer) => h[0] === 0xff && h[1] === 0xd8 ? 'jpg' : h.subarray(1, 4).toString() === 'PNG' ? 'png'
: h.subarray(0, 4).toString() === 'RIFF' && h.subarray(8, 12).toString() === 'WEBP' ? 'webp' : null;
const KEYS = { title: 'عنوان', writer: 'مصنف', coauthors: 'شریک مصنفین', language: 'زبان', direction: 'سمت', tags: 'ٹیگ',
source: 'ماخذ', licence: 'اجازت', note: 'تفصیل' } as const;
type Meta = Record<keyof typeof KEYS, string>;
export const LANGUAGES = ['اردو', 'فارسی', 'عربی', 'انگریزی', 'ہندی', 'پنجابی', 'سندھی', 'پشتو', 'دیگر'];
export const DIRECTIONS: Record<string, 'rtl' | 'ltr'> = { 'دائیں سے بائیں': 'rtl', 'بائیں سے دائیں': 'ltr' };
const RTL_LANGS = ['اردو', 'فارسی', 'عربی', 'سندھی', 'پشتو'];
const list = (s: string) => [...new Set(s.split(/[،,]/).map((x) => x.trim()).filter(Boolean))].slice(0, 30);
const isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role); const isModerator = (u: any) => ['mod-l2', 'mod-l1', 'admin'].includes(u?.role);
export const mayUpload = (u: any, poetId: number) => can(u, 'create', 'ebooks', { poetId }); export const mayUpload = (u: any, poetId: number) => can(u, 'create', 'ebooks', { poetId });
@ -35,13 +49,31 @@ export const mayUpload = (u: any, poetId: number) => can(u, 'create', 'ebooks',
export const metaText = (m: Partial<Meta>) => export const metaText = (m: Partial<Meta>) =>
(Object.keys(KEYS) as (keyof Meta)[]).map((k) => `${KEYS[k]}: ${String(m[k] ?? '').replace(/\s+/g, ' ').trim()}`).join('\n'); (Object.keys(KEYS) as (keyof Meta)[]).map((k) => `${KEYS[k]}: ${String(m[k] ?? '').replace(/\s+/g, ' ').trim()}`).join('\n');
export function parseMeta(text: string): Meta | string { export function parseMeta(text: string): Meta | string {
const m: any = { title: '', source: '', licence: '', note: '' }; const m: any = Object.fromEntries(Object.keys(KEYS).map((k) => [k, '']));
for (const line of text.split('\n')) { for (const line of text.split('\n')) {
const i = line.indexOf(':'); if (i < 0) continue; const i = line.indexOf(':'); if (i < 0) continue;
const key = (Object.keys(KEYS) as (keyof Meta)[]).find((k) => KEYS[k] === line.slice(0, i).trim()); const key = (Object.keys(KEYS) as (keyof Meta)[]).find((k) => KEYS[k] === line.slice(0, i).trim());
if (key) m[key] = line.slice(i + 1).trim().slice(0, key === 'note' ? 2000 : 300); if (key) m[key] = line.slice(i + 1).trim().slice(0, key === 'note' ? 2000 : 300);
} }
return m.title ? m : 'کتاب کا عنوان ضروری ہے'; if (!m.title) return 'کتاب کا عنوان ضروری ہے';
m.language ||= 'اردو';
if (!LANGUAGES.includes(m.language)) return `زبان ${LANGUAGES.join('، ')} میں سے ہو`;
m.direction ||= RTL_LANGS.includes(m.language) ? 'دائیں سے بائیں' : 'بائیں سے دائیں';
if (!DIRECTIONS[m.direction]) return 'سمت «دائیں سے بائیں» یا «بائیں سے دائیں» ہو';
if (list(m.tags).some((t) => t.length > 60)) return 'ٹیگ بہت لمبا ہے';
return m;
}
// co-authors who are poets on the site (by name or pen name, Urdu-normalised)
async function poetsNamed(names: string[], db: { query: PoolClient['query'] } = pool) {
if (!names.length) return new Map<string, { id: number; url: string }>();
const { rows } = await db.query('SELECT id, url, name, nickname FROM poets');
const out = new Map<string, { id: number; url: string }>();
for (const n of names) {
const hit = rows.find((p) => normalise(p.nickname) === normalise(n) || normalise(p.name) === normalise(n));
if (hit) out.set(n, { id: hit.id, url: hit.url });
}
return out;
} }
// archive.org: an item id, or a link to it // archive.org: an item id, or a link to it
@ -85,7 +117,10 @@ export async function currentEbook(id: number) {
'SELECT b.*, t.url AS poet_url, t.nickname AS poet FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.id = $1', [id])).rows[0]; 'SELECT b.*, t.url AS poet_url, t.nickname AS poet FROM ebooks b JOIN poets t ON t.id = b.poet_id WHERE b.id = $1', [id])).rows[0];
if (!ebook) return null; if (!ebook) return null;
const last = (await pool.query(`SELECT max(version) AS v FROM revisions WHERE entity = 'ebook' AND entity_id = $1 AND status = 'published'`, [id])).rows[0]; const last = (await pool.query(`SELECT max(version) AS v FROM revisions WHERE entity = 'ebook' AND entity_id = $1 AND status = 'published'`, [id])).rows[0];
return { ebook, version: Number(last?.v ?? 0), content: ebook.published ? metaText(ebook) : '' }; const tags = (await pool.query(`SELECT t.name FROM entity_tags e JOIN tags t ON t.id = e.tag_id WHERE e.entity = 'ebook' AND e.entity_id = $1 ORDER BY t.name`, [id])).rows.map((r) => r.name);
const text = metaText({ ...ebook, coauthors: ebook.coauthors.join('، '), tags: tags.join('، '),
direction: Object.keys(DIRECTIONS).find((k) => DIRECTIONS[k] === ebook.direction) });
return { ebook, version: Number(last?.v ?? 0), content: ebook.published ? text : '' };
} }
export const checkEbook = (text: string) => { const m = parseMeta(text); return typeof m === 'string' ? m : null; }; export const checkEbook = (text: string) => { const m = parseMeta(text); return typeof m === 'string' ? m : null; };
@ -94,8 +129,20 @@ export const checkEbook = (text: string) => { const m = parseMeta(text); return
export async function applyEbook(client: PoolClient, id: number, text: string) { export async function applyEbook(client: PoolClient, id: number, text: string) {
const m = parseMeta(text) as Meta; const m = parseMeta(text) as Meta;
const b = (await client.query('SELECT kind, file FROM ebooks WHERE id = $1', [id])).rows[0]; const b = (await client.query('SELECT kind, file FROM ebooks WHERE id = $1', [id])).rows[0];
await client.query('UPDATE ebooks SET title = $2, source = $3, licence = $4, note = $5, search_text = $6, published = true WHERE id = $1', const co = list(m.coauthors), linked = await poetsNamed(co, client);
[id, m.title, m.source || null, m.licence || null, m.note || null, await searchText(m.title, b.kind, b.file)]); await client.query(
`UPDATE ebooks SET title = $2, source = $3, licence = $4, note = $5, search_text = $6, writer = $7, language = $8, direction = $9,
coauthors = $10, coauthor_ids = $11, published = true WHERE id = $1`,
[id, m.title, m.source || null, m.licence || null, m.note || null,
await searchText([m.title, m.writer, ...co, ...list(m.tags)].join(' '), b.kind, b.file), m.writer || null, m.language,
DIRECTIONS[m.direction], co, [...new Set([...linked.values()].map((p) => p.id))]]);
// extra tags: free tags (ٹیگ), so tag pages list the book
await client.query(`DELETE FROM entity_tags WHERE entity = 'ebook' AND entity_id = $1`, [id]);
for (const name of list(m.tags)) {
const tagId = (await client.query(`INSERT INTO tags (type, name) VALUES ('ٹیگ', $1) ON CONFLICT (type, name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name])).rows[0].id;
await client.query(`INSERT INTO entity_tags (tag_id, entity, entity_id) VALUES ($1, 'ebook', $2) ON CONFLICT DO NOTHING`, [tagId, id]);
}
await client.query('DELETE FROM tags t WHERE NOT EXISTS (SELECT 1 FROM entity_tags e WHERE e.tag_id = t.id)');
} }
// divan-data/divan/<poet>/ebooks/<id>.txt: the details and where the file is (the file itself stays in the store) // divan-data/divan/<poet>/ebooks/<id>.txt: the details and where the file is (the file itself stays in the store)
@ -150,7 +197,8 @@ export function ebookRoutes(app: FastifyInstance) {
if (!u) return reply.code(401).send({ error: 'لاگ ان کریں' }); if (!u) return reply.code(401).send({ error: 'لاگ ان کریں' });
const b = req.body ?? {}, poetId = Number(b.poet) || 0; const b = req.body ?? {}, poetId = Number(b.poet) || 0;
if (!(await mayUpload(u, poetId))) return reply.code(403).send({ error: 'اس شاعر کی ای بکس شامل کرنے کی اجازت نہیں' }); if (!(await mayUpload(u, poetId))) return reply.code(403).send({ error: 'اس شاعر کی ای بکس شامل کرنے کی اجازت نہیں' });
const meta = parseMeta(metaText(b)); const meta = parseMeta(metaText({ ...b, coauthors: Array.isArray(b.coauthors) ? b.coauthors.join('، ') : b.coauthors,
direction: DIRECTIONS[b.direction] ? b.direction : b.direction === 'ltr' ? 'بائیں سے دائیں' : b.direction === 'rtl' ? 'دائیں سے بائیں' : '' }));
if (typeof meta === 'string') return reply.code(400).send({ error: meta }); if (typeof meta === 'string') return reply.code(400).send({ error: meta });
let kind: string, file: string | null = null, archive: string | null = null, size: number | null = null; let kind: string, file: string | null = null, archive: string | null = null, size: number | null = null;
if (b.archive) { if (b.archive) {
@ -169,9 +217,10 @@ export function ebookRoutes(app: FastifyInstance) {
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
const id = (await client.query( const id = (await client.query(
`INSERT INTO ebooks (poet_id, title, kind, file, archive_id, size, source, licence, note, created_by) `INSERT INTO ebooks (poet_id, title, kind, file, archive_id, size, source, licence, note, created_by, writer, language, direction, coauthors)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id`,
[poetId, meta.title, kind, file, archive, size, meta.source || null, meta.licence || null, meta.note || null, u.id])).rows[0].id; [poetId, meta.title, kind, file, archive, size, meta.source || null, meta.licence || null, meta.note || null, u.id,
meta.writer || null, meta.language, DIRECTIONS[meta.direction], list(meta.coauthors)])).rows[0].id;
const rev = (await client.query( const rev = (await client.query(
`INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email, summary) `INSERT INTO revisions (entity, entity_id, base_version, base_content, content, status, author_id, author_email, summary)
VALUES ('ebook', $1, 0, '', $2, 'draft', $3, $4, $5) RETURNING id`, [id, content, u.id, u.email, 'نئی ای بک'])).rows[0].id; VALUES ('ebook', $1, 0, '', $2, 'draft', $3, $4, $5) RETURNING id`, [id, content, u.id, u.email, 'نئی ای بک'])).rows[0].id;
@ -187,7 +236,32 @@ export function ebookRoutes(app: FastifyInstance) {
}); });
app.get<{ Querystring: { poet?: string } }>('/api/ebooks', async (req) => (await pool.query( app.get<{ Querystring: { poet?: string } }>('/api/ebooks', async (req) => (await pool.query(
`SELECT id, title, kind, size, source FROM ebooks WHERE poet_id = $1 AND published ORDER BY title`, [Number(req.query.poet) || 0])).rows); `SELECT id, title, kind, size, source, cover IS NOT NULL AS has_cover, archive_id, language, poet_id <> $1 AS co
FROM ebooks WHERE (poet_id = $1 OR $1 = ANY(coauthor_ids)) AND published ORDER BY title`,
[Number(req.query.poet) || 0])).rows);
app.post<{ Params: { id: string } }>('/api/mod/ebooks/:id/cover', { bodyLimit: 5 * 1024 * 1024 }, async (req, reply) => {
const u = await sessionUser(req);
if (!u) return reply.code(401).send({ error: 'لاگ ان کریں' });
const b = (await pool.query('SELECT id, poet_id FROM ebooks WHERE id = $1', [Number(req.params.id) || 0])).rows[0];
if (!b) return reply.code(404).send({ error: 'کتاب نہیں ملی' });
if (!(await mayUpload(u, b.poet_id))) return reply.code(403).send({ error: 'اس کتاب کا سرورق بدلنے کی اجازت نہیں' });
const chunks: Buffer[] = [];
let size = 0;
for await (const c of req.body as AsyncIterable<Buffer>) {
size += c.length;
if (size > 5 * 1024 * 1024) return reply.code(413).send({ error: 'تصویر ۵ میگا بائٹ سے بڑی ہے' });
chunks.push(c);
}
const img = Buffer.concat(chunks), ext = imageOf(img);
if (!ext) return reply.code(415).send({ error: 'صرف JPEG، PNG یا WebP تصویر' });
const tmp = join(store(), 'tmp', randomUUID());
await mkdir(dirname(tmp), { recursive: true });
await writeFile(tmp, img);
const cover = await keep(tmp, ext);
await pool.query('UPDATE ebooks SET cover = $2 WHERE id = $1', [b.id, cover]);
return { cover };
});
const ebook = async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const ebook = async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const b = (await pool.query( const b = (await pool.query(
@ -199,16 +273,36 @@ export function ebookRoutes(app: FastifyInstance) {
app.get<{ Params: { id: string } }>('/api/ebook/:id', async (req, reply) => { app.get<{ Params: { id: string } }>('/api/ebook/:id', async (req, reply) => {
const b = await ebook(req, reply); if (!b) return; const b = await ebook(req, reply); if (!b) return;
const { search_text, created_by, ...rest } = b; const { search_text, created_by, ...rest } = b;
return { ...rest, text: b.kind === 'text' ? (await textOf(b.file)).slice(0, 3_000_000) : undefined }; // co-authors: linked to their pages when they are poets on the site
const linked = await poetsNamed(b.coauthors);
const tags = (await pool.query(`SELECT t.type, t.name FROM entity_tags e JOIN tags t ON t.id = e.tag_id WHERE e.entity = 'ebook' AND e.entity_id = $1 ORDER BY t.name`, [b.id])).rows;
return { ...rest, coauthors: b.coauthors.map((name: string) => ({ name, url: linked.get(name)?.url ?? null })), tags,
text: b.kind === 'text' ? (await textOf(b.file)).slice(0, 3_000_000) : undefined };
});
app.get<{ Params: { id: string } }>('/api/ebook/:id/cover', async (req, reply) => {
const b = await ebook(req, reply); if (!b) return;
if (!b.cover) return reply.code(404).send({ error: 'سرورق نہیں' });
reply.header('content-type', TYPES[b.cover.split('.').pop()]).header('cache-control', b.published ? 'public, max-age=604800' : 'private, no-store');
return reply.send(createReadStream(pathOf(b.cover)));
}); });
app.get<{ Params: { id: string } }>('/api/ebook/:id/file', async (req, reply) => { app.get<{ Params: { id: string } }>('/api/ebook/:id/file', async (req, reply) => {
const b = await ebook(req, reply); if (!b) return; const b = await ebook(req, reply); if (!b) return;
if (!b.file) return reply.code(404).send({ error: 'اس کتاب کی فائل نہیں' }); if (!b.file) return reply.code(404).send({ error: 'اس کتاب کی فائل نہیں' });
const ext = b.file.split('.').pop(); const ext = b.file.split('.').pop(), size = (await stat(pathOf(b.file))).size;
reply.header('content-type', TYPES[ext]).header('content-length', String((await stat(pathOf(b.file))).size)) reply.header('content-type', TYPES[ext]).header('accept-ranges', 'bytes')
.header('content-disposition', `inline; filename="divan-${b.id}.${ext}"`) .header('content-disposition', `inline; filename="divan-${b.id}.${ext}"`)
.header('cache-control', b.published ? 'public, max-age=86400' : 'private, no-store'); .header('cache-control', b.published ? 'public, max-age=86400' : 'private, no-store');
// a byte range (the PDF reader asks for the pages it shows, not the whole file)
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ''));
if (m && (m[1] || m[2])) {
const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2])), end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
if (start > end || start >= size) return reply.code(416).header('content-range', `bytes */${size}`).send();
reply.code(206).header('content-range', `bytes ${start}-${end}/${size}`).header('content-length', String(end - start + 1));
return reply.send(createReadStream(pathOf(b.file), { start, end }));
}
reply.header('content-length', String(size));
return reply.send(createReadStream(pathOf(b.file))); return reply.send(createReadStream(pathOf(b.file)));
}); });
} }

View File

@ -1,19 +1,20 @@
// Personal library (#24, #25): a reader's saved poets and works, bookmarked couplets and phrases, saved words. // Personal library (#24, #25): a reader's saved poets and works, bookmarked couplets and phrases, saved words.
// JSON in and out with Bearer tokens, so the site and mobile apps (#44) use the same endpoints. // JSON in and out with Bearer tokens, so the site and mobile apps (#44) use the same endpoints.
// GET /api/library everything, each item with its path in the site // GET /api/library everything, each item with its path in the site
// GET /api/library/state?poet=&category=&poem=&word= what is saved on one page, or whether a word is in the word book // GET /api/library/state?poet=&category=&poem=&word= what is saved on one page, or whether a word is in the word book;
// ?ebook= the bookmarked pages of an e-book
// GET /api/library/marks which works, books/sections and poets hold the reader's saved items // GET /api/library/marks which works, books/sections and poets hold the reader's saved items
// POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?} // POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?}
// POST /api/library/:id/note {note} // POST /api/library/:id/note {note}
// POST /api/library/:id/delete // POST /api/library/:id/delete
// kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a // kind: poet {poetId} | category {categoryId} (a book or chapter) | poem {poemId} | couplet {poemId, couplet} | phrase {poemId, couplet, phrase} (part of a
// couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) // couplet or paragraph) | word {word, poemId?, couplet?} (dictionary word) | page {ebookId, page} (a page of an e-book)
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { pool } from './db.ts'; import { pool } from './db.ts';
import { sessionUser } from './auth.ts'; import { sessionUser } from './auth.ts';
import { PUNCT } from './dictionary.ts'; import { PUNCT } from './dictionary.ts';
const KINDS = ['poet', 'category', 'poem', 'couplet', 'phrase', 'word'] as const; const KINDS = ['poet', 'category', 'poem', 'couplet', 'phrase', 'word', 'page'] as const;
// a bookmarked phrase: spaces collapsed, 2 to 300 characters // a bookmarked phrase: spaces collapsed, 2 to 300 characters
export const cleanPhrase = (p: unknown) => { export const cleanPhrase = (p: unknown) => {
@ -65,9 +66,11 @@ export function libraryRoutes(app: FastifyInstance) {
app.get('/api/library', async (req, reply) => { app.get('/api/library', async (req, reply) => {
const u = await reader(req, reply); if (!u) return; const u = await reader(req, reply); if (!u) return;
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT l.id, l.kind, l.poet_id, l.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, `SELECT l.id, l.kind, l.poet_id, l.category_id, l.poem_id, l.couplet, l.word, l.phrase, l.note, l.created_at, l.ebook_id, l.page,
pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet pt.nickname AS poet_name, pt.url AS poet_url, pm.title AS poem_title, pm.url AS poem_url, pm.poet_id AS poem_poet,
eb.title AS ebook_title, ep.nickname AS ebook_poet, ep.url AS ebook_poet_url
FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id FROM library l LEFT JOIN poets pt ON pt.id = l.poet_id LEFT JOIN poems pm ON pm.id = l.poem_id
LEFT JOIN ebooks eb ON eb.id = l.ebook_id LEFT JOIN poets ep ON ep.id = eb.poet_id
WHERE l.user_id = $1 ORDER BY l.created_at DESC, l.id DESC`, [u.id]); WHERE l.user_id = $1 ORDER BY l.created_at DESC, l.id DESC`, [u.id]);
const poemIds = [...new Set(rows.filter((r) => r.poem_id).map((r) => r.poem_id))]; const poemIds = [...new Set(rows.filter((r) => r.poem_id).map((r) => r.poem_id))];
const [crumbs, verses, chapters] = await Promise.all([ const [crumbs, verses, chapters] = await Promise.all([
@ -86,14 +89,18 @@ export function libraryRoutes(app: FastifyInstance) {
poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })), poems: rows.filter((r) => r.kind === 'poem').map((r) => ({ ...item(r), poem: r.poem_url && where(r) })),
couplets: rows.filter((r) => r.kind === 'couplet').map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })), couplets: rows.filter((r) => r.kind === 'couplet').map((r) => ({ ...item(r), poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })), phrases: rows.filter((r) => r.kind === 'phrase').map((r) => ({ ...item(r), phrase: r.phrase, poem: r.poem_url && where(r), lines: lines(r.poem_id, r.couplet) })),
pages: rows.filter((r) => r.kind === 'page' && r.ebook_title).map((r) => ({
...item(r), page: r.page, ebook: { id: r.ebook_id, title: r.ebook_title, poet: r.ebook_poet, poet_url: r.ebook_poet_url } })),
words: rows.filter((r) => r.kind === 'word').map((r) => ({ words: rows.filter((r) => r.kind === 'word').map((r) => ({
...item(r), word: r.word, source: r.poem_url ? { ...where(r), lines: r.couplet != null ? lines(r.poem_id, r.couplet) : [] } : null, ...item(r), word: r.word, source: r.poem_url ? { ...where(r), lines: r.couplet != null ? lines(r.poem_id, r.couplet) : [] } : null,
})), })),
}; };
}); });
app.get<{ Querystring: { poet?: string; category?: string; poem?: string; word?: string } }>('/api/library/state', async (req, reply) => { app.get<{ Querystring: { poet?: string; category?: string; poem?: string; word?: string; ebook?: string } }>('/api/library/state', async (req, reply) => {
const u = await reader(req, reply); if (!u) return; const u = await reader(req, reply); if (!u) return;
if (req.query.ebook != null) return { pages: (await pool.query(
`SELECT page FROM library WHERE user_id = $1 AND kind = 'page' AND ebook_id = $2 ORDER BY page`, [u.id, Number(req.query.ebook) || 0])).rows.map((r) => r.page) };
if (req.query.word != null) { if (req.query.word != null) {
const w = cleanWord(req.query.word); const w = cleanWord(req.query.word);
return { word: !!w && !!(await pool.query(`SELECT 1 FROM library WHERE user_id = $1 AND kind = 'word' AND word = $2`, [u.id, w])).rowCount }; return { word: !!w && !!(await pool.query(`SELECT 1 FROM library WHERE user_id = $1 AND kind = 'word' AND word = $2`, [u.id, w])).rowCount };
@ -150,6 +157,15 @@ export function libraryRoutes(app: FastifyInstance) {
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, word, poem_id, couplet) VALUES ($1, 'word', $2, $3, $4) RETURNING id`, [u.id, word, ...src]); const { rows } = await pool.query(`INSERT INTO library (user_id, kind, word, poem_id, couplet) VALUES ($1, 'word', $2, $3, $4) RETURNING id`, [u.id, word, ...src]);
return { saved: true, id: Number(rows[0].id) }; return { saved: true, id: Number(rows[0].id) };
} }
if (kind === 'page') {
const ebookId = Number((b as any).ebookId) || 0, page = Number((b as any).page);
if (!Number.isInteger(page) || page < 1 || page > 100000) return reply.code(400).send({ error: 'صفحہ نمبر درست نہیں' });
if (!(await pool.query('SELECT 1 FROM ebooks WHERE id = $1 AND published', [ebookId])).rowCount) return reply.code(404).send({ error: 'کتاب نہیں ملی' });
if ((await pool.query(`DELETE FROM library WHERE user_id = $1 AND kind = 'page' AND ebook_id = $2 AND page = $3 RETURNING id`, [u.id, ebookId, page])).rowCount)
return { saved: false };
const { rows } = await pool.query(`INSERT INTO library (user_id, kind, ebook_id, page) VALUES ($1, 'page', $2, $3) RETURNING id`, [u.id, ebookId, page]);
return { saved: true, id: Number(rows[0].id) };
}
if (kind === 'phrase') { if (kind === 'phrase') {
const phrase = cleanPhrase(b.phrase); const phrase = cleanPhrase(b.phrase);
if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' }); if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' });

View File

@ -126,13 +126,15 @@ export function tagRoutes(app: FastifyInstance) {
app.get<{ Querystring: { type?: string; name?: string } }>('/api/tag', async (req, reply) => { app.get<{ Querystring: { type?: string; name?: string } }>('/api/tag', async (req, reply) => {
const tag = (await pool.query('SELECT id, type, name FROM tags WHERE type = $1 AND name = $2', [req.query.type ?? '', req.query.name ?? ''])).rows[0]; const tag = (await pool.query('SELECT id, type, name FROM tags WHERE type = $1 AND name = $2', [req.query.type ?? '', req.query.name ?? ''])).rows[0];
if (!tag) return reply.code(404).send({ error: 'ٹیگ نہیں ملا' }); if (!tag) return reply.code(404).send({ error: 'ٹیگ نہیں ملا' });
const [cats, works] = await Promise.all([ const [cats, works, ebooks] = await Promise.all([
pool.query(`SELECT c.id, c.url, c.title, t.nickname AS poet, t.url AS poet_url, c.parent_id IS NULL AS is_poet pool.query(`SELECT c.id, c.url, c.title, t.nickname AS poet, t.url AS poet_url, c.parent_id IS NULL AS is_poet
FROM entity_tags e JOIN categories c ON c.id = e.entity_id JOIN poets t ON t.id = c.poet_id FROM entity_tags e JOIN categories c ON c.id = e.entity_id JOIN poets t ON t.id = c.poet_id
WHERE e.tag_id = $1 AND e.entity = 'category' ORDER BY t.birth_year_ah NULLS LAST, c.title`, [tag.id]), WHERE e.tag_id = $1 AND e.entity = 'category' ORDER BY t.birth_year_ah NULLS LAST, c.title`, [tag.id]),
pool.query(`SELECT p.id, p.url, p.title, t.nickname AS poet, t.url AS poet_url, array_agg(e.couplet ORDER BY e.couplet) AS couplets pool.query(`SELECT p.id, p.url, p.title, t.nickname AS poet, t.url AS poet_url, array_agg(e.couplet ORDER BY e.couplet) AS couplets
FROM entity_tags e JOIN poems p ON p.id = e.entity_id JOIN poets t ON t.id = p.poet_id FROM entity_tags e JOIN poems p ON p.id = e.entity_id JOIN poets t ON t.id = p.poet_id
WHERE e.tag_id = $1 AND e.entity = 'work' GROUP BY p.id, t.id ORDER BY t.birth_year_ah NULLS LAST, p.title LIMIT 500`, [tag.id]), WHERE e.tag_id = $1 AND e.entity = 'work' GROUP BY p.id, t.id ORDER BY t.birth_year_ah NULLS LAST, p.title LIMIT 500`, [tag.id]),
pool.query(`SELECT b.id, b.title, b.kind, t.nickname AS poet, t.url AS poet_url FROM entity_tags e JOIN ebooks b ON b.id = e.entity_id
JOIN poets t ON t.id = b.poet_id WHERE e.tag_id = $1 AND e.entity = 'ebook' AND b.published ORDER BY b.title`, [tag.id]),
]); ]);
// the tagged couplets' lines // the tagged couplets' lines
const pairs = works.rows.flatMap((w) => w.couplets.filter((c: number) => c > 0).map((c: number) => [w.id, c])); const pairs = works.rows.flatMap((w) => w.couplets.filter((c: number) => c > 0).map((c: number) => [w.id, c]));
@ -141,7 +143,7 @@ export function tagRoutes(app: FastifyInstance) {
WHERE (poem_id, couplet + 1) IN (SELECT * FROM unnest($1::int[], $2::int[])) GROUP BY poem_id, couplet`, WHERE (poem_id, couplet + 1) IN (SELECT * FROM unnest($1::int[], $2::int[])) GROUP BY poem_id, couplet`,
[pairs.map((p) => p[0]), pairs.map((p) => p[1])])).rows : []; [pairs.map((p) => p[0]), pairs.map((p) => p[1])])).rows : [];
return { return {
tag, categories: cats.rows, tag, categories: cats.rows, ebooks: ebooks.rows,
works: works.rows.map((w) => ({ ...w, whole: w.couplets.includes(0), works: works.rows.map((w) => ({ ...w, whole: w.couplets.includes(0),
couplets: lines.filter((l) => l.poem_id === w.id).map((l) => ({ couplet: l.couplet + 1, anchor: l.couplet, lines: l.lines })) })), couplets: lines.filter((l) => l.poem_id === w.id).map((l) => ({ couplet: l.couplet + 1, anchor: l.couplet, lines: l.lines })) })),
}; };

View File

@ -143,7 +143,7 @@ CREATE TABLE IF NOT EXISTS library (
ALTER TABLE library ADD COLUMN IF NOT EXISTS phrase text; ALTER TABLE library ADD COLUMN IF NOT EXISTS phrase text;
ALTER TABLE library DROP CONSTRAINT IF EXISTS library_kind_check; ALTER TABLE library DROP CONSTRAINT IF EXISTS library_kind_check;
ALTER TABLE library ADD COLUMN IF NOT EXISTS category_id integer; ALTER TABLE library ADD COLUMN IF NOT EXISTS category_id integer;
ALTER TABLE library ADD CONSTRAINT library_kind_check CHECK (kind IN ('poet', 'category', 'poem', 'couplet', 'phrase', 'word')); ALTER TABLE library ADD CONSTRAINT library_kind_check CHECK (kind IN ('poet', 'category', 'poem', 'couplet', 'phrase', 'word', 'page'));
DROP INDEX IF EXISTS library_items; DROP INDEX IF EXISTS library_items;
DROP INDEX IF EXISTS library_places; DROP INDEX IF EXISTS library_places;
CREATE UNIQUE INDEX IF NOT EXISTS library_bookmarks ON library (user_id, kind, coalesce(poet_id, 0), coalesce(category_id, 0), coalesce(poem_id, 0), coalesce(couplet, -1)) WHERE kind IN ('poet', 'category', 'poem', 'couplet'); CREATE UNIQUE INDEX IF NOT EXISTS library_bookmarks ON library (user_id, kind, coalesce(poet_id, 0), coalesce(category_id, 0), coalesce(poem_id, 0), coalesce(couplet, -1)) WHERE kind IN ('poet', 'category', 'poem', 'couplet');
@ -236,3 +236,18 @@ CREATE TABLE IF NOT EXISTS ebooks (
); );
CREATE INDEX IF NOT EXISTS ebooks_poet ON ebooks(poet_id) WHERE published; CREATE INDEX IF NOT EXISTS ebooks_poet ON ebooks(poet_id) WHERE published;
CREATE INDEX IF NOT EXISTS ebooks_search ON ebooks USING gin (search_text gin_trgm_ops); CREATE INDEX IF NOT EXISTS ebooks_search ON ebooks USING gin (search_text gin_trgm_ops);
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS cover text; -- cover image '<sha256>.jpg|png|webp' in the file store
-- library: bookmarked pages of e-books (kind 'page': ebook_id, page)
ALTER TABLE library ADD COLUMN IF NOT EXISTS ebook_id integer;
ALTER TABLE library ADD COLUMN IF NOT EXISTS page integer; -- kinds: see library_kind_check above
-- e-book details (owner request): language, reading direction, the writer's name as printed, co-authors (names;
-- coauthor_ids = those who are poets on the site, so the book is listed on their pages too). Extra tags are
-- entity_tags with entity 'ebook'.
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS writer text;
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS language text NOT NULL DEFAULT 'اردو';
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS direction text NOT NULL DEFAULT 'rtl' CHECK (direction IN ('rtl', 'ltr'));
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS coauthors text[] NOT NULL DEFAULT '{}';
ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS coauthor_ids integer[] NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS ebooks_coauthors ON ebooks USING gin (coauthor_ids);
ALTER TABLE entity_tags DROP CONSTRAINT IF EXISTS entity_tags_entity_check;
ALTER TABLE entity_tags ADD CONSTRAINT entity_tags_entity_check CHECK (entity IN ('category', 'work', 'ebook'));

280
web/package-lock.json generated
View File

@ -15,7 +15,8 @@
"@tiptap/extensions": "^3.31.4", "@tiptap/extensions": "^3.31.4",
"@tiptap/pm": "^3.31.4", "@tiptap/pm": "^3.31.4",
"astro": "^7.3.8", "astro": "^7.3.8",
"epubjs": "^0.3.93" "epubjs": "^0.3.93",
"pdfjs-dist": "^6.4.299"
} }
}, },
"node_modules/@astrojs/compiler-binding": { "node_modules/@astrojs/compiler-binding": {
@ -1149,6 +1150,271 @@
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/canvas": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.10",
"@napi-rs/canvas-darwin-arm64": "1.0.10",
"@napi-rs/canvas-darwin-x64": "1.0.10",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
"@napi-rs/canvas-win32-x64-msvc": "1.0.10"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.10.tgz",
"integrity": "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.10.tgz",
"integrity": "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.10.tgz",
"integrity": "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.10.tgz",
"integrity": "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.10.tgz",
"integrity": "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.10.tgz",
"integrity": "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.10.tgz",
"integrity": "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.10.tgz",
"integrity": "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.10.tgz",
"integrity": "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.10.tgz",
"integrity": "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz",
@ -3377,6 +3643,18 @@
"integrity": "sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==", "integrity": "sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfjs-dist": {
"version": "6.4.299",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
"integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.10"
}
},
"node_modules/piccolore": { "node_modules/piccolore": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",

View File

@ -18,6 +18,7 @@
"@tiptap/extensions": "^3.31.4", "@tiptap/extensions": "^3.31.4",
"@tiptap/pm": "^3.31.4", "@tiptap/pm": "^3.31.4",
"astro": "^7.3.8", "astro": "^7.3.8",
"epubjs": "^0.3.93" "epubjs": "^0.3.93",
"pdfjs-dist": "^6.4.299"
} }
} }

View File

@ -0,0 +1,60 @@
---
// An e-book's details, for adding a book and for reviewing it: each input has a name (the upload form posts it)
// and data-eb (the review form writes them into the revision text). Co-authors are picked from the site's
// poets (linked) or typed as new names.
import { api } from '../lib/api';
const { v = {} } = Astro.props as { v?: Record<string, string> };
const LANGUAGES = ['اردو', 'فارسی', 'عربی', 'انگریزی', 'ہندی', 'پنجابی', 'سندھی', 'پشتو', 'دیگر'];
const poets = ((await api<any[]>('/api/poets')) ?? []).map((p) => p.nickname);
const KB = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" 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" stroke-linecap="round" stroke-width="2"/></svg>';
const keys = (id: string) => `<button type="button" class="in-box rom" data-urdu-rom="${id}" aria-pressed="false" title="رومن حروف سے اردو">اب</button><button type="button" class="in-box" data-urdu-kb="${id}" aria-pressed="false" title="اردو کی بورڈ" aria-label="اردو کی بورڈ">${KB}</button>`;
const direction = v.direction || (['انگریزی', 'ہندی'].includes(v.language) ? 'بائیں سے دائیں' : 'دائیں سے بائیں');
---
<label>عنوان<span class="in-box-field"><input id="eb-title" name="title" data-eb="title" value={v.title ?? ''} required maxlength="300" data-urdu /><Fragment set:html={keys('eb-title')} /></span></label>
<label>مصنف کا نام (جیسا کتاب پر لکھا ہے)<span class="in-box-field"><input id="eb-writer" name="writer" data-eb="writer" value={v.writer ?? ''} maxlength="300" data-urdu /><Fragment set:html={keys('eb-writer')} /></span></label>
<div class="eb-co">
<span>شریک مصنفین <small class="muted">(سائٹ پر موجود ہوں تو ان کے صفحے سے جڑ جاتے ہیں؛ نیا نام بھی لکھ سکتے ہیں)</small></span>
<p class="tag-chips eb-co-chips"></p>
<span class="in-box-field author-find"><input id="eb-co" placeholder="نام لکھیں، Enter سے شامل کریں" autocomplete="off" data-urdu /><Fragment set:html={keys('eb-co')} />
<span class="author-suggest" hidden></span></span>
<input type="hidden" name="coauthors" data-eb="coauthors" value={v.coauthors ?? ''} />
<script type="application/json" class="eb-poets" set:html={JSON.stringify(poets)} />
</div>
<div class="eb-row">
<label>زبان<select name="language" data-eb="language" class="tool">{LANGUAGES.map((l) => <option selected={l === (v.language || 'اردو')}>{l}</option>)}</select></label>
<fieldset class="eb-dir"><legend>پڑھنے کی سمت</legend>
{['دائیں سے بائیں', 'بائیں سے دائیں'].map((d) => <label><input type="radio" name="direction" data-eb="direction" value={d} checked={d === direction} /> {d} <span lang="en" dir="ltr">({d === 'دائیں سے بائیں' ? 'RTL' : 'LTR'})</span></label>)}
</fieldset>
</div>
<label>ٹیگ (کئی ہوں تو ، سے الگ کریں)<span class="in-box-field"><input id="eb-tags" name="tags" data-eb="tags" value={v.tags ?? ''} maxlength="500" data-urdu placeholder="مثلاً: نعت، کلاسیکی" /><Fragment set:html={keys('eb-tags')} /></span></label>
<label>ماخذ<input name="source" data-eb="source" value={v.source ?? ''} maxlength="300" data-urdu placeholder="مثلاً: ناشر، سال، کتب خانہ" /></label>
<label>حقوق / اجازت<input name="licence" data-eb="licence" value={v.licence ?? ''} maxlength="300" data-urdu placeholder="مثلاً: عوامی ملکیت، مصنف کی اجازت سے" /></label>
<label>تفصیل<textarea name="note" data-eb="note" rows="3" maxlength="2000" data-urdu>{v.note ?? ''}</textarea></label>
<script>
// co-author chips: suggestions from the site's poets; Enter adds what is typed (a new name)
for (const box of document.querySelectorAll<HTMLElement>('.eb-co')) {
const poets: string[] = JSON.parse(box.querySelector('.eb-poets')!.textContent!);
const hidden = box.querySelector<HTMLInputElement>('input[type=hidden]')!, input = box.querySelector<HTMLInputElement>('#eb-co')!;
const chips = box.querySelector<HTMLElement>('.eb-co-chips')!, list = box.querySelector<HTMLElement>('.author-suggest')!;
const norm = (s: string) => s.normalize('NFC').replace(/[ً-ْٰٔ]/g, '').replace(/[يى]/g, 'ی').replace(/ك/g, 'ک').replace(/[هۂ]/g, 'ہ');
let names = hidden.value.split(/[،,]/).map((s) => s.trim()).filter(Boolean);
const draw = () => {
hidden.value = names.join('، '); hidden.dispatchEvent(new Event('input', { bubbles: true }));
chips.replaceChildren(...names.map((n) => {
const c = document.createElement('button'); c.type = 'button'; c.className = 'tag-chip';
c.textContent = `${n}${poets.some((p) => norm(p) === norm(n)) ? '' : ' (نیا)'} ✕`; c.title = 'ہٹائیں';
c.onclick = () => { names = names.filter((x) => x !== n); draw(); }; return c;
}));
};
const add = (n: string) => { n = n.trim(); if (n && !names.includes(n)) names.push(n); input.value = ''; list.hidden = true; draw(); input.focus(); };
input.addEventListener('input', () => {
const t = norm(input.value.trim());
const hits = t ? poets.filter((p) => norm(p).includes(t) && !names.includes(p)).slice(0, 10) : [];
list.replaceChildren(...hits.map((p) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = p; b.onclick = () => add(p); return b; }));
list.hidden = !hits.length;
});
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); add(input.value); } });
draw();
}
</script>

View File

@ -342,7 +342,11 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک
clearTimeout(selTimer); clearTimeout(selTimer);
selTimer = setTimeout(() => { selTimer = setTimeout(() => {
const sel = getSelection(); const sel = getSelection();
const inText = sel && !sel.isCollapsed && main.contains(sel.anchorNode) && !sel.anchorNode.parentElement?.closest('form, input, textarea, [contenteditable]'); // not while editing // only the reading text (poems and prose, intros, e-book text, search snippets, library couplets), never the
// site's menus, buttons, tabs or forms
const at = sel && !sel.isCollapsed && sel.anchorNode?.parentElement;
const inText = at && main.contains(at) && at.closest('article, .intro, .ebook-text, .snippet, .lib-couplet')
&& !at.closest('form, input, textarea, [contenteditable], button, nav, .admin-nav');
selText = inText ? sel.toString().replace(/\u0614/g, '').trim() : ''; selText = inText ? sel.toString().replace(/\u0614/g, '').trim() : '';
if (!selText) return hideMenu(); if (!selText) return hideMenu();
const w = oneWord(selText); const w = oneWord(selText);

22
web/src/lib/cover.ts Normal file
View File

@ -0,0 +1,22 @@
// A book's cover, made in the browser: a PDF's first page (pdf.js) or an EPUB's own cover image (epub.js).
// The server has no PDF tools; the moderator's browser makes the cover once, when the book is added.
export async function makeCover(data: ArrayBuffer, kind: 'pdf' | 'epub'): Promise<Blob | null> {
if (kind === 'pdf') {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
const page = await (await pdfjs.getDocument({ data: new Uint8Array(data) }).promise).getPage(1);
const viewport = page.getViewport({ scale: 1 }), scale = 480 / viewport.width; // 480px wide thumbnails
const v = page.getViewport({ scale }), canvas = document.createElement('canvas');
canvas.width = Math.round(v.width); canvas.height = Math.round(v.height);
await page.render({ canvasContext: canvas.getContext('2d')!, viewport: v, canvas } as any).promise;
return new Promise((done) => canvas.toBlob(done, 'image/jpeg', 0.85));
}
const { default: ePub } = await import('epubjs');
const url = await ePub(data).coverUrl();
return url ? (await fetch(url)).blob() : null;
}
export async function sendCover(id: number | string, cover: Blob) {
const res = await fetch(`/ebooks/cover/${id}`, { method: 'POST', body: cover, headers: { 'content-type': 'application/octet-stream' } });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'سرورق محفوظ نہیں ہوا');
}

72
web/src/lib/pdfreader.ts Normal file
View File

@ -0,0 +1,72 @@
// The PDF reader (pdf.js), right to left as an Urdu book: the next page is to the left (button, ← key, a click on
// the left half), and on wide screens pages face each other with the cover alone, then 2|3 with page 2 on the
// right. A book marked left-to-right (dir="ltr" on the reader, e.g. English) turns the other way. Pages are fetched by byte range as they are shown, so a large scan starts quickly. Signed-in readers
// bookmark pages (the right-hand page of a spread) in their library; their bookmarks jump to the page.
export async function pdfReader(root: HTMLElement) {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
const rtl = root.dir !== 'ltr';
const pagesBox = root.querySelector<HTMLElement>('.pdf-pages')!, input = root.querySelector<HTMLInputElement>('.pdf-page')!;
const doc = await pdfjs.getDocument({ url: root.dataset.src!, disableAutoFetch: true, disableStream: true, rangeChunkSize: 262144 }).promise;
root.querySelector('.pdf-total')!.textContent = String(doc.numPages).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[+d]);
input.max = String(doc.numPages);
const wide = () => pagesBox.clientWidth > 900;
// the pages shown together, starting with page p
const spread = (p: number) => (!wide() || p === 1 ? [p] : [p % 2 ? p - 1 : p, (p % 2 ? p - 1 : p) + 1].filter((n) => n >= 2 && n <= doc.numPages));
let page = 1, job = 0;
async function show(p: number) {
page = Math.min(Math.max(1, p), doc.numPages);
const nums = spread(page), mine = ++job;
input.value = String(nums[0]);
const canvases = await Promise.all(nums.map(async (n) => {
const pg = await doc.getPage(n), base = pg.getViewport({ scale: 1 });
const tall = document.fullscreenElement === root ? window.innerHeight - 70 : window.innerHeight * 0.82;
const fit = Math.min((pagesBox.clientWidth - 8 * nums.length) / nums.length / base.width, tall / base.height);
const v = pg.getViewport({ scale: fit * devicePixelRatio }), c = document.createElement('canvas');
c.width = Math.floor(v.width); c.height = Math.floor(v.height);
c.style.width = `${Math.floor(v.width / devicePixelRatio)}px`; c.style.height = `${Math.floor(v.height / devicePixelRatio)}px`;
await pg.render({ canvasContext: c.getContext('2d')!, viewport: v, canvas: c } as any).promise;
return c;
}));
if (mine === job) { pagesBox.replaceChildren(...canvases); marks(); } // the container is RTL: the first page is on the right
}
const step = (d: number) => { const nums = spread(page); show(d > 0 ? nums[nums.length - 1] + 1 : spread(Math.max(1, nums[0] - 1))[0]); };
root.querySelector<HTMLButtonElement>('.pdf-next')!.onclick = () => step(1);
root.querySelector<HTMLButtonElement>('.pdf-prev')!.onclick = () => step(-1);
input.onchange = () => show(Number(input.value) || 1);
// page bookmarks
const markBtn = root.querySelector<HTMLButtonElement>('.pdf-mark'), list = root.querySelector<HTMLElement>('.pdf-marks');
let saved: number[] = JSON.parse(root.dataset.marks ?? '[]');
const digits = (n: number) => String(n).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[+d]);
function marks() {
if (!markBtn || !list) return;
const on = saved.includes(spread(page)[0]);
markBtn.setAttribute('aria-pressed', String(on)); markBtn.textContent = on ? 'صفحہ محفوظ شدہ' : 'صفحہ محفوظ کریں';
list.replaceChildren(...saved.map((n) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tag-chip'; b.textContent = `صفحہ ${digits(n)}`; b.onclick = () => show(n); return b; }));
list.hidden = !saved.length;
}
markBtn?.addEventListener('click', async () => {
if (!document.body.dataset.user) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return; }
const p = spread(page)[0];
const r = await fetch('/api/library', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'page', ebookId: Number(root.dataset.ebook), page: p }) });
if (!r.ok) return;
saved = (await r.json()).saved ? [...saved, p].sort((a, b) => a - b) : saved.filter((n) => n !== p);
marks();
});
// the whole screen for the reader (the page is redrawn to the new size)
const full = root.querySelector<HTMLButtonElement>('.pdf-full')!;
const toggle = () => (document.fullscreenElement ? document.exitFullscreen() : root.requestFullscreen()).catch(() => {});
full.onclick = toggle;
document.addEventListener('fullscreenchange', () => { full.textContent = document.fullscreenElement === root ? 'واپس' : 'پوری اسکرین'; show(page); });
// a click on the half the book turns towards goes forward
pagesBox.onclick = (e) => { const left = e.clientX < pagesBox.getBoundingClientRect().left + pagesBox.clientWidth / 2; step(left === rtl ? 1 : -1); };
document.addEventListener('keydown', (e) => {
if ((e.target as HTMLElement).closest('input, textarea')) return;
if (e.key === 'ArrowLeft') step(rtl ? 1 : -1);
if (e.key === 'ArrowRight') step(rtl ? -1 : 1);
if (e.key === 'f' || e.key === 'F') toggle();
});
let resize = 0;
window.addEventListener('resize', () => { clearTimeout(resize); resize = window.setTimeout(() => show(page), 200); });
await show(Number(new URLSearchParams(location.search).get('page')) || 1); // ?page= from a bookmark
}

View File

@ -77,9 +77,8 @@ const arrange = catCan.arrange, tagCan = page.type === 'poem' ? modCan.tags : ca
// tags (#52): the page's own, and each couplet's (tags count couplets from 1, verses from 0) // tags (#52): the page's own, and each couplet's (tags count couplets from 1, verses from 0)
const pageTags = (page.tags ?? []).filter((t: any) => !t.couplet); const pageTags = (page.tags ?? []).filter((t: any) => !t.couplet);
const coupletTags = (c: number) => (page.tags ?? []).filter((t: any) => t.couplet === c + 1); const coupletTags = (c: number) => (page.tags ?? []).filter((t: any) => t.couplet === c + 1);
// a poet's e-books (published), and the upload link for moderators with the e-books permission for this poet // every poet has an ای بکس section beside their books and genres (/p17/ebooks)
const ebooks = page.type === 'poet' ? (await api<any[]>(`/api/ebooks?poet=${poetId}`)) ?? [] : []; const ebookCount = page.type === 'poet' ? ((await api<any[]>(`/api/ebooks?poet=${poetId}`)) ?? []).length : 0;
const EBOOK_KIND: Record<string, string> = { pdf: 'PDF', epub: 'EPUB', text: 'متن', archive: 'آرکائیو' };
const tagTarget = page.type === 'poem' ? `work/${poemId}` : `category/${page.category?.id}`; const tagTarget = page.type === 'poem' ? `work/${poemId}` : `category/${page.category?.id}`;
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;
@ -112,11 +111,12 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
</> </>
)} )}
{page.type !== 'poem' && page.children.length > 0 && ( {page.type !== 'poem' && (page.children.length > 0 || page.type === 'poet') && (
<div class="cards"> <div class="cards">
{page.children.map((c: any) => ( {page.children.map((c: any) => (
<a class={`card${marks.categories.includes(c.id) ? ' mark-bm' : ''}`} href={c.url}>{c.title}{c.poems > 0 && <small>{ud(c.poems)}</small>}</a> <a class={`card${marks.categories.includes(c.id) ? ' mark-bm' : ''}`} href={c.url}>{c.title}{c.poems > 0 && <small>{ud(c.poems)}</small>}</a>
))} ))}
{page.type === 'poet' && <a class="card" href={`${page.poet.url}/ebooks`}>ای بکس{ebookCount > 0 && <small>{ud(ebookCount)}</small>}</a>}
</div> </div>
)} )}
{page.type !== 'poem' && groups.map((g) => ( {page.type !== 'poem' && groups.map((g) => (
@ -126,16 +126,6 @@ const title = page.type === 'poem' ? page.poem.title : page.type === 'poet' ? pa
</> </>
))} ))}
{page.type === 'poet' && (ebooks.length > 0 || catCan.ebooks) && (
<section class="search-group" id="ebooks">
<h2>ای بکس {ebooks.length > 0 && <small class="muted">{ud(ebooks.length)}</small>}</h2>
<ul class="book-results">
{ebooks.map((b) => <li><a href={`/ebook/${b.id}`}>{b.title}</a> <small class="muted">{EBOOK_KIND[b.kind]}</small></li>)}
</ul>
{catCan.ebooks && <p class="center"><a class="mod-edit-btn" href={`/mod/ebook/new?poet=${poetId}`}>ای بک شامل کریں</a></p>}
</section>
)}
{page.type === 'poem' && ( {page.type === 'poem' && (
<> <>
<h1><Seg s={show(page.poem.title)} /></h1> <h1><Seg s={show(page.poem.title)} /></h1>

View File

@ -0,0 +1,42 @@
---
// A poet's e-books (/p17/ebooks), every poet has one: tabs by kind, covers in a grid
import Base from '../../layouts/Base.astro';
import { api } from '../../lib/api';
import { asUser, COOKIE } from '../../lib/auth';
import { ud } from '../../lib/urdu';
const page = /^p\d+$/.test(Astro.params.poet ?? '') ? await api(`/api/page?url=${encodeURIComponent('/' + Astro.params.poet)}`) : null;
if (!page || page.type !== 'poet') return new Response(null, { status: 404, statusText: 'Not found' });
const books = (await api<any[]>(`/api/ebooks?poet=${page.poet.id}`)) ?? [];
// tabs: the three kinds always, archive.org only when there are such books
const TABS = [['pdf', 'پی ڈی ایف', 'PDF'], ['epub', 'ای پب', 'ePUB'], ['text', 'یونیکوڈ متن', 'Unicode'], ['archive', 'آرکائیو', 'archive.org']] as const;
const tabs = TABS.filter(([k]) => k !== 'archive' || books.some((b) => b.kind === 'archive'));
const asked = Astro.url.searchParams.get('kind');
const kind = tabs.some(([k]) => k === asked) ? asked : (tabs.find(([k]) => books.some((b) => b.kind === k))?.[0] ?? 'pdf');
const shown = books.filter((b) => b.kind === kind);
const cover = (b: any) => (b.has_cover ? `/ebooks/cover/${b.id}` : b.kind === 'archive' ? `https://archive.org/services/img/${b.archive_id}` : null);
const token = Astro.locals.user ? Astro.cookies.get(COOKIE)?.value : undefined;
const mayAdd = token && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user!.role)
&& (await asUser(token, `/api/mod/can?category=${page.category.id}`)).data.ebooks;
---
<Base title={`ای بکس · ${page.poet.nickname}`}>
<nav class="crumbs"><a href={page.poet.url}>{page.poet.nickname}</a></nav>
<h1>ای بکس</h1>
{mayAdd && <p class="center"><a class="mod-edit-btn" href={`/mod/ebook/new?poet=${page.poet.id}`}>ای بک شامل کریں</a></p>}
<nav class="admin-nav ebook-tabs" aria-label="کتابوں کی قسم">
{tabs.map(([k, ur, en]) => {
const n = books.filter((b) => b.kind === k).length;
return <a href={`?kind=${k}`} aria-current={k === kind ? 'page' : undefined}>{ur} <span lang="en" dir="ltr">({en})</span>{n > 0 && <small> {ud(n)}</small>}</a>;
})}
</nav>
{shown.length === 0 ? <p class="muted center">اس قسم کی کوئی کتاب ابھی نہیں۔</p> : (
<div class="ebook-grid">
{shown.map((b) => (
<a class="ebook-card" href={`/ebook/${b.id}`}>
{cover(b) ? <img src={cover(b)} alt="" loading="lazy" /> : <span class="ebook-plain"><span>{b.title}</span><small>{page.poet.nickname}</small></span>}
<span class="ebook-title">{b.title}{b.co && <small class="muted"> (شریک مصنف)</small>}{b.language !== 'اردو' && <small class="muted"> · {b.language}</small>}</span>
</a>
))}
</div>
)}
</Base>

View File

@ -4,16 +4,23 @@ import Base from '../../layouts/Base.astro';
import { asUser, COOKIE } from '../../lib/auth'; import { asUser, COOKIE } from '../../lib/auth';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { ud } from '../../lib/urdu'; import { ud } from '../../lib/urdu';
import Tags from '../../components/Tags.astro';
const id = Number(Astro.params.id) || 0, token = Astro.cookies.get(COOKIE)?.value; const id = Number(Astro.params.id) || 0, token = Astro.cookies.get(COOKIE)?.value;
const b = token ? (await asUser(token, `/api/ebook/${id}`)).data : await api(`/api/ebook/${id}`); const b = token ? (await asUser(token, `/api/ebook/${id}`)).data : await api(`/api/ebook/${id}`);
if (!b?.id) return new Response(null, { status: 404, statusText: 'Not found' }); if (!b?.id) return new Response(null, { status: 404, statusText: 'Not found' });
const file = `/ebooks/file/${b.id}`; const file = `/ebooks/file/${b.id}`;
const KIND: Record<string, string> = { pdf: 'PDF', epub: 'EPUB', text: 'متن', archive: 'آرکائیو' }; const KIND: Record<string, string> = { pdf: 'PDF', epub: 'EPUB', text: 'متن', archive: 'آرکائیو' };
// moderators with the e-books permission for this poet can make or change the cover
const poetPage = token ? await api(`/api/page?url=${encodeURIComponent(b.poet_url)}`) : null;
const mayCover = token && poetPage && b.kind !== 'archive' && ['mod-l2', 'mod-l1', 'admin'].includes(Astro.locals.user?.role ?? '')
&& (await asUser(token, `/api/mod/can?category=${poetPage.category.id}`)).data.ebooks;
// the reader's bookmarked pages of this book
const marks = token && b.published && b.kind === 'pdf' ? (await asUser(token, `/api/library/state?ebook=${b.id}`)).data.pages ?? [] : [];
const mb = !b.size ? '' : b.size < 1048576 ? `${ud(Math.max(1, Math.round(b.size / 1024)))} KB` : `${ud((b.size / 1048576).toFixed(1))} MB`; const mb = !b.size ? '' : b.size < 1048576 ? `${ud(Math.max(1, Math.round(b.size / 1024)))} KB` : `${ud((b.size / 1048576).toFixed(1))} MB`;
--- ---
<Base title={`${b.title} · ${b.poet}`}> <Base title={`${b.title} · ${b.poet}`}>
<nav class="crumbs"><a href={b.poet_url}>{b.poet}</a> » <a href={`${b.poet_url}#ebooks`}>ای بکس</a></nav> <nav class="crumbs"><a href={b.poet_url}>{b.poet}</a> » <a href={`${b.poet_url}/ebooks?kind=${b.kind}`}>ای بکس</a></nav>
<h1>{b.title}</h1> <h1>{b.title}</h1>
{!b.published && <p class="form-error center">یہ کتاب ابھی جائزے میں ہے؛ صرف موڈریٹر دیکھ سکتے ہیں۔</p>} {!b.published && <p class="form-error center">یہ کتاب ابھی جائزے میں ہے؛ صرف موڈریٹر دیکھ سکتے ہیں۔</p>}
<p class="center muted"> <p class="center muted">
@ -21,9 +28,36 @@ const mb = !b.size ? '' : b.size < 1048576 ? `${ud(Math.max(1, Math.round(b.size
{b.file && <> · <a href={file} download>ڈاؤن لوڈ</a></>} {b.file && <> · <a href={file} download>ڈاؤن لوڈ</a></>}
{b.kind === 'archive' && <> · <a href={`https://archive.org/details/${b.archive_id}`}>archive.org پر</a></>} {b.kind === 'archive' && <> · <a href={`https://archive.org/details/${b.archive_id}`}>archive.org پر</a></>}
</p> </p>
<p class="center eb-people">
{b.writer && <>مصنف: {b.writer}</>}
{b.coauthors?.length > 0 && <> · شریک مصنفین: {b.coauthors.map((c: any, i: number) => <>{i > 0 && '، '}{c.url ? <a href={c.url}>{c.name}</a> : c.name}</>)}</>}
· زبان: {b.language} · {b.direction === 'ltr' ? 'بائیں سے دائیں (LTR)' : 'دائیں سے بائیں (RTL)'}
</p>
<Tags tags={b.tags ?? []} />
{b.note && <p class="intro">{b.note}</p>} {b.note && <p class="intro">{b.note}</p>}
{mayCover && (
<p class="center cover-tools" data-id={b.id} data-kind={b.kind} data-src={file}>
{b.cover && <img class="cover-small" src={`/ebooks/cover/${b.id}`} alt="سرورق" />}
{(b.kind === 'pdf' || b.kind === 'epub') && <button type="button" class="mod-edit-btn" data-make>{b.kind === 'pdf' ? 'پہلے صفحے سے سرورق بنائیں' : 'کتاب کا سرورق لیں'}</button>}
<label class="mod-edit-btn">اپنی تصویر سرورق بنائیں<input type="file" accept="image/jpeg,image/png,image/webp" hidden data-own /></label>
<span class="muted" data-cover-status></span>
</p>
)}
{b.kind === 'pdf' && <iframe class="ebook-frame" src={file} title={b.title}></iframe>} {b.kind === 'pdf' && (
<div class="pdf-reader" data-src={file} data-ebook={b.id} data-marks={JSON.stringify(marks)} dir={b.direction === 'ltr' ? 'ltr' : 'rtl'}>
<nav class="pdf-bar">
<button type="button" class="mod-edit-btn pdf-prev">‹ پچھلا</button>
<span>صفحہ <input class="pdf-page" type="number" min="1" value="1" aria-label="صفحہ نمبر" /> از <span class="pdf-total">…</span></span>
<button type="button" class="mod-edit-btn pdf-next">اگلا ›</button>
<button type="button" class="mod-edit-btn pdf-full" title="پوری اسکرین (F)">پوری اسکرین</button>
{b.published && <button type="button" class="save pdf-mark" aria-pressed="false">صفحہ محفوظ کریں</button>}
</nav>
<p class="tag-chips pdf-marks" hidden></p>
<div class="pdf-pages" title="بائیں طرف کلک: اگلا صفحہ"><p class="muted">کتاب کھل رہی ہے…</p></div>
<p class="muted center small-line" dir="rtl">{b.direction === 'ltr' ? '→ اگلا صفحہ · ← پچھلا صفحہ' : '← اگلا صفحہ · → پچھلا صفحہ'} · <a href={file} target="_blank" rel="noopener">نئی ٹیب میں کھولیں</a></p>
</div>
)}
{b.kind === 'archive' && <iframe class="ebook-frame" src={`https://archive.org/embed/${b.archive_id}`} title={b.title} allowfullscreen></iframe>} {b.kind === 'archive' && <iframe class="ebook-frame" src={`https://archive.org/embed/${b.archive_id}`} title={b.title} allowfullscreen></iframe>}
{b.kind === 'text' && <article class="ebook-text" dir="auto">{b.text}</article>} {b.kind === 'text' && <article class="ebook-text" dir="auto">{b.text}</article>}
{b.kind === 'epub' && ( {b.kind === 'epub' && (
@ -33,6 +67,25 @@ const mb = !b.size ? '' : b.size < 1048576 ? `${ud(Math.max(1, Math.round(b.size
</> </>
)} )}
<script> <script>
// PDF: the right-to-left reader; covers made in the browser (lib/cover.ts)
import { pdfReader } from '../../lib/pdfreader';
import { makeCover, sendCover } from '../../lib/cover';
const reader = document.querySelector<HTMLElement>('.pdf-reader');
if (reader) pdfReader(reader).catch((e) => { reader.querySelector('.pdf-pages')!.textContent = 'کتاب نہیں کھل سکی: ' + e.message; });
const tools = document.querySelector<HTMLElement>('.cover-tools');
if (tools) {
const status = tools.querySelector('[data-cover-status]')!;
const save = async (blob: Blob | null) => {
if (!blob) { status.textContent = 'اس کتاب میں سرورق نہیں ملا'; return; }
status.textContent = 'محفوظ ہو رہا ہے…';
try { await sendCover(tools.dataset.id!, blob); location.reload(); } catch (e: any) { status.textContent = e.message; }
};
tools.querySelector<HTMLButtonElement>('[data-make]')?.addEventListener('click', async () => {
status.textContent = 'سرورق بن رہا ہے…';
save(await makeCover(await (await fetch(tools.dataset.src!)).arrayBuffer(), tools.dataset.kind as 'pdf' | 'epub').catch(() => null));
});
tools.querySelector<HTMLInputElement>('[data-own]')!.addEventListener('change', (e) => { const f = (e.target as HTMLInputElement).files?.[0]; if (f) save(f); });
}
// EPUB: epub.js pages through the book (right-to-left books turn the other way) // EPUB: epub.js pages through the book (right-to-left books turn the other way)
const view = document.getElementById('epub-view'); const view = document.getElementById('epub-view');
if (view) { if (view) {

View File

@ -0,0 +1,23 @@
// An e-book's cover: shown from the private API, and saved by moderators (the image made in their browser)
import type { APIRoute } from 'astro';
import { COOKIE } from '../../../lib/auth';
const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
const pass = (res: Response) => {
const headers = new Headers();
for (const k of ['content-type', 'cache-control']) { const v = res.headers.get(k); if (v) headers.set(k, v); }
return new Response(res.body, { status: res.status, headers });
};
export const GET: APIRoute = async ({ params, cookies }) => {
const token = cookies.get(COOKIE)?.value;
return pass(await fetch(`${API}/api/ebook/${Number(params.id) || 0}/cover`, { headers: token ? { authorization: `Bearer ${token}` } : {} }));
};
export const POST: APIRoute = async ({ params, cookies, request }) => {
const token = cookies.get(COOKIE)?.value;
if (!token) return new Response(JSON.stringify({ error: 'لاگ ان کریں' }), { status: 401 });
return pass(await fetch(`${API}/api/mod/ebooks/${Number(params.id) || 0}/cover`, {
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' }, body: await request.arrayBuffer(),
}));
};

View File

@ -5,10 +5,13 @@ import { COOKIE } from '../../../lib/auth';
const API = process.env.API_URL ?? 'http://127.0.0.1:4100'; const API = process.env.API_URL ?? 'http://127.0.0.1:4100';
export const GET: APIRoute = async ({ params, cookies }) => { export const GET: APIRoute = async ({ params, cookies, request }) => {
const token = cookies.get(COOKIE)?.value; const token = cookies.get(COOKIE)?.value;
const res = await fetch(`${API}/api/ebook/${Number(params.id) || 0}/file`, { headers: token ? { authorization: `Bearer ${token}` } : {} }); const range = request.headers.get('range'); // the PDF reader asks for byte ranges
const res = await fetch(`${API}/api/ebook/${Number(params.id) || 0}/file`, {
headers: { ...(token && { authorization: `Bearer ${token}` }), ...(range && { range }) },
});
const headers = new Headers(); const headers = new Headers();
for (const k of ['content-type', 'content-length', 'content-disposition', 'cache-control']) { const v = res.headers.get(k); if (v) headers.set(k, v); } for (const k of ['content-type', 'content-length', 'content-disposition', 'cache-control', 'accept-ranges', 'content-range']) { const v = res.headers.get(k); if (v) headers.set(k, v); }
return new Response(res.ok ? res.body : null, { status: res.status, headers }); return new Response(res.ok ? res.body : null, { status: res.status, headers });
}; };

View File

@ -17,7 +17,7 @@ if (Astro.request.method === 'POST') {
return Astro.redirect(`/library#i${id}`, 303); return Astro.redirect(`/library#i${id}`, 303);
} }
const lib = (await asUser(token, '/api/library')).data; const lib = (await asUser(token, '/api/library')).data;
const total = lib.poets.length + lib.categories.length + lib.poems.length + lib.couplets.length + lib.phrases.length; const total = lib.poets.length + lib.categories.length + lib.poems.length + lib.couplets.length + lib.phrases.length + (lib.pages?.length ?? 0);
// a bookmarked phrase shown inside its couplet: [before, phrase, after] per line // a bookmarked phrase shown inside its couplet: [before, phrase, after] per line
const marked = (lines: string[], phrase: string) => phraseParts(lines, phrase).map((part, i) => { const marked = (lines: string[], phrase: string) => phraseParts(lines, phrase).map((part, i) => {
if (!part) return [lines[i], '', '']; if (!part) return [lines[i], '', ''];
@ -123,4 +123,23 @@ const at = (item: any) => (item.poem ? `${item.poem.url}${item.poem.couplet != n
</section> </section>
)} )}
{lib.pages?.length > 0 && (
<section class="lib-section">
<h2>کتابوں کے محفوظ صفحات</h2>
<ul class="lib-list">
{lib.pages.map((p: any) => (
<li id={`i${p.id}`}>
<nav class="crumbs"><span><a href={p.ebook.poet_url}>{p.ebook.poet}</a></span><span><a href={`${p.ebook.poet_url}/ebooks`}>ای بکس</a></span></nav>
<a class="lib-title" href={`/ebook/${p.ebook.id}?page=${p.page}`}>{p.ebook.title} · صفحہ {ud(p.page)}</a>
<form method="post" class="lib-note">
<input type="hidden" name="id" value={p.id} />
<input name="note" value={p.note ?? ''} placeholder="نوٹ" maxlength="1000" dir="auto" />
<button name="act" value="note">محفوظ</button>
</form>
<form method="post" class="lib-remove"><input type="hidden" name="id" value={p.id} /><button name="act" value="delete" aria-label="ہٹائیں">✕</button></form>
</li>
))}
</ul>
</section>
)}
</Base> </Base>

View File

@ -5,6 +5,7 @@ import Base from '../../../layouts/Base.astro';
import ModNav from '../../../components/ModNav.astro'; import ModNav from '../../../components/ModNav.astro';
import { asUser, COOKIE } from '../../../lib/auth'; import { asUser, COOKIE } from '../../../lib/auth';
import { api } from '../../../lib/api'; import { api } from '../../../lib/api';
import EbookFields from '../../../components/EbookFields.astro';
const me = Astro.locals.user; const me = Astro.locals.user;
if (!me) return Astro.redirect(`/signin?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`); if (!me) return Astro.redirect(`/signin?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`);
@ -15,7 +16,8 @@ const may = page && ['mod-l2', 'mod-l1', 'admin'].includes(me.role) && (await as
let error = ''; let error = '';
if (may && Astro.request.method === 'POST') { if (may && Astro.request.method === 'POST') {
const f = await Astro.request.formData(); const f = await Astro.request.formData();
const fields = { poet: poetId, title: f.get('title'), source: f.get('source'), licence: f.get('licence'), note: f.get('note') }; const fields = Object.fromEntries(['title', 'writer', 'coauthors', 'language', 'direction', 'tags', 'source', 'licence', 'note'].map((k) => [k, f.get(k) ?? '']));
Object.assign(fields, { poet: poetId });
const upload = f.get('file') as File | null, archive = String(f.get('archive') ?? '').trim(); const upload = f.get('file') as File | null, archive = String(f.get('archive') ?? '').trim();
let body: object | null = null; let body: object | null = null;
if (archive) body = { ...fields, archive }; if (archive) body = { ...fields, archive };
@ -28,6 +30,11 @@ if (may && Astro.request.method === 'POST') {
} else error = 'فائل چنیں یا archive.org کا ربط دیں'; } else error = 'فائل چنیں یا archive.org کا ربط دیں';
if (body) { if (body) {
const r = await asUser(token, '/api/mod/ebooks', body); const r = await asUser(token, '/api/mod/ebooks', body);
// the cover: made in the browser from the PDF's first page or the EPUB's cover, or the moderator's own image
const cover = f.get('cover') as File | null;
if (r.ok && cover && cover.size) await fetch(`${API}/api/mod/ebooks/${r.data.id}/cover`, {
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/octet-stream' }, body: await cover.arrayBuffer(),
});
if (r.ok) return Astro.redirect(`/mod/rev/${r.data.revision}`); if (r.ok) return Astro.redirect(`/mod/rev/${r.data.revision}`);
error = r.data.error ?? 'کچھ غلط ہو گیا'; error = r.data.error ?? 'کچھ غلط ہو گیا';
} }
@ -40,14 +47,33 @@ if (may && Astro.request.method === 'POST') {
<form method="post" enctype="multipart/form-data" class="account-form ebook-form"> <form method="post" enctype="multipart/form-data" class="account-form ebook-form">
<p class="center">شاعر: <a href={page.poet.url}>{page.poet.nickname}</a></p> <p class="center">شاعر: <a href={page.poet.url}>{page.poet.nickname}</a></p>
{error && <p class="form-error" role="alert">{error}</p>} {error && <p class="form-error" role="alert">{error}</p>}
<label>عنوان<input name="title" required maxlength="300" data-urdu /></label>
<label>فائل (PDF، EPUB، TXT یا DOCX؛ زیادہ سے زیادہ ۱۰۰ میگا بائٹ)<input type="file" name="file" accept=".pdf,.epub,.txt,.docx,application/pdf,application/epub+zip,text/plain" /></label> <label>فائل (PDF، EPUB، TXT یا DOCX؛ زیادہ سے زیادہ ۱۰۰ میگا بائٹ)<input type="file" name="file" accept=".pdf,.epub,.txt,.docx,application/pdf,application/epub+zip,text/plain" /></label>
<label>سرورق (PDF کے پہلے صفحے یا EPUB کے سرورق سے خود بنتا ہے؛ چاہیں تو اپنی تصویر چنیں)
<input type="file" name="cover" accept="image/jpeg,image/png,image/webp" /></label>
<img class="cover-small" id="cover-preview" alt="سرورق" hidden />
<label>یا archive.org کا ربط<input name="archive" dir="ltr" placeholder="https://archive.org/details/…" /></label> <label>یا archive.org کا ربط<input name="archive" dir="ltr" placeholder="https://archive.org/details/…" /></label>
<label>ماخذ<input name="source" maxlength="300" data-urdu placeholder="مثلاً: ناشر، سال، کتب خانہ" /></label> <EbookFields v={{ writer: page.poet.nickname }} />
<label>حقوق / اجازت<input name="licence" maxlength="300" data-urdu placeholder="مثلاً: عوامی ملکیت، مصنف کی اجازت سے" /></label>
<label>تفصیل<textarea name="note" rows="3" maxlength="2000" data-urdu></textarea></label>
<p class="muted note">کتاب جائزے کے بعد شاعر کے صفحے پر «ای بکس» میں نظر آئے گی۔ جائزہ لینے والے حقوق کی تصدیق کرتے ہیں۔</p> <p class="muted note">کتاب جائزے کے بعد شاعر کے صفحے پر «ای بکس» میں نظر آئے گی۔ جائزہ لینے والے حقوق کی تصدیق کرتے ہیں۔</p>
<button>اپ لوڈ کریں</button> <button>اپ لوڈ کریں</button>
</form> </form>
)} )}
<script>
import { makeCover } from '../../../lib/cover';
const form = document.querySelector<HTMLFormElement>('.ebook-form');
if (form) {
const book = form.querySelector<HTMLInputElement>('[name=file]')!, cover = form.querySelector<HTMLInputElement>('[name=cover]')!;
const preview = document.getElementById('cover-preview') as HTMLImageElement;
let own = false; // a picture the moderator chose themselves is never replaced
const showCover = (f: Blob) => { preview.src = URL.createObjectURL(f); preview.hidden = false; };
cover.addEventListener('change', () => { own = !!cover.files?.length; if (own) showCover(cover.files![0]); });
book.addEventListener('change', async () => {
const f = book.files?.[0], kind = f?.name.toLowerCase().endsWith('.pdf') ? 'pdf' : f?.name.toLowerCase().endsWith('.epub') ? 'epub' : null;
if (!f || !kind || own) return;
const blob = await makeCover(await f.arrayBuffer(), kind).catch(() => null);
if (!blob || own) return;
const dt = new DataTransfer(); dt.items.add(new File([blob], 'cover.jpg', { type: blob.type || 'image/jpeg' }));
cover.files = dt.files; showCover(blob);
});
}
</script>
</Base> </Base>

View File

@ -7,6 +7,7 @@ import { STATUS, EVENT, when } from '../../../lib/mod';
import { ud } from '../../../lib/urdu'; import { ud } from '../../../lib/urdu';
import DivanPreview from '../../../components/DivanPreview.astro'; import DivanPreview from '../../../components/DivanPreview.astro';
import Tags from '../../../components/Tags.astro'; import Tags from '../../../components/Tags.astro';
import EbookFields from '../../../components/EbookFields.astro';
import { api } from '../../../lib/api'; import { api } from '../../../lib/api';
const me = Astro.locals.user; const me = Astro.locals.user;
@ -38,7 +39,8 @@ const couplets = tagging && tagKind === 'work' ? (await asUser(token, `/api/mod/
const knownTags = tagging ? ((await api<{ name: string }[]>('/api/tags')) ?? []).map((t) => t.name) : []; const knownTags = tagging ? ((await api<{ name: string }[]>('/api/tags')) ?? []).map((t) => t.name) : [];
// an e-book's details: title, source, copyright, note // an e-book's details: title, source, copyright, note
const ebook = rev.entity === 'ebook'; const ebook = rev.entity === 'ebook';
const EB: [string, string][] = [['title', 'عنوان'], ['source', 'ماخذ'], ['licence', 'اجازت'], ['note', 'تفصیل']]; 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()])) : {}; 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()])) : {};
const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]); const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]);
--- ---
@ -57,10 +59,7 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
{may.save && ebook ? ( {may.save && ebook ? (
<form method="post" class="mod-edit account-form"> <form method="post" class="mod-edit account-form">
<label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu /></label> <label>ترمیم کا خلاصہ<input name="summary" value={rev.summary ?? ''} maxlength="500" data-urdu /></label>
<label>عنوان<input data-eb="title" value={ebMeta.title} maxlength="300" required data-urdu /></label> <EbookFields v={ebMeta} />
<label>ماخذ<input data-eb="source" value={ebMeta.source} maxlength="300" data-urdu /></label>
<label>حقوق / اجازت<input data-eb="licence" value={ebMeta.licence} maxlength="300" data-urdu /></label>
<label>تفصیل<textarea data-eb="note" rows="3" maxlength="2000" data-urdu>{ebMeta.note}</textarea></label>
<textarea name="content" id="eb-content" hidden>{rev.content}</textarea> <textarea name="content" id="eb-content" hidden>{rev.content}</textarea>
<div class="filter-actions"> <div class="filter-actions">
<button name="act" value="save">محفوظ کریں</button> <button name="act" value="save">محفوظ کریں</button>
@ -203,9 +202,13 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج
// e-book details -> the hidden text, one "label: value" per line // e-book details -> the hidden text, one "label: value" per line
const ebText = document.getElementById('eb-content') as HTMLTextAreaElement | null; const ebText = document.getElementById('eb-content') as HTMLTextAreaElement | null;
if (ebText) { if (ebText) {
const LABEL: Record<string, string> = { title: 'عنوان', source: 'ماخذ', licence: 'اجازت', note: 'تفصیل' }; const LABEL: Record<string, string> = { title: 'عنوان', writer: 'مصنف', coauthors: 'شریک مصنفین', language: 'زبان', direction: 'سمت',
const sync = () => { ebText.value = [...document.querySelectorAll<HTMLInputElement>('[data-eb]')].map((i) => `${LABEL[i.dataset.eb!]}: ${i.value.replace(/\s+/g, ' ').trim()}`).join('\n'); }; tags: 'ٹیگ', source: 'ماخذ', licence: 'اجازت', note: 'تفصیل' };
ebText.form!.addEventListener('input', sync); ebText.form!.addEventListener('submit', sync); const sync = () => {
const val = (k: string) => [...ebText.form!.querySelectorAll<HTMLInputElement>(`[data-eb="${k}"]`)].find((i) => i.type !== 'radio' || i.checked)?.value ?? '';
ebText.value = Object.keys(LABEL).map((k) => `${LABEL[k]}: ${val(k).replace(/\s+/g, ' ').trim()}`).join('\n');
};
ebText.form!.addEventListener('input', sync); ebText.form!.addEventListener('change', sync); ebText.form!.addEventListener('submit', sync);
} }
// tagging: rows -> the hidden text ("شعر 3 › موضوع: عشق"), on every change and before saving // tagging: rows -> the hidden text ("شعر 3 › موضوع: عشق"), on every change and before saving
const rowsBox = document.getElementById('tag-rows'); const rowsBox = document.getElementById('tag-rows');

View File

@ -7,8 +7,8 @@ import { ud } from '../../../lib/urdu';
const { type = '', name = '' } = Astro.params; const { type = '', name = '' } = Astro.params;
const res = await api(`/api/tag?type=${encodeURIComponent(type)}&name=${encodeURIComponent(name)}`); const res = await api(`/api/tag?type=${encodeURIComponent(type)}&name=${encodeURIComponent(name)}`);
if (!res) return new Response(null, { status: 404, statusText: 'Not found' }); if (!res) return new Response(null, { status: 404, statusText: 'Not found' });
const { tag, categories, works } = res; const { tag, categories, works, ebooks = [] } = res;
const count = categories.length + works.length; const count = categories.length + works.length + ebooks.length;
--- ---
<Base title={`${tag.type}: ${tag.name}`}> <Base title={`${tag.type}: ${tag.name}`}>
<h1><small class="muted">{tag.type}:</small> {tag.name}</h1> <h1><small class="muted">{tag.type}:</small> {tag.name}</h1>
@ -21,6 +21,12 @@ const count = categories.length + works.length;
</ul> </ul>
</section> </section>
)} )}
{ebooks.length > 0 && (
<section class="search-group">
<h2>ای بکس</h2>
<ul class="book-results">{ebooks.map((b: any) => <li><a href={`/ebook/${b.id}`}>{b.title}</a> · <a class="muted" href={b.poet_url}>{b.poet}</a></li>)}</ul>
</section>
)}
{works.length > 0 && ( {works.length > 0 && (
<section class="search-group"> <section class="search-group">
<h2>کلام</h2> <h2>کلام</h2>

View File

@ -196,6 +196,11 @@ h1 + .muted { text-align: center; margin-top: 0; }
.profile-bio { max-width: 520px; margin: 0 auto 12px; text-align: center; white-space: pre-line; } .profile-bio { max-width: 520px; margin: 0 auto 12px; text-align: center; white-space: pre-line; }
.account-form button { font: inherit; padding: 6px 14px; border: 1.5px solid var(--gold); border-radius: 10px; background: var(--inner); color: var(--ink); cursor: pointer; } .account-form button { font: inherit; padding: 6px 14px; border: 1.5px solid var(--gold); border-radius: 10px; background: var(--inner); color: var(--ink); cursor: pointer; }
.account-form button:hover { border-color: var(--brand); color: var(--brand); } .account-form button:hover { border-color: var(--brand); color: var(--brand); }
/* boxes with the keyboard buttons inside them: full width, and the small in-box buttons keep their own look */
.account-form .in-box-field { display: block; position: relative; }
.account-form .in-box-field input { width: 100%; box-sizing: border-box; }
.account-form .in-box { padding: 4px; border: 0; border-radius: 6px; background: none; color: var(--muted, currentColor); }
.account-form .author-suggest button, .account-form .tag-chip { padding: 0 10px; border-radius: 999px; }
.account-form.danger button { border-color: var(--brand); color: var(--brand); } .account-form.danger button { border-color: var(--brand); color: var(--brand); }
.form-error { color: var(--brand); text-align: center; } .form-error { color: var(--brand); text-align: center; }
.form-done { color: var(--gold); text-align: center; } .form-done { color: var(--gold); text-align: center; }
@ -425,3 +430,30 @@ 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 { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; max-width: 700px; margin: 0 auto; }
.eb-meta dt { color: var(--muted); } .eb-meta dt { color: var(--muted); }
.eb-meta dd { margin: 0; } .eb-meta dd { margin: 0; }
/* 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; }
.ebook-card { display: flex; flex-direction: column; gap: 6px; text-decoration: none; color: var(--ink); text-align: center; }
.ebook-card img, .ebook-plain { width: 100%; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); box-shadow: 0 2px 8px rgb(0 0 0 / .12); background: var(--inner); transition: transform .2s; }
.ebook-card:hover img, .ebook-card:hover .ebook-plain { transform: translateY(-3px); }
.ebook-plain { display: flex; flex-direction: column; justify-content: center; gap: 8px; padding: 12px; background: linear-gradient(160deg, var(--brand), #5a1e14); color: #fff; font-family: 'Noto Nastaliq Urdu', serif; line-height: 2; }
.ebook-plain small { opacity: .8; }
.ebook-title { font-size: .9rem; line-height: 1.6; }
.cover-small { max-height: 120px; border-radius: 4px; border: 1px solid var(--border); vertical-align: middle; }
.cover-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; }
.pdf-reader { margin: 12px 0; }
.pdf-bar { display: flex; justify-content: center; align-items: center; gap: 16px; margin-bottom: 8px; }
.pdf-page { width: 4.5em; font: inherit; text-align: center; border: 1px solid var(--border); border-radius: 6px; background: var(--inner); color: var(--ink); }
.pdf-pages { display: flex; justify-content: center; gap: 8px; min-height: 60vh; cursor: pointer; }
.pdf-pages canvas { box-shadow: 0 2px 10px rgb(0 0 0 / .2); background: #fff; }
.pdf-reader:fullscreen { background: var(--bg); padding: 10px; overflow: auto; }
.pdf-reader:fullscreen .pdf-pages { min-height: calc(100vh - 70px); align-items: center; }
.eb-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; }
.eb-dir { border: 1px solid var(--border); border-radius: 8px; padding: 2px 12px; display: flex; gap: 14px; }
.eb-dir label { display: inline-flex; gap: 4px; align-items: center; }
.eb-co { display: flex; flex-direction: column; gap: 4px; margin: 6px 0; }
.eb-co .tag-chips { justify-content: flex-start; }
.eb-co .author-find { position: relative; display: block; }
.eb-co .author-find input { width: 100%; }
.eb-people { color: var(--muted); }
.account-form.ebook-form, .mod-edit.account-form { max-width: 560px; }