Add the web interface

Plain HTML, CSS and JavaScript modules, with no build step. Hash routes
and relative URLs make it work under any path. The job list is polled:
every 2 seconds while a job runs, and less often otherwise.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 09d6b76a17
commit 2f4271314b
30 changed files with 4442 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="5" y="3" width="20" height="26" rx="2" fill="#2f6fd6"/>
<rect x="9" y="8" width="12" height="2" fill="#fff"/>
<rect x="9" y="13" width="12" height="2" fill="#fff"/>
<path d="M16 18v7m-3.5-3.5L16 25l3.5-3.5" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 360 B

+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="CWA Bindery keeps web fiction in Calibre-Web-Automated up to date" />
<!-- Relative URLs need a trailing slash under a path prefix, such as /bindery/. -->
<script>
if (!location.pathname.endsWith('/')) location.replace(location.pathname + '/' + location.search + location.hash);
</script>
<link rel="icon" type="image/svg+xml" href="static/favicon.svg" />
<link rel="stylesheet" href="static/style.css" />
<script type="module" src="static/js/main.js"></script>
<title>Bindery</title>
</head>
<body>
<div id="bindery-app"></div>
<div id="toasts" aria-live="polite"></div>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
// One chapter: its revisions, which revision goes in the book, and diffs between revisions.
import { watchJobs } from './jobs.js';
import { api, badge, button, chapterTitle, checkbox, diffView, externalLink, fill, fmtDate, h, link, showError, showStoryTitle, toast } from './lib.js';
export function chapterPage(main, { storyId, chapterId }) {
let a = null;
let b = null;
let source = false;
const header = h('section', { class: 'card' }, h('p', { class: 'empty' }, 'Loading…'));
const revisions = h('section', { class: 'card' });
const compare = h('section', { class: 'card' });
const load = async (revA = a, revB = b) => {
const query = new URLSearchParams();
if (revA && revB) {
query.set('a', revA);
query.set('b', revB);
}
if (source) query.set('source', '1');
const diff = await api(`chapters/${chapterId}/diff?${query}`);
a = diff.a;
b = diff.b;
document.title = `${chapterTitle(diff.chapter.title)} · Bindery`;
showStoryTitle(storyId, diff.story_title);
render(diff);
};
function render(diff) {
const { chapter } = diff;
fill(
header,
h('nav', { class: 'breadcrumbs', 'aria-label': 'Back to story' }, link(`/story/${storyId}`, '← Back to the story')),
h('h1', {}, chapterTitle(chapter.title)),
h('p', {}, externalLink(chapter.url)),
);
// A recheck compares the site text with the newest revision, so it cannot be deleted.
const newestRev = Math.max(...diff.revisions.map((r) => r.rev));
const rows = diff.revisions.map((r) => {
const current = r.rev === chapter.current_rev;
const newest = r.rev === newestRev;
return h(
'tr',
{},
h(
'td',
{ class: 'badges' },
String(r.rev),
current && badge('ok', 'in book'),
newest && badge('muted', 'newest', 'The newest text from the site. The next recheck compares with it.'),
),
h('td', {}, r.source),
h('td', {}, fmtDate(r.fetched_at)),
h('td', {}, `${Math.round(r.size / 1024)} KB`),
h(
'td',
{ class: 'actions' },
!current &&
button(
'Use this revision',
async () => {
await api(`chapters/${chapterId}/revision`, 'POST', { rev: r.rev });
toast(`Revision ${r.rev} is now in the book. The book is being rebuilt.`);
await load();
},
),
!current &&
!newest &&
button(
'Delete',
async () => {
if (!window.confirm(`Delete revision ${r.rev} of this chapter? This cannot be undone.`)) return;
await api(`chapters/${chapterId}/revisions/${r.rev}`, 'DELETE');
toast(`Deleted revision ${r.rev}`);
await load(null, null);
},
{ class: 'danger' },
),
),
);
});
fill(
revisions,
h('h2', {}, 'Revisions'),
h(
'div',
{ class: 'table-wrap' },
h(
'table',
{},
h('thead', {}, h('tr', {}, ['Revision', 'Source', 'Downloaded', 'Size', ''].map((t) => h('th', {}, t)))),
h('tbody', {}, rows),
),
),
);
if (diff.revisions.length < 2) {
fill(
compare,
h('h2', {}, 'Compare'),
h(
'p',
{ class: 'hint' },
'Bindery has only one version of this chapter, so there is nothing to compare yet. ' +
'To look for changes on the site, recheck the chapter on the story page.',
),
);
return;
}
const picker = (value, label, onchange) =>
h(
'select',
{ 'aria-label': label, onchange: (e) => onchange(Number(e.target.value)) },
diff.revisions.map((r) => h('option', { value: r.rev, selected: r.rev === value }, `Revision ${r.rev} (${r.source})`)),
);
const text = diff.identical
? `Revisions ${diff.a} and ${diff.b} have the same text.`
: `Revision ${diff.a} to ${diff.b}: ${diff.changed} changed, ${diff.added} added, ${diff.removed} removed paragraphs.`;
fill(
compare,
h('h2', {}, 'Compare'),
h(
'div',
{ class: 'controls' },
picker(a, 'Older revision', (v) => (a = v)),
h('span', {}, 'to'),
picker(b, 'Newer revision', (v) => (b = v)),
h('label', {}, checkbox(source, (on) => (source = on)), ' Compare HTML source'),
button('Compare', () => load()),
),
h('p', {}, text),
diff.html && diffView(diff.html),
);
}
fill(main, header, revisions, compare);
load(null, null).catch(showError);
return watchJobs({ storyId: Number(storyId), onFinished: () => load().catch(() => {}) });
}
+118
View File
@@ -0,0 +1,118 @@
// The job list. The page asks for it every 2 seconds while a job waits or runs,
// every 10 seconds otherwise, and every 60 seconds while the tab is hidden.
import { api, badge, button, chapterTitle, fill, fmtDate, h, link, toast } from './lib.js';
const ACTIVE = new Set(['queued', 'running']);
let jobs = [];
let timer = null;
let running = false;
const listeners = new Set();
async function poll() {
clearTimeout(timer);
if (!running) return;
try {
jobs = await api('jobs');
for (const listener of listeners) listener(jobs);
} catch {
// A 401 shows the login page. Other errors retry on the next poll.
}
if (!running) return;
const busy = jobs.some((j) => ACTIVE.has(j.status));
timer = setTimeout(poll, document.hidden ? 60000 : busy ? 2000 : 10000);
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) poll();
});
export function startPolling() {
running = true;
poll();
}
export function stopPolling() {
running = false;
clearTimeout(timer);
}
export const refreshJobs = () => poll();
// Calls render(jobs) now and on each change. Calls onFinished() when a job that
// was queued or running ends. With a story id, only jobs of that story count.
// Returns a function that stops the subscription.
export function watchJobs({ storyId, render, onFinished }) {
const active = new Set();
const listener = (list) => {
const mine = storyId === undefined ? list : list.filter((j) => j.story_id === storyId);
let finished = false;
for (const job of mine) {
if (ACTIVE.has(job.status)) active.add(job.id);
else if (active.delete(job.id)) finished = true;
}
render?.(mine);
if (finished) onFinished?.();
};
listeners.add(listener);
listener(jobs);
return () => listeners.delete(listener);
}
function jobItem(job) {
const title = job.title || job.url || job.id;
const changed = job.result?.changed || [];
return h(
'div',
{ class: 'job' },
h(
'div',
{ class: 'job-heading' },
badge(job.status),
job.story_id ? link(`/story/${job.story_id}`, title) : h('span', {}, title),
h('small', {}, job.mode_label),
h('time', { datetime: job.updated }, fmtDate(job.updated)),
job.status === 'queued' &&
button(
'Cancel',
async () => {
await api(`jobs/${job.id}/cancel`, 'POST');
toast('Job cancelled');
refreshJobs();
},
),
),
job.status === 'running' && h('progress', { max: 1, value: job.progress || 0, 'aria-label': 'Progress' }),
job.message && h('p', {}, job.message),
changed.length > 0 &&
job.story_id &&
h(
'p',
{},
'Changed: ',
changed.map((c, i) => [i > 0 && ', ', link(`/story/${job.story_id}/chapter/${c.chapter_id}`, chapterTitle(c.title))]),
),
);
}
// A job list for a page that polls. The list keeps its scroll box and changes only the jobs in it, so an update
// does not move the list back to the top. A poll without changes leaves the list alone.
export function jobPanel() {
const box = h('div', { class: 'jobs' });
const empty = h('p', { class: 'empty' }, 'No jobs.');
const el = h('div');
let shown = null;
let child = null;
const render = (list) => {
const key = JSON.stringify(list);
if (key === shown) return;
shown = key;
fill(box, list.map(jobItem));
const next = list.length ? box : empty;
if (child !== next) {
child = next;
fill(el, child);
}
};
return { el, render };
}
+175
View File
@@ -0,0 +1,175 @@
// Shared helpers: DOM building, API calls, toasts and formatting.
const PROPERTIES = new Set(['value', 'checked', 'disabled', 'selected', 'open']);
// Build an element. Strings become text nodes, so chapter text and titles are never parsed as HTML.
// Attributes that start with "on" are event listeners. Children are set before properties, so a
// select gets its value after its options exist.
export function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
el.append(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false));
for (const [key, value] of Object.entries(attrs || {})) {
if (value === null || value === undefined || value === false) continue;
if (key.startsWith('on')) el.addEventListener(key.slice(2), value);
else if (key === 'class') el.className = value;
else if (PROPERTIES.has(key)) el[key] = value;
else el.setAttribute(key, value === true ? '' : value);
}
return el;
}
export class ApiError extends Error {
constructor(message, status, data) {
super(message);
this.status = status;
this.data = data;
}
}
let onUnauthorized = () => {};
export const setUnauthorizedHandler = (handler) => {
onUnauthorized = handler;
};
// The header has a tab for the open story. The story and chapter pages report its title.
let onStoryTitle = () => {};
export const setStoryTitleHandler = (handler) => {
onStoryTitle = handler;
};
export const showStoryTitle = (storyId, title) => onStoryTitle(String(storyId), title);
// The paths are relative, so the app works under any path prefix.
export const apiUrl = (path) => `api/${path}`;
export async function api(path, method = 'GET', body) {
const headers = {};
// The server refuses changes without this header, which stops cross-site requests.
if (method !== 'GET') headers['X-Requested-With'] = 'bindery';
// The browser sets the multipart Content-Type of a FormData upload itself.
const form = body instanceof FormData;
if (body !== undefined && !form) headers['Content-Type'] = 'application/json';
const response = await fetch(apiUrl(path), {
method,
headers,
credentials: 'same-origin',
body: body === undefined || form ? body : JSON.stringify(body),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
if (response.status === 401 && !path.startsWith('auth/')) onUnauthorized();
throw new ApiError(data?.error || `${response.status} ${response.statusText}`, response.status, data);
}
return data;
}
export function toast(message, error = false) {
const box = document.getElementById('toasts');
const item = h('div', { class: error ? 'toast toast-error' : 'toast', role: error ? 'alert' : 'status' }, message);
box.append(item);
while (box.children.length > 4) box.firstElementChild.remove();
setTimeout(() => item.remove(), error ? 8000 : 4000);
}
export const showError = (e) => toast(e instanceof Error ? e.message : String(e), true);
// A button that is disabled while its action runs, and shows an error as a toast.
export function button(label, action, attrs = {}) {
const el = h('button', { type: 'button', ...attrs }, label);
el.addEventListener('click', async (event) => {
event.preventDefault();
el.disabled = true;
try {
await action();
} catch (e) {
showError(e);
} finally {
el.disabled = false;
}
});
return el;
}
const BADGES = {
ok: 'badge-ok',
done: 'badge-ok',
error: 'badge-err',
warn: 'badge-warn',
queued: 'badge-warn',
muted: 'badge-muted',
cancelled: 'badge-muted',
};
export const badge = (kind, text = kind, title) => h('span', { class: `badge ${BADGES[kind] || ''}`, title }, text);
export const link = (route, text) => h('a', { href: `#${route}` }, text);
// A link to another site. Story data sets the URL, so a URL that is not http or https, such as javascript:,
// stays as text.
export const externalLink = (url, text = url) =>
/^https?:\/\//i.test(url || '') ? h('a', { href: url, target: '_blank', rel: 'noreferrer noopener' }, text) : text;
// FanFicFare keeps &amp;, &lt; and &gt; in chapter titles, for its epub writer. &amp; goes last, so "&amp;lt;"
// becomes "&lt;".
export const chapterTitle = (title) =>
(title || '').replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&');
export function fmtDate(iso) {
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
// DOMParser does not run scripts or load images.
const htmlToText = (html) => new DOMParser().parseFromString(html || '', 'text/html').body.textContent || '';
export function formatField(key, value) {
if (value === undefined || value === null || value === '') return '';
if (key === 'series') {
const [name, index] = Array.isArray(value) ? value : [value, null];
if (!name) return '';
return index !== null && index !== undefined ? `${name} [${index}]` : String(name);
}
if (key === 'comments') {
const text = htmlToText(String(value)).trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
if (Array.isArray(value)) return value.join(', ');
return String(value);
}
// The description is HTML. The story page edits it as text with blank lines between paragraphs.
export function htmlToEditText(html) {
const body = new DOMParser().parseFromString(html || '', 'text/html').body;
body.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
body.querySelectorAll('p, div, li').forEach((el) => el.append('\n\n'));
return body.textContent.replace(/\n{3,}/g, '\n\n').trim();
}
export function textToHtml(text) {
const escape = (t) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return text
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean)
.map((p) => `<p>${escape(p).replace(/\n/g, '<br/>')}</p>`)
.join('');
}
export const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
// The server builds diff HTML and escapes every piece of chapter text in it.
export function diffView(html) {
const el = h('div', { class: 'diff' });
el.innerHTML = html;
return el;
}
// Replace the contents of an element.
export const fill = (el, ...children) => {
el.replaceChildren(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false));
return el;
};
export const checkbox = (checked, onchange, attrs = {}) =>
h('input', { type: 'checkbox', checked, onchange: (e) => onchange(e.target.checked), ...attrs });
+187
View File
@@ -0,0 +1,187 @@
// Start-up, login, the header and the hash routes (#/story/3 and so on).
import { chapterPage } from './chapter.js';
import { startPolling, stopPolling } from './jobs.js';
import { api, fill, h, setStoryTitleHandler, setUnauthorizedHandler, showError } from './lib.js';
import { settingsPage } from './settings.js';
import { storiesPage } from './stories.js';
import { storyPage } from './story.js';
const ROUTES = [
[/^\/$/, storiesPage, []],
[/^\/story\/(\d+)$/, storyPage, ['storyId']],
[/^\/story\/(\d+)\/chapter\/(\d+)$/, chapterPage, ['storyId', 'chapterId']],
[/^\/settings$/, settingsPage, []],
];
const app = document.getElementById('bindery-app');
let auth = null;
let config = null;
let cleanup = null;
let loggedIn = false;
let nav = null;
let storyTab = null;
let storyTabShown = false;
const currentPath = () => window.location.hash.replace(/^#/, '') || '/';
// The story of #/story/3 and of its chapter pages, such as #/story/3/chapter/5.
const currentStoryId = () => currentPath().match(/^\/story\/(\d+)(?:\/|$)/)?.[1];
// A story tab stays current on the chapter pages of its story.
function markCurrent(a, path) {
const target = a.getAttribute('href').replace(/^#/, '');
if (path === target || (target !== '/' && path.startsWith(`${target}/`))) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current');
}
function hideStoryTab() {
if (storyTabShown) storyTab.remove();
storyTabShown = false;
}
function notFound(main) {
document.title = 'Not found · Bindery';
fill(main, h('section', { class: 'card' }, h('h1', {}, 'Page not found'), h('p', {}, h('a', { href: '#/' }, 'Go to the story list'))));
}
function route() {
const main = document.getElementById('main');
if (!main) return;
cleanup?.();
cleanup = null;
const path = currentPath();
// The page of another story reports its own title.
if (storyTab?.getAttribute('href') !== `#/story/${currentStoryId()}`) hideStoryTab();
const found = ROUTES.find(([pattern]) => pattern.test(path));
if (found) {
const [pattern, page, names] = found;
const match = path.match(pattern);
cleanup = page(main, Object.fromEntries(names.map((name, i) => [name, match[i + 1]])), config) || null;
} else {
notFound(main);
}
for (const a of document.querySelectorAll('header nav a')) markCurrent(a, path);
window.scrollTo(0, 0);
}
async function logout() {
await api('auth/logout', 'POST');
if (auth.logout_url) {
window.location.href = auth.logout_url;
return;
}
await start();
}
function shell() {
const user =
auth.mode !== 'none' &&
auth.user && [
h('span', {}, auth.user),
(auth.mode === 'cwa' || auth.logout_url) &&
h('button', { type: 'button', onclick: () => logout().catch(showError) }, 'Log out'),
];
nav = h('nav', { 'aria-label': 'Main navigation' }, h('a', { href: '#/' }, 'Stories'), h('a', { href: '#/settings' }, 'Settings'));
storyTab = h('a', { class: 'story-tab' });
storyTabShown = false;
fill(
app,
h(
'header',
{},
h('a', { href: '#/', class: 'brand' }, h('img', { src: 'static/favicon.svg', alt: '', width: 22, height: 22 }), 'Bindery'),
h('div', { class: 'account' }, user),
nav,
),
h('main', { id: 'main' }),
);
}
function loginPage() {
document.title = 'Log in · Bindery';
if (auth.mode === 'proxy') {
fill(
app,
h(
'main',
{ class: 'login' },
h('div', { class: 'card' }, h('h1', {}, 'Bindery'), h('p', {}, 'Your reverse proxy did not send a user name. Log in through the proxy.')),
),
);
return;
}
const error = h('p', { class: 'error', role: 'alert' });
const username = h('input', { name: 'username', autocomplete: 'username', required: true });
const password = h('input', { name: 'password', type: 'password', autocomplete: 'current-password', required: true });
const remember = h('input', { type: 'checkbox', checked: true });
const submit = h('button', { type: 'submit', class: 'primary' }, 'Log in');
const form = h(
'form',
{
class: 'card',
onsubmit: async (e) => {
e.preventDefault();
submit.disabled = true;
error.textContent = '';
try {
await api('auth/login', 'POST', { username: username.value, password: password.value, remember: remember.checked });
await start();
} catch (err) {
error.textContent = err.message;
} finally {
submit.disabled = false;
}
},
},
h('h1', { class: 'brand' }, h('img', { src: 'static/favicon.svg', alt: '', width: 28, height: 28 }), 'Bindery'),
h('p', { class: 'hint' }, 'Log in with your CWA account.'),
h('label', {}, 'User name', username),
h('label', {}, 'Password', password),
h('label', {}, remember, ' Keep me logged in'),
error,
submit,
);
fill(app, h('main', { class: 'login' }, form));
username.focus();
}
async function start() {
cleanup?.();
cleanup = null;
auth = await api('auth/check');
if (!auth.authenticated) {
loggedIn = false;
stopPolling();
loginPage();
return;
}
config = await api('config');
loggedIn = true;
shell();
route();
startPolling();
}
setStoryTitleHandler((storyId, title) => {
if (!storyTab || !title || storyId !== currentStoryId()) return;
fill(storyTab, title);
storyTab.setAttribute('title', title);
storyTab.setAttribute('href', `#/story/${storyId}`);
markCurrent(storyTab, currentPath());
if (!storyTabShown) nav.append(storyTab);
storyTabShown = true;
});
// A 401 means the session ended. Show the login page once, not on each failed poll.
// A reverse proxy often needs a full page load to step in again, and its own login page has no
// form to retry from, so reload the page instead of re-checking auth inside the app.
setUnauthorizedHandler(() => {
if (!loggedIn) return;
if (auth?.mode === 'proxy') window.location.reload();
else start().catch(showError);
});
window.addEventListener('hashchange', () => {
if (loggedIn) route();
});
start().catch((e) => {
fill(app, h('main', { class: 'login' }, h('div', { class: 'card' }, h('h1', {}, 'Bindery'), h('p', { class: 'error' }, e.message))));
});
+244
View File
@@ -0,0 +1,244 @@
// Replacement rules: the editor and preview that the story page and the settings page share.
import { api, button, chapterTitle, checkbox, diffView, externalLink, fill, h, showError, toast } from './lib.js';
const RE_SYNTAX = 'https://docs.python.org/3/library/re.html#regular-expression-syntax';
const RE_HOWTO = 'https://docs.python.org/3/howto/regex.html';
// The rules use the regex module, which accepts the syntax of Python's re module.
export const regexDocs = () => [
externalLink(RE_SYNTAX, 'Python regular expressions'),
' (',
externalLink(RE_HOWTO, 'introduction'),
')',
];
const newRule = () => ({ pattern: '', replacement: '', flags: '', enabled: true, note: '' });
// Edits the ordered rules at an API path, such as "rules" or "stories/3/rules".
// Typing changes the rule objects directly, so only structural changes re-render.
export function rulesEditor(path, extraButtons = () => []) {
const isGlobal = path === 'rules';
let rules = [];
let invalid = null;
// Whether line breaks become paragraphs before the rules. For a story, null follows the global setting.
let paragraphs = null;
let globalParagraphs = false;
const list = h('div', { class: 'rules' });
const options = h('div', { class: 'controls' });
const move = (index, delta) => {
const target = index + delta;
if (target < 0 || target >= rules.length) return;
[rules[index], rules[target]] = [rules[target], rules[index]];
render();
};
const row = (rule, index) => {
const input = (field, label) =>
h('input', {
type: 'text',
class: field === 'note' ? 'rule-note' : field === 'flags' ? 'rule-flags' : null,
value: rule[field],
placeholder: label,
'aria-label': label,
spellcheck: 'false',
maxlength: field === 'flags' ? 4 : null,
oninput: (e) => {
rule[field] = e.target.value;
},
});
const small = (text, label, action) =>
h('button', { type: 'button', 'aria-label': label, title: label, onclick: action }, text);
return h(
'div',
{ class: index === invalid ? 'rule rule-invalid' : 'rule' },
h(
'div',
{ class: 'rule-top' },
checkbox(rule.enabled, (on) => (rule.enabled = on), { 'aria-label': 'Enabled', title: 'Enabled' }),
input('note', 'Note'),
),
input('pattern', 'Pattern'),
input('replacement', 'Replacement'),
h(
'div',
{ class: 'rule-bottom' },
input('flags', 'Flags'),
h(
'div',
{ class: 'rule-actions' },
small('↑', 'Move up', () => move(index, -1)),
small('↓', 'Move down', () => move(index, 1)),
small('✕', 'Remove', () => {
rules.splice(index, 1);
render();
}),
),
),
);
};
const render = () => fill(list, rules.length ? rules.map(row) : h('p', { class: 'empty' }, 'No rules.'));
const onOff = (on) => (on ? 'on' : 'off');
const renderOptions = () => {
const label = 'Turn line breaks into paragraphs before the rules run';
if (isGlobal) {
fill(options, h('label', {}, checkbox(Boolean(paragraphs), (on) => (paragraphs = on)), ` ${label}`));
return;
}
const choices = [
['', `Follow the global setting (${onOff(globalParagraphs)})`],
['on', 'On'],
['off', 'Off'],
];
const current = paragraphs === null ? '' : onOff(paragraphs);
fill(
options,
h('label', { for: 'paragraphs' }, label),
h(
'select',
{
id: 'paragraphs',
onchange: (e) => (paragraphs = e.target.value === '' ? null : e.target.value === 'on'),
},
choices.map(([value, text]) => h('option', { value, selected: value === current }, text)),
),
);
};
const loaded = (data) => {
rules = data.rules;
paragraphs = data.paragraphs;
globalParagraphs = data.global_paragraphs;
render();
renderOptions();
};
// Mark the rule that the server rejected, then pass the error on.
const markError = (e) => {
if (typeof e.data?.index === 'number') {
invalid = e.data.index;
render();
}
throw e;
};
async function save() {
invalid = null;
try {
loaded(await api(path, 'PUT', { rules, paragraphs }));
} catch (e) {
markError(e);
}
toast('Rules saved');
return true;
}
async function preview(chapterId, source) {
invalid = null;
try {
return await api(`${path}/preview`, 'POST', { rules, paragraphs, chapter_id: chapterId, source });
} catch (e) {
return markError(e);
}
}
api(path).then(loaded).catch(showError);
const element = h(
'div',
{},
options,
h(
'p',
{ class: 'hint' },
'This puts each piece of text between <br> tags in its own paragraph, and drops empty pieces. ' +
'A <br> inside other markup, such as italics, stays.',
),
list,
h(
'div',
{ class: 'controls' },
h('button', { type: 'button', onclick: () => { rules.push(newRule()); render(); } }, 'Add rule'),
button('Save', save, { class: 'primary' }),
extraButtons(save),
),
);
return { element, preview };
}
// Shows a diff of one chapter before and after the rules in the editor.
export function rulesPreview(editor, storyPicker = null) {
let chapterId = null;
let source = false;
const select = h('select', {
'aria-label': 'Chapter to preview',
onchange: (e) => (chapterId = Number(e.target.value)),
});
const result = h('div');
const run = button('Preview', async () => {
if (chapterId === null) return;
const diff = await editor.preview(chapterId, source);
const same = source
? 'The rules do not change this chapter.'
: 'The rules do not change the text of this chapter. To see changes to the markup, such as new paragraphs, ' +
'compare the HTML source.';
fill(result, diff.identical ? h('p', { class: 'empty' }, same) : diffView(diff.html));
});
const setChapters = (chapters) => {
if (!chapters.some((c) => c.id === chapterId)) chapterId = chapters[0]?.id ?? null;
fill(select, chapters.map((c) => h('option', { value: c.id, selected: c.id === chapterId }, chapterTitle(c.title))));
};
const element = h(
'details',
{ class: 'preview' },
h('summary', {}, 'Preview on a chapter'),
h(
'div',
{ class: 'controls' },
storyPicker,
select,
h('label', {}, checkbox(false, (on) => (source = on)), ' Compare HTML source'),
run,
),
result,
);
return { element, setChapters };
}
// The global rules card on the settings page.
export function globalRulesCard() {
const editor = rulesEditor('rules');
const stories = h('select', { 'aria-label': 'Story to preview' });
const preview = rulesPreview(editor, stories);
const loadChapters = async (storyId) => preview.setChapters((await api(`stories/${storyId}`)).chapters);
stories.addEventListener('change', (e) => loadChapters(Number(e.target.value)).catch(showError));
api('stories')
.then((list) => {
fill(stories, list.map((s) => h('option', { value: s.id }, s.title || s.url)));
if (list.length) return loadChapters(list[0].id);
return null;
})
.catch(showError);
return h(
'section',
{ class: 'card' },
h('h2', {}, 'Global replacement rules'),
h(
'p',
{ class: 'hint' },
"These rules run on every story's chapter HTML when a book is built, before the story's own rules. They use ",
regexDocs(),
'. Flags: i ignore case, s dot matches newline, m multiline, x verbose. ' +
'A changed rule takes effect on the next build of each book. Use "Rebuild book" on a story page to apply it now.',
),
editor.element,
preview.element,
);
}
+127
View File
@@ -0,0 +1,127 @@
// Settings: the recheck of recent chapters, the uploads to CWA, the global rules, and FanFicFare's personal.ini.
import { api, button, checkbox, externalLink, fill, h, showError, toast } from './lib.js';
import { globalRulesCard } from './rules.js';
const INI_DOCS = 'https://github.com/JimmXinu/FanFicFare/wiki/INI-File';
const DEFAULTS_INI = 'https://github.com/JimmXinu/FanFicFare/blob/main/fanficfare/defaults.ini';
export function settingsPage(main) {
document.title = 'Settings · Bindery';
const recent = h('input', { id: 'recent-chapters', type: 'number', min: 0, step: 1 });
const holdNewLinks = checkbox(false, () => {});
const pauseUploads = checkbox(false, () => {});
const ini = h('textarea', {
class: 'ini',
rows: 24,
spellcheck: 'false',
'aria-label': 'personal.ini',
});
const warnings = h('div');
const showWarnings = (list) =>
fill(
warnings,
list.length > 0 &&
h(
'div',
{ class: 'notice' },
h('p', {}, 'Bindery saved the file, but FanFicFare found these problems:'),
h('ul', {}, list.map((w) => h('li', {}, w))),
),
);
api('settings')
.then((settings) => {
recent.value = settings.recent_chapters;
holdNewLinks.checked = settings.hold_new_links;
pauseUploads.checked = settings.pause_uploads;
ini.value = settings.personal_ini;
})
.catch(showError);
fill(
main,
h(
'section',
{ class: 'card' },
h('h1', {}, 'Settings'),
h('h2', {}, 'Recheck recent chapters'),
h(
'div',
{ class: 'controls' },
h('label', { for: 'recent-chapters' }, 'When Bindery downloads new chapters, also recheck the last'),
recent,
'chapters',
),
h(
'p',
{ class: 'hint' },
'Authors most often fix their latest chapters. Each rechecked chapter is one more request to the site. ' +
'Enter 0 to turn this off. A story with its own interval for full rechecks does not use this setting.',
),
h(
'div',
{ class: 'controls' },
button('Save', async () => {
await api('settings', 'PUT', { recent_chapters: recent.value === '' ? 0 : Number(recent.value) });
toast('Saved');
}, { class: 'primary' }),
),
),
h(
'section',
{ class: 'card' },
h('h2', {}, 'Uploads to CWA'),
h('label', {}, holdNewLinks, ' Hold new links for approval'),
h(
'p',
{ class: 'hint' },
'The first upload of a new story waits for your approval. So does the first upload after a change of the ' +
'library link, also a link that you make. Check the linked book on the story page, and approve it there. ' +
'Later updates of an approved book go to CWA as usual.',
),
h('label', {}, pauseUploads, ' Pause all uploads'),
h(
'p',
{ class: 'hint' },
'Bindery downloads chapters and builds books, but sends no book to CWA. ' +
'When you turn the pause off, Bindery sends the books that wait.',
),
h(
'div',
{ class: 'controls' },
button('Save', async () => {
await api('settings', 'PUT', { hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked });
toast('Saved');
}, { class: 'primary' }),
),
),
globalRulesCard(),
h(
'section',
{ class: 'card' },
h('h2', {}, 'FanFicFare settings (personal.ini)'),
h(
'p',
{ class: 'hint' },
'FanFicFare reads this file at the start of each job, so a change needs no restart. See the ',
externalLink(INI_DOCS, 'personal.ini documentation'),
' and the ',
externalLink(DEFAULTS_INI, 'list of all settings with their defaults'),
'. The file can hold passwords for story sites. Each user who can log in to Bindery can read them here.',
),
ini,
warnings,
h(
'div',
{ class: 'controls' },
button('Save', async () => {
const saved = await api('settings', 'PUT', { personal_ini: ini.value });
showWarnings(saved.warnings);
toast(saved.warnings.length ? 'Saved, with warnings' : 'Saved');
}, { class: 'primary' }),
),
),
);
}
+268
View File
@@ -0,0 +1,268 @@
// The story list: add stories, filter them, see all jobs, and import epub files and library books.
import { jobPanel, refreshJobs, watchJobs } from './jobs.js';
import { api, badge, button, checkbox, fill, fmtDate, h, link, showError, toast } from './lib.js';
function libraryImport(config) {
if (!config.library_available) {
return h('p', { class: 'hint' }, 'Set CWA_URL to import books from the library.');
}
let candidates = [];
let selected = new Set();
let check = true;
const results = h('div');
const render = () => {
if (!candidates.length) {
fill(results, h('p', { class: 'empty' }, 'Every library book from a story site is tracked already.'));
return;
}
const toggle = (id, on) => {
if (on) selected.add(id);
else selected.delete(id);
};
fill(
results,
h(
'div',
{ class: 'table-wrap' },
h(
'table',
{},
h(
'thead',
{},
h(
'tr',
{},
h(
'th',
{},
checkbox(selected.size === candidates.length, (on) => {
selected = new Set(on ? candidates.map((c) => c.calibre_id) : []);
render();
}, { 'aria-label': 'Select all' }),
),
h('th', {}, 'Title'),
h('th', {}, 'Author'),
h('th', {}, 'Site'),
),
),
h(
'tbody',
{},
candidates.map((c) =>
h(
'tr',
{},
h('td', {}, checkbox(selected.has(c.calibre_id), (on) => toggle(c.calibre_id, on), { 'aria-label': c.title })),
h('td', {}, c.title),
h('td', {}, c.authors),
h('td', {}, c.site),
),
),
),
),
),
button(
'Import selected',
async () => {
const books = candidates
.filter((c) => selected.has(c.calibre_id))
.map((c) => ({ id: c.calibre_id, uuid: c.uuid, title: c.title }));
if (!books.length) return;
const jobs = await api('library/import', 'POST', { books, check });
toast(`Queued ${jobs.length} imports`);
fill(results);
refreshJobs();
},
{ class: 'primary' },
),
);
};
return h(
'div',
{},
h(
'p',
{ class: 'hint' },
'Finds library books whose publisher is a story site, for example books from the FanFicFare calibre plugin. ' +
'Importing downloads each ePub from CWA and keeps its chapters, including chapters the site no longer has.',
),
h(
'div',
{ class: 'controls' },
button('Find stories in the library', async () => {
candidates = await api('library/candidates');
selected = new Set(candidates.map((c) => c.calibre_id));
render();
}),
h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'),
),
results,
);
}
// Upload a FanFicFare ePub. Bindery stores its chapters as if it had downloaded
// them, so chapters that the site stubbed or removed stay in the book.
export function epubImport(storyId) {
let check = true;
const file = h('input', { type: 'file', accept: '.epub,application/epub+zip', 'aria-label': 'Epub file' });
const upload = button(
'Import',
async () => {
const chosen = file.files[0];
if (!chosen) {
toast('Choose an ePub file', true);
return;
}
const form = new FormData();
form.append('file', chosen);
form.append('check', String(check));
await api(storyId ? `stories/${storyId}/import-epub` : 'stories/import-epub', 'POST', form);
toast(`Queued the import of ${chosen.name}`);
file.value = '';
refreshJobs();
},
{ class: 'primary' },
);
const hint = storyId
? 'Adds the chapters of an ePub of this story that Bindery does not have, in the order of the ePub. ' +
'Chapters that Bindery has already keep their stored text.'
: 'Starts tracking the story of an ePub made by FanFicFare, with all of its chapters. ' +
'If Bindery tracks the story already, it adds the chapters that it does not have.';
return h(
'div',
{},
h('p', { class: 'hint' }, hint, ' The next update adds the chapters that the site published after the ePub.'),
h(
'div',
{ class: 'controls' },
file,
upload,
h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'),
),
);
}
function storyRow(s) {
return h(
'tr',
{},
h('td', {}, link(`/story/${s.id}`, s.title || s.url), s.busy && [' ', badge('running', 'working')]),
h('td', {}, s.author),
h('td', { class: 'story-site' }, s.site),
h(
'td',
{},
String(s.chapter_count),
s.offsite_count > 0 && [' ', badge('warn', `${s.offsite_count} removed`, 'Chapters the site no longer lists')],
),
h(
'td',
{},
s.status && badge(s.status),
s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')],
' ',
h('small', {}, s.message),
),
h('td', { class: 'date' }, fmtDate(s.last_checked)),
);
}
export function storiesPage(main, _params, config) {
document.title = 'Stories · Bindery';
let stories = null;
let filter = '';
const table = h('div', {}, h('p', { class: 'empty' }, 'Loading…'));
const jobs = jobPanel();
const render = () => {
const q = filter.trim().toLowerCase();
const shown = q
? stories.filter((s) => [s.title, s.author, s.site, s.url].some((v) => v?.toLowerCase().includes(q)))
: stories;
if (!shown.length) {
const text = stories.length ? 'No stories match.' : 'No stories yet. Add a URL above, or import an ePub.';
fill(table, h('p', { class: 'empty' }, text));
return;
}
const head = ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last check'].map((t) => h('th', {}, t));
fill(table, h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow)))));
};
const load = () =>
api('stories')
.then((list) => {
stories = list;
render();
})
.catch(showError);
const urls = h('textarea', {
class: 'story-urls',
rows: 3,
placeholder: 'https://www.royalroad.com/fiction/… (one URL per line)',
'aria-label': 'Story URLs',
});
const add = button(
'Add',
async () => {
if (!urls.value.trim()) return;
const result = await api('stories', 'POST', { urls: urls.value });
if (result.errors.length) toast(result.errors.join('\n'), true);
if (result.jobs.length) {
toast(`Queued ${result.jobs.length} ${result.jobs.length === 1 ? 'story' : 'stories'}`);
urls.value = '';
refreshJobs();
}
},
{ class: 'primary' },
);
fill(
main,
h(
'section',
{ class: 'card' },
h('h2', {}, 'Add stories'),
urls,
h(
'div',
{ class: 'controls' },
add,
h(
'span',
{ class: 'hint' },
'If CWA already has this story, Bindery links that book and keeps its chapters, then downloads only the chapters that are missing.',
),
),
),
h(
'section',
{ class: 'card' },
h(
'div',
{ class: 'section-heading' },
h('h2', {}, 'Stories'),
h('input', {
type: 'search',
placeholder: 'Filter',
'aria-label': 'Filter stories',
oninput: (e) => {
filter = e.target.value;
if (stories) render();
},
}),
),
table,
),
h('section', { class: 'card' }, h('h2', {}, 'Jobs'), jobs.el),
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import an epub file')), epubImport()),
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)),
);
load();
return watchJobs({ render: jobs.render, onFinished: load });
}
+643
View File
@@ -0,0 +1,643 @@
// One story: download modes, metadata locks, rules, chapters, epub import, jobs and the library link.
import { jobPanel, refreshJobs, watchJobs } from './jobs.js';
import {
api,
apiUrl,
badge,
button,
chapterTitle,
checkbox,
externalLink,
fill,
fmtDate,
formatField,
h,
htmlToEditText,
link,
plural,
showError,
showStoryTitle,
textToHtml,
toast,
} from './lib.js';
import { regexDocs, rulesEditor, rulesPreview } from './rules.js';
import { epubImport } from './stories.js';
const SITE_MODES = ['new', 'recheck', 'recheck_all', 'full', 'metadata'];
const MODE_HINTS = {
new:
'Downloads chapters that are not in the book yet. Each goes after the chapter that comes before it on the site. ' +
'Chapters that the site no longer lists stay in the book.',
recheck:
'Downloads the chapters you select in the chapter list again. A chapter whose text changed gets a new revision, ' +
'and you can compare or undo it.',
recheck_all:
'Downloads every chapter that is still on the site again, and new chapters too. This makes one request per chapter.',
full:
'Downloads every chapter again. Chapters that the site no longer lists are taken out of the book, but they stay ' +
'stored, and you can put them back.',
metadata: 'Updates the metadata in the ePub from the site. No chapters change.',
};
const siteValue = (story, key) => {
const site = story.site_values || {};
return key === 'series' ? [site.series, site.series_index] : site[key];
};
// The text of a metadata value in its edit box.
const editText = (key, value) => {
if (value === undefined || value === null) return '';
if (key === 'authors') return Array.isArray(value) ? value.join(' & ') : String(value);
if (key === 'comments') return htmlToEditText(String(value));
if (key === 'pubdate') return String(value).slice(0, 10);
return formatField(key, value);
};
const authorsText = (authors) => (Array.isArray(authors) ? authors.join(' & ') : authors);
const table = (headings, rows) =>
h(
'div',
{ class: 'table-wrap' },
h('table', {}, h('thead', {}, h('tr', {}, headings.map((t) => h('th', {}, t)))), h('tbody', {}, rows)),
);
export function storyPage(main, { storyId }, config) {
const id = Number(storyId);
const fields = config.metadata_fields;
let details = null;
let selected = new Set();
let mode = 'new';
let force = new Set();
let locked = new Set();
let autoUpdate = true;
let recheckDays = '';
// Unsaved changes survive a reload of the story, for example when a job ends.
let dirty = false;
let updatesDirty = false;
let drafts = {};
let initial = {};
const summary = h('section', { class: 'card story-summary' }, h('p', { class: 'empty' }, 'Loading…'));
const forceBox = h('div', { class: 'field-overrides' });
const metadata = h('div', { class: 'metadata' });
const updates = h('div');
const chapters = h('div');
const jobs = jobPanel();
const linked = h('div');
const searchResults = h('div');
const queue = async (jobMode, extra = {}) => {
const job = await api(`stories/${id}/download`, 'POST', { mode: jobMode, ...extra });
toast(`Queued: ${job.mode_label}`);
refreshJobs();
};
const load = async () => {
try {
details = await api(`stories/${id}`);
} catch (e) {
showError(e);
return;
}
if (!dirty) locked = new Set(details.story.locked_fields);
if (!updatesDirty) {
autoUpdate = Boolean(details.story.auto_update);
recheckDays = details.story.recheck_days ?? '';
}
const known = new Set(details.chapters.map((c) => c.id));
selected = new Set([...selected].filter((c) => known.has(c)));
document.title = `${details.story.title} · Bindery`;
showStoryTitle(storyId, details.story.title);
renderSummary();
renderForce();
renderMetadata();
renderUpdates();
renderChapters();
renderLinked();
preview.setChapters(details.chapters);
};
function renderSummary() {
const { story, book } = details;
let library = 'Not in the library yet';
if (book) library = `Library book ${book.id}`;
else if (story.calibre_id) library = `Library book ${story.calibre_id}`;
else if (story.ingested_at) library = 'Sent to CWA, unlinked';
fill(
summary,
// The version in the URL changes with the image, so the browser keeps the cover until the site changes it.
story.cover && h('img', { class: 'cover', src: apiUrl(`stories/${story.id}/cover?v=${story.cover}`), alt: '' }),
h(
'div',
{ class: 'story-heading' },
h('h1', {}, story.title),
h(
'p',
{ class: 'byline' },
`${story.author} · `,
externalLink(story.url, story.site),
` · ${library}`,
),
h(
'p',
{},
story.status && badge(story.status),
` ${story.message || ''}`,
story.last_checked && h('span', { class: 'last-check' }, ` Last check ${fmtDate(story.last_checked)}.`),
),
details.library_error && h('p', { class: 'error' }, `CWA: ${details.library_error}`),
holdNotice(),
),
);
}
const approve = async (uuid) => {
const result = await api(`stories/${id}/approve`, 'POST', { uuid });
toast(result.job ? 'Approved. Bindery rebuilds the book and sends it.' : 'Approved');
if (result.job) refreshJobs();
await load();
};
const approveAsNew = async () => {
const message =
'Unlink the library book, and send this story to CWA as a new book? If CWA already has the book, you get a ' +
'duplicate. In that case, search for the book under "Library link" and link it instead.';
if (!window.confirm(message)) return;
await api(`stories/${id}/link`, 'POST', { uuid: null });
await approve(null);
};
// Why the book did not go to CWA, and the approval of a new library link.
function holdNotice() {
const { story, book } = details;
const paused = story.upload_held && details.uploads_paused;
// A story that went to CWA without a link gets its book at the next update, and the approval comes then.
const approval = story.needs_approval && (story.calibre_uuid || !story.ingested_at);
if (!paused && !approval) return null;
let target = 'The next upload goes to CWA as a new book.';
if (book) {
target = `The next upload replaces the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`;
} else if (story.calibre_id) {
target = `The next upload replaces the ePub of library book ${story.calibre_id}.`;
}
return h(
'div',
{ class: 'notice' },
paused &&
h(
'p',
{},
'Uploads to CWA are paused. Bindery sends this book when you turn the pause off in ',
link('/settings', 'Settings'),
'.',
),
approval && [
h('p', {}, `${story.upload_held ? 'The book waits for your approval. ' : ''}${target} Make sure that this is right.`),
h(
'div',
{ class: 'controls' },
button(story.upload_held ? 'Approve and send' : 'Approve', () => approve(story.calibre_uuid), {
class: 'primary',
}),
story.calibre_uuid && button('Send as a new book instead', approveAsNew),
),
],
);
}
function renderForce() {
fill(
forceBox,
Object.entries(fields).map(([key, label]) =>
h(
'label',
{ class: details.story.locked_fields.includes(key) || key in details.story.pinned ? null : 'field-default' },
checkbox(force.has(key), (on) => (on ? force.add(key) : force.delete(key))),
` ${label}`,
),
),
);
}
function renderMetadata() {
const { story } = details;
initial = {};
const rows = Object.entries(fields).map(([key, label]) => {
const edited = key in story.pinned;
initial[key] = editText(key, edited ? story.pinned[key] : siteValue(story, key));
const attrs = {
'aria-label': label,
value: drafts[key] ?? initial[key],
placeholder: locked.has(key) ? 'Set on the next update' : '',
oninput: (e) => {
drafts[key] = e.target.value;
dirty = true;
},
};
const input =
key === 'comments'
? h('textarea', { ...attrs, rows: 4 })
: h('input', { ...attrs, type: key === 'pubdate' ? 'date' : 'text' });
const note =
edited &&
!locked.has(key) &&
h(
'div',
{ class: 'field-note' },
badge('edited', 'Edited', 'A change on the site replaces this value'),
button('Use the site value', async () => {
await api(`stories/${id}/metadata`, 'PUT', { values: { [key]: null } });
delete drafts[key];
toast(`${label} follows the site again`);
await load();
}),
);
return h(
'tr',
{},
h('th', { scope: 'row' }, label),
h(
'td',
{},
checkbox(locked.has(key), (on) => {
if (on) locked.add(key);
else locked.delete(key);
dirty = true;
renderMetadata();
}, { 'aria-label': `Lock ${label}` }),
),
h('td', {}, input, note),
h('td', {}, formatField(key, siteValue(story, key))),
);
});
fill(
metadata,
table(['Field', 'Locked', 'Value', 'Latest from site'], rows),
h(
'div',
{ class: 'controls' },
button('Save', async () => {
await api(`stories/${id}/settings`, 'PUT', { locked_fields: [...locked] });
const values = {};
for (const [key, text] of Object.entries(drafts)) {
if (text !== initial[key]) values[key] = key === 'comments' ? textToHtml(text) : text;
}
let message = 'Saved';
if (Object.keys(values).length) {
const result = await api(`stories/${id}/metadata`, 'PUT', { values });
if (result.job) {
message = 'Saved. Bindery rebuilds the book with the new metadata.';
refreshJobs();
}
}
dirty = false;
drafts = {};
toast(message);
await load();
}, { class: 'primary' }),
),
);
}
function renderUpdates() {
const { story } = details;
const recent = details.recent_chapters;
const interval = config.update_interval;
const days = h('input', {
id: 'recheck-days',
type: 'number',
min: 0,
step: 1,
value: recheckDays,
oninput: (e) => {
recheckDays = e.target.value;
updatesDirty = true;
},
});
const schedule = interval
? `Bindery looks for new chapters every ${plural(interval, 'hour')}.`
: 'Scheduled updates are off, because UPDATE_INTERVAL_HOURS is 0.';
const withoutFull = recent
? `Without full rechecks, each download of new chapters also rechecks the last ${plural(recent, 'chapter')}. ` +
'The Settings page sets that number.'
: 'Without full rechecks, a download of new chapters does not recheck stored chapters. ' +
'The Settings page can turn on a recheck of the latest chapters.';
fill(
updates,
h(
'label',
{},
checkbox(autoUpdate, (on) => {
autoUpdate = on;
updatesDirty = true;
}),
' Check for new chapters automatically',
),
h('p', { class: 'hint' }, `${schedule} Clear this for a finished story. You can still download it by hand.`),
h('div', { class: 'controls' }, h('label', { for: 'recheck-days' }, 'Recheck all chapters every'), days, 'days'),
h(
'p',
{ class: 'hint' },
'A full recheck finds chapters that the author edited. When one is due, it takes the place of the next ' +
'scheduled update. Leave the box empty for no full rechecks. ',
withoutFull,
story.last_full_recheck ? ` The last full recheck was ${fmtDate(story.last_full_recheck)}.` : '',
),
h(
'div',
{ class: 'controls' },
button('Save', async () => {
const recheck = recheckDays === '' ? 0 : Number(recheckDays);
await api(`stories/${id}/settings`, 'PUT', { auto_update: autoUpdate, recheck_days: recheck });
updatesDirty = false;
toast('Saved');
await load();
}, { class: 'primary' }),
),
);
}
function renderChapters() {
const list = details.chapters;
let number = 0;
const rows = list.map((c) => {
if (c.included) number += 1;
const route = `/story/${id}/chapter/${c.id}`;
return h(
'tr',
{ class: c.included ? null : 'chapter-excluded' },
h(
'td',
{},
checkbox(selected.has(c.id), (on) => (on ? selected.add(c.id) : selected.delete(c.id)), {
'aria-label': `Select ${chapterTitle(c.title)}`,
}),
),
h('td', {}, c.included ? String(number) : ''),
h('td', {}, link(route, chapterTitle(c.title))),
h(
'td',
{ class: 'badges' },
!c.on_site && badge('warn', 'removed from site', 'The site no longer lists this chapter'),
!c.included && badge('muted', 'not in book'),
c.revisions > 1 && badge('info', c.current_rev === c.revisions ? 'changed' : `using revision ${c.current_rev}`),
),
h('td', {}, c.revisions > 1 ? link(route, `${c.revisions}, compare`) : String(c.revisions)),
h('td', { class: 'date' }, fmtDate(c.added_at)),
);
});
const all = checkbox(list.length > 0 && selected.size === list.length, (on) => {
selected = new Set(on ? list.map((c) => c.id) : []);
renderChapters();
}, { 'aria-label': 'Select all chapters' });
fill(
chapters,
table(
[all, '#', 'Title', 'State', 'Revisions', 'Added'],
rows.length ? rows : h('tr', {}, h('td', { colspan: 6, class: 'empty' }, 'No chapters stored yet.')),
),
);
}
// A story that went to CWA but has no library book cannot get updates. The user links
// it to the right book, or sends it again, for example after CWA rejected the file.
const sendAsNew = async () => {
const message =
'Send this story to CWA as a new book? If CWA already has the book, you get a duplicate. ' +
'In that case, search for the book below and link it instead.';
if (!window.confirm(message)) return;
await api(`stories/${id}/link`, 'POST', { uuid: null });
await queue('rebuild');
await load();
};
function renderLinked() {
const { book, story } = details;
fill(
linked,
book && h('p', {}, `Linked to library book ${book.id}: ${book.title} · ${authorsText(book.authors)}`),
!story.calibre_id &&
story.ingested_at &&
h(
'div',
{ class: 'controls' },
h('span', {}, 'This story went to CWA, but it is not linked to a library book.'),
button('Send to CWA as a new book', sendAsNew),
),
);
}
const setIncluded = async (included) => {
if (!selected.size) {
toast('Select chapters first', true);
return;
}
await api(`stories/${id}/chapters`, 'POST', { chapter_ids: [...selected], included });
toast(included ? 'Chapters put back. The book is being rebuilt.' : 'Chapters taken out. The book is being rebuilt.');
refreshJobs();
await load();
};
const modeHint = h('p', { class: 'hint' }, MODE_HINTS[mode]);
const modes = SITE_MODES.map((m) =>
h(
'label',
{},
h('input', {
type: 'radio',
name: 'mode',
value: m,
checked: m === mode,
onchange: () => {
mode = m;
modeHint.textContent = MODE_HINTS[m];
},
}),
` ${config.mode_labels[m] || m}`,
),
);
const start = async () => {
if (mode === 'recheck' && !selected.size) {
toast('Select the chapters to recheck in the chapter list', true);
return;
}
await queue(mode, { force_fields: [...force], chapter_ids: mode === 'recheck' ? [...selected] : [] });
force = new Set();
renderForce();
};
const rules = rulesEditor(`stories/${id}/rules`, (save) =>
button('Save and rebuild', async () => {
if (await save()) await queue('rebuild');
}),
);
const preview = rulesPreview(rules);
const query = h('input', {
type: 'search',
placeholder: 'Search the library by title or author',
'aria-label': 'Search the library',
});
const search = async () => {
if (!query.value.trim()) return;
const books = await api(`library/search?q=${encodeURIComponent(query.value.trim())}`);
if (!books.length) {
fill(searchResults, h('p', { class: 'empty' }, 'No library books match.'));
return;
}
fill(
searchResults,
h(
'ul',
{ class: 'library-results' },
books.map((b) =>
h(
'li',
{},
button(
'Link',
async () => {
await api(`stories/${id}/link`, 'POST', { uuid: b.uuid });
toast(`Linked to library book ${b.id}`);
fill(searchResults);
await load();
},
{ disabled: Boolean(b.tracked_as) || details?.book?.id === b.id },
),
h('span', {}, `${b.title} · ${authorsText(b.authors)}`),
h('small', {}, `book ${b.id}${b.tracked_as ? `, tracked as ${b.tracked_as}` : ''}`),
),
),
),
);
};
fill(
main,
summary,
h(
'section',
{ class: 'card' },
h('h2', {}, 'Download'),
h('fieldset', {}, h('legend', {}, 'What to download'), modes, modeHint),
h(
'details',
{ class: 'force-fields' },
h('summary', {}, 'Force update metadata fields'),
forceBox,
h('p', { class: 'hint' }, "A selected field takes the site's current value, even if it is locked or you edited it."),
),
h(
'div',
{ class: 'controls' },
button('Start', start, { class: 'primary' }),
button('Rebuild book', () => queue('rebuild')),
h('a', { download: true, href: apiUrl(`stories/${id}/epub`) }, 'Download ePub'),
),
),
h('section', { class: 'card' }, h('h2', {}, 'Updates'), updates),
h(
'section',
{ class: 'card' },
h('h2', {}, 'Metadata'),
h(
'p',
{ class: 'hint' },
'Bindery writes this metadata into the ePub. You can edit each field. A locked field keeps its value on ' +
'update, unless you take it from the site above. An unlocked field keeps an edited value until the site ' +
"changes that field. CWA reads the ePub's metadata only when it first imports a book, so later changes " +
'show in the ePub but not in the CWA catalog.',
),
metadata,
),
h(
'section',
{ class: 'card' },
h('h2', {}, 'Replacement rules'),
h(
'p',
{ class: 'hint' },
'Rules are ',
regexDocs(),
" that run on each chapter's HTML when the book is built. The ",
link('/settings', 'global rules'),
' run first. Stored chapters never change, so you can edit a rule and rebuild. ' +
'Flags: i ignore case, s dot matches newline, m multiline, x verbose.',
),
rules.element,
preview.element,
),
h(
'section',
{ class: 'card' },
h(
'div',
{ class: 'section-heading' },
h('h2', {}, 'Chapters'),
h(
'div',
{ class: 'controls' },
button('Recheck selected', () =>
selected.size ? queue('recheck', { chapter_ids: [...selected] }) : toast('Select chapters first', true),
),
button('Take out of book', () => setIncluded(false)),
button('Put back in book', () => setIncluded(true)),
),
),
chapters,
),
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import chapters from an epub')), epubImport(id)),
h('section', { class: 'card' }, h('h2', {}, 'Recent jobs'), jobs.el),
h(
'section',
{ class: 'card' },
h('h2', {}, 'Library link'),
h(
'p',
{ class: 'hint' },
'Updates go to the linked library book. The first version of a story goes to the CWA ingest folder. ' +
'For the next update, Bindery finds the new book by its title and the story URL inside the ePub. ' +
'If that fails, the update fails with an error. Then link the right book here, or send the story to CWA again.',
),
linked,
config.library_available
? h(
'form',
{
class: 'library-search',
onsubmit: (e) => {
e.preventDefault();
search().catch(showError);
},
},
query,
h('button', { type: 'submit' }, 'Search'),
)
: h('p', { class: 'hint' }, 'Set CWA_URL to search the library.'),
searchResults,
),
h(
'section',
{ class: 'card card-danger' },
h('h2', {}, 'Stop tracking'),
h('p', { class: 'hint' }, 'Deletes the stored chapters, revisions and rules for this story. The library book stays.'),
button(
'Stop tracking this story',
async () => {
const message = 'Stop tracking this story? Stored chapters, revisions and rules are deleted. The library book stays.';
if (!window.confirm(message)) return;
await api(`stories/${id}`, 'DELETE');
window.location.hash = '#/';
},
{ class: 'danger' },
),
),
);
load();
return watchJobs({ storyId: id, render: jobs.render, onFinished: load });
}
+780
View File
@@ -0,0 +1,780 @@
:root {
color-scheme: light dark;
--bg: #f4f5f7;
--surface: #ffffff;
--header-bg: #eaf0f8;
--card-bg: #fafafa;
--text: #1d2330;
--muted: #5f6878;
--border: #d9dde4;
--accent: #2f6fd6;
--accent-text: #ffffff;
--ok: #1f7a45;
--ok-bg: #e3f4ea;
--warn: #8a5a00;
--warn-bg: #fff2d6;
--err: #b3261e;
--err-bg: #fbe4e2;
--info: #22529e;
--info-bg: #e2ecfb;
--ins-bg: #dcf5e3;
--del-bg: #fbe0de;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* Text sizes. They use rem, so they do not shrink when one small element sits in another. */
--text-sm: 0.875rem;
--text-xs: 0.8rem;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171c;
--surface: #1d2128;
--header-bg: #202b3d;
--card-bg: #262626;
--text: #e3e6eb;
--muted: #9aa3b2;
--border: #343a45;
--accent: #6b9cf0;
--accent-text: #0e1420;
--ok: #74d49a;
--ok-bg: #1d3527;
--warn: #f0c060;
--warn-bg: #3a2f14;
--err: #f28b82;
--err-bg: #3d1f1d;
--info: #9cbcf5;
--info-bg: #1f2c44;
--ins-bg: #1c3a26;
--del-bg: #43211f;
}
}
* {
box-sizing: border-box;
}
html {
font-size: 15px;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 1rem/1.5 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
a {
color: var(--accent);
}
a:focus-visible,
button:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
h1,
h2,
p {
margin: 0 0 8px;
}
h1,
h2 {
line-height: 1.3;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.1rem;
margin-bottom: 10px;
}
h1 + h2 {
margin-top: 16px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
font-weight: 600;
color: var(--muted);
font-size: var(--text-sm);
white-space: nowrap;
}
tbody tr:last-child > td,
tbody tr:last-child > th {
border-bottom: 0;
}
td.actions {
white-space: nowrap;
}
td.actions button + button {
margin-left: 6px;
}
fieldset {
border: 0;
margin: 0 0 12px;
padding: 0;
}
legend {
font-weight: 600;
padding: 0;
margin-bottom: 4px;
}
input[type='checkbox'] {
width: 1rem;
height: 1rem;
margin: 0;
vertical-align: -2px;
accent-color: var(--accent);
}
progress {
width: 100%;
margin-top: 6px;
}
/* Layout */
header,
main {
width: min(1100px, calc(100% - 32px));
margin: 0px auto;
}
header,
main:not(.login) {
background: var(--surface);
border: 1px solid var(--border);
box-shadow: 0 4px 16px rgb(0 0 0 / 5%);
}
main:not(.login) {
border-top: 0px;
}
header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px 24px;
padding: 20px 24px 0;
background: var(--header-bg);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
margin-top: 8px;
}
/* min-width: 0 lets the nav get narrower than its tabs, so on a phone the story tab shrinks
to an ellipsis instead of pushing the nav past the edge of the header. */
header nav {
display: flex;
flex-basis: 100%;
min-width: 0;
gap: 4px;
margin-bottom: -1px;
}
header nav a {
padding: 10px 18px;
border: 1px solid var(--border);
border-radius: 8px 8px 0 0;
background: var(--bg);
color: var(--muted);
font-weight: 600;
text-decoration: none;
}
header nav a:hover {
background: var(--surface);
color: var(--text);
}
header nav a[aria-current='page'] {
background: var(--surface);
border-bottom-color: var(--surface);
color: var(--text);
}
/* A long story title ends in an ellipsis. The title attribute shows all of it. */
header nav a.story-tab {
max-width: 24ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-size: 1.1rem;
font-weight: 700;
color: var(--text);
text-decoration: none;
}
header .brand {
font-size: 1.65rem;
line-height: 1.2;
letter-spacing: -0.025em;
}
header .brand img {
width: 32px;
height: 32px;
}
main {
padding: 20px;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
margin-bottom: 8px;
}
main > :last-child {
margin-bottom: 0;
}
main.login {
max-width: 400px;
margin-top: 64px;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
@media (max-width: 600px) {
header,
main {
width: calc(100% - 24px);
padding: 12px;
}
header {
padding-bottom: 0;
}
.card {
padding: 14px;
}
}
.card-danger {
border-color: var(--err);
}
details > summary {
cursor: pointer;
}
/* Adjoining margins collapse to the largest one, and the fieldset above ends in a p.hint with
an 8px bottom margin. These both go to 0, so that 8px is the whole gap: the smallest this
space can be without changing the hint's own margin. */
fieldset:has(+ .force-fields) {
margin-bottom: 0;
}
.force-fields {
margin: 0 0 12px;
}
details > summary h2 {
display: inline;
}
details[open] > summary {
margin-bottom: 10px;
}
.controls,
.section-heading,
.account,
.job-heading,
.rule-actions,
.field-overrides,
.library-search,
.library-results > li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.controls,
.field-overrides,
.library-search,
.library-results > li {
margin-top: 8px;
}
.section-heading {
justify-content: space-between;
}
.login form,
.login label:has(> input:not([type='checkbox'])) {
display: flex;
flex-direction: column;
gap: 8px;
}
.account {
margin-left: auto;
color: var(--muted);
font-size: var(--text-sm);
}
.library-search > input {
flex-grow: 1;
}
label:has(> input[type='radio']) {
display: block;
}
.library-results {
list-style: none;
margin: 8px 0 0;
padding: 0;
}
/* Text */
.hint {
max-width: 80ch;
color: var(--muted);
font-size: var(--text-sm);
margin: 4px 0 8px;
}
.controls .hint,
.controls .error {
margin: 0;
}
.notice {
margin: 12px 0;
padding: 10px 14px;
border-left: 3px solid var(--warn);
border-radius: 6px;
background: var(--warn-bg);
}
.notice p {
margin: 0;
}
.notice ul {
margin: 4px 0 0;
padding-left: 20px;
}
.byline,
.last-check,
.story-site,
.field-default,
small,
.date,
.job time {
color: var(--muted);
}
small,
.date,
.job time,
.breadcrumbs {
font-size: var(--text-sm);
}
.breadcrumbs {
margin-bottom: 8px;
}
.ini,
.story-urls,
.rule input:not(.rule-note) {
font-family: var(--mono);
}
/* A monospace font looks larger than the text font at the same size. The line height keeps these fields as tall as
the note field in the same row. */
.rule input:not(.rule-note) {
font-size: var(--text-sm);
line-height: 1.5rem;
}
.date {
white-space: nowrap;
}
.error {
color: var(--err);
}
.error:empty {
display: none;
}
.empty {
color: var(--muted);
font-style: italic;
}
.chapter-excluded {
opacity: 0.6;
}
/* Controls */
button,
a[download] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
font: inherit;
cursor: pointer;
text-decoration: none;
}
button:hover:not(:disabled),
a[download]:hover {
border-color: var(--accent);
}
button.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
button.danger:hover:not(:disabled) {
border-color: var(--err);
background: var(--err-bg);
}
button:disabled {
opacity: 0.55;
cursor: default;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
button.danger {
border-color: var(--err);
color: var(--err);
}
:is(.account, .actions, .rule-actions, .job, .library-results) button {
padding: 2px 10px;
font-size: var(--text-sm);
}
.field-note button {
padding: 0;
border: 0;
background: none;
color: var(--accent);
text-decoration: underline;
}
/* A line under a metadata field with an edited value */
.field-note {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
font-size: var(--text-sm);
}
input:not([type='checkbox'], [type='radio']),
select,
textarea {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font: inherit;
min-width: 0;
max-width: 100%;
}
input:not([type='checkbox'], [type='radio']):focus,
select:focus,
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
input[type='number'] {
width: 6em;
}
textarea {
width: 100%;
resize: vertical;
line-height: 1.45;
}
.metadata input:not([type='checkbox']) {
width: 100%;
}
.ini,
.story-urls {
font-size: var(--text-sm);
}
.badge {
display: inline-block;
padding: 0 7px;
border-radius: 10px;
font-size: var(--text-xs);
line-height: 1.6;
font-weight: 600;
background: var(--info-bg);
color: var(--info);
white-space: nowrap;
}
.badge-ok {
background: var(--ok-bg);
color: var(--ok);
}
.badge-err {
background: var(--err-bg);
color: var(--err);
}
.badge-warn {
background: var(--warn-bg);
color: var(--warn);
}
.badge-muted {
background: var(--bg);
color: var(--muted);
}
.badges .badge {
margin-right: 4px;
}
.table-wrap {
overflow-x: auto;
}
/* Jobs */
.jobs {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 420px;
overflow-y: auto;
}
.job {
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
}
.job-heading > time {
margin-left: auto;
}
.job > p {
margin: 0;
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
/* Rules */
.rules {
display: flex;
flex-direction: column;
gap: 6px;
}
/* A rule is a card, like a row of the chapter list, the same at every width. The pattern and the
replacement each need the room of their own line, so nothing here shares a line with them. */
.rule {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
}
.rule-top,
.rule-bottom {
display: flex;
align-items: center;
gap: 6px;
}
/* Fits its 4-character maximum, and no more. */
.rule-flags {
width: 4.5em;
flex: none;
}
.rule-top .rule-note {
flex: 1;
}
.rule-actions {
display: flex;
flex: 1;
gap: 4px;
justify-content: flex-end;
}
/* The site cover sits at the top left of the story page, beside the title. */
.story-summary {
display: flex;
align-items: flex-start;
gap: 20px;
}
.story-heading {
flex: 1;
min-width: 0;
}
.story-summary .cover {
flex: none;
width: 140px;
height: auto;
border-radius: 6px;
box-shadow: 0 2px 8px rgb(0 0 0 / 20%);
}
@media (max-width: 600px) {
.story-summary {
gap: 12px;
}
.story-summary .cover {
width: 88px;
}
}
.rule-invalid {
outline: 2px solid var(--err);
}
.preview {
margin-top: 12px;
}
/* Toasts */
#toasts {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(480px, calc(100vw - 32px));
}
.toast {
padding: 10px 16px;
border: 1px solid var(--accent);
border-radius: 8px;
background: var(--surface);
white-space: pre-line;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.toast-error {
border-color: var(--err);
color: var(--err);
}
/* The server builds diff HTML with these classes, and escapes all text in it. */
.diff {
max-height: 70vh;
overflow-y: auto;
margin-top: 10px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 12px;
}
.diff p {
margin: 6px 0;
padding: 2px 6px;
border-radius: 4px;
overflow-wrap: anywhere;
}
.diff .diff-eq {
color: var(--muted);
}
.diff .diff-ins,
.diff ins {
background: var(--ins-bg);
text-decoration: none;
}
.diff .diff-del,
.diff del {
background: var(--del-bg);
}
.diff .diff-del {
text-decoration: line-through;
}
.diff .diff-skip {
text-align: center;
color: var(--muted);
font-style: italic;
font-size: var(--text-sm);
}
+30
View File
@@ -1,5 +1,7 @@
"""The Flask app: login, the JSON API and the static web interface."""
import base64
import hashlib
import io
import logging
import os
@@ -8,6 +10,7 @@ import secrets
import uuid
from collections.abc import Callable, Iterable
from datetime import timedelta
from pathlib import Path
from typing import Any
from flask import Flask, Response, g, jsonify, request, send_file, session
@@ -397,6 +400,33 @@ def library_import() -> Any:
return orchestrator.queue_import(books, bool(data.get("check", True)))
# Web interface. It uses relative URLs and hash routes, so it works under any path.
def _content_security_policy(html: str) -> str:
"""Allow only Bindery's own files, and the inline scripts of the page by their hashes.
The trailing-slash redirect in index.html must be inline: before the
redirect, a relative script URL misses the path prefix.
"""
hashes = [
"'sha256-" + base64.b64encode(hashlib.sha256(script.encode()).digest()).decode() + "'"
for script in re.findall(r"<script>(.*?)</script>", html, re.DOTALL)
]
return (
f"default-src 'self'; script-src 'self' {' '.join(hashes)}; object-src 'none'; "
"base-uri 'none'; form-action 'self'; frame-ancestors 'self'"
)
@app.get("/")
def index() -> Any:
response = app.send_static_file("index.html")
html = Path(app.static_folder or "", "index.html").read_text(encoding="utf-8")
response.headers["Content-Security-Policy"] = _content_security_policy(html)
return response
# Gunicorn runs one worker, and the dev server runs without the reloader, so this runs once.
if config.START_THREADS:
orchestrator.start_threads()