Merge pull request 'devel' (#54) from devel into main

Reviewed-on: #54
This commit is contained in:
inhji 2023-04-09 16:47:52 +02:00
commit 6ec394679c
13 changed files with 293 additions and 25 deletions

View file

@ -1,3 +1,6 @@
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
import lolight from "../vendor/lolight" import lolight from "../vendor/lolight"
import GLightbox from 'glightbox' import GLightbox from 'glightbox'
@ -17,4 +20,4 @@ document
} }
}) })
const lightbox = GLightbox({ selector: ".lightbox" }) GLightbox({ selector: ".lightbox" })

View file

@ -5,10 +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} @preloads [:channels, :images, :links_from, :links_to]
@preloads [:channels, :images]
@doc """ @doc """
Returns the list of notes. Returns the list of notes.
@ -61,6 +60,22 @@ defmodule Chiya.Notes do
""" """
def get_note!(id), do: Repo.get!(Note, id) 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 """ @doc """
Gets a single note and preloads it. 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() 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() 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 """ @doc """
Creates a note. Creates a note.
@ -113,6 +158,7 @@ defmodule Chiya.Notes do
note note
|> Note.changeset(attrs) |> Note.changeset(attrs)
|> Repo.update() |> Repo.update()
|> Chiya.Notes.References.update_references(attrs)
end end
@doc """ @doc """
@ -178,6 +224,24 @@ defmodule Chiya.Notes do
:ok :ok
end 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 """ @doc """
Returns an `%Ecto.Changeset{}` for tracking note_image changes. Returns an `%Ecto.Changeset{}` for tracking note_image changes.
""" """

View file

@ -19,6 +19,14 @@ defmodule Chiya.Notes.Note do
join_keys: [note: :id, channel: :id], join_keys: [note: :id, channel: :id],
on_replace: :delete 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 has_many :images, Chiya.Notes.NoteImage
timestamps() timestamps()

View file

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

View file

@ -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/\[\[(?<id>[\d\w-]+)(?:\|(?<title>[\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

View file

@ -76,6 +76,7 @@ defmodule ChiyaWeb.AdminComponents do
""" """
attr :current_user, :map, required: true attr :current_user, :map, required: true
attr :settings, :map, required: true
def admin_bar(assigns) do def admin_bar(assigns) do
~H""" ~H"""
@ -85,10 +86,7 @@ defmodule ChiyaWeb.AdminComponents do
href={~p"/"} href={~p"/"}
class="flex gap-3 text-sm leading-6 font-semibold text-gray-900 dark:text-gray-100 dark:hover:text-gray-300 hover:text-gray-700" class="flex gap-3 text-sm leading-6 font-semibold text-gray-900 dark:text-gray-100 dark:hover:text-gray-300 hover:text-gray-700"
> >
<span>Chiya</span> <%= @settings.title %>
<p class="rounded-full bg-theme-primary/10 px-2 text-[0.8125rem] font-medium leading-6 text-theme-primary">
v<%= Application.spec(:chiya, :vsn) %>
</p>
</.link> </.link>
</li> </li>
<li class="flex-1"></li> <li class="flex-1"></li>

View file

@ -321,7 +321,7 @@ defmodule ChiyaWeb.CoreComponents do
attr :prompt, :string, default: nil, doc: "the prompt for select inputs" attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :rest, :global, include: ~w(autocomplete cols disabled form max maxlength min minlength attr :rest, :global, include: ~w(autocomplete accept cols disabled form max maxlength min minlength
pattern placeholder readonly required rows size step) pattern placeholder readonly required rows size step)
slot :inner_block slot :inner_block

View file

@ -22,7 +22,7 @@
</script> </script>
</head> </head>
<body class="bg-gray-200 dark:bg-gray-900 antialiased"> <body class="bg-gray-200 dark:bg-gray-900 antialiased">
<.admin_bar current_user={@current_user} /> <.admin_bar current_user={@current_user} settings={@settings} />
<%= @inner_content %> <%= @inner_content %>
<div id="react-root"></div> <div id="react-root"></div>
</body> </body>

View file

@ -25,14 +25,15 @@
</style> </style>
</head> </head>
<body class="bg-theme-background antialiased min-h-screen flex flex-col"> <body class="bg-theme-background antialiased min-h-screen flex flex-col">
<.admin_bar current_user={@current_user} /> <.admin_bar current_user={@current_user} settings={@settings} />
<main class="flex-1"> <main class="flex-1">
<%= @inner_content %> <%= @inner_content %>
</main> </main>
<footer class="px-4 py-1 sm:px-6 lg:px-8 bg-white/30 dark:bg-black/30 text-gray-900 dark:text-gray-100 text-xs leading-6 font-semibold"> <footer class="px-4 py-1 sm:px-6 lg:px-8 bg-white/30 dark:bg-black/30 text-gray-900 dark:text-gray-100 text-xs leading-6 font-semibold">
<span class="text-theme-primary">⌘</span> <span><%= @settings.title %></span> <span class="text-theme-primary">⌘</span> <span>Chiya</span> <span class="rounded-full bg-theme-primary/10 px-2 text-xs font-medium leading-6 text-theme-primary">
v<%= Application.spec(:chiya, :vsn) %></span>
</footer> </footer>
<%= raw(@settings.custom_html) %> <%= raw(@settings.custom_html) %>

View file

@ -7,6 +7,10 @@
<span>Published</span> <time class="text-theme-primary font-semibold"><%= pretty_date(@note.published_at) %></time> <span>Published</span> <time class="text-theme-primary font-semibold"><%= pretty_date(@note.published_at) %></time>
<span>·</span> <span>·</span>
<span>Last Updated</span> <time class="text-theme-primary font-semibold"><%= pretty_date(@note.updated_at) %></time> <span>Last Updated</span> <time class="text-theme-primary font-semibold"><%= pretty_date(@note.updated_at) %></time>
<%= if @current_user do %>
<span>·</span>
<a href={~p"/admin/notes/#{@note}/edit"} class="text-theme-primary font-semibold">Edit</a>
<% end %>
</p> </p>
</header> </header>
@ -20,8 +24,7 @@
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<%= for image <- @note.images do %> <%= for image <- @note.images do %>
<a href={ChiyaWeb.Uploaders.NoteImage.url({image.path, image}, :full_dithered)} class="lightbox | w-28" data-gallery="note" data-description={ChiyaWeb.Markdown.render(image.content)}> <a href={ChiyaWeb.Uploaders.NoteImage.url({image.path, image}, :full_dithered)} class="lightbox | w-28" data-gallery="note" data-description={ChiyaWeb.Markdown.render(image.content)}>
<img src={ChiyaWeb.Uploaders.NoteImage.url({image.path, image}, :thumb_dithered)} <img src={ChiyaWeb.Uploaders.NoteImage.url({image.path, image}, :thumb_dithered)} class="rounded"/>
/>
</a> </a>
<% end %> <% end %>
</div> </div>

View file

@ -15,7 +15,7 @@ defmodule ChiyaWeb.NoteShowLive do
<:subtitle><%= @note.slug %></:subtitle> <:subtitle><%= @note.slug %></:subtitle>
<:actions> <:actions>
<.link href={~p"/admin/notes/#{@note}/edit"}> <.link href={~p"/admin/notes/#{@note}/edit"}>
<.button>Edit note</.button> <.button>Edit</.button>
</.link> </.link>
<.link href={~p"/#{@note.slug}"}> <.link href={~p"/#{@note.slug}"}>
<.button>Preview</.button> <.button>Preview</.button>
@ -31,18 +31,10 @@ 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="Links outgoing"><%= note_links(@note.links_to) %></:item>
<:item title="Links incoming"><%= note_links(@note.links_from) %></:item>
</.list> </.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 /> <.line />
<%= if !Enum.empty?(@note.images) do %> <%= 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))} {:noreply, assign(socket, :note, Notes.get_note_preloaded!(socket.assigns.note.id))}
end end
defp note_links(notes) do
Enum.map_join(notes, ", ",fn n -> n.name end)
end
end end

View file

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

View file

@ -0,0 +1,17 @@
defmodule Chiya.Repo.Migrations.FixNoteImageReference do
use Ecto.Migration
def up do
drop constraint :note_images, "note_images_note_id_fkey"
alter table(:note_images) do
modify :note_id, references(:notes, on_delete: :delete_all)
end
end
def down do
drop constraint :note_images, "note_images_note_id_fkey"
alter table(:note_images) do
modify :note_id, references(:notes, on_delete: :nothing)
end
end
end