import { useCallback, useMemo } from 'react' import type { VaultEntry } from '../types' import { isMissingFrontmatterTargetError, type FrontmatterOpOptions } from './frontmatterOps' import { trackEvent } from '../lib/telemetry' import { findTypeDefinition } from '../utils/typeDefinitions' import type { ActionHistoryController, ActionHistoryEntry } from './useActionHistory' interface EntryActionsConfig { entries: VaultEntry[] updateEntry: (path: string, updates: Partial) => void handleUpdateFrontmatter: (path: string, key: string, value: string | number | boolean | string[], options?: FrontmatterOpOptions) => Promise handleDeleteProperty: (path: string, key: string, options?: FrontmatterOpOptions) => Promise setToastMessage: (msg: string | null) => void createTypeEntry: (typeName: string) => Promise onFrontmatterPersisted?: () => void /** Called before trash/archive to flush unsaved editor content to disk. */ onBeforeAction?: (path: string) => Promise actionHistory?: ActionHistoryController } type ArchiveActionDeps = Pick type TypeActionDeps = Pick type EntryStateActionDeps = Pick type FavoriteActionDeps = Pick type ReorderFavoritesDeps = Pick type FavoriteState = Pick type FavoriteReplay = (path: string, favorite: boolean, favoriteIndex: number | null) => Promise interface ArchiveTransition { path: string before: boolean after: boolean eventName: string label: string toast: string rollbackToast: string rollbackLog: string } interface FavoriteTransition { path: string action: 'favorite' | 'unfavorite' eventName: string label: string before: FavoriteState after: FavoriteState rollback: FavoriteState rollbackToast: string } interface StateTransitionInput { path: string before: boolean after: boolean } interface CustomizeTypeArgs { typeName: string icon: string color: string } interface ReorderTypeSectionsArgs { orderedTypes: { typeName: string; order: number }[] } interface UpdateTypeTemplateArgs { typeName: string template: string } interface RenameTypeSectionArgs { typeName: string label: string } function logOptimisticRollback(label: string, error: unknown): void { if (isMissingFrontmatterTargetError(error)) { console.warn(label, error) return } console.error(label, error) } function recordEntryActionHistory( actionHistory: ActionHistoryController | undefined, entry: ActionHistoryEntry, ): (() => void) | void { return actionHistory?.record(entry) } async function persistBooleanProperty( deps: Pick, path: string, key: string, enabled: boolean, ): Promise { if (enabled) { await deps.handleUpdateFrontmatter(path, key, true, { silent: true }) } else { await deps.handleDeleteProperty(path, key, { silent: true }) } deps.onFrontmatterPersisted?.() } async function applyEntryBooleanState( deps: Pick, path: string, key: string, patchKey: keyof Pick, enabled: boolean, ): Promise { await persistBooleanProperty(deps, path, key, enabled) deps.updateEntry(path, { [patchKey]: enabled }) } function recordBooleanStateHistory( deps: Pick, params: { id: string label: string path: string key: string patchKey: keyof Pick before: boolean after: boolean waitForPersist?: Promise }, ): (() => void) | void { const applyState = async (enabled: boolean) => { await params.waitForPersist await applyEntryBooleanState(deps, params.path, params.key, params.patchKey, enabled) } return recordEntryActionHistory(deps.actionHistory, { id: params.id, label: params.label, path: params.path, undo: () => applyState(params.before), redo: () => applyState(params.after), }) } async function findOrCreateType( deps: Pick, typeName: string, typeEntryPath?: string, ): Promise { const existingType = findTypeDefinition({ entries: deps.entries, type: typeName, typeEntryPath }) if (existingType) return existingType if (typeEntryPath) return null try { return await deps.createTypeEntry(typeName) } catch { return null } } async function customizeTypeEntry(deps: TypeActionDeps, args: CustomizeTypeArgs): Promise { const typeEntry = await findOrCreateType(deps, args.typeName) if (!typeEntry) return await deps.handleUpdateFrontmatter(typeEntry.path, 'icon', args.icon) await deps.handleUpdateFrontmatter(typeEntry.path, 'color', args.color) deps.updateEntry(typeEntry.path, { icon: args.icon, color: args.color }) deps.onFrontmatterPersisted?.() } async function reorderTypeSections(deps: TypeActionDeps, args: ReorderTypeSectionsArgs): Promise { for (const { typeName, order } of args.orderedTypes) { const typeEntry = await findOrCreateType(deps, typeName) if (!typeEntry) return await deps.handleUpdateFrontmatter(typeEntry.path, 'order', order) deps.updateEntry(typeEntry.path, { order }) } deps.onFrontmatterPersisted?.() } async function updateTypeTemplate(deps: TypeActionDeps, args: UpdateTypeTemplateArgs): Promise { const typeEntry = await findOrCreateType(deps, args.typeName) if (!typeEntry) return await deps.handleUpdateFrontmatter(typeEntry.path, 'template', args.template) deps.updateEntry(typeEntry.path, { template: args.template || null }) deps.onFrontmatterPersisted?.() } async function renameTypeSection(deps: TypeActionDeps, args: RenameTypeSectionArgs): Promise { const typeEntry = await findOrCreateType(deps, args.typeName) if (!typeEntry) return const trimmed = args.label.trim() if (trimmed) { await deps.handleUpdateFrontmatter(typeEntry.path, 'sidebar label', trimmed) } else { await deps.handleDeleteProperty(typeEntry.path, 'sidebar label') } deps.updateEntry(typeEntry.path, { sidebarLabel: trimmed || null }) deps.onFrontmatterPersisted?.() } async function toggleTypeVisibility(deps: TypeActionDeps, typeName: string, typeEntryPath?: string): Promise { const typeEntry = await findOrCreateType(deps, typeName, typeEntryPath) if (!typeEntry) return if (typeEntry.visible === false) { await deps.handleDeleteProperty(typeEntry.path, 'visible') deps.updateEntry(typeEntry.path, { visible: null }) } else { await deps.handleUpdateFrontmatter(typeEntry.path, 'visible', false) deps.updateEntry(typeEntry.path, { visible: false }) } deps.onFrontmatterPersisted?.() } function useArchiveActions({ entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted, onBeforeAction, actionHistory, }: ArchiveActionDeps) { const handleArchiveNote = useCallback((path: string) => archiveNote({ entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted, onBeforeAction, actionHistory, }, path), [ actionHistory, entries, handleDeleteProperty, handleUpdateFrontmatter, onBeforeAction, onFrontmatterPersisted, setToastMessage, updateEntry, ]) const handleUnarchiveNote = useCallback((path: string) => unarchiveNote({ entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted, onBeforeAction, actionHistory, }, path), [ actionHistory, entries, handleDeleteProperty, handleUpdateFrontmatter, onBeforeAction, onFrontmatterPersisted, setToastMessage, updateEntry, ]) return { handleArchiveNote, handleUnarchiveNote } } async function archiveNote(deps: ArchiveActionDeps, path: string): Promise { const entry = deps.entries.find((candidate) => candidate.path === path) await deps.onBeforeAction?.(path) await runArchiveTransition(deps, createArchiveTransition({ path, before: entry?.archived ?? false, after: true })) } async function unarchiveNote(deps: ArchiveActionDeps, path: string): Promise { const entry = deps.entries.find((candidate) => candidate.path === path) await runArchiveTransition(deps, createArchiveTransition({ path, before: entry?.archived ?? true, after: false })) } function createArchiveTransition(input: StateTransitionInput): ArchiveTransition { if (input.after) { return { path: input.path, before: input.before, after: input.after, eventName: 'note_archived', label: 'Archive Note', toast: 'Note archived', rollbackToast: 'Failed to archive note — rolled back', rollbackLog: 'Optimistic archive rollback:', } } return { path: input.path, before: input.before, after: input.after, eventName: 'note_unarchived', label: 'Unarchive Note', toast: 'Note unarchived', rollbackToast: 'Failed to unarchive note — rolled back', rollbackLog: 'Optimistic unarchive rollback:', } } async function runArchiveTransition(deps: ArchiveActionDeps, transition: ArchiveTransition): Promise { deps.updateEntry(transition.path, { archived: transition.after }) trackEvent(transition.eventName) deps.setToastMessage(transition.toast) const persistPromise = persistBooleanProperty(deps, transition.path, '_archived', transition.after) const cleanupHistory = recordArchiveHistory(deps, transition, persistPromise) try { await persistPromise } catch (err) { cleanupHistory?.() deps.updateEntry(transition.path, { archived: transition.before }) deps.setToastMessage(transition.rollbackToast) logOptimisticRollback(transition.rollbackLog, err) } } function recordArchiveHistory( deps: ArchiveActionDeps, transition: ArchiveTransition, waitForPersist: Promise, ): (() => void) | void { return recordBooleanStateHistory(deps, { id: `${transition.after ? 'archive' : 'unarchive'}:${transition.path}:${Date.now()}`, label: transition.label, path: transition.path, key: '_archived', patchKey: 'archived', before: transition.before, after: transition.after, waitForPersist, }) } function useTypeActions(deps: TypeActionDeps) { const { entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, createTypeEntry, onFrontmatterPersisted, } = deps const typeActionDeps = useMemo(() => ({ entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, createTypeEntry, onFrontmatterPersisted, }), [entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, createTypeEntry, onFrontmatterPersisted]) const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => { await customizeTypeEntry(typeActionDeps, { typeName, icon, color }) }, [typeActionDeps]) const handleReorderSections = useCallback(async (orderedTypes: { typeName: string; order: number }[]) => { await reorderTypeSections(typeActionDeps, { orderedTypes }) }, [typeActionDeps]) const handleUpdateTypeTemplate = useCallback(async (typeName: string, template: string) => { await updateTypeTemplate(typeActionDeps, { typeName, template }) }, [typeActionDeps]) const handleRenameSection = useCallback(async (typeName: string, label: string) => { await renameTypeSection(typeActionDeps, { typeName, label }) }, [typeActionDeps]) const handleToggleTypeVisibility = useCallback(async (typeName: string, typeEntryPath?: string) => { await toggleTypeVisibility(typeActionDeps, typeName, typeEntryPath) }, [typeActionDeps]) return { handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility } } function useFavoriteAction({ entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted, actionHistory, }: EntryStateActionDeps) { const applyFavoriteState = useCallback(async (path: string, favorite: boolean, favoriteIndex: number | null) => { if (favorite) { await handleUpdateFrontmatter(path, '_favorite', true, { silent: true }) await handleUpdateFrontmatter(path, '_favorite_index', favoriteIndex ?? 1, { silent: true }) } else { await handleDeleteProperty(path, '_favorite', { silent: true }) await handleDeleteProperty(path, '_favorite_index', { silent: true }) } onFrontmatterPersisted?.() updateEntry(path, { favorite, favoriteIndex }) }, [handleDeleteProperty, handleUpdateFrontmatter, onFrontmatterPersisted, updateEntry]) return useCallback(async (path: string) => { const entry = entries.find((candidate) => candidate.path === path) if (!entry) return const before = { favorite: entry.favorite, favoriteIndex: entry.favoriteIndex } if (entry.favorite) { await runFavoriteTransition({ handleUpdateFrontmatter, handleDeleteProperty, onFrontmatterPersisted, updateEntry, setToastMessage, actionHistory, }, { path, action: 'unfavorite', eventName: 'note_unfavorited', label: 'Remove from Favorites', before, after: { favorite: false, favoriteIndex: null }, rollback: { favorite: true, favoriteIndex: entry.favoriteIndex }, rollbackToast: 'Failed to unfavorite — rolled back', }, applyFavoriteState) return } const newIndex = nextFavoriteIndex(entries) await runFavoriteTransition({ handleUpdateFrontmatter, handleDeleteProperty, onFrontmatterPersisted, updateEntry, setToastMessage, actionHistory, }, { path, action: 'favorite', eventName: 'note_favorited', label: 'Add to Favorites', before, after: { favorite: true, favoriteIndex: newIndex }, rollback: { favorite: false, favoriteIndex: null }, rollbackToast: 'Failed to favorite — rolled back', }, applyFavoriteState) }, [applyFavoriteState, entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted, actionHistory]) } function nextFavoriteIndex(entries: VaultEntry[]): number { return entries .filter((candidate) => candidate.favorite) .reduce((max, candidate) => Math.max(max, candidate.favoriteIndex ?? 0), 0) + 1 } async function runFavoriteTransition( deps: FavoriteActionDeps, transition: FavoriteTransition, applyFavoriteState: FavoriteReplay, ): Promise { trackEvent(transition.eventName) deps.updateEntry(transition.path, transition.after) const persistPromise = persistFavoriteFrontmatter(deps, transition.path, transition.after) const cleanupHistory = recordFavoriteHistory(deps, transition, applyFavoriteState, persistPromise) try { await persistPromise } catch { cleanupHistory?.() deps.updateEntry(transition.path, transition.rollback) deps.setToastMessage(transition.rollbackToast) } } async function persistFavoriteFrontmatter( deps: FavoriteActionDeps, path: string, state: FavoriteState, ): Promise { if (state.favorite) { await deps.handleUpdateFrontmatter(path, '_favorite', true, { silent: true }) await deps.handleUpdateFrontmatter(path, '_favorite_index', state.favoriteIndex ?? 1, { silent: true }) } else { await deps.handleDeleteProperty(path, '_favorite', { silent: true }) await deps.handleDeleteProperty(path, '_favorite_index', { silent: true }) } deps.onFrontmatterPersisted?.() } function recordFavoriteHistory( deps: Pick, transition: FavoriteTransition, applyFavoriteState: FavoriteReplay, waitForPersist: Promise, ): (() => void) | void { const replay = async (state: FavoriteState) => { await waitForPersist await applyFavoriteState(transition.path, state.favorite, state.favoriteIndex) } return recordEntryActionHistory(deps.actionHistory, { id: `${transition.action}:${transition.path}:${Date.now()}`, label: transition.label, path: transition.path, undo: () => replay(transition.before), redo: () => replay(transition.after), }) } function useOrganizedAction({ entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted, actionHistory, }: EntryStateActionDeps) { return useCallback(async (path: string) => { const entry = entries.find((candidate) => candidate.path === path) if (!entry) return false return runOrganizedTransition({ entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted, actionHistory, }, { path, before: entry.organized, after: !entry.organized }) }, [ entries, handleDeleteProperty, handleUpdateFrontmatter, onFrontmatterPersisted, actionHistory, setToastMessage, updateEntry, ]) } async function runOrganizedTransition( deps: EntryStateActionDeps, transition: StateTransitionInput, ): Promise { const action = createOrganizedTransition(transition) deps.updateEntry(transition.path, { organized: transition.after }) trackEvent(action.eventName) const persistPromise = persistBooleanProperty(deps, transition.path, '_organized', transition.after) const cleanupHistory = recordBooleanStateHistory(deps, { id: `${action.idPrefix}:${transition.path}:${Date.now()}`, label: action.label, path: transition.path, key: '_organized', patchKey: 'organized', before: transition.before, after: transition.after, waitForPersist: persistPromise, }) try { await persistPromise return true } catch { cleanupHistory?.() deps.updateEntry(transition.path, { organized: transition.before }) deps.setToastMessage(action.rollbackToast) return false } } function createOrganizedTransition(transition: StateTransitionInput) { return transition.after ? { idPrefix: 'organize', eventName: 'note_organized', label: 'Mark as Organized', rollbackToast: 'Failed to organize — rolled back', } : { idPrefix: 'unorganize', eventName: 'note_unorganized', label: 'Mark as Unorganized', rollbackToast: 'Failed to unorganize — rolled back', } } function useReorderFavoritesAction({ updateEntry, handleUpdateFrontmatter, onFrontmatterPersisted }: ReorderFavoritesDeps) { return useCallback(async (orderedPaths: string[]) => { for (let i = 0; i < orderedPaths.length; i++) { const orderedPath = orderedPaths.at(i) if (!orderedPath) continue updateEntry(orderedPath, { favoriteIndex: i }) await handleUpdateFrontmatter(orderedPath, '_favorite_index', i, { silent: true }) } onFrontmatterPersisted?.() }, [updateEntry, handleUpdateFrontmatter, onFrontmatterPersisted]) } export function useEntryActions(config: EntryActionsConfig) { const archiveActions = useArchiveActions(config) const typeActions = useTypeActions(config) const handleToggleFavorite = useFavoriteAction(config) const handleToggleOrganized = useOrganizedAction(config) const handleReorderFavorites = useReorderFavoritesAction(config) return { ...archiveActions, ...typeActions, handleToggleFavorite, handleToggleOrganized, handleReorderFavorites, } }