feat(kairo): file links, embeds, and always-on file rename
All checks were successful
Deploy Docker Images / Build, push, and deploy (push) Successful in 17m2s

Open an upload dialog so project/tags can be set before save. Support file
wikilinks and ![[embeds]], copy [[title]], flexible title matching, and
autosave title/project/tags when selecting images and PDFs.
This commit is contained in:
maxfield 2026-08-08 19:30:29 -04:00
parent 025ced5129
commit 1bd9defee6
7 changed files with 678 additions and 155 deletions

View file

@ -6,16 +6,26 @@ defmodule Elektrine.Markdown do
@doc """
Converts markdown text to safe HTML.
Strips all images and dangerous content.
By default strips images (profile bios). Pass `allow_images: true` for
contexts like Kairo note embeds (`![[photo]]`).
"""
def to_html(markdown_text) when is_binary(markdown_text) do
markdown_text
|> MDEx.to_html!()
|> HtmlSanitizeEx.markdown_html()
|> strip_images()
def to_html(markdown_text, opts \\ [])
def to_html(markdown_text, opts) when is_binary(markdown_text) and is_list(opts) do
html =
markdown_text
|> MDEx.to_html!()
|> HtmlSanitizeEx.markdown_html()
if Keyword.get(opts, :allow_images, false) do
html
else
strip_images(html)
end
end
def to_html(nil), do: ""
def to_html(nil, _opts), do: ""
@doc """
Strips markdown formatting and returns plain text.

View file

@ -51,6 +51,8 @@ defmodule ElektrineWeb.KairoLive.Index do
|> assign(:search_hits, nil)
|> assign(:inbox_total, 0)
|> assign(:source_limit, @source_page)
|> assign(:upload_project_id, "")
|> assign(:upload_tags, "")
|> allow_upload(:kairo_files,
accept: @kairo_upload_extensions,
max_entries: 5,
@ -157,12 +159,23 @@ defmodule ElektrineWeb.KairoLive.Index do
|> load_kairo(socket.assigns.current_user)}
end
def handle_event("open_dialog", %{"name" => "upload"}, socket) do
{:noreply, open_upload_dialog(socket)}
end
def handle_event("open_dialog", %{"name" => name}, socket)
when name in ~w(link upload project filters) do
when name in ~w(link project filters) do
{:noreply, assign(socket, :dialog, String.to_existing_atom(name))}
end
def handle_event("close_dialog", _params, socket) do
socket =
if socket.assigns.dialog == :upload do
cancel_pending_kairo_uploads(socket)
else
socket
end
{:noreply, assign(socket, :dialog, nil)}
end
@ -193,12 +206,14 @@ defmodule ElektrineWeb.KairoLive.Index do
end
def handle_event("validate_kairo_upload", params, socket) do
# Files transfer with auto_upload, but we only consume on explicit submit so
# the user can set project/tags after drop or file pick.
socket =
socket
|> maybe_store_upload_meta(params)
|> maybe_open_upload_dialog()
{:noreply, maybe_auto_finish_kairo_upload(socket)}
{:noreply, socket}
end
def handle_event("cancel_kairo_upload", %{"ref" => ref}, socket) do
@ -206,7 +221,13 @@ defmodule ElektrineWeb.KairoLive.Index do
end
def handle_event("upload_kairo_files", params, socket) do
{:noreply, finish_kairo_upload(socket, params)}
socket = maybe_store_upload_meta(socket, params)
if kairo_upload_ready?(socket.assigns.uploads.kairo_files) do
{:noreply, finish_kairo_upload(socket, params)}
else
{:noreply, put_flash(socket, :error, "Wait for the upload to finish, then try again.")}
end
end
def handle_event("save_encrypted_note", %{"note" => note, "payload" => payload}, socket) do
@ -418,11 +439,15 @@ defmodule ElektrineWeb.KairoLive.Index do
{:noreply, put_flash(socket, :error, "Source not found")}
source ->
{:noreply,
socket
|> assign(:view_mode, "reader")
|> open_editor(source)
|> assign_view()}
if renamable_source?(source) do
{:noreply,
socket
|> assign(:view_mode, "reader")
|> open_editor(source)
|> load_kairo(user)}
else
{:noreply, put_flash(socket, :error, "This source cannot be edited")}
end
end
end
@ -535,24 +560,38 @@ defmodule ElektrineWeb.KairoLive.Index do
def handle_info({:storage_updated, _storage}, socket), do: {:noreply, socket}
def handle_info(_message, socket), do: {:noreply, socket}
def handle_kairo_upload_progress(:kairo_files, entry, socket) do
socket = maybe_open_upload_dialog(socket)
if entry.done? do
{:noreply, maybe_auto_finish_kairo_upload(socket)}
else
{:noreply, socket}
end
def handle_kairo_upload_progress(:kairo_files, _entry, socket) do
# Open the project/tags dialog while transfer runs; do not consume yet.
{:noreply, maybe_open_upload_dialog(socket)}
end
defp maybe_open_upload_dialog(socket) do
if socket.assigns.uploads.kairo_files.entries != [] do
assign(socket, :dialog, :upload)
else
socket
cond do
socket.assigns.uploads.kairo_files.entries == [] ->
socket
socket.assigns.dialog == :upload ->
# Already open — do not re-seed project/tags over the user's picks.
socket
true ->
open_upload_dialog(socket)
end
end
defp open_upload_dialog(socket) do
project_id =
case socket.assigns[:active_project] do
id when is_integer(id) -> to_string(id)
_ -> socket.assigns[:upload_project_id] || ""
end
socket
|> assign(:dialog, :upload)
|> assign(:upload_project_id, project_id)
|> assign(:upload_tags, socket.assigns[:upload_tags] || "")
end
defp maybe_store_upload_meta(socket, %{"upload" => params}) when is_map(params) do
socket
|> assign(:upload_project_id, Map.get(params, "project_id", ""))
@ -561,19 +600,19 @@ defmodule ElektrineWeb.KairoLive.Index do
defp maybe_store_upload_meta(socket, _params), do: socket
defp maybe_auto_finish_kairo_upload(socket) do
entries = socket.assigns.uploads.kairo_files.entries
defp kairo_upload_ready?(%{entries: entries} = upload) do
entries != [] and
Enum.all?(entries, & &1.done?) and
Enum.all?(entries, fn entry -> upload_errors(upload, entry) == [] end) and
upload_errors(upload) == []
end
if entries != [] and Enum.all?(entries, & &1.done?) do
finish_kairo_upload(socket, %{
"upload" => %{
"project_id" => socket.assigns[:upload_project_id] || "",
"tags" => socket.assigns[:upload_tags] || ""
}
})
else
socket
end
defp kairo_upload_ready?(_), do: false
defp cancel_pending_kairo_uploads(socket) do
Enum.reduce(socket.assigns.uploads.kairo_files.entries, socket, fn entry, acc ->
cancel_upload(acc, :kairo_files, entry.ref)
end)
end
defp finish_kairo_upload(socket, params) do
@ -611,6 +650,8 @@ defmodule ElektrineWeb.KairoLive.Index do
socket
|> assign(:dialog, nil)
|> assign(:upload_project_id, "")
|> assign(:upload_tags, "")
|> assign(:selected_id, last.id)
|> put_flash(:info, upload_success_message(successes))
|> load_kairo(user)
@ -621,6 +662,8 @@ defmodule ElektrineWeb.KairoLive.Index do
socket
|> assign(:dialog, nil)
|> assign(:upload_project_id, "")
|> assign(:upload_tags, "")
|> assign(:selected_id, last.id)
|> put_flash(:error, "Some files could not be saved.")
|> load_kairo(user)
@ -661,7 +704,9 @@ defmodule ElektrineWeb.KairoLive.Index do
previous_selected_id == selected_id ->
socket
editable_source?(selected) ->
# Notes and files open the always-on form (title/project/tags). Files keep
# a preview below; notes get the markdown body. No Rename click required.
editable_source?(selected) or file_source?(selected) ->
open_editor(socket, selected)
true ->
@ -861,17 +906,25 @@ defmodule ElektrineWeb.KairoLive.Index do
defp source_update_attrs(source, note) do
attrs = %{
"source_type" => source.source_type || "markdown",
"content_format" => source.content_format || "markdown",
"title" => note["title"],
"tags" => note["tags"],
"project_id" => blank_to_nil(note["project_id"])
}
if source.encrypted do
attrs
else
Map.put(attrs, "content", note["content"])
# Never blank out extracted/binary content when renaming a file from a
# metadata-only form (no content field posted).
cond do
source.encrypted ->
attrs
file_source?(source) ->
attrs
true ->
attrs
|> Map.put("source_type", source.source_type || "markdown")
|> Map.put("content_format", source.content_format || "markdown")
|> Map.put("content", note["content"])
end
end
@ -900,16 +953,19 @@ defmodule ElektrineWeb.KairoLive.Index do
defp parse_id(_), do: nil
# Only note-like sources open in the markdown editor. Files, PDFs, images,
# and URL captures always open in the reader (preview the asset like Obsidian).
# Never treat "has extracted text" as editable — PDFs often have extracted
# body text and must still open as documents, not notes.
# Note body editing (markdown textarea). Files stay in the reader; rename
# uses the metadata form via renamable_source?/1 instead.
# Never treat "has extracted text" as body-editable — PDFs often extract text.
defp editable_source?(%{encrypted: true}), do: false
defp editable_source?(%{source_type: type}) when type in ~w(markdown text html json), do: true
defp editable_source?(_source), do: false
# Title / project / tags — notes, files, URLs, encrypted shells.
defp renamable_source?(%{id: id}) when not is_nil(id), do: true
defp renamable_source?(_), do: false
defp apply_search(socket, query) do
query = query || ""
user = socket.assigns.current_user
@ -993,7 +1049,8 @@ defmodule ElektrineWeb.KairoLive.Index do
editing_source =
Enum.find(pool, &(&1.id == socket.assigns.editing_source_id)) ||
Enum.find(sources, &(&1.id == socket.assigns.editing_source_id))
Enum.find(sources, &(&1.id == socket.assigns.editing_source_id)) ||
resolve_selected(socket.assigns.current_user, socket.assigns.editing_source_id)
{backlinks, outgoing} =
if selected do
@ -1055,20 +1112,12 @@ defmodule ElektrineWeb.KairoLive.Index do
if preformatted_content?(source) do
{:pre, content}
else
by_title =
Map.new(sources, fn s ->
{String.downcase(s.title || ""), s.id}
end)
resolver = wikilink_resolver(sources)
html =
content
|> Kairo.Wikilinks.to_markdown(fn title ->
case Map.get(by_title, String.downcase(title)) do
nil -> nil
id -> "/kairo?s=#{id}"
end
end)
|> Elektrine.Markdown.to_html()
|> Kairo.Wikilinks.to_markdown(resolver)
|> Elektrine.Markdown.to_html(allow_images: true)
{:html, html}
end
@ -1076,6 +1125,61 @@ defmodule ElektrineWeb.KairoLive.Index do
defp rendered_content(_source, _sources), do: nil
# Build a title → resolve map for [[links]] / ![[embeds]].
defp wikilink_resolver(sources) when is_list(sources) do
by_key =
sources
|> Enum.reduce(%{}, fn source, acc ->
Enum.reduce(source_title_keys(source), acc, fn key, inner ->
Map.put_new(inner, key, source)
end)
end)
fn title ->
case Map.get(by_key, String.downcase(String.trim(title || ""))) do
nil ->
nil
source ->
path = "/kairo?s=#{source.id}"
embed_url = source_file_url(source)
cond do
source_image?(source) and is_binary(embed_url) ->
%{path: path, embed_url: embed_url, media: :image}
source_pdf?(source) ->
%{path: path, media: :pdf}
source.source_type in ~w(file image pdf) ->
%{path: path, media: :file}
true ->
%{path: path, media: :note}
end
end
end
end
defp source_title_keys(source) do
filename =
case source.metadata do
%{} = meta -> meta["original_filename"] || meta["filename"]
_ -> nil
end
[source.title, filename]
|> Enum.filter(&(is_binary(&1) and String.trim(&1) != ""))
|> Enum.flat_map(fn name ->
trimmed = String.trim(name)
bare = Path.rootname(trimmed)
[trimmed, bare, bare <> Path.extname(trimmed)]
end)
|> Enum.map(&String.downcase/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
end
@project_palette ~w(#6366f1 #ec4899 #14b8a6 #f59e0b #8b5cf6 #ef4444 #10b981 #3b82f6)
@inbox_color "#9ca3af"
@max_edges_per_source 5
@ -1372,6 +1476,10 @@ defmodule ElektrineWeb.KairoLive.Index do
source.source_type == "pdf" or source_file_content_type(source) == "application/pdf"
end
defp file_source?(source) do
source.source_type in ~w(image pdf file) or is_binary(source_file_key(source))
end
defp format_file_size(size) when is_integer(size) and size >= 1_048_576 do
"#{Float.round(size / 1_048_576, 1)} MB"
end
@ -1417,7 +1525,7 @@ defmodule ElektrineWeb.KairoLive.Index do
end
defp run_palette_command(socket, "upload") do
{:noreply, assign(socket, :dialog, :upload)}
{:noreply, open_upload_dialog(socket)}
end
defp run_palette_command(socket, "new_project") do
@ -1900,10 +2008,20 @@ defmodule ElektrineWeb.KairoLive.Index do
type="text"
name="note[title]"
value={@compose["title"]}
placeholder="Untitled"
placeholder={
if(@editing_source && file_source?(@editing_source),
do: "File title",
else: "Untitled"
)
}
autocomplete="off"
phx-debounce="400"
phx-mounted={JS.focus()}
phx-mounted={
if(is_nil(@editing_source) or editable_source?(@editing_source),
do: JS.focus(),
else: %JS{}
)
}
class="w-full border-0 bg-transparent p-0 text-2xl font-semibold tracking-tight outline-none placeholder:text-base-content/30 focus:ring-0"
/>
@ -1937,12 +2055,91 @@ defmodule ElektrineWeb.KairoLive.Index do
{save_status_label(@save_status, @save_status_at)}
</p>
</div>
<p class="text-xs text-base-content/45">
Type <code class="text-base-content/70">[[</code> to link another note
<p
:if={is_nil(@editing_source) or editable_source?(@editing_source)}
class="text-xs text-base-content/45"
>
Type <code class="text-base-content/70">[[</code>
to link another note · <code class="text-base-content/70">![[</code>
embeds images
</p>
<p
:if={@editing_source && file_source?(@editing_source)}
class="text-xs text-base-content/50"
>
Title is the <span class="font-mono">[[wikilink]]</span>
name
<span :if={
source_file_name(@editing_source) not in [nil, ""] and
source_file_name(@editing_source) != (@compose["title"] || "")
}>
· file <span class="font-mono">{source_file_name(@editing_source)}</span>
</span>
</p>
<div
:if={is_nil(@editing_source) or !@editing_source.encrypted}
:if={
@editing_source && not editable_source?(@editing_source) &&
not file_source?(@editing_source) && not @editing_source.encrypted
}
class="rounded-xl border border-base-300 bg-base-200/30 px-4 py-3 text-xs text-base-content/60"
>
You can change the title, project, and tags for this source.
</div>
<%!-- File preview under the always-on title (same idea as note body). --%>
<div
:if={@editing_source && file_source?(@editing_source)}
class="min-h-0 flex-1 space-y-2"
>
<% file_url = source_file_url(@editing_source) %>
<div
:if={is_binary(file_url) and source_image?(@editing_source)}
class="flex max-h-[min(28rem,50dvh)] items-center justify-center overflow-auto rounded-xl border border-base-300 bg-base-200/40 p-3"
>
<img
src={file_url}
alt={source_file_name(@editing_source)}
class="max-h-[min(26rem,48dvh)] max-w-full object-contain"
/>
</div>
<iframe
:if={is_binary(file_url) and source_pdf?(@editing_source)}
src={file_url}
class="h-[min(28rem,50dvh)] w-full rounded-xl border border-base-300 bg-base-100"
title={source_file_name(@editing_source)}
>
</iframe>
<div
:if={
is_binary(file_url) and not source_image?(@editing_source) and
not source_pdf?(@editing_source)
}
class="flex flex-col items-center justify-center gap-2 rounded-xl border border-base-300 bg-base-200/30 px-6 py-10 text-center"
>
<.icon name={source_icon(@editing_source)} class="h-10 w-10 text-base-content/40" />
<.button
href={file_url}
target="_blank"
rel="noopener noreferrer"
variant="default"
outline
size="sm"
>
Open file
</.button>
</div>
<div
:if={not is_binary(file_url)}
class="rounded-xl border border-base-300 bg-base-200/30 px-4 py-6 text-center text-sm text-base-content/55"
>
Preview unavailable title still autosaves above.
</div>
</div>
<div
:if={is_nil(@editing_source) or editable_source?(@editing_source)}
id={"kairo-markdown-editor-#{@editing_source_id || "new"}"}
data-markdown-editor
class="space-y-0 overflow-hidden rounded-xl"
@ -2020,9 +2217,14 @@ defmodule ElektrineWeb.KairoLive.Index do
<div class="flex shrink-0 items-center justify-between gap-1.5 border-t border-base-300 bg-base-200/20 px-3 py-2 sm:px-4">
<span class="text-xs text-base-content/45">
{if @compose["encrypt"] == "true",
do: "Encrypted notes need an explicit save",
else: "Autosaves as you type · ⌘S to save now"}
<%= cond do %>
<% @compose["encrypt"] == "true" -> %>
Encrypted notes need an explicit save
<% @editing_source && not editable_source?(@editing_source) -> %>
Autosaves title, project, and tags
<% true -> %>
Autosaves as you type · S to save now
<% end %>
</span>
<div class="flex items-center gap-1.5">
<.button type="button" phx-click="cancel_note" variant="ghost" size="sm">
@ -2087,7 +2289,20 @@ defmodule ElektrineWeb.KairoLive.Index do
</h1>
<div class="flex shrink-0 flex-wrap items-center gap-1.5">
<.button
:if={editable_source?(@selected)}
:if={present?(@selected.title)}
type="button"
phx-click={CopyButton.copy()}
data-copy={"[[#{@selected.title}]]"}
data-copy-type="selection"
variant="ghost"
size="xs"
title="Copy wikilink for notes"
>
<.icon name="hero-clipboard-document" class="h-3.5 w-3.5" />
<span class="font-mono text-2xs">[[link]]</span>
</.button>
<.button
:if={renamable_source?(@selected) and not file_source?(@selected)}
type="button"
phx-click="edit_source"
phx-value-id={@selected.id}
@ -2097,6 +2312,16 @@ defmodule ElektrineWeb.KairoLive.Index do
>
<.icon name="hero-pencil-square" class="h-3.5 w-3.5" /> Edit
</.button>
<.button
:if={file_source?(@selected)}
type="button"
phx-click="edit_source"
phx-value-id={@selected.id}
variant="ghost"
size="xs"
>
<.icon name="hero-pencil-square" class="h-3.5 w-3.5" /> Edit details
</.button>
<.button
type="button"
phx-click="delete_source"
@ -2324,6 +2549,38 @@ defmodule ElektrineWeb.KairoLive.Index do
<%= if is_nil(@selected) and not @composing do %>
<p class="text-xs text-base-content/50">Select a note to see links.</p>
<% else %>
<div
:if={@selected && present?(@selected.title)}
class="rounded-lg border border-base-300 bg-base-200/25 px-2.5 py-2"
>
<p class="text-2xs font-medium uppercase tracking-wide text-base-content/50">
Wikilink
</p>
<div class="mt-1 flex items-center gap-1.5">
<code class="min-w-0 flex-1 truncate font-mono text-xs text-base-content/80">
[[{@selected.title}]]
</code>
<.button
type="button"
phx-click={CopyButton.copy()}
data-copy={"[[#{@selected.title}]]"}
data-copy-type="selection"
variant="ghost"
size="xs"
class="shrink-0"
>
Copy
</.button>
</div>
<p
:if={file_source?(@selected)}
class="mt-1.5 text-2xs leading-snug text-base-content/50"
>
Paste into a note. Use <span class="font-mono">![[{@selected.title}]]</span>
to embed images.
</p>
</div>
<div>
<p class="mb-1.5 text-xs font-medium text-base-content/60">
Linked from ({length(@backlinks)})
@ -2342,7 +2599,11 @@ defmodule ElektrineWeb.KairoLive.Index do
</li>
</ul>
<p :if={@backlinks == []} class="px-1 text-xs text-base-content/45">
No backlinks yet.
<%= if @selected && file_source?(@selected) && present?(@selected.title) do %>
No notes link here yet. In a note, write <span class="font-mono">[[<%= @selected.title %>]]</span>.
<% else %>
No backlinks yet.
<% end %>
</p>
</div>
@ -2378,7 +2639,15 @@ defmodule ElektrineWeb.KairoLive.Index do
</li>
</ul>
<p :if={@outgoing_links == []} class="px-1 text-xs text-base-content/45">
Use [[Title]] in a note body.
<%= if @selected && file_source?(@selected) do %>
Files do not start links. Notes use <span class="font-mono">[[Title]]</span>
and <span class="font-mono">![[Title]]</span>
for embeds.
<% else %>
Use <span class="font-mono">[[Title]]</span>
or <span class="font-mono">![[Title]]</span>
in a note body.
<% end %>
</p>
</div>
@ -2522,8 +2791,14 @@ defmodule ElektrineWeb.KairoLive.Index do
<div>
<label class="label py-1"><span class="label-text">Project</span></label>
<select name="upload[project_id]" class="select select-bordered w-full">
<option value="">Inbox</option>
<option :for={project <- @projects} value={project.id}>{project.name}</option>
<option value="" selected={@upload_project_id in [nil, ""]}>Inbox</option>
<option
:for={project <- @projects}
value={project.id}
selected={to_string(@upload_project_id) == to_string(project.id)}
>
{project.name}
</option>
</select>
</div>
<div>
@ -2531,6 +2806,7 @@ defmodule ElektrineWeb.KairoLive.Index do
<input
type="text"
name="upload[tags]"
value={@upload_tags}
placeholder="#files, #docs"
class="input input-bordered w-full"
/>
@ -2543,8 +2819,13 @@ defmodule ElektrineWeb.KairoLive.Index do
<div class="modal-action mt-2">
<button type="button" phx-click="close_dialog" class="btn btn-ghost">Cancel</button>
<.button type="submit" disabled={@uploads.kairo_files.entries == []}>
Upload
<.button type="submit" disabled={not kairo_upload_ready?(@uploads.kairo_files)}>
{if(
@uploads.kairo_files.entries != [] and
not Enum.all?(@uploads.kairo_files.entries, & &1.done?),
do: "Uploading…",
else: "Save to Kairo"
)}
</.button>
</div>
</form>

View file

@ -75,7 +75,7 @@ defmodule ElektrineWeb.KairoLiveTest do
assert render(view) =~ "Linked note"
end
test "selecting a non-editable source leaves the new-note composer", %{conn: conn} do
test "selecting a file opens title fields without the markdown body", %{conn: conn} do
user = AccountsFixtures.user_fixture()
{:ok, pdf} =
@ -83,7 +83,7 @@ defmodule ElektrineWeb.KairoLiveTest do
"source_type" => "pdf",
"title" => "Manual.pdf",
"status" => "stored",
# Extracted PDF text must not force the markdown editor open.
# Extracted PDF text must not force the markdown body editor open.
"content" => "words extracted from the pdf body"
})
@ -95,11 +95,10 @@ defmodule ElektrineWeb.KairoLiveTest do
assert_patch(view, ~p"/kairo?s=#{pdf.id}")
html = render(view)
refute html =~ ~s(id="kairo-note-title")
# Same always-on title form as notes — rename without a button.
assert has_element?(view, "#kairo-note-title")
refute html =~ ~s(data-markdown-textarea)
assert has_element?(view, "article h1", "Manual.pdf")
# Reader chrome, not the note composer.
assert has_element?(view, "article")
assert html =~ "wikilink"
end
test "saves a link as a url source", %{conn: conn} do
@ -178,6 +177,8 @@ defmodule ElektrineWeb.KairoLiveTest do
test "uploads a file source from the explorer", %{conn: conn} do
{user, view} = mount_kairo(conn)
{:ok, project} = Kairo.create_project(user, %{"name" => "Drop target"})
upload =
file_input(view, "#kairo-upload-form-root", :kairo_files, [
%{
@ -188,16 +189,62 @@ defmodule ElektrineWeb.KairoLiveTest do
}
])
# auto_upload + progress handler consumes when transfer completes
# Transfer completes, but the dialog stays open so the user can set project.
_ = render_upload(upload, "notes.txt")
assert render(view) =~ "Save to Kairo"
assert Kairo.list_sources(user) == []
render_submit(view, "upload_kairo_files", %{
"upload" => %{"project_id" => to_string(project.id), "tags" => "#drop"}
})
sources = Kairo.list_sources(user)
assert [source] = sources
assert source.source_type == "file"
assert source.content == "remember this file"
assert source.project_id == project.id
assert "drop" in source.tags or "#drop" in source.tags or source.tags != []
assert source.metadata["key"] =~ "kairo-sources/#{user.id}/"
end
test "renames a file source title without wiping content", %{conn: conn} do
{user, view} = mount_kairo(conn)
{:ok, source} =
Kairo.create_source(user, %{
"source_type" => "image",
"title" => "diagram",
"content" => "extracted-ocr-text",
"status" => "stored",
"metadata" => %{
"original_filename" => "diagram.png",
"content_type" => "image/png",
"key" => "kairo-sources/#{user.id}/diagram.png"
}
})
render_click(view, "select_source", %{"id" => to_string(source.id)})
html = render(view)
# Selecting a file opens the always-on title form (no Rename click).
assert has_element?(view, "#kairo-note-title")
refute html =~ ~s(data-markdown-textarea)
render_submit(view, "save_note", %{
"note" => %{
"title" => "Architecture diagram",
"tags" => "#design",
"project_id" => "",
"content" => ""
}
})
updated = Kairo.get_source(user, source.id)
assert updated.title == "Architecture diagram"
assert updated.content == "extracted-ocr-text"
assert "design" in updated.tags
assert updated.metadata["original_filename"] == "diagram.png"
end
test "manages the project lifecycle", %{conn: conn} do
{user, view} = mount_kairo(conn)

View file

@ -423,10 +423,22 @@ defmodule Kairo do
if present_value?(attrs["title"]) do
attrs
else
Map.put(attrs, "title", filename)
# Prefer stem so notes can `[[photo]]` as well as `[[photo.jpg]]`.
Map.put(attrs, "title", title_from_filename(filename))
end
end
defp title_from_filename(filename) when is_binary(filename) do
base = Path.basename(filename)
case Path.rootname(base) do
"" -> base
stem -> stem
end
end
defp title_from_filename(_), do: "file"
defp present_value?(value) when is_binary(value), do: String.trim(value) != ""
defp present_value?(value), do: not is_nil(value)
@ -693,7 +705,11 @@ defmodule Kairo do
end
@doc """
Find a source by exact title (case-insensitive) for the user.
Find a source by title (case-insensitive) for the user.
Matches the stored title, title stem / with extension, and
`metadata.original_filename` so `[[photo]]` and `[[photo.jpg]]` both resolve
to an upload titled `photo` (or named `photo.jpg`).
"""
def find_source_by_title(%User{id: user_id}, title), do: find_source_by_title(user_id, title)
@ -703,9 +719,16 @@ defmodule Kairo do
if title == "" do
nil
else
candidates = title_match_candidates(title)
Source
|> where([source], source.user_id == ^user_id)
|> where([source], fragment("lower(?) = lower(?)", source.title, ^title))
|> where(
[source],
fragment("lower(?)", source.title) in ^candidates or
fragment("lower(coalesce(metadata->>'original_filename', ''))") in ^candidates or
fragment("lower(coalesce(metadata->>'filename', ''))") in ^candidates
)
|> order_by([source], desc: source.updated_at)
|> limit(1)
|> preload(:project)
@ -716,6 +739,17 @@ defmodule Kairo do
def find_source_by_title(_user_id, _title), do: nil
# Downcased forms used for flexible title / filename matching.
defp title_match_candidates(title) when is_binary(title) do
trimmed = String.trim(title)
bare = Path.rootname(trimmed)
[trimmed, bare, bare <> Path.extname(trimmed)]
|> Enum.map(&String.downcase/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
end
@doc """
Sources that link to `source_id` (resolved backlinks), newest first.
"""
@ -736,6 +770,8 @@ defmodule Kairo do
|> Enum.map(fn link ->
%{link | from_source: decrypt_at_rest_content(link.from_source)}
end)
# One row per source even when the same note used both [[t]] and ![[t.ext]].
|> Enum.uniq_by(& &1.from_source_id)
:error ->
[]
@ -820,18 +856,40 @@ defmodule Kairo do
defp resolve_incoming_links(_user_id, %Source{title: title}) when not is_binary(title), do: :ok
defp resolve_incoming_links(_user_id, %Source{title: ""}), do: :ok
defp resolve_incoming_links(user_id, %Source{id: id, title: title}) do
Link
|> where(
[link],
link.user_id == ^user_id and is_nil(link.to_source_id) and
fragment("lower(?) = lower(?)", link.target_title, ^String.trim(title))
)
|> Repo.update_all(
set: [to_source_id: id, updated_at: DateTime.utc_now() |> DateTime.truncate(:second)]
)
defp resolve_incoming_links(user_id, %Source{} = source) do
candidates = matchable_titles_for_source(source)
:ok
if candidates == [] do
:ok
else
Link
|> where(
[link],
link.user_id == ^user_id and is_nil(link.to_source_id) and
fragment("lower(?)", link.target_title) in ^candidates
)
|> Repo.update_all(
set: [
to_source_id: source.id,
updated_at: DateTime.utc_now() |> DateTime.truncate(:second)
]
)
:ok
end
end
defp matchable_titles_for_source(%Source{} = source) do
filename =
case source.metadata do
%{} = meta -> meta["original_filename"] || meta["filename"]
_ -> nil
end
[source.title, filename]
|> Enum.filter(&(is_binary(&1) and String.trim(&1) != ""))
|> Enum.flat_map(&title_match_candidates/1)
|> Enum.uniq()
end
def retry_url_source(%User{id: user_id}, id), do: retry_url_source(user_id, id)

View file

@ -1,66 +1,76 @@
defmodule Kairo.Wikilinks do
@moduledoc """
Parse `[[Title]]` / `[[Title|alias]]` from note bodies and keep `kairo_links` in sync.
Parse `[[Title]]` / `[[Title|alias]]` / `![[Title]]` embeds from note bodies
and keep `kairo_links` in sync.
"""
# Match [[target]] or [[target|alias]], not escaped \[[
@wikilink_re ~r/(?<!\\)\[\[([^\]|#]+)(?:\|([^\]]+))?\]\]/u
# Optional embed bang, then [[target]] or [[target|alias]]; not escaped \[[
@wikilink_re ~r/(?<!\\)(!?)\[\[([^\]|#]+)(?:\|([^\]]+))?\]\]/u
@doc """
Returns a list of `%{target_title: binary, alias: binary | nil}` in document order.
Dedupes by downcased target title, keeping the first alias.
Returns a list of `%{target_title: binary, alias: binary | nil, embed?: boolean}`
in document order. Dedupes by downcased target title, keeping the first alias
and treating any embed occurrence as embed for that title.
"""
def extract(content) when is_binary(content) do
@wikilink_re
|> Regex.scan(content, capture: :all_but_first)
|> Enum.reduce({[], MapSet.new()}, fn
[target, alias], {acc, seen} ->
title = target |> String.trim() |> String.slice(0, 255)
|> Enum.reduce({[], %{}}, fn captures, {order, by_title} ->
case parse_capture(captures) do
nil ->
{order, by_title}
if title == "" or MapSet.member?(seen, String.downcase(title)) do
{acc, seen}
else
alias =
case blank_to_nil(alias) do
nil -> nil
value -> String.slice(value, 0, 255)
end
%{target_title: title} = entry ->
key = String.downcase(title)
entry = %{target_title: title, alias: alias}
{[entry | acc], MapSet.put(seen, String.downcase(title))}
end
case Map.get(by_title, key) do
nil ->
{[key | order], Map.put(by_title, key, entry)}
[target], {acc, seen} ->
title = target |> String.trim() |> String.slice(0, 255)
existing ->
merged = %{
existing
| embed?: existing.embed? or entry.embed?,
alias: existing.alias || entry.alias
}
if title == "" or MapSet.member?(seen, String.downcase(title)) do
{acc, seen}
else
entry = %{target_title: title, alias: nil}
{[entry | acc], MapSet.put(seen, String.downcase(title))}
end
_other, acc ->
acc
{order, Map.put(by_title, key, merged)}
end
end
end)
|> then(fn {order, by_title} ->
order
|> Enum.reverse()
|> Enum.map(&Map.fetch!(by_title, &1))
end)
|> elem(0)
|> Enum.reverse()
end
def extract(_content), do: []
@doc """
Rewrite wikilinks to markdown links using `resolver.(title) -> path | nil`.
Rewrite wikilinks to markdown using `resolver.(title)`.
Resolver return values:
* `nil` unresolved (renders as bold)
* binary path standard markdown link
* `%{path: path}` link
* `%{path: path, embed_url: url, media: :image}` image embed when `![[]]`, else link
* `%{path: path, media: :pdf | :file | :note, }` link (chip-style label for embeds)
Unresolved titles become plain bold text so they stay readable.
"""
def to_markdown(content, resolver) when is_binary(content) and is_function(resolver, 1) do
Regex.replace(@wikilink_re, content, fn full ->
case Regex.run(@wikilink_re, full, capture: :all_but_first) do
[target, alias] ->
render_link(String.trim(target), blank_to_nil(alias), resolver)
captures when is_list(captures) ->
case parse_capture(captures) do
%{target_title: title, alias: alias, embed?: embed?} ->
render_link(title, alias, embed?, resolver)
[target] ->
render_link(String.trim(target), nil, resolver)
nil ->
full
end
_other ->
full
@ -70,31 +80,82 @@ defmodule Kairo.Wikilinks do
def to_markdown(content, _resolver), do: content || ""
defp render_link(title, alias, resolver) do
defp parse_capture([bang, target, alias]) do
build_entry(bang, target, alias)
end
defp parse_capture([bang, target]) do
build_entry(bang, target, nil)
end
defp parse_capture(_), do: nil
defp build_entry(bang, target, alias) do
title = target |> String.trim() |> String.slice(0, 255)
if title == "" do
nil
else
%{
target_title: title,
alias: normalize_alias(alias),
embed?: bang == "!"
}
end
end
defp normalize_alias(nil), do: nil
defp normalize_alias(""), do: nil
defp normalize_alias(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
other -> String.slice(other, 0, 255)
end
end
defp render_link(title, alias, embed?, resolver) do
label = alias || title
resolved = resolver.(title)
case resolver.(title) do
path when is_binary(path) and path != "" ->
"[#{escape_label(label)}](#{path})"
cond do
embed? and match?(%{media: :image, embed_url: url} when is_binary(url), resolved) ->
url = resolved.embed_url
path = Map.get(resolved, :path)
_ ->
img = "![#{escape_label(label)}](#{url})"
if is_binary(path) and path != "" do
# Image embeds stay clickable through to the source.
"[#{img}](#{path})"
else
img
end
embed? and is_map(resolved) and is_binary(Map.get(resolved, :path)) ->
path = resolved.path
chip = embed_chip_label(label, Map.get(resolved, :media))
"[#{escape_label(chip)}](#{path})"
is_map(resolved) and is_binary(Map.get(resolved, :path)) ->
"[#{escape_label(label)}](#{resolved.path})"
is_binary(resolved) and resolved != "" ->
"[#{escape_label(label)}](#{resolved})"
true ->
"**#{escape_label(label)}**"
end
end
defp embed_chip_label(label, :pdf), do: "PDF · #{label}"
defp embed_chip_label(label, :file), do: "File · #{label}"
defp embed_chip_label(label, :image), do: label
defp embed_chip_label(label, _), do: label
defp escape_label(label) do
label
|> String.replace("[", "\\[")
|> String.replace("]", "\\]")
end
defp blank_to_nil(nil), do: nil
defp blank_to_nil(""), do: nil
defp blank_to_nil(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
other -> other
end
end
end

View file

@ -3,14 +3,16 @@ defmodule Kairo.WikilinksTest do
alias Kairo.Wikilinks
test "extracts titles and aliases" do
test "extracts titles, aliases, and embeds" do
content = """
See [[Alpha]] and [[Beta|β]] plus [[Alpha]] again.
See [[Alpha]] and [[Beta|β]] plus ![[photo]] and [[Alpha]] again.
Embed again ![[photo|shot]].
"""
assert Wikilinks.extract(content) == [
%{target_title: "Alpha", alias: nil},
%{target_title: "Beta", alias: "β"}
%{target_title: "Alpha", alias: nil, embed?: false},
%{target_title: "Beta", alias: "β", embed?: false},
%{target_title: "photo", alias: "shot", embed?: true}
]
end
@ -26,4 +28,34 @@ defmodule Kairo.WikilinksTest do
assert html_md =~ "[Alpha](/kairo?s=1)"
assert html_md =~ "**Missing**"
end
test "to_markdown embeds images with ![[title]]" do
content = "Here ![[photo]] and [[photo]]."
md =
Wikilinks.to_markdown(content, fn
"photo" ->
%{path: "/kairo?s=9", embed_url: "/uploads/photo.png", media: :image}
_ ->
nil
end)
assert md =~ "![photo](/uploads/photo.png)"
assert md =~ "/kairo?s=9"
# Non-embed form stays a normal link.
assert md =~ "[photo](/kairo?s=9)"
end
test "to_markdown embeds non-images as chips" do
content = "![[report.pdf]]"
md =
Wikilinks.to_markdown(content, fn
"report.pdf" -> %{path: "/kairo?s=3", media: :pdf}
_ -> nil
end)
assert md =~ "[PDF · report.pdf](/kairo?s=3)"
end
end

View file

@ -682,6 +682,40 @@ defmodule KairoTest do
assert hd(backlinks).from_source_id == source.id
end
test "resolves embeds and filename-style titles to file sources" do
user = user_fixture()
{:ok, image} =
Kairo.create_source(user, %{
"source_type" => "image",
"title" => "diagram",
"content" => "",
"status" => "stored",
"metadata" => %{"original_filename" => "diagram.png", "content_type" => "image/png"}
})
assert Kairo.find_source_by_title(user, "diagram").id == image.id
assert Kairo.find_source_by_title(user, "diagram.png").id == image.id
{:ok, note} =
Kairo.create_source(user, %{
"source_type" => "markdown",
"title" => "Notes",
"content" => "See ![[diagram.png]] and [[diagram]].",
"status" => "stored"
})
outgoing = Kairo.list_outgoing_links(user, note.id)
# Distinct titles even when they resolve to the same file source.
assert length(outgoing) == 2
assert Enum.all?(outgoing, &(&1.to_source_id == image.id))
titles = outgoing |> Enum.map(& &1.target_title) |> Enum.sort()
assert titles == ["diagram", "diagram.png"]
assert [%{from_source_id: from_id}] = Kairo.list_backlinks(user, image.id)
assert from_id == note.id
end
test "ensure_daily_note/1 is idempotent" do
user = user_fixture()
assert {:ok, first} = Kairo.ensure_daily_note(user)