Packages

phoenix_kit

1.7.19
1.7.210 1.7.209 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 blogging blogging.ex
Raw

lib/modules/blogging/blogging.ex

defmodule PhoenixKit.Modules.Blogging do
@moduledoc """
Blogging module for managing site blogs and their posts.
This keeps content in the filesystem while providing an admin-friendly UI
for creating timestamped markdown blog posts.
"""
alias PhoenixKit.Modules.Blogging.ListingCache
alias PhoenixKit.Modules.Blogging.PubSub, as: BloggingPubSub
alias PhoenixKit.Modules.Blogging.Storage
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Users.Auth.Scope
# Suppress dialyzer false positives for pattern matches
@dialyzer :no_match
@dialyzer {:nowarn_function, create_post: 2}
@dialyzer {:nowarn_function, add_language_to_post: 4}
@dialyzer {:nowarn_function, parse_version_directory: 1}
# Delegate language info function to Storage
defdelegate get_language_info(language_code), to: Storage
# Delegate version functions to Storage
defdelegate list_versions(blog_slug, post_slug), to: Storage
defdelegate get_latest_version(blog_slug, post_slug), to: Storage
defdelegate get_latest_published_version(blog_slug, post_slug), to: Storage
defdelegate get_live_version(blog_slug, post_slug), to: Storage
defdelegate get_version_status(blog_slug, post_slug, version, language), to: Storage
defdelegate detect_post_structure(post_path), to: Storage
defdelegate content_changed?(post, params), to: Storage
defdelegate status_change_only?(post, params), to: Storage
defdelegate should_create_new_version?(post, params, editing_language), to: Storage
# Delegate slug utilities to Storage
defdelegate validate_slug(slug), to: Storage
defdelegate slug_exists?(blog_slug, post_slug), to: Storage
defdelegate generate_unique_slug(blog_slug, title), to: Storage
defdelegate generate_unique_slug(blog_slug, title, preferred_slug), to: Storage
defdelegate generate_unique_slug(blog_slug, title, preferred_slug, opts), to: Storage
# Delegate language utilities to Storage
defdelegate enabled_language_codes(), to: Storage
defdelegate get_master_language(), to: Storage
defdelegate language_enabled?(language_code, enabled_languages), to: Storage
defdelegate get_display_code(language_code, enabled_languages), to: Storage
defdelegate order_languages_for_display(available_languages, enabled_languages), to: Storage
# Delegate version metadata to Storage
defdelegate get_version_metadata(blog_slug, post_slug, version, language), to: Storage
defdelegate migrate_post_to_versioned(post), to: Storage
defdelegate migrate_post_to_versioned(post, language), to: Storage
# Delegate cache operations to ListingCache
defdelegate regenerate_cache(blog_slug), to: ListingCache, as: :regenerate
defdelegate invalidate_cache(blog_slug), to: ListingCache, as: :invalidate
defdelegate cache_exists?(blog_slug), to: ListingCache, as: :exists?
defdelegate find_cached_post(blog_slug, post_slug), to: ListingCache, as: :find_post
defdelegate find_cached_post_by_path(blog_slug, date, time),
to: ListingCache,
as: :find_post_by_path
@enabled_key "blogging_enabled"
@blogs_key "blogging_blogs"
@legacy_categories_key "blogging_categories"
@default_blog_mode "timestamp"
@slug_regex ~r/^[a-z0-9]+(?:-[a-z0-9]+)*$/
@type blog :: map()
@doc """
Returns true when the blogging module is enabled.
"""
@spec enabled?() :: boolean()
def enabled? do
settings_call(:get_boolean_setting, [@enabled_key, false])
end
@doc """
Enables the blogging module.
"""
@spec enable_system() :: {:ok, any()} | {:error, any()}
def enable_system do
settings_call(:update_boolean_setting, [@enabled_key, true])
end
@doc """
Disables the blogging module.
"""
@spec disable_system() :: {:ok, any()} | {:error, any()}
def disable_system do
settings_call(:update_boolean_setting, [@enabled_key, false])
end
@doc """
Returns all configured blogs.
"""
@spec list_blogs() :: [blog()]
def list_blogs do
case settings_call(:get_json_setting_cached, [@blogs_key, nil]) do
%{"blogs" => blogs} when is_list(blogs) ->
normalize_blogs(blogs)
list when is_list(list) ->
normalize_blogs(list)
_ ->
legacy =
case settings_call(:get_json_setting_cached, [@legacy_categories_key, %{"types" => []}]) do
%{"types" => types} when is_list(types) -> types
other when is_list(other) -> other
_ -> []
end
if legacy != [] do
settings_call(:update_json_setting, [@blogs_key, %{"blogs" => legacy}])
end
normalize_blogs(legacy)
end
end
@doc """
Gets a blog by slug.
## Examples
iex> Blogging.get_blog("news")
{:ok, %{"name" => "News", "slug" => "news", ...}}
iex> Blogging.get_blog("nonexistent")
{:error, :not_found}
"""
@spec get_blog(String.t()) :: {:ok, blog()} | {:error, :not_found}
def get_blog(slug) when is_binary(slug) do
case Enum.find(list_blogs(), &(&1["slug"] == slug)) do
nil -> {:error, :not_found}
blog -> {:ok, blog}
end
end
@doc """
Adds a new blog.
"""
@spec add_blog(String.t(), String.t(), String.t() | nil) :: {:ok, blog()} | {:error, atom()}
def add_blog(name, mode \\ @default_blog_mode, preferred_slug \\ nil) when is_binary(name) do
trimmed = String.trim(name)
mode = normalize_mode(mode)
cond do
trimmed == "" ->
{:error, :invalid_name}
is_nil(mode) ->
{:error, :invalid_mode}
true ->
blogs = list_blogs()
with {:ok, requested_slug} <- derive_requested_slug(preferred_slug, trimmed),
:ok <- check_slug_availability(requested_slug, blogs, preferred_slug) do
slug = ensure_unique_slug(requested_slug, blogs)
blog = %{"name" => trimmed, "slug" => slug, "mode" => mode}
updated = blogs ++ [blog]
payload = %{"blogs" => updated}
with {:ok, _} <- settings_call(:update_json_setting, [@blogs_key, payload]),
:ok <- Storage.ensure_blog_root(slug) do
{:ok, blog}
end
end
end
end
@doc """
Removes a blog by slug.
"""
@spec remove_blog(String.t()) :: {:ok, any()} | {:error, any()}
def remove_blog(slug) when is_binary(slug) do
updated =
list_blogs()
|> Enum.reject(&(&1["slug"] == slug))
settings_call(:update_json_setting, [@blogs_key, %{"blogs" => updated}])
end
@doc """
Updates a blog's display name and slug.
"""
@spec update_blog(String.t(), map() | keyword()) :: {:ok, blog()} | {:error, atom()}
def update_blog(slug, params) when is_binary(slug) do
blogs = list_blogs()
case Enum.find(blogs, &(&1["slug"] == slug)) do
nil -> {:error, :not_found}
blog -> process_blog_update(blog, blogs, params)
end
end
defp process_blog_update(blog, blogs, params) do
with {:ok, name} <- extract_and_validate_name(blog, params),
{:ok, sanitized_slug} <- extract_and_validate_slug(blog, params, name),
:ok <- check_slug_uniqueness(blog, blogs, sanitized_slug) do
apply_blog_update(blog, blogs, name, sanitized_slug)
end
end
defp extract_and_validate_name(blog, params) do
name =
params
|> fetch_option(:name)
|> case do
nil -> blog["name"]
value -> String.trim(to_string(value || ""))
end
if name == "", do: {:error, :invalid_name}, else: {:ok, name}
end
defp extract_and_validate_slug(blog, params, name) do
desired_slug =
params
|> fetch_option(:slug)
|> case do
nil -> blog["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(blog, blogs, sanitized_slug) do
if sanitized_slug != blog["slug"] and Enum.any?(blogs, &(&1["slug"] == sanitized_slug)) do
{:error, :already_exists}
else
:ok
end
end
defp apply_blog_update(blog, blogs, name, sanitized_slug) do
updated_blog =
blog
|> Map.put("name", name)
|> Map.put("slug", sanitized_slug)
with :ok <- Storage.rename_blog_directory(blog["slug"], sanitized_slug),
{:ok, _} <- persist_blog_update(blogs, blog["slug"], updated_blog) do
{:ok, updated_blog}
end
end
@doc """
Moves a blog to trash by renaming its directory with timestamp.
The blog is removed from the active blogs list and its directory is renamed to:
BLOGNAME-YYYY-MM-DD-HH-MM-SS
"""
@spec trash_blog(String.t()) :: {:ok, String.t()} | {:error, any()}
def trash_blog(slug) when is_binary(slug) do
with {:ok, _} <- remove_blog(slug) do
Storage.move_blog_to_trash(slug)
end
end
@doc """
Looks up a blog name from its slug.
"""
@spec blog_name(String.t()) :: String.t() | nil
def blog_name(slug) do
Enum.find_value(list_blogs(), fn blog ->
if blog["slug"] == slug, do: blog["name"]
end)
end
@doc """
Returns the configured storage mode for a blog slug.
"""
@spec get_blog_mode(String.t()) :: String.t()
def get_blog_mode(blog_slug) do
list_blogs()
|> Enum.find(%{}, &(&1["slug"] == blog_slug))
|> Map.get("mode", @default_blog_mode)
end
@doc """
Lists blog posts for a given blog slug.
Accepts optional preferred_language to show titles in user's language.
"""
@spec list_posts(String.t(), String.t() | nil) :: [Storage.post()]
def list_posts(blog_slug, preferred_language \\ nil) do
case get_blog_mode(blog_slug) do
"slug" -> Storage.list_posts_slug_mode(blog_slug, preferred_language)
_ -> Storage.list_posts(blog_slug, preferred_language)
end
end
@doc """
Creates a new blog post for the given blog using the current timestamp.
"""
@spec create_post(String.t(), map() | keyword()) :: {:ok, Storage.post()} | {:error, any()}
def create_post(blog_slug, opts \\ %{}) do
scope = fetch_option(opts, :scope)
audit_meta = audit_metadata(scope, :create)
result =
case get_blog_mode(blog_slug) do
"slug" ->
title = fetch_option(opts, :title)
slug = fetch_option(opts, :slug)
Storage.create_post_slug_mode(blog_slug, title, slug, audit_meta)
_ ->
Storage.create_post(blog_slug, audit_meta)
end
# Regenerate listing cache and broadcast on success
with {:ok, post} <- result do
ListingCache.regenerate(blog_slug)
BloggingPubSub.broadcast_post_created(blog_slug, post)
end
result
end
@doc """
Reads an existing blog post.
For slug-mode blogs, accepts an optional version parameter.
If version is nil, reads the latest version.
"""
@spec read_post(String.t(), String.t(), String.t() | nil, integer() | nil) ::
{:ok, Storage.post()} | {:error, any()}
def read_post(blog_slug, identifier, language \\ nil, version \\ nil) do
case get_blog_mode(blog_slug) do
"slug" ->
{post_slug, inferred_version, inferred_language} =
extract_slug_version_and_language(blog_slug, identifier)
# Use explicit parameters if provided, otherwise use inferred values from path
final_language = language || inferred_language
final_version = version || inferred_version
Storage.read_post_slug_mode(blog_slug, post_slug, final_language, final_version)
_ ->
Storage.read_post(blog_slug, identifier)
end
end
@doc """
Updates a blog post and moves the file if the publication timestamp changes.
"""
@spec update_post(String.t(), Storage.post(), map(), map() | keyword()) ::
{:ok, Storage.post()} | {:error, any()}
def update_post(blog_slug, post, params, opts \\ %{}) do
audit_meta =
opts
|> fetch_option(:scope)
|> audit_metadata(:update)
mode =
Map.get(post, :mode) ||
Map.get(post, "mode") ||
mode_atom(get_blog_mode(blog_slug))
result =
case mode do
:slug -> Storage.update_post_slug_mode(blog_slug, post, params, audit_meta)
_ -> Storage.update_post(blog_slug, post, params, audit_meta)
end
# Regenerate listing cache on success, but only if the post is live
# (For versioned posts, non-live versions don't affect public listings)
# Always broadcast so all viewers of any version see updates
with {:ok, updated_post} <- result do
if should_regenerate_cache?(updated_post) do
ListingCache.regenerate(blog_slug)
end
BloggingPubSub.broadcast_post_updated(blog_slug, updated_post)
end
result
end
@doc """
Creates a new version of a slug-mode post by copying from the source version.
The new version starts as draft with is_live: false.
Content and metadata updates from params are applied to the new version.
"""
@spec create_new_version(String.t(), Storage.post(), map(), map() | keyword()) ::
{:ok, Storage.post()} | {:error, any()}
def create_new_version(blog_slug, source_post, params \\ %{}, opts \\ %{}) do
audit_meta =
opts
|> fetch_option(:scope)
|> audit_metadata(:create)
result = Storage.create_new_version(blog_slug, source_post, params, audit_meta)
# Note: New versions start as drafts (is_live: false), so we don't
# regenerate the cache here. Cache will be regenerated when the
# version is set live or published.
# Always broadcast so all viewers see the new version exists
with {:ok, new_version} <- result do
BloggingPubSub.broadcast_version_created(blog_slug, new_version)
end
result
end
@doc """
Sets a version as the live version for a post.
Clears is_live from all other versions.
"""
@spec set_version_live(String.t(), String.t(), integer()) :: :ok | {:error, any()}
def set_version_live(blog_slug, post_slug, version) do
result = Storage.set_version_live(blog_slug, post_slug, version)
# Regenerate listing cache and broadcast on success
if result == :ok do
ListingCache.regenerate(blog_slug)
BloggingPubSub.broadcast_version_live_changed(blog_slug, post_slug, version)
end
result
end
@doc """
Adds a new language file to an existing post.
For slug-mode blogs, 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, Storage.post()} | {:error, any()}
def add_language_to_post(blog_slug, identifier, language_code, version \\ nil) do
result =
case get_blog_mode(blog_slug) do
"slug" ->
{post_slug, inferred_version, _language} =
extract_slug_version_and_language(blog_slug, identifier)
# Use explicit version if provided, otherwise use version from path
target_version = version || inferred_version
Storage.add_language_to_post_slug_mode(
blog_slug,
post_slug,
language_code,
target_version
)
_ ->
Storage.add_language_to_post(blog_slug, identifier, language_code)
end
# Regenerate listing cache on success, but only if the post is live
# Always broadcast so all viewers see the new translation
with {:ok, new_post} <- result do
if should_regenerate_cache?(new_post) do
ListingCache.regenerate(blog_slug)
end
# Broadcast translation created
if new_post.slug do
BloggingPubSub.broadcast_translation_created(blog_slug, new_post.slug, language_code)
end
end
result
end
@doc """
Moves a post to the trash folder.
For slug-mode blogs, provide the post slug.
For timestamp-mode blogs, 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(blog_slug, post_identifier) do
result = Storage.trash_post(blog_slug, post_identifier)
with {:ok, _trash_path} <- result do
ListingCache.regenerate(blog_slug)
BloggingPubSub.broadcast_post_deleted(blog_slug, post_identifier)
end
result
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(blog_slug, post_identifier, language_code, version \\ nil) do
result = Storage.delete_language(blog_slug, post_identifier, language_code, version)
if result == :ok do
ListingCache.regenerate(blog_slug)
BloggingPubSub.broadcast_translation_deleted(blog_slug, post_identifier, language_code)
end
result
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(blog_slug, post_identifier, version) do
result = Storage.delete_version(blog_slug, post_identifier, version)
if result == :ok do
# Only regenerate cache if deleting affected the live version
# (but we already prevent deleting live version, so this is just for safety)
ListingCache.regenerate(blog_slug)
BloggingPubSub.broadcast_version_deleted(blog_slug, post_identifier, version)
end
result
end
# Legacy wrappers (deprecated)
def list_entries(blog_slug, preferred_language \\ nil),
do: list_posts(blog_slug, preferred_language)
def create_entry(blog_slug), do: create_post(blog_slug)
def read_entry(blog_slug, relative_path), do: read_post(blog_slug, relative_path)
def update_entry(blog_slug, post, params), do: update_post(blog_slug, post, params)
def add_language_to_entry(blog_slug, post_path, language_code),
do: add_language_to_post(blog_slug, post_path, language_code)
@doc """
Generates a slug from a user-provided blog 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.
Blog 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 live.
# 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, %{})
is_live = Map.get(metadata, :is_live)
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: only regenerate if this is the live version
is_live == true -> true
# Non-live versioned posts don't affect public listings
true -> false
end
end
defp settings_module do
PhoenixKit.Config.get(:blogging_settings_module, PhoenixKit.Settings)
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_blogs(blogs) do
blogs
|> Enum.map(&normalize_blog_keys/1)
|> Enum.map(fn
%{"mode" => mode} = blog when mode in ["timestamp", "slug"] ->
blog
blog ->
Map.put(blog, "mode", @default_blog_mode)
end)
end
defp normalize_blog_keys(blog) when is_map(blog) do
Enum.reduce(blog, %{}, 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_blog_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
defp fetch_option(opts, key) when is_map(opts) do
Map.get(opts, key) || Map.get(opts, Atom.to_string(key))
end
defp fetch_option(opts, key) when is_list(opts) do
if Keyword.keyword?(opts) do
Keyword.get(opts, key)
else
nil
end
end
defp fetch_option(_, _), do: nil
defp audit_metadata(nil, _action), do: %{}
defp audit_metadata(scope, action) do
user_id =
scope
|> Scope.user_id()
|> normalize_audit_value()
user_email =
scope
|> Scope.user_email()
|> normalize_audit_value()
base =
case action do
:create ->
%{
created_by_id: user_id,
created_by_email: user_email
}
_ ->
%{}
end
base
|> maybe_put_audit(:updated_by_id, user_id)
|> 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_blog_update(blogs, slug, updated_blog) do
updated =
Enum.map(blogs, fn
%{"slug" => ^slug} -> updated_blog
other -> other
end)
settings_call(:update_json_setting, [@blogs_key, %{"blogs" => 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, blogs, preferred_slug) when not is_nil(preferred_slug) do
if Enum.any?(blogs, &(&1["slug"] == slug)) do
{:error, :already_exists}
else
:ok
end
end
defp check_slug_availability(_slug, _blogs, nil), do: :ok
defp ensure_unique_slug(slug, blogs), do: ensure_unique_slug(slug, blogs, 2)
defp ensure_unique_slug(slug, blogs, counter) do
if Enum.any?(blogs, &(&1["slug"] == slug)) do
ensure_unique_slug("#{slug}-#{counter}", blogs, counter + 1)
else
slug
end
end
defp mode_atom("slug"), do: :slug
defp mode_atom(_), do: :timestamp
# Extract slug, version, and language from a path identifier
# Handles paths like:
# - "post-slug" → {"post-slug", nil, nil}
# - "post-slug/en.phk" → {"post-slug", nil, "en"}
# - "post-slug/v1/en.phk" → {"post-slug", 1, "en"}
# - "blog/post-slug/v2/am.phk" → {"post-slug", 2, "am"}
defp extract_slug_version_and_language(_blog_slug, nil), do: {"", nil, nil}
defp extract_slug_version_and_language(blog_slug, identifier) do
parts =
identifier
|> to_string()
|> String.trim()
|> String.trim_leading("/")
|> String.split("/", trim: true)
|> drop_blog_prefix(blog_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 -> String.replace_suffix(lang_file, ".phk", "")
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 blog prefix if there are more elements after it
# This prevents dropping the post slug when it matches the blog slug
defp drop_blog_prefix([blog_slug | rest], blog_slug) when rest != [], do: rest
defp drop_blog_prefix(list, _), do: list
end