758 lines
27 KiB
JavaScript
758 lines
27 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);
|
|
|
|
// On a narrow screen, the rows stack instead of scrolling sideways. `extra` names the wrapper class
|
|
// that gives a table's stacked rows their own layout, such as picking out the row's first cell.
|
|
const table = (headings, rows, extra = '') =>
|
|
h(
|
|
'div',
|
|
{ class: `table-wrap ${extra}`.trim() },
|
|
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');
|
|
// One dialog for Move… and Delete…. It names the selected chapters before anything changes.
|
|
const dialog = h('dialog', { class: 'chapter-dialog', 'aria-labelledby': 'chapter-dialog-title' });
|
|
let moveAfterId = null;
|
|
// A shift-click on a chapter checkbox sets every chapter from the last one clicked.
|
|
let anchor = null;
|
|
let shift = false;
|
|
const jobs = jobPanel({ hours: config.story_jobs_hours, count: config.story_jobs_count });
|
|
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 = details.link_books === false ? 'Sent to ingest folder' : '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)}.`),
|
|
story.last_changed && h('span', { class: 'last-update' }, ` Last update ${fmtDate(story.last_changed)}.`),
|
|
),
|
|
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. Rebuild queued.' : 'Approved');
|
|
if (result.job) refreshJobs();
|
|
await load();
|
|
};
|
|
|
|
const approveAsNew = async () => {
|
|
const action = details.uploads_paused
|
|
? 'Unlink the library book, and approve this story as a new book? Uploads remain paused. '
|
|
: 'Unlink the library book, and send this story to CWA as a new book? ';
|
|
const message = action +
|
|
'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 linking = details.link_books !== false;
|
|
const unlinked = !linking && story.calibre_id == null;
|
|
// A previously uploaded book needs a known target before approval.
|
|
const approval = linking && story.needs_approval && (story.calibre_id != null || !story.ingested_at);
|
|
const paused = details.uploads_paused && (story.upload_held || approval);
|
|
if (!paused && !approval && !unlinked) return null;
|
|
let target = 'Approve this story as a new CWA book.';
|
|
if (book) {
|
|
target = `Approve replacing the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`;
|
|
} else if (story.calibre_id != null) {
|
|
target = `Approve replacing the ePub of library book ${story.calibre_id}.`;
|
|
}
|
|
return h(
|
|
'div',
|
|
{ class: 'notice' },
|
|
paused &&
|
|
h(
|
|
'p',
|
|
{},
|
|
details.ingest_available === false
|
|
? 'Set INGEST_DIR and restart Bindery to enable uploads.'
|
|
: ['Uploads to CWA are paused. Resume them in ', link('/settings', 'Settings'), '.'],
|
|
),
|
|
unlinked &&
|
|
h(
|
|
'p',
|
|
{},
|
|
'Uploads of this book have no CWA book ID. If CWA imports them, updates will create duplicate books.',
|
|
),
|
|
approval && [
|
|
h('p', {}, target),
|
|
h(
|
|
'div',
|
|
{ class: 'controls' },
|
|
button(story.upload_held && !details.uploads_paused ? 'Approve and send' : 'Approve', () => approve(story.calibre_uuid), {
|
|
class: 'primary',
|
|
}),
|
|
story.calibre_uuid && button(
|
|
details.uploads_paused ? 'Approve as a new book instead' : '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;
|
|
const ids = list.map((c) => c.id);
|
|
const pick = (i, on) => {
|
|
const from = shift ? ids.indexOf(anchor) : -1;
|
|
const range = from < 0 ? [ids[i]] : ids.slice(Math.min(from, i), Math.max(from, i) + 1);
|
|
for (const cid of range) on ? selected.add(cid) : selected.delete(cid);
|
|
anchor = ids[i];
|
|
shift = false;
|
|
if (range.length > 1) renderChapters();
|
|
};
|
|
let number = 0;
|
|
const rows = list.map((c, i) => {
|
|
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) => pick(i, on), {
|
|
'aria-label': `Select ${chapterTitle(c.title)}`,
|
|
title: 'Shift-click to select all chapters from the last one you clicked',
|
|
onclick: (e) => (shift = e.shiftKey),
|
|
}),
|
|
),
|
|
h('td', { 'data-label': '#' }, c.included ? String(number) : ''),
|
|
h('td', {}, link(route, chapterTitle(c.title))),
|
|
h(
|
|
'td',
|
|
{ class: 'badges', 'data-label': 'State' },
|
|
!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', { 'data-label': 'Revisions' }, c.revisions > 1 ? link(route, `${c.revisions}, compare`) : String(c.revisions)),
|
|
h('td', { class: 'date', 'data-label': 'Added' }, 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' });
|
|
// A phone hides the other headings, so the text shows only there.
|
|
const selectAll = h('label', {}, all, h('span', { class: 'select-all-text' }, ' Select all'));
|
|
fill(
|
|
chapters,
|
|
table(
|
|
[selectAll, '#', 'Title', 'State', 'Revisions', 'Added'],
|
|
rows.length ? rows : h('tr', {}, h('td', { colspan: 6, class: 'empty' }, 'No chapters stored yet.')),
|
|
'chapters-table',
|
|
),
|
|
);
|
|
}
|
|
|
|
// 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 finish = async (message) => {
|
|
dialog.close();
|
|
toast(message);
|
|
refreshJobs();
|
|
await load();
|
|
};
|
|
|
|
const moveForm = (picked, chapterIds) => {
|
|
const others = details.chapters.filter((c) => !selected.has(c.id));
|
|
const after = h(
|
|
'select',
|
|
{ id: 'move-after' },
|
|
h('option', { value: '' }, 'The start'),
|
|
others.map((c) => h('option', { value: String(c.id) }, chapterTitle(c.title))),
|
|
);
|
|
after.value = others.some((c) => c.id === moveAfterId) ? String(moveAfterId) : '';
|
|
return [
|
|
h('div', { class: 'controls' }, h('label', { for: 'move-after' }, picked.length === 1 ? 'Put it after' : 'Put them after'), after),
|
|
button('Move', async () => {
|
|
moveAfterId = after.value === '' ? null : Number(after.value);
|
|
await api(`stories/${id}/chapters/move`, 'POST', { chapter_ids: chapterIds, after: moveAfterId });
|
|
await finish('Chapters moved. The book is being rebuilt.');
|
|
}, { class: 'primary' }),
|
|
];
|
|
};
|
|
|
|
const deleteForm = (picked, chapterIds) => [
|
|
h(
|
|
'p',
|
|
{},
|
|
`Bindery deletes ${picked.length === 1 ? 'this chapter and its' : 'these chapters and their'} revisions. `,
|
|
'You cannot undo this.',
|
|
picked.some((c) => c.on_site) ? ' A chapter that the site still lists comes back with the next download of new chapters.' : '',
|
|
),
|
|
button('Delete', async () => {
|
|
await api(`stories/${id}/chapters/delete`, 'POST', { chapter_ids: chapterIds });
|
|
await finish('Chapters deleted. The book is being rebuilt.');
|
|
}, { class: 'danger' }),
|
|
];
|
|
|
|
const openDialog = (verb) => {
|
|
if (!selected.size) {
|
|
toast('Select chapters first', true);
|
|
return;
|
|
}
|
|
const picked = details.chapters.filter((c) => selected.has(c.id));
|
|
const chapterIds = picked.map((c) => c.id);
|
|
const [body, action] = (verb === 'Move' ? moveForm : deleteForm)(picked, chapterIds);
|
|
fill(
|
|
dialog,
|
|
h('h3', { id: 'chapter-dialog-title' }, `${verb} ${plural(picked.length, 'chapter')}`),
|
|
h('ol', { class: 'dialog-chapters' }, picked.map((c) => h('li', {}, chapterTitle(c.title)))),
|
|
body,
|
|
h('div', { class: 'dialog-actions' }, h('button', { type: 'button', onclick: () => dialog.close() }, 'Cancel'), action),
|
|
);
|
|
dialog.showModal();
|
|
};
|
|
|
|
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(
|
|
'details',
|
|
{ class: 'card' },
|
|
h(
|
|
'summary',
|
|
{ class: 'section-heading' },
|
|
h('h2', {}, 'Download'),
|
|
button('Start', start, { class: 'primary summary-action' }),
|
|
),
|
|
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(
|
|
'details',
|
|
{ class: 'card' },
|
|
h('summary', {}, 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('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Checking for new chapters')), updates),
|
|
h(
|
|
'details',
|
|
{ class: 'card' },
|
|
h('summary', {}, 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(
|
|
'details',
|
|
{ class: 'card', open: true },
|
|
h('summary', {}, h('h2', {}, 'Chapters')),
|
|
h(
|
|
'div',
|
|
{ class: 'controls chapter-actions' },
|
|
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)),
|
|
button('Move…', () => openDialog('Move')),
|
|
button('Delete…', () => openDialog('Delete'), { class: 'danger' }),
|
|
),
|
|
chapters,
|
|
dialog,
|
|
),
|
|
epubImport(id, 'Import chapters from an ePub'),
|
|
h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Recent jobs')), jobs.el),
|
|
h(
|
|
'details',
|
|
{ class: 'card' },
|
|
h('summary', {}, 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(
|
|
'details',
|
|
{ class: 'card card-danger' },
|
|
h('summary', {}, 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 });
|
|
}
|