Libros de texto: fuente Project Gutenberg y lectura continua
Los EPUB se convierten en el servidor en una lista cerrada de bloques y el navegador los maqueta en páginas según su pantalla. Cada página se dibuja en un canvas y llega al lector como una imagen más, así que hoja, sombra, hendidura y sonido no cambian. El libro se lee de corrido: cada capítulo empieza debajo del anterior y el progreso se sigue guardando por capítulo. El catálogo sale del OPDS de gutenberg.org, con reintentos. Ajustes: mostrar solo manga, solo libros o ambos, por usuario. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+451
@@ -0,0 +1,451 @@
|
||||
// Libros de texto (EPUB). El EPUB se convierte aquí en una lista cerrada de bloques: al navegador nunca llega
|
||||
// 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~<n>", capítulo "gb~<n>~<i>".
|
||||
// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo.
|
||||
// En <DATA_DIR>/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 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 OPDS_PAGE = 25
|
||||
// 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 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 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: '×' }
|
||||
const entities = s => s.replace(/&(#x?[0-9a-f]+|[a-z0-9]+);/gi, (all, e) => {
|
||||
if (e[0] !== '#') return ENTITIES[e] ?? all
|
||||
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)
|
||||
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ''
|
||||
})
|
||||
const attr = (attrs, name) => {
|
||||
const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs)
|
||||
return m ? entities(m[1] ?? m[2]) : null
|
||||
}
|
||||
// Ruta dentro del ZIP de un enlace relativo a un archivo del libro.
|
||||
function resolveHref(base, href) {
|
||||
let rel = href.split('#')[0]
|
||||
try { rel = decodeURIComponent(rel) } catch {}
|
||||
return path.posix.normalize(path.posix.join(path.posix.dirname(base), rel)).replace(/^\.\//, '')
|
||||
}
|
||||
|
||||
// ---------- XHTML a bloques ----------
|
||||
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 = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[?!][^>]*>|<(\/?)([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) {
|
||||
let skip = 0, skipTag = null
|
||||
let italic = 0, bold = 0, quote = 0, center = 0, verse = 0
|
||||
const stack = [] // etiquetas de bloque con estilo, para deshacerlo al cerrarlas
|
||||
let runs = [], heading = 0
|
||||
const flush = () => {
|
||||
// Espacios del código fuente fuera; los saltos de línea reales (<br>) son \n.
|
||||
const out = []
|
||||
let atStart = true
|
||||
for (const [text, style] of runs) {
|
||||
let t = text === '\n' ? text : text.replace(/[ \t\r\n\f]+/g, ' ') // el \n solo viene de un <br>
|
||||
if (atStart) t = t.replace(/^ +/, '')
|
||||
if (!t) continue
|
||||
atStart = t.endsWith('\n')
|
||||
const last = out.at(-1)
|
||||
if (last && last[1] === style) last[0] += t
|
||||
else out.push([t, style])
|
||||
}
|
||||
while (out.length) {
|
||||
const last = out.at(-1)
|
||||
last[0] = last[0].replace(/[ \n]+$/, '')
|
||||
if (last[0]) break
|
||||
out.pop()
|
||||
}
|
||||
runs = []
|
||||
if (out.length && out.some(([t]) => t.trim())) {
|
||||
const b = heading ? { k: 'h', l: heading, r: out } : { k: quote ? 'q' : 'p', r: out }
|
||||
if (center || heading) b.c = 1
|
||||
if (verse || out.some(([t]) => t.includes('\n'))) b.v = 1
|
||||
onBlock(b)
|
||||
}
|
||||
}
|
||||
const body = xml.search(/<body[\s>]/i)
|
||||
let pos = body >= 0 ? body : 0
|
||||
TOKEN.lastIndex = pos
|
||||
for (let m; (m = TOKEN.exec(xml));) {
|
||||
if (!skip && m.index > pos) {
|
||||
const text = entities(xml.slice(pos, m.index))
|
||||
if (text) runs.push([text, (italic ? 1 : 0) + (bold ? 2 : 0)])
|
||||
}
|
||||
pos = TOKEN.lastIndex
|
||||
const tag = m[2]?.toLowerCase().replace(/^.*:/, '')
|
||||
if (!tag) { // comentario, DOCTYPE; CDATA se pierde a propósito (no es texto de libro)
|
||||
continue
|
||||
}
|
||||
const closing = m[1] === '/'
|
||||
const rawAttrs = m[3] || ''
|
||||
const selfClosed = /\/\s*$/.test(rawAttrs)
|
||||
if (skip) {
|
||||
if (tag === skipTag) {
|
||||
if (closing) { if (--skip === 0) skipTag = null } else if (!selfClosed) skip++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (closing) {
|
||||
if (BLOCK.has(tag)) {
|
||||
flush()
|
||||
const top = stack.findLastIndex(s => s.tag === tag)
|
||||
if (top >= 0) {
|
||||
for (const s of stack.splice(top)) {
|
||||
quote -= s.quote; center -= s.center; verse -= s.verse; italic -= s.italic; bold -= s.bold
|
||||
if (s.heading) heading = 0
|
||||
}
|
||||
}
|
||||
} else if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic = Math.max(0, italic - 1)
|
||||
else if (tag === 'b' || tag === 'strong') bold = Math.max(0, bold - 1)
|
||||
continue
|
||||
}
|
||||
const id = attr(rawAttrs, 'id') ?? (tag === 'a' ? attr(rawAttrs, 'name') : null)
|
||||
if (id && onAnchor(id, flush)) { /* el capítulo nuevo ya vació lo pendiente */ }
|
||||
const cls = (attr(rawAttrs, 'class') || '').toLowerCase()
|
||||
const type = (attr(rawAttrs, 'epub:type') || '').toLowerCase()
|
||||
// Números de página de la edición impresa: ruido entre el texto.
|
||||
if (SKIP.has(tag) || /\bpage-?num(ber)?\b|\bpageno\b/.test(cls) || type.includes('pagebreak') || attr(rawAttrs, 'hidden') !== null) {
|
||||
if (!selfClosed && !VOID.has(tag)) { skip = 1; skipTag = tag }
|
||||
continue
|
||||
}
|
||||
if (tag === 'br') { runs.push(['\n', 0]); continue }
|
||||
if (tag === 'hr') { flush(); onBlock({ k: 'hr' }); continue }
|
||||
if (tag === 'img' || tag === 'image') {
|
||||
const src = attr(rawAttrs, 'src') ?? attr(rawAttrs, 'xlink:href') ?? attr(rawAttrs, 'href')
|
||||
const n = src != null && !/^[a-z]+:/i.test(src) ? imageOf(resolveHref(file, src)) : null
|
||||
if (n != null) { flush(); onBlock({ k: 'img', n }) }
|
||||
continue
|
||||
}
|
||||
if (BLOCK.has(tag)) {
|
||||
flush()
|
||||
const style = attr(rawAttrs, 'style') || ''
|
||||
const s = {
|
||||
tag,
|
||||
quote: tag === 'blockquote' ? 1 : 0,
|
||||
center: tag === 'center' || /\bcent(er|re)\b/.test(cls) || /text-align\s*:\s*center/i.test(style) ? 1 : 0,
|
||||
verse: tag === 'pre' || /\b(verse|poem|poetry|stanza|line-?block)\b/.test(cls) ? 1 : 0,
|
||||
italic: 0, bold: 0, heading: 0,
|
||||
}
|
||||
if (/^h[1-6]$/.test(tag)) { heading = Number(tag[1]); s.heading = 1 }
|
||||
quote += s.quote; center += s.center; verse += s.verse
|
||||
if (!selfClosed) stack.push(s)
|
||||
else { quote -= s.quote; center -= s.center; verse -= s.verse; if (s.heading) heading = 0 }
|
||||
continue
|
||||
}
|
||||
if (selfClosed) continue
|
||||
if (tag === 'i' || tag === 'em' || tag === 'cite' || tag === 'dfn') italic++
|
||||
else if (tag === 'b' || tag === 'strong') bold++
|
||||
else if (tag === 'td' || tag === 'th') { if (runs.length) runs.push([' ', 0]) }
|
||||
}
|
||||
if (!skip && pos < xml.length) {
|
||||
const text = entities(xml.slice(pos).replace(/<[^>]*$/, ''))
|
||||
if (text.trim()) runs.push([text, 0])
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
// Ancho y alto de una imagen PNG, GIF, JPEG o WebP, por su cabecera.
|
||||
function imageSize(b) {
|
||||
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) }
|
||||
if (b.length > 10 && b.toString('latin1', 0, 3) === 'GIF') return { w: b.readUInt16LE(6), h: b.readUInt16LE(8) }
|
||||
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
|
||||
for (let p = 2; p + 9 < b.length;) {
|
||||
if (b[p] !== 0xff) { p++; continue }
|
||||
const m = b[p + 1]
|
||||
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: b.readUInt16BE(p + 7), h: b.readUInt16BE(p + 5) }
|
||||
if (m === 0xff) { p++; continue }
|
||||
p += 2 + b.readUInt16BE(p + 2)
|
||||
}
|
||||
}
|
||||
if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
|
||||
const kind = b.toString('latin1', 12, 16)
|
||||
if (kind === 'VP8X') return { w: b.readUIntLE(24, 3) + 1, h: b.readUIntLE(27, 3) + 1 }
|
||||
if (kind === 'VP8 ') return { w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff }
|
||||
if (kind === 'VP8L') { const v = b.readUInt32LE(21); return { w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1 } }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// ---------- EPUB ----------
|
||||
|
||||
// Índice del libro en orden: [{ file, anchor, title }]. EPUB 3 (nav) o EPUB 2 (toc.ncx).
|
||||
function readToc(read, opf, manifest) {
|
||||
const navItem = [...manifest.values()].find(it => /\bnav\b/.test(it.props))
|
||||
if (navItem) {
|
||||
const xml = read(navItem.path)
|
||||
const nav = /<nav\b[^>]*(?:epub:)?type\s*=\s*["']toc["'][^>]*>([\s\S]*?)<\/nav>/i.exec(xml)?.[1]
|
||||
if (nav) {
|
||||
const out = []
|
||||
for (const m of nav.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
|
||||
const href = attr(m[1], 'href')
|
||||
if (!href) continue
|
||||
out.push({ file: resolveHref(navItem.path, href), anchor: href.split('#')[1] || null, title: clean(m[2]) })
|
||||
}
|
||||
if (out.length) return out
|
||||
}
|
||||
}
|
||||
const spineToc = /<spine\b[^>]*\btoc\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1]
|
||||
const ncx = manifest.get(spineToc) || [...manifest.values()].find(it => /ncx/.test(it.type))
|
||||
if (!ncx) return []
|
||||
const xml = read(ncx.path)
|
||||
const out = []
|
||||
// Solo la etiqueta y el contenido propios de cada entrada: los navPoint se anidan.
|
||||
for (const m of xml.matchAll(/<navPoint\b[^>]*>\s*<navLabel\b[^>]*>\s*<text\b[^>]*>([\s\S]*?)<\/text>\s*<\/navLabel>\s*<content\b([^>]*)\/?>/gi)) {
|
||||
const src = attr(m[2], 'src')
|
||||
if (src) out.push({ file: resolveHref(ncx.path, src), anchor: src.split('#')[1] || null, title: clean(m[1]) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
const clean = s => entities(String(s).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
|
||||
|
||||
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')
|
||||
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 read = name => readBuf(name).toString('utf8').replace(/^/, '')
|
||||
|
||||
const container = read('META-INF/container.xml')
|
||||
const opfPath = attr(/<rootfile\b([^>]*)>/i.exec(container)?.[1] || '', 'full-path')
|
||||
if (!opfPath) throw new Error('EPUB sin paquete')
|
||||
const opf = read(opfPath)
|
||||
const dc = name => clean(new RegExp(`<dc:${name}\\b[^>]*>([\\s\\S]*?)</dc:${name}>`, 'i').exec(opf)?.[1] || '')
|
||||
const manifest = new Map()
|
||||
for (const m of opf.matchAll(/<item\b([^>]*)>/gi)) {
|
||||
const id = attr(m[1], 'id'), href = attr(m[1], 'href')
|
||||
if (id && href) manifest.set(id, { path: resolveHref(opfPath, href), type: attr(m[1], 'media-type') || '', props: attr(m[1], 'properties') || '' })
|
||||
}
|
||||
const spine = [...opf.matchAll(/<itemref\b([^>]*)>/gi)].map(m => manifest.get(attr(m[1], 'idref'))).filter(it => it && /x?html/i.test(it.type))
|
||||
if (!spine.length) throw new Error('EPUB sin contenido')
|
||||
let toc = []
|
||||
try { toc = readToc(read, opf, manifest) } catch {}
|
||||
|
||||
const images = []
|
||||
const imageIdx = new Map()
|
||||
const imageOf = name => {
|
||||
const e = entries.get(name)
|
||||
if (!e || !BOOK_IMAGE.test(name) || e.unzipped > MAX_UNZIPPED_ENTRY) return null
|
||||
if (!imageIdx.has(name)) { imageIdx.set(name, images.length); images.push(name) }
|
||||
return imageIdx.get(name)
|
||||
}
|
||||
|
||||
// Las entradas del índice que apuntan a cada archivo. Un capítulo empieza en su ancla, o en el archivo si no tiene.
|
||||
const targets = new Map()
|
||||
for (const t of toc) {
|
||||
if (!targets.has(t.file)) targets.set(t.file, new Map())
|
||||
targets.get(t.file).set(t.anchor, t.title)
|
||||
}
|
||||
const chapters = []
|
||||
const start = title => chapters.push({ title, blocks: [] })
|
||||
for (const item of spine) {
|
||||
const here = targets.get(item.path)
|
||||
if (here?.has(null) || (!toc.length)) start(here?.get(null) || '')
|
||||
if (!chapters.length) start('')
|
||||
xhtmlToBlocks(read(item.path), item.path, imageOf,
|
||||
b => chapters.at(-1).blocks.push(b),
|
||||
(id, flush) => {
|
||||
if (!here?.has(id)) return false
|
||||
flush()
|
||||
start(here.get(id))
|
||||
here.delete(id) // un id repetido no abre otro capítulo
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Limpieza: fuera los vacíos; un capítulo que solo trae su título ("Parte primera") se une al siguiente.
|
||||
const merged = []
|
||||
let carry = null
|
||||
for (const c of chapters) {
|
||||
if (!c.blocks.length) continue
|
||||
const blocks = carry ? [...carry.blocks, ...c.blocks] : c.blocks
|
||||
const title = c.title || carry?.title || ''
|
||||
carry = null
|
||||
if (blocks.every(b => b.k === 'h' || b.k === 'hr') && c !== chapters.at(-1)) { carry = { title, blocks }; continue }
|
||||
merged.push({ title, blocks })
|
||||
}
|
||||
if (carry) merged.push(carry)
|
||||
const out = merged
|
||||
if (!out.length) throw new Error('EPUB sin texto')
|
||||
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) {
|
||||
for (const b of c.blocks) {
|
||||
if (b.k !== 'img') continue
|
||||
try { Object.assign(b, imageSize(readBuf(images[b.n]))) } catch {}
|
||||
}
|
||||
}
|
||||
const year = parseInt(dc('date'), 10)
|
||||
return {
|
||||
version: PARSER_VERSION,
|
||||
title: dc('title'), authors: [...opf.matchAll(/<dc:creator\b[^>]*>([\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,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Fuente y caché ----------
|
||||
export function setupBooks({ 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`)
|
||||
|
||||
// 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) {
|
||||
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}`)
|
||||
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.')
|
||||
await new Promise(r => setTimeout(r, 800 * i))
|
||||
}
|
||||
}
|
||||
const coverOf = (n, id) => coverUrl(`${GUTENBERG}/cache/epub/${n}/pg${n}.cover.medium.jpg`, id)
|
||||
// "Cervantes Saavedra, Miguel de" -> "Miguel de Cervantes Saavedra"
|
||||
const authorName = a => a.split(', ').reverse().join(' ')
|
||||
const entryText = (xml, tag) => clean(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`).exec(xml)?.[1] || '')
|
||||
function parseFeed(xml) {
|
||||
const out = []
|
||||
for (const [entry] of xml.matchAll(/<entry>[\s\S]*?<\/entry>/g)) {
|
||||
const n = /<id>[^<]*\/ebooks\/(\d+)\.opds<\/id>/.exec(entry)?.[1]
|
||||
if (!n) continue // "Ordenar por…" y otras entradas de navegación
|
||||
const id = `gb~${n}`
|
||||
// El buscador añade el idioma al título: "Las Fábulas de Esopo (Spanish)".
|
||||
const title = entryText(entry, 'title').replace(/\s*\([A-Z][a-z]+\)$/, '')
|
||||
out.push({ id, source: 'gb', title, cover: coverOf(n, id), sub: entryText(entry, 'content') || undefined })
|
||||
}
|
||||
return out
|
||||
}
|
||||
// lang del usuario ("es-la") a idioma del catálogo ("spanish").
|
||||
async function search(q, mode, page, lang) {
|
||||
const name = LANG_NAMES[String(lang || 'es').slice(0, 2)]
|
||||
const text = q.trim().replace(/\s+/g, ' ')
|
||||
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()))
|
||||
}
|
||||
|
||||
// Una sola descarga y conversión a la vez por libro.
|
||||
const loading = new Map()
|
||||
const memory = new Map() // últimos libros convertidos
|
||||
function remember(id, book) {
|
||||
memory.delete(id)
|
||||
memory.set(id, book)
|
||||
if (memory.size > 4) memory.delete(memory.keys().next().value)
|
||||
return book
|
||||
}
|
||||
async function downloadEpub(id) {
|
||||
const r = await gutenberg(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3)
|
||||
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')
|
||||
const tmp = `${epubFile(id)}.part`
|
||||
fs.writeFileSync(tmp, buf)
|
||||
fs.renameSync(tmp, epubFile(id))
|
||||
}
|
||||
function loadBook(id) {
|
||||
const hit = memory.get(id)
|
||||
if (hit) return Promise.resolve(remember(id, hit))
|
||||
if (loading.has(id)) return loading.get(id)
|
||||
const p = (async () => {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(jsonFile(id), 'utf8'))
|
||||
if (saved.version === PARSER_VERSION) return remember(id, saved)
|
||||
} catch {}
|
||||
if (!fs.existsSync(epubFile(id))) await downloadEpub(id)
|
||||
let book
|
||||
try { book = parseEpub(epubFile(id)) } catch (e) {
|
||||
console.warn(`EPUB ${id}: ${e.message}`)
|
||||
fs.rmSync(epubFile(id), { force: true }) // descarga mala: que el siguiente intento la repita
|
||||
throw new HttpError(502, 'No se pudo leer el EPUB de este libro')
|
||||
}
|
||||
const tmp = `${jsonFile(id)}.part`
|
||||
fs.writeFileSync(tmp, JSON.stringify(book))
|
||||
fs.renameSync(tmp, jsonFile(id))
|
||||
return remember(id, book)
|
||||
})().finally(() => loading.delete(id))
|
||||
loading.set(id, p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function manga(id) {
|
||||
const book = await loadBook(id)
|
||||
return {
|
||||
manga: {
|
||||
id, source: 'gb', type: 'Libro',
|
||||
title: book.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),
|
||||
},
|
||||
chapters: book.chapters.map((c, i) => ({ id: `${id}~${i}`, num: String(i + 1), volume: null, title: c.title, pages: null, group: '', publishAt: null })),
|
||||
}
|
||||
}
|
||||
async function chapter(chapterId) {
|
||||
const cut = chapterId.lastIndexOf('~')
|
||||
const book = await loadBook(chapterId.slice(0, cut))
|
||||
const c = book.chapters[Number(chapterId.slice(cut + 1))]
|
||||
if (!c) throw new HttpError(404, 'Ese capítulo no existe')
|
||||
return { title: c.title }
|
||||
}
|
||||
// El libro entero (lo maqueta el navegador), comprimido una sola vez: son megas de texto.
|
||||
const packed = new Map()
|
||||
async function sendText(req, res, id) {
|
||||
const book = await loadBook(id)
|
||||
let hit = packed.get(id)
|
||||
if (hit?.book !== book) {
|
||||
hit = { book, gz: zlib.gzipSync(JSON.stringify({ title: book.title, chapters: book.chapters })) }
|
||||
packed.delete(id)
|
||||
packed.set(id, hit)
|
||||
if (packed.size > 4) packed.delete(packed.keys().next().value)
|
||||
}
|
||||
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'private, no-cache', Vary: 'Accept-Encoding' }
|
||||
if (/\bgzip\b/.test(req.headers['accept-encoding'] || '')) { res.writeHead(200, { ...headers, 'Content-Encoding': 'gzip' }); return res.end(hit.gz) }
|
||||
res.writeHead(200, headers)
|
||||
res.end(zlib.gunzipSync(hit.gz))
|
||||
}
|
||||
async function sendImage(res, id, n) {
|
||||
const book = await loadBook(id)
|
||||
const name = book.images[n]
|
||||
const entry = name && readZipIndex(epubFile(id)).find(e => e.name === name)
|
||||
if (!entry) throw new HttpError(404, 'Imagen no encontrada')
|
||||
const data = readCbzEntry(epubFile(id), entry, MAX_UNZIPPED_ENTRY)
|
||||
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 }
|
||||
}
|
||||
+14
-8
@@ -56,8 +56,14 @@ export function writeCbz(file, entries) {
|
||||
fs.renameSync(tmp, file)
|
||||
}
|
||||
|
||||
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size }]
|
||||
// Devuelve las imágenes del CBZ ordenadas por nombre: [{ name, method, start, size, unzipped }]
|
||||
export function readCbzIndex(file) {
|
||||
return readZipIndex(file).filter(e => IMAGE_EXT.test(e.name) && !e.name.startsWith('__MACOSX'))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
// Todas las entradas del ZIP, en el orden del archivo (también lo usan los EPUB).
|
||||
export function readZipIndex(file) {
|
||||
const fd = fs.openSync(file, 'r')
|
||||
try {
|
||||
const { size } = fs.fstatSync(fd)
|
||||
@@ -65,7 +71,7 @@ export function readCbzIndex(file) {
|
||||
const tail = Buffer.alloc(tailLen)
|
||||
fs.readSync(fd, tail, 0, tailLen, size - tailLen)
|
||||
const eocd = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
|
||||
if (eocd < 0) throw new Error('CBZ inválido')
|
||||
if (eocd < 0) throw new Error('ZIP inválido')
|
||||
const count = tail.readUInt16LE(eocd + 10)
|
||||
const cdSize = tail.readUInt32LE(eocd + 12)
|
||||
const cdOffset = tail.readUInt32LE(eocd + 16)
|
||||
@@ -76,18 +82,18 @@ export function readCbzIndex(file) {
|
||||
for (let p = 0, i = 0; i < count; i++) {
|
||||
const method = cd.readUInt16LE(p + 10)
|
||||
const compSize = cd.readUInt32LE(p + 20)
|
||||
const unzipped = cd.readUInt32LE(p + 24)
|
||||
const nameLen = cd.readUInt16LE(p + 28)
|
||||
const extraLen = cd.readUInt16LE(p + 30)
|
||||
const commentLen = cd.readUInt16LE(p + 32)
|
||||
const localOffset = cd.readUInt32LE(p + 42)
|
||||
const name = cd.toString('utf8', p + 46, p + 46 + nameLen)
|
||||
p += 46 + nameLen + extraLen + commentLen
|
||||
if (!IMAGE_EXT.test(name) || name.startsWith('__MACOSX')) continue
|
||||
fs.readSync(fd, lh, 0, 30, localOffset)
|
||||
const start = localOffset + 30 + lh.readUInt16LE(26) + lh.readUInt16LE(28)
|
||||
entries.push({ name, method, start, size: compSize })
|
||||
entries.push({ name, method, start, size: compSize, unzipped })
|
||||
}
|
||||
return entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
|
||||
return entries
|
||||
} finally {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
@@ -98,13 +104,13 @@ export function imageType(name) {
|
||||
return { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' }[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
// Lee una entrada completa del CBZ a memoria.
|
||||
export function readCbzEntry(file, entry) {
|
||||
// Lee una entrada completa del CBZ a memoria. maxBytes corta una entrada que al descomprimirse crezca de más.
|
||||
export function readCbzEntry(file, entry, maxBytes) {
|
||||
if (entry.method !== 0 && entry.method !== 8) throw new Error(`Compresión ZIP no soportada (${entry.method})`)
|
||||
const fd = fs.openSync(file, 'r')
|
||||
const buf = Buffer.alloc(entry.size)
|
||||
try { fs.readSync(fd, buf, 0, entry.size, entry.start) } finally { fs.closeSync(fd) }
|
||||
return entry.method === 8 ? zlib.inflateRawSync(buf) : buf
|
||||
return entry.method === 8 ? zlib.inflateRawSync(buf, maxBytes ? { maxOutputLength: maxBytes } : {}) : buf
|
||||
}
|
||||
|
||||
// Envía una entrada del CBZ por la respuesta HTTP.
|
||||
|
||||
@@ -218,6 +218,7 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
|
||||
if (total && page >= total - 1) {
|
||||
db.prepare('INSERT OR IGNORE INTO chapter_done (user_id, chapter_id, manga_id, day) VALUES (?, ?, ?, ?)').run(userId, b.chapterId, b.mangaId, day)
|
||||
}
|
||||
if (/^(gb|ws|ub)~/.test(b.mangaId)) return // un libro no tiene capítulos nuevos que seguir
|
||||
// Seguir automáticamente lo que se empieza a leer (salvo que se haya dejado de seguir).
|
||||
db.prepare('INSERT OR IGNORE INTO follows (user_id, manga_id, title, cover, lang, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(userId, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null,
|
||||
|
||||
+596
-49
@@ -50,8 +50,14 @@ const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="cu
|
||||
// Iconos del menú superior (index.html).
|
||||
document.querySelectorAll('#topbar nav a[data-icon]').forEach(a => { a.innerHTML = `${icon(a.dataset.icon)}<span>${a.textContent}</span>` })
|
||||
|
||||
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)' }
|
||||
const NO_CATS = new Set(['mi']) // fuentes sin listado por categorías
|
||||
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)', gb: 'Project Gutenberg (libros)' }
|
||||
const NO_CATS = new Set(['mi', 'gb']) // fuentes sin listado por categorías
|
||||
const BOOK_SOURCES = new Set(['gb'])
|
||||
const isBookId = id => /^(gb|ws|ub)~/.test(id || '')
|
||||
// Ajuste del usuario: ver solo manga, solo libros o ambos. Solo filtra lo que se ofrece; un enlace directo sigue abriendo.
|
||||
const contentMode = () => me?.content_mode || 'both'
|
||||
const wantsId = id => contentMode() === 'both' || (contentMode() === 'books') === isBookId(id)
|
||||
const visibleSources = () => Object.entries(SOURCES).filter(([k]) => contentMode() === 'both' || (contentMode() === 'books') === BOOK_SOURCES.has(k))
|
||||
// Filtros de imagen del lector (CSS filter); el brillo se suma aparte.
|
||||
const FILTERS = {
|
||||
none: ['Sin filtro', ''],
|
||||
@@ -431,6 +437,7 @@ function continueCard(h) {
|
||||
}
|
||||
// Sección "Novedades": mangas seguidos con capítulos nuevos.
|
||||
function newsSection(fol) {
|
||||
fol = { ...fol, follows: fol.follows.filter(f => wantsId(f.manga_id)) }
|
||||
const fresh = fol.follows.filter(f => f.newCount > 0)
|
||||
const status = fol.checking ? 'Comprobando…' : fol.lastCheckAt ? `Última comprobación ${timeAgo(fol.lastCheckAt)}` : `Se comprueba cada ${fol.intervalHours} h`
|
||||
if (!fol.follows.length) return ''
|
||||
@@ -484,7 +491,8 @@ async function viewHomeOffline(params) {
|
||||
<h2>${icon('download')} Descargado en el servidor</h2>
|
||||
<form class="search"><input type="search" name="q" placeholder="Buscar en lo descargado…" value="${esc(params.get('q') || '')}" autocomplete="off"></form>
|
||||
<div id="results" class="loading">Cargando…</div>`
|
||||
const [list, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
|
||||
const [all, hist] = await Promise.all([api('/api/search?q='), api('/api/history').catch(() => [])])
|
||||
const list = all.filter(m => wantsId(m.id))
|
||||
const have = new Set(list.map(m => m.id))
|
||||
const cont = hist.filter(h => !h.finished && have.has(h.manga_id))
|
||||
if (cont.length) $('#cont').innerHTML = `<h2>Continuar leyendo</h2><div class="continue">${cont.slice(0, 12).map(continueCard).join('')}</div>`
|
||||
@@ -517,7 +525,9 @@ async function viewHome(params) {
|
||||
const tag = params.get('tag') || ''
|
||||
const tagName = params.get('tagName') || tag
|
||||
const anyLang = params.get('any') === '1'
|
||||
const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc')
|
||||
const shownSources = visibleSources()
|
||||
const isShown = k => shownSources.some(([s]) => s === k)
|
||||
const source = isShown(params.get('source')) ? params.get('source') : isShown(store.get('source', 'lc')) ? store.get('source', 'lc') : shownSources[0][0]
|
||||
let mode = tag ? 'cats' : params.get('mode') || 'popular'
|
||||
if (mode === 'cats' && NO_CATS.has(source)) mode = 'popular'
|
||||
store.set('source', source)
|
||||
@@ -525,12 +535,12 @@ async function viewHome(params) {
|
||||
app.innerHTML = `
|
||||
<section id="cont"></section>
|
||||
<section id="recs"></section>
|
||||
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
|
||||
<div class="tabs" id="sources">${shownSources.map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}" title="${esc(v)}">${esc(v.replace(/\s*\(.*\)$/, ''))}</button>`).join('')}</div>
|
||||
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
|
||||
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
|
||||
${q && !tag ? `<h2>Resultados para “${esc(q)}”</h2>` : `<div class="tabs" id="modes">
|
||||
<button data-m="popular" class="${mode === 'popular' ? 'on' : ''}">${icon('flame')} Populares</button>
|
||||
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} Actualizados recientemente</button>
|
||||
<button data-m="latest" class="${mode === 'latest' ? 'on' : ''}">${icon('clock')} ${BOOK_SOURCES.has(source) ? 'Añadidos recientemente' : 'Actualizados recientemente'}</button>
|
||||
${NO_CATS.has(source) ? '' : `<button data-m="cats" class="${mode === 'cats' ? 'on' : ''}">${icon('grid')} Categorías</button>`}</div>`}
|
||||
${tag ? `<h2>Categoría: <a class="tag on" href="#/?source=${source}&mode=cats" title="Quitar filtro">${esc(tagName)} ✕</a>${q ? ` · “${esc(q)}”` : ''}</h2>` : ''}
|
||||
<div id="results" class="loading">Cargando…</div>
|
||||
@@ -548,10 +558,10 @@ async function viewHome(params) {
|
||||
if (!q && !tag && !showCats) {
|
||||
Promise.all([api('/api/history'), api('/api/follows')]).then(([hist, fol]) => {
|
||||
if (!$('#cont')) return
|
||||
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
|
||||
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished && wantsId(h.manga_id)), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
|
||||
bindNews(fol)
|
||||
}).catch(() => {})
|
||||
loadRecs()
|
||||
if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga
|
||||
}
|
||||
|
||||
const res = $('#results')
|
||||
@@ -564,7 +574,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' || !!tag || (source === 'mi' && mode === 'latest' && !q)
|
||||
const pageable = source === 'md' || BOOK_SOURCES.has(source) || !!tag || (source === 'mi' && mode === 'latest' && !q)
|
||||
const seen = new Set()
|
||||
let page = 1
|
||||
const load = async () => {
|
||||
@@ -650,6 +660,7 @@ async function viewManga(id, langOverride) {
|
||||
let isFollowing = following
|
||||
const readMap = new Map(reads.map(r => [r.chapter_id, r]))
|
||||
const isLc = manga.source !== 'md' // sitios en español sin selector de idioma
|
||||
const isBook = isBookId(id)
|
||||
const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean)
|
||||
const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}`
|
||||
const altSearchMi = `#/?source=mi&q=${encodeURIComponent(manga.title)}`
|
||||
@@ -663,8 +674,8 @@ async function viewManga(id, langOverride) {
|
||||
if (first && !mainBtn) mainBtn = `<a class="btn primary" href="${readUrl(id, first.id)}">${icon('book')} Empezar a leer</a>`
|
||||
|
||||
const menuItems = [
|
||||
manga.source === 'lc' ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
manga.source === 'mi' ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
||||
manga.source === 'lc' || isBook ? '' : `<a class="btn" href="${altSearch}">${icon('search')} Buscar en LeerCapitulo</a>`,
|
||||
manga.source === 'mi' || isBook ? '' : `<a class="btn" href="${altSearchMi}">${icon('search')} Buscar en M440</a>`,
|
||||
progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
|
||||
].join('')
|
||||
|
||||
@@ -697,7 +708,7 @@ async function viewManga(id, langOverride) {
|
||||
${clipCount ? `<a class="btn" href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}
|
||||
${menuItems}
|
||||
</div>
|
||||
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
|
||||
<div class="libpanel" ${isBook ? 'style="display:none"' : ''}><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
|
||||
<div class="libpanel" id="mdata"><div class="psec"><div class="psec-title">${icon('book')} Datos de la obra</div><dl class="kv" id="mkv"></dl></div></div>
|
||||
</aside>
|
||||
<div class="list-head">
|
||||
@@ -735,7 +746,7 @@ async function viewManga(id, langOverride) {
|
||||
<a class="tag" href="https://anilist.co/search/manga?search=${encodeURIComponent(manga.title)}" target="_blank" rel="noopener">AniList</a>.</p>` : '')
|
||||
}
|
||||
paintInfo(savedInfo)
|
||||
if (!savedInfo) {
|
||||
if (!savedInfo && !isBook) {
|
||||
$('#mextra').innerHTML = '<p class="muted note">Buscando información de la obra…</p>'
|
||||
api(`/api/meta/${id}`).then(paintInfo, () => { if (synBox.isConnected) $('#mextra').innerHTML = '' })
|
||||
}
|
||||
@@ -1116,7 +1127,7 @@ async function viewManga(id, langOverride) {
|
||||
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
|
||||
b.classList.toggle('on', isFollowing)
|
||||
b.title = isFollowing ? 'Recibes avisos de capítulos nuevos. Pulsa para dejar de seguir.' : 'Avisarme cuando salgan capítulos nuevos'
|
||||
b.hidden = offline
|
||||
b.hidden = offline || isBook
|
||||
}
|
||||
renderFollow()
|
||||
$('#follow').addEventListener('click', async () => {
|
||||
@@ -1224,7 +1235,7 @@ async function viewManga(id, langOverride) {
|
||||
updateSummary()
|
||||
})
|
||||
renderList()
|
||||
api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
|
||||
if (!isBook) api(`/api/meta/${id}/dates`).then(r => { extDates = r.dates || {}; if (Object.keys(extDates).length) { buildDates(); renderList() } }, () => {})
|
||||
renderDl(download)
|
||||
renderHd(upscale)
|
||||
renderOff()
|
||||
@@ -1330,12 +1341,418 @@ function turnSound(level, pan = 0) {
|
||||
}
|
||||
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
|
||||
function defaultPrefs(manga) {
|
||||
if (manga.type === 'Libro') return { mode: 'paged', fit: 'height', dir: 'ltr' }
|
||||
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
|
||||
if (manga.type === 'Manga') return { mode: 'paged', fit: 'height', dir: 'rtl' }
|
||||
const legacy = store.get('readerSettings', {})
|
||||
return { mode: READ_MODES[legacy.mode] ? legacy.mode : 'paged', fit: legacy.fit === 'width' ? 'width' : 'height', dir: legacy.dir === 'rtl' ? 'rtl' : 'ltr' }
|
||||
}
|
||||
|
||||
// ---------- Libros: el libro entero se maqueta como un texto continuo (cada capítulo empieza justo debajo del
|
||||
// anterior) en páginas del tamaño de la pantalla, y cada página se dibuja en un canvas. Al lector le llega como
|
||||
// una imagen más, así que hoja, sombra, hendidura y sonido no cambian.
|
||||
const BOOK_DEFAULTS = { size: 100, font: 'serif', margin: 'mid', paper: 'light' }
|
||||
const BOOK_FONTS = { serif: "Georgia, 'Iowan Old Style', 'Noto Serif', 'Times New Roman', serif", sans: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" }
|
||||
const BOOK_PAPERS = { light: { bg: '#fbf9f3', fg: '#22201c', dim: '#8b867b' }, sepia: { bg: '#f3e9d3', fg: '#3a2f21', dim: '#8f7f66' }, dark: { bg: '#1b1a18', fg: '#d8d4ca', dim: '#7c786f' } }
|
||||
const BOOK_MARGINS = { small: 0.055, mid: 0.085, large: 0.12 }
|
||||
const BOOK_KEEP_PAGES = 16 // páginas dibujadas que se conservan a cada lado de la actual
|
||||
const bookSettings = () => ({ ...BOOK_DEFAULTS, ...store.get('bookSettings', {}) })
|
||||
let bookResume = null // al volver a maquetar (rotar, cambiar la letra): seguir en la misma palabra
|
||||
let bookCache = null // { id, text } el último libro, para no pedirlo otra vez al reabrir
|
||||
let bookLayoutCache = null // { key, layout } y su maquetado, para esta pantalla y esta letra
|
||||
|
||||
// El libro como una sola lista de bloques. Cada capítulo lleva su división y su título, aunque el texto no lo traiga.
|
||||
function bookFlatten(chapters) {
|
||||
const flat = [], chapBlock = []
|
||||
chapters.forEach((c, i) => {
|
||||
chapBlock.push(flat.length)
|
||||
if (i > 0) flat.push({ k: 'div' })
|
||||
if (c.blocks[0]?.k !== 'h' && !/^Sección \d+$/.test(c.title)) flat.push({ k: 'h', l: 2, r: [[c.title, 0]] })
|
||||
for (const b of c.blocks) flat.push(b)
|
||||
})
|
||||
return { flat, chapBlock }
|
||||
}
|
||||
|
||||
// Una primera pasada solo busca dónde empieza cada página; lo que se dibuja se calcula al pedir esa página. Así un
|
||||
// libro de miles de páginas no ocupa memoria con la posición de cada palabra.
|
||||
function makeBookLayout(text, W, H, opts) {
|
||||
const { flat, chapBlock } = bookFlatten(text.chapters)
|
||||
const measure = document.createElement('canvas').getContext('2d')
|
||||
const family = BOOK_FONTS[opts.font] || BOOK_FONTS.serif
|
||||
const px = Math.min(22, Math.max(15, W / 24)) * (opts.size / 100)
|
||||
const lineH = Math.round(px * 1.45)
|
||||
const marginX = Math.round(W * (BOOK_MARGINS[opts.margin] || BOOK_MARGINS.mid))
|
||||
const top = Math.round(Math.max(px * 2.6, 30)), bottom = Math.round(H - Math.max(px * 2.6, 30))
|
||||
const cw = W - marginX * 2
|
||||
const widths = new Map()
|
||||
const fontOf = (size, style, forceBold) => `${style & 1 ? 'italic ' : ''}${style & 2 || forceBold ? 'bold ' : ''}${size}px ${family}`
|
||||
const widthOf = (t, font) => {
|
||||
const key = `${font}|${t}`
|
||||
let w = widths.get(key)
|
||||
if (w === undefined) {
|
||||
if (measure.font !== font) measure.font = font
|
||||
w = measure.measureText(t).width
|
||||
widths.set(key, w)
|
||||
}
|
||||
return w
|
||||
}
|
||||
// Un bloque en líneas: cada línea son cajas (palabras, que pueden cambiar de estilo por dentro).
|
||||
function breakLines(runs, width, indent, size, forceBold) {
|
||||
const spaceW = widthOf(' ', fontOf(size, 0, forceBold))
|
||||
const lines = []
|
||||
let line = { boxes: [], w: 0, hard: false }
|
||||
let box = null, space = false
|
||||
const room = () => width - (lines.length ? 0 : indent)
|
||||
const endLine = hard => { line.hard = hard; lines.push(line); line = { boxes: [], w: 0, hard: false } }
|
||||
const place = b => {
|
||||
if (line.boxes.length && line.w + spaceW + b.w > room()) endLine(false)
|
||||
line.w += (line.boxes.length ? spaceW : 0) + b.w
|
||||
line.boxes.push(b)
|
||||
}
|
||||
const flushBox = () => {
|
||||
if (!box) return
|
||||
// Una palabra más ancha que la línea se corta por letras.
|
||||
if (box.w > room() && box.frags.length === 1) {
|
||||
const { font } = box.frags[0]
|
||||
let cur = ''
|
||||
for (const ch of Array.from(box.frags[0].t)) {
|
||||
if (cur && widthOf(cur + ch, font) > width) {
|
||||
place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) })
|
||||
endLine(false)
|
||||
cur = ''
|
||||
}
|
||||
cur += ch
|
||||
}
|
||||
if (cur) place({ frags: [{ t: cur, font, w: widthOf(cur, font) }], w: widthOf(cur, font) })
|
||||
} else place(box)
|
||||
box = null
|
||||
}
|
||||
for (const [t0, style] of runs) {
|
||||
const font = fontOf(size, style, forceBold)
|
||||
for (const m of t0.matchAll(/\n| +|[^ \n]+/g)) {
|
||||
const t = m[0]
|
||||
if (t === '\n') { flushBox(); space = false; endLine(true) }
|
||||
else if (t[0] === ' ') { flushBox(); space = true }
|
||||
else {
|
||||
if (space) { flushBox(); space = false }
|
||||
const w = widthOf(t, font)
|
||||
box ??= { frags: [], w: 0 }
|
||||
box.frags.push({ t, font, w })
|
||||
box.w += w
|
||||
}
|
||||
}
|
||||
}
|
||||
flushBox()
|
||||
if (line.boxes.length) endLine(true)
|
||||
return { lines, spaceW }
|
||||
}
|
||||
const headSize = b => Math.round(px * ({ 1: 1.5, 2: 1.35, 3: 1.2 }[b.l] || 1.08))
|
||||
const headLines = b => { const size = headSize(b), height = Math.round(size * 1.3); return { ...breakLines(b.r, cw, 0, size, true), height } }
|
||||
|
||||
const pages = [{ block: 0, line: 0, word: 0, after: true }]
|
||||
const chapStarts = [0]
|
||||
// Recorre el libro desde el inicio de una página. Sin items, anota dónde empieza cada página; con items, recoge lo
|
||||
// que hay que dibujar en esa página y termina.
|
||||
function* walk(cp, items) {
|
||||
let y = top, used = false, words = cp.word, after = cp.after
|
||||
const newPage = (block, line) => {
|
||||
if (items) return true
|
||||
pages.push({ block, line, word: words, after })
|
||||
y = top
|
||||
used = false
|
||||
return false
|
||||
}
|
||||
const remaining = () => bottom - y
|
||||
const placeLine = (l, { x, width, indent, center, justify, spaceW, height }) => {
|
||||
if (items) {
|
||||
const avail = width - (center ? 0 : indent)
|
||||
let gap = spaceW
|
||||
if (justify && !l.hard && l.boxes.length > 1) {
|
||||
const stretched = spaceW + (avail - l.w) / (l.boxes.length - 1)
|
||||
if (stretched < spaceW + px * 1.2) gap = stretched // una línea muy suelta se queda sin justificar
|
||||
}
|
||||
let cx = center ? x + (width - l.w) / 2 : x + indent
|
||||
const segs = []
|
||||
for (const b of l.boxes) {
|
||||
for (const f of b.frags) { segs.push({ t: f.t, font: f.font, x: cx }); cx += f.w }
|
||||
cx += gap
|
||||
}
|
||||
items.push({ line: segs, y: y + height * 0.78 })
|
||||
}
|
||||
y += height
|
||||
words += l.boxes.length
|
||||
used = true
|
||||
}
|
||||
for (let b = cp.block; b < flat.length; b++) {
|
||||
if (!items && !(b & 127)) yield
|
||||
const block = flat[b]
|
||||
const from = b === cp.block ? cp.line : 0
|
||||
if (block.k === 'div') {
|
||||
// División de capítulo: el capítulo sigue en la misma página si caben la línea, su título y algo de texto.
|
||||
const nx = flat[b + 1]
|
||||
let need = lineH * 5
|
||||
if (nx?.k === 'h') { const h = headLines(nx); need += h.lines.length * h.height }
|
||||
if (used && remaining() < need && newPage(b, 0)) return
|
||||
if (!items) chapStarts.push(pages.length - 1)
|
||||
if (used) {
|
||||
y += lineH * 1.2
|
||||
items?.push({ rule: true, y })
|
||||
y += lineH * 0.6
|
||||
}
|
||||
after = true
|
||||
continue
|
||||
}
|
||||
if (block.k === 'hr') {
|
||||
if (remaining() < lineH * 3 && used && newPage(b, 0)) return
|
||||
y += lineH * 0.7
|
||||
items?.push({ rule: true, y })
|
||||
y += lineH * 0.8
|
||||
used = true
|
||||
after = true
|
||||
continue
|
||||
}
|
||||
if (block.k === 'img') {
|
||||
if (!block.w || !block.h) continue
|
||||
const fit = Math.min(cw / block.w, (bottom - top) / block.h, 2)
|
||||
const w = block.w * fit, h = block.h * fit
|
||||
if (h > remaining() && used && newPage(b, 0)) return
|
||||
items?.push({ n: block.n, x: (W - w) / 2, y, w, h })
|
||||
y += h + lineH * 0.5
|
||||
used = true
|
||||
after = true
|
||||
continue
|
||||
}
|
||||
if (block.k === 'h') {
|
||||
const { lines, spaceW, height } = headLines(block)
|
||||
if (!from) {
|
||||
const need = lines.length * height + lineH * 3 + (used ? lineH * 1.2 : 0)
|
||||
if (used && remaining() < need && newPage(b, 0)) return // el título no se queda solo al pie
|
||||
if (used) y += lineH * 1.2
|
||||
}
|
||||
for (let i = from; i < lines.length; i++) {
|
||||
if (remaining() < height && used && newPage(b, i)) return
|
||||
placeLine(lines[i], { x: marginX, width: cw, indent: 0, center: true, justify: false, spaceW, height })
|
||||
}
|
||||
y += lineH * 0.8
|
||||
after = true
|
||||
continue
|
||||
}
|
||||
const quote = block.k === 'q'
|
||||
const verse = !!block.v
|
||||
const center = !!block.c
|
||||
const inset = quote ? Math.round(px * 1.4) : 0
|
||||
const width = cw - inset * 2
|
||||
const indent = !after && !quote && !verse && !center ? Math.round(px * 1.3) : 0
|
||||
const { lines, spaceW } = breakLines(block.r, width, indent, px, false)
|
||||
if (quote && used && !from) y += lineH * 0.3
|
||||
for (let i = from; i < lines.length;) {
|
||||
const fits = Math.floor(remaining() / lineH)
|
||||
let k = Math.min(fits, lines.length - i)
|
||||
if (!verse) {
|
||||
// Ni una primera línea sola al pie ni una última línea sola arriba de la página siguiente.
|
||||
if (i === 0 && k === 1 && lines.length > 1) k = 0
|
||||
else if (i + k === lines.length - 1) k = k >= 2 ? k - 1 : 0
|
||||
}
|
||||
if (k <= 0) {
|
||||
if (!used) k = Math.max(1, Math.min(fits, lines.length - i)) // no cabe ni en una página vacía: no dar vueltas
|
||||
else { if (newPage(b, i)) return; continue }
|
||||
}
|
||||
for (let n = 0; n < k; n++) placeLine(lines[i + n], { x: marginX + inset, width, indent: i + n === 0 ? indent : 0, center, justify: !verse && !center, spaceW, height: lineH })
|
||||
i += k
|
||||
if (i < lines.length && newPage(b, i)) return
|
||||
}
|
||||
if (quote) y += lineH * 0.3
|
||||
after = quote
|
||||
}
|
||||
}
|
||||
return {
|
||||
pages, chapStarts, chapBlock, flat, top, cw, family, footPx: Math.max(11, Math.round(px * 0.68)),
|
||||
// Reparte el libro cediendo el paso de vez en cuando, para que la pantalla no se quede congelada.
|
||||
async build() {
|
||||
let t = performance.now()
|
||||
for (const _ of walk(pages[0], null)) {
|
||||
if (performance.now() - t > 40) { await new Promise(r => setTimeout(r)); t = performance.now() }
|
||||
}
|
||||
},
|
||||
capture(i) {
|
||||
const items = []
|
||||
const run = walk(pages[i], items)
|
||||
while (!run.next().done);
|
||||
return items
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBookText(mangaId) {
|
||||
if (bookCache?.id === mangaId) return bookCache.text
|
||||
const text = await api(`/api/book/${mangaId}/text`)
|
||||
bookCache = { id: mangaId, text }
|
||||
return text
|
||||
}
|
||||
|
||||
async function createBook(text, mangaId, size) {
|
||||
const opts = bookSettings()
|
||||
const colors = BOOK_PAPERS[opts.paper] || BOOK_PAPERS.light
|
||||
const { w: W, h: H } = size
|
||||
const key = JSON.stringify([mangaId, W, H, opts])
|
||||
let layout = bookLayoutCache?.key === key ? bookLayoutCache.layout : null
|
||||
if (!layout) {
|
||||
layout = makeBookLayout(text, W, H, opts)
|
||||
await layout.build()
|
||||
bookLayoutCache = { key, layout }
|
||||
}
|
||||
const { pages, chapStarts, chapBlock, flat } = layout
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 3, 4096 / Math.max(W, H))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.round(W * dpr)
|
||||
canvas.height = Math.round(H * dpr)
|
||||
const ctx = canvas.getContext('2d', { alpha: false })
|
||||
const urls = new Map()
|
||||
const waiting = new Map()
|
||||
const queue = []
|
||||
const images = new Map() // n -> { im, ready }
|
||||
let released = false, timer = 0
|
||||
const imageOf = n => {
|
||||
let e = images.get(n)
|
||||
if (!e) {
|
||||
const im = new Image()
|
||||
e = { im, ok: false, ready: new Promise(r => { im.onload = () => { e.ok = im.naturalWidth > 0; e.failed = !e.ok; r() }; im.onerror = () => { e.failed = true; r() } }) }
|
||||
images.set(n, e)
|
||||
im.src = `/api/book/${mangaId}/img/${n}`
|
||||
}
|
||||
return e
|
||||
}
|
||||
const chapterOfBlock = block => {
|
||||
let lo = 0, hi = chapBlock.length - 1
|
||||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapBlock[mid] <= block) lo = mid; else hi = mid - 1 }
|
||||
return lo
|
||||
}
|
||||
function draw(i, items) {
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.fillStyle = colors.bg
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillStyle = colors.fg
|
||||
let font = ''
|
||||
for (const it of items) {
|
||||
if (it.line) {
|
||||
for (const s of it.line) {
|
||||
if (s.font !== font) { ctx.font = font = s.font }
|
||||
ctx.fillText(s.t, s.x, it.y)
|
||||
}
|
||||
} else if (it.rule) {
|
||||
ctx.fillStyle = colors.dim
|
||||
ctx.fillRect(W / 2 - W * 0.08, it.y, W * 0.16, 1)
|
||||
ctx.fillStyle = colors.fg
|
||||
} else {
|
||||
const e = imageOf(it.n)
|
||||
if (e.ok) ctx.drawImage(e.im, it.x, it.y, it.w, it.h)
|
||||
}
|
||||
}
|
||||
// Título del capítulo arriba (salvo donde empieza) y número abajo: guías de lectura, no parte del texto.
|
||||
ctx.fillStyle = colors.dim
|
||||
ctx.textAlign = 'center'
|
||||
ctx.font = `${layout.footPx}px ${layout.family}`
|
||||
const p = pages[i]
|
||||
const c = chapterOfBlock(p.block)
|
||||
const title = text.chapters[c].title
|
||||
const atStart = p.line === 0 && (p.block === chapBlock[c] || (p.block === chapBlock[c] + 1 && flat[p.block].k === 'h'))
|
||||
if (!atStart && title && !/^Sección \d+$/.test(title)) {
|
||||
let t = title
|
||||
if (ctx.measureText(t).width > layout.cw) {
|
||||
while (t && ctx.measureText(`${t}…`).width > layout.cw) t = t.slice(0, -1)
|
||||
t += '…'
|
||||
}
|
||||
ctx.fillText(t, W / 2, layout.top - layout.footPx * 1.2)
|
||||
}
|
||||
ctx.fillText(`${i + 1} / ${pages.length}`, W / 2, H - layout.footPx * 1.4)
|
||||
}
|
||||
const pendingImages = items => items.filter(it => it.n != null && !images.get(it.n)?.ok && !images.get(it.n)?.failed).map(it => imageOf(it.n).ready)
|
||||
const settle = (i, url) => {
|
||||
if (!urls.has(i)) urls.set(i, url)
|
||||
for (const cb of waiting.get(i) || []) cb(urls.get(i))
|
||||
waiting.delete(i)
|
||||
}
|
||||
const forget = i => {
|
||||
const u = urls.get(i)
|
||||
if (u?.startsWith('blob:')) URL.revokeObjectURL(u)
|
||||
urls.delete(i)
|
||||
}
|
||||
async function tick() {
|
||||
timer = 0
|
||||
if (released || !queue.length) return
|
||||
if (book.busy?.()) { timer = setTimeout(tick, 150); return } // no dibujar mientras gira la hoja
|
||||
const i = queue.shift()
|
||||
if (urls.has(i)) { settle(i, urls.get(i)); timer = setTimeout(tick); return }
|
||||
const items = layout.capture(i)
|
||||
timer = -1 // ocupado: que nadie arranque otra vuelta mientras llegan las imágenes
|
||||
await Promise.all(pendingImages(items))
|
||||
if (released) return
|
||||
draw(i, items)
|
||||
canvas.toBlob(blob => {
|
||||
if (released) return
|
||||
if (blob && !urls.has(i)) settle(i, URL.createObjectURL(blob))
|
||||
else settle(i, urls.get(i) ?? book.src(i))
|
||||
timer = setTimeout(tick, 16)
|
||||
}, 'image/png')
|
||||
}
|
||||
const book = {
|
||||
count: pages.length, W, H, busy: null, onChange: null,
|
||||
// Síncrono: la página hace falta ya (la URL no cambia una vez entregada; identifica su textura). Si aún no han
|
||||
// llegado sus imágenes, se dibuja sin ellas y se avisa para cambiarla al llegar.
|
||||
src(i) {
|
||||
if (urls.has(i)) return urls.get(i)
|
||||
const items = layout.capture(i)
|
||||
const pending = pendingImages(items)
|
||||
draw(i, items)
|
||||
urls.set(i, canvas.toDataURL('image/png'))
|
||||
if (waiting.has(i)) settle(i, urls.get(i))
|
||||
if (pending.length) {
|
||||
Promise.all(pending).then(() => {
|
||||
if (released || !urls.has(i)) return
|
||||
urls.delete(i)
|
||||
book.onChange?.(i)
|
||||
})
|
||||
}
|
||||
return urls.get(i)
|
||||
},
|
||||
// Asíncrono: se dibuja cuando haya un momento libre, en el orden en que se pidió.
|
||||
whenReady(i, cb) {
|
||||
if (urls.has(i)) return cb(urls.get(i))
|
||||
if (!waiting.has(i)) { waiting.set(i, []); queue.push(i) }
|
||||
waiting.get(i).push(cb)
|
||||
if (!timer) timer = setTimeout(tick)
|
||||
},
|
||||
// Lo dibujado lejos de la página actual se suelta; si se vuelve, se dibuja otra vez.
|
||||
trim(center) {
|
||||
for (const i of [...urls.keys()]) if (Math.abs(i - center) > BOOK_KEEP_PAGES) forget(i)
|
||||
},
|
||||
near: (i, center) => Math.abs(i - center) <= BOOK_KEEP_PAGES,
|
||||
wordAt: i => pages[Math.min(i, pages.length - 1)]?.word ?? 0,
|
||||
pageAtWord(word) {
|
||||
let lo = 0, hi = pages.length - 1
|
||||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (pages[mid].word <= word) lo = mid; else hi = mid - 1 }
|
||||
return lo
|
||||
},
|
||||
chapterStart: c => chapStarts[c] ?? 0,
|
||||
// Si varios capítulos empiezan en una página, cuenta el último: ahí es donde sigue la lectura.
|
||||
chapterAt(p) {
|
||||
let lo = 0, hi = chapStarts.length - 1
|
||||
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (chapStarts[mid] <= p) lo = mid; else hi = mid - 1 }
|
||||
return lo
|
||||
},
|
||||
chapterPages: c => Math.max(1, (chapStarts[c + 1] ?? pages.length) - chapStarts[c]),
|
||||
release() {
|
||||
released = true
|
||||
clearTimeout(timer)
|
||||
for (const i of [...urls.keys()]) forget(i)
|
||||
canvas.width = canvas.height = 0
|
||||
},
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
async function viewReader(mangaId, chapterId, startPage) {
|
||||
document.body.classList.add('reading')
|
||||
app.innerHTML = '<div class="reader"><div class="loading">Cargando capítulo…</div></div>'
|
||||
@@ -1353,17 +1770,54 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' }
|
||||
const prev = idx > 0 ? chapters[idx - 1] : null
|
||||
const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null
|
||||
const pages = chap.pages
|
||||
const total = pages.length
|
||||
let page = Math.min(Math.max(startPage >= 0 ? startPage : chap.savedPage || 0, 0), total - 1)
|
||||
let shown = [page] // páginas visibles (2 en doble página)
|
||||
const isPaged = () => settings.mode !== 'vertical'
|
||||
const isDouble = () => settings.mode === 'double' && window.innerWidth >= 700 && window.innerWidth > window.innerHeight
|
||||
// Libro: las páginas salen de maquetar el texto para esta pantalla (con forma de página, para que nunca cuenten como anchas).
|
||||
const isBook = !!chap.text
|
||||
const noun = isBook ? 'libro' : 'manga'
|
||||
const bookSize = () => {
|
||||
const vw = window.innerWidth, vh = window.innerHeight
|
||||
return { w: Math.floor(Math.min(isDouble() ? vw / 2 : vw, vh * (vh < 600 ? 1.05 : 0.8))), h: vh }
|
||||
}
|
||||
let book = null
|
||||
if (isBook) {
|
||||
settings.fit = 'height'
|
||||
const opening = location.hash
|
||||
app.innerHTML = '<div class="reader"><div class="loading">Preparando el libro…</div></div>'
|
||||
book = await createBook(await loadBookText(mangaId), mangaId, bookSize())
|
||||
if (location.hash !== opening) { book.release(); return }
|
||||
book.busy = () => turning
|
||||
}
|
||||
const pages = chap.pages
|
||||
const pageSrc = book ? i => book.src(i) : i => pages[i]
|
||||
const total = book ? book.count : pages.length
|
||||
let wanted = startPage >= 0 ? startPage : chap.savedPage || 0
|
||||
if (book) {
|
||||
// El progreso de un libro se guarda por capítulo. Con otra pantalla o tamaño de letra un capítulo tiene otro
|
||||
// número de páginas: ir a la equivalente.
|
||||
const c = Math.max(idx, 0)
|
||||
const pagesHere = book.chapterPages(c)
|
||||
if (chap.savedTotal > 1 && chap.savedTotal !== pagesHere) wanted = Math.round((wanted / (chap.savedTotal - 1)) * (pagesHere - 1))
|
||||
wanted = book.chapterStart(c) + Math.min(wanted, pagesHere - 1)
|
||||
if (bookResume?.mangaId === mangaId) wanted = book.pageAtWord(bookResume.word)
|
||||
bookResume = null
|
||||
}
|
||||
let page = Math.min(Math.max(wanted, 0), total - 1)
|
||||
let shown = [page] // páginas visibles (2 en doble página)
|
||||
const lastShown = () => (isPaged() ? shown.at(-1) : page)
|
||||
|
||||
// Guardado del progreso (con debounce y al salir de la página).
|
||||
let saveTimer = null
|
||||
const payload = () => ({
|
||||
const bookPayload = (c, local, count) => ({
|
||||
mangaId, chapterId: chapters[c].id, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapters[c].num ?? null,
|
||||
chapterTitle: chapters[c].title, lang: manga.languages?.[0] || 'es', page: local, totalPages: count, isLast: c === chapters.length - 1,
|
||||
})
|
||||
const payload = () => {
|
||||
if (!book) return mangaPayload()
|
||||
const p = lastShown(), c = book.chapterAt(p)
|
||||
return bookPayload(c, p - book.chapterStart(c), book.chapterPages(c))
|
||||
}
|
||||
const mangaPayload = () => ({
|
||||
mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null,
|
||||
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"
|
||||
@@ -1376,21 +1830,22 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
window.addEventListener('pagehide', beacon)
|
||||
saveNow()
|
||||
|
||||
const chapOpts = chapters.map(c => `<option value="${c.id}" ${c.id === chapterId ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
|
||||
let curChap = book ? book.chapterAt(page) : idx
|
||||
const chapOpts = chapters.map((c, i) => `<option value="${c.id}" ${i === curChap ? 'selected' : ''}>${esc(chapLabel(c))}</option>`).join('')
|
||||
app.innerHTML = `
|
||||
<div class="reader">
|
||||
<div class="rbar top">
|
||||
<a class="btn small" href="#/manga/${mangaId}" title="Volver a la ficha">${icon('back')}</a>
|
||||
<div class="ttl"><b>${esc(manga.title)}</b></div>
|
||||
<select id="chap" style="max-width:40vw">${chapOpts}</select>
|
||||
<button class="btn small" id="clipbtn" title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
|
||||
<button class="btn small" id="clipbtn" ${isBook ? 'hidden' : ''} title="Recortar un panel (se guarda en Recortes y se mejora a HD)">${icon('scissors')}</button>
|
||||
<button class="btn small" id="modequick"></button>
|
||||
<button class="btn small" id="ropts" title="Opciones de lectura">${icon('settings')}</button>
|
||||
</div>
|
||||
<div class="rpanel" hidden>
|
||||
<select id="mode" title="Modo de lectura (se recuerda para este manga)">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="fit" title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||||
<select id="dir" title="Dirección (se recuerda para este manga)"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||
<select id="mode" title="Modo de lectura (se recuerda para este ${noun})">${Object.entries(READ_MODES).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="fit" ${isBook ? 'hidden' : ''} title="Ajuste (se recuerda para este manga)"><option value="height">Ajustar alto</option><option value="width">Ajustar ancho</option></select>
|
||||
<select id="dir" title="Dirección (se recuerda para este ${noun})"><option value="ltr">Izq → Der</option><option value="rtl">Der → Izq</option></select>
|
||||
<select id="anim" title="Animación al pasar página (modos por página)">${Object.entries(PAGE_ANIMS).map(([k, v]) => `<option value="${k}">${v}</option>`).join('')}</select>
|
||||
<select id="sfx" title="Sonido al pasar página (modos por página)">${Object.entries(SOUND_LEVELS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
<select id="filter" title="Filtro">${Object.entries(FILTERS).map(([k, [label]]) => `<option value="${k}">${label}</option>`).join('')}</select>
|
||||
@@ -1398,16 +1853,20 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
${manga.source !== 'md' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
|
||||
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
|
||||
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
|
||||
${OFFLINE_OK ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
|
||||
<p class="muted">Modo, ajuste y dirección se recuerdan para este manga. ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
${isBook ? `<select id="bsize" title="Tamaño de letra">${[80, 90, 100, 110, 120, 135, 150, 175].map(n => `<option value="${n}">Letra ${n}%</option>`).join('')}</select>
|
||||
<select id="bfont" title="Tipo de letra"><option value="serif">Con remates</option><option value="sans">Sin remates</option></select>
|
||||
<select id="bmargin" title="Márgenes"><option value="small">Margen pequeño</option><option value="mid">Margen medio</option><option value="large">Margen grande</option></select>
|
||||
<select id="bpaper" title="Papel"><option value="light">Papel claro</option><option value="sepia">Papel sepia</option><option value="dark">Papel oscuro</option></select>` : ''}
|
||||
${OFFLINE_OK && !isBook ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
|
||||
<p class="muted">${isBook ? 'Modo y dirección se recuerdan para este libro; la letra y el papel, en este dispositivo.' : 'Modo, ajuste y dirección se recuerdan para este manga.'} ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
|
||||
</div>
|
||||
<div id="pages"></div>
|
||||
<div class="rbar bottom">
|
||||
${prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
|
||||
${book ? `<a class="btn small" id="bprev" href="#">${icon('prev')} Cap. ant.</a>` : prev ? `<a class="btn small" href="${readUrl(mangaId, prev.id)}">${icon('prev')} Cap. ant.</a>` : ''}
|
||||
<span id="pnum" class="muted"></span>
|
||||
<input type="range" id="slider" min="1" max="${total}" value="${page + 1}">
|
||||
<button class="btn small" id="zoomreset" hidden title="Quitar zoom">${icon('zoomOut')}</button>
|
||||
${next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
|
||||
${book ? `<a class="btn small chnext" id="bnext" href="#">Cap. sig. ${icon('next')}</a>` : next ? `<a class="btn small chnext" href="${readUrl(mangaId, next.id)}">Cap. sig. ${icon('next')}</a>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
const reader = $('.reader', app)
|
||||
@@ -1434,7 +1893,8 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
applyFilter()
|
||||
if ($('#saver')) $('#saver').checked = settings.saver
|
||||
if ($('#hd')) $('#hd').checked = settings.hd !== false
|
||||
const endBlock = `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
|
||||
const endBlock = book ? `<div class="chapter-end"><p class="muted">Fin del libro</p>
|
||||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>` : `<div class="chapter-end"><p class="muted">Fin del capítulo ${esc(chapter.num ?? '')}</p>
|
||||
${next ? `<a class="btn primary" href="${readUrl(mangaId, next.id)}">${icon('next')} Siguiente: ${esc(chapLabel(next))}</a>` : '<p>No hay más capítulos disponibles.</p>'}
|
||||
<a class="btn" href="#/manga/${mangaId}">${icon('back')} Volver a la ficha</a></div>`
|
||||
|
||||
@@ -1443,12 +1903,53 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
if (manga.type) toast(`${manga.type}: ${READ_MODES[settings.mode].toLowerCase()}${settings.mode === 'vertical' ? '' : `, ${settings.dir === 'rtl' ? 'der → izq' : 'izq → der'}`}. Lo que cambies se recordará.`)
|
||||
}
|
||||
|
||||
// Libro: al cruzar a otro capítulo leyendo, los que quedan atrás cuentan como leídos; a uno lejano (la barra, el
|
||||
// selector) se salta sin marcar nada.
|
||||
let lastBookPage = page
|
||||
const syncBookNav = () => {
|
||||
$('#bprev').style.display = curChap > 0 || (shown[0] ?? page) > 0 ? '' : 'none'
|
||||
$('#bnext').style.display = curChap < chapters.length - 1 ? '' : 'none'
|
||||
$('#chap').value = chapters[curChap].id
|
||||
}
|
||||
const syncBookChapter = () => {
|
||||
const p = lastShown()
|
||||
book.trim(p)
|
||||
if (!isPaged()) {
|
||||
box.querySelectorAll('img[src]').forEach(im => {
|
||||
if (!book.near(Number(im.dataset.i), p)) { im.removeAttribute('src'); im.dataset.lazy = '1' }
|
||||
})
|
||||
}
|
||||
const c = book.chapterAt(p)
|
||||
if (c !== curChap) {
|
||||
if (c > curChap && Math.abs(p - lastBookPage) <= 2) {
|
||||
for (let j = curChap; j < c; j++) {
|
||||
const count = book.chapterPages(j)
|
||||
api('/api/progress', { method: 'POST', body: bookPayload(j, count - 1, count) }).catch(() => {})
|
||||
}
|
||||
}
|
||||
curChap = c
|
||||
history.replaceState(null, '', readUrl(mangaId, chapters[c].id))
|
||||
}
|
||||
lastBookPage = p
|
||||
syncBookNav()
|
||||
}
|
||||
const setPageLabel = () => {
|
||||
if (book) syncBookChapter()
|
||||
$('#pnum').textContent = `${shown.length > 1 ? `${shown[0] + 1}-${shown[1] + 1}` : lastShown() + 1} / ${total}`
|
||||
slider.value = lastShown() + 1
|
||||
}
|
||||
let observer = null
|
||||
let atEnd = false
|
||||
// Libro: ¿la pantalla ya pide otro tamaño de página? La barra del navegador móvil al aparecer no cuenta.
|
||||
const sizeChanged = () => {
|
||||
const s = bookSize()
|
||||
return s.w !== book.W || Math.abs(s.h - book.H) > 120
|
||||
}
|
||||
function reopenBook() {
|
||||
bookResume = { mangaId, word: book.wordAt(shown[0] ?? page) }
|
||||
history.replaceState(null, '', readUrl(mangaId, chapters[curChap].id))
|
||||
router()
|
||||
}
|
||||
|
||||
// --- Doble página: la 1.ª va sola (portada) y las apaisadas (ya dobles) también.
|
||||
const wide = new Set()
|
||||
@@ -1479,9 +1980,10 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
wide.add(i)
|
||||
refreshGutters() // una página ancha cambia las parejas de las páginas de delante
|
||||
}
|
||||
im.src = pages[i]
|
||||
im.decode?.().catch(() => {})
|
||||
preloaded.set(i, im)
|
||||
const load = src => { im.src = src; im.decode?.().catch(() => {}) }
|
||||
if (book) book.whenReady(i, load) // un libro dibuja sus páginas poco a poco, sin frenar la lectura
|
||||
else load(pages[i])
|
||||
}
|
||||
for (const i of preloaded.keys()) if (i < shown[0] - 4 || i > shown.at(-1) + 10) preloaded.delete(i)
|
||||
scheduleLeafTextures()
|
||||
@@ -1517,7 +2019,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const stage = document.createElement('div')
|
||||
stage.className = 'stage'
|
||||
stage.classList.toggle('two', shown.length === 2)
|
||||
stage.innerHTML = shown.map((i, n) => `<img data-i="${i}" data-gutter="${gutterOf(i, n)}" src="${pages[i]}" alt="" draggable="false">`).join('')
|
||||
stage.innerHTML = shown.map((i, n) => `<img data-i="${i}" data-gutter="${gutterOf(i, n)}" src="${pageSrc(i)}" alt="" draggable="false">`).join('')
|
||||
const imgs = [...stage.querySelectorAll('img')]
|
||||
if ($('.stage', box) && dir && !wantDrag && !imgs.every(imgReady)) {
|
||||
waitTimer = setTimeout(() => box.classList.add('waiting'), 200)
|
||||
@@ -2100,7 +2602,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const under = []
|
||||
const neighbour = (i, onHingeSide) => {
|
||||
if (i < 0 || i >= total || wide.has(i)) return
|
||||
const im = preloaded.get(i) || Object.assign(new Image(), { src: pages[i] })
|
||||
const im = preloaded.get(i) || Object.assign(new Image(), { src: pageSrc(i) })
|
||||
const fit = imgReady(im) ? Math.min(box.clientWidth / im.naturalWidth, box.clientHeight / im.naturalHeight) : 0
|
||||
const w = fit ? im.naturalWidth * fit : rect.w, h = fit ? im.naturalHeight * fit : rect.h
|
||||
im.className = 'leafkeep'
|
||||
@@ -2286,6 +2788,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
function render() {
|
||||
observer?.disconnect()
|
||||
const keep = lastShown()
|
||||
if (book && sizeChanged()) return reopenBook() // cambiar a doble página cambia el tamaño de página
|
||||
resetZoom()
|
||||
box.className = isPaged() ? `paged ${settings.fit === 'width' ? 'fit-width' : ''} ${isDouble() ? 'double' : ''}` : `vertical fit-${settings.fit}`
|
||||
renderModeQuick()
|
||||
@@ -2302,10 +2805,16 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
}
|
||||
page = keep
|
||||
shown = [keep]
|
||||
box.innerHTML = pages.map((src, i) => `<img data-i="${i}" ${i < page + 3 ? `src="${src}"` : `data-src="${src}"`} alt="">`).join('') + endBlock
|
||||
// Las páginas de un libro tienen tamaño conocido: no hace falta cargar las anteriores para saltar a la guardada.
|
||||
const near = i => (book ? Math.abs(i - page) <= 2 : i < page + 3)
|
||||
box.innerHTML = Array.from({ length: total }, (_, i) => `<img data-i="${i}" ${near(i) ? `src="${pageSrc(i)}"` : 'data-lazy="1"'} ${book ? `style="aspect-ratio:${book.W}/${book.H}"` : ''} alt="">`).join('') + endBlock
|
||||
box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true }))
|
||||
const lazy = new IntersectionObserver(entries => entries.forEach(e => {
|
||||
if (e.isIntersecting && e.target.dataset.src) { e.target.src = e.target.dataset.src; delete e.target.dataset.src }
|
||||
if (!e.isIntersecting || !e.target.dataset.lazy) return
|
||||
delete e.target.dataset.lazy
|
||||
const i = Number(e.target.dataset.i)
|
||||
if (book) book.whenReady(i, url => { if (!e.target.dataset.lazy) e.target.src = url })
|
||||
else e.target.src = pageSrc(i)
|
||||
}), { rootMargin: '2000px 0px' })
|
||||
// No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0.
|
||||
let ready = false
|
||||
@@ -2317,7 +2826,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
imgs.forEach(img => { lazy.observe(img); observer.observe(img) })
|
||||
const lazyDisconnect = observer.disconnect.bind(observer)
|
||||
observer.disconnect = () => { lazyDisconnect(); lazy.disconnect() }
|
||||
const before = imgs.slice(0, page + 1).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) }))
|
||||
const before = (book ? [] : imgs.slice(0, page + 1)).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) }))
|
||||
Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => {
|
||||
imgs[page]?.scrollIntoView()
|
||||
lastY = window.scrollY
|
||||
@@ -2493,14 +3002,15 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
|
||||
const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim, sfx: settings.sfx })
|
||||
const savePrefs = () => {
|
||||
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
|
||||
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message))
|
||||
return api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
|
||||
.then(() => toast(`Guardado para este ${noun}`)).catch(err => toast(err.message))
|
||||
}
|
||||
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefs(); render() })
|
||||
// En un libro, volver a maquetar reabre el lector: antes hay que esperar a que el modo nuevo esté guardado.
|
||||
const savePrefsThenRender = () => { const saved = savePrefs(); if (book) saved.finally(render); else render() }
|
||||
$('#mode').addEventListener('change', e => { settings.mode = e.target.value; if (isPaged()) lastPaged = settings.mode; savePrefsThenRender() })
|
||||
$('#modequick').addEventListener('click', () => {
|
||||
settings.mode = isPaged() ? 'vertical' : lastPaged
|
||||
savePrefs()
|
||||
render()
|
||||
savePrefsThenRender()
|
||||
})
|
||||
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
|
||||
$('#dir').addEventListener('change', e => {
|
||||
@@ -2523,7 +3033,21 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
e.target.closest('button').disabled = true
|
||||
try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false }
|
||||
})
|
||||
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
|
||||
$('#chap').addEventListener('change', e => {
|
||||
if (book) goTo(book.chapterStart(chapters.findIndex(c => c.id === e.target.value)))
|
||||
else location.hash = readUrl(mangaId, e.target.value)
|
||||
})
|
||||
if (book) {
|
||||
$('#bnext').addEventListener('click', e => { e.preventDefault(); goTo(book.chapterStart(curChap + 1)) })
|
||||
$('#bprev').addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
// Al inicio del capítulo actual o, si ya se está ahí, al anterior que empiece en otra página.
|
||||
let c = curChap
|
||||
while (c > 0 && book.chapterStart(c) >= (shown[0] ?? page)) c--
|
||||
goTo(book.chapterStart(c))
|
||||
})
|
||||
book.onChange = i => { box.querySelectorAll(`img[data-i="${i}"]`).forEach(im => { im.src = book.src(i) }) }
|
||||
}
|
||||
$('#offone')?.addEventListener('click', e => {
|
||||
const b = e.target.closest('button')
|
||||
b.disabled = true
|
||||
@@ -2742,6 +3266,7 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
const onResize = () => {
|
||||
clearTimeout(resizeTimer)
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (book && sizeChanged()) return reopenBook()
|
||||
if (!isPaged() || atEnd) return
|
||||
box.classList.toggle('double', isDouble())
|
||||
goTo(lastShown(), false, true)
|
||||
@@ -2749,8 +3274,19 @@ async function viewReader(mangaId, chapterId, startPage) {
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
if (book) {
|
||||
const opts = bookSettings()
|
||||
for (const [id, key] of [['bsize', 'size'], ['bfont', 'font'], ['bmargin', 'margin'], ['bpaper', 'paper']]) {
|
||||
$(`#${id}`).value = String(opts[key])
|
||||
$(`#${id}`).addEventListener('change', e => {
|
||||
store.set('bookSettings', { ...bookSettings(), [key]: key === 'size' ? Number(e.target.value) : e.target.value })
|
||||
reopenBook()
|
||||
})
|
||||
}
|
||||
}
|
||||
render()
|
||||
cleanup = () => {
|
||||
book?.release()
|
||||
freeLeafGL()
|
||||
endCrop()
|
||||
if (saveTimer) saveNow()
|
||||
@@ -2900,16 +3436,16 @@ async function viewLibrary(params) {
|
||||
: [null, null, []]
|
||||
if (!location.hash.startsWith('#/library')) return
|
||||
const online = !!hist
|
||||
const dev = OFFLINE_OK ? offByManga() : []
|
||||
const reading = (hist || []).filter(h => !h.finished)
|
||||
const finished = (hist || []).filter(h => h.finished)
|
||||
const follows = fol?.follows || []
|
||||
const dev = OFFLINE_OK ? offByManga().filter(m => wantsId(m.mangaId)) : []
|
||||
const reading = (hist || []).filter(h => !h.finished && wantsId(h.manga_id))
|
||||
const finished = (hist || []).filter(h => h.finished && wantsId(h.manga_id))
|
||||
const follows = (fol?.follows || []).filter(f => wantsId(f.manga_id))
|
||||
const tabs = [
|
||||
['reading', 'Leyendo', 'book', reading.length],
|
||||
['following', 'Siguiendo', 'bell', follows.length],
|
||||
['finished', 'Terminados', 'check', finished.length],
|
||||
...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []),
|
||||
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', l.count]),
|
||||
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado
|
||||
]
|
||||
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
|
||||
app.innerHTML = `<h1>Librería</h1>
|
||||
@@ -2952,7 +3488,7 @@ async function viewLibrary(params) {
|
||||
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «⋯ → En este dispositivo» guarda capítulos para leerlos sin internet.')
|
||||
} else if (curList) {
|
||||
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
|
||||
body.innerHTML = grid(l.items.map(it => libCard({
|
||||
body.innerHTML = grid(l.items.filter(it => wantsId(it.manga_id)).map(it => libCard({
|
||||
href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`,
|
||||
del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' },
|
||||
})), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».')
|
||||
@@ -3292,6 +3828,9 @@ function viewSettings() {
|
||||
<button class="btn" id="logout">${icon('logout')} Cerrar sesión</button></section>
|
||||
<section><b>Idioma preferido de los capítulos</b>
|
||||
<select id="lang">${Object.entries(LANGS).map(([k, v]) => `<option value="${k}" ${k === me.lang ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
|
||||
<section><b>${icon('book')} Qué mostrar</b>
|
||||
<p class="muted" style="margin:0;font-size:.88rem">Elige qué aparece en el inicio y en tu librería. No se borra nada: lo oculto vuelve al cambiar este ajuste.</p>
|
||||
<select id="contentmode">${[['both', 'Manga y libros'], ['manga', 'Solo manga'], ['books', 'Solo libros']].map(([k, v]) => `<option value="${k}" ${k === contentMode() ? 'selected' : ''}>${v}</option>`).join('')}</select></section>
|
||||
<section><b>Cambiar contraseña</b>
|
||||
<form id="pw" style="display:grid;gap:8px">
|
||||
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
|
||||
@@ -3382,6 +3921,14 @@ function viewSettings() {
|
||||
})
|
||||
$('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() })
|
||||
$('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') })
|
||||
$('#contentmode').addEventListener('change', async e => {
|
||||
const prev = contentMode()
|
||||
try {
|
||||
await api('/api/settings', { method: 'POST', body: { contentMode: e.target.value } })
|
||||
me.content_mode = e.target.value
|
||||
toast('Guardado')
|
||||
} catch (err) { e.target.value = prev; toast(err.message) }
|
||||
})
|
||||
$('#pw').addEventListener('submit', async e => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
|
||||
+2
-1
@@ -128,7 +128,8 @@ async function hostIsPublic(hostname) {
|
||||
const hit = dnsCache.get(hostname)
|
||||
if (hit && hit.exp > Date.now()) return hit.ok
|
||||
const addrs = await dns.lookup(hostname, { all: true }).catch(() => [])
|
||||
const ok = addrs.length > 0 && addrs.every(a => !isPrivateIp(a.address))
|
||||
if (!addrs.length) return false // fallo de DNS: puede ser pasajero, no se recuerda
|
||||
const ok = addrs.every(a => !isPrivateIp(a.address))
|
||||
dnsCache.set(hostname, { ok, exp: Date.now() + 5 * 60e3 })
|
||||
if (dnsCache.size > 1000) dnsCache.delete(dnsCache.keys().next().value)
|
||||
return ok
|
||||
|
||||
+25
-8
@@ -15,6 +15,7 @@ import { setupSecurity, safeFetch } from './security.js'
|
||||
import { setupM440, MI_BASE } from './m440.js'
|
||||
import { setupConnectivity } from './connectivity.js'
|
||||
import { setupMeta } from './meta.js'
|
||||
import { setupBooks, isBookId } from './books.js'
|
||||
|
||||
const PORT = Number(process.env.PORT || 3352)
|
||||
const DATA_DIR = process.env.DATA_DIR || '/app/data'
|
||||
@@ -162,10 +163,12 @@ const DUMMY_HASH = hashPassword(crypto.randomBytes(16).toString('hex'))
|
||||
function getUser(req) {
|
||||
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
|
||||
if (!m) return null
|
||||
return db.prepare(`SELECT u.id, u.username, u.lang FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
return db.prepare(`SELECT u.id, u.username, u.lang, u.content_mode FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ? AND s.expires_at > ?`).get(m[1], Date.now()) ?? null
|
||||
}
|
||||
|
||||
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'content_mode')) db.exec("ALTER TABLE users ADD COLUMN content_mode TEXT NOT NULL DEFAULT 'both'")
|
||||
|
||||
// ---------- Cliente MangaDex (adaptado de Tanko src/servers/mangadex.ts) ----------
|
||||
const cache = new Map()
|
||||
async function cached(key, ttl, fn) {
|
||||
@@ -456,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' : 'md')
|
||||
const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : id.startsWith('gb~') ? 'gb' : '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.
|
||||
@@ -539,6 +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)
|
||||
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
|
||||
}
|
||||
|
||||
@@ -777,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._-]+)$/
|
||||
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+)$/
|
||||
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 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 = ?
|
||||
@@ -877,7 +881,7 @@ async function route(req, res) {
|
||||
return
|
||||
}
|
||||
if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return
|
||||
if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return
|
||||
if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && !isBookId(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return
|
||||
if (/^\/api\/(lists|prefetch|recommendations)\b/.test(p) && (await library.route(p, method, q, req, res, user)) !== false) return
|
||||
|
||||
if (p === '/api/settings' && method === 'POST') {
|
||||
@@ -886,6 +890,10 @@ async function route(req, res) {
|
||||
if (!LANG.test(body.lang)) throw new HttpError(400, 'Idioma inválido')
|
||||
db.prepare('UPDATE users SET lang = ? WHERE id = ?').run(body.lang, user.id)
|
||||
}
|
||||
if (body.contentMode !== undefined) {
|
||||
if (!['both', 'manga', 'books'].includes(body.contentMode)) throw new HttpError(400, 'Valor inválido')
|
||||
db.prepare('UPDATE users SET content_mode = ? WHERE id = ?').run(body.contentMode, user.id)
|
||||
}
|
||||
if (body.newPassword !== undefined) {
|
||||
const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id)
|
||||
if (!verifyPassword(String(body.oldPassword || '').slice(0, 256), row.pass_hash)) { security.fail(ip, 'contraseña actual incorrecta'); throw new HttpError(401, 'Contraseña actual incorrecta') }
|
||||
@@ -919,12 +927,13 @@ 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' ? [] : await mdCategories())
|
||||
return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : q.get('source') === 'mi' || q.get('source') === 'gb' ? [] : 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 (!net.online()) return json(res, 200, localSearch(q.get('q') || ''))
|
||||
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))
|
||||
const anyLang = q.get('anyLang') === '1'
|
||||
@@ -951,9 +960,15 @@ async function route(req, res) {
|
||||
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
|
||||
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
|
||||
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: meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
|
||||
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 ((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') {
|
||||
if (isBookId(m[1])) {
|
||||
const read = db.prepare('SELECT page, total_pages FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1])
|
||||
return json(res, 200, { text: true, ...(await books.chapter(m[1])), pages: [], savedPage: read?.page ?? 0, savedTotal: read?.total_pages ?? 0, local: false, hd: false, hdAvailable: false })
|
||||
}
|
||||
const hdAvailable = !!upscale.hdFileOf(m[1])
|
||||
const hd = hdAvailable && q.get('hd') !== '0'
|
||||
const local = localChapter(m[1], hd)
|
||||
@@ -1013,6 +1028,7 @@ async function route(req, res) {
|
||||
return json(res, 200, d && { ...d, shared: sharedWith(user.id, m[1]) })
|
||||
}
|
||||
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
|
||||
if (isBookId(m[1])) throw new HttpError(400, 'Los libros no se descargan como capítulos')
|
||||
const b = await readBody(req)
|
||||
const dlLang = spanishOnly(m[1]) ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang
|
||||
const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title
|
||||
@@ -1066,7 +1082,7 @@ async function route(req, res) {
|
||||
.run(user.id, b.chapterId, b.mangaId, page, total, now)
|
||||
if (db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(b.mangaId)) linkDownload(user.id, b.mangaId)
|
||||
features.onProgress(user.id, b, page, total, prevPage)
|
||||
library.onProgress(user.id, b, page, total)
|
||||
if (!isBookId(b.mangaId)) library.onProgress(user.id, b, page, total)
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
|
||||
@@ -1080,6 +1096,7 @@ async function route(req, res) {
|
||||
}
|
||||
|
||||
const security = setupSecurity({ db })
|
||||
const books = setupBooks({ 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 })
|
||||
|
||||
Reference in New Issue
Block a user