Move and delete chapters by hand in the chapter list

The merge cannot always tell where new chapters go. For example, a site
can list an announcement before the chapters of an old book. You can now
select chapters and move them, in their order, to after another chapter
or to the start. You can also delete them with their revisions. A story
keeps at least one chapter. The book is then rebuilt. A deleted chapter
that the site still lists comes back with the next download.

The Move… and Delete… buttons open a dialog that names the selected
chapters before anything changes. A shift-click selects a range of
chapters.

The Select all checkbox moves into the first heading of the chapter
list. On a phone, that heading shows with the text "Select all".
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent ee611cd283
commit 8faab2463b
9 changed files with 306 additions and 15 deletions
+2
View File
@@ -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(''); }
+69 -4
View File
@@ -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 () => {
@@ -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();