Packages

phoenix_kit

1.7.79
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 translation_manager.ex
Raw

lib/modules/publishing/translation_manager.ex

defmodule PhoenixKit.Modules.Publishing.TranslationManager do
@moduledoc """
Language and translation management for the Publishing module.
Handles primary language detection, migration, adding/removing languages,
setting translation statuses, and AI-powered translation.
"""
require Logger
alias PhoenixKit.Modules.Publishing.Constants
alias PhoenixKit.Modules.Publishing.DBStorage
@content_statuses Constants.content_statuses()
alias PhoenixKit.Modules.Publishing.LanguageHelpers
alias PhoenixKit.Modules.Publishing.ListingCache
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Modules.Publishing.Shared
alias PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker
@doc "Gets the primary language for a specific post from the database."
def get_post_primary_language(group_slug, post_slug, _version \\ nil) do
db_post =
if Shared.uuid_format?(post_slug) do
DBStorage.get_post_by_uuid(post_slug)
else
DBStorage.get_post(group_slug, post_slug)
end
case db_post do
nil -> LanguageHelpers.get_primary_language()
post -> post.primary_language || LanguageHelpers.get_primary_language()
end
rescue
e ->
Logger.warning(
"[Publishing] get_post_primary_language failed for #{group_slug}/#{post_slug}: #{inspect(e)}"
)
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
e ->
Logger.warning(
"[Publishing] check_primary_language_status failed for #{group_slug}/#{post_slug}: #{inspect(e)}"
)
{:needs_backfill, nil}
end
@doc """
Updates the primary language for a post.
Accepts a post UUID.
"""
def update_post_primary_language(_group_slug, post_uuid, new_primary_language) do
update_primary_language_in_db(post_uuid, new_primary_language)
end
@doc """
Updates all posts in a group to use the current global primary language.
Single bulk UPDATE query — runs in milliseconds. Regenerates cache after.
Returns `{:ok, count}` with the number of posts updated.
"""
@spec update_posts_primary_language(String.t()) :: {:ok, integer()}
def update_posts_primary_language(group_slug) do
primary_language = LanguageHelpers.get_primary_language()
{:ok, count} = DBStorage.update_primary_language(group_slug, primary_language)
if count > 0 do
Logger.info(
"[Publishing] Updated #{count} posts in #{group_slug} to primary language #{primary_language}"
)
ListingCache.regenerate(group_slug)
PublishingPubSub.broadcast_primary_language_migration_completed(
group_slug,
count,
0,
primary_language
)
end
{:ok, count}
end
@doc "Counts posts in a group that don't match the current primary language."
@spec count_posts_needing_language_update(String.t()) :: integer()
def count_posts_needing_language_update(group_slug) do
primary_language = LanguageHelpers.get_primary_language()
DBStorage.count_posts_needing_language_update(group_slug, primary_language)
end
@doc """
Adds a new language translation to an existing post.
Accepts an optional version parameter to specify which version to add
the translation to. If not specified, 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, post_uuid, language_code, version \\ nil) do
result = add_language_to_db(group_slug, post_uuid, language_code, version)
with {:ok, new_post} <- result do
ListingCache.regenerate(group_slug)
broadcast_id = new_post.slug || new_post.uuid
if broadcast_id do
PublishingPubSub.broadcast_translation_created(group_slug, broadcast_id, language_code)
end
end
result
end
# Adds a language to a post.
# Creates a new content row in the database and returns the post map.
@doc false
def add_language_to_db(group_slug, post_uuid, language_code, version_number) do
with db_post when not is_nil(db_post) <- DBStorage.get_post_by_uuid(post_uuid, [:group]),
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: Constants.default_title(),
content: "",
status: "draft"
}) do
# Read the post back from DB to return a proper post map
Shared.read_back_post(group_slug, post_uuid, db_post, language_code, version.version_number)
else
nil ->
{:error, :not_found}
%PhoenixKit.Modules.Publishing.PublishingContent{} = _existing ->
# Content already exists for this language - read back the post
db_post = DBStorage.get_post_by_uuid(post_uuid, [:group])
resolved_version =
if version_number do
version_number
else
case db_post && DBStorage.get_latest_version(db_post.uuid) do
nil -> nil
v -> v.version_number
end
end
Shared.read_back_post(group_slug, post_uuid, db_post, language_code, resolved_version)
{:error, reason} ->
{:error, reason}
end
rescue
e in [Ecto.QueryError, DBConnection.ConnectionError] ->
Logger.warning(
"[Publishing] add_language_to_db failed for #{group_slug}/#{post_uuid}/#{language_code}: #{inspect(e)}"
)
{:error, :not_found}
end
@doc """
Hard-deletes a language's content row from a post.
Unlike `delete_language` (which archives), this permanently removes the content.
Refuses to delete the last remaining language.
"""
@spec clear_translation(String.t(), String.t(), String.t()) :: :ok | {:error, term()}
def clear_translation(group_slug, post_uuid, language_code) do
with db_post when not is_nil(db_post) <- DBStorage.get_post_by_uuid(post_uuid, [:group]),
db_version when not is_nil(db_version) <- Shared.resolve_db_version(db_post, nil),
content when not is_nil(content) <-
DBStorage.get_content(db_version.uuid, language_code),
:ok <- validate_not_last_content(db_version, language_code) do
repo = PhoenixKit.RepoHelper.repo()
case repo.delete(content) do
{:ok, _} ->
broadcast_id = db_post.slug || db_post.uuid
ListingCache.regenerate(group_slug)
PublishingPubSub.broadcast_translation_deleted(group_slug, broadcast_id, language_code)
:ok
{:error, reason} ->
{:error, reason}
end
else
nil -> {:error, :not_found}
{:error, _} = err -> err
end
end
defp validate_not_last_content(db_version, language_code) do
remaining =
DBStorage.list_contents(db_version.uuid)
|> Enum.reject(&(&1.language == language_code))
if remaining == [], do: {:error, :last_language}, else: :ok
end
@doc """
Deletes a specific language translation from a post.
For versioned posts, specify the version. For unversioned posts, version is ignored.
Refuses to delete the last remaining language content.
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_uuid, language_code, version \\ nil) do
with db_post when not is_nil(db_post) <- DBStorage.get_post_by_uuid(post_uuid, [:group]),
db_version when not is_nil(db_version) <- Shared.resolve_db_version(db_post, version),
content when not is_nil(content) <-
DBStorage.get_content(db_version.uuid, language_code),
:ok <- validate_not_last_language(db_version) do
case DBStorage.update_content(content, %{status: "archived"}) do
{:ok, _} ->
broadcast_id = db_post.slug || db_post.uuid
ListingCache.regenerate(group_slug)
PublishingPubSub.broadcast_translation_deleted(group_slug, broadcast_id, language_code)
:ok
{:error, reason} ->
{:error, reason}
end
else
nil -> {:error, :not_found}
{:error, _} = err -> err
end
end
defp validate_not_last_language(db_version) do
active =
DBStorage.list_contents(db_version.uuid)
|> Enum.reject(&(&1.status == "archived"))
if length(active) <= 1, do: {:error, :last_language}, else: :ok
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.
Accepts a post UUID or slug as the post identifier.
## Examples
iex> Publishing.set_translation_status("blog", "019cce93-...", 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_identifier, version, language, status)
when status in @content_statuses do
db_post =
if Shared.uuid_format?(post_identifier) do
DBStorage.get_post_by_uuid(post_identifier)
else
DBStorage.get_post(group_slug, post_identifier)
end
with db_post when not is_nil(db_post) <- db_post,
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),
:ok <- validate_translation_status_change(db_post, db_version, language, status) do
case DBStorage.update_content(content, %{status: status}) do
{:ok, _} ->
ListingCache.regenerate(group_slug)
broadcast_id = db_post.slug || db_post.uuid
PublishingPubSub.broadcast_post_updated(group_slug, %{slug: broadcast_id})
:ok
{:error, reason} ->
{:error, reason}
end
else
nil -> {:error, :not_found}
{:error, _} = error -> error
end
end
def set_translation_status(_group_slug, _post_identifier, _version, _language, _status) do
{:error, :invalid_status}
end
# Prevents publishing a translation when the primary language content isn't published.
# This avoids the contradiction where set_translation_status allows publishing but
# fix_translation_status_consistency (stale fixer) silently reverts it.
defp validate_translation_status_change(_db_post, _db_version, _language, status)
when status != "published",
do: :ok
defp validate_translation_status_change(db_post, db_version, language, "published") do
if language == db_post.primary_language do
:ok
else
case DBStorage.get_content(db_version.uuid, db_post.primary_language) do
nil ->
{:error, :primary_not_published}
primary ->
if primary.status == "published", do: :ok, else: {:error, :primary_not_published}
end
end
end
@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 content 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", "019cce93-...")
# Translate with specific endpoint
{:ok, job} = Publishing.translate_post_to_all_languages("docs", "019cce93-...",
endpoint_uuid: "endpoint-uuid"
)
# Translate to specific languages only
{:ok, job} = Publishing.translate_post_to_all_languages("docs", "019cce93-...",
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_uuid, opts \\ []) do
TranslatePostWorker.enqueue(group_slug, post_uuid, opts)
end
# ============================================================================
# Private Helpers
# ============================================================================
defp update_primary_language_in_db(post_uuid, new_primary_language) do
case DBStorage.get_post_by_uuid(post_uuid) 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
e ->
Logger.warning(
"[Publishing] update_primary_language_in_db failed for #{post_uuid}: #{inspect(e)}"
)
{:error, :post_not_found}
end
end