add tags \o/

This commit is contained in:
Inhji 2023-04-10 19:18:27 +02:00
parent 03256e4f9d
commit ebacb7919c
20 changed files with 434 additions and 16 deletions

View file

@ -5,9 +5,9 @@ defmodule Chiya.Notes do
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Chiya.Repo 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 """ @doc """
Returns the list of notes. Returns the list of notes.
@ -158,6 +158,7 @@ defmodule Chiya.Notes do
note note
|> Note.changeset(attrs) |> Note.changeset(attrs)
|> Repo.update() |> Repo.update()
|> Chiya.Tags.TagUpdater.update_tags(attrs)
|> Chiya.Notes.References.update_references(attrs) |> Chiya.Notes.References.update_references(attrs)
end end
@ -248,4 +249,18 @@ defmodule Chiya.Notes do
def change_note_image(%NoteImage{} = note_image, attrs \\ %{}) do def change_note_image(%NoteImage{} = note_image, attrs \\ %{}) do
NoteImage.update_changeset(note_image, attrs) NoteImage.update_changeset(note_image, attrs)
end 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 end

View file

@ -31,8 +31,16 @@ defmodule Chiya.Notes.Note do
join_through: Chiya.Notes.NoteNote, join_through: Chiya.Notes.NoteNote,
join_keys: [source_id: :id, target_id: :id] 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 has_many :images, Chiya.Notes.NoteImage
field :tags_string, :string,
virtual: true,
default: ""
timestamps() timestamps()
end end

View file

@ -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

View file

@ -117,13 +117,18 @@ defmodule Chiya.Notes.References do
attrs = get_attrs(origin_note.id, linked_note.id) attrs = get_attrs(origin_note.id, linked_note.id)
note_note = Chiya.Notes.get_note_note(attrs) note_note = Chiya.Notes.get_note_note(attrs)
case Chiya.Notes.delete_note_note(note_note) do if note_note do
{:ok, _note_note} -> case Chiya.Notes.delete_note_note(note_note) do
Logger.info("Reference to '#{slug}' deleted") {:ok, _note_note} ->
Logger.info("Reference to '#{slug}' deleted")
error -> error ->
Logger.warn(error) Logger.warn(error)
end
else
Logger.debug("Note '#{slug}' does not exist anymore.")
end end
end) end)
end end

136
lib/chiya/tags.ex Normal file
View file

@ -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

32
lib/chiya/tags/tag.ex Normal file
View file

@ -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

View file

@ -0,0 +1,3 @@
defmodule Chiya.Tags.TagSlug do
use EctoAutoslugField.Slug, from: :name, to: :slug
end

View file

@ -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

View file

@ -24,7 +24,12 @@ defmodule ChiyaWeb.NoteController do
def new(conn, _params) do def new(conn, _params) do
changeset = Notes.change_note(%Note{}) 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 end
def create(conn, %{"note" => note_params}) do def create(conn, %{"note" => note_params}) do
@ -50,7 +55,13 @@ defmodule ChiyaWeb.NoteController do
def edit(conn, %{"id" => id}) do def edit(conn, %{"id" => id}) do
note = Notes.get_note_preloaded!(id) note = Notes.get_note_preloaded!(id)
changeset = Notes.change_note(note) 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 end
def update(conn, %{"id" => id, "note" => note_params}) do def update(conn, %{"id" => id, "note" => note_params}) do
@ -64,7 +75,11 @@ defmodule ChiyaWeb.NoteController do
|> redirect(to: ~p"/admin/notes/#{note}") |> redirect(to: ~p"/admin/notes/#{note}")
{:error, %Ecto.Changeset{} = changeset} -> {: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
end end

View file

@ -9,8 +9,13 @@ defmodule ChiyaWeb.NoteHTML do
attr :changeset, Ecto.Changeset, required: true attr :changeset, Ecto.Changeset, required: true
attr :action, :string, required: true attr :action, :string, required: true
attr :channels, :list, required: true attr :channels, :list, required: true
attr :tags, :list, required: true
def note_form(assigns) 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 end

View file

@ -3,6 +3,6 @@
<:subtitle>Use this form to manage note records in your database.</:subtitle> <:subtitle>Use this form to manage note records in your database.</:subtitle>
</.header> </.header>
<.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</.back> <.back navigate={~p"/admin/notes"}>Back to notes</.back>

View file

@ -3,6 +3,6 @@
<:subtitle>Use this form to manage note records in your database.</:subtitle> <:subtitle>Use this form to manage note records in your database.</:subtitle>
</.header> </.header>
<.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</.back> <.back navigate={~p"/admin/notes"}>Back to notes</.back>

View file

@ -14,6 +14,7 @@
options={Ecto.Enum.values(Chiya.Notes.Note, :kind)} options={Ecto.Enum.values(Chiya.Notes.Note, :kind)}
/> />
<.input field={f[:url]} type="text" label="Url" /> <.input field={f[:url]} type="text" label="Url" />
<.input field={f[:tags_string]} type="text" label="Tags" value={tags_to_string(@tags)} />
<.input <.input
field={f[:channels]} field={f[:channels]}
type="select" type="select"
@ -22,7 +23,9 @@
options={@channels} options={@channels}
value={selected_channels(@changeset)} value={selected_channels(@changeset)}
/> />
<:actions> <:actions>
<.button>Save Note</.button> <.button>Save Note</.button>
</:actions> </:actions>
</.simple_form> </.simple_form>

View file

@ -29,6 +29,16 @@ defmodule ChiyaWeb.PageController do
) )
end 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 def note(conn, %{"slug" => note_slug}) do
note = Chiya.Notes.get_note_by_slug_preloaded!(note_slug) note = Chiya.Notes.get_note_by_slug_preloaded!(note_slug)

View file

@ -2,4 +2,7 @@ defmodule ChiyaWeb.PageHTML do
use ChiyaWeb, :html_public use ChiyaWeb, :html_public
embed_templates "page_html/*" 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 end

View file

@ -9,6 +9,13 @@
<span>·</span> <span>·</span>
<span>Last Updated</span> <span>Last Updated</span>
<time class="text-theme-primary font-semibold"><%= pretty_date(@note.updated_at) %></time> <time class="text-theme-primary font-semibold"><%= pretty_date(@note.updated_at) %></time>
<span>·</span>
<span>Tags</span>
<span class="text-theme-primary font-semibold">
<%= for tag <- @note.tags do %>
<a href={~p"/t/#{tag.slug}"}><%= tag.name %></a>
<% end %>
</span>
<%= if @current_user do %> <%= if @current_user do %>
<span>·</span> <span>·</span>
<a href={~p"/admin/notes/#{@note}/edit"} class="text-theme-primary font-semibold">Edit</a> <a href={~p"/admin/notes/#{@note}/edit"} class="text-theme-primary font-semibold">Edit</a>

View file

@ -0,0 +1,20 @@
<div class="mx-auto max-w-xl mx-4 lg:mx-0">
<h1 class="mt-16 text-[2rem] font-semibold font-serif leading-10 tracking-tighter text-theme-heading">
Tagged with &ldquo;<%= @tag.name %>&rdquo;
</h1>
<p class="mt-4 text-base leading-7 text-theme-base">
<%= @tag.content %>
</p>
<div class="w-full mt-6 sm:w-auto flex flex-col gap-1.5">
<%= for note <- @tag.notes do %>
<a
href={~p"/#{note.slug}"}
class="rounded -mx-2 -my-0.5 px-2 py-0.5 hover:bg-theme-primary/10 transition"
>
<span class="text-theme-primary text-lg font-semibold leading-8"><%= note.name %></span>
<span class="text-theme-base text-sm"><%= pretty_date(note.published_at) %></span>
</a>
<% end %>
</div>
</div>

View file

@ -35,6 +35,7 @@ defmodule ChiyaWeb.NoteShowLive do
<:item title="Channels"><%= @channels %></:item> <:item title="Channels"><%= @channels %></:item>
<:item title="Kind"><%= @note.kind %></:item> <:item title="Kind"><%= @note.kind %></:item>
<:item title="Url"><%= @note.url %></:item> <:item title="Url"><%= @note.url %></:item>
<:item title="Tags"><%= note_tags(@note.tags) %></:item>
<:item title="Links outgoing"><%= note_links(@note.links_to) %></:item> <:item title="Links outgoing"><%= note_links(@note.links_to) %></:item>
<:item title="Links incoming"><%= note_links(@note.links_from) %></:item> <:item title="Links incoming"><%= note_links(@note.links_from) %></:item>
</.list> </.list>
@ -151,7 +152,6 @@ defmodule ChiyaWeb.NoteShowLive do
{:noreply, assign(socket, :note, Notes.get_note_preloaded!(socket.assigns.note.id))} {:noreply, assign(socket, :note, Notes.get_note_preloaded!(socket.assigns.note.id))}
end end
defp note_links(notes) do defp note_links(notes), do: Enum.map_join(notes, ", ", fn n -> n.name end)
Enum.map_join(notes, ", ", fn n -> n.name end) defp note_tags(tags), do: Enum.map_join(tags, ", ", fn t -> t.name end)
end
end end

View file

@ -117,6 +117,7 @@ defmodule ChiyaWeb.Router do
get "/:slug", PageController, :note get "/:slug", PageController, :note
get "/c/:slug", PageController, :channel get "/c/:slug", PageController, :channel
get "/t/:slug", PageController, :tag
get "/", PageController, :home get "/", PageController, :home
end end
end end

View file

@ -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