defmodule BDS.Posts do @moduledoc false import Ecto.Query import BDS.MapUtils, only: [attr: 2, maybe_put: 3] alias BDS.Embeddings alias BDS.Media alias BDS.Persistence alias BDS.PostLinks alias BDS.Posts.AutoTranslation alias BDS.Posts.FileSync alias BDS.Posts.Link alias BDS.Posts.Post alias BDS.Posts.PostMedia alias BDS.Posts.RebuildFromFiles alias BDS.Posts.Slugs alias BDS.Posts.Translation alias BDS.Posts.Translations alias BDS.Posts.TranslationValidation alias BDS.Projects alias BDS.Repo alias BDS.Search alias BDS.Slug import FileSync, only: [ post_relative_path: 2, publishable_post_body: 3, published_post_body: 2, read_markdown_body: 1, serialize_post_file: 2, delete_post_file: 1 ] @typedoc "An attribute map that may use atom or string keys." @type attrs :: %{optional(atom()) => term(), optional(String.t()) => term()} @typedoc "Options accepted by long-running rebuild operations." @type rebuild_opts :: keyword() @typedoc "Aggregate counts returned by `dashboard_stats/1`." @type dashboard_stats :: %{ total_posts: non_neg_integer(), draft_count: non_neg_integer(), published_count: non_neg_integer(), archived_count: non_neg_integer() } @typedoc "Per-month post count entry returned by `post_counts_by_year_month/1`." @type month_count :: %{year: integer(), month: integer(), count: non_neg_integer()} @typedoc "Translation validation report returned by `validate_translations/2`." @type translation_validation_report :: %{ checked_database_row_count: non_neg_integer(), checked_filesystem_file_count: non_neg_integer(), invalid_database_rows: [map()], invalid_filesystem_files: [map()], missing: [map()], orphan_files: [map()], do_not_translate_posts: [map()] } @spec create_post(attrs()) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()} def create_post(attrs) do now = Persistence.now_ms() project_id = attr(attrs, :project_id) title = normalize_title(attr(attrs, :title)) base_slug = title |> Slugs.default_source() |> Slug.slugify() %Post{} |> Post.changeset(%{ id: Ecto.UUID.generate(), project_id: project_id, title: title, slug: Slugs.unique(project_id, base_slug), excerpt: attr(attrs, :excerpt), content: attr(attrs, :content), status: :draft, author: attr(attrs, :author), created_at: now, updated_at: now, published_at: nil, file_path: "", checksum: attr(attrs, :checksum), tags: attr(attrs, :tags) || [], categories: attr(attrs, :categories) || [], template_slug: attr(attrs, :template_slug), language: attr(attrs, :language), do_not_translate: attr(attrs, :do_not_translate) || false, published_title: nil, published_content: nil, published_tags: nil, published_categories: nil, published_excerpt: nil }) |> Repo.insert() |> case do {:ok, post} -> :ok = Embeddings.sync_post(post) :ok = Search.sync_post(post) :ok = AutoTranslation.maybe_schedule(post) {:ok, post} error -> error end end @spec update_post(String.t(), attrs()) :: {:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()} def update_post(post_id, attrs) do case Repo.get(Post, post_id) do nil -> {:error, :not_found} post -> with :ok <- validate_slug_change(post, attrs) do now = Persistence.now_ms() updates = attrs |> normalize_updates(post) |> Map.put(:updated_at, now) |> maybe_reopen_published_post(post) post |> Post.changeset(updates) |> Repo.update() |> case do {:ok, updated_post} -> if post.status == :published and updated_post.status == :published and Map.get(updates, :template_slug) != nil and updated_post.template_slug != post.template_slug do :ok = rewrite_published_post(updated_post.id) end :ok = Embeddings.sync_post(updated_post) :ok = PostLinks.sync_post_links(updated_post) :ok = Search.sync_post(updated_post) :ok = AutoTranslation.maybe_schedule(updated_post) {:ok, updated_post} error -> error end else {:error, changeset} -> {:error, changeset} end end end @spec publish_post(String.t()) :: {:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()} def publish_post(post_id) do case Repo.get(Post, post_id) do nil -> {:error, :not_found} %Post{} = post -> project = Projects.get_project!(post.project_id) published_at = post.published_at || Persistence.now_ms() relative_path = post_relative_path(post.slug, post.created_at) full_path = Path.join(Projects.project_data_dir(project), relative_path) updated_at = Persistence.now_ms() body = publishable_post_body(post, full_path, project) :ok = Persistence.atomic_write( full_path, serialize_post_file(%{post | updated_at: updated_at, content: body}, published_at) ) post |> Post.changeset(%{ status: :published, published_at: published_at, file_path: relative_path, content: nil, updated_at: updated_at }) |> Repo.update() |> case do {:ok, updated_post} -> :ok = Embeddings.sync_post(updated_post) :ok = Translations.publish_post_translations(updated_post) :ok = PostLinks.sync_post_links(updated_post) :ok = Search.sync_post(updated_post) {:ok, updated_post} error -> error end end end @spec rebuild_posts_from_files(String.t(), rebuild_opts()) :: {:ok, [Post.t()]} | {:error, term()} defdelegate rebuild_posts_from_files(project_id, opts \\ []), to: RebuildFromFiles @spec discard_post_changes(String.t()) :: {:ok, Post.t()} | {:error, :not_found} def discard_post_changes(post_id) do case Repo.get(Post, post_id) do nil -> {:error, :not_found} %Post{file_path: file_path} when file_path in [nil, ""] -> {:error, :not_found} %Post{} = post -> project = Projects.get_project!(post.project_id) full_path = Path.join(Projects.project_data_dir(project), post.file_path) if File.exists?(full_path) do with {:ok, restored_post} <- RebuildFromFiles.upsert_post_from_file(post.project_id, project, full_path) do :ok = PostLinks.sync_post_links(restored_post) {:ok, restored_post} else {:error, reason} -> {:error, reason} end else {:error, :not_found} end end end @spec editor_body(Post.t() | Translation.t() | term()) :: String.t() def editor_body(%Post{content: content}) when is_binary(content), do: content def editor_body(%Post{project_id: project_id, file_path: file_path}) when is_binary(file_path) and file_path != "" do project_id |> Projects.get_project!() |> Projects.project_data_dir() |> Path.join(file_path) |> read_markdown_body() end def editor_body(%Translation{content: content}) when is_binary(content), do: content def editor_body(%Translation{project_id: project_id, file_path: file_path}) when is_binary(file_path) and file_path != "" do project_id |> Projects.get_project!() |> Projects.project_data_dir() |> Path.join(file_path) |> read_markdown_body() end def editor_body(_record), do: "" @spec sync_post_from_file(String.t()) :: {:ok, Post.t()} | {:error, :not_found} def sync_post_from_file(post_id) do case Repo.get(Post, post_id) do nil -> {:error, :not_found} %Post{file_path: file_path} when file_path in [nil, ""] -> {:error, :not_found} %Post{} = post -> project = Projects.get_project!(post.project_id) full_path = Path.join(Projects.project_data_dir(project), post.file_path) if File.exists?(full_path) do with {:ok, repaired_post} <- RebuildFromFiles.upsert_post_from_file(post.project_id, project, full_path) do :ok = PostLinks.sync_post_links(repaired_post) {:ok, repaired_post} else {:error, reason} -> {:error, reason} end else {:error, :not_found} end end end @spec sync_post_translation_from_file(String.t()) :: {:ok, Translation.t()} | {:error, :not_found} defdelegate sync_post_translation_from_file(translation_id), to: Translations @spec rewrite_published_post_translation(String.t()) :: {:ok, Translation.t()} | {:error, :not_found} defdelegate rewrite_published_post_translation(translation_id), to: Translations @spec import_orphan_post_file(String.t(), String.t()) :: {:ok, Post.t()} | {:error, :not_found | :unsupported_file} defdelegate import_orphan_post_file(project_id, relative_path), to: RebuildFromFiles @spec import_orphan_post_translation_file(String.t(), String.t()) :: {:ok, Translation.t()} | {:error, :not_found | :unsupported_file | :conflict} defdelegate import_orphan_post_translation_file(project_id, relative_path), to: RebuildFromFiles @spec delete_post(String.t()) :: {:ok, :deleted} | {:error, :not_found | Ecto.Changeset.t()} def delete_post(post_id) do case Repo.get(Post, post_id) do nil -> {:error, :not_found} %Post{} = post -> linked_media_ids = Repo.all( from pm in PostMedia, where: pm.post_id == ^post.id, order_by: [asc: pm.sort_order, asc: pm.media_id], select: pm.media_id ) case Repo.delete(post) do {:ok, deleted_post} -> delete_post_file(deleted_post) Embeddings.remove_post(deleted_post.id) PostLinks.delete_post_links(deleted_post.id) Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1) Search.delete_post(deleted_post.id) {:ok, :deleted} {:error, changeset} -> {:error, changeset} end end end @spec archive_post(String.t()) :: {:ok, Post.t()} | {:error, :not_found | Ecto.Changeset.t()} def archive_post(post_id) do case Repo.get(Post, post_id) do nil -> {:error, :not_found} %Post{status: status} = post when status in [:draft, :published] -> post |> Post.changeset(%{status: :archived, updated_at: Persistence.now_ms()}) |> Repo.update() |> case do {:ok, updated_post} -> :ok = Search.sync_post(updated_post) {:ok, updated_post} error -> error end %Post{} = post -> {:error, post |> Post.changeset(%{}) |> Ecto.Changeset.add_error(:status, "cannot archive archived post")} end end @spec get_post!(String.t()) :: Post.t() @spec get_post(String.t()) :: Post.t() | nil def get_post(post_id), do: Repo.get(Post, post_id) def get_post!(post_id), do: Repo.get!(Post, post_id) @spec get_post_translation!(String.t()) :: Translation.t() def get_post_translation!(translation_id), do: Repo.get!(Translation, translation_id) @spec publish_post_translation(String.t(), String.t() | atom()) :: {:ok, Translation.t()} | {:error, :not_found | term()} defdelegate publish_post_translation(post_id, language), to: Translations @spec slug_available(String.t(), String.t(), String.t() | nil) :: boolean() defdelegate slug_available(project_id, slug, exclude_post_id \\ nil), to: Slugs, as: :available @spec unique_slug_for_title(String.t(), String.t(), String.t() | nil) :: String.t() defdelegate unique_slug_for_title(project_id, title, exclude_post_id \\ nil), to: Slugs, as: :unique_for_title @spec dashboard_stats(String.t()) :: dashboard_stats() def dashboard_stats(project_id) do Repo.all( from(post in Post, where: post.project_id == ^project_id, group_by: post.status, select: {post.status, count(post.id)} ) ) |> Enum.reduce( %{total_posts: 0, draft_count: 0, published_count: 0, archived_count: 0}, fn {:draft, n}, acc -> %{acc | total_posts: acc.total_posts + n, draft_count: n} {:published, n}, acc -> %{acc | total_posts: acc.total_posts + n, published_count: n} {:archived, n}, acc -> %{acc | total_posts: acc.total_posts + n, archived_count: n} {_other, n}, acc -> %{acc | total_posts: acc.total_posts + n} end ) end @spec post_counts_by_year_month(String.t()) :: [month_count()] def post_counts_by_year_month(project_id) do Repo.all( from(post in Post, where: post.project_id == ^project_id, select: post.created_at ) ) |> Enum.reduce(%{}, fn created_at, acc -> datetime = DateTime.from_unix!(created_at, :millisecond) key = {datetime.year, datetime.month} Map.update(acc, key, 1, &(&1 + 1)) end) |> Enum.map(fn {{year, month}, count} -> %{year: year, month: month, count: count} end) |> Enum.sort_by(fn %{year: year, month: month} -> {-year, -month} end) end @spec rebuild_post_links(String.t(), rebuild_opts()) :: :ok def rebuild_post_links(project_id, opts \\ []) do post_ids = Repo.all(from(post in Post, where: post.project_id == ^project_id, select: post.id)) on_progress = RebuildFromFiles.progress_callback(opts) Repo.delete_all( from(link in Link, where: link.source_post_id in ^post_ids or link.target_post_id in ^post_ids ) ) posts = Repo.all( from(post in Post, where: post.project_id == ^project_id, order_by: [asc: post.created_at] ) ) total_posts = length(posts) :ok = RebuildFromFiles.report_rebuild_started(on_progress, total_posts, "post links") posts |> Enum.with_index(1) |> Enum.each(fn {post, index} -> PostLinks.sync_post_links(post) :ok = RebuildFromFiles.report_rebuild_progress(on_progress, index, total_posts, "post links") end) :ok end @spec list_post_translations(String.t()) :: {:ok, [Translation.t()]} defdelegate list_post_translations(post_id), to: Translations @spec upsert_post_translation(String.t(), String.t() | atom(), attrs()) :: {:ok, Translation.t()} | {:error, :not_found | Ecto.Changeset.t()} defdelegate upsert_post_translation(post_id, language, attrs), to: Translations @spec delete_post_translation(String.t()) :: {:ok, :deleted} | {:error, :not_found} defdelegate delete_post_translation(translation_id), to: Translations @spec validate_translations(String.t(), rebuild_opts()) :: {:ok, translation_validation_report()} defdelegate validate_translations(project_id, opts \\ []), to: TranslationValidation, as: :validate @spec fix_invalid_translations(map()) :: {:ok, %{ deleted_database_rows: non_neg_integer(), deleted_files: non_neg_integer(), flushed_translations: non_neg_integer() }} defdelegate fix_invalid_translations(report), to: TranslationValidation, as: :fix_invalid @spec fill_missing_translations(String.t(), rebuild_opts()) :: {:ok, %{ translated_posts: non_neg_integer(), translated_media: non_neg_integer(), failed_count: non_neg_integer(), warned_count: non_neg_integer(), nothing_to_do: boolean() }} defdelegate fill_missing_translations(project_id, opts \\ []), to: AutoTranslation, as: :fill_missing @spec rewrite_published_post(String.t()) :: :ok def rewrite_published_post(post_id) do post = Repo.get!(Post, post_id) if post.status == :published and post.file_path not in [nil, ""] do project = Projects.get_project!(post.project_id) full_path = Path.join(Projects.project_data_dir(project), post.file_path) body = published_post_body(post, full_path) :ok = Persistence.atomic_write( full_path, serialize_post_file( %{post | content: body}, post.published_at || Persistence.now_ms() ) ) end :ok end defp normalize_updates(attrs, _post) do %{} |> maybe_put(:title, normalize_optional_title(attr(attrs, :title), attrs)) |> maybe_put(:slug, attr(attrs, :slug)) |> maybe_put(:excerpt, attr(attrs, :excerpt)) |> maybe_put(:content, attr(attrs, :content)) |> maybe_put(:status, attr(attrs, :status)) |> maybe_put(:author, attr(attrs, :author)) |> maybe_put(:published_at, attr(attrs, :published_at)) |> maybe_put(:file_path, attr(attrs, :file_path)) |> maybe_put(:checksum, attr(attrs, :checksum)) |> maybe_put(:tags, attr(attrs, :tags)) |> maybe_put(:categories, attr(attrs, :categories)) |> maybe_put(:template_slug, attr(attrs, :template_slug)) |> maybe_put(:language, attr(attrs, :language)) |> maybe_put(:do_not_translate, attr(attrs, :do_not_translate)) |> maybe_put(:published_title, attr(attrs, :published_title)) |> maybe_put(:published_content, attr(attrs, :published_content)) |> maybe_put(:published_tags, attr(attrs, :published_tags)) |> maybe_put(:published_categories, attr(attrs, :published_categories)) |> maybe_put(:published_excerpt, attr(attrs, :published_excerpt)) end defp validate_slug_change(%Post{published_at: published_at} = post, attrs) when not is_nil(published_at) do case attr(attrs, :slug) do nil -> :ok slug when slug == post.slug -> :ok _slug -> {:error, post |> Post.changeset(%{}) |> Ecto.Changeset.add_error(:slug, "cannot change slug after first publish")} end end defp validate_slug_change(_post, _attrs), do: :ok defp maybe_reopen_published_post(updates, %Post{status: :published} = post) do if published_content_change?(updates, post) do Map.put(updates, :status, :draft) else updates end end defp maybe_reopen_published_post(updates, _post), do: updates defp published_content_change?(updates, post) do Enum.any?( [ :title, :excerpt, :content, :author, :language, :tags, :categories, :do_not_translate ], fn field -> case Map.fetch(updates, field) do {:ok, value} -> value != Map.get(post, field) :error -> false end end ) end defp normalize_title(nil), do: "" defp normalize_title(title), do: title defp normalize_optional_title(_title, attrs) do if has_attr?(attrs, :title), do: normalize_title(attr(attrs, :title)), else: nil end defp sync_deleted_post_media_sidecar(media_id) do case Media.sync_media_sidecar(media_id) do :ok -> :ok {:error, :not_found} -> :ok end end defp has_attr?(attrs, key) do Map.has_key?(attrs, key) or Map.has_key?(attrs, Atom.to_string(key)) end end