✨ recent project
This commit is contained in:
@@ -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)!)
|
||||
|
||||
@@ -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 ?? []
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user