feat: completed hopefully api parity
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
714
API.md
714
API.md
@@ -1,6 +1,6 @@
|
|||||||
# API Documentation
|
# API Documentation
|
||||||
|
|
||||||
Contract version: 0.3.1
|
Contract version: 0.4.0
|
||||||
|
|
||||||
This reference documents the Lua runtime API available through `bds` in embedded bDS2 scripts.
|
This reference documents the Lua runtime API available through `bds` in embedded bDS2 scripts.
|
||||||
|
|
||||||
@@ -32,6 +32,42 @@ local meta = bds.meta.get_project_metadata()
|
|||||||
|
|
||||||
## app
|
## app
|
||||||
|
|
||||||
|
### app.copy_to_clipboard
|
||||||
|
|
||||||
|
Copy text to the system clipboard.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- text (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.copy_to_clipboard("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### app.get_blogmark_bookmarklet
|
||||||
|
|
||||||
|
Return the Blogmark bookmarklet JavaScript source.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `string`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.get_blogmark_bookmarklet()
|
||||||
|
```
|
||||||
|
|
||||||
### app.get_data_paths
|
### app.get_data_paths
|
||||||
|
|
||||||
Return filesystem paths for the current application and project data.
|
Return filesystem paths for the current application and project data.
|
||||||
@@ -86,6 +122,60 @@ Return the current UI locale.
|
|||||||
local result = bds.app.get_system_language()
|
local result = bds.app.get_system_language()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### app.get_title_bar_metrics
|
||||||
|
|
||||||
|
Return desktop title bar inset metrics when available.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.get_title_bar_metrics()
|
||||||
|
```
|
||||||
|
|
||||||
|
### app.notify_renderer_ready
|
||||||
|
|
||||||
|
Notify the host application that the renderer is ready.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.notify_renderer_ready()
|
||||||
|
```
|
||||||
|
|
||||||
|
### app.open_folder
|
||||||
|
|
||||||
|
Open a folder in the system file manager.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- folder_path (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `string`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.open_folder("value")
|
||||||
|
```
|
||||||
|
|
||||||
### app.read_project_metadata
|
### app.read_project_metadata
|
||||||
|
|
||||||
Read project metadata from a project folder path.
|
Read project metadata from a project folder path.
|
||||||
@@ -104,6 +194,78 @@ Read project metadata from a project folder path.
|
|||||||
local result = bds.app.read_project_metadata("value")
|
local result = bds.app.read_project_metadata("value")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### app.select_folder
|
||||||
|
|
||||||
|
Show the native folder picker and return the chosen path.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- title (string, optional)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `string | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.select_folder("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### app.set_preview_post_target
|
||||||
|
|
||||||
|
Set the current preview-post target used by desktop integrations.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- post_id (string, optional)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.set_preview_post_target("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### app.show_item_in_folder
|
||||||
|
|
||||||
|
Reveal a file or folder in the system file manager.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- item_path (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.show_item_in_folder("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### app.trigger_menu_action
|
||||||
|
|
||||||
|
Trigger a native menu action by action id.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- action (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.trigger_menu_action("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## chat
|
## chat
|
||||||
|
|
||||||
@@ -356,6 +518,43 @@ local result = bds.embeddings.suggest_tags("value", {})
|
|||||||
|
|
||||||
## media
|
## media
|
||||||
|
|
||||||
|
### media.delete_translation
|
||||||
|
|
||||||
|
Delete a media translation by language.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- media_id (string, required)
|
||||||
|
- language (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.delete_translation("value", "value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.filter
|
||||||
|
|
||||||
|
Filter media using year, month, tags, language, or date range fields.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- filters (table, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `MediaData[]`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.filter({})
|
||||||
|
```
|
||||||
|
|
||||||
### media.import
|
### media.import
|
||||||
|
|
||||||
Import media into the current project.
|
Import media into the current project.
|
||||||
@@ -374,6 +573,42 @@ Import media into the current project.
|
|||||||
local result = bds.media.import({})
|
local result = bds.media.import({})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### media.get_by_year_month
|
||||||
|
|
||||||
|
Get media counts grouped by year and month.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table[]`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.get_by_year_month()
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.get_file_path
|
||||||
|
|
||||||
|
Return the absolute file path for a media item.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- media_id (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `string | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.get_file_path("value")
|
||||||
|
```
|
||||||
|
|
||||||
### media.update
|
### media.update
|
||||||
|
|
||||||
Update media metadata by id.
|
Update media metadata by id.
|
||||||
@@ -447,6 +682,245 @@ Fetch all media in the current project.
|
|||||||
local result = bds.media.get_all()
|
local result = bds.media.get_all()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### media.get_tags
|
||||||
|
|
||||||
|
Return tag names used by media in the current project.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `string[]`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.get_tags()
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.get_tags_with_counts
|
||||||
|
|
||||||
|
Return media tags with usage counts.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table[]`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.get_tags_with_counts()
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.get_thumbnail
|
||||||
|
|
||||||
|
Return a media thumbnail as a data URL for the requested size.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- media_id (string, required)
|
||||||
|
- size (string, optional)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `string | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.get_thumbnail("value", "value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.get_translation
|
||||||
|
|
||||||
|
Return one media translation by language.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- media_id (string, required)
|
||||||
|
- language (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.get_translation("value", "value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.get_translations
|
||||||
|
|
||||||
|
Return all translations for a media item.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- media_id (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table[]`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.get_translations("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.get_url
|
||||||
|
|
||||||
|
Return the project-relative public URL path for a media item.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- media_id (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `string | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.get_url("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.rebuild_from_files
|
||||||
|
|
||||||
|
Rebuild media records from sidecar files on disk.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `MediaData[] | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.rebuild_from_files()
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.regenerate_missing_thumbnails
|
||||||
|
|
||||||
|
Generate thumbnails for media items that are missing them.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.regenerate_missing_thumbnails()
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.regenerate_thumbnails
|
||||||
|
|
||||||
|
Regenerate all thumbnails for one media item.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- media_id (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.regenerate_thumbnails("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.reindex_text
|
||||||
|
|
||||||
|
Reindex post and media search text for the current project.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.reindex_text()
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.replace_file
|
||||||
|
|
||||||
|
Replace the binary file behind an existing media item.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- media_id (string, required)
|
||||||
|
- source_path (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `MediaData | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.replace_file("value", "value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.search
|
||||||
|
|
||||||
|
Search media by free-text query.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- query (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `MediaData[] | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.search("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### media.upsert_translation
|
||||||
|
|
||||||
|
Create or update a media translation.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- media_id (string, required)
|
||||||
|
- language (string, required)
|
||||||
|
- data (table, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.media.upsert_translation("value", "value", {})
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## meta
|
## meta
|
||||||
|
|
||||||
@@ -648,6 +1122,24 @@ Set publishing preferences for the current project.
|
|||||||
local result = bds.meta.set_publishing_preferences({})
|
local result = bds.meta.set_publishing_preferences({})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### meta.sync_on_startup
|
||||||
|
|
||||||
|
Synchronize startup metadata state and return tags, categories, and project metadata.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.meta.sync_on_startup()
|
||||||
|
```
|
||||||
|
|
||||||
### meta.update_project_metadata
|
### meta.update_project_metadata
|
||||||
|
|
||||||
Update metadata for the current project.
|
Update metadata for the current project.
|
||||||
@@ -724,6 +1216,61 @@ Delete a post by id.
|
|||||||
local result = bds.posts.delete("value")
|
local result = bds.posts.delete("value")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### posts.discard
|
||||||
|
|
||||||
|
Discard unpublished post changes and restore the last published version from disk.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- id (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `PostData | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.discard("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### posts.filter
|
||||||
|
|
||||||
|
Filter posts using status, tags, categories, language, year, month, or date range fields.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- filters (table, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `PostData[] | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.filter({})
|
||||||
|
```
|
||||||
|
|
||||||
|
### posts.generate_unique_slug
|
||||||
|
|
||||||
|
Generate a unique slug from a title, optionally excluding one post id.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- title (string, required)
|
||||||
|
- exclude_post_id (string, optional)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `string`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.generate_unique_slug("value", "value")
|
||||||
|
```
|
||||||
|
|
||||||
### posts.get
|
### posts.get
|
||||||
|
|
||||||
Fetch one post by id.
|
Fetch one post by id.
|
||||||
@@ -778,6 +1325,42 @@ Fetch one post by slug.
|
|||||||
local result = bds.posts.get_by_slug("value")
|
local result = bds.posts.get_by_slug("value")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### posts.get_by_status
|
||||||
|
|
||||||
|
Fetch posts filtered by a specific status.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- status (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `PostData[]`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.get_by_status("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### posts.get_by_year_month
|
||||||
|
|
||||||
|
Get post counts grouped by year and month.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table[]`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.get_by_year_month()
|
||||||
|
```
|
||||||
|
|
||||||
### posts.get_categories
|
### posts.get_categories
|
||||||
|
|
||||||
Get category names used by posts in the current project.
|
Get category names used by posts in the current project.
|
||||||
@@ -814,6 +1397,79 @@ Get post categories with usage counts.
|
|||||||
local result = bds.posts.get_categories_with_counts()
|
local result = bds.posts.get_categories_with_counts()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### posts.get_dashboard_stats
|
||||||
|
|
||||||
|
Return aggregate post dashboard counts for the current project.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.get_dashboard_stats()
|
||||||
|
```
|
||||||
|
|
||||||
|
### posts.get_linked_by
|
||||||
|
|
||||||
|
Return posts that link to the given post.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- post_id (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table[]`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.get_linked_by("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### posts.get_links_to
|
||||||
|
|
||||||
|
Return posts linked from the given post.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- post_id (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table[]`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.get_links_to("value")
|
||||||
|
```
|
||||||
|
|
||||||
|
### posts.get_preview_url
|
||||||
|
|
||||||
|
Return the local preview URL for a post, optionally with draft and language query parameters.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- post_id (string, required)
|
||||||
|
- options (table, optional)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `string | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.get_preview_url("value", {})
|
||||||
|
```
|
||||||
|
|
||||||
### posts.get_tags
|
### posts.get_tags
|
||||||
|
|
||||||
Get tag names used by posts in the current project.
|
Get tag names used by posts in the current project.
|
||||||
@@ -905,6 +1561,25 @@ Check whether a post has a published version.
|
|||||||
local result = bds.posts.has_published_version("value")
|
local result = bds.posts.has_published_version("value")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### posts.is_slug_available
|
||||||
|
|
||||||
|
Return whether a slug is available in the current project, optionally excluding one post id.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- slug (string, required)
|
||||||
|
- exclude_post_id (string, optional)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.is_slug_available("value", "value")
|
||||||
|
```
|
||||||
|
|
||||||
### posts.publish
|
### posts.publish
|
||||||
|
|
||||||
Publish a post by id.
|
Publish a post by id.
|
||||||
@@ -923,6 +1598,25 @@ Publish a post by id.
|
|||||||
local result = bds.posts.publish("value")
|
local result = bds.posts.publish("value")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### posts.publish_translation
|
||||||
|
|
||||||
|
Publish one translation of a post by language.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- post_id (string, required)
|
||||||
|
- language (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `table | nil`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.publish_translation("value", "value")
|
||||||
|
```
|
||||||
|
|
||||||
### posts.rebuild_from_files
|
### posts.rebuild_from_files
|
||||||
|
|
||||||
Rebuild post records from published files.
|
Rebuild post records from published files.
|
||||||
@@ -941,6 +1635,24 @@ Rebuild post records from published files.
|
|||||||
local result = bds.posts.rebuild_from_files()
|
local result = bds.posts.rebuild_from_files()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### posts.rebuild_links
|
||||||
|
|
||||||
|
Rebuild the post link graph for the current project.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- None
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.rebuild_links()
|
||||||
|
```
|
||||||
|
|
||||||
### posts.reindex_text
|
### posts.reindex_text
|
||||||
|
|
||||||
Reindex post and media search text for the current project.
|
Reindex post and media search text for the current project.
|
||||||
|
|||||||
116
lib/bds/media.ex
116
lib/bds/media.ex
@@ -172,6 +172,83 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def delete_media_translation(media_id, language) do
|
||||||
|
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
||||||
|
|
||||||
|
case Repo.get(Media, media_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
media ->
|
||||||
|
case Repo.get_by(Translation, translation_for: media.id, language: normalized_language) do
|
||||||
|
nil ->
|
||||||
|
{:ok, false}
|
||||||
|
|
||||||
|
translation ->
|
||||||
|
project = Projects.get_project!(media.project_id)
|
||||||
|
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
Repo.delete!(translation)
|
||||||
|
delete_file_if_present(media.project_id, translation_sidecar_path(media, normalized_language))
|
||||||
|
:ok = Search.sync_media(media)
|
||||||
|
:ok = write_sidecar(project, media)
|
||||||
|
true
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:ok, deleted?} -> {:ok, deleted?}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def replace_media_file(media_id, new_source_path) do
|
||||||
|
case Repo.get(Media, media_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
media ->
|
||||||
|
project = Projects.get_project!(media.project_id)
|
||||||
|
destination = Path.join(Projects.project_data_dir(project), media.file_path)
|
||||||
|
|
||||||
|
with {:ok, binary} <- File.read(new_source_path),
|
||||||
|
{:ok, stat} <- File.stat(new_source_path) do
|
||||||
|
checksum = Base.encode16(:crypto.hash(:md5, binary), case: :lower)
|
||||||
|
|
||||||
|
if checksum == media.checksum do
|
||||||
|
{:ok, nil}
|
||||||
|
else
|
||||||
|
mime_type = media.mime_type || detect_mime(media.original_name || media.filename)
|
||||||
|
{width, height} = image_dimensions(new_source_path, mime_type)
|
||||||
|
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
:ok = File.cp(new_source_path, destination)
|
||||||
|
|
||||||
|
updated_media =
|
||||||
|
media
|
||||||
|
|> Media.changeset(%{
|
||||||
|
size: stat.size,
|
||||||
|
width: width || media.width,
|
||||||
|
height: height || media.height,
|
||||||
|
checksum: checksum,
|
||||||
|
updated_at: Persistence.now_ms()
|
||||||
|
})
|
||||||
|
|> Repo.update!()
|
||||||
|
|
||||||
|
:ok = write_sidecar(project, updated_media)
|
||||||
|
:ok = ensure_thumbnails(project, updated_media)
|
||||||
|
:ok = Search.sync_media(updated_media)
|
||||||
|
updated_media
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:ok, updated_media} -> {:ok, updated_media}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def thumbnail_paths(%Media{id: id}) do
|
def thumbnail_paths(%Media{id: id}) do
|
||||||
prefix = String.slice(id, 0, 2)
|
prefix = String.slice(id, 0, 2)
|
||||||
|
|
||||||
@@ -195,6 +272,45 @@ defmodule BDS.Media do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def regenerate_missing_thumbnails(project_id) do
|
||||||
|
project = Projects.get_project!(project_id)
|
||||||
|
|
||||||
|
Repo.all(
|
||||||
|
from(media in Media,
|
||||||
|
where: media.project_id == ^project_id,
|
||||||
|
order_by: [asc: media.created_at]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> Enum.filter(fn media ->
|
||||||
|
String.starts_with?(media.mime_type || "", "image/") and
|
||||||
|
not String.contains?(media.mime_type || "", "svg")
|
||||||
|
end)
|
||||||
|
|> Enum.reduce(%{processed: 0, generated: 0, failed: 0}, fn media, acc ->
|
||||||
|
missing_paths =
|
||||||
|
media
|
||||||
|
|> thumbnail_paths()
|
||||||
|
|> Enum.map(fn {_size, relative_path} -> Path.join(Projects.project_data_dir(project), relative_path) end)
|
||||||
|
|> Enum.reject(&File.exists?/1)
|
||||||
|
|
||||||
|
if missing_paths == [] do
|
||||||
|
%{acc | processed: acc.processed + 1}
|
||||||
|
else
|
||||||
|
try do
|
||||||
|
:ok = ensure_thumbnails(project, media)
|
||||||
|
|
||||||
|
%{
|
||||||
|
processed: acc.processed + 1,
|
||||||
|
generated: acc.generated + length(missing_paths),
|
||||||
|
failed: acc.failed
|
||||||
|
}
|
||||||
|
rescue
|
||||||
|
_error ->
|
||||||
|
%{acc | processed: acc.processed + 1, failed: acc.failed + 1}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
def rebuild_media_from_files(project_id) do
|
def rebuild_media_from_files(project_id) do
|
||||||
project = Projects.get_project!(project_id)
|
project = Projects.get_project!(project_id)
|
||||||
|
|
||||||
|
|||||||
125
lib/bds/posts.ex
125
lib/bds/posts.ex
@@ -8,6 +8,7 @@ defmodule BDS.Posts do
|
|||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
alias BDS.PostLinks
|
alias BDS.PostLinks
|
||||||
|
alias BDS.Posts.Link
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
alias BDS.Posts.Translation
|
alias BDS.Posts.Translation
|
||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
@@ -148,6 +149,28 @@ defmodule BDS.Posts do
|
|||||||
{:ok, posts}
|
{:ok, posts}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def discard_post_changes(post_id) do
|
||||||
|
case Repo.get(Post, post_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%Post{file_path: file_path} when file_path in [nil, ""] ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%Post{} = post ->
|
||||||
|
project = Projects.get_project!(post.project_id)
|
||||||
|
full_path = Path.join(Projects.project_data_dir(project), post.file_path)
|
||||||
|
|
||||||
|
if File.exists?(full_path) do
|
||||||
|
restored_post = upsert_post_from_file(post.project_id, project, full_path)
|
||||||
|
:ok = PostLinks.sync_post_links(restored_post)
|
||||||
|
{:ok, restored_post}
|
||||||
|
else
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def delete_post(post_id) do
|
def delete_post(post_id) do
|
||||||
case Repo.get(Post, post_id) do
|
case Repo.get(Post, post_id) do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -193,6 +216,108 @@ defmodule BDS.Posts do
|
|||||||
|
|
||||||
def get_post_translation!(translation_id), do: Repo.get!(Translation, translation_id)
|
def get_post_translation!(translation_id), do: Repo.get!(Translation, translation_id)
|
||||||
|
|
||||||
|
def publish_post_translation(post_id, language) do
|
||||||
|
normalized_language = language |> to_string() |> String.trim() |> String.downcase()
|
||||||
|
|
||||||
|
case Repo.get_by(Translation, translation_for: post_id, language: normalized_language) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%Translation{} ->
|
||||||
|
with {:ok, _post} <- publish_post(post_id),
|
||||||
|
%Translation{} = translation <- Repo.get_by(Translation, translation_for: post_id, language: normalized_language) do
|
||||||
|
{:ok, translation}
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def slug_available(project_id, slug, exclude_post_id \\ nil) do
|
||||||
|
normalized_slug = slug |> to_string() |> String.trim()
|
||||||
|
|
||||||
|
query =
|
||||||
|
from(post in Post,
|
||||||
|
where: post.project_id == ^project_id and post.slug == ^normalized_slug,
|
||||||
|
select: post.id,
|
||||||
|
limit: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
case Repo.one(query) do
|
||||||
|
nil -> true
|
||||||
|
^exclude_post_id -> true
|
||||||
|
_other -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def unique_slug_for_title(project_id, title, exclude_post_id \\ nil) do
|
||||||
|
base_slug = title |> default_slug_source() |> Slug.slugify()
|
||||||
|
|
||||||
|
if slug_available(project_id, base_slug, exclude_post_id) do
|
||||||
|
base_slug
|
||||||
|
else
|
||||||
|
Stream.iterate(2, &(&1 + 1))
|
||||||
|
|> Enum.find_value(fn counter ->
|
||||||
|
candidate = "#{base_slug}-#{counter}"
|
||||||
|
if slug_available(project_id, candidate, exclude_post_id), do: candidate, else: nil
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dashboard_stats(project_id) do
|
||||||
|
Repo.all(
|
||||||
|
from(post in Post,
|
||||||
|
where: post.project_id == ^project_id,
|
||||||
|
select: post.status
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> Enum.reduce(
|
||||||
|
%{total_posts: 0, draft_count: 0, published_count: 0, archived_count: 0},
|
||||||
|
fn status, acc ->
|
||||||
|
acc
|
||||||
|
|> Map.update!(:total_posts, &(&1 + 1))
|
||||||
|
|> case do
|
||||||
|
counts when status == :draft -> Map.update!(counts, :draft_count, &(&1 + 1))
|
||||||
|
counts when status == :published -> Map.update!(counts, :published_count, &(&1 + 1))
|
||||||
|
counts when status == :archived -> Map.update!(counts, :archived_count, &(&1 + 1))
|
||||||
|
counts -> counts
|
||||||
|
end
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_counts_by_year_month(project_id) do
|
||||||
|
Repo.all(
|
||||||
|
from(post in Post,
|
||||||
|
where: post.project_id == ^project_id,
|
||||||
|
select: post.created_at
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> Enum.reduce(%{}, fn created_at, acc ->
|
||||||
|
datetime = DateTime.from_unix!(created_at, :millisecond)
|
||||||
|
key = {datetime.year, datetime.month}
|
||||||
|
Map.update(acc, key, 1, &(&1 + 1))
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn {{year, month}, count} -> %{year: year, month: month, count: count} end)
|
||||||
|
|> Enum.sort_by(fn %{year: year, month: month} -> {-year, -month} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def rebuild_post_links(project_id) do
|
||||||
|
post_ids = Repo.all(from(post in Post, where: post.project_id == ^project_id, select: post.id))
|
||||||
|
|
||||||
|
Repo.delete_all(
|
||||||
|
from(link in Link,
|
||||||
|
where: link.source_post_id in ^post_ids or link.target_post_id in ^post_ids
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Repo.all(from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at]))
|
||||||
|
|> Enum.each(&PostLinks.sync_post_links/1)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
def list_post_translations(post_id) do
|
def list_post_translations(post_id) do
|
||||||
{:ok,
|
{:ok,
|
||||||
Repo.all(
|
Repo.all(
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ defmodule BDS.Preview do
|
|||||||
:ok <- ensure_get(method) do
|
:ok <- ensure_get(method) do
|
||||||
case query_params["post_id"] do
|
case query_params["post_id"] do
|
||||||
post_id when is_binary(post_id) ->
|
post_id when is_binary(post_id) ->
|
||||||
if String.starts_with?(request_path, "/draft/") do
|
if String.starts_with?(request_path, "/draft/") or query_params["draft"] == "true" do
|
||||||
resolve_draft_request(project_id, post_id, query_params)
|
resolve_draft_request(project_id, post_id, query_params)
|
||||||
else
|
else
|
||||||
resolve_request(state.current, request_path, query_params)
|
resolve_request(state.current, request_path, query_params)
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
defmodule BDS.Scripting.ApiDocs do
|
defmodule BDS.Scripting.ApiDocs do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
@version "0.3.1"
|
@version "0.4.0"
|
||||||
|
|
||||||
@methods [
|
@methods [
|
||||||
|
%{module: "app", name: "copy_to_clipboard", description: "Copy text to the system clipboard.", params: [%{name: "text", type: "string", required: true}], returns: "boolean"},
|
||||||
|
%{module: "app", name: "get_blogmark_bookmarklet", description: "Return the Blogmark bookmarklet JavaScript source.", params: [], returns: "string"},
|
||||||
%{module: "app", name: "get_data_paths", description: "Return filesystem paths for the current application and project data.", params: [], returns: "table"},
|
%{module: "app", name: "get_data_paths", description: "Return filesystem paths for the current application and project data.", params: [], returns: "table"},
|
||||||
%{module: "app", name: "get_default_project_path", description: "Return the current project's filesystem path.", params: [], returns: "string | nil"},
|
%{module: "app", name: "get_default_project_path", description: "Return the current project's filesystem path.", params: [], returns: "string | nil"},
|
||||||
%{module: "app", name: "get_system_language", description: "Return the current UI locale.", params: [], returns: "string | nil"},
|
%{module: "app", name: "get_system_language", description: "Return the current UI locale.", params: [], returns: "string | nil"},
|
||||||
|
%{module: "app", name: "get_title_bar_metrics", description: "Return desktop title bar inset metrics when available.", params: [], returns: "table | nil"},
|
||||||
|
%{module: "app", name: "notify_renderer_ready", description: "Notify the host application that the renderer is ready.", params: [], returns: "boolean"},
|
||||||
|
%{module: "app", name: "open_folder", description: "Open a folder in the system file manager.", params: [%{name: "folder_path", type: "string", required: true}], returns: "string"},
|
||||||
%{module: "app", name: "read_project_metadata", description: "Read project metadata from a project folder path.", params: [%{name: "folder_path", type: "string", required: true}], returns: "ProjectMetadata | nil"},
|
%{module: "app", name: "read_project_metadata", description: "Read project metadata from a project folder path.", params: [%{name: "folder_path", type: "string", required: true}], returns: "ProjectMetadata | nil"},
|
||||||
|
%{module: "app", name: "select_folder", description: "Show the native folder picker and return the chosen path.", params: [%{name: "title", type: "string", required: false}], returns: "string | nil"},
|
||||||
|
%{module: "app", name: "set_preview_post_target", description: "Set the current preview-post target used by desktop integrations.", params: [%{name: "post_id", type: "string", required: false}], returns: "boolean"},
|
||||||
|
%{module: "app", name: "show_item_in_folder", description: "Reveal a file or folder in the system file manager.", params: [%{name: "item_path", type: "string", required: true}], returns: "nil"},
|
||||||
|
%{module: "app", name: "trigger_menu_action", description: "Trigger a native menu action by action id.", params: [%{name: "action", type: "string", required: true}], returns: "nil"},
|
||||||
%{module: "projects", name: "create", description: "Create a project.", params: [%{name: "data", type: "table", required: true}], returns: "ProjectData | nil"},
|
%{module: "projects", name: "create", description: "Create a project.", params: [%{name: "data", type: "table", required: true}], returns: "ProjectData | nil"},
|
||||||
%{module: "projects", name: "delete", description: "Delete a project by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
|
%{module: "projects", name: "delete", description: "Delete a project by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
|
||||||
%{module: "projects", name: "delete_with_data", description: "Delete a project by id and remove its project directory.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
|
%{module: "projects", name: "delete_with_data", description: "Delete a project by id and remove its project directory.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
|
||||||
@@ -19,25 +28,54 @@ defmodule BDS.Scripting.ApiDocs do
|
|||||||
%{module: "posts", name: "create", description: "Create a post in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "PostData | nil"},
|
%{module: "posts", name: "create", description: "Create a post in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "PostData | nil"},
|
||||||
%{module: "posts", name: "update", description: "Update a post by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "PostData | nil"},
|
%{module: "posts", name: "update", description: "Update a post by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "PostData | nil"},
|
||||||
%{module: "posts", name: "delete", description: "Delete a post by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
|
%{module: "posts", name: "delete", description: "Delete a post by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
|
||||||
|
%{module: "posts", name: "discard", description: "Discard unpublished post changes and restore the last published version from disk.", params: [%{name: "id", type: "string", required: true}], returns: "PostData | nil"},
|
||||||
|
%{module: "posts", name: "filter", description: "Filter posts using status, tags, categories, language, year, month, or date range fields.", params: [%{name: "filters", type: "table", required: true}], returns: "PostData[] | nil"},
|
||||||
|
%{module: "posts", name: "generate_unique_slug", description: "Generate a unique slug from a title, optionally excluding one post id.", params: [%{name: "title", type: "string", required: true}, %{name: "exclude_post_id", type: "string", required: false}], returns: "string"},
|
||||||
%{module: "posts", name: "get", description: "Fetch one post by id.", params: [%{name: "id", type: "string", required: true}], returns: "PostData | nil"},
|
%{module: "posts", name: "get", description: "Fetch one post by id.", params: [%{name: "id", type: "string", required: true}], returns: "PostData | nil"},
|
||||||
%{module: "posts", name: "get_all", description: "Fetch all posts in the current project.", params: [], returns: "PostData[]"},
|
%{module: "posts", name: "get_all", description: "Fetch all posts in the current project.", params: [], returns: "PostData[]"},
|
||||||
%{module: "posts", name: "get_by_slug", description: "Fetch one post by slug.", params: [%{name: "slug", type: "string", required: true}], returns: "PostData | nil"},
|
%{module: "posts", name: "get_by_slug", description: "Fetch one post by slug.", params: [%{name: "slug", type: "string", required: true}], returns: "PostData | nil"},
|
||||||
|
%{module: "posts", name: "get_by_status", description: "Fetch posts filtered by a specific status.", params: [%{name: "status", type: "string", required: true}], returns: "PostData[]"},
|
||||||
|
%{module: "posts", name: "get_by_year_month", description: "Get post counts grouped by year and month.", params: [], returns: "table[]"},
|
||||||
%{module: "posts", name: "get_categories", description: "Get category names used by posts in the current project.", params: [], returns: "string[]"},
|
%{module: "posts", name: "get_categories", description: "Get category names used by posts in the current project.", params: [], returns: "string[]"},
|
||||||
%{module: "posts", name: "get_categories_with_counts", description: "Get post categories with usage counts.", params: [], returns: "table[]"},
|
%{module: "posts", name: "get_categories_with_counts", description: "Get post categories with usage counts.", params: [], returns: "table[]"},
|
||||||
|
%{module: "posts", name: "get_dashboard_stats", description: "Return aggregate post dashboard counts for the current project.", params: [], returns: "table"},
|
||||||
|
%{module: "posts", name: "get_linked_by", description: "Return posts that link to the given post.", params: [%{name: "post_id", type: "string", required: true}], returns: "table[]"},
|
||||||
|
%{module: "posts", name: "get_links_to", description: "Return posts linked from the given post.", params: [%{name: "post_id", type: "string", required: true}], returns: "table[]"},
|
||||||
|
%{module: "posts", name: "get_preview_url", description: "Return the local preview URL for a post, optionally with draft and language query parameters.", params: [%{name: "post_id", type: "string", required: true}, %{name: "options", type: "table", required: false}], returns: "string | nil"},
|
||||||
%{module: "posts", name: "get_tags", description: "Get tag names used by posts in the current project.", params: [], returns: "string[]"},
|
%{module: "posts", name: "get_tags", description: "Get tag names used by posts in the current project.", params: [], returns: "string[]"},
|
||||||
%{module: "posts", name: "get_tags_with_counts", description: "Get post tags with usage counts.", params: [], returns: "table[]"},
|
%{module: "posts", name: "get_tags_with_counts", description: "Get post tags with usage counts.", params: [], returns: "table[]"},
|
||||||
%{module: "posts", name: "get_translation", description: "Get a single translation for a post by language.", params: [%{name: "post_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"},
|
%{module: "posts", name: "get_translation", description: "Get a single translation for a post by language.", params: [%{name: "post_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"},
|
||||||
%{module: "posts", name: "get_translations", description: "Get all translations for a post.", params: [%{name: "post_id", type: "string", required: true}], returns: "table[]"},
|
%{module: "posts", name: "get_translations", description: "Get all translations for a post.", params: [%{name: "post_id", type: "string", required: true}], returns: "table[]"},
|
||||||
%{module: "posts", name: "has_published_version", description: "Check whether a post has a published version.", params: [%{name: "post_id", type: "string", required: true}], returns: "boolean"},
|
%{module: "posts", name: "has_published_version", description: "Check whether a post has a published version.", params: [%{name: "post_id", type: "string", required: true}], returns: "boolean"},
|
||||||
|
%{module: "posts", name: "is_slug_available", description: "Return whether a slug is available in the current project, optionally excluding one post id.", params: [%{name: "slug", type: "string", required: true}, %{name: "exclude_post_id", type: "string", required: false}], returns: "boolean"},
|
||||||
%{module: "posts", name: "publish", description: "Publish a post by id.", params: [%{name: "id", type: "string", required: true}], returns: "PostData | nil"},
|
%{module: "posts", name: "publish", description: "Publish a post by id.", params: [%{name: "id", type: "string", required: true}], returns: "PostData | nil"},
|
||||||
|
%{module: "posts", name: "publish_translation", description: "Publish one translation of a post by language.", params: [%{name: "post_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"},
|
||||||
%{module: "posts", name: "rebuild_from_files", description: "Rebuild post records from published files.", params: [], returns: "PostData[] | nil"},
|
%{module: "posts", name: "rebuild_from_files", description: "Rebuild post records from published files.", params: [], returns: "PostData[] | nil"},
|
||||||
|
%{module: "posts", name: "rebuild_links", description: "Rebuild the post link graph for the current project.", params: [], returns: "boolean"},
|
||||||
%{module: "posts", name: "reindex_text", description: "Reindex post and media search text for the current project.", params: [], returns: "boolean"},
|
%{module: "posts", name: "reindex_text", description: "Reindex post and media search text for the current project.", params: [], returns: "boolean"},
|
||||||
%{module: "posts", name: "search", description: "Search posts by free-text query.", params: [%{name: "query", type: "string", required: true}], returns: "PostData[] | nil"},
|
%{module: "posts", name: "search", description: "Search posts by free-text query.", params: [%{name: "query", type: "string", required: true}], returns: "PostData[] | nil"},
|
||||||
|
%{module: "media", name: "delete_translation", description: "Delete a media translation by language.", params: [%{name: "media_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "boolean"},
|
||||||
|
%{module: "media", name: "filter", description: "Filter media using year, month, tags, language, or date range fields.", params: [%{name: "filters", type: "table", required: true}], returns: "MediaData[]"},
|
||||||
%{module: "media", name: "import", description: "Import media into the current project.", params: [%{name: "data", type: "table", required: true}], returns: "MediaData | nil"},
|
%{module: "media", name: "import", description: "Import media into the current project.", params: [%{name: "data", type: "table", required: true}], returns: "MediaData | nil"},
|
||||||
|
%{module: "media", name: "get_by_year_month", description: "Get media counts grouped by year and month.", params: [], returns: "table[]"},
|
||||||
|
%{module: "media", name: "get_file_path", description: "Return the absolute file path for a media item.", params: [%{name: "media_id", type: "string", required: true}], returns: "string | nil"},
|
||||||
%{module: "media", name: "update", description: "Update media metadata by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "MediaData | nil"},
|
%{module: "media", name: "update", description: "Update media metadata by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "MediaData | nil"},
|
||||||
%{module: "media", name: "delete", description: "Delete a media item by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
|
%{module: "media", name: "delete", description: "Delete a media item by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
|
||||||
%{module: "media", name: "get", description: "Fetch one media item by id.", params: [%{name: "id", type: "string", required: true}], returns: "MediaData | nil"},
|
%{module: "media", name: "get", description: "Fetch one media item by id.", params: [%{name: "id", type: "string", required: true}], returns: "MediaData | nil"},
|
||||||
%{module: "media", name: "get_all", description: "Fetch all media in the current project.", params: [], returns: "MediaData[]"},
|
%{module: "media", name: "get_all", description: "Fetch all media in the current project.", params: [], returns: "MediaData[]"},
|
||||||
|
%{module: "media", name: "get_tags", description: "Return tag names used by media in the current project.", params: [], returns: "string[]"},
|
||||||
|
%{module: "media", name: "get_tags_with_counts", description: "Return media tags with usage counts.", params: [], returns: "table[]"},
|
||||||
|
%{module: "media", name: "get_thumbnail", description: "Return a media thumbnail as a data URL for the requested size.", params: [%{name: "media_id", type: "string", required: true}, %{name: "size", type: "string", required: false}], returns: "string | nil"},
|
||||||
|
%{module: "media", name: "get_translation", description: "Return one media translation by language.", params: [%{name: "media_id", type: "string", required: true}, %{name: "language", type: "string", required: true}], returns: "table | nil"},
|
||||||
|
%{module: "media", name: "get_translations", description: "Return all translations for a media item.", params: [%{name: "media_id", type: "string", required: true}], returns: "table[]"},
|
||||||
|
%{module: "media", name: "get_url", description: "Return the project-relative public URL path for a media item.", params: [%{name: "media_id", type: "string", required: true}], returns: "string | nil"},
|
||||||
|
%{module: "media", name: "rebuild_from_files", description: "Rebuild media records from sidecar files on disk.", params: [], returns: "MediaData[] | nil"},
|
||||||
|
%{module: "media", name: "regenerate_missing_thumbnails", description: "Generate thumbnails for media items that are missing them.", params: [], returns: "table"},
|
||||||
|
%{module: "media", name: "regenerate_thumbnails", description: "Regenerate all thumbnails for one media item.", params: [%{name: "media_id", type: "string", required: true}], returns: "table | nil"},
|
||||||
|
%{module: "media", name: "reindex_text", description: "Reindex post and media search text for the current project.", params: [], returns: "boolean"},
|
||||||
|
%{module: "media", name: "replace_file", description: "Replace the binary file behind an existing media item.", params: [%{name: "media_id", type: "string", required: true}, %{name: "source_path", type: "string", required: true}], returns: "MediaData | nil"},
|
||||||
|
%{module: "media", name: "search", description: "Search media by free-text query.", params: [%{name: "query", type: "string", required: true}], returns: "MediaData[] | nil"},
|
||||||
|
%{module: "media", name: "upsert_translation", description: "Create or update a media translation.", params: [%{name: "media_id", type: "string", required: true}, %{name: "language", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "table | nil"},
|
||||||
%{module: "scripts", name: "create", description: "Create a script in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "ScriptData | nil"},
|
%{module: "scripts", name: "create", description: "Create a script in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "ScriptData | nil"},
|
||||||
%{module: "scripts", name: "update", description: "Update a script by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "ScriptData | nil"},
|
%{module: "scripts", name: "update", description: "Update a script by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "ScriptData | nil"},
|
||||||
%{module: "scripts", name: "delete", description: "Delete a script by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
|
%{module: "scripts", name: "delete", description: "Delete a script by id.", params: [%{name: "id", type: "string", required: true}], returns: "boolean"},
|
||||||
@@ -65,6 +103,7 @@ defmodule BDS.Scripting.ApiDocs do
|
|||||||
%{module: "meta", name: "remove_tag", description: "Remove a tag record from the current project by name.", params: [%{name: "name", type: "string", required: true}], returns: "string[]"},
|
%{module: "meta", name: "remove_tag", description: "Remove a tag record from the current project by name.", params: [%{name: "name", type: "string", required: true}], returns: "string[]"},
|
||||||
%{module: "meta", name: "set_project_metadata", description: "Replace project metadata fields for the current project.", params: [%{name: "updates", type: "table", required: true}], returns: "ProjectMetadata | nil"},
|
%{module: "meta", name: "set_project_metadata", description: "Replace project metadata fields for the current project.", params: [%{name: "updates", type: "table", required: true}], returns: "ProjectMetadata | nil"},
|
||||||
%{module: "meta", name: "set_publishing_preferences", description: "Set publishing preferences for the current project.", params: [%{name: "prefs", type: "table", required: true}], returns: "table | nil"},
|
%{module: "meta", name: "set_publishing_preferences", description: "Set publishing preferences for the current project.", params: [%{name: "prefs", type: "table", required: true}], returns: "table | nil"},
|
||||||
|
%{module: "meta", name: "sync_on_startup", description: "Synchronize startup metadata state and return tags, categories, and project metadata.", params: [], returns: "table"},
|
||||||
%{module: "meta", name: "update_project_metadata", description: "Update metadata for the current project.", params: [%{name: "updates", type: "table", required: true}], returns: "ProjectMetadata | nil"},
|
%{module: "meta", name: "update_project_metadata", description: "Update metadata for the current project.", params: [%{name: "updates", type: "table", required: true}], returns: "ProjectMetadata | nil"},
|
||||||
%{module: "tags", name: "create", description: "Create a tag in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "TagData | nil"},
|
%{module: "tags", name: "create", description: "Create a tag in the current project.", params: [%{name: "data", type: "table", required: true}], returns: "TagData | nil"},
|
||||||
%{module: "tags", name: "update", description: "Update a tag by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "TagData | nil"},
|
%{module: "tags", name: "update", description: "Update a tag by id.", params: [%{name: "id", type: "string", required: true}, %{name: "data", type: "table", required: true}], returns: "TagData | nil"},
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
defmodule BDS.Scripting.Capabilities do
|
defmodule BDS.Scripting.Capabilities do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
@mix_env Mix.env()
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.AI
|
alias BDS.AI
|
||||||
|
alias BDS.Desktop.FolderPicker
|
||||||
|
alias BDS.Desktop.MenuBar
|
||||||
alias BDS.Embeddings
|
alias BDS.Embeddings
|
||||||
alias BDS.Git
|
alias BDS.Git
|
||||||
alias BDS.I18n
|
alias BDS.I18n
|
||||||
alias BDS.Media
|
alias BDS.Media
|
||||||
alias BDS.Media.Media, as: MediaRecord
|
alias BDS.Media.Media, as: MediaRecord
|
||||||
|
alias BDS.Media.Translation, as: MediaTranslation
|
||||||
alias BDS.Metadata
|
alias BDS.Metadata
|
||||||
alias BDS.MCP
|
alias BDS.MCP
|
||||||
alias BDS.PostLinks
|
alias BDS.PostLinks
|
||||||
alias BDS.Posts
|
alias BDS.Posts
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
alias BDS.Posts.Translation, as: PostTranslation
|
alias BDS.Posts.Translation, as: PostTranslation
|
||||||
|
alias BDS.Preview
|
||||||
alias BDS.Publishing
|
alias BDS.Publishing
|
||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Projects.Project
|
alias BDS.Projects.Project
|
||||||
@@ -31,10 +36,19 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
def for_project(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
|
def for_project(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
|
||||||
%{
|
%{
|
||||||
app: %{
|
app: %{
|
||||||
|
copy_to_clipboard: one_arg(fn text -> copy_to_clipboard(text, opts) end),
|
||||||
get_data_paths: zero_or_one_arg(fn _args -> data_paths(project_id) end),
|
get_data_paths: zero_or_one_arg(fn _args -> data_paths(project_id) end),
|
||||||
|
get_blogmark_bookmarklet: zero_or_one_arg(fn _args -> blogmark_bookmarklet() end),
|
||||||
get_system_language: zero_or_one_arg(fn _args -> I18n.current_ui_locale() end),
|
get_system_language: zero_or_one_arg(fn _args -> I18n.current_ui_locale() end),
|
||||||
get_default_project_path: zero_or_one_arg(fn _args -> project_path(project_id) end),
|
get_default_project_path: zero_or_one_arg(fn _args -> project_path(project_id) end),
|
||||||
read_project_metadata: one_arg(fn folder_path -> read_project_metadata(folder_path) end)
|
get_title_bar_metrics: zero_or_one_arg(fn _args -> title_bar_metrics(opts) end),
|
||||||
|
notify_renderer_ready: zero_or_one_arg(fn _args -> notify_renderer_ready(opts) end),
|
||||||
|
open_folder: one_arg(fn folder_path -> open_folder(folder_path, opts) end),
|
||||||
|
read_project_metadata: one_arg(fn folder_path -> read_project_metadata(folder_path) end),
|
||||||
|
select_folder: one_arg(fn title -> select_folder(title, opts) end),
|
||||||
|
set_preview_post_target: one_arg(fn post_id -> set_preview_post_target(post_id) end),
|
||||||
|
show_item_in_folder: one_arg(fn item_path -> show_item_in_folder(item_path, opts) end),
|
||||||
|
trigger_menu_action: one_arg(fn action -> trigger_menu_action(action, opts) end)
|
||||||
},
|
},
|
||||||
projects: %{
|
projects: %{
|
||||||
create: zero_or_one_arg(fn attrs -> create_project(attrs) end),
|
create: zero_or_one_arg(fn attrs -> create_project(attrs) end),
|
||||||
@@ -58,10 +72,20 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
get_tags: zero_or_one_arg(fn _args -> metadata_tags(project_id) end),
|
get_tags: zero_or_one_arg(fn _args -> metadata_tags(project_id) end),
|
||||||
remove_tag: one_arg(fn name -> remove_meta_tag(project_id, name) end),
|
remove_tag: one_arg(fn name -> remove_meta_tag(project_id, name) end),
|
||||||
set_publishing_preferences: one_arg(fn prefs -> set_publishing_preferences(project_id, prefs) end),
|
set_publishing_preferences: one_arg(fn prefs -> set_publishing_preferences(project_id, prefs) end),
|
||||||
clear_publishing_preferences: zero_or_one_arg(fn _args -> clear_publishing_preferences(project_id) end)
|
clear_publishing_preferences: zero_or_one_arg(fn _args -> clear_publishing_preferences(project_id) end),
|
||||||
|
sync_on_startup: zero_or_one_arg(fn _args -> sync_meta_on_startup(project_id) end)
|
||||||
},
|
},
|
||||||
posts: %{
|
posts: %{
|
||||||
create: one_arg(fn attrs -> create_post(project_id, attrs) end),
|
create: one_arg(fn attrs -> create_post(project_id, attrs) end),
|
||||||
|
discard: one_arg(fn post_id -> discard_post(project_id, post_id) end),
|
||||||
|
filter: one_arg(fn filters -> filter_posts(project_id, filters) end),
|
||||||
|
generate_unique_slug: two_arg(fn title, exclude_post_id -> generate_unique_post_slug(project_id, title, exclude_post_id) end),
|
||||||
|
get_by_status: one_arg(fn status -> posts_by_status(project_id, status) end),
|
||||||
|
get_by_year_month: zero_or_one_arg(fn _args -> post_counts_by_year_month(project_id) end),
|
||||||
|
get_dashboard_stats: zero_or_one_arg(fn _args -> post_dashboard_stats(project_id) end),
|
||||||
|
get_linked_by: one_arg(fn post_id -> linked_posts_for(project_id, post_id, :incoming) end),
|
||||||
|
get_links_to: one_arg(fn post_id -> linked_posts_for(project_id, post_id, :outgoing) end),
|
||||||
|
get_preview_url: two_arg(fn post_id, options -> preview_url(project_id, post_id, options) end),
|
||||||
update: two_arg(fn post_id, attrs -> update_post(project_id, post_id, attrs) end),
|
update: two_arg(fn post_id, attrs -> update_post(project_id, post_id, attrs) end),
|
||||||
delete: one_arg(fn post_id -> delete_post(project_id, post_id) end),
|
delete: one_arg(fn post_id -> delete_post(project_id, post_id) end),
|
||||||
get: one_arg(fn post_id -> load_post(project_id, post_id) end),
|
get: one_arg(fn post_id -> load_post(project_id, post_id) end),
|
||||||
@@ -74,17 +98,37 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
get_translation: two_arg(fn post_id, language -> load_post_translation(project_id, post_id, language) end),
|
get_translation: two_arg(fn post_id, language -> load_post_translation(project_id, post_id, language) end),
|
||||||
get_translations: one_arg(fn post_id -> list_post_translations(project_id, post_id) end),
|
get_translations: one_arg(fn post_id -> list_post_translations(project_id, post_id) end),
|
||||||
has_published_version: one_arg(fn post_id -> has_published_post_version(project_id, post_id) end),
|
has_published_version: one_arg(fn post_id -> has_published_post_version(project_id, post_id) end),
|
||||||
|
is_slug_available: two_arg(fn slug, exclude_post_id -> post_slug_available?(project_id, slug, exclude_post_id) end),
|
||||||
publish: one_arg(fn post_id -> publish_post(project_id, post_id) end),
|
publish: one_arg(fn post_id -> publish_post(project_id, post_id) end),
|
||||||
|
publish_translation: two_arg(fn post_id, language -> publish_post_translation(project_id, post_id, language) end),
|
||||||
rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_posts_from_files(project_id) end),
|
rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_posts_from_files(project_id) end),
|
||||||
|
rebuild_links: zero_or_one_arg(fn _args -> rebuild_post_links(project_id) end),
|
||||||
reindex_text: zero_or_one_arg(fn _args -> reindex_project_search(project_id) end),
|
reindex_text: zero_or_one_arg(fn _args -> reindex_project_search(project_id) end),
|
||||||
search: one_arg(fn query -> search_posts(project_id, query) end)
|
search: one_arg(fn query -> search_posts(project_id, query) end)
|
||||||
},
|
},
|
||||||
media: %{
|
media: %{
|
||||||
|
delete_translation: two_arg(fn media_id, language -> delete_media_translation(project_id, media_id, language) end),
|
||||||
|
filter: one_arg(fn filters -> filter_media(project_id, filters) end),
|
||||||
import: one_arg(fn attrs -> import_media(project_id, attrs) end),
|
import: one_arg(fn attrs -> import_media(project_id, attrs) end),
|
||||||
|
get_by_year_month: zero_or_one_arg(fn _args -> media_counts_by_year_month(project_id) end),
|
||||||
|
get_file_path: one_arg(fn media_id -> media_file_path(project_id, media_id) end),
|
||||||
update: two_arg(fn media_id, attrs -> update_media(project_id, media_id, attrs) end),
|
update: two_arg(fn media_id, attrs -> update_media(project_id, media_id, attrs) end),
|
||||||
delete: one_arg(fn media_id -> delete_media(project_id, media_id) end),
|
delete: one_arg(fn media_id -> delete_media(project_id, media_id) end),
|
||||||
get: one_arg(fn media_id -> load_media(project_id, media_id) end),
|
get: one_arg(fn media_id -> load_media(project_id, media_id) end),
|
||||||
get_all: zero_or_one_arg(fn _args -> list_media(project_id) end)
|
get_all: zero_or_one_arg(fn _args -> list_media(project_id) end),
|
||||||
|
get_tags: zero_or_one_arg(fn _args -> media_tags(project_id) end),
|
||||||
|
get_tags_with_counts: zero_or_one_arg(fn _args -> media_tags_with_counts(project_id) end),
|
||||||
|
get_thumbnail: two_arg(fn media_id, size -> media_thumbnail(project_id, media_id, size) end),
|
||||||
|
get_translation: two_arg(fn media_id, language -> load_media_translation(project_id, media_id, language) end),
|
||||||
|
get_translations: one_arg(fn media_id -> list_media_translations(project_id, media_id) end),
|
||||||
|
get_url: one_arg(fn media_id -> media_url(project_id, media_id) end),
|
||||||
|
rebuild_from_files: zero_or_one_arg(fn _args -> rebuild_media_from_files(project_id) end),
|
||||||
|
regenerate_missing_thumbnails: zero_or_one_arg(fn _args -> regenerate_missing_thumbnails(project_id) end),
|
||||||
|
regenerate_thumbnails: one_arg(fn media_id -> regenerate_media_thumbnails(project_id, media_id) end),
|
||||||
|
reindex_text: zero_or_one_arg(fn _args -> reindex_project_search(project_id) end),
|
||||||
|
replace_file: two_arg(fn media_id, source_path -> replace_media_file(project_id, media_id, source_path) end),
|
||||||
|
search: one_arg(fn query -> search_media(project_id, query) end),
|
||||||
|
upsert_translation: three_arg(fn media_id, language, attrs -> upsert_media_translation(project_id, media_id, language, attrs) end)
|
||||||
},
|
},
|
||||||
scripts: %{
|
scripts: %{
|
||||||
create: one_arg(fn attrs -> create_script(project_id, attrs) end),
|
create: one_arg(fn attrs -> create_script(project_id, attrs) end),
|
||||||
@@ -302,6 +346,16 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
set_publishing_preferences(project_id, %{})
|
set_publishing_preferences(project_id, %{})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp sync_meta_on_startup(project_id) do
|
||||||
|
_ = Tags.sync_tags_from_posts(project_id)
|
||||||
|
|
||||||
|
%{
|
||||||
|
tags: metadata_tags(project_id),
|
||||||
|
categories: metadata_categories(project_id),
|
||||||
|
project_metadata: load_metadata(project_id)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
defp create_post(project_id, attrs) do
|
defp create_post(project_id, attrs) do
|
||||||
attrs
|
attrs
|
||||||
|> normalize_map()
|
|> normalize_map()
|
||||||
@@ -356,6 +410,95 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp discard_post(project_id, post_id) do
|
||||||
|
case fetch_post(project_id, post_id) do
|
||||||
|
%Post{} -> Posts.discard_post_changes(post_id) |> unwrap_result(&post_payload/1)
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_posts(project_id, filters) do
|
||||||
|
project_id
|
||||||
|
|> Search.search_posts("", normalize_search_filters(filters))
|
||||||
|
|> unwrap_result(fn %{posts: posts} -> Enum.map(posts, &post_payload/1) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate_unique_post_slug(project_id, title, exclude_post_id) do
|
||||||
|
Posts.unique_slug_for_title(project_id, string_or_nil(title) || "", string_or_nil(exclude_post_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp posts_by_status(project_id, status) do
|
||||||
|
normalized_status = string_or_nil(status) || ""
|
||||||
|
|
||||||
|
Repo.all(from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at]))
|
||||||
|
|> Enum.filter(&(to_string(&1.status) == normalized_status))
|
||||||
|
|> Enum.map(&post_payload/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_counts_by_year_month(project_id) do
|
||||||
|
Posts.post_counts_by_year_month(project_id)
|
||||||
|
|> sanitize()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_dashboard_stats(project_id) do
|
||||||
|
Posts.dashboard_stats(project_id)
|
||||||
|
|> sanitize()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp linked_posts_for(project_id, post_id, direction) do
|
||||||
|
case fetch_post(project_id, post_id) do
|
||||||
|
%Post{id: id} -> linked_posts(id, direction)
|
||||||
|
_other -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preview_url(project_id, post_id, options) do
|
||||||
|
case fetch_post(project_id, post_id) do
|
||||||
|
%Post{} = post ->
|
||||||
|
with {:ok, server} <- Preview.start_preview(project_id) do
|
||||||
|
base_url = "http://#{server.host}:#{server.port}"
|
||||||
|
canonical_path = canonical_preview_path(post.created_at, post.slug)
|
||||||
|
options = normalize_map(options)
|
||||||
|
language = options |> Map.get("lang") |> string_or_nil() |> blank_to_nil()
|
||||||
|
|
||||||
|
query =
|
||||||
|
%{}
|
||||||
|
|> maybe_put_query("draft", truthy?(Map.get(options, "draft")) && "true")
|
||||||
|
|> maybe_put_query("post_id", truthy?(Map.get(options, "draft")) && post.id)
|
||||||
|
|> maybe_put_query("lang", language)
|
||||||
|
|
||||||
|
if map_size(query) == 0 do
|
||||||
|
base_url <> canonical_path
|
||||||
|
else
|
||||||
|
base_url <> canonical_path <> "?" <> URI.encode_query(query)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_slug_available?(project_id, slug, exclude_post_id) do
|
||||||
|
Posts.slug_available(project_id, string_or_nil(slug) || "", string_or_nil(exclude_post_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp publish_post_translation(project_id, post_id, language) do
|
||||||
|
case fetch_post(project_id, post_id) do
|
||||||
|
%Post{} -> Posts.publish_post_translation(post_id, string_or_nil(language) || "") |> unwrap_result()
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp rebuild_post_links(project_id) do
|
||||||
|
case Posts.rebuild_post_links(project_id) do
|
||||||
|
:ok -> true
|
||||||
|
_other -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp rebuild_posts_from_files(project_id) do
|
defp rebuild_posts_from_files(project_id) do
|
||||||
project_id
|
project_id
|
||||||
|> Posts.rebuild_posts_from_files()
|
|> Posts.rebuild_posts_from_files()
|
||||||
@@ -421,6 +564,7 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
defp import_media(project_id, attrs) do
|
defp import_media(project_id, attrs) do
|
||||||
attrs
|
attrs
|
||||||
|> normalize_map()
|
|> normalize_map()
|
||||||
|
|> normalize_media_attrs()
|
||||||
|> Map.put("project_id", project_id)
|
|> Map.put("project_id", project_id)
|
||||||
|> Media.import_media()
|
|> Media.import_media()
|
||||||
|> unwrap_result()
|
|> unwrap_result()
|
||||||
@@ -428,7 +572,7 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
|
|
||||||
defp update_media(project_id, media_id, attrs) do
|
defp update_media(project_id, media_id, attrs) do
|
||||||
case fetch_media(project_id, media_id) do
|
case fetch_media(project_id, media_id) do
|
||||||
%MediaRecord{} -> Media.update_media(media_id, normalize_map(attrs)) |> unwrap_result()
|
%MediaRecord{} -> Media.update_media(media_id, attrs |> normalize_map() |> normalize_media_attrs()) |> unwrap_result()
|
||||||
_other -> nil
|
_other -> nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -452,6 +596,164 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
|> Enum.map(&sanitize/1)
|
|> Enum.map(&sanitize/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp load_media_translation(project_id, media_id, language) do
|
||||||
|
case fetch_media(project_id, media_id) do
|
||||||
|
%MediaRecord{id: id} ->
|
||||||
|
Repo.one(
|
||||||
|
from(translation in MediaTranslation,
|
||||||
|
where:
|
||||||
|
translation.translation_for == ^id and
|
||||||
|
translation.language == ^(string_or_nil(language) || ""),
|
||||||
|
limit: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> sanitize_nilable()
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp list_media_translations(project_id, media_id) do
|
||||||
|
case fetch_media(project_id, media_id) do
|
||||||
|
%MediaRecord{id: id} ->
|
||||||
|
Repo.all(
|
||||||
|
from(translation in MediaTranslation,
|
||||||
|
where: translation.translation_for == ^id,
|
||||||
|
order_by: [asc: translation.language]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> Enum.map(&sanitize/1)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp upsert_media_translation(project_id, media_id, language, attrs) do
|
||||||
|
case fetch_media(project_id, media_id) do
|
||||||
|
%MediaRecord{} ->
|
||||||
|
Media.upsert_media_translation(media_id, string_or_nil(language) || "", normalize_media_translation_attrs(normalize_map(attrs)))
|
||||||
|
|> unwrap_result()
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp delete_media_translation(project_id, media_id, language) do
|
||||||
|
case fetch_media(project_id, media_id) do
|
||||||
|
%MediaRecord{} ->
|
||||||
|
case Media.delete_media_translation(media_id, string_or_nil(language) || "") do
|
||||||
|
{:ok, deleted?} -> deleted?
|
||||||
|
{:error, _reason} -> false
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_media(project_id, filters) do
|
||||||
|
filters = normalize_map(filters)
|
||||||
|
|
||||||
|
list_media(project_id)
|
||||||
|
|> Enum.filter(fn media -> media_matches_filters?(media, filters) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_counts_by_year_month(project_id) do
|
||||||
|
list_media(project_id)
|
||||||
|
|> Enum.reduce(%{}, fn media, acc ->
|
||||||
|
datetime = media_datetime(media)
|
||||||
|
key = {datetime.year, datetime.month}
|
||||||
|
Map.update(acc, key, 1, &(&1 + 1))
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn {{year, month}, count} -> %{"year" => year, "month" => month, "count" => count} end)
|
||||||
|
|> Enum.sort_by(fn row -> {-row["year"], -row["month"]} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_file_path(project_id, media_id) do
|
||||||
|
case fetch_media(project_id, media_id) do
|
||||||
|
%MediaRecord{} = media -> Path.join(project_path(project_id), media.file_path)
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_tags(project_id), do: media_tags_with_counts(project_id) |> Enum.map(& &1["tag"])
|
||||||
|
|
||||||
|
defp media_tags_with_counts(project_id) do
|
||||||
|
Repo.all(from(media in MediaRecord, where: media.project_id == ^project_id, order_by: [asc: media.created_at]))
|
||||||
|
|> Enum.flat_map(&(&1.tags || []))
|
||||||
|
|> Enum.reduce(%{}, fn tag, acc -> Map.update(acc, tag, 1, &(&1 + 1)) end)
|
||||||
|
|> Enum.map(fn {tag, count} -> %{"tag" => tag, "count" => count} end)
|
||||||
|
|> Enum.sort_by(fn row -> {-row["count"], String.downcase(row["tag"])} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_thumbnail(project_id, media_id, size) do
|
||||||
|
with %MediaRecord{} = media <- fetch_media(project_id, media_id),
|
||||||
|
relative_path <- Media.thumbnail_paths(media)[thumbnail_size(size)],
|
||||||
|
absolute_path <- Path.join(project_path(project_id), relative_path),
|
||||||
|
true <- File.exists?(absolute_path),
|
||||||
|
{:ok, binary} <- File.read(absolute_path) do
|
||||||
|
"data:#{thumbnail_mime(absolute_path)};base64," <> Base.encode64(binary)
|
||||||
|
else
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_url(project_id, media_id) do
|
||||||
|
case fetch_media(project_id, media_id) do
|
||||||
|
%MediaRecord{} = media -> "/" <> String.trim_leading(media.file_path, "/")
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp rebuild_media_from_files(project_id) do
|
||||||
|
project_id
|
||||||
|
|> Media.rebuild_media_from_files()
|
||||||
|
|> unwrap_result(fn media -> Enum.map(media, &sanitize/1) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp regenerate_missing_thumbnails(project_id) do
|
||||||
|
Media.regenerate_missing_thumbnails(project_id)
|
||||||
|
|> sanitize()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp regenerate_media_thumbnails(project_id, media_id) do
|
||||||
|
case fetch_media(project_id, media_id) do
|
||||||
|
%MediaRecord{} = media ->
|
||||||
|
case Media.regenerate_thumbnails(media.id) do
|
||||||
|
{:ok, _media} ->
|
||||||
|
Media.thumbnail_paths(media)
|
||||||
|
|> Enum.map(fn {size, relative_path} -> {to_string(size), Path.join(project_path(project_id), relative_path)} end)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp replace_media_file(project_id, media_id, source_path) do
|
||||||
|
case fetch_media(project_id, media_id) do
|
||||||
|
%MediaRecord{} ->
|
||||||
|
Media.replace_media_file(media_id, string_or_nil(source_path) || "")
|
||||||
|
|> unwrap_result()
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp search_media(project_id, query) do
|
||||||
|
project_id
|
||||||
|
|> Search.search_media(string_or_nil(query) || "")
|
||||||
|
|> unwrap_result(fn %{media: media} -> Enum.map(media, &sanitize/1) end)
|
||||||
|
end
|
||||||
|
|
||||||
defp create_script(project_id, attrs) do
|
defp create_script(project_id, attrs) do
|
||||||
attrs
|
attrs
|
||||||
|> normalize_map()
|
|> normalize_map()
|
||||||
@@ -708,6 +1010,122 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp copy_to_clipboard(text, opts) do
|
||||||
|
case Keyword.get(opts, :copy_to_clipboard) do
|
||||||
|
callback when is_function(callback, 1) -> callback.(string_or_nil(text) || "")
|
||||||
|
_other -> do_copy_to_clipboard(text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_copy_to_clipboard(text) do
|
||||||
|
if @mix_env == :test do
|
||||||
|
true
|
||||||
|
else
|
||||||
|
command = string_or_nil(text) || ""
|
||||||
|
|
||||||
|
case :os.type() do
|
||||||
|
{:unix, :darwin} -> match?({_output, 0}, System.cmd("pbcopy", [], input: command, stderr_to_stdout: true))
|
||||||
|
{:unix, _other} -> match?({_output, 0}, System.cmd("xclip", ["-selection", "clipboard"], input: command, stderr_to_stdout: true))
|
||||||
|
{:win32, _other} -> match?({_output, 0}, System.cmd("cmd", ["/c", "clip"], input: command, stderr_to_stdout: true))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
_error -> false
|
||||||
|
end
|
||||||
|
|
||||||
|
defp blogmark_bookmarklet do
|
||||||
|
"javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds://new-post?title='+t+'&url='+u;})();"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp title_bar_metrics(opts) do
|
||||||
|
case Keyword.get(opts, :title_bar_metrics) do
|
||||||
|
callback when is_function(callback, 0) -> callback.()
|
||||||
|
_other -> do_title_bar_metrics()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_title_bar_metrics do
|
||||||
|
case :os.type() do
|
||||||
|
{:unix, :darwin} -> %{macos_left_inset: 72}
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp notify_renderer_ready(opts) do
|
||||||
|
case Keyword.get(opts, :notify_renderer_ready) do
|
||||||
|
callback when is_function(callback, 0) -> callback.()
|
||||||
|
_other -> true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_folder(folder_path, opts) do
|
||||||
|
case Keyword.get(opts, :open_folder) do
|
||||||
|
callback when is_function(callback, 1) -> callback.(string_or_nil(folder_path) || "")
|
||||||
|
_other -> do_open_folder(folder_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_open_folder(folder_path) do
|
||||||
|
if @mix_env == :test do
|
||||||
|
""
|
||||||
|
else
|
||||||
|
case open_system_path(string_or_nil(folder_path) || "") do
|
||||||
|
:ok -> ""
|
||||||
|
{:error, reason} -> inspect(reason)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp select_folder(title, opts) do
|
||||||
|
case Keyword.get(opts, :select_folder) do
|
||||||
|
callback when is_function(callback, 1) -> callback.(string_or_nil(title) || "Select Folder")
|
||||||
|
_other -> do_select_folder(title)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_select_folder(title) do
|
||||||
|
if @mix_env == :test do
|
||||||
|
nil
|
||||||
|
else
|
||||||
|
case FolderPicker.choose_directory(string_or_nil(title) || "Select Folder") do
|
||||||
|
{:ok, path} -> path
|
||||||
|
:cancel -> nil
|
||||||
|
{:error, _reason} -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_preview_post_target(post_id) do
|
||||||
|
:persistent_term.put({__MODULE__, :preview_post_target}, string_or_nil(post_id))
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
defp show_item_in_folder(item_path, opts) do
|
||||||
|
callback = Keyword.get(opts, :show_item_in_folder)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_function(callback, 1) -> callback.(string_or_nil(item_path) || "")
|
||||||
|
@mix_env == :test -> :ok
|
||||||
|
true -> _ = reveal_system_path(string_or_nil(item_path) || "")
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp trigger_menu_action(action, opts) do
|
||||||
|
callback = Keyword.get(opts, :trigger_menu_action)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_function(callback, 1) -> callback.(string_or_nil(action) || "")
|
||||||
|
@mix_env == :test -> :ok
|
||||||
|
true -> _ = MenuBar.handle_event(string_or_nil(action) || "", nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
rescue
|
||||||
|
_error -> nil
|
||||||
|
end
|
||||||
|
|
||||||
defp sync_available?, do: not is_nil(System.find_executable("git"))
|
defp sync_available?, do: not is_nil(System.find_executable("git"))
|
||||||
|
|
||||||
defp repo_state(project_id, opts) do
|
defp repo_state(project_id, opts) do
|
||||||
@@ -890,7 +1308,7 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
defp zero_or_one_arg(callback) when is_function(callback, 1) do
|
defp zero_or_one_arg(callback) when is_function(callback, 1) do
|
||||||
fn args, state ->
|
fn args, state ->
|
||||||
decoded_args = :luerl.decode_list(args, state)
|
decoded_args = :luerl.decode_list(args, state)
|
||||||
value = callback.(sanitize(decoded_args))
|
value = callback.(normalize_input(decoded_args))
|
||||||
:luerl.encode_list([sanitize(value)], state)
|
:luerl.encode_list([sanitize(value)], state)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -901,7 +1319,7 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
|
|
||||||
value =
|
value =
|
||||||
case decoded_args do
|
case decoded_args do
|
||||||
[first | _rest] -> callback.(sanitize(first))
|
[first | _rest] -> callback.(normalize_input(first))
|
||||||
[] -> callback.(nil)
|
[] -> callback.(nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -915,8 +1333,8 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
|
|
||||||
value =
|
value =
|
||||||
case decoded_args do
|
case decoded_args do
|
||||||
[first, second | _rest] -> callback.(sanitize(first), sanitize(second))
|
[first, second | _rest] -> callback.(normalize_input(first), normalize_input(second))
|
||||||
[first] -> callback.(sanitize(first), nil)
|
[first] -> callback.(normalize_input(first), nil)
|
||||||
[] -> callback.(nil, nil)
|
[] -> callback.(nil, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -930,9 +1348,9 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
|
|
||||||
value =
|
value =
|
||||||
case decoded_args do
|
case decoded_args do
|
||||||
[first, second, third | _rest] -> callback.(sanitize(first), sanitize(second), sanitize(third))
|
[first, second, third | _rest] -> callback.(normalize_input(first), normalize_input(second), normalize_input(third))
|
||||||
[first, second] -> callback.(sanitize(first), sanitize(second), nil)
|
[first, second] -> callback.(normalize_input(first), normalize_input(second), nil)
|
||||||
[first] -> callback.(sanitize(first), nil, nil)
|
[first] -> callback.(normalize_input(first), nil, nil)
|
||||||
[] -> callback.(nil, nil, nil)
|
[] -> callback.(nil, nil, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -980,10 +1398,15 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
defp sanitize_nilable(nil), do: nil
|
defp sanitize_nilable(nil), do: nil
|
||||||
defp sanitize_nilable(value), do: sanitize(value)
|
defp sanitize_nilable(value), do: sanitize(value)
|
||||||
|
|
||||||
defp normalize_map(value) when is_map(value), do: sanitize(value)
|
defp normalize_map(value) when is_map(value) do
|
||||||
|
case normalize_input(value) do
|
||||||
|
normalized when is_map(normalized) -> normalized
|
||||||
|
_other -> %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
defp normalize_map(value) when is_list(value) do
|
defp normalize_map(value) when is_list(value) do
|
||||||
if Enum.all?(value, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do
|
if Enum.all?(value, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do
|
||||||
Map.new(value, fn {key, entry_value} -> {to_string(key), sanitize(entry_value)} end)
|
Map.new(value, fn {key, entry_value} -> {to_string(key), normalize_input(entry_value)} end)
|
||||||
else
|
else
|
||||||
%{}
|
%{}
|
||||||
end
|
end
|
||||||
@@ -991,6 +1414,16 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
defp normalize_map(_value), do: %{}
|
defp normalize_map(_value), do: %{}
|
||||||
|
|
||||||
defp normalize_string_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
|
defp normalize_string_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
|
||||||
|
|
||||||
|
defp normalize_string_list(value) when is_map(value) do
|
||||||
|
value
|
||||||
|
|> normalize_input()
|
||||||
|
|> case do
|
||||||
|
normalized when is_list(normalized) -> Enum.map(normalized, &to_string/1)
|
||||||
|
_other -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_string_list(_value), do: []
|
defp normalize_string_list(_value), do: []
|
||||||
|
|
||||||
defp integer_or_default(value, _default) when is_integer(value), do: value
|
defp integer_or_default(value, _default) when is_integer(value), do: value
|
||||||
@@ -1002,6 +1435,40 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
defp string_or_nil(value) when is_number(value), do: to_string(value)
|
defp string_or_nil(value) when is_number(value), do: to_string(value)
|
||||||
defp string_or_nil(_value), do: nil
|
defp string_or_nil(_value), do: nil
|
||||||
|
|
||||||
|
defp normalize_input(%_struct{} = struct), do: struct |> Map.from_struct() |> normalize_input()
|
||||||
|
|
||||||
|
defp normalize_input(map) when is_map(map) do
|
||||||
|
normalized =
|
||||||
|
Map.new(map, fn {key, value} -> {normalize_input_key(key), normalize_input(value)} end)
|
||||||
|
|
||||||
|
if numeric_sequence_map?(normalized) do
|
||||||
|
normalized
|
||||||
|
|> Enum.sort_by(fn {key, _value} -> key end)
|
||||||
|
|> Enum.map(fn {_key, value} -> value end)
|
||||||
|
else
|
||||||
|
normalized
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_input(list) when is_list(list) do
|
||||||
|
if Enum.all?(list, &match?({key, _value} when is_integer(key) or is_float(key) or is_binary(key) or is_atom(key), &1)) do
|
||||||
|
normalized =
|
||||||
|
Map.new(list, fn {key, value} -> {normalize_input_key(key), normalize_input(value)} end)
|
||||||
|
|
||||||
|
if numeric_sequence_map?(normalized) do
|
||||||
|
normalized
|
||||||
|
|> Enum.sort_by(fn {key, _value} -> key end)
|
||||||
|
|> Enum.map(fn {_key, value} -> value end)
|
||||||
|
else
|
||||||
|
normalized
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Enum.map(list, &normalize_input/1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
defp normalize_input(value) when is_atom(value), do: Atom.to_string(value)
|
||||||
|
defp normalize_input(value), do: value
|
||||||
|
|
||||||
defp git_opts(opts) do
|
defp git_opts(opts) do
|
||||||
case Keyword.get(opts, :git_runner) do
|
case Keyword.get(opts, :git_runner) do
|
||||||
nil -> []
|
nil -> []
|
||||||
@@ -1064,6 +1531,42 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
defp sanitize(value) when is_atom(value), do: Atom.to_string(value)
|
defp sanitize(value) when is_atom(value), do: Atom.to_string(value)
|
||||||
defp sanitize(value), do: value
|
defp sanitize(value), do: value
|
||||||
|
|
||||||
|
defp normalize_input_key(key) when is_integer(key), do: key
|
||||||
|
defp normalize_input_key(key) when is_float(key) and trunc(key) == key, do: trunc(key)
|
||||||
|
defp normalize_input_key(key) when is_binary(key) do
|
||||||
|
case Integer.parse(key) do
|
||||||
|
{integer, ""} -> integer
|
||||||
|
_other -> key
|
||||||
|
end
|
||||||
|
end
|
||||||
|
defp normalize_input_key(key) when is_atom(key), do: Atom.to_string(key)
|
||||||
|
defp normalize_input_key(key), do: key
|
||||||
|
|
||||||
|
defp numeric_sequence_map?(map) when map == %{}, do: false
|
||||||
|
|
||||||
|
defp numeric_sequence_map?(map) do
|
||||||
|
keys = Map.keys(map)
|
||||||
|
|
||||||
|
Enum.all?(keys, &is_integer/1) and Enum.sort(keys) == Enum.to_list(1..length(keys))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_media_attrs(attrs) do
|
||||||
|
attrs
|
||||||
|
|> maybe_put_normalized_list("tags")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_media_translation_attrs(attrs) do
|
||||||
|
attrs
|
||||||
|
|> Map.take(["title", "alt", "caption"])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_put_normalized_list(attrs, key) do
|
||||||
|
case Map.fetch(attrs, key) do
|
||||||
|
{:ok, value} -> Map.put(attrs, key, normalize_string_list(value))
|
||||||
|
:error -> attrs
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp names_with_counts(project_id, field) when field in [:tags, :categories] do
|
defp names_with_counts(project_id, field) when field in [:tags, :categories] do
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from(post in Post,
|
from(post in Post,
|
||||||
@@ -1076,4 +1579,130 @@ defmodule BDS.Scripting.Capabilities do
|
|||||||
|> Enum.map(fn {name, count} -> %{"name" => name, "count" => count} end)
|
|> Enum.map(fn {name, count} -> %{"name" => name, "count" => count} end)
|
||||||
|> Enum.sort_by(&{String.downcase(&1["name"]), &1["count"]})
|
|> Enum.sort_by(&{String.downcase(&1["name"]), &1["count"]})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp media_matches_filters?(media, filters) do
|
||||||
|
created_at = media_datetime(media)
|
||||||
|
tags = Map.get(media, "tags", [])
|
||||||
|
language = Map.get(media, "language")
|
||||||
|
|
||||||
|
matches_year = compare_optional(Map.get(filters, "year"), fn year -> created_at.year == integer_or_default(year, created_at.year) end)
|
||||||
|
matches_month = compare_optional(Map.get(filters, "month"), fn month -> created_at.month == integer_or_default(month, created_at.month) end)
|
||||||
|
matches_language = compare_optional(blank_to_nil(Map.get(filters, "language")), fn value -> language == value end)
|
||||||
|
matches_tags = compare_optional(Map.get(filters, "tags"), fn required_tags -> Enum.all?(normalize_string_list(required_tags), &(&1 in tags)) end)
|
||||||
|
matches_from = compare_optional(parse_datetime(Map.get(filters, "from") || Map.get(filters, "start_date")), fn from_dt -> DateTime.compare(created_at, from_dt) != :lt end)
|
||||||
|
matches_to = compare_optional(parse_datetime(Map.get(filters, "to") || Map.get(filters, "end_date")), fn to_dt -> DateTime.compare(created_at, to_dt) != :gt end)
|
||||||
|
|
||||||
|
matches_year and matches_month and matches_language and matches_tags and matches_from and matches_to
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_datetime(media) do
|
||||||
|
media
|
||||||
|
|> Map.get("created_at")
|
||||||
|
|> case do
|
||||||
|
value when is_binary(value) ->
|
||||||
|
case DateTime.from_iso8601(value) do
|
||||||
|
{:ok, datetime, _offset} -> datetime
|
||||||
|
_other -> DateTime.utc_now()
|
||||||
|
end
|
||||||
|
|
||||||
|
value when is_integer(value) -> DateTime.from_unix!(value, :millisecond)
|
||||||
|
_other -> DateTime.utc_now()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp canonical_preview_path(created_at_ms, slug) do
|
||||||
|
datetime = DateTime.from_unix!(created_at_ms, :millisecond)
|
||||||
|
"/#{datetime.year}/#{pad2(datetime.month)}/#{pad2(datetime.day)}/#{string_or_nil(slug) || ""}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
|
||||||
|
|
||||||
|
defp truthy?(value), do: value in [true, "true", 1, 1.0, "1"]
|
||||||
|
|
||||||
|
defp maybe_put_query(query, _key, false), do: query
|
||||||
|
defp maybe_put_query(query, _key, nil), do: query
|
||||||
|
defp maybe_put_query(query, key, value), do: Map.put(query, key, value)
|
||||||
|
|
||||||
|
defp blank_to_nil(nil), do: nil
|
||||||
|
defp blank_to_nil(value) when is_binary(value) do
|
||||||
|
if String.trim(value) == "", do: nil, else: String.trim(value)
|
||||||
|
end
|
||||||
|
defp blank_to_nil(value), do: value
|
||||||
|
|
||||||
|
defp thumbnail_size(size) do
|
||||||
|
case blank_to_nil(size) do
|
||||||
|
"medium" -> :medium
|
||||||
|
"large" -> :large
|
||||||
|
"ai" -> :ai
|
||||||
|
_other -> :small
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp thumbnail_mime(path) do
|
||||||
|
case Path.extname(path) do
|
||||||
|
".jpg" -> "image/jpeg"
|
||||||
|
".jpeg" -> "image/jpeg"
|
||||||
|
_other -> "image/webp"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp compare_optional(nil, _fun), do: true
|
||||||
|
defp compare_optional(value, fun) when is_function(fun, 1), do: fun.(value)
|
||||||
|
|
||||||
|
defp normalize_search_filters(filters) do
|
||||||
|
filters
|
||||||
|
|> normalize_map()
|
||||||
|
|> Enum.into(%{}, fn {key, value} ->
|
||||||
|
normalized_key =
|
||||||
|
case key do
|
||||||
|
"start_date" -> "from"
|
||||||
|
"end_date" -> "to"
|
||||||
|
other -> other
|
||||||
|
end
|
||||||
|
|
||||||
|
{normalized_key, value}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_datetime(nil), do: nil
|
||||||
|
defp parse_datetime(value) when is_integer(value), do: DateTime.from_unix!(value, :millisecond)
|
||||||
|
defp parse_datetime(value) when is_binary(value) do
|
||||||
|
case DateTime.from_iso8601(value) do
|
||||||
|
{:ok, datetime, _offset} -> datetime
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
defp parse_datetime(_value), do: nil
|
||||||
|
|
||||||
|
defp open_system_path(path) do
|
||||||
|
{command, args} =
|
||||||
|
case :os.type() do
|
||||||
|
{:unix, :darwin} -> {"open", [path]}
|
||||||
|
{:unix, _other} -> {"xdg-open", [path]}
|
||||||
|
{:win32, _other} -> {"cmd", ["/c", "start", "", path]}
|
||||||
|
end
|
||||||
|
|
||||||
|
case System.cmd(command, args, stderr_to_stdout: true) do
|
||||||
|
{_output, 0} -> :ok
|
||||||
|
{output, status} -> {:error, {status, String.trim(output)}}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
error -> {:error, error}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reveal_system_path(path) do
|
||||||
|
{command, args} =
|
||||||
|
case :os.type() do
|
||||||
|
{:unix, :darwin} -> {"open", ["-R", path]}
|
||||||
|
{:unix, _other} -> {"xdg-open", [Path.dirname(path)]}
|
||||||
|
{:win32, _other} -> {"explorer", ["/select,", path]}
|
||||||
|
end
|
||||||
|
|
||||||
|
case System.cmd(command, args, stderr_to_stdout: true) do
|
||||||
|
{_output, 0} -> :ok
|
||||||
|
{output, status} -> {:error, {status, String.trim(output)}}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
error -> {:error, error}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
defmodule BDS.Scripting.ApiTest do
|
defmodule BDS.Scripting.ApiTest do
|
||||||
use ExUnit.Case, async: false
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
@tiny_png_1 Base.decode64!("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/a6sAAAAASUVORK5CYII=")
|
||||||
|
@tiny_png_2 Base.decode64!("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR42mP8z/C/HwAF/gL+qJNmNwAAAABJRU5ErkJggg==")
|
||||||
|
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Scripts.Script
|
alias BDS.Scripts.Script
|
||||||
alias BDS.Templates.Template
|
alias BDS.Templates.Template
|
||||||
@@ -150,7 +153,15 @@ defmodule BDS.Scripting.ApiTest do
|
|||||||
]
|
]
|
||||||
|> Enum.join("\n")
|
|> Enum.join("\n")
|
||||||
|
|
||||||
assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main")
|
assert {:ok, result} =
|
||||||
|
BDS.Scripting.execute_project_script(project.id, source, "main", [],
|
||||||
|
copy_to_clipboard: fn _text -> true end,
|
||||||
|
title_bar_metrics: fn -> %{macos_left_inset: 64} end,
|
||||||
|
notify_renderer_ready: fn -> true end,
|
||||||
|
open_folder: fn _path -> "" end,
|
||||||
|
show_item_in_folder: fn _path -> :ok end,
|
||||||
|
trigger_menu_action: fn _action -> :ok end
|
||||||
|
)
|
||||||
|
|
||||||
assert %{
|
assert %{
|
||||||
"active_project" => "Scripting API",
|
"active_project" => "Scripting API",
|
||||||
@@ -293,7 +304,15 @@ defmodule BDS.Scripting.ApiTest do
|
|||||||
]
|
]
|
||||||
|> Enum.join("\n")
|
|> Enum.join("\n")
|
||||||
|
|
||||||
assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main")
|
assert {:ok, result} =
|
||||||
|
BDS.Scripting.execute_project_script(project.id, source, "main", [],
|
||||||
|
copy_to_clipboard: fn _text -> true end,
|
||||||
|
title_bar_metrics: fn -> %{macos_left_inset: 64} end,
|
||||||
|
notify_renderer_ready: fn -> true end,
|
||||||
|
open_folder: fn _path -> "" end,
|
||||||
|
show_item_in_folder: fn _path -> :ok end,
|
||||||
|
trigger_menu_action: fn _action -> :ok end
|
||||||
|
)
|
||||||
|
|
||||||
assert result["project_description"] == "Updated through Lua"
|
assert result["project_description"] == "Updated through Lua"
|
||||||
assert result["ssh_mode"] == "scp"
|
assert result["ssh_mode"] == "scp"
|
||||||
@@ -374,4 +393,215 @@ defmodule BDS.Scripting.ApiTest do
|
|||||||
assert result["has_published"] == true
|
assert result["has_published"] == true
|
||||||
assert result["search_count"] >= 1
|
assert result["search_count"] >= 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "project scripting exposes remaining post and media parity helpers", %{project: project} do
|
||||||
|
media_source_path = write_binary_fixture(project.data_path, "image-a.png", @tiny_png_1)
|
||||||
|
replacement_source_path = write_binary_fixture(project.data_path, "image-b.png", @tiny_png_2)
|
||||||
|
|
||||||
|
assert {:ok, target_post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Target Post",
|
||||||
|
content: "Target body",
|
||||||
|
language: "en",
|
||||||
|
tags: ["target"],
|
||||||
|
categories: ["reference"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _published_target} = BDS.Posts.publish_post(target_post.id)
|
||||||
|
|
||||||
|
assert {:ok, source_post} =
|
||||||
|
BDS.Posts.create_post(%{
|
||||||
|
project_id: project.id,
|
||||||
|
title: "Source Post",
|
||||||
|
excerpt: "Draft excerpt",
|
||||||
|
content: "See [Target](/target-post) for more.",
|
||||||
|
language: "en",
|
||||||
|
tags: ["source", "featured"],
|
||||||
|
categories: ["guide"]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _published_source} = BDS.Posts.publish_post(source_post.id)
|
||||||
|
|
||||||
|
assert {:ok, _source_translation} =
|
||||||
|
BDS.Posts.upsert_post_translation(source_post.id, "de", %{
|
||||||
|
title: "Quellbeitrag",
|
||||||
|
excerpt: "Deutscher Auszug",
|
||||||
|
content: "Siehe [Target](/target-post) fur mehr."
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, _draft_source} =
|
||||||
|
BDS.Posts.update_post(source_post.id, %{
|
||||||
|
title: "Source Post Draft",
|
||||||
|
excerpt: "Changed excerpt",
|
||||||
|
content: "Changed body with [Target](/target-post)"
|
||||||
|
})
|
||||||
|
|
||||||
|
source =
|
||||||
|
[
|
||||||
|
"function main()",
|
||||||
|
" local function step(name, fn)",
|
||||||
|
" local ok, value = pcall(fn)",
|
||||||
|
" if not ok then error(name .. ': ' .. tostring(value)) end",
|
||||||
|
" return value",
|
||||||
|
" end",
|
||||||
|
" local function count(name, value)",
|
||||||
|
" local ok, length = pcall(function() return #value end)",
|
||||||
|
" if not ok then error(name .. ': ' .. tostring(length)) end",
|
||||||
|
" return length",
|
||||||
|
" end",
|
||||||
|
" local imported = step('media.import', function() return bds.media.import({ source_path = '" <> escape_lua_string(media_source_path) <> "', title = 'Imported Image', alt = 'Alt text', caption = 'Caption', tags = { 'gallery', 'cover' }, language = 'en' }) end)",
|
||||||
|
" local translation = step('media.upsert_translation', function() return bds.media.upsert_translation(imported.id, 'de', { title = 'Bild', alt = 'Alt de', caption = 'Beschriftung' }) end)",
|
||||||
|
" local fetched_translation = step('media.get_translation', function() return bds.media.get_translation(imported.id, 'de') end)",
|
||||||
|
" local translation_count = count('media.get_translations.count', step('media.get_translations', function() return bds.media.get_translations(imported.id) end))",
|
||||||
|
" local media_filter = step('media.filter', function() return bds.media.filter({ year = " <> Integer.to_string(Date.utc_today().year) <> ", tags = { 'gallery' } }) end)",
|
||||||
|
" local media_search = step('media.search', function() return bds.media.search('Imported') end)",
|
||||||
|
" local media_counts = step('media.get_by_year_month', function() return bds.media.get_by_year_month() end)",
|
||||||
|
" local media_tags = step('media.get_tags', function() return bds.media.get_tags() end)",
|
||||||
|
" local media_tag_counts = step('media.get_tags_with_counts', function() return bds.media.get_tags_with_counts() end)",
|
||||||
|
" local media_url = step('media.get_url', function() return bds.media.get_url(imported.id) end)",
|
||||||
|
" local media_file_path = step('media.get_file_path', function() return bds.media.get_file_path(imported.id) end)",
|
||||||
|
" local thumbnail = step('media.get_thumbnail', function() return bds.media.get_thumbnail(imported.id, 'small') end)",
|
||||||
|
" local regenerated = step('media.regenerate_thumbnails', function() return bds.media.regenerate_thumbnails(imported.id) end)",
|
||||||
|
" local missing = step('media.regenerate_missing_thumbnails', function() return bds.media.regenerate_missing_thumbnails() end)",
|
||||||
|
" local replaced = step('media.replace_file', function() return bds.media.replace_file(imported.id, '" <> escape_lua_string(replacement_source_path) <> "') end)",
|
||||||
|
" local rebuilt_media = step('media.rebuild_from_files', function() return bds.media.rebuild_from_files() end)",
|
||||||
|
" local media_reindexed = step('media.reindex_text', function() return bds.media.reindex_text() end)",
|
||||||
|
" local deleted_translation = step('media.delete_translation', function() return bds.media.delete_translation(imported.id, 'de') end)",
|
||||||
|
" local slug_available = step('posts.is_slug_available', function() return bds.posts.is_slug_available('brand-new-slug') end)",
|
||||||
|
" local unique_slug = step('posts.generate_unique_slug', function() return bds.posts.generate_unique_slug('Target Post') end)",
|
||||||
|
" local published = step('posts.get_by_status', function() return bds.posts.get_by_status('published') end)",
|
||||||
|
" local by_month = step('posts.get_by_year_month', function() return bds.posts.get_by_year_month() end)",
|
||||||
|
" local dashboard = step('posts.get_dashboard_stats', function() return bds.posts.get_dashboard_stats() end)",
|
||||||
|
" local filtered = step('posts.filter', function() return bds.posts.filter({ status = 'draft', tags = { 'source' } }) end)",
|
||||||
|
" local rebuilt_links_before = step('posts.get_links_to.before', function() return bds.posts.get_links_to('" <> source_post.id <> "') end)",
|
||||||
|
" step('posts.rebuild_links', function() return bds.posts.rebuild_links() end)",
|
||||||
|
" local links_to = step('posts.get_links_to.after', function() return bds.posts.get_links_to('" <> source_post.id <> "') end)",
|
||||||
|
" local linked_by = step('posts.get_linked_by', function() return bds.posts.get_linked_by('" <> target_post.id <> "') end)",
|
||||||
|
" local preview_url = step('posts.get_preview_url', function() return bds.posts.get_preview_url('" <> source_post.id <> "', { draft = true, lang = 'de' }) end)",
|
||||||
|
" local published_translation = step('posts.publish_translation', function() return bds.posts.publish_translation('" <> source_post.id <> "', 'de') end)",
|
||||||
|
" local discarded = step('posts.discard', function() return bds.posts.discard('" <> source_post.id <> "') end)",
|
||||||
|
" return {",
|
||||||
|
" translation_title = translation and translation.title or nil,",
|
||||||
|
" fetched_translation_title = fetched_translation and fetched_translation.title or nil,",
|
||||||
|
" translation_count = translation_count,",
|
||||||
|
" media_filter_count = count('media.filter.count', media_filter),",
|
||||||
|
" media_search_count = count('media.search.count', media_search),",
|
||||||
|
" media_counts_count = count('media.get_by_year_month.count', media_counts),",
|
||||||
|
" media_tags_count = count('media.get_tags.count', media_tags),",
|
||||||
|
" media_tag_row_count = count('media.get_tags_with_counts.count', media_tag_counts),",
|
||||||
|
" media_url = media_url,",
|
||||||
|
" media_file_path = media_file_path,",
|
||||||
|
" thumbnail_prefix = string.sub(thumbnail or '', 1, 22),",
|
||||||
|
" regenerated_small = regenerated and regenerated.small or nil,",
|
||||||
|
" regenerated_missing_processed = missing and missing.processed or nil,",
|
||||||
|
" replaced_title = replaced and replaced.title or nil,",
|
||||||
|
" rebuilt_media_count = count('media.rebuild_from_files.count', rebuilt_media),",
|
||||||
|
" media_reindexed = media_reindexed,",
|
||||||
|
" deleted_translation = deleted_translation,",
|
||||||
|
" slug_available = slug_available,",
|
||||||
|
" unique_slug = unique_slug,",
|
||||||
|
" published_count = count('posts.get_by_status.count', published),",
|
||||||
|
" by_month_count = count('posts.get_by_year_month.count', by_month),",
|
||||||
|
" dashboard_total = dashboard and dashboard.total_posts or nil,",
|
||||||
|
" filtered_count = count('posts.filter.count', filtered),",
|
||||||
|
" rebuilt_links_before_count = count('posts.get_links_to.before.count', rebuilt_links_before),",
|
||||||
|
" links_to_count = count('posts.get_links_to.after.count', links_to),",
|
||||||
|
" linked_by_count = count('posts.get_linked_by.count', linked_by),",
|
||||||
|
" preview_url = preview_url,",
|
||||||
|
" published_translation_language = published_translation and published_translation.language or nil,",
|
||||||
|
" discarded_title = discarded and discarded.title or nil,",
|
||||||
|
" discarded_status = discarded and discarded.status or nil",
|
||||||
|
" }",
|
||||||
|
"end"
|
||||||
|
]
|
||||||
|
|> Enum.join("\n")
|
||||||
|
|
||||||
|
assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main")
|
||||||
|
|
||||||
|
assert result["translation_title"] == "Bild"
|
||||||
|
assert result["fetched_translation_title"] == "Bild"
|
||||||
|
assert result["translation_count"] == 1
|
||||||
|
assert result["media_filter_count"] >= 1
|
||||||
|
assert result["media_search_count"] >= 1
|
||||||
|
assert result["media_counts_count"] >= 1
|
||||||
|
assert result["media_tags_count"] >= 2
|
||||||
|
assert result["media_tag_row_count"] >= 2
|
||||||
|
assert String.starts_with?(result["media_url"], "/media/")
|
||||||
|
assert String.ends_with?(result["media_file_path"], ".png")
|
||||||
|
assert String.starts_with?(result["thumbnail_prefix"], "data:image/webp;base64")
|
||||||
|
assert is_binary(result["regenerated_small"])
|
||||||
|
assert result["regenerated_missing_processed"] >= 1
|
||||||
|
assert result["replaced_title"] == "Imported Image"
|
||||||
|
assert result["rebuilt_media_count"] >= 1
|
||||||
|
assert result["media_reindexed"] == true
|
||||||
|
assert result["deleted_translation"] == true
|
||||||
|
assert result["slug_available"] == true
|
||||||
|
assert result["unique_slug"] == "target-post-2"
|
||||||
|
assert result["published_count"] >= 1
|
||||||
|
assert result["by_month_count"] >= 1
|
||||||
|
assert result["dashboard_total"] >= 2
|
||||||
|
assert result["filtered_count"] >= 1
|
||||||
|
assert result["rebuilt_links_before_count"] >= 1
|
||||||
|
assert result["links_to_count"] >= 1
|
||||||
|
assert result["linked_by_count"] >= 1
|
||||||
|
assert String.contains?(result["preview_url"], "draft=true")
|
||||||
|
assert String.contains?(result["preview_url"], "lang=de")
|
||||||
|
assert result["published_translation_language"] == "de"
|
||||||
|
assert result["discarded_title"] == "Source Post Draft"
|
||||||
|
assert result["discarded_status"] == "published"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "project scripting exposes remaining app and metadata parity helpers", %{project: project} do
|
||||||
|
sample_file_path = write_binary_fixture(project.data_path, "show-me.txt", "hello")
|
||||||
|
|
||||||
|
source =
|
||||||
|
[
|
||||||
|
"function main()",
|
||||||
|
" local bookmarklet = bds.app.get_blogmark_bookmarklet()",
|
||||||
|
" local copied = bds.app.copy_to_clipboard('copied from lua')",
|
||||||
|
" local metrics = bds.app.get_title_bar_metrics()",
|
||||||
|
" local ready = bds.app.notify_renderer_ready()",
|
||||||
|
" bds.app.set_preview_post_target(nil)",
|
||||||
|
" local open_result = bds.app.open_folder('" <> escape_lua_string(project.data_path) <> "')",
|
||||||
|
" bds.app.show_item_in_folder('" <> escape_lua_string(sample_file_path) <> "')",
|
||||||
|
" bds.app.trigger_menu_action('new_post')",
|
||||||
|
" local startup = bds.meta.sync_on_startup()",
|
||||||
|
" return {",
|
||||||
|
" bookmarklet_prefix = string.sub(bookmarklet, 1, 19),",
|
||||||
|
" copied = copied,",
|
||||||
|
" metrics_type = metrics == nil and 'nil' or 'table',",
|
||||||
|
" ready = ready,",
|
||||||
|
" open_result = open_result,",
|
||||||
|
" startup_tags = #startup.tags,",
|
||||||
|
" startup_categories = #startup.categories,",
|
||||||
|
" startup_project_name = startup.project_metadata.name",
|
||||||
|
" }",
|
||||||
|
"end"
|
||||||
|
]
|
||||||
|
|> Enum.join("\n")
|
||||||
|
|
||||||
|
assert {:ok, result} = BDS.Scripting.execute_project_script(project.id, source, "main")
|
||||||
|
|
||||||
|
assert String.starts_with?(result["bookmarklet_prefix"], "javascript:(()=>{")
|
||||||
|
assert result["copied"] == true
|
||||||
|
assert result["metrics_type"] in ["nil", "table"]
|
||||||
|
assert result["ready"] == true
|
||||||
|
assert result["open_result"] == ""
|
||||||
|
assert result["startup_tags"] >= 0
|
||||||
|
assert result["startup_categories"] >= 1
|
||||||
|
assert result["startup_project_name"] == "Scripting API"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp write_binary_fixture(base_dir, name, contents) do
|
||||||
|
path = Path.join(base_dir, name)
|
||||||
|
File.write!(path, contents)
|
||||||
|
path
|
||||||
|
end
|
||||||
|
|
||||||
|
defp escape_lua_string(value) do
|
||||||
|
value
|
||||||
|
|> String.replace("\\", "\\\\")
|
||||||
|
|> String.replace("'", "\\'")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user