From a62722fe17e7050bbebc80b740695ea1b04af154 Mon Sep 17 00:00:00 2001 From: Inhji Date: Sun, 9 Apr 2023 16:14:55 +0200 Subject: [PATCH] add note references --- lib/chiya/notes.ex | 70 ++++++++- lib/chiya/notes/note.ex | 8 + lib/chiya/notes/note_note.ex | 19 +++ lib/chiya/notes/references.ex | 147 ++++++++++++++++++ lib/chiya_web/live/note_show_live.ex | 18 +-- .../20230409120133_add_notes_notes_table.exs | 12 ++ 6 files changed, 260 insertions(+), 14 deletions(-) create mode 100644 lib/chiya/notes/note_note.ex create mode 100644 lib/chiya/notes/references.ex create mode 100644 priv/repo/migrations/20230409120133_add_notes_notes_table.exs diff --git a/lib/chiya/notes.ex b/lib/chiya/notes.ex index 8b1c5e0..bcd3b6e 100644 --- a/lib/chiya/notes.ex +++ b/lib/chiya/notes.ex @@ -5,10 +5,9 @@ defmodule Chiya.Notes do import Ecto.Query, warn: false alias Chiya.Repo + alias Chiya.Notes.{Note, NoteImage, NoteNote, References} - alias Chiya.Notes.{Note, NoteImage} - - @preloads [:channels, :images] + @preloads [:channels, :images, :links_from, :links_to] @doc """ Returns the list of notes. @@ -61,6 +60,22 @@ defmodule Chiya.Notes do """ def get_note!(id), do: Repo.get!(Note, id) + @doc """ + Gets a single note. + + Returns nil if the Note does not exist. + + ## Examples + + iex> get_note!(123) + %Note{} + + iex> get_note!(456) + nil + + """ + def get_note(id), do: Repo.get(Note, id) + @doc """ Gets a single note and preloads it. @@ -77,8 +92,38 @@ defmodule Chiya.Notes do """ def get_note_preloaded!(id), do: Repo.get!(Note, id) |> preload_note() + @doc """ + Gets a single note by its slug and preloads it. + + Raises `Ecto.NoResultsError` if the Note does not exist. + + ## Examples + + iex> get_note_preloaded!(123) + %Note{} + + iex> get_note_preloaded!(456) + ** (Ecto.NoResultsError) + + """ def get_note_by_slug_preloaded!(slug), do: Repo.get_by!(Note, slug: slug) |> preload_note() + @doc """ + Gets a single note by its slug and preloads it. + + Returns nil if the Note does not exist. + + ## Examples + + iex> get_note_preloaded!(123) + %Note{} + + iex> get_note_preloaded!(456) + nil + + """ + def get_note_by_slug_preloaded(slug), do: Repo.get_by(Note, slug: slug) |> preload_note() + @doc """ Creates a note. @@ -113,6 +158,7 @@ defmodule Chiya.Notes do note |> Note.changeset(attrs) |> Repo.update() + |> Chiya.Notes.References.update_references(attrs) end @doc """ @@ -178,6 +224,24 @@ defmodule Chiya.Notes do :ok end + def get_note_note!(attrs \\ %{}) do + Repo.get_by!(NoteNote, attrs) + end + + def get_note_note(attrs \\ %{}) do + Repo.get_by(NoteNote, attrs) + end + + def create_note_note(attrs \\ %{}) do + %NoteNote{} + |> NoteNote.changeset(attrs) + |> Repo.insert() + end + + def delete_note_note(%NoteNote{} = note_note) do + Repo.delete(note_note) + end + @doc """ Returns an `%Ecto.Changeset{}` for tracking note_image changes. """ diff --git a/lib/chiya/notes/note.ex b/lib/chiya/notes/note.ex index dc85047..b5f9dbe 100644 --- a/lib/chiya/notes/note.ex +++ b/lib/chiya/notes/note.ex @@ -19,6 +19,14 @@ defmodule Chiya.Notes.Note do join_keys: [note: :id, channel: :id], on_replace: :delete + many_to_many :links_from, Chiya.Notes.Note, + join_through: Chiya.Notes.NoteNote, + join_keys: [target_id: :id, source_id: :id] + + many_to_many :links_to, Chiya.Notes.Note, + join_through: Chiya.Notes.NoteNote, + join_keys: [source_id: :id, target_id: :id] + has_many :images, Chiya.Notes.NoteImage timestamps() diff --git a/lib/chiya/notes/note_note.ex b/lib/chiya/notes/note_note.ex new file mode 100644 index 0000000..a2ae964 --- /dev/null +++ b/lib/chiya/notes/note_note.ex @@ -0,0 +1,19 @@ +defmodule Chiya.Notes.NoteNote do + @moduledoc """ + The NoteNote module + """ + use Ecto.Schema + import Ecto.Changeset + + schema "notes_notes" do + belongs_to :source, Chiya.Notes.Note + belongs_to :target, Chiya.Notes.Note + end + + @doc false + def changeset(note_note, attrs) do + note_note + |> cast(attrs, [:source_id, :target_id]) + |> validate_required([:source_id, :target_id]) + end +end diff --git a/lib/chiya/notes/references.ex b/lib/chiya/notes/references.ex new file mode 100644 index 0000000..2a24e37 --- /dev/null +++ b/lib/chiya/notes/references.ex @@ -0,0 +1,147 @@ +defmodule Chiya.Notes.References do + @moduledoc """ + Finds and replaces references to entities in a string. + + A reference is an internal link like the following examples: + + * `[[sustainablity]]` -> A note named *Sustainability* + * `[[tag:video-games]]` -> A tag named *Video Games* + * `[[list:blog]]` -> A list named *Blog* + * `[[a-long-unfitting-title|A simple title]]` -> A note named *A long unfitting title* + """ + + require Logger + + use Phoenix.VerifiedRoutes, + endpoint: ChiyaWeb.Endpoint, + router: ChiyaWeb.Router, + statics: ChiyaWeb.static_paths() + + @reference_regex ~r/\[\[(?[\d\w-]+)(?:\|(?[\w\d\s'`äöüß]+))?\]\]/ + + @doc """ + Returns a list of references in `string`. + """ + def get_references(nil), do: [] + def get_references(""), do: [] + + def get_references(string) do + @reference_regex + |> Regex.scan(string, capture: :all) + |> Enum.map(&map_to_tuple/1) + end + + @doc """ + Checks each reference returned from `get_references/1` and validates its existence. + """ + def validate_references(references) do + Enum.map(references, fn {placeholder, type, slug, custom_title} -> + {note, valid} = + case Chiya.Notes.get_note_by_slug_preloaded(slug) do + nil -> {nil, false} + note -> {note, true} + end + + # If a custom title was defined, use it, + # otherwise use the notes title + title = + if(slug == custom_title, + do: note.name, + else: custom_title + ) + + {placeholder, type, slug, title, valid} + end) + end + + @doc """ + Returns a list of slugs that are referenced in `string`, optionally filtering by `filter_type`. + """ + def get_reference_ids(string) do + string + |> get_references() + |> Enum.map(fn {_, note_slug, _} = _ref -> note_slug end) + end + + @doc """ + Finds and replaces references with the matching url in `string`. + """ + def replace_references(string) do + string + |> get_references() + |> validate_references() + |> Enum.reduce(string, fn {placeholder, slug, title, valid}, s -> + String.replace(s, placeholder, get_link(slug, title, valid)) + end) + end + + + def update_references({:ok, note}, attrs) do + new_reference_slugs = get_reference_ids(attrs["content"]) + old_reference_slugs = Enum.map(note.links_from, fn n -> n.slug end) + references_to_add = new_reference_slugs -- old_reference_slugs + references_to_remove = old_reference_slugs -- new_reference_slugs + + add_note_links(note, references_to_add) + remove_note_links(note, references_to_remove) + + {:ok, note} + end + + def update_references(error, _attrs), do: error + + defp add_note_links(origin_note, slugs) do + Enum.each(slugs, fn slug -> + case linked_note = Chiya.Notes.get_note_by_slug_preloaded(slug) do + nil -> + Logger.warn("Reference to '#{slug}' could not be resolved") + + _note -> + add_note_link(slug, origin_note, linked_note) + end + end) + end + + defp add_note_link(slug, origin_note, linked_note) do + with attrs <- get_attrs(origin_note.id, linked_note.id), + {:ok, _note_note} <- Chiya.Notes.create_note_note(attrs) do + Logger.info("Reference to '#{slug}' created") + else + error -> + Logger.warn(error) + end + end + + defp remove_note_links(origin_note, slugs) do + Enum.each(slugs, fn slug -> + linked_note = Chiya.Notes.get_note_by_slug_preloaded!(slug) + attrs = get_attrs(origin_note.id, linked_note.id) + note_note = Chiya.Notes.get_note_note(attrs) + + case Chiya.Notes.delete_note_note(note_note) do + {:ok, _note_note} -> + Logger.info("Reference to '#{slug}' deleted") + + error -> + Logger.warn(error) + end + end) + end + + defp get_attrs(origin_id, linked_id), + do: %{ + source_id: origin_id, + target_id: linked_id + } + + defp get_link(slug, title, valid), do: "[#{title}](#{~p"/#{slug}"})#{get_link_class(valid)}" + + defp get_link_class(false), do: "{:.invalid}" + defp get_link_class(_), do: "" + + defp map_to_tuple([placeholder, note_slug]), + do: {placeholder, note_slug, note_slug} + + defp map_to_tuple([placeholder, note_slug, title]), + do: {placeholder, note_slug, title} +end diff --git a/lib/chiya_web/live/note_show_live.ex b/lib/chiya_web/live/note_show_live.ex index 16979ad..39dcb6e 100644 --- a/lib/chiya_web/live/note_show_live.ex +++ b/lib/chiya_web/live/note_show_live.ex @@ -15,7 +15,7 @@ defmodule ChiyaWeb.NoteShowLive do <:subtitle><%= @note.slug %></:subtitle> <:actions> <.link href={~p"/admin/notes/#{@note}/edit"}> - <.button>Edit note</.button> + <.button>Edit</.button> </.link> <.link href={~p"/#{@note.slug}"}> <.button>Preview</.button> @@ -31,18 +31,10 @@ defmodule ChiyaWeb.NoteShowLive do <:item title="Channels"><%= @channels %></:item> <:item title="Kind"><%= @note.kind %></:item> <:item title="Url"><%= @note.url %></:item> + <:item title="Links outgoing"><%= note_links(@note.links_to) %></:item> + <:item title="Links incoming"><%= note_links(@note.links_from) %></:item> </.list> - <.line /> - - <details> - <summary class="text-gray-900 dark:text-gray-100">File Content</summary> - <section class="prose"> - <%= raw ChiyaWeb.Markdown.render(@note.content) %> - </section> - </details> - - <.line /> <%= if !Enum.empty?(@note.images) do %> @@ -158,4 +150,8 @@ defmodule ChiyaWeb.NoteShowLive do {:noreply, assign(socket, :note, Notes.get_note_preloaded!(socket.assigns.note.id))} end + + defp note_links(notes) do + Enum.map_join(notes, ", ",fn n -> n.name end) + end end diff --git a/priv/repo/migrations/20230409120133_add_notes_notes_table.exs b/priv/repo/migrations/20230409120133_add_notes_notes_table.exs new file mode 100644 index 0000000..a08e659 --- /dev/null +++ b/priv/repo/migrations/20230409120133_add_notes_notes_table.exs @@ -0,0 +1,12 @@ +defmodule Chiya.Repo.Migrations.AddNotesNotesTable do + use Ecto.Migration + + def change do + create table(:notes_notes) do + add :source_id, references(:notes, on_delete: :delete_all) + add :target_id, references(:notes, on_delete: :delete_all) + end + + create unique_index(:notes_notes, [:source_id, :target_id]) + end +end