342dd549bb
- 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>
585 lines
25 KiB
JavaScript
585 lines
25 KiB
JavaScript
// 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();
|
||
})();
|