defmodule Trunk.State do
@moduledoc """
This module defines a `Trunk.State` struct and provides some helper functions for working with that state.
## Fields
The following fields are available in the state object. Some values are filled in during processing.
- `filename` - The base filename of the file being processed. (e.g. `"photo.jpg"`)
- `rootname` - The root of the filen being processed. (e.g. `"photo"`)
- `extname` - The file extension of the file being processed. (e.g. `".jpg"`)
- `lower_extname` - The file extension of the file being processed forced to lower case (e.g. `"*.jpg"`, even if the file is `"PHOTO.JPG"`)
- `path` - The full path to the file being processed. If the file was passed in as a binary, it is a path to the temporary file created with that binary.
- `versions` - A map of the versions and their respective `Trunk.VersionState` (e.g. `%{original: %Trunk.VersionState{}, thumbnail: %Trunk.VersionState{}}`)
- `scope` - A user struct/map passed in useful for determining storage locations and file naming.
- `async` - A boolean indicator of whether processing will be done in parallel.
- `timeout` - The timeout after which each processing process will be terminated. (Only applies with `async: true`)
- `storage` - The module to use for storage processing. (e.g. `Trunk.Storage.Filesystem` or `Trunk.Storage.S3`)
- `storage_opts` - A keyword list of options for the `storage` module
- `errors` - a place to record errors encountered during processing. (`nli` if no errors, otherwise a map of errors)
- `opts` - All the options merged together (see Options in `Trunk` module documentation).
- `assigns` - shared user data as a map (Same as assigns in `Plug.Conn`)
"""
alias Trunk.VersionState
defstruct module: nil,
path: nil,
filename: nil,
rootname: nil,
extname: nil,
lower_extname: nil,
versions: %{},
scope: %{},
async: true,
timeout: 5_000,
storage: nil,
storage_opts: [],
errors: nil,
opts: [],
assigns: %{}
@type opts :: Keyword.t()
@type t :: %__MODULE__{
module: atom,
opts: opts,
filename: String.t(),
rootname: String.t(),
extname: String.t(),
lower_extname: String.t(),
path: String.t(),
versions: map,
async: boolean,
timeout: integer,
scope: map | struct,
storage: atom,
storage_opts: Keyword.t(),
errors: Keyword.t(),
assigns: map
}
def init(%{} = info, scope, opts) do
state = restore(info, opts)
rootname = Path.rootname(state.filename)
extname = Path.extname(state.filename)
%{
state
| extname: extname,
lower_extname: String.downcase(extname),
rootname: rootname,
timeout: Keyword.fetch!(opts, :timeout),
async: Keyword.fetch!(opts, :async),
storage: Keyword.fetch!(opts, :storage),
storage_opts: Keyword.fetch!(opts, :storage_opts),
scope: scope,
opts: opts
}
end
@doc ~S"""
Puts an error into the error map.
## Example:
```
iex> state.errors
nil
iex> state = Trunk.State.put_error(state, :thumb, :transform, "Error with convert blah blah")
iex> state.errors
%{thumb: [transform: "Error with convert blah blah"]}
```
"""
def put_error(%__MODULE__{errors: errors} = state, version, stage, error),
do: %{
state
| errors: Map.update(errors || %{}, version, [{stage, error}], &[{stage, error} | &1])
}
@doc ~S"""
Assigns a value to a key on the state.
## Example:
```
iex> state.assigns[:hello]
nil
iex> state = Trunk.State.assign(state, :hello, :world)
iex> state.assigns[:hello]
:world
```
"""
@spec assign(state :: Trunk.State.t(), key :: any, value :: any) :: map
def assign(%{assigns: assigns} = state, key, value),
do: %{state | assigns: Map.put(assigns, key, value)}
@doc ~S"""
Retrieves an assign value for a specific version.
## Example:
```
iex> state = %Trunk.State{versions: %{thumbnail: %Trunk.VersionState{assigns: %{hello: :world}}}}
iex> %Trunk.State.get_version_assign(state, :thumbnail, :hello)
:world
iex> %Trunk.State.get_version_assign(state, :thumbnail, :unknown)
nil
```
"""
@type version :: atom
@spec get_version_assign(state :: Trunk.State.t(), version, assign :: atom) :: any | nil
def get_version_assign(%{versions: versions}, version, assign) do
case versions[version] do
%{assigns: %{^assign => value}} -> value
_ -> nil
end
end
@doc ~S"""
Extracts the data needed from the state in order to reconstruct the file paths in future.
Options:
- `:as` - How to save the state.
- `:string` - Default, will just save the file name. An error will be raised if there are any assigns unless `:ignore_assigns` is set to tru
- `:map` - will save a map with keys `:filename`, `:assigns`, and `:version_assigns`
- `:json` - will save a map encoded as JSON (Requires Poison library to be included in deps)
- `:ignore_assigns` boolean, default false. Use this to save as string and ignore any assigns (Make sure you’re not using assigns for `c:Trunk.storage_dir/2` or `c:Trunk.filename/2`)
- `:assigns` - a list of keys to save from the assigns hashes
## Example:
```
iex> Trunk.State.save(%Trunk.State{filename: "photo.jpg"})
"photo.jpg"
iex> Trunk.State.save(%Trunk.State{filename: "photo.jpg", assigns: %{hash: "abcdef"}}, as: :map)
%{filename: "photo.jpg", assigns: %{hash: "abcdef"}}
iex> Trunk.State.save(%Trunk.State{filename: "photo.jpg", assigns: %{hash: "abcdef", file_size: 12345}}, as: :map, assigns: [:hash])
%{filename: "photo.jpg", assigns: %{hash: "abcdef"}}
iex> Trunk.State.save(%Trunk.State{filename: "photo.jpg", assigns: %{hash: "abcdef"}}, as: :json)
"{\"filename\": \"photo.jpg\", \"assigns\": {\"hash\": \"abcdef\"}}"
```
"""
@type assign_keys :: [atom]
@type save_opts :: [assigns: :all | assign_keys]
@spec save(Trunk.State.t()) :: String.t()
@spec save(Trunk.State.t(), [{:as, :string} | save_opts]) :: String.t()
@spec save(Trunk.State.t(), [{:as, :json} | save_opts]) :: String.t()
@spec save(Trunk.State.t(), [{:as, :map} | save_opts]) :: map
def save(state, opts \\ []) do
save_as = Keyword.get(opts, :as, :string)
save_as(state, save_as, opts)
end
defp save_as(%{filename: filename} = state, :string, opts) do
unless Keyword.get(opts, :ignore_assigns, false), do: assert_no_assigns(state)
filename
end
defp(save_as(state, :json, opts), do: state |> save_as(:map, opts) |> json_parser().encode!())
defp save_as(%{filename: filename, assigns: assigns, versions: versions}, :map, opts) do
assigns_to_save = Keyword.get(opts, :assigns, :all)
%{filename: filename}
|> save_assigns(assigns, assigns_to_save)
|> save_version_assigns(versions, assigns_to_save)
end
defp assert_no_assigns(%{assigns: assigns}) when assigns != %{},
do: raise(ArgumentError, message: "Cannot save state as string with non-empty assigns hash")
defp assert_no_assigns(%{versions: versions}),
do: Enum.each(versions, fn {_version, state} -> assert_no_assigns(state) end)
defp assert_no_assigns(%{}), do: nil
defp save_assigns(map, assigns, _keys) when assigns == %{}, do: map
defp save_assigns(map, assigns, :all), do: Map.put(map, :assigns, assigns)
defp save_assigns(map, assigns, keys), do: Map.put(map, :assigns, Map.take(assigns, keys))
defp save_version_assigns(map, versions, keys) do
version_assigns =
versions
|> Enum.map(fn
{version, %{assigns: assigns}} when assigns == %{} ->
{version, nil}
{version, %{assigns: assigns}} ->
{version, if(keys == :all, do: assigns, else: Map.take(assigns, keys))}
end)
|> Enum.filter(fn {_version, value} -> value end)
|> Map.new()
if Enum.empty?(version_assigns),
do: map,
else: Map.put(map, :version_assigns, version_assigns)
end
@doc ~S"""
Restore a saved state from a filename, JSON, or a map
## Example:
```
iex> Trunk.State.restore("photo.jpg")
%Trunk.State{filename: "photo.jpg"}
iex> Trunk.State.restore(%{filename: "photo.jpg", assigns: %{hash: "abcdef"}}
%Trunk.State{filename: "photo.jpg", assigns: %{hash: "abcdef"}}
iex> Trunk.State.restore(%{"filename" => "photo.jpg", "assigns" => %{"hash" => "abcdef"}}
%Trunk.State{filename: "photo.jpg", assigns: %{hash: "abcdef"}}
iex> Trunk.State.restore("{\"filename\": \"photo.jpg\", \"assigns\": {\"hash\": \"abcdef\"}}")
%Trunk.State{filename: "photo.jpg", assigns: %{hash: "abcdef"}}
```
"""
@type file_info :: String.t() | map
@spec restore(file_info, opts) :: Trunk.State.t()
def restore(file_info, opts \\ [])
def restore(<<"{", _rest::binary>> = json, opts) do
{:ok, map} = json_parser().decode(json)
map
|> keys_to_atom
|> restore(opts)
end
def restore(<<filename::binary>>, opts), do: restore(%{filename: filename}, opts)
def restore(%{} = info, opts) do
info = keys_to_atom(info)
state = struct(__MODULE__, info)
version_assigns = info[:version_assigns] || %{}
versions =
opts
|> Keyword.fetch!(:versions)
|> Enum.map(fn version ->
assigns = version_assigns[version] || %{}
{version, %VersionState{assigns: assigns}}
end)
|> Map.new()
%{state | versions: versions}
end
defp keys_to_atom(%{} = map) do
map
|> Enum.map(fn {key, value} ->
try do
{String.to_existing_atom(key), keys_to_atom(value)}
rescue
ArgumentError ->
{key, keys_to_atom(value)}
end
end)
|> Map.new()
end
defp keys_to_atom(arg), do: arg
defp json_parser do
cond do
Code.ensure_loaded?(Jason) ->
Jason
Code.ensure_loaded?(Poison) ->
Poison
raise RuntimeError,
"You must have a JSON parser loaded (Jason and Poison are supported)"
end
end
end