257 lines
7.2 KiB
Elixir
257 lines
7.2 KiB
Elixir
defmodule BDS.MCP.Resources do
|
|
@moduledoc false
|
|
|
|
import Ecto.Query
|
|
|
|
alias BDS.MCP.ProposalStore
|
|
alias BDS.MCP.Queries
|
|
alias BDS.MCP.Util
|
|
alias BDS.Media.Media, as: MediaAsset
|
|
alias BDS.Metadata
|
|
alias BDS.Posts.PostMedia
|
|
alias BDS.Posts.Post
|
|
alias BDS.Projects
|
|
alias BDS.Repo
|
|
alias BDS.Search
|
|
alias BDS.Tags
|
|
|
|
@typedoc "Resource descriptor returned by `list/0`."
|
|
@type descriptor :: %{name: String.t(), uri: String.t()}
|
|
|
|
@typedoc "Resource template descriptor returned by `templates/0`."
|
|
@type template_descriptor :: %{name: String.t(), uriTemplate: String.t()}
|
|
|
|
@spec list() :: [descriptor()]
|
|
def list do
|
|
[
|
|
%{name: "posts", uri: "bds://posts"},
|
|
%{name: "media", uri: "bds://media"},
|
|
%{name: "tags", uri: "bds://tags"},
|
|
%{name: "categories", uri: "bds://categories"},
|
|
%{name: "stats", uri: "bds://stats"}
|
|
]
|
|
end
|
|
|
|
@spec templates() :: [template_descriptor()]
|
|
def templates do
|
|
[
|
|
%{name: "posts", uriTemplate: "bds://posts{?cursor}"},
|
|
%{name: "media", uriTemplate: "bds://media{?cursor}"},
|
|
%{name: "post media", uriTemplate: "bds://posts/{id}/media"},
|
|
%{name: "media image", uriTemplate: "bds://media/{id}/image"}
|
|
]
|
|
end
|
|
|
|
@spec read(String.t()) :: {:ok, term()} | {:error, term()}
|
|
def read(uri) when is_binary(uri) do
|
|
ProposalStore.ensure_started()
|
|
|
|
case URI.parse(uri) do
|
|
%URI{scheme: "bds", host: "posts", path: nil, query: query} -> posts_resource(query)
|
|
%URI{scheme: "bds", host: "media", path: nil, query: query} -> media_resource(query)
|
|
%URI{scheme: "bds", host: "tags", path: nil} -> {:ok, tags_resource()}
|
|
%URI{scheme: "bds", host: "categories", path: nil} -> {:ok, categories_resource()}
|
|
%URI{scheme: "bds", host: "stats", path: nil} -> {:ok, stats_resource()}
|
|
%URI{scheme: "bds", host: "posts", path: path} -> read_posts_path(path)
|
|
%URI{scheme: "bds", host: "media", path: path} -> read_media_path(path)
|
|
_other -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp posts_resource(query) do
|
|
with {:ok, offset} <- cursor_offset(query) do
|
|
{:ok, posts_page(offset)}
|
|
end
|
|
end
|
|
|
|
defp posts_page(offset) do
|
|
project = Queries.active_project!()
|
|
page_size = Queries.page_size()
|
|
{:ok, result} = Search.search_posts(project.id, "", %{offset: offset, limit: page_size})
|
|
|
|
%{
|
|
"items" => Enum.map(result.posts, &Queries.post_summary/1),
|
|
"total" => result.total,
|
|
"offset" => result.offset,
|
|
"limit" => result.limit
|
|
}
|
|
|> maybe_put_next_cursor(result)
|
|
end
|
|
|
|
defp media_resource(query) do
|
|
with {:ok, offset} <- cursor_offset(query) do
|
|
{:ok, media_page(offset)}
|
|
end
|
|
end
|
|
|
|
defp media_page(offset) do
|
|
project = Queries.active_project!()
|
|
page_size = Queries.page_size()
|
|
{:ok, result} = Search.search_media(project.id, "", %{offset: offset, limit: page_size})
|
|
|
|
%{
|
|
"items" => Enum.map(result.media, &Util.sanitize/1),
|
|
"total" => result.total,
|
|
"offset" => result.offset,
|
|
"limit" => result.limit
|
|
}
|
|
|> maybe_put_next_cursor(result)
|
|
end
|
|
|
|
defp cursor_offset(nil), do: {:ok, 0}
|
|
|
|
defp cursor_offset(query) do
|
|
case URI.decode_query(query) do
|
|
%{"cursor" => cursor} when cursor != "" -> decode_cursor(cursor)
|
|
%{"cursor" => ""} -> {:error, :invalid_cursor}
|
|
_params -> {:ok, 0}
|
|
end
|
|
end
|
|
|
|
defp decode_cursor(cursor) do
|
|
with {:ok, decoded} <- Base.url_decode64(cursor, padding: false),
|
|
{:ok, %{"offset" => offset}} when is_integer(offset) and offset >= 0 <-
|
|
Jason.decode(decoded) do
|
|
{:ok, offset}
|
|
else
|
|
_other -> {:error, :invalid_cursor}
|
|
end
|
|
end
|
|
|
|
defp maybe_put_next_cursor(resource, result) do
|
|
next_offset = result.offset + result.limit
|
|
|
|
if next_offset < result.total do
|
|
Map.put(resource, "nextCursor", encode_cursor(next_offset))
|
|
else
|
|
resource
|
|
end
|
|
end
|
|
|
|
defp encode_cursor(offset) do
|
|
%{"offset" => offset}
|
|
|> Jason.encode!()
|
|
|> Base.url_encode64(padding: false)
|
|
end
|
|
|
|
defp tags_resource do
|
|
project = Queries.active_project!()
|
|
tags = Tags.list_tags(project.id)
|
|
|
|
%{
|
|
"items" =>
|
|
Enum.map(tags, fn tag ->
|
|
%{
|
|
"id" => tag.id,
|
|
"name" => tag.name,
|
|
"color" => tag.color,
|
|
"post_count" => Queries.tag_post_count(project.id, tag.name)
|
|
}
|
|
end)
|
|
}
|
|
end
|
|
|
|
defp categories_resource do
|
|
project = Queries.active_project!()
|
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
|
|
|
%{
|
|
"items" =>
|
|
Enum.map(metadata.categories, fn category ->
|
|
%{"name" => category, "post_count" => Queries.category_post_count(project.id, category)}
|
|
end)
|
|
}
|
|
end
|
|
|
|
defp stats_resource do
|
|
project = Queries.active_project!()
|
|
{:ok, posts} = Search.search_posts(project.id, "", %{offset: 0, limit: 1})
|
|
{:ok, media} = Search.search_media(project.id, "", %{offset: 0, limit: 1})
|
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
|
|
|
%{
|
|
"posts" => posts.total,
|
|
"media" => media.total,
|
|
"tags" => length(Tags.list_tags(project.id)),
|
|
"categories" => length(metadata.categories)
|
|
}
|
|
end
|
|
|
|
defp read_posts_path("/" <> path) do
|
|
case String.split(path, "/", trim: true) do
|
|
[id] -> read_post_resource(id)
|
|
[id, "media"] -> read_post_media_resource(id)
|
|
_parts -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp read_posts_path(_path), do: {:error, :not_found}
|
|
|
|
defp read_media_path("/" <> path) do
|
|
case String.split(path, "/", trim: true) do
|
|
[id] -> read_media_resource(id)
|
|
[id, "image"] -> read_media_image_resource(id)
|
|
_parts -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp read_media_path(_path), do: {:error, :not_found}
|
|
|
|
defp read_post_resource(id) do
|
|
case Repo.get(Post, id) do
|
|
%Post{} = post -> {:ok, Queries.post_detail(post)}
|
|
nil -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp read_media_resource(id) do
|
|
case Repo.get(MediaAsset, id) do
|
|
%MediaAsset{} = media -> {:ok, Util.sanitize(media)}
|
|
nil -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp read_post_media_resource(post_id) do
|
|
case Repo.get(Post, post_id) do
|
|
nil ->
|
|
{:error, :not_found}
|
|
|
|
%Post{} ->
|
|
items =
|
|
Repo.all(
|
|
from media in MediaAsset,
|
|
join: post_media in PostMedia,
|
|
on: post_media.media_id == media.id,
|
|
where: post_media.post_id == ^post_id,
|
|
order_by: [asc: post_media.sort_order, asc: media.updated_at],
|
|
select: media
|
|
)
|
|
|
|
{:ok, %{"items" => Enum.map(items, &Util.sanitize/1)}}
|
|
end
|
|
end
|
|
|
|
defp read_media_image_resource(id) do
|
|
case Repo.get(MediaAsset, id) do
|
|
nil ->
|
|
{:error, :not_found}
|
|
|
|
%MediaAsset{} = media ->
|
|
project = Projects.get_project!(media.project_id)
|
|
full_path = Path.join(Projects.project_data_dir(project), media.file_path || "")
|
|
|
|
case File.read(full_path) do
|
|
{:ok, bytes} ->
|
|
{:ok,
|
|
%{
|
|
"mimeType" => media.mime_type || "application/octet-stream",
|
|
"blob" => Base.encode64(bytes)
|
|
}}
|
|
|
|
{:error, _reason} ->
|
|
{:error, :not_found}
|
|
end
|
|
end
|
|
end
|
|
end
|