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')
}
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[] {
const map = new Map(nodes.map((n) => [n.name, 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 { readFileSync, writeFileSync, mkdirSync } from 'fs'
export interface RecentProject {
path: string
title: string
}
export interface GlobalConfig {
apiKey?: string
projectPath?: string
projectTitle?: string
fontSize?: number
theme?: 'dark' | 'light'
// Manuscript metadata
authorName?: string
penName?: string
authorAddress?: string
authorEmail?: string
authorPhone?: string
recentProjects?: RecentProject[]
}
const CONFIG_DIR = join(homedir(), '.hohoff')
@@ -22,7 +21,12 @@ const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
let _config: GlobalConfig = {}
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 {
// first run or file missing — use fallbacks
}
@@ -35,8 +39,8 @@ export const getDraftRoot = (): string =>
export const getApiKey = (): string | undefined =>
_config.apiKey ?? process.env.ANTHROPIC_API_KEY
export const getProjectTitle = (): string =>
_config.projectTitle?.trim() || basename(getDraftRoot())
export const getProjectTitle = (projectTitle?: string): string =>
projectTitle?.trim() || basename(getDraftRoot())
export function readGlobalConfig(): GlobalConfig {
return { ..._config }
@@ -47,3 +51,19 @@ export function writeGlobalConfig(updates: Partial<GlobalConfig>): void {
mkdirSync(CONFIG_DIR, { recursive: true })
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 { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { registerIpcHandlers } from './ipcHandlers'
import { getDraftRoot } from './globalConfig'
import { registerIpcHandlers, setOnProjectChanged } from './ipcHandlers'
import { getDraftRoot, getRecentProjects, writeGlobalConfig } from './globalConfig'
app.setName('Hohoff')
@@ -10,7 +10,14 @@ function send(win: BrowserWindow, action: string): void {
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 {
_menuWin = win
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = [
@@ -58,6 +65,29 @@ function buildAppMenu(win: BrowserWindow): void {
accelerator: 'CmdOrCtrl+Shift+O',
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' },
{
label: 'Save',
@@ -243,6 +273,7 @@ app.whenReady().then(() => {
registerIpcHandlers()
const mainWindow = createWindow()
buildAppMenu(mainWindow)
setOnProjectChanged(rebuildAppMenu)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {

View File

@@ -2,13 +2,21 @@ import { ipcMain, dialog, BrowserWindow } from 'electron'
import { readFileSync, writeFileSync, unlinkSync } from 'fs'
import { extname, basename, join } from 'path'
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 type { SearchOptions } 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, ProjectConfig } from './fileSystem'
import { streamMessage, resetClient } from './aiService'
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'
type MergedConfig = GlobalConfig & ProjectConfig
let _onProjectChanged: (() => void) | null = null
export function setOnProjectChanged(cb: () => void): void {
_onProjectChanged = cb
}
export function registerIpcHandlers(): void {
ipcMain.handle('fs:listFiles', async () => {
return await listDraftFiles()
@@ -168,13 +176,59 @@ export function registerIpcHandlers(): void {
}
})
ipcMain.handle('config:read', (): GlobalConfig => {
return readGlobalConfig()
ipcMain.handle('config:read', async (): Promise<MergedConfig> => {
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 => {
writeGlobalConfig(updates)
ipcMain.handle('config:write', async (_event, updates: Partial<MergedConfig>): Promise<void> => {
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.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> => {
@@ -198,7 +252,8 @@ export function registerIpcHandlers(): void {
const bodyHtml = await marked(normalized)
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 html = `<!DOCTYPE html>
@@ -298,7 +353,8 @@ export function registerIpcHandlers(): void {
ipcMain.handle('export:projectPdf', async (event): Promise<void> => {
const win = BrowserWindow.fromWebContents(event.sender)
const projectName = getProjectTitle()
const projectCfg = await readProjectConfig()
const projectName = getProjectTitle(projectCfg.projectTitle)
const result = await dialog.showSaveDialog(win!, {
defaultPath: `${projectName}.pdf`,
@@ -318,12 +374,11 @@ export function registerIpcHandlers(): void {
const projectTitle = projectName.toUpperCase()
// ── Cover page ──────────────────────────────────────────────────────────────
const cfg = readGlobalConfig()
const authorLegal = cfg.authorName?.trim() ?? ''
const authorByline = cfg.penName?.trim() || authorLegal
const authorAddr = cfg.authorAddress?.trim() ?? ''
const authorEmail = cfg.authorEmail?.trim() ?? ''
const authorPhone = cfg.authorPhone?.trim() ?? ''
const authorLegal = projectCfg.authorName?.trim() ?? ''
const authorByline = projectCfg.penName?.trim() || authorLegal
const authorAddr = projectCfg.authorAddress?.trim() ?? ''
const authorEmail = projectCfg.authorEmail?.trim() ?? ''
const authorPhone = projectCfg.authorPhone?.trim() ?? ''
// Count words from docs already in memory, rounded to nearest 1,000
const totalWords = docs.reduce((sum, doc) => {

View File

@@ -88,6 +88,8 @@ export default function App(): JSX.Element {
} else if (action === 'openProject') {
const picked = await window.api.pickProjectFolder()
if (picked) await switchProject(picked)
} else if (action.startsWith('openRecent:')) {
await switchProject(action.slice('openRecent:'.length))
} else if (action === 'exportPDF') {
if (activeFilePath && activeFileContent) {
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 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">
<label className="settings-label" htmlFor="settings-api-key">Anthropic API Key</label>
<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-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">
<label className="settings-label" htmlFor="settings-author-name">Author Name</label>
<input
@@ -201,7 +201,7 @@ export function SettingsDialog({ onClose, onProjectChanged, isSetup }: Props): J
</div>
<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">
{!isSetup && (
<button className="settings-btn settings-btn--secondary" onClick={onClose}>Cancel</button>