Files
tolaria/src/utils/vaultMetadataNormalization.ts
2026-05-15 08:33:17 +02:00

264 lines
8.9 KiB
TypeScript

import type { FilterGroup, FilterNode, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from '../types'
type UnknownRecord = Record<string, unknown>
interface EntryNormalizationArgs {
rawEntry: unknown
vaultPath: string
index: number
workspace?: WorkspaceIdentity
}
interface EntryPathArgs {
explicitPath: string
filename: string
vaultPath: string
}
interface ViewNormalizationArgs {
rawView: unknown
index: number
}
interface ViewDefinitionArgs {
rawDefinition: unknown
filename: string
index: number
}
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function recordFrom(value: unknown): UnknownRecord {
return isRecord(value) ? value : {}
}
function stringFrom(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function nullableStringFrom(value: unknown): string | null {
const text = stringFrom(value).trim()
return text.length > 0 ? text : null
}
function numberFrom(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function nullableNumberFrom(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function booleanFrom(value: unknown, fallback = false): boolean {
return typeof value === 'boolean' ? value : fallback
}
function nullableBooleanFrom(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null
}
function stringArrayFrom(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}
function hasUsablePath(rawEntry: unknown): boolean {
const source = recordFrom(rawEntry)
return typeof source.path === 'string' && source.path.trim().length > 0
}
function filenameFromPath(path: string): string {
const normalizedPath = path.replace(/\\/g, '/')
return normalizedPath.split('/').filter(Boolean).pop() ?? ''
}
function stripExtension(filename: string): string {
return filename.replace(/\.[^.]+$/, '')
}
function fallbackEntryFilename(source: UnknownRecord, index: number): string {
return stringFrom(source.filename) || filenameFromPath(stringFrom(source.path)) || `untitled-${index + 1}.md`
}
function resolveEntryPath({ explicitPath, filename, vaultPath }: EntryPathArgs): string {
if (explicitPath) return explicitPath
const root = vaultPath.replace(/\/+$/, '')
return root ? `${root}/${filename}` : filename
}
function normalizeRelationships(value: unknown): Record<string, string[]> {
const source = recordFrom(value)
const result: Record<string, string[]> = {}
for (const [key, rawRefs] of Object.entries(source)) {
const refs = stringArrayFrom(rawRefs)
if (refs.length > 0) Reflect.set(result, key, refs)
}
return result
}
function normalizeProperties(value: unknown): VaultEntry['properties'] {
const source = recordFrom(value)
const result: VaultEntry['properties'] = {}
for (const [key, rawValue] of Object.entries(source)) {
if (Array.isArray(rawValue)) {
const values = rawValue.filter((item): item is string | number | boolean => (
typeof item === 'string'
|| typeof item === 'boolean'
|| (typeof item === 'number' && Number.isFinite(item))
))
if (values.length === rawValue.length) {
Reflect.set(result, key, values.length === 1 ? values[0] : values)
}
continue
}
if (
rawValue === null
|| typeof rawValue === 'string'
|| typeof rawValue === 'boolean'
|| (typeof rawValue === 'number' && Number.isFinite(rawValue))
) {
Reflect.set(result, key, rawValue)
}
}
return result
}
function normalizeFileKind(value: unknown): VaultEntry['fileKind'] {
if (value === 'markdown' || value === 'text' || value === 'binary') return value
return undefined
}
function normalizeWorkspaceIdentity(value: unknown): WorkspaceIdentity | undefined {
const source = recordFrom(value)
const id = stringFrom(source.id).trim()
const label = stringFrom(source.label).trim()
const alias = stringFrom(source.alias).trim()
const path = stringFrom(source.path).trim()
if (!id || !label || !alias || !path) return undefined
return {
id,
label,
alias,
path,
shortLabel: stringFrom(source.shortLabel).trim() || label.slice(0, 2).toUpperCase(),
color: nullableStringFrom(source.color),
icon: nullableStringFrom(source.icon),
mounted: booleanFrom(source.mounted, true),
available: booleanFrom(source.available, true),
defaultForNewNotes: booleanFrom(source.defaultForNewNotes),
}
}
function normalizeFilterGroup(value: unknown): FilterGroup {
const source = recordFrom(value)
if (Array.isArray(source.all)) return { all: source.all as FilterNode[] }
if (Array.isArray(source.any)) return { any: source.any as FilterNode[] }
return { all: [] }
}
function fallbackViewName(filename: string, index: number): string {
const stem = stripExtension(filename).trim()
return stem && stem !== `view-${index + 1}` ? stem : `View ${index + 1}`
}
function normalizeVaultEntryRecord({ rawEntry, vaultPath, index, workspace }: EntryNormalizationArgs): VaultEntry {
const source = recordFrom(rawEntry)
const filename = fallbackEntryFilename(source, index)
const path = resolveEntryPath({
explicitPath: stringFrom(source.path),
filename,
vaultPath,
})
const title = stringFrom(source.title).trim() || stripExtension(filename) || 'Untitled'
const fileKind = normalizeFileKind(source.fileKind)
const entry = {
...(source as Partial<VaultEntry>),
path,
filename,
title,
workspace: workspace ?? normalizeWorkspaceIdentity(source.workspace),
isA: nullableStringFrom(source.isA),
aliases: stringArrayFrom(source.aliases),
belongsTo: stringArrayFrom(source.belongsTo),
relatedTo: stringArrayFrom(source.relatedTo),
status: nullableStringFrom(source.status),
archived: booleanFrom(source.archived),
modifiedAt: nullableNumberFrom(source.modifiedAt),
createdAt: nullableNumberFrom(source.createdAt),
fileSize: numberFrom(source.fileSize),
snippet: stringFrom(source.snippet),
wordCount: numberFrom(source.wordCount),
relationships: normalizeRelationships(source.relationships),
icon: nullableStringFrom(source.icon),
color: nullableStringFrom(source.color),
order: nullableNumberFrom(source.order),
sidebarLabel: nullableStringFrom(source.sidebarLabel),
template: nullableStringFrom(source.template),
sort: nullableStringFrom(source.sort),
view: nullableStringFrom(source.view),
visible: nullableBooleanFrom(source.visible),
organized: booleanFrom(source.organized),
favorite: booleanFrom(source.favorite),
favoriteIndex: nullableNumberFrom(source.favoriteIndex),
listPropertiesDisplay: stringArrayFrom(source.listPropertiesDisplay),
outgoingLinks: stringArrayFrom(source.outgoingLinks),
properties: normalizeProperties(source.properties),
hasH1: booleanFrom(source.hasH1),
} as VaultEntry
if (fileKind) entry.fileKind = fileKind
return entry
}
function normalizeViewDefinition({ rawDefinition, filename, index }: ViewDefinitionArgs): ViewDefinition {
const definition = recordFrom(rawDefinition)
const name = stringFrom(definition.name).trim() || fallbackViewName(filename, index)
const normalized = {
...(definition as Partial<ViewDefinition>),
name,
icon: nullableStringFrom(definition.icon),
color: nullableStringFrom(definition.color),
sort: nullableStringFrom(definition.sort),
filters: normalizeFilterGroup(definition.filters),
} as ViewDefinition
if ('order' in definition) normalized.order = nullableNumberFrom(definition.order)
if ('listPropertiesDisplay' in definition) {
normalized.listPropertiesDisplay = stringArrayFrom(definition.listPropertiesDisplay)
}
return normalized
}
function normalizeViewFile({ rawView, index }: ViewNormalizationArgs): ViewFile {
const source = recordFrom(rawView)
const filename = stringFrom(source.filename) || `view-${index + 1}.yml`
return {
filename,
definition: normalizeViewDefinition({
rawDefinition: source.definition,
filename,
index,
}),
}
}
export function normalizeVaultEntries(rawEntries: unknown, vaultPath: string, workspace?: WorkspaceIdentity): VaultEntry[] {
if (!Array.isArray(rawEntries)) return []
return rawEntries
.filter(hasUsablePath)
.map((rawEntry, index) => normalizeVaultEntry(rawEntry, vaultPath, index, workspace))
}
export function normalizeVaultEntry(rawEntry: unknown, vaultPath = '', index = 0, workspace?: WorkspaceIdentity): VaultEntry {
return normalizeVaultEntryRecord({ rawEntry, vaultPath, index, workspace })
}
export function normalizeViewFiles(rawViews: unknown): ViewFile[] {
if (!Array.isArray(rawViews)) return []
return rawViews.map((rawView, index) => normalizeViewFile({ rawView, index }))
}