Packages
phoenix_kit
1.7.55
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
Current section
Files
lib/modules/publishing/web/editor/translation.ex
defmodule PhoenixKit.Modules.Publishing.Web.Editor.Translation do
@moduledoc """
AI translation functionality for the publishing editor.
Handles translation workflow, Oban job enqueuing, and
translation progress tracking.
"""
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Modules.AI
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.Storage
alias PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker
alias PhoenixKit.Settings
# ============================================================================
# Availability Checks
# ============================================================================
@doc """
Checks if AI translation is available (AI module enabled + endpoints configured).
"""
def ai_translation_available? do
AI.enabled?() and list_ai_endpoints() != []
end
@doc """
Lists available AI endpoints for translation.
"""
def list_ai_endpoints do
if AI.enabled?() do
case AI.list_endpoints(enabled: true) do
# Use UUID for dropdown values (stable across systems, matches settings storage)
{endpoints, _total} -> Enum.map(endpoints, &{&1.uuid, &1.name})
endpoints when is_list(endpoints) -> Enum.map(endpoints, &{&1.uuid, &1.name})
_ -> []
end
else
[]
end
end
@doc """
Gets the default AI endpoint ID from settings.
"""
def get_default_ai_endpoint_id do
# endpoint_id can be UUID or integer - AI module handles both
case Settings.get_setting("publishing_translation_endpoint_id") do
nil -> nil
"" -> nil
id -> id
end
end
# ============================================================================
# Target Language Resolution
# ============================================================================
@doc """
Gets target languages for translation (missing languages only).
"""
def get_target_languages_for_translation(socket) do
post = socket.assigns.post
# Use post's stored primary language for translation source
primary_language = post[:primary_language] || Storage.get_primary_language()
available_languages = post.available_languages || []
Storage.enabled_language_codes()
|> Enum.reject(&(&1 == primary_language or &1 in available_languages))
end
@doc """
Gets all target languages for translation (all except primary).
"""
def get_all_target_languages(socket) do
post = socket.assigns.post
# Use post's stored primary language to exclude from targets
primary_language = post[:primary_language] || Storage.get_primary_language()
Storage.enabled_language_codes()
|> Enum.reject(&(&1 == primary_language))
end
# ============================================================================
# Translation Enqueuing
# ============================================================================
@doc """
Enqueues translation job with validation and warnings.
Returns {:noreply, socket} for use in handle_event.
"""
def enqueue_translation(socket, target_languages, {empty_level, empty_message}) do
cond do
socket.assigns.is_new_post ->
{:noreply,
Phoenix.LiveView.put_flash(
socket,
:error,
gettext("Please save the post first before translating")
)}
is_nil(socket.assigns.ai_selected_endpoint_id) ->
{:noreply,
Phoenix.LiveView.put_flash(socket, :error, gettext("Please select an AI endpoint"))}
target_languages == [] ->
{:noreply, Phoenix.LiveView.put_flash(socket, empty_level, empty_message)}
true ->
# Build list of warnings for the confirmation modal
warnings = build_translation_warnings(socket, target_languages)
if warnings == [] do
# No warnings - proceed directly
do_enqueue_translation(socket, target_languages)
else
# Show confirmation modal with warnings
{:noreply,
socket
|> Phoenix.Component.assign(:show_translation_confirm, true)
|> Phoenix.Component.assign(:pending_translation_languages, target_languages)
|> Phoenix.Component.assign(:translation_warnings, warnings)}
end
end
end
@doc """
Actually enqueues the translation job (after confirmation if needed).
"""
def do_enqueue_translation(socket, target_languages) do
user = socket.assigns[:phoenix_kit_current_scope]
user_uuid = if user, do: user.user.uuid, else: nil
post = socket.assigns.post
# Get the source language from the post's stored primary_language
source_language =
post[:primary_language] ||
socket.assigns[:current_language] ||
Storage.get_primary_language()
# For timestamp mode, use date/time identifier; for slug mode, use slug
post_identifier =
case post.mode do
:timestamp -> extract_timestamp_identifier(post.path)
_ -> post.slug
end
case TranslatePostWorker.enqueue(
socket.assigns.group_slug,
post_identifier,
endpoint_id: socket.assigns.ai_selected_endpoint_id,
version: socket.assigns.current_version,
user_uuid: user_uuid,
target_languages: target_languages,
source_language: source_language
) do
{:ok, %{conflict?: true}} ->
# Job already exists for this post
{:noreply,
Phoenix.LiveView.put_flash(
socket,
:info,
gettext("A translation job is already running for this post")
)}
{:ok, _job} ->
{:noreply, translation_success_socket(socket, target_languages)}
{:error, _reason} ->
{:noreply, translation_error_socket(socket)}
end
end
defp translation_success_socket(socket, target_languages) do
lang_names =
Enum.map_join(target_languages, ", ", fn code ->
info = Storage.get_language_info(code)
if info, do: info.name, else: code
end)
socket
|> Phoenix.Component.assign(:ai_translation_status, :enqueued)
|> Phoenix.LiveView.put_flash(
:info,
gettext("Translation job enqueued for: %{languages}", languages: lang_names)
)
end
defp translation_error_socket(socket) do
socket
|> Phoenix.Component.assign(:ai_translation_status, :error)
|> Phoenix.LiveView.put_flash(:error, gettext("Failed to enqueue translation job"))
end
# ============================================================================
# Translation Warnings
# ============================================================================
@doc """
Builds warnings for the translation confirmation modal.
"""
def build_translation_warnings(socket, target_languages) do
warnings = []
# Check if source content is blank
warnings =
if source_content_blank?(socket) do
[
{:warning,
gettext("The source content is empty. This will create empty translation files.")}
| warnings
]
else
warnings
end
# Check if any target languages have existing content that will be overwritten
existing_languages = get_existing_translation_languages(socket, target_languages)
warnings =
if existing_languages != [] do
lang_names = format_language_names(existing_languages)
[
{:warning,
gettext("This will overwrite existing content in: %{languages}",
languages: lang_names
)}
| warnings
]
else
warnings
end
Enum.reverse(warnings)
end
defp get_existing_translation_languages(socket, target_languages) do
post = socket.assigns.post
available = post.available_languages || []
Enum.filter(target_languages, fn lang -> lang in available end)
end
defp format_language_names(language_codes) do
Enum.map_join(language_codes, ", ", fn code ->
info = Storage.get_language_info(code)
if info, do: info.name, else: code
end)
end
@doc """
Checks if the source content is blank.
"""
def source_content_blank?(socket) do
post = socket.assigns.post
group_slug = socket.assigns.group_slug
source_language =
post[:primary_language] ||
socket.assigns[:current_language] ||
Storage.get_primary_language()
current_version = socket.assigns[:current_version]
# If we're on the primary language, check current content
if socket.assigns[:current_language] == source_language do
content = socket.assigns.content || ""
String.trim(content) == ""
else
# For timestamp mode, use date/time identifier; for slug mode, use slug
post_identifier =
case post.mode do
:timestamp -> extract_timestamp_identifier(post.path)
_ -> post.slug
end
# Read the source language content from disk
case Publishing.read_post(group_slug, post_identifier, source_language, current_version) do
{:ok, source_post} ->
content = source_post.content || ""
String.trim(content) == ""
{:error, _} ->
# Can't read source - assume it's blank to be safe
true
end
end
end
# ============================================================================
# Translation to Current Language
# ============================================================================
@doc """
Starts translation to the current (non-primary) language.
"""
def start_translation_to_current(socket) do
cond do
socket.assigns.is_new_post ->
{:noreply,
Phoenix.LiveView.put_flash(
socket,
:error,
gettext("Please save the post first before translating")
)}
is_nil(socket.assigns.ai_selected_endpoint_id) ->
{:noreply,
Phoenix.LiveView.put_flash(socket, :error, gettext("Please select an AI endpoint"))}
true ->
target_language = socket.assigns.current_language
# Enqueue as Oban job with single target language
enqueue_translation(socket, [target_language], {:info, nil})
end
end
# ============================================================================
# Helpers
# ============================================================================
@doc """
Clears completed translation status when switching languages.
"""
def maybe_clear_completed_translation_status(socket) do
if socket.assigns[:ai_translation_status] == :completed do
socket
|> Phoenix.Component.assign(:ai_translation_status, nil)
|> Phoenix.Component.assign(:ai_translation_progress, nil)
|> Phoenix.Component.assign(:ai_translation_total, nil)
|> Phoenix.Component.assign(:ai_translation_languages, [])
else
socket
end
end
# Extract timestamp identifier (YYYY-MM-DD/HH:MM) from a post path
defp extract_timestamp_identifier(path) when is_binary(path) do
case Regex.run(~r/(\d{4}-\d{2}-\d{2}\/\d{2}:\d{2})/, path) do
[_, timestamp] -> timestamp
nil -> nil
end
end
defp extract_timestamp_identifier(_), do: nil
end