Add in-app search across chapters and switch to manual save

- Search: a 'Search' button in the sidebar footer (or Ctrl/Cmd+F) opens a
  modal that scans every chapter, lists matches grouped by chapter with
  highlighted snippets, and jumps to and scrolls the match on click. Includes
  a 'Match case' toggle.
- Manual save: remove the per-keystroke auto-save; a chapter is only persisted
  via the new Save button or Ctrl/Cmd+S. Switching chapters or closing the book
  with unsaved edits now prompts to save first.
- Extend the packaged self-test to exercise both features.
This commit is contained in:
avi 2026-08-20 15:35:28 -05:00
commit aad1a9332a
4 changed files with 459 additions and 16 deletions

182
tests/selftest-scenario.js Normal file
View file

@ -0,0 +1,182 @@
// Packaged self-test scenario. Electron Builder ships this file inside the
// distributable (resources/app.asar/tests/selftest-scenario.js) and the main
// process executes it in the renderer (see src/main/index.ts FOLIO_SELF_TEST)
// only when the app is launched with FOLIO_SELF_TEST=1 and FOLIO_TEST_BOOK set.
// Normal launches never touch this file. It drives the real packaged binary
// through the same public API the UI uses and returns a summary JSON.
(async () => {
function withTimeout(p, ms, tag) {
return Promise.race([
p,
new Promise((r) => setTimeout(() => r({ ok: false, timedOut: tag }), ms)),
]);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function waitFor(fn, timeout = 10000, interval = 100) {
return new Promise((resolve) => {
const start = Date.now();
(function check() {
let ok = false;
try {
ok = fn();
} catch (e) {}
if (ok) return resolve(true);
if (Date.now() - start > timeout) return resolve(false);
setTimeout(check, interval);
})();
});
}
return withTimeout(
(async () => {
const out = { ok: false, steps: {} };
const step = (k, v) => {
out.steps[k] = v;
};
const BOOK = __BOOKDIR_VALUE__;
try {
const api = window.folio;
step("api", !!api);
const opened = await api.openBookPath(BOOK);
step("openBook", opened);
const editorMounted = await waitFor(() =>
!!document.querySelector("#editorHost .ProseMirror")
);
step("editorMounted", editorMounted);
const meta = await api.getBookMeta();
step("meta", {
title: meta && meta.title,
chapters: meta && meta.chapters ? Object.keys(meta.chapters).length : 0,
});
const chapters = await api.getChapterList();
step("chapterList", Array.isArray(chapters) ? chapters.length : "n/a");
// Promote legacy chapters to the native model so DOCX/ODT have content.
try {
step("migrate", await api.migrateBook());
} catch (e) {
step("migrate", "err:" + e.message);
}
// Theme switching must update the document attribute. Drive it through
// the actual UI control (the theme <select>), not just the stored pref.
const sel = document.getElementById("themeSelect");
if (sel) {
sel.value = "dark-mode";
sel.dispatchEvent(new Event("change", { bubbles: true }));
await sleep(200);
step("themeDark", document.documentElement.getAttribute("data-theme"));
sel.value = "light";
sel.dispatchEvent(new Event("change", { bubbles: true }));
await sleep(200);
step("themeLight", document.documentElement.getAttribute("data-theme"));
} else {
step("themeDark", "no-theme-select");
step("themeLight", "no-theme-select");
}
// Chapter navigation: clicking a chapter row selects it.
const row = document.querySelector("#chapterList li.chapter-row");
if (row) {
row.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await sleep(300);
}
step(
"navActive",
!!document.querySelector("#chapterList li.chapter-row.active")
);
// Manual save: typing marks the chapter Unsaved; the Save button (or
// Ctrl/Cmd+S) is required to persist. No auto-save should occur.
const ed = window.__folioEditor;
if (ed) {
ed.commands.focus("end");
ed.commands.insertContent(" manual-save-check");
await sleep(150);
const unsaved = document.getElementById("saveState");
step("afterTypeUnsaved", unsaved ? unsaved.textContent : "n/a");
const sb = document.getElementById("saveBtn");
if (sb) {
sb.click();
await sleep(400);
}
const after = document.getElementById("saveState");
step("afterSaveState", after ? after.textContent : "n/a");
} else {
step("afterTypeUnsaved", "no-editor");
}
// Full Book view.
const fb = document.getElementById("fullBookBtn");
if (fb) {
fb.click();
await sleep(1200);
}
const fbv = document.getElementById("fullBookView");
step("fullBookVisible", fbv ? !fbv.classList.contains("hidden") : false);
step(
"fullBookContent",
!!document.querySelector("#fullBookHost .ProseMirror") ||
!!document.querySelector("#fullBookHost *")
);
// Search across all chapters (the new feature): open the search UI,
// type a known term, confirm results, and jump to the first match.
const sb = document.getElementById("searchBtn");
if (sb) {
sb.click();
await sleep(120);
const si = document.getElementById("searchInput");
if (si) {
si.value = "bold";
si.dispatchEvent(new Event("input", { bubbles: true }));
await sleep(600);
}
step(
"searchResults",
document.querySelectorAll("#searchResults .search-result").length
);
const first = document.querySelector("#searchResults .search-result");
if (first) {
first.click();
await sleep(400);
}
const cv = document.getElementById("chapterView");
step("afterSearchChapterView", cv ? !cv.classList.contains("hidden") : false);
} else {
step("searchResults", "no-search-btn");
}
// Exports (save paths come from FOLIO_TEST_* env in the main process).
const ex = {};
try {
ex.pdf = await api.exportPdf(null);
} catch (e) {
ex.pdf = "err:" + e.message;
}
try {
ex.docx = await api.exportDocx(null);
} catch (e) {
ex.docx = "err:" + e.message;
}
try {
ex.odt = await api.exportOdt(null);
} catch (e) {
ex.odt = "err:" + e.message;
}
step("exports", ex);
out.ok = true;
} catch (e) {
out.error = String((e && e.stack) || e);
}
return out;
})(),
90000,
"scenario"
);
})();