Compare commits

...

2 Commits

Author SHA1 Message Date
5a464920de chore: next big god module down
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 10:47:25 +02:00
13a86e92bd chore: next god module down (AI)
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 10:19:26 +02:00
17 changed files with 3438 additions and 3116 deletions

View File

@@ -464,7 +464,33 @@ Total: 2245 lines now live in focused submodules; the remaining 647 in `BDS.Gene
| `Translations` | 279 | `publish_post_translation/2`, `list_post_translations/1`, `upsert_post_translation/3`, `delete_post_translation/1`, `sync_post_translation_from_file/1`, `rewrite_published_post_translation/1`, `publish_translation/2`, `publish_post_translations/1`, `normalize_translation_updates`, `maybe_reopen_source_post_for_manual_translation` | | `Translations` | 279 | `publish_post_translation/2`, `list_post_translations/1`, `upsert_post_translation/3`, `delete_post_translation/1`, `sync_post_translation_from_file/1`, `rewrite_published_post_translation/1`, `publish_translation/2`, `publish_post_translations/1`, `normalize_translation_updates`, `maybe_reopen_source_post_for_manual_translation` |
Public API on `BDS.Posts` preserved via `defdelegate` for: `slug_available/3`, `unique_slug_for_title/3`, `validate_translations/2`, `fix_invalid_translations/1`, `rebuild_posts_from_files/2`, `import_orphan_post_file/2`, `import_orphan_post_translation_file/2`, `publish_post_translation/2`, `list_post_translations/1`, `upsert_post_translation/3`, `delete_post_translation/1`, `sync_post_translation_from_file/1`, `rewrite_published_post_translation/1`. Remaining clusters in posts.ex are core CRUD (`create_post`, `update_post`, `publish_post`, `delete_post`, `archive_post`, `discard_post_changes`, `sync_post_from_file`, `rewrite_published_post`, `editor_body`), small stats (`dashboard_stats`, `post_counts_by_year_month`, ~40 lines extractable), and `rebuild_post_links` (~22 lines). Stats could be split next, but ~569 lines is a reasonable steady state. Public API on `BDS.Posts` preserved via `defdelegate` for: `slug_available/3`, `unique_slug_for_title/3`, `validate_translations/2`, `fix_invalid_translations/1`, `rebuild_posts_from_files/2`, `import_orphan_post_file/2`, `import_orphan_post_translation_file/2`, `publish_post_translation/2`, `list_post_translations/1`, `upsert_post_translation/3`, `delete_post_translation/1`, `sync_post_translation_from_file/1`, `rewrite_published_post_translation/1`. Remaining clusters in posts.ex are core CRUD (`create_post`, `update_post`, `publish_post`, `delete_post`, `archive_post`, `discard_post_changes`, `sync_post_from_file`, `rewrite_published_post`, `editor_body`), small stats (`dashboard_stats`, `post_counts_by_year_month`, ~40 lines extractable), and `rebuild_post_links` (~22 lines). Stats could be split next, but ~569 lines is a reasonable steady state.
-`BDS.AI` (1711). -`BDS.AI` (1711 → 168, **90% reduction**). Submodules extracted under `lib/bds/ai/`:
| Module | Lines | Responsibility |
|---|---|---|
| `Chat` | 597 | Public chat API (`start_chat`, `list_chat_conversations`, `available_chat_models`, `set_conversation_model`, `list_chat_messages`, `send_chat_message`, `cancel_chat`) + chat round/tool-call orchestration, request building, message truncation, system prompt + project stats summary, conversation/message persistence, `count_distinct_string_list/3`, `normalize_usage/1` |
| `OneShot` | 382 | One-shot operations (`detect_language`, `analyze_taxonomy`, `analyze_import_taxonomy`, `analyze_post`, `translate_post`, `analyze_image`, `translate_media`) + per-op system/user prompt builders, JSON response extraction, post/media input normalization, taxonomy mapping filtering |
| `Catalog` | 306 | Model catalog API (`list_endpoint_models`, `refresh_model_catalog`, `list_catalog_providers`, `get_catalog_model`, `catalog_meta`, `put_model_capabilities`, `format_model`, `model_capabilities`, `decode_nullable_json`) + models.dev fetch, persistence, modality parsing |
| `ChatTools` | 271 | Chat tool dispatch (`execute/3`) for blog_stats, list_posts, list_media, render_table/chart/form/card/metric/list/tabs/mindmap; tool spec generation (`available_specs/2`) |
| `Runtime` | 100 | `model_preference_keys/0`, `resolve_target/2`, `validate_target/3`, `endpoint_with_model/2`, per-operation airplane/online resolution |
| `SettingsStore` | 78 | Setting/secret/catalog-meta storage helpers (`get_setting`, `put_setting`, `delete_setting`, `put_secret`, `get_secret`, `encrypted_key`, `secret_backend`, catalog meta) |
Public `BDS.AI` API preserved via `defdelegate` for all extracted operations. Remaining 168 lines hold endpoint storage (`put_endpoint`, `get_endpoint`, `delete_endpoint`), airplane mode (`set_airplane_mode`, `airplane_mode?`), model preferences (`put_model_preference`, `get_model_preference`), and the defdelegate facade.
-`BDS.Scripting.Capabilities` (1715 → 194, **89% reduction**). Submodules extracted under `lib/bds/scripting/capabilities/`:
| Module | Lines | Responsibility |
|---|---|---|
| `Util` | 301 | Sanitization, normalization, arity wrappers, optional-key map builders, datetime parsing, project-path lookup, shell-open helpers |
| `Posts` | 270 | All `posts.*` capabilities (CRUD, publishing, body/cover/excerpt, search, tags, categories, archive, restore, preview path, names-with-counts) |
| `Media` | 254 | All `media.*` capabilities (CRUD, upload, thumbnails, metadata, translations, search) |
| `Crud` | 284 | `scripts.*`, `templates.*`, `tags.*`, `tasks.*` CRUD/search/exec |
| `Projects` | 204 | Project CRUD, metadata read/write, sync-meta-on-startup, data paths, project-for-folder |
| `AppShell` | 134 | Clipboard, bookmarklet, title-bar metrics, renderer-ready, open/select folder, show-in-folder, trigger-menu-action, preview-target, test-mode/env detection |
| `Bridges` | 176 | Sync availability, repo state/status/history/fetch/pull/push/commit-all, upload-site, AI detect/analyze/translate (post + media), embeddings progress/find-similar/compute-similarities/suggest-tags/find-duplicates/dismiss-pair/index-unindexed |
Public `BDS.Scripting.Capabilities.for_project/2` contract preserved unchanged. Main file (194 lines) now holds only the capability-map assembly using `import` of all submodules.
-`BDS.MCP` (677). -`BDS.MCP` (677).
--- ---

File diff suppressed because it is too large Load Diff

306
lib/bds/ai/catalog.ex Normal file
View File

@@ -0,0 +1,306 @@
defmodule BDS.AI.Catalog do
@moduledoc false
import Ecto.Query
import BDS.AI.SettingsStore,
only: [
get_setting: 1,
put_setting: 2,
get_catalog_meta_value: 1,
put_catalog_meta: 2
]
alias BDS.AI.CatalogProvider
alias BDS.AI.Model
alias BDS.AI.ModelModality
alias BDS.AI.OpenAICompatibleRuntime
alias BDS.Persistence
alias BDS.Repo
@catalog_url "https://models.dev/api.json"
@spec list_endpoint_models(map(), keyword()) :: {:ok, [map()]} | {:error, term()}
def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
http_client = Keyword.get(opts, :http_client, Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient))
OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client)
end
@spec refresh_model_catalog(keyword()) ::
{:ok, %{success: boolean(), models_updated: non_neg_integer(), not_modified: boolean()}}
| {:error, term()}
def refresh_model_catalog(opts \\ []) when is_list(opts) do
http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient)
headers =
%{"accept" => "application/json"}
|> maybe_put_header("if-none-match", get_catalog_meta_value("etag"))
with {:ok, response} <- http_get(http_client, @catalog_url, headers) do
case response.status do
304 ->
:ok = put_catalog_meta("last_fetched_at", DateTime.utc_now() |> DateTime.to_iso8601())
{:ok, %{success: true, models_updated: 0, not_modified: true}}
200 ->
payload = Jason.decode!(response.body)
models_updated = persist_catalog(payload)
if etag = response.headers["etag"] do
:ok = put_catalog_meta("etag", etag)
end
:ok = put_catalog_meta("last_fetched_at", DateTime.utc_now() |> DateTime.to_iso8601())
{:ok, %{success: true, models_updated: models_updated, not_modified: false}}
status ->
{:error, %{kind: :http_error, status: status}}
end
end
end
@spec list_catalog_providers() :: [map()]
def list_catalog_providers do
Repo.all(from(provider in CatalogProvider, order_by: [asc: provider.id]))
|> Enum.map(fn provider ->
%{
id: provider.id,
name: provider.name,
env_keys: decode_json_list(provider.env_keys),
package_ref: provider.package_ref,
api_url: provider.api_url,
doc_url: provider.doc_url,
updated_at: provider.updated_at
}
end)
end
@spec get_catalog_model(String.t(), String.t() | nil) :: {:ok, map()} | {:error, :not_found}
def get_catalog_model(model_id, provider_id \\ nil) when is_binary(model_id) do
query =
from(model in Model,
where: model.model_id == ^model_id,
order_by: [asc: model.provider]
)
query =
case provider_id do
nil -> query
provider -> from(model in query, where: model.provider == ^provider)
end
case Repo.one(query) do
nil -> {:error, :not_found}
model -> {:ok, format_model(model)}
end
end
@spec catalog_meta(String.t()) :: {:ok, String.t() | nil}
def catalog_meta(key) when is_binary(key) do
{:ok, get_catalog_meta_value(key)}
end
@spec put_model_capabilities(String.t(), map()) :: :ok | {:error, term()}
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
capabilities = %{
supports_attachment: truthy?(Map.get(attrs, :supports_attachment) || Map.get(attrs, "supports_attachment")),
supports_tool_calls: truthy?(Map.get(attrs, :supports_tool_calls) || Map.get(attrs, "supports_tool_calls"))
}
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
end
@spec format_model(map()) :: map()
def format_model(model) do
modalities =
Repo.all(
from(modality in ModelModality,
where: modality.provider == ^model.provider and modality.model_id == ^model.model_id
)
)
%{
provider: model.provider,
model_id: model.model_id,
name: model.name,
family: model.family,
supports_attachment: model.supports_attachment,
supports_reasoning: model.supports_reasoning,
supports_tool_calls: model.supports_tool_calls,
supports_structured_output: model.supports_structured_output,
supports_temperature: model.supports_temperature,
knowledge: model.knowledge,
release_date: model.release_date,
last_updated_date: model.last_updated_date,
open_weights: model.open_weights,
input_price: model.input_price,
output_price: model.output_price,
cache_read_price: model.cache_read_price,
cache_write_price: model.cache_write_price,
context_window: model.context_window,
max_input_tokens: model.max_input_tokens,
max_output_tokens: model.max_output_tokens,
interleaved: model.interleaved,
status: model.status,
updated_at: model.updated_at,
input_modalities:
modalities
|> Enum.filter(&(&1.direction == :input))
|> Enum.map(&Atom.to_string(&1.modality)),
output_modalities:
modalities
|> Enum.filter(&(&1.direction == :output))
|> Enum.map(&Atom.to_string(&1.modality))
}
end
@spec model_capabilities(String.t()) :: %{supports_attachment: boolean(), supports_tool_calls: boolean()}
def model_capabilities(model_id) do
overrides = decode_model_capabilities_override(model_id)
from_catalog =
case get_catalog_model(model_id) do
{:ok, model} ->
%{
supports_attachment: model.supports_attachment or ("image" in model.input_modalities),
supports_tool_calls: model.supports_tool_calls
}
_other ->
inferred_model_capabilities(model_id)
end
Map.merge(from_catalog, overrides)
end
@spec decode_nullable_json(nil | binary()) :: any()
def decode_nullable_json(nil), do: nil
def decode_nullable_json(value) when is_binary(value), do: Jason.decode!(value)
defp inferred_model_capabilities(model_id) do
normalized = String.downcase(model_id)
%{
supports_attachment:
String.contains?(normalized, "4o") or String.contains?(normalized, "vision") or
String.contains?(normalized, "llava"),
supports_tool_calls:
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
String.contains?(normalized, "tool")
}
end
defp decode_model_capabilities_override(model_id) do
case get_setting("ai.model_capabilities.#{model_id}") do
nil -> %{}
value -> Jason.decode!(value) |> atomize_map_keys()
end
end
defp atomize_map_keys(map) do
Enum.into(map, %{}, fn {key, value} -> {String.to_atom(key), value} end)
end
defp persist_catalog(payload) do
now = Persistence.now_ms()
Repo.transaction(fn ->
Repo.delete_all(ModelModality)
Repo.delete_all(Model)
Repo.delete_all(CatalogProvider)
Enum.reduce(payload, 0, fn {provider_id, provider_data}, count ->
provider_attrs = %{
id: provider_id,
name: Map.get(provider_data, "name", provider_id),
env_keys: Jason.encode!(Map.get(provider_data, "env", [])),
package_ref: Map.get(provider_data, "npm"),
api_url: Map.get(provider_data, "api"),
doc_url: Map.get(provider_data, "doc"),
updated_at: now
}
%CatalogProvider{}
|> CatalogProvider.changeset(provider_attrs)
|> Repo.insert!()
models = Map.get(provider_data, "models", %{})
Enum.reduce(models, count, fn {model_id, model_data}, inner_count ->
model_attrs = %{
provider: provider_id,
model_id: model_id,
name: Map.get(model_data, "name", model_id),
family: Map.get(model_data, "family"),
supports_attachment: Map.get(model_data, "attachment", false),
supports_reasoning: Map.get(model_data, "reasoning", false),
supports_tool_calls: Map.get(model_data, "tool_call", false),
supports_structured_output: Map.get(model_data, "structured_output", false),
supports_temperature: Map.get(model_data, "temperature", false),
knowledge: Map.get(model_data, "knowledge"),
release_date: Map.get(model_data, "release_date"),
last_updated_date: Map.get(model_data, "last_updated"),
open_weights: Map.get(model_data, "open_weights", false),
input_price: get_in(model_data, ["cost", "input"]),
output_price: get_in(model_data, ["cost", "output"]),
cache_read_price: get_in(model_data, ["cost", "cache_read"]),
cache_write_price: get_in(model_data, ["cost", "cache_write"]),
context_window: get_in(model_data, ["limit", "context"]) || 0,
max_input_tokens: get_in(model_data, ["limit", "input"]) || 0,
max_output_tokens: get_in(model_data, ["limit", "output"]) || 0,
interleaved: encode_nullable(Map.get(model_data, "interleaved")),
status: Map.get(model_data, "status"),
updated_at: now
}
%Model{}
|> Model.changeset(model_attrs)
|> Repo.insert!()
insert_modalities(provider_id, model_id, Map.get(model_data, "input_modalities", []), :input)
insert_modalities(provider_id, model_id, Map.get(model_data, "output_modalities", []), :output)
inner_count + 1
end)
end)
end)
|> case do
{:ok, count} -> count
{:error, reason} -> raise reason
end
end
defp insert_modalities(provider_id, model_id, modalities, direction) do
Enum.each(modalities, fn modality ->
%ModelModality{}
|> ModelModality.changeset(%{
provider: provider_id,
model_id: model_id,
direction: direction,
modality: parse_modality(modality)
})
|> Repo.insert!()
end)
end
defp parse_modality("text"), do: :text
defp parse_modality("image"), do: :image
defp parse_modality("audio"), do: :audio
defp parse_modality("file"), do: :file
defp parse_modality("tool"), do: :tool
defp parse_modality(other) when is_binary(other), do: String.to_atom(other)
defp encode_nullable(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value)
defp http_get(client, url, headers) when is_atom(client), do: client.get(url, headers)
defp http_get(client, url, headers) when is_function(client, 2), do: client.(url, headers)
defp maybe_put_header(headers, _key, nil), do: headers
defp maybe_put_header(headers, key, value), do: Map.put(headers, key, value)
defp decode_json_list(nil), do: []
defp decode_json_list(value), do: Jason.decode!(value)
defp truthy?(value), do: value in [true, "true", 1, "1"]
end

597
lib/bds/ai/chat.ex Normal file
View File

@@ -0,0 +1,597 @@
defmodule BDS.AI.Chat do
@moduledoc false
import Ecto.Query
alias BDS.AI
alias BDS.AI.Catalog
alias BDS.AI.CatalogProvider
alias BDS.AI.ChatConversation
alias BDS.AI.ChatMessage
alias BDS.AI.ChatTools
alias BDS.AI.InFlight
alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime
alias BDS.AI.SecretBackend
import BDS.AI.SettingsStore, only: [get_setting: 1]
alias BDS.Media.Media
alias BDS.Persistence
alias BDS.Posts.Post
alias BDS.Projects.Project
alias BDS.Repo
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
@default_max_output_tokens 16_384
@chat_max_tool_rounds 10
@default_context_window 128_000
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
def start_chat(attrs \\ %{}) when is_map(attrs) do
now = Persistence.now_ms()
model = Map.get(attrs, :model) || Map.get(attrs, "model")
title = Map.get(attrs, :title) || Map.get(attrs, "title") || generated_chat_title(model)
%ChatConversation{}
|> ChatConversation.changeset(%{
id: Ecto.UUID.generate(),
title: title,
model: model,
copilot_session_id: Map.get(attrs, :copilot_session_id) || Map.get(attrs, "copilot_session_id"),
created_at: now,
updated_at: now
})
|> Repo.insert()
|> case do
{:ok, conversation} -> {:ok, format_conversation(conversation)}
error -> error
end
end
@spec list_chat_conversations() :: [map()]
def list_chat_conversations do
Repo.all(from conversation in ChatConversation, order_by: [desc: conversation.updated_at])
|> Enum.map(&format_conversation/1)
end
@spec available_chat_models(String.t() | nil) :: [map()]
def available_chat_models(current_model \\ nil) do
endpoint_models = configured_chat_models()
preference_models =
[:chat, :airplane_chat]
|> Enum.flat_map(fn key ->
case AI.get_model_preference(key) do
{:ok, model} when is_binary(model) and model != "" -> [model]
_other -> []
end
end)
provider_names = catalog_provider_name_map()
endpoint_provider_map = Map.new(endpoint_models, &{&1.id, &1.provider})
[current_model | Enum.map(endpoint_models, & &1.id) ++ preference_models]
|> Enum.filter(&(is_binary(&1) and String.trim(&1) != ""))
|> Enum.uniq()
|> Enum.map(&build_available_chat_model(&1, endpoint_provider_map, provider_names))
|> Enum.sort_by(fn model ->
{
String.downcase(to_string(model.provider_name || model.provider || "")),
String.downcase(to_string(model.name || model.id))
}
end)
end
@spec set_conversation_model(String.t(), String.t()) ::
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
def set_conversation_model(conversation_id, model_id)
when is_binary(conversation_id) and is_binary(model_id) do
case Repo.get(ChatConversation, conversation_id) do
nil ->
{:error, :not_found}
%ChatConversation{} = conversation ->
conversation
|> ChatConversation.changeset(%{model: model_id, updated_at: Persistence.now_ms()})
|> Repo.update()
|> case do
{:ok, updated_conversation} -> {:ok, format_conversation(updated_conversation)}
error -> error
end
end
end
@spec list_chat_messages(String.t()) :: [map()]
def list_chat_messages(conversation_id) when is_binary(conversation_id) do
Repo.all(
from message in ChatMessage,
where: message.conversation_id == ^conversation_id,
order_by: [asc: message.created_at, asc: message.id]
)
|> Enum.map(&format_chat_message/1)
end
@spec send_chat_message(String.t(), String.t(), keyword()) ::
{:ok, map()} | {:error, :not_found | term()}
def send_chat_message(conversation_id, content, opts \\ [])
when is_binary(conversation_id) and is_binary(content) and is_list(opts) do
with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id),
{:ok, user_message} <- persist_chat_message(%{
conversation_id: conversation.id,
role: :user,
content: content,
created_at: Persistence.now_ms()
}) do
task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
receive do
:sandbox_ready -> :ok
end
do_send_chat_message(conversation, user_message, opts)
end)
InFlight.register(conversation.id, task.pid)
:ok = allow_repo_sandbox(task.pid)
send(task.pid, :sandbox_ready)
try do
await_chat_task(task)
after
InFlight.unregister(conversation.id)
end
else
nil -> {:error, :not_found}
error -> error
end
end
@spec cancel_chat(String.t()) :: :ok
def cancel_chat(conversation_id) when is_binary(conversation_id) do
case InFlight.lookup(conversation_id) do
nil -> :ok
pid ->
_ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid)
:ok
end
end
@doc false
def count_distinct_string_list(schema, field, project_id) do
Repo.all(from record in schema, where: field(record, :project_id) == ^project_id, select: field(record, ^field))
|> List.flatten()
|> Enum.reject(&blank?/1)
|> MapSet.new()
|> MapSet.size()
end
@doc false
def normalize_usage(usage) when is_map(usage) do
%{
input_tokens: usage[:input_tokens] || usage["input_tokens"],
output_tokens: usage[:output_tokens] || usage["output_tokens"],
cache_read_tokens: usage[:cache_read_tokens] || usage["cache_read_tokens"],
cache_write_tokens: usage[:cache_write_tokens] || usage["cache_write_tokens"]
}
end
def normalize_usage(_usage) do
%{
input_tokens: nil,
output_tokens: nil,
cache_read_tokens: nil,
cache_write_tokens: nil
}
end
defp format_conversation(conversation) do
%{
id: conversation.id,
title: conversation.title,
model: conversation.model,
copilot_session_id: conversation.copilot_session_id,
created_at: conversation.created_at,
updated_at: conversation.updated_at
}
end
defp format_chat_message(message) do
%{
id: message.id,
conversation_id: message.conversation_id,
role: message.role,
content: message.content,
tool_call_id: message.tool_call_id,
tool_calls: Catalog.decode_nullable_json(message.tool_calls),
token_usage_input: message.token_usage_input,
token_usage_output: message.token_usage_output,
cache_read_tokens: message.cache_read_tokens,
cache_write_tokens: message.cache_write_tokens,
created_at: message.created_at
}
end
defp configured_chat_models do
[:online, :airplane]
|> Enum.flat_map(fn kind ->
case AI.get_endpoint(kind) do
{:ok, %{model: model, url: url}} when is_binary(model) and model != "" ->
[%{id: model, provider: infer_endpoint_provider(kind, url)}]
_other ->
[]
end
end)
end
defp build_available_chat_model(model_id, endpoint_provider_map, provider_names) do
case Catalog.get_catalog_model(model_id) do
{:ok, model} ->
provider = model.provider || Map.get(endpoint_provider_map, model_id, "other")
%{
id: model.model_id,
name: model.name || model.model_id,
provider: provider,
provider_name: Map.get(provider_names, provider, fallback_provider_name(provider)),
context_window: model.context_window,
max_output_tokens: model.max_output_tokens
}
{:error, :not_found} ->
provider = Map.get(endpoint_provider_map, model_id, "other")
%{
id: model_id,
name: model_id,
provider: provider,
provider_name: Map.get(provider_names, provider, fallback_provider_name(provider)),
context_window: nil,
max_output_tokens: nil
}
end
end
defp catalog_provider_name_map do
Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name})
|> Map.new()
end
defp infer_endpoint_provider(:online, _url), do: "generic-openai"
defp infer_endpoint_provider(:airplane, url) when is_binary(url) do
normalized_url = String.downcase(url)
cond do
String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") -> "ollama"
String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") -> "lmstudio"
true -> "generic-openai"
end
end
defp infer_endpoint_provider(:airplane, _url), do: "generic-openai"
defp fallback_provider_name("generic-openai"), do: "Generic OpenAI"
defp fallback_provider_name("lmstudio"), do: "LM Studio"
defp fallback_provider_name("mistral"), do: "Mistral"
defp fallback_provider_name("ollama"), do: "Ollama"
defp fallback_provider_name("openai"), do: "OpenAI"
defp fallback_provider_name(provider) when is_binary(provider) and provider != "" do
provider
|> String.split(["-", "_"], trim: true)
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
end
defp fallback_provider_name(_provider), do: "Other"
defp do_send_chat_message(conversation, _user_message, opts) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
project_id = Keyword.get(opts, :project_id, active_project_id())
with {:ok, endpoint, model, mode} <-
Runtime.resolve_target(
:chat,
conversation: conversation,
secret_backend: Keyword.get(opts, :secret_backend, SecretBackend)
),
:ok <- Runtime.validate_target(:chat, model, mode),
messages <- load_chat_messages(conversation.id),
tools <- available_chat_tools(project_id, model),
{:ok, reply} <- chat_round(conversation, messages, endpoint, model, project_id, tools, runtime, opts, @chat_max_tool_rounds) do
{:ok, reply}
end
end
defp chat_round(_conversation, _messages, _endpoint, _model, _project_id, _tools, _runtime, _opts, 0) do
{:error, %{kind: :tool_loop_exhausted}}
end
defp chat_round(conversation, messages, endpoint, model, project_id, tools, runtime, opts, rounds_left) do
request = build_chat_request(conversation, messages, model, project_id, tools)
with {:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, assistant_message} <- persist_assistant_response(conversation.id, response),
:ok <- touch_conversation(conversation.id) do
if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do
notify_chat_event(opts, {:chat_streaming_content, conversation.id, Map.get(response, :content)})
end
tool_calls = decode_tool_calls(Map.get(response, :tool_calls))
Enum.each(tool_calls, fn tool_call ->
notify_chat_event(opts, {:chat_tool_call, conversation.id, tool_call})
end)
cond do
tool_calls != [] and tools != [] ->
with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id, opts),
updated_messages <- load_chat_messages(conversation.id),
{:ok, reply} <-
chat_round(
Repo.get!(ChatConversation, conversation.id),
updated_messages,
endpoint,
model,
project_id,
tools,
runtime,
opts,
rounds_left - 1
) do
{:ok, Map.put(reply, :tool_messages, tool_messages)}
end
true ->
{:ok,
%{
conversation: format_conversation(Repo.get!(ChatConversation, conversation.id)),
assistant_message: format_chat_message(assistant_message),
tool_messages: []
}}
end
end
end
defp persist_assistant_response(conversation_id, response) do
usage = normalize_usage(response.usage)
content =
case Map.get(response, :content) do
nil -> encode_nullable(Map.get(response, :json))
value -> value
end
persist_chat_message(%{
conversation_id: conversation_id,
role: :assistant,
content: content,
tool_calls: encode_nullable(Map.get(response, :tool_calls)),
token_usage_input: usage.input_tokens,
token_usage_output: usage.output_tokens,
cache_read_tokens: usage.cache_read_tokens,
cache_write_tokens: usage.cache_write_tokens,
created_at: Persistence.now_ms()
})
end
defp execute_tool_calls(conversation_id, tool_calls, project_id, opts) do
tool_messages =
Enum.map(tool_calls, fn tool_call ->
result = ChatTools.execute(tool_call.name, tool_call.arguments || %{}, project_id)
{:ok, message} =
persist_chat_message(%{
conversation_id: conversation_id,
role: :tool,
content: Jason.encode!(result),
tool_call_id: tool_call.id,
created_at: Persistence.now_ms()
})
notify_chat_event(opts, {:chat_tool_result, conversation_id, tool_call.name})
format_chat_message(message)
end)
{:ok, tool_messages}
end
defp build_chat_request(conversation, messages, model, project_id, tools) do
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id)}
%{
operation: :chat,
conversation_id: conversation.id,
model: model,
max_output_tokens: @default_max_output_tokens,
tools: Enum.map(tools, & &1.spec),
messages:
[system_message | Enum.map(messages, &message_for_runtime/1)]
|> truncate_chat_messages(model, tools)
}
end
defp message_for_runtime(%ChatMessage{} = message) do
base = %{"role" => Atom.to_string(message.role)}
base = if is_binary(message.content), do: Map.put(base, "content", message.content), else: base
base = if is_binary(message.tool_call_id), do: Map.put(base, "tool_call_id", message.tool_call_id), else: base
case Catalog.decode_nullable_json(message.tool_calls) do
nil -> base
tool_calls -> Map.put(base, "tool_calls", tool_calls)
end
end
defp truncate_chat_messages(messages, model, tools) do
context_window = model_context_window(model)
reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512))
tool_budget = length(tools) * 120
max_budget = max(context_window - reserve - tool_budget, 512)
[system | remainder] = messages
{kept, _tokens} =
Enum.reduce(Enum.reverse(remainder), {[], approximate_message_tokens(system)}, fn message, {acc, used} ->
message_tokens = approximate_message_tokens(message)
if used + message_tokens <= max_budget do
{[message | acc], used + message_tokens}
else
{acc, used}
end
end)
[system | kept]
end
defp available_chat_tools(project_id, model) do
ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
end
defp chat_system_prompt(project_id) do
base = get_setting("ai.system_prompt") || @default_system_prompt
case project_stats_summary(project_id) do
nil -> base
summary -> base <> "\n\nCurrent blog statistics:\n" <> summary
end
end
defp project_stats_summary(nil), do: nil
defp project_stats_summary(project_id) do
post_count = Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id)
media_count = Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id)
tag_count = count_distinct_string_list(Post, :tags, project_id)
category_count = count_distinct_string_list(Post, :categories, project_id)
Enum.join(
[
"Posts: #{post_count}",
"Media: #{media_count}",
"Tags: #{tag_count}",
"Categories: #{category_count}"
],
"\n"
)
end
defp generated_chat_title(nil), do: "New Chat"
defp generated_chat_title(model), do: "Chat with #{model}"
defp load_chat_messages(conversation_id) do
Repo.all(
from message in ChatMessage,
where: message.conversation_id == ^conversation_id,
order_by: [asc: message.created_at, asc: message.id]
)
end
defp persist_chat_message(attrs) do
%ChatMessage{}
|> ChatMessage.changeset(attrs)
|> Repo.insert()
end
defp touch_conversation(conversation_id) do
now = Persistence.now_ms()
Repo.update_all(
from(conversation in ChatConversation, where: conversation.id == ^conversation_id),
set: [updated_at: now]
)
:ok
end
defp await_chat_task(task) do
ref = task.ref
receive do
{^ref, result} ->
Process.demonitor(task.ref, [:flush])
result
{:DOWN, ^ref, :process, _pid, reason} ->
case reason do
:normal ->
receive do
{^ref, result} -> result
after
10 -> {:error, :cancelled}
end
:shutdown -> {:error, :cancelled}
{:shutdown, _detail} -> {:error, :cancelled}
_other -> {:error, :cancelled}
end
end
end
defp decode_tool_calls(nil), do: []
defp decode_tool_calls(tool_calls) when is_list(tool_calls) do
Enum.map(tool_calls, fn tool_call ->
%{
id: tool_call[:id] || tool_call["id"],
name: tool_call[:name] || tool_call["name"],
arguments: tool_call[:arguments] || tool_call["arguments"] || %{}
}
end)
end
defp approximate_message_tokens(message) when is_map(message) do
message
|> Map.values()
|> Enum.map(&approximate_value_tokens/1)
|> Enum.sum()
|> Kernel.+(4)
end
defp approximate_value_tokens(value) when is_binary(value), do: div(String.length(value), 4) + 1
defp approximate_value_tokens(value) when is_list(value), do: Enum.map(value, &approximate_value_tokens/1) |> Enum.sum()
defp approximate_value_tokens(value) when is_map(value), do: Jason.encode!(value) |> approximate_value_tokens()
defp approximate_value_tokens(_value), do: 1
defp model_context_window(model_id) do
case Catalog.get_catalog_model(model_id) do
{:ok, model} when is_integer(model.context_window) and model.context_window > 0 -> model.context_window
_other -> @default_context_window
end
end
defp notify_chat_event(opts, event) do
case Keyword.get(opts, :event_target) do
pid when is_pid(pid) -> send(pid, event)
callback when is_function(callback, 1) -> callback.(event)
_other -> :ok
end
:ok
end
defp active_project_id do
Repo.one(from project in Project, where: project.is_active == true, select: project.id)
end
defp allow_repo_sandbox(pid) when is_pid(pid) do
if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do
try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue
_error -> :ok
end
else
:ok
end
:ok
end
defp encode_nullable(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value)
defp blank?(value), do: value in [nil, ""]
end

271
lib/bds/ai/chat_tools.ex Normal file
View File

@@ -0,0 +1,271 @@
defmodule BDS.AI.ChatTools do
@moduledoc false
import Ecto.Query
alias BDS.AI.Chat
alias BDS.Media.Media
alias BDS.Posts.Post
alias BDS.Projects.Project
alias BDS.Repo
@spec execute(String.t(), map(), String.t() | nil) :: map()
def execute("blog_stats", _arguments, project_id) do
project_id = project_id || active_project_id()
%{
post_count: Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id),
media_count: Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id),
tag_count: Chat.count_distinct_string_list(Post, :tags, project_id),
category_count: Chat.count_distinct_string_list(Post, :categories, project_id)
}
end
def execute("list_posts", arguments, project_id) do
limit = normalize_limit(arguments["limit"])
Repo.all(
from(post in Post,
where: post.project_id == ^project_id,
order_by: [desc: post.updated_at],
limit: ^limit,
select: %{id: post.id, title: post.title, slug: post.slug, status: post.status}
)
)
end
def execute("list_media", arguments, project_id) do
limit = normalize_limit(arguments["limit"])
Repo.all(
from(media in Media,
where: media.project_id == ^project_id,
order_by: [desc: media.updated_at],
limit: ^limit,
select: %{
id: media.id,
title: media.title,
mime_type: media.mime_type,
filename: media.filename
}
)
)
end
def execute("render_table", arguments, _project_id) do
%{
type: "table",
title: arguments["title"],
columns: arguments["columns"] || [],
rows: arguments["rows"] || []
}
end
def execute("render_chart", arguments, _project_id) do
%{
type: "chart",
title: arguments["title"],
chart_type: arguments["chart_type"] || "bar",
series: arguments["series"] || []
}
end
def execute("render_form", arguments, _project_id) do
%{
type: "form",
title: arguments["title"],
fields: arguments["fields"] || [],
submit_label: arguments["submit_label"] || arguments["submitLabel"],
submit_action: arguments["submit_action"] || arguments["submitAction"]
}
end
def execute("render_card", arguments, _project_id) do
%{
type: "card",
title: arguments["title"],
subtitle: arguments["subtitle"],
body: arguments["body"],
actions: arguments["actions"] || []
}
end
def execute("render_metric", arguments, _project_id) do
%{
type: "metric",
label: arguments["label"],
value: arguments["value"]
}
end
def execute("render_list", arguments, _project_id) do
%{
type: "list",
title: arguments["title"],
items: arguments["items"] || []
}
end
def execute("render_tabs", arguments, _project_id) do
%{
type: "tabs",
title: arguments["title"],
tabs: arguments["tabs"] || []
}
end
def execute("render_mindmap", arguments, _project_id) do
%{
type: "mindmap",
title: arguments["title"],
nodes: arguments["nodes"] || []
}
end
def execute(name, _arguments, _project_id) do
%{error: "unknown_tool", name: name}
end
@spec available_specs(String.t() | nil, map()) :: [map()]
def available_specs(project_id, capabilities) do
if capabilities.supports_tool_calls do
project_tools =
if is_binary(project_id) do
[
%{name: "blog_stats", spec: tool_spec("blog_stats", "Return aggregate blog statistics", %{"type" => "object", "properties" => %{}})},
%{name: "list_posts", spec: tool_spec("list_posts", "List recent posts in the active project", limit_schema())},
%{name: "list_media", spec: tool_spec("list_media", "List recent media items in the active project", limit_schema())}
]
else
[]
end
project_tools ++
[
%{name: "render_card", spec: tool_spec("render_card", "Return a structured card payload", render_card_schema())},
%{name: "render_table", spec: tool_spec("render_table", "Return a structured table payload", render_table_schema())},
%{name: "render_chart", spec: tool_spec("render_chart", "Return a structured chart payload", render_chart_schema())},
%{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())},
%{name: "render_metric", spec: tool_spec("render_metric", "Return a structured metric payload", render_metric_schema())},
%{name: "render_list", spec: tool_spec("render_list", "Return a structured list payload", render_list_schema())},
%{name: "render_tabs", spec: tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())},
%{name: "render_mindmap", spec: tool_spec("render_mindmap", "Return a structured mindmap payload", render_mindmap_schema())}
]
else
[]
end
end
defp tool_spec(name, description, parameters) do
%{
"type" => "function",
"function" => %{
"name" => name,
"description" => description,
"parameters" => parameters
}
}
end
defp limit_schema do
%{
"type" => "object",
"properties" => %{
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
}
}
end
defp render_table_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"columns" => %{"type" => "array"},
"rows" => %{"type" => "array"}
}
}
end
defp render_chart_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"chart_type" => %{"type" => "string"},
"series" => %{"type" => "array"}
}
}
end
defp render_form_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"fields" => %{"type" => "array"},
"submitLabel" => %{"type" => "string"},
"submitAction" => %{"type" => "string"}
}
}
end
defp render_card_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"subtitle" => %{"type" => "string"},
"body" => %{"type" => "string"},
"actions" => %{"type" => "array"}
}
}
end
defp render_metric_schema do
%{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "string"}
}
}
end
defp render_list_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"items" => %{"type" => "array"}
}
}
end
defp render_tabs_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"tabs" => %{"type" => "array"}
}
}
end
defp render_mindmap_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"nodes" => %{"type" => "array"}
}
}
end
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
defp normalize_limit(_value), do: 10
defp active_project_id do
Repo.one(from(project in Project, where: project.is_active == true, select: project.id))
end
end

382
lib/bds/ai/one_shot.ex Normal file
View File

@@ -0,0 +1,382 @@
defmodule BDS.AI.OneShot do
@moduledoc false
alias BDS.AI.Chat
alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime
alias BDS.Media.Media
alias BDS.Posts.Post
alias BDS.Repo
@default_max_output_tokens 16_384
@spec detect_language(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def detect_language(text, opts \\ []) when is_binary(text) and is_list(opts) do
run_one_shot(
:detect_language,
%{text: text},
opts,
fn json, usage ->
{:ok, %{language_code: json["language_code"], usage: usage}}
end
)
end
@spec analyze_taxonomy(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def analyze_taxonomy(post_input, opts \\ []) when is_list(opts) do
with {:ok, post} <- normalize_post_input(post_input) do
run_one_shot(
:analyze_taxonomy,
post,
opts,
fn json, usage ->
{:ok,
%{
tags: json["tags"] || [],
categories: json["categories"] || [],
usage: usage
}}
end
)
end
end
@spec analyze_import_taxonomy(map(), map(), keyword()) :: {:ok, map()} | {:error, term()}
def analyze_import_taxonomy(import_terms, existing_terms, opts \\ [])
when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do
payload = %{
import_categories: normalize_string_list(Map.get(import_terms, :categories) || Map.get(import_terms, "categories")),
import_tags: normalize_string_list(Map.get(import_terms, :tags) || Map.get(import_terms, "tags")),
existing_categories: normalize_string_list(Map.get(existing_terms, :categories) || Map.get(existing_terms, "categories")),
existing_tags: normalize_string_list(Map.get(existing_terms, :tags) || Map.get(existing_terms, "tags"))
}
run_one_shot(
:import_taxonomy_mapping,
payload,
opts,
fn json, usage ->
{:ok,
%{
category_mappings:
filter_taxonomy_mapping_response(
json["categoryMappings"] || json["category_mappings"],
payload.import_categories,
payload.existing_categories
),
tag_mappings:
filter_taxonomy_mapping_response(
json["tagMappings"] || json["tag_mappings"],
payload.import_tags,
payload.existing_tags
),
usage: usage
}}
end
)
end
@spec analyze_post(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def analyze_post(post_input, opts \\ []) when is_list(opts) do
with {:ok, post} <- normalize_post_input(post_input) do
run_one_shot(
:analyze_post,
post,
opts,
fn json, usage ->
{:ok,
%{
title: json["title"],
excerpt: json["excerpt"],
slug: json["slug"],
usage: usage
}}
end
)
end
end
@spec translate_post(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def translate_post(post_input, target_language, opts \\ [])
when is_binary(target_language) and is_list(opts) do
with {:ok, post} <- normalize_post_input(post_input) do
run_one_shot(
:translate_post,
Map.put(post, :target_language, target_language),
opts,
fn json, usage ->
{:ok,
%{
title: json["title"],
excerpt: json["excerpt"],
content: json["content"],
usage: usage
}}
end
)
end
end
@spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def analyze_image(media_input, opts \\ []) when is_list(opts) do
with {:ok, media} <- normalize_media_input(media_input),
:ok <- ensure_image_media(media) do
run_one_shot(
:analyze_image,
media,
opts,
fn json, usage ->
{:ok,
%{
title: json["title"],
alt: json["alt"],
caption: json["caption"],
usage: usage
}}
end
)
end
end
@spec translate_media(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def translate_media(media_input, target_language, opts \\ [])
when is_binary(target_language) and is_list(opts) do
with {:ok, media} <- normalize_media_input(media_input) do
run_one_shot(
:translate_media,
Map.put(media, :target_language, target_language),
opts,
fn json, usage ->
{:ok,
%{
title: json["title"],
alt: json["alt"],
caption: json["caption"],
usage: usage
}}
end
)
end
end
defp run_one_shot(operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model),
{:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response),
usage <- Chat.normalize_usage(response.usage),
{:ok, result} <- formatter.(json, usage) do
{:ok, result}
end
end
defp build_one_shot_request(operation, payload, model) do
%{
operation: operation,
model: model,
max_output_tokens: @default_max_output_tokens,
messages: [
%{"role" => "system", "content" => one_shot_system_prompt(operation)},
%{"role" => "user", "content" => one_shot_user_content(operation, payload)}
]
}
end
defp one_shot_system_prompt(:detect_language) do
"Return JSON with exactly one key: language_code."
end
defp one_shot_system_prompt(:analyze_taxonomy) do
"Return JSON with keys tags and categories, each an array of short strings."
end
defp one_shot_system_prompt(:import_taxonomy_mapping) do
"You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects."
end
defp one_shot_system_prompt(:analyze_post) do
"Return JSON with keys title, excerpt, and slug."
end
defp one_shot_system_prompt(:translate_post) do
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure."
end
defp one_shot_system_prompt(:analyze_image) do
"Return JSON with keys title, alt, and caption for the provided image."
end
defp one_shot_system_prompt(:translate_media) do
"Return JSON with keys title, alt, and caption translated to the requested language."
end
defp one_shot_user_content(:detect_language, %{text: text}) do
"Detect the language of this text: #{text}"
end
defp one_shot_user_content(:analyze_taxonomy, post) do
"Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end
defp one_shot_user_content(:import_taxonomy_mapping, payload) do
[
"Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.",
"",
"Imported categories:",
Enum.join(payload.import_categories, ", "),
"",
"Imported tags:",
Enum.join(payload.import_tags, ", "),
"",
"Existing project categories:",
Enum.join(payload.existing_categories, ", "),
"",
"Existing project tags:",
Enum.join(payload.existing_tags, ", "),
"",
"Return JSON only."
]
|> Enum.join("\n")
end
defp one_shot_user_content(:analyze_post, post) do
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
end
defp one_shot_user_content(:translate_post, post) do
"Translate this post to #{post.target_language}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
end
defp one_shot_user_content(:analyze_image, media) do
[
%{"type" => "text", "text" => "Analyze this image and return title, alt text, and caption."},
%{"type" => "image_url", "image_url" => %{"url" => media.image_url}}
]
end
defp one_shot_user_content(:translate_media, media) do
"Translate this media metadata to #{media.target_language}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
end
defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json}
defp extract_json_response(%{content: content}) when is_binary(content) do
case Jason.decode(content) do
{:ok, json} when is_map(json) -> {:ok, json}
_other -> {:error, %{kind: :invalid_json_response}}
end
end
defp extract_json_response(_response), do: {:error, %{kind: :invalid_json_response}}
defp normalize_post_input(%Post{} = post) do
{:ok, %{title: post.title || "", excerpt: post.excerpt || "", content: post.content || ""}}
end
defp normalize_post_input(post_id) when is_binary(post_id) do
case Repo.get(Post, post_id) do
nil -> {:error, :not_found}
post -> normalize_post_input(post)
end
end
defp normalize_post_input(attrs) when is_map(attrs) do
{:ok,
%{
title: Map.get(attrs, :title) || Map.get(attrs, "title") || "",
excerpt: Map.get(attrs, :excerpt) || Map.get(attrs, "excerpt") || "",
content: Map.get(attrs, :content) || Map.get(attrs, "content") || ""
}}
end
defp normalize_media_input(%Media{} = media) do
{:ok,
%{
mime_type: media.mime_type,
title: media.title || "",
alt: media.alt || "",
caption: media.caption || "",
image_url: Map.get(media, :image_url) || media_path_to_file_url(media.file_path)
}}
end
defp normalize_media_input(media_id) when is_binary(media_id) do
case Repo.get(Media, media_id) do
nil -> {:error, :not_found}
media -> normalize_media_input(media)
end
end
defp normalize_media_input(attrs) when is_map(attrs) do
{:ok,
%{
mime_type: Map.get(attrs, :mime_type) || Map.get(attrs, "mime_type"),
title: Map.get(attrs, :title) || Map.get(attrs, "title") || "",
alt: Map.get(attrs, :alt) || Map.get(attrs, "alt") || "",
caption: Map.get(attrs, :caption) || Map.get(attrs, "caption") || "",
image_url: Map.get(attrs, :image_url) || Map.get(attrs, "image_url")
}}
end
defp ensure_image_media(%{mime_type: "image/" <> _rest}), do: :ok
defp ensure_image_media(_media), do: {:error, %{kind: :invalid_media_type}}
defp media_path_to_file_url(nil), do: nil
defp media_path_to_file_url(path), do: "file://" <> path
defp normalize_string_list(values) do
values
|> List.wrap()
|> Enum.map(&to_string/1)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
end
defp filter_taxonomy_mapping_response(mappings, import_terms, existing_terms) when is_map(mappings) do
import_lookup = canonical_term_lookup(import_terms)
existing_lookup = canonical_term_lookup(existing_terms)
Enum.reduce(mappings, %{}, fn {source, target}, acc ->
with {:ok, canonical_source} <- resolve_canonical_term(source, import_lookup),
{:ok, canonical_target} <- resolve_canonical_term(target, existing_lookup) do
Map.put(acc, canonical_source, canonical_target)
else
_other -> acc
end
end)
end
defp filter_taxonomy_mapping_response(_mappings, _import_terms, _existing_terms), do: %{}
defp canonical_term_lookup(terms) do
Map.new(terms, fn term -> {normalize_term(term), term} end)
end
defp resolve_canonical_term(term, lookup) do
case Map.get(lookup, normalize_term(term)) do
nil -> :error
canonical -> {:ok, canonical}
end
end
defp normalize_term(term) do
term
|> to_string()
|> String.trim()
|> String.downcase()
end
defp truncate_text(nil, _max_length), do: ""
defp truncate_text(text, max_length) when is_binary(text) do
if String.length(text) <= max_length do
text
else
String.slice(text, 0, max_length)
end
end
end

100
lib/bds/ai/runtime.ex Normal file
View File

@@ -0,0 +1,100 @@
defmodule BDS.AI.Runtime do
@moduledoc false
alias BDS.AI
alias BDS.AI.Catalog
alias BDS.AI.SecretBackend
@model_preference_keys %{
default: "ai.model.default",
chat: "ai.model.chat",
title: "ai.model.title",
image_analysis: "ai.model.image_analysis",
airplane_chat: "ai.airplane.model.chat",
airplane_title: "ai.airplane.model.title",
airplane_image_analysis: "ai.airplane.model.image_analysis"
}
@spec model_preference_keys() :: %{atom() => String.t()}
def model_preference_keys, do: @model_preference_keys
@spec resolve_target(atom(), keyword()) ::
{:ok, map(), String.t(), :airplane | :online} | {:error, term()}
def resolve_target(operation, extra) do
mode = if AI.airplane_mode?(), do: :airplane, else: :online
secret_backend = Keyword.get(extra, :secret_backend, SecretBackend)
with {:ok, endpoint} <- fetch_endpoint_for_mode(mode, secret_backend),
{:ok, model} <- resolve_model_for_operation(operation, mode, endpoint, extra) do
{:ok, endpoint, model, mode}
end
end
@spec validate_target(atom(), String.t(), :airplane | :online) :: :ok | {:error, term()}
def validate_target(:analyze_image, model, _mode) do
if Catalog.model_capabilities(model).supports_attachment do
:ok
else
{:error, %{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
end
end
def validate_target(_operation, _model, _mode), do: :ok
@spec endpoint_with_model(map(), String.t()) :: map()
def endpoint_with_model(endpoint, model), do: Map.put(endpoint, :model, model)
@spec model_preference_value(atom()) :: String.t() | nil
def model_preference_value(key) do
case AI.get_model_preference(key) do
{:ok, value} -> value
_other -> nil
end
end
defp resolve_model_for_operation(:chat, :airplane, endpoint, _extra) do
{:ok, model_preference_value(:airplane_chat) || endpoint.model}
end
defp resolve_model_for_operation(:chat, :online, endpoint, conversation: conversation) do
{:ok, conversation.model || model_preference_value(:chat) || endpoint.model}
end
defp resolve_model_for_operation(:chat, :online, endpoint, _extra) do
{:ok, model_preference_value(:chat) || endpoint.model}
end
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do
{:ok, Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) || endpoint.model}
end
defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do
{:ok, Keyword.get(extra, :model) || model_preference_value(:image_analysis) || endpoint.model}
end
defp resolve_model_for_operation(_operation, :airplane, endpoint, extra) do
{:ok, Keyword.get(extra, :model) || model_preference_value(:airplane_title) || endpoint.model}
end
defp resolve_model_for_operation(_operation, :online, endpoint, extra) do
{:ok, Keyword.get(extra, :model) || model_preference_value(:title) || endpoint.model}
end
defp fetch_endpoint_for_mode(mode, secret_backend) do
with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do
case endpoint do
%{url: url, model: model} = loaded when is_binary(url) and url != "" and is_binary(model) and model != "" ->
if mode == :online and blank?(loaded.api_key) do
{:error, %{kind: :endpoint_not_configured, endpoint: mode}}
else
{:ok, loaded}
end
_other ->
{:error, %{kind: :endpoint_not_configured, endpoint: mode}}
end
end
end
defp blank?(value), do: value in [nil, ""]
end

View File

@@ -0,0 +1,78 @@
defmodule BDS.AI.SettingsStore do
@moduledoc false
import Ecto.Query
alias BDS.AI.CatalogMeta
alias BDS.AI.SecretBackend
alias BDS.Persistence
alias BDS.Repo
alias BDS.Settings.Setting
@spec get_setting(String.t()) :: String.t() | nil
def get_setting(key) do
case Repo.get(Setting, key) do
nil -> nil
setting -> setting.value
end
end
@spec put_setting(String.t(), String.t()) :: :ok
def put_setting(key, value) when is_binary(key) and is_binary(value) do
now = Persistence.now_ms()
(%Setting{}
|> Setting.changeset(%{key: key, value: value, updated_at: now}))
|> Repo.insert(
on_conflict: [set: [value: value, updated_at: now]],
conflict_target: [:key]
)
:ok
end
@spec delete_setting(String.t()) :: :ok
def delete_setting(key) do
Repo.delete_all(from(setting in Setting, where: setting.key == ^key))
:ok
end
@spec put_secret(String.t(), String.t() | nil, module()) :: :ok | {:error, term()}
def put_secret(_key, nil, _backend), do: :ok
def put_secret(key, value, backend) do
with {:ok, encrypted_value} <- backend.encrypt(value) do
put_setting(encrypted_key(key), encrypted_value)
end
end
@spec get_secret(String.t() | nil, module()) :: {:ok, String.t() | nil} | {:error, term()}
def get_secret(nil, _backend), do: {:ok, nil}
def get_secret(value, backend), do: backend.decrypt(value)
@spec encrypted_key(String.t()) :: String.t()
def encrypted_key(key), do: "__encrypted_#{key}"
@spec secret_backend() :: module()
def secret_backend, do: SecretBackend
@spec get_catalog_meta_value(String.t()) :: String.t() | nil
def get_catalog_meta_value(key) do
case Repo.get(CatalogMeta, key) do
nil -> get_setting("ai.catalog.#{key}")
meta -> meta.value
end
end
@spec put_catalog_meta(String.t(), String.t()) :: :ok
def put_catalog_meta(key, value) do
%CatalogMeta{}
|> CatalogMeta.changeset(%{key: key, value: value})
|> Repo.insert(
on_conflict: [set: [value: value]],
conflict_target: [:key]
)
:ok
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
defmodule BDS.Scripting.Capabilities.AppShell do
@moduledoc false
import BDS.Scripting.Capabilities.Util
alias BDS.Desktop.FolderPicker
alias BDS.Desktop.MenuBar
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
def copy_to_clipboard(text, opts) do
case Keyword.get(opts, :copy_to_clipboard) do
callback when is_function(callback, 1) -> callback.(string_or_nil(text) || "")
_other -> do_copy_to_clipboard(text)
end
end
defp do_copy_to_clipboard(text) do
if test_mode?() do
true
else
command = string_or_nil(text)
case :os.type() do
{:unix, :darwin} -> match?({_output, 0}, System.cmd("pbcopy", [], input: command, stderr_to_stdout: true))
{:unix, _other} -> match?({_output, 0}, System.cmd("xclip", ["-selection", "clipboard"], input: command, stderr_to_stdout: true))
{:win32, _other} -> match?({_output, 0}, System.cmd("cmd", ["/c", "clip"], input: command, stderr_to_stdout: true))
end
end
rescue
_error -> false
end
def blogmark_bookmarklet do
"javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds://new-post?title='+t+'&url='+u;})();"
end
def title_bar_metrics(opts) do
case Keyword.get(opts, :title_bar_metrics) do
callback when is_function(callback, 0) -> callback.()
_other -> do_title_bar_metrics()
end
end
defp do_title_bar_metrics do
case :os.type() do
{:unix, :darwin} -> %{macos_left_inset: 72}
_other -> nil
end
end
def notify_renderer_ready(opts) do
case Keyword.get(opts, :notify_renderer_ready) do
callback when is_function(callback, 0) -> callback.()
_other -> true
end
end
def open_folder(folder_path, opts) do
case Keyword.get(opts, :open_folder) do
callback when is_function(callback, 1) -> callback.(string_or_nil(folder_path))
_other -> do_open_folder(folder_path)
end
end
defp do_open_folder(folder_path) do
if test_mode?() do
""
else
case shell_open_system_path(string_or_nil(folder_path)) do
:ok -> ""
{:error, reason} -> inspect(reason)
end
end
end
def select_folder(title, opts) do
case Keyword.get(opts, :select_folder) do
callback when is_function(callback, 1) -> callback.(string_or_nil(title) || "Select Folder")
_other -> do_select_folder(title)
end
end
defp do_select_folder(title) do
if test_mode?() do
nil
else
case FolderPicker.choose_directory(string_or_nil(title) || "Select Folder") do
{:ok, path} -> path
:cancel -> nil
{:error, _reason} -> nil
end
end
end
def set_preview_post_target(post_id) do
:persistent_term.put({BDS.Scripting.Capabilities, :preview_post_target}, string_or_nil(post_id))
true
end
def show_item_in_folder(item_path, opts) do
callback = Keyword.get(opts, :show_item_in_folder)
cond do
is_function(callback, 1) -> callback.(string_or_nil(item_path))
test_mode?() -> :ok
true -> _ = shell_reveal_system_path(string_or_nil(item_path))
end
nil
end
def trigger_menu_action(action, opts) do
callback = Keyword.get(opts, :trigger_menu_action)
cond do
is_function(callback, 1) -> callback.(string_or_nil(action))
test_mode?() -> :ok
true -> _ = MenuBar.handle_event(string_or_nil(action), nil)
end
nil
rescue
_error -> nil
end
def test_mode? do
Application.get_env(:bds, :test_mode, false) or current_env() == :test
end
def current_env do
Application.get_env(:bds, :current_env_override) || @compiled_env
end
end

View File

@@ -0,0 +1,176 @@
defmodule BDS.Scripting.Capabilities.Bridges do
@moduledoc false
import BDS.Scripting.Capabilities.Util
alias BDS.AI
alias BDS.Embeddings
alias BDS.Git
alias BDS.Media
alias BDS.Posts
alias BDS.Publishing
# --- Sync / Git ---
def sync_available?, do: not is_nil(System.find_executable("git"))
def repo_state(project_id, opts) do
project_id
|> Git.repository(git_opts(opts))
|> unwrap_result()
end
def repo_status(project_id, opts) do
project_id
|> Git.status(git_opts(opts))
|> unwrap_result()
end
def repo_history(project_id, opts) do
case Git.repository(project_id, git_opts(opts)) do
{:ok, %{current_branch: branch}} when is_binary(branch) and branch != "" ->
Git.history(project_id, branch, git_opts(opts))
|> unwrap_result()
_other ->
%{"commits" => []}
end
end
def repo_fetch(project_id, opts), do: project_id |> Git.fetch(git_opts(opts)) |> unwrap_result()
def repo_pull(project_id, opts), do: project_id |> Git.pull(git_opts(opts)) |> unwrap_result()
def repo_push(project_id, opts), do: project_id |> Git.push(git_opts(opts)) |> unwrap_result()
def repo_commit_all(project_id, message, opts) do
project_id
|> Git.commit_all(string_or_nil(message) || "", git_opts(opts))
|> unwrap_result()
end
# --- Publishing ---
def upload_site(project_id, credentials, opts) do
project_id
|> Publishing.upload_site(normalize_map(credentials), publishing_opts(opts))
|> unwrap_result()
end
# --- AI ---
def detect_post_language(title, content, opts) do
text = Enum.join([string_or_nil(title) || "", string_or_nil(content) || ""], "\n\n")
case AI.detect_language(text, ai_opts(opts)) do
{:ok, %{language_code: language_code}} -> %{"success" => true, "language" => language_code}
{:error, reason} -> %{"success" => false, "error" => inspect(reason)}
end
end
def analyze_post(post_id, opts) do
post_id
|> string_or_nil()
|> AI.analyze_post(ai_opts(opts))
|> unwrap_result()
end
def translate_post(post_id, language, opts) do
post_id = string_or_nil(post_id)
language = string_or_nil(language) || ""
with {:ok, translation} <- AI.translate_post(post_id, language, ai_opts(opts)),
{:ok, saved_translation} <-
Posts.upsert_post_translation(post_id, language, %{
title: translation.title,
excerpt: translation.excerpt,
content: translation.content
}) do
sanitize(saved_translation)
else
_other -> nil
end
end
def analyze_media_image(media_id, opts) do
case AI.analyze_image(string_or_nil(media_id), ai_opts(opts)) do
{:ok, result} -> sanitize(result)
{:error, _reason} -> nil
end
end
def detect_media_language(title, alt, caption, opts) do
text = Enum.join([string_or_nil(title) || "", string_or_nil(alt) || "", string_or_nil(caption) || ""], "\n")
case AI.detect_language(text, ai_opts(opts)) do
{:ok, %{language_code: language_code}} -> %{"success" => true, "language" => language_code}
{:error, reason} -> %{"success" => false, "error" => inspect(reason)}
end
end
def translate_media_metadata(media_id, language, opts) do
media_id = string_or_nil(media_id)
language = string_or_nil(language) || ""
with {:ok, translation} <- AI.translate_media(media_id, language, ai_opts(opts)),
{:ok, saved_translation} <-
Media.upsert_media_translation(media_id, language, %{
title: translation.title,
alt: translation.alt,
caption: translation.caption
}) do
sanitize(saved_translation)
else
_other -> nil
end
end
# --- Embeddings ---
def embedding_progress(project_id), do: project_id |> Embeddings.get_indexing_progress() |> unwrap_result()
def find_similar(post_id, limit) do
post_id
|> string_or_nil()
|> Embeddings.find_similar(integer_or_default(limit, 5))
|> unwrap_result()
end
def compute_similarities(post_id, target_ids) do
post_id
|> string_or_nil()
|> Embeddings.compute_similarities(normalize_string_list(target_ids))
|> unwrap_result()
end
def suggest_tags(post_id, exclude_tags) do
post_id
|> string_or_nil()
|> Embeddings.suggest_tags(normalize_string_list(exclude_tags))
|> unwrap_result()
end
def find_duplicates(project_id), do: project_id |> Embeddings.find_duplicates() |> unwrap_result()
def dismiss_pair(post_id_a, post_id_b), do: atom_result(Embeddings.dismiss_duplicate_pair(string_or_nil(post_id_a) || "", string_or_nil(post_id_b) || ""), :ok)
def index_unindexed_posts(project_id), do: project_id |> Embeddings.index_unindexed() |> unwrap_result()
# --- Opt builders ---
def git_opts(opts) do
case Keyword.get(opts, :git_runner) do
nil -> []
runner -> [runner: runner]
end
end
def publishing_opts(opts) do
case Keyword.get(opts, :publishing_uploader) do
nil -> []
uploader -> [uploader: uploader]
end
end
def ai_opts(opts) do
[]
|> maybe_put_opt(:runtime, Keyword.get(opts, :ai_runtime))
|> maybe_put_opt(:secret_backend, Keyword.get(opts, :ai_secret_backend))
end
end

View File

@@ -0,0 +1,284 @@
defmodule BDS.Scripting.Capabilities.Crud do
@moduledoc false
import Ecto.Query
import BDS.Scripting.Capabilities.Util
alias BDS.MCP
alias BDS.Posts.Post
alias BDS.Repo
alias BDS.Scripting.Capabilities.Posts, as: PostsCaps
alias BDS.Scripts
alias BDS.Scripts.Script
alias BDS.Tags
alias BDS.Tags.Tag
alias BDS.Tasks
alias BDS.Templates
alias BDS.Templates.Template
# --- Scripts ---
def create_script(project_id, attrs) do
attrs
|> normalize_map()
|> Map.put("project_id", project_id)
|> Scripts.create_script()
|> unwrap_result()
end
def update_script(project_id, script_id, attrs) do
case fetch_script(project_id, script_id) do
%Script{} -> Scripts.update_script(script_id, normalize_map(attrs)) |> unwrap_result()
_other -> nil
end
end
def delete_script(project_id, script_id) do
case fetch_script(project_id, script_id) do
%Script{} -> boolean_result(Scripts.delete_script(script_id))
_other -> false
end
end
def load_script(project_id, script_id) do
fetch_script(project_id, script_id)
|> sanitize_nilable()
end
def list_scripts(project_id) do
Repo.all(
from(script in Script, where: script.project_id == ^project_id, order_by: [asc: script.created_at])
)
|> Enum.map(&sanitize/1)
end
def publish_script(project_id, script_id) do
case fetch_script(project_id, script_id) do
%Script{} -> Scripts.publish_script(script_id) |> unwrap_result()
_other -> nil
end
end
def rebuild_scripts_from_files(project_id) do
project_id
|> Scripts.rebuild_scripts_from_files()
|> unwrap_result()
end
def fetch_script(project_id, script_id) do
Repo.one(
from(script in Script,
where: script.project_id == ^project_id and script.id == ^(string_or_nil(script_id) || ""),
limit: 1
)
)
end
# --- Templates ---
def create_template(project_id, attrs) do
attrs
|> normalize_map()
|> Map.put("project_id", project_id)
|> Templates.create_template()
|> unwrap_result()
end
def update_template(project_id, template_id, attrs) do
case fetch_template(project_id, template_id) do
%Template{} -> Templates.update_template(template_id, normalize_map(attrs)) |> unwrap_result()
_other -> nil
end
end
def delete_template(project_id, template_id) do
case fetch_template(project_id, template_id) do
%Template{} -> boolean_result(Templates.delete_template(template_id))
_other -> false
end
end
def load_template(project_id, template_id) do
fetch_template(project_id, template_id)
|> sanitize_nilable()
end
def list_templates(project_id) do
Repo.all(
from(template in Template, where: template.project_id == ^project_id, order_by: [asc: template.created_at])
)
|> Enum.map(&sanitize/1)
end
def publish_template(project_id, template_id) do
case fetch_template(project_id, template_id) do
%Template{} -> Templates.publish_template(template_id) |> unwrap_result()
_other -> nil
end
end
def list_enabled_templates(project_id, kind) do
Repo.all(
from(template in Template,
where:
template.project_id == ^project_id and template.enabled == true and
template.kind == ^string_or_nil(kind),
order_by: [asc: template.created_at]
)
)
|> Enum.map(&sanitize/1)
end
def rebuild_templates_from_files(project_id) do
project_id
|> Templates.rebuild_templates_from_files()
|> unwrap_result()
end
def validate_template_source(source) do
source
|> string_or_nil()
|> Kernel.||("")
|> MCP.validate_template()
|> unwrap_result()
end
def fetch_template(project_id, template_id) do
Repo.one(
from(template in Template,
where: template.project_id == ^project_id and template.id == ^(string_or_nil(template_id) || ""),
limit: 1
)
)
end
# --- Tags ---
def create_tag(project_id, attrs) do
attrs
|> normalize_map()
|> Map.put("project_id", project_id)
|> Tags.create_tag()
|> unwrap_result()
end
def update_tag(project_id, tag_id, attrs) do
case fetch_tag(project_id, tag_id) do
%Tag{} -> Tags.update_tag(tag_id, normalize_map(attrs)) |> unwrap_result()
_other -> nil
end
end
def delete_tag(project_id, tag_id) do
case fetch_tag(project_id, tag_id) do
%Tag{} -> boolean_result(Tags.delete_tag(tag_id))
_other -> false
end
end
def load_tag(project_id, tag_id) do
fetch_tag(project_id, tag_id)
|> sanitize_nilable()
end
def list_tags(project_id) do
Tags.list_tags(project_id)
|> Enum.map(&sanitize/1)
end
def tags_with_counts(project_id) do
counts_by_name =
PostsCaps.names_with_counts(project_id, :tags)
|> Map.new(fn entry -> {entry["name"], entry["count"]} end)
list_tags(project_id)
|> Enum.map(fn tag -> Map.put(tag, "count", Map.get(counts_by_name, tag["name"], 0)) end)
end
def tag_post_ids(project_id, tag_id) do
case fetch_tag(project_id, tag_id) do
%Tag{name: tag_name} ->
Repo.all(
from(post in Post,
where: post.project_id == ^project_id,
order_by: [asc: post.created_at]
)
)
|> Enum.filter(&(tag_name in (&1.tags || [])))
|> Enum.map(& &1.id)
_other ->
[]
end
end
def load_tag_by_name(project_id, tag_name) do
Repo.one(
from(tag in Tag,
where:
tag.project_id == ^project_id and
fragment("lower(?)", tag.name) == ^String.downcase(string_or_nil(tag_name) || ""),
limit: 1
)
)
|> sanitize_nilable()
end
def rename_tag(project_id, tag_id, new_name) do
case fetch_tag(project_id, tag_id) do
%Tag{} -> Tags.rename_tag(tag_id, string_or_nil(new_name) || "") |> unwrap_result()
_other -> nil
end
end
def merge_tags(project_id, source_tag_ids, target_tag_id) do
case fetch_tag(project_id, target_tag_id) do
%Tag{} -> atom_result(Tags.merge_tags(normalize_string_list(source_tag_ids), target_tag_id), :merged)
_other -> false
end
end
def sync_tags_from_posts(project_id) do
Tags.sync_tags_from_posts(project_id)
|> unwrap_result()
end
def fetch_tag(project_id, tag_id) do
Repo.one(
from(tag in Tag,
where: tag.project_id == ^project_id and tag.id == ^(string_or_nil(tag_id) || ""),
limit: 1
)
)
end
# --- Tasks ---
def load_task(task_id) do
case string_or_nil(task_id) do
nil -> nil
id -> Tasks.get_task(id) |> sanitize_nilable()
end
end
def cancel_task(task_id) do
case string_or_nil(task_id) do
nil -> false
id -> match?(:ok, Tasks.cancel_task(id))
end
end
def list_all_tasks do
Tasks.list_tasks()
|> Enum.map(&sanitize/1)
end
def list_running_tasks do
Tasks.list_running_tasks()
|> Enum.map(&sanitize/1)
end
def clear_completed_tasks do
match?(:ok, Tasks.clear_completed())
end
end

View File

@@ -0,0 +1,254 @@
defmodule BDS.Scripting.Capabilities.Media do
@moduledoc false
import Ecto.Query
import BDS.Scripting.Capabilities.Util
alias BDS.Media
alias BDS.Media.Media, as: MediaRecord
alias BDS.Media.Translation, as: MediaTranslation
alias BDS.Repo
alias BDS.Search
def import_media(project_id, attrs) do
attrs
|> normalize_map()
|> normalize_media_attrs()
|> Map.put("project_id", project_id)
|> Media.import_media()
|> unwrap_result()
end
def update_media(project_id, media_id, attrs) do
case fetch_media(project_id, media_id) do
%MediaRecord{} -> Media.update_media(media_id, attrs |> normalize_map() |> normalize_media_attrs()) |> unwrap_result()
_other -> nil
end
end
def delete_media(project_id, media_id) do
case fetch_media(project_id, media_id) do
%MediaRecord{} -> boolean_result(Media.delete_media(media_id))
_other -> false
end
end
def load_media(project_id, media_id) do
fetch_media(project_id, media_id)
|> sanitize_nilable()
end
def list_media(project_id) do
Repo.all(
from(media in MediaRecord, where: media.project_id == ^project_id, order_by: [asc: media.created_at])
)
|> Enum.map(&sanitize/1)
end
def load_media_translation(project_id, media_id, language) do
case fetch_media(project_id, media_id) do
%MediaRecord{id: id} ->
Repo.one(
from(translation in MediaTranslation,
where:
translation.translation_for == ^id and
translation.language == ^(string_or_nil(language) || ""),
limit: 1
)
)
|> sanitize_nilable()
_other ->
nil
end
end
def list_media_translations(project_id, media_id) do
case fetch_media(project_id, media_id) do
%MediaRecord{id: id} ->
Repo.all(
from(translation in MediaTranslation,
where: translation.translation_for == ^id,
order_by: [asc: translation.language]
)
)
|> Enum.map(&sanitize/1)
_other ->
[]
end
end
def upsert_media_translation(project_id, media_id, language, attrs) do
case fetch_media(project_id, media_id) do
%MediaRecord{} ->
Media.upsert_media_translation(media_id, string_or_nil(language) || "", normalize_media_translation_attrs(normalize_map(attrs)))
|> unwrap_result()
_other ->
nil
end
end
def delete_media_translation(project_id, media_id, language) do
case fetch_media(project_id, media_id) do
%MediaRecord{} ->
case Media.delete_media_translation(media_id, string_or_nil(language) || "") do
{:ok, deleted?} -> deleted?
{:error, _reason} -> false
end
_other ->
false
end
end
def filter_media(project_id, filters) do
filters = normalize_map(filters)
list_media(project_id)
|> Enum.filter(fn media -> media_matches_filters?(media, filters) end)
end
def media_counts_by_year_month(project_id) do
list_media(project_id)
|> Enum.reduce(%{}, fn media, acc ->
datetime = media_datetime(media)
key = {datetime.year, datetime.month}
Map.update(acc, key, 1, &(&1 + 1))
end)
|> Enum.map(fn {{year, month}, count} -> %{"year" => year, "month" => month, "count" => count} end)
|> Enum.sort_by(fn row -> {-row["year"], -row["month"]} end)
end
def media_file_path(project_id, media_id) do
case fetch_media(project_id, media_id) do
%MediaRecord{} = media -> Path.join(project_path(project_id), media.file_path)
_other -> nil
end
end
def media_tags(project_id), do: media_tags_with_counts(project_id) |> Enum.map(& &1["tag"])
def media_tags_with_counts(project_id) do
Repo.all(from(media in MediaRecord, where: media.project_id == ^project_id, order_by: [asc: media.created_at]))
|> Enum.flat_map(&(&1.tags || []))
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
|> Enum.map(fn {tag, count} -> %{"tag" => tag, "count" => count} end)
|> Enum.sort_by(fn row -> {-row["count"], String.downcase(row["tag"])} end)
end
def media_thumbnail(project_id, media_id, size) do
with %MediaRecord{} = media <- fetch_media(project_id, media_id),
relative_path <- Media.thumbnail_paths(media)[thumbnail_size(size)],
absolute_path <- Path.join(project_path(project_id), relative_path),
true <- File.exists?(absolute_path),
{:ok, binary} <- File.read(absolute_path) do
"data:#{thumbnail_mime(absolute_path)};base64," <> Base.encode64(binary)
else
_other -> nil
end
end
def media_url(project_id, media_id) do
case fetch_media(project_id, media_id) do
%MediaRecord{} = media -> "/" <> String.trim_leading(media.file_path, "/")
_other -> nil
end
end
def rebuild_media_from_files(project_id) do
project_id
|> Media.rebuild_media_from_files()
|> unwrap_result(fn media -> Enum.map(media, &sanitize/1) end)
end
def regenerate_missing_thumbnails(project_id) do
Media.regenerate_missing_thumbnails(project_id)
|> sanitize()
end
def regenerate_media_thumbnails(project_id, media_id) do
case fetch_media(project_id, media_id) do
%MediaRecord{} = media ->
case Media.regenerate_thumbnails(media.id) do
{:ok, _media} ->
Media.thumbnail_paths(media)
|> Enum.map(fn {size, relative_path} -> {to_string(size), Path.join(project_path(project_id), relative_path)} end)
|> Map.new()
{:error, _reason} ->
nil
end
_other ->
nil
end
end
def replace_media_file(project_id, media_id, source_path) do
case fetch_media(project_id, media_id) do
%MediaRecord{} ->
Media.replace_media_file(media_id, string_or_nil(source_path) || "")
|> unwrap_result()
_other ->
nil
end
end
def search_media(project_id, query) do
project_id
|> Search.search_media(string_or_nil(query) || "")
|> unwrap_result(fn %{media: media} -> Enum.map(media, &sanitize/1) end)
end
def fetch_media(project_id, media_id) do
Repo.one(
from(media in MediaRecord,
where: media.project_id == ^project_id and media.id == ^(string_or_nil(media_id) || ""),
limit: 1
)
)
end
def normalize_media_attrs(attrs) do
attrs
|> maybe_put_normalized_list("tags")
end
def normalize_media_translation_attrs(attrs) do
attrs
|> Map.take(["title", "alt", "caption"])
end
def media_matches_filters?(media, filters) do
created_at = media_datetime(media)
tags = Map.get(media, "tags", [])
language = Map.get(media, "language")
matches_year = compare_optional(Map.get(filters, "year"), fn year -> created_at.year == integer_or_default(year, created_at.year) end)
matches_month = compare_optional(Map.get(filters, "month"), fn month -> created_at.month == integer_or_default(month, created_at.month) end)
matches_language = compare_optional(blank_to_nil(Map.get(filters, "language")), fn value -> language == value end)
matches_tags = compare_optional(Map.get(filters, "tags"), fn required_tags -> Enum.all?(normalize_string_list(required_tags), &(&1 in tags)) end)
matches_from = compare_optional(parse_datetime(Map.get(filters, "from") || Map.get(filters, "start_date")), fn from_dt -> DateTime.compare(created_at, from_dt) != :lt end)
matches_to = compare_optional(parse_datetime(Map.get(filters, "to") || Map.get(filters, "end_date")), fn to_dt -> DateTime.compare(created_at, to_dt) != :gt end)
matches_year and matches_month and matches_language and matches_tags and matches_from and matches_to
end
def media_datetime(media) do
media
|> Map.get("created_at")
|> case do
value when is_binary(value) ->
case DateTime.from_iso8601(value) do
{:ok, datetime, _offset} -> datetime
_other -> DateTime.utc_now()
end
value when is_integer(value) -> DateTime.from_unix!(value, :millisecond)
_other -> DateTime.utc_now()
end
end
end

View File

@@ -0,0 +1,270 @@
defmodule BDS.Scripting.Capabilities.Posts do
@moduledoc false
import Ecto.Query
import BDS.Scripting.Capabilities.Util
alias BDS.PostLinks
alias BDS.Posts
alias BDS.Posts.Post
alias BDS.Posts.Translation, as: PostTranslation
alias BDS.Preview
alias BDS.Repo
alias BDS.Search
def create_post(project_id, attrs) do
attrs
|> normalize_map()
|> Map.put("project_id", project_id)
|> Posts.create_post()
|> unwrap_result(&post_payload/1)
end
def update_post(project_id, post_id, attrs) do
case fetch_post(project_id, post_id) do
%Post{} -> Posts.update_post(post_id, normalize_map(attrs)) |> unwrap_result(&post_payload/1)
_other -> nil
end
end
def delete_post(project_id, post_id) do
case fetch_post(project_id, post_id) do
%Post{} -> boolean_result(Posts.delete_post(post_id))
_other -> false
end
end
def load_post(project_id, post_id) do
case fetch_post(project_id, post_id) do
%Post{} = post -> post_payload(post)
_other -> nil
end
end
def list_posts(project_id) do
Repo.all(from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at]))
|> Enum.map(&post_payload/1)
end
def load_post_by_slug(project_id, slug) do
Repo.one(
from(post in Post,
where: post.project_id == ^project_id and post.slug == ^(string_or_nil(slug) || ""),
limit: 1
)
)
|> case do
%Post{} = post -> post_payload(post)
nil -> nil
end
end
def publish_post(project_id, post_id) do
case fetch_post(project_id, post_id) do
%Post{} -> Posts.publish_post(post_id) |> unwrap_result(&post_payload/1)
_other -> nil
end
end
def discard_post(project_id, post_id) do
case fetch_post(project_id, post_id) do
%Post{} -> Posts.discard_post_changes(post_id) |> unwrap_result(&post_payload/1)
_other -> nil
end
end
def filter_posts(project_id, filters) do
project_id
|> Search.search_posts("", normalize_search_filters(filters))
|> unwrap_result(fn %{posts: posts} -> Enum.map(posts, &post_payload/1) end)
end
def generate_unique_post_slug(project_id, title, exclude_post_id) do
Posts.unique_slug_for_title(project_id, string_or_nil(title) || "", string_or_nil(exclude_post_id))
end
def posts_by_status(project_id, status) do
normalized_status = string_or_nil(status) || ""
Repo.all(from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at]))
|> Enum.filter(&(to_string(&1.status) == normalized_status))
|> Enum.map(&post_payload/1)
end
def post_counts_by_year_month(project_id) do
Posts.post_counts_by_year_month(project_id)
|> sanitize()
end
def post_dashboard_stats(project_id) do
Posts.dashboard_stats(project_id)
|> sanitize()
end
def linked_posts_for(project_id, post_id, direction) do
case fetch_post(project_id, post_id) do
%Post{id: id} -> linked_posts(id, direction)
_other -> []
end
end
def preview_url(project_id, post_id, options) do
case fetch_post(project_id, post_id) do
%Post{} = post ->
with {:ok, server} <- Preview.start_preview(project_id) do
base_url = "http://#{server.host}:#{server.port}"
canonical_path = canonical_preview_path(post.created_at, post.slug)
options = normalize_map(options)
language = options |> Map.get("lang") |> string_or_nil() |> blank_to_nil()
query =
%{}
|> maybe_put_query("draft", truthy?(Map.get(options, "draft")) && "true")
|> maybe_put_query("post_id", truthy?(Map.get(options, "draft")) && post.id)
|> maybe_put_query("lang", language)
if map_size(query) == 0 do
base_url <> canonical_path
else
base_url <> canonical_path <> "?" <> URI.encode_query(query)
end
else
_other -> nil
end
_other ->
nil
end
end
def post_slug_available?(project_id, slug, exclude_post_id) do
Posts.slug_available(project_id, string_or_nil(slug) || "", string_or_nil(exclude_post_id))
end
def publish_post_translation(project_id, post_id, language) do
case fetch_post(project_id, post_id) do
%Post{} -> Posts.publish_post_translation(post_id, string_or_nil(language) || "") |> unwrap_result()
_other -> nil
end
end
def rebuild_post_links(project_id) do
case Posts.rebuild_post_links(project_id) do
:ok -> true
end
end
def rebuild_posts_from_files(project_id) do
project_id
|> Posts.rebuild_posts_from_files()
|> unwrap_result(fn posts -> Enum.map(posts, &post_payload/1) end)
end
def reindex_project_search(project_id) do
case Search.reindex_project(project_id) do
:ok -> true
end
end
def search_posts(project_id, query) do
project_id
|> Search.search_posts(string_or_nil(query) || "")
|> unwrap_result(fn %{posts: posts} -> Enum.map(posts, &post_payload/1) end)
end
def post_tags(project_id), do: names_with_counts(project_id, :tags) |> Enum.map(& &1["name"])
def post_tags_with_counts(project_id), do: names_with_counts(project_id, :tags)
def post_categories(project_id), do: names_with_counts(project_id, :categories) |> Enum.map(& &1["name"])
def post_categories_with_counts(project_id), do: names_with_counts(project_id, :categories)
def list_post_translations(project_id, post_id) do
case fetch_post(project_id, post_id) do
%Post{id: id} ->
id
|> Posts.list_post_translations()
|> unwrap_result(fn translations -> Enum.map(translations, &sanitize/1) end)
_other ->
[]
end
end
def load_post_translation(project_id, post_id, language) do
case fetch_post(project_id, post_id) do
%Post{id: id} ->
Repo.one(
from(translation in PostTranslation,
where:
translation.translation_for == ^id and
translation.language == ^(string_or_nil(language) || ""),
limit: 1
)
)
|> sanitize_nilable()
_other ->
nil
end
end
def has_published_post_version(project_id, post_id) do
case fetch_post(project_id, post_id) do
%Post{status: :published} -> true
%Post{published_at: published_at, file_path: file_path} -> not is_nil(published_at) or file_path not in [nil, ""]
_other -> false
end
end
def fetch_post(project_id, post_id) do
Repo.one(
from(post in Post,
where: post.project_id == ^project_id and post.id == ^(string_or_nil(post_id) || ""),
limit: 1
)
)
end
def post_payload(%Post{} = post) do
post
|> sanitize()
|> Map.put("backlinks", linked_posts(post.id, :incoming))
|> Map.put("links_to", linked_posts(post.id, :outgoing))
end
def linked_posts(post_id, :incoming) do
PostLinks.list_incoming_links(post_id)
|> Enum.map(&load_linked_post(&1.source_post_id))
|> Enum.reject(&is_nil/1)
end
def linked_posts(post_id, :outgoing) do
PostLinks.list_outgoing_links(post_id)
|> Enum.map(&load_linked_post(&1.target_post_id))
|> Enum.reject(&is_nil/1)
end
defp load_linked_post(post_id) do
case Repo.get(Post, post_id) do
%Post{} = post -> %{"id" => post.id, "title" => post.title, "slug" => post.slug}
nil -> nil
end
end
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)}/#{string_or_nil(slug) || ""}"
end
def names_with_counts(project_id, field) when field in [:tags, :categories] do
Repo.all(
from(post in Post,
where: post.project_id == ^project_id,
order_by: [asc: post.created_at]
)
)
|> Enum.flat_map(&(Map.get(&1, field) || []))
|> Enum.reduce(%{}, fn name, acc -> Map.update(acc, name, 1, &(&1 + 1)) end)
|> Enum.map(fn {name, count} -> %{"name" => name, "count" => count} end)
|> Enum.sort_by(&{String.downcase(&1["name"]), &1["count"]})
end
end

View File

@@ -0,0 +1,204 @@
defmodule BDS.Scripting.Capabilities.Projects do
@moduledoc false
import BDS.Scripting.Capabilities.Crud
import BDS.Scripting.Capabilities.Util
alias BDS.Metadata
alias BDS.Projects, as: ProjectsCtx
alias BDS.Projects.Project
alias BDS.Repo
alias BDS.Tags
def create_project(attrs), do: attrs |> normalize_map() |> ProjectsCtx.create_project() |> unwrap_result()
def delete_project(project_id), do: boolean_result(ProjectsCtx.delete_project(string_or_nil(project_id)))
def delete_project_with_data(project_id) do
case string_or_nil(project_id) && ProjectsCtx.get_project(string_or_nil(project_id)) do
%Project{} = project ->
data_dir = ProjectsCtx.project_data_dir(project)
case ProjectsCtx.delete_project(project.id) do
{:ok, _deleted_project} ->
_ = File.rm_rf(data_dir)
true
{:error, _reason} ->
false
end
_other ->
false
end
end
def load_project(project_id) do
case string_or_nil(project_id) do
nil -> nil
id -> ProjectsCtx.get_project(id) |> sanitize_nilable()
end
end
def list_projects do
ProjectsCtx.list_projects()
|> Enum.map(&sanitize/1)
end
def set_active_project(project_id) do
project_id
|> string_or_nil()
|> then(fn
nil -> {:error, :not_found}
id -> ProjectsCtx.set_active_project(id)
end)
|> unwrap_result()
end
def update_project(project_id, attrs) do
case string_or_nil(project_id) && ProjectsCtx.get_project(string_or_nil(project_id)) do
%Project{} = project ->
attrs = normalize_map(attrs)
updates = %{
name: Map.get(attrs, "name", project.name),
description: Map.get(attrs, "description", project.description),
data_path: Map.get(attrs, "data_path", project.data_path),
updated_at: System.system_time(:millisecond),
is_active: Map.get(attrs, "is_active", project.is_active)
}
project
|> Project.changeset(updates)
|> Repo.update()
|> unwrap_result()
_other ->
nil
end
end
def load_metadata(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
sanitize(metadata)
end
def update_project_metadata(project_id, attrs) do
Metadata.update_project_metadata(project_id, normalize_map(attrs))
|> unwrap_result()
end
def add_category(project_id, name) do
Metadata.add_category(project_id, string_or_nil(name) || "")
|> unwrap_result()
end
def remove_category(project_id, name) do
Metadata.remove_category(project_id, string_or_nil(name) || "")
|> unwrap_result()
end
def metadata_categories(project_id) do
load_metadata(project_id)
|> Map.get("categories", [])
end
def metadata_tags(project_id) do
project_id
|> list_tags()
|> Enum.map(&Map.get(&1, "name"))
end
def add_meta_tag(project_id, name) do
normalized_name = string_or_nil(name) |> to_string() |> String.trim()
cond do
normalized_name == "" -> metadata_tags(project_id)
load_tag_by_name(project_id, normalized_name) -> metadata_tags(project_id)
true ->
create_tag(project_id, %{"name" => normalized_name})
metadata_tags(project_id)
end
end
def remove_meta_tag(project_id, name) do
case load_tag_by_name(project_id, name) do
%{"id" => tag_id} ->
_ = delete_tag(project_id, tag_id)
metadata_tags(project_id)
_other ->
metadata_tags(project_id)
end
end
def publishing_preferences(project_id) do
load_metadata(project_id)
|> Map.get("publishing_preferences")
end
def set_publishing_preferences(project_id, prefs) do
project_id
|> Metadata.set_publishing_preferences(normalize_map(prefs))
|> unwrap_result()
|> case do
nil -> nil
metadata -> Map.get(metadata, "publishing_preferences")
end
end
def clear_publishing_preferences(project_id) do
set_publishing_preferences(project_id, %{})
end
def sync_meta_on_startup(project_id) do
_ = Tags.sync_tags_from_posts(project_id)
%{
tags: metadata_tags(project_id),
categories: metadata_categories(project_id),
project_metadata: load_metadata(project_id)
}
end
def data_paths(project_id) do
database_path = Repo.config()[:database]
project_dir = project_path(project_id)
%{
database: database_path,
project: project_dir,
posts: Path.join(project_dir, "posts"),
media: Path.join(project_dir, "media")
}
end
def read_project_metadata(folder_path) do
case project_for_folder(folder_path) do
nil -> read_project_metadata_file(folder_path)
project -> load_metadata(project.id)
end
end
def project_for_folder(folder_path) do
normalized = string_or_nil(folder_path)
ProjectsCtx.list_projects()
|> Enum.find(fn project -> ProjectsCtx.project_data_dir(project) == normalized end)
end
def read_project_metadata_file(folder_path) do
path = Path.join([string_or_nil(folder_path) || "", "meta", "project.json"])
case File.read(path) do
{:ok, contents} ->
case Jason.decode(contents) do
{:ok, decoded} when is_map(decoded) -> sanitize(decoded)
_other -> nil
end
{:error, _reason} ->
nil
end
end
end

View File

@@ -0,0 +1,301 @@
defmodule BDS.Scripting.Capabilities.Util do
@moduledoc false
alias BDS.Projects
def project_path(project_id) do
project_id
|> Projects.get_project()
|> Projects.project_data_dir()
end
def sanitize(%DateTime{} = value), do: DateTime.to_iso8601(value)
def sanitize(%_struct{} = struct) do
struct
|> Map.from_struct()
|> Map.drop([:__meta__, :post, :project, :media, :translations])
|> sanitize()
end
def sanitize(map) when is_map(map) do
Map.new(map, fn {key, value} -> {to_string(key), sanitize(value)} end)
end
def sanitize(list) when is_list(list), do: Enum.map(list, &sanitize/1)
def sanitize(value) when is_boolean(value), do: value
def sanitize(value) when is_atom(value), do: Atom.to_string(value)
def sanitize(value), do: value
def sanitize_nilable(nil), do: nil
def sanitize_nilable(value), do: sanitize(value)
def normalize_input(%_struct{} = struct), do: struct |> Map.from_struct() |> normalize_input()
def normalize_input(map) when is_map(map) do
normalized =
Map.new(map, fn {key, value} -> {normalize_input_key(key), normalize_input(value)} end)
if numeric_sequence_map?(normalized) do
normalized
|> Enum.sort_by(fn {key, _value} -> key end)
|> Enum.map(fn {_key, value} -> value end)
else
normalized
end
end
def normalize_input(list) when is_list(list) do
if Enum.all?(list, &match?({key, _value} when is_integer(key) or is_float(key) or is_binary(key) or is_atom(key), &1)) do
normalized =
Map.new(list, fn {key, value} -> {normalize_input_key(key), normalize_input(value)} end)
if numeric_sequence_map?(normalized) do
normalized
|> Enum.sort_by(fn {key, _value} -> key end)
|> Enum.map(fn {_key, value} -> value end)
else
normalized
end
else
Enum.map(list, &normalize_input/1)
end
end
def normalize_input(value) when is_atom(value), do: Atom.to_string(value)
def normalize_input(value), do: value
def normalize_input_key(key) when is_integer(key), do: key
def normalize_input_key(key) when is_float(key) and trunc(key) == key, do: trunc(key)
def normalize_input_key(key) when is_binary(key) do
case Integer.parse(key) do
{integer, ""} -> integer
_other -> key
end
end
def normalize_input_key(key) when is_atom(key), do: Atom.to_string(key)
def normalize_input_key(key), do: key
def numeric_sequence_map?(map) when map == %{}, do: false
def numeric_sequence_map?(map) do
keys = Map.keys(map)
Enum.all?(keys, &is_integer/1) and Enum.sort(keys) == Enum.to_list(1..length(keys))
end
def normalize_map(value) when is_map(value) do
case normalize_input(value) do
normalized when is_map(normalized) -> normalized
_other -> %{}
end
end
def normalize_map(value) when is_list(value) do
if Enum.all?(value, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do
Map.new(value, fn {key, entry_value} -> {to_string(key), normalize_input(entry_value)} end)
else
%{}
end
end
def normalize_map(_value), do: %{}
def normalize_string_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
def normalize_string_list(value) when is_map(value) do
value
|> normalize_input()
|> case do
normalized when is_list(normalized) -> Enum.map(normalized, &to_string/1)
_other -> []
end
end
def normalize_string_list(_value), do: []
def normalize_search_filters(filters) do
filters
|> normalize_map()
|> Enum.into(%{}, fn {key, value} ->
normalized_key =
case key do
"start_date" -> "from"
"end_date" -> "to"
other -> other
end
{normalized_key, value}
end)
end
def integer_or_default(value, _default) when is_integer(value), do: value
def integer_or_default(value, _default) when is_float(value), do: trunc(value)
def integer_or_default(_value, default), do: default
def string_or_nil(value) when is_binary(value), do: value
def string_or_nil(value) when is_atom(value), do: Atom.to_string(value)
def string_or_nil(value) when is_number(value), do: to_string(value)
def string_or_nil(_value), do: nil
def truthy?(value), do: value in [true, "true", 1, 1.0, "1"]
def pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
def blank_to_nil(nil), do: nil
def blank_to_nil(value) when is_binary(value) do
if String.trim(value) == "", do: nil, else: String.trim(value)
end
def blank_to_nil(value), do: value
def maybe_put_query(query, _key, false), do: query
def maybe_put_query(query, _key, nil), do: query
def maybe_put_query(query, key, value), do: Map.put(query, key, value)
def maybe_put_opt(opts, _key, nil), do: opts
def maybe_put_opt(opts, key, value), do: Keyword.put(opts, key, value)
def maybe_put_normalized_list(attrs, key) do
case Map.fetch(attrs, key) do
{:ok, value} -> Map.put(attrs, key, normalize_string_list(value))
:error -> attrs
end
end
def compare_optional(nil, _fun), do: true
def compare_optional(value, fun) when is_function(fun, 1), do: fun.(value)
def parse_datetime(nil), do: nil
def parse_datetime(value) when is_integer(value), do: DateTime.from_unix!(value, :millisecond)
def parse_datetime(value) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, datetime, _offset} -> datetime
_other -> nil
end
end
def parse_datetime(_value), do: nil
def unwrap_result(result, transform \\ &sanitize/1)
def unwrap_result({:ok, value}, transform), do: transform.(value)
def unwrap_result({:error, _reason}, _transform), do: nil
def boolean_result({:ok, _value}), do: true
def boolean_result({:error, _reason}), do: false
def atom_result({:ok, value}, expected_value), do: value == expected_value
def atom_result(_result, _expected_value), do: false
def thumbnail_size(size) do
case blank_to_nil(size) do
"medium" -> :medium
"large" -> :large
"ai" -> :ai
_other -> :small
end
end
def thumbnail_mime(path) do
case Path.extname(path) do
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
_other -> "image/webp"
end
end
def shell_open_system_path(path) do
{command, args} =
case :os.type() do
{:unix, :darwin} -> {"open", [path]}
{:unix, _other} -> {"xdg-open", [path]}
{:win32, _other} -> {"cmd", ["/c", "start", "", path]}
end
case System.cmd(command, args, stderr_to_stdout: true) do
{_output, 0} -> :ok
{output, status} -> {:error, {status, String.trim(output)}}
end
rescue
error -> {:error, error}
end
def shell_reveal_system_path(path) do
{command, args} =
case :os.type() do
{:unix, :darwin} -> {"open", ["-R", path]}
{:unix, _other} -> {"xdg-open", [Path.dirname(path)]}
{:win32, _other} -> {"explorer", ["/select,", path]}
end
case System.cmd(command, args, stderr_to_stdout: true) do
{_output, 0} -> :ok
{output, status} -> {:error, {status, String.trim(output)}}
end
rescue
error -> {:error, error}
end
def zero_or_one_arg(callback) when is_function(callback, 1) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
value = callback.(normalize_input(decoded_args))
:luerl.encode_list([sanitize(value)], state)
end
end
def one_arg(callback) when is_function(callback, 1) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
value =
case decoded_args do
[first | _rest] -> callback.(normalize_input(first))
[] -> callback.(nil)
end
:luerl.encode_list([sanitize(value)], state)
end
end
def two_arg(callback) when is_function(callback, 2) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
value =
case decoded_args do
[first, second | _rest] -> callback.(normalize_input(first), normalize_input(second))
[first] -> callback.(normalize_input(first), nil)
[] -> callback.(nil, nil)
end
:luerl.encode_list([sanitize(value)], state)
end
end
def three_arg(callback) when is_function(callback, 3) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
value =
case decoded_args do
[first, second, third | _rest] ->
callback.(normalize_input(first), normalize_input(second), normalize_input(third))
[first, second] ->
callback.(normalize_input(first), normalize_input(second), nil)
[first] ->
callback.(normalize_input(first), nil, nil)
[] ->
callback.(nil, nil, nil)
end
:luerl.encode_list([sanitize(value)], state)
end
end
end

View File

@@ -1072,7 +1072,7 @@ defmodule BDS.Desktop.ShellLiveTest do
parent = self() parent = self()
:ok = BDS.Tasks.clear_finished() :ok = BDS.Tasks.clear_finished()
{:ok, _task} = {:ok, task} =
BDS.Tasks.submit_task( BDS.Tasks.submit_task(
"Metadata Diff", "Metadata Diff",
fn report -> fn report ->
@@ -1149,7 +1149,10 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "35%" assert html =~ "35%"
assert html =~ ~s(task-status-running) assert html =~ ~s(task-status-running)
worker_ref = Process.monitor(worker_pid)
send(worker_pid, :finish) send(worker_pid, :finish)
assert_receive {:DOWN, ^worker_ref, :process, _, _}, 1_000
completed_task!(task.id)
send(view.pid, :refresh_task_status) send(view.pid, :refresh_task_status)
html = render(view) html = render(view)