lib/bow.ex

defmodule Bow do
  @moduledoc """
  Bow - the file uploader

  ## Global Configuration

      config :bow,
        storage: Bow.Storage.Local,             # storage adapter; Bow.Storage.Local or Bow.Storage.S3
        storage_prefix: "priv/static/uploads",  # storage directory prefix

        store_timeout:  30_000,                 # single version upload timeout
        exec_timeout:   15_000,                 # single command execution timeout
  """

  def storage, do: Application.get_env(:bow, :storage, Bow.Storage.Local)
  def store_timeout, do: Application.get_env(:bow, :store_timeout, 30_000)
  def version_timeout, do: Application.get_env(:bow, :version_timeout, 60_000)

  @type t :: %__MODULE__{
          # "cat.jpg",  "README"
          name: String.t(),
          # "cat",      "README"
          rootname: String.t(),
          # ".jpg",     ""
          ext: String.t(),
          path: String.t() | nil,
          scope: any,
          uploader: atom
        }

  @typep opts :: keyword

  @enforce_keys [:name, :rootname, :ext]
  defstruct name: "",
            rootname: "",
            ext: nil,
            path: nil,
            scope: nil,
            uploader: nil

  defmodule Error do
    defexception message: ""
  end

  @doc """
  Process & store given file with its uploader
  """
  @spec store(t, opts) :: {:ok, map} | {:error, map}
  def store(file, opts \\ []) do
    uploader = file.uploader
    versions = uploader.versions(file)

    make(uploader, file, file, versions, opts) |> combine_results
  end

  @doc """
  Load given file
  """
  @spec load(t, opts) :: {:ok, t} | {:error, any}
  def load(file, opts \\ []) do
    with {:ok, path} <- load_file(file.uploader, file, opts) do
      {:ok, %{file | path: path}}
    end
  end

  @doc """
  Delete all versions of given file
  """
  @spec delete(t, opts) :: :ok
  def delete(file, opts \\ []) do
    uploader = file.uploader

    for version <- uploader.versions(file) do
      name = uploader.filename(file, version)
      delete_file(uploader, set(file, :name, name), opts)
    end

    :ok
  end

  @doc """
  Regenerate file using different uploader
  """
  @spec regenerate(t) :: {:ok, map} | {:error, any}
  def regenerate(file) do
    with {:ok, file} <- load(file), do: store(file)
  end

  @doc """
  Copy file
  """
  @spec copy(src :: t, dst :: t, opts) :: {:ok, map} | {:error, any}
  def copy(src, dst, opts \\ []) do
    if src.uploader == dst.uploader do
      uploader = src.uploader

      src
      |> uploader.versions()
      |> Enum.map(fn version ->
        src_file = set(src, :name, uploader.filename(src, version))
        dst_file = set(dst, :name, uploader.filename(dst, version))

        {version, copy_file(uploader, src_file, dst_file, opts)}
      end)
      |> combine_results()
    else
      {:error, :uploader_mismatch}
    end
  end

  defp make(up, f0, fx, versions, opts) when is_list(versions) do
    versions
    |> Enum.map(&Task.async(fn -> make(up, f0, fx, &1, opts) end))
    |> Enum.map(&Task.await(&1, version_timeout()))
    |> List.flatten()
  end

  defp make(up, f0, fx, version, opts) do
    fy =
      fx
      |> set(:name, up.filename(f0, version))
      |> set(:path, nil)

    case transform(up, fx, fy, version) do
      {:ok, fy, next_versions} ->
        res0 = Task.async(fn -> store_file(up, fy, opts) end)
        res1 = make(up, f0, fy, next_versions, opts)
        [{version, Task.await(res0, store_timeout())} | res1]

      {:ok, fy} ->
        [{version, store_file(up, fy, opts)}]

      :ok ->
        [{version, {:ok, :no_store}}]

      {:error, reason} ->
        [{version, {:error, reason}}]
    end
  end

  defp transform(up, fx, fy, version) do
    up.transform(fx, fy, version)
  rescue
    ex -> {:error, ex}
  end

  defp store_file(uploader, file, opts) do
    storage().store(
      file.path,
      uploader.store_dir(file),
      file.name,
      opts ++ uploader.store_options(file)
    )
  end

  defp load_file(uploader, file, opts) do
    storage().load(
      uploader.store_dir(file),
      file.name,
      opts
    )
  end

  defp delete_file(uploader, file, opts) do
    storage().delete(
      uploader.store_dir(file),
      file.name,
      opts
    )
  end

  defp copy_file(uploader, src, dst, opts) do
    storage().copy(
      uploader.store_dir(src),
      src.name,
      uploader.store_dir(dst),
      dst.name,
      opts ++ uploader.store_options(dst)
    )
  end

  @spec url(t | nil) :: String.t() | nil

  def url(file), do: url(file, [])

  @spec url(t | nil, atom | list) :: String.t() | nil
  def url(file, opts) when is_list(opts), do: url(file, :original, opts)
  def url(file, version), do: url(file, version, [])

  @spec url(t | nil, atom, list) :: String.t() | nil
  def url(nil, _version, _opts), do: nil

  def url(file, version, opts) do
    assets_host = file.uploader.assets_host()

    opts = opts |> Keyword.put(:assets_host, assets_host)

    storage().url(
      file.uploader.store_dir(file),
      file.uploader.filename(file, version),
      opts
    )
  end

  @spec new(keyword) :: t
  def new(args) do
    {name, path} =
      case {args[:name], args[:path]} do
        {nil, nil} ->
          raise Error, message: "Missing :name or :path attributes when creating new Bow file"

        {nil, path} ->
          {basename(path), path}

        {name, path} ->
          {name, path}
      end

    args =
      Keyword.merge(args,
        path: path,
        name: name,
        rootname: rootname(name),
        ext: extname(name)
      )

    struct!(__MODULE__, args)
  end

  defp basename(name), do: name |> Path.basename()
  defp rootname(name), do: name |> Path.rootname()
  defp extname(name), do: name |> Path.extname() |> String.downcase()

  @spec set(t, atom, any) :: t
  def set(file, :name, name),
    do: %{file | name: name, rootname: rootname(name), ext: extname(name)}

  def set(file, :rootname, rootname), do: %{file | name: rootname <> file.ext, rootname: rootname}
  def set(file, :ext, ext), do: %{file | name: file.rootname <> ext, ext: ext}
  def set(file, key, value), do: struct(file, [{key, value}])

  @doc false
  @spec combine_results(list) :: {:ok, map} | {:error, map}
  def combine_results(results) do
    Enum.reduce(results, {:ok, %{}}, fn
      {key, {:error, reason}}, {_, map} ->
        {:error, Map.put(map, key, {:error, reason})}

      {key, value}, {ok, map} ->
        {ok, Map.put(map, key, value)}
    end)
  end
end