Files
bDS2/lib/bds/posts.ex

587 lines
19 KiB
Elixir

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}
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
)
delete_post_file(post)
:ok = Embeddings.remove_post(post.id)
:ok = PostLinks.delete_post_links(post.id)
Repo.delete!(post)
Enum.each(linked_media_ids, &sync_deleted_post_media_sidecar/1)
:ok = Search.delete_post(post.id)
{:ok, :deleted}
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,
select: post.status
)
)
|> Enum.reduce(
%{total_posts: 0, draft_count: 0, published_count: 0, archived_count: 0},
fn status, acc ->
acc
|> Map.update!(:total_posts, &(&1 + 1))
|> case do
counts when status == :draft -> Map.update!(counts, :draft_count, &(&1 + 1))
counts when status == :published -> Map.update!(counts, :published_count, &(&1 + 1))
counts when status == :archived -> Map.update!(counts, :archived_count, &(&1 + 1))
counts -> counts
end
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 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