refactor(renderer): Step 7 plain state object; fix(export): ZIP emits Markdown for native chapters

This commit is contained in:
avi 2026-09-19 18:21:48 -05:00
commit f1ee73f2a1
4 changed files with 18 additions and 70 deletions

View file

@ -1,5 +1,4 @@
import { ZipArchive } from "archiver"; import { ZipArchive } from "archiver";
import fs from "fs";
import path from "path"; import path from "path";
import { loadBook } from "./project.js"; import { loadBook } from "./project.js";
import { listChaptersInOrder, loadChapterDoc } from "./chapters.js"; import { listChaptersInOrder, loadChapterDoc } from "./chapters.js";
@ -52,13 +51,17 @@ export async function buildZip(bookPath: string): Promise<Buffer> {
archive.append(combineMarkdown(bookPath), { name: "book.md" }); archive.append(combineMarkdown(bookPath), { name: "book.md" });
for (const ch of chapters) { for (const ch of chapters) {
// Per-chapter entries are readable Markdown generated from the native
// document model (same source as book.md). Raw storage files are never
// leaked: a native chapter's .json would otherwise land in the bundle
// as opaque JSON next to readable .md files.
let content = ""; let content = "";
try { try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8"); content = nativeToMarkdown(loadChapterDoc(bookPath, ch.id));
} catch { } catch {
content = ""; content = "";
} }
const entryName = path.posix.join("chapters", path.basename(ch.file)); const entryName = path.posix.join("chapters", `${ch.id}.md`);
archive.append(content, { name: entryName }); archive.append(content, { name: entryName });
} }
@ -71,7 +74,7 @@ export async function buildZip(bookPath: string): Promise<Buffer> {
chapters: chapters.map((c) => ({ chapters: chapters.map((c) => ({
id: c.id, id: c.id,
title: c.title, title: c.title,
file: path.basename(c.file), file: `${c.id}.md`,
})), })),
}; };
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" }); archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });

View file

@ -1,10 +1,6 @@
// The chapter editing surface: main Tiptap editor instance, dirty-tracking + // The chapter editing surface: main Tiptap editor instance, dirty-tracking +
// autosave engine, chapter selection (with stale-load guard), zoom, and the // autosave engine, chapter selection (with stale-load guard), zoom, and the
// writing-statistics modal. // writing-statistics modal.
//
// Storage note: state.dirty / state.loadedDocJson are the shared contract
// fields; until Step 7 they are backed by the orchestrator's closure locals
// through the accessor bridge, so writes here are visible to every module.
import type { Editor } from "@tiptap/core"; import type { Editor } from "@tiptap/core";
import type { BookCtx } from "./types.js"; import type { BookCtx } from "./types.js";

View file

@ -96,8 +96,7 @@ export function createExportsFeature(ctx: BookCtx): ExportsFeature {
// Explicitly migrate the whole book to the native document format. The main // Explicitly migrate the whole book to the native document format. The main
// process performs a recovery backup and leaves the original .md files in // process performs a recovery backup and leaves the original .md files in
// place; after this, chapters are stored as native JSON but the (still // place; after this, chapters are stored as native JSON.
// Markdown-based) editor and exports keep working via the format bridge.
async function migrateBookNative(): Promise<void> { async function migrateBookNative(): Promise<void> {
if (!state.currentBook) return; if (!state.currentBook) return;
const confirmed = await confirmMessage( const confirmed = await confirmMessage(

View file

@ -1,6 +1,5 @@
import type { BookMeta, ChapterComment } from "../../main/project.js"; import type { FolioAPI } from "./types.js";
import { alertMessage, confirmMessage, promptInput } from "../dialog.js"; import { alertMessage, confirmMessage } from "../dialog.js";
import type { ChapterEntry, FolioAPI, Recent } from "./types.js";
import type { BookCtx, BookHooks, SharedState } from "./types.js"; import type { BookCtx, BookHooks, SharedState } from "./types.js";
import { createEditorFeature } from "./editor.js"; import { createEditorFeature } from "./editor.js";
import { createToolbarFeature } from "./toolbar.js"; import { createToolbarFeature } from "./toolbar.js";
@ -14,41 +13,21 @@ import { createSettingsFeature } from "./settings.js";
import { import {
$, $,
basename, basename,
closeEl,
hideUndoToast,
openEl,
selectedText,
showUndoToast,
showToast, showToast,
} from "./ui.js"; } from "./ui.js";
export type { ChapterSnapshot, FolioAPI } from "./types.js"; export type { ChapterSnapshot, FolioAPI } from "./types.js";
export function initBook(api: FolioAPI) { export function initBook(api: FolioAPI) {
let currentBook: { path: string; title: string } | null = null; // Shared mutable session state. Plain object literal: every feature module
let meta: BookMeta | null = null; // receives the same reference via ctx, so mutations stay visible everywhere.
let ordered: ChapterEntry[] = [];
let selectedId: string | null = null;
let dirty = false;
let loadedDocJson = "";
// ---- P7 decomposition bridge -------------------------------------------
// Temporary: exposes these closure locals through the SharedState contract so
// extracted feature modules share one source of truth. Removed in Step 7 when
// the last feature leaves and state becomes a plain object literal.
const state: SharedState = { const state: SharedState = {
get currentBook() { return currentBook; }, currentBook: null,
set currentBook(v) { currentBook = v; }, meta: null,
get meta() { return meta; }, ordered: [],
set meta(v) { meta = v; }, selectedId: null,
get ordered() { return ordered; }, dirty: false,
set ordered(v) { ordered = v; }, loadedDocJson: "",
get selectedId() { return selectedId; },
set selectedId(v) { selectedId = v; },
get dirty() { return dirty; },
set dirty(v) { dirty = v; },
get loadedDocJson() { return loadedDocJson; },
set loadedDocJson(v) { loadedDocJson = v; },
}; };
const hooks = {} as BookHooks; const hooks = {} as BookHooks;
const ctx: BookCtx = { api, state, hooks }; const ctx: BookCtx = { api, state, hooks };
@ -110,35 +89,6 @@ export function initBook(api: FolioAPI) {
// Wire comment facade from the extracted module. // Wire comment facade from the extracted module.
hooks.updateCommentsBadge = comments.updateCommentsBadge; hooks.updateCommentsBadge = comments.updateCommentsBadge;
// ---- element consts (kept from the original file) ----
const editorPlaceholder = $("editorPlaceholder");
const chapterView = $("chapterView");
const chapterTitleEl = $("chapterTitle");
const saveStateEl = $("saveState");
const exportBtn = $("exportBtn") as HTMLButtonElement;
const exportZipBtn = $("exportZipBtn") as HTMLButtonElement;
const projectBar = $("projectBar");
const fullBookNav = $("fullBookNav");
const fullBookBtn = $("fullBookBtn") as HTMLButtonElement;
const exportMenuToggle = $("exportMenuToggle") as HTMLButtonElement;
const exportMenu = $("exportMenu");
const exportDropdown = $("exportDropdown");
const welcomeEl = $("welcome");
const recentList = $("recentList") as HTMLUListElement;
// ---- welcome / recents logic (moved from inline into createWelcomeFeature) ----
// The welcome module owns its own DOM chrome and wiring; here we only keep the
// top-level constructor call and the bridge assignments it already set above.
// ---- export workflow (whole book vs selected chapters) ----
// The exports module owns its own DOM chrome and wiring; here we only keep the
// top-level constructor call and the bridge assignments it already set above.
// ---- comments / annotations ----
// The comments module owns its own DOM chrome and wiring; here we only keep the
// top-level constructor call and the bridge assignments it already set above.
// ---- update flow ---- // ---- update flow ----
// "Check for Updates…" (File menu / welcome button): query the release // "Check for Updates…" (File menu / welcome button): query the release
// server, then offer a one-click install. The app downloads the tarball, // server, then offer a one-click install. The app downloads the tarball,