diff --git a/api/src/ebooks.test.ts b/api/src/ebooks.test.ts index 43388fbc..76b77439 100644 --- a/api/src/ebooks.test.ts +++ b/api/src/ebooks.test.ts @@ -8,14 +8,18 @@ import Fastify from 'fastify'; import JSZip from 'jszip'; import { authRoutes } from './auth.ts'; import { moderationRoutes } from './moderation.ts'; +import { libraryRoutes } from './library.ts'; import { ebookRoutes, archiveId, parseMeta, metaText } from './ebooks.ts'; import { pool } from './db.ts'; after(() => pool.end()); test('e-book details and archive.org links', () => { - assert.deepEqual(parseMeta(metaText({ title: 'کلیات اقبال', source: 'ریختہ نہیں', licence: 'عوامی ملکیت' })), - { title: 'کلیات اقبال', source: 'ریختہ نہیں', licence: 'عوامی ملکیت', note: '' }); + const m = parseMeta(metaText({ title: 'کلیات اقبال', source: 'archive.org', licence: 'عوامی ملکیت' })) as any; + 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.equal(archiveId('https://archive.org/details/BaangEDara/page/n5'), 'BaangEDara'); 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' }); git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start'); const app = Fastify(); - authRoutes(app); moderationRoutes(app); ebookRoutes(app); + authRoutes(app); moderationRoutes(app); ebookRoutes(app); libraryRoutes(app); const run = Date.now(); const call = (method: string, url: string, body?: any, token?: string, type?: string) => app.inject({ 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 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.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'); @@ -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('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`); 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'); assert.match(record, new RegExp(`عنوان: بانگ درا ${run}`)); assert.match(record, new RegExp(up.file)); 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'); + // 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 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.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(); + 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'); } 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 revisions WHERE author_email LIKE $1`, [`%-${run}@divan.test`]); await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]); diff --git a/api/src/ebooks.ts b/api/src/ebooks.ts index 984984ca..e3f6d748 100644 --- a/api/src/ebooks.ts +++ b/api/src/ebooks.ts @@ -4,12 +4,17 @@ // Files live in the file store (DIVAN_FILES_DIR) under their content hash, ab/cd/., 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 // 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 {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/ebook/:id one e-book (unpublished: moderators only); the text of a text book // 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 { createReadStream, createWriteStream } from 'node:fs'; 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 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 TYPES: Record = { pdf: 'application/pdf', epub: 'application/epub+zip', txt: 'text/plain; charset=utf-8' }; -const KEYS = { title: 'عنوان', source: 'ماخذ', licence: 'اجازت', note: 'تفصیل' } as const; -type Meta = { title: string; source: string; licence: string; note: string }; +const TYPES: Record = { pdf: 'application/pdf', epub: 'application/epub+zip', txt: 'text/plain; charset=utf-8', + jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' }; +// 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; +export const LANGUAGES = ['اردو', 'فارسی', 'عربی', 'انگریزی', 'ہندی', 'پنجابی', 'سندھی', 'پشتو', 'دیگر']; +export const DIRECTIONS: Record = { 'دائیں سے بائیں': '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); 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) => (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 { - 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')) { 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()); 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(); + const { rows } = await db.query('SELECT id, url, name, nickname FROM poets'); + const out = new Map(); + 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 @@ -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]; 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]; - 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; }; @@ -94,8 +129,20 @@ export const checkEbook = (text: string) => { const m = parseMeta(text); return export async function applyEbook(client: PoolClient, id: number, text: string) { const m = parseMeta(text) as Meta; 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', - [id, m.title, m.source || null, m.licence || null, m.note || null, await searchText(m.title, b.kind, b.file)]); + const co = list(m.coauthors), linked = await poetsNamed(co, client); + 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//ebooks/.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: 'لاگ ان کریں' }); const b = req.body ?? {}, poetId = Number(b.poet) || 0; 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 }); let kind: string, file: string | null = null, archive: string | null = null, size: number | null = null; if (b.archive) { @@ -169,9 +217,10 @@ export function ebookRoutes(app: FastifyInstance) { try { await client.query('BEGIN'); const id = (await client.query( - `INSERT INTO ebooks (poet_id, title, kind, file, archive_id, size, source, licence, note, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, - [poetId, meta.title, kind, file, archive, size, meta.source || null, meta.licence || null, meta.note || null, u.id])).rows[0].id; + `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, $11, $12, $13, $14) RETURNING 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( `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; @@ -187,7 +236,32 @@ export function ebookRoutes(app: FastifyInstance) { }); 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) { + 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 b = (await pool.query( @@ -199,16 +273,36 @@ export function ebookRoutes(app: FastifyInstance) { app.get<{ Params: { id: string } }>('/api/ebook/:id', async (req, reply) => { const b = await ebook(req, reply); if (!b) return; 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) => { const b = await ebook(req, reply); if (!b) return; if (!b.file) return reply.code(404).send({ error: 'اس کتاب کی فائل نہیں' }); - const ext = b.file.split('.').pop(); - reply.header('content-type', TYPES[ext]).header('content-length', String((await stat(pathOf(b.file))).size)) + const ext = b.file.split('.').pop(), size = (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('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))); }); } diff --git a/api/src/library.ts b/api/src/library.ts index cd3c3d46..6b5b7f67 100644 --- a/api/src/library.ts +++ b/api/src/library.ts @@ -1,19 +1,20 @@ // 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. // 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 // POST /api/library/toggle {kind, poetId?, categoryId?, poemId?, couplet?, phrase?, word?} -> {saved, id?} // POST /api/library/:id/note {note} // 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 -// 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 { pool } from './db.ts'; import { sessionUser } from './auth.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 export const cleanPhrase = (p: unknown) => { @@ -65,9 +66,11 @@ export function libraryRoutes(app: FastifyInstance) { app.get('/api/library', async (req, reply) => { const u = await reader(req, reply); if (!u) return; 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, - 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 + `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, + 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 + 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]); const poemIds = [...new Set(rows.filter((r) => r.poem_id).map((r) => r.poem_id))]; 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) })), 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) })), + 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) => ({ ...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; + 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) { 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 }; @@ -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]); 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') { const phrase = cleanPhrase(b.phrase); if (!phrase || !poemId || couplet == null) return reply.code(400).send({ error: 'عبارت منتخب کریں' }); diff --git a/api/src/tags.ts b/api/src/tags.ts index 73117a66..b9bbe720 100644 --- a/api/src/tags.ts +++ b/api/src/tags.ts @@ -126,13 +126,15 @@ export function tagRoutes(app: FastifyInstance) { 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]; 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 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]), 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 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 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`, [pairs.map((p) => p[0]), pairs.map((p) => p[1])])).rows : []; return { - tag, categories: cats.rows, + tag, categories: cats.rows, ebooks: ebooks.rows, 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 })) })), }; diff --git a/db/schema.sql b/db/schema.sql index a3d943f5..b6eba6e3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -143,7 +143,7 @@ CREATE TABLE IF NOT EXISTS library ( ALTER TABLE library ADD COLUMN IF NOT EXISTS phrase text; 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 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_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'); @@ -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_search ON ebooks USING gin (search_text gin_trgm_ops); +ALTER TABLE ebooks ADD COLUMN IF NOT EXISTS cover text; -- cover image '.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')); diff --git a/web/package-lock.json b/web/package-lock.json index 705407da..ad50b823 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,7 +15,8 @@ "@tiptap/extensions": "^3.31.4", "@tiptap/pm": "^3.31.4", "astro": "^7.3.8", - "epubjs": "^0.3.93" + "epubjs": "^0.3.93", + "pdfjs-dist": "^6.4.299" } }, "node_modules/@astrojs/compiler-binding": { @@ -1149,6 +1150,271 @@ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "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": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", @@ -3377,6 +3643,18 @@ "integrity": "sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==", "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": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", diff --git a/web/package.json b/web/package.json index 359914b3..0f3bdfe8 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@tiptap/extensions": "^3.31.4", "@tiptap/pm": "^3.31.4", "astro": "^7.3.8", - "epubjs": "^0.3.93" + "epubjs": "^0.3.93", + "pdfjs-dist": "^6.4.299" } } diff --git a/web/src/components/EbookFields.astro b/web/src/components/EbookFields.astro new file mode 100644 index 00000000..400c1c12 --- /dev/null +++ b/web/src/components/EbookFields.astro @@ -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 }; +const LANGUAGES = ['اردو', 'فارسی', 'عربی', 'انگریزی', 'ہندی', 'پنجابی', 'سندھی', 'پشتو', 'دیگر']; +const poets = ((await api('/api/poets')) ?? []).map((p) => p.nickname); +const KB = ''; +const keys = (id: string) => ``; +const direction = v.direction || (['انگریزی', 'ہندی'].includes(v.language) ? 'بائیں سے دائیں' : 'دائیں سے بائیں'); +--- + + +
+ شریک مصنفین (سائٹ پر موجود ہوں تو ان کے صفحے سے جڑ جاتے ہیں؛ نیا نام بھی لکھ سکتے ہیں) +

+ + + + diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index f6a5de86..5fb512d6 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -342,7 +342,11 @@ const fullTitle = title ? `${title} · دیوان` : 'دیوان · اردو ک clearTimeout(selTimer); selTimer = setTimeout(() => { 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() : ''; if (!selText) return hideMenu(); const w = oneWord(selText); diff --git a/web/src/lib/cover.ts b/web/src/lib/cover.ts new file mode 100644 index 00000000..f0ca1f1f --- /dev/null +++ b/web/src/lib/cover.ts @@ -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 { + 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 ?? 'سرورق محفوظ نہیں ہوا'); +} diff --git a/web/src/lib/pdfreader.ts b/web/src/lib/pdfreader.ts new file mode 100644 index 00000000..48db1ab3 --- /dev/null +++ b/web/src/lib/pdfreader.ts @@ -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('.pdf-pages')!, input = root.querySelector('.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('.pdf-next')!.onclick = () => step(1); + root.querySelector('.pdf-prev')!.onclick = () => step(-1); + input.onchange = () => show(Number(input.value) || 1); + // page bookmarks + const markBtn = root.querySelector('.pdf-mark'), list = root.querySelector('.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('.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 +} diff --git a/web/src/pages/[...path].astro b/web/src/pages/[...path].astro index 5ddef86b..2d70d5b2 100644 --- a/web/src/pages/[...path].astro +++ b/web/src/pages/[...path].astro @@ -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) const pageTags = (page.tags ?? []).filter((t: any) => !t.couplet); 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 -const ebooks = page.type === 'poet' ? (await api(`/api/ebooks?poet=${poetId}`)) ?? [] : []; -const EBOOK_KIND: Record = { pdf: 'PDF', epub: 'EPUB', text: 'متن', archive: 'آرکائیو' }; +// every poet has an ای بکس section beside their books and genres (/p17/ebooks) +const ebookCount = page.type === 'poet' ? ((await api(`/api/ebooks?poet=${poetId}`)) ?? []).length : 0; const tagTarget = page.type === 'poem' ? `work/${poemId}` : `category/${page.category?.id}`; 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; @@ -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') && (
{page.children.map((c: any) => ( {c.title}{c.poems > 0 && {ud(c.poems)}} ))} + {page.type === 'poet' && ای بکس{ebookCount > 0 && {ud(ebookCount)}}}
)} {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) && ( -
-

ای بکس {ebooks.length > 0 && {ud(ebooks.length)}}

-
    - {ebooks.map((b) =>
  • {b.title} {EBOOK_KIND[b.kind]}
  • )} -
- {catCan.ebooks &&

ای بک شامل کریں

} -
- )} - {page.type === 'poem' && ( <>

diff --git a/web/src/pages/[poet]/ebooks.astro b/web/src/pages/[poet]/ebooks.astro new file mode 100644 index 00000000..5945f4ea --- /dev/null +++ b/web/src/pages/[poet]/ebooks.astro @@ -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(`/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; +--- + + +

ای بکس

+ {mayAdd &&

ای بک شامل کریں

} + + {shown.length === 0 ?

اس قسم کی کوئی کتاب ابھی نہیں۔

: ( + + )} + diff --git a/web/src/pages/ebook/[id].astro b/web/src/pages/ebook/[id].astro index 2713df5b..372bbd87 100644 --- a/web/src/pages/ebook/[id].astro +++ b/web/src/pages/ebook/[id].astro @@ -4,16 +4,23 @@ import Base from '../../layouts/Base.astro'; import { asUser, COOKIE } from '../../lib/auth'; import { api } from '../../lib/api'; 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 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' }); const file = `/ebooks/file/${b.id}`; const KIND: Record = { 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`; --- - +

{b.title}

{!b.published &&

یہ کتاب ابھی جائزے میں ہے؛ صرف موڈریٹر دیکھ سکتے ہیں۔

}

@@ -21,9 +28,36 @@ const mb = !b.size ? '' : b.size < 1048576 ? `${ud(Math.max(1, Math.round(b.size {b.file && <> · ڈاؤن لوڈ} {b.kind === 'archive' && <> · archive.org پر}

+

+ {b.writer && <>مصنف: {b.writer}} + {b.coauthors?.length > 0 && <> · شریک مصنفین: {b.coauthors.map((c: any, i: number) => <>{i > 0 && '، '}{c.url ? {c.name} : c.name})}} + · زبان: {b.language} · {b.direction === 'ltr' ? 'بائیں سے دائیں (LTR)' : 'دائیں سے بائیں (RTL)'} +

+ {b.note &&

{b.note}

} + {mayCover && ( +

+ {b.cover && سرورق} + {(b.kind === 'pdf' || b.kind === 'epub') && } + + +

+ )} - {b.kind === 'pdf' && } + {b.kind === 'pdf' && ( +
+ + +

کتاب کھل رہی ہے…

+

{b.direction === 'ltr' ? '→ اگلا صفحہ · ← پچھلا صفحہ' : '← اگلا صفحہ · → پچھلا صفحہ'} · نئی ٹیب میں کھولیں

+
+ )} {b.kind === 'archive' && } {b.kind === 'text' &&
{b.text}
} {b.kind === 'epub' && ( @@ -33,6 +67,25 @@ const mb = !b.size ? '' : b.size < 1048576 ? `${ud(Math.max(1, Math.round(b.size )} diff --git a/web/src/pages/mod/rev/[id].astro b/web/src/pages/mod/rev/[id].astro index 3ca0b486..4c549db7 100644 --- a/web/src/pages/mod/rev/[id].astro +++ b/web/src/pages/mod/rev/[id].astro @@ -7,6 +7,7 @@ import { STATUS, EVENT, when } from '../../../lib/mod'; import { ud } from '../../../lib/urdu'; import DivanPreview from '../../../components/DivanPreview.astro'; import Tags from '../../../components/Tags.astro'; +import EbookFields from '../../../components/EbookFields.astro'; import { api } from '../../../lib/api'; 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) : []; // an e-book's details: title, source, copyright, note 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 acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیجیں'], ['reject', 'مسترد کریں'], ['publish', 'شائع کریں']] as const).filter(([a]) => may[a]); --- @@ -57,10 +59,7 @@ const acts = ([['approve', 'منظور کریں'], ['return', 'واپس بھیج {may.save && ebook ? (