380 lines
13 KiB
JavaScript
380 lines
13 KiB
JavaScript
// 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';
|
|
|
|
const STORY_SORT_KEY = 'bindery.story-sort';
|
|
const STORY_COLUMNS = [
|
|
{ label: 'Title', key: 'title', type: 'text' },
|
|
{ label: 'Author', key: 'author', type: 'text' },
|
|
{ label: 'Site', key: 'site', type: 'text' },
|
|
{ label: 'Chapters', key: 'chapter_count', type: 'number' },
|
|
{ label: 'Status', key: 'status', type: 'text' },
|
|
{ label: 'Last change', key: 'last_changed', type: 'date' },
|
|
{ label: 'Last check', key: 'last_checked', type: 'date' },
|
|
];
|
|
|
|
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, title = 'Import an ePub file') {
|
|
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.';
|
|
const card = h(
|
|
'details',
|
|
{
|
|
class: 'card',
|
|
// A file dropped on the closed card opens it and puts the file in the picker, ready to import.
|
|
ondragover: (e) => e.preventDefault(),
|
|
ondrop: (e) => {
|
|
e.preventDefault();
|
|
const dropped = e.dataTransfer?.files;
|
|
if (dropped && dropped.length) {
|
|
file.files = dropped;
|
|
card.open = true;
|
|
}
|
|
},
|
|
},
|
|
h('summary', {}, h('h2', {}, title)),
|
|
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'),
|
|
),
|
|
);
|
|
return card;
|
|
}
|
|
|
|
function storyDate(label, value) {
|
|
const date = new Date(value);
|
|
let content = fmtDate(value);
|
|
if (value && !Number.isNaN(date.getTime())) {
|
|
content = h('time', { datetime: value, title: content }, content);
|
|
}
|
|
return h('td', { class: 'date', 'data-label': label }, content);
|
|
}
|
|
|
|
function storyRow(s) {
|
|
const status = s.status && badge(s.status, s.status, s.message);
|
|
if (status && s.message) {
|
|
status.setAttribute('tabindex', '0');
|
|
status.setAttribute('aria-description', s.message);
|
|
}
|
|
return h(
|
|
'tr',
|
|
{},
|
|
h('td', {}, link(`/story/${s.id}`, s.title || s.url), s.busy && [' ', badge('running', 'working')]),
|
|
h('td', { 'data-label': 'Author' }, s.author),
|
|
h('td', { class: 'story-site', 'data-label': 'Site' }, s.site),
|
|
h(
|
|
'td',
|
|
{ 'data-label': 'Chapters' },
|
|
String(s.chapter_count),
|
|
s.offsite_count > 0 && h('small', { title: 'Chapters the site no longer lists' }, `${s.offsite_count} removed`),
|
|
s.revised_count > 0 && h('small', { title: 'Chapters the site changed since Bindery added them' }, `${s.revised_count} revised`),
|
|
),
|
|
h(
|
|
'td',
|
|
{ 'data-label': 'Status' },
|
|
status,
|
|
s.approval_held && [' ', badge('warn', 'held', 'The book waits for your approval. See the story page.')],
|
|
),
|
|
storyDate('Last change', s.last_changed),
|
|
storyDate('Last check', s.last_checked),
|
|
);
|
|
}
|
|
|
|
function sortTitle(title) {
|
|
return title.trim().replace(/^(?:the|an?)\s+/i, '');
|
|
}
|
|
|
|
export function storiesPage(main, _params, config) {
|
|
document.title = 'Stories · Bindery';
|
|
let stories = null;
|
|
let filter = '';
|
|
let sort = 'title';
|
|
let direction = 1;
|
|
try {
|
|
const saved = JSON.parse(window.localStorage.getItem(STORY_SORT_KEY));
|
|
if (saved && STORY_COLUMNS.some(({ key }) => key === saved.column) && [1, -1].includes(saved.direction)) {
|
|
sort = saved.column;
|
|
direction = saved.direction;
|
|
}
|
|
} catch {
|
|
// Keep the default if storage is blocked or its value is invalid.
|
|
}
|
|
const sortButtons = new Map();
|
|
const table = h('div', {}, h('p', { class: 'empty' }, 'Loading…'));
|
|
const jobs = jobPanel({ hours: config.main_jobs_hours, count: config.main_jobs_count });
|
|
|
|
const render = () => {
|
|
const focusedSort = [...sortButtons].find(([, control]) => control === document.activeElement)?.[0];
|
|
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];
|
|
const sortType = STORY_COLUMNS.find(({ key }) => key === sort).type;
|
|
shown.sort((a, b) => {
|
|
if (sortType === 'date') {
|
|
const left = Date.parse(a[sort]);
|
|
const right = Date.parse(b[sort]);
|
|
if (Number.isNaN(left) !== Number.isNaN(right)) return Number.isNaN(left) ? 1 : -1;
|
|
const order = (left - right) * direction;
|
|
if (order) return order;
|
|
} else if (sort !== 'title') {
|
|
const order = sortType === 'number'
|
|
? a[sort] - b[sort]
|
|
: (a[sort] || '').localeCompare(b[sort] || '', undefined, { sensitivity: 'base' });
|
|
if (order) return order * direction;
|
|
}
|
|
const order = sortTitle(a.title || a.url).localeCompare(sortTitle(b.title || b.url), undefined, { sensitivity: 'base' }) || a.id - b.id;
|
|
return sort === 'title' ? order * direction : order;
|
|
});
|
|
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 heading = ({ label, key, type }) => {
|
|
const active = sort === key;
|
|
const nextDirection = active ? -direction : type === 'text' ? 1 : -1;
|
|
const orders = type === 'text' ? ['A to Z', 'Z to A']
|
|
: type === 'date' ? ['oldest first', 'newest first'] : ['fewest first', 'most first'];
|
|
const action = `Sort by ${label.toLowerCase()}, ${orders[nextDirection === 1 ? 0 : 1]}`;
|
|
const control = button(label, () => {
|
|
sort = key;
|
|
direction = nextDirection;
|
|
try {
|
|
window.localStorage.setItem(STORY_SORT_KEY, JSON.stringify({ column: sort, direction }));
|
|
} catch {
|
|
// Sorting still works when the browser blocks storage.
|
|
}
|
|
render();
|
|
sortButtons.get(key).focus();
|
|
}, { title: action, 'aria-label': action });
|
|
sortButtons.set(key, control);
|
|
return h('th', { scope: 'col', 'aria-sort': active ? direction === 1 ? 'ascending' : 'descending' : 'none' }, control);
|
|
};
|
|
const head = STORY_COLUMNS.map(heading);
|
|
fill(
|
|
table,
|
|
h(
|
|
'div',
|
|
{ class: 'table-wrap stories-table' },
|
|
h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow))),
|
|
),
|
|
);
|
|
if (focusedSort) sortButtons.get(focusedSort).focus();
|
|
};
|
|
|
|
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',
|
|
// Phones give no handle to resize a textarea, so the field grows to fit its URLs. It never
|
|
// shrinks, so a size set with the handle on a desktop stays.
|
|
oninput: () => {
|
|
if (urls.scrollHeight > urls.clientHeight) {
|
|
urls.style.height = `${urls.scrollHeight + urls.offsetHeight - urls.clientHeight}px`;
|
|
}
|
|
},
|
|
});
|
|
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 = '';
|
|
urls.style.height = '';
|
|
refreshJobs();
|
|
}
|
|
},
|
|
{ class: 'primary' },
|
|
);
|
|
|
|
fill(
|
|
main,
|
|
h(
|
|
'details',
|
|
{ class: 'card', open: true },
|
|
h('summary', {}, 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(
|
|
'details',
|
|
{ class: 'card', open: true },
|
|
h(
|
|
'summary',
|
|
{ class: 'section-heading' },
|
|
h('h2', {}, 'Stories'),
|
|
h('input', {
|
|
type: 'search',
|
|
placeholder: 'Filter',
|
|
'aria-label': 'Filter stories',
|
|
// Prevents a click here from also toggling the details open or closed.
|
|
onclick: (e) => e.preventDefault(),
|
|
oninput: (e) => {
|
|
filter = e.target.value;
|
|
if (stories) render();
|
|
},
|
|
}),
|
|
),
|
|
table,
|
|
),
|
|
h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Jobs')), jobs.el),
|
|
epubImport(),
|
|
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)),
|
|
);
|
|
|
|
load();
|
|
return watchJobs({ render: jobs.render, onFinished: load });
|
|
}
|