lib/pardall_markdown/repository.ex

defmodule PardallMarkdown.Repository do
  @moduledoc """
  Provides the necessary API to retrieve and save parsed content.

  Preferrably, use only the `get_*` functions. The `build_*` and
  `save_*` functions are used by other PardallMarkdown modules.

  ## Understanding taxonomy Post archives (`Link.children`):
  - Posts are saved individually (to be retrieved with `PardallMarkdown.Repository.get_by_slug("/a/post/slug")`) and under their taxonomies and taxonomies' hierarchy. A taxonomy archive (all posts of a taxonomy) and its hierarchy are contained in `PardallMarkdown.Content.Link.children` when the taxonomy is retrieved by:
    - `PardallMarkdown.Repository.get_by_slug("/taxonomy/inner-taxonomy")`
    - `PardallMarkdown.Repository.get_content_tree("/taxonomy/inner-taxonomy")`
    - `PardallMarkdown.Repository.get_content_tree("/")` - root, which contains all taxonomies, their posts and hierarchy.
  - **When retrieving a taxonomy by slug** with `PardallMarkdown.Repository.get_by_slug("/taxonomy/name")` the taxonomy `:children` contains all posts from all of its innermost taxonomies `:children`.
    - For example, the post: "/blog/news/city/foo" appears inside the `:children` of 3 taxonomies: `"/blog"`, `"/blog/news"` and `"/blog/news/city"`.
  - On the other hand, **taxonomies in the content tree** retrieved with `PardallMarkdown.Repository.get_content_tree/1` contains only their immediate children posts.
    - For example, the post: "/blog/news/city/foo" appears only inside the `:children` its definying taxonomy: `"/blog/news/city"`.
  """

  alias PardallMarkdown.Content.Post
  alias PardallMarkdown.Cache
  import PardallMarkdown.Content.Filters
  import PardallMarkdown.Content.Utils
  require Logger
  alias Ecto.Changeset

  def init do
    Cache.delete_all()
  end

  #

  # CRUD interface

  #


  @doc """
  Gets all posts, unordered.

  `type`:
  - `:all`: all posts
  - `:post`: posts inside taxonomies
  - `:page`: posts at the root level "/"
  """
  def get_all_posts(type \\ :all) do
    Cache.get_all_posts(type)
  end

  @doc """
  Gets all links, ordered by slug
  `type`:
  - `:all`
  - `:taxonomy`: only taxonomy links
  - `:post`: only post links
  """
  def get_all_links(type \\ :all) do
    Cache.get_all_links(type)
  end

  @doc """
  Gets a map with all links, where the map keys are the links' slugs.
  `type`:
  - `:all`
  - `:taxonomy`: only taxonomy links
  - `:post`: only post links
  """
  def get_all_links_indexed_by_slug(type \\ :all) do
    Cache.get_all_links_indexed_by_slug(type)
  end

  @doc """
  Gets the content taxonomy tree, ordered
  and nested by `Link.level`
  """
  def get_taxonomy_tree() do
    Cache.get_taxonomy_tree()
  end

  @doc """
  Gets content trees. The content tree contains
  nested taxonomies and their nested posts.

  Posts are nested inside their innermost taxonomy.
  Posts are sorted by their outmost taxonomy sorting rules.

  `slug`:
  - `"/"`: content tree for all content
  - `"/taxonomy"`: content tree for the given taxonomy slug.
  Any level can be provided and the tree will be returned accordingly, example:
  "/any/nesting/level": taxonomies and posts that start at "/any/nesting/level",
  including "/any/nesting/level" itself.
  """
  def get_content_tree(slug \\ "/") do
    Cache.get_content_tree(slug)
  end

  def get_all_published do
    get_all_posts()
    |> filter_by_is_published()
  end

  @doc """
  Gets a single post or taxonomy by slug.

  Returns `PardallMarkdown.Post` (single post) or `PardallMarkdown.Link` (taxonomy and its :children)
  """
  def get_by_slug(slug), do: Cache.get_by_slug(slug)

  @doc """
  Same as `get_by_slug/1` but raises `PardallMarkdown.Content.NotFoundError`
  if slug not found.
  """
  def get_by_slug!(slug) do
    get_by_slug(slug) || raise PardallMarkdown.Content.NotFoundError, "Post not found: #{slug}"
  end

  def push_post(path, %{slug: slug, is_index: is_index?} = attrs, content, _type \\ :post) do
    model = get_by_slug(slug) || %Post{}

    model
    |> Post.changeset(%{
      type: get_post_type_from_taxonomies(attrs.categories, is_index?),
      file_path: path,
      title: attrs.title,
      content: content,
      slug: attrs.slug,
      date: attrs.date,
      summary: Map.get(attrs, :summary, nil),
      is_published: Map.get(attrs, :published, not is_content_draft_by_default?()),
      # for now, when a post is pushed to the repository, only "categories" are known

      taxonomies: attrs.categories,
      metadata: attrs |> remove_default_attributes(),
      position: Map.get(attrs, :position, 0),
      toc: attrs.toc
    })
    |> (fn
          %Changeset{valid?: true} = changeset ->
            (%Post{} = post) = changeset
            |> Changeset.apply_changes()
            |> save_post()

            Logger.info("Saved post #{slug}")
            {:ok, post}

          changeset ->
            Logger.error("Could not save post #{slug}, errors: #{inspect(changeset.errors)}")
            {:error, changeset}
        end).()
  end

  @doc """
  Rebuild indexes and then notify with a custom callback that the content has been reloaded.

  The notification can be used, for example, to perform a `Phoenix.Endpoint`
  broadcast, then a website which implements `Phoenix.Channel` or `Phoenix.LiveVew`
  can react accordingly.

  The notification callback must be put inside the application configuration key `:notify_content_reloaded`.

  Example:
  ```
  config :pardall_markdown, PardallMarkdown.Content,
    notify_content_reloaded: &Website.notify/0
  ```
  """
  def rebuild_indexes do
    Cache.build_taxonomy_tree()
    Cache.build_content_tree()

    notify_content_reloaded =
      Application.get_env(:pardall_markdown, PardallMarkdown.Content)[:notify_content_reloaded]

    cond do
      is_function(notify_content_reloaded) -> notify_content_reloaded.()
      true -> {:ok, :reloaded}
    end
  end

  #

  # Data helpers

  #


  # No taxonomy or a post contains only a taxonomy in the root:

  # the post is then considered a page, ex: /about, /contact

  defp get_post_type_from_taxonomies(categories, is_index?)
  defp get_post_type_from_taxonomies(_, true), do: :index
  defp get_post_type_from_taxonomies([], _), do: :page
  defp get_post_type_from_taxonomies([%{slug: "/"}], _), do: :page
  defp get_post_type_from_taxonomies(_, _), do: :post

  defp save_post(%Post{} = model), do: Cache.save_post(model)

  defp remove_default_attributes(attrs) do
    attrs
    |> Map.drop([:title, :slug, :date, :summary, :published, :categories, :is_index, :toc])
  end
end