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
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])
end
end

View File

@@ -25,7 +25,9 @@ defmodule BDS.AI.ChatConversation do
def changeset(conversation, attrs) do
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])
end
end

View File

@@ -23,18 +23,20 @@ defmodule BDS.AI.ChatMessage do
def changeset(message, attrs) do
message
|> cast(attrs, [
:conversation_id,
:role,
:content,
:tool_call_id,
:tool_calls,
:token_usage_input,
:token_usage_output,
:cache_read_tokens,
:cache_write_tokens,
:created_at
], empty_values: [nil])
|> cast(
attrs,
[
:conversation_id,
:role,
:content,
:tool_call_id,
:tool_calls,
:token_usage_input,
:token_usage_output,
:cache_read_tokens,
:cache_write_tokens,
:created_at
], empty_values: [nil])
|> validate_required([:conversation_id, :role, :created_at])
|> assoc_constraint(:conversation)
end

View File

@@ -14,8 +14,10 @@ defmodule BDS.AI.ChatTools do
project_id = project_id || active_project_id()
%{
post_count: 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),
post_count:
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),
category_count: Chat.count_distinct_string_list(Post, :categories, project_id)
}
@@ -132,9 +134,28 @@ defmodule BDS.AI.ChatTools do
project_tools =
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: "list_media", spec: tool_spec("list_media", "List recent media items in the active project", limit_schema())}
%{
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: "list_media",
spec:
tool_spec(
"list_media",
"List recent media items in the active project",
limit_schema()
)
}
]
else
[]
@@ -142,14 +163,62 @@ defmodule BDS.AI.ChatTools do
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_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())}
%{
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_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
[]

View File

@@ -2,7 +2,11 @@ defmodule BDS.AI.HttpClient do
@moduledoc false
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()
:ssl.start()
@@ -24,7 +28,10 @@ defmodule BDS.AI.HttpClient do
def post(url, headers, body)
when is_binary(url) and is_map(headers) and is_binary(body) do
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()
:ssl.start()

View File

@@ -34,31 +34,41 @@ defmodule BDS.AI.Model do
def changeset(model, attrs) do
model
|> cast(attrs, [
|> cast(
attrs,
[
:provider,
:model_id,
:name,
:family,
:supports_attachment,
:supports_reasoning,
:supports_tool_calls,
:supports_structured_output,
:supports_temperature,
:knowledge,
:release_date,
:last_updated_date,
:open_weights,
:input_price,
:output_price,
:cache_read_price,
:cache_write_price,
:context_window,
:max_input_tokens,
:max_output_tokens,
:interleaved,
:status,
:updated_at
], empty_values: [nil])
|> validate_required([
:provider,
:model_id,
:name,
:family,
:supports_attachment,
:supports_reasoning,
:supports_tool_calls,
:supports_structured_output,
:supports_temperature,
:knowledge,
:release_date,
:last_updated_date,
:open_weights,
:input_price,
:output_price,
:cache_read_price,
:cache_write_price,
:context_window,
:max_input_tokens,
:max_output_tokens,
:interleaved,
:status,
:updated_at
], empty_values: [nil])
|> validate_required([:provider, :model_id, :name, :context_window, :max_input_tokens, :max_output_tokens, :updated_at])
])
end
end

View File

@@ -30,12 +30,13 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
}
|> maybe_put_auth(endpoint.api_key)
payload = %{
"model" => request.model,
"messages" => request.messages,
"max_tokens" => request.max_output_tokens
}
|> maybe_put_tools(request.tools)
payload =
%{
"model" => request.model,
"messages" => request.messages,
"max_tokens" => request.max_output_tokens
}
|> maybe_put_tools(request.tools)
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
200 <- response.status do
@@ -55,7 +56,9 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
json =
case content do
nil -> nil
nil ->
nil
value when is_binary(value) ->
case Jason.decode(value) do
{:ok, decoded} when is_map(decoded) -> decoded
@@ -77,10 +80,17 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
defp models_url(url) do
cond do
String.ends_with?(url, "/chat/completions") -> String.replace_suffix(url, "/chat/completions", "/models")
String.ends_with?(url, "/models") -> url
String.ends_with?(url, "/") -> url <> "models"
true -> url <> "/models"
String.ends_with?(url, "/chat/completions") ->
String.replace_suffix(url, "/chat/completions", "/models")
String.ends_with?(url, "/models") ->
url
String.ends_with?(url, "/") ->
url <> "models"
true ->
url <> "/models"
end
end
@@ -114,7 +124,9 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
defp maybe_put_auth(headers, nil), 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, nil), do: payload

View File

@@ -65,7 +65,9 @@ defmodule BDS.AI.Runtime do
end
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
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
with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) 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
{:error, %{kind: :endpoint_not_configured, endpoint: mode}}
else

View File

@@ -17,7 +17,15 @@ defmodule BDS.AI.SecretBackend do
with {:ok, binary} <- Base.decode64(encoded),
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
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}
else
_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
now = Persistence.now_ms()
(%Setting{}
|> Setting.changeset(%{key: key, value: value, updated_at: now}))
%Setting{}
|> Setting.changeset(%{key: key, value: value, updated_at: now})
|> Repo.insert(
on_conflict: [set: [value: value, updated_at: now]],
conflict_target: [:key]

View File

@@ -39,12 +39,18 @@ defmodule BDS.CliSync do
ids = Enum.map(notifications, & &1.id)
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
{:ok,
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
@@ -52,13 +58,17 @@ defmodule BDS.CliSync do
{processed_count, _} =
Repo.delete_all(
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, _} =
Repo.delete_all(
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}}

View File

@@ -15,7 +15,9 @@ defmodule BDS.CliSync.Notification do
def changeset(notification, attrs) do
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])
end
end

View File

@@ -24,7 +24,11 @@ defmodule BDS.CliSync.Watcher do
@impl true
def init(opts) do
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)
}
@@ -49,7 +53,11 @@ defmodule BDS.CliSync.Watcher do
{:ok, _pruned} = CliSync.prune_notifications()
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)
state

View File

@@ -107,7 +107,9 @@ defmodule BDS.Desktop.Automation do
end
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}
end
@@ -204,7 +206,9 @@ defmodule BDS.Desktop.Automation do
receive_driver_message(state, @request_timeout, fn message ->
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} ->
raise "desktop automation request failed: #{reason}"
@@ -242,7 +246,8 @@ defmodule BDS.Desktop.Automation do
defp process_driver_messages(state, deadline, matcher) do
{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
:skip ->
{:cont, {acc, nil}}
@@ -259,7 +264,11 @@ defmodule BDS.Desktop.Automation do
receive do
{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 ->
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
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
{:ok, {{_, 200, _}, _headers, _body}} -> :ok
{:ok, {{_, 200, _}, _headers, _body}} ->
:ok
_other ->
if System.monotonic_time(:millisecond) >= deadline do
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"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]]
socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
plug Plug.Session, @session_options
plug :maybe_require_desktop_auth
plug(Plug.Session, @session_options)
plug(:maybe_require_desktop_auth)
plug Plug.Static,
plug(Plug.Static,
at: "/assets",
from: {:bds, "priv/ui"},
only: ["app.css", "live.js", "monaco"]
)
plug Plug.Static,
plug(Plug.Static,
at: "/vendor/phoenix",
from: {:phoenix, "priv/static"},
only: ["phoenix.min.js"]
)
plug Plug.Static,
plug(Plug.Static,
at: "/vendor/live_view",
from: {:phoenix_live_view, "priv/static"},
only: ["phoenix_live_view.min.js"]
)
plug BDS.Desktop.Router
plug(BDS.Desktop.Router)
defp maybe_require_desktop_auth(conn, _opts) 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()
{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)
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 = [
app: :bds,
@@ -70,7 +72,9 @@ defmodule BDS.Desktop.MainWindow do
frame ->
apply_restored_bounds(frame)
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
@@ -124,9 +128,15 @@ defmodule BDS.Desktop.MainWindow do
defp current_bounds(frame) do
with_wx_env(fn ->
cond do
not :wxWindow.isShown(frame) -> nil
:wxTopLevelWindow.isFullScreen(frame) -> nil
:wxTopLevelWindow.isMaximized(frame) -> nil
not :wxWindow.isShown(frame) ->
nil
:wxTopLevelWindow.isFullScreen(frame) ->
nil
:wxTopLevelWindow.isMaximized(frame) ->
nil
true ->
{x, y} = :wxWindow.getPosition(frame)
{width, height} = :wxWindow.getSize(frame)
@@ -160,7 +170,8 @@ defmodule BDS.Desktop.MainWindow do
end
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}}
end
@@ -180,7 +191,8 @@ defmodule BDS.Desktop.MainWindow do
desktop_config = Application.get_env(:bds, :desktop, [])
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}
_ ->

View File

@@ -24,7 +24,8 @@ defmodule BDS.Desktop.MediaController do
with %{} = project <- Projects.get_active_project(),
%MediaRecord{} = media <- Repo.get(MediaRecord, media_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),
true <- File.exists?(absolute_path) do
{:ok, thumbnail_content_type(relative_path), absolute_path}
@@ -33,7 +34,8 @@ defmodule BDS.Desktop.MediaController do
end
rescue
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__
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(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))
%{overlay | lightbox: lightbox_from_index(images, next_index)}
end
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))
%{overlay | lightbox: lightbox_from_index(images, next_index)}
end

View File

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

View File

@@ -38,7 +38,8 @@ defmodule BDS.Desktop.ShellData do
Projects.shell_snapshot()
rescue
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__
end
@@ -54,7 +55,8 @@ defmodule BDS.Desktop.ShellData do
Dashboard.snapshot(project_id)
rescue
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__
end
@@ -65,7 +67,8 @@ defmodule BDS.Desktop.ShellData do
Sidebar.view(project_id, view_id, params)
rescue
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__
end
@@ -75,7 +78,10 @@ defmodule BDS.Desktop.ShellData do
def assistant_cards do
[
%{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."}
]
end
@@ -117,7 +123,8 @@ defmodule BDS.Desktop.ShellData do
end
rescue
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__
end
@@ -146,17 +153,38 @@ defmodule BDS.Desktop.ShellData do
def activity_icon(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>)
"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>)
"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>)
"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")
"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>)
"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>)
"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>)
"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
@@ -171,7 +199,10 @@ defmodule BDS.Desktop.ShellData do
def dashboard_post_count_label(count) do
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})
end
@@ -188,7 +219,7 @@ defmodule BDS.Desktop.ShellData do
top_items
|> 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)})
end)
|> Enum.sort_by(&String.downcase(to_string(&1.tag || "")))
@@ -199,10 +230,11 @@ defmodule BDS.Desktop.ShellData do
declarations =
if item.color do
declarations ++ [
"background-color: #{item.color};",
"color: #{dashboard_contrast_color(item.color)};"
]
declarations ++
[
"background-color: #{item.color};",
"color: #{dashboard_contrast_color(item.color)};"
]
else
declarations
end
@@ -225,9 +257,17 @@ defmodule BDS.Desktop.ShellData do
def route_label(route) do
case to_string(route) do
"git_log" -> "Git Log"
"post_links" -> "Post Links"
other -> other |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1)
"git_log" ->
"Git Log"
"post_links" ->
"Post Links"
other ->
other
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
end
@@ -255,7 +295,10 @@ defmodule BDS.Desktop.ShellData do
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, 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 default_project_snapshot do

View File

@@ -8,10 +8,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
embed_templates "chat_editor_html/*"
embed_templates("chat_editor_html/*")
# ── Public API: state assignment ───────────────────────────────────────────
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :chat_editor, MessageBuild.build(socket.assigns))
end
@@ -25,6 +26,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Public API: input + surface state ──────────────────────────────────────
@spec update_input(term(), term(), term()) :: term()
def update_input(socket, value, reload) do
%{id: conversation_id} = socket.assigns.current_tab
@@ -36,6 +38,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench)
end
@spec update_surface_form(term(), term(), term(), term()) :: term()
def update_surface_form(socket, surface_id, fields, reload)
when is_binary(surface_id) and is_map(fields) do
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)
end
@spec select_surface_tab(term(), term(), term(), term()) :: term()
def select_surface_tab(socket, surface_id, index, reload)
when is_binary(surface_id) and is_integer(index) and index >= 0 do
socket
@@ -55,10 +59,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench)
end
@spec current_surface_data(term(), term()) :: term()
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
end
@spec set_action_error(term(), term(), term(), term()) :: term()
def set_action_error(socket, conversation_id, message, reload)
when is_binary(conversation_id) and is_binary(message) do
socket
@@ -69,6 +75,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench)
end
@spec clear_action_error(term(), term(), term()) :: term()
def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do
socket
|> assign(
@@ -80,6 +87,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Public API: messaging ──────────────────────────────────────────────────
@spec send_message(term(), term(), term()) :: term()
def send_message(socket, reload, append_output) do
%{id: conversation_id} = socket.assigns.current_tab
message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
@@ -144,6 +152,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end
end
@spec abort_message(term(), term()) :: term()
def abort_message(socket, reload) do
%{id: conversation_id} = socket.assigns.current_tab
@@ -167,6 +176,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end
end
@spec note_tool_call(term(), term(), term(), term()) :: term()
def note_tool_call(socket, conversation_id, tool_call, reload)
when is_binary(conversation_id) and is_map(tool_call) do
update_request(
@@ -189,6 +199,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
)
end
@spec note_tool_result(term(), term(), term(), term()) :: term()
def note_tool_result(socket, conversation_id, name, reload)
when is_binary(conversation_id) and is_binary(name) do
update_request(
@@ -201,6 +212,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
)
end
@spec note_streaming_content(term(), term(), term(), term()) :: term()
def note_streaming_content(socket, conversation_id, content, reload)
when is_binary(conversation_id) and is_binary(content) do
update_request(
@@ -211,6 +223,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
)
end
@spec finish_request(term(), term(), term(), term(), term()) :: term()
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
{nil, _remaining_refs} ->
@@ -245,12 +258,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── HEEx-callable helpers ─────────────────────────────────────────────────
@spec message_role_label(term()) :: term()
def message_role_label(:user), do: translated("chat.role.you")
def message_role_label(_role), do: translated("chat.role.assistant")
defdelegate tool_call_name(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 markdown_html(content) when is_binary(content) do
@@ -264,8 +279,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
raw(html)
end
@spec markdown_html(term()) :: term()
def markdown_html(_content), do: ""
@spec payload_json(term()) :: term()
def payload_json(nil), do: "{}"
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)
end
@spec chart_width(term(), term()) :: term()
def chart_width(_max_value, _value), do: 0
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false
# ── HEEx components ───────────────────────────────────────────────────────
attr :markers, :list, required: true
attr(:markers, :list, required: true)
@spec chat_tool_markers(term()) :: term()
def chat_tool_markers(assigns) do
~H"""
<%= if @markers != [] do %>
@@ -307,8 +327,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
"""
end
attr :surface, :map, required: true
attr(:surface, :map, required: true)
@spec chat_surface(term()) :: term()
def chat_surface(assigns) do
~H"""
<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
)
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)
end)
end
@@ -571,6 +593,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp format_error(reason), do: inspect(reason)
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

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

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
import Phoenix.Component, only: [assign: 3]
@spec toggle_model_selector(term(), term()) :: term()
def toggle_model_selector(socket, reload) do
%{id: conversation_id} = socket.assigns.current_tab
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)
end
@spec set_model(term(), term(), term(), term()) :: term()
def set_model(socket, model_id, reload, append_output) do
%{id: conversation_id} = socket.assigns.current_tab
@@ -37,6 +39,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
end
end
@spec group_available_models(term()) :: term()
def group_available_models(models) when is_list(models) do
models
|> 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)))
end
@spec needs_api_key?(term()) :: term()
def needs_api_key?(true), do: false
def needs_api_key?(false) do

View File

@@ -14,9 +14,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
"render_tabs"
])
@spec render_tool?(term()) :: term()
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
@spec build_render_surfaces(term(), term(), term()) :: term()
def build_render_surfaces(tool_calls, message_id, assigns) do
tool_calls
|> Enum.with_index()
@@ -28,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
end)
end
@spec build_render_surface(term(), term(), term()) :: term()
def build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do
if MapSet.member?(@render_tool_names, name) do
do_build_render_surface(name, arguments || %{}, surface_id, assigns)
@@ -51,6 +55,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
end
end
@spec normalize_tool_surface(term()) :: term()
def normalize_tool_surface(_content), do: nil
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),
input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"),
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", [])),
required?: truthy?(map_value(field, "required", false))
}
@@ -161,8 +171,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
type: "form",
title: map_value(arguments, "title"),
fields: fields,
submit_label: map_value(arguments, "submitLabel") || map_value(arguments, "submit_label", translated("chat.stop")),
submit_action: map_value(arguments, "submitAction") || map_value(arguments, "submit_action", "submitForm")
submit_label:
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
@@ -181,7 +195,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|> List.wrap()
|> Enum.with_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)
@@ -203,11 +221,21 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
type = map_value(content, "type", "text")
case type do
render_type 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)
render_type
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" ->
%{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 ->
%{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
@spec tool_call_name(term()) :: term()
def tool_call_name(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :name) || "tool"
end
@spec tool_call_arguments(term()) :: term()
def tool_call_arguments(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
end
@@ -25,6 +27,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
end)
end
@spec normalize_tool_calls(term()) :: term()
def normalize_tool_calls(_tool_calls), do: []
def tool_arguments_preview(arguments) when is_map(arguments) do
@@ -33,6 +36,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|> Enum.join(", ")
end
@spec tool_arguments_preview(term()) :: term()
def tool_arguments_preview(_arguments), 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
@spec mark_tool_call_completed(term(), term()) :: term()
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(%{tool_events: tool_events}) do

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,7 @@ defmodule BDS.Desktop.ShellLive.Layout do
end
defp maybe_set_sidebar_width(workbench, nil), do: workbench
defp maybe_set_sidebar_width(workbench, 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.UI.Workbench
embed_templates "media_editor_html/*"
embed_templates("media_editor_html/*")
@post_picker_limit 10
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :media_editor, build(socket.assigns))
end
@spec update(term(), term(), term()) :: term()
def update(socket, params, reload) do
case socket.assigns.current_tab do
%{type: :media, id: media_id} ->
@@ -38,6 +40,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec persist_socket(term(), term(), term(), term()) :: term()
def persist_socket(socket, media_id, reload, append_output) do
case Media.get_media(media_id) do
nil ->
@@ -52,9 +55,18 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket
|> assign(:workbench, workbench)
|> assign(:media_editor_drafts, 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)))
|> assign(
:media_editor_drafts,
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)
{:error, reason} ->
@@ -65,14 +77,19 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec toggle_quick_actions(term(), term(), term()) :: term()
def toggle_quick_actions(socket, media_id, reload) do
workbench = socket.assigns.workbench
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)
end
@spec replace_file(term(), term(), term(), term()) :: term()
def replace_file(socket, media_id, reload, append_output) do
case FilePicker.choose_file(translated("Replace Media File")) do
{:ok, source_path} ->
@@ -82,9 +99,18 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket
|> assign(:workbench, workbench)
|> assign(:media_editor_drafts, 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)))
|> assign(
:media_editor_drafts,
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)
{:ok, nil} ->
@@ -106,10 +132,16 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec detect_language(term(), term(), term(), term()) :: term()
def detect_language(socket, media_id, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do
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)
else
case Media.get_media(media_id) do
@@ -118,15 +150,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
%MediaRecord{} = 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
{: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)
case Media.update_media(media.id, %{language: normalized}) do
{: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
|> reconcile_draft(updated_media, updated_draft)
@@ -145,17 +188,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
_other ->
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)
end
end
end
end
@spec translate(term(), term(), term(), term(), term()) :: term()
def translate(socket, media_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do
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)
else
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
{:ok, _saved_translation} ->
socket
|> assign(: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))
|> assign(
: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)
{:error, reason} ->
@@ -183,6 +243,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term()
def apply_ai_suggestions(socket, media_id, fields, reload, append_output) do
try do
case Media.get_media(media_id) do
@@ -213,6 +274,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec delete_socket(term(), term(), term(), term()) :: term()
def delete_socket(socket, media_id, reload, append_output) do
case Media.delete_media(media_id) do
{:ok, :deleted} ->
@@ -223,11 +285,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|> assign(:shell_overlay, nil)
|> 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_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, 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))
|> assign(
:media_editor_quick_actions_open,
Map.delete(socket.assigns.media_editor_quick_actions_open, 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)
{:error, reason} ->
@@ -237,28 +314,43 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec toggle_post_picker(term(), term(), term()) :: term()
def toggle_post_picker(socket, media_id, reload) do
workbench = socket.assigns.workbench
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)
end
@spec set_post_picker_query(term(), term(), term(), term()) :: term()
def set_post_picker_query(socket, media_id, query, reload) do
workbench = socket.assigns.workbench
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)
end
@spec link_post(term(), term(), term(), term(), term()) :: term()
def link_post(socket, media_id, post_id, reload, append_output) do
case Media.link_media_to_post(media_id, post_id) do
{:ok, _linked} ->
socket
|> assign(: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, ""))
|> assign(
: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)
{:error, reason} ->
@@ -268,6 +360,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec unlink_post(term(), term(), term(), term(), term()) :: term()
def unlink_post(socket, media_id, post_id, reload, append_output) do
case Media.unlink_media_from_post(media_id, post_id) do
{:ok, _unlinked} ->
@@ -280,6 +373,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec edit_translation(term(), term(), term(), term()) :: term()
def edit_translation(socket, media_id, language, reload) do
workbench = socket.assigns.workbench
@@ -287,16 +381,20 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
form = %{
"language" => language,
"title" => translation && translation.title || "",
"alt" => translation && translation.alt || "",
"caption" => translation && translation.caption || ""
"title" => (translation && translation.title) || "",
"alt" => (translation && translation.alt) || "",
"caption" => (translation && translation.caption) || ""
}
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)
end
@spec update_translation(term(), term(), term(), term()) :: term()
def update_translation(socket, media_id, params, reload) do
workbench = socket.assigns.workbench
@@ -308,10 +406,14 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
}
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)
end
@spec save_translation(term(), term(), term(), term()) :: term()
def save_translation(socket, media_id, reload, append_output) do
case Map.get(socket.assigns.media_editor_translation_forms, media_id) do
%{"language" => language} = form when language not in [nil, ""] ->
@@ -322,7 +424,10 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
}) do
{:ok, _translation} ->
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)
{:error, reason} ->
@@ -336,16 +441,23 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec refresh_translation(term(), term(), term(), term(), term()) :: term()
def refresh_translation(socket, media_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do
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)
else
case AI.translate_media(media_id, normalize_language(language)) do
{:ok, translation} ->
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} ->
socket
@@ -361,11 +473,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec delete_translation(term(), term(), term(), term(), term()) :: term()
def delete_translation(socket, media_id, language, reload, append_output) do
case Media.delete_media_translation(media_id, language) do
{:ok, _deleted?} ->
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)
{:error, reason} ->
@@ -375,6 +491,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :media, id: media_id}} = assigns) do
case Media.get_media(media_id) do
nil ->
@@ -385,7 +502,9 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
translations = Media.list_media_translations(media.id)
form = current_draft(assigns, media)
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,
@@ -416,20 +535,26 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
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(:saved), do: translated("Saved")
def media_editor_save_state_label(_state), do: translated("Idle")
@spec language_label(term()) :: term()
def language_label(code) do
code
|> to_string()
|> String.upcase()
end
@spec normalize_language(term()) :: term()
def normalize_language(value), do: value |> to_string() |> String.trim() |> String.downcase()
@spec persist(term(), term()) :: term()
def persist(%MediaRecord{} = media, draft) do
Media.update_media(media.id, %{
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
persisted = persisted_form(media)
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 =
if dirty? do
@@ -456,8 +585,21 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
socket
|> assign(:workbench, workbench)
|> 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(: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 || ""}))
|> assign(
: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
defp current_draft(assigns, %MediaRecord{} = media) do
@@ -505,10 +647,15 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
from post in Post,
where: post.project_id == ^media.project_id,
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.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)}
end
@@ -518,18 +665,28 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end
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
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 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), do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
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),
do: :erlang.float_to_binary(size / 1024, decimals: 1) <> " KB"
defp format_file_size(_size), do: "0.0 KB"
defp detect_language_enabled?(form) do
@@ -567,5 +724,6 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
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

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
use Phoenix.Component
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.MenuEditor.{
DraftManagement,
PageCategory,
@@ -12,8 +13,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
TreePredicates
}
embed_templates "menu_editor_html/*"
embed_templates("menu_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
case socket.assigns[:current_tab] do
%{type: :menu_editor, id: tab_id} ->
@@ -36,12 +38,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec select_item(term(), term(), term()) :: term()
def select_item(socket, item_id, reload) do
socket
|> State.update_state(fn state -> %{state | selected_id: item_id} end)
|> reload.(socket.assigns.workbench)
end
@spec change_entry(term(), term(), term()) :: term()
def change_entry(socket, params, reload) do
query = Map.get(params, "query", "")
@@ -50,6 +54,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|> reload.(socket.assigns.workbench)
end
@spec submit_entry(term(), term()) :: term()
def submit_entry(socket, reload) do
case DraftManagement.current_draft(socket.assigns) do
%{type: :page} ->
@@ -67,12 +72,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec cancel_entry(term(), term()) :: term()
def cancel_entry(socket, reload) do
socket
|> State.update_state(&DraftManagement.cancel_draft/1)
|> reload.(socket.assigns.workbench)
end
@spec select_page(term(), term(), term()) :: term()
def select_page(socket, post_id, reload) do
case PageCategory.page_post(socket.assigns.projects.active_project_id, post_id) do
nil ->
@@ -85,6 +92,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec select_category(term(), term(), term()) :: term()
def select_category(socket, name, reload) do
project_id = socket.assigns.projects.active_project_id
@@ -99,6 +107,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec toolbar_action(term(), term(), term(), term()) :: term()
def toolbar_action(socket, action, reload, append_output) do
case action do
"add-entry" ->
@@ -144,12 +153,14 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec drop_item(term(), term(), term(), term(), term()) :: term()
def drop_item(socket, drag_item_id, target_item_id, position, reload) do
socket
|> State.update_state(&TreeOps.drop_selected(&1, drag_item_id, target_item_id, position))
|> reload.(socket.assigns.workbench)
end
@spec handle_keydown(term(), term(), term()) :: term()
def handle_keydown(socket, "Escape", reload) do
cancel_entry(socket, reload)
end
@@ -158,14 +169,16 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
reload.(socket, socket.assigns.workbench)
end
attr :menu_editor, :map, required: true
attr(:menu_editor, :map, required: true)
@spec menu_editor(term()) :: term()
def menu_editor(assigns)
attr :items, :list, required: true
attr :menu_editor, :map, required: true
attr :depth, :integer, required: true
attr(:items, :list, required: true)
attr(:menu_editor, :map, required: true)
attr(:depth, :integer, required: true)
@spec menu_tree_level(term()) :: term()
def menu_tree_level(assigns) do
~H"""
<%= for item <- @items do %>
@@ -289,8 +302,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
"""
end
attr :kind, :atom, required: true
attr(:kind, :atom, required: true)
@spec kind_icon(term()) :: term()
def kind_icon(assigns) do
~H"""
<%= case @kind do %>
@@ -306,9 +320,11 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
"""
end
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
@spec row_label(term(), term()) :: term()
def row_label(item, category_titles) do
if item.kind == :category_archive do
Map.get(category_titles || %{}, item.slug, item.label)
@@ -317,6 +333,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
end
end
@spec kind_label(term()) :: term()
def kind_label(:home), do: translated("menuEditor.type.home")
def kind_label(:page), do: translated("menuEditor.type.page")
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
@spec editing_title(term()) :: term()
def editing_title(%{draft: %{type: :category}}), do: translated("menuEditor.addCategoryArchive")
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(_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")
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.TreeOps
@spec current_draft(term()) :: term()
def current_draft(assigns), do: Map.get(assigns.menu_editor_state || %{}, :draft)
@spec start_page_draft(term()) :: term()
def start_page_draft(state) do
item = %{
item_id: Ecto.UUID.generate(),
@@ -29,6 +31,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
}
end
@spec start_category_draft(term()) :: term()
def start_category_draft(state) do
item = %{
item_id: Ecto.UUID.generate(),
@@ -50,6 +53,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
}
end
@spec finalize_submenu_draft(term()) :: term()
def finalize_submenu_draft(%{draft: %{item_id: item_id, query: query}} = state) do
label =
if(String.trim(query) == "",
@@ -69,12 +73,19 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
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
%{
state
| items:
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),
draft: nil
}
@@ -82,6 +93,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
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
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
@spec cancel_draft(term()) :: term()
def cancel_draft(%{draft: %{item_id: item_id}} = state) do
items = TreeOps.remove_item(state.items, item_id)
%{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
@spec confirm_category_draft(term(), term()) :: term()
def confirm_category_draft(socket, update_state_fun) do
project_id = socket.assigns.projects.active_project_id
draft = current_draft(socket.assigns)
@@ -117,8 +131,12 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.DraftManagement do
category =
cond do
category != nil -> category
normalized == "" -> %{name: "", title: ""}
category != nil ->
category
normalized == "" ->
%{name: "", title: ""}
true ->
{:ok, _metadata} = Metadata.add_category(project_id, normalized)
%{name: normalized, title: normalized}

View File

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

View File

@@ -7,6 +7,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
alias BDS.Menu
alias BDS.Desktop.ShellLive.MenuEditor.{PageCategory, TreeOps, TreePredicates}
@spec ensure_state(term()) :: term()
def ensure_state(assigns) do
project_id = assigns.projects.active_project_id
@@ -16,11 +17,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
end
end
@spec update_state(term(), term()) :: term()
def update_state(socket, updater) do
state = ensure_state(socket.assigns)
assign(socket, :menu_editor_state, updater.(state))
end
@spec build(term(), term()) :: term()
def build(_assigns, state) do
categories = PageCategory.category_options(state.project_id)
draft = state.draft
@@ -35,7 +38,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
draft_query: draft_query,
filtered_pages:
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: []
),
filtered_categories:
@@ -53,6 +57,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.State do
}
end
@spec save(term(), term(), term()) :: term()
def save(socket, reload, append_output) do
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))
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)
end
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
defp load_state(project_id) do

View File

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

View File

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

View File

@@ -20,10 +20,12 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
:git_diff
]
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :misc_editor, build(socket.assigns))
end
@spec rerun(term()) :: term()
def rerun(socket) do
case meta(socket.assigns) do
%{action: action} when is_binary(action) ->
@@ -37,6 +39,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec apply_site_validation(term(), term()) :: term()
def apply_site_validation(socket, append_output) do
meta = meta(socket.assigns)
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")}
end
@spec toggle_duplicate(term(), term(), term()) :: term()
def toggle_duplicate(socket, pair_id, reload) do
selected_by_tab = Map.get(socket.assigns, :misc_editor_selected_pairs, %{})
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)
end
@spec dismiss_duplicate(term(), term(), term(), term(), term()) :: term()
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
{:ok, _saved_pair} ->
@@ -109,6 +114,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec dismiss_selected(term(), term(), term()) :: term()
def dismiss_selected(socket, reload, append_output) do
tab_id = socket.assigns.current_tab.id
@@ -141,6 +147,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec fix_translation_validation(term(), term()) :: term()
def fix_translation_validation(socket, append_output) do
report =
socket.assigns
@@ -166,6 +173,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
append_output.(socket, translated("Translation Validation"), inspect(error), nil, "error")}
end
@spec select_git_diff_file(term(), term()) :: term()
def select_git_diff_file(socket, file_path) do
assign(
socket,
@@ -178,6 +186,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
)
end
@spec metadata_diff_repair_request(term(), term(), term()) :: term()
def metadata_diff_repair_request(socket, field, direction) do
meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{})
@@ -209,6 +218,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec metadata_diff_orphan_import_request(term()) :: term()
def metadata_diff_orphan_import_request(socket) do
meta = meta(socket.assigns)
payload = Map.get(meta, :payload, %{})
@@ -232,6 +242,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec build(term()) :: term()
def build(%{current_tab: %{type: type}} = assigns) when type in @misc_routes do
meta = meta(assigns)
payload = Map.get(meta, :payload, %{})
@@ -245,11 +256,14 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec build(term()) :: term()
def build(_assigns), do: nil
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
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(:metadata_diff), do: "metadata-diff-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 summary_items(%{summary: summary}) when is_map(summary), do: Enum.to_list(summary)
@spec summary_items(term()) :: term()
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)
@spec pair_id_from_pair(term()) :: term()
def pair_id_from_pair(pair), do: pair_identity(pair)
defp build_site_validation(meta, payload) do
@@ -410,6 +427,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
}
end
@spec translation_issue_label(term()) :: term()
def translation_issue_label(issue) do
case issue_value(issue, :issue) do
"same-language-as-canonical" ->
@@ -426,6 +444,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec translation_issue_languages(term()) :: term()
def translation_issue_languages(issue) do
canonical_language = issue_value(issue, :canonical_language)
translation_language = issue_value(issue, :translation_language)
@@ -440,8 +459,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
end
end
@spec translation_issue_value(term(), term()) :: term()
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(file_path) do

View File

@@ -12,7 +12,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
alias BDS.Posts.{Post, PostMedia, Translation}
alias BDS.Tags.Tag
embed_templates "overlay_html/*"
embed_templates("overlay_html/*")
def context(assigns, tab_title, tab_subtitle) do
project_id = assigns.projects.active_project_id
@@ -23,7 +23,12 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
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_media_language: source_language(current_tab, metadata),
posts: posts,
@@ -59,7 +64,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
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: []}
@@ -77,7 +83,15 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
from post in Post,
where: post.project_id == ^project_id,
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 ->
%{
@@ -96,7 +110,14 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
from media in MediaRecord,
where: media.project_id == ^project_id,
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 ->
%{
@@ -149,7 +170,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
defp existing_translations(_tab), 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.uniq()
end
@@ -193,9 +215,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
case Posts.get_post(post_id) do
%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: "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}
%{
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: "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 ->
@@ -209,9 +249,27 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
case Media.get_media(media_id) do
%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: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", suggested_value: refine_excerpt(title, media.caption || title), locked: false}
%{
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: "caption",
label: ShellData.translate("Caption", %{}, page_language),
current_value: media.caption || "",
suggested_value: refine_excerpt(title, media.caption || title),
locked: false
}
]
_other ->
@@ -248,7 +306,13 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: reference_list
}
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
defp delete_details(%{type: :tags}, page_language) do
@@ -263,16 +327,33 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
reference_list: []
}
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
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
defp merge_details(project_id, page_language) do
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"
@@ -283,7 +364,13 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
message: ShellData.translate("Cannot be undone.", %{}, page_language)
}
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
defp canonical_post_url(post) do
@@ -302,7 +389,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
if base == "", do: "#{title} overview", else: base <> "."
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
value

View File

@@ -210,8 +210,15 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
defp related_posts(links, key) do
Enum.map(links, fn link ->
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}
_other -> nil
%Post{} = post ->
%{
id: post.id,
title: post.title || post.slug || post.id,
text: link.link_text || post.slug || post.id
}
_other ->
nil
end
end)
|> 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
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}
_other -> nil
%Post{project_id: project_id, file_path: file_path} when file_path not in [nil, ""] ->
{project_id, file_path}
_other ->
nil
end
end
defp git_history_target(%{type: :media, id: 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}
_other -> nil
%MediaRecord{project_id: project_id, file_path: file_path}
when file_path not in [nil, ""] ->
{project_id, file_path}
_other ->
nil
end
end
@@ -287,5 +301,6 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
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

View File

@@ -74,13 +74,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
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
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))
end
@spec update(term(), term(), term()) :: term()
def update(socket, params, reload) do
case socket.assigns.current_tab do
%{type: :post, id: post_id} ->
@@ -91,7 +99,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
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)
next_language =
@@ -117,6 +128,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec persist_socket(term(), term(), term(), term(), term()) :: term()
def persist_socket(socket, post_id, action, reload, append_output) do
case Posts.get_post(post_id) do
nil ->
@@ -125,7 +137,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
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)
case persist(post, draft, active_language, metadata, action) do
@@ -135,9 +150,30 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket
|> assign(:workbench, workbench)
|> assign(:post_editor_drafts, 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))}))
|> assign(
:post_editor_drafts,
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)
{:error, reason} ->
@@ -148,6 +184,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec discard_socket(term(), term(), term(), term()) :: term()
def discard_socket(socket, post_id, reload, append_output) do
case Posts.get_post(post_id) do
nil ->
@@ -156,7 +193,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
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
{:ok, restored_post} ->
@@ -164,9 +203,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket
|> assign(:workbench, workbench)
|> assign(:post_editor_drafts, 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)}))
|> assign(
:post_editor_drafts,
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)
{:error, reason} ->
@@ -177,6 +228,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec delete_socket(term(), term(), term(), term()) :: term()
def delete_socket(socket, post_id, reload, append_output) do
case Posts.delete_post(post_id) do
{:ok, :deleted} ->
@@ -185,13 +237,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
socket
|> 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_active_languages, Map.delete(socket.assigns.post_editor_active_languages, 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_active_languages,
Map.delete(socket.assigns.post_editor_active_languages, 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_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)
{:error, reason} ->
@@ -201,6 +268,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec set_mode(term(), term(), term(), term()) :: term()
def set_mode(socket, post_id, mode, reload) do
workbench = socket.assigns.workbench
normalized_mode = normalize_mode(mode)
@@ -216,38 +284,67 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
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)
end
@spec toggle_section(term(), term(), term(), term()) :: term()
def toggle_section(socket, post_id, section, reload) when section in [:metadata, :excerpt] do
workbench = socket.assigns.workbench
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)
end
@spec select_language(term(), term(), term(), term()) :: term()
def select_language(socket, post_id, language, reload) do
workbench = socket.assigns.workbench
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)
end
@spec toggle_quick_actions(term(), term(), term()) :: term()
def toggle_quick_actions(socket, post_id, reload) do
workbench = socket.assigns.workbench
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)
end
@spec detect_language(term(), term(), term(), term()) :: term()
def detect_language(socket, post_id, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do
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)
else
case Posts.get_post(post_id) do
@@ -257,14 +354,24 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
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)
text = Enum.join([Map.get(draft, "title", ""), Map.get(draft, "content", "")], "\n\n")
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
|> 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)
{:error, reason} ->
@@ -274,17 +381,28 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
_other ->
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)
end
end
end
end
@spec translate(term(), term(), term(), term(), term()) :: term()
def translate(socket, post_id, language, reload, append_output) do
if Map.get(socket.assigns, :offline_mode, true) do
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)
else
normalized_language = normalize_language(language, "")
@@ -298,9 +416,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
content: translation.content
}) do
socket
|> assign(:post_editor_active_languages, 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))
|> assign(
:post_editor_active_languages,
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)
else
{:error, reason} ->
@@ -317,6 +444,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec apply_ai_suggestions(term(), term(), term(), term(), term()) :: term()
def apply_ai_suggestions(socket, post_id, fields, reload, append_output) do
case Posts.get_post(post_id) do
nil ->
@@ -340,12 +468,30 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
case Posts.update_post(post_id, attrs) do
{:ok, updated_post} ->
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)
socket
|> assign(: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(
: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)
|> reload.(socket.assigns.workbench)
@@ -358,6 +504,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec insert_content(term(), term(), term(), term()) :: term()
def insert_content(socket, post_id, snippet, reload) do
socket
|> 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)
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
case Posts.get_post(post_id) do
nil ->
@@ -373,7 +521,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
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)
normalized = normalize_list_entry(value)
@@ -398,6 +549,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
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
case Posts.get_post(post_id) do
nil ->
@@ -406,9 +558,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = project_metadata(post.project_id)
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)
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
|> put_draft_field(post_id, post, active_language, field_key(kind), updated)
@@ -416,6 +577,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end
end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :post, id: post_id}} = assigns) do
case Posts.get_post(post_id) do
nil ->
@@ -424,7 +586,10 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
%Post{} = post ->
metadata = assigned_project_metadata(assigns)
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)
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),
excerpt_expanded: Map.get(expanded, :excerpt, false),
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_delete?: post.status == :published,
has_published_version?: has_published_version?(post),
discard_label: discard_label(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)),
languages: languages(metadata),
form: form,
@@ -469,16 +636,45 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
tag_values: tag_values(form),
tag_chips: tag_chips(form, Tags.list_tags(post.project_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_query: query_value(assigns, :categories, post.id),
category_options: metadata.categories || [],
category_query_addable?: query_addable?(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)),
category_query_addable?:
query_addable?(
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),
preview_url: 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),
preview_url:
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),
post_links: post_links(post.id),
footer: footer(post, current_translation, active_language, canonical_language)
@@ -488,17 +684,21 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
def build(_assigns), do: nil
@spec post_status_label(term()) :: term()
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(:saved), do: translated("Saved")
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(_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(:preview), do: translated("Preview")
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
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.UI.Workbench
@spec normalize_mode(term()) :: term()
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("preview"), do: :preview
def normalize_mode(_mode), do: :markdown
@spec normalize_language(term(), term()) :: term()
def normalize_language(value, fallback) do
case value |> to_string() |> String.trim() do
"" -> fallback
@@ -20,6 +23,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
end
end
@spec normalize_params(term(), term(), term()) :: term()
def normalize_params(params, current_language, next_language) do
%{
"title" => Map.get(params, "title", ""),
@@ -28,12 +32,17 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
"tags" => Map.get(params, "tags", ""),
"categories" => Map.get(params, "categories", ""),
"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")),
"template_slug" => Map.get(params, "template_slug", "")
}
end
@spec current_draft(term(), term(), term(), term()) :: term()
def current_draft(assigns, %Post{} = post, metadata, active_language) do
persisted = persisted_form(post, metadata, active_language)
@@ -42,10 +51,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
|> Map.get(active_language, persisted)
end
@spec persisted_form(term(), term(), term()) :: term()
def persisted_form(%Post{} = post, metadata, active_language) do
persisted_form(post, metadata, active_language, PostMetadata.translations(post.id))
end
@spec persisted_form(term(), term(), term(), term()) :: term()
def persisted_form(post, metadata, active_language, translations) do
canonical_language = PostMetadata.canonical_language(post, metadata)
translation = Map.get(translations, active_language)
@@ -64,8 +75,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
}
else
%{
"title" => translation && translation.title || "",
"excerpt" => translation && translation.excerpt || "",
"title" => (translation && translation.title) || "",
"excerpt" => (translation && translation.excerpt) || "",
"content" => if(translation, do: Posts.editor_body(translation), else: ""),
"tags" => Enum.join(post.tags || [], ", "),
"categories" => Enum.join(post.categories || [], ", "),
@@ -77,22 +88,43 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
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
workbench = Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
socket
|> assign(:workbench, workbench)
|> assign(:post_editor_drafts, put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, 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)}))
|> assign(
:post_editor_drafts,
put_nested_map(socket.assigns.post_editor_drafts, post_id, next_language, 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)
end
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
@spec put_draft_field(term(), term(), term(), term(), term(), term()) :: term()
def put_draft_field(socket, post_id, post, active_language, field, value) do
metadata = PostMetadata.project_metadata(post.project_id)
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
|> assign(:workbench, workbench)
|> assign(: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))
|> assign(
: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
@spec put_query_state(term(), term(), term(), term()) :: term()
def put_query_state(socket, post_id, kind, value) do
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
@spec query_value(term(), term(), term()) :: term()
def query_value(assigns, kind, post_id) do
assigns
|> 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(:categories), do: :post_editor_category_queries
defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language) when current_language == next_language,
do: socket
defp maybe_drop_old_language_draft(socket, _post_id, current_language, next_language)
when current_language == next_language,
do: socket
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
@spec toggled_sections(term(), term(), term()) :: term()
def toggled_sections(expanded_by_post, post_id, section) do
expanded_by_post
|> Map.get(post_id, %{metadata: false, excerpt: false})
|> Map.put_new(:metadata, false)
|> Map.put_new(:excerpt, false)
|> Map.update!(section, &not &1)
|> Map.update!(section, &(not &1))
end
@spec put_nested_map(term(), term(), term(), term()) :: term()
def put_nested_map(map, key, nested_key, value) do
Map.update(map, key, %{nested_key => value}, &Map.put(&1, nested_key, value))
end
@spec delete_nested_map(term(), term(), term()) :: term()
def delete_nested_map(map, key, nested_key) do
case Map.get(map, key) do
nil ->
@@ -150,20 +203,26 @@ defmodule BDS.Desktop.ShellLive.PostEditor.DraftManagement do
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(_action), do: :saved
@spec record_title(term(), term()) :: term()
def record_title(%Translation{title: title}, post),
do: blank_to_nil(title) || post.title || post.slug || post.id
def record_title(%Post{title: title, slug: slug, id: id}, _post),
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(%Post{status: status}), do: status || :draft
@spec editing_canonical_language?(term(), term(), term()) :: term()
def editing_canonical_language?(translations, active_language, canonical_language) do
active_language == canonical_language or not Map.has_key?(translations, active_language)
end

View File

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

View File

@@ -6,11 +6,16 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
alias BDS.Desktop.ShellData
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
canonical_language = PostMetadata.canonical_language(post, metadata)
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
|> save_canonical_draft(draft)
|> maybe_publish_post(post.id, action)
@@ -21,12 +26,17 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
end
end
@spec discard(term(), term(), term()) :: term()
def discard(%Post{} = post, active_language, metadata) do
canonical_language = PostMetadata.canonical_language(post, metadata)
current_translations = PostMetadata.translations(post.id)
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}
post.file_path not in [nil, ""] and post.status == :draft ->
@@ -37,15 +47,18 @@ defmodule BDS.Desktop.ShellLive.PostEditor.Persistence do
end
end
@spec has_published_version?(term()) :: term()
def has_published_version?(%Post{} = post),
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
if has_published_version?(post),
do: translated("Discard Changes"),
else: translated("Discard Draft")
end
@spec discard_title(term()) :: term()
def discard_title(%Post{} = post) do
if has_published_version?(post),
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.Posts.{Post, PostMedia}
@spec project_metadata(term()) :: term()
def project_metadata(nil), do: %{main_language: "en", blog_languages: []}
def project_metadata(project_id) do
@@ -17,6 +18,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
_error -> %{main_language: "en", blog_languages: []}
end
@spec canonical_language(term(), term()) :: term()
def canonical_language(post, metadata) do
BDS.Desktop.ShellLive.PostEditor.DraftManagement.normalize_language(
post.language,
@@ -24,28 +26,36 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
)
end
@spec translations(term()) :: term()
def translations(post_id) do
{:ok, translations} = Posts.list_post_translations(post_id)
Map.new(translations, fn translation -> {translation.language, translation} end)
end
@spec languages(term()) :: term()
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.uniq()
end
@spec template_options(term()) :: term()
def template_options(project_id) do
Repo.all(
from template in Templates.Template,
where: template.project_id == ^project_id,
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
_error -> []
end
@spec linked_media(term()) :: term()
def linked_media(post_id) do
rows =
Repo.all(
@@ -74,6 +84,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
_error -> []
end
@spec post_links(term()) :: term()
def post_links(post_id) do
%{
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
Enum.map(links, fn link ->
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}
_other -> nil
%Post{} = post ->
%{
id: post.id,
title: post.title || post.slug || post.id,
text: link.link_text || post.slug || post.id
}
_other ->
nil
end
end)
|> Enum.reject(&is_nil/1)
end
@spec translation_flags(term(), term(), term(), term()) :: term()
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 =
translations
@@ -111,6 +136,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
[canonical | others]
end
@spec footer(term(), term(), term(), term()) :: term()
def footer(post, translation, active_language, canonical_language) do
if active_language == canonical_language do
%{
@@ -120,8 +146,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
}
else
%{
created_at: format_timestamp(translation && translation.created_at || post.created_at),
updated_at: format_timestamp(translation && translation.updated_at || post.updated_at),
created_at: format_timestamp((translation && translation.created_at) || post.created_at),
updated_at: format_timestamp((translation && translation.updated_at) || post.updated_at),
published_at: format_timestamp(translation && translation.published_at)
}
end
@@ -135,10 +161,12 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> Calendar.strftime("%x")
end
@spec display_title(term(), term(), term()) :: term()
def display_title(title, slug, fallback_id) do
blank_to_nil(title) || blank_to_nil(slug) || fallback_id || translated("Untitled")
end
@spec gallery_count(term()) :: term()
def gallery_count(form) do
form
|> Map.get("content", "")
@@ -147,8 +175,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> length()
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
query =
%{}
@@ -156,7 +187,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|> maybe_put_query("post_id", post.id)
|> 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
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)
def truthy?(value) when value in [true, "true", "on", 1, "1"], do: true
@spec truthy?(term()) :: term()
def truthy?(_value), do: false
@spec blank?(term()) :: term()
def blank?(value), do: blank_to_nil(value) == nil
@spec blank_to_nil(term()) :: term()
def blank_to_nil(value) do
value
|> to_string()

View File

@@ -19,7 +19,9 @@ defmodule BDS.Desktop.ShellLive.SessionUtil do
Stream.iterate(1, &(&1 + 1))
|> Enum.find_value(fn index ->
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
end)

View File

@@ -17,7 +17,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
alias BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings
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)
@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 protected_category?(category), to: ManagedCategories
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
case socket.assigns[:current_tab] do
%{type: :settings} ->
@@ -64,12 +65,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
end
end
@spec update_search(term(), term(), term()) :: term()
def update_search(socket, query, reload) do
socket
|> assign(:settings_editor_search, to_string(query || ""))
|> reload.(socket.assigns.workbench)
end
@spec build_settings(term()) :: term()
def build_settings(%{projects: %{active_project_id: nil}}), do: nil
def build_settings(assigns) do
@@ -82,7 +85,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
)
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 =
Map.merge(AISettings.ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{}))
@@ -142,6 +148,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
}
end
@spec translated(term(), term()) :: term()
def translated(text, bindings \\ %{}),
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 ->
case section do
"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" ->
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))
"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

View File

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

View File

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

View File

@@ -14,10 +14,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
"page" => %{title: "page", render_in_lists: false, show_title: true}
}
@spec protected_categories() :: term()
def protected_categories, do: @protected_categories
@spec protected_category?(term()) :: term()
def protected_category?(category), do: MapSet.member?(@protected_categories, category)
@spec category_rows(term()) :: term()
def category_rows(metadata) do
categories = Map.get(metadata, :categories, [])
settings = Map.get(metadata, :category_settings, %{})
@@ -37,12 +40,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end)
end
@spec update_new_category(term(), term(), term()) :: term()
def update_new_category(socket, name, reload) do
socket
|> assign(:settings_editor_new_category, to_string(name || ""))
|> reload.(socket.assigns.workbench)
end
@spec add_category(term(), term(), term()) :: term()
def add_category(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
name = socket.assigns[:settings_editor_new_category] |> to_string() |> String.trim()
@@ -73,11 +78,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end
end
@spec reset_categories(term(), term(), term()) :: term()
def reset_categories(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
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
{:cont, :ok}
else
@@ -102,6 +109,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end
end
@spec save_category(term(), term(), term(), term()) :: term()
def save_category(socket, params, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
category = Map.get(params, "category", "")
@@ -125,6 +133,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end
end
@spec remove_category(term(), term(), term(), term()) :: term()
def remove_category(socket, category, reload, append_output) do
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}
]
@spec mcp_rows() :: term()
def mcp_rows do
Enum.map(@mcp_agents, fn agent ->
%{
@@ -28,6 +29,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.MCPConfig do
end)
end
@spec toggle_mcp_agent(term(), term(), term(), term()) :: term()
def toggle_mcp_agent(socket, agent, reload, append_output) do
case find_mcp_agent(agent) do
%{id: agent_id, supported?: true} = config ->

View File

@@ -6,12 +6,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
alias BDS.Metadata
alias BDS.Desktop.ShellData
@spec project_metadata(term()) :: term()
def project_metadata(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} -> metadata
end
end
@spec project_form(term()) :: term()
def project_form(metadata) do
%{
"name" => Map.get(metadata, :name, ""),
@@ -28,18 +30,21 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
}
end
@spec technology_form(term()) :: term()
def technology_form(project_form) do
%{
"semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false)
}
end
@spec update_project_draft(term(), term(), term()) :: term()
def update_project_draft(socket, params, reload) do
socket
|> assign(:settings_editor_project_draft, normalize_project_params(params))
|> reload.(socket.assigns.workbench)
end
@spec save_project(term(), term(), term()) :: term()
def save_project(socket, reload, append_output) do
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.Desktop.ShellData
@spec publishing_form(term()) :: term()
def publishing_form(metadata) do
prefs = Map.get(metadata, :publishing_preferences, %{})
@@ -17,12 +18,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
}
end
@spec update_publishing_draft(term(), term(), term()) :: term()
def update_publishing_draft(socket, params, reload) do
socket
|> assign(:settings_editor_publishing_draft, normalize_publishing_params(params))
|> reload.(socket.assigns.workbench)
end
@spec save_publishing(term(), term(), term()) :: term()
def save_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
@@ -39,6 +42,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
end
end
@spec clear_publishing(term(), term(), term()) :: term()
def clear_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id

View File

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

View File

@@ -22,7 +22,13 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
end
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} ->
callbacks.reload.(socket, socket.assigns.workbench)
@@ -42,7 +48,12 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:error, reason} ->
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)
end
@@ -68,13 +79,23 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:ok, script} ->
callbacks.open_sidebar.(
socket,
%{"route" => "scripts", "id" => script.id, "title" => script.title, "subtitle" => "Automation helpers"},
%{
"route" => "scripts",
"id" => script.id,
"title" => script.title,
"subtitle" => "Automation helpers"
},
:pin
)
{:error, reason} ->
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)
end
end
@@ -90,29 +111,52 @@ defmodule BDS.Desktop.ShellLive.SidebarCreate do
{:ok, template} ->
callbacks.open_sidebar.(
socket,
%{"route" => "templates", "id" => template.id, "title" => template.title, "subtitle" => "Site rendering"},
%{
"route" => "templates",
"id" => template.id,
"title" => template.title,
"subtitle" => "Site rendering"
},
:pin
)
{:error, reason} ->
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)
end
end
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} ->
callbacks.open_sidebar.(
socket,
%{"route" => "import", "id" => definition.id, "title" => definition.name, "subtitle" => "Import definitions"},
%{
"route" => "import",
"id" => definition.id,
"title" => definition.name,
"subtitle" => "Import definitions"
},
:pin
)
{:error, reason} ->
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)
end
end

View File

@@ -7,13 +7,17 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
if is_map(filters) and Map.get(filters, :enabled) do
panel_state = filter_panel_state(socket, view_id)
Map.put(sidebar_data, :filters, Map.merge(filters, %{
filter_panel_visible: panel_state.visible,
archive_collapsed: panel_state.archive_collapsed,
tags_collapsed: panel_state.tags_collapsed,
categories_collapsed: panel_state.categories_collapsed,
expanded_year: panel_state.expanded_year
}))
Map.put(
sidebar_data,
:filters,
Map.merge(filters, %{
filter_panel_visible: panel_state.visible,
archive_collapsed: panel_state.archive_collapsed,
tags_collapsed: panel_state.tags_collapsed,
categories_collapsed: panel_state.categories_collapsed,
expanded_year: panel_state.expanded_year
})
)
else
sidebar_data
end
@@ -22,7 +26,12 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
def put_filter_panel_state(socket, updater) do
view_id = Atom.to_string(socket.assigns.workbench.active_view)
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
def current_filters(socket, view_id) do
@@ -33,8 +42,17 @@ defmodule BDS.Desktop.ShellLive.SidebarState do
def put_filters(socket, updater) do
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
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.Templates.Template
embed_templates "tags_editor_html/*"
embed_templates("tags_editor_html/*")
@spec assign_socket(term()) :: term()
def assign_socket(socket) do
assign(socket, :tags_editor, build(socket.assigns))
end
@spec toggle_selection(term(), term(), term()) :: term()
def toggle_selection(socket, tag_name, reload) do
selected = Map.get(socket.assigns, :tags_editor_selected, [])
@@ -33,6 +35,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench)
end
@spec update_new_tag(term(), term(), term()) :: term()
def update_new_tag(socket, params, reload) do
socket
|> assign(:tags_editor_new_tag, %{
@@ -42,11 +45,16 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench)
end
@spec create_tag(term(), term(), term()) :: term()
def create_tag(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
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} ->
socket
|> assign(:tags_editor_new_tag, %{"name" => "", "color" => ""})
@@ -59,6 +67,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
end
@spec update_edit_tag(term(), term(), term()) :: term()
def update_edit_tag(socket, params, reload) do
socket
|> assign(:tags_editor_edit_draft, %{
@@ -69,16 +78,26 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|> reload.(socket.assigns.workbench)
end
@spec save_tag(term(), term(), term()) :: term()
def save_tag(socket, reload, append_output) do
selected = Map.get(socket.assigns, :tags_editor_selected, [])
draft = Map.get(socket.assigns, :tags_editor_edit_draft, %{})
case selected do
[tag_name] ->
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do
nil -> reload.(socket, socket.assigns.workbench)
case Repo.get_by(Tag,
project_id: socket.assigns.projects.active_project_id,
name: tag_name
) do
nil ->
reload.(socket, socket.assigns.workbench)
%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
socket
|> assign(:tags_editor_selected, [renamed_tag.name])
@@ -92,15 +111,22 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
end
_other -> reload.(socket, socket.assigns.workbench)
_other ->
reload.(socket, socket.assigns.workbench)
end
end
@spec delete_selected(term(), term(), term()) :: term()
def delete_selected(socket, reload, append_output) do
case Map.get(socket.assigns, :tags_editor_selected, []) do
[tag_name] ->
case Repo.get_by(Tag, project_id: socket.assigns.projects.active_project_id, name: tag_name) do
nil -> reload.(socket, socket.assigns.workbench)
case Repo.get_by(Tag,
project_id: socket.assigns.projects.active_project_id,
name: tag_name
) do
nil ->
reload.(socket, socket.assigns.workbench)
%Tag{} = tag ->
case Tags.delete_tag(tag.id) do
{:ok, _deleted} ->
@@ -116,16 +142,19 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
end
_other -> reload.(socket, socket.assigns.workbench)
_other ->
reload.(socket, socket.assigns.workbench)
end
end
@spec update_merge_target(term(), term(), term()) :: term()
def update_merge_target(socket, target, reload) do
socket
|> assign(:tags_editor_merge_target, to_string(target || ""))
|> reload.(socket.assigns.workbench)
end
@spec merge_selected(term(), term(), term()) :: term()
def merge_selected(socket, reload, append_output) do
selected = Map.get(socket.assigns, :tags_editor_selected, [])
target_name = Map.get(socket.assigns, :tags_editor_merge_target, "")
@@ -136,12 +165,19 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
true ->
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))
sources = Enum.reject(tags, &(&1.name == target_name))
case target do
nil -> reload.(socket, socket.assigns.workbench)
nil ->
reload.(socket, socket.assigns.workbench)
_target ->
case Tags.merge_tags(Enum.map(sources, & &1.id), target.id) do
{:ok, _merged} ->
@@ -160,23 +196,41 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
end
@spec sync(term(), term(), term()) :: term()
def sync(socket, reload, append_output) do
_ = append_output
:ok = Tags.sync_tags_json(socket.assigns.projects.active_project_id)
reload.(socket, socket.assigns.workbench)
end
@spec build(term()) :: term()
def build(%{current_tab: %{type: :tags}} = assigns) do
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)
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))
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,
new_tag: Map.get(assigns, :tags_editor_new_tag, %{"name" => "", "color" => ""}),
edit_draft: edit_draft,
@@ -187,14 +241,18 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
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
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)
Float.round(0.85 + (1.8 - 0.85) * ratio, 2)
end
@spec tag_style(term(), term()) :: term()
def tag_style(tag, counts) do
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 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
normalized = String.trim(to_string(next_name || tag.name))
@@ -237,6 +301,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
end
defp blank_to_nil(nil), do: nil
defp blank_to_nil(value) do
case String.trim(to_string(value)) do
"" -> 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(:group_name, localize_task_group(Map.get(task, :group_name), 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
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
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
@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
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()
def handle_keydown(socket, key, invoke_fun) do
if socket.assigns.titlebar_menu_group do
@@ -114,7 +118,9 @@ defmodule BDS.Desktop.ShellLive.TitlebarMenu do
defp rotate_group(socket, offset) do
groups = socket.assigns.menu_groups || []
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
socket

View File

@@ -57,4 +57,4 @@ defmodule BDS.Embeddings.Backends.InApp do
Enum.map(vector, &(&1 / norm))
end
end
end
end

View File

@@ -117,7 +117,9 @@ defmodule BDS.Frontmatter do
defp take_block_scalar_lines([line | rest], lines) 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
{Enum.reverse(lines), [line | rest]}
end

View File

@@ -2,13 +2,16 @@ defmodule BDS.Generation do
@moduledoc false
import Ecto.Query
import BDS.Generation.Paths,
except: [post_output_path: 1, post_output_path: 2]
import BDS.Generation.Sitemap,
only: [
render: 1,
render_multi_language: 6
]
import BDS.Generation.Progress
import BDS.Generation.Outputs
import BDS.Generation.Data
@@ -89,7 +92,8 @@ defmodule BDS.Generation do
{:ok, validation_report()} | {:error, term()}
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
on_progress = callback(opts)
: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)
generated_file_updated_at = generated_file_updated_at_map(generated_files_list)
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(
plan,
data,
@@ -155,8 +162,8 @@ defmodule BDS.Generation do
@spec apply_validation(String.t(), [section()] | map()) :: {:ok, map()} | {:error, term()}
def apply_validation(project_id, sections) when is_binary(project_id) and is_list(sections) do
with {:ok, plan} <- plan_generation(project_id, sections),
{:ok, actual_files} <- disk_generated_files(project_id) do
with {:ok, plan} <- plan_generation(project_id, sections),
{:ok, actual_files} <- disk_generated_files(project_id) do
expected_outputs = build_outputs(plan)
expected_paths = MapSet.new(Enum.map(expected_outputs, &elem(&1, 0)))
project = Projects.get_project!(project_id)
@@ -190,7 +197,8 @@ defmodule BDS.Generation do
generated_files_on_disk
|> Map.keys()
|> 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)
|> Enum.each(fn relative_path ->
_ = File.rm(output_path(project, relative_path))
@@ -215,6 +223,7 @@ defmodule BDS.Generation do
expected_output_map = Map.new(expected_outputs)
project = Projects.get_project!(project_id)
published_posts = list_published_posts(project_id)
targeted_plan =
build_targeted_validation_plan(
plan_validation_paths(report_paths(report), additional_languages(plan)),
@@ -224,7 +233,12 @@ defmodule BDS.Generation do
outputs_to_render =
expected_outputs
|> 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)
Enum.each(outputs_to_render, fn {relative_path, content} ->
@@ -243,7 +257,10 @@ defmodule BDS.Generation do
{: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,
removed_empty_dir_count: removed_empty_dir_count
}}
@@ -257,15 +274,21 @@ defmodule BDS.Generation do
defdelegate post_output_path(post, language), to: Paths
@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()}
def 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)
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)
content_hash = sha256(content)
now = Persistence.now_ms()
@@ -331,8 +354,12 @@ defmodule BDS.Generation do
data = generation_data(plan)
published_translations = flattened_generation_translations(data.translations_by_post)
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 =
additional_languages(plan)
@@ -421,7 +448,10 @@ defmodule BDS.Generation do
pagefind_outputs =
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
[]
end
@@ -433,7 +463,9 @@ defmodule BDS.Generation do
[]
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
defp build_validation_sitemap_artifacts(
@@ -454,17 +486,27 @@ defmodule BDS.Generation do
additional_language_sets =
Enum.map(additional_languages(plan), fn language ->
language_posts = 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_posts =
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,
language_posts,
build_validation_route_paths(plan, language_posts, language_list_posts, language_post_index, language)}
{language, language_posts,
build_validation_route_paths(
plan,
language_posts,
language_list_posts,
language_post_index,
language
)}
end)
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)
@@ -497,7 +539,8 @@ defmodule BDS.Generation do
sitemap_to_write =
case additional_languages(plan) do
[] -> sitemap_content
[] ->
sitemap_content
languages ->
render_multi_language(
@@ -510,7 +553,8 @@ defmodule BDS.Generation do
)
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
defp disk_generated_files(project_id) do
@@ -544,21 +588,52 @@ defmodule BDS.Generation do
segments = String.split(relative_path, "/", trim: true)
case strip_language_prefix(segments) do
["404.html"] -> :core
["index.html"] -> :core
["page", _page, "index.html"] -> :core
["sitemap.xml"] -> :core
["feed.xml"] -> :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
["404.html"] ->
:core
["index.html"] ->
:core
["page", _page, "index.html"] ->
:core
["sitemap.xml"] ->
:core
["feed.xml"] ->
: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
@@ -615,7 +690,9 @@ defmodule BDS.Generation do
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}
{:error, :enoent} ->
@@ -634,7 +711,12 @@ defmodule BDS.Generation do
end)
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)
:ok

View File

@@ -40,7 +40,13 @@ defmodule BDS.Generation.Data do
post_snapshot_candidates
|> Enum.with_index(1)
|> 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
nil -> acc
@@ -54,7 +60,9 @@ defmodule BDS.Generation.Data do
|> then(fn published ->
draft_candidates
|> 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()
end)
|> 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
@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 || ""))
main = String.downcase(to_string(main_language || ""))
@@ -126,22 +139,42 @@ defmodule BDS.Generation.Data do
@spec build_generation_post_index([map()]) :: map()
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 ->
{year, month_value, day_value} = local_date_parts!(post.created_at)
month = String.pad_leading(Integer.to_string(month_value), 2, "0")
day = String.pad_leading(Integer.to_string(day_value), 2, "0")
year_month = "#{year}/#{month}"
year_month_day = "#{year}/#{month}/#{day}"
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)
month = String.pad_leading(Integer.to_string(month_value), 2, "0")
day = String.pad_leading(Integer.to_string(day_value), 2, "0")
year_month = "#{year}/#{month}"
year_month_day = "#{year}/#{month}/#{day}"
acc
|> append_generation_index(:posts_by_year, year, post)
|> append_generation_index(:posts_by_year_month, year_month, post)
|> append_generation_index(:posts_by_year_month_day, year_month_day, post)
|> then(fn indexed ->
indexed = Enum.reduce(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)
acc
|> append_generation_index(:posts_by_year, year, post)
|> append_generation_index(:posts_by_year_month, year_month, post)
|> append_generation_index(:posts_by_year_month_day, year_month_day, post)
|> then(fn indexed ->
indexed =
Enum.reduce(
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
## --- internals -----------------------------------------------------------
@@ -168,9 +201,11 @@ defmodule BDS.Generation.Data do
"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, %{
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)
})
end)
@@ -207,23 +242,30 @@ defmodule BDS.Generation.Data do
{:ok, contents} ->
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
%Post{fallback_post |
id: DocumentFields.get(fields, "id", fallback_post.id),
title: DocumentFields.get(fields, "title", fallback_post.title) || "",
slug: DocumentFields.fetch!(fields, "slug"),
excerpt: Map.get(fields, "excerpt"),
content: nil,
status: :published,
author: Map.get(fields, "author"),
language: Map.get(fields, "language", fallback_post.language),
do_not_translate: 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),
updated_at: DocumentFields.get(fields, "updatedAt", fallback_post.updated_at),
published_at: DocumentFields.get(fields, "publishedAt", fallback_post.published_at),
file_path: fallback_post.file_path,
tags: Map.get(fields, "tags", fallback_post.tags || []),
categories: Map.get(fields, "categories", fallback_post.categories || [])
%Post{
fallback_post
| id: DocumentFields.get(fields, "id", fallback_post.id),
title: DocumentFields.get(fields, "title", fallback_post.title) || "",
slug: DocumentFields.fetch!(fields, "slug"),
excerpt: Map.get(fields, "excerpt"),
content: nil,
status: :published,
author: Map.get(fields, "author"),
language: Map.get(fields, "language", fallback_post.language),
do_not_translate:
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),
updated_at: DocumentFields.get(fields, "updatedAt", fallback_post.updated_at),
published_at: DocumentFields.get(fields, "publishedAt", fallback_post.published_at),
file_path: fallback_post.file_path,
tags: Map.get(fields, "tags", fallback_post.tags || []),
categories: Map.get(fields, "categories", fallback_post.categories || [])
}
{:error, _reason} ->
@@ -231,13 +273,20 @@ defmodule BDS.Generation.Data do
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)
translation_candidates =
Repo.all(
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],
order_by: [asc: translation.translation_for, asc: translation.language]
)
@@ -246,7 +295,13 @@ defmodule BDS.Generation.Data do
translation_candidates
|> Enum.with_index(1)
|> 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
nil -> acc
@@ -288,18 +343,20 @@ defmodule BDS.Generation.Data do
{:ok, contents} ->
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
%Translation{fallback_translation |
id: DocumentFields.get(fields, "id", fallback_translation.id),
translation_for: DocumentFields.fetch!(fields, "translationFor"),
language: DocumentFields.fetch!(fields, "language"),
title: DocumentFields.get(fields, "title", fallback_translation.title) || "",
excerpt: Map.get(fields, "excerpt", fallback_translation.excerpt),
content: nil,
status: :published,
created_at: DocumentFields.get(fields, "createdAt", fallback_translation.created_at),
updated_at: DocumentFields.get(fields, "updatedAt", fallback_translation.updated_at),
published_at: DocumentFields.get(fields, "publishedAt", fallback_translation.published_at),
file_path: fallback_translation.file_path
%Translation{
fallback_translation
| id: DocumentFields.get(fields, "id", fallback_translation.id),
translation_for: DocumentFields.fetch!(fields, "translationFor"),
language: DocumentFields.fetch!(fields, "language"),
title: DocumentFields.get(fields, "title", fallback_translation.title) || "",
excerpt: Map.get(fields, "excerpt", fallback_translation.excerpt),
content: nil,
status: :published,
created_at: DocumentFields.get(fields, "createdAt", fallback_translation.created_at),
updated_at: DocumentFields.get(fields, "updatedAt", fallback_translation.updated_at),
published_at:
DocumentFields.get(fields, "publishedAt", fallback_translation.published_at),
file_path: fallback_translation.file_path
}
{:error, _reason} ->

View File

@@ -25,8 +25,16 @@ defmodule BDS.Generation.Outputs do
end)
end
@spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [String.t()]
def build_validation_route_paths(plan, route_posts, published_list_posts, post_index, route_language) do
@spec build_validation_route_paths(map(), [map()], [map()], map(), String.t() | nil) :: [
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),
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} ->
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)
end)
end)
@@ -260,23 +270,31 @@ defmodule BDS.Generation.Outputs do
def build_date_outputs(plan, post_index, languages) do
year_outputs =
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 ->
render_date_archive_page(
plan,
Integer.to_string(year),
%{kind: "year", year: year},
page_posts,
language,
pagination
)
end)
build_paginated_archive_outputs(
plan,
languages,
[Integer.to_string(year)],
posts,
fn page_posts, language, pagination ->
render_date_archive_page(
plan,
Integer.to_string(year),
%{kind: "year", year: year},
page_posts,
language,
pagination
)
end
)
end)
month_outputs =
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
[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(
plan,
"#{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} ->
[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(
plan,
"#{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,
language,
pagination
@@ -323,19 +348,32 @@ defmodule BDS.Generation.Outputs do
Enum.flat_map(additional_languages, fn localized_language ->
localized_prefix = route_language(plan.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) ++
[
{Path.join(localized_language, "404.html"), render_not_found_output(plan, localized_language)},
{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)}
{Path.join(localized_language, "404.html"),
render_not_found_output(plan, localized_language)},
{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
@spec build_page_outputs(String.t(), String.t(), [map()], map(), map()) :: [{String.t(), iodata()}]
def build_page_outputs(project_id, main_language, published_posts, translations_by_post_language, localized_posts_by_language) do
@spec build_page_outputs(String.t(), String.t(), [map()], map(), map()) :: [
{String.t(), iodata()}
]
def build_page_outputs(
project_id,
main_language,
published_posts,
translations_by_post_language,
localized_posts_by_language
) do
page_outputs =
published_posts
|> Enum.filter(&("page" in (&1.categories || [])))
@@ -355,7 +393,14 @@ defmodule BDS.Generation.Outputs do
language: canonical_variant.language,
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)
@@ -404,13 +449,22 @@ defmodule BDS.Generation.Outputs do
plan.project_name,
page_posts,
%{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
)}
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
total_pages = page_count(length(posts), plan.max_posts_per_page)
@@ -425,13 +479,22 @@ defmodule BDS.Generation.Outputs do
render_fun.(
page_posts,
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
@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(
project_id,
main_language,
@@ -457,7 +520,12 @@ defmodule BDS.Generation.Outputs do
excerpt: canonical_variant.excerpt
},
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)

View File

@@ -19,10 +19,13 @@ defmodule BDS.Generation.Pagefind do
|> Enum.flat_map(fn language ->
route_language = route_language(plan.language, 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.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("", 1), do: "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(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()
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, 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()
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,
total_pages: total_pages,
@@ -75,8 +94,12 @@ defmodule BDS.Generation.Paths do
@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, [], 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()
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()
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
def normalize_base_url(nil), do: nil

View File

@@ -44,7 +44,8 @@ defmodule BDS.Generation.Renderers do
end
@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
fallback = fn ->
items =
@@ -130,7 +131,15 @@ defmodule BDS.Generation.Renderers do
end
@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()
def render_list_output(
%{project_id: project_id, language: main_language},

View File

@@ -34,17 +34,20 @@ defmodule BDS.Generation.Sitemap do
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 ->
page_path = "/page/#{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}"
url_entry(
Paths.url_for_path(plan.base_url, page_path),
latest_post_updated_at,
"daily",
"0.9",
build_hreflang_links(plan.base_url, page_path, plan.language, all_languages)
)
end) ++
url_entry(
Paths.url_for_path(plan.base_url, page_path),
latest_post_updated_at,
"daily",
"0.9",
build_hreflang_links(plan.base_url, page_path, plan.language, all_languages)
)
end
) ++
Enum.map(translatable_posts, fn post ->
post_path = Paths.relative_path_to_url_path(Paths.post_output_path(post))
@@ -100,28 +103,34 @@ defmodule BDS.Generation.Sitemap do
build_hreflang_links(plan.base_url, year_path, plan.language, all_languages)
)
end) ++
Enum.map(Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc), fn {year_month, _posts} ->
month_path = "/#{year_month}"
Enum.map(
Enum.sort_by(post_index.posts_by_year_month, &elem(&1, 0), :desc),
fn {year_month, _posts} ->
month_path = "/#{year_month}"
url_entry(
Paths.url_for_path(plan.base_url, month_path),
latest_post_updated_at,
"monthly",
"0.5",
build_hreflang_links(plan.base_url, month_path, plan.language, all_languages)
)
end) ++
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}"
url_entry(
Paths.url_for_path(plan.base_url, month_path),
latest_post_updated_at,
"monthly",
"0.5",
build_hreflang_links(plan.base_url, month_path, plan.language, all_languages)
)
end
) ++
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}"
url_entry(
Paths.url_for_path(plan.base_url, day_path),
latest_post_updated_at,
"monthly",
"0.4",
build_hreflang_links(plan.base_url, day_path, plan.language, all_languages)
)
end) ++
url_entry(
Paths.url_for_path(plan.base_url, day_path),
latest_post_updated_at,
"monthly",
"0.4",
build_hreflang_links(plan.base_url, day_path, plan.language, all_languages)
)
end
) ++
Enum.map(Enum.sort_by(post_index.posts_by_category, &elem(&1, 0)), fn {category, _posts} ->
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,
url_path_to_relative_index_path: 1
]
import BDS.Generation.Progress, only: [report_validation_compare_progress: 3]
import BDS.Generation.Sitemap, only: [extract_locs: 1, loc_to_project_path: 2]
@@ -20,7 +21,11 @@ defmodule BDS.Generation.Validation do
end
@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 ->
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.reduce(MapSet.new(), &MapSet.put(&2, normalize_url_path(&1)))
|> 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))
end)
end)
{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 =
expected_path_set
@@ -119,11 +130,14 @@ defmodule BDS.Generation.Validation do
acc
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}} ->
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
MapSet.put(acc, normalized_url_path)
@@ -233,7 +247,18 @@ defmodule BDS.Generation.Validation do
nil ->
case Regex.run(~r|^/(\d{4})/(\d{2})/(\d{2})/([^/]+)$|, path) do
[_, 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 ->
case Regex.run(~r|^/(\d{4})/(\d{2})(?:/page/\d+)?$|, path) do
@@ -281,29 +306,43 @@ defmodule BDS.Generation.Validation do
end)
enriched =
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
nil ->
acc
|> update_in([:requested_years], &MapSet.put(&1, route.year))
|> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(route.year, route.month)))
|> Map.put(:request_root_routes, true)
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
nil ->
acc
|> update_in([:requested_years], &MapSet.put(&1, route.year))
|> update_in(
[:requested_year_months],
&MapSet.put(&1, route_month_key(route.year, route.month))
)
|> Map.put(:request_root_routes, true)
post ->
{year, month, _day} = local_date_parts!(post.created_at)
post ->
{year, month, _day} = local_date_parts!(post.created_at)
acc
|> update_in([:requested_category_slugs], fn set ->
Enum.reduce(post.categories || [], set, &MapSet.put(&2, archive_route_segment(&1)))
end)
|> update_in([:requested_tag_slugs], fn set ->
Enum.reduce(post.tags || [], set, &MapSet.put(&2, archive_route_segment(&1)))
end)
|> update_in([:requested_years], &MapSet.put(&1, year))
|> update_in([:requested_year_months], &MapSet.put(&1, route_month_key(year, month)))
|> Map.put(:request_root_routes, true)
acc
|> update_in([:requested_category_slugs], fn set ->
Enum.reduce(
post.categories || [],
set,
&MapSet.put(&2, archive_route_segment(&1))
)
end)
|> update_in([:requested_tag_slugs], fn set ->
Enum.reduce(post.tags || [], set, &MapSet.put(&2, archive_route_segment(&1)))
end)
|> update_in([:requested_years], &MapSet.put(&1, year))
|> update_in(
[:requested_year_months],
&MapSet.put(&1, route_month_key(year, month))
)
|> Map.put(:request_root_routes, true)
end
end
end)
)
language_plans =
initial_plan.language_plans
@@ -314,8 +353,10 @@ defmodule BDS.Generation.Validation do
%{
enriched
| requested_category_slugs: MapSet.intersection(enriched.requested_category_slugs, available_category_slugs),
requested_tag_slugs: MapSet.intersection(enriched.requested_tag_slugs, available_tag_slugs),
| requested_category_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
}
end
@@ -351,13 +392,15 @@ defmodule BDS.Generation.Validation do
{nil, path}
end
_other -> {nil, path}
_other ->
{nil, path}
end
end
@spec targeted_output?(String.t(), map(), String.t() | nil, [String.t()]) :: boolean()
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 =
case language do
@@ -384,7 +427,11 @@ defmodule BDS.Generation.Validation do
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
cond do
@@ -400,8 +447,18 @@ defmodule BDS.Generation.Validation do
MapSet.member?(plan.requested_tag_slugs, slug)
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)
MapSet.member?(plan.requested_post_routes, route_key(String.to_integer(year), String.to_integer(month), String.to_integer(day), slug))
[_, year, month, 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) ->
[_, 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)
}}
else
{:error, :not_found} = error -> error
{:error, :not_found} = error ->
error
{:error, _reason} ->
{:ok,
%{
@@ -74,7 +76,8 @@ defmodule BDS.Git do
def status(project_id, opts \\ []) when is_binary(project_id) and is_list(opts) do
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)}}
end
end
@@ -112,7 +115,8 @@ defmodule BDS.Git do
when is_binary(project_id) and is_binary(branch) and is_list(opts) do
with {:ok, project_dir} <- project_dir(project_id),
{: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)
remote_hashes = MapSet.new(parse_remote_history(remote_log))
local_hashes = MapSet.new(Enum.map(local_commits, & &1.hash))
@@ -121,7 +125,9 @@ defmodule BDS.Git do
remote_hashes
|> MapSet.difference(local_hashes)
|> 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 =
Enum.map(local_commits, fn commit ->
@@ -136,7 +142,8 @@ defmodule BDS.Git do
def file_history(project_id, file_path, opts \\ [])
when is_binary(project_id) and is_binary(file_path) and is_list(opts) do
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)}}
else
{: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
with {:ok, project_dir} <- project_dir(project_id) do
case run_git(project_dir, ["fetch", "--all", "--prune"], opts) do
{:ok, output} -> {:ok, %{updated: true, output: output}}
{:error, {:git_failed, message}} -> structured_git_error(project_dir, :fetch, message, opts)
{:ok, output} ->
{:ok, %{updated: true, output: output}}
{:error, {:git_failed, message}} ->
structured_git_error(project_dir, :fetch, message, opts)
end
end
end
@@ -177,9 +187,11 @@ defmodule BDS.Git do
end
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),
{: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)}}
end
end
@@ -197,7 +209,14 @@ defmodule BDS.Git do
{:ok, local_branch} <- current_branch(project_dir, opts) do
case upstream_branch(project_dir, opts) do
{: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,
@@ -316,7 +335,11 @@ defmodule BDS.Git do
end
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)}
{:error, {:git_failed, _message}} -> {:ok, nil}
end
@@ -364,21 +387,37 @@ defmodule BDS.Git do
defp parse_changed_files(output) do
base = fn -> %{added: [], modified: [], deleted: [], renamed: []} end
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
["A", path] -> update_changed(acc, path, :added, path)
["M", path] -> 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
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
["A", path] ->
update_changed(acc, path, :added, path)
["M", path] ->
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
defp update_changed(acc, path, key, value) do
case category_for_path(path) do
nil -> acc
category -> Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end))
nil ->
acc
category ->
Map.update!(acc, category, &Map.update!(&1, key, fn items -> items ++ [value] end))
end
end
@@ -427,6 +466,7 @@ defmodule BDS.Git do
defp auth_guidance(provider, platform) do
provider_label = provider || :git
"Authentication failed for #{provider_label} on #{platform}. Configure SSH keys or a credential helper that works non-interactively."
end

View File

@@ -32,11 +32,23 @@ defmodule BDS.ImportAnalysis do
notify_progress(on_progress, "Loading existing posts...")
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)
notify_progress(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)
notify_progress(
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()
posts_by_slug = Map.new(existing_posts, &{&1.slug, &1})
@@ -53,15 +65,35 @@ defmodule BDS.ImportAnalysis do
|> Enum.reject(&is_nil(&1.checksum))
|> Map.new(&{&1.checksum, &1})
notify_progress(on_progress, "Analyzing posts...", "#{length(wxr_data.posts)} posts to analyze")
analyzed_posts = Enum.map(wxr_data.posts, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "post"))
notify_progress(
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_pages = Enum.map(wxr_data.pages, &analyze_post_item(&1, posts_by_slug, posts_by_checksum, "page"))
analyzed_posts =
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 =
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...")
category_items = Enum.map(wxr_data.categories, &analyze_taxonomy_item(&1, existing_tag_set))
@@ -113,10 +145,18 @@ defmodule BDS.ImportAnalysis do
{status, existing} =
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 -> {"conflict", existing_by_slug}
existing_by_checksum -> {"content-duplicate", existing_by_checksum}
true -> {"new", nil}
existing_by_slug && existing_by_slug.checksum == content_checksum &&
not is_nil(existing_by_slug.checksum) ->
{"update", existing_by_slug}
existing_by_slug ->
{"conflict", existing_by_slug}
existing_by_checksum ->
{"content-duplicate", existing_by_checksum}
true ->
{"new", nil}
end
%{
@@ -163,10 +203,18 @@ defmodule BDS.ImportAnalysis do
existing_by_checksum = Map.get(media_by_checksum, file_checksum)
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 -> {"conflict", file_checksum, existing_by_name}
existing_by_checksum -> {"content-duplicate", file_checksum, existing_by_checksum}
true -> {"new", file_checksum, nil}
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 ->
{"conflict", file_checksum, existing_by_name}
existing_by_checksum ->
{"content-duplicate", file_checksum, existing_by_checksum}
true ->
{"new", file_checksum, nil}
end
end
@@ -265,7 +313,9 @@ defmodule BDS.ImportAnalysis do
defp date_distribution(posts, pages, media) do
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))
post_counts
@@ -325,7 +375,10 @@ defmodule BDS.ImportAnalysis do
| total_count: existing.total_count + 1,
usages: Map.put(existing.usages, params_key, usage),
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)
@@ -393,9 +446,17 @@ defmodule BDS.ImportAnalysis do
defp year_from(value) when is_integer(value) do
cond do
value > 100_000_000_000 -> 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
value > 100_000_000_000 ->
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
rescue
_error -> nil
@@ -405,10 +466,14 @@ defmodule BDS.ImportAnalysis do
normalized = String.replace(value, " ", "T")
case NaiveDateTime.from_iso8601(normalized) do
{:ok, naive} -> naive.year
{:ok, naive} ->
naive.year
_other ->
case DateTime.from_iso8601(value) do
{:ok, datetime, _offset} -> datetime.year
{:ok, datetime, _offset} ->
datetime.year
_ ->
case Regex.run(~r/(\d{4})/, value) do
[_, year] -> String.to_integer(year)

View File

@@ -39,7 +39,10 @@ defmodule BDS.ImportDefinitions do
|> maybe_put(:name, attr(attrs, :name))
|> maybe_put(:wxr_file_path, attr(attrs, :wxr_file_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())
definition
@@ -50,7 +53,9 @@ defmodule BDS.ImportDefinitions do
def delete_definition(definition_id) when is_binary(definition_id) do
case Repo.get(ImportDefinition, definition_id) do
nil -> {:error, :not_found}
nil ->
{:error, :not_found}
%ImportDefinition{} = definition ->
Repo.delete(definition)
|> case do
@@ -60,7 +65,8 @@ defmodule BDS.ImportDefinitions do
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
case Jason.decode(result) do

View File

@@ -19,7 +19,16 @@ defmodule BDS.ImportDefinitions.ImportDefinition do
def changeset(definition, attrs) do
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])
end
end

View File

@@ -8,7 +8,8 @@ defmodule BDS.ImportExecution do
alias BDS.Repo
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)
default_author = Keyword.get(opts, :default_author) || project_default_author(project_id)
uploads_folder_path = Keyword.get(opts, :uploads_folder_path)
@@ -42,16 +43,52 @@ defmodule BDS.ImportExecution do
started_at = System.monotonic_time(:millisecond)
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)
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)
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)
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)
{:ok, result}
@@ -68,41 +105,99 @@ defmodule BDS.ImportExecution do
|> Enum.reduce(result, fn {item, index}, acc ->
cond do
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)
true ->
case Tags.create_tag(%{project_id: project_id, name: item.name}) do
{: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)
{: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)
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)
phase = Atom.to_string(bucket)
Enum.with_index(items, 1)
|> Enum.reduce(result, fn {item, index}, acc ->
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
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)
items
|> Enum.with_index(1)
|> 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
item.status == "missing" ->
@@ -116,7 +211,9 @@ defmodule BDS.ImportExecution do
true ->
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} ->
acc
|> put_in([:media, :errors], acc.media.errors + 1)
@@ -127,7 +224,15 @@ defmodule BDS.ImportExecution do
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
item.status in ["update", "content-duplicate", "duplicate"] ->
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
case Repo.get(Post, item.existing_id) do
nil -> {:error, :not_found}
nil ->
{:error, :not_found}
%Post{} = post ->
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
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)
if source_path && File.exists?(source_path) do
@@ -221,7 +333,10 @@ defmodule BDS.ImportExecution do
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
{:ok, %{id: media_id} = media} ->
@@ -255,8 +370,12 @@ defmodule BDS.ImportExecution do
defp parent_post_ids(item, result) do
case Map.get(item, :parent_wp_id) do
nil -> []
0 -> []
nil ->
[]
0 ->
[]
wp_id ->
case Map.get(result.wp_id_to_post_id, wp_id) do
nil -> []
@@ -265,7 +384,8 @@ defmodule BDS.ImportExecution do
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))
end
@@ -333,7 +453,9 @@ defmodule BDS.ImportExecution do
end
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}
end
@@ -349,7 +471,11 @@ defmodule BDS.ImportExecution do
true -> key
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
@@ -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)
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)
end
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)
try do
@@ -466,7 +594,9 @@ defmodule BDS.ImportExecution do
:ok
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
if current >= total, do: 0, else: trunc(elapsed / current * (total - current))
end

View File

@@ -114,9 +114,11 @@ defmodule BDS.Maintenance do
phases = [
{"Comparing project metadata", fn -> project_metadata_diff_reports(project_id) 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 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 template metadata", fn -> template_diff_reports(project_id, project) end},
{"Comparing embeddings", fn -> Embeddings.diff_reports(project_id) end}
@@ -132,7 +134,9 @@ defmodule BDS.Maintenance do
fun.()
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)
:ok = report_metadata_diff_complete(on_progress)

View File

@@ -87,7 +87,10 @@ defmodule BDS.Maintenance.DiffComputation do
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_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), do: value
end

View File

@@ -110,10 +110,18 @@ defmodule BDS.Maintenance.DiffReports do
diff_field("author", post.author, Map.get(fields, "author")),
diff_field("language", post.language, Map.get(fields, "language")),
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("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("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("entrypoint", script.entrypoint, Map.get(fields, "entrypoint")),
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"))
]
|> 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("enabled", template.enabled, Map.get(fields, "enabled")),
diff_field("created_at", template.created_at, DocumentFields.get(fields, "createdAt")),
diff_field("updated_at", template.updated_at, DocumentFields.get(fields, "updatedAt"))
diff_field(
"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)

View File

@@ -30,7 +30,9 @@ defmodule BDS.MCP.AgentConfig do
end
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
executable_name =
@@ -90,12 +92,21 @@ defmodule BDS.MCP.AgentConfig do
defp merge_config(:github_copilot, config, command, args) do
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
defp merge_config(:claude_code, config, command, args) do
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
defp remove_server_entry(:github_copilot, config) do

View File

@@ -8,7 +8,11 @@ defmodule BDS.MCP.Proposal do
schema "mcp_proposals" do
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 :data, :map
field :created_at, :integer
@@ -17,7 +21,9 @@ defmodule BDS.MCP.Proposal do
def changeset(proposal, attrs) do
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])
|> unique_constraint(:status, name: :mcp_proposals_entity_idx)
end

View File

@@ -74,12 +74,15 @@ defmodule BDS.MCP.ProposalStore do
defp mark_status(id, status) do
case Repo.get(Proposal, id) do
nil -> nil
nil ->
nil
proposal ->
Repo.delete_all(
from other in Proposal,
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
)
@@ -90,6 +93,7 @@ defmodule BDS.MCP.ProposalStore do
end
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

View File

@@ -138,8 +138,11 @@ defmodule BDS.MCP.Server do
case URI.parse(target) do
%URI{path: "/mcp"} ->
case GenServer.call(__MODULE__, {:http_request, request}, 5_000) do
{:ok, status, body} -> http_response(status, Jason.encode!(body), "application/json", request.headers)
{:error, status, body} -> http_response(status, body, "text/plain", request.headers)
{:ok, status, body} ->
http_response(status, Jason.encode!(body), "application/json", request.headers)
{:error, status, body} ->
http_response(status, body, "text/plain", request.headers)
end
_other ->
@@ -170,7 +173,10 @@ defmodule BDS.MCP.Server do
success_response(id, %{
"protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"),
"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" ->
@@ -196,10 +202,17 @@ defmodule BDS.MCP.Server do
arguments = Map.get(params, "arguments", %{})
case BDS.MCP.call_tool(name, arguments) do
{:ok, result} -> {:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})}
{: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))}
{:ok, result} ->
{:ok, success_response(id, %{"content" => [%{"type" => "json", "json" => result}]})}
{: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
@@ -286,7 +299,8 @@ defmodule BDS.MCP.Server do
|> IO.iodata_to_binary()
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(404), do: "Not Found"

View File

@@ -9,8 +9,15 @@ defmodule BDS.MCP.Stdio do
if line != "" do
response =
case Jason.decode(line) do
{:ok, payload} -> handle_payload(payload)
{:error, _reason} -> %{"jsonrpc" => "2.0", "id" => nil, "error" => %{"code" => -32700, "message" => "Parse error"}}
{:ok, payload} ->
handle_payload(payload)
{:error, _reason} ->
%{
"jsonrpc" => "2.0",
"id" => nil,
"error" => %{"code" => -32700, "message" => "Parse error"}
}
end
IO.write(Jason.encode!(response) <> "\n")
@@ -18,14 +25,22 @@ defmodule BDS.MCP.Stdio do
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",
"id" => id,
"result" => %{
"protocolVersion" => Map.get(params, "protocolVersion", "2025-03-26"),
"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
@@ -34,10 +49,26 @@ defmodule BDS.MCP.Stdio do
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"tools" => BDS.MCP.list_tools()}}
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
{:ok, result} -> %{"jsonrpc" => "2.0", "id" => id, "result" => %{"content" => [%{"type" => "json", "json" => result}]}}
{:error, reason} -> %{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}}
{:ok, result} ->
%{
"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
@@ -45,17 +76,38 @@ defmodule BDS.MCP.Stdio do
%{"jsonrpc" => "2.0", "id" => id, "result" => %{"resources" => BDS.MCP.list_resources()}}
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
{: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} ->
%{"jsonrpc" => "2.0", "id" => id, "error" => %{"code" => -32000, "message" => inspect(reason)}}
%{
"jsonrpc" => "2.0",
"id" => id,
"error" => %{"code" => -32000, "message" => inspect(reason)}
}
end
end
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

View File

@@ -60,8 +60,11 @@ defmodule BDS.MCP.Tools do
@spec validate_template(String.t()) :: {:ok, %{valid: boolean(), errors: [String.t()]}}
def validate_template(source) when is_binary(source) do
case Liquex.parse(source) do
{:ok, _ast} -> {:ok, %{valid: true, errors: []}}
{:error, reason, line} -> {:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}}
{:ok, _ast} ->
{:ok, %{valid: true, errors: []}}
{:error, reason, line} ->
{:ok, %{valid: false, errors: ["#{inspect(reason)} at line #{line}"]}}
end
end
@@ -276,7 +279,8 @@ defmodule BDS.MCP.Tools do
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

View File

@@ -19,7 +19,8 @@ defmodule BDS.Media.Rebuilder do
@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
project = Projects.get_project!(project_id)
on_progress = progress_callback(opts)
@@ -61,9 +62,10 @@ defmodule BDS.Media.Rebuilder do
translation_sidecars
|> Enum.with_index(length(canonical_sidecars) + 1)
|> Enum.each(fn {sidecar, index} ->
Sidecars.upsert_translation_from_sidecar(project, canonical_media_by_binary_path, sidecar,
sync_search: false
)
Sidecars.upsert_translation_from_sidecar(
project,
canonical_media_by_binary_path,
sidecar, sync_search: false)
:ok = report_rebuild_progress(on_progress, index, total_files, "media files")
end)

View File

@@ -141,7 +141,12 @@ defmodule BDS.Media.Sidecars do
media
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
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

View File

@@ -70,7 +70,9 @@ defmodule BDS.Media.Thumbnails do
missing_paths =
media
|> 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)
next_acc =

View File

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

View File

@@ -9,6 +9,7 @@ defmodule BDS.PostLinks do
alias BDS.Projects
alias BDS.Repo
@spec sync_post_links(Post.t()) :: :ok
def sync_post_links(%Post{} = post) do
links =
post
@@ -41,6 +42,7 @@ defmodule BDS.PostLinks do
:ok
end
@spec delete_post_links(String.t()) :: :ok
def delete_post_links(post_id) when is_binary(post_id) do
Repo.delete_all(
from link in Link,
@@ -50,12 +52,18 @@ defmodule BDS.PostLinks do
:ok
end
@spec list_outgoing_links(String.t()) :: [Link.t()]
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
@spec list_incoming_links(String.t()) :: [Link.t()]
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
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
markdown_links =
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 =
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
end
@@ -121,12 +133,17 @@ defmodule BDS.PostLinks do
[language, year, month, day, slug] ->
if language_code?(language) and numeric_year?(year) and numeric_month_or_day?(month) and
numeric_month_or_day?(day),
do: slug,
else: nil
do: slug,
else: nil
[slug] -> slug
[language, slug] -> if(language_code?(language), do: slug, else: nil)
_other -> nil
[slug] ->
slug
[language, slug] ->
if(language_code?(language), do: slug, else: nil)
_other ->
nil
end
end

View File

@@ -122,7 +122,8 @@ defmodule BDS.Posts.AutoTranslation do
defp media_needed?(media_id, language) 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?(
from translation in Media.Translation,
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
belongs_to :source_post, BDS.Posts.Post, 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
belongs_to :source_post, BDS.Posts.Post,
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 :created_at, :integer

View File

@@ -50,7 +50,11 @@ defmodule BDS.Posts.TranslationValidation do
Repo.all(
from translation in Translation,
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)
@@ -67,7 +71,13 @@ defmodule BDS.Posts.TranslationValidation do
translation_rows
|> Enum.with_index(1)
|> 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
nil -> []
@@ -80,7 +90,13 @@ defmodule BDS.Posts.TranslationValidation do
markdown_files
|> Enum.with_index(length(translation_rows) + 1)
|> 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
{:ok, nil} -> {count + 1, issues}
@@ -118,11 +134,19 @@ defmodule BDS.Posts.TranslationValidation do
normalized_report = normalize_report(report)
{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
{:deleted, post_id} -> {deleted + 1, flushed, maybe_put_synced_post(synced_ids, post_id)}
{:flushed, post_id} -> {deleted, flushed + 1, maybe_put_synced_post(synced_ids, post_id)}
:noop -> {deleted, flushed, synced_ids}
{:deleted, post_id} ->
{deleted + 1, flushed, maybe_put_synced_post(synced_ids, post_id)}
{:flushed, post_id} ->
{deleted, flushed + 1, maybe_put_synced_post(synced_ids, post_id)}
:noop ->
{deleted, flushed, synced_ids}
end
end)
@@ -365,7 +389,10 @@ defmodule BDS.Posts.TranslationValidation do
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
case Repo.get(Translation, translation_id) do
%Translation{} = translation ->
@@ -402,7 +429,11 @@ defmodule BDS.Posts.TranslationValidation do
end
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.join(":")
end

View File

@@ -64,7 +64,11 @@ defmodule BDS.Preview do
{:reply, reply, next_state}
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}
end
@@ -224,7 +228,9 @@ defmodule BDS.Preview do
end
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
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 || "")}
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
if String.starts_with?(content_type, "text/html") do
%{response | body: apply_preview_overrides(body, query_params)}
@@ -465,7 +474,8 @@ defmodule BDS.Preview do
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"])
mode_override = normalize_mode_override(query_params["mode"])
@@ -506,7 +516,9 @@ defmodule BDS.Preview do
[html_tag] ->
replacement =
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
String.replace_suffix(html_tag, ">", ~s( #{attribute}="#{value}">))
end
@@ -520,7 +532,11 @@ defmodule BDS.Preview 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
defp maybe_put_assign(assigns, _key, nil, _mapper), do: assigns

View File

@@ -134,7 +134,8 @@ defmodule BDS.Projects do
sync_filesystem_metadata(project)
end
{:error, reason} -> {:error, reason}
{:error, reason} ->
{:error, reason}
end
end
@@ -166,7 +167,8 @@ defmodule BDS.Projects do
@spec delete_project(String.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
case Repo.get(Project, project_id) do
nil ->
@@ -180,7 +182,9 @@ defmodule BDS.Projects do
%Project{} = project ->
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.delete!(project)

View File

@@ -7,7 +7,11 @@ defmodule BDS.Rebuild do
timeout = Keyword.get(opts, :timeout, :infinity)
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
{:ok, item} -> item
{:exit, reason} -> exit(reason)

View File

@@ -26,7 +26,8 @@ defmodule BDS.ReleasePackaging do
]
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)
payload_name = "bds2-#{normalized_platform}-#{version}"
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(: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(_platform), do: ".tar.gz"
@@ -107,7 +110,9 @@ defmodule BDS.ReleasePackaging do
relative_entries = collect_entries(metadata.payload_root)
cwd = metadata.output_dir |> 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
{:ok, _archive_path} -> :ok
@@ -116,7 +121,13 @@ defmodule BDS.ReleasePackaging do
end
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, status} -> {:error, {:tar_failed, status, output}}
end

View File

@@ -7,9 +7,14 @@ defmodule BDS.Rendering do
def render_post_page(project_id, template_slug, assigns)
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} <-
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}
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
with {:ok, template_source} <- TemplateSelection.load_template_source(project_id, :list, nil),
{: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}
end
end
def render_not_found_page(project_id, assigns \\ %{})
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} <-
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}
end
end

View File

@@ -54,7 +54,9 @@ defmodule BDS.Rendering.Metadata do
|> Enum.uniq()
|> Enum.map(fn 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,
@@ -84,9 +86,17 @@ defmodule BDS.Rendering.Metadata do
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 ->
%{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

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