Show an estimated page count for each built book

Bindery counts the pages as the last step of a build, from the text of the ePub. The story list and the OPDS catalog show the count. A story has no count until its book is built.

The method follows "Paragraphs (APNX Accurate)" of the Count Pages plugin for calibre. Bindery counts each paragraph that a reader sees, so the same text gives the same count as <p> elements, as <div> elements and as lines with <br> between them. An empty paragraph is not counted.
This commit is contained in:
tim
2026-10-04 02:05:47 +02:00
parent b73b2808ca
commit 46de77c228
13 changed files with 395 additions and 11 deletions
+1 -1
View File
@@ -380,7 +380,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 change', 'Last check']);
assert.deepEqual(dataLabels, [undefined, 'Author', 'Site', 'Chapters', 'Pages', 'Status', 'Last change', 'Last check']);
});
test('the story list shows removed and revised chapter counts, only when there are any', async () => {
+6
View File
@@ -114,6 +114,12 @@ test('sortable headings show direction and remain usable above story cards on na
assert.doesNotMatch(css, /\.stories-table thead\s*\{[^}]*clip-path/);
});
test('on narrow screens a story cell with no value shows no line', () => {
const start = css.indexOf('@media (max-width: 600px)', css.indexOf('.stories-table th button'));
assert.equal(declarations('.stories-table td:empty', css.slice(start)).display, 'none');
assert.doesNotMatch(css.slice(0, start), /\.stories-table td:empty/);
});
test('on narrow screens the headings are sort chips, and only the sorted chip is tinted with an arrow', () => {
const start = css.indexOf('@media (max-width: 600px)', css.indexOf('.stories-table th button'));
const mobile = css.slice(start);
+35 -2
View File
@@ -21,7 +21,7 @@ const story = (id, title, changes = {}) => ({
id, title, url: `https://example.com/${id}`,
sort_title: (title || `https://example.com/${id}`).trim().replace(/^(?:the|an?)\s+/i, '').toLowerCase(),
author: 'Reader', site: 'example.com',
chapter_count: 42, offsite_count: 0, revised_count: 0, status: 'ok', message: 'No changes',
chapter_count: 42, page_count: 120, offsite_count: 0, revised_count: 0, status: 'ok', message: 'No changes',
last_changed: null, last_checked: null, ...changes,
});
const rows = () => {
@@ -106,6 +106,39 @@ test('chapter sorting compares counts numerically and keeps equal counts in titl
assert.equal(heading('Chapters').attributes['aria-sort'], 'ascending');
});
test('pages show beside the chapters and sort numerically', async (t) => {
await mount(t, [
story(1, 'Zeta', { page_count: 9 }),
story(2, 'Alpha', { page_count: 1200 }),
story(3, 'Beta', { page_count: 0 }),
story(4, 'Gamma', { page_count: 9 }),
]);
const cells = nodes(rows()[0]).filter((el) => el.tagName === 'td').map((el) => el.attributes['data-label']);
assert.deepEqual(cells.slice(3, 5), ['Chapters', 'Pages']);
assert.equal(find(app, 'button', 'Pages').attributes['aria-label'], 'Sort by pages, most first');
await click(app, 'Pages');
assert.deepEqual(titles(), ['Alpha', 'Gamma', 'Zeta', 'Beta']);
await click(app, 'Pages');
assert.deepEqual(titles(), ['Beta', 'Gamma', 'Zeta', 'Alpha']);
assert.deepEqual(JSON.parse(window.localStorage.getItem(SORT_KEY)), { column: 'page_count', direction: 1 });
});
test('a story with no built book shows no page count and sorts last in each direction', async (t) => {
await mount(t, [
story(1, 'Alpha', { page_count: null }),
story(2, 'Beta', { page_count: 300 }),
story(3, 'Gamma', { page_count: 0 }),
story(4, 'Delta', { page_count: null }),
]);
const pages = () => rows().map((row) => cell(row, 'Pages'));
assert.deepEqual(pages().map((el) => el.textContent), ['', '300', '', '0']);
assert.deepEqual(pages().map((el) => el.children.length), [0, 1, 0, 1], 'An empty cell hides on a narrow screen');
await click(app, 'Pages');
assert.deepEqual(titles(), ['Beta', 'Gamma', 'Alpha', 'Delta']);
await click(app, 'Pages');
assert.deepEqual(titles(), ['Gamma', 'Beta', 'Alpha', 'Delta']);
});
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, [
@@ -248,7 +281,7 @@ test('date columns keep each timestamp on one line, with last change before last
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']);
assert.deepEqual(headings, ['Title', 'Author', 'Site', 'Chapters', 'Pages', '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');