chore: next big god module down
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
14
CODESMELL.md
14
CODESMELL.md
@@ -477,6 +477,20 @@ Total: 2245 lines now live in focused submodules; the remaining 647 in `BDS.Gene
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
134
lib/bds/scripting/capabilities/app_shell.ex
Normal file
134
lib/bds/scripting/capabilities/app_shell.ex
Normal 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
|
||||
176
lib/bds/scripting/capabilities/bridges.ex
Normal file
176
lib/bds/scripting/capabilities/bridges.ex
Normal 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
|
||||
284
lib/bds/scripting/capabilities/crud.ex
Normal file
284
lib/bds/scripting/capabilities/crud.ex
Normal 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
|
||||
254
lib/bds/scripting/capabilities/media.ex
Normal file
254
lib/bds/scripting/capabilities/media.ex
Normal 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
|
||||
270
lib/bds/scripting/capabilities/posts.ex
Normal file
270
lib/bds/scripting/capabilities/posts.ex
Normal 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
|
||||
204
lib/bds/scripting/capabilities/projects.ex
Normal file
204
lib/bds/scripting/capabilities/projects.ex
Normal 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
|
||||
301
lib/bds/scripting/capabilities/util.ex
Normal file
301
lib/bds/scripting/capabilities/util.ex
Normal 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
|
||||
@@ -1072,7 +1072,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
parent = self()
|
||||
:ok = BDS.Tasks.clear_finished()
|
||||
|
||||
{:ok, _task} =
|
||||
{:ok, task} =
|
||||
BDS.Tasks.submit_task(
|
||||
"Metadata Diff",
|
||||
fn report ->
|
||||
@@ -1149,7 +1149,10 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
assert html =~ "35%"
|
||||
assert html =~ ~s(task-status-running)
|
||||
|
||||
worker_ref = Process.monitor(worker_pid)
|
||||
send(worker_pid, :finish)
|
||||
assert_receive {:DOWN, ^worker_ref, :process, _, _}, 1_000
|
||||
completed_task!(task.id)
|
||||
send(view.pid, :refresh_task_status)
|
||||
|
||||
html = render(view)
|
||||
|
||||
Reference in New Issue
Block a user