All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.1.0 - 2026-11-10
First release. Notes are markdown files on disk; Postgres indexes them.
0.1.0 was drafted on 2026-08-25 but not published then. The sections below are what changed after that draft; the draft's own list, which describes the foundations, follows under its date.
Added
notes_paneledits on today's PhoenixKit. Without a Leaf that supportsnotify(0.11; core 3.4.0 caps Leaf at 0.10) the drop-in panel used to show notes read-only. It now edits them in a plain markdown textarea: autosave, the stale-write guard with its "Take theirs / Keep mine" choice, read-only without:editaccess, and a debounced change for a note no longer open is dropped. With Leaf 0.11 the Leaf editor takes over, unchanged.Search snippets:
search_with_snippets/3returns each match with a snippet of its body around the matched words (ts_headline, front matter left out), as{:text, _}/{:match, _}segments for the host to render and escape itself.Numbered names on demand.
create_note/4,rename_note/4,move_note/4andrestore_note/3takeunique: true: a name already taken becomesIdeas 2,Ideas 3, … instead of:already_exists/:target_exists(Obsidian's rule). The note's own current path counts as free, so renaming a note to its own name stays a no-op. Past 100 the answer is:too_many_duplicates, with its own message.Rename and move in one step:
move_note/4takestitle:. One file move, so the note is never briefly renamed in the old folder or moved under a name that collides there.A trash a host can search:
list_trash/2withfrontmatter: trueadds each entry's parsed front matter, so a host that binds notes by a property (anid:) finds its trashed copy without reading.trash/itself.Notes by front-matter property:
list_notes/2takesproperty: {key, value}(compared as text) andhas_property: key | [keys], andget_note_by_property/3returns the first match, so hosts stop queryingfrontmatterin this module's tables with their own SQL.Right-click menus on the vault tree. Folders offer Rename and Move to trash, notes offer Open and Move to trash; touch-and-hold does the same on a phone, and the menu names the row it was opened on. Core's explorer already marks every row with the attributes
<.context_menu>reads, so this is two menu declarations — one element each, not one per row, and no round-trip to open. Confined to the sidebar withwithin, so a right-click on a note title in the search results or the editor still gets the browser's own menu. The folder menu stamps both spellings of the uuid param, becausestart_rename_foldertakesfolder-uuid(the sidebar's click vocabulary) andtrash_foldertakesfolder_uuid(the drag hook's) — cheaper than a shim clause that exists only to rename a key. One new handler,open_note: the tree opens a note through a<.link patch>and a context menu cannot, since item hrefs are fixed at render time and onlyphx-value-*varies per row.[[wiki-links]]render as links in the editor. Leaf 0.6.0 decorates them and asks which targets exist; the answer goes through the same resolver and same-folder tie-break that builds the stored link index, so a link never renders as pointing somewhere the backlinks panel disagrees with. An ambiguous title still resolves and now says so on hover, naming the note it picked. An unresolved target is styled distinctly and points at the create page, and the hover tooltip names the note a link resolved to. Leaf never navigates: the target is re-resolved on the server rather than trusting the href the page was rendered with.Configured
follow: :click, matching Obsidian's Live Preview. Following a link does not work yet, through no fault of the configuration: hybrid's source swap runs offselectionchange, which a mouse press fires beforeclick, so Leaf's handler finds the link's span already replaced and returns. No gesture reaches it. Written up for upstream indev_docs/2026-08-28-leaf-wikilink-click-bug.md; the outgoing-links panel remains the way to navigate.Folder move and rename, which the vault had no way to do — the README said "
mvthe directory and rescan".Storage.move_folder/3plusNotes.relocate_folder/3: one operation, because a rename and a reparent are one operation on a filesystem. Notes underneath keep their uuids and therefore their backlinks, empty subdirectories move with the rest, and every key the subtree answered to is re-resolved so path-qualified[[folder/Note]]links follow. Refuses the vault root, an existing target, a move into its own subtree, and anything outside the vault.Notes and folders in one tree, as Obsidian shows them. Core's
FolderExplorergained leaf items in 2.13.14; this passes every note keyed by the folder holding it and renders each as a link. The flat note list now appears only under a tag filter — without one it was a second copy of the tree.The sidebar now uses core's
FolderExplorer— guide lines, expand and collapse, inline rename — and core'sMediaDragDrophook, so notes and folders can be dragged into folders and notes onto Trash. Both come fromphoenix_kit, so this module still ships no JavaScript.Live editing groundwork. A
Leaf.Collab.Roomper note, started on demand under core's supervisor via thechildren/0callback, and aLeaf.Collab.Storeimplementation that persists throughNotes.update_content/4rather than writing the file directly — so a collaborative edit re-derives the title, front matter, tags, link graph and search vector, whichLeaf.Collab.Store.Filewould have left stale. The editor is not wired to it yet.scripts/verify_collab.exs— what a room writes and what it refuses, against a real database and filesystem.scripts/verify_links.exs— link resolution checked against a real database: what a link points at after every operation that can change the answer.mix testis deliberately database-free andsmoke_http.exsonly proves a page renders, not that it renders the right note.Trash. Deleted notes and folders move to
.trash/inside the vault instead of being unlinked, under a per-deletion directory that preserves the original path — so restore is a move, and deleting the same path twice keeps both copies. Trash panel with restore, permanent delete and empty.Leaf collaboration spec (
dev_docs/2026-08-25-leaf-collaboration-spec.md) — what Leaf needs for CRDT multi-user editing, staged, with the Hex ecosystem survey (y_ex,prosemirror_ex, they-phoenix-channeldemo) and the settled storage model: the CRDT lives in Postgres while a note is open and is flushed to disk on a timer.Live sync across browsers and devices. Each vault page subscribes to a PubSub topic; note saves, deletions, folder changes and rescans propagate to every open session. A note open in reading view is replaced immediately; one being edited raises a banner instead, so unsaved keystrokes are never discarded on your behalf. Rescans emit a single message rather than one per file, so
mix phoenix_kit_knowledge_base.rescanin a terminal refreshes open browsers without flooding them.
Changed
- Leaf 0.6.0, phoenix_kit 2.13.13. No code change was needed — the existing
~> 0.5constraint already admits 0.6.0 — but the pair has to move together: core vendors the jsDelivr pin for Leaf's JS, and 2.13.13 is the release that points it at@v0.6.0. 0.6.0 bringsLeaf.Collab(live editing) and[[wiki-link]]rendering, neither wired up here yet. - Reading view removed. There is one surface — the editor — as in Obsidian.
- File names are validated, not repaired. An invalid title is refused with
the reason instead of having its characters silently stripped, which used to
file
Ideas: Part 2asIdeas Part 2. Adds Obsidian's link-breaking characters ([ ] # ^) and Windows device names to the refused set. phoenix_kitconstraint raised from~> 2.0to>= 2.14.0 and < 4.0.0, and all deps updated to their latest allowed versions. 2.14.0 is the first published core with the<.context_menu>components the vault tree's right-click menus use (and 2.13.15+ hasFolderExplorer'sitemsattr /:itemslot); with the old lock at 2.13.13 the module did not compile at all. The lock now resolves phoenix_kit 3.0.0 (compile-, test- and credo-verified; core 3.0 still drives module-owned tables throughmigration_module/0, which this module implements) plus mdex 0.14.6, ex_doc 0.40.4, yaml_elixir 2.12.2, dialyxir 1.4.8. Hosts upgrading to core 3.0 must be at V225 (core 2.65.0) first — core refuses lower withBelowFloorError.
Fixed
Renaming or moving a note listed it twice.
index_entry/4looks the row up by path, which is the wrong key when the path is what changed: it found nothing at the new path and inserted a second row, leaving the old entry pointing at a file that had moved,rename_note/4returning that stale row, and every backlink still attached to it. The row being moved is now named explicitly. (Rescan-detected renames were never affected — they update in place and match by content hash.)The Reload button did not reload the editor. Leaf's surfaces are
phx-update="ignore", so assigningcontentchanges nothing on screen — replacing the buffer needssend_update(Leaf, action: :set_content, …). Discard mine, reload and a rescan that changed the open note underneath both left the old text in the editor while the server believed otherwise.[[autocomplete died at the first space. Neither suggestion trigger set:token, so both inherited Leaf's default of[\p{L}\p{N}_-]. A title likeAttention Is All You Needcould never be completed, nor a path-qualified[[research/Ideas]], nor a nested#project/alphatag. Both tokens now match the parser that has to read the result back.A resolved
[[link]]never reconsidered its target. Resolution only filled links that were still unresolved, so a link kept pointing at whichever same-named note got there first — creating a nearerarchive/Ideas.mddid not take the link fromresearch/Ideas.mduntil the next full rescan, contradicting the documented same-folder rule while still rendering as resolved. Every operation that changes which notes answer to a name — create, rename, move, delete, restore, folder delete — now re-resolves the links naming it. Scoped by key, so the work is proportional to how many notes mention that title rather than to vault size.A viewer could rename a note, and could start a rescan. Both were the only mutating events without a write check; the title input is
readonly, but readonly is a DOM property, not a permission. Membership changes now re-resolve the actor's role on each event too, rather than trusting the check done at mount.A vault row could point at a directory that was never created. The row was inserted first and the directory made second, so a failed
mkdirleft a vault that failed on its first read. Reversed, as the comment above it always claimed.UI copy contradicted the model. Deleting a note or folder said "from disk… cannot be undone" when both go to
.trash/, and the new-note pane claimed titles are unique within a vault — they are unique within a folder, and two folders each holding anIdeasis the case the whole tie-break exists for.Every vault page crashed with
BadBooleanError. The trash panel added@search_results and not @trash_opento a section guard;@search_resultsisnilwhen no search is running, andnil and …raises even though:if={@search_results}alone is fine. Addedscripts/smoke_http.exs, which renders every page as a logged-in user, because neither the database-free test suite nor an unauthenticated request executes a template.Two deletions in the same second lost the first recoverable copy — trash directories are now unique per deletion, not per second.
Autosave could silently overwrite a colleague's edit. Once a conflict was flagged, every subsequent autosave passed
force:— so continuing to type was enough to overwrite the other version without ever choosing to. Overwriting is now reachable only through an explicit button.Switching notes showed the previous note's text. Leaf's editing surfaces are
phx-update="ignore", so LiveView never patches them after mount; with a constant component id, patching between notes reused the same hook and every note rendered whatever was loaded first. The editor id is now keyed to the note, so LiveView mounts a fresh component per note.A late edit could be written into the wrong note. Leaf debounces, so a
:leaf_changedfor the note you just left could arrive after switching and be saved into the note now open. Payloads are now matched against the current editor id and dropped when stale.A note named with an uppercase extension (
Note.MD) was indexed but unlinkable. The walk accepts.MD(it downcases before comparing), but the title and the path-key both stripped only a lowercase.md, so the note's title came out as"Note.MD"and a bare[[Note]]never resolved to it. Extension stripping is now case-insensitive everywhere (Storage.drop_extension/1).The vault list ran one count query per card.
IndexLivenow fetches all note counts in one grouped query (Notes.count_notes_by_vault/1).Removed
Links.unresolve_stale/1— dead code: rename and move already re-resolve throughLinks.reresolve_keys/2, which covers both directions.
0.1.0 draft - 2026-08-25 (not published)
Added
- Filesystem storage. A vault is a directory, a folder is a directory, a note
is a
<Title>.mdfile — sorg,git,vimand Obsidian work on the tree directly. Layout:system/<slug>/andusers/<email-slug>/<slug>/under a configured root. - Rescan — reconciles the index with the disk, via a button or
mix phoenix_kit_knowledge_base.rescan. Compares(mtime, size)before hashing, and detects renames by content hash so an externalmvpreserves a note's uuid, permalink and backlinks. - Stale-write guard — the web UI refuses to overwrite a file that changed underneath it, offering reload or deliberate overwrite.
- Path safety — two independent containment checks, symlinks inside a vault skipped rather than followed, sanitised filenames, atomic writes.
- Ambiguity-tolerant link resolution — titles are not unique on a
filesystem, so
[[Ideas]]resolves same-folder first, then shortest path, then alphabetically, with[[folder/Ideas]]for explicit disambiguation. - Vaults — system (organisation-wide) and personal, sharing one
owner/editor/viewermembership model.knowledge_base.manage_system_vaultsconfers ownership of every system vault, and deliberately grants nothing on other people's personal vaults. - Notes in a nested folder tree, with YAML front matter and tags — both derived from the markdown on every save, never edited separately.
[[Wiki-links]]with aliases and heading anchors, resolved case- and whitespace-insensitively within a vault.- Backlinks panel and a per-vault unresolved links list, ordered by how many notes are asking for each missing title. Creating a note retroactively resolves every link already pointing at it; deleting one turns links back into unresolved rather than dropping the mentions.
[[autocomplete driven entirely server-side, plus a caret-aware "Link to note" picker for mid-paragraph inserts.- Full-text search over a generated
tsvectorcolumn with a GIN index, title-weighted, parsed withwebsearch_to_tsquery. - Read / edit toggle using core's
MarkdownEditorand MDEx rendering, with autosave that rebuilds the link index in the same transaction. - Versioned migration coordinator for the five
phoenix_kit_kb_*tables, prefix-aware and stamped viaCOMMENT ON TABLE. - 126 database-free tests covering markdown parsing, path containment and filename sanitisation, link-resolution tie-breaking, changeset invariants, behaviour compliance, route ordering and migration version bookkeeping.