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:
parent
2862c433bd
commit
d3ab261346
11 changed files with 811 additions and 134 deletions
|
|
@ -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",
|
||||
|
|
|
|||
108
src/main/chapters.ts
Normal file
108
src/main/chapters.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
287
src/renderer/book.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
113
tests/run-chapters-test.mjs
Normal 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
11
tests/run-e2e.sh
Executable 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 .
|
||||
39
tests/selftest-scenario.js
Normal file
39
tests/selftest-scenario.js
Normal 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;
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue