import { useCallback } from 'react' import type { VaultEntry } from '../types' import type { FrontmatterValue } from '../components/Inspector' import { useTabManagement } from './useTabManagement' import { resolveEntry } from '../utils/wikilink' import { useNoteCreation } from './useNoteCreation' import { useNoteRename, performRename, loadNoteContent, renameToastMessage, reloadTabsAfterRename, reloadVaultAfterRename, } from './useNoteRename' import { runFrontmatterAndApply, type FrontmatterOpOptions } from './frontmatterOps' export interface NoteActionsConfig { addEntry: (entry: VaultEntry) => void removeEntry: (path: string) => void entries: VaultEntry[] flushBeforeNoteSwitch?: (path: string) => Promise flushBeforeFrontmatterChange?: (path: string) => Promise flushBeforePathRename?: (path: string) => Promise reloadVault?: () => Promise setToastMessage: (msg: string | null) => void updateEntry: (path: string, patch: Partial) => void vaultPath: string addPendingSave?: (path: string) => void removePendingSave?: (path: string) => void trackUnsaved?: (path: string) => void clearUnsaved?: (path: string) => void unsavedPaths?: Set markContentPending?: (path: string, content: string) => void onNewNotePersisted?: () => void replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void onPathRenamed?: (oldPath: string, newPath: string) => void /** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */ onFrontmatterContentChanged?: (path: string, content: string) => void /** Called after a frontmatter mutation is fully persisted, including follow-up renames. */ onFrontmatterPersisted?: () => void /** Called after type files or type assignments change, so derived type surfaces can reload. */ onTypeStateChanged?: () => void | Promise } function isTitleKey(key: string): boolean { return key.toLowerCase().replace(/\s+/g, '_') === 'title' } interface TitleRenameDeps { vaultPath: string tabsRef: React.MutableRefObject<{ entry: VaultEntry; content: string }[]> reloadVault?: () => Promise replaceEntry?: (oldPath: string, patch: Partial & { path: string }) => void onPathRenamed?: (oldPath: string, newPath: string) => void setTabs: React.Dispatch> activeTabPathRef: React.MutableRefObject handleSwitchTab: (path: string) => void setToastMessage: (msg: string | null) => void updateTabContent: (path: string, content: string) => void } interface FrontmatterCallbackParams { config: NoteActionsConfig path: string newContent: string | undefined } function applyFrontmatterCallbacks({ config, path, newContent }: FrontmatterCallbackParams): boolean { if (!newContent) return false config.onFrontmatterContentChanged?.(path, newContent) return true } interface RenameAfterTitleChangeParams { path: string newTitle: string deps: TitleRenameDeps } async function renameAfterTitleChange({ path, newTitle, deps }: RenameAfterTitleChangeParams): Promise { const oldTitle = deps.tabsRef.current.find(t => t.entry.path === path)?.entry.title const result = await performRename({ path, newTitle, vaultPath: deps.vaultPath, oldTitle }) if (result.new_path !== path) { const newFilename = result.new_path.split('/').pop() ?? '' deps.onPathRenamed?.(path, result.new_path) deps.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: newTitle } as Partial & { path: string }) const newContent = await loadNoteContent({ path: result.new_path }) deps.setTabs(prev => prev.map(t => t.entry.path === path ? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: newTitle }, content: newContent } : t)) if (deps.activeTabPathRef.current === path) deps.handleSwitchTab(result.new_path) const otherTabPaths = deps.tabsRef.current.filter(t => t.entry.path !== path && t.entry.path !== result.new_path).map(t => t.entry.path) await reloadTabsAfterRename({ tabPaths: otherTabPaths, updateTabContent: deps.updateTabContent }) } await reloadVaultAfterRename(deps.reloadVault) deps.setToastMessage(renameToastMessage(result.updated_files, result.failed_updates ?? 0)) } function shouldRenameOnTitleUpdate(key: string, value: FrontmatterValue): value is string { return isTitleKey(key) && typeof value === 'string' && value !== '' } function isTypeFieldKey(key: string): boolean { const normalized = key.trim().toLowerCase().replace(/\s+/g, '_') return normalized === 'type' || normalized === 'is_a' } async function notifyFrontmatterPersisted(config: NoteActionsConfig, key: string): Promise { config.onFrontmatterPersisted?.() if (isTypeFieldKey(key)) { await config.onTypeStateChanged?.() } } interface NavigateWikilinkParams { entries: VaultEntry[] target: string selectNote: (entry: VaultEntry) => void } function navigateWikilink({ entries, target, selectNote }: NavigateWikilinkParams): void { const found = resolveEntry(entries, target) if (found) selectNote(found) else console.warn(`Navigation target not found: ${target}`) } interface MaybeRenameAfterFrontmatterUpdateParams { path: string key: string value: FrontmatterValue deps: TitleRenameDeps } async function flushBeforeTitleRename( path: string, key: string, value: FrontmatterValue, flushBeforePathRename?: (path: string) => Promise, ): Promise { if (!shouldRenameOnTitleUpdate(key, value) || !flushBeforePathRename) return true try { await flushBeforePathRename(path) return true } catch { return false } } async function flushBeforeFrontmatterMutation( path: string, flushBeforeFrontmatterChange?: (path: string) => Promise, ): Promise { if (!flushBeforeFrontmatterChange) return true try { await flushBeforeFrontmatterChange(path) return true } catch { return false } } async function maybeRenameAfterFrontmatterUpdate({ path, key, value, deps, }: MaybeRenameAfterFrontmatterUpdateParams): Promise { if (!shouldRenameOnTitleUpdate(key, value)) return try { await renameAfterTitleChange({ path, newTitle: value, deps }) } catch (err) { console.error('Failed to rename note after title change:', err) } } interface UpdateFrontmatterAndMaybeRenameParams { config: NoteActionsConfig deps: TitleRenameDeps key: string options?: FrontmatterOpOptions path: string runFrontmatterOp: ( op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions, ) => Promise value: FrontmatterValue } async function updateFrontmatterAndMaybeRename({ config, deps, key, options, path, runFrontmatterOp, value, }: UpdateFrontmatterAndMaybeRenameParams): Promise { const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange) if (!canFlush) return const canRename = await flushBeforeTitleRename(path, key, value, config.flushBeforePathRename) if (!canRename) return const newContent = await runFrontmatterOp('update', path, key, value, options) if (!applyFrontmatterCallbacks({ config, path, newContent })) return await maybeRenameAfterFrontmatterUpdate({ path, key, value, deps }) await notifyFrontmatterPersisted(config, key) } function buildTabManagementOptions( config: Pick, ) { const options: { beforeNavigate?: (fromPath: string, toPath: string) => Promise onMissingNotePath: (entry: VaultEntry) => void onUnreadableNoteContent: (entry: VaultEntry) => void } = { onMissingNotePath: (entry) => { const label = entry.title.trim() || entry.filename config.setToastMessage(`"${label}" could not be opened because its file is missing or moved.`) void config.reloadVault?.() }, onUnreadableNoteContent: (entry) => { const label = entry.title.trim() || entry.filename config.setToastMessage(`"${label}" could not be opened because it is not valid UTF-8 text.`) }, } if (config.flushBeforeNoteSwitch) { options.beforeNavigate = (fromPath: string) => config.flushBeforeNoteSwitch!(fromPath) } return options } function useFrontmatterActionHandlers({ config, renameTabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent, runFrontmatterOp, }: { config: NoteActionsConfig renameTabsRef: TitleRenameDeps['tabsRef'] setTabs: React.Dispatch> activeTabPathRef: React.MutableRefObject handleSwitchTab: (path: string) => void setToastMessage: (msg: string | null) => void updateTabContent: (path: string, newContent: string) => void runFrontmatterOp: ( op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions, ) => Promise }) { const handleUpdateFrontmatter = useCallback(async ( path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions, ) => { await updateFrontmatterAndMaybeRename({ config, deps: { vaultPath: config.vaultPath, tabsRef: renameTabsRef, reloadVault: config.reloadVault, replaceEntry: config.replaceEntry, onPathRenamed: config.onPathRenamed, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent, }, path, key, value, options, runFrontmatterOp, }) }, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent]) const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => { const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange) if (!canFlush) return const newContent = await runFrontmatterOp('delete', path, key, undefined, options) if (!applyFrontmatterCallbacks({ config, path, newContent })) return await notifyFrontmatterPersisted(config, key) }, [config, runFrontmatterOp]) const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => { const canFlush = await flushBeforeFrontmatterMutation(path, config.flushBeforeFrontmatterChange) if (!canFlush) return const newContent = await runFrontmatterOp('update', path, key, value) if (!applyFrontmatterCallbacks({ config, path, newContent })) return await notifyFrontmatterPersisted(config, key) }, [config, runFrontmatterOp]) return { handleUpdateFrontmatter, handleDeleteProperty, handleAddProperty, } } export function useNoteActions(config: NoteActionsConfig) { const { entries, setToastMessage, updateEntry } = config const tabMgmt = useTabManagement(buildTabManagementOptions(config)) const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt const updateTabContent = useCallback((path: string, newContent: string) => { setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t)) }, [setTabs]) const creation = useNoteCreation(config, { openTabWithContent }) const rename = useNoteRename( { entries, setToastMessage, reloadVault: config.reloadVault }, { tabs: tabMgmt.tabs, setTabs, activeTabPathRef, handleSwitchTab, updateTabContent }, ) const handleNavigateWikilink = useCallback( (target: string) => navigateWikilink({ entries, target, selectNote: handleSelectNote }), [entries, handleSelectNote], ) const runFrontmatterOp = useCallback( (op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions) => runFrontmatterAndApply({ op, path, key, value, callbacks: { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options, }), [updateTabContent, updateEntry, setToastMessage, entries], ) const frontmatterActions = useFrontmatterActionHandlers({ config, renameTabsRef: rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent, runFrontmatterOp, }) return { ...tabMgmt, handleNavigateWikilink, handleCreateNote: creation.handleCreateNote, handleCreateNoteImmediate: creation.handleCreateNoteImmediate, handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship, handleCreateType: creation.handleCreateType, createTypeEntrySilent: creation.createTypeEntrySilent, handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter, handleDeleteProperty: frontmatterActions.handleDeleteProperty, handleAddProperty: frontmatterActions.handleAddProperty, handleRenameNote: rename.handleRenameNote, handleRenameFilename: rename.handleRenameFilename, handleMoveNoteToFolder: rename.handleMoveNoteToFolder, } }