Fix Full Book assembly to always render from canonical chapter metadata

Full Book now assembles from a fresh read of the canonical book metadata
(chapter order + chapter entries) instead of the renderer's in-memory
chapter array, so it always contains every chapter in the current order
and can never render from stale or partially-loaded state. The chapter
sidebar and Full Book share the same single source of truth.

Also refresh Full Book whenever book metadata loads, and extend the e2e
scenario to cover reordering to a new order, order persistence across
reopen, and delete/rename/new-empty chapters all being reflected.
This commit is contained in:
avi 2026-08-17 19:33:41 -05:00
commit 6b1a9da5cc
2 changed files with 104 additions and 4 deletions

View file

@ -402,6 +402,7 @@ export function initBook(api: FolioAPI) {
if (ordered.length) showPlaceholder();
else showPlaceholder();
}
await refreshFullBook();
}
async function refreshMeta() {
@ -410,7 +411,6 @@ export function initBook(api: FolioAPI) {
selectedId = null;
showPlaceholder();
}
await refreshFullBook();
}
function renderChapterList() {
@ -501,11 +501,19 @@ export function initBook(api: FolioAPI) {
return false;
}
// Assemble the whole book from the canonical book metadata (the same source
// the chapter sidebar uses) so Full Book always reflects the current chapter
// set and order, regardless of any in-memory renderer state.
async function assembleFullBook(): Promise<string> {
const res = await api.getBookMeta();
if (!res || typeof res !== "object" || "error" in res) return "";
const book = res as BookMeta;
const parts: string[] = [];
for (const ch of ordered) {
const res = await api.getChapterContent(ch.id);
const content = typeof res === "string" ? res : "";
for (const id of book.chapterOrder) {
const ch = book.chapters[id];
if (!ch) continue;
const contentRes = await api.getChapterContent(ch.id);
const content = typeof contentRes === "string" ? contentRes : "";
const body = content.replace(/^\n+/, "").replace(/\s+$/, "");
const block: string[] = [];
if (!chapterHasOwnHeading(content, ch.title)) {

View file

@ -269,6 +269,89 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
!document.getElementById("chapterView").classList.contains("hidden") &&
!document.getElementById("fullBookBtn").classList.contains("active");
// --- requirement matrix: reorder, persistence, edit ops reflect in Full Book ---
const rowFor = (id) =>
Array.from(document.querySelectorAll("#chapterList .chapter-row")).find((r) => r.dataset.id === id);
const moveUp = async (id) => {
rowFor(id).dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowUp", altKey: true, bubbles: true })
);
await new Promise((r) => setTimeout(r, 250));
};
// current order: [Alpha, Beta, Gamma]; move Gamma to front -> [Gamma, Alpha, Beta]
rowFor(gammaId).click();
await new Promise((r) => setTimeout(r, 200));
await moveUp(gammaId);
await moveUp(gammaId);
const orderReordered = (await window.folio.getBookMeta()).chapterOrder;
out.orderReordered = orderReordered;
out.reorderOk =
orderReordered[0] === gammaId && orderReordered[1] === alphaId && orderReordered[2] === betaId;
// Full Book follows the new chapter order and keeps every chapter's content
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const assembledGAB = fb.state.doc.toString();
out.fbAfterReorder = assembledGAB;
out.fbAfterReorderOk =
assembledGAB.startsWith("# Gamma\n\n# Gamma's Own Title") &&
assembledGAB.indexOf("# Alpha") > assembledGAB.indexOf("# Gamma") &&
assembledGAB.indexOf("# Beta") > assembledGAB.indexOf("# Alpha");
out.fbHasAllContent =
assembledGAB.includes("Gamma text") &&
assembledGAB.includes("Alpha body text") &&
assembledGAB.includes("Beta body without heading");
// switching between an individual chapter and Full Book never shows stale data
document.querySelector("#chapterList .chapter-row").click();
await new Promise((r) => setTimeout(r, 200));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
out.fbNoStale = fb.state.doc.toString() === assembledGAB;
// closing and reopening the book preserves the chapter order
await window.folio.openBookPath(BOOK2_DIR);
await new Promise((r) => setTimeout(r, 400));
const orderReopened = (await window.folio.getBookMeta()).chapterOrder;
out.orderAfterReopen = orderReopened;
out.reopenPersists = JSON.stringify(orderReopened) === JSON.stringify(orderReordered);
// deleting a chapter updates the sidebar and Full Book
rowFor(alphaId).querySelector('[data-act="del"]').click();
await new Promise((r) => setTimeout(r, 150));
document.getElementById("modalOk").click();
await new Promise((r) => setTimeout(r, 300));
out.orderAfterDelete = (await window.folio.getBookMeta()).chapterOrder;
out.deleteOk = !(await window.folio.getBookMeta()).chapterOrder.includes(alphaId);
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const assembledAfterDelete = fb.state.doc.toString();
out.fbAfterDelete = assembledAfterDelete;
out.fbAfterDeleteOk =
!assembledAfterDelete.includes("Alpha body text") && assembledAfterDelete.includes("Gamma text");
// renaming a chapter updates its Full Book heading
rowFor(betaId).querySelector('[data-act="rename"]').click();
await new Promise((r) => setTimeout(r, 150));
document.getElementById("modalInput").value = "Beta Renamed";
document.getElementById("modalOk").click();
await new Promise((r) => setTimeout(r, 300));
const assembledAfterRename = fb.state.doc.toString();
out.fbAfterRename = assembledAfterRename;
out.fbAfterRenameOk =
assembledAfterRename.includes("# Beta Renamed") &&
!assembledAfterRename.includes("# Beta\n\nBeta body without heading");
// a newly created empty chapter appears in Full Book with just its heading
const emptyId = (await window.folio.createChapter("Empty")).id;
await new Promise((r) => setTimeout(r, 300));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const assembledWithEmpty = fb.state.doc.toString();
out.fbWithEmpty = assembledWithEmpty;
out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed");
const okFlags = [
"dndMoved",
"kbMoved",
@ -297,6 +380,15 @@ const BOOK2_DIR = __BOOKDIR2_VALUE__;
"copyStatusShown",
"fullBookSelectAllOk",
"backToChapterOk",
"reorderOk",
"fbAfterReorderOk",
"fbHasAllContent",
"fbNoStale",
"reopenPersists",
"deleteOk",
"fbAfterDeleteOk",
"fbAfterRenameOk",
"fbEmptyOk",
];
out.all = okFlags.every((f) => out[f] === true);
} catch (e) {