lib/files.ex

defmodule Uploadex.Files do
  @moduledoc """
  Functions to store and delete files.

  Note that all functions in this module require the Uploader as an argument. You are free to call them like that:

      iex> Uploadex.Files.store_files(user, MyUploader)
      {:ok, %User{}}

  However, by doing `use Uploadex` in your uploader, you can call these functions directly through the uploader to avoid having to pass this
  extra argument around:

      iex> MyUploader.store_files(user)
      {:ok, %User{}}
  """

  @type record :: any()
  @type record_field :: atom()
  @type status :: :ok | :error

  alias Uploadex.{
    Validation,
    Uploader,
  }

  @doc """
  Stores all files of a record, as defined by the uploader.

  Files that are not maps are ignored, which allows for assigning an existing file to a record without recreating it, by simply passing it's filename.
  """
  @spec store_files(record, Uploader.t) :: {:ok, record} | {:error, any()}
  def store_files(record, uploader) do
    files = wrap_files(record, uploader)
    validate_and_store_files(record, files, uploader)
  end

  @spec store_files(record, record, Uploader.t) :: {:ok, record} | {:error, any()}
  def store_files(record, previous_record, uploader) do
    current_files = wrap_files(record, uploader)
    previous_files = wrap_files(previous_record, uploader)

    new_changed_files = get_new_files(current_files, previous_files)

    validate_and_store_files(record, new_changed_files, uploader)
  end

  defp validate_and_store_files(record, changed_files, uploader) do
    extensions = get_accepted_extensions(record, uploader)

    case Validation.validate_extensions(changed_files, extensions) do
      :ok ->
        changed_files
        |> Enum.filter(fn {file, _, _} -> is_map(file) end)
        |> do_store_files(record)

      error -> error
    end
  end

  # Recursively stores all files, stopping if one operation fails.
  defp do_store_files([{file, _field, {storage, opts}} | remaining_files], record) do
    case apply(storage, :store, [file, opts]) do
      :ok -> do_store_files(remaining_files, record)
      {:error, error} -> {:error, error}
    end
  end

  defp do_store_files([], record) do
    {:ok, record}
  end

  @doc """
  Deletes all files that changed.
  """
  @spec delete_previous_files(record, record, Uploader.t) :: {:ok, record} | {:error, any()}
  def delete_previous_files(new_record, previous_record, uploader) do
    new_files = wrap_files(new_record, uploader)
    old_files = wrap_files(previous_record, uploader)

    new_files
    |> get_discarded_files(old_files)
    |> do_delete_files(new_record)
  end

  @doc """
  Deletes all files for a record.
  """
  @spec delete_files(record, Uploader.t) :: {:ok, record} | {:error, any()}
  def delete_files(record, uploader) do
    record
    |> wrap_files(uploader)
    |> do_delete_files(record)
  end

  defp do_delete_files(files, record) do
    Enum.each(files, fn {file, _field, {storage, opts}} -> apply(storage, :delete, [file, opts]) end)
    {:ok, record}
  end

  # Returns all old files that are not in new files.
  defp get_discarded_files(new_files, old_files), do: old_files -- new_files

  # Returns all new files that are not in old files.
  defp get_new_files(new_files, old_files), do: new_files -- old_files

  @spec get_file_url(record, String.t, record_field, Uploader.t) :: {status, String.t | nil}
  def get_file_url(record, file, field, uploader) do
    {status, result} = get_files_url(record, file, field, uploader)

    {status, List.first(result)}
  end

  @spec get_files_url(record, record_field, Uploader.t) :: {status, [String.t]}
  def get_files_url(record, field, uploader) do
    get_files_url(record, wrap_files(record, uploader, field), field, uploader)
  end

  @spec get_files_url(record, String.t | [String.t], record_field, Uploader.t) :: {status, [String.t]}
  def get_files_url(record, files, field, uploader) do
    files
    |> List.wrap()
    |> Enum.map(fn
      %{filename: _filename} = file ->
        {storage, opts} = get_storage_opts(record, field, uploader)
        apply(storage, :get_url, [file, opts])

      {file, _field, {storage, opts}} ->
        apply(storage, :get_url, [file, opts])
    end)
    |> Enum.group_by(& elem(&1, 0), & elem(&1, 1))
    |> case do
      %{error: errors} -> {:error, errors}
      %{ok: urls} -> {:ok, urls}
      %{} -> {:ok, []}
    end
  end

  @spec get_temporary_file(record, String.t, String.t, record_field, Uploader.t) :: String.t | nil | {:error, String.t}
  def get_temporary_file(record, file, path, field, uploader) do
    record
    |> get_temporary_files(file, path, field, uploader)
    |> List.first()
  end

  @spec get_temporary_files(record, String.t, record_field, Uploader.t) :: [String.t]
  def get_temporary_files(record, path, field, uploader) do
    get_temporary_files(record, wrap_files(record, uploader), path, field, uploader)
  end

  @spec get_temporary_files(record, String.t | [String.t], String.t, record_field, Uploader.t) :: [String.t]
  def get_temporary_files(record, files, path, field, uploader) do
    files
    |> List.wrap()
    |> Enum.map(fn
      %{filename: _filename} = file ->
        {storage, opts} = get_storage_opts(record, field, uploader)
        apply(storage, :get_temporary_file, [file, path, opts])

      {file, _field, {storage, opts}} ->
        apply(storage, :get_temporary_file, [file, path, opts])
    end)
  end

  # Get storage opts considering default values
  defp get_storage_opts(record, field, uploader) do
    {storage, opts} = uploader.storage(record, field)
    default_opts = uploader.default_opts(storage)

    {storage, Keyword.merge(default_opts, opts)}
  end

  # Wraps the user defined `get_fields` function to always return a list
  defp wrap_files(record, uploader, field \\ nil) do
    field
    |> Kernel.||(uploader.get_fields(record))
    |> List.wrap()
    |> Enum.map(fn field ->
      case Map.get(record, field) do
        result when is_list(result) -> Enum.map(result, & ({&1, field, get_storage_opts(record, field, uploader)}))
        result when is_map(result) -> {result, field, get_storage_opts(record, field, uploader)}
        result when is_binary(result) -> {result, field, get_storage_opts(record, field, uploader)}
        nil -> nil
      end
    end)
    |> List.flatten()
    |> Enum.reject(&is_nil/1)
  end

  defp get_accepted_extensions(record, uploader) do
    case function_exported?(uploader, :accepted_extensions, 2) do
      true ->
        record
        |> uploader.get_fields()
        |> List.wrap()
        |> Enum.into(%{}, fn field -> {field, uploader.accepted_extensions(record, field)} end)

      false ->
        :any
    end
  end
end