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 """
|
@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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 =~ ""
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue