Packages

phoenix_kit

1.7.69
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 web listing.ex
Raw

lib/modules/publishing/web/listing.ex

defmodule PhoenixKit.Modules.Publishing.Web.Listing do
@moduledoc """
Lists posts for a publishing group and provides creation actions.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
require Logger
alias PhoenixKit.Modules.Publishing
alias PhoenixKit.Modules.Publishing.DBStorage
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Modules.Publishing.Renderer
alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers
alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML
alias PhoenixKit.Modules.Publishing.Workers.MigratePrimaryLanguageWorker
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
# Threshold for using background job vs synchronous migration
@migration_async_threshold 20
# Import publishing-specific components
import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher
@impl true
def mount(params, _session, socket) do
group_slug = params["group"] || params["category"] || params["type"]
if connected?(socket), do: subscribe_to_pubsub(group_slug)
socket =
socket
|> assign(:project_title, Settings.get_project_title())
|> assign(:page_title, "Publishing")
|> assign(:current_path, Routes.path("/admin/publishing/#{group_slug}"))
|> assign(:groups, [])
|> assign(:current_group, nil)
|> assign(:group_slug, group_slug)
|> assign(:enabled_languages, Publishing.enabled_language_codes())
|> assign(:primary_language, Publishing.get_primary_language())
|> assign(:primary_language_name, get_language_name(Publishing.get_primary_language()))
|> assign(:posts, [])
|> assign(:loading, false)
|> assign(:endpoint_url, "")
|> assign(:date_time_settings, load_date_time_settings())
|> assign(:primary_language_status, nil)
|> assign(:active_editors, %{})
|> assign(:translating_posts, %{})
|> assign(:pending_post_updates, %{})
|> assign(:show_migration_modal, false)
|> assign(:migration_in_progress, false)
|> assign(:migration_progress, nil)
# Groups, posts, current_group, and primary_language_status are loaded in
# handle_params which always runs after mount — no need to load them twice.
{:ok, socket}
end
@impl true
def handle_params(params, uri, socket) do
new_group_slug = params["group"] || params["category"] || params["type"]
old_group_slug = socket.assigns[:group_slug]
socket = handle_subscription_change(socket, old_group_slug, new_group_slug)
{groups, current_group} = load_groups_and_current(new_group_slug)
posts =
case new_group_slug do
nil -> []
slug -> DBStorage.list_posts_with_metadata(slug)
end
socket =
socket
|> assign(:groups, groups)
|> assign(:current_group, current_group)
|> assign(:posts, posts)
|> assign(:endpoint_url, extract_endpoint_url(uri))
|> assign(:primary_language_status, primary_language_status_from_posts(posts))
{:noreply, redirect_if_missing(socket)}
end
@impl true
def handle_event("create_post", _params, %{assigns: %{group_slug: group_slug}} = socket) do
{:noreply, push_navigate(socket, to: Helpers.build_new_post_url(group_slug))}
end
def handle_event("refresh", _params, socket) do
group_slug = socket.assigns.group_slug
{:noreply,
socket
|> assign(:posts, DBStorage.list_posts_with_metadata(group_slug))}
end
def handle_event("add_language", params, socket) do
lang_code = params["language"]
group_slug = socket.assigns.group_slug
uuid = params["uuid"]
url = Helpers.build_edit_url(group_slug, %{uuid: uuid}, lang: lang_code)
{:noreply, push_navigate(socket, to: url)}
end
def handle_event("language_action", %{"language" => lang_code} = params, socket) do
group_slug = socket.assigns.group_slug
case params["uuid"] do
uuid when is_binary(uuid) and uuid != "" ->
url = Helpers.build_edit_url(group_slug, %{uuid: uuid}, lang: lang_code)
{:noreply, push_navigate(socket, to: url)}
_ ->
{:noreply, socket}
end
end
def handle_event("change_status", %{"uuid" => post_uuid, "status" => new_status}, socket) do
scope = socket.assigns[:phoenix_kit_current_scope]
case Publishing.read_post_by_uuid(post_uuid) do
{:ok, post} ->
# Determine if this is the primary language for status propagation
primary_language = post[:primary_language] || Publishing.get_primary_language()
is_primary_language = post.language == primary_language
case Publishing.update_post(socket.assigns.group_slug, post, %{"status" => new_status}, %{
scope: scope,
is_primary_language: is_primary_language
}) do
{:ok, updated_post} ->
# Invalidate cache for this post
invalidate_post_cache(socket.assigns.group_slug, updated_post)
# Broadcast status change to other connected clients
PublishingPubSub.broadcast_post_status_changed(
socket.assigns.group_slug,
updated_post
)
{:noreply,
socket
|> put_flash(:info, gettext("Status updated to %{status}", status: new_status))
|> replace_post_in_list(updated_post[:slug], updated_post)}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update status"))}
end
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Post not found"))}
end
end
def handle_event(
"toggle_status",
%{"uuid" => post_uuid, "current-status" => current_status},
socket
) do
scope = socket.assigns[:phoenix_kit_current_scope]
new_status =
case current_status do
"draft" -> "published"
"published" -> "archived"
"archived" -> "draft"
_ -> "draft"
end
case Publishing.read_post_by_uuid(post_uuid) do
{:ok, post} ->
# Determine if this is the primary language for status propagation
primary_language = post[:primary_language] || Publishing.get_primary_language()
is_primary_language = post.language == primary_language
case Publishing.update_post(socket.assigns.group_slug, post, %{"status" => new_status}, %{
scope: scope,
is_primary_language: is_primary_language
}) do
{:ok, updated_post} ->
# Broadcast status change to other connected clients
PublishingPubSub.broadcast_post_status_changed(
socket.assigns.group_slug,
updated_post
)
{:noreply,
socket
|> put_flash(:info, gettext("Status updated to %{status}", status: new_status))
|> replace_post_in_list(updated_post[:slug], updated_post)}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Failed to update status"))}
end
{:error, _reason} ->
{:noreply, put_flash(socket, :error, gettext("Post not found"))}
end
end
def handle_event("show_migration_modal", _params, socket) do
{:noreply, assign(socket, :show_migration_modal, true)}
end
def handle_event("close_migration_modal", _params, socket) do
{:noreply, assign(socket, :show_migration_modal, false)}
end
def handle_event("confirm_migrate_primary_language", _params, socket) do
group_slug = socket.assigns.group_slug
primary_language = Publishing.get_primary_language()
status = socket.assigns.primary_language_status
total_count = status.needs_backfill + status.needs_migration
# Use background job for large migrations
if total_count > @migration_async_threshold do
case MigratePrimaryLanguageWorker.enqueue(group_slug, primary_language) do
{:ok, _job} ->
{:noreply,
socket
|> assign(:show_migration_modal, false)
|> assign(:migration_in_progress, true)
|> assign(:migration_progress, %{current: 0, total: total_count})
|> put_flash(
:info,
gettext("Migration started for %{count} posts. You can continue working.",
count: total_count
)
)}
{:error, reason} ->
{:noreply,
socket
|> assign(:show_migration_modal, false)
|> put_flash(
:error,
gettext("Failed to start migration: %{reason}", reason: inspect(reason))
)}
end
else
# Synchronous migration for small counts
case Publishing.migrate_posts_to_current_primary_language(group_slug) do
{:ok, count} ->
posts = DBStorage.list_posts_with_metadata(group_slug)
{:noreply,
socket
|> assign(:show_migration_modal, false)
|> assign(:posts, posts)
|> assign(:primary_language_status, primary_language_status_from_posts(posts))
|> put_flash(
:info,
gettext("Updated %{count} posts to primary language: %{lang}",
count: count,
lang: get_language_name(primary_language)
)
)}
{:error, reason} ->
{:noreply,
socket
|> assign(:show_migration_modal, false)
|> put_flash(
:error,
gettext("Failed to migrate: %{reason}", reason: inspect(reason))
)}
end
end
end
# PubSub handlers for live updates
@impl true
def handle_info({:post_created, _post}, socket) do
# Add new post to the list (at beginning since sorted by published_at desc)
# We do a full refresh since the new post needs to be in the right position
# based on sorting and the broadcast post may not have all required fields
{:noreply, refresh_posts(socket)}
end
def handle_info({:post_updated, updated_post}, socket) do
# Debounce post updates to prevent DB hammering on rapid saves
socket = schedule_debounced_update(socket, updated_post)
{:noreply, socket}
end
def handle_info({:post_status_changed, updated_post}, socket) do
# Debounce status changes as well
socket = schedule_debounced_update(socket, updated_post)
{:noreply, socket}
end
def handle_info({:debounced_post_update, post_slug}, socket) do
# Timer fired - now do the actual update
socket = do_debounced_update(socket, post_slug)
{:noreply, socket}
end
def handle_info({:post_deleted, post_identifier}, socket) do
# Remove the deleted post from the list (identifier may be slug or UUID)
socket = remove_post_from_list(socket, post_identifier)
{:noreply, socket}
end
def handle_info({:version_created, updated_post}, socket) do
# Incrementally update the post with new version info
socket = update_post_in_list(socket, updated_post)
{:noreply, socket}
end
def handle_info({:version_live_changed, post_slug, _version}, socket) do
# Refresh the specific post since live version change affects displayed content
socket = refresh_post_by_slug(socket, post_slug)
{:noreply, socket}
end
def handle_info({:version_deleted, post_slug, _version}, socket) do
# Refresh the specific post since version deletion affects available versions
socket = refresh_post_by_slug(socket, post_slug)
{:noreply, socket}
end
def handle_info({:cache_changed, _group_slug}, socket) do
{:noreply, socket}
end
# Editor presence handlers - show who's currently editing posts
def handle_info({:editor_joined, post_slug, user_info}, socket) do
# Only show actual editors (owners), not spectators
if user_info[:role] == :owner do
active_editors = socket.assigns.active_editors
post_editors = Map.get(active_editors, post_slug, [])
# Add user if not already in the list
updated_editors =
if Enum.any?(post_editors, fn e -> e.socket_id == user_info.socket_id end) do
post_editors
else
[user_info | post_editors]
end
{:noreply,
assign(socket, :active_editors, Map.put(active_editors, post_slug, updated_editors))}
else
{:noreply, socket}
end
end
def handle_info({:editor_left, post_slug, user_info}, socket) do
active_editors = socket.assigns.active_editors
post_editors = Map.get(active_editors, post_slug, [])
# Remove user from the list
updated_editors = Enum.reject(post_editors, fn e -> e.socket_id == user_info.socket_id end)
updated_active_editors =
if updated_editors == [] do
Map.delete(active_editors, post_slug)
else
Map.put(active_editors, post_slug, updated_editors)
end
{:noreply, assign(socket, :active_editors, updated_active_editors)}
end
# Translation progress handlers - show translation status on posts
def handle_info({:translation_started, post_slug, language_count}, socket) do
translating =
Map.put(socket.assigns.translating_posts, post_slug, %{
total: language_count,
completed: 0,
status: :in_progress
})
{:noreply, assign(socket, :translating_posts, translating)}
end
def handle_info({:translation_progress, post_slug, completed, total}, socket) do
# Update progress for this post
case Map.get(socket.assigns.translating_posts, post_slug) do
nil ->
# Post not in our tracking, add it
translating =
Map.put(socket.assigns.translating_posts, post_slug, %{
total: total,
completed: completed,
status: :in_progress
})
{:noreply, assign(socket, :translating_posts, translating)}
existing ->
# Update existing entry
translating =
Map.put(socket.assigns.translating_posts, post_slug, %{
existing
| completed: completed,
total: total
})
{:noreply, assign(socket, :translating_posts, translating)}
end
end
def handle_info({:translation_completed, post_slug, results}, socket) do
# Mark translation as complete - status stays visible
translating =
Map.put(socket.assigns.translating_posts, post_slug, %{
status: :completed,
success_count: results.success_count,
failure_count: results.failure_count
})
socket = assign(socket, :translating_posts, translating)
# Refresh posts to show new translations
socket = refresh_posts(socket)
{:noreply, socket}
end
# Primary language migration progress handlers
def handle_info({:primary_language_migration_started, group_slug, total_count}, socket) do
# Only track if it's for our current group
if group_slug == socket.assigns.group_slug do
{:noreply,
socket
|> assign(:migration_in_progress, true)
|> assign(:migration_progress, %{current: 0, total: total_count})}
else
{:noreply, socket}
end
end
def handle_info({:primary_language_migration_progress, group_slug, current, total}, socket) do
if group_slug == socket.assigns.group_slug do
{:noreply, assign(socket, :migration_progress, %{current: current, total: total})}
else
{:noreply, socket}
end
end
def handle_info(
{:primary_language_migration_completed, group_slug, success_count, error_count,
primary_language},
socket
) do
# Only handle if it's for our current group
if group_slug == socket.assigns.group_slug do
# Refresh posts from DB to get updated primary_language values
posts = DBStorage.list_posts_with_metadata(group_slug)
socket =
socket
|> assign(:migration_in_progress, false)
|> assign(:migration_progress, nil)
|> assign(:posts, posts)
|> assign(:primary_language_status, primary_language_status_from_posts(posts))
socket =
if error_count > 0 do
put_flash(
socket,
:warning,
gettext("Migration completed: %{success} succeeded, %{errors} failed",
success: success_count,
errors: error_count
)
)
else
put_flash(
socket,
:info,
gettext("Updated %{count} posts to primary language: %{lang}",
count: success_count,
lang: get_language_name(primary_language)
)
)
end
{:noreply, socket}
else
{:noreply, socket}
end
end
# Group change handlers - keep sidebar in sync
def handle_info({:group_created, _group}, socket) do
{:noreply, assign(socket, :groups, load_db_groups())}
end
def handle_info({:group_updated, group}, socket) do
groups = load_db_groups()
current_group = Enum.find(groups, fn b -> b["slug"] == socket.assigns.group_slug end)
socket =
socket
|> assign(:groups, groups)
|> assign(:current_group, current_group || group)
{:noreply, socket}
end
def handle_info({:group_deleted, deleted_slug}, socket) do
groups = load_db_groups()
socket =
if socket.assigns.group_slug == deleted_slug do
# Current group was deleted - redirect to first available
case groups do
[%{"slug" => slug} | _] ->
push_navigate(socket, to: Routes.path("/admin/publishing/#{slug}"))
[] ->
push_navigate(socket, to: Routes.path("/admin/settings/publishing"))
end
else
assign(socket, :groups, groups)
end
{:noreply, socket}
end
defp refresh_posts(socket) do
case socket.assigns.group_slug do
nil ->
socket
group_slug ->
posts = DBStorage.list_posts_with_metadata(group_slug)
socket
|> assign(:posts, posts)
|> assign(:primary_language_status, primary_language_status_from_posts(posts))
end
end
# Debounce interval for post updates (500ms)
@update_debounce_ms 500
# Schedule a debounced update for a post
defp schedule_debounced_update(socket, updated_post) do
post_slug = updated_post[:slug] || updated_post["slug"]
if post_slug do
pending = socket.assigns[:pending_post_updates] || %{}
# Cancel existing timer for this post if any
if timer_ref = Map.get(pending, post_slug) do
Process.cancel_timer(timer_ref)
end
# Schedule new debounced update
timer_ref =
Process.send_after(self(), {:debounced_post_update, post_slug}, @update_debounce_ms)
assign(socket, :pending_post_updates, Map.put(pending, post_slug, timer_ref))
else
socket
end
end
# Execute debounced update when timer fires
defp do_debounced_update(socket, post_slug) do
# Clear the timer from pending
pending = socket.assigns[:pending_post_updates] || %{}
socket = assign(socket, :pending_post_updates, Map.delete(pending, post_slug))
# Do the actual update
refresh_post_by_slug(socket, post_slug)
end
# Incrementally update a single post in the list by slug
# We refresh the full post from the database to ensure all fields are current
# (available_versions, language_slugs, version_statuses, etc.)
defp update_post_in_list(socket, updated_post) do
post_slug = updated_post[:slug] || updated_post["slug"]
can_update? = post_slug && socket.assigns[:posts] && socket.assigns[:group_slug]
if can_update? do
fetch_and_update_post(socket, post_slug)
else
refresh_posts(socket)
end
end
defp fetch_and_update_post(socket, post_slug) do
case Publishing.read_post(
socket.assigns.group_slug,
post_slug,
socket.assigns.current_locale_base,
nil
) do
{:ok, fresh_post} ->
replace_post_in_list(socket, post_slug, fresh_post)
{:error, _} ->
# Post may have been deleted or unreadable - full refresh
refresh_posts(socket)
end
end
defp replace_post_in_list(socket, post_slug, fresh_post) do
updated_posts =
Enum.map(socket.assigns.posts, fn post ->
if post[:slug] == post_slug, do: fresh_post, else: post
end)
assign(socket, :posts, updated_posts)
end
# Remove a post from the list by slug or UUID
defp remove_post_from_list(socket, post_identifier) do
if socket.assigns[:posts] do
updated_posts =
Enum.reject(socket.assigns.posts, fn post ->
post[:slug] == post_identifier or post[:uuid] == post_identifier
end)
assign(socket, :posts, updated_posts)
else
socket
end
end
# Refresh a single post by slug (used when we need fresh data from the database)
defp refresh_post_by_slug(socket, post_slug) do
case socket.assigns.group_slug do
nil ->
socket
group_slug ->
case Publishing.read_post(group_slug, post_slug, socket.assigns.current_locale_base, nil) do
{:ok, fresh_post} ->
update_post_in_list(socket, fresh_post)
{:error, _} ->
# Post might have been deleted - refresh all
refresh_posts(socket)
end
end
end
# ============================================================================
# Shared Mount/HandleParams Helpers
# ============================================================================
defp subscribe_to_pubsub(group_slug) do
PublishingPubSub.subscribe_to_groups()
if group_slug do
PublishingPubSub.subscribe_to_posts(group_slug)
PublishingPubSub.subscribe_to_cache(group_slug)
PublishingPubSub.subscribe_to_group_editors(group_slug)
end
end
defp handle_subscription_change(socket, old_slug, new_slug) do
if connected?(socket) && new_slug != old_slug do
if old_slug do
PublishingPubSub.unsubscribe_from_posts(old_slug)
PublishingPubSub.unsubscribe_from_cache(old_slug)
PublishingPubSub.unsubscribe_from_group_editors(old_slug)
end
if new_slug do
PublishingPubSub.subscribe_to_posts(new_slug)
PublishingPubSub.subscribe_to_cache(new_slug)
PublishingPubSub.subscribe_to_group_editors(new_slug)
end
socket
|> assign(:group_slug, new_slug)
|> assign(:active_editors, %{})
|> assign(:translating_posts, %{})
|> assign(:pending_post_updates, %{})
else
assign(socket, :group_slug, new_slug)
end
end
defp load_date_time_settings do
Settings.get_settings_cached(
["date_format", "time_format", "time_zone"],
%{"date_format" => "Y-m-d", "time_format" => "H:i", "time_zone" => "0"}
)
end
defp load_groups_and_current(group_slug) do
groups = load_db_groups()
current_group = Enum.find(groups, fn group -> group["slug"] == group_slug end)
{groups, current_group}
end
defp load_db_groups do
DBStorage.list_groups()
|> Enum.map(fn g ->
%{"name" => g.name, "slug" => g.slug, "mode" => g.mode, "position" => g.position}
end)
end
defp redirect_if_missing(%{assigns: %{current_group: nil}} = socket) do
case socket.assigns.groups do
[%{"slug" => slug} | _] ->
push_navigate(socket, to: Routes.path("/admin/publishing/#{slug}"))
[] ->
push_navigate(socket, to: Routes.path("/admin/settings/publishing"))
end
end
defp redirect_if_missing(socket), do: socket
def format_datetime(
%{date: %Date{} = date, time: %Time{} = time},
current_user,
date_time_settings
) do
# Fallback to dummy user if current_user is nil
user = current_user || %{user_timezone: nil}
# Dates and times are already in the timezone they were created in
# Just format them with user preferences
date_str = UtilsDate.format_date_with_user_timezone_cached(date, user, date_time_settings)
time_str = UtilsDate.format_time_with_user_timezone_cached(time, user, date_time_settings)
"#{date_str} #{gettext("at")} #{time_str}"
end
def format_datetime(
%{metadata: %{published_at: published_at}},
current_user,
date_time_settings
)
when is_binary(published_at) do
# Fallback to dummy user if current_user is nil
user = current_user || %{user_timezone: nil}
case DateTime.from_iso8601(published_at) do
{:ok, dt, _} ->
# Convert DateTime to NaiveDateTime (assuming stored as UTC)
naive_dt = DateTime.to_naive(dt)
# Format date part with timezone conversion
date_str =
UtilsDate.format_date_with_user_timezone_cached(naive_dt, user, date_time_settings)
# Format time part with timezone conversion
time_str =
UtilsDate.format_time_with_user_timezone_cached(naive_dt, user, date_time_settings)
"#{date_str} #{gettext("at")} #{time_str}"
_ ->
gettext("Unsaved draft")
end
end
def format_datetime(_post, _user, _settings), do: gettext("Unsaved draft")
@doc """
Gets the published version number from a post's version_statuses map.
Returns nil if no version is published.
"""
def get_published_version(post) do
version_statuses = Map.get(post, :version_statuses, %{})
version_statuses
|> Enum.find(fn {_version, status} -> status == "published" end)
|> case do
{version, _status} -> version
nil -> nil
end
end
@doc """
Gets the display version info for a post based on priority:
1. Published version (if exists) - what visitors see
2. Newest draft version (if no published) - work in progress
3. Latest version (fallback)
Returns `{version_number, status, label}` where label is :live, :draft, or :latest
"""
def get_display_version(post) do
version_statuses = Map.get(post, :version_statuses, %{})
available_versions = Map.get(post, :available_versions, [])
# 1. Check for published version
published =
version_statuses
|> Enum.find(fn {_version, status} -> status == "published" end)
case published do
{version, _status} ->
{version, "published", :live}
nil ->
# 2. Check for newest draft version
draft_versions =
version_statuses
|> Enum.filter(fn {_version, status} -> status == "draft" end)
|> Enum.map(fn {version, _} -> version end)
|> Enum.sort(:desc)
case draft_versions do
[newest_draft | _] ->
{newest_draft, "draft", :draft}
[] ->
# 3. Fall back to latest version
latest = Enum.max(available_versions, fn -> post[:version] || 1 end)
status = Map.get(version_statuses, latest, "draft")
{latest, status, :latest}
end
end
end
@doc """
Builds language data for the display version (live > draft > latest).
"""
def build_display_version_languages(post, enabled_languages, primary_language \\ nil) do
{version, status, _label} = get_display_version(post)
# Get languages for this specific version
version_languages = Map.get(post, :version_languages, %{})
available_languages = Map.get(version_languages, version, post[:available_languages] || [])
# Get primary language - prefer passed param, then post's stored value, then global
primary_lang =
primary_language || post[:primary_language] || Publishing.get_primary_language()
# Use shared ordering function for consistent display
all_languages =
Publishing.order_languages_for_display(
available_languages,
enabled_languages,
primary_lang
)
Enum.map(all_languages, fn lang_code ->
lang_info = Publishing.get_language_info(lang_code)
file_exists = lang_code in available_languages
is_enabled = Publishing.language_enabled?(lang_code, enabled_languages)
is_known = lang_info != nil
is_primary = lang_code == primary_lang
# Status matches the version's status
lang_status = if file_exists, do: status, else: nil
# Get display code (base or full dialect depending on enabled languages)
display_code = Publishing.get_display_code(lang_code, enabled_languages)
%{
code: lang_code,
display_code: display_code,
name: if(lang_info, do: lang_info.name, else: lang_code),
flag: if(lang_info, do: lang_info.flag, else: ""),
status: lang_status,
exists: file_exists,
enabled: is_enabled,
known: is_known,
is_primary: is_primary,
uuid: post[:uuid]
}
end)
|> Enum.filter(fn lang -> lang.exists end)
end
defp extract_endpoint_url(uri) when is_binary(uri) do
case URI.parse(uri) do
%URI{scheme: scheme, host: host, port: port} when not is_nil(scheme) and not is_nil(host) ->
port_string = if port in [80, 443], do: "", else: ":#{port}"
"#{scheme}://#{host}#{port_string}"
_ ->
""
end
end
defp extract_endpoint_url(_), do: ""
defp invalidate_post_cache(group_slug, post) do
identifier = post[:uuid] || post.slug
# Invalidate the render cache for this post
Renderer.invalidate_cache(group_slug, identifier, post.language)
end
@doc """
Builds language data for the publishing_language_switcher component.
Returns a list of language maps with status, enabled flag, known flag, and metadata.
The `enabled` field indicates if the language is currently active in the Languages module.
The `known` field indicates if the language code is recognized.
The `is_primary` field indicates if this is the primary language for versioning.
"""
def build_post_languages(
post,
_group_slug,
enabled_languages,
_current_locale,
primary_language \\ nil
) do
primary_lang =
primary_language || post[:primary_language] || Publishing.get_primary_language()
all_languages =
Publishing.order_languages_for_display(
post.available_languages || [],
enabled_languages,
primary_lang
)
all_languages
|> Enum.map(&build_language_entry(&1, post, enabled_languages, primary_lang))
|> Enum.filter(fn lang -> lang.exists || lang.enabled end)
end
defp build_language_entry(lang_code, post, enabled_languages, primary_lang) do
lang_info = Publishing.get_language_info(lang_code)
available = post.available_languages || []
file_exists = lang_code in available
language_statuses = Map.get(post, :language_statuses) || %{}
%{
code: lang_code,
display_code: Publishing.get_display_code(lang_code, enabled_languages),
name: if(lang_info, do: lang_info.name, else: lang_code),
flag: if(lang_info, do: lang_info.flag, else: ""),
status: Map.get(language_statuses, lang_code),
exists: file_exists,
enabled: Publishing.language_enabled?(lang_code, enabled_languages),
known: lang_info != nil,
is_primary: lang_code == primary_lang,
uuid: post[:uuid]
}
end
defp primary_language_status_from_posts([]), do: nil
defp primary_language_status_from_posts(posts) do
global_primary = Publishing.get_primary_language()
DBStorage.count_primary_language_status_from_posts(posts, global_primary)
end
defp get_language_name(language_code) do
case Publishing.get_language_info(language_code) do
%{name: name} -> name
_ -> String.upcase(language_code)
end
end
end