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:
@@ -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 |
@@ -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>
|
||||
@@ -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(() => {}) });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 &, < and > in chapter titles, for its epub writer. & goes last, so "&lt;"
|
||||
// becomes "<".
|
||||
export const chapterTitle = (title) =>
|
||||
(title || '').replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&');
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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 });
|
||||
@@ -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))));
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 <three></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 & Magic <1>';
|
||||
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 & B' }] } }]);
|
||||
assert.ok(find(el, 'a', 'A & B'));
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 <b><br/>line</p><p>Two & 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 & Magic <1>'), 'Folly & Magic <1>');
|
||||
assert.equal(chapterTitle('Literal &lt;'), 'Literal <');
|
||||
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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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;'));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user