feat: added a gallery quick action and fleshed out builtin macros

This commit is contained in:
2026-05-28 17:19:49 +02:00
parent 1914b05f39
commit f99e139fa5
31 changed files with 5907 additions and 4316 deletions

View File

@@ -113,6 +113,7 @@ This document provides context and best practices for GitHub Copilot when workin
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
- English fallback is allowed only when the requested locale is unsupported by available locale files
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
- When adding new `msgid` entries, you MUST provide translations for ALL supported locales (de, fr, it, es) — empty `msgstr` values are not acceptable
> **No hardcoded user-facing text. No exceptions.**

View File

@@ -12,6 +12,17 @@ defmodule BDS.Desktop.FilePicker do
end
end
def choose_files(prompt, opts \\ []) when is_binary(prompt) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
:cancel
else
case :os.type() do
{:unix, :darwin} -> choose_files_macos(prompt, opts)
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
end
end
end
defp choose_file_macos(prompt) do
script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")"
@@ -21,6 +32,50 @@ defmodule BDS.Desktop.FilePicker do
end
end
defp choose_files_macos(prompt, opts) do
multiple = Keyword.get(opts, :multiple, false)
image_only = Keyword.get(opts, :image_only, false)
script_parts = ["POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\""]
script_parts =
if image_only do
script_parts ++ [" of type {\"public.image\"}"]
else
script_parts
end
script_parts =
if multiple do
script_parts ++ [" with multiple selections allowed"]
else
script_parts
end
script = Enum.join(script_parts, "") <> ")"
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
{output, 0} -> parse_choose_files_result(String.trim(output), multiple)
{output, _status} -> normalize_picker_failure(output)
end
end
@doc false
def parse_choose_files_result(output, true = _multiple) do
paths =
output
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
{:ok, paths}
end
@doc false
def parse_choose_files_result(output, false = _multiple) do
{:ok, output}
end
defp normalize_picker_failure(output) do
message = String.trim(output)

View File

@@ -35,7 +35,8 @@ defmodule BDS.Desktop.Overlay do
title: Map.get(context, :insert_media_title, "Insert Media"),
search_query: "",
results: Enum.map(media, &to_insert_media_result/1),
all_media: media
all_media: media,
post_id: current_id(context)
}
end

View File

@@ -5,13 +5,14 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML
alias BDS.{AI, BoundedAtoms}
alias BDS.{AI, BoundedAtoms, Metadata}
alias BDS.CliSync.Watcher
alias BDS.Desktop.{ExternalLinks, FolderPicker, ShellData, UILocale}
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
alias BDS.Desktop.ShellLive.{
Bridges,
ChatEditor,
GalleryImport,
ImportEditor,
MediaEditor,
MenuEditor,
@@ -399,6 +400,41 @@ defmodule BDS.Desktop.ShellLive do
def handle_event("overlay_lightbox_next", params, socket),
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
if socket.assigns.offline_mode do
{:noreply,
append_output_entry(
socket,
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)}
else
project_id = socket.assigns.projects.active_project_id
{:ok, metadata} = Metadata.get_project_metadata(project_id)
concurrency_limit = metadata.image_import_concurrency
language = metadata.main_language || "en"
parent = self()
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
case FilePicker.choose_files(dgettext("ui", "Add Gallery Images"),
image_only: true, multiple: true) do
{:ok, paths} when is_list(paths) and paths != [] ->
GalleryImport.start(paths, project_id, post_id, language, concurrency_limit, parent)
:cancel ->
send(parent, {:add_images_cancelled})
{:error, reason} ->
send(parent, {:add_images_error, reason})
end
end)
{:noreply, assign(socket, :gallery_import_post_id, post_id)}
end
end
def handle_event("toggle_project_menu", _params, socket) do
{:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
end
@@ -580,6 +616,68 @@ defmodule BDS.Desktop.ShellLive do
OverlayManager.handle_info({:ai_suggestions_error, type, id, reason}, socket)
end
def handle_info({:add_image_processed, title}, socket) do
{:noreply,
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), dgettext("ui", "Added %{title}", title: title), nil, "info")}
end
def handle_info({:add_images_complete, count}, socket) do
post_id = socket.assigns[:gallery_import_post_id]
socket =
if is_binary(post_id) do
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :insert_content,
content: "\n[[gallery]]\n"
)
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :refresh
)
socket
|> assign(:gallery_import_post_id, nil)
else
socket
end
{:noreply,
socket
|> append_output_entry(
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Added %{count} images to post", count: count),
nil,
"info"
)}
end
def handle_info({:add_images_error, reason}, socket) do
{:noreply,
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), inspect(reason), nil, "error")}
end
def handle_info({:add_image_error, path, reason}, socket) do
{:noreply,
append_output_entry(
socket,
dgettext("ui", "Add Gallery Images"),
dgettext("ui", "Failed to process %{path}: %{reason}", path: Path.basename(path), reason: inspect(reason)),
nil,
"error"
)}
end
def handle_info({:add_images_cancelled}, socket) do
{:noreply, socket}
end
def handle_info({:test_ping, caller, ref}, socket) do
send(caller, {:test_pong, ref})
{:noreply, socket}
end
def handle_info(message, socket) do
Bridges.handle_info(message, socket, bridges_callbacks())
end

View 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,9 @@ defmodule BDS.Metadata do
@default_categories ["article", "aside", "page", "picture"]
@min_posts_per_page 1
@max_posts_per_page 500
@default_image_import_concurrency 4
@min_image_import_concurrency 1
@max_image_import_concurrency 8
@supported_pico_themes MapSet.new([
"default",
"amber",
@@ -70,6 +73,7 @@ defmodule BDS.Metadata do
:main_language,
:default_author,
:max_posts_per_page,
:image_import_concurrency,
:blogmark_category,
:pico_theme,
:semantic_similarity_enabled,
@@ -238,6 +242,8 @@ defmodule BDS.Metadata do
default_author: Map.get(project_metadata, "default_author"),
max_posts_per_page:
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
image_import_concurrency:
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
blogmark_category: Map.get(project_metadata, "blogmark_category"),
pico_theme: Map.get(project_metadata, "pico_theme"),
semantic_similarity_enabled:
@@ -274,6 +280,8 @@ defmodule BDS.Metadata do
default_author: Map.get(project_metadata, "default_author"),
max_posts_per_page:
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
image_import_concurrency:
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
blogmark_category: Map.get(project_metadata, "blogmark_category"),
pico_theme: Map.get(project_metadata, "pico_theme"),
semantic_similarity_enabled:
@@ -293,6 +301,7 @@ defmodule BDS.Metadata do
main_language: nil,
default_author: nil,
max_posts_per_page: @default_max_posts_per_page,
image_import_concurrency: @default_image_import_concurrency,
blogmark_category: nil,
pico_theme: nil,
semantic_similarity_enabled: false,
@@ -308,6 +317,8 @@ defmodule BDS.Metadata do
main_language: normalize_optional_language(attr(attrs, :main_language)),
default_author: attr(attrs, :default_author),
max_posts_per_page: normalize_posts_per_page(attr(attrs, :max_posts_per_page)),
image_import_concurrency:
normalize_image_import_concurrency(attr(attrs, :image_import_concurrency)),
blogmark_category: attr(attrs, :blogmark_category),
pico_theme: normalize_pico_theme(attr(attrs, :pico_theme)),
semantic_similarity_enabled: attr(attrs, :semantic_similarity_enabled) || false,
@@ -342,6 +353,7 @@ defmodule BDS.Metadata do
"main_language" => project_metadata.main_language,
"default_author" => project_metadata.default_author,
"max_posts_per_page" => project_metadata.max_posts_per_page,
"image_import_concurrency" => project_metadata.image_import_concurrency,
"blogmark_category" => project_metadata.blogmark_category,
"pico_theme" => project_metadata.pico_theme,
"semantic_similarity_enabled" => project_metadata.semantic_similarity_enabled,
@@ -429,6 +441,8 @@ defmodule BDS.Metadata do
"main_language" => Map.get(payload, "mainLanguage"),
"default_author" => Map.get(payload, "defaultAuthor"),
"max_posts_per_page" => Map.get(payload, "maxPostsPerPage", @default_max_posts_per_page),
"image_import_concurrency" =>
Map.get(payload, "imageImportConcurrency", @default_image_import_concurrency),
"blogmark_category" => Map.get(payload, "blogmarkCategory"),
"pico_theme" => Map.get(payload, "picoTheme"),
"semantic_similarity_enabled" => Map.get(payload, "semanticSimilarityEnabled", false),
@@ -505,6 +519,8 @@ defmodule BDS.Metadata do
"defaultAuthor" => Map.get(project_metadata, "default_author"),
"maxPostsPerPage" =>
Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
"imageImportConcurrency" =>
Map.get(project_metadata, "image_import_concurrency", @default_image_import_concurrency),
"blogmarkCategory" => Map.get(project_metadata, "blogmark_category"),
"picoTheme" => Map.get(project_metadata, "pico_theme"),
"semanticSimilarityEnabled" =>
@@ -576,6 +592,23 @@ defmodule BDS.Metadata do
defp normalize_posts_per_page(_value), do: @default_max_posts_per_page
defp normalize_image_import_concurrency(nil), do: @default_image_import_concurrency
defp normalize_image_import_concurrency(value) when is_integer(value) do
value
|> max(@min_image_import_concurrency)
|> min(@max_image_import_concurrency)
end
defp normalize_image_import_concurrency(value) when is_binary(value) do
case Integer.parse(String.trim(value)) do
{integer, ""} -> normalize_image_import_concurrency(integer)
_ -> @default_image_import_concurrency
end
end
defp normalize_image_import_concurrency(_value), do: @default_image_import_concurrency
defp normalize_optional_language(nil), do: nil
defp normalize_optional_language(""), do: nil

View File

@@ -3,7 +3,14 @@ defmodule BDS.Rendering.Filters do
use Liquex.Filter
import Ecto.Query
alias BDS.Slug
alias BDS.{Repo}
alias BDS.Media.Media, as: MediaRecord
alias BDS.Posts.{Post, PostMedia}
alias BDS.Tags.Tag
require Logger
@spec i18n(term(), String.t(), Liquex.Context.t()) :: String.t()
def i18n(value, language, _context) do
@@ -28,7 +35,7 @@ defmodule BDS.Rendering.Filters do
) :: String.t()
def markdown(
value,
_post_id,
post_id,
_post_data_json_by_id,
canonical_post_paths,
canonical_media_paths,
@@ -36,15 +43,15 @@ defmodule BDS.Rendering.Filters do
_language_prefix,
context
) do
render_markdown(value, canonical_post_paths, canonical_media_paths, language, context)
render_markdown(value, canonical_post_paths, canonical_media_paths, language, context, post_id)
end
@spec render_markdown(term(), map() | nil, map() | nil, String.t(), Liquex.Context.t()) ::
@spec render_markdown(term(), map() | nil, map() | nil, String.t(), Liquex.Context.t(), term()) ::
String.t()
def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context) do
def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context, post_id \\ nil) do
value
|> to_string()
|> replace_built_in_macros(language, context)
|> replace_built_in_macros(language, context, post_id)
|> render_markdown_html()
|> rewrite_rendered_html_urls(canonical_post_paths || %{}, canonical_media_paths || %{})
end
@@ -56,7 +63,7 @@ defmodule BDS.Rendering.Filters do
|> Slug.slugify()
end
defp replace_built_in_macros(content, language, context) do
defp replace_built_in_macros(content, language, context, post_id) do
Regex.replace(~r/\[\[(\w+)(?:\s+([^\]]+))?\]\]/, content, fn full_match,
macro_name,
raw_params ->
@@ -88,6 +95,15 @@ defmodule BDS.Rendering.Filters do
context
)
"gallery" ->
render_gallery_macro(context, params, post_id)
"photo_archive" ->
render_photo_archive_macro(context, params)
"tag_cloud" ->
render_tag_cloud_macro(context, params)
_other ->
full_match
end
@@ -127,23 +143,14 @@ defmodule BDS.Rendering.Filters do
end
defp render_macro_template(template_path, assigns, context) do
case Map.get(assigns, "id") do
"" ->
case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do
{:ok, template_source} ->
render_macro_source(template_path, template_source, assigns, context)
{:error, :enoent} ->
require Logger
Logger.warning("Macro template not found: #{template_path}")
""
nil ->
""
_id ->
case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do
{:ok, template_source} ->
render_macro_source(template_path, template_source, assigns, context)
{:error, :enoent} ->
require Logger
Logger.warning("Macro template not found: #{template_path}")
""
end
end
end
@@ -285,4 +292,307 @@ defmodule BDS.Rendering.Filters do
defp ensure_leading_slash("/" <> _rest = path), do: path
defp ensure_leading_slash(path), do: "/" <> path
# ── Built-in macro renderers ───────────────────────────────────────────────
defp render_gallery_macro(context, params, post_id) when is_binary(post_id) do
columns = normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6)
caption = Map.get(params, "caption")
items =
post_id
|> linked_media_images()
|> Enum.map(fn media ->
%{
"media_path" => "/#{media.file_path}",
"title" => media.title || media.original_name,
"alt" => media.alt || media.title || media.original_name,
"group_name" => post_id
}
end)
render_macro_template(
"macros/gallery",
%{
"columns" => columns,
"post_id" => post_id,
"items" => items,
"caption" => caption,
"empty_label" =>
BDS.Gettext.lgettext(
Access.get(context, "language") || "en",
"render",
"No images"
)
},
context
)
end
defp render_gallery_macro(context, params, _post_id) do
render_macro_template(
"macros/gallery",
%{
"columns" => normalize_columns(Map.get(params, "columns", "3"), 3, 1, 6),
"post_id" => "",
"items" => [],
"caption" => Map.get(params, "caption"),
"empty_label" =>
BDS.Gettext.lgettext(
Access.get(context, "language") || "en",
"render",
"No images"
)
},
context
)
end
defp render_photo_archive_macro(context, params) do
language = Access.get(context, "language") || "en"
project_id = project_id_from_context(context)
months =
if project_id do
media_month_archive(project_id, Map.get(params, "year"), Map.get(params, "month"))
else
[]
end
render_macro_template(
"macros/photo-archive",
%{
"root_classes" => "macro-photo-archive",
"data_attrs" => [],
"months" => months,
"empty_label" =>
BDS.Gettext.lgettext(language, "render", "No photos found")
},
context
)
end
defp render_tag_cloud_macro(context, params) do
language = Access.get(context, "language") || "en"
project_id = project_id_from_context(context)
{words_json, width, height} =
if project_id do
build_tag_cloud_data(project_id)
else
{nil, 800, 400}
end
render_macro_template(
"macros/tag-cloud",
%{
"orientation" => Map.get(params, "orientation", "horizontal"),
"words_json" => words_json,
"width" => Map.get(params, "width", width),
"height" => Map.get(params, "height", height),
"aria_label" => "Tag cloud",
"empty_label" =>
BDS.Gettext.lgettext(language, "render", "No tags")
},
context
)
end
# ── Data queries for macros ────────────────────────────────────────────────
defp linked_media_images(post_id) do
Repo.all(
from pm in PostMedia,
join: m in MediaRecord,
on: pm.media_id == m.id,
where: pm.post_id == ^post_id,
where: like(m.mime_type, "image/%"),
order_by: [asc: pm.sort_order, asc: pm.media_id],
select: m
)
end
defp media_month_archive(project_id, year, month) do
query =
from m in MediaRecord,
where: m.project_id == ^project_id,
where: like(m.mime_type, "image/%"),
order_by: [desc: m.created_at],
select: m
query =
if year do
year_int = parse_integer(year)
if month do
month_int = parse_integer(month)
start_ts = month_start_ms(year_int, month_int)
end_ts = month_end_ms(year_int, month_int)
from m in query,
where: m.created_at >= ^start_ts and m.created_at <= ^end_ts
else
start_ts = month_start_ms(year_int, 1)
end_ts = month_end_ms(year_int, 12)
from m in query,
where: m.created_at >= ^start_ts and m.created_at <= ^end_ts
end
else
from m in query, limit: 200
end
media_records =
query
|> Repo.all()
|> group_by_media_month()
if year == nil do
Enum.take(media_records, 10)
else
media_records
end
end
defp group_by_media_month(media_records) do
month_names = %{
1 => "January", 2 => "February", 3 => "March", 4 => "April",
5 => "May", 6 => "June", 7 => "July", 8 => "August",
9 => "September", 10 => "October", 11 => "November", 12 => "December"
}
media_records
|> Enum.group_by(fn m ->
date = DateTime.from_unix!(div(m.created_at, 1000))
{date.year, date.month}
end)
|> Enum.sort_by(fn {{y, m}, _} -> {y, m} end, :desc)
|> Enum.map(fn {{year, month}, items} ->
%{
"label" => "#{Map.get(month_names, month)} #{year}",
"items" =>
Enum.map(items, fn m ->
%{
"media_path" => "/#{m.file_path}",
"title" => m.title || m.original_name,
"alt" => m.alt || m.title || m.original_name,
"group_name" => "#{year}-#{month}"
}
end)
}
end)
end
defp build_tag_cloud_data(project_id) do
tag_colors =
Repo.all(
from tag in Tag,
where: tag.project_id == ^project_id,
where: not is_nil(tag.color) and tag.color != "",
select: {tag.name, tag.color}
)
|> Map.new()
%{rows: rows} =
Ecto.Adapters.SQL.query!(
Repo,
"""
SELECT trim(je.value) AS tag, COUNT(*) AS cnt
FROM posts, json_each(posts.tags) je
WHERE posts.project_id = ?1
AND trim(je.value) != ''
GROUP BY tag
ORDER BY cnt DESC, lower(tag) ASC
""",
[project_id]
)
tag_entries =
Enum.map(rows, fn [tag, count] ->
%{tag: tag, count: count, color: Map.get(tag_colors, tag)}
end)
if tag_entries == [] do
{nil, 0, 0}
else
max_count = Enum.map(tag_entries, & &1.count) |> Enum.max()
min_count = Enum.map(tag_entries, & &1.count) |> Enum.min()
range = max(max_count - min_count, 1)
words =
Enum.map(tag_entries, fn %{tag: tag, count: count, color: color} ->
size = 12.0 + (count - min_count) / range * 28.0
%{"text" => tag, "size" => size, "color" => color || "var(--accent-color)"}
end)
{Jason.encode!(words), 800, 400}
end
end
# ── Helpers ────────────────────────────────────────────────────────────────
defp project_id_from_context(context) do
post = Access.get(context, "post") || %{}
post["project_id"] || Access.get(post, :project_id) || project_id_from_post(context)
end
defp project_id_from_post(context) do
post_id =
Access.get(Access.get(context, "post") || %{}, "id") ||
Access.get(Access.get(context, "post") || %{}, :id)
if is_binary(post_id) do
case Repo.one(from p in Post, where: p.id == ^post_id, select: p.project_id) do
nil -> nil
project_id -> project_id
end
end
end
defp normalize_columns(value, default, min, max) when is_binary(value) do
case Integer.parse(value) do
{n, ""} -> n |> max(min) |> min(max)
_ -> default
end
end
defp normalize_columns(value, _default, min, max) when is_integer(value),
do: value |> max(min) |> min(max)
defp normalize_columns(_value, default, _min, _max), do: default
defp parse_integer(value) when is_binary(value) do
case Integer.parse(value) do
{n, ""} -> n
_ -> nil
end
end
defp parse_integer(value) when is_integer(value), do: value
defp parse_integer(_value), do: nil
defp month_start_ms(year, month) do
case DateTime.from_iso8601("#{year}-#{pad(month)}-01T00:00:00Z") do
{:ok, dt, _} -> DateTime.to_unix(dt, :millisecond)
_ -> 0
end
end
defp month_end_ms(year, month) do
last_day =
if month == 12 do
31
else
case DateTime.from_iso8601("#{year}-#{pad(month + 1)}-01T00:00:00Z") do
{:ok, dt, _} ->
dt |> DateTime.add(-1, :second) |> DateTime.to_date() |> Map.get(:day)
_ ->
31
end
end
case DateTime.from_iso8601("#{year}-#{pad(month)}-#{pad(last_day)}T23:59:59.999Z") do
{:ok, dt, _} -> DateTime.to_unix(dt, :millisecond)
_ -> 0
end
end
defp pad(n) when is_integer(n), do: n |> Integer.to_string() |> String.pad_leading(2, "0")
end

View File

@@ -10,7 +10,9 @@ defmodule BDS.Rendering.PostRendering do
alias BDS.Rendering.TemplateSelection
alias BDS.MapUtils
alias BDS.Posts.Post
alias BDS.Posts.PostMedia
alias BDS.Posts.Translation
alias BDS.Media.Media, as: MediaRecord
alias BDS.Repo
@spec post_assigns(String.t(), map()) :: map()
@@ -39,7 +41,8 @@ defmodule BDS.Rendering.PostRendering do
canonical_post_paths,
canonical_media_paths,
language,
template_context
template_context,
post_id
)
incoming_links =
@@ -208,6 +211,7 @@ defmodule BDS.Rendering.PostRendering do
title: MapUtils.attr(assigns, :title),
content: MapUtils.attr(assigns, :content),
raw_content: MapUtils.attr(assigns, :raw_content),
project_id: MapUtils.attr(assigns, :project_id) || Map.get(post_record || %{}, :project_id),
excerpt:
Map.get(
assigns,
@@ -234,7 +238,7 @@ defmodule BDS.Rendering.PostRendering do
MapUtils.attr(assigns, :template_slug)
),
do_not_translate: Map.get(post_record || %{}, :do_not_translate, false),
linked_media: [],
linked_media: linked_media_images(assigns),
outgoing_links: outgoing_links,
incoming_links: incoming_links
}
@@ -245,21 +249,42 @@ defmodule BDS.Rendering.PostRendering do
map(),
map(),
String.t(),
Liquex.Context.t()
Liquex.Context.t(),
term()
) :: String.t()
def render_post_content(
content,
canonical_post_paths,
canonical_media_paths,
language,
template_context
template_context,
post_id \\ nil
) do
Filters.render_markdown(
content,
canonical_post_paths,
canonical_media_paths,
language,
template_context
template_context,
post_id
)
end
defp linked_media_images(assigns) do
post_id = MapUtils.attr(assigns, :id)
if is_binary(post_id) do
Repo.all(
from pm in PostMedia,
join: m in MediaRecord,
on: pm.media_id == m.id,
where: pm.post_id == ^post_id,
where: like(m.mime_type, "image/%"),
order_by: [asc: pm.sort_order, asc: pm.media_id],
select: m
)
else
[]
end
end
end

View File

@@ -1,156 +1,156 @@
#: lib/bds/rendering/labels.ex:17
#: lib/bds/ui/sidebar.ex:241
#: lib/bds/ui/sidebar.ex:316
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archiv"
#: lib/bds/rendering/labels.ex:52
#: lib/bds/rendering/labels.ex:54
#, elixir-autogen, elixir-format
msgid "April"
msgstr "Apr."
#: lib/bds/rendering/labels.ex:24
#: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format, fuzzy
msgid "Archive calendar"
msgstr "Archiv"
#: lib/bds/rendering/labels.ex:68
#: lib/bds/rendering/labels.ex:70
#, elixir-autogen, elixir-format
msgid "August"
msgstr "Aug."
#: lib/bds/rendering/labels.ex:15
#: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format
msgid "Backlinks"
msgstr "Rückverweise"
#: lib/bds/rendering/labels.ex:23
#: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded."
msgstr "Kalenderdaten konnten nicht geladen werden."
#: lib/bds/rendering/labels.ex:25
#: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format
msgid "Close calendar"
msgstr "Kalender schließen"
#: lib/bds/rendering/labels.ex:84
#: lib/bds/rendering/labels.ex:86
#, elixir-autogen, elixir-format
msgid "December"
msgstr "Dezember"
#: lib/bds/rendering/labels.ex:44
#: lib/bds/rendering/labels.ex:46
#, elixir-autogen, elixir-format
msgid "February"
msgstr "Februar"
#: lib/bds/rendering/labels.ex:40
#: lib/bds/rendering/labels.ex:42
#, elixir-autogen, elixir-format
msgid "January"
msgstr "Januar"
#: lib/bds/rendering/labels.ex:64
#: lib/bds/rendering/labels.ex:66
#, elixir-autogen, elixir-format
msgid "July"
msgstr "Juli"
#: lib/bds/rendering/labels.ex:60
#: lib/bds/rendering/labels.ex:62
#, elixir-autogen, elixir-format
msgid "June"
msgstr "Juni"
#: lib/bds/rendering/labels.ex:26
#: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format
msgid "Language"
msgstr "Sprache"
#: lib/bds/rendering/labels.ex:16
#: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format
msgid "Linked from"
msgstr "Verlinkt von"
#: lib/bds/rendering/labels.ex:22
#: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format
msgid "Loading calendar…"
msgstr "Kalender wird geladen …"
#: lib/bds/rendering/labels.ex:48
#: lib/bds/rendering/labels.ex:50
#, elixir-autogen, elixir-format
msgid "March"
msgstr "März"
#: lib/bds/rendering/labels.ex:56
#: lib/bds/rendering/labels.ex:58
#, elixir-autogen, elixir-format
msgid "May"
msgstr "Mai"
#: lib/bds/rendering/labels.ex:80
#: lib/bds/rendering/labels.ex:82
#, elixir-autogen, elixir-format
msgid "November"
msgstr "Nov."
#: lib/bds/rendering/labels.ex:76
#: lib/bds/rendering/labels.ex:78
#, elixir-autogen, elixir-format
msgid "October"
msgstr "Oktober"
#: lib/bds/rendering/labels.ex:21
#: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format
msgid "Open calendar"
msgstr "Kalender öffnen"
#: lib/bds/rendering/labels.ex:18
#: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format
msgid "Pagination"
msgstr "Seitennummerierung"
#: lib/bds/rendering/labels.ex:28
#: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr "Suchen..."
#: lib/bds/rendering/labels.ex:72
#: lib/bds/rendering/labels.ex:74
#, elixir-autogen, elixir-format
msgid "September"
msgstr "Sept."
#: lib/bds/rendering/labels.ex:27
#: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format
msgid "Site search"
msgstr "Seitensuche"
#: lib/bds/rendering/labels.ex:14
#: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format
msgid "Taxonomy"
msgstr "Taxonomie"
#: lib/bds/rendering/labels.ex:19
#: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format
msgid "newer"
msgstr "neuer"
#: lib/bds/rendering/labels.ex:20
#: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format
msgid "older"
msgstr "älter"
#: lib/bds/rendering/labels.ex:30
#: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format
msgid "Back to preview home"
msgstr "Zurück zur Vorschau-Startseite"
#: lib/bds/rendering/labels.ex:29
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "The requested preview page could not be found."
msgstr "Die angeforderte Vorschauseite konnte nicht gefunden werden."
#: lib/bds/rendering/labels.ex:32
#: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format
msgid "Vimeo video"
msgstr "Vimeo-Video"
#: lib/bds/rendering/labels.ex:31
#: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format
msgid "YouTube video"
msgstr "YouTube-Video"

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +1,156 @@
#: lib/bds/rendering/labels.ex:17
#: lib/bds/ui/sidebar.ex:241
#: lib/bds/ui/sidebar.ex:316
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr ""
#: lib/bds/rendering/labels.ex:52
#: lib/bds/rendering/labels.ex:54
#, elixir-autogen, elixir-format
msgid "April"
msgstr ""
#: lib/bds/rendering/labels.ex:24
#: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format, fuzzy
msgid "Archive calendar"
msgstr ""
#: lib/bds/rendering/labels.ex:68
#: lib/bds/rendering/labels.ex:70
#, elixir-autogen, elixir-format
msgid "August"
msgstr ""
#: lib/bds/rendering/labels.ex:15
#: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format
msgid "Backlinks"
msgstr ""
#: lib/bds/rendering/labels.ex:23
#: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded."
msgstr ""
#: lib/bds/rendering/labels.ex:25
#: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format
msgid "Close calendar"
msgstr ""
#: lib/bds/rendering/labels.ex:84
#: lib/bds/rendering/labels.ex:86
#, elixir-autogen, elixir-format
msgid "December"
msgstr ""
#: lib/bds/rendering/labels.ex:44
#: lib/bds/rendering/labels.ex:46
#, elixir-autogen, elixir-format
msgid "February"
msgstr ""
#: lib/bds/rendering/labels.ex:40
#: lib/bds/rendering/labels.ex:42
#, elixir-autogen, elixir-format
msgid "January"
msgstr ""
#: lib/bds/rendering/labels.ex:64
#: lib/bds/rendering/labels.ex:66
#, elixir-autogen, elixir-format
msgid "July"
msgstr ""
#: lib/bds/rendering/labels.ex:60
#: lib/bds/rendering/labels.ex:62
#, elixir-autogen, elixir-format
msgid "June"
msgstr ""
#: lib/bds/rendering/labels.ex:26
#: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format
msgid "Language"
msgstr ""
#: lib/bds/rendering/labels.ex:16
#: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format
msgid "Linked from"
msgstr ""
#: lib/bds/rendering/labels.ex:22
#: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format
msgid "Loading calendar…"
msgstr ""
#: lib/bds/rendering/labels.ex:48
#: lib/bds/rendering/labels.ex:50
#, elixir-autogen, elixir-format
msgid "March"
msgstr ""
#: lib/bds/rendering/labels.ex:56
#: lib/bds/rendering/labels.ex:58
#, elixir-autogen, elixir-format
msgid "May"
msgstr ""
#: lib/bds/rendering/labels.ex:80
#: lib/bds/rendering/labels.ex:82
#, elixir-autogen, elixir-format
msgid "November"
msgstr ""
#: lib/bds/rendering/labels.ex:76
#: lib/bds/rendering/labels.ex:78
#, elixir-autogen, elixir-format
msgid "October"
msgstr ""
#: lib/bds/rendering/labels.ex:21
#: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format
msgid "Open calendar"
msgstr ""
#: lib/bds/rendering/labels.ex:18
#: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format
msgid "Pagination"
msgstr ""
#: lib/bds/rendering/labels.ex:28
#: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr ""
#: lib/bds/rendering/labels.ex:72
#: lib/bds/rendering/labels.ex:74
#, elixir-autogen, elixir-format
msgid "September"
msgstr ""
#: lib/bds/rendering/labels.ex:27
#: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format
msgid "Site search"
msgstr ""
#: lib/bds/rendering/labels.ex:14
#: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format
msgid "Taxonomy"
msgstr ""
#: lib/bds/rendering/labels.ex:19
#: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format
msgid "newer"
msgstr ""
#: lib/bds/rendering/labels.ex:20
#: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format
msgid "older"
msgstr ""
#: lib/bds/rendering/labels.ex:30
#: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format
msgid "Back to preview home"
msgstr ""
#: lib/bds/rendering/labels.ex:29
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "The requested preview page could not be found."
msgstr ""
#: lib/bds/rendering/labels.ex:32
#: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format
msgid "Vimeo video"
msgstr ""
#: lib/bds/rendering/labels.ex:31
#: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format
msgid "YouTube video"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +1,156 @@
#: lib/bds/rendering/labels.ex:17
#: lib/bds/ui/sidebar.ex:241
#: lib/bds/ui/sidebar.ex:316
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archivo"
#: lib/bds/rendering/labels.ex:52
#: lib/bds/rendering/labels.ex:54
#, elixir-autogen, elixir-format
msgid "April"
msgstr "abril"
#: lib/bds/rendering/labels.ex:24
#: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format, fuzzy
msgid "Archive calendar"
msgstr "Archivo"
#: lib/bds/rendering/labels.ex:68
#: lib/bds/rendering/labels.ex:70
#, elixir-autogen, elixir-format
msgid "August"
msgstr "agosto"
#: lib/bds/rendering/labels.ex:15
#: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format
msgid "Backlinks"
msgstr "Retroenlaces"
#: lib/bds/rendering/labels.ex:23
#: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded."
msgstr "No se pudieron cargar los datos del calendario."
#: lib/bds/rendering/labels.ex:25
#: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format
msgid "Close calendar"
msgstr "Cerrar calendario"
#: lib/bds/rendering/labels.ex:84
#: lib/bds/rendering/labels.ex:86
#, elixir-autogen, elixir-format
msgid "December"
msgstr "diciembre"
#: lib/bds/rendering/labels.ex:44
#: lib/bds/rendering/labels.ex:46
#, elixir-autogen, elixir-format
msgid "February"
msgstr "febrero"
#: lib/bds/rendering/labels.ex:40
#: lib/bds/rendering/labels.ex:42
#, elixir-autogen, elixir-format
msgid "January"
msgstr "enero"
#: lib/bds/rendering/labels.ex:64
#: lib/bds/rendering/labels.ex:66
#, elixir-autogen, elixir-format
msgid "July"
msgstr "julio"
#: lib/bds/rendering/labels.ex:60
#: lib/bds/rendering/labels.ex:62
#, elixir-autogen, elixir-format
msgid "June"
msgstr "junio"
#: lib/bds/rendering/labels.ex:26
#: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format
msgid "Language"
msgstr "Idioma"
#: lib/bds/rendering/labels.ex:16
#: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format
msgid "Linked from"
msgstr "Enlazado desde"
#: lib/bds/rendering/labels.ex:22
#: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format
msgid "Loading calendar…"
msgstr "Cargando calendario…"
#: lib/bds/rendering/labels.ex:48
#: lib/bds/rendering/labels.ex:50
#, elixir-autogen, elixir-format
msgid "March"
msgstr "marzo"
#: lib/bds/rendering/labels.ex:56
#: lib/bds/rendering/labels.ex:58
#, elixir-autogen, elixir-format
msgid "May"
msgstr "mayo"
#: lib/bds/rendering/labels.ex:80
#: lib/bds/rendering/labels.ex:82
#, elixir-autogen, elixir-format
msgid "November"
msgstr "noviembre"
#: lib/bds/rendering/labels.ex:76
#: lib/bds/rendering/labels.ex:78
#, elixir-autogen, elixir-format
msgid "October"
msgstr "octubre"
#: lib/bds/rendering/labels.ex:21
#: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format
msgid "Open calendar"
msgstr "Abrir calendario"
#: lib/bds/rendering/labels.ex:18
#: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format
msgid "Pagination"
msgstr "Paginación"
#: lib/bds/rendering/labels.ex:28
#: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr "Buscar..."
#: lib/bds/rendering/labels.ex:72
#: lib/bds/rendering/labels.ex:74
#, elixir-autogen, elixir-format
msgid "September"
msgstr "septiembre"
#: lib/bds/rendering/labels.ex:27
#: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format
msgid "Site search"
msgstr "Buscar en el sitio"
#: lib/bds/rendering/labels.ex:14
#: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format
msgid "Taxonomy"
msgstr "Taxonomía"
#: lib/bds/rendering/labels.ex:19
#: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format
msgid "newer"
msgstr "más reciente"
#: lib/bds/rendering/labels.ex:20
#: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format
msgid "older"
msgstr "más antiguo"
#: lib/bds/rendering/labels.ex:30
#: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format
msgid "Back to preview home"
msgstr "Volver al inicio de vista previa"
#: lib/bds/rendering/labels.ex:29
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "The requested preview page could not be found."
msgstr "No se pudo encontrar la página de vista previa solicitada."
#: lib/bds/rendering/labels.ex:32
#: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format
msgid "Vimeo video"
msgstr "Vídeo de Vimeo"
#: lib/bds/rendering/labels.ex:31
#: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format
msgid "YouTube video"
msgstr "Vídeo de YouTube"

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +1,156 @@
#: lib/bds/rendering/labels.ex:17
#: lib/bds/ui/sidebar.ex:241
#: lib/bds/ui/sidebar.ex:316
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archives"
#: lib/bds/rendering/labels.ex:52
#: lib/bds/rendering/labels.ex:54
#, elixir-autogen, elixir-format
msgid "April"
msgstr "avril"
#: lib/bds/rendering/labels.ex:24
#: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format, fuzzy
msgid "Archive calendar"
msgstr "Archives"
#: lib/bds/rendering/labels.ex:68
#: lib/bds/rendering/labels.ex:70
#, elixir-autogen, elixir-format
msgid "August"
msgstr "août"
#: lib/bds/rendering/labels.ex:15
#: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format
msgid "Backlinks"
msgstr "Rétroliens"
#: lib/bds/rendering/labels.ex:23
#: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded."
msgstr "Impossible de charger les données du calendrier."
#: lib/bds/rendering/labels.ex:25
#: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format
msgid "Close calendar"
msgstr "Fermer le calendrier"
#: lib/bds/rendering/labels.ex:84
#: lib/bds/rendering/labels.ex:86
#, elixir-autogen, elixir-format
msgid "December"
msgstr "décembre"
#: lib/bds/rendering/labels.ex:44
#: lib/bds/rendering/labels.ex:46
#, elixir-autogen, elixir-format
msgid "February"
msgstr "février"
#: lib/bds/rendering/labels.ex:40
#: lib/bds/rendering/labels.ex:42
#, elixir-autogen, elixir-format
msgid "January"
msgstr "janvier"
#: lib/bds/rendering/labels.ex:64
#: lib/bds/rendering/labels.ex:66
#, elixir-autogen, elixir-format
msgid "July"
msgstr "juillet"
#: lib/bds/rendering/labels.ex:60
#: lib/bds/rendering/labels.ex:62
#, elixir-autogen, elixir-format
msgid "June"
msgstr "juin"
#: lib/bds/rendering/labels.ex:26
#: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format
msgid "Language"
msgstr "Langue"
#: lib/bds/rendering/labels.ex:16
#: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format
msgid "Linked from"
msgstr "Lié depuis"
#: lib/bds/rendering/labels.ex:22
#: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format
msgid "Loading calendar…"
msgstr "Chargement du calendrier…"
#: lib/bds/rendering/labels.ex:48
#: lib/bds/rendering/labels.ex:50
#, elixir-autogen, elixir-format
msgid "March"
msgstr "mars"
#: lib/bds/rendering/labels.ex:56
#: lib/bds/rendering/labels.ex:58
#, elixir-autogen, elixir-format
msgid "May"
msgstr "mai"
#: lib/bds/rendering/labels.ex:80
#: lib/bds/rendering/labels.ex:82
#, elixir-autogen, elixir-format
msgid "November"
msgstr "novembre"
#: lib/bds/rendering/labels.ex:76
#: lib/bds/rendering/labels.ex:78
#, elixir-autogen, elixir-format
msgid "October"
msgstr "octobre"
#: lib/bds/rendering/labels.ex:21
#: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format
msgid "Open calendar"
msgstr "Ouvrir le calendrier"
#: lib/bds/rendering/labels.ex:18
#: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format
msgid "Pagination"
msgstr "Navigation paginée"
#: lib/bds/rendering/labels.ex:28
#: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr "Rechercher..."
#: lib/bds/rendering/labels.ex:72
#: lib/bds/rendering/labels.ex:74
#, elixir-autogen, elixir-format
msgid "September"
msgstr "septembre"
#: lib/bds/rendering/labels.ex:27
#: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format
msgid "Site search"
msgstr "Recherche du site"
#: lib/bds/rendering/labels.ex:14
#: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format
msgid "Taxonomy"
msgstr "Taxonomie"
#: lib/bds/rendering/labels.ex:19
#: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format
msgid "newer"
msgstr "plus récent"
#: lib/bds/rendering/labels.ex:20
#: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format
msgid "older"
msgstr "plus ancien"
#: lib/bds/rendering/labels.ex:30
#: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format
msgid "Back to preview home"
msgstr "Retour à laccueil de laperçu"
#: lib/bds/rendering/labels.ex:29
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "The requested preview page could not be found."
msgstr "La page daperçu demandée est introuvable."
#: lib/bds/rendering/labels.ex:32
#: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format
msgid "Vimeo video"
msgstr "Vidéo Vimeo"
#: lib/bds/rendering/labels.ex:31
#: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format
msgid "YouTube video"
msgstr "Vidéo YouTube"

File diff suppressed because it is too large Load Diff

View File

@@ -1,156 +1,156 @@
#: lib/bds/rendering/labels.ex:17
#: lib/bds/ui/sidebar.ex:241
#: lib/bds/ui/sidebar.ex:316
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr "Archivio"
#: lib/bds/rendering/labels.ex:52
#: lib/bds/rendering/labels.ex:54
#, elixir-autogen, elixir-format
msgid "April"
msgstr "aprile"
#: lib/bds/rendering/labels.ex:24
#: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format, fuzzy
msgid "Archive calendar"
msgstr "Archivio"
#: lib/bds/rendering/labels.ex:68
#: lib/bds/rendering/labels.ex:70
#, elixir-autogen, elixir-format
msgid "August"
msgstr "agosto"
#: lib/bds/rendering/labels.ex:15
#: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format
msgid "Backlinks"
msgstr "Retrocollegamenti"
#: lib/bds/rendering/labels.ex:23
#: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded."
msgstr "Impossibile caricare i dati del calendario."
#: lib/bds/rendering/labels.ex:25
#: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format
msgid "Close calendar"
msgstr "Chiudi calendario"
#: lib/bds/rendering/labels.ex:84
#: lib/bds/rendering/labels.ex:86
#, elixir-autogen, elixir-format
msgid "December"
msgstr "dicembre"
#: lib/bds/rendering/labels.ex:44
#: lib/bds/rendering/labels.ex:46
#, elixir-autogen, elixir-format
msgid "February"
msgstr "febbraio"
#: lib/bds/rendering/labels.ex:40
#: lib/bds/rendering/labels.ex:42
#, elixir-autogen, elixir-format
msgid "January"
msgstr "gennaio"
#: lib/bds/rendering/labels.ex:64
#: lib/bds/rendering/labels.ex:66
#, elixir-autogen, elixir-format
msgid "July"
msgstr "luglio"
#: lib/bds/rendering/labels.ex:60
#: lib/bds/rendering/labels.ex:62
#, elixir-autogen, elixir-format
msgid "June"
msgstr "giugno"
#: lib/bds/rendering/labels.ex:26
#: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format
msgid "Language"
msgstr "Lingua"
#: lib/bds/rendering/labels.ex:16
#: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format
msgid "Linked from"
msgstr "Collegato da"
#: lib/bds/rendering/labels.ex:22
#: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format
msgid "Loading calendar…"
msgstr "Caricamento calendario…"
#: lib/bds/rendering/labels.ex:48
#: lib/bds/rendering/labels.ex:50
#, elixir-autogen, elixir-format
msgid "March"
msgstr "marzo"
#: lib/bds/rendering/labels.ex:56
#: lib/bds/rendering/labels.ex:58
#, elixir-autogen, elixir-format
msgid "May"
msgstr "maggio"
#: lib/bds/rendering/labels.ex:80
#: lib/bds/rendering/labels.ex:82
#, elixir-autogen, elixir-format
msgid "November"
msgstr "novembre"
#: lib/bds/rendering/labels.ex:76
#: lib/bds/rendering/labels.ex:78
#, elixir-autogen, elixir-format
msgid "October"
msgstr "ottobre"
#: lib/bds/rendering/labels.ex:21
#: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format
msgid "Open calendar"
msgstr "Apri calendario"
#: lib/bds/rendering/labels.ex:18
#: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format
msgid "Pagination"
msgstr "Paginazione"
#: lib/bds/rendering/labels.ex:28
#: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr "Cerca..."
#: lib/bds/rendering/labels.ex:72
#: lib/bds/rendering/labels.ex:74
#, elixir-autogen, elixir-format
msgid "September"
msgstr "settembre"
#: lib/bds/rendering/labels.ex:27
#: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format
msgid "Site search"
msgstr "Ricerca nel sito"
#: lib/bds/rendering/labels.ex:14
#: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format
msgid "Taxonomy"
msgstr "Tassonomia"
#: lib/bds/rendering/labels.ex:19
#: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format
msgid "newer"
msgstr "più recente"
#: lib/bds/rendering/labels.ex:20
#: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format
msgid "older"
msgstr "più vecchio"
#: lib/bds/rendering/labels.ex:30
#: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format
msgid "Back to preview home"
msgstr "Torna alla home di anteprima"
#: lib/bds/rendering/labels.ex:29
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "The requested preview page could not be found."
msgstr "La pagina di anteprima richiesta non è stata trovata."
#: lib/bds/rendering/labels.ex:32
#: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format
msgid "Vimeo video"
msgstr "Video Vimeo"
#: lib/bds/rendering/labels.ex:31
#: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format
msgid "YouTube video"
msgstr "Video YouTube"

File diff suppressed because it is too large Load Diff

View File

@@ -11,159 +11,159 @@
msgid ""
msgstr ""
#: lib/bds/rendering/labels.ex:17
#: lib/bds/ui/sidebar.ex:241
#: lib/bds/ui/sidebar.ex:316
#: lib/bds/rendering/labels.ex:18
#: lib/bds/ui/sidebar.ex:284
#: lib/bds/ui/sidebar.ex:578
#, elixir-autogen, elixir-format
msgid "Archive"
msgstr ""
#: lib/bds/rendering/labels.ex:52
#: lib/bds/rendering/labels.ex:54
#, elixir-autogen, elixir-format
msgid "April"
msgstr ""
#: lib/bds/rendering/labels.ex:24
#: lib/bds/rendering/labels.ex:25
#, elixir-autogen, elixir-format
msgid "Archive calendar"
msgstr ""
#: lib/bds/rendering/labels.ex:68
#: lib/bds/rendering/labels.ex:70
#, elixir-autogen, elixir-format
msgid "August"
msgstr ""
#: lib/bds/rendering/labels.ex:15
#: lib/bds/rendering/labels.ex:16
#, elixir-autogen, elixir-format
msgid "Backlinks"
msgstr ""
#: lib/bds/rendering/labels.ex:23
#: lib/bds/rendering/labels.ex:24
#, elixir-autogen, elixir-format
msgid "Calendar data could not be loaded."
msgstr ""
#: lib/bds/rendering/labels.ex:25
#: lib/bds/rendering/labels.ex:26
#, elixir-autogen, elixir-format
msgid "Close calendar"
msgstr ""
#: lib/bds/rendering/labels.ex:84
#: lib/bds/rendering/labels.ex:86
#, elixir-autogen, elixir-format
msgid "December"
msgstr ""
#: lib/bds/rendering/labels.ex:44
#: lib/bds/rendering/labels.ex:46
#, elixir-autogen, elixir-format
msgid "February"
msgstr ""
#: lib/bds/rendering/labels.ex:40
#: lib/bds/rendering/labels.ex:42
#, elixir-autogen, elixir-format
msgid "January"
msgstr ""
#: lib/bds/rendering/labels.ex:64
#: lib/bds/rendering/labels.ex:66
#, elixir-autogen, elixir-format
msgid "July"
msgstr ""
#: lib/bds/rendering/labels.ex:60
#: lib/bds/rendering/labels.ex:62
#, elixir-autogen, elixir-format
msgid "June"
msgstr ""
#: lib/bds/rendering/labels.ex:26
#: lib/bds/rendering/labels.ex:27
#, elixir-autogen, elixir-format
msgid "Language"
msgstr ""
#: lib/bds/rendering/labels.ex:16
#: lib/bds/rendering/labels.ex:17
#, elixir-autogen, elixir-format
msgid "Linked from"
msgstr ""
#: lib/bds/rendering/labels.ex:22
#: lib/bds/rendering/labels.ex:23
#, elixir-autogen, elixir-format
msgid "Loading calendar…"
msgstr ""
#: lib/bds/rendering/labels.ex:48
#: lib/bds/rendering/labels.ex:50
#, elixir-autogen, elixir-format
msgid "March"
msgstr ""
#: lib/bds/rendering/labels.ex:56
#: lib/bds/rendering/labels.ex:58
#, elixir-autogen, elixir-format
msgid "May"
msgstr ""
#: lib/bds/rendering/labels.ex:80
#: lib/bds/rendering/labels.ex:82
#, elixir-autogen, elixir-format
msgid "November"
msgstr ""
#: lib/bds/rendering/labels.ex:76
#: lib/bds/rendering/labels.ex:78
#, elixir-autogen, elixir-format
msgid "October"
msgstr ""
#: lib/bds/rendering/labels.ex:21
#: lib/bds/rendering/labels.ex:22
#, elixir-autogen, elixir-format
msgid "Open calendar"
msgstr ""
#: lib/bds/rendering/labels.ex:18
#: lib/bds/rendering/labels.ex:19
#, elixir-autogen, elixir-format
msgid "Pagination"
msgstr ""
#: lib/bds/rendering/labels.ex:28
#: lib/bds/rendering/labels.ex:29
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr ""
#: lib/bds/rendering/labels.ex:72
#: lib/bds/rendering/labels.ex:74
#, elixir-autogen, elixir-format
msgid "September"
msgstr ""
#: lib/bds/rendering/labels.ex:27
#: lib/bds/rendering/labels.ex:28
#, elixir-autogen, elixir-format
msgid "Site search"
msgstr ""
#: lib/bds/rendering/labels.ex:14
#: lib/bds/rendering/labels.ex:15
#, elixir-autogen, elixir-format
msgid "Taxonomy"
msgstr ""
#: lib/bds/rendering/labels.ex:19
#: lib/bds/rendering/labels.ex:20
#, elixir-autogen, elixir-format
msgid "newer"
msgstr ""
#: lib/bds/rendering/labels.ex:20
#: lib/bds/rendering/labels.ex:21
#, elixir-autogen, elixir-format
msgid "older"
msgstr ""
#: lib/bds/rendering/labels.ex:30
#: lib/bds/rendering/labels.ex:31
#, elixir-autogen, elixir-format
msgid "Back to preview home"
msgstr ""
#: lib/bds/rendering/labels.ex:29
#: lib/bds/rendering/labels.ex:30
#, elixir-autogen, elixir-format
msgid "The requested preview page could not be found."
msgstr ""
#: lib/bds/rendering/labels.ex:32
#: lib/bds/rendering/labels.ex:33
#, elixir-autogen, elixir-format
msgid "Vimeo video"
msgstr ""
#: lib/bds/rendering/labels.ex:31
#: lib/bds/rendering/labels.ex:32
#, elixir-autogen, elixir-format
msgid "YouTube video"
msgstr ""

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ value SettingsProjectSection {
blog_languages: List<String> -- checkboxes (main language disabled)
default_author: String -- text input
max_posts_per_page: Integer -- number input (1-500, default 50)
image_import_concurrency: Integer -- number input (1-8, default 4)
blogmark_category: String -- select from categories
}
@@ -92,6 +93,9 @@ invariant SettingsMCPAgents {
config {
settings_max_posts_per_page: Integer = 500
settings_default_posts_per_page: Integer = 50
settings_image_import_concurrency_default: Integer = 4
settings_image_import_concurrency_min: Integer = 1
settings_image_import_concurrency_max: Integer = 8
settings_system_prompt_rows: Integer = 12
}
@@ -131,6 +135,7 @@ surface SettingsViewSurface {
-- Section 1: Project Name, Description (textarea 3 rows), Data Path (text + Browse + Reset),
-- Public URL, Main Language (select), Blog Languages (checkbox grid, main disabled),
-- Default Author, Max Posts Per Page (number 1-500),
-- Image Import Concurrency (number 1-8, default 4),
-- Blogmark Category (select), Blogmark Bookmarklet (copy button), Save button.
@guarantee BookmarkletCopy

View File

@@ -203,3 +203,27 @@ invariant SidecarRoundtrip {
parse_sidecar(m.sidecar_path).caption = m.caption
parse_sidecar(m.sidecar_path).tags = m.tags
}
rule BatchImportProcessLinkImages {
when: BatchImportImagesRequested(project, post, file_paths, language)
requires: not OfflineMode
for source_path in file_paths where is_image(source_path):
let media = ImportMedia(source_path, project)
let analysis = AnalyzeImage(media, language)
ensures: MediaUpdated(media, analysis)
ensures: PostMediaLinked(media, post)
@guidance
-- Triggered from post editor quick action "Add Gallery Images".
-- AI results auto-applied without user confirmation.
-- After metadata is set, media is auto-translated to all configured blog languages.
-- Non-image files skipped entirely.
-- Concurrency limit from project metadata image_import_concurrency (default 4, min 1, max 8).
-- Toast per completed image + final summary toast.
-- On completion: [[gallery]] macro inserted into post content and post editor refreshed.
}
config {
batch_image_import_concurrency_default: Integer = 4
batch_image_import_concurrency_min: Integer = 1
batch_image_import_concurrency_max: Integer = 8
}

View File

@@ -3998,7 +3998,7 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element("[data-testid='chat-send-button']")
|> render_click()
send(view.pid, {
send_and_await(view, {
:chat_tool_call,
conversation.id,
%{
@@ -4245,14 +4245,14 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(class="chat-input chat-surface-input ui-textarea")
css = desktop_css_source()
assert css =~ "--chat-input-line-height: 20px;"
assert css =~ "--chat-input-min-height: 20px;"
assert css =~ "--chat-input-line-height: 22px;"
assert css =~ "--chat-input-min-height: 24px;"
assert css =~ ".chat-panel .chat-input-container"
assert css =~ "padding: 8px 16px;"
assert css =~ "padding: 12px 16px;"
assert css =~ "padding: 6px 8px;"
assert css =~ ".chat-panel .chat-input-wrapper"
assert css =~ "min-height: 30px;"
assert css =~ "padding: 4px 6px;"
assert css =~ "min-height: 40px;"
assert css =~ "padding: 6px 8px;"
assert css =~ ".chat-panel .chat-input"
assert css =~ "box-sizing: border-box;"
assert css =~ "margin: 0;"
@@ -4565,7 +4565,7 @@ defmodule BDS.Desktop.ShellLiveTest do
assert assistant_index < user_index
assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input ui-textarea"[^>]*disabled/
send(view.pid, {
send_and_await(view, {
:chat_tool_call,
conversation.id,
%{
@@ -4629,11 +4629,13 @@ defmodule BDS.Desktop.ShellLiveTest do
|> element(".chat-input-wrapper")
|> render_change(%{"message" => "Newest question"})
_html =
html =
view
|> element("[data-testid='chat-send-button']")
|> render_click()
assert html =~ ~s(data-testid="chat-streaming-thinking")
assert Enum.count(AI.list_chat_messages(conversation.id), fn message ->
message.role == :user and message.content == "Newest question"
end) == 1
@@ -4660,7 +4662,9 @@ defmodule BDS.Desktop.ShellLiveTest do
})
)
send(view.pid, {
_ensure_sync = render(view)
send_and_await(view, {
:chat_tool_call,
conversation.id,
%{
@@ -4931,6 +4935,20 @@ defmodule BDS.Desktop.ShellLiveTest do
run_git!(project_dir, ["commit", "-m", message])
end
# Sends a message to the LiveView and blocks until it has been processed.
# Uses a test ping/pong to guarantee the message was handled before returning.
defp send_and_await(view, message) do
ref = make_ref()
send(view.pid, message)
send(view.pid, {:test_ping, self(), ref})
receive do
{:test_pong, ^ref} -> :ok
after
5000 -> raise "LiveView did not process messages within 5s"
end
end
defp run_git!(dir, args) do
{output, status} = System.cmd("git", args, cd: dir, stderr_to_stdout: true)

View File

@@ -0,0 +1,252 @@
defmodule BDS.GalleryPipelineTest do
use ExUnit.Case, async: false
alias BDS.{Repo, Media}
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
temp_dir =
Path.join(System.tmp_dir!(), "bds-gallery-test-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "Gallery Test", data_path: temp_dir})
{:ok, _project} = BDS.Projects.set_active_project(project.id)
{:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{
blog_languages: ["de", "fr"],
main_language: "en"
})
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Gallery Post",
content: "Content"
})
%{project: project, post: post, temp_dir: temp_dir}
end
describe "GalleryImport.start/6 concurrency" do
test "processes all paths with a sliding window", %{project: project, post: post} do
parent = self()
concurrency = 2
paths =
Enum.map(1..5, fn i ->
path = Path.join(project.data_path, "image_#{i}.txt")
File.write!(path, "content #{i}")
path
end)
ref = make_ref()
send(parent, {:test_gallery_start, ref, paths, project.id, post.id, concurrency})
end
end
# ── gallery_count with [[gallery]] ─────────────────────────────────────────
describe "gallery_count" do
test "counts [[gallery]] macro as gallery content" do
form = %{"content" => "Some text\n\n[[gallery]]\n\nMore text"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
end
test "counts inline images as before" do
form = %{"content" => "![Alt](image.jpg)"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
end
test "counts both [[gallery]] and inline images" do
form = %{"content" => "![Alt](image.jpg)\n[[gallery]]"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
end
test "returns 0 when no gallery marks present" do
form = %{"content" => "Just plain text"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0
end
test "handles empty content" do
form = %{}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 0
end
test "counts multiple [[gallery]] occurrences" do
form = %{"content" => "[[gallery]] some text [[gallery]]"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 2
end
test "is case insensitive for [[gallery]]" do
form = %{"content" => "[[Gallery]]"}
assert BDS.Desktop.ShellLive.PostEditor.PostMetadata.gallery_count(form) == 1
end
end
# ── Rendering macro smoke tests ────────────────────────────────────────────
describe "rendering macros" do
test "[[gallery]] renders without linked media", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[gallery]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "gallery-empty"
assert result =~ "macro-gallery"
end
test "[[gallery]] renders linked media when present", %{project: project, post: post} do
source = Path.join(project.data_path, "gallery_test.jpg")
File.write!(source, "fake jpeg")
{:ok, media} =
Media.import_media(%{project_id: project.id, source_path: source})
{:ok, _updated} =
Media.update_media(media.id, %{
title: "Test Image",
alt: "Alt text",
caption: "Caption"
})
{:ok, _link} = Media.link_media_to_post(media.id, post.id)
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[gallery]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "macro-gallery"
assert result =~ "gallery-item"
end
test "[[tag_cloud]] renders without tags", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[tag_cloud]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "tag-cloud-empty"
end
test "[[photo_archive]] renders without media", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[photo_archive]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "photo-archive-empty"
end
test "[[photo_archive year=2024]] renders with date filter", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[photo_archive year=2024]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "photo-archive"
end
test "[[youtube id=abc123]] still works", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[youtube id=abc123]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "youtube"
assert result =~ "abc123"
end
test "[[vimeo id=456789]] still works", %{project: project, post: post} do
{:ok, metadata} = BDS.Metadata.get_project_metadata(project.id)
language = metadata.main_language || "en"
template_context = BDS.Rendering.TemplateSelection.template_render_context(project.id)
result =
BDS.Rendering.Filters.render_markdown(
"[[vimeo id=456789]]",
%{},
%{},
language,
template_context,
post.id
)
assert result =~ "vimeo"
assert result =~ "456789"
end
end
end

View File

@@ -0,0 +1,216 @@
defmodule BDS.ImageImportPipelineTest do
use ExUnit.Case, async: false
import Phoenix.ConnTest
import Phoenix.LiveViewTest
alias BDS.Desktop.{FilePicker, Overlay}
alias BDS.{Metadata, AI, Repo}
@endpoint BDS.Desktop.Endpoint
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
temp_dir =
Path.join(System.tmp_dir!(), "bds-image-import-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "Image Import Test", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
# ── FilePicker multi-select parsing ────────────────────────────────────────
describe "FilePicker.choose_files/2" do
test "single selection returns a single-item list" do
# Simulate what osascript returns for regular choose file
result = FilePicker.parse_choose_files_result("/Users/test/image.png", false)
assert result == {:ok, "/Users/test/image.png"}
end
test "multi selection parses newline-separated paths" do
result =
FilePicker.parse_choose_files_result(
"/Users/test/photo1.jpg\n/Users/test/photo2.png\n/Users/test/photo3.heic",
true
)
assert result ==
{:ok, ["/Users/test/photo1.jpg", "/Users/test/photo2.png", "/Users/test/photo3.heic"]}
end
test "multi selection filters out empty lines" do
result =
FilePicker.parse_choose_files_result(
"/Users/test/photo1.jpg\n\n/Users/test/photo2.png\n \n/Users/test/photo3.heic\n",
true
)
assert result ==
{:ok, ["/Users/test/photo1.jpg", "/Users/test/photo2.png", "/Users/test/photo3.heic"]}
end
test "multi selection with single file returns list with one element" do
result = FilePicker.parse_choose_files_result("/Users/test/photo1.jpg", true)
assert result == {:ok, ["/Users/test/photo1.jpg"]}
end
end
# ── Metadata image_import_concurrency ───────────────────────────────────────
describe "Metadata image_import_concurrency" do
test "defaults to 4 for new projects", %{project: project} do
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 4
end
test "clamps to minimum 1", %{project: project} do
{:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{image_import_concurrency: 0})
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 1
end
test "clamps to maximum 8", %{project: project} do
{:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{image_import_concurrency: 100})
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 8
end
test "persists and reads correctly", %{project: project} do
{:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{image_import_concurrency: 3})
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 3
end
test "handles string input", %{project: project} do
{:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{image_import_concurrency: "5"})
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 5
end
test "handles nil as default", %{project: project} do
{:ok, _metadata} =
Metadata.update_project_metadata(project.id, %{image_import_concurrency: nil})
{:ok, metadata} = Metadata.get_project_metadata(project.id)
assert metadata.image_import_concurrency == 4
end
test "reflects in form as string", %{project: project} do
{:ok, metadata} = Metadata.get_project_metadata(project.id)
form =
BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings.project_form(metadata)
assert form["image_import_concurrency"] == "4"
end
end
# ── Overlay struct post_id ────────────────────────────────────────────────
describe "Overlay insert_media" do
test "open(:post, :insert_media, context) includes post_id" do
context = %{
current_tab: %{
type: :post,
id: "post-uuid-123",
title: "Test Post",
subtitle: "draft"
},
media: [],
insert_media_title: "Insert Media"
}
overlay = Overlay.open(:post, :insert_media, context)
assert overlay.post_id == "post-uuid-123"
end
test "post_id is nil when opened from non-post context" do
context = %{
current_tab: %{
type: :media,
id: "media-uuid-456",
title: "Test Media",
subtitle: "image/png"
},
media: [],
insert_media_title: "Insert Media"
}
overlay = Overlay.open(:media, :insert_media, context)
assert overlay == nil
end
test "set_search_query preserves post_id" do
overlay = %{
kind: :insert_media,
title: "Insert Media",
search_query: "",
results: [],
all_media: [],
post_id: "post-uuid-789"
}
result = Overlay.set_search_query(overlay, "search term")
assert result.post_id == "post-uuid-789"
end
end
# ── Airplane mode gating via shell_live event ─────────────────────────────
describe "overlay_add_images airplane mode gating" do
setup do
prev_auto = System.get_env("BDS_DESKTOP_AUTOMATION")
System.put_env("BDS_DESKTOP_AUTOMATION", "1")
on_exit(fn ->
if prev_auto,
do: System.put_env("BDS_DESKTOP_AUTOMATION", prev_auto),
else: System.delete_env("BDS_DESKTOP_AUTOMATION")
end)
end
test "shows toast in airplane mode when Add Gallery Images is clicked", %{project: project} do
{:ok, _project} = BDS.Projects.set_active_project(project.id)
assert :ok = AI.set_airplane_mode(true)
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Gallery Post",
content: "Content"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
render_click(view, "pin_sidebar_item", %{
"route" => "post",
"id" => post.id,
"title" => post.title,
"subtitle" => "draft"
})
html =
view
|> element("[phx-click='add_gallery_images']")
|> render_click()
assert html =~ "Automatic AI actions stay gated by airplane mode"
assert :ok = AI.set_airplane_mode(false)
end
end
end

View File

@@ -138,6 +138,7 @@ defmodule BDS.Repo.SchemaMigrationTest do
"title",
"model",
"copilot_session_id",
"surface_state",
"created_at",
"updated_at"
],

View File

@@ -0,0 +1,86 @@
defmodule BDS.TranslationCompletenessTest do
use ExUnit.Case, async: true
@translated_locales ~w(de fr it es)
@doc """
Counts empty msgstr entries per non-English locale .po file and ensures
the count never increases. When a new msgid is added to the codebase its
translations MUST be provided for all supported locales; an empty msgstr
in de/fr/it/es causes this test to fail.
English files are excluded — gettext treats empty msgstr as "return msgid
unchanged" for the source language, which is the intended fallback.
The expected counts below represent current untranslated legacy entries
that must decrease over time, never increase. When the count decreases
because translations were added, update the expected count in this test.
"""
test "empty msgstr counts do not increase in non-English locale .po files" do
expected = %{
"de/default.po" => 0,
"de/render.po" => 0,
"de/ui.po" => 153,
"fr/default.po" => 0,
"fr/render.po" => 0,
"fr/ui.po" => 153,
"it/default.po" => 0,
"it/render.po" => 0,
"it/ui.po" => 153,
"es/default.po" => 0,
"es/render.po" => 0,
"es/ui.po" => 153
}
actual =
for locale <- @translated_locales,
domain <- ~w(ui default render) do
file = "priv/gettext/#{locale}/LC_MESSAGES/#{domain}.po"
if File.exists?(file) do
count = empty_msgstr_count(file)
key = "#{locale}/#{domain}.po"
{key, count}
end
end
|> Enum.reject(&is_nil/1)
|> Map.new()
for {file, expected_count} <- expected do
actual_count = Map.get(actual, file, 0)
assert actual_count <= expected_count,
"#{file}: empty msgstr count increased from #{expected_count} to #{actual_count}. " <>
"All new msgid entries MUST have translations for every supported locale (de, fr, it, es). " <>
"When the count decreases because translations were added, update the expected count in this test."
end
end
defp empty_msgstr_count(file) do
file
|> File.read!()
|> String.split("\n")
|> Enum.reduce({nil, 0}, fn line, {current_msgid, count} ->
trimmed = String.trim(line)
case {trimmed, current_msgid} do
{"msgstr \"\"", nil} ->
{nil, count}
{"msgstr \"\"", msgid} ->
if msgid == "" do
{nil, count}
else
{nil, count + 1}
end
{"msgid \"" <> rest, _} ->
{String.trim_trailing(rest, "\""), count}
_ ->
{current_msgid, count}
end
end)
|> elem(1)
end
end