Compare commits

...

2 Commits

Author SHA1 Message Date
9f9dab85fc chore: removed done document 2026-05-01 17:51:35 +02:00
881056eb61 chore: added more @spec 2026-05-01 17:49:50 +02:00
157 changed files with 6220 additions and 1887 deletions

View File

@@ -1,241 +0,0 @@
# Elixir Code Smell Analysis — bDS2
Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`).
Last refreshed: 2026-05-09.
---
## 1. God Modules
**Status:** in progress. The originally-flagged god modules (now including `BDS.MCP`) are all reduced to coordinator size; the open queue is empty.
### Open queue (priority order)
_None._ All modules previously on the queue have been split; refresh the queue if a new module crosses the 800-line threshold.
**Established pattern:** extract cohesive helper clusters into submodules under `lib/<context>/<sub>.ex`; `import BDS.X.Y, only: [...]` from the main module so internal call sites are unchanged; `defdelegate` for any helper still needed through the public namespace; verify with `mix compile --warnings-as-errors`, `mix dialyzer --format short`, and full `mix test` after each extraction.
### Completed (see Changelog for details)
- `BDS.Generation` 2651 → 647 (76 %)
- `BDS.AI` 1711 → 168 (90 %)
- `BDS.Scripting.Capabilities` 1715 → 194 (89 %)
- `BDS.Posts` 1781 → 569 (68 %)
- `BDS.Desktop.ShellLive` 2607 → 1545 (41 %)
- `BDS.Maintenance` 810 → 141 (83 %)
- `BDS.Media` 993 → 324 (67 %)
- `BDS.Desktop.ShellLive.ImportEditor` 1436 → 776 (46 %)
- `BDS.Rendering` 838 → 33 (96 %)
- `BDS.Desktop.ShellLive.MenuEditor` 871 → 335 (62 %)
- `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %)
- `BDS.Desktop.ShellLive.SettingsEditor` 872 → 226 (74 %)
- `BDS.Desktop.ShellLive.ChatEditor` 972 → 576 (41 %)
- `BDS.MCP` 677 → 27 (96 %)
---
## 2. Process Dictionary for i18n State
**Status:** ✅ encapsulated (2026-05-10). Raw `Process.put(:bds_ui_locale, _)` / `Process.get(:bds_ui_locale)` no longer appears outside `BDS.Desktop.UILocale`. The two render boundaries (`BDS.Desktop.ShellLive.render/1` and `BDS.Desktop.ShellLive.SidebarComponents.sidebar_content/1`) now call `UILocale.put/1`; the ~30 helper read sites call `BDS.Desktop.UILocale.current/0`. The full thread-locale-through-assigns rewrite (~733 HEEx call sites) was rejected as too invasive for the marginal benefit; the encapsulation removes the implicit-global smell while keeping the LiveView lazy-render path intact.
**Why the helper does not restore on render exit:** Phoenix's `~H` returns a `Phoenix.LiveView.Rendered` whose `dynamic` function is invoked lazily by LiveView *after* `render/1` returns. A `try/after Process.delete` wrapper around `render/1` would clear the binding before child components materialize, so render boundaries use `UILocale.put/1` (set-only). `UILocale.with_locale/2` is provided for short-lived non-LiveView contexts (background tasks, scripts) that consume the binding eagerly inside the closure.
**Module rules:**
- Only `lib/bds/desktop/ui_locale.ex` may touch the `:bds_ui_locale` process key.
- New code that needs the active UI locale must call `BDS.Desktop.UILocale.current/0` (or take it as an argument).
- New render entry points that change the locale must call `BDS.Desktop.UILocale.put/1` at the top of `render/1`.
---
## 3. Side Effects in Transactions
**Status:** ✅ done for explicit `Repo.transaction/1` sites (2026-05-10). `BDS.Media`, `BDS.Templates.update_template/2`, `BDS.Metadata`, `BDS.Tags`, and `BDS.Projects` now keep filesystem/search/template-rebuild side effects outside their DB transactions. Remaining explicit transactions (`BDS.PostLinks`, `BDS.AI.Catalog`, project activation/deletion cleanup, and media link helpers) are DB-only or already run filesystem cleanup after commit. `BDS.Posts`, `BDS.Publishing`, and `BDS.Generation` do not currently use `Repo.transaction/1`.
**Rule:** only DB writes inside explicit transactions; filesystem, search sync, template rebuilds, and published-file rewrites run after commit.
---
## 4. `String.to_atom/1` on Untrusted Input
**Status:** ✅ done. The only attacker-reachable site (`MCP.atomize_keys/1`) was deleted (2026-04-30). Nine remaining `String.to_atom/1` call sites all operate on bounded enums (workbench types, release platforms, route IDs, capability keys, AI catalog modalities) and are safe.
---
## 5. Bang File Operations in Long-Running Code
**Status:** ✅ done (2026-05-10). Scoped `File.read!` / `File.write!` sites reachable from rebuild workers and LiveView events have been replaced with `{:ok, _} | {:error, _}` propagation.
**Scope audited:** `BDS.Posts.RebuildFromFiles`, `BDS.Media`, `BDS.MCP`, `BDS.Generation`, and the MCP settings UI config probe. Mandatory-config reads at boot remain out of scope.
**Rule:** long-running rebuild/sync/import paths and LiveView event-triggered config writes must not crash on expected filesystem read/write failures; return tagged errors instead.
---
## 6. Duplicate Helpers Across Contexts
**Status:** ✅ done (2026-05-10). Shared atom-or-string map helpers now live in `BDS.MapUtils`; shared two-arity progress callback extraction, count-based progress math, message shaping, scaling, phase reporting, and rebuild wrappers now live in `BDS.ProgressReporter`.
**Scope closed:** `Posts`, `Media`, `Search`, `Generation`, `Publishing`, `MCP`, plus the same rebuild helper copies in `Scripts`, `Templates`, and `Embeddings`. Domain modules may keep thin wrappers for readability, but progress behavior is parameterized through the shared reporter. Remaining helpers with similar names are intentionally different local UI/import trimming helpers or non-progress map utilities.
---
## 7. Direct `Repo.get` in `BDS.Desktop.ShellLive`
**Status:** ✅ done (2026-05-01). `Repo.get/2` and `Repo.get!/2` no longer appear in `BDS.Desktop.ShellLive` or its submodules. ShellLive entity reads now go through context APIs (`Posts.get_post/1`, `Posts.get_post!/1`, `Media.get_media/1`, `Templates.get_template/1`, `Scripts.get_script/1`, `AI.get_chat_conversation/1`, and `Settings.get_global_setting/1` / `put_global_setting/2`). Added a regression test that scans ShellLive source for direct `Repo.get` calls.
**Rule:** ShellLive modules may use `Repo` for query-shaped read models where no context abstraction exists yet, but direct primary-key entity fetches belong in context modules.
---
## 8. `String.to_existing_atom/1` + `rescue ArgumentError`
**Status:** ✅ done (2026-05-01). `String.to_existing_atom/1` call sites were replaced with explicit string→atom whitelists in `BDS.BoundedAtoms`. Session restore, LiveView view/tab/route parsing, panel tab parsing, shell command parsing, import sections, taxonomy types, AI endpoints, MCP agents, menu kinds, script kinds, and post/translation status parsing now all use bounded parsers with explicit fallbacks.
**Rule:** user/client/file-provided strings must not be converted with `String.to_existing_atom/1` plus rescue; add a bounded parser for the relevant enum instead.
---
## 9. `Jason.decode!/1` on External HTTP Responses
**Status:** ✅ done (2026-05-01). The 2 scoped HTTP response decodes in `BDS.AI.OpenAICompatibleRuntime` now use `Jason.decode/1` and return `{:error, %{kind: :invalid_json_response, reason: reason}}` for malformed model-list or chat-completion response bodies. The error shape propagates through endpoint model listing, chat, and one-shot orchestration via the existing `with` chains. Remaining `Jason.decode!/1` sites in `BDS.AI.Catalog` decode model-catalog HTTP/cache payloads or trusted on-disk JSON and are out of scope for this section.
**Rule:** OpenAI-compatible runtime HTTP responses must not be decoded with bang functions; malformed provider JSON is a recoverable runtime error.
---
## 10. Missing `@spec`
**Status:** ✅ done for core contexts (2026-04-30). Open for LiveView editor modules and the smaller contexts (`Tags`, `Templates`, `Scripts`, `PostLinks`).
**Convention reminder:** Ecto schemas need explicit `@type t`; use `term()` for associations; use `@typedoc` + named types for repeated map shapes; for attrs maps use `%{optional(atom()) => term(), optional(String.t()) => term()}`.
---
## 11. Raw SQL Outside FTS5
**Status:** ✅ done for the `post_media` table (2026-04-30, replaced by `BDS.Posts.PostMedia` schema). Remaining raw SQL is FTS5-virtual-table queries in `BDS.Search` only — keep them raw.
---
## 12. Atom/String Key Duality
**Status:** ✅ done (2026-05-01). Same-name atom/string boundary reads now use `BDS.MapUtils.attr/2` or `attr/3` instead of nested `Map.get/3` or `Map.get/2 || Map.get/2` fallbacks. The cleanup covers AI endpoint/chat/one-shot attrs, model capabilities, rendering assigns and list pagination/archive contexts, UI shortcut/filter params, metadata category settings, metadata-diff repair payloads, CLI sync payloads, chat tool calls, and duplicate/metadata-diff editor payloads.
**Rule:** atoms internally, strings only at JSON/HTTP/form/render boundaries. If a boundary must accept both atom and string keys for the same snake_case field, use `BDS.MapUtils.attr/2` or `attr/3`; do not open-code same-name dual-key reads.
---
## 13. `BDS.Tasks` Memory Growth
**Status:** ✅ done (2026-05-01). `BDS.Tasks` now evicts terminal tasks (`:completed`/`:failed`/`:cancelled`) after a configurable TTL (`:finished_task_ttl_ms`, default 1 h). Eviction is triggered by the GenServer after a task finishes and also pruned on read calls (`get_task`, `status_snapshot`, `list_tasks`, `list_running_tasks`), while pending/running tasks remain retained. The UI snapshot still separately limits recently finished entries with `recent_finished_limit`.
---
## 14. Synchronous Tests
**Status:** intentional, no further action.
Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search`, `BDS.AI.Catalog`, `BDS.Embeddings`). `async: false` is correct. Pure-logic tests (parsers, formatters) could opt into `async: true` opportunistically.
---
## What the Project Does Well
- Clear context separation (`Posts`, `Media`, `Projects`, `Search`, `AI`, `Generation`).
- Consistent Ecto changesets at every write boundary.
- Idiomatic `with` blocks for multi-step error flows.
- `Task.Supervisor` used correctly for background work.
- Submodule extraction pattern proven across 5 large modules without breaking public API contracts.
---
## Changelog
### 2026-05-01
- **`BDS.Tasks` memory growth**: added configurable TTL eviction for terminal tasks in the `BDS.Tasks` GenServer (`:finished_task_ttl_ms`, default 1 h). Finished tasks are pruned by delayed GenServer cleanup and on task read calls; active tasks are preserved. Added regression coverage that completes a task with a tiny TTL and verifies it disappears without `clear_finished/0` while a running task remains. Section 13 is closed.
- **Atom/string key duality**: added `BDS.MapUtils.attr/3` and a regression test that scans `lib/**/*.ex` and `lib/**/*.heex` for same-name atom/string `Map.get` fallback reads. Replaced same-name atom/string boundary reads across AI attrs, rendering assigns, pagination/archive contexts, UI command/filter params, metadata category settings, metadata-diff repair payloads, CLI sync payloads, chat tool call normalization, and misc editor duplicate/metadata-diff payload rendering. Remaining mixed-key scan hits are intentionally different-key fallbacks (for example camelCase/snake_case JSON compatibility) or atom-only/string-only boundaries. Section 12 is closed.
- **`Jason.decode!/1` on external HTTP responses**: replaced the 2 scoped OpenAI-compatible runtime response decodes with `Jason.decode/1` and tagged `{:error, %{kind: :invalid_json_response, reason: reason}}` propagation for malformed `/models` and `/chat/completions` bodies. Added regressions covering endpoint model listing through a fake HTTP client and generation through a local Bandit server. Section 9 is closed.
### 2026-05-10
- **Duplicate helpers across contexts**: added `BDS.MapUtils` (`attr/2`, `maybe_put/3`, `blank_to_nil/1`) and expanded `BDS.ProgressReporter` (`callback/1`, `scaled/3`, `report_count_started/4`, `report_count_progress/5`, `report_rebuild_started/3`, `report_rebuild_progress/4`, `report_phase/3`). Replaced copy-pasted helpers in posts, post translations, post rebuild, media file ops, search, generation progress, maintenance progress, embeddings/index progress, publishing, MCP util, scripts, and templates. Domain-specific modules now express their wording/ranges as options to the shared reporter, preserving existing user-facing progress messages while sharing one implementation. Added focused tests for both shared modules. Section 6 is closed.
- **Bang file operations in long-running code**: `BDS.Media.Sidecars.parse_canonical_sidecar/2` and `parse_translation_sidecar/1` now use `File.read/1` and return `{:ok, sidecar}` or `{:error, {:read_sidecar, path, reason}}` instead of raising. Media rebuild collects parsed sidecars and returns the first sidecar read error before mutating rows; media sync/import sidecar entrypoints propagate the same errors. Added regression coverage for an unreadable `.meta` sidecar directory preserving the worker instead of crashing it.
- **Bang file operations in long-running code**: `BDS.Posts.RebuildFromFiles.parse_rebuild_file/2` now uses `File.read/1` and returns `{:ok, rebuild_file}` or `{:error, {:read_rebuild_file, path, reason}}`; post rebuild/import/sync-from-file callers propagate the tagged error. `BDS.Generation.apply_validation/2` now hashes existing generated files with `File.read/1` and returns `{:error, {:read_generated_file, path, reason}}` on read failures. `BDS.MCP.AgentConfig` now uses `File.mkdir_p/1`, `File.read/1`, `Jason.decode/1`, and `File.write/2`, returning tagged config read/write/create/decode errors instead of raising; the settings editor reports those errors through its existing output surface and its config probe no longer uses bang reads. Added regressions for unreadable post files, unreadable generated files, unreadable MCP config, and unwritable MCP config. Section 5 is closed.
- **Side effects in transactions**: `BDS.Metadata.update_project_metadata/2`, `sync_project_metadata_from_filesystem/1`, and the shared category/publishing `update_state` path now keep only project/settings row changes inside `Repo.transaction/1`. Metadata JSON files are flushed after commit and `Persistence.atomic_write/2` now returns `{:error, reason}` for directory-creation failures instead of raising. Added regression coverage for a failed metadata filesystem flush preserving the committed project/settings changes.
- **Side effects in transactions**: `BDS.Tags.sync_tags_from_posts/1`, `delete_tag/1`, `rename_tag/2`, and `merge_tags/2` now commit tag/post-tag DB changes before published-post rewrites and `meta/tags.json` flushes. `BDS.Projects.ensure_default_project/0` and `create_project/1` now commit the project row before rebuilding templates from filesystem files. Added regressions for failed tag JSON flushes and failed template rebuilds preserving committed DB changes. Finished the explicit `Repo.transaction/1` audit: remaining transactions are DB-only or already defer filesystem cleanup until after commit; `BDS.Posts`, `BDS.Publishing`, and `BDS.Generation` have no explicit transaction sites.
- **Process dictionary for i18n state (Section 2)**: encapsulated behind `BDS.Desktop.UILocale` (`lib/bds/desktop/ui_locale.ex`, ~50 lines). Public surface: `put/1` (set without restore, for LV render boundaries that return lazy `Rendered`), `with_locale/2` (set + try/after restore, for short-lived eager contexts), `current/0` (read, returns `nil` when unset). The two raw `Process.put(:bds_ui_locale, _)` sites (`BDS.Desktop.ShellLive.render/1` and `BDS.Desktop.ShellLive.SidebarComponents.sidebar_content/1`) now call `UILocale.put/1`; the ~30 raw `Process.get(:bds_ui_locale)` reads (every editor `translated/1,2` helper plus `BDS.Desktop.ShellData.effective_ui_language/1`) now call `BDS.Desktop.UILocale.current/0`. The full thread-locale-through-assigns rewrite (~733 HEEx call sites) was deliberately rejected as too invasive; the encapsulation removes the implicit-global smell while preserving Phoenix's lazy `Rendered` evaluation. The render path uses `put/1` (not `with_locale/2`) because Phoenix `~H` returns a `Rendered` whose `dynamic` is invoked by LiveView *after* `render/1` returns; a `try/after Process.delete` would clear the binding before child components materialize. Only `BDS.Desktop.UILocale` is allowed to touch the `:bds_ui_locale` process key. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (Total errors: 0), `mix test` (342 tests, 0 failures, 4 skipped, three consecutive runs).
### 2026-05-09
- **God modules**:
- `BDS.MCP` 677 → 27 (96 %). Submodules under `lib/bds/mcp/`: `Util` (36, sanitize/1 + normalize_term/1 + maybe_put/3 + map_get/3), `Queries` (201, page_size/0 + active_project!/0 + post_summary/1 + post_detail/1 + translated_post_detail/2 + search_filters/1 + parse_status/1 + group_rows/2 + private group_values/2 (5 clauses) + available_languages/2 + linked_posts/2 + private load_linked_post/1 + post_body/1 + translation_body/1 + tag_post_count/2 + category_post_count/2), `Tools` (394, list/0 + call/2 + validate_template/1 + private check_term/1 + search_posts/1 + count_posts/1 + read_post_by_slug/1 + draft_post/1 + propose_script/1 + propose_template/1 + propose_media_metadata/1 + propose_post_metadata/1 + accept_proposal/1 + discard_proposal/1 + script_validation/1 + parse_script_kind/1 + parse_template_kind/1 + tool/2), `Resources` (112, list/0 + read/1 + private posts_resource/1 + media_resource/1 + tags_resource/0 + categories_resource/0 + read_post_resource/1 + read_media_resource/1). Coordinator `BDS.MCP` keeps the 5 public entrypoints (`list_tools/0`, `list_resources/0`, `call_tool/2`, `read_resource/1`, `validate_template/1`) as `defdelegate` to `Tools`/`Resources` and re-exports the `tool_descriptor`/`resource_descriptor` types as aliases of `Tools.descriptor`/`Resources.descriptor`. Cross-submodule deps are linear and acyclic: `Tools``Queries` + `Util` + `ProposalStore`; `Resources``Queries` + `Util` + `ProposalStore`; `Queries``Util`; `Util` is a leaf. Existing siblings (`ProposalStore`, `Proposal`, `AgentConfig`, `Server`, `Stdio`) untouched. The pre-existing `BDS.MCP.Server`/`Stdio` callers (`call_tool` / `list_tools` / `list_resources` / `read_resource`) keep using the coordinator unchanged. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (Total errors: 0), `mix test` (342 tests, 0 failures, 4 skipped).
### 2026-05-08
- **God modules**:
- `BDS.Desktop.ShellLive.ChatEditor` 972 → 576 (41 %). Submodules under `lib/bds/desktop/shell_live/chat_editor/`: `ToolSurfaces` (274, build_render_surfaces + build_render_surface + do_build_render_surface (8 clauses) + build_tab_surface + decode_surface_actions/options + normalize_tool_surface + map_value + numeric_value + stringify_list + render_tool? + @render_tool_names), `ToolTracking` (101, tool_call_name + tool_call_arguments + normalize_tool_calls + tool_arguments_preview + mark_tool_call_completed + tool_markers_from_events + mark_last_matching_complete + preview_value + @tool_args_max_length), `MessageBuild` (118, build/1,2 + build_entries + finalize_entry + start_entry + append_tool_surface + pending_user_message + streaming_content), `ModelSelection` (80, toggle_model_selector + set_model + group_available_models + needs_api_key? + provider_group_label + blank?). Coordinator keeps the 3 HEEx components (chat_editor, chat_tool_markers, chat_surface), the 13 public event handlers (assign_socket, update_input, update_surface_form, select_surface_tab, current_surface_data, set_action_error, clear_action_error, send_message, abort_message, note_tool_call, note_tool_result, note_streaming_content, finish_request), the HEEx-callable helpers (markdown_html, message_role_label, payload_json, chart_width, truthy?, tool_surface_type, translated/1,2), private helpers (update_request, allow_repo_sandbox, rewrite_external_images, external_image_link, surface_input_type, present?, format_error), and `defdelegate` entries for `build/1`, `toggle_model_selector/2`, `set_model/4`, `tool_call_name/1`, `tool_call_arguments/1`. Cross-submodule deps are linear: MessageBuild → ModelSelection + ToolSurfaces + ToolTracking; ToolSurfaces, ToolTracking, ModelSelection are leaves. Each submodule that needs it duplicates the small `translated/2` and `blank?/1` helpers locally per the established convention; ToolSurfaces also duplicates `truthy?/1` privately. ModelSelection uses `Phoenix.Component.assign/3` via `import only:`. The 400-line target was not reachable while keeping all 3 HEEx components in the coordinator (the chat_surface component alone is ~200 lines). Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (Total errors: 0), `mix test` (342 tests, 0 failures, 4 skipped).
### 2026-05-07
- **God modules**:
- `BDS.Desktop.ShellLive.SettingsEditor` 872 → 226 (74 %). Submodules under `lib/bds/desktop/shell_live/settings_editor/`: `StyleEditor` (103, build_style + select_style_theme + change_style_preview_mode + apply_style_theme + theme_display_name + current_theme + style_theme + @themes), `MCPConfig` (100, mcp_rows + toggle_mcp_agent + find_mcp_agent + mcp_configured? + mcp_config_path + mcp_server_present? + @mcp_agents), `PublishingSettings` (89, publishing_form + update_publishing_draft + save_publishing + clear_publishing + publishing_attrs + normalize_publishing_params), `EditorSettings` (93, editor_form + update_editor_draft + save_editor + get_global_setting + put_global_setting + editor_attrs + normalize_editor_params + boolean_string), `ProjectSettings` (112, project_metadata + project_form + technology_form + update_project_draft + save_project + project_attrs + normalize_project_params), `ManagedCategories` (194, protected_categories + protected_category? + category_rows + update_new_category + add_category + reset_categories + save_category + remove_category + ensure_default_categories + reset_default_category_settings + @protected_categories + @default_category_settings), `AISettings` (203, ai_form + endpoint_model_options + update_ai_draft + refresh_ai_models + save_ai + reset_ai_prompt + ai_attrs + normalize_ai_params + get_model_preference + maybe_put_model_preference + put_endpoint_preferences + endpoint_refresh_attrs + normalize_endpoint_result). Coordinator keeps `embed_templates "settings_editor_html/*"` (HEEx components `settings_editor` + `style_editor`), `assign_socket/1`, `update_search/3`, the `build_settings/1` aggregator, the `current_settings_section`/`current_tab_meta`/`visible_settings_sections`/`section_matches?`/`template_options` helpers, the `translated/1,2` HEEx render-time helper, and `defdelegate` entries for the 21 public event handlers + the HEEx-callable `theme_display_name/1` (delegated to `StyleEditor`) and `protected_category?/1` (delegated to `ManagedCategories`). Cross-submodule deps: `AISettings` calls `EditorSettings.{get_global_setting/1, put_global_setting/2}` (the only intra-submodule dependency); all other submodules are leaves. Each submodule duplicates the small `translated/2`, `truthy?/1`, `blank_to_nil/1`, `parse_integer/2`, `boolean_string/1` helpers locally per the established convention. Submodules use `Phoenix.Component.assign/3` directly via `use Phoenix.Component`. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (Total errors: 0), `mix test` (342 tests, 0 failures, 4 skipped).
### 2026-05-06
- **God modules**:
- `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %). Submodules under `lib/bds/desktop/shell_live/post_editor/`: `PostMetadata` (190, project_metadata + canonical_language + translations + languages + template_options + linked_media + post_links + translation_flags + footer + format_timestamp + display_title + gallery_count + preview_url + canonical_preview_path + pad2 + maybe_put_query + truthy? + blank? + blank_to_nil), `ListValues` (125, field_key + tag_values/category_values + tag_suggestions/category_suggestions + filter_suggestions + tag_chips + query_addable? + normalize_query + normalize_list_entry + ensure_list_value + csv_to_list + tag_chip_style + normalize_color + contrast_color + ai_overlay_fields), `DraftManagement` (183, normalize_mode + normalize_language + normalize_params + current_draft + persisted_form/3,4 + maybe_update_draft + put_draft_field + put_query_state + query_value + query_key + maybe_drop_old_language_draft + toggled_sections + put_nested_map + delete_nested_map + reload_with_assigned_workbench + save_state_for_action + record_title + record_status + editing_canonical_language?), `Persistence` (105, persist + discard + has_published_version? + discard_label + discard_title + save_canonical_draft + save_translation_draft + maybe_publish_post + maybe_publish_translation). Coordinator keeps the 16 public event handlers (assign_socket, update, persist_socket, discard_socket, delete_socket, set_mode, toggle_section, select_language, toggle_quick_actions, detect_language, translate, apply_ai_suggestions, insert_content, add_list_value, remove_list_value), build/1,2, and the HEEx-callable helpers (`translated/1,2`, `post_status_label/1`, `post_editor_save_state_label/1`, `post_editor_mode_label/1`); `tag_chip_style/1` is exposed via `defdelegate` so HEEx call sites stay unchanged. Cross-submodule deps form a runtime cycle between PostMetadata.canonical_language → DraftManagement.normalize_language and DraftManagement.persisted_form → PostMetadata.translations/canonical_language (compile-safe, no compile cycle). Persistence → DraftManagement + PostMetadata; ListValues is a leaf. Each submodule that needs it duplicates the small `translated/2`, `blank_to_nil/1`, `csv_to_list/1` helpers locally per the established convention. Submodules use `Phoenix.Component.assign/3` directly (only DraftManagement needs it). The 400-line target was not reachable while keeping all 16 public event handlers + build + HEEx helpers in the coordinator. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (0 errors), `mix test` (342 tests, 0 failures, 4 skipped).
### 2026-05-05
- **God modules**:
- `BDS.Desktop.ShellLive.MenuEditor` 871 → 335 (62 %). Submodules under `lib/bds/desktop/shell_live/menu_editor/`: `TreeOps` (296, home_item/home_item_id + ui_item + persisted_item + first_item_id + insert_target + path_prefix? + find_path + item_at_path + items_at_path + replace_items_at_path + update_item + insert_item + remove_item + remove_item_with_value + append_child + move_selected + indent_selected + unindent_selected + delete_selected + drop_selected + insert_dropped_item), `TreePredicates` (55, can_move_up?/can_move_down?/can_indent?/can_unindent?/can_delete? + draft_item?), `DraftManagement` (132, current_draft + start_page_draft + start_category_draft + finalize_submenu_draft + assign_page_to_draft + assign_category_to_draft + cancel_draft + confirm_category_draft), `PageCategory` (58, page_posts + page_post + filter_page_posts + category_options + filter_categories + blank_to_nil), `State` (85, ensure_state + update_state + build + save + load_state). Coordinator keeps the 11 public event handlers (assign_socket, select_item, change_entry, submit_entry, cancel_entry, select_page, select_category, toolbar_action, drop_item, handle_keydown), the 3 HEEx components (menu_editor, menu_tree_level, kind_icon), and the render-time helpers (translated, row_label, kind_label, editing_title/hint/placeholder); `draft_item?/2` is exposed via `defdelegate` so HEEx call sites stay unchanged. Cross-submodule deps are linear: State → PageCategory + TreeOps + TreePredicates; DraftManagement → PageCategory + TreeOps; TreePredicates → TreeOps; PageCategory and TreeOps are leaves. `confirm_category_draft/2` takes the State.update_state function as an argument to avoid a cycle. Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (0 errors), `mix test` (342 tests, 0 failures, 4 skipped).
### 2026-05-04
- **God modules**:
- `BDS.Rendering` 838 → 33 (96 %). Submodules under `lib/bds/rendering/`: `LinksAndLanguages` (131, canonical_post_path_by_slug + canonical_media_path_by_source_path + post_path/2,3 + link_contexts/4 + link_context + language_prefix + normalize_language), `TemplateSelection` (153, load_template_source + select_template + published_template_body + render_template + load_bundled_template_source + maybe_load_bundled_template_source + bundled_template_slug + template_render_context), `Metadata` (113, project_metadata + menu_items + to_template_menu_item + menu_item_href + blog_languages + tag_color_by_name + alternate_links + backlinks + default_pico_stylesheet_href + href_for_language + calendar_initial_year + calendar_initial_month), `PostRendering` (167, post_assigns + load_post_record + canonical_post_record + canonical_post_id + post_data_json + post_data_json_value + build_post_context + render_post_content), `ListArchive` (295, list_assigns + not_found_assigns + normalize_list_posts + normalize_pagination + normalize_archive_context + build_day_blocks + min_date + max_date + show_archive_range_heading? + calendar_initial_year_from_posts + calendar_initial_month_from_posts). Coordinator now contains only the 3 public renders (`render_post_page/3`, `render_list_page/2`, `render_not_found_page/1,2`) which delegate to `TemplateSelection.load_template_source`, `TemplateSelection.render_template`, and the appropriate assigns builder (`PostRendering.post_assigns`, `ListArchive.list_assigns`, `ListArchive.not_found_assigns`). Cross-submodule deps are linear: ListArchive → PostRendering + Metadata + TemplateSelection + LinksAndLanguages; PostRendering → Metadata + TemplateSelection + LinksAndLanguages; Metadata → LinksAndLanguages; TemplateSelection + LinksAndLanguages have no internal deps.
### 2026-05-03
- **God modules**:
- `BDS.Desktop.ShellLive.ImportEditor` 1436 → 776 (46 %). Submodules under `lib/bds/desktop/shell_live/import_editor/`: `ConflictResolution` (50, change_conflict_resolution + update_conflict_resolution + update_conflict_bucket), `TaxonomyEditing` (206, start/cancel/save/clear_taxonomy_edit + analyze_taxonomy_ai + update_taxonomy_mapping + rebuild_taxonomy_stats + stat_key + apply_taxonomy_mappings + apply_taxonomy_mapping_bucket + existing_taxonomy_terms + normalize_taxonomy_mapping_value + auto_mapped_count + taxonomy_pill_class + taxonomy_item_editing? + taxonomy_mapping_tooltip + maybe_put_option), `AnalysisState` (248, change_definition + select_uploads_folder + select_and_analyze + note_analysis_progress + finish_analysis + handle_analysis_task_down + importable_counts + importable_entity_count + detail_items + default_analysis_state + default_sections + default_author + suggested_definition_name + maybe_put + allow_repo_sandbox + translate_phase), `ProgressTracking` (246, execute_import + note_execution_progress + finish_execution + handle_task_down + default_execution_state + execution_progress_width + decompose_progress_detail + to_string_or_nil + format_eta + translate_execution_phase). Components (`import_editor`, `conflict_section`, `post_detail_section`, `media_detail_section`, `stat_card`, `other_stat_card`, `media_stat_card`, `taxonomy_stat_card`, `taxonomy_group`) stay in main file (587 lines of HEEx); main also keeps `assign_socket/1`, `toggle_section/3`, `toggle_model_selector/2`, `select_ai_model/3`, and the small `selected_model`/`selected_model_label`/`preferred_model` helpers tied to `assign_socket`. Public API preserved via `defdelegate` for the 14 event handlers called from `BDS.Desktop.ShellLive`. ProgressTracking calls back into AnalysisState for `default_author/1`, `importable_counts/1`, `allow_repo_sandbox/1`, and the `:analysis` branch of `handle_task_down/6`. The 600-line target was not reachable while keeping all 9 components in the main file (components alone are 587 lines).
### 2026-05-02
### 2026-05-02
- **God modules**:
- `BDS.Media` 993 → 324 (67 %). Submodules under `lib/bds/media/`: `FileOps` (150, attr/maybe_put/blank_to_nil/atomic_write/delete_file_if_present/list_matching_files/media_file_path/detect_mime/image_dimensions/image_mime?/progress callbacks), `Thumbnails` (165, thumbnail_paths/regenerate_thumbnails/regenerate_missing_thumbnails/ensure_thumbnails/delete_thumbnail_files + private render/write helpers), `Sidecars` (329, write_sidecar/write_translation_sidecar/parse_canonical_sidecar/parse_translation_sidecar/upsert_media_from_sidecar/upsert_translation_from_sidecar + sync/import-orphan public API + translation_sidecar_path/canonical_sidecar?/translation_sidecar?/binary_path_for_translation_sidecar/binary_exists_for_sidecar?), `Linking` (125, list_linked_posts/link_media_to_post/unlink_media_from_post/linked_post_ids), `Rebuilder` (82, rebuild_media_from_files/2). Public API preserved via `defdelegate`; coordinator keeps import_media/update_media/delete_media/upsert_media_translation/delete_media_translation/replace_media_file/list_media_translations and uses `import only:` for shared helpers.
### 2026-05-01
- **`String.to_existing_atom/1` + rescue**: added `BDS.BoundedAtoms` as the shared bounded string→atom parser for sidebar views, editor routes, panel tabs, post statuses, translation statuses, script/template/menu kinds, import sections, taxonomy types, AI endpoints, MCP agents, and shell commands. Replaced every `String.to_existing_atom/1` call site and removed the `safe_existing_atom` rescue helpers. Added regression coverage that scans `lib/**/*.ex` to prevent reintroducing `String.to_existing_atom/1` outside the bounded parser module. Section 8 is closed.
- **Direct `Repo.get` in `BDS.Desktop.ShellLive`**: added context helpers for primary-key reads (`Posts.get_post/1`, `Media.get_media/1`, `Templates.get_template/1`, `Scripts.get_script/1`, `AI.get_chat_conversation/1`) and introduced `BDS.Settings` for global editor settings. Replaced all ShellLive `Repo.get/2` and `Repo.get!/2` calls across the main shell, tab helpers, CLI sync, panel renderer, chat message build, code entity editor, post editor, media editor, overlay components, post metadata, and settings editor. Added a ShellLive regression test that scans source files to keep direct `Repo.get` calls out. Section 7 is closed.
- **God modules**:
- `BDS.Maintenance` 810 → 141 (83 %). Submodules under `lib/bds/maintenance/`: `Progress` (45), `FileScan` (158), `DiffComputation` (93), `DiffReports` (315), `Repair` (145). Coordinator keeps the 4 public entrypoints (`repair_metadata_diff/4`, `import_metadata_diff_orphans/3`, `rebuild_from_filesystem/3`, `metadata_diff/2`); submodules wired via `import only:`.
- `BDS.Scripting.Capabilities` 1715 → 194 (89 %). Submodules: `Util` (301), `Posts` (270), `Media` (254), `Crud` (284), `Projects` (204), `AppShell` (134), `Bridges` (176). Public `for_project/2` preserved.
- Fixed real race in `test/bds/desktop/shell_live_test.exs:1149` (metadata-diff editor open) — was diagnosed as flake but was a missing `completed_task!(task.id)` synchronization between the worker `:DOWN` and the next `:refresh_task_status` tick.
### 2026-04-30
- **God modules**:
- `BDS.AI` 1711 → 168 (90 %). Submodules: `Chat` (597), `OneShot` (382), `Catalog` (306), `ChatTools` (271), `Runtime` (100), `SettingsStore` (78). Public API preserved via `defdelegate`.
- `BDS.Posts` 1781 → 569 (68 %). Submodules: `Slugs` (86), `AutoTranslation` (176), `FileSync` (146), `TranslationValidation` (464), `RebuildFromFiles` (320), `Translations` (279). Public API preserved via `defdelegate`.
- `BDS.Generation` 2651 → 647 (76 %). Submodules: `Outputs` (490), `Validation` (445), `Data` (352), `Sitemap` (280), `Paths` (262), `Renderers` (227), `Progress` (96), `Pagefind` (70), `GeneratedFileHash` (23). `import only:` used to keep call sites unchanged.
- `BDS.Desktop.ShellLive` 2607 → 1545 (41 %). Submodules: `TitlebarMenu`, `CliSync`, `PanelRenderer`, `TabHelpers`, `TaskLocalization`, `ChatSurface`, `SidebarCreate`, `Layout`, `ShellCommandRunner`, `SessionUtil`. Coordinator now holds only `mount/3`, `render/1`, `handle_event/3`, `handle_info/2`.
- Side fix: `test/bds/maintenance_test.exs` had hardcoded `posts/2026/04/...` paths; added explicit `File.mkdir_p!` calls for the orphan-file fixtures.
- **Side effects in transactions** (`BDS.Media`): every `Repo.transaction/1` block in [lib/bds/media.ex](lib/bds/media.ex) refactored — DB writes inside, filesystem + `Search.sync_*` after commit. Functions touched: `import_media/1`, `update_media/2`, `upsert_media_translation/3`, `delete_media_translation/2`, `replace_media_file/2`, `link_media_to_post/2`, `unlink_media_from_post/2`. Spec correction: `delete_media_translation/2` returns `{:ok, boolean()} | {:error, …}`.
- **`String.to_atom` DoS**: `BDS.MCP.atomize_keys/1` deleted; the two `accept_proposal` sites pass string-keyed maps directly to `Media.update_media/2` and `Posts.update_post/2`, both of which already accept `%{optional(atom()) => term(), optional(String.t()) => term()}`.
- **Raw SQL on `post_media`**: introduced `BDS.Posts.PostMedia` schema with `unique_constraint([:post_id, :media_id])`; migrated 8 raw-query sites in `BDS.Media`, `BDS.Posts`, `BDS.Desktop.ShellLive.PostEditor`, `BDS.Desktop.ShellLive.OverlayComponents` to typed Ecto queries.
- **`@spec` for core contexts**: added specs and `@type t` to `BDS.Projects`, `BDS.Posts`, `BDS.Media`, `BDS.Search`, `BDS.Publishing`, `BDS.Generation`, `BDS.Metadata`, `BDS.MCP`, `BDS.AI` and their schemas. Bugs surfaced: `Search.list_stemmer_languages/0` return shape, `Media.sync_media_sidecar/1` returns `:ok`, `Media.replace_media_file/2` can return `{:ok, nil}`, removed unreachable fall-through clauses in `BDS.Posts` auto-translate cascades and `BDS.Desktop.ShellLive.ChatEditor.blank?/1`.
**Validation gate after every change:** `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342 tests / 0 failures / 4 skipped.

View File

@@ -17,7 +17,9 @@ defmodule BDS.AI.CatalogProvider do
def changeset(provider, attrs) do def changeset(provider, attrs) do
provider provider
|> cast(attrs, [:id, :name, :env_keys, :package_ref, :api_url, :doc_url, :updated_at], empty_values: [nil]) |> cast(attrs, [:id, :name, :env_keys, :package_ref, :api_url, :doc_url, :updated_at],
empty_values: [nil]
)
|> validate_required([:id, :name, :updated_at]) |> validate_required([:id, :name, :updated_at])
end end
end end

View File

@@ -25,7 +25,9 @@ defmodule BDS.AI.ChatConversation do
def changeset(conversation, attrs) do def changeset(conversation, attrs) do
conversation conversation
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at], empty_values: [nil]) |> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at],
empty_values: [nil]
)
|> validate_required([:id, :title, :created_at, :updated_at]) |> validate_required([:id, :title, :created_at, :updated_at])
end end
end end

View File

@@ -23,7 +23,9 @@ defmodule BDS.AI.ChatMessage do
def changeset(message, attrs) do def changeset(message, attrs) do
message message
|> cast(attrs, [ |> cast(
attrs,
[
:conversation_id, :conversation_id,
:role, :role,
:content, :content,

View File

@@ -14,8 +14,10 @@ defmodule BDS.AI.ChatTools do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
%{ %{
post_count: Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id), post_count:
media_count: Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id), Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id),
media_count:
Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id),
tag_count: Chat.count_distinct_string_list(Post, :tags, project_id), tag_count: Chat.count_distinct_string_list(Post, :tags, project_id),
category_count: Chat.count_distinct_string_list(Post, :categories, project_id) category_count: Chat.count_distinct_string_list(Post, :categories, project_id)
} }
@@ -132,9 +134,28 @@ defmodule BDS.AI.ChatTools do
project_tools = project_tools =
if is_binary(project_id) do if is_binary(project_id) do
[ [
%{name: "blog_stats", spec: tool_spec("blog_stats", "Return aggregate blog statistics", %{"type" => "object", "properties" => %{}})}, %{
%{name: "list_posts", spec: tool_spec("list_posts", "List recent posts in the active project", limit_schema())}, name: "blog_stats",
%{name: "list_media", spec: tool_spec("list_media", "List recent media items in the active project", limit_schema())} spec:
tool_spec("blog_stats", "Return aggregate blog statistics", %{
"type" => "object",
"properties" => %{}
})
},
%{
name: "list_posts",
spec:
tool_spec("list_posts", "List recent posts in the active project", limit_schema())
},
%{
name: "list_media",
spec:
tool_spec(
"list_media",
"List recent media items in the active project",
limit_schema()
)
}
] ]
else else
[] []
@@ -142,14 +163,62 @@ defmodule BDS.AI.ChatTools do
project_tools ++ project_tools ++
[ [
%{name: "render_card", spec: tool_spec("render_card", "Return a structured card payload", render_card_schema())}, %{
%{name: "render_table", spec: tool_spec("render_table", "Return a structured table payload", render_table_schema())}, name: "render_card",
%{name: "render_chart", spec: tool_spec("render_chart", "Return a structured chart payload", render_chart_schema())}, spec:
%{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())}, tool_spec("render_card", "Return a structured card payload", render_card_schema())
%{name: "render_metric", spec: tool_spec("render_metric", "Return a structured metric payload", render_metric_schema())}, },
%{name: "render_list", spec: tool_spec("render_list", "Return a structured list payload", render_list_schema())}, %{
%{name: "render_tabs", spec: tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())}, name: "render_table",
%{name: "render_mindmap", spec: tool_spec("render_mindmap", "Return a structured mindmap payload", render_mindmap_schema())} spec:
tool_spec(
"render_table",
"Return a structured table payload",
render_table_schema()
)
},
%{
name: "render_chart",
spec:
tool_spec(
"render_chart",
"Return a structured chart payload",
render_chart_schema()
)
},
%{
name: "render_form",
spec:
tool_spec("render_form", "Return a structured form payload", render_form_schema())
},
%{
name: "render_metric",
spec:
tool_spec(
"render_metric",
"Return a structured metric payload",
render_metric_schema()
)
},
%{
name: "render_list",
spec:
tool_spec("render_list", "Return a structured list payload", render_list_schema())
},
%{
name: "render_tabs",
spec:
tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())
},
%{
name: "render_mindmap",
spec:
tool_spec(
"render_mindmap",
"Return a structured mindmap payload",
render_mindmap_schema()
)
}
] ]
else else
[] []

View File

@@ -2,7 +2,11 @@ defmodule BDS.AI.HttpClient do
@moduledoc false @moduledoc false
def get(url, headers) when is_binary(url) and is_map(headers) do def get(url, headers) when is_binary(url) and is_map(headers) do
request = {String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end)} request =
{String.to_charlist(url),
Enum.map(headers, fn {key, value} ->
{String.to_charlist(key), String.to_charlist(value)}
end)}
:inets.start() :inets.start()
:ssl.start() :ssl.start()
@@ -24,7 +28,10 @@ defmodule BDS.AI.HttpClient do
def post(url, headers, body) def post(url, headers, body)
when is_binary(url) and is_map(headers) and is_binary(body) do when is_binary(url) and is_map(headers) and is_binary(body) do
request = request =
{String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end), ~c"application/json", body} {String.to_charlist(url),
Enum.map(headers, fn {key, value} ->
{String.to_charlist(key), String.to_charlist(value)}
end), ~c"application/json", body}
:inets.start() :inets.start()
:ssl.start() :ssl.start()

View File

@@ -34,7 +34,9 @@ defmodule BDS.AI.Model do
def changeset(model, attrs) do def changeset(model, attrs) do
model model
|> cast(attrs, [ |> cast(
attrs,
[
:provider, :provider,
:model_id, :model_id,
:name, :name,
@@ -59,6 +61,14 @@ defmodule BDS.AI.Model do
:status, :status,
:updated_at :updated_at
], empty_values: [nil]) ], empty_values: [nil])
|> validate_required([:provider, :model_id, :name, :context_window, :max_input_tokens, :max_output_tokens, :updated_at]) |> validate_required([
:provider,
:model_id,
:name,
:context_window,
:max_input_tokens,
:max_output_tokens,
:updated_at
])
end end
end end

View File

@@ -30,7 +30,8 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
} }
|> maybe_put_auth(endpoint.api_key) |> maybe_put_auth(endpoint.api_key)
payload = %{ payload =
%{
"model" => request.model, "model" => request.model,
"messages" => request.messages, "messages" => request.messages,
"max_tokens" => request.max_output_tokens "max_tokens" => request.max_output_tokens
@@ -55,7 +56,9 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
json = json =
case content do case content do
nil -> nil nil ->
nil
value when is_binary(value) -> value when is_binary(value) ->
case Jason.decode(value) do case Jason.decode(value) do
{:ok, decoded} when is_map(decoded) -> decoded {:ok, decoded} when is_map(decoded) -> decoded
@@ -77,10 +80,17 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
defp models_url(url) do defp models_url(url) do
cond do cond do
String.ends_with?(url, "/chat/completions") -> String.replace_suffix(url, "/chat/completions", "/models") String.ends_with?(url, "/chat/completions") ->
String.ends_with?(url, "/models") -> url String.replace_suffix(url, "/chat/completions", "/models")
String.ends_with?(url, "/") -> url <> "models"
true -> url <> "/models" String.ends_with?(url, "/models") ->
url
String.ends_with?(url, "/") ->
url <> "models"
true ->
url <> "/models"
end end
end end
@@ -114,7 +124,9 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
defp maybe_put_auth(headers, nil), do: headers defp maybe_put_auth(headers, nil), do: headers
defp maybe_put_auth(headers, ""), do: headers defp maybe_put_auth(headers, ""), do: headers
defp maybe_put_auth(headers, api_key), do: Map.put(headers, "authorization", "Bearer #{api_key}")
defp maybe_put_auth(headers, api_key),
do: Map.put(headers, "authorization", "Bearer #{api_key}")
defp maybe_put_tools(payload, []), do: payload defp maybe_put_tools(payload, []), do: payload
defp maybe_put_tools(payload, nil), do: payload defp maybe_put_tools(payload, nil), do: payload

View File

@@ -65,7 +65,9 @@ defmodule BDS.AI.Runtime do
end end
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do
{:ok, Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) || endpoint.model} {:ok,
Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) ||
endpoint.model}
end end
defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do
@@ -83,7 +85,8 @@ defmodule BDS.AI.Runtime do
defp fetch_endpoint_for_mode(mode, secret_backend) do defp fetch_endpoint_for_mode(mode, secret_backend) do
with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do
case endpoint do case endpoint do
%{url: url, model: model} = loaded when is_binary(url) and url != "" and is_binary(model) and model != "" -> %{url: url, model: model} = loaded
when is_binary(url) and url != "" and is_binary(model) and model != "" ->
if mode == :online and blank?(loaded.api_key) do if mode == :online and blank?(loaded.api_key) do
{:error, %{kind: :endpoint_not_configured, endpoint: mode}} {:error, %{kind: :endpoint_not_configured, endpoint: mode}}
else else

View File

@@ -17,7 +17,15 @@ defmodule BDS.AI.SecretBackend do
with {:ok, binary} <- Base.decode64(encoded), with {:ok, binary} <- Base.decode64(encoded),
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary, <<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
plaintext when is_binary(plaintext) <- plaintext when is_binary(plaintext) <-
:crypto.crypto_one_time_aead(:aes_256_gcm, secret_key(), iv, ciphertext, @aad, tag, false) do :crypto.crypto_one_time_aead(
:aes_256_gcm,
secret_key(),
iv,
ciphertext,
@aad,
tag,
false
) do
{:ok, plaintext} {:ok, plaintext}
else else
_other -> {:error, :invalid_ciphertext} _other -> {:error, :invalid_ciphertext}

View File

@@ -21,8 +21,8 @@ defmodule BDS.AI.SettingsStore do
def put_setting(key, value) when is_binary(key) and is_binary(value) do def put_setting(key, value) when is_binary(key) and is_binary(value) do
now = Persistence.now_ms() now = Persistence.now_ms()
(%Setting{} %Setting{}
|> Setting.changeset(%{key: key, value: value, updated_at: now})) |> Setting.changeset(%{key: key, value: value, updated_at: now})
|> Repo.insert( |> Repo.insert(
on_conflict: [set: [value: value, updated_at: now]], on_conflict: [set: [value: value, updated_at: now]],
conflict_target: [:key] conflict_target: [:key]

View File

@@ -39,12 +39,18 @@ defmodule BDS.CliSync do
ids = Enum.map(notifications, & &1.id) ids = Enum.map(notifications, & &1.id)
if ids != [] do if ids != [] do
Repo.update_all(from(notification in Notification, where: notification.id in ^ids), set: [seen_at: now]) Repo.update_all(from(notification in Notification, where: notification.id in ^ids),
set: [seen_at: now]
)
end end
{:ok, {:ok,
Enum.map(notifications, fn notification -> Enum.map(notifications, fn notification ->
%{entity_type: notification.entity_type, entity_id: notification.entity_id, action: notification.action} %{
entity_type: notification.entity_type,
entity_id: notification.entity_id,
action: notification.action
}
end)} end)}
end end
@@ -52,13 +58,17 @@ defmodule BDS.CliSync do
{processed_count, _} = {processed_count, _} =
Repo.delete_all( Repo.delete_all(
from notification in Notification, from notification in Notification,
where: not is_nil(notification.seen_at) and notification.created_at <= ^(now - @processed_ttl_ms) where:
not is_nil(notification.seen_at) and
notification.created_at <= ^(now - @processed_ttl_ms)
) )
{unprocessed_count, _} = {unprocessed_count, _} =
Repo.delete_all( Repo.delete_all(
from notification in Notification, from notification in Notification,
where: is_nil(notification.seen_at) and notification.created_at <= ^(now - @unprocessed_ttl_ms) where:
is_nil(notification.seen_at) and
notification.created_at <= ^(now - @unprocessed_ttl_ms)
) )
{:ok, %{processed: processed_count, unprocessed: unprocessed_count}} {:ok, %{processed: processed_count, unprocessed: unprocessed_count}}

View File

@@ -15,7 +15,9 @@ defmodule BDS.CliSync.Notification do
def changeset(notification, attrs) do def changeset(notification, attrs) do
notification notification
|> cast(attrs, [:entity_type, :entity_id, :action, :from_cli, :seen_at, :created_at], empty_values: [nil]) |> cast(attrs, [:entity_type, :entity_id, :action, :from_cli, :seen_at, :created_at],
empty_values: [nil]
)
|> validate_required([:entity_type, :entity_id, :action, :from_cli, :created_at]) |> validate_required([:entity_type, :entity_id, :action, :from_cli, :created_at])
end end
end end

View File

@@ -24,7 +24,11 @@ defmodule BDS.CliSync.Watcher do
@impl true @impl true
def init(opts) do def init(opts) do
state = %{ state = %{
poll_interval_ms: normalize_positive_integer(Keyword.get(opts, :poll_interval_ms), @default_poll_interval_ms), poll_interval_ms:
normalize_positive_integer(
Keyword.get(opts, :poll_interval_ms),
@default_poll_interval_ms
),
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub) pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
} }
@@ -49,7 +53,11 @@ defmodule BDS.CliSync.Watcher do
{:ok, _pruned} = CliSync.prune_notifications() {:ok, _pruned} = CliSync.prune_notifications()
Enum.each(notifications, fn notification -> Enum.each(notifications, fn notification ->
Phoenix.PubSub.broadcast(state.pubsub, topic(), {:entity_changed, notification_payload(notification)}) Phoenix.PubSub.broadcast(
state.pubsub,
topic(),
{:entity_changed, notification_payload(notification)}
)
end) end)
state state

View File

@@ -107,7 +107,9 @@ defmodule BDS.Desktop.Automation do
end end
def handle_call({:native_menu_action, action}, _from, state) do def handle_call({:native_menu_action, action}, _from, state) do
{reply, state} = driver_request(state, %{"command" => "native_menu_action", "action" => action}) {reply, state} =
driver_request(state, %{"command" => "native_menu_action", "action" => action})
{:reply, normalize_simple_reply(reply), state} {:reply, normalize_simple_reply(reply), state}
end end
@@ -204,7 +206,9 @@ defmodule BDS.Desktop.Automation do
receive_driver_message(state, @request_timeout, fn message -> receive_driver_message(state, @request_timeout, fn message ->
case message do case message do
%{"ref" => ^ref, "status" => "ok", "result" => result} -> {:ok, result} %{"ref" => ^ref, "status" => "ok", "result" => result} ->
{:ok, result}
%{"ref" => ^ref, "status" => "error", "message" => reason} -> %{"ref" => ^ref, "status" => "error", "message" => reason} ->
raise "desktop automation request failed: #{reason}" raise "desktop automation request failed: #{reason}"
@@ -242,7 +246,8 @@ defmodule BDS.Desktop.Automation do
defp process_driver_messages(state, deadline, matcher) do defp process_driver_messages(state, deadline, matcher) do
{messages, buffer} = split_driver_buffer(state.driver_buffer) {messages, buffer} = split_driver_buffer(state.driver_buffer)
case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} -> case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message,
{acc, _} ->
case decode_driver_message(message) do case decode_driver_message(message) do
:skip -> :skip ->
{:cont, {acc, nil}} {:cont, {acc, nil}}
@@ -259,7 +264,11 @@ defmodule BDS.Desktop.Automation do
receive do receive do
{port, {:data, data}} when port == state.driver_port -> {port, {:data, data}} when port == state.driver_port ->
process_driver_messages(%{state | driver_buffer: state.driver_buffer <> data}, deadline, matcher) process_driver_messages(
%{state | driver_buffer: state.driver_buffer <> data},
deadline,
matcher
)
{port, {:exit_status, status}} when port == state.driver_port -> {port, {:exit_status, status}} when port == state.driver_port ->
raise "desktop automation driver exited with status #{status}" raise "desktop automation driver exited with status #{status}"
@@ -311,7 +320,9 @@ defmodule BDS.Desktop.Automation do
defp do_wait_for_server(base_url, deadline) do defp do_wait_for_server(base_url, deadline) do
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
{:ok, {{_, 200, _}, _headers, _body}} -> :ok {:ok, {{_, 200, _}, _headers, _body}} ->
:ok
_other -> _other ->
if System.monotonic_time(:millisecond) >= deadline do if System.monotonic_time(:millisecond) >= deadline do
raise "desktop app process did not become healthy in time" raise "desktop app process did not become healthy in time"

View File

@@ -9,28 +9,30 @@ defmodule BDS.Desktop.Endpoint do
signing_salt: "desktop-shell" signing_salt: "desktop-shell"
] ]
socket "/live", Phoenix.LiveView.Socket, socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
websocket: [connect_info: [session: @session_options]]
plug Plug.Session, @session_options plug(Plug.Session, @session_options)
plug :maybe_require_desktop_auth plug(:maybe_require_desktop_auth)
plug Plug.Static, plug(Plug.Static,
at: "/assets", at: "/assets",
from: {:bds, "priv/ui"}, from: {:bds, "priv/ui"},
only: ["app.css", "live.js", "monaco"] only: ["app.css", "live.js", "monaco"]
)
plug Plug.Static, plug(Plug.Static,
at: "/vendor/phoenix", at: "/vendor/phoenix",
from: {:phoenix, "priv/static"}, from: {:phoenix, "priv/static"},
only: ["phoenix.min.js"] only: ["phoenix.min.js"]
)
plug Plug.Static, plug(Plug.Static,
at: "/vendor/live_view", at: "/vendor/live_view",
from: {:phoenix_live_view, "priv/static"}, from: {:phoenix_live_view, "priv/static"},
only: ["phoenix_live_view.min.js"] only: ["phoenix_live_view.min.js"]
)
plug BDS.Desktop.Router plug(BDS.Desktop.Router)
defp maybe_require_desktop_auth(conn, _opts) do defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do

View File

@@ -22,7 +22,9 @@ defmodule BDS.Desktop.MainWindow do
restored = restore_bounds() restored = restore_bounds()
{default_width, default_height} = Keyword.get(desktop_config, :window_size, @default_size) {default_width, default_height} = Keyword.get(desktop_config, :window_size, @default_size)
{min_width, min_height} = Keyword.get(desktop_config, :window_min_size, @default_min_size) {min_width, min_height} = Keyword.get(desktop_config, :window_min_size, @default_min_size)
startup_bounds = clamp_startup_bounds(restored || %{width: default_width, height: default_height})
startup_bounds =
clamp_startup_bounds(restored || %{width: default_width, height: default_height})
base_opts = [ base_opts = [
app: :bds, app: :bds,
@@ -70,7 +72,9 @@ defmodule BDS.Desktop.MainWindow do
frame -> frame ->
apply_restored_bounds(frame) apply_restored_bounds(frame)
schedule_persist() schedule_persist()
{:noreply, %{state | frame: frame, last_bounds: current_bounds(frame) || state.last_bounds}}
{:noreply,
%{state | frame: frame, last_bounds: current_bounds(frame) || state.last_bounds}}
end end
end end
@@ -124,9 +128,15 @@ defmodule BDS.Desktop.MainWindow do
defp current_bounds(frame) do defp current_bounds(frame) do
with_wx_env(fn -> with_wx_env(fn ->
cond do cond do
not :wxWindow.isShown(frame) -> nil not :wxWindow.isShown(frame) ->
:wxTopLevelWindow.isFullScreen(frame) -> nil nil
:wxTopLevelWindow.isMaximized(frame) -> nil
:wxTopLevelWindow.isFullScreen(frame) ->
nil
:wxTopLevelWindow.isMaximized(frame) ->
nil
true -> true ->
{x, y} = :wxWindow.getPosition(frame) {x, y} = :wxWindow.getPosition(frame)
{width, height} = :wxWindow.getSize(frame) {width, height} = :wxWindow.getSize(frame)
@@ -160,7 +170,8 @@ defmodule BDS.Desktop.MainWindow do
end end
defp normalize_bounds(%{x: x, y: y, width: width, height: height}) defp normalize_bounds(%{x: x, y: y, width: width, height: height})
when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) and width > 0 and height > 0 do when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) and
width > 0 and height > 0 do
{:ok, %{x: x, y: y, width: width, height: height}} {:ok, %{x: x, y: y, width: width, height: height}}
end end
@@ -180,7 +191,8 @@ defmodule BDS.Desktop.MainWindow do
desktop_config = Application.get_env(:bds, :desktop, []) desktop_config = Application.get_env(:bds, :desktop, [])
case Keyword.get(desktop_config, :window_client_area_override) do case Keyword.get(desktop_config, :window_client_area_override) do
{x, y, width, height} when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) -> {x, y, width, height}
when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) ->
%{x: x, y: y, width: width, height: height} %{x: x, y: y, width: width, height: height}
_ -> _ ->

View File

@@ -24,7 +24,8 @@ defmodule BDS.Desktop.MediaController do
with %{} = project <- Projects.get_active_project(), with %{} = project <- Projects.get_active_project(),
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id), %MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
true <- media.project_id == project.id, true <- media.project_id == project.id,
relative_path when is_binary(relative_path) <- Media.thumbnail_paths(media)[thumbnail_size(size)], relative_path when is_binary(relative_path) <-
Media.thumbnail_paths(media)[thumbnail_size(size)],
absolute_path = Path.join(Projects.project_data_dir(project), relative_path), absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
true <- File.exists?(absolute_path) do true <- File.exists?(absolute_path) do
{:ok, thumbnail_content_type(relative_path), absolute_path} {:ok, thumbnail_content_type(relative_path), absolute_path}
@@ -33,7 +34,8 @@ defmodule BDS.Desktop.MediaController do
end end
rescue rescue
error in [Exqlite.Error, DBConnection.OwnershipError] -> error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__ reraise error, __STACKTRACE__
end end

View File

@@ -168,14 +168,16 @@ defmodule BDS.Desktop.Overlay do
def close_lightbox(%{kind: :gallery} = overlay), do: %{overlay | lightbox: nil} def close_lightbox(%{kind: :gallery} = overlay), do: %{overlay | lightbox: nil}
def close_lightbox(overlay), do: overlay def close_lightbox(overlay), do: overlay
def lightbox_next(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) when is_map(lightbox) and images != [] do def lightbox_next(%{kind: :gallery, lightbox: lightbox, images: images} = overlay)
when is_map(lightbox) and images != [] do
next_index = rem(lightbox.current_index + 1, length(images)) next_index = rem(lightbox.current_index + 1, length(images))
%{overlay | lightbox: lightbox_from_index(images, next_index)} %{overlay | lightbox: lightbox_from_index(images, next_index)}
end end
def lightbox_next(overlay), do: overlay def lightbox_next(overlay), do: overlay
def lightbox_previous(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) when is_map(lightbox) and images != [] do def lightbox_previous(%{kind: :gallery, lightbox: lightbox, images: images} = overlay)
when is_map(lightbox) and images != [] do
next_index = rem(lightbox.current_index - 1 + length(images), length(images)) next_index = rem(lightbox.current_index - 1 + length(images), length(images))
%{overlay | lightbox: lightbox_from_index(images, next_index)} %{overlay | lightbox: lightbox_from_index(images, next_index)}
end end

View File

@@ -6,23 +6,23 @@ defmodule BDS.Desktop.Router do
import Phoenix.LiveView.Router import Phoenix.LiveView.Router
pipeline :browser do pipeline :browser do
plug :accepts, ["html"] plug(:accepts, ["html"])
plug :fetch_session plug(:fetch_session)
plug :fetch_live_flash plug(:fetch_live_flash)
plug :put_root_layout, html: {BDS.Desktop.Layouts, :root} plug(:put_root_layout, html: {BDS.Desktop.Layouts, :root})
plug :protect_from_forgery plug(:protect_from_forgery)
plug :put_secure_browser_headers plug(:put_secure_browser_headers)
end end
scope "/", BDS.Desktop do scope "/", BDS.Desktop do
pipe_through :browser pipe_through(:browser)
get "/health", HealthController, :show get("/health", HealthController, :show)
get "/media-thumbnail/:media_id", MediaController, :thumbnail get("/media-thumbnail/:media_id", MediaController, :thumbnail)
live_session :desktop_shell, live_session :desktop_shell,
root_layout: {BDS.Desktop.Layouts, :root} do root_layout: {BDS.Desktop.Layouts, :root} do
live "/", ShellLive, :index live("/", ShellLive, :index)
end end
end end
end end

View File

@@ -38,7 +38,8 @@ defmodule BDS.Desktop.ShellData do
Projects.shell_snapshot() Projects.shell_snapshot()
rescue rescue
error in [Exqlite.Error, DBConnection.OwnershipError] -> error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table: projects") do if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table: projects") do
reraise error, __STACKTRACE__ reraise error, __STACKTRACE__
end end
@@ -54,7 +55,8 @@ defmodule BDS.Desktop.ShellData do
Dashboard.snapshot(project_id) Dashboard.snapshot(project_id)
rescue rescue
error in [Exqlite.Error, DBConnection.OwnershipError] -> error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__ reraise error, __STACKTRACE__
end end
@@ -65,7 +67,8 @@ defmodule BDS.Desktop.ShellData do
Sidebar.view(project_id, view_id, params) Sidebar.view(project_id, view_id, params)
rescue rescue
error in [Exqlite.Error, DBConnection.OwnershipError] -> error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__ reraise error, __STACKTRACE__
end end
@@ -75,7 +78,10 @@ defmodule BDS.Desktop.ShellData do
def assistant_cards do def assistant_cards do
[ [
%{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."}, %{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
%{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."}, %{
label: "Filesystem Sync",
text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."
},
%{label: "Desktop Runtime", text: "The app window is now served from LiveView state."} %{label: "Desktop Runtime", text: "The app window is now served from LiveView state."}
] ]
end end
@@ -117,7 +123,8 @@ defmodule BDS.Desktop.ShellData do
end end
rescue rescue
error in [DBConnection.OwnershipError, Exqlite.Error] -> error in [DBConnection.OwnershipError, Exqlite.Error] ->
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__ reraise error, __STACKTRACE__
end end
@@ -146,17 +153,38 @@ defmodule BDS.Desktop.ShellData do
def activity_icon(id) do def activity_icon(id) do
case to_string(id) do case to_string(id) do
"posts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"></path><path d="M8 12h8v2H8zm0 4h8v2H8z"></path></svg>) "posts" ->
"pages" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"></path></svg>) ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"></path><path d="M8 12h8v2H8zm0 4h8v2H8z"></path></svg>)
"media" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg>)
"scripts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"></path></svg>) "pages" ->
"templates" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"></path></svg>) ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"></path></svg>)
"tags" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"></path></svg>)
"chat" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"></path><circle cx="8" cy="10" r="1.5"></circle><circle cx="12" cy="10" r="1.5"></circle><circle cx="16" cy="10" r="1.5"></circle></svg>) "media" ->
"import" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>) ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg>)
"git" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"></path></svg>)
"settings" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>) "scripts" ->
_other -> activity_icon("posts") ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"></path></svg>)
"templates" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"></path></svg>)
"tags" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"></path></svg>)
"chat" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"></path><circle cx="8" cy="10" r="1.5"></circle><circle cx="12" cy="10" r="1.5"></circle><circle cx="16" cy="10" r="1.5"></circle></svg>)
"import" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>)
"git" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"></path></svg>)
"settings" ->
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>)
_other ->
activity_icon("posts")
end end
end end
@@ -171,7 +199,10 @@ defmodule BDS.Desktop.ShellData do
def dashboard_post_count_label(count) do def dashboard_post_count_label(count) do
normalized_count = count || 0 normalized_count = count || 0
key = if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
key =
if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
translate(key, %{count: normalized_count}) translate(key, %{count: normalized_count})
end end
@@ -188,7 +219,7 @@ defmodule BDS.Desktop.ShellData do
top_items top_items
|> Enum.map(fn item -> |> Enum.map(fn item ->
font_size = 11 + (((item.count || 0) - min_count) / range) * 11 font_size = 11 + ((item.count || 0) - min_count) / range * 11
Map.merge(item, %{font_size: font_size, color: normalize_dashboard_tag_color(item.color)}) Map.merge(item, %{font_size: font_size, color: normalize_dashboard_tag_color(item.color)})
end) end)
|> Enum.sort_by(&String.downcase(to_string(&1.tag || ""))) |> Enum.sort_by(&String.downcase(to_string(&1.tag || "")))
@@ -199,7 +230,8 @@ defmodule BDS.Desktop.ShellData do
declarations = declarations =
if item.color do if item.color do
declarations ++ [ declarations ++
[
"background-color: #{item.color};", "background-color: #{item.color};",
"color: #{dashboard_contrast_color(item.color)};" "color: #{dashboard_contrast_color(item.color)};"
] ]
@@ -225,9 +257,17 @@ defmodule BDS.Desktop.ShellData do
def route_label(route) do def route_label(route) do
case to_string(route) do case to_string(route) do
"git_log" -> "Git Log" "git_log" ->
"post_links" -> "Post Links" "Git Log"
other -> other |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1)
"post_links" ->
"Post Links"
other ->
other
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end end
end end
@@ -255,7 +295,10 @@ defmodule BDS.Desktop.ShellData do
defp effective_ui_language(locale), do: locale defp effective_ui_language(locale), do: locale
defp maybe_add_panel_tab(tabs, :post, :post_links), do: tabs ++ [:post_links] defp maybe_add_panel_tab(tabs, :post, :post_links), do: tabs ++ [:post_links]
defp maybe_add_panel_tab(tabs, route, :git_log) when route in [:post, :media], do: tabs ++ [:git_log]
defp maybe_add_panel_tab(tabs, route, :git_log) when route in [:post, :media],
do: tabs ++ [:git_log]
defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs
defp default_project_snapshot do defp default_project_snapshot do

View File

@@ -8,10 +8,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
embed_templates "chat_editor_html/*" embed_templates("chat_editor_html/*")
# ── Public API: state assignment ─────────────────────────────────────────── # ── Public API: state assignment ───────────────────────────────────────────
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
assign(socket, :chat_editor, MessageBuild.build(socket.assigns)) assign(socket, :chat_editor, MessageBuild.build(socket.assigns))
end end
@@ -25,6 +26,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Public API: input + surface state ────────────────────────────────────── # ── Public API: input + surface state ──────────────────────────────────────
@spec update_input(term(), term(), term()) :: term()
def update_input(socket, value, reload) do def update_input(socket, value, reload) do
%{id: conversation_id} = socket.assigns.current_tab %{id: conversation_id} = socket.assigns.current_tab
@@ -36,6 +38,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec update_surface_form(term(), term(), term(), term()) :: term()
def update_surface_form(socket, surface_id, fields, reload) def update_surface_form(socket, surface_id, fields, reload)
when is_binary(surface_id) and is_map(fields) do when is_binary(surface_id) and is_map(fields) do
next_data = Map.put(socket.assigns.chat_editor_surface_data, surface_id, fields) next_data = Map.put(socket.assigns.chat_editor_surface_data, surface_id, fields)
@@ -45,6 +48,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec select_surface_tab(term(), term(), term(), term()) :: term()
def select_surface_tab(socket, surface_id, index, reload) def select_surface_tab(socket, surface_id, index, reload)
when is_binary(surface_id) and is_integer(index) and index >= 0 do when is_binary(surface_id) and is_integer(index) and index >= 0 do
socket socket
@@ -55,10 +59,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec current_surface_data(term(), term()) :: term()
def current_surface_data(socket, surface_id) when is_binary(surface_id) do def current_surface_data(socket, surface_id) when is_binary(surface_id) do
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{}) Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
end end
@spec set_action_error(term(), term(), term(), term()) :: term()
def set_action_error(socket, conversation_id, message, reload) def set_action_error(socket, conversation_id, message, reload)
when is_binary(conversation_id) and is_binary(message) do when is_binary(conversation_id) and is_binary(message) do
socket socket
@@ -69,6 +75,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec clear_action_error(term(), term(), term()) :: term()
def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do
socket socket
|> assign( |> assign(
@@ -80,6 +87,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Public API: messaging ────────────────────────────────────────────────── # ── Public API: messaging ──────────────────────────────────────────────────
@spec send_message(term(), term(), term()) :: term()
def send_message(socket, reload, append_output) do def send_message(socket, reload, append_output) do
%{id: conversation_id} = socket.assigns.current_tab %{id: conversation_id} = socket.assigns.current_tab
message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim() message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
@@ -144,6 +152,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end end
end end
@spec abort_message(term(), term()) :: term()
def abort_message(socket, reload) do def abort_message(socket, reload) do
%{id: conversation_id} = socket.assigns.current_tab %{id: conversation_id} = socket.assigns.current_tab
@@ -167,6 +176,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end end
end end
@spec note_tool_call(term(), term(), term(), term()) :: term()
def note_tool_call(socket, conversation_id, tool_call, reload) def note_tool_call(socket, conversation_id, tool_call, reload)
when is_binary(conversation_id) and is_map(tool_call) do when is_binary(conversation_id) and is_map(tool_call) do
update_request( update_request(
@@ -189,6 +199,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
) )
end end
@spec note_tool_result(term(), term(), term(), term()) :: term()
def note_tool_result(socket, conversation_id, name, reload) def note_tool_result(socket, conversation_id, name, reload)
when is_binary(conversation_id) and is_binary(name) do when is_binary(conversation_id) and is_binary(name) do
update_request( update_request(
@@ -201,6 +212,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
) )
end end
@spec note_streaming_content(term(), term(), term(), term()) :: term()
def note_streaming_content(socket, conversation_id, content, reload) def note_streaming_content(socket, conversation_id, content, reload)
when is_binary(conversation_id) and is_binary(content) do when is_binary(conversation_id) and is_binary(content) do
update_request( update_request(
@@ -211,6 +223,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
) )
end end
@spec finish_request(term(), term(), term(), term(), term()) :: term()
def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do
case Map.pop(socket.assigns.chat_editor_request_refs, ref) do case Map.pop(socket.assigns.chat_editor_request_refs, ref) do
{nil, _remaining_refs} -> {nil, _remaining_refs} ->
@@ -245,12 +258,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── HEEx-callable helpers ───────────────────────────────────────────────── # ── HEEx-callable helpers ─────────────────────────────────────────────────
@spec message_role_label(term()) :: term()
def message_role_label(:user), do: translated("chat.role.you") def message_role_label(:user), do: translated("chat.role.you")
def message_role_label(_role), do: translated("chat.role.assistant") def message_role_label(_role), do: translated("chat.role.assistant")
defdelegate tool_call_name(tool_call), to: ToolTracking defdelegate tool_call_name(tool_call), to: ToolTracking
defdelegate tool_call_arguments(tool_call), to: ToolTracking defdelegate tool_call_arguments(tool_call), to: ToolTracking
@spec tool_surface_type(term()) :: term()
def tool_surface_type(surface), do: Map.get(surface, :type, "json") def tool_surface_type(surface), do: Map.get(surface, :type, "json")
def markdown_html(content) when is_binary(content) do def markdown_html(content) when is_binary(content) do
@@ -264,8 +279,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
raw(html) raw(html)
end end
@spec markdown_html(term()) :: term()
def markdown_html(_content), do: "" def markdown_html(_content), do: ""
@spec payload_json(term()) :: term()
def payload_json(nil), do: "{}" def payload_json(nil), do: "{}"
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload) def payload_json(payload) when is_map(payload), do: Jason.encode!(payload)
@@ -280,15 +297,18 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> Float.round(2) |> Float.round(2)
end end
@spec chart_width(term(), term()) :: term()
def chart_width(_max_value, _value), do: 0 def chart_width(_max_value, _value), do: 0
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false def truthy?(_value), do: false
# ── HEEx components ─────────────────────────────────────────────────────── # ── HEEx components ───────────────────────────────────────────────────────
attr :markers, :list, required: true attr(:markers, :list, required: true)
@spec chat_tool_markers(term()) :: term()
def chat_tool_markers(assigns) do def chat_tool_markers(assigns) do
~H""" ~H"""
<%= if @markers != [] do %> <%= if @markers != [] do %>
@@ -307,8 +327,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
""" """
end end
attr :surface, :map, required: true attr(:surface, :map, required: true)
@spec chat_surface(term()) :: term()
def chat_surface(assigns) do def chat_surface(assigns) do
~H""" ~H"""
<article class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface"> <article class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface">
@@ -548,7 +569,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
fn _match, src, alt -> external_image_link(src, alt) end fn _match, src, alt -> external_image_link(src, alt) end
) )
Regex.replace(~r/<img\b(?=[^>]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match, src -> Regex.replace(~r/<img\b(?=[^>]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match,
src ->
external_image_link(src, src) external_image_link(src, src)
end) end)
end end
@@ -571,6 +593,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp format_error(reason), do: inspect(reason) defp format_error(reason), do: inspect(reason)
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end end

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking} alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking}
@spec build(term()) :: term()
def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
case AI.get_chat_conversation(conversation_id) do case AI.get_chat_conversation(conversation_id) do
nil -> nil ->

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
@spec toggle_model_selector(term(), term()) :: term()
def toggle_model_selector(socket, reload) do def toggle_model_selector(socket, reload) do
%{id: conversation_id} = socket.assigns.current_tab %{id: conversation_id} = socket.assigns.current_tab
current = Map.get(socket.assigns.chat_model_selectors_open, conversation_id, false) current = Map.get(socket.assigns.chat_model_selectors_open, conversation_id, false)
@@ -18,6 +19,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec set_model(term(), term(), term(), term()) :: term()
def set_model(socket, model_id, reload, append_output) do def set_model(socket, model_id, reload, append_output) do
%{id: conversation_id} = socket.assigns.current_tab %{id: conversation_id} = socket.assigns.current_tab
@@ -37,6 +39,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
end end
end end
@spec group_available_models(term()) :: term()
def group_available_models(models) when is_list(models) do def group_available_models(models) when is_list(models) do
models models
|> Enum.group_by(&Map.get(&1, :provider, "other")) |> Enum.group_by(&Map.get(&1, :provider, "other"))
@@ -54,6 +57,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
|> Enum.sort_by(&String.downcase(to_string(&1.label))) |> Enum.sort_by(&String.downcase(to_string(&1.label)))
end end
@spec needs_api_key?(term()) :: term()
def needs_api_key?(true), do: false def needs_api_key?(true), do: false
def needs_api_key?(false) do def needs_api_key?(false) do

View File

@@ -14,9 +14,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
"render_tabs" "render_tabs"
]) ])
@spec render_tool?(term()) :: term()
def render_tool?(name) when is_binary(name), do: MapSet.member?(@render_tool_names, name) def render_tool?(name) when is_binary(name), do: MapSet.member?(@render_tool_names, name)
@spec render_tool?(term()) :: term()
def render_tool?(_name), do: false def render_tool?(_name), do: false
@spec build_render_surfaces(term(), term(), term()) :: term()
def build_render_surfaces(tool_calls, message_id, assigns) do def build_render_surfaces(tool_calls, message_id, assigns) do
tool_calls tool_calls
|> Enum.with_index() |> Enum.with_index()
@@ -28,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
end) end)
end end
@spec build_render_surface(term(), term(), term()) :: term()
def build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do def build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do
if MapSet.member?(@render_tool_names, name) do if MapSet.member?(@render_tool_names, name) do
do_build_render_surface(name, arguments || %{}, surface_id, assigns) do_build_render_surface(name, arguments || %{}, surface_id, assigns)
@@ -51,6 +55,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
end end
end end
@spec normalize_tool_surface(term()) :: term()
def normalize_tool_surface(_content), do: nil def normalize_tool_surface(_content), do: nil
defp do_build_render_surface("render_card", arguments, surface_id, _assigns) do defp do_build_render_surface("render_card", arguments, surface_id, _assigns) do
@@ -150,7 +155,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
label: map_value(field, "label", key), label: map_value(field, "label", key),
input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"), input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"),
placeholder: map_value(field, "placeholder"), placeholder: map_value(field, "placeholder"),
value: Map.get(stored_fields, key, map_value(field, "defaultValue") || map_value(field, "default_value")), value:
Map.get(
stored_fields,
key,
map_value(field, "defaultValue") || map_value(field, "default_value")
),
options: decode_surface_options(map_value(field, "options", [])), options: decode_surface_options(map_value(field, "options", [])),
required?: truthy?(map_value(field, "required", false)) required?: truthy?(map_value(field, "required", false))
} }
@@ -161,8 +171,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
type: "form", type: "form",
title: map_value(arguments, "title"), title: map_value(arguments, "title"),
fields: fields, fields: fields,
submit_label: map_value(arguments, "submitLabel") || map_value(arguments, "submit_label", translated("chat.stop")), submit_label:
submit_action: map_value(arguments, "submitAction") || map_value(arguments, "submit_action", "submitForm") map_value(arguments, "submitLabel") ||
map_value(arguments, "submit_label", translated("chat.stop")),
submit_action:
map_value(arguments, "submitAction") ||
map_value(arguments, "submit_action", "submitForm")
} }
end end
@@ -181,7 +195,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|> List.wrap() |> List.wrap()
|> Enum.with_index() |> Enum.with_index()
|> Enum.map(fn {content, content_index} -> |> Enum.map(fn {content, content_index} ->
build_tab_surface(content, "#{surface_id}-tab-#{tab_index}-#{content_index}", assigns) build_tab_surface(
content,
"#{surface_id}-tab-#{tab_index}-#{content_index}",
assigns
)
end) end)
} }
end) end)
@@ -203,11 +221,21 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
type = map_value(content, "type", "text") type = map_value(content, "type", "text")
case type do case type do
render_type when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] -> render_type
do_build_render_surface("render_#{render_type}", Map.delete(content, "type"), surface_id, assigns) when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] ->
do_build_render_surface(
"render_#{render_type}",
Map.delete(content, "type"),
surface_id,
assigns
)
"text" -> "text" ->
%{id: surface_id, type: "text", body: map_value(content, "body") || map_value(content, "text", "")} %{
id: surface_id,
type: "text",
body: map_value(content, "body") || map_value(content, "text", "")
}
_other -> _other ->
%{id: surface_id, type: "json", raw: content} %{id: surface_id, type: "json", raw: content}

View File

@@ -3,10 +3,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
@tool_args_max_length 30 @tool_args_max_length 30
@spec tool_call_name(term()) :: term()
def tool_call_name(tool_call) when is_map(tool_call) do def tool_call_name(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :name) || "tool" BDS.MapUtils.attr(tool_call, :name) || "tool"
end end
@spec tool_call_arguments(term()) :: term()
def tool_call_arguments(tool_call) when is_map(tool_call) do def tool_call_arguments(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{} BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
end end
@@ -25,6 +27,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
end) end)
end end
@spec normalize_tool_calls(term()) :: term()
def normalize_tool_calls(_tool_calls), do: [] def normalize_tool_calls(_tool_calls), do: []
def tool_arguments_preview(arguments) when is_map(arguments) do def tool_arguments_preview(arguments) when is_map(arguments) do
@@ -33,6 +36,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|> Enum.join(", ") |> Enum.join(", ")
end end
@spec tool_arguments_preview(term()) :: term()
def tool_arguments_preview(_arguments), do: "" def tool_arguments_preview(_arguments), do: ""
def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do
@@ -47,8 +51,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
end) end)
end end
@spec mark_tool_call_completed(term(), term()) :: term()
def mark_tool_call_completed(entry, _tool_call_id), do: entry def mark_tool_call_completed(entry, _tool_call_id), do: entry
@spec tool_markers_from_events(term()) :: term()
def tool_markers_from_events(nil), do: [] def tool_markers_from_events(nil), do: []
def tool_markers_from_events(%{tool_events: tool_events}) do def tool_markers_from_events(%{tool_events: tool_events}) do

View File

@@ -10,12 +10,14 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
embed_templates("code_entity_editor_html/*") embed_templates("code_entity_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
socket socket
|> assign(:script_editor, build_script(socket.assigns)) |> assign(:script_editor, build_script(socket.assigns))
|> assign(:template_editor, build_template(socket.assigns)) |> assign(:template_editor, build_template(socket.assigns))
end end
@spec update_script(term(), term(), term()) :: term()
def update_script(socket, params, reload) do def update_script(socket, params, reload) do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
@@ -27,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_script(term(), term(), term()) :: term()
def save_script(socket, reload, append_output) do def save_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
@@ -62,6 +65,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec check_script(term(), term(), term()) :: term()
def check_script(socket, reload, append_output) do def check_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
@@ -82,6 +86,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec run_script(term(), term(), term()) :: term()
def run_script(socket, reload, append_output) do def run_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
@@ -111,6 +116,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec delete_script(term(), term(), term()) :: term()
def delete_script(socket, reload, append_output) do def delete_script(socket, reload, append_output) do
%{id: script_id} = socket.assigns.current_tab %{id: script_id} = socket.assigns.current_tab
@@ -124,6 +130,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec update_template(term(), term(), term()) :: term()
def update_template(socket, params, reload) do def update_template(socket, params, reload) do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
@@ -139,6 +146,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_template(term(), term(), term()) :: term()
def save_template(socket, reload, append_output) do def save_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
@@ -169,6 +177,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec validate_template(term(), term(), term()) :: term()
def validate_template(socket, reload, append_output) do def validate_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
@@ -195,6 +204,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec delete_template(term(), term(), term()) :: term()
def delete_template(socket, reload, append_output) do def delete_template(socket, reload, append_output) do
%{id: template_id} = socket.assigns.current_tab %{id: template_id} = socket.assigns.current_tab
@@ -211,6 +221,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
end end
end end
@spec build_script(term()) :: term()
def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do
case Scripts.get_script(script_id) do case Scripts.get_script(script_id) do
nil -> nil ->
@@ -236,6 +247,7 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
def build_script(_assigns), do: nil def build_script(_assigns), do: nil
@spec build_template(term()) :: term()
def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do
case Templates.get_template(template_id) do case Templates.get_template(template_id) do
nil -> nil ->
@@ -259,9 +271,11 @@ defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
def build_template(_assigns), do: nil def build_template(_assigns), do: nil
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec format_timestamp(term()) :: term()
def format_timestamp(nil), do: "" def format_timestamp(nil), do: ""
def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp) def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)

View File

@@ -57,7 +57,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
total, total,
detail, detail,
reload reload
), to: ProgressTracking ),
to: ProgressTracking
defdelegate finish_execution(socket, ref, result, reload, append_output), to: ProgressTracking defdelegate finish_execution(socket, ref, result, reload, append_output), to: ProgressTracking
@@ -72,6 +73,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
defdelegate clear_taxonomy_mapping(socket, params, reload), to: TaxonomyEditing defdelegate clear_taxonomy_mapping(socket, params, reload), to: TaxonomyEditing
defdelegate analyze_taxonomy_ai(socket, reload, append_output), to: TaxonomyEditing defdelegate analyze_taxonomy_ai(socket, reload, append_output), to: TaxonomyEditing
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
case socket.assigns[:current_tab] do case socket.assigns[:current_tab] do
%{type: :import, id: definition_id} -> %{type: :import, id: definition_id} ->
@@ -140,6 +142,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
end end
end end
@spec toggle_section(term(), term(), term()) :: term()
def toggle_section(socket, section, reload) do def toggle_section(socket, section, reload) do
with %{id: definition_id} <- socket.assigns.current_tab, with %{id: definition_id} <- socket.assigns.current_tab,
section_key section_key
@@ -171,6 +174,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
end end
end end
@spec toggle_model_selector(term(), term()) :: term()
def toggle_model_selector(socket, reload) do def toggle_model_selector(socket, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do with %{id: definition_id} <- socket.assigns.current_tab do
current = Map.get(socket.assigns.import_editor_model_selectors_open, definition_id, false) current = Map.get(socket.assigns.import_editor_model_selectors_open, definition_id, false)
@@ -186,6 +190,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
end end
end end
@spec select_ai_model(term(), term(), term()) :: term()
def select_ai_model(socket, model_id, reload) do def select_ai_model(socket, model_id, reload) do
with %{id: definition_id} <- socket.assigns.current_tab do with %{id: definition_id} <- socket.assigns.current_tab do
socket socket
@@ -205,6 +210,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:import_editor, :map, required: true) attr(:import_editor, :map, required: true)
@spec import_editor(term()) :: term()
def import_editor(assigns) do def import_editor(assigns) do
assigns = assigns =
assigns assigns
@@ -547,6 +553,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:expanded, :boolean, required: true) attr(:expanded, :boolean, required: true)
attr(:section, :string, required: true) attr(:section, :string, required: true)
@spec conflict_section(term()) :: term()
def conflict_section(assigns) do def conflict_section(assigns) do
~H""" ~H"""
<section class="import-detail-section conflicts-section"> <section class="import-detail-section conflicts-section">
@@ -597,6 +604,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:section, :string, required: true) attr(:section, :string, required: true)
attr(:show_type, :boolean, default: false) attr(:show_type, :boolean, default: false)
@spec post_detail_section(term()) :: term()
def post_detail_section(assigns) do def post_detail_section(assigns) do
~H""" ~H"""
<section class="import-detail-section"> <section class="import-detail-section">
@@ -646,6 +654,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:expanded, :boolean, required: true) attr(:expanded, :boolean, required: true)
attr(:section, :string, required: true) attr(:section, :string, required: true)
@spec media_detail_section(term()) :: term()
def media_detail_section(assigns) do def media_detail_section(assigns) do
~H""" ~H"""
<section class="import-detail-section"> <section class="import-detail-section">
@@ -685,6 +694,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:label, :string, required: true) attr(:label, :string, required: true)
attr(:stats, :map, required: true) attr(:stats, :map, required: true)
@spec stat_card(term()) :: term()
def stat_card(assigns) do def stat_card(assigns) do
~H""" ~H"""
<div class="import-stat-card"> <div class="import-stat-card">
@@ -703,6 +713,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:label, :string, required: true) attr(:label, :string, required: true)
attr(:stats, :map, required: true) attr(:stats, :map, required: true)
@spec other_stat_card(term()) :: term()
def other_stat_card(assigns) do def other_stat_card(assigns) do
~H""" ~H"""
<div class="import-stat-card import-stat-card-other"> <div class="import-stat-card import-stat-card-other">
@@ -720,6 +731,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:label, :string, required: true) attr(:label, :string, required: true)
attr(:stats, :map, required: true) attr(:stats, :map, required: true)
@spec media_stat_card(term()) :: term()
def media_stat_card(assigns) do def media_stat_card(assigns) do
~H""" ~H"""
<div class="import-stat-card"> <div class="import-stat-card">
@@ -739,6 +751,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:label, :string, required: true) attr(:label, :string, required: true)
attr(:stats, :map, required: true) attr(:stats, :map, required: true)
@spec taxonomy_stat_card(term()) :: term()
def taxonomy_stat_card(assigns) do def taxonomy_stat_card(assigns) do
~H""" ~H"""
<div class="import-stat-card"> <div class="import-stat-card">
@@ -759,6 +772,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
attr(:edit, :map, default: nil) attr(:edit, :map, default: nil)
attr(:type, :string, required: true) attr(:type, :string, required: true)
@spec taxonomy_group(term()) :: term()
def taxonomy_group(assigns) do def taxonomy_group(assigns) do
~H""" ~H"""
<div class="taxonomy-group"> <div class="taxonomy-group">

View File

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

View File

@@ -3,18 +3,27 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ConflictResolution do
alias BDS.ImportDefinitions 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, with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = definition <- ImportDefinitions.get_definition(definition_id),
%{} = report <- ImportDefinitions.decode_analysis_result(definition), %{} = report <- ImportDefinitions.decode_analysis_result(definition),
updated_report <- update_conflict_resolution(report, item_type, item_name, resolution), 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) reload.(socket, socket.assigns.workbench)
else else
_other -> reload.(socket, socket.assigns.workbench) _other -> reload.(socket, socket.assigns.workbench)
end end
end end
@spec update_conflict_resolution(term(), term(), term(), term()) :: term()
def update_conflict_resolution(report, item_type, item_name, resolution) do def update_conflict_resolution(report, item_type, item_name, resolution) do
report report
|> update_in([:conflicts], fn conflicts -> |> 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)) |> update_in([:details], &update_conflict_bucket(&1, item_type, item_name, resolution))
end 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(nil, _item_type, _item_name, _resolution), do: nil
def update_conflict_bucket(buckets, item_type, item_name, resolution) do 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 -> update_in(buckets, [bucket_key], fn items ->
Enum.map(items || [], fn item -> 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.ShellData
alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState alias BDS.Desktop.ShellLive.ImportEditor.AnalysisState
@spec execute_import(term(), term(), term()) :: term()
def execute_import(socket, reload, _append_output) do def execute_import(socket, reload, _append_output) do
with %{id: definition_id} <- socket.assigns.current_tab, with %{id: definition_id} <- socket.assigns.current_tab,
%{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = definition <- ImportDefinitions.get_definition(definition_id),
@@ -24,7 +25,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
uploads_folder_path: definition.uploads_folder_path, uploads_folder_path: definition.uploads_folder_path,
default_author: default_author, default_author: default_author,
on_progress: fn phase, current, total, detail -> 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
) )
end) end)
@@ -50,7 +54,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: task.ref 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) |> reload.(socket.assigns.workbench)
end end
else else
@@ -58,6 +65,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end end
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 def note_execution_progress(socket, definition_id, phase, current, total, detail, reload) do
{detail_text, eta} = decompose_progress_detail(detail) {detail_text, eta} = decompose_progress_detail(detail)
translated_phase = translate_execution_phase(phase) translated_phase = translate_execution_phase(phase)
@@ -65,7 +73,11 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
socket socket
|> Phoenix.Component.assign( |> Phoenix.Component.assign(
:import_editor_execution_states, :import_editor_execution_states,
Map.update(socket.assigns.import_editor_execution_states, definition_id, default_execution_state(), fn state -> Map.update(
socket.assigns.import_editor_execution_states,
definition_id,
default_execution_state(),
fn state ->
state state
|> Map.put(:is_executing, true) |> Map.put(:is_executing, true)
|> Map.put(:phase, translated_phase) |> Map.put(:phase, translated_phase)
@@ -73,22 +85,32 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
|> Map.put(:total, total) |> Map.put(:total, total)
|> Map.put(:detail, detail_text) |> Map.put(:detail, detail_text)
|> Map.put(:eta, eta) |> Map.put(:eta, eta)
end) end
)
) )
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec finish_execution(term(), term(), term(), term(), term()) :: term()
def finish_execution(socket, ref, result, reload, append_output) do def finish_execution(socket, ref, result, reload, append_output) do
case Map.get(socket.assigns.import_editor_execution_task_refs, ref) do case Map.get(socket.assigns.import_editor_execution_task_refs, ref) do
nil -> nil ->
socket socket
definition_id -> 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 =
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 case result do
{:ok, execution_result} -> {:ok, execution_result} ->
@@ -106,7 +128,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
ref: nil 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) |> reload.(socket.assigns.workbench)
{:error, %{message: message}} -> {:error, %{message: message}} ->
@@ -144,7 +171,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end end
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) message = inspect(reason)
case kind do case kind do
@@ -157,10 +186,18 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
socket socket
definition_id -> 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)
)
|> Phoenix.Component.assign( |> Phoenix.Component.assign(
:import_editor_execution_states, :import_editor_execution_states,
Map.put(socket.assigns.import_editor_execution_states, definition_id, %{ Map.put(socket.assigns.import_editor_execution_states, definition_id, %{
@@ -177,8 +214,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end end
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 def handle_task_down(socket, _kind, _ref, _reason, _reload, _append_output), do: socket
@spec default_execution_state() :: term()
def default_execution_state do def default_execution_state do
%{ %{
is_executing: false, is_executing: false,
@@ -195,6 +234,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
} }
end end
@spec execution_progress_width(term()) :: term()
def execution_progress_width(state) do def execution_progress_width(state) do
current = Map.get(state, :current, 0) current = Map.get(state, :current, 0)
total = Map.get(state, :total, 0) total = Map.get(state, :total, 0)
@@ -205,25 +245,36 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end end
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: 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} 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(nil), do: nil
def to_string_or_nil(value) when is_binary(value), do: value def to_string_or_nil(value) when is_binary(value), do: value
def to_string_or_nil(value), do: inspect(value) def to_string_or_nil(value), do: inspect(value)
@spec format_eta(term()) :: term()
def format_eta(nil), do: nil def format_eta(nil), do: nil
def format_eta(ms) when is_integer(ms) and ms >= 0 do def format_eta(ms) when is_integer(ms) and ms >= 0 do
seconds = div(ms, 1000) seconds = div(ms, 1000)
if seconds < 60 do if seconds < 60 do
translated("importAnalysis.eta", %{value: translated("importAnalysis.etaSeconds", %{count: seconds})}) translated("importAnalysis.eta", %{
value: translated("importAnalysis.etaSeconds", %{count: seconds})
})
else else
m = div(seconds, 60) m = div(seconds, 60)
s = rem(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
end end
@@ -240,7 +291,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.ProgressTracking do
end end
end end
@spec translate_execution_phase(term()) :: term()
def translate_execution_phase(other), do: other 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 end

View File

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

View File

@@ -32,6 +32,7 @@ defmodule BDS.Desktop.ShellLive.Layout do
end end
defp maybe_set_sidebar_width(workbench, nil), do: workbench defp maybe_set_sidebar_width(workbench, nil), do: workbench
defp maybe_set_sidebar_width(workbench, width), defp maybe_set_sidebar_width(workbench, width),
do: Workbench.set_sidebar_width(workbench, parse_width(width)) do: Workbench.set_sidebar_width(workbench, parse_width(width))

View File

@@ -13,14 +13,16 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
alias BDS.Repo alias BDS.Repo
alias BDS.UI.Workbench alias BDS.UI.Workbench
embed_templates "media_editor_html/*" embed_templates("media_editor_html/*")
@post_picker_limit 10 @post_picker_limit 10
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
assign(socket, :media_editor, build(socket.assigns)) assign(socket, :media_editor, build(socket.assigns))
end end
@spec update(term(), term(), term()) :: term()
def update(socket, params, reload) do def update(socket, params, reload) do
case socket.assigns.current_tab do case socket.assigns.current_tab do
%{type: :media, id: media_id} -> %{type: :media, id: media_id} ->
@@ -38,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec persist_socket(term(), term(), term(), term()) :: term()
def persist_socket(socket, media_id, reload, append_output) do def persist_socket(socket, media_id, reload, append_output) do
case Media.get_media(media_id) do case Media.get_media(media_id) do
nil -> nil ->
@@ -52,9 +55,18 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) |> assign(
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved)) :media_editor_drafts,
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media))) Map.delete(socket.assigns.media_editor_drafts, media_id)
)
|> assign(
:media_editor_save_states,
Map.put(socket.assigns.media_editor_save_states, media_id, :saved)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media))
)
|> reload.(workbench) |> reload.(workbench)
{:error, reason} -> {:error, reason} ->
@@ -65,14 +77,19 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec toggle_quick_actions(term(), term(), term()) :: term()
def toggle_quick_actions(socket, media_id, reload) do def toggle_quick_actions(socket, media_id, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket socket
|> assign(:media_editor_quick_actions_open, Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1))) |> assign(
:media_editor_quick_actions_open,
Map.update(socket.assigns.media_editor_quick_actions_open, media_id, true, &(!&1))
)
|> reload.(workbench) |> reload.(workbench)
end end
@spec replace_file(term(), term(), term(), term()) :: term()
def replace_file(socket, media_id, reload, append_output) do def replace_file(socket, media_id, reload, append_output) do
case FilePicker.choose_file(translated("Replace Media File")) do case FilePicker.choose_file(translated("Replace Media File")) do
{:ok, source_path} -> {:ok, source_path} ->
@@ -82,9 +99,18 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) |> assign(
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media_id, :saved)) :media_editor_drafts,
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media))) Map.delete(socket.assigns.media_editor_drafts, media_id)
)
|> assign(
:media_editor_save_states,
Map.put(socket.assigns.media_editor_save_states, media_id, :saved)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:media, media_id}, tab_meta(updated_media))
)
|> reload.(workbench) |> reload.(workbench)
{:ok, nil} -> {:ok, nil} ->
@@ -106,10 +132,16 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec detect_language(term(), term(), term(), term()) :: term()
def detect_language(socket, media_id, reload, append_output) do def detect_language(socket, media_id, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
socket socket
|> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") |> append_output.(
translated("Detect Language"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
case Media.get_media(media_id) do case Media.get_media(media_id) do
@@ -118,15 +150,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
%MediaRecord{} = media -> %MediaRecord{} = media ->
draft = current_draft(socket.assigns, media) draft = current_draft(socket.assigns, media)
text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "alt", ""), Map.get(draft, "caption", "")], "\n\n")
text =
Enum.join(
[
Map.get(draft, "title", ""),
Map.get(draft, "alt", ""),
Map.get(draft, "caption", "")
],
"\n\n"
)
case AI.detect_language(text) do case AI.detect_language(text) do
{:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" -> {:ok, %{language_code: language_code}}
when is_binary(language_code) and language_code != "" ->
normalized = normalize_language(language_code) normalized = normalize_language(language_code)
case Media.update_media(media.id, %{language: normalized}) do case Media.update_media(media.id, %{language: normalized}) do
{:ok, updated_media} -> {:ok, updated_media} ->
updated_draft = Map.put(current_draft(socket.assigns, media), "language", normalized) updated_draft =
Map.put(current_draft(socket.assigns, media), "language", normalized)
socket socket
|> reconcile_draft(updated_media, updated_draft) |> reconcile_draft(updated_media, updated_draft)
@@ -145,17 +188,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
_other -> _other ->
socket socket
|> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error") |> append_output.(
translated("Detect Language"),
translated("Language detection failed."),
nil,
"error"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
end end
end end
@spec translate(term(), term(), term(), term(), term()) :: term()
def translate(socket, media_id, language, reload, append_output) do def translate(socket, media_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
socket socket
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") |> append_output.(
translated("Translate"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
normalized_language = normalize_language(language) normalized_language = normalize_language(language)
@@ -165,8 +219,14 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
case Media.upsert_media_translation(media_id, normalized_language, translation) do case Media.upsert_media_translation(media_id, normalized_language, translation) do
{:ok, _saved_translation} -> {:ok, _saved_translation} ->
socket socket
|> assign(:media_editor_quick_actions_open, Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false)) |> assign(
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) :media_editor_quick_actions_open,
Map.put(socket.assigns.media_editor_quick_actions_open, media_id, false)
)
|> assign(
:media_editor_translation_forms,
Map.delete(socket.assigns.media_editor_translation_forms, media_id)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
@@ -183,6 +243,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term()
def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do
try do try do
case Media.get_media(media_id) do case Media.get_media(media_id) do
@@ -213,6 +274,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec delete_socket(term(), term(), term(), term()) :: term()
def delete_socket(socket, media_id, reload, append_output) do def delete_socket(socket, media_id, reload, append_output) do
case Media.delete_media(media_id) do case Media.delete_media(media_id) do
{:ok, :deleted} -> {:ok, :deleted} ->
@@ -223,11 +285,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|> assign(:shell_overlay, nil) |> assign(:shell_overlay, nil)
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id})) |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id)) |> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id)) |> assign(
|> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id)) :media_editor_quick_actions_open,
|> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id)) Map.delete(socket.assigns.media_editor_quick_actions_open, media_id)
|> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id)) )
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) |> assign(
:media_editor_post_pickers_open,
Map.delete(socket.assigns.media_editor_post_pickers_open, media_id)
)
|> assign(
:media_editor_post_picker_queries,
Map.delete(socket.assigns.media_editor_post_picker_queries, media_id)
)
|> assign(
:media_editor_save_states,
Map.delete(socket.assigns.media_editor_save_states, media_id)
)
|> assign(
:media_editor_translation_forms,
Map.delete(socket.assigns.media_editor_translation_forms, media_id)
)
|> reload.(workbench) |> reload.(workbench)
{:error, reason} -> {:error, reason} ->
@@ -237,28 +314,43 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec toggle_post_picker(term(), term(), term()) :: term()
def toggle_post_picker(socket, media_id, reload) do def toggle_post_picker(socket, media_id, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket socket
|> assign(:media_editor_post_pickers_open, Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1))) |> assign(
:media_editor_post_pickers_open,
Map.update(socket.assigns.media_editor_post_pickers_open, media_id, true, &(!&1))
)
|> reload.(workbench) |> reload.(workbench)
end end
@spec set_post_picker_query(term(), term(), term(), term()) :: term()
def set_post_picker_query(socket, media_id, query, reload) do def set_post_picker_query(socket, media_id, query, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket socket
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, to_string(query || ""))) |> assign(
:media_editor_post_picker_queries,
Map.put(socket.assigns.media_editor_post_picker_queries, media_id, to_string(query || ""))
)
|> reload.(workbench) |> reload.(workbench)
end end
@spec link_post(term(), term(), term(), term(), term()) :: term()
def link_post(socket, media_id, post_id, reload, append_output) do def link_post(socket, media_id, post_id, reload, append_output) do
case Media.link_media_to_post(media_id, post_id) do case Media.link_media_to_post(media_id, post_id) do
{:ok, _linked} -> {:ok, _linked} ->
socket socket
|> assign(:media_editor_post_pickers_open, Map.put(socket.assigns.media_editor_post_pickers_open, media_id, false)) |> assign(
|> assign(:media_editor_post_picker_queries, Map.put(socket.assigns.media_editor_post_picker_queries, media_id, "")) :media_editor_post_pickers_open,
Map.put(socket.assigns.media_editor_post_pickers_open, media_id, false)
)
|> assign(
:media_editor_post_picker_queries,
Map.put(socket.assigns.media_editor_post_picker_queries, media_id, "")
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
@@ -268,6 +360,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec unlink_post(term(), term(), term(), term(), term()) :: term()
def unlink_post(socket, media_id, post_id, reload, append_output) do def unlink_post(socket, media_id, post_id, reload, append_output) do
case Media.unlink_media_from_post(media_id, post_id) do case Media.unlink_media_from_post(media_id, post_id) do
{:ok, _unlinked} -> {:ok, _unlinked} ->
@@ -280,6 +373,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec edit_translation(term(), term(), term(), term()) :: term()
def edit_translation(socket, media_id, language, reload) do def edit_translation(socket, media_id, language, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
@@ -287,16 +381,20 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
form = %{ form = %{
"language" => language, "language" => language,
"title" => translation && translation.title || "", "title" => (translation && translation.title) || "",
"alt" => translation && translation.alt || "", "alt" => (translation && translation.alt) || "",
"caption" => translation && translation.caption || "" "caption" => (translation && translation.caption) || ""
} }
socket socket
|> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form)) |> assign(
:media_editor_translation_forms,
Map.put(socket.assigns.media_editor_translation_forms, media_id, form)
)
|> reload.(workbench) |> reload.(workbench)
end end
@spec update_translation(term(), term(), term(), term()) :: term()
def update_translation(socket, media_id, params, reload) do def update_translation(socket, media_id, params, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
@@ -308,10 +406,14 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
} }
socket socket
|> assign(:media_editor_translation_forms, Map.put(socket.assigns.media_editor_translation_forms, media_id, form)) |> assign(
:media_editor_translation_forms,
Map.put(socket.assigns.media_editor_translation_forms, media_id, form)
)
|> reload.(workbench) |> reload.(workbench)
end end
@spec save_translation(term(), term(), term(), term()) :: term()
def save_translation(socket, media_id, reload, append_output) do def save_translation(socket, media_id, reload, append_output) do
case Map.get(socket.assigns.media_editor_translation_forms, media_id) do case Map.get(socket.assigns.media_editor_translation_forms, media_id) do
%{"language" => language} = form when language not in [nil, ""] -> %{"language" => language} = form when language not in [nil, ""] ->
@@ -322,7 +424,10 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
}) do }) do
{:ok, _translation} -> {:ok, _translation} ->
socket socket
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) |> assign(
:media_editor_translation_forms,
Map.delete(socket.assigns.media_editor_translation_forms, media_id)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
@@ -336,16 +441,23 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec refresh_translation(term(), term(), term(), term(), term()) :: term()
def refresh_translation(socket, media_id, language, reload, append_output) do def refresh_translation(socket, media_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
socket socket
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") |> append_output.(
translated("Translate"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
case AI.translate_media(media_id, normalize_language(language)) do case AI.translate_media(media_id, normalize_language(language)) do
{:ok, translation} -> {:ok, translation} ->
case Media.upsert_media_translation(media_id, language, translation) do case Media.upsert_media_translation(media_id, language, translation) do
{:ok, _saved_translation} -> socket |> reload.(socket.assigns.workbench) {:ok, _saved_translation} ->
socket |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
socket socket
@@ -361,11 +473,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec delete_translation(term(), term(), term(), term(), term()) :: term()
def delete_translation(socket, media_id, language, reload, append_output) do def delete_translation(socket, media_id, language, reload, append_output) do
case Media.delete_media_translation(media_id, language) do case Media.delete_media_translation(media_id, language) do
{:ok, _deleted?} -> {:ok, _deleted?} ->
socket socket
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id)) |> assign(
:media_editor_translation_forms,
Map.delete(socket.assigns.media_editor_translation_forms, media_id)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
{:error, reason} -> {:error, reason} ->
@@ -375,6 +491,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do
case Media.get_media(media_id) do case Media.get_media(media_id) do
nil -> nil ->
@@ -385,7 +502,9 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
translations = Media.list_media_translations(media.id) translations = Media.list_media_translations(media.id)
form = current_draft(assigns, media) form = current_draft(assigns, media)
picker_query = Map.get(assigns.media_editor_post_picker_queries, media.id, "") picker_query = Map.get(assigns.media_editor_post_picker_queries, media.id, "")
{picker_results, picker_overflow_count} = post_picker_results(media, linked_posts, picker_query)
{picker_results, picker_overflow_count} =
post_picker_results(media, linked_posts, picker_query)
%{ %{
id: media.id, id: media.id,
@@ -416,20 +535,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
def build(_assigns), do: nil def build(_assigns), do: nil
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) @spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec media_editor_save_state_label(term()) :: term()
def media_editor_save_state_label(:dirty), do: translated("Unsaved") def media_editor_save_state_label(:dirty), do: translated("Unsaved")
def media_editor_save_state_label(:saved), do: translated("Saved") def media_editor_save_state_label(:saved), do: translated("Saved")
def media_editor_save_state_label(_state), do: translated("Idle") def media_editor_save_state_label(_state), do: translated("Idle")
@spec language_label(term()) :: term()
def language_label(code) do def language_label(code) do
code code
|> to_string() |> to_string()
|> String.upcase() |> String.upcase()
end end
@spec normalize_language(term()) :: term()
def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase() def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase()
@spec persist(term(), term()) :: term()
def persist(%MediaRecord{} = media, draft) do def persist(%MediaRecord{} = media, draft) do
Media.update_media(media.id, %{ Media.update_media(media.id, %{
title: blank_to_nil(Map.get(draft, "title")), title: blank_to_nil(Map.get(draft, "title")),
@@ -444,7 +569,11 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
defp reconcile_draft(socket, %MediaRecord{} = media, draft) do defp reconcile_draft(socket, %MediaRecord{} = media, draft) do
persisted = persisted_form(media) persisted = persisted_form(media)
dirty? = draft != persisted dirty? = draft != persisted
workbench = if dirty?, do: Workbench.mark_dirty(socket.assigns.workbench, :media, media.id), else: Workbench.clear_dirty(socket.assigns.workbench, :media, media.id)
workbench =
if dirty?,
do: Workbench.mark_dirty(socket.assigns.workbench, :media, media.id),
else: Workbench.clear_dirty(socket.assigns.workbench, :media, media.id)
drafts = drafts =
if dirty? do if dirty? do
@@ -456,8 +585,21 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:media_editor_drafts, drafts) |> assign(:media_editor_drafts, drafts)
|> assign(:media_editor_save_states, Map.put(socket.assigns.media_editor_save_states, media.id, if(dirty?, do: :dirty, else: :idle))) |> assign(
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:media, media.id}, %{title: blank_to_nil(Map.get(draft, "title")) || display_title(media), subtitle: media.original_name || media.mime_type || ""})) :media_editor_save_states,
Map.put(
socket.assigns.media_editor_save_states,
media.id,
if(dirty?, do: :dirty, else: :idle)
)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:media, media.id}, %{
title: blank_to_nil(Map.get(draft, "title")) || display_title(media),
subtitle: media.original_name || media.mime_type || ""
})
)
end end
defp current_draft(assigns, %MediaRecord{} = media) do defp current_draft(assigns, %MediaRecord{} = media) do
@@ -505,10 +647,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
from post in Post, from post in Post,
where: post.project_id == ^media.project_id, where: post.project_id == ^media.project_id,
order_by: [desc: post.updated_at, desc: post.created_at], order_by: [desc: post.updated_at, desc: post.created_at],
select: %{post_id: post.id, title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id)} select: %{
post_id: post.id,
title: fragment("COALESCE(?, ?, ?)", post.title, post.slug, post.id)
}
) )
|> Enum.reject(&MapSet.member?(linked_ids, &1.post_id)) |> Enum.reject(&MapSet.member?(linked_ids, &1.post_id))
|> Enum.filter(fn post -> normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query) end) |> Enum.filter(fn post ->
normalized_query == "" or String.contains?(String.downcase(post.title), normalized_query)
end)
{Enum.take(posts, @post_picker_limit), max(length(posts) - @post_picker_limit, 0)} {Enum.take(posts, @post_picker_limit), max(length(posts) - @post_picker_limit, 0)}
end end
@@ -518,18 +665,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
defp preview_url(%MediaRecord{} = media) do defp preview_url(%MediaRecord{} = media) do
if image?(media), do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}", else: nil if image?(media),
do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}",
else: nil
end end
defp image?(%MediaRecord{} = media), do: String.starts_with?(to_string(media.mime_type || ""), "image/") defp image?(%MediaRecord{} = media),
do: String.starts_with?(to_string(media.mime_type || ""), "image/")
defp display_title(%MediaRecord{} = media), do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id defp display_title(%MediaRecord{} = media),
do: blank_to_nil(media.title) || blank_to_nil(media.original_name) || media.id
defp dimensions_label(%MediaRecord{width: width, height: height})
when is_integer(width) and is_integer(height), do: "#{width} x #{height}"
defp dimensions_label(%MediaRecord{width: width, height: height}) when is_integer(width) and is_integer(height), do: "#{width} x #{height}"
defp dimensions_label(_media), do: nil defp dimensions_label(_media), do: nil
defp format_file_size(size) when is_integer(size) and size >= 1_048_576, do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB" defp format_file_size(size) when is_integer(size) and size >= 1_048_576,
defp format_file_size(size) when is_integer(size), do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB" do: :erlang.float_to_binary(size / 1_048_576, decimals: 1) <> " MB"
defp format_file_size(size) when is_integer(size),
do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
defp format_file_size(_size), do: "0.0 KB" defp format_file_size(_size), do: "0.0 KB"
defp detect_language_enabled?(form) do defp detect_language_enabled?(form) do
@@ -567,5 +724,6 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
end end
defp reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench) defp reload_with_assigned_workbench(socket, reload),
do: reload.(socket, socket.assigns.workbench)
end end

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
use Phoenix.Component use Phoenix.Component
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.MenuEditor.{ alias BDS.Desktop.ShellLive.MenuEditor.{
DraftManagement, DraftManagement,
PageCategory, PageCategory,
@@ -12,8 +13,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
TreePredicates TreePredicates
} }
embed_templates "menu_editor_html/*" embed_templates("menu_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
case socket.assigns[:current_tab] do case socket.assigns[:current_tab] do
%{type: :menu_editor, id: tab_id} -> %{type: :menu_editor, id: tab_id} ->
@@ -36,12 +38,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec select_item(term(), term(), term()) :: term()
def select_item(socket, item_id, reload) do def select_item(socket, item_id, reload) do
socket socket
|> State.update_state(fn state -> %{state | selected_id: item_id} end) |> State.update_state(fn state -> %{state | selected_id: item_id} end)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec change_entry(term(), term(), term()) :: term()
def change_entry(socket, params, reload) do def change_entry(socket, params, reload) do
query = Map.get(params, "query", "") query = Map.get(params, "query", "")
@@ -50,6 +54,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec submit_entry(term(), term()) :: term()
def submit_entry(socket, reload) do def submit_entry(socket, reload) do
case DraftManagement.current_draft(socket.assigns) do case DraftManagement.current_draft(socket.assigns) do
%{type: :page} -> %{type: :page} ->
@@ -67,12 +72,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec cancel_entry(term(), term()) :: term()
def cancel_entry(socket, reload) do def cancel_entry(socket, reload) do
socket socket
|> State.update_state(&DraftManagement.cancel_draft/1) |> State.update_state(&DraftManagement.cancel_draft/1)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec select_page(term(), term(), term()) :: term()
def select_page(socket, post_id, reload) do def select_page(socket, post_id, reload) do
case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do
nil -> nil ->
@@ -85,6 +92,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec select_category(term(), term(), term()) :: term()
def select_category(socket, name, reload) do def select_category(socket, name, reload) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
@@ -99,6 +107,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec toolbar_action(term(), term(), term(), term()) :: term()
def toolbar_action(socket, action, reload, append_output) do def toolbar_action(socket, action, reload, append_output) do
case action do case action do
"add-entry" -> "add-entry" ->
@@ -144,12 +153,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec drop_item(term(), term(), term(), term(), term()) :: term()
def drop_item(socket, drag_item_id, target_item_id, position, reload) do def drop_item(socket, drag_item_id, target_item_id, position, reload) do
socket socket
|> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position)) |> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec handle_keydown(term(), term(), term()) :: term()
def handle_keydown(socket, "Escape", reload) do def handle_keydown(socket, "Escape", reload) do
cancel_entry(socket, reload) cancel_entry(socket, reload)
end end
@@ -158,14 +169,16 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
reload.(socket, socket.assigns.workbench) reload.(socket, socket.assigns.workbench)
end end
attr :menu_editor, :map, required: true attr(:menu_editor, :map, required: true)
@spec menu_editor(term()) :: term()
def menu_editor(assigns) def menu_editor(assigns)
attr :items, :list, required: true attr(:items, :list, required: true)
attr :menu_editor, :map, required: true attr(:menu_editor, :map, required: true)
attr :depth, :integer, required: true attr(:depth, :integer, required: true)
@spec menu_tree_level(term()) :: term()
def menu_tree_level(assigns) do def menu_tree_level(assigns) do
~H""" ~H"""
<%= for item <- @items do %> <%= for item <- @items do %>
@@ -289,8 +302,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
""" """
end end
attr :kind, :atom, required: true attr(:kind, :atom, required: true)
@spec kind_icon(term()) :: term()
def kind_icon(assigns) do def kind_icon(assigns) do
~H""" ~H"""
<%= case @kind do %> <%= case @kind do %>
@@ -306,9 +320,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
""" """
end end
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec row_label(term(), term()) :: term()
def row_label(item, category_titles) do def row_label(item, category_titles) do
if item.kind == :category_archive do if item.kind == :category_archive do
Map.get(category_titles || %{}, item.slug, item.label) Map.get(category_titles || %{}, item.slug, item.label)
@@ -317,6 +333,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end end
end end
@spec kind_label(term()) :: term()
def kind_label(:home), do: translated("menuEditor.type.home") def kind_label(:home), do: translated("menuEditor.type.home")
def kind_label(:page), do: translated("menuEditor.type.page") def kind_label(:page), do: translated("menuEditor.type.page")
def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive") def kind_label(:category_archive), do: translated("menuEditor.type.categoryArchive")
@@ -324,12 +341,17 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
defdelegate draft_item?(menu_editor, item_id), to: TreePredicates defdelegate draft_item?(menu_editor, item_id), to: TreePredicates
@spec editing_title(term()) :: term()
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive") def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title") def editing_title(_menu_editor), do: translated("menuEditor.pagePicker.title")
@spec editing_hint(term()) :: term()
def editing_hint(%{draft: %{type: :category}}), do: translated("menuEditor.categoryPicker.hint") def editing_hint(%{draft: %{type: :category}}), do: translated("menuEditor.categoryPicker.hint")
def editing_hint(_menu_editor), do: translated("menuEditor.createHint") def editing_hint(_menu_editor), do: translated("menuEditor.createHint")
def editing_placeholder(%{draft: %{type: :category}}), do: translated("menuEditor.newCategoryPlaceholder") @spec editing_placeholder(term()) :: term()
def editing_placeholder(%{draft: %{type: :category}}),
do: translated("menuEditor.newCategoryPlaceholder")
def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder") def editing_placeholder(_menu_editor), do: translated("menuEditor.newEntryPlaceholder")
end end

View File

@@ -6,8 +6,10 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
alias BDS.Desktop.ShellLive.MenuEditor.PageCategory alias BDS.Desktop.ShellLive.MenuEditor.PageCategory
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
@spec current_draft(term()) :: term()
def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft) def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft)
@spec start_page_draft(term()) :: term()
def start_page_draft(state) do def start_page_draft(state) do
item = %{ item = %{
item_id: Ecto.UUID.generate(), item_id: Ecto.UUID.generate(),
@@ -29,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
} }
end end
@spec start_category_draft(term()) :: term()
def start_category_draft(state) do def start_category_draft(state) do
item = %{ item = %{
item_id: Ecto.UUID.generate(), item_id: Ecto.UUID.generate(),
@@ -50,6 +53,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
} }
end end
@spec finalize_submenu_draft(term()) :: term()
def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do
label = label =
if(String.trim(query) == "", if(String.trim(query) == "",
@@ -69,12 +73,19 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def finalize_submenu_draft(state), do: state def finalize_submenu_draft(state), do: state
@spec assign_page_to_draft(term(), term()) :: term()
def assign_page_to_draft(%{draft: %{item_id: item_id}} = state, post) do def assign_page_to_draft(%{draft: %{item_id: item_id}} = state, post) do
%{ %{
state state
| items: | items:
TreeOps.update_item(state.items, item_id, fn item -> TreeOps.update_item(state.items, item_id, fn item ->
%{item | kind: :page, label: post.title, slug: PageCategory.blank_to_nil(post.slug), children: []} %{
item
| kind: :page,
label: post.title,
slug: PageCategory.blank_to_nil(post.slug),
children: []
}
end), end),
draft: nil draft: nil
} }
@@ -82,6 +93,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def assign_page_to_draft(state, _post), do: state def assign_page_to_draft(state, _post), do: state
@spec assign_category_to_draft(term(), term()) :: term()
def assign_category_to_draft(%{draft: %{item_id: item_id}} = state, category) do def assign_category_to_draft(%{draft: %{item_id: item_id}} = state, category) do
label = PageCategory.blank_to_nil(category.title) || category.name label = PageCategory.blank_to_nil(category.title) || category.name
@@ -97,6 +109,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def assign_category_to_draft(state, _category), do: state def assign_category_to_draft(state, _category), do: state
@spec cancel_draft(term()) :: term()
def cancel_draft(%{draft: %{item_id: item_id}} = state) do def cancel_draft(%{draft: %{item_id: item_id}} = state) do
items = TreeOps.remove_item(state.items, item_id) items = TreeOps.remove_item(state.items, item_id)
%{state | items: items, selected_id: TreeOps.first_item_id(items), draft: nil} %{state | items: items, selected_id: TreeOps.first_item_id(items), draft: nil}
@@ -104,6 +117,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
def cancel_draft(state), do: state def cancel_draft(state), do: state
@spec confirm_category_draft(term(), term()) :: term()
def confirm_category_draft(socket, update_state_fun) do def confirm_category_draft(socket, update_state_fun) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
draft = current_draft(socket.assigns) draft = current_draft(socket.assigns)
@@ -117,8 +131,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
category = category =
cond do cond do
category != nil -> category category != nil ->
normalized == "" -> %{name: "", title: ""} category
normalized == "" ->
%{name: "", title: ""}
true -> true ->
{:ok, _metadata} = Metadata.add_category(project_id, normalized) {:ok, _metadata} = Metadata.add_category(project_id, normalized)
%{name: normalized, title: normalized} %{name: normalized, title: normalized}

View File

@@ -6,19 +6,26 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do
alias BDS.{Metadata, Repo} alias BDS.{Metadata, Repo}
alias BDS.Posts.Post alias BDS.Posts.Post
@spec page_posts(term()) :: term()
def page_posts(nil), do: [] def page_posts(nil), do: []
def page_posts(project_id) do def page_posts(project_id) do
Repo.all(from post in Post, where: post.project_id == ^project_id, order_by: [asc: post.title, asc: post.slug]) Repo.all(
from post in Post,
where: post.project_id == ^project_id,
order_by: [asc: post.title, asc: post.slug]
)
|> Enum.filter(&("page" in (&1.categories || []))) |> Enum.filter(&("page" in (&1.categories || [])))
end end
@spec page_post(term(), term()) :: term()
def page_post(nil, _post_id), do: nil def page_post(nil, _post_id), do: nil
def page_post(project_id, post_id) do def page_post(project_id, post_id) do
Enum.find(page_posts(project_id), &(&1.id == post_id)) Enum.find(page_posts(project_id), &(&1.id == post_id))
end end
@spec filter_page_posts(term(), term()) :: term()
def filter_page_posts(posts, query) do def filter_page_posts(posts, query) do
normalized = query |> to_string() |> String.trim() |> String.downcase() normalized = query |> to_string() |> String.trim() |> String.downcase()
@@ -29,6 +36,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do
end) end)
end end
@spec category_options(term()) :: term()
def category_options(nil), do: [] def category_options(nil), do: []
def category_options(project_id) do def category_options(project_id) do
@@ -40,6 +48,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do
end) end)
end end
@spec filter_categories(term(), term()) :: term()
def filter_categories(categories, query) do def filter_categories(categories, query) do
normalized = query |> to_string() |> String.trim() |> String.downcase() normalized = query |> to_string() |> String.trim() |> String.downcase()
@@ -50,7 +59,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.PageCategory do
end) end)
end end
@spec blank_to_nil(term()) :: term()
def blank_to_nil(nil), do: nil def blank_to_nil(nil), do: nil
def blank_to_nil(value) do def blank_to_nil(value) do
trimmed = String.trim(to_string(value)) trimmed = String.trim(to_string(value))
if trimmed == "", do: nil, else: trimmed if trimmed == "", do: nil, else: trimmed

View File

@@ -7,6 +7,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
alias BDS.Menu alias BDS.Menu
alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates} alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates}
@spec ensure_state(term()) :: term()
def ensure_state(assigns) do def ensure_state(assigns) do
project_id = assigns.projects.active_project_id project_id = assigns.projects.active_project_id
@@ -16,11 +17,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
end end
end end
@spec update_state(term(), term()) :: term()
def update_state(socket, updater) do def update_state(socket, updater) do
state = ensure_state(socket.assigns) state = ensure_state(socket.assigns)
assign(socket, :menu_editor_state, updater.(state)) assign(socket, :menu_editor_state, updater.(state))
end end
@spec build(term(), term()) :: term()
def build(_assigns, state) do def build(_assigns, state) do
categories = PageCategory.category_options(state.project_id) categories = PageCategory.category_options(state.project_id)
draft = state.draft draft = state.draft
@@ -35,7 +38,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
draft_query: draft_query, draft_query: draft_query,
filtered_pages: filtered_pages:
if(match?(%{type: :page}, draft), if(match?(%{type: :page}, draft),
do: PageCategory.filter_page_posts(PageCategory.page_posts(state.project_id), draft_query), do:
PageCategory.filter_page_posts(PageCategory.page_posts(state.project_id), draft_query),
else: [] else: []
), ),
filtered_categories: filtered_categories:
@@ -53,6 +57,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
} }
end end
@spec save(term(), term(), term()) :: term()
def save(socket, reload, append_output) do def save(socket, reload, append_output) do
state = socket.assigns.menu_editor_state state = socket.assigns.menu_editor_state
@@ -60,12 +65,22 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
Menu.update_menu(state.project_id, Enum.map(state.items, &TreeOps.persisted_item/1)) Menu.update_menu(state.project_id, Enum.map(state.items, &TreeOps.persisted_item/1))
socket socket
|> append_output.(translated("menuEditor.tabTitle"), translated("menuEditor.saved"), nil, "info") |> append_output.(
translated("menuEditor.tabTitle"),
translated("menuEditor.saved"),
nil,
"info"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
defp load_state(nil) do defp load_state(nil) do
%{project_id: nil, items: [TreeOps.home_item()], selected_id: TreeOps.home_item_id(), draft: nil} %{
project_id: nil,
items: [TreeOps.home_item()],
selected_id: TreeOps.home_item_id(),
draft: nil
}
end end
defp load_state(project_id) do defp load_state(project_id) do

View File

@@ -3,12 +3,15 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
@home_item_id "menu-home" @home_item_id "menu-home"
@spec home_item_id() :: term()
def home_item_id, do: @home_item_id def home_item_id, do: @home_item_id
@spec home_item() :: term()
def home_item do def home_item do
%{item_id: @home_item_id, kind: :home, label: "Home", slug: nil, children: [], is_home: true} %{item_id: @home_item_id, kind: :home, label: "Home", slug: nil, children: [], is_home: true}
end end
@spec ui_item(term()) :: term()
def ui_item(%{kind: :home}), do: home_item() def ui_item(%{kind: :home}), do: home_item()
def ui_item(item) do def ui_item(item) do
@@ -24,25 +27,37 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
} }
end end
@spec persisted_item(term()) :: term()
def persisted_item(%{kind: :home}), do: %{kind: :home, label: "Home", slug: nil} def persisted_item(%{kind: :home}), do: %{kind: :home, label: "Home", slug: nil}
def persisted_item(%{kind: :submenu} = item) do def persisted_item(%{kind: :submenu} = item) do
%{kind: :submenu, label: item.label, slug: nil, children: Enum.map(item.children || [], &persisted_item/1)} %{
kind: :submenu,
label: item.label,
slug: nil,
children: Enum.map(item.children || [], &persisted_item/1)
}
end end
def persisted_item(item) do def persisted_item(item) do
%{kind: item.kind, label: item.label, slug: item.slug} %{kind: item.kind, label: item.label, slug: item.slug}
end end
@spec first_item_id(term()) :: term()
def first_item_id([item | _rest]), do: item.item_id def first_item_id([item | _rest]), do: item.item_id
def first_item_id([]), do: nil def first_item_id([]), do: nil
@spec insert_target(term(), term()) :: term()
def insert_target(items, nil), do: {[], length(items)} def insert_target(items, nil), do: {[], length(items)}
def insert_target(items, selected_id) do def insert_target(items, selected_id) do
case find_path(items, selected_id) do case find_path(items, selected_id) do
nil -> {[], length(items)} nil ->
[] -> {[], length(items)} {[], length(items)}
[] ->
{[], length(items)}
path -> path ->
case item_at_path(items, path) do case item_at_path(items, path) do
%{kind: :submenu} -> {path, 0} %{kind: :submenu} -> {path, 0}
@@ -51,9 +66,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end end
end end
@spec path_prefix?(term(), term()) :: term()
def path_prefix?(prefix, path) when length(prefix) > length(path), do: false def path_prefix?(prefix, path) when length(prefix) > length(path), do: false
@spec path_prefix?(term(), term()) :: term()
def path_prefix?(prefix, path), do: Enum.take(path, length(prefix)) == prefix def path_prefix?(prefix, path), do: Enum.take(path, length(prefix)) == prefix
@spec find_path(term(), term(), term()) :: term()
def find_path(items, item_id, path \\ []) do def find_path(items, item_id, path \\ []) do
Enum.find_value(Enum.with_index(items), fn {item, index} -> Enum.find_value(Enum.with_index(items), fn {item, index} ->
next_path = path ++ [index] next_path = path ++ [index]
@@ -71,6 +89,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end) end)
end end
@spec item_at_path(term(), term()) :: term()
def item_at_path(_items, []), do: nil def item_at_path(_items, []), do: nil
def item_at_path(items, [index]) do def item_at_path(items, [index]) do
@@ -84,6 +103,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end end
end end
@spec items_at_path(term(), term()) :: term()
def items_at_path(items, []), do: items def items_at_path(items, []), do: items
def items_at_path(items, [index | rest]) do def items_at_path(items, [index | rest]) do
@@ -93,6 +113,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end end
end end
@spec replace_items_at_path(term(), term(), term()) :: term()
def replace_items_at_path(_items, [], replacement), do: replacement def replace_items_at_path(_items, [], replacement), do: replacement
def replace_items_at_path(items, [index | rest], replacement) do def replace_items_at_path(items, [index | rest], replacement) do
@@ -101,6 +122,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end) end)
end end
@spec update_item(term(), term(), term()) :: term()
def update_item(items, item_id, updater) do def update_item(items, item_id, updater) do
Enum.map(items, fn item -> Enum.map(items, fn item ->
cond do cond do
@@ -111,6 +133,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end) end)
end end
@spec insert_item(term(), term(), term(), term()) :: term()
def insert_item(items, [], index, item) do def insert_item(items, [], index, item) do
List.insert_at(items, index, item) List.insert_at(items, index, item)
end end
@@ -121,10 +144,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end) end)
end end
@spec remove_item(term(), term()) :: term()
def remove_item(items, item_id) do def remove_item(items, item_id) do
remove_item_with_value(items, item_id) |> elem(0) remove_item_with_value(items, item_id) |> elem(0)
end end
@spec remove_item_with_value(term(), term()) :: term()
def remove_item_with_value(items, item_id) do def remove_item_with_value(items, item_id) do
Enum.reduce_while(Enum.with_index(items), {items, nil}, fn {item, index}, _acc -> Enum.reduce_while(Enum.with_index(items), {items, nil}, fn {item, index}, _acc ->
cond do cond do
@@ -135,7 +160,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
{next_children, removed_item} = remove_item_with_value(item.children, item_id) {next_children, removed_item} = remove_item_with_value(item.children, item_id)
if removed_item do if removed_item do
{:halt, {List.replace_at(items, index, %{item | children: next_children}), removed_item}} {:halt,
{List.replace_at(items, index, %{item | children: next_children}), removed_item}}
else else
{:cont, {items, nil}} {:cont, {items, nil}}
end end
@@ -146,16 +172,23 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end) end)
end end
@spec append_child(term(), term(), term()) :: term()
def append_child(items, parent_item_id, child) do def append_child(items, parent_item_id, child) do
update_item(items, parent_item_id, fn item -> update_item(items, parent_item_id, fn item ->
%{item | children: (item.children || []) ++ [child]} %{item | children: (item.children || []) ++ [child]}
end) end)
end end
def move_selected(%{selected_id: selected_id} = state, direction) when direction in [:up, :down] do @spec move_selected(term(), term()) :: term()
def move_selected(%{selected_id: selected_id} = state, direction)
when direction in [:up, :down] do
case find_path(state.items, selected_id) do case find_path(state.items, selected_id) do
nil -> state nil ->
[] -> state state
[] ->
state
path -> path ->
parent_path = Enum.drop(path, -1) parent_path = Enum.drop(path, -1)
index = List.last(path) index = List.last(path)
@@ -175,10 +208,15 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end end
end end
@spec indent_selected(term()) :: term()
def indent_selected(%{selected_id: selected_id} = state) do def indent_selected(%{selected_id: selected_id} = state) do
case find_path(state.items, selected_id) do case find_path(state.items, selected_id) do
nil -> state nil ->
[] -> state state
[] ->
state
path -> path ->
parent_path = Enum.drop(path, -1) parent_path = Enum.drop(path, -1)
index = List.last(path) index = List.last(path)
@@ -193,7 +231,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
case item_at_path(state.items, previous_sibling_path) do case item_at_path(state.items, previous_sibling_path) do
%{kind: :submenu, item_id: sibling_id} -> %{kind: :submenu, item_id: sibling_id} ->
case remove_item_with_value(state.items, selected_id) do case remove_item_with_value(state.items, selected_id) do
{_next_items, nil} -> state {_next_items, nil} ->
state
{next_items, removed_item} -> {next_items, removed_item} ->
%{ %{
state state
@@ -208,18 +248,27 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end end
end end
@spec unindent_selected(term()) :: term()
def unindent_selected(%{selected_id: selected_id} = state) do def unindent_selected(%{selected_id: selected_id} = state) do
case find_path(state.items, selected_id) do case find_path(state.items, selected_id) do
nil -> state nil ->
[] -> state state
[_root_index] -> state
[] ->
state
[_root_index] ->
state
path -> path ->
parent_path = Enum.drop(path, -1) parent_path = Enum.drop(path, -1)
parent_index = List.last(parent_path) parent_index = List.last(parent_path)
grand_parent_path = Enum.drop(parent_path, -1) grand_parent_path = Enum.drop(parent_path, -1)
case remove_item_with_value(state.items, selected_id) do case remove_item_with_value(state.items, selected_id) do
{_next_items, nil} -> state {_next_items, nil} ->
state
{next_items, removed_item} -> {next_items, removed_item} ->
%{ %{
state state
@@ -229,6 +278,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
end end
end end
@spec delete_selected(term()) :: term()
def delete_selected(%{selected_id: @home_item_id} = state), do: state def delete_selected(%{selected_id: @home_item_id} = state), do: state
def delete_selected(%{selected_id: selected_id} = state) do def delete_selected(%{selected_id: selected_id} = state) do
@@ -241,9 +291,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
state state
end end
def drop_selected(state, drag_item_id, target_item_id, _position) when drag_item_id == target_item_id, def drop_selected(state, drag_item_id, target_item_id, _position)
when drag_item_id == target_item_id,
do: state do: state
@spec drop_selected(term(), term(), term(), term()) :: term()
def drop_selected(state, drag_item_id, target_item_id, position) do def drop_selected(state, drag_item_id, target_item_id, position) do
drag_path = find_path(state.items, drag_item_id) drag_path = find_path(state.items, drag_item_id)
target_path = find_path(state.items, target_item_id) target_path = find_path(state.items, target_item_id)
@@ -275,7 +327,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
defp insert_dropped_item(state, next_items, dragged_item, target_path, "inside") do defp insert_dropped_item(state, next_items, dragged_item, target_path, "inside") do
case item_at_path(next_items, target_path) do case item_at_path(next_items, target_path) do
%{kind: :submenu} -> %{kind: :submenu} ->
%{state | items: insert_item(next_items, target_path, 0, dragged_item), selected_id: dragged_item.item_id} %{
state
| items: insert_item(next_items, target_path, 0, dragged_item),
selected_id: dragged_item.item_id
}
_other -> _other ->
state state
@@ -285,12 +341,22 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
defp insert_dropped_item(state, next_items, dragged_item, target_path, "before") do defp insert_dropped_item(state, next_items, dragged_item, target_path, "before") do
parent_path = Enum.drop(target_path, -1) parent_path = Enum.drop(target_path, -1)
index = List.last(target_path) index = List.last(target_path)
%{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id}
%{
state
| items: insert_item(next_items, parent_path, index, dragged_item),
selected_id: dragged_item.item_id
}
end end
defp insert_dropped_item(state, next_items, dragged_item, target_path, _position) do defp insert_dropped_item(state, next_items, dragged_item, target_path, _position) do
parent_path = Enum.drop(target_path, -1) parent_path = Enum.drop(target_path, -1)
index = List.last(target_path) + 1 index = List.last(target_path) + 1
%{state | items: insert_item(next_items, parent_path, index, dragged_item), selected_id: dragged_item.item_id}
%{
state
| items: insert_item(next_items, parent_path, index, dragged_item),
selected_id: dragged_item.item_id
}
end end
end end

View File

@@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
alias BDS.Desktop.ShellLive.MenuEditor.TreeOps alias BDS.Desktop.ShellLive.MenuEditor.TreeOps
@spec can_move_up?(term(), term()) :: term()
def can_move_up?(items, selected_id) do def can_move_up?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do case TreeOps.find_path(items, selected_id) do
[_parent, index] -> index > 0 [_parent, index] -> index > 0
@@ -12,9 +13,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
end end
end end
@spec can_move_down?(term(), term()) :: term()
def can_move_down?(items, selected_id) do def can_move_down?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do case TreeOps.find_path(items, selected_id) do
nil -> false nil ->
false
path -> path ->
parent_path = Enum.drop(path, -1) parent_path = Enum.drop(path, -1)
index = List.last(path) index = List.last(path)
@@ -22,10 +26,15 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
end end
end end
@spec can_indent?(term(), term()) :: term()
def can_indent?(items, selected_id) do def can_indent?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do case TreeOps.find_path(items, selected_id) do
nil -> false nil ->
[] -> false false
[] ->
false
[_index] = path -> [_index] = path ->
index = List.last(path) index = List.last(path)
index > 0 and match?(%{kind: :submenu}, TreeOps.item_at_path(items, [index - 1])) index > 0 and match?(%{kind: :submenu}, TreeOps.item_at_path(items, [index - 1]))
@@ -34,10 +43,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
index = List.last(path) index = List.last(path)
index > 0 and index > 0 and
match?(%{kind: :submenu}, TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1])) match?(
%{kind: :submenu},
TreeOps.item_at_path(items, Enum.drop(path, -1) ++ [index - 1])
)
end end
end end
@spec can_unindent?(term(), term()) :: term()
def can_unindent?(items, selected_id) do def can_unindent?(items, selected_id) do
case TreeOps.find_path(items, selected_id) do case TreeOps.find_path(items, selected_id) do
[_index] -> false [_index] -> false
@@ -46,9 +59,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
end end
end end
@spec can_delete?(term()) :: term()
def can_delete?(selected_id), def can_delete?(selected_id),
do: is_binary(selected_id) and selected_id != TreeOps.home_item_id() do: is_binary(selected_id) and selected_id != TreeOps.home_item_id()
@spec draft_item?(term(), term()) :: term()
def draft_item?(menu_editor, item_id) do def draft_item?(menu_editor, item_id) do
match?(%{item_id: ^item_id}, menu_editor.draft) match?(%{item_id: ^item_id}, menu_editor.draft)
end end

View File

@@ -20,10 +20,12 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
:git_diff :git_diff
] ]
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
assign(socket, :misc_editor, build(socket.assigns)) assign(socket, :misc_editor, build(socket.assigns))
end end
@spec rerun(term()) :: term()
def rerun(socket) do def rerun(socket) do
case meta(socket.assigns) do case meta(socket.assigns) do
%{action: action} when is_binary(action) -> %{action: action} when is_binary(action) ->
@@ -37,6 +39,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec apply_site_validation(term(), term()) :: term()
def apply_site_validation(socket, append_output) do def apply_site_validation(socket, append_output) do
meta = meta(socket.assigns) meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
@@ -68,6 +71,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")} append_output.(socket, translated("Site Validation"), inspect(error), nil, "error")}
end end
@spec toggle_duplicate(term(), term(), term()) :: term()
def toggle_duplicate(socket, pair_id, reload) do def toggle_duplicate(socket, pair_id, reload) do
selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{}) selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{})
current = Map.get(selected_by_tab, socket.assigns.current_tab.id, MapSet.new()) current = Map.get(selected_by_tab, socket.assigns.current_tab.id, MapSet.new())
@@ -87,6 +91,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec dismiss_duplicate(term(), term(), term(), term(), term()) :: term()
def dismiss_duplicate(socket, post_id_a, post_id_b, reload, append_output) do def dismiss_duplicate(socket, post_id_a, post_id_b, reload, append_output) do
case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do case Embeddings.dismiss_duplicate_pair(post_id_a, post_id_b) do
{:ok, _saved_pair} -> {:ok, _saved_pair} ->
@@ -109,6 +114,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec dismiss_selected(term(), term(), term()) :: term()
def dismiss_selected(socket, reload, append_output) do def dismiss_selected(socket, reload, append_output) do
tab_id = socket.assigns.current_tab.id tab_id = socket.assigns.current_tab.id
@@ -141,6 +147,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec fix_translation_validation(term(), term()) :: term()
def fix_translation_validation(socket, append_output) do def fix_translation_validation(socket, append_output) do
report = report =
socket.assigns socket.assigns
@@ -166,6 +173,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")} append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")}
end end
@spec select_git_diff_file(term(), term()) :: term()
def select_git_diff_file(socket, file_path) do def select_git_diff_file(socket, file_path) do
assign( assign(
socket, socket,
@@ -178,6 +186,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
) )
end end
@spec metadata_diff_repair_request(term(), term(), term()) :: term()
def metadata_diff_repair_request(socket, field, direction) do def metadata_diff_repair_request(socket, field, direction) do
meta = meta(socket.assigns) meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
@@ -209,6 +218,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec metadata_diff_orphan_import_request(term()) :: term()
def metadata_diff_orphan_import_request(socket) do def metadata_diff_orphan_import_request(socket) do
meta = meta(socket.assigns) meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
@@ -232,6 +242,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec build(term()) :: term()
def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do
meta = meta(assigns) meta = meta(assigns)
payload = Map.get(meta, :payload, %{}) payload = Map.get(meta, :payload, %{})
@@ -245,11 +256,14 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec build(term()) :: term()
def build(_assigns), do: nil def build(_assigns), do: nil
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec misc_class(term()) :: term()
def misc_class(:site_validation), do: "site-validation-view" def misc_class(:site_validation), do: "site-validation-view"
def misc_class(:metadata_diff), do: "metadata-diff-view" def misc_class(:metadata_diff), do: "metadata-diff-view"
def misc_class(:translation_validation), do: "translation-validation-view" def misc_class(:translation_validation), do: "translation-validation-view"
@@ -257,10 +271,13 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
def misc_class(:git_diff), do: "git-diff-view" def misc_class(:git_diff), do: "git-diff-view"
def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary) def summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary)
@spec summary_items(term()) :: term()
def summary_items(_misc), do: [] def summary_items(_misc), do: []
@spec duplicate_checked?(term(), term()) :: term()
def duplicate_checked?(misc, pair_id), do: MapSet.member?(misc.selected_pairs, pair_id) def duplicate_checked?(misc, pair_id), do: MapSet.member?(misc.selected_pairs, pair_id)
@spec pair_id_from_pair(term()) :: term()
def pair_id_from_pair(pair), do: pair_identity(pair) def pair_id_from_pair(pair), do: pair_identity(pair)
defp build_site_validation(meta, payload) do defp build_site_validation(meta, payload) do
@@ -410,6 +427,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
} }
end end
@spec translation_issue_label(term()) :: term()
def translation_issue_label(issue) do def translation_issue_label(issue) do
case issue_value(issue, :issue) do case issue_value(issue, :issue) do
"same-language-as-canonical" -> "same-language-as-canonical" ->
@@ -426,6 +444,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec translation_issue_languages(term()) :: term()
def translation_issue_languages(issue) do def translation_issue_languages(issue) do
canonical_language = issue_value(issue, :canonical_language) canonical_language = issue_value(issue, :canonical_language)
translation_language = issue_value(issue, :translation_language) translation_language = issue_value(issue, :translation_language)
@@ -440,8 +459,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end end
end end
@spec translation_issue_value(term(), term()) :: term()
def translation_issue_value(issue, key), do: issue_value(issue, key) def translation_issue_value(issue, key), do: issue_value(issue, key)
@spec git_diff_language(term()) :: term()
def git_diff_language(nil), do: "plaintext" def git_diff_language(nil), do: "plaintext"
def git_diff_language(file_path) do def git_diff_language(file_path) do

View File

@@ -12,7 +12,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
alias BDS.Posts.{Post, PostMedia, Translation} alias BDS.Posts.{Post, PostMedia, Translation}
alias BDS.Tags.Tag alias BDS.Tags.Tag
embed_templates "overlay_html/*" embed_templates("overlay_html/*")
def context(assigns, tab_title, tab_subtitle) do def context(assigns, tab_title, tab_subtitle) do
project_id = assigns.projects.active_project_id project_id = assigns.projects.active_project_id
@@ -23,7 +23,12 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
media = media(project_id) media = media(project_id)
%{ %{
current_tab: %{type: current_tab.type, id: current_tab.id, title: tab_title, subtitle: tab_subtitle}, current_tab: %{
type: current_tab.type,
id: current_tab.id,
title: tab_title,
subtitle: tab_subtitle
},
current_post_language: source_language(current_tab, metadata), current_post_language: source_language(current_tab, metadata),
current_media_language: source_language(current_tab, metadata), current_media_language: source_language(current_tab, metadata),
posts: posts, posts: posts,
@@ -59,7 +64,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
def markdown_link(text, url), do: "[#{text}](#{url})" def markdown_link(text, url), do: "[#{text}](#{url})"
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
def project_metadata(nil), do: %{main_language: "en", blog_languages: []} def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
@@ -77,7 +83,15 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
from post in Post, from post in Post,
where: post.project_id == ^project_id, where: post.project_id == ^project_id,
order_by: [desc: post.updated_at, desc: post.created_at], order_by: [desc: post.updated_at, desc: post.created_at],
select: %{id: post.id, title: post.title, slug: post.slug, status: post.status, published_at: post.published_at, updated_at: post.updated_at, language: post.language} select: %{
id: post.id,
title: post.title,
slug: post.slug,
status: post.status,
published_at: post.published_at,
updated_at: post.updated_at,
language: post.language
}
) )
|> Enum.map(fn post -> |> Enum.map(fn post ->
%{ %{
@@ -96,7 +110,14 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
from media in MediaRecord, from media in MediaRecord,
where: media.project_id == ^project_id, where: media.project_id == ^project_id,
order_by: [desc: media.updated_at, desc: media.created_at], order_by: [desc: media.updated_at, desc: media.created_at],
select: %{id: media.id, title: media.title, original_name: media.original_name, mime_type: media.mime_type, alt: media.alt, caption: media.caption} select: %{
id: media.id,
title: media.title,
original_name: media.original_name,
mime_type: media.mime_type,
alt: media.alt,
caption: media.caption
}
) )
|> Enum.map(fn media -> |> Enum.map(fn media ->
%{ %{
@@ -149,7 +170,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
defp existing_translations(_tab), do: %{} defp existing_translations(_tab), do: %{}
defp blog_languages(metadata) do defp blog_languages(metadata) do
([metadata.main_language || "en"] ++ (metadata.blog_languages || []) ++ Enum.map(I18n.supported_languages(), & &1.code)) ([metadata.main_language || "en"] ++
(metadata.blog_languages || []) ++ Enum.map(I18n.supported_languages(), & &1.code))
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
|> Enum.uniq() |> Enum.uniq()
end end
@@ -193,9 +215,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
%Post{} = post -> %Post{} = post ->
[ [
%{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: post.title || title, suggested_value: refine_title(post.title || title), locked: false}, %{
%{key: "excerpt", label: ShellData.translate("Excerpt", %{}, page_language), current_value: post.excerpt || subtitle, suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), locked: false}, key: "title",
%{key: "slug", label: ShellData.translate("Slug", %{}, page_language), current_value: post.slug || slugify(post.title || title), suggested_value: refine_slug(post.slug || slugify(post.title || title)), locked: post.status == :published} label: ShellData.translate("Title", %{}, page_language),
current_value: post.title || title,
suggested_value: refine_title(post.title || title),
locked: false
},
%{
key: "excerpt",
label: ShellData.translate("Excerpt", %{}, page_language),
current_value: post.excerpt || subtitle,
suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle),
locked: false
},
%{
key: "slug",
label: ShellData.translate("Slug", %{}, page_language),
current_value: post.slug || slugify(post.title || title),
suggested_value: refine_slug(post.slug || slugify(post.title || title)),
locked: post.status == :published
}
] ]
_other -> _other ->
@@ -209,9 +249,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
case Media.get_media(media_id) do case Media.get_media(media_id) do
%MediaRecord{} = media -> %MediaRecord{} = media ->
[ [
%{key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: media.title || title, suggested_value: refine_title(media.title || title), locked: false}, %{
%{key: "alt", label: ShellData.translate("Alt Text", %{}, page_language), current_value: media.alt || "", suggested_value: media.alt || title, locked: false}, key: "title",
%{key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false} label: ShellData.translate("Title", %{}, page_language),
current_value: media.title || title,
suggested_value: refine_title(media.title || title),
locked: false
},
%{
key: "alt",
label: ShellData.translate("Alt Text", %{}, page_language),
current_value: media.alt || "",
suggested_value: media.alt || title,
locked: false
},
%{
key: "caption",
label: ShellData.translate("Caption", %{}, page_language),
current_value: media.caption || "",
suggested_value: refine_excerpt(title, media.caption || title),
locked: false
}
] ]
_other -> _other ->
@@ -248,7 +306,13 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: reference_list reference_list: reference_list
} }
rescue rescue
_error -> %{title: ShellData.translate("Delete Media", %{}, page_language), entity_name: media_id, entity_type: "media", reference_list: []} _error ->
%{
title: ShellData.translate("Delete Media", %{}, page_language),
entity_name: media_id,
entity_type: "media",
reference_list: []
}
end end
defp delete_details(%{type: :tags}, page_language) do defp delete_details(%{type: :tags}, page_language) do
@@ -263,16 +327,33 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: [] reference_list: []
} }
rescue rescue
_error -> %{title: ShellData.translate("Delete Tag", %{}, page_language), entity_name: "tag", entity_type: "tag", reference_list: []} _error ->
%{
title: ShellData.translate("Delete Tag", %{}, page_language),
entity_name: "tag",
entity_type: "tag",
reference_list: []
}
end end
defp delete_details(_tab, page_language) do defp delete_details(_tab, page_language) do
%{title: ShellData.translate("Delete", %{}, page_language), entity_name: "", entity_type: "item", reference_list: []} %{
title: ShellData.translate("Delete", %{}, page_language),
entity_name: "",
entity_type: "item",
reference_list: []
}
end end
defp merge_details(project_id, page_language) do defp merge_details(project_id, page_language) do
tags = tags =
Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name], limit: 3, select: tag.name) Repo.all(
from tag in Tag,
where: tag.project_id == ^project_id,
order_by: [asc: tag.name],
limit: 3,
select: tag.name
)
target = List.first(tags) || "tag" target = List.first(tags) || "tag"
@@ -283,7 +364,13 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
message: ShellData.translate("Cannot be undone.", %{}, page_language) message: ShellData.translate("Cannot be undone.", %{}, page_language)
} }
rescue rescue
_error -> %{target: "tag", count: 1, title: ShellData.translate("Merge Tags", %{}, page_language), message: ShellData.translate("Cannot be undone.", %{}, page_language)} _error ->
%{
target: "tag",
count: 1,
title: ShellData.translate("Merge Tags", %{}, page_language),
message: ShellData.translate("Cannot be undone.", %{}, page_language)
}
end end
defp canonical_post_url(post) do defp canonical_post_url(post) do
@@ -302,7 +389,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
if base == "", do: "#{title} overview", else: base <> "." if base == "", do: "#{title} overview", else: base <> "."
end end
defp refine_slug(slug), do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated") defp refine_slug(slug),
do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated")
defp slugify(value) do defp slugify(value) do
value value

View File

@@ -210,8 +210,15 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
defp related_posts(links, key) do defp related_posts(links, key) do
Enum.map(links, fn link -> Enum.map(links, fn link ->
case Posts.get_post(Map.fetch!(link, key)) do case Posts.get_post(Map.fetch!(link, key)) do
%Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id} %Post{} = post ->
_other -> nil %{
id: post.id,
title: post.title || post.slug || post.id,
text: link.link_text || post.slug || post.id
}
_other ->
nil
end end
end) end)
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
@@ -232,15 +239,22 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
defp git_history_target(%{type: :post, id: post_id}) do defp git_history_target(%{type: :post, id: post_id}) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
%Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path} %Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] ->
_other -> nil {project_id, file_path}
_other ->
nil
end end
end end
defp git_history_target(%{type: :media, id: media_id}) do defp git_history_target(%{type: :media, id: media_id}) do
case Media.get_media(media_id) do case Media.get_media(media_id) do
%MediaRecord{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] -> {project_id, file_path} %MediaRecord{project_id: project_id, file_path: file_path}
_other -> nil when file_path not in [nil, ""] ->
{project_id, file_path}
_other ->
nil
end end
end end
@@ -287,5 +301,6 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
defp present?(value), do: value not in [nil, ""] defp present?(value), do: value not in [nil, ""]
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 end

View File

@@ -74,13 +74,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
defdelegate tag_chip_style(color), to: ListValues defdelegate tag_chip_style(color), to: ListValues
embed_templates "post_editor_html/*" embed_templates("post_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
assigns = Map.put(socket.assigns, :project_metadata, project_metadata(socket.assigns.projects.active_project_id)) assigns =
Map.put(
socket.assigns,
:project_metadata,
project_metadata(socket.assigns.projects.active_project_id)
)
assign(socket, :post_editor, build(assigns)) assign(socket, :post_editor, build(assigns))
end end
@spec update(term(), term(), term()) :: term()
def update(socket, params, reload) do def update(socket, params, reload) do
case socket.assigns.current_tab do case socket.assigns.current_tab do
%{type: :post, id: post_id} -> %{type: :post, id: post_id} ->
@@ -91,7 +99,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) canonical_language = canonical_language(post, metadata)
current_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
current_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
requested_language = normalize_language(Map.get(params, "language"), current_language) requested_language = normalize_language(Map.get(params, "language"), current_language)
next_language = next_language =
@@ -117,6 +128,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec persist_socket(term(), term(), term(), term(), term()) :: term()
def persist_socket(socket, post_id, action, reload, append_output) do def persist_socket(socket, post_id, action, reload, append_output) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -125,7 +137,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) canonical_language = canonical_language(post, metadata)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
active_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
draft = current_draft(socket.assigns, post, metadata, active_language) draft = current_draft(socket.assigns, post, metadata, active_language)
case persist(post, draft, active_language, metadata, action) do case persist(post, draft, active_language, metadata, action) do
@@ -135,9 +150,30 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, normalized_form)) |> assign(
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, save_state_for_action(action))) :post_editor_drafts,
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: record_title(record, Posts.get_post!(post_id)), subtitle: Atom.to_string(record_status(record))})) put_nested_map(
socket.assigns.post_editor_drafts,
post_id,
active_language,
normalized_form
)
)
|> assign(
:post_editor_save_states,
Map.put(
socket.assigns.post_editor_save_states,
post_id,
save_state_for_action(action)
)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:post, post_id}, %{
title: record_title(record, Posts.get_post!(post_id)),
subtitle: Atom.to_string(record_status(record))
})
)
|> reload.(workbench) |> reload.(workbench)
{:error, reason} -> {:error, reason} ->
@@ -148,6 +184,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec discard_socket(term(), term(), term(), term()) :: term()
def discard_socket(socket, post_id, reload, append_output) do def discard_socket(socket, post_id, reload, append_output) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -156,7 +193,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) canonical_language = canonical_language(post, metadata)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
active_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
case discard(post, active_language, metadata) do case discard(post, active_language, metadata) do
{:ok, restored_post} -> {:ok, restored_post} ->
@@ -164,9 +203,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language)) |> assign(
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :discarded)) :post_editor_drafts,
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: restored_post.title || restored_post.slug || restored_post.id, subtitle: Atom.to_string(restored_post.status || :draft)})) delete_nested_map(socket.assigns.post_editor_drafts, post_id, active_language)
)
|> assign(
:post_editor_save_states,
Map.put(socket.assigns.post_editor_save_states, post_id, :discarded)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:post, post_id}, %{
title: restored_post.title || restored_post.slug || restored_post.id,
subtitle: Atom.to_string(restored_post.status || :draft)
})
)
|> reload.(workbench) |> reload.(workbench)
{:error, reason} -> {:error, reason} ->
@@ -177,6 +228,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec delete_socket(term(), term(), term(), term()) :: term()
def delete_socket(socket, post_id, reload, append_output) do def delete_socket(socket, post_id, reload, append_output) do
case Posts.delete_post(post_id) do case Posts.delete_post(post_id) do
{:ok, :deleted} -> {:ok, :deleted} ->
@@ -185,13 +237,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket socket
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id})) |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
|> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id)) |> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id))
|> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id)) |> assign(
|> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id)) :post_editor_active_languages,
|> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id)) Map.delete(socket.assigns.post_editor_active_languages, post_id)
|> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, post_id)) )
|> assign(
:post_editor_tag_queries,
Map.delete(socket.assigns.post_editor_tag_queries, post_id)
)
|> assign(
:post_editor_category_queries,
Map.delete(socket.assigns.post_editor_category_queries, post_id)
)
|> assign(
:post_editor_quick_actions_open,
Map.delete(socket.assigns.post_editor_quick_actions_open, post_id)
)
|> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id)) |> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id))
|> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id)) |> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id))
|> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id)) |> assign(
:post_editor_save_states,
Map.delete(socket.assigns.post_editor_save_states, post_id)
)
|> reload.(workbench) |> reload.(workbench)
{:error, reason} -> {:error, reason} ->
@@ -201,6 +268,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec set_mode(term(), term(), term(), term()) :: term()
def set_mode(socket, post_id, mode, reload) do def set_mode(socket, post_id, mode, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
normalized_mode = normalize_mode(mode) normalized_mode = normalize_mode(mode)
@@ -216,38 +284,67 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
socket socket
|> assign(:post_editor_modes, Map.put(socket.assigns.post_editor_modes, post_id, normalized_mode)) |> assign(
:post_editor_modes,
Map.put(socket.assigns.post_editor_modes, post_id, normalized_mode)
)
|> reload.(workbench) |> reload.(workbench)
end end
@spec toggle_section(term(), term(), term(), term()) :: term()
def toggle_section(socket, post_id, section, reload) when section in [:metadata, :excerpt] do def toggle_section(socket, post_id, section, reload) when section in [:metadata, :excerpt] do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket socket
|> assign(:post_editor_expanded, Map.put(socket.assigns.post_editor_expanded, post_id, toggled_sections(socket.assigns.post_editor_expanded, post_id, section))) |> assign(
:post_editor_expanded,
Map.put(
socket.assigns.post_editor_expanded,
post_id,
toggled_sections(socket.assigns.post_editor_expanded, post_id, section)
)
)
|> reload.(workbench) |> reload.(workbench)
end end
@spec select_language(term(), term(), term(), term()) :: term()
def select_language(socket, post_id, language, reload) do def select_language(socket, post_id, language, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket socket
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, normalize_language(language, language))) |> assign(
:post_editor_active_languages,
Map.put(
socket.assigns.post_editor_active_languages,
post_id,
normalize_language(language, language)
)
)
|> reload.(workbench) |> reload.(workbench)
end end
@spec toggle_quick_actions(term(), term(), term()) :: term()
def toggle_quick_actions(socket, post_id, reload) do def toggle_quick_actions(socket, post_id, reload) do
workbench = socket.assigns.workbench workbench = socket.assigns.workbench
socket socket
|> assign(:post_editor_quick_actions_open, Map.update(socket.assigns.post_editor_quick_actions_open, post_id, true, &(!&1))) |> assign(
:post_editor_quick_actions_open,
Map.update(socket.assigns.post_editor_quick_actions_open, post_id, true, &(!&1))
)
|> reload.(workbench) |> reload.(workbench)
end end
@spec detect_language(term(), term(), term(), term()) :: term()
def detect_language(socket, post_id, reload, append_output) do def detect_language(socket, post_id, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
socket socket
|> append_output.(translated("Detect Language"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") |> append_output.(
translated("Detect Language"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
@@ -257,14 +354,24 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) canonical_language = canonical_language(post, metadata)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
active_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
draft = current_draft(socket.assigns, post, metadata, active_language) draft = current_draft(socket.assigns, post, metadata, active_language)
text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "content", "")], "\n\n") text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "content", "")], "\n\n")
case AI.detect_language(text) do case AI.detect_language(text) do
{:ok, %{language_code: language_code}} when is_binary(language_code) and language_code != "" -> {:ok, %{language_code: language_code}}
when is_binary(language_code) and language_code != "" ->
socket socket
|> put_draft_field(post_id, post, active_language, "language", normalize_language(language_code, canonical_language)) |> put_draft_field(
post_id,
post,
active_language,
"language",
normalize_language(language_code, canonical_language)
)
|> reload_with_assigned_workbench(reload) |> reload_with_assigned_workbench(reload)
{:error, reason} -> {:error, reason} ->
@@ -274,17 +381,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
_other -> _other ->
socket socket
|> append_output.(translated("Detect Language"), translated("Language detection failed."), nil, "error") |> append_output.(
translated("Detect Language"),
translated("Language detection failed."),
nil,
"error"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
end end
end end
end end
@spec translate(term(), term(), term(), term(), term()) :: term()
def translate(socket, post_id, language, reload, append_output) do def translate(socket, post_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) do
socket socket
|> append_output.(translated("Translate"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info") |> append_output.(
translated("Translate"),
translated("Automatic AI actions stay gated by airplane mode."),
nil,
"info"
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
normalized_language = normalize_language(language, "") normalized_language = normalize_language(language, "")
@@ -298,9 +416,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
content: translation.content content: translation.content
}) do }) do
socket socket
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, normalized_language)) |> assign(
|> assign(:post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, normalized_language)) :post_editor_active_languages,
|> assign(:post_editor_quick_actions_open, Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false)) Map.put(socket.assigns.post_editor_active_languages, post_id, normalized_language)
)
|> assign(
:post_editor_drafts,
delete_nested_map(socket.assigns.post_editor_drafts, post_id, normalized_language)
)
|> assign(
:post_editor_quick_actions_open,
Map.put(socket.assigns.post_editor_quick_actions_open, post_id, false)
)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
{:error, reason} -> {:error, reason} ->
@@ -317,6 +444,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term()
def apply_ai_suggestions(socket, post_id, fields, reload, append_output) do def apply_ai_suggestions(socket, post_id, fields, reload, append_output) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -340,12 +468,30 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
case Posts.update_post(post_id, attrs) do case Posts.update_post(post_id, attrs) do
{:ok, updated_post} -> {:ok, updated_post} ->
metadata = project_metadata(updated_post.project_id) metadata = project_metadata(updated_post.project_id)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language(updated_post, metadata))
active_language =
Map.get(
socket.assigns.post_editor_active_languages,
post_id,
canonical_language(updated_post, metadata)
)
refreshed_form = persisted_form(updated_post, metadata, active_language) refreshed_form = persisted_form(updated_post, metadata, active_language)
socket socket
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, refreshed_form)) |> assign(
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) :post_editor_drafts,
put_nested_map(
socket.assigns.post_editor_drafts,
post_id,
active_language,
refreshed_form
)
)
|> assign(
:post_editor_save_states,
Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)
)
|> assign(:shell_overlay, nil) |> assign(:shell_overlay, nil)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
@@ -358,6 +504,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec insert_content(term(), term(), term(), term()) :: term()
def insert_content(socket, post_id, snippet, reload) do def insert_content(socket, post_id, snippet, reload) do
socket socket
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{id: post_id, content: snippet}) |> Phoenix.LiveView.push_event("post-editor-insert-content", %{id: post_id, content: snippet})
@@ -365,6 +512,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec add_list_value(term(), term(), term(), term(), term()) :: term()
def add_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do def add_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -373,7 +521,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) canonical_language = canonical_language(post, metadata)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
active_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
draft = current_draft(socket.assigns, post, metadata, active_language) draft = current_draft(socket.assigns, post, metadata, active_language)
normalized = normalize_list_entry(value) normalized = normalize_list_entry(value)
@@ -398,6 +549,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec remove_list_value(term(), term(), term(), term(), term()) :: term()
def remove_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do def remove_list_value(socket, post_id, kind, value, reload) when kind in [:tags, :categories] do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -406,9 +558,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = project_metadata(post.project_id) metadata = project_metadata(post.project_id)
canonical_language = canonical_language(post, metadata) canonical_language = canonical_language(post, metadata)
active_language = Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
active_language =
Map.get(socket.assigns.post_editor_active_languages, post_id, canonical_language)
draft = current_draft(socket.assigns, post, metadata, active_language) draft = current_draft(socket.assigns, post, metadata, active_language)
updated = draft |> Map.get(field_key(kind), "") |> csv_to_list() |> Enum.reject(&(&1 == value)) |> Enum.join(", ")
updated =
draft
|> Map.get(field_key(kind), "")
|> csv_to_list()
|> Enum.reject(&(&1 == value))
|> Enum.join(", ")
socket socket
|> put_draft_field(post_id, post, active_language, field_key(kind), updated) |> put_draft_field(post_id, post, active_language, field_key(kind), updated)
@@ -416,6 +577,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
end end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do
case Posts.get_post(post_id) do case Posts.get_post(post_id) do
nil -> nil ->
@@ -424,7 +586,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post -> %Post{} = post ->
metadata = assigned_project_metadata(assigns) metadata = assigned_project_metadata(assigns)
canonical_language = canonical_language(post, metadata) canonical_language = canonical_language(post, metadata)
active_language = Map.get(assigns.post_editor_active_languages, post.id, canonical_language)
active_language =
Map.get(assigns.post_editor_active_languages, post.id, canonical_language)
translations = translations(post.id) translations = translations(post.id)
persisted = DraftManagement.persisted_form(post, metadata, active_language, translations) persisted = DraftManagement.persisted_form(post, metadata, active_language, translations)
@@ -453,13 +618,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
metadata_expanded: Map.get(expanded, :metadata, false), metadata_expanded: Map.get(expanded, :metadata, false),
excerpt_expanded: Map.get(expanded, :excerpt, false), excerpt_expanded: Map.get(expanded, :excerpt, false),
mode: Map.get(assigns.post_editor_modes, post.id, :markdown), mode: Map.get(assigns.post_editor_modes, post.id, :markdown),
editing_canonical?: editing_canonical_language?(translations, active_language, canonical_language), editing_canonical?:
editing_canonical_language?(translations, active_language, canonical_language),
can_publish?: post.status == :draft, can_publish?: post.status == :draft,
can_delete?: post.status == :published, can_delete?: post.status == :published,
has_published_version?: has_published_version?(post), has_published_version?: has_published_version?(post),
discard_label: discard_label(post), discard_label: discard_label(post),
discard_title: discard_title(post), discard_title: discard_title(post),
detect_language_enabled?: not blank?(Map.get(form, "title")) or not blank?(Map.get(form, "content")), detect_language_enabled?:
not blank?(Map.get(form, "title")) or not blank?(Map.get(form, "content")),
can_translate?: Enum.any?(languages(metadata), &(&1 != canonical_language)), can_translate?: Enum.any?(languages(metadata), &(&1 != canonical_language)),
languages: languages(metadata), languages: languages(metadata),
form: form, form: form,
@@ -469,16 +636,45 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
tag_values: tag_values(form), tag_values: tag_values(form),
tag_chips: tag_chips(form, Tags.list_tags(post.project_id)), tag_chips: tag_chips(form, Tags.list_tags(post.project_id)),
tag_query: query_value(assigns, :tags, post.id), tag_query: query_value(assigns, :tags, post.id),
tag_query_addable?: query_addable?(query_value(assigns, :tags, post.id), tag_values(form), Tags.list_tags(post.project_id), fn option -> option.name end), tag_query_addable?:
query_addable?(
query_value(assigns, :tags, post.id),
tag_values(form),
Tags.list_tags(post.project_id),
fn option -> option.name end
),
category_values: category_values(form), category_values: category_values(form),
category_query: query_value(assigns, :categories, post.id), category_query: query_value(assigns, :categories, post.id),
category_options: metadata.categories || [], category_options: metadata.categories || [],
category_query_addable?: query_addable?(query_value(assigns, :categories, post.id), category_values(form), metadata.categories || [], & &1), category_query_addable?:
tag_suggestions: tag_suggestions(form, Tags.list_tags(post.project_id), query_value(assigns, :tags, post.id)), query_addable?(
category_suggestions: category_suggestions(form, metadata.categories || [], query_value(assigns, :categories, post.id)), query_value(assigns, :categories, post.id),
category_values(form),
metadata.categories || [],
& &1
),
tag_suggestions:
tag_suggestions(
form,
Tags.list_tags(post.project_id),
query_value(assigns, :tags, post.id)
),
category_suggestions:
category_suggestions(
form,
metadata.categories || [],
query_value(assigns, :categories, post.id)
),
gallery_count: gallery_count(form), gallery_count: gallery_count(form),
preview_url: preview_url(post, active_language, canonical_language, Map.get(assigns.post_editor_modes, post.id, :markdown)), preview_url:
translation_flags: translation_flags(post, canonical_language, active_language, translations), preview_url(
post,
active_language,
canonical_language,
Map.get(assigns.post_editor_modes, post.id, :markdown)
),
translation_flags:
translation_flags(post, canonical_language, active_language, translations),
linked_media: linked_media(post.id), linked_media: linked_media(post.id),
post_links: post_links(post.id), post_links: post_links(post.id),
footer: footer(post, current_translation, active_language, canonical_language) footer: footer(post, current_translation, active_language, canonical_language)
@@ -488,17 +684,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
def build(_assigns), do: nil def build(_assigns), do: nil
@spec post_status_label(term()) :: term()
def post_status_label(status), do: ShellData.dashboard_status_label(status) def post_status_label(status), do: ShellData.dashboard_status_label(status)
@spec post_editor_save_state_label(term()) :: term()
def post_editor_save_state_label(:dirty), do: translated("Unsaved") def post_editor_save_state_label(:dirty), do: translated("Unsaved")
def post_editor_save_state_label(:saved), do: translated("Saved") def post_editor_save_state_label(:saved), do: translated("Saved")
def post_editor_save_state_label(:published), do: translated("Published") def post_editor_save_state_label(:published), do: translated("Published")
def post_editor_save_state_label(:discarded), do: translated("Reverted") def post_editor_save_state_label(:discarded), do: translated("Reverted")
def post_editor_save_state_label(_state), do: translated("Idle") def post_editor_save_state_label(_state), do: translated("Idle")
@spec post_editor_mode_label(term()) :: term()
def post_editor_mode_label(:markdown), do: translated("Markdown") def post_editor_mode_label(:markdown), do: translated("Markdown")
def post_editor_mode_label(:preview), do: translated("Preview") def post_editor_mode_label(:preview), do: translated("Preview")
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())

View File

@@ -8,11 +8,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
alias BDS.Desktop.ShellLive.PostEditor.PostMetadata alias BDS.Desktop.ShellLive.PostEditor.PostMetadata
alias BDS.UI.Workbench alias BDS.UI.Workbench
@spec normalize_mode(term()) :: term()
def normalize_mode(mode) when mode in [:markdown, :preview], do: mode def normalize_mode(mode) when mode in [:markdown, :preview], do: mode
@spec normalize_mode(term()) :: term()
def normalize_mode("visual"), do: :markdown def normalize_mode("visual"), do: :markdown
def normalize_mode("preview"), do: :preview def normalize_mode("preview"), do: :preview
def normalize_mode(_mode), do: :markdown def normalize_mode(_mode), do: :markdown
@spec normalize_language(term(), term()) :: term()
def normalize_language(value, fallback) do def normalize_language(value, fallback) do
case value |> to_string() |> String.trim() do case value |> to_string() |> String.trim() do
"" -> fallback "" -> fallback
@@ -20,6 +23,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
end end
end end
@spec normalize_params(term(), term(), term()) :: term()
def normalize_params(params, current_language, next_language) do def normalize_params(params, current_language, next_language) do
%{ %{
"title" => Map.get(params, "title", ""), "title" => Map.get(params, "title", ""),
@@ -28,12 +32,17 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
"tags" => Map.get(params, "tags", ""), "tags" => Map.get(params, "tags", ""),
"categories" => Map.get(params, "categories", ""), "categories" => Map.get(params, "categories", ""),
"author" => Map.get(params, "author", ""), "author" => Map.get(params, "author", ""),
"language" => if(current_language == next_language, do: normalize_language(Map.get(params, "language"), current_language), else: next_language), "language" =>
if(current_language == next_language,
do: normalize_language(Map.get(params, "language"), current_language),
else: next_language
),
"do_not_translate" => truthy?(Map.get(params, "do_not_translate")), "do_not_translate" => truthy?(Map.get(params, "do_not_translate")),
"template_slug" => Map.get(params, "template_slug", "") "template_slug" => Map.get(params, "template_slug", "")
} }
end end
@spec current_draft(term(), term(), term(), term()) :: term()
def current_draft(assigns, %Post{} = post, metadata, active_language) do def current_draft(assigns, %Post{} = post, metadata, active_language) do
persisted = persisted_form(post, metadata, active_language) persisted = persisted_form(post, metadata, active_language)
@@ -42,10 +51,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
|> Map.get(active_language, persisted) |> Map.get(active_language, persisted)
end end
@spec persisted_form(term(), term(), term()) :: term()
def persisted_form(%Post{} = post, metadata, active_language) do def persisted_form(%Post{} = post, metadata, active_language) do
persisted_form(post, metadata, active_language, PostMetadata.translations(post.id)) persisted_form(post, metadata, active_language, PostMetadata.translations(post.id))
end end
@spec persisted_form(term(), term(), term(), term()) :: term()
def persisted_form(post, metadata, active_language, translations) do def persisted_form(post, metadata, active_language, translations) do
canonical_language = PostMetadata.canonical_language(post, metadata) canonical_language = PostMetadata.canonical_language(post, metadata)
translation = Map.get(translations, active_language) translation = Map.get(translations, active_language)
@@ -64,8 +75,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
} }
else else
%{ %{
"title" => translation && translation.title || "", "title" => (translation && translation.title) || "",
"excerpt" => translation && translation.excerpt || "", "excerpt" => (translation && translation.excerpt) || "",
"content" => if(translation, do: Posts.editor_body(translation), else: ""), "content" => if(translation, do: Posts.editor_body(translation), else: ""),
"tags" => Enum.join(post.tags || [], ", "), "tags" => Enum.join(post.tags || [], ", "),
"categories" => Enum.join(post.categories || [], ", "), "categories" => Enum.join(post.categories || [], ", "),
@@ -77,22 +88,43 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
end end
end end
@spec maybe_update_draft(term(), term(), term(), term(), term(), term(), term()) :: term()
def maybe_update_draft(socket, post_id, post, current_language, next_language, draft, true) do def maybe_update_draft(socket, post_id, post, current_language, next_language, draft, true) do
workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id) workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)) |> assign(
|> assign(:post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)) :post_editor_drafts,
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, draft)
|> assign(:tab_meta, Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: draft["title"], subtitle: Atom.to_string(post.status || :draft)})) )
|> assign(
:post_editor_active_languages,
Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)
)
|> assign(
:post_editor_save_states,
Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)
)
|> assign(
:tab_meta,
Map.put(socket.assigns.tab_meta, {:post, post_id}, %{
title: draft["title"],
subtitle: Atom.to_string(post.status || :draft)
})
)
|> maybe_drop_old_language_draft(post_id, current_language, next_language) |> maybe_drop_old_language_draft(post_id, current_language, next_language)
end end
def maybe_update_draft(socket, post_id, _post, _current_language, next_language, _draft, false) do def maybe_update_draft(socket, post_id, _post, _current_language, next_language, _draft, false) do
assign(socket, :post_editor_active_languages, Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)) assign(
socket,
:post_editor_active_languages,
Map.put(socket.assigns.post_editor_active_languages, post_id, next_language)
)
end end
@spec put_draft_field(term(), term(), term(), term(), term(), term()) :: term()
def put_draft_field(socket, post_id, post, active_language, field, value) do def put_draft_field(socket, post_id, post, active_language, field, value) do
metadata = PostMetadata.project_metadata(post.project_id) metadata = PostMetadata.project_metadata(post.project_id)
draft = Map.put(current_draft(socket.assigns, post, metadata, active_language), field, value) draft = Map.put(current_draft(socket.assigns, post, metadata, active_language), field, value)
@@ -100,15 +132,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft)) |> assign(
|> assign(:post_editor_save_states, Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)) :post_editor_drafts,
put_nested_map(socket.assigns.post_editor_drafts, post_id, active_language, draft)
)
|> assign(
:post_editor_save_states,
Map.put(socket.assigns.post_editor_save_states, post_id, :dirty)
)
end end
@spec put_query_state(term(), term(), term(), term()) :: term()
def put_query_state(socket, post_id, kind, value) do def put_query_state(socket, post_id, kind, value) do
key = query_key(kind) key = query_key(kind)
assign(socket, key, Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || "")))
assign(
socket,
key,
Map.put(Map.get(socket.assigns, key, %{}), post_id, to_string(value || ""))
)
end end
@spec query_value(term(), term(), term()) :: term()
def query_value(assigns, kind, post_id) do def query_value(assigns, kind, post_id) do
assigns assigns
|> Map.get(query_key(kind), %{}) |> Map.get(query_key(kind), %{})
@@ -118,25 +163,33 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
defp query_key(:tags), do: :post_editor_tag_queries defp query_key(:tags), do: :post_editor_tag_queries
defp query_key(:categories), do: :post_editor_category_queries defp query_key(:categories), do: :post_editor_category_queries
defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) when current_language == next_language, defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language)
when current_language == next_language,
do: socket do: socket
defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do defp maybe_drop_old_language_draft(socket, post_id, current_language, _next_language) do
assign(socket, :post_editor_drafts, delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language)) assign(
socket,
:post_editor_drafts,
delete_nested_map(socket.assigns.post_editor_drafts, post_id, current_language)
)
end end
@spec toggled_sections(term(), term(), term()) :: term()
def toggled_sections(expanded_by_post, post_id, section) do def toggled_sections(expanded_by_post, post_id, section) do
expanded_by_post expanded_by_post
|> Map.get(post_id, %{metadata: false, excerpt: false}) |> Map.get(post_id, %{metadata: false, excerpt: false})
|> Map.put_new(:metadata, false) |> Map.put_new(:metadata, false)
|> Map.put_new(:excerpt, false) |> Map.put_new(:excerpt, false)
|> Map.update!(section, &not &1) |> Map.update!(section, &(not &1))
end end
@spec put_nested_map(term(), term(), term(), term()) :: term()
def put_nested_map(map, key, nested_key, value) do def put_nested_map(map, key, nested_key, value) do
Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value)) Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value))
end end
@spec delete_nested_map(term(), term(), term()) :: term()
def delete_nested_map(map, key, nested_key) do def delete_nested_map(map, key, nested_key) do
case Map.get(map, key) do case Map.get(map, key) do
nil -> nil ->
@@ -150,20 +203,26 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
end end
end end
def reload_with_assigned_workbench(socket, reload), do: reload.(socket, socket.assigns.workbench) @spec reload_with_assigned_workbench(term(), term()) :: term()
def reload_with_assigned_workbench(socket, reload),
do: reload.(socket, socket.assigns.workbench)
@spec save_state_for_action(term()) :: term()
def save_state_for_action(:publish), do: :published def save_state_for_action(:publish), do: :published
def save_state_for_action(_action), do: :saved def save_state_for_action(_action), do: :saved
@spec record_title(term(), term()) :: term()
def record_title(%Translation{title: title}, post), def record_title(%Translation{title: title}, post),
do: blank_to_nil(title) || post.title || post.slug || post.id do: blank_to_nil(title) || post.title || post.slug || post.id
def record_title(%Post{title: title, slug: slug, id: id}, _post), def record_title(%Post{title: title, slug: slug, id: id}, _post),
do: blank_to_nil(title) || blank_to_nil(slug) || id do: blank_to_nil(title) || blank_to_nil(slug) || id
@spec record_status(term()) :: term()
def record_status(%Translation{status: status}), do: status || :draft def record_status(%Translation{status: status}), do: status || :draft
def record_status(%Post{status: status}), do: status || :draft def record_status(%Post{status: status}), do: status || :draft
@spec editing_canonical_language?(term(), term(), term()) :: term()
def editing_canonical_language?(translations, active_language, canonical_language) do def editing_canonical_language?(translations, active_language, canonical_language) do
active_language == canonical_language or not Map.has_key?(translations, active_language) active_language == canonical_language or not Map.has_key?(translations, active_language)
end end

View File

@@ -3,17 +3,22 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
alias BDS.{Metadata, Tags} alias BDS.{Metadata, Tags}
@spec field_key(term()) :: term()
def field_key(:tags), do: "tags" def field_key(:tags), do: "tags"
def field_key(:categories), do: "categories" def field_key(:categories), do: "categories"
@spec tag_values(term()) :: term()
def tag_values(form), do: csv_to_list(Map.get(form, "tags", "")) def tag_values(form), do: csv_to_list(Map.get(form, "tags", ""))
@spec category_values(term()) :: term()
def category_values(form), do: csv_to_list(Map.get(form, "categories", "")) def category_values(form), do: csv_to_list(Map.get(form, "categories", ""))
@spec tag_suggestions(term(), term(), term()) :: term()
def tag_suggestions(form, options, query) do def tag_suggestions(form, options, query) do
selected = MapSet.new(tag_values(form)) selected = MapSet.new(tag_values(form))
filter_suggestions(options, query, fn option -> option.name end, selected) filter_suggestions(options, query, fn option -> option.name end, selected)
end end
@spec tag_chips(term(), term()) :: term()
def tag_chips(form, options) do def tag_chips(form, options) do
option_map = Map.new(options, fn option -> {option.name, option} end) option_map = Map.new(options, fn option -> {option.name, option} end)
@@ -23,6 +28,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
end) end)
end end
@spec category_suggestions(term(), term(), term()) :: term()
def category_suggestions(form, options, query) do def category_suggestions(form, options, query) do
selected = MapSet.new(category_values(form)) selected = MapSet.new(category_values(form))
filter_suggestions(options, query, & &1, selected) filter_suggestions(options, query, & &1, selected)
@@ -34,11 +40,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
options options
|> Enum.filter(fn option -> |> Enum.filter(fn option ->
label = labeler.(option) label = labeler.(option)
not MapSet.member?(selected, label) and (query == "" or String.contains?(String.downcase(label), query))
not MapSet.member?(selected, label) and
(query == "" or String.contains?(String.downcase(label), query))
end) end)
|> Enum.take(8) |> Enum.take(8)
end end
@spec query_addable?(term(), term(), term(), term()) :: term()
def query_addable?(query, selected_values, options, labeler) do def query_addable?(query, selected_values, options, labeler) do
normalized = normalize_query(query) normalized = normalize_query(query)
@@ -54,6 +63,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|> String.downcase() |> String.downcase()
end end
@spec normalize_list_entry(term()) :: term()
def normalize_list_entry(value) do def normalize_list_entry(value) do
value value
|> to_string() |> to_string()
@@ -61,6 +71,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|> String.downcase() |> String.downcase()
end end
@spec ensure_list_value(term(), term(), term()) :: term()
def ensure_list_value(project_id, :tags, value) do def ensure_list_value(project_id, :tags, value) do
if Enum.any?(Tags.list_tags(project_id), &(String.downcase(&1.name) == value)) do if Enum.any?(Tags.list_tags(project_id), &(String.downcase(&1.name) == value)) do
:ok :ok
@@ -83,6 +94,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
_error -> :ok _error -> :ok
end end
@spec csv_to_list(term()) :: term()
def csv_to_list(value) do def csv_to_list(value) do
value value
|> to_string() |> to_string()
@@ -91,6 +103,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|> Enum.reject(&(&1 == "")) |> Enum.reject(&(&1 == ""))
end end
@spec tag_chip_style(term()) :: term()
def tag_chip_style(nil), do: nil def tag_chip_style(nil), do: nil
def tag_chip_style(color) do def tag_chip_style(color) do
@@ -121,5 +134,6 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
defp contrast_color(_color), do: "#ffffff" defp contrast_color(_color), do: "#ffffff"
@spec ai_overlay_fields(term()) :: term()
def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted) def ai_overlay_fields(selected), do: Enum.filter(selected, & &1.accepted)
end end

View File

@@ -6,11 +6,16 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata} alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, PostMetadata}
@spec persist(term(), term(), term(), term(), term()) :: term()
def persist(%Post{} = post, draft, active_language, metadata, action) do def persist(%Post{} = post, draft, active_language, metadata, action) do
canonical_language = PostMetadata.canonical_language(post, metadata) canonical_language = PostMetadata.canonical_language(post, metadata)
translations = PostMetadata.translations(post.id) translations = PostMetadata.translations(post.id)
if DraftManagement.editing_canonical_language?(translations, active_language, canonical_language) do if DraftManagement.editing_canonical_language?(
translations,
active_language,
canonical_language
) do
post post
|> save_canonical_draft(draft) |> save_canonical_draft(draft)
|> maybe_publish_post(post.id, action) |> maybe_publish_post(post.id, action)
@@ -21,12 +26,17 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
end end
end end
@spec discard(term(), term(), term()) :: term()
def discard(%Post{} = post, active_language, metadata) do def discard(%Post{} = post, active_language, metadata) do
canonical_language = PostMetadata.canonical_language(post, metadata) canonical_language = PostMetadata.canonical_language(post, metadata)
current_translations = PostMetadata.translations(post.id) current_translations = PostMetadata.translations(post.id)
cond do cond do
not DraftManagement.editing_canonical_language?(current_translations, active_language, canonical_language) -> not DraftManagement.editing_canonical_language?(
current_translations,
active_language,
canonical_language
) ->
{:ok, post} {:ok, post}
post.file_path not in [nil, ""] and post.status == :draft -> post.file_path not in [nil, ""] and post.status == :draft ->
@@ -37,15 +47,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
end end
end end
@spec has_published_version?(term()) :: term()
def has_published_version?(%Post{} = post), def has_published_version?(%Post{} = post),
do: not is_nil(post.published_at) or post.file_path not in [nil, ""] do: not is_nil(post.published_at) or post.file_path not in [nil, ""]
@spec discard_label(term()) :: term()
def discard_label(%Post{} = post) do def discard_label(%Post{} = post) do
if has_published_version?(post), if has_published_version?(post),
do: translated("Discard Changes"), do: translated("Discard Changes"),
else: translated("Discard Draft") else: translated("Discard Draft")
end end
@spec discard_title(term()) :: term()
def discard_title(%Post{} = post) do def discard_title(%Post{} = post) do
if has_published_version?(post), if has_published_version?(post),
do: translated("Discard changes and restore the published version"), do: translated("Discard changes and restore the published version"),

View File

@@ -8,6 +8,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
alias BDS.Media.Media, as: MediaRecord alias BDS.Media.Media, as: MediaRecord
alias BDS.Posts.{Post, PostMedia} alias BDS.Posts.{Post, PostMedia}
@spec project_metadata(term()) :: term()
def project_metadata(nil), do: %{main_language: "en", blog_languages: []} def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
def project_metadata(project_id) do def project_metadata(project_id) do
@@ -17,6 +18,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
_error -> %{main_language: "en", blog_languages: []} _error -> %{main_language: "en", blog_languages: []}
end end
@spec canonical_language(term(), term()) :: term()
def canonical_language(post, metadata) do def canonical_language(post, metadata) do
BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language( BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language(
post.language, post.language,
@@ -24,28 +26,36 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
) )
end end
@spec translations(term()) :: term()
def translations(post_id) do def translations(post_id) do
{:ok, translations} = Posts.list_post_translations(post_id) {:ok, translations} = Posts.list_post_translations(post_id)
Map.new(translations, fn translation -> {translation.language, translation} end) Map.new(translations, fn translation -> {translation.language, translation} end)
end end
@spec languages(term()) :: term()
def languages(metadata) do def languages(metadata) do
(([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++ Enum.map(I18n.supported_languages(), & &1.code)) (([metadata.main_language || "en"] ++ (metadata.blog_languages || [])) ++
Enum.map(I18n.supported_languages(), & &1.code))
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
|> Enum.uniq() |> Enum.uniq()
end end
@spec template_options(term()) :: term()
def template_options(project_id) do def template_options(project_id) do
Repo.all( Repo.all(
from template in Templates.Template, from template in Templates.Template,
where: template.project_id == ^project_id, where: template.project_id == ^project_id,
order_by: [asc: template.title, asc: template.slug], order_by: [asc: template.title, asc: template.slug],
select: %{slug: template.slug, title: fragment("COALESCE(?, ?)", template.title, template.slug)} select: %{
slug: template.slug,
title: fragment("COALESCE(?, ?)", template.title, template.slug)
}
) )
rescue rescue
_error -> [] _error -> []
end end
@spec linked_media(term()) :: term()
def linked_media(post_id) do def linked_media(post_id) do
rows = rows =
Repo.all( Repo.all(
@@ -74,6 +84,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
_error -> [] _error -> []
end end
@spec post_links(term()) :: term()
def post_links(post_id) do def post_links(post_id) do
%{ %{
backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id), backlinks: related_posts(PostLinks.list_incoming_links(post_id), :source_post_id),
@@ -84,15 +95,29 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
defp related_posts(links, key) do defp related_posts(links, key) do
Enum.map(links, fn link -> Enum.map(links, fn link ->
case Posts.get_post(Map.fetch!(link, key)) do case Posts.get_post(Map.fetch!(link, key)) do
%Post{} = post -> %{id: post.id, title: post.title || post.slug || post.id, text: link.link_text || post.slug || post.id} %Post{} = post ->
_other -> nil %{
id: post.id,
title: post.title || post.slug || post.id,
text: link.link_text || post.slug || post.id
}
_other ->
nil
end end
end) end)
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
end end
@spec translation_flags(term(), term(), term(), term()) :: term()
def translation_flags(post, canonical_language, active_language, translations) do def translation_flags(post, canonical_language, active_language, translations) do
canonical = %{language: canonical_language, flag: I18n.flag(canonical_language), status: Atom.to_string(post.status || :draft), active: active_language == canonical_language, label: canonical_language} canonical = %{
language: canonical_language,
flag: I18n.flag(canonical_language),
status: Atom.to_string(post.status || :draft),
active: active_language == canonical_language,
label: canonical_language
}
others = others =
translations translations
@@ -111,6 +136,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
[canonical | others] [canonical | others]
end end
@spec footer(term(), term(), term(), term()) :: term()
def footer(post, translation, active_language, canonical_language) do def footer(post, translation, active_language, canonical_language) do
if active_language == canonical_language do if active_language == canonical_language do
%{ %{
@@ -120,8 +146,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
} }
else else
%{ %{
created_at: format_timestamp(translation && translation.created_at || post.created_at), created_at: format_timestamp((translation && translation.created_at) || post.created_at),
updated_at: format_timestamp(translation && translation.updated_at || post.updated_at), updated_at: format_timestamp((translation && translation.updated_at) || post.updated_at),
published_at: format_timestamp(translation && translation.published_at) published_at: format_timestamp(translation && translation.published_at)
} }
end end
@@ -135,10 +161,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> Calendar.strftime("%x") |> Calendar.strftime("%x")
end end
@spec display_title(term(), term(), term()) :: term()
def display_title(title, slug, fallback_id) do def display_title(title, slug, fallback_id) do
blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled") blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled")
end end
@spec gallery_count(term()) :: term()
def gallery_count(form) do def gallery_count(form) do
form form
|> Map.get("content", "") |> Map.get("content", "")
@@ -147,8 +175,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> length() |> length()
end end
def preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview, do: nil @spec preview_url(term(), term(), term(), term()) :: term()
def preview_url(_post, _active_language, _canonical_language, mode) when mode != :preview,
do: nil
@spec preview_url(term(), term(), term(), term()) :: term()
def preview_url(%Post{} = post, active_language, canonical_language, :preview) do def preview_url(%Post{} = post, active_language, canonical_language, :preview) do
query = query =
%{} %{}
@@ -156,7 +187,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> maybe_put_query("post_id", post.id) |> maybe_put_query("post_id", post.id)
|> maybe_put_query("lang", active_language != canonical_language && active_language) |> maybe_put_query("lang", active_language != canonical_language && active_language)
Preview.base_url() <> canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query) Preview.base_url() <>
canonical_preview_path(post.created_at, post.slug) <> "?" <> URI.encode_query(query)
end end
defp canonical_preview_path(created_at_ms, slug) do defp canonical_preview_path(created_at_ms, slug) do
@@ -171,10 +203,13 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
defp maybe_put_query(query, key, value), do: Map.put(query, key, value) defp maybe_put_query(query, key, value), do: Map.put(query, key, value)
def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false def truthy?(_value), do: false
@spec blank?(term()) :: term()
def blank?(value), do: blank_to_nil(value) == nil def blank?(value), do: blank_to_nil(value) == nil
@spec blank_to_nil(term()) :: term()
def blank_to_nil(value) do def blank_to_nil(value) do
value value
|> to_string() |> to_string()

View File

@@ -19,7 +19,9 @@ defmodule BDS.Desktop.ShellLive.SessionUtil do
Stream.iterate(1, &(&1 + 1)) Stream.iterate(1, &(&1 + 1))
|> Enum.find_value(fn index -> |> Enum.find_value(fn index ->
candidate = candidate =
if index == 1, do: @default_new_project_name, else: "#{@default_new_project_name} #{index}" if index == 1,
do: @default_new_project_name,
else: "#{@default_new_project_name} #{index}"
if MapSet.member?(existing_names, candidate), do: nil, else: candidate if MapSet.member?(existing_names, candidate), do: nil, else: candidate
end) end)

View File

@@ -17,7 +17,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
alias BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings alias BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings
alias BDS.Desktop.ShellLive.SettingsEditor.StyleEditor alias BDS.Desktop.ShellLive.SettingsEditor.StyleEditor
embed_templates "settings_editor_html/*" embed_templates("settings_editor_html/*")
@settings_sections ~w(project editor content ai technology publishing data mcp) @settings_sections ~w(project editor content ai technology publishing data mcp)
@supported_languages ["en", "de", "fr", "it", "es"] @supported_languages ["en", "de", "fr", "it", "es"]
@@ -45,6 +45,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
defdelegate theme_display_name(theme), to: StyleEditor defdelegate theme_display_name(theme), to: StyleEditor
defdelegate protected_category?(category), to: ManagedCategories defdelegate protected_category?(category), to: ManagedCategories
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
case socket.assigns[:current_tab] do case socket.assigns[:current_tab] do
%{type: :settings} -> %{type: :settings} ->
@@ -64,12 +65,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
end end
end end
@spec update_search(term(), term(), term()) :: term()
def update_search(socket, query, reload) do def update_search(socket, query, reload) do
socket socket
|> assign(:settings_editor_search, to_string(query || "")) |> assign(:settings_editor_search, to_string(query || ""))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec build_settings(term()) :: term()
def build_settings(%{projects: %{active_project_id: nil}}), do: nil def build_settings(%{projects: %{active_project_id: nil}}), do: nil
def build_settings(assigns) do def build_settings(assigns) do
@@ -82,7 +85,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
) )
editor_form = editor_form =
Map.merge(EditorSettings.editor_form(), Map.get(assigns, :settings_editor_editor_draft, %{})) Map.merge(
EditorSettings.editor_form(),
Map.get(assigns, :settings_editor_editor_draft, %{})
)
ai_form = ai_form =
Map.merge(AISettings.ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{})) Map.merge(AISettings.ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{}))
@@ -142,6 +148,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
} }
end end
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}), def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@@ -171,7 +178,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
Enum.filter(@settings_sections, fn section -> Enum.filter(@settings_sections, fn section ->
case section do case section do
"project" -> "project" ->
section_matches?(query, ~w(project name description data url language author bookmarklet)) section_matches?(
query,
~w(project name description data url language author bookmarklet)
)
"editor" -> "editor" ->
section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged)) section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged))
@@ -195,7 +205,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
section_matches?(query, ~w(data rebuild maintenance links thumbnails filesystem)) section_matches?(query, ~w(data rebuild maintenance links thumbnails filesystem))
"mcp" -> "mcp" ->
section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server)) section_matches?(
query,
~w(mcp claude copilot gemini opencode mistral codex agent server)
)
end end
end) end)
end end

View File

@@ -7,6 +7,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
@spec ai_form(term()) :: term()
def ai_form(assigns) do def ai_form(assigns) do
{:ok, online_endpoint} = AI.get_endpoint(:online) {:ok, online_endpoint} = AI.get_endpoint(:online)
{:ok, airplane_endpoint} = AI.get_endpoint(:airplane) {:ok, airplane_endpoint} = AI.get_endpoint(:airplane)
@@ -30,18 +31,21 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
} }
end end
@spec endpoint_model_options(term(), term()) :: term()
def endpoint_model_options(assigns, endpoint_key) do def endpoint_model_options(assigns, endpoint_key) do
assigns assigns
|> Map.get(:settings_editor_endpoint_models, %{}) |> Map.get(:settings_editor_endpoint_models, %{})
|> Map.get(endpoint_key, []) |> Map.get(endpoint_key, [])
end end
@spec update_ai_draft(term(), term(), term()) :: term()
def update_ai_draft(socket, params, reload) do def update_ai_draft(socket, params, reload) do
socket socket
|> assign(:settings_editor_ai_draft, normalize_ai_params(params)) |> assign(:settings_editor_ai_draft, normalize_ai_params(params))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec refresh_ai_models(term(), term(), term(), term()) :: term()
def refresh_ai_models(socket, endpoint_key, reload, append_output) do def refresh_ai_models(socket, endpoint_key, reload, append_output) do
attrs = ai_attrs(socket.assigns) attrs = ai_attrs(socket.assigns)
@@ -65,11 +69,17 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
end end
end end
@spec save_ai(term(), term(), term()) :: term()
def save_ai(socket, reload, append_output) do def save_ai(socket, reload, append_output) do
attrs = ai_attrs(socket.assigns) attrs = ai_attrs(socket.assigns)
with :ok <- with :ok <-
put_endpoint_preferences(:online, attrs.online_url, attrs.online_api_key, attrs.online_chat_model), put_endpoint_preferences(
:online,
attrs.online_url,
attrs.online_api_key,
attrs.online_chat_model
),
:ok <- :ok <-
put_endpoint_preferences( put_endpoint_preferences(
:airplane, :airplane,
@@ -85,7 +95,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
:ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model), :ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model),
:ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model), :ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model),
:ok <- :ok <-
maybe_put_model_preference(:airplane_image_analysis, attrs.offline_image_analysis_model), maybe_put_model_preference(
:airplane_image_analysis,
attrs.offline_image_analysis_model
),
:ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do :ok <- EditorSettings.put_global_setting("ai.system_prompt", attrs.system_prompt) do
socket socket
|> assign(:settings_editor_ai_draft, %{}) |> assign(:settings_editor_ai_draft, %{})
@@ -99,6 +112,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
end end
end end
@spec reset_ai_prompt(term(), term(), term()) :: term()
def reset_ai_prompt(socket, reload, append_output) do def reset_ai_prompt(socket, reload, append_output) do
case EditorSettings.put_global_setting("ai.system_prompt", "") do case EditorSettings.put_global_setting("ai.system_prompt", "") do
:ok -> :ok ->

View File

@@ -6,21 +6,25 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do
alias BDS.Settings alias BDS.Settings
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
@spec editor_form() :: term()
def editor_form do def editor_form do
%{ %{
"default_mode" => get_global_setting("ui.preferred_editor_mode") || "markdown", "default_mode" => get_global_setting("ui.preferred_editor_mode") || "markdown",
"diff_view_style" => get_global_setting("ui.git_diff_view_style") || "inline", "diff_view_style" => get_global_setting("ui.git_diff_view_style") || "inline",
"wrap_long_lines" => get_global_setting("ui.git_diff_word_wrap") == "true", "wrap_long_lines" => get_global_setting("ui.git_diff_word_wrap") == "true",
"hide_unchanged_regions" => get_global_setting("ui.git_diff_hide_unchanged_regions") == "true" "hide_unchanged_regions" =>
get_global_setting("ui.git_diff_hide_unchanged_regions") == "true"
} }
end end
@spec update_editor_draft(term(), term(), term()) :: term()
def update_editor_draft(socket, params, reload) do def update_editor_draft(socket, params, reload) do
socket socket
|> assign(:settings_editor_editor_draft, normalize_editor_params(params)) |> assign(:settings_editor_editor_draft, normalize_editor_params(params))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_editor(term(), term(), term()) :: term()
def save_editor(socket, reload, append_output) do def save_editor(socket, reload, append_output) do
attrs = editor_attrs(socket.assigns) attrs = editor_attrs(socket.assigns)
@@ -43,10 +47,12 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.EditorSettings do
end end
end end
@spec get_global_setting(term()) :: term()
def get_global_setting(key) do def get_global_setting(key) do
Settings.get_global_setting(key) Settings.get_global_setting(key)
end end
@spec put_global_setting(term(), term()) :: term()
def put_global_setting(key, value) do def put_global_setting(key, value) do
Settings.put_global_setting(key, value) Settings.put_global_setting(key, value)
end end

View File

@@ -14,10 +14,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
"page" => %{title: "page", render_in_lists: false, show_title: true} "page" => %{title: "page", render_in_lists: false, show_title: true}
} }
@spec protected_categories() :: term()
def protected_categories, do: @protected_categories def protected_categories, do: @protected_categories
@spec protected_category?(term()) :: term()
def protected_category?(category), do: MapSet.member?(@protected_categories, category) def protected_category?(category), do: MapSet.member?(@protected_categories, category)
@spec category_rows(term()) :: term()
def category_rows(metadata) do def category_rows(metadata) do
categories = Map.get(metadata, :categories, []) categories = Map.get(metadata, :categories, [])
settings = Map.get(metadata, :category_settings, %{}) settings = Map.get(metadata, :category_settings, %{})
@@ -37,12 +40,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end) end)
end end
@spec update_new_category(term(), term(), term()) :: term()
def update_new_category(socket, name, reload) do def update_new_category(socket, name, reload) do
socket socket
|> assign(:settings_editor_new_category, to_string(name || "")) |> assign(:settings_editor_new_category, to_string(name || ""))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec add_category(term(), term(), term()) :: term()
def add_category(socket, reload, append_output) do def add_category(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim() name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim()
@@ -73,11 +78,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end end
end end
@spec reset_categories(term(), term(), term()) :: term()
def reset_categories(socket, reload, append_output) do def reset_categories(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
result = result =
Enum.reduce_while(category_names(project_metadata(socket.assigns)), :ok, fn category, _acc -> Enum.reduce_while(category_names(project_metadata(socket.assigns)), :ok, fn category,
_acc ->
if MapSet.member?(@protected_categories, category) do if MapSet.member?(@protected_categories, category) do
{:cont, :ok} {:cont, :ok}
else else
@@ -102,6 +109,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end end
end end
@spec save_category(term(), term(), term(), term()) :: term()
def save_category(socket, params, reload, append_output) do def save_category(socket, params, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
category = Map.get(params, "category", "") category = Map.get(params, "category", "")
@@ -125,6 +133,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end end
end end
@spec remove_category(term(), term(), term(), term()) :: term()
def remove_category(socket, category, reload, append_output) do def remove_category(socket, category, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id

View File

@@ -16,6 +16,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
%{id: :openai_codex, label: "OpenAI Codex", supported?: false} %{id: :openai_codex, label: "OpenAI Codex", supported?: false}
] ]
@spec mcp_rows() :: term()
def mcp_rows do def mcp_rows do
Enum.map(@mcp_agents, fn agent -> Enum.map(@mcp_agents, fn agent ->
%{ %{
@@ -28,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
end) end)
end end
@spec toggle_mcp_agent(term(), term(), term(), term()) :: term()
def toggle_mcp_agent(socket, agent, reload, append_output) do def toggle_mcp_agent(socket, agent, reload, append_output) do
case find_mcp_agent(agent) do case find_mcp_agent(agent) do
%{id: agent_id, supported?: true} = config -> %{id: agent_id, supported?: true} = config ->

View File

@@ -6,12 +6,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
alias BDS.Metadata alias BDS.Metadata
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
@spec project_metadata(term()) :: term()
def project_metadata(assigns) do def project_metadata(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} -> metadata {:ok, metadata} -> metadata
end end
end end
@spec project_form(term()) :: term()
def project_form(metadata) do def project_form(metadata) do
%{ %{
"name" => Map.get(metadata, :name, ""), "name" => Map.get(metadata, :name, ""),
@@ -28,18 +30,21 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
} }
end end
@spec technology_form(term()) :: term()
def technology_form(project_form) do def technology_form(project_form) do
%{ %{
"semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false) "semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false)
} }
end end
@spec update_project_draft(term(), term(), term()) :: term()
def update_project_draft(socket, params, reload) do def update_project_draft(socket, params, reload) do
socket socket
|> assign(:settings_editor_project_draft, normalize_project_params(params)) |> assign(:settings_editor_project_draft, normalize_project_params(params))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_project(term(), term(), term()) :: term()
def save_project(socket, reload, append_output) do def save_project(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
alias BDS.Metadata alias BDS.Metadata
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
@spec publishing_form(term()) :: term()
def publishing_form(metadata) do def publishing_form(metadata) do
prefs = Map.get(metadata, :publishing_preferences, %{}) prefs = Map.get(metadata, :publishing_preferences, %{})
@@ -17,12 +18,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
} }
end end
@spec update_publishing_draft(term(), term(), term()) :: term()
def update_publishing_draft(socket, params, reload) do def update_publishing_draft(socket, params, reload) do
socket socket
|> assign(:settings_editor_publishing_draft, normalize_publishing_params(params)) |> assign(:settings_editor_publishing_draft, normalize_publishing_params(params))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_publishing(term(), term(), term()) :: term()
def save_publishing(socket, reload, append_output) do def save_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
@@ -39,6 +42,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
end end
end end
@spec clear_publishing(term(), term(), term()) :: term()
def clear_publishing(socket, reload, append_output) do def clear_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id

View File

@@ -29,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
"zinc" "zinc"
] ]
@spec build_style(term()) :: term()
def build_style(%{projects: %{active_project_id: nil}}), do: nil def build_style(%{projects: %{active_project_id: nil}}), do: nil
def build_style(assigns) do def build_style(assigns) do
@@ -40,22 +41,26 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
selected_theme: selected_theme, selected_theme: selected_theme,
applied_theme: current_theme(assigns), applied_theme: current_theme(assigns),
preview_mode: preview_mode, preview_mode: preview_mode,
preview_url: "http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}" preview_url:
"http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
} }
end end
@spec select_style_theme(term(), term(), term()) :: term()
def select_style_theme(socket, theme, reload) do def select_style_theme(socket, theme, reload) do
socket socket
|> assign(:style_editor_theme, to_string(theme || "default")) |> assign(:style_editor_theme, to_string(theme || "default"))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec change_style_preview_mode(term(), term(), term()) :: term()
def change_style_preview_mode(socket, mode, reload) do def change_style_preview_mode(socket, mode, reload) do
socket socket
|> assign(:style_editor_preview_mode, to_string(mode || "auto")) |> assign(:style_editor_preview_mode, to_string(mode || "auto"))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec apply_style_theme(term(), term(), term()) :: term()
def apply_style_theme(socket, reload, append_output) do def apply_style_theme(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
theme = socket.assigns[:style_editor_theme] || current_theme(socket.assigns) theme = socket.assigns[:style_editor_theme] || current_theme(socket.assigns)
@@ -71,6 +76,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
end end
end end
@spec theme_display_name(term()) :: term()
def theme_display_name(theme) do def theme_display_name(theme) do
theme theme
|> to_string() |> to_string()
@@ -78,6 +84,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
|> String.capitalize() |> String.capitalize()
end end
@spec current_theme(term()) :: term()
def current_theme(assigns) do def current_theme(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} -> {:ok, metadata} ->

View File

@@ -22,7 +22,13 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
end end
def create(socket, project_id, "post", callbacks) do def create(socket, project_id, "post", callbacks) do
case BDS.Posts.create_post(%{project_id: project_id, title: "", content: "", tags: [], categories: []}) do case BDS.Posts.create_post(%{
project_id: project_id,
title: "",
content: "",
tags: [],
categories: []
}) do
{:ok, _post} -> {:ok, _post} ->
callbacks.reload.(socket, socket.assigns.workbench) callbacks.reload.(socket, socket.assigns.workbench)
@@ -42,7 +48,12 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:error, reason} -> {:error, reason} ->
socket socket
|> callbacks.append_output.(translated("sidebar.importMedia"), inspect(reason), nil, "error") |> callbacks.append_output.(
translated("sidebar.importMedia"),
inspect(reason),
nil,
"error"
)
|> callbacks.reload.(socket.assigns.workbench) |> callbacks.reload.(socket.assigns.workbench)
end end
@@ -68,13 +79,23 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:ok, script} -> {:ok, script} ->
callbacks.open_sidebar.( callbacks.open_sidebar.(
socket, socket,
%{"route" => "scripts", "id" => script.id, "title" => script.title, "subtitle" => "Automation helpers"}, %{
"route" => "scripts",
"id" => script.id,
"title" => script.title,
"subtitle" => "Automation helpers"
},
:pin :pin
) )
{:error, reason} -> {:error, reason} ->
socket socket
|> callbacks.append_output.(translated("sidebar.scripts.newScript"), inspect(reason), nil, "error") |> callbacks.append_output.(
translated("sidebar.scripts.newScript"),
inspect(reason),
nil,
"error"
)
|> callbacks.reload.(socket.assigns.workbench) |> callbacks.reload.(socket.assigns.workbench)
end end
end end
@@ -90,29 +111,52 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:ok, template} -> {:ok, template} ->
callbacks.open_sidebar.( callbacks.open_sidebar.(
socket, socket,
%{"route" => "templates", "id" => template.id, "title" => template.title, "subtitle" => "Site rendering"}, %{
"route" => "templates",
"id" => template.id,
"title" => template.title,
"subtitle" => "Site rendering"
},
:pin :pin
) )
{:error, reason} -> {:error, reason} ->
socket socket
|> callbacks.append_output.(translated("sidebar.templates.newTemplate"), inspect(reason), nil, "error") |> callbacks.append_output.(
translated("sidebar.templates.newTemplate"),
inspect(reason),
nil,
"error"
)
|> callbacks.reload.(socket.assigns.workbench) |> callbacks.reload.(socket.assigns.workbench)
end end
end end
def create(socket, project_id, "import", callbacks) do def create(socket, project_id, "import", callbacks) do
case ImportDefinitions.create_definition(%{project_id: project_id, name: translated("sidebar.import.newDefinition")}) do case ImportDefinitions.create_definition(%{
project_id: project_id,
name: translated("sidebar.import.newDefinition")
}) do
{:ok, definition} -> {:ok, definition} ->
callbacks.open_sidebar.( callbacks.open_sidebar.(
socket, socket,
%{"route" => "import", "id" => definition.id, "title" => definition.name, "subtitle" => "Import definitions"}, %{
"route" => "import",
"id" => definition.id,
"title" => definition.name,
"subtitle" => "Import definitions"
},
:pin :pin
) )
{:error, reason} -> {:error, reason} ->
socket socket
|> callbacks.append_output.(translated("sidebar.import.newDefinition"), inspect(reason), nil, "error") |> callbacks.append_output.(
translated("sidebar.import.newDefinition"),
inspect(reason),
nil,
"error"
)
|> callbacks.reload.(socket.assigns.workbench) |> callbacks.reload.(socket.assigns.workbench)
end end
end end

View File

@@ -7,13 +7,17 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
if is_map(filters) and Map.get(filters, :enabled) do if is_map(filters) and Map.get(filters, :enabled) do
panel_state = filter_panel_state(socket, view_id) panel_state = filter_panel_state(socket, view_id)
Map.put(sidebar_data, :filters, Map.merge(filters, %{ Map.put(
sidebar_data,
:filters,
Map.merge(filters, %{
filter_panel_visible: panel_state.visible, filter_panel_visible: panel_state.visible,
archive_collapsed: panel_state.archive_collapsed, archive_collapsed: panel_state.archive_collapsed,
tags_collapsed: panel_state.tags_collapsed, tags_collapsed: panel_state.tags_collapsed,
categories_collapsed: panel_state.categories_collapsed, categories_collapsed: panel_state.categories_collapsed,
expanded_year: panel_state.expanded_year expanded_year: panel_state.expanded_year
})) })
)
else else
sidebar_data sidebar_data
end end
@@ -22,7 +26,12 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
def put_filter_panel_state(socket, updater) do def put_filter_panel_state(socket, updater) do
view_id = Atom.to_string(socket.assigns.workbench.active_view) view_id = Atom.to_string(socket.assigns.workbench.active_view)
state = socket |> filter_panel_state(view_id) |> updater.() state = socket |> filter_panel_state(view_id) |> updater.()
Phoenix.Component.assign(socket, :sidebar_filter_panels, Map.put(socket.assigns.sidebar_filter_panels, view_id, state))
Phoenix.Component.assign(
socket,
:sidebar_filter_panels,
Map.put(socket.assigns.sidebar_filter_panels, view_id, state)
)
end end
def current_filters(socket, view_id) do def current_filters(socket, view_id) do
@@ -33,8 +42,17 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
def put_filters(socket, updater) do def put_filters(socket, updater) do
view_id = Atom.to_string(socket.assigns.workbench.active_view) view_id = Atom.to_string(socket.assigns.workbench.active_view)
filters = current_filters(socket, view_id) |> updater.() |> normalize_filters(socket.assigns.sidebar_data)
Phoenix.Component.assign(socket, :sidebar_filters_by_view, Map.put(socket.assigns.sidebar_filters_by_view, view_id, filters)) filters =
current_filters(socket, view_id)
|> updater.()
|> normalize_filters(socket.assigns.sidebar_data)
Phoenix.Component.assign(
socket,
:sidebar_filters_by_view,
Map.put(socket.assigns.sidebar_filters_by_view, view_id, filters)
)
end end
def toggle_filter_value(filters, key, value) do def toggle_filter_value(filters, key, value) do

View File

@@ -11,12 +11,14 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
alias BDS.Tags.Tag alias BDS.Tags.Tag
alias BDS.Templates.Template alias BDS.Templates.Template
embed_templates "tags_editor_html/*" embed_templates("tags_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do def assign_socket(socket) do
assign(socket, :tags_editor, build(socket.assigns)) assign(socket, :tags_editor, build(socket.assigns))
end end
@spec toggle_selection(term(), term(), term()) :: term()
def toggle_selection(socket, tag_name, reload) do def toggle_selection(socket, tag_name, reload) do
selected = Map.get(socket.assigns, :tags_editor_selected, []) selected = Map.get(socket.assigns, :tags_editor_selected, [])
@@ -33,6 +35,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec update_new_tag(term(), term(), term()) :: term()
def update_new_tag(socket, params, reload) do def update_new_tag(socket, params, reload) do
socket socket
|> assign(:tags_editor_new_tag, %{ |> assign(:tags_editor_new_tag, %{
@@ -42,11 +45,16 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec create_tag(term(), term(), term()) :: term()
def create_tag(socket, reload, append_output) do def create_tag(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
draft = Map.get(socket.assigns, :tags_editor_new_tag, %{}) draft = Map.get(socket.assigns, :tags_editor_new_tag, %{})
case Tags.create_tag(%{project_id: project_id, name: Map.get(draft, "name"), color: blank_to_nil(Map.get(draft, "color"))}) do case Tags.create_tag(%{
project_id: project_id,
name: Map.get(draft, "name"),
color: blank_to_nil(Map.get(draft, "color"))
}) do
{:ok, _tag} -> {:ok, _tag} ->
socket socket
|> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""}) |> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""})
@@ -59,6 +67,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
end end
@spec update_edit_tag(term(), term(), term()) :: term()
def update_edit_tag(socket, params, reload) do def update_edit_tag(socket, params, reload) do
socket socket
|> assign(:tags_editor_edit_draft, %{ |> assign(:tags_editor_edit_draft, %{
@@ -69,16 +78,26 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec save_tag(term(), term(), term()) :: term()
def save_tag(socket, reload, append_output) do def save_tag(socket, reload, append_output) do
selected = Map.get(socket.assigns, :tags_editor_selected, []) selected = Map.get(socket.assigns, :tags_editor_selected, [])
draft = Map.get(socket.assigns, :tags_editor_edit_draft, %{}) draft = Map.get(socket.assigns, :tags_editor_edit_draft, %{})
case selected do case selected do
[tag_name] -> [tag_name] ->
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do case Repo.get_by(Tag,
nil -> reload.(socket, socket.assigns.workbench) project_id: socket.assigns.projects.active_project_id,
name: tag_name
) do
nil ->
reload.(socket, socket.assigns.workbench)
%Tag{} = tag -> %Tag{} = tag ->
with {:ok, _updated_tag} <- Tags.update_tag(tag.id, %{color: blank_to_nil(Map.get(draft, "color")), post_template_slug: blank_to_nil(Map.get(draft, "post_template_slug"))}), with {:ok, _updated_tag} <-
Tags.update_tag(tag.id, %{
color: blank_to_nil(Map.get(draft, "color")),
post_template_slug: blank_to_nil(Map.get(draft, "post_template_slug"))
}),
{:ok, renamed_tag} <- maybe_rename_tag(tag, Map.get(draft, "name", tag.name)) do {:ok, renamed_tag} <- maybe_rename_tag(tag, Map.get(draft, "name", tag.name)) do
socket socket
|> assign(:tags_editor_selected, [renamed_tag.name]) |> assign(:tags_editor_selected, [renamed_tag.name])
@@ -92,15 +111,22 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
end end
_other -> reload.(socket, socket.assigns.workbench) _other ->
reload.(socket, socket.assigns.workbench)
end end
end end
@spec delete_selected(term(), term(), term()) :: term()
def delete_selected(socket, reload, append_output) do def delete_selected(socket, reload, append_output) do
case Map.get(socket.assigns, :tags_editor_selected, []) do case Map.get(socket.assigns, :tags_editor_selected, []) do
[tag_name] -> [tag_name] ->
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do case Repo.get_by(Tag,
nil -> reload.(socket, socket.assigns.workbench) project_id: socket.assigns.projects.active_project_id,
name: tag_name
) do
nil ->
reload.(socket, socket.assigns.workbench)
%Tag{} = tag -> %Tag{} = tag ->
case Tags.delete_tag(tag.id) do case Tags.delete_tag(tag.id) do
{:ok, _deleted} -> {:ok, _deleted} ->
@@ -116,16 +142,19 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
end end
_other -> reload.(socket, socket.assigns.workbench) _other ->
reload.(socket, socket.assigns.workbench)
end end
end end
@spec update_merge_target(term(), term(), term()) :: term()
def update_merge_target(socket, target, reload) do def update_merge_target(socket, target, reload) do
socket socket
|> assign(:tags_editor_merge_target, to_string(target || "")) |> assign(:tags_editor_merge_target, to_string(target || ""))
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
@spec merge_selected(term(), term(), term()) :: term()
def merge_selected(socket, reload, append_output) do def merge_selected(socket, reload, append_output) do
selected = Map.get(socket.assigns, :tags_editor_selected, []) selected = Map.get(socket.assigns, :tags_editor_selected, [])
target_name = Map.get(socket.assigns, :tags_editor_merge_target, "") target_name = Map.get(socket.assigns, :tags_editor_merge_target, "")
@@ -136,12 +165,19 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
true -> true ->
project_id = socket.assigns.projects.active_project_id project_id = socket.assigns.projects.active_project_id
tags = Repo.all(from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^selected)
tags =
Repo.all(
from tag in Tag, where: tag.project_id == ^project_id and tag.name in ^selected
)
target = Enum.find(tags, &(&1.name == target_name)) target = Enum.find(tags, &(&1.name == target_name))
sources = Enum.reject(tags, &(&1.name == target_name)) sources = Enum.reject(tags, &(&1.name == target_name))
case target do case target do
nil -> reload.(socket, socket.assigns.workbench) nil ->
reload.(socket, socket.assigns.workbench)
_target -> _target ->
case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do
{:ok, _merged} -> {:ok, _merged} ->
@@ -160,23 +196,41 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
end end
@spec sync(term(), term(), term()) :: term()
def sync(socket, reload, append_output) do def sync(socket, reload, append_output) do
_ = append_output _ = append_output
:ok = Tags.sync_tags_json(socket.assigns.projects.active_project_id) :ok = Tags.sync_tags_json(socket.assigns.projects.active_project_id)
reload.(socket, socket.assigns.workbench) reload.(socket, socket.assigns.workbench)
end end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :tags}} = assigns) do def build(%{current_tab: %{type: :tags}} = assigns) do
project_id = assigns.projects.active_project_id project_id = assigns.projects.active_project_id
tags = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name])
tags =
Repo.all(from tag in Tag, where: tag.project_id == ^project_id, order_by: [asc: tag.name])
counts = tag_counts(project_id) counts = tag_counts(project_id)
selected = Map.get(assigns, :tags_editor_selected, []) selected = Map.get(assigns, :tags_editor_selected, [])
edit_tag = if length(selected) == 1, do: Enum.find(tags, &(&1.name == hd(selected))), else: nil
edit_tag =
if length(selected) == 1, do: Enum.find(tags, &(&1.name == hd(selected))), else: nil
edit_draft = Map.get(assigns, :tags_editor_edit_draft, edit_draft(edit_tag)) edit_draft = Map.get(assigns, :tags_editor_edit_draft, edit_draft(edit_tag))
templates = Repo.all(from template in Template, where: template.project_id == ^project_id, order_by: [asc: template.title], select: %{slug: template.slug, title: template.title})
templates =
Repo.all(
from template in Template,
where: template.project_id == ^project_id,
order_by: [asc: template.title],
select: %{slug: template.slug, title: template.title}
)
%{ %{
tags: Enum.map(tags, fn tag -> %{name: tag.name, color: tag.color, count: Map.get(counts, tag.name, 0)} end), tags:
Enum.map(tags, fn tag ->
%{name: tag.name, color: tag.color, count: Map.get(counts, tag.name, 0)}
end),
selected: selected, selected: selected,
new_tag: Map.get(assigns, :tags_editor_new_tag, %{"name" => "", "color" => ""}), new_tag: Map.get(assigns, :tags_editor_new_tag, %{"name" => "", "color" => ""}),
edit_draft: edit_draft, edit_draft: edit_draft,
@@ -187,14 +241,18 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
def build(_assigns), do: nil def build(_assigns), do: nil
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) @spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec tag_font_size(term(), term()) :: term()
def tag_font_size(count, counts) do def tag_font_size(count, counts) do
max_count = Enum.max([1 | Enum.map(counts, & &1.count)]) max_count = Enum.max([1 | Enum.map(counts, & &1.count)])
ratio = if max_count <= 1, do: 0.0, else: (count - 1) / max(max_count - 1, 1) ratio = if max_count <= 1, do: 0.0, else: (count - 1) / max(max_count - 1, 1)
Float.round(0.85 + (1.8 - 0.85) * ratio, 2) Float.round(0.85 + (1.8 - 0.85) * ratio, 2)
end end
@spec tag_style(term(), term()) :: term()
def tag_style(tag, counts) do def tag_style(tag, counts) do
size = tag_font_size(tag.count, counts) size = tag_font_size(tag.count, counts)
@@ -217,7 +275,13 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
defp maybe_seed_edit_draft(socket, _selected), do: assign(socket, :tags_editor_edit_draft, %{}) defp maybe_seed_edit_draft(socket, _selected), do: assign(socket, :tags_editor_edit_draft, %{})
defp edit_draft(nil), do: %{} defp edit_draft(nil), do: %{}
defp edit_draft(%Tag{} = tag), do: %{"name" => tag.name, "color" => tag.color || "", "post_template_slug" => tag.post_template_slug || ""}
defp edit_draft(%Tag{} = tag),
do: %{
"name" => tag.name,
"color" => tag.color || "",
"post_template_slug" => tag.post_template_slug || ""
}
defp maybe_rename_tag(%Tag{} = tag, next_name) do defp maybe_rename_tag(%Tag{} = tag, next_name) do
normalized = String.trim(to_string(next_name || tag.name)) normalized = String.trim(to_string(next_name || tag.name))
@@ -237,6 +301,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end end
defp blank_to_nil(nil), do: nil defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do defp blank_to_nil(value) do
case String.trim(to_string(value)) do case String.trim(to_string(value)) do
"" -> nil "" -> nil

View File

@@ -46,7 +46,10 @@ defmodule BDS.Desktop.ShellLive.TaskLocalization do
|> Map.put(:message, localize_task_message(Map.get(task, :message), locale)) |> Map.put(:message, localize_task_message(Map.get(task, :message), locale))
|> Map.put(:group_name, localize_task_group(Map.get(task, :group_name), locale)) |> Map.put(:group_name, localize_task_group(Map.get(task, :group_name), locale))
|> Map.put(:status_label, localize_task_status_label(task.status, locale)) |> Map.put(:status_label, localize_task_status_label(task.status, locale))
|> Map.put(:progress_label, if(is_number(progress), do: progress_percent(progress), else: nil)) |> Map.put(
:progress_label,
if(is_number(progress), do: progress_percent(progress), else: nil)
)
end end
defp localize_task_message(nil, _locale), do: nil defp localize_task_message(nil, _locale), do: nil

View File

@@ -41,7 +41,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do
@spec active_group(map()) :: map() | nil @spec active_group(map()) :: map() | nil
def active_group(assigns) do def active_group(assigns) do
Enum.find(assigns.menu_groups || [], fn group -> Atom.to_string(group.id) == assigns.titlebar_menu_group end) Enum.find(assigns.menu_groups || [], fn group ->
Atom.to_string(group.id) == assigns.titlebar_menu_group
end)
end end
@spec active_items(map()) :: [map()] @spec active_items(map()) :: [map()]
@@ -90,7 +92,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do
Handle a keydown event on an open titlebar menu. `invoke_fun` is called Handle a keydown event on an open titlebar menu. `invoke_fun` is called
with the action id (string) when the user activates an item. with the action id (string) when the user activates an item.
""" """
@spec handle_keydown(Phoenix.LiveView.Socket.t(), String.t(), (Phoenix.LiveView.Socket.t(), String.t() -> Phoenix.LiveView.Socket.t())) :: @spec handle_keydown(Phoenix.LiveView.Socket.t(), String.t(), (Phoenix.LiveView.Socket.t(),
String.t() ->
Phoenix.LiveView.Socket.t())) ::
Phoenix.LiveView.Socket.t() Phoenix.LiveView.Socket.t()
def handle_keydown(socket, key, invoke_fun) do def handle_keydown(socket, key, invoke_fun) do
if socket.assigns.titlebar_menu_group do if socket.assigns.titlebar_menu_group do
@@ -114,7 +118,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do
defp rotate_group(socket, offset) do defp rotate_group(socket, offset) do
groups = socket.assigns.menu_groups || [] groups = socket.assigns.menu_groups || []
current_group = socket.assigns.titlebar_menu_group current_group = socket.assigns.titlebar_menu_group
current_index = Enum.find_index(groups, fn group -> Atom.to_string(group.id) == current_group end)
current_index =
Enum.find_index(groups, fn group -> Atom.to_string(group.id) == current_group end)
if is_nil(current_index) or groups == [] do if is_nil(current_index) or groups == [] do
socket socket

View File

@@ -117,7 +117,9 @@ defmodule BDS.Frontmatter do
defp take_block_scalar_lines([line | rest], lines) do defp take_block_scalar_lines([line | rest], lines) do
if String.starts_with?(line, @block_scalar_indent) do if String.starts_with?(line, @block_scalar_indent) do
take_block_scalar_lines(rest, [String.replace_prefix(line, @block_scalar_indent, "") | lines]) take_block_scalar_lines(rest, [
String.replace_prefix(line, @block_scalar_indent, "") | lines
])
else else
{Enum.reverse(lines), [line | rest]} {Enum.reverse(lines), [line | rest]}
end end

View File

@@ -2,13 +2,16 @@ defmodule BDS.Generation do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
import BDS.Generation.Paths, import BDS.Generation.Paths,
except: [post_output_path: 1, post_output_path: 2] except: [post_output_path: 1, post_output_path: 2]
import BDS.Generation.Sitemap, import BDS.Generation.Sitemap,
only: [ only: [
render: 1, render: 1,
render_multi_language: 6 render_multi_language: 6
] ]
import BDS.Generation.Progress import BDS.Generation.Progress
import BDS.Generation.Outputs import BDS.Generation.Outputs
import BDS.Generation.Data import BDS.Generation.Data
@@ -89,7 +92,8 @@ defmodule BDS.Generation do
{:ok, validation_report()} | {:error, term()} {:ok, validation_report()} | {:error, term()}
def validate_site(project_id, sections \\ @core_sections, opts \\ []) def validate_site(project_id, sections \\ @core_sections, opts \\ [])
def validate_site(project_id, sections, opts) when is_binary(project_id) and is_list(sections) and is_list(opts) do def validate_site(project_id, sections, opts)
when is_binary(project_id) and is_list(sections) and is_list(opts) do
with {:ok, plan} <- plan_generation(project_id, sections) do with {:ok, plan} <- plan_generation(project_id, sections) do
on_progress = callback(opts) on_progress = callback(opts)
:ok = report_validation_progress(on_progress, 0.0, "Collecting sitemap URLs...") :ok = report_validation_progress(on_progress, 0.0, "Collecting sitemap URLs...")
@@ -104,9 +108,12 @@ defmodule BDS.Generation do
{:ok, generated_files_list} = list_generated_files(project_id) {:ok, generated_files_list} = list_generated_files(project_id)
generated_file_updated_at = generated_file_updated_at_map(generated_files_list) generated_file_updated_at = generated_file_updated_at_map(generated_files_list)
additional_languages = additional_languages(plan) additional_languages = additional_languages(plan)
published_route_posts = suppress_subtree_translation_variants(data.published_route_posts, additional_languages)
{sitemap_content, sitemap_to_write, additional_expected_paths, additional_post_timestamp_checks} = published_route_posts =
suppress_subtree_translation_variants(data.published_route_posts, additional_languages)
{sitemap_content, sitemap_to_write, additional_expected_paths,
additional_post_timestamp_checks} =
build_validation_sitemap_artifacts( build_validation_sitemap_artifacts(
plan, plan,
data, data,
@@ -190,7 +197,8 @@ defmodule BDS.Generation do
generated_files_on_disk generated_files_on_disk
|> Map.keys() |> Map.keys()
|> Enum.filter(fn relative_path -> |> Enum.filter(fn relative_path ->
path_section(relative_path) in plan.sections and not MapSet.member?(expected_paths, relative_path) path_section(relative_path) in plan.sections and
not MapSet.member?(expected_paths, relative_path)
end) end)
|> Enum.each(fn relative_path -> |> Enum.each(fn relative_path ->
_ = File.rm(output_path(project, relative_path)) _ = File.rm(output_path(project, relative_path))
@@ -215,6 +223,7 @@ defmodule BDS.Generation do
expected_output_map = Map.new(expected_outputs) expected_output_map = Map.new(expected_outputs)
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
published_posts = list_published_posts(project_id) published_posts = list_published_posts(project_id)
targeted_plan = targeted_plan =
build_targeted_validation_plan( build_targeted_validation_plan(
plan_validation_paths(report_paths(report), additional_languages(plan)), plan_validation_paths(report_paths(report), additional_languages(plan)),
@@ -224,7 +233,12 @@ defmodule BDS.Generation do
outputs_to_render = outputs_to_render =
expected_outputs expected_outputs
|> Enum.filter(fn {relative_path, _content} -> |> Enum.filter(fn {relative_path, _content} ->
targeted_output?(relative_path, targeted_plan, plan.language, additional_languages(plan)) targeted_output?(
relative_path,
targeted_plan,
plan.language,
additional_languages(plan)
)
end) end)
Enum.each(outputs_to_render, fn {relative_path, content} -> Enum.each(outputs_to_render, fn {relative_path, content} ->
@@ -243,7 +257,10 @@ defmodule BDS.Generation do
{:ok, {:ok,
%{ %{
rendered_url_count: Enum.count(outputs_to_render, fn {relative_path, _content} -> route_html_path?(relative_path) end), rendered_url_count:
Enum.count(outputs_to_render, fn {relative_path, _content} ->
route_html_path?(relative_path)
end),
deleted_url_count: deleted_url_count, deleted_url_count: deleted_url_count,
removed_empty_dir_count: removed_empty_dir_count removed_empty_dir_count: removed_empty_dir_count
}} }}
@@ -257,15 +274,21 @@ defmodule BDS.Generation do
defdelegate post_output_path(post, language), to: Paths defdelegate post_output_path(post, language), to: Paths
@typedoc "Result returned by `write_generated_file/3,4`." @typedoc "Result returned by `write_generated_file/3,4`."
@type write_result :: %{relative_path: String.t(), content_hash: String.t(), written?: boolean()} @type write_result :: %{
relative_path: String.t(),
content_hash: String.t(),
written?: boolean()
}
@spec write_generated_file(String.t(), String.t(), String.t()) :: {:ok, write_result()} @spec write_generated_file(String.t(), String.t(), String.t()) :: {:ok, write_result()}
def write_generated_file(project_id, relative_path, content), def write_generated_file(project_id, relative_path, content),
do: write_generated_file(project_id, relative_path, content, []) do: write_generated_file(project_id, relative_path, content, [])
@spec write_generated_file(String.t(), String.t(), String.t(), keyword()) :: {:ok, write_result()} @spec write_generated_file(String.t(), String.t(), String.t(), keyword()) ::
{:ok, write_result()}
def write_generated_file(project_id, relative_path, content, opts) def write_generated_file(project_id, relative_path, content, opts)
when is_binary(project_id) and is_binary(relative_path) and is_binary(content) and is_list(opts) do when is_binary(project_id) and is_binary(relative_path) and is_binary(content) and
is_list(opts) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
content_hash = sha256(content) content_hash = sha256(content)
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -331,8 +354,12 @@ defmodule BDS.Generation do
data = generation_data(plan) data = generation_data(plan)
published_translations = flattened_generation_translations(data.translations_by_post) published_translations = flattened_generation_translations(data.translations_by_post)
translations_by_post_language = translation_lookup_map(published_translations) translations_by_post_language = translation_lookup_map(published_translations)
translatable_published_posts = Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
translatable_published_list_posts = Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) translatable_published_posts =
Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
translatable_published_list_posts =
Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
localized_posts_by_language = localized_posts_by_language =
additional_languages(plan) additional_languages(plan)
@@ -421,7 +448,10 @@ defmodule BDS.Generation do
pagefind_outputs = pagefind_outputs =
if :core in plan.sections do if :core in plan.sections do
BDS.Generation.Pagefind.build_outputs(plan, core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs) BDS.Generation.Pagefind.build_outputs(
plan,
core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs
)
else else
[] []
end end
@@ -433,7 +463,9 @@ defmodule BDS.Generation do
[] []
end end
core_outputs ++ page_outputs ++ single_outputs ++ archive_outputs ++ sitemap ++ pagefind_outputs ++ asset_outputs core_outputs ++
page_outputs ++
single_outputs ++ archive_outputs ++ sitemap ++ pagefind_outputs ++ asset_outputs
end end
defp build_validation_sitemap_artifacts( defp build_validation_sitemap_artifacts(
@@ -454,17 +486,27 @@ defmodule BDS.Generation do
additional_language_sets = additional_language_sets =
Enum.map(additional_languages(plan), fn language -> Enum.map(additional_languages(plan), fn language ->
language_posts = Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) language_posts =
language_list_posts = Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate))) Enum.reject(data.published_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
language_list_posts =
Enum.reject(data.published_list_posts, &truthy_flag?(Map.get(&1, :do_not_translate)))
language_post_index = build_generation_post_index(language_list_posts) language_post_index = build_generation_post_index(language_list_posts)
{language, {language, language_posts,
build_validation_route_paths(
plan,
language_posts, language_posts,
build_validation_route_paths(plan, language_posts, language_list_posts, language_post_index, language)} language_list_posts,
language_post_index,
language
)}
end) end)
all_collection_paths = all_collection_paths =
main_paths ++ Enum.flat_map(additional_language_sets, fn {_language, _posts, paths} -> paths end) main_paths ++
Enum.flat_map(additional_language_sets, fn {_language, _posts, paths} -> paths end)
total_route_count = max(length(all_collection_paths), 1) total_route_count = max(length(all_collection_paths), 1)
@@ -497,7 +539,8 @@ defmodule BDS.Generation do
sitemap_to_write = sitemap_to_write =
case additional_languages(plan) do case additional_languages(plan) do
[] -> sitemap_content [] ->
sitemap_content
languages -> languages ->
render_multi_language( render_multi_language(
@@ -510,7 +553,8 @@ defmodule BDS.Generation do
) )
end end
{sitemap_content, sitemap_to_write, additional_expected_paths, additional_post_timestamp_checks} {sitemap_content, sitemap_to_write, additional_expected_paths,
additional_post_timestamp_checks}
end end
defp disk_generated_files(project_id) do defp disk_generated_files(project_id) do
@@ -544,21 +588,52 @@ defmodule BDS.Generation do
segments = String.split(relative_path, "/", trim: true) segments = String.split(relative_path, "/", trim: true)
case strip_language_prefix(segments) do case strip_language_prefix(segments) do
["404.html"] -> :core ["404.html"] ->
["index.html"] -> :core :core
["page", _page, "index.html"] -> :core
["sitemap.xml"] -> :core ["index.html"] ->
["feed.xml"] -> :core :core
["atom.xml"] -> :core
["calendar.json"] -> :core ["page", _page, "index.html"] ->
["pagefind" | _rest] -> :core :core
[year, month, day, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 -> :date
[year, month, day, _slug, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 -> :single ["sitemap.xml"] ->
["category" | _rest] -> :category :core
["tag" | _rest] -> :tag
[year, "index.html"] when byte_size(year) == 4 -> :date ["feed.xml"] ->
[year, month, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 -> :date :core
_other -> :core
["atom.xml"] ->
:core
["calendar.json"] ->
:core
["pagefind" | _rest] ->
:core
[year, month, day, "index.html"]
when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 ->
:date
[year, month, day, _slug, "index.html"]
when byte_size(year) == 4 and byte_size(month) == 2 and byte_size(day) == 2 ->
:single
["category" | _rest] ->
:category
["tag" | _rest] ->
:tag
[year, "index.html"] when byte_size(year) == 4 ->
:date
[year, month, "index.html"] when byte_size(year) == 4 and byte_size(month) == 2 ->
:date
_other ->
:core
end end
end end
@@ -615,7 +690,9 @@ defmodule BDS.Generation do
generated_file.relative_path == ^relative_path generated_file.relative_path == ^relative_path
) )
{pruned_count, _last_dir} = prune_empty_parent_dirs(Path.dirname(full_path), output_path(project, "")) {pruned_count, _last_dir} =
prune_empty_parent_dirs(Path.dirname(full_path), output_path(project, ""))
{deleted_count + 1, removed_dir_count + pruned_count} {deleted_count + 1, removed_dir_count + pruned_count}
{:error, :enoent} -> {:error, :enoent} ->
@@ -634,7 +711,12 @@ defmodule BDS.Generation do
end) end)
Enum.each(ancillary_paths, fn relative_path -> Enum.each(ancillary_paths, fn relative_path ->
_ = write_generated_file(project_id, relative_path, Map.fetch!(expected_output_map, relative_path)) _ =
write_generated_file(
project_id,
relative_path,
Map.fetch!(expected_output_map, relative_path)
)
end) end)
:ok :ok

View File

@@ -40,7 +40,13 @@ defmodule BDS.Generation.Data do
post_snapshot_candidates post_snapshot_candidates
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.reduce(%{}, fn {post, index}, acc -> |> Enum.reduce(%{}, fn {post, index}, acc ->
:ok = report_snapshot_stage_progress(on_snapshot_progress, :posts, index, length(post_snapshot_candidates)) :ok =
report_snapshot_stage_progress(
on_snapshot_progress,
:posts,
index,
length(post_snapshot_candidates)
)
case published_post_snapshot(project_data_dir, post) do case published_post_snapshot(project_data_dir, post) do
nil -> acc nil -> acc
@@ -54,7 +60,9 @@ defmodule BDS.Generation.Data do
|> then(fn published -> |> then(fn published ->
draft_candidates draft_candidates
|> merge_generation_snapshots(snapshots_by_id) |> merge_generation_snapshots(snapshots_by_id)
|> Enum.reduce(Map.new(published, &{&1.id, &1}), fn post, acc -> Map.put(acc, post.id, post) end) |> Enum.reduce(Map.new(published, &{&1.id, &1}), fn post, acc ->
Map.put(acc, post.id, post)
end)
|> Map.values() |> Map.values()
end) end)
|> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)}) |> Enum.sort_by(&{-(&1.created_at || 0), -(&1.published_at || 0), to_string(&1.slug)})
@@ -100,7 +108,12 @@ defmodule BDS.Generation.Data do
end end
@spec resolve_posts_for_language([map()], String.t() | nil, map(), String.t() | nil) :: [map()] @spec resolve_posts_for_language([map()], String.t() | nil, map(), String.t() | nil) :: [map()]
def resolve_posts_for_language(posts, target_language, translations_by_post_language, main_language) do def resolve_posts_for_language(
posts,
target_language,
translations_by_post_language,
main_language
) do
target = String.downcase(to_string(target_language || "")) target = String.downcase(to_string(target_language || ""))
main = String.downcase(to_string(main_language || "")) main = String.downcase(to_string(main_language || ""))
@@ -126,7 +139,16 @@ defmodule BDS.Generation.Data do
@spec build_generation_post_index([map()]) :: map() @spec build_generation_post_index([map()]) :: map()
def build_generation_post_index(posts) do def build_generation_post_index(posts) do
Enum.reduce(posts, %{posts_by_category: %{}, posts_by_tag: %{}, posts_by_year: %{}, posts_by_year_month: %{}, posts_by_year_month_day: %{}}, fn post, acc -> Enum.reduce(
posts,
%{
posts_by_category: %{},
posts_by_tag: %{},
posts_by_year: %{},
posts_by_year_month: %{},
posts_by_year_month_day: %{}
},
fn post, acc ->
{year, month_value, day_value} = local_date_parts!(post.created_at) {year, month_value, day_value} = local_date_parts!(post.created_at)
month = String.pad_leading(Integer.to_string(month_value), 2, "0") month = String.pad_leading(Integer.to_string(month_value), 2, "0")
day = String.pad_leading(Integer.to_string(day_value), 2, "0") day = String.pad_leading(Integer.to_string(day_value), 2, "0")
@@ -138,11 +160,22 @@ defmodule BDS.Generation.Data do
|> append_generation_index(:posts_by_year_month, year_month, post) |> append_generation_index(:posts_by_year_month, year_month, post)
|> append_generation_index(:posts_by_year_month_day, year_month_day, post) |> append_generation_index(:posts_by_year_month_day, year_month_day, post)
|> then(fn indexed -> |> then(fn indexed ->
indexed = Enum.reduce(post.categories || [], indexed, &append_generation_index(&2, :posts_by_category, &1, post)) indexed =
Enum.reduce(post.tags || [], indexed, &append_generation_index(&2, :posts_by_tag, &1, post)) Enum.reduce(
end) post.categories || [],
indexed,
&append_generation_index(&2, :posts_by_category, &1, post)
)
Enum.reduce(
post.tags || [],
indexed,
&append_generation_index(&2, :posts_by_tag, &1, post)
)
end) end)
end end
)
end
## --- internals ----------------------------------------------------------- ## --- internals -----------------------------------------------------------
@@ -168,9 +201,11 @@ defmodule BDS.Generation.Data do
"page" => %{render_in_lists: false, show_title: true} "page" => %{render_in_lists: false, show_title: true}
} }
Enum.reduce(Map.get(plan, :category_settings, %{}) || %{}, defaults, fn {category, settings}, acc -> Enum.reduce(Map.get(plan, :category_settings, %{}) || %{}, defaults, fn {category, settings},
acc ->
Map.put(acc, category, %{ Map.put(acc, category, %{
render_in_lists: category_setting_flag(settings, :render_in_lists, "render_in_lists", true), render_in_lists:
category_setting_flag(settings, :render_in_lists, "render_in_lists", true),
show_title: category_setting_flag(settings, :show_title, "show_title", true) show_title: category_setting_flag(settings, :show_title, "show_title", true)
}) })
end) end)
@@ -207,8 +242,9 @@ defmodule BDS.Generation.Data do
{:ok, contents} -> {:ok, contents} ->
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents) {:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
%Post{fallback_post | %Post{
id: DocumentFields.get(fields, "id", fallback_post.id), fallback_post
| id: DocumentFields.get(fields, "id", fallback_post.id),
title: DocumentFields.get(fields, "title", fallback_post.title) || "", title: DocumentFields.get(fields, "title", fallback_post.title) || "",
slug: DocumentFields.fetch!(fields, "slug"), slug: DocumentFields.fetch!(fields, "slug"),
excerpt: Map.get(fields, "excerpt"), excerpt: Map.get(fields, "excerpt"),
@@ -216,8 +252,14 @@ defmodule BDS.Generation.Data do
status: :published, status: :published,
author: Map.get(fields, "author"), author: Map.get(fields, "author"),
language: Map.get(fields, "language", fallback_post.language), language: Map.get(fields, "language", fallback_post.language),
do_not_translate: DocumentFields.get(fields, "doNotTranslate", fallback_post.do_not_translate || false), do_not_translate:
template_slug: DocumentFields.get(fields, "templateSlug", fallback_post.template_slug), DocumentFields.get(
fields,
"doNotTranslate",
fallback_post.do_not_translate || false
),
template_slug:
DocumentFields.get(fields, "templateSlug", fallback_post.template_slug),
created_at: DocumentFields.get(fields, "createdAt", fallback_post.created_at), created_at: DocumentFields.get(fields, "createdAt", fallback_post.created_at),
updated_at: DocumentFields.get(fields, "updatedAt", fallback_post.updated_at), updated_at: DocumentFields.get(fields, "updatedAt", fallback_post.updated_at),
published_at: DocumentFields.get(fields, "publishedAt", fallback_post.published_at), published_at: DocumentFields.get(fields, "publishedAt", fallback_post.published_at),
@@ -231,13 +273,20 @@ defmodule BDS.Generation.Data do
end end
end end
defp build_generation_route_posts(project_id, project_data_dir, published_posts, on_snapshot_progress) do defp build_generation_route_posts(
project_id,
project_data_dir,
published_posts,
on_snapshot_progress
) do
source_post_ids = Enum.map(published_posts, & &1.id) source_post_ids = Enum.map(published_posts, & &1.id)
translation_candidates = translation_candidates =
Repo.all( Repo.all(
from translation in Translation, from translation in Translation,
where: translation.project_id == ^project_id and translation.translation_for in ^source_post_ids, where:
translation.project_id == ^project_id and
translation.translation_for in ^source_post_ids,
where: translation.status in [:published, :draft], where: translation.status in [:published, :draft],
order_by: [asc: translation.translation_for, asc: translation.language] order_by: [asc: translation.translation_for, asc: translation.language]
) )
@@ -246,7 +295,13 @@ defmodule BDS.Generation.Data do
translation_candidates translation_candidates
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.reduce(%{}, fn {translation, index}, acc -> |> Enum.reduce(%{}, fn {translation, index}, acc ->
:ok = report_snapshot_stage_progress(on_snapshot_progress, :translations, index, length(translation_candidates)) :ok =
report_snapshot_stage_progress(
on_snapshot_progress,
:translations,
index,
length(translation_candidates)
)
case published_translation_snapshot(project_data_dir, translation) do case published_translation_snapshot(project_data_dir, translation) do
nil -> acc nil -> acc
@@ -288,8 +343,9 @@ defmodule BDS.Generation.Data do
{:ok, contents} -> {:ok, contents} ->
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents) {:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
%Translation{fallback_translation | %Translation{
id: DocumentFields.get(fields, "id", fallback_translation.id), fallback_translation
| id: DocumentFields.get(fields, "id", fallback_translation.id),
translation_for: DocumentFields.fetch!(fields, "translationFor"), translation_for: DocumentFields.fetch!(fields, "translationFor"),
language: DocumentFields.fetch!(fields, "language"), language: DocumentFields.fetch!(fields, "language"),
title: DocumentFields.get(fields, "title", fallback_translation.title) || "", title: DocumentFields.get(fields, "title", fallback_translation.title) || "",
@@ -298,7 +354,8 @@ defmodule BDS.Generation.Data do
status: :published, status: :published,
created_at: DocumentFields.get(fields, "createdAt", fallback_translation.created_at), created_at: DocumentFields.get(fields, "createdAt", fallback_translation.created_at),
updated_at: DocumentFields.get(fields, "updatedAt", fallback_translation.updated_at), updated_at: DocumentFields.get(fields, "updatedAt", fallback_translation.updated_at),
published_at: DocumentFields.get(fields, "publishedAt", fallback_translation.published_at), published_at:
DocumentFields.get(fields, "publishedAt", fallback_translation.published_at),
file_path: fallback_translation.file_path file_path: fallback_translation.file_path
} }

View File

@@ -25,8 +25,16 @@ defmodule BDS.Generation.Outputs do
end) end)
end end
@spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [String.t()] @spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [
def build_validation_route_paths(plan, route_posts, published_list_posts, post_index, route_language) do String.t()
]
def build_validation_route_paths(
plan,
route_posts,
published_list_posts,
post_index,
route_language
) do
[ [
core_route_paths(plan, published_list_posts, route_language), core_route_paths(plan, published_list_posts, route_language),
page_route_paths(plan, route_posts, route_language), page_route_paths(plan, route_posts, route_language),
@@ -250,7 +258,9 @@ defmodule BDS.Generation.Outputs do
Enum.flat_map(posts_by_tag, fn {tag, posts} -> Enum.flat_map(posts_by_tag, fn {tag, posts} ->
tag_slug = archive_route_segment(tag) tag_slug = archive_route_segment(tag)
build_paginated_archive_outputs(plan, languages, ["tag", tag_slug], posts, fn page_posts, language, pagination -> build_paginated_archive_outputs(plan, languages, ["tag", tag_slug], posts, fn page_posts,
language,
pagination ->
render_archive_page(plan, tag, page_posts, language, "tag", pagination) render_archive_page(plan, tag, page_posts, language, "tag", pagination)
end) end)
end) end)
@@ -260,7 +270,12 @@ defmodule BDS.Generation.Outputs do
def build_date_outputs(plan, post_index, languages) do def build_date_outputs(plan, post_index, languages) do
year_outputs = year_outputs =
Enum.flat_map(post_index.posts_by_year, fn {year, posts} -> Enum.flat_map(post_index.posts_by_year, fn {year, posts} ->
build_paginated_archive_outputs(plan, languages, [Integer.to_string(year)], posts, fn page_posts, language, pagination -> build_paginated_archive_outputs(
plan,
languages,
[Integer.to_string(year)],
posts,
fn page_posts, language, pagination ->
render_date_archive_page( render_date_archive_page(
plan, plan,
Integer.to_string(year), Integer.to_string(year),
@@ -269,14 +284,17 @@ defmodule BDS.Generation.Outputs do
language, language,
pagination pagination
) )
end) end
)
end) end)
month_outputs = month_outputs =
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} -> Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
[year, month] = String.split(year_month, "/", parts: 2) [year, month] = String.split(year_month, "/", parts: 2)
build_paginated_archive_outputs(plan, languages, [year, month], posts, fn page_posts, language, pagination -> build_paginated_archive_outputs(plan, languages, [year, month], posts, fn page_posts,
language,
pagination ->
render_date_archive_page( render_date_archive_page(
plan, plan,
"#{year}-#{month}", "#{year}-#{month}",
@@ -292,11 +310,18 @@ defmodule BDS.Generation.Outputs do
Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} -> Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} ->
[year, month, day] = String.split(year_month_day, "/", parts: 3) [year, month, day] = String.split(year_month_day, "/", parts: 3)
build_paginated_archive_outputs(plan, languages, [year, month, day], posts, fn page_posts, language, pagination -> build_paginated_archive_outputs(plan, languages, [year, month, day], posts, fn page_posts,
language,
pagination ->
render_date_archive_page( render_date_archive_page(
plan, plan,
"#{year}-#{month}-#{day}", "#{year}-#{month}-#{day}",
%{kind: "day", year: String.to_integer(year), month: String.to_integer(month), day: String.to_integer(day)}, %{
kind: "day",
year: String.to_integer(year),
month: String.to_integer(month),
day: String.to_integer(day)
},
page_posts, page_posts,
language, language,
pagination pagination
@@ -323,19 +348,32 @@ defmodule BDS.Generation.Outputs do
Enum.flat_map(additional_languages, fn localized_language -> Enum.flat_map(additional_languages, fn localized_language ->
localized_prefix = route_language(plan.language, localized_language) localized_prefix = route_language(plan.language, localized_language)
localized_source_posts = Map.get(localized_posts_by_language, localized_language, []) localized_source_posts = Map.get(localized_posts_by_language, localized_language, [])
localized_posts = build_list_posts(plan.base_url, localized_source_posts, localized_prefix)
localized_posts =
build_list_posts(plan.base_url, localized_source_posts, localized_prefix)
build_root_outputs(plan, localized_language, localized_posts) ++ build_root_outputs(plan, localized_language, localized_posts) ++
[ [
{Path.join(localized_language, "404.html"), render_not_found_output(plan, localized_language)}, {Path.join(localized_language, "404.html"),
{Path.join(localized_language, "feed.xml"), render_feed(plan, localized_language, localized_source_posts)}, render_not_found_output(plan, localized_language)},
{Path.join(localized_language, "atom.xml"), render_atom(plan, localized_language, localized_source_posts)} {Path.join(localized_language, "feed.xml"),
render_feed(plan, localized_language, localized_source_posts)},
{Path.join(localized_language, "atom.xml"),
render_atom(plan, localized_language, localized_source_posts)}
] ]
end) end)
end end
@spec build_page_outputs(String.t(), String.t(), [map()], map(), map()) :: [{String.t(), iodata()}] @spec build_page_outputs(String.t(), String.t(), [map()], map(), map()) :: [
def build_page_outputs(project_id, main_language, published_posts, translations_by_post_language, localized_posts_by_language) do {String.t(), iodata()}
]
def build_page_outputs(
project_id,
main_language,
published_posts,
translations_by_post_language,
localized_posts_by_language
) do
page_outputs = page_outputs =
published_posts published_posts
|> Enum.filter(&("page" in (&1.categories || []))) |> Enum.filter(&("page" in (&1.categories || [])))
@@ -355,7 +393,14 @@ defmodule BDS.Generation.Outputs do
language: canonical_variant.language, language: canonical_variant.language,
excerpt: canonical_variant.excerpt excerpt: canonical_variant.excerpt
}, },
fn -> render_post_page(canonical_variant.title, body, post.slug, canonical_variant.language) end fn ->
render_post_page(
canonical_variant.title,
body,
post.slug,
canonical_variant.language
)
end
)} )}
end) end)
@@ -404,13 +449,22 @@ defmodule BDS.Generation.Outputs do
plan.project_name, plan.project_name,
page_posts, page_posts,
%{kind: "core"}, %{kind: "core"},
pagination_for_page(page_number, total_pages, length(posts), plan.max_posts_per_page, route_language, []), pagination_for_page(
page_number,
total_pages,
length(posts),
plan.max_posts_per_page,
route_language,
[]
),
fn -> render_home(plan, language) end fn -> render_home(plan, language) end
)} )}
end) end)
end end
@spec build_paginated_archive_outputs(map(), [String.t()], [String.t()], [map()], (... -> iodata())) :: [{String.t(), iodata()}] @spec build_paginated_archive_outputs(map(), [String.t()], [String.t()], [map()], (... ->
iodata())) ::
[{String.t(), iodata()}]
def build_paginated_archive_outputs(plan, languages, segments, posts, render_fun) do def build_paginated_archive_outputs(plan, languages, segments, posts, render_fun) do
total_pages = page_count(length(posts), plan.max_posts_per_page) total_pages = page_count(length(posts), plan.max_posts_per_page)
@@ -425,13 +479,22 @@ defmodule BDS.Generation.Outputs do
render_fun.( render_fun.(
page_posts, page_posts,
language, language,
pagination_for_page(page_number, total_pages, length(posts), plan.max_posts_per_page, route_language, segments) pagination_for_page(
page_number,
total_pages,
length(posts),
plan.max_posts_per_page,
route_language,
segments
)
)} )}
end) end)
end) end)
end end
@spec build_single_outputs(String.t(), String.t(), [map()], map(), map()) :: [{String.t(), iodata()}] @spec build_single_outputs(String.t(), String.t(), [map()], map(), map()) :: [
{String.t(), iodata()}
]
def build_single_outputs( def build_single_outputs(
project_id, project_id,
main_language, main_language,
@@ -457,7 +520,12 @@ defmodule BDS.Generation.Outputs do
excerpt: canonical_variant.excerpt excerpt: canonical_variant.excerpt
}, },
fn -> fn ->
render_post_page(canonical_variant.title, body, post.slug, canonical_variant.language) render_post_page(
canonical_variant.title,
body,
post.slug,
canonical_variant.language
)
end end
)} )}
end) end)

View File

@@ -19,10 +19,13 @@ defmodule BDS.Generation.Pagefind do
|> Enum.flat_map(fn language -> |> Enum.flat_map(fn language ->
route_language = route_language(plan.language, language) route_language = route_language(plan.language, language)
pages = pages_for_language(html_outputs, route_language) pages = pages_for_language(html_outputs, route_language)
prefix = if route_language in [nil, ""], do: ["pagefind"], else: [route_language, "pagefind"]
prefix =
if route_language in [nil, ""], do: ["pagefind"], else: [route_language, "pagefind"]
[ [
{Path.join(prefix ++ ["index.json"]), Jason.encode!(%{"language" => language, "pages" => pages})}, {Path.join(prefix ++ ["index.json"]),
Jason.encode!(%{"language" => language, "pages" => pages})},
{Path.join(prefix ++ ["pagefind-ui.js"]), ui_js(language)}, {Path.join(prefix ++ ["pagefind-ui.js"]), ui_js(language)},
{Path.join(prefix ++ ["pagefind-ui.css"]), ui_css()} {Path.join(prefix ++ ["pagefind-ui.css"]), ui_css()}
] ]

View File

@@ -49,18 +49,37 @@ defmodule BDS.Generation.Paths do
def root_output_path(nil, 1), do: "index.html" def root_output_path(nil, 1), do: "index.html"
def root_output_path("", 1), do: "index.html" def root_output_path("", 1), do: "index.html"
def root_output_path(route_language, 1), do: Path.join(route_language, "index.html") def root_output_path(route_language, 1), do: Path.join(route_language, "index.html")
def root_output_path(nil, page_number), do: Path.join(["page", Integer.to_string(page_number), "index.html"])
def root_output_path(nil, page_number),
do: Path.join(["page", Integer.to_string(page_number), "index.html"])
def root_output_path("", page_number), do: root_output_path(nil, page_number) def root_output_path("", page_number), do: root_output_path(nil, page_number)
def root_output_path(route_language, page_number), do: Path.join([route_language, "page", Integer.to_string(page_number), "index.html"])
def root_output_path(route_language, page_number),
do: Path.join([route_language, "page", Integer.to_string(page_number), "index.html"])
@spec page_output_path(String.t(), language()) :: String.t() @spec page_output_path(String.t(), language()) :: String.t()
def page_output_path(slug, nil), do: Path.join([slug, "index.html"]) def page_output_path(slug, nil), do: Path.join([slug, "index.html"])
def page_output_path(slug, ""), do: page_output_path(slug, nil) def page_output_path(slug, ""), do: page_output_path(slug, nil)
def page_output_path(slug, language), do: Path.join([language, slug, "index.html"]) def page_output_path(slug, language), do: Path.join([language, slug, "index.html"])
@spec pagination_for_page(pos_integer(), pos_integer(), non_neg_integer(), pos_integer(), language(), [String.t()]) :: @spec pagination_for_page(
pos_integer(),
pos_integer(),
non_neg_integer(),
pos_integer(),
language(),
[String.t()]
) ::
map() map()
def pagination_for_page(page_number, total_pages, total_items, items_per_page, route_language, segments) do def pagination_for_page(
page_number,
total_pages,
total_items,
items_per_page,
route_language,
segments
) do
%{ %{
current_page: page_number, current_page: page_number,
total_pages: total_pages, total_pages: total_pages,
@@ -75,8 +94,12 @@ defmodule BDS.Generation.Paths do
@spec archive_or_root_href(language(), [String.t()], integer()) :: String.t() @spec archive_or_root_href(language(), [String.t()], integer()) :: String.t()
def archive_or_root_href(_route_language, _segments, page_number) when page_number < 1, do: "" def archive_or_root_href(_route_language, _segments, page_number) when page_number < 1, do: ""
def archive_or_root_href(route_language, [], page_number), do: root_page_href(route_language, page_number)
def archive_or_root_href(route_language, segments, page_number), do: archive_href(route_language, segments, page_number) def archive_or_root_href(route_language, [], page_number),
do: root_page_href(route_language, page_number)
def archive_or_root_href(route_language, segments, page_number),
do: archive_href(route_language, segments, page_number)
@spec root_page_href(language(), integer()) :: String.t() @spec root_page_href(language(), integer()) :: String.t()
def root_page_href(route_language, page_number) when page_number <= 1 do def root_page_href(route_language, page_number) when page_number <= 1 do
@@ -147,7 +170,9 @@ defmodule BDS.Generation.Paths do
@spec archive_route_segment(any()) :: String.t() @spec archive_route_segment(any()) :: String.t()
def archive_route_segment(nil), do: "" def archive_route_segment(nil), do: ""
def archive_route_segment(value), do: value |> to_string() |> URI.encode(&URI.char_unreserved?/1)
def archive_route_segment(value),
do: value |> to_string() |> URI.encode(&URI.char_unreserved?/1)
@spec normalize_base_url(String.t() | nil) :: String.t() | nil @spec normalize_base_url(String.t() | nil) :: String.t() | nil
def normalize_base_url(nil), do: nil def normalize_base_url(nil), do: nil

View File

@@ -44,7 +44,8 @@ defmodule BDS.Generation.Renderers do
end end
@doc "Render an archive page (category, tag, year) with pagination." @doc "Render an archive page (category, tag, year) with pagination."
@spec render_archive_page(map(), String.t(), [map()], String.t() | nil, String.t(), map()) :: String.t() @spec render_archive_page(map(), String.t(), [map()], String.t() | nil, String.t(), map()) ::
String.t()
def render_archive_page(plan, title, posts, language, kind, pagination) do def render_archive_page(plan, title, posts, language, kind, pagination) do
fallback = fn -> fallback = fn ->
items = items =
@@ -130,7 +131,15 @@ defmodule BDS.Generation.Renderers do
end end
@doc "Render a list/archive page through the project template, falling back to inline." @doc "Render a list/archive page through the project template, falling back to inline."
@spec render_list_output(map(), String.t() | nil, String.t(), [map()], map(), map(), (-> String.t())) :: @spec render_list_output(
map(),
String.t() | nil,
String.t(),
[map()],
map(),
map(),
(-> String.t())
) ::
String.t() String.t()
def render_list_output( def render_list_output(
%{project_id: project_id, language: main_language}, %{project_id: project_id, language: main_language},

View File

@@ -34,7 +34,9 @@ defmodule BDS.Generation.Sitemap do
build_hreflang_links(plan.base_url, "/", plan.language, all_languages) build_hreflang_links(plan.base_url, "/", plan.language, all_languages)
) )
] ++ ] ++
Enum.map(Paths.root_pagination_pages(length(published_list_posts), plan.max_posts_per_page), fn page_number -> Enum.map(
Paths.root_pagination_pages(length(published_list_posts), plan.max_posts_per_page),
fn page_number ->
page_path = "/page/#{page_number}" page_path = "/page/#{page_number}"
url_entry( url_entry(
@@ -44,7 +46,8 @@ defmodule BDS.Generation.Sitemap do
"0.9", "0.9",
build_hreflang_links(plan.base_url, page_path, plan.language, all_languages) build_hreflang_links(plan.base_url, page_path, plan.language, all_languages)
) )
end) ++ end
) ++
Enum.map(translatable_posts, fn post -> Enum.map(translatable_posts, fn post ->
post_path = Paths.relative_path_to_url_path(Paths.post_output_path(post)) post_path = Paths.relative_path_to_url_path(Paths.post_output_path(post))
@@ -100,7 +103,9 @@ defmodule BDS.Generation.Sitemap do
build_hreflang_links(plan.base_url, year_path, plan.language, all_languages) build_hreflang_links(plan.base_url, year_path, plan.language, all_languages)
) )
end) ++ end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc), fn {year_month, _posts} -> Enum.map(
Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc),
fn {year_month, _posts} ->
month_path = "/#{year_month}" month_path = "/#{year_month}"
url_entry( url_entry(
@@ -110,8 +115,11 @@ defmodule BDS.Generation.Sitemap do
"0.5", "0.5",
build_hreflang_links(plan.base_url, month_path, plan.language, all_languages) build_hreflang_links(plan.base_url, month_path, plan.language, all_languages)
) )
end) ++ end
Enum.map(Enum.sort_by(post_index.posts_by_year_month_day, &elem(&1, 0), :desc), fn {year_month_day, _posts} -> ) ++
Enum.map(
Enum.sort_by(post_index.posts_by_year_month_day, &elem(&1, 0), :desc),
fn {year_month_day, _posts} ->
day_path = "/#{year_month_day}" day_path = "/#{year_month_day}"
url_entry( url_entry(
@@ -121,7 +129,8 @@ defmodule BDS.Generation.Sitemap do
"0.4", "0.4",
build_hreflang_links(plan.base_url, day_path, plan.language, all_languages) build_hreflang_links(plan.base_url, day_path, plan.language, all_languages)
) )
end) ++ end
) ++
Enum.map(Enum.sort_by(post_index.posts_by_category, &elem(&1, 0)), fn {category, _posts} -> Enum.map(Enum.sort_by(post_index.posts_by_category, &elem(&1, 0)), fn {category, _posts} ->
category_path = "/category/#{Paths.archive_route_segment(category)}" category_path = "/category/#{Paths.archive_route_segment(category)}"

View File

@@ -9,6 +9,7 @@ defmodule BDS.Generation.Validation do
relative_path_to_url_path: 1, relative_path_to_url_path: 1,
url_path_to_relative_index_path: 1 url_path_to_relative_index_path: 1
] ]
import BDS.Generation.Progress, only: [report_validation_compare_progress: 3] import BDS.Generation.Progress, only: [report_validation_compare_progress: 3]
import BDS.Generation.Sitemap, only: [extract_locs: 1, loc_to_project_path: 2] import BDS.Generation.Sitemap, only: [extract_locs: 1, loc_to_project_path: 2]
@@ -20,7 +21,11 @@ defmodule BDS.Generation.Validation do
end end
@spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()] @spec build_post_timestamp_checks(String.t(), [map()], map()) :: [map()]
def build_post_timestamp_checks(project_data_dir, published_route_posts, generated_file_updated_at) do def build_post_timestamp_checks(
project_data_dir,
published_route_posts,
generated_file_updated_at
) do
Enum.map(published_route_posts, fn post -> Enum.map(published_route_posts, fn post ->
relative_path = BDS.Generation.Paths.post_output_path(post) relative_path = BDS.Generation.Paths.post_output_path(post)
@@ -69,13 +74,19 @@ defmodule BDS.Generation.Validation do
|> Enum.map(&loc_to_project_path(&1, params.base_url)) |> Enum.map(&loc_to_project_path(&1, params.base_url))
|> Enum.reduce(MapSet.new(), &MapSet.put(&2, normalize_url_path(&1))) |> Enum.reduce(MapSet.new(), &MapSet.put(&2, normalize_url_path(&1)))
|> then(fn expected_paths -> |> then(fn expected_paths ->
Enum.reduce(Map.get(params, :additional_expected_paths, []), expected_paths, fn path, acc -> Enum.reduce(Map.get(params, :additional_expected_paths, []), expected_paths, fn path,
acc ->
MapSet.put(acc, normalize_url_path(path)) MapSet.put(acc, normalize_url_path(path))
end) end)
end) end)
{existing_html_path_set, zero_byte_html_path_set} = {existing_html_path_set, zero_byte_html_path_set} =
collect_html_index_paths(index_paths, params.html_dir, params.on_progress, total_compare_steps) collect_html_index_paths(
index_paths,
params.html_dir,
params.on_progress,
total_compare_steps
)
missing_url_paths = missing_url_paths =
expected_path_set expected_path_set
@@ -119,11 +130,14 @@ defmodule BDS.Generation.Validation do
acc acc
true -> true ->
html_path = Path.join(params.html_dir, url_path_to_relative_index_path(normalized_url_path)) html_path =
Path.join(params.html_dir, url_path_to_relative_index_path(normalized_url_path))
case {File.stat(html_path, time: :posix), File.stat(check.post_file_path, time: :posix)} do case {File.stat(html_path, time: :posix),
File.stat(check.post_file_path, time: :posix)} do
{{:ok, html_stat}, {:ok, post_stat}} -> {{:ok, html_stat}, {:ok, post_stat}} ->
effective_generated_at_ms = max(mtime_ms(html_stat), check.generated_updated_at_ms || 0) effective_generated_at_ms =
max(mtime_ms(html_stat), check.generated_updated_at_ms || 0)
if mtime_ms(post_stat) > effective_generated_at_ms do if mtime_ms(post_stat) > effective_generated_at_ms do
MapSet.put(acc, normalized_url_path) MapSet.put(acc, normalized_url_path)
@@ -233,7 +247,18 @@ defmodule BDS.Generation.Validation do
nil -> nil ->
case Regex.run(~r|^/(\d{4})/(\d{2})/(\d{2})/([^/]+)$|, path) do case Regex.run(~r|^/(\d{4})/(\d{2})/(\d{2})/([^/]+)$|, path) do
[_, year, month, day, slug] -> [_, year, month, day, slug] ->
update_in(plan.requested_post_routes, &[ %{year: String.to_integer(year), month: String.to_integer(month), day: String.to_integer(day), slug: slug} | &1 ]) update_in(
plan.requested_post_routes,
&[
%{
year: String.to_integer(year),
month: String.to_integer(month),
day: String.to_integer(day),
slug: slug
}
| &1
]
)
nil -> nil ->
case Regex.run(~r|^/(\d{4})/(\d{2})(?:/page/\d+)?$|, path) do case Regex.run(~r|^/(\d{4})/(\d{2})(?:/page/\d+)?$|, path) do
@@ -281,12 +306,18 @@ defmodule BDS.Generation.Validation do
end) end)
enriched = enriched =
Enum.reduce(initial_plan.requested_post_routes, %{initial_plan | requested_post_routes: targeted_post_routes}, fn route, acc -> Enum.reduce(
initial_plan.requested_post_routes,
%{initial_plan | requested_post_routes: targeted_post_routes},
fn route, acc ->
case Enum.find(published_posts, &post_matches_route?(&1, route)) do case Enum.find(published_posts, &post_matches_route?(&1, route)) do
nil -> nil ->
acc acc
|> update_in([:requested_years], &MapSet.put(&1, route.year)) |> update_in([:requested_years], &MapSet.put(&1, route.year))
|> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(route.year, route.month))) |> update_in(
[:requested_year_months],
&MapSet.put(&1, route_month_key(route.year, route.month))
)
|> Map.put(:request_root_routes, true) |> Map.put(:request_root_routes, true)
post -> post ->
@@ -294,16 +325,24 @@ defmodule BDS.Generation.Validation do
acc acc
|> update_in([:requested_category_slugs], fn set -> |> update_in([:requested_category_slugs], fn set ->
Enum.reduce(post.categories || [], set, &MapSet.put(&2, archive_route_segment(&1))) Enum.reduce(
post.categories || [],
set,
&MapSet.put(&2, archive_route_segment(&1))
)
end) end)
|> update_in([:requested_tag_slugs], fn set -> |> update_in([:requested_tag_slugs], fn set ->
Enum.reduce(post.tags || [], set, &MapSet.put(&2, archive_route_segment(&1))) Enum.reduce(post.tags || [], set, &MapSet.put(&2, archive_route_segment(&1)))
end) end)
|> update_in([:requested_years], &MapSet.put(&1, year)) |> update_in([:requested_years], &MapSet.put(&1, year))
|> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(year, month))) |> update_in(
[:requested_year_months],
&MapSet.put(&1, route_month_key(year, month))
)
|> Map.put(:request_root_routes, true) |> Map.put(:request_root_routes, true)
end end
end) end
)
language_plans = language_plans =
initial_plan.language_plans initial_plan.language_plans
@@ -314,8 +353,10 @@ defmodule BDS.Generation.Validation do
%{ %{
enriched enriched
| requested_category_slugs: MapSet.intersection(enriched.requested_category_slugs, available_category_slugs), | requested_category_slugs:
requested_tag_slugs: MapSet.intersection(enriched.requested_tag_slugs, available_tag_slugs), MapSet.intersection(enriched.requested_category_slugs, available_category_slugs),
requested_tag_slugs:
MapSet.intersection(enriched.requested_tag_slugs, available_tag_slugs),
language_plans: language_plans language_plans: language_plans
} }
end end
@@ -351,13 +392,15 @@ defmodule BDS.Generation.Validation do
{nil, path} {nil, path}
end end
_other -> {nil, path} _other ->
{nil, path}
end end
end end
@spec targeted_output?(String.t(), map(), String.t() | nil, [String.t()]) :: boolean() @spec targeted_output?(String.t(), map(), String.t() | nil, [String.t()]) :: boolean()
def targeted_output?(relative_path, targeted_plan, main_language, additional_languages) do def targeted_output?(relative_path, targeted_plan, main_language, additional_languages) do
{language, stripped_path} = extract_relative_output_language(relative_path, additional_languages) {language, stripped_path} =
extract_relative_output_language(relative_path, additional_languages)
plan = plan =
case language do case language do
@@ -384,7 +427,11 @@ defmodule BDS.Generation.Validation do
end end
end end
defp targeted_output_for_plan?(_relative_path, %{requires_fallback_section_render: true}, _main?), do: true defp targeted_output_for_plan?(
_relative_path,
%{requires_fallback_section_render: true},
_main?
), do: true
defp targeted_output_for_plan?(relative_path, plan, _main?) do defp targeted_output_for_plan?(relative_path, plan, _main?) do
cond do cond do
@@ -400,8 +447,18 @@ defmodule BDS.Generation.Validation do
MapSet.member?(plan.requested_tag_slugs, slug) MapSet.member?(plan.requested_tag_slugs, slug)
Regex.match?(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path) -> Regex.match?(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path) ->
[_, year, month, day, slug] = Regex.run(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path) [_, year, month, day, slug] =
MapSet.member?(plan.requested_post_routes, route_key(String.to_integer(year), String.to_integer(month), String.to_integer(day), slug)) Regex.run(~r|^(\d{4})/(\d{2})/(\d{2})/([^/]+)/index\.html$|, relative_path)
MapSet.member?(
plan.requested_post_routes,
route_key(
String.to_integer(year),
String.to_integer(month),
String.to_integer(day),
slug
)
)
Regex.match?(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path) -> Regex.match?(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path) ->
[_, year, month] = Regex.run(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path) [_, year, month] = Regex.run(~r|^(\d{4})/(\d{2})/index\.html$|, relative_path)

View File

@@ -59,7 +59,9 @@ defmodule BDS.Git do
has_lfs: has_lfs_configured?(project_dir) has_lfs: has_lfs_configured?(project_dir)
}} }}
else else
{:error, :not_found} = error -> error {:error, :not_found} = error ->
error
{:error, _reason} -> {:error, _reason} ->
{:ok, {:ok,
%{ %{
@@ -74,7 +76,8 @@ defmodule BDS.Git do
def status(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do def status(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id), with {:ok, project_dir} <- project_dir(project_id),
{:ok, output} <- run_git(project_dir, ["status", "--porcelain=v1", "--untracked-files=all"], opts) do {:ok, output} <-
run_git(project_dir, ["status", "--porcelain=v1", "--untracked-files=all"], opts) do
{:ok, %{files: parse_status(output)}} {:ok, %{files: parse_status(output)}}
end end
end end
@@ -112,7 +115,8 @@ defmodule BDS.Git do
when is_binary(project_id) and is_binary(branch) and is_list(opts) do when is_binary(project_id) and is_binary(branch) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id), with {:ok, project_dir} <- project_dir(project_id),
{:ok, local_log} <- run_git(project_dir, ["log", "--format=%H%x09%s", branch], opts), {:ok, local_log} <- run_git(project_dir, ["log", "--format=%H%x09%s", branch], opts),
{:ok, remote_log} <- run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do {:ok, remote_log} <-
run_git(project_dir, ["log", "--format=%H", "origin/#{branch}"], opts) do
local_commits = parse_local_history(local_log) local_commits = parse_local_history(local_log)
remote_hashes = MapSet.new(parse_remote_history(remote_log)) remote_hashes = MapSet.new(parse_remote_history(remote_log))
local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash)) local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash))
@@ -121,7 +125,9 @@ defmodule BDS.Git do
remote_hashes remote_hashes
|> MapSet.difference(local_hashes) |> MapSet.difference(local_hashes)
|> MapSet.to_list() |> MapSet.to_list()
|> Enum.map(fn hash -> %{hash: hash, subject: nil, sync_status: %{kind: :remote_only}} end) |> Enum.map(fn hash ->
%{hash: hash, subject: nil, sync_status: %{kind: :remote_only}}
end)
commits = commits =
Enum.map(local_commits, fn commit -> Enum.map(local_commits, fn commit ->
@@ -136,7 +142,8 @@ defmodule BDS.Git do
def file_history(project_id, file_path, opts \\ []) def file_history(project_id, file_path, opts \\ [])
when is_binary(project_id) and is_binary(file_path) and is_list(opts) do when is_binary(project_id) and is_binary(file_path) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id), with {:ok, project_dir} <- project_dir(project_id),
{:ok, output} <- run_git(project_dir, ["log", "--follow", "--format=%H%x09%s", "--", file_path], opts) do {:ok, output} <-
run_git(project_dir, ["log", "--follow", "--format=%H%x09%s", "--", file_path], opts) do
{:ok, %{commits: parse_local_history(output) |> Enum.take(50)}} {:ok, %{commits: parse_local_history(output) |> Enum.take(50)}}
else else
{:error, {:git_failed, _message}} -> {:ok, %{commits: []}} {:error, {:git_failed, _message}} -> {:ok, %{commits: []}}
@@ -147,8 +154,11 @@ defmodule BDS.Git do
def fetch(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do def fetch(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id) do with {:ok, project_dir} <- project_dir(project_id) do
case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do
{:ok, output} -> {:ok, %{updated: true, output: output}} {:ok, output} ->
{:error, {:git_failed, message}} -> structured_git_error(project_dir, :fetch, message, opts) {:ok, %{updated: true, output: output}}
{:error, {:git_failed, message}} ->
structured_git_error(project_dir, :fetch, message, opts)
end end
end end
end end
@@ -177,9 +187,11 @@ defmodule BDS.Git do
end end
def reconcile(project_id, old_commit, new_commit, opts \\ []) def reconcile(project_id, old_commit, new_commit, opts \\ [])
when is_binary(project_id) and is_binary(old_commit) and is_binary(new_commit) and is_list(opts) do when is_binary(project_id) and is_binary(old_commit) and is_binary(new_commit) and
is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id), with {:ok, project_dir} <- project_dir(project_id),
{:ok, output} <- run_git(project_dir, ["diff", "--name-status", old_commit, new_commit], opts) do {:ok, output} <-
run_git(project_dir, ["diff", "--name-status", old_commit, new_commit], opts) do
{:ok, %{changed: parse_changed_files(output)}} {:ok, %{changed: parse_changed_files(output)}}
end end
end end
@@ -197,7 +209,14 @@ defmodule BDS.Git do
{:ok, local_branch} <- current_branch(project_dir, opts) do {:ok, local_branch} <- current_branch(project_dir, opts) do
case upstream_branch(project_dir, opts) do case upstream_branch(project_dir, opts) do
{:ok, nil} -> {:ok, nil} ->
{:ok, %{local_branch: local_branch, upstream_branch: nil, has_upstream: false, ahead: 0, behind: 0}} {:ok,
%{
local_branch: local_branch,
upstream_branch: nil,
has_upstream: false,
ahead: 0,
behind: 0
}}
{:ok, upstream_branch} -> {:ok, upstream_branch} ->
{:ok, {:ok,
@@ -316,7 +335,11 @@ defmodule BDS.Git do
end end
defp upstream_branch(project_dir, opts) do defp upstream_branch(project_dir, opts) do
case run_git(project_dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], opts) do case run_git(
project_dir,
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
opts
) do
{:ok, output} -> {:ok, blank_to_nil(output)} {:ok, output} -> {:ok, blank_to_nil(output)}
{:error, {:git_failed, _message}} -> {:ok, nil} {:error, {:git_failed, _message}} -> {:ok, nil}
end end
@@ -364,21 +387,37 @@ defmodule BDS.Git do
defp parse_changed_files(output) do defp parse_changed_files(output) do
base = fn -> %{added: [], modified: [], deleted: [], renamed: []} end base = fn -> %{added: [], modified: [], deleted: [], renamed: []} end
Enum.reduce(String.split(output, "\n", trim: true), %{posts: base.(), scripts: base.(), templates: base.()}, fn line, acc -> Enum.reduce(
String.split(output, "\n", trim: true),
%{posts: base.(), scripts: base.(), templates: base.()},
fn line, acc ->
case String.split(line, "\t", trim: true) do case String.split(line, "\t", trim: true) do
["A", path] -> update_changed(acc, path, :added, path) ["A", path] ->
["M", path] -> update_changed(acc, path, :modified, path) update_changed(acc, path, :added, path)
["D", path] -> update_changed(acc, path, :deleted, path)
["R" <> _score, old_path, new_path] -> update_changed(acc, new_path, :renamed, %{old: old_path, new: new_path}) ["M", path] ->
_other -> acc update_changed(acc, path, :modified, path)
["D", path] ->
update_changed(acc, path, :deleted, path)
["R" <> _score, old_path, new_path] ->
update_changed(acc, new_path, :renamed, %{old: old_path, new: new_path})
_other ->
acc
end end
end) end
)
end end
defp update_changed(acc, path, key, value) do defp update_changed(acc, path, key, value) do
case category_for_path(path) do case category_for_path(path) do
nil -> acc nil ->
category -> Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end)) acc
category ->
Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end))
end end
end end
@@ -427,6 +466,7 @@ defmodule BDS.Git do
defp auth_guidance(provider, platform) do defp auth_guidance(provider, platform) do
provider_label = provider || :git provider_label = provider || :git
"Authentication failed for #{provider_label} on #{platform}. Configure SSH keys or a credential helper that works non-interactively." "Authentication failed for #{provider_label} on #{platform}. Configure SSH keys or a credential helper that works non-interactively."
end end

View File

@@ -32,11 +32,23 @@ defmodule BDS.ImportAnalysis do
notify_progress(on_progress, "Loading existing posts...") notify_progress(on_progress, "Loading existing posts...")
existing_posts = Repo.all(from post in Post, where: post.project_id == ^project_id) existing_posts = Repo.all(from post in Post, where: post.project_id == ^project_id)
notify_progress(on_progress, "Loading existing media...", "#{length(existing_posts)} posts in project") notify_progress(
on_progress,
"Loading existing media...",
"#{length(existing_posts)} posts in project"
)
existing_media = Repo.all(from media in Media, where: media.project_id == ^project_id) existing_media = Repo.all(from media in Media, where: media.project_id == ^project_id)
notify_progress(on_progress, "Loading existing tags...", "#{length(existing_media)} media in project") notify_progress(
existing_tag_names = Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: tag.name) on_progress,
"Loading existing tags...",
"#{length(existing_media)} media in project"
)
existing_tag_names =
Repo.all(from tag in Tag, where: tag.project_id == ^project_id, select: tag.name)
existing_tag_set = existing_tag_names |> Enum.map(&String.downcase/1) |> MapSet.new() existing_tag_set = existing_tag_names |> Enum.map(&String.downcase/1) |> MapSet.new()
posts_by_slug = Map.new(existing_posts, &{&1.slug, &1}) posts_by_slug = Map.new(existing_posts, &{&1.slug, &1})
@@ -53,15 +65,35 @@ defmodule BDS.ImportAnalysis do
|> Enum.reject(&is_nil(&1.checksum)) |> Enum.reject(&is_nil(&1.checksum))
|> Map.new(&{&1.checksum, &1}) |> Map.new(&{&1.checksum, &1})
notify_progress(on_progress, "Analyzing posts...", "#{length(wxr_data.posts)} posts to analyze") notify_progress(
analyzed_posts = Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post")) on_progress,
"Analyzing posts...",
"#{length(wxr_data.posts)} posts to analyze"
)
notify_progress(on_progress, "Analyzing pages...", "#{length(wxr_data.pages)} pages to analyze") analyzed_posts =
analyzed_pages = Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page")) Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post"))
notify_progress(
on_progress,
"Analyzing pages...",
"#{length(wxr_data.pages)} pages to analyze"
)
analyzed_pages =
Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page"))
notify_progress(
on_progress,
"Analyzing media files...",
"#{length(wxr_data.media)} media files to analyze"
)
notify_progress(on_progress, "Analyzing media files...", "#{length(wxr_data.media)} media files to analyze")
analyzed_media = analyzed_media =
Enum.map(wxr_data.media, &analyze_media_item(&1, uploads_folder_path, media_by_name, media_by_checksum)) Enum.map(
wxr_data.media,
&analyze_media_item(&1, uploads_folder_path, media_by_name, media_by_checksum)
)
notify_progress(on_progress, "Processing categories and tags...") notify_progress(on_progress, "Processing categories and tags...")
category_items = Enum.map(wxr_data.categories, &analyze_taxonomy_item(&1, existing_tag_set)) category_items = Enum.map(wxr_data.categories, &analyze_taxonomy_item(&1, existing_tag_set))
@@ -113,10 +145,18 @@ defmodule BDS.ImportAnalysis do
{status, existing} = {status, existing} =
cond do cond do
existing_by_slug && existing_by_slug.checksum == content_checksum && not is_nil(existing_by_slug.checksum) -> {"update", existing_by_slug} existing_by_slug && existing_by_slug.checksum == content_checksum &&
existing_by_slug -> {"conflict", existing_by_slug} not is_nil(existing_by_slug.checksum) ->
existing_by_checksum -> {"content-duplicate", existing_by_checksum} {"update", existing_by_slug}
true -> {"new", nil}
existing_by_slug ->
{"conflict", existing_by_slug}
existing_by_checksum ->
{"content-duplicate", existing_by_checksum}
true ->
{"new", nil}
end end
%{ %{
@@ -163,10 +203,18 @@ defmodule BDS.ImportAnalysis do
existing_by_checksum = Map.get(media_by_checksum, file_checksum) existing_by_checksum = Map.get(media_by_checksum, file_checksum)
cond do cond do
existing_by_name && existing_by_name.checksum == file_checksum && not is_nil(existing_by_name.checksum) -> {"update", file_checksum, existing_by_name} existing_by_name && existing_by_name.checksum == file_checksum &&
existing_by_name -> {"conflict", file_checksum, existing_by_name} not is_nil(existing_by_name.checksum) ->
existing_by_checksum -> {"content-duplicate", file_checksum, existing_by_checksum} {"update", file_checksum, existing_by_name}
true -> {"new", file_checksum, nil}
existing_by_name ->
{"conflict", file_checksum, existing_by_name}
existing_by_checksum ->
{"content-duplicate", file_checksum, existing_by_checksum}
true ->
{"new", file_checksum, nil}
end end
end end
@@ -265,7 +313,9 @@ defmodule BDS.ImportAnalysis do
defp date_distribution(posts, pages, media) do defp date_distribution(posts, pages, media) do
combined_posts = posts ++ pages combined_posts = posts ++ pages
post_counts = Enum.reduce(combined_posts, %{}, &increment_year(&1.created_at || &1.published_at, &2)) post_counts =
Enum.reduce(combined_posts, %{}, &increment_year(&1.created_at || &1.published_at, &2))
media_counts = Enum.reduce(media, %{}, &increment_year(&1.created_at, &2)) media_counts = Enum.reduce(media, %{}, &increment_year(&1.created_at, &2))
post_counts post_counts
@@ -325,7 +375,10 @@ defmodule BDS.ImportAnalysis do
| total_count: existing.total_count + 1, | total_count: existing.total_count + 1,
usages: Map.put(existing.usages, params_key, usage), usages: Map.put(existing.usages, params_key, usage),
post_slugs: post_slugs:
if(is_binary(slug), do: MapSet.put(existing.post_slugs, slug), else: existing.post_slugs) if(is_binary(slug),
do: MapSet.put(existing.post_slugs, slug),
else: existing.post_slugs
)
} }
Map.put(inner_acc, name, updated) Map.put(inner_acc, name, updated)
@@ -393,9 +446,17 @@ defmodule BDS.ImportAnalysis do
defp year_from(value) when is_integer(value) do defp year_from(value) when is_integer(value) do
cond do cond do
value > 100_000_000_000 -> value |> DateTime.from_unix!(:millisecond) |> DateTime.shift_zone!("Etc/UTC") |> Map.get(:year) value > 100_000_000_000 ->
value > 1_000_000_000 -> value |> DateTime.from_unix!(:second) |> Map.get(:year) value
true -> value |> DateTime.from_unix!(:millisecond)
|> DateTime.shift_zone!("Etc/UTC")
|> Map.get(:year)
value > 1_000_000_000 ->
value |> DateTime.from_unix!(:second) |> Map.get(:year)
true ->
value
end end
rescue rescue
_error -> nil _error -> nil
@@ -405,10 +466,14 @@ defmodule BDS.ImportAnalysis do
normalized = String.replace(value, " ", "T") normalized = String.replace(value, " ", "T")
case NaiveDateTime.from_iso8601(normalized) do case NaiveDateTime.from_iso8601(normalized) do
{:ok, naive} -> naive.year {:ok, naive} ->
naive.year
_other -> _other ->
case DateTime.from_iso8601(value) do case DateTime.from_iso8601(value) do
{:ok, datetime, _offset} -> datetime.year {:ok, datetime, _offset} ->
datetime.year
_ -> _ ->
case Regex.run(~r/(\d{4})/, value) do case Regex.run(~r/(\d{4})/, value) do
[_, year] -> String.to_integer(year) [_, year] -> String.to_integer(year)

View File

@@ -39,7 +39,10 @@ defmodule BDS.ImportDefinitions do
|> maybe_put(:name, attr(attrs, :name)) |> maybe_put(:name, attr(attrs, :name))
|> maybe_put(:wxr_file_path, attr(attrs, :wxr_file_path)) |> maybe_put(:wxr_file_path, attr(attrs, :wxr_file_path))
|> maybe_put(:uploads_folder_path, attr(attrs, :uploads_folder_path)) |> maybe_put(:uploads_folder_path, attr(attrs, :uploads_folder_path))
|> maybe_put(:last_analysis_result, normalize_analysis_result(attr(attrs, :last_analysis_result))) |> maybe_put(
:last_analysis_result,
normalize_analysis_result(attr(attrs, :last_analysis_result))
)
|> Map.put(:updated_at, Persistence.now_ms()) |> Map.put(:updated_at, Persistence.now_ms())
definition definition
@@ -50,7 +53,9 @@ defmodule BDS.ImportDefinitions do
def delete_definition(definition_id) when is_binary(definition_id) do def delete_definition(definition_id) when is_binary(definition_id) do
case Repo.get(ImportDefinition, definition_id) do case Repo.get(ImportDefinition, definition_id) do
nil -> {:error, :not_found} nil ->
{:error, :not_found}
%ImportDefinition{} = definition -> %ImportDefinition{} = definition ->
Repo.delete(definition) Repo.delete(definition)
|> case do |> case do
@@ -60,7 +65,8 @@ defmodule BDS.ImportDefinitions do
end end
end end
def decode_analysis_result(%ImportDefinition{last_analysis_result: result}), do: decode_analysis_result(result) def decode_analysis_result(%ImportDefinition{last_analysis_result: result}),
do: decode_analysis_result(result)
def decode_analysis_result(result) when is_binary(result) do def decode_analysis_result(result) when is_binary(result) do
case Jason.decode(result) do case Jason.decode(result) do

View File

@@ -19,7 +19,16 @@ defmodule BDS.ImportDefinitions.ImportDefinition do
def changeset(definition, attrs) do def changeset(definition, attrs) do
definition definition
|> cast(attrs, [:id, :project_id, :name, :wxr_file_path, :uploads_folder_path, :last_analysis_result, :created_at, :updated_at]) |> cast(attrs, [
:id,
:project_id,
:name,
:wxr_file_path,
:uploads_folder_path,
:last_analysis_result,
:created_at,
:updated_at
])
|> validate_required([:id, :project_id, :name, :created_at, :updated_at]) |> validate_required([:id, :project_id, :name, :created_at, :updated_at])
end end
end end

View File

@@ -8,7 +8,8 @@ defmodule BDS.ImportExecution do
alias BDS.Repo alias BDS.Repo
alias BDS.Tags alias BDS.Tags
def execute_import(project_id, report, opts \\ []) when is_binary(project_id) and is_map(report) do def execute_import(project_id, report, opts \\ [])
when is_binary(project_id) and is_map(report) do
normalized_report = normalize_report(report) normalized_report = normalize_report(report)
default_author = Keyword.get(opts, :default_author) || project_default_author(project_id) default_author = Keyword.get(opts, :default_author) || project_default_author(project_id)
uploads_folder_path = Keyword.get(opts, :uploads_folder_path) uploads_folder_path = Keyword.get(opts, :uploads_folder_path)
@@ -42,16 +43,52 @@ defmodule BDS.ImportExecution do
started_at = System.monotonic_time(:millisecond) started_at = System.monotonic_time(:millisecond)
notify_progress(on_progress, "tags", 0, taxonomy_total, "creating_tags", started_at) notify_progress(on_progress, "tags", 0, taxonomy_total, "creating_tags", started_at)
result = execute_taxonomies(category_items, tag_items, project_id, result, on_progress, started_at)
result =
execute_taxonomies(category_items, tag_items, project_id, result, on_progress, started_at)
notify_progress(on_progress, "posts", 0, length(post_items), "importing_posts", started_at) notify_progress(on_progress, "posts", 0, length(post_items), "importing_posts", started_at)
result = execute_posts(post_items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, :posts, started_at)
result =
execute_posts(
post_items,
project_id,
default_author,
tag_mapping,
category_mapping,
result,
on_progress,
:posts,
started_at
)
notify_progress(on_progress, "media", 0, length(media_items), "importing_media", started_at) notify_progress(on_progress, "media", 0, length(media_items), "importing_media", started_at)
result = execute_media(media_items, project_id, default_author, result, on_progress, uploads_folder_path, started_at)
result =
execute_media(
media_items,
project_id,
default_author,
result,
on_progress,
uploads_folder_path,
started_at
)
notify_progress(on_progress, "pages", 0, length(page_items), "importing_pages", started_at) notify_progress(on_progress, "pages", 0, length(page_items), "importing_pages", started_at)
result = execute_posts(page_items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, :pages, started_at)
result =
execute_posts(
page_items,
project_id,
default_author,
tag_mapping,
category_mapping,
result,
on_progress,
:pages,
started_at
)
notify_progress(on_progress, "complete", 1, 1, "import_complete", started_at) notify_progress(on_progress, "complete", 1, 1, "import_complete", started_at)
{:ok, result} {:ok, result}
@@ -68,41 +105,99 @@ defmodule BDS.ImportExecution do
|> Enum.reduce(result, fn {item, index}, acc -> |> Enum.reduce(result, fn {item, index}, acc ->
cond do cond do
Map.get(item, :exists_in_project) || not is_nil(Map.get(item, :mapped_to)) -> Map.get(item, :exists_in_project) || not is_nil(Map.get(item, :mapped_to)) ->
notify_progress(on_progress, "tags", index, total, "skipped_tag:#{item.name}", started_at) notify_progress(
on_progress,
"tags",
index,
total,
"skipped_tag:#{item.name}",
started_at
)
put_in(acc, [:tags, :skipped], acc.tags.skipped + 1) put_in(acc, [:tags, :skipped], acc.tags.skipped + 1)
true -> true ->
case Tags.create_tag(%{project_id: project_id, name: item.name}) do case Tags.create_tag(%{project_id: project_id, name: item.name}) do
{:ok, _tag} -> {:ok, _tag} ->
notify_progress(on_progress, "tags", index, total, "created_tag:#{item.name}", started_at) notify_progress(
on_progress,
"tags",
index,
total,
"created_tag:#{item.name}",
started_at
)
put_in(acc, [:tags, :created], acc.tags.created + 1) put_in(acc, [:tags, :created], acc.tags.created + 1)
{:error, _reason} -> {:error, _reason} ->
notify_progress(on_progress, "tags", index, total, "skipped_tag:#{item.name}", started_at) notify_progress(
on_progress,
"tags",
index,
total,
"skipped_tag:#{item.name}",
started_at
)
put_in(acc, [:tags, :skipped], acc.tags.skipped + 1) put_in(acc, [:tags, :skipped], acc.tags.skipped + 1)
end end
end end
end) end)
end end
defp execute_posts(items, project_id, default_author, tag_mapping, category_mapping, result, on_progress, bucket, started_at) do defp execute_posts(
items,
project_id,
default_author,
tag_mapping,
category_mapping,
result,
on_progress,
bucket,
started_at
) do
total = length(items) total = length(items)
phase = Atom.to_string(bucket) phase = Atom.to_string(bucket)
Enum.with_index(items, 1) Enum.with_index(items, 1)
|> Enum.reduce(result, fn {item, index}, acc -> |> Enum.reduce(result, fn {item, index}, acc ->
notify_progress(on_progress, phase, index, total, "processing:#{item.title}", started_at) notify_progress(on_progress, phase, index, total, "processing:#{item.title}", started_at)
execute_post_item(project_id, maybe_apply_page_category(item, bucket), acc, bucket, default_author, tag_mapping, category_mapping)
execute_post_item(
project_id,
maybe_apply_page_category(item, bucket),
acc,
bucket,
default_author,
tag_mapping,
category_mapping
)
end) end)
end end
defp execute_media(items, project_id, default_author, result, on_progress, uploads_folder_path, started_at) do defp execute_media(
items,
project_id,
default_author,
result,
on_progress,
uploads_folder_path,
started_at
) do
total = length(items) total = length(items)
items items
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.reduce(result, fn {item, index}, acc -> |> Enum.reduce(result, fn {item, index}, acc ->
notify_progress(on_progress, "media", index, total, "processing:#{item.filename}", started_at) notify_progress(
on_progress,
"media",
index,
total,
"processing:#{item.filename}",
started_at
)
cond do cond do
item.status == "missing" -> item.status == "missing" ->
@@ -116,7 +211,9 @@ defmodule BDS.ImportExecution do
true -> true ->
case import_media_item(project_id, item, default_author, uploads_folder_path, acc) do case import_media_item(project_id, item, default_author, uploads_folder_path, acc) do
{:ok, _media} -> put_in(acc, [:media, :imported], acc.media.imported + 1) {:ok, _media} ->
put_in(acc, [:media, :imported], acc.media.imported + 1)
{:error, reason} -> {:error, reason} ->
acc acc
|> put_in([:media, :errors], acc.media.errors + 1) |> put_in([:media, :errors], acc.media.errors + 1)
@@ -127,7 +224,15 @@ defmodule BDS.ImportExecution do
end) end)
end end
defp execute_post_item(project_id, item, result, bucket, default_author, tag_mapping, category_mapping) do defp execute_post_item(
project_id,
item,
result,
bucket,
default_author,
tag_mapping,
category_mapping
) do
cond do cond do
item.status in ["update", "content-duplicate", "duplicate"] -> item.status in ["update", "content-duplicate", "duplicate"] ->
put_in(result, [bucket, :skipped], get_in(result, [bucket, :skipped]) + 1) put_in(result, [bucket, :skipped], get_in(result, [bucket, :skipped]) + 1)
@@ -177,7 +282,8 @@ defmodule BDS.ImportExecution do
defp overwrite_post_item(item, default_author, tag_mapping, category_mapping) do defp overwrite_post_item(item, default_author, tag_mapping, category_mapping) do
case Repo.get(Post, item.existing_id) do case Repo.get(Post, item.existing_id) do
nil -> {:error, :not_found} nil ->
{:error, :not_found}
%Post{} = post -> %Post{} = post ->
Posts.update_post(post.id, %{ Posts.update_post(post.id, %{
@@ -194,7 +300,13 @@ defmodule BDS.ImportExecution do
defp import_media_item(project_id, item, default_author, uploads_folder_path, result) do defp import_media_item(project_id, item, default_author, uploads_folder_path, result) do
source_path = item.source_file || uploads_source_path(item.relative_path, uploads_folder_path) source_path = item.source_file || uploads_source_path(item.relative_path, uploads_folder_path)
checksum = if(source_path != nil and File.exists?(source_path), do: md5(File.read!(source_path)), else: nil)
checksum =
if(source_path != nil and File.exists?(source_path),
do: md5(File.read!(source_path)),
else: nil
)
linked_post_ids = parent_post_ids(item, result) linked_post_ids = parent_post_ids(item, result)
if source_path && File.exists?(source_path) do if source_path && File.exists?(source_path) do
@@ -221,7 +333,10 @@ defmodule BDS.ImportExecution do
checksum: checksum checksum: checksum
} }
attrs = if linked_post_ids == [], do: attrs, else: Map.put(attrs, :linked_post_ids, linked_post_ids) attrs =
if linked_post_ids == [],
do: attrs,
else: Map.put(attrs, :linked_post_ids, linked_post_ids)
case Media.import_media(attrs) do case Media.import_media(attrs) do
{:ok, %{id: media_id} = media} -> {:ok, %{id: media_id} = media} ->
@@ -255,8 +370,12 @@ defmodule BDS.ImportExecution do
defp parent_post_ids(item, result) do defp parent_post_ids(item, result) do
case Map.get(item, :parent_wp_id) do case Map.get(item, :parent_wp_id) do
nil -> [] nil ->
0 -> [] []
0 ->
[]
wp_id -> wp_id ->
case Map.get(result.wp_id_to_post_id, wp_id) do case Map.get(result.wp_id_to_post_id, wp_id) do
nil -> [] nil -> []
@@ -265,7 +384,8 @@ defmodule BDS.ImportExecution do
end end
end end
defp track_wp_id(result, %{wp_id: wp_id}, %{id: post_id}) when is_integer(wp_id) and not is_nil(post_id) do defp track_wp_id(result, %{wp_id: wp_id}, %{id: post_id})
when is_integer(wp_id) and not is_nil(post_id) do
update_in(result, [:wp_id_to_post_id], &Map.put(&1, wp_id, post_id)) update_in(result, [:wp_id_to_post_id], &Map.put(&1, wp_id, post_id))
end end
@@ -333,7 +453,9 @@ defmodule BDS.ImportExecution do
end end
defp maybe_apply_page_category(item, :pages) do defp maybe_apply_page_category(item, :pages) do
categories = (Map.get(item, :categories) || []) |> Enum.uniq() |> Enum.concat(["page"]) |> Enum.uniq() categories =
(Map.get(item, :categories) || []) |> Enum.uniq() |> Enum.concat(["page"]) |> Enum.uniq()
%{item | categories: categories} %{item | categories: categories}
end end
@@ -349,7 +471,11 @@ defmodule BDS.ImportExecution do
true -> key true -> key
end end
Map.put(acc, key, %{resolved: resolved, needs_creation: not item.exists_in_project and not present_string?(Map.get(item, :mapped_to))}) Map.put(acc, key, %{
resolved: resolved,
needs_creation:
not item.exists_in_project and not present_string?(Map.get(item, :mapped_to))
})
end) end)
end end
@@ -443,13 +569,15 @@ defmodule BDS.ImportExecution do
defp uploads_source_path(relative_path, uploads_folder_path) defp uploads_source_path(relative_path, uploads_folder_path)
defp uploads_source_path(relative_path, uploads_folder_path) defp uploads_source_path(relative_path, uploads_folder_path)
when is_binary(relative_path) and is_binary(uploads_folder_path) and uploads_folder_path != "" do when is_binary(relative_path) and is_binary(uploads_folder_path) and
uploads_folder_path != "" do
Path.join(uploads_folder_path, relative_path) Path.join(uploads_folder_path, relative_path)
end end
defp uploads_source_path(_relative_path, _uploads_folder_path), do: nil defp uploads_source_path(_relative_path, _uploads_folder_path), do: nil
defp notify_progress(callback, phase, current, total, detail, started_at) when is_function(callback, 4) do defp notify_progress(callback, phase, current, total, detail, started_at)
when is_function(callback, 4) do
eta = compute_eta(current, total, started_at) eta = compute_eta(current, total, started_at)
try do try do
@@ -466,7 +594,9 @@ defmodule BDS.ImportExecution do
:ok :ok
end end
defp compute_eta(current, total, started_at) when is_integer(current) and is_integer(total) and current > 0 and total > 0 and current <= total do defp compute_eta(current, total, started_at)
when is_integer(current) and is_integer(total) and current > 0 and total > 0 and
current <= total do
elapsed = System.monotonic_time(:millisecond) - started_at elapsed = System.monotonic_time(:millisecond) - started_at
if current >= total, do: 0, else: trunc(elapsed / current * (total - current)) if current >= total, do: 0, else: trunc(elapsed / current * (total - current))
end end

View File

@@ -114,9 +114,11 @@ defmodule BDS.Maintenance do
phases = [ phases = [
{"Comparing project metadata", fn -> project_metadata_diff_reports(project_id) end}, {"Comparing project metadata", fn -> project_metadata_diff_reports(project_id) end},
{"Comparing post metadata", fn -> post_diff_reports(project_id, project) end}, {"Comparing post metadata", fn -> post_diff_reports(project_id, project) end},
{"Comparing post translations", fn -> post_translation_diff_reports(project_id, project) end}, {"Comparing post translations",
fn -> post_translation_diff_reports(project_id, project) end},
{"Comparing media metadata", fn -> media_diff_reports(project_id, project) end}, {"Comparing media metadata", fn -> media_diff_reports(project_id, project) end},
{"Comparing media translations", fn -> media_translation_diff_reports(project_id, project) end}, {"Comparing media translations",
fn -> media_translation_diff_reports(project_id, project) end},
{"Comparing script metadata", fn -> script_diff_reports(project_id, project) end}, {"Comparing script metadata", fn -> script_diff_reports(project_id, project) end},
{"Comparing template metadata", fn -> template_diff_reports(project_id, project) end}, {"Comparing template metadata", fn -> template_diff_reports(project_id, project) end},
{"Comparing embeddings", fn -> Embeddings.diff_reports(project_id) end} {"Comparing embeddings", fn -> Embeddings.diff_reports(project_id) end}
@@ -132,7 +134,9 @@ defmodule BDS.Maintenance do
fun.() fun.()
end) end)
:ok = report_metadata_diff_phase(on_progress, total_phases, total_phases, "Scanning orphan files") :ok =
report_metadata_diff_phase(on_progress, total_phases, total_phases, "Scanning orphan files")
orphan_reports = orphan_reports(project_id, project) orphan_reports = orphan_reports(project_id, project)
:ok = report_metadata_diff_complete(on_progress) :ok = report_metadata_diff_complete(on_progress)

View File

@@ -87,7 +87,10 @@ defmodule BDS.Maintenance.DiffComputation do
end end
def normalize_nested_diff_value(value) when is_map(value), do: normalize_map_diff_values(value) def normalize_nested_diff_value(value) when is_map(value), do: normalize_map_diff_values(value)
def normalize_nested_diff_value(value) when is_list(value), do: Enum.map(value, &normalize_nested_diff_value/1)
def normalize_nested_diff_value(value) when is_list(value),
do: Enum.map(value, &normalize_nested_diff_value/1)
def normalize_nested_diff_value(value) when is_atom(value), do: Atom.to_string(value) def normalize_nested_diff_value(value) when is_atom(value), do: Atom.to_string(value)
def normalize_nested_diff_value(value), do: value def normalize_nested_diff_value(value), do: value
end end

View File

@@ -110,10 +110,18 @@ defmodule BDS.Maintenance.DiffReports do
diff_field("author", post.author, Map.get(fields, "author")), diff_field("author", post.author, Map.get(fields, "author")),
diff_field("language", post.language, Map.get(fields, "language")), diff_field("language", post.language, Map.get(fields, "language")),
diff_field("status", post.status, DocumentFields.get(fields, "status")), diff_field("status", post.status, DocumentFields.get(fields, "status")),
diff_field("template_slug", post.template_slug, DocumentFields.get(fields, "templateSlug")), diff_field(
"template_slug",
post.template_slug,
DocumentFields.get(fields, "templateSlug")
),
diff_field("created_at", post.created_at, DocumentFields.get(fields, "createdAt")), diff_field("created_at", post.created_at, DocumentFields.get(fields, "createdAt")),
diff_field("updated_at", post.updated_at, DocumentFields.get(fields, "updatedAt")), diff_field("updated_at", post.updated_at, DocumentFields.get(fields, "updatedAt")),
diff_field("published_at", post.published_at, DocumentFields.get(fields, "publishedAt")), diff_field(
"published_at",
post.published_at,
DocumentFields.get(fields, "publishedAt")
),
diff_field("tags", post.tags, Map.get(fields, "tags", [])), diff_field("tags", post.tags, Map.get(fields, "tags", [])),
diff_field("categories", post.categories, Map.get(fields, "categories", [])) diff_field("categories", post.categories, Map.get(fields, "categories", []))
] ]
@@ -265,7 +273,11 @@ defmodule BDS.Maintenance.DiffReports do
diff_field("title", script.title, Map.get(fields, "title")), diff_field("title", script.title, Map.get(fields, "title")),
diff_field("entrypoint", script.entrypoint, Map.get(fields, "entrypoint")), diff_field("entrypoint", script.entrypoint, Map.get(fields, "entrypoint")),
diff_field("enabled", script.enabled, Map.get(fields, "enabled")), diff_field("enabled", script.enabled, Map.get(fields, "enabled")),
diff_field("created_at", script.created_at, DocumentFields.get(fields, "createdAt")), diff_field(
"created_at",
script.created_at,
DocumentFields.get(fields, "createdAt")
),
diff_field("updated_at", script.updated_at, DocumentFields.get(fields, "updatedAt")) diff_field("updated_at", script.updated_at, DocumentFields.get(fields, "updatedAt"))
] ]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
@@ -296,8 +308,16 @@ defmodule BDS.Maintenance.DiffReports do
[ [
diff_field("title", template.title, Map.get(fields, "title")), diff_field("title", template.title, Map.get(fields, "title")),
diff_field("enabled", template.enabled, Map.get(fields, "enabled")), diff_field("enabled", template.enabled, Map.get(fields, "enabled")),
diff_field("created_at", template.created_at, DocumentFields.get(fields, "createdAt")), diff_field(
diff_field("updated_at", template.updated_at, DocumentFields.get(fields, "updatedAt")) "created_at",
template.created_at,
DocumentFields.get(fields, "createdAt")
),
diff_field(
"updated_at",
template.updated_at,
DocumentFields.get(fields, "updatedAt")
)
] ]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)

View File

@@ -30,7 +30,9 @@ defmodule BDS.MCP.AgentConfig do
end end
def config_path(:claude_code, home_dir), do: Path.join(home_dir, ".claude.json") def config_path(:claude_code, home_dir), do: Path.join(home_dir, ".claude.json")
def config_path(:github_copilot, home_dir), do: Path.join([home_dir, "Library", "Application Support", "Code", "User", "mcp.json"])
def config_path(:github_copilot, home_dir),
do: Path.join([home_dir, "Library", "Application Support", "Code", "User", "mcp.json"])
def packaged_executable_path(install_root, platform) when is_binary(install_root) do def packaged_executable_path(install_root, platform) when is_binary(install_root) do
executable_name = executable_name =
@@ -90,12 +92,21 @@ defmodule BDS.MCP.AgentConfig do
defp merge_config(:github_copilot, config, command, args) do defp merge_config(:github_copilot, config, command, args) do
servers = Map.get(config, "servers", %{}) servers = Map.get(config, "servers", %{})
Map.put(config, "servers", Map.put(servers, @server_name, %{"type" => "stdio", "command" => command, "args" => args})) Map.put(
config,
"servers",
Map.put(servers, @server_name, %{"type" => "stdio", "command" => command, "args" => args})
)
end end
defp merge_config(:claude_code, config, command, args) do defp merge_config(:claude_code, config, command, args) do
servers = Map.get(config, "mcpServers", %{}) servers = Map.get(config, "mcpServers", %{})
Map.put(config, "mcpServers", Map.put(servers, @server_name, %{"command" => command, "args" => args}))
Map.put(
config,
"mcpServers",
Map.put(servers, @server_name, %{"command" => command, "args" => args})
)
end end
defp remove_server_entry(:github_copilot, config) do defp remove_server_entry(:github_copilot, config) do

View File

@@ -8,7 +8,11 @@ defmodule BDS.MCP.Proposal do
schema "mcp_proposals" do schema "mcp_proposals" do
field :kind, :string field :kind, :string
field :status, Ecto.Enum, values: [:pending, :accepted, :discarded, :expired], default: :pending
field :status, Ecto.Enum,
values: [:pending, :accepted, :discarded, :expired],
default: :pending
field :entity_id, :string field :entity_id, :string
field :data, :map field :data, :map
field :created_at, :integer field :created_at, :integer
@@ -17,7 +21,9 @@ defmodule BDS.MCP.Proposal do
def changeset(proposal, attrs) do def changeset(proposal, attrs) do
proposal proposal
|> cast(attrs, [:id, :kind, :status, :entity_id, :data, :created_at, :expires_at], empty_values: [nil]) |> cast(attrs, [:id, :kind, :status, :entity_id, :data, :created_at, :expires_at],
empty_values: [nil]
)
|> validate_required([:id, :kind, :status, :entity_id, :data, :created_at, :expires_at]) |> validate_required([:id, :kind, :status, :entity_id, :data, :created_at, :expires_at])
|> unique_constraint(:status, name: :mcp_proposals_entity_idx) |> unique_constraint(:status, name: :mcp_proposals_entity_idx)
end end

View File

@@ -74,12 +74,15 @@ defmodule BDS.MCP.ProposalStore do
defp mark_status(id, status) do defp mark_status(id, status) do
case Repo.get(Proposal, id) do case Repo.get(Proposal, id) do
nil -> nil nil ->
nil
proposal -> proposal ->
Repo.delete_all( Repo.delete_all(
from other in Proposal, from other in Proposal,
where: where:
other.id != ^id and other.kind == ^proposal.kind and other.entity_id == ^proposal.entity_id and other.id != ^id and other.kind == ^proposal.kind and
other.entity_id == ^proposal.entity_id and
other.status == ^status other.status == ^status
) )
@@ -90,6 +93,7 @@ defmodule BDS.MCP.ProposalStore do
end end
defp derive_entity_id(data) do defp derive_entity_id(data) do
data["post_id"] || data["script_id"] || data["template_id"] || data["media_id"] || Ecto.UUID.generate() data["post_id"] || data["script_id"] || data["template_id"] || data["media_id"] ||
Ecto.UUID.generate()
end end
end end

View File

@@ -138,8 +138,11 @@ defmodule BDS.MCP.Server do
case URI.parse(target) do case URI.parse(target) do
%URI{path: "/mcp"} -> %URI{path: "/mcp"} ->
case GenServer.call(__MODULE__, {:http_request, request}, 5_000) do case GenServer.call(__MODULE__, {:http_request, request}, 5_000) do
{:ok, status, body} -> http_response(status, Jason.encode!(body), "application/json", request.headers) {:ok, status, body} ->
{:error, status, body} -> http_response(status, body, "text/plain", request.headers) http_response(status, Jason.encode!(body), "application/json", request.headers)
{:error, status, body} ->
http_response(status, body, "text/plain", request.headers)
end end
_other -> _other ->
@@ -170,7 +173,10 @@ defmodule BDS.MCP.Server do
success_response(id, %{ success_response(id, %{
"protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"), "protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"),
"capabilities" => %{"tools" => %{}, "resources" => %{}}, "capabilities" => %{"tools" => %{}, "resources" => %{}},
"serverInfo" => %{"name" => @server_name, "version" => Application.spec(:bds, :vsn) |> to_string()} "serverInfo" => %{
"name" => @server_name,
"version" => Application.spec(:bds, :vsn) |> to_string()
}
})} })}
"tools/list" -> "tools/list" ->
@@ -196,10 +202,17 @@ defmodule BDS.MCP.Server do
arguments = Map.get(params, "arguments", %{}) arguments = Map.get(params, "arguments", %{})
case BDS.MCP.call_tool(name, arguments) do case BDS.MCP.call_tool(name, arguments) do
{:ok, result} -> {:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})} {:ok, result} ->
{:error, :unknown_tool} -> {:error, error_response(id, -32601, "Unknown tool")} {:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})}
{:error, :not_found} -> {:error, error_response(id, -32004, "Not found")}
{:error, reason} -> {:error, error_response(id, -32000, inspect(reason))} {:error, :unknown_tool} ->
{:error, error_response(id, -32601, "Unknown tool")}
{:error, :not_found} ->
{:error, error_response(id, -32004, "Not found")}
{:error, reason} ->
{:error, error_response(id, -32000, inspect(reason))}
end end
end end
@@ -286,7 +299,8 @@ defmodule BDS.MCP.Server do
|> IO.iodata_to_binary() |> IO.iodata_to_binary()
end end
defp http_error_response(status, headers \\ %{}), do: http_response(status, reason_body(status), "text/plain", headers) defp http_error_response(status, headers \\ %{}),
do: http_response(status, reason_body(status), "text/plain", headers)
defp reason_body(400), do: "Bad Request" defp reason_body(400), do: "Bad Request"
defp reason_body(404), do: "Not Found" defp reason_body(404), do: "Not Found"

View File

@@ -9,8 +9,15 @@ defmodule BDS.MCP.Stdio do
if line != "" do if line != "" do
response = response =
case Jason.decode(line) do case Jason.decode(line) do
{:ok, payload} -> handle_payload(payload) {:ok, payload} ->
{:error, _reason} -> %{"jsonrpc" => "2.0", "id" => nil, "error" => %{"code" => -32700, "message" => "Parse error"}} handle_payload(payload)
{:error, _reason} ->
%{
"jsonrpc" => "2.0",
"id" => nil,
"error" => %{"code" => -32700, "message" => "Parse error"}
}
end end
IO.write(Jason.encode!(response) <> "\n") IO.write(Jason.encode!(response) <> "\n")
@@ -18,14 +25,22 @@ defmodule BDS.MCP.Stdio do
end) end)
end end
defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "initialize", "params" => params}) do defp handle_payload(%{
"jsonrpc" => "2.0",
"id" => id,
"method" => "initialize",
"params" => params
}) do
%{ %{
"jsonrpc" => "2.0", "jsonrpc" => "2.0",
"id" => id, "id" => id,
"result" => %{ "result" => %{
"protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"), "protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"),
"capabilities" => %{"tools" => %{}, "resources" => %{}}, "capabilities" => %{"tools" => %{}, "resources" => %{}},
"serverInfo" => %{"name" => "Blogging Desktop Server", "version" => Application.spec(:bds, :vsn) |> to_string()} "serverInfo" => %{
"name" => "Blogging Desktop Server",
"version" => Application.spec(:bds, :vsn) |> to_string()
}
} }
} }
end end
@@ -34,10 +49,26 @@ defmodule BDS.MCP.Stdio do
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"tools" => BDS.MCP.list_tools()}} %{"jsonrpc" => "2.0", "id" => id, "result" => %{"tools" => BDS.MCP.list_tools()}}
end end
defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "tools/call", "params" => %{"name" => name} = params}) do defp handle_payload(%{
"jsonrpc" => "2.0",
"id" => id,
"method" => "tools/call",
"params" => %{"name" => name} = params
}) do
case BDS.MCP.call_tool(name, Map.get(params, "arguments", %{})) do case BDS.MCP.call_tool(name, Map.get(params, "arguments", %{})) do
{:ok, result} -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"content" => [%{"type" => "json", "json" => result}]}} {:ok, result} ->
{:error, reason} -> %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}} %{
"jsonrpc" => "2.0",
"id" => id,
"result" => %{"content" => [%{"type" => "json", "json" => result}]}
}
{:error, reason} ->
%{
"jsonrpc" => "2.0",
"id" => id,
"error" => %{"code" => -32000, "message" => inspect(reason)}
}
end end
end end
@@ -45,17 +76,38 @@ defmodule BDS.MCP.Stdio do
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}} %{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}}
end end
defp handle_payload(%{"jsonrpc" => "2.0", "id" => id, "method" => "resources/read", "params" => %{"uri" => uri}}) do defp handle_payload(%{
"jsonrpc" => "2.0",
"id" => id,
"method" => "resources/read",
"params" => %{"uri" => uri}
}) do
case BDS.MCP.read_resource(uri) do case BDS.MCP.read_resource(uri) do
{:ok, result} -> {:ok, result} ->
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"contents" => [%{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)}]}} %{
"jsonrpc" => "2.0",
"id" => id,
"result" => %{
"contents" => [
%{"uri" => uri, "mimeType" => "application/json", "text" => Jason.encode!(result)}
]
}
}
{:error, reason} -> {:error, reason} ->
%{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}} %{
"jsonrpc" => "2.0",
"id" => id,
"error" => %{"code" => -32000, "message" => inspect(reason)}
}
end end
end end
defp handle_payload(%{"jsonrpc" => "2.0", "id" => id}) do defp handle_payload(%{"jsonrpc" => "2.0", "id" => id}) do
%{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32601, "message" => "Method not found"}} %{
"jsonrpc" => "2.0",
"id" => id,
"error" => %{"code" => -32601, "message" => "Method not found"}
}
end end
end end

View File

@@ -60,8 +60,11 @@ defmodule BDS.MCP.Tools do
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}} @spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
def validate_template(source) when is_binary(source) do def validate_template(source) when is_binary(source) do
case Liquex.parse(source) do case Liquex.parse(source) do
{:ok, _ast} -> {:ok, %{valid: true, errors: []}} {:ok, _ast} ->
{:error, reason, line} -> {:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}} {:ok, %{valid: true, errors: []}}
{:error, reason, line} ->
{:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}}
end end
end end
@@ -276,7 +279,8 @@ defmodule BDS.MCP.Tools do
ttl_ms: @proposal_ttl_app_ms ttl_ms: @proposal_ttl_app_ms
) )
{:ok, %{"proposal_id" => proposal.id, "current" => sanitize(media), "proposed" => changes}} {:ok,
%{"proposal_id" => proposal.id, "current" => sanitize(media), "proposed" => changes}}
end end
end end

View File

@@ -19,7 +19,8 @@ defmodule BDS.Media.Rebuilder do
@type rebuild_opts :: keyword() @type rebuild_opts :: keyword()
@spec rebuild_media_from_files(String.t(), rebuild_opts()) :: {:ok, [Media.t()]} | {:error, term()} @spec rebuild_media_from_files(String.t(), rebuild_opts()) ::
{:ok, [Media.t()]} | {:error, term()}
def rebuild_media_from_files(project_id, opts \\ []) do def rebuild_media_from_files(project_id, opts \\ []) do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
on_progress = progress_callback(opts) on_progress = progress_callback(opts)
@@ -61,9 +62,10 @@ defmodule BDS.Media.Rebuilder do
translation_sidecars translation_sidecars
|> Enum.with_index(length(canonical_sidecars) + 1) |> Enum.with_index(length(canonical_sidecars) + 1)
|> Enum.each(fn {sidecar, index} -> |> Enum.each(fn {sidecar, index} ->
Sidecars.upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, Sidecars.upsert_translation_from_sidecar(
sync_search: false project,
) canonical_media_by_binary_path,
sidecar, sync_search: false)
:ok = report_rebuild_progress(on_progress, index, total_files, "media files") :ok = report_rebuild_progress(on_progress, index, total_files, "media files")
end) end)

View File

@@ -141,7 +141,12 @@ defmodule BDS.Media.Sidecars do
media media
end end
@spec upsert_translation_from_sidecar(BDS.Projects.Project.t(), %{required(Path.t()) => Media.t()}, map(), keyword()) :: @spec upsert_translation_from_sidecar(
BDS.Projects.Project.t(),
%{required(Path.t()) => Media.t()},
map(),
keyword()
) ::
Translation.t() | :skip | :ok Translation.t() | :skip | :ok
def upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, opts) do def upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar, opts) do
case Map.get(canonical_media_by_binary_path, sidecar.binary_path) do case Map.get(canonical_media_by_binary_path, sidecar.binary_path) do

View File

@@ -70,7 +70,9 @@ defmodule BDS.Media.Thumbnails do
missing_paths = missing_paths =
media media
|> thumbnail_paths() |> thumbnail_paths()
|> Enum.map(fn {_size, relative_path} -> Path.join(Projects.project_data_dir(project), relative_path) end) |> Enum.map(fn {_size, relative_path} ->
Path.join(Projects.project_data_dir(project), relative_path)
end)
|> Enum.reject(&File.exists?/1) |> Enum.reject(&File.exists?/1)
next_acc = next_acc =

View File

@@ -17,7 +17,9 @@ defmodule BDS.Persistence do
value value
|> String.trim() |> String.trim()
|> case do |> case do
"" -> nil "" ->
nil
trimmed -> trimmed ->
case Integer.parse(trimmed) do case Integer.parse(trimmed) do
{integer, ""} -> normalize_unix_timestamp(integer) {integer, ""} -> normalize_unix_timestamp(integer)

View File

@@ -9,6 +9,7 @@ defmodule BDS.PostLinks do
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
@spec sync_post_links(Post.t()) :: :ok
def sync_post_links(%Post{} = post) do def sync_post_links(%Post{} = post) do
links = links =
post post
@@ -41,6 +42,7 @@ defmodule BDS.PostLinks do
:ok :ok
end end
@spec delete_post_links(String.t()) :: :ok
def delete_post_links(post_id) when is_binary(post_id) do def delete_post_links(post_id) when is_binary(post_id) do
Repo.delete_all( Repo.delete_all(
from link in Link, from link in Link,
@@ -50,12 +52,18 @@ defmodule BDS.PostLinks do
:ok :ok
end end
@spec list_outgoing_links(String.t()) :: [Link.t()]
def list_outgoing_links(post_id) when is_binary(post_id) do def list_outgoing_links(post_id) when is_binary(post_id) do
Repo.all(from link in Link, where: link.source_post_id == ^post_id, order_by: [asc: link.created_at]) Repo.all(
from link in Link, where: link.source_post_id == ^post_id, order_by: [asc: link.created_at]
)
end end
@spec list_incoming_links(String.t()) :: [Link.t()]
def list_incoming_links(post_id) when is_binary(post_id) do def list_incoming_links(post_id) when is_binary(post_id) do
Repo.all(from link in Link, where: link.target_post_id == ^post_id, order_by: [asc: link.created_at]) Repo.all(
from link in Link, where: link.target_post_id == ^post_id, order_by: [asc: link.created_at]
)
end end
defp post_body(%Post{content: content}) when is_binary(content), do: content defp post_body(%Post{content: content}) when is_binary(content), do: content
@@ -83,11 +91,15 @@ defmodule BDS.PostLinks do
defp extract_links(body) when is_binary(body) do defp extract_links(body) when is_binary(body) do
markdown_links = markdown_links =
Regex.scan(~r/\[([^\]]+)\]\(([^)]+)\)/, body) Regex.scan(~r/\[([^\]]+)\]\(([^)]+)\)/, body)
|> Enum.map(fn [_full, link_text, href] -> %{link_text: normalize_link_text(link_text), href: href} end) |> Enum.map(fn [_full, link_text, href] ->
%{link_text: normalize_link_text(link_text), href: href}
end)
html_links = html_links =
Regex.scan(~r/<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/is, body) Regex.scan(~r/<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/is, body)
|> Enum.map(fn [_full, href, link_text] -> %{link_text: normalize_link_text(link_text), href: href} end) |> Enum.map(fn [_full, href, link_text] ->
%{link_text: normalize_link_text(link_text), href: href}
end)
markdown_links ++ html_links markdown_links ++ html_links
end end
@@ -124,9 +136,14 @@ defmodule BDS.PostLinks do
do: slug, do: slug,
else: nil else: nil
[slug] -> slug [slug] ->
[language, slug] -> if(language_code?(language), do: slug, else: nil) slug
_other -> nil
[language, slug] ->
if(language_code?(language), do: slug, else: nil)
_other ->
nil
end end
end end

View File

@@ -122,7 +122,8 @@ defmodule BDS.Posts.AutoTranslation do
defp media_needed?(media_id, language) do defp media_needed?(media_id, language) do
case Repo.get(Media.Media, media_id) do case Repo.get(Media.Media, media_id) do
%Media.Media{language: source_language} when source_language not in [nil, ""] and source_language != language -> %Media.Media{language: source_language}
when source_language not in [nil, ""] and source_language != language ->
not Repo.exists?( not Repo.exists?(
from translation in Media.Translation, from translation in Media.Translation,
where: translation.translation_for == ^media_id and translation.language == ^language where: translation.translation_for == ^media_id and translation.language == ^language

View File

@@ -18,8 +18,15 @@ defmodule BDS.Posts.Link do
} }
schema "post_links" do schema "post_links" do
belongs_to :source_post, BDS.Posts.Post, foreign_key: :source_post_id, references: :id, type: :string belongs_to :source_post, BDS.Posts.Post,
belongs_to :target_post, BDS.Posts.Post, foreign_key: :target_post_id, references: :id, type: :string foreign_key: :source_post_id,
references: :id,
type: :string
belongs_to :target_post, BDS.Posts.Post,
foreign_key: :target_post_id,
references: :id,
type: :string
field :link_text, :string field :link_text, :string
field :created_at, :integer field :created_at, :integer

View File

@@ -50,7 +50,11 @@ defmodule BDS.Posts.TranslationValidation do
Repo.all( Repo.all(
from translation in Translation, from translation in Translation,
where: translation.project_id == ^project_id, where: translation.project_id == ^project_id,
order_by: [asc: translation.translation_for, asc: translation.language, asc: translation.id] order_by: [
asc: translation.translation_for,
asc: translation.language,
asc: translation.id
]
) )
project_data_dir = Projects.project_data_dir(project) project_data_dir = Projects.project_data_dir(project)
@@ -67,7 +71,13 @@ defmodule BDS.Posts.TranslationValidation do
translation_rows translation_rows
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.flat_map(fn {translation, index} -> |> Enum.flat_map(fn {translation, index} ->
:ok = RebuildFromFiles.report_rebuild_progress(on_progress, index, total_items, "translations") :ok =
RebuildFromFiles.report_rebuild_progress(
on_progress,
index,
total_items,
"translations"
)
case invalid_database_translation_issue(translation, source_post_map, metadata) do case invalid_database_translation_issue(translation, source_post_map, metadata) do
nil -> [] nil -> []
@@ -80,7 +90,13 @@ defmodule BDS.Posts.TranslationValidation do
markdown_files markdown_files
|> Enum.with_index(length(translation_rows) + 1) |> Enum.with_index(length(translation_rows) + 1)
|> Enum.reduce({0, []}, fn {file_path, index}, {count, issues} -> |> Enum.reduce({0, []}, fn {file_path, index}, {count, issues} ->
:ok = RebuildFromFiles.report_rebuild_progress(on_progress, index, total_items, "translations") :ok =
RebuildFromFiles.report_rebuild_progress(
on_progress,
index,
total_items,
"translations"
)
case invalid_filesystem_translation_issue(file_path, source_post_map, metadata) do case invalid_filesystem_translation_issue(file_path, source_post_map, metadata) do
{:ok, nil} -> {count + 1, issues} {:ok, nil} -> {count + 1, issues}
@@ -118,11 +134,19 @@ defmodule BDS.Posts.TranslationValidation do
normalized_report = normalize_report(report) normalized_report = normalize_report(report)
{deleted_database_rows, flushed_translations, synced_post_ids} = {deleted_database_rows, flushed_translations, synced_post_ids} =
Enum.reduce(normalized_report.invalid_database_rows, {0, 0, MapSet.new()}, fn issue, {deleted, flushed, synced_ids} -> Enum.reduce(normalized_report.invalid_database_rows, {0, 0, MapSet.new()}, fn issue,
{deleted,
flushed,
synced_ids} ->
case fix_invalid_database_row(issue) do case fix_invalid_database_row(issue) do
{:deleted, post_id} -> {deleted + 1, flushed, maybe_put_synced_post(synced_ids, post_id)} {:deleted, post_id} ->
{:flushed, post_id} -> {deleted, flushed + 1, maybe_put_synced_post(synced_ids, post_id)} {deleted + 1, flushed, maybe_put_synced_post(synced_ids, post_id)}
:noop -> {deleted, flushed, synced_ids}
{:flushed, post_id} ->
{deleted, flushed + 1, maybe_put_synced_post(synced_ids, post_id)}
:noop ->
{deleted, flushed, synced_ids}
end end
end) end)
@@ -365,7 +389,10 @@ defmodule BDS.Posts.TranslationValidation do
end end
end end
defp fix_invalid_database_row(%{translation_id: translation_id, translation_for: translation_for}) defp fix_invalid_database_row(%{
translation_id: translation_id,
translation_for: translation_for
})
when is_binary(translation_id) do when is_binary(translation_id) do
case Repo.get(Translation, translation_id) do case Repo.get(Translation, translation_id) do
%Translation{} = translation -> %Translation{} = translation ->
@@ -402,7 +429,11 @@ defmodule BDS.Posts.TranslationValidation do
end end
defp issue_sort_key(issue) do defp issue_sort_key(issue) do
[Map.get(issue, :translation_for), Map.get(issue, :translation_id), Map.get(issue, :file_path)] [
Map.get(issue, :translation_for),
Map.get(issue, :translation_id),
Map.get(issue, :file_path)
]
|> Enum.map(&to_string(&1 || "")) |> Enum.map(&to_string(&1 || ""))
|> Enum.join(":") |> Enum.join(":")
end end

View File

@@ -64,7 +64,11 @@ defmodule BDS.Preview do
{:reply, reply, next_state} {:reply, reply, next_state}
end end
def handle_call({:ensure_preview, project_id, _data_dir, _owner_pid}, _from, %{current: %{project_id: project_id, is_running: true}} = state) do def handle_call(
{:ensure_preview, project_id, _data_dir, _owner_pid},
_from,
%{current: %{project_id: project_id, is_running: true}} = state
) do
{:reply, {:ok, public_server(state.current)}, state} {:reply, {:ok, public_server(state.current)}, state}
end end
@@ -224,7 +228,9 @@ defmodule BDS.Preview do
end end
defp draft_preview_translation(_post_id, nil, _post_language), do: nil defp draft_preview_translation(_post_id, nil, _post_language), do: nil
defp draft_preview_translation(_post_id, requested_language, post_language) when requested_language == post_language, do: nil
defp draft_preview_translation(_post_id, requested_language, post_language)
when requested_language == post_language, do: nil
defp draft_preview_translation(post_id, requested_language, _post_language) do defp draft_preview_translation(post_id, requested_language, _post_language) do
Repo.get_by(Translation, translation_for: post_id, language: requested_language) Repo.get_by(Translation, translation_for: post_id, language: requested_language)
@@ -456,7 +462,10 @@ defmodule BDS.Preview do
{uri.path || "/", URI.decode_query(uri.query || "")} {uri.path || "/", URI.decode_query(uri.query || "")}
end end
defp apply_response_overrides(%{content_type: content_type, body: body} = response, query_params) defp apply_response_overrides(
%{content_type: content_type, body: body} = response,
query_params
)
when is_binary(content_type) and is_binary(body) do when is_binary(content_type) and is_binary(body) do
if String.starts_with?(content_type, "text/html") do if String.starts_with?(content_type, "text/html") do
%{response | body: apply_preview_overrides(body, query_params)} %{response | body: apply_preview_overrides(body, query_params)}
@@ -465,7 +474,8 @@ defmodule BDS.Preview do
end end
end end
defp apply_preview_overrides(body, query_params) when is_binary(body) and is_map(query_params) do defp apply_preview_overrides(body, query_params)
when is_binary(body) and is_map(query_params) do
theme_override = normalize_pico_theme_override(query_params["theme"]) theme_override = normalize_pico_theme_override(query_params["theme"])
mode_override = normalize_mode_override(query_params["mode"]) mode_override = normalize_mode_override(query_params["mode"])
@@ -506,7 +516,9 @@ defmodule BDS.Preview do
[html_tag] -> [html_tag] ->
replacement = replacement =
if String.contains?(html_tag, attribute <> "=") do if String.contains?(html_tag, attribute <> "=") do
Regex.replace(~r/\s#{attribute}="[^"]*"/, html_tag, ~s( #{attribute}="#{value}"), global: false) Regex.replace(~r/\s#{attribute}="[^"]*"/, html_tag, ~s( #{attribute}="#{value}"),
global: false
)
else else
String.replace_suffix(html_tag, ">", ~s( #{attribute}="#{value}">)) String.replace_suffix(html_tag, ">", ~s( #{attribute}="#{value}">))
end end
@@ -520,7 +532,11 @@ defmodule BDS.Preview do
defp not_found_assigns(query_params) do defp not_found_assigns(query_params) do
%{} %{}
|> maybe_put_assign("pico_stylesheet_href", normalize_pico_theme_override(query_params["theme"]), &PreviewAssets.stylesheet_href/1) |> maybe_put_assign(
"pico_stylesheet_href",
normalize_pico_theme_override(query_params["theme"]),
&PreviewAssets.stylesheet_href/1
)
end end
defp maybe_put_assign(assigns, _key, nil, _mapper), do: assigns defp maybe_put_assign(assigns, _key, nil, _mapper), do: assigns

View File

@@ -134,7 +134,8 @@ defmodule BDS.Projects do
sync_filesystem_metadata(project) sync_filesystem_metadata(project)
end end
{:error, reason} -> {:error, reason} {:error, reason} ->
{:error, reason}
end end
end end
@@ -166,7 +167,8 @@ defmodule BDS.Projects do
@spec delete_project(String.t()) :: @spec delete_project(String.t()) ::
{:ok, Project.t()} {:ok, Project.t()}
| {:error, :not_found | :cannot_delete_default_project | :cannot_delete_active_project | term()} | {:error,
:not_found | :cannot_delete_default_project | :cannot_delete_active_project | term()}
def delete_project(project_id) when is_binary(project_id) do def delete_project(project_id) when is_binary(project_id) do
case Repo.get(Project, project_id) do case Repo.get(Project, project_id) do
nil -> nil ->
@@ -180,7 +182,9 @@ defmodule BDS.Projects do
%Project{} = project -> %Project{} = project ->
internal_dir = if is_nil(project.data_path), do: project_data_dir(project), else: nil internal_dir = if is_nil(project.data_path), do: project_data_dir(project), else: nil
cleanup_dirs = [internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
cleanup_dirs =
[internal_dir, project_cache_dir(project)] |> Enum.filter(&is_binary/1) |> Enum.uniq()
Repo.transaction(fn -> Repo.transaction(fn ->
Repo.delete!(project) Repo.delete!(project)

View File

@@ -7,7 +7,11 @@ defmodule BDS.Rebuild do
timeout = Keyword.get(opts, :timeout, :infinity) timeout = Keyword.get(opts, :timeout, :infinity)
items items
|> Task.async_stream(mapper, max_concurrency: max_concurrency, ordered: ordered, timeout: timeout) |> Task.async_stream(mapper,
max_concurrency: max_concurrency,
ordered: ordered,
timeout: timeout
)
|> Enum.map(fn |> Enum.map(fn
{:ok, item} -> item {:ok, item} -> item
{:exit, reason} -> exit(reason) {:exit, reason} -> exit(reason)

View File

@@ -26,7 +26,8 @@ defmodule BDS.ReleasePackaging do
] ]
end end
def build_metadata(platform, version, output_dir) when is_binary(version) and is_binary(output_dir) do def build_metadata(platform, version, output_dir)
when is_binary(version) and is_binary(output_dir) do
normalized_platform = normalize_platform(platform) normalized_platform = normalize_platform(platform)
payload_name = "bds2-#{normalized_platform}-#{version}" payload_name = "bds2-#{normalized_platform}-#{version}"
payload_root = Path.join(output_dir, payload_name) payload_root = Path.join(output_dir, payload_name)
@@ -66,7 +67,9 @@ defmodule BDS.ReleasePackaging do
defp normalize_platform(platform) when platform in [:macos, :linux, :windows], do: platform defp normalize_platform(platform) when platform in [:macos, :linux, :windows], do: platform
defp normalize_platform(:darwin), do: :macos defp normalize_platform(:darwin), do: :macos
defp normalize_platform(platform) when is_binary(platform), do: platform |> String.downcase() |> String.to_atom()
defp normalize_platform(platform) when is_binary(platform),
do: platform |> String.downcase() |> String.to_atom()
defp archive_extension(:windows), do: ".zip" defp archive_extension(:windows), do: ".zip"
defp archive_extension(_platform), do: ".tar.gz" defp archive_extension(_platform), do: ".tar.gz"
@@ -107,7 +110,9 @@ defmodule BDS.ReleasePackaging do
relative_entries = collect_entries(metadata.payload_root) relative_entries = collect_entries(metadata.payload_root)
cwd = metadata.output_dir |> String.to_charlist() cwd = metadata.output_dir |> String.to_charlist()
archive = metadata.archive_path |> String.to_charlist() archive = metadata.archive_path |> String.to_charlist()
entries = Enum.map(relative_entries, &String.to_charlist(Path.join(metadata.payload_name, &1)))
entries =
Enum.map(relative_entries, &String.to_charlist(Path.join(metadata.payload_name, &1)))
case :zip.create(archive, entries, cwd: cwd) do case :zip.create(archive, entries, cwd: cwd) do
{:ok, _archive_path} -> :ok {:ok, _archive_path} -> :ok
@@ -116,7 +121,13 @@ defmodule BDS.ReleasePackaging do
end end
defp create_archive(metadata) do defp create_archive(metadata) do
case System.cmd("tar", ["-czf", metadata.archive_path, "-C", metadata.output_dir, metadata.payload_name]) do case System.cmd("tar", [
"-czf",
metadata.archive_path,
"-C",
metadata.output_dir,
metadata.payload_name
]) do
{_output, 0} -> :ok {_output, 0} -> :ok
{output, status} -> {:error, {:tar_failed, status, output}} {output, status} -> {:error, {:tar_failed, status, output}}
end end

View File

@@ -7,9 +7,14 @@ defmodule BDS.Rendering do
def render_post_page(project_id, template_slug, assigns) def render_post_page(project_id, template_slug, assigns)
when is_binary(project_id) and is_map(assigns) do when is_binary(project_id) and is_map(assigns) do
with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :post, template_slug), with {:ok, template_source} <-
TemplateSelection.load_template_source(project_id, :post, template_slug),
{:ok, rendered} <- {:ok, rendered} <-
TemplateSelection.render_template(project_id, template_source, PostRendering.post_assigns(project_id, assigns)) do TemplateSelection.render_template(
project_id,
template_source,
PostRendering.post_assigns(project_id, assigns)
) do
{:ok, rendered} {:ok, rendered}
end end
end end
@@ -17,16 +22,25 @@ defmodule BDS.Rendering do
def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do def render_list_page(project_id, assigns) when is_binary(project_id) and is_map(assigns) do
with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :list, nil), with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :list, nil),
{:ok, rendered} <- {:ok, rendered} <-
TemplateSelection.render_template(project_id, template_source, ListArchive.list_assigns(project_id, assigns)) do TemplateSelection.render_template(
project_id,
template_source,
ListArchive.list_assigns(project_id, assigns)
) do
{:ok, rendered} {:ok, rendered}
end end
end end
def render_not_found_page(project_id, assigns \\ %{}) def render_not_found_page(project_id, assigns \\ %{})
when is_binary(project_id) and is_map(assigns) do when is_binary(project_id) and is_map(assigns) do
with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :not_found, nil), with {:ok, template_source} <-
TemplateSelection.load_template_source(project_id, :not_found, nil),
{:ok, rendered} <- {:ok, rendered} <-
TemplateSelection.render_template(project_id, template_source, ListArchive.not_found_assigns(project_id, assigns)) do TemplateSelection.render_template(
project_id,
template_source,
ListArchive.not_found_assigns(project_id, assigns)
) do
{:ok, rendered} {:ok, rendered}
end end
end end

View File

@@ -54,7 +54,9 @@ defmodule BDS.Rendering.Metadata do
|> Enum.uniq() |> Enum.uniq()
|> Enum.map(fn language -> |> Enum.map(fn language ->
normalized = I18n.normalize_language(language) normalized = I18n.normalize_language(language)
href_prefix = LinksAndLanguages.language_prefix(normalized, metadata.main_language || current_language)
href_prefix =
LinksAndLanguages.language_prefix(normalized, metadata.main_language || current_language)
%{ %{
code: normalized, code: normalized,
@@ -84,9 +86,17 @@ defmodule BDS.Rendering.Metadata do
order_by: [asc: translation.language] order_by: [asc: translation.language]
) )
[%{href: LinksAndLanguages.post_path(post, nil), hreflang: LinksAndLanguages.normalize_language(post.language, main_language)}] ++ [
%{
href: LinksAndLanguages.post_path(post, nil),
hreflang: LinksAndLanguages.normalize_language(post.language, main_language)
}
] ++
Enum.map(translations, fn translation -> Enum.map(translations, fn translation ->
%{href: LinksAndLanguages.post_path(post, translation.language, main_language), hreflang: translation.language} %{
href: LinksAndLanguages.post_path(post, translation.language, main_language),
hreflang: translation.language
}
end) end)
end end

View File

@@ -36,7 +36,9 @@ defmodule BDS.Scripting do
runtime().execute(source, entrypoint, args, opts) runtime().execute(source, entrypoint, args, opts)
end end
@spec execute_project_script(String.t(), String.t(), String.t(), [term()], [Runtime.execution_option()]) :: @spec execute_project_script(String.t(), String.t(), String.t(), [term()], [
Runtime.execution_option()
]) ::
{:ok, term()} | {:error, term()} {:ok, term()} | {:error, term()}
def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ []) def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ [])
when is_binary(project_id) and is_binary(source) and is_binary(entrypoint) and when is_binary(project_id) and is_binary(source) and is_binary(entrypoint) and
@@ -45,13 +47,20 @@ defmodule BDS.Scripting do
execute(source, entrypoint, args, Keyword.put(opts, :capabilities, capabilities)) execute(source, entrypoint, args, Keyword.put(opts, :capabilities, capabilities))
end end
@spec execute_macro(String.t(), String.t(), [term()], keyword()) :: {:ok, String.t()} | {:error, term()} @spec execute_macro(String.t(), String.t(), [term()], keyword()) ::
{:ok, String.t()} | {:error, term()}
def execute_macro(project_id, source, args, opts \\ []) def execute_macro(project_id, source, args, opts \\ [])
when is_binary(project_id) and is_binary(source) and is_list(args) and is_list(opts) do when is_binary(project_id) and is_binary(source) and is_list(args) and is_list(opts) do
config = Application.fetch_env!(:bds, :scripting) config = Application.fetch_env!(:bds, :scripting)
timeout = Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout)) timeout = Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout))
case execute_project_script(project_id, source, "render", args, Keyword.put(opts, :timeout, timeout)) do case execute_project_script(
project_id,
source,
"render",
args,
Keyword.put(opts, :timeout, timeout)
) do
{:ok, nil} -> {:ok, ""} {:ok, nil} -> {:ok, ""}
{:ok, value} -> {:ok, to_string(value)} {:ok, value} -> {:ok, to_string(value)}
{:error, _reason} -> {:ok, ""} {:error, _reason} -> {:ok, ""}

Some files were not shown because too many files have changed in this diff Show More