Files
2026-09-26 16:30:11 -03:00

287 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""A read-only gallery page per novel: illustrations in reading order and each character's
looks over time. Shows nothing past the furthest chapter read, so it never spoils."""
from html import escape
from urllib.parse import quote
import store
from config import HERE
STYLE = """
:root { --bg: #f6f4ef; --fg: #1f1d1a; --muted: #6b665d; --card: #ffffff; --line: #e3ded4; }
@media (prefers-color-scheme: dark) {
:root { --bg: #151412; --fg: #ece8df; --muted: #9c968a; --card: #1f1d1a; --line: #2e2b27; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.5 Georgia, 'Iowan Old Style', serif; }
main { max-width: 1100px; margin: 0 auto; padding: 24px 16px 64px; }
a { color: inherit; }
h1 { font-size: 1.8em; margin: 0 0 4px; }
h2 { font-size: 1.25em; margin: 40px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--line); }
.muted { color: var(--muted); font-size: 0.9em; }
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
figure { margin: 0; background: var(--card); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
figure img { width: 100%; height: auto; display: block; }
figcaption { padding: 8px 12px 12px; font-size: 0.9em; }
.cast { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
.person { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
.looks { display: flex; gap: 10px; overflow-x: auto; margin-top: 8px; }
.look { flex: 0 0 110px; font-size: 0.8em; text-align: center; }
.look img { width: 110px; height: 160px; object-fit: cover; border-radius: 8px; display: block; }
.empty { width: 110px; height: 160px; border-radius: 8px; border: 1px dashed var(--line); display: grid;
place-items: center; color: var(--muted); }
ul.novels { list-style: none; padding: 0; } ul.novels li { padding: 10px 0; border-bottom: 1px solid var(--line); }
"""
def page(title, body):
return (f"<!doctype html><html lang=en><head><meta charset=utf-8>"
f"<meta name=viewport content='width=device-width,initial-scale=1'><title>{escape(title)}</title>"
f"<style>{STYLE}</style></head><body><main>{body}</main></body></html>").encode()
def index():
novels = store.gallery_novels()
cards = []
for n in novels:
latest = store.illustrations_of(n["novel"], store.FOREVER)
cover = (f"<img loading=lazy src='/v1/illustrations/{latest[-1]['id']}.webp?w=480' alt=''>"
if latest else "<div class=empty></div>")
cards.append(f"<a class=novelcard href='/gallery?novel={quote(n['novel'])}'>{cover}"
f"<div><strong>{escape(n['title'])}</strong><br><span class=muted>{n['count']} illustrations"
f"</span></div></a>")
style = ("<style>.novels{display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-top:16px}"
".novelcard{display:block;text-decoration:none;background:var(--card);border:1px solid var(--line);"
"border-radius:12px;overflow:hidden}.novelcard img,.novelcard .empty{width:100%;aspect-ratio:16/10;"
"object-fit:cover;display:block;background:var(--line)}.novelcard div{padding:10px 12px}</style>")
return page("Novel gallery", f"{style}<h1>Novel gallery</h1><p><a href='/library'>+ Request a novel</a></p>"
f"<div class=novels>{''.join(cards) or '<p>Nothing yet.</p>'}</div>")
def wiki_data(novel):
"""Everything the wiki page renders, cut at the furthest chapter read. Characters are
ranked by how many chapters they appear in; each carries its looks as a timeline."""
import pipeline
limit = store.max_position(novel)
cut = store.FOREVER if limit is None else limit
cards = store.codex(novel, limit)
with store.connect() as conn:
counts = store.mention_counts(conn, novel, cut)
characters = []
for c in cards["characters"]:
looks = []
for look in store.looks_of(conn, novel, c["name"]):
since = store.first_seen(conn, novel, c["name"], look["label"])
if since is None or since > cut:
continue
looks.append({"label": look["label"], "since": since, "description": look["description"],
"portrait": store.portrait_url(look["portrait"])})
looks.sort(key=lambda l: l["since"])
characters.append({**c, "chapters": counts.get(("c", c["name"]), 0), "timeline": looks})
characters.sort(key=lambda c: (-c["chapters"], c["name"]))
story = [dict(r) for r in conn.execute(
"SELECT position, summary, score FROM summaries WHERE novel = ? AND position <= ? AND summary != '' "
"ORDER BY position", (novel, cut))]
terms = [{**t, "chapters": counts.get(("t", t["term"]), 0)} for t in cards["terms"]]
illustrations = [{"id": i["id"], "position": i["position"], "chapter_title": i["chapter_title"],
"moment": i["moment"], "image": f"/v1/illustrations/{i['id']}.webp"}
for i in store.illustrations_of(novel, cut)]
return {"novel": novel, "title": store.novel_title(novel), "limit": limit, "characters": characters,
"terms": terms, "story": story, "illustrations": illustrations, "style": pipeline.styles(novel)}
WIKI_HTML = HERE / "wiki.html"
def wiki_page(novel):
return WIKI_HTML.read_text().replace("__NOVEL__", escape(novel, quote=True)).encode()
LIBRARY_SCRIPT = """
'use strict';
const $ = s => document.querySelector(s);
function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (v === null || v === undefined || v === false) continue;
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else if (k === 'text') el.textContent = v;
else el.setAttribute(k, v === true ? '' : v);
}
for (const kid of kids.flat(Infinity)) if (kid !== null && kid !== undefined && kid !== false)
el.append(kid instanceof Node ? kid : document.createTextNode(String(kid)));
return el;
}
async function api(path, body) {
const r = await fetch(path, body ? {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)} : {});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || r.status);
return data;
}
const norm = t => (t || '').toLowerCase().replace(/[’']/g, '').replace(/[^a-z0-9]+/g, ' ').trim();
let library = new Set();
async function search(ev) {
ev.preventDefault();
const q = $('#q').value.trim();
if (q.length < 2) return;
$('#results').replaceChildren(h('p', {class: 'muted', text: 'Searching every source… about 30 seconds.'}));
try {
const [hits, owned] = await Promise.all([api('/v1/library/find?q=' + encodeURIComponent(q)),
api('/v1/library/titles').catch(() => [])]);
library = new Set(owned.map(norm));
render(hits);
} catch (e) { $('#results').replaceChildren(h('p', {text: 'Search failed: ' + e.message})); }
}
// One card per novel (same title across sources), sources listed inside with their counts.
function render(hits) {
if (!hits.length) return $('#results').replaceChildren(h('p', {text: 'No source has it.'}));
const groups = new Map();
for (const hit of hits) {
const k = norm(hit.name);
if (!groups.has(k)) groups.set(k, {name: hit.name, exact: hit.exact, sources: []});
groups.get(k).sources.push(hit);
}
const list = [...groups.values()].sort((a, b) => (b.exact - a.exact) || (b.sources.length - a.sources.length));
$('#results').replaceChildren(...list.map(card));
}
function card(group) {
const cover = h('div', {class: 'cover'}), meta = h('div', {class: 'meta muted small'});
const summary = h('p', {class: 'summary'});
const owned = library.has(norm(group.name));
const rows = group.sources.map(src => {
const counts = h('span', {class: 'muted small', text: 'loading details…'});
const add = h('button', {onclick: async () => {
add.disabled = true; add.textContent = 'Requested ↓';
try {
await api('/v1/library/fetch', {id: src.id, path: src.path, name: src.name});
await jobs();
$('#requests').scrollIntoView({behavior: 'smooth'});
}
catch (e) { add.disabled = false; add.textContent = 'Retry'; alert(e.message); }
}}, 'Add');
const row = h('div', {class: 'source'},
h('div', {}, h('strong', {text: src.source}), ' ', h('a', {href: src.url, target: '_blank', rel: 'noopener', class: 'small'}, 'open'),
h('div', {}, counts)), add);
row.load = () => api('/v1/library/info?id=' + encodeURIComponent(src.id) + '&path=' + encodeURIComponent(src.path))
.then(info => {
row.info = info;
const locked = info.chapters - info.free;
counts.textContent = info.chapters + ' chapters · ' +
(locked ? info.free + ' free, ' + locked + ' locked' : 'all free') +
(info.latest ? ' · latest: ' + info.latest.replace(' 🔒', '') : '');
if (!info.free) { add.disabled = true; add.textContent = 'All locked'; }
if (!cover.firstChild && info.cover) cover.append(h('img', {src: info.cover, alt: '', referrerpolicy: 'no-referrer', loading: 'lazy'}));
if (!meta.textContent) meta.textContent = [info.author, info.status, info.genres].filter(Boolean).join(' · ');
if (!summary.textContent && info.summary) summary.textContent = info.summary;
mark();
})
.catch(e => { counts.textContent = 'details unavailable (' + e.message + ')'; });
return row;
});
// Point at the source listing the most free chapters (split parts can inflate counts).
function mark() {
const best = rows.filter(r => r.info).sort((a, b) => b.info.free - a.info.free)[0];
rows.forEach(r => r.classList.toggle('best', r === best && best.info.free > 0));
}
const el = h('article', {class: 'novel'}, cover,
h('div', {class: 'info'},
h('h3', {}, group.name, group.exact ? h('span', {class: 'tag', text: 'exact'}) : null,
owned ? h('span', {class: 'tag done', text: 'in your library'}) : null),
meta, summary,
h('details', {open: group.exact}, h('summary', {text: group.sources.length + ' source' + (group.sources.length > 1 ? 's' : '')}), rows)));
// Details load when the card is opened (exact matches are open from the start).
const details = el.querySelector('details');
const loadAll = () => { rows.forEach(r => r.load()); details.removeEventListener('toggle', loadAll); };
if (group.exact) loadAll(); else details.addEventListener('toggle', loadAll);
return el;
}
const LABEL = {queued: 'Queued', running: 'Downloading', done: 'Done', failed: 'Failed'};
const ago = ms => { const m = Math.round((Date.now() - ms) / 60000); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : Math.round(m / 60) + ' h ago'; };
const mins = s => s < 60 ? Math.max(1, Math.round(s)) + ' s' : Math.round(s / 60) + ' min';
let jobTimer = null;
// The server keeps the requests, so this list is the same after a refresh.
async function jobs() {
clearTimeout(jobTimer);
try {
const list = await api('/v1/library/jobs');
const active = list.filter(j => j.status === 'queued' || j.status === 'running');
$('#active-count').textContent = active.length ? ' · ' + active.length + ' in progress' : '';
$('#jobs').replaceChildren(...(list.length ? list.map(j => {
const pct = j.total ? Math.round(100 * (j.done || 0) / j.total) : 0;
const elapsed = j.started ? (Date.now() - j.started) / 1000 : 0;
const eta = j.status === 'running' && j.done > 5 && j.total ? (elapsed / j.done) * (j.total - j.done) : null;
const detail = j.status === 'running'
? (j.total ? (j.done || 0) + ' / ' + j.total + ' chapters · ' + pct + '%' : (j.phase || 'starting')) +
(j.phase && j.total ? ' · ' + j.phase : '') + (eta ? ' · about ' + mins(eta) + ' left' : '')
: j.status === 'queued' ? (j.phase || 'waiting') + ' · requested ' + ago(j.queued)
: j.status === 'done' ? (j.progress || '') + ' · finished ' + ago(j.finished)
: 'failed ' + ago(j.finished || j.queued);
return h('li', {class: 'job ' + j.status},
h('div', {class: 'jobhead'}, h('strong', {text: j.title || j.name}),
h('span', {class: 'tag ' + j.status, text: LABEL[j.status] || j.status})),
h('div', {class: 'muted small', text: j.id + ' · ' + detail}),
j.status === 'running' ? h('div', {class: 'bar'}, h('div', {style: 'width:' + (j.total ? pct : 3) + '%'})) : null,
j.status === 'done' ? h('div', {class: 'small'}, 'Downloaded. Kavita lists it within about 10 minutes (it waits for files to settle); then open it in LNReader from the Kavita source, or in ',
h('a', {href: 'https://novel.marcospaulo.dev.br', target: '_blank', rel: 'noopener'}, 'Kavita'), '.') : null,
j.error ? h('div', {class: 'err small', text: j.error}) : null);
}) : [h('li', {class: 'muted', text: 'Nothing requested yet.'})]));
// Search results reflect finished downloads too.
list.filter(j => j.status === 'done').forEach(j => library.add(norm(j.title || j.name)));
if (active.length) jobTimer = setTimeout(jobs, 2000);
} catch (e) { $('#jobs').replaceChildren(h('li', {class: 'err', text: 'The fetcher is not reachable.'})); }
}
$('#search').onsubmit = search;
jobs();
// /library?q=Title searches straight away (shareable links, bookmarks).
const initial = new URLSearchParams(location.search).get('q');
if (initial) { $('#q').value = initial; search(new Event('submit')); }
"""
LIBRARY_STYLE = """
form { display: flex; gap: 8px; margin: 16px 0; }
input { flex: 1; font: inherit; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--card); color: var(--fg); min-width: 0; }
button { font: inherit; padding: 8px 14px; border-radius: 10px; border: 1px solid var(--fg);
background: transparent; color: var(--fg); cursor: pointer; }
button:disabled { opacity: 0.6; cursor: default; }
.small { font-size: .85em; }
.novel { display: grid; grid-template-columns: 110px 1fr; gap: 14px; padding: 14px; margin-bottom: 12px;
background: var(--card); border: 1px solid var(--line); border-radius: 12px; }
@media (max-width: 520px) { .novel { grid-template-columns: 76px 1fr; } }
.cover img { width: 100%; border-radius: 8px; display: block; }
.cover:empty { aspect-ratio: 2 / 3; border-radius: 8px; background: var(--line); }
.novel h3 { margin: 0 0 4px; font-size: 1.1em; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.summary { margin: 6px 0; font-size: .9em; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden; }
details summary { cursor: pointer; margin: 6px 0; }
.source { display: flex; gap: 10px; justify-content: space-between; align-items: center; padding: 8px 10px;
border-top: 1px solid var(--line); border-radius: 8px; }
.source.best { background: color-mix(in srgb, #3c8d5a 12%, transparent); }
.source.best strong::after { content: ' · most chapters listed'; font-weight: normal; font-size: .8em; color: #3c8d5a; }
.tag { font-size: 0.72em; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); font-weight: normal; }
.done { border-color: #3c8d5a; color: #3c8d5a; }
.running { border-color: #b8862b; color: #b8862b; }
.job { display: grid; gap: 4px; }
.jobhead { display: flex; gap: 8px; align-items: center; justify-content: space-between; }
.bar { height: 6px; border-radius: 999px; background: var(--line); overflow: hidden; }
.bar div { height: 100%; background: #b8862b; transition: width .6s ease; } .failed, .err { color: #c0503a; border-color: #c0503a; }
ul#jobs { list-style: none; padding: 0; } ul#jobs li { padding: 8px 0; border-bottom: 1px solid var(--line); }
"""
def library_page():
"""Request a novel: search every source, add one to the Kavita library."""
return page("Request a novel",
f"<style>{LIBRARY_STYLE}</style><p class=muted><a href='/gallery'>Gallery</a></p>"
"<h1>Request a novel</h1><div class=muted>Searches every source in your plugin repo. "
"Added novels appear in Kavita (Novels) within about 10 minutes and get new chapters nightly. "
"Locked chapters are skipped.</div>"
"<form id=search><input id=q placeholder='Title, e.g. Overgeared' autocomplete=off>"
"<button>Search</button></form><div id=results></div>"
f"<h2 id=requests>Requests<span id=active-count class=muted></span></h2><ul id=jobs></ul><script>{LIBRARY_SCRIPT}</script>")