chore: added more @spec

This commit is contained in:
2026-05-01 17:49:50 +02:00
parent abcae1dad7
commit 881056eb61
157 changed files with 6223 additions and 1647 deletions

View File

@@ -4,20 +4,27 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
@spec change_definition(term(), term(), term()) :: term()
def change_definition(socket, params, reload) do
with %{id: definition_id} <- socket.assigns.current_tab,
{:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{name: Map.get(params, "name", "")}) do
{:ok, _definition} <-
ImportDefinitions.update_definition(definition_id, %{name: Map.get(params, "name", "")}) do
reload.(socket, socket.assigns.workbench)
else
_other -> reload.(socket, socket.assigns.workbench)
end
end
@spec select_uploads_folder(term(), term(), term()) :: term()
def select_uploads_folder(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab do
case FolderPicker.choose_directory(translated("importAnalysis.uploadsFolder")) do
{:ok, uploads_folder_path} ->
{:ok, _definition} = ImportDefinitions.update_definition(definition_id, %{uploads_folder_path: uploads_folder_path})
{:ok, _definition} =
ImportDefinitions.update_definition(definition_id, %{
uploads_folder_path: uploads_folder_path
})
reload.(socket, socket.assigns.workbench)
:cancel ->
@@ -33,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end
end
@spec select_and_analyze(term(), term(), term()) :: term()
def select_and_analyze(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id) do
@@ -50,9 +58,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
ImportAnalysis.analyze_wxr(project_id, wxr_file_path, definition.uploads_folder_path,
ImportAnalysis.analyze_wxr(
project_id,
wxr_file_path,
definition.uploads_folder_path,
on_progress: fn step, detail ->
send(live_view_pid, {:import_analysis_progress, definition_id, translate_phase(step), detail})
send(
live_view_pid,
{:import_analysis_progress, definition_id, translate_phase(step), detail}
)
end
)
end)
@@ -70,8 +84,14 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
ref: task.ref
})
)
|> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.put(socket.assigns.import_editor_analysis_task_refs, task.ref, definition_id))
|> Phoenix.Component.assign(:import_editor_execution_states, Map.delete(socket.assigns.import_editor_execution_states, definition_id))
|> Phoenix.Component.assign(
:import_editor_analysis_task_refs,
Map.put(socket.assigns.import_editor_analysis_task_refs, task.ref, definition_id)
)
|> Phoenix.Component.assign(
:import_editor_execution_states,
Map.delete(socket.assigns.import_editor_execution_states, definition_id)
)
|> reload.(socket.assigns.workbench)
:cancel ->
@@ -87,32 +107,50 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end
end
@spec note_analysis_progress(term(), term(), term(), term(), term()) :: term()
def note_analysis_progress(socket, definition_id, step, detail, reload) do
socket
|> Phoenix.Component.assign(
:import_editor_analysis_states,
Map.update(socket.assigns.import_editor_analysis_states, definition_id, default_analysis_state(), fn state ->
state
|> Map.put(:loading, true)
|> Map.put(:step, step)
|> Map.put(:detail, detail)
end)
Map.update(
socket.assigns.import_editor_analysis_states,
definition_id,
default_analysis_state(),
fn state ->
state
|> Map.put(:loading, true)
|> Map.put(:step, step)
|> Map.put(:detail, detail)
end
)
)
|> reload.(socket.assigns.workbench)
end
@spec finish_analysis(term(), term(), term(), term(), term()) :: term()
def finish_analysis(socket, ref, result, reload, append_output) do
case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do
nil ->
socket
definition_id ->
analysis_state = Map.get(socket.assigns.import_editor_analysis_states, definition_id, default_analysis_state())
analysis_state =
Map.get(
socket.assigns.import_editor_analysis_states,
definition_id,
default_analysis_state()
)
socket =
socket
|> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.delete(socket.assigns.import_editor_analysis_task_refs, ref))
|> Phoenix.Component.assign(:import_editor_analysis_states, Map.delete(socket.assigns.import_editor_analysis_states, definition_id))
|> Phoenix.Component.assign(
:import_editor_analysis_task_refs,
Map.delete(socket.assigns.import_editor_analysis_task_refs, ref)
)
|> Phoenix.Component.assign(
:import_editor_analysis_states,
Map.delete(socket.assigns.import_editor_analysis_states, definition_id)
)
case result do
{:ok, report} ->
@@ -146,6 +184,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end
end
@spec handle_analysis_task_down(term(), term(), term(), term(), term()) :: term()
def handle_analysis_task_down(socket, ref, message, reload, append_output) do
case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do
nil ->
@@ -153,13 +192,20 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
definition_id ->
socket
|> Phoenix.Component.assign(:import_editor_analysis_task_refs, Map.delete(socket.assigns.import_editor_analysis_task_refs, ref))
|> Phoenix.Component.assign(:import_editor_analysis_states, Map.delete(socket.assigns.import_editor_analysis_states, definition_id))
|> Phoenix.Component.assign(
:import_editor_analysis_task_refs,
Map.delete(socket.assigns.import_editor_analysis_task_refs, ref)
)
|> Phoenix.Component.assign(
:import_editor_analysis_states,
Map.delete(socket.assigns.import_editor_analysis_states, definition_id)
)
|> append_output.(translated("activity.import"), message, nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@spec importable_counts(term()) :: term()
def importable_counts(nil), do: %{total: 0, tags: 0, posts: 0, media: 0, pages: 0}
def importable_counts(report) do
@@ -171,25 +217,37 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
pages = importable_entity_count(Map.get(report.items, :pages, []))
media = importable_entity_count(Map.get(report.items, :media, []))
%{total: tag_count + posts + pages + media, tags: tag_count, posts: posts, media: media, pages: pages}
%{
total: tag_count + posts + pages + media,
tags: tag_count,
posts: posts,
media: media,
pages: pages
}
end
@spec importable_entity_count(term()) :: term()
def importable_entity_count(items) do
Enum.count(items || [], fn item ->
item.status == "new" or (item.status == "conflict" and Map.get(item, :resolution, "ignore") not in ["ignore", "skip"])
item.status == "new" or
(item.status == "conflict" and
Map.get(item, :resolution, "ignore") not in ["ignore", "skip"])
end)
end
@spec detail_items(term(), term()) :: term()
def detail_items(nil, _bucket), do: []
def detail_items(report, bucket) do
get_in(report, [:details, bucket]) || get_in(report, [:items, bucket]) || []
end
@spec default_analysis_state() :: term()
def default_analysis_state do
%{loading: false, step: nil, detail: nil, file_path: nil, ref: nil}
end
@spec default_sections() :: term()
def default_sections do
%{
post_conflicts: true,
@@ -203,18 +261,22 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
}
end
@spec default_author(term()) :: term()
def default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author)
end
@spec suggested_definition_name(term()) :: term()
def suggested_definition_name(report) do
get_in(report, [:site_info, :url]) || get_in(report, [:site_info, :title])
end
@spec maybe_put(term(), term(), term()) :: term()
def maybe_put(map, _key, nil), do: map
def maybe_put(map, key, value), do: Map.put(map, key, value)
@spec allow_repo_sandbox(term()) :: term()
def allow_repo_sandbox(pid) when is_pid(pid) do
if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do
try do
@@ -241,8 +303,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
end
end
@spec translate_phase(term()) :: term()
def translate_phase(other), do: other
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp present?(value), do: value not in [nil, ""]
end

View File

@@ -3,18 +3,27 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ConflictResolution do
alias BDS.ImportDefinitions
def change_conflict_resolution(socket, %{"item_type" => item_type, "item_name" => item_name, "resolution" => resolution}, reload) do
@spec change_conflict_resolution(term(), term(), term()) :: term()
def change_conflict_resolution(
socket,
%{"item_type" => item_type, "item_name" => item_name, "resolution" => resolution},
reload
) do
with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id),
%{} = report <- ImportDefinitions.decode_analysis_result(definition),
updated_report <- update_conflict_resolution(report, item_type, item_name, resolution),
{:ok, _definition} <- ImportDefinitions.update_definition(definition_id, %{last_analysis_result: updated_report}) do
{:ok, _definition} <-
ImportDefinitions.update_definition(definition_id, %{
last_analysis_result: updated_report
}) do
reload.(socket, socket.assigns.workbench)
else
_other -> reload.(socket, socket.assigns.workbench)
end
end
@spec update_conflict_resolution(term(), term(), term(), term()) :: term()
def update_conflict_resolution(report, item_type, item_name, resolution) do
report
|> update_in([:conflicts], fn conflicts ->
@@ -30,10 +39,15 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ConflictResolution do
|> update_in([:details], &update_conflict_bucket(&1, item_type, item_name, resolution))
end
@spec update_conflict_bucket(term(), term(), term(), term()) :: term()
def update_conflict_bucket(nil, _item_type, _item_name, _resolution), do: nil
def update_conflict_bucket(buckets, item_type, item_name, resolution) do
bucket_key = if(item_type == "page", do: :pages, else: if(item_type == "media", do: :media, else: :posts))
bucket_key =
if(item_type == "page",
do: :pages,
else: if(item_type == "media", do: :media, else: :posts)
)
update_in(buckets, [bucket_key], fn items ->
Enum.map(items || [], fn item ->

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState
@spec execute_import(term(), term(), term()) :: term()
def execute_import(socket, reload, _append_output) do
with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id),
@@ -24,7 +25,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
uploads_folder_path: definition.uploads_folder_path,
default_author: default_author,
on_progress: fn phase, current, total, detail ->
send(live_view_pid, {:import_execution_progress, definition_id, phase, current, total, detail})
send(
live_view_pid,
{:import_execution_progress, definition_id, phase, current, total, detail}
)
end
)
end)
@@ -50,7 +54,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: task.ref
})
)
|> Phoenix.Component.assign(:import_editor_execution_task_refs, Map.put(socket.assigns.import_editor_execution_task_refs, task.ref, definition_id))
|> Phoenix.Component.assign(
:import_editor_execution_task_refs,
Map.put(socket.assigns.import_editor_execution_task_refs, task.ref, definition_id)
)
|> reload.(socket.assigns.workbench)
end
else
@@ -58,6 +65,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end
end
@spec note_execution_progress(term(), term(), term(), term(), term(), term(), term()) :: term()
def note_execution_progress(socket, definition_id, phase, current, total, detail, reload) do
{detail_text, eta} = decompose_progress_detail(detail)
translated_phase = translate_execution_phase(phase)
@@ -65,30 +73,44 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
socket
|> Phoenix.Component.assign(
:import_editor_execution_states,
Map.update(socket.assigns.import_editor_execution_states, definition_id, default_execution_state(), fn state ->
state
|> Map.put(:is_executing, true)
|> Map.put(:phase, translated_phase)
|> Map.put(:current, current)
|> Map.put(:total, total)
|> Map.put(:detail, detail_text)
|> Map.put(:eta, eta)
end)
Map.update(
socket.assigns.import_editor_execution_states,
definition_id,
default_execution_state(),
fn state ->
state
|> Map.put(:is_executing, true)
|> Map.put(:phase, translated_phase)
|> Map.put(:current, current)
|> Map.put(:total, total)
|> Map.put(:detail, detail_text)
|> Map.put(:eta, eta)
end
)
)
|> reload.(socket.assigns.workbench)
end
@spec finish_execution(term(), term(), term(), term(), term()) :: term()
def finish_execution(socket, ref, result, reload, append_output) do
case Map.get(socket.assigns.import_editor_execution_task_refs, ref) do
nil ->
socket
definition_id ->
previous_state = Map.get(socket.assigns.import_editor_execution_states, definition_id, default_execution_state())
previous_state =
Map.get(
socket.assigns.import_editor_execution_states,
definition_id,
default_execution_state()
)
socket =
socket
|> Phoenix.Component.assign(:import_editor_execution_task_refs, Map.delete(socket.assigns.import_editor_execution_task_refs, ref))
|> Phoenix.Component.assign(
:import_editor_execution_task_refs,
Map.delete(socket.assigns.import_editor_execution_task_refs, ref)
)
case result do
{:ok, execution_result} ->
@@ -106,7 +128,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: nil
})
)
|> append_output.(translated("activity.import"), translated("importAnalysis.importComplete", %{count: previous_state.count}), nil, "info")
|> append_output.(
translated("activity.import"),
translated("importAnalysis.importComplete", %{count: previous_state.count}),
nil,
"info"
)
|> reload.(socket.assigns.workbench)
{:error, %{message: message}} ->
@@ -144,7 +171,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end
end
def handle_task_down(socket, kind, ref, reason, reload, append_output) when reason not in [:normal, :shutdown] do
@spec handle_task_down(term(), term(), term(), term(), term(), term()) :: term()
def handle_task_down(socket, kind, ref, reason, reload, append_output)
when reason not in [:normal, :shutdown] do
message = inspect(reason)
case kind do
@@ -157,10 +186,18 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
socket
definition_id ->
previous_state = Map.get(socket.assigns.import_editor_execution_states, definition_id, default_execution_state())
previous_state =
Map.get(
socket.assigns.import_editor_execution_states,
definition_id,
default_execution_state()
)
socket
|> Phoenix.Component.assign(:import_editor_execution_task_refs, Map.delete(socket.assigns.import_editor_execution_task_refs, ref))
|> Phoenix.Component.assign(
:import_editor_execution_task_refs,
Map.delete(socket.assigns.import_editor_execution_task_refs, ref)
)
|> Phoenix.Component.assign(
:import_editor_execution_states,
Map.put(socket.assigns.import_editor_execution_states, definition_id, %{
@@ -177,8 +214,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end
end
@spec handle_task_down(term(), term(), term(), term(), term(), term()) :: term()
def handle_task_down(socket, _kind, _ref, _reason, _reload, _append_output), do: socket
@spec default_execution_state() :: term()
def default_execution_state do
%{
is_executing: false,
@@ -195,6 +234,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
}
end
@spec execution_progress_width(term()) :: term()
def execution_progress_width(state) do
current = Map.get(state, :current, 0)
total = Map.get(state, :total, 0)
@@ -205,25 +245,36 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end
end
@spec decompose_progress_detail(term()) :: term()
def decompose_progress_detail(%{detail: detail, eta: eta}), do: {to_string_or_nil(detail), eta}
def decompose_progress_detail(detail) when is_binary(detail) or is_nil(detail), do: {detail, nil}
def decompose_progress_detail(detail) when is_binary(detail) or is_nil(detail),
do: {detail, nil}
def decompose_progress_detail(detail), do: {to_string_or_nil(detail), nil}
@spec to_string_or_nil(term()) :: term()
def to_string_or_nil(nil), do: nil
def to_string_or_nil(value) when is_binary(value), do: value
def to_string_or_nil(value), do: inspect(value)
@spec format_eta(term()) :: term()
def format_eta(nil), do: nil
def format_eta(ms) when is_integer(ms) and ms >= 0 do
seconds = div(ms, 1000)
if seconds < 60 do
translated("importAnalysis.eta", %{value: translated("importAnalysis.etaSeconds", %{count: seconds})})
translated("importAnalysis.eta", %{
value: translated("importAnalysis.etaSeconds", %{count: seconds})
})
else
m = div(seconds, 60)
s = rem(seconds, 60)
translated("importAnalysis.eta", %{value: translated("importAnalysis.etaMinutes", %{minutes: m, seconds: s})})
translated("importAnalysis.eta", %{
value: translated("importAnalysis.etaMinutes", %{minutes: m, seconds: s})
})
end
end
@@ -240,7 +291,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end
end
@spec translate_execution_phase(term()) :: term()
def translate_execution_phase(other), do: other
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
alias BDS.{AI, ImportDefinitions, Metadata, Tags}
alias BDS.Desktop.ShellData
@spec start_taxonomy_edit(term(), term(), term()) :: term()
def start_taxonomy_edit(
socket,
%{"type" => type, "name" => name, "mapped_to" => mapped_to},
@@ -25,6 +26,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec cancel_taxonomy_edit(term(), term()) :: term()
def cancel_taxonomy_edit(socket, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do
socket
@@ -38,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec save_taxonomy_edit(term(), term(), term()) :: term()
def save_taxonomy_edit(
socket,
%{"type" => type, "name" => name, "mapped_to" => mapped_to},
@@ -68,10 +71,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec clear_taxonomy_mapping(term(), term(), term()) :: term()
def clear_taxonomy_mapping(socket, %{"type" => type, "name" => name}, reload) do
save_taxonomy_edit(socket, %{"type" => type, "name" => name, "mapped_to" => ""}, reload)
end
@spec analyze_taxonomy_ai(term(), term(), term()) :: term()
def analyze_taxonomy_ai(socket, reload, append_output) do
with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id),
@@ -142,6 +147,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec update_taxonomy_mapping(term(), term(), term(), term()) :: term()
def update_taxonomy_mapping(report, type, name, mapped_to) do
bucket_key = if(type == "categories", do: :categories, else: :tags)
normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil()
@@ -164,6 +170,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
)
end
@spec rebuild_taxonomy_stats(term()) :: term()
def rebuild_taxonomy_stats(items) do
%{
existing_count: Enum.count(items, & &1.exists_in_project),
@@ -172,9 +179,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
}
end
@spec stat_key(term()) :: term()
def stat_key(:categories), do: :category_stats
def stat_key(:tags), do: :tag_stats
@spec apply_taxonomy_mappings(term(), term()) :: term()
def apply_taxonomy_mappings(report, analysis) do
report
|> update_in(
@@ -198,6 +207,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end)
end
@spec apply_taxonomy_mapping_bucket(term(), term()) :: term()
def apply_taxonomy_mapping_bucket(items, mappings) do
Enum.map(items || [], fn item ->
case Map.fetch(mappings, item.name) do
@@ -207,6 +217,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end)
end
@spec existing_taxonomy_terms(term()) :: term()
def existing_taxonomy_terms(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id)
@@ -216,6 +227,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
}
end
@spec normalize_taxonomy_mapping_value(term(), term(), term()) :: term()
def normalize_taxonomy_mapping_value(project_id, type, mapped_to) do
normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil()
@@ -231,6 +243,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec auto_mapped_count(term(), term()) :: term()
def auto_mapped_count(previous_report, next_report) do
previous_count =
(Map.get(previous_report.items, :categories, []) ++
@@ -244,6 +257,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
max(next_count - previous_count, 0)
end
@spec taxonomy_pill_class(term()) :: term()
def taxonomy_pill_class(item) do
cond do
item.exists_in_project -> "import-taxonomy-pill exists"
@@ -252,9 +266,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
end
end
@spec taxonomy_item_editing?(term(), term(), term()) :: term()
def taxonomy_item_editing?(%{type: type, name: name}, type, name), do: true
def taxonomy_item_editing?(_edit, _type, _name), do: false
@spec taxonomy_mapping_tooltip(term()) :: term()
def taxonomy_mapping_tooltip(item) do
action =
if present?(item.mapped_to),
@@ -264,6 +280,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
translated("importAnalysis.mappingTooltip", %{action: action})
end
@spec maybe_put_option(term(), term(), term()) :: term()
def maybe_put_option(opts, _key, nil), do: opts
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)