recent project

This commit is contained in:
2026-05-29 10:29:59 +10:00
parent 07f22bdaa1
commit c6f55ddfe1
7 changed files with 187 additions and 43 deletions

View File

@@ -71,6 +71,42 @@ export async function writeSession(data: Record<string, unknown>): Promise<void>
await writeFile(sessionFile(), JSON.stringify(data), 'utf-8') await writeFile(sessionFile(), JSON.stringify(data), 'utf-8')
} }
export interface ProjectConfig {
projectTitle?: string
authorName?: string
penName?: string
authorAddress?: string
authorEmail?: string
authorPhone?: string
}
const PROJECT_CONFIG_FIELDS: (keyof ProjectConfig)[] = [
'projectTitle', 'authorName', 'penName', 'authorAddress', 'authorEmail', 'authorPhone'
]
export { PROJECT_CONFIG_FIELDS }
const projectConfigFile = (): string => join(hohoffDir(), 'project.json')
export async function readProjectConfig(): Promise<ProjectConfig> {
try {
return JSON.parse(await readFile(projectConfigFile(), 'utf-8'))
} catch {
return {}
}
}
export async function writeProjectConfig(updates: Partial<ProjectConfig>): Promise<void> {
await mkdir(hohoffDir(), { recursive: true })
const existing = await readProjectConfig()
const merged = { ...existing, ...updates }
// Remove undefined values
for (const key of Object.keys(merged) as (keyof ProjectConfig)[]) {
if (merged[key] === undefined) delete merged[key]
}
await writeFile(projectConfigFile(), JSON.stringify(merged, null, 2), 'utf-8')
}
function applyOrder(nodes: FileNode[], savedNames: string[]): FileNode[] { function applyOrder(nodes: FileNode[], savedNames: string[]): FileNode[] {
const map = new Map(nodes.map((n) => [n.name, n])) const map = new Map(nodes.map((n) => [n.name, n]))
const ordered = savedNames.filter((n) => map.has(n)).map((n) => map.get(n)!) const ordered = savedNames.filter((n) => map.has(n)).map((n) => map.get(n)!)

View File

@@ -2,18 +2,17 @@ import { join, basename } from 'path'
import { homedir } from 'os' import { homedir } from 'os'
import { readFileSync, writeFileSync, mkdirSync } from 'fs' import { readFileSync, writeFileSync, mkdirSync } from 'fs'
export interface RecentProject {
path: string
title: string
}
export interface GlobalConfig { export interface GlobalConfig {
apiKey?: string apiKey?: string
projectPath?: string projectPath?: string
projectTitle?: string
fontSize?: number fontSize?: number
theme?: 'dark' | 'light' theme?: 'dark' | 'light'
// Manuscript metadata recentProjects?: RecentProject[]
authorName?: string
penName?: string
authorAddress?: string
authorEmail?: string
authorPhone?: string
} }
const CONFIG_DIR = join(homedir(), '.hohoff') const CONFIG_DIR = join(homedir(), '.hohoff')
@@ -22,7 +21,12 @@ const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
let _config: GlobalConfig = {} let _config: GlobalConfig = {}
try { try {
_config = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) const raw = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
// Migrate old string[] recentProjects to RecentProject[]
if (Array.isArray(raw.recentProjects) && typeof raw.recentProjects[0] === 'string') {
raw.recentProjects = (raw.recentProjects as string[]).map((p) => ({ path: p, title: basename(p) }))
}
_config = raw
} catch { } catch {
// first run or file missing — use fallbacks // first run or file missing — use fallbacks
} }
@@ -35,8 +39,8 @@ export const getDraftRoot = (): string =>
export const getApiKey = (): string | undefined => export const getApiKey = (): string | undefined =>
_config.apiKey ?? process.env.ANTHROPIC_API_KEY _config.apiKey ?? process.env.ANTHROPIC_API_KEY
export const getProjectTitle = (): string => export const getProjectTitle = (projectTitle?: string): string =>
_config.projectTitle?.trim() || basename(getDraftRoot()) projectTitle?.trim() || basename(getDraftRoot())
export function readGlobalConfig(): GlobalConfig { export function readGlobalConfig(): GlobalConfig {
return { ..._config } return { ..._config }
@@ -47,3 +51,19 @@ export function writeGlobalConfig(updates: Partial<GlobalConfig>): void {
mkdirSync(CONFIG_DIR, { recursive: true }) mkdirSync(CONFIG_DIR, { recursive: true })
writeFileSync(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8') writeFileSync(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8')
} }
export function addRecentProject(path: string, title: string): void {
const existing = _config.recentProjects ?? []
const deduped = [{ path, title }, ...existing.filter((p) => p.path !== path)].slice(0, 10)
writeGlobalConfig({ recentProjects: deduped })
}
export function updateRecentProjectTitle(path: string, title: string): void {
const existing = _config.recentProjects ?? []
const updated = existing.map((p) => p.path === path ? { path, title } : p)
writeGlobalConfig({ recentProjects: updated })
}
export function getRecentProjects(): RecentProject[] {
return _config.recentProjects ?? []
}

View File

@@ -1,8 +1,8 @@
import { app, BrowserWindow, shell, nativeImage, Menu, dialog } from 'electron' import { app, BrowserWindow, shell, nativeImage, Menu, dialog } from 'electron'
import { join } from 'path' import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcHandlers } from './ipcHandlers' import { registerIpcHandlers, setOnProjectChanged } from './ipcHandlers'
import { getDraftRoot } from './globalConfig' import { getDraftRoot, getRecentProjects, writeGlobalConfig } from './globalConfig'
app.setName('Hohoff') app.setName('Hohoff')
@@ -10,7 +10,14 @@ function send(win: BrowserWindow, action: string): void {
if (!win.isDestroyed()) win.webContents.send('menu:action', action) if (!win.isDestroyed()) win.webContents.send('menu:action', action)
} }
let _menuWin: BrowserWindow | null = null
export function rebuildAppMenu(): void {
if (_menuWin) buildAppMenu(_menuWin)
}
function buildAppMenu(win: BrowserWindow): void { function buildAppMenu(win: BrowserWindow): void {
_menuWin = win
const isMac = process.platform === 'darwin' const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = [ const template: Electron.MenuItemConstructorOptions[] = [
@@ -58,6 +65,29 @@ function buildAppMenu(win: BrowserWindow): void {
accelerator: 'CmdOrCtrl+Shift+O', accelerator: 'CmdOrCtrl+Shift+O',
click: () => send(win, 'openProject') click: () => send(win, 'openProject')
}, },
{
label: 'Open Recent',
submenu: (() => {
const recents = getRecentProjects()
if (recents.length === 0) {
return [{ label: 'No Recent Projects', enabled: false }]
}
return [
...recents.map((r) => ({
label: `${r.title} (${r.path})`,
click: () => win.webContents.send('menu:action', `openRecent:${r.path}`)
})),
{ type: 'separator' as const },
{
label: 'Clear Recent',
click: () => {
writeGlobalConfig({ recentProjects: [] })
rebuildAppMenu()
}
}
]
})()
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Save', label: 'Save',
@@ -243,6 +273,7 @@ app.whenReady().then(() => {
registerIpcHandlers() registerIpcHandlers()
const mainWindow = createWindow() const mainWindow = createWindow()
buildAppMenu(mainWindow) buildAppMenu(mainWindow)
setOnProjectChanged(rebuildAppMenu)
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {

View File

@@ -2,13 +2,21 @@ import { ipcMain, dialog, BrowserWindow } from 'electron'
import { readFileSync, writeFileSync, unlinkSync } from 'fs' import { readFileSync, writeFileSync, unlinkSync } from 'fs'
import { extname, basename, join } from 'path' import { extname, basename, join } from 'path'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, searchAcrossFiles, replaceInFiles, readAllDraftFiles } from './fileSystem' import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, searchAcrossFiles, replaceInFiles, readAllDraftFiles, readProjectConfig, writeProjectConfig, PROJECT_CONFIG_FIELDS } from './fileSystem'
import type { SearchOptions } from './fileSystem' import type { SearchOptions, ProjectConfig } from './fileSystem'
import { streamMessage, resetClient } from './aiService' import { streamMessage, resetClient } from './aiService'
import type { AIPayload, Attachment } from '../renderer/types/editor' import type { AIPayload, Attachment } from '../renderer/types/editor'
import { readGlobalConfig, writeGlobalConfig, getProjectTitle } from './globalConfig' import { readGlobalConfig, writeGlobalConfig, getProjectTitle, addRecentProject, updateRecentProjectTitle } from './globalConfig'
import type { GlobalConfig } from './globalConfig' import type { GlobalConfig } from './globalConfig'
type MergedConfig = GlobalConfig & ProjectConfig
let _onProjectChanged: (() => void) | null = null
export function setOnProjectChanged(cb: () => void): void {
_onProjectChanged = cb
}
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
ipcMain.handle('fs:listFiles', async () => { ipcMain.handle('fs:listFiles', async () => {
return await listDraftFiles() return await listDraftFiles()
@@ -168,13 +176,59 @@ export function registerIpcHandlers(): void {
} }
}) })
ipcMain.handle('config:read', (): GlobalConfig => { ipcMain.handle('config:read', async (): Promise<MergedConfig> => {
return readGlobalConfig() const global = readGlobalConfig()
const project = await readProjectConfig()
// One-time migration: if global has project fields, move them to project config
const legacyGlobal = global as Record<string, unknown>
const migrationFields = PROJECT_CONFIG_FIELDS.filter((f) => legacyGlobal[f] !== undefined)
if (migrationFields.length > 0 && Object.keys(project).length === 0) {
const migrated: Partial<ProjectConfig> = {}
for (const f of migrationFields) {
(migrated as Record<string, unknown>)[f] = legacyGlobal[f]
}
await writeProjectConfig(migrated)
// Remove from global config
const cleaned = { ...legacyGlobal }
for (const f of migrationFields) delete cleaned[f]
writeGlobalConfig(cleaned as Partial<GlobalConfig>)
return { ...global, ...migrated }
}
return { ...global, ...project }
}) })
ipcMain.handle('config:write', (_event, updates: Partial<GlobalConfig>): void => { ipcMain.handle('config:write', async (_event, updates: Partial<MergedConfig>): Promise<void> => {
writeGlobalConfig(updates) const globalUpdates: Partial<GlobalConfig> = {}
const projectUpdates: Partial<ProjectConfig> = {}
for (const [key, value] of Object.entries(updates)) {
if ((PROJECT_CONFIG_FIELDS as string[]).includes(key)) {
(projectUpdates as Record<string, unknown>)[key] = value
} else {
(globalUpdates as Record<string, unknown>)[key] = value
}
}
if (Object.keys(globalUpdates).length > 0) writeGlobalConfig(globalUpdates)
if (Object.keys(projectUpdates).length > 0) await writeProjectConfig(projectUpdates)
if (updates.apiKey !== undefined) resetClient() if (updates.apiKey !== undefined) resetClient()
if (updates.projectPath) {
const projectCfg = await readProjectConfig()
const { basename } = require('path') as typeof import('path')
addRecentProject(updates.projectPath, projectCfg.projectTitle?.trim() || basename(updates.projectPath))
_onProjectChanged?.()
} else if (updates.projectTitle !== undefined) {
const currentPath = readGlobalConfig().projectPath
if (currentPath) {
const { basename } = require('path') as typeof import('path')
updateRecentProjectTitle(currentPath, updates.projectTitle.trim() || basename(currentPath))
_onProjectChanged?.()
}
}
}) })
ipcMain.handle('config:pickFolder', async (event): Promise<string | null> => { ipcMain.handle('config:pickFolder', async (event): Promise<string | null> => {
@@ -198,7 +252,8 @@ export function registerIpcHandlers(): void {
const bodyHtml = await marked(normalized) const bodyHtml = await marked(normalized)
const safeTitle = fileName.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;') const safeTitle = fileName.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
const projectTitle = getProjectTitle().toUpperCase() const projectCfgForPdf = await readProjectConfig()
const projectTitle = getProjectTitle(projectCfgForPdf.projectTitle).toUpperCase()
const chapterTitle = fileName.toUpperCase() const chapterTitle = fileName.toUpperCase()
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
@@ -298,7 +353,8 @@ export function registerIpcHandlers(): void {
ipcMain.handle('export:projectPdf', async (event): Promise<void> => { ipcMain.handle('export:projectPdf', async (event): Promise<void> => {
const win = BrowserWindow.fromWebContents(event.sender) const win = BrowserWindow.fromWebContents(event.sender)
const projectName = getProjectTitle() const projectCfg = await readProjectConfig()
const projectName = getProjectTitle(projectCfg.projectTitle)
const result = await dialog.showSaveDialog(win!, { const result = await dialog.showSaveDialog(win!, {
defaultPath: `${projectName}.pdf`, defaultPath: `${projectName}.pdf`,
@@ -318,12 +374,11 @@ export function registerIpcHandlers(): void {
const projectTitle = projectName.toUpperCase() const projectTitle = projectName.toUpperCase()
// ── Cover page ────────────────────────────────────────────────────────────── // ── Cover page ──────────────────────────────────────────────────────────────
const cfg = readGlobalConfig() const authorLegal = projectCfg.authorName?.trim() ?? ''
const authorLegal = cfg.authorName?.trim() ?? '' const authorByline = projectCfg.penName?.trim() || authorLegal
const authorByline = cfg.penName?.trim() || authorLegal const authorAddr = projectCfg.authorAddress?.trim() ?? ''
const authorAddr = cfg.authorAddress?.trim() ?? '' const authorEmail = projectCfg.authorEmail?.trim() ?? ''
const authorEmail = cfg.authorEmail?.trim() ?? '' const authorPhone = projectCfg.authorPhone?.trim() ?? ''
const authorPhone = cfg.authorPhone?.trim() ?? ''
// Count words from docs already in memory, rounded to nearest 1,000 // Count words from docs already in memory, rounded to nearest 1,000
const totalWords = docs.reduce((sum, doc) => { const totalWords = docs.reduce((sum, doc) => {

View File

@@ -88,6 +88,8 @@ export default function App(): JSX.Element {
} else if (action === 'openProject') { } else if (action === 'openProject') {
const picked = await window.api.pickProjectFolder() const picked = await window.api.pickProjectFolder()
if (picked) await switchProject(picked) if (picked) await switchProject(picked)
} else if (action.startsWith('openRecent:')) {
await switchProject(action.slice('openRecent:'.length))
} else if (action === 'exportPDF') { } else if (action === 'exportPDF') {
if (activeFilePath && activeFileContent) { if (activeFilePath && activeFileContent) {
const fileName = activeFilePath.split('/').pop()?.replace(/\.md$/, '') ?? 'document' const fileName = activeFilePath.split('/').pop()?.replace(/\.md$/, '') ?? 'document'

View File

@@ -100,20 +100,6 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J
</div> </div>
</div> </div>
<div className="settings-field">
<label className="settings-label" htmlFor="settings-project-title">Project Title</label>
<input
id="settings-project-title"
className="settings-input"
type="text"
value={projectTitle}
onChange={(e) => setProjectTitle(e.target.value)}
placeholder="My Novel"
spellCheck={false}
/>
<p className="settings-hint">Used as the title in PDF exports. Defaults to the project folder name if left blank.</p>
</div>
<div className="settings-field"> <div className="settings-field">
<label className="settings-label" htmlFor="settings-api-key">Anthropic API Key</label> <label className="settings-label" htmlFor="settings-api-key">Anthropic API Key</label>
<input <input
@@ -131,6 +117,20 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J
<div className="settings-section-divider"><span>Manuscript</span></div> <div className="settings-section-divider"><span>Manuscript</span></div>
<div className="settings-field">
<label className="settings-label" htmlFor="settings-project-title">Project Title</label>
<input
id="settings-project-title"
className="settings-input"
type="text"
value={projectTitle}
onChange={(e) => setProjectTitle(e.target.value)}
placeholder="My Novel"
spellCheck={false}
/>
<p className="settings-hint">Used as the title in PDF exports. Defaults to the project folder name if left blank.</p>
</div>
<div className="settings-field"> <div className="settings-field">
<label className="settings-label" htmlFor="settings-author-name">Author Name</label> <label className="settings-label" htmlFor="settings-author-name">Author Name</label>
<input <input
@@ -201,7 +201,7 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J
</div> </div>
<div className="settings-footer"> <div className="settings-footer">
<span className="settings-config-path">Config: ~/.hohoff/config.json</span> <span className="settings-config-path">Global: ~/.hohoff/config.json · Project: .hohoff/project.json</span>
<div className="settings-footer-actions"> <div className="settings-footer-actions">
{!isSetup && ( {!isSetup && (
<button className="settings-btn settings-btn--secondary" onClick={onClose}>Cancel</button> <button className="settings-btn settings-btn--secondary" onClick={onClose}>Cancel</button>

File diff suppressed because one or more lines are too long