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

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);
});