feat: D1-15 implement drag-and-drop image chain (import+thumbnails+link+insert) with tests
This commit is contained in:
110
lib/bds/desktop/shell_live/editor_image_drop.ex
Normal file
110
lib/bds/desktop/shell_live/editor_image_drop.ex
Normal file
@@ -0,0 +1,110 @@
|
||||
defmodule BDS.Desktop.ShellLive.EditorImageDrop do
|
||||
@moduledoc false
|
||||
|
||||
# Implements the drag-and-drop image chain described in
|
||||
# action_patterns.allium DragDropImageChain (trigger: editor_post.allium
|
||||
# PostDragDropImage). A single image file dropped on the post editor body
|
||||
# runs four synchronous steps the user waits on, then two background steps
|
||||
# whose results are auto-applied without a modal.
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.{AI, Media, Metadata, Posts}
|
||||
|
||||
@doc """
|
||||
Synchronous portion of the chain (steps 1-4):
|
||||
|
||||
1. importMedia(file) -> media record + file copy + base sidecar
|
||||
2. generateThumbnails(media) -> small/medium/large/ai (done inside import_media)
|
||||
3. linkMediaToPost(media, post) -> update sidecar linkedPostIds
|
||||
4. caller inserts the returned markdown at the cursor
|
||||
|
||||
Returns `{:ok, media, markdown}` where `markdown` is the reference inserted at
|
||||
the cursor. These steps are not AI activities, so they run regardless of
|
||||
airplane mode.
|
||||
"""
|
||||
@spec import_and_link(String.t(), String.t(), String.t()) ::
|
||||
{:ok, Media.Media.t(), String.t()} | {:error, term()}
|
||||
def import_and_link(project_id, post_id, source_path) do
|
||||
with {:ok, media} <-
|
||||
Media.import_media(%{project_id: project_id, source_path: source_path}),
|
||||
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
||||
{:ok, media, markdown_for(media)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Markdown reference inserted at the cursor (step 4): `` for
|
||||
images, a plain link for other file types.
|
||||
"""
|
||||
@spec markdown_for(Media.Media.t()) :: String.t()
|
||||
def markdown_for(media) do
|
||||
if String.starts_with?(media.mime_type || "", "image/") do
|
||||
""
|
||||
else
|
||||
"[#{media.original_name}](bds-media://#{media.id})"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Background portion of the chain (steps 5-6), gated behind airplane mode:
|
||||
|
||||
5. aiImageAnalysis(media) -> results auto-applied to media metadata (no modal)
|
||||
6. if auto-translate enabled (post.do_not_translate == false):
|
||||
translateMediaMetadata(media, lang) for each blog language
|
||||
|
||||
Only runs for images. Failures are logged and never roll back the import.
|
||||
"""
|
||||
@spec enrich(Media.Media.t(), String.t(), String.t()) :: :ok
|
||||
def enrich(media, post_id, language) do
|
||||
if image?(media) do
|
||||
with {: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
|
||||
}) do
|
||||
maybe_translate(media.id, post_id, language)
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Drag-drop AI analysis failed for #{media.id}: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp maybe_translate(media_id, post_id, language) do
|
||||
post = Posts.get_post(post_id)
|
||||
|
||||
if post && not post.do_not_translate do
|
||||
translate_targets(post.project_id, language)
|
||||
|> Enum.each(fn target ->
|
||||
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
|
||||
})
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Drag-drop media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp translate_targets(project_id, language) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
|
||||
[metadata.main_language | metadata.blog_languages || []]
|
||||
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp image?(media), do: String.starts_with?(media.mime_type || "", "image/")
|
||||
end
|
||||
@@ -3,9 +3,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
alias BDS.{AI, Posts, Preview}
|
||||
alias BDS.{AI, Metadata, Posts, Preview}
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify}
|
||||
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Tags
|
||||
@@ -212,6 +212,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
{:noreply, do_archive(socket)}
|
||||
end
|
||||
|
||||
def handle_event("editor_image_dropped", %{"path" => path}, socket)
|
||||
when is_binary(path) do
|
||||
{:noreply, do_image_drop(socket, path)}
|
||||
end
|
||||
|
||||
def handle_event("unarchive_post_editor", _params, socket) do
|
||||
{:noreply, do_unarchive(socket)}
|
||||
end
|
||||
@@ -618,6 +623,56 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
end
|
||||
end
|
||||
|
||||
# Drag-and-drop image chain (action_patterns.allium DragDropImageChain).
|
||||
# Steps 1-4 run synchronously while the user waits; steps 5-6 (AI analysis +
|
||||
# auto-translate) run in the background and are gated behind airplane mode.
|
||||
defp do_image_drop(socket, path) do
|
||||
case socket.assigns.post do
|
||||
%Post{} = post ->
|
||||
case EditorImageDrop.import_and_link(post.project_id, post.id, path) do
|
||||
{:ok, media, markdown} ->
|
||||
maybe_enrich_dropped_image(media, post)
|
||||
|
||||
socket
|
||||
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
|
||||
id: socket.assigns.post_id,
|
||||
content: markdown
|
||||
})
|
||||
|> notify_output(
|
||||
dgettext("ui", "Insert Image"),
|
||||
dgettext("ui", "Added %{name}", name: media.original_name)
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
notify_output(
|
||||
socket,
|
||||
dgettext("ui", "Insert Image"),
|
||||
dgettext("ui", "Failed to import %{path}: %{reason}",
|
||||
path: Path.basename(path),
|
||||
reason: inspect(reason)
|
||||
),
|
||||
"error"
|
||||
)
|
||||
end
|
||||
|
||||
_other ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_enrich_dropped_image(media, post) do
|
||||
unless AI.airplane_mode?() do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(post.project_id)
|
||||
language = metadata.main_language || "en"
|
||||
|
||||
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||
EditorImageDrop.enrich(media, post.id, language)
|
||||
end)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp do_unarchive(socket) do
|
||||
case socket.assigns.post do
|
||||
nil ->
|
||||
|
||||
@@ -436,11 +436,14 @@
|
||||
class="post-editor-markdown-surface monaco-editor-shell"
|
||||
data-testid="post-editor-markdown-surface"
|
||||
phx-hook="MonacoEditor"
|
||||
phx-target={@myself}
|
||||
data-monaco-editor-id={@post_editor.id}
|
||||
data-monaco-input-id={"post-editor-content-#{@post_editor.id}"}
|
||||
data-monaco-language="markdown-with-macros"
|
||||
data-monaco-word-wrap="on"
|
||||
data-monaco-insert-event="post-editor-insert-content"
|
||||
data-monaco-drop-event="editor_image_dropped"
|
||||
data-monaco-drop-post-id={@post_editor.id}
|
||||
>
|
||||
<div id={"post-editor-monaco-#{@post_editor.id}"} class="monaco-editor-instance min-h-0 flex-1" phx-update="ignore"></div>
|
||||
<textarea id={"post-editor-content-#{@post_editor.id}"} class="monaco-editor-input post-editor-content" data-testid="post-editor-content" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18" spellcheck="false"><%= @post_editor.form["content"] %></textarea>
|
||||
|
||||
Reference in New Issue
Block a user