Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34ff432aac | ||
|
|
e4a17029c1 | ||
|
|
b536a1f116 | ||
|
|
13d7360f60 | ||
|
|
8faab2463b | ||
|
|
ee611cd283 | ||
|
|
df9959a078 | ||
|
|
5df2806694 |
@@ -482,6 +482,34 @@ def set_chapters_included(story_id: int, chapter_ids: list[int], included: bool)
|
||||
return queue_story(story_id, DownloadMode.REBUILD.value)
|
||||
|
||||
|
||||
def move_chapters(story_id: int, chapter_ids: list[int], after_id: int | None) -> dict[str, Any]:
|
||||
"""Move chapters, in their current order, to directly after the chapter `after_id`, or to the start with None."""
|
||||
order = [c["id"] for c in store.list_chapters(story_id)]
|
||||
moving = [cid for cid in order if cid in set(chapter_ids)]
|
||||
if not moving:
|
||||
raise ValueError("Select the chapters to move")
|
||||
if after_id is not None and after_id not in order:
|
||||
raise KeyError(f"No chapter {after_id} in this story")
|
||||
if after_id in moving:
|
||||
raise ValueError("The chapters cannot go after a chapter that moves with them")
|
||||
rest = [cid for cid in order if cid not in moving]
|
||||
at = rest.index(after_id) + 1 if after_id is not None else 0
|
||||
store.set_positions(story_id, rest[:at] + moving + rest[at:])
|
||||
return queue_story(story_id, DownloadMode.REBUILD.value)
|
||||
|
||||
|
||||
def delete_chapters(story_id: int, chapter_ids: list[int]) -> dict[str, Any]:
|
||||
"""Delete chapters and their revisions. A chapter that the site still lists comes back with the next download."""
|
||||
order = [c["id"] for c in store.list_chapters(story_id)]
|
||||
deleting = [cid for cid in order if cid in set(chapter_ids)]
|
||||
if not deleting:
|
||||
raise ValueError("Select the chapters to delete")
|
||||
if len(deleting) == len(order):
|
||||
raise ValueError("A story must keep at least one chapter. To remove the story, stop tracking it")
|
||||
store.delete_chapters(story_id, deleting)
|
||||
return queue_story(story_id, DownloadMode.REBUILD.value)
|
||||
|
||||
|
||||
def link_story(story_id: int, book_uuid: str | None) -> None:
|
||||
"""Link a story to a library book by its uuid, or unlink it with None."""
|
||||
story = _require_story(story_id)
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
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.');
|
||||
@@ -160,7 +171,21 @@ export function epubImport(storyId, title = 'Import an ePub file') {
|
||||
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',
|
||||
{},
|
||||
@@ -171,39 +196,92 @@ function storyRow(s) {
|
||||
'td',
|
||||
{ 'data-label': 'Chapters' },
|
||||
String(s.chapter_count),
|
||||
s.offsite_count > 0 && [' ', badge('warn', `${s.offsite_count} removed`, 'Chapters the site no longer lists')],
|
||||
s.revised_count > 0 && [' ', badge('info', `${s.revised_count} revised`, 'Chapters the site changed since Bindery added them')],
|
||||
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' },
|
||||
s.status && badge(s.status),
|
||||
status,
|
||||
s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')],
|
||||
' ',
|
||||
h('small', {}, s.message),
|
||||
),
|
||||
h('td', { class: 'date', 'data-label': 'Last check' }, fmtDate(s.last_checked)),
|
||||
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;
|
||||
: [...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 head = ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last check'].map((t) => h('th', {}, t));
|
||||
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(
|
||||
@@ -212,6 +290,7 @@ export function storiesPage(main, _params, config) {
|
||||
h('table', {}, h('thead', {}, h('tr', {}, head)), h('tbody', {}, shown.map(storyRow))),
|
||||
),
|
||||
);
|
||||
if (focusedSort) sortButtons.get(focusedSort).focus();
|
||||
};
|
||||
|
||||
const load = () =>
|
||||
|
||||
@@ -87,7 +87,12 @@ export function storyPage(main, { storyId }, config) {
|
||||
const metadata = h('div', { class: 'metadata' });
|
||||
const updates = h('div');
|
||||
const chapters = h('div');
|
||||
const selectAll = h('span', { class: 'chapter-select-all' });
|
||||
// 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');
|
||||
@@ -150,6 +155,7 @@ export function storyPage(main, { storyId }, config) {
|
||||
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(),
|
||||
@@ -381,8 +387,17 @@ export function storyPage(main, { storyId }, config) {
|
||||
|
||||
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) => {
|
||||
const rows = list.map((c, i) => {
|
||||
if (c.included) number += 1;
|
||||
const route = `/story/${id}/chapter/${c.id}`;
|
||||
return h(
|
||||
@@ -391,8 +406,10 @@ export function storyPage(main, { storyId }, config) {
|
||||
h(
|
||||
'td',
|
||||
{},
|
||||
checkbox(selected.has(c.id), (on) => (on ? selected.add(c.id) : selected.delete(c.id)), {
|
||||
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) : ''),
|
||||
@@ -412,11 +429,12 @@ export function storyPage(main, { storyId }, config) {
|
||||
selected = new Set(on ? list.map((c) => c.id) : []);
|
||||
renderChapters();
|
||||
}, { 'aria-label': 'Select all chapters' });
|
||||
fill(selectAll, h('label', {}, all, ' Select all'));
|
||||
// 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(
|
||||
['', '#', 'Title', 'State', 'Revisions', 'Added'],
|
||||
[selectAll, '#', 'Title', 'State', 'Revisions', 'Added'],
|
||||
rows.length ? rows : h('tr', {}, h('td', { colspan: 6, class: 'empty' }, 'No chapters stored yet.')),
|
||||
'chapters-table',
|
||||
),
|
||||
@@ -462,6 +480,64 @@ export function storyPage(main, { storyId }, config) {
|
||||
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(
|
||||
@@ -601,7 +677,6 @@ export function storyPage(main, { storyId }, config) {
|
||||
'details',
|
||||
{ class: 'card', open: true },
|
||||
h('summary', {}, h('h2', {}, 'Chapters')),
|
||||
selectAll,
|
||||
h(
|
||||
'div',
|
||||
{ class: 'controls chapter-actions' },
|
||||
@@ -610,8 +685,11 @@ export function storyPage(main, { storyId }, config) {
|
||||
),
|
||||
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),
|
||||
|
||||
+114
-11
@@ -390,9 +390,8 @@ details.card[open] > summary .summary-action {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chapter-select-all {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
.select-all-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Stays visible while the chapter list scrolls, so the buttons need no scrolling back up to
|
||||
@@ -467,6 +466,7 @@ label:has(> input[type='radio']) {
|
||||
|
||||
.byline,
|
||||
.last-check,
|
||||
.last-update,
|
||||
.story-site,
|
||||
.field-default,
|
||||
small,
|
||||
@@ -745,6 +745,56 @@ textarea {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stories-table th {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stories-table th button {
|
||||
inline-size: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.stories-table th button:is(:hover, :focus-visible) {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.stories-table th[aria-sort] button::after {
|
||||
content: '↕';
|
||||
display: inline-block;
|
||||
inline-size: 1em;
|
||||
margin-inline-start: 0.3em;
|
||||
}
|
||||
|
||||
.stories-table th[aria-sort='ascending'] button::after {
|
||||
content: '↑';
|
||||
}
|
||||
|
||||
.stories-table th[aria-sort='descending'] button::after {
|
||||
content: '↓';
|
||||
}
|
||||
|
||||
.stories-table th[aria-sort]:not([aria-sort='none']) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stories-table td[data-label='Chapters'] small {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 601px) {
|
||||
.stories-table .date {
|
||||
inline-size: 1%;
|
||||
}
|
||||
}
|
||||
|
||||
/* The story list and the chapter list stack into cards below 600px, instead of scrolling
|
||||
sideways. data-label repeats each column heading in front of its value. */
|
||||
@media (max-width: 600px) {
|
||||
@@ -768,14 +818,16 @@ textarea {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stories-table thead,
|
||||
.chapters-table thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
clip-path: inset(50%);
|
||||
.stories-table thead tr {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stories-table th {
|
||||
padding: 4px 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.stories-table tbody tr,
|
||||
@@ -823,6 +875,57 @@ textarea {
|
||||
flex-basis: 100%;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Of the chapter list headings, only the Select all checkbox shows. */
|
||||
/* The left padding is the 1px border and 12px padding of a row card, so the checkboxes line up. */
|
||||
.chapters-table th {
|
||||
border-bottom: 0;
|
||||
padding: 0 0 8px 13px;
|
||||
}
|
||||
|
||||
.chapters-table th:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.select-all-text {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
/* The Move… and Delete… dialog of the chapter list. */
|
||||
.chapter-dialog {
|
||||
width: min(32rem, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chapter-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
.chapter-dialog h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.dialog-chapters {
|
||||
max-height: 40dvh;
|
||||
overflow-y: auto;
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 8px 8px 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Jobs */
|
||||
|
||||
+18
-2
@@ -6,6 +6,7 @@ site without losing text.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
@@ -120,6 +121,14 @@ def _story(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
return d
|
||||
|
||||
|
||||
_ARTICLE = re.compile(r"^(?:the|an?)\s+", re.I)
|
||||
|
||||
|
||||
def sort_title(title: str | None) -> str:
|
||||
"""The key that sorts a story list by title, without a leading "The", "A" or "An"."""
|
||||
return _ARTICLE.sub("", (title or "").strip()).casefold()
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
@@ -190,8 +199,8 @@ class Store:
|
||||
AND c.on_site = 0) AS offsite_count,
|
||||
(SELECT COUNT(*) FROM chapters c WHERE c.story_id = s.id AND c.included = 1
|
||||
AND (SELECT COUNT(*) FROM revisions r WHERE r.chapter_id = c.id) > 1) AS revised_count
|
||||
FROM stories s ORDER BY s.title COLLATE NOCASE""").fetchall()
|
||||
return [_story(r) for r in rows]
|
||||
FROM stories s""").fetchall()
|
||||
return [_story(r) for r in sorted(rows, key=lambda r: sort_title(r["title"]))]
|
||||
|
||||
def update_story(self, story_id: int, **fields: Any) -> None:
|
||||
if not fields:
|
||||
@@ -336,6 +345,13 @@ class Store:
|
||||
[(i, cid, story_id) for i, cid in enumerate(chapter_ids)],
|
||||
)
|
||||
|
||||
def delete_chapters(self, story_id: int, chapter_ids: list[int]) -> None:
|
||||
"""Delete chapters of the story and their revisions."""
|
||||
with self._connect() as conn:
|
||||
conn.executemany(
|
||||
"DELETE FROM chapters WHERE id = ? AND story_id = ?", [(cid, story_id) for cid in chapter_ids]
|
||||
)
|
||||
|
||||
def list_revisions(self, chapter_id: int) -> list[dict[str, Any]]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -332,6 +332,20 @@ def chapters_included(story_id: int) -> Any:
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/stories/<int:story_id>/chapters/move")
|
||||
def move_chapters(story_id: int) -> Any:
|
||||
data = _json()
|
||||
after = data.get("after")
|
||||
return orchestrator.move_chapters(
|
||||
story_id, [int(c) for c in data.get("chapter_ids", [])], None if after is None else int(after)
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/stories/<int:story_id>/chapters/delete")
|
||||
def delete_chapters(story_id: int) -> Any:
|
||||
return orchestrator.delete_chapters(story_id, [int(c) for c in _json().get("chapter_ids", [])])
|
||||
|
||||
|
||||
@app.get("/api/stories/<int:story_id>/epub")
|
||||
def story_epub(story_id: int) -> Any:
|
||||
path, title = orchestrator.build_for_download(story_id)
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fill a gap in a tracked story with chapters from an ePub that has no chapter links.
|
||||
|
||||
Bindery can import chapters from an ePub that FanFicFare made, because that
|
||||
ePub records the site address (the link) of each chapter. Other ePubs, such as
|
||||
the author's own ebook of the story, have no chapter links, so Bindery cannot
|
||||
tell where their chapters go. Use this script when the site no longer has some
|
||||
chapters, and you have an ePub without chapter links that has them.
|
||||
|
||||
Usage:
|
||||
|
||||
CONFIG_DIR=./config uv run python scripts/import_unlinked_epub_chapters.py <story-url> <path-to-epub>
|
||||
CONFIG_DIR=./config uv run python scripts/import_unlinked_epub_chapters.py <story-url> --remove
|
||||
|
||||
Run the script on the host, in the project folder, with CONFIG_DIR set to the
|
||||
folder that holds bindery.db. The container image does not include the
|
||||
scripts folder. Stop Bindery before you run the script, so that nothing else
|
||||
writes to the database at the same time.
|
||||
|
||||
The story must be tracked in Bindery, with at least one stored chapter. The
|
||||
script asks four questions:
|
||||
|
||||
1. Which stored chapter comes just before the gap. Type "start" if the gap
|
||||
is at the start of the story.
|
||||
2. Which ePub chapter is the same as that stored chapter. After "start", the
|
||||
question is which ePub chapter to add first, so that you can leave out the
|
||||
cover and the title pages.
|
||||
3. Which stored chapter comes just after the gap. Type "end" if the gap is at
|
||||
the end of the story.
|
||||
4. Which ePub chapter is the same as that stored chapter. After "end", the
|
||||
question is which ePub chapter to add last.
|
||||
|
||||
The script suggests an answer where the chapter titles match, and puts it on
|
||||
the input line. Press Enter to accept it, or change it. Type "list" to see all
|
||||
the chapters again. The script then shows the ePub chapters that go into the
|
||||
gap, in the order of the ePub, and adds them after you say yes. Their images
|
||||
come with them. A second run with the same ePub does not add the same chapters
|
||||
again.
|
||||
|
||||
The added chapters have no address on the site, so they show as "removed from
|
||||
site" on the story page. A "full" download takes them out of the book, like
|
||||
other chapters that the site does not list. Select them and use "Put back in
|
||||
book" to put them back. To delete them, use "Delete…" on the story page, or run
|
||||
the script with --remove. Rebuild the book after each change.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import posixpath
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import warnings
|
||||
import zipfile
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
|
||||
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
|
||||
from defusedxml import ElementTree
|
||||
|
||||
try:
|
||||
import readline
|
||||
except ImportError: # Windows
|
||||
readline = None
|
||||
|
||||
# Run as a file, Python puts scripts/ on the path, not the project folder that holds bindery/.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from fanficfare.story import imagetypes # noqa: E402
|
||||
|
||||
from bindery import config, fff # noqa: E402
|
||||
from bindery.diffing import text_hash # noqa: E402
|
||||
from bindery.store import Store # noqa: E402
|
||||
|
||||
OPF = "{http://www.idpf.org/2007/opf}"
|
||||
NCX = "{http://www.daisy.org/z3986/2005/ncx/}"
|
||||
DC = "{http://purl.org/dc/elements/1.1/}"
|
||||
CONTAINER = "{urn:oasis:names:tc:opendocument:xmlns:container}"
|
||||
URL_MARK = "#bindery-import-"
|
||||
SOURCE = "epub-import"
|
||||
# Images get a file: URL, so a build never asks the story site for them.
|
||||
IMAGE_URL = "file:///bindery-epub-import/"
|
||||
# A long list shows only this many chapters around the one that the question is about.
|
||||
WINDOW = 12
|
||||
|
||||
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||
|
||||
|
||||
class ScriptError(Exception):
|
||||
"""A problem the user can fix."""
|
||||
|
||||
|
||||
def _resolve(folder: str, href: str) -> str:
|
||||
"""The path in the zip file of a link from a file in `folder`, without its #fragment."""
|
||||
return posixpath.normpath(posixpath.join(folder, href.split("#", 1)[0]))
|
||||
|
||||
|
||||
def _toc_titles(z: zipfile.ZipFile, opf, folder: str) -> dict[str, str]:
|
||||
"""The title of each file in the ePub's table of contents (EPUB 2 NCX or EPUB 3 nav), by path.
|
||||
|
||||
The first entry of a file wins. Entries that are only a number, such as page numbers, are left out.
|
||||
"""
|
||||
items = list(opf.find(f"{OPF}manifest"))
|
||||
titles: dict[str, str] = {}
|
||||
|
||||
def add(path: str, label: str) -> None:
|
||||
label = " ".join(label.split())
|
||||
if label and not label.isdigit():
|
||||
titles.setdefault(path, label)
|
||||
|
||||
ncx = next((i for i in items if i.get("media-type") == "application/x-dtbncx+xml"), None)
|
||||
if ncx is not None:
|
||||
ncx_path = _resolve(folder, ncx.get("href"))
|
||||
nav_map = ElementTree.fromstring(z.read(ncx_path)).find(f"{NCX}navMap")
|
||||
for point in nav_map.iter(f"{NCX}navPoint") if nav_map is not None else []:
|
||||
text, content = point.find(f"{NCX}navLabel/{NCX}text"), point.find(f"{NCX}content")
|
||||
if text is not None and content is not None and content.get("src"):
|
||||
add(_resolve(posixpath.dirname(ncx_path), content.get("src")), text.text or "")
|
||||
nav = next((i for i in items if "nav" in (i.get("properties") or "").split()), None)
|
||||
if nav is not None:
|
||||
nav_path = _resolve(folder, nav.get("href"))
|
||||
soup = BeautifulSoup(z.read(nav_path), "html5lib")
|
||||
toc = soup.find("nav", attrs={"epub:type": "toc"}) or soup.find("nav")
|
||||
for a in toc.find_all("a", href=True) if toc else []:
|
||||
add(_resolve(posixpath.dirname(nav_path), a["href"]), a.get_text(" "))
|
||||
return titles
|
||||
|
||||
|
||||
def _only_links(soup: BeautifulSoup) -> bool:
|
||||
"""Whether all the text of a file is in links, as in a table of contents."""
|
||||
texts = [t for t in soup.body.find_all(string=True) if t.strip()] if soup.body else []
|
||||
return bool(texts) and all(t.find_parent("a") for t in texts)
|
||||
|
||||
|
||||
def _book_id(opf, path: Path) -> str:
|
||||
"""A short ID of the ePub, from its identifier, or else its file name."""
|
||||
identifier = opf.find(f"{OPF}metadata/{DC}identifier")
|
||||
key = identifier.text.strip() if identifier is not None and identifier.text else path.name
|
||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
def _take_images(z: zipfile.ZipFile, soup: BeautifulSoup, folder: str, book: str) -> list[dict]:
|
||||
"""Point each <img> of a chapter at a URL of its own, and return the images with those URLs.
|
||||
|
||||
An image that the ePub does not have, or that FanFicFare cannot use, is left out.
|
||||
"""
|
||||
images = []
|
||||
for img in soup.body.find_all("img", src=True):
|
||||
if img["src"].startswith(("data:", "http:", "https:")):
|
||||
continue
|
||||
path = _resolve(folder, img["src"])
|
||||
ext = path.rsplit(".", 1)[-1].lower()
|
||||
if ext in imagetypes and path in z.namelist():
|
||||
img["src"] = f"{IMAGE_URL}{book}/{path}"
|
||||
images.append({"url": img["src"], "ext": ext, "mime": imagetypes[ext], "data": z.read(path)})
|
||||
else:
|
||||
img.decompose()
|
||||
return images
|
||||
|
||||
|
||||
def read_epub(path: Path) -> list[dict]:
|
||||
"""Read the chapters of an ePub, in reading order, with the images that each one uses.
|
||||
|
||||
The script reads the OPF manifest and spine directly, so it can read any
|
||||
ePub. A file that has only links, such as a table of contents, is not a
|
||||
chapter. Titles come from the table of contents of the ePub, or else from
|
||||
the first heading of the chapter. Each chapter has a stable `key`: the ID
|
||||
of the ePub and the path of the chapter in it.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
container = ElementTree.fromstring(z.read("META-INF/container.xml"))
|
||||
rootfile = container.find(f".//{CONTAINER}rootfile")
|
||||
if rootfile is None or not rootfile.get("full-path"):
|
||||
raise ScriptError(f"{path.name} has no OPF file")
|
||||
opf_path = rootfile.get("full-path")
|
||||
opf = ElementTree.fromstring(z.read(opf_path))
|
||||
folder = posixpath.dirname(opf_path)
|
||||
manifest = {item.get("id"): item for item in opf.find(f"{OPF}manifest")}
|
||||
toc = _toc_titles(z, opf, folder)
|
||||
book = _book_id(opf, path)
|
||||
chapters = []
|
||||
for itemref in opf.find(f"{OPF}spine").findall(f"{OPF}itemref"):
|
||||
item = manifest.get(itemref.get("idref"))
|
||||
if itemref.get("linear") == "no" or item is None:
|
||||
continue
|
||||
if "nav" in (item.get("properties") or "").split():
|
||||
continue
|
||||
href = item.get("href") or ""
|
||||
if not href.lower().endswith((".xhtml", ".html", ".htm")):
|
||||
continue
|
||||
full = _resolve(folder, href)
|
||||
soup = BeautifulSoup(z.read(full), "html5lib")
|
||||
if not soup.body or _only_links(soup):
|
||||
continue
|
||||
heading = soup.find(["h1", "h2", "h3"])
|
||||
if full in toc:
|
||||
title = toc[full]
|
||||
elif heading and heading.get_text(strip=True):
|
||||
title = heading.get_text(" ", strip=True)
|
||||
elif soup.title and soup.title.get_text(strip=True):
|
||||
title = soup.title.get_text(strip=True)
|
||||
else:
|
||||
title = href
|
||||
images = _take_images(z, soup, posixpath.dirname(full), book)
|
||||
html = "".join(str(c) for c in soup.body.contents).strip()
|
||||
if html:
|
||||
chapters.append({"title": title, "html": html, "key": f"{book}-{full}", "images": images})
|
||||
except (zipfile.BadZipFile, KeyError, ElementTree.ParseError) as e:
|
||||
raise ScriptError(f"{path.name} is not a readable ePub: {e}") from e
|
||||
if not chapters:
|
||||
raise ScriptError(f"{path.name} has no chapters that the script can read")
|
||||
return chapters
|
||||
|
||||
|
||||
def suggest_matches(title: str, candidates: list[str], limit: int = 3) -> list[int]:
|
||||
"""Indexes of the candidates most like `title`, best first."""
|
||||
ratio = {i: SequenceMatcher(None, title.lower(), c.lower()).ratio() for i, c in enumerate(candidates)}
|
||||
return sorted((i for i in ratio if ratio[i] > 0.4), key=lambda i: ratio[i], reverse=True)[:limit]
|
||||
|
||||
|
||||
def chapter_url(story_url: str, chapter: dict) -> str:
|
||||
"""A made-up, stable URL for a chapter that has no link. The same ePub chapter always gets the same URL."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", chapter["key"].lower()).strip("-")
|
||||
return f"{story_url.rstrip('/')}{URL_MARK}{slug}"
|
||||
|
||||
|
||||
def splice_order(existing_urls: list[str], before_idx: int, new_urls: list[str]) -> list[str]:
|
||||
"""`existing_urls` with `new_urls` put directly after position `before_idx` (-1 for the start)."""
|
||||
return existing_urls[: before_idx + 1] + new_urls + existing_urls[before_idx + 1 :]
|
||||
|
||||
|
||||
def _show(items: list[str], marks: set[int] = frozenset(), around: int | None = None) -> None:
|
||||
"""Print a numbered list. With `around`, a long list shows only the part near that index."""
|
||||
first, last = 0, len(items)
|
||||
if around is not None and len(items) > WINDOW * 2:
|
||||
first = max(0, min(around - WINDOW // 2, len(items) - WINDOW))
|
||||
last = first + WINDOW
|
||||
# The numbers go in brackets, padded so that the titles line up. A suggestion has a * in front.
|
||||
width = len(str(len(items))) + 2
|
||||
if first:
|
||||
print(" …")
|
||||
for i in range(first, last):
|
||||
print(f"{'*' if i in marks else ' '} {f'[{i + 1}]':<{width}} {items[i]}")
|
||||
if last < len(items):
|
||||
print(f" … (type 'list' to see all {len(items)})")
|
||||
|
||||
|
||||
def _ask(prompt: str, default: str) -> str:
|
||||
"""Read a line. The default is already on the input line, where the terminal allows it."""
|
||||
prefill = bool(default) and readline is not None and sys.stdin.isatty()
|
||||
if prefill:
|
||||
readline.set_startup_hook(lambda: readline.insert_text(default))
|
||||
elif default:
|
||||
prompt = f"{prompt[:-2]} [{default}]: "
|
||||
try:
|
||||
answer = input(prompt).strip()
|
||||
finally:
|
||||
if prefill:
|
||||
readline.set_startup_hook()
|
||||
return answer or default
|
||||
|
||||
|
||||
def choose(
|
||||
prompt: str,
|
||||
items: list[str],
|
||||
*,
|
||||
word: str | None = None,
|
||||
default: int | None = None,
|
||||
marks: set[int] = frozenset(),
|
||||
around: int | None = None,
|
||||
minimum: int = 0,
|
||||
) -> int | str:
|
||||
"""Show `items` and ask for one of them. Returns its index, or `word` if the user typed it.
|
||||
|
||||
The prompt says what the question is, so the user does not have to look above a long list for it.
|
||||
"""
|
||||
_show(items, marks, around)
|
||||
words = ["'list'"] + ([f"'{word}'"] if word else [])
|
||||
while True:
|
||||
answer = _ask(f"{prompt}: ", "" if default is None else str(default + 1)).lower()
|
||||
if answer == "list":
|
||||
_show(items, marks)
|
||||
elif word and answer == word:
|
||||
return word
|
||||
elif answer.isdigit() and minimum < int(answer) <= len(items):
|
||||
return int(answer) - 1
|
||||
else:
|
||||
print(f"Type a number from {minimum + 1} to {len(items)}, or {' or '.join(words)}.")
|
||||
|
||||
|
||||
def _confirm(prompt: str) -> bool:
|
||||
return input(f"{prompt} [y/N] ").strip().lower() == "y"
|
||||
|
||||
|
||||
def _added_by_script(url: str) -> bool:
|
||||
return URL_MARK in url
|
||||
|
||||
|
||||
def remove_added_chapters(db_store: Store, story: dict, existing: list[dict]) -> None:
|
||||
added = [c for c in existing if _added_by_script(c["url"])]
|
||||
if not added:
|
||||
print(f"{story['title']} has no chapters that this script added.")
|
||||
return
|
||||
print(f"{story['title']} has {_count(len(added), 'chapter')} that this script added:")
|
||||
for c in added:
|
||||
print(f" - {fff.plain_title(c['title'])}")
|
||||
if not _confirm("Delete them and all their revisions?"):
|
||||
return
|
||||
conn = sqlite3.connect(config.DB_PATH)
|
||||
try:
|
||||
# Without this, SQLite keeps the revisions of a deleted chapter.
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
with conn:
|
||||
conn.executemany("DELETE FROM chapters WHERE id = ?", [(c["id"],) for c in added])
|
||||
finally:
|
||||
conn.close()
|
||||
print(f"Deleted {_count(len(added), 'chapter')}. Rebuild {story['title']} in Bindery.")
|
||||
|
||||
|
||||
def _count(n: int, word: str) -> str:
|
||||
return f"{n} {word}{'' if n == 1 else 's'}"
|
||||
|
||||
|
||||
def ask_for_gap(
|
||||
story_title: str, titles: list[str], labels: list[str], epub_name: str, epub_titles: list[str]
|
||||
) -> tuple[int, int, int]:
|
||||
"""Ask where the gap is. Returns the index of the stored chapter before the gap (-1 for the start),
|
||||
and the indexes of the first and last ePub chapters to add.
|
||||
|
||||
`titles` are the stored chapter titles, and `labels` are the same titles as the lists show them.
|
||||
"""
|
||||
print(f"\n{story_title} has {_count(len(titles), 'stored chapter')}:")
|
||||
before = choose("Stored chapter just BEFORE the gap (or 'start')", labels, word="start")
|
||||
print(f"\n{epub_name} has {_count(len(epub_titles), 'chapter')}:")
|
||||
if before == "start":
|
||||
before = -1
|
||||
first = choose("FIRST ePub chapter to add", epub_titles)
|
||||
else:
|
||||
guess = suggest_matches(titles[before], epub_titles)
|
||||
match = choose(
|
||||
f'ePub chapter that matches "{titles[before]}"',
|
||||
epub_titles,
|
||||
default=guess[0] if guess else None,
|
||||
marks=set(guess),
|
||||
)
|
||||
first = match + 1
|
||||
|
||||
print(f"\n{story_title}, near the gap:")
|
||||
if before + 1 < len(titles):
|
||||
after = choose(
|
||||
"Stored chapter just AFTER the gap (or 'end')",
|
||||
labels,
|
||||
word="end",
|
||||
default=before + 1,
|
||||
around=before + 1,
|
||||
minimum=before + 1,
|
||||
)
|
||||
else:
|
||||
print(f' None. The gap is after the last stored chapter, "{titles[before]}".')
|
||||
after = "end"
|
||||
print(f"\n{epub_name}, near the gap:")
|
||||
if after == "end":
|
||||
last = choose(
|
||||
"LAST ePub chapter to add", epub_titles, default=len(epub_titles) - 1, around=first, minimum=first
|
||||
)
|
||||
else:
|
||||
if after != before + 1:
|
||||
skipped = _count(after - before - 1, "stored chapter")
|
||||
print(f"Note: the {skipped} between those two stay where they are, after the added chapters.")
|
||||
later = epub_titles[first:]
|
||||
guess = [first + i for i in suggest_matches(titles[after], later)]
|
||||
match = choose(
|
||||
f'ePub chapter that matches "{titles[after]}"',
|
||||
epub_titles,
|
||||
default=guess[0] if guess else None,
|
||||
marks=set(guess),
|
||||
around=guess[0] if guess else first,
|
||||
minimum=first,
|
||||
)
|
||||
last = match - 1
|
||||
return before, first, last
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("story_url", help="The URL of the tracked story")
|
||||
parser.add_argument("epub", type=Path, nargs="?", help="The ePub that has the missing chapters")
|
||||
parser.add_argument("--remove", action="store_true", help="Delete the chapters that this script added")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
normal = fff.normalize_url(args.story_url)
|
||||
if not normal:
|
||||
raise ScriptError(f"Not a supported story URL: {args.story_url}")
|
||||
if not args.remove and (args.epub is None or not args.epub.is_file()):
|
||||
raise ScriptError(f"No such file: {args.epub}" if args.epub else "Give the path to the ePub.")
|
||||
|
||||
db_store = Store(config.DB_PATH)
|
||||
story = db_store.get_story_by_url(normal[0])
|
||||
if not story:
|
||||
raise ScriptError(f"No tracked story at {normal[0]}. Add it in Bindery first.")
|
||||
|
||||
existing = db_store.list_chapters(story["id"])
|
||||
if args.remove:
|
||||
remove_added_chapters(db_store, story, existing)
|
||||
return
|
||||
if not existing:
|
||||
raise ScriptError(f"{story['title']} has no stored chapters yet. Download it in Bindery first.")
|
||||
|
||||
epub_chapters = read_epub(args.epub)
|
||||
titles = [fff.plain_title(c["title"]) for c in existing]
|
||||
labels = [t + ("" if c["on_site"] else " (not on site)") for t, c in zip(titles, existing, strict=True)]
|
||||
epub_titles = [c["title"] for c in epub_chapters]
|
||||
|
||||
before, first, last = ask_for_gap(story["title"], titles, labels, args.epub.name, epub_titles)
|
||||
stored_urls = {c["url"] for c in existing}
|
||||
to_add = [c for c in epub_chapters[first : last + 1] if chapter_url(story["url"], c) not in stored_urls]
|
||||
already = last + 1 - first - len(to_add)
|
||||
if already:
|
||||
print(f"\n{_count(already, 'ePub chapter')} of the gap are in the story already, from an earlier run.")
|
||||
if not to_add:
|
||||
print("There are no chapters to add.")
|
||||
return
|
||||
|
||||
where = "at the start" if before == -1 else f'after "{titles[before]}"'
|
||||
print(f"\nThe script will add {'this chapter' if len(to_add) == 1 else f'these {len(to_add)} chapters'} {where}:")
|
||||
for c in to_add:
|
||||
print(f" - {c['title']}")
|
||||
if not _confirm("Add it?" if len(to_add) == 1 else "Add them?"):
|
||||
return
|
||||
|
||||
new_chapters = [
|
||||
{
|
||||
"url": chapter_url(story["url"], c),
|
||||
"title": fff.escape_title(c["title"]),
|
||||
"html": c["html"],
|
||||
"text_hash": text_hash(c["html"]),
|
||||
}
|
||||
for c in to_add
|
||||
]
|
||||
for img in {img["url"]: img for c in to_add for img in c["images"]}.values():
|
||||
db_store.put_image(story["id"], img["url"], img["ext"], img["mime"], img["data"])
|
||||
order = splice_order([c["url"] for c in existing], before, [c["url"] for c in new_chapters])
|
||||
for chapter_id in db_store.add_chapters(story["id"], new_chapters, SOURCE, order):
|
||||
# The site does not list these chapters. The next download would set this too.
|
||||
db_store.update_chapter(chapter_id, on_site=0)
|
||||
print(f"\nAdded {_count(len(new_chapters), 'chapter')} to {story['title']}. Rebuild the book in Bindery.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except ScriptError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nStopped. Nothing changed.")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clean up smart punctuation in chapter revisions: curly quotes, curly apostrophes and the ellipsis.
|
||||
|
||||
Usage:
|
||||
|
||||
python3 scripts/remove_smart_quote_revisions.py /path/to/config/bindery.db
|
||||
python3 scripts/remove_smart_quote_revisions.py /path/to/config/bindery.db --apply
|
||||
|
||||
The first command only lists what it would do. With --apply, the script copies
|
||||
the database to a backup file next to it, and then changes the revisions. Stop
|
||||
Bindery before you use --apply.
|
||||
|
||||
The script compares the text of the revisions of each chapter, as Bindery does,
|
||||
so differences in the markup do not count. Smart punctuation is ‘ ’ “ ” and …,
|
||||
and its straight form is ' " and ...
|
||||
|
||||
- If two or more revisions of a chapter differ only by smart punctuation, the
|
||||
revision with the least smart punctuation stays, and the others go. If a
|
||||
deleted revision was in the book, the revision that stays goes in the book.
|
||||
- If a revision has text that no other revision of its chapter has, the script
|
||||
keeps it, and changes its smart punctuation to straight. Only the text
|
||||
changes, not the tags or comments. The script stores the new text hash, so
|
||||
Bindery compares the next site text with the new text.
|
||||
|
||||
The script does not delete or change the newest revision of a chapter if it came
|
||||
from the site. Each recheck compares the site text with the newest revision, so
|
||||
the next recheck would add the site text again.
|
||||
|
||||
Rebuild the stories that the script names, because the text in their books
|
||||
changed. The script uses only the Python standard library.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from html import unescape
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
SMART = {"‘": "'", "’": "'", "“": '"', "”": '"', "…": "..."}
|
||||
STRAIGHT = str.maketrans(SMART)
|
||||
ENTITY = re.compile(r"&(?:#\d+|#[xX][0-9a-fA-F]+|[a-zA-Z]+);")
|
||||
MARKUP = re.compile(r"(<!--.*?-->|<[^>]*>)", re.S)
|
||||
IMPORT_SOURCES = {"import", "epub-import"}
|
||||
# The same tags as BLOCK_TAGS in bindery/diffing.py.
|
||||
BLOCK_TAGS = {
|
||||
"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre",
|
||||
"tr", "table", "hr", "section", "article", "ul", "ol", "center",
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
class _TextParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts: list[str] = []
|
||||
self.hidden = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
if tag in ("script", "style"):
|
||||
self.hidden += 1
|
||||
elif tag == "br" or tag in BLOCK_TAGS:
|
||||
self.parts.append("\n")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in ("script", "style"):
|
||||
self.hidden = max(0, self.hidden - 1)
|
||||
elif tag in BLOCK_TAGS:
|
||||
self.parts.append("\n")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not self.hidden:
|
||||
self.parts.append(data)
|
||||
|
||||
|
||||
def text_blocks(markup: str) -> list[str]:
|
||||
"""The text of a chapter, one string per paragraph, as html_to_blocks in bindery/diffing.py makes it."""
|
||||
parser = _TextParser()
|
||||
parser.feed(markup or "")
|
||||
parser.close()
|
||||
lines = (re.sub(r"\s+", " ", line).strip() for line in "".join(parser.parts).split("\n"))
|
||||
return [line for line in lines if line]
|
||||
|
||||
|
||||
def text_hash(markup: str) -> str:
|
||||
"""The hash of the text of a chapter, as text_hash in bindery/diffing.py makes it."""
|
||||
return hashlib.sha256("\n".join(text_blocks(markup)).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def smart_count(text: str) -> int:
|
||||
return sum(text.count(c) for c in SMART)
|
||||
|
||||
|
||||
def straighten_html(markup: str) -> str:
|
||||
"""Change the smart punctuation in the text of a chapter to straight, also where the HTML has it as an entity.
|
||||
|
||||
Tags and comments do not change, so a straight quote cannot end an attribute value.
|
||||
"""
|
||||
|
||||
def entity(match: re.Match) -> str:
|
||||
return SMART.get(unescape(match.group(0)), match.group(0))
|
||||
|
||||
# With a group in the pattern, split() puts the tags and comments at the odd places.
|
||||
parts = MARKUP.split(markup)
|
||||
for i in range(0, len(parts), 2):
|
||||
parts[i] = ENTITY.sub(entity, parts[i]).translate(STRAIGHT)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def plan(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""For each chapter with revisions to change: what goes, what stays, what gets straight, and what is in the book."""
|
||||
chapters = conn.execute(
|
||||
"""
|
||||
SELECT c.id, c.title, c.current_rev, s.id AS story_id, s.title AS story_title
|
||||
FROM chapters c JOIN stories s ON s.id = c.story_id
|
||||
WHERE (SELECT COUNT(*) FROM revisions r WHERE r.chapter_id = c.id) > 1
|
||||
ORDER BY s.title COLLATE NOCASE, c.position"""
|
||||
).fetchall()
|
||||
actions = []
|
||||
for chapter in chapters:
|
||||
revisions = conn.execute(
|
||||
"SELECT rev, source, html FROM revisions WHERE chapter_id = ? ORDER BY rev", (chapter["id"],)
|
||||
).fetchall()
|
||||
newest = revisions[-1]["rev"]
|
||||
sources = {r["rev"]: r["source"] for r in revisions}
|
||||
markup = {r["rev"]: r["html"] for r in revisions}
|
||||
|
||||
def from_site(rev: int, newest: int = newest, sources: dict = sources) -> bool:
|
||||
return rev == newest and sources[rev] not in IMPORT_SOURCES
|
||||
|
||||
groups = defaultdict(list)
|
||||
smart = {}
|
||||
for r in revisions:
|
||||
text = "\n".join(text_blocks(r["html"]))
|
||||
smart[r["rev"]] = smart_count(text)
|
||||
groups[text.translate(STRAIGHT)].append(r["rev"])
|
||||
delete, keep, skipped = [], [], []
|
||||
for group in groups.values():
|
||||
least = min(smart[rev] for rev in group)
|
||||
if all(smart[rev] == least for rev in group):
|
||||
continue
|
||||
keep += [rev for rev in group if smart[rev] == least]
|
||||
for rev in group:
|
||||
if smart[rev] > least:
|
||||
(skipped if from_site(rev) else delete).append(rev)
|
||||
straighten = []
|
||||
if len(groups) > 1:
|
||||
for group in groups.values():
|
||||
left = [rev for rev in group if rev not in delete]
|
||||
if len(left) == 1 and smart[left[0]] and left[0] not in skipped:
|
||||
(skipped if from_site(left[0]) else straighten).append(left[0])
|
||||
if not delete and not straighten and not skipped:
|
||||
continue
|
||||
current = chapter["current_rev"]
|
||||
if current in delete:
|
||||
# The revision that stays in the group of the deleted one.
|
||||
same = next(g for g in groups.values() if current in g)
|
||||
current = max(rev for rev in same if smart[rev] == min(smart[x] for x in same))
|
||||
straight = {rev: straighten_html(markup[rev]) for rev in straighten}
|
||||
actions.append(
|
||||
{
|
||||
"chapter_id": chapter["id"],
|
||||
"chapter": unescape(chapter["title"]),
|
||||
"story": chapter["story_title"],
|
||||
"delete": sorted(delete),
|
||||
"keep": sorted(keep),
|
||||
"straighten": sorted(straighten),
|
||||
"straight": {rev: (html, text_hash(html)) for rev, html in straight.items()},
|
||||
"skipped": sorted(skipped),
|
||||
"sources": sources,
|
||||
"old_current": chapter["current_rev"],
|
||||
"new_current": current,
|
||||
}
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
def book_changed(action: dict) -> bool:
|
||||
return action["new_current"] != action["old_current"] or action["new_current"] in action["straighten"]
|
||||
|
||||
|
||||
def apply(conn: sqlite3.Connection, actions: list[dict]) -> None:
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
with conn:
|
||||
for a in actions:
|
||||
if a["new_current"] != a["old_current"]:
|
||||
conn.execute(
|
||||
"UPDATE chapters SET current_rev = ?, changed_at = ? WHERE id = ?",
|
||||
(a["new_current"], now, a["chapter_id"]),
|
||||
)
|
||||
conn.executemany(
|
||||
"UPDATE revisions SET html = ?, text_hash = ? WHERE chapter_id = ? AND rev = ?",
|
||||
[(html, digest, a["chapter_id"], rev) for rev, (html, digest) in a["straight"].items()],
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM revisions WHERE chapter_id = ? AND rev = ?",
|
||||
[(a["chapter_id"], rev) for rev in a["delete"]],
|
||||
)
|
||||
|
||||
|
||||
def backup(conn: sqlite3.Connection, db: Path) -> Path:
|
||||
path = db.with_name(f"{db.stem}.before-quote-cleanup-{datetime.now():%Y%m%d-%H%M%S}{db.suffix}")
|
||||
target = sqlite3.connect(path)
|
||||
try:
|
||||
conn.backup(target)
|
||||
finally:
|
||||
target.close()
|
||||
return path
|
||||
|
||||
|
||||
def _revs(revs: list[int], sources: dict[int, str]) -> str:
|
||||
return ", ".join(f"{rev} ({sources[rev]})" for rev in revs)
|
||||
|
||||
|
||||
def report(actions: list[dict]) -> None:
|
||||
for a in actions:
|
||||
print(f"{a['story']} / {a['chapter']}")
|
||||
if a["delete"]:
|
||||
print(f" delete revision {_revs(a['delete'], a['sources'])}, keep {_revs(a['keep'], a['sources'])}")
|
||||
if a["new_current"] != a["old_current"]:
|
||||
print(f" revision {a['new_current']} goes in the book instead of revision {a['old_current']}")
|
||||
if a["straighten"]:
|
||||
print(f" make the punctuation straight in revision {_revs(a['straighten'], a['sources'])}")
|
||||
if a["skipped"]:
|
||||
print(
|
||||
f" not changed: revision {_revs(a['skipped'], a['sources'])} is the newest text from the site, "
|
||||
"so the next recheck would add it again"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("db", type=Path, help="Path to bindery.db")
|
||||
parser.add_argument("--apply", action="store_true", help="Back up the database, then change the revisions")
|
||||
args = parser.parse_args(argv)
|
||||
if not args.db.is_file():
|
||||
print(f"Error: no such file: {args.db}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
conn = sqlite3.connect(args.db, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
actions = plan(conn)
|
||||
report(actions)
|
||||
deletions = sum(len(a["delete"]) for a in actions)
|
||||
straightened = sum(len(a["straighten"]) for a in actions)
|
||||
if not deletions and not straightened:
|
||||
print("No revisions to change.")
|
||||
return 0
|
||||
print(f"\n{deletions} revisions to delete, and {straightened} revisions to make straight.")
|
||||
if not args.apply:
|
||||
print("Nothing changed. Run again with --apply to change them.")
|
||||
return 0
|
||||
print(f"Backup: {backup(conn, args.db)}")
|
||||
apply(conn, actions)
|
||||
print(f"Deleted {deletions} revisions, and made {straightened} revisions straight.")
|
||||
rebuild = sorted({a["story"] for a in actions if book_changed(a)})
|
||||
if rebuild:
|
||||
print("The text in the book changed for these stories. Rebuild them:")
|
||||
for title in rebuild:
|
||||
print(f" - {title}")
|
||||
finally:
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -30,6 +30,8 @@ export class Element {
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
addEventListener(name, fn) { this.listeners[name] = fn; }
|
||||
focus() { document.activeElement = this; }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
remove() { if (this.parent) this.parent.children = this.parent.children.filter((child) => child !== this); }
|
||||
get firstElementChild() { return this.children.find((child) => child instanceof Element); }
|
||||
get textContent() { return this.children.map((child) => child instanceof Element ? child.textContent : String(child)).join(''); }
|
||||
|
||||
+70
-5
@@ -275,13 +275,13 @@ test('the chapter table stacks on a narrow screen instead of scrolling sideways'
|
||||
await mountStory();
|
||||
const wrap = nodes(main).find((el) => el.className === 'table-wrap chapters-table');
|
||||
assert.ok(wrap);
|
||||
const headings = nodes(wrap).filter((el) => el.tagName === 'th').map((el) => el.textContent);
|
||||
assert.deepEqual(headings, ['', '#', 'Title', 'State', 'Revisions', 'Added']);
|
||||
const headings = nodes(wrap).filter((el) => el.tagName === 'th');
|
||||
assert.deepEqual(headings.map((el) => el.textContent.trim()), ['Select all', '#', 'Title', 'State', 'Revisions', 'Added']);
|
||||
const dataLabels = nodes(wrap).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']);
|
||||
const perRow = [undefined, '#', undefined, 'State', 'Revisions', 'Added'];
|
||||
assert.deepEqual(dataLabels, [...perRow, ...perRow]);
|
||||
// "Select all" moved above the table, so a hidden table header on mobile does not hide it.
|
||||
assert.ok(nodes(main).find((el) => el.tagName === 'label' && el.textContent.trim() === 'Select all'));
|
||||
// The Select all checkbox is in the first heading. On a phone, it is the only heading that shows.
|
||||
assert.ok(nodes(headings[0]).includes(field(main, 'Select all chapters')));
|
||||
});
|
||||
|
||||
test('story list stacks on a narrow screen instead of scrolling sideways', async () => {
|
||||
@@ -289,7 +289,7 @@ test('story list stacks on a narrow screen instead of scrolling sideways', async
|
||||
const wrap = nodes(main).find((el) => el.className === 'table-wrap stories-table');
|
||||
assert.ok(wrap);
|
||||
const dataLabels = nodes(wrap).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']);
|
||||
assert.deepEqual(dataLabels, [undefined, 'Author', 'Site', 'Chapters', 'Status', 'Last check']);
|
||||
assert.deepEqual(dataLabels, [undefined, 'Author', 'Site', 'Chapters', 'Status', 'Last change', 'Last check']);
|
||||
});
|
||||
|
||||
test('the story list shows removed and revised chapter counts, only when there are any', async () => {
|
||||
@@ -348,6 +348,47 @@ test('chapter inclusion changes reload the story after queuing the rebuild', asy
|
||||
assert.equal(api.sent('GET', 'stories/1').length, 3);
|
||||
});
|
||||
|
||||
test('shift-click selects a range of chapters, and Move sends them with the chapter to go after', async () => {
|
||||
details.chapters.push({ id: 4, title: 'Three', url: 'chapter-3', included: true, on_site: true, current_rev: 1, revisions: 1 });
|
||||
await mountStory();
|
||||
api.on('POST', 'stories/1/chapters/move', {});
|
||||
const dialog = nodes(main).find((el) => el.tagName === 'dialog');
|
||||
const texts = (tag) => nodes(dialog).filter((el) => el.tagName === tag).map((el) => el.textContent);
|
||||
await click(main, 'Move…');
|
||||
assert.ok(!dialog.open);
|
||||
const box = (title) => field(main, `Select ${title}`);
|
||||
const shiftClick = (el, on) => {
|
||||
el.listeners.click({ shiftKey: true, target: el });
|
||||
return change(el, on);
|
||||
};
|
||||
change(box('One'), true);
|
||||
shiftClick(box('Three'), true);
|
||||
assert.equal(box('Two').checked, true);
|
||||
// The dialog names the chapters, and they are not in the list of chapters to go after.
|
||||
await click(main, 'Move…');
|
||||
assert.ok(dialog.open);
|
||||
assert.deepEqual(texts('h3'), ['Move 3 chapters']);
|
||||
assert.deepEqual(texts('li'), ['One', 'Two', 'Three']);
|
||||
assert.deepEqual(texts('option'), ['The start']);
|
||||
await click(dialog, 'Cancel');
|
||||
assert.ok(!dialog.open);
|
||||
assert.equal(writes().length, 0);
|
||||
await click(main, 'Move…');
|
||||
await click(dialog, 'Move');
|
||||
assert.deepEqual(api.sent('POST', 'stories/1/chapters/move').at(-1).body, { chapter_ids: [2, 3, 4], after: null });
|
||||
assert.ok(!dialog.open);
|
||||
shiftClick(box('One'), false);
|
||||
change(box('Two'), true);
|
||||
await click(main, 'Move…');
|
||||
assert.deepEqual(texts('option'), ['The start', 'One', 'Three']);
|
||||
field(dialog, 'move-after').value = '4';
|
||||
await click(dialog, 'Move');
|
||||
assert.deepEqual(api.sent('POST', 'stories/1/chapters/move').at(-1).body, { chapter_ids: [3], after: 4 });
|
||||
// The next dialog starts with the last choice.
|
||||
await click(main, 'Move…');
|
||||
assert.equal(field(dialog, 'move-after').value, '4');
|
||||
});
|
||||
|
||||
test('unsaved metadata and update settings survive job completion, then save typed values', async () => {
|
||||
await mountStory();
|
||||
startPolling();
|
||||
@@ -449,6 +490,30 @@ test('library search trims the query and links by UUID', async () => {
|
||||
assert.equal(api.sent('GET', 'stories/1').length, 2);
|
||||
});
|
||||
|
||||
test('deleting chapters asks in a dialog and warns about chapters that are on the site', async () => {
|
||||
await mountStory();
|
||||
api.on('POST', 'stories/1/chapters/delete', {});
|
||||
const dialog = nodes(main).find((el) => el.tagName === 'dialog');
|
||||
const text = (tag) => nodes(dialog).find((el) => el.tagName === tag).textContent;
|
||||
await click(main, 'Delete…');
|
||||
assert.ok(!dialog.open);
|
||||
change(field(main, 'Select Two'), true);
|
||||
await click(main, 'Delete…');
|
||||
assert.ok(dialog.open);
|
||||
assert.equal(text('h3'), 'Delete 1 chapter');
|
||||
assert.equal(text('p'), 'Bindery deletes this chapter and its revisions. You cannot undo this.');
|
||||
await click(dialog, 'Cancel');
|
||||
change(field(main, 'Select One'), true);
|
||||
await click(main, 'Delete…');
|
||||
assert.equal(text('h3'), 'Delete 2 chapters');
|
||||
assert.match(text('p'), /^Bindery deletes these chapters and their revisions\..*comes back with the next download/);
|
||||
assert.equal(writes().length, 0);
|
||||
await click(dialog, 'Delete');
|
||||
assert.deepEqual(api.sent('POST', 'stories/1/chapters/delete').at(-1).body, { chapter_ids: [2, 3] });
|
||||
assert.ok(!dialog.open);
|
||||
assert.equal(api.sent('GET', 'stories/1').length, 2);
|
||||
});
|
||||
|
||||
test('sending as a new book and stopping tracking require confirmation', async () => {
|
||||
details.story.ingested_at = '2026-01-01';
|
||||
await mountStory();
|
||||
|
||||
@@ -87,6 +87,49 @@ test('the Add stories textarea uses the smaller text size without shrinking othe
|
||||
assert.equal(declarations('textarea')['font-size'], undefined);
|
||||
});
|
||||
|
||||
test('story timestamps fit their text and chapter details use plain small text', () => {
|
||||
const desktop = css.slice(css.indexOf('@media (min-width: 601px)'));
|
||||
const date = declarations('.date');
|
||||
assert.equal(date['white-space'], 'nowrap');
|
||||
assert.equal(date['min-inline-size'], undefined);
|
||||
const storyDate = declarations('.stories-table .date', desktop);
|
||||
assert.equal(storyDate['inline-size'], '1%');
|
||||
assert.equal(storyDate['min-inline-size'], undefined);
|
||||
const details = declarations(".stories-table td[data-label='Chapters'] small");
|
||||
assert.equal(details.display, 'block');
|
||||
assert.equal(details['white-space'], 'nowrap');
|
||||
assert.equal(details.background, undefined);
|
||||
assert.equal(details.border, undefined);
|
||||
assert.equal(details.padding, undefined);
|
||||
});
|
||||
|
||||
test('sortable headings show direction and remain usable above story cards on narrow screens', () => {
|
||||
assert.equal(declarations('.stories-table th button').background, 'none');
|
||||
assert.equal(declarations('.stories-table th[aria-sort] button::after').content, "'↕'");
|
||||
assert.equal(declarations(".stories-table th[aria-sort='ascending'] button::after").content, "'↑'");
|
||||
assert.equal(declarations(".stories-table th[aria-sort='descending'] button::after").content, "'↓'");
|
||||
const mobile = css.slice(css.indexOf('@media (max-width: 600px)'));
|
||||
assert.equal(declarations('.stories-table thead tr', mobile).display, 'flex');
|
||||
assert.notEqual(declarations('.stories-table th', mobile).display, 'none');
|
||||
assert.doesNotMatch(css, /\.stories-table thead\s*\{[^}]*clip-path/);
|
||||
});
|
||||
|
||||
test('sortable headings fill the column with one clickable highlight without shifting layout', () => {
|
||||
assert.equal(declarations('.stories-table th').padding, '0');
|
||||
const button = declarations('.stories-table th button');
|
||||
assert.equal(button['inline-size'], '100%');
|
||||
assert.equal(button['justify-content'], 'flex-start');
|
||||
assert.equal(button.padding, '6px 8px');
|
||||
assert.equal(button.margin, undefined);
|
||||
const highlight = declarations('.stories-table th button:is(:hover, :focus-visible)');
|
||||
assert.equal(highlight.background, 'var(--info-bg)');
|
||||
assert.equal(highlight.color, 'var(--info)');
|
||||
assert.equal(highlight['text-decoration'], undefined);
|
||||
assert.equal(highlight.padding, undefined);
|
||||
assert.equal(highlight.border, undefined);
|
||||
assert.doesNotMatch(css, /\.stories-table th button:hover\s*\{[^}]*underline/);
|
||||
});
|
||||
|
||||
function luminance(hex) {
|
||||
const channels = hex.slice(1).match(/../g).map((channel) => {
|
||||
const value = parseInt(channel, 16) / 255;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { beforeEach, test } from 'node:test';
|
||||
import { click, field, find, input, install, nodes, server, settle } from './flow-helpers.mjs';
|
||||
|
||||
const { app, toasts } = install();
|
||||
const SORT_KEY = 'bindery.story-sort';
|
||||
beforeEach(() => {
|
||||
const values = new Map();
|
||||
window.localStorage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
toasts.replaceChildren();
|
||||
});
|
||||
const { storiesPage } = await import('../../bindery/static/js/stories.js');
|
||||
const { refreshJobs, startPolling, stopPolling } = await import('../../bindery/static/js/jobs.js');
|
||||
const { fmtDate } = await import('../../bindery/static/js/lib.js');
|
||||
|
||||
const story = (id, title, changes = {}) => ({
|
||||
id, title, url: `https://example.com/${id}`, author: 'Reader', site: 'example.com',
|
||||
chapter_count: 42, offsite_count: 0, revised_count: 0, status: 'ok', message: 'No changes',
|
||||
last_changed: null, last_checked: null, ...changes,
|
||||
});
|
||||
const rows = () => {
|
||||
const table = nodes(app).find((el) => el.className === 'table-wrap stories-table');
|
||||
return nodes(table).find((el) => el.tagName === 'tbody').children;
|
||||
};
|
||||
const titles = () => rows().map((row) => row.children[0].textContent);
|
||||
const cell = (row, label) => row.children.find((el) => el.attributes['data-label'] === label);
|
||||
const heading = (label) => nodes(app).find((el) => el.tagName === 'th' && el.textContent === label);
|
||||
|
||||
async function mount(t, list) {
|
||||
const backend = server(t);
|
||||
backend.on('GET', 'stories', list);
|
||||
backend.on('GET', 'jobs', []);
|
||||
const cleanup = storiesPage(app, {}, { library_available: false, main_jobs_hours: 24, main_jobs_count: 10 });
|
||||
t.after(() => { cleanup(); stopPolling(); });
|
||||
await settle();
|
||||
return backend;
|
||||
}
|
||||
|
||||
test('column headings toggle sorting, with newest dates first and missing dates always last', async (t) => {
|
||||
const backend = await mount(t, [
|
||||
story(3, 'Gamma', { last_changed: '2026-01-01T12:00:00Z', last_checked: '2026-01-03T12:00:00Z' }),
|
||||
story(2, 'Beta', { last_changed: '2026-01-02T12:00:00Z', last_checked: '2026-01-02T12:00:00Z' }),
|
||||
story(4, 'No date'),
|
||||
story(1, 'Alpha', { last_changed: '2026-01-03T12:00:00Z', last_checked: '2026-01-01T12:00:00Z' }),
|
||||
]);
|
||||
assert.equal(field(app, 'Sort stories by'), undefined);
|
||||
assert.equal(heading('Title').attributes['aria-sort'], 'ascending');
|
||||
assert.equal(heading('Last change').attributes['aria-sort'], 'none');
|
||||
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']);
|
||||
assert.equal(find(app, 'button', 'Last change').attributes['aria-label'], 'Sort by last change, newest first');
|
||||
await click(app, 'Last change');
|
||||
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']);
|
||||
assert.equal(heading('Title').attributes['aria-sort'], 'none');
|
||||
assert.equal(heading('Last change').attributes['aria-sort'], 'descending');
|
||||
assert.equal(document.activeElement, find(app, 'button', 'Last change'));
|
||||
assert.equal(find(app, 'button', 'Last change').attributes['aria-label'], 'Sort by last change, oldest first');
|
||||
await click(app, 'Last change');
|
||||
assert.deepEqual(titles(), ['Gamma', 'Beta', 'Alpha', 'No date']);
|
||||
assert.equal(heading('Last change').attributes['aria-sort'], 'ascending');
|
||||
await click(app, 'Last check');
|
||||
assert.deepEqual(titles(), ['Gamma', 'Beta', 'Alpha', 'No date']);
|
||||
assert.equal(heading('Last check').attributes['aria-sort'], 'descending');
|
||||
await click(app, 'Last check');
|
||||
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']);
|
||||
assert.equal(heading('Last check').attributes['aria-sort'], 'ascending');
|
||||
await click(app, 'Title');
|
||||
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']);
|
||||
await click(app, 'Title');
|
||||
assert.deepEqual(titles(), ['No date', 'Gamma', 'Beta', 'Alpha']);
|
||||
assert.equal(heading('Title').attributes['aria-sort'], 'descending');
|
||||
await click(app, 'Last change');
|
||||
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma', 'No date']);
|
||||
assert.equal(backend.sent('GET', 'stories').length, 1);
|
||||
});
|
||||
|
||||
test('title sorting and tie breaks keep ignoring leading articles', async (t) => {
|
||||
await mount(t, ['The Zebra', 'an Owl', 'Anthem', 'A Bee', 'Theory', 'Cat'].map((title, i) => story(i + 1, title)));
|
||||
const order = ['Anthem', 'A Bee', 'Cat', 'an Owl', 'Theory', 'The Zebra'];
|
||||
assert.deepEqual(titles(), order);
|
||||
await click(app, 'Title');
|
||||
assert.deepEqual(titles(), [...order].reverse());
|
||||
await click(app, 'Chapters');
|
||||
assert.deepEqual(titles(), order);
|
||||
});
|
||||
|
||||
test('chapter sorting compares counts numerically and keeps equal counts in title order', async (t) => {
|
||||
await mount(t, [
|
||||
story(1, 'Zeta', { chapter_count: 2, offsite_count: 2, revised_count: 1 }),
|
||||
story(2, 'Alpha', { chapter_count: 10 }),
|
||||
story(3, 'Beta', { chapter_count: 10 }),
|
||||
story(4, 'Gamma', { chapter_count: 0 }),
|
||||
story(5, 'Omega', { chapter_count: 100 }),
|
||||
]);
|
||||
assert.equal(find(app, 'button', 'Chapters').attributes['aria-label'], 'Sort by chapters, most first');
|
||||
await click(app, 'Chapters');
|
||||
assert.deepEqual(titles(), ['Omega', 'Alpha', 'Beta', 'Zeta', 'Gamma']);
|
||||
assert.equal(heading('Chapters').attributes['aria-sort'], 'descending');
|
||||
await click(app, 'Chapters');
|
||||
assert.deepEqual(titles(), ['Gamma', 'Zeta', 'Alpha', 'Beta', 'Omega']);
|
||||
assert.equal(heading('Chapters').attributes['aria-sort'], 'ascending');
|
||||
});
|
||||
|
||||
for (const [label, key] of [['Author', 'author'], ['Site', 'site'], ['Status', 'status']]) {
|
||||
test(`${label} sorts alphabetically in either direction and uses titles to break ties`, async (t) => {
|
||||
await mount(t, [
|
||||
story(1, 'Zeta', { [key]: 'ok' }),
|
||||
story(3, 'Beta', { [key]: 'error' }),
|
||||
story(2, 'Alpha', { [key]: 'ERROR' }),
|
||||
story(4, 'Gamma', { [key]: null }),
|
||||
story(5, 'Omega', { [key]: 'queued' }),
|
||||
]);
|
||||
await click(app, label);
|
||||
assert.deepEqual(titles(), ['Gamma', 'Alpha', 'Beta', 'Zeta', 'Omega']);
|
||||
assert.equal(heading(label).attributes['aria-sort'], 'ascending');
|
||||
await click(app, label);
|
||||
assert.deepEqual(titles(), ['Omega', 'Zeta', 'Alpha', 'Beta', 'Gamma']);
|
||||
assert.equal(heading(label).attributes['aria-sort'], 'descending');
|
||||
});
|
||||
}
|
||||
|
||||
test('sorting saves the column and direction and restores both when the list reopens', async (t) => {
|
||||
const list = [
|
||||
story(1, 'Alpha', { author: 'Zed', site: 'z.test', status: 'ok', chapter_count: 2,
|
||||
last_changed: '2026-01-01T00:00:00Z', last_checked: '2026-01-03T00:00:00Z' }),
|
||||
story(2, 'Beta', { author: 'Anne', site: 'a.test', status: 'error', chapter_count: 10,
|
||||
last_changed: '2026-01-02T00:00:00Z', last_checked: '2026-01-02T00:00:00Z' }),
|
||||
];
|
||||
await mount(t, list);
|
||||
for (const [label, column, direction, order] of [
|
||||
['Last change', 'last_changed', -1, ['Beta', 'Alpha']],
|
||||
['Last change', 'last_changed', 1, ['Alpha', 'Beta']],
|
||||
['Last check', 'last_checked', -1, ['Alpha', 'Beta']],
|
||||
['Last check', 'last_checked', 1, ['Beta', 'Alpha']],
|
||||
['Title', 'title', 1, ['Alpha', 'Beta']],
|
||||
['Title', 'title', -1, ['Beta', 'Alpha']],
|
||||
['Author', 'author', 1, ['Beta', 'Alpha']],
|
||||
['Author', 'author', -1, ['Alpha', 'Beta']],
|
||||
['Site', 'site', 1, ['Beta', 'Alpha']],
|
||||
['Site', 'site', -1, ['Alpha', 'Beta']],
|
||||
['Status', 'status', 1, ['Beta', 'Alpha']],
|
||||
['Status', 'status', -1, ['Alpha', 'Beta']],
|
||||
['Chapters', 'chapter_count', -1, ['Beta', 'Alpha']],
|
||||
['Chapters', 'chapter_count', 1, ['Alpha', 'Beta']],
|
||||
]) {
|
||||
await click(app, label);
|
||||
assert.deepEqual(JSON.parse(window.localStorage.getItem(SORT_KEY)), { column, direction });
|
||||
await mount(t, list);
|
||||
assert.deepEqual(titles(), order);
|
||||
assert.equal(heading(label).attributes['aria-sort'], direction === 1 ? 'ascending' : 'descending');
|
||||
}
|
||||
});
|
||||
|
||||
for (const saved of ['not JSON', 'null', '{}', '{"column":"message","direction":-1}',
|
||||
'{"column":"last_changed","direction":0}', '{"column":"last_checked","direction":"-1"}']) {
|
||||
test(`invalid saved sorting falls back to title order: ${saved}`, async (t) => {
|
||||
window.localStorage.setItem(SORT_KEY, saved);
|
||||
await mount(t, [story(2, 'Beta'), story(1, 'Alpha')]);
|
||||
assert.deepEqual(titles(), ['Alpha', 'Beta']);
|
||||
assert.equal(heading('Title').attributes['aria-sort'], 'ascending');
|
||||
await click(app, 'Title');
|
||||
assert.deepEqual(JSON.parse(window.localStorage.getItem(SORT_KEY)), { column: 'title', direction: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
test('sorting works when local storage cannot be read or written', async (t) => {
|
||||
window.localStorage.getItem = () => { throw new Error('Storage blocked'); };
|
||||
window.localStorage.setItem = () => { throw new Error('Storage full'); };
|
||||
await mount(t, [story(2, 'Beta'), story(1, 'Alpha')]);
|
||||
assert.deepEqual(titles(), ['Alpha', 'Beta']);
|
||||
await click(app, 'Title');
|
||||
assert.deepEqual(titles(), ['Beta', 'Alpha']);
|
||||
assert.equal(heading('Title').attributes['aria-sort'], 'descending');
|
||||
assert.equal(toasts.textContent, '');
|
||||
});
|
||||
|
||||
test('date sorting compares instants, handles invalid dates and breaks ties by title and id', async (t) => {
|
||||
await mount(t, [
|
||||
story(7, 'Missing', { last_changed: 'not a date' }),
|
||||
story(6, 'Unknown'),
|
||||
story(5, 'Epoch', { last_changed: '1970-01-01T00:00:00Z' }),
|
||||
story(4, 'Before epoch', { last_changed: '1969-01-01T00:00:00Z' }),
|
||||
story(3, 'Zulu', { last_changed: '2026-01-02T00:00:00+02:00' }),
|
||||
story(2, 'alpha', { last_changed: '2026-01-01T23:00:00Z' }),
|
||||
story(1, 'Alpha', { last_changed: '2026-01-01T23:00:00Z' }),
|
||||
story(8, '', { last_changed: '2026-01-01T23:00:00Z' }),
|
||||
]);
|
||||
await click(app, 'Last change');
|
||||
assert.deepEqual(titles(), ['Alpha', 'alpha', 'https://example.com/8', 'Zulu', 'Epoch', 'Before epoch', 'Missing', 'Unknown']);
|
||||
assert.equal(cell(rows()[6], 'Last change').textContent, 'not a date');
|
||||
assert.equal(cell(rows()[7], 'Last change').textContent, '');
|
||||
await click(app, 'Last change');
|
||||
assert.deepEqual(titles(), ['Before epoch', 'Epoch', 'Zulu', 'Alpha', 'alpha', 'https://example.com/8', 'Missing', 'Unknown']);
|
||||
});
|
||||
|
||||
test('headings sort the filtered list after the initial story request finishes', async (t) => {
|
||||
let resolve;
|
||||
await mount(t, () => new Promise((done) => { resolve = done; }));
|
||||
assert.equal(find(app, 'button', 'Last check'), undefined);
|
||||
input(field(app, 'Filter stories'), 'Reader');
|
||||
resolve([
|
||||
story(1, 'Alpha', { last_checked: '2026-01-01T00:00:00Z' }),
|
||||
story(2, 'Beta', { last_checked: '2026-01-02T00:00:00Z' }),
|
||||
]);
|
||||
await settle();
|
||||
await click(app, 'Last check');
|
||||
assert.deepEqual(titles(), ['Beta', 'Alpha']);
|
||||
});
|
||||
|
||||
test('filtering and job refreshes keep the selected sort without dropping hidden stories', async (t) => {
|
||||
let list = [
|
||||
story(1, 'Alpha', { last_changed: '2026-01-01T00:00:00Z' }),
|
||||
story(2, 'Beta', { last_changed: '2026-01-02T00:00:00Z' }),
|
||||
story(3, 'Gamma', { last_changed: '2026-01-03T00:00:00Z', author: 'Someone else' }),
|
||||
];
|
||||
const backend = await mount(t, () => list);
|
||||
await click(app, 'Last change');
|
||||
input(field(app, 'Filter stories'), 'Reader');
|
||||
assert.deepEqual(titles(), ['Beta', 'Alpha']);
|
||||
backend.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'running' }]);
|
||||
startPolling();
|
||||
await settle();
|
||||
list = [{ ...list[0], last_changed: '2026-01-04T00:00:00Z' }, ...list.slice(1)];
|
||||
backend.on('GET', 'jobs', [{ id: 'job', story_id: 1, status: 'done' }]);
|
||||
await refreshJobs();
|
||||
await settle();
|
||||
assert.equal(backend.sent('GET', 'stories').length, 2);
|
||||
assert.equal(heading('Last change').attributes['aria-sort'], 'descending');
|
||||
assert.equal(document.activeElement, find(app, 'button', 'Last change'));
|
||||
assert.deepEqual(titles(), ['Alpha', 'Beta']);
|
||||
input(field(app, 'Filter stories'), 'missing');
|
||||
assert.match(app.textContent, /No stories match/);
|
||||
input(field(app, 'Filter stories'), '');
|
||||
assert.deepEqual(titles(), ['Alpha', 'Gamma', 'Beta']);
|
||||
await click(app, 'Last check');
|
||||
assert.deepEqual(titles(), ['Alpha', 'Beta', 'Gamma']);
|
||||
await click(app, 'Last change');
|
||||
assert.deepEqual(titles(), ['Alpha', 'Gamma', 'Beta']);
|
||||
});
|
||||
|
||||
test('date columns keep each timestamp on one line, with last change before last check', async (t) => {
|
||||
const changed = '2026-01-02T03:04:00Z';
|
||||
const checked = '2026-01-03T05:06:00Z';
|
||||
await mount(t, [story(1, 'Alpha', { last_changed: changed, last_checked: checked })]);
|
||||
const headings = nodes(app).filter((el) => el.tagName === 'th').map((el) => el.textContent);
|
||||
assert.deepEqual(headings, ['Title', 'Author', 'Site', 'Chapters', 'Status', 'Last change', 'Last check']);
|
||||
for (const [label, value] of [['Last change', changed], ['Last check', checked]]) {
|
||||
const dateCell = cell(rows()[0], label);
|
||||
const time = nodes(dateCell).find((el) => el.tagName === 'time');
|
||||
assert.equal(time.attributes.datetime, value);
|
||||
assert.equal(time.attributes.title, fmtDate(value));
|
||||
assert.equal(nodes(time).filter((el) => el.tagName === 'br').length, 0);
|
||||
assert.equal(time.textContent, fmtDate(value));
|
||||
}
|
||||
});
|
||||
|
||||
test('status messages move into accessible pill descriptions without hiding held or chapter counts', async (t) => {
|
||||
await mount(t, [
|
||||
story(1, 'Alpha', { offsite_count: 3, revised_count: 2, message: 'Updated 2 chapters', upload_held: true }),
|
||||
story(2, 'Beta', { status: 'error', message: '<script>Connection failed</script>' }),
|
||||
story(3, 'Gamma'),
|
||||
story(4, 'New', { status: '', message: '' }),
|
||||
story(5, 'Queued', { status: 'queued', message: '' }),
|
||||
]);
|
||||
for (const [row, label, message] of [
|
||||
[rows()[0], 'ok', 'Updated 2 chapters'],
|
||||
[rows()[1], 'error', '<script>Connection failed</script>'],
|
||||
[rows()[2], 'ok', 'No changes'],
|
||||
]) {
|
||||
const status = cell(row, 'Status');
|
||||
const pill = status.children[0];
|
||||
assert.equal(pill.textContent, label);
|
||||
assert.equal(pill.attributes.title, message);
|
||||
assert.equal(pill.attributes['aria-description'], message);
|
||||
assert.equal(pill.attributes.tabindex, '0');
|
||||
assert.ok(!status.textContent.includes(message));
|
||||
assert.equal(nodes(status).filter((el) => el.tagName === 'small' || el.tagName === 'script').length, 0);
|
||||
}
|
||||
assert.match(cell(rows()[0], 'Status').textContent, /held/);
|
||||
const chapters = cell(rows()[0], 'Chapters');
|
||||
assert.match(chapters.textContent, /^42/);
|
||||
assert.equal(nodes(chapters).filter((el) => el.className.startsWith('badge')).length, 0);
|
||||
const counts = nodes(chapters).filter((el) => el.tagName === 'small');
|
||||
assert.deepEqual(counts.map((el) => el.textContent), ['3 removed', '2 revised']);
|
||||
assert.deepEqual(counts.map((el) => el.attributes.title), [
|
||||
'Chapters the site no longer lists', 'Chapters the site changed since Bindery added them',
|
||||
]);
|
||||
assert.equal(cell(rows()[1], 'Chapters').textContent, '42');
|
||||
assert.equal(cell(rows()[3], 'Status').textContent, '');
|
||||
assert.equal(cell(rows()[4], 'Status').textContent, 'queued');
|
||||
});
|
||||
@@ -64,6 +64,14 @@ def test_settings_and_inclusion_persist_and_delete_cascades(client, story):
|
||||
)
|
||||
assert response.json["mode"] == "rebuild"
|
||||
assert not o.store.get_chapter(cid)["included"]
|
||||
response = client.post(f"/api/stories/{sid}/chapters/move", json={"chapter_ids": [cid], "after": None}, headers=H)
|
||||
assert response.json["mode"] == "rebuild"
|
||||
move = client.post(f"/api/stories/{sid}/chapters/move", json={"chapter_ids": [cid], "after": 999}, headers=H)
|
||||
assert move.status_code == 404
|
||||
# The story has only this chapter, so it cannot go.
|
||||
response = client.post(f"/api/stories/{sid}/chapters/delete", json={"chapter_ids": [cid]}, headers=H)
|
||||
assert response.status_code == 400
|
||||
assert "at least one chapter" in response.json["error"]
|
||||
o.store.set_rules(sid, [{"pattern": "a", "replacement": "b", "enabled": True}])
|
||||
response = client.delete(f"/api/stories/{sid}", headers=H)
|
||||
assert response.json == {"deleted": sid}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Tests for scripts/import_unlinked_epub_chapters.py, a manual tool."""
|
||||
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from bindery import config
|
||||
from bindery.store import Store
|
||||
from scripts import import_unlinked_epub_chapters as script
|
||||
from scripts.import_unlinked_epub_chapters import (
|
||||
ScriptError,
|
||||
chapter_url,
|
||||
main,
|
||||
read_epub,
|
||||
splice_order,
|
||||
suggest_matches,
|
||||
)
|
||||
|
||||
STORY_URL = "https://www.royalroad.com/fiction/1234"
|
||||
|
||||
CONTAINER_XML = """<?xml version="1.0"?>
|
||||
<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container" version="1.0">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>"""
|
||||
|
||||
|
||||
def chapter_html(title: str, text: str) -> str:
|
||||
return f"<html><head><title>{title}</title></head><body><h1>{title}</h1><p>{text}</p></body></html>"
|
||||
|
||||
|
||||
def make_opf(count: int, identifier: str) -> str:
|
||||
items = "\n".join(
|
||||
f' <item id="c{i}" href="chap{i}.xhtml" media-type="application/xhtml+xml"/>' for i in range(1, count + 1)
|
||||
)
|
||||
refs = "\n".join(f' <itemref idref="c{i}"/>' for i in range(1, count + 1))
|
||||
return f"""<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier>{identifier}</dc:identifier></metadata>
|
||||
<manifest>
|
||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||
{items}
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="nav" linear="no"/>
|
||||
{refs}
|
||||
</spine>
|
||||
</package>"""
|
||||
|
||||
|
||||
def make_epub(path, chapters, identifier="book-1", files=None):
|
||||
"""An ePub with a chapter file for each (title, text). `files` adds more files, such as images."""
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("META-INF/container.xml", CONTAINER_XML)
|
||||
z.writestr("OEBPS/content.opf", make_opf(len(chapters), identifier))
|
||||
z.writestr("OEBPS/nav.xhtml", "<html><body><nav></nav></body></html>")
|
||||
for i, (title, text) in enumerate(chapters, start=1):
|
||||
z.writestr(f"OEBPS/chap{i}.xhtml", chapter_html(title, text))
|
||||
for name, data in (files or {}).items():
|
||||
z.writestr(name, data)
|
||||
return path
|
||||
|
||||
|
||||
def test_read_epub_reads_spine_order_and_skips_nonlinear_items(tmp_path):
|
||||
path = make_epub(tmp_path / "book.epub", [("Chapter One", "First text"), ("Chapter Two", "Second text")])
|
||||
chapters = read_epub(path)
|
||||
assert [c["title"] for c in chapters] == ["Chapter One", "Chapter Two"]
|
||||
assert "First text" in chapters[0]["html"]
|
||||
assert "<body" not in chapters[0]["html"]
|
||||
assert chapters[0]["key"] != chapters[1]["key"]
|
||||
|
||||
|
||||
NCX_OPF = """<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata/>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="contents" href="text/contents.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="c1" href="text/one.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="c2" href="text/two.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx"><itemref idref="contents"/><itemref idref="c1"/><itemref idref="c2"/></spine>
|
||||
</package>"""
|
||||
|
||||
NCX_TOC = """<?xml version="1.0"?>
|
||||
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"><navMap>
|
||||
<navPoint id="p1"><navLabel><text>CHAPTER ONE: Arrival</text></navLabel><content src="text/one.xhtml"/></navPoint>
|
||||
<navPoint id="p2"><navLabel><text>12</text></navLabel><content src="text/two.xhtml#page_12"/></navPoint>
|
||||
</navMap></ncx>"""
|
||||
|
||||
|
||||
def test_read_epub_skips_a_contents_page_and_takes_titles_from_the_ncx(tmp_path):
|
||||
# As in a calibre conversion: a contents page in the reading order, a title in two headings,
|
||||
# and a page number in the NCX. Curly quotes stay.
|
||||
path = tmp_path / "book.epub"
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("META-INF/container.xml", CONTAINER_XML)
|
||||
z.writestr("OEBPS/content.opf", NCX_OPF)
|
||||
z.writestr("OEBPS/toc.ncx", NCX_TOC)
|
||||
z.writestr(
|
||||
"OEBPS/text/contents.xhtml",
|
||||
'<html><body><h1><a href="one.xhtml">CHAPTER ONE: Arrival</a></h1>'
|
||||
'<p><a href="two.xhtml">CHAPTER TWO: Decision</a></p></body></html>',
|
||||
)
|
||||
z.writestr(
|
||||
"OEBPS/text/one.xhtml", "<html><body><h1>CHAPTER ONE</h1><h1>Arrival</h1><p>“I’m here.”</p></body></html>"
|
||||
)
|
||||
z.writestr("OEBPS/text/two.xhtml", "<html><body><h1>CHAPTER TWO</h1><h1>Decision</h1><p>Text</p></body></html>")
|
||||
chapters = read_epub(path)
|
||||
assert [c["title"] for c in chapters] == ["CHAPTER ONE: Arrival", "CHAPTER TWO"]
|
||||
assert "<p>“I’m here.”</p>" in chapters[0]["html"]
|
||||
|
||||
|
||||
def test_read_epub_points_images_at_their_own_urls_and_drops_missing_ones(tmp_path):
|
||||
path = make_epub(
|
||||
tmp_path / "book.epub",
|
||||
[("Map", '<img src="images/map.png"/><img src="images/gone.png"/><img src="images/notes.txt"/>')],
|
||||
files={"OEBPS/images/map.png": b"png data", "OEBPS/images/notes.txt": b"text"},
|
||||
)
|
||||
(chapter,) = read_epub(path)
|
||||
(image,) = chapter["images"]
|
||||
assert image["url"].startswith("file:///bindery-epub-import/")
|
||||
assert image["url"].endswith("/OEBPS/images/map.png")
|
||||
assert (image["ext"], image["mime"], image["data"]) == ("png", "image/png", b"png data")
|
||||
assert f'src="{image["url"]}"' in chapter["html"]
|
||||
assert "gone.png" not in chapter["html"]
|
||||
assert "notes.txt" not in chapter["html"]
|
||||
|
||||
|
||||
def test_read_epub_rejects_a_bad_file(tmp_path):
|
||||
path = tmp_path / "not-an-epub.epub"
|
||||
path.write_text("nope")
|
||||
with pytest.raises(ScriptError):
|
||||
read_epub(path)
|
||||
|
||||
|
||||
def test_suggest_matches_ranks_the_closest_title_first_and_drops_weak_ones():
|
||||
candidates = ["Chapter One: A New Start", "Interlude", "Chapter Two: Trouble Brews"]
|
||||
assert suggest_matches("Chapter Two", candidates)[0] == 2
|
||||
assert suggest_matches("Chapter One", ["Completely unrelated title"]) == []
|
||||
|
||||
|
||||
def test_chapter_url_is_stable_and_differs_between_epubs(tmp_path):
|
||||
one = read_epub(make_epub(tmp_path / "one.epub", [("Prologue", "a")], identifier="book-1"))[0]
|
||||
again = read_epub(make_epub(tmp_path / "again.epub", [("Prologue", "a")], identifier="book-1"))[0]
|
||||
other = read_epub(make_epub(tmp_path / "other.epub", [("Prologue", "a")], identifier="book-3"))[0]
|
||||
url = chapter_url(STORY_URL, one)
|
||||
assert url.startswith(f"{STORY_URL}#bindery-import-")
|
||||
assert chapter_url(STORY_URL, again) == url
|
||||
assert chapter_url(STORY_URL, other) != url
|
||||
|
||||
|
||||
def test_splice_order_puts_the_new_urls_after_the_given_index():
|
||||
assert splice_order(["a", "b", "c"], 0, ["x", "y"]) == ["a", "x", "y", "b", "c"]
|
||||
assert splice_order(["a", "b"], -1, ["x"]) == ["x", "a", "b"]
|
||||
assert splice_order(["a", "b"], 1, ["x"]) == ["a", "b", "x"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def story(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "DB_PATH", tmp_path / "bindery.db")
|
||||
db_store = Store(config.DB_PATH)
|
||||
story_id = db_store.create_story(STORY_URL, "www.royalroad.com", "Story", "Writer", [])
|
||||
db_store.add_chapter(story_id, f"{STORY_URL}/1", "Chapter 1", "<p>one</p>", "h1", "new", 0)
|
||||
db_store.add_chapter(story_id, f"{STORY_URL}/4", "Chapter 4", "<p>four</p>", "h4", "new", 1)
|
||||
epub = make_epub(
|
||||
tmp_path / "book.epub",
|
||||
[
|
||||
("Cover", "cover"),
|
||||
("Chapter 1", "one text"),
|
||||
("Chapter 2", '<img src="map.png"/>two text'),
|
||||
("Chapter 3", "three text"),
|
||||
("Chapter 4", "four text"),
|
||||
("About the Author", "author"),
|
||||
],
|
||||
files={"OEBPS/map.png": b"png"},
|
||||
)
|
||||
return db_store, story_id, epub
|
||||
|
||||
|
||||
def answer(monkeypatch, *answers):
|
||||
"""Answer the prompts in order. A prompt that shows a default in [brackets] gets it for an empty answer."""
|
||||
replies = iter(answers)
|
||||
prompts = []
|
||||
|
||||
def fake_input(prompt=""):
|
||||
prompts.append(prompt)
|
||||
return next(replies)
|
||||
|
||||
monkeypatch.setattr("builtins.input", fake_input)
|
||||
return prompts
|
||||
|
||||
|
||||
def titles(db_store, story_id):
|
||||
return [c["title"] for c in db_store.list_chapters(story_id)]
|
||||
|
||||
|
||||
def test_main_fills_a_gap_with_the_suggested_matches(story, monkeypatch, capsys):
|
||||
db_store, story_id, epub = story
|
||||
# Before the gap: stored chapter 1. Then Enter takes each suggestion.
|
||||
prompts = answer(monkeypatch, "1", "", "", "", "y")
|
||||
main([STORY_URL, str(epub)])
|
||||
|
||||
# Each prompt says what it asks for, and shows the suggestion. Numbers start at 1.
|
||||
assert prompts[:4] == [
|
||||
"Stored chapter just BEFORE the gap (or 'start'): ",
|
||||
'ePub chapter that matches "Chapter 1" [2]: ',
|
||||
"Stored chapter just AFTER the gap (or 'end') [2]: ",
|
||||
'ePub chapter that matches "Chapter 4" [5]: ',
|
||||
]
|
||||
assert titles(db_store, story_id) == ["Chapter 1", "Chapter 2", "Chapter 3", "Chapter 4"]
|
||||
added = db_store.list_chapters(story_id)[1]
|
||||
assert added["url"].startswith(f"{STORY_URL}#bindery-import-")
|
||||
assert added["on_site"] == 0
|
||||
assert db_store.list_revisions(added["id"])[0]["source"] == "epub-import"
|
||||
html = db_store.revision_html(added["id"], 1)
|
||||
(image,) = db_store.images(story_id)
|
||||
assert f'src="{image["url"]}"' in html
|
||||
assert 'The script will add these 2 chapters after "Chapter 1"' in capsys.readouterr().out
|
||||
|
||||
# A second run over the same gap finds the same chapters in the story, and adds nothing.
|
||||
answer(monkeypatch, "1", "", "4", "")
|
||||
main([STORY_URL, str(epub)])
|
||||
assert len(db_store.list_chapters(story_id)) == 4
|
||||
assert "2 ePub chapters of the gap are in the story already" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_main_asks_again_for_a_bad_answer_and_can_show_the_whole_list(story, monkeypatch, capsys):
|
||||
db_store, story_id, epub = story
|
||||
monkeypatch.setattr(script, "WINDOW", 2)
|
||||
answer(monkeypatch, "9", "list", "1", "", "1", "", "", "n")
|
||||
main([STORY_URL, str(epub)])
|
||||
out = capsys.readouterr().out
|
||||
assert "Type a number from 1 to 2, or 'list' or 'start'." in out
|
||||
# The AFTER question cannot go before the BEFORE chapter.
|
||||
assert "Type a number from 2 to 2, or 'list' or 'end'." in out
|
||||
assert titles(db_store, story_id) == ["Chapter 1", "Chapter 4"]
|
||||
|
||||
|
||||
def test_main_asks_for_the_first_and_last_epub_chapters_at_the_start_and_end(story, monkeypatch):
|
||||
db_store, story_id, epub = story
|
||||
# The gap is at the start: add from ePub chapter 1 up to the match of stored Chapter 1.
|
||||
prompts = answer(monkeypatch, "start", "1", "", "", "y")
|
||||
main([STORY_URL, str(epub)])
|
||||
assert prompts[1] == "FIRST ePub chapter to add: "
|
||||
assert titles(db_store, story_id) == ["Cover", "Chapter 1", "Chapter 4"]
|
||||
|
||||
# The gap is after the last stored chapter, so the script does not ask for the chapter after it.
|
||||
prompts = answer(monkeypatch, "3", "", "", "y")
|
||||
main([STORY_URL, str(epub)])
|
||||
assert prompts[2] == "LAST ePub chapter to add [6]: "
|
||||
assert titles(db_store, story_id) == ["Cover", "Chapter 1", "Chapter 4", "About the Author"]
|
||||
|
||||
|
||||
def test_main_changes_nothing_when_the_user_says_no(story, monkeypatch):
|
||||
db_store, story_id, epub = story
|
||||
answer(monkeypatch, "1", "", "", "", "n")
|
||||
main([STORY_URL, str(epub)])
|
||||
assert titles(db_store, story_id) == ["Chapter 1", "Chapter 4"]
|
||||
assert db_store.images(story_id) == []
|
||||
|
||||
|
||||
def test_remove_deletes_only_the_added_chapters_and_their_revisions(story, monkeypatch):
|
||||
db_store, story_id, _ = story
|
||||
added = db_store.add_chapter(story_id, f"{STORY_URL}#bindery-import-x", "Two", "<p>2</p>", "h2", "epub-import", 1)
|
||||
db_store.add_revision(added, "<p>2b</p>", "h2b", "recheck")
|
||||
|
||||
answer(monkeypatch, "n")
|
||||
main([STORY_URL, "--remove"])
|
||||
assert len(db_store.list_chapters(story_id)) == 3
|
||||
|
||||
answer(monkeypatch, "y")
|
||||
main([STORY_URL, "--remove"])
|
||||
assert titles(db_store, story_id) == ["Chapter 1", "Chapter 4"]
|
||||
assert db_store.list_revisions(added) == []
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for scripts/remove_smart_quote_revisions.py, a manual cleanup tool."""
|
||||
|
||||
import pytest
|
||||
|
||||
from bindery import diffing
|
||||
from bindery.store import Store
|
||||
from scripts.remove_smart_quote_revisions import main, straighten_html, text_blocks, text_hash
|
||||
|
||||
CURLY = "<div><p class='a1'>“Hi,” he said.</p><p>It’s ‘fine’…</p></div>"
|
||||
STRAIGHT = "<div><p class='b2'>\"Hi,\" he said.</p><p>It's 'fine'...</p></div>"
|
||||
# The same text as CURLY, with the smart punctuation as entities.
|
||||
ENTITIES = "<div><p>“Hi,” he said.</p><p>It’s ‘fine’…</p></div>"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"html",
|
||||
[
|
||||
CURLY,
|
||||
ENTITIES,
|
||||
"<p>One<br/>Two</p><script>x()</script><style>p{}</style>",
|
||||
"<div>Loose <em>text</em><ul><li>a</li><li>b & c</li></ul> tail </div><hr/>",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_text_and_hash_match_bindery(html):
|
||||
assert text_blocks(html) == diffing.html_to_blocks(html)
|
||||
assert text_hash(html) == diffing.text_hash(html)
|
||||
|
||||
|
||||
def test_straighten_html_changes_only_the_text():
|
||||
html = '<p title="“x”">“Hi,” it’s ‘fine’… & ’<!-- “note” --></p>'
|
||||
assert straighten_html(html) == "<p title=\"“x”\">\"Hi,\" it's 'fine'... & '<!-- “note” --></p>"
|
||||
assert diffing.html_to_blocks(straighten_html(ENTITIES)) == diffing.html_to_blocks(STRAIGHT)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
path = tmp_path / "bindery.db"
|
||||
store = Store(path)
|
||||
story_id = store.create_story("https://example.com/s/1", "example.com", "Story", "Writer", [])
|
||||
return path, store, story_id
|
||||
|
||||
|
||||
def chapter(store, story_id, n, revisions, current=None):
|
||||
"""A chapter with revisions given as (html, source). The last one is in the book, unless `current` says."""
|
||||
html, source = revisions[0]
|
||||
cid = store.add_chapter(story_id, f"u{n}", f"Chapter {n}", html, diffing.text_hash(html), source, n)
|
||||
for html, source in revisions[1:]:
|
||||
store.add_revision(cid, html, diffing.text_hash(html), source)
|
||||
if current:
|
||||
store.update_chapter(cid, current_rev=current)
|
||||
return cid
|
||||
|
||||
|
||||
def revs(store, cid):
|
||||
return [r["rev"] for r in store.list_revisions(cid)]
|
||||
|
||||
|
||||
def test_dry_run_changes_nothing(db, capsys):
|
||||
path, store, story_id = db
|
||||
cid = chapter(store, story_id, 1, [(CURLY, "import"), (STRAIGHT, "recheck")])
|
||||
assert main([str(path)]) == 0
|
||||
assert revs(store, cid) == [1, 2]
|
||||
out = capsys.readouterr().out
|
||||
assert "delete revision 1 (import), keep 2 (recheck)" in out
|
||||
assert "Run again with --apply" in out
|
||||
assert not list(path.parent.glob("*before-quote-cleanup*"))
|
||||
|
||||
|
||||
def test_apply_deletes_revisions_that_differ_only_by_smart_punctuation(db, capsys):
|
||||
path, store, story_id = db
|
||||
# The smart import, then the straight site text.
|
||||
older = chapter(store, story_id, 1, [(CURLY, "import"), (STRAIGHT, "recheck")])
|
||||
# The same, but the user put the smart revision in the book.
|
||||
chosen = chapter(store, story_id, 2, [(CURLY, "import"), (STRAIGHT, "recheck")], current=1)
|
||||
# A smart import after the site text. It is the newest, but not from the site.
|
||||
imported = chapter(store, story_id, 3, [(STRAIGHT, "new"), (ENTITIES, "import")])
|
||||
# The site sends smart punctuation now. The next recheck would add it again.
|
||||
site = chapter(store, story_id, 4, [(STRAIGHT, "new"), (CURLY, "recheck")])
|
||||
|
||||
assert main([str(path), "--apply"]) == 0
|
||||
assert revs(store, older) == [2]
|
||||
assert revs(store, chosen) == [2]
|
||||
assert store.get_chapter(chosen)["current_rev"] == 2
|
||||
assert revs(store, imported) == [1]
|
||||
assert store.get_chapter(imported)["current_rev"] == 1
|
||||
assert revs(store, site) == [1, 2]
|
||||
out = capsys.readouterr().out
|
||||
assert "3 revisions to delete, and 0 revisions to make straight" in out
|
||||
assert "not changed: revision 2 (recheck) is the newest text from the site" in out
|
||||
assert "Rebuild them:\n - Story" in out
|
||||
(backup,) = path.parent.glob("bindery.before-quote-cleanup-*.db")
|
||||
assert revs(Store(backup), older) == [1, 2]
|
||||
|
||||
|
||||
def test_apply_makes_revisions_with_other_changes_straight(db, capsys):
|
||||
path, store, story_id = db
|
||||
changed = CURLY.replace("fine", "good")
|
||||
# Three different texts. The newest is not from the site, so all of them get straight.
|
||||
both = chapter(
|
||||
store, story_id, 1, [(CURLY, "import"), (changed, "recheck"), (ENTITIES.replace("Hi", "Yo"), "import")]
|
||||
)
|
||||
# The newest is from the site, so only the older one gets straight.
|
||||
older = chapter(store, story_id, 2, [(CURLY, "import"), (changed, "recheck")])
|
||||
# A smart revision that also matches a straight one goes, and the other text gets straight.
|
||||
mixed = chapter(store, story_id, 3, [(CURLY, "import"), (STRAIGHT, "new"), (changed, "import")], current=1)
|
||||
|
||||
assert main([str(path), "--apply"]) == 0
|
||||
|
||||
def smart(cid, rev):
|
||||
text = "\n".join(diffing.html_to_blocks(store.revision_html(cid, rev)))
|
||||
return any(c in text for c in "‘’“”…")
|
||||
|
||||
assert [smart(both, rev) for rev in (1, 2, 3)] == [False, False, False]
|
||||
assert [smart(older, rev) for rev in (1, 2)] == [False, True]
|
||||
assert revs(store, mixed) == [2, 3]
|
||||
assert [smart(mixed, rev) for rev in (2, 3)] == [False, False]
|
||||
assert store.get_chapter(mixed)["current_rev"] == 2
|
||||
# Bindery compares the next site text with the stored hash, so it must be the hash of the new text.
|
||||
for cid in (both, older, mixed):
|
||||
for r in store.list_revisions(cid):
|
||||
assert r["text_hash"] == diffing.text_hash(store.revision_html(cid, r["rev"]))
|
||||
assert "class='a1'" in store.revision_html(both, 1)
|
||||
out = capsys.readouterr().out
|
||||
assert "make the punctuation straight in revision 1 (import), 2 (recheck), 3 (import)" in out
|
||||
assert "1 revisions to delete, and 5 revisions to make straight" in out
|
||||
# A second run finds nothing to do.
|
||||
assert main([str(path)]) == 0
|
||||
assert "not changed: revision 2 (recheck)" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_no_work_and_a_missing_file(db, tmp_path, capsys):
|
||||
path, store, story_id = db
|
||||
chapter(store, story_id, 1, [(STRAIGHT, "new")])
|
||||
assert main([str(path), "--apply"]) == 0
|
||||
assert "No revisions to change." in capsys.readouterr().out
|
||||
assert main([str(tmp_path / "missing.db")]) == 1
|
||||
@@ -47,6 +47,14 @@ def test_list_stories_counts_offsite_and_revised_chapters(tmp_path):
|
||||
assert story["revised_count"] == 1
|
||||
|
||||
|
||||
def test_list_stories_sorts_titles_without_leading_articles(tmp_path):
|
||||
store = Store(tmp_path / "db.sqlite")
|
||||
for n, title in enumerate(["The Zebra", "an Owl", "Anthem", "A Bee", "Theory", "Cat"]):
|
||||
store.create_story(f"https://example/s/{n}", "example", title, "A", [])
|
||||
titles = [s["title"] for s in store.list_stories()]
|
||||
assert titles == ["Anthem", "A Bee", "Cat", "an Owl", "Theory", "The Zebra"]
|
||||
|
||||
|
||||
def test_cover_replaces_previous_cover(tmp_path):
|
||||
store = Store(tmp_path / "db.sqlite")
|
||||
sid = store.create_story("u", "s", "T", "A", [])
|
||||
|
||||
@@ -250,6 +250,47 @@ def test_inclusion_changes_only_the_requested_story_and_queues_a_rebuild(state):
|
||||
assert len(queue.get_status()) == 1
|
||||
|
||||
|
||||
def test_move_chapters_keeps_their_order_and_queues_a_rebuild(state):
|
||||
store, queue = state
|
||||
sid, other = add_story(store), add_story(store, "other")
|
||||
ids = [store.add_chapter(sid, f"c{n}", str(n), "<p>x</p>", "h", "new", n) for n in range(5)]
|
||||
foreign = store.add_chapter(other, "foreign", "Other", "<p>x</p>", "h", "new", 0)
|
||||
|
||||
def titles():
|
||||
return [c["title"] for c in store.list_chapters(sid)]
|
||||
|
||||
# The selection order does not count, and a chapter of another story is ignored.
|
||||
assert o.move_chapters(sid, [ids[3], ids[1], foreign], ids[4])["mode"] == "rebuild"
|
||||
assert titles() == ["0", "2", "4", "1", "3"]
|
||||
o.move_chapters(sid, [ids[4], ids[3]], None)
|
||||
assert titles() == ["4", "3", "0", "2", "1"]
|
||||
assert len(queue.get_status()) == 1
|
||||
with pytest.raises(ValueError, match="Select"):
|
||||
o.move_chapters(sid, [foreign], None)
|
||||
with pytest.raises(ValueError, match="moves with them"):
|
||||
o.move_chapters(sid, [ids[0], ids[1]], ids[1])
|
||||
with pytest.raises(KeyError):
|
||||
o.move_chapters(sid, [ids[0]], foreign)
|
||||
|
||||
|
||||
def test_delete_chapters_removes_their_revisions_and_keeps_one_chapter(state):
|
||||
store, queue = state
|
||||
sid, other = add_story(store), add_story(store, "other")
|
||||
ids = [store.add_chapter(sid, f"c{n}", str(n), "<p>x</p>", "h", "new", n) for n in range(3)]
|
||||
store.add_revision(ids[1], "<p>y</p>", "h2", "recheck")
|
||||
foreign = store.add_chapter(other, "foreign", "Other", "<p>x</p>", "h", "new", 0)
|
||||
|
||||
assert o.delete_chapters(sid, [ids[1], foreign])["mode"] == "rebuild"
|
||||
assert [c["id"] for c in store.list_chapters(sid)] == [ids[0], ids[2]]
|
||||
assert store.list_revisions(ids[1]) == []
|
||||
assert store.get_chapter(foreign)
|
||||
with pytest.raises(ValueError, match="Select"):
|
||||
o.delete_chapters(sid, [foreign])
|
||||
with pytest.raises(ValueError, match="at least one chapter"):
|
||||
o.delete_chapters(sid, [ids[0], ids[2]])
|
||||
assert len(store.list_chapters(sid)) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode,chapters,fields,message",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user