Compare commits
2 Commits
68de265137
...
49675a49d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 49675a49d2 | |||
| d1acfd4d71 |
@@ -1,5 +1,11 @@
|
||||
defmodule BDS.AI.Chat do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Context for AI chat conversations: start, list, fetch, and delete
|
||||
conversations, persist surface state, and resolve the effective chat model.
|
||||
|
||||
Automatic AI activity is gated by the app's airplane (offline) mode; callers
|
||||
must respect that gating before invoking model-backed operations.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
require Logger
|
||||
@@ -935,5 +941,5 @@ defmodule BDS.AI.Chat do
|
||||
defp encode_nullable(nil), do: nil
|
||||
defp encode_nullable(value), do: Jason.encode!(value)
|
||||
|
||||
defp blank?(value), do: value in [nil, ""]
|
||||
defp blank?(value), do: BDS.Values.blank?(value)
|
||||
end
|
||||
|
||||
@@ -107,5 +107,5 @@ defmodule BDS.AI.Runtime do
|
||||
end
|
||||
end
|
||||
|
||||
defp blank?(value), do: value in [nil, ""]
|
||||
defp blank?(value), do: BDS.Values.blank?(value)
|
||||
end
|
||||
|
||||
@@ -18,6 +18,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
MediaEditor,
|
||||
MenuEditor,
|
||||
MiscEditor,
|
||||
NativeMenuEvents,
|
||||
OverlayManager,
|
||||
ScriptEditor,
|
||||
SettingsEditor,
|
||||
@@ -81,6 +82,8 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|
||||
@git_action_events ["git_fetch", "git_pull", "git_push", "git_prune_lfs"]
|
||||
|
||||
@native_menu_events NativeMenuEvents.events()
|
||||
|
||||
@layout_menu_actions MapSet.new([
|
||||
:toggle_sidebar,
|
||||
:toggle_panel,
|
||||
@@ -524,31 +527,8 @@ defmodule BDS.Desktop.ShellLive do
|
||||
{:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))}
|
||||
end
|
||||
|
||||
def handle_event("native_menu_action", %{"action" => action}, socket) do
|
||||
{:noreply, handle_native_menu_action(socket, action)}
|
||||
end
|
||||
|
||||
def handle_event("titlebar_menu_keydown", %{"key" => key}, socket) do
|
||||
{:noreply, TitlebarMenu.handle_keydown(socket, key, &handle_native_menu_action/2)}
|
||||
end
|
||||
|
||||
def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do
|
||||
{:noreply, TitlebarMenu.toggle(socket, group)}
|
||||
end
|
||||
|
||||
def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do
|
||||
{:noreply, TitlebarMenu.hover(socket, group)}
|
||||
end
|
||||
|
||||
def handle_event("close_titlebar_menu", _params, socket) do
|
||||
{:noreply, TitlebarMenu.close(socket)}
|
||||
end
|
||||
|
||||
def handle_event("titlebar_menu_action", %{"action" => action}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> TitlebarMenu.close()
|
||||
|> handle_native_menu_action(action)}
|
||||
def handle_event(event, params, socket) when event in @native_menu_events do
|
||||
NativeMenuEvents.handle(event, params, socket, &handle_native_menu_action/2)
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -90,12 +90,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
end
|
||||
|
||||
def handle_event("send_chat_editor_message", _params, socket) do
|
||||
Logger.info("CHAT send_chat_editor_message called, input=#{inspect(socket.assigns.input)}")
|
||||
{:noreply, do_send_message(socket)}
|
||||
end
|
||||
|
||||
def handle_event("abort_chat_editor_message", _params, socket) do
|
||||
Logger.info("CHAT abort_chat_editor_message called")
|
||||
{:noreply, do_abort_message(socket)}
|
||||
end
|
||||
|
||||
@@ -581,8 +579,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
def chart_width(_max_value, _value), do: 0
|
||||
|
||||
@spec truthy?(term()) :: boolean()
|
||||
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
|
||||
def truthy?(_value), do: false
|
||||
def truthy?(value), do: BDS.Values.truthy?(value)
|
||||
|
||||
# ── HEEx components ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -303,6 +303,5 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
||||
|
||||
defp map_value(_map, _key, default), do: default
|
||||
|
||||
defp truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
|
||||
defp truthy?(_value), do: false
|
||||
defp truthy?(value), do: BDS.Values.truthy?(value)
|
||||
end
|
||||
|
||||
@@ -1452,6 +1452,6 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
Map.get(item, :resolution) in ["overwrite", "merge"]
|
||||
end
|
||||
|
||||
defp present?(value), do: value not in [nil, ""]
|
||||
defp blank?(value), do: value in [nil, ""]
|
||||
defp present?(value), do: BDS.Values.present?(value)
|
||||
defp blank?(value), do: BDS.Values.blank?(value)
|
||||
end
|
||||
|
||||
@@ -310,5 +310,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
||||
@spec translate_phase(term()) :: term()
|
||||
def translate_phase(other), do: other
|
||||
|
||||
defp present?(value), do: value not in [nil, ""]
|
||||
defp present?(value), do: BDS.Values.present?(value)
|
||||
end
|
||||
|
||||
@@ -284,5 +284,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
||||
def maybe_put_option(opts, _key, nil), do: opts
|
||||
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
|
||||
|
||||
defp present?(value), do: value not in [nil, ""]
|
||||
defp present?(value), do: BDS.Values.present?(value)
|
||||
end
|
||||
|
||||
52
lib/bds/desktop/shell_live/native_menu_events.ex
Normal file
52
lib/bds/desktop/shell_live/native_menu_events.ex
Normal file
@@ -0,0 +1,52 @@
|
||||
defmodule BDS.Desktop.ShellLive.NativeMenuEvents do
|
||||
@moduledoc """
|
||||
Native- and titlebar-menu LiveView events extracted from `BDS.Desktop.ShellLive`.
|
||||
|
||||
`ShellLive` routes the events listed in `events/0` here via a single delegating
|
||||
`handle_event/3`, passing its `handle_native_menu_action/2` callback so the
|
||||
menu-action dispatch logic stays owned by `ShellLive`.
|
||||
"""
|
||||
|
||||
alias BDS.Desktop.ShellLive.TitlebarMenu
|
||||
|
||||
@events ~w(
|
||||
native_menu_action
|
||||
titlebar_menu_keydown
|
||||
toggle_titlebar_menu
|
||||
hover_titlebar_menu
|
||||
close_titlebar_menu
|
||||
titlebar_menu_action
|
||||
)
|
||||
|
||||
@doc "Event names handled by this module."
|
||||
@spec events() :: [String.t()]
|
||||
def events, do: @events
|
||||
|
||||
@typep socket :: Phoenix.LiveView.Socket.t()
|
||||
@typep native_action :: (socket(), String.t() -> socket())
|
||||
|
||||
@spec handle(String.t(), map(), socket(), native_action()) :: {:noreply, socket()}
|
||||
def handle("native_menu_action", %{"action" => action}, socket, native_action) do
|
||||
{:noreply, native_action.(socket, action)}
|
||||
end
|
||||
|
||||
def handle("titlebar_menu_keydown", %{"key" => key}, socket, native_action) do
|
||||
{:noreply, TitlebarMenu.handle_keydown(socket, key, native_action)}
|
||||
end
|
||||
|
||||
def handle("toggle_titlebar_menu", %{"group" => group}, socket, _native_action) do
|
||||
{:noreply, TitlebarMenu.toggle(socket, group)}
|
||||
end
|
||||
|
||||
def handle("hover_titlebar_menu", %{"group" => group}, socket, _native_action) do
|
||||
{:noreply, TitlebarMenu.hover(socket, group)}
|
||||
end
|
||||
|
||||
def handle("close_titlebar_menu", _params, socket, _native_action) do
|
||||
{:noreply, TitlebarMenu.close(socket)}
|
||||
end
|
||||
|
||||
def handle("titlebar_menu_action", %{"action" => action}, socket, native_action) do
|
||||
{:noreply, socket |> TitlebarMenu.close() |> native_action.(action)}
|
||||
end
|
||||
end
|
||||
@@ -7,12 +7,17 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
import Ecto.Query
|
||||
require Logger
|
||||
|
||||
alias BDS.{I18n, Media, Metadata, Posts, Repo}
|
||||
alias BDS.{I18n, Media, Metadata, Posts, Repo, Slug, Strings}
|
||||
alias BDS.Media.Media, as: MediaRecord
|
||||
alias BDS.Media.Translation, as: MediaTranslation
|
||||
alias BDS.Posts.{Post, PostMedia, Translation}
|
||||
alias BDS.Tags.Tag
|
||||
|
||||
# Expected data/DB exceptions for the overlay fallbacks below. Narrowed (not a
|
||||
# broad `rescue error ->`) so programmer bugs (MatchError, FunctionClauseError)
|
||||
# surface unmodified instead of being swallowed into a fallback value.
|
||||
@overlay_rescue [Ecto.NoResultsError, Ecto.QueryError, DBConnection.ConnectionError]
|
||||
|
||||
embed_templates("overlay_html/*")
|
||||
|
||||
def context(assigns, tab_title, tab_subtitle) do
|
||||
@@ -71,8 +76,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
metadata
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("project metadata", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("project metadata", error)
|
||||
%{main_language: "en", blog_languages: []}
|
||||
end
|
||||
|
||||
@@ -140,8 +145,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
select: pm.media_id
|
||||
)
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("post media ids for #{post_id}", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("post media ids for #{post_id}", error)
|
||||
[]
|
||||
end
|
||||
|
||||
@@ -155,8 +160,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
)
|
||||
|> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end)
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("post translations for #{post_id}", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("post translations for #{post_id}", error)
|
||||
%{}
|
||||
end
|
||||
|
||||
@@ -168,8 +173,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
)
|
||||
|> Map.new(fn {language, status} -> {language, status} end)
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("media translations for #{media_id}", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("media translations for #{media_id}", error)
|
||||
%{}
|
||||
end
|
||||
|
||||
@@ -188,8 +193,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
_other -> metadata.main_language || "en"
|
||||
end
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("post source language for #{post_id}", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("post source language for #{post_id}", error)
|
||||
metadata.main_language || "en"
|
||||
end
|
||||
|
||||
@@ -199,21 +204,16 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
_other -> metadata.main_language || "en"
|
||||
end
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("media source language for #{media_id}", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("media source language for #{media_id}", error)
|
||||
metadata.main_language || "en"
|
||||
end
|
||||
|
||||
defp source_language(_tab, metadata), do: metadata.main_language || "en"
|
||||
|
||||
defp language_names do
|
||||
%{
|
||||
"en" => "English",
|
||||
"de" => "Deutsch",
|
||||
"fr" => "Francais",
|
||||
"it" => "Italiano",
|
||||
"es" => "Espanol"
|
||||
}
|
||||
I18n.supported_languages()
|
||||
|> Enum.into(%{}, fn language -> {language.code, I18n.language_name(language.code)} end)
|
||||
end
|
||||
|
||||
defp language_flags do
|
||||
@@ -244,7 +244,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
%{
|
||||
key: "slug",
|
||||
label: BDS.Gettext.lgettext(page_language, "ui", "Slug"),
|
||||
current_value: post.slug || slugify(post.title || title),
|
||||
current_value: post.slug || Slug.slugify(post.title || title),
|
||||
suggested_value: "",
|
||||
locked: post.status == :published,
|
||||
loading: true
|
||||
@@ -255,8 +255,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
[]
|
||||
end
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("post AI fields for #{post_id}", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("post AI fields for #{post_id}", error)
|
||||
[]
|
||||
end
|
||||
|
||||
@@ -294,8 +294,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
[]
|
||||
end
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("media AI fields for #{media_id}", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("media AI fields for #{media_id}", error)
|
||||
[]
|
||||
end
|
||||
|
||||
@@ -326,8 +326,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
reference_list: reference_list
|
||||
}
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("delete media details for #{media_id}", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("delete media details for #{media_id}", error)
|
||||
|
||||
%{
|
||||
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Media"),
|
||||
@@ -349,8 +349,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
reference_list: []
|
||||
}
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("delete tag details", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("delete tag details", error)
|
||||
|
||||
%{
|
||||
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
|
||||
@@ -388,8 +388,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
message: BDS.Gettext.lgettext(page_language, "ui", "Cannot be undone.")
|
||||
}
|
||||
rescue
|
||||
error ->
|
||||
log_overlay_warning("merge tag details for project #{project_id}", error)
|
||||
error in @overlay_rescue ->
|
||||
log_overlay_error("merge tag details for project #{project_id}", error)
|
||||
|
||||
%{
|
||||
target: "tag",
|
||||
@@ -402,20 +402,10 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
defp canonical_post_url(post) do
|
||||
timestamp = post.published_at || post.updated_at || System.system_time(:millisecond)
|
||||
date = DateTime.from_unix!(timestamp, :millisecond)
|
||||
"/#{date.year}/#{pad2(date.month)}/#{pad2(date.day)}/#{post.slug || post.id}"
|
||||
"/#{date.year}/#{Strings.pad2(date.month)}/#{Strings.pad2(date.day)}/#{post.slug || post.id}"
|
||||
end
|
||||
|
||||
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||
|
||||
defp slugify(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[^a-z0-9]+/u, "-")
|
||||
|> String.trim("-")
|
||||
end
|
||||
|
||||
defp log_overlay_warning(context, error) do
|
||||
Logger.warning("overlay component fallback for #{context}: #{Exception.message(error)}")
|
||||
defp log_overlay_error(context, error) do
|
||||
Logger.error("overlay component fallback for #{context}: #{Exception.message(error)}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -300,5 +300,5 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
"#{rounded}%"
|
||||
end
|
||||
|
||||
defp present?(value), do: value not in [nil, ""]
|
||||
defp present?(value), do: BDS.Values.present?(value)
|
||||
end
|
||||
|
||||
@@ -227,8 +227,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
|
||||
active_language == canonical_language or not Map.has_key?(translations, active_language)
|
||||
end
|
||||
|
||||
defp truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
|
||||
defp truthy?(_value), do: false
|
||||
defp truthy?(value), do: BDS.Values.truthy?(value)
|
||||
|
||||
defp blank_to_nil(value) do
|
||||
value
|
||||
|
||||
@@ -201,18 +201,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|
||||
|
||||
defp canonical_preview_path(created_at_ms, slug) do
|
||||
datetime = DateTime.from_unix!(created_at_ms, :millisecond)
|
||||
"/#{datetime.year}/#{pad2(datetime.month)}/#{pad2(datetime.day)}/#{slug || ""}"
|
||||
"/#{datetime.year}/#{BDS.Strings.pad2(datetime.month)}/#{BDS.Strings.pad2(datetime.day)}/#{slug || ""}"
|
||||
end
|
||||
|
||||
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||
|
||||
defp maybe_put_query(query, _key, false), do: query
|
||||
defp maybe_put_query(query, _key, nil), do: query
|
||||
defp maybe_put_query(query, key, value), do: Map.put(query, key, value)
|
||||
|
||||
def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
|
||||
@spec truthy?(term()) :: term()
|
||||
def truthy?(_value), do: false
|
||||
@spec truthy?(term()) :: boolean()
|
||||
def truthy?(value), do: BDS.Values.truthy?(value)
|
||||
|
||||
@spec blank?(term()) :: term()
|
||||
def blank?(value), do: blank_to_nil(value) == nil
|
||||
|
||||
@@ -312,7 +312,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
||||
defp normalize_endpoint_result({:ok, _endpoint}), do: :ok
|
||||
defp normalize_endpoint_result({:error, reason}), do: {:error, reason}
|
||||
|
||||
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
|
||||
defp truthy?(value), do: BDS.Values.truthy?(value)
|
||||
|
||||
defp blank_to_nil(nil), do: nil
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do
|
||||
}
|
||||
end
|
||||
|
||||
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
|
||||
defp truthy?(value), do: BDS.Values.truthy?(value)
|
||||
defp boolean_string(true), do: "true"
|
||||
defp boolean_string(false), do: "false"
|
||||
end
|
||||
|
||||
@@ -187,7 +187,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
|
||||
end)
|
||||
end
|
||||
|
||||
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
|
||||
defp truthy?(value), do: BDS.Values.truthy?(value)
|
||||
|
||||
defp blank_to_nil(nil), do: nil
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
||||
}
|
||||
end
|
||||
|
||||
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
|
||||
defp truthy?(value), do: BDS.Values.truthy?(value)
|
||||
|
||||
defp parse_integer(nil, fallback), do: fallback
|
||||
defp parse_integer(value, _fallback) when is_integer(value), do: value
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
defmodule BDS.Embeddings do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Context for post embeddings: build and refresh the per-project vector index,
|
||||
sync individual posts, report indexing progress, and compute similarity.
|
||||
|
||||
The backing index is persisted to disk and rebuilt on demand; a failed persist
|
||||
is retried by the next reindex.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
require Logger
|
||||
|
||||
@@ -108,7 +108,10 @@ defmodule BDS.Embeddings.Backends.Neural do
|
||||
{:reply, error, state}
|
||||
end
|
||||
rescue
|
||||
exception ->
|
||||
# Only genuine Nx/serving runtime errors become {:error, _}. Programmer bugs
|
||||
# (MatchError, FunctionClauseError, …) are not caught here so they crash the
|
||||
# supervised GenServer and surface unmodified instead of being repackaged.
|
||||
exception in [ArgumentError, RuntimeError] ->
|
||||
{:reply, {:error, Exception.message(exception)}, state}
|
||||
end
|
||||
|
||||
|
||||
@@ -347,8 +347,10 @@ defmodule BDS.Embeddings.Index do
|
||||
:ok
|
||||
rescue
|
||||
exception ->
|
||||
Logger.debug(
|
||||
"swallowed embeddings index persist error for #{project_id}: #{inspect(exception)}"
|
||||
# Logged at :error (not :debug) so a real persist failure — or a programmer
|
||||
# bug surfacing as an exception — is visible; next reindex retries the save.
|
||||
Logger.error(
|
||||
"embeddings index persist failed for #{project_id}: #{inspect(exception)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
@@ -392,8 +394,8 @@ defmodule BDS.Embeddings.Index do
|
||||
end
|
||||
rescue
|
||||
exception ->
|
||||
Logger.debug(
|
||||
"swallowed embeddings index load_from_disk error for #{project_id}: #{inspect(exception)}"
|
||||
Logger.error(
|
||||
"embeddings index load_from_disk failed for #{project_id}: #{inspect(exception)}"
|
||||
)
|
||||
|
||||
:error
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
defmodule BDS.Generation do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Context for static-site generation: plan, generate, and validate a project's
|
||||
rendered output, apply validation results, and write generated files.
|
||||
|
||||
Generation is organised into sections (defaulting to `:core`) so callers can
|
||||
regenerate a subset of the site.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
defmodule BDS.Git do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Git operations for a project's repository: initialize, inspect status and
|
||||
diffs, browse history, and run network actions (fetch/pull/push) with
|
||||
Git LFS support.
|
||||
|
||||
Local and network operations use separate timeouts; network calls are gated by
|
||||
the app's offline mode at the call sites that invoke them.
|
||||
"""
|
||||
|
||||
alias BDS.Projects
|
||||
require Logger
|
||||
|
||||
@@ -28,6 +28,15 @@ defmodule BDS.I18n do
|
||||
"ES" => "🇪🇸"
|
||||
}
|
||||
|
||||
# Native autonyms — a language's self-name, not translated into the UI locale.
|
||||
@language_names %{
|
||||
"en" => "English",
|
||||
"de" => "Deutsch",
|
||||
"fr" => "Français",
|
||||
"it" => "Italiano",
|
||||
"es" => "Español"
|
||||
}
|
||||
|
||||
@default_language "en"
|
||||
@default_format_locale "en-US"
|
||||
|
||||
@@ -35,6 +44,12 @@ defmodule BDS.I18n do
|
||||
|
||||
def default_language, do: @default_language
|
||||
|
||||
@doc "Native autonym (self-name) for a supported language code, e.g. \"de\" -> \"Deutsch\"."
|
||||
def language_name(language) do
|
||||
code = resolve_render_locale(language)
|
||||
Map.get(@language_names, code, code)
|
||||
end
|
||||
|
||||
def normalize_language(language) do
|
||||
language
|
||||
|> normalize_locale_prefix()
|
||||
|
||||
@@ -423,13 +423,14 @@ defmodule BDS.ImportAnalysis do
|
||||
end
|
||||
|
||||
defp parse_macro_params(raw_params) do
|
||||
Regex.scan(@param_regex, raw_params)
|
||||
|> Enum.map(fn captures ->
|
||||
key = Enum.at(captures, 1)
|
||||
value = Enum.at(captures, 2) || Enum.at(captures, 3) || Enum.at(captures, 4) || ""
|
||||
@param_regex
|
||||
|> Regex.scan(raw_params)
|
||||
|> Map.new(fn [_full, key | value_groups] ->
|
||||
# Exactly one of the quoted/unquoted value groups participates (the others
|
||||
# are "" or dropped by Regex.scan); an empty quoted value falls back to "".
|
||||
value = value_groups |> Enum.reject(&(&1 == "")) |> List.last() |> Kernel.||("")
|
||||
{key, value}
|
||||
end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp serialize_params(params) when params == %{}, do: ""
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
defmodule BDS.Media do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Context for media assets and their translations: import, update, replace, and
|
||||
delete media, and manage per-language metadata translations.
|
||||
|
||||
Each operation keeps the database record, the media file on disk, and the
|
||||
derived metadata in sync.
|
||||
"""
|
||||
|
||||
import BDS.Media.FileOps,
|
||||
only: [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
defmodule BDS.Posts do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Context for blog posts and their translations: create, update, publish, and
|
||||
discard changes, plus editor-body resolution.
|
||||
|
||||
Published posts keep their body on the filesystem rather than in the database,
|
||||
so `editor_body/1` reads from the post's file when no in-memory content is
|
||||
present. Changes are kept in sync with the filesystem and the embeddings index.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
defmodule BDS.Preview do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
GenServer that runs the local, self-contained preview server for a project.
|
||||
|
||||
Starts/stops per-project preview rendering and serves rendered pages and draft
|
||||
previews via `request/2` and `preview_draft/3`. Generated HTML references only
|
||||
local, package-bundled assets — never remote CDN JS/CSS.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
@@ -22,10 +28,12 @@ defmodule BDS.Preview do
|
||||
@listen_retry_attempts 10
|
||||
@listen_retry_delay_ms 50
|
||||
|
||||
@spec start_link(term()) :: GenServer.on_start()
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||
end
|
||||
|
||||
@spec start_preview(String.t()) :: {:ok, map()} | {:error, term()}
|
||||
def start_preview(project_id) when is_binary(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
|
||||
@@ -35,6 +43,7 @@ defmodule BDS.Preview do
|
||||
)
|
||||
end
|
||||
|
||||
@spec ensure_preview(String.t()) :: {:ok, map()} | {:error, term()}
|
||||
def ensure_preview(project_id) when is_binary(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
|
||||
@@ -44,6 +53,7 @@ defmodule BDS.Preview do
|
||||
)
|
||||
end
|
||||
|
||||
@spec base_url() :: String.t()
|
||||
def base_url do
|
||||
case :ets.whereis(@server_table) do
|
||||
:undefined -> "http://#{@host}:#{@preferred_port}"
|
||||
@@ -56,10 +66,12 @@ defmodule BDS.Preview do
|
||||
end
|
||||
end
|
||||
|
||||
@spec stop_preview(String.t()) :: :ok | {:error, term()}
|
||||
def stop_preview(project_id) when is_binary(project_id) do
|
||||
GenServer.call(__MODULE__, {:stop_preview, project_id})
|
||||
end
|
||||
|
||||
@spec request(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
|
||||
def request(project_id, request_path) when is_binary(project_id) and is_binary(request_path) do
|
||||
{path, query_params} = split_request_path(request_path)
|
||||
|
||||
@@ -68,6 +80,7 @@ defmodule BDS.Preview do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec preview_draft(String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, term()}
|
||||
def preview_draft(project_id, request_path, post_id)
|
||||
when is_binary(project_id) and is_binary(request_path) and is_binary(post_id) do
|
||||
{_path, query_params} = split_request_path(request_path)
|
||||
|
||||
@@ -238,26 +238,27 @@ defmodule BDS.Rendering.Filters do
|
||||
path_part |> String.replace(~r/\.html?$/i, "")
|
||||
|
||||
match = Regex.run(~r|^/?post/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
|
||||
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "")
|
||||
Map.get(canonical_post_paths, slug)
|
||||
slug_from_match(match, canonical_post_paths)
|
||||
|
||||
match = Regex.run(~r|^/?post/\d{4}/\d{1,2}/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
|
||||
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "")
|
||||
Map.get(canonical_post_paths, slug)
|
||||
slug_from_match(match, canonical_post_paths)
|
||||
|
||||
match = Regex.run(~r|^/?posts/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
|
||||
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "")
|
||||
Map.get(canonical_post_paths, slug)
|
||||
slug_from_match(match, canonical_post_paths)
|
||||
|
||||
match = Regex.run(~r|^/?posts/\d{4}/\d{1,2}/([a-z0-9-]+(?:\.html?)?)$|i, path_part) ->
|
||||
slug = match |> Enum.at(1) |> String.replace(~r/\.html?$/i, "")
|
||||
Map.get(canonical_post_paths, slug)
|
||||
slug_from_match(match, canonical_post_paths)
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp slug_from_match([_full, captured], canonical_post_paths) do
|
||||
slug = String.replace(captured, ~r/\.html?$/i, "")
|
||||
Map.get(canonical_post_paths, slug)
|
||||
end
|
||||
|
||||
defp normalize_media_src(raw_src, canonical_media_paths) do
|
||||
cond do
|
||||
raw_src == "" ->
|
||||
|
||||
@@ -120,7 +120,7 @@ defmodule BDS.Rendering.LinksAndLanguages do
|
||||
|
||||
defp post_date_path_parts(created_at) do
|
||||
datetime = Persistence.from_unix_ms!(created_at)
|
||||
year_month_path_parts(datetime) ++ [pad2(datetime.day)]
|
||||
year_month_path_parts(datetime) ++ [BDS.Strings.pad2(datetime.day)]
|
||||
end
|
||||
|
||||
defp year_month_path_parts(created_at) when is_integer(created_at) do
|
||||
@@ -130,13 +130,11 @@ defmodule BDS.Rendering.LinksAndLanguages do
|
||||
end
|
||||
|
||||
defp year_month_path_parts(%DateTime{} = datetime) do
|
||||
[Integer.to_string(datetime.year), pad2(datetime.month)]
|
||||
[Integer.to_string(datetime.year), BDS.Strings.pad2(datetime.month)]
|
||||
end
|
||||
|
||||
defp normalize_language_prefix(prefix) when is_binary(prefix) and prefix != "",
|
||||
do: String.trim_trailing(prefix, "/")
|
||||
|
||||
defp normalize_language_prefix(_prefix), do: ""
|
||||
|
||||
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||
end
|
||||
|
||||
@@ -160,7 +160,7 @@ defmodule BDS.Scripting.Capabilities.Util do
|
||||
def truthy?(value), do: value in [true, "true", 1, 1.0, "1"]
|
||||
|
||||
@spec pad2(integer()) :: String.t()
|
||||
def pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||
defdelegate pad2(value), to: BDS.Strings
|
||||
|
||||
@spec blank_to_nil(term()) :: term()
|
||||
def blank_to_nil(nil), do: nil
|
||||
|
||||
9
lib/bds/strings.ex
Normal file
9
lib/bds/strings.ex
Normal file
@@ -0,0 +1,9 @@
|
||||
defmodule BDS.Strings do
|
||||
@moduledoc """
|
||||
Small string-formatting helpers shared across the codebase.
|
||||
"""
|
||||
|
||||
@doc "Zero-pads an integer to at least two digits, e.g. `3 -> \"03\"`."
|
||||
@spec pad2(integer()) :: String.t()
|
||||
def pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||
end
|
||||
@@ -1,5 +1,8 @@
|
||||
defmodule BDS.Tags do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Context for post tags: create, list, update, rename, delete, and merge tags,
|
||||
and keep them in sync with posts and the on-disk `tags.json`.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
|
||||
@@ -194,5 +194,5 @@ defmodule BDS.UI.Dashboard do
|
||||
if blank?(post.title), do: post.slug || "", else: post.title
|
||||
end
|
||||
|
||||
defp blank?(value), do: value in [nil, ""]
|
||||
defp blank?(value), do: BDS.Values.blank?(value)
|
||||
end
|
||||
|
||||
@@ -1125,5 +1125,5 @@ defmodule BDS.UI.Sidebar do
|
||||
|
||||
defp media_size_label(_size), do: "0 B"
|
||||
|
||||
defp present?(value), do: value not in [nil, ""]
|
||||
defp present?(value), do: BDS.Values.present?(value)
|
||||
end
|
||||
|
||||
27
lib/bds/values.ex
Normal file
27
lib/bds/values.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule BDS.Values do
|
||||
@moduledoc """
|
||||
Canonical scalar predicates for blank/present/truthy checks.
|
||||
|
||||
These operate on individual values (not maps), which is why they live here
|
||||
rather than in `BDS.MapUtils`. `present?/1` and `blank?/1` use the same
|
||||
non-trimming contract as `BDS.MapUtils.blank_to_nil/1`: a whitespace-only
|
||||
string such as `" "` is considered **present**. Call sites that need
|
||||
trim-aware semantics must compose `String.trim/1` themselves.
|
||||
"""
|
||||
|
||||
@doc "Maps `nil` and `\"\"` to `nil`, everything else to itself (non-trimming)."
|
||||
@spec blank_to_nil(term()) :: term()
|
||||
defdelegate blank_to_nil(value), to: BDS.MapUtils
|
||||
|
||||
@doc "True when `value` is neither `nil` nor `\"\"` (non-trimming)."
|
||||
@spec present?(term()) :: boolean()
|
||||
def present?(value), do: blank_to_nil(value) != nil
|
||||
|
||||
@doc "True when `value` is `nil` or `\"\"` (non-trimming)."
|
||||
@spec blank?(term()) :: boolean()
|
||||
def blank?(value), do: blank_to_nil(value) == nil
|
||||
|
||||
@doc "True for the canonical truthy set: `true`, `\"true\"`, `\"on\"`, `1`, `1.0`, `\"1\"`."
|
||||
@spec truthy?(term()) :: boolean()
|
||||
def truthy?(value), do: value in [true, "true", "on", 1, 1.0, "1"]
|
||||
end
|
||||
3
mix.exs
3
mix.exs
@@ -55,7 +55,8 @@ defmodule BDS.MixProject do
|
||||
{:lazy_html, ">= 0.1.0", only: :test},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
|
||||
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
|
||||
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
|
||||
{:ex_doc, "~> 0.34", only: :dev, runtime: false}
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
5
mix.lock
5
mix.lock
@@ -15,6 +15,7 @@
|
||||
"decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
|
||||
"desktop": {:hex, :desktop, "1.5.3", "dcf875dcff5b49a54646b4e6964acb079545c8c9c3790799aa5f1ccdcd314d15", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_sni, "~> 0.2", [hex: :ex_sni, repo: "hexpm", optional: false]}, {:gettext, "> 0.10.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:oncrash, "~> 0.1", [hex: :oncrash, repo: "hexpm", optional: false]}, {:phoenix, "> 1.0.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "> 1.0.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3750aabb8ed8aaf09b33f3cad5bda20f8ce4dfa65b026c019baed99c5264e2aa"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.45", "cba8369ab2a1342e419bc2760eec731b17be828941dcf494045d44766227e1d5", [:mix], [], "hexpm", "d3ec045bf122965db20c0bdb420e19ee1415843135327124918473feb4b328e8"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
||||
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
|
||||
@@ -23,6 +24,7 @@
|
||||
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ex_dbus": {:hex, :ex_dbus, "0.1.4", "053df83d45b27ba0b9b6ef55a47253922069a3ace12a2a7dd30d3aff58301e17", [:mix], [{:dbus, "~> 0.8.0", [hex: :dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "d8baeaf465eab57b70a47b70e29fdfef6eb09ba110fc37176eebe6ac7874d6d5"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"},
|
||||
"ex_sni": {:hex, :ex_sni, "0.2.9", "81f9421035dd3edb6d69f1a4dd5f53c7071b41628130d32ba5ab7bb4bfdc2da0", [:mix], [{:debouncer, "~> 0.1", [hex: :debouncer, repo: "hexpm", optional: false]}, {:ex_dbus, "~> 0.1", [hex: :ex_dbus, repo: "hexpm", optional: false]}, {:saxy, "~> 1.4.0", [hex: :saxy, repo: "hexpm", optional: false]}], "hexpm", "921d67d913765ed20ea8354fd1798dabc957bf66990a6842d6aaa7cd5ee5bc06"},
|
||||
"ex_stemmers": {:hex, :ex_stemmers, "0.1.0", "63a84ae3a6f0c28a1d75768411f0ae15cfe8462fb70589b60977aa1b04c9372d", [:mix], [{:rustler, "~> 0.32.1", [hex: :rustler, repo: "hexpm", optional: false]}], "hexpm", "498826e2188e502f41d1a15f3d90e7738f0d94747e197367f03a2a44c09167c0"},
|
||||
"exla": {:hex, :exla, "0.10.0", "93e7d75a774fbc06ce05b96de20c4b01bda413b315238cb3c727c09a05d2bc3a", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:nx, "~> 0.10.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.9.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "16fffdb64667d7f0a3bc683fdcd2792b143a9b345e4b1f1d5cd50330c63d8119"},
|
||||
@@ -42,6 +44,9 @@
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
|
||||
"liquex": {:hex, :liquex, "0.13.1", "49f90d0b85fb2908f2558f35cd49d78497fe77a895eb55b360889940e1d7afb9", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:html_entities, "~> 0.5.2", [hex: :html_entities, repo: "hexpm", optional: false]}, {:html_sanitize_ex, "~> 1.4.3", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fbea5b9db264c1758a69bfafdcc8aaebcd56e168365bb9575392cd55d800108f"},
|
||||
"luerl": {:hex, :luerl, "1.5.1", "f6700420950fc6889137e7a0c11c4a8467dea04a8c23f707a40d83566d14e786", [:rebar3], [], "hexpm", "abf88d849baa0d5dca93b245a8688d4de2ee3d588159bb2faf51e15946509390"},
|
||||
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
|
||||
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
|
||||
"makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"},
|
||||
"mdex": {:hex, :mdex, "0.13.1", "2af3da1e0ec1594d9fc4c0134031a0b48397092963eef87448a7a1c2b9332663", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.2", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "99e934e977ea4045914870616baae8d4e1513aa0309f769ed65a681ebe5e0f02"},
|
||||
"mdex_native": {:hex, :mdex_native, "0.2.2", "adc230643a173b4a2b3593c450f56c41e51714c51f59bb8e528993810373cb92", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f16b0548dca63b91c134316cc89c1160119e8f308db57bcc5fc058206a7df32d"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
defmodule BDS.I18nTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
test "language_name/1 returns a non-empty native autonym for exactly the supported codes" do
|
||||
codes = Enum.map(BDS.I18n.supported_languages(), & &1.code)
|
||||
|
||||
for code <- codes do
|
||||
assert BDS.I18n.language_name(code) not in [nil, ""]
|
||||
end
|
||||
|
||||
assert BDS.I18n.language_name("de") == "Deutsch"
|
||||
assert BDS.I18n.language_name("fr") == "Français"
|
||||
assert BDS.I18n.language_name("es") == "Español"
|
||||
# Unsupported codes fall back to the resolved render locale (default "en").
|
||||
assert BDS.I18n.language_name("pt-BR") == "English"
|
||||
end
|
||||
|
||||
test "supported languages, normalization, and UI locale resolution follow the spec" do
|
||||
assert Enum.map(BDS.I18n.supported_languages(), & &1.code) == ["en", "de", "fr", "it", "es"]
|
||||
|
||||
|
||||
12
test/bds/strings_test.exs
Normal file
12
test/bds/strings_test.exs
Normal file
@@ -0,0 +1,12 @@
|
||||
defmodule BDS.StringsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias BDS.Strings
|
||||
|
||||
test "pad2/1 zero-pads to at least two digits" do
|
||||
assert Strings.pad2(0) == "00"
|
||||
assert Strings.pad2(3) == "03"
|
||||
assert Strings.pad2(12) == "12"
|
||||
assert Strings.pad2(123) == "123"
|
||||
end
|
||||
end
|
||||
@@ -552,7 +552,13 @@ defmodule BDS.UI.ShellTest do
|
||||
"phx-window-keydown={if(@titlebar_menu_group, do: \"titlebar_menu_keydown\")}"
|
||||
|
||||
assert template =~ "window-titlebar-menu-group"
|
||||
assert live_ex =~ ~s(def handle_event("titlebar_menu_keydown")
|
||||
|
||||
# titlebar/native menu events are routed from ShellLive to NativeMenuEvents.
|
||||
native_menu_ex =
|
||||
File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/native_menu_events.ex")
|
||||
|
||||
assert live_ex =~ "@native_menu_events"
|
||||
assert native_menu_ex =~ ~s(def handle("titlebar_menu_keydown")
|
||||
assert live_ex =~ "titlebar_menu_item_index"
|
||||
end
|
||||
|
||||
|
||||
51
test/bds/values_test.exs
Normal file
51
test/bds/values_test.exs
Normal file
@@ -0,0 +1,51 @@
|
||||
defmodule BDS.ValuesTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias BDS.Values
|
||||
|
||||
describe "present?/1 and blank?/1 (non-trimming)" do
|
||||
test "nil and empty string are blank" do
|
||||
assert Values.blank?(nil)
|
||||
assert Values.blank?("")
|
||||
refute Values.present?(nil)
|
||||
refute Values.present?("")
|
||||
end
|
||||
|
||||
test "whitespace-only strings are PRESENT (non-trimming contract)" do
|
||||
# Crucial: matches BDS.MapUtils.blank_to_nil/1 — a trim-aware caller must
|
||||
# compose String.trim/1 itself.
|
||||
assert Values.present?(" ")
|
||||
assert Values.present?("\n")
|
||||
refute Values.blank?(" ")
|
||||
refute Values.blank?("\n")
|
||||
end
|
||||
|
||||
test "non-empty values are present" do
|
||||
for v <- ["x", 0, 1, 1.0, false, true] do
|
||||
assert Values.present?(v)
|
||||
refute Values.blank?(v)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "truthy?/1" do
|
||||
test "accepts the canonical truthy set" do
|
||||
for v <- [true, "true", "on", 1, 1.0, "1"] do
|
||||
assert Values.truthy?(v)
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects everything else" do
|
||||
for v <- [false, "false", "off", 0, "0", nil, "", "yes", 2] do
|
||||
refute Values.truthy?(v)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "blank_to_nil/1 delegates to MapUtils (non-trimming)" do
|
||||
assert Values.blank_to_nil(nil) == nil
|
||||
assert Values.blank_to_nil("") == nil
|
||||
assert Values.blank_to_nil(" ") == " "
|
||||
assert Values.blank_to_nil("x") == "x"
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user