feat: work on completing the lua scripting capabilities

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-25 08:03:19 +02:00
parent 6d86d0ce3f
commit 67ecc5ab3d
8 changed files with 3664 additions and 26 deletions

2043
API.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,7 @@ defmodule BDS.Scripting do
def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ []) def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ [])
when is_binary(project_id) and is_binary(source) and is_binary(entrypoint) and when is_binary(project_id) and is_binary(source) and is_binary(entrypoint) and
is_list(args) and is_list(opts) do is_list(args) and is_list(opts) do
capabilities = Capabilities.for_project(project_id) capabilities = Capabilities.for_project(project_id, opts)
execute(source, entrypoint, args, Keyword.put(opts, :capabilities, capabilities)) execute(source, entrypoint, args, Keyword.put(opts, :capabilities, capabilities))
end end

View File

@@ -0,0 +1,234 @@
defmodule BDS.Scripting.ApiDocs do
@moduledoc false
@version "0.3.1"
@methods [
%{module: "app", name: "get_data_paths", description: "Return filesystem paths for the current application and project data.", params: [], returns: "table"},
%{module: "app", name: "get_default_project_path", description: "Return the current project's filesystem path.", params: [], returns: "string | nil"},
%{module: "app", name: "get_system_language", description: "Return the current UI locale.", params: [], returns: "string | nil"},
%{module: "app", name: "read_project_metadata", description: "Read project metadata from a project folder path.", params: [%{name: "folder_path", type: "string", required: true}], returns: "ProjectMetadata | nil"},
%{module: "projects", name: "create", description: "Create a project.", params: [%{name: "data", type: "table", required: true}], returns: "ProjectData | nil"},
%{module: "projects", name: "delete", description: "Delete a project by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
%{module: "projects", name: "delete_with_data", description: "Delete a project by id and remove its project directory.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
%{module: "projects", name: "get", description: "Fetch one project by id.", params: [%{name: "id", type: "string", required: true}], returns: "ProjectData | nil"},
%{module: "projects", name: "get_all", description: "Fetch all projects.", params: [], returns: "ProjectData[]"},
%{module: "projects", name: "get_active", description: "Fetch the active project.", params: [], returns: "ProjectData | nil"},
%{module: "projects", name: "set_active", description: "Set the active project by id.", params: [%{name: "id", type: "string", required: true}], returns: "ProjectData | nil"},
%{module: "projects", name: "update", description: "Update a project by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "ProjectData | nil"},
%{module: "posts", name: "create", description: "Create a post in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "PostData | nil"},
%{module: "posts", name: "update", description: "Update a post by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "PostData | nil"},
%{module: "posts", name: "delete", description: "Delete a post by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
%{module: "posts", name: "get", description: "Fetch one post by id.", params: [%{name: "id", type: "string", required: true}], returns: "PostData | nil"},
%{module: "posts", name: "get_all", description: "Fetch all posts in the current project.", params: [], returns: "PostData[]"},
%{module: "posts", name: "get_by_slug", description: "Fetch one post by slug.", params: [%{name: "slug", type: "string", required: true}], returns: "PostData | nil"},
%{module: "posts", name: "get_categories", description: "Get category names used by posts in the current project.", params: [], returns: "string[]"},
%{module: "posts", name: "get_categories_with_counts", description: "Get post categories with usage counts.", params: [], returns: "table[]"},
%{module: "posts", name: "get_tags", description: "Get tag names used by posts in the current project.", params: [], returns: "string[]"},
%{module: "posts", name: "get_tags_with_counts", description: "Get post tags with usage counts.", params: [], returns: "table[]"},
%{module: "posts", name: "get_translation", description: "Get a single translation for a post by language.", params: [%{name: "post_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"},
%{module: "posts", name: "get_translations", description: "Get all translations for a post.", params: [%{name: "post_id", type: "string", required: true}], returns: "table[]"},
%{module: "posts", name: "has_published_version", description: "Check whether a post has a published version.", params: [%{name: "post_id", type: "string", required: true}], returns: "boolean"},
%{module: "posts", name: "publish", description: "Publish a post by id.", params: [%{name: "id", type: "string", required: true}], returns: "PostData | nil"},
%{module: "posts", name: "rebuild_from_files", description: "Rebuild post records from published files.", params: [], returns: "PostData[] | nil"},
%{module: "posts", name: "reindex_text", description: "Reindex post and media search text for the current project.", params: [], returns: "boolean"},
%{module: "posts", name: "search", description: "Search posts by free-text query.", params: [%{name: "query", type: "string", required: true}], returns: "PostData[] | nil"},
%{module: "media", name: "import", description: "Import media into the current project.", params: [%{name: "data", type: "table", required: true}], returns: "MediaData | nil"},
%{module: "media", name: "update", description: "Update media metadata by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "MediaData | nil"},
%{module: "media", name: "delete", description: "Delete a media item by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
%{module: "media", name: "get", description: "Fetch one media item by id.", params: [%{name: "id", type: "string", required: true}], returns: "MediaData | nil"},
%{module: "media", name: "get_all", description: "Fetch all media in the current project.", params: [], returns: "MediaData[]"},
%{module: "scripts", name: "create", description: "Create a script in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "ScriptData | nil"},
%{module: "scripts", name: "update", description: "Update a script by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "ScriptData | nil"},
%{module: "scripts", name: "delete", description: "Delete a script by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
%{module: "scripts", name: "get", description: "Fetch one script by id.", params: [%{name: "id", type: "string", required: true}], returns: "ScriptData | nil"},
%{module: "scripts", name: "get_all", description: "Fetch all scripts in the current project.", params: [], returns: "ScriptData[]"},
%{module: "scripts", name: "publish", description: "Publish a script by id.", params: [%{name: "id", type: "string", required: true}], returns: "ScriptData | nil"},
%{module: "scripts", name: "rebuild_from_files", description: "Rebuild script records from published files.", params: [], returns: "ScriptData[] | nil"},
%{module: "templates", name: "create", description: "Create a template in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "TemplateData | nil"},
%{module: "templates", name: "update", description: "Update a template by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "TemplateData | nil"},
%{module: "templates", name: "delete", description: "Delete a template by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
%{module: "templates", name: "get", description: "Fetch one template by id.", params: [%{name: "id", type: "string", required: true}], returns: "TemplateData | nil"},
%{module: "templates", name: "get_all", description: "Fetch all templates in the current project.", params: [], returns: "TemplateData[]"},
%{module: "templates", name: "get_enabled_by_kind", description: "Fetch enabled templates filtered by kind.", params: [%{name: "kind", type: "string", required: true}], returns: "TemplateData[]"},
%{module: "templates", name: "publish", description: "Publish a template by id.", params: [%{name: "id", type: "string", required: true}], returns: "TemplateData | nil"},
%{module: "templates", name: "rebuild_from_files", description: "Rebuild template records from published files.", params: [], returns: "TemplateData[] | nil"},
%{module: "templates", name: "validate", description: "Validate Liquid template syntax.", params: [%{name: "content", type: "string", required: true}], returns: "ValidationResult | nil"},
%{module: "meta", name: "add_category", description: "Add a category to the current project.", params: [%{name: "name", type: "string", required: true}], returns: "ProjectMetadata | nil"},
%{module: "meta", name: "add_tag", description: "Add a tag record to the current project if it does not already exist.", params: [%{name: "name", type: "string", required: true}], returns: "string[]"},
%{module: "meta", name: "clear_publishing_preferences", description: "Reset publishing preferences to defaults.", params: [], returns: "table | nil"},
%{module: "meta", name: "get_categories", description: "Get project categories.", params: [], returns: "string[]"},
%{module: "meta", name: "get_project_metadata", description: "Read metadata for the current project.", params: [], returns: "ProjectMetadata"},
%{module: "meta", name: "get_publishing_preferences", description: "Get publishing preferences for the current project.", params: [], returns: "table | nil"},
%{module: "meta", name: "get_tags", description: "Get tag names for the current project.", params: [], returns: "string[]"},
%{module: "meta", name: "remove_category", description: "Remove a category from the current project.", params: [%{name: "name", type: "string", required: true}], returns: "ProjectMetadata | nil"},
%{module: "meta", name: "remove_tag", description: "Remove a tag record from the current project by name.", params: [%{name: "name", type: "string", required: true}], returns: "string[]"},
%{module: "meta", name: "set_project_metadata", description: "Replace project metadata fields for the current project.", params: [%{name: "updates", type: "table", required: true}], returns: "ProjectMetadata | nil"},
%{module: "meta", name: "set_publishing_preferences", description: "Set publishing preferences for the current project.", params: [%{name: "prefs", type: "table", required: true}], returns: "table | nil"},
%{module: "meta", name: "update_project_metadata", description: "Update metadata for the current project.", params: [%{name: "updates", type: "table", required: true}], returns: "ProjectMetadata | nil"},
%{module: "tags", name: "create", description: "Create a tag in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "TagData | nil"},
%{module: "tags", name: "update", description: "Update a tag by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "TagData | nil"},
%{module: "tags", name: "delete", description: "Delete a tag by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
%{module: "tags", name: "get", description: "Fetch one tag by id.", params: [%{name: "id", type: "string", required: true}], returns: "TagData | nil"},
%{module: "tags", name: "get_all", description: "Fetch all tags in the current project.", params: [], returns: "TagData[]"},
%{module: "tags", name: "get_by_name", description: "Fetch one tag by name.", params: [%{name: "name", type: "string", required: true}], returns: "TagData | nil"},
%{module: "tags", name: "get_posts_with_tag", description: "Get post ids using a specific tag.", params: [%{name: "tag_id", type: "string", required: true}], returns: "string[]"},
%{module: "tags", name: "get_with_counts", description: "Fetch tags with usage counts.", params: [], returns: "table[]"},
%{module: "tags", name: "merge", description: "Merge source tags into a target tag.", params: [%{name: "source_tag_ids", type: "table", required: true}, %{name: "target_tag_id", type: "string", required: true}], returns: "boolean"},
%{module: "tags", name: "rename", description: "Rename a tag by id.", params: [%{name: "id", type: "string", required: true}, %{name: "new_name", type: "string", required: true}], returns: "TagData | nil"},
%{module: "tags", name: "sync_from_posts", description: "Sync tag records from post tags.", params: [], returns: "TagData[] | nil"},
%{module: "tasks", name: "cancel", description: "Cancel a task by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
%{module: "tasks", name: "clear_completed", description: "Clear completed tasks from the in-memory task list.", params: [], returns: "boolean"},
%{module: "tasks", name: "get", description: "Fetch one task by id.", params: [%{name: "id", type: "string", required: true}], returns: "TaskData | nil"},
%{module: "tasks", name: "get_all", description: "Fetch all tasks currently tracked by the task manager.", params: [], returns: "TaskData[]"},
%{module: "tasks", name: "get_running", description: "Fetch running tasks currently tracked by the task manager.", params: [], returns: "TaskData[]"},
%{module: "tasks", name: "status_snapshot", description: "Fetch the current task status snapshot.", params: [], returns: "TaskStatus"},
%{module: "sync", name: "check_availability", description: "Return whether Git is available on the current machine.", params: [], returns: "boolean"},
%{module: "sync", name: "commit_all", description: "Commit all pending repository changes for the current project.", params: [%{name: "message", type: "string", required: true}], returns: "table | nil"},
%{module: "sync", name: "fetch", description: "Fetch remote Git refs for the current project.", params: [], returns: "table | nil"},
%{module: "sync", name: "get_history", description: "Return commit history for the current project repository.", params: [], returns: "table | nil"},
%{module: "sync", name: "get_remote_state", description: "Return remote repository state information for the current project.", params: [], returns: "table | nil"},
%{module: "sync", name: "get_repo_state", description: "Return repository state information for the current project.", params: [], returns: "table | nil"},
%{module: "sync", name: "get_status", description: "Return Git status information for the current project.", params: [], returns: "table | nil"},
%{module: "sync", name: "pull", description: "Pull remote changes for the current project.", params: [], returns: "table | nil"},
%{module: "sync", name: "push", description: "Push local changes for the current project.", params: [], returns: "table | nil"},
%{module: "publish", name: "upload_site", description: "Upload the rendered site using the provided publishing credentials.", params: [%{name: "credentials", type: "table", required: true}], returns: "TaskData | nil"},
%{module: "chat", name: "analyze_media_image", description: "Analyze a media image using the configured AI runtime.", params: [%{name: "media_id", type: "string", required: true}], returns: "table | nil"},
%{module: "chat", name: "analyze_post", description: "Analyze a post using the configured AI runtime.", params: [%{name: "post_id", type: "string", required: true}], returns: "table | nil"},
%{module: "chat", name: "detect_media_language", description: "Detect the language of media metadata.", params: [%{name: "title", type: "string", required: true}, %{name: "alt", type: "string", required: false}, %{name: "caption", type: "string", required: false}], returns: "table"},
%{module: "chat", name: "detect_post_language", description: "Detect the language of post title and content.", params: [%{name: "title", type: "string", required: true}, %{name: "content", type: "string", required: true}], returns: "table"},
%{module: "chat", name: "translate_media_metadata", description: "Translate media metadata and persist the translation.", params: [%{name: "media_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"},
%{module: "chat", name: "translate_post", description: "Translate a post and persist the translation.", params: [%{name: "post_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"},
%{module: "embeddings", name: "compute_similarities", description: "Compute similarity scores from one source post to target posts.", params: [%{name: "post_id", type: "string", required: true}, %{name: "target_ids", type: "table", required: true}], returns: "table | nil"},
%{module: "embeddings", name: "dismiss_pair", description: "Dismiss a duplicate candidate pair.", params: [%{name: "post_id_a", type: "string", required: true}, %{name: "post_id_b", type: "string", required: true}], returns: "boolean"},
%{module: "embeddings", name: "find_duplicates", description: "Find duplicate post candidates for the current project.", params: [], returns: "table | nil"},
%{module: "embeddings", name: "find_similar", description: "Find posts similar to the given post id.", params: [%{name: "post_id", type: "string", required: true}, %{name: "limit", type: "integer", required: false}], returns: "table | nil"},
%{module: "embeddings", name: "get_progress", description: "Get embedding index progress for the current project.", params: [], returns: "table | nil"},
%{module: "embeddings", name: "index_unindexed_posts", description: "Index posts missing embeddings for the current project.", params: [], returns: "table | nil"},
%{module: "embeddings", name: "suggest_tags", description: "Suggest tags for a post from semantic similarity.", params: [%{name: "post_id", type: "string", required: true}, %{name: "exclude_tags", type: "table", required: false}], returns: "table | nil"}
]
@data_structures [
%{name: "ProjectData", description: "Project record stored in the application database.", fields: [%{name: "id", type: "string"}, %{name: "name", type: "string"}, %{name: "slug", type: "string"}, %{name: "description", type: "string | nil"}, %{name: "data_path", type: "string | nil"}, %{name: "is_active", type: "boolean"}, %{name: "created_at", type: "integer"}, %{name: "updated_at", type: "integer"}]},
%{name: "ProjectMetadata", description: "Current project metadata and publishing settings snapshot.", fields: [%{name: "name", type: "string"}, %{name: "description", type: "string | nil"}, %{name: "public_url", type: "string | nil"}, %{name: "main_language", type: "string | nil"}, %{name: "default_author", type: "string | nil"}, %{name: "categories", type: "string[]"}, %{name: "blog_languages", type: "string[]"}, %{name: "publishing_preferences", type: "table"}]},
%{name: "PostData", description: "Post record with link graph data added for scripting.", fields: [%{name: "id", type: "string"}, %{name: "project_id", type: "string"}, %{name: "title", type: "string"}, %{name: "slug", type: "string"}, %{name: "status", type: "string"}, %{name: "language", type: "string | nil"}, %{name: "tags", type: "string[]"}, %{name: "categories", type: "string[]"}, %{name: "backlinks", type: "table[]"}, %{name: "links_to", type: "table[]"}]},
%{name: "MediaData", description: "Media record stored for a project.", fields: [%{name: "id", type: "string"}, %{name: "project_id", type: "string"}, %{name: "original_name", type: "string"}, %{name: "mime_type", type: "string"}, %{name: "file_path", type: "string"}, %{name: "title", type: "string | nil"}, %{name: "alt", type: "string | nil"}, %{name: "caption", type: "string | nil"}, %{name: "tags", type: "string[]"}]},
%{name: "ScriptData", description: "Lua script record.", fields: [%{name: "id", type: "string"}, %{name: "project_id", type: "string"}, %{name: "slug", type: "string"}, %{name: "title", type: "string"}, %{name: "kind", type: "string"}, %{name: "entrypoint", type: "string"}, %{name: "enabled", type: "boolean"}, %{name: "status", type: "string"}]},
%{name: "TemplateData", description: "Template record for site rendering.", fields: [%{name: "id", type: "string"}, %{name: "project_id", type: "string"}, %{name: "slug", type: "string"}, %{name: "title", type: "string"}, %{name: "kind", type: "string"}, %{name: "enabled", type: "boolean"}, %{name: "status", type: "string"}]},
%{name: "TagData", description: "Tag record stored for a project.", fields: [%{name: "id", type: "string"}, %{name: "project_id", type: "string"}, %{name: "name", type: "string"}, %{name: "color", type: "string | nil"}, %{name: "post_template_slug", type: "string | nil"}]},
%{name: "TaskData", description: "Public task snapshot returned by the task manager.", fields: [%{name: "id", type: "string"}, %{name: "name", type: "string"}, %{name: "status", type: "string"}, %{name: "progress", type: "number | table | nil"}, %{name: "message", type: "string | nil"}]},
%{name: "TaskStatus", description: "Aggregate task status snapshot.", fields: [%{name: "active_count", type: "integer"}, %{name: "running_count", type: "integer"}, %{name: "pending_count", type: "integer"}, %{name: "tasks", type: "TaskData[]"}]},
%{name: "ValidationResult", description: "Template validation result.", fields: [%{name: "valid", type: "boolean"}, %{name: "errors", type: "string[]"}]}
]
def render do
[
"# API Documentation",
"",
"Contract version: #{@version}",
"",
"This reference documents the Lua runtime API available through `bds` in embedded bDS2 scripts.",
"",
"`bds` is available in project-scoped Lua scripts executed through the bDS2 scripting runtime.",
"",
"## Usage",
"",
"```lua",
"local project = bds.projects.get_active()",
"local meta = bds.meta.get_project_metadata()",
"```",
"",
"## Table of contents",
"",
table_of_contents(),
"",
render_modules(),
"",
"## Data Structures",
"",
render_data_structures()
]
|> List.flatten()
|> Enum.join("\n")
end
defp table_of_contents do
@methods
|> Enum.map(& &1.module)
|> Enum.uniq()
|> Enum.map(fn module_name -> "- [#{module_name}](##{module_name})" end)
|> Kernel.++(["- [Data Structures](#data-structures)"])
end
defp render_modules do
@methods
|> Enum.group_by(& &1.module)
|> Enum.flat_map(fn {module_name, methods} ->
[
"## #{module_name}",
"",
Enum.map(methods, &render_method/1),
""
]
end)
end
defp render_method(method) do
[
"### #{method.module}.#{method.name}",
"",
method.description,
"",
"**Parameters**",
"",
render_params(method.params),
"",
"**Response specification**",
"",
"- Return type: `#{method.returns}`",
"",
"**Example call**",
"",
"```lua",
example_call(method),
"```",
""
]
end
defp render_params([]), do: ["- None"]
defp render_params(params) do
Enum.map(params, fn param ->
required = if param.required, do: "required", else: "optional"
"- #{param.name} (#{param.type}, #{required})"
end)
end
defp example_call(method) do
args =
method.params
|> Enum.map(fn param -> example_value(param.type) end)
|> Enum.join(", ")
"local result = bds.#{method.module}.#{method.name}(#{args})"
end
defp example_value("string"), do: "\"value\""
defp example_value("table"), do: "{}"
defp example_value("integer"), do: "1"
defp example_value(_type), do: "nil"
defp render_data_structures do
Enum.flat_map(@data_structures, fn structure ->
[
"### #{structure.name}",
"",
structure.description,
"",
Enum.map(structure.fields, fn field -> "- `#{field.name}`: `#{field.type}`" end),
""
]
end)
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,18 @@ defmodule BDS.Tasks do
GenServer.call(__MODULE__, :status_snapshot) GenServer.call(__MODULE__, :status_snapshot)
end end
def list_tasks do
GenServer.call(__MODULE__, :list_tasks)
end
def list_running_tasks do
GenServer.call(__MODULE__, :list_running_tasks)
end
def clear_completed do
GenServer.call(__MODULE__, :clear_completed)
end
def cancel_task(task_id) when is_binary(task_id) do def cancel_task(task_id) when is_binary(task_id) do
GenServer.call(__MODULE__, {:cancel_task, task_id}) GenServer.call(__MODULE__, {:cancel_task, task_id})
end end
@@ -75,6 +87,23 @@ defmodule BDS.Tasks do
{:reply, build_status_snapshot(state), state} {:reply, build_status_snapshot(state), state}
end end
def handle_call(:list_tasks, _from, state) do
{:reply, all_tasks(state) |> Enum.map(&public_task/1), state}
end
def handle_call(:list_running_tasks, _from, state) do
{:reply, running_tasks(state) |> Enum.map(&public_task/1), state}
end
def handle_call(:clear_completed, _from, state) do
next_tasks =
state.tasks
|> Enum.reject(fn {_task_id, task} -> task.status == :completed end)
|> Map.new()
{:reply, :ok, %{state | tasks: next_tasks}}
end
def handle_call({:cancel_task, task_id}, _from, state) do def handle_call({:cancel_task, task_id}, _from, state) do
cond do cond do
Map.has_key?(state.running, task_id) -> Map.has_key?(state.running, task_id) ->
@@ -330,6 +359,19 @@ defmodule BDS.Tasks do
|> Enum.sort_by(&task_sort_key/1) |> Enum.sort_by(&task_sort_key/1)
end end
defp all_tasks(state) do
state.tasks
|> Map.values()
|> Enum.sort_by(&DateTime.to_unix(&1.created_at, :microsecond), :desc)
end
defp running_tasks(state) do
state.tasks
|> Map.values()
|> Enum.filter(&(&1.status == :running))
|> Enum.sort_by(&task_sort_key/1)
end
defp task_sort_key(task) do defp task_sort_key(task) do
{task_priority(task.status), task.started_at || task.created_at} {task_priority(task.status), task.started_at || task.created_at}
end end

View File

@@ -0,0 +1,14 @@
defmodule Mix.Tasks.Bds.ApiDocs do
use Mix.Task
@shortdoc "Generate API.md from the Lua scripting contract"
@impl Mix.Task
def run(_args) do
Mix.Task.run("app.start")
path = Path.expand("API.md", File.cwd!())
File.write!(path, BDS.Scripting.ApiDocs.render())
Mix.shell().info("Wrote #{path}")
end
end

View File

@@ -0,0 +1,29 @@
defmodule BDS.Scripting.ApiDocumentationTest do
use ExUnit.Case, async: true
test "API.md matches the generated Lua scripting contract" do
api_doc_path = Path.expand("../../../API.md", __DIR__)
assert File.exists?(api_doc_path)
assert File.read!(api_doc_path) == BDS.Scripting.ApiDocs.render()
end
test "documented Lua methods match the live capability map" do
documented_methods =
BDS.Scripting.ApiDocs.render()
|> String.split("\n")
|> Enum.filter(&String.starts_with?(&1, "### "))
|> Enum.map(&String.replace_prefix(&1, "### ", ""))
|> Enum.filter(&String.contains?(&1, "."))
|> MapSet.new()
live_methods =
BDS.Scripting.Capabilities.for_project("project-id")
|> Enum.flat_map(fn {module_name, functions} ->
Enum.map(Map.keys(functions), fn function_name -> "#{module_name}.#{function_name}" end)
end)
|> MapSet.new()
assert documented_methods == live_methods
end
end

View File

@@ -1,8 +1,34 @@
defmodule BDS.Scripting.ApiTest do defmodule BDS.Scripting.ApiTest do
use ExUnit.Case, async: false use ExUnit.Case, async: false
alias BDS.Repo
alias BDS.Scripts.Script
alias BDS.Templates.Template
defmodule StubRuntime do
def generate(_endpoint, request, _opts) do
message_content = get_in(request, [:messages, Access.at(1), "content"])
content =
if is_binary(message_content) and String.contains?(message_content, "Detect the language") do
Jason.encode!(%{"language_code" => "de"})
else
Jason.encode!(%{"title" => "Stub", "alt" => "Stub alt", "caption" => "Stub caption"})
end
{:ok,
%{
content: content,
json: Jason.decode!(content),
tool_calls: [],
usage: %{input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_write_tokens: 0}
}}
end
end
setup do setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
temp_dir = temp_dir =
Path.join(System.tmp_dir!(), "bds-scripting-api-#{System.unique_integer([:positive])}") Path.join(System.tmp_dir!(), "bds-scripting-api-#{System.unique_integer([:positive])}")
@@ -67,4 +93,285 @@ defmodule BDS.Scripting.ApiTest do
assert {:ok, ""} = BDS.Scripting.execute_macro(project.id, bad_source, []) assert {:ok, ""} = BDS.Scripting.execute_macro(project.id, bad_source, [])
end end
test "project scripting exposes project, post, script, template, metadata, and task namespaces", %{
project: project
} do
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Lua Coverage",
content: "Body",
language: "en"
})
assert {:ok, published_post} = BDS.Posts.publish_post(post.id)
assert {:ok, _created_script} =
BDS.Scripts.create_script(%{
project_id: project.id,
title: "Existing Utility",
kind: :utility,
content: "function main() return true end"
})
assert {:ok, _created_template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Existing Template",
kind: :post,
content: "<article>{{ post.title }}</article>"
})
source =
[
"function main()",
" local active = bds.projects.get_active()",
" local projects = bds.projects.get_all()",
" local created = bds.scripts.create({ title = 'Lua Utility', kind = 'utility', content = 'function main() return 42 end' })",
" local templates = bds.templates.get_all()",
" local created_template = bds.templates.create({ title = 'Lua Template', kind = 'partial', content = '<p>Lua</p>' })",
" local updated_meta = bds.meta.update_project_metadata({ description = 'Updated from Lua' })",
" local task_status = bds.tasks.status_snapshot()",
" local fetched = bds.posts.get_by_slug('lua-coverage')",
" return {",
" active_project = active.name,",
" project_count = #projects,",
" created_script_title = created.title,",
" existing_script_title = bds.scripts.get(created.id).title,",
" existing_template_title = templates[1].title,",
" created_template_kind = created_template.kind,",
" meta_description = updated_meta.description,",
" task_active_count = task_status.active_count,",
" published_post_slug = fetched.slug,",
" published_post_status = bds.posts.get(fetched.id).status",
" }",
"end"
]
|> Enum.join("\n")
assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main")
assert %{
"active_project" => "Scripting API",
"created_script_title" => "Lua Utility",
"existing_script_title" => "Lua Utility",
"existing_template_title" => "Existing Template",
"created_template_kind" => "partial",
"meta_description" => "Updated from Lua",
"task_active_count" => 0,
"published_post_status" => "published"
} = result
assert result["project_count"] >= 1
assert result["published_post_slug"] == published_post.slug
assert %Script{title: "Lua Utility"} =
Repo.get_by(Script, project_id: project.id, title: "Lua Utility")
assert %Template{title: "Lua Template"} =
Repo.get_by(Template, project_id: project.id, title: "Lua Template")
end
test "project scripting exposes remaining old-app compatibility namespaces", %{project: project} do
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(:airplane, %{url: "http://stub.local", model: "stub-model"})
assert :ok = BDS.AI.set_airplane_mode(true)
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Embedding Source",
content: "Guten Tag aus Berlin",
language: "de"
})
source =
[
"function main()",
" local app = bds.app.get_system_language()",
" local data_paths = bds.app.get_data_paths()",
" local folder_meta = bds.app.read_project_metadata(data_paths.project)",
" local sync_available = bds.sync.check_availability()",
" local repo_state = bds.sync.get_repo_state()",
" local publish_job = bds.publish.upload_site({ ssh_host = 'example.test', ssh_user = 'deploy', ssh_remote_path = '/srv/www', ssh_mode = 'scp' })",
" local detect = bds.chat.detect_post_language('Hallo Welt', 'Guten Tag aus Berlin')",
" local progress = bds.embeddings.get_progress()",
" local similar = bds.embeddings.find_similar('" <> post.id <> "', 5)",
" return {",
" system_language = app,",
" project_path = data_paths.project,",
" folder_name = folder_meta.name,",
" sync_available = sync_available,",
" repo_initialized = repo_state.is_initialized,",
" publish_job_id = publish_job.id,",
" detected_language = detect.language,",
" progress_total = progress.total,",
" similar_count = #similar",
" }",
"end"
]
|> Enum.join("\n")
assert {:ok, result} =
BDS.Scripting.execute_project_script(project.id, source, "main", [],
ai_runtime: StubRuntime,
publishing_uploader: fn _target, _files, _credentials -> :ok end
)
assert is_binary(result["system_language"])
assert result["project_path"] == project.data_path
assert result["folder_name"] == "Scripting API"
assert is_boolean(result["sync_available"])
assert result["repo_initialized"] == false
assert is_binary(result["publish_job_id"])
assert result["detected_language"] == "de"
assert result["progress_total"] >= 1
assert result["similar_count"] == 0
end
test "project scripting exposes project metadata, rebuild, and task listing helpers", %{
project: project
} do
assert {:ok, script} =
BDS.Scripts.create_script(%{
project_id: project.id,
title: "Published Utility",
kind: :utility,
content: "function main() return 1 end"
})
assert {:ok, _published_script} = BDS.Scripts.publish_script(script.id)
assert {:ok, template} =
BDS.Templates.create_template(%{
project_id: project.id,
title: "Published Post Template",
kind: :post,
content: "<article>{{ post.title }}</article>"
})
assert {:ok, _published_template} = BDS.Templates.publish_template(template.id)
assert {:ok, running_task} =
BDS.Tasks.register_external_task("preview build", %{
group_id: "generation",
group_name: "Generation"
})
on_exit(fn ->
_ = BDS.Tasks.complete_task(running_task.id)
end)
source =
[
"function main()",
" local updated = bds.projects.update('" <> project.id <> "', { description = 'Updated through Lua' })",
" bds.meta.set_publishing_preferences({ ssh_host = 'example.test', ssh_user = 'deploy', ssh_remote_path = '/srv/www', ssh_mode = 'scp' })",
" local prefs = bds.meta.get_publishing_preferences()",
" local categories = bds.meta.get_categories()",
" local scripts = bds.scripts.rebuild_from_files()",
" local templates = bds.templates.rebuild_from_files()",
" local enabled_post_templates = bds.templates.get_enabled_by_kind('post')",
" local tasks = bds.tasks.get_all()",
" local running = bds.tasks.get_running()",
" bds.meta.clear_publishing_preferences()",
" local cleared = bds.meta.get_publishing_preferences()",
" return {",
" project_description = updated.description,",
" ssh_mode = prefs.ssh_mode,",
" category_count = #categories,",
" rebuilt_script_count = #scripts,",
" rebuilt_template_count = #templates,",
" enabled_post_template_count = #enabled_post_templates,",
" task_count = #tasks,",
" running_count = #running,",
" cleared_ssh_mode = cleared.ssh_mode",
" }",
"end"
]
|> Enum.join("\n")
assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main")
assert result["project_description"] == "Updated through Lua"
assert result["ssh_mode"] == "scp"
assert result["category_count"] >= 1
assert result["rebuilt_script_count"] >= 1
assert result["rebuilt_template_count"] >= 1
assert result["enabled_post_template_count"] >= 1
assert result["task_count"] >= 1
assert result["running_count"] >= 1
assert result["cleared_ssh_mode"] == "scp"
end
test "project scripting exposes post, tag, and translation lookup helpers", %{project: project} do
assert {:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Lookup Post",
excerpt: "Search me",
content: "Elixir lookup body with tag coverage",
language: "en",
tags: ["elixir", "lua"],
categories: ["article", "guide"]
})
assert {:ok, _published_post} = BDS.Posts.publish_post(post.id)
assert {:ok, _translation} =
BDS.Posts.upsert_post_translation(post.id, "de", %{
title: "Nachschlagebeitrag",
excerpt: "Suche mich",
content: "Deutscher Inhalt"
})
assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id)
source =
[
"function main()",
" local meta_tags = bds.meta.get_tags()",
" local with_counts = bds.tags.get_with_counts()",
" local tag_posts = bds.tags.get_posts_with_tag(bds.tags.get_by_name('elixir').id)",
" local post_tags = bds.posts.get_tags()",
" local post_tag_counts = bds.posts.get_tags_with_counts()",
" local post_categories = bds.posts.get_categories()",
" local post_category_counts = bds.posts.get_categories_with_counts()",
" local translations = bds.posts.get_translations('" <> post.id <> "')",
" local translation = bds.posts.get_translation('" <> post.id <> "', 'de')",
" local published = bds.posts.has_published_version('" <> post.id <> "')",
" local search = bds.posts.search('lookup')",
" return {",
" meta_tag_count = #meta_tags,",
" tag_with_count = with_counts[1].count,",
" tag_post_count = #tag_posts,",
" post_tag_count = #post_tags,",
" post_tag_count_rows = #post_tag_counts,",
" category_count = #post_categories,",
" category_count_rows = #post_category_counts,",
" translation_count = #translations,",
" translation_title = translation.title,",
" has_published = published,",
" search_count = #search",
" }",
"end"
]
|> Enum.join("\n")
assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main")
assert result["meta_tag_count"] >= 2
assert result["tag_with_count"] >= 1
assert result["tag_post_count"] == 1
assert result["post_tag_count"] >= 2
assert result["post_tag_count_rows"] >= 2
assert result["category_count"] >= 2
assert result["category_count_rows"] >= 2
assert result["translation_count"] == 1
assert result["translation_title"] == "Nachschlagebeitrag"
assert result["has_published"] == true
assert result["search_count"] >= 1
end
end end