Compare commits
2 Commits
abcae1dad7
...
9f9dab85fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f9dab85fc | |||
| 881056eb61 |
241
CODESMELL.md
241
CODESMELL.md
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
[]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
_ ->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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, ¬ &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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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} ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ defmodule BDS.Scripting do
|
||||
runtime().execute(source, entrypoint, args, opts)
|
||||
end
|
||||
|
||||
@spec execute_project_script(String.t(), String.t(), String.t(), [term()], [Runtime.execution_option()]) ::
|
||||
@spec execute_project_script(String.t(), String.t(), String.t(), [term()], [
|
||||
Runtime.execution_option()
|
||||
]) ::
|
||||
{:ok, term()} | {:error, term()}
|
||||
def execute_project_script(project_id, source, entrypoint, args \\ [], opts \\ [])
|
||||
when is_binary(project_id) and is_binary(source) and is_binary(entrypoint) and
|
||||
@@ -45,13 +47,20 @@ defmodule BDS.Scripting do
|
||||
execute(source, entrypoint, args, Keyword.put(opts, :capabilities, capabilities))
|
||||
end
|
||||
|
||||
@spec execute_macro(String.t(), String.t(), [term()], keyword()) :: {:ok, String.t()} | {:error, term()}
|
||||
@spec execute_macro(String.t(), String.t(), [term()], keyword()) ::
|
||||
{:ok, String.t()} | {:error, term()}
|
||||
def execute_macro(project_id, source, args, opts \\ [])
|
||||
when is_binary(project_id) and is_binary(source) and is_list(args) and is_list(opts) do
|
||||
config = Application.fetch_env!(:bds, :scripting)
|
||||
timeout = Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout))
|
||||
|
||||
case execute_project_script(project_id, source, "render", args, Keyword.put(opts, :timeout, timeout)) do
|
||||
case execute_project_script(
|
||||
project_id,
|
||||
source,
|
||||
"render",
|
||||
args,
|
||||
Keyword.put(opts, :timeout, timeout)
|
||||
) do
|
||||
{:ok, nil} -> {:ok, ""}
|
||||
{:ok, value} -> {:ok, to_string(value)}
|
||||
{:error, _reason} -> {:ok, ""}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user