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