lib/notionex/renderer/html_renderer.ex

defmodule Notionex.Renderer.HTMLRenderer do
  @behaviour Notionex.Renderer

  alias Notionex.Object.{Block, List}

  @impl true
  def render_block(
        %List{object: "list", type: "block", results: results},
        opts
      ) do
    results
    |> Enum.reduce([], fn block, acc ->
      updated_block = update_numbered_list_item_number(block, Enum.at(acc, 0))
      [updated_block | acc]
    end)
    |> Enum.reverse()
    |> Enum.map(fn b ->
      # Check for custom renderer
      # Current limitation is that custom renderer is only applied if called from the top level List block.
      # TODO: Move to different function
      custom_render_fn = Map.get(opts, :custom, %{}) |> Map.get({b.object, b.type})

      block_content =
        case custom_render_fn do
          nil -> render_block(b, opts)
          _ -> custom_render_fn.(b, opts)
        end

      # TODO: allow custom renderer for children
      child_content =
        if b.has_children do
          child_blocks = Notionex.API.retrieve_block_children(%{block_id: b.id})
          render_block(child_blocks, opts)
        else
          ""
        end

      "<div>#{block_content}#{child_content}</div>"
    end)
    |> Enum.join("<br />")
  end

  @doc """
  Renders the paragraph block into HTML.

  ## Example
      iex> Notionex.Renderer.HTMLRenderer.render_block(%Notionex.Object.Block{object: "block", type: "paragraph", paragraph: %{"color" => "default","rich_text" => [%{"annotations" => %{"bold" => false,"code" => false,"color" => "default","italic" => false,"strikethrough" => false,"underline" => false},"href" => nil,"plain_text" => "Hello world!","text" => %{"content" => "Hello world!", "link" => nil},"type" => "text"}]}}, %{})
      "<p>Hello world!</p>"
  """
  def render_block(%Block{object: "block", type: "paragraph", paragraph: paragraph}, _opts) do
    paragraph
    |> render_rich_text()
    |> then(&"<p>#{&1}</p>")
  end

  def render_block(%Block{object: "block", type: "heading_1", heading_1: heading_1}, _opts) do
    heading_1
    |> render_rich_text()
    |> then(&"<h1>#{&1}</h1>")
  end

  def render_block(%Block{object: "block", type: "heading_2", heading_2: heading_2}, _opts) do
    heading_2
    |> render_rich_text()
    |> then(&"<h3>#{&1}</h3>")
  end

  def render_block(%Block{object: "block", type: "heading_3", heading_3: heading_3}, _opts) do
    heading_3
    |> render_rich_text()
    |> then(&"<h5>#{&1}</h5>")
  end

  def render_block(
        %Block{
          object: "block",
          type: "numbered_list_item",
          numbered_list_item: numbered_list_item,
          numbered_list_item_number: numbered_list_item_number
        },
        _opts
      ) do
    numbered_list_item
    |> render_rich_text()
    # TODO: Wrap within <li> and let HTML generate the numbers
    |> then(&"#{numbered_list_item_number}. #{&1}")
    |> then(&"<ol>#{&1}</ol>")
  end

  def render_block(
        %Block{
          object: "block",
          type: "bulleted_list_item",
          bulleted_list_item: bullet_list_item
        },
        _opts
      ) do
    bullet_list_item
    |> render_rich_text()
    |> then(&"<ul><li>#{&1}</li></ul>")
  end

  def render_block(%Block{object: "block", type: "code", code: code}, _opts) do
    code
    |> render_rich_text()
    |> then(&"<pre><code>#{&1}</code></pre>")
  end

  # TODO: Handle caption
  def render_block(%Block{object: "block", type: "video", video: video}, _opts) do
    case Map.get(video, "type") do
      "external" ->
        video
        |> get_in(["external", "url"])
        |> then(&"<iframe src=\"#{&1}\" frameborder=\"0\" allowfullscreen></iframe>")

      _ ->
        raise "Block type video, not implemented type: #{Map.get(video, "type")}"
    end
  end

  # TODO: Handle caption
  def render_block(%Block{object: "block", type: "image", image: image}, _opts) do
    case Map.get(image, "type") do
      "external" ->
        image
        |> get_in(["external", "url"])
        |> then(&"<img src=\"#{&1}\" />")

      "file" ->
        image
        |> get_in(["file", "url"])
        |> then(&"<img src=\"#{&1}\" />")

      _ ->
        raise "Block type image, not implemented type: #{Map.get(image, "type")}"
    end
  end

  # TODO: Handle caption
  def render_block(%Block{object: "block", type: "bookmark", bookmark: bookmark}, _opts) do
    bookmark
    |> Map.get("url")
    |> then(&"<a href=\"#{&1}\">#{&1}</a>")
  end

  def render_block(%Block{object: "block", type: "quote", quote: blockquote}, _opts) do
    blockquote
    |> render_rich_text()
    |> then(&"<blockquote>#{&1}</blockquote>")
  end

  def render_block(%Block{object: "block", type: "embed", embed: embed}, _opts) do
    "<iframe width=\"560px\" height=\"315px\" src=\"#{Map.get(embed, "url")}\" frameborder=\"0\" allowfullscreen></iframe>"
  end

  # Not sure what to do with these
  def render_block(%Block{object: "block", type: "column"}, _opts) do
    ""
  end

  def render_block(%Block{object: "block", type: "column_list"}, _opts) do
    ""
  end

  def render_block(%Block{object: "block", type: type}, _opts) do
    raise "Block type not implemented: #{type}"
  end

  @impl true
  def render_rich_text(%{"rich_text" => rich_text}) do
    render_rich_text(rich_text)
  end

  def render_rich_text(rich_text_list) when is_list(rich_text_list) do
    rich_text_list
    |> Enum.map(&render_rich_text/1)
    |> Enum.join("")
  end

  def render_rich_text(%{"type" => "text"} = rich_text) do
    rich_text
    |> apply_annotations()
  end

  def render_rich_text(_, _), do: ""

  def apply_annotations(%{"type" => "text", "annotations" => annotations} = rich_text) do
    content = get_in(rich_text, ["text", "content"])

    annotations
    |> Enum.reduce(content, fn
      {"bold", true}, acc -> "<strong>#{acc}</strong>"
      {"italic", true}, acc -> "<em>#{acc}</em>"
      {"strikethrough", true}, acc -> "<del>#{acc}</del>"
      {"underline", true}, acc -> "<u>#{acc}</u>"
      {"code", true}, acc -> "<code>#{acc}</code>"
      {"color", "default"}, acc -> acc
      _, acc -> acc
    end)
  end

  ## Helpers

  # Current and prev blocks are numbered_list_items
  def update_numbered_list_item_number(
        %Block{type: "numbered_list_item"} = block,
        %Block{
          type: "numbered_list_item",
          numbered_list_item_number: numbered_list_item_number
        } = _prev_block
      ) do
    block
    |> Map.put(:numbered_list_item_number, numbered_list_item_number + 1)
  end

  # Current block is numbered_list_item, prev block is not
  def update_numbered_list_item_number(
        %Block{type: "numbered_list_item"} = block,
        _prev_block
      ) do
    block
    |> Map.put(:numbered_list_item_number, 1)
  end

  def update_numbered_list_item_number(block, _prev_block), do: block
end