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
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:
parent
025ced5129
commit
1bd9defee6
7 changed files with 678 additions and 155 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 =~ ""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue