Stage 3: chapter management — create/rename/delete/duplicate, sidebar list, select, read-only preview, word count, IPC + tests + e2e self-test

This commit is contained in:
avi 2026-08-16 14:34:34 -05:00
commit d3ab261346
11 changed files with 811 additions and 134 deletions

View file

@ -13,8 +13,9 @@
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
"start": "npm run build && npm run electron",
"lint": "tsc --noEmit",
"test": "node tests/run-project-test.mjs",
"package": "electron-builder"
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs",
"package": "electron-builder",
"test:e2e": "sh tests/run-e2e.sh"
},
"devDependencies": {
"@types/node": "^22.5.0",
@ -24,4 +25,4 @@
"typescript": "^5.5.0",
"wait-on": "^8.0.0"
}
}
}

108
src/main/chapters.ts Normal file
View file

@ -0,0 +1,108 @@
import fs from "fs";
import path from "path";
import {
BookMeta,
ChapterEntry,
chapterFileName,
chaptersDir,
loadBook,
makeChapterId,
saveMeta,
} from "./project.js";
export function listChaptersInOrder(meta: BookMeta): ChapterEntry[] {
return meta.chapterOrder
.map((id) => meta.chapters[id])
.filter(Boolean) as ChapterEntry[];
}
export function getChapterContent(bookPath: string, id: string): string {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
try {
return fs.readFileSync(path.join(bookPath, entry.file), "utf-8");
} catch {
return "";
}
}
export function createChapter(bookPath: string, title: string): ChapterEntry {
if (!title.trim()) throw new Error("Chapter title cannot be empty");
const meta = loadBook(bookPath);
const id = makeChapterId(title);
const file = path.posix.join("chapters", chapterFileName(id));
const absFile = path.join(bookPath, file);
let finalId = id;
let finalFile = file;
let counter = 2;
while (meta.chapters[finalId]) {
finalId = `${id}-${counter}`;
finalFile = path.posix.join("chapters", chapterFileName(finalId));
counter += 1;
}
meta.chapters[finalId] = { id: finalId, title, file: finalFile };
meta.chapterOrder.push(finalId);
saveMeta(bookPath, meta);
fs.mkdirSync(path.join(bookPath, "chapters"), { recursive: true });
fs.writeFileSync(path.join(bookPath, finalFile), "", "utf-8");
return { id: finalId, title, file: finalFile };
}
export function renameChapter(bookPath: string, id: string, newTitle: string): void {
if (!newTitle.trim()) throw new Error("Chapter title cannot be empty");
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
entry.title = newTitle;
saveMeta(bookPath, meta);
}
export function deleteChapter(bookPath: string, id: string): void {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
try {
fs.rmSync(path.join(bookPath, entry.file), { force: true });
} catch {
/* file may be absent */
}
delete meta.chapters[id];
meta.chapterOrder = meta.chapterOrder.filter((x) => x !== id);
saveMeta(bookPath, meta);
}
export function duplicateChapter(
bookPath: string,
id: string,
title?: string
): ChapterEntry {
const meta = loadBook(bookPath);
const src = meta.chapters[id];
if (!src) throw new Error("Chapter not found");
const baseTitle = title || `${src.title} (copy)`;
const srcContent = getChapterContent(bookPath, id);
let finalId = makeChapterId(baseTitle);
let counter = 2;
while (meta.chapters[finalId]) {
finalId = `${makeChapterId(baseTitle)}-${counter}`;
counter += 1;
}
const finalFile = path.posix.join("chapters", chapterFileName(finalId));
meta.chapters[finalId] = { id: finalId, title: baseTitle, file: finalFile };
meta.chapterOrder.push(finalId);
saveMeta(bookPath, meta);
fs.mkdirSync(path.join(bookPath, "chapters"), { recursive: true });
fs.writeFileSync(path.join(bookPath, finalFile), srcContent, "utf-8");
return { id: finalId, title: baseTitle, file: finalFile };
}
export function reorderChapters(bookPath: string, newOrder: string[]): void {
const meta = loadBook(bookPath);
const valid = new Set(Object.keys(meta.chapters));
if (!newOrder.every((id) => valid.has(id))) {
throw new Error("Invalid chapter order");
}
meta.chapterOrder = newOrder;
saveMeta(bookPath, meta);
}

View file

@ -1,5 +1,6 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import type { OpenDialogOptions } from "electron";
import fs from "fs";
import path from "path";
import {
BookResult,
@ -11,6 +12,15 @@ import {
} from "./project.js";
import { listRecents, addRecent } from "./recents.js";
import { buildMenu } from "./menu.js";
import {
createChapter,
deleteChapter,
duplicateChapter,
getChapterContent,
renameChapter,
reorderChapters,
listChaptersInOrder,
} from "./chapters.js";
let mainWindow: BrowserWindow | null = null;
let bookPath: string | null = null;
@ -42,9 +52,29 @@ function createWindow() {
},
});
mainWindow.loadFile(path.join(__dirname, "index.html"));
mainWindow.webContents.on("did-finish-load", () =>
console.log("[folio] renderer loaded")
);
mainWindow.webContents.on("did-finish-load", () => {
console.log("[folio] renderer loaded");
if (process.env.FOLIO_SELF_TEST === "1") {
const bookDir = process.env.FOLIO_TEST_BOOK;
if (bookDir) {
const scenarioPath = path.join(__dirname, "..", "tests", "selftest-scenario.js");
const scenario = fs
.readFileSync(scenarioPath, "utf-8")
.replace(/__BOOKDIR_VALUE__/, JSON.stringify(bookDir));
mainWindow?.webContents
.executeJavaScript(scenario, false)
.then((summary) => {
console.log("[selftest] " + JSON.stringify(summary));
app.quit();
})
.catch((e) => {
console.error("[selftest] ERROR", e);
app.quit();
});
return;
}
}
});
}
app.whenReady().then(() => {
@ -134,3 +164,79 @@ ipcMain.handle("folio:closeBook", () => {
getWindow()?.webContents.send("folio:bookClosed");
return true;
});
function requireBook(): string {
if (!bookPath) throw new Error("No book is open.");
return bookPath;
}
ipcMain.handle("folio:getBookMeta", () => {
try {
return loadBook(requireBook());
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:getChapterContent", (_e, id: string) => {
try {
return getChapterContent(requireBook(), id);
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:createChapter", (_e, title: string) => {
try {
const entry = createChapter(requireBook(), title);
return { id: entry.id, title: entry.title, file: entry.file };
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:renameChapter", (_e, id: string, title: string) => {
try {
renameChapter(requireBook(), id, title);
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:deleteChapter", (_e, id: string) => {
try {
deleteChapter(requireBook(), id);
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:duplicateChapter", (_e, id: string, title?: string) => {
try {
const entry = duplicateChapter(requireBook(), id, title);
return { id: entry.id, title: entry.title };
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:reorderChapters", (_e, newOrder: string[]) => {
try {
reorderChapters(requireBook(), newOrder);
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
});
// expose ordered chapter list for the renderer
ipcMain.handle("folio:getChapterList", () => {
try {
const meta = loadBook(requireBook());
return listChaptersInOrder(meta);
} catch (e) {
return { error: (e as Error).message };
}
});

View file

@ -1,12 +1,30 @@
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("folio", {
// book / project
newBook: () => ipcRenderer.invoke("folio:newBook"),
openBook: () => ipcRenderer.invoke("folio:openBook"),
openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p),
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
getRecents: () => ipcRenderer.invoke("folio:getRecents"),
closeBook: () => ipcRenderer.invoke("folio:closeBook"),
// chapters
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),
getChapterList: () => ipcRenderer.invoke("folio:getChapterList"),
getChapterContent: (id: string) => ipcRenderer.invoke("folio:getChapterContent", id),
createChapter: (title: string) =>
ipcRenderer.invoke("folio:createChapter", title),
renameChapter: (id: string, title: string) =>
ipcRenderer.invoke("folio:renameChapter", id, title),
deleteChapter: (id: string) =>
ipcRenderer.invoke("folio:deleteChapter", id),
duplicateChapter: (id: string, title?: string) =>
ipcRenderer.invoke("folio:duplicateChapter", id, title),
reorderChapters: (newOrder: string[]) =>
ipcRenderer.invoke("folio:reorderChapters", newOrder),
// events
onBookOpened: (cb: (data: { path: string; title: string }) => void) =>
ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)),
onBookClosed: (cb: () => void) =>

287
src/renderer/book.ts Normal file
View file

@ -0,0 +1,287 @@
import type { BookMeta } from "../main/project.js";
export type FolioAPI = {
newBook: () => Promise<any>;
openBook: () => Promise<any>;
openBookPath: (p: string) => Promise<any>;
revealInFolder: (p: string) => Promise<boolean>;
getRecents: () => Promise<any[]>;
closeBook: () => Promise<boolean>;
getBookMeta: () => Promise<BookMeta | { error: string }>;
getChapterList: () => Promise<any[]>;
getChapterContent: (id: string) => Promise<string | { error: string }>;
createChapter: (title: string) => Promise<any>;
renameChapter: (id: string, title: string) => Promise<any>;
deleteChapter: (id: string) => Promise<any>;
duplicateChapter: (id: string, title?: string) => Promise<any>;
reorderChapters: (newOrder: string[]) => Promise<any>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void;
onNewBookRequested: (cb: () => void) => void;
onOpenBookRequested: (cb: () => void) => void;
onOpenRecent: (cb: (p: string) => void) => void;
onRevealRequested: (cb: (p: string) => void) => void;
};
const $ = (id: string) => document.getElementById(id) as HTMLElement;
interface ChapterEntry {
id: string;
title: string;
file: string;
}
interface Recent {
path: string;
title: string;
lastOpened: string;
}
export function initBook(api: FolioAPI) {
let currentBook: { path: string; title: string } | null = null;
let meta: BookMeta | null = null;
let selectedId: string | null = null;
const chapterList = $("chapterList") as HTMLUListElement;
const editorPlaceholder = $("editorPlaceholder");
const chapterView = $("chapterView");
const chapterTitleEl = $("chapterTitle");
const chapterContentEl = $("chapterContent");
const wordCountEl = $("wordCount");
const projectBar = $("projectBar");
const bookTitleEl = $("bookTitle");
const bookPathEl = $("bookPath");
const welcome = $("welcome");
const newChapterBtn = $("newChapterBtn") as HTMLButtonElement;
const recentList = $("recentList") as HTMLUListElement;
function showWelcome() {
welcome.classList.remove("hidden");
chapterView.classList.add("hidden");
editorPlaceholder.classList.add("hidden");
projectBar.classList.add("hidden");
currentBook = null;
meta = null;
selectedId = null;
renderChapterList();
void renderRecents();
}
function showPlaceholder() {
chapterView.classList.add("hidden");
editorPlaceholder.classList.remove("hidden");
}
function openBook(data: { path: string; title: string }) {
currentBook = data;
bookTitleEl.textContent = data.title;
bookPathEl.textContent = data.path;
bookPathEl.title = data.path;
projectBar.classList.remove("hidden");
welcome.classList.add("hidden");
selectedId = null;
void loadMeta();
}
async function loadMeta() {
const res = await api.getBookMeta();
if (res && typeof res === "object" && "error" in res) {
alert((res as { error: string }).error);
meta = null;
} else {
meta = res as BookMeta;
}
renderChapterList();
showPlaceholder();
}
async function refreshMeta() {
await loadMeta();
if (meta && meta.chapters[selectedId ?? ""]) {
// keep selection if still valid
} else {
selectedId = null;
showPlaceholder();
}
}
function renderChapterList() {
chapterList.innerHTML = "";
if (!meta) return;
const ordered = meta.chapterOrder
.map((id) => meta!.chapters[id])
.filter(Boolean) as ChapterEntry[];
for (const ch of ordered) {
const li = document.createElement("li");
li.className = "chapter-row" + (selectedId === ch.id ? " active" : "");
li.dataset.id = ch.id;
li.innerHTML = `
<span class="row-title" title="${ch.title}">${ch.title}</span>
<span class="row-actions">
<button class="icon-btn" data-act="rename" title="Rename">✎</button>
<button class="icon-btn" data-act="dup" title="Duplicate">⧉</button>
<button class="icon-btn" data-act="del" title="Delete">×</button>
</span>`;
li.addEventListener("click", (e) => {
e.stopPropagation();
void selectChapter(ch.id);
});
li.querySelector('[data-act="rename"]')?.addEventListener("click", (e) => {
e.stopPropagation();
void rename(ch.id);
});
li.querySelector('[data-act="dup"]')?.addEventListener("click", (e) => {
e.stopPropagation();
void duplicate(ch.id);
});
li.querySelector('[data-act="del"]')?.addEventListener("click", (e) => {
e.stopPropagation();
void del(ch.id);
});
chapterList.appendChild(li);
}
}
async function selectChapter(id: string) {
selectedId = id;
renderChapterList();
try {
const content = await api.getChapterContent(id);
const text = typeof content === "string" ? content : "";
const title = (meta && meta.chapters[id]?.title) || id;
chapterTitleEl.textContent = title;
chapterContentEl.textContent = text;
wordCountEl.textContent = `words: ${countWords(text)}`;
chapterView.classList.remove("hidden");
editorPlaceholder.classList.add("hidden");
} catch {
showPlaceholder();
}
}
function countWords(text: string): number {
const trimmed = text.trim();
if (!trimmed) return 0;
return trimmed.split(/\s+/).length;
}
async function create() {
if (!meta) return;
const count = meta.chapterOrder.length + 1;
const title = prompt("Chapter title", `Chapter ${count}`);
if (!title || !title.trim()) return;
const res = await api.createChapter(title.trim());
if (res && "error" in res) {
alert((res as { error: string }).error);
} else if (res && "id" in res) {
await refreshMeta();
void selectChapter((res as ChapterEntry).id);
}
}
async function rename(id: string) {
if (!meta || !meta.chapters[id]) return;
const entry = meta.chapters[id];
const title = prompt("Rename chapter", entry.title);
if (!title || !title.trim()) return;
const res = await api.renameChapter(id, title.trim());
if (res && "error" in res) alert((res as { error: string }).error);
else {
await refreshMeta();
if (selectedId === id && meta && meta.chapters[id]) {
chapterTitleEl.textContent = meta.chapters[id].title;
}
}
}
async function del(id: string) {
if (!meta || !meta.chapters[id]) return;
const entry = meta.chapters[id];
if (!confirm(`Delete "${entry.title}"? This cannot be undone.`)) return;
const res = await api.deleteChapter(id);
if (res && "error" in res) alert((res as { error: string }).error);
else {
if (selectedId === id) {
selectedId = null;
showPlaceholder();
}
await refreshMeta();
}
}
async function duplicate(id: string) {
if (!meta || !meta.chapters[id]) return;
const entry = meta.chapters[id];
const title = prompt("Duplicate as:", `${entry.title} (copy)`);
if (title === null) return;
const res = await api.duplicateChapter(id, title.trim() || undefined);
if (res && "error" in res) alert((res as { error: string }).error);
else if (res && "id" in res) {
await refreshMeta();
void selectChapter((res as ChapterEntry).id);
}
}
async function handleResult(res: any) {
if (!res) return;
if ("canceled" in res) return;
if ("error" in res) {
alert((res as { error: string }).error);
return;
}
if ("path" in res) openBook({ path: res.path, title: res.title });
}
async function startNewBook() {
const res = await api.newBook();
await handleResult(res);
await renderRecents();
}
async function startOpenBook() {
const res = await api.openBook();
await handleResult(res);
await renderRecents();
}
async function openPath(p: string) {
const res = await api.openBookPath(p);
await handleResult(res);
await renderRecents();
}
async function renderRecents() {
const recents = (await api.getRecents()) as Recent[];
recentList.innerHTML = "";
if (!recents.length) {
const li = document.createElement("li");
li.className = "muted";
li.textContent = "No recent books.";
recentList.appendChild(li);
return;
}
for (const r of recents) {
const li = document.createElement("li");
li.textContent = r.title || r.path;
li.title = r.path;
li.addEventListener("click", () => void openPath(r.path));
recentList.appendChild(li);
}
}
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
newChapterBtn.addEventListener("click", () => void create());
$("revealBtn").addEventListener("click", () => {
if (currentBook) void api.revealInFolder(currentBook.path);
});
api.onBookOpened((data) => openBook(data));
api.onBookClosed(showWelcome);
api.onNewBookRequested(() => void startNewBook());
api.onOpenBookRequested(() => void startOpenBook());
api.onOpenRecent((p) => void openPath(p));
api.onRevealRequested((p) => void api.revealInFolder(p));
void renderRecents();
}

View file

@ -283,3 +283,115 @@ html, body {
.hidden {
display: none !important;
}
/* ---- chapter list ---- */
.chapter-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: var(--radius);
cursor: pointer;
font-family: var(--font-serif);
font-size: 0.95rem;
color: var(--color-text);
transition: background 0.12s ease, color 0.12s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.85;
}
.chapter-row:hover {
opacity: 1;
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
border: 1px solid var(--color-accent);
}
.chapter-row.active {
background: var(--color-accent);
color: #fff;
font-weight: 600;
}
.row-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 6px;
}
.row-actions {
display: none;
gap: 2px;
}
.chapter-row:hover .row-actions {
display: inline-flex;
}
.icon-btn {
border: 1px solid transparent;
background: transparent;
border-radius: calc(var(--radius) - 4px);
padding: 2px 6px;
font-size: 0.8rem;
line-height: 1;
cursor: pointer;
color: inherit;
opacity: 0.8;
transition: opacity 0.12s ease, background 0.12s ease, border-color 0.12s ease;
}
.icon-btn:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
/* ---- chapter view ---- */
.chapter-view {
flex: 1;
display: flex;
flex-direction: column;
padding: var(--gap);
overflow: auto;
}
.chapter-header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: var(--gap);
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--gap);
}
.chapter-title {
font-family: var(--font-serif);
font-size: 1.4rem;
font-weight: 600;
margin: 0;
color: var(--color-text);
}
.word-count {
font-size: 0.72rem;
color: var(--color-muted);
}
.chapter-content {
flex: 1;
font-family: var(--font-serif);
font-size: 1.05rem;
line-height: 1.7;
color: var(--color-text);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 0;
outline: none;
max-width: 720px;
}

View file

@ -37,6 +37,13 @@
</ul>
</div>
</div>
<div class="chapter-view hidden" id="chapterView">
<header class="chapter-header">
<h2 class="chapter-title" id="chapterTitle"></h2>
<span class="word-count" id="wordCount"></span>
</header>
<pre class="chapter-content" id="chapterContent"></pre>
</div>
<div class="editor-placeholder hidden" id="editorPlaceholder">
<p>Select a chapter to begin writing.</p>
</div>

View file

@ -1,20 +1,5 @@
type BookResultMsg = { path: string; title: string } | { canceled: true } | { error: string } | null;
export {};
type FolioAPI = {
newBook: () => Promise<BookResultMsg>;
openBook: () => Promise<BookResultMsg>;
openBookPath: (p: string) => Promise<BookResultMsg>;
revealInFolder: (p: string) => Promise<boolean>;
getRecents: () => Promise<any[]>;
closeBook: () => Promise<boolean>;
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
onBookClosed: (cb: () => void) => void;
onNewBookRequested: (cb: () => void) => void;
onOpenBookRequested: (cb: () => void) => void;
onOpenRecent: (cb: (p: string) => void) => void;
onRevealRequested: (cb: (p: string) => void) => void;
};
import { initBook } from "./book.js";
import type { FolioAPI } from "./book.js";
declare global {
interface Window {
@ -22,116 +7,6 @@ declare global {
}
}
const $ = (id: string) => document.getElementById(id) as HTMLElement;
const welcome = $("welcome");
const placeholder = $("editorPlaceholder");
const projectBar = $("projectBar");
const bookTitle = $("bookTitle");
const bookPath = $("bookPath");
const revealBtn = $("revealBtn") as HTMLButtonElement;
const newChapterBtn = $("newChapterBtn") as HTMLButtonElement;
const recentList = $("recentList");
let currentBook: { path: string; title: string } | null = null;
function showBook() {
welcome.classList.add("hidden");
placeholder.classList.remove("hidden");
projectBar.classList.remove("hidden");
}
function showWelcome() {
welcome.classList.remove("hidden");
placeholder.classList.add("hidden");
projectBar.classList.add("hidden");
}
function setCurrentBook(book: { path: string; title: string }) {
currentBook = book;
bookTitle.textContent = book.title;
bookPath.textContent = book.path;
bookPath.title = book.path;
showBook();
void refreshRecents();
}
function clearBook() {
currentBook = null;
showWelcome();
}
function handleResult(res: BookResultMsg) {
if (!res) return;
if ("canceled" in res) return;
if ("error" in res) {
alert(res.error);
return;
}
setCurrentBook({ path: res.path, title: res.title });
}
async function startNewBook() {
const res = await window.folio.newBook();
handleResult(res);
}
async function startOpenBook() {
const res = await window.folio.openBook();
handleResult(res);
}
async function openPath(p: string) {
const res = await window.folio.openBookPath(p);
handleResult(res);
}
function renderRecents(recents: any[]) {
recentList.innerHTML = "";
if (!recents.length) {
const li = document.createElement("li");
li.className = "muted";
li.textContent = "No recent books.";
recentList.appendChild(li);
return;
}
for (const r of recents as { title: string; path: string }[]) {
const li = document.createElement("li");
li.textContent = r.title || r.path;
li.title = r.path;
li.addEventListener("mousedown", (e) => {
e.preventDefault(); // prevent sidebar blur/focus issues
void openPath(r.path);
});
recentList.appendChild(li);
}
}
async function refreshRecents() {
const recents = await window.folio.getRecents();
renderRecents(recents);
}
function setupButtons() {
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
newChapterBtn.addEventListener("click", () => alert("Creating chapters is part of the next stage."));
revealBtn.addEventListener("click", () => {
if (currentBook) void window.folio.revealInFolder(currentBook.path);
});
}
function setupListeners() {
window.folio.onBookOpened((data) => setCurrentBook({ path: data.path, title: data.title }));
window.folio.onBookClosed(clearBook);
window.folio.onNewBookRequested(() => void startNewBook());
window.folio.onOpenBookRequested(() => void startOpenBook());
window.folio.onOpenRecent((p) => void openPath(p));
window.folio.onRevealRequested((p) => void window.folio.revealInFolder(p));
}
document.addEventListener("DOMContentLoaded", () => {
setupButtons();
setupListeners();
void refreshRecents();
initBook(window.folio);
});

113
tests/run-chapters-test.mjs Normal file
View file

@ -0,0 +1,113 @@
// Standalone test for chapter CRUD logic.
// Run with: node tests/run-chapters-test.mjs
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
const projOut = path.join(os.tmpdir(), `folio-project-${process.pid}.mjs`);
const chapOut = path.join(os.tmpdir(), `folio-chapters-${process.pid}.mjs`);
await esbuild.build({
entryPoints: [path.resolve("src/main/project.ts")],
bundle: true,
platform: "node",
format: "esm",
outfile: projOut,
write: true,
logLevel: "silent",
});
await esbuild.build({
entryPoints: [path.resolve("src/main/chapters.ts")],
bundle: true,
platform: "node",
format: "esm",
outfile: chapOut,
write: true,
logLevel: "silent",
});
const project = await import(url.pathToFileURL(projOut).href);
const chapters = await import(url.pathToFileURL(chapOut).href);
const { createBook, loadBook, saveMeta, hasBook } = project;
const {
listChaptersInOrder,
getChapterContent,
createChapter,
renameChapter,
deleteChapter,
duplicateChapter,
reorderChapters,
} = chapters;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { failures++; console.error("FAIL -", msg); }
}
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-ch-"));
createBook(dir, "Test Book");
// create
const c1 = createChapter(dir, "Introduction");
ok(c1.id.startsWith("introduction-"), "create returns id slug: " + c1.id);
ok(fs.existsSync(path.join(dir, c1.file)) && fs.existsSync(path.join(dir, "chapters")), "chapter file + chapters dir created");
const c2 = createChapter(dir, "Money");
const c3 = createChapter(dir, "Conclusion");
const meta = loadBook(dir);
ok(meta.chapterOrder.length === 3, "chapterOrder has 3 chapters");
ok(listChaptersInOrder(meta).length === 3, "listChaptersInOrder returns 3");
// content default empty
ok(getChapterContent(dir, c1.id) === "", "new chapter content empty");
// write content + read back
fs.writeFileSync(path.join(dir, c1.file), "Hello world this is text", "utf-8");
ok(getChapterContent(dir, c1.id) === "Hello world this is text", "chapter content read back");
// getChapterContent throws on unknown id
let threw = false;
try { getChapterContent(dir, "nope"); } catch { threw = true; }
ok(threw, "getChapterContent throws on unknown id");
// rename
renameChapter(dir, c2.id, "Economics");
ok(loadBook(dir).chapters[c2.id].title === "Economics", "rename updates title");
// duplicate
const dup = duplicateChapter(dir, c1.id);
ok(dup.title === "Introduction (copy)", "duplicate default title");
ok(dup.id !== c1.id, "duplicate has new id");
ok(getChapterContent(dir, dup.id) === "Hello world this is text", "duplicate copies content");
ok(loadBook(dir).chapterOrder.slice(-1)[0] === dup.id, "duplicate appended to order");
// delete
const c3File = path.join(dir, c3.file);
deleteChapter(dir, c3.id);
const mAfter = loadBook(dir);
ok(!mAfter.chapters[c3.id], "chapter removed from map");
ok(!mAfter.chapterOrder.includes(c3.id), "chapter removed from order");
ok(!fs.existsSync(c3File), "deleted chapter file removed from disk");
// reorder
const order = loadBook(dir).chapterOrder;
reorderChapters(dir, [order[1], order[0]]);
ok(loadBook(dir).chapterOrder[0] === order[1] && loadBook(dir).chapterOrder[1] === order[0], "reorder persists");
// reorder rejects invalid id
threw = false;
try { reorderChapters(dir, ["bogus", order[0]]); } catch { threw = true; }
ok(threw, "reorder throws on invalid id");
// empty title throws
threw = false;
try { createChapter(dir, " "); } catch { threw = true; }
ok(threw, "createChapter rejects empty title");
fs.rmSync(projOut, { force: true });
fs.rmSync(chapOut, { force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log("\nChapter tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
process.exit(failures === 0 ? 0 : 1);

11
tests/run-e2e.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env sh
# End-to-end self-test runner (Linux, requires xvfb-run for headless).
# Creates a throwaway book, opens it via folio:openBookPath (no dialogs),
# runs the full chapter CRUD scenario through the real renderer -> IPC -> chapters
# chain, prints a JSON summary, and quits. Inert unless run explicitly.
set -e
BOOK=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");const d=fs.mkdtempSync(path.join(os.tmpdir(),"folio-e2e-"));fs.mkdirSync(path.join(d,"chapters"),{recursive:true});fs.writeFileSync(path.join(d,"folio.json"),JSON.stringify({app:"folio",version:1,title:"E2E Book",chapterOrder:[],chapters:{}}));process.stdout.write(d);')
export FOLIO_SELF_TEST=1
export FOLIO_TEST_BOOK="$BOOK"
trap 'rm -rf "$BOOK"' EXIT
xvfb-run -a electron .

View file

@ -0,0 +1,39 @@
/* Selftest scenario - runs inside the renderer via executeJavaScript.
Injected only when FOLIO_SELF_TEST=1. Exercises the full
renderer -> preload -> IPC -> chapters chain. */
const BOOK_DIR = __BOOKDIR_VALUE__;
(async () => {
const out = {};
try {
out.folioType = typeof window.folio;
out.recentsCount = (await window.folio.getRecents()).length;
out.openBook = await window.folio.openBookPath(BOOK_DIR);
out.metaTitle = (await window.folio.getBookMeta()).title;
const created = await window.folio.createChapter("Introduction");
out.created = created && !created.error ? { id: created.id, title: created.title } : created;
const cid = created.id;
out.contentEmpty = await window.folio.getChapterContent(cid);
out.listLen1 = (await window.folio.getChapterList()).length;
out.renamed = await window.folio.renameChapter(cid, "Intro");
const dup = await window.folio.duplicateChapter(cid);
out.dup = dup && !dup.error ? { id: dup.id, title: dup.title } : dup;
const did = dup.id;
out.listLen2 = (await window.folio.getChapterList()).length;
out.deleted = await window.folio.deleteChapter(cid);
const remaining = (await window.folio.getChapterList())[0];
out.listLen3 = (await window.folio.getChapterList()).length;
out.reorderOk = await window.folio.reorderChapters([remaining.id]);
out.reorderErr = await window.folio.reorderChapters(["bogus-id"]);
out.dupContent = await window.folio.getChapterContent(did);
out.all = true;
} catch (e) {
out.error = String((e && e.message) || e);
}
return out;
})();