lib/anki_connect/services/add_notes_from_file.ex

defmodule AnkiConnect.Services.AddNotesFromFile do
  @moduledoc """
  Provides functionality to upload notes from a given text file to Anki.
  """

  import AnkiConnect, only: [add_notes: 1]
  import AnkiConnect.Utils.MapUtils

  @default_model_name "Basic"
  @default_regex "-\s+(.*)\s+-\s+(.*)"

  @type note :: {String.t(), String.t()}

  @doc """
  Uploads notes from a given text file to Anki.

  ## Parameters
  * `file` - path to the file with notes
  * `deck` - name of the deck to which the notes should be added
  * `model` [optional] - name of the model to which the notes should be added, by default `Basic`
  * `regex` [optional] - regex used to parse the file

  Default regex is `-\\s+(.*)\\s+-\\s+(.*)` which means that the file should contain lines in the following format:
  ```
  - back - front
  ```
  where `back` and `front` are the back and front of the note respectively.

  ## Example
  File content:
  ```
  - subsidiary - filia
  - pageant - widowisko
  - indispensable - niezbędny
  - out-of-the-way - odległy
  ```

  ```bash
  > mix anki_connect add_notes_from_file --file="words.md" --deck="TEST DECK"
  [1684955655336, 1684955655337, 1684955655338, 1684955655339]
  ```
  """
  @spec add_notes_from_file(%{
          file: String.t(),
          deck: String.t(),
          model: String.t() | nil,
          regex: String.t() | nil
        }) :: {:ok, nil} | {:error, String.t()}
  def add_notes_from_file(%{file: filename, deck: deck} = param) do
    model = Map.get(param, :model, @default_model_name)
    regex = Map.get(param, :regex, @default_regex)
    options = Map.get(param, :options)

    compiled_regex = Regex.compile!(regex)

    notes =
      File.stream!(filename)
      |> Stream.map(&parse_line(&1, compiled_regex))
      |> Enum.to_list()

    duplicates = find_duplicates(notes)

    if length(duplicates) > 0 do
      Enum.each(duplicates, fn {back, duplicates} ->
        IO.puts("Duplicated notes: #{back} -> #{inspect(duplicates)}")
      end)

      {:error, "Duplicated notes found"}
    else
      formatted_notes =
        Enum.map(notes, fn {back, front} ->
          %{
            deck_name: deck,
            model_name: model,
            fields: %{Back: back, Front: front}
          }
          |> maybe_add_field(:options, options)
        end)

      add_notes(%{
        notes: formatted_notes
      })
    end
  end

  @spec parse_line(String.t(), Regex.t()) :: note()
  defp parse_line(line, compiled_regex) do
    line
    |> String.trim()
    |> then(&Regex.run(compiled_regex, &1))
    |> then(fn [_, back, front] -> {back, front} end)
  end

  @spec find_duplicates([note()]) :: [{String.t(), [String.t()]}]
  defp find_duplicates(notes) do
    notes
    |> Enum.group_by(fn {back, _} -> back end)
    |> Enum.filter(fn {_, notes} -> length(notes) > 1 end)
    |> Enum.map(fn {back, notes} -> {back, Enum.map(notes, fn {_, front} -> front end)} end)
  end
end