feat: added a gallery quick action and fleshed out builtin macros
This commit is contained in:
172
lib/bds/desktop/shell_live/gallery_import.ex
Normal file
172
lib/bds/desktop/shell_live/gallery_import.ex
Normal file
@@ -0,0 +1,172 @@
|
||||
defmodule BDS.Desktop.ShellLive.GalleryImport do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.{AI, Media, Metadata}
|
||||
|
||||
@doc """
|
||||
Starts the image import pipeline: for each selected path, imports the file,
|
||||
runs AI analysis, updates metadata, links to the post, and translates to
|
||||
all configured blog languages.
|
||||
|
||||
Processes images with a concurrency cap via a sliding window.
|
||||
"""
|
||||
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
||||
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
main_language = metadata.main_language || language
|
||||
blog_languages = metadata.blog_languages || []
|
||||
|
||||
translate_targets =
|
||||
[main_language | blog_languages]
|
||||
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||
|> Enum.uniq()
|
||||
|
||||
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
||||
|
||||
tasks =
|
||||
Enum.map(in_flight, fn path ->
|
||||
Task.async(fn ->
|
||||
process_single_image(path, project_id, post_id, language, translate_targets, parent)
|
||||
end)
|
||||
end)
|
||||
|
||||
known_refs = MapSet.new(tasks, & &1.ref)
|
||||
|
||||
drain_tasks(
|
||||
remaining, tasks, known_refs, project_id, post_id, language, translate_targets, parent
|
||||
)
|
||||
|
||||
send(parent, {:add_images_complete, length(paths)})
|
||||
end
|
||||
|
||||
defp drain_tasks(
|
||||
[], tasks, _known_refs, _project_id, _post_id, _language, _translate_targets, _parent
|
||||
) do
|
||||
Enum.each(tasks, fn task -> Task.await(task, :infinity) end)
|
||||
end
|
||||
|
||||
defp drain_tasks(
|
||||
[next_path | rest],
|
||||
tasks,
|
||||
known_refs,
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
) do
|
||||
receive do
|
||||
{ref, _result} when is_reference(ref) ->
|
||||
if MapSet.member?(known_refs, ref) do
|
||||
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
|
||||
|
||||
new_task =
|
||||
Task.async(fn ->
|
||||
process_single_image(
|
||||
next_path, project_id, post_id, language, translate_targets, parent
|
||||
)
|
||||
end)
|
||||
|
||||
drain_tasks(
|
||||
rest,
|
||||
[new_task | remaining_tasks],
|
||||
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
else
|
||||
drain_tasks(
|
||||
[next_path | rest], tasks, known_refs,
|
||||
project_id, post_id, language, translate_targets, parent
|
||||
)
|
||||
end
|
||||
|
||||
{:DOWN, ref, :process, _pid, _reason} when is_reference(ref) ->
|
||||
if MapSet.member?(known_refs, ref) do
|
||||
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
|
||||
|
||||
new_task =
|
||||
Task.async(fn ->
|
||||
process_single_image(
|
||||
next_path, project_id, post_id, language, translate_targets, parent
|
||||
)
|
||||
end)
|
||||
|
||||
drain_tasks(
|
||||
rest,
|
||||
[new_task | remaining_tasks],
|
||||
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
else
|
||||
drain_tasks(
|
||||
[next_path | rest], tasks, known_refs,
|
||||
project_id, post_id, language, translate_targets, parent
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp pop_task_by_ref(tasks, ref) do
|
||||
Enum.reduce(tasks, {nil, []}, fn
|
||||
%{ref: ^ref} = task, {nil, rest} -> {task, rest}
|
||||
task, {found, rest} -> {found, [task | rest]}
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_single_image(
|
||||
path, project_id, post_id, language, translate_targets, parent
|
||||
) do
|
||||
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
|
||||
true <- String.starts_with?(media.mime_type || "", "image/"),
|
||||
{:ok, result} <- AI.analyze_image(media.id, language: language),
|
||||
{:ok, _updated} <- Media.update_media(media.id, %{
|
||||
title: result.title,
|
||||
alt: result.alt,
|
||||
caption: result.caption
|
||||
}),
|
||||
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
||||
translate_media_translations(media.id, translate_targets)
|
||||
title = result.title || media.original_name
|
||||
send(parent, {:add_image_processed, title})
|
||||
else
|
||||
false ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Image pipeline error for #{path}: #{inspect(reason)}")
|
||||
send(parent, {:add_image_error, path, reason})
|
||||
end
|
||||
end
|
||||
|
||||
defp translate_media_translations(_media_id, []), do: :ok
|
||||
|
||||
defp translate_media_translations(media_id, [target | rest]) do
|
||||
case AI.translate_media(media_id, target) do
|
||||
{:ok, translation} ->
|
||||
Media.upsert_media_translation(media_id, target, %{
|
||||
title: translation.title,
|
||||
alt: translation.alt,
|
||||
caption: translation.caption
|
||||
})
|
||||
|
||||
translate_media_translations(media_id, rest)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
translate_media_translations(media_id, rest)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -168,11 +168,19 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|
||||
|
||||
@spec gallery_count(term()) :: term()
|
||||
def gallery_count(form) do
|
||||
form
|
||||
|> Map.get("content", "")
|
||||
|> to_string()
|
||||
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|
||||
|> length()
|
||||
content = form |> Map.get("content", "") |> to_string()
|
||||
|
||||
image_count =
|
||||
content
|
||||
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|
||||
|> length()
|
||||
|
||||
gallery_macro_count =
|
||||
content
|
||||
|> then(&Regex.scan(~r/\[\[gallery\]\]/i, &1))
|
||||
|> length()
|
||||
|
||||
max(image_count, gallery_macro_count)
|
||||
end
|
||||
|
||||
@spec preview_url(term(), term(), term(), term()) :: term()
|
||||
|
||||
@@ -362,6 +362,14 @@
|
||||
>
|
||||
<%= dgettext("ui", "Insert Media") %>
|
||||
</button>
|
||||
<button
|
||||
class="add-gallery-images-button"
|
||||
type="button"
|
||||
phx-click="add_gallery_images"
|
||||
phx-value-post-id={@post_editor.id}
|
||||
>
|
||||
<%= dgettext("ui", "Add Gallery Images") %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @post_editor.gallery_count > 0 do %>
|
||||
|
||||
@@ -22,6 +22,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
||||
"main_language" => metadata.main_language || "en",
|
||||
"default_author" => metadata.default_author || "",
|
||||
"max_posts_per_page" => Integer.to_string(metadata.max_posts_per_page),
|
||||
"image_import_concurrency" => Integer.to_string(metadata.image_import_concurrency),
|
||||
"blogmark_category" =>
|
||||
metadata.blogmark_category ||
|
||||
List.first(metadata.categories) || "article",
|
||||
@@ -71,6 +72,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
||||
main_language: blank_to_nil(Map.get(draft, "main_language")),
|
||||
default_author: blank_to_nil(Map.get(draft, "default_author")),
|
||||
max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50),
|
||||
image_import_concurrency: parse_integer(Map.get(draft, "image_import_concurrency"), 4),
|
||||
blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")),
|
||||
blog_languages: Map.get(draft, "blog_languages", []),
|
||||
semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled"))
|
||||
@@ -85,6 +87,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
||||
"main_language" => Map.get(params, "main_language", "en"),
|
||||
"default_author" => Map.get(params, "default_author", ""),
|
||||
"max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"),
|
||||
"image_import_concurrency" => Map.get(params, "image_import_concurrency", "4"),
|
||||
"blogmark_category" => Map.get(params, "blogmark_category", "article"),
|
||||
"blog_languages" => List.wrap(Map.get(params, "blog_languages", [])),
|
||||
"semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled"))
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Max Posts Per Page") %></label></div>
|
||||
<div class="setting-control"><input class="ui-input" type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Image Import Concurrency") %></label></div>
|
||||
<div class="setting-control"><input class="ui-input" type="number" min="1" max="8" name="settings_project[image_import_concurrency]" value={@settings_editor.project["image_import_concurrency"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Category") %></label></div>
|
||||
<div class="setting-control">
|
||||
|
||||
Reference in New Issue
Block a user