Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215/** * 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<string, WikiLinkIndexEntry[]>; byRelPathLower: Map<string, WikiLinkIndexEntry[]>; /** Folders present in the vault, relative to the root — for create-on-click. */ dirs: Set<string>; /** Precomputed so the completion popup never rebuilds this per keystroke. */ candidates: readonly WikiLinkCandidate[];}
function pushInto( map: Map<string, WikiLinkIndexEntry[]>, 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<string>(['']);
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<string, WikiLinkIndexEntry[]>(); const byRelPathLower = new Map<string, WikiLinkIndexEntry[]>(); 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, };}