fix: add @spec to all public functions across 24 modules (CSM-019)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 11:40:42 +02:00
parent 3f77488e33
commit b6f9cf58e1
24 changed files with 150 additions and 3 deletions

View File

@@ -326,9 +326,17 @@
---
### CSM-019 — Missing `@spec` on Public Functions
- **Files:** Widespread across rendering, generation, publishing, UI, and scripting modules.
- **Fix:** Add `@spec` to every public function. This is a Dialyzer prerequisite (the project already runs Dialyzer; the report notes it should be clean).
### ~~CSM-019 — Missing `@spec` on Public Functions~~ ✅ FIXED
- **Fixed:** 2026-05-10
- **What was done:**
- Added `@spec` annotations to every public function across 25 files in rendering, generation, publishing, UI, and scripting modules.
- Added `@type t :: %__MODULE__{}` to `workbench.ex` and `file_system.ex` to support struct-based specs.
- Rendering: `post_rendering.ex`, `links_and_languages.ex`, `labels.ex`, `metadata.ex`, `file_system.ex`, `filters.ex`, `list_archive.ex`, `template_selection.ex`
- Generation: `generated_file_hash.ex`
- Publishing: `publishing.ex`
- UI: `registry.ex`, `session.ex`, `sidebar.ex`, `menu_bar.ex`, `commands.ex`, `dashboard.ex`, `workbench.ex`
- Scripting: `job_store.ex`, `job_runner.ex`, `job_supervisor.ex`, `capabilities.ex`, `capabilities/util.ex`, `api_docs.ex`
- Dialyzer passes with 0 errors; all 619 tests pass.
---

View File

@@ -14,6 +14,7 @@ defmodule BDS.Generation.GeneratedFileHash do
field :updated_at, :integer
end
@spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t()
def changeset(record, attrs) do
record
|> cast(attrs, [:project_id, :relative_path, :content_hash, :updated_at], empty_values: [nil])

View File

@@ -286,6 +286,7 @@ defmodule BDS.Publishing do
defp rsync_excludes(%{kind: :media}), do: ["--exclude=*.meta"]
defp rsync_excludes(_target), do: []
@spec ensure_trailing_slash(String.t()) :: String.t()
def ensure_trailing_slash(path), do: String.trim_trailing(path, "/") <> "/"
defp remote_dir_spec(credentials, remote_dir) do

View File

@@ -1,8 +1,10 @@
defmodule BDS.Rendering.FileSystem do
@moduledoc false
@type t :: %__MODULE__{root_paths: [String.t()]}
defstruct [:root_paths]
@spec new([String.t()] | String.t()) :: t()
def new(root_paths) when is_list(root_paths) do
%__MODULE__{root_paths: Enum.uniq(root_paths)}
end
@@ -11,6 +13,7 @@ defmodule BDS.Rendering.FileSystem do
new([root_path])
end
@spec full_path(t(), String.t()) :: String.t()
def full_path(%__MODULE__{root_paths: root_paths}, template_path) do
normalized_path = to_string(template_path)

View File

@@ -5,6 +5,7 @@ defmodule BDS.Rendering.Filters do
alias BDS.Slug
@spec i18n(term(), String.t(), Liquex.Context.t()) :: String.t()
def i18n(value, language, _context) do
key = value |> to_string() |> String.trim()
@@ -15,6 +16,16 @@ defmodule BDS.Rendering.Filters do
end
end
@spec markdown(
term(),
term(),
term(),
map(),
map(),
String.t(),
term(),
Liquex.Context.t()
) :: String.t()
def markdown(
value,
_post_id,
@@ -28,6 +39,8 @@ defmodule BDS.Rendering.Filters do
render_markdown(value, canonical_post_paths, canonical_media_paths, language, context)
end
@spec render_markdown(term(), map() | nil, map() | nil, String.t(), Liquex.Context.t()) ::
String.t()
def render_markdown(value, canonical_post_paths, canonical_media_paths, language, context) do
value
|> to_string()
@@ -36,6 +49,7 @@ defmodule BDS.Rendering.Filters do
|> rewrite_rendered_html_urls(canonical_post_paths || %{}, canonical_media_paths || %{})
end
@spec slugify(term(), Liquex.Context.t()) :: String.t()
def slugify(value, _context) do
value
|> to_string()

View File

@@ -8,6 +8,7 @@ defmodule BDS.Rendering.Labels do
use Gettext, backend: BDS.Gettext
@spec for_language(String.t()) :: map()
def for_language(language) do
Gettext.with_locale(BDS.Gettext, language, fn ->
%{
@@ -34,6 +35,7 @@ defmodule BDS.Rendering.Labels do
end)
end
@spec month_name(integer() | nil, String.t()) :: String.t() | nil
def month_name(nil, _language), do: nil
def month_name(1, language) do

View File

@@ -9,6 +9,7 @@ defmodule BDS.Rendering.LinksAndLanguages do
alias BDS.Posts.Post
alias BDS.Repo
@spec canonical_post_path_by_slug(String.t(), String.t()) :: %{String.t() => String.t()}
def canonical_post_path_by_slug(project_id, main_language) do
posts =
Repo.all(
@@ -36,6 +37,7 @@ defmodule BDS.Rendering.LinksAndLanguages do
end)
end
@spec canonical_media_path_by_source_path(String.t()) :: %{String.t() => String.t()}
def canonical_media_path_by_source_path(project_id) do
Repo.all(from media in MediaAsset, where: media.project_id == ^project_id)
|> Enum.reduce(%{}, fn media, acc ->
@@ -54,6 +56,7 @@ defmodule BDS.Rendering.LinksAndLanguages do
end)
end
@spec post_path(map(), String.t() | nil) :: String.t()
def post_path(post, language_prefix)
when is_binary(language_prefix) and language_prefix != "" do
String.trim_trailing(language_prefix, "/") <> post_path(post, nil)
@@ -73,11 +76,15 @@ defmodule BDS.Rendering.LinksAndLanguages do
]) <> "/"
end
@spec post_path(map(), String.t() | nil, String.t()) :: String.t()
def post_path(post, language, main_language) do
prefix = language_prefix(language, main_language)
post_path(post, prefix)
end
@spec link_contexts(String.t() | nil, String.t() | nil, :incoming | :outgoing, String.t()) :: [
map()
]
def link_contexts(_project_id, nil, _direction, _main_language), do: []
def link_contexts(project_id, post_id, :incoming, main_language) do
@@ -113,10 +120,12 @@ defmodule BDS.Rendering.LinksAndLanguages do
end
end
@spec language_prefix(String.t() | nil, String.t()) :: String.t()
def language_prefix(language, main_language) when language == main_language, do: ""
def language_prefix(nil, _main_language), do: ""
def language_prefix(language, _main_language), do: "/#{language}"
@spec normalize_language(String.t() | nil, String.t()) :: String.t()
def normalize_language(nil, fallback), do: fallback
def normalize_language("", fallback), do: fallback

View File

@@ -10,6 +10,7 @@ defmodule BDS.Rendering.ListArchive do
alias BDS.Rendering.TemplateSelection
use Gettext, backend: BDS.Gettext
@spec list_assigns(String.t(), map()) :: map()
def list_assigns(project_id, assigns) do
metadata = RenderMetadata.project_metadata(project_id)
template_context = TemplateSelection.template_render_context(project_id)
@@ -114,6 +115,7 @@ defmodule BDS.Rendering.ListArchive do
}
end
@spec not_found_assigns(String.t(), map()) :: map()
def not_found_assigns(project_id, assigns) do
metadata = RenderMetadata.project_metadata(project_id)

View File

@@ -14,16 +14,19 @@ defmodule BDS.Rendering.Metadata do
alias BDS.Posts.Translation
alias BDS.Tags.Tag
@spec project_metadata(String.t()) :: map()
def project_metadata(project_id) do
{:ok, metadata} = ProjectMetadata.get_project_metadata(project_id)
metadata
end
@spec menu_items(String.t()) :: [map()]
def menu_items(project_id) do
{:ok, %{items: items}} = Menu.get_menu(project_id)
Enum.map(items, &to_template_menu_item/1)
end
@spec menu_items_from_raw([map()]) :: [map()]
def menu_items_from_raw(items) when is_list(items) do
Enum.map(items, &to_template_menu_item/1)
end
@@ -52,6 +55,7 @@ defmodule BDS.Rendering.Metadata do
defp menu_item_href(%{kind: :submenu}), do: "#"
defp menu_item_href(_item), do: "#"
@spec blog_languages(map(), String.t()) :: [map()]
def blog_languages(metadata, current_language) do
([metadata.main_language] ++ (metadata.blog_languages || []))
|> Enum.reject(&(&1 in [nil, ""]))
@@ -72,11 +76,13 @@ defmodule BDS.Rendering.Metadata do
end)
end
@spec tag_color_by_name(String.t()) :: %{String.t() => String.t() | nil}
def tag_color_by_name(project_id) do
Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: {tag.name, tag.color})
|> Enum.into(%{}, fn {name, color} -> {name, color} end)
end
@spec alternate_links(Post.t() | nil, String.t(), String.t()) :: [map()]
def alternate_links(nil, _project_id, _main_language), do: []
def alternate_links(%Post{} = post, project_id, main_language) do
@@ -104,22 +110,27 @@ defmodule BDS.Rendering.Metadata do
end)
end
@spec backlinks([map()]) :: [map()]
def backlinks(incoming_links) do
Enum.map(incoming_links, fn link ->
%{path: link.href, display_slug: link.display_slug, title: link.title}
end)
end
@spec default_pico_stylesheet_href(String.t() | nil) :: String.t()
def default_pico_stylesheet_href(theme), do: PreviewAssets.stylesheet_href(theme)
@spec href_for_language(String.t()) :: String.t()
def href_for_language(""), do: "/"
def href_for_language(prefix), do: String.trim_trailing(prefix, "/") <> "/"
@spec calendar_initial_year(map() | nil) :: integer() | nil
def calendar_initial_year(%{created_at: created_at}) when is_integer(created_at),
do: Persistence.from_unix_ms!(created_at).year
def calendar_initial_year(_post), do: nil
@spec calendar_initial_month(map() | nil) :: integer() | nil
def calendar_initial_month(%{created_at: created_at}) when is_integer(created_at),
do: Persistence.from_unix_ms!(created_at).month

View File

@@ -13,6 +13,7 @@ defmodule BDS.Rendering.PostRendering do
alias BDS.Posts.Translation
alias BDS.Repo
@spec post_assigns(String.t(), map()) :: map()
def post_assigns(project_id, assigns) do
metadata = RenderMetadata.project_metadata(project_id)
template_context = TemplateSelection.template_render_context(project_id)
@@ -180,6 +181,7 @@ defmodule BDS.Rendering.PostRendering do
end
end
@spec post_data_json_value(map()) :: String.t()
def post_data_json_value(post_context) do
case Jason.encode(%{
id: Map.get(post_context, :id),
@@ -238,6 +240,13 @@ defmodule BDS.Rendering.PostRendering do
}
end
@spec render_post_content(
String.t() | nil,
map(),
map(),
String.t(),
Liquex.Context.t()
) :: String.t()
def render_post_content(
content,
canonical_post_paths,

View File

@@ -11,6 +11,8 @@ defmodule BDS.Rendering.TemplateSelection do
alias BDS.StarterTemplates
alias BDS.Templates.Template
@spec load_template_source(String.t(), atom(), String.t() | nil) ::
{:ok, String.t()} | {:error, term()}
def load_template_source(project_id, kind, slug) do
project = Projects.get_project!(project_id)
@@ -88,6 +90,8 @@ defmodule BDS.Rendering.TemplateSelection do
end
end
@spec render_template(String.t(), String.t(), map()) ::
{:ok, String.t()} | {:error, String.t()}
def render_template(project_id, source, assigns) do
with {:ok, template_ast} <- Liquex.parse(source) do
project = Projects.get_project!(project_id)
@@ -145,6 +149,7 @@ defmodule BDS.Rendering.TemplateSelection do
defp bundled_template_slug(_kind, slug) when is_binary(slug) and slug != "", do: slug
defp bundled_template_slug(kind, _slug), do: StarterTemplates.default_slug(kind)
@spec template_render_context(String.t()) :: Liquex.Context.t()
def template_render_context(project_id) do
project = Projects.get_project!(project_id)

View File

@@ -1207,6 +1207,7 @@ defmodule BDS.Scripting.ApiDocs do
}
]
@spec render() :: String.t()
def render do
[
"# API Documentation",

View File

@@ -22,6 +22,7 @@ defmodule BDS.Scripting.Capabilities do
import PostsCaps
import ProjectsCaps
@spec for_project(String.t(), keyword()) :: map()
def for_project(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
%{
app: %{

View File

@@ -3,12 +3,14 @@ defmodule BDS.Scripting.Capabilities.Util do
alias BDS.Projects
@spec project_path(String.t()) :: String.t()
def project_path(project_id) do
project_id
|> Projects.get_project()
|> Projects.project_data_dir()
end
@spec sanitize(term()) :: term()
def sanitize(%DateTime{} = value), do: DateTime.to_iso8601(value)
def sanitize(%_struct{} = struct) do
@@ -27,9 +29,11 @@ defmodule BDS.Scripting.Capabilities.Util do
def sanitize(value) when is_atom(value), do: Atom.to_string(value)
def sanitize(value), do: value
@spec sanitize_nilable(term()) :: term()
def sanitize_nilable(nil), do: nil
def sanitize_nilable(value), do: sanitize(value)
@spec normalize_input(term()) :: term()
def normalize_input(%_struct{} = struct), do: struct |> Map.from_struct() |> normalize_input()
def normalize_input(map) when is_map(map) do
@@ -71,6 +75,7 @@ defmodule BDS.Scripting.Capabilities.Util do
def normalize_input(value) when is_atom(value), do: Atom.to_string(value)
def normalize_input(value), do: value
@spec normalize_input_key(term()) :: term()
def normalize_input_key(key) when is_integer(key), do: key
def normalize_input_key(key) when is_float(key) and trunc(key) == key, do: trunc(key)
@@ -84,6 +89,7 @@ defmodule BDS.Scripting.Capabilities.Util do
def normalize_input_key(key) when is_atom(key), do: Atom.to_string(key)
def normalize_input_key(key), do: key
@spec numeric_sequence_map?(map()) :: boolean()
def numeric_sequence_map?(map) when map == %{}, do: false
def numeric_sequence_map?(map) do
@@ -91,6 +97,7 @@ defmodule BDS.Scripting.Capabilities.Util do
Enum.all?(keys, &is_integer/1) and Enum.sort(keys) == Enum.to_list(1..length(keys))
end
@spec normalize_map(term()) :: map()
def normalize_map(value) when is_map(value) do
case normalize_input(value) do
normalized when is_map(normalized) -> normalized
@@ -108,6 +115,7 @@ defmodule BDS.Scripting.Capabilities.Util do
def normalize_map(_value), do: %{}
@spec normalize_string_list(term()) :: [String.t()]
def normalize_string_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
def normalize_string_list(value) when is_map(value) do
@@ -121,6 +129,7 @@ defmodule BDS.Scripting.Capabilities.Util do
def normalize_string_list(_value), do: []
@spec normalize_search_filters(term()) :: map()
def normalize_search_filters(filters) do
filters
|> normalize_map()
@@ -136,19 +145,24 @@ defmodule BDS.Scripting.Capabilities.Util do
end)
end
@spec integer_or_default(term(), integer()) :: integer()
def integer_or_default(value, _default) when is_integer(value), do: value
def integer_or_default(value, _default) when is_float(value), do: trunc(value)
def integer_or_default(_value, default), do: default
@spec string_or_nil(term()) :: String.t() | nil
def string_or_nil(value) when is_binary(value), do: value
def string_or_nil(value) when is_atom(value), do: Atom.to_string(value)
def string_or_nil(value) when is_number(value), do: to_string(value)
def string_or_nil(_value), do: nil
@spec truthy?(term()) :: boolean()
def truthy?(value), do: value in [true, "true", 1, 1.0, "1"]
@spec pad2(integer()) :: String.t()
def pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0")
@spec blank_to_nil(term()) :: term()
def blank_to_nil(nil), do: nil
def blank_to_nil(value) when is_binary(value) do
@@ -157,13 +171,16 @@ defmodule BDS.Scripting.Capabilities.Util do
def blank_to_nil(value), do: value
@spec maybe_put_query(map(), term(), term()) :: map()
def maybe_put_query(query, _key, false), do: query
def maybe_put_query(query, _key, nil), do: query
def maybe_put_query(query, key, value), do: Map.put(query, key, value)
@spec maybe_put_opt(keyword(), atom(), term()) :: keyword()
def maybe_put_opt(opts, _key, nil), do: opts
def maybe_put_opt(opts, key, value), do: Keyword.put(opts, key, value)
@spec maybe_put_normalized_list(map(), atom() | String.t()) :: map()
def maybe_put_normalized_list(attrs, key) do
case Map.fetch(attrs, key) do
{:ok, value} -> Map.put(attrs, key, normalize_string_list(value))
@@ -171,9 +188,11 @@ defmodule BDS.Scripting.Capabilities.Util do
end
end
@spec compare_optional(term(), (term() -> boolean())) :: boolean()
def compare_optional(nil, _fun), do: true
def compare_optional(value, fun) when is_function(fun, 1), do: fun.(value)
@spec parse_datetime(term()) :: DateTime.t() | nil
def parse_datetime(nil), do: nil
def parse_datetime(value) when is_integer(value), do: DateTime.from_unix!(value, :millisecond)
@@ -186,16 +205,20 @@ defmodule BDS.Scripting.Capabilities.Util do
def parse_datetime(_value), do: nil
@spec unwrap_result({:ok, term()} | {:error, term()}, (term() -> term())) :: term()
def unwrap_result(result, transform \\ &sanitize/1)
def unwrap_result({:ok, value}, transform), do: transform.(value)
def unwrap_result({:error, _reason}, _transform), do: nil
@spec boolean_result({:ok, term()} | {:error, term()}) :: boolean()
def boolean_result({:ok, _value}), do: true
def boolean_result({:error, _reason}), do: false
@spec atom_result({:ok, term()} | {:error, term()}, term()) :: boolean()
def atom_result({:ok, value}, expected_value), do: value == expected_value
def atom_result(_result, _expected_value), do: false
@spec thumbnail_size(term()) :: :small | :medium | :large | :ai
def thumbnail_size(size) do
case blank_to_nil(size) do
"medium" -> :medium
@@ -205,6 +228,7 @@ defmodule BDS.Scripting.Capabilities.Util do
end
end
@spec thumbnail_mime(String.t()) :: String.t()
def thumbnail_mime(path) do
case Path.extname(path) do
".jpg" -> "image/jpeg"
@@ -213,6 +237,7 @@ defmodule BDS.Scripting.Capabilities.Util do
end
end
@spec shell_open_system_path(String.t()) :: :ok | {:error, term()}
def shell_open_system_path(path) do
{command, args} =
case :os.type() do
@@ -229,6 +254,7 @@ defmodule BDS.Scripting.Capabilities.Util do
error -> {:error, error}
end
@spec shell_reveal_system_path(String.t()) :: :ok | {:error, term()}
def shell_reveal_system_path(path) do
{command, args} =
case :os.type() do
@@ -245,6 +271,7 @@ defmodule BDS.Scripting.Capabilities.Util do
error -> {:error, error}
end
@spec zero_or_one_arg((term() -> term())) :: (list(), tuple() -> {list(), tuple()})
def zero_or_one_arg(callback) when is_function(callback, 1) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
@@ -253,6 +280,7 @@ defmodule BDS.Scripting.Capabilities.Util do
end
end
@spec one_arg((term() -> term())) :: (list(), tuple() -> {list(), tuple()})
def one_arg(callback) when is_function(callback, 1) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
@@ -267,6 +295,7 @@ defmodule BDS.Scripting.Capabilities.Util do
end
end
@spec two_arg((term(), term() -> term())) :: (list(), tuple() -> {list(), tuple()})
def two_arg(callback) when is_function(callback, 2) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)
@@ -282,6 +311,7 @@ defmodule BDS.Scripting.Capabilities.Util do
end
end
@spec three_arg((term(), term(), term() -> term())) :: (list(), tuple() -> {list(), tuple()})
def three_arg(callback) when is_function(callback, 3) do
fn args, state ->
decoded_args = :luerl.decode_list(args, state)

View File

@@ -3,10 +3,12 @@ defmodule BDS.Scripting.JobRunner do
use GenServer, restart: :temporary
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
@spec cancel(pid()) :: :ok
def cancel(pid) when is_pid(pid) do
GenServer.call(pid, :cancel)
end

View File

@@ -3,30 +3,37 @@ defmodule BDS.Scripting.JobStore do
use GenServer
@spec start_link(term()) :: GenServer.on_start()
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@spec put_job(map()) :: :ok
def put_job(job) when is_map(job) do
GenServer.call(__MODULE__, {:put_job, job})
end
@spec update_job(String.t(), map()) :: :ok
def update_job(job_id, attrs) when is_binary(job_id) and is_map(attrs) do
GenServer.call(__MODULE__, {:update_job, job_id, attrs})
end
@spec attach_runner(String.t(), pid()) :: :ok
def attach_runner(job_id, pid) when is_binary(job_id) and is_pid(pid) do
GenServer.call(__MODULE__, {:attach_runner, job_id, pid})
end
@spec detach_runner(String.t()) :: :ok
def detach_runner(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:detach_runner, job_id})
end
@spec fetch_job(String.t()) :: map() | nil
def fetch_job(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:fetch_job, job_id})
end
@spec fetch_job!(String.t()) :: map()
def fetch_job!(job_id) when is_binary(job_id) do
case fetch_job(job_id) do
nil -> raise KeyError, key: job_id, term: :jobs
@@ -34,6 +41,7 @@ defmodule BDS.Scripting.JobStore do
end
end
@spec runner_for(String.t()) :: pid() | nil
def runner_for(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:runner_for, job_id})
end

View File

@@ -3,6 +3,7 @@ defmodule BDS.Scripting.JobSupervisor do
use DynamicSupervisor
@spec start_link(term()) :: Supervisor.on_start()
def start_link(_opts) do
DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
end

View File

@@ -31,6 +31,7 @@ defmodule BDS.UI.Commands do
%{id: :upload_site, accelerator: "CTRL+SHIFT+U"}
]
@spec handle_shortcut(BDS.UI.Workbench.t(), map()) :: BDS.UI.Workbench.t()
def handle_shortcut(state, shortcut) when is_map(shortcut) do
case command_for_shortcut(shortcut) do
nil -> state
@@ -38,6 +39,7 @@ defmodule BDS.UI.Commands do
end
end
@spec command_for_shortcut(map()) :: atom() | nil
def command_for_shortcut(shortcut) when is_map(shortcut) do
key = shortcut |> BDS.MapUtils.attr(:key, "") |> String.downcase()
@@ -54,6 +56,7 @@ defmodule BDS.UI.Commands do
end
end
@spec client_shortcuts() :: [map()]
def client_shortcuts do
@menu_shortcuts
|> Enum.filter(&Map.has_key?(&1, :key))
@@ -67,6 +70,7 @@ defmodule BDS.UI.Commands do
end)
end
@spec accelerator_label(atom()) :: String.t() | nil
def accelerator_label(command_id) when is_atom(command_id) do
case Enum.find(@menu_shortcuts, &(&1.id == command_id)) do
%{accelerator: accelerator} -> accelerator

View File

@@ -8,6 +8,7 @@ defmodule BDS.UI.Dashboard do
alias BDS.Repo
alias BDS.Tags.Tag
@spec snapshot(String.t() | nil) :: map()
def snapshot(nil), do: empty_snapshot()
def snapshot(project_id) when is_binary(project_id) do
@@ -23,6 +24,7 @@ defmodule BDS.UI.Dashboard do
}
end
@spec empty_snapshot() :: map()
def empty_snapshot do
%{
title: "dashboard.title",

View File

@@ -4,6 +4,7 @@ defmodule BDS.UI.MenuBar do
alias BDS.UI.Registry
alias BDS.UI.Workbench
@spec default_groups(keyword()) :: [map()]
def default_groups(opts \\ []) do
dev_mode? = Keyword.get(opts, :dev_mode?, false)
@@ -80,6 +81,7 @@ defmodule BDS.UI.MenuBar do
]
end
@spec execute(Workbench.t(), atom()) :: Workbench.t()
def execute(state, :toggle_sidebar), do: Workbench.toggle_sidebar(state)
def execute(state, :toggle_panel), do: Workbench.toggle_panel(state)
def execute(state, :toggle_assistant_sidebar), do: Workbench.toggle_assistant_sidebar(state)

View File

@@ -3,8 +3,10 @@ defmodule BDS.UI.Registry do
use Gettext, backend: BDS.Gettext
@spec default_sidebar_view() :: atom()
def default_sidebar_view, do: :posts
@spec sidebar_views() :: [map()]
def sidebar_views do
[
%{
@@ -90,6 +92,7 @@ defmodule BDS.UI.Registry do
]
end
@spec editor_routes() :: [map()]
def editor_routes do
[
%{id: :dashboard, singleton: true, entity_tab: false, title: dgettext("ui", "Dashboard")},
@@ -138,6 +141,8 @@ defmodule BDS.UI.Registry do
]
end
@spec sidebar_view(atom()) :: map() | nil
def sidebar_view(id) when is_atom(id), do: Enum.find(sidebar_views(), &(&1.id == id))
@spec editor_route(atom()) :: map() | nil
def editor_route(id) when is_atom(id), do: Enum.find(editor_routes(), &(&1.id == id))
end

View File

@@ -4,6 +4,7 @@ defmodule BDS.UI.Session do
alias BDS.BoundedAtoms
alias BDS.UI.Workbench
@spec serialize(Workbench.t()) :: map()
def serialize(state) do
%{
"sidebar_visible" => state.sidebar_visible,
@@ -28,6 +29,7 @@ defmodule BDS.UI.Session do
}
end
@spec restore(map()) :: Workbench.t()
def restore(payload) when is_map(payload) do
state =
Workbench.new(

View File

@@ -16,6 +16,7 @@ defmodule BDS.UI.Sidebar do
@default_page_size 500
@spec snapshot(String.t() | nil) :: map()
def snapshot(nil), do: empty_snapshot()
def snapshot(project_id) when is_binary(project_id) do
@@ -39,6 +40,7 @@ defmodule BDS.UI.Sidebar do
}
end
@spec view(String.t() | nil, String.t() | atom(), map()) :: map()
def view(project_id, view_id, params \\ %{})
def view(nil, view_id, _params), do: empty_view(view_id)
@@ -102,6 +104,7 @@ defmodule BDS.UI.Sidebar do
end
end
@spec empty_snapshot() :: map()
def empty_snapshot do
%{
"posts" => empty_view("posts"),

View File

@@ -19,6 +19,8 @@ defmodule BDS.UI.Workbench do
:find_duplicates
])
@type t :: %__MODULE__{}
defstruct sidebar_visible: true,
sidebar_width: 280,
active_view: :posts,
@@ -30,6 +32,7 @@ defmodule BDS.UI.Workbench do
editor_route: :dashboard,
dirty_tabs: MapSet.new()
@spec new(keyword()) :: t()
def new(opts \\ []) do
%__MODULE__{
sidebar_visible: Keyword.get(opts, :sidebar_visible, true),
@@ -48,14 +51,17 @@ defmodule BDS.UI.Workbench do
|> normalize_panel()
end
@spec set_sidebar_width(t(), integer()) :: t()
def set_sidebar_width(state, width) when is_integer(width) do
%{state | sidebar_width: clamp_sidebar_width(width)}
end
@spec set_assistant_sidebar_width(t(), integer()) :: t()
def set_assistant_sidebar_width(state, width) when is_integer(width) do
%{state | assistant_sidebar_width: clamp_assistant_sidebar_width(width)}
end
@spec open_tab(t(), atom() | String.t(), String.t(), atom()) :: t()
def open_tab(state, type, id, intent) do
{tabs, opened_tab} = upsert_tab(state.tabs, normalize_type(type), id, intent)
@@ -66,6 +72,7 @@ defmodule BDS.UI.Workbench do
|> normalize_panel()
end
@spec open_tab_in_background(t(), atom() | String.t(), String.t(), atom()) :: t()
def open_tab_in_background(state, type, id, intent) do
current_active = state.active_tab
{tabs, _opened_tab} = upsert_tab(state.tabs, normalize_type(type), id, intent)
@@ -77,6 +84,7 @@ defmodule BDS.UI.Workbench do
|> normalize_panel()
end
@spec close_tab(t(), atom() | String.t(), String.t()) :: t()
def close_tab(state, type, id) do
type = normalize_type(type)
target = {type, id}
@@ -103,6 +111,7 @@ defmodule BDS.UI.Workbench do
end
end
@spec pin_tab(t(), atom() | String.t(), String.t()) :: t()
def pin_tab(state, type, id) do
type = normalize_type(type)
@@ -114,11 +123,13 @@ defmodule BDS.UI.Workbench do
%{state | tabs: tabs}
end
@spec clear_tabs(t()) :: t()
def clear_tabs(state) do
%{state | tabs: [], active_tab: nil, editor_route: :dashboard, dirty_tabs: MapSet.new()}
|> normalize_panel()
end
@spec mark_dirty(t(), atom() | String.t(), String.t()) :: t()
def mark_dirty(state, type, id) do
if normalize_type(type) == :post do
%{state | dirty_tabs: MapSet.put(state.dirty_tabs, {normalize_type(type), id})}
@@ -127,32 +138,40 @@ defmodule BDS.UI.Workbench do
end
end
@spec clear_dirty(t(), atom() | String.t(), String.t()) :: t()
def clear_dirty(state, type, id) do
%{state | dirty_tabs: MapSet.delete(state.dirty_tabs, {normalize_type(type), id})}
end
@spec dirty?(t(), atom() | String.t(), String.t()) :: boolean()
def dirty?(state, type, id) do
MapSet.member?(state.dirty_tabs, {normalize_type(type), id})
end
@spec toggle_sidebar(t()) :: t()
def toggle_sidebar(state), do: %{state | sidebar_visible: not state.sidebar_visible}
@spec set_panel_visible(t(), boolean()) :: t()
def set_panel_visible(state, visible) when is_boolean(visible) do
%{state | panel: %{state.panel | visible: visible}}
end
@spec toggle_panel(t()) :: t()
def toggle_panel(state) do
set_panel_visible(state, not state.panel.visible)
end
@spec toggle_assistant_sidebar(t()) :: t()
def toggle_assistant_sidebar(state) do
%{state | assistant_sidebar_visible: not state.assistant_sidebar_visible}
end
@spec set_panel_tab(t(), atom()) :: t()
def set_panel_tab(state, tab) when tab in [:tasks, :output, :post_links, :git_log] do
%{state | panel: %{state.panel | active_tab: tab}}
end
@spec click_activity(t(), atom() | String.t()) :: t()
def click_activity(state, activity_id) do
activity_id = normalize_type(activity_id)
@@ -163,6 +182,7 @@ defmodule BDS.UI.Workbench do
end
end
@spec activity_buttons(t(), integer()) :: [map()]
def activity_buttons(state, git_badge_count \\ 0) do
Registry.sidebar_views()
|> Enum.map(fn view ->
@@ -176,6 +196,7 @@ defmodule BDS.UI.Workbench do
end)
end
@spec status_bar(t(), keyword()) :: map()
def status_bar(state, opts) do
post_count = Keyword.get(opts, :post_count, 0)
media_count = Keyword.get(opts, :media_count, 0)