diff --git a/lib/chiya/notes.ex b/lib/chiya/notes.ex index bcd3b6e..124ed4c 100644 --- a/lib/chiya/notes.ex +++ b/lib/chiya/notes.ex @@ -5,9 +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, NoteNote, NoteTag} - @preloads [:channels, :images, :links_from, :links_to] + @preloads [:channels, :images, :links_from, :links_to, :tags] @doc """ Returns the list of notes. @@ -158,6 +158,7 @@ defmodule Chiya.Notes do note |> Note.changeset(attrs) |> Repo.update() + |> Chiya.Tags.TagUpdater.update_tags(attrs) |> Chiya.Notes.References.update_references(attrs) end @@ -248,4 +249,18 @@ defmodule Chiya.Notes do def change_note_image(%NoteImage{} = note_image, attrs \\ %{}) do NoteImage.update_changeset(note_image, attrs) end + + def get_note_tag!(attrs \\ %{}) do + Repo.get_by!(NoteTag, attrs) + end + + def create_note_tag(attrs \\ %{}) do + %NoteTag{} + |> NoteTag.changeset(attrs) + |> Repo.insert() + end + + def delete_note_tag(%NoteTag{} = note_tag) do + Repo.delete(note_tag) + end end diff --git a/lib/chiya/notes/note.ex b/lib/chiya/notes/note.ex index b29e149..6ce8c28 100644 --- a/lib/chiya/notes/note.ex +++ b/lib/chiya/notes/note.ex @@ -31,8 +31,16 @@ defmodule Chiya.Notes.Note do join_through: Chiya.Notes.NoteNote, join_keys: [source_id: :id, target_id: :id] + many_to_many :tags, Chiya.Tags.Tag, + join_through: Chiya.Notes.NoteTag, + join_keys: [note_id: :id, tag_id: :id] + has_many :images, Chiya.Notes.NoteImage + field :tags_string, :string, + virtual: true, + default: "" + timestamps() end diff --git a/lib/chiya/notes/note_tag.ex b/lib/chiya/notes/note_tag.ex new file mode 100644 index 0000000..fe27e3e --- /dev/null +++ b/lib/chiya/notes/note_tag.ex @@ -0,0 +1,19 @@ +defmodule Chiya.Notes.NoteTag do + @moduledoc """ + The NoteTag module + """ + use Ecto.Schema + import Ecto.Changeset + + schema "notes_tags" do + belongs_to :note, Chiya.Notes.Note + belongs_to :tag, Chiya.Tags.Tag + end + + @doc false + def changeset(note_tag, attrs) do + note_tag + |> cast(attrs, [:note_id, :tag_id]) + |> validate_required([:note_id, :tag_id]) + end +end diff --git a/lib/chiya/notes/references.ex b/lib/chiya/notes/references.ex index d50d152..b0b686b 100644 --- a/lib/chiya/notes/references.ex +++ b/lib/chiya/notes/references.ex @@ -117,13 +117,18 @@ defmodule Chiya.Notes.References do 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") + if note_note do + case Chiya.Notes.delete_note_note(note_note) do + {:ok, _note_note} -> + Logger.info("Reference to '#{slug}' deleted") - error -> - Logger.warn(error) + error -> + Logger.warn(error) + end + else + Logger.debug("Note '#{slug}' does not exist anymore.") end + end) end diff --git a/lib/chiya/tags.ex b/lib/chiya/tags.ex new file mode 100644 index 0000000..1e69b28 --- /dev/null +++ b/lib/chiya/tags.ex @@ -0,0 +1,136 @@ +defmodule Chiya.Tags do + @moduledoc """ + The Tags context. + """ + + import Ecto.Query, warn: false + alias Chiya.Repo + + alias Chiya.Tags.Tag + + @preloads [:notes] + defp with_preloads(query), do: preload(query, ^@preloads) + + @doc """ + Preloads a tag with all defined preloads. + + ## Examples + + iex> preload_tag(tag) + %Tag{} + + """ + def preload_tag(tag), do: Repo.preload(tag, @preloads) + + @doc """ + Returns the list of tags. + + ## Examples + + iex> list_tags() + [%Tag{}, ...] + + """ + def list_tags do + Tag + |> with_preloads() + |> order_by(:title) + |> Repo.all() + end + + @doc """ + Gets a single tag. + + Raises `Ecto.NoResultsError` if the Tag does not exist. + + ## Examples + + iex> get_tag!(123) + %Tag{} + + iex> get_tag!(456) + ** (Ecto.NoResultsError) + + """ + def get_tag_by_slug!(slug), do: Repo.get_by!(Tag, slug: slug) |> preload_tag() + + @doc """ + Gets a single tag. + + Returns nil if the Tag does not exist. + + ## Examples + + iex> get_tag(123) + %Tag{} + + iex> get_tag(456) + nil + """ + def get_tag_by_slug(slug), do: Repo.get_by(Tag, slug: slug) |> preload_tag() + + @doc """ + Creates a tag. + + ## Examples + + iex> create_tag(%{field: value}) + {:ok, %Tag{}} + + iex> create_tag(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_tag(attrs \\ %{}) do + %Tag{} + |> Tag.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a tag. + + ## Examples + + iex> update_tag(tag, %{field: new_value}) + {:ok, %Tag{}} + + iex> update_tag(tag, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_tag(%Tag{} = tag, attrs) do + tag + |> Tag.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a tag. + + ## Examples + + iex> delete_tag(tag) + {:ok, %Tag{}} + + iex> delete_tag(tag) + {:error, %Ecto.Changeset{}} + + """ + def delete_tag(%Tag{} = tag) do + Repo.delete(tag) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking tag changes. + + ## Examples + + iex> change_tag(tag) + %Ecto.Changeset{data: %Tag{}} + + """ + def change_tag(%Tag{} = tag, attrs \\ %{}) do + Tag.changeset(tag, attrs) + end +end diff --git a/lib/chiya/tags/tag.ex b/lib/chiya/tags/tag.ex new file mode 100644 index 0000000..90952a8 --- /dev/null +++ b/lib/chiya/tags/tag.ex @@ -0,0 +1,32 @@ +defmodule Chiya.Tags.Tag do + @moduledoc """ + The Tag Schema + """ + use Ecto.Schema + import Ecto.Changeset + alias Chiya.Tags.TagSlug + + schema "tags" do + field :name, :string + field :slug, TagSlug.Type + + field :content, :string + + field :icon, :string + field :regex, :string + + many_to_many :notes, Chiya.Notes.Note, join_through: "notes_tags" + + timestamps() + end + + @doc false + def changeset(tag, attrs) do + tag + |> cast(attrs, [:name, :content, :icon, :regex]) + |> validate_required([:name]) + |> unique_constraint(:name) + |> TagSlug.maybe_generate_slug() + |> TagSlug.unique_constraint() + end +end diff --git a/lib/chiya/tags/tag_slug.ex b/lib/chiya/tags/tag_slug.ex new file mode 100644 index 0000000..36b49bf --- /dev/null +++ b/lib/chiya/tags/tag_slug.ex @@ -0,0 +1,3 @@ +defmodule Chiya.Tags.TagSlug do + use EctoAutoslugField.Slug, from: :name, to: :slug +end diff --git a/lib/chiya/tags/tag_updater.ex b/lib/chiya/tags/tag_updater.ex new file mode 100644 index 0000000..6c6d21c --- /dev/null +++ b/lib/chiya/tags/tag_updater.ex @@ -0,0 +1,112 @@ +defmodule Chiya.Tags.TagUpdater do + @moduledoc """ + Updates tags for a schema like notes by adding new ones and removing old ones. + """ + + require Logger + + alias Chiya.{Notes, Tags} + alias Chiya.Notes.Note + + def update_tags({:ok, %Note{} = note}, attrs) do + update_tags(note, attrs) + + {:ok, note} + end + + @doc """ + Updates the tags for the given schema + + ## Examples + + iex> update_tags(note, "foo,bar") + + """ + def update_tags(schema, %{tags_string: new_tags} = attrs) when is_map(attrs) do + update_tags(schema, new_tags) + end + + def update_tags(schema, %{"tags_string" => new_tags} = attrs) when is_map(attrs) do + update_tags(schema, new_tags) + end + + def update_tags(schema, attrs) when is_map(attrs) do + schema + end + + def update_tags(schema, new_tags) when is_binary(new_tags) do + update_tags(schema, split_tags(new_tags)) + end + + def update_tags(%{tags: tags} = schema, new_tags) when is_list(new_tags) do + old_tags = Enum.map(tags, fn tag -> tag.name end) + + Logger.info("Adding tags #{inspect(new_tags -- old_tags)}") + Logger.info("Removing tags #{inspect(old_tags -- new_tags)}") + + schema + |> add_tags(new_tags -- old_tags) + |> remove_tags(old_tags -- new_tags) + end + + defp split_tags(tags_string) when is_binary(tags_string) do + tags_string + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.filter(&(String.length(&1) > 0)) + end + + defp add_tags(schema, tags) do + Enum.each(tags, &add_tag(schema, &1)) + schema + end + + defp add_tag(%{id: schema_id} = schema, tag) when is_binary(tag) do + slug = Slugger.slugify_downcase(tag) + + Logger.debug("Looking up tag [#{tag}] with slug [#{slug}]") + + {:ok, tag} = + case Tags.get_tag(slug) do + nil -> + Logger.debug("Tag [#{tag}] does not exist. Creating.") + Tags.create_tag(%{name: tag}) + + tag -> + Logger.debug("Tag already exists. Returning.") + {:ok, tag} + end + + case schema do + %Note{} -> + attrs = %{ + note_id: schema_id, + tag_id: tag.id + } + + {:ok, _note_tag} = Notes.create_note_tag(attrs) + end + end + + defp remove_tags(schema, tags) do + Enum.each(tags, &remove_tag(schema, &1)) + schema + end + + defp remove_tag(schema, tag) do + slug = Slugger.slugify_downcase(tag) + + if tag = Tags.get_tag(slug) do + case schema do + %Note{} -> + attrs = %{tag_id: tag.id, note_id: schema.id} + note_tag = Notes.get_note_tag!(attrs) + + {:ok, _} = Notes.delete_note_tag(note_tag) + end + else + Logger.warn("Tag with slug #{slug} was not removed.") + nil + end + end +end diff --git a/lib/chiya_web/controllers/note_controller.ex b/lib/chiya_web/controllers/note_controller.ex index 0d4a958..b00e9db 100644 --- a/lib/chiya_web/controllers/note_controller.ex +++ b/lib/chiya_web/controllers/note_controller.ex @@ -24,7 +24,12 @@ defmodule ChiyaWeb.NoteController do def new(conn, _params) do changeset = Notes.change_note(%Note{}) - render(conn, :new, changeset: changeset, channels: to_channel_options()) + + render(conn, :new, + changeset: changeset, + channels: to_channel_options(), + tags: [] + ) end def create(conn, %{"note" => note_params}) do @@ -50,7 +55,13 @@ defmodule ChiyaWeb.NoteController do def edit(conn, %{"id" => id}) do note = Notes.get_note_preloaded!(id) changeset = Notes.change_note(note) - render(conn, :edit, note: note, changeset: changeset, channels: to_channel_options()) + + render(conn, :edit, + note: note, + changeset: changeset, + channels: to_channel_options(), + tags: note.tags + ) end def update(conn, %{"id" => id, "note" => note_params}) do @@ -64,7 +75,11 @@ defmodule ChiyaWeb.NoteController do |> redirect(to: ~p"/admin/notes/#{note}") {:error, %Ecto.Changeset{} = changeset} -> - render(conn, :edit, note: note, changeset: changeset, channels: to_channel_options()) + render(conn, :edit, + note: note, + changeset: changeset, + channels: to_channel_options(), + tags: note.tags) end end diff --git a/lib/chiya_web/controllers/note_html.ex b/lib/chiya_web/controllers/note_html.ex index 5456387..92c37de 100644 --- a/lib/chiya_web/controllers/note_html.ex +++ b/lib/chiya_web/controllers/note_html.ex @@ -9,8 +9,13 @@ defmodule ChiyaWeb.NoteHTML do attr :changeset, Ecto.Changeset, required: true attr :action, :string, required: true attr :channels, :list, required: true + attr :tags, :list, required: true def note_form(assigns) - def selected_channels(changeset), do: Enum.map(changeset.data.channels, fn c -> c.id end) + def selected_channels(changeset), do: + Enum.map(changeset.data.channels, fn c -> c.id end) + + def tags_to_string(tags), do: + Enum.map_join(tags, ", ", fn t -> t.name end) end diff --git a/lib/chiya_web/controllers/note_html/edit.html.heex b/lib/chiya_web/controllers/note_html/edit.html.heex index 8e24aef..1c80b92 100644 --- a/lib/chiya_web/controllers/note_html/edit.html.heex +++ b/lib/chiya_web/controllers/note_html/edit.html.heex @@ -3,6 +3,6 @@ <:subtitle>Use this form to manage note records in your database. -<.note_form changeset={@changeset} action={~p"/admin/notes/#{@note}"} channels={@channels} /> +<.note_form changeset={@changeset} action={~p"/admin/notes/#{@note}"} channels={@channels} tags={@tags} /> <.back navigate={~p"/admin/notes"}>Back to notes diff --git a/lib/chiya_web/controllers/note_html/new.html.heex b/lib/chiya_web/controllers/note_html/new.html.heex index 957fd12..f916670 100644 --- a/lib/chiya_web/controllers/note_html/new.html.heex +++ b/lib/chiya_web/controllers/note_html/new.html.heex @@ -3,6 +3,6 @@ <:subtitle>Use this form to manage note records in your database. -<.note_form changeset={@changeset} action={~p"/admin/notes"} channels={@channels} /> +<.note_form changeset={@changeset} action={~p"/admin/notes"} channels={@channels} tags={@tags} /> <.back navigate={~p"/admin/notes"}>Back to notes diff --git a/lib/chiya_web/controllers/note_html/note_form.html.heex b/lib/chiya_web/controllers/note_html/note_form.html.heex index cf04a0c..13ab183 100644 --- a/lib/chiya_web/controllers/note_html/note_form.html.heex +++ b/lib/chiya_web/controllers/note_html/note_form.html.heex @@ -14,6 +14,7 @@ options={Ecto.Enum.values(Chiya.Notes.Note, :kind)} /> <.input field={f[:url]} type="text" label="Url" /> + <.input field={f[:tags_string]} type="text" label="Tags" value={tags_to_string(@tags)} /> <.input field={f[:channels]} type="select" @@ -22,7 +23,9 @@ options={@channels} value={selected_channels(@changeset)} /> + <:actions> <.button>Save Note + diff --git a/lib/chiya_web/controllers/page_controller.ex b/lib/chiya_web/controllers/page_controller.ex index 2abf172..7cb30fa 100644 --- a/lib/chiya_web/controllers/page_controller.ex +++ b/lib/chiya_web/controllers/page_controller.ex @@ -29,6 +29,16 @@ defmodule ChiyaWeb.PageController do ) end + def tag(conn, %{"slug" => tag_slug}) do + tag = Chiya.Tags.get_tag_by_slug!(tag_slug) + + render(conn, :tag, + layout: {ChiyaWeb.Layouts, "public.html"}, + tag: tag, + page_title: tag.name + ) + end + def note(conn, %{"slug" => note_slug}) do note = Chiya.Notes.get_note_by_slug_preloaded!(note_slug) diff --git a/lib/chiya_web/controllers/page_html.ex b/lib/chiya_web/controllers/page_html.ex index a43f0ea..daf0c91 100644 --- a/lib/chiya_web/controllers/page_html.ex +++ b/lib/chiya_web/controllers/page_html.ex @@ -2,4 +2,7 @@ defmodule ChiyaWeb.PageHTML do use ChiyaWeb, :html_public embed_templates "page_html/*" + + def tag_list([]), do: "No Tags" + def tag_list(tags), do: Enum.map_join(tags, ", ", fn t -> t.name end) end diff --git a/lib/chiya_web/controllers/page_html/note.html.heex b/lib/chiya_web/controllers/page_html/note.html.heex index deef784..024b35a 100644 --- a/lib/chiya_web/controllers/page_html/note.html.heex +++ b/lib/chiya_web/controllers/page_html/note.html.heex @@ -9,6 +9,13 @@ · Last Updated + · + Tags + + <%= for tag <- @note.tags do %> + <%= tag.name %> + <% end %> + <%= if @current_user do %> · Edit diff --git a/lib/chiya_web/controllers/page_html/tag.html.heex b/lib/chiya_web/controllers/page_html/tag.html.heex new file mode 100644 index 0000000..3ddfd15 --- /dev/null +++ b/lib/chiya_web/controllers/page_html/tag.html.heex @@ -0,0 +1,20 @@ +
+

+ Tagged with “<%= @tag.name %>” +

+

+ <%= @tag.content %> +

+ +
+ <%= for note <- @tag.notes do %> + + <%= note.name %> + <%= pretty_date(note.published_at) %> + + <% end %> +
+
diff --git a/lib/chiya_web/live/note_show_live.ex b/lib/chiya_web/live/note_show_live.ex index 0faac86..c12688b 100644 --- a/lib/chiya_web/live/note_show_live.ex +++ b/lib/chiya_web/live/note_show_live.ex @@ -35,6 +35,7 @@ defmodule ChiyaWeb.NoteShowLive do <:item title="Channels"><%= @channels %> <:item title="Kind"><%= @note.kind %> <:item title="Url"><%= @note.url %> + <:item title="Tags"><%= note_tags(@note.tags) %> <:item title="Links outgoing"><%= note_links(@note.links_to) %> <:item title="Links incoming"><%= note_links(@note.links_from) %> @@ -151,7 +152,6 @@ 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 + defp note_links(notes), do: Enum.map_join(notes, ", ", fn n -> n.name end) + defp note_tags(tags), do: Enum.map_join(tags, ", ", fn t -> t.name end) end diff --git a/lib/chiya_web/router.ex b/lib/chiya_web/router.ex index 33a9cba..c8ae3c5 100644 --- a/lib/chiya_web/router.ex +++ b/lib/chiya_web/router.ex @@ -117,6 +117,7 @@ defmodule ChiyaWeb.Router do get "/:slug", PageController, :note get "/c/:slug", PageController, :channel + get "/t/:slug", PageController, :tag get "/", PageController, :home end end diff --git a/priv/repo/migrations/20230410153445_add_tags.exs b/priv/repo/migrations/20230410153445_add_tags.exs new file mode 100644 index 0000000..17736b5 --- /dev/null +++ b/priv/repo/migrations/20230410153445_add_tags.exs @@ -0,0 +1,24 @@ +defmodule Chiya.Repo.Migrations.AddTags do + use Ecto.Migration + + def change do + create table(:tags) do + add :name, :string + add :slug, :string + add :content, :text + add :icon, :string + add :regex, :string + + timestamps() + end + + create table(:notes_tags) do + add :note_id, references(:notes, on_delete: :delete_all) + add :tag_id, references(:tags, on_delete: :delete_all) + end + + create unique_index(:tags, [:slug]) + create unique_index(:tags, [:name]) + create unique_index(:notes_tags, [:note_id, :tag_id]) + end +end