feat: completed hopefully api parity

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-25 08:28:49 +02:00
parent 67ecc5ab3d
commit e37d0bb483
7 changed files with 1869 additions and 18 deletions

714
API.md
View File

@@ -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.

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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"},

View File

@@ -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

View File

@@ -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