Files
tolaria/src/hooks/frontmatterOps.ts
2026-05-23 18:12:58 +02:00

409 lines
15 KiB
TypeScript

import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, VaultPropertyValue } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
import { updateMockContent, trackMockChange } from '../mock-tauri'
import { parseFrontmatter } from '../utils/frontmatter'
import { canonicalFrontmatterKey, isSystemMetadataKey } from '../utils/systemMetadata'
import { normalizeNoteWidthMode } from '../utils/noteWidth'
type FrontmatterCommand = 'update_frontmatter' | 'delete_frontmatter_property'
type FrontmatterKey = string
type FrontmatterOp = 'update' | 'delete'
type MarkdownContent = string
type ToastMessage = string | null
type VaultPath = string
type WikilinkText = string
type PropertyPatchValue = VaultEntry['properties'][string]
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
title: { title: '' },
type: { isA: null }, status: { status: null }, color: { color: null },
_icon: { icon: null }, _sidebar_label: { sidebarLabel: null },
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
_archived: { archived: false },
_order: { order: null },
template: { template: null }, _sort: { sort: null }, view: { view: null },
_width: { noteWidth: null }, visible: { visible: null },
_organized: { organized: false },
_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },
_list_properties_display: { listPropertiesDisplay: [] },
}
/** Check if a string contains a wikilink pattern `[[...]]`. */
function isWikilink(value: WikilinkText): boolean {
return value.startsWith('[[') && value.includes(']]')
}
/** Extract wikilink strings from a FrontmatterValue. Returns empty array if none. */
function extractWikilinks(value: FrontmatterValue): WikilinkText[] {
if (typeof value === 'string') return isWikilink(value) ? [value] : []
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string' && isWikilink(v))
return []
}
/**
* Relationship patch: a partial update to merge into `entry.relationships`.
* Keys map to their new ref arrays. A `null` value means "remove this key".
*/
export type RelationshipPatch = Record<FrontmatterKey, WikilinkText[] | null>
/** Properties patch: a partial update to merge into `entry.properties`.
* Keys map to their new property values. A `null` value means "remove this key". */
export type PropertiesPatch = Record<FrontmatterKey, PropertyPatchValue>
export interface EntryPatchResult {
patch: Partial<VaultEntry>
relationshipPatch: RelationshipPatch | null
propertiesPatch: PropertiesPatch | null
}
interface FrontmatterPatchInput {
key: FrontmatterKey
lookupKey: FrontmatterKey
systemMetadataKey: boolean
value?: FrontmatterValue
}
function singleEntryRecord<T>({ key, value }: { key: FrontmatterKey; value: T }): Record<FrontmatterKey, T> {
return Object.fromEntries([[key, value]])
}
function applyRecordPatch<T>(
existing: Record<string, T>,
patch: Record<string, T | null>,
): Record<string, T> {
const merged = { ...existing }
for (const [key, value] of Object.entries(patch)) {
if (value === null) Reflect.deleteProperty(merged, key)
else Reflect.set(merged, key, value)
}
return merged
}
function frontmatterString(value: FrontmatterValue | undefined): string | null {
return value != null ? String(value) : null
}
function frontmatterStringList(value: FrontmatterValue | undefined): string[] {
return Array.isArray(value) ? value.map(String) : []
}
function frontmatterNumber(value: FrontmatterValue | undefined): number | null {
return typeof value === 'number' ? value : null
}
function visibleValue(value: FrontmatterValue | undefined): false | null {
return value === false ? false : null
}
function propertyValue(value: FrontmatterValue): PropertyPatchValue {
if (Array.isArray(value)) {
const values = value.map(String)
return values.length === 1 ? values[0] ?? '' : values
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value
return String(value)
}
function knownFrontmatterUpdates(value: FrontmatterValue | undefined): Record<FrontmatterKey, Partial<VaultEntry>> {
const str = frontmatterString(value)
const arr = frontmatterStringList(value)
return {
title: { title: str ?? '' },
type: { isA: str }, status: { status: str }, color: { color: str },
_icon: { icon: str }, _sidebar_label: { sidebarLabel: str },
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
_archived: { archived: Boolean(value) },
_order: { order: frontmatterNumber(value) },
template: { template: str },
_sort: { sort: str },
view: { view: str },
_width: { noteWidth: normalizeNoteWidthMode(value) },
visible: { visible: visibleValue(value) },
_organized: { organized: Boolean(value) },
_favorite: { favorite: Boolean(value) },
_favorite_index: { favoriteIndex: frontmatterNumber(value) },
_list_properties_display: { listPropertiesDisplay: arr },
}
}
function deleteEntryPatch({ key, lookupKey, systemMetadataKey }: FrontmatterPatchInput): EntryPatchResult {
const relationshipPatch = systemMetadataKey ? null : singleEntryRecord({ key, value: null })
const hasKnownDelete = Object.hasOwn(ENTRY_DELETE_MAP, lookupKey)
const propertiesPatch = !systemMetadataKey && !hasKnownDelete ? singleEntryRecord({ key, value: null }) : null
return {
patch: (Reflect.get(ENTRY_DELETE_MAP, lookupKey) as Partial<VaultEntry> | undefined) ?? {},
relationshipPatch,
propertiesPatch,
}
}
function relationshipUpdatePatch(
key: FrontmatterKey,
systemMetadataKey: boolean,
value: FrontmatterValue | undefined,
): RelationshipPatch | null {
const wikilinks = value != null ? extractWikilinks(value) : []
return !systemMetadataKey && wikilinks.length > 0 ? singleEntryRecord({ key, value: wikilinks }) : null
}
function propertiesUpdatePatch({
key,
lookupKey,
systemMetadataKey,
value,
}: FrontmatterPatchInput): PropertiesPatch | null {
const knownUpdates = knownFrontmatterUpdates(value)
if (systemMetadataKey || Object.hasOwn(knownUpdates, lookupKey) || value == null) return null
return singleEntryRecord({ key, value: propertyValue(value) })
}
function updateEntryPatch(input: FrontmatterPatchInput): EntryPatchResult {
const updates = knownFrontmatterUpdates(input.value)
return {
patch: (Reflect.get(updates, input.lookupKey) as Partial<VaultEntry> | undefined) ?? {},
relationshipPatch: relationshipUpdatePatch(input.key, input.systemMetadataKey, input.value),
propertiesPatch: propertiesUpdatePatch(input),
}
}
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
export function frontmatterToEntryPatch(
op: FrontmatterOp, key: FrontmatterKey, value?: FrontmatterValue,
): EntryPatchResult {
const lookupKey = canonicalFrontmatterKey(key)
const systemMetadataKey = isSystemMetadataKey(key)
const input = { key, lookupKey, systemMetadataKey, value }
return op === 'delete' ? deleteEntryPatch(input) : updateEntryPatch(input)
}
/** Parse frontmatter from full content and return a merged VaultEntry patch for all known fields. */
export function contentToEntryPatch(content: MarkdownContent): Partial<VaultEntry> {
const fm = parseFrontmatter(content)
const merged: Partial<VaultEntry> = {}
const customProps: Record<FrontmatterKey, PropertyPatchValue> = {}
for (const [key, value] of Object.entries(fm)) {
const { patch, propertiesPatch } = frontmatterToEntryPatch('update', key, value)
Object.assign(merged, patch)
if (propertiesPatch) Object.assign(customProps, propertiesPatch)
}
if (Object.keys(customProps).length > 0) merged.properties = customProps
return merged
}
async function invokeFrontmatter(command: FrontmatterCommand, args: Record<string, unknown>): Promise<MarkdownContent> {
return invoke<string>(command, args)
}
function seedMockContent(path: VaultPath, content: MarkdownContent): void {
updateMockContent(path, content)
}
async function loadMockContent(path: VaultPath): Promise<MarkdownContent> {
try {
return await mockInvoke<MarkdownContent>('get_note_content', { path })
} catch {
return typeof window === 'undefined'
? ''
: (window.__mockContent ? Reflect.get(window.__mockContent, path) as string | undefined : undefined) ?? ''
}
}
async function persistMockContent(path: VaultPath, content: MarkdownContent): Promise<void> {
try {
await mockInvoke('save_note_content', { path, content })
} finally {
updateMockContent(path, content)
trackMockChange(path)
}
}
function applyMockFrontmatterUpdate(path: VaultPath, key: FrontmatterKey, value: FrontmatterValue): MarkdownContent {
const content = updateMockFrontmatter(path, key, value)
return content
}
function applyMockFrontmatterDelete(path: VaultPath, key: FrontmatterKey): MarkdownContent {
const content = deleteMockFrontmatterProperty(path, key)
return content
}
async function executeMockFrontmatterOp(
op: FrontmatterOp,
path: VaultPath,
key: FrontmatterKey,
value?: FrontmatterValue,
): Promise<MarkdownContent> {
seedMockContent(path, await loadMockContent(path))
const content = op === 'update'
? applyMockFrontmatterUpdate(path, key, value!)
: applyMockFrontmatterDelete(path, key)
await persistMockContent(path, content)
return content
}
async function executeFrontmatterOp(
op: FrontmatterOp,
path: VaultPath,
key: FrontmatterKey,
value?: FrontmatterValue,
): Promise<MarkdownContent> {
if (op === 'update') {
return isTauri()
? invokeFrontmatter('update_frontmatter', { path, key, value })
: executeMockFrontmatterOp(op, path, key, value)
}
return isTauri()
? invokeFrontmatter('delete_frontmatter_property', { path, key })
: executeMockFrontmatterOp(op, path, key)
}
export interface FrontmatterOpOptions {
/** Suppress toast feedback (caller manages its own toast). */
silent?: boolean
/** Require this note to still be active before applying UI-side mutation state. */
requireActivePath?: VaultPath
}
export interface FrontmatterApplyCallbacks {
updateTab: (path: VaultPath, content: MarkdownContent) => void
updateEntry: (path: VaultPath, patch: Partial<VaultEntry>) => void
cacheContent?: (path: VaultPath, content: MarkdownContent) => void
toast: (message: ToastMessage) => void
getEntry?: (path: VaultPath) => VaultEntry | undefined
onMissingNotePath?: (path: VaultPath, error: unknown) => void | Promise<void>
shouldApply?: (path: VaultPath) => boolean
}
export interface FrontmatterRunRequest {
op: FrontmatterOp
path: VaultPath
key: FrontmatterKey
value?: FrontmatterValue
callbacks: FrontmatterApplyCallbacks
options?: FrontmatterOpOptions
}
/** Apply a properties patch by merging into the existing properties map. */
export function applyPropertiesPatch(
existing: Record<FrontmatterKey, VaultPropertyValue>, propPatch: PropertiesPatch,
): Record<FrontmatterKey, VaultPropertyValue> {
return applyRecordPatch(existing, propPatch)
}
/** Apply a relationship patch by merging into the existing relationships map. */
export function applyRelationshipPatch(
existing: Record<FrontmatterKey, WikilinkText[]>, relPatch: RelationshipPatch,
): Record<FrontmatterKey, WikilinkText[]> {
return applyRecordPatch(existing, relPatch)
}
function patchNeedsExistingEntry(result: EntryPatchResult): boolean {
return Boolean(result.relationshipPatch || result.propertiesPatch)
}
function buildFullEntryPatch(
path: VaultPath,
callbacks: FrontmatterApplyCallbacks,
result: EntryPatchResult,
): Partial<VaultEntry> {
const fullPatch = { ...result.patch }
if (!patchNeedsExistingEntry(result) || !callbacks.getEntry) return fullPatch
const current = callbacks.getEntry(path)
if (!current) return fullPatch
if (result.relationshipPatch) {
fullPatch.relationships = applyRelationshipPatch(current.relationships, result.relationshipPatch)
}
if (result.propertiesPatch) {
fullPatch.properties = applyPropertiesPatch(current.properties, result.propertiesPatch)
}
return fullPatch
}
function applyEntryPatch(path: VaultPath, callbacks: FrontmatterApplyCallbacks, result: EntryPatchResult) {
const fullPatch = buildFullEntryPatch(path, callbacks, result)
if (Object.keys(fullPatch).length > 0) callbacks.updateEntry(path, fullPatch)
}
function successToastMessage(op: FrontmatterOp): ToastMessage {
return op === 'update' ? 'Property updated' : 'Property deleted'
}
function notifyFrontmatterSuccess(op: FrontmatterOp, callbacks: FrontmatterApplyCallbacks, options?: FrontmatterOpOptions) {
if (!options?.silent) callbacks.toast(successToastMessage(op))
}
function failureToastMessage(op: FrontmatterOp): ToastMessage {
return `Failed to ${op} property`
}
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,
): 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
if (!missingTarget) callbacks.toast(failureToastMessage(op))
return undefined
}
/** Run a frontmatter update/delete and apply the result to state.
* Returns the new file content on success, or undefined on failure. */
export async function runFrontmatterAndApply(request: FrontmatterRunRequest): Promise<MarkdownContent | undefined> {
const { op, path, key, value, callbacks, options } = request
try {
const newContent = await executeFrontmatterOp(op, path, key, value)
callbacks.cacheContent?.(path, newContent)
if (callbacks.shouldApply && !callbacks.shouldApply(path)) return undefined
callbacks.updateTab(path, newContent)
applyEntryPatch(path, callbacks, frontmatterToEntryPatch(op, key, value))
notifyFrontmatterSuccess(op, callbacks, options)
return newContent
} catch (err) {
return handleFrontmatterFailure({ op, path, err, callbacks, options })
}
}