diff --git a/CODESMELL.md b/CODESMELL.md index 1a431cb..a40cca2 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -2,7 +2,7 @@ Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`). -Last refreshed: 2026-05-01. +Last refreshed: 2026-05-02. --- @@ -14,7 +14,6 @@ Last refreshed: 2026-05-01. | # | Module | Current lines | Target | Strategy | |---|---|---|---|---| -| 1 | `BDS.Media` | 993 | ≤ 250 | Extract `Thumbnails` (~140), `Sidecars` (~150), `FileOps` (~180), `Rebuild` (~130), `Linking` (~80). Main keeps CRUD + translation API. | | 3 | `BDS.Desktop.ShellLive.ImportEditor` | 1436 | ≤ 600 | Extract `ConflictResolution` (~150), `TaxonomyEditing` (~120), `AnalysisState` (~150), `ProgressTracking` (~120). Components stay in main file. | | 4 | `BDS.Rendering` | 838 | ≤ 200 | Extract `TemplateSelection` (~120), `PostRendering` (~180), `ListArchive` (~150), `Metadata` (~140), `LinksAndLanguages` (~100). Main keeps the 3 public renders. | | 5 | `BDS.Desktop.ShellLive.MenuEditor` | 871 | ≤ 350 | Extract `TreeOps` (~280), `TreePredicates` (~60), `DraftManagement` (~140), `PageCategory` (~120), `State` (~80). | @@ -33,6 +32,7 @@ Last refreshed: 2026-05-01. - `BDS.Posts` 1781 → 569 (68 %) - `BDS.Desktop.ShellLive` 2607 → 1545 (41 %) - `BDS.Maintenance` 810 → 141 (83 %) +- `BDS.Media` 993 → 324 (67 %) --- @@ -166,6 +166,13 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search` ## Changelog +### 2026-05-02 + +### 2026-05-02 + +- **God modules**: + - `BDS.Media` 993 → 324 (67 %). Submodules under `lib/bds/media/`: `FileOps` (150, attr/maybe_put/blank_to_nil/atomic_write/delete_file_if_present/list_matching_files/media_file_path/detect_mime/image_dimensions/image_mime?/progress callbacks), `Thumbnails` (165, thumbnail_paths/regenerate_thumbnails/regenerate_missing_thumbnails/ensure_thumbnails/delete_thumbnail_files + private render/write helpers), `Sidecars` (329, write_sidecar/write_translation_sidecar/parse_canonical_sidecar/parse_translation_sidecar/upsert_media_from_sidecar/upsert_translation_from_sidecar + sync/import-orphan public API + translation_sidecar_path/canonical_sidecar?/translation_sidecar?/binary_path_for_translation_sidecar/binary_exists_for_sidecar?), `Linking` (125, list_linked_posts/link_media_to_post/unlink_media_from_post/linked_post_ids), `Rebuilder` (82, rebuild_media_from_files/2). Public API preserved via `defdelegate`; coordinator keeps import_media/update_media/delete_media/upsert_media_translation/delete_media_translation/replace_media_file/list_media_translations and uses `import only:` for shared helpers. + ### 2026-05-01 - **God modules**: diff --git a/lib/bds/media.ex b/lib/bds/media.ex index e34cf76..f3bfbfb 100644 --- a/lib/bds/media.ex +++ b/lib/bds/media.ex @@ -1,18 +1,37 @@ defmodule BDS.Media do @moduledoc false + import BDS.Media.FileOps, + only: [ + attr: 2, + delete_file_if_present: 2, + detect_mime: 1, + image_dimensions: 2, + maybe_put: 3, + media_file_path: 2 + ] + + import BDS.Media.Sidecars, + only: [ + translation_sidecar_path: 2, + write_sidecar: 2, + write_translation_sidecar: 3 + ] + + import BDS.Media.Thumbnails, + only: [ + delete_thumbnail_files: 2, + ensure_thumbnails: 2 + ] + import Ecto.Query - alias BDS.DocumentFields alias BDS.Media.Media alias BDS.Media.Translation alias BDS.Persistence - alias BDS.Posts.PostMedia alias BDS.Projects - alias BDS.Rebuild alias BDS.Repo alias BDS.Search - alias BDS.Sidecar @typedoc "An attribute map that may use atom or string keys." @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} @@ -20,6 +39,28 @@ defmodule BDS.Media do @typedoc "Options accepted by long-running rebuild operations." @type rebuild_opts :: keyword() + # Public API delegations to submodules + defdelegate thumbnail_paths(media), to: BDS.Media.Thumbnails + defdelegate regenerate_thumbnails(media_id), to: BDS.Media.Thumbnails + defdelegate regenerate_missing_thumbnails(project_id), to: BDS.Media.Thumbnails + defdelegate regenerate_missing_thumbnails(project_id, opts), to: BDS.Media.Thumbnails + + defdelegate sync_media_sidecar(media_id), to: BDS.Media.Sidecars + defdelegate sync_media_from_sidecar(media_id), to: BDS.Media.Sidecars + defdelegate sync_media_translation_sidecar(translation_id), to: BDS.Media.Sidecars + defdelegate sync_media_translation_from_sidecar(translation_id), to: BDS.Media.Sidecars + defdelegate import_orphan_media_sidecar(project_id, relative_path), to: BDS.Media.Sidecars + + defdelegate import_orphan_media_translation_sidecar(project_id, relative_path), + to: BDS.Media.Sidecars + + defdelegate list_linked_posts(media_id), to: BDS.Media.Linking + defdelegate link_media_to_post(media_id, post_id), to: BDS.Media.Linking + defdelegate unlink_media_from_post(media_id, post_id), to: BDS.Media.Linking + + defdelegate rebuild_media_from_files(project_id), to: BDS.Media.Rebuilder + defdelegate rebuild_media_from_files(project_id, opts), to: BDS.Media.Rebuilder + @spec import_media(attrs()) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t() | term()} def import_media(attrs) do project = Projects.get_project!(attr(attrs, :project_id)) @@ -112,129 +153,6 @@ defmodule BDS.Media do end end - @spec sync_media_sidecar(String.t()) :: :ok | {:error, :not_found | term()} - def sync_media_sidecar(media_id) do - case Repo.get(Media, media_id) do - nil -> - {:error, :not_found} - - media -> - project = Projects.get_project!(media.project_id) - :ok = write_sidecar(project, media) - :ok - end - end - - @spec sync_media_from_sidecar(String.t()) :: - {:ok, Media.t()} | {:error, :not_found | term()} - def sync_media_from_sidecar(media_id) do - case Repo.get(Media, media_id) do - nil -> - {:error, :not_found} - - %Media{} = media -> - project = Projects.get_project!(media.project_id) - sidecar_path = Path.join(Projects.project_data_dir(project), media.sidecar_path) - - if File.exists?(sidecar_path) do - {:ok, upsert_media_from_sidecar(project, parse_canonical_sidecar(project, sidecar_path), sync_search: true)} - else - {:error, :not_found} - end - end - end - - @spec sync_media_translation_sidecar(String.t()) :: - {:ok, Translation.t()} | {:error, :not_found | term()} - def sync_media_translation_sidecar(translation_id) do - case Repo.get(Translation, translation_id) do - nil -> - {:error, :not_found} - - %Translation{} = translation -> - media = Repo.get!(Media, translation.translation_for) - project = Projects.get_project!(media.project_id) - :ok = write_translation_sidecar(project, media, translation) - {:ok, translation} - end - end - - @spec sync_media_translation_from_sidecar(String.t()) :: - {:ok, Translation.t()} | {:error, :not_found | term()} - def sync_media_translation_from_sidecar(translation_id) do - case Repo.get(Translation, translation_id) do - nil -> - {:error, :not_found} - - %Translation{} = translation -> - media = Repo.get!(Media, translation.translation_for) - project = Projects.get_project!(media.project_id) - sidecar_path = Path.join(Projects.project_data_dir(project), translation_sidecar_path(media, translation.language)) - - if File.exists?(sidecar_path) do - sidecar = parse_translation_sidecar(sidecar_path) - - case upsert_media_translation(media.id, DocumentFields.fetch!(sidecar.fields, "language"), %{ - title: DocumentFields.get(sidecar.fields, "title"), - alt: DocumentFields.get(sidecar.fields, "alt"), - caption: DocumentFields.get(sidecar.fields, "caption") - }) do - {:ok, updated_translation} -> {:ok, updated_translation} - error -> error - end - else - {:error, :not_found} - end - end - end - - @spec import_orphan_media_sidecar(String.t(), String.t()) :: - {:ok, Media.t()} | {:error, term()} - def import_orphan_media_sidecar(project_id, relative_path) do - project = Projects.get_project!(project_id) - sidecar_path = Path.join(Projects.project_data_dir(project), relative_path) - - if File.exists?(sidecar_path) do - {:ok, upsert_media_from_sidecar(project, parse_canonical_sidecar(project, sidecar_path), sync_search: true)} - else - {:error, :not_found} - end - end - - @spec import_orphan_media_translation_sidecar(String.t(), String.t()) :: - {:ok, Translation.t()} | {:error, term()} - def import_orphan_media_translation_sidecar(project_id, relative_path) do - project = Projects.get_project!(project_id) - sidecar_path = Path.join(Projects.project_data_dir(project), relative_path) - - if File.exists?(sidecar_path) do - sidecar = parse_translation_sidecar(sidecar_path) - - case Repo.get(Media, DocumentFields.get(sidecar.fields, "translationFor")) do - nil -> - {:error, :not_found} - - media -> - case Repo.get_by(Translation, - translation_for: media.id, - language: DocumentFields.fetch!(sidecar.fields, "language") - ) do - nil -> - upsert_media_translation(media.id, DocumentFields.fetch!(sidecar.fields, "language"), %{ - title: DocumentFields.get(sidecar.fields, "title"), - alt: DocumentFields.get(sidecar.fields, "alt"), - caption: DocumentFields.get(sidecar.fields, "caption") - }) - - _translation -> - {:error, :conflict} - end - end - else - {:error, :not_found} - end - end - @spec delete_media(String.t()) :: {:ok, :deleted} | {:error, :not_found} def delete_media(media_id) do case Repo.get(Media, media_id) do @@ -328,7 +246,11 @@ defmodule BDS.Media do case Repo.transaction(fn -> Repo.delete!(translation) end) do {:ok, _deleted} -> - delete_file_if_present(media.project_id, translation_sidecar_path(media, normalized_language)) + delete_file_if_present( + media.project_id, + translation_sidecar_path(media, normalized_language) + ) + :ok = Search.sync_media(media) :ok = write_sidecar(project, media) {:ok, true} @@ -399,595 +321,4 @@ defmodule BDS.Media do order_by: [asc: translation.language] ) end - - @spec list_linked_posts(String.t()) :: [%{post_id: String.t(), title: String.t(), sort_order: integer()}] - def list_linked_posts(media_id) when is_binary(media_id) do - Repo.all( - from post in BDS.Posts.Post, - join: pm in PostMedia, - on: pm.post_id == post.id, - where: pm.media_id == ^media_id, - order_by: [asc: pm.sort_order, asc: post.updated_at], - select: %{ - post_id: post.id, - title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id), - sort_order: pm.sort_order - } - ) - end - - @spec link_media_to_post(String.t(), String.t()) :: - {:ok, :linked} | {:error, :not_found | term()} - def link_media_to_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do - case {Repo.get(Media, media_id), Repo.get(BDS.Posts.Post, post_id)} do - {nil, _post} -> - {:error, :not_found} - - {_media, nil} -> - {:error, :not_found} - - {%Media{} = media, %BDS.Posts.Post{} = post} -> - project = Projects.get_project!(media.project_id) - - case Repo.transaction(fn -> - if Repo.exists?(from pm in PostMedia, where: pm.post_id == ^post.id and pm.media_id == ^media.id) do - :already_linked - else - sort_order = next_sort_order(media.id) - - %PostMedia{} - |> PostMedia.changeset(%{ - id: Ecto.UUID.generate(), - project_id: media.project_id, - post_id: post.id, - media_id: media.id, - sort_order: sort_order, - created_at: Persistence.now_ms() - }) - |> Repo.insert!() - - :linked - end - end) do - {:ok, _result} -> - :ok = write_sidecar(project, media) - {:ok, :linked} - - {:error, reason} -> - {:error, reason} - end - end - end - - @spec unlink_media_from_post(String.t(), String.t()) :: - {:ok, :unlinked} | {:error, :not_found | term()} - def unlink_media_from_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do - case Repo.get(Media, media_id) do - nil -> - {:error, :not_found} - - %Media{} = media -> - project = Projects.get_project!(media.project_id) - - case Repo.transaction(fn -> - {_count, _} = - Repo.delete_all( - from pm in PostMedia, where: pm.media_id == ^media.id and pm.post_id == ^post_id - ) - - :ok - end) do - {:ok, :ok} -> - :ok = write_sidecar(project, media) - {:ok, :unlinked} - - {:error, reason} -> - {:error, reason} - end - end - end - - @spec thumbnail_paths(Media.t()) :: %{required(atom()) => String.t()} - def thumbnail_paths(%Media{id: id}) do - prefix = String.slice(id, 0, 2) - - %{ - small: Path.join(["thumbnails", prefix, "#{id}-small.webp"]), - medium: Path.join(["thumbnails", prefix, "#{id}-medium.webp"]), - large: Path.join(["thumbnails", prefix, "#{id}-large.webp"]), - ai: Path.join(["thumbnails", prefix, "#{id}-ai.jpg"]) - } - end - - @spec regenerate_thumbnails(String.t()) :: {:ok, Media.t()} | {:error, :not_found | term()} - def regenerate_thumbnails(media_id) do - case Repo.get(Media, media_id) do - nil -> - {:error, :not_found} - - media -> - project = Projects.get_project!(media.project_id) - :ok = ensure_thumbnails(project, media) - {:ok, media} - end - end - - @spec regenerate_missing_thumbnails(String.t(), rebuild_opts()) :: - %{processed: non_neg_integer(), generated: non_neg_integer(), failed: non_neg_integer()} - def regenerate_missing_thumbnails(project_id, opts \\ []) do - project = Projects.get_project!(project_id) - on_progress = progress_callback(opts) - - media_items = - Repo.all( - from(media in Media, - where: media.project_id == ^project_id, - order_by: [asc: media.created_at] - ) - ) - |> Enum.filter(fn media -> - String.starts_with?(media.mime_type || "", "image/") and - not String.contains?(media.mime_type || "", "svg") - end) - - total_media = length(media_items) - :ok = report_rebuild_started(on_progress, total_media, "image assets") - - media_items - |> Enum.with_index(1) - |> Enum.reduce(%{processed: 0, generated: 0, failed: 0}, fn {media, index}, acc -> - missing_paths = - media - |> thumbnail_paths() - |> Enum.map(fn {_size, relative_path} -> Path.join(Projects.project_data_dir(project), relative_path) end) - |> Enum.reject(&File.exists?/1) - - next_acc = - if missing_paths == [] do - %{acc | processed: acc.processed + 1} - else - try do - :ok = ensure_thumbnails(project, media) - - %{ - processed: acc.processed + 1, - generated: acc.generated + length(missing_paths), - failed: acc.failed - } - rescue - _error -> - %{acc | processed: acc.processed + 1, failed: acc.failed + 1} - end - end - - :ok = report_rebuild_progress(on_progress, index, total_media, "image assets") - next_acc - end) - end - - @spec rebuild_media_from_files(String.t(), rebuild_opts()) :: {:ok, [Media.t()]} - def rebuild_media_from_files(project_id, opts \\ []) do - project = Projects.get_project!(project_id) - on_progress = progress_callback(opts) - - canonical_sidecars = - project - |> Projects.project_data_dir() - |> Path.join("media") - |> list_matching_files("*.meta") - |> Enum.filter(&canonical_sidecar?/1) - |> Enum.filter(&binary_exists_for_sidecar?/1) - |> Rebuild.parallel_map(&parse_canonical_sidecar(project, &1)) - - translation_sidecars = - project - |> Projects.project_data_dir() - |> Path.join("media") - |> list_matching_files("*.meta") - |> Enum.filter(&translation_sidecar?/1) - |> Rebuild.parallel_map(&parse_translation_sidecar(&1)) - - total_files = length(canonical_sidecars) + length(translation_sidecars) - :ok = report_rebuild_started(on_progress, total_files, "media files") - - media_items = - canonical_sidecars - |> Enum.with_index(1) - |> Enum.map(fn {sidecar, index} -> - media = upsert_media_from_sidecar(project, sidecar, sync_search: false) - :ok = report_rebuild_progress(on_progress, index, total_files, "media files") - media - end) - - canonical_media_by_binary_path = - Map.new(media_items, fn media -> - {Path.join(Projects.project_data_dir(project), media.file_path), media} - end) - - translation_sidecars - |> Enum.with_index(length(canonical_sidecars) + 1) - |> Enum.each(fn {sidecar, index} -> - upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, sync_search: false) - :ok = report_rebuild_progress(on_progress, index, total_files, "media files") - end) - - if Keyword.get(opts, :reindex_search, true) do - :ok = report_rebuild_phase(on_progress, 0.99, "Refreshing media search index") - :ok = - Search.reindex_media(project.id, - on_progress: scaled_progress_reporter(on_progress, 0.99, 1.0) - ) - end - - {:ok, media_items} - end - - defp upsert_media_from_sidecar(project, sidecar, opts) do - now = Persistence.now_ms() - - attrs = %{ - id: DocumentFields.get(sidecar.fields, "id") || Ecto.UUID.generate(), - project_id: project.id, - filename: sidecar.filename, - original_name: DocumentFields.get(sidecar.fields, "originalName") || sidecar.filename, - mime_type: DocumentFields.get(sidecar.fields, "mimeType") || detect_mime(sidecar.filename), - size: DocumentFields.get(sidecar.fields, "size", 0), - width: blank_to_nil(DocumentFields.get(sidecar.fields, "width")), - height: blank_to_nil(DocumentFields.get(sidecar.fields, "height")), - title: DocumentFields.get(sidecar.fields, "title"), - alt: DocumentFields.get(sidecar.fields, "alt"), - caption: DocumentFields.get(sidecar.fields, "caption"), - author: DocumentFields.get(sidecar.fields, "author"), - language: DocumentFields.get(sidecar.fields, "language"), - file_path: sidecar.relative_file_path, - sidecar_path: sidecar.relative_sidecar_path, - checksum: nil, - tags: DocumentFields.get(sidecar.fields, "tags", []), - created_at: DocumentFields.get(sidecar.fields, "createdAt", now), - updated_at: DocumentFields.get(sidecar.fields, "updatedAt", now) - } - - media = - Repo.get(Media, attrs.id) || - Repo.get_by(Media, project_id: project.id, file_path: sidecar.relative_file_path) || %Media{} - - media = - media - |> Media.changeset(attrs) - |> Repo.insert_or_update!() - - if Keyword.get(opts, :sync_search, true) do - :ok = Search.sync_media(media) - end - - media - end - - defp write_sidecar(project, media) do - path = Path.join(Projects.project_data_dir(project), media.sidecar_path) - :ok = File.mkdir_p(Path.dirname(path)) - - atomic_write( - path, - Sidecar.serialize_document([ - {"id", media.id}, - {"originalName", media.original_name}, - {"mimeType", media.mime_type}, - {"size", media.size}, - {"width", media.width}, - {"height", media.height}, - {"title", media.title}, - {"alt", media.alt}, - {"caption", media.caption}, - {"author", media.author}, - {"language", media.language}, - {"createdAt", media.created_at}, - {"updatedAt", media.updated_at}, - {"linkedPostIds", linked_post_ids(media.id)}, - {"tags", media.tags || []} - ]) - ) - end - - defp write_translation_sidecar(project, media, translation) do - path = - Path.join( - Projects.project_data_dir(project), - translation_sidecar_path(media, translation.language) - ) - - :ok = File.mkdir_p(Path.dirname(path)) - - atomic_write( - path, - Sidecar.serialize_document([ - {"translationFor", media.id}, - {"language", translation.language}, - {"title", translation.title}, - {"alt", translation.alt}, - {"caption", translation.caption} - ]) - ) - end - - defp upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, opts) do - case Map.get(canonical_media_by_binary_path, sidecar.binary_path) do - nil -> - :skip - - media -> - now = Persistence.now_ms() - language = DocumentFields.fetch!(sidecar.fields, "language") - - translation = - Repo.get_by(Translation, translation_for: media.id, language: language) || - %Translation{id: Ecto.UUID.generate(), created_at: now} - - translation - |> Translation.changeset(%{ - id: translation.id, - project_id: project.id, - translation_for: media.id, - language: language, - title: DocumentFields.get(sidecar.fields, "title"), - alt: DocumentFields.get(sidecar.fields, "alt"), - caption: DocumentFields.get(sidecar.fields, "caption"), - created_at: translation.created_at || now, - updated_at: now - }) - |> Repo.insert_or_update!() - - if Keyword.get(opts, :sync_search, true) do - :ok = Search.sync_media(media.id) - end - end - end - - defp parse_canonical_sidecar(project, sidecar_path) do - {:ok, fields} = sidecar_path |> File.read!() |> Sidecar.parse_document() - relative_sidecar_path = Path.relative_to(sidecar_path, Projects.project_data_dir(project)) - relative_file_path = String.trim_trailing(relative_sidecar_path, ".meta") - - %{ - fields: fields, - relative_sidecar_path: relative_sidecar_path, - relative_file_path: relative_file_path, - filename: Path.basename(relative_file_path) - } - end - - defp parse_translation_sidecar(sidecar_path) do - {:ok, fields} = sidecar_path |> File.read!() |> Sidecar.parse_document() - - %{ - fields: fields, - binary_path: binary_path_for_translation_sidecar(sidecar_path) - } - end - - defp ensure_thumbnails(project, media) do - if image_mime?(media.mime_type) do - source_path = Path.join(Projects.project_data_dir(project), media.file_path) - - case Image.open(source_path) do - {:ok, image} -> - image - |> Image.autorotate!() - |> write_all_thumbnails(project, media) - - {:error, _reason} -> - :ok - end - end - - :ok - end - - defp write_all_thumbnails(image, project, media) do - thumbnail_paths(media) - |> Enum.each(fn {size, relative_path} -> - destination = Path.join(Projects.project_data_dir(project), relative_path) - :ok = File.mkdir_p(Path.dirname(destination)) - - image - |> render_thumbnail(size) - |> write_thumbnail(destination, size) - end) - - :ok - end - - defp render_thumbnail(image, :small), do: bounded_thumbnail(image, 150, 150) - defp render_thumbnail(image, :medium), do: bounded_thumbnail(image, 400, 400) - defp render_thumbnail(image, :large), do: bounded_thumbnail(image, 800, 800) - - defp render_thumbnail(image, :ai) do - image - |> Image.thumbnail!("448x448", fit: :contain, resize: :both, autorotate: false) - |> Image.embed!(448, 448, x: :center, y: :center, background_color: :black) - end - - defp bounded_thumbnail(image, width, height) do - Image.thumbnail!(image, "#{width}x#{height}", fit: :contain, resize: :down, autorotate: false) - end - - defp write_thumbnail(image, destination, :ai) do - flattened = Image.flatten!(image, background_color: :black) - Image.write!(flattened, destination, quality: 85, strip_metadata: true) - :ok - end - - defp write_thumbnail(image, destination, _size) do - Image.write!(image, destination, quality: 80, strip_metadata: true) - :ok - end - - defp delete_thumbnail_files(project_id, media) do - Enum.each(Map.values(thumbnail_paths(media)), fn path -> - delete_file_if_present(project_id, path) - end) - - :ok - end - - defp media_file_path(file_name, timestamp) do - datetime = Persistence.from_unix_ms!(timestamp) - year = Integer.to_string(datetime.year) - month = datetime.month |> Integer.to_string() |> String.pad_leading(2, "0") - Path.join(["media", year, month, file_name]) - end - - defp detect_mime(file_name) do - case String.downcase(Path.extname(file_name)) do - ".txt" -> "text/plain" - ".md" -> "text/markdown" - ".jpg" -> "image/jpeg" - ".jpeg" -> "image/jpeg" - ".png" -> "image/png" - ".gif" -> "image/gif" - ".webp" -> "image/webp" - ".tif" -> "image/tiff" - ".tiff" -> "image/tiff" - ".bmp" -> "image/bmp" - ".heic" -> "image/heic" - ".heif" -> "image/heif" - _ -> "application/octet-stream" - end - end - - defp image_dimensions(source_path, mime_type) do - if image_mime?(mime_type) do - case Image.open(source_path) do - {:ok, image} -> {Image.width(image), Image.height(image)} - {:error, _reason} -> {nil, nil} - end - else - {nil, nil} - end - end - - defp image_mime?(mime_type), do: String.starts_with?(mime_type || "", "image/") - - defp binary_exists_for_sidecar?(sidecar_path) do - sidecar_path - |> String.trim_trailing(".meta") - |> File.exists?() - end - - defp list_matching_files(dir, pattern) do - if File.dir?(dir) do - Path.join([dir, "**", pattern]) - |> Path.wildcard() - |> Enum.sort() - else - [] - end - end - - defp canonical_sidecar?(sidecar_path) do - not translation_sidecar?(sidecar_path) - end - - defp translation_sidecar?(sidecar_path) do - Regex.match?(~r/\.[a-z]{2}\.meta$/i, sidecar_path) - end - - defp binary_path_for_translation_sidecar(sidecar_path) do - Regex.replace(~r/\.[a-z]{2}\.meta$/i, sidecar_path, "") - end - - defp translation_sidecar_path(media, language), do: "#{media.file_path}.#{language}.meta" - - defp delete_file_if_present(project_id, relative_path) do - project = Projects.get_project!(project_id) - full_path = Path.join(Projects.project_data_dir(project), relative_path) - - case File.rm(full_path) do - :ok -> :ok - {:error, :enoent} -> :ok - {:error, reason} -> {:error, reason} - end - end - - defp atomic_write(path, contents) do - Persistence.atomic_write(path, contents) - end - - defp linked_post_ids(media_id) do - Repo.all( - from pm in PostMedia, - where: pm.media_id == ^media_id, - order_by: [asc: pm.sort_order, asc: pm.post_id], - select: pm.post_id - ) - end - - defp next_sort_order(media_id) do - case Repo.one( - from pm in PostMedia, - where: pm.media_id == ^media_id, - select: max(pm.sort_order) - ) do - value when is_integer(value) -> value + 1 - _other -> 0 - end - end - - defp blank_to_nil(nil), do: nil - defp blank_to_nil(""), do: nil - defp blank_to_nil(value), do: value - - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) - - defp attr(attrs, key) do - cond do - Map.has_key?(attrs, key) -> Map.get(attrs, key) - Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key)) - true -> nil - end - end - - defp progress_callback(opts) do - case Keyword.get(opts, :on_progress) do - callback when is_function(callback, 2) -> callback - _other -> nil - end - end - - defp scaled_progress_reporter(nil, _start_value, _end_value), do: nil - - defp scaled_progress_reporter(report, start_value, end_value) when is_function(report, 2) do - fn value, message -> - scaled_value = start_value + (end_value - start_value) * value - report.(scaled_value, message) - end - end - - defp report_rebuild_started(nil, _total, _label), do: :ok - - defp report_rebuild_started(callback, 0, label) do - callback.(1.0, "No #{label} found") - :ok - end - - defp report_rebuild_started(callback, total, label) do - callback.(0.05, "Rebuilding #{label} (0/#{total})") - :ok - end - - defp report_rebuild_progress(nil, _current, _total, _label), do: :ok - defp report_rebuild_progress(_callback, _current, 0, _label), do: :ok - - defp report_rebuild_progress(callback, current, total, label) do - callback.(0.05 + 0.95 * (current / total), "Rebuilding #{label} (#{current}/#{total})") - :ok - end - - defp report_rebuild_phase(nil, _progress, _message), do: :ok - - defp report_rebuild_phase(callback, progress, message) do - callback.(progress, message) - :ok - end end diff --git a/lib/bds/media/file_ops.ex b/lib/bds/media/file_ops.ex new file mode 100644 index 0000000..313a6cf --- /dev/null +++ b/lib/bds/media/file_ops.ex @@ -0,0 +1,150 @@ +defmodule BDS.Media.FileOps do + @moduledoc false + + alias BDS.Persistence + alias BDS.Projects + + @typedoc "An attribute map that may use atom or string keys." + @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} + + @spec attr(attrs(), atom()) :: term() + def attr(attrs, key) do + cond do + Map.has_key?(attrs, key) -> Map.get(attrs, key) + Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key)) + true -> nil + end + end + + @spec maybe_put(map(), atom(), term()) :: map() + def maybe_put(map, _key, nil), do: map + def maybe_put(map, key, value), do: Map.put(map, key, value) + + @spec blank_to_nil(term()) :: term() + def blank_to_nil(nil), do: nil + def blank_to_nil(""), do: nil + def blank_to_nil(value), do: value + + @spec atomic_write(Path.t(), iodata()) :: :ok | {:error, term()} + def atomic_write(path, contents), do: Persistence.atomic_write(path, contents) + + @spec delete_file_if_present(String.t(), Path.t()) :: :ok | {:error, term()} + def delete_file_if_present(project_id, relative_path) do + project = Projects.get_project!(project_id) + full_path = Path.join(Projects.project_data_dir(project), relative_path) + + case File.rm(full_path) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @spec list_matching_files(Path.t(), String.t()) :: [Path.t()] + def list_matching_files(dir, pattern) do + if File.dir?(dir) do + Path.join([dir, "**", pattern]) + |> Path.wildcard() + |> Enum.sort() + else + [] + end + end + + @spec media_file_path(String.t(), integer()) :: Path.t() + def media_file_path(file_name, timestamp) do + datetime = Persistence.from_unix_ms!(timestamp) + year = Integer.to_string(datetime.year) + month = datetime.month |> Integer.to_string() |> String.pad_leading(2, "0") + Path.join(["media", year, month, file_name]) + end + + @spec detect_mime(String.t()) :: String.t() + def detect_mime(file_name) do + case String.downcase(Path.extname(file_name)) do + ".txt" -> "text/plain" + ".md" -> "text/markdown" + ".jpg" -> "image/jpeg" + ".jpeg" -> "image/jpeg" + ".png" -> "image/png" + ".gif" -> "image/gif" + ".webp" -> "image/webp" + ".tif" -> "image/tiff" + ".tiff" -> "image/tiff" + ".bmp" -> "image/bmp" + ".heic" -> "image/heic" + ".heif" -> "image/heif" + _ -> "application/octet-stream" + end + end + + @spec image_dimensions(Path.t(), String.t() | nil) :: + {non_neg_integer() | nil, non_neg_integer() | nil} + def image_dimensions(source_path, mime_type) do + if image_mime?(mime_type) do + case Image.open(source_path) do + {:ok, image} -> {Image.width(image), Image.height(image)} + {:error, _reason} -> {nil, nil} + end + else + {nil, nil} + end + end + + @spec image_mime?(String.t() | nil) :: boolean() + def image_mime?(mime_type), do: String.starts_with?(mime_type || "", "image/") + + @spec progress_callback(keyword()) :: (float(), String.t() -> any()) | nil + def progress_callback(opts) do + case Keyword.get(opts, :on_progress) do + callback when is_function(callback, 2) -> callback + _other -> nil + end + end + + @spec scaled_progress_reporter((float(), String.t() -> any()) | nil, float(), float()) :: + (float(), String.t() -> any()) | nil + def scaled_progress_reporter(nil, _start_value, _end_value), do: nil + + def scaled_progress_reporter(report, start_value, end_value) when is_function(report, 2) do + fn value, message -> + scaled_value = start_value + (end_value - start_value) * value + report.(scaled_value, message) + end + end + + @spec report_rebuild_started((float(), String.t() -> any()) | nil, non_neg_integer(), String.t()) :: :ok + def report_rebuild_started(nil, _total, _label), do: :ok + + def report_rebuild_started(callback, 0, label) do + callback.(1.0, "No #{label} found") + :ok + end + + def report_rebuild_started(callback, total, label) do + callback.(0.05, "Rebuilding #{label} (0/#{total})") + :ok + end + + @spec report_rebuild_progress( + (float(), String.t() -> any()) | nil, + non_neg_integer(), + non_neg_integer(), + String.t() + ) :: :ok + def report_rebuild_progress(nil, _current, _total, _label), do: :ok + def report_rebuild_progress(_callback, _current, 0, _label), do: :ok + + def report_rebuild_progress(callback, current, total, label) do + callback.(0.05 + 0.95 * (current / total), "Rebuilding #{label} (#{current}/#{total})") + :ok + end + + @spec report_rebuild_phase((float(), String.t() -> any()) | nil, float(), String.t()) :: :ok + def report_rebuild_phase(nil, _progress, _message), do: :ok + + def report_rebuild_phase(callback, progress, message) do + callback.(progress, message) + :ok + end +end diff --git a/lib/bds/media/linking.ex b/lib/bds/media/linking.ex new file mode 100644 index 0000000..470a9c8 --- /dev/null +++ b/lib/bds/media/linking.ex @@ -0,0 +1,125 @@ +defmodule BDS.Media.Linking do + @moduledoc false + + import Ecto.Query + + alias BDS.Media.Media + alias BDS.Media.Sidecars + alias BDS.Persistence + alias BDS.Posts.PostMedia + alias BDS.Projects + alias BDS.Repo + + @spec list_linked_posts(String.t()) :: + [%{post_id: String.t(), title: String.t(), sort_order: integer()}] + def list_linked_posts(media_id) when is_binary(media_id) do + Repo.all( + from post in BDS.Posts.Post, + join: pm in PostMedia, + on: pm.post_id == post.id, + where: pm.media_id == ^media_id, + order_by: [asc: pm.sort_order, asc: post.updated_at], + select: %{ + post_id: post.id, + title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id), + sort_order: pm.sort_order + } + ) + end + + @spec link_media_to_post(String.t(), String.t()) :: + {:ok, :linked} | {:error, :not_found | term()} + def link_media_to_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do + case {Repo.get(Media, media_id), Repo.get(BDS.Posts.Post, post_id)} do + {nil, _post} -> + {:error, :not_found} + + {_media, nil} -> + {:error, :not_found} + + {%Media{} = media, %BDS.Posts.Post{} = post} -> + project = Projects.get_project!(media.project_id) + + case Repo.transaction(fn -> + if Repo.exists?( + from pm in PostMedia, + where: pm.post_id == ^post.id and pm.media_id == ^media.id + ) do + :already_linked + else + sort_order = next_sort_order(media.id) + + %PostMedia{} + |> PostMedia.changeset(%{ + id: Ecto.UUID.generate(), + project_id: media.project_id, + post_id: post.id, + media_id: media.id, + sort_order: sort_order, + created_at: Persistence.now_ms() + }) + |> Repo.insert!() + + :linked + end + end) do + {:ok, _result} -> + :ok = Sidecars.write_sidecar(project, media) + {:ok, :linked} + + {:error, reason} -> + {:error, reason} + end + end + end + + @spec unlink_media_from_post(String.t(), String.t()) :: + {:ok, :unlinked} | {:error, :not_found | term()} + def unlink_media_from_post(media_id, post_id) when is_binary(media_id) and is_binary(post_id) do + case Repo.get(Media, media_id) do + nil -> + {:error, :not_found} + + %Media{} = media -> + project = Projects.get_project!(media.project_id) + + case Repo.transaction(fn -> + {_count, _} = + Repo.delete_all( + from pm in PostMedia, + where: pm.media_id == ^media.id and pm.post_id == ^post_id + ) + + :ok + end) do + {:ok, :ok} -> + :ok = Sidecars.write_sidecar(project, media) + {:ok, :unlinked} + + {:error, reason} -> + {:error, reason} + end + end + end + + @spec linked_post_ids(String.t()) :: [String.t()] + def linked_post_ids(media_id) do + Repo.all( + from pm in PostMedia, + where: pm.media_id == ^media_id, + order_by: [asc: pm.sort_order, asc: pm.post_id], + select: pm.post_id + ) + end + + defp next_sort_order(media_id) do + case Repo.one( + from pm in PostMedia, + where: pm.media_id == ^media_id, + select: max(pm.sort_order) + ) do + value when is_integer(value) -> value + 1 + _other -> 0 + end + end +end diff --git a/lib/bds/media/rebuilder.ex b/lib/bds/media/rebuilder.ex new file mode 100644 index 0000000..375a08c --- /dev/null +++ b/lib/bds/media/rebuilder.ex @@ -0,0 +1,82 @@ +defmodule BDS.Media.Rebuilder do + @moduledoc false + + import BDS.Media.FileOps, + only: [ + list_matching_files: 2, + progress_callback: 1, + report_rebuild_phase: 3, + report_rebuild_progress: 4, + report_rebuild_started: 3, + scaled_progress_reporter: 3 + ] + + alias BDS.Media.Media + alias BDS.Media.Sidecars + alias BDS.Projects + alias BDS.Rebuild + alias BDS.Search + + @type rebuild_opts :: keyword() + + @spec rebuild_media_from_files(String.t(), rebuild_opts()) :: {:ok, [Media.t()]} + def rebuild_media_from_files(project_id, opts \\ []) do + project = Projects.get_project!(project_id) + on_progress = progress_callback(opts) + + canonical_sidecars = + project + |> Projects.project_data_dir() + |> Path.join("media") + |> list_matching_files("*.meta") + |> Enum.filter(&Sidecars.canonical_sidecar?/1) + |> Enum.filter(&Sidecars.binary_exists_for_sidecar?/1) + |> Rebuild.parallel_map(&Sidecars.parse_canonical_sidecar(project, &1)) + + translation_sidecars = + project + |> Projects.project_data_dir() + |> Path.join("media") + |> list_matching_files("*.meta") + |> Enum.filter(&Sidecars.translation_sidecar?/1) + |> Rebuild.parallel_map(&Sidecars.parse_translation_sidecar(&1)) + + total_files = length(canonical_sidecars) + length(translation_sidecars) + :ok = report_rebuild_started(on_progress, total_files, "media files") + + media_items = + canonical_sidecars + |> Enum.with_index(1) + |> Enum.map(fn {sidecar, index} -> + media = Sidecars.upsert_media_from_sidecar(project, sidecar, sync_search: false) + :ok = report_rebuild_progress(on_progress, index, total_files, "media files") + media + end) + + canonical_media_by_binary_path = + Map.new(media_items, fn media -> + {Path.join(Projects.project_data_dir(project), media.file_path), media} + end) + + translation_sidecars + |> Enum.with_index(length(canonical_sidecars) + 1) + |> Enum.each(fn {sidecar, index} -> + Sidecars.upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, + sync_search: false + ) + + :ok = report_rebuild_progress(on_progress, index, total_files, "media files") + end) + + if Keyword.get(opts, :reindex_search, true) do + :ok = report_rebuild_phase(on_progress, 0.99, "Refreshing media search index") + + :ok = + Search.reindex_media(project.id, + on_progress: scaled_progress_reporter(on_progress, 0.99, 1.0) + ) + end + + {:ok, media_items} + end +end diff --git a/lib/bds/media/sidecars.ex b/lib/bds/media/sidecars.ex new file mode 100644 index 0000000..7e49ff0 --- /dev/null +++ b/lib/bds/media/sidecars.ex @@ -0,0 +1,329 @@ +defmodule BDS.Media.Sidecars do + @moduledoc false + + import BDS.Media.FileOps, + only: [ + atomic_write: 2, + blank_to_nil: 1, + detect_mime: 1 + ] + + alias BDS.DocumentFields + alias BDS.Media.Linking + alias BDS.Media.Media + alias BDS.Media.Translation + alias BDS.Persistence + alias BDS.Projects + alias BDS.Repo + alias BDS.Search + alias BDS.Sidecar + + @spec write_sidecar(BDS.Projects.Project.t(), Media.t()) :: :ok + def write_sidecar(project, media) do + path = Path.join(Projects.project_data_dir(project), media.sidecar_path) + :ok = File.mkdir_p(Path.dirname(path)) + + atomic_write( + path, + Sidecar.serialize_document([ + {"id", media.id}, + {"originalName", media.original_name}, + {"mimeType", media.mime_type}, + {"size", media.size}, + {"width", media.width}, + {"height", media.height}, + {"title", media.title}, + {"alt", media.alt}, + {"caption", media.caption}, + {"author", media.author}, + {"language", media.language}, + {"createdAt", media.created_at}, + {"updatedAt", media.updated_at}, + {"linkedPostIds", Linking.linked_post_ids(media.id)}, + {"tags", media.tags || []} + ]) + ) + end + + @spec write_translation_sidecar(BDS.Projects.Project.t(), Media.t(), Translation.t()) :: :ok + def write_translation_sidecar(project, media, translation) do + path = + Path.join( + Projects.project_data_dir(project), + translation_sidecar_path(media, translation.language) + ) + + :ok = File.mkdir_p(Path.dirname(path)) + + atomic_write( + path, + Sidecar.serialize_document([ + {"translationFor", media.id}, + {"language", translation.language}, + {"title", translation.title}, + {"alt", translation.alt}, + {"caption", translation.caption} + ]) + ) + end + + @spec parse_canonical_sidecar(BDS.Projects.Project.t(), Path.t()) :: map() + def parse_canonical_sidecar(project, sidecar_path) do + {:ok, fields} = sidecar_path |> File.read!() |> Sidecar.parse_document() + relative_sidecar_path = Path.relative_to(sidecar_path, Projects.project_data_dir(project)) + relative_file_path = String.trim_trailing(relative_sidecar_path, ".meta") + + %{ + fields: fields, + relative_sidecar_path: relative_sidecar_path, + relative_file_path: relative_file_path, + filename: Path.basename(relative_file_path) + } + end + + @spec parse_translation_sidecar(Path.t()) :: map() + def parse_translation_sidecar(sidecar_path) do + {:ok, fields} = sidecar_path |> File.read!() |> Sidecar.parse_document() + + %{ + fields: fields, + binary_path: binary_path_for_translation_sidecar(sidecar_path) + } + end + + @spec upsert_media_from_sidecar(BDS.Projects.Project.t(), map(), keyword()) :: Media.t() + def upsert_media_from_sidecar(project, sidecar, opts) do + now = Persistence.now_ms() + + attrs = %{ + id: DocumentFields.get(sidecar.fields, "id") || Ecto.UUID.generate(), + project_id: project.id, + filename: sidecar.filename, + original_name: DocumentFields.get(sidecar.fields, "originalName") || sidecar.filename, + mime_type: DocumentFields.get(sidecar.fields, "mimeType") || detect_mime(sidecar.filename), + size: DocumentFields.get(sidecar.fields, "size", 0), + width: blank_to_nil(DocumentFields.get(sidecar.fields, "width")), + height: blank_to_nil(DocumentFields.get(sidecar.fields, "height")), + title: DocumentFields.get(sidecar.fields, "title"), + alt: DocumentFields.get(sidecar.fields, "alt"), + caption: DocumentFields.get(sidecar.fields, "caption"), + author: DocumentFields.get(sidecar.fields, "author"), + language: DocumentFields.get(sidecar.fields, "language"), + file_path: sidecar.relative_file_path, + sidecar_path: sidecar.relative_sidecar_path, + checksum: nil, + tags: DocumentFields.get(sidecar.fields, "tags", []), + created_at: DocumentFields.get(sidecar.fields, "createdAt", now), + updated_at: DocumentFields.get(sidecar.fields, "updatedAt", now) + } + + media = + Repo.get(Media, attrs.id) || + Repo.get_by(Media, project_id: project.id, file_path: sidecar.relative_file_path) || + %Media{} + + media = + media + |> Media.changeset(attrs) + |> Repo.insert_or_update!() + + if Keyword.get(opts, :sync_search, true) do + :ok = Search.sync_media(media) + end + + media + end + + @spec upsert_translation_from_sidecar(BDS.Projects.Project.t(), %{required(Path.t()) => Media.t()}, map(), keyword()) :: + Translation.t() | :skip | :ok + def upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, opts) do + case Map.get(canonical_media_by_binary_path, sidecar.binary_path) do + nil -> + :skip + + media -> + now = Persistence.now_ms() + language = DocumentFields.fetch!(sidecar.fields, "language") + + translation = + Repo.get_by(Translation, translation_for: media.id, language: language) || + %Translation{id: Ecto.UUID.generate(), created_at: now} + + translation + |> Translation.changeset(%{ + id: translation.id, + project_id: project.id, + translation_for: media.id, + language: language, + title: DocumentFields.get(sidecar.fields, "title"), + alt: DocumentFields.get(sidecar.fields, "alt"), + caption: DocumentFields.get(sidecar.fields, "caption"), + created_at: translation.created_at || now, + updated_at: now + }) + |> Repo.insert_or_update!() + + if Keyword.get(opts, :sync_search, true) do + :ok = Search.sync_media(media.id) + end + end + end + + @spec sync_media_sidecar(String.t()) :: :ok | {:error, :not_found | term()} + def sync_media_sidecar(media_id) do + case Repo.get(Media, media_id) do + nil -> + {:error, :not_found} + + media -> + project = Projects.get_project!(media.project_id) + :ok = write_sidecar(project, media) + :ok + end + end + + @spec sync_media_from_sidecar(String.t()) :: {:ok, Media.t()} | {:error, :not_found | term()} + def sync_media_from_sidecar(media_id) do + case Repo.get(Media, media_id) do + nil -> + {:error, :not_found} + + %Media{} = media -> + project = Projects.get_project!(media.project_id) + sidecar_path = Path.join(Projects.project_data_dir(project), media.sidecar_path) + + if File.exists?(sidecar_path) do + {:ok, upsert_media_from_sidecar(project, parse_canonical_sidecar(project, sidecar_path), sync_search: true)} + else + {:error, :not_found} + end + end + end + + @spec sync_media_translation_sidecar(String.t()) :: + {:ok, Translation.t()} | {:error, :not_found | term()} + def sync_media_translation_sidecar(translation_id) do + case Repo.get(Translation, translation_id) do + nil -> + {:error, :not_found} + + %Translation{} = translation -> + media = Repo.get!(Media, translation.translation_for) + project = Projects.get_project!(media.project_id) + :ok = write_translation_sidecar(project, media, translation) + {:ok, translation} + end + end + + @spec sync_media_translation_from_sidecar(String.t()) :: + {:ok, Translation.t()} | {:error, :not_found | term()} + def sync_media_translation_from_sidecar(translation_id) do + case Repo.get(Translation, translation_id) do + nil -> + {:error, :not_found} + + %Translation{} = translation -> + media = Repo.get!(Media, translation.translation_for) + project = Projects.get_project!(media.project_id) + + sidecar_path = + Path.join( + Projects.project_data_dir(project), + translation_sidecar_path(media, translation.language) + ) + + if File.exists?(sidecar_path) do + sidecar = parse_translation_sidecar(sidecar_path) + + case BDS.Media.upsert_media_translation( + media.id, + DocumentFields.fetch!(sidecar.fields, "language"), + %{ + title: DocumentFields.get(sidecar.fields, "title"), + alt: DocumentFields.get(sidecar.fields, "alt"), + caption: DocumentFields.get(sidecar.fields, "caption") + } + ) do + {:ok, updated_translation} -> {:ok, updated_translation} + error -> error + end + else + {:error, :not_found} + end + end + end + + @spec import_orphan_media_sidecar(String.t(), String.t()) :: + {:ok, Media.t()} | {:error, term()} + def import_orphan_media_sidecar(project_id, relative_path) do + project = Projects.get_project!(project_id) + sidecar_path = Path.join(Projects.project_data_dir(project), relative_path) + + if File.exists?(sidecar_path) do + {:ok, upsert_media_from_sidecar(project, parse_canonical_sidecar(project, sidecar_path), sync_search: true)} + else + {:error, :not_found} + end + end + + @spec import_orphan_media_translation_sidecar(String.t(), String.t()) :: + {:ok, Translation.t()} | {:error, term()} + def import_orphan_media_translation_sidecar(project_id, relative_path) do + project = Projects.get_project!(project_id) + sidecar_path = Path.join(Projects.project_data_dir(project), relative_path) + + if File.exists?(sidecar_path) do + sidecar = parse_translation_sidecar(sidecar_path) + + case Repo.get(Media, DocumentFields.get(sidecar.fields, "translationFor")) do + nil -> + {:error, :not_found} + + media -> + case Repo.get_by(Translation, + translation_for: media.id, + language: DocumentFields.fetch!(sidecar.fields, "language") + ) do + nil -> + BDS.Media.upsert_media_translation( + media.id, + DocumentFields.fetch!(sidecar.fields, "language"), + %{ + title: DocumentFields.get(sidecar.fields, "title"), + alt: DocumentFields.get(sidecar.fields, "alt"), + caption: DocumentFields.get(sidecar.fields, "caption") + } + ) + + _translation -> + {:error, :conflict} + end + end + else + {:error, :not_found} + end + end + + @spec translation_sidecar_path(Media.t(), String.t()) :: String.t() + def translation_sidecar_path(media, language), do: "#{media.file_path}.#{language}.meta" + + @spec canonical_sidecar?(Path.t()) :: boolean() + def canonical_sidecar?(sidecar_path), do: not translation_sidecar?(sidecar_path) + + @spec translation_sidecar?(Path.t()) :: boolean() + def translation_sidecar?(sidecar_path) do + Regex.match?(~r/\.[a-z]{2}\.meta$/i, sidecar_path) + end + + @spec binary_path_for_translation_sidecar(Path.t()) :: Path.t() + def binary_path_for_translation_sidecar(sidecar_path) do + Regex.replace(~r/\.[a-z]{2}\.meta$/i, sidecar_path, "") + end + + @spec binary_exists_for_sidecar?(Path.t()) :: boolean() + def binary_exists_for_sidecar?(sidecar_path) do + sidecar_path + |> String.trim_trailing(".meta") + |> File.exists?() + end +end diff --git a/lib/bds/media/thumbnails.ex b/lib/bds/media/thumbnails.ex new file mode 100644 index 0000000..89e828d --- /dev/null +++ b/lib/bds/media/thumbnails.ex @@ -0,0 +1,165 @@ +defmodule BDS.Media.Thumbnails do + @moduledoc false + + import BDS.Media.FileOps, + only: [ + delete_file_if_present: 2, + image_mime?: 1, + progress_callback: 1, + report_rebuild_progress: 4, + report_rebuild_started: 3 + ] + + import Ecto.Query + + alias BDS.Media.Media + alias BDS.Projects + alias BDS.Repo + + @type rebuild_opts :: keyword() + + @spec thumbnail_paths(Media.t()) :: %{required(atom()) => String.t()} + def thumbnail_paths(%Media{id: id}) do + prefix = String.slice(id, 0, 2) + + %{ + small: Path.join(["thumbnails", prefix, "#{id}-small.webp"]), + medium: Path.join(["thumbnails", prefix, "#{id}-medium.webp"]), + large: Path.join(["thumbnails", prefix, "#{id}-large.webp"]), + ai: Path.join(["thumbnails", prefix, "#{id}-ai.jpg"]) + } + end + + @spec regenerate_thumbnails(String.t()) :: {:ok, Media.t()} | {:error, :not_found | term()} + def regenerate_thumbnails(media_id) do + case Repo.get(Media, media_id) do + nil -> + {:error, :not_found} + + media -> + project = Projects.get_project!(media.project_id) + :ok = ensure_thumbnails(project, media) + {:ok, media} + end + end + + @spec regenerate_missing_thumbnails(String.t(), rebuild_opts()) :: + %{processed: non_neg_integer(), generated: non_neg_integer(), failed: non_neg_integer()} + def regenerate_missing_thumbnails(project_id, opts \\ []) do + project = Projects.get_project!(project_id) + on_progress = progress_callback(opts) + + media_items = + Repo.all( + from(media in Media, + where: media.project_id == ^project_id, + order_by: [asc: media.created_at] + ) + ) + |> Enum.filter(fn media -> + String.starts_with?(media.mime_type || "", "image/") and + not String.contains?(media.mime_type || "", "svg") + end) + + total_media = length(media_items) + :ok = report_rebuild_started(on_progress, total_media, "image assets") + + media_items + |> Enum.with_index(1) + |> Enum.reduce(%{processed: 0, generated: 0, failed: 0}, fn {media, index}, acc -> + missing_paths = + media + |> thumbnail_paths() + |> Enum.map(fn {_size, relative_path} -> Path.join(Projects.project_data_dir(project), relative_path) end) + |> Enum.reject(&File.exists?/1) + + next_acc = + if missing_paths == [] do + %{acc | processed: acc.processed + 1} + else + try do + :ok = ensure_thumbnails(project, media) + + %{ + processed: acc.processed + 1, + generated: acc.generated + length(missing_paths), + failed: acc.failed + } + rescue + _error -> + %{acc | processed: acc.processed + 1, failed: acc.failed + 1} + end + end + + :ok = report_rebuild_progress(on_progress, index, total_media, "image assets") + next_acc + end) + end + + @spec ensure_thumbnails(BDS.Projects.Project.t(), Media.t()) :: :ok + def ensure_thumbnails(project, media) do + if image_mime?(media.mime_type) do + source_path = Path.join(Projects.project_data_dir(project), media.file_path) + + case Image.open(source_path) do + {:ok, image} -> + image + |> Image.autorotate!() + |> write_all_thumbnails(project, media) + + {:error, _reason} -> + :ok + end + end + + :ok + end + + @spec delete_thumbnail_files(String.t(), Media.t()) :: :ok + def delete_thumbnail_files(project_id, media) do + Enum.each(Map.values(thumbnail_paths(media)), fn path -> + delete_file_if_present(project_id, path) + end) + + :ok + end + + defp write_all_thumbnails(image, project, media) do + thumbnail_paths(media) + |> Enum.each(fn {size, relative_path} -> + destination = Path.join(Projects.project_data_dir(project), relative_path) + :ok = File.mkdir_p(Path.dirname(destination)) + + image + |> render_thumbnail(size) + |> write_thumbnail(destination, size) + end) + + :ok + end + + defp render_thumbnail(image, :small), do: bounded_thumbnail(image, 150, 150) + defp render_thumbnail(image, :medium), do: bounded_thumbnail(image, 400, 400) + defp render_thumbnail(image, :large), do: bounded_thumbnail(image, 800, 800) + + defp render_thumbnail(image, :ai) do + image + |> Image.thumbnail!("448x448", fit: :contain, resize: :both, autorotate: false) + |> Image.embed!(448, 448, x: :center, y: :center, background_color: :black) + end + + defp bounded_thumbnail(image, width, height) do + Image.thumbnail!(image, "#{width}x#{height}", fit: :contain, resize: :down, autorotate: false) + end + + defp write_thumbnail(image, destination, :ai) do + flattened = Image.flatten!(image, background_color: :black) + Image.write!(flattened, destination, quality: 85, strip_metadata: true) + :ok + end + + defp write_thumbnail(image, destination, _size) do + Image.write!(image, destination, quality: 80, strip_metadata: true) + :ok + end +end