Files
tolaria/src/utils/noteListHelpers.ts

202 lines
6.8 KiB
TypeScript
Raw Normal View History

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
}
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'
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'modified', label: 'Modified' },
{ value: 'created', label: 'Created' },
{ value: 'title', label: 'Title' },
{ value: 'status', label: 'Status' },
]
const STATUS_ORDER: Record<string, number> = {
Active: 0, Paused: 1, Done: 2, Finished: 3,
}
export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number {
switch (option) {
case 'modified':
return sortByModified
case 'created':
return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0)
case 'title':
return (a, b) => a.title.localeCompare(b.title)
case 'status':
return (a, b) => {
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
if (sa !== sb) return sa - sb
return sortByModified(a, b)
}
}
}
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
export function loadSortPreferences(): Record<string, SortOption> {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
export function saveSortPreferences(prefs: Record<string, SortOption>) {
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)
}
b.addFromRefs('Has', rels['Has'] ?? [])
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.addFromRefs('Topics', rels['Topics'] ?? [])
const handledKeys = new Set(['Has', 'Topics'])
Object.keys(rels)
.filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type')
.sort((a, b) => a.localeCompare(b))
.forEach((key) => b.addFromRefs(key, rels[key] ?? []))
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)
}