Files
tolaria/src/hooks/useNoteActions.ts
2026-04-14 16:49:18 +02:00

249 lines
9.4 KiB
TypeScript

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[]
flushBeforePathRename?: (path: string) => Promise<void>
reloadVault?: () => Promise<unknown>
setToastMessage: (msg: string | null) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
vaultPath: string
addPendingSave?: (path: string) => void
removePendingSave?: (path: string) => void
trackUnsaved?: (path: string) => void
clearUnsaved?: (path: string) => void
unsavedPaths?: Set<string>
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { 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
}
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<unknown>
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
onPathRenamed?: (oldPath: string, newPath: string) => void
setTabs: React.Dispatch<React.SetStateAction<{ entry: VaultEntry; content: string }[]>>
activeTabPathRef: React.MutableRefObject<string | null>
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<void> {
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<VaultEntry> & { 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))
}
function shouldRenameOnTitleUpdate(key: string, value: FrontmatterValue): value is string {
return isTitleKey(key) && typeof value === 'string' && value !== ''
}
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<void>,
): Promise<boolean> {
if (!shouldRenameOnTitleUpdate(key, value) || !flushBeforePathRename) return true
try {
await flushBeforePathRename(path)
return true
} catch {
return false
}
}
async function maybeRenameAfterFrontmatterUpdate({
path,
key,
value,
deps,
}: MaybeRenameAfterFrontmatterUpdateParams): Promise<void> {
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<string | undefined>
value: FrontmatterValue
}
async function updateFrontmatterAndMaybeRename({
config,
deps,
key,
options,
path,
runFrontmatterOp,
value,
}: UpdateFrontmatterAndMaybeRenameParams): Promise<void> {
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 })
config.onFrontmatterPersisted?.()
}
export function useNoteActions(config: NoteActionsConfig) {
const { entries, setToastMessage, updateEntry } = config
const tabMgmt = useTabManagement()
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, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
[updateTabContent, updateEntry, setToastMessage, entries],
)
return {
...tabMgmt,
handleNavigateWikilink,
handleCreateNote: creation.handleCreateNote,
handleCreateNoteImmediate: creation.handleCreateNoteImmediate,
handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship,
handleCreateType: creation.handleCreateType,
createTypeEntrySilent: creation.createTypeEntrySilent,
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
await updateFrontmatterAndMaybeRename({
config,
deps: {
vaultPath: config.vaultPath,
tabsRef: rename.tabsRef,
reloadVault: config.reloadVault,
replaceEntry: config.replaceEntry,
onPathRenamed: config.onPathRenamed,
setTabs,
activeTabPathRef,
handleSwitchTab,
setToastMessage,
updateTabContent,
},
path,
key,
value,
options,
runFrontmatterOp,
})
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
config.onFrontmatterPersisted?.()
}, [runFrontmatterOp, config]),
handleRenameNote: rename.handleRenameNote,
handleRenameFilename: rename.handleRenameFilename,
}
}