Packages
phoenix_kit
1.7.78
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.PresenceHelpers
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker
alias PhoenixKit.Settings
@translation_prompt_slug "translate-publishing-posts"
# ============================================================================
# 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 """
Lists available AI prompts for translation.
"""
def list_ai_prompts do
if AI.enabled?() do
case AI.list_prompts(enabled: true) do
{prompts, _total} -> Enum.map(prompts, &{&1.uuid, &1.name})
prompts when is_list(prompts) -> Enum.map(prompts, &{&1.uuid, &1.name})
_ -> []
end
else
[]
end
end
@doc """
Gets the default AI endpoint UUID from settings.
"""
def get_default_ai_endpoint_uuid do
case Settings.get_setting("publishing_translation_endpoint_uuid") do
nil -> nil
"" -> nil
id -> id
end
end
@doc """
Gets the default AI prompt UUID for translation from settings.
"""
def get_default_ai_prompt_uuid do
case Settings.get_setting("publishing_translation_prompt_uuid") do
nil -> fallback_prompt_uuid()
"" -> fallback_prompt_uuid()
id -> id
end
end
defp fallback_prompt_uuid do
if AI.enabled?() do
case AI.get_prompt_by_slug(@translation_prompt_slug) do
nil -> nil
prompt -> prompt.uuid
end
else
nil
end
end
@doc """
Checks if the default translation prompt already exists.
"""
def default_translation_prompt_exists? do
AI.enabled?() and AI.get_prompt_by_slug(@translation_prompt_slug) != nil
end
@doc """
Generates the default translation prompt in the AI prompts system.
Returns {:ok, prompt} or {:error, changeset}.
"""
def generate_default_translation_prompt do
attrs = %{
name: "Translate Publishing Posts",
description: "Default prompt for translating publishing posts between languages",
content: """
Translate the following content from {{SourceLanguage}} to {{TargetLanguage}}.
RULES:
- Preserve the EXACT formatting of the original (headings, line breaks, spacing, etc.)
- If the original has a # heading, keep it. If it doesn't, don't add one.
- Preserve all Markdown formatting (bold, italic, links, code blocks, lists)
- Do NOT translate text inside code blocks or inline code
- Translate naturally and idiomatically
- Keep HTML tags and special syntax unchanged
OUTPUT FORMAT - respond with ONLY this format, nothing else before or after:
---TITLE---
[translated title - just the title text, no # symbol]
---SLUG---
[url-friendly-slug-in-target-language]
---CONTENT---
[translated content - preserve EXACT original formatting]
SLUG RULES:
- Lowercase letters only (a-z)
- Numbers allowed (0-9)
- Use hyphens (-) to separate words
- No spaces, accents, or special characters
- Keep it short and SEO-friendly
- Example: "getting-started" -> "primeros-pasos" (Spanish)
=== SOURCE CONTENT ===
Title: {{Title}}
{{Content}}
"""
}
AI.create_prompt(attrs)
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] || Publishing.get_primary_language()
available_languages = post.available_languages || []
Publishing.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] || Publishing.get_primary_language()
Publishing.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_uuid) ->
{:noreply,
Phoenix.LiveView.put_flash(socket, :error, gettext("Please select an AI endpoint"))}
is_nil(socket.assigns[:ai_selected_prompt_uuid]) ->
{:noreply,
Phoenix.LiveView.put_flash(socket, :error, gettext("Please select an AI prompt"))}
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] ||
Publishing.get_primary_language()
case TranslatePostWorker.enqueue(
socket.assigns.group_slug,
post.uuid,
endpoint_uuid: socket.assigns.ai_selected_endpoint_uuid,
prompt_uuid: socket.assigns[:ai_selected_prompt_uuid],
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 = Publishing.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 translations.")}
| 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
# Check if any target languages are currently being edited by other users
active_editors = get_active_editors_for_languages(socket, target_languages)
warnings =
if active_editors != [] do
editor_warnings =
Enum.map(active_editors, fn {lang_code, editor_email} ->
lang_name = get_language_display_name(lang_code)
{:warning,
gettext(
"%{language} is currently being edited by %{user}. They will be locked out and unsaved changes will be discarded.",
language: lang_name,
user: editor_email
)}
end)
editor_warnings ++ warnings
else
warnings
end
# Also check source language
warnings = check_source_language_editor(socket, warnings)
Enum.reverse(warnings)
end
defp get_active_editors_for_languages(socket, target_languages) do
post = socket.assigns.post
group_slug = socket.assigns.group_slug
Enum.flat_map(target_languages, fn lang_code ->
form_key =
PublishingPubSub.generate_form_key(group_slug, %{uuid: post.uuid, language: lang_code})
case PresenceHelpers.get_lock_owner(form_key) do
nil ->
[]
owner_meta ->
# Don't warn about ourselves
current_user = socket.assigns[:phoenix_kit_current_scope]
current_uuid = if current_user, do: current_user.user.uuid, else: nil
if owner_meta.user_uuid != current_uuid do
[{lang_code, owner_meta.user_email}]
else
[]
end
end
end)
end
@doc """
Returns the source language for translation based on the post's primary language
or the system default.
"""
def source_language_for_translation(socket) do
post = socket.assigns[:post]
(post && post[:primary_language]) || Publishing.get_primary_language()
end
defp check_source_language_editor(socket, warnings) do
post = socket.assigns.post
group_slug = socket.assigns.group_slug
source_language = source_language_for_translation(socket)
current_language = socket.assigns[:current_language]
# If we're currently on the source language, we are the editor — no warning needed
if current_language == source_language do
warnings
else
form_key =
PublishingPubSub.generate_form_key(group_slug, %{
uuid: post.uuid,
language: source_language
})
case PresenceHelpers.get_lock_owner(form_key) do
nil ->
warnings
owner_meta ->
current_user = socket.assigns[:phoenix_kit_current_scope]
current_uuid = if current_user, do: current_user.user.uuid, else: nil
if owner_meta.user_uuid != current_uuid do
lang_name = get_language_display_name(source_language)
[
{:warning,
gettext(
"%{language} (source) is currently being edited by %{user}. They will be locked out during translation.",
language: lang_name,
user: owner_meta.user_email
)}
| warnings
]
else
warnings
end
end
end
end
defp get_language_display_name(lang_code) do
case Publishing.get_language_info(lang_code) do
%{name: name} -> name
_ -> String.upcase(lang_code)
end
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 = Publishing.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
source_language =
post[:primary_language] ||
socket.assigns[:current_language] ||
Publishing.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
# Read the source language content from the database
case Publishing.read_post_by_uuid(post.uuid, 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_uuid) ->
{:noreply,
Phoenix.LiveView.put_flash(socket, :error, gettext("Please select an AI endpoint"))}
is_nil(socket.assigns[:ai_selected_prompt_uuid]) ->
{:noreply,
Phoenix.LiveView.put_flash(socket, :error, gettext("Please select an AI prompt"))}
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
@doc """
Restores translation status from an active Oban job if one exists for this post.
Call on mount to survive page refreshes.
"""
def maybe_restore_translation_status(socket) do
post = socket.assigns[:post]
if post && post[:uuid] do
case TranslatePostWorker.active_job(post.uuid) do
nil ->
socket
job ->
target_languages = Map.get(job.args, "target_languages", [])
source_language = Map.get(job.args, "source_language")
current_lang = socket.assigns[:current_language]
# Lock this editor if the current language is being translated or is the source
should_lock =
current_lang != nil and
(current_lang == source_language or current_lang in target_languages)
socket
|> Phoenix.Component.assign(:ai_translation_status, :in_progress)
|> Phoenix.Component.assign(:ai_translation_progress, 0)
|> Phoenix.Component.assign(:ai_translation_total, length(target_languages))
|> Phoenix.Component.assign(:ai_translation_languages, target_languages)
|> Phoenix.Component.assign(:translation_locked?, should_lock)
end
else
socket
end
end
end