feat: TUI tags panels (cloud, manage, merge) wired to the shared tags backend (issue #34)
This commit is contained in:
240
lib/bds/ui/tags_panel.ex
Normal file
240
lib/bds/ui/tags_panel.ex
Normal file
@@ -0,0 +1,240 @@
|
||||
defmodule BDS.UI.TagsPanel do
|
||||
@moduledoc """
|
||||
Renderer-agnostic tag management core (issue #34).
|
||||
|
||||
Exposes the same tag operations the GUI tags editor
|
||||
(`BDS.Desktop.ShellLive.TagsEditor`) performs — create, rename, colour,
|
||||
post template, delete, merge, sync-from-posts — as plain functions with
|
||||
localized result messages, so the TUI tags panel and the GUI operate on
|
||||
the same `BDS.Tags` backend.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Repo
|
||||
alias BDS.Tags
|
||||
alias BDS.Tags.Tag
|
||||
alias BDS.Templates.Template
|
||||
|
||||
@colour_presets ~w(
|
||||
#ef4444 #f97316 #f59e0b #eab308 #84cc16
|
||||
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
|
||||
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
|
||||
#ec4899 #64748b
|
||||
)
|
||||
|
||||
@type tag_entry :: %{
|
||||
id: String.t(),
|
||||
name: String.t(),
|
||||
color: String.t() | nil,
|
||||
post_template_slug: String.t() | nil,
|
||||
count: non_neg_integer()
|
||||
}
|
||||
|
||||
@spec colour_presets() :: [String.t()]
|
||||
def colour_presets, do: @colour_presets
|
||||
|
||||
@doc "All project tags with their post usage counts, plus template slugs."
|
||||
@spec load(String.t()) :: %{tags: [tag_entry()], templates: [String.t()]}
|
||||
def load(project_id) do
|
||||
counts = tag_counts(project_id)
|
||||
|
||||
tags =
|
||||
project_id
|
||||
|> Tags.list_tags()
|
||||
|> Enum.map(fn tag ->
|
||||
%{
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
post_template_slug: tag.post_template_slug,
|
||||
count: Map.get(counts, tag.name, 0)
|
||||
}
|
||||
end)
|
||||
|
||||
%{tags: tags, templates: template_slugs(project_id)}
|
||||
end
|
||||
|
||||
@doc "How many posts use each tag name."
|
||||
@spec tag_counts(String.t()) :: %{String.t() => non_neg_integer()}
|
||||
def tag_counts(project_id) do
|
||||
Repo.all(from post in Post, where: post.project_id == ^project_id, select: post.tags)
|
||||
|> List.flatten()
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
|
||||
end
|
||||
|
||||
@spec post_count(String.t(), String.t()) :: non_neg_integer()
|
||||
def post_count(project_id, tag_name), do: Map.get(tag_counts(project_id), tag_name, 0)
|
||||
|
||||
@spec create(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
def create(project_id, name) do
|
||||
case Tags.create_tag(%{project_id: project_id, name: name}) do
|
||||
{:ok, tag} -> {:ok, dgettext("ui", "Tag %{name} created", name: tag.name)}
|
||||
{:error, reason} -> {:error, error_message(reason)}
|
||||
end
|
||||
end
|
||||
|
||||
@spec rename(String.t(), String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
def rename(project_id, tag_name, new_name) do
|
||||
with {:ok, tag} <- fetch(project_id, tag_name) do
|
||||
normalized = String.trim(to_string(new_name || ""))
|
||||
|
||||
cond do
|
||||
normalized == "" ->
|
||||
{:error, dgettext("ui", "Tag name cannot be empty")}
|
||||
|
||||
normalized == tag.name ->
|
||||
{:ok, dgettext("ui", "Tag unchanged")}
|
||||
|
||||
true ->
|
||||
case Tags.rename_tag(tag.id, normalized) do
|
||||
{:ok, renamed} ->
|
||||
{:ok, dgettext("ui", "Tag renamed to %{name}", name: renamed.name)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, error_message(reason)}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Advances the tag colour through the presets (last preset wraps to none)."
|
||||
@spec cycle_color(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
def cycle_color(project_id, tag_name) do
|
||||
with {:ok, tag} <- fetch(project_id, tag_name) do
|
||||
next = next_in_cycle(@colour_presets, tag.color)
|
||||
|
||||
case Tags.update_tag(tag.id, %{color: next}) do
|
||||
{:ok, updated} ->
|
||||
{:ok,
|
||||
dgettext("ui", "Colour of %{name}: %{color}",
|
||||
name: updated.name,
|
||||
color: updated.color || dgettext("ui", "none")
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, error_message(reason)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Advances the tag post template through the project templates (wraps to none)."
|
||||
@spec cycle_template(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
def cycle_template(project_id, tag_name) do
|
||||
with {:ok, tag} <- fetch(project_id, tag_name) do
|
||||
next = next_in_cycle(template_slugs(project_id), tag.post_template_slug)
|
||||
|
||||
case Tags.update_tag(tag.id, %{post_template_slug: next}) do
|
||||
{:ok, updated} ->
|
||||
{:ok,
|
||||
dgettext("ui", "Template of %{name}: %{template}",
|
||||
name: updated.name,
|
||||
template: updated.post_template_slug || dgettext("ui", "default")
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, error_message(reason)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
def delete(project_id, tag_name) do
|
||||
with {:ok, tag} <- fetch(project_id, tag_name) do
|
||||
case Tags.delete_tag(tag.id) do
|
||||
{:ok, _deleted} -> {:ok, dgettext("ui", "Tag %{name} deleted", name: tag_name)}
|
||||
{:error, reason} -> {:error, error_message(reason)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Merges the named tags into `target_name` (which must be one of them)."
|
||||
@spec merge(String.t(), [String.t()], String.t()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
def merge(project_id, source_names, target_name) do
|
||||
tags =
|
||||
Repo.all(
|
||||
from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^source_names
|
||||
)
|
||||
|
||||
target = Enum.find(tags, &(&1.name == target_name))
|
||||
sources = Enum.reject(tags, &(&1.name == target_name))
|
||||
|
||||
cond do
|
||||
target == nil ->
|
||||
{:error, dgettext("ui", "The merge target must be one of the marked tags")}
|
||||
|
||||
sources == [] ->
|
||||
{:error, dgettext("ui", "Mark at least two tags to merge")}
|
||||
|
||||
true ->
|
||||
case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do
|
||||
{:ok, _merged} ->
|
||||
{:ok,
|
||||
dgettext("ui", "Merged %{count} tags into %{name}",
|
||||
count: length(sources),
|
||||
name: target.name
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, error_message(reason)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec sync(String.t()) :: {:ok, String.t()} | {:error, String.t()}
|
||||
def sync(project_id) do
|
||||
case Tags.sync_tags_from_posts(project_id) do
|
||||
{:ok, tags} ->
|
||||
{:ok, dgettext("ui", "Tags synced from posts (%{count} total)", count: length(tags))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, error_message(reason)}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch(project_id, tag_name) do
|
||||
case Repo.get_by(Tag, project_id: project_id, name: tag_name) do
|
||||
nil -> {:error, dgettext("ui", "Tag %{name} not found", name: tag_name)}
|
||||
tag -> {:ok, tag}
|
||||
end
|
||||
end
|
||||
|
||||
defp template_slugs(project_id) do
|
||||
Repo.all(
|
||||
from template in Template,
|
||||
where: template.project_id == ^project_id,
|
||||
order_by: [asc: template.slug],
|
||||
select: template.slug
|
||||
)
|
||||
end
|
||||
|
||||
# nil → first option → … → last option → nil
|
||||
defp next_in_cycle([], _current), do: nil
|
||||
|
||||
defp next_in_cycle(options, current) do
|
||||
case Enum.find_index(options, &(&1 == current)) do
|
||||
nil -> List.first(options)
|
||||
index when index == length(options) - 1 -> nil
|
||||
index -> Enum.at(options, index + 1)
|
||||
end
|
||||
end
|
||||
|
||||
defp error_message(reason) when is_binary(reason), do: reason
|
||||
defp error_message(:not_found), do: dgettext("ui", "Tag not found")
|
||||
|
||||
defp error_message(%Ecto.Changeset{} = changeset) do
|
||||
changeset
|
||||
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
|
||||
Enum.reduce(opts, message, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end)
|
||||
|> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
|
||||
end
|
||||
|
||||
defp error_message(reason), do: inspect(reason)
|
||||
end
|
||||
Reference in New Issue
Block a user