diff --git a/API.md b/API.md
new file mode 100644
index 0000000..6118dc6
--- /dev/null
+++ b/API.md
@@ -0,0 +1,2043 @@
+# API Documentation
+
+Contract version: 0.3.1
+
+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
+
+- [app](#app)
+- [projects](#projects)
+- [posts](#posts)
+- [media](#media)
+- [scripts](#scripts)
+- [templates](#templates)
+- [meta](#meta)
+- [tags](#tags)
+- [tasks](#tasks)
+- [sync](#sync)
+- [publish](#publish)
+- [chat](#chat)
+- [embeddings](#embeddings)
+- [Data Structures](#data-structures)
+
+## app
+
+### app.get_data_paths
+
+Return filesystem paths for the current application and project data.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table`
+
+**Example call**
+
+```lua
+local result = bds.app.get_data_paths()
+```
+
+### app.get_default_project_path
+
+Return the current project's filesystem path.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `string | nil`
+
+**Example call**
+
+```lua
+local result = bds.app.get_default_project_path()
+```
+
+### app.get_system_language
+
+Return the current UI locale.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `string | nil`
+
+**Example call**
+
+```lua
+local result = bds.app.get_system_language()
+```
+
+### app.read_project_metadata
+
+Read project metadata from a project folder path.
+
+**Parameters**
+
+- folder_path (string, required)
+
+**Response specification**
+
+- Return type: `ProjectMetadata | nil`
+
+**Example call**
+
+```lua
+local result = bds.app.read_project_metadata("value")
+```
+
+
+## chat
+
+### chat.analyze_media_image
+
+Analyze a media image using the configured AI runtime.
+
+**Parameters**
+
+- media_id (string, required)
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.chat.analyze_media_image("value")
+```
+
+### chat.analyze_post
+
+Analyze a post using the configured AI runtime.
+
+**Parameters**
+
+- post_id (string, required)
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.chat.analyze_post("value")
+```
+
+### chat.detect_media_language
+
+Detect the language of media metadata.
+
+**Parameters**
+
+- title (string, required)
+- alt (string, optional)
+- caption (string, optional)
+
+**Response specification**
+
+- Return type: `table`
+
+**Example call**
+
+```lua
+local result = bds.chat.detect_media_language("value", "value", "value")
+```
+
+### chat.detect_post_language
+
+Detect the language of post title and content.
+
+**Parameters**
+
+- title (string, required)
+- content (string, required)
+
+**Response specification**
+
+- Return type: `table`
+
+**Example call**
+
+```lua
+local result = bds.chat.detect_post_language("value", "value")
+```
+
+### chat.translate_media_metadata
+
+Translate media metadata and persist the translation.
+
+**Parameters**
+
+- media_id (string, required)
+- language (string, required)
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.chat.translate_media_metadata("value", "value")
+```
+
+### chat.translate_post
+
+Translate a post and persist the translation.
+
+**Parameters**
+
+- post_id (string, required)
+- language (string, required)
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.chat.translate_post("value", "value")
+```
+
+
+## embeddings
+
+### embeddings.compute_similarities
+
+Compute similarity scores from one source post to target posts.
+
+**Parameters**
+
+- post_id (string, required)
+- target_ids (table, required)
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.embeddings.compute_similarities("value", {})
+```
+
+### embeddings.dismiss_pair
+
+Dismiss a duplicate candidate pair.
+
+**Parameters**
+
+- post_id_a (string, required)
+- post_id_b (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.embeddings.dismiss_pair("value", "value")
+```
+
+### embeddings.find_duplicates
+
+Find duplicate post candidates for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.embeddings.find_duplicates()
+```
+
+### embeddings.find_similar
+
+Find posts similar to the given post id.
+
+**Parameters**
+
+- post_id (string, required)
+- limit (integer, optional)
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.embeddings.find_similar("value", 1)
+```
+
+### embeddings.get_progress
+
+Get embedding index progress for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.embeddings.get_progress()
+```
+
+### embeddings.index_unindexed_posts
+
+Index posts missing embeddings for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.embeddings.index_unindexed_posts()
+```
+
+### embeddings.suggest_tags
+
+Suggest tags for a post from semantic similarity.
+
+**Parameters**
+
+- post_id (string, required)
+- exclude_tags (table, optional)
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.embeddings.suggest_tags("value", {})
+```
+
+
+## media
+
+### media.import
+
+Import media into the current project.
+
+**Parameters**
+
+- data (table, required)
+
+**Response specification**
+
+- Return type: `MediaData | nil`
+
+**Example call**
+
+```lua
+local result = bds.media.import({})
+```
+
+### media.update
+
+Update media metadata by id.
+
+**Parameters**
+
+- id (string, required)
+- data (table, required)
+
+**Response specification**
+
+- Return type: `MediaData | nil`
+
+**Example call**
+
+```lua
+local result = bds.media.update("value", {})
+```
+
+### media.delete
+
+Delete a media item by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.media.delete("value")
+```
+
+### media.get
+
+Fetch one media item by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `MediaData | nil`
+
+**Example call**
+
+```lua
+local result = bds.media.get("value")
+```
+
+### media.get_all
+
+Fetch all media in the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `MediaData[]`
+
+**Example call**
+
+```lua
+local result = bds.media.get_all()
+```
+
+
+## meta
+
+### meta.add_category
+
+Add a category to the current project.
+
+**Parameters**
+
+- name (string, required)
+
+**Response specification**
+
+- Return type: `ProjectMetadata | nil`
+
+**Example call**
+
+```lua
+local result = bds.meta.add_category("value")
+```
+
+### meta.add_tag
+
+Add a tag record to the current project if it does not already exist.
+
+**Parameters**
+
+- name (string, required)
+
+**Response specification**
+
+- Return type: `string[]`
+
+**Example call**
+
+```lua
+local result = bds.meta.add_tag("value")
+```
+
+### meta.clear_publishing_preferences
+
+Reset publishing preferences to defaults.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.meta.clear_publishing_preferences()
+```
+
+### meta.get_categories
+
+Get project categories.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `string[]`
+
+**Example call**
+
+```lua
+local result = bds.meta.get_categories()
+```
+
+### meta.get_project_metadata
+
+Read metadata for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `ProjectMetadata`
+
+**Example call**
+
+```lua
+local result = bds.meta.get_project_metadata()
+```
+
+### meta.get_publishing_preferences
+
+Get publishing preferences for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.meta.get_publishing_preferences()
+```
+
+### meta.get_tags
+
+Get tag names for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `string[]`
+
+**Example call**
+
+```lua
+local result = bds.meta.get_tags()
+```
+
+### meta.remove_category
+
+Remove a category from the current project.
+
+**Parameters**
+
+- name (string, required)
+
+**Response specification**
+
+- Return type: `ProjectMetadata | nil`
+
+**Example call**
+
+```lua
+local result = bds.meta.remove_category("value")
+```
+
+### meta.remove_tag
+
+Remove a tag record from the current project by name.
+
+**Parameters**
+
+- name (string, required)
+
+**Response specification**
+
+- Return type: `string[]`
+
+**Example call**
+
+```lua
+local result = bds.meta.remove_tag("value")
+```
+
+### meta.set_project_metadata
+
+Replace project metadata fields for the current project.
+
+**Parameters**
+
+- updates (table, required)
+
+**Response specification**
+
+- Return type: `ProjectMetadata | nil`
+
+**Example call**
+
+```lua
+local result = bds.meta.set_project_metadata({})
+```
+
+### meta.set_publishing_preferences
+
+Set publishing preferences for the current project.
+
+**Parameters**
+
+- prefs (table, required)
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.meta.set_publishing_preferences({})
+```
+
+### meta.update_project_metadata
+
+Update metadata for the current project.
+
+**Parameters**
+
+- updates (table, required)
+
+**Response specification**
+
+- Return type: `ProjectMetadata | nil`
+
+**Example call**
+
+```lua
+local result = bds.meta.update_project_metadata({})
+```
+
+
+## posts
+
+### posts.create
+
+Create a post in the current project.
+
+**Parameters**
+
+- data (table, required)
+
+**Response specification**
+
+- Return type: `PostData | nil`
+
+**Example call**
+
+```lua
+local result = bds.posts.create({})
+```
+
+### posts.update
+
+Update a post by id.
+
+**Parameters**
+
+- id (string, required)
+- data (table, required)
+
+**Response specification**
+
+- Return type: `PostData | nil`
+
+**Example call**
+
+```lua
+local result = bds.posts.update("value", {})
+```
+
+### posts.delete
+
+Delete a post by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.posts.delete("value")
+```
+
+### posts.get
+
+Fetch one post by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `PostData | nil`
+
+**Example call**
+
+```lua
+local result = bds.posts.get("value")
+```
+
+### posts.get_all
+
+Fetch all posts in the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `PostData[]`
+
+**Example call**
+
+```lua
+local result = bds.posts.get_all()
+```
+
+### posts.get_by_slug
+
+Fetch one post by slug.
+
+**Parameters**
+
+- slug (string, required)
+
+**Response specification**
+
+- Return type: `PostData | nil`
+
+**Example call**
+
+```lua
+local result = bds.posts.get_by_slug("value")
+```
+
+### posts.get_categories
+
+Get category names used by posts in the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `string[]`
+
+**Example call**
+
+```lua
+local result = bds.posts.get_categories()
+```
+
+### posts.get_categories_with_counts
+
+Get post categories with usage counts.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table[]`
+
+**Example call**
+
+```lua
+local result = bds.posts.get_categories_with_counts()
+```
+
+### posts.get_tags
+
+Get tag names used by posts in the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `string[]`
+
+**Example call**
+
+```lua
+local result = bds.posts.get_tags()
+```
+
+### posts.get_tags_with_counts
+
+Get post tags with usage counts.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table[]`
+
+**Example call**
+
+```lua
+local result = bds.posts.get_tags_with_counts()
+```
+
+### posts.get_translation
+
+Get a single translation for a post by language.
+
+**Parameters**
+
+- post_id (string, required)
+- language (string, required)
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.posts.get_translation("value", "value")
+```
+
+### posts.get_translations
+
+Get all translations for a post.
+
+**Parameters**
+
+- post_id (string, required)
+
+**Response specification**
+
+- Return type: `table[]`
+
+**Example call**
+
+```lua
+local result = bds.posts.get_translations("value")
+```
+
+### posts.has_published_version
+
+Check whether a post has a published version.
+
+**Parameters**
+
+- post_id (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.posts.has_published_version("value")
+```
+
+### posts.publish
+
+Publish a post by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `PostData | nil`
+
+**Example call**
+
+```lua
+local result = bds.posts.publish("value")
+```
+
+### posts.rebuild_from_files
+
+Rebuild post records from published files.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `PostData[] | nil`
+
+**Example call**
+
+```lua
+local result = bds.posts.rebuild_from_files()
+```
+
+### posts.reindex_text
+
+Reindex post and media search text for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.posts.reindex_text()
+```
+
+### posts.search
+
+Search posts by free-text query.
+
+**Parameters**
+
+- query (string, required)
+
+**Response specification**
+
+- Return type: `PostData[] | nil`
+
+**Example call**
+
+```lua
+local result = bds.posts.search("value")
+```
+
+
+## projects
+
+### projects.create
+
+Create a project.
+
+**Parameters**
+
+- data (table, required)
+
+**Response specification**
+
+- Return type: `ProjectData | nil`
+
+**Example call**
+
+```lua
+local result = bds.projects.create({})
+```
+
+### projects.delete
+
+Delete a project by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.projects.delete("value")
+```
+
+### projects.delete_with_data
+
+Delete a project by id and remove its project directory.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.projects.delete_with_data("value")
+```
+
+### projects.get
+
+Fetch one project by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `ProjectData | nil`
+
+**Example call**
+
+```lua
+local result = bds.projects.get("value")
+```
+
+### projects.get_all
+
+Fetch all projects.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `ProjectData[]`
+
+**Example call**
+
+```lua
+local result = bds.projects.get_all()
+```
+
+### projects.get_active
+
+Fetch the active project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `ProjectData | nil`
+
+**Example call**
+
+```lua
+local result = bds.projects.get_active()
+```
+
+### projects.set_active
+
+Set the active project by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `ProjectData | nil`
+
+**Example call**
+
+```lua
+local result = bds.projects.set_active("value")
+```
+
+### projects.update
+
+Update a project by id.
+
+**Parameters**
+
+- id (string, required)
+- data (table, required)
+
+**Response specification**
+
+- Return type: `ProjectData | nil`
+
+**Example call**
+
+```lua
+local result = bds.projects.update("value", {})
+```
+
+
+## publish
+
+### publish.upload_site
+
+Upload the rendered site using the provided publishing credentials.
+
+**Parameters**
+
+- credentials (table, required)
+
+**Response specification**
+
+- Return type: `TaskData | nil`
+
+**Example call**
+
+```lua
+local result = bds.publish.upload_site({})
+```
+
+
+## scripts
+
+### scripts.create
+
+Create a script in the current project.
+
+**Parameters**
+
+- data (table, required)
+
+**Response specification**
+
+- Return type: `ScriptData | nil`
+
+**Example call**
+
+```lua
+local result = bds.scripts.create({})
+```
+
+### scripts.update
+
+Update a script by id.
+
+**Parameters**
+
+- id (string, required)
+- data (table, required)
+
+**Response specification**
+
+- Return type: `ScriptData | nil`
+
+**Example call**
+
+```lua
+local result = bds.scripts.update("value", {})
+```
+
+### scripts.delete
+
+Delete a script by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.scripts.delete("value")
+```
+
+### scripts.get
+
+Fetch one script by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `ScriptData | nil`
+
+**Example call**
+
+```lua
+local result = bds.scripts.get("value")
+```
+
+### scripts.get_all
+
+Fetch all scripts in the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `ScriptData[]`
+
+**Example call**
+
+```lua
+local result = bds.scripts.get_all()
+```
+
+### scripts.publish
+
+Publish a script by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `ScriptData | nil`
+
+**Example call**
+
+```lua
+local result = bds.scripts.publish("value")
+```
+
+### scripts.rebuild_from_files
+
+Rebuild script records from published files.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `ScriptData[] | nil`
+
+**Example call**
+
+```lua
+local result = bds.scripts.rebuild_from_files()
+```
+
+
+## sync
+
+### sync.check_availability
+
+Return whether Git is available on the current machine.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.sync.check_availability()
+```
+
+### sync.commit_all
+
+Commit all pending repository changes for the current project.
+
+**Parameters**
+
+- message (string, required)
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.sync.commit_all("value")
+```
+
+### sync.fetch
+
+Fetch remote Git refs for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.sync.fetch()
+```
+
+### sync.get_history
+
+Return commit history for the current project repository.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.sync.get_history()
+```
+
+### sync.get_remote_state
+
+Return remote repository state information for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.sync.get_remote_state()
+```
+
+### sync.get_repo_state
+
+Return repository state information for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.sync.get_repo_state()
+```
+
+### sync.get_status
+
+Return Git status information for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.sync.get_status()
+```
+
+### sync.pull
+
+Pull remote changes for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.sync.pull()
+```
+
+### sync.push
+
+Push local changes for the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table | nil`
+
+**Example call**
+
+```lua
+local result = bds.sync.push()
+```
+
+
+## tags
+
+### tags.create
+
+Create a tag in the current project.
+
+**Parameters**
+
+- data (table, required)
+
+**Response specification**
+
+- Return type: `TagData | nil`
+
+**Example call**
+
+```lua
+local result = bds.tags.create({})
+```
+
+### tags.update
+
+Update a tag by id.
+
+**Parameters**
+
+- id (string, required)
+- data (table, required)
+
+**Response specification**
+
+- Return type: `TagData | nil`
+
+**Example call**
+
+```lua
+local result = bds.tags.update("value", {})
+```
+
+### tags.delete
+
+Delete a tag by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.tags.delete("value")
+```
+
+### tags.get
+
+Fetch one tag by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `TagData | nil`
+
+**Example call**
+
+```lua
+local result = bds.tags.get("value")
+```
+
+### tags.get_all
+
+Fetch all tags in the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `TagData[]`
+
+**Example call**
+
+```lua
+local result = bds.tags.get_all()
+```
+
+### tags.get_by_name
+
+Fetch one tag by name.
+
+**Parameters**
+
+- name (string, required)
+
+**Response specification**
+
+- Return type: `TagData | nil`
+
+**Example call**
+
+```lua
+local result = bds.tags.get_by_name("value")
+```
+
+### tags.get_posts_with_tag
+
+Get post ids using a specific tag.
+
+**Parameters**
+
+- tag_id (string, required)
+
+**Response specification**
+
+- Return type: `string[]`
+
+**Example call**
+
+```lua
+local result = bds.tags.get_posts_with_tag("value")
+```
+
+### tags.get_with_counts
+
+Fetch tags with usage counts.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `table[]`
+
+**Example call**
+
+```lua
+local result = bds.tags.get_with_counts()
+```
+
+### tags.merge
+
+Merge source tags into a target tag.
+
+**Parameters**
+
+- source_tag_ids (table, required)
+- target_tag_id (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.tags.merge({}, "value")
+```
+
+### tags.rename
+
+Rename a tag by id.
+
+**Parameters**
+
+- id (string, required)
+- new_name (string, required)
+
+**Response specification**
+
+- Return type: `TagData | nil`
+
+**Example call**
+
+```lua
+local result = bds.tags.rename("value", "value")
+```
+
+### tags.sync_from_posts
+
+Sync tag records from post tags.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `TagData[] | nil`
+
+**Example call**
+
+```lua
+local result = bds.tags.sync_from_posts()
+```
+
+
+## tasks
+
+### tasks.cancel
+
+Cancel a task by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.tasks.cancel("value")
+```
+
+### tasks.clear_completed
+
+Clear completed tasks from the in-memory task list.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.tasks.clear_completed()
+```
+
+### tasks.get
+
+Fetch one task by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `TaskData | nil`
+
+**Example call**
+
+```lua
+local result = bds.tasks.get("value")
+```
+
+### tasks.get_all
+
+Fetch all tasks currently tracked by the task manager.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `TaskData[]`
+
+**Example call**
+
+```lua
+local result = bds.tasks.get_all()
+```
+
+### tasks.get_running
+
+Fetch running tasks currently tracked by the task manager.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `TaskData[]`
+
+**Example call**
+
+```lua
+local result = bds.tasks.get_running()
+```
+
+### tasks.status_snapshot
+
+Fetch the current task status snapshot.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `TaskStatus`
+
+**Example call**
+
+```lua
+local result = bds.tasks.status_snapshot()
+```
+
+
+## templates
+
+### templates.create
+
+Create a template in the current project.
+
+**Parameters**
+
+- data (table, required)
+
+**Response specification**
+
+- Return type: `TemplateData | nil`
+
+**Example call**
+
+```lua
+local result = bds.templates.create({})
+```
+
+### templates.update
+
+Update a template by id.
+
+**Parameters**
+
+- id (string, required)
+- data (table, required)
+
+**Response specification**
+
+- Return type: `TemplateData | nil`
+
+**Example call**
+
+```lua
+local result = bds.templates.update("value", {})
+```
+
+### templates.delete
+
+Delete a template by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `boolean`
+
+**Example call**
+
+```lua
+local result = bds.templates.delete("value")
+```
+
+### templates.get
+
+Fetch one template by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `TemplateData | nil`
+
+**Example call**
+
+```lua
+local result = bds.templates.get("value")
+```
+
+### templates.get_all
+
+Fetch all templates in the current project.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `TemplateData[]`
+
+**Example call**
+
+```lua
+local result = bds.templates.get_all()
+```
+
+### templates.get_enabled_by_kind
+
+Fetch enabled templates filtered by kind.
+
+**Parameters**
+
+- kind (string, required)
+
+**Response specification**
+
+- Return type: `TemplateData[]`
+
+**Example call**
+
+```lua
+local result = bds.templates.get_enabled_by_kind("value")
+```
+
+### templates.publish
+
+Publish a template by id.
+
+**Parameters**
+
+- id (string, required)
+
+**Response specification**
+
+- Return type: `TemplateData | nil`
+
+**Example call**
+
+```lua
+local result = bds.templates.publish("value")
+```
+
+### templates.rebuild_from_files
+
+Rebuild template records from published files.
+
+**Parameters**
+
+- None
+
+**Response specification**
+
+- Return type: `TemplateData[] | nil`
+
+**Example call**
+
+```lua
+local result = bds.templates.rebuild_from_files()
+```
+
+### templates.validate
+
+Validate Liquid template syntax.
+
+**Parameters**
+
+- content (string, required)
+
+**Response specification**
+
+- Return type: `ValidationResult | nil`
+
+**Example call**
+
+```lua
+local result = bds.templates.validate("value")
+```
+
+
+
+## Data Structures
+
+### ProjectData
+
+Project record stored in the application database.
+
+- `id`: `string`
+- `name`: `string`
+- `slug`: `string`
+- `description`: `string | nil`
+- `data_path`: `string | nil`
+- `is_active`: `boolean`
+- `created_at`: `integer`
+- `updated_at`: `integer`
+
+### ProjectMetadata
+
+Current project metadata and publishing settings snapshot.
+
+- `name`: `string`
+- `description`: `string | nil`
+- `public_url`: `string | nil`
+- `main_language`: `string | nil`
+- `default_author`: `string | nil`
+- `categories`: `string[]`
+- `blog_languages`: `string[]`
+- `publishing_preferences`: `table`
+
+### PostData
+
+Post record with link graph data added for scripting.
+
+- `id`: `string`
+- `project_id`: `string`
+- `title`: `string`
+- `slug`: `string`
+- `status`: `string`
+- `language`: `string | nil`
+- `tags`: `string[]`
+- `categories`: `string[]`
+- `backlinks`: `table[]`
+- `links_to`: `table[]`
+
+### MediaData
+
+Media record stored for a project.
+
+- `id`: `string`
+- `project_id`: `string`
+- `original_name`: `string`
+- `mime_type`: `string`
+- `file_path`: `string`
+- `title`: `string | nil`
+- `alt`: `string | nil`
+- `caption`: `string | nil`
+- `tags`: `string[]`
+
+### ScriptData
+
+Lua script record.
+
+- `id`: `string`
+- `project_id`: `string`
+- `slug`: `string`
+- `title`: `string`
+- `kind`: `string`
+- `entrypoint`: `string`
+- `enabled`: `boolean`
+- `status`: `string`
+
+### TemplateData
+
+Template record for site rendering.
+
+- `id`: `string`
+- `project_id`: `string`
+- `slug`: `string`
+- `title`: `string`
+- `kind`: `string`
+- `enabled`: `boolean`
+- `status`: `string`
+
+### TagData
+
+Tag record stored for a project.
+
+- `id`: `string`
+- `project_id`: `string`
+- `name`: `string`
+- `color`: `string | nil`
+- `post_template_slug`: `string | nil`
+
+### TaskData
+
+Public task snapshot returned by the task manager.
+
+- `id`: `string`
+- `name`: `string`
+- `status`: `string`
+- `progress`: `number | table | nil`
+- `message`: `string | nil`
+
+### TaskStatus
+
+Aggregate task status snapshot.
+
+- `active_count`: `integer`
+- `running_count`: `integer`
+- `pending_count`: `integer`
+- `tasks`: `TaskData[]`
+
+### ValidationResult
+
+Template validation result.
+
+- `valid`: `boolean`
+- `errors`: `string[]`
diff --git a/lib/bds/scripting.ex b/lib/bds/scripting.ex
index f82915d..2d91401 100644
--- a/lib/bds/scripting.ex
+++ b/lib/bds/scripting.ex
@@ -41,7 +41,7 @@ defmodule BDS.Scripting do
def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ [])
when is_binary(project_id) and is_binary(source) and is_binary(entrypoint) and
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))
end
diff --git a/lib/bds/scripting/api_docs.ex b/lib/bds/scripting/api_docs.ex
new file mode 100644
index 0000000..54fa53c
--- /dev/null
+++ b/lib/bds/scripting/api_docs.ex
@@ -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
diff --git a/lib/bds/scripting/capabilities.ex b/lib/bds/scripting/capabilities.ex
index 173abac..828e56b 100644
--- a/lib/bds/scripting/capabilities.ex
+++ b/lib/bds/scripting/capabilities.ex
@@ -3,57 +3,899 @@ defmodule BDS.Scripting.Capabilities do
import Ecto.Query
+ alias BDS.AI
+ alias BDS.Embeddings
+ alias BDS.Git
+ alias BDS.I18n
+ alias BDS.Media
+ alias BDS.Media.Media, as: MediaRecord
alias BDS.Metadata
+ alias BDS.MCP
alias BDS.PostLinks
+ alias BDS.Posts
alias BDS.Posts.Post
+ alias BDS.Posts.Translation, as: PostTranslation
+ alias BDS.Publishing
+ alias BDS.Projects
+ alias BDS.Projects.Project
alias BDS.Repo
+ alias BDS.Search
+ alias BDS.Scripts
+ alias BDS.Scripts.Script
alias BDS.Tags
+ alias BDS.Tags.Tag
+ alias BDS.Tasks
+ alias BDS.Templates
+ alias BDS.Templates.Template
- def for_project(project_id) when is_binary(project_id) do
- metadata = preload_metadata(project_id)
- posts = preload_posts(project_id)
- posts_by_id = Map.new(posts, &{&1["id"], &1})
- posts_by_slug = Map.new(posts, &{&1["slug"], &1})
- tags = preload_tags(project_id)
-
+ def for_project(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
%{
+ app: %{
+ get_data_paths: zero_or_one_arg(fn _args -> data_paths(project_id) end),
+ get_system_language: zero_or_one_arg(fn _args -> I18n.current_ui_locale() end),
+ get_default_project_path: zero_or_one_arg(fn _args -> project_path(project_id) end),
+ read_project_metadata: one_arg(fn folder_path -> read_project_metadata(folder_path) end)
+ },
+ projects: %{
+ create: zero_or_one_arg(fn attrs -> create_project(attrs) end),
+ delete: one_arg(fn project_id_to_delete -> delete_project(project_id_to_delete) end),
+ delete_with_data: one_arg(fn project_id_to_delete -> delete_project_with_data(project_id_to_delete) end),
+ get: one_arg(fn project_id_to_load -> load_project(project_id_to_load) end),
+ get_all: zero_or_one_arg(fn _args -> list_projects() end),
+ get_active: zero_or_one_arg(fn _args -> load_project(project_id) end),
+ set_active: one_arg(fn project_id_to_activate -> set_active_project(project_id_to_activate) end),
+ update: two_arg(fn project_id_to_update, attrs -> update_project(project_id_to_update, attrs) end)
+ },
meta: %{
- get_project_metadata: unary(fn -> metadata end)
+ get_project_metadata: zero_or_one_arg(fn _args -> load_metadata(project_id) end),
+ update_project_metadata: one_arg(fn attrs -> update_project_metadata(project_id, attrs) end),
+ add_category: one_arg(fn name -> add_category(project_id, name) end),
+ remove_category: one_arg(fn name -> remove_category(project_id, name) end),
+ add_tag: one_arg(fn name -> add_meta_tag(project_id, name) end),
+ get_categories: zero_or_one_arg(fn _args -> metadata_categories(project_id) end),
+ set_project_metadata: one_arg(fn attrs -> update_project_metadata(project_id, attrs) end),
+ get_publishing_preferences: zero_or_one_arg(fn _args -> publishing_preferences(project_id) end),
+ get_tags: zero_or_one_arg(fn _args -> metadata_tags(project_id) end),
+ remove_tag: one_arg(fn name -> remove_meta_tag(project_id, name) end),
+ set_publishing_preferences: one_arg(fn prefs -> set_publishing_preferences(project_id, prefs) end),
+ clear_publishing_preferences: zero_or_one_arg(fn _args -> clear_publishing_preferences(project_id) end)
},
posts: %{
- get: unary(fn post_id -> Map.get(posts_by_id, post_id) end),
- get_by_slug: unary(fn slug -> Map.get(posts_by_slug, slug) end)
+ create: one_arg(fn attrs -> create_post(project_id, attrs) end),
+ update: two_arg(fn post_id, attrs -> update_post(project_id, post_id, attrs) end),
+ delete: one_arg(fn post_id -> delete_post(project_id, post_id) end),
+ get: one_arg(fn post_id -> load_post(project_id, post_id) end),
+ get_all: zero_or_one_arg(fn _args -> list_posts(project_id) end),
+ get_by_slug: one_arg(fn slug -> load_post_by_slug(project_id, slug) end),
+ get_categories: zero_or_one_arg(fn _args -> post_categories(project_id) end),
+ get_categories_with_counts: zero_or_one_arg(fn _args -> post_categories_with_counts(project_id) end),
+ get_tags: zero_or_one_arg(fn _args -> post_tags(project_id) end),
+ get_tags_with_counts: zero_or_one_arg(fn _args -> post_tags_with_counts(project_id) end),
+ get_translation: two_arg(fn post_id, language -> load_post_translation(project_id, post_id, language) end),
+ get_translations: one_arg(fn post_id -> list_post_translations(project_id, post_id) end),
+ has_published_version: one_arg(fn post_id -> has_published_post_version(project_id, post_id) end),
+ publish: one_arg(fn post_id -> publish_post(project_id, post_id) end),
+ rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_posts_from_files(project_id) end),
+ reindex_text: zero_or_one_arg(fn _args -> reindex_project_search(project_id) end),
+ search: one_arg(fn query -> search_posts(project_id, query) end)
+ },
+ media: %{
+ import: one_arg(fn attrs -> import_media(project_id, attrs) end),
+ update: two_arg(fn media_id, attrs -> update_media(project_id, media_id, attrs) end),
+ delete: one_arg(fn media_id -> delete_media(project_id, media_id) end),
+ get: one_arg(fn media_id -> load_media(project_id, media_id) end),
+ get_all: zero_or_one_arg(fn _args -> list_media(project_id) end)
+ },
+ scripts: %{
+ create: one_arg(fn attrs -> create_script(project_id, attrs) end),
+ update: two_arg(fn script_id, attrs -> update_script(project_id, script_id, attrs) end),
+ delete: one_arg(fn script_id -> delete_script(project_id, script_id) end),
+ get: one_arg(fn script_id -> load_script(project_id, script_id) end),
+ get_all: zero_or_one_arg(fn _args -> list_scripts(project_id) end),
+ publish: one_arg(fn script_id -> publish_script(project_id, script_id) end),
+ rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_scripts_from_files(project_id) end)
+ },
+ templates: %{
+ create: one_arg(fn attrs -> create_template(project_id, attrs) end),
+ update: two_arg(fn template_id, attrs -> update_template(project_id, template_id, attrs) end),
+ delete: one_arg(fn template_id -> delete_template(project_id, template_id) end),
+ get: one_arg(fn template_id -> load_template(project_id, template_id) end),
+ get_all: zero_or_one_arg(fn _args -> list_templates(project_id) end),
+ publish: one_arg(fn template_id -> publish_template(project_id, template_id) end),
+ get_enabled_by_kind: one_arg(fn kind -> list_enabled_templates(project_id, kind) end),
+ rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_templates_from_files(project_id) end),
+ validate: one_arg(fn source -> validate_template_source(source) end)
},
tags: %{
- get_all: unary(fn -> tags end)
+ create: one_arg(fn attrs -> create_tag(project_id, attrs) end),
+ update: two_arg(fn tag_id, attrs -> update_tag(project_id, tag_id, attrs) end),
+ delete: one_arg(fn tag_id -> delete_tag(project_id, tag_id) end),
+ get: one_arg(fn tag_id -> load_tag(project_id, tag_id) end),
+ get_all: zero_or_one_arg(fn _args -> list_tags(project_id) end),
+ get_by_name: one_arg(fn tag_name -> load_tag_by_name(project_id, tag_name) end),
+ get_posts_with_tag: one_arg(fn tag_id -> tag_post_ids(project_id, tag_id) end),
+ get_with_counts: zero_or_one_arg(fn _args -> tags_with_counts(project_id) end),
+ merge: two_arg(fn source_tag_ids, target_tag_id -> merge_tags(project_id, source_tag_ids, target_tag_id) end),
+ rename: two_arg(fn tag_id, new_name -> rename_tag(project_id, tag_id, new_name) end),
+ sync_from_posts: zero_or_one_arg(fn _args -> sync_tags_from_posts(project_id) end)
+ },
+ tasks: %{
+ get: one_arg(fn task_id -> load_task(task_id) end),
+ status_snapshot: zero_or_one_arg(fn _args -> sanitize(Tasks.status_snapshot()) end),
+ cancel: one_arg(fn task_id -> cancel_task(task_id) end),
+ get_all: zero_or_one_arg(fn _args -> list_all_tasks() end),
+ get_running: zero_or_one_arg(fn _args -> list_running_tasks() end),
+ clear_completed: zero_or_one_arg(fn _args -> clear_completed_tasks() end)
+ },
+ sync: %{
+ check_availability: zero_or_one_arg(fn _args -> sync_available?() end),
+ get_repo_state: zero_or_one_arg(fn _args -> repo_state(project_id, opts) end),
+ get_status: zero_or_one_arg(fn _args -> repo_status(project_id, opts) end),
+ get_history: zero_or_one_arg(fn _args -> repo_history(project_id, opts) end),
+ get_remote_state: zero_or_one_arg(fn _args -> repo_state(project_id, opts) end),
+ fetch: zero_or_one_arg(fn _args -> repo_fetch(project_id, opts) end),
+ pull: zero_or_one_arg(fn _args -> repo_pull(project_id, opts) end),
+ push: zero_or_one_arg(fn _args -> repo_push(project_id, opts) end),
+ commit_all: one_arg(fn message -> repo_commit_all(project_id, message, opts) end)
+ },
+ publish: %{
+ upload_site: one_arg(fn credentials -> upload_site(project_id, credentials, opts) end)
+ },
+ chat: %{
+ detect_post_language: two_arg(fn title, content -> detect_post_language(title, content, opts) end),
+ analyze_post: one_arg(fn post_id -> analyze_post(post_id, opts) end),
+ translate_post: two_arg(fn post_id, language -> translate_post(post_id, language, opts) end),
+ analyze_media_image: one_arg(fn media_id -> analyze_media_image(media_id, opts) end),
+ detect_media_language: three_arg(fn title, alt, caption -> detect_media_language(title, alt, caption, opts) end),
+ translate_media_metadata: two_arg(fn media_id, language -> translate_media_metadata(media_id, language, opts) end)
+ },
+ embeddings: %{
+ get_progress: zero_or_one_arg(fn _args -> embedding_progress(project_id) end),
+ find_similar: two_arg(fn post_id, limit -> find_similar(post_id, limit) end),
+ compute_similarities: two_arg(fn post_id, target_ids -> compute_similarities(post_id, target_ids) end),
+ suggest_tags: two_arg(fn post_id, exclude_tags -> suggest_tags(post_id, exclude_tags) end),
+ find_duplicates: zero_or_one_arg(fn _args -> find_duplicates(project_id) end),
+ dismiss_pair: two_arg(fn post_id_a, post_id_b -> dismiss_pair(post_id_a, post_id_b) end),
+ index_unindexed_posts: zero_or_one_arg(fn _args -> index_unindexed_posts(project_id) end)
}
}
end
- defp preload_metadata(project_id) do
+ defp create_project(attrs), do: attrs |> normalize_map() |> Projects.create_project() |> unwrap_result()
+
+ defp delete_project(project_id), do: boolean_result(Projects.delete_project(string_or_nil(project_id)))
+
+ defp delete_project_with_data(project_id) do
+ case string_or_nil(project_id) && Projects.get_project(string_or_nil(project_id)) do
+ %Project{} = project ->
+ data_dir = Projects.project_data_dir(project)
+
+ case Projects.delete_project(project.id) do
+ {:ok, _deleted_project} ->
+ _ = File.rm_rf(data_dir)
+ true
+
+ {:error, _reason} ->
+ false
+ end
+
+ _other ->
+ false
+ end
+ end
+
+ defp load_project(project_id) do
+ case string_or_nil(project_id) do
+ nil -> nil
+ id -> Projects.get_project(id) |> sanitize_nilable()
+ end
+ end
+
+ defp list_projects do
+ Projects.list_projects()
+ |> Enum.map(&sanitize/1)
+ end
+
+ defp set_active_project(project_id) do
+ project_id
+ |> string_or_nil()
+ |> then(fn
+ nil -> {:error, :not_found}
+ id -> Projects.set_active_project(id)
+ end)
+ |> unwrap_result()
+ end
+
+ defp update_project(project_id, attrs) do
+ case string_or_nil(project_id) && Projects.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
+
+ defp load_metadata(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
sanitize(metadata)
end
- defp preload_posts(project_id) do
- Repo.all(from(post in Post, where: post.project_id == ^project_id))
- |> Enum.map(&post_payload/1)
+ defp update_project_metadata(project_id, attrs) do
+ Metadata.update_project_metadata(project_id, normalize_map(attrs))
+ |> unwrap_result()
end
- defp preload_tags(project_id) do
+ defp add_category(project_id, name) do
+ Metadata.add_category(project_id, string_or_nil(name) || "")
+ |> unwrap_result()
+ end
+
+ defp remove_category(project_id, name) do
+ Metadata.remove_category(project_id, string_or_nil(name) || "")
+ |> unwrap_result()
+ end
+
+ defp metadata_categories(project_id) do
+ load_metadata(project_id)
+ |> Map.get("categories", [])
+ end
+
+ defp metadata_tags(project_id) do
project_id
- |> Tags.list_tags()
- |> Enum.map(&sanitize/1)
+ |> list_tags()
+ |> Enum.map(&Map.get(&1, "name"))
end
- defp unary(callback) when is_function(callback, 0) do
- fn args, state ->
- _decoded_args = :luerl.decode_list(args, state)
- :luerl.encode_list([callback.()], state)
+ defp 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
- defp unary(callback) when is_function(callback, 1) do
+ defp 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
+
+ defp publishing_preferences(project_id) do
+ load_metadata(project_id)
+ |> Map.get("publishing_preferences")
+ end
+
+ defp 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
+
+ defp clear_publishing_preferences(project_id) do
+ set_publishing_preferences(project_id, %{})
+ end
+
+ defp create_post(project_id, attrs) do
+ attrs
+ |> normalize_map()
+ |> Map.put("project_id", project_id)
+ |> Posts.create_post()
+ |> unwrap_result(&post_payload/1)
+ end
+
+ defp 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
+
+ defp 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
+
+ defp load_post(project_id, post_id) do
+ case fetch_post(project_id, post_id) do
+ %Post{} = post -> post_payload(post)
+ _other -> nil
+ end
+ end
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp reindex_project_search(project_id) do
+ case Search.reindex_project(project_id) do
+ :ok -> true
+ _other -> false
+ end
+ end
+
+ defp 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
+
+ defp post_tags(project_id), do: names_with_counts(project_id, :tags) |> Enum.map(& &1["name"])
+ defp post_tags_with_counts(project_id), do: names_with_counts(project_id, :tags)
+ defp post_categories(project_id), do: names_with_counts(project_id, :categories) |> Enum.map(& &1["name"])
+ defp post_categories_with_counts(project_id), do: names_with_counts(project_id, :categories)
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp import_media(project_id, attrs) do
+ attrs
+ |> normalize_map()
+ |> Map.put("project_id", project_id)
+ |> Media.import_media()
+ |> unwrap_result()
+ end
+
+ defp update_media(project_id, media_id, attrs) do
+ case fetch_media(project_id, media_id) do
+ %MediaRecord{} -> Media.update_media(media_id, normalize_map(attrs)) |> unwrap_result()
+ _other -> nil
+ end
+ end
+
+ defp 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
+
+ defp load_media(project_id, media_id) do
+ fetch_media(project_id, media_id)
+ |> sanitize_nilable()
+ end
+
+ defp 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
+
+ defp create_script(project_id, attrs) do
+ attrs
+ |> normalize_map()
+ |> Map.put("project_id", project_id)
+ |> Scripts.create_script()
+ |> unwrap_result()
+ end
+
+ defp 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
+
+ defp 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
+
+ defp load_script(project_id, script_id) do
+ fetch_script(project_id, script_id)
+ |> sanitize_nilable()
+ end
+
+ defp 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
+
+ defp 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
+
+ defp rebuild_scripts_from_files(project_id) do
+ project_id
+ |> Scripts.rebuild_scripts_from_files()
+ |> unwrap_result()
+ end
+
+ defp create_template(project_id, attrs) do
+ attrs
+ |> normalize_map()
+ |> Map.put("project_id", project_id)
+ |> Templates.create_template()
+ |> unwrap_result()
+ end
+
+ defp 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
+
+ defp 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
+
+ defp load_template(project_id, template_id) do
+ fetch_template(project_id, template_id)
+ |> sanitize_nilable()
+ end
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp rebuild_templates_from_files(project_id) do
+ project_id
+ |> Templates.rebuild_templates_from_files()
+ |> unwrap_result()
+ end
+
+ defp validate_template_source(source) do
+ source
+ |> string_or_nil()
+ |> Kernel.||("")
+ |> MCP.validate_template()
+ |> unwrap_result()
+ end
+
+ defp create_tag(project_id, attrs) do
+ attrs
+ |> normalize_map()
+ |> Map.put("project_id", project_id)
+ |> Tags.create_tag()
+ |> unwrap_result()
+ end
+
+ defp 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
+
+ defp 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
+
+ defp load_tag(project_id, tag_id) do
+ fetch_tag(project_id, tag_id)
+ |> sanitize_nilable()
+ end
+
+ defp list_tags(project_id) do
+ Tags.list_tags(project_id)
+ |> Enum.map(&sanitize/1)
+ end
+
+ defp tags_with_counts(project_id) do
+ counts_by_name =
+ 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
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp sync_tags_from_posts(project_id) do
+ Tags.sync_tags_from_posts(project_id)
+ |> unwrap_result()
+ end
+
+ defp load_task(task_id) do
+ case string_or_nil(task_id) do
+ nil -> nil
+ id -> Tasks.get_task(id) |> sanitize_nilable()
+ end
+ end
+
+ defp cancel_task(task_id) do
+ case string_or_nil(task_id) do
+ nil -> false
+ id -> match?(:ok, Tasks.cancel_task(id))
+ end
+ end
+
+ defp list_all_tasks do
+ Tasks.list_tasks()
+ |> Enum.map(&sanitize/1)
+ end
+
+ defp list_running_tasks do
+ Tasks.list_running_tasks()
+ |> Enum.map(&sanitize/1)
+ end
+
+ defp clear_completed_tasks do
+ match?(:ok, Tasks.clear_completed())
+ end
+
+ defp 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
+
+ defp project_path(project_id) do
+ project_id
+ |> Projects.get_project()
+ |> Projects.project_data_dir()
+ end
+
+ defp 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
+
+ defp sync_available?, do: not is_nil(System.find_executable("git"))
+
+ defp repo_state(project_id, opts) do
+ project_id
+ |> Git.repository(git_opts(opts))
+ |> unwrap_result()
+ end
+
+ defp repo_status(project_id, opts) do
+ project_id
+ |> Git.status(git_opts(opts))
+ |> unwrap_result()
+ end
+
+ defp 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
+
+ defp repo_fetch(project_id, opts), do: project_id |> Git.fetch(git_opts(opts)) |> unwrap_result()
+ defp repo_pull(project_id, opts), do: project_id |> Git.pull(git_opts(opts)) |> unwrap_result()
+ defp repo_push(project_id, opts), do: project_id |> Git.push(git_opts(opts)) |> unwrap_result()
+
+ defp repo_commit_all(project_id, message, opts) do
+ project_id
+ |> Git.commit_all(string_or_nil(message) || "", git_opts(opts))
+ |> unwrap_result()
+ end
+
+ defp upload_site(project_id, credentials, opts) do
+ project_id
+ |> Publishing.upload_site(normalize_map(credentials), publishing_opts(opts))
+ |> unwrap_result()
+ end
+
+ defp 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
+
+ defp analyze_post(post_id, opts) do
+ post_id
+ |> string_or_nil()
+ |> AI.analyze_post(ai_opts(opts))
+ |> unwrap_result()
+ end
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp embedding_progress(project_id), do: project_id |> Embeddings.get_indexing_progress() |> unwrap_result()
+
+ defp find_similar(post_id, limit) do
+ post_id
+ |> string_or_nil()
+ |> Embeddings.find_similar(integer_or_default(limit, 5))
+ |> unwrap_result()
+ end
+
+ defp compute_similarities(post_id, target_ids) do
+ post_id
+ |> string_or_nil()
+ |> Embeddings.compute_similarities(normalize_string_list(target_ids))
+ |> unwrap_result()
+ end
+
+ defp suggest_tags(post_id, exclude_tags) do
+ post_id
+ |> string_or_nil()
+ |> Embeddings.suggest_tags(normalize_string_list(exclude_tags))
+ |> unwrap_result()
+ end
+
+ defp find_duplicates(project_id), do: project_id |> Embeddings.find_duplicates() |> unwrap_result()
+ defp 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)
+ defp index_unindexed_posts(project_id), do: project_id |> Embeddings.index_unindexed() |> unwrap_result()
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp 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
+
+ defp zero_or_one_arg(callback) when is_function(callback, 1) do
+ fn args, state ->
+ decoded_args = :luerl.decode_list(args, state)
+ value = callback.(sanitize(decoded_args))
+ :luerl.encode_list([sanitize(value)], state)
+ end
+ end
+
+ defp one_arg(callback) when is_function(callback, 1) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
@@ -63,7 +905,38 @@ defmodule BDS.Scripting.Capabilities do
[] -> callback.(nil)
end
- :luerl.encode_list([value], state)
+ :luerl.encode_list([sanitize(value)], state)
+ end
+ end
+
+ defp 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.(sanitize(first), sanitize(second))
+ [first] -> callback.(sanitize(first), nil)
+ [] -> callback.(nil, nil)
+ end
+
+ :luerl.encode_list([sanitize(value)], state)
+ end
+ end
+
+ defp 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.(sanitize(first), sanitize(second), sanitize(third))
+ [first, second] -> callback.(sanitize(first), sanitize(second), nil)
+ [first] -> callback.(sanitize(first), nil, nil)
+ [] -> callback.(nil, nil, nil)
+ end
+
+ :luerl.encode_list([sanitize(value)], state)
end
end
@@ -93,10 +966,92 @@ defmodule BDS.Scripting.Capabilities do
end
end
+ defp unwrap_result(result, transform \\ &sanitize/1)
+
+ defp unwrap_result({:ok, value}, transform), do: transform.(value)
+ defp unwrap_result({:error, _reason}, _transform), do: nil
+
+ defp boolean_result({:ok, _value}), do: true
+ defp boolean_result({:error, _reason}), do: false
+
+ defp atom_result({:ok, value}, expected_value), do: value == expected_value
+ defp atom_result(_result, _expected_value), do: false
+
+ defp sanitize_nilable(nil), do: nil
+ defp sanitize_nilable(value), do: sanitize(value)
+
+ defp normalize_map(value) when is_map(value), do: sanitize(value)
+ defp 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), sanitize(entry_value)} end)
+ else
+ %{}
+ end
+ end
+ defp normalize_map(_value), do: %{}
+
+ defp normalize_string_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
+ defp normalize_string_list(_value), do: []
+
+ defp integer_or_default(value, _default) when is_integer(value), do: value
+ defp integer_or_default(value, _default) when is_float(value), do: trunc(value)
+ defp integer_or_default(_value, default), do: default
+
+ defp string_or_nil(value) when is_binary(value), do: value
+ defp string_or_nil(value) when is_atom(value), do: Atom.to_string(value)
+ defp string_or_nil(value) when is_number(value), do: to_string(value)
+ defp string_or_nil(_value), do: nil
+
+ defp git_opts(opts) do
+ case Keyword.get(opts, :git_runner) do
+ nil -> []
+ runner -> [runner: runner]
+ end
+ end
+
+ defp publishing_opts(opts) do
+ case Keyword.get(opts, :publishing_uploader) do
+ nil -> []
+ uploader -> [uploader: uploader]
+ end
+ end
+
+ defp 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
+
+ defp maybe_put_opt(opts, _key, nil), do: opts
+ defp maybe_put_opt(opts, key, value), do: Keyword.put(opts, key, value)
+
+ defp project_for_folder(folder_path) do
+ normalized = string_or_nil(folder_path)
+
+ Projects.list_projects()
+ |> Enum.find(fn project -> Projects.project_data_dir(project) == normalized end)
+ end
+
+ defp 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
+
+ defp sanitize(%DateTime{} = value), do: DateTime.to_iso8601(value)
defp sanitize(%_struct{} = struct) do
struct
|> Map.from_struct()
- |> Map.drop([:__meta__, :post, :project, :media])
+ |> Map.drop([:__meta__, :post, :project, :media, :translations])
|> sanitize()
end
@@ -105,6 +1060,20 @@ defmodule BDS.Scripting.Capabilities do
end
defp sanitize(list) when is_list(list), do: Enum.map(list, &sanitize/1)
+ defp sanitize(value) when is_boolean(value), do: value
defp sanitize(value) when is_atom(value), do: Atom.to_string(value)
defp sanitize(value), do: value
+
+ defp 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
diff --git a/lib/bds/tasks.ex b/lib/bds/tasks.ex
index 8eb1445..9154c7b 100644
--- a/lib/bds/tasks.ex
+++ b/lib/bds/tasks.ex
@@ -23,6 +23,18 @@ defmodule BDS.Tasks do
GenServer.call(__MODULE__, :status_snapshot)
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
GenServer.call(__MODULE__, {:cancel_task, task_id})
end
@@ -75,6 +87,23 @@ defmodule BDS.Tasks do
{:reply, build_status_snapshot(state), state}
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
cond do
Map.has_key?(state.running, task_id) ->
@@ -330,6 +359,19 @@ defmodule BDS.Tasks do
|> Enum.sort_by(&task_sort_key/1)
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
{task_priority(task.status), task.started_at || task.created_at}
end
diff --git a/lib/mix/tasks/bds.api_docs.ex b/lib/mix/tasks/bds.api_docs.ex
new file mode 100644
index 0000000..1deb4e0
--- /dev/null
+++ b/lib/mix/tasks/bds.api_docs.ex
@@ -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
diff --git a/test/bds/scripting/api_documentation_test.exs b/test/bds/scripting/api_documentation_test.exs
new file mode 100644
index 0000000..79e331e
--- /dev/null
+++ b/test/bds/scripting/api_documentation_test.exs
@@ -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
diff --git a/test/bds/scripting/api_test.exs b/test/bds/scripting/api_test.exs
index 3aa244c..2680c5f 100644
--- a/test/bds/scripting/api_test.exs
+++ b/test/bds/scripting/api_test.exs
@@ -1,8 +1,34 @@
defmodule BDS.Scripting.ApiTest do
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
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
+ Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
temp_dir =
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, [])
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: "
Lua
' })", + " 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: "