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: "
{{ post.title }}
" + }) + + source = + [ + "function main()", + " local active = bds.projects.get_active()", + " local projects = bds.projects.get_all()", + " local created = bds.scripts.create({ title = 'Lua Utility', kind = 'utility', content = 'function main() return 42 end' })", + " local templates = bds.templates.get_all()", + " local created_template = bds.templates.create({ title = 'Lua Template', kind = 'partial', content = '

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: "
{{ post.title }}
" + }) + + assert {:ok, _published_template} = BDS.Templates.publish_template(template.id) + + assert {:ok, running_task} = + BDS.Tasks.register_external_task("preview build", %{ + group_id: "generation", + group_name: "Generation" + }) + + on_exit(fn -> + _ = BDS.Tasks.complete_task(running_task.id) + end) + + source = + [ + "function main()", + " local updated = bds.projects.update('" <> project.id <> "', { description = 'Updated through Lua' })", + " bds.meta.set_publishing_preferences({ ssh_host = 'example.test', ssh_user = 'deploy', ssh_remote_path = '/srv/www', ssh_mode = 'scp' })", + " local prefs = bds.meta.get_publishing_preferences()", + " local categories = bds.meta.get_categories()", + " local scripts = bds.scripts.rebuild_from_files()", + " local templates = bds.templates.rebuild_from_files()", + " local enabled_post_templates = bds.templates.get_enabled_by_kind('post')", + " local tasks = bds.tasks.get_all()", + " local running = bds.tasks.get_running()", + " bds.meta.clear_publishing_preferences()", + " local cleared = bds.meta.get_publishing_preferences()", + " return {", + " project_description = updated.description,", + " ssh_mode = prefs.ssh_mode,", + " category_count = #categories,", + " rebuilt_script_count = #scripts,", + " rebuilt_template_count = #templates,", + " enabled_post_template_count = #enabled_post_templates,", + " task_count = #tasks,", + " running_count = #running,", + " cleared_ssh_mode = cleared.ssh_mode", + " }", + "end" + ] + |> Enum.join("\n") + + assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main") + + assert result["project_description"] == "Updated through Lua" + assert result["ssh_mode"] == "scp" + assert result["category_count"] >= 1 + assert result["rebuilt_script_count"] >= 1 + assert result["rebuilt_template_count"] >= 1 + assert result["enabled_post_template_count"] >= 1 + assert result["task_count"] >= 1 + assert result["running_count"] >= 1 + assert result["cleared_ssh_mode"] == "scp" + end + + test "project scripting exposes post, tag, and translation lookup helpers", %{project: project} do + assert {:ok, post} = + BDS.Posts.create_post(%{ + project_id: project.id, + title: "Lookup Post", + excerpt: "Search me", + content: "Elixir lookup body with tag coverage", + language: "en", + tags: ["elixir", "lua"], + categories: ["article", "guide"] + }) + + assert {:ok, _published_post} = BDS.Posts.publish_post(post.id) + + assert {:ok, _translation} = + BDS.Posts.upsert_post_translation(post.id, "de", %{ + title: "Nachschlagebeitrag", + excerpt: "Suche mich", + content: "Deutscher Inhalt" + }) + + assert {:ok, _tags} = BDS.Tags.sync_tags_from_posts(project.id) + + source = + [ + "function main()", + " local meta_tags = bds.meta.get_tags()", + " local with_counts = bds.tags.get_with_counts()", + " local tag_posts = bds.tags.get_posts_with_tag(bds.tags.get_by_name('elixir').id)", + " local post_tags = bds.posts.get_tags()", + " local post_tag_counts = bds.posts.get_tags_with_counts()", + " local post_categories = bds.posts.get_categories()", + " local post_category_counts = bds.posts.get_categories_with_counts()", + " local translations = bds.posts.get_translations('" <> post.id <> "')", + " local translation = bds.posts.get_translation('" <> post.id <> "', 'de')", + " local published = bds.posts.has_published_version('" <> post.id <> "')", + " local search = bds.posts.search('lookup')", + " return {", + " meta_tag_count = #meta_tags,", + " tag_with_count = with_counts[1].count,", + " tag_post_count = #tag_posts,", + " post_tag_count = #post_tags,", + " post_tag_count_rows = #post_tag_counts,", + " category_count = #post_categories,", + " category_count_rows = #post_category_counts,", + " translation_count = #translations,", + " translation_title = translation.title,", + " has_published = published,", + " search_count = #search", + " }", + "end" + ] + |> Enum.join("\n") + + assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main") + + assert result["meta_tag_count"] >= 2 + assert result["tag_with_count"] >= 1 + assert result["tag_post_count"] == 1 + assert result["post_tag_count"] >= 2 + assert result["post_tag_count_rows"] >= 2 + assert result["category_count"] >= 2 + assert result["category_count_rows"] >= 2 + assert result["translation_count"] == 1 + assert result["translation_title"] == "Nachschlagebeitrag" + assert result["has_published"] == true + assert result["search_count"] >= 1 + end end