chore: working on code smells
This commit is contained in:
@@ -34,6 +34,17 @@ defmodule BDS.AI do
|
||||
airplane_image_analysis: "ai.airplane.model.image_analysis"
|
||||
}
|
||||
|
||||
@typedoc "Endpoint kind such as :chat, :airplane_chat, :embedding, etc."
|
||||
@type endpoint_kind :: atom()
|
||||
|
||||
@typedoc "Endpoint configuration map."
|
||||
@type endpoint :: %{kind: endpoint_kind(), url: String.t() | nil, api_key: String.t() | nil, model: String.t() | nil}
|
||||
|
||||
@typedoc "Attribute map for endpoint operations."
|
||||
@type endpoint_attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
|
||||
|
||||
@spec put_endpoint(endpoint_kind(), endpoint_attrs(), keyword()) ::
|
||||
{:ok, endpoint()} | {:error, term()}
|
||||
def put_endpoint(kind, attrs, opts \\ []) when is_atom(kind) and is_map(attrs) and is_list(opts) do
|
||||
backend = Keyword.get(opts, :secret_backend, SecretBackend)
|
||||
kind_key = Atom.to_string(kind)
|
||||
@@ -49,6 +60,8 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_endpoint(endpoint_kind(), keyword()) ::
|
||||
{:ok, endpoint() | nil} | {:error, term()}
|
||||
def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do
|
||||
backend = Keyword.get(opts, :secret_backend, SecretBackend)
|
||||
kind_key = Atom.to_string(kind)
|
||||
@@ -67,6 +80,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_endpoint(endpoint_kind()) :: :ok
|
||||
def delete_endpoint(kind) when is_atom(kind) do
|
||||
kind_key = Atom.to_string(kind)
|
||||
delete_setting("ai.#{kind_key}.url")
|
||||
@@ -75,11 +89,15 @@ defmodule BDS.AI do
|
||||
:ok
|
||||
end
|
||||
|
||||
@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)
|
||||
|
||||
@@ -111,6 +129,7 @@ defmodule BDS.AI do
|
||||
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 ->
|
||||
@@ -126,6 +145,7 @@ defmodule BDS.AI do
|
||||
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,
|
||||
@@ -144,14 +164,17 @@ defmodule BDS.AI do
|
||||
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 set_airplane_mode(boolean()) :: :ok | {:error, term()}
|
||||
def set_airplane_mode(enabled) when is_boolean(enabled) do
|
||||
put_setting("ai.airplane_mode_enabled", Atom.to_string(enabled))
|
||||
end
|
||||
|
||||
@spec airplane_mode?(boolean()) :: boolean()
|
||||
def airplane_mode?(default \\ false) when is_boolean(default) do
|
||||
case get_setting("ai.airplane_mode_enabled") do
|
||||
nil -> default
|
||||
@@ -160,6 +183,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec put_model_preference(atom(), String.t()) :: :ok | {:error, :unknown_model_preference | term()}
|
||||
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
|
||||
case Map.fetch(@model_preference_keys, key) do
|
||||
{:ok, setting_key} -> put_setting(setting_key, model)
|
||||
@@ -167,6 +191,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_model_preference(atom()) :: {:ok, String.t() | nil} | {:error, :unknown_model_preference}
|
||||
def get_model_preference(key) when is_atom(key) do
|
||||
case Map.fetch(@model_preference_keys, key) do
|
||||
{:ok, setting_key} -> {:ok, get_setting(setting_key)}
|
||||
@@ -174,6 +199,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
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")),
|
||||
@@ -183,6 +209,7 @@ defmodule BDS.AI do
|
||||
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
|
||||
end
|
||||
|
||||
@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,
|
||||
@@ -194,6 +221,7 @@ defmodule BDS.AI do
|
||||
)
|
||||
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(
|
||||
@@ -212,6 +240,7 @@ defmodule BDS.AI do
|
||||
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 = %{
|
||||
@@ -246,6 +275,7 @@ defmodule BDS.AI do
|
||||
)
|
||||
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(
|
||||
@@ -265,6 +295,7 @@ defmodule BDS.AI do
|
||||
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
|
||||
@@ -285,6 +316,7 @@ defmodule BDS.AI do
|
||||
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
|
||||
@@ -305,6 +337,7 @@ defmodule BDS.AI do
|
||||
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
|
||||
@@ -325,6 +358,7 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@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")
|
||||
@@ -346,11 +380,13 @@ defmodule BDS.AI do
|
||||
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()
|
||||
|
||||
@@ -378,6 +414,8 @@ defmodule BDS.AI do
|
||||
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
|
||||
@@ -395,6 +433,7 @@ defmodule BDS.AI do
|
||||
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,
|
||||
@@ -404,6 +443,8 @@ defmodule BDS.AI do
|
||||
|> 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),
|
||||
@@ -437,6 +478,7 @@ defmodule BDS.AI do
|
||||
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
|
||||
|
||||
@@ -915,7 +915,6 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
|
||||
defp blank?(nil), do: true
|
||||
defp blank?(_value), do: false
|
||||
|
||||
defp rewrite_external_images(html) do
|
||||
html =
|
||||
|
||||
@@ -18,6 +18,19 @@ defmodule BDS.Generation do
|
||||
|
||||
@core_sections [:core, :single, :category, :tag, :date]
|
||||
|
||||
@typedoc "A section identifier accepted by `generate_site/3` and friends."
|
||||
@type section :: :core | :single | :category | :tag | :date
|
||||
|
||||
@typedoc "Options accepted by long-running generation operations."
|
||||
@type generation_opts :: keyword()
|
||||
|
||||
@typedoc "Plan returned by `plan_generation/2`."
|
||||
@type plan :: map()
|
||||
|
||||
@typedoc "Validation report returned by `validate_site/3`."
|
||||
@type validation_report :: map()
|
||||
|
||||
@spec plan_generation(String.t(), [section()]) :: {:ok, plan()}
|
||||
def plan_generation(project_id, sections \\ [:core])
|
||||
when is_binary(project_id) and is_list(sections) do
|
||||
project = Projects.get_project!(project_id)
|
||||
@@ -40,6 +53,8 @@ defmodule BDS.Generation do
|
||||
}}
|
||||
end
|
||||
|
||||
@spec generate_site(String.t(), [section()], generation_opts()) ::
|
||||
{:ok, %{sections: [section()], generated_files: [map()]}} | {:error, term()}
|
||||
def generate_site(project_id, sections \\ [:core], opts \\ [])
|
||||
|
||||
def generate_site(project_id, sections, opts)
|
||||
@@ -63,6 +78,8 @@ defmodule BDS.Generation do
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_site(String.t(), [section()], generation_opts()) ::
|
||||
{:ok, validation_report()} | {:error, term()}
|
||||
def validate_site(project_id, sections \\ @core_sections, opts \\ [])
|
||||
|
||||
def validate_site(project_id, sections, opts) when is_binary(project_id) and is_list(sections) and is_list(opts) do
|
||||
@@ -189,6 +206,7 @@ defmodule BDS.Generation do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec apply_validation(String.t(), [section()] | map()) :: {:ok, map()} | {:error, term()}
|
||||
def apply_validation(project_id, sections) when is_binary(project_id) and is_list(sections) do
|
||||
with {:ok, plan} <- plan_generation(project_id, sections) do
|
||||
expected_outputs = build_outputs(plan)
|
||||
@@ -283,8 +301,10 @@ defmodule BDS.Generation do
|
||||
end
|
||||
end
|
||||
|
||||
@spec post_output_path(map()) :: String.t()
|
||||
def post_output_path(post), do: post_output_path(post, nil)
|
||||
|
||||
@spec post_output_path(map(), String.t() | nil) :: String.t()
|
||||
def post_output_path(post, language) when is_map(post) do
|
||||
{year, month, day} = local_date_parts!(post.created_at)
|
||||
year = Integer.to_string(year)
|
||||
@@ -300,9 +320,14 @@ defmodule BDS.Generation do
|
||||
end
|
||||
end
|
||||
|
||||
@typedoc "Result returned by `write_generated_file/3,4`."
|
||||
@type write_result :: %{relative_path: String.t(), content_hash: String.t(), written?: boolean()}
|
||||
|
||||
@spec write_generated_file(String.t(), String.t(), String.t()) :: {:ok, write_result()}
|
||||
def write_generated_file(project_id, relative_path, content),
|
||||
do: write_generated_file(project_id, relative_path, content, [])
|
||||
|
||||
@spec write_generated_file(String.t(), String.t(), String.t(), keyword()) :: {:ok, write_result()}
|
||||
def write_generated_file(project_id, relative_path, content, opts)
|
||||
when is_binary(project_id) and is_binary(relative_path) and is_binary(content) and is_list(opts) do
|
||||
project = Projects.get_project!(project_id)
|
||||
@@ -335,6 +360,7 @@ defmodule BDS.Generation do
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_generated_files(String.t()) :: {:ok, [map()]}
|
||||
def list_generated_files(project_id) when is_binary(project_id) do
|
||||
{:ok,
|
||||
Repo.all(
|
||||
@@ -344,6 +370,7 @@ defmodule BDS.Generation do
|
||||
)}
|
||||
end
|
||||
|
||||
@spec delete_generated_file(String.t(), String.t()) :: :ok | {:error, term()}
|
||||
def delete_generated_file(project_id, relative_path)
|
||||
when is_binary(project_id) and is_binary(relative_path) do
|
||||
project = Projects.get_project!(project_id)
|
||||
|
||||
@@ -21,6 +21,13 @@ defmodule BDS.MCP do
|
||||
@page_size 50
|
||||
@proposal_ttl_app_ms 30 * 60 * 1000
|
||||
|
||||
@typedoc "Tool descriptor returned by `list_tools/0`."
|
||||
@type tool_descriptor :: %{name: String.t(), annotations: map()}
|
||||
|
||||
@typedoc "Resource descriptor returned by `list_resources/0`."
|
||||
@type resource_descriptor :: %{name: String.t(), uri: String.t()}
|
||||
|
||||
@spec list_tools() :: [tool_descriptor()]
|
||||
def list_tools do
|
||||
[
|
||||
tool("check_term", true),
|
||||
@@ -37,6 +44,7 @@ defmodule BDS.MCP do
|
||||
]
|
||||
end
|
||||
|
||||
@spec list_resources() :: [resource_descriptor()]
|
||||
def list_resources do
|
||||
[
|
||||
%{name: "posts", uri: "bds://posts"},
|
||||
@@ -46,6 +54,7 @@ defmodule BDS.MCP do
|
||||
]
|
||||
end
|
||||
|
||||
@spec call_tool(String.t(), map()) :: {:ok, term()} | {:error, term()}
|
||||
def call_tool(name, params) when is_binary(name) and is_map(params) do
|
||||
ProposalStore.ensure_started()
|
||||
|
||||
@@ -65,6 +74,7 @@ defmodule BDS.MCP do
|
||||
end
|
||||
end
|
||||
|
||||
@spec read_resource(String.t()) :: {:ok, term()} | {:error, term()}
|
||||
def read_resource(uri) when is_binary(uri) do
|
||||
ProposalStore.ensure_started()
|
||||
|
||||
@@ -79,6 +89,7 @@ defmodule BDS.MCP do
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
|
||||
def validate_template(source) when is_binary(source) do
|
||||
case Liquex.parse(source) do
|
||||
{:ok, _ast} -> {:ok, %{valid: true, errors: []}}
|
||||
|
||||
@@ -13,6 +13,13 @@ defmodule BDS.Media do
|
||||
alias BDS.Search
|
||||
alias BDS.Sidecar
|
||||
|
||||
@typedoc "An attribute map that may use atom or string keys."
|
||||
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
|
||||
|
||||
@typedoc "Options accepted by long-running rebuild operations."
|
||||
@type rebuild_opts :: keyword()
|
||||
|
||||
@spec import_media(attrs()) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t() | term()}
|
||||
def import_media(attrs) do
|
||||
project = Projects.get_project!(attr(attrs, :project_id))
|
||||
source_path = attr(attrs, :source_path)
|
||||
@@ -65,6 +72,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec update_media(String.t(), attrs()) ::
|
||||
{:ok, Media.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def update_media(media_id, attrs) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -102,6 +111,7 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec sync_media_sidecar(String.t()) :: :ok | {:error, :not_found | term()}
|
||||
def sync_media_sidecar(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -114,6 +124,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec sync_media_from_sidecar(String.t()) ::
|
||||
{:ok, Media.t()} | {:error, :not_found | term()}
|
||||
def sync_media_from_sidecar(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -131,6 +143,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec sync_media_translation_sidecar(String.t()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found | term()}
|
||||
def sync_media_translation_sidecar(translation_id) do
|
||||
case Repo.get(Translation, translation_id) do
|
||||
nil ->
|
||||
@@ -144,6 +158,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec sync_media_translation_from_sidecar(String.t()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found | term()}
|
||||
def sync_media_translation_from_sidecar(translation_id) do
|
||||
case Repo.get(Translation, translation_id) do
|
||||
nil ->
|
||||
@@ -171,6 +187,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec import_orphan_media_sidecar(String.t(), String.t()) ::
|
||||
{:ok, Media.t()} | {:error, term()}
|
||||
def import_orphan_media_sidecar(project_id, relative_path) do
|
||||
project = Projects.get_project!(project_id)
|
||||
sidecar_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||
@@ -182,6 +200,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec import_orphan_media_translation_sidecar(String.t(), String.t()) ::
|
||||
{:ok, Translation.t()} | {:error, term()}
|
||||
def import_orphan_media_translation_sidecar(project_id, relative_path) do
|
||||
project = Projects.get_project!(project_id)
|
||||
sidecar_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||
@@ -214,6 +234,7 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_media(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
||||
def delete_media(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -244,6 +265,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec upsert_media_translation(String.t(), String.t() | atom(), attrs()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def upsert_media_translation(media_id, language, attrs) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -286,6 +309,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_media_translation(String.t(), String.t() | atom()) ::
|
||||
{:ok, :deleted} | {:error, :not_found}
|
||||
def delete_media_translation(media_id, language) do
|
||||
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
||||
|
||||
@@ -316,6 +341,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec replace_media_file(String.t(), String.t()) ::
|
||||
{:ok, Media.t() | nil} | {:error, :not_found | Ecto.Changeset.t() | term()}
|
||||
def replace_media_file(media_id, new_source_path) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -363,6 +390,7 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_media_translations(String.t()) :: [Translation.t()]
|
||||
def list_media_translations(media_id) when is_binary(media_id) do
|
||||
Repo.all(
|
||||
from translation in Translation,
|
||||
@@ -371,6 +399,7 @@ defmodule BDS.Media do
|
||||
)
|
||||
end
|
||||
|
||||
@spec list_linked_posts(String.t()) :: [%{post_id: String.t(), title: String.t(), sort_order: integer()}]
|
||||
def list_linked_posts(media_id) when is_binary(media_id) do
|
||||
Repo.all(
|
||||
from post in BDS.Posts.Post,
|
||||
@@ -386,6 +415,8 @@ defmodule BDS.Media do
|
||||
)
|
||||
end
|
||||
|
||||
@spec link_media_to_post(String.t(), String.t()) ::
|
||||
{:ok, :linked} | {:error, :not_found | term()}
|
||||
def link_media_to_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do
|
||||
case {Repo.get(Media, media_id), Repo.get(BDS.Posts.Post, post_id)} do
|
||||
{nil, _post} ->
|
||||
@@ -424,6 +455,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec unlink_media_from_post(String.t(), String.t()) ::
|
||||
{:ok, :unlinked} | {:error, :not_found | term()}
|
||||
def unlink_media_from_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -444,6 +477,7 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec thumbnail_paths(Media.t()) :: %{required(atom()) => String.t()}
|
||||
def thumbnail_paths(%Media{id: id}) do
|
||||
prefix = String.slice(id, 0, 2)
|
||||
|
||||
@@ -455,6 +489,7 @@ defmodule BDS.Media do
|
||||
}
|
||||
end
|
||||
|
||||
@spec regenerate_thumbnails(String.t()) :: {:ok, Media.t()} | {:error, :not_found | term()}
|
||||
def regenerate_thumbnails(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
nil ->
|
||||
@@ -467,6 +502,8 @@ defmodule BDS.Media do
|
||||
end
|
||||
end
|
||||
|
||||
@spec regenerate_missing_thumbnails(String.t(), rebuild_opts()) ::
|
||||
%{processed: non_neg_integer(), generated: non_neg_integer(), failed: non_neg_integer()}
|
||||
def regenerate_missing_thumbnails(project_id, opts \\ []) do
|
||||
project = Projects.get_project!(project_id)
|
||||
on_progress = progress_callback(opts)
|
||||
@@ -518,6 +555,7 @@ defmodule BDS.Media do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec rebuild_media_from_files(String.t(), rebuild_opts()) :: {:ok, [Media.t()]}
|
||||
def rebuild_media_from_files(project_id, opts \\ []) do
|
||||
project = Projects.get_project!(project_id)
|
||||
on_progress = progress_callback(opts)
|
||||
|
||||
@@ -9,6 +9,29 @@ defmodule BDS.Media.Media do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@foreign_key_type :string
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: String.t() | nil,
|
||||
project_id: String.t() | nil,
|
||||
project: term(),
|
||||
filename: String.t() | nil,
|
||||
original_name: String.t() | nil,
|
||||
mime_type: String.t() | nil,
|
||||
size: integer() | nil,
|
||||
width: integer() | nil,
|
||||
height: integer() | nil,
|
||||
title: String.t() | nil,
|
||||
alt: String.t() | nil,
|
||||
caption: String.t() | nil,
|
||||
author: String.t() | nil,
|
||||
language: String.t() | nil,
|
||||
file_path: String.t() | nil,
|
||||
sidecar_path: String.t() | nil,
|
||||
checksum: String.t() | nil,
|
||||
tags: [String.t()],
|
||||
created_at: integer() | nil,
|
||||
updated_at: integer() | nil
|
||||
}
|
||||
|
||||
schema "media" do
|
||||
belongs_to :project, BDS.Projects.Project, type: :string
|
||||
|
||||
@@ -31,6 +54,7 @@ defmodule BDS.Media.Media do
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(media, attrs) do
|
||||
media
|
||||
|> cast(
|
||||
|
||||
@@ -7,6 +7,19 @@ defmodule BDS.Media.Translation do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@foreign_key_type :string
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: String.t() | nil,
|
||||
translation_for: String.t() | nil,
|
||||
media: term(),
|
||||
project_id: String.t() | nil,
|
||||
language: String.t() | nil,
|
||||
title: String.t() | nil,
|
||||
alt: String.t() | nil,
|
||||
caption: String.t() | nil,
|
||||
created_at: integer() | nil,
|
||||
updated_at: integer() | nil
|
||||
}
|
||||
|
||||
schema "media_translations" do
|
||||
belongs_to :media, BDS.Media.Media,
|
||||
foreign_key: :translation_for,
|
||||
@@ -22,6 +35,7 @@ defmodule BDS.Media.Translation do
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(translation, attrs) do
|
||||
translation
|
||||
|> cast(
|
||||
|
||||
@@ -36,16 +36,26 @@ defmodule BDS.Metadata do
|
||||
"zinc"
|
||||
])
|
||||
|
||||
@typedoc "Project metadata state map."
|
||||
@type metadata_state :: map()
|
||||
|
||||
@typedoc "Attribute map for `update_project_metadata/2`."
|
||||
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
|
||||
|
||||
@spec get_project_metadata(String.t()) :: {:ok, metadata_state()}
|
||||
def get_project_metadata(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
{:ok, load_state(project)}
|
||||
end
|
||||
|
||||
@spec read_project_metadata_from_filesystem(String.t()) :: {:ok, metadata_state()}
|
||||
def read_project_metadata_from_filesystem(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
{:ok, load_state_from_filesystem(project)}
|
||||
end
|
||||
|
||||
@spec update_project_metadata(String.t(), attrs()) ::
|
||||
{:ok, metadata_state()} | {:error, term()}
|
||||
def update_project_metadata(project_id, attrs) do
|
||||
project = Projects.get_project!(project_id)
|
||||
state = load_state(project)
|
||||
@@ -85,6 +95,7 @@ defmodule BDS.Metadata do
|
||||
|> maybe_backfill_embeddings(project_id, state, project_metadata)
|
||||
end
|
||||
|
||||
@spec add_category(String.t(), String.t()) :: {:ok, metadata_state()} | {:error, term()}
|
||||
def add_category(project_id, name) do
|
||||
update_state(project_id, fn project, state, now ->
|
||||
categories =
|
||||
@@ -100,6 +111,7 @@ defmodule BDS.Metadata do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec remove_category(String.t(), String.t()) :: {:ok, metadata_state()} | {:error, term()}
|
||||
def remove_category(project_id, name) do
|
||||
update_state(project_id, fn project, state, now ->
|
||||
categories = Enum.reject(state.categories, &(&1 == name))
|
||||
@@ -113,6 +125,8 @@ defmodule BDS.Metadata do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec update_category_settings(String.t(), String.t(), map()) ::
|
||||
{:ok, metadata_state()} | {:error, term()}
|
||||
def update_category_settings(project_id, category, settings) do
|
||||
update_state(project_id, fn project, state, now ->
|
||||
normalized = normalize_category_settings(settings)
|
||||
@@ -124,6 +138,8 @@ defmodule BDS.Metadata do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec set_publishing_preferences(String.t(), map()) ::
|
||||
{:ok, metadata_state()} | {:error, term()}
|
||||
def set_publishing_preferences(project_id, prefs) do
|
||||
update_state(project_id, fn project, state, now ->
|
||||
publishing_preferences = normalize_publishing_preferences(prefs)
|
||||
@@ -133,6 +149,8 @@ defmodule BDS.Metadata do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec sync_project_metadata_from_filesystem(String.t()) ::
|
||||
{:ok, metadata_state()} | {:error, term()}
|
||||
def sync_project_metadata_from_filesystem(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
now = Persistence.now_ms()
|
||||
@@ -167,6 +185,7 @@ defmodule BDS.Metadata do
|
||||
|> unwrap_transaction()
|
||||
end
|
||||
|
||||
@spec flush_project_metadata_to_filesystem(String.t()) :: {:ok, metadata_state()}
|
||||
def flush_project_metadata_to_filesystem(project_id) do
|
||||
project = Projects.get_project!(project_id)
|
||||
state = load_state(project)
|
||||
|
||||
@@ -21,6 +21,35 @@ defmodule BDS.Posts do
|
||||
alias BDS.Slug
|
||||
alias BDS.Tasks
|
||||
|
||||
@typedoc "An attribute map that may use atom or string keys."
|
||||
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
|
||||
|
||||
@typedoc "Options accepted by long-running rebuild operations."
|
||||
@type rebuild_opts :: keyword()
|
||||
|
||||
@typedoc "Aggregate counts returned by `dashboard_stats/1`."
|
||||
@type dashboard_stats :: %{
|
||||
total_posts: non_neg_integer(),
|
||||
draft_count: non_neg_integer(),
|
||||
published_count: non_neg_integer(),
|
||||
archived_count: non_neg_integer()
|
||||
}
|
||||
|
||||
@typedoc "Per-month post count entry returned by `post_counts_by_year_month/1`."
|
||||
@type month_count :: %{year: integer(), month: integer(), count: non_neg_integer()}
|
||||
|
||||
@typedoc "Translation validation report returned by `validate_translations/2`."
|
||||
@type translation_validation_report :: %{
|
||||
checked_database_row_count: non_neg_integer(),
|
||||
checked_filesystem_file_count: non_neg_integer(),
|
||||
invalid_database_rows: [map()],
|
||||
invalid_filesystem_files: [map()],
|
||||
missing: [map()],
|
||||
orphan_files: [map()],
|
||||
do_not_translate_posts: [map()]
|
||||
}
|
||||
|
||||
@spec create_post(attrs()) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()}
|
||||
def create_post(attrs) do
|
||||
now = Persistence.now_ms()
|
||||
project_id = attr(attrs, :project_id)
|
||||
@@ -66,6 +95,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec update_post(String.t(), attrs()) ::
|
||||
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def update_post(post_id, attrs) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -107,6 +138,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec publish_post(String.t()) ::
|
||||
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def publish_post(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -149,6 +182,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec rebuild_posts_from_files(String.t(), rebuild_opts()) :: {:ok, [Post.t()]}
|
||||
def rebuild_posts_from_files(project_id, opts \\ []) do
|
||||
project = Projects.get_project!(project_id)
|
||||
on_progress = progress_callback(opts)
|
||||
@@ -200,6 +234,8 @@ defmodule BDS.Posts do
|
||||
{:ok, posts}
|
||||
end
|
||||
|
||||
@spec discard_post_changes(String.t()) ::
|
||||
{:ok, Post.t()} | {:error, :not_found}
|
||||
def discard_post_changes(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -222,6 +258,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec editor_body(Post.t() | Translation.t() | term()) :: String.t()
|
||||
def editor_body(%Post{content: content}) when is_binary(content), do: content
|
||||
|
||||
def editor_body(%Post{project_id: project_id, file_path: file_path})
|
||||
@@ -246,6 +283,7 @@ defmodule BDS.Posts do
|
||||
|
||||
def editor_body(_record), do: ""
|
||||
|
||||
@spec sync_post_from_file(String.t()) :: {:ok, Post.t()} | {:error, :not_found}
|
||||
def sync_post_from_file(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -268,6 +306,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec sync_post_translation_from_file(String.t()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found}
|
||||
def sync_post_translation_from_file(translation_id) do
|
||||
case Repo.get(Translation, translation_id) do
|
||||
nil ->
|
||||
@@ -289,6 +329,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec rewrite_published_post_translation(String.t()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found}
|
||||
def rewrite_published_post_translation(translation_id) do
|
||||
case Repo.get(Translation, translation_id) do
|
||||
nil ->
|
||||
@@ -305,6 +347,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec import_orphan_post_file(String.t(), String.t()) ::
|
||||
{:ok, Post.t()} | {:error, :not_found | :unsupported_file}
|
||||
def import_orphan_post_file(project_id, relative_path) do
|
||||
project = Projects.get_project!(project_id)
|
||||
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||
@@ -327,6 +371,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec import_orphan_post_translation_file(String.t(), String.t()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found | :unsupported_file | :conflict}
|
||||
def import_orphan_post_translation_file(project_id, relative_path) do
|
||||
project = Projects.get_project!(project_id)
|
||||
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||
@@ -359,6 +405,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_post(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
||||
def delete_post(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -376,6 +423,8 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec archive_post(String.t()) ::
|
||||
{:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def archive_post(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -402,10 +451,14 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_post!(String.t()) :: Post.t()
|
||||
def get_post!(post_id), do: Repo.get!(Post, post_id)
|
||||
|
||||
@spec get_post_translation!(String.t()) :: Translation.t()
|
||||
def get_post_translation!(translation_id), do: Repo.get!(Translation, translation_id)
|
||||
|
||||
@spec publish_post_translation(String.t(), String.t() | atom()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found | term()}
|
||||
def publish_post_translation(post_id, language) do
|
||||
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
||||
|
||||
@@ -424,6 +477,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec slug_available(String.t(), String.t(), String.t() | nil) :: boolean()
|
||||
def slug_available(project_id, slug, exclude_post_id \\ nil) do
|
||||
normalized_slug = slug |> to_string() |> String.trim()
|
||||
|
||||
@@ -441,6 +495,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec unique_slug_for_title(String.t(), String.t(), String.t() | nil) :: String.t()
|
||||
def unique_slug_for_title(project_id, title, exclude_post_id \\ nil) do
|
||||
base_slug = title |> default_slug_source() |> Slug.slugify()
|
||||
|
||||
@@ -455,6 +510,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec dashboard_stats(String.t()) :: dashboard_stats()
|
||||
def dashboard_stats(project_id) do
|
||||
Repo.all(
|
||||
from(post in Post,
|
||||
@@ -477,6 +533,7 @@ defmodule BDS.Posts do
|
||||
)
|
||||
end
|
||||
|
||||
@spec post_counts_by_year_month(String.t()) :: [month_count()]
|
||||
def post_counts_by_year_month(project_id) do
|
||||
Repo.all(
|
||||
from(post in Post,
|
||||
@@ -493,6 +550,7 @@ defmodule BDS.Posts do
|
||||
|> Enum.sort_by(fn %{year: year, month: month} -> {-year, -month} end)
|
||||
end
|
||||
|
||||
@spec rebuild_post_links(String.t(), rebuild_opts()) :: :ok
|
||||
def rebuild_post_links(project_id, opts \\ []) do
|
||||
post_ids = Repo.all(from(post in Post, where: post.project_id == ^project_id, select: post.id))
|
||||
on_progress = progress_callback(opts)
|
||||
@@ -517,6 +575,7 @@ defmodule BDS.Posts do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec list_post_translations(String.t()) :: {:ok, [Translation.t()]}
|
||||
def list_post_translations(post_id) do
|
||||
{:ok,
|
||||
Repo.all(
|
||||
@@ -526,6 +585,8 @@ defmodule BDS.Posts do
|
||||
)}
|
||||
end
|
||||
|
||||
@spec upsert_post_translation(String.t(), String.t() | atom(), attrs()) ::
|
||||
{:ok, Translation.t()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||
def upsert_post_translation(post_id, language, attrs) do
|
||||
case Repo.get(Post, post_id) do
|
||||
nil ->
|
||||
@@ -566,6 +627,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_post_translation(String.t()) :: {:ok, :deleted} | {:error, :not_found}
|
||||
def delete_post_translation(translation_id) do
|
||||
case Repo.get(Translation, translation_id) do
|
||||
nil ->
|
||||
@@ -579,6 +641,7 @@ defmodule BDS.Posts do
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_translations(String.t(), rebuild_opts()) :: {:ok, translation_validation_report()}
|
||||
def validate_translations(project_id, opts \\ []) do
|
||||
project = Projects.get_project!(project_id)
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
@@ -657,6 +720,13 @@ defmodule BDS.Posts do
|
||||
}}
|
||||
end
|
||||
|
||||
@spec fix_invalid_translations(map()) ::
|
||||
{:ok,
|
||||
%{
|
||||
deleted_database_rows: non_neg_integer(),
|
||||
deleted_files: non_neg_integer(),
|
||||
flushed_translations: non_neg_integer()
|
||||
}}
|
||||
def fix_invalid_translations(report) when is_map(report) do
|
||||
normalized_report = normalize_translation_validation_report(report)
|
||||
|
||||
@@ -693,6 +763,7 @@ defmodule BDS.Posts do
|
||||
}}
|
||||
end
|
||||
|
||||
@spec rewrite_published_post(String.t()) :: :ok
|
||||
def rewrite_published_post(post_id) do
|
||||
post = Repo.get!(Post, post_id)
|
||||
|
||||
@@ -1256,7 +1327,6 @@ defmodule BDS.Posts do
|
||||
%{post_id: post.id, translation_id: saved_translation.id, language: language}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
other -> {:error, other}
|
||||
end
|
||||
end,
|
||||
auto_translation_task_attrs(post)
|
||||
@@ -1294,7 +1364,6 @@ defmodule BDS.Posts do
|
||||
%{media_id: media_id, translation_id: saved_translation.id, language: language}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
other -> {:error, other}
|
||||
end
|
||||
end,
|
||||
auto_translation_task_attrs(post)
|
||||
|
||||
@@ -7,6 +7,16 @@ defmodule BDS.Posts.Link do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@foreign_key_type :string
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: String.t() | nil,
|
||||
source_post_id: String.t() | nil,
|
||||
target_post_id: String.t() | nil,
|
||||
source_post: term(),
|
||||
target_post: term(),
|
||||
link_text: String.t() | nil,
|
||||
created_at: integer() | nil
|
||||
}
|
||||
|
||||
schema "post_links" do
|
||||
belongs_to :source_post, BDS.Posts.Post, foreign_key: :source_post_id, references: :id, type: :string
|
||||
belongs_to :target_post, BDS.Posts.Post, foreign_key: :target_post_id, references: :id, type: :string
|
||||
@@ -15,6 +25,7 @@ defmodule BDS.Posts.Link do
|
||||
field :created_at, :integer
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(link, attrs) do
|
||||
link
|
||||
|> cast(attrs, [:id, :source_post_id, :target_post_id, :link_text, :created_at])
|
||||
|
||||
@@ -10,6 +10,35 @@ defmodule BDS.Posts.Post do
|
||||
@foreign_key_type :string
|
||||
@statuses [:draft, :published, :archived]
|
||||
|
||||
@type status :: :draft | :published | :archived
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: String.t() | nil,
|
||||
project_id: String.t() | nil,
|
||||
project: term(),
|
||||
title: String.t() | nil,
|
||||
slug: String.t() | nil,
|
||||
excerpt: String.t() | nil,
|
||||
content: String.t() | nil,
|
||||
status: status(),
|
||||
author: String.t() | nil,
|
||||
created_at: integer() | nil,
|
||||
updated_at: integer() | nil,
|
||||
published_at: integer() | nil,
|
||||
file_path: String.t(),
|
||||
checksum: String.t() | nil,
|
||||
tags: [String.t()],
|
||||
categories: [String.t()],
|
||||
template_slug: String.t() | nil,
|
||||
language: String.t() | nil,
|
||||
do_not_translate: boolean(),
|
||||
published_title: String.t() | nil,
|
||||
published_content: String.t() | nil,
|
||||
published_tags: String.t() | nil,
|
||||
published_categories: String.t() | nil,
|
||||
published_excerpt: String.t() | nil
|
||||
}
|
||||
|
||||
schema "posts" do
|
||||
belongs_to :project, BDS.Projects.Project, type: :string
|
||||
|
||||
@@ -36,6 +65,7 @@ defmodule BDS.Posts.Post do
|
||||
field :published_excerpt, :string
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(post, attrs) do
|
||||
post
|
||||
|> cast(
|
||||
|
||||
@@ -8,6 +8,25 @@ defmodule BDS.Posts.Translation do
|
||||
@foreign_key_type :string
|
||||
@statuses [:draft, :published]
|
||||
|
||||
@type status :: :draft | :published
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: String.t() | nil,
|
||||
translation_for: String.t() | nil,
|
||||
post: term(),
|
||||
project_id: String.t() | nil,
|
||||
language: String.t() | nil,
|
||||
title: String.t() | nil,
|
||||
excerpt: String.t() | nil,
|
||||
content: String.t() | nil,
|
||||
status: status(),
|
||||
created_at: integer() | nil,
|
||||
updated_at: integer() | nil,
|
||||
published_at: integer() | nil,
|
||||
file_path: String.t(),
|
||||
checksum: String.t() | nil
|
||||
}
|
||||
|
||||
schema "post_translations" do
|
||||
belongs_to :post, BDS.Posts.Post,
|
||||
foreign_key: :translation_for,
|
||||
@@ -27,6 +46,7 @@ defmodule BDS.Posts.Translation do
|
||||
field :checksum, :string
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(translation, attrs) do
|
||||
translation
|
||||
|> cast(
|
||||
|
||||
@@ -13,14 +13,35 @@ defmodule BDS.Projects do
|
||||
@default_project_id "default"
|
||||
@default_project_name "My Blog"
|
||||
|
||||
@typedoc "An attribute map that may use atom or string keys."
|
||||
@type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
|
||||
|
||||
@typedoc "Summary map returned for the shell projects panel."
|
||||
@type project_summary :: %{
|
||||
id: String.t(),
|
||||
name: String.t(),
|
||||
slug: String.t(),
|
||||
data_path: String.t() | nil,
|
||||
is_active: boolean()
|
||||
}
|
||||
|
||||
@typedoc "Snapshot returned to the desktop shell."
|
||||
@type shell_snapshot :: %{
|
||||
active_project_id: String.t() | nil,
|
||||
projects: [project_summary()]
|
||||
}
|
||||
|
||||
@spec list_projects() :: [Project.t()]
|
||||
def list_projects do
|
||||
Repo.all(from project in Project, order_by: [asc: project.created_at])
|
||||
end
|
||||
|
||||
@spec get_active_project() :: Project.t() | nil
|
||||
def get_active_project do
|
||||
Repo.one(from project in Project, where: project.is_active == true, limit: 1)
|
||||
end
|
||||
|
||||
@spec shell_snapshot() :: shell_snapshot()
|
||||
def shell_snapshot do
|
||||
_ = ensure_default_project()
|
||||
projects = list_projects()
|
||||
@@ -32,9 +53,13 @@ defmodule BDS.Projects do
|
||||
}
|
||||
end
|
||||
|
||||
@spec get_project(String.t()) :: Project.t() | nil
|
||||
def get_project(id), do: Repo.get(Project, id)
|
||||
|
||||
@spec get_project!(String.t()) :: Project.t()
|
||||
def get_project!(id), do: Repo.get!(Project, id)
|
||||
|
||||
@spec ensure_default_project() :: {:ok, Project.t()} | {:error, term()}
|
||||
def ensure_default_project do
|
||||
case Repo.get(Project, @default_project_id) do
|
||||
%Project{} = project ->
|
||||
@@ -69,16 +94,19 @@ defmodule BDS.Projects do
|
||||
end
|
||||
end
|
||||
|
||||
@spec project_data_dir(Project.t()) :: String.t()
|
||||
def project_data_dir(%Project{} = project) do
|
||||
project.data_path || Path.expand("../../priv/data/projects/#{project.id}", __DIR__)
|
||||
end
|
||||
|
||||
@spec project_cache_dir(Project.t() | String.t()) :: String.t()
|
||||
def project_cache_dir(%Project{} = project), do: project_cache_dir(project.id)
|
||||
|
||||
def project_cache_dir(project_id) when is_binary(project_id) do
|
||||
Path.join([project_cache_root(), "projects", project_id])
|
||||
end
|
||||
|
||||
@spec create_project(attrs()) :: {:ok, Project.t()} | {:error, term()}
|
||||
def create_project(attrs) do
|
||||
now = Persistence.now_ms()
|
||||
name = attr(attrs, :name) || ""
|
||||
@@ -108,6 +136,7 @@ defmodule BDS.Projects do
|
||||
end
|
||||
end
|
||||
|
||||
@spec set_active_project(String.t()) :: {:ok, Project.t()} | {:error, :not_found | term()}
|
||||
def set_active_project(project_id) do
|
||||
case Repo.get(Project, project_id) do
|
||||
nil ->
|
||||
@@ -133,6 +162,9 @@ defmodule BDS.Projects do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_project(String.t()) ::
|
||||
{:ok, Project.t()}
|
||||
| {:error, :not_found | :cannot_delete_default_project | :cannot_delete_active_project | term()}
|
||||
def delete_project(project_id) when is_binary(project_id) do
|
||||
case Repo.get(Project, project_id) do
|
||||
nil ->
|
||||
|
||||
@@ -7,6 +7,18 @@ defmodule BDS.Projects.Project do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@foreign_key_type :string
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: String.t() | nil,
|
||||
name: String.t() | nil,
|
||||
slug: String.t() | nil,
|
||||
description: String.t() | nil,
|
||||
data_path: String.t() | nil,
|
||||
created_at: integer() | nil,
|
||||
updated_at: integer() | nil,
|
||||
is_active: boolean(),
|
||||
posts: term()
|
||||
}
|
||||
|
||||
schema "projects" do
|
||||
field :name, :string
|
||||
field :slug, :string
|
||||
@@ -19,6 +31,7 @@ defmodule BDS.Projects.Project do
|
||||
has_many :posts, BDS.Posts.Post, foreign_key: :project_id
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(project, attrs) do
|
||||
project
|
||||
|> cast(
|
||||
|
||||
@@ -9,10 +9,15 @@ defmodule BDS.Publishing do
|
||||
alias BDS.Repo
|
||||
alias BDS.Tasks
|
||||
|
||||
@typedoc "Credentials map for an upload destination."
|
||||
@type credentials :: map()
|
||||
|
||||
@spec start_link(term()) :: GenServer.on_start()
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||
end
|
||||
|
||||
@spec upload_site(String.t(), credentials(), keyword()) :: {:ok, String.t()} | {:error, term()}
|
||||
def upload_site(project_id, credentials, opts \\ [])
|
||||
when is_binary(project_id) and is_map(credentials) and is_list(opts) do
|
||||
project = Projects.get_project!(project_id)
|
||||
@@ -21,6 +26,7 @@ defmodule BDS.Publishing do
|
||||
GenServer.call(__MODULE__, {:upload_site, project_id, normalized_credentials, targets, opts})
|
||||
end
|
||||
|
||||
@spec get_job(String.t()) :: PublishJob.t() | nil
|
||||
def get_job(job_id) when is_binary(job_id) do
|
||||
GenServer.call(__MODULE__, {:get_job, job_id})
|
||||
end
|
||||
|
||||
@@ -7,6 +7,24 @@ defmodule BDS.Publishing.PublishJob do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@foreign_key_type :string
|
||||
|
||||
@type ssh_mode :: :scp | :rsync
|
||||
@type status :: :pending | :running | :completed | :failed
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: String.t() | nil,
|
||||
project_id: String.t() | nil,
|
||||
ssh_host: String.t() | nil,
|
||||
ssh_user: String.t() | nil,
|
||||
ssh_remote_path: String.t() | nil,
|
||||
ssh_mode: ssh_mode(),
|
||||
status: status(),
|
||||
task_id: String.t() | nil,
|
||||
targets: [String.t()],
|
||||
error: String.t() | nil,
|
||||
inserted_at: integer() | nil,
|
||||
updated_at: integer() | nil
|
||||
}
|
||||
|
||||
schema "publish_jobs" do
|
||||
field :project_id, :string
|
||||
field :ssh_host, :string
|
||||
@@ -21,6 +39,7 @@ defmodule BDS.Publishing.PublishJob do
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||
def changeset(job, attrs) do
|
||||
job
|
||||
|> cast(
|
||||
|
||||
@@ -62,10 +62,18 @@ defmodule BDS.Search do
|
||||
{"it", ~w(il lo la gli le un una uno e che per con ogni mattina)}
|
||||
]
|
||||
|
||||
@typedoc "Filters and pagination accepted by the search functions."
|
||||
@type search_filters :: %{optional(atom()) => term(), optional(String.t()) => term()}
|
||||
|
||||
@typedoc "Reindex/long-running progress options."
|
||||
@type reindex_opts :: keyword()
|
||||
|
||||
@spec list_stemmer_languages() :: [String.t()]
|
||||
def list_stemmer_languages do
|
||||
@stemmer_languages
|
||||
end
|
||||
|
||||
@spec detect_language(String.t() | nil) :: String.t()
|
||||
def detect_language(text) do
|
||||
normalized_text = text |> to_string() |> String.downcase()
|
||||
|
||||
@@ -78,6 +86,7 @@ defmodule BDS.Search do
|
||||
end
|
||||
end
|
||||
|
||||
@spec stem(String.t() | nil, String.t() | nil) :: String.t()
|
||||
def stem(text, language \\ nil) do
|
||||
language = normalize_language(language || detect_language(text))
|
||||
|
||||
@@ -86,6 +95,14 @@ defmodule BDS.Search do
|
||||
|> Enum.map_join(" ", &stem_token(&1, language))
|
||||
end
|
||||
|
||||
@spec search_posts(String.t(), String.t() | nil, search_filters()) ::
|
||||
{:ok,
|
||||
%{
|
||||
posts: [Post.t()],
|
||||
total: non_neg_integer(),
|
||||
offset: non_neg_integer(),
|
||||
limit: non_neg_integer()
|
||||
}}
|
||||
def search_posts(project_id, query, filters \\ %{}) do
|
||||
filters = normalize_filters(filters)
|
||||
|
||||
@@ -104,6 +121,14 @@ defmodule BDS.Search do
|
||||
}}
|
||||
end
|
||||
|
||||
@spec search_media(String.t(), String.t() | nil, search_filters()) ::
|
||||
{:ok,
|
||||
%{
|
||||
media: [Media.t()],
|
||||
total: non_neg_integer(),
|
||||
offset: non_neg_integer(),
|
||||
limit: non_neg_integer()
|
||||
}}
|
||||
def search_media(project_id, query, filters \\ %{}) do
|
||||
filters = normalize_filters(filters)
|
||||
|
||||
@@ -121,6 +146,7 @@ defmodule BDS.Search do
|
||||
}}
|
||||
end
|
||||
|
||||
@spec reindex_project(String.t()) :: :ok
|
||||
def reindex_project(project_id) do
|
||||
:ok = reindex_posts(project_id)
|
||||
:ok = reindex_media(project_id)
|
||||
@@ -128,6 +154,7 @@ defmodule BDS.Search do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec reindex_posts(String.t(), reindex_opts()) :: :ok
|
||||
def reindex_posts(project_id, opts \\ []) do
|
||||
Repo.query!(
|
||||
"DELETE FROM posts_fts WHERE post_id IN (SELECT id FROM posts WHERE project_id = ?)",
|
||||
@@ -150,6 +177,7 @@ defmodule BDS.Search do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec reindex_media(String.t(), reindex_opts()) :: :ok
|
||||
def reindex_media(project_id, opts \\ []) do
|
||||
Repo.query!(
|
||||
"DELETE FROM media_fts WHERE media_id IN (SELECT id FROM media WHERE project_id = ?)",
|
||||
@@ -172,6 +200,7 @@ defmodule BDS.Search do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec sync_post(Post.t() | String.t()) :: :ok
|
||||
def sync_post(%Post{} = post) do
|
||||
delete_post(post.id)
|
||||
insert_post_index(post)
|
||||
@@ -185,6 +214,7 @@ defmodule BDS.Search do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_post(Post.t() | String.t()) :: :ok
|
||||
def delete_post(%Post{id: post_id}), do: delete_post(post_id)
|
||||
|
||||
def delete_post(post_id) when is_binary(post_id) do
|
||||
@@ -192,6 +222,7 @@ defmodule BDS.Search do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec sync_media(Media.t() | String.t()) :: :ok
|
||||
def sync_media(%Media{} = media) do
|
||||
delete_media(media.id)
|
||||
insert_media_index(media)
|
||||
@@ -205,6 +236,7 @@ defmodule BDS.Search do
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_media(Media.t() | String.t()) :: :ok
|
||||
def delete_media(%Media{id: media_id}), do: delete_media(media_id)
|
||||
|
||||
def delete_media(media_id) when is_binary(media_id) do
|
||||
|
||||
Reference in New Issue
Block a user