diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 66d473d..34e7bef 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -102,6 +102,23 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo --- +## ⚠️ MANDATORY: Keep Python API Bindings and API Docs in Sync + +**Whenever any app API is added, removed, or changed, you MUST update the Python API bridge and API documentation in the same change set.** + +- Update the Python API contract/bindings used by embedded Pyodide (`bds_api`) +- Regenerate and commit `API.md` +- Ensure every API entry documents: + - Parameter names, types, and required/optional status + - Return type/response specification + - At least one sample Python call +- Maintain a shared **Data Structures** section in `API.md` for canonical objects (for example `PostData`, `MediaData`) so users can see expected attributes in one place +- Keep docs sync tests passing (documentation and generator output must match) + +> **No API contract drift between app APIs, Python bindings, and API.md. No exceptions.** + +--- + ## Architecture Principles ### Separation of Concerns diff --git a/API.md b/API.md new file mode 100644 index 0000000..53ec1ea --- /dev/null +++ b/API.md @@ -0,0 +1,4054 @@ +# API Documentation + +Contract version: 1.3.0 + +This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide. + +## Usage + +```python +from bds_api import bds + +# inside an async Python function in bDS runtime: +project = await bds.meta.get_project_metadata() +``` + +## Table of contents + +- [projects](#projects) +- [posts](#posts) +- [media](#media) +- [scripts](#scripts) +- [tasks](#tasks) +- [app](#app) +- [meta](#meta) +- [tags](#tags) +- [chat](#chat) +- [sync](#sync) +- [Data Structures](#data-structures) + +## projects + +**Module APIs** + +- [projects.create](#projectscreate) +- [projects.update](#projectsupdate) +- [projects.delete](#projectsdelete) +- [projects.deleteWithData](#projectsdeletewithdata) +- [projects.get](#projectsget) +- [projects.getAll](#projectsgetall) +- [projects.getActive](#projectsgetactive) +- [projects.setActive](#projectssetactive) + +### projects.create + +Create a project. + +**Parameters** + +- data (dict, required) + +**Response specification** + +- Return type: `ProjectData` +- Data structures: `ProjectData` + +**Example call** + +```python +from bds_api import bds +result = await bds.projects.create(data={}) +``` + +**Example response** + +```python +{ + 'id': 'value', + 'name': 'value', + 'slug': 'value', + 'description': 'value', + 'dataPath': 'value', + 'isActive': False, + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### projects.update + +Update a project by id. + +**Parameters** + +- id (str, required) +- data (dict, required) + +**Response specification** + +- Return type: `ProjectData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ProjectData` + +**Example call** + +```python +from bds_api import bds +result = await bds.projects.update(id='id-1', data={}) +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'name': 'value', + 'slug': 'value', + 'description': 'value', + 'dataPath': 'value', + 'isActive': False, + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### projects.delete + +Delete a project by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.projects.delete(id='id-1') +``` + +**Example response** + +```python +True +``` + +### projects.deleteWithData + +Delete a project and data by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.projects.delete_with_data(id='id-1') +``` + +**Example response** + +```python +True +``` + +### projects.get + +Fetch one project by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `ProjectData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ProjectData` + +**Example call** + +```python +from bds_api import bds +result = await bds.projects.get(id='id-1') +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'name': 'value', + 'slug': 'value', + 'description': 'value', + 'dataPath': 'value', + 'isActive': False, + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### projects.getAll + +Fetch all projects. + +**Parameters** + +- None + +**Response specification** + +- Return type: `ProjectData[]` +- Data structures: `ProjectData` + +**Example call** + +```python +from bds_api import bds +result = await bds.projects.get_all() +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'name': 'value', + 'slug': 'value', + 'description': 'value', + 'dataPath': 'value', + 'isActive': False, + 'createdAt': 'value', + 'updatedAt': 'value' +} +] +``` + +### projects.getActive + +Fetch active project. + +**Parameters** + +- None + +**Response specification** + +- Return type: `ProjectData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ProjectData` + +**Example call** + +```python +from bds_api import bds +result = await bds.projects.get_active() +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'name': 'value', + 'slug': 'value', + 'description': 'value', + 'dataPath': 'value', + 'isActive': False, + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### projects.setActive + +Set active project by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `ProjectData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ProjectData` + +**Example call** + +```python +from bds_api import bds +result = await bds.projects.set_active(id='id-1') +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'name': 'value', + 'slug': 'value', + 'description': 'value', + 'dataPath': 'value', + 'isActive': False, + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +[↑ Back to Table of contents](#table-of-contents) + +## posts + +**Module APIs** + +- [posts.create](#postscreate) +- [posts.update](#postsupdate) +- [posts.delete](#postsdelete) +- [posts.get](#postsget) +- [posts.getPreviewUrl](#postsgetpreviewurl) +- [posts.getAll](#postsgetall) +- [posts.getByStatus](#postsgetbystatus) +- [posts.publish](#postspublish) +- [posts.discard](#postsdiscard) +- [posts.hasPublishedVersion](#postshaspublishedversion) +- [posts.rebuildFromFiles](#postsrebuildfromfiles) +- [posts.reindexText](#postsreindextext) +- [posts.search](#postssearch) +- [posts.filter](#postsfilter) +- [posts.getTags](#postsgettags) +- [posts.getCategories](#postsgetcategories) +- [posts.getByYearMonth](#postsgetbyyearmonth) +- [posts.getDashboardStats](#postsgetdashboardstats) +- [posts.getTagsWithCounts](#postsgettagswithcounts) +- [posts.getCategoriesWithCounts](#postsgetcategorieswithcounts) +- [posts.getLinksTo](#postsgetlinksto) +- [posts.getLinkedBy](#postsgetlinkedby) +- [posts.rebuildLinks](#postsrebuildlinks) +- [posts.isSlugAvailable](#postsisslugavailable) +- [posts.generateUniqueSlug](#postsgenerateuniqueslug) + +### posts.create + +Create a post. + +**Parameters** + +- data (dict, required) + +**Response specification** + +- Return type: `PostData` +- Data structures: `PostData` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.create(data={}) +``` + +**Example response** + +```python +{ + 'id': 'value', + 'projectId': 'value', + 'title': 'value', + 'slug': 'value', + 'excerpt': 'value', + 'content': 'value', + 'status': None, + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'publishedAt': 'value', + 'tags': 'value', + 'categories': 'value' +} +``` + +### posts.update + +Update a post by id. + +**Parameters** + +- id (str, required) +- data (dict, required) + +**Response specification** + +- Return type: `PostData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `PostData` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.update(id='id-1', data={}) +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'title': 'value', + 'slug': 'value', + 'excerpt': 'value', + 'content': 'value', + 'status': None, + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'publishedAt': 'value', + 'tags': 'value', + 'categories': 'value' +} +``` + +### posts.delete + +Delete a post by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.delete(id='id-1') +``` + +**Example response** + +```python +True +``` + +### posts.get + +Fetch one post by id. + +**Parameters** + +- postId (str, required) + +**Response specification** + +- Return type: `PostData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `PostData` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get(post_id='post-1') +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'title': 'value', + 'slug': 'value', + 'excerpt': 'value', + 'content': 'value', + 'status': None, + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'publishedAt': 'value', + 'tags': 'value', + 'categories': 'value' +} +``` + +### posts.getPreviewUrl + +Get preview URL for post. + +**Parameters** + +- id (str, required) +- options (dict, optional) + +**Response specification** + +- Return type: `string | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_preview_url(id='id-1') +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### posts.getAll + +Fetch posts with pagination. + +**Parameters** + +- options (dict, optional) + +**Response specification** + +- Return type: `PaginatedPostsResult` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_all() +``` + +**Example response** + +```python +{} +``` + +### posts.getByStatus + +Fetch posts by status. + +**Parameters** + +- status (str, required) + +**Response specification** + +- Return type: `PostData[]` +- Data structures: `PostData` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_by_status(status='status') +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'projectId': 'value', + 'title': 'value', + 'slug': 'value', + 'excerpt': 'value', + 'content': 'value', + 'status': None, + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'publishedAt': 'value', + 'tags': 'value', + 'categories': 'value' +} +] +``` + +### posts.publish + +Publish a post by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `PostData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `PostData` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.publish(id='id-1') +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'title': 'value', + 'slug': 'value', + 'excerpt': 'value', + 'content': 'value', + 'status': None, + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'publishedAt': 'value', + 'tags': 'value', + 'categories': 'value' +} +``` + +### posts.discard + +Discard draft changes for post. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `PostData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `PostData` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.discard(id='id-1') +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'title': 'value', + 'slug': 'value', + 'excerpt': 'value', + 'content': 'value', + 'status': None, + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'publishedAt': 'value', + 'tags': 'value', + 'categories': 'value' +} +``` + +### posts.hasPublishedVersion + +Check if post has published version. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.has_published_version(id='id-1') +``` + +**Example response** + +```python +True +``` + +### posts.rebuildFromFiles + +Rebuild posts database from files. + +**Parameters** + +- None + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.rebuild_from_files() +``` + +**Example response** + +```python +None +``` + +### posts.reindexText + +Reindex post search text. + +**Parameters** + +- None + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.reindex_text() +``` + +**Example response** + +```python +None +``` + +### posts.search + +Search posts by free-text query. + +**Parameters** + +- query (str, required) + +**Response specification** + +- Return type: `SearchResult[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.search(query='search phrase') +``` + +**Example response** + +```python +[] +``` + +### posts.filter + +Filter posts by criteria. + +**Parameters** + +- filter (dict, required) + +**Response specification** + +- Return type: `PostData[]` +- Data structures: `PostData` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.filter(filter={}) +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'projectId': 'value', + 'title': 'value', + 'slug': 'value', + 'excerpt': 'value', + 'content': 'value', + 'status': None, + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'publishedAt': 'value', + 'tags': 'value', + 'categories': 'value' +} +] +``` + +### posts.getTags + +Get all post tags. + +**Parameters** + +- None + +**Response specification** + +- Return type: `string[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_tags() +``` + +**Example response** + +```python +[] +``` + +### posts.getCategories + +Get all post categories. + +**Parameters** + +- None + +**Response specification** + +- Return type: `string[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_categories() +``` + +**Example response** + +```python +[] +``` + +### posts.getByYearMonth + +Get post counts grouped by year/month. + +**Parameters** + +- None + +**Response specification** + +- Return type: `Array<{ year: number; month: number; count: number } >` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_by_year_month() +``` + +**Example response** + +```python +[] +``` + +### posts.getDashboardStats + +Get post dashboard stats. + +**Parameters** + +- None + +**Response specification** + +- Return type: `DashboardStats` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_dashboard_stats() +``` + +**Example response** + +```python +{} +``` + +### posts.getTagsWithCounts + +Get post tags with counts. + +**Parameters** + +- None + +**Response specification** + +- Return type: `TagCount[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_tags_with_counts() +``` + +**Example response** + +```python +[] +``` + +### posts.getCategoriesWithCounts + +Get post categories with counts. + +**Parameters** + +- None + +**Response specification** + +- Return type: `CategoryCount[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_categories_with_counts() +``` + +**Example response** + +```python +[] +``` + +### posts.getLinksTo + +Get posts linked to given post. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `PostData[]` +- Data structures: `PostData` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_links_to(id='id-1') +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'projectId': 'value', + 'title': 'value', + 'slug': 'value', + 'excerpt': 'value', + 'content': 'value', + 'status': None, + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'publishedAt': 'value', + 'tags': 'value', + 'categories': 'value' +} +] +``` + +### posts.getLinkedBy + +Get posts linking to given post. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `PostData[]` +- Data structures: `PostData` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.get_linked_by(id='id-1') +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'projectId': 'value', + 'title': 'value', + 'slug': 'value', + 'excerpt': 'value', + 'content': 'value', + 'status': None, + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'publishedAt': 'value', + 'tags': 'value', + 'categories': 'value' +} +] +``` + +### posts.rebuildLinks + +Rebuild post link graph. + +**Parameters** + +- None + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.rebuild_links() +``` + +**Example response** + +```python +None +``` + +### posts.isSlugAvailable + +Check if post slug is available. + +**Parameters** + +- slug (str, required) +- excludePostId (str, optional) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.is_slug_available(slug='slug') +``` + +**Example response** + +```python +True +``` + +### posts.generateUniqueSlug + +Generate unique slug from title. + +**Parameters** + +- title (str, required) +- excludePostId (str, optional) + +**Response specification** + +- Return type: `string` + +**Example call** + +```python +from bds_api import bds +result = await bds.posts.generate_unique_slug(title='title') +``` + +**Example response** + +```python +'value' +``` + +[↑ Back to Table of contents](#table-of-contents) + +## media + +**Module APIs** + +- [media.import](#mediaimport) +- [media.update](#mediaupdate) +- [media.replaceFile](#mediareplacefile) +- [media.delete](#mediadelete) +- [media.get](#mediaget) +- [media.getUrl](#mediageturl) +- [media.getFilePath](#mediagetfilepath) +- [media.getAll](#mediagetall) +- [media.rebuildFromFiles](#mediarebuildfromfiles) +- [media.reindexText](#mediareindextext) +- [media.getThumbnail](#mediagetthumbnail) +- [media.regenerateThumbnails](#mediaregeneratethumbnails) +- [media.regenerateMissingThumbnails](#mediaregeneratemissingthumbnails) +- [media.filter](#mediafilter) +- [media.search](#mediasearch) +- [media.getByYearMonth](#mediagetbyyearmonth) +- [media.getTags](#mediagettags) +- [media.getTagsWithCounts](#mediagettagswithcounts) + +### media.import + +Import media file. + +**Parameters** + +- sourcePath (str, required) +- metadata (dict, optional) + +**Response specification** + +- Return type: `MediaData` +- Data structures: `MediaData` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.import(source_path='source_path') +``` + +**Example response** + +```python +{ + 'id': 'value', + 'projectId': 'value', + 'filename': 'value', + 'originalName': 'value', + 'mimeType': 'value', + 'size': 0, + 'width': 0, + 'height': 0, + 'title': 'value', + 'alt': 'value', + 'caption': 'value', + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'tags': 'value' +} +``` + +### media.update + +Update media metadata by id. + +**Parameters** + +- id (str, required) +- data (dict, required) + +**Response specification** + +- Return type: `MediaData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `MediaData` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.update(id='id-1', data={}) +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'filename': 'value', + 'originalName': 'value', + 'mimeType': 'value', + 'size': 0, + 'width': 0, + 'height': 0, + 'title': 'value', + 'alt': 'value', + 'caption': 'value', + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'tags': 'value' +} +``` + +### media.replaceFile + +Replace media file by id. + +**Parameters** + +- id (str, required) +- newSourcePath (str, required) + +**Response specification** + +- Return type: `MediaData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `MediaData` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.replace_file(id='id-1', new_source_path='new_source_path') +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'filename': 'value', + 'originalName': 'value', + 'mimeType': 'value', + 'size': 0, + 'width': 0, + 'height': 0, + 'title': 'value', + 'alt': 'value', + 'caption': 'value', + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'tags': 'value' +} +``` + +### media.delete + +Delete media by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.delete(id='id-1') +``` + +**Example response** + +```python +True +``` + +### media.get + +Fetch one media by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `MediaData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `MediaData` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.get(id='id-1') +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'filename': 'value', + 'originalName': 'value', + 'mimeType': 'value', + 'size': 0, + 'width': 0, + 'height': 0, + 'title': 'value', + 'alt': 'value', + 'caption': 'value', + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'tags': 'value' +} +``` + +### media.getUrl + +Get media URL by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `string | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.media.get_url(id='id-1') +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### media.getFilePath + +Get media file path by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `string | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.media.get_file_path(id='id-1') +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### media.getAll + +Fetch all media. + +**Parameters** + +- None + +**Response specification** + +- Return type: `MediaData[]` +- Data structures: `MediaData` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.get_all() +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'projectId': 'value', + 'filename': 'value', + 'originalName': 'value', + 'mimeType': 'value', + 'size': 0, + 'width': 0, + 'height': 0, + 'title': 'value', + 'alt': 'value', + 'caption': 'value', + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'tags': 'value' +} +] +``` + +### media.rebuildFromFiles + +Rebuild media database from files. + +**Parameters** + +- None + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.rebuild_from_files() +``` + +**Example response** + +```python +None +``` + +### media.reindexText + +Reindex media search text. + +**Parameters** + +- None + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.reindex_text() +``` + +**Example response** + +```python +None +``` + +### media.getThumbnail + +Get media thumbnail URL. + +**Parameters** + +- id (str, required) +- size (str, optional) + +**Response specification** + +- Return type: `string | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.media.get_thumbnail(id='id-1') +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### media.regenerateThumbnails + +Regenerate thumbnails for media. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `Record | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.media.regenerate_thumbnails(id='id-1') +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### media.regenerateMissingThumbnails + +Regenerate all missing thumbnails. + +**Parameters** + +- None + +**Response specification** + +- Return type: `{ processed: number; generated: number; failed: number }` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.regenerate_missing_thumbnails() +``` + +**Example response** + +```python +{} +``` + +### media.filter + +Filter media by criteria. + +**Parameters** + +- filter (dict, required) + +**Response specification** + +- Return type: `MediaData[]` +- Data structures: `MediaData` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.filter(filter={}) +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'projectId': 'value', + 'filename': 'value', + 'originalName': 'value', + 'mimeType': 'value', + 'size': 0, + 'width': 0, + 'height': 0, + 'title': 'value', + 'alt': 'value', + 'caption': 'value', + 'author': 'value', + 'createdAt': 'value', + 'updatedAt': 'value', + 'tags': 'value' +} +] +``` + +### media.search + +Search media by free-text query. + +**Parameters** + +- query (str, required) + +**Response specification** + +- Return type: `MediaSearchResult[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.search(query='search phrase') +``` + +**Example response** + +```python +[] +``` + +### media.getByYearMonth + +Get media counts grouped by year/month. + +**Parameters** + +- None + +**Response specification** + +- Return type: `Array<{ year: number; month: number; count: number } >` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.get_by_year_month() +``` + +**Example response** + +```python +[] +``` + +### media.getTags + +Get all media tags. + +**Parameters** + +- None + +**Response specification** + +- Return type: `string[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.get_tags() +``` + +**Example response** + +```python +[] +``` + +### media.getTagsWithCounts + +Get media tags with counts. + +**Parameters** + +- None + +**Response specification** + +- Return type: `TagCount[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.media.get_tags_with_counts() +``` + +**Example response** + +```python +[] +``` + +[↑ Back to Table of contents](#table-of-contents) + +## scripts + +**Module APIs** + +- [scripts.create](#scriptscreate) +- [scripts.update](#scriptsupdate) +- [scripts.delete](#scriptsdelete) +- [scripts.get](#scriptsget) +- [scripts.getAll](#scriptsgetall) +- [scripts.rebuildFromFiles](#scriptsrebuildfromfiles) + +### scripts.create + +Create script. + +**Parameters** + +- data (dict, required) + +**Response specification** + +- Return type: `ScriptData` +- Data structures: `ScriptData` + +**Example call** + +```python +from bds_api import bds +result = await bds.scripts.create(data={}) +``` + +**Example response** + +```python +{ + 'id': 'value', + 'projectId': 'value', + 'slug': 'value', + 'title': 'value', + 'kind': None, + 'entrypoint': 'value', + 'enabled': False, + 'version': 0, + 'filePath': 'value', + 'content': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### scripts.update + +Update script by id. + +**Parameters** + +- id (str, required) +- data (dict, required) + +**Response specification** + +- Return type: `ScriptData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ScriptData` + +**Example call** + +```python +from bds_api import bds +result = await bds.scripts.update(id='id-1', data={}) +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'slug': 'value', + 'title': 'value', + 'kind': None, + 'entrypoint': 'value', + 'enabled': False, + 'version': 0, + 'filePath': 'value', + 'content': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### scripts.delete + +Delete script by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.scripts.delete(id='id-1') +``` + +**Example response** + +```python +True +``` + +### scripts.get + +Fetch script by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `ScriptData | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ScriptData` + +**Example call** + +```python +from bds_api import bds +result = await bds.scripts.get(id='id-1') +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'projectId': 'value', + 'slug': 'value', + 'title': 'value', + 'kind': None, + 'entrypoint': 'value', + 'enabled': False, + 'version': 0, + 'filePath': 'value', + 'content': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### scripts.getAll + +Fetch all scripts. + +**Parameters** + +- None + +**Response specification** + +- Return type: `ScriptData[]` +- Data structures: `ScriptData` + +**Example call** + +```python +from bds_api import bds +result = await bds.scripts.get_all() +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'projectId': 'value', + 'slug': 'value', + 'title': 'value', + 'kind': None, + 'entrypoint': 'value', + 'enabled': False, + 'version': 0, + 'filePath': 'value', + 'content': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +] +``` + +### scripts.rebuildFromFiles + +Rebuild scripts from files. + +**Parameters** + +- None + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.scripts.rebuild_from_files() +``` + +**Example response** + +```python +None +``` + +[↑ Back to Table of contents](#table-of-contents) + +## tasks + +**Module APIs** + +- [tasks.getAll](#tasksgetall) +- [tasks.getRunning](#tasksgetrunning) +- [tasks.cancel](#taskscancel) +- [tasks.clearCompleted](#tasksclearcompleted) + +### tasks.getAll + +Fetch all tasks. + +**Parameters** + +- None + +**Response specification** + +- Return type: `TaskProgress[]` +- Data structures: `TaskProgress` + +**Example call** + +```python +from bds_api import bds +result = await bds.tasks.get_all() +``` + +**Example response** + +```python +[ +{ + 'taskId': 'value', + 'name': 'value', + 'status': None, + 'progress': 0, + 'message': 'value', + 'startTime': 'value', + 'endTime': 'value', + 'error': 'value', + 'groupId': 'value', + 'groupName': 'value' +} +] +``` + +### tasks.getRunning + +Fetch running tasks. + +**Parameters** + +- None + +**Response specification** + +- Return type: `TaskProgress[]` +- Data structures: `TaskProgress` + +**Example call** + +```python +from bds_api import bds +result = await bds.tasks.get_running() +``` + +**Example response** + +```python +[ +{ + 'taskId': 'value', + 'name': 'value', + 'status': None, + 'progress': 0, + 'message': 'value', + 'startTime': 'value', + 'endTime': 'value', + 'error': 'value', + 'groupId': 'value', + 'groupName': 'value' +} +] +``` + +### tasks.cancel + +Cancel task by id. + +**Parameters** + +- taskId (str, required) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.tasks.cancel(task_id='task-1') +``` + +**Example response** + +```python +True +``` + +### tasks.clearCompleted + +Clear completed tasks. + +**Parameters** + +- None + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.tasks.clear_completed() +``` + +**Example response** + +```python +None +``` + +[↑ Back to Table of contents](#table-of-contents) + +## app + +**Module APIs** + +- [app.getDataPaths](#appgetdatapaths) +- [app.getSystemLanguage](#appgetsystemlanguage) +- [app.getTitleBarMetrics](#appgettitlebarmetrics) +- [app.openFolder](#appopenfolder) +- [app.showItemInFolder](#appshowiteminfolder) +- [app.selectFolder](#appselectfolder) +- [app.getDefaultProjectPath](#appgetdefaultprojectpath) +- [app.readProjectMetadata](#appreadprojectmetadata) +- [app.getBlogmarkBookmarklet](#appgetblogmarkbookmarklet) +- [app.copyToClipboard](#appcopytoclipboard) +- [app.notifyRendererReady](#appnotifyrendererready) +- [app.setPreviewPostTarget](#appsetpreviewposttarget) +- [app.triggerMenuAction](#apptriggermenuaction) + +### app.getDataPaths + +Get app data paths. + +**Parameters** + +- None + +**Response specification** + +- Return type: `{ database: string; posts: string; media: string }` + +**Example call** + +```python +from bds_api import bds +result = await bds.app.get_data_paths() +``` + +**Example response** + +```python +{} +``` + +### app.getSystemLanguage + +Get system language. + +**Parameters** + +- None + +**Response specification** + +- Return type: `string` + +**Example call** + +```python +from bds_api import bds +result = await bds.app.get_system_language() +``` + +**Example response** + +```python +'value' +``` + +### app.getTitleBarMetrics + +Get title bar metrics. + +**Parameters** + +- None + +**Response specification** + +- Return type: `{ macosLeftInset: number } | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.app.get_title_bar_metrics() +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### app.openFolder + +Open folder in system file manager. + +**Parameters** + +- folderPath (str, required) + +**Response specification** + +- Return type: `string` + +**Example call** + +```python +from bds_api import bds +result = await bds.app.open_folder(folder_path='folder_path') +``` + +**Example response** + +```python +'value' +``` + +### app.showItemInFolder + +Reveal item in system file manager. + +**Parameters** + +- itemPath (str, required) + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.app.show_item_in_folder(item_path='item_path') +``` + +**Example response** + +```python +None +``` + +### app.selectFolder + +Show folder picker dialog. + +**Parameters** + +- title (str, optional) + +**Response specification** + +- Return type: `string | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.app.select_folder() +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### app.getDefaultProjectPath + +Get default project path. + +**Parameters** + +- projectId (str, required) + +**Response specification** + +- Return type: `string` + +**Example call** + +```python +from bds_api import bds +result = await bds.app.get_default_project_path(project_id='project-1') +``` + +**Example response** + +```python +'value' +``` + +### app.readProjectMetadata + +Read project metadata from path. + +**Parameters** + +- folderPath (str, required) + +**Response specification** + +- Return type: `{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.app.read_project_metadata(folder_path='folder_path') +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### app.getBlogmarkBookmarklet + +Get blogmark bookmarklet script. + +**Parameters** + +- None + +**Response specification** + +- Return type: `string` + +**Example call** + +```python +from bds_api import bds +result = await bds.app.get_blogmark_bookmarklet() +``` + +**Example response** + +```python +'value' +``` + +### app.copyToClipboard + +Copy text to clipboard. + +**Parameters** + +- text (str, required) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.app.copy_to_clipboard(text='text') +``` + +**Example response** + +```python +True +``` + +### app.notifyRendererReady + +Notify main process renderer is ready. + +**Parameters** + +- None + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.app.notify_renderer_ready() +``` + +**Example response** + +```python +True +``` + +### app.setPreviewPostTarget + +Set preview post target. + +**Parameters** + +- postId (str | None, required) + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.app.set_preview_post_target(post_id=None) +``` + +**Example response** + +```python +None +``` + +### app.triggerMenuAction + +Trigger menu action. + +**Parameters** + +- action (str, required) + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.app.trigger_menu_action(action='action') +``` + +**Example response** + +```python +None +``` + +[↑ Back to Table of contents](#table-of-contents) + +## meta + +**Module APIs** + +- [meta.getTags](#metagettags) +- [meta.getCategories](#metagetcategories) +- [meta.addTag](#metaaddtag) +- [meta.removeTag](#metaremovetag) +- [meta.addCategory](#metaaddcategory) +- [meta.removeCategory](#metaremovecategory) +- [meta.syncOnStartup](#metasynconstartup) +- [meta.getProjectMetadata](#metagetprojectmetadata) +- [meta.setProjectMetadata](#metasetprojectmetadata) +- [meta.updateProjectMetadata](#metaupdateprojectmetadata) + +### meta.getTags + +Get project tags. + +**Parameters** + +- None + +**Response specification** + +- Return type: `string[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.get_tags() +``` + +**Example response** + +```python +[] +``` + +### meta.getCategories + +Get project categories. + +**Parameters** + +- None + +**Response specification** + +- Return type: `string[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.get_categories() +``` + +**Example response** + +```python +[] +``` + +### meta.addTag + +Add project tag. + +**Parameters** + +- tag (str, required) + +**Response specification** + +- Return type: `string[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.add_tag(tag='tag') +``` + +**Example response** + +```python +[] +``` + +### meta.removeTag + +Remove project tag. + +**Parameters** + +- tag (str, required) + +**Response specification** + +- Return type: `string[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.remove_tag(tag='tag') +``` + +**Example response** + +```python +[] +``` + +### meta.addCategory + +Add project category. + +**Parameters** + +- category (str, required) + +**Response specification** + +- Return type: `string[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.add_category(category='category') +``` + +**Example response** + +```python +[] +``` + +### meta.removeCategory + +Remove project category. + +**Parameters** + +- category (str, required) + +**Response specification** + +- Return type: `string[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.remove_category(category='category') +``` + +**Example response** + +```python +[] +``` + +### meta.syncOnStartup + +Sync meta values on startup. + +**Parameters** + +- None + +**Response specification** + +- Return type: `{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ProjectMetadata` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.sync_on_startup() +``` + +**Example response** + +```python +[ +{ + 'name': 'value', + 'description': 'value', + 'dataPath': 'value', + 'publicUrl': 'value', + 'mainLanguage': 'value', + 'defaultAuthor': 'value', + 'maxPostsPerPage': 0, + 'blogmarkCategory': 'value', + 'pythonRuntimeMode': None, + 'picoTheme': 'value', + 'categoryMetadata': {}, + 'categorySettings': {} +} +] +``` + +### meta.getProjectMetadata + +Read active project metadata. + +**Parameters** + +- None + +**Response specification** + +- Return type: `ProjectMetadata | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ProjectMetadata` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.get_project_metadata() +``` + +**Example response** + +```python +None # or +{ + 'name': 'value', + 'description': 'value', + 'dataPath': 'value', + 'publicUrl': 'value', + 'mainLanguage': 'value', + 'defaultAuthor': 'value', + 'maxPostsPerPage': 0, + 'blogmarkCategory': 'value', + 'pythonRuntimeMode': None, + 'picoTheme': 'value', + 'categoryMetadata': {}, + 'categorySettings': {} +} +``` + +### meta.setProjectMetadata + +Set project metadata. + +**Parameters** + +- metadata (dict, required) + +**Response specification** + +- Return type: `ProjectMetadata | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ProjectMetadata` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.set_project_metadata(metadata={}) +``` + +**Example response** + +```python +None # or +{ + 'name': 'value', + 'description': 'value', + 'dataPath': 'value', + 'publicUrl': 'value', + 'mainLanguage': 'value', + 'defaultAuthor': 'value', + 'maxPostsPerPage': 0, + 'blogmarkCategory': 'value', + 'pythonRuntimeMode': None, + 'picoTheme': 'value', + 'categoryMetadata': {}, + 'categorySettings': {} +} +``` + +### meta.updateProjectMetadata + +Update project metadata. + +**Parameters** + +- updates (dict, required) + +**Response specification** + +- Return type: `ProjectMetadata | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ProjectMetadata` + +**Example call** + +```python +from bds_api import bds +result = await bds.meta.update_project_metadata(updates={}) +``` + +**Example response** + +```python +None # or +{ + 'name': 'value', + 'description': 'value', + 'dataPath': 'value', + 'publicUrl': 'value', + 'mainLanguage': 'value', + 'defaultAuthor': 'value', + 'maxPostsPerPage': 0, + 'blogmarkCategory': 'value', + 'pythonRuntimeMode': None, + 'picoTheme': 'value', + 'categoryMetadata': {}, + 'categorySettings': {} +} +``` + +[↑ Back to Table of contents](#table-of-contents) + +## tags + +**Module APIs** + +- [tags.getAll](#tagsgetall) +- [tags.getWithCounts](#tagsgetwithcounts) +- [tags.get](#tagsget) +- [tags.getByName](#tagsgetbyname) +- [tags.create](#tagscreate) +- [tags.update](#tagsupdate) +- [tags.delete](#tagsdelete) +- [tags.merge](#tagsmerge) +- [tags.rename](#tagsrename) +- [tags.getPostsWithTag](#tagsgetpostswithtag) +- [tags.syncFromPosts](#tagssyncfromposts) + +### tags.getAll + +Fetch all tags. + +**Parameters** + +- None + +**Response specification** + +- Return type: `TagData[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.get_all() +``` + +**Example response** + +```python +[] +``` + +### tags.getWithCounts + +Fetch tags with counts. + +**Parameters** + +- None + +**Response specification** + +- Return type: `TagWithCount[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.get_with_counts() +``` + +**Example response** + +```python +[] +``` + +### tags.get + +Fetch tag by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `TagData | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.get(id='id-1') +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### tags.getByName + +Fetch tag by name. + +**Parameters** + +- name (str, required) + +**Response specification** + +- Return type: `TagData | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.get_by_name(name='name') +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### tags.create + +Create tag. + +**Parameters** + +- data (dict, required) + +**Response specification** + +- Return type: `TagData` + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.create(data={}) +``` + +**Example response** + +```python +{} +``` + +### tags.update + +Update tag by id. + +**Parameters** + +- id (str, required) +- data (dict, required) + +**Response specification** + +- Return type: `TagData | null` +- Nullability: Returns `None` when no matching value exists. + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.update(id='id-1', data={}) +``` + +**Example response** + +```python +None # or dict-like object when found +``` + +### tags.delete + +Delete tag by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `DeleteTagResult` + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.delete(id='id-1') +``` + +**Example response** + +```python +{} +``` + +### tags.merge + +Merge tags into target tag. + +**Parameters** + +- sourceTagIds (list, required) +- targetTagId (str, required) + +**Response specification** + +- Return type: `MergeTagsResult` + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.merge(source_tag_ids=[], target_tag_id='target_tag-1') +``` + +**Example response** + +```python +{} +``` + +### tags.rename + +Rename tag by id. + +**Parameters** + +- id (str, required) +- newName (str, required) + +**Response specification** + +- Return type: `RenameTagResult` + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.rename(id='id-1', new_name='new_name') +``` + +**Example response** + +```python +{} +``` + +### tags.getPostsWithTag + +Get posts using a tag. + +**Parameters** + +- tagId (str, required) + +**Response specification** + +- Return type: `string[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.get_posts_with_tag(tag_id='tag-1') +``` + +**Example response** + +```python +[] +``` + +### tags.syncFromPosts + +Sync tag index from posts. + +**Parameters** + +- None + +**Response specification** + +- Return type: `SyncTagsResult` + +**Example call** + +```python +from bds_api import bds +result = await bds.tags.sync_from_posts() +``` + +**Example response** + +```python +{} +``` + +[↑ Back to Table of contents](#table-of-contents) + +## chat + +**Module APIs** + +- [chat.checkReady](#chatcheckready) +- [chat.validateApiKey](#chatvalidateapikey) +- [chat.setApiKey](#chatsetapikey) +- [chat.getApiKey](#chatgetapikey) +- [chat.getAvailableModels](#chatgetavailablemodels) +- [chat.setDefaultModel](#chatsetdefaultmodel) +- [chat.getSystemPrompt](#chatgetsystemprompt) +- [chat.setSystemPrompt](#chatsetsystemprompt) +- [chat.getConversations](#chatgetconversations) +- [chat.createConversation](#chatcreateconversation) +- [chat.getConversation](#chatgetconversation) +- [chat.updateConversation](#chatupdateconversation) +- [chat.deleteConversation](#chatdeleteconversation) +- [chat.sendMessage](#chatsendmessage) +- [chat.abortMessage](#chatabortmessage) +- [chat.getHistory](#chatgethistory) +- [chat.clearMessages](#chatclearmessages) +- [chat.setConversationModel](#chatsetconversationmodel) +- [chat.analyzeTaxonomy](#chatanalyzetaxonomy) +- [chat.analyzeMediaImage](#chatanalyzemediaimage) + +### chat.checkReady + +Check chat backend readiness. + +**Parameters** + +- None + +**Response specification** + +- Return type: `ChatReadyStatus` +- Data structures: `ChatReadyStatus` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.check_ready() +``` + +**Example response** + +```python +{ + 'ready': False, + 'error': 'value', + 'backend': 'value' +} +``` + +### chat.validateApiKey + +Validate chat API key and list available models. + +**Parameters** + +- apiKey (str, required) + +**Response specification** + +- Return type: `{ isValid: boolean; models: ChatModel[] }` +- Data structures: `ChatModel` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.validate_api_key(api_key='api_key') +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'name': 'value', + 'provider': 'value' +} +] +``` + +### chat.setApiKey + +Store chat API key. + +**Parameters** + +- apiKey (str, required) + +**Response specification** + +- Return type: `{ success: boolean; error?: string }` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.set_api_key(api_key='api_key') +``` + +**Example response** + +```python +{} +``` + +### chat.getApiKey + +Get stored chat API key status. + +**Parameters** + +- None + +**Response specification** + +- Return type: `ChatApiKeyStatus` +- Data structures: `ChatApiKeyStatus` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.get_api_key() +``` + +**Example response** + +```python +{ + 'hasKey': False, + 'maskedKey': 'value' +} +``` + +### chat.getAvailableModels + +Get available chat models and selected default. + +**Parameters** + +- None + +**Response specification** + +- Return type: `{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }` +- Data structures: `ChatModel` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.get_available_models() +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'name': 'value', + 'provider': 'value' +} +] +``` + +### chat.setDefaultModel + +Set default chat model. + +**Parameters** + +- modelId (str, required) + +**Response specification** + +- Return type: `{ success: boolean; error?: string }` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.set_default_model(model_id='model-1') +``` + +**Example response** + +```python +{} +``` + +### chat.getSystemPrompt + +Get configured system prompt. + +**Parameters** + +- None + +**Response specification** + +- Return type: `{ success: boolean; prompt?: string; error?: string }` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.get_system_prompt() +``` + +**Example response** + +```python +{} +``` + +### chat.setSystemPrompt + +Set system prompt. + +**Parameters** + +- prompt (str, required) + +**Response specification** + +- Return type: `{ success: boolean; error?: string }` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.set_system_prompt(prompt='prompt') +``` + +**Example response** + +```python +{} +``` + +### chat.getConversations + +Fetch all chat conversations. + +**Parameters** + +- None + +**Response specification** + +- Return type: `ChatConversation[]` +- Data structures: `ChatConversation` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.get_conversations() +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'title': 'value', + 'model': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +] +``` + +### chat.createConversation + +Create a chat conversation. + +**Parameters** + +- title (str, optional) +- model (str, optional) + +**Response specification** + +- Return type: `ChatConversation` +- Data structures: `ChatConversation` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.create_conversation() +``` + +**Example response** + +```python +{ + 'id': 'value', + 'title': 'value', + 'model': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### chat.getConversation + +Fetch one chat conversation by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `ChatConversation | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ChatConversation` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.get_conversation(id='id-1') +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'title': 'value', + 'model': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### chat.updateConversation + +Update chat conversation metadata. + +**Parameters** + +- id (str, required) +- updates (dict, required) + +**Response specification** + +- Return type: `ChatConversation | null` +- Nullability: Returns `None` when no matching value exists. +- Data structures: `ChatConversation` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.update_conversation(id='id-1', updates={}) +``` + +**Example response** + +```python +None # or +{ + 'id': 'value', + 'title': 'value', + 'model': 'value', + 'createdAt': 'value', + 'updatedAt': 'value' +} +``` + +### chat.deleteConversation + +Delete chat conversation by id. + +**Parameters** + +- id (str, required) + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.delete_conversation(id='id-1') +``` + +**Example response** + +```python +True +``` + +### chat.sendMessage + +Send message to chat conversation. + +**Parameters** + +- conversationId (str, required) +- message (str, required) + +**Response specification** + +- Return type: `{ success: boolean; message?: string; error?: string }` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.send_message(conversation_id='conversation-1', message='message') +``` + +**Example response** + +```python +{} +``` + +### chat.abortMessage + +Abort active streaming chat response. + +**Parameters** + +- conversationId (str, required) + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.abort_message(conversation_id='conversation-1') +``` + +**Example response** + +```python +None +``` + +### chat.getHistory + +Get message history for conversation. + +**Parameters** + +- conversationId (str, required) + +**Response specification** + +- Return type: `ChatMessage[]` +- Data structures: `ChatMessage` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.get_history(conversation_id='conversation-1') +``` + +**Example response** + +```python +[ +{ + 'id': 'value', + 'conversationId': 'value', + 'role': None, + 'content': 'value', + 'toolCallId': 'value', + 'toolCalls': 'value', + 'createdAt': 'value' +} +] +``` + +### chat.clearMessages + +Clear messages for conversation. + +**Parameters** + +- conversationId (str, required) + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.clear_messages(conversation_id='conversation-1') +``` + +**Example response** + +```python +None +``` + +### chat.setConversationModel + +Set model for a conversation. + +**Parameters** + +- conversationId (str, required) +- modelId (str, required) + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.set_conversation_model(conversation_id='conversation-1', model_id='model-1') +``` + +**Example response** + +```python +None +``` + +### chat.analyzeTaxonomy + +Analyze categories and tags using AI. + +**Parameters** + +- categories (list, required) +- tags (list, required) +- modelId (str, required) + +**Response specification** + +- Return type: `{ success: boolean; categoryMappings?: Record; tagMappings?: Record; error?: string }` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.analyze_taxonomy(categories=[], tags=[], model_id='model-1') +``` + +**Example response** + +```python +{} +``` + +### chat.analyzeMediaImage + +Analyze media image and propose metadata. + +**Parameters** + +- mediaId (str, required) +- language (str, optional) + +**Response specification** + +- Return type: `{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }` + +**Example call** + +```python +from bds_api import bds +result = await bds.chat.analyze_media_image(media_id='media-1') +``` + +**Example response** + +```python +{} +``` + +[↑ Back to Table of contents](#table-of-contents) + +## sync + +**Module APIs** + +- [sync.configure](#syncconfigure) +- [sync.start](#syncstart) +- [sync.getStatus](#syncgetstatus) +- [sync.isConfigured](#syncisconfigured) +- [sync.getPendingCount](#syncgetpendingcount) +- [sync.getLog](#syncgetlog) +- [sync.stopAutoSync](#syncstopautosync) + +### sync.configure + +Configure sync. + +**Parameters** + +- config (dict, required) + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.sync.configure(config={}) +``` + +**Example response** + +```python +None +``` + +### sync.start + +Start sync operation. + +**Parameters** + +- direction (str, optional) + +**Response specification** + +- Return type: `SyncResult` + +**Example call** + +```python +from bds_api import bds +result = await bds.sync.start() +``` + +**Example response** + +```python +{} +``` + +### sync.getStatus + +Get sync status. + +**Parameters** + +- None + +**Response specification** + +- Return type: `'idle' | 'syncing' | 'error'` + +**Example call** + +```python +from bds_api import bds +result = await bds.sync.get_status() +``` + +**Example response** + +```python +{} +``` + +### sync.isConfigured + +Check if sync is configured. + +**Parameters** + +- None + +**Response specification** + +- Return type: `boolean` + +**Example call** + +```python +from bds_api import bds +result = await bds.sync.is_configured() +``` + +**Example response** + +```python +True +``` + +### sync.getPendingCount + +Get pending sync item count. + +**Parameters** + +- None + +**Response specification** + +- Return type: `{ posts: number; media: number }` + +**Example call** + +```python +from bds_api import bds +result = await bds.sync.get_pending_count() +``` + +**Example response** + +```python +{} +``` + +### sync.getLog + +Get sync log. + +**Parameters** + +- limit (int | float, optional) + +**Response specification** + +- Return type: `unknown[]` + +**Example call** + +```python +from bds_api import bds +result = await bds.sync.get_log() +``` + +**Example response** + +```python +[] +``` + +### sync.stopAutoSync + +Stop automatic sync. + +**Parameters** + +- None + +**Response specification** + +- Return type: `void` + +**Example call** + +```python +from bds_api import bds +result = await bds.sync.stop_auto_sync() +``` + +**Example response** + +```python +None +``` + +[↑ Back to Table of contents](#table-of-contents) + +## Data Structures + +Shared structures referenced by response types are defined once here. + +### ProjectData + +Project metadata stored in the app database. + +**Fields** + +- id (`string`, required): Unique project identifier. +- name (`string`, required): Human-readable project name. +- slug (`string`, required): URL-friendly project slug. +- description (`string`, optional): Optional project description. +- dataPath (`string`, optional): Filesystem path for project data. +- isActive (`boolean`, required): Whether this project is currently active. +- createdAt (`string`, required): Creation timestamp (ISO string). +- updatedAt (`string`, required): Last update timestamp (ISO string). + +[↑ Back to Table of contents](#table-of-contents) + +### PostData + +Canonical post object used across editor and generation flows. + +**Fields** + +- id (`string`, required): Unique post identifier. +- projectId (`string`, required): Owning project id. +- title (`string`, required): Post title. +- slug (`string`, required): URL slug used for generated routes. +- excerpt (`string`, optional): Optional short summary. +- content (`string`, required): Markdown body content. +- status (`'draft' | 'published' | 'archived'`, required): Publication lifecycle state. +- author (`string`, optional): Optional author name. +- createdAt (`string`, required): Creation timestamp (ISO string). +- updatedAt (`string`, required): Last update timestamp (ISO string). +- publishedAt (`string`, optional): Publication timestamp for published posts. +- tags (`string[]`, required): List of tag names. +- categories (`string[]`, required): List of category names. + +[↑ Back to Table of contents](#table-of-contents) + +### MediaData + +Canonical media object representing imported files and metadata. + +**Fields** + +- id (`string`, required): Unique media identifier. +- projectId (`string`, required): Owning project id. +- filename (`string`, required): Stored filename in project media folder. +- originalName (`string`, required): Original imported filename. +- mimeType (`string`, required): Detected MIME type. +- size (`number`, required): File size in bytes. +- width (`number`, optional): Image width in pixels when available. +- height (`number`, optional): Image height in pixels when available. +- title (`string`, optional): Optional display title. +- alt (`string`, optional): Optional alternative text. +- caption (`string`, optional): Optional caption text. +- author (`string`, optional): Optional author credit. +- createdAt (`string`, required): Creation timestamp (ISO string). +- updatedAt (`string`, required): Last update timestamp (ISO string). +- tags (`string[]`, required): List of media tags. + +[↑ Back to Table of contents](#table-of-contents) + +### ScriptData + +Script definition for Python macros, utilities, and transforms. + +**Fields** + +- id (`string`, required): Unique script identifier. +- projectId (`string`, required): Owning project id. +- slug (`string`, required): Stable script slug. +- title (`string`, required): Human-readable script title. +- kind (`'macro' | 'utility' | 'transform'`, required): Script category. +- entrypoint (`string`, required): Python entrypoint function name. +- enabled (`boolean`, required): Whether script is enabled. +- version (`number`, required): Incrementing script version. +- filePath (`string`, required): Filesystem path to script file. +- content (`string`, required): Script source code. +- createdAt (`string`, required): Creation timestamp (ISO string). +- updatedAt (`string`, required): Last update timestamp (ISO string). + +[↑ Back to Table of contents](#table-of-contents) + +### TaskProgress + +Task queue status object for long-running operations. + +**Fields** + +- taskId (`string`, required): Unique task identifier. +- name (`string`, required): Task display name. +- status (`'pending' | 'running' | 'completed' | 'failed' | 'cancelled'`, required): Current task status. +- progress (`number`, required): Progress percentage from 0-100. +- message (`string`, required): Current progress message. +- startTime (`string`, required): Task start time (ISO string). +- endTime (`string`, optional): Task completion time (ISO string). +- error (`string`, optional): Error message when failed. +- groupId (`string`, optional): Optional grouping id. +- groupName (`string`, optional): Optional grouping label. + +[↑ Back to Table of contents](#table-of-contents) + +### ProjectMetadata + +Extended project metadata from project settings. + +**Fields** + +- name (`string`, required): Project display name. +- description (`string`, optional): Optional project description. +- dataPath (`string`, optional): Optional custom data path. +- publicUrl (`string`, optional): Optional public site URL. +- mainLanguage (`string`, optional): Main render language code. +- defaultAuthor (`string`, optional): Default author for new posts. +- maxPostsPerPage (`number`, optional): Pagination size for generated lists. +- blogmarkCategory (`string`, optional): Default category for blogmark imports. +- pythonRuntimeMode (`'webworker' | 'main-thread'`, optional): Python runtime execution mode. +- picoTheme (`string`, optional): Preferred Pico theme token. +- categoryMetadata (`object`, optional): Category metadata keyed by category slug. +- categorySettings (`object`, optional): Category render settings keyed by category slug. + +[↑ Back to Table of contents](#table-of-contents) + +### ChatConversation + +Chat conversation container. + +**Fields** + +- id (`string`, required): Unique conversation identifier. +- title (`string`, required): Conversation title. +- model (`string`, optional): Optional model id used by this conversation. +- createdAt (`string`, required): Creation timestamp (ISO string). +- updatedAt (`string`, required): Last update timestamp (ISO string). + +[↑ Back to Table of contents](#table-of-contents) + +### ChatMessage + +Single message entry in a conversation history. + +**Fields** + +- id (`string`, required): Unique message identifier. +- conversationId (`string`, required): Owning conversation id. +- role (`'user' | 'assistant' | 'system' | 'tool'`, required): Message author role. +- content (`string`, required): Message text content. +- toolCallId (`string`, optional): Tool call id when associated with tool output. +- toolCalls (`string`, optional): Serialized tool call payload when present. +- createdAt (`string`, required): Creation timestamp (ISO string). + +[↑ Back to Table of contents](#table-of-contents) + +### ChatModel + +Available chat model descriptor. + +**Fields** + +- id (`string`, required): Model identifier. +- name (`string`, required): Human-readable model name. +- provider (`string`, optional): Model provider name. + +[↑ Back to Table of contents](#table-of-contents) + +### ChatReadyStatus + +Chat backend readiness status. + +**Fields** + +- ready (`boolean`, required): Whether chat backend is ready. +- error (`string`, optional): Error description when not ready. +- backend (`string`, optional): Selected backend identifier. + +[↑ Back to Table of contents](#table-of-contents) + +### ChatApiKeyStatus + +Stored API key state for chat provider. + +**Fields** + +- hasKey (`boolean`, required): Whether a key is configured. +- maskedKey (`string`, required): Masked key representation for UI display. + +[↑ Back to Table of contents](#table-of-contents) + +--- + +Generated from contract at 2026-02-24T00:00:00.000Z. diff --git a/package.json b/package.json index 21a7eac..282b31f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", "bench:python-runtime": "node ./node_modules/tsx/dist/cli.mjs scripts/python-runtime-benchmark.ts", + "docs:api": "node ./node_modules/tsx/dist/cli.mjs scripts/generate-api-docs.ts", "lint": "eslint \"src/renderer/**/*.{ts,tsx}\" --max-warnings 0", "lint:i18n": "eslint \"src/renderer/**/*.{ts,tsx}\" --max-warnings 0", "db:generate": "node ./node_modules/drizzle-kit/bin.cjs generate", @@ -136,6 +137,14 @@ "from": "drizzle", "to": "drizzle" }, + { + "from": "API.md", + "to": "docs/API.md" + }, + { + "from": "DOCUMENTATION.md", + "to": "docs/DOCUMENTATION.md" + }, { "from": "src/main/engine/templates", "to": "templates" diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts new file mode 100644 index 0000000..54f3a8d --- /dev/null +++ b/scripts/generate-api-docs.ts @@ -0,0 +1,12 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { generateApiDocumentationMarkdownV1 } from '../src/renderer/python/generateApiDocumentationMarkdownV1'; + +async function main(): Promise { + const outputPath = resolve(process.cwd(), 'API.md'); + const markdown = generateApiDocumentationMarkdownV1(); + await writeFile(outputPath, markdown, 'utf8'); + console.log(`Generated API documentation at ${outputPath}`); +} + +void main(); \ No newline at end of file diff --git a/src/main/shared/i18n/locales/de.json b/src/main/shared/i18n/locales/de.json index 5f3172b..295e877 100644 --- a/src/main/shared/i18n/locales/de.json +++ b/src/main/shared/i18n/locales/de.json @@ -42,6 +42,7 @@ "menu.item.validateSite": "Website validieren", "menu.item.about": "Über Blogging Desktop Server", "menu.item.openDocumentation": "Dokumentation öffnen", + "menu.item.openApiDocumentation": "API-Dokumentation", "menu.item.viewOnGitHub": "Auf GitHub ansehen", "menu.item.reportIssue": "Problem melden", "render.archive": "Archiv", diff --git a/src/main/shared/i18n/locales/en.json b/src/main/shared/i18n/locales/en.json index a99974d..d801fc6 100644 --- a/src/main/shared/i18n/locales/en.json +++ b/src/main/shared/i18n/locales/en.json @@ -42,6 +42,7 @@ "menu.item.validateSite": "Validate Site", "menu.item.about": "About Blogging Desktop Server", "menu.item.openDocumentation": "Open Documentation", + "menu.item.openApiDocumentation": "API documentation", "menu.item.viewOnGitHub": "View on GitHub", "menu.item.reportIssue": "Report Issue", "render.archive": "Archive", diff --git a/src/main/shared/i18n/locales/es.json b/src/main/shared/i18n/locales/es.json index d77db16..da940d5 100644 --- a/src/main/shared/i18n/locales/es.json +++ b/src/main/shared/i18n/locales/es.json @@ -42,6 +42,7 @@ "menu.item.validateSite": "Validar sitio", "menu.item.about": "Acerca de Blogging Desktop Server", "menu.item.openDocumentation": "Abrir documentación", + "menu.item.openApiDocumentation": "Documentación API", "menu.item.viewOnGitHub": "Ver en GitHub", "menu.item.reportIssue": "Reportar problema", "render.archive": "Archivo", diff --git a/src/main/shared/i18n/locales/fr.json b/src/main/shared/i18n/locales/fr.json index ee4a901..3badedc 100644 --- a/src/main/shared/i18n/locales/fr.json +++ b/src/main/shared/i18n/locales/fr.json @@ -42,6 +42,7 @@ "menu.item.validateSite": "Valider le site", "menu.item.about": "À propos de Blogging Desktop Server", "menu.item.openDocumentation": "Ouvrir la documentation", + "menu.item.openApiDocumentation": "Documentation API", "menu.item.viewOnGitHub": "Voir sur GitHub", "menu.item.reportIssue": "Signaler un problème", "render.archive": "Archives", diff --git a/src/main/shared/i18n/locales/it.json b/src/main/shared/i18n/locales/it.json index 70a8d30..407b4fa 100644 --- a/src/main/shared/i18n/locales/it.json +++ b/src/main/shared/i18n/locales/it.json @@ -42,6 +42,7 @@ "menu.item.validateSite": "Valida sito", "menu.item.about": "Informazioni su Blogging Desktop Server", "menu.item.openDocumentation": "Apri documentazione", + "menu.item.openApiDocumentation": "Documentazione API", "menu.item.viewOnGitHub": "Visualizza su GitHub", "menu.item.reportIssue": "Segnala problema", "render.archive": "Archivio", diff --git a/src/main/shared/menuCommands.ts b/src/main/shared/menuCommands.ts index 2f01d1d..8d240b1 100644 --- a/src/main/shared/menuCommands.ts +++ b/src/main/shared/menuCommands.ts @@ -36,6 +36,7 @@ export type AppMenuAction = | 'regenerateCalendar' | 'validateSite' | 'openDocumentation' + | 'openApiDocumentation' | 'about' | 'viewOnGitHub' | 'reportIssue'; @@ -136,6 +137,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [ items: [ { label: 'menu.item.about', action: 'about' }, { label: 'menu.item.openDocumentation', action: 'openDocumentation' }, + { label: 'menu.item.openApiDocumentation', action: 'openApiDocumentation' }, { label: '', action: 'help-separator-1', separator: true }, { label: 'menu.item.viewOnGitHub', action: 'viewOnGitHub' }, { label: 'menu.item.reportIssue', action: 'reportIssue' }, @@ -165,6 +167,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial> = regenerateCalendar: 'menu:regenerateCalendar', validateSite: 'menu:validateSite', openDocumentation: 'menu:openDocumentation', + openApiDocumentation: 'menu:openApiDocumentation', about: 'menu:about', }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 452f60e..b0167fd 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -494,6 +494,12 @@ const App: React.FC = () => { }) || (() => {}) ); + unsubscribers.push( + window.electronAPI?.on('menu:openApiDocumentation', () => { + openSingletonToolTab(openTab, 'api-documentation'); + }) || (() => {}) + ); + // Import completion event - refresh posts and media stores unsubscribers.push( window.electronAPI?.import.onComplete(async (data) => { diff --git a/src/renderer/components/DocumentationView/DocumentationView.tsx b/src/renderer/components/DocumentationView/DocumentationView.tsx index e493c6f..c346375 100644 --- a/src/renderer/components/DocumentationView/DocumentationView.tsx +++ b/src/renderer/components/DocumentationView/DocumentationView.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react'; import Markdown from 'marked-react'; import hljs from '@highlightjs/cdn-assets/es/highlight.min.js'; import type { ReactNode } from 'react'; -import documentationContent from '../../../../DOCUMENTATION.md?raw'; +import defaultDocumentationContent from '../../../../DOCUMENTATION.md?raw'; import { useAppStore } from '../../store'; import { useI18n } from '../../i18n'; import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme'; @@ -84,7 +84,17 @@ function resolveTargetHeadingInArticle(articleElement: HTMLElement, targetId: st return null; } -export const DocumentationView: React.FC = () => { +interface DocumentationViewProps { + content?: string; + titleKey?: string; + subtitleKey?: string; +} + +export const DocumentationView: React.FC = ({ + content = defaultDocumentationContent, + titleKey = 'docs.title', + subtitleKey = 'docs.subtitle', +}) => { const { t: tr } = useI18n(); const { picoTheme } = useAppStore(); const resolvedTheme = getRendererPicoTheme(picoTheme); @@ -258,8 +268,8 @@ export const DocumentationView: React.FC = () => { return (
-

{tr('docs.title')}

-

{tr('docs.subtitle')}

+

{tr(titleKey)}

+

{tr(subtitleKey)}

{ >
- {documentationContent} + {content}
diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 6b2e3d8..c7b27db 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -26,6 +26,8 @@ import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISugge import { openEntityTab } from '../../navigation/tabPolicy'; import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting'; import { useI18n } from '../../i18n'; +import documentationContent from '../../../../DOCUMENTATION.md?raw'; +import apiDocumentationContent from '../../../../API.md?raw'; import './Editor.css'; const UI_DATE_LOCALE: Record = { @@ -1781,7 +1783,20 @@ export const Editor: React.FC = () => { editorRoute.tabId && editorRoute.gitDiffResource ? : , - documentation: () => , + documentation: () => ( + + ), + 'api-documentation': () => ( + + ), 'site-validation': () => , scripts: () => , post: () => (editorRoute.tabId ? : ), diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index 75c57dc..4d37c88 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -77,6 +77,10 @@ const getTabTitle = ( return tr('docs.title'); } + if (tab.type === 'api-documentation') { + return tr('docs.apiTitle'); + } + if (tab.type === 'site-validation') { return tr('siteValidation.tabTitle'); } @@ -157,6 +161,13 @@ const getTabIcon = (tab: Tab): React.ReactNode => { ); + case 'api-documentation': + return ( + + + + + ); case 'site-validation': return ( diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 8c69fd8..9f9fc4b 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -253,6 +253,8 @@ "postLinks.openTitle": "Öffnen: {title}", "docs.title": "Dokumentation", "docs.subtitle": "Benutzerhandbuch für diese installierte bDS-Version.", + "docs.apiTitle": "API-Dokumentation", + "docs.apiSubtitle": "Vollständige Referenz aller Python-Runtime-API-Aufrufe.", "docs.copyCode": "Code kopieren", "gitDiff.header": "Unterschied: {target}", "gitDiff.noProject": "Kein aktives Projekt ausgewählt.", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 7a27b9b..b99535b 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -253,6 +253,8 @@ "postLinks.openTitle": "Open: {title}", "docs.title": "Documentation", "docs.subtitle": "User guide for this installed bDS version.", + "docs.apiTitle": "API Documentation", + "docs.apiSubtitle": "Complete reference of Python runtime API calls.", "docs.copyCode": "Copy code", "gitDiff.header": "Diff: {target}", "gitDiff.noProject": "No active project selected.", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index a2d744e..062f602 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -253,6 +253,8 @@ "postLinks.openTitle": "Abrir: {title}", "docs.title": "Documentación", "docs.subtitle": "Guía de usuario para esta versión instalada de bDS.", + "docs.apiTitle": "Documentación API", + "docs.apiSubtitle": "Referencia completa de llamadas API del runtime de Python.", "docs.copyCode": "Copiar código", "gitDiff.header": "Diferencia: {target}", "gitDiff.noProject": "No hay un proyecto activo seleccionado.", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 7aef400..bf3138b 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -253,6 +253,8 @@ "postLinks.openTitle": "Ouvrir: {title}", "docs.title": "Guide utilisateur", "docs.subtitle": "Guide utilisateur pour cette version installée de bDS.", + "docs.apiTitle": "Documentation API", + "docs.apiSubtitle": "Référence complète des appels API Python Runtime.", "docs.copyCode": "Copier le code", "gitDiff.header": "Diff : {target}", "gitDiff.noProject": "Aucun projet actif sélectionné.", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 58864ff..78c4abd 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -253,6 +253,8 @@ "postLinks.openTitle": "Apri: {title}", "docs.title": "Documentazione", "docs.subtitle": "Guida utente per questa versione installata di bDS.", + "docs.apiTitle": "Documentazione API", + "docs.apiSubtitle": "Riferimento completo delle chiamate API del runtime Python.", "docs.copyCode": "Copia codice", "gitDiff.header": "Differenza: {target}", "gitDiff.noProject": "Nessun progetto attivo selezionato.", diff --git a/src/renderer/navigation/editorRouting.ts b/src/renderer/navigation/editorRouting.ts index ccde8f8..d6358fd 100644 --- a/src/renderer/navigation/editorRouting.ts +++ b/src/renderer/navigation/editorRouting.ts @@ -14,6 +14,7 @@ export type EditorRoute = | 'metadata-diff' | 'git-diff' | 'documentation' + | 'api-documentation' | 'site-validation' | 'scripts'; @@ -29,6 +30,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record scripts: { type: 'scripts', id: 'scripts', isTransient: false }, 'menu-editor': { type: 'menu-editor', id: 'menu-editor', isTransient: false }, documentation: { type: 'documentation', id: 'documentation', isTransient: false }, + 'api-documentation': { type: 'api-documentation', id: 'api-documentation', isTransient: false }, 'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false }, 'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false }, }; diff --git a/src/renderer/python/PythonRuntimeManager.ts b/src/renderer/python/PythonRuntimeManager.ts index e8f0c2c..2227ef9 100644 --- a/src/renderer/python/PythonRuntimeManager.ts +++ b/src/renderer/python/PythonRuntimeManager.ts @@ -2,8 +2,14 @@ import { createPythonRuntimeWorker } from './createPythonRuntimeWorker'; import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol'; import type { PythonSyntaxError } from './runtimeProtocol'; import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1'; +import { invokePythonApiMethodV1 } from './pythonApiInvokerV1'; type WorkerFactory = () => Worker; +type PythonApiInvoker = (method: string, args: unknown) => Promise; + +interface PythonRuntimeManagerOptions { + invokeApiCall?: PythonApiInvoker; +} interface InitializeDeferred { resolve: () => void; @@ -57,8 +63,14 @@ export class PythonRuntimeManager { private requestQueue: PythonWorkerRequest[] = []; private activeRequestId: string | null = null; private requestCounter = 0; + private readonly invokeApiCall: PythonApiInvoker; - constructor(private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker) {} + constructor( + private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker, + options: PythonRuntimeManagerOptions = {} + ) { + this.invokeApiCall = options.invokeApiCall ?? invokePythonApiMethodV1; + } initialize(): Promise { if (this.ready) { @@ -262,6 +274,11 @@ export class PythonRuntimeManager { return; } + if (payload.type === 'apiCall') { + void this.handleApiCall(payload); + return; + } + const pendingRun = this.pendingRuns.get(payload.requestId); if (!pendingRun) { if (this.activeRequestId === payload.requestId && payload.type !== 'stdout') { @@ -335,6 +352,33 @@ export class PythonRuntimeManager { this.finishRequest(payload.requestId); } + private async handleApiCall(payload: Extract): Promise { + if (!this.worker || !this.ready) { + return; + } + + try { + const result = await this.invokeApiCall(payload.method, payload.args); + const response: PythonWorkerRequest = { + type: 'apiResult', + requestId: payload.requestId, + callId: payload.callId, + ok: true, + result, + }; + this.worker.postMessage(response); + } catch (error) { + const response: PythonWorkerRequest = { + type: 'apiResult', + requestId: payload.requestId, + callId: payload.callId, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + this.worker.postMessage(response); + } + } + private handleWorkerError(error: Error): void { if (this.initializeDeferred) { this.initializeDeferred.reject(error); diff --git a/src/renderer/python/generateApiDocumentationMarkdownV1.ts b/src/renderer/python/generateApiDocumentationMarkdownV1.ts new file mode 100644 index 0000000..68eb532 --- /dev/null +++ b/src/renderer/python/generateApiDocumentationMarkdownV1.ts @@ -0,0 +1,327 @@ +import { + BDS_PYTHON_API_CONTRACT_V1, + type PythonApiDataStructureContractV1, + type PythonApiParamContractV1, +} from './pythonApiContractV1'; + +function toSnakeCase(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase(); +} + +function toDocumentationHeadingSlug(value: string): string { + return value + .normalize('NFKD') + .toLowerCase() + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-+/g, '-'); +} + +function toPythonTypeName(type: PythonApiParamContractV1['type']): string { + switch (type) { + case 'string': + return 'str'; + case 'number': + return 'int | float'; + case 'boolean': + return 'bool'; + case 'object': + return 'dict'; + case 'array': + return 'list'; + case 'stringOrNull': + return 'str | None'; + case 'any': + default: + return 'Any'; + } +} + +function sampleValueForParam(param: PythonApiParamContractV1): string { + const snakeName = toSnakeCase(param.name); + + switch (param.type) { + case 'string': + if (snakeName.includes('id')) { + return `'${snakeName.replace(/_id$/, '')}-1'`; + } + if (snakeName.includes('query')) { + return `'search phrase'`; + } + return `'${snakeName}'`; + case 'number': + return '10'; + case 'boolean': + return 'True'; + case 'object': + return '{}'; + case 'array': + return '[]'; + case 'stringOrNull': + return 'None'; + case 'any': + default: + return 'None'; + } +} + +function buildExampleCall(namespace: string, method: string, params: PythonApiParamContractV1[]): string { + const pythonMethod = toSnakeCase(method); + const requiredParams = params.filter((param) => param.required); + + if (requiredParams.length === 0) { + return `result = await bds.${namespace}.${pythonMethod}()`; + } + + const args = requiredParams + .map((param) => `${toSnakeCase(param.name)}=${sampleValueForParam(param)}`) + .join(', '); + + return `result = await bds.${namespace}.${pythonMethod}(${args})`; +} + +function buildResponseExample(returnsType: string): string { + const value = returnsType.trim(); + + if (value === 'boolean') { + return 'True'; + } + + if (value === 'void') { + return 'None'; + } + + if (value.endsWith('[]') || value.startsWith('Array<')) { + return '[]'; + } + + if (value.includes(' | null')) { + return 'None # or dict-like object when found'; + } + + if (value === 'string') { + return "'value'"; + } + + return '{}'; +} + +function extractDataStructureNames(typeSignature: string): string[] { + const matches = typeSignature.match(/\b[A-Z][A-Za-z0-9_]*\b/g) ?? []; + const names = matches.filter((name) => + BDS_PYTHON_API_CONTRACT_V1.dataStructures.some((entry) => entry.name === name) + ); + + return [...new Set(names)]; +} + +function sampleValueForField(type: string): string { + const normalized = type.trim(); + + if (normalized.includes('string')) { + return "'value'"; + } + + if (normalized.includes('number')) { + return '0'; + } + + if (normalized.includes('boolean')) { + return 'False'; + } + + if (normalized.includes('[]')) { + return '[]'; + } + + if (normalized.includes('object') || normalized.includes('Record<')) { + return '{}'; + } + + return 'None'; +} + +function buildDataStructureExample(structure: PythonApiDataStructureContractV1): string { + const lines: string[] = ['{']; + + structure.fields.forEach((field, index) => { + const comma = index < structure.fields.length - 1 ? ',' : ''; + lines.push(` '${field.name}': ${sampleValueForField(field.type)}${comma}`); + }); + + lines.push('}'); + return lines.join('\n'); +} + +function buildResponseExampleWithStructure(returnsType: string): string { + const structureNames = extractDataStructureNames(returnsType); + + if (structureNames.length > 0) { + const structure = BDS_PYTHON_API_CONTRACT_V1.dataStructures.find((entry) => entry.name === structureNames[0]); + if (structure) { + if (returnsType.includes('[]') || returnsType.startsWith('Array<')) { + return `[\n${buildDataStructureExample(structure)}\n]`; + } + + if (returnsType.includes(' | null')) { + return `None # or\n${buildDataStructureExample(structure)}`; + } + + return buildDataStructureExample(structure); + } + } + + return buildResponseExample(returnsType); +} + +export function generateApiDocumentationMarkdownV1(): string { + const sections: string[] = []; + const namespaceOrder: string[] = []; + const grouped = new Map(); + + for (const method of BDS_PYTHON_API_CONTRACT_V1.methods) { + const [namespace] = method.method.split('.'); + if (!namespace) { + continue; + } + + if (!grouped.has(namespace)) { + grouped.set(namespace, []); + namespaceOrder.push(namespace); + } + + grouped.get(namespace)?.push(method); + } + + sections.push('# API Documentation'); + sections.push(''); + sections.push(`Contract version: ${BDS_PYTHON_API_CONTRACT_V1.version}`); + sections.push(''); + sections.push('This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.'); + sections.push(''); + sections.push('## Usage'); + sections.push(''); + sections.push('```python'); + sections.push('from bds_api import bds'); + sections.push(''); + sections.push('# inside an async Python function in bDS runtime:'); + sections.push("project = await bds.meta.get_project_metadata()"); + sections.push('```'); + sections.push(''); + sections.push('## Table of contents'); + sections.push(''); + + for (const namespace of namespaceOrder) { + const moduleAnchor = toDocumentationHeadingSlug(namespace); + sections.push(`- [${namespace}](#${moduleAnchor})`); + } + + sections.push('- [Data Structures](#data-structures)'); + + for (const namespace of namespaceOrder) { + const namespaceMethods = grouped.get(namespace) ?? []; + sections.push(''); + sections.push(`## ${namespace}`); + sections.push(''); + sections.push('**Module APIs**'); + sections.push(''); + + for (const method of namespaceMethods) { + const apiAnchor = toDocumentationHeadingSlug(method.method); + sections.push(`- [${method.method}](#${apiAnchor})`); + } + + for (const method of namespaceMethods) { + const [, member] = method.method.split('.'); + if (!member) { + continue; + } + + sections.push(''); + sections.push(`### ${method.method}`); + sections.push(''); + sections.push(method.description); + sections.push(''); + sections.push('**Parameters**'); + sections.push(''); + + if (method.params.length === 0) { + sections.push('- None'); + } else { + for (const param of method.params) { + const requiredState = param.required ? 'required' : 'optional'; + sections.push(`- ${param.name} (${toPythonTypeName(param.type)}, ${requiredState})`); + } + } + + sections.push(''); + sections.push('**Response specification**'); + sections.push(''); + sections.push(`- Return type: \`${method.returns}\``); + + if (method.returns.includes(' | null')) { + sections.push('- Nullability: Returns `None` when no matching value exists.'); + } + + const referencedStructures = extractDataStructureNames(method.returns); + if (referencedStructures.length > 0) { + sections.push(`- Data structures: ${referencedStructures.map((name) => `\`${name}\``).join(', ')}`); + } + + sections.push(''); + sections.push('**Example call**'); + sections.push(''); + sections.push('```python'); + sections.push('from bds_api import bds'); + sections.push(buildExampleCall(namespace, member, method.params)); + sections.push('```'); + sections.push(''); + sections.push('**Example response**'); + sections.push(''); + sections.push('```python'); + sections.push(buildResponseExampleWithStructure(method.returns)); + sections.push('```'); + } + + sections.push(''); + sections.push('[↑ Back to Table of contents](#table-of-contents)'); + } + + sections.push(''); + sections.push('## Data Structures'); + sections.push(''); + sections.push('Shared structures referenced by response types are defined once here.'); + + for (const structure of BDS_PYTHON_API_CONTRACT_V1.dataStructures) { + sections.push(''); + sections.push(`### ${structure.name}`); + sections.push(''); + sections.push(structure.description); + sections.push(''); + sections.push('**Fields**'); + sections.push(''); + + for (const field of structure.fields) { + const requiredState = field.required ? 'required' : 'optional'; + sections.push(`- ${field.name} (\`${field.type}\`, ${requiredState}): ${field.description}`); + } + + sections.push(''); + sections.push('[↑ Back to Table of contents](#table-of-contents)'); + } + + sections.push(''); + sections.push('---'); + sections.push(''); + sections.push(`Generated from contract at ${BDS_PYTHON_API_CONTRACT_V1.generatedAt}.`); + sections.push(''); + + return `${sections.join('\n').trim()}\n`; +} \ No newline at end of file diff --git a/src/renderer/python/generatePythonApiModuleV1.ts b/src/renderer/python/generatePythonApiModuleV1.ts new file mode 100644 index 0000000..7cce259 --- /dev/null +++ b/src/renderer/python/generatePythonApiModuleV1.ts @@ -0,0 +1,128 @@ +import { BDS_PYTHON_API_CONTRACT_V1 } from './pythonApiContractV1'; + +function toSnakeCase(value: string): string { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase(); +} + +function quotePython(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function buildPythonMethod(method: { + method: string; + description: string; + params: Array<{ name: string; required: boolean }>; +}): string { + const [namespace, member] = method.method.split('.'); + if (!namespace || !member) { + return ''; + } + + const pythonMethodName = toSnakeCase(member); + const pythonParams = method.params.map((param) => ({ + sourceName: param.name, + pythonName: toSnakeCase(param.name), + required: param.required, + })); + + const signature = pythonParams.length > 0 + ? `, ${pythonParams.map((param) => (param.required ? param.pythonName : `${param.pythonName}=None`)).join(', ')}` + : ''; + + const argsDict = method.params.length > 0 + ? `{ ${method.params.map((param, index) => `"${param.name}": ${pythonParams[index]?.pythonName}`).join(', ')} }` + : '{}'; + + return [ + ` async def ${pythonMethodName}(self${signature}):`, + ` \"\"\"${quotePython(method.description)}\"\"\"`, + ` return await self._transport.call("${method.method}", ${argsDict})`, + '', + ].join('\n'); +} + +function buildPythonNamespaceClass( + namespace: string, + methods: Array<{ method: string; description: string; params: Array<{ name: string; required: boolean }> }> +): string { + const className = `${namespace[0].toUpperCase()}${namespace.slice(1)}Api`; + const methodBlocks = methods.map((method) => buildPythonMethod(method)).join(''); + + return [ + `class ${className}:`, + ' def __init__(self, transport):', + ' self._transport = transport', + '', + methodBlocks.trimEnd(), + '', + ].join('\n'); +} + +export function generatePythonApiModuleV1(): string { + const namespaceMap = new Map }>>(); + + for (const method of BDS_PYTHON_API_CONTRACT_V1.methods) { + const [namespace] = method.method.split('.'); + if (!namespace) { + continue; + } + + const entries = namespaceMap.get(namespace) ?? []; + entries.push({ + method: method.method, + description: method.description, + params: method.params.map((param) => ({ + name: param.name, + required: param.required, + })), + }); + namespaceMap.set(namespace, entries); + } + + const namespaceBlocks = Array.from(namespaceMap.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([namespace, methods]) => buildPythonNamespaceClass(namespace, methods)) + .join('\n'); + + const namespaceAssignments = Array.from(namespaceMap.keys()) + .sort((left, right) => left.localeCompare(right)) + .map((namespace) => ` self.${toSnakeCase(namespace)} = ${namespace[0].toUpperCase()}${namespace.slice(1)}Api(transport)`) + .join('\n'); + + return [ + '# Auto-generated by generatePythonApiModuleV1.ts', + `# Contract version: ${BDS_PYTHON_API_CONTRACT_V1.version}`, + '', + 'import json', + '', + 'class BdsApiError(Exception):', + ' pass', + '', + namespaceBlocks.trimEnd(), + '', + 'class BdsApi:', + ' def __init__(self, transport):', + ' self._transport = transport', + namespaceAssignments, + '', + 'class _Transport:', + ' def __init__(self, call_impl):', + ' self._call_impl = call_impl', + '', + ' async def call(self, method, args):', + ' raw_result = await self._call_impl(method, json.dumps(args))', + ' if raw_result is None or raw_result == "":', + ' return None', + ' return json.loads(raw_result)', + '', + 'def install_bds_api(call_impl):', + ' _transport = _Transport(call_impl)', + ' bds = BdsApi(_transport)', + ' return bds', + '', + ].join('\n'); +} \ No newline at end of file diff --git a/src/renderer/python/pythonApiContractV1.ts b/src/renderer/python/pythonApiContractV1.ts new file mode 100644 index 0000000..bc263f5 --- /dev/null +++ b/src/renderer/python/pythonApiContractV1.ts @@ -0,0 +1,381 @@ +import type { ElectronAPI } from '../../main/shared/electronApi'; + +type PythonPromiseMethodPath = { + [Group in keyof ElectronAPI]: ElectronAPI[Group] extends Record unknown> + ? { + [Method in keyof ElectronAPI[Group]]: ElectronAPI[Group][Method] extends (...args: never[]) => Promise + ? `${Extract}.${Extract}` + : never; + }[keyof ElectronAPI[Group]] + : never; +}[keyof ElectronAPI]; + +export type PythonApiParamType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'stringOrNull'; + +export interface PythonApiParamContractV1 { + name: string; + type: PythonApiParamType; + required: boolean; +} + +export interface PythonApiMethodContractV1 { + method: PythonPromiseMethodPath; + description: string; + params: PythonApiParamContractV1[]; + returns: string; +} + +export interface PythonApiDataStructureFieldContractV1 { + name: string; + type: string; + required: boolean; + description: string; +} + +export interface PythonApiDataStructureContractV1 { + name: string; + description: string; + fields: PythonApiDataStructureFieldContractV1[]; +} + +export interface PythonApiContractV1 { + version: string; + generatedAt: string; + methods: PythonApiMethodContractV1[]; + dataStructures: PythonApiDataStructureContractV1[]; +} + +const requiredString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: true }); +const optionalString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: false }); +const optionalNumber = (name: string): PythonApiParamContractV1 => ({ name, type: 'number', required: false }); +const requiredObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: true }); +const optionalObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: false }); +const requiredArray = (name: string): PythonApiParamContractV1 => ({ name, type: 'array', required: true }); +const requiredAny = (name: string): PythonApiParamContractV1 => ({ name, type: 'any', required: true }); +const requiredStringOrNull = (name: string): PythonApiParamContractV1 => ({ name, type: 'stringOrNull', required: true }); + +function method( + methodName: PythonPromiseMethodPath, + description: string, + params: PythonApiParamContractV1[], + returns: string +): PythonApiMethodContractV1 { + return { + method: methodName, + description, + params, + returns, + }; +} + +const METHODS_V1: PythonApiMethodContractV1[] = [ + method('projects.create', 'Create a project.', [requiredObject('data')], 'ProjectData'), + method('projects.update', 'Update a project by id.', [requiredString('id'), requiredObject('data')], 'ProjectData | null'), + method('projects.delete', 'Delete a project by id.', [requiredString('id')], 'boolean'), + method('projects.deleteWithData', 'Delete a project and data by id.', [requiredString('id')], 'boolean'), + method('projects.get', 'Fetch one project by id.', [requiredString('id')], 'ProjectData | null'), + method('projects.getAll', 'Fetch all projects.', [], 'ProjectData[]'), + method('projects.getActive', 'Fetch active project.', [], 'ProjectData | null'), + method('projects.setActive', 'Set active project by id.', [requiredString('id')], 'ProjectData | null'), + + method('posts.create', 'Create a post.', [requiredObject('data')], 'PostData'), + method('posts.update', 'Update a post by id.', [requiredString('id'), requiredObject('data')], 'PostData | null'), + method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'), + method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'), + method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'), + method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'), + method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'), + method('posts.publish', 'Publish a post by id.', [requiredString('id')], 'PostData | null'), + method('posts.discard', 'Discard draft changes for post.', [requiredString('id')], 'PostData | null'), + method('posts.hasPublishedVersion', 'Check if post has published version.', [requiredString('id')], 'boolean'), + method('posts.rebuildFromFiles', 'Rebuild posts database from files.', [], 'void'), + method('posts.reindexText', 'Reindex post search text.', [], 'void'), + method('posts.search', 'Search posts by free-text query.', [requiredString('query')], 'SearchResult[]'), + method('posts.filter', 'Filter posts by criteria.', [requiredObject('filter')], 'PostData[]'), + method('posts.getTags', 'Get all post tags.', [], 'string[]'), + method('posts.getCategories', 'Get all post categories.', [], 'string[]'), + method('posts.getByYearMonth', 'Get post counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'), + method('posts.getDashboardStats', 'Get post dashboard stats.', [], 'DashboardStats'), + method('posts.getTagsWithCounts', 'Get post tags with counts.', [], 'TagCount[]'), + method('posts.getCategoriesWithCounts', 'Get post categories with counts.', [], 'CategoryCount[]'), + method('posts.getLinksTo', 'Get posts linked to given post.', [requiredString('id')], 'PostData[]'), + method('posts.getLinkedBy', 'Get posts linking to given post.', [requiredString('id')], 'PostData[]'), + method('posts.rebuildLinks', 'Rebuild post link graph.', [], 'void'), + method('posts.isSlugAvailable', 'Check if post slug is available.', [requiredString('slug'), optionalString('excludePostId')], 'boolean'), + method('posts.generateUniqueSlug', 'Generate unique slug from title.', [requiredString('title'), optionalString('excludePostId')], 'string'), + + method('media.import', 'Import media file.', [requiredString('sourcePath'), optionalObject('metadata')], 'MediaData'), + method('media.update', 'Update media metadata by id.', [requiredString('id'), requiredObject('data')], 'MediaData | null'), + method('media.replaceFile', 'Replace media file by id.', [requiredString('id'), requiredString('newSourcePath')], 'MediaData | null'), + method('media.delete', 'Delete media by id.', [requiredString('id')], 'boolean'), + method('media.get', 'Fetch one media by id.', [requiredString('id')], 'MediaData | null'), + method('media.getUrl', 'Get media URL by id.', [requiredString('id')], 'string | null'), + method('media.getFilePath', 'Get media file path by id.', [requiredString('id')], 'string | null'), + method('media.getAll', 'Fetch all media.', [], 'MediaData[]'), + method('media.rebuildFromFiles', 'Rebuild media database from files.', [], 'void'), + method('media.reindexText', 'Reindex media search text.', [], 'void'), + method('media.getThumbnail', 'Get media thumbnail URL.', [requiredString('id'), optionalString('size')], 'string | null'), + method('media.regenerateThumbnails', 'Regenerate thumbnails for media.', [requiredString('id')], 'Record | null'), + method('media.regenerateMissingThumbnails', 'Regenerate all missing thumbnails.', [], '{ processed: number; generated: number; failed: number }'), + method('media.filter', 'Filter media by criteria.', [requiredObject('filter')], 'MediaData[]'), + method('media.search', 'Search media by free-text query.', [requiredString('query')], 'MediaSearchResult[]'), + method('media.getByYearMonth', 'Get media counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'), + method('media.getTags', 'Get all media tags.', [], 'string[]'), + method('media.getTagsWithCounts', 'Get media tags with counts.', [], 'TagCount[]'), + + method('scripts.create', 'Create script.', [requiredObject('data')], 'ScriptData'), + method('scripts.update', 'Update script by id.', [requiredString('id'), requiredObject('data')], 'ScriptData | null'), + method('scripts.delete', 'Delete script by id.', [requiredString('id')], 'boolean'), + method('scripts.get', 'Fetch script by id.', [requiredString('id')], 'ScriptData | null'), + method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'), + method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'), + + method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'), + method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'), + method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'), + method('tasks.clearCompleted', 'Clear completed tasks.', [], 'void'), + + method('app.getDataPaths', 'Get app data paths.', [], '{ database: string; posts: string; media: string }'), + method('app.getSystemLanguage', 'Get system language.', [], 'string'), + method('app.getTitleBarMetrics', 'Get title bar metrics.', [], '{ macosLeftInset: number } | null'), + method('app.openFolder', 'Open folder in system file manager.', [requiredString('folderPath')], 'string'), + method('app.showItemInFolder', 'Reveal item in system file manager.', [requiredString('itemPath')], 'void'), + method('app.selectFolder', 'Show folder picker dialog.', [optionalString('title')], 'string | null'), + method('app.getDefaultProjectPath', 'Get default project path.', [requiredString('projectId')], 'string'), + method('app.readProjectMetadata', 'Read project metadata from path.', [requiredString('folderPath')], '{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null'), + method('app.getBlogmarkBookmarklet', 'Get blogmark bookmarklet script.', [], 'string'), + method('app.copyToClipboard', 'Copy text to clipboard.', [requiredString('text')], 'boolean'), + method('app.notifyRendererReady', 'Notify main process renderer is ready.', [], 'boolean'), + method('app.setPreviewPostTarget', 'Set preview post target.', [requiredStringOrNull('postId')], 'void'), + method('app.triggerMenuAction', 'Trigger menu action.', [requiredString('action')], 'void'), + + method('meta.getTags', 'Get project tags.', [], 'string[]'), + method('meta.getCategories', 'Get project categories.', [], 'string[]'), + method('meta.addTag', 'Add project tag.', [requiredString('tag')], 'string[]'), + method('meta.removeTag', 'Remove project tag.', [requiredString('tag')], 'string[]'), + method('meta.addCategory', 'Add project category.', [requiredString('category')], 'string[]'), + method('meta.removeCategory', 'Remove project category.', [requiredString('category')], 'string[]'), + method('meta.syncOnStartup', 'Sync meta values on startup.', [], '{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }'), + method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'), + method('meta.setProjectMetadata', 'Set project metadata.', [requiredObject('metadata')], 'ProjectMetadata | null'), + method('meta.updateProjectMetadata', 'Update project metadata.', [requiredObject('updates')], 'ProjectMetadata | null'), + + method('tags.getAll', 'Fetch all tags.', [], 'TagData[]'), + method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'), + method('tags.get', 'Fetch tag by id.', [requiredString('id')], 'TagData | null'), + method('tags.getByName', 'Fetch tag by name.', [requiredString('name')], 'TagData | null'), + method('tags.create', 'Create tag.', [requiredObject('data')], 'TagData'), + method('tags.update', 'Update tag by id.', [requiredString('id'), requiredObject('data')], 'TagData | null'), + method('tags.delete', 'Delete tag by id.', [requiredString('id')], 'DeleteTagResult'), + method('tags.merge', 'Merge tags into target tag.', [requiredArray('sourceTagIds'), requiredString('targetTagId')], 'MergeTagsResult'), + method('tags.rename', 'Rename tag by id.', [requiredString('id'), requiredString('newName')], 'RenameTagResult'), + method('tags.getPostsWithTag', 'Get posts using a tag.', [requiredString('tagId')], 'string[]'), + method('tags.syncFromPosts', 'Sync tag index from posts.', [], 'SyncTagsResult'), + + method('chat.checkReady', 'Check chat backend readiness.', [], 'ChatReadyStatus'), + method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'), + method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'), + method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'), + method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'), + method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'), + method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'), + method('chat.setSystemPrompt', 'Set system prompt.', [requiredString('prompt')], '{ success: boolean; error?: string }'), + method('chat.getConversations', 'Fetch all chat conversations.', [], 'ChatConversation[]'), + method('chat.createConversation', 'Create a chat conversation.', [optionalString('title'), optionalString('model')], 'ChatConversation'), + method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'), + method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'), + method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'), + method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message')], '{ success: boolean; message?: string; error?: string }'), + method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'), + method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'), + method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'), + method('chat.setConversationModel', 'Set model for a conversation.', [requiredString('conversationId'), requiredString('modelId')], 'void'), + method('chat.analyzeTaxonomy', 'Analyze categories and tags using AI.', [requiredArray('categories'), requiredArray('tags'), requiredString('modelId')], '{ success: boolean; categoryMappings?: Record; tagMappings?: Record; error?: string }'), + method('chat.analyzeMediaImage', 'Analyze media image and propose metadata.', [requiredString('mediaId'), optionalString('language')], '{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }'), + + method('sync.configure', 'Configure sync.', [requiredObject('config')], 'void'), + method('sync.start', 'Start sync operation.', [optionalString('direction')], 'SyncResult'), + method('sync.getStatus', 'Get sync status.', [], "'idle' | 'syncing' | 'error'"), + method('sync.isConfigured', 'Check if sync is configured.', [], 'boolean'), + method('sync.getPendingCount', 'Get pending sync item count.', [], '{ posts: number; media: number }'), + method('sync.getLog', 'Get sync log.', [optionalNumber('limit')], 'unknown[]'), + method('sync.stopAutoSync', 'Stop automatic sync.', [], 'void'), +]; + +const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [ + { + name: 'ProjectData', + description: 'Project metadata stored in the app database.', + fields: [ + { name: 'id', type: 'string', required: true, description: 'Unique project identifier.' }, + { name: 'name', type: 'string', required: true, description: 'Human-readable project name.' }, + { name: 'slug', type: 'string', required: true, description: 'URL-friendly project slug.' }, + { name: 'description', type: 'string', required: false, description: 'Optional project description.' }, + { name: 'dataPath', type: 'string', required: false, description: 'Filesystem path for project data.' }, + { name: 'isActive', type: 'boolean', required: true, description: 'Whether this project is currently active.' }, + { name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' }, + { name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' }, + ], + }, + { + name: 'PostData', + description: 'Canonical post object used across editor and generation flows.', + fields: [ + { name: 'id', type: 'string', required: true, description: 'Unique post identifier.' }, + { name: 'projectId', type: 'string', required: true, description: 'Owning project id.' }, + { name: 'title', type: 'string', required: true, description: 'Post title.' }, + { name: 'slug', type: 'string', required: true, description: 'URL slug used for generated routes.' }, + { name: 'excerpt', type: 'string', required: false, description: 'Optional short summary.' }, + { name: 'content', type: 'string', required: true, description: 'Markdown body content.' }, + { name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' }, + { name: 'author', type: 'string', required: false, description: 'Optional author name.' }, + { name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' }, + { name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' }, + { name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' }, + { name: 'tags', type: 'string[]', required: true, description: 'List of tag names.' }, + { name: 'categories', type: 'string[]', required: true, description: 'List of category names.' }, + ], + }, + { + name: 'MediaData', + description: 'Canonical media object representing imported files and metadata.', + fields: [ + { name: 'id', type: 'string', required: true, description: 'Unique media identifier.' }, + { name: 'projectId', type: 'string', required: true, description: 'Owning project id.' }, + { name: 'filename', type: 'string', required: true, description: 'Stored filename in project media folder.' }, + { name: 'originalName', type: 'string', required: true, description: 'Original imported filename.' }, + { name: 'mimeType', type: 'string', required: true, description: 'Detected MIME type.' }, + { name: 'size', type: 'number', required: true, description: 'File size in bytes.' }, + { name: 'width', type: 'number', required: false, description: 'Image width in pixels when available.' }, + { name: 'height', type: 'number', required: false, description: 'Image height in pixels when available.' }, + { name: 'title', type: 'string', required: false, description: 'Optional display title.' }, + { name: 'alt', type: 'string', required: false, description: 'Optional alternative text.' }, + { name: 'caption', type: 'string', required: false, description: 'Optional caption text.' }, + { name: 'author', type: 'string', required: false, description: 'Optional author credit.' }, + { name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' }, + { name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' }, + { name: 'tags', type: 'string[]', required: true, description: 'List of media tags.' }, + ], + }, + { + name: 'ScriptData', + description: 'Script definition for Python macros, utilities, and transforms.', + fields: [ + { name: 'id', type: 'string', required: true, description: 'Unique script identifier.' }, + { name: 'projectId', type: 'string', required: true, description: 'Owning project id.' }, + { name: 'slug', type: 'string', required: true, description: 'Stable script slug.' }, + { name: 'title', type: 'string', required: true, description: 'Human-readable script title.' }, + { name: 'kind', type: "'macro' | 'utility' | 'transform'", required: true, description: 'Script category.' }, + { name: 'entrypoint', type: 'string', required: true, description: 'Python entrypoint function name.' }, + { name: 'enabled', type: 'boolean', required: true, description: 'Whether script is enabled.' }, + { name: 'version', type: 'number', required: true, description: 'Incrementing script version.' }, + { name: 'filePath', type: 'string', required: true, description: 'Filesystem path to script file.' }, + { name: 'content', type: 'string', required: true, description: 'Script source code.' }, + { name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' }, + { name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' }, + ], + }, + { + name: 'TaskProgress', + description: 'Task queue status object for long-running operations.', + fields: [ + { name: 'taskId', type: 'string', required: true, description: 'Unique task identifier.' }, + { name: 'name', type: 'string', required: true, description: 'Task display name.' }, + { name: 'status', type: "'pending' | 'running' | 'completed' | 'failed' | 'cancelled'", required: true, description: 'Current task status.' }, + { name: 'progress', type: 'number', required: true, description: 'Progress percentage from 0-100.' }, + { name: 'message', type: 'string', required: true, description: 'Current progress message.' }, + { name: 'startTime', type: 'string', required: true, description: 'Task start time (ISO string).' }, + { name: 'endTime', type: 'string', required: false, description: 'Task completion time (ISO string).' }, + { name: 'error', type: 'string', required: false, description: 'Error message when failed.' }, + { name: 'groupId', type: 'string', required: false, description: 'Optional grouping id.' }, + { name: 'groupName', type: 'string', required: false, description: 'Optional grouping label.' }, + ], + }, + { + name: 'ProjectMetadata', + description: 'Extended project metadata from project settings.', + fields: [ + { name: 'name', type: 'string', required: true, description: 'Project display name.' }, + { name: 'description', type: 'string', required: false, description: 'Optional project description.' }, + { name: 'dataPath', type: 'string', required: false, description: 'Optional custom data path.' }, + { name: 'publicUrl', type: 'string', required: false, description: 'Optional public site URL.' }, + { name: 'mainLanguage', type: 'string', required: false, description: 'Main render language code.' }, + { name: 'defaultAuthor', type: 'string', required: false, description: 'Default author for new posts.' }, + { name: 'maxPostsPerPage', type: 'number', required: false, description: 'Pagination size for generated lists.' }, + { name: 'blogmarkCategory', type: 'string', required: false, description: 'Default category for blogmark imports.' }, + { name: 'pythonRuntimeMode', type: "'webworker' | 'main-thread'", required: false, description: 'Python runtime execution mode.' }, + { name: 'picoTheme', type: 'string', required: false, description: 'Preferred Pico theme token.' }, + { name: 'categoryMetadata', type: 'object', required: false, description: 'Category metadata keyed by category slug.' }, + { name: 'categorySettings', type: 'object', required: false, description: 'Category render settings keyed by category slug.' }, + ], + }, + { + name: 'ChatConversation', + description: 'Chat conversation container.', + fields: [ + { name: 'id', type: 'string', required: true, description: 'Unique conversation identifier.' }, + { name: 'title', type: 'string', required: true, description: 'Conversation title.' }, + { name: 'model', type: 'string', required: false, description: 'Optional model id used by this conversation.' }, + { name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' }, + { name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' }, + ], + }, + { + name: 'ChatMessage', + description: 'Single message entry in a conversation history.', + fields: [ + { name: 'id', type: 'string', required: true, description: 'Unique message identifier.' }, + { name: 'conversationId', type: 'string', required: true, description: 'Owning conversation id.' }, + { name: 'role', type: "'user' | 'assistant' | 'system' | 'tool'", required: true, description: 'Message author role.' }, + { name: 'content', type: 'string', required: true, description: 'Message text content.' }, + { name: 'toolCallId', type: 'string', required: false, description: 'Tool call id when associated with tool output.' }, + { name: 'toolCalls', type: 'string', required: false, description: 'Serialized tool call payload when present.' }, + { name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' }, + ], + }, + { + name: 'ChatModel', + description: 'Available chat model descriptor.', + fields: [ + { name: 'id', type: 'string', required: true, description: 'Model identifier.' }, + { name: 'name', type: 'string', required: true, description: 'Human-readable model name.' }, + { name: 'provider', type: 'string', required: false, description: 'Model provider name.' }, + ], + }, + { + name: 'ChatReadyStatus', + description: 'Chat backend readiness status.', + fields: [ + { name: 'ready', type: 'boolean', required: true, description: 'Whether chat backend is ready.' }, + { name: 'error', type: 'string', required: false, description: 'Error description when not ready.' }, + { name: 'backend', type: 'string', required: false, description: 'Selected backend identifier.' }, + ], + }, + { + name: 'ChatApiKeyStatus', + description: 'Stored API key state for chat provider.', + fields: [ + { name: 'hasKey', type: 'boolean', required: true, description: 'Whether a key is configured.' }, + { name: 'maskedKey', type: 'string', required: true, description: 'Masked key representation for UI display.' }, + ], + }, +]; + +export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = { + version: '1.3.0', + generatedAt: '2026-02-24T00:00:00.000Z', + methods: METHODS_V1, + dataStructures: DATA_STRUCTURES_V1, +}; + +export function listPythonApiMethodNames(): string[] { + return BDS_PYTHON_API_CONTRACT_V1.methods.map((entry) => entry.method); +} + +export function getPythonApiMethodContract(methodName: string): PythonApiMethodContractV1 | undefined { + return BDS_PYTHON_API_CONTRACT_V1.methods.find((entry) => entry.method === methodName); +} + +export function getPythonApiDataStructureContracts(): PythonApiDataStructureContractV1[] { + return BDS_PYTHON_API_CONTRACT_V1.dataStructures; +} \ No newline at end of file diff --git a/src/renderer/python/pythonApiInvokerV1.ts b/src/renderer/python/pythonApiInvokerV1.ts new file mode 100644 index 0000000..a9eb010 --- /dev/null +++ b/src/renderer/python/pythonApiInvokerV1.ts @@ -0,0 +1,102 @@ +import { getPythonApiMethodContract, type PythonApiParamContractV1 } from './pythonApiContractV1'; + +function asRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +function getElectronApi(): Window['electronAPI'] { + if (typeof window === 'undefined' || !window.electronAPI) { + throw new Error('electronAPI is not available in renderer context'); + } + return window.electronAPI; +} + +function validateParamValue(methodName: string, param: PythonApiParamContractV1, value: unknown): void { + if (param.type === 'stringOrNull') { + if (value === null || (typeof value === 'string' && value.length > 0)) { + return; + } + throw new Error(`${methodName} requires stringOrNull arg ${param.name}`); + } + + if (value === undefined || value === null) { + if (!param.required) { + return; + } + throw new Error(`${methodName} requires ${param.type} arg ${param.name}`); + } + + if (param.type === 'any') { + return; + } + + if (param.type === 'string') { + if (typeof value === 'string' && value.length > 0) { + return; + } + throw new Error(`${methodName} requires string arg ${param.name}`); + } + + if (param.type === 'number') { + if (typeof value === 'number' && Number.isFinite(value)) { + return; + } + throw new Error(`${methodName} requires number arg ${param.name}`); + } + + if (param.type === 'boolean') { + if (typeof value === 'boolean') { + return; + } + throw new Error(`${methodName} requires boolean arg ${param.name}`); + } + + if (param.type === 'array') { + if (Array.isArray(value)) { + return; + } + throw new Error(`${methodName} requires array arg ${param.name}`); + } + + if (param.type === 'object') { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return; + } + throw new Error(`${methodName} requires object arg ${param.name}`); + } +} + +export async function invokePythonApiMethodV1(method: string, args: unknown): Promise { + const contract = getPythonApiMethodContract(method); + if (!contract) { + throw new Error(`Unsupported Python API method: ${method}`); + } + + const normalizedArgs = asRecord(args); + const electronApi = getElectronApi(); + const [namespace, member] = contract.method.split('.'); + if (!namespace || !member) { + throw new Error(`Unsupported Python API method: ${method}`); + } + + const namespaceRecord = (electronApi as unknown as Record)[namespace]; + if (!namespaceRecord || typeof namespaceRecord !== 'object') { + throw new Error(`Unsupported Python API namespace: ${namespace}`); + } + + const callable = (namespaceRecord as Record)[member]; + if (typeof callable !== 'function') { + throw new Error(`Unsupported Python API method: ${method}`); + } + + const orderedArgs = contract.params.map((param) => { + const value = normalizedArgs[param.name]; + validateParamValue(contract.method, param, value); + return value; + }); + + return (callable as (...values: unknown[]) => Promise)(...orderedArgs); +} \ No newline at end of file diff --git a/src/renderer/python/pythonRuntime.worker.ts b/src/renderer/python/pythonRuntime.worker.ts index 1a30478..6557caf 100644 --- a/src/renderer/python/pythonRuntime.worker.ts +++ b/src/renderer/python/pythonRuntime.worker.ts @@ -3,9 +3,19 @@ import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol import { parseMacroContextV1, parseMacroResultV1 } from './abiV1'; import { resolvePyodideIndexURL } from './pyodideAssetUrl'; import { runPythonSyntaxCheck } from './pythonSyntaxCheck'; +import { generatePythonApiModuleV1 } from './generatePythonApiModuleV1'; let runtime: PyodideInterface | null = null; let activeRequestId: string | null = null; +let apiCallCounter = 0; + +interface PendingApiCall { + requestId: string; + resolve: (value: unknown) => void; + reject: (error: Error) => void; +} + +const pendingApiCalls = new Map(); function postRuntimeMessage(message: PythonWorkerMessage): void { self.postMessage(message); @@ -21,6 +31,64 @@ function toResultString(result: unknown): string { return String(result); } +function toRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +function rejectPendingApiCallsForRequest(requestId: string, message: string): void { + for (const [callId, pendingCall] of pendingApiCalls.entries()) { + if (pendingCall.requestId !== requestId) { + continue; + } + pendingApiCalls.delete(callId); + pendingCall.reject(new Error(message)); + } +} + +function requestHostApi(requestId: string, method: string, args: Record): Promise { + apiCallCounter += 1; + const callId = `api-${apiCallCounter}`; + + return new Promise((resolve, reject) => { + pendingApiCalls.set(callId, { + requestId, + resolve, + reject, + }); + + postRuntimeMessage({ + type: 'apiCall', + requestId, + callId, + method, + args, + }); + }); +} + +function handleApiResultMessage(request: PythonWorkerRequest): void { + if (request.type !== 'apiResult') { + return; + } + + const pendingCall = pendingApiCalls.get(request.callId); + if (!pendingCall) { + return; + } + + pendingApiCalls.delete(request.callId); + + if (request.ok) { + pendingCall.resolve(request.result); + return; + } + + pendingCall.reject(new Error(request.error ?? 'Host API call failed')); +} + async function runPythonCode(code: string, cacheKey?: string): Promise { if (!runtime) { throw new Error('Python runtime is not ready'); @@ -83,9 +151,11 @@ __bds_target() result: toResultString(result), }); } catch (error) { + rejectPendingApiCallsForRequest(request.requestId, 'Python script execution failed'); const message = error instanceof Error ? error.message : String(error); postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message }); } finally { + rejectPendingApiCallsForRequest(request.requestId, 'Python script execution finished'); activeRequestId = null; } } @@ -125,9 +195,11 @@ json.dumps(render(__bds_context_v1)) result: parsedResult, }); } catch (error) { + rejectPendingApiCallsForRequest(request.requestId, 'Python macro execution failed'); const message = error instanceof Error ? error.message : String(error); postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message }); } finally { + rejectPendingApiCallsForRequest(request.requestId, 'Python macro execution finished'); activeRequestId = null; } } @@ -175,9 +247,11 @@ json.dumps(__bds_entrypoints) entrypoints, }); } catch (error) { + rejectPendingApiCallsForRequest(request.requestId, 'Entrypoint inspection failed'); const message = error instanceof Error ? error.message : String(error); postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message }); } finally { + rejectPendingApiCallsForRequest(request.requestId, 'Entrypoint inspection finished'); activeRequestId = null; } } @@ -208,9 +282,11 @@ async function syntaxCheck(request: PythonWorkerRequest): Promise { errors, }); } catch (error) { + rejectPendingApiCallsForRequest(request.requestId, 'Syntax check failed'); const message = error instanceof Error ? error.message : String(error); postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message }); } finally { + rejectPendingApiCallsForRequest(request.requestId, 'Syntax check finished'); activeRequestId = null; } } @@ -230,6 +306,41 @@ async function bootstrapRuntime(): Promise { if (!runtime) { throw new Error('Pyodide initialization returned no runtime'); } + + runtime.registerJsModule('__bds_transport', { + call_host_api: async (method: unknown, argsJson: unknown) => { + if (!activeRequestId) { + throw new Error('No active Python request for host API bridge'); + } + + if (typeof method !== 'string' || method.length === 0) { + throw new Error('Host API method must be a non-empty string'); + } + + let parsedArgs: Record = {}; + if (typeof argsJson === 'string' && argsJson.length > 0) { + const decoded = JSON.parse(argsJson); + parsedArgs = toRecord(decoded); + } + + const result = await requestHostApi(activeRequestId, method, parsedArgs); + return JSON.stringify(result ?? null); + }, + }); + + runtime.globals.set('__bds_api_module_source', generatePythonApiModuleV1()); + await runtime.runPythonAsync(` +import sys +import types + +__bds_api_module = types.ModuleType("bds_api") +exec(__bds_api_module_source, __bds_api_module.__dict__) + +from __bds_transport import call_host_api as __bds_call_host_api +__bds_api_module.bds = __bds_api_module.install_bds_api(__bds_call_host_api) +sys.modules["bds_api"] = __bds_api_module +`); + postRuntimeMessage({ type: 'ready' }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -239,6 +350,11 @@ async function bootstrapRuntime(): Promise { self.onmessage = (event: MessageEvent) => { const request = event.data; + if (request.type === 'apiResult') { + handleApiResultMessage(request); + return; + } + if (request.type === 'run') { void runScript(request); return; diff --git a/src/renderer/python/runtimeProtocol.ts b/src/renderer/python/runtimeProtocol.ts index 4d46735..462d64a 100644 --- a/src/renderer/python/runtimeProtocol.ts +++ b/src/renderer/python/runtimeProtocol.ts @@ -8,6 +8,14 @@ export type PythonWorkerRequest = cacheKey?: string; entrypoint?: string; } + | { + type: 'apiResult'; + requestId: string; + callId: string; + ok: boolean; + result?: unknown; + error?: string; + } | { type: 'renderMacroV1'; requestId: string; @@ -40,6 +48,7 @@ export type PythonWorkerMessage = | { type: 'ready' } | { type: 'error'; error: string } | { type: 'stdout'; requestId: string; chunk: string } + | { type: 'apiCall'; requestId: string; callId: string; method: string; args: Record } | { type: 'runResult'; requestId: string; result: string } | { type: 'entrypoints'; requestId: string; entrypoints: string[] } | { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] } diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index 3b53313..d4c2fbe 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -13,7 +13,7 @@ import type { const STORAGE_KEY = 'bds-app-state'; // Tab types -export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation' | 'scripts'; +export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts'; export interface Tab { type: TabType; diff --git a/tests/engine/PackagingConfig.test.ts b/tests/engine/PackagingConfig.test.ts index 32bb6df..290ab17 100644 --- a/tests/engine/PackagingConfig.test.ts +++ b/tests/engine/PackagingConfig.test.ts @@ -52,4 +52,28 @@ describe('package.json packaging configuration', () => { expect(dependencies.uuid).toBeTypeOf('string'); expect(devDependencies.uuid).toBeUndefined(); }); + + it('copies API documentation into packaged app resources', () => { + const build = packageJson.build as Record; + const extraResources = build.extraResources as Array<{ from: string; to: string }>; + + expect(Array.isArray(extraResources)).toBe(true); + expect(extraResources).toEqual(expect.arrayContaining([ + expect.objectContaining({ + from: 'API.md', + }), + ])); + }); + + it('copies user documentation into packaged app resources', () => { + const build = packageJson.build as Record; + const extraResources = build.extraResources as Array<{ from: string; to: string }>; + + expect(Array.isArray(extraResources)).toBe(true); + expect(extraResources).toEqual(expect.arrayContaining([ + expect.objectContaining({ + from: 'DOCUMENTATION.md', + }), + ])); + }); }); diff --git a/tests/renderer/menuCommands.test.ts b/tests/renderer/menuCommands.test.ts index a081a44..24cb2ea 100644 --- a/tests/renderer/menuCommands.test.ts +++ b/tests/renderer/menuCommands.test.ts @@ -13,6 +13,17 @@ describe('Help menu documentation entry', () => { expect(APP_MENU_ACTION_EVENT_MAP.openDocumentation).toBe('menu:openDocumentation'); }); + it('includes an API documentation action in Help menu', () => { + const helpGroup = APP_MENU_GROUPS.find((group) => group.label === 'Help'); + + expect(helpGroup).toBeDefined(); + expect(helpGroup?.items.some((item) => item.action === 'openApiDocumentation')).toBe(true); + }); + + it('maps API documentation to a renderer menu event', () => { + expect(APP_MENU_ACTION_EVENT_MAP.openApiDocumentation).toBe('menu:openApiDocumentation'); + }); + it('includes Open in Browser and Open Data Folder actions in File menu', () => { const fileGroup = APP_MENU_GROUPS.find((group) => group.label === 'File'); diff --git a/tests/renderer/navigation/editorRouting.test.ts b/tests/renderer/navigation/editorRouting.test.ts index 59e6fc1..afb2e56 100644 --- a/tests/renderer/navigation/editorRouting.test.ts +++ b/tests/renderer/navigation/editorRouting.test.ts @@ -19,6 +19,7 @@ describe('editorRouting', () => { 'metadata-diff': 'metadata-diff', 'git-diff': 'git-diff', documentation: 'documentation', + 'api-documentation': 'api-documentation', 'site-validation': 'site-validation', scripts: 'scripts', }); diff --git a/tests/renderer/python/PythonRuntimeManager.test.ts b/tests/renderer/python/PythonRuntimeManager.test.ts index cf4bdfa..c2ea96c 100644 --- a/tests/renderer/python/PythonRuntimeManager.test.ts +++ b/tests/renderer/python/PythonRuntimeManager.test.ts @@ -421,4 +421,85 @@ describe('PythonRuntimeManager', () => { await expect(runPromise).rejects.toThrow('Invalid macro result'); }); + + it('handles worker apiCall by invoking host bridge and returning apiResult', async () => { + const worker = new MockWorker(); + const invokeApiCall = vi.fn().mockResolvedValue({ id: 'post-1', title: 'Hello' }); + const manager = new PythonRuntimeManager( + () => worker as unknown as Worker, + { + invokeApiCall, + } + ); + + const initPromise = manager.initialize(); + worker.emitMessage({ type: 'ready' }); + await initPromise; + + const runPromise = manager.execute('print("hello")'); + await Promise.resolve(); + + const runRequest = worker.postedMessages[0] as { requestId: string }; + worker.emitMessage({ + type: 'apiCall', + requestId: runRequest.requestId, + callId: 'call-1', + method: 'posts.get', + args: { postId: 'post-1' }, + }); + + await Promise.resolve(); + + expect(invokeApiCall).toHaveBeenCalledWith('posts.get', { postId: 'post-1' }); + expect(worker.postedMessages[1]).toEqual({ + type: 'apiResult', + requestId: runRequest.requestId, + callId: 'call-1', + ok: true, + result: { id: 'post-1', title: 'Hello' }, + }); + + worker.emitMessage({ type: 'runResult', requestId: runRequest.requestId, result: 'done' }); + await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' }); + }); + + it('returns apiResult with error when host bridge invocation fails', async () => { + const worker = new MockWorker(); + const invokeApiCall = vi.fn().mockRejectedValue(new Error('unknown api method')); + const manager = new PythonRuntimeManager( + () => worker as unknown as Worker, + { + invokeApiCall, + } + ); + + const initPromise = manager.initialize(); + worker.emitMessage({ type: 'ready' }); + await initPromise; + + const runPromise = manager.execute('print("hello")'); + await Promise.resolve(); + + const runRequest = worker.postedMessages[0] as { requestId: string }; + worker.emitMessage({ + type: 'apiCall', + requestId: runRequest.requestId, + callId: 'call-2', + method: 'posts.nonExisting', + args: {}, + }); + + await Promise.resolve(); + + expect(worker.postedMessages[1]).toEqual({ + type: 'apiResult', + requestId: runRequest.requestId, + callId: 'call-2', + ok: false, + error: 'unknown api method', + }); + + worker.emitMessage({ type: 'runResult', requestId: runRequest.requestId, result: 'done' }); + await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' }); + }); }); diff --git a/tests/renderer/python/apiDocumentationSync.test.ts b/tests/renderer/python/apiDocumentationSync.test.ts new file mode 100644 index 0000000..e869bd9 --- /dev/null +++ b/tests/renderer/python/apiDocumentationSync.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { generateApiDocumentationMarkdownV1 } from '../../../src/renderer/python/generateApiDocumentationMarkdownV1'; + +describe('API documentation markdown sync', () => { + it('matches generated contract documentation', () => { + const apiMarkdownPath = resolve(process.cwd(), 'API.md'); + const committedMarkdown = readFileSync(apiMarkdownPath, 'utf8'); + const generatedMarkdown = generateApiDocumentationMarkdownV1(); + + expect(committedMarkdown).toBe(generatedMarkdown); + }); +}); \ No newline at end of file diff --git a/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts b/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts new file mode 100644 index 0000000..2d310a5 --- /dev/null +++ b/tests/renderer/python/generateApiDocumentationMarkdownV1.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { generateApiDocumentationMarkdownV1 } from '../../../src/renderer/python/generateApiDocumentationMarkdownV1'; + +describe('generateApiDocumentationMarkdownV1', () => { + it('includes a top-level table of contents with module jump links', () => { + const markdown = generateApiDocumentationMarkdownV1(); + + expect(markdown).toContain('## Table of contents'); + expect(markdown).toContain('- [projects](#projects)'); + expect(markdown).toContain('- [posts](#posts)'); + expect(markdown).toContain('- [media](#media)'); + expect(markdown).toContain('- [Data Structures](#data-structures)'); + }); + + it('includes per-module API sub-table of contents with endpoint jump links', () => { + const markdown = generateApiDocumentationMarkdownV1(); + + expect(markdown).toContain('**Module APIs**'); + expect(markdown).toContain('- [projects.create](#projectscreate)'); + expect(markdown).toContain('- [posts.getAll](#postsgetall)'); + expect(markdown).toContain('- [media.import](#mediaimport)'); + }); + + it('includes quick links back to top navigation sections', () => { + const markdown = generateApiDocumentationMarkdownV1(); + + expect(markdown).toContain('[↑ Back to Table of contents](#table-of-contents)'); + }); + + it('documents chat APIs in a dedicated module section', () => { + const markdown = generateApiDocumentationMarkdownV1(); + + expect(markdown).toContain('## chat'); + expect(markdown).toContain('### chat.getConversations'); + expect(markdown).toContain('### chat.sendMessage'); + expect(markdown).toContain('- [chat](#chat)'); + expect(markdown).toContain('- [chat.sendMessage](#chatsendmessage)'); + }); + + it('includes a dedicated Data Structures section with core object shapes', () => { + const markdown = generateApiDocumentationMarkdownV1(); + + expect(markdown).toContain('## Data Structures'); + expect(markdown).toContain('### PostData'); + expect(markdown).toContain('- id (`string`, required)'); + expect(markdown).toContain('- title (`string`, required)'); + expect(markdown).toContain('- content (`string`, required)'); + expect(markdown).toContain('### MediaData'); + expect(markdown).toContain('- filename (`string`, required)'); + expect(markdown).toContain('- mimeType (`string`, required)'); + }); + + it('documents return type details in the response section', () => { + const markdown = generateApiDocumentationMarkdownV1(); + + expect(markdown).toContain('**Response specification**'); + expect(markdown).toContain('- Return type: `PostData | null`'); + expect(markdown).toContain('- Return type: `MediaData[]`'); + }); +}); \ No newline at end of file diff --git a/tests/renderer/python/pythonApiContractV1.test.ts b/tests/renderer/python/pythonApiContractV1.test.ts new file mode 100644 index 0000000..8feecef --- /dev/null +++ b/tests/renderer/python/pythonApiContractV1.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { + BDS_PYTHON_API_CONTRACT_V1, + getPythonApiMethodContract, + listPythonApiMethodNames, +} from '../../../src/renderer/python/pythonApiContractV1'; +import { generatePythonApiModuleV1 } from '../../../src/renderer/python/generatePythonApiModuleV1'; + +describe('pythonApiContractV1', () => { + it('exposes broad stable method names for v1 contract', () => { + const methodNames = listPythonApiMethodNames(); + + expect(methodNames.length).toBeGreaterThan(40); + expect(methodNames).toEqual(expect.arrayContaining([ + 'projects.getAll', + 'posts.get', + 'posts.getAll', + 'posts.search', + 'media.get', + 'media.search', + 'meta.getProjectMetadata', + 'tags.getAll', + 'scripts.getAll', + 'tasks.getAll', + 'app.getSystemLanguage', + 'chat.getConversations', + 'chat.sendMessage', + ])); + }); + + it('returns method contract metadata by name', () => { + expect(getPythonApiMethodContract('posts.get')).toEqual({ + method: 'posts.get', + description: 'Fetch one post by id.', + params: [ + { + name: 'postId', + type: 'string', + required: true, + }, + ], + returns: 'PostData | null', + }); + }); + + it('contains semantic version metadata for compatibility checks', () => { + expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({ + version: '1.3.0', + generatedAt: expect.any(String), + }); + }); + + it('includes canonical data structures for response documentation', () => { + expect(BDS_PYTHON_API_CONTRACT_V1.dataStructures).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'PostData' }), + expect.objectContaining({ name: 'MediaData' }), + expect.objectContaining({ name: 'ProjectData' }), + ])); + }); +}); + +describe('generatePythonApiModuleV1', () => { + it('generates python facade that hides transport details', () => { + const moduleCode = generatePythonApiModuleV1(); + + expect(moduleCode).toContain('class BdsApiError(Exception):'); + expect(moduleCode).toContain('class ProjectsApi:'); + expect(moduleCode).toContain('class PostsApi:'); + expect(moduleCode).toContain('class MediaApi:'); + expect(moduleCode).toContain('class MetaApi:'); + expect(moduleCode).toContain('class ChatApi:'); + expect(moduleCode).toContain('async def get(self, post_id):'); + expect(moduleCode).toContain('async def get_all(self, options=None):'); + expect(moduleCode).toContain('async def search(self, query):'); + expect(moduleCode).toContain('async def get_project_metadata(self):'); + expect(moduleCode).toContain('async def get_conversations(self):'); + expect(moduleCode).toContain('async def send_message(self, conversation_id, message):'); + expect(moduleCode).toContain('class BdsApi:'); + expect(moduleCode).toContain('bds = BdsApi(_transport)'); + }); +}); \ No newline at end of file diff --git a/tests/renderer/python/pythonApiInvokerV1.test.ts b/tests/renderer/python/pythonApiInvokerV1.test.ts new file mode 100644 index 0000000..4bc0bd0 --- /dev/null +++ b/tests/renderer/python/pythonApiInvokerV1.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { invokePythonApiMethodV1 } from '../../../src/renderer/python/pythonApiInvokerV1'; + +describe('invokePythonApiMethodV1', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('invokes posts.get via electronAPI with validated args', async () => { + const getPost = vi.fn().mockResolvedValue({ id: 'p1', title: 'Post 1' }); + + vi.stubGlobal('window', { + electronAPI: { + posts: { + get: getPost, + }, + }, + }); + + await expect(invokePythonApiMethodV1('posts.get', { postId: 'p1' })).resolves.toEqual({ + id: 'p1', + title: 'Post 1', + }); + expect(getPost).toHaveBeenCalledWith('p1'); + }); + + it('invokes methods from multiple namespaces via contract metadata', async () => { + const searchPosts = vi.fn().mockResolvedValue([{ id: 'p1', title: 'Hit' }]); + const getProjectMetadata = vi.fn().mockResolvedValue({ name: 'My Project' }); + const getAllProjects = vi.fn().mockResolvedValue([{ id: 'prj-1', name: 'Main' }]); + const getAllPosts = vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 }); + + vi.stubGlobal('window', { + electronAPI: { + projects: { + getAll: getAllProjects, + }, + posts: { + search: searchPosts, + getAll: getAllPosts, + }, + meta: { + getProjectMetadata, + }, + }, + }); + + await expect(invokePythonApiMethodV1('projects.getAll', {})).resolves.toEqual([{ id: 'prj-1', name: 'Main' }]); + await expect(invokePythonApiMethodV1('posts.getAll', { options: { limit: 10, offset: 5 } })).resolves.toEqual({ items: [], hasMore: false, total: 0 }); + await expect(invokePythonApiMethodV1('posts.search', { query: 'hit' })).resolves.toEqual([{ id: 'p1', title: 'Hit' }]); + await expect(invokePythonApiMethodV1('meta.getProjectMetadata', {})).resolves.toEqual({ name: 'My Project' }); + expect(getAllProjects).toHaveBeenCalledWith(); + expect(getAllPosts).toHaveBeenCalledWith({ limit: 10, offset: 5 }); + expect(searchPosts).toHaveBeenCalledWith('hit'); + expect(getProjectMetadata).toHaveBeenCalledWith(); + }); + + it('rejects unknown methods and malformed args', async () => { + vi.stubGlobal('window', { + electronAPI: { + posts: { + get: vi.fn(), + search: vi.fn(), + getAll: vi.fn(), + }, + projects: { + getAll: vi.fn(), + }, + meta: { + getProjectMetadata: vi.fn(), + }, + }, + }); + + await expect(invokePythonApiMethodV1('posts.unknown', {})).rejects.toThrow('Unsupported Python API method'); + await expect(invokePythonApiMethodV1('posts.get', {})).rejects.toThrow('posts.get requires string arg postId'); + await expect(invokePythonApiMethodV1('posts.search', { query: 1 })).rejects.toThrow('posts.search requires string arg query'); + await expect(invokePythonApiMethodV1('posts.getAll', { options: 1 })).rejects.toThrow('posts.getAll requires object arg options'); + }); +}); \ No newline at end of file