diff --git a/README.md b/README.md index 041e917..f0d6bbf 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,20 @@ Lector web de manga autoalojado, con las fuentes de favoritos y lo más leído. - Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`). +## Libros de texto + +Además del manga, lee libros en EPUB con el mismo lector (hoja que gira, sombra, hendidura, sonido): + +- **Project Gutenberg**: dominio público, con búsqueda en su catálogo OPDS. +- **Wikisource en español**: novelas, con el EPUB que genera ws-export. +- **Mis libros**: los EPUB que sube cada usuario (hasta 100 MB), visibles solo para quien los sube. + +El servidor convierte el EPUB en una lista cerrada de bloques (`app/books.js`), sin pasar HTML del libro al +navegador, y guarda el resultado en `data/books/`. El navegador maqueta el libro entero como texto continuo según su +pantalla y dibuja cada página en un canvas. La letra, los márgenes y el papel se eligen en el lector, por dispositivo. +Un libro se puede guardar entero para leerlo sin conexión. En Ajustes → «Qué mostrar», cada usuario elige ver manga, +libros o ambos. + ## Uso ```bash diff --git a/app/books.js b/app/books.js index 4b0edee..b5176db 100644 --- a/app/books.js +++ b/app/books.js @@ -2,23 +2,32 @@ // HTML del libro, y es él quien maqueta los bloques en páginas según su pantalla (ver maquetado en app.js). // bloque: { k: 'p' | 'q' | 'h' | 'hr' | 'img', l?: nivel de título, n?: imagen, c?: centrado, v?: verso, // r?: [[texto, estilo]] } estilo: 1 cursiva, 2 negrita (sumables) -// Fuente en línea: Project Gutenberg, con su propio catálogo OPDS. IDs: libro "gb~", capítulo "gb~~". -// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo. +// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo. Orígenes: +// - Project Gutenberg, con su catálogo OPDS. IDs: libro "gb~", capítulo "gb~~". +// - Wikisource en español, con el EPUB que genera ws-export. IDs: "ws~", "ws~~". +// - Los EPUB que sube cada usuario, solo visibles para él. IDs: "ub~<32 hex>", "ub~<32 hex>~". // En /books/ se guardan el EPUB (de ahí salen las imágenes) y el libro ya convertido. import fs from 'node:fs' import zlib from 'node:zlib' +import crypto from 'node:crypto' import path from 'node:path' import { readZipIndex, readCbzEntry, imageType } from './cbz.js' export const isBookId = id => /^(gb|ws|ub)~/.test(id || '') const GUTENBERG = 'https://www.gutenberg.org' +const WS_API = 'https://es.wikisource.org/w/api.php' +const WS_EXPORT = 'https://ws-export.wmcloud.org/' +const WS_MIN_WORDS = 2000 // sin subpáginas, lo que no llega a esto es un poema o un texto suelto, no un libro const OPDS_PAGE = 25 +const GB_NAME = 'Project Gutenberg' +const WS_NAME = 'Wikisource' // El buscador de Gutenberg filtra por el nombre del idioma, no por el código. const LANG_NAMES = { es: 'spanish', en: 'english', fr: 'french', pt: 'portuguese', it: 'italian', de: 'german', ru: 'russian', ja: 'japanese', zh: 'chinese', ko: 'korean', ca: 'catalan', nl: 'dutch', pl: 'polish' } -const PARSER_VERSION = 2 +const PARSER_VERSION = 3 const MAX_EPUB_BYTES = 100 * 1024 * 1024 const MAX_UNZIPPED_ENTRY = 64 * 1024 * 1024 // un XHTML o una imagen -const MAX_UNZIPPED_TOTAL = 512 * 1024 * 1024 // todo el libro: lo que pase de aquí es una ZIP bomb +const MAX_UNZIPPED_TOTAL = 256 * 1024 * 1024 // todo lo que se lee del libro: lo que pase de aquí es una ZIP bomb +const MAX_UPLOADS_PER_USER = 500 const BOOK_IMAGE = /\.(jpe?g|png|gif|webp)$/i const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', laquo: '«', raquo: '»', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', iexcl: '¡', iquest: '¿', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', agrave: 'à', egrave: 'è', igrave: 'ì', ograve: 'ò', ugrave: 'ù', ccedil: 'ç', shy: '', ensp: ' ', emsp: ' ', thinsp: ' ', zwnj: '', zwj: '', copy: '©', deg: '°', ordm: 'º', ordf: 'ª', sect: '§', para: '¶', middot: '·', dagger: '†', Dagger: '‡', bull: '•', times: '×' } @@ -42,7 +51,7 @@ function resolveHref(base, href) { const SKIP = new Set(['head', 'script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'audio', 'video', 'math', 'select', 'form', 'nav']) const BLOCK = new Set(['p', 'div', 'section', 'article', 'aside', 'header', 'footer', 'figure', 'figcaption', 'main', 'body', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'caption', 'pre', 'address', 'center', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote']) const VOID = new Set(['br', 'hr', 'img', 'image', 'meta', 'link', 'input', 'col', 'area', 'base', 'wbr']) -const TOKEN = /||<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g +const TOKEN = /||<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^<>"']|"[^"]*"|'[^']*')*)>/g // onAnchor(id) se llama con cada id del documento; devuelve true si ahí empieza un capítulo. function xhtmlToBlocks(xml, file, imageOf, onBlock, onAnchor) { @@ -216,11 +225,15 @@ export function parseEpub(file) { const index = readZipIndex(file) const entries = new Map(index.map(e => [e.name, e])) if (index.reduce((n, e) => n + e.unzipped, 0) > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir') + let unzipped = 0 const readBuf = name => { const e = entries.get(name) if (!e) throw new Error(`Falta ${name} en el EPUB`) if (e.unzipped > MAX_UNZIPPED_ENTRY) throw new Error('Archivo demasiado grande dentro del EPUB') - return readCbzEntry(file, e, MAX_UNZIPPED_ENTRY) + const buf = readCbzEntry(file, e, MAX_UNZIPPED_ENTRY) + unzipped += buf.length // lo real: la cabecera del ZIP puede mentir + if (unzipped > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir') + return buf } const read = name => readBuf(name).toString('utf8').replace(/^/, '') @@ -285,6 +298,10 @@ export function parseEpub(file) { if (carry) merged.push(carry) const out = merged if (!out.length) throw new Error('EPUB sin texto') + // Portada: EPUB 3 la marca en el manifiesto y EPUB 2 con un , que a veces no apunta a una imagen. + const coverId = /]*\bname\s*=\s*["']cover["'])[^>]*\bcontent\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1] + const coverItem = [...manifest.values()].find(it => /\bcover-image\b/.test(it.props)) || manifest.get(coverId) + const cover = coverItem ? imageOf(coverItem.path) : null out.forEach((c, i) => { if (!c.title) c.title = `Sección ${i + 1}` }) // El navegador maqueta el libro entero sin cargar las imágenes: necesita saber su tamaño. for (const c of out) { @@ -298,35 +315,65 @@ export function parseEpub(file) { version: PARSER_VERSION, title: dc('title'), authors: [...opf.matchAll(/]*>([\s\S]*?)<\/dc:creator>/gi)].map(m => clean(m[1])).filter(Boolean), language: dc('language').slice(0, 2).toLowerCase(), description: dc('description'), year: year > 0 && year < 2100 ? year : null, - images, chapters: out, + images, cover, chapters: out, } } // ---------- Fuente y caché ---------- -export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) { +// Portada generada para lo que no trae una: el título sobre un color que sale del propio título. +function titleCover(title, author) { + const esc = t => String(t).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c])) + const wrap = (t, width, max) => { + const lines = [] + let cur = '' + for (const w of t.split(/\s+/)) { + if (cur && cur.length + w.length + 1 > width) { lines.push(cur); cur = w } else cur = cur ? `${cur} ${w}` : w + } + if (cur) lines.push(cur) + if (lines.length > max) { lines.length = max; lines[max - 1] += '…' } + return lines.map(l => l.slice(0, width + 6)) + } + const hue = [...String(title)].reduce((n, c) => (n * 31 + c.codePointAt(0)) % 360, 7) + const lines = wrap(title, 15, 6) + const by = author ? wrap(author, 22, 2) : [] + const text = (t, y, size, weight, fill) => `${esc(t)}` + return `${lines.map((l, i) => text(l, 150 + i * 48, 38, 'bold', '#f6f1e6')).join('')}${by.map((l, i) => text(l, 500 + i * 30, 23, 'normal', `hsl(${hue} 45% 80%)`)).join('')}` +} + +export function setupBooks({ db, DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) { const DIR = path.join(DATA_DIR, 'books') fs.mkdirSync(DIR, { recursive: true }) - const epubFile = id => path.join(DIR, `${id.replace('~', '-')}.epub`) - const jsonFile = id => path.join(DIR, `${id.replace('~', '-')}.json`) + db.exec(`CREATE TABLE IF NOT EXISTS user_books ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + authors TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_user_books_user ON user_books(user_id, created_at);`) + // Un título de Wikisource puede ser largo para nombre de archivo. + const fileKey = id => (id.startsWith('ws~') ? `ws-${crypto.createHash('sha256').update(id).digest('hex').slice(0, 32)}` : id.replace('~', '-')) + const epubFile = id => path.join(DIR, `${fileKey(id)}.epub`) + const jsonFile = id => path.join(DIR, `${fileKey(id)}.json`) // Hasta 3 intentos: un fallo de red o un 5xx de Gutenberg suele durar poco. El motivo real queda en el log. - async function gutenberg(url, timeout) { + async function fetchSource(url, timeout, name) { for (let i = 1; ; i++) { net.guard() let why try { const r = await safeFetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(timeout) }) if (r.ok) return r - if (r.status === 404) throw new HttpError(404, 'Ese libro no está en Project Gutenberg') - if (r.status < 500 && r.status !== 429) throw new HttpError(502, `Project Gutenberg respondió ${r.status}`) + if (r.status === 404) throw new HttpError(404, `Ese libro no está en ${name}`) + if (r.status < 500 && r.status !== 429) throw new HttpError(502, `${name} respondió ${r.status}`) why = `HTTP ${r.status}` } catch (e) { if (e instanceof HttpError) throw e why = e.cause?.code || e.cause?.message || e.message net.failed() } - console.warn(`Gutenberg ${url}: intento ${i}: ${why}`) - if (i >= 3) throw new HttpError(502, 'Project Gutenberg no responde ahora mismo. Prueba en un rato.') + console.warn(`${name} ${url}: intento ${i}: ${why}`) + if (i >= 3) throw new HttpError(502, `${name} no responde ahora mismo. Prueba en un rato.`) await new Promise(r => setTimeout(r, 800 * i)) } } @@ -353,7 +400,93 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf const qs = new URLSearchParams({ query: [text, name && `l.${name}`].filter(Boolean).join(' '), sort_order: mode === 'latest' && !text ? 'release_date' : 'downloads' }) const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE + 1 if (start > 1) qs.set('start_index', String(start)) - return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await gutenberg(`${GUTENBERG}/ebooks/search.opds/?${qs}`, 15e3)).text())) + return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await fetchSource(`${GUTENBERG}/ebooks/search.opds/?${qs}`, 15e3, GB_NAME)).text())) + } + + // ---------- Wikisource ---------- + const wsId = title => `ws~${Buffer.from(title).toString('base64url')}` + const wsTitle = id => Buffer.from(id.slice(3), 'base64url').toString('utf8') + const wsCard = root => { + const id = wsId(root) + // "Realidad (Pérez Galdós)": la obra y, entre paréntesis, de quién es o de qué año. + const m = /^(.+?)\s*\(([^()]+)\)$/.exec(root) + return { id, source: 'ws', title: m ? m[1] : root, cover: `/api/book/${id}/cover`, sub: m ? m[2] : undefined } + } + const wsQuery = async params => { + const qs = new URLSearchParams({ action: 'query', format: 'json', ...params }) + return (await fetchSource(`${WS_API}?${qs}`, 20e3, WS_NAME)).json() + } + async function wsList(q, mode) { + const text = q.trim() + if (text) { + const d = await wsQuery({ list: 'search', srsearch: text, srnamespace: '0', srlimit: '200', srprop: 'wordcount' }) + // Una novela es una página índice corta con muchas subpáginas ("Obra/12"): cuenta la obra, una vez. + const works = new Map() + for (const r of d.query?.search || []) { + const root = r.title.split('/')[0] + const w = works.get(root) || { root, chapters: 0, words: 0 } + if (r.title.includes('/')) w.chapters++ + else w.words = r.wordcount + works.set(root, w) + } + return [...works.values()].filter(w => w.chapters || w.words >= WS_MIN_WORDS).map(w => w.root) + } + const d = await wsQuery({ list: 'categorymembers', cmtitle: 'Categoría:Novelas', cmnamespace: '0', cmlimit: '500', ...(mode === 'latest' ? { cmsort: 'timestamp', cmdir: 'desc' } : {}) }) + return (d.query?.categorymembers || []).map(m => m.title) + } + async function wsSearch(q, mode, page) { + const list = await cached(`ws:${mode === 'latest' ? 'latest' : 'popular'}:${q.trim().toLowerCase()}`, 30 * 60e3, () => wsList(q, mode)) + const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE + return list.filter(t => Buffer.byteLength(t) <= 280).slice(start, start + OPDS_PAGE).map(wsCard) + } + + // ---------- Libros subidos ---------- + const owns = (userId, id) => !!db.prepare('SELECT 1 FROM user_books WHERE id = ? AND user_id = ?').get(id, userId) + const ubCard = r => ({ id: r.id, source: 'ub', title: r.title, cover: `/api/book/${r.id}/cover`, sub: JSON.parse(r.authors).join(', ') || undefined }) + function ubList(userId, q) { + const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') + const words = fold(q).split(/\s+/).filter(Boolean) + return db.prepare('SELECT * FROM user_books WHERE user_id = ? ORDER BY created_at DESC').all(userId) + .filter(r => words.every(w => fold(`${r.title} ${r.authors}`).includes(w))).map(ubCard) + } + async function upload(req, user) { + if (db.prepare('SELECT COUNT(*) n FROM user_books WHERE user_id = ?').get(user.id).n >= MAX_UPLOADS_PER_USER) throw new HttpError(400, `Se admiten hasta ${MAX_UPLOADS_PER_USER} libros por usuario`) + let size = 0 + const chunks = [] + for await (const c of req) { + size += c.length + if (size > MAX_EPUB_BYTES) throw new HttpError(413, 'El libro es demasiado grande (máximo 100 MB)') + chunks.push(c) + } + if (!size) throw new HttpError(400, 'No se recibió ningún archivo') + const data = Buffer.concat(chunks) + // El mismo archivo subido dos veces por la misma persona es el mismo libro. + const id = `ub~${crypto.createHash('sha256').update(`${user.id}:`).update(data).digest('hex').slice(0, 32)}` + const tmp = path.join(DIR, `up-${crypto.randomBytes(8).toString('hex')}.part`) + fs.writeFileSync(tmp, data) + let book + try { book = parseEpub(tmp) } catch (e) { + fs.rmSync(tmp, { force: true }) + throw new HttpError(400, `No se pudo leer ese archivo como EPUB: ${String(e.message).slice(0, 120)}`) + } + fs.renameSync(tmp, epubFile(id)) + fs.writeFileSync(jsonFile(id), JSON.stringify(book)) + remember(id, book) + db.prepare(`INSERT INTO user_books (id, user_id, title, authors, created_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET created_at = excluded.created_at`) + .run(id, user.id, (book.title || 'Sin título').slice(0, 300), JSON.stringify(book.authors.map(authorName).slice(0, 10)), Date.now()) + return ubCard(db.prepare('SELECT * FROM user_books WHERE id = ?').get(id)) + } + function removeUpload(id, userId) { + db.prepare('DELETE FROM user_books WHERE id = ? AND user_id = ?').run(id, userId) + db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(userId, id) + db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(userId, id) + db.prepare('DELETE FROM manga_prefs WHERE user_id = ? AND manga_id = ?').run(userId, id) + db.prepare('DELETE FROM list_items WHERE manga_id = ? AND list_id IN (SELECT id FROM lists WHERE user_id = ?)').run(id, userId) + memory.delete(id) + packed.delete(id) + fs.rmSync(epubFile(id), { force: true }) + fs.rmSync(jsonFile(id), { force: true }) } // Una sola descarga y conversión a la vez por libro. @@ -366,7 +499,10 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf return book } async function downloadEpub(id) { - const r = await gutenberg(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3) + if (id.startsWith('ub~')) throw new HttpError(404, 'Ese libro ya no está') + const r = id.startsWith('ws~') + ? await fetchSource(`${WS_EXPORT}?${new URLSearchParams({ lang: 'es', page: wsTitle(id), format: 'epub-3' })}`, 120e3, WS_NAME) + : await fetchSource(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3, GB_NAME) if (Number(r.headers.get('content-length')) > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande') const buf = Buffer.from(await r.arrayBuffer()) if (buf.length > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande') @@ -401,16 +537,17 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf async function manga(id) { const book = await loadBook(id) + const source = id.slice(0, 2) return { manga: { - id, source: 'gb', type: 'Libro', - title: book.title || `Libro ${id.slice(3)}`, + id, source, type: 'Libro', + title: book.title || (source === 'ws' ? wsCard(wsTitle(id)).title : `Libro ${id.slice(3)}`), description: book.description || '', status: null, year: null, // la fecha del EPUB es la de la edición digital, no la de la obra tags: [], authors: book.authors.map(authorName), languages: book.language ? [book.language] : [], - cover: coverOf(id.slice(3), id), + cover: source === 'gb' ? coverOf(id.slice(3), id) : `/api/book/${id}/cover`, }, chapters: book.chapters.map((c, i) => ({ id: `${id}~${i}`, num: String(i + 1), volume: null, title: c.title, pages: null, group: '', publishAt: null })), } @@ -447,5 +584,20 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf res.writeHead(200, { 'Content-Type': imageType(name), 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' }) res.end(data) } - return { search, manga, chapter, sendText, sendImage } + async function sendCover(res, id) { + if (id.startsWith('ub~')) { + const book = await loadBook(id) + if (book.cover != null) return sendImage(res, id, book.cover) + const r = db.prepare('SELECT title, authors FROM user_books WHERE id = ?').get(id) + return sendSvg(res, titleCover(r?.title || book.title || 'Sin título', book.authors.map(authorName)[0])) + } + if (!id.startsWith('ws~')) throw new HttpError(404, 'Sin portada') + const card = wsCard(wsTitle(id)) // en un listado: sin descargar el libro + sendSvg(res, titleCover(card.title, card.sub)) + } + const sendSvg = (res, svg) => { + res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' }) + res.end(svg) + } + return { search, wsSearch, ubList, upload, removeUpload, owns, manga, chapter, sendText, sendImage, sendCover } } diff --git a/app/cbz.js b/app/cbz.js index a03c701..382ae5a 100644 --- a/app/cbz.js +++ b/app/cbz.js @@ -75,6 +75,7 @@ export function readZipIndex(file) { const count = tail.readUInt16LE(eocd + 10) const cdSize = tail.readUInt32LE(eocd + 12) const cdOffset = tail.readUInt32LE(eocd + 16) + if (cdSize > size) throw new Error('ZIP inválido') const cd = Buffer.alloc(cdSize) fs.readSync(fd, cd, 0, cdSize, cdOffset) const entries = [] @@ -87,6 +88,7 @@ export function readZipIndex(file) { const extraLen = cd.readUInt16LE(p + 30) const commentLen = cd.readUInt16LE(p + 32) const localOffset = cd.readUInt32LE(p + 42) + if (compSize > size) throw new Error('ZIP inválido') const name = cd.toString('utf8', p + 46, p + 46 + nameLen) p += 46 + nameLen + extraLen + commentLen fs.readSync(fd, lh, 0, 30, localOffset) diff --git a/app/public/app.js b/app/public/app.js index eecb9b6..17496bb 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -50,9 +50,9 @@ const icon = name => `${shownSources.map(([k, v]) => ``).join('')} Buscar - ${q && !tag ? `

Resultados para “${esc(q)}”

` : `
+ ${source === 'ub' ? `
Solo tú puedes verlos. Máximo 100 MB por libro.
` : ''} + ${source === 'ub' ? (q ? `

Resultados para “${esc(q)}”

` : '') : q && !tag ? `

Resultados para “${esc(q)}”

` : `
${NO_CATS.has(source) ? '' : ``}
`} @@ -564,6 +574,23 @@ async function viewHome(params) { if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga } + $('#ubfile')?.addEventListener('change', async e => { + const files = [...e.target.files] + const label = $('#ubpick') + label.classList.add('disabled') + let added = 0 + for (const [i, f] of files.entries()) { + toast(`Subiendo ${files.length > 1 ? `${i + 1} de ${files.length}: ` : ''}${f.name}…`) + try { + const r = await fetch('/api/books', { method: 'POST', headers: { 'Content-Type': 'application/epub+zip' }, body: f }) + const d = await r.json().catch(() => ({})) + if (!r.ok) throw new Error(d.error || `Error ${r.status}`) + added++ + } catch (err) { toast(`${f.name}: ${err.message}`) } + } + label.classList.remove('disabled') + if (added) { toast(added === 1 ? 'Libro añadido' : `${added} libros añadidos`); router() } + }) const res = $('#results') if (showCats) { const cats = await api(`/api/categories?source=${source}`) @@ -574,7 +601,7 @@ async function viewHome(params) { } // Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan). - const pageable = source === 'md' || BOOK_SOURCES.has(source) || !!tag || (source === 'mi' && mode === 'latest' && !q) + const pageable = source === 'md' || source === 'gb' || source === 'ws' || !!tag || (source === 'mi' && mode === 'latest' && !q) const seen = new Set() let page = 1 const load = async () => { @@ -582,7 +609,7 @@ async function viewHome(params) { const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id)) if (page === 1) { res.className = list.length ? 'grid' : 'loading' - res.innerHTML = list.length ? '' : 'Sin resultados.' + res.innerHTML = list.length ? '' : source === 'ub' && !q ? 'Aún no has subido ningún libro. Pulsa «Subir EPUB» para añadir el primero.' : 'Sin resultados.' } res.insertAdjacentHTML('beforeend', list.map(mangaCard).join('')) $('#more').hidden = !pageable || list.length < 20 @@ -676,6 +703,7 @@ async function viewManga(id, langOverride) { const menuItems = [ manga.source === 'lc' || isBook ? '' : `${icon('search')} Buscar en LeerCapitulo`, manga.source === 'mi' || isBook ? '' : `${icon('search')} Buscar en M440`, + manga.source === 'ub' ? `` : '', progress || reads.length ? `` : '', ].join('') @@ -708,7 +736,7 @@ async function viewManga(id, langOverride) { ${clipCount ? `${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}` : ''} ${menuItems}
-
+
${icon('book')} Datos de la obra
@@ -1048,6 +1076,20 @@ async function viewManga(id, langOverride) { const queued = offState.queue.filter(q => q.mangaId === id).length markLib('off', !!(run || queued)) box.hidden = false + if (isBook) { + // Un libro se guarda entero: el texto de todos sus capítulos y sus imágenes. + const status = mine.length ? `${icon('check')} Guardado · ${fmtSize(bytes)}` : '' + const note = run ? `Guardando… ${run.done}/${run.total || '?'}` : queued ? 'En cola…' : mine.length ? 'Se puede leer sin internet.' : 'Guarda el libro en este móvil o PC para leerlo sin internet.' + box.innerHTML = `${secHead('phone', 'En este dispositivo', status)}
${note}
+ ${mine.length ? `` : run || queued ? '' : ``}
` + $('#offbook')?.addEventListener('click', () => offEnqueue([{ mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang: manga.languages?.[0] || 'es', chapterId: chapters[0].id, label: 'Libro completo', num: null }])) + $('#offdel')?.addEventListener('click', async () => { + if (!confirm(`¿Borrar «${manga.title}» de este dispositivo (${fmtSize(bytes)})?`)) return + await offRemove(mine.map(([cid]) => cid)) + toast('Borrado del dispositivo') + }) + return + } // Siguientes 3 sin guardar a partir de donde vas (o desde el principio). const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id)) const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3) @@ -1136,6 +1178,15 @@ async function viewManga(id, langOverride) { renderFollow() toast(isFollowing ? 'Te avisaremos de los capítulos nuevos' : 'Ya no sigues este manga') }) + $('#ubdel')?.addEventListener('click', async () => { + if (!confirm(`¿Eliminar «${manga.title}»?\nSe borrará el archivo del servidor y tu progreso de lectura.`)) return + try { + await api(`/api/books/${id}`, { method: 'DELETE' }) + if (OFFLINE_OK) await offRemove(Object.entries(offIdx()).filter(([, c]) => c.mangaId === id).map(([cid]) => cid)) + toast('Libro eliminado') + location.hash = '#/?source=ub' + } catch (err) { toast(err.message) } + }) $('#drop')?.addEventListener('click', async () => { if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial, se borrará el progreso de sus capítulos y dejarás de seguirlo.`)) return await api(`/api/history/${id}`, { method: 'DELETE' }) @@ -1762,7 +1813,12 @@ async function viewReader(mangaId, chapterId, startPage) { const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', sfx: 'mid', ...store.get('readerSettings', {}) } const [info, chap] = await Promise.all([ api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`), - api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`), + api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`).catch(err => { + // Un libro guardado se guarda entero: sin conexión, cualquier capítulo abre con el texto del dispositivo. + if (!isBookId(mangaId) || !OFFLINE_OK || !Object.values(offIdx()).some(c => c.mangaId === mangaId)) throw err + const pos = store.get(`bookPos:${mangaId}`) + return { text: true, pages: [], savedPage: pos?.chapterId === chapterId ? pos.page : 0, savedTotal: pos?.chapterId === chapterId ? pos.totalPages : 0 } + }), ]) const { manga, chapters } = info const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) } @@ -1822,7 +1878,7 @@ async function viewReader(mangaId, chapterId, startPage) { chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total, isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo" }) - const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) } + const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); if (isBook) store.set(`bookPos:${mangaId}`, { chapterId: p.chapterId, page: p.page, totalPages: p.totalPages }); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) } const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) } const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } } const onVis = () => document.visibilityState === 'hidden' && beacon() diff --git a/app/public/sw.js b/app/public/sw.js index cf9a4c3..4073a16 100644 --- a/app/public/sw.js +++ b/app/public/sw.js @@ -74,7 +74,7 @@ self.addEventListener('fetch', e => { if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true })) if (p.startsWith('/api/chapter/')) return e.respondWith(networkFirst(req, OFFLINE, { store: false, ignoreSearch: true })) // Imágenes: si están guardadas, sin red (más rápido y sin gastar datos). - if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/')) { + if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/') || p.startsWith('/api/book/')) { e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req))) } }) diff --git a/app/server.js b/app/server.js index 494710e..1c6d7a6 100644 --- a/app/server.js +++ b/app/server.js @@ -459,7 +459,7 @@ const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl) const mi = setupM440({ cached, httpError: (status, message) => new HttpError(status, message), coverUrl, decode, BROWSER_UA, net }) const miPages = async chapterId => (await mi.pageUrls(chapterId)).map(imgUrl) // Fuente según el ID: 'lc' y 'mi' son sitios en español; lo demás es MangaDex. -const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : id.startsWith('gb~') ? 'gb' : 'md') +const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : /^(gb|ws|ub)~/.test(id) ? id.slice(0, 2) : 'md') const spanishOnly = id => sourceOf(id) !== 'md' // Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos. @@ -542,7 +542,7 @@ async function fetchMangaFull(mangaId, lang) { const src = sourceOf(mangaId) return src === 'lc' ? lcManga(mangaId) : src === 'mi' ? mi.manga(mangaId) - : src === 'gb' ? books.manga(mangaId) + : isBookId(mangaId) ? books.manga(mangaId) : { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) } } @@ -781,8 +781,8 @@ async function readBody(req) { } try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') } } -const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+|gb~\d{1,7})$/ -const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+|gb~\d{1,7}~\d{1,5})$/ +const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+|gb~\d{1,7}|ws~[A-Za-z0-9_-]{1,400}|ub~[0-9a-f]{32})$/ +const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+|gb~\d{1,7}~\d{1,5}|ws~[A-Za-z0-9_-]{1,400}~\d{1,5}|ub~[0-9a-f]{32}~\d{1,5})$/ const LANG = /^[a-z]{2}(-[a-z]{2})?$/ // Manga en el historial o en seguidos del usuario (sus portadas se reescalan). const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ? @@ -873,6 +873,10 @@ async function route(req, res) { // Todo lo demás requiere sesión. const user = requireUser(req) + // Un libro subido solo existe para quien lo subió, en cualquier ruta y parámetro que lo nombre. + let rawUrl = req.url + try { rawUrl = decodeURIComponent(req.url) } catch {} + for (const [ub] of rawUrl.matchAll(/ub~[0-9a-f]{32}/g)) if (!books.owns(user.id, ub)) throw new HttpError(404, 'No encontrado') if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return if ((await features.route(p, method, q, req, res, user)) !== false) return if ((await upscale.route(p, method, req, res)) !== false) { @@ -927,12 +931,14 @@ async function route(req, res) { const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang if (p === '/api/categories' && method === 'GET') { - return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : q.get('source') === 'mi' || q.get('source') === 'gb' ? [] : await mdCategories()) + return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : ['mi', 'gb', 'ws', 'ub'].includes(q.get('source')) ? [] : await mdCategories()) } if (p === '/api/search' && method === 'GET') { const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500) const tag = q.get('tag') || '' + if (q.get('source') === 'ub') return json(res, 200, books.ubList(user.id, (q.get('q') || '').slice(0, 100))) // no necesita internet if (!net.online()) return json(res, 200, localSearch(q.get('q') || '')) + if (q.get('source') === 'ws') return json(res, 200, await books.wsSearch((q.get('q') || '').slice(0, 100), q.get('mode'), page)) if (q.get('source') === 'gb') return json(res, 200, await books.search((q.get('q') || '').slice(0, 100), q.get('mode'), page, user.lang)) if (q.get('source') === 'mi') return json(res, 200, await mi.search((q.get('q') || '').slice(0, 100), q.get('mode'), page)) if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page)) @@ -962,6 +968,12 @@ async function route(req, res) { const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) }) } + if (p === '/api/books' && method === 'POST') return json(res, 200, await books.upload(req, user)) + if ((m = /^\/api\/books\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && m[1].startsWith('ub~') && method === 'DELETE') { + books.removeUpload(m[1], user.id) + return json(res, 200, { ok: true }) + } + if ((m = /^\/api\/book\/([^/]+)\/cover$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendCover(res, m[1]) if ((m = /^\/api\/book\/([^/]+)\/text$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendText(req, res, m[1]) if ((m = /^\/api\/book\/([^/]+)\/img\/(\d{1,5})$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendImage(res, m[1], Number(m[2])) if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { @@ -1096,7 +1108,7 @@ async function route(req, res) { } const security = setupSecurity({ db }) -const books = setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) +const books = setupBooks({ db, DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net }) const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) const covers = setupCovers({ DATA_DIR, upscale })