fix: recover missing note frontmatter writes
This commit is contained in:
@@ -835,8 +835,10 @@ function App() {
|
||||
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : [], resolvedPath) },
|
||||
})
|
||||
|
||||
const handleInitializeProperties = useCallback(async (path: string) => {
|
||||
await initializeNoteProperties(notes.handleUpdateFrontmatter, path)
|
||||
const handleInitializeProperties = useCallback((path: string) => {
|
||||
void initializeNoteProperties(notes.handleUpdateFrontmatter, path).catch((err) => {
|
||||
console.warn('Failed to initialize note properties:', err)
|
||||
})
|
||||
}, [notes])
|
||||
|
||||
const handleRemoveNoteIcon = useCallback(async (path: string) => {
|
||||
|
||||
@@ -272,6 +272,7 @@ export interface FrontmatterApplyCallbacks {
|
||||
updateEntry: (path: VaultPath, patch: Partial<VaultEntry>) => void
|
||||
toast: (message: ToastMessage) => void
|
||||
getEntry?: (path: VaultPath) => VaultEntry | undefined
|
||||
onMissingNotePath?: (path: VaultPath, error: unknown) => void | Promise<void>
|
||||
shouldApply?: (path: VaultPath) => boolean
|
||||
}
|
||||
|
||||
@@ -339,15 +340,52 @@ function failureToastMessage(op: FrontmatterOp): ToastMessage {
|
||||
return `Failed to ${op} property`
|
||||
}
|
||||
|
||||
function handleFrontmatterFailure(
|
||||
op: FrontmatterOp,
|
||||
err: unknown,
|
||||
function frontmatterErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export function isMissingFrontmatterTargetError(error: unknown): boolean {
|
||||
return /does not exist|not found|enoent/i.test(frontmatterErrorMessage(error))
|
||||
}
|
||||
|
||||
async function runMissingNotePathCallback(
|
||||
path: VaultPath,
|
||||
error: unknown,
|
||||
callbacks: FrontmatterApplyCallbacks,
|
||||
options?: FrontmatterOpOptions,
|
||||
): undefined {
|
||||
console.error(`Failed to ${op} frontmatter:`, err)
|
||||
): Promise<void> {
|
||||
try {
|
||||
await callbacks.onMissingNotePath?.(path, error)
|
||||
} catch (callbackError) {
|
||||
console.warn('Failed to handle missing frontmatter target:', callbackError)
|
||||
}
|
||||
}
|
||||
|
||||
interface FrontmatterFailureParams {
|
||||
callbacks: FrontmatterApplyCallbacks
|
||||
err: unknown
|
||||
op: FrontmatterOp
|
||||
options?: FrontmatterOpOptions
|
||||
path: VaultPath
|
||||
}
|
||||
|
||||
async function handleFrontmatterFailure({
|
||||
callbacks,
|
||||
err,
|
||||
op,
|
||||
options,
|
||||
path,
|
||||
}: FrontmatterFailureParams): Promise<undefined> {
|
||||
const missingTarget = isMissingFrontmatterTargetError(err)
|
||||
if (missingTarget) {
|
||||
console.warn(`Skipped ${op} frontmatter for missing note:`, err)
|
||||
await runMissingNotePathCallback(path, err, callbacks)
|
||||
} else {
|
||||
console.error(`Failed to ${op} frontmatter:`, err)
|
||||
}
|
||||
if (options?.silent) throw err
|
||||
callbacks.toast(failureToastMessage(op))
|
||||
if (!missingTarget) callbacks.toast(failureToastMessage(op))
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -363,6 +401,6 @@ export async function runFrontmatterAndApply(request: FrontmatterRunRequest): Pr
|
||||
notifyFrontmatterSuccess(op, callbacks, options)
|
||||
return newContent
|
||||
} catch (err) {
|
||||
return handleFrontmatterFailure(op, err, callbacks, options)
|
||||
return handleFrontmatterFailure({ op, path, err, callbacks, options })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { isMissingFrontmatterTargetError, type FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface EntryActionsConfig {
|
||||
@@ -53,6 +53,14 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
|
||||
return entries.find((entry) => entry.isA === 'Type' && entry.title === typeName)
|
||||
}
|
||||
|
||||
function logOptimisticRollback(label: string, error: unknown): void {
|
||||
if (isMissingFrontmatterTargetError(error)) {
|
||||
console.warn(label, error)
|
||||
return
|
||||
}
|
||||
console.error(label, error)
|
||||
}
|
||||
|
||||
async function findOrCreateType(
|
||||
deps: Pick<TypeActionDeps, 'entries' | 'createTypeEntry'>,
|
||||
typeName: string,
|
||||
@@ -139,7 +147,7 @@ function useArchiveActions({
|
||||
} catch (err) {
|
||||
updateEntry(path, { archived: false })
|
||||
setToastMessage('Failed to archive note — rolled back')
|
||||
console.error('Optimistic archive rollback:', err)
|
||||
logOptimisticRollback('Optimistic archive rollback:', err)
|
||||
}
|
||||
}, [onBeforeAction, handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
@@ -153,7 +161,7 @@ function useArchiveActions({
|
||||
} catch (err) {
|
||||
updateEntry(path, { archived: true })
|
||||
setToastMessage('Failed to unarchive note — rolled back')
|
||||
console.error('Optimistic unarchive rollback:', err)
|
||||
logOptimisticRollback('Optimistic unarchive rollback:', err)
|
||||
}
|
||||
}, [handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useNoteActions, type NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
addMockEntry: vi.fn(),
|
||||
@@ -59,9 +61,34 @@ function makeConfig(overrides: Partial<NoteActionsConfig> = {}): NoteActionsConf
|
||||
}
|
||||
}
|
||||
|
||||
async function openNoteForFrontmatterRecovery(
|
||||
result: { current: ReturnType<typeof useNoteActions> },
|
||||
entry: VaultEntry,
|
||||
) {
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Missing Note\n')
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(entry)
|
||||
})
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke).mockRejectedValueOnce(new Error('File does not exist: /test/vault/missing.md'))
|
||||
}
|
||||
|
||||
function expectMissingFrontmatterRecovery(
|
||||
result: { current: ReturnType<typeof useNoteActions> },
|
||||
config: NoteActionsConfig,
|
||||
) {
|
||||
expect(result.current.tabs).toEqual([])
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
expect(config.reloadVault).toHaveBeenCalledTimes(1)
|
||||
expect(config.setToastMessage).toHaveBeenCalledWith(
|
||||
'"Missing Note" could not be opened because its file is missing or moved.',
|
||||
)
|
||||
}
|
||||
|
||||
describe('useNoteActions missing-path recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(invoke).mockReset()
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
})
|
||||
|
||||
@@ -129,4 +156,44 @@ describe('useNoteActions missing-path recovery', () => {
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reloads and clears the active tab when a frontmatter write targets a deleted note file', async () => {
|
||||
const entry = makeEntry()
|
||||
const config = makeConfig({ entries: [entry] })
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await openNoteForFrontmatterRecovery(result, entry)
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter(entry.path, 'status', 'Done')
|
||||
})
|
||||
|
||||
expectMissingFrontmatterRecovery(result, config)
|
||||
expect(config.updateEntry).not.toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('still rejects silent frontmatter writes after running missing-note recovery', async () => {
|
||||
const entry = makeEntry()
|
||||
const config = makeConfig({ entries: [entry] })
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await openNoteForFrontmatterRecovery(result, entry)
|
||||
let thrown: unknown
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.handleUpdateFrontmatter(entry.path, '_archived', true, { silent: true })
|
||||
} catch (err) {
|
||||
thrown = err
|
||||
}
|
||||
})
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error)
|
||||
expectMissingFrontmatterRecovery(result, config)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -315,6 +315,28 @@ function buildTabManagementOptions(
|
||||
return options
|
||||
}
|
||||
|
||||
function handleMissingFrontmatterTarget({
|
||||
activeTabPathRef,
|
||||
closeAllTabs,
|
||||
entries,
|
||||
path,
|
||||
reloadVault,
|
||||
setToastMessage,
|
||||
}: {
|
||||
activeTabPathRef: MutableRefObject<string | null>
|
||||
closeAllTabs: () => void
|
||||
entries: VaultEntry[]
|
||||
path: string
|
||||
reloadVault?: () => Promise<unknown>
|
||||
setToastMessage: NoteActionsConfig['setToastMessage']
|
||||
}) {
|
||||
const entry = findByNotePath(entries, path)
|
||||
const label = entry ? entryDisplayLabel(entry) : notePathFilename(path) || 'Note'
|
||||
if (notePathsMatch(activeTabPathRef.current, path)) closeAllTabs()
|
||||
setToastMessage(`"${label}" could not be opened because its file is missing or moved.`)
|
||||
void reloadVault?.()
|
||||
}
|
||||
|
||||
function useGitignoredVisibilityTabCleanup({
|
||||
activeTabPathRef,
|
||||
closeAllTabs,
|
||||
@@ -430,13 +452,17 @@ function useFrontmatterActionHandlers({
|
||||
|
||||
function useFrontmatterRunner({
|
||||
activeTabPathRef,
|
||||
closeAllTabs,
|
||||
entries,
|
||||
reloadVault,
|
||||
setToastMessage,
|
||||
updateEntry,
|
||||
updateTabContent,
|
||||
}: {
|
||||
activeTabPathRef: MutableRefObject<string | null>
|
||||
closeAllTabs: () => void
|
||||
entries: VaultEntry[]
|
||||
reloadVault?: () => Promise<unknown>
|
||||
setToastMessage: NoteActionsConfig['setToastMessage']
|
||||
updateEntry: NoteActionsConfig['updateEntry']
|
||||
updateTabContent: (path: string, newContent: string) => void
|
||||
@@ -452,11 +478,19 @@ function useFrontmatterRunner({
|
||||
updateEntry,
|
||||
toast: setToastMessage,
|
||||
getEntry: (p) => findByNotePath(entries, p),
|
||||
onMissingNotePath: (p) => handleMissingFrontmatterTarget({
|
||||
activeTabPathRef,
|
||||
closeAllTabs,
|
||||
entries,
|
||||
path: p,
|
||||
reloadVault,
|
||||
setToastMessage,
|
||||
}),
|
||||
shouldApply: (p) => activePathGuardAllowsMutation(p, activeTabPathRef, options),
|
||||
},
|
||||
options,
|
||||
}),
|
||||
[activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent],
|
||||
[activeTabPathRef, closeAllTabs, entries, reloadVault, setToastMessage, updateEntry, updateTabContent],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -502,7 +536,15 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
[entries, handleSelectNote, tabMgmt.activeTabPath, tabMgmt.tabs],
|
||||
)
|
||||
|
||||
const runFrontmatterOp = useFrontmatterRunner({ activeTabPathRef, entries, setToastMessage, updateEntry, updateTabContent })
|
||||
const runFrontmatterOp = useFrontmatterRunner({
|
||||
activeTabPathRef,
|
||||
closeAllTabs: tabMgmt.closeAllTabs,
|
||||
entries,
|
||||
reloadVault: config.reloadVault,
|
||||
setToastMessage,
|
||||
updateEntry,
|
||||
updateTabContent,
|
||||
})
|
||||
const frontmatterActions = useFrontmatterActionHandlers({
|
||||
config,
|
||||
onPathRenamed: handlePathRenamed,
|
||||
|
||||
Reference in New Issue
Block a user