lib/memorex/parser.ex

defmodule Memorex.Parser do
  @moduledoc """
  Parses Memorex Markdown `Memorex.Domain.Deck` files.  The `Memorex.Parser` is invoked from the mix task
  `memorex.read_notes`.  This mix task invokes `read_note_dirs/1` which is the main entry point to
  `Memorex.Parser`; the rest of the funtions are implementation details (which are public simply so they can be
  tested in isolation).
  """

  alias Memorex.{Cards, Decks, Notes}
  alias Memorex.Ecto.Repo
  alias Memorex.Domain.{Deck, Note}

  @image_file_types ~w{gif jpg jpeg png svg webp}

  @doc """
  Reads in notes from directories, and in the process creates cards.  Takes an array of directory names;
  e.g., `["/note_dir1", "/note_dir2"]`, etc.

  The existing notes in the database are marked with a flag prior to reading (flag `:in_latest_parse?` on
  `Memorex.Domain.Note`is set to false), and as the notes are read, existing notes have this flag toggled to true. New
  notes also have this flag set to `true`.  At the end, notes in the database which have not had their flag set to `true`
  are expunged.  These are notes which have either been deleted in the local filesystem content, or else their content
  has been modified (so they have been read in again as a new note; i.e., their drilling info is lost in the process).

  Note that this is the primary (and only) external API function to `Memorex.Parser`; the other functions are public
  solely for purposes of testing.
  """
  @spec read_note_dirs([String.t()] | nil) :: {non_neg_integer(), nil | [any()]}
  def read_note_dirs(note_dirs \\ Application.get_env(:memorex, Memorex.Note)[:note_dirs]) do
    Notes.clear_parse_flags()

    note_dirs
    |> Enum.each(fn dir ->
      {:ok, files_and_dirs} = File.ls(dir)

      files_and_dirs
      |> Enum.map(fn file_or_dir ->
        if does_not_start_with_dot(file_or_dir) do
          pathname = Path.join(dir, file_or_dir)
          {:ok, file_stat} = File.stat(pathname)

          case file_stat.type do
            :regular ->
              if Path.extname(file_or_dir) == ".md" do
                deck = Decks.find_or_create!(Path.rootname(file_or_dir))
                config_filename = Path.rootname(pathname) <> ".deck_config.toml"
                opts = load_config_file_if_it_exists(deck, config_filename)
                read_notes_file(pathname, opts)
              end

            :directory ->
              read_dir(pathname)
          end
        end
      end)
    end)

    Notes.delete_notes_without_flag_set()
  end

  # --------------------------------------------------------------------------------------------------------------------
  # "private" functions below here (public for testing purposes)

  @spec read_notes_file(String.t(), Keyword.t()) :: :ok
  def read_notes_file(filename, opts \\ default_opts()) do
    filename
    |> File.read!()
    |> parse_file_contents(opts)
  end

  @spec read_dir(String.t(), Keyword.t()) :: :ok
  def read_dir(dirname, opts \\ []) do
    opts =
      if Keyword.has_key?(opts, :deck) do
        opts |> Keyword.update!(:category, &(&1 ++ [Path.basename(dirname)]))
      else
        dirname
        |> Path.basename()
        |> Decks.find_or_create!()
        |> load_config_file_if_it_exists(Path.join(dirname, "deck_config.toml"))
        |> Keyword.merge(category: [])
      end

    Path.wildcard(dirname <> "/*.md")
    |> Enum.each(fn filename ->
      category = Path.basename(filename, ".md")
      opts = opts |> Keyword.update!(:category, &(&1 ++ [category]))
      read_notes_file(filename, opts)
    end)

    Path.wildcard(dirname <> "/*.{#{@image_file_types |> Enum.join(",")}}")
    |> Enum.each(fn filename ->
      read_image_note(filename, opts)
    end)

    File.ls!(dirname)
    |> Enum.map(&Path.join(dirname, &1))
    |> Enum.filter(&File.dir?/1)
    |> Enum.each(&read_dir(&1, opts))
  end

  @spec read_image_note(String.t(), Keyword.t()) :: nil | Ecto.Schema.t()
  def read_image_note(filename, opts \\ default_opts()) do
    text_filename = Path.rootname(filename) <> ".txt"

    if File.exists?(text_filename) do
      image_file_contents = File.read!(filename)
      text_file_contents = File.read!(text_filename)
      deck_name = opts[:deck].name
      image_file_path = "/images/decks/#{deck_name}/#{Path.basename(filename)}"
      symlink = "priv/static#{image_file_path}"

      File.mkdir_p!("priv/static/images/decks/#{deck_name}")
      File.rm(symlink)
      File.ln_s!(Path.absname(filename), symlink)

      new_opts = [
        image_file_contents: image_file_contents,
        image_file_path: image_file_path,
        content: [text_file_contents],
        bidirectional?: false
      ]

      opts |> Keyword.merge(new_opts) |> Note.new() |> create_or_update_note()
    end
  end

  @spec parse_file_contents(String.t(), Keyword.t()) :: :ok
  def parse_file_contents(contents, opts) do
    contents
    |> String.split("\n")
    |> Enum.each(fn line ->
      if is_note_line?(line, opts) do
        line
        |> parse_line(opts)
        |> create_or_update_note()
      end
    end)
  end

  @spec load_config_file_if_it_exists(Deck.t(), String.t()) :: Keyword.t()
  defp load_config_file_if_it_exists(deck, config_filename) do
    if File.exists?(config_filename) do
      config_file = read_toml_deck_config(config_filename)
      {uni_delimitter, config_file} = Map.pop(config_file, "unidirectional_note_delimitter", unidirectional_note_delimitter())
      {bi_delimitter, config_file} = Map.pop(config_file, "bidirectional_note_delimitter", bidirectional_note_delimitter())
      deck = Decks.update_config(deck, config_file)

      [deck: deck, unidirectional_note_delimitter: uni_delimitter, bidirectional_note_delimitter: bi_delimitter]
    else
      [deck: deck] |> Keyword.merge(default_opts())
    end
  end

  # @spec create_or_update_note(Note.t()) :: Note.t()
  @spec create_or_update_note(Note.t()) :: Ecto.Schema.t()
  def create_or_update_note(note) do
    existing_note_in_db = Repo.get(Note, note.id)

    if existing_note_in_db do
      existing_note_in_db |> Notes.set_parse_flag()
    else
      note
      |> Repo.insert!(on_conflict: :nothing)
      |> Cards.create_from_note()
    end
  end

  @spec is_note_line?(String.t(), Keyword.t()) :: boolean()
  def is_note_line?(line, opts), do: String.match?(line, note_regex(opts))

  @spec is_bidirectional_note?(String.t(), Keyword.t()) :: boolean()
  def is_bidirectional_note?(line, opts) do
    bidirectional_note_delimitter = Keyword.get(opts, :bidirectional_note_delimitter)
    String.match?(line, ~r/#{bidirectional_note_delimitter}/)
  end

  @spec parse_line(String.t(), Keyword.t()) :: Note.t()
  def parse_line(line, opts) do
    content = line |> String.split(note_regex(opts)) |> Enum.map(&String.trim(&1))

    opts
    |> Keyword.merge(
      content: content,
      bidirectional?: is_bidirectional_note?(line, opts)
    )
    |> Note.new()
  end

  @spec read_toml_deck_config(String.t()) :: map()
  def read_toml_deck_config(filename) do
    filename |> File.read!() |> Toml.decode() |> elem(1)
  end

  @spec does_not_start_with_dot(String.t()) :: boolean()
  defp does_not_start_with_dot(file_or_dir), do: !String.starts_with?(file_or_dir, ".")

  @spec note_regex(Keyword.t()) :: Regex.t()
  defp note_regex(opts) do
    bidirectional_note_delimitter = Keyword.get(opts, :bidirectional_note_delimitter)
    unidirectional_note_delimitter = Keyword.get(opts, :unidirectional_note_delimitter)
    ~r/#{bidirectional_note_delimitter}|#{unidirectional_note_delimitter}/
  end

  @spec default_opts() :: Keyword.t()
  def default_opts() do
    [bidirectional_note_delimitter: bidirectional_note_delimitter(), unidirectional_note_delimitter: unidirectional_note_delimitter()]
  end

  @spec bidirectional_note_delimitter() :: String.t()
  defp bidirectional_note_delimitter, do: Application.get_env(:memorex, Memorex.Note)[:bidirectional_note_delimitter]

  @spec unidirectional_note_delimitter() :: String.t()
  defp unidirectional_note_delimitter, do: Application.get_env(:memorex, Memorex.Note)[:unidirectional_note_delimitter]
end