Files
bindery/bindery/static/js/story.js
T
tim 5ef68fb240 Make the metadata fields readable on a phone
Each field was a row of a four-column table: name, lock, value and the
site's current value. On a narrow screen the columns squeezed the
inputs down to a few characters wide.

Each field is now a card with its name and lock on one line and the
input below at full width. The site's value shows only when it
differs, folded behind a line that opens on tap, instead of always
taking its own column. The lock is a labelled toggle instead of a
bare checkbox, and Save stays disabled until something changes.

On a wide screen, the label sits beside the field instead of above
it, and a single-line input no longer stretches to fill the card.
2026-09-27 23:38:28 +02:00

660 lines
22 KiB
JavaScript

// 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 unsaved = h('span', { class: 'hint' }, 'Unsaved changes');
const unsavedBox = h('span', {});
const renderSaveState = () => {
save.disabled = !dirty;
fill(unsavedBox, dirty && unsaved);
};
const fieldCards = 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;
renderSaveState();
},
};
const input =
key === 'comments'
? h('textarea', { ...attrs, rows: 4 })
: h('input', { ...attrs, type: key === 'pubdate' ? 'date' : 'text' });
// The current value, edited or from the site. A note appears only when it differs from the site.
const currentText = edited ? formatField(key, story.pinned[key]) : formatField(key, siteValue(story, key));
const siteText = formatField(key, siteValue(story, key));
const differs = currentText !== siteText;
const note =
differs &&
h(
'details',
{ class: 'field-site' },
h('summary', {}, edited && !locked.has(key) ? [badge('edited', 'Edited'), ' Site value differs'] : 'Site value differs'),
h('p', {}, siteText || h('em', {}, 'Not set on the site')),
edited &&
!locked.has(key) &&
h(
'div',
{ class: 'controls' },
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(
'div',
{ class: 'field' },
h(
'div',
{ class: 'field-head' },
h('span', { class: 'field-label' }, label),
h(
'label',
{ class: 'lock-toggle' },
checkbox(locked.has(key), (on) => {
if (on) locked.add(key);
else locked.delete(key);
dirty = true;
renderMetadata();
}, { 'aria-label': `Lock ${label}` }),
locked.has(key) ? 'Locked' : 'Lock',
),
),
input,
note,
);
});
const save = 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' });
fill(metadata, h('div', { class: 'field-grid' }, fieldCards), h('div', { class: 'controls' }, save, unsavedBox));
renderSaveState();
}
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 });
}