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:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user