The [[wiki-link]] index: backlinks, unresolved links, and the resolution
rules that make a bare [[Title]] unambiguous when the filesystem allows two
files to share a name.
The index is a cache of the markdown, rebuilt whenever a note's bytes change, and reproducible from scratch by re-reading the vault.
Resolving a link
A vault may legitimately hold research/Ideas.md and archive/Ideas.md —
the filesystem permits it, so the index has to describe it rather than reject
it. [[Ideas]] therefore resolves by:
- a note in the same folder as the linking note, then
- the shortest path (a note nearer the vault root is the more general one, and the likelier intent), then
- alphabetical order, purely so the answer is stable rather than whichever row Postgres returned first.
Write [[research/Ideas]] to say exactly which one you meant. Path-qualified
links are matched against the path instead of the title, and never ambiguous.
Unresolved links are a feature
A link to a note that does not exist is stored as a real row with a nil
target_note_uuid rather than discarded, so clicking a dangling link offers
to create that note, the vault can list everything it has referred to but not
written, and creating a note retroactively resolves every link already
pointing at its title. Deleting a note runs it backwards: nilify_all turns
links into it back into unresolved ones, so the mentions survive the target.
Summary
Types
Everything needed to resolve links in one vault, loaded once.
Functions
Notes that link to this one — the 'linked mentions' panel, in title order.
How many notes a bare title could refer to.
The two keys a note answers to: its bare title, and its path-qualified form.
Distinct outgoing links from a note, resolved and unresolved, in title order.
The normalised form of a path, matching what a path-qualified link normalises to.
Rebuild the outgoing link rows for one note.
Rebuild every link in a vault.
Re-resolve every link naming this note, under either of its keys.
Re-resolve every link in a vault that names one of keys, whether or not it
currently resolves.
The note a link points at, or nil when nothing matches.
Point unresolved links at a note that now exists.
Load a vault's notes into a structure that can resolve any link without further queries.
A resolver over the given notes only — for a notes panel, whose links must
resolve inside its own folder and nowhere else: a title from outside it is
somebody else's note, and saying it exists would tell its name to a person who
may only see this folder. Each note needs title_key, relative_path and
folder_path.
Every unresolved target in a vault, with how many notes want it.
How many distinct unresolved targets a vault has.
Types
@type resolver() :: %{ by_title: %{ required(String.t()) => [PhoenixKitKnowledgeBase.Schemas.Note.t()] }, by_path: %{required(String.t()) => PhoenixKitKnowledgeBase.Schemas.Note.t()} }
Everything needed to resolve links in one vault, loaded once.
Functions
@spec backlinks(PhoenixKitKnowledgeBase.Schemas.Note.t()) :: [ PhoenixKitKnowledgeBase.Schemas.Note.t() ]
Notes that link to this one — the 'linked mentions' panel, in title order.
Each source once. A note may name the same target two ways — [[Ideas]] and
[[research/Ideas]] are different keys, so two link rows — and listing it
twice would say two notes mention this one.
@spec candidate_count(resolver(), String.t()) :: non_neg_integer()
How many notes a bare title could refer to.
Used by the UI to mark a link ambiguous — the link still resolves, but it is
worth telling the author that their [[Ideas]] had to be guessed at.
@spec keys_for(PhoenixKitKnowledgeBase.Schemas.Note.t()) :: [String.t()]
The two keys a note answers to: its bare title, and its path-qualified form.
@spec outgoing(PhoenixKitKnowledgeBase.Schemas.Note.t()) :: [ PhoenixKitKnowledgeBase.Schemas.NoteLink.t() ]
Distinct outgoing links from a note, resolved and unresolved, in title order.
The normalised form of a path, matching what a path-qualified link normalises to.
@spec reindex_note(PhoenixKitKnowledgeBase.Schemas.Note.t(), resolver() | nil) :: :ok
Rebuild the outgoing link rows for one note.
Deletes and re-inserts rather than diffing: a note has a handful of links, and "delete what's there, insert what the text says" cannot drift, whereas a diff can.
@spec reindex_vault(String.t()) :: non_neg_integer()
Rebuild every link in a vault.
Used after a rescan. Incremental updates cannot be correct there: adding one note can change what an existing link resolves to, because a nearer same-name file may have appeared. Rebuilding the lot is O(notes) once, and it is the only version of this that is actually right.
@spec reresolve_for_note(PhoenixKitKnowledgeBase.Schemas.Note.t()) :: non_neg_integer()
Re-resolve every link naming this note, under either of its keys.
@spec reresolve_keys(String.t(), [String.t()]) :: non_neg_integer()
Re-resolve every link in a vault that names one of keys, whether or not it
currently resolves.
This is the difference between resolve_for_note/1 and being right.
resolve_for_note/1 only fills rows where target_note_uuid is nil, so an
already-resolved link stays pointed at whichever note got there first:
archive/A.mdwrites[[Ideas]]— nothing to resolve to, so unresolvedresearch/Ideas.mdis created — the link now points at researcharchive/Ideas.mdis created — same-folder should win, but the row is already resolved, so it does not move until a full rescan
Step 3 contradicts the resolution rules at the top of this module, which is a worse bug than it looks: the link renders as resolved, so nothing tells the author it went to the wrong note.
Scoped by key rather than rebuilding the vault, because only links naming the changed note can change meaning. The work is proportional to how many notes mention that title, not to vault size, which is what makes it cheap enough to run on every create, rename, move, restore and delete.
The note a link points at, or nil when nothing matches.
from_folder is the linking note's folder, which is what makes the
same-folder tie-break possible; pass "" from the vault root.
@spec resolve_for_note(PhoenixKitKnowledgeBase.Schemas.Note.t()) :: non_neg_integer()
Point unresolved links at a note that now exists.
Matches both spellings a link can take: the bare title, and the path-qualified form.
Load a vault's notes into a structure that can resolve any link without further queries.
Built once per render or per reindex pass. Resolving link-by-link would be a
query per [[…]] on the page, and a query per link per note during a rescan.
A resolver over the given notes only — for a notes panel, whose links must
resolve inside its own folder and nowhere else: a title from outside it is
somebody else's note, and saying it exists would tell its name to a person who
may only see this folder. Each note needs title_key, relative_path and
folder_path.
@spec unresolved(String.t()) :: [ %{title: String.t(), key: String.t(), count: non_neg_integer()} ]
Every unresolved target in a vault, with how many notes want it.
Sorted by mention count: the note five others already reference is a better next thing to write than one mentioned once.
@spec unresolved_count(String.t()) :: non_neg_integer()
How many distinct unresolved targets a vault has.