From 2f4271314b4643243851e90a1b9b85e9b88d31a0 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sat, 26 Sep 2026 08:06:02 +0200 Subject: [PATCH] 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. --- bindery/static/favicon.svg | 6 + bindery/static/index.html | 20 + bindery/static/js/chapter.js | 140 ++++++ bindery/static/js/jobs.js | 118 +++++ bindery/static/js/lib.js | 175 +++++++ bindery/static/js/main.js | 187 ++++++++ bindery/static/js/rules.js | 244 ++++++++++ bindery/static/js/settings.js | 127 ++++++ bindery/static/js/stories.js | 268 +++++++++++ bindery/static/js/story.js | 643 ++++++++++++++++++++++++++ bindery/static/style.css | 780 ++++++++++++++++++++++++++++++++ bindery/web.py | 30 ++ tests/js/auth-flows.test.mjs | 79 ++++ tests/js/flow-helpers.mjs | 100 ++++ tests/js/flows.test.mjs | 444 ++++++++++++++++++ tests/js/frontend.test.mjs | 235 ++++++++++ tests/js/jobs.test.mjs | 88 ++++ tests/js/layout.test.mjs | 129 ++++++ tests/js/lib.test.mjs | 161 +++++++ tests/js/polling-flows.test.mjs | 60 +++ tests/js/proxy-auth.test.mjs | 15 + tests/js/proxy-reload.test.mjs | 31 ++ tests/js/regex-docs.test.mjs | 23 + tests/js/revisions.test.mjs | 24 + tests/js/rule-fields.test.mjs | 11 + tests/js/startup-error.test.mjs | 14 + tests/js/story-cover.test.mjs | 41 ++ tests/js/story-tab.test.mjs | 93 ++++ tests/js/upload-hold.test.mjs | 121 +++++ tests/test_api.py | 35 ++ 30 files changed, 4442 insertions(+) create mode 100644 bindery/static/favicon.svg create mode 100644 bindery/static/index.html create mode 100644 bindery/static/js/chapter.js create mode 100644 bindery/static/js/jobs.js create mode 100644 bindery/static/js/lib.js create mode 100644 bindery/static/js/main.js create mode 100644 bindery/static/js/rules.js create mode 100644 bindery/static/js/settings.js create mode 100644 bindery/static/js/stories.js create mode 100644 bindery/static/js/story.js create mode 100644 bindery/static/style.css create mode 100644 tests/js/auth-flows.test.mjs create mode 100644 tests/js/flow-helpers.mjs create mode 100644 tests/js/flows.test.mjs create mode 100644 tests/js/frontend.test.mjs create mode 100644 tests/js/jobs.test.mjs create mode 100644 tests/js/layout.test.mjs create mode 100644 tests/js/lib.test.mjs create mode 100644 tests/js/polling-flows.test.mjs create mode 100644 tests/js/proxy-auth.test.mjs create mode 100644 tests/js/proxy-reload.test.mjs create mode 100644 tests/js/regex-docs.test.mjs create mode 100644 tests/js/revisions.test.mjs create mode 100644 tests/js/rule-fields.test.mjs create mode 100644 tests/js/startup-error.test.mjs create mode 100644 tests/js/story-cover.test.mjs create mode 100644 tests/js/story-tab.test.mjs create mode 100644 tests/js/upload-hold.test.mjs diff --git a/bindery/static/favicon.svg b/bindery/static/favicon.svg new file mode 100644 index 0000000..130b62d --- /dev/null +++ b/bindery/static/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/bindery/static/index.html b/bindery/static/index.html new file mode 100644 index 0000000..1782707 --- /dev/null +++ b/bindery/static/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + Bindery + + +
+
+ + diff --git a/bindery/static/js/chapter.js b/bindery/static/js/chapter.js new file mode 100644 index 0000000..9830063 --- /dev/null +++ b/bindery/static/js/chapter.js @@ -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(() => {}) }); +} diff --git a/bindery/static/js/jobs.js b/bindery/static/js/jobs.js new file mode 100644 index 0000000..3a25865 --- /dev/null +++ b/bindery/static/js/jobs.js @@ -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 }; +} diff --git a/bindery/static/js/lib.js b/bindery/static/js/lib.js new file mode 100644 index 0000000..f899c52 --- /dev/null +++ b/bindery/static/js/lib.js @@ -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, '>'); + return text + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => `

${escape(p).replace(/\n/g, '
')}

`) + .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 }); diff --git a/bindery/static/js/main.js b/bindery/static/js/main.js new file mode 100644 index 0000000..2e7fb64 --- /dev/null +++ b/bindery/static/js/main.js @@ -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)))); +}); diff --git a/bindery/static/js/rules.js b/bindery/static/js/rules.js new file mode 100644 index 0000000..4667dac --- /dev/null +++ b/bindery/static/js/rules.js @@ -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
tags in its own paragraph, and drops empty pieces. ' + + 'A
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, + ); +} diff --git a/bindery/static/js/settings.js b/bindery/static/js/settings.js new file mode 100644 index 0000000..1fbea25 --- /dev/null +++ b/bindery/static/js/settings.js @@ -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' }), + ), + ), + ); +} diff --git a/bindery/static/js/stories.js b/bindery/static/js/stories.js new file mode 100644 index 0000000..6253c8a --- /dev/null +++ b/bindery/static/js/stories.js @@ -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 }); +} diff --git a/bindery/static/js/story.js b/bindery/static/js/story.js new file mode 100644 index 0000000..50a1732 --- /dev/null +++ b/bindery/static/js/story.js @@ -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 }); +} diff --git a/bindery/static/style.css b/bindery/static/style.css new file mode 100644 index 0000000..6c55c8e --- /dev/null +++ b/bindery/static/style.css @@ -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); +} diff --git a/bindery/web.py b/bindery/web.py index 925a300..b7782f4 100644 --- a/bindery/web.py +++ b/bindery/web.py @@ -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"", 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() diff --git a/tests/js/auth-flows.test.mjs b/tests/js/auth-flows.test.mjs new file mode 100644 index 0000000..12313e7 --- /dev/null +++ b/tests/js/auth-flows.test.mjs @@ -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'); +}); diff --git a/tests/js/flow-helpers.mjs b/tests/js/flow-helpers.mjs new file mode 100644 index 0000000..36adbed --- /dev/null +++ b/tests/js/flow-helpers.mjs @@ -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), + }; +} diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs new file mode 100644 index 0000000..c552ffb --- /dev/null +++ b/tests/js/flows.test.mjs @@ -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: '

Description

', 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: '

diff

' }; } }; + 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 '); + 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: '

One

Two <three>

' } }); + 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: '

diff

', + 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')); +}); diff --git a/tests/js/frontend.test.mjs b/tests/js/frontend.test.mjs new file mode 100644 index 0000000..ce5389a --- /dev/null +++ b/tests/js/frontend.test.mjs @@ -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>/); + 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}`); + } + } +}); diff --git a/tests/js/jobs.test.mjs b/tests/js/jobs.test.mjs new file mode 100644 index 0000000..6153a09 --- /dev/null +++ b/tests/js/jobs.test.mjs @@ -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; +}); diff --git a/tests/js/layout.test.mjs b/tests/js/layout.test.mjs new file mode 100644 index 0000000..cf79d93 --- /dev/null +++ b/tests/js/layout.test.mjs @@ -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`); + } + } +}); diff --git a/tests/js/lib.test.mjs b/tests/js/lib.test.mjs new file mode 100644 index 0000000..ec66ac8 --- /dev/null +++ b/tests/js/lib.test.mjs @@ -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', '

A bold start.

'), 'A bold start.'); + const long = formatField('comments', `

${'x'.repeat(300)}

`); + assert.equal(long.length, 201); + assert.ok(long.endsWith('…')); +}); + +test('textToHtml makes paragraphs and escapes the text', () => { + assert.equal(textToHtml('One \nline\n\n Two & more \n\n\n'), '

One <b>
line

Two & more

'); + 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'); +}); diff --git a/tests/js/polling-flows.test.mjs b/tests/js/polling-flows.test.mjs new file mode 100644 index 0000000..07b20cd --- /dev/null +++ b/tests/js/polling-flows.test.mjs @@ -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); +}); diff --git a/tests/js/proxy-auth.test.mjs b/tests/js/proxy-auth.test.mjs new file mode 100644 index 0000000..302e384 --- /dev/null +++ b/tests/js/proxy-auth.test.mjs @@ -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']); +}); diff --git a/tests/js/proxy-reload.test.mjs b/tests/js/proxy-reload.test.mjs new file mode 100644 index 0000000..e0df5a1 --- /dev/null +++ b/tests/js/proxy-reload.test.mjs @@ -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); +}); diff --git a/tests/js/regex-docs.test.mjs b/tests/js/regex-docs.test.mjs new file mode 100644 index 0000000..13fc567 --- /dev/null +++ b/tests/js/regex-docs.test.mjs @@ -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'); +}); diff --git a/tests/js/revisions.test.mjs b/tests/js/revisions.test.mjs new file mode 100644 index 0000000..cecb7a7 --- /dev/null +++ b/tests/js/revisions.test.mjs @@ -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]); +}); diff --git a/tests/js/rule-fields.test.mjs b/tests/js/rule-fields.test.mjs new file mode 100644 index 0000000..c48cb97 --- /dev/null +++ b/tests/js/rule-fields.test.mjs @@ -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); + } +}); diff --git a/tests/js/startup-error.test.mjs b/tests/js/startup-error.test.mjs new file mode 100644 index 0000000..af92a86 --- /dev/null +++ b/tests/js/startup-error.test.mjs @@ -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']); +}); diff --git a/tests/js/story-cover.test.mjs b/tests/js/story-cover.test.mjs new file mode 100644 index 0000000..bb19e2b --- /dev/null +++ b/tests/js/story-cover.test.mjs @@ -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']); +}); diff --git a/tests/js/story-tab.test.mjs b/tests/js/story-tab.test.mjs new file mode 100644 index 0000000..eeeaeff --- /dev/null +++ b/tests/js/story-tab.test.mjs @@ -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;')); +}); diff --git a/tests/js/upload-hold.test.mjs b/tests/js/upload-hold.test.mjs new file mode 100644 index 0000000..a41a73d --- /dev/null +++ b/tests/js/upload-hold.test.mjs @@ -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/); +}); diff --git a/tests/test_api.py b/tests/test_api.py index 6a29718..3ec45ed 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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'", 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