diff --git a/package.json b/package.json index feff2ed..6faf269 100644 --- a/package.json +++ b/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/src/main/chapters.ts b/src/main/chapters.ts new file mode 100644 index 0000000..08bab3b --- /dev/null +++ b/src/main/chapters.ts @@ -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); +} diff --git a/src/main/index.ts b/src/main/index.ts index c52d8ae..ffd0fc0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 }; + } +}); diff --git a/src/main/preload.ts b/src/main/preload.ts index 2b86138..2b4374c 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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) => diff --git a/src/renderer/book.ts b/src/renderer/book.ts new file mode 100644 index 0000000..19c07a4 --- /dev/null +++ b/src/renderer/book.ts @@ -0,0 +1,287 @@ +import type { BookMeta } from "../main/project.js"; + +export type FolioAPI = { + newBook: () => Promise; + openBook: () => Promise; + openBookPath: (p: string) => Promise; + revealInFolder: (p: string) => Promise; + getRecents: () => Promise; + closeBook: () => Promise; + getBookMeta: () => Promise; + getChapterList: () => Promise; + getChapterContent: (id: string) => Promise; + createChapter: (title: string) => Promise; + renameChapter: (id: string, title: string) => Promise; + deleteChapter: (id: string) => Promise; + duplicateChapter: (id: string, title?: string) => Promise; + reorderChapters: (newOrder: string[]) => Promise; + 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 = ` + ${ch.title} + + + + + `; + 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(); +} diff --git a/src/renderer/index.css b/src/renderer/index.css index cec2790..15c676f 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index ac33b82..05d9db8 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -37,6 +37,13 @@ + diff --git a/src/renderer/index.ts b/src/renderer/index.ts index cd41b21..a5203b2 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -1,20 +1,5 @@ -type BookResultMsg = { path: string; title: string } | { canceled: true } | { error: string } | null; -export {}; - -type FolioAPI = { - newBook: () => Promise; - openBook: () => Promise; - openBookPath: (p: string) => Promise; - revealInFolder: (p: string) => Promise; - getRecents: () => Promise; - closeBook: () => Promise; - 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); }); diff --git a/tests/run-chapters-test.mjs b/tests/run-chapters-test.mjs new file mode 100644 index 0000000..5ea3237 --- /dev/null +++ b/tests/run-chapters-test.mjs @@ -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); diff --git a/tests/run-e2e.sh b/tests/run-e2e.sh new file mode 100755 index 0000000..492d33c --- /dev/null +++ b/tests/run-e2e.sh @@ -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 . diff --git a/tests/selftest-scenario.js b/tests/selftest-scenario.js new file mode 100644 index 0000000..4c6eef9 --- /dev/null +++ b/tests/selftest-scenario.js @@ -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; +})();