feat: TUI tags panels (cloud, manage, merge) wired to the shared tags backend (issue #34)

This commit is contained in:
2026-07-17 14:50:25 +02:00
parent 0f3f1efa08
commit 2fd132e827
11 changed files with 1987 additions and 661 deletions

240
lib/bds/ui/tags_panel.ex Normal file
View 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