Libros: Wikisource, EPUB propios y lectura sin conexión

Wikisource en español como segunda fuente, con el EPUB que genera ws-export
y portadas generadas con el título.

Cada usuario puede subir sus EPUB, visibles solo para él en cualquier ruta.
Los archivos manipulados (ZIP bombs, cabeceras falsas) se rechazan.

Un libro se puede guardar entero en el dispositivo para leerlo sin conexión.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 22:00:44 -06:00
co-authored by Claude Opus 5.5
parent be19e35156
commit bb1f3ff8e4
6 changed files with 274 additions and 38 deletions
+66 -10
View File
@@ -50,9 +50,9 @@ 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)', gb: 'Project Gutenberg (libros)' }
const NO_CATS = new Set(['mi', 'gb']) // fuentes sin listado por categorías
const BOOK_SOURCES = new Set(['gb'])
const SOURCES = { lc: 'LeerCapitulo (español)', mi: 'M440 (español)', md: 'MangaDex (multi-idioma)', gb: 'Project Gutenberg (libros)', ws: 'Wikisource (libros en español)', ub: 'Mis libros (EPUB subidos)' }
const NO_CATS = new Set(['mi', 'gb', 'ws', 'ub']) // fuentes sin listado por categorías
const BOOK_SOURCES = new Set(['gb', 'ws', 'ub'])
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'
@@ -279,7 +279,16 @@ async function offSaveInto(cache, it, saved, signal) {
const res = await fetch(chapUrl, { signal })
if (!res.ok) throw new Error(`Error ${res.status}`)
const data = await res.clone().json()
await cache.put(chapUrl, res)
if (data.text) {
// Libro: lo que se guarda es su texto y sus imágenes; la lista queda en la copia, para poder borrarlos después.
const textUrl = `/api/book/${it.mangaId}/text`
const tres = await fetch(textUrl, { signal })
if (!tres.ok) throw new Error(`Error ${tres.status}`)
const text = await tres.json()
const images = new Set(text.chapters.flatMap(c => c.blocks).filter(b => b.k === 'img').map(b => `/api/book/${it.mangaId}/img/${b.n}`))
data.pages = [textUrl, ...images]
await cache.put(chapUrl, new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }))
} else await cache.put(chapUrl, res)
saved.push(chapUrl)
const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}`
const mres = await fetch(mUrl).catch(() => null)
@@ -538,7 +547,8 @@ async function viewHome(params) {
<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">
${source === 'ub' ? `<div class="row" style="margin:10px 0;flex-wrap:wrap;gap:10px"><label class="btn primary" id="ubpick">${icon('plus')} Subir EPUB<input type="file" id="ubfile" accept=".epub,application/epub+zip" multiple hidden></label><span class="muted" style="font-size:.85rem">Solo tú puedes verlos. Máximo 100 MB por libro.</span></div>` : ''}
${source === 'ub' ? (q ? `<h2>Resultados para “${esc(q)}”</h2>` : '') : 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')} ${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>`}
@@ -564,6 +574,23 @@ async function viewHome(params) {
if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga
}
$('#ubfile')?.addEventListener('change', async e => {
const files = [...e.target.files]
const label = $('#ubpick')
label.classList.add('disabled')
let added = 0
for (const [i, f] of files.entries()) {
toast(`Subiendo ${files.length > 1 ? `${i + 1} de ${files.length}: ` : ''}${f.name}…`)
try {
const r = await fetch('/api/books', { method: 'POST', headers: { 'Content-Type': 'application/epub+zip' }, body: f })
const d = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(d.error || `Error ${r.status}`)
added++
} catch (err) { toast(`${f.name}: ${err.message}`) }
}
label.classList.remove('disabled')
if (added) { toast(added === 1 ? 'Libro añadido' : `${added} libros añadidos`); router() }
})
const res = $('#results')
if (showCats) {
const cats = await api(`/api/categories?source=${source}`)
@@ -574,7 +601,7 @@ async function viewHome(params) {
}
// Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan).
const pageable = source === 'md' || BOOK_SOURCES.has(source) || !!tag || (source === 'mi' && mode === 'latest' && !q)
const pageable = source === 'md' || source === 'gb' || source === 'ws' || !!tag || (source === 'mi' && mode === 'latest' && !q)
const seen = new Set()
let page = 1
const load = async () => {
@@ -582,7 +609,7 @@ async function viewHome(params) {
const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id))
if (page === 1) {
res.className = list.length ? 'grid' : 'loading'
res.innerHTML = list.length ? '' : 'Sin resultados.'
res.innerHTML = list.length ? '' : source === 'ub' && !q ? 'Aún no has subido ningún libro. Pulsa «Subir EPUB» para añadir el primero.' : 'Sin resultados.'
}
res.insertAdjacentHTML('beforeend', list.map(mangaCard).join(''))
$('#more').hidden = !pageable || list.length < 20
@@ -676,6 +703,7 @@ async function viewManga(id, langOverride) {
const menuItems = [
manga.source === 'lc' || isBook ? '' : `<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>`,
manga.source === 'ub' ? `<button id="ubdel" class="btn danger">${icon('trash')} Eliminar libro</button>` : '',
progress || reads.length ? `<button id="drop" class="btn danger">${icon('stopRead')} Dejar de leer…</button>` : '',
].join('')
@@ -708,7 +736,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" ${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" ${isBook && !OFFLINE_OK ? 'style="display:none"' : ''}><div class="psec" id="dl" ${isBook ? 'style="display:none"' : ''}></div><div class="psec" id="hd" hidden ${isBook ? 'style="display:none"' : ''}></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">
@@ -1048,6 +1076,20 @@ async function viewManga(id, langOverride) {
const queued = offState.queue.filter(q => q.mangaId === id).length
markLib('off', !!(run || queued))
box.hidden = false
if (isBook) {
// Un libro se guarda entero: el texto de todos sus capítulos y sus imágenes.
const status = mine.length ? `${icon('check')} Guardado · ${fmtSize(bytes)}` : ''
const note = run ? `Guardando… ${run.done}/${run.total || '?'}` : queued ? 'En cola…' : mine.length ? 'Se puede leer sin internet.' : 'Guarda el libro en este móvil o PC para leerlo sin internet.'
box.innerHTML = `${secHead('phone', 'En este dispositivo', status)}<div class="note muted">${note}</div><div class="psec-actions">
${mine.length ? `<button class="btn small danger" id="offdel">${icon('trash')} Borrar del dispositivo</button>` : run || queued ? '' : `<button class="btn small" id="offbook">${icon('phone')} Guardar libro</button>`}</div>`
$('#offbook')?.addEventListener('click', () => offEnqueue([{ mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang: manga.languages?.[0] || 'es', chapterId: chapters[0].id, label: 'Libro completo', num: null }]))
$('#offdel')?.addEventListener('click', async () => {
if (!confirm(`¿Borrar «${manga.title}» de este dispositivo (${fmtSize(bytes)})?`)) return
await offRemove(mine.map(([cid]) => cid))
toast('Borrado del dispositivo')
})
return
}
// Siguientes 3 sin guardar a partir de donde vas (o desde el principio).
const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id))
const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3)
@@ -1136,6 +1178,15 @@ async function viewManga(id, langOverride) {
renderFollow()
toast(isFollowing ? 'Te avisaremos de los capítulos nuevos' : 'Ya no sigues este manga')
})
$('#ubdel')?.addEventListener('click', async () => {
if (!confirm(`¿Eliminar «${manga.title}»?\nSe borrará el archivo del servidor y tu progreso de lectura.`)) return
try {
await api(`/api/books/${id}`, { method: 'DELETE' })
if (OFFLINE_OK) await offRemove(Object.entries(offIdx()).filter(([, c]) => c.mangaId === id).map(([cid]) => cid))
toast('Libro eliminado')
location.hash = '#/?source=ub'
} catch (err) { toast(err.message) }
})
$('#drop')?.addEventListener('click', async () => {
if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial, se borrará el progreso de sus capítulos y dejarás de seguirlo.`)) return
await api(`/api/history/${id}`, { method: 'DELETE' })
@@ -1762,7 +1813,12 @@ async function viewReader(mangaId, chapterId, startPage) {
const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', sfx: 'mid', ...store.get('readerSettings', {}) }
const [info, chap] = await Promise.all([
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`),
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`).catch(err => {
// Un libro guardado se guarda entero: sin conexión, cualquier capítulo abre con el texto del dispositivo.
if (!isBookId(mangaId) || !OFFLINE_OK || !Object.values(offIdx()).some(c => c.mangaId === mangaId)) throw err
const pos = store.get(`bookPos:${mangaId}`)
return { text: true, pages: [], savedPage: pos?.chapterId === chapterId ? pos.page : 0, savedTotal: pos?.chapterId === chapterId ? pos.totalPages : 0 }
}),
])
const { manga, chapters } = info
const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) }
@@ -1822,7 +1878,7 @@ async function viewReader(mangaId, chapterId, startPage) {
chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
})
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) }
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); if (isBook) store.set(`bookPos:${mangaId}`, { chapterId: p.chapterId, page: p.page, totalPages: p.totalPages }); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) }
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } }
const onVis = () => document.visibilityState === 'hidden' && beacon()