// 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 }); }