/** * Name → document resolution for `[[wikilinks]]`, over a provider's file tree. * * Pure and framework-free: `buildWikiLinkIndex` takes the tree the sidebar * already fetches and produces lookup tables; `resolveWikiLink` answers a * single target. The React seam that keeps this fed lives in * `WikiLinkVaultContext.tsx`. * * THE ENTRY-ID RULE: a resolved link's `entryId` is copied verbatim from the * tree node, never rebuilt by string concatenation. Tab dedup compares * `providerId + entryId` as exact strings (`tabsModel.slotShowsRequest`), so a * path that is merely equivalent — rather than identical — would silently open * a second tab for a document that is already showing. */ import type { FileSystemTreeNode } from '../filesystem/types'; import type { WikiLinkCandidate, WikiLinkResolution, WikiLinkVaultView, } from '../editors/types'; import { isTextFile } from '../vault/utils/fileType'; import { baseName, dirName, ensureMdExtension, normalizeRelPath, relPathFrom, stripMdExtension, } from './paths'; export interface WikiLinkIndexEntry { /** Absolute path, verbatim from `getTree()`. See THE ENTRY-ID RULE above. */ entryId: string; /** Basename including extension, e.g. `Meeting notes.md`. */ name: string; /** Path relative to the vault root, extension stripped. */ relPath: string; /** Basename, extension stripped — what a bare `[[Note]]` matches. */ stem: string; /** Containing folder relative to the root; `''` at the root. */ dirRel: string; /** Segment count of `relPath`; smaller is "shallower" for tie-breaking. */ depth: number; } export interface WikiLinkIndex { rootId: string; byStemLower: Map; byRelPathLower: Map; /** Folders present in the vault, relative to the root — for create-on-click. */ dirs: Set; /** Precomputed so the completion popup never rebuilds this per keystroke. */ candidates: readonly WikiLinkCandidate[]; } function pushInto( map: Map, key: string, entry: WikiLinkIndexEntry, ): void { const existing = map.get(key); if (existing) existing.push(entry); else map.set(key, [entry]); } export function buildWikiLinkIndex( rootId: string, tree: readonly FileSystemTreeNode[], ): WikiLinkIndex { const entries: WikiLinkIndexEntry[] = []; const dirs = new Set(['']); const walk = (nodes: readonly FileSystemTreeNode[]): void => { for (const node of nodes) { if (node.type === 'folder') { dirs.add(relPathFrom(rootId, node.id)); if (node.children) walk(node.children); continue; } // Binary files (images and friends) are listed in the sidebar but are not // editable, so they are not wikilink targets. if (!isTextFile(node.id)) continue; const relFull = relPathFrom(rootId, node.id); const relPath = stripMdExtension(relFull); entries.push({ entryId: node.id, name: node.name, relPath, stem: stripMdExtension(node.name), dirRel: dirName(relFull), depth: relPath.split('/').length, }); } }; walk(tree); const byStemLower = new Map(); const byRelPathLower = new Map(); for (const entry of entries) { pushInto(byStemLower, entry.stem.toLowerCase(), entry); pushInto(byRelPathLower, entry.relPath.toLowerCase(), entry); } // Ambiguous stems complete as full paths so the two are distinguishable. const candidates: WikiLinkCandidate[] = entries .map((entry) => ({ label: (byStemLower.get(entry.stem.toLowerCase())?.length ?? 0) > 1 ? entry.relPath : entry.stem, detail: entry.dirRel || '/', entryId: entry.entryId, })) .sort((a, b) => a.label.localeCompare(b.label)); return { rootId, byStemLower, byRelPathLower, dirs, candidates }; } /** * Pick one entry from an ambiguous bucket. * * Order: exact-case match, then a sibling of the linking note, then the * shallowest path, then lexicographic. The last tiebreak is not cosmetic — * without it the winner would depend on `readdir` order, which varies by * platform and filesystem. */ function disambiguate( matches: readonly WikiLinkIndexEntry[], exact: (entry: WikiLinkIndexEntry) => boolean, fromDir: string, ): WikiLinkIndexEntry { const exactCase = matches.filter(exact); const pool = exactCase.length > 0 ? exactCase : matches; const siblings = pool.filter((entry) => entry.dirRel === fromDir); const finalPool = siblings.length > 0 ? siblings : pool; return [...finalPool].sort( (a, b) => a.depth - b.depth || a.entryId.localeCompare(b.entryId), )[0]; } function resolutionOf(entry: WikiLinkIndexEntry): WikiLinkResolution { return { entryId: entry.entryId, title: entry.name, exists: true }; } /** * Resolve a bare target (no `#anchor`, no `|alias` — the parser already split * those off) against the vault. * * A target containing `/` is path-ish: tried relative to the linking note * first, then relative to the vault root. A bare name matches any note's * basename, vault-wide and case-insensitively. * * Returns `exists: false` with the path that *would* be created when nothing * matches: alongside the linking note for a bare name, under the vault root for * a path-ish target. */ export function resolveWikiLink( index: WikiLinkIndex, target: string, fromEntryId: string, ): WikiLinkResolution | null { const normalized = stripMdExtension(normalizeRelPath(target.trim())); if (normalized.length === 0) return null; const fromDir = dirName(relPathFrom(index.rootId, fromEntryId)); const isPathish = normalized.includes('/'); if (isPathish) { const relativeToNote = normalizeRelPath( fromDir ? `${fromDir}/${normalized}` : normalized, ); for (const key of [relativeToNote, normalized]) { const matches = index.byRelPathLower.get(key.toLowerCase()); if (matches?.length) { return resolutionOf( disambiguate(matches, (e) => e.relPath === key, fromDir), ); } } } else { const matches = index.byStemLower.get(normalized.toLowerCase()); if (matches?.length) { return resolutionOf( disambiguate(matches, (e) => e.stem === normalized, fromDir), ); } } // Nothing matched — describe the note we would create. const relTarget = isPathish ? normalized : fromDir ? `${fromDir}/${normalized}` : normalized; const fileName = ensureMdExtension(baseName(relTarget)); const dir = dirName(relTarget); const relFull = dir ? `${dir}/${fileName}` : fileName; return { entryId: `${index.rootId}/${relFull}`, title: fileName, exists: false, }; } /** Bind an index to the document doing the linking. */ export function makeWikiLinkVaultView( index: WikiLinkIndex, fromEntryId: string, ): WikiLinkVaultView { return { resolve: (target) => resolveWikiLink(index, target, fromEntryId), candidates: () => index.candidates, }; }