# `PhoenixKitKnowledgeBase.Notes`
[🔗](https://github.com/BeamLabEU/phoenix_kit_knowledge_base/blob/v0.1.0/lib/phoenix_kit_knowledge_base/notes.ex#L1)

Notes and folders, where **the filesystem is the source of truth** and this
table is an index of it.

Every write goes to disk first and is then indexed from what actually landed —
never from what the caller intended. If the write succeeds and the indexing
fails, the note still exists and the next rescan picks it up; if the write
fails, nothing is indexed. The disk is never the thing that ends up wrong.

## The stale-write guard

Without a file watcher, the real hazard is the web UI overwriting an edit made
in `vim` (or Obsidian, or a script) since the page was loaded. Before every
write, the file's current hash is compared with the indexed one; if they
differ, the save is refused with `{:error, :stale}` and the caller is told to
rescan. That is the difference between "eventually consistent" and "silently
loses work".

## Access is not checked here

The caller has already resolved a role through
`PhoenixKitKnowledgeBase.Vaults.fetch_for/2`, and every function is scoped to
a vault — which keeps the permission decision in one place rather than
re-derived in twenty query functions.

# `count_notes`

```elixir
@spec count_notes(String.t()) :: non_neg_integer()
```

How many notes a vault holds.

# `count_notes_by_vault`

```elixir
@spec count_notes_by_vault([String.t()]) :: %{
  required(String.t()) =&gt; non_neg_integer()
}
```

Note counts for several vaults in one query, as `%{vault_uuid => count}`.

For list pages that show a count per row — one grouped query instead of one
`count_notes/1` per card.

# `create_folder`

```elixir
@spec create_folder(PhoenixKitKnowledgeBase.Schemas.Vault.t(), String.t(), String.t()) ::
  {:ok, PhoenixKitKnowledgeBase.Schemas.Folder.t()} | {:error, term()}
```

Create a directory on disk and index it, plus any parents it needed.

`parent_path` is `""` for the vault root.

# `create_note`

```elixir
@spec create_note(
  PhoenixKitKnowledgeBase.Schemas.Vault.t(),
  map(),
  String.t() | nil,
  keyword()
) ::
  {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
```

Create a note: write the file, then index what landed.

Refuses if a file is already at that path, rather than overwriting a note that
the index may not even know about yet.

Options:

  * `unique: true` — when the name is taken, number it instead of refusing:
    `Ideas` → `Ideas 2` → `Ideas 3` … (the note's title is the file name,
    so read it back from the returned note).

# `delete_folder`

```elixir
@spec delete_folder(
  PhoenixKitKnowledgeBase.Schemas.Vault.t(),
  PhoenixKitKnowledgeBase.Schemas.Folder.t()
) :: {:ok, non_neg_integer()} | {:error, term()}
```

Move a directory and everything under it to the trash, and drop its index rows.

The notes inside go with it — a folder is its contents — but they go to
`.trash/` with the subtree intact, so the whole folder can be restored.

# `delete_note`

```elixir
@spec delete_note(
  PhoenixKitKnowledgeBase.Schemas.Vault.t(),
  PhoenixKitKnowledgeBase.Schemas.Note.t()
) ::
  {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
```

Move a note to the vault's trash and drop its index row.

The file is not unlinked — it goes to `.trash/`, so an accidental delete is
recoverable (`list_trash/1`, `restore_note/2`). The index row goes because a
trashed note is not part of the vault any more: it should not appear in
search, in the sidebar or in the link graph.

Links into it become unresolved rather than disappearing, so the notes that
mentioned it keep saying so — and restoring re-resolves them.

# `disk_state`

```elixir
@spec disk_state(
  PhoenixKitKnowledgeBase.Schemas.Vault.t(),
  PhoenixKitKnowledgeBase.Schemas.Note.t()
) ::
  :current | :changed | :missing
```

Whether the file backing a note has changed since it was indexed.

`:missing` when the file is gone, which is a conflict too — saving would
silently resurrect a note somebody deleted on disk.

# `empty_trash`

```elixir
@spec empty_trash(PhoenixKitKnowledgeBase.Schemas.Vault.t()) :: :ok | {:error, term()}
```

Empty the vault's trash.

# `fetch_with_vault`

```elixir
@spec fetch_with_vault(String.t()) ::
  {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t(),
   PhoenixKitKnowledgeBase.Schemas.Vault.t()}
  | :error
```

A note and the vault holding it, from the note uuid alone.

For a caller that has only the uuid and no vault in hand — the collaboration
store, whose document id is the note uuid, running inside a room process that
never saw a LiveView's assigns. One query rather than two, because it runs on
every flush.

# `folder_tree`

```elixir
@spec folder_tree(String.t()) :: [map()]
```

The folder tree as nested `%{folder: folder, children: [...]}` nodes.

Rebuilt from `relative_path` rather than stored: the filesystem already owns
the hierarchy, and a second copy of it in the database is a second thing that
can disagree with the disk.

# `get_folder`

```elixir
@spec get_folder(String.t(), String.t() | nil) ::
  PhoenixKitKnowledgeBase.Schemas.Folder.t() | nil
```

Fetch a folder scoped to its vault, by uuid, or `nil`.

# `get_folder_by_path`

```elixir
@spec get_folder_by_path(String.t(), String.t()) ::
  PhoenixKitKnowledgeBase.Schemas.Folder.t() | nil
```

Fetch a folder by its path inside the vault, or `nil`.

# `get_note`

```elixir
@spec get_note(String.t(), String.t() | nil) ::
  PhoenixKitKnowledgeBase.Schemas.Note.t() | nil
```

Fetch a note scoped to its vault, or `nil` (also for a malformed uuid).

# `get_note_by_path`

```elixir
@spec get_note_by_path(String.t(), String.t()) ::
  PhoenixKitKnowledgeBase.Schemas.Note.t() | nil
```

Fetch a note by its path within the vault, or `nil`.

# `get_note_by_property`

```elixir
@spec get_note_by_property(String.t(), String.t(), term()) ::
  PhoenixKitKnowledgeBase.Schemas.Note.t() | nil
```

The first note (by path) whose front matter has `key: value`, or `nil`.
See `list_notes/2`'s `:property`.

# `get_note_by_title`

```elixir
@spec get_note_by_title(String.t(), String.t() | nil) ::
  PhoenixKitKnowledgeBase.Schemas.Note.t() | nil
```

Fetch a note by title, matched case- and whitespace-insensitively.

Returns the same note `[[Title]]` would resolve to from the vault root when
several share a title.

# `index_entry`

```elixir
@spec index_entry(
  PhoenixKitKnowledgeBase.Schemas.Vault.t(),
  String.t(),
  map(),
  keyword()
) ::
  {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
```

Index one file: upsert its row from the bytes on disk and rebuild its links.

The single entry point for "make the database agree with this file", used by
every write path and by the rescan.

Pass `broadcast: false` during a bulk reindex; a rescan announces itself once
at the end rather than once per file.

Pass `resolver:` when indexing several notes in one pass; otherwise each call
builds its own, which is a scan of every note in the vault per note indexed.

Pass `existing: note` when the file has just *moved* and the row to update is
therefore not the one at `relative_path`. Without it a rename looks up the
new path, finds nothing, and inserts a second row — leaving the note listed
twice, the old entry pointing at a file that is no longer there, and every
backlink still attached to the stale one. `Storage.move_note/3` refuses to
overwrite an existing file, so the row named here cannot collide with a real
note at the target.

# `list_folders`

```elixir
@spec list_folders(String.t(), keyword()) :: [
  PhoenixKitKnowledgeBase.Schemas.Folder.t()
]
```

Every indexed folder in a vault, ordered by path so a tree can be built in one pass.

Options:

  * `:parent` — only the folders directly inside this one (`""`: the vault
    root's own). For a listing of one folder, which would otherwise read the
    whole vault's tree to show a level of it.

# `list_notes`

```elixir
@spec list_notes(String.t(), keyword()) :: [PhoenixKitKnowledgeBase.Schemas.Note.t()]
```

Notes in a vault.

Options:

  * `:folder_path` — `""` for the vault root, `:any` (default) for everything
  * `:under` — a folder and everything below it (`""` or `nil`: no limit)
  * `:tag` — only notes carrying this tag
  * `:order` — `:title` (default) or `:updated`
  * `:limit`
  * `:content` — `false` leaves each note's body unloaded (it reads `""`).
    For listings and link resolution, which need titles, paths, tags and
    dates and would otherwise read every note's whole text — a notes panel
    does that on every poll.
  * `:property` — `{key, value}`: only notes whose front matter has
    `key: value` at the top level. The value is compared as text (YAML
    `7` matches `"7"`), so a host keeping an id in its notes' properties
    finds them without querying this module's tables.
  * `:has_property` — a key, or a list of keys: only notes whose front
    matter has at least one of them.

# `list_tags`

```elixir
@spec list_tags(String.t()) :: [{String.t(), pos_integer()}]
```

Every tag in a vault with how many notes carry it, most used first.

Aggregated in Elixir rather than with `unnest`: a vault's tag arrays are small
and this keeps the query a plain scoped select. If a vault ever outgrows
loading one array per note, this is the function to move into SQL — the
callers do not change.

# `list_titles`

```elixir
@spec list_titles(String.t()) :: [
  %{uuid: String.t(), title: String.t(), folder_path: String.t()}
]
```

Every note's uuid, title and folder, for building the sidebar tree.

Three columns rather than whole rows: the tree needs a label and something to
drag, and `content` is the entire markdown of every note in the vault. This
runs on every note click, folder click, tag click, drag and peer save.

# `list_trash`

```elixir
@spec list_trash(PhoenixKitKnowledgeBase.Schemas.Vault.t(), keyword()) :: [map()]
```

Everything in the vault's trash, newest first.

`frontmatter: true` adds each entry's parsed front matter (`%{}` for a
file without any, or one that is not markdown), so a host that binds notes
by a property — an `id:` — can find its trashed copy without reading the
trash itself.

# `move_note`

```elixir
@spec move_note(
  PhoenixKitKnowledgeBase.Schemas.Vault.t(),
  PhoenixKitKnowledgeBase.Schemas.Note.t(),
  String.t(),
  keyword()
) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
```

Move a note into another folder (`""` = the vault root), moving its file.

Options:

  * `title:` — rename it in the same step: one file move, so there is no
    moment where the note sits renamed in the old folder (or moved under
    its old name, colliding there).
  * `unique: true` — number the name when it is taken in the target folder
    (`Ideas 2`) instead of refusing with `:target_exists`.

# `purge_trash`

```elixir
@spec purge_trash(PhoenixKitKnowledgeBase.Schemas.Vault.t(), map()) ::
  :ok | {:error, term()}
```

Delete one trashed item for good.

# `read_from_disk`

```elixir
@spec read_from_disk(
  PhoenixKitKnowledgeBase.Schemas.Vault.t(),
  PhoenixKitKnowledgeBase.Schemas.Note.t()
) :: {:ok, String.t()} | {:error, term()}
```

The note's current content read from disk, bypassing the cached column.

# `relocate_folder`

```elixir
@spec relocate_folder(
  PhoenixKitKnowledgeBase.Schemas.Vault.t(),
  PhoenixKitKnowledgeBase.Schemas.Folder.t(),
  String.t()
) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Folder.t()} | {:error, term()}
```

Move or rename a folder, and reindex everything underneath it.

One function, because on a filesystem it is one operation: renaming
`research` and moving it into `2026/` are both a directory rename.

The reindex is the part worth reading. Every note under the folder gets a new
`relative_path`, and each is re-indexed **naming the row to update** — the
same `existing:` that `move_and_reindex/4` needs, and for the same reason: a
lookup by the new path finds nothing, inserts a second row, and strands every
backlink on the old one. Moving a folder of thirty notes would do that thirty
times.

Then the link graph. Path-qualified links (`[[research/Ideas]]`) name a path
that no longer exists, and the same link written against the new path starts
resolving — so every key the subtree answered to, before and after, is
re-resolved together.

Folder rows are reconciled against the disk rather than rewritten from the
old prefix, so empty subdirectories move with the rest instead of vanishing
from the sidebar until the next rescan.

# `rename_note`

```elixir
@spec rename_note(
  PhoenixKitKnowledgeBase.Schemas.Vault.t(),
  PhoenixKitKnowledgeBase.Schemas.Note.t(),
  String.t(),
  keyword()
) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
```

Rename a note — which renames its file, because the filename *is* the title.

Links to the old name stop resolving and links waiting for the new one start,
both handled here.

`unique: true` numbers the new name when it is taken in the folder
(`Ideas 2`), instead of refusing with `:target_exists`.

# `restore_note`

```elixir
@spec restore_note(PhoenixKitKnowledgeBase.Schemas.Vault.t(), map(), keyword()) ::
  {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
```

Restore a trashed note to where it came from, and reindex it.

Every link that was waiting for its title resolves again, which is the whole
point of un-deleting rather than re-typing.

Refuses with `:target_exists` when a note has taken the old place since;
with `unique: true` it comes back numbered beside it instead (`Ideas 2`).

# `search`

```elixir
@spec search(String.t(), String.t(), keyword()) :: [
  PhoenixKitKnowledgeBase.Schemas.Note.t()
]
```

Full-text search over a vault, best match first.

Runs against the cached `content` column via a generated `tsvector`, with the
title weighted above the body so an exact-title hit outranks a passing
mention. `websearch_to_tsquery` parses the query the way a search box user
expects (quoted phrases, `-exclusions`) and, unlike `to_tsquery`, cannot be
made to raise by punctuation someone typed.

The `'english'` config is written literally in both fragments and matches the
one the migration's generated column uses — a mismatch would search a vector
built with different stemming rules. It cannot be a bound parameter: that
argument is a `regconfig`, so Postgres types the placeholder as an OID and
Postgrex refuses to encode a string into it.

The `search_vector` reference is unqualified SQL because the column is
deliberately not mapped in `Note`; there is exactly one table here, so it is
unambiguous.

# `search_titles`

```elixir
@spec search_titles(String.t(), String.t(), keyword()) :: [
  PhoenixKitKnowledgeBase.Schemas.Note.t()
]
```

Title-prefix search, for the `[[` link picker and the quick switcher.

Separate from `search/3` on purpose: full-text matches whole lexemes, so
typing "kno" finds nothing until the word is complete, while picking a link
has to respond to a half-typed word.

# `search_with_snippets`

```elixir
@spec search_with_snippets(String.t(), String.t(), keyword()) :: [
  %{
    note: PhoenixKitKnowledgeBase.Schemas.Note.t(),
    snippet: [{:text | :match, String.t()}]
  }
]
```

`search/3`, with a snippet of each note showing where it matched.

Returns `[%{note: note, snippet: segments}]`, best match first, where
`segments` is a list of `{:text, binary}` and `{:match, binary}` pieces of
the note's body around the matched words (Postgres `ts_headline`, with the
front matter left out). Segments rather than HTML, so the host renders and
escapes them its own way: nothing from a note's text reaches a page
unescaped through this.

Options: `:limit` (default 50), `:words` (the snippet's length in words,
default 18, kept within 3..60).

# `sync_folders`

```elixir
@spec sync_folders(PhoenixKitKnowledgeBase.Schemas.Vault.t()) ::
  {:ok, {non_neg_integer(), non_neg_integer()}} | {:error, term()}
```

Make the folder index agree with the directories on disk.

Used after a folder move and by the rescan, which is the only other thing
that can make the two disagree.

`{:error, reason}` when the directory cannot be listed — a vault that lost
read permission or was unmounted. That has to reach the rescan report:
answering `{0, 0}` there would report "everything already up to date" for a
vault nothing can read.

# `title_key_counts`

```elixir
@spec title_key_counts(String.t(), [String.t()]) :: %{
  required(String.t()) =&gt; pos_integer()
}
```

How many notes carry each of these normalised titles.

Used to decide whether a suggested `[[link]]` needs to be path-qualified: a
title only one note answers to can be inserted bare, and one that several
share should be inserted as a path so the link is not left to the tie-break.

# `update_content`

```elixir
@spec update_content(
  PhoenixKitKnowledgeBase.Schemas.Vault.t(),
  PhoenixKitKnowledgeBase.Schemas.Note.t(),
  String.t(),
  keyword()
) :: {:ok, PhoenixKitKnowledgeBase.Schemas.Note.t()} | {:error, term()}
```

Save new content for a note.

Guarded against clobbering an external edit — see the moduledoc. Pass
`force: true` to overwrite deliberately, which is what a "save anyway" button
would do.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
