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 """ @doc """
Converts markdown text to safe HTML. 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 def to_html(markdown_text, opts \\ [])
markdown_text
|> MDEx.to_html!() def to_html(markdown_text, opts) when is_binary(markdown_text) and is_list(opts) do
|> HtmlSanitizeEx.markdown_html() html =
|> strip_images() markdown_text
|> MDEx.to_html!()
|> HtmlSanitizeEx.markdown_html()
if Keyword.get(opts, :allow_images, false) do
html
else
strip_images(html)
end
end end
def to_html(nil), do: "" def to_html(nil, _opts), do: ""
@doc """ @doc """
Strips markdown formatting and returns plain text. Strips markdown formatting and returns plain text.

View file

@ -51,6 +51,8 @@ defmodule ElektrineWeb.KairoLive.Index do
|> assign(:search_hits, nil) |> assign(:search_hits, nil)
|> assign(:inbox_total, 0) |> assign(:inbox_total, 0)
|> assign(:source_limit, @source_page) |> assign(:source_limit, @source_page)
|> assign(:upload_project_id, "")
|> assign(:upload_tags, "")
|> allow_upload(:kairo_files, |> allow_upload(:kairo_files,
accept: @kairo_upload_extensions, accept: @kairo_upload_extensions,
max_entries: 5, max_entries: 5,
@ -157,12 +159,23 @@ defmodule ElektrineWeb.KairoLive.Index do
|> load_kairo(socket.assigns.current_user)} |> load_kairo(socket.assigns.current_user)}
end end
def handle_event("open_dialog", %{"name" => "upload"}, socket) do
{:noreply, open_upload_dialog(socket)}
end
def handle_event("open_dialog", %{"name" => name}, socket) 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))} {:noreply, assign(socket, :dialog, String.to_existing_atom(name))}
end end
def handle_event("close_dialog", _params, socket) do 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)} {:noreply, assign(socket, :dialog, nil)}
end end
@ -193,12 +206,14 @@ defmodule ElektrineWeb.KairoLive.Index do
end end
def handle_event("validate_kairo_upload", params, socket) do 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 =
socket socket
|> maybe_store_upload_meta(params) |> maybe_store_upload_meta(params)
|> maybe_open_upload_dialog() |> maybe_open_upload_dialog()
{:noreply, maybe_auto_finish_kairo_upload(socket)} {:noreply, socket}
end end
def handle_event("cancel_kairo_upload", %{"ref" => ref}, socket) do def handle_event("cancel_kairo_upload", %{"ref" => ref}, socket) do
@ -206,7 +221,13 @@ defmodule ElektrineWeb.KairoLive.Index do
end end
def handle_event("upload_kairo_files", params, socket) do 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 end
def handle_event("save_encrypted_note", %{"note" => note, "payload" => payload}, socket) do 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")} {:noreply, put_flash(socket, :error, "Source not found")}
source -> source ->
{:noreply, if renamable_source?(source) do
socket {:noreply,
|> assign(:view_mode, "reader") socket
|> open_editor(source) |> assign(:view_mode, "reader")
|> assign_view()} |> open_editor(source)
|> load_kairo(user)}
else
{:noreply, put_flash(socket, :error, "This source cannot be edited")}
end
end 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({:storage_updated, _storage}, socket), do: {:noreply, socket}
def handle_info(_message, socket), do: {:noreply, socket} def handle_info(_message, socket), do: {:noreply, socket}
def handle_kairo_upload_progress(:kairo_files, entry, socket) do def handle_kairo_upload_progress(:kairo_files, _entry, socket) do
socket = maybe_open_upload_dialog(socket) # Open the project/tags dialog while transfer runs; do not consume yet.
{:noreply, maybe_open_upload_dialog(socket)}
if entry.done? do
{:noreply, maybe_auto_finish_kairo_upload(socket)}
else
{:noreply, socket}
end
end end
defp maybe_open_upload_dialog(socket) do defp maybe_open_upload_dialog(socket) do
if socket.assigns.uploads.kairo_files.entries != [] do cond do
assign(socket, :dialog, :upload) socket.assigns.uploads.kairo_files.entries == [] ->
else socket
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
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 defp maybe_store_upload_meta(socket, %{"upload" => params}) when is_map(params) do
socket socket
|> assign(:upload_project_id, Map.get(params, "project_id", "")) |> 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_store_upload_meta(socket, _params), do: socket
defp maybe_auto_finish_kairo_upload(socket) do defp kairo_upload_ready?(%{entries: entries} = upload) do
entries = socket.assigns.uploads.kairo_files.entries 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 defp kairo_upload_ready?(_), do: false
finish_kairo_upload(socket, %{
"upload" => %{ defp cancel_pending_kairo_uploads(socket) do
"project_id" => socket.assigns[:upload_project_id] || "", Enum.reduce(socket.assigns.uploads.kairo_files.entries, socket, fn entry, acc ->
"tags" => socket.assigns[:upload_tags] || "" cancel_upload(acc, :kairo_files, entry.ref)
} end)
})
else
socket
end
end end
defp finish_kairo_upload(socket, params) do defp finish_kairo_upload(socket, params) do
@ -611,6 +650,8 @@ defmodule ElektrineWeb.KairoLive.Index do
socket socket
|> assign(:dialog, nil) |> assign(:dialog, nil)
|> assign(:upload_project_id, "")
|> assign(:upload_tags, "")
|> assign(:selected_id, last.id) |> assign(:selected_id, last.id)
|> put_flash(:info, upload_success_message(successes)) |> put_flash(:info, upload_success_message(successes))
|> load_kairo(user) |> load_kairo(user)
@ -621,6 +662,8 @@ defmodule ElektrineWeb.KairoLive.Index do
socket socket
|> assign(:dialog, nil) |> assign(:dialog, nil)
|> assign(:upload_project_id, "")
|> assign(:upload_tags, "")
|> assign(:selected_id, last.id) |> assign(:selected_id, last.id)
|> put_flash(:error, "Some files could not be saved.") |> put_flash(:error, "Some files could not be saved.")
|> load_kairo(user) |> load_kairo(user)
@ -661,7 +704,9 @@ defmodule ElektrineWeb.KairoLive.Index do
previous_selected_id == selected_id -> previous_selected_id == selected_id ->
socket 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) open_editor(socket, selected)
true -> true ->
@ -861,17 +906,25 @@ defmodule ElektrineWeb.KairoLive.Index do
defp source_update_attrs(source, note) do defp source_update_attrs(source, note) do
attrs = %{ attrs = %{
"source_type" => source.source_type || "markdown",
"content_format" => source.content_format || "markdown",
"title" => note["title"], "title" => note["title"],
"tags" => note["tags"], "tags" => note["tags"],
"project_id" => blank_to_nil(note["project_id"]) "project_id" => blank_to_nil(note["project_id"])
} }
if source.encrypted do # Never blank out extracted/binary content when renaming a file from a
attrs # metadata-only form (no content field posted).
else cond do
Map.put(attrs, "content", note["content"]) 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
end end
@ -900,16 +953,19 @@ defmodule ElektrineWeb.KairoLive.Index do
defp parse_id(_), do: nil defp parse_id(_), do: nil
# Only note-like sources open in the markdown editor. Files, PDFs, images, # Note body editing (markdown textarea). Files stay in the reader; rename
# and URL captures always open in the reader (preview the asset like Obsidian). # uses the metadata form via renamable_source?/1 instead.
# Never treat "has extracted text" as editable — PDFs often have extracted # Never treat "has extracted text" as body-editable — PDFs often extract text.
# body text and must still open as documents, not notes.
defp editable_source?(%{encrypted: true}), do: false 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_type: type}) when type in ~w(markdown text html json), do: true
defp editable_source?(_source), do: false 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 defp apply_search(socket, query) do
query = query || "" query = query || ""
user = socket.assigns.current_user user = socket.assigns.current_user
@ -993,7 +1049,8 @@ defmodule ElektrineWeb.KairoLive.Index do
editing_source = editing_source =
Enum.find(pool, &(&1.id == socket.assigns.editing_source_id)) || 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} = {backlinks, outgoing} =
if selected do if selected do
@ -1055,20 +1112,12 @@ defmodule ElektrineWeb.KairoLive.Index do
if preformatted_content?(source) do if preformatted_content?(source) do
{:pre, content} {:pre, content}
else else
by_title = resolver = wikilink_resolver(sources)
Map.new(sources, fn s ->
{String.downcase(s.title || ""), s.id}
end)
html = html =
content content
|> Kairo.Wikilinks.to_markdown(fn title -> |> Kairo.Wikilinks.to_markdown(resolver)
case Map.get(by_title, String.downcase(title)) do |> Elektrine.Markdown.to_html(allow_images: true)
nil -> nil
id -> "/kairo?s=#{id}"
end
end)
|> Elektrine.Markdown.to_html()
{:html, html} {:html, html}
end end
@ -1076,6 +1125,61 @@ defmodule ElektrineWeb.KairoLive.Index do
defp rendered_content(_source, _sources), do: nil 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) @project_palette ~w(#6366f1 #ec4899 #14b8a6 #f59e0b #8b5cf6 #ef4444 #10b981 #3b82f6)
@inbox_color "#9ca3af" @inbox_color "#9ca3af"
@max_edges_per_source 5 @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" source.source_type == "pdf" or source_file_content_type(source) == "application/pdf"
end 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 defp format_file_size(size) when is_integer(size) and size >= 1_048_576 do
"#{Float.round(size / 1_048_576, 1)} MB" "#{Float.round(size / 1_048_576, 1)} MB"
end end
@ -1417,7 +1525,7 @@ defmodule ElektrineWeb.KairoLive.Index do
end end
defp run_palette_command(socket, "upload") do defp run_palette_command(socket, "upload") do
{:noreply, assign(socket, :dialog, :upload)} {:noreply, open_upload_dialog(socket)}
end end
defp run_palette_command(socket, "new_project") do defp run_palette_command(socket, "new_project") do
@ -1900,10 +2008,20 @@ defmodule ElektrineWeb.KairoLive.Index do
type="text" type="text"
name="note[title]" name="note[title]"
value={@compose["title"]} value={@compose["title"]}
placeholder="Untitled" placeholder={
if(@editing_source && file_source?(@editing_source),
do: "File title",
else: "Untitled"
)
}
autocomplete="off" autocomplete="off"
phx-debounce="400" 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" 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)} {save_status_label(@save_status, @save_status_at)}
</p> </p>
</div> </div>
<p class="text-xs text-base-content/45"> <p
Type <code class="text-base-content/70">[[</code> to link another note :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> </p>
<div <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"}"} id={"kairo-markdown-editor-#{@editing_source_id || "new"}"}
data-markdown-editor data-markdown-editor
class="space-y-0 overflow-hidden rounded-xl" 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"> <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"> <span class="text-xs text-base-content/45">
{if @compose["encrypt"] == "true", <%= cond do %>
do: "Encrypted notes need an explicit save", <% @compose["encrypt"] == "true" -> %>
else: "Autosaves as you type · ⌘S to save now"} 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> </span>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<.button type="button" phx-click="cancel_note" variant="ghost" size="sm"> <.button type="button" phx-click="cancel_note" variant="ghost" size="sm">
@ -2087,7 +2289,20 @@ defmodule ElektrineWeb.KairoLive.Index do
</h1> </h1>
<div class="flex shrink-0 flex-wrap items-center gap-1.5"> <div class="flex shrink-0 flex-wrap items-center gap-1.5">
<.button <.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" type="button"
phx-click="edit_source" phx-click="edit_source"
phx-value-id={@selected.id} 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 <.icon name="hero-pencil-square" class="h-3.5 w-3.5" /> Edit
</.button> </.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 <.button
type="button" type="button"
phx-click="delete_source" phx-click="delete_source"
@ -2324,6 +2549,38 @@ defmodule ElektrineWeb.KairoLive.Index do
<%= if is_nil(@selected) and not @composing do %> <%= if is_nil(@selected) and not @composing do %>
<p class="text-xs text-base-content/50">Select a note to see links.</p> <p class="text-xs text-base-content/50">Select a note to see links.</p>
<% else %> <% 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> <div>
<p class="mb-1.5 text-xs font-medium text-base-content/60"> <p class="mb-1.5 text-xs font-medium text-base-content/60">
Linked from ({length(@backlinks)}) Linked from ({length(@backlinks)})
@ -2342,7 +2599,11 @@ defmodule ElektrineWeb.KairoLive.Index do
</li> </li>
</ul> </ul>
<p :if={@backlinks == []} class="px-1 text-xs text-base-content/45"> <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> </p>
</div> </div>
@ -2378,7 +2639,15 @@ defmodule ElektrineWeb.KairoLive.Index do
</li> </li>
</ul> </ul>
<p :if={@outgoing_links == []} class="px-1 text-xs text-base-content/45"> <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> </p>
</div> </div>
@ -2522,8 +2791,14 @@ defmodule ElektrineWeb.KairoLive.Index do
<div> <div>
<label class="label py-1"><span class="label-text">Project</span></label> <label class="label py-1"><span class="label-text">Project</span></label>
<select name="upload[project_id]" class="select select-bordered w-full"> <select name="upload[project_id]" class="select select-bordered w-full">
<option value="">Inbox</option> <option value="" selected={@upload_project_id in [nil, ""]}>Inbox</option>
<option :for={project <- @projects} value={project.id}>{project.name}</option> <option
:for={project <- @projects}
value={project.id}
selected={to_string(@upload_project_id) == to_string(project.id)}
>
{project.name}
</option>
</select> </select>
</div> </div>
<div> <div>
@ -2531,6 +2806,7 @@ defmodule ElektrineWeb.KairoLive.Index do
<input <input
type="text" type="text"
name="upload[tags]" name="upload[tags]"
value={@upload_tags}
placeholder="#files, #docs" placeholder="#files, #docs"
class="input input-bordered w-full" class="input input-bordered w-full"
/> />
@ -2543,8 +2819,13 @@ defmodule ElektrineWeb.KairoLive.Index do
<div class="modal-action mt-2"> <div class="modal-action mt-2">
<button type="button" phx-click="close_dialog" class="btn btn-ghost">Cancel</button> <button type="button" phx-click="close_dialog" class="btn btn-ghost">Cancel</button>
<.button type="submit" disabled={@uploads.kairo_files.entries == []}> <.button type="submit" disabled={not kairo_upload_ready?(@uploads.kairo_files)}>
Upload {if(
@uploads.kairo_files.entries != [] and
not Enum.all?(@uploads.kairo_files.entries, & &1.done?),
do: "Uploading…",
else: "Save to Kairo"
)}
</.button> </.button>
</div> </div>
</form> </form>

View file

@ -75,7 +75,7 @@ defmodule ElektrineWeb.KairoLiveTest do
assert render(view) =~ "Linked note" assert render(view) =~ "Linked note"
end 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() user = AccountsFixtures.user_fixture()
{:ok, pdf} = {:ok, pdf} =
@ -83,7 +83,7 @@ defmodule ElektrineWeb.KairoLiveTest do
"source_type" => "pdf", "source_type" => "pdf",
"title" => "Manual.pdf", "title" => "Manual.pdf",
"status" => "stored", "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" "content" => "words extracted from the pdf body"
}) })
@ -95,11 +95,10 @@ defmodule ElektrineWeb.KairoLiveTest do
assert_patch(view, ~p"/kairo?s=#{pdf.id}") assert_patch(view, ~p"/kairo?s=#{pdf.id}")
html = render(view) 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) refute html =~ ~s(data-markdown-textarea)
assert has_element?(view, "article h1", "Manual.pdf") assert html =~ "wikilink"
# Reader chrome, not the note composer.
assert has_element?(view, "article")
end end
test "saves a link as a url source", %{conn: conn} do 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 test "uploads a file source from the explorer", %{conn: conn} do
{user, view} = mount_kairo(conn) {user, view} = mount_kairo(conn)
{:ok, project} = Kairo.create_project(user, %{"name" => "Drop target"})
upload = upload =
file_input(view, "#kairo-upload-form-root", :kairo_files, [ 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") _ = 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) sources = Kairo.list_sources(user)
assert [source] = sources assert [source] = sources
assert source.source_type == "file" assert source.source_type == "file"
assert source.content == "remember this 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}/" assert source.metadata["key"] =~ "kairo-sources/#{user.id}/"
end 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 test "manages the project lifecycle", %{conn: conn} do
{user, view} = mount_kairo(conn) {user, view} = mount_kairo(conn)

View file

@ -423,10 +423,22 @@ defmodule Kairo do
if present_value?(attrs["title"]) do if present_value?(attrs["title"]) do
attrs attrs
else 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
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) when is_binary(value), do: String.trim(value) != ""
defp present_value?(value), do: not is_nil(value) defp present_value?(value), do: not is_nil(value)
@ -693,7 +705,11 @@ defmodule Kairo do
end end
@doc """ @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) 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 if title == "" do
nil nil
else else
candidates = title_match_candidates(title)
Source Source
|> where([source], source.user_id == ^user_id) |> 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) |> order_by([source], desc: source.updated_at)
|> limit(1) |> limit(1)
|> preload(:project) |> preload(:project)
@ -716,6 +739,17 @@ defmodule Kairo do
def find_source_by_title(_user_id, _title), do: nil 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 """ @doc """
Sources that link to `source_id` (resolved backlinks), newest first. Sources that link to `source_id` (resolved backlinks), newest first.
""" """
@ -736,6 +770,8 @@ defmodule Kairo do
|> Enum.map(fn link -> |> Enum.map(fn link ->
%{link | from_source: decrypt_at_rest_content(link.from_source)} %{link | from_source: decrypt_at_rest_content(link.from_source)}
end) end)
# One row per source even when the same note used both [[t]] and ![[t.ext]].
|> Enum.uniq_by(& &1.from_source_id)
:error -> :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: 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{title: ""}), do: :ok
defp resolve_incoming_links(user_id, %Source{id: id, title: title}) do defp resolve_incoming_links(user_id, %Source{} = source) do
Link candidates = matchable_titles_for_source(source)
|> 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)]
)
: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 end
def retry_url_source(%User{id: user_id}, id), do: retry_url_source(user_id, id) 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 defmodule Kairo.Wikilinks do
@moduledoc """ @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 \[[ # Optional embed bang, then [[target]] or [[target|alias]]; not escaped \[[
@wikilink_re ~r/(?<!\\)\[\[([^\]|#]+)(?:\|([^\]]+))?\]\]/u @wikilink_re ~r/(?<!\\)(!?)\[\[([^\]|#]+)(?:\|([^\]]+))?\]\]/u
@doc """ @doc """
Returns a list of `%{target_title: binary, alias: binary | nil}` in document order. Returns a list of `%{target_title: binary, alias: binary | nil, embed?: boolean}`
Dedupes by downcased target title, keeping the first alias. 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 def extract(content) when is_binary(content) do
@wikilink_re @wikilink_re
|> Regex.scan(content, capture: :all_but_first) |> Regex.scan(content, capture: :all_but_first)
|> Enum.reduce({[], MapSet.new()}, fn |> Enum.reduce({[], %{}}, fn captures, {order, by_title} ->
[target, alias], {acc, seen} -> case parse_capture(captures) do
title = target |> String.trim() |> String.slice(0, 255) nil ->
{order, by_title}
if title == "" or MapSet.member?(seen, String.downcase(title)) do %{target_title: title} = entry ->
{acc, seen} key = String.downcase(title)
else
alias =
case blank_to_nil(alias) do
nil -> nil
value -> String.slice(value, 0, 255)
end
entry = %{target_title: title, alias: alias} case Map.get(by_title, key) do
{[entry | acc], MapSet.put(seen, String.downcase(title))} nil ->
end {[key | order], Map.put(by_title, key, entry)}
[target], {acc, seen} -> existing ->
title = target |> String.trim() |> String.slice(0, 255) merged = %{
existing
| embed?: existing.embed? or entry.embed?,
alias: existing.alias || entry.alias
}
if title == "" or MapSet.member?(seen, String.downcase(title)) do {order, Map.put(by_title, key, merged)}
{acc, seen} end
else end
entry = %{target_title: title, alias: nil} end)
{[entry | acc], MapSet.put(seen, String.downcase(title))} |> then(fn {order, by_title} ->
end order
|> Enum.reverse()
_other, acc -> |> Enum.map(&Map.fetch!(by_title, &1))
acc
end) end)
|> elem(0)
|> Enum.reverse()
end end
def extract(_content), do: [] def extract(_content), do: []
@doc """ @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. 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 def to_markdown(content, resolver) when is_binary(content) and is_function(resolver, 1) do
Regex.replace(@wikilink_re, content, fn full -> Regex.replace(@wikilink_re, content, fn full ->
case Regex.run(@wikilink_re, full, capture: :all_but_first) do case Regex.run(@wikilink_re, full, capture: :all_but_first) do
[target, alias] -> captures when is_list(captures) ->
render_link(String.trim(target), blank_to_nil(alias), resolver) case parse_capture(captures) do
%{target_title: title, alias: alias, embed?: embed?} ->
render_link(title, alias, embed?, resolver)
[target] -> nil ->
render_link(String.trim(target), nil, resolver) full
end
_other -> _other ->
full full
@ -70,31 +80,82 @@ defmodule Kairo.Wikilinks do
def to_markdown(content, _resolver), do: content || "" 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 label = alias || title
resolved = resolver.(title)
case resolver.(title) do cond do
path when is_binary(path) and path != "" -> embed? and match?(%{media: :image, embed_url: url} when is_binary(url), resolved) ->
"[#{escape_label(label)}](#{path})" 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)}**" "**#{escape_label(label)}**"
end end
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 defp escape_label(label) do
label label
|> String.replace("[", "\\[") |> String.replace("[", "\\[")
|> String.replace("]", "\\]") |> String.replace("]", "\\]")
end 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 end

View file

@ -3,14 +3,16 @@ defmodule Kairo.WikilinksTest do
alias Kairo.Wikilinks alias Kairo.Wikilinks
test "extracts titles and aliases" do test "extracts titles, aliases, and embeds" do
content = """ 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) == [ assert Wikilinks.extract(content) == [
%{target_title: "Alpha", alias: nil}, %{target_title: "Alpha", alias: nil, embed?: false},
%{target_title: "Beta", alias: "β"} %{target_title: "Beta", alias: "β", embed?: false},
%{target_title: "photo", alias: "shot", embed?: true}
] ]
end end
@ -26,4 +28,34 @@ defmodule Kairo.WikilinksTest do
assert html_md =~ "[Alpha](/kairo?s=1)" assert html_md =~ "[Alpha](/kairo?s=1)"
assert html_md =~ "**Missing**" assert html_md =~ "**Missing**"
end 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 end

View file

@ -682,6 +682,40 @@ defmodule KairoTest do
assert hd(backlinks).from_source_id == source.id assert hd(backlinks).from_source_id == source.id
end 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 test "ensure_daily_note/1 is idempotent" do
user = user_fixture() user = user_fixture()
assert {:ok, first} = Kairo.ensure_daily_note(user) assert {:ok, first} = Kairo.ensure_daily_note(user)