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.
660 lines
22 KiB
JavaScript
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 });
|
|
}
|