Files
masi 342dd549bb feat: wiki redesign with character editing; richer novel requests; maintenance as a Job
- wiki.html: tabs (characters ranked by chapters, illustrations lightbox,
  A-Z glossary, story in groups, style), search, character sheet with a look
  timeline and Edit mode: rename, role, aliases, look label/appearance/first
  chapter, redraw, upload a picture, delete look, merge, split, delete.
- /v1/wiki JSON; ?w= thumbnails; old positions backfilled from Kavita paths.
- /library groups results by novel with cover, author, status, genres,
  summary and per-source chapter counts (free vs locked, latest), marks
  novels already in the library; ?q= starts a search.
- maintain-job.sh runs cleanup as a Job; consolidation skips characters on
  GLM timeouts; GLM-only jobs run off the GPU queue; opening a chapter queued
  as a prefetch moves it up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:10:26 -03:00

585 lines
25 KiB
JavaScript
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.
// Loaded into LNReader's chapter reader by a one-line code snippet:
// Settings > Reader > Custom code > JS:
// var s=document.createElement('script');s.src='https://illustrate.marcospaulo.dev.br/reader.js';document.head.appendChild(s);
// - Shows the chapter's illustration inside the text, where its moment happens (or an
// "Illustrate" button), with per-novel automation: every N chapters, pause, cancel.
// - Hides translator credits/promo lines.
// - Turns known character names and glossary terms into tappable cards (as of this
// chapter), where portraits can be redrawn and a character's look corrected.
// - Offers a "previously on" recap when a novel is reopened after a break.
(function () {
if (window.__novelIllustrator) return;
window.__novelIllustrator = true;
var script = document.currentScript;
var BASE = script ? new URL(script.src).origin : 'https://illustrate.marcospaulo.dev.br';
var POLL_MS = 5000;
var EVERY = [0, 1, 2, 3, 5, 10];
var KEY = 'key'; // Auto option: the chapters pre-reading rated most visual
var cfg = window.initialReaderConfig || {};
var novel = cfg.novel || {};
var chapter = cfg.chapter || {};
if (!novel.path || !chapter.path) return;
var sourceKey = (novel.pluginId || 'local') + ':' + novel.path;
var novelKey = sourceKey; // replaced by the canonical key (one identity across sources)
var chapterKey = chapter.path;
// 0-based index in the novel's chapter list (older app versions lack it).
var position = typeof chapter.position === 'number' ? chapter.position
: chapter.chapterNumber ? chapter.chapterNumber - 1 : null;
var settings = { every: 0, paused: false, mode: 'every' };
var auto = { due: 0, next_due: null }; // decided by the server (key mode needs its scores)
var current = { status: 'missing' };
var pollTimer = null;
var target = null; // #LNReader-chapter
var figures = []; // placed illustrations (a key chapter can have two)
// ---- promo cleanup -------------------------------------------------------
// Same rule as lnreader-plugins/scripts/clean-chapter.js (used for EPUBs).
var MAX_PROMO_LENGTH = 200;
var URL_RE = /https?:\/\/\S+|\b(dsc|discord)\.gg\/\S*/gi;
var MAX_LINK_LABEL = 60;
var PROMO = [
/^(discord|patreon|ko-?fi)\b\s*:?/i,
/\b(join|support)\b.{0,40}\b(discord|patreon|ko-?fi)\b/i,
/\bko-?fi\b|\bpatreon\b/i,
/^(translators?|translated by|translation|editors?|edited by|editing|proofreaders?|proofread by|tl|tln|pr|ed)(\s*\/\s*\w+)*\s*[:-]/i,
/\bread (the )?(latest|advanced?|more) chapters?\b/i,
];
function isPromo(text) {
var t = text.replace(/\s+/g, ' ').trim();
if (!t || t.length > MAX_PROMO_LENGTH) return false;
var label = t.replace(URL_RE, '').trim();
if (label.length < t.length && label.length <= MAX_LINK_LABEL) return true;
return PROMO.some(function (r) { return r.test(t); });
}
// Innermost text blocks of the chapter (not wrappers holding other blocks).
function blocks(root) {
return Array.prototype.filter.call(
root.querySelectorAll('p, div, li, blockquote, h1, h2, h3, h4, h5, h6'),
function (e) { return !e.querySelector('p, div, li') && !e.closest('[data-ni]'); });
}
// Hidden, not removed: innerText (sent to the illustrator) skips hidden nodes.
function hidePromo(root) {
blocks(root).forEach(function (e) {
if (isPromo(e.textContent || '')) e.style.display = 'none';
});
}
// ---- helpers -------------------------------------------------------------
function el(tag, style, text) {
var e = document.createElement(tag);
if (style) e.style.cssText = style;
if (text) e.textContent = text;
return e;
}
function button(label, onClick, style) {
var b = el('button',
'font:inherit;padding:8px 16px;border-radius:18px;border:1px solid currentColor;' +
'background:transparent;color:inherit;cursor:pointer;' + (style || ''), label);
b.addEventListener('click', onClick);
return b;
}
// A button that asks for a second tap before acting (no native confirm() in the reader).
function confirmButton(label, confirmLabel, onConfirm, style) {
var armed = false;
var b = button(label, function () {
if (!armed) {
armed = true;
b.textContent = confirmLabel;
setTimeout(function () { armed = false; b.textContent = label; }, 4000);
return;
}
onConfirm();
}, style);
return b;
}
function json(method, path, body) {
return fetch(BASE + path, body ? {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
} : { method: method }).then(function (r) { return r.json(); });
}
function q(name, value) { return name + '=' + encodeURIComponent(value); }
// Taps in the reader toggle its menus; keep ours from reaching them.
function isolate(e) {
['click', 'touchstart', 'touchend', 'pointerdown', 'pointerup'].forEach(function (t) {
e.addEventListener(t, function (ev) { ev.stopPropagation(); });
});
e.setAttribute('data-ni', '');
return e;
}
var SMALL = 'padding:4px 12px;font-size:0.85em';
// ---- bottom box: status and automation controls ---------------------------
var box = isolate(el('div', 'margin:12px 0 20px;text-align:center;font-size:0.9em;opacity:0.9'));
var main = el('div');
var controls = el('div',
'margin-top:10px;display:flex;gap:8px;justify-content:center;align-items:center;flex-wrap:wrap;font-size:0.85em');
box.appendChild(main);
box.appendChild(controls);
// ---- illustration placement ----------------------------------------------
function norm(t) {
return (t || '').toLowerCase().replace(/[’‘`]/g, "'").replace(/[“”]/g, '"')
.replace(/[^a-z0-9']+/g, ' ').trim();
}
// The paragraph where the illustrated moment happens: GLM copies a few words of it.
function anchorBlock(anchor) {
var words = norm(anchor).split(' ').filter(Boolean);
if (words.length < 3) return null;
var needles = [words.join(' '), words.slice(0, 6).join(' '), words.slice(-6).join(' ')];
var candidates = blocks(target).map(function (b) { return { b: b, t: norm(b.textContent) }; });
for (var i = 0; i < needles.length; i++) {
for (var j = 0; j < candidates.length; j++) {
if (candidates[j].t.indexOf(needles[i]) >= 0) return candidates[j].b;
}
}
return null;
}
function placeImage(s, slot) {
var figure = isolate(el('div', 'margin:18px 0;text-align:center'));
var img = el('img', 'width:100%;height:auto;border-radius:8px;display:block');
img.alt = s.moment || 'Illustration for ' + (chapter.name || 'this chapter');
img.src = BASE + s.image;
figure.appendChild(img);
var row = el('div', 'margin-top:6px;opacity:0.75');
row.appendChild(confirmButton('🔄 New image', '🔄 Tap again to repaint', function () {
request(true, slot);
}, SMALL));
figure.appendChild(row);
var at = anchorBlock(s.anchor);
if (at) {
at.parentNode.insertBefore(figure, at.nextSibling);
} else {
main.appendChild(figure); // no anchor (older images) or not found: below the chapter
}
figures.push(figure);
}
function clearFigures() {
figures.forEach(function (f) { f.remove(); });
figures = [];
}
// ---- chapter state -------------------------------------------------------
function render(s) {
current = s;
clearTimeout(pollTimer);
main.textContent = '';
clearFigures();
var pending = (s.extra || []).some(function (x) { return x.status === 'queued' || x.status === 'running'; });
(s.extra || []).forEach(function (x) { if (x.status === 'done') placeImage(x, x.slot); });
if (s.status === 'done') {
placeImage(s, 1);
if (pending) {
main.appendChild(el('div', 'margin-bottom:8px', '🎨 Painting a second image for this key chapter…'));
pollTimer = setTimeout(poll, POLL_MS);
}
} else if (s.status === 'queued' || s.status === 'running') {
var ahead = s.queued_ahead ? ' (' + s.queued_ahead + ' ahead in queue)' : '';
main.appendChild(el('div', 'margin-bottom:8px', s.status === 'running'
? '🎨 Painting this chapter… about a minute'
: '🎨 Queued' + ahead + '…'));
if (s.status === 'queued') main.appendChild(button('✕ Cancel', cancel));
pollTimer = setTimeout(poll, POLL_MS);
} else if (s.status === 'skipped') {
main.appendChild(button('Skipped — 🎨 illustrate anyway', function () { request(); }));
} else if (s.status === 'failed') {
main.appendChild(button('⚠️ Failed — tap to retry', function () { request(); }));
} else {
main.appendChild(button('🎨 Illustrate this chapter', function () { request(); }));
}
renderControls();
}
function lookup() {
return json('GET', '/v1/illustrations?' + q('novel', novelKey) + '&' + q('chapter', chapterKey));
}
function poll() {
lookup().then(render).catch(function () { main.textContent = ''; });
}
function request(regenerate, slot) {
// The chapter text only: placed illustrations sit inside the chapter element.
figures.forEach(function (f) { f.style.display = 'none'; });
var text = target.innerText || '';
figures.forEach(function (f) { f.style.display = ''; });
main.textContent = regenerate ? '🎨 Repainting…' : '🎨 Sending chapter…';
json('POST', '/v1/illustrations', {
novel: novelKey,
chapter: chapterKey,
title: novel.name || '',
chapter_title: chapter.name || '',
cover: novel.cover || '',
genres: novel.genres || '',
position: position,
regenerate: regenerate === true,
slot: slot || 1,
text: text.slice(0, 60000),
})
.then(render)
.catch(function () {
main.textContent = '';
main.appendChild(button('⚠️ Illustrator unreachable — tap to retry', function () { request(); }));
});
}
function cancel() {
json('DELETE', '/v1/illustrations/' + current.id).then(function (s) {
// 409: it started painting in the meantime.
if (s.error) return poll();
render(s);
}).catch(poll);
}
// Auto-request when this chapter is due and nothing exists or is pending.
function maybeAuto() {
if (current.status === 'missing' && auto.due > 0) request();
// Queued as a prefetch: asking again moves it up to "the chapter being read".
else if (current.status === 'queued') request();
}
// Records this visit (prefetch, pre-reading, recap timing) and asks the server what
// auto mode wants here: {due: images for this chapter, next_due: next chapter it paints}.
function progress() {
return json('POST', '/v1/progress', {
novel: novelKey, source: sourceKey, title: novel.name || '', chapter: chapterKey, position: position,
}).then(function (p) {
auto = { due: p.due || 0, next_due: p.next_due };
return p;
});
}
// ---- novel automation controls -------------------------------------------
function renderControls() {
controls.textContent = '';
controls.appendChild(el('span', '', 'Auto:'));
var select = el('select',
'font:inherit;padding:4px 8px;border-radius:12px;border:1px solid currentColor;' +
'background:transparent;color:inherit');
var options = [[0, 'Off'], [KEY, 'Key moments']].concat(EVERY.slice(1).map(function (n) {
return [n, n === 1 ? 'Every chapter' : 'Every ' + n + ' chapters'];
}));
var currentValue = !settings.every ? 0 : settings.mode === KEY ? KEY : settings.every;
if (settings.mode !== KEY && settings.every && EVERY.indexOf(settings.every) < 0) {
options.push([settings.every, 'Every ' + settings.every + ' chapters']);
}
options.forEach(function (o) {
var opt = el('option', '', o[1]);
opt.value = String(o[0]);
opt.selected = o[0] === currentValue;
select.appendChild(opt);
});
select.addEventListener('change', function () {
var v = select.value;
save(v === KEY ? { every: 1, mode: KEY, paused: false } : { every: Number(v), mode: 'every', paused: false });
});
controls.appendChild(select);
if (settings.every) {
controls.appendChild(button(settings.paused ? '▶ Resume' : '⏸ Pause', function () {
save({ every: settings.every, mode: settings.mode, paused: !settings.paused });
}, SMALL));
if (!settings.paused && !auto.due && auto.next_due !== null && auto.next_due !== undefined) {
controls.appendChild(el('span', 'opacity:0.7', 'next: chapter ' + (auto.next_due + 1)));
}
if (settings.paused) controls.appendChild(el('span', 'opacity:0.7', 'paused'));
}
if (position) controls.appendChild(button('📜 Recap', showRecap, SMALL));
}
function save(next) {
json('POST', '/v1/settings', { novel: novelKey, every: next.every, paused: next.paused, mode: next.mode })
.then(function (s) {
if (s.error) return;
settings = s;
return progress().then(function () {
renderControls();
maybeAuto();
});
})
.catch(function () {});
}
// ---- recap ---------------------------------------------------------------
var recapBox = null;
function showRecap() {
if (recapBox) { recapBox.remove(); recapBox = null; return; }
recapBox = isolate(el('div',
'margin:0 0 18px;padding:12px 14px;border:1px solid currentColor;border-radius:10px;' +
'font-size:0.92em;line-height:1.5;opacity:0.9'));
recapBox.appendChild(el('div', 'font-weight:bold;margin-bottom:6px', '📜 Previously…'));
var body = el('div', '', 'Writing a recap of the last chapters…');
recapBox.appendChild(body);
var hide = el('div', 'margin-top:8px;font-size:0.85em;opacity:0.7;text-align:right;cursor:pointer', 'Hide');
hide.addEventListener('click', showRecap);
recapBox.appendChild(hide);
target.parentNode.insertBefore(recapBox, target);
json('GET', '/v1/recap?' + q('novel', novelKey) + '&position=' + position)
.then(function (r) { body.textContent = r.recap || 'Nothing to recap yet for this novel.'; })
.catch(function () { body.textContent = 'The recap is unavailable right now.'; });
}
// ---- character and glossary cards ----------------------------------------
var MARK_STYLE = 'text-decoration:underline dotted;text-decoration-color:rgba(128,128,128,0.7);' +
'text-underline-offset:3px;cursor:pointer';
var cards = {}; // lowercased label -> {character} | {term}
var MARK_SPACING = 10; // mark a name/term at most once every N paragraphs
function escapeRe(t) { return t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function loadCodex() {
return json('GET', '/v1/codex?' + q('novel', novelKey) + (position !== null ? '&position=' + position : ''))
.then(function (cx) {
cards = {};
(cx.characters || []).forEach(function (c) {
[c.name].concat(c.aliases || []).forEach(function (label) {
// Only names/titles with a capital: "the young man" would match anyone.
if (label && label.length > 2 && /[A-Z]/.test(label) && !cards[label.toLowerCase()]) {
cards[label.toLowerCase()] = { character: c };
}
});
});
(cx.terms || []).forEach(function (t) {
if (t.term && t.term.length > 2 && !cards[t.term.toLowerCase()]) cards[t.term.toLowerCase()] = { term: t };
});
return cx;
});
}
function annotate(root) {
var labels = Object.keys(cards).sort(function (a, b) { return b.length - a.length; });
if (!labels.length) return;
var re = new RegExp('(?<![\\w-])(' + labels.map(escapeRe).join('|') + ')(?![\\w-])', 'gi');
var blockIndex = new Map();
blocks(root).forEach(function (b, i) { blockIndex.set(b, i); });
var lastMarked = {}; // card identity -> block index of its last mark
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
var nodes = [];
for (var n; (n = walker.nextNode());) {
var parent = n.parentElement;
if (parent && !parent.closest('script, style, a, [data-ni]') && re.test(n.nodeValue)) nodes.push(n);
re.lastIndex = 0;
}
nodes.forEach(function (node) {
var text = node.nodeValue, frag = document.createDocumentFragment(), last = 0, m;
var block = node.parentElement.closest('p, div, li, blockquote, h1, h2, h3, h4, h5, h6');
var at = blockIndex.has(block) ? blockIndex.get(block) : 0;
re.lastIndex = 0;
while ((m = re.exec(text))) {
var card = cards[m[0].toLowerCase()];
var id = card.character ? 'c:' + card.character.name : 't:' + card.term.term;
if (id in lastMarked && at - lastMarked[id] < MARK_SPACING) continue; // too soon: plain text
lastMarked[id] = at;
frag.appendChild(document.createTextNode(text.slice(last, m.index)));
var mark = isolate(el('span', MARK_STYLE, m[0]));
mark.addEventListener('click', openCard.bind(null, m[0].toLowerCase()));
frag.appendChild(mark);
last = m.index + m[0].length;
}
frag.appendChild(document.createTextNode(text.slice(last)));
node.parentNode.replaceChild(frag, node);
});
}
var sheet = null, overlay = null;
function closeCard() {
if (sheet) sheet.remove();
if (overlay) overlay.remove();
sheet = overlay = null;
}
function openCard(label) {
var card = cards[label];
if (!card) return;
closeCard();
var body = getComputedStyle(document.body);
overlay = isolate(el('div', 'position:fixed;inset:0;z-index:2147483646;background:rgba(0,0,0,0.35)'));
sheet = isolate(el('div',
'position:fixed;left:0;right:0;bottom:0;z-index:2147483647;max-height:70vh;overflow:auto;' +
'padding:16px 16px 24px;border-radius:16px 16px 0 0;box-shadow:0 -4px 20px rgba(0,0,0,0.4);' +
'font-size:0.9em;line-height:1.45;text-align:left;' +
'background:' + (body.backgroundColor === 'rgba(0, 0, 0, 0)' ? '#fff' : body.backgroundColor) +
';color:' + body.color));
overlay.addEventListener('click', closeCard);
var closeBtn = el('div', 'float:right;font-size:1.4em;line-height:1;padding:0 4px 8px 12px;cursor:pointer', '×');
closeBtn.addEventListener('click', closeCard);
sheet.appendChild(closeBtn);
if (card.character) fillCharacter(card.character, label);
else {
sheet.appendChild(el('div', 'font-weight:bold;font-size:1.15em;margin-bottom:6px', card.term.term));
sheet.appendChild(el('div', '', card.term.meaning));
}
document.body.appendChild(overlay);
document.body.appendChild(sheet);
}
function fillCharacter(c, label) {
if (c.portrait) {
var img = el('img', 'float:left;width:110px;height:auto;border-radius:8px;margin:0 12px 8px 0');
img.src = BASE + c.portrait;
img.alt = c.name;
sheet.appendChild(img);
}
sheet.appendChild(el('div', 'font-weight:bold;font-size:1.15em', c.name));
if (c.aliases && c.aliases.length) sheet.appendChild(el('div', 'opacity:0.7', c.aliases.join(' · ')));
if (c.looks && c.looks.length > 1) sheet.appendChild(el('div', 'opacity:0.7;margin-bottom:6px', 'Look: ' + c.look));
if (c.role) sheet.appendChild(el('div', 'margin:6px 0', c.role));
sheet.appendChild(el('div', 'opacity:0.85', c.description));
sheet.appendChild(el('div', 'clear:both'));
// Fixes: redraw the portrait (with an optional correction), or set the look here.
var tools = el('div', 'margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center');
var status = el('div', 'margin-top:8px;opacity:0.8');
var hint = el('input', 'flex:1 1 180px;font:inherit;padding:6px 10px;border-radius:12px;' +
'border:1px solid currentColor;background:transparent;color:inherit;display:none');
hint.placeholder = 'What is wrong? e.g. "older, with a beard" (optional)';
hint.maxLength = 300;
var sent = false;
var draw = button(c.portrait ? '🎨 Redraw portrait' : '🎨 Draw portrait', function () {
if (c.portrait && hint.style.display === 'none') {
hint.style.display = '';
draw.textContent = '🎨 Redraw now';
hint.focus();
return;
}
if (sent) return;
sent = true;
json('POST', '/v1/characters', { action: 'redraw', novel: novelKey, name: c.name, label: c.look, hint: hint.value })
.then(function (job) { waitPortrait(job.id, status, label); })
.catch(function () { status.textContent = 'The illustrator is unreachable.'; sent = false; });
}, SMALL);
tools.appendChild(draw);
tools.appendChild(hint);
if (c.looks && c.looks.length > 1 && position !== null) {
var pick = el('select', 'font:inherit;padding:4px 8px;border-radius:12px;border:1px solid currentColor;' +
'background:transparent;color:inherit');
c.looks.forEach(function (l) {
var o = el('option', '', l);
o.value = l;
o.selected = l === c.look;
pick.appendChild(o);
});
pick.addEventListener('change', function () {
json('POST', '/v1/characters', { action: 'look', novel: novelKey, name: c.name, label: pick.value, position: position })
.then(function () { return loadCodex(); })
.then(function () { openCard(label); });
});
var wrap = el('label', 'display:flex;gap:6px;align-items:center', 'Look here:');
wrap.appendChild(pick);
tools.appendChild(wrap);
}
// Duplicates happen when a character is first known by a title ("Director Kim").
var others = Object.keys(cards).map(function (k) { return cards[k].character; })
.filter(function (o, i, all) { return o && o.name !== c.name && all.indexOf(o) === i; })
.sort(function (a, b) { return a.name.localeCompare(b.name); });
if (others.length) {
var same = el('select', 'font:inherit;padding:4px 8px;border-radius:12px;border:1px solid currentColor;' +
'background:transparent;color:inherit;max-width:12em');
same.appendChild(el('option', '', '—'));
others.forEach(function (o) {
var opt = el('option', '', o.name);
opt.value = o.name;
same.appendChild(opt);
});
same.addEventListener('change', function () {
if (!same.value) return;
json('POST', '/v1/characters', { action: 'merge', novel: novelKey, name: c.name, into: same.value })
.then(function () { return loadCodex(); })
.then(function () { openCard(same.value.toLowerCase()); });
});
var sameWrap = el('label', 'display:flex;gap:6px;align-items:center', 'Same person as:');
sameWrap.appendChild(same);
tools.appendChild(sameWrap);
}
sheet.appendChild(tools);
sheet.appendChild(status);
}
function waitPortrait(jobId, status, label) {
json('GET', '/v1/jobs/' + jobId).then(function (j) {
if (j.status === 'done') {
return loadCodex().then(function () { if (sheet) openCard(label); });
}
if (j.status === 'failed' || j.status === 'missing') {
status.textContent = '⚠️ The portrait could not be drawn.';
return;
}
status.textContent = j.status === 'running' ? '🎨 Painting the portrait… about 30 seconds'
: '🎨 Queued' + (j.queued_ahead ? ' (' + j.queued_ahead + ' ahead)' : '') + '…';
setTimeout(function () { waitPortrait(jobId, status, label); }, POLL_MS);
});
}
// A character's appearance changes in this chapter (a new form, a transformation):
// a small notice above the text; tapping a name opens its card with the new portrait.
function announceLooks(cx) {
var changed = (cx.characters || []).filter(function (c) { return c.new_look; });
if (!changed.length) return;
var note = isolate(el('div',
'margin:0 0 14px;padding:10px 12px;border:1px dashed currentColor;border-radius:10px;font-size:0.9em;opacity:0.9'));
note.appendChild(el('span', '', '✨ New look: '));
changed.forEach(function (c, i) {
if (i) note.appendChild(document.createTextNode(', '));
var link = el('span', MARK_STYLE, c.name + ' (' + c.look + ')');
link.addEventListener('click', function () { openCard(c.name.toLowerCase()); });
note.appendChild(link);
});
target.parentNode.insertBefore(note, target);
}
// ---- start -----------------------------------------------------------------
function start() {
target = document.querySelector('#LNReader-chapter');
if (!target) return setTimeout(start, 300);
hidePromo(target);
// Below the chapter text. Sibling, not child: the reader may re-render the
// chapter element itself.
target.parentNode.insertBefore(box, target.nextSibling);
json('GET', '/v1/novels/resolve?' + q('key', sourceKey) + '&' + q('title', novel.name || ''))
.then(function (r) {
novelKey = r.novel || sourceKey;
// Cards as of this chapter: the looks characters have here, nothing met later.
loadCodex().then(function (cx) { annotate(target); announceLooks(cx); }).catch(function () {});
return Promise.all([json('GET', '/v1/settings?' + q('novel', novelKey)), lookup(),
progress().catch(function () { return {}; })]);
})
.then(function (r) {
settings = r[0];
render(r[1]);
if (r[2].recap_due) showRecap();
maybeAuto();
})
// Off the tailnet the service is unreachable: stay invisible.
.catch(function () { box.remove(); });
}
start();
})();