diff --git a/.gitignore b/.gitignore index 1b39c0a..ff81fda 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist/ releases/ .DS_Store tests/.tmp-folio-format-*.mjs + +.opencode/ diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 40ceb38..992cac1 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -21,6 +21,10 @@ const copyAssets = { path.join("src", "renderer", "themes.css"), path.join("dist", "themes.css") ); + fs.copyFileSync( + path.join("src", "renderer", "fonts.css"), + path.join("dist", "fonts.css") + ); fs.mkdirSync(path.join("dist", "icons"), { recursive: true }); fs.copyFileSync( path.join("build", "icons", "256x256.png"), @@ -34,6 +38,14 @@ const copyAssets = { path.join("build", "brand-icons", "128x128-black.png"), path.join("dist", "brand-icon-black.png") ); + // Bundle locally-stored fonts so the app needs no network at runtime. + fs.mkdirSync(path.join("dist", "fonts"), { recursive: true }); + const fontsDir = path.join("public", "fonts"); + if (fs.existsSync(fontsDir)) { + for (const f of fs.readdirSync(fontsDir)) { + fs.copyFileSync(path.join(fontsDir, f), path.join("dist", "fonts", f)); + } + } }); }, }; diff --git a/public/fonts/dm-sans-bold.woff2 b/public/fonts/dm-sans-bold.woff2 new file mode 100644 index 0000000..26edc56 Binary files /dev/null and b/public/fonts/dm-sans-bold.woff2 differ diff --git a/public/fonts/dm-sans-regular.woff2 b/public/fonts/dm-sans-regular.woff2 new file mode 100644 index 0000000..0b8bc55 Binary files /dev/null and b/public/fonts/dm-sans-regular.woff2 differ diff --git a/public/fonts/inter-bold.woff2 b/public/fonts/inter-bold.woff2 new file mode 100644 index 0000000..a68fb10 Binary files /dev/null and b/public/fonts/inter-bold.woff2 differ diff --git a/public/fonts/inter-regular.woff2 b/public/fonts/inter-regular.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/public/fonts/inter-regular.woff2 differ diff --git a/public/fonts/lato-bold.woff2 b/public/fonts/lato-bold.woff2 new file mode 100644 index 0000000..11de83f Binary files /dev/null and b/public/fonts/lato-bold.woff2 differ diff --git a/public/fonts/lato-regular.woff2 b/public/fonts/lato-regular.woff2 new file mode 100644 index 0000000..ff60934 Binary files /dev/null and b/public/fonts/lato-regular.woff2 differ diff --git a/public/fonts/libre-baskerville-bold.woff2 b/public/fonts/libre-baskerville-bold.woff2 new file mode 100644 index 0000000..91db8ae Binary files /dev/null and b/public/fonts/libre-baskerville-bold.woff2 differ diff --git a/public/fonts/libre-baskerville-regular.woff2 b/public/fonts/libre-baskerville-regular.woff2 new file mode 100644 index 0000000..c32fb72 Binary files /dev/null and b/public/fonts/libre-baskerville-regular.woff2 differ diff --git a/public/fonts/merriweather-bold.woff2 b/public/fonts/merriweather-bold.woff2 new file mode 100644 index 0000000..1c1896b Binary files /dev/null and b/public/fonts/merriweather-bold.woff2 differ diff --git a/public/fonts/merriweather-regular.woff2 b/public/fonts/merriweather-regular.woff2 new file mode 100644 index 0000000..824753f Binary files /dev/null and b/public/fonts/merriweather-regular.woff2 differ diff --git a/public/fonts/montserrat-bold.woff2 b/public/fonts/montserrat-bold.woff2 new file mode 100644 index 0000000..ff9d6a7 Binary files /dev/null and b/public/fonts/montserrat-bold.woff2 differ diff --git a/public/fonts/montserrat-regular.woff2 b/public/fonts/montserrat-regular.woff2 new file mode 100644 index 0000000..6fbeafa Binary files /dev/null and b/public/fonts/montserrat-regular.woff2 differ diff --git a/public/fonts/noto-sans-bold.woff2 b/public/fonts/noto-sans-bold.woff2 new file mode 100644 index 0000000..feff1bf Binary files /dev/null and b/public/fonts/noto-sans-bold.woff2 differ diff --git a/public/fonts/noto-sans-regular.woff2 b/public/fonts/noto-sans-regular.woff2 new file mode 100644 index 0000000..f9e0a65 Binary files /dev/null and b/public/fonts/noto-sans-regular.woff2 differ diff --git a/public/fonts/nunito-bold.woff2 b/public/fonts/nunito-bold.woff2 new file mode 100644 index 0000000..5b423ba Binary files /dev/null and b/public/fonts/nunito-bold.woff2 differ diff --git a/public/fonts/nunito-regular.woff2 b/public/fonts/nunito-regular.woff2 new file mode 100644 index 0000000..8c68e57 Binary files /dev/null and b/public/fonts/nunito-regular.woff2 differ diff --git a/public/fonts/open-sans-bold.woff2 b/public/fonts/open-sans-bold.woff2 new file mode 100644 index 0000000..f5977a4 Binary files /dev/null and b/public/fonts/open-sans-bold.woff2 differ diff --git a/public/fonts/open-sans-regular.woff2 b/public/fonts/open-sans-regular.woff2 new file mode 100644 index 0000000..e2d3fa4 Binary files /dev/null and b/public/fonts/open-sans-regular.woff2 differ diff --git a/public/fonts/oswald-bold.woff2 b/public/fonts/oswald-bold.woff2 new file mode 100644 index 0000000..e1290c1 Binary files /dev/null and b/public/fonts/oswald-bold.woff2 differ diff --git a/public/fonts/oswald-regular.woff2 b/public/fonts/oswald-regular.woff2 new file mode 100644 index 0000000..3387150 Binary files /dev/null and b/public/fonts/oswald-regular.woff2 differ diff --git a/public/fonts/playfair-display-bold.woff2 b/public/fonts/playfair-display-bold.woff2 new file mode 100644 index 0000000..39eefe0 Binary files /dev/null and b/public/fonts/playfair-display-bold.woff2 differ diff --git a/public/fonts/playfair-display-regular.woff2 b/public/fonts/playfair-display-regular.woff2 new file mode 100644 index 0000000..141ea84 Binary files /dev/null and b/public/fonts/playfair-display-regular.woff2 differ diff --git a/public/fonts/poppins-bold.woff2 b/public/fonts/poppins-bold.woff2 new file mode 100644 index 0000000..bf022fc Binary files /dev/null and b/public/fonts/poppins-bold.woff2 differ diff --git a/public/fonts/poppins-regular.woff2 b/public/fonts/poppins-regular.woff2 new file mode 100644 index 0000000..b69e009 Binary files /dev/null and b/public/fonts/poppins-regular.woff2 differ diff --git a/public/fonts/pt-sans-bold.woff2 b/public/fonts/pt-sans-bold.woff2 new file mode 100644 index 0000000..b4bbe89 Binary files /dev/null and b/public/fonts/pt-sans-bold.woff2 differ diff --git a/public/fonts/pt-sans-regular.woff2 b/public/fonts/pt-sans-regular.woff2 new file mode 100644 index 0000000..0a75bf9 Binary files /dev/null and b/public/fonts/pt-sans-regular.woff2 differ diff --git a/public/fonts/quicksand-bold.woff2 b/public/fonts/quicksand-bold.woff2 new file mode 100644 index 0000000..42f7a0b Binary files /dev/null and b/public/fonts/quicksand-bold.woff2 differ diff --git a/public/fonts/quicksand-regular.woff2 b/public/fonts/quicksand-regular.woff2 new file mode 100644 index 0000000..8018cfd Binary files /dev/null and b/public/fonts/quicksand-regular.woff2 differ diff --git a/public/fonts/raleway-bold.woff2 b/public/fonts/raleway-bold.woff2 new file mode 100644 index 0000000..90960e6 Binary files /dev/null and b/public/fonts/raleway-bold.woff2 differ diff --git a/public/fonts/raleway-regular.woff2 b/public/fonts/raleway-regular.woff2 new file mode 100644 index 0000000..b8dd014 Binary files /dev/null and b/public/fonts/raleway-regular.woff2 differ diff --git a/public/fonts/roboto-bold.woff2 b/public/fonts/roboto-bold.woff2 new file mode 100644 index 0000000..85b8ace Binary files /dev/null and b/public/fonts/roboto-bold.woff2 differ diff --git a/public/fonts/roboto-regular.woff2 b/public/fonts/roboto-regular.woff2 new file mode 100644 index 0000000..77e4259 Binary files /dev/null and b/public/fonts/roboto-regular.woff2 differ diff --git a/public/fonts/roboto-slab-bold.woff2 b/public/fonts/roboto-slab-bold.woff2 new file mode 100644 index 0000000..8b3858c Binary files /dev/null and b/public/fonts/roboto-slab-bold.woff2 differ diff --git a/public/fonts/roboto-slab-regular.woff2 b/public/fonts/roboto-slab-regular.woff2 new file mode 100644 index 0000000..490784f Binary files /dev/null and b/public/fonts/roboto-slab-regular.woff2 differ diff --git a/public/fonts/source-sans-3-bold.woff2 b/public/fonts/source-sans-3-bold.woff2 new file mode 100644 index 0000000..3cb944c Binary files /dev/null and b/public/fonts/source-sans-3-bold.woff2 differ diff --git a/public/fonts/source-sans-3-regular.woff2 b/public/fonts/source-sans-3-regular.woff2 new file mode 100644 index 0000000..0c4242e Binary files /dev/null and b/public/fonts/source-sans-3-regular.woff2 differ diff --git a/public/fonts/ubuntu-bold.woff2 b/public/fonts/ubuntu-bold.woff2 new file mode 100644 index 0000000..8eaed1f Binary files /dev/null and b/public/fonts/ubuntu-bold.woff2 differ diff --git a/public/fonts/ubuntu-regular.woff2 b/public/fonts/ubuntu-regular.woff2 new file mode 100644 index 0000000..8cb3d32 Binary files /dev/null and b/public/fonts/ubuntu-regular.woff2 differ diff --git a/public/fonts/work-sans-bold.woff2 b/public/fonts/work-sans-bold.woff2 new file mode 100644 index 0000000..8236077 Binary files /dev/null and b/public/fonts/work-sans-bold.woff2 differ diff --git a/public/fonts/work-sans-regular.woff2 b/public/fonts/work-sans-regular.woff2 new file mode 100644 index 0000000..02a6a42 Binary files /dev/null and b/public/fonts/work-sans-regular.woff2 differ diff --git a/scripts/fetch-fonts.mjs b/scripts/fetch-fonts.mjs new file mode 100644 index 0000000..6d7aed3 --- /dev/null +++ b/scripts/fetch-fonts.mjs @@ -0,0 +1,108 @@ +// One-time setup script: copy locally-bundled Google Fonts (.woff2) into +// public/fonts and generate the offline @font-face CSS (src/renderer/fonts.css). +// +// Run ONCE while online (or with @fontsource packages already installed via npm): +// node scripts/fetch-fonts.mjs +// +// After this, the app never requests fonts from the network at runtime. +// +// Fonts are sourced from the @fontsource packages (already in node_modules +// after `npm i @fontsource/`). If a package is missing, the script +// falls back to downloading the file from the jsDelivr CDN. + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); + +// Each entry: display name, @fontsource package, file slug, CSS fallback. +const FONTS = [ + { name: "Inter", pkg: "inter", slug: "inter", fallback: "sans-serif" }, + { name: "Roboto", pkg: "roboto", slug: "roboto", fallback: "sans-serif" }, + { name: "Open Sans", pkg: "open-sans", slug: "open-sans", fallback: "sans-serif" }, + { name: "Lato", pkg: "lato", slug: "lato", fallback: "sans-serif" }, + { name: "Montserrat", pkg: "montserrat", slug: "montserrat", fallback: "sans-serif" }, + { name: "Poppins", pkg: "poppins", slug: "poppins", fallback: "sans-serif" }, + { name: "Oswald", pkg: "oswald", slug: "oswald", fallback: "sans-serif" }, + { name: "Roboto Slab", pkg: "roboto-slab", slug: "roboto-slab", fallback: "serif" }, + { name: "Merriweather", pkg: "merriweather", slug: "merriweather", fallback: "serif" }, + { name: "Playfair Display", pkg: "playfair-display", slug: "playfair-display", fallback: "serif" }, + { name: "Raleway", pkg: "raleway", slug: "raleway", fallback: "sans-serif" }, + { name: "Nunito", pkg: "nunito", slug: "nunito", fallback: "sans-serif" }, + { name: "Ubuntu", pkg: "ubuntu", slug: "ubuntu", fallback: "sans-serif" }, + { name: "Source Sans 3", pkg: "source-sans-3", slug: "source-sans-3", fallback: "sans-serif" }, + { name: "Noto Sans", pkg: "noto-sans", slug: "noto-sans", fallback: "sans-serif" }, + { name: "PT Sans", pkg: "pt-sans", slug: "pt-sans", fallback: "sans-serif" }, + { name: "Libre Baskerville", pkg: "libre-baskerville", slug: "libre-baskerville", fallback: "serif" }, + { name: "DM Sans", pkg: "dm-sans", slug: "dm-sans", fallback: "sans-serif" }, + { name: "Quicksand", pkg: "quicksand", slug: "quicksand", fallback: "sans-serif" }, + { name: "Work Sans", pkg: "work-sans", slug: "work-sans", fallback: "sans-serif" }, +]; + +const publicFonts = path.join(root, "public", "fonts"); +fs.mkdirSync(publicFonts, { recursive: true }); + +async function copyOrDownload(srcRel, destFile, cdnUrl) { + const src = path.join(root, srcRel); + const dest = path.join(publicFonts, destFile); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + return true; + } + // Fallback: download once from jsDelivr CDN. + try { + const res = await fetch(cdnUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + fs.writeFileSync(dest, buf); + return true; + } catch (err) { + console.error(` ! failed: ${destFile} (${err.message})`); + return false; + } +} + +let css = "/* AUTO-GENERATED by scripts/fetch-fonts.mjs — do not edit by hand.\n" + + " All fonts are bundled locally; no network requests at runtime. */\n\n"; + +let ok = 0; +const report = []; +for (const f of FONTS) { + const regularDest = `${f.slug}-regular.woff2`; + const boldDest = `${f.slug}-bold.woff2`; + const rOk = await copyOrDownload( + `node_modules/@fontsource/${f.pkg}/files/${f.slug}-latin-400-normal.woff2`, + regularDest, + `https://cdn.jsdelivr.net/npm/@fontsource/${f.pkg}/files/${f.slug}-latin-400-normal.woff2` + ); + const bOk = await copyOrDownload( + `node_modules/@fontsource/${f.pkg}/files/${f.slug}-latin-700-normal.woff2`, + boldDest, + `https://cdn.jsdelivr.net/npm/@fontsource/${f.pkg}/files/${f.slug}-latin-700-normal.woff2` + ); + if (rOk && bOk) ok++; + report.push(` ${f.name}: public/fonts/${regularDest} + public/fonts/${boldDest}`); + css += `@font-face {\n` + + ` font-family: "${f.name}";\n` + + ` font-style: normal;\n` + + ` font-weight: 400;\n` + + ` font-display: swap;\n` + + ` src: url("./fonts/${regularDest}") format("woff2");\n` + + `}\n` + + `@font-face {\n` + + ` font-family: "${f.name}";\n` + + ` font-style: normal;\n` + + ` font-weight: 700;\n` + + ` font-display: swap;\n` + + ` src: url("./fonts/${boldDest}") format("woff2");\n` + + `}\n\n`; +} + +fs.writeFileSync(path.join(root, "src", "renderer", "fonts.css"), css); + +console.log(`Copied ${ok}/${FONTS.length} font families (2 files each).`); +console.log("Files placed in public/fonts/:"); +console.log(report.join("\n")); +console.log(`\nGenerated src/renderer/fonts.css (${ok * 2} @font-face rules).`); diff --git a/src/main/backup.ts b/src/main/backup.ts index 78b1a28..7e74bc6 100644 --- a/src/main/backup.ts +++ b/src/main/backup.ts @@ -9,7 +9,29 @@ import fs from "fs"; import os from "os"; import path from "path"; -export const RECOVERY_ROOT = path.join(os.homedir(), "Folio-Recovery"); +// Settings bridge, wired by the main process (bindBackupSettings). Keeping +// settings access behind injection lets this module stay Electron-free so +// the test harness can bundle it directly. +interface BackupSettingsStore { + get(key: string, fallback: T): T; + set(key: string, value: unknown): void; +} +let store: BackupSettingsStore | null = null; + +export function bindBackupSettings(s: BackupSettingsStore): void { + store = s; +} + +export function getRecoveryRoot(): string { + const custom = store ? store.get("backupLocation", "") : ""; + if (custom && custom.trim()) return path.resolve(custom.trim()); + return path.join(os.homedir(), "Folio-Recovery"); +} + +export function setRecoveryRoot(loc: string): void { + if (!store) return; + store.set("backupLocation", loc.trim() || ""); +} // Books already backed up in this process run (avoids duplicate copies when // several chapters of the same book are migrated one after another). @@ -23,9 +45,9 @@ export function ensureRecoveryBackup(bookPath: string): string | null { const bookName = path.basename(key) || "book"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - const dest = path.join(RECOVERY_ROOT, `${bookName}-${stamp}`); + const dest = path.join(getRecoveryRoot(), `${bookName}-${stamp}`); - fs.mkdirSync(RECOVERY_ROOT, { recursive: true }); + fs.mkdirSync(getRecoveryRoot(), { recursive: true }); fs.cpSync(key, dest, { recursive: true }); sessionBackedUp.add(key); diff --git a/src/main/index.ts b/src/main/index.ts index 707f60c..261af8b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,15 +2,22 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "ele import type { OpenDialogOptions, SaveDialogOptions } from "electron"; import fs from "fs"; import path from "path"; + +// When launched via `electron .` (npm run dev), Electron defaults app.name to +// "electron", which puts userData under ~/.config/Electron and splits recents +// and settings from the packaged build (~/.config/Folio). Unify them by setting +// the name before any userData path is read. +app.setName("Folio"); import { BookResult, ErrResult, createBook, hasBook, loadBook, + saveMeta, Recent, } from "./project.js"; -import { listRecents, addRecent, removeRecent } from "./recents.js"; +import { listRecents, addRecent, removeRecent, migrateLegacyRecents } from "./recents.js"; import { buildMenu } from "./menu.js"; import { createChapter, @@ -25,6 +32,11 @@ import { restoreChapter, } from "./chapters.js"; import { combineMarkdown, buildZip } from "./export.js"; +import { + bindBackupSettings, + getRecoveryRoot, + setRecoveryRoot, +} from "./backup.js"; import { buildDocx, buildOdt } from "./office-export.js"; import { getSetting, setSetting } from "./settings.js"; import { migrateChapterToNative, migrateBookToNative } from "./migration.js"; @@ -95,6 +107,7 @@ function createWindow() { } app.whenReady().then(() => { + migrateLegacyRecents(); buildMenu( getWindow, getState, @@ -209,7 +222,14 @@ ipcMain.handle("folio:newBookNamed", async (_e, name: string) => { ipcMain.handle("folio:deleteBook", async (_e, p: string) => { try { - if (!hasBook(p)) return { error: "Not a Folio book." }; + // Always drop the recent entry, even if the folder is gone or was moved, + // so stale/missing books can be cleared from the list. + removeRecent(p); + if (!hasBook(p)) { + if (bookPath === p) bookPath = null; + refreshMenu(); + return { ok: true }; + } // Move to the system trash rather than permanently erasing, so an // accidental delete can be undone from the desktop trash. try { @@ -220,7 +240,6 @@ ipcMain.handle("folio:deleteBook", async (_e, p: string) => { // book is moved to the trash and remains recoverable. fs.rmSync(p, { recursive: true, force: true }); } - removeRecent(p); if (bookPath === p) bookPath = null; refreshMenu(); return { ok: true }; @@ -245,8 +264,24 @@ ipcMain.handle("folio:openBook", async () => { ipcMain.handle("folio:openPath", async (_e, p: string) => openBookAt(p)); ipcMain.handle("folio:reveal", async (_e, p: string) => { - if (p) await shell.openPath(p); - return true; + if (!p) return false; + // True "reveal": show the book's folio.json selected in the file manager. + // Fall back to opening the folder itself if the meta file is missing. + const metaFile = path.join(p, "folio.json"); + if (fs.existsSync(metaFile)) { + shell.showItemInFolder(metaFile); + return true; + } + const err = await shell.openPath(p); + return !err; +}); + +ipcMain.handle("folio:pickBackupFolder", async () => { + const { canceled, filePaths } = await showOpenDialog({ + properties: ["openDirectory"], + title: "Choose recovery backup folder", + }); + return { canceled, filePaths }; }); ipcMain.handle("folio:getRecents", (): Recent[] => listRecents()); @@ -272,6 +307,17 @@ ipcMain.handle("folio:getBookMeta", () => { } }); +ipcMain.handle("folio:setComments", (_e, comments: Record) => { + try { + const meta = loadBook(requireBook()); + meta.comments = comments as never; + saveMeta(requireBook(), meta); + return { ok: true }; + } catch (e) { + return { error: (e as Error).message }; + } +}); + ipcMain.handle("folio:getChapterContent", (_e, id: string) => { try { return getChapterContent(requireBook(), id); @@ -658,6 +704,16 @@ ipcMain.handle("folio:setZoom", (_e, pct: unknown) => { return { ok: true }; }); +// Backup location preference. Backup reads/writes settings through the +// injected store so the module itself stays Electron-free for tests. +bindBackupSettings({ get: getSetting, set: setSetting }); +ipcMain.handle("folio:getBackupLocation", () => getRecoveryRoot()); +ipcMain.handle("folio:setBackupLocation", (_e, loc: unknown) => { + const s = typeof loc === "string" ? loc.trim() : ""; + setRecoveryRoot(s); + return { ok: true }; +}); + ipcMain.handle("folio:importAttachment", async () => { const bp = requireBook(); const { canceled, filePaths } = await showOpenDialog({ diff --git a/src/main/menu.ts b/src/main/menu.ts index ed05efc..cc6b848 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,7 +1,8 @@ import path from "path"; -import { app, Menu, BrowserWindow } from "electron"; +import { app, dialog, Menu, BrowserWindow } from "electron"; import { listRecents } from "./recents.js"; import type { Recent } from "./recents.js"; +import { setRecoveryRoot } from "./backup.js"; type MenuItem = Electron.MenuItemConstructorOptions; type GetState = () => { bookPath: string | null }; @@ -40,12 +41,33 @@ export function buildMenu( { label: "Open Recent", submenu: recentItems }, { type: "separator" }, { + id: "reveal", label: "Reveal in Folder", accelerator: "CmdOrCtrl+R", - enabled: !!getState().bookPath, + // Always usable: falls back to the most recent valid book when none + // is currently open, so the item never looks clickable yet do nothing. + enabled: !!getState().bookPath || listRecents().some((r) => !r.missing), click: () => { - const p = getState().bookPath; - if (p) getWindow()?.webContents.send("folio:reveal-requested", p); + const target = + getState().bookPath ?? + listRecents().find((r) => !r.missing)?.path ?? + null; + if (!target) return; + getWindow()?.webContents.send("folio:reveal-requested", target); + }, + }, + { + label: "Backup Location…", + click: async () => { + const win = getWindow(); + const opts = { + properties: ["openDirectory" as const], + title: "Choose recovery backup folder", + }; + const { canceled, filePaths } = win + ? await dialog.showOpenDialog(win, opts) + : await dialog.showOpenDialog(opts); + if (!canceled && filePaths[0]) setRecoveryRoot(filePaths[0]); }, }, { type: "separator" }, @@ -112,7 +134,23 @@ export function buildMenu( ], }; - const menu = Menu.buildFromTemplate([fileMenu, editMenu]); + const viewMenu: MenuItem = { + label: "View", + submenu: [ + { + label: "Reload Window", + accelerator: "F5", + click: () => getWindow()?.webContents.reload(), + }, + { + label: "Toggle Developer Tools", + accelerator: "F12", + click: () => getWindow()?.webContents.toggleDevTools(), + }, + ], + }; + + const menu = Menu.buildFromTemplate([fileMenu, editMenu, viewMenu]); Menu.setApplicationMenu(menu); return menu; } diff --git a/src/main/preload.ts b/src/main/preload.ts index 59b5adc..cdf81a3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -15,6 +15,8 @@ contextBridge.exposeInMainWorld("folio", { // chapters getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"), + setComments: (comments: unknown) => + ipcRenderer.invoke("folio:setComments", comments), getChapterList: () => ipcRenderer.invoke("folio:getChapterList"), getChapterContent: (id: string) => ipcRenderer.invoke("folio:getChapterContent", id), createChapter: (title: string) => @@ -60,6 +62,11 @@ contextBridge.exposeInMainWorld("folio", { getTheme: () => ipcRenderer.invoke("folio:getTheme"), setTheme: (id: string) => ipcRenderer.invoke("folio:setTheme", id), + // backup location preference + getBackupLocation: () => ipcRenderer.invoke("folio:getBackupLocation"), + setBackupLocation: (loc: string) => ipcRenderer.invoke("folio:setBackupLocation", loc), + pickBackupFolder: () => ipcRenderer.invoke("folio:pickBackupFolder"), + // events onBookOpened: (cb: (data: { path: string; title: string }) => void) => ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)), diff --git a/src/main/project.ts b/src/main/project.ts index afb9718..24507e6 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -10,12 +10,22 @@ export interface ChapterEntry { color?: string; } +export interface ChapterComment { + id: string; + chapterId: string; + quote: string; + text: string; + createdAt: string; +} + export interface BookMeta { app: string; version: number; title: string; chapterOrder: string[]; chapters: Record; + // Per-chapter user annotations, keyed by chapter id. + comments?: Record; // Native document format metadata (added when a book is migrated). Legacy // Markdown books omit these fields entirely. docFormat?: "markdown" | "folio-doc"; diff --git a/src/main/recents.ts b/src/main/recents.ts index be5b1ee..8044cf3 100644 --- a/src/main/recents.ts +++ b/src/main/recents.ts @@ -1,3 +1,4 @@ +import fs from "fs"; import path from "path"; import { app } from "electron"; import { @@ -11,24 +12,62 @@ import type { Recent } from "./project.js"; export type { Recent }; +// Canonical recents file for this install. export function recentsFilePath(): string { return path.join(app.getPath("userData"), `${APP_ID}-recents.json`); } -export function listRecents(): Recent[] { - // Drop entries whose book no longer exists (e.g. e2e temp books). - const recents = _listRecents(recentsFilePath()).filter( - (r) => r && typeof r.path === "string" && hasBook(r.path) +// Legacy location written by dev runs before app.setName("Folio") unified the +// userData directory (~/.config/Electron instead of ~/.config/Folio). +function legacyElectronRecentsFile(): string { + return path.join( + path.dirname(app.getPath("userData")), + "Electron", + `${APP_ID}-recents.json` ); - // Collapse repeats of the same book (same title) so the home/recent list - // is not cluttered with multiple temp copies of one book. +} + +function loadRaw(file: string): Recent[] { + try { + return _listRecents(file).filter((r) => r && typeof r.path === "string"); + } catch { + return []; + } +} + +// One-time migration: fold entries from a stray ~/.config/Electron recents +// file into the canonical one, then delete the legacy file. Reading two files +// made removals ambiguous (an entry deleted from the canonical file came back +// from the legacy one), so after this everything lives in a single file. +export function migrateLegacyRecents(): void { + try { + const legacy = legacyElectronRecentsFile(); + if (!fs.existsSync(legacy)) return; + const canonical = recentsFilePath(); + const current = loadRaw(canonical); + const incoming = loadRaw(legacy); + const known = new Set(current.map((r) => r.path)); + const merged = [...current, ...incoming.filter((r) => !known.has(r.path))]; + if (merged.length !== current.length) { + fs.writeFileSync(canonical, JSON.stringify(merged, null, 2), "utf-8"); + } + fs.rmSync(legacy, { force: true }); + } catch { + // Migration is best-effort; never block startup. + } +} + +export function listRecents(): (Recent & { missing?: boolean })[] { + // Keep every saved entry so a book never "disappears" from the list just + // because its folder moved or was renamed. Mark entries whose book can't be + // found so the UI can show them as missing instead of hiding them. const seen = new Set(); - const deduped: Recent[] = []; - for (const r of recents) { + const deduped: (Recent & { missing?: boolean })[] = []; + for (const r of loadRaw(recentsFilePath())) { const key = (r.title || r.path).toLowerCase(); if (seen.has(key)) continue; seen.add(key); - deduped.push(r); + deduped.push({ ...r, missing: !hasBook(r.path) }); } return deduped; } diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 409c418..456dbda 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -1,4 +1,4 @@ -import type { BookMeta } from "../main/project.js"; +import type { BookMeta, ChapterComment } from "../main/project.js"; import { alertMessage, confirmMessage, promptInput } from "./dialog.js"; import { renderPrintChaptersNative } from "./print-format.js"; import type { WikilinkTarget } from "./wikilinks.js"; @@ -36,6 +36,7 @@ export type FolioAPI = { closeBook: () => Promise; showContextMenu: (x: number, y: number) => void; getBookMeta: () => Promise; + setComments: (comments: Record) => Promise; getChapterList: () => Promise; getChapterContent: (id: string) => Promise; createChapter: (title: string) => Promise; @@ -153,12 +154,38 @@ function makeSnippet(plain: string, pos: number, len: number): string { return escapeHtml(pre + before) + "" + escapeHtml(match) + "" + escapeHtml(after) + post; } -export function initBook(api: FolioAPI) { + export function initBook(api: FolioAPI) { let currentBook: { path: string; title: string } | null = null; let meta: BookMeta | null = null; let ordered: ChapterEntry[] = []; let selectedId: string | null = null; + function scrollToTerm(term: string, matchCase: boolean): void { + if (!term) return; + const needle = matchCase ? term : term.toLowerCase(); + let found: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (found !== null) return false; + if (node.isText && node.text) { + const t = matchCase ? node.text : node.text.toLowerCase(); + const idx = t.indexOf(needle); + if (idx !== -1) { + found = pos + idx; + return false; + } + } + return true; + }); + if (found === null) return; + const to = Math.min(found + term.length, editor.state.doc.content.size); + editor.commands.focus(); + editor.commands.setTextSelection({ from: found, to }); + const dom = (editor.view as any).domAt(found); + const node = (dom.node.nodeType === 3 ? dom.node.parentElement : dom.node) as HTMLElement | null; + node?.scrollIntoView({ block: "center" }); + } + + const chapterList = $("chapterList") as HTMLUListElement; const editorPlaceholder = $("editorPlaceholder"); const chapterView = $("chapterView"); @@ -287,8 +314,41 @@ export function initBook(api: FolioAPI) { const toolbar = $("editorToolbar"); const headingSelect = $("headingSelect") as HTMLSelectElement; + // Open/close helpers that drive the CSS open/close animations. `closeEl` + // adds `.closing` so the exit animation can play, then hides after it ends + // (with a fallback timer). `openEl` cancels any pending close so rapid + // toggles don't leave an element stuck hidden. + function openEl(el: HTMLElement) { + const pending = (el as unknown as { __close?: () => void }).__close; + if (pending) pending(); + el.classList.remove("hidden", "closing"); + } + function closeEl(el: HTMLElement | null) { + if (!el || el.classList.contains("hidden")) return; + el.classList.add("closing"); + let done = false; + const finish = () => { + if (done) return; + done = true; + el.classList.add("hidden"); + el.classList.remove("closing"); + el.removeEventListener("animationend", onEnd); + delete (el as unknown as { __close?: () => void }).__close; + }; + const onEnd = () => finish(); + el.addEventListener("animationend", onEnd); + const timer = setTimeout(finish, 420); + (el as unknown as { __close?: () => void }).__close = () => { + done = true; + clearTimeout(timer); + el.removeEventListener("animationend", onEnd); + el.classList.remove("closing"); + delete (el as unknown as { __close?: () => void }).__close; + }; + } + function closeDropdowns() { - toolbar.querySelectorAll(".dropdown-menu").forEach((m) => m.classList.add("hidden")); + toolbar.querySelectorAll(".dropdown-menu").forEach((m) => closeEl(m)); toolbar.querySelectorAll(".dropdown-toggle").forEach((t) => t.classList.remove("open")); } @@ -451,7 +511,7 @@ export function initBook(api: FolioAPI) { const wasOpen = !menu.classList.contains("hidden"); closeDropdowns(); if (!wasOpen) { - menu.classList.remove("hidden"); + openEl(menu); toggle.classList.add("open"); const rect = toggle.getBoundingClientRect(); const menuWidth = menu.offsetWidth; @@ -480,17 +540,143 @@ export function initBook(api: FolioAPI) { editor.commands.focus(); }); - const fontFamilySelect = $("fontFamilySelect") as HTMLSelectElement; - fontFamilySelect?.addEventListener("change", async () => { - const f = fontFamilySelect.value; - // Font is a book-wide setting: changing it in one chapter applies the same - // font to every chapter so the whole book stays consistent. - if (f) editor.chain().focus().selectAll().setMark("fontFamily", { fontFamily: f }).run(); - else editor.chain().focus().selectAll().unsetMark("fontFamily").run(); + // Custom font-family dropdown. A native + + -
@@ -47,11 +48,11 @@

Folio

Your stories, in folders you choose.

- - + +
-
Recent Projects
+
Recent
  • No recent books.
@@ -64,6 +65,8 @@ Saved words: 0 + +
@@ -94,20 +97,13 @@ - +
+ + +
+
    + +