PhoenixKitKnowledgeBase.Links (PhoenixKitKnowledgeBase v0.1.0)

Copy Markdown View Source

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.

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:

  1. a note in the same folder as the linking note, then
  2. the shortest path (a note nearer the vault root is the more general one, and the likelier intent), then
  3. 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.

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

resolver()

@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

backlinks(note)

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.

candidate_count(resolver, key)

@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.

keys_for(note)

The two keys a note answers to: its bare title, and its path-qualified form.

outgoing(note)

Distinct outgoing links from a note, resolved and unresolved, in title order.

path_key(relative_path)

@spec path_key(String.t()) :: String.t()

The normalised form of a path, matching what a path-qualified link normalises to.

reindex_note(note, resolver \\ nil)

@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.

reindex_vault(vault_uuid)

@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.

reresolve_for_note(note)

@spec reresolve_for_note(PhoenixKitKnowledgeBase.Schemas.Note.t()) ::
  non_neg_integer()

Re-resolve every link naming this note, under either of its keys.

reresolve_keys(vault_uuid, 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:

  1. archive/A.md writes [[Ideas]] — nothing to resolve to, so unresolved
  2. research/Ideas.md is created — the link now points at research
  3. archive/Ideas.md is 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.

resolve(resolver, key, from_folder \\ "")

@spec resolve(resolver(), String.t(), String.t()) :: map() | nil

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.

resolve_for_note(note)

Point unresolved links at a note that now exists.

Matches both spellings a link can take: the bare title, and the path-qualified form.

resolver(vault_uuid)

@spec resolver(String.t()) :: resolver()

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.

resolver_from(notes)

@spec resolver_from([map()]) :: resolver()

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.

unresolved(vault_uuid)

@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.

unresolved_count(vault_uuid)

@spec unresolved_count(String.t()) :: non_neg_integer()

How many distinct unresolved targets a vault has.