Packages

phoenix_kit

1.7.65
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules publishing publishing.ex
Raw

lib/modules/publishing/publishing.ex

defmodule PhoenixKit.Modules.Publishing do
@moduledoc """
Publishing module for managing content groups and their posts.
Database-backed CMS for creating timestamped or slug-based posts
with multi-language support and versioning.
"""
use PhoenixKit.Module
require Logger
alias PhoenixKit.Dashboard.Tab
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Publishing.DBStorage
alias PhoenixKit.Modules.Publishing.LanguageHelpers
alias PhoenixKit.Modules.Publishing.ListingCache
alias PhoenixKit.Modules.Publishing.Metadata
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Modules.Publishing.SlugHelpers
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Utils.Date, as: UtilsDate
# Suppress dialyzer false positives for pattern matches
@dialyzer :no_match
@dialyzer {:nowarn_function, create_post: 2}
@dialyzer {:nowarn_function, add_language_to_post: 4}
# Language utility delegates
defdelegate get_language_info(language_code), to: LanguageHelpers
defdelegate enabled_language_codes(), to: LanguageHelpers
defdelegate get_primary_language(), to: LanguageHelpers
defdelegate language_enabled?(language_code, enabled_languages), to: LanguageHelpers
defdelegate get_display_code(language_code, enabled_languages), to: LanguageHelpers
defdelegate order_languages_for_display(available_languages, enabled_languages),
to: LanguageHelpers
defdelegate order_languages_for_display(available_languages, enabled_languages, primary),
to: LanguageHelpers
# Slug utility delegates
defdelegate validate_slug(slug), to: SlugHelpers
defdelegate slug_exists?(group_slug, post_slug), to: SlugHelpers
defdelegate generate_unique_slug(group_slug, title), to: SlugHelpers
defdelegate generate_unique_slug(group_slug, title, preferred_slug), to: SlugHelpers
defdelegate generate_unique_slug(group_slug, title, preferred_slug, opts), to: SlugHelpers
defdelegate validate_url_slug(group_slug, url_slug, language, exclude), to: SlugHelpers
@doc "Always returns false — auto-versioning is disabled."
def should_create_new_version?(_post, _params, _editing_language), do: false
@doc "Gets the primary language for a specific post from the database."
def get_post_primary_language(group_slug, post_slug, _version \\ nil) do
case DBStorage.get_post(group_slug, post_slug) do
nil -> LanguageHelpers.get_primary_language()
post -> post.primary_language || LanguageHelpers.get_primary_language()
end
rescue
_ -> LanguageHelpers.get_primary_language()
end
@doc "Checks the primary language migration status for a post."
def check_primary_language_status(group_slug, post_slug) do
global_primary = LanguageHelpers.get_primary_language()
case DBStorage.get_post(group_slug, post_slug) do
nil ->
{:needs_backfill, nil}
%{primary_language: nil} ->
{:needs_backfill, nil}
%{primary_language: ^global_primary} ->
{:ok, :current}
%{primary_language: stored} ->
{:needs_migration, stored}
end
rescue
_ -> {:needs_backfill, nil}
end
@doc "Lists version numbers for a post."
def list_versions(group_slug, post_slug) do
case DBStorage.get_post(group_slug, post_slug) do
nil ->
[]
db_post ->
db_post.uuid
|> DBStorage.list_versions()
|> Enum.map(& &1.version_number)
end
rescue
_ -> []
end
@doc "Gets the published version number for a post."
def get_published_version(group_slug, post_slug) do
case DBStorage.get_post(group_slug, post_slug) do
nil ->
{:error, :not_found}
db_post ->
db_post.uuid
|> DBStorage.list_versions()
|> Enum.find(&(&1.status == "published"))
|> case do
nil -> {:error, :no_published_version}
v -> {:ok, v.version_number}
end
end
rescue
_ -> {:error, :not_found}
end
@doc "Gets the status of a specific version/language."
def get_version_status(group_slug, post_slug, version_number, language) do
with db_post when not is_nil(db_post) <- DBStorage.get_post(group_slug, post_slug),
db_version when not is_nil(db_version) <-
DBStorage.get_version(db_post.uuid, version_number),
content when not is_nil(content) <- DBStorage.get_content(db_version.uuid, language) do
content.status
else
_ -> "draft"
end
rescue
_ -> "draft"
end
@doc "Counts posts on a specific date for a group."
def count_posts_on_date(group_slug, date) do
group_slug
|> list_times_on_date(date)
|> length()
end
@doc "Lists time values for posts on a specific date."
def list_times_on_date(group_slug, date) do
date = if is_binary(date), do: Date.from_iso8601!(date), else: date
group_slug
|> DBStorage.list_posts_timestamp_mode()
|> Enum.filter(&(&1.post_date == date))
|> Enum.map(&(Time.to_string(&1.post_time) |> String.slice(0, 5)))
|> Enum.uniq()
|> Enum.sort()
rescue
_ -> []
end
@doc """
Updates the primary language for a post. Falls back to DB for DB-only posts.
"""
def update_post_primary_language(group_slug, post_slug, new_primary_language) do
update_primary_language_in_db(group_slug, post_slug, new_primary_language)
end
# Migration detection functions (via ListingCache)
defdelegate posts_needing_primary_language_migration(group_slug), to: ListingCache
defdelegate count_primary_language_status(group_slug), to: ListingCache
@doc """
Checks if any posts in a group need primary_language migration.
"""
@spec posts_need_primary_language_migration?(String.t()) :: boolean()
def posts_need_primary_language_migration?(group_slug) do
ListingCache.posts_needing_primary_language_migration(group_slug) != []
end
@doc """
Returns count of posts by primary_language status.
Alias for `count_primary_language_status/1`.
"""
@spec get_primary_language_migration_status(String.t()) :: map()
def get_primary_language_migration_status(group_slug) do
ListingCache.count_primary_language_status(group_slug)
end
@doc """
Migrates all posts in a group to use the current global primary_language.
This updates the `primary_language` field in the database and regenerates
the listing cache. The migration is idempotent - running it multiple times
is safe and will skip posts that are already at the current primary language.
Returns `{:ok, count}` where count is the number of posts updated.
"""
@spec migrate_posts_to_current_primary_language(String.t()) ::
{:ok, integer()} | {:error, any()}
def migrate_posts_to_current_primary_language(group_slug) do
require Logger
global_primary = LanguageHelpers.get_primary_language()
posts = ListingCache.posts_needing_primary_language_migration(group_slug)
Logger.debug("[PrimaryLangMigration] Found #{length(posts)} posts needing migration")
if posts == [] do
{:ok, 0}
else
results =
posts
|> Enum.map(fn post ->
# Get slug from post (using atom keys since posts are normalized)
post_slug = get_post_slug(post)
Logger.debug(
"[PrimaryLangMigration] Post uuid=#{inspect(post[:uuid])} identifier=#{inspect(post_slug)}"
)
if post_slug do
result = update_primary_language_in_db(group_slug, post_slug, global_primary)
Logger.debug("[PrimaryLangMigration] Result for #{post_slug}: #{inspect(result)}")
result
else
Logger.warning(
"[PrimaryLangMigration] No identifier for post: #{inspect(post[:uuid])}"
)
{:error, :no_slug}
end
end)
success_count = Enum.count(results, &(&1 == :ok))
error_count = length(results) - success_count
Logger.debug("[PrimaryLangMigration] Success: #{success_count}, Errors: #{error_count}")
# Regenerate cache with updated primary_language values
# Note: ListingCache.regenerate/1 broadcasts cache_changed internally
ListingCache.regenerate(group_slug)
if error_count > 0 and success_count == 0 do
{:error, :all_migrations_failed}
else
{:ok, success_count}
end
end
end
# Get post identifier for DB operations (UUID preferred, slug as fallback)
defp get_post_slug(post) do
post[:uuid] || post[:slug]
end
# Version metadata lookup (DB-based)
def get_version_metadata(group_slug, post_slug, version_number, language) do
with db_post when not is_nil(db_post) <- DBStorage.get_post(group_slug, post_slug),
db_version when not is_nil(db_version) <-
DBStorage.get_version(db_post.uuid, version_number),
content when not is_nil(content) <- DBStorage.get_content(db_version.uuid, language) do
%{
status: content.status,
title: content.title,
url_slug: content.url_slug,
version: version_number
}
else
_ -> nil
end
rescue
_ -> nil
end
# Delegate cache operations to ListingCache
defdelegate regenerate_cache(group_slug), to: ListingCache, as: :regenerate
defdelegate invalidate_cache(group_slug), to: ListingCache, as: :invalidate
defdelegate cache_exists?(group_slug), to: ListingCache, as: :exists?
defdelegate find_cached_post(group_slug, post_slug), to: ListingCache, as: :find_post
defdelegate find_cached_post_by_path(group_slug, date, time),
to: ListingCache,
as: :find_post_by_path
@doc """
Finds a post by URL slug from the database.
"""
@spec find_by_url_slug(String.t(), String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | :cache_miss}
def find_by_url_slug(group_slug, language, url_slug) do
case DBStorage.find_by_url_slug(group_slug, language, url_slug) do
nil -> {:error, :not_found}
content -> {:ok, db_content_to_legacy_post(content, group_slug, language)}
end
end
@doc """
Finds a post by a previous URL slug (for 301 redirects).
"""
@spec find_by_previous_url_slug(String.t(), String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | :cache_miss}
def find_by_previous_url_slug(group_slug, language, url_slug) do
case DBStorage.find_by_previous_url_slug(group_slug, language, url_slug) do
nil -> {:error, :not_found}
content -> {:ok, db_content_to_legacy_post(content, group_slug, language)}
end
end
# Converts a DBStorage content record (with preloaded version/post/group) to a legacy post map
defp db_content_to_legacy_post(content, _group_slug, _language) do
version = content.version
post = version.post
%{
slug: post.slug,
url_slug: content.url_slug,
language: content.language,
metadata: %{
title: content.title,
status: content.status,
description: (content.data || %{})["description"]
}
}
end
@doc "Always returns false — DB-only mode has no legacy groups."
def legacy_group?(_group_slug), do: false
@doc "Always returns false — DB-only mode has no legacy groups."
def has_legacy_groups?, do: false
# New settings keys (write to these)
@publishing_enabled_key "publishing_enabled"
@publishing_groups_key "publishing_groups"
# Legacy settings keys (read from these as fallback)
@legacy_enabled_key "blogging_enabled"
@legacy_blogs_key "blogging_blogs"
@legacy_categories_key "blogging_categories"
@default_group_mode "timestamp"
@default_group_type "blogging"
@preset_types ["blogging", "faq", "legal"]
@slug_regex ~r/^[a-z0-9]+(?:-[a-z0-9]+)*$/
@type_regex ~r/^[a-z][a-z0-9-]{0,31}$/
# Default item names for preset types (singular, plural)
# Note: "blogging" type value kept for backward compatibility
@type_item_names %{
"blogging" => {"post", "posts"},
"faq" => {"question", "questions"},
"legal" => {"document", "documents"}
}
@default_item_singular "item"
@default_item_plural "items"
@type group :: map()
@impl PhoenixKit.Module
@doc """
Returns true when the publishing module is enabled.
Checks new key first, falls back to legacy key.
"""
@spec enabled?() :: boolean()
def enabled? do
# Check new key first, then fall back to legacy key
# Uses get_boolean_setting (cached) to avoid DB queries on every sidebar render
settings_call(:get_boolean_setting, [@publishing_enabled_key, false]) or
settings_call(:get_boolean_setting, [@legacy_enabled_key, false])
end
@impl PhoenixKit.Module
@doc """
Enables the publishing module.
Always writes to the new key.
"""
@spec enable_system() :: {:ok, any()} | {:error, any()}
def enable_system do
settings_call(:update_boolean_setting, [@publishing_enabled_key, true])
end
@impl PhoenixKit.Module
@doc """
Disables the publishing module.
Always writes to the new key.
"""
@spec disable_system() :: {:ok, any()} | {:error, any()}
def disable_system do
settings_call(:update_boolean_setting, [@publishing_enabled_key, false])
end
@doc """
Returns true when the given post is a DB-backed post (has a UUID).
"""
@spec db_post?(map()) :: boolean()
def db_post?(post), do: not is_nil(post[:uuid])
# ============================================================================
# Module Behaviour Callbacks
# ============================================================================
@impl PhoenixKit.Module
def module_key, do: "publishing"
@impl PhoenixKit.Module
def module_name, do: "Publishing"
@impl PhoenixKit.Module
def get_config do
%{
enabled: enabled?(),
groups_count: length(list_groups())
}
end
@impl PhoenixKit.Module
def permission_metadata do
%{
key: "publishing",
label: "Publishing",
icon: "hero-document-duplicate",
description: "Database-backed CMS pages and multi-language content"
}
end
@impl PhoenixKit.Module
def admin_tabs do
[
Tab.new!(
id: :admin_publishing,
label: "Publishing",
icon: "hero-document-text",
path: "publishing",
priority: 600,
level: :admin,
permission: "publishing",
match: :exact,
group: :admin_modules,
subtab_display: :when_active,
highlight_with_subtabs: false,
dynamic_children: &__MODULE__.publishing_children/1
)
]
end
@doc "Dynamic children function for Publishing sidebar tabs."
def publishing_children(_scope) do
groups = load_publishing_groups_for_tabs()
groups
|> Enum.with_index()
|> Enum.map(fn {group, idx} ->
slug = group["slug"] || ""
name = group["name"] || slug
hash = :erlang.phash2(slug) |> Integer.to_string(16) |> String.downcase()
sanitized = slug |> String.replace(~r/[^a-zA-Z0-9_]/, "_") |> String.slice(0, 50)
%Tab{
id: :"admin_publishing_#{sanitized}_#{hash}",
label: name,
icon: "hero-document-text",
path: "publishing/#{slug}",
priority: 601 + idx,
level: :admin,
permission: "publishing",
match: :prefix,
parent: :admin_publishing
}
end)
rescue
_ -> []
end
defp load_publishing_groups_for_tabs do
alias PhoenixKit.Settings
publishing_enabled =
Settings.get_boolean_setting("publishing_enabled", false) or
Settings.get_boolean_setting("blogging_enabled", false)
if publishing_enabled do
alias PhoenixKit.Modules.Publishing.DBStorage
DBStorage.list_groups()
|> Enum.map(fn g -> %{"name" => g.name, "slug" => g.slug} end)
else
[]
end
rescue
_ -> []
end
@impl PhoenixKit.Module
def settings_tabs do
[
Tab.new!(
id: :admin_settings_publishing,
label: "Publishing",
icon: "hero-document-text",
path: "publishing",
priority: 921,
level: :admin,
parent: :admin_settings,
permission: "publishing"
)
]
end
@impl PhoenixKit.Module
def children, do: [PhoenixKit.Modules.Publishing.Presence]
@impl PhoenixKit.Module
def route_module, do: PhoenixKitWeb.Routes.PublishingRoutes
@doc """
Returns all configured publishing groups.
Checks new key first, falls back to legacy keys.
"""
@spec list_groups() :: [group()]
def list_groups do
# Try new key first
case settings_call(:get_json_setting_cached, [@publishing_groups_key, nil]) do
%{"publishing_groups" => groups} when is_list(groups) ->
normalize_groups(groups)
%{"blogs" => groups} when is_list(groups) ->
# Handle if someone wrote with old structure to new key
normalize_groups(groups)
list when is_list(list) ->
normalize_groups(list)
_ ->
# Fall back to legacy blogging_blogs key
case settings_call(:get_json_setting_cached, [@legacy_blogs_key, nil]) do
%{"blogs" => groups} when is_list(groups) ->
normalize_groups(groups)
list when is_list(list) ->
normalize_groups(list)
_ ->
# Fall back to oldest legacy key (blogging_categories)
legacy =
case settings_call(:get_json_setting_cached, [@legacy_categories_key, nil]) do
%{"types" => types} when is_list(types) -> types
other when is_list(other) -> other
_ -> []
end
normalize_groups(legacy)
end
end
end
@doc """
Gets a publishing group by slug.
## Examples
iex> Publishing.get_group("news")
{:ok, %{"name" => "News", "slug" => "news", ...}}
iex> Publishing.get_group("nonexistent")
{:error, :not_found}
"""
@spec get_group(String.t()) :: {:ok, group()} | {:error, :not_found}
def get_group(slug) when is_binary(slug) do
case Enum.find(list_groups(), &(&1["slug"] == slug)) do
nil -> {:error, :not_found}
group -> {:ok, group}
end
end
@doc """
Adds a new publishing group.
## Parameters
* `name` - Display name for the group
* `opts` - Keyword list or map with options:
* `:mode` - Post mode: "timestamp" or "slug" (default: "timestamp")
* `:slug` - Optional custom slug, auto-generated from name if nil
* `:type` - Content type: "blogging", "faq", "legal", or custom (default: "blogging")
* `:item_singular` - Singular name for items (default: based on type, e.g., "post")
* `:item_plural` - Plural name for items (default: based on type, e.g., "posts")
## Examples
iex> Publishing.add_group("News")
{:ok, %{"name" => "News", "slug" => "news", "mode" => "timestamp", "type" => "blogging", ...}}
iex> Publishing.add_group("FAQ", type: "faq", mode: "slug")
{:ok, %{"name" => "FAQ", "slug" => "faq", "mode" => "slug", "type" => "faq", "item_singular" => "question", ...}}
iex> Publishing.add_group("Recipes", type: "custom", item_singular: "recipe", item_plural: "recipes")
{:ok, %{"name" => "Recipes", ..., "item_singular" => "recipe", "item_plural" => "recipes"}}
"""
@spec add_group(String.t(), keyword() | map()) :: {:ok, group()} | {:error, atom()}
def add_group(name, opts \\ [])
def add_group(name, opts) when is_binary(name) and (is_list(opts) or is_map(opts)) do
trimmed = String.trim(name)
mode = opts |> fetch_option(:mode) |> normalize_mode_with_default()
normalized_type = opts |> fetch_option(:type) |> normalize_type()
cond do
trimmed == "" ->
{:error, :invalid_name}
is_nil(mode) ->
{:error, :invalid_mode}
is_nil(normalized_type) ->
{:error, :invalid_type}
true ->
groups = list_groups()
preferred_slug = fetch_option(opts, :slug)
with {:ok, requested_slug} <- derive_requested_slug(preferred_slug, trimmed),
:ok <- check_slug_availability(requested_slug, groups, preferred_slug) do
slug = ensure_unique_slug(requested_slug, groups)
# Get item names - use provided values or defaults based on type
{default_singular, default_plural} = default_item_names(normalized_type)
item_singular =
opts
|> fetch_option(:item_singular)
|> normalize_item_name(default_singular)
item_plural =
opts
|> fetch_option(:item_plural)
|> normalize_item_name(default_plural)
group = %{
"name" => trimmed,
"slug" => slug,
"mode" => mode,
"type" => normalized_type,
"item_singular" => item_singular,
"item_plural" => item_plural
}
updated = groups ++ [group]
payload = %{"publishing_groups" => updated}
# Always write to new key
with {:ok, _} <- settings_call(:update_json_setting, [@publishing_groups_key, payload]) do
PublishingPubSub.broadcast_group_created(group)
{:ok, group}
end
end
end
end
# Legacy 4-arity version for backward compatibility
@doc false
@spec add_group(String.t(), String.t(), String.t() | nil, String.t() | nil) ::
{:ok, group()} | {:error, atom()}
def add_group(name, mode, preferred_slug, type)
when is_binary(name) and is_binary(mode) do
add_group(name, mode: mode, slug: preferred_slug, type: type)
end
@doc """
Removes a publishing group by slug.
"""
@spec remove_group(String.t()) :: {:ok, any()} | {:error, any()}
def remove_group(slug) when is_binary(slug) do
updated =
list_groups()
|> Enum.reject(&(&1["slug"] == slug))
result =
settings_call(:update_json_setting, [
@publishing_groups_key,
%{"publishing_groups" => updated}
])
# Broadcast after successful deletion
if match?({:ok, _}, result) do
PublishingPubSub.broadcast_group_deleted(slug)
end
result
end
@doc """
Updates a publishing group's display name and slug.
"""
@spec update_group(String.t(), map() | keyword()) :: {:ok, group()} | {:error, atom()}
def update_group(slug, params) when is_binary(slug) do
groups = list_groups()
case Enum.find(groups, &(&1["slug"] == slug)) do
nil -> {:error, :not_found}
group -> process_group_update(group, groups, params)
end
end
defp process_group_update(group, groups, params) do
with {:ok, name} <- extract_and_validate_name(group, params),
{:ok, sanitized_slug} <- extract_and_validate_slug(group, params, name),
:ok <- check_slug_uniqueness(group, groups, sanitized_slug) do
apply_group_update(group, groups, name, sanitized_slug)
end
end
defp extract_and_validate_name(group, params) do
name =
params
|> fetch_option(:name)
|> case do
nil -> group["name"]
value -> String.trim(to_string(value || ""))
end
if name == "", do: {:error, :invalid_name}, else: {:ok, name}
end
defp extract_and_validate_slug(group, params, name) do
desired_slug =
params
|> fetch_option(:slug)
|> case do
nil -> group["slug"]
value -> String.trim(to_string(value || ""))
end
# If slug is empty, auto-generate from name; otherwise validate as-is
cond do
desired_slug == "" ->
auto_slug = slugify(name)
if valid_slug?(auto_slug), do: {:ok, auto_slug}, else: {:error, :invalid_slug}
valid_slug?(desired_slug) ->
{:ok, desired_slug}
true ->
{:error, :invalid_slug}
end
end
defp check_slug_uniqueness(group, groups, sanitized_slug) do
if sanitized_slug != group["slug"] and Enum.any?(groups, &(&1["slug"] == sanitized_slug)) do
{:error, :already_exists}
else
:ok
end
end
defp apply_group_update(group, groups, name, sanitized_slug) do
updated_group =
group
|> Map.put("name", name)
|> Map.put("slug", sanitized_slug)
with {:ok, _} <- persist_group_update(groups, group["slug"], updated_group) do
PublishingPubSub.broadcast_group_updated(updated_group)
{:ok, updated_group}
end
end
@doc """
Removes a publishing group. The group is removed from the active groups list
and soft-deleted in the database.
"""
@spec trash_group(String.t()) :: {:ok, String.t()} | {:error, any()}
def trash_group(slug) when is_binary(slug) do
with {:ok, _} <- remove_group(slug) do
{:ok, slug}
end
end
@doc """
Looks up a publishing group name from its slug.
"""
@spec group_name(String.t()) :: String.t() | nil
def group_name(slug) do
Enum.find_value(list_groups(), fn group ->
if group["slug"] == slug, do: group["name"]
end)
end
@doc """
Returns the configured post mode for a publishing group slug.
"""
@spec get_group_mode(String.t()) :: String.t()
def get_group_mode(group_slug) do
list_groups()
|> Enum.find(%{}, &(&1["slug"] == group_slug))
|> Map.get("mode", @default_group_mode)
end
@doc """
Lists posts for a given publishing group slug.
Accepts optional preferred_language to show titles in user's language.
Queries the database directly via DBStorage.
"""
@spec list_posts(String.t(), String.t() | nil) :: [map()]
def list_posts(group_slug, _preferred_language \\ nil) do
DBStorage.list_posts_with_metadata(group_slug)
end
@doc """
Creates a new post for the given publishing group using the current timestamp.
"""
@spec create_post(String.t(), map() | keyword()) :: {:ok, map()} | {:error, any()}
def create_post(group_slug, opts \\ %{}) do
create_post_in_db(group_slug, opts)
end
defp create_post_in_db(group_slug, opts) do
scope = fetch_option(opts, :scope)
group = DBStorage.get_group_by_slug(group_slug)
unless group, do: throw({:error, :group_not_found})
mode = get_group_mode(group_slug)
primary_language = LanguageHelpers.get_primary_language()
now = UtilsDate.utc_now()
# Resolve user UUID for audit
created_by_uuid = resolve_scope_user_uuids(scope)
# Generate slug for slug-mode groups
slug_result =
case mode do
"slug" ->
title = fetch_option(opts, :title)
preferred_slug = fetch_option(opts, :slug)
SlugHelpers.generate_unique_slug(group_slug, title || "", preferred_slug)
_ ->
{:ok, nil}
end
with {:ok, post_slug} <- slug_result do
# Build post attributes
post_attrs = %{
group_uuid: group.uuid,
slug: post_slug,
status: "draft",
mode: mode,
primary_language: primary_language,
published_at: now,
created_by_uuid: created_by_uuid,
updated_by_uuid: created_by_uuid
}
# Add date/time for timestamp mode (truncate seconds since URLs use HH:MM only)
post_attrs =
if mode == "timestamp" do
date = DateTime.to_date(now)
time = %Time{hour: now.hour, minute: now.minute, second: 0, microsecond: {0, 0}}
# Find next available minute if this one is taken
{date, time} = find_available_timestamp(group_slug, date, time)
Map.merge(post_attrs, %{
post_date: date,
post_time: time
})
else
post_attrs
end
with {:ok, db_post} <- DBStorage.create_post(post_attrs),
{:ok, db_version} <-
DBStorage.create_version(%{
post_uuid: db_post.uuid,
version_number: 1,
status: "draft",
created_by_uuid: created_by_uuid
}),
{:ok, _content} <-
DBStorage.create_content(%{
version_uuid: db_version.uuid,
language: primary_language,
title: fetch_option(opts, :title) || "",
content: fetch_option(opts, :content) || "",
status: "draft",
url_slug: post_slug
}) do
# Read back via mapper to get a proper legacy map with UUID
read_result =
if mode == "timestamp" do
DBStorage.read_post_by_datetime(
group_slug,
db_post.post_date,
db_post.post_time,
primary_language,
1
)
else
DBStorage.read_post(group_slug, db_post.slug, primary_language, 1)
end
case read_result do
{:ok, post} ->
ListingCache.regenerate(group_slug)
PublishingPubSub.broadcast_post_created(group_slug, post)
{:ok, post}
{:error, _} = err ->
err
end
end
end
catch
{:error, reason} -> {:error, reason}
end
defp resolve_scope_user_uuids(nil), do: nil
defp resolve_scope_user_uuids(scope) do
Scope.user_uuid(scope)
end
@doc """
Reads a post by its database UUID.
Resolves the UUID to a group slug and post slug, then delegates to `read_post/4`.
Invalid version/language params gracefully fall back to latest/primary.
"""
def read_post_by_uuid(post_uuid, language \\ nil, version \\ nil) do
case DBStorage.get_post_by_uuid(post_uuid, [:group]) do
nil ->
{:error, :not_found}
db_post ->
group_slug = db_post.group.slug
version_number = if version, do: normalize_version_number(version), else: nil
if db_post.post_date && db_post.post_time do
DBStorage.read_post_by_datetime(
group_slug,
db_post.post_date,
db_post.post_time,
language,
version_number
)
else
DBStorage.read_post(group_slug, db_post.slug, language, version_number)
end
end
rescue
Ecto.QueryError -> {:error, :not_found}
DBConnection.ConnectionError -> {:error, :not_found}
end
@doc """
Reads an existing post.
For slug-mode groups, accepts an optional version parameter.
If version is nil, reads the latest version.
Reads from the database.
"""
@spec read_post(String.t(), String.t(), String.t() | nil, integer() | nil) ::
{:ok, map()} | {:error, any()}
def read_post(group_slug, identifier, language \\ nil, version \\ nil) do
read_post_from_db(group_slug, identifier, language, version)
end
defp read_post_from_db(group_slug, identifier, language, version) do
case get_group_mode(group_slug) do
"timestamp" ->
read_post_from_db_timestamp(group_slug, identifier, language, version)
_ ->
read_post_from_db_slug(group_slug, identifier, language, version)
end
end
defp read_post_from_db_timestamp(group_slug, identifier, language, version) do
case parse_timestamp_path(identifier) do
{:ok, date, time, inferred_version, inferred_language} ->
final_language = language || inferred_language
final_version = version || inferred_version
version_number = normalize_version_number(final_version)
DBStorage.read_post_by_datetime(
group_slug,
date,
time,
final_language,
version_number
)
:error ->
# Fallback: try as slug-based lookup
read_post_from_db_slug(group_slug, identifier, language, version)
end
end
defp read_post_from_db_slug(group_slug, identifier, language, version) do
{post_slug, inferred_version, inferred_language} =
extract_slug_version_and_language(group_slug, identifier)
final_language = language || inferred_language
final_version = version || inferred_version
version_number = normalize_version_number(final_version)
DBStorage.read_post(group_slug, post_slug, final_language, version_number)
end
defp normalize_version_number(nil), do: nil
defp normalize_version_number(v) when is_integer(v), do: v
defp normalize_version_number(v) do
case Integer.parse("#{v}") do
{n, _} -> n
:error -> nil
end
end
# Finds the next available minute for a timestamp-mode post.
# If the given date/time is already taken, bumps forward by one minute at a time.
# Limited to 60 attempts to prevent unbounded recursion.
@max_timestamp_attempts 60
defp find_available_timestamp(group_slug, date, time, attempts \\ 0)
defp find_available_timestamp(_group_slug, date, time, @max_timestamp_attempts) do
{date, time}
end
defp find_available_timestamp(group_slug, date, time, attempts) do
case DBStorage.get_post_by_datetime(group_slug, date, time) do
nil ->
{date, time}
_existing ->
# Bump by one minute
total_seconds = time.hour * 3600 + time.minute * 60 + 60
if total_seconds >= 86_400 do
# Rolled past midnight — advance to next day at 00:00
next_date = Date.add(date, 1)
find_available_timestamp(group_slug, next_date, ~T[00:00:00], attempts + 1)
else
next_hour = div(total_seconds, 3600)
next_minute = div(rem(total_seconds, 3600), 60)
next_time = %Time{hour: next_hour, minute: next_minute, second: 0, microsecond: {0, 0}}
find_available_timestamp(group_slug, date, next_time, attempts + 1)
end
end
end
# Parses timestamp paths like "2026-01-24/04:13/v7/sq" or "2026-01-24/04:13"
defp parse_timestamp_path(identifier) do
parts =
identifier
|> to_string()
|> String.trim_leading("/")
|> String.split("/", trim: true)
case parts do
[date_str, time_str] ->
with {:ok, date} <- Date.from_iso8601(date_str),
{:ok, time} <- parse_time(time_str) do
{:ok, date, time, nil, nil}
else
_ -> :error
end
[date_str, time_str | rest] ->
with {:ok, date} <- Date.from_iso8601(date_str),
{:ok, time} <- parse_time(time_str) do
{version, rest_after} = extract_version_from_parts(rest)
lang =
rest_after
|> List.first()
|> case do
nil -> nil
"" -> nil
lang_file -> lang_file
end
{:ok, date, time, version, lang}
else
_ -> :error
end
_ ->
:error
end
end
defp parse_time(time_str) do
# Handle "HH:MM" format
case Time.from_iso8601(time_str <> ":00") do
{:ok, time} -> {:ok, time}
_ -> :error
end
end
# Adds a language to a post.
# Creates a new content row in the database and returns the legacy map.
@doc false
def add_language_to_db(group_slug, post_slug, language_code, version_number) do
with db_post when not is_nil(db_post) <- resolve_db_post(group_slug, post_slug),
version when not is_nil(version) <-
if(version_number,
do: DBStorage.get_version(db_post.uuid, version_number),
else: DBStorage.get_latest_version(db_post.uuid)
),
# Check if content already exists for this language
nil <- DBStorage.get_content(version.uuid, language_code),
{:ok, _content} <-
DBStorage.create_content(%{
version_uuid: version.uuid,
language: language_code,
title: "Untitled",
content: "",
status: "draft"
}) do
# Read the post back from DB to return a proper legacy map
read_back_post(group_slug, post_slug, db_post, language_code, version.version_number)
else
nil ->
{:error, :not_found}
%PhoenixKit.Modules.Publishing.PublishingContent{} ->
# Content already exists for this language - just read the post
read_back_post(group_slug, post_slug, nil, language_code, version_number)
{:error, reason} ->
{:error, reason}
end
rescue
Ecto.QueryError -> {:error, :not_found}
DBConnection.ConnectionError -> {:error, :not_found}
end
# Resolves a DB post by slug, UUID, or datetime depending on what's available
defp resolve_db_post(_group_slug, nil), do: nil
defp resolve_db_post(group_slug, identifier) do
# Try UUID lookup if identifier looks like a UUID
if uuid_format?(identifier) do
DBStorage.get_post_by_uuid(identifier, [:group])
else
# Try slug-based lookup first
case DBStorage.get_post(group_slug, identifier) do
nil ->
# For timestamp-mode posts, the identifier may be "YYYY-MM-DD/HH:MM"
case parse_timestamp_path(identifier) do
{:ok, date, time, _version, _lang} ->
DBStorage.get_post_by_datetime(group_slug, date, time)
:error ->
nil
end
db_post ->
db_post
end
end
end
defp uuid_format?(str) when is_binary(str), do: match?({:ok, _}, UUIDv7.cast(str))
defp uuid_format?(_), do: false
# Reads a post back from DB using the appropriate method for the group mode
defp read_back_post(group_slug, identifier, db_post, language, version_number) do
if db_post && db_post.mode == "timestamp" && db_post.post_date && db_post.post_time do
# Timestamp-mode: use date/time from the DB post
DBStorage.read_post_by_datetime(
group_slug,
db_post.post_date,
db_post.post_time,
language,
version_number
)
else
# Try parsing identifier as timestamp path, fall back to slug
case is_binary(identifier) && parse_timestamp_path(identifier) do
{:ok, date, time, _v, _l} ->
DBStorage.read_post_by_datetime(group_slug, date, time, language, version_number)
_ ->
slug = if db_post, do: db_post.slug, else: identifier
DBStorage.read_post(group_slug, slug, language, version_number)
end
end
end
# Updates a post in the database.
# Writes directly to the database and returns the updated legacy map.
defp update_post_in_db(group_slug, post, params, _audit_meta) do
db_post = find_db_post_for_update(group_slug, post)
if db_post do
if post[:mode] == :timestamp || db_post.mode == "timestamp" do
# Timestamp-mode posts don't have slugs — skip slug validation
do_update_post_in_db(db_post, post, params, group_slug, nil)
else
# Handle slug changes (same validation as FS path)
desired_slug = Map.get(params, "slug", post.slug)
case maybe_update_db_slug(db_post, desired_slug, group_slug) do
{:ok, final_slug} ->
do_update_post_in_db(db_post, post, params, group_slug, final_slug)
{:error, _reason} = error ->
error
end
end
else
{:error, :not_found}
end
rescue
e ->
Logger.warning("[Publishing] update_post_in_db failed: #{inspect(e)}")
{:error, :db_update_failed}
end
# Find the DB post record for update, using UUID, date/time, or slug as available
defp find_db_post_for_update(group_slug, post) do
cond do
# If we have a UUID, use it directly (most reliable)
post[:uuid] ->
DBStorage.get_post_by_uuid(post[:uuid], [:group])
# Timestamp-mode: use date/time
post[:mode] == :timestamp && post[:date] && post[:time] ->
DBStorage.get_post_by_datetime(group_slug, post[:date], post[:time])
# Slug-mode: use slug
post[:slug] ->
DBStorage.get_post(group_slug, post[:slug])
true ->
nil
end
end
defp maybe_update_db_slug(db_post, desired_slug, _group_slug)
when desired_slug == db_post.slug do
{:ok, db_post.slug}
end
defp maybe_update_db_slug(db_post, desired_slug, group_slug) do
with {:ok, valid_slug} <- SlugHelpers.validate_slug(desired_slug),
false <- SlugHelpers.slug_exists?(group_slug, valid_slug),
{:ok, _} <- DBStorage.update_post(db_post, %{slug: valid_slug}) do
{:ok, valid_slug}
else
true -> {:error, :slug_already_exists}
{:error, reason} -> {:error, reason}
end
end
defp do_update_post_in_db(db_post, post, params, group_slug, final_slug) do
version_number = post[:version] || 1
version = DBStorage.get_version(db_post.uuid, version_number)
if version do
language = post[:language] || db_post.primary_language
new_status = Map.get(params, "status", post[:metadata][:status] || "draft")
content = Map.get(params, "content", post[:content] || "")
new_title = resolve_post_title(params, post, content)
# Title is required for primary language when publishing (drafts can be untitled)
if language == db_post.primary_language and new_status == "published" and
new_title in ["", "Untitled"] do
throw({:post_update_failed, :title_required})
end
# Capture old status from DB before updating (editor assigns may already reflect new status)
old_db_status = db_post.status
update_post_level_fields!(db_post, new_status, params)
upsert_post_content(version, language, new_title, content, new_status, params, post)
maybe_propagate_status(version, language, db_post, new_status, old_db_status)
if db_post.mode == "timestamp" do
DBStorage.read_post_by_datetime(
group_slug,
db_post.post_date,
db_post.post_time,
language,
version_number
)
else
DBStorage.read_post(group_slug, final_slug, language, version_number)
end
else
{:error, :not_found}
end
catch
{:post_update_failed, reason} -> {:error, reason}
end
defp resolve_post_title(params, post, content) do
extracted_title = Metadata.extract_title_from_content(content)
Map.get(params, "title") ||
if(extracted_title != "Untitled", do: extracted_title) ||
post[:metadata][:title] ||
"Untitled"
end
defp update_post_level_fields!(db_post, new_status, params) do
case DBStorage.update_post(db_post, %{
status: new_status,
published_at: parse_published_at(params, db_post)
}) do
{:ok, _} -> :ok
{:error, reason} -> throw({:post_update_failed, reason})
end
end
defp upsert_post_content(version, language, new_title, content, new_status, params, post) do
existing_content = DBStorage.get_content(version.uuid, language)
existing_url_slug = if existing_content, do: existing_content.url_slug
existing_data = if existing_content, do: existing_content.data || %{}, else: %{}
resolved_url_slug =
case Map.fetch(params, "url_slug") do
{:ok, val} -> val
:error -> existing_url_slug
end
DBStorage.upsert_content(%{
version_uuid: version.uuid,
language: language,
title: new_title,
content: content,
status: new_status,
url_slug: resolved_url_slug,
data: build_content_data(params, post, existing_data)
})
end
defp maybe_propagate_status(version, language, db_post, new_status, old_db_status) do
is_primary = language == db_post.primary_language
if is_primary and new_status != old_db_status do
propagate_db_status_to_translations(version.uuid, language, new_status)
end
end
# Propagates a status change from the primary language to all other translations
defp update_primary_language_in_db(group_slug, post_identifier, new_primary_language) do
case resolve_db_post(group_slug, post_identifier) do
nil ->
{:error, :post_not_found}
db_post ->
case DBStorage.update_post(db_post, %{primary_language: new_primary_language}) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
rescue
_ -> {:error, :post_not_found}
end
defp resolve_db_version(db_post, nil), do: DBStorage.get_latest_version(db_post.uuid)
defp resolve_db_version(db_post, version_number),
do: DBStorage.get_version(db_post.uuid, version_number)
defp propagate_db_status_to_translations(version_uuid, primary_language, new_status) do
DBStorage.list_contents(version_uuid)
|> Enum.reject(fn c -> c.language == primary_language end)
|> Enum.each(fn c ->
DBStorage.update_content(c, %{status: new_status})
end)
end
defp parse_published_at(params, db_post) do
case Map.get(params, "published_at") do
nil ->
db_post.published_at
"" ->
db_post.published_at
dt_string when is_binary(dt_string) ->
case DateTime.from_iso8601(dt_string) do
{:ok, dt, _} -> dt
_ -> db_post.published_at
end
dt ->
dt
end
end
defp build_content_data(params, post, existing_data) do
# Start from existing data to preserve previous_url_slugs, excerpt, seo_title, etc.
data = existing_data
data =
case Map.get(params, "featured_image_uuid") do
nil -> data
id -> Map.put(data, "featured_image_uuid", id)
end
case Map.get(params, "description", post[:metadata][:description]) do
nil -> data
desc -> Map.put(data, "description", desc)
end
end
@doc """
Updates a post and moves the file if the publication timestamp changes.
"""
@spec update_post(String.t(), map(), map(), map() | keyword()) ::
{:ok, map()} | {:error, any()}
def update_post(group_slug, post, params, opts \\ %{}) do
# Normalize opts to map (callers may pass keyword list or map)
opts_map = if Keyword.keyword?(opts), do: Map.new(opts), else: opts
audit_meta =
opts_map
|> fetch_option(:scope)
|> audit_metadata(:update)
|> Map.put(:is_primary_language, Map.get(opts_map, :is_primary_language, true))
result = update_post_in_db(group_slug, post, params, audit_meta)
with {:ok, updated_post} <- result do
if should_regenerate_cache?(updated_post) do
ListingCache.regenerate(group_slug)
end
PublishingPubSub.broadcast_post_updated(group_slug, updated_post)
end
result
end
@doc """
Creates a new version of a slug-mode post by copying from the latest version.
The new version starts as draft with status: "draft".
Content and metadata updates from params are applied to the new version.
Note: For more control over which version to branch from, use `create_version_from/5`.
"""
@spec create_new_version(String.t(), map(), map(), map() | keyword()) ::
{:ok, map()} | {:error, any()}
def create_new_version(group_slug, source_post, params \\ %{}, opts \\ %{}) do
source_version = source_post[:version] || 1
create_version_in_db(group_slug, source_post.slug, source_version, params, opts)
end
@doc """
Publishes a version, making it the only published version.
- All files in the target version (primary and translations) → `status: "published"`
- All files in other versions that were "published" → `status: "archived"`
- Draft/archived files in other versions keep their current status
## Options
- `:source_id` - ID of the source (e.g., socket.id) to include in broadcasts,
allowing receivers to ignore their own messages
## Examples
iex> Publishing.publish_version("blog", "my-post", 2)
:ok
iex> Publishing.publish_version("blog", "my-post", 2, source_id: "phx-abc123")
:ok
iex> Publishing.publish_version("blog", "nonexistent", 1)
{:error, :not_found}
"""
@spec publish_version(String.t(), String.t(), integer(), keyword()) :: :ok | {:error, any()}
def publish_version(group_slug, post_identifier, version, opts \\ []) do
db_post = resolve_db_post(group_slug, post_identifier)
unless db_post, do: throw({:error, :not_found})
# Set target version to published, archive previously-published versions
for v <- DBStorage.list_versions(db_post.uuid) do
new_status =
cond do
v.version_number == version -> "published"
v.status == "published" -> "archived"
true -> v.status
end
if new_status != v.status, do: DBStorage.update_version(v, %{status: new_status})
end
# Update post status and published_at
DBStorage.update_post(db_post, %{
status: "published",
published_at: db_post.published_at || UtilsDate.utc_now()
})
source_id = Keyword.get(opts, :source_id)
broadcast_id = db_post.slug || db_post.uuid
ListingCache.regenerate(group_slug)
PublishingPubSub.broadcast_version_live_changed(group_slug, broadcast_id, version)
PublishingPubSub.broadcast_post_version_published(
group_slug,
broadcast_id,
version,
source_id
)
:ok
catch
{:error, _} = err -> err
end
@doc """
Creates a new version from an existing version or blank.
## Parameters
* `group_slug` - The publishing group slug
* `post_slug` - The post slug
* `source_version` - Version to copy from, or `nil` for blank version
* `params` - Optional parameters for the new version
* `opts` - Options including `:scope` for audit metadata
## Examples
# Create blank version
iex> Publishing.create_version_from("blog", "my-post", nil, %{}, scope: scope)
{:ok, %{version: 3, ...}}
# Branch from version 1
iex> Publishing.create_version_from("blog", "my-post", 1, %{}, scope: scope)
{:ok, %{version: 3, ...}}
"""
@spec create_version_from(String.t(), String.t(), integer() | nil, map(), map() | keyword()) ::
{:ok, map()} | {:error, any()}
def create_version_from(group_slug, post_slug, source_version, params \\ %{}, opts \\ %{}) do
create_version_in_db(group_slug, post_slug, source_version, params, opts)
end
defp create_version_in_db(group_slug, post_identifier, source_version, _params, opts) do
db_post = resolve_db_post(group_slug, post_identifier)
unless db_post, do: throw({:error, :post_not_found})
scope = fetch_option(opts, :scope)
created_by_uuid = resolve_scope_user_uuids(scope)
user_opts = %{created_by_uuid: created_by_uuid}
result =
if source_version do
DBStorage.create_version_from(db_post.uuid, source_version, user_opts)
else
# Blank version — create empty version with primary language content
with {:ok, db_version} <-
DBStorage.create_version(%{
post_uuid: db_post.uuid,
version_number: DBStorage.next_version_number(db_post.uuid),
status: "draft",
created_by_uuid: created_by_uuid
}),
{:ok, _content} <-
DBStorage.create_content(%{
version_uuid: db_version.uuid,
language: db_post.primary_language,
title: "",
content: "",
status: "draft"
}) do
{:ok, db_version}
end
end
with {:ok, db_version} <- result do
case read_back_post(group_slug, post_identifier, db_post, nil, db_version.version_number) do
{:ok, post} ->
broadcast_id = db_post.slug || db_post.uuid
broadcast_version_created(group_slug, broadcast_id, post)
{:ok, post}
{:error, _} = err ->
err
end
end
catch
{:error, reason} -> {:error, reason}
end
@doc false
def broadcast_version_created(group_slug, post_slug, new_version) do
PublishingPubSub.broadcast_version_created(group_slug, new_version)
version_info = %{
version: new_version[:current_version] || new_version[:version],
available_versions: new_version[:available_versions] || []
}
PublishingPubSub.broadcast_post_version_created(group_slug, post_slug, version_info)
end
@doc """
Sets a translation's status and marks it as manually overridden.
When a translation status is set manually, it will NOT inherit status
changes from the primary language when publishing.
## Examples
iex> Publishing.set_translation_status("blog", "my-post", 2, "es", "draft")
:ok
"""
@spec set_translation_status(String.t(), String.t(), integer(), String.t(), String.t()) ::
:ok | {:error, any()}
def set_translation_status(group_slug, post_slug, version, language, status) do
with db_post when not is_nil(db_post) <- DBStorage.get_post(group_slug, post_slug),
db_version when not is_nil(db_version) <- DBStorage.get_version(db_post.uuid, version),
content when not is_nil(content) <- DBStorage.get_content(db_version.uuid, language) do
case DBStorage.update_content(content, %{status: status}) do
{:ok, _} ->
if status == "published", do: ListingCache.regenerate(group_slug)
:ok
{:error, reason} ->
{:error, reason}
end
else
nil -> {:error, :not_found}
end
end
@doc false
@deprecated "Use publish_version/3 instead"
@spec set_version_live(String.t(), String.t(), integer()) :: :ok | {:error, any()}
def set_version_live(group_slug, post_slug, version) do
publish_version(group_slug, post_slug, version)
end
@doc """
Adds a new language file to an existing post.
For slug-mode groups, accepts an optional version parameter to specify which
version to add the translation to. If not specified, uses the version from
the identifier path (if present) or defaults to the latest version.
"""
@spec add_language_to_post(String.t(), String.t(), String.t(), integer() | nil) ::
{:ok, map()} | {:error, any()}
def add_language_to_post(group_slug, identifier, language_code, version \\ nil) do
post_slug = extract_slug_from_identifier(group_slug, identifier)
result = add_language_to_db(group_slug, post_slug, language_code, version)
with {:ok, new_post} <- result do
if should_regenerate_cache?(new_post) do
ListingCache.regenerate(group_slug)
end
if new_post.slug do
PublishingPubSub.broadcast_translation_created(group_slug, new_post.slug, language_code)
end
end
result
end
defp extract_slug_from_identifier(group_slug, identifier) do
case get_group_mode(group_slug) do
"slug" ->
{post_slug, _version, _language} =
extract_slug_version_and_language(group_slug, identifier)
post_slug
_ ->
# For timestamp mode, identifier might be the slug directly
identifier
end
end
@doc """
Moves a post to the trash folder.
For slug-mode groups, provide the post slug.
For timestamp-mode groups, provide the date/time path (e.g., "2025-01-15/14:30").
Returns {:ok, trash_path} on success or {:error, reason} on failure.
"""
@spec trash_post(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()}
def trash_post(group_slug, post_identifier) do
case resolve_db_post(group_slug, post_identifier) do
nil ->
{:error, :not_found}
db_post ->
case DBStorage.soft_delete_post(db_post) do
{:ok, _} ->
broadcast_id = db_post.slug || db_post.uuid
ListingCache.regenerate(group_slug)
PublishingPubSub.broadcast_post_deleted(group_slug, broadcast_id)
{:ok, post_identifier}
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Deletes a specific language translation from a post.
For versioned posts, specify the version. For legacy posts, version is ignored.
Refuses to delete the last remaining language file.
Returns :ok on success or {:error, reason} on failure.
"""
@spec delete_language(String.t(), String.t(), String.t(), integer() | nil) ::
:ok | {:error, term()}
def delete_language(group_slug, post_identifier, language_code, version \\ nil) do
with db_post when not is_nil(db_post) <- resolve_db_post(group_slug, post_identifier),
db_version when not is_nil(db_version) <- resolve_db_version(db_post, version),
content when not is_nil(content) <- DBStorage.get_content(db_version.uuid, language_code) do
# Don't delete the last active language
active =
DBStorage.list_contents(db_version.uuid)
|> Enum.reject(&(&1.status == "archived"))
if length(active) <= 1, do: throw({:error, :last_language})
case DBStorage.update_content(content, %{status: "archived"}) do
{:ok, _} ->
ListingCache.regenerate(group_slug)
PublishingPubSub.broadcast_translation_deleted(
group_slug,
post_identifier,
language_code
)
:ok
{:error, reason} ->
{:error, reason}
end
else
nil -> {:error, :not_found}
end
catch
{:error, _} = err -> err
end
@doc """
Deletes an entire version of a post.
Moves the version folder to trash instead of permanent deletion.
Refuses to delete the last remaining version or the live version.
Returns :ok on success or {:error, reason} on failure.
"""
@spec delete_version(String.t(), String.t(), integer()) :: :ok | {:error, term()}
def delete_version(group_slug, post_identifier, version) do
with db_post when not is_nil(db_post) <- resolve_db_post(group_slug, post_identifier),
db_version when not is_nil(db_version) <- DBStorage.get_version(db_post.uuid, version) do
if db_version.status == "published", do: throw({:error, :cannot_delete_live})
active =
DBStorage.list_versions(db_post.uuid)
|> Enum.reject(&(&1.status == "archived"))
if length(active) <= 1, do: throw({:error, :last_version})
case DBStorage.update_version(db_version, %{status: "archived"}) do
{:ok, _} ->
ListingCache.regenerate(group_slug)
PublishingPubSub.broadcast_version_deleted(group_slug, post_identifier, version)
PublishingPubSub.broadcast_post_version_deleted(group_slug, post_identifier, version)
:ok
{:error, reason} ->
{:error, reason}
end
else
nil -> {:error, :not_found}
end
catch
{:error, _} = err -> err
end
# Legacy wrappers (deprecated)
def list_entries(group_slug, preferred_language \\ nil),
do: list_posts(group_slug, preferred_language)
def create_entry(group_slug), do: create_post(group_slug)
def read_entry(group_slug, relative_path), do: read_post(group_slug, relative_path)
def update_entry(group_slug, post, params), do: update_post(group_slug, post, params)
def add_language_to_entry(group_slug, post_path, language_code),
do: add_language_to_post(group_slug, post_path, language_code)
@doc """
Generates a slug from a user-provided group name.
Returns empty string if the name contains only invalid characters.
"""
@spec slugify(String.t()) :: String.t()
def slugify(name) when is_binary(name) do
name
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
end
@doc """
Returns true when the slug matches the allowed lowercase letters, numbers, and hyphen pattern,
and is not a reserved language code.
Group slugs cannot be language codes (like 'en', 'es', 'fr') to prevent routing ambiguity.
"""
@spec valid_slug?(String.t()) :: boolean()
def valid_slug?(slug) when is_binary(slug) do
slug != "" and Regex.match?(@slug_regex, slug) and not reserved_language_code?(slug)
end
def valid_slug?(_), do: false
# Check if slug is a reserved language code
# We check against all available language codes from the language system
defp reserved_language_code?(slug) do
# Get all available language codes dynamically from the language module
language_codes =
try do
Languages.get_language_codes()
rescue
_ -> []
end
slug in language_codes
end
# Determines if a post update should trigger cache regeneration.
# For versioned posts (slug mode with version info), only regenerate if the post is published.
# For non-versioned posts (timestamp mode or legacy), always regenerate.
defp should_regenerate_cache?(post) do
mode = Map.get(post, :mode)
metadata = Map.get(post, :metadata, %{})
status = Map.get(metadata, :status)
version = Map.get(metadata, :version) || Map.get(post, :version)
cond do
# Timestamp mode posts always regenerate (no versioning)
mode == :timestamp -> true
# Slug mode posts without version info (legacy) always regenerate
is_nil(version) -> true
# Slug mode posts: always regenerate to keep language_slugs current
# The cache stores url_slugs for ALL translations, so any edit
# (even to drafts) should update the cache for URL generation
mode == :slug -> true
# Published posts always regenerate
status == "published" -> true
# Fallback: don't regenerate for unknown modes with non-published status
true -> false
end
end
defp settings_module do
# Check new key first, fall back to legacy key for backward compatibility
case PhoenixKit.Config.get(:publishing_settings_module) do
:not_found -> PhoenixKit.Config.get(:blogging_settings_module, PhoenixKit.Settings)
{:ok, module} -> module
end
end
defp settings_call(fun, args) do
module = settings_module()
# For cached functions, fall back to uncached if custom module doesn't implement them
case fun do
:get_json_setting_cached ->
if function_exported?(module, :get_json_setting_cached, length(args)) do
apply(module, :get_json_setting_cached, args)
else
apply(module, :get_json_setting, args)
end
_ ->
apply(module, fun, args)
end
end
defp normalize_groups(groups) do
groups
|> Enum.map(&normalize_group_keys/1)
|> Enum.map(fn group ->
group
|> ensure_mode()
|> ensure_type()
end)
end
defp ensure_mode(%{"mode" => mode} = group) when mode in ["timestamp", "slug"], do: group
defp ensure_mode(group), do: Map.put(group, "mode", @default_group_mode)
# Ensure type field exists, defaulting to "blogging" for backward compatibility
# Also ensures item_singular and item_plural are set based on the type
defp ensure_type(group) do
type = Map.get(group, "type")
type = if is_binary(type) and type != "", do: type, else: @default_group_type
{default_singular, default_plural} =
Map.get(@type_item_names, type, {@default_item_singular, @default_item_plural})
group
|> Map.put("type", type)
|> Map.put_new("item_singular", default_singular)
|> Map.put_new("item_plural", default_plural)
end
defp normalize_group_keys(group) when is_map(group) do
Enum.reduce(group, %{}, fn
{key, value}, acc when is_binary(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_atom(key) ->
Map.put(acc, Atom.to_string(key), value)
{key, value}, acc ->
Map.put(acc, to_string(key), value)
end)
end
defp normalize_group_keys(other), do: other
defp normalize_mode(mode) when is_binary(mode) do
mode
|> String.downcase()
|> case do
"slug" -> "slug"
"timestamp" -> "timestamp"
_ -> nil
end
end
defp normalize_mode(mode) when is_atom(mode), do: normalize_mode(Atom.to_string(mode))
defp normalize_mode(_), do: nil
# Normalize mode with default fallback
defp normalize_mode_with_default(nil), do: @default_group_mode
defp normalize_mode_with_default(mode), do: normalize_mode(mode) || @default_group_mode
# Normalize and validate type
# Preset types are passed through, custom types are validated and normalized
defp normalize_type(nil), do: @default_group_type
defp normalize_type(type) when is_binary(type) do
trimmed = String.trim(type)
downcased = String.downcase(trimmed)
cond do
# Preset type - pass through as-is
downcased in @preset_types ->
downcased
# Empty after trim - use default
trimmed == "" ->
@default_group_type
# Custom type - validate format
true ->
# Normalize: downcase, replace spaces/underscores with hyphens
normalized =
downcased
|> String.replace(~r/[\s_]+/, "-")
|> String.replace(~r/[^a-z0-9-]/, "")
|> String.slice(0, 32)
# Validate against type regex
if Regex.match?(@type_regex, normalized) do
normalized
else
nil
end
end
end
defp normalize_type(type) when is_atom(type), do: normalize_type(Atom.to_string(type))
defp normalize_type(_), do: nil
# Get default item names for a type
defp default_item_names(type) do
Map.get(@type_item_names, type, {@default_item_singular, @default_item_plural})
end
# Normalize item name, using default if nil/empty
defp normalize_item_name(nil, default), do: default
defp normalize_item_name("", default), do: default
defp normalize_item_name(name, default) when is_binary(name) do
trimmed = String.trim(name)
if trimmed == "", do: default, else: trimmed
end
defp normalize_item_name(_, default), do: default
@doc """
Returns the preset content types with their default item names.
## Examples
iex> Publishing.preset_types()
[
%{type: "blogging", label: "Blog", item_singular: "post", item_plural: "posts"},
%{type: "faq", label: "FAQ", item_singular: "question", item_plural: "questions"},
%{type: "legal", label: "Legal", item_singular: "document", item_plural: "documents"}
]
"""
@spec preset_types() :: [map()]
def preset_types do
[
# Note: type value "blogging" kept for backward compatibility, label is "Blog"
%{type: "blogging", label: "Blog", item_singular: "post", item_plural: "posts"},
%{type: "faq", label: "FAQ", item_singular: "question", item_plural: "questions"},
%{type: "legal", label: "Legal", item_singular: "document", item_plural: "documents"}
]
end
@doc false
def fetch_option(opts, key) when is_map(opts) do
Map.get(opts, key) || Map.get(opts, Atom.to_string(key))
end
@doc false
def fetch_option(opts, key) when is_list(opts) do
if Keyword.keyword?(opts) do
Keyword.get(opts, key)
else
nil
end
end
@doc false
def fetch_option(_, _), do: nil
@doc false
def audit_metadata(nil, _action), do: %{}
@doc false
def audit_metadata(scope, action) do
user_uuid =
scope
|> Scope.user_uuid()
|> normalize_audit_value()
user_email =
scope
|> Scope.user_email()
|> normalize_audit_value()
base =
case action do
:create ->
%{
created_by_uuid: user_uuid,
created_by_email: user_email
}
_ ->
%{}
end
base
|> maybe_put_audit(:updated_by_uuid, user_uuid)
|> maybe_put_audit(:updated_by_email, user_email)
end
defp normalize_audit_value(nil), do: nil
defp normalize_audit_value(value) when is_binary(value), do: String.trim(value)
defp normalize_audit_value(value), do: to_string(value)
defp maybe_put_audit(map, _key, nil), do: map
defp maybe_put_audit(map, key, value), do: Map.put(map, key, value)
defp persist_group_update(groups, slug, updated_group) do
updated =
Enum.map(groups, fn
%{"slug" => ^slug} -> updated_group
other -> other
end)
settings_call(:update_json_setting, [
@publishing_groups_key,
%{"publishing_groups" => updated}
])
end
defp derive_requested_slug(nil, fallback_name) do
slugified = slugify(fallback_name)
if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified}
end
defp derive_requested_slug(slug, fallback_name) when is_binary(slug) do
trimmed = slug |> String.trim()
cond do
trimmed == "" ->
slugified = slugify(fallback_name)
if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified}
valid_slug?(trimmed) ->
{:ok, trimmed}
true ->
{:error, :invalid_slug}
end
end
defp derive_requested_slug(_other, fallback_name) do
slugified = slugify(fallback_name)
if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified}
end
# Check if explicit slug already exists (only when preferred_slug is provided)
defp check_slug_availability(slug, groups, preferred_slug) when not is_nil(preferred_slug) do
if Enum.any?(groups, &(&1["slug"] == slug)) do
{:error, :already_exists}
else
:ok
end
end
defp check_slug_availability(_slug, _groups, nil), do: :ok
defp ensure_unique_slug(slug, groups), do: ensure_unique_slug(slug, groups, 2)
defp ensure_unique_slug(slug, groups, counter) do
if Enum.any?(groups, &(&1["slug"] == slug)) do
ensure_unique_slug("#{slug}-#{counter}", groups, counter + 1)
else
slug
end
end
# Extract slug, version, and language from a path identifier
# Handles paths like:
# - "post-slug" → {"post-slug", nil, nil}
# - "post-slug/en" → {"post-slug", nil, "en"}
# - "post-slug/v1/en" → {"post-slug", 1, "en"}
# - "group/post-slug/v2/am" → {"post-slug", 2, "am"}
@doc false
def extract_slug_version_and_language(_group_slug, nil), do: {"", nil, nil}
@doc false
def extract_slug_version_and_language(group_slug, identifier) do
parts =
identifier
|> to_string()
|> String.trim()
|> String.trim_leading("/")
|> String.split("/", trim: true)
|> drop_group_prefix(group_slug)
case parts do
[] ->
{"", nil, nil}
[slug] ->
{slug, nil, nil}
[slug | rest] ->
# Extract version if present (v1, v2, v3, etc.)
{version, rest_after_version} = extract_version_from_parts(rest)
# Extract language from remaining parts
language =
rest_after_version
|> List.first()
|> case do
nil -> nil
<<>> -> nil
lang_file -> lang_file
end
{slug, version, language}
end
end
# Extract version number from path parts if present
# Returns {version_integer | nil, remaining_parts}
defp extract_version_from_parts([]), do: {nil, []}
defp extract_version_from_parts([first | rest] = parts) do
case parse_version_directory(first) do
{:ok, version} -> {version, rest}
:error -> {nil, parts}
end
end
# Parse a version directory like "v1", "v2", etc. to an integer
defp parse_version_directory(segment) when is_binary(segment) do
case Regex.run(~r/^v(\d+)$/, segment) do
[_, num_str] -> {:ok, String.to_integer(num_str)}
nil -> :error
end
end
defp parse_version_directory(_), do: :error
# Only drop group prefix if there are more elements after it
# This prevents dropping the post slug when it matches the group slug
defp drop_group_prefix([group_slug | rest], group_slug) when rest != [], do: rest
defp drop_group_prefix(list, _), do: list
# ============================================================================
# AI Translation
# ============================================================================
alias PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker
@doc """
Enqueues an Oban job to translate a post to all enabled languages using AI.
This creates a background job that will:
1. Read the source post in the primary language
2. Translate the content to each target language using the AI module
3. Create or update translation files for each language
## Options
- `:endpoint_uuid` - AI endpoint UUID to use for translation (required if not set in settings)
- `:source_language` - Source language to translate from (defaults to primary language)
- `:target_languages` - List of target language codes (defaults to all enabled except source)
- `:version` - Version number to translate (defaults to latest/published)
- `:user_uuid` - User UUID for audit trail
## Configuration
Set the default AI endpoint for translations:
PhoenixKit.Settings.update_setting("publishing_translation_endpoint_uuid", "endpoint-uuid")
## Examples
# Translate to all enabled languages using default endpoint
{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started")
# Translate with specific endpoint
{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started",
endpoint_uuid: "endpoint-uuid"
)
# Translate to specific languages only
{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started",
endpoint_uuid: "endpoint-uuid",
target_languages: ["es", "fr", "de"]
)
## Returns
- `{:ok, %Oban.Job{}}` - Job was successfully enqueued
- `{:error, changeset}` - Failed to enqueue job
"""
@spec translate_post_to_all_languages(String.t(), String.t(), keyword()) ::
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
def translate_post_to_all_languages(group_slug, post_slug, opts \\ []) do
TranslatePostWorker.enqueue(group_slug, post_slug, opts)
end
# ============================================================================
# Backward compatibility aliases (deprecated)
# These functions delegate to the new "group" terminology functions
# ============================================================================
@doc false
@deprecated "Use list_groups/0 instead"
def list_blogs, do: list_groups()
@doc false
@deprecated "Use get_group/1 instead"
def get_blog(slug), do: get_group(slug)
@doc false
@deprecated "Use add_group/2 instead"
def add_blog(name, opts \\ []), do: add_group(name, opts)
@doc false
@deprecated "Use remove_group/1 instead"
def remove_blog(slug), do: remove_group(slug)
@doc false
@deprecated "Use update_group/2 instead"
def update_blog(slug, params), do: update_group(slug, params)
@doc false
@deprecated "Use trash_group/1 instead"
def trash_blog(slug), do: trash_group(slug)
@doc false
@deprecated "Use group_name/1 instead"
def blog_name(slug), do: group_name(slug)
@doc false
@deprecated "Use get_group_mode/1 instead"
def get_blog_mode(group_slug), do: get_group_mode(group_slug)
@doc false
@deprecated "Use legacy_group?/1 instead"
def legacy_blog?(group_slug), do: legacy_group?(group_slug)
@doc false
@deprecated "Use has_legacy_groups?/0 instead"
def has_legacy_blogs?, do: has_legacy_groups?()
end