feat: TUI tags panels (cloud, manage, merge) wired to the shared tags backend (issue #34)
This commit is contained in:
@@ -7,7 +7,6 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
|
||||
alias BDS.{Repo, Tags}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Tags.Tag
|
||||
alias BDS.Templates.Template
|
||||
use Gettext, backend: BDS.Gettext
|
||||
@@ -16,12 +15,8 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
|
||||
@tags_sections ~w(cloud manage merge)
|
||||
|
||||
@colour_presets ~w(
|
||||
#ef4444 #f97316 #f59e0b #eab308 #84cc16
|
||||
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
|
||||
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
|
||||
#ec4899 #64748b
|
||||
)
|
||||
# Shared with the TUI tags panel (issue #34) via BDS.UI.TagsPanel.
|
||||
@colour_presets BDS.UI.TagsPanel.colour_presets()
|
||||
|
||||
@spec colour_presets() :: [String.t()]
|
||||
def colour_presets, do: @colour_presets
|
||||
@@ -439,12 +434,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
|
||||
defp current_tab_meta(_assigns), do: %{}
|
||||
|
||||
defp 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
|
||||
defp tag_counts(project_id), do: BDS.UI.TagsPanel.tag_counts(project_id)
|
||||
|
||||
defp blank_to_nil(nil), do: nil
|
||||
|
||||
|
||||
273
lib/bds/tui.ex
273
lib/bds/tui.ex
@@ -45,6 +45,7 @@ defmodule BDS.TUI do
|
||||
alias BDS.UI.PostEditor.{Draft, Metadata, Persistence}
|
||||
alias BDS.UI.SettingsForm
|
||||
alias BDS.UI.Sidebar
|
||||
alias BDS.UI.TagsPanel
|
||||
alias ExRatatui.Layout
|
||||
alias ExRatatui.Layout.Rect
|
||||
alias ExRatatui.Style
|
||||
@@ -78,6 +79,7 @@ defmodule BDS.TUI do
|
||||
git: nil,
|
||||
git_commit: nil,
|
||||
settings_form: nil,
|
||||
tags_panel: nil,
|
||||
report: nil,
|
||||
handled_task_ids: nil,
|
||||
task_polling: false,
|
||||
@@ -149,6 +151,10 @@ defmodule BDS.TUI do
|
||||
when form != nil,
|
||||
do: settings_form_key(key, state)
|
||||
|
||||
def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{tags_panel: panel} = state)
|
||||
when panel != nil,
|
||||
do: tags_panel_key(key, state)
|
||||
|
||||
def handle_event(%ExRatatui.Event.Key{code: "esc"}, %{image: image} = state)
|
||||
when image != nil,
|
||||
do: {:noreply, %{state | image: nil}}
|
||||
@@ -240,6 +246,7 @@ defmodule BDS.TUI do
|
||||
%{route: "git_file", id: path} -> {:noreply, jump_to_file_diff(state, path)}
|
||||
%{route: "settings", id: "settings-" <> section} -> {:noreply, open_settings_form(state, section)}
|
||||
%{route: "style"} -> {:noreply, open_settings_form(state, "style")}
|
||||
%{route: "tags", id: "tags-" <> section} -> {:noreply, open_tags_panel(state, section)}
|
||||
%{id: _id} -> {:noreply, toast(state, view_label(state.view), dgettext("ui", "Editing this item is not available in the terminal UI yet."))}
|
||||
_other -> {:noreply, state}
|
||||
end
|
||||
@@ -563,6 +570,217 @@ defmodule BDS.TUI do
|
||||
end
|
||||
end
|
||||
|
||||
# ── Events: tags panel (issue #34) ────────────────────────────────────────
|
||||
|
||||
# The tags sidebar entries open tag management in the main area — the
|
||||
# same BDS.Tags operations as the GUI tags editor, shared through
|
||||
# BDS.UI.TagsPanel: cloud (usage overview), manage (create / rename /
|
||||
# colour / template / delete / sync-from-posts), merge (mark tags with
|
||||
# space, merge them into the selected one).
|
||||
defp open_tags_panel(state, section) when section in ~w(cloud manage merge) do
|
||||
data = TagsPanel.load(state.project_id)
|
||||
|
||||
%{
|
||||
state
|
||||
| tags_panel: %{
|
||||
section: section,
|
||||
tags: data.tags,
|
||||
selected: 0,
|
||||
marked: [],
|
||||
input: nil,
|
||||
confirm_delete: nil
|
||||
},
|
||||
image: nil
|
||||
}
|
||||
end
|
||||
|
||||
defp open_tags_panel(state, _section), do: state
|
||||
|
||||
defp reload_tags_panel(%{tags_panel: panel} = state) do
|
||||
data = TagsPanel.load(state.project_id)
|
||||
names = MapSet.new(data.tags, & &1.name)
|
||||
|
||||
panel = %{
|
||||
panel
|
||||
| tags: data.tags,
|
||||
selected: min(panel.selected, max(length(data.tags) - 1, 0)),
|
||||
marked: Enum.filter(panel.marked, &MapSet.member?(names, &1)),
|
||||
input: nil,
|
||||
confirm_delete: nil
|
||||
}
|
||||
|
||||
load_sidebar(%{state | tags_panel: panel})
|
||||
end
|
||||
|
||||
# Text input prompt (new tag name / rename) lives in the status line,
|
||||
# like the settings form and the commit message prompt.
|
||||
defp tags_panel_key(%{code: "esc"}, %{tags_panel: %{input: input}} = state) when input != nil,
|
||||
do: {:noreply, put_in(state.tags_panel.input, nil)}
|
||||
|
||||
defp tags_panel_key(%{code: "enter"}, %{tags_panel: %{input: input}} = state)
|
||||
when input != nil do
|
||||
result =
|
||||
case input do
|
||||
%{kind: :new} -> TagsPanel.create(state.project_id, input.value)
|
||||
%{kind: :rename, name: name} -> TagsPanel.rename(state.project_id, name, input.value)
|
||||
end
|
||||
|
||||
{:noreply, tags_panel_result(state, result)}
|
||||
end
|
||||
|
||||
defp tags_panel_key(%{code: "backspace"}, %{tags_panel: %{input: input}} = state)
|
||||
when input != nil do
|
||||
{:noreply, put_in(state.tags_panel.input.value, String.slice(input.value, 0..-2//1))}
|
||||
end
|
||||
|
||||
defp tags_panel_key(%{code: code, modifiers: []}, %{tags_panel: %{input: input}} = state)
|
||||
when input != nil and byte_size(code) >= 1 do
|
||||
if String.length(code) == 1 do
|
||||
{:noreply, put_in(state.tags_panel.input.value, input.value <> code)}
|
||||
else
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp tags_panel_key(_key, %{tags_panel: %{input: input}} = state) when input != nil,
|
||||
do: {:noreply, state}
|
||||
|
||||
# Pending delete confirmation: y deletes, anything else cancels.
|
||||
defp tags_panel_key(%{code: "y"}, %{tags_panel: %{confirm_delete: name}} = state)
|
||||
when name != nil do
|
||||
{:noreply, tags_panel_result(state, TagsPanel.delete(state.project_id, name))}
|
||||
end
|
||||
|
||||
defp tags_panel_key(_key, %{tags_panel: %{confirm_delete: name}} = state) when name != nil do
|
||||
state = put_in(state.tags_panel.confirm_delete, nil)
|
||||
{:noreply, toast(state, view_label("tags"), dgettext("ui", "Delete cancelled"))}
|
||||
end
|
||||
|
||||
defp tags_panel_key(%{code: "esc"}, state), do: {:noreply, %{state | tags_panel: nil}}
|
||||
|
||||
defp tags_panel_key(%{code: code}, state) when code in ["down", "j"],
|
||||
do: {:noreply, move_tags_selection(state, 1)}
|
||||
|
||||
defp tags_panel_key(%{code: code}, state) when code in ["up", "k"],
|
||||
do: {:noreply, move_tags_selection(state, -1)}
|
||||
|
||||
defp tags_panel_key(%{code: "n"}, %{tags_panel: %{section: "manage"}} = state) do
|
||||
input = %{kind: :new, label: dgettext("ui", "New tag"), value: ""}
|
||||
{:noreply, put_in(state.tags_panel.input, input)}
|
||||
end
|
||||
|
||||
defp tags_panel_key(%{code: "enter"}, %{tags_panel: %{section: "manage"}} = state) do
|
||||
case selected_tag(state.tags_panel) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
tag ->
|
||||
input = %{
|
||||
kind: :rename,
|
||||
name: tag.name,
|
||||
label: dgettext("ui", "Rename tag"),
|
||||
value: tag.name
|
||||
}
|
||||
|
||||
{:noreply, put_in(state.tags_panel.input, input)}
|
||||
end
|
||||
end
|
||||
|
||||
defp tags_panel_key(%{code: "c"}, %{tags_panel: %{section: "manage"}} = state),
|
||||
do: with_selected_tag(state, &TagsPanel.cycle_color(state.project_id, &1.name))
|
||||
|
||||
defp tags_panel_key(%{code: "t"}, %{tags_panel: %{section: "manage"}} = state),
|
||||
do: with_selected_tag(state, &TagsPanel.cycle_template(state.project_id, &1.name))
|
||||
|
||||
defp tags_panel_key(%{code: "d"}, %{tags_panel: %{section: "manage"}} = state) do
|
||||
case selected_tag(state.tags_panel) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
tag ->
|
||||
count = TagsPanel.post_count(state.project_id, tag.name)
|
||||
state = put_in(state.tags_panel.confirm_delete, tag.name)
|
||||
|
||||
{:noreply,
|
||||
toast(
|
||||
state,
|
||||
view_label("tags"),
|
||||
dgettext("ui", "Delete %{name} (used by %{count} posts)? Press y to confirm.",
|
||||
name: tag.name,
|
||||
count: count
|
||||
)
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp tags_panel_key(%{code: "s"}, %{tags_panel: %{section: "manage"}} = state),
|
||||
do: {:noreply, tags_panel_result(state, TagsPanel.sync(state.project_id))}
|
||||
|
||||
defp tags_panel_key(%{code: code}, %{tags_panel: %{section: "merge"}} = state)
|
||||
when code in [" ", "space"] do
|
||||
case selected_tag(state.tags_panel) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
tag ->
|
||||
marked = state.tags_panel.marked
|
||||
|
||||
next =
|
||||
if tag.name in marked,
|
||||
do: marked -- [tag.name],
|
||||
else: marked ++ [tag.name]
|
||||
|
||||
{:noreply, put_in(state.tags_panel.marked, next)}
|
||||
end
|
||||
end
|
||||
|
||||
defp tags_panel_key(%{code: "m"}, %{tags_panel: %{section: "merge"}} = state) do
|
||||
case selected_tag(state.tags_panel) do
|
||||
nil ->
|
||||
{:noreply, state}
|
||||
|
||||
target ->
|
||||
marked = Enum.uniq(state.tags_panel.marked ++ [target.name])
|
||||
{:noreply, tags_panel_result(state, TagsPanel.merge(state.project_id, marked, target.name))}
|
||||
end
|
||||
end
|
||||
|
||||
defp tags_panel_key(_key, state), do: {:noreply, state}
|
||||
|
||||
defp tags_panel_result(state, {:ok, message}) do
|
||||
state |> reload_tags_panel() |> toast(view_label("tags"), message)
|
||||
end
|
||||
|
||||
defp tags_panel_result(state, {:error, message}) do
|
||||
state = put_in(state.tags_panel.input, nil)
|
||||
state = put_in(state.tags_panel.confirm_delete, nil)
|
||||
toast(state, view_label("tags"), message)
|
||||
end
|
||||
|
||||
defp with_selected_tag(state, fun) do
|
||||
case selected_tag(state.tags_panel) do
|
||||
nil -> {:noreply, state}
|
||||
tag -> {:noreply, tags_panel_result(state, fun.(tag))}
|
||||
end
|
||||
end
|
||||
|
||||
# The cloud orders by usage (a terminal tag cloud); manage and merge
|
||||
# stay alphabetical like the GUI list.
|
||||
defp panel_tags(%{section: "cloud", tags: tags}), do: Enum.sort_by(tags, &{-&1.count, &1.name})
|
||||
defp panel_tags(%{tags: tags}), do: tags
|
||||
|
||||
defp selected_tag(panel), do: Enum.at(panel_tags(panel), panel.selected)
|
||||
|
||||
defp move_tags_selection(%{tags_panel: panel} = state, delta) do
|
||||
count = length(panel.tags)
|
||||
|
||||
if count == 0 do
|
||||
state
|
||||
else
|
||||
put_in(state.tags_panel.selected, clamp(panel.selected + delta, count))
|
||||
end
|
||||
end
|
||||
|
||||
# ── Events: sidebar search (vi-style "/") ─────────────────────────────────
|
||||
|
||||
# The prompt lives in the status line and filters the current view live
|
||||
@@ -921,6 +1139,14 @@ defmodule BDS.TUI do
|
||||
state
|
||||
end
|
||||
|
||||
# An open tags panel tracks external tag changes (other clients, CLI).
|
||||
state =
|
||||
if state.tags_panel != nil and entity == "tag" do
|
||||
reload_tags_panel(state)
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@@ -1155,6 +1381,20 @@ defmodule BDS.TUI do
|
||||
]
|
||||
end
|
||||
|
||||
defp main_widgets(%{tags_panel: panel}, rect) when panel != nil do
|
||||
items = Enum.map(panel_tags(panel), &tag_line(panel, &1))
|
||||
|
||||
[
|
||||
{%List{
|
||||
items: items,
|
||||
selected: list_selected(items, panel.selected),
|
||||
scroll_padding: 2,
|
||||
highlight_style: %Style{fg: :black, bg: :cyan},
|
||||
block: %Block{title: tags_panel_title(panel), borders: [:all]}
|
||||
}, rect}
|
||||
]
|
||||
end
|
||||
|
||||
defp main_widgets(%{view: "git", editor: nil, git: nil}, rect) do
|
||||
[
|
||||
{%Paragraph{
|
||||
@@ -1241,10 +1481,12 @@ defmodule BDS.TUI do
|
||||
|
||||
defp status_widgets(state, rect) do
|
||||
settings_input = state.settings_form && state.settings_form.input
|
||||
tags_input = state.tags_panel && state.tags_panel.input
|
||||
|
||||
text =
|
||||
cond do
|
||||
settings_input -> settings_input.label <> ": " <> settings_input.value
|
||||
tags_input -> tags_input.label <> ": " <> tags_input.value
|
||||
state.git_commit -> dgettext("ui", "Commit message") <> ": " <> state.git_commit.input
|
||||
state.search -> "/" <> state.search.input
|
||||
state.busy -> dgettext("ui", "Working…")
|
||||
@@ -1654,6 +1896,37 @@ defmodule BDS.TUI do
|
||||
defp field_line(%{type: :info, value: ""} = field), do: field.label
|
||||
defp field_line(field), do: "#{field.label}: #{field.value}"
|
||||
|
||||
defp tag_line(%{section: "merge", marked: marked}, tag) do
|
||||
if(tag.name in marked, do: "[x] ", else: "[ ] ") <> tag_line_base(tag)
|
||||
end
|
||||
|
||||
defp tag_line(%{section: "manage"}, tag) do
|
||||
extras =
|
||||
[tag.color, tag.post_template_slug]
|
||||
|> Enum.reject(&(&1 in [nil, ""]))
|
||||
|> Enum.map_join("", &(" · " <> &1))
|
||||
|
||||
tag_line_base(tag) <> extras
|
||||
end
|
||||
|
||||
defp tag_line(_panel, tag), do: tag_line_base(tag)
|
||||
|
||||
defp tag_line_base(tag), do: "#{tag.name} (#{tag.count})"
|
||||
|
||||
defp tags_panel_title(%{section: "cloud"}),
|
||||
do: dgettext("ui", "Tag Cloud") <> " — " <> dgettext("ui", "esc close")
|
||||
|
||||
defp tags_panel_title(%{section: "manage"}) do
|
||||
dgettext("ui", "Tags") <>
|
||||
" — " <>
|
||||
dgettext("ui", "n new · enter rename · c colour · t template · d delete · s sync · esc close")
|
||||
end
|
||||
|
||||
defp tags_panel_title(%{section: "merge"}) do
|
||||
dgettext("ui", "Merge Tags") <>
|
||||
" — " <> dgettext("ui", "space mark · m merge into selected · esc close")
|
||||
end
|
||||
|
||||
defp item_label(item) do
|
||||
title =
|
||||
item[:title] || item[:name] || item[:original_name] || item[:label] || item[:id] || "?"
|
||||
|
||||
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