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()
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, server, nodes, find, field, emit, settle } from './flow-helpers.mjs';
const ui = install();
const { api, setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js');
const { stopPolling } = await import('../../bindery/static/js/jobs.js');
test('login retry, navigation, session expiry and logout preserve the authentication lifecycle', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const backend = server(t);
let auth = { authenticated: false, mode: 'cwa', user: null };
backend.on('GET', 'auth/check', () => auth);
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
backend.on('GET', 'rules', { rules: [], paragraphs: false });
backend.on('GET', 'settings', { recent_chapters: 3, personal_ini: '' });
backend.on('GET', 'stories/1', { error: 'Story unavailable' }, 404);
backend.on('GET', 'stories/1/rules', { rules: [], paragraphs: null });
backend.on('GET', 'chapters/2/diff?', { error: 'Chapter unavailable' }, 404);
backend.on('GET', 'expired', { error: 'Session ended' }, 401);
backend.on('POST', 'auth/logout', () => { auth = { ...auth, authenticated: false }; return {}; });
t.after(() => { stopPolling(); setUnauthorizedHandler(() => {}); });
await import('../../bindery/static/js/main.js');
await settle();
assert.equal(document.activeElement, field(ui.app, 'username'));
assert.equal(backend.sent('GET', 'config').length, 0);
ui.windowEvents.hashchange();
assert.equal(backend.sent('GET', 'stories').length, 0);
const form = () => nodes(ui.app).find((el) => el.tagName === 'form');
field(ui.app, 'username').value = 'reader';
field(ui.app, 'password').value = 'wrong';
nodes(form()).find((el) => el.attributes.type === 'checkbox').checked = false;
backend.on('POST', 'auth/login', { error: 'Wrong password' }, 401);
await emit(form(), 'submit');
assert.match(ui.app.textContent, /Wrong password/);
assert.equal(find(ui.app, 'button', 'Log in').disabled, false);
assert.deepEqual(backend.sent('POST', 'auth/login')[0].body, { username: 'reader', password: 'wrong', remember: false });
field(ui.app, 'password').value = 'correct';
backend.on('POST', 'auth/login', () => { auth = { authenticated: true, mode: 'cwa', user: 'reader' }; return {}; });
await emit(form(), 'submit');
await settle();
assert.ok(find(ui.app, 'button', 'Log out'));
assert.equal(backend.sent('GET', 'config').length, 1);
for (const path of ['', '#/settings', '#/story/1', '#/story/1/chapter/2', '#/missing']) {
window.location.hash = path;
ui.windowEvents.hashchange();
await settle();
}
assert.match(ui.app.textContent, /Page not found/);
assert.equal(backend.sent('GET', 'stories/1/rules').length, 1);
assert.equal(backend.sent('GET', 'chapters/2/diff?').length, 1);
auth = { authenticated: false, mode: 'cwa' };
await assert.rejects(api('expired'), /Session ended/);
await settle();
assert.ok(find(ui.app, 'button', 'Log in'));
const polls = backend.sent('GET', 'jobs').length;
t.mock.timers.tick(60000);
await settle();
assert.equal(backend.sent('GET', 'jobs').length, polls);
const checks = backend.sent('GET', 'auth/check').length;
await assert.rejects(api('expired'), /Session ended/);
assert.equal(backend.sent('GET', 'auth/check').length, checks);
await emit(form(), 'submit');
await settle();
await emit(find(ui.app, 'button', 'Log out'), 'click');
await settle();
assert.ok(find(ui.app, 'button', 'Log in'));
assert.equal(backend.sent('POST', 'auth/logout').length, 1);
backend.on('POST', 'auth/login', () => {
auth = { authenticated: true, mode: 'proxy', user: 'reader', logout_url: 'https://login.example/logout' };
return {};
});
await emit(form(), 'submit');
await settle();
await emit(find(ui.app, 'button', 'Log out'), 'click');
assert.equal(window.location.href, 'https://login.example/logout');
});
+100
View File
@@ -0,0 +1,100 @@
// Small event harness for testing requests and state transitions, not layout.
import assert from 'node:assert/strict';
export class Element {
constructor(tag) {
this.tagName = tag;
this.attributes = {};
this.children = [];
this.listeners = {};
this.className = '';
this.value = '';
this.checked = false;
this.disabled = false;
this.files = [];
}
append(...children) {
for (const child of children) {
if (child instanceof Element) child.parent = this;
this.children.push(child);
}
}
replaceChildren(...children) {
for (const child of this.children) if (child instanceof Element) child.parent = null;
this.children = [];
this.append(...children);
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
removeAttribute(name) { delete this.attributes[name]; }
addEventListener(name, fn) { this.listeners[name] = fn; }
focus() { document.activeElement = this; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); }
get firstElementChild() { return this.children.find((child) => child instanceof Element); }
get textContent() { return this.children.map((child) => child instanceof Element ? child.textContent : String(child)).join(''); }
set textContent(value) { this.replaceChildren(value); }
}
export const nodes = (root) => root instanceof Element ? [root, ...root.children.flatMap(nodes)] : [];
export const find = (root, tag, text) => nodes(root).find((el) => el.tagName === tag && el.textContent === text);
export const field = (root, label) => nodes(root).find((el) => el.attributes['aria-label'] === label || el.attributes.id === label || el.attributes.name === label);
export const section = (root, title) => nodes(root).find((el) => ['section', 'details'].includes(el.tagName) && (find(el, 'h2', title) || find(el, 'h1', title)));
export const emit = (el, event) => {
assert.ok(el, `Missing control for ${event}`);
assert.ok(el.listeners[event], `No ${event} handler on ${el.tagName}`);
return el.listeners[event]({ target: el, preventDefault() {} });
};
export const click = (root, label) => emit(find(root, 'button', label), 'click');
export const input = (el, value) => { el.value = value; return emit(el, 'input'); };
export const change = (el, value) => {
if (el.attributes.type === 'checkbox') el.checked = value;
else el.value = value;
return emit(el, 'change');
};
export const settle = async () => { for (let i = 0; i < 4; i++) await new Promise((resolve) => setImmediate(resolve)); };
export function install() {
const app = new Element('div');
const toasts = new Element('div');
const documentEvents = {};
const windowEvents = {};
globalThis.document = {
hidden: false,
createElement: (tag) => new Element(tag),
getElementById: (id) => id === 'bindery-app' ? app : id === 'toasts' ? toasts : nodes(app).find((el) => el.attributes.id === id),
addEventListener: (name, fn) => { documentEvents[name] = fn; },
querySelectorAll: () => nodes(app).filter((el) => el.tagName === 'nav').flatMap((el) => nodes(el).filter((child) => child.tagName === 'a')),
};
globalThis.window = {
location: { hash: '#/', reload() {} }, confirm: () => true, scrollTo() {},
addEventListener: (name, fn) => { windowEvents[name] = fn; },
};
globalThis.DOMParser = class {
parseFromString(html) {
return { body: { textContent: html.replace(/<[^>]*>/g, ''), querySelectorAll: () => [] } };
}
};
return { app, toasts, documentEvents, windowEvents };
}
export function server(t) {
const routes = new Map();
const requests = [];
const unexpected = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
const request = { method: options.method, path: url.replace(/^api\//, ''),
body: typeof options.body === 'string' ? JSON.parse(options.body) : options.body, headers: options.headers };
requests.push(request);
const key = `${request.method} ${request.path}`;
const route = routes.get(key);
if (!route) { unexpected.push(key); throw new Error(`Unexpected request: ${key}`); }
const body = typeof route.body === 'function' ? await route.body(request) : route.body;
return { ok: route.status < 400, status: route.status, statusText: 'Error', json: async () => structuredClone(body) };
});
t.after(() => assert.deepEqual(unexpected, [], 'No unexpected API calls'));
return {
on(method, path, body, status = 200) { routes.set(`${method} ${path}`, { body, status }); },
requests,
sent: (method, path) => requests.filter((r) => r.method === method && r.path === path),
};
}
+444
View File
@@ -0,0 +1,444 @@
import assert from 'node:assert/strict';
import { beforeEach, afterEach, test } from 'node:test';
import { Element, install, server, nodes, find, field, section, emit, click, input, change, settle } from './flow-helpers.mjs';
let ui = install();
const { settingsPage } = await import('../../bindery/static/js/settings.js');
const { storiesPage, epubImport } = await import('../../bindery/static/js/stories.js');
const { storyPage } = await import('../../bindery/static/js/story.js');
const { chapterPage } = await import('../../bindery/static/js/chapter.js');
const { rulesEditor, rulesPreview } = await import('../../bindery/static/js/rules.js');
const { startPolling, stopPolling, refreshJobs, jobPanel } = await import('../../bindery/static/js/jobs.js');
const { setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js');
let api, main, details, cleanups;
const config = {
metadata_fields: { title: 'Title', authors: 'Authors', comments: 'Description', series: 'Series', pubdate: 'Published', tags: 'Tags', languages: 'Language' },
mode_labels: { new: 'New chapters' }, library_available: true, update_interval: 2,
};
const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: '', enabled: true };
const emptyRules = { rules: [], paragraphs: null, global_paragraphs: true };
beforeEach((t) => {
ui = install();
t.mock.timers.enable({ apis: ['setTimeout'] });
setUnauthorizedHandler(() => {});
api = server(t);
main = new Element('main');
cleanups = [];
details = {
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: { title: 'Edited title' }, site_values: { title: 'Site title', authors: ['Writer'],
comments: '<p>Description</p>', series: 'Saga', series_index: 2, pubdate: '2024-01-02T00:00:00', tags: ['Fantasy'], languages: 'en' },
auto_update: true, recheck_days: 3, last_full_recheck: '2026-01-01', last_checked: '2026-01-02', status: 'ok' },
chapters: [{ id: 2, title: 'One', url: 'chapter-1', included: true, on_site: true, current_rev: 1, revisions: 2 },
{ id: 3, title: 'Two', url: 'chapter-2', included: false, on_site: false, current_rev: 1, revisions: 1 }],
recent_chapters: 3,
};
api.on('GET', 'jobs', []);
api.on('GET', 'stories', () => [details.story]);
api.on('GET', 'stories/1', () => details);
api.on('GET', 'rules', emptyRules);
api.on('GET', 'stories/1/rules', emptyRules);
api.on('GET', 'settings', { recent_chapters: 3, personal_ini: '[defaults]' });
});
afterEach(() => { cleanups.forEach((fn) => fn?.()); stopPolling(); });
const mountStory = async (cfg = config) => { cleanups.push(storyPage(main, { storyId: '1' }, cfg)); await settle(); };
const mountStories = async (cfg = config) => { cleanups.push(storiesPage(main, {}, cfg)); await settle(); };
const writes = () => api.requests.filter((r) => r.method !== 'GET');
for (const value of ['7', '']) {
test(`settings save converts ${JSON.stringify(value)} to a count`, async () => {
settingsPage(main);
await settle();
api.on('PUT', 'settings', {});
field(main, 'recent-chapters').value = value;
await click(section(main, 'Settings'), 'Save');
assert.deepEqual(api.sent('PUT', 'settings')[0].body, { recent_chapters: value === '' ? 0 : 7 });
assert.match(ui.toasts.textContent, /Saved/);
});
}
test('personal.ini save reports warnings and clears them after a clean save', async () => {
settingsPage(main);
await settle();
const card = section(main, 'FanFicFare settings (personal.ini)');
field(main, 'personal.ini').value = '[defaults]\nis_adult: true';
api.on('PUT', 'settings', { warnings: ['Unknown setting'] });
await click(card, 'Save');
assert.deepEqual(api.sent('PUT', 'settings')[0].body, { personal_ini: '[defaults]\nis_adult: true' });
assert.match(card.textContent, /Unknown setting/);
api.on('PUT', 'settings', { warnings: [] });
await click(card, 'Save');
assert.doesNotMatch(card.textContent, /Unknown setting/);
});
test('failed settings saves retain edits and re-enable the action', async () => {
settingsPage(main);
await settle();
const card = section(main, 'FanFicFare settings (personal.ini)');
field(main, 'personal.ini').value = 'invalid ini';
api.on('PUT', 'settings', { error: 'Invalid settings' }, 400);
await click(card, 'Save');
assert.equal(field(main, 'personal.ini').value, 'invalid ini');
assert.equal(find(card, 'button', 'Save').disabled, false);
assert.match(ui.toasts.textContent, /Invalid settings/);
});
test('adding stories keeps input on rejection and clears it after a mixed result', async () => {
await mountStories({ ...config, library_available: false });
const urls = nodes(main).find((el) => el.tagName === 'textarea');
urls.value = ' https://site.test/1\n\nhttps://site.test/2 ';
api.on('POST', 'stories', { error: 'Unavailable' }, 503);
await click(main, 'Add');
assert.match(urls.value, /site.test/);
api.on('POST', 'stories', { jobs: [{ id: 'job' }], errors: ['Unsupported URL'] });
await click(main, 'Add');
assert.deepEqual(api.sent('POST', 'stories').at(-1).body, { urls: ' https://site.test/1\n\nhttps://site.test/2 ' });
assert.equal(urls.value, '');
assert.match(ui.toasts.textContent, /Unsupported URL/);
await click(main, 'Add');
assert.equal(api.sent('POST', 'stories').length, 2);
});
test('story filtering handles early input, empty results and case-insensitive matches', async () => {
let resolve;
api.on('GET', 'stories', () => new Promise((done) => { resolve = done; }));
cleanups.push(storiesPage(main, {}, config));
input(field(main, 'Filter stories'), ' WRITER ');
resolve([details.story]);
await settle();
assert.ok(find(main, 'a', 'Story'));
input(field(main, 'Filter stories'), 'missing');
assert.match(main.textContent, /No stories match/);
input(field(main, 'Filter stories'), 'example.com');
assert.ok(find(main, 'a', 'Story'));
cleanups.pop()();
api.on('GET', 'stories', []);
await mountStories();
assert.match(main.textContent, /No stories yet/);
});
for (const storyId of [undefined, 1]) {
test(`epub import ${storyId ?? 'new story'} sends the file and check option`, async () => {
const root = epubImport(storyId);
const path = storyId ? 'stories/1/import-epub' : 'stories/import-epub';
await click(root, 'Import');
assert.equal(writes().length, 0);
assert.match(ui.toasts.textContent, /Choose an ePub/);
const file = field(root, 'Epub file');
const book = new File(['book'], 'story.epub', { type: 'application/epub+zip' });
file.files = [book];
file.value = 'story.epub';
change(nodes(root).find((el) => el.attributes.type === 'checkbox'), false);
api.on('POST', path, { error: 'Wrong story' }, 400);
await click(root, 'Import');
assert.equal(file.value, 'story.epub');
api.on('POST', path, { id: 'import' });
await click(root, 'Import');
const sent = api.sent('POST', path).at(-1).body;
assert.equal(sent.get('check'), 'false');
assert.equal(sent.get('file').name, 'story.epub');
assert.equal(await sent.get('file').text(), 'book');
assert.equal(file.value, '');
});
}
test('library import sends only selected books and handles an empty candidate list', async () => {
await mountStories();
api.on('GET', 'library/candidates', []);
await click(main, 'Find stories in the library');
assert.match(main.textContent, /tracked already/);
const books = [{ calibre_id: 8, uuid: 'a', title: 'A' }, { calibre_id: 9, uuid: 'b', title: 'B' }];
api.on('GET', 'library/candidates', books);
await click(main, 'Find stories in the library');
change(field(main, 'Select all'), false);
await click(main, 'Import selected');
assert.equal(writes().length, 0);
change(field(main, 'Select all'), true);
change(field(main, 'A'), false);
change(field(main, 'A'), true);
change(field(main, 'B'), false);
const card = section(main, 'Import from the library');
change(nodes(card).find((el) => el.attributes.type === 'checkbox'), false);
api.on('POST', 'library/import', [{ id: 'job' }]);
await click(main, 'Import selected');
assert.deepEqual(api.sent('POST', 'library/import')[0].body, { books: [{ id: 8, uuid: 'a', title: 'A' }], check: false });
});
test('rules edits, moves, removal and paragraph choices are saved in order', async () => {
api.on('GET', 'stories/1/rules', { ...emptyRules, rules: [rule, { ...rule, pattern: 'second' }] });
const editor = rulesEditor('stories/1/rules');
await settle();
const root = editor.element;
const controls = (label) => nodes(root).filter((el) => el.attributes['aria-label'] === label);
emit(controls('Move up')[0], 'click');
emit(controls('Move down')[1], 'click');
emit(controls('Move down')[0], 'click');
input(controls('Pattern')[0], 'edited');
change(controls('Enabled')[0], false);
emit(controls('Move up')[1], 'click');
emit(controls('Remove')[0], 'click');
await click(root, 'Add rule');
input(controls('Replacement')[1], 'replacement');
api.on('PUT', 'stories/1/rules', (req) => ({ ...req.body, global_paragraphs: true }));
for (const [choice, expected] of [['off', false], ['on', true], ['', null]]) {
change(field(root, 'paragraphs'), choice);
await click(root, 'Save');
const sent = api.sent('PUT', 'stories/1/rules').at(-1).body;
assert.equal(sent.paragraphs, expected);
assert.equal(sent.rules[0].pattern, 'edited');
assert.equal(sent.rules[0].enabled, false);
assert.equal(sent.rules[1].replacement, 'replacement');
}
});
test('rule validation failures retain edits and preview uses unsaved values', async () => {
api.on('GET', 'rules', { ...emptyRules, rules: [rule], paragraphs: false });
const editor = rulesEditor('rules');
await settle();
input(field(editor.element, 'Pattern'), '[');
change(nodes(editor.element).find((el) => el.attributes.type === 'checkbox'), true);
api.on('PUT', 'rules', { error: 'Invalid pattern', index: 0 }, 400);
await click(editor.element, 'Save');
assert.equal(field(editor.element, 'Pattern').value, '[');
assert.match(ui.toasts.textContent, /Invalid pattern/);
api.on('POST', 'rules/preview', { error: 'Invalid pattern', index: 0 }, 400);
await assert.rejects(editor.preview(2, false), /Invalid pattern/);
input(field(editor.element, 'Pattern'), 'fixed');
api.on('POST', 'rules/preview', { identical: true });
assert.deepEqual(await editor.preview(2, true), { identical: true });
assert.deepEqual(api.sent('POST', 'rules/preview').at(-1).body,
{ rules: [{ ...rule, pattern: 'fixed' }], paragraphs: true, chapter_id: 2, source: true });
});
test('rule previews keep a valid chapter selection and skip empty lists', async () => {
const calls = [];
const editor = { preview: async (...args) => { calls.push(args); return { identical: calls.length < 3, html: '<p>diff</p>' }; } };
const preview = rulesPreview(editor);
await click(preview.element, 'Preview');
assert.equal(calls.length, 0);
preview.setChapters(details.chapters);
change(field(preview.element, 'Chapter to preview'), '3');
preview.setChapters(details.chapters);
await click(preview.element, 'Preview');
change(nodes(preview.element).find((el) => el.attributes.type === 'checkbox'), true);
await click(preview.element, 'Preview');
preview.setChapters([details.chapters[0]]);
await click(preview.element, 'Preview');
assert.deepEqual(calls, [[3, false], [3, true], [2, true]]);
preview.setChapters([]);
await click(preview.element, 'Preview');
assert.equal(calls.length, 3);
});
test('story download modes send selected chapters and clear forced fields after success', async () => {
await mountStory();
api.on('POST', 'stories/1/download', { mode_label: 'Queued' });
const radio = (mode) => nodes(main).find((el) => el.attributes.type === 'radio' && el.value === mode);
change(radio('recheck'), 'recheck');
await click(main, 'Start');
await click(main, 'Recheck selected');
await click(main, 'Take out of book');
assert.equal(writes().length, 0);
change(field(main, 'Select One'), true);
change(field(main, 'Select One'), false);
change(field(main, 'Select all chapters'), true);
change(field(main, 'Select all chapters'), false);
change(field(main, 'Select One'), true);
const force = nodes(section(main, 'Download')).filter((el) => el.attributes.type === 'checkbox');
change(force[0], true);
change(force[1], true);
change(force[1], false);
await click(main, 'Start');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'recheck', force_fields: ['title'], chapter_ids: [2] });
for (const mode of ['new', 'recheck_all', 'full', 'metadata']) {
change(radio(mode), mode);
await click(main, 'Start');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode, force_fields: [], chapter_ids: [] });
}
await click(main, 'Recheck selected');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'recheck', chapter_ids: [2] });
await click(main, 'Rebuild book');
assert.deepEqual(api.sent('POST', 'stories/1/download').at(-1).body, { mode: 'rebuild' });
});
test('chapter inclusion changes reload the story after queuing the rebuild', async () => {
await mountStory();
change(field(main, 'Select One'), true);
api.on('POST', 'stories/1/chapters', {});
for (const [label, included] of [['Take out of book', false], ['Put back in book', true]]) {
await click(main, label);
assert.deepEqual(api.sent('POST', 'stories/1/chapters').at(-1).body, { chapter_ids: [2], included });
}
assert.equal(api.sent('GET', 'stories/1').length, 3);
});
test('unsaved metadata and update settings survive job completion, then save typed values', async () => {
await mountStory();
startPolling();
await settle();
api.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'running' }]);
await refreshJobs();
input(field(main, 'Title'), 'My title');
input(field(main, 'Description'), 'One\n\nTwo <three>');
input(field(main, 'Authors'), 'Writer');
change(field(main, 'Lock Title'), true);
change(field(main, 'Lock Authors'), true);
change(field(main, 'Lock Authors'), false);
input(field(main, 'recheck-days'), '5');
change(nodes(section(main, 'Updates')).find((el) => el.attributes.type === 'checkbox'), false);
api.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'done' }]);
await refreshJobs();
await settle();
assert.equal(field(main, 'Title').value, 'My title');
assert.equal(field(main, 'Lock Title').checked, true);
assert.equal(field(main, 'recheck-days').value, '5');
api.on('PUT', 'stories/1/settings', {});
api.on('PUT', 'stories/1/metadata', { job: { id: 'rebuild' } });
await click(section(main, 'Metadata'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { locked_fields: ['title'] });
assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body,
{ values: { title: 'My title', comments: '<p>One</p><p>Two &lt;three&gt;</p>' } });
await click(section(main, 'Updates'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/settings').at(-1).body, { auto_update: false, recheck_days: 5 });
input(field(main, 'recheck-days'), '');
await click(section(main, 'Updates'), 'Save');
assert.equal(api.sent('PUT', 'stories/1/settings').at(-1).body.recheck_days, 0);
});
test('metadata saves skip unchanged drafts, allow edits without a rebuild, and restore site values', async () => {
await mountStory();
api.on('PUT', 'stories/1/settings', {});
api.on('PUT', 'stories/1/metadata', { job: null });
input(field(main, 'Title'), 'Edited title');
await click(section(main, 'Metadata'), 'Save');
assert.equal(api.sent('PUT', 'stories/1/metadata').length, 0);
input(field(main, 'Title'), 'New title');
await click(section(main, 'Metadata'), 'Save');
assert.deepEqual(api.sent('PUT', 'stories/1/metadata')[0].body, { values: { title: 'New title' } });
await click(main, 'Use the site value');
assert.deepEqual(api.sent('PUT', 'stories/1/metadata').at(-1).body, { values: { title: null } });
});
test('failed rule saves do not queue a rebuild', async () => {
await mountStory();
const card = section(main, 'Replacement rules');
api.on('PUT', 'stories/1/rules', { error: 'Bad rule' }, 400);
await click(card, 'Save and rebuild');
assert.equal(api.sent('POST', 'stories/1/download').length, 0);
api.on('PUT', 'stories/1/rules', emptyRules);
api.on('POST', 'stories/1/download', { mode_label: 'Rebuild' });
await click(card, 'Save and rebuild');
assert.deepEqual(api.sent('POST', 'stories/1/download')[0].body, { mode: 'rebuild' });
});
test('library search trims the query and links by UUID', async () => {
await mountStory();
const form = nodes(section(main, 'Library link')).find((el) => el.tagName === 'form');
emit(form, 'submit');
assert.equal(api.requests.some((r) => r.path.startsWith('library/search')), false);
field(main, 'Search the library').value = ' A & B ';
api.on('GET', 'library/search?q=A%20%26%20B', []);
emit(form, 'submit');
await settle();
assert.match(main.textContent, /No library books match/);
api.on('GET', 'library/search?q=A%20%26%20B', [{ id: 7, uuid: 'book-7', title: 'A & B', authors: ['A', 'B'] },
{ id: 8, uuid: 'book-8', title: 'Tracked', authors: 'Writer', tracked_as: 'Other' }]);
emit(form, 'submit');
await settle();
const links = nodes(main).filter((el) => el.tagName === 'button' && el.textContent === 'Link');
assert.equal(links[1].disabled, true);
api.on('POST', 'stories/1/link', {});
await emit(links[0], 'click');
assert.deepEqual(api.sent('POST', 'stories/1/link')[0].body, { uuid: 'book-7' });
assert.equal(api.sent('GET', 'stories/1').length, 2);
});
test('sending as a new book and stopping tracking require confirmation', async () => {
details.story.ingested_at = '2026-01-01';
await mountStory();
window.confirm = () => false;
await click(main, 'Send to CWA as a new book');
await click(main, 'Stop tracking this story');
assert.equal(writes().length, 0);
window.confirm = () => true;
api.on('POST', 'stories/1/link', {});
api.on('POST', 'stories/1/download', { mode_label: 'Rebuild' });
await click(main, 'Send to CWA as a new book');
assert.deepEqual(writes().map((r) => [r.path, r.body]), [['stories/1/link', { uuid: null }], ['stories/1/download', { mode: 'rebuild' }]]);
api.on('DELETE', 'stories/1', {});
window.location.hash = '#/story/1';
await click(main, 'Stop tracking this story');
assert.equal(api.sent('DELETE', 'stories/1').length, 1);
assert.equal(window.location.hash, '#/');
});
test('story load failures are reported and sparse offline stories remain usable', async () => {
api.on('GET', 'stories/1', { error: 'Missing story' }, 404);
await mountStory();
assert.match(ui.toasts.textContent, /Missing story/);
cleanups.pop()();
details.story.pinned = {};
details.story.site_values = {};
details.chapters = [];
details.recent_chapters = 0;
details.story.last_full_recheck = null;
details.story.calibre_id = 4;
details.library_error = 'Offline';
api.on('GET', 'stories/1', () => details);
await mountStory({ ...config, library_available: false, update_interval: 0 });
assert.equal(field(main, 'Title').value, '');
assert.match(main.textContent, /Offline/);
});
test('chapter comparisons and revision changes send the chosen revision numbers', async () => {
let diff = { chapter: details.chapters[0], a: 1, b: 2, identical: false, html: '<p>diff</p>',
changed: 1, added: 0, removed: 0, revisions: [{ rev: 1, size: 100 }, { rev: 2, size: 110 }, { rev: 3, size: 120 }] };
api.on('GET', 'chapters/2/diff?', () => diff);
cleanups.push(chapterPage(main, { storyId: '1', chapterId: '2' }));
await settle();
change(field(main, 'Older revision'), '2');
change(field(main, 'Newer revision'), '1');
change(nodes(main).find((el) => el.attributes.type === 'checkbox'), true);
api.on('GET', 'chapters/2/diff?a=2&b=1&source=1', () => diff);
await click(main, 'Compare');
assert.equal(api.sent('GET', 'chapters/2/diff?a=2&b=1&source=1').length, 1);
api.on('POST', 'chapters/2/revision', {});
api.on('GET', 'chapters/2/diff?a=1&b=2&source=1', () => diff);
await click(main, 'Use this revision');
assert.deepEqual(api.sent('POST', 'chapters/2/revision')[0].body, { rev: 2 });
window.confirm = () => false;
await click(main, 'Delete');
assert.equal(api.sent('DELETE', 'chapters/2/revisions/2').length, 0);
window.confirm = () => true;
api.on('DELETE', 'chapters/2/revisions/2', {});
diff = { ...diff, a: 1, b: 1, revisions: [{ rev: 1, size: 100 }] };
api.on('GET', 'chapters/2/diff?source=1', () => diff);
await click(main, 'Delete');
assert.equal(api.sent('DELETE', 'chapters/2/revisions/2').length, 1);
assert.equal(api.sent('GET', 'chapters/2/diff?source=1').length, 1);
});
test('job cancellation posts the job ID and refreshes polling', async () => {
startPolling();
await settle();
const { el: root, render } = jobPanel();
render([{ id: 'cancel-me', status: 'queued', url: 'story-url' },
{ id: 'running', status: 'running', story_id: 1, progress: 0.5,
result: { changed: [{ chapter_id: 2, title: 'One' }, { chapter_id: 3, title: 'Two' }] } }]);
api.on('POST', 'jobs/cancel-me/cancel', {});
await click(root, 'Cancel');
await settle();
assert.equal(api.sent('POST', 'jobs/cancel-me/cancel').length, 1);
assert.equal(api.sent('GET', 'jobs').length, 2);
});
test('chapter titles show the entities that FanFicFare keeps as text', async () => {
details.chapters[0].title = 'Folly &amp; Magic &lt;1&gt;';
await mountStory();
assert.ok(find(main, 'a', 'Folly & Magic <1>'));
const { el, render } = jobPanel();
render([{ id: 'j', status: 'done', story_id: 1, result: { changed: [{ chapter_id: 2, title: 'A &amp; B' }] } }]);
assert.ok(find(el, 'a', 'A & B'));
});
+235
View File
@@ -0,0 +1,235 @@
// DOM and stylesheet checks without a browser.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { beforeEach, test } from 'node:test';
class Element {
constructor(tag) {
this.tagName = tag;
this.attributes = {};
this.children = [];
this.listeners = {};
this.className = '';
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = children; }
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
removeAttribute(name) { delete this.attributes[name]; }
addEventListener(name, listener) { this.listeners[name] = listener; }
}
const toasts = new Element('div');
globalThis.document = {
createElement: (tag) => new Element(tag),
addEventListener() {},
getElementById: () => toasts,
querySelectorAll: () => [],
};
const { button } = await import('../../bindery/static/js/lib.js');
const { settingsPage } = await import('../../bindery/static/js/settings.js');
const { storiesPage } = await import('../../bindery/static/js/stories.js');
const { storyPage } = await import('../../bindery/static/js/story.js');
const { chapterPage } = await import('../../bindery/static/js/chapter.js');
const { jobPanel, stopPolling } = await import('../../bindery/static/js/jobs.js');
const obsolete = new Set([
'btn', 'btn-primary', 'btn-danger', 'btn-small', 'btn-link', 'input', 'input-number',
'row', 'spread', 'stack', 'push', 'grow', 'wide', 'block', 'plain', 'muted', 'small',
'mono', 'nowrap', 'wrap', 'faded',
]);
const elements = (node) => node instanceof Element ? [node, ...node.children.flatMap(elements)] : [];
const text = (node) => node instanceof Element ? node.children.map(text).join('') : String(node);
const find = (root, tag, label) => elements(root).find((el) => el.tagName === tag && text(el) === label);
const settle = () => new Promise((resolve) => setImmediate(resolve));
const config = { metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: true, update_interval: 0 };
const story = {
id: 1, title: 'Story', author: 'Author', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: { title: 'Edited title' }, site_values: { title: 'Site title' },
};
const chapter = {
id: 2, title: 'Chapter', url: 'https://example.com/chapter', included: false,
on_site: true, current_rev: 2, revisions: 2,
};
const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: 'A note', enabled: true };
beforeEach(() => {
toasts.replaceChildren();
globalThis.fetch = async (url) => {
const replies = {
'api/auth/check': { authenticated: true, mode: 'cwa', user: 'Reader' },
'api/config': config,
'api/jobs': [],
'api/settings': { recent_chapters: 3, personal_ini: '[defaults]' },
'api/rules': { rules: [rule], paragraphs: false, global_paragraphs: false },
'api/stories/1/rules': { rules: [rule], paragraphs: null, global_paragraphs: false },
'api/stories': [story],
'api/stories/1': { story, chapters: [chapter] },
'api/chapters/2/diff?': {
chapter, a: 1, b: 2, identical: true,
revisions: [{ rev: 1, source: 'download', size: 100 }, { rev: 2, source: 'recheck', size: 100 }],
},
};
assert.ok(url in replies, `Unexpected request: ${url}`);
return { ok: true, json: async () => replies[url] };
};
});
test('plain buttons need no class and stay disabled until the action finishes', async () => {
let finish;
const pending = new Promise((resolve) => { finish = resolve; });
const control = button('Save', () => pending);
assert.equal(control.tagName, 'button');
assert.equal(control.attributes.type, 'button');
assert.equal(control.className, '');
let prevented = false;
const clicked = control.listeners.click({ preventDefault() { prevented = true; } });
assert.equal(prevented, true);
assert.equal(control.disabled, true);
finish();
await clicked;
assert.equal(control.disabled, false);
});
test('button variants and attributes do not need a base class', () => {
const control = button('Delete', () => {}, { class: 'danger', disabled: true, 'aria-label': 'Delete revision' });
assert.equal(control.className, 'danger');
assert.equal(control.disabled, true);
assert.equal(control.attributes['aria-label'], 'Delete revision');
});
for (const [name, render] of [
['settings', (main) => settingsPage(main)],
['stories', (main) => storiesPage(main, {}, config)],
['story', (main) => storyPage(main, { storyId: '1' }, config)],
['chapter', (main) => chapterPage(main, { storyId: '1', chapterId: '2' })],
]) {
test(`${name} renders native controls without utility classes`, async () => {
const main = new Element('main');
const cleanup = render(main);
try {
await settle();
assert.equal(toasts.children.length, 0);
const nodes = elements(main);
assert.ok(nodes.some((el) => el.tagName === 'button'));
assert.ok(nodes.some((el) => el.tagName === 'input' || el.tagName === 'select'));
for (const el of nodes) {
for (const cls of el.className.split(/\s+/)) assert.ok(!obsolete.has(cls), `${el.tagName}.${cls}`);
}
if (name === 'settings') {
assert.equal(nodes.find((el) => el.attributes.id === 'recent-chapters').value, 3);
assert.equal(nodes.find((el) => el.tagName === 'textarea').className, 'ini');
const pattern = nodes.find((el) => el.attributes['aria-label'] === 'Pattern');
assert.equal(pattern.value, 'before');
assert.equal(pattern.className, '');
assert.equal(nodes.find((el) => el.attributes['aria-label'] === 'Note').className, 'rule-note');
}
if (name === 'story') {
const download = find(main, 'a', 'Download ePub');
assert.equal(download.attributes.href, 'api/stories/1/epub');
assert.equal(download.attributes.download, '');
assert.equal(download.className, '');
assert.equal(find(main, 'button', 'Use the site value').className, '');
assert.ok(nodes.some((el) => el.className === 'chapter-excluded'));
}
if (name === 'chapter') {
assert.equal(find(main, 'button', 'Delete').className, 'danger');
assert.equal(find(main, 'button', 'Use this revision').className, '');
}
} finally {
cleanup?.();
}
});
}
test('jobs use native secondary text and timestamps', () => {
const updated = '2026-01-02T03:04:05Z';
const { el: list, render } = jobPanel();
render([{ id: 'job', status: 'queued', title: 'Story', mode_label: 'Update', updated, message: 'Waiting' }]);
const nodes = elements(list);
assert.ok(find(list, 'small', 'Update'));
assert.equal(nodes.find((el) => el.tagName === 'time').attributes.datetime, updated);
assert.equal(find(list, 'button', 'Cancel').className, '');
assert.ok(find(list, 'p', 'Waiting'));
});
test('a job update keeps the scroll box of the job list', () => {
const { el, render } = jobPanel();
const job = { id: 'job', status: 'running', title: 'Story', mode_label: 'Update', progress: 0.1 };
render([job]);
const [box] = el.children;
assert.equal(box.className, 'jobs');
render([{ ...job, progress: 0.5 }, { ...job, id: 'next', status: 'queued' }]);
assert.deepEqual(el.children, [box]);
assert.equal(box.children.length, 2);
const [first] = box.children;
render([{ ...job, progress: 0.5 }, { ...job, id: 'next', status: 'queued' }]);
assert.equal(box.children[0], first, 'A poll without changes leaves the list alone');
render([]);
assert.deepEqual(el.children[0].children, ['No jobs.']);
render([job]);
assert.deepEqual(el.children, [box]);
});
test('header navigation follows account controls and marks the current page', async (t) => {
const html = readFileSync(new URL('../../bindery/static/index.html', import.meta.url), 'utf8');
assert.match(html, /<div id="bindery-app"><\/div>/);
assert.doesNotMatch(html, /id="app"/);
const app = new Element('div');
const previousWindow = globalThis.window;
const listeners = {};
globalThis.window = {
location: { hash: '#/' },
addEventListener: (name, listener) => { listeners[name] = listener; },
scrollTo() {},
};
t.mock.method(document, 'getElementById', (id) => {
if (id === 'bindery-app') return app;
if (id === 'main') return elements(app).find((el) => el.attributes.id === 'main');
return toasts;
});
t.mock.method(document, 'querySelectorAll', (selector) => {
assert.equal(selector, 'header nav a');
const header = app.children[0];
const nav = header.children.find((el) => el.tagName === 'nav');
return nav.children;
});
try {
await import('../../bindery/static/js/main.js');
await settle();
assert.equal(toasts.children.length, 0);
const header = app.children[0];
assert.deepEqual(header.children.map((el) => el.tagName), ['a', 'div', 'nav']);
assert.ok(find(header.children[1], 'button', 'Log out'));
const nav = header.children[2];
assert.equal(nav.attributes['aria-label'], 'Main navigation');
assert.equal(find(nav, 'a', 'Stories').attributes['aria-current'], 'page');
assert.equal(find(nav, 'a', 'Settings').attributes['aria-current'], undefined);
window.location.hash = '#/settings';
listeners.hashchange();
await settle();
assert.equal(find(nav, 'a', 'Stories').attributes['aria-current'], undefined);
assert.equal(find(nav, 'a', 'Settings').attributes['aria-current'], 'page');
assert.equal(toasts.children.length, 0);
} finally {
stopPolling();
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
test('CSS and HTML generators contain no obsolete utility classes', () => {
const root = new URL('../../bindery/static/', import.meta.url);
const css = readFileSync(new URL('style.css', root), 'utf8');
for (const [, cls] of css.matchAll(/\.([a-z][\w-]*)/gi)) assert.ok(!obsolete.has(cls), `CSS: .${cls}`);
const files = ['index.html', ...readdirSync(new URL('js/', root)).filter((file) => file.endsWith('.js')).map((file) => `js/${file}`)];
for (const file of files) {
const source = readFileSync(new URL(file, root), 'utf8');
for (const [, , classes] of source.matchAll(/\bclass\s*[:=]\s*(['"`])([^'"`]+)\1/g)) {
for (const cls of classes.split(/\s+/)) assert.ok(!obsolete.has(cls), `${file}: ${cls}`);
}
}
});
+88
View File
@@ -0,0 +1,88 @@
// Tests for the job polling in bindery/static/js/jobs.js. Run with: node --test tests/js/
import assert from 'node:assert/strict';
import { after, beforeEach, test } from 'node:test';
// jobs.js listens for tab visibility changes when it loads.
globalThis.document = { hidden: false, addEventListener() {} };
let jobs = [];
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => jobs });
const { refreshJobs, startPolling, stopPolling, watchJobs } = await import('../../bindery/static/js/jobs.js');
const job = (id, storyId, status) => ({ id, story_id: storyId, status });
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
beforeEach(async () => {
jobs = [];
startPolling();
await tick();
});
after(() => stopPolling());
test('watchJobs renders the jobs of one story', async () => {
const rendered = [];
const stop = watchJobs({ storyId: 1, render: (list) => rendered.push(list.map((j) => j.id)) });
jobs = [job('a', 1, 'queued'), job('b', 2, 'queued')];
await refreshJobs();
assert.deepEqual(rendered.at(-1), ['a']);
stop();
jobs = [job('c', 1, 'queued')];
await refreshJobs();
assert.deepEqual(rendered.at(-1), ['a']);
});
test('watchJobs reports a finished job once', async () => {
let finished = 0;
const stop = watchJobs({ storyId: 1, onFinished: () => finished++ });
jobs = [job('a', 1, 'queued')];
await refreshJobs();
jobs = [job('a', 1, 'running')];
await refreshJobs();
assert.equal(finished, 0);
jobs = [job('a', 1, 'done')];
await refreshJobs();
assert.equal(finished, 1);
await refreshJobs();
assert.equal(finished, 1);
stop();
});
test('watchJobs ignores jobs that finished before it started, and other stories', async () => {
let finished = 0;
jobs = [job('old', 1, 'done')];
await refreshJobs();
const stop = watchJobs({ storyId: 1, onFinished: () => finished++ });
jobs = [job('old', 1, 'done'), job('other', 2, 'running')];
await refreshJobs();
jobs = [job('old', 1, 'done'), job('other', 2, 'error')];
await refreshJobs();
assert.equal(finished, 0);
stop();
});
test('watchJobs without a story watches every job', async () => {
let finished = 0;
const stop = watchJobs({ onFinished: () => finished++ });
jobs = [job('a', 1, 'running'), job('b', null, 'queued')];
await refreshJobs();
jobs = [job('a', 1, 'cancelled'), job('b', null, 'error')];
await refreshJobs();
assert.equal(finished, 1);
stop();
});
test('stopPolling stops the requests', async () => {
stopPolling();
let calls = 0;
const saved = globalThis.fetch;
globalThis.fetch = async () => {
calls++;
return { ok: true, status: 200, json: async () => [] };
};
await refreshJobs();
assert.equal(calls, 0);
globalThis.fetch = saved;
});
+129
View File
@@ -0,0 +1,129 @@
// Stylesheet contracts. Check the rendered layout in a browser.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8');
const normalize = (selector) => selector.trim().replace(/\s+/g, ' ');
function declarations(selector, source = css) {
const rules = [...source.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)];
const rule = rules.find(([, selectors]) => normalize(selectors) === selector);
assert.ok(rule, `Missing CSS rule: ${selector}`);
return Object.fromEntries(rule[2].split(';').filter((entry) => entry.trim()).map((entry) => {
const colon = entry.indexOf(':');
return [entry.slice(0, colon).trim(), entry.slice(colon + 1).trim()];
}));
}
test('header and main share a centered width without a gap between them', () => {
const shared = declarations('header, main');
assert.equal(shared.width, 'min(1100px, calc(100% - 32px))');
assert.equal(shared.margin, '0px auto');
for (const selector of ['header', 'main']) {
const styles = declarations(selector);
assert.equal(styles.width, undefined);
assert.equal(styles.margin, undefined);
}
});
test('header and main share a theme-aware frame without adding a second login frame', () => {
const frame = declarations('header, main:not(.login)');
assert.equal(frame.border, '1px solid var(--border)');
assert.equal(frame.background, 'var(--surface)');
assert.ok(frame['box-shadow']);
assert.equal(declarations('header')['border-top-left-radius'], '12px');
assert.equal(declarations('header')['border-top-right-radius'], '12px');
assert.equal(declarations('main')['border-bottom-left-radius'], '12px');
assert.equal(declarations('main')['border-bottom-right-radius'], '12px');
assert.equal(declarations('main:not(.login)')['border-top'], '0px');
assert.equal(declarations('main.login')['max-width'], '400px');
assert.equal(declarations('main > :last-child')['margin-bottom'], '0');
});
test('header and main keep matching gutters on narrow screens', () => {
const mobile = css.slice(css.indexOf('@media (max-width: 600px)'));
const shared = declarations('header, main', mobile);
assert.equal(shared.width, 'calc(100% - 24px)');
assert.equal(shared.margin, undefined);
assert.equal(shared.padding, '12px');
assert.equal(declarations('header', mobile)['padding-bottom'], '0');
});
test('navigation looks like tabs and joins the active link to the main surface', () => {
const navigation = declarations('header nav');
assert.equal(navigation['flex-basis'], '100%');
assert.equal(navigation['margin-bottom'], '-1px');
assert.equal(declarations('header nav a')['border-radius'], '8px 8px 0 0');
const current = declarations("header nav a[aria-current='page']");
assert.equal(current.background, 'var(--surface)');
assert.equal(current.color, 'var(--text)');
assert.equal(current['border-bottom-color'], 'var(--surface)');
assert.equal(current['box-shadow'], undefined);
const focus = declarations('a:focus-visible, button:focus-visible, summary:focus-visible');
assert.equal(focus.outline, '2px solid var(--accent)');
});
test('inactive tabs have a visible outline and fill without a shadow', () => {
const tab = declarations('header nav a');
assert.equal(tab.border, '1px solid var(--border)');
assert.equal(tab.background, 'var(--bg)');
assert.equal(tab['box-shadow'], undefined);
const hover = declarations('header nav a:hover');
assert.equal(hover.background, 'var(--surface)');
assert.equal(hover['box-shadow'], undefined);
});
test('the header title and icon grow without changing the login title', () => {
assert.equal(declarations('header .brand')['font-size'], '1.65rem');
assert.equal(declarations('header .brand img').width, '32px');
assert.equal(declarations('header .brand img').height, '32px');
assert.equal(declarations('.brand')['font-size'], '1.1rem');
});
test('the Add stories textarea uses the smaller text size without shrinking other textareas', () => {
assert.equal(declarations('.ini, .story-urls')['font-size'], 'var(--text-sm)');
assert.equal(declarations('textarea')['font-size'], undefined);
});
function luminance(hex) {
const channels = hex.slice(1).match(/../g).map((channel) => {
const value = parseInt(channel, 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
test('cards keep their borders and use a subtle background in both themes', () => {
const card = declarations('.card');
assert.equal(card.background, 'var(--card-bg)');
assert.equal(card.border, '1px solid var(--border)');
assert.equal(card['border-radius'], '8px');
assert.equal(declarations('.card-danger')['border-color'], 'var(--err)');
const dark = css.slice(css.indexOf('@media (prefers-color-scheme: dark)'));
for (const source of [css, dark]) {
const theme = declarations(':root', source);
assert.notEqual(theme['--card-bg'], theme['--surface']);
assert.notEqual(theme['--card-bg'], theme['--bg']);
const channels = theme['--card-bg'].slice(1).match(/../g).map((channel) => parseInt(channel, 16));
assert.ok(channels.every((channel) => channel === channels[0]), 'Cards use neutral grey without a colour tint');
for (const foreground of ['--text', '--muted', '--accent', '--err']) {
const light = [luminance(theme[foreground]), luminance(theme['--card-bg'])].sort((a, b) => b - a);
assert.ok((light[0] + 0.05) / (light[1] + 0.05) >= 4.5, `${foreground} on a card`);
}
}
});
test('header text has at least 4.5:1 contrast in both color themes', () => {
assert.equal(declarations('header').background, 'var(--header-bg)');
const dark = css.slice(css.indexOf('@media (prefers-color-scheme: dark)'));
for (const source of [css, dark]) {
const theme = declarations(':root', source);
assert.notEqual(theme['--header-bg'], theme['--surface']);
for (const foreground of ['--text', '--muted']) {
const light = [luminance(theme[foreground]), luminance(theme['--header-bg'])].sort((a, b) => b - a);
assert.ok((light[0] + 0.05) / (light[1] + 0.05) >= 4.5, `${foreground} on the header`);
}
}
});
+161
View File
@@ -0,0 +1,161 @@
// Tests for the helpers in bindery/static/js/lib.js. Run with: node --test "tests/js/*.test.mjs"
import assert from 'node:assert/strict';
import { beforeEach, test } from 'node:test';
// The comments field is HTML. A browser turns it into text with DOMParser.
globalThis.DOMParser = class {
parseFromString(html) {
return { body: { textContent: html.replace(/<[^>]*>/g, '') } };
}
};
// h() builds elements. These fakes keep only what the tests read.
globalThis.document = {
createElement: (tagName) => ({
tagName,
attributes: {},
children: [],
append(...children) { this.children.push(...children); },
setAttribute(name, value) { this.attributes[name] = String(value); },
}),
};
const { api, ApiError, chapterTitle, externalLink, fmtDate, formatField, plural, setUnauthorizedHandler, textToHtml } = await import(
'../../bindery/static/js/lib.js'
);
let requests = [];
let reply = { status: 200, body: {} };
beforeEach(() => {
requests = [];
reply = { status: 200, body: {} };
globalThis.fetch = async (url, options) => {
requests.push({ url, ...options });
return {
ok: reply.status < 400,
status: reply.status,
statusText: 'Status',
json: async () => {
if (reply.body === undefined) throw new SyntaxError('not JSON');
return reply.body;
},
};
};
setUnauthorizedHandler(() => {});
});
test('formatField shows series with and without a number', () => {
assert.equal(formatField('series', ['Saga', 2]), 'Saga [2]');
assert.equal(formatField('series', ['Saga', null]), 'Saga');
assert.equal(formatField('series', ['', 2]), '');
assert.equal(formatField('series', 'Saga'), 'Saga');
});
test('formatField joins lists and handles empty values', () => {
assert.equal(formatField('tags', ['Fantasy', 'LitRPG']), 'Fantasy, LitRPG');
assert.equal(formatField('title', 'A Story'), 'A Story');
assert.equal(formatField('title', null), '');
assert.equal(formatField('title', undefined), '');
assert.equal(formatField('title', ''), '');
});
test('formatField turns the description into short text', () => {
assert.equal(formatField('comments', '<p>A <b>bold</b> start.</p>'), 'A bold start.');
const long = formatField('comments', `<p>${'x'.repeat(300)}</p>`);
assert.equal(long.length, 201);
assert.ok(long.endsWith('…'));
});
test('textToHtml makes paragraphs and escapes the text', () => {
assert.equal(textToHtml('One <b>\nline\n\n Two & more \n\n\n'), '<p>One &lt;b&gt;<br/>line</p><p>Two &amp; more</p>');
assert.equal(textToHtml(' '), '');
});
test('fmtDate', () => {
assert.equal(fmtDate(null), '');
assert.equal(fmtDate(''), '');
assert.equal(fmtDate('not a date'), 'not a date');
const text = fmtDate('2026-01-02T03:04:05');
assert.ok(text.length > 0 && text !== '2026-01-02T03:04:05');
});
test('chapterTitle turns the entities that FanFicFare keeps into text', () => {
assert.equal(chapterTitle('Folly &amp; Magic &lt;1&gt;'), 'Folly & Magic <1>');
assert.equal(chapterTitle('Literal &amp;lt;'), 'Literal &lt;');
assert.equal(chapterTitle(undefined), '');
});
test('plural', () => {
assert.equal(plural(1, 'chapter'), '1 chapter');
assert.equal(plural(0, 'chapter'), '0 chapters');
assert.equal(plural(3, 'chapter'), '3 chapters');
});
test('api reads with a relative GET and no CSRF header', async () => {
reply.body = [{ id: 1 }];
assert.deepEqual(await api('stories'), [{ id: 1 }]);
assert.equal(requests[0].url, 'api/stories');
assert.equal(requests[0].method, 'GET');
assert.deepEqual(requests[0].headers, {});
assert.equal(requests[0].body, undefined);
});
test('api sends changes as JSON with the CSRF header', async () => {
await api('stories', 'POST', { urls: 'x' });
assert.equal(requests[0].method, 'POST');
assert.equal(requests[0].headers['X-Requested-With'], 'bindery');
assert.equal(requests[0].headers['Content-Type'], 'application/json');
assert.equal(requests[0].body, '{"urls":"x"}');
await api('stories/1', 'DELETE');
assert.equal(requests[1].headers['X-Requested-With'], 'bindery');
assert.equal(requests[1].headers['Content-Type'], undefined);
});
test('api sends a file upload as it is, so the browser sets the multipart type', async () => {
const form = new FormData();
form.append('file', new Blob(['epub']), 'story.epub');
await api('stories/import-epub', 'POST', form);
assert.equal(requests[0].body, form);
assert.equal(requests[0].headers['X-Requested-With'], 'bindery');
assert.equal(requests[0].headers['Content-Type'], undefined);
});
test('api turns an error answer into an ApiError with its data', async () => {
reply = { status: 400, body: { error: 'Bad pattern', index: 2 } };
await assert.rejects(api('rules', 'PUT', { rules: [] }), (e) => {
assert.ok(e instanceof ApiError);
assert.equal(e.message, 'Bad pattern');
assert.equal(e.status, 400);
assert.equal(e.data.index, 2);
return true;
});
});
test('api reports an answer that is not JSON by its status', async () => {
reply = { status: 502, body: undefined };
await assert.rejects(api('stories'), { message: '502 Status', status: 502 });
});
test('api calls the unauthorized handler, except for login requests', async () => {
let calls = 0;
setUnauthorizedHandler(() => calls++);
reply = { status: 401, body: { error: 'Unauthorized' } };
await assert.rejects(api('stories'));
assert.equal(calls, 1);
await assert.rejects(api('auth/login', 'POST', {}));
assert.equal(calls, 1);
});
test('externalLink links http and https URLs, and leaves other URLs as text', () => {
const a = externalLink('https://example.com/s/1', 'example.com');
assert.equal(a.tagName, 'a');
assert.deepEqual(a.attributes, { href: 'https://example.com/s/1', target: '_blank', rel: 'noreferrer noopener' });
assert.deepEqual(a.children, ['example.com']);
assert.deepEqual(externalLink('HTTP://example.com').children, ['HTTP://example.com']);
assert.equal(externalLink('javascript:alert(1)'), 'javascript:alert(1)');
assert.equal(externalLink(' javascript:alert(1)', 'site'), 'site');
assert.equal(externalLink('data:text/html,x', 'site'), 'site');
assert.equal(externalLink(null, 'site'), 'site');
});
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, server, settle } from './flow-helpers.mjs';
const ui = install();
const { startPolling, stopPolling, watchJobs } = await import('../../bindery/static/js/jobs.js');
test('polling adapts to work and visibility, retries failures, and stops after an in-flight request', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const api = server(t);
const rendered = [];
const unsubscribe = watchJobs({ render: (jobs) => rendered.push(jobs) });
t.after(() => { unsubscribe(); stopPolling(); });
let jobs = [];
api.on('GET', 'jobs', () => jobs);
startPolling();
await settle();
t.mock.timers.tick(9999);
assert.equal(api.requests.length, 1);
jobs = [{ id: 'job', status: 'running' }];
t.mock.timers.tick(1);
await settle();
assert.equal(api.requests.length, 2);
t.mock.timers.tick(1999);
assert.equal(api.requests.length, 2);
t.mock.timers.tick(1);
await settle();
assert.equal(api.requests.length, 3);
document.hidden = true;
ui.documentEvents.visibilitychange();
assert.equal(api.requests.length, 3);
t.mock.timers.tick(2000);
await settle();
t.mock.timers.tick(59999);
assert.equal(api.requests.length, 4);
t.mock.timers.tick(1);
await settle();
assert.equal(api.requests.length, 5);
document.hidden = false;
api.on('GET', 'jobs', () => { throw new Error('Connection lost'); });
const previous = rendered.length;
ui.documentEvents.visibilitychange();
await settle();
assert.equal(rendered.length, previous);
jobs = [{ id: 'job', status: 'done' }];
api.on('GET', 'jobs', () => jobs);
t.mock.timers.tick(2000);
await settle();
assert.deepEqual(rendered.at(-1), jobs);
let resolve;
api.on('GET', 'jobs', () => new Promise((done) => { resolve = done; }));
t.mock.timers.tick(10000);
stopPolling();
resolve([]);
await settle();
const total = api.requests.length;
t.mock.timers.tick(60000);
await settle();
assert.equal(api.requests.length, total);
});
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, server, find, settle } from './flow-helpers.mjs';
const ui = install();
test('proxy authentication without a user does not offer a password login', async (t) => {
const backend = server(t);
backend.on('GET', 'auth/check', { authenticated: false, mode: 'proxy' });
await import('../../bindery/static/js/main.js');
await settle();
assert.match(ui.app.textContent, /Log in through the proxy/);
assert.equal(find(ui.app, 'button', 'Log in'), undefined);
assert.deepEqual(backend.requests.map((r) => r.path), ['auth/check']);
});
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { find, install, server, settle } from './flow-helpers.mjs';
const ui = install();
const { api, setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js');
const { stopPolling } = await import('../../bindery/static/js/jobs.js');
// The proxy login page has no form to retry from, and a fetch keeps the proxy's own session
// cookie, so only a full page load lets the proxy step in again.
test('a 401 while logged in through the proxy reloads the page', async (t) => {
t.after(() => {
stopPolling();
setUnauthorizedHandler(() => {});
});
const backend = server(t);
backend.on('GET', 'auth/check', { authenticated: true, mode: 'proxy', user: 'alice' });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
backend.on('GET', 'expired', { error: 'Session ended' }, 401);
await import('../../bindery/static/js/main.js');
await settle();
assert.match(ui.app.textContent, /alice/);
let reloaded = 0;
window.location.reload = () => reloaded++;
await assert.rejects(api('expired'), /Session ended/);
await settle();
assert.equal(reloaded, 1);
assert.equal(backend.sent('GET', 'auth/check').length, 1);
});
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, nodes, server, settle } from './flow-helpers.mjs';
install();
const { globalRulesCard, regexDocs } = await import('../../bindery/static/js/rules.js');
const SYNTAX = 'https://docs.python.org/3/library/re.html#regular-expression-syntax';
const HOWTO = 'https://docs.python.org/3/howto/regex.html';
const links = (root) => nodes(root).filter((el) => el.tagName === 'a').map((a) => [a.textContent, a.attributes.href]);
test('the rule hints link the regular expression syntax and an introduction', async (t) => {
const backend = server(t);
backend.on('GET', 'rules', { rules: [], paragraphs: null, global_paragraphs: false });
backend.on('GET', 'stories', []);
const card = globalRulesCard();
await settle();
const expected = [['Python regular expressions', SYNTAX], ['introduction', HOWTO]];
assert.deepEqual(links(card).filter(([, href]) => href.startsWith('https://docs.python.org/')), expected);
const [syntax] = regexDocs();
assert.equal(syntax.attributes.target, '_blank');
assert.equal(syntax.attributes.rel, 'noreferrer noopener');
});
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Element, find, install, nodes, server, settle } from './flow-helpers.mjs';
install();
const { chapterPage } = await import('../../bindery/static/js/chapter.js');
test('the revision in the book and the newest revision have no Delete button', async (t) => {
const backend = server(t);
backend.on('GET', 'jobs', []);
backend.on('GET', 'chapters/2/diff?', {
chapter: { id: 2, title: 'One', url: 'https://example.com/c/1', current_rev: 1 }, story_title: 'Story',
a: 1, b: 3, identical: false, html: '', changed: 1, added: 0, removed: 0,
revisions: [{ rev: 1, size: 100 }, { rev: 2, size: 100 }, { rev: 3, size: 100 }],
});
const main = new Element('main');
const cleanup = chapterPage(main, { storyId: '1', chapterId: '2' });
await settle();
cleanup?.();
const rows = nodes(main).filter((el) => el.tagName === 'tr').slice(1);
const badges = rows.map((row) => nodes(row.children[0]).filter((el) => el.tagName === 'span').map((el) => el.textContent));
assert.deepEqual(badges, [['in book'], [], ['newest']]);
assert.deepEqual(rows.map((row) => Boolean(find(row, 'button', 'Delete'))), [false, true, false]);
});
+11
View File
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
test('the monospace rule fields use the small text size, and stay as tall as the note field', () => {
const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8');
const rules = [...css.matchAll(/\.rule input:not\(\.rule-note\) \{([^}]*)\}/g)].map((m) => m[1]).join('');
for (const declaration of ['font-family: var(--mono)', 'font-size: var(--text-sm)', 'line-height: 1.5rem']) {
assert.ok(rules.includes(`${declaration};`), declaration);
}
});
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { install, server, settle } from './flow-helpers.mjs';
const ui = install();
test('startup failure reports the API error rather than leaving a blank application', async (t) => {
const backend = server(t);
backend.on('GET', 'auth/check', { error: 'Service unavailable' }, 503);
await import('../../bindery/static/js/main.js');
await settle();
assert.match(ui.app.textContent, /Service unavailable/);
assert.deepEqual(backend.requests.map((r) => r.path), ['auth/check']);
});
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Element, install, nodes, server, settle } from './flow-helpers.mjs';
install();
const { storyPage } = await import('../../bindery/static/js/story.js');
const details = (cover) => ({
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: {}, site_values: {}, auto_update: true, status: 'ok', cover },
chapters: [],
recent_chapters: 0,
});
const rules = { rules: [], paragraphs: null, global_paragraphs: false };
async function render(t, cover) {
const backend = server(t);
backend.on('GET', 'stories/1', details(cover));
backend.on('GET', 'stories/1/rules', rules);
backend.on('GET', 'jobs?story_id=1', []);
const main = new Element('main');
const cleanup = storyPage(main, { storyId: '1' }, { metadata_fields: {}, mode_labels: {} });
await settle();
cleanup?.();
const summary = nodes(main).find((el) => el.className === 'card story-summary');
return summary.children.filter((el) => el instanceof Element);
}
test('the cover comes first in the story summary, with its version in the URL', async (t) => {
const [cover, heading] = await render(t, 'abc123');
assert.equal(cover.tagName, 'img');
assert.equal(cover.className, 'cover');
assert.equal(cover.attributes.src, 'api/stories/1/cover?v=abc123');
assert.equal(heading.className, 'story-heading');
assert.equal(heading.children[0].textContent, 'Story');
});
test('a story without a cover shows only its heading', async (t) => {
const children = await render(t, null);
assert.deepEqual(children.map((el) => el.className), ['story-heading']);
});
+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { install, nodes, server, settle } from './flow-helpers.mjs';
const ui = install();
const { stopPolling } = await import('../../bindery/static/js/jobs.js');
const title = 'A very long story title that the tab cuts off with an ellipsis';
const story = (id, storyTitle) => ({
story: { id, title: storyTitle, author: 'Writer', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: {}, site_values: {}, auto_update: true, status: 'ok' },
chapters: [{ id: 2, title: 'One', url: 'chapter-1', included: true, on_site: true, current_rev: 1, revisions: 1 }],
recent_chapters: 0,
});
const diff = { chapter: { id: 2, title: 'One', url: 'https://example.com/c/1', current_rev: 1 }, story_title: title,
a: 1, b: 1, identical: true, html: '', changed: 0, added: 0, removed: 0, revisions: [{ rev: 1, size: 10 }] };
const rules = { rules: [], paragraphs: null, global_paragraphs: false };
const tabs = () => nodes(ui.app).filter((el) => el.tagName === 'nav').flatMap((nav) => nav.children);
const storyTabs = () => tabs().filter((a) => a.className === 'story-tab');
const current = () => tabs().filter((a) => a.attributes['aria-current'] === 'page').map((a) => a.textContent);
const go = async (hash) => {
window.location.hash = hash;
ui.windowEvents.hashchange();
await settle();
};
test('an open story has a tab with its title, also on its chapter pages', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(stopPolling);
const backend = server(t);
backend.on('GET', 'auth/check', { authenticated: true, mode: 'none', user: '' });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
backend.on('GET', 'settings', { recent_chapters: 0, personal_ini: '' });
backend.on('GET', 'rules', rules);
backend.on('GET', 'stories/1', story(1, title));
backend.on('GET', 'stories/1/rules', rules);
backend.on('GET', 'stories/2', story(2, 'Second'));
backend.on('GET', 'stories/2/rules', rules);
backend.on('GET', 'chapters/2/diff?', diff);
await import('../../bindery/static/js/main.js');
await settle();
assert.deepEqual(storyTabs(), []);
await go('#/story/1');
const [tab] = storyTabs();
assert.equal(tab.textContent, title);
assert.equal(tab.attributes.title, title);
assert.equal(tab.attributes.href, '#/story/1');
assert.deepEqual(current(), [title]);
await go('#/story/1/chapter/2');
assert.deepEqual(storyTabs(), [tab]);
assert.deepEqual(current(), [title]);
await go('#/story/2');
assert.equal(storyTabs().length, 1);
assert.deepEqual(current(), ['Second']);
await go('#/settings');
assert.deepEqual(storyTabs(), []);
assert.deepEqual(current(), ['Settings']);
});
test('a story that loads after the user left it gets no tab', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(stopPolling);
const backend = server(t);
let release;
backend.on('GET', 'stories', []);
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories/3', () => new Promise((resolve) => { release = () => resolve(story(3, 'Late')); }));
backend.on('GET', 'stories/3/rules', rules);
await go('#/story/3');
await go('#/');
release();
await settle();
assert.deepEqual(storyTabs(), []);
assert.deepEqual(current(), ['Stories']);
});
test('a long title ends in an ellipsis', () => {
const css = readFileSync(new URL('../../bindery/static/style.css', import.meta.url), 'utf8');
const rule = css.match(/header nav a\.story-tab \{([^}]*)\}/)[1];
for (const declaration of ['max-width: 24ch', 'overflow: hidden', 'text-overflow: ellipsis', 'white-space: nowrap']) {
assert.ok(rule.includes(`${declaration};`), declaration);
}
// Without it, the nav keeps the width of the whole title and runs past the header on a phone.
assert.ok(css.match(/header nav \{([^}]*)\}/)[1].includes('min-width: 0;'));
});
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Element, change, click, find, install, nodes, section, server, settle } from './flow-helpers.mjs';
const ui = install();
const { settingsPage } = await import('../../bindery/static/js/settings.js');
const { storyPage } = await import('../../bindery/static/js/story.js');
const { storiesPage } = await import('../../bindery/static/js/stories.js');
const book = { id: 7, uuid: 'book-7', title: 'Library Title', authors: ['Writer'] };
const details = (story, extra = {}) => ({
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
locked_fields: [], pinned: {}, site_values: {}, auto_update: true, status: 'ok', cover: null,
calibre_id: null, calibre_uuid: null, ingested_at: null, upload_held: false, needs_approval: false, ...story },
chapters: [],
recent_chapters: 0,
uploads_paused: false,
book: null,
...extra,
});
async function renderStory(t, data) {
const backend = server(t);
backend.on('GET', 'stories/1', data);
backend.on('GET', 'stories/1/rules', { rules: [], paragraphs: null, global_paragraphs: false });
backend.on('GET', 'jobs?story_id=1', []);
backend.on('GET', 'jobs', []);
const main = new Element('main');
const cleanup = storyPage(main, { storyId: '1' }, { metadata_fields: {}, mode_labels: {} });
t.after(() => cleanup?.());
await settle();
const summary = nodes(main).find((el) => el.className === 'card story-summary');
return { backend, notice: nodes(summary).find((el) => el.className === 'notice') };
}
test('the upload switches load and save on the settings page', async (t) => {
const backend = server(t);
backend.on('GET', 'settings', { recent_chapters: 0, hold_new_links: true, pause_uploads: false, personal_ini: '' });
backend.on('PUT', 'settings', {});
backend.on('GET', 'rules', { rules: [], paragraphs: false });
backend.on('GET', 'stories', []);
const main = new Element('main');
settingsPage(main);
await settle();
const card = section(main, 'Uploads to CWA');
const [hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
assert.deepEqual([hold.checked, pause.checked], [true, false]);
change(pause, true);
await click(card, 'Save');
assert.deepEqual(backend.sent('PUT', 'settings')[0].body, { hold_new_links: true, pause_uploads: true });
});
test('a held book with a new link names the book and sends it on approval', async (t) => {
const data = details(
{ calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true, needs_approval: true },
{ book },
);
const { backend, notice } = await renderStory(t, data);
assert.match(notice.textContent, /waits for your approval\. The next upload replaces the ePub of library book 7: Library Title · Writer\./);
backend.on('POST', 'stories/1/approve', { story: data.story, job: { id: 'j1' } });
await click(notice, 'Approve and send');
await settle();
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: 'book-7' });
assert.match(ui.toasts.textContent, /Approved\. Bindery rebuilds the book and sends it\./);
});
test('a wrong link can go to CWA as a new book instead', async (t) => {
const data = details(
{ calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true, needs_approval: true },
{ book },
);
const { backend, notice } = await renderStory(t, data);
backend.on('POST', 'stories/1/link', data.story);
backend.on('POST', 'stories/1/approve', { story: data.story, job: null });
await click(notice, 'Send as a new book instead');
await settle();
assert.deepEqual(
backend.requests.filter((r) => r.method === 'POST').map((r) => [r.path, r.body]),
[['stories/1/link', { uuid: null }], ['stories/1/approve', { uuid: null }]],
);
});
test('a new story asks for approval to go to CWA as a new book', async (t) => {
const { notice } = await renderStory(t, details({ needs_approval: true }));
assert.match(notice.textContent, /The next upload goes to CWA as a new book\./);
assert.ok(find(notice, 'button', 'Approve'));
assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined);
});
test('a paused upload points to the settings', async (t) => {
const { notice } = await renderStory(
t,
details({ calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true }, { book, uploads_paused: true }),
);
assert.match(notice.textContent, /Uploads to CWA are paused/);
assert.equal(find(notice, 'a', 'Settings').attributes.href, '#/settings');
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
});
test('a story that went to CWA without a link shows no approval until Bindery finds its book', async (t) => {
const { notice } = await renderStory(t, details({ ingested_at: '2026-01-01', needs_approval: true }));
assert.equal(notice, undefined);
});
test('the story list marks a held book', async (t) => {
const backend = server(t);
backend.on('GET', 'stories', [
{ id: 1, url: 'u1', title: 'Held', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
message: '', upload_held: true },
{ id: 2, url: 'u2', title: 'Sent', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
message: '', upload_held: false },
]);
backend.on('GET', 'jobs', []);
const main = new Element('main');
const cleanup = storiesPage(main, {}, { library_available: false });
t.after(() => cleanup?.());
await settle();
const held = nodes(main).filter((el) => el.tagName === 'tr').filter((row) => find(row, 'span', 'held'));
assert.equal(held.length, 1);
assert.match(held[0].textContent, /^Held/);
});
+35
View File
@@ -1,6 +1,9 @@
"""The web API: login modes, the CSRF header, URL_BASE and the web interface."""
import base64
import hashlib
import io
import re
import time
import pytest
@@ -177,6 +180,7 @@ def test_incomplete_login_settings_refuse_every_request(client, monkeypatch, set
):
assert (response.status_code, response.json) == (503, {"error": message})
assert client.get("/api/health").status_code == 200
assert client.get("/").status_code == 200
def test_api_key(client, cwa, monkeypatch):
@@ -309,3 +313,34 @@ def test_metadata_edit(client):
assert (response.status_code, response.json) == (400, {"error": "A story needs a title"})
assert client.put(f"/api/stories/{story_id}/metadata", json={}, headers=H).status_code == 400
assert client.put("/api/stories/999/metadata", json={"values": {}}, headers=H).status_code == 404
def test_web_interface_is_served(client):
page = client.get("/")
assert page.status_code == 200 and b'<script type="module" src="static/js/main.js">' in page.data
assert client.get("/static/js/main.js").status_code == 200
assert client.get("/static/style.css").status_code == 200
def test_the_page_allows_only_its_own_scripts(client):
page = client.get("/")
policy = page.headers["Content-Security-Policy"]
assert "default-src 'self'" in policy and "unsafe-inline" not in policy
scripts = re.findall(r"<script>(.*?)</script>", page.get_data(as_text=True), re.DOTALL)
assert scripts
for script in scripts:
digest = base64.b64encode(hashlib.sha256(script.encode()).digest()).decode()
assert f"'sha256-{digest}'" in policy
def test_url_base():
app = web.PrefixMiddleware(web.app.wsgi_app, "/bindery")
web.app.wsgi_app, original = app, web.app.wsgi_app
try:
client = web.app.test_client()
assert client.get("/bindery/api/health").status_code == 200
assert client.get("/api/health").status_code == 200
assert client.get("/bindery/static/style.css").status_code == 200
assert client.get("/other").status_code == 404
finally:
web.app.wsgi_app = original