Files
bDS2/lib/bds/posts/file_sync.ex

147 lines
5.2 KiB
Elixir

defmodule BDS.Posts.FileSync do
@moduledoc false
alias BDS.Frontmatter
alias BDS.Persistence
alias BDS.Posts.Post
alias BDS.Posts.Translation
alias BDS.Projects
@doc "Compute the canonical relative path for a published post."
@spec post_relative_path(String.t(), integer()) :: String.t()
def post_relative_path(slug, created_at) do
datetime = Persistence.from_unix_ms!(created_at)
year = Integer.to_string(datetime.year)
month = datetime.month |> Integer.to_string() |> String.pad_leading(2, "0")
Path.join(["posts", year, month, "#{slug}.md"])
end
@doc "Compute the canonical relative path for a translation file."
@spec translation_relative_path(Post.t(), String.t()) :: String.t()
def translation_relative_path(post, language) do
datetime = Persistence.from_unix_ms!(post.created_at)
year = Integer.to_string(datetime.year)
month = datetime.month |> Integer.to_string() |> String.pad_leading(2, "0")
Path.join(["posts", year, month, "#{post.slug}.#{language}.md"])
end
@doc "Resolve the body to publish for a post, falling back to its existing file."
@spec publishable_post_body(Post.t(), String.t(), term()) :: String.t()
def publishable_post_body(%Post{content: content}, _full_path, _project)
when is_binary(content), do: content
def publishable_post_body(%Post{file_path: file_path} = post, full_path, project) do
source_path =
if file_path in [nil, ""] do
full_path
else
Path.join(Projects.project_data_dir(project), file_path)
end
published_post_body(post, source_path)
end
@doc "Read the body of a previously-published post (DB content first, file fallback)."
@spec published_post_body(Post.t(), String.t()) :: String.t()
def published_post_body(%Post{content: content}, _full_path) when is_binary(content),
do: content
def published_post_body(_post, full_path), do: read_markdown_body(full_path)
@doc "Read the body section (after frontmatter) from a markdown file on disk."
@spec read_markdown_body(String.t()) :: String.t()
def read_markdown_body(path) do
case File.read(path) do
{:ok, contents} ->
case String.split(contents, "\n---\n", parts: 2) do
[_frontmatter, body] -> String.trim_trailing(body, "\n")
_parts -> ""
end
{:error, _reason} ->
""
end
end
@doc "Serialize a post to a frontmatter+body string for the published file."
@spec serialize_post_file(Post.t(), integer()) :: String.t()
def serialize_post_file(post, published_at) do
Frontmatter.serialize_document(
[
{"id", post.id},
{"title", post.title},
{"slug", post.slug},
{"excerpt", post.excerpt},
{"status", :published},
{"author", post.author},
{"language", post.language},
{"doNotTranslate", post.do_not_translate || nil},
{"templateSlug", post.template_slug},
{"createdAt", post.created_at},
{"updatedAt", post.updated_at},
{"publishedAt", published_at},
{"tags", post.tags || []},
{"categories", post.categories || []}
],
post.content
)
end
@doc "Serialize a translation row to a frontmatter+body string."
@spec serialize_translation_file(Translation.t(), integer()) :: String.t()
def serialize_translation_file(translation, published_at) do
Frontmatter.serialize_document(
[
{"id", translation.id},
{"translationFor", translation.translation_for},
{"language", translation.language},
{"title", translation.title},
{"excerpt", translation.excerpt},
{"status", :published},
{"createdAt", translation.created_at},
{"updatedAt", translation.updated_at},
{"publishedAt", published_at}
],
translation.content
)
end
@doc "Resolve the body of a translation, falling back to its existing file."
@spec publishable_translation_body(Translation.t(), String.t()) :: String.t()
def publishable_translation_body(%Translation{content: content}, _full_path)
when is_binary(content), do: content
def publishable_translation_body(_translation, full_path) do
read_markdown_body(full_path)
end
@doc "Delete a published post's file on disk (no-op if it has none)."
@spec delete_post_file(Post.t()) :: :ok | {:error, term()}
def delete_post_file(%Post{file_path: file_path}) when file_path in [nil, ""], do: :ok
def delete_post_file(%Post{} = post) do
project = Projects.get_project!(post.project_id)
full_path = Path.join(Projects.project_data_dir(project), post.file_path)
rm_quiet(full_path)
end
@doc "Delete a translation's file on disk (no-op if it has none)."
@spec delete_translation_file(Translation.t()) :: :ok | {:error, term()}
def delete_translation_file(%Translation{file_path: file_path}) when file_path in [nil, ""],
do: :ok
def delete_translation_file(%Translation{} = translation) do
project = Projects.get_project!(translation.project_id)
full_path = Path.join(Projects.project_data_dir(project), translation.file_path)
rm_quiet(full_path)
end
defp rm_quiet(full_path) do
case File.rm(full_path) do
:ok -> :ok
{:error, :enoent} -> :ok
{:error, reason} -> {:error, reason}
end
end
end