Files
tolaria/src/utils/noteListHelpers.ts
Luca Rossi 2c5cfe2923 feat: sort picker shows custom frontmatter properties (#185)
The sort dropdown now discovers all scalar properties (string, number,
boolean, date) across notes in the current list and shows them below a
separator after the built-in options. Properties that no longer exist
in the current list are gracefully handled by falling back to Modified.

Rust backend extracts custom properties during vault scan so they are
available on every VaultEntry without loading file content on demand.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:31:18 +01:00

311 lines
11 KiB
TypeScript

import type { VaultEntry, SidebarSelection } from '../types'
export interface RelationshipGroup {
label: string
entries: VaultEntry[]
}
export function relativeDate(ts: number | null): string {
if (!ts) return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - ts
if (diff < 0) {
const date = new Date(ts * 1000)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
const date = new Date(ts * 1000)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
export function getDisplayDate(entry: VaultEntry): number | null {
return entry.modifiedAt ?? entry.createdAt
}
export function formatSubtitle(entry: VaultEntry): string {
const parts: string[] = []
const date = getDisplayDate(entry)
if (date) parts.push(relativeDate(date))
if (entry.wordCount > 0) {
parts.push(`${entry.wordCount.toLocaleString()} words`)
} else {
parts.push('Empty')
}
if (entry.outgoingLinks.length > 0) {
parts.push(`${entry.outgoingLinks.length} ${entry.outgoingLinks.length === 1 ? 'link' : 'links'}`)
}
return parts.join(' \u00b7 ')
}
export function formatSearchSubtitle(entry: VaultEntry): string {
const parts: string[] = []
const modified = entry.modifiedAt ?? entry.createdAt
if (modified) parts.push(relativeDate(modified))
if (entry.createdAt && entry.modifiedAt && entry.createdAt !== entry.modifiedAt) {
parts.push(`Created ${relativeDate(entry.createdAt)}`)
}
if (entry.wordCount > 0) {
parts.push(`${entry.wordCount.toLocaleString()} words`)
} else {
parts.push('Empty')
}
if (entry.outgoingLinks.length > 0) {
parts.push(`${entry.outgoingLinks.length} ${entry.outgoingLinks.length === 1 ? 'link' : 'links'}`)
}
return parts.join(' \u00b7 ')
}
function refsMatch(refs: string[], entry: VaultEntry): boolean {
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
const fileStem = entry.filename.replace(/\.md$/, '')
return refs.some((ref) => {
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
return inner === stem || inner.split('/').pop() === fileStem
})
}
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
return refs
.map((ref) => {
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
return entries.find((e) => {
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (stem === inner) return true
const fileStem = e.filename.replace(/\.md$/, '')
return fileStem === inner.split('/').pop()
})
})
.filter((e): e is VaultEntry => e !== undefined)
}
export function sortByModified(a: VaultEntry, b: VaultEntry): number {
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
export type SortOption = 'modified' | 'created' | 'title' | 'status' | `property:${string}`
export type SortDirection = 'asc' | 'desc'
export interface SortConfig {
option: SortOption
direction: SortDirection
}
export const DEFAULT_SORT_OPTIONS: SortOption[] = ['modified', 'created', 'title', 'status']
export function getDefaultDirection(option: SortOption): SortDirection {
if (option === 'modified' || option === 'created') return 'desc'
return 'asc'
}
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'modified', label: 'Modified' },
{ value: 'created', label: 'Created' },
{ value: 'title', label: 'Title' },
{ value: 'status', label: 'Status' },
]
export function getSortOptionLabel(option: SortOption): string {
if (option.startsWith('property:')) return option.slice('property:'.length)
return SORT_OPTIONS.find((o) => o.value === option)?.label ?? option
}
/** Extract sortable custom property keys from a list of entries. */
export function extractSortableProperties(entries: VaultEntry[]): string[] {
const keys = new Set<string>()
for (const entry of entries) {
if (entry.properties) {
for (const key of Object.keys(entry.properties)) keys.add(key)
}
}
return [...keys].sort((a, b) => a.localeCompare(b))
}
const STATUS_ORDER: Record<string, number> = {
Active: 0, Paused: 1, Done: 2, Finished: 3,
}
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}/
function tryParseDate(s: string): number | null {
if (!ISO_DATE_RE.test(s)) return null
const d = new Date(s)
return isNaN(d.getTime()) ? null : d.getTime()
}
function compareNumericPair(a: unknown, b: unknown): number | null {
if (typeof a === 'number' && typeof b === 'number') return a - b
if (typeof a === 'boolean' && typeof b === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0)
return null
}
function comparePropertyValues(a: unknown, b: unknown): number {
const numeric = compareNumericPair(a, b)
if (numeric !== null) return numeric
const sa = String(a)
const sb = String(b)
const da = tryParseDate(sa)
const db = tryParseDate(sb)
if (da !== null && db !== null) return da - db
return sa.localeCompare(sb)
}
function makePropertyComparator(key: string, flip: number): (a: VaultEntry, b: VaultEntry) => number {
return (a, b) => {
const va = a.properties?.[key] ?? null
const vb = b.properties?.[key] ?? null
if (va == null && vb == null) return 0
if (va == null) return 1
if (vb == null) return -1
return flip * comparePropertyValues(va, vb)
}
}
function makeBuiltinComparator(option: string, flip: number): (a: VaultEntry, b: VaultEntry) => number {
if (option === 'title') return (a, b) => flip * a.title.localeCompare(b.title)
if (option === 'created') return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0))
if (option === 'status') return (a, b) => {
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
if (sa !== sb) return flip * (sa - sb)
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
return (a, b) => flip * ((getDisplayDate(a) ?? 0) - (getDisplayDate(b) ?? 0))
}
export function getSortComparator(option: SortOption, direction?: SortDirection): (a: VaultEntry, b: VaultEntry) => number {
const flip = (direction ?? getDefaultDirection(option)) === 'asc' ? 1 : -1
if (option.startsWith('property:')) return makePropertyComparator(option.slice('property:'.length), flip)
return makeBuiltinComparator(option, flip)
}
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
export function loadSortPreferences(): Record<string, SortConfig> {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
const result: Record<string, SortConfig> = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'string') {
// Migrate old format: bare SortOption string → SortConfig
const opt = value as SortOption
result[key] = { option: opt, direction: getDefaultDirection(opt) }
} else {
result[key] = value as SortConfig
}
}
return result
} catch {
return {}
}
}
export function saveSortPreferences(prefs: Record<string, SortConfig>) {
try {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs))
} catch { /* ignore */ }
}
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
const stem = entity.filename.replace(/\.md$/, '')
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
const targets = [entity.title, ...entity.aliases]
return allEntries.filter((e) => {
if (e.path === entity.path) return false
const content = allContent[e.path]
if (!content) return false
for (const t of targets) {
if (content.includes(`[[${t}]]`)) return true
}
if (content.includes(`[[${stem}]]`)) return true
if (content.includes(`[[${pathStem}]]`)) return true
return content.includes(`[[${pathStem}|`)
})
}
class GroupBuilder {
readonly groups: RelationshipGroup[] = []
private readonly seen: Set<string>
private readonly allEntries: VaultEntry[]
constructor(entityPath: string, allEntries: VaultEntry[]) {
this.seen = new Set([entityPath])
this.allEntries = allEntries
}
add(label: string, entries: VaultEntry[]) {
const unseen = entries.filter((e) => !this.seen.has(e.path))
if (unseen.length > 0) {
this.groups.push({ label, entries: unseen })
unseen.forEach((e) => this.seen.add(e.path))
}
}
addFromRefs(label: string, refs: string[]) {
if (refs.length > 0) {
this.add(label, resolveRefs(refs, this.allEntries).sort(sortByModified))
}
}
filterAndAdd(label: string, predicate: (e: VaultEntry) => boolean) {
this.add(label, this.allEntries.filter((e) => !this.seen.has(e.path) && predicate(e)).sort(sortByModified))
}
}
export function buildRelationshipGroups(
entity: VaultEntry,
allEntries: VaultEntry[],
allContent: Record<string, string>,
): RelationshipGroup[] {
const b = new GroupBuilder(entity.path, allEntries)
const rels = entity.relationships ?? {}
if (entity.isA === 'Type') {
b.filterAndAdd('Instances', (e) => e.isA === entity.title)
}
// Direct relationships first — all keys from entity.relationships take
// priority so that reverse/computed groups (Children, Events, Referenced By)
// only show *additional* entries not already covered by a direct property.
Object.keys(rels)
.filter((k) => k.toLowerCase() !== 'type')
.sort((a, b) => a.localeCompare(b))
.forEach((key) => b.addFromRefs(key, rels[key] ?? []))
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified))
return b.groups
}
const isActive = (e: VaultEntry) => !e.archived && !e.trashed
function filterByKind(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] {
if (selection.kind === 'entity') return []
if (selection.kind === 'sectionGroup') {
return entries.filter((e) => e.isA === selection.type && isActive(e))
}
if (selection.kind === 'topic') {
return entries.filter((e) => refsMatch(e.relatedTo, selection.entry) && isActive(e))
}
return filterByFilterType(entries, selection.filter)
}
function filterByFilterType(entries: VaultEntry[], filter: string): VaultEntry[] {
if (filter === 'all') return entries.filter(isActive)
if (filter === 'archived') return entries.filter((e) => e.archived && !e.trashed)
if (filter === 'trash') return entries.filter((e) => e.trashed)
return []
}
export function filterEntries(entries: VaultEntry[], selection: SidebarSelection): VaultEntry[] {
return filterByKind(entries, selection)
}