Packages
phoenix_kit
1.7.39
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/storage/helpers.ex
defmodule PhoenixKit.Modules.Publishing.Storage.Helpers do
@moduledoc """
Shared helper functions for publishing storage.
Contains audit metadata helpers, path utilities, and other
common functionality used across storage modules.
"""
alias PhoenixKit.Modules.Publishing.Metadata
alias PhoenixKit.Modules.Publishing.Storage.Languages
# ============================================================================
# Audit Metadata Helpers
# ============================================================================
@doc """
Applies creation audit metadata to a metadata map.
Sets both created_by and updated_by fields.
"""
@spec apply_creation_audit_metadata(map(), map()) :: map()
def apply_creation_audit_metadata(metadata, audit_meta) do
created_id = audit_value(audit_meta, :created_by_id)
created_email = audit_value(audit_meta, :created_by_email)
updated_id = audit_value(audit_meta, :updated_by_id) || created_id
updated_email = audit_value(audit_meta, :updated_by_email) || created_email
metadata
|> maybe_put_audit_field(:created_by_id, created_id)
|> maybe_put_audit_field(:created_by_email, created_email)
|> maybe_put_audit_field(:updated_by_id, updated_id)
|> maybe_put_audit_field(:updated_by_email, updated_email)
end
@doc """
Applies update audit metadata to a metadata map.
Only sets updated_by fields.
"""
@spec apply_update_audit_metadata(map(), map()) :: map()
def apply_update_audit_metadata(metadata, audit_meta) do
metadata
|> maybe_put_audit_field(:updated_by_id, audit_value(audit_meta, :updated_by_id))
|> maybe_put_audit_field(:updated_by_email, audit_value(audit_meta, :updated_by_email))
end
defp audit_value(audit_meta, key) do
audit_meta
|> Map.get(key)
|> case do
nil -> Map.get(audit_meta, Atom.to_string(key))
value -> value
end
|> normalize_audit_value()
end
defp maybe_put_audit_field(metadata, _key, nil), do: metadata
defp maybe_put_audit_field(metadata, key, value) do
Map.put(metadata, key, value)
end
defp normalize_audit_value(nil), do: nil
defp normalize_audit_value(value) when is_binary(value) do
trimmed = String.trim(value)
if trimmed == "", do: nil, else: trimmed
end
defp normalize_audit_value(value), do: to_string(value)
# ============================================================================
# Metadata Helpers
# ============================================================================
@doc """
Gets a value from metadata, checking both atom and string keys.
"""
@spec metadata_value(map(), atom(), any()) :: any()
def metadata_value(metadata, key, fallback \\ nil) do
Map.get(metadata, key) ||
Map.get(metadata, Atom.to_string(key)) ||
fallback
end
@doc """
Resolves featured_image_id from params or existing metadata.
"""
@spec resolve_featured_image_id(map(), map()) :: String.t() | nil
def resolve_featured_image_id(params, metadata) do
case Map.fetch(params, "featured_image_id") do
{:ok, value} -> normalize_featured_image_id(value)
:error -> metadata_value(metadata, :featured_image_id)
end
end
defp normalize_featured_image_id(value) when is_binary(value) do
value
|> String.trim()
|> case do
"" -> nil
trimmed -> trimmed
end
end
defp normalize_featured_image_id(_), do: nil
@doc """
Resolves url_slug from params or existing metadata.
Empty string clears the custom slug.
"""
@spec resolve_url_slug(map(), map()) :: String.t() | nil
def resolve_url_slug(params, metadata) do
case Map.get(params, "url_slug") do
nil -> Map.get(metadata, :url_slug)
"" -> nil
slug when is_binary(slug) -> String.trim(slug)
_ -> Map.get(metadata, :url_slug)
end
end
@doc """
Resolves previous_url_slugs, tracking old slugs for 301 redirects.
When url_slug changes, the old value is added to previous_url_slugs.
"""
@spec resolve_previous_url_slugs(map(), map()) :: [String.t()]
def resolve_previous_url_slugs(params, metadata) do
current_slugs = Map.get(metadata, :previous_url_slugs) || []
old_url_slug = Map.get(metadata, :url_slug)
new_url_slug = Map.get(params, "url_slug")
cond do
new_url_slug == nil ->
current_slugs
new_url_slug == "" and old_url_slug not in [nil, ""] ->
add_to_previous_slugs(current_slugs, old_url_slug)
is_binary(new_url_slug) and new_url_slug != "" and old_url_slug not in [nil, ""] and
String.trim(new_url_slug) != old_url_slug ->
add_to_previous_slugs(current_slugs, old_url_slug)
true ->
current_slugs
end
end
defp add_to_previous_slugs(current_slugs, slug) do
if slug in current_slugs do
current_slugs
else
current_slugs ++ [slug]
end
end
@doc """
Resolves allow_version_access from params or existing metadata.
"""
@spec resolve_allow_version_access(map(), map()) :: boolean()
def resolve_allow_version_access(params, metadata) do
case Map.get(params, "allow_version_access") do
nil -> Map.get(metadata, :allow_version_access, false)
value when is_boolean(value) -> value
"true" -> true
"false" -> false
_ -> Map.get(metadata, :allow_version_access, false)
end
end
@doc """
Gets slug from metadata, falling back to provided default if nil or empty.
"""
@spec get_slug_with_fallback(map(), String.t()) :: String.t()
def get_slug_with_fallback(metadata, fallback) do
case Map.get(metadata, :slug) do
nil -> fallback
"" -> fallback
slug -> slug
end
end
# ============================================================================
# Time Helpers
# ============================================================================
@doc """
Formats a Time struct as a time folder string (HH:MM).
"""
@spec format_time_folder(Time.t()) :: String.t()
def format_time_folder(%Time{} = time) do
{hour, minute, _second} = Time.to_erl(time)
"#{pad(hour)}:#{pad(minute)}"
end
@doc """
Parses a time folder string (HH:MM) into a Time struct.
"""
@spec parse_time_folder(String.t()) :: {:ok, Time.t()} | {:error, :invalid_time}
def parse_time_folder(folder) do
case String.split(folder, ":") do
[hour, minute] ->
with {h, ""} <- Integer.parse(hour),
{m, ""} <- Integer.parse(minute),
true <- h in 0..23,
true <- m in 0..59 do
{:ok, Time.new!(h, m, 0)}
else
_ -> {:error, :invalid_time}
end
_ ->
{:error, :invalid_time}
end
end
@doc """
Floors a DateTime to the minute (sets seconds and microseconds to 0).
"""
@spec floor_to_minute(DateTime.t()) :: DateTime.t()
def floor_to_minute(%DateTime{} = datetime) do
%DateTime{datetime | second: 0, microsecond: {0, 0}}
end
defp pad(value) when value < 10, do: "0#{value}"
defp pad(value), do: Integer.to_string(value)
# ============================================================================
# Path Helpers
# ============================================================================
@doc """
Extracts date and time from a relative path.
Handles both legacy (4 parts) and versioned (5 parts) paths.
"""
@spec date_time_from_path(String.t()) :: {:ok, {Date.t(), Time.t()}} | {:error, :invalid_path}
def date_time_from_path(path) do
parts = String.split(path, "/", trim: true)
{date_part, time_part} =
case parts do
[_type, date_part, time_part, _file] ->
{date_part, time_part}
[_type, date_part, time_part, _version, _file] ->
{date_part, time_part}
_ ->
{nil, nil}
end
if date_part && time_part do
with {:ok, date} <- Date.from_iso8601(date_part),
{:ok, time} <- parse_time_folder(time_part) do
{:ok, {date, time}}
else
_ -> {:error, :invalid_path}
end
else
{:error, :invalid_path}
end
rescue
_ -> {:error, :invalid_path}
end
@doc """
Extracts date and time from a relative path, raising on error.
"""
@spec date_time_from_path!(String.t()) :: {Date.t(), Time.t()}
def date_time_from_path!(path) do
case date_time_from_path(path) do
{:ok, result} -> result
_ -> raise ArgumentError, "invalid blogging path #{inspect(path)}"
end
end
@doc """
Extracts language code from a path (e.g., "blog/post/en.phk" -> "en").
"""
@spec extract_language_from_path(String.t()) :: String.t()
def extract_language_from_path(relative_path) do
relative_path
|> Path.basename()
|> String.replace_suffix(".phk", "")
end
@doc """
Builds a relative path for a timestamp-mode post with language.
"""
@spec relative_path_with_language(String.t(), Date.t(), Time.t(), String.t()) :: String.t()
def relative_path_with_language(group_slug, date, time, language_code) do
date_part = Date.to_iso8601(date)
time_part = format_time_folder(time)
Path.join([group_slug, date_part, time_part, Languages.language_filename(language_code)])
end
@doc """
Builds a relative path for a versioned timestamp-mode post with language.
"""
@spec relative_path_with_language_versioned(
String.t(),
Date.t(),
Time.t(),
integer(),
String.t()
) :: String.t()
def relative_path_with_language_versioned(group_slug, date, time, version, language_code) do
date_part = Date.to_iso8601(date)
time_part = format_time_folder(time)
Path.join([
group_slug,
date_part,
time_part,
"v#{version}",
Languages.language_filename(language_code)
])
end
# ============================================================================
# Update Metadata Builder
# ============================================================================
@doc """
Builds updated metadata for a post update operation.
"""
@spec build_update_metadata(map(), map(), map(), boolean()) :: map()
def build_update_metadata(post, params, audit_meta, _becoming_published?) do
current_title =
metadata_value(post.metadata, :title) ||
Metadata.extract_title_from_content(post.content || "")
current_status = metadata_value(post.metadata, :status, "draft")
new_status = Map.get(params, "status", current_status)
post.metadata
|> Map.put(:title, Map.get(params, "title", current_title))
|> Map.put(:status, new_status)
|> Map.put(
:published_at,
Map.get(params, "published_at", metadata_value(post.metadata, :published_at))
)
|> Map.put(:featured_image_id, resolve_featured_image_id(params, post.metadata))
|> Map.put(:created_at, Map.get(post.metadata, :created_at))
|> Map.put(:slug, post.slug)
|> Map.put(:version, Map.get(post.metadata, :version, 1))
|> Map.put(:version_created_at, Map.get(post.metadata, :version_created_at))
|> Map.put(:version_created_from, Map.get(post.metadata, :version_created_from))
|> Map.put(:allow_version_access, resolve_allow_version_access(params, post.metadata))
|> Map.put(:url_slug, resolve_url_slug(params, post.metadata))
|> Map.put(:previous_url_slugs, resolve_previous_url_slugs(params, post.metadata))
|> Map.delete(:is_live)
|> Map.delete(:legacy_is_live)
|> apply_update_audit_metadata(audit_meta)
end
end