From 0c796051b9c83a816048f47ddf6b834fc771946a Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 24 Mar 2026 02:16:35 +0200 Subject: [PATCH 1/7] Extract Publishing module into external phoenix_kit_publishing package Remove all publishing source code, tests, and routes from the monorepo. Add css_sources/0 callback to PhoenixKit.Module behaviour for external module CSS source scanning. Update CSS installer to auto-discover and resolve plugin module source paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/README.md | 1507 --------------- lib/modules/publishing/components/cta.ex | 9 - .../publishing/components/entity_form.ex | 9 - lib/modules/publishing/components/headline.ex | 9 - lib/modules/publishing/components/hero.ex | 9 - lib/modules/publishing/components/image.ex | 9 - lib/modules/publishing/components/page.ex | 9 - .../publishing/components/subheadline.ex | 9 - lib/modules/publishing/components/video.ex | 9 - lib/modules/publishing/constants.ex | 111 -- lib/modules/publishing/db_storage.ex | 849 --------- lib/modules/publishing/db_storage/mapper.ex | 254 --- lib/modules/publishing/groups.ex | 505 ----- lib/modules/publishing/language_helpers.ex | 280 --- lib/modules/publishing/listing_cache.ex | 728 ------- lib/modules/publishing/metadata.ex | 162 -- lib/modules/publishing/page_builder.ex | 99 - lib/modules/publishing/page_builder/parser.ex | 152 -- .../publishing/page_builder/renderer.ex | 100 - lib/modules/publishing/posts.ex | 818 -------- lib/modules/publishing/presence.ex | 34 - lib/modules/publishing/presence_helpers.ex | 190 -- lib/modules/publishing/publishing.ex | 414 ---- lib/modules/publishing/pubsub.ex | 550 ------ lib/modules/publishing/renderer.ex | 596 ------ .../publishing/schemas/publishing_content.ex | 114 -- .../publishing/schemas/publishing_group.ex | 122 -- .../publishing/schemas/publishing_post.ex | 150 -- .../publishing/schemas/publishing_version.ex | 89 - lib/modules/publishing/shared.ex | 227 --- lib/modules/publishing/slug_helpers.ex | 228 --- lib/modules/publishing/stale_fixer.ex | 660 ------- lib/modules/publishing/translation_manager.ex | 412 ---- lib/modules/publishing/versions.ex | 372 ---- .../web/components/version_switcher.ex | 256 --- lib/modules/publishing/web/controller.ex | 362 ---- .../publishing/web/controller/fallback.ex | 381 ---- .../publishing/web/controller/language.ex | 317 --- .../publishing/web/controller/listing.ex | 367 ---- .../web/controller/post_fetching.ex | 111 -- .../web/controller/post_rendering.ex | 408 ---- .../publishing/web/controller/routing.ex | 103 - .../web/controller/slug_resolution.ex | 120 -- .../publishing/web/controller/translations.ex | 247 --- lib/modules/publishing/web/edit.ex | 118 -- lib/modules/publishing/web/edit.html.heex | 92 - lib/modules/publishing/web/editor.ex | 1692 ----------------- lib/modules/publishing/web/editor.html.heex | 1199 ------------ .../publishing/web/editor/collaborative.ex | 582 ------ lib/modules/publishing/web/editor/forms.ex | 429 ----- lib/modules/publishing/web/editor/helpers.ex | 349 ---- .../publishing/web/editor/persistence.ex | 852 --------- lib/modules/publishing/web/editor/preview.ex | 365 ---- .../publishing/web/editor/translation.ex | 568 ------ lib/modules/publishing/web/editor/versions.ex | 237 --- lib/modules/publishing/web/html.ex | 520 ----- lib/modules/publishing/web/index.ex | 423 ----- lib/modules/publishing/web/index.html.heex | 302 --- lib/modules/publishing/web/listing.ex | 947 --------- lib/modules/publishing/web/listing.html.heex | 457 ----- lib/modules/publishing/web/new.ex | 379 ---- lib/modules/publishing/web/new.html.heex | 309 --- lib/modules/publishing/web/post_show.ex | 110 -- .../publishing/web/post_show.html.heex | 153 -- lib/modules/publishing/web/preview.ex | 166 -- lib/modules/publishing/web/preview.html.heex | 148 -- lib/modules/publishing/web/settings.ex | 243 --- lib/modules/publishing/web/settings.html.heex | 327 ---- .../web/templates/all_groups.html.heex | 65 - .../publishing/web/templates/index.html.heex | 148 -- .../publishing/web/templates/show.html.heex | 121 -- .../migrate_primary_language_worker.ex | 145 -- .../workers/translate_post_worker.ex | 974 ---------- lib/phoenix_kit/install/css_integration.ex | 100 +- lib/phoenix_kit/module.ex | 24 +- lib/phoenix_kit_web/routes/publishing.ex | 120 -- mix.lock | 1 + test/modules/publishing/editor_forms_test.exs | 250 --- test/modules/publishing/facade_test.exs | 173 -- test/modules/publishing/groups_test.exs | 94 - .../publishing/integration/groups_test.exs | 342 ---- .../publishing/integration/posts_test.exs | 398 ---- .../integration/slug_update_test.exs | 65 - .../integration/translate_retry_test.exs | 121 -- .../integration/translation_reload_test.exs | 70 - .../integration/translations_test.exs | 277 --- .../publishing/integration/versions_test.exs | 305 --- test/modules/publishing/mapper_test.exs | 488 ----- test/modules/publishing/metadata_test.exs | 61 - test/modules/publishing/posts_test.exs | 106 -- .../publishing/publishing_api_test.exs | 203 -- .../publishing/pubsub_broadcast_id_test.exs | 71 - test/modules/publishing/pubsub_test.exs | 80 - test/modules/publishing/renderer_test.exs | 203 -- test/modules/publishing/schema_test.exs | 373 ---- test/modules/publishing/shared_test.exs | 204 -- .../publishing/translate_post_worker_test.exs | 43 - .../web/controller/listing_test.exs | 238 --- .../web/controller/routing_test.exs | 154 -- 99 files changed, 114 insertions(+), 29337 deletions(-) delete mode 100644 lib/modules/publishing/README.md delete mode 100644 lib/modules/publishing/components/cta.ex delete mode 100644 lib/modules/publishing/components/entity_form.ex delete mode 100644 lib/modules/publishing/components/headline.ex delete mode 100644 lib/modules/publishing/components/hero.ex delete mode 100644 lib/modules/publishing/components/image.ex delete mode 100644 lib/modules/publishing/components/page.ex delete mode 100644 lib/modules/publishing/components/subheadline.ex delete mode 100644 lib/modules/publishing/components/video.ex delete mode 100644 lib/modules/publishing/constants.ex delete mode 100644 lib/modules/publishing/db_storage.ex delete mode 100644 lib/modules/publishing/db_storage/mapper.ex delete mode 100644 lib/modules/publishing/groups.ex delete mode 100644 lib/modules/publishing/language_helpers.ex delete mode 100644 lib/modules/publishing/listing_cache.ex delete mode 100644 lib/modules/publishing/metadata.ex delete mode 100644 lib/modules/publishing/page_builder.ex delete mode 100644 lib/modules/publishing/page_builder/parser.ex delete mode 100644 lib/modules/publishing/page_builder/renderer.ex delete mode 100644 lib/modules/publishing/posts.ex delete mode 100644 lib/modules/publishing/presence.ex delete mode 100644 lib/modules/publishing/presence_helpers.ex delete mode 100644 lib/modules/publishing/publishing.ex delete mode 100644 lib/modules/publishing/pubsub.ex delete mode 100644 lib/modules/publishing/renderer.ex delete mode 100644 lib/modules/publishing/schemas/publishing_content.ex delete mode 100644 lib/modules/publishing/schemas/publishing_group.ex delete mode 100644 lib/modules/publishing/schemas/publishing_post.ex delete mode 100644 lib/modules/publishing/schemas/publishing_version.ex delete mode 100644 lib/modules/publishing/shared.ex delete mode 100644 lib/modules/publishing/slug_helpers.ex delete mode 100644 lib/modules/publishing/stale_fixer.ex delete mode 100644 lib/modules/publishing/translation_manager.ex delete mode 100644 lib/modules/publishing/versions.ex delete mode 100644 lib/modules/publishing/web/components/version_switcher.ex delete mode 100644 lib/modules/publishing/web/controller.ex delete mode 100644 lib/modules/publishing/web/controller/fallback.ex delete mode 100644 lib/modules/publishing/web/controller/language.ex delete mode 100644 lib/modules/publishing/web/controller/listing.ex delete mode 100644 lib/modules/publishing/web/controller/post_fetching.ex delete mode 100644 lib/modules/publishing/web/controller/post_rendering.ex delete mode 100644 lib/modules/publishing/web/controller/routing.ex delete mode 100644 lib/modules/publishing/web/controller/slug_resolution.ex delete mode 100644 lib/modules/publishing/web/controller/translations.ex delete mode 100644 lib/modules/publishing/web/edit.ex delete mode 100644 lib/modules/publishing/web/edit.html.heex delete mode 100644 lib/modules/publishing/web/editor.ex delete mode 100644 lib/modules/publishing/web/editor.html.heex delete mode 100644 lib/modules/publishing/web/editor/collaborative.ex delete mode 100644 lib/modules/publishing/web/editor/forms.ex delete mode 100644 lib/modules/publishing/web/editor/helpers.ex delete mode 100644 lib/modules/publishing/web/editor/persistence.ex delete mode 100644 lib/modules/publishing/web/editor/preview.ex delete mode 100644 lib/modules/publishing/web/editor/translation.ex delete mode 100644 lib/modules/publishing/web/editor/versions.ex delete mode 100644 lib/modules/publishing/web/html.ex delete mode 100644 lib/modules/publishing/web/index.ex delete mode 100644 lib/modules/publishing/web/index.html.heex delete mode 100644 lib/modules/publishing/web/listing.ex delete mode 100644 lib/modules/publishing/web/listing.html.heex delete mode 100644 lib/modules/publishing/web/new.ex delete mode 100644 lib/modules/publishing/web/new.html.heex delete mode 100644 lib/modules/publishing/web/post_show.ex delete mode 100644 lib/modules/publishing/web/post_show.html.heex delete mode 100644 lib/modules/publishing/web/preview.ex delete mode 100644 lib/modules/publishing/web/preview.html.heex delete mode 100644 lib/modules/publishing/web/settings.ex delete mode 100644 lib/modules/publishing/web/settings.html.heex delete mode 100644 lib/modules/publishing/web/templates/all_groups.html.heex delete mode 100644 lib/modules/publishing/web/templates/index.html.heex delete mode 100644 lib/modules/publishing/web/templates/show.html.heex delete mode 100644 lib/modules/publishing/workers/migrate_primary_language_worker.ex delete mode 100644 lib/modules/publishing/workers/translate_post_worker.ex delete mode 100644 lib/phoenix_kit_web/routes/publishing.ex delete mode 100644 test/modules/publishing/editor_forms_test.exs delete mode 100644 test/modules/publishing/facade_test.exs delete mode 100644 test/modules/publishing/groups_test.exs delete mode 100644 test/modules/publishing/integration/groups_test.exs delete mode 100644 test/modules/publishing/integration/posts_test.exs delete mode 100644 test/modules/publishing/integration/slug_update_test.exs delete mode 100644 test/modules/publishing/integration/translate_retry_test.exs delete mode 100644 test/modules/publishing/integration/translation_reload_test.exs delete mode 100644 test/modules/publishing/integration/translations_test.exs delete mode 100644 test/modules/publishing/integration/versions_test.exs delete mode 100644 test/modules/publishing/mapper_test.exs delete mode 100644 test/modules/publishing/metadata_test.exs delete mode 100644 test/modules/publishing/posts_test.exs delete mode 100644 test/modules/publishing/publishing_api_test.exs delete mode 100644 test/modules/publishing/pubsub_broadcast_id_test.exs delete mode 100644 test/modules/publishing/pubsub_test.exs delete mode 100644 test/modules/publishing/renderer_test.exs delete mode 100644 test/modules/publishing/schema_test.exs delete mode 100644 test/modules/publishing/shared_test.exs delete mode 100644 test/modules/publishing/translate_post_worker_test.exs delete mode 100644 test/modules/publishing/web/controller/listing_test.exs delete mode 100644 test/modules/publishing/web/controller/routing_test.exs diff --git a/lib/modules/publishing/README.md b/lib/modules/publishing/README.md deleted file mode 100644 index ad970e5cb..000000000 --- a/lib/modules/publishing/README.md +++ /dev/null @@ -1,1507 +0,0 @@ -# Publishing Module - -The PhoenixKit Publishing module provides a database-backed content management system with multi-language support and dual URL modes (slug-based or timestamp-based). Posts are stored in PostgreSQL via four normalized tables (`publishing_groups`, `publishing_posts`, `publishing_versions`, `publishing_contents`) with UUIDv7 primary keys. - -## Quick Links - -- **Admin Interface**: `/{prefix}/admin/publishing` -- **Public Content**: `/{prefix}/{language}/{group-slug}` (listing) or `/{prefix}/{group-slug}` (single-language) -- **Settings**: Configure via `publishing_public_enabled` and `publishing_posts_per_page` in Settings -- **Enable Module**: Activate via Admin → Modules or run `PhoenixKit.Modules.Publishing.enable_system/0` -- **Cache Settings**: Toggle `publishing_memory_cache_enabled` and `publishing_render_cache_enabled[_]` -- **What it ships**: Listing cache, render cache, collaborative editor, public fallback routing, and optional per-post version history - -## Public Content Display - -The publishing module includes public-facing routes for displaying published posts to site visitors. - -### Public URLs - -**Multi-language mode:** -``` -/{prefix}/{language}/{group-slug} # Group post listing -/{prefix}/{language}/{group-slug}/{post-slug} # Slug mode post -/{prefix}/{language}/{group-slug}/{post-slug}/v/{version} # Versioned slug-mode post -/{prefix}/{language}/{group-slug}/{date} # Timestamp mode (date-only shortcut) -/{prefix}/{language}/{group-slug}/{date}/{time} # Timestamp mode post -``` - -**Single-language mode** (when only one language is enabled): -``` -/{prefix}/{group-slug} # Group post listing -/{prefix}/{group-slug}/{post-slug} # Slug mode post -/{prefix}/{group-slug}/{post-slug}/v/{version} # Versioned slug-mode post -/{prefix}/{group-slug}/{date} # Timestamp mode (date-only shortcut) -/{prefix}/{group-slug}/{date}/{time} # Timestamp mode post -``` - -**Examples** (assuming `{prefix}` is `/phoenix_kit`): -- `/phoenix_kit/en/docs` - Lists all published posts in the Docs group (English) -- `/phoenix_kit/en/docs/getting-started` - Shows specific post (slug mode) -- `/phoenix_kit/en/news/2025-11-02/14:30` - Shows specific post (timestamp mode) -- `/phoenix_kit/en/news/2025-11-02` - Date-only timestamp URL (auto-resolves to the first published time) -- `/phoenix_kit/docs` - Single-language mode listing -- `/phoenix_kit/news/2025-11-02` - Date-only timestamp URL (renders if single post exists, otherwise redirects to the first time slot on that date) - -### Features - -- **Status-Based Access Control** - Only `status: published` posts are visible -- **Markdown Rendering** - GitHub-style markdown CSS with syntax highlighting -- **Language Support** - Multi-language posts with language switcher -- **Content-Based Language Detection** - Custom language content records work without predefinition -- **Flexible Fallbacks** - Missing language versions redirect to available alternatives -- **Pagination** - Configurable posts per page (default: 20) -- **SEO Ready** - Clean URLs, breadcrumbs, responsive design -- **Performance** - Content-hash-based caching with versioned keys (`v1:publishing_post:...`) - -### Language Detection - -The publishing module uses a multi-step detection process to determine if a URL segment is a language code or a group slug: - -**Detection Flow (`detect_language_or_group`):** -1. **Enabled language** - If the segment matches an enabled language code (e.g., `en`, `fr-CA`), treat as language -2. **Base code mapping** - If it's a 2-letter code that maps to an enabled dialect (e.g., `en` → `en-US`), treat as language -3. **Known language pattern** - If it matches a predefined language code (even if disabled), treat as language -4. **Content-based check** - If content exists for this language in the requested group, treat as language -5. **Default** - Otherwise, treat as a group slug and use the default language - -**Supported Language Types:** -- **Predefined Languages** - Languages configured in the Languages module (e.g., `en`, `fr`, `es`) -- **Content-Based Languages** - Any content record with a language code is treated as a valid language - -This allows custom language content records (e.g., Afrikaans `af`) to work correctly even if not predefined in the Languages module. In the **admin interface**, the language switcher shows these with a strikethrough to indicate they're not officially enabled. In the **public display**, only enabled languages appear in the language switcher, but custom language URLs remain accessible via direct link. - -**Single-Language Mode:** -When only one language is enabled, URLs don't require the language segment: -- `/phoenix_kit/docs/getting-started` works the same as `/phoenix_kit/en/docs/getting-started` - -### Fallback Behavior - -Fallbacks are triggered when posts are missing (`:post_not_found`, `:unpublished`) **and** when a group -slug is invalid (`:group_not_found`). Server errors or other reasons still render the standard 404 page. - -**For slug-mode posts (`/{prefix}/en/docs/getting-started`):** -1. Try other languages for the same post (default language first) -2. If no published language versions exist, redirect to group listing - -**For timestamp-mode posts (`/{prefix}/en/news/2025-12-24/15:30`):** -1. Try other languages for the same date/time -2. Try other times on the same date -3. If no posts on that date, redirect to group listing - -**Fallback Priority:** -The system tries languages in this order: -1. Default language (from Settings) -2. Other available languages (alphabetically sorted) - -**User Experience:** -- Redirects include a flash message: "The page you requested was not found. Showing closest match." -- Bookmarked URLs continue to work even if specific translations are removed -- Users are never shown a 404 if any published version of the content exists -- Invalid group slugs fall back to the default group listing (if one exists) before showing a 404 - -### Configuration - -Enable/disable public content display and set pagination programmatically: - -```elixir -# Enable public content routes (default: true) -PhoenixKit.Settings.update_setting("publishing_public_enabled", "true") - -# Set posts per page in listings (default: 20) -PhoenixKit.Settings.update_setting("publishing_posts_per_page", "20") -``` - -`publishing_public_enabled` gates the entire `PhoenixKit.Modules.Publishing.Web.Controller` – set it to `"false"` to return a 404 for every public content route. `publishing_posts_per_page` drives listing pagination. - -**Note:** These settings are currently only configurable via code. There is no admin UI for these options yet; expose them in your app if customers need runtime control. - -### Templates - -Public templates are located in: - -- `lib/modules/publishing/web/templates/show.html.heex` - Single post view -- `lib/modules/publishing/web/templates/index.html.heex` - Group listing - -### Admin Integration - -When editing a post in the admin interface: - -- **View Public** button appears for published posts -- Button links directly to the public URL -- Automatically updates when status changes to "published" - -### Caching - -PhoenixKit ships two cache layers: - -1. **Listing cache** – `PhoenixKit.Modules.Publishing.ListingCache` stores parsed listing data - in `:persistent_term` for sub-microsecond reads. Memory caching can be toggled via the - `publishing_memory_cache_enabled` setting or from the Publishing Settings UI, which also - offers regenerate/clear actions per group. -2. **Render cache** – `PhoenixKit.Modules.Publishing.Renderer` stores rendered HTML for published posts in the - `:publishing_posts` cache (6-hour TTL) with content-hash keys, a global - `publishing_render_cache_enabled` toggle, and per-group overrides (`publishing_render_cache_enabled_`) - plus UI buttons to clear stats or individual group caches. - -Example render cache key: `v1:publishing_post:docs:getting-started:en:a1b2c3d4` - -Manual cache operations remain available when scripting: - -```elixir -alias PhoenixKit.Modules.Publishing.ListingCache - -ListingCache.regenerate("my-blog") -ListingCache.invalidate("my-blog") -ListingCache.read("my-blog") -ListingCache.exists?("my-blog") - -# Context helpers that wrap ListingCache -PhoenixKit.Modules.Publishing.regenerate_cache("my-blog") -PhoenixKit.Modules.Publishing.invalidate_cache("my-blog") - -alias PhoenixKit.Modules.Publishing.Renderer - -Renderer.clear_group_cache("my-blog") -Renderer.clear_all_cache() -``` - -## Architecture Overview - -**Core Modules:** - -- **PhoenixKit.Modules.Publishing** – Main context module with mode-aware routing (all writes are DB-only) -- **PhoenixKit.Modules.Publishing.DBStorage** – Database CRUD layer for groups, posts, versions, and contents -- **PhoenixKit.Modules.Publishing.DBStorage.Mapper** – Converts DB records to the post map format consumed by web layer -- **PhoenixKit.Modules.Publishing.Metadata** – Metadata parsing and serialization - -**Schemas (V59 migration):** - -- **PhoenixKit.Modules.Publishing.PublishingGroup** – Publishing group schema (name, slug, mode, data JSONB) -- **PhoenixKit.Modules.Publishing.PublishingPost** – Post schema (group FK, slug, status, mode, data JSONB) -- **PhoenixKit.Modules.Publishing.PublishingVersion** – Version schema (post FK, version_number, status, data JSONB) -- **PhoenixKit.Modules.Publishing.PublishingContent** – Content/translation schema (version FK, language, title, content, url_slug, data JSONB) - -**Admin Interfaces:** - -- **PhoenixKit.Modules.Publishing.Web.Index** – Publishing groups overview with skeleton loading on tab switch -- **PhoenixKit.Modules.Publishing.Web.Listing** – Post listing with inline status controls, skeleton loading, and trash management -- **PhoenixKit.Modules.Publishing.Web.Settings** – Admin interface for group configuration -- **PhoenixKit.Modules.Publishing.Web.Editor** – Markdown editor with autosave, featured images, skeleton loading on language switch, and clear translation button -- **PhoenixKit.Modules.Publishing.Web.Preview** – Live preview for posts - -**Public Display:** - -- **PhoenixKit.Modules.Publishing.Web.Controller** – Public-facing routes for group listings and posts -- **PhoenixKit.Modules.Publishing.Web.HTML** – HTML helpers and view functions for public content - -**Rendering & Caching:** - -- **PhoenixKit.Modules.Publishing.ListingCache** – Memory listing cache -- **PhoenixKit.Modules.Publishing.Renderer** – Markdown/PHK rendering with content-hash caching - -**Collaborative Editing:** - -- **PhoenixKit.Modules.Publishing.Presence** – Phoenix.Presence for real-time user tracking -- **PhoenixKit.Modules.Publishing.PresenceHelpers** – Owner/spectator logic helpers -- **PhoenixKit.Modules.Publishing.PubSub** – Real-time change broadcasting - -**Workers:** - -- **MigratePrimaryLanguageWorker** – Ensures primary language metadata consistency - -## Core Features - -- **Dual URL Modes** – Timestamp-based (date/time URLs) or slug-based (semantic URLs) -- **Mode Immutability** – URL mode locked at group creation, cannot be changed -- **Slug Mutability** – Post slugs can be changed after creation (DB update, no file movement) -- **Multi-Language Support** – Separate content records per language, all stored in `publishing_contents` table -- **Database Storage** – All reads and writes go to PostgreSQL -- **Markdown Content** – Full Markdown support with syntax highlighting -- **JSONB Metadata** – Flexible metadata via `data` JSONB columns on all four schemas -- **Backward Compatibility** – Legacy groups without mode field default to "timestamp" - -## Admin UI Features - -### Inline Status Control - -Posts on the listing page have a status dropdown (Draft/Published/Archived) next to the -primary language badge. Changing status keeps you in the current tab — the post updates -in place with the new status color. Status tab counts update automatically. - -### Skeleton Loading - -All tab switches and language switches show animated skeleton placeholders while loading: -- **Index page**: Group card skeletons when switching Active/Trash tabs -- **Listing page**: Post card skeletons when switching status tabs -- **Editor**: Content area skeleton when switching between language translations - -Skeletons use `bg-base-200` with `animate-pulse` (not DaisyUI's `skeleton` class, which -depends on `--color-base-300` that resolves to white in some PhoenixKit themes). - -### Trash Management - -- **Trashing**: Posts are soft-deleted (status set to "trashed"). Trashed posts are excluded - from all public-facing queries (URL slugs, timestamp lookups, listing cache). -- **Empty posts**: Posts with no content across any version are automatically hard-deleted - by the stale fixer (prevents the restore → auto-trash loop). -- **Trash tab**: Shows trashed posts with full metadata (titles, languages) but all elements - are non-interactive (dimmed languages, no clickable titles, no public URL). -- **Restore**: Button next to primary language badge. Restores post as "draft" status. - -### Clear Translation - -Non-primary language translations can be cleared via a button in the editor sidebar. -This hard-deletes the content row from the database (not a soft-delete/archive). -The language disappears from the post and can be re-added later. The button appears -whenever a content row exists in the DB, regardless of whether it has body text. - -## URL Modes - -Each publishing group has an immutable URL mode that determines how post URLs are structured. The mode is set at creation and cannot be changed afterward. Both modes store data identically in the database. - -### 1. Timestamp Mode (Default, Legacy) - -Posts addressed by publication date and time: - -``` -/{prefix}/{language}/{group}/{YYYY-MM-DD}/{HH:MM} -``` - -**Characteristics:** -- URL auto-generated from `published_at` timestamp -- No slug field in editor UI -- Ideal for chronological content (news, announcements, changelogs) -- URL path cannot be manually controlled by user - -**Example URL:** `/phoenix_kit/en/news/2025-01-15/09:30` - -### 2. Slug Mode (Semantic URLs) - -Posts addressed by semantic slug: - -``` -/{prefix}/{language}/{group}/{post-slug} -``` - -**Characteristics:** -- User-provided or auto-generated slug from title -- Slug field visible in editor UI -- Slug validation: lowercase letters, numbers, hyphens only -- Ideal for documentation, guides, evergreen content -- Slug can be changed (DB update, old URLs can redirect via `previous_url_slugs`) - -**Example URL:** `/phoenix_kit/en/docs/getting-started` - -## Content Format - -Post content is stored in the `publishing_contents` table as Markdown text with metadata tracked in the schema fields and `data` JSONB column. - -**Title Extraction:** - -The post title is **extracted from the first Markdown heading** (`# Title`) in the content body. This approach: -- Keeps the title visible in the content for authors -- Avoids duplication between metadata and content -- Makes the rendered output match the source - -**Metadata Fields:** - -- `slug` – Post slug (used for URL in slug mode) -- `status` – Publication status: `draft`, `published`, or `archived` -- `published_at` – Publication timestamp (ISO8601 format) -- `featured_image_uuid` – Optional reference to a featured image asset -- `description` – Optional post description/excerpt for SEO -- `version`, `version_created_at`, `version_created_from` – Managed automatically for versioned posts -- `allow_version_access` – Enables public viewing of historical versions when set to `true` - -**Audit Fields (optional):** - -- `created_at` – Creation timestamp (for audit purposes) -- `created_by_uuid` – User ID who created the post -- `created_by_email` – Email of user who created the post -- `updated_by_uuid` – User ID who last updated the post -- `updated_by_email` – Email of user who last updated the post - -**PHK Component Format** - -In addition to Markdown, post content can contain PHK components for structured page layouts: - -```html - - -# Introduction - -Regular **Markdown** content can be mixed with components. - -Hero image - - -``` - -Supported components: `Image`, `Hero`, `CTA`, `Headline`, `Subheadline`, `Video`, `EntityForm`. The renderer processes these via the PageBuilder system. - -## Context Layer API - -The main context module (`publishing.ex`) provides the public API. All writes go to the database via `DBStorage`: - -## Command-Line / IEx Usage - -PhoenixKit exposes the entire publishing system through the `PhoenixKit.Modules.Publishing` -module, so you can manage publishing groups from IEx or any script without touching the UI. This is extremely -useful when seeding sample content, migrating posts, or when an AI assistant has CLI access. - -### Bootstrapping a session - -```bash -$ iex -S mix -iex> alias PhoenixKit.Modules.Publishing -iex> alias PhoenixKit.Users.Auth.Scope -iex> Publishing.enable_system() -``` - -- `Publishing` is available anywhere via the alias above. -- `Scope` is optional but lets you stamp `created_by_*` / `updated_by_*` metadata. -- Module settings live in `PhoenixKit.Settings`. - -### Managing publishing groups - -```elixir -iex> {:ok, docs} = Publishing.add_group("Documentation", mode: "slug") -iex> Publishing.list_groups() -[%{"name" => "Documentation", "slug" => "documentation", "mode" => "slug"}] -iex> {:ok, group} = Publishing.get_group("documentation") -iex> {:ok, _} = Publishing.update_group("documentation", %{"name" => "Docs"}) -iex> Publishing.trash_group("documentation") -{:ok, "trash/documentation-2025-01-15-09-30-00"} -``` - -- `mode` must be `"slug"` or `"timestamp"` and is immutable after creation. -- Groups are stored in the `publishing_groups` DB table (and synced to Settings JSON for backward compatibility). - -### Creating scope-aware posts - -```elixir -iex> user = MyApp.Repo.get!(MyApp.Users.User, 123) -iex> scope = Scope.for_user(user) -iex> {:ok, post} = Publishing.create_post("documentation", %{title: "Intro", scope: scope}) -iex> {:ok, post} = Publishing.create_post("docs", %{title: "Intro", slug: "getting-started"}) -iex> {:ok, post} = Publishing.create_post("news", %{scope: Scope.for_user(nil)}) -``` - -- Slug mode expects a title (auto-slug) or explicit `:slug`. -- Timestamp mode ignores slug and uses current UTC time for the URL. -- `scope` is optional; pass `Scope.for_user(nil)` for system automation. -- Replace `MyApp.*` with your host application's modules/Repo. - -### Reading and updating posts - -```elixir -iex> {:ok, post} = Publishing.read_post("docs", "getting-started") -iex> {:ok, post_es} = Publishing.read_post("docs", "getting-started", "es") -iex> {:ok, updated} = Publishing.update_post("docs", post, %{"content" => "# v2"}, scope: scope) -``` - -- Slug-mode identifiers can include versions, e.g. `"getting-started"` with version number. -- Timestamp-mode identifiers are `"YYYY-MM-DD/HH:MM"` paths. -- `update_post/4` updates the DB record directly; slug changes are tracked via `previous_url_slugs`. - -### Versioning and translations - -```elixir -iex> {:ok, draft_v2} = Publishing.create_version_from("docs", "getting-started", 1, %{"content" => "..."}) -iex> :ok = Publishing.publish_version("docs", "getting-started", 2) -iex> {:ok, spanish} = Publishing.add_language_to_post("docs", "getting-started", "es") -iex> :ok = Publishing.delete_language("docs", "getting-started", "fr") -iex> :ok = Publishing.delete_version("docs", "getting-started", 1) -``` - -- `create_version_from/5` creates a new version by copying from source (or blank if `nil`); publish with `publish_version/3`. -- Languages are stored as separate `publishing_contents` rows sharing the same version. - -### Cache helpers - -```elixir -iex> posts = Publishing.list_posts("docs") -iex> :ok = Publishing.regenerate_cache("docs") -iex> {:ok, cached} = Publishing.find_cached_post("docs", "getting-started") -iex> {:ok, _} = Publishing.trash_post("docs", "getting-started") -``` - -- Listing cache uses `:persistent_term` for sub-microsecond reads. -- `trash_post/2` performs a DB soft-delete (sets `status` to `"trashed"`). Trashed posts can be restored from the admin trash tab. -- Empty posts (no content in any version) are automatically hard-deleted by the stale fixer on the next listing page load. - -### Group Management - -```elixir -# Create group with URL mode -{:ok, group} = Publishing.add_group("Documentation", mode: "slug") -{:ok, group} = Publishing.add_group("Company News", mode: "timestamp") - -# With custom slug -{:ok, group} = Publishing.add_group("My API Docs", mode: "slug", slug: "api-docs") - -# List all groups (includes mode field) -groups = Publishing.list_groups() -# => [%{"name" => "Docs", "slug" => "docs", "mode" => "slug"}, ...] - -# Get group URL mode -mode = Publishing.get_group_mode("docs") # => "slug" - -# Update group name/slug -{:ok, group} = Publishing.update_group("docs", %{"name" => "New Name", "slug" => "new-docs"}) - -# Remove group from settings list -{:ok, _} = Publishing.remove_group("docs") - -# Trash group (soft-delete in DB, removes from settings) -{:ok, _} = Publishing.trash_group("docs") - -# Get group name from slug -name = Publishing.group_name("docs") # => "Documentation" - -# Slug utilities -slug = Publishing.slugify("My First Post!") # => "my-first-post" -Publishing.valid_slug?("my-slug") # => true -Publishing.valid_slug?("en") # => false (reserved language code) - -# Slug validation with error reason -{:ok, "hello-world"} = Publishing.validate_slug("hello-world") -{:error, :invalid_format} = Publishing.validate_slug("Hello World") -{:error, :reserved_language_code} = Publishing.validate_slug("en") - -# Check if slug exists and generate unique slugs -Publishing.slug_exists?("docs", "getting-started") # => true/false -{:ok, slug} = Publishing.generate_unique_slug("docs", "Getting Started") -# => {:ok, "getting-started"} or {:ok, "getting-started-1"} if exists - -# Language utilities -Publishing.enabled_language_codes() # => ["en", "es", "fr"] -Publishing.get_primary_language() # => "en" -Publishing.language_enabled?("en", ["en-US", "es"]) # => true -Publishing.get_display_code("en", ["en-US", "es"]) # => "en-US" -Publishing.order_languages_for_display(["fr", "en"], ["en", "es"]) -# => ["en", "fr"] (enabled first, then others) - -# Language info -info = Publishing.get_language_info("en") -# => %{code: "en", name: "English", flag: "🇺🇸"} -``` - -### Post Operations - -The context layer routes all writes to the database: - -```elixir -# Create post (DB insert, routes by group mode for URL generation) -{:ok, post} = Publishing.create_post("docs", %{title: "Hello World"}) -# Slug mode: auto-generates slug "hello-world" -# Timestamp mode: uses current date/time for URL - -# Create post with explicit slug and audit trail (slug mode only) -{:ok, post} = Publishing.create_post("docs", %{ - title: "Getting Started", - slug: "get-started", - scope: current_user_scope # Optional: records created_by_uuid/email -}) - -# List posts (routes by group mode) -posts = Publishing.list_posts("docs") -posts = Publishing.list_posts("docs", "es") # With language preference - -# Read post (routes by group mode) -{:ok, post} = Publishing.read_post("docs", "getting-started") -{:ok, post} = Publishing.read_post("docs", "getting-started", "es") - -# Update post (DB update) -{:ok, updated} = Publishing.update_post("docs", post, %{ - "title" => "Updated Title", - "slug" => "new-slug", # Slug mode: updates DB, tracks old slug for redirects - "content" => "Updated content..." -}, scope: current_user_scope) # Optional 4th arg: records updated_by_uuid/email - -# Add translation -{:ok, spanish_post} = Publishing.add_language_to_post("docs", "getting-started", "es") -``` - -### Delete Operations - -Posts use soft-delete (setting `status` to `"trashed"`). Trashed posts are excluded from -public access and can be restored from the admin trash tab. Empty posts (no content in any -version) are automatically hard-deleted by the stale fixer. - -Translations are hard-deleted (the content row is removed from the DB), so the language -disappears from the post entirely. A new translation can be added later. - -```elixir -# Trash post (soft-delete — sets status to "trashed") -{:ok, _} = Publishing.trash_post("docs", "getting-started") - -# For timestamp mode, use the post UUID -{:ok, _} = Publishing.trash_post("news", post_uuid) - -# Archive a translation (soft-delete — sets status to "archived", refuses if last language) -:ok = Publishing.delete_language("docs", post_uuid, "es") -:ok = Publishing.delete_language("docs", post_uuid, "es", 2) # specific version -{:error, :last_language} = Publishing.delete_language("docs", post_uuid, "en") - -# Hard-delete a translation (permanently removes the content row) -:ok = Publishing.clear_translation("docs", post_uuid, "es") - -# Archive a version (refuses if live or last active version) -:ok = Publishing.delete_version("docs", post_uuid, 1) -{:error, :cannot_delete_live} = Publishing.delete_version("docs", post_uuid, 2) -{:error, :last_version} = Publishing.delete_version("docs", post_uuid, 1) -``` - -### Versioning Operations - -```elixir -# List all versions of a post -versions = Publishing.list_versions("docs", "getting-started") -# => [1, 2, 3] - -# Get specific version info -{:ok, 3} = Publishing.get_latest_version("docs", "getting-started") -{:ok, 2} = Publishing.get_published_version("docs", "getting-started") - -# Get version metadata -{:ok, metadata} = Publishing.get_version_metadata("docs", "getting-started", 1, "en") - -# Create new version from existing post -# Create a new version from an existing version (branching) -{:ok, new_post} = Publishing.create_version_from("docs", "getting-started", 1, %{ - "content" => "Updated content..." -}, scope: current_user_scope) - -# Create a blank new version -{:ok, new_post} = Publishing.create_version_from("docs", "getting-started", nil, %{}, - scope: current_user_scope) - -# For timestamp mode, use the date/time path as identifier -{:ok, new_post} = Publishing.create_version_from("news", "2025-01-15/14:30", 1, %{}, - scope: current_user_scope) - -# Publish a version (archives all other published versions) -:ok = Publishing.publish_version("docs", "getting-started", 2) - -# Get the currently published version -{:ok, version} = Publishing.get_published_version("docs", "getting-started") - -# Helpers for version logic -Publishing.content_changed?(post, params) # => true/false -Publishing.status_change_only?(post, params) # => true/false -``` - -### Variant Versioning System - -Both slug-mode and timestamp-mode posts support **variant versioning** - versions are independent -attempts or drafts rather than sequential history. Only ONE version can be published at a time. - -**Key Concepts:** - -- **Radio-style publishing**: When you publish a version, all other published versions are - automatically archived. Only the newly published version is visible to the public. -- **Versions are editable**: Unlike historical versioning, all versions remain editable regardless - of status. You can modify drafts, archived versions, or even the published version. -- **Branching**: Create new versions by copying from an existing version or starting blank. -- **Translation inheritance**: When publishing, all translations inherit the primary language's status. - -**Creating New Versions:** - -1. Open any post in the editor -2. Click the **"New Version"** button next to the version switcher -3. Choose to copy from an existing version or start blank -4. The new version is created as a draft - -**Publishing a Version:** - -1. Open the version you want to publish -2. Change status to "Published" and save -3. All other published versions are automatically archived -4. The public URL now shows this version's content - -**Version Statuses:** - -- `published` - The live version visible to the public (only ONE per post) -- `draft` - Work in progress, not visible publicly -- `archived` - Previously published or intentionally hidden versions - -**Editor UI:** - -The editor provides a complete version management interface: - -- **Version Switcher** - Dropdown showing all versions with status indicators (green=published, yellow=draft, gray=archived) -- **New Version Button** - Opens a modal to create a new version -- **New Version Modal** - Choose to start blank or copy from any existing version -- **Status Dropdown** - Change status directly in the metadata panel - -**Translation Status Inheritance:** - -Translations always follow the primary language's status: - -- When the primary language status changes, all translations are updated to match -- Users can temporarily change a translation's status (e.g., set to "draft" while reviewing) -- However, the next primary status change will reset all translations to match -- Translations can only be changed to "draft" or "archived" if the primary is already published - -Example workflow: -1. Primary (English) is published with v2 → all translations become "published" -2. French translation has an issue, translator sets it to "draft" temporarily -3. When English v3 is published, French becomes "published" again (along with all translations) - -**Public URLs:** - -Public URLs always show the published version's content: -- `/{prefix}/{language}/{group}/{post}` - Shows the published version - -**Version Browsing (Opt-in):** - -By default, only the published version is accessible. To enable public access to older -published versions, set `allow_version_access: true` in the post's metadata (stored in the `data` JSONB column). - -When enabled, versioned URLs become accessible: -- `/{prefix}/{language}/{group}/{post}/v/{version}` - Direct version access - -The version dropdown appears on the public post page, showing all published versions. -This is useful for documentation sites where users may need to reference older versions. - -### Cache Operations - -```elixir -# Regenerate listing cache (called automatically on post changes) -:ok = Publishing.regenerate_cache("docs") - -# Invalidate (delete) cache -:ok = Publishing.invalidate_cache("docs") - -# Check if cache exists -Publishing.cache_exists?("docs") # => true/false - -# Fast post lookup from cache (O(1) via :persistent_term) -{:ok, post_data} = Publishing.find_cached_post("docs", "getting-started") -{:ok, post_data} = Publishing.find_cached_post_by_path("news", "2025-01-15", "14:30") -``` - -## Storage Architecture - -### DB Storage (Primary — All Writes) - -All create/update/delete operations go through `DBStorage`: - -```elixir -alias PhoenixKit.Modules.Publishing.DBStorage - -# Posts -{:ok, post} = DBStorage.create_post(%{group_uuid: group_uuid, slug: "hello", status: "draft", mode: "slug"}) -post = DBStorage.get_post("docs", "hello") # excludes trashed posts -{:ok, updated} = DBStorage.update_post(post, %{status: "published"}) -{:ok, _} = DBStorage.trash_post(post) # soft-delete (status → "trashed") -{:ok, _} = DBStorage.delete_post(post) # hard-delete (cascade removes versions/contents) - -# Versions -{:ok, version} = DBStorage.create_version(post_uuid, %{version_number: 1, status: "draft"}) -versions = DBStorage.list_versions(post_uuid) -{:ok, version} = DBStorage.get_version(post_uuid, 1) - -# Contents (translations) -{:ok, content} = DBStorage.create_content(version_uuid, %{language: "en", title: "Hello", content: "# Hello"}) -{:ok, content} = DBStorage.get_content(version_uuid, "en") -contents = DBStorage.list_contents(version_uuid) -``` - -The `Mapper` module converts DB records to the post map format that the web layer and templates consume: - -```elixir -alias PhoenixKit.Modules.Publishing.DBStorage.Mapper - -post_map = Mapper.to_post_map(group, post, version, content, available_languages) -listing_map = Mapper.to_listing_map(group, post, version, primary_content) -``` - -## LiveView Interfaces - -### Settings (`settings.ex`) - -Group configuration interface at `{prefix}/admin/settings/publishing`: - -- Create new groups with mode selector -- View existing groups with mode badges -- Delete groups -- Configure public display settings - -**Group Creation (New Group Form):** -- Mode selector: Radio buttons (Timestamp / Slug) -- Warning text: "Cannot be changed after group creation" -- Mode is locked permanently after creation - -### Editor (`editor.ex`) - -Markdown editor at `{prefix}/admin/publishing/{group}/edit`: - -- Title input (all modes) -- **Slug input** (slug mode only, with validation) -- Status selector (draft/published/archived) -- Published at timestamp picker -- Featured image selector (integrates with Media module) -- Markdown editor with preview -- Language switcher for translations - -**Autosave:** - -The editor automatically saves changes after 2 seconds of inactivity: -- Debounced saves prevent excessive writes -- Status indicator shows: "Saving...", "Saved", or error state -- Dirty detection tracks unsaved changes -- Navigation warnings when leaving with unsaved changes - -**Featured Images:** - -Posts can have an optional featured image: -- Click "Select Featured Image" to open the media picker -- Preview displays below the image selector -- Click "Clear" to remove the featured image -- Stored as `featured_image_uuid` in metadata - -**Migration Gate:** - -The editor requires the V59 database migration to be applied. The editor is available immediately on fresh installs. - -**Mode-Specific Behavior:** - -**Timestamp Mode:** -- No slug field visible -- URL auto-generated from `published_at` - -**Slug Mode:** -- Slug field visible with validation -- Auto-generates slug from title (debounced) -- User can override auto-generated slug -- Validation: lowercase, numbers, hyphens only -- **Reserved slugs**: Any language code from the Languages module cannot be used as a slug to prevent routing ambiguity -- Shows validation error for invalid slugs - -### Preview (`preview.ex`) - -Live preview at `{prefix}/admin/publishing/{group}/preview`: - -- Renders Markdown content with Phoenix.Component -- Shows metadata preview (title, status, published date) -- Language switcher for viewing translations - -### Collaborative Editing - -The editor uses Phoenix.Presence to coordinate multiple users editing the same post. - -**Owner/Spectator Model:** - -1. First user to open a post becomes the **owner** (full edit access) -2. Subsequent users become **spectators** (read-only mode) -3. When the owner leaves, the next spectator auto-promotes to owner -4. All users see who else is viewing the post in real-time - -**How It Works:** - -- Users join a Presence topic (e.g., `publishing_edit:group-slug:post-slug`) -- Users sorted by `joined_at` timestamp (FIFO ordering) -- First user in sorted list = owner (`readonly?: false`) -- All other users = spectators (`readonly?: true`) -- Phoenix.Presence auto-cleans disconnected users - -**UI Indicators:** - -- Spectator mode shows a banner: "Another user is currently editing this post" -- Users see avatars/names of other connected editors -- Read-only mode disables form inputs and save button - -**Files:** - -- `presence.ex` – Phoenix.Presence configuration -- `presence_helpers.ex` – Helper functions for owner/spectator logic -- `editor.ex` – Presence integration in the editor LiveView - -## Multi-Language Support - -Every post version can have multiple language translations stored as separate `publishing_contents` rows: - -``` -publishing_contents table: - version_uuid | language | title | content | url_slug - abc-123 | en | Getting Started | # Getting… | getting-started - abc-123 | es | Primeros Pasos | # Primeros | primeros-pasos - abc-123 | fr | Prise en Main | # Prise… | prise-en-main -``` - -**Workflow:** - -1. Create primary post (e.g., English) -2. Click language switcher → Select "Add Spanish" -3. System creates a new content record with empty content and title -4. Fill in translated content and save -5. All translations share same post/version, each with its own `url_slug` - -**Post Map Fields:** - -The `Mapper` module converts DB records into this map format consumed by templates and the web layer: - -```elixir -%{ - group: "docs", # Publishing group slug - slug: "getting-started", # Slug mode only - uuid: "01234567-...", # UUIDv7 from DB - date: ~D[2025-01-15], # Timestamp mode only - time: ~T[09:30:00], # Timestamp mode only - path: "docs/getting-started/v1/en", # Virtual path for compatibility - metadata: %{ - title: "Getting Started", - status: "published", # "published", "draft", or "archived" - slug: "getting-started", - published_at: "2025-01-15T09:30:00Z", - created_at: "2025-01-15T09:30:00Z", - version: 1, - version_created_at: "2025-01-15T09:30:00Z", - version_created_from: nil # Source version when branching - }, - content: "# Markdown content...", - language: "en", - available_languages: ["en", "es", "fr"], - language_statuses: %{"en" => "published", "es" => "draft", "fr" => "published"}, - mode: :slug, # :slug or :timestamp - version: 1, # Current version number - available_versions: [1, 2, 3], # All versions for this post - version_statuses: %{1 => "archived", 2 => "archived", 3 => "published"} -} -``` - -**Note on `uuid`:** All posts have a non-nil `uuid` field. The helper `Publishing.db_post?(post)` checks this. - -## AI Translation - -The Publishing module integrates with the AI module to provide automated translation of posts to multiple languages using an Oban background job. - -### Prerequisites - -1. **AI Module Enabled**: The AI module must be enabled (`PhoenixKit.Modules.AI.enable_system()`) -2. **AI Endpoint Configured**: At least one AI endpoint must be configured with a capable model -3. **Languages Enabled**: The Languages module should have multiple languages enabled - -### Editor UI - -When prerequisites are met, a collapsible **AI Translation** panel appears in the post editor (for primary language posts only): - -1. Open any post in the primary language -2. Expand the "AI Translation" section (marked with Beta badge) -3. Select an AI endpoint from the dropdown -4. Click one of the translation buttons: - - **Translate All Languages** - Translates to ALL enabled languages - - **Translate Missing Only** - Only translates languages that don't have a content record yet - -The translation runs as a background job. Progress can be monitored in the Oban dashboard or via logs. - -### Quick Start (Programmatic) - -```elixir -# Translate a post to all enabled languages -{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started", - endpoint_id: 1 -) - -# Translate to specific languages only -{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started", - endpoint_id: 1, - target_languages: ["es", "fr", "de"] -) - -# Translate a specific version -{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started", - endpoint_id: 1, - version: 2 -) -``` - -### Configuration - -Set a default AI endpoint for translations (optional): - -```elixir -PhoenixKit.Settings.update_setting("publishing_translation_endpoint_id", "1") -``` - -With a default endpoint configured, you can omit the `endpoint_id` option: - -```elixir -{:ok, job} = Publishing.translate_post_to_all_languages("docs", "getting-started") -``` - -### How It Works - -1. **Job Enqueued**: An Oban job is created in the `:default` queue -2. **Source Read**: The primary language content is read from the specified post -3. **AI Translation**: For each target language, the content is sent to the AI with a translation prompt -4. **Records Created**: Translation content records are created or updated in the database -5. **Cache Updated**: The listing cache is regenerated to include new translations - -### Translation Features - -**Format Preservation:** -- The AI preserves the EXACT formatting of the original content -- If the original has `# headings`, translations keep them; if not, they don't add them -- All Markdown formatting is preserved (bold, italic, lists, code blocks, links) -- Line breaks and spacing are maintained -- Code blocks and inline code are NOT translated - -**URL Slug Translation:** -- The AI generates a localized URL slug for each translation -- Example: `getting-started` → `primeros-pasos` (Spanish) -- Slugs are automatically sanitized (lowercase, hyphens, no special characters) -- See [Per-Language URL Slugs](#per-language-url-slugs) for more details - -**Title Extraction:** -- The AI extracts and translates the title separately -- Title is stored in metadata for listings and SEO -- Original document structure is preserved - -### Translation Prompt - -The worker uses a built-in translation prompt that instructs the AI to: -- Preserve exact formatting (headings, spacing, structure) -- Keep Markdown syntax intact -- Not translate code blocks or inline code -- Translate naturally and idiomatically -- Generate SEO-friendly URL slugs in the target language - -### Options - -| Option | Type | Description | -|--------|------|-------------| -| `endpoint_id` | integer | AI endpoint ID (required if not set in settings) | -| `source_language` | string | Source language code (defaults to primary language) | -| `target_languages` | list | Target language codes (defaults to all enabled except source) | -| `version` | integer | Version number to translate (defaults to latest) | -| `user_id` | integer | User ID for audit trail | - -### Job Monitoring - -Translation jobs can be monitored via: -- **Oban Dashboard**: View job status, retries, and errors -- **Jobs Module**: Enable at `/{prefix}/admin/modules` → Jobs -- **Logs**: Jobs log progress and errors with `[TranslatePostWorker]` prefix - -Example log output: -``` -[TranslatePostWorker] Starting translation of docs/getting-started from en to 5 languages -[TranslatePostWorker] Translating to es (Spanish)... -[TranslatePostWorker] AI call for es completed in 2341ms -[TranslatePostWorker] Got translated slug for es: primeros-pasos -[TranslatePostWorker] Creating new es translation -[TranslatePostWorker] Successfully translated to es -... -[TranslatePostWorker] Completed: 5 succeeded, 0 failed -``` - -### Error Handling - -- **Partial Failures**: If some languages fail, the job reports which languages succeeded and which failed -- **Retries**: Jobs retry up to 3 times with exponential backoff -- **Timeout**: Jobs have a 10-minute timeout for large posts or many languages -- **Language Fallback Protection**: The worker verifies each translation is saved to the correct language record (prevents overwriting primary) - -### Programmatic Usage - -```elixir -alias PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker - -# Create a job without inserting -job = TranslatePostWorker.create_job("docs", "getting-started", endpoint_id: 1) - -# Insert the job -{:ok, oban_job} = Oban.insert(job) - -# Or use the convenience function -{:ok, oban_job} = TranslatePostWorker.enqueue("docs", "getting-started", endpoint_id: 1) - -# Translate only missing languages -missing_langs = ["de", "ja", "zh"] # Languages without content records -{:ok, job} = TranslatePostWorker.enqueue("docs", "getting-started", - endpoint_id: 1, - target_languages: missing_langs -) -``` - -## Migration Path - -### Fresh Installs - -New installs start with database storage immediately. The editor is available right away after the V59 migration is applied. - -### Existing Groups (Pre-Dual-Mode) - -All existing groups automatically default to `"timestamp"` mode via `normalize_groups/1`: - -```elixir -# Before (legacy group without mode field) -%{"name" => "News", "slug" => "news"} - -# After (normalized with default mode) -%{"name" => "News", "slug" => "news", "mode" => "timestamp"} -``` - -No migration script needed – backward compatibility is automatic. - -### Creating New Groups - -Admin chooses mode at creation time: - -1. Navigate to `{prefix}/admin/publishing/settings` -2. Enter group name: "Documentation" -3. Select mode: **Slug** or **Timestamp** -4. Click "Add Group" -5. Mode is now permanently locked for this group - -## Test Coverage - -**Status:** 122+ unit tests across 6 test files (all pure-function, no database required). - -**Test files:** - -| File | Tests | Coverage | -|------|-------|----------| -| `test/modules/publishing/schema_test.exs` | 28 | All 4 schema changesets, JSONB accessors, defaults | -| `test/modules/publishing/metadata_test.exs` | 26 | parse/serialize round-trip, title extraction, legacy XML | -| `test/modules/publishing/mapper_test.exs` | 26 | `to_post_map`, `to_listing_map`, field mapping, edge cases | -| `test/modules/publishing/pubsub_test.exs` | 13 | Topic generation, form key generation | -| `test/modules/publishing/publishing_api_test.exs` | 20 | Module loading, slugify, valid_slug?, db_post?, extract helpers | -| `test/modules/publishing/storage_utils_test.exs` | 9 | content_changed?, status_change_only?, should_create_new_version? | - -**Running Tests:** - -```bash -# Run all publishing tests -mix test test/modules/publishing/ - -# Run a specific test file -mix test test/modules/publishing/schema_test.exs -``` - -**Testing Philosophy:** - -PhoenixKit is a library module. Unit tests cover pure functions, changesets, and data transformations. Integration tests requiring a database (CRUD operations, LiveView flows) are run in parent Phoenix applications. - -## Configuration - -Publishing module uses PhoenixKit Settings for configuration: - -```elixir -# Enable/disable publishing system -Publishing.enable_system() -Publishing.disable_system() -Publishing.enabled?() # => true/false - -# Publishing groups are stored in the database (publishing_groups table) - -# Cache toggles -PhoenixKit.Settings.update_setting("publishing_memory_cache_enabled", "true") - -# Render cache (global + per group) -PhoenixKit.Settings.update_setting("publishing_render_cache_enabled", "true") -PhoenixKit.Settings.update_setting("publishing_render_cache_enabled_docs", "false") - -# Custom settings backend (optional) -config :phoenix_kit, publishing_settings_module: MyApp.CustomSettings -``` - -### Database Tables - -All content is stored in PostgreSQL via the V59 migration tables: - -| Table | Purpose | -|-------|---------| -| `phoenix_kit_publishing_groups` | Publishing groups (name, slug, mode, data JSONB) | -| `phoenix_kit_publishing_posts` | Posts (group FK, slug, status, mode, published_at) | -| `phoenix_kit_publishing_versions` | Versions (post FK, version_number, status) | -| `phoenix_kit_publishing_contents` | Content/translations (version FK, language, title, content, url_slug) | - -All reads and writes use the database. The editor is available once the V59 migration is applied. - -## Best Practices - -### Choosing URL Mode - -**Use Timestamp Mode when:** -- Content is time-sensitive (news, announcements, changelogs) -- Chronological order is primary navigation pattern -- URLs should reflect publication date -- Posts are rarely renamed or restructured - -**Use Slug Mode when:** -- Content is evergreen (documentation, guides, tutorials) -- Semantic URLs improve SEO and user experience -- Posts may be reorganized or renamed over time -- URL structure matters for branding - -### Slug Design Guidelines - -**Good slugs:** -- `getting-started` – Clear, readable -- `api-authentication` – Descriptive -- `migrate-from-v1-to-v2` – Self-explanatory - -**Bad slugs:** -- `Getting Started` – Contains uppercase and spaces (invalid) -- `post-1` – Not descriptive -- `api_auth` – Uses underscores instead of hyphens (invalid) -- `article` – Too generic - -### Multi-Language Strategy - -1. **Always create English first** – Establish primary content structure -2. **Use consistent slugs** – All translations share the same slug/path -3. **Translate titles** – Each language content record has its own `# Title` heading -4. **Don't mix languages** – One language per content record -5. **Test translations** – Use language switcher in editor/preview - -## Troubleshooting - -### Problem: Slug validation fails with valid-looking slug - -**Symptoms:** -``` -Invalid slug format -``` - -**Root Cause:** - -Slug contains uppercase letters, underscores, or special characters. - -**Solution:** - -Use only lowercase letters, numbers, and hyphens. Avoid language codes: - -```elixir -# ✅ Valid slugs -"hello-world" -"api-v2-guide" -"2025-roadmap" - -# ❌ Invalid slugs -"Hello-World" # Uppercase -"api_guide" # Underscore -"guide!" # Special char -"my slug" # Space -"en" # Reserved language code -"fr" # Reserved language code -``` - ---- - -### Problem: Slug is a reserved language code - -**Symptoms:** -``` -Slug cannot be a reserved language code -``` - -**Root Cause:** - -The slug matches a language code defined in the Languages module (e.g., `en`, `es`, `fr-CA`). - -**Solution:** - -Choose a different slug. Language codes are reserved to prevent URL routing ambiguity between `/{prefix}/en/docs` (language + group) and a post with slug `en`. - ---- - -### Problem: Slug already exists - -**Symptoms:** -``` -A post with this slug already exists -``` - -**Root Cause:** - -Another post in the same group already uses this slug. - -**Solution:** - -Choose a unique slug or append a number (e.g., `my-post-2`). The auto-slug generator handles this automatically when creating new posts. - ---- - -### Problem: Editor is in read-only mode - -**Symptoms:** - -Form inputs are disabled and a banner says "Another user is currently editing this post". - -**Root Cause:** - -Another user opened the editor first and is the current "owner". The collaborative editing system only allows one person to edit at a time. - -**Solution:** - -Wait for the other user to leave, or coordinate with them. When they close the editor, you'll automatically become the owner and gain edit access. - ---- - -### Problem: Post not found after changing slug - -**Symptoms:** -``` -Post not found -``` - -**Root Cause:** - -Old links still reference the previous slug. - -**Solution:** - -Slug changes update the database record. Old URLs need redirects. Update any hardcoded links: - -```elixir -# Before slug change -Publishing.read_post("docs", "old-slug") - -# After slug change (from "old-slug" to "new-slug") -Publishing.read_post("docs", "new-slug") # ✅ Works -Publishing.read_post("docs", "old-slug") # ❌ Not found -``` - -Consider implementing redirects in your application for user-facing URLs. - ---- - -### Problem: Cannot change group mode - -**Symptoms:** - -Mode field is read-only in settings UI. - -**Root Cause:** - -Mode immutability is by design – URL mode is locked at group creation. - -**Solution:** - -To change modes, you must: - -1. Create a new group with the desired mode -2. Manually migrate posts (via IEx or scripts) to the new group -3. Update internal references -4. Delete old group - -**No automatic migration is provided** – this is an infrequent operation best done manually. - ---- - -### Problem: Cannot delete the last language - -**Symptoms:** -``` -{:error, :last_language} -``` - -**Root Cause:** - -Every post version must have at least one active language. You cannot archive the only remaining translation. - -**Solution:** - -Either add another translation first, or trash the entire post: - -```elixir -# Add another language first -{:ok, _} = Publishing.add_language_to_post("docs", "post", "es") -# Then delete the unwanted one -:ok = Publishing.delete_language("docs", "post", "en") - -# Or trash the entire post -{:ok, _} = Publishing.trash_post("docs", "post") -``` - ---- - -### Problem: Cannot delete the live version - -**Symptoms:** -``` -{:error, :cannot_delete_live} -``` - -**Root Cause:** - -The version you're trying to delete is currently the live (public-facing) version. - -**Solution:** - -Publish a different version first: - -```elixir -# Publish another version (this archives the current published version) -:ok = Publishing.publish_version("docs", "post", 2) -# Now delete the old version -:ok = Publishing.delete_version("docs", "post", 1) -``` - ---- - -### Problem: Cannot delete the last version - -**Symptoms:** -``` -{:error, :last_version} -``` - -**Root Cause:** - -Every post must have at least one active version. You cannot archive the only remaining version. - -**Solution:** - -Either create a new version first, or trash the entire post: - -```elixir -# Trash the entire post instead -{:ok, _} = Publishing.trash_post("docs", "post") -``` - -## Per-Language URL Slugs - -Each language translation can have its own SEO-friendly URL slug, enabling localized URLs for better search engine optimization and user experience. - -**Example:** -``` -# Each language has its own URL slug -/en/docs/getting-started → post slug: "getting-started", content url_slug: "getting-started" -/es/docs/primeros-pasos → post slug: "getting-started", content url_slug: "primeros-pasos" -/fr/docs/prise-en-main → post slug: "getting-started", content url_slug: "prise-en-main" -``` - -**Key Concepts:** - -1. **Post Slug = Internal Identifier** - The post's `slug` field in the database ties all translations together. This is the canonical identifier. - -2. **url_slug = Public URL** - Each translation's `publishing_contents` row has its own `url_slug` for the public-facing URL. - -3. **Backward Compatible** - If no `url_slug` is set, the post slug is used (existing behavior). - -### Setting Up Per-Language Slugs - -**In the Editor:** - -1. Open a translation (non-primary language) in the editor -2. Find the "URL Slug" field in the metadata panel (only visible for translations) -3. Enter a localized slug (e.g., `primeros-pasos` for Spanish) -4. Save - the URL immediately updates - -**In Database:** - -The `url_slug` is stored on the `publishing_contents` record. - -**Auto-Generation:** - -When creating or editing a translation, the URL slug is automatically generated from the content title (first `# Heading`). You can override this by manually typing in the URL Slug field. - -### URL Slug Validation - -URL slugs are validated before saving: - -| Rule | Example | Error | -|------|---------|-------| -| Lowercase, numbers, hyphens only | `Hello-World` | Invalid format | -| Cannot be a language code | `en`, `es`, `fr-CA` | Reserved language code | -| Cannot be a reserved route | `admin`, `api`, `assets` | Reserved route word | -| Must be unique per language | Duplicate in same group+language | Already in use | - -**Reserved Route Words:** `admin`, `api`, `assets`, `phoenix_kit`, `auth`, `login`, `logout`, `register`, `settings` - -### 301 Redirects for Changed Slugs - -When you change a URL slug, the old slug is automatically stored in `previous_url_slugs` (in the content record's `data` JSONB) for 301 redirects. - -**Redirect Behavior:** -- Old URLs automatically 301 redirect to the new URL -- Multiple previous slugs are supported -- Works even on cold starts (no cache) via database query - -**Example:** -``` -# User changed Spanish slug from "empezando" to "primeros-pasos" -GET /es/docs/empezando -→ 301 Redirect to /es/docs/primeros-pasos -``` - -### Language Switcher Integration - -The language switcher automatically shows localized URLs for each language: - -```html - -English -Español -Français -``` - -### Cache Structure - -The listing cache stores per-language slug mappings for O(1) lookups: - -```json -{ - "slug": "getting-started", - "language_slugs": { - "en": "getting-started", - "es": "primeros-pasos", - "fr": "prise-en-main" - }, - "language_previous_slugs": { - "es": ["empezando", "comenzar"], - "fr": ["demarrage"] - } -} -``` - -### Programmatic API - -```elixir -# Find post by URL slug (any language) -{:ok, post} = ListingCache.find_by_url_slug("docs", "es", "primeros-pasos") -# => Returns post with slug: "getting-started" - -# Find post by previous URL slug (for redirects) -{:ok, post} = ListingCache.find_by_previous_url_slug("docs", "es", "empezando") -# => Returns post so you can build redirect URL - -# Validate URL slug before saving -{:ok, "primeros-pasos"} = SlugHelpers.validate_url_slug("docs", "primeros-pasos", "es", "getting-started") -{:error, :slug_already_exists} = SlugHelpers.validate_url_slug("docs", "existing-slug", "es", nil) -``` - -### Cold Start Fallback - -On cold starts (no cache), the system queries the database to resolve URL slugs: - -1. Queries `publishing_contents` for matching `url_slug` or entries in `data->previous_url_slugs` -2. Returns redirect for previous slugs, resolution for current slugs - -This ensures localized URLs work immediately after deployment without waiting for cache warm-up. - -### SEO Benefits - -- **Localized URLs**: Search engines prefer URLs in the user's language -- **Better Click-Through**: Users are more likely to click localized URLs in search results -- **Proper Hreflang**: The `` tags use language-specific URLs -- **Canonical URLs**: Each translation has its own canonical URL with its localized slug - -## Future Refactoring Notes - -- **Rename translation functions**: `clear_translation` (hard delete) and `delete_language` (archive/soft delete) have counterintuitive names — "delete" sounds harder than "clear" but does less. Consider renaming to `hard_delete_translation` / `archive_translation` in a future cleanup pass. - -## Getting Help - -1. Review DB storage layer: `lib/modules/publishing/db_storage.ex` -2. Review context module: `lib/modules/publishing/publishing.ex` -3. Inspect post map in IEx: `{:ok, post} = Publishing.read_post("docs", "slug")` → `IO.inspect(post)` -4. Enable debug logging: `Logger.configure(level: :debug)` -5. Search GitHub issues: diff --git a/lib/modules/publishing/components/cta.ex b/lib/modules/publishing/components/cta.ex deleted file mode 100644 index aef206542..000000000 --- a/lib/modules/publishing/components/cta.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.CTA do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.CTA`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.CTA -end diff --git a/lib/modules/publishing/components/entity_form.ex b/lib/modules/publishing/components/entity_form.ex deleted file mode 100644 index c22f7d9b1..000000000 --- a/lib/modules/publishing/components/entity_form.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.EntityForm do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.EntityForm`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.EntityForm -end diff --git a/lib/modules/publishing/components/headline.ex b/lib/modules/publishing/components/headline.ex deleted file mode 100644 index c83a232e6..000000000 --- a/lib/modules/publishing/components/headline.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Headline do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Headline`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Headline -end diff --git a/lib/modules/publishing/components/hero.ex b/lib/modules/publishing/components/hero.ex deleted file mode 100644 index 99b496060..000000000 --- a/lib/modules/publishing/components/hero.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Hero do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Hero`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Hero -end diff --git a/lib/modules/publishing/components/image.ex b/lib/modules/publishing/components/image.ex deleted file mode 100644 index bee43193a..000000000 --- a/lib/modules/publishing/components/image.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Image do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Image`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Image -end diff --git a/lib/modules/publishing/components/page.ex b/lib/modules/publishing/components/page.ex deleted file mode 100644 index 964db972e..000000000 --- a/lib/modules/publishing/components/page.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Page do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Page`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Page -end diff --git a/lib/modules/publishing/components/subheadline.ex b/lib/modules/publishing/components/subheadline.ex deleted file mode 100644 index af9613fd6..000000000 --- a/lib/modules/publishing/components/subheadline.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Subheadline do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Subheadline`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Subheadline -end diff --git a/lib/modules/publishing/components/video.ex b/lib/modules/publishing/components/video.ex deleted file mode 100644 index c225b3a12..000000000 --- a/lib/modules/publishing/components/video.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Components.Video do - @moduledoc """ - Delegates to `PhoenixKit.Modules.Shared.Components.Video`. - - Kept for backward compatibility with external consumers. - """ - - defdelegate render(assigns), to: PhoenixKit.Modules.Shared.Components.Video -end diff --git a/lib/modules/publishing/constants.ex b/lib/modules/publishing/constants.ex deleted file mode 100644 index 73260deb7..000000000 --- a/lib/modules/publishing/constants.ex +++ /dev/null @@ -1,111 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Constants do - @moduledoc """ - Centralized constants for the Publishing module. - - Provides canonical lists for statuses, modes, and types used across - schemas, business logic, and templates. Import or alias this module - instead of hardcoding these values inline. - - For guard clauses and pattern matches, use the module attributes: - - @timestamp_modes Publishing.Constants.timestamp_modes() - @slug_modes Publishing.Constants.slug_modes() - - def my_func(mode) when mode in @timestamp_modes do ... - """ - - # --------------------------------------------------------------------------- - # Modes (post and group) - # --------------------------------------------------------------------------- - - @timestamp_modes [:timestamp, "timestamp"] - @slug_modes [:slug, "slug"] - @valid_modes ["timestamp", "slug"] - - @doc "Atom and string variants for timestamp mode — use in guards/pattern matches." - def timestamp_modes, do: @timestamp_modes - - @doc "Atom and string variants for slug mode — use in guards/pattern matches." - def slug_modes, do: @slug_modes - - @doc "Valid mode strings for schema validation." - def valid_modes, do: @valid_modes - - @doc "Returns true if mode is a timestamp mode (atom or string)." - def timestamp_mode?(mode), do: mode in @timestamp_modes - - @doc "Returns true if mode is a slug mode (atom or string)." - def slug_mode?(mode), do: mode in @slug_modes - - # --------------------------------------------------------------------------- - # Statuses - # --------------------------------------------------------------------------- - - @post_statuses ["draft", "published", "archived", "trashed"] - @content_statuses ["draft", "published", "archived"] - @group_statuses ["active", "trashed"] - - @doc "Valid post statuses: draft, published, archived, trashed." - def post_statuses, do: @post_statuses - - @doc "Valid version and content statuses: draft, published, archived." - def content_statuses, do: @content_statuses - - @doc "Valid group statuses: active, trashed." - def group_statuses, do: @group_statuses - - # --------------------------------------------------------------------------- - # Group types - # --------------------------------------------------------------------------- - - @preset_types ["blog", "faq", "legal"] - @valid_types ["blog", "faq", "legal", "custom"] - - @doc "Preset group types (shown as radio buttons in UI)." - def preset_types, do: @preset_types - - @doc "All valid group types including custom." - def valid_types, do: @valid_types - - # --------------------------------------------------------------------------- - # Defaults - # --------------------------------------------------------------------------- - - @default_mode "timestamp" - @default_type "blog" - @default_title "Untitled" - - @doc "Default group mode." - def default_mode, do: @default_mode - - @doc "Default group type." - def default_type, do: @default_type - - @doc "Default title for posts without a title." - def default_title, do: @default_title - - # --------------------------------------------------------------------------- - # Schema limits - # --------------------------------------------------------------------------- - - @max_slug_length 500 - @max_title_length 500 - @max_language_code_length 10 - @max_group_name_length 255 - @max_group_slug_length 255 - - @doc "Max length for post/content slugs." - def max_slug_length, do: @max_slug_length - - @doc "Max length for content titles." - def max_title_length, do: @max_title_length - - @doc "Max length for language codes." - def max_language_code_length, do: @max_language_code_length - - @doc "Max length for group names." - def max_group_name_length, do: @max_group_name_length - - @doc "Max length for group slugs." - def max_group_slug_length, do: @max_group_slug_length -end diff --git a/lib/modules/publishing/db_storage.ex b/lib/modules/publishing/db_storage.ex deleted file mode 100644 index 67c0e1fc9..000000000 --- a/lib/modules/publishing/db_storage.ex +++ /dev/null @@ -1,849 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.DBStorage do - @moduledoc """ - Database storage layer for the Publishing module. - - Provides CRUD operations for publishing groups, posts, versions, and contents - via PostgreSQL with Ecto. - """ - - import Ecto.Query - - alias PhoenixKit.Modules.Publishing.DBStorage.Mapper - alias PhoenixKit.Modules.Publishing.PublishingContent - alias PhoenixKit.Modules.Publishing.PublishingGroup - alias PhoenixKit.Modules.Publishing.PublishingPost - alias PhoenixKit.Modules.Publishing.PublishingVersion - - require Logger - - defp repo, do: PhoenixKit.RepoHelper.repo() - - # =========================================================================== - # Groups - # =========================================================================== - - @doc "Creates a publishing group." - def create_group(attrs) do - %PublishingGroup{} - |> PublishingGroup.changeset(attrs) - |> repo().insert() - end - - @doc "Updates a publishing group." - def update_group(%PublishingGroup{} = group, attrs) do - group - |> PublishingGroup.changeset(attrs) - |> repo().update() - end - - @doc "Gets a group by slug." - def get_group_by_slug(slug) do - repo().get_by(PublishingGroup, slug: slug) - end - - @doc "Gets a group by UUID." - def get_group(uuid) do - repo().get(PublishingGroup, uuid) - end - - @doc "Lists groups ordered by position. Filters by status (default: active only)." - def list_groups(status \\ "active") do - query = from(g in PublishingGroup, order_by: [asc: g.position, asc: g.name]) - - if status do - where(query, [g], g.status == ^status) - else - query - end - |> repo().all() - end - - @doc "Trashes a group by setting status to 'trashed'." - def trash_group(%PublishingGroup{} = group) do - update_group(group, %{status: "trashed"}) - end - - @doc "Restores a trashed group by setting status to 'active'." - def restore_group(%PublishingGroup{} = group) do - update_group(group, %{status: "active"}) - end - - @doc "Upserts a group by slug." - def upsert_group(attrs) do - slug = Map.get(attrs, :slug) || Map.get(attrs, "slug") - - case get_group_by_slug(slug) do - nil -> create_group(attrs) - group -> update_group(group, attrs) - end - end - - @doc "Deletes a group and all its posts (cascade)." - def delete_group(%PublishingGroup{} = group) do - repo().delete(group) - end - - # =========================================================================== - # Posts - # =========================================================================== - - @doc "Creates a post within a group." - def create_post(attrs) do - %PublishingPost{} - |> PublishingPost.changeset(attrs) - |> repo().insert() - end - - @doc "Updates a post." - def update_post(%PublishingPost{} = post, attrs) do - post - |> PublishingPost.changeset(attrs) - |> repo().update() - end - - @doc "Gets a post by group slug and post slug. Excludes trashed posts." - def get_post(group_slug, post_slug) do - from(p in PublishingPost, - join: g in assoc(p, :group), - where: g.slug == ^group_slug and p.slug == ^post_slug and p.status != "trashed", - preload: [group: g] - ) - |> repo().one() - end - - @doc """ - Gets a timestamp-mode post by date and time. - - Truncates seconds from the input time since URLs use HH:MM format only, - and new posts are stored with seconds zeroed. For older posts with non-zero - seconds, falls back to hour:minute matching. - """ - def get_post_by_datetime(group_slug, %Date{} = date, %Time{} = time) do - # Normalize to zero seconds (URLs only carry HH:MM) - normalized_time = %Time{hour: time.hour, minute: time.minute, second: 0, microsecond: {0, 0}} - - # Try exact match first (fast, uses index, works for all properly-stored posts) - result = - from(p in PublishingPost, - join: g in assoc(p, :group), - where: - g.slug == ^group_slug and p.post_date == ^date and p.post_time == ^normalized_time and - p.status != "trashed", - preload: [group: g] - ) - |> repo().one() - - if result do - result - else - # Fallback for older posts stored with non-zero seconds - hour = time.hour - minute = time.minute - - from(p in PublishingPost, - join: g in assoc(p, :group), - where: - g.slug == ^group_slug and p.post_date == ^date and p.status != "trashed" and - fragment( - "EXTRACT(HOUR FROM ?)::integer = ? AND EXTRACT(MINUTE FROM ?)::integer = ?", - p.post_time, - ^hour, - p.post_time, - ^minute - ), - order_by: [asc: p.post_time], - limit: 1, - preload: [group: g] - ) - |> repo().one() - end - end - - @doc "Gets a post by UUID with preloads." - def get_post_by_uuid(uuid, preloads \\ []) do - PublishingPost - |> repo().get(uuid) - |> maybe_preload(preloads) - end - - @doc "Lists posts in a group, optionally filtered by status. Excludes trashed by default." - def list_posts(group_slug, status \\ nil) do - query = - from(p in PublishingPost, - join: g in assoc(p, :group), - where: g.slug == ^group_slug, - preload: [group: g] - ) - - query = - if status do - where(query, [p], p.status == ^status) - else - where(query, [p], p.status != "trashed") - end - - query - |> order_by_mode() - |> repo().all() - end - - @doc "Counts non-trashed posts in a group." - def count_posts(group_slug) do - from(p in PublishingPost, - join: g in assoc(p, :group), - where: g.slug == ^group_slug and p.status != "trashed", - select: count(p.uuid) - ) - |> repo().one() || 0 - end - - @doc """ - Lists posts in timestamp mode (ordered by date/time desc). - - Options: - * `:date` - Filter to a specific date (Date struct or ISO 8601 string) - """ - def list_posts_timestamp_mode(group_slug, status \\ nil, opts \\ []) do - query = - from(p in PublishingPost, - join: g in assoc(p, :group), - where: g.slug == ^group_slug, - order_by: [desc: p.post_date, desc: p.post_time], - preload: [group: g] - ) - - query = - if status do - where(query, [p], p.status == ^status) - else - query - end - - query = - case Keyword.get(opts, :date) do - nil -> - query - - %Date{} = date -> - where(query, [p], p.post_date == ^date) - - date_string when is_binary(date_string) -> - where(query, [p], p.post_date == ^Date.from_iso8601!(date_string)) - end - - repo().all(query) - end - - @doc "Lists posts in slug mode (ordered by slug asc)." - def list_posts_slug_mode(group_slug, status \\ nil) do - query = - from(p in PublishingPost, - join: g in assoc(p, :group), - where: g.slug == ^group_slug, - order_by: [asc: p.slug], - preload: [group: g] - ) - - if status do - where(query, [p], p.status == ^status) - else - query - end - |> repo().all() - end - - @doc "Finds a post by date and time (timestamp mode, matches hour:minute only)." - def find_post_by_date_time(group_slug, date, time) do - # Delegate to get_post_by_datetime which handles normalization and fallback - get_post_by_datetime(group_slug, date, time) - end - - @doc "Trashes a post by setting status to 'trashed'." - # Uses Ecto.Changeset.change/2 instead of the full changeset to avoid - # slug validation errors on posts with nil/blank slugs. - def trash_post(%PublishingPost{} = post) do - post - |> Ecto.Changeset.change(status: "trashed") - |> repo().update() - end - - @doc "Hard-deletes a post and all its versions/contents (cascade)." - def delete_post(%PublishingPost{} = post) do - repo().delete(post) - end - - @doc """ - Counts posts by primary language status for a group. - - Returns `%{current: n, needs_migration: n, needs_backfill: n}` where: - - `current` — primary_language matches the global setting - - `needs_migration` — primary_language is set but differs from global - - `needs_backfill` — primary_language is nil - """ - def count_primary_language_status(group_slug, global_primary) do - posts = list_posts(group_slug) - count_primary_language_status_from_posts(posts, global_primary) - end - - @doc """ - Counts primary language status from an already-loaded list of posts. - Avoids re-querying when posts are already available. - - Posts can be DB structs or maps (with `:primary_language` key). - """ - def count_primary_language_status_from_posts(posts, global_primary) do - Enum.reduce(posts, %{current: 0, needs_migration: 0, needs_backfill: 0}, fn post, acc -> - primary_lang = Map.get(post, :primary_language) - - cond do - is_nil(primary_lang) -> - %{acc | needs_backfill: acc.needs_backfill + 1} - - primary_lang == global_primary -> - %{acc | current: acc.current + 1} - - true -> - %{acc | needs_migration: acc.needs_migration + 1} - end - end) - end - - @doc """ - Updates all posts in a group to use the given primary language. - - Returns `{:ok, count}` with the number of updated posts. - """ - def update_primary_language(group_slug, primary_language) do - group = get_group_by_slug(group_slug) - - if group do - {count, _} = - from(p in PublishingPost, - where: - p.group_uuid == ^group.uuid and - (is_nil(p.primary_language) or p.primary_language != ^primary_language) - ) - |> repo().update_all( - set: [primary_language: primary_language, updated_at: DateTime.utc_now()] - ) - - {:ok, count} - else - {:ok, 0} - end - end - - @doc "Counts posts needing primary language update in a group." - def count_posts_needing_language_update(group_slug, primary_language) do - group = get_group_by_slug(group_slug) - - if group do - from(p in PublishingPost, - where: - p.group_uuid == ^group.uuid and - (is_nil(p.primary_language) or p.primary_language != ^primary_language), - select: count(p.uuid) - ) - |> repo().one() || 0 - else - 0 - end - end - - # =========================================================================== - # Versions - # =========================================================================== - - @doc "Creates a new version for a post." - def create_version(attrs) do - %PublishingVersion{} - |> PublishingVersion.changeset(attrs) - |> repo().insert() - end - - @doc "Updates a version." - def update_version(%PublishingVersion{} = version, attrs) do - version - |> PublishingVersion.changeset(attrs) - |> repo().update() - end - - @doc "Gets the latest version for a post." - def get_latest_version(post_uuid) do - from(v in PublishingVersion, - where: v.post_uuid == ^post_uuid, - order_by: [desc: v.version_number], - limit: 1 - ) - |> repo().one() - end - - @doc "Gets a specific version by post and version number." - def get_version(post_uuid, version_number) do - repo().get_by(PublishingVersion, - post_uuid: post_uuid, - version_number: version_number - ) - end - - @doc "Lists all versions for a post, ordered by version number." - def list_versions(post_uuid) do - from(v in PublishingVersion, - where: v.post_uuid == ^post_uuid, - order_by: [asc: v.version_number] - ) - |> repo().all() - end - - @doc """ - Gets the next version number for a post. - - Uses SELECT ... FOR UPDATE to lock the row and prevent concurrent reads - from getting the same number. - """ - def next_version_number(post_uuid) do - # Lock existing version rows to prevent concurrent inserts, - # then compute max in Elixir. FOR UPDATE cannot be combined - # with aggregate functions in PostgreSQL. - versions = - from(v in PublishingVersion, - where: v.post_uuid == ^post_uuid, - select: v.version_number, - lock: "FOR UPDATE" - ) - |> repo().all() - - Enum.max(versions, fn -> 0 end) + 1 - end - - @doc """ - Creates a new version by cloning content from a source version. - - Creates a new version row and copies all content rows from the source. - Wrapped in a transaction for atomicity. - - Returns `{:ok, %PublishingVersion{}}` or `{:error, reason}`. - """ - def create_version_from(post_uuid, source_version_number, opts \\ %{}) do - repo().transaction(fn -> - source_version = get_version(post_uuid, source_version_number) - unless source_version, do: repo().rollback(:source_not_found) - - new_version = do_create_cloned_version(post_uuid, source_version, opts) - copy_contents_to_version(source_version.uuid, new_version.uuid) - new_version - end) - end - - defp do_create_cloned_version(post_uuid, source_version, opts) do - new_number = next_version_number(post_uuid) - - case create_version(%{ - post_uuid: post_uuid, - version_number: new_number, - status: "draft", - created_by_uuid: opts[:created_by_uuid], - data: %{"created_from" => source_version.version_number} - }) do - {:ok, new_version} -> new_version - {:error, reason} -> repo().rollback(reason) - end - end - - defp copy_contents_to_version(source_version_uuid, target_version_uuid) do - now = DateTime.utc_now() |> DateTime.truncate(:second) - - rows = - list_contents(source_version_uuid) - |> Enum.map(fn content -> - %{ - uuid: UUIDv7.generate(), - version_uuid: target_version_uuid, - language: content.language, - title: content.title || "", - content: content.content || "", - status: "draft", - url_slug: content.url_slug, - data: content.data || %{}, - inserted_at: now, - updated_at: now - } - end) - - if rows != [] do - case repo().insert_all(PublishingContent, rows, on_conflict: :nothing) do - {count, _} when count >= 0 -> :ok - _ -> repo().rollback(:content_copy_failed) - end - end - end - - # =========================================================================== - # Contents - # =========================================================================== - - @doc "Creates content for a version/language." - def create_content(attrs) do - %PublishingContent{} - |> PublishingContent.changeset(attrs) - |> repo().insert() - end - - @doc "Updates content." - def update_content(%PublishingContent{} = content, attrs) do - content - |> PublishingContent.changeset(attrs) - |> repo().update() - end - - @doc "Bulk-updates the status of all content rows for a version." - def update_content_status(version_uuid, new_status) do - from(c in PublishingContent, where: c.version_uuid == ^version_uuid) - |> repo().update_all(set: [status: new_status, updated_at: DateTime.utc_now()]) - end - - @doc "Bulk-updates the status of all content rows for a version, excluding a specific language." - def update_content_status_except(version_uuid, exclude_language, new_status) do - from(c in PublishingContent, - where: c.version_uuid == ^version_uuid and c.language != ^exclude_language - ) - |> repo().update_all(set: [status: new_status, updated_at: DateTime.utc_now()]) - end - - @doc "Gets content for a specific version and language." - def get_content(version_uuid, language) do - repo().get_by(PublishingContent, - version_uuid: version_uuid, - language: language - ) - end - - @doc "Lists all content rows for a version." - def list_contents(version_uuid) do - from(c in PublishingContent, - where: c.version_uuid == ^version_uuid, - order_by: [asc: c.language] - ) - |> repo().all() - end - - @doc "Lists available languages for a version." - def list_languages(version_uuid) do - from(c in PublishingContent, - where: c.version_uuid == ^version_uuid, - select: c.language, - order_by: [asc: c.language] - ) - |> repo().all() - end - - @doc "Finds content by URL slug across all versions in a group. Excludes trashed posts." - def find_by_url_slug(group_slug, language, url_slug) do - # Try matching by content url_slug first - result = - from(c in PublishingContent, - join: v in assoc(c, :version), - join: p in assoc(v, :post), - join: g in assoc(p, :group), - where: - g.slug == ^group_slug and c.language == ^language and c.url_slug == ^url_slug and - p.status != "trashed", - preload: [version: {v, post: {p, group: g}}] - ) - |> repo().one() - - # Fallback: if no custom url_slug match, try matching by post.slug - # (content rows with NULL/empty url_slug use the post slug as their public URL) - result || - from(c in PublishingContent, - join: v in assoc(c, :version), - join: p in assoc(v, :post), - join: g in assoc(p, :group), - where: - g.slug == ^group_slug and c.language == ^language and p.slug == ^url_slug and - p.status != "trashed" and - (is_nil(c.url_slug) or c.url_slug == ""), - preload: [version: {v, post: {p, group: g}}] - ) - |> repo().one() - end - - @doc "Finds content by a previous URL slug (stored in data.previous_url_slugs JSONB array). Excludes trashed posts." - def find_by_previous_url_slug(group_slug, language, url_slug) do - from(c in PublishingContent, - join: v in assoc(c, :version), - join: p in assoc(v, :post), - join: g in assoc(p, :group), - where: - g.slug == ^group_slug and - c.language == ^language and - p.status != "trashed" and - fragment("? @> ?", c.data, ^%{"previous_url_slugs" => [url_slug]}), - preload: [version: {v, post: {p, group: g}}] - ) - |> repo().one() - end - - @doc "Clears a specific url_slug from all content rows of a post. Returns cleared language codes." - def clear_url_slug_from_post(group_slug, post_slug, url_slug_to_clear) do - case get_post(group_slug, post_slug) do - nil -> - [] - - db_post -> - # Find affected languages, then bulk-clear url_slugs - contents = - from(c in PublishingContent, - join: v in assoc(c, :version), - where: v.post_uuid == ^db_post.uuid and c.url_slug == ^url_slug_to_clear, - select: {c, c.language} - ) - |> repo().all() - - # Bulk clear in one query - from(c in PublishingContent, - join: v in assoc(c, :version), - where: v.post_uuid == ^db_post.uuid and c.url_slug == ^url_slug_to_clear - ) - |> repo().update_all(set: [url_slug: nil, updated_at: DateTime.utc_now()]) - - Enum.map(contents, fn {_content, lang} -> lang end) |> Enum.uniq() - end - end - - @doc "Upserts content by version_id + language using ON CONFLICT." - def upsert_content(attrs) do - changeset = PublishingContent.changeset(%PublishingContent{}, attrs) - - repo().insert(changeset, - on_conflict: {:replace, [:title, :content, :status, :url_slug, :data, :updated_at]}, - conflict_target: [:version_uuid, :language], - returning: true - ) - end - - # =========================================================================== - # Compound Operations - # =========================================================================== - - @doc """ - Reads a full post with its latest version and content for a specific language. - - Returns a post map or nil if not found. - """ - def read_post(group_slug, post_slug, language \\ nil, version_number \\ nil) do - with post when not is_nil(post) <- get_post(group_slug, post_slug), - version when not is_nil(version) <- resolve_version(post, version_number), - contents <- list_contents(version.uuid), - content when not is_nil(content) <- resolve_content(contents, language, post) do - all_versions = list_versions(post.uuid) - - {:ok, Mapper.to_post_map(post, version, content, contents, all_versions)} - else - nil -> {:error, :not_found} - end - end - - @doc """ - Reads a timestamp-mode post by date and time instead of slug. - """ - def read_post_by_datetime(group_slug, date, time, language \\ nil, version_number \\ nil) do - with post when not is_nil(post) <- get_post_by_datetime(group_slug, date, time), - version when not is_nil(version) <- resolve_version(post, version_number), - contents <- list_contents(version.uuid), - content when not is_nil(content) <- resolve_content(contents, language, post) do - all_versions = list_versions(post.uuid) - - {:ok, Mapper.to_post_map(post, version, content, contents, all_versions)} - else - nil -> {:error, :not_found} - end - end - - @doc """ - Lists all posts in a group with their latest version metadata. - - Returns a list of post maps suitable for listing pages. - """ - def list_posts_with_metadata(group_slug, status \\ nil) do - posts = if status, do: list_posts(group_slug, status), else: list_posts(group_slug) - post_uuids = Enum.map(posts, & &1.uuid) - - # Batch-load ALL versions for all posts in one query - all_versions_by_post = batch_load_versions(post_uuids) - - # Find latest version per post and collect all version UUIDs we need contents for - latest_by_post = - Map.new(all_versions_by_post, fn {post_uuid, versions} -> - {post_uuid, List.last(versions)} - end) - - # Also find published versions that differ from latest (for status overlay) - published_by_post = - Map.new(all_versions_by_post, fn {post_uuid, versions} -> - {post_uuid, Enum.find(versions, fn v -> v.status == "published" end)} - end) - - # Collect all version UUIDs we need contents for (latest + published if different) - version_uuids_needed = - Enum.flat_map(posts, fn post -> - latest = latest_by_post[post.uuid] - published = published_by_post[post.uuid] - - [latest, published] - |> Enum.reject(&is_nil/1) - |> Enum.uniq_by(& &1.uuid) - |> Enum.map(& &1.uuid) - end) - - # Batch-load ALL contents for all needed versions in one query - all_contents_by_version = batch_load_contents(version_uuids_needed) - - Enum.map(posts, fn post -> - all_versions = Map.get(all_versions_by_post, post.uuid, []) - version = latest_by_post[post.uuid] - - if version do - contents = Map.get(all_contents_by_version, version.uuid, []) - published_version = published_by_post[post.uuid] - - published_statuses = - build_published_statuses(published_version, version, all_contents_by_version) - - primary_content = resolve_content(contents, nil, post) - - if primary_content do - Mapper.to_post_map(post, version, primary_content, contents, all_versions, - published_language_statuses: published_statuses - ) - else - Mapper.to_listing_map(post, version, contents, all_versions, - published_language_statuses: published_statuses - ) - end - else - Mapper.to_listing_map(post, nil, [], []) - end - end) - end - - @doc """ - Lists all posts in a group in listing format (excerpt only, no full content). - - Always uses `Mapper.to_listing_map/4` which strips content bodies and includes - only excerpts. Designed for caching in `:persistent_term` where data is copied - to the reading process heap — keeping entries small matters. - """ - def list_posts_for_listing(group_slug) do - posts = list_posts(group_slug) - post_uuids = Enum.map(posts, & &1.uuid) - - all_versions_by_post = batch_load_versions(post_uuids) - - latest_by_post = - Map.new(all_versions_by_post, fn {post_uuid, versions} -> - {post_uuid, List.last(versions)} - end) - - published_by_post = - Map.new(all_versions_by_post, fn {post_uuid, versions} -> - {post_uuid, Enum.find(versions, fn v -> v.status == "published" end)} - end) - - version_uuids_needed = - Enum.flat_map(posts, fn post -> - latest = latest_by_post[post.uuid] - published = published_by_post[post.uuid] - - [latest, published] - |> Enum.reject(&is_nil/1) - |> Enum.uniq_by(& &1.uuid) - |> Enum.map(& &1.uuid) - end) - - all_contents_by_version = batch_load_contents(version_uuids_needed) - - Enum.map(posts, fn post -> - all_versions = Map.get(all_versions_by_post, post.uuid, []) - version = latest_by_post[post.uuid] - - if version do - contents = Map.get(all_contents_by_version, version.uuid, []) - published_version = published_by_post[post.uuid] - - published_statuses = - build_published_statuses(published_version, version, all_contents_by_version) - - Mapper.to_listing_map(post, version, contents, all_versions, - published_language_statuses: published_statuses - ) - else - Mapper.to_listing_map(post, nil, [], []) - end - end) - end - - # =========================================================================== - # Private Helpers - # =========================================================================== - - defp resolve_version(post, nil), do: get_latest_version(post.uuid) - defp resolve_version(post, version_number), do: get_version(post.uuid, version_number) - - defp build_published_statuses(published_version, latest_version, all_contents_by_version) do - if published_version && published_version.uuid != latest_version.uuid do - Map.get(all_contents_by_version, published_version.uuid, []) - |> Map.new(fn c -> {c.language, c.status} end) - else - %{} - end - end - - defp resolve_content(contents, nil, post) do - # No language specified — use primary language, then any available - Enum.find(contents, fn c -> c.language == post.primary_language end) || - List.first(contents) - end - - defp resolve_content(contents, language, post) do - # Try exact language match first, fall back to primary language, then any available. - # This handles cases where the DB has partial content (e.g., only 3 of 39 languages - # were imported) but the editor requests the primary language. - Enum.find(contents, fn c -> c.language == language end) || - Enum.find(contents, fn c -> c.language == post.primary_language end) || - List.first(contents) - end - - defp order_by_mode(query) do - # Default ordering: published_at desc, then inserted_at desc - order_by(query, [p], desc: p.published_at, desc: p.inserted_at) - end - - @doc false - def batch_load_versions([]), do: %{} - - def batch_load_versions(post_uuids) do - from(v in PublishingVersion, - where: v.post_uuid in ^post_uuids, - order_by: [asc: v.version_number] - ) - |> repo().all() - |> Enum.group_by(& &1.post_uuid) - end - - @doc false - def batch_load_contents([]), do: %{} - - def batch_load_contents(version_uuids) do - from(c in PublishingContent, - where: c.version_uuid in ^version_uuids, - order_by: [asc: c.language] - ) - |> repo().all() - |> Enum.group_by(& &1.version_uuid) - end - - defp maybe_preload(nil, _preloads), do: nil - defp maybe_preload(record, []), do: record - defp maybe_preload(record, preloads), do: repo().preload(record, preloads) -end diff --git a/lib/modules/publishing/db_storage/mapper.ex b/lib/modules/publishing/db_storage/mapper.ex deleted file mode 100644 index 42f840bd9..000000000 --- a/lib/modules/publishing/db_storage/mapper.ex +++ /dev/null @@ -1,254 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.DBStorage.Mapper do - @moduledoc """ - Mapper: converts DB records to the map format expected by - Publishing's web layer (LiveViews, templates, controllers). - - ## Map Shape - - The web layer expects maps with these keys: - - `:group` - group slug - - `:slug` - post slug identifier - - `:url_slug` - per-language URL slug - - `:date` - Date struct (timestamp mode) - - `:time` - Time struct (timestamp mode) - - `:mode` - :timestamp or :slug atom - - `:language` - current language code - - `:available_languages` - list of language codes - - `:language_statuses` - %{language => status} - - `:version` - current version number - - `:available_versions` - list of version numbers - - `:version_statuses` - %{version_number => status} - - `:version_dates` - %{version_number => date_string} - - `:content` - markdown/PHK body - - `:metadata` - map with :title, :description, :status, :slug, etc. - - `:primary_language` - primary language code - """ - - alias PhoenixKit.Modules.Publishing.PublishingContent - alias PhoenixKit.Modules.Publishing.PublishingPost - alias PhoenixKit.Modules.Publishing.PublishingVersion - - @doc """ - Converts a full post read (post + version + content + all contents + all versions) - into the map format expected by the web layer. - """ - def to_post_map( - %PublishingPost{} = post, - %PublishingVersion{} = version, - %PublishingContent{} = content, - all_contents, - all_versions, - opts \\ [] - ) do - available_languages = Enum.map(all_contents, & &1.language) |> Enum.sort() - - language_statuses = - Map.new(all_contents, fn c -> {c.language, c.status} end) - |> merge_published_statuses(Keyword.get(opts, :published_language_statuses, %{})) - - available_versions = Enum.map(all_versions, & &1.version_number) |> Enum.sort() - - version_statuses = - Map.new(all_versions, fn v -> {v.version_number, v.status} end) - - version_dates = - Map.new(all_versions, fn v -> - {v.version_number, format_datetime(v.inserted_at)} - end) - - group_slug = get_group_slug(post) - - %{ - uuid: post.uuid, - group: group_slug, - slug: post.slug, - url_slug: presence(content.url_slug) || post.slug, - date: post.post_date, - time: post.post_time, - mode: safe_mode_atom(post.mode), - language: content.language, - available_languages: available_languages, - language_statuses: language_statuses, - language_slugs: build_language_slugs(all_contents, post.slug), - language_previous_slugs: build_language_previous_slugs(all_contents), - version: version.version_number, - available_versions: available_versions, - version_statuses: version_statuses, - version_dates: version_dates, - content: content.content, - content_updated_at: content.updated_at, - metadata: build_metadata(post, version, content), - primary_language: post.primary_language - } - end - - @doc """ - Converts a post to a listing-format map (no content body, just metadata). - Used for listing pages where full content isn't needed. - """ - def to_listing_map(%PublishingPost{} = post, version, all_contents, all_versions, opts \\ []) do - available_languages = Enum.map(all_contents, & &1.language) |> Enum.sort() - - language_statuses = - Map.new(all_contents, fn c -> {c.language, c.status} end) - |> merge_published_statuses(Keyword.get(opts, :published_language_statuses, %{})) - - available_versions = Enum.map(all_versions, & &1.version_number) |> Enum.sort() - - version_statuses = - Map.new(all_versions, fn v -> {v.version_number, v.status} end) - - version_dates = - Map.new(all_versions, fn v -> - {v.version_number, format_datetime(v.inserted_at)} - end) - - primary_content = - Enum.find(all_contents, fn c -> c.language == post.primary_language end) || - List.first(all_contents) - - group_slug = get_group_slug(post) - current_version = if version, do: version.version_number, else: 1 - - %{ - uuid: post.uuid, - group: group_slug, - slug: post.slug, - url_slug: presence(primary_content && primary_content.url_slug) || post.slug, - date: post.post_date, - time: post.post_time, - mode: safe_mode_atom(post.mode), - language: post.primary_language, - available_languages: available_languages, - language_statuses: language_statuses, - language_slugs: build_language_slugs(all_contents, post.slug), - language_previous_slugs: build_language_previous_slugs(all_contents), - version: current_version, - available_versions: available_versions, - version_statuses: version_statuses, - version_dates: version_dates, - content: primary_content && extract_excerpt(primary_content), - metadata: build_listing_metadata(post, primary_content), - primary_language: post.primary_language, - # Per-language data for listing pages (so language switching shows correct titles) - language_titles: Map.new(all_contents, fn c -> {c.language, c.title} end), - language_excerpts: Map.new(all_contents, fn c -> {c.language, extract_excerpt(c)} end) - } - end - - # =========================================================================== - # Private Helpers - # =========================================================================== - - defp get_group_slug(%PublishingPost{group: %{slug: slug}}), do: slug - defp get_group_slug(%PublishingPost{} = _post), do: nil - - defp build_metadata(post, version, content) do - %{ - title: content.title, - description: PublishingContent.get_description(content), - status: content.status, - slug: post.slug, - version: version.version_number, - allow_version_access: PublishingPost.allow_version_access?(post), - url_slug: content.url_slug, - previous_url_slugs: PublishingContent.get_previous_url_slugs(content), - published_at: format_datetime(post.published_at), - featured_image_uuid: PublishingContent.get_featured_image_uuid(content), - primary_language: post.primary_language - } - end - - defp build_listing_metadata(post, nil) do - %{ - title: nil, - description: nil, - status: post.status, - slug: post.slug, - published_at: format_datetime(post.published_at), - featured_image_uuid: nil, - primary_language: post.primary_language - } - end - - defp build_listing_metadata(post, content) do - %{ - title: content.title, - description: PublishingContent.get_description(content), - status: content.status, - slug: post.slug, - published_at: format_datetime(post.published_at), - featured_image_uuid: PublishingContent.get_featured_image_uuid(content), - primary_language: post.primary_language - } - end - - defp build_language_slugs(all_contents, default_slug) do - Map.new(all_contents, fn c -> - {c.language, presence(c.url_slug) || default_slug} - end) - end - - defp build_language_previous_slugs(all_contents) do - Map.new(all_contents, fn c -> - {c.language, PublishingContent.get_previous_url_slugs(c)} - end) - end - - defp extract_excerpt(%PublishingContent{} = content) do - # Use custom excerpt from data, or description, or first paragraph - case PublishingContent.get_excerpt(content) do - excerpt when is_binary(excerpt) and excerpt != "" -> - excerpt - - _ -> - case PublishingContent.get_description(content) do - desc when is_binary(desc) and desc != "" -> - desc - - _ -> - extract_first_paragraph(content.content) - end - end - end - - defp extract_first_paragraph(nil), do: nil - - defp extract_first_paragraph(content) when is_binary(content) do - content - |> String.split(~r/\n\n+/) - |> Enum.reject(&String.starts_with?(&1, "#")) - |> List.first() - |> case do - nil -> "" - text -> text |> String.trim() |> String.slice(0, 300) - end - end - - defp format_datetime(nil), do: nil - defp format_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt) - defp format_datetime(other), do: to_string(other) - - # Merges published version's language statuses into the latest version's statuses. - # For each language, if the published version has it as "published", override the - # latest version's status. This ensures the listing page shows "published" when - # a language is live on an older version even if the latest draft doesn't have it published. - defp merge_published_statuses(latest_statuses, published_statuses) - when map_size(published_statuses) == 0, - do: latest_statuses - - defp merge_published_statuses(latest_statuses, published_statuses) do - Map.merge(latest_statuses, published_statuses, fn _lang, latest, published -> - if published == "published", do: "published", else: latest - end) - end - - defp safe_mode_atom("timestamp"), do: :timestamp - defp safe_mode_atom("slug"), do: :slug - defp safe_mode_atom(_), do: :timestamp - - # Returns nil for nil and empty string, otherwise the value - defp presence(nil), do: nil - defp presence(""), do: nil - defp presence(value), do: value -end diff --git a/lib/modules/publishing/groups.ex b/lib/modules/publishing/groups.ex deleted file mode 100644 index 5c998d389..000000000 --- a/lib/modules/publishing/groups.ex +++ /dev/null @@ -1,505 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Groups do - @moduledoc """ - Group management functions for the Publishing module. - - Handles creating, listing, updating, and removing publishing groups, - as well as slug generation, type/mode normalization, and item naming. - """ - - require Logger - - alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Modules.Publishing.ListingCache - alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Shared - alias PhoenixKit.Modules.Publishing.StaleFixer - - alias PhoenixKit.Modules.Publishing.Constants - - @default_group_mode Constants.default_mode() - @default_group_type Constants.default_type() - @preset_types Constants.preset_types() - @valid_types Constants.valid_types() - @type_regex ~r/^[a-z][a-z0-9-]{0,31}$/ - - @type_item_names %{ - "blog" => {"post", "posts"}, - "faq" => {"question", "questions"}, - "legal" => {"document", "documents"} - } - @default_item_singular "item" - @default_item_plural "items" - - @type group :: map() - - @doc """ - Returns all publishing groups from the database. - """ - @spec list_groups() :: [group()] - def list_groups do - DBStorage.list_groups() - |> Enum.map(fn group -> group |> StaleFixer.fix_stale_group() |> db_group_to_map() end) - end - - @doc "Lists groups filtered by status (e.g. 'active', 'trashed')." - @spec list_groups(String.t()) :: [group()] - def list_groups(status) do - DBStorage.list_groups(status) - |> Enum.map(fn group -> group |> StaleFixer.fix_stale_group() |> db_group_to_map() end) - end - - @doc """ - Gets a publishing group by slug. - - ## Examples - - iex> Groups.get_group("news") - {:ok, %{"name" => "News", "slug" => "news", ...}} - - iex> Groups.get_group("nonexistent") - {:error, :not_found} - """ - @spec get_group(String.t()) :: {:ok, group()} | {:error, :not_found} - def get_group(slug) when is_binary(slug) do - case DBStorage.get_group_by_slug(slug) do - nil -> {:error, :not_found} - db_group -> {:ok, db_group |> StaleFixer.fix_stale_group() |> db_group_to_map()} - end - end - - @doc """ - Adds a new publishing group. - - ## Parameters - - * `name` - Display name for the group - * `opts` - Keyword list or map with options: - * `:mode` - Post mode: "timestamp" or "slug" (default: "timestamp") - * `:slug` - Optional custom slug, auto-generated from name if nil - * `:type` - Content type: "blog", "faq", "legal", or custom (default: "blog") - * `:item_singular` - Singular name for items (default: based on type, e.g., "post") - * `:item_plural` - Plural name for items (default: based on type, e.g., "posts") - - ## Examples - - iex> Groups.add_group("News") - {:ok, %{"name" => "News", "slug" => "news", "mode" => "timestamp", "type" => "blog", ...}} - - iex> Groups.add_group("FAQ", type: "faq", mode: "slug") - {:ok, %{"name" => "FAQ", "slug" => "faq", "mode" => "slug", "type" => "faq", "item_singular" => "question", ...}} - - iex> Groups.add_group("Recipes", type: "custom", item_singular: "recipe", item_plural: "recipes") - {:ok, %{"name" => "Recipes", ..., "item_singular" => "recipe", "item_plural" => "recipes"}} - """ - @spec add_group(String.t(), keyword() | map()) :: {:ok, group()} | {:error, atom()} - def add_group(name, opts \\ []) - - def add_group(name, opts) when is_binary(name) and (is_list(opts) or is_map(opts)) do - trimmed = String.trim(name) - mode = opts |> fetch_option(:mode) |> normalize_mode_with_default() - normalized_type = opts |> fetch_option(:type) |> normalize_type() - - cond do - trimmed == "" -> - {:error, :invalid_name} - - is_nil(mode) -> - {:error, :invalid_mode} - - is_nil(normalized_type) -> - {:error, :invalid_type} - - true -> - groups = list_groups() - preferred_slug = fetch_option(opts, :slug) - - with {:ok, requested_slug} <- derive_requested_slug(preferred_slug, trimmed), - :ok <- check_slug_availability(requested_slug, groups, preferred_slug) do - slug = ensure_unique_slug(requested_slug, groups) - - {default_singular, default_plural} = default_item_names(normalized_type) - - item_singular = - opts - |> fetch_option(:item_singular) - |> normalize_item_name(default_singular) - - item_plural = - opts - |> fetch_option(:item_plural) - |> normalize_item_name(default_plural) - - db_attrs = %{ - name: trimmed, - slug: slug, - mode: mode, - data: %{ - "type" => normalized_type, - "item_singular" => item_singular, - "item_plural" => item_plural - } - } - - case DBStorage.create_group(db_attrs) do - {:ok, db_group} -> - group = db_group_to_map(db_group) - PublishingPubSub.broadcast_group_created(group) - {:ok, group} - - {:error, _changeset} -> - {:error, :already_exists} - end - end - end - end - - @doc """ - Removes a publishing group by slug. - """ - @spec remove_group(String.t()) :: {:ok, any()} | {:error, any()} - def remove_group(slug) when is_binary(slug) do - remove_group(slug, force: false) - end - - @doc """ - Removes a publishing group by slug. - - By default, refuses to delete groups that contain posts. - Pass `force: true` to cascade-delete the group and all its posts. - """ - def remove_group(slug, opts) when is_binary(slug) do - force = Keyword.get(opts, :force, false) - - case DBStorage.get_group_by_slug(slug) do - nil -> - {:error, :not_found} - - db_group -> - post_count = DBStorage.count_posts(db_group.slug) - - if post_count > 0 and not force do - {:error, {:has_posts, post_count}} - else - case DBStorage.delete_group(db_group) do - {:ok, _} -> - ListingCache.invalidate(slug) - PublishingPubSub.broadcast_group_deleted(slug) - {:ok, slug} - - error -> - error - end - end - end - end - - @doc """ - Updates a publishing group's display name and slug. - """ - @spec update_group(String.t(), map() | keyword()) :: {:ok, group()} | {:error, atom()} - def update_group(slug, params) when is_binary(slug) do - case DBStorage.get_group_by_slug(slug) do - nil -> - {:error, :not_found} - - db_group -> - with {:ok, name} <- extract_and_validate_name(db_group, params), - {:ok, sanitized_slug} <- extract_and_validate_slug(db_group, params, name) do - case DBStorage.update_group(db_group, %{name: name, slug: sanitized_slug}) do - {:ok, updated} -> - group = db_group_to_map(updated) - PublishingPubSub.broadcast_group_updated(group) - {:ok, group} - - {:error, _} = error -> - error - end - end - end - end - - @doc """ - Moves a publishing group to trash (soft-delete). - - Sets the group status to "trashed". The group and its posts remain in the - database and can be restored. Trashed groups are hidden from list_groups/0. - """ - @spec trash_group(String.t()) :: {:ok, String.t()} | {:error, any()} - def trash_group(slug) when is_binary(slug) do - case DBStorage.get_group_by_slug(slug) do - nil -> - {:error, :not_found} - - db_group -> - case DBStorage.trash_group(db_group) do - {:ok, _} -> - ListingCache.invalidate(slug) - PublishingPubSub.broadcast_group_deleted(slug) - {:ok, slug} - - {:error, reason} -> - {:error, reason} - end - end - end - - @doc """ - Restores a trashed publishing group. - """ - @spec restore_group(String.t()) :: {:ok, String.t()} | {:error, any()} - def restore_group(slug) when is_binary(slug) do - case DBStorage.get_group_by_slug(slug) do - nil -> - {:error, :not_found} - - db_group -> - # Check if an active group already uses this slug (created while this was trashed) - active_conflict = - DBStorage.list_groups("active") - |> Enum.any?(fn g -> g.slug == slug and g.uuid != db_group.uuid end) - - if active_conflict do - {:error, :slug_taken} - else - case DBStorage.restore_group(db_group) do - {:ok, _} -> - ListingCache.regenerate(slug) - - PublishingPubSub.broadcast_group_created(%{ - "slug" => slug, - "name" => db_group.name - }) - - {:ok, slug} - - {:error, reason} -> - {:error, reason} - end - end - end - end - - @doc """ - Lists trashed publishing groups. - """ - @spec list_trashed_groups() :: [map()] - def list_trashed_groups do - DBStorage.list_groups("trashed") - |> Enum.map(&db_group_to_map/1) - end - - @doc """ - Looks up a publishing group name from its slug. - """ - @spec group_name(String.t()) :: String.t() | nil - def group_name(slug) do - case DBStorage.get_group_by_slug(slug) do - nil -> nil - db_group -> db_group.name - end - end - - @doc """ - Returns the configured post mode for a publishing group slug. - """ - @spec get_group_mode(String.t()) :: String.t() - def get_group_mode(group_slug) do - case DBStorage.get_group_by_slug(group_slug) do - nil -> @default_group_mode - db_group -> db_group.mode || @default_group_mode - end - end - - @doc """ - Returns the preset content types with their default item names. - """ - @spec preset_types() :: [map()] - def preset_types do - [ - %{type: "blog", label: "Blog", item_singular: "post", item_plural: "posts"}, - %{type: "faq", label: "FAQ", item_singular: "question", item_plural: "questions"}, - %{type: "legal", label: "Legal", item_singular: "document", item_plural: "documents"} - ] - end - - @doc """ - Returns the list of valid group type values. - """ - @spec valid_types() :: [String.t()] - def valid_types, do: @valid_types - - # ============================================================================ - # Private Helpers - # ============================================================================ - - defp extract_and_validate_name(db_group, params) do - name = - params - |> fetch_option(:name) - |> case do - nil -> db_group.name - value -> String.trim(to_string(value || "")) - end - - if name == "", do: {:error, :invalid_name}, else: {:ok, name} - end - - defp extract_and_validate_slug(db_group, params, name) do - desired_slug = - params - |> fetch_option(:slug) - |> case do - nil -> db_group.slug - value -> String.trim(to_string(value || "")) - end - - cond do - desired_slug == "" -> - auto_slug = Publishing.slugify(name) - - if Publishing.valid_slug?(auto_slug), - do: {:ok, auto_slug}, - else: {:error, :invalid_slug} - - Publishing.valid_slug?(desired_slug) -> - {:ok, desired_slug} - - true -> - {:error, :invalid_slug} - end - end - - defp db_group_to_map(%{name: name, slug: slug, mode: mode, status: status, data: data}) do - %{ - "name" => name, - "slug" => slug, - "mode" => mode || @default_group_mode, - "status" => status || "active", - "type" => Map.get(data, "type", @default_group_type), - "item_singular" => Map.get(data, "item_singular", @default_item_singular), - "item_plural" => Map.get(data, "item_plural", @default_item_plural) - } - end - - defp derive_requested_slug(nil, fallback_name) do - slugified = Publishing.slugify(fallback_name) - if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified} - end - - defp derive_requested_slug(slug, fallback_name) when is_binary(slug) do - trimmed = slug |> String.trim() - - cond do - trimmed == "" -> - slugified = Publishing.slugify(fallback_name) - if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified} - - Publishing.valid_slug?(trimmed) -> - {:ok, trimmed} - - true -> - {:error, :invalid_slug} - end - end - - defp derive_requested_slug(_other, fallback_name) do - slugified = Publishing.slugify(fallback_name) - if slugified == "", do: {:error, :invalid_slug}, else: {:ok, slugified} - end - - # Check if explicit slug already exists (only when preferred_slug is provided) - defp check_slug_availability(slug, groups, preferred_slug) when not is_nil(preferred_slug) do - if Enum.any?(groups, &(&1["slug"] == slug)) do - {:error, :already_exists} - else - :ok - end - end - - defp check_slug_availability(_slug, _groups, nil), do: :ok - - defp ensure_unique_slug(slug, groups), do: ensure_unique_slug(slug, groups, 2) - - defp ensure_unique_slug(slug, groups, counter) do - if Enum.any?(groups, &(&1["slug"] == slug)) do - ensure_unique_slug("#{slug}-#{counter}", groups, counter + 1) - else - slug - end - end - - defp normalize_mode(mode) when is_binary(mode) do - mode - |> String.downcase() - |> case do - "slug" -> "slug" - "timestamp" -> "timestamp" - _ -> nil - end - end - - defp normalize_mode(mode) when is_atom(mode), do: normalize_mode(Atom.to_string(mode)) - defp normalize_mode(_), do: nil - - # Normalize mode with default fallback - defp normalize_mode_with_default(nil), do: @default_group_mode - defp normalize_mode_with_default(mode), do: normalize_mode(mode) || @default_group_mode - - # Normalize and validate type - # Preset types are passed through, custom types are validated and normalized - defp normalize_type(nil), do: @default_group_type - - defp normalize_type(type) when is_binary(type) do - trimmed = String.trim(type) - downcased = String.downcase(trimmed) - - cond do - # Preset type - pass through as-is - downcased in @preset_types -> - downcased - - # Empty after trim - use default - trimmed == "" -> - @default_group_type - - # Custom type - validate format - true -> - # Normalize: downcase, replace spaces/underscores with hyphens - normalized = - downcased - |> String.replace(~r/[\s_]+/, "-") - |> String.replace(~r/[^a-z0-9-]/, "") - |> String.slice(0, 32) - - # Validate against type regex - if Regex.match?(@type_regex, normalized) do - normalized - else - nil - end - end - end - - defp normalize_type(type) when is_atom(type), do: normalize_type(Atom.to_string(type)) - defp normalize_type(_), do: nil - - # Get default item names for a type - defp default_item_names(type) do - Map.get(@type_item_names, type, {@default_item_singular, @default_item_plural}) - end - - # Normalize item name, using default if nil/empty - defp normalize_item_name(nil, default), do: default - defp normalize_item_name("", default), do: default - - defp normalize_item_name(name, default) when is_binary(name) do - trimmed = String.trim(name) - if trimmed == "", do: default, else: trimmed - end - - defp normalize_item_name(_, default), do: default - - @doc false - defdelegate fetch_option(opts, key), to: Shared -end diff --git a/lib/modules/publishing/language_helpers.ex b/lib/modules/publishing/language_helpers.ex deleted file mode 100644 index 043831604..000000000 --- a/lib/modules/publishing/language_helpers.ex +++ /dev/null @@ -1,280 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.LanguageHelpers do - @moduledoc """ - Pure language utility functions for the Publishing module. - - Provides language detection, display ordering, language info lookup, - and primary language management. - """ - - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Settings - - @doc """ - Returns all enabled language codes for multi-language support. - Falls back to content language if Languages module is disabled. - """ - @spec enabled_language_codes() :: [String.t()] - def enabled_language_codes do - if Languages.enabled?() do - Languages.get_enabled_language_codes() - else - [Settings.get_content_language()] - end - end - - @doc """ - Returns the primary/canonical language for versioning. - Uses Settings.get_content_language(). - """ - @spec get_primary_language() :: String.t() - def get_primary_language do - Settings.get_content_language() - end - - @doc """ - Gets language details (name, flag) for a given language code. - - Searches in order: - 1. Predefined languages (BeamLabCountries) - for full locale details - 2. User-configured languages - for custom/less common languages - """ - @spec get_language_info(String.t()) :: - %{code: String.t(), name: String.t(), flag: String.t()} | nil - def get_language_info(language_code) do - find_in_predefined_languages(language_code) || - find_in_configured_languages(language_code) - end - - @doc """ - Checks if a language code is enabled, considering base code matching. - - Handles cases where: - - The code is `en` and enabled languages has `"en-US"` -> matches - - The code is `en-US` and enabled languages has `"en"` -> matches - """ - @spec language_enabled?(String.t(), [String.t()]) :: boolean() - def language_enabled?(language_code, enabled_languages) do - if language_code in enabled_languages do - true - else - base_code = DialectMapper.extract_base(language_code) - - Enum.any?(enabled_languages, fn enabled_lang -> - enabled_lang == language_code or - DialectMapper.extract_base(enabled_lang) == base_code - end) - end - end - - @doc """ - Determines the display code for a language based on whether multiple dialects - of the same base language are enabled. - - If only one dialect of a base language is enabled (e.g., just "en-US"), - returns the base code ("en") for cleaner display. - - If multiple dialects are enabled (e.g., "en-US" and "en-GB"), - returns the full dialect code ("en-US") to distinguish them. - """ - @spec get_display_code(String.t(), [String.t()]) :: String.t() - def get_display_code(language_code, enabled_languages) do - base_code = DialectMapper.extract_base(language_code) - - dialects_count = - Enum.count(enabled_languages, fn lang -> - DialectMapper.extract_base(lang) == base_code - end) - - if dialects_count > 1 do - language_code - else - base_code - end - end - - @doc """ - Orders languages for display in the language switcher. - - Order: primary language first, then languages with translations (sorted), - then languages without translations (sorted). - """ - @spec order_languages_for_display([String.t()], [String.t()], String.t() | nil) :: [String.t()] - def order_languages_for_display(available_languages, enabled_languages, primary_language \\ nil) do - primary_lang = primary_language || get_primary_language() - - langs_with_content = - available_languages - |> Enum.reject(&(&1 == primary_lang)) - |> Enum.sort() - - langs_without_content = - enabled_languages - |> Enum.reject(&(&1 in available_languages or &1 == primary_lang)) - |> Enum.sort() - - [primary_lang] ++ langs_with_content ++ langs_without_content - end - - @doc """ - Checks if a language code is reserved (cannot be used as a slug). - """ - @spec reserved_language_code?(String.t()) :: boolean() - def reserved_language_code?(slug) do - language_codes = - try do - Languages.get_language_codes() - rescue - _ -> [] - end - - slug in language_codes - end - - # =========================================================================== - # Private Helpers - # =========================================================================== - - defp find_in_predefined_languages(language_code) do - case Languages.get_available_language_by_code(language_code) do - nil -> - base_code = DialectMapper.extract_base(language_code) - is_base_code = language_code == base_code and not String.contains?(language_code, "-") - default_dialect = DialectMapper.base_to_dialect(base_code) - - case Languages.get_available_language_by_code(default_dialect) do - nil -> - all_languages = Languages.get_available_languages() - - Enum.find(all_languages, fn lang -> - DialectMapper.extract_base(lang.code) == base_code - end) - - default_match -> - if is_base_code do - %{default_match | name: extract_base_language_name(default_match.name)} - else - default_match - end - end - - exact_match -> - exact_match - end - end - - defp extract_base_language_name(name) when is_binary(name) do - case String.split(name, " (", parts: 2) do - [base_name, _region] -> base_name - [base_name] -> base_name - end - end - - defp extract_base_language_name(name), do: name - - defp find_in_configured_languages(language_code) do - configured_languages = Languages.get_languages() - - exact_match = - Enum.find(configured_languages, fn lang -> lang.code == language_code end) - - result = - if exact_match do - exact_match - else - base_code = DialectMapper.extract_base(language_code) - default_dialect = DialectMapper.base_to_dialect(base_code) - - default_match = - Enum.find(configured_languages, fn lang -> lang.code == default_dialect end) - - if default_match do - default_match - else - Enum.find(configured_languages, fn lang -> - DialectMapper.extract_base(lang.code) == base_code - end) - end - end - - if result do - %{ - code: result.code, - name: result.name || result.code, - flag: result.flag || "" - } - else - nil - end - end - - # =========================================================================== - # Language Map Key Resolution - # =========================================================================== - - @doc """ - Resolves a display language code to a key in a language map. - - Language maps (e.g., `language_titles`, `language_slugs`) use full dialect - codes as keys (e.g., `"en-US"`), but the display/canonical language may be - a base code (e.g., `"en"`) when only one dialect is enabled. - - Tries exact match first, then falls back to base code matching. - """ - @spec resolve_language_key(String.t(), [String.t()]) :: String.t() - def resolve_language_key(language, available_keys) do - if language in available_keys do - language - else - base = DialectMapper.extract_base(language) - Enum.find(available_keys, language, fn key -> DialectMapper.extract_base(key) == base end) - end - end - - # =========================================================================== - # Post Language Building - # =========================================================================== - - @doc """ - Builds language data for a post's language switcher. - Returns a list of language maps with status, enabled flag, known flag, and metadata. - """ - def build_post_languages(post, enabled_languages, primary_language \\ nil) do - primary_lang = - primary_language || post[:primary_language] || get_primary_language() - - all_languages = - order_languages_for_display( - post.available_languages || [], - enabled_languages, - primary_lang - ) - - all_languages - |> Enum.map(&build_language_entry(&1, post, enabled_languages, primary_lang)) - |> Enum.filter(fn lang -> lang.exists || lang.enabled end) - end - - @doc """ - Builds a single language entry map for a post. - """ - def build_language_entry(lang_code, post, enabled_languages, primary_lang) do - lang_info = get_language_info(lang_code) - available = post.available_languages || [] - content_exists = lang_code in available - post_status = post[:metadata] && post.metadata.status - - %{ - code: lang_code, - display_code: get_display_code(lang_code, enabled_languages), - name: if(lang_info, do: lang_info.name, else: lang_code), - flag: if(lang_info, do: lang_info.flag, else: ""), - status: if(content_exists, do: post_status, else: nil), - exists: content_exists, - enabled: language_enabled?(lang_code, enabled_languages), - known: lang_info != nil, - is_primary: lang_code == primary_lang, - uuid: post[:uuid] - } - end -end diff --git a/lib/modules/publishing/listing_cache.ex b/lib/modules/publishing/listing_cache.ex deleted file mode 100644 index 770dd1ebc..000000000 --- a/lib/modules/publishing/listing_cache.ex +++ /dev/null @@ -1,728 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.ListingCache do - @moduledoc """ - Caches publishing group listing metadata in :persistent_term for sub-millisecond reads. - - Instead of querying the database on every request, the listing page reads from - an in-memory cache populated from the database. - - ## How It Works - - 1. When a post is created/updated/published, `regenerate/1` is called - 2. This queries the database and stores post metadata in :persistent_term - 3. `render_group_listing` reads from the in-memory cache - 4. Cache includes: title, slug, date, status, languages, versions (no content) - - ## Performance - - - Cache miss: ~20ms (DB query + store in :persistent_term) - - Cache hit: ~0.1μs (direct memory access, no variance) - - ## Cache Invalidation - - Cache is regenerated when: - - Post is created - - Post is updated (metadata or content) - - Post status changes (draft/published/archived) - - Translation is added - - Version is created - - ## In-Memory Caching with :persistent_term - - For sub-millisecond performance, parsed cache data is stored in `:persistent_term`. - - - First read after restart: queries DB, stores in :persistent_term (~20ms) - - Subsequent reads: direct memory access (~0.1μs, no variance) - - On regenerate: updates :persistent_term from DB - - On invalidate: clears :persistent_term entry (next read triggers regeneration) - """ - - alias PhoenixKit.Modules.Publishing.Constants - alias PhoenixKit.Modules.Publishing.DBStorage - - @timestamp_modes Constants.timestamp_modes() - alias PhoenixKit.Modules.Publishing.LanguageHelpers - alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Date, as: UtilsDate - - require Logger - - @persistent_term_prefix :phoenix_kit_group_listing_cache - @persistent_term_loaded_at_prefix :phoenix_kit_group_listing_cache_loaded_at - @persistent_term_cache_generated_at_prefix :phoenix_kit_group_listing_cache_generated_at - - # ETS table for regeneration locks (provides atomic test-and-set via insert_new) - @lock_table :phoenix_kit_listing_cache_locks - - # Settings key for memory cache toggle - @memory_cache_key "publishing_memory_cache_enabled" - - @doc """ - Reads the cached listing for a publishing group. - - Returns `{:ok, posts}` if cache exists and is valid. - Returns `{:error, :cache_miss}` if cache doesn't exist or caching is disabled. - - Respects the `publishing_memory_cache_enabled` setting. - """ - @spec read(String.t()) :: {:ok, [map()]} | {:error, :cache_miss} - def read(group_slug) do - if memory_cache_enabled?() do - term_key = persistent_term_key(group_slug) - - case safe_persistent_term_get(term_key) do - {:ok, _} = hit -> - hit - - :not_found -> - # Cache miss — regenerate from database - regenerate(group_slug) - - case safe_persistent_term_get(term_key) do - {:ok, _} = hit -> hit - :not_found -> {:error, :cache_miss} - end - end - else - {:error, :cache_miss} - end - end - - # Safely get from :persistent_term (returns :not_found instead of raising) - defp safe_persistent_term_get(key) do - {:ok, :persistent_term.get(key)} - rescue - ArgumentError -> :not_found - end - - @doc """ - Regenerates the listing cache for a group. - - Queries the database for all posts and stores the metadata in :persistent_term. - - This should be called after any post operation that changes the listing: - - create_post - - update_post - - add_language_to_post - - create_new_version - - Returns `:ok` on success or `{:error, reason}` on failure. - """ - @spec regenerate(String.t()) :: :ok | {:error, any()} - def regenerate(group_slug) do - if memory_cache_enabled?() do - do_regenerate(group_slug) - else - :ok - end - rescue - error -> - Logger.error( - "[ListingCache] Failed to regenerate cache for #{group_slug}: #{inspect(error)}" - ) - - {:error, {:regenerate_failed, error}} - end - - # Maximum number of posts to cache in :persistent_term per group. - # Groups exceeding this will still work but only cache the most recent posts. - @max_cached_posts 5000 - - defp do_regenerate(group_slug) do - start_time = System.monotonic_time(:millisecond) - - # Posts from to_listing_map are already atom-key maps with excerpts - all_posts = DBStorage.list_posts_for_listing(group_slug) - - posts = - if length(all_posts) > @max_cached_posts do - Logger.warning( - "[ListingCache] Group #{group_slug} has #{length(all_posts)} posts, caching most recent #{@max_cached_posts}" - ) - - Enum.take(all_posts, @max_cached_posts) - else - all_posts - end - - generated_at = UtilsDate.utc_now() |> DateTime.to_iso8601() - - safe_persistent_term_put(persistent_term_key(group_slug), posts) - safe_persistent_term_put(loaded_at_key(group_slug), generated_at) - safe_persistent_term_put(cache_generated_at_key(group_slug), generated_at) - - elapsed = System.monotonic_time(:millisecond) - start_time - - Logger.debug( - "[ListingCache] Regenerated cache from DB for #{group_slug} (#{length(posts)} posts) in #{elapsed}ms" - ) - - PublishingPubSub.broadcast_cache_changed(group_slug) - :ok - rescue - error -> - Logger.error( - "[ListingCache] Failed to regenerate cache for #{group_slug}: #{inspect(error)}" - ) - - {:error, {:regenerate_failed, error}} - end - - # Lock timeout in milliseconds (30 seconds) - # If a lock is older than this, it's considered stale (process likely died) - @lock_timeout_ms 30_000 - - @doc """ - Regenerates the cache if no other process is already regenerating it. - - This prevents the "thundering herd" problem where multiple concurrent requests - all trigger cache regeneration simultaneously after a server restart. - - Uses ETS with `insert_new/2` for atomic lock acquisition - only one process - can acquire the lock at a time. The lock includes a timestamp and will be - considered stale after #{@lock_timeout_ms}ms to prevent permanent lockout - if a process dies mid-regeneration. - - Returns: - - `:ok` if regeneration was performed successfully - - `:already_in_progress` if another process is currently regenerating - - `{:error, reason}` if regeneration failed - - ## Usage - - On cache miss in read paths, use this instead of `regenerate/1`: - - case ListingCache.regenerate_if_not_in_progress(group_slug) do - :ok -> # Cache is ready, read from it - :already_in_progress -> # Another process is regenerating, try again later - {:error, _} -> # Regeneration failed, query DB directly - end - """ - @spec regenerate_if_not_in_progress(String.t()) :: :ok | :already_in_progress | {:error, any()} - def regenerate_if_not_in_progress(group_slug) do - ensure_lock_table_exists() - now = System.monotonic_time(:millisecond) - - # Try to atomically acquire the lock using ETS insert_new - # Returns true if inserted (lock acquired), false if key already exists - case :ets.insert_new(@lock_table, {group_slug, now}) do - true -> - # We acquired the lock - perform regeneration - do_regenerate_with_lock(group_slug) - - false -> - # Lock exists - check if it's stale - handle_existing_lock(group_slug, now) - end - end - - # Handle case where lock already exists - check staleness - defp handle_existing_lock(group_slug, now) do - case :ets.lookup(@lock_table, group_slug) do - [{^group_slug, lock_timestamp}] -> - lock_age = now - lock_timestamp - - if lock_age < @lock_timeout_ms do - # Lock is valid and recent - another process is regenerating - Logger.debug( - "[ListingCache] Regeneration already in progress for #{group_slug} (#{lock_age}ms ago), skipping" - ) - - :already_in_progress - else - # Lock is stale - previous process likely died - # Try to take over by deleting and re-acquiring atomically - take_over_stale_lock(group_slug, lock_timestamp, lock_age, now) - end - - [] -> - # Lock was released between insert_new and lookup - try again - regenerate_if_not_in_progress(group_slug) - end - end - - # Attempt to take over a stale lock using compare-and-delete - defp take_over_stale_lock(group_slug, old_timestamp, lock_age, now) do - # Use match_delete for atomic compare-and-delete - # Only deletes if the timestamp matches (no one else took over) - case :ets.select_delete(@lock_table, [{{group_slug, old_timestamp}, [], [true]}]) do - 1 -> - # Successfully deleted stale lock - now try to acquire - Logger.warning( - "[ListingCache] Found stale lock for #{group_slug} (#{lock_age}ms old), taking over regeneration" - ) - - case :ets.insert_new(@lock_table, {group_slug, now}) do - true -> - do_regenerate_with_lock(group_slug) - - false -> - # Another process beat us to it - :already_in_progress - end - - 0 -> - # Lock was already taken over by another process or timestamp changed - :already_in_progress - end - end - - # Perform regeneration while holding the lock - defp do_regenerate_with_lock(group_slug) do - result = regenerate(group_slug) - - case result do - :ok -> :ok - {:error, _} = error -> error - end - after - # Always release the lock when done (success or failure) - :ets.delete(@lock_table, group_slug) - end - - # Ensure the ETS table for locks exists (lazy initialization) - defp ensure_lock_table_exists do - case :ets.whereis(@lock_table) do - :undefined -> - # Table doesn't exist - create it - # Use :public so any process can read/write - # Use :named_table so we can reference by atom - # Use :set for key-value storage - try do - :ets.new(@lock_table, [:set, :public, :named_table]) - rescue - ArgumentError -> - # Table was created by another process between whereis and new - :ok - end - - _tid -> - :ok - end - end - - # Safely put to :persistent_term (logs warning on failure instead of crashing) - defp safe_persistent_term_put(key, value) do - :persistent_term.put(key, value) - rescue - error -> - Logger.warning("[ListingCache] Failed to write to :persistent_term: #{inspect(error)}") - :error - end - - @doc """ - Loads the cache from the database into :persistent_term. - - Returns `:ok` if successful or `{:error, reason}` on failure. - """ - @spec load_into_memory(String.t()) :: :ok | {:error, any()} - def load_into_memory(group_slug) do - load_into_memory_from_db(group_slug) - end - - defp load_into_memory_from_db(group_slug) do - posts = DBStorage.list_posts_for_listing(group_slug) - generated_at = UtilsDate.utc_now() |> DateTime.to_iso8601() - - safe_persistent_term_put(persistent_term_key(group_slug), posts) - safe_persistent_term_put(loaded_at_key(group_slug), generated_at) - safe_persistent_term_put(cache_generated_at_key(group_slug), generated_at) - - Logger.debug( - "[ListingCache] Loaded #{group_slug} from DB into :persistent_term (#{length(posts)} posts)" - ) - - PublishingPubSub.broadcast_cache_changed(group_slug) - :ok - rescue - error -> - Logger.error("[ListingCache] Failed to load #{group_slug} from DB: #{inspect(error)}") - - {:error, {:load_failed, error}} - end - - @doc """ - Invalidates (clears) the cache for a group. - - Clears the :persistent_term entries. The next read will trigger - a regeneration from the database. - """ - @spec invalidate(String.t()) :: :ok - def invalidate(group_slug) do - # Clear :persistent_term entries - term_key = persistent_term_key(group_slug) - - try do - :persistent_term.erase(term_key) - rescue - ArgumentError -> :ok - end - - try do - :persistent_term.erase(loaded_at_key(group_slug)) - rescue - ArgumentError -> :ok - end - - try do - :persistent_term.erase(cache_generated_at_key(group_slug)) - rescue - ArgumentError -> :ok - end - - Logger.debug("[ListingCache] Invalidated cache for #{group_slug}") - :ok - end - - @doc """ - Checks if a cache exists for a group in :persistent_term. - """ - @spec exists?(String.t()) :: boolean() - def exists?(group_slug) do - case safe_persistent_term_get(persistent_term_key(group_slug)) do - {:ok, _} -> true - :not_found -> false - end - end - - @doc """ - Finds a post by slug in the cache. - - This is useful for single post views where we need metadata (language_statuses, - version_statuses, allow_version_access) without a separate DB query. - - Returns `{:ok, cached_post}` if found, `{:error, :not_found}` otherwise. - """ - @spec find_post(String.t(), String.t()) :: {:ok, map()} | {:error, :not_found | :cache_miss} - def find_post(group_slug, post_slug) do - case read(group_slug) do - {:ok, posts} -> - case Enum.find(posts, fn p -> p.slug == post_slug end) do - nil -> {:error, :not_found} - post -> {:ok, post} - end - - {:error, _} = error -> - error - end - end - - @doc """ - Finds a post by path pattern in the cache (for timestamp mode). - - Matches posts where the path contains the date/time pattern. - Returns `{:ok, cached_post}` if found, `{:error, :not_found}` otherwise. - """ - @spec find_post_by_path(String.t(), String.t(), String.t()) :: - {:ok, map()} | {:error, :not_found | :cache_miss} - def find_post_by_path(group_slug, date, time) do - case read(group_slug) do - {:ok, posts} -> - # Match posts using discrete date and time fields (more robust than path string matching) - # Parse the input date string to compare with the cached Date struct - target_date = parse_date_for_lookup(date) - # Normalize time format (handles both "HH:MM" and "HH:MM:SS") - target_time = normalize_time_for_lookup(time) - - case Enum.find(posts, fn p -> - dates_match?(p.date, target_date) && times_match?(p.time, target_time) - end) do - nil -> {:error, :not_found} - post -> {:ok, post} - end - - {:error, _} = error -> - error - end - end - - # Parse date string for lookup comparison - defp parse_date_for_lookup(date_str) when is_binary(date_str) do - case Date.from_iso8601(date_str) do - {:ok, date} -> date - _ -> date_str - end - end - - defp parse_date_for_lookup(date), do: date - - # Normalize time to "HH:MM" format for comparison - defp normalize_time_for_lookup(time_str) when is_binary(time_str) do - # Take just HH:MM portion - String.slice(time_str, 0, 5) - end - - defp normalize_time_for_lookup(time), do: time - - # Compare dates - handles both Date structs and strings - defp dates_match?(nil, _), do: false - defp dates_match?(_, nil), do: false - - defp dates_match?(%Date{} = cached, %Date{} = target) do - Date.compare(cached, target) == :eq - end - - defp dates_match?(%Date{} = cached, target_str) when is_binary(target_str) do - Date.to_iso8601(cached) == target_str - end - - defp dates_match?(_, _), do: false - - # Compare times - handles Time structs and "HH:MM" strings - defp times_match?(nil, _), do: false - defp times_match?(_, nil), do: false - - defp times_match?(%Time{} = cached, target_str) when is_binary(target_str) do - # Format cached time as HH:MM and compare - cached_str = cached |> Time.to_string() |> String.slice(0, 5) - cached_str == target_str - end - - defp times_match?(cached_str, target_str) - when is_binary(cached_str) and is_binary(target_str) do - String.slice(cached_str, 0, 5) == String.slice(target_str, 0, 5) - end - - defp times_match?(_, _), do: false - - @doc """ - Finds a post by URL slug for a specific language. - - This enables O(1) lookup from URL slug to internal identifier, supporting - per-language URL slugs for SEO-friendly localized URLs. - - ## Parameters - - `group_slug` - The publishing group - - `language` - The language code to search in - - `url_slug` - The URL slug to find - - ## Returns - - `{:ok, cached_post}` - Found post (includes internal `slug` for DB lookup) - - `{:error, :not_found}` - No post with this URL slug for this language - - `{:error, :cache_miss}` - Cache not available - """ - @spec find_by_url_slug(String.t(), String.t(), String.t()) :: - {:ok, map()} | {:error, :not_found | :cache_miss} - def find_by_url_slug(group_slug, language, url_slug) do - case read(group_slug) do - {:ok, posts} -> find_post_by_url_slug(posts, language, url_slug) - {:error, _} -> {:error, :cache_miss} - end - end - - defp find_post_by_url_slug(posts, language, url_slug) do - case Enum.find(posts, &(Map.get(&1.language_slugs || %{}, language) == url_slug)) do - nil -> {:error, :not_found} - post -> {:ok, post} - end - end - - @doc """ - Finds a post by a previous URL slug for 301 redirects. - - When a URL slug changes, the old slug is stored in `previous_url_slugs`. - This function finds posts that previously used the given URL slug. - - ## Returns - - `{:ok, cached_post}` - Found post that previously used this slug - - `{:error, :not_found}` - No post with this previous slug - - `{:error, :cache_miss}` - Cache not available - """ - @spec find_by_previous_url_slug(String.t(), String.t(), String.t()) :: - {:ok, map()} | {:error, :not_found | :cache_miss} - def find_by_previous_url_slug(group_slug, language, url_slug) do - case read(group_slug) do - {:ok, posts} -> find_post_by_previous_slug(posts, language, url_slug) - {:error, _} -> {:error, :cache_miss} - end - end - - defp find_post_by_previous_slug(posts, language, url_slug) do - case Enum.find(posts, &post_has_previous_slug?(&1, language, url_slug)) do - nil -> {:error, :not_found} - post -> {:ok, post} - end - end - - defp post_has_previous_slug?(post, language, url_slug) do - lang_previous_slugs = Map.get(post, :language_previous_slugs) || %{} - previous_for_lang = Map.get(lang_previous_slugs, language) || [] - - url_slug in previous_for_lang - end - - @doc """ - Finds a cached post by mode — uses date/time lookup for timestamp mode, slug for others. - """ - def find_post_by_mode(group_slug, post) do - mode = Map.get(post, :mode) - - if mode in @timestamp_modes do - date = post[:date] - time = post[:time] - - if date && time do - date_str = if is_struct(date, Date), do: Date.to_iso8601(date), else: to_string(date) - time_str = format_time_for_cache(time) - find_post_by_path(group_slug, date_str, time_str) - else - {:error, :not_found} - end - else - find_post(group_slug, post.slug) - end - end - - defp format_time_for_cache(%Time{} = time) do - time |> Time.to_string() |> String.slice(0, 5) - end - - defp format_time_for_cache(time) when is_binary(time), do: String.slice(time, 0, 5) - defp format_time_for_cache(_), do: "" - - @doc """ - Returns the :persistent_term key for a publishing group's cache. - """ - @spec persistent_term_key(String.t()) :: tuple() - def persistent_term_key(group_slug) do - {@persistent_term_prefix, group_slug} - end - - @doc """ - Returns the :persistent_term key for tracking when the memory cache was loaded. - """ - @spec loaded_at_key(String.t()) :: tuple() - def loaded_at_key(group_slug) do - {@persistent_term_loaded_at_prefix, group_slug} - end - - @doc """ - Returns when the memory cache was loaded (ISO 8601 string), or nil if not loaded. - """ - @spec memory_loaded_at(String.t()) :: String.t() | nil - def memory_loaded_at(group_slug) do - case safe_persistent_term_get(loaded_at_key(group_slug)) do - {:ok, loaded_at} -> loaded_at - :not_found -> nil - end - end - - @doc """ - Returns the :persistent_term key for tracking when the cache was last generated. - """ - @spec cache_generated_at_key(String.t()) :: tuple() - def cache_generated_at_key(group_slug) do - {@persistent_term_cache_generated_at_prefix, group_slug} - end - - @doc """ - Returns the timestamp of when the cache was last generated from the database. - """ - @spec cache_generated_at(String.t()) :: String.t() | nil - def cache_generated_at(group_slug) do - case safe_persistent_term_get(cache_generated_at_key(group_slug)) do - {:ok, generated_at} -> generated_at - :not_found -> nil - end - end - - @doc """ - Returns whether memory caching (:persistent_term) is enabled. - Uses cached settings to avoid database queries on every call. - """ - @spec memory_cache_enabled?() :: boolean() - def memory_cache_enabled? do - Settings.get_setting_cached(@memory_cache_key, "true") == "true" - end - - @doc """ - Returns a list of posts that need primary_language migration. - - This checks all posts in a group and returns those that either: - 1. Have no `primary_language` stored (need backfill) - 2. Have `primary_language` different from global setting (need migration decision) - """ - @spec posts_needing_primary_language_migration(String.t()) :: [map()] - def posts_needing_primary_language_migration(group_slug) do - case read(group_slug) do - {:ok, posts} -> - global_primary = LanguageHelpers.get_primary_language() - - Enum.filter(posts, fn post -> - # Use atom key since normalized posts use atoms - stored_primary = post[:primary_language] - stored_primary == nil or stored_primary != global_primary - end) - - {:error, _} -> - # If cache doesn't exist, query DB directly - scan_posts_needing_migration(group_slug) - end - end - - defp scan_posts_needing_migration(group_slug) do - global_primary = LanguageHelpers.get_primary_language() - - DBStorage.list_posts_for_listing(group_slug) - |> Enum.filter(fn post -> - stored_primary = post[:primary_language] - stored_primary == nil or stored_primary != global_primary - end) - end - - @doc """ - Counts posts by primary_language status in a group. - - Returns `%{current: n, needs_migration: n, needs_backfill: n}` where: - - `current` - posts with primary_language matching global setting - - `needs_migration` - posts with different primary_language (were created under old setting) - - `needs_backfill` - posts with no primary_language stored - """ - @spec count_primary_language_status(String.t()) :: map() - def count_primary_language_status(group_slug) do - case read(group_slug) do - {:ok, posts} -> - global_primary = LanguageHelpers.get_primary_language() - - Enum.reduce(posts, %{current: 0, needs_migration: 0, needs_backfill: 0}, fn post, acc -> - # Use atom key since normalized posts use atoms - stored_primary = post[:primary_language] - - cond do - stored_primary == nil -> - %{acc | needs_backfill: acc.needs_backfill + 1} - - stored_primary == global_primary -> - %{acc | current: acc.current + 1} - - true -> - %{acc | needs_migration: acc.needs_migration + 1} - end - end) - - {:error, _} -> - # If cache doesn't exist, query DB directly - scan_primary_language_status(group_slug) - end - end - - defp scan_primary_language_status(group_slug) do - global_primary = LanguageHelpers.get_primary_language() - - DBStorage.list_posts_for_listing(group_slug) - |> Enum.reduce(%{current: 0, needs_migration: 0, needs_backfill: 0}, fn post, acc -> - stored_primary = post[:primary_language] - - cond do - stored_primary == nil -> - %{acc | needs_backfill: acc.needs_backfill + 1} - - stored_primary == global_primary -> - %{acc | current: acc.current + 1} - - true -> - %{acc | needs_migration: acc.needs_migration + 1} - end - end) - end -end diff --git a/lib/modules/publishing/metadata.ex b/lib/modules/publishing/metadata.ex deleted file mode 100644 index 1f6ea630f..000000000 --- a/lib/modules/publishing/metadata.ex +++ /dev/null @@ -1,162 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Metadata do - @moduledoc """ - Content metadata helpers for the Publishing module. - - Provides title extraction from markdown/component content. - """ - - @default_title PhoenixKit.Modules.Publishing.Constants.default_title() - - @doc """ - Extracts title from markdown content. - Looks for the first H1 heading (# Title) within the first few lines. - Falls back to the first line if no H1 found. - """ - @spec extract_title_from_content(String.t()) :: String.t() - def extract_title_from_content(content) when is_binary(content) do - content - |> String.trim() - |> do_extract_title() - end - - def extract_title_from_content(_), do: @default_title - - defp do_extract_title(""), do: @default_title - - defp do_extract_title(content) do - content - |> extract_title_from_lines() - |> case do - @default_title -> - extract_title_from_components(content) || @default_title - - title -> - title - end - end - - defp extract_title_from_lines(""), do: @default_title - - defp extract_title_from_lines(content) do - lines = - content - |> extract_candidate_lines() - |> Enum.take(15) - - # Look for first H1 heading (# Title) - h1_line = - Enum.find(lines, fn line -> - String.starts_with?(line, "# ") and String.length(line) > 2 - end) - - cond do - h1_line != nil -> - h1_line - |> String.trim_leading("# ") - |> String.trim() - - not Enum.empty?(lines) -> - List.first(lines) - |> String.slice(0, 100) - - true -> - @default_title - end - end - - defp extract_candidate_lines(content) do - {lines, _depth} = - content - |> String.split("\n") - |> Enum.reduce({[], 0}, fn raw_line, {acc, depth} -> - line = String.trim(raw_line) - - cond do - line == "" and depth == 0 -> - {acc, depth} - - component_self_closing?(line) -> - {acc, depth} - - component_open?(line) -> - {acc, depth + 1} - - depth > 0 and multiline_self_close?(raw_line) -> - {acc, max(depth - 1, 0)} - - component_close?(line) and depth > 0 -> - {acc, max(depth - 1, 0)} - - depth > 0 -> - {acc, depth} - - true -> - {[line | acc], depth} - end - end) - - lines - |> Enum.reverse() - |> Enum.reject(&(&1 == "")) - end - - defp component_open?(line) do - String.starts_with?(line, "<") and - not String.starts_with?(line, "}, line) - end - - defp component_self_closing?(line) do - component_open?(line) and String.ends_with?(line, "/>") - end - - defp multiline_self_close?(line) do - line - |> String.trim() - |> case do - "/>" -> true - ">" -> false - other -> String.ends_with?(other, "/>") - end - end - - defp extract_title_from_components(content) do - component_title(content, "Headline") || - component_attribute(content, "Hero", "title") || - component_title(content, "Title") - end - - defp component_title(content, tag) do - regex = ~r/<#{tag}\b[^>]*>(.*?)<\/#{tag}>/is - - case Regex.run(regex, content, capture: :all_but_first) do - [inner | _] -> sanitize_component_text(inner) - _ -> nil - end - end - - defp component_attribute(content, tag, attr) do - regex = ~r/<#{tag}\b[^>]*#{attr}="([^"]+)"[^>]*>/i - - case Regex.run(regex, content, capture: :all_but_first) do - [value | _] -> sanitize_component_text(value) - _ -> nil - end - end - - defp sanitize_component_text(text) do - text - |> String.trim() - |> String.replace(~r/<[^>]+>/, "") - |> String.replace(~r/\s+/, " ") - |> String.trim() - |> case do - "" -> nil - cleaned -> String.slice(cleaned, 0, 100) - end - end -end diff --git a/lib/modules/publishing/page_builder.ex b/lib/modules/publishing/page_builder.ex deleted file mode 100644 index eb0409c93..000000000 --- a/lib/modules/publishing/page_builder.ex +++ /dev/null @@ -1,99 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PageBuilder do - @moduledoc """ - Rendering pipeline for PHK (PhoenixKit) page content. - - Processes component-based page definitions through: - 1. Parse XML to AST - 2. Inject dynamic data ({{variable}} placeholders) - 3. Resolve components (map to actual component modules) - 4. Apply theme/variants - 5. Render to HTML - """ - - alias PhoenixKit.Modules.Publishing.PageBuilder.Parser - alias PhoenixKit.Modules.Publishing.PageBuilder.Renderer - - @type assigns :: map() - @type ast :: map() - @type render_result :: {:ok, Phoenix.LiveView.Rendered.t()} | {:error, term()} - - @doc """ - Renders PHK content directly from a string. - """ - @spec render_content(String.t(), assigns()) :: render_result() - def render_content(content, assigns \\ %{}) do - with {:ok, ast} <- parse_to_ast(content), - {:ok, ast_with_data} <- inject_dynamic_data(ast, assigns), - {:ok, resolved} <- resolve_components(ast_with_data), - {:ok, themed} <- apply_theme(resolved, assigns), - {:ok, html} <- render_to_html(themed, assigns) do - {:ok, html} - else - {:error, reason} -> {:error, reason} - end - end - - # Step 1: Parse XML to AST - defp parse_to_ast(content) do - Parser.parse(content) - end - - # Step 3: Inject dynamic data (replace {{variable}} placeholders) - defp inject_dynamic_data(ast, assigns) do - {:ok, inject_assigns(ast, assigns)} - end - - # Step 4: Resolve components (map XML tags to actual component modules) - defp resolve_components(ast) do - {:ok, ast} - end - - # Step 5: Apply theme/variant settings - defp apply_theme(ast, _assigns) do - {:ok, ast} - end - - # Step 6: Render to HTML - defp render_to_html(ast, assigns) do - Renderer.render(ast, assigns) - end - - # Recursively inject assigns into AST nodes - defp inject_assigns(ast, assigns) when is_map(ast) do - ast - |> Map.update(:content, nil, &inject_assigns(&1, assigns)) - |> Map.update(:attributes, %{}, &inject_assigns(&1, assigns)) - |> Map.update(:children, [], &inject_assigns(&1, assigns)) - end - - defp inject_assigns(ast, assigns) when is_list(ast) do - Enum.map(ast, &inject_assigns(&1, assigns)) - end - - defp inject_assigns(content, assigns) when is_binary(content) do - interpolate_string(content, assigns) - end - - defp inject_assigns(value, _assigns), do: value - - # Interpolate {{variable}} placeholders - defp interpolate_string(string, assigns) do - Regex.replace(~r/\{\{([^}]+)\}\}/, string, fn _, path -> - get_nested_value(assigns, String.trim(path)) |> to_string() - end) - end - - # Get nested value from assigns (e.g., "user.name" -> assigns.user.name) - defp get_nested_value(map, path) do - path - |> String.split(".") - |> Enum.reduce(map, fn key, acc -> - case acc do - %{} -> Map.get(acc, key) || Map.get(acc, String.to_existing_atom(key)) - _ -> nil - end - end) - rescue - _ -> "" - end -end diff --git a/lib/modules/publishing/page_builder/parser.ex b/lib/modules/publishing/page_builder/parser.ex deleted file mode 100644 index 436390332..000000000 --- a/lib/modules/publishing/page_builder/parser.ex +++ /dev/null @@ -1,152 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PageBuilder.Parser do - @moduledoc """ - Parses PHK (PhoenixKit) XML-style markup into an AST. - - Example input: - ```xml - - - Welcome to PhoenixKit - Build faster with {{framework}} - Get Started - - - ``` - - Output AST: - ```elixir - %{ - type: :page, - attributes: %{slug: "home"}, - children: [ - %{ - type: :hero, - attributes: %{variant: "split-image"}, - children: [ - %{type: :headline, content: "Welcome to PhoenixKit"}, - %{type: :subheadline, content: "Build faster with {{framework}}"}, - %{type: :cta, attributes: %{primary: "true", action: "/signup"}, content: "Get Started"} - ] - } - ] - } - ``` - """ - - @doc """ - Parses PHK XML content into an AST. - """ - @spec parse(String.t()) :: {:ok, map()} | {:error, term()} - def parse(content) when is_binary(content) do - content = String.trim(content) - - case Saxy.parse_string( - content, - PhoenixKit.Modules.Publishing.PageBuilder.SaxHandler, - [] - ) do - {:ok, ast} -> {:ok, ast} - {:error, reason} -> {:error, {:parse_error, reason}} - end - rescue - e -> {:error, {:parse_exception, e}} - end - - def parse(_), do: {:error, :invalid_content} -end - -defmodule PhoenixKit.Modules.Publishing.PageBuilder.SaxHandler do - @moduledoc false - @behaviour Saxy.Handler - - def handle_event(:start_document, _prolog, _state) do - {:ok, %{stack: [], result: nil}} - end - - def handle_event(:end_document, _data, state) do - {:ok, state.result} - end - - def handle_event(:start_element, {name, attributes}, state) do - node = %{ - type: normalize_tag_name(name), - attributes: parse_attributes(attributes), - children: [], - content: nil - } - - new_state = %{state | stack: [node | state.stack]} - {:ok, new_state} - end - - def handle_event(:end_element, _name, %{stack: [current | rest]} = state) do - # Simplify node if it only has content and no children - simplified = - cond do - current.children == [] and is_binary(current.content) -> - %{ - type: current.type, - attributes: current.attributes, - content: String.trim(current.content) - } - - current.content == nil and current.children != [] -> - %{ - type: current.type, - attributes: current.attributes, - children: Enum.reverse(current.children) - } - - true -> - %{ - type: current.type, - attributes: current.attributes, - children: Enum.reverse(current.children), - content: current.content && String.trim(current.content) - } - end - - case rest do - [] -> - {:ok, %{state | stack: [], result: simplified}} - - [parent | ancestors] -> - updated_parent = %{parent | children: [simplified | parent.children]} - {:ok, %{state | stack: [updated_parent | ancestors]}} - end - end - - def handle_event(:characters, chars, %{stack: [current | rest]} = state) do - trimmed = String.trim(chars) - - updated_current = - if trimmed != "" do - case current.content do - nil -> %{current | content: chars} - existing -> %{current | content: existing <> chars} - end - else - current - end - - {:ok, %{state | stack: [updated_current | rest]}} - end - - def handle_event(:characters, _chars, state) do - {:ok, state} - end - - # Normalize tag names to atoms (Page -> :page, Hero -> :hero) - defp normalize_tag_name(name) do - name - |> String.downcase() - |> String.to_atom() - end - - # Convert attribute list to map with string keys - defp parse_attributes(attrs) do - Enum.into(attrs, %{}, fn {key, value} -> - {String.downcase(key), value} - end) - end -end diff --git a/lib/modules/publishing/page_builder/renderer.ex b/lib/modules/publishing/page_builder/renderer.ex deleted file mode 100644 index 9f0e5a42d..000000000 --- a/lib/modules/publishing/page_builder/renderer.ex +++ /dev/null @@ -1,100 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PageBuilder.Renderer do - @moduledoc """ - Renders AST nodes to HTML by delegating to component modules. - """ - - @doc """ - Renders an AST node to HTML. - """ - def render(ast, assigns) when is_map(ast) do - case resolve_component(ast.type) do - {:ok, component_module} -> - render_component(component_module, ast, assigns) - - {:error, :not_found} -> - # Fallback for unknown components - render_unknown(ast, assigns) - end - end - - def render(ast, _assigns) when is_list(ast) do - {:ok, - Phoenix.HTML.raw( - Enum.map_join(ast, fn node -> - case render(node, %{}) do - {:ok, html} -> Phoenix.HTML.safe_to_string(html) - {:error, _} -> "" - end - end) - )} - end - - def render(content, _assigns) when is_binary(content) do - {:ok, Phoenix.HTML.raw(content)} - end - - # Resolve component type to module - defp resolve_component(:page), do: {:ok, PhoenixKit.Modules.Shared.Components.Page} - defp resolve_component(:hero), do: {:ok, PhoenixKit.Modules.Shared.Components.Hero} - defp resolve_component(:headline), do: {:ok, PhoenixKit.Modules.Shared.Components.Headline} - - defp resolve_component(:subheadline), - do: {:ok, PhoenixKit.Modules.Shared.Components.Subheadline} - - defp resolve_component(:cta), do: {:ok, PhoenixKit.Modules.Shared.Components.CTA} - defp resolve_component(:image), do: {:ok, PhoenixKit.Modules.Shared.Components.Image} - defp resolve_component(:video), do: {:ok, PhoenixKit.Modules.Shared.Components.Video} - - defp resolve_component(:entityform), - do: {:ok, PhoenixKit.Modules.Shared.Components.EntityForm} - - defp resolve_component(_), do: {:error, :not_found} - - # Render using the component module - defp render_component(component_module, ast, assigns) do - component_assigns = build_component_assigns(ast, assigns) - - try do - html = component_module.render(component_assigns) - {:ok, html} - rescue - e -> - {:error, {:render_error, e}} - end - end - - # Build assigns map for component - defp build_component_assigns(ast, parent_assigns) do - base_assigns = %{ - __changed__: nil, - variant: Map.get(ast.attributes, "variant", "default"), - attributes: ast.attributes, - content: ast[:content], - children: ast[:children] || [] - } - - Map.merge(parent_assigns, base_assigns) - end - - # Fallback renderer for unknown components - defp render_unknown(ast, assigns) do - content = - cond do - ast[:content] -> - ast.content - - ast[:children] -> - Enum.map_join(ast.children, fn child -> - case render(child, assigns) do - {:ok, html} -> Phoenix.HTML.safe_to_string(html) - _ -> "" - end - end) - - true -> - "" - end - - {:ok, Phoenix.HTML.raw("
#{content}
")} - end -end diff --git a/lib/modules/publishing/posts.ex b/lib/modules/publishing/posts.ex deleted file mode 100644 index 09f8d059c..000000000 --- a/lib/modules/publishing/posts.ex +++ /dev/null @@ -1,818 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Posts do - @moduledoc """ - Post CRUD operations for the Publishing module. - - Handles creating, reading, updating, and trashing posts, - as well as slug/version/language extraction and timestamp management. - """ - - require Logger - - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Constants - - @timestamp_modes Constants.timestamp_modes() - alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Modules.Publishing.LanguageHelpers - alias PhoenixKit.Modules.Publishing.ListingCache - alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Shared - alias PhoenixKit.Modules.Publishing.SlugHelpers - alias PhoenixKit.Modules.Publishing.StaleFixer - alias PhoenixKit.Utils.Date, as: UtilsDate - - # Suppress dialyzer false positives for pattern matches - @dialyzer {:nowarn_function, create_post: 2} - - @max_timestamp_attempts 60 - - @doc """ - Returns true when the given post is a DB-backed post (has a UUID). - """ - @spec db_post?(map()) :: boolean() - def db_post?(post), do: not is_nil(post[:uuid]) - - @doc "Counts posts on a specific date for a group." - def count_posts_on_date(group_slug, date) do - group_slug - |> list_times_on_date(date) - |> length() - end - - @doc "Lists time values for posts on a specific date." - def list_times_on_date(group_slug, date) do - date = if is_binary(date), do: Date.from_iso8601!(date), else: date - - group_slug - |> DBStorage.list_posts_timestamp_mode("published", date: date) - |> Enum.map(&(Time.to_string(&1.post_time) |> String.slice(0, 5))) - |> Enum.uniq() - |> Enum.sort() - rescue - e -> - Logger.warning( - "[Publishing] list_times_on_date failed for #{group_slug}/#{date}: #{inspect(e)}" - ) - - [] - end - - @doc """ - Finds a post by URL slug from the database. - """ - @spec find_by_url_slug(String.t(), String.t(), String.t()) :: - {:ok, map()} | {:error, :not_found | :cache_miss} - def find_by_url_slug(group_slug, language, url_slug) do - case DBStorage.find_by_url_slug(group_slug, language, url_slug) do - nil -> {:error, :not_found} - content -> {:ok, db_content_to_post_map(content)} - end - end - - @doc """ - Finds a post by a previous URL slug (for 301 redirects). - """ - @spec find_by_previous_url_slug(String.t(), String.t(), String.t()) :: - {:ok, map()} | {:error, :not_found | :cache_miss} - def find_by_previous_url_slug(group_slug, language, url_slug) do - case DBStorage.find_by_previous_url_slug(group_slug, language, url_slug) do - nil -> {:error, :not_found} - content -> {:ok, db_content_to_post_map(content)} - end - end - - @doc """ - Lists posts for a given publishing group slug. - - Queries the database directly via DBStorage. - The optional second argument is accepted for API compatibility but unused. - """ - @spec list_posts(String.t(), String.t() | nil) :: [map()] - def list_posts(group_slug, _preferred_language \\ nil) do - DBStorage.list_posts_with_metadata(group_slug) - end - - @doc "Lists posts filtered by status (e.g. 'trashed', 'published')." - @spec list_posts_by_status(String.t(), String.t()) :: [map()] - def list_posts_by_status(group_slug, status) do - DBStorage.list_posts_with_metadata(group_slug, status) - end - - @doc "Lists raw DB post records for a group, optionally filtered by status." - @spec list_raw_posts(String.t(), String.t() | nil) :: [struct()] - def list_raw_posts(group_slug, status \\ nil) do - if status, - do: DBStorage.list_posts(group_slug, status), - else: DBStorage.list_posts(group_slug) - end - - @doc "Counts primary language migration status from a list of posts." - @spec count_primary_language_status(list(), String.t()) :: map() | nil - def count_primary_language_status([], _primary), do: nil - - def count_primary_language_status(posts, primary_language) do - DBStorage.count_primary_language_status_from_posts(posts, primary_language) - end - - @doc """ - Creates a new post for the given publishing group using the current timestamp. - """ - @spec create_post(String.t(), map() | keyword()) :: {:ok, map()} | {:error, any()} - def create_post(group_slug, opts \\ %{}) do - create_post_in_db(group_slug, opts) - end - - @doc """ - Reads a post by its database UUID. - - Resolves the UUID to a group slug and post slug, then delegates to `read_post/4`. - Invalid version/language params gracefully fall back to latest/primary. - """ - def read_post_by_uuid(post_uuid, language \\ nil, version \\ nil) do - case DBStorage.get_post_by_uuid(post_uuid, [:group]) do - nil -> - {:error, :not_found} - - db_post -> - db_post = StaleFixer.fix_stale_post(db_post) - group_slug = db_post.group.slug - resolved_language = resolve_language_to_dialect(language) - version_number = if version, do: normalize_version_number(version), else: nil - - if db_post.post_date && db_post.post_time do - DBStorage.read_post_by_datetime( - group_slug, - db_post.post_date, - db_post.post_time, - resolved_language, - version_number - ) - else - DBStorage.read_post(group_slug, db_post.slug, resolved_language, version_number) - end - end - rescue - e in [Ecto.QueryError, DBConnection.ConnectionError] -> - Logger.warning("[Publishing] read_post_by_uuid failed for #{post_uuid}: #{inspect(e)}") - {:error, :not_found} - end - - @doc """ - Reads an existing post. - - For slug-mode groups, accepts an optional version parameter. - If version is nil, reads the latest version. - - Reads from the database. - """ - @spec read_post(String.t(), String.t(), String.t() | nil, integer() | nil) :: - {:ok, map()} | {:error, any()} - def read_post(group_slug, identifier, language \\ nil, version \\ nil) do - read_post_from_db(group_slug, identifier, language, version) - end - - @doc """ - Updates a post in the database. - """ - @spec update_post(String.t(), map(), map(), map() | keyword()) :: - {:ok, map()} | {:error, any()} - def update_post(group_slug, post, params, opts \\ %{}) do - # Normalize opts to map (callers may pass keyword list or map) - opts_map = if Keyword.keyword?(opts), do: Map.new(opts), else: opts - - audit_meta = - opts_map - |> Shared.fetch_option(:scope) - |> Shared.audit_metadata(:update) - |> Map.put(:is_primary_language, Map.get(opts_map, :is_primary_language, true)) - - result = update_post_in_db(group_slug, post, params, audit_meta) - - with {:ok, updated_post} <- result do - ListingCache.regenerate(group_slug) - - unless Map.get(opts_map, :skip_broadcast, false) do - PublishingPubSub.broadcast_post_updated(group_slug, updated_post) - end - end - - result - end - - @doc """ - Changes a post's status by UUID. - - Reads the post, resolves primary language, updates status via `update_post`, - invalidates render cache, and broadcasts the change. - - Returns `{:ok, updated_post}` or `{:error, reason}`. - """ - @spec change_post_status(String.t(), String.t(), String.t(), keyword()) :: - {:ok, map()} | {:error, term()} - def change_post_status(group_slug, post_uuid, new_status, opts \\ []) do - case read_post_by_uuid(post_uuid) do - {:ok, post} -> - primary_language = post[:primary_language] || LanguageHelpers.get_primary_language() - is_primary_language = post.language == primary_language - - case update_post(group_slug, post, %{"status" => new_status}, - scope: opts[:scope], - is_primary_language: is_primary_language, - skip_broadcast: true - ) do - {:ok, updated_post} -> - identifier = updated_post[:uuid] || updated_post.slug - Publishing.Renderer.invalidate_cache(group_slug, identifier, updated_post.language) - PublishingPubSub.broadcast_post_status_changed(group_slug, updated_post) - {:ok, updated_post} - - {:error, _} = err -> - err - end - - {:error, _} = err -> - err - end - end - - @doc """ - Restores a trashed post by UUID, setting its status back to "draft". - - Reconciles version/content statuses and regenerates the group cache. - Returns {:ok, post_uuid} on success or {:error, reason} on failure. - """ - @spec restore_post(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()} - def restore_post(group_slug, post_uuid) do - case DBStorage.get_post_by_uuid(post_uuid) do - nil -> - {:error, :not_found} - - db_post -> - case DBStorage.update_post(db_post, %{status: "draft"}) do - {:ok, _} -> - StaleFixer.reconcile_post_status(db_post) - ListingCache.regenerate(group_slug) - broadcast_id = db_post.slug || db_post.uuid - PublishingPubSub.broadcast_post_updated(group_slug, %{slug: broadcast_id}) - {:ok, post_uuid} - - {:error, reason} -> - {:error, reason} - end - end - end - - @doc """ - Soft-deletes a post by UUID. - - Returns {:ok, post_uuid} on success or {:error, reason} on failure. - """ - @spec trash_post(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()} - def trash_post(group_slug, post_uuid) do - case DBStorage.get_post_by_uuid(post_uuid, [:group]) do - nil -> - {:error, :not_found} - - db_post -> - case DBStorage.trash_post(db_post) do - {:ok, _} -> - broadcast_id = db_post.slug || db_post.uuid - ListingCache.regenerate(group_slug) - PublishingPubSub.broadcast_post_deleted(group_slug, broadcast_id) - {:ok, post_uuid} - - {:error, reason} -> - {:error, reason} - end - end - end - - # Extract slug, version, and language from a path identifier - # Handles paths like: - # - "post-slug" → {"post-slug", nil, nil} - # - "post-slug/en" → {"post-slug", nil, "en"} - # - "post-slug/v1/en" → {"post-slug", 1, "en"} - # - "group/post-slug/v2/am" → {"post-slug", 2, "am"} - def extract_slug_version_and_language(_group_slug, nil), do: {"", nil, nil} - - def extract_slug_version_and_language(group_slug, identifier) do - parts = - identifier - |> to_string() - |> String.trim() - |> String.trim_leading("/") - |> String.split("/", trim: true) - |> drop_group_prefix(group_slug) - - case parts do - [] -> - {"", nil, nil} - - [slug] -> - {slug, nil, nil} - - [slug | rest] -> - # Extract version if present (v1, v2, v3, etc.) - {version, rest_after_version} = Shared.extract_version_from_parts(rest) - - # Extract language from remaining parts - language = - rest_after_version - |> List.first() - |> case do - nil -> nil - <<>> -> nil - lang_code -> lang_code - end - - {slug, version, language} - end - end - - @doc false - def read_back_post(group_slug, identifier, db_post, language, version_number) do - Shared.read_back_post(group_slug, identifier, db_post, language, version_number) - end - - # =========================================================================== - # Private helpers - # =========================================================================== - - # Converts a DBStorage content record (with preloaded version/post/group) to a post map - defp db_content_to_post_map(content) do - version = content.version - post = version.post - - %{ - slug: post.slug, - url_slug: content.url_slug, - language: content.language, - metadata: %{ - title: content.title, - status: content.status, - description: (content.data || %{})["description"] - } - } - end - - defp create_post_in_db(group_slug, opts) do - case DBStorage.get_group_by_slug(group_slug) do - nil -> - {:error, :group_not_found} - - group -> - do_create_post_in_db(group_slug, group, opts) - end - end - - defp do_create_post_in_db(group_slug, group, opts) do - scope = Shared.fetch_option(opts, :scope) - mode = Publishing.get_group_mode(group_slug) - primary_language = LanguageHelpers.get_primary_language() - now = UtilsDate.utc_now() - - # Resolve user UUID for audit - created_by_uuid = Shared.resolve_scope_user_uuids(scope) - - # Generate slug for slug-mode groups - slug_result = - case mode do - "slug" -> - title = Shared.fetch_option(opts, :title) - preferred_slug = Shared.fetch_option(opts, :slug) - SlugHelpers.generate_unique_slug(group_slug, title || "", preferred_slug) - - _ -> - {:ok, nil} - end - - with {:ok, post_slug} <- slug_result do - # Build post attributes - post_attrs = %{ - group_uuid: group.uuid, - slug: post_slug, - status: "draft", - mode: mode, - primary_language: primary_language, - published_at: nil, - created_by_uuid: created_by_uuid, - updated_by_uuid: created_by_uuid - } - - # Add initial date/time for timestamp mode (truncate seconds since URLs use HH:MM only) - # The actual available timestamp is resolved inside the transaction to avoid races. - post_attrs = - if mode == "timestamp" do - date = DateTime.to_date(now) - time = %Time{hour: now.hour, minute: now.minute, second: 0, microsecond: {0, 0}} - - Map.merge(post_attrs, %{ - post_date: date, - post_time: time - }) - else - post_attrs - end - - repo = PhoenixKit.RepoHelper.repo() - - tx_result = - repo.transaction(fn -> - # Find available timestamp INSIDE the transaction to prevent race conditions - final_attrs = - if mode == "timestamp" do - {date, time} = - find_available_timestamp(group_slug, post_attrs.post_date, post_attrs.post_time) - - %{post_attrs | post_date: date, post_time: time} - else - post_attrs - end - - with {:ok, db_post} <- DBStorage.create_post(final_attrs), - {:ok, db_version} <- - DBStorage.create_version(%{ - post_uuid: db_post.uuid, - version_number: 1, - status: "draft", - created_by_uuid: created_by_uuid - }), - {:ok, _content} <- - DBStorage.create_content(%{ - version_uuid: db_version.uuid, - language: primary_language, - title: Shared.fetch_option(opts, :title) || "", - content: Shared.fetch_option(opts, :content) || "", - status: "draft", - url_slug: post_slug - }) do - db_post - else - {:error, reason} -> repo.rollback(reason) - end - end) - - with {:ok, db_post} <- tx_result do - # Read back via mapper to get a proper post map with UUID - read_result = - if mode == "timestamp" do - DBStorage.read_post_by_datetime( - group_slug, - db_post.post_date, - db_post.post_time, - primary_language, - 1 - ) - else - DBStorage.read_post(group_slug, db_post.slug, primary_language, 1) - end - - case read_result do - {:ok, post} -> - ListingCache.regenerate(group_slug) - PublishingPubSub.broadcast_post_created(group_slug, post) - {:ok, post} - - {:error, _} = err -> - err - end - end - end - end - - defp read_post_from_db(group_slug, identifier, language, version) do - # If identifier is a UUID, resolve via UUID lookup (handles both modes) - if Shared.uuid_format?(identifier) do - read_post_by_uuid(identifier, language, version) - else - case Publishing.get_group_mode(group_slug) do - "timestamp" -> - read_post_from_db_timestamp(group_slug, identifier, language, version) - - _ -> - read_post_from_db_slug(group_slug, identifier, language, version) - end - end - end - - defp read_post_from_db_timestamp(group_slug, identifier, language, version) do - case Shared.parse_timestamp_path(identifier) do - {:ok, date, time, inferred_version, inferred_language} -> - final_language = resolve_language_to_dialect(language || inferred_language) - final_version = version || inferred_version - version_number = normalize_version_number(final_version) - - DBStorage.read_post_by_datetime( - group_slug, - date, - time, - final_language, - version_number - ) - - _ -> - # Fallback: try as slug-based lookup - read_post_from_db_slug(group_slug, identifier, language, version) - end - end - - defp read_post_from_db_slug(group_slug, identifier, language, version) do - {post_slug, inferred_version, inferred_language} = - extract_slug_version_and_language(group_slug, identifier) - - final_language = resolve_language_to_dialect(language || inferred_language) - final_version = version || inferred_version - version_number = normalize_version_number(final_version) - - DBStorage.read_post(group_slug, post_slug, final_language, version_number) - end - - defp normalize_version_number(nil), do: nil - - defp normalize_version_number(v) when is_integer(v) and v > 0, do: v - defp normalize_version_number(v) when is_integer(v), do: nil - - defp normalize_version_number(v) do - case Integer.parse("#{v}") do - {n, _} when n > 0 -> n - _ -> nil - end - end - - # Resolves base language codes (de, en) to stored BCP-47 dialect codes (de-DE, en-US). - # Content rows store full dialect codes, but URL paths use base codes. - defp resolve_language_to_dialect(nil), do: nil - - defp resolve_language_to_dialect(language) do - base = DialectMapper.extract_base(language) - - if base == language do - DialectMapper.base_to_dialect(language) - else - language - end - end - - # Finds the next available minute for a timestamp-mode post. - # If the given date/time is already taken, bumps forward by one minute at a time. - # Limited to 60 attempts to prevent unbounded recursion. - defp find_available_timestamp(group_slug, date, time, attempts \\ 0) - - defp find_available_timestamp(_group_slug, date, time, @max_timestamp_attempts) do - {date, time} - end - - defp find_available_timestamp(group_slug, date, time, attempts) do - case DBStorage.get_post_by_datetime(group_slug, date, time) do - nil -> - {date, time} - - _existing -> - # Bump by one minute - total_seconds = time.hour * 3600 + time.minute * 60 + 60 - - if total_seconds >= 86_400 do - # Rolled past midnight — advance to next day at 00:00 - next_date = Date.add(date, 1) - find_available_timestamp(group_slug, next_date, ~T[00:00:00], attempts + 1) - else - next_hour = div(total_seconds, 3600) - next_minute = div(rem(total_seconds, 3600), 60) - next_time = %Time{hour: next_hour, minute: next_minute, second: 0, microsecond: {0, 0}} - find_available_timestamp(group_slug, date, next_time, attempts + 1) - end - end - end - - # Updates a post in the database. - # Writes directly to the database and returns the updated post map. - defp update_post_in_db(group_slug, post, params, audit_meta) do - db_post = find_db_post_for_update(group_slug, post) - - if db_post do - if post[:mode] in @timestamp_modes || db_post.mode == "timestamp" do - # Timestamp-mode posts don't have slugs — skip slug validation - do_update_post_in_db(db_post, post, params, group_slug, nil, audit_meta) - else - # Handle slug changes - desired_slug = Map.get(params, "slug", post.slug) - - case maybe_update_db_slug(db_post, desired_slug, group_slug) do - {:ok, final_slug} -> - do_update_post_in_db(db_post, post, params, group_slug, final_slug, audit_meta) - - {:error, _reason} = error -> - error - end - end - else - {:error, :not_found} - end - rescue - e -> - Logger.warning("[Publishing] update_post_in_db failed: #{inspect(e)}") - {:error, :db_update_failed} - end - - # Find the DB post record for update, using UUID, date/time, or slug as available - defp find_db_post_for_update(group_slug, post) do - cond do - # If we have a UUID, use it directly (most reliable) - post[:uuid] -> - DBStorage.get_post_by_uuid(post[:uuid], [:group]) - - # Timestamp-mode: use date/time - post[:mode] in @timestamp_modes && post[:date] && post[:time] -> - DBStorage.get_post_by_datetime(group_slug, post[:date], post[:time]) - - # Slug-mode: use slug - post[:slug] -> - DBStorage.get_post(group_slug, post[:slug]) - - true -> - nil - end - end - - defp maybe_update_db_slug(db_post, desired_slug, _group_slug) - when desired_slug == db_post.slug do - {:ok, db_post.slug} - end - - defp maybe_update_db_slug(db_post, desired_slug, group_slug) do - with {:ok, valid_slug} <- SlugHelpers.validate_slug(desired_slug), - false <- SlugHelpers.slug_exists?(group_slug, valid_slug), - {:ok, _} <- DBStorage.update_post(db_post, %{slug: valid_slug}) do - {:ok, valid_slug} - else - true -> - {:error, :slug_already_exists} - - {:error, %Ecto.Changeset{} = changeset} -> - Logger.warning("[Publishing] slug update changeset error: #{inspect(changeset.errors)}") - - if Keyword.has_key?(changeset.errors, :slug), - do: {:error, :slug_already_exists}, - else: {:error, :db_update_failed} - - {:error, reason} -> - Logger.warning("[Publishing] slug update failed: #{inspect(reason)}") - {:error, reason} - end - end - - defp do_update_post_in_db(db_post, post, params, group_slug, final_slug, audit_meta) do - version_number = post[:version] || 1 - version = DBStorage.get_version(db_post.uuid, version_number) - - if version do - language = post[:language] || db_post.primary_language - post_metadata = post[:metadata] || %{} - new_status = Map.get(params, "status", post_metadata[:status] || "draft") - content = Map.get(params, "content", post[:content] || "") - new_title = resolve_post_title(params, post, content) - - with :ok <- validate_title_for_publish(db_post, language, new_status, new_title), - old_db_status = db_post.status, - :ok <- update_post_level_fields(db_post, new_status, params, audit_meta), - :ok <- - upsert_post_content(version, language, new_title, content, new_status, params, post) do - maybe_propagate_status(version, language, db_post, new_status, old_db_status) - read_updated_post(db_post, group_slug, final_slug, language, version_number) - end - else - {:error, :not_found} - end - end - - @default_title Constants.default_title() - - defp validate_title_for_publish(db_post, language, "published", title) - when title in ["", @default_title] do - if language == db_post.primary_language, - do: {:error, :title_required}, - else: :ok - end - - defp validate_title_for_publish(_db_post, _language, _status, _title), do: :ok - - defp read_updated_post(db_post, group_slug, final_slug, language, version_number) do - if db_post.mode == "timestamp" do - DBStorage.read_post_by_datetime( - group_slug, - db_post.post_date, - db_post.post_time, - language, - version_number - ) - else - DBStorage.read_post(group_slug, final_slug, language, version_number) - end - end - - defp resolve_post_title(params, post, _content) do - post_metadata = post[:metadata] || %{} - - Map.get(params, "title") || - post_metadata[:title] || - Constants.default_title() - end - - defp update_post_level_fields(db_post, new_status, params, audit_meta) do - update_attrs = - %{ - status: new_status, - published_at: parse_published_at(params, db_post) - } - |> maybe_put(:updated_by_uuid, audit_meta[:updated_by_uuid]) - |> maybe_put(:updated_by_email, audit_meta[:updated_by_email]) - - case DBStorage.update_post(db_post, update_attrs) do - {:ok, _} -> :ok - {:error, reason} -> {:error, reason} - end - end - - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) - - defp upsert_post_content(version, language, new_title, content, new_status, params, post) do - existing_content = DBStorage.get_content(version.uuid, language) - existing_url_slug = if existing_content, do: existing_content.url_slug - existing_data = if existing_content, do: existing_content.data || %{}, else: %{} - - resolved_url_slug = - case Map.fetch(params, "url_slug") do - {:ok, val} -> val - :error -> existing_url_slug - end - - case DBStorage.upsert_content(%{ - version_uuid: version.uuid, - language: language, - title: new_title, - content: content, - status: new_status, - url_slug: resolved_url_slug, - data: build_content_data(params, post, existing_data) - }) do - {:ok, _} -> :ok - {:error, reason} -> {:error, reason} - end - end - - defp maybe_propagate_status(version, language, db_post, new_status, old_db_status) do - is_primary = language == db_post.primary_language - - if is_primary and new_status != old_db_status do - propagate_db_status_to_translations(version.uuid, language, new_status) - end - end - - defp propagate_db_status_to_translations(version_uuid, primary_language, new_status) do - DBStorage.update_content_status_except(version_uuid, primary_language, new_status) - end - - defp parse_published_at(params, db_post) do - case Map.get(params, "published_at") do - nil -> - db_post.published_at - - "" -> - db_post.published_at - - dt_string when is_binary(dt_string) -> - case DateTime.from_iso8601(dt_string) do - {:ok, dt, _} -> dt - _ -> db_post.published_at - end - - dt -> - dt - end - end - - defp build_content_data(params, post, existing_data) do - # Start from existing data to preserve previous_url_slugs, excerpt, seo_title, etc. - data = existing_data - - data = - case Map.get(params, "featured_image_uuid") do - nil -> data - id -> Map.put(data, "featured_image_uuid", id) - end - - post_metadata = post[:metadata] || %{} - - case Map.get(params, "description", post_metadata[:description]) do - nil -> data - desc -> Map.put(data, "description", desc) - end - end - - # Only drop group prefix if there are more elements after it - # This prevents dropping the post slug when it matches the group slug - defp drop_group_prefix([group_slug | rest], group_slug) when rest != [], do: rest - defp drop_group_prefix(list, _), do: list -end diff --git a/lib/modules/publishing/presence.ex b/lib/modules/publishing/presence.ex deleted file mode 100644 index 5c9d01f6f..000000000 --- a/lib/modules/publishing/presence.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Presence do - @moduledoc """ - Presence tracking for collaborative post editing. - - Uses Phoenix.Presence to track who is currently editing a post. - The first person to join a topic becomes the "owner" (can edit), and everyone else - becomes "spectators" (read-only mode). - - ## How It Works - - 1. When a user opens the editor, they join a Presence topic (e.g., "publishing_edit:docs:post-slug") - 2. Presence tracks all connected users with metadata (user info, joined_at timestamp) - 3. Users are sorted by joined_at to determine order (FIFO) - 4. First user in the sorted list = owner (readonly?: false) - 5. All other users = spectators (readonly?: true) - 6. When owner leaves, Presence removes them automatically - 7. All connected users receive presence_diff event - 8. Each user re-evaluates: "Am I first now?" - 9. New first user auto-promotes to owner - - ## Automatic Cleanup - - Phoenix.Presence automatically detects when LiveView processes die and removes - them immediately via process monitoring. No manual cleanup needed. - - ## Topics - - - Post editing: "publishing_edit:" - """ - - use Phoenix.Presence, - otp_app: :phoenix_kit, - pubsub_server: :phoenix_kit_internal_pubsub -end diff --git a/lib/modules/publishing/presence_helpers.ex b/lib/modules/publishing/presence_helpers.ex deleted file mode 100644 index 416543d43..000000000 --- a/lib/modules/publishing/presence_helpers.ex +++ /dev/null @@ -1,190 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PresenceHelpers do - @moduledoc """ - Helper functions for collaborative post editing with Phoenix.Presence. - - Provides utilities for tracking editing sessions, determining owner/spectator roles, - and syncing state between users. - """ - - alias PhoenixKit.Modules.Publishing.Presence - - @doc """ - Tracks the current LiveView process in a Presence topic. - - ## Parameters - - - `form_key`: The unique key for the post being edited - - `socket`: The LiveView socket - - `user`: The current user struct - - ## Examples - - track_editing_session("blog:my-post:en", socket, user) - # => {:ok, ref} - """ - def track_editing_session(form_key, socket, user) do - topic = editing_topic(form_key) - - Presence.track(self(), topic, socket.id, %{ - user_uuid: user.uuid, - user_email: user.email, - user: user, - joined_at: System.system_time(:millisecond), - phx_ref: socket.id, - pid: self(), - transport_pid: socket.transport_pid - }) - end - - @doc """ - Untracks the current LiveView process from a Presence topic. - - Call this when switching languages or versions to release the lock - on the previous form before tracking the new one. - - ## Parameters - - - `form_key`: The unique key for the post that was being edited - - `socket`: The LiveView socket - - ## Examples - - untrack_editing_session("blog:my-post:en", socket) - # => :ok - """ - def untrack_editing_session(form_key, socket) do - topic = editing_topic(form_key) - Presence.untrack(self(), topic, socket.id) - end - - @doc """ - Unsubscribes from presence events and editor form events for a form. - - Call this when switching languages or versions to clean up subscriptions. - """ - def unsubscribe_from_editing(form_key) do - topic = editing_topic(form_key) - Phoenix.PubSub.unsubscribe(:phoenix_kit_internal_pubsub, topic) - end - - @doc """ - Determines if the current socket is the owner (first in the presence list). - - Returns `{:owner, presences}` if this socket is the owner (or same user in different tab), or - `{:spectator, owner_meta, presences}` if a different user is the owner. - - ## Examples - - case get_editing_role("blog:my-post", socket.id, current_user.uuid) do - {:owner, all_presences} -> - # I can edit! - - {:spectator, owner_metadata, all_presences} -> - # I'm read-only, sync with owner's state - end - """ - def get_editing_role(form_key, socket_id, current_user_uuid) do - presences = get_sorted_presences(form_key) - - case presences do - [] -> - # No one here (shouldn't happen since caller is here) - # But treat as owner to avoid blocking - {:owner, []} - - [{^socket_id, _meta} | _rest] -> - # I'm first! I'm the owner - {:owner, presences} - - [{_other_socket_id, owner_meta} | _rest] -> - # Check if same user (different tab) or different user - if owner_meta.user_uuid == current_user_uuid do - # Same user, different tab - treat as owner so both tabs can edit - {:owner, presences} - else - # Different user - spectator mode (FIFO locking) - {:spectator, owner_meta, presences} - end - end - end - - @doc """ - Gets all presences for a form, sorted by join time (FIFO). - - Returns a list of tuples: `[{socket_id, metadata}, ...]` - """ - def get_sorted_presences(form_key) do - topic = editing_topic(form_key) - raw_presences = Presence.list(topic) - - raw_presences - |> Enum.flat_map(fn {socket_id, %{metas: metas}} -> - # Filter out metas with dead PIDs - valid_metas = - Enum.filter(metas, fn meta -> - case Map.get(meta, :pid) do - pid when is_pid(pid) -> Process.alive?(pid) - # Keep metas without PID - _ -> true - end - end) - - # Take the first valid meta (most recent) - case valid_metas do - [meta | _] -> [{socket_id, meta}] - [] -> [] - end - end) - |> Enum.sort_by(fn {_socket_id, meta} -> meta.joined_at end) - end - - @doc """ - Gets the lock owner's metadata, or nil if no one is editing. - """ - def get_lock_owner(form_key) do - case get_sorted_presences(form_key) do - [{_socket_id, meta} | _] -> meta - [] -> nil - end - end - - @doc """ - Gets all spectators (everyone except the first person). - - Returns a list of metadata for spectators only. - """ - def get_spectators(form_key) do - case get_sorted_presences(form_key) do - [] -> [] - [_owner | spectators] -> Enum.map(spectators, fn {_id, meta} -> meta end) - end - end - - @doc """ - Counts total number of people editing (owner + spectators). - """ - def count_editors(form_key) do - get_sorted_presences(form_key) |> length() - end - - @doc """ - Subscribes the current process to presence events for a form. - - After subscribing, the process will receive: - - `%Phoenix.Socket.Broadcast{event: "presence_diff", ...}` when users join/leave - """ - def subscribe_to_editing(form_key) do - topic = editing_topic(form_key) - Phoenix.PubSub.subscribe(:phoenix_kit_internal_pubsub, topic) - end - - @doc """ - Generates the Presence topic name for a form. - - ## Examples - - editing_topic("docs:my-post:en") - # => "publishing_edit:docs:my-post:en" - """ - def editing_topic(form_key), do: "publishing_edit:#{form_key}" -end diff --git a/lib/modules/publishing/publishing.ex b/lib/modules/publishing/publishing.ex deleted file mode 100644 index a38665da8..000000000 --- a/lib/modules/publishing/publishing.ex +++ /dev/null @@ -1,414 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing do - @moduledoc """ - Publishing module for managing content groups and their posts. - - Database-backed CMS for creating timestamped or slug-based posts - with multi-language support and versioning. - - This module acts as a facade, delegating to focused submodules: - - - `Publishing.Groups` — Group CRUD - - `Publishing.Posts` — Post CRUD, reading, and listing - - `Publishing.Versions` — Version create, publish, delete - - `Publishing.TranslationManager` — Language/translation management - - `Publishing.StaleFixer` — Stale value detection and repair - """ - - use PhoenixKit.Module - - require Logger - - alias PhoenixKit.Dashboard.Tab - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Modules.Publishing.LanguageHelpers - alias PhoenixKit.Modules.Publishing.SlugHelpers - # ============================================================================ - # Language Utility Delegates - # ============================================================================ - - defdelegate get_language_info(language_code), to: LanguageHelpers - defdelegate enabled_language_codes(), to: LanguageHelpers - defdelegate get_primary_language(), to: LanguageHelpers - defdelegate language_enabled?(language_code, enabled_languages), to: LanguageHelpers - defdelegate get_display_code(language_code, enabled_languages), to: LanguageHelpers - - defdelegate order_languages_for_display(available_languages, enabled_languages), - to: LanguageHelpers - - defdelegate order_languages_for_display(available_languages, enabled_languages, primary), - to: LanguageHelpers - - # ============================================================================ - # Slug Utility Delegates - # ============================================================================ - - defdelegate validate_slug(slug), to: SlugHelpers - defdelegate slug_exists?(group_slug, post_slug), to: SlugHelpers - defdelegate generate_unique_slug(group_slug, title), to: SlugHelpers - defdelegate generate_unique_slug(group_slug, title, preferred_slug), to: SlugHelpers - defdelegate generate_unique_slug(group_slug, title, preferred_slug, opts), to: SlugHelpers - defdelegate validate_url_slug(group_slug, url_slug, language, exclude), to: SlugHelpers - defdelegate clear_url_slug_from_post(group_slug, post_slug, url_slug), to: DBStorage - - # ============================================================================ - # Cache Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.ListingCache - - defdelegate regenerate_cache(group_slug), to: ListingCache, as: :regenerate - defdelegate invalidate_cache(group_slug), to: ListingCache, as: :invalidate - defdelegate cache_exists?(group_slug), to: ListingCache, as: :exists? - defdelegate find_cached_post(group_slug, post_slug), to: ListingCache, as: :find_post - - defdelegate find_cached_post_by_path(group_slug, date, time), - to: ListingCache, - as: :find_post_by_path - - # ============================================================================ - # Group Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.Groups - - defdelegate list_groups(), to: Groups - defdelegate list_groups(status), to: Groups - defdelegate get_group(slug), to: Groups - defdelegate add_group(name, opts \\ []), to: Groups - defdelegate remove_group(slug), to: Groups - defdelegate remove_group(slug, opts), to: Groups - defdelegate update_group(slug, params), to: Groups - defdelegate trash_group(slug), to: Groups - defdelegate group_name(slug), to: Groups - defdelegate get_group_mode(group_slug), to: Groups - defdelegate preset_types(), to: Groups - defdelegate valid_types(), to: Groups - defdelegate restore_group(slug), to: Groups - defdelegate list_trashed_groups(), to: Groups - - # ============================================================================ - # Post Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.Posts - - defdelegate list_posts(group_slug, preferred_language \\ nil), to: Posts - defdelegate list_posts_by_status(group_slug, status), to: Posts - defdelegate list_raw_posts(group_slug, status \\ nil), to: Posts - defdelegate count_primary_language_status(posts, primary_language), to: Posts - defdelegate create_post(group_slug, opts \\ %{}), to: Posts - defdelegate read_post(group_slug, identifier, language \\ nil, version \\ nil), to: Posts - defdelegate read_post_by_uuid(post_uuid, language \\ nil, version \\ nil), to: Posts - defdelegate update_post(group_slug, post, params, opts \\ %{}), to: Posts - defdelegate change_post_status(group_slug, post_uuid, new_status, opts \\ []), to: Posts - defdelegate trash_post(group_slug, post_uuid), to: Posts - defdelegate restore_post(group_slug, post_uuid), to: Posts - defdelegate count_posts_on_date(group_slug, date), to: Posts - defdelegate list_times_on_date(group_slug, date), to: Posts - defdelegate read_post_by_datetime(group_slug, date, time), to: DBStorage - defdelegate find_by_url_slug(group_slug, language, url_slug), to: Posts - defdelegate find_by_previous_url_slug(group_slug, language, url_slug), to: Posts - defdelegate extract_slug_version_and_language(group_slug, identifier), to: Posts - - @doc "Always returns false — auto-versioning is disabled." - def should_create_new_version?(_post, _params, _editing_language), do: false - - @doc "Returns true when the given post is a DB-backed post (has a UUID)." - @spec db_post?(map()) :: boolean() - defdelegate db_post?(post), to: Posts - - # ============================================================================ - # Version Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.Versions - - defdelegate list_versions(group_slug, post_slug), to: Versions - defdelegate get_published_version(group_slug, post_slug), to: Versions - defdelegate get_version_status(group_slug, post_slug, version_number, language), to: Versions - defdelegate get_version_metadata(group_slug, post_slug, version_number, language), to: Versions - - defdelegate create_new_version(group_slug, source_post, params \\ %{}, opts \\ %{}), - to: Versions - - defdelegate publish_version(group_slug, post_uuid, version, opts \\ []), to: Versions - - defdelegate create_version_from( - group_slug, - post_uuid, - source_version, - params \\ %{}, - opts \\ %{} - ), - to: Versions - - defdelegate delete_version(group_slug, post_uuid, version), to: Versions - @doc false - defdelegate broadcast_version_created(group_slug, broadcast_id, new_version), to: Versions - - # ============================================================================ - # Translation Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.TranslationManager - - defdelegate get_post_primary_language(group_slug, post_slug, version \\ nil), - to: TranslationManager - - defdelegate check_primary_language_status(group_slug, post_slug), to: TranslationManager - - defdelegate update_post_primary_language(group_slug, post_uuid, new_primary_language), - to: TranslationManager - - defdelegate update_posts_primary_language(group_slug), to: TranslationManager - defdelegate count_posts_needing_language_update(group_slug), to: TranslationManager - - defdelegate add_language_to_post(group_slug, post_uuid, language_code, version \\ nil), - to: TranslationManager - - @doc false - defdelegate add_language_to_db(group_slug, post_uuid, language_code, version_number), - to: TranslationManager - - defdelegate delete_language(group_slug, post_uuid, language_code, version \\ nil), - to: TranslationManager - - defdelegate clear_translation(group_slug, post_uuid, language_code), to: TranslationManager - - defdelegate set_translation_status(group_slug, post_identifier, version, language, status), - to: TranslationManager - - defdelegate translate_post_to_all_languages(group_slug, post_uuid, opts \\ []), - to: TranslationManager - - # ============================================================================ - # Stale Value Correction Delegates - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.StaleFixer - - defdelegate fix_stale_group(group), to: StaleFixer - defdelegate fix_stale_post(post), to: StaleFixer - defdelegate fix_stale_version(version), to: StaleFixer - defdelegate fix_stale_content(content), to: StaleFixer - defdelegate fix_all_stale_values(), to: StaleFixer - defdelegate reconcile_post_status(post), to: StaleFixer - - # ============================================================================ - # Module Behaviour Callbacks - # ============================================================================ - - @publishing_enabled_key "publishing_enabled" - - @impl PhoenixKit.Module - @spec enabled?() :: boolean() - def enabled? do - settings_call(:get_boolean_setting, [@publishing_enabled_key, false]) - end - - @impl PhoenixKit.Module - @spec enable_system() :: {:ok, any()} | {:error, any()} - def enable_system do - settings_call(:update_boolean_setting, [@publishing_enabled_key, true]) - end - - @impl PhoenixKit.Module - @spec disable_system() :: {:ok, any()} | {:error, any()} - def disable_system do - settings_call(:update_boolean_setting, [@publishing_enabled_key, false]) - end - - @impl PhoenixKit.Module - def module_key, do: "publishing" - - @impl PhoenixKit.Module - def module_name, do: "Publishing" - - @impl PhoenixKit.Module - def get_config do - %{ - enabled: enabled?(), - groups_count: length(list_groups()) - } - end - - @impl PhoenixKit.Module - def permission_metadata do - %{ - key: "publishing", - label: "Publishing", - icon: "hero-document-duplicate", - description: "Database-backed CMS pages and multi-language content" - } - end - - @impl PhoenixKit.Module - def admin_tabs do - [ - Tab.new!( - id: :admin_publishing, - label: "Publishing", - icon: "hero-document-text", - path: "publishing", - priority: 600, - level: :admin, - permission: "publishing", - match: :prefix, - group: :admin_modules, - subtab_display: :when_active, - highlight_with_subtabs: false, - dynamic_children: &__MODULE__.publishing_children/1 - ) - ] - end - - @doc "Dynamic children function for Publishing sidebar tabs." - def publishing_children(_scope) do - groups = load_publishing_groups_for_tabs() - - groups - |> Enum.with_index() - |> Enum.map(fn {group, idx} -> - slug = group["slug"] || "" - name = group["name"] || slug - hash = :erlang.phash2(slug) |> Integer.to_string(16) |> String.downcase() - sanitized = slug |> String.replace(~r/[^a-zA-Z0-9_]/, "_") |> String.slice(0, 50) - - %Tab{ - id: :"admin_publishing_#{sanitized}_#{hash}", - label: name, - icon: "hero-document-text", - path: "publishing/#{slug}", - priority: 601 + idx, - level: :admin, - permission: "publishing", - match: :prefix, - parent: :admin_publishing - } - end) - rescue - e -> - Logger.warning("[Publishing] dashboard_tabs failed: #{inspect(e)}") - [] - end - - defp load_publishing_groups_for_tabs do - alias PhoenixKit.Settings - - publishing_enabled = Settings.get_boolean_setting("publishing_enabled", false) - - if publishing_enabled do - alias PhoenixKit.Modules.Publishing.DBStorage - - DBStorage.list_groups() - |> Enum.map(fn g -> %{"name" => g.name, "slug" => g.slug} end) - else - [] - end - rescue - e -> - Logger.warning("[Publishing] load_publishing_groups_for_tabs failed: #{inspect(e)}") - [] - end - - @impl PhoenixKit.Module - def settings_tabs do - [ - Tab.new!( - id: :admin_settings_publishing, - label: "Publishing", - icon: "hero-document-text", - path: "publishing", - priority: 921, - level: :admin, - parent: :admin_settings, - permission: "publishing" - ) - ] - end - - @impl PhoenixKit.Module - def children, do: [PhoenixKit.Modules.Publishing.Presence] - - @impl PhoenixKit.Module - def route_module, do: PhoenixKitWeb.Routes.PublishingRoutes - - # ============================================================================ - # Shared Helpers (used across submodules) - # ============================================================================ - - alias PhoenixKit.Modules.Publishing.Shared - - @slug_regex ~r/^[a-z0-9]+(?:-[a-z0-9]+)*$/ - - @doc false - def slugify(name) when is_binary(name) do - name - |> String.downcase() - |> String.replace(~r/[^a-z0-9]+/u, "-") - |> String.trim("-") - end - - @doc """ - Returns true when the slug matches the allowed lowercase letters, numbers, and hyphen pattern, - and is not a reserved language code. - - Group slugs cannot be language codes (like 'en', 'es', 'fr') to prevent routing ambiguity. - """ - @spec valid_slug?(String.t()) :: boolean() - def valid_slug?(slug) when is_binary(slug) do - slug != "" and Regex.match?(@slug_regex, slug) and not reserved_language_code?(slug) - end - - def valid_slug?(_), do: false - - defp reserved_language_code?(slug) do - language_codes = - try do - Languages.get_language_codes() - rescue - e -> - Logger.debug( - "[Publishing] reserved_language_code? check failed, assuming no reserved codes: #{inspect(e)}" - ) - - [] - end - - slug in language_codes - end - - @doc false - defdelegate fetch_option(opts, key), to: Shared - - @doc false - defdelegate audit_metadata(scope, action), to: Shared - - # ============================================================================ - # Settings Helpers (private) - # ============================================================================ - - defp settings_module do - case PhoenixKit.Config.get(:publishing_settings_module) do - :not_found -> PhoenixKit.Settings - {:ok, module} -> module - end - end - - defp settings_call(fun, args) do - module = settings_module() - - case fun do - :get_json_setting_cached -> - if function_exported?(module, :get_json_setting_cached, length(args)) do - apply(module, :get_json_setting_cached, args) - else - apply(module, :get_json_setting, args) - end - - _ -> - apply(module, fun, args) - end - end -end diff --git a/lib/modules/publishing/pubsub.ex b/lib/modules/publishing/pubsub.ex deleted file mode 100644 index 0a3b626d6..000000000 --- a/lib/modules/publishing/pubsub.ex +++ /dev/null @@ -1,550 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.PubSub do - @moduledoc """ - PubSub integration for real-time publishing updates. - - Provides broadcasting and subscription for post changes, - enabling live updates across all connected admin clients. - - ## Features - - - Post lifecycle events (create, update, delete, status change) - - Collaborative editing with real-time form state sync - - Owner/spectator model for concurrent editing - """ - - alias PhoenixKit.PubSub.Manager - - @topic_prefix "publishing" - @topic_editor_forms "publishing:editor_forms" - @topic_groups "publishing:groups" - - # ============================================================================ - # Post Identifier Resolution - # ============================================================================ - - @doc """ - Returns the broadcast identifier for a post. - - Uses slug when available, falls back to uuid. This identifier is used - for PubSub topic construction and must be consistent between broadcasters - (e.g. translation worker) and subscribers (e.g. editor). - """ - def broadcast_id(post) do - post[:slug] || post[:uuid] - end - - # ============================================================================ - # Group-Level Updates (group creation/deletion) - # ============================================================================ - - @doc """ - Returns the topic for global group updates (create, delete). - """ - def groups_topic, do: @topic_groups - - @doc """ - Subscribes the current process to group updates (creation/deletion). - """ - def subscribe_to_groups do - Manager.subscribe(groups_topic()) - end - - @doc """ - Unsubscribes the current process from group updates. - """ - def unsubscribe_from_groups do - Manager.unsubscribe(groups_topic()) - end - - @doc """ - Broadcasts a group created event. - """ - def broadcast_group_created(group) do - Manager.broadcast(groups_topic(), {:group_created, group}) - end - - @doc """ - Broadcasts a group deleted event. - """ - def broadcast_group_deleted(group_slug) do - Manager.broadcast(groups_topic(), {:group_deleted, group_slug}) - end - - @doc """ - Broadcasts a group updated event. - """ - def broadcast_group_updated(group) do - Manager.broadcast(groups_topic(), {:group_updated, group}) - end - - # ============================================================================ - # Post List Updates (simple refresh) - # ============================================================================ - - @doc """ - Returns the topic for a specific group's posts. - """ - def posts_topic(group_slug) do - "#{@topic_prefix}:#{group_slug}:posts" - end - - @doc """ - Subscribes the current process to post updates for a group. - """ - def subscribe_to_posts(group_slug) do - Manager.subscribe(posts_topic(group_slug)) - end - - @doc """ - Unsubscribes the current process from post updates for a group. - """ - def unsubscribe_from_posts(group_slug) do - Manager.unsubscribe(posts_topic(group_slug)) - end - - @doc """ - Broadcasts a post created event. - """ - def broadcast_post_created(group_slug, post) do - Manager.broadcast(posts_topic(group_slug), {:post_created, post}) - end - - @doc """ - Broadcasts a post updated event. - """ - def broadcast_post_updated(group_slug, post) do - Manager.broadcast(posts_topic(group_slug), {:post_updated, post}) - end - - @doc """ - Broadcasts a post deleted event. - """ - def broadcast_post_deleted(group_slug, post_identifier) do - Manager.broadcast(posts_topic(group_slug), {:post_deleted, post_identifier}) - end - - @doc """ - Broadcasts a post status changed event. - """ - def broadcast_post_status_changed(group_slug, post) do - Manager.broadcast(posts_topic(group_slug), {:post_status_changed, post}) - end - - @doc """ - Broadcasts that a new version was created for a post. - """ - def broadcast_version_created(group_slug, post) do - Manager.broadcast(posts_topic(group_slug), {:version_created, post}) - end - - @doc """ - Broadcasts that the live version changed for a post. - """ - def broadcast_version_live_changed(group_slug, post_identifier, version) do - Manager.broadcast(posts_topic(group_slug), {:version_live_changed, post_identifier, version}) - end - - @doc """ - Broadcasts that a version was deleted from a post. - """ - def broadcast_version_deleted(group_slug, post_identifier, version) do - Manager.broadcast(posts_topic(group_slug), {:version_deleted, post_identifier, version}) - end - - # ============================================================================ - # Post-Level Updates (version and translation changes) - # ============================================================================ - - @doc """ - Returns the topic for a specific post's version updates. - This allows editors to receive notifications when versions are created/deleted. - """ - def post_versions_topic(group_slug, post_slug) do - "#{@topic_prefix}:#{group_slug}:post:#{post_slug}:versions" - end - - @doc """ - Subscribes to version updates for a specific post. - """ - def subscribe_to_post_versions(group_slug, post_slug) do - Manager.subscribe(post_versions_topic(group_slug, post_slug)) - end - - @doc """ - Unsubscribes from version updates for a specific post. - """ - def unsubscribe_from_post_versions(group_slug, post_slug) do - Manager.unsubscribe(post_versions_topic(group_slug, post_slug)) - end - - @doc """ - Broadcasts that a new version was created for a post (to post-level topic). - """ - def broadcast_post_version_created(group_slug, post_slug, version_info) do - Manager.broadcast( - post_versions_topic(group_slug, post_slug), - {:post_version_created, group_slug, post_slug, version_info} - ) - end - - @doc """ - Broadcasts that a version was deleted from a post (to post-level topic). - """ - def broadcast_post_version_deleted(group_slug, post_slug, version) do - Manager.broadcast( - post_versions_topic(group_slug, post_slug), - {:post_version_deleted, group_slug, post_slug, version} - ) - end - - @doc """ - Broadcasts that the live/published version changed (to post-level topic). - Includes source_id so receivers can ignore their own broadcasts. - """ - def broadcast_post_version_published(group_slug, post_slug, version, source_id \\ nil) do - Manager.broadcast( - post_versions_topic(group_slug, post_slug), - {:post_version_published, group_slug, post_slug, version, source_id} - ) - end - - @doc """ - Returns the topic for a specific post's translation updates. - This allows all editors of different language versions to receive updates - when new translations are added. - """ - def post_translations_topic(group_slug, post_slug) do - "#{@topic_prefix}:#{group_slug}:post:#{post_slug}:translations" - end - - @doc """ - Subscribes to translation updates for a specific post. - """ - def subscribe_to_post_translations(group_slug, post_slug) do - Manager.subscribe(post_translations_topic(group_slug, post_slug)) - end - - @doc """ - Unsubscribes from translation updates for a specific post. - """ - def unsubscribe_from_post_translations(group_slug, post_slug) do - Manager.unsubscribe(post_translations_topic(group_slug, post_slug)) - end - - @doc """ - Broadcasts that a new translation was created for a post. - """ - def broadcast_translation_created(group_slug, post_slug, language) do - Manager.broadcast( - post_translations_topic(group_slug, post_slug), - {:translation_created, group_slug, post_slug, language} - ) - end - - @doc """ - Broadcasts that a translation was deleted from a post. - """ - def broadcast_translation_deleted(group_slug, post_slug, language) do - Manager.broadcast( - post_translations_topic(group_slug, post_slug), - {:translation_deleted, group_slug, post_slug, language} - ) - end - - # ============================================================================ - # Editor Save Sync (last-save-wins model) - # ============================================================================ - - @doc """ - Broadcasts that a post was saved, so other editors can reload. - - The `source` is the socket.id of the saver, so they don't reload their own save. - """ - def broadcast_editor_saved(form_key, source) do - Manager.broadcast( - editor_form_topic(form_key), - {:editor_saved, form_key, source} - ) - end - - # ============================================================================ - # Collaborative Editor (real-time form sync) - # ============================================================================ - - @doc """ - Returns the topic for a specific editor form. - - The form_key uniquely identifies a post being edited: - - For existing posts: "group_slug:post_path" or "group_slug:slug" - - For new posts: "group_slug:new:language" - """ - def editor_form_topic(form_key) do - "#{@topic_editor_forms}:#{form_key}" - end - - @doc """ - Returns the presence topic for tracking editors of a post. - """ - def editor_presence_topic(form_key) do - "publishing:presence:editor:#{form_key}" - end - - @doc """ - Subscribes to collaborative events for a specific editor form. - """ - def subscribe_to_editor_form(form_key) do - Manager.subscribe(editor_form_topic(form_key)) - end - - @doc """ - Unsubscribes from collaborative events for a specific editor form. - """ - def unsubscribe_from_editor_form(form_key) do - Manager.unsubscribe(editor_form_topic(form_key)) - end - - @doc """ - Broadcasts a form state change to all subscribers. - - Options: - - `:source` - The source identifier to prevent self-echoing - """ - def broadcast_editor_form_change(form_key, payload, opts \\ []) do - Manager.broadcast( - editor_form_topic(form_key), - {:editor_form_change, form_key, payload, Keyword.get(opts, :source)} - ) - end - - @doc """ - Broadcasts a sync request for new joiners to get current state. - """ - def broadcast_editor_sync_request(form_key, requester_socket_id) do - Manager.broadcast( - editor_form_topic(form_key), - {:editor_sync_request, form_key, requester_socket_id} - ) - end - - @doc """ - Broadcasts a sync response with current form state. - """ - def broadcast_editor_sync_response(form_key, requester_socket_id, state) do - Manager.broadcast( - editor_form_topic(form_key), - {:editor_sync_response, form_key, requester_socket_id, state} - ) - end - - # ============================================================================ - # Cache Updates (for live admin UI updates) - # ============================================================================ - - @doc """ - Returns the topic for cache updates for a specific group. - """ - def cache_topic(group_slug) do - "#{@topic_prefix}:#{group_slug}:cache" - end - - @doc """ - Subscribes the current process to cache updates for a group. - """ - def subscribe_to_cache(group_slug) do - Manager.subscribe(cache_topic(group_slug)) - end - - @doc """ - Unsubscribes the current process from cache updates for a group. - """ - def unsubscribe_from_cache(group_slug) do - Manager.unsubscribe(cache_topic(group_slug)) - end - - @doc """ - Broadcasts that the cache state has changed (cache regenerated, memory loaded, etc). - """ - def broadcast_cache_changed(group_slug) do - Manager.broadcast(cache_topic(group_slug), {:cache_changed, group_slug}) - end - - # ============================================================================ - # AI Translation Progress - # ============================================================================ - - @doc """ - Broadcasts that AI translation has started. - Sent to both posts_topic (for group listing) and post_translations_topic (for editor). - """ - def broadcast_translation_started(group_slug, post_slug, target_languages) do - payload = {:translation_started, group_slug, post_slug, target_languages} - - # Broadcast to group listing - Manager.broadcast( - posts_topic(group_slug), - {:translation_started, post_slug, length(target_languages)} - ) - - # Broadcast to editor (more detailed info) - Manager.broadcast(post_translations_topic(group_slug, post_slug), payload) - end - - @doc """ - Broadcasts AI translation progress (after each language completes). - Sent to both posts_topic (for group listing) and post_translations_topic (for editor). - """ - def broadcast_translation_progress(group_slug, post_slug, completed, total, last_language) do - # Broadcast to group listing - Manager.broadcast( - posts_topic(group_slug), - {:translation_progress, post_slug, completed, total} - ) - - # Broadcast to editor (more detailed info) - Manager.broadcast( - post_translations_topic(group_slug, post_slug), - {:translation_progress, group_slug, post_slug, completed, total, last_language} - ) - end - - @doc """ - Broadcasts that AI translation has completed (success or partial failure). - Sent to both posts_topic (for group listing) and post_translations_topic (for editor). - """ - def broadcast_translation_completed(group_slug, post_slug, results) do - # Broadcast to group listing - Manager.broadcast( - posts_topic(group_slug), - {:translation_completed, post_slug, results} - ) - - # Broadcast to editor - Manager.broadcast( - post_translations_topic(group_slug, post_slug), - {:translation_completed, group_slug, post_slug, results} - ) - end - - # ============================================================================ - # Editor Presence for Group Listing - # ============================================================================ - - @doc """ - Returns the global topic for editor activity across a group. - Used by group listing to show who's editing what. - """ - def group_editors_topic(group_slug) do - "#{@topic_prefix}:#{group_slug}:editors" - end - - @doc """ - Subscribes to editor activity for a group (used by group listing). - """ - def subscribe_to_group_editors(group_slug) do - Manager.subscribe(group_editors_topic(group_slug)) - end - - @doc """ - Unsubscribes from editor activity for a group. - """ - def unsubscribe_from_group_editors(group_slug) do - Manager.unsubscribe(group_editors_topic(group_slug)) - end - - @doc """ - Broadcasts that a user started editing a post. - """ - def broadcast_editor_joined(group_slug, post_slug, user_info) do - Manager.broadcast( - group_editors_topic(group_slug), - {:editor_joined, post_slug, user_info} - ) - end - - @doc """ - Broadcasts that a user stopped editing a post. - """ - def broadcast_editor_left(group_slug, post_slug, user_info) do - Manager.broadcast( - group_editors_topic(group_slug), - {:editor_left, post_slug, user_info} - ) - end - - # ============================================================================ - # Form Key Helpers - # ============================================================================ - - @doc """ - Generates a form key for a post being edited. - - The form key includes the language to allow concurrent editing of different - translations of the same post. - - ## Examples - - generate_form_key("blog", %{path: "blog/my-post/v1/en"}) - # => "blog:blog/my-post/v1/en" - - generate_form_key("blog", %{slug: "my-post", language: "en"}) - # => "blog:my-post:en" - - generate_form_key("blog", %{slug: "my-post", language: "en"}, :new) - # => "blog:new:en" - """ - def generate_form_key(group_slug, post, mode \\ :edit) - - # UUID-based form key (preferred for DB posts) - def generate_form_key(group_slug, %{uuid: uuid, language: lang}, :edit) - when is_binary(uuid) and is_binary(lang) do - "#{group_slug}:#{uuid}:#{lang}" - end - - # Path already includes language (e.g., "blog/my-post/v1/en") - def generate_form_key(group_slug, %{path: path}, :edit) when is_binary(path) do - "#{group_slug}:#{path}" - end - - # Slug mode - include language for per-language locking - def generate_form_key(group_slug, %{slug: slug, language: lang}, :edit) - when is_binary(slug) and is_binary(lang) do - "#{group_slug}:#{slug}:#{lang}" - end - - # Fallback for slug without language (shouldn't happen in practice) - def generate_form_key(group_slug, %{slug: slug}, :edit) when is_binary(slug) do - "#{group_slug}:#{slug}" - end - - def generate_form_key(group_slug, %{language: lang}, :new) do - "#{group_slug}:new:#{lang}" - end - - def generate_form_key(group_slug, _post, :new) do - "#{group_slug}:new" - end - - def generate_form_key(group_slug, _, _) do - "#{group_slug}:unknown" - end - - # ============================================================================ - # Primary Language Migration Progress - # ============================================================================ - - @doc """ - Broadcasts that primary language migration has completed. - """ - def broadcast_primary_language_migration_completed( - group_slug, - success_count, - error_count, - primary_language - ) do - Manager.broadcast( - posts_topic(group_slug), - {:primary_language_migration_completed, group_slug, success_count, error_count, - primary_language} - ) - end -end diff --git a/lib/modules/publishing/renderer.ex b/lib/modules/publishing/renderer.ex deleted file mode 100644 index 2dc53480a..000000000 --- a/lib/modules/publishing/renderer.ex +++ /dev/null @@ -1,596 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Renderer do - @moduledoc """ - Renders publishing post markdown to HTML with caching support. - - Uses PhoenixKit.Cache for performance optimization of markdown rendering. - Cache keys include content hashes for automatic invalidation. - """ - - require Logger - - alias Phoenix.HTML.Safe - alias PhoenixKit.Modules.Publishing.PageBuilder - alias PhoenixKit.Modules.Shared.Components.EntityForm - alias PhoenixKit.Modules.Shared.Components.Image - alias PhoenixKit.Modules.Shared.Components.Video - alias PhoenixKit.Settings - - @cache_name :publishing_posts - @cache_version "v2" - - @global_cache_key "publishing_render_cache_enabled" - @per_group_cache_prefix "publishing_render_cache_enabled_" - - @component_regex ~r/<(Image|Hero|CTA|Headline|Subheadline|Video|EntityForm)\s+([^>]*?)\/>/s - @component_block_regex ~r/<(Hero|CTA|Headline|Subheadline|Video|EntityForm)\s*([^>]*)>(.*?)<\/\1>/s - - # Tailwind/daisyUI classes for post-processing Earmark HTML output. - # Code blocks (pre, code) are handled separately in style_code_blocks/1. - @pre_classes "bg-base-300 p-4 rounded-lg overflow-x-auto my-4" - @inline_code_classes "bg-base-200 px-1.5 py-0.5 rounded text-sm font-mono" - - @tag_classes [ - {"h1", "text-4xl font-bold mt-6 mb-4 pb-2 border-b border-base-content/10"}, - {"h2", "text-3xl font-semibold mt-6 mb-3"}, - {"h3", "text-2xl font-semibold mt-5 mb-2"}, - {"h4", "text-xl font-semibold mt-4 mb-2"}, - {"h5", "text-lg font-semibold mt-4 mb-2"}, - {"h6", "text-base font-semibold mt-4 mb-2"}, - {"p", "my-4 leading-relaxed"}, - {"a", "link link-primary"}, - {"blockquote", "border-l-4 border-primary pl-4 my-4 text-base-content/70 italic"}, - {"table", "table w-full my-4"}, - {"thead", "bg-base-200"}, - {"th", "font-semibold text-left p-2"}, - {"td", "border-t border-base-content/10 p-2"}, - {"img", "max-w-full h-auto rounded-lg my-4"}, - {"ul", "list-disc pl-8 my-4"}, - {"ol", "list-decimal pl-8 my-4"}, - {"li", "my-1"}, - {"hr", "my-8 border-0 border-t-2 border-base-content/10"} - ] - - # Build {regex_source, tag, classes} tuples at compile time. - # Regex structs can't be stored in module attributes, so we store the source - # strings and compile them once at runtime via a persistent cache. - @tag_patterns Enum.map(@tag_classes, fn {tag, classes} -> - {"<#{Regex.escape(tag)}(?=[\\s>\\/])([^>]*)>", tag, classes} - end) - - @doc """ - Renders a post's markdown content to HTML. - - Caches the result for published posts using content-hash-based keys. - Lazy-loads cache (only caches after first render). - - Respects `publishing_render_cache_enabled` (global) and - `publishing_render_cache_enabled_{group_slug}` (per-group) settings. - - ## Examples - - {:ok, html} = Renderer.render_post(post) - - """ - def render_post(post) do - if post.metadata.status == "published" and render_cache_enabled?(post.group) do - cache_key = build_cache_key(post) - - case get_cached(cache_key) do - {:ok, html} -> - {:ok, html} - - :miss -> - render_and_cache(post, cache_key) - end - else - # Don't cache drafts, archived posts, or when cache is disabled - {:ok, render_markdown(post.content)} - end - end - - @doc """ - Returns whether render caching is enabled for a group. - - Checks both the global setting and per-group setting. - Both must be enabled (or default to enabled) for caching to work. - """ - @spec render_cache_enabled?(String.t()) :: boolean() - def render_cache_enabled?(group_slug) do - global_enabled = global_render_cache_enabled?() - per_group_enabled = group_render_cache_enabled?(group_slug) - - global_enabled and per_group_enabled - end - - @doc """ - Returns whether the global render cache is enabled. - """ - @spec global_render_cache_enabled?() :: boolean() - def global_render_cache_enabled? do - Settings.get_setting_cached(@global_cache_key, "true") == "true" - end - - @doc """ - Returns whether render cache is enabled for a specific group. - Does not check the global setting. - """ - @spec group_render_cache_enabled?(String.t()) :: boolean() - def group_render_cache_enabled?(group_slug) do - key = @per_group_cache_prefix <> group_slug - Settings.get_setting_cached(key, "true") == "true" - end - - @doc """ - Returns the settings key for per-group render cache. - Used by other modules that need to write to the setting. - """ - @spec per_group_cache_key(String.t()) :: String.t() - def per_group_cache_key(group_slug), do: @per_group_cache_prefix <> group_slug - - @doc """ - Renders markdown or PHK content directly without caching. - - Automatically detects PHK XML format and routes to PageBuilder. - Falls back to Earmark markdown rendering for non-XML content. - - ## Examples - - html = Renderer.render_markdown(content) - - """ - def render_markdown(content) when is_binary(content) do - {time, result} = - :timer.tc(fn -> - cond do - pure_phk_content?(content) -> - render_phk_content(content) - - has_embedded_components?(content) -> - render_mixed_content(content) - - true -> - render_earmark_markdown(content) - end - end) - - Logger.debug("Content render time: #{time}μs", content_size: byte_size(content)) - result - end - - def render_markdown(_), do: "" - - # Detect if content is pure PHK XML format (starts with or ) - defp pure_phk_content?(content) do - trimmed = String.trim(content) - String.starts_with?(trimmed, " - # Convert Phoenix.LiveView.Rendered to string - html - |> Safe.to_iodata() - |> IO.iodata_to_binary() - - {:error, reason} -> - Logger.warning("PHK render error: #{inspect(reason)}") - "

Error rendering page content

" - end - end - - # Render markdown using Earmark, then inject Tailwind/daisyUI classes on each tag. - defp render_earmark_markdown(content) do - content = normalize_markdown(content) - - case Earmark.as_html(content, %Earmark.Options{ - code_class_prefix: "language-", - smartypants: true, - gfm: true, - escape: false - }) do - {:ok, html, _warnings} -> add_tailwind_classes(html) - {:error, _html, _errors} -> ~s(

Error rendering markdown

) - end - end - - defp normalize_markdown(content) when is_binary(content) do - content - # Remove leading indentation before Markdown headings (e.g., " ## Title") - |> then(&Regex.replace(~r/^[ \t]+(?=#)/m, &1, "")) - # Preserve intentional blank lines: convert runs of 2+ blank lines into - # visible spacing so the rendered output matches what the author typed. - # A single blank line remains a normal paragraph break (standard Markdown). - |> preserve_blank_lines() - end - - # Converts sequences of 2+ consecutive blank lines into paragraph breaks - # with
spacers. Each extra blank line beyond the first becomes one
. - defp preserve_blank_lines(content) do - Regex.replace(~r/\n{3,}/, content, fn match -> - # Number of extra blank lines beyond the standard paragraph break - # \n\n = 1 blank line (normal paragraph break), \n\n\n = 2 blank lines, etc. - extra_lines = String.length(match) - 2 - br_tags = String.duplicate(" \n\n", extra_lines) - "\n\n#{br_tags}" - end) - end - - # ============================================================================ - # Tailwind Class Injection - # ============================================================================ - - # Adds Tailwind/daisyUI classes to rendered HTML tags so markdown content - # is styled without requiring a prose plugin or inline

Visible

" - result = HtmlSanitizer.sanitize(input) - refute result =~ "style" - assert result =~ "

Visible

" - end - - test "removes onclick event handlers" do - result = HtmlSanitizer.sanitize(~s[

Hello

]) - assert result == "

Hello

" - end - - test "removes onerror event handlers" do - result = HtmlSanitizer.sanitize(~s[]) - refute result =~ "onerror" - end - - test "removes onload event handlers" do - input = ~s[

Content

] - result = HtmlSanitizer.sanitize(input) - refute result =~ "onload" - end - - test "removes javascript: URLs from href" do - result = HtmlSanitizer.sanitize(~s[Click]) - refute result =~ "javascript" - assert result =~ "Click" - end - - test "removes javascript: URLs from src" do - input = ~s[] - result = HtmlSanitizer.sanitize(input) - refute result =~ "javascript" - end - - test "removes data: URLs" do - input = ~s[Click] - result = HtmlSanitizer.sanitize(input) - refute result =~ "data:" - end - - test "removes iframe tags" do - input = ~s(

Safe

) - result = HtmlSanitizer.sanitize(input) - refute result =~ "iframe" - assert result =~ "

Safe

" - end - - test "removes object tags" do - input = ~s(

Safe

) - result = HtmlSanitizer.sanitize(input) - refute result =~ "object" - end - - test "removes embed tags" do - input = ~s(

Safe

) - result = HtmlSanitizer.sanitize(input) - refute result =~ "embed" - end - - test "removes form tags" do - input = ~s(
) - result = HtmlSanitizer.sanitize(input) - refute result =~ "form" - refute result =~ "input" - end - - test "preserves safe links" do - input = ~s(Link) - assert HtmlSanitizer.sanitize(input) == input - end - - test "preserves tables" do - input = "
Cell
" - assert HtmlSanitizer.sanitize(input) == input - end - - test "preserves lists" do - input = "
  • Item 1
  • Item 2
" - assert HtmlSanitizer.sanitize(input) == input - end - - test "returns nil for nil input" do - assert HtmlSanitizer.sanitize(nil) == nil - end - - test "returns empty string for empty input" do - assert HtmlSanitizer.sanitize("") == "" - end - - test "passes through non-string values" do - assert HtmlSanitizer.sanitize(42) == 42 - end - - test "trims whitespace from result" do - assert HtmlSanitizer.sanitize("

Hello

") == "

Hello

" - end - end - - # --- sanitize_rich_text_fields/2 --- - - describe "sanitize_rich_text_fields/2" do - test "sanitizes only rich_text fields" do - fields = [ - %{"type" => "rich_text", "key" => "content"}, - %{"type" => "text", "key" => "title"} - ] - - data = %{ - "content" => "

Hello

", - "title" => "Title" - } - - result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) - - assert result["content"] == "

Hello

" - # text field is NOT sanitized - assert result["title"] == "Title" - end - - test "handles multiple rich_text fields" do - fields = [ - %{"type" => "rich_text", "key" => "body"}, - %{"type" => "rich_text", "key" => "summary"} - ] - - data = %{ - "body" => "

Body

", - "summary" => "

Summary

" - } - - result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) - - assert result["body"] == "

Body

" - assert result["summary"] == "

Summary

" - end - - test "skips nil values in rich_text fields" do - fields = [%{"type" => "rich_text", "key" => "content"}] - data = %{"content" => nil} - - result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) - assert result["content"] == nil - end - - test "handles no rich_text fields" do - fields = [%{"type" => "text", "key" => "name"}] - data = %{"name" => "test"} - - result = HtmlSanitizer.sanitize_rich_text_fields(fields, data) - assert result == data - end - - test "handles empty fields list" do - data = %{"content" => ""} - result = HtmlSanitizer.sanitize_rich_text_fields([], data) - assert result == data - end - - test "returns data unchanged for invalid fields argument" do - data = %{"test" => "value"} - assert HtmlSanitizer.sanitize_rich_text_fields("invalid", data) == data - end - end -end diff --git a/test/modules/entities/multilang_test.exs b/test/modules/entities/multilang_test.exs deleted file mode 100644 index bee83fc12..000000000 --- a/test/modules/entities/multilang_test.exs +++ /dev/null @@ -1,459 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.MultilangTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Entities.Multilang - - # --- Test Data --- - - defp multilang_data do - %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme", "category" => "Tech", "desc" => "A company"}, - "es-ES" => %{"name" => "Acme España"}, - "fr-FR" => %{"desc" => "Une entreprise"} - } - end - - defp flat_data do - %{"name" => "Acme", "category" => "Tech"} - end - - # --- multilang_data?/1 --- - - describe "multilang_data?/1" do - test "returns true for data with _primary_language key" do - assert Multilang.multilang_data?(multilang_data()) - end - - test "returns false for flat data" do - refute Multilang.multilang_data?(flat_data()) - end - - test "returns false for nil" do - refute Multilang.multilang_data?(nil) - end - - test "returns false for empty map" do - refute Multilang.multilang_data?(%{}) - end - - test "returns false for non-map values" do - refute Multilang.multilang_data?("string") - refute Multilang.multilang_data?(42) - refute Multilang.multilang_data?([]) - end - end - - # --- get_language_data/2 --- - - describe "get_language_data/2" do - test "returns primary data for primary language" do - result = Multilang.get_language_data(multilang_data(), "en-US") - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - end - - test "returns merged data for secondary language" do - result = Multilang.get_language_data(multilang_data(), "es-ES") - - assert result == %{ - "name" => "Acme España", - "category" => "Tech", - "desc" => "A company" - } - end - - test "secondary language overrides only differ from primary" do - result = Multilang.get_language_data(multilang_data(), "fr-FR") - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "Une entreprise" - } - end - - test "returns primary data for language with no overrides" do - result = Multilang.get_language_data(multilang_data(), "de-DE") - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - end - - test "returns flat data as-is for non-multilang data" do - result = Multilang.get_language_data(flat_data(), "en-US") - assert result == flat_data() - end - - test "returns empty map for nil data" do - assert Multilang.get_language_data(nil, "en-US") == %{} - end - end - - # --- get_primary_data/1 --- - - describe "get_primary_data/1" do - test "extracts primary language data from multilang" do - result = Multilang.get_primary_data(multilang_data()) - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - end - - test "returns flat data as-is" do - assert Multilang.get_primary_data(flat_data()) == flat_data() - end - - test "returns empty map for nil" do - assert Multilang.get_primary_data(nil) == %{} - end - end - - # --- get_raw_language_data/2 --- - - describe "get_raw_language_data/2" do - test "returns raw primary data (all fields)" do - result = Multilang.get_raw_language_data(multilang_data(), "en-US") - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - end - - test "returns raw overrides only for secondary language" do - result = Multilang.get_raw_language_data(multilang_data(), "es-ES") - assert result == %{"name" => "Acme España"} - end - - test "returns empty map for language with no overrides" do - result = Multilang.get_raw_language_data(multilang_data(), "de-DE") - assert result == %{} - end - - test "returns flat data as-is for non-multilang" do - result = Multilang.get_raw_language_data(flat_data(), "en-US") - assert result == flat_data() - end - - test "returns empty map for nil" do - assert Multilang.get_raw_language_data(nil, "en-US") == %{} - end - end - - # --- put_language_data/3 --- - - describe "put_language_data/3" do - test "stores all fields for primary language" do - new_fields = %{"name" => "Acme Corp", "category" => "Business", "desc" => "Updated"} - result = Multilang.put_language_data(multilang_data(), "en-US", new_fields) - - assert result["_primary_language"] == "en-US" - assert result["en-US"] == new_fields - # Other languages preserved - assert result["es-ES"] == %{"name" => "Acme España"} - end - - test "stores only overrides for secondary language" do - new_fields = %{"name" => "Acme Frankreich", "category" => "Tech", "desc" => "A company"} - result = Multilang.put_language_data(multilang_data(), "de-DE", new_fields) - - # Only "name" differs from primary, so only "name" is stored - assert result["de-DE"] == %{"name" => "Acme Frankreich"} - end - - test "removes secondary language key when all fields match primary" do - # Submit exact same data as primary - primary_data = %{"name" => "Acme", "category" => "Tech", "desc" => "A company"} - result = Multilang.put_language_data(multilang_data(), "es-ES", primary_data) - - refute Map.has_key?(result, "es-ES") - end - - test "removes secondary language key when all fields are empty" do - result = - Multilang.put_language_data(multilang_data(), "es-ES", %{"name" => "", "category" => ""}) - - refute Map.has_key?(result, "es-ES") - end - - test "converts flat data to multilang structure on first put" do - result = Multilang.put_language_data(flat_data(), "en-US", %{"name" => "Updated"}) - - assert Multilang.multilang_data?(result) - assert result["en-US"] == %{"name" => "Updated"} - end - - test "handles nil existing data" do - result = Multilang.put_language_data(nil, "en-US", %{"name" => "New"}) - - assert Multilang.multilang_data?(result) - end - - test "uses embedded primary for existing multilang data" do - data = multilang_data() - new_es = %{"name" => "Nuevo Nombre", "category" => "Tech", "desc" => "A company"} - result = Multilang.put_language_data(data, "es-ES", new_es) - - # Only the override (name) should be stored - assert result["es-ES"] == %{"name" => "Nuevo Nombre"} - # Primary unchanged - assert result["_primary_language"] == "en-US" - end - end - - # --- migrate_to_multilang/2 --- - - describe "migrate_to_multilang/2" do - test "wraps flat data into multilang structure" do - result = Multilang.migrate_to_multilang(flat_data(), "en-US") - - assert result["_primary_language"] == "en-US" - assert result["en-US"] == flat_data() - end - - test "handles nil data" do - result = Multilang.migrate_to_multilang(nil, "en-US") - - assert result["_primary_language"] == "en-US" - assert result["en-US"] == %{} - end - - test "uses provided language code" do - result = Multilang.migrate_to_multilang(flat_data(), "es-ES") - - assert result["_primary_language"] == "es-ES" - assert result["es-ES"] == flat_data() - end - end - - # --- flatten_to_primary/1 --- - - describe "flatten_to_primary/1" do - test "extracts primary language data" do - result = Multilang.flatten_to_primary(multilang_data()) - - assert result == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - end - - test "returns flat data as-is (no _primary_language key)" do - assert Multilang.flatten_to_primary(flat_data()) == flat_data() - end - - test "returns empty map for nil" do - assert Multilang.flatten_to_primary(nil) == %{} - end - - test "returns empty map for non-map input" do - assert Multilang.flatten_to_primary("string") == %{} - end - - test "handles missing primary language data gracefully" do - data = %{"_primary_language" => "ja-JP"} - assert Multilang.flatten_to_primary(data) == %{} - end - end - - # --- rekey_primary/2 --- - - describe "rekey_primary/2" do - test "promotes new primary with all fields from old primary" do - result = Multilang.rekey_primary(multilang_data(), "es-ES") - - assert result["_primary_language"] == "es-ES" - - # New primary gets merged: old primary base + its own overrides - assert result["es-ES"] == %{ - "name" => "Acme España", - "category" => "Tech", - "desc" => "A company" - } - end - - test "strips old primary to overrides" do - result = Multilang.rekey_primary(multilang_data(), "es-ES") - - # Old primary (en-US) is now secondary — only fields differing from new primary are kept. - # New primary has: name="Acme España", category="Tech", desc="A company" - # Old primary had: name="Acme", category="Tech", desc="A company" - # Only "name" differs → stored as override - assert result["en-US"] == %{"name" => "Acme"} - end - - test "recomputes other secondaries against new primary" do - result = Multilang.rekey_primary(multilang_data(), "es-ES") - - # fr-FR had override: desc="Une entreprise" - # New primary has: name="Acme España", category="Tech", desc="A company" - # fr-FR full data: name="Acme", category="Tech", desc="Une entreprise" - # Overrides vs new primary: name differs ("Acme" vs "Acme España"), desc differs - assert result["fr-FR"] == %{"name" => "Acme", "desc" => "Une entreprise"} - end - - test "returns data unchanged when already using that primary" do - result = Multilang.rekey_primary(multilang_data(), "en-US") - assert result == multilang_data() - end - - test "returns non-multilang data unchanged" do - result = Multilang.rekey_primary(flat_data(), "es-ES") - assert result == flat_data() - end - - test "returns nil unchanged" do - assert Multilang.rekey_primary(nil, "es-ES") == nil - end - - test "re-keys to language with no existing overrides" do - result = Multilang.rekey_primary(multilang_data(), "de-DE") - - assert result["_primary_language"] == "de-DE" - - # de-DE gets all fields from old primary (no overrides existed) - assert result["de-DE"] == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - - # Old primary (en-US) now matches de-DE exactly → key removed entirely - refute Map.has_key?(result, "en-US") - end - - test "removes secondary when all fields match new primary" do - # Create data where es-ES has overrides that match what de-DE would promote to - data = %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme", "color" => "red"}, - "es-ES" => %{"name" => "Acme"} - } - - # Rekey to es-ES: promoted = merge(en-US, es-ES) = %{name: "Acme", color: "red"} - # en-US vs promoted: name same, color same → removed entirely - result = Multilang.rekey_primary(data, "es-ES") - - assert result["_primary_language"] == "es-ES" - assert result["es-ES"] == %{"name" => "Acme", "color" => "red"} - refute Map.has_key?(result, "en-US") - end - - test "is idempotent" do - once = Multilang.rekey_primary(multilang_data(), "es-ES") - twice = Multilang.rekey_primary(once, "es-ES") - assert once == twice - end - - test "round-trip preserves all translatable data" do - rekeyed = Multilang.rekey_primary(multilang_data(), "es-ES") - back = Multilang.rekey_primary(rekeyed, "en-US") - - # Primary data should be fully restored - assert back["_primary_language"] == "en-US" - - assert back["en-US"] == %{ - "name" => "Acme", - "category" => "Tech", - "desc" => "A company" - } - - # es-ES becomes overrides-only (name differs from restored primary) - assert back["es-ES"] == %{"name" => "Acme España"} - - # fr-FR still has its override - assert back["fr-FR"] == %{"desc" => "Une entreprise"} - end - end - - # --- maybe_rekey_data/1 --- - # Note: In test env without Languages module DB, primary_language() falls - # back to "en-US". So data with embedded "en-US" is a no-op, while data - # with any other embedded primary will be re-keyed to "en-US". - - describe "maybe_rekey_data/1" do - test "re-keys when embedded primary differs from global" do - # Embedded is "es-ES", global fallback is "en-US" → should re-key - data = %{ - "_primary_language" => "es-ES", - "es-ES" => %{"name" => "Acme España", "category" => "Tech"}, - "en-US" => %{"name" => "Acme"} - } - - result = Multilang.maybe_rekey_data(data) - - assert result["_primary_language"] == "en-US" - # New primary promoted: merge(es-ES base, en-US overrides) = name="Acme", category="Tech" - assert result["en-US"] == %{"name" => "Acme", "category" => "Tech"} - # Old primary (es-ES) stripped to overrides: only name differs - assert result["es-ES"] == %{"name" => "Acme España"} - end - - test "returns data unchanged when already using global primary" do - # Embedded is "en-US" which matches the fallback global - result = Multilang.maybe_rekey_data(multilang_data()) - - assert result == multilang_data() - end - - test "returns non-multilang data unchanged" do - result = Multilang.maybe_rekey_data(flat_data()) - assert result == flat_data() - end - - test "returns nil unchanged" do - assert Multilang.maybe_rekey_data(nil) == nil - end - end - - # --- Integration: migrate then put --- - - describe "migrate + put workflow" do - test "flat data -> multilang -> add secondary" do - data = flat_data() - multilang = Multilang.migrate_to_multilang(data, "en-US") - - assert Multilang.multilang_data?(multilang) - - result = - Multilang.put_language_data(multilang, "es-ES", %{ - "name" => "Acme España", - "category" => "Tech" - }) - - # Only name differs, category matches primary - assert result["es-ES"] == %{"name" => "Acme España"} - assert result["en-US"] == flat_data() - end - - test "get_language_data returns correct merged result after put" do - data = multilang_data() - - updated = - Multilang.put_language_data(data, "de-DE", %{ - "name" => "Acme DE", - "category" => "Tech", - "desc" => "A company" - }) - - result = Multilang.get_language_data(updated, "de-DE") - - assert result["name"] == "Acme DE" - assert result["category"] == "Tech" - assert result["desc"] == "A company" - end - end -end diff --git a/test/modules/entities/title_translation_test.exs b/test/modules/entities/title_translation_test.exs deleted file mode 100644 index 1e479b2d4..000000000 --- a/test/modules/entities/title_translation_test.exs +++ /dev/null @@ -1,165 +0,0 @@ -defmodule PhoenixKit.Modules.Entities.TitleTranslationTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.Entities.EntityData - - # Pure-function tests for get_title_translation/2 and get_all_title_translations/1. - # set_title_translation/3 requires DB access and is covered in parent app integration tests. - - # --- Test Fixtures --- - - defp record_with_title_in_data do - %EntityData{ - title: "Acme", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme Corp", "_title" => "Acme"}, - "es-ES" => %{"name" => "Acme España", "_title" => "Acme ES"} - }, - metadata: %{} - } - end - - defp record_with_old_metadata_translations do - %EntityData{ - title: "Acme", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme Corp"} - }, - metadata: %{ - "translations" => %{ - "es-ES" => %{"title" => "Acme Metadata ES"} - } - } - } - end - - defp record_with_no_translations do - %EntityData{ - title: "Acme", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme Corp"} - }, - metadata: %{} - } - end - - defp record_with_flat_data do - %EntityData{ - title: "Acme", - data: %{"name" => "Acme Corp"}, - metadata: %{} - } - end - - defp record_with_nil_data do - %EntityData{ - title: "Acme", - data: nil, - metadata: nil - } - end - - # --- get_title_translation/2 --- - - describe "get_title_translation/2" do - test "returns _title from JSONB data for primary language" do - assert EntityData.get_title_translation(record_with_title_in_data(), "en-US") == "Acme" - end - - test "returns _title from JSONB data for secondary language" do - assert EntityData.get_title_translation(record_with_title_in_data(), "es-ES") == "Acme ES" - end - - test "falls back to metadata translations for unmigrated records" do - assert EntityData.get_title_translation(record_with_old_metadata_translations(), "es-ES") == - "Acme Metadata ES" - end - - test "falls back to title column when no translations exist" do - assert EntityData.get_title_translation(record_with_no_translations(), "es-ES") == "Acme" - end - - test "falls back to title column for unknown language" do - assert EntityData.get_title_translation(record_with_title_in_data(), "de-DE") == "Acme" - end - - test "handles flat (non-multilang) data" do - assert EntityData.get_title_translation(record_with_flat_data(), "en-US") == "Acme" - end - - test "handles nil data" do - assert EntityData.get_title_translation(record_with_nil_data(), "en-US") == "Acme" - end - - test "prefers JSONB _title over metadata translations" do - # Record has _title in data AND old metadata translations - record = %EntityData{ - title: "Fallback", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"_title" => "From Data"}, - "es-ES" => %{"_title" => "Desde Datos"} - }, - metadata: %{ - "translations" => %{ - "es-ES" => %{"title" => "Desde Metadata"} - } - } - } - - assert EntityData.get_title_translation(record, "es-ES") == "Desde Datos" - end - - test "skips empty _title and falls back" do - record = %EntityData{ - title: "Fallback Title", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"_title" => ""}, - "es-ES" => %{"_title" => ""} - }, - metadata: %{} - } - - assert EntityData.get_title_translation(record, "en-US") == "Fallback Title" - assert EntityData.get_title_translation(record, "es-ES") == "Fallback Title" - end - - test "secondary language without override inherits primary _title" do - record = %EntityData{ - title: "Acme", - data: %{ - "_primary_language" => "en-US", - "en-US" => %{"name" => "Acme Corp", "_title" => "Acme Products"} - }, - metadata: %{} - } - - # fr-FR has no override, get_language_data merges primary → _title inherited - assert EntityData.get_title_translation(record, "fr-FR") == "Acme Products" - end - end - - # --- get_all_title_translations/1 --- - - describe "get_all_title_translations/1" do - test "returns map with all enabled languages" do - result = EntityData.get_all_title_translations(record_with_title_in_data()) - - # In test env, enabled_languages falls back to ["en-US"] - assert is_map(result) - assert Map.has_key?(result, "en-US") - assert result["en-US"] == "Acme" - end - - test "handles record with no translations" do - result = EntityData.get_all_title_translations(record_with_no_translations()) - - assert is_map(result) - assert result["en-US"] == "Acme" - end - end -end diff --git a/test/phoenix_kit/module_registry_test.exs b/test/phoenix_kit/module_registry_test.exs index 028c41b56..ae6563ee2 100644 --- a/test/phoenix_kit/module_registry_test.exs +++ b/test/phoenix_kit/module_registry_test.exs @@ -3,7 +3,7 @@ defmodule PhoenixKit.ModuleRegistryTest do alias PhoenixKit.ModuleRegistry - # The registry is started in test_helper.exs with all 17 internal modules loaded. + # The registry is started in test_helper.exs with all 16 internal modules loaded. describe "all_modules/0" do test "returns a non-empty list" do @@ -12,9 +12,9 @@ defmodule PhoenixKit.ModuleRegistryTest do assert modules != [] end - test "contains all 17 internal modules" do + test "contains all 16 internal modules" do modules = ModuleRegistry.all_modules() - assert length(modules) >= 17 + assert length(modules) >= 16 end test "all entries are atoms" do @@ -28,7 +28,6 @@ defmodule PhoenixKit.ModuleRegistryTest do assert PhoenixKit.Modules.AI in modules assert PhoenixKit.Modules.CustomerService in modules assert PhoenixKit.Modules.Billing in modules - assert PhoenixKit.Modules.Entities in modules assert PhoenixKit.Jobs in modules end @@ -125,7 +124,6 @@ defmodule PhoenixKit.ModuleRegistryTest do assert :admin_customer_service in tab_ids assert :admin_billing in tab_ids - assert :admin_entities in tab_ids end end @@ -144,7 +142,7 @@ defmodule PhoenixKit.ModuleRegistryTest do test "returns a list of permission metadata maps" do metadata = ModuleRegistry.all_permission_metadata() assert is_list(metadata) - assert length(metadata) >= 16 + assert length(metadata) >= 15 for meta <- metadata do assert is_map(meta) @@ -160,16 +158,15 @@ defmodule PhoenixKit.ModuleRegistryTest do assert "customer_service" in keys assert "billing" in keys assert "ai" in keys - assert "entities" in keys assert "shop" in keys end end describe "all_feature_keys/0" do - test "returns sorted list of 16 feature keys" do + test "returns sorted list of 15 feature keys" do keys = ModuleRegistry.all_feature_keys() assert is_list(keys) - assert length(keys) == 16 + assert length(keys) == 15 assert keys == Enum.sort(keys) end @@ -196,7 +193,7 @@ defmodule PhoenixKit.ModuleRegistryTest do test "returns a map of key => {module, :enabled?}" do checks = ModuleRegistry.feature_enabled_checks() assert is_map(checks) - assert map_size(checks) >= 16 + assert map_size(checks) >= 15 for {key, {mod, fun}} <- checks do assert is_binary(key) diff --git a/test/phoenix_kit/module_test.exs b/test/phoenix_kit/module_test.exs index 1069ada95..c0793824e 100644 --- a/test/phoenix_kit/module_test.exs +++ b/test/phoenix_kit/module_test.exs @@ -9,7 +9,6 @@ defmodule PhoenixKit.ModuleTest do PhoenixKit.Modules.Comments, PhoenixKit.Modules.Connections, PhoenixKit.Modules.DB, - PhoenixKit.Modules.Entities, PhoenixKit.Modules.Languages, PhoenixKit.Modules.Legal, PhoenixKit.Modules.Maintenance, @@ -30,7 +29,7 @@ defmodule PhoenixKit.ModuleTest do :ok end - describe "all 17 modules implement PhoenixKit.Module behaviour" do + describe "all 16 modules implement PhoenixKit.Module behaviour" do test "all modules are loadable" do for mod <- @all_internal_modules do assert Code.ensure_loaded?(mod), "#{inspect(mod)} should be loadable" diff --git a/test/phoenix_kit/users/permissions_test.exs b/test/phoenix_kit/users/permissions_test.exs index d73218559..617026aa7 100644 --- a/test/phoenix_kit/users/permissions_test.exs +++ b/test/phoenix_kit/users/permissions_test.exs @@ -50,9 +50,7 @@ defmodule PhoenixKit.Users.PermissionsTest do assert is_list(keys) assert "billing" in keys assert "shop" in keys - assert "entities" in keys assert "ai" in keys - assert length(keys) == 17 end test "does not include core keys" do @@ -69,8 +67,8 @@ defmodule PhoenixKit.Users.PermissionsTest do assert MapSet.new(all) == MapSet.new(expected) end - test "has 22 built-in keys" do - assert length(Permissions.all_module_keys()) == 22 + test "has 20 built-in keys" do + assert length(Permissions.all_module_keys()) == 20 end end @@ -283,7 +281,7 @@ defmodule PhoenixKit.Users.PermissionsTest do assert Permissions.valid_module_key?("billing") end - test "returns true for all 24 built-in keys" do + test "returns true for all 20 built-in keys" do for key <- Permissions.core_section_keys() ++ Permissions.feature_module_keys() do assert Permissions.valid_module_key?(key), "Expected #{key} to be valid" end From 2833857f64ccc0645c5a795d5716fa0818bd2d87 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 24 Mar 2026 07:38:01 +0200 Subject: [PATCH 4/7] Extract AI module into external phoenix_kit_ai package Remove all AI source code, tests, and admin routes from the monorepo. Add AI to known_external_packages in module registry. Update test counts and remove AI-specific assertions across module, registry, and permissions tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/ai/README.md | 661 ------- lib/modules/ai/ai.ex | 1833 ------------------- lib/modules/ai/ai_model.ex | 45 - lib/modules/ai/completion.ex | 321 ---- lib/modules/ai/endpoint.ex | 404 ---- lib/modules/ai/openrouter_client.ex | 665 ------- lib/modules/ai/prompt.ex | 527 ------ lib/modules/ai/request.ex | 258 --- lib/modules/ai/web/endpoint_form.ex | 718 -------- lib/modules/ai/web/endpoint_form.html.heex | 747 -------- lib/modules/ai/web/endpoints.ex | 587 ------ lib/modules/ai/web/endpoints.html.heex | 978 ---------- lib/modules/ai/web/playground.ex | 300 --- lib/modules/ai/web/playground.html.heex | 336 ---- lib/modules/ai/web/prompt_form.ex | 130 -- lib/modules/ai/web/prompt_form.html.heex | 217 --- lib/modules/ai/web/prompts.ex | 242 --- lib/modules/ai/web/prompts.html.heex | 222 --- lib/phoenix_kit/module_registry.ex | 11 +- lib/phoenix_kit_web/integration.ex | 25 - test/modules/ai/prompt_test.exs | 298 --- test/phoenix_kit/module_discovery_test.exs | 1 - test/phoenix_kit/module_registry_test.exs | 20 +- test/phoenix_kit/module_test.exs | 3 +- test/phoenix_kit/users/permissions_test.exs | 9 +- 25 files changed, 21 insertions(+), 9537 deletions(-) delete mode 100644 lib/modules/ai/README.md delete mode 100644 lib/modules/ai/ai.ex delete mode 100644 lib/modules/ai/ai_model.ex delete mode 100644 lib/modules/ai/completion.ex delete mode 100644 lib/modules/ai/endpoint.ex delete mode 100644 lib/modules/ai/openrouter_client.ex delete mode 100644 lib/modules/ai/prompt.ex delete mode 100644 lib/modules/ai/request.ex delete mode 100644 lib/modules/ai/web/endpoint_form.ex delete mode 100644 lib/modules/ai/web/endpoint_form.html.heex delete mode 100644 lib/modules/ai/web/endpoints.ex delete mode 100644 lib/modules/ai/web/endpoints.html.heex delete mode 100644 lib/modules/ai/web/playground.ex delete mode 100644 lib/modules/ai/web/playground.html.heex delete mode 100644 lib/modules/ai/web/prompt_form.ex delete mode 100644 lib/modules/ai/web/prompt_form.html.heex delete mode 100644 lib/modules/ai/web/prompts.ex delete mode 100644 lib/modules/ai/web/prompts.html.heex delete mode 100644 test/modules/ai/prompt_test.exs diff --git a/lib/modules/ai/README.md b/lib/modules/ai/README.md deleted file mode 100644 index 8a04a9e09..000000000 --- a/lib/modules/ai/README.md +++ /dev/null @@ -1,661 +0,0 @@ -# AI Module - -The PhoenixKit AI module provides a complete AI integration system with unified endpoint management, usage tracking, and a simple API for making AI calls. Currently supports OpenRouter as the AI provider gateway, giving access to hundreds of models from various providers. - -## Quick Links - -- **Admin Interface**: `/{prefix}/admin/ai/endpoints` -- **Prompt Templates**: `/{prefix}/admin/ai/prompts` -- **Usage Statistics**: `/{prefix}/admin/ai/usage` -- **Create Endpoint**: `/{prefix}/admin/ai/endpoints/new` - -## Architecture Overview - -The AI module uses a unified **Endpoint** architecture where each endpoint contains everything needed to make AI calls: - -- **Provider credentials** (API key, base URL) -- **Model selection** (e.g., `anthropic/claude-3-haiku`) -- **Generation parameters** (temperature, max_tokens, etc.) - -### Core Modules - -- **PhoenixKit.Modules.AI** – Main API module with completion functions and endpoint management -- **PhoenixKit.Modules.AI.Endpoint** – Endpoint schema combining credentials + model + parameters -- **PhoenixKit.Modules.AI.Prompt** – Reusable prompt templates with variable substitution -- **PhoenixKit.Modules.AI.Request** – Request logging schema for usage tracking -- **PhoenixKit.Modules.AI.Completion** – HTTP client for making API calls -- **PhoenixKit.Modules.AI.OpenRouterClient** – Model discovery and API key validation - -## Core Features - -- **Unified Endpoints** – Each endpoint is a complete AI configuration -- **Unlimited Endpoints** – Create as many endpoints as needed -- **Prompt Templates** – Reusable prompts with `{{Variable}}` substitution -- **Usage Tracking** – All requests logged with tokens, latency, and cost -- **Parameter Overrides** – Override endpoint parameters per-request -- **Model Discovery** – Dynamic model fetching from OpenRouter API -- **Sortable Lists** – Sort endpoints by ID, name, usage, cost, last used, etc. -- **Filterable History** – Filter request history by endpoint, model, status, source - -## Database Tables - -- **phoenix_kit_ai_endpoints** – Endpoint storage (credentials, model, parameters) -- **phoenix_kit_ai_prompts** – Reusable prompt templates with variables -- **phoenix_kit_ai_requests** – Request logging with usage statistics - -## ID System - -The AI module uses **UUIDs** as primary keys: - -| Context | ID Type | Field | Example | -|---------|---------|-------|---------| -| Primary key | UUID | `.uuid` | `endpoint.uuid` → `"018f1234-..."` | -| External references (URLs, APIs) | UUID | `.uuid` | `/endpoints/{uuid}/edit` | -| Foreign keys (requests → endpoints) | UUID | `.uuid` | `endpoint.uuid` | -| Usage stats keys | UUID | `.uuid` | `stats[endpoint.uuid]` | -| Lookups | UUID | - | `get_endpoint("uuid-string")` | - -**Rule of thumb:** -- Use `endpoint.uuid` for database operations, FKs, and stats -- Use `endpoint.uuid` for URLs and external API references -- The `get_endpoint/1` function accepts UUID strings - -## API Usage - -### Simple Chat Completion - -```elixir -# Using endpoint ID (UUID or legacy integer) -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "What is 2+2?") -{:ok, text} = PhoenixKit.Modules.AI.extract_content(response) -# => "4" -``` - -### With System Message - -```elixir -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "Hello", - system: "You are a pirate. Always respond like a pirate." -) -``` - -### Multi-Turn Conversation - -```elixir -{:ok, response} = PhoenixKit.Modules.AI.complete(endpoint.uuid, [ - %{role: "system", content: "You are a helpful assistant."}, - %{role: "user", content: "What's the weather like?"}, - %{role: "assistant", content: "I don't have real-time weather data..."}, - %{role: "user", content: "That's okay, just make something up."} -]) -``` - -### Parameter Overrides - -```elixir -# Override temperature and max_tokens for this request only -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "Write a creative poem", - temperature: 1.5, - max_tokens: 500 -) -``` - -### Embeddings - -```elixir -# Single text -{:ok, response} = PhoenixKit.Modules.AI.embed(endpoint.uuid, "Hello, world!") - -# Multiple texts (batch) -{:ok, response} = PhoenixKit.Modules.AI.embed(endpoint.uuid, ["Text 1", "Text 2", "Text 3"]) - -# With dimension override -{:ok, response} = PhoenixKit.Modules.AI.embed(endpoint.uuid, "Hello", dimensions: 512) -``` - -### Extracting Response Data - -```elixir -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "Hello!") - -# Get just the text content -{:ok, text} = PhoenixKit.Modules.AI.extract_content(response) - -# Get usage statistics (includes cost in nanodollars) -usage = PhoenixKit.Modules.AI.extract_usage(response) -# => %{prompt_tokens: 10, completion_tokens: 15, total_tokens: 25, cost_cents: 30} - -# Full response includes latency -response["latency_ms"] # => 850 -``` - -## Source Tracking & Debugging - -All AI requests automatically capture caller information for analytics and debugging. - -### Automatic Tracking - -Every request automatically stores: - -- **Source** - Clean identifier like `PhoenixKitWeb.Live.Modules.Languages.translate` -- **Stacktrace** - Full call stack (up to 20 frames) for debugging -- **Caller Context** - Additional debug info: - - `request_id` - Phoenix request ID (if in HTTP/LiveView context) - - `node` - Node name (useful for distributed systems) - - `pid` - Process ID - - `memory_bytes` - Process memory at call time - -```elixir -# Automatic detection - no code changes needed -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "Hello!") -# Source automatically detected from caller: "MyApp.ContentGenerator.summarize" -``` - -### Manual Source Override - -Override the auto-detected source when needed: - -```elixir -{:ok, response} = PhoenixKit.Modules.AI.ask(endpoint.uuid, "Hello!", - source: "CustomLabel" -) -# Manual source used, but stacktrace and caller context still captured -``` - -### Viewing Debug Info - -In the Usage tab request details modal: -- **Source** is displayed prominently for quick identification -- **Caller Context** shows request ID, node, PID, and memory usage -- **Stacktrace** is in a collapsible section for debugging - -This information is stored in the request's `metadata` field (JSONB) and requires no database migration. - -## Endpoint Management - -### Creating Endpoints - -```elixir -{:ok, endpoint} = PhoenixKit.Modules.AI.create_endpoint(%{ - name: "Claude Fast", - provider: "openrouter", - api_key: "sk-or-v1-...", - model: "anthropic/claude-3-haiku", - temperature: 0.7, - max_tokens: 1000 -}) -``` - -### Listing Endpoints - -```elixir -# List all endpoints -endpoints = PhoenixKit.Modules.AI.list_endpoints() - -# With sorting -endpoints = PhoenixKit.Modules.AI.list_endpoints(sort_by: :usage, sort_dir: :desc) - -# Filter by provider or status -endpoints = PhoenixKit.Modules.AI.list_endpoints(provider: "openrouter", enabled: true) -``` - -### Updating Endpoints - -```elixir -endpoint = PhoenixKit.Modules.AI.get_endpoint!("550e8400-e29b-41d4-a716-446655440000") -{:ok, updated} = PhoenixKit.Modules.AI.update_endpoint(endpoint, %{temperature: 0.5}) -``` - -### Enabling/Disabling Endpoints - -```elixir -# Disabled endpoints return an error when called -endpoint = PhoenixKit.Modules.AI.get_endpoint!("550e8400-e29b-41d4-a716-446655440000") -{:ok, _} = PhoenixKit.Modules.AI.update_endpoint(endpoint, %{enabled: false}) - -# Calling a disabled endpoint -{:error, "Endpoint is disabled"} = PhoenixKit.Modules.AI.ask("550e8400-e29b-41d4-a716-446655440000", "Hello") -``` - -## Prompt Templates - -Prompts are reusable templates with variable substitution using `{{VariableName}}` syntax. - -### Creating Prompts - -```elixir -{:ok, prompt} = PhoenixKit.Modules.AI.create_prompt(%{ - name: "Email Writer", - slug: "email-writer", - content: "Write a professional email about {{Topic}} to {{Recipient}}.", - description: "Generates professional emails", - enabled: true -}) -``` - -### Using Prompts with AI Calls - -```elixir -# Simple: render prompt and make AI call -{:ok, response} = PhoenixKit.Modules.AI.ask_with_prompt( - endpoint_id, - "email-writer", # Can use ID, slug, or Prompt struct - %{"Topic" => "project update", "Recipient" => "the team"} -) - -# Advanced: use prompt as system message with user input -{:ok, response} = PhoenixKit.Modules.AI.complete_with_system_prompt( - endpoint_id, - "email-writer", - %{"Topic" => "Q4 results", "Recipient" => "stakeholders"}, - "Make it concise and include key metrics.", - temperature: 0.7 -) -``` - -### Variable Management - -```elixir -# Get variables from a prompt -{:ok, variables} = PhoenixKit.Modules.AI.get_prompt_variables("email-writer") -# => ["Topic", "Recipient"] - -# Preview rendered prompt -{:ok, rendered} = PhoenixKit.Modules.AI.preview_prompt("email-writer", %{ - "Topic" => "meeting notes", - "Recipient" => "the manager" -}) -# => "Write a professional email about meeting notes to the manager." - -# Validate variables before use -case PhoenixKit.Modules.AI.validate_prompt_variables("email-writer", %{"Topic" => "test"}) do - :ok -> # All required variables provided - {:error, missing} -> # Handle missing: ["Recipient"] -end -``` - -### Prompt Discovery - -```elixir -# Search prompts by name or content -prompts = PhoenixKit.Modules.AI.search_prompts("email", enabled_only: true) - -# Find prompts using a specific variable -prompts = PhoenixKit.Modules.AI.get_prompts_with_variable("Recipient") - -# Validate prompt content syntax -:ok = PhoenixKit.Modules.AI.validate_prompt_content("Hello {{Name}}") -``` - -### Prompt Management - -```elixir -# List all prompts -prompts = PhoenixKit.Modules.AI.list_prompts() - -# List enabled prompts only -prompts = PhoenixKit.Modules.AI.list_enabled_prompts() - -# Get by UUID or slug -prompt = PhoenixKit.Modules.AI.get_prompt!("660e8400-e29b-41d4-a716-446655440001") -prompt = PhoenixKit.Modules.AI.get_prompt_by_slug("email-writer") - -# Enable/disable -{:ok, prompt} = PhoenixKit.Modules.AI.enable_prompt(prompt_uuid) -{:ok, prompt} = PhoenixKit.Modules.AI.disable_prompt(prompt_uuid) - -# Duplicate a prompt -{:ok, new_prompt} = PhoenixKit.Modules.AI.duplicate_prompt(prompt_uuid, "Email Writer v2") - -# Delete -{:ok, _} = PhoenixKit.Modules.AI.delete_prompt(prompt) -``` - -### Usage Statistics - -```elixir -# Get usage stats for all prompts -stats = PhoenixKit.Modules.AI.get_prompt_usage_stats() -# => [%{prompt: %{uuid: "660e8400-...", name: "Email Writer", ...}, usage_count: 150, ...}, ...] - -# Reset usage counter -{:ok, prompt} = PhoenixKit.Modules.AI.reset_prompt_usage(prompt_uuid) -``` - -### Prompt Schema - -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | Display name (required) | -| `slug` | string | URL-friendly identifier (auto-generated) | -| `content` | text | Prompt template with `{{Variables}}` | -| `description` | string | Optional description | -| `enabled` | boolean | Whether prompt is active | -| `usage_count` | integer | Number of times used | -| `sort_order` | integer | Display order | - -## Endpoint Schema - -Each endpoint contains: - -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | Display name (required) | -| `description` | string | Optional description | -| `provider` | string | Provider type ("openrouter") | -| `api_key` | string | Provider API key (required) | -| `base_url` | string | Custom API base URL | -| `provider_settings` | map | Provider-specific settings | -| `model` | string | Model identifier (required) | -| `temperature` | float | Sampling temperature (0-2) | -| `max_tokens` | integer | Maximum tokens to generate | -| `top_p` | float | Nucleus sampling (0-1) | -| `top_k` | integer | Top-k sampling | -| `frequency_penalty` | float | Frequency penalty (-2 to 2) | -| `presence_penalty` | float | Presence penalty (-2 to 2) | -| `repetition_penalty` | float | Repetition penalty (0-2) | -| `stop` | array | Stop sequences | -| `seed` | integer | Random seed for reproducibility | -| `image_size` | string | Image generation size | -| `image_quality` | string | Image generation quality | -| `dimensions` | integer | Embeddings dimensions | -| `enabled` | boolean | Whether endpoint is active | -| `sort_order` | integer | Display order | -| `last_validated_at` | datetime | Last API key validation time | - -## Configuration via Admin UI - -### Creating an Endpoint - -1. Navigate to `/{prefix}/admin/ai/endpoints` -2. Click "New Endpoint" -3. Enter endpoint details: - - Name and optional description - - OpenRouter API key (must start with `sk-or-v1-`) - - Select a model from the dropdown - - Adjust parameters as needed -4. Click "Create Endpoint" - -> **Note**: Get your API key from https://openrouter.ai/keys - -### Sorting Endpoints - -The endpoints list supports sorting by: -- **ID** – Endpoint ID (default) -- **Name** – Alphabetical -- **Status** – Enabled/Disabled -- **Model** – Model name -- **Requests** – Total request count -- **Tokens** – Total tokens used -- **Cost** – Total cost -- **Last Used** – Most recent request time - -Sort parameters are preserved in the URL for bookmarking. - -## Usage Tracking - -All requests are automatically logged to `phoenix_kit_ai_requests`. - -### Dashboard Statistics - -```elixir -# Get dashboard stats (today, last 30 days, all time) -stats = PhoenixKit.Modules.AI.get_dashboard_stats() -# => %{ -# today: %{total_requests: 50, total_tokens: 25000, ...}, -# last_30_days: %{...}, -# all_time: %{total_requests: 1234, success_rate: 98.5, ...} -# } -``` - -### Endpoint Usage Statistics - -```elixir -# Get usage stats per endpoint (keyed by UUID) -stats = PhoenixKit.Modules.AI.get_endpoint_usage_stats() -# => %{ -# "018f1234-..." => %{request_count: 100, total_tokens: 50000, total_cost: 150000, last_used_at: ~U[...]}, -# "018f5678-..." => %{...} -# } - -# Access stats for an endpoint -endpoint_stats = Map.get(stats, endpoint.uuid, %{request_count: 0}) -``` - -### Request History - -```elixir -# List requests with pagination -{requests, total} = PhoenixKit.Modules.AI.list_requests(page: 1, page_size: 20) - -# With filters -{requests, total} = PhoenixKit.Modules.AI.list_requests( - endpoint_uuid: endpoint.uuid, - model: "anthropic/claude-3-haiku", - status: "success", - source: "MyApp.ContentGenerator" -) - -# With sorting -{requests, total} = PhoenixKit.Modules.AI.list_requests( - sort_by: :cost_cents, - sort_dir: :desc -) - -# Get filter options (for UI dropdowns) -options = PhoenixKit.Modules.AI.get_request_filter_options() -# => %{endpoints: [{"018f1234-...", "Claude Fast"}, {"018f5678-...", "GPT-4"}], models: [...], statuses: [...], sources: [...]} -``` - -## Response Structure - -### Chat Completion Response - -```elixir -%{ - "id" => "gen-...", - "model" => "anthropic/claude-3-haiku", - "choices" => [ - %{ - "message" => %{ - "role" => "assistant", - "content" => "Hello! How can I help you today?" - }, - "finish_reason" => "stop" - } - ], - "usage" => %{ - "prompt_tokens" => 10, - "completion_tokens" => 15, - "total_tokens" => 25, - "cost" => 0.00003 # Cost in dollars from OpenRouter - }, - "latency_ms" => 850 -} -``` - -### Embeddings Response - -```elixir -%{ - "data" => [ - %{ - "embedding" => [0.123, -0.456, ...], - "index" => 0 - } - ], - "usage" => %{ - "prompt_tokens" => 5, - "total_tokens" => 5 - }, - "latency_ms" => 120 -} -``` - -## Error Handling - -All functions return `{:ok, result}` or `{:error, reason}`: - -```elixir -case PhoenixKit.Modules.AI.ask(endpoint.uuid, "Hello") do - {:ok, response} -> - {:ok, text} = PhoenixKit.Modules.AI.extract_content(response) - IO.puts(text) - - {:error, "Endpoint not found"} -> - IO.puts("Endpoint doesn't exist") - - {:error, "Endpoint is disabled"} -> - IO.puts("Enable the endpoint first") - - {:error, "Invalid API key"} -> - IO.puts("Check your OpenRouter API key") - - {:error, "Rate limited"} -> - Process.sleep(1000) - # Retry... - - {:error, reason} -> - IO.puts("Error: #{reason}") -end -``` - -### Common Error Messages - -| Error | Cause | Solution | -|-------|-------|----------| -| `"Endpoint not found"` | Invalid endpoint ID | Check endpoint exists | -| `"Endpoint is disabled"` | Endpoint not active | Enable endpoint in settings | -| `"Invalid API key"` | API key rejected | Update API key | -| `"Insufficient credits"` | OpenRouter balance empty | Add credits to OpenRouter | -| `"Rate limited"` | Too many requests | Implement backoff/retry | -| `"Request timeout"` | Slow response | Use faster model | - -## LiveView Interfaces - -> **Note**: The AI module must be enabled before accessing admin pages. Enable via Admin UI at `/{prefix}/admin/modules` or programmatically with `PhoenixKit.Modules.AI.enable_system()`. - -### Endpoints Page (`/{prefix}/admin/ai/endpoints`) - -- List all endpoints with usage statistics -- Sort by ID, name, status, usage, cost, last used -- Quick actions: edit, enable/disable, delete -- Each card shows: model, temperature, request count, tokens, cost - -### Prompts Page (`/{prefix}/admin/ai/prompts`) - -- List all prompt templates with usage counts -- Create, edit, duplicate, and delete prompts -- Variable preview with live substitution -- Enable/disable prompts -- Drag-and-drop reordering - -### Usage Page (`/{prefix}/admin/ai/usage`) - -- Dashboard statistics (today, 30 days, all time) -- Recent requests table with filtering and sorting -- Filter by endpoint, model, status, source (filters only appear when there are 2+ options) -- Sort by time, endpoint, model, tokens, latency, cost, status -- Request details modal with full request/response JSON, source, and debug info -- Responsive table design (columns adapt to screen size) - -### Endpoint Form (`/{prefix}/admin/ai/endpoints/new` or `.../edit`) - -- Name and description -- API key configuration -- Model selection from dropdown -- Parameter configuration (temperature, max_tokens, etc.) - -## Cost Tracking - -Costs are tracked in **nanodollars** (1/1,000,000 of a dollar) for precision with cheap API calls. - -```elixir -# In database: cost_cents stores nanodollars -# Example: $0.00003 = 30 nanodollars - -# Format for display -PhoenixKit.Modules.AI.Request.format_cost(30) -# => "$0.000030" - -PhoenixKit.Modules.AI.Request.format_cost(1_500_000) -# => "$1.50" -``` - -## Supported Models - -OpenRouter provides access to models from: - -- **Anthropic** – Claude 3.5 Sonnet, Claude 3 Opus/Sonnet/Haiku -- **OpenAI** – GPT-4o, GPT-4 Turbo, GPT-3.5 Turbo -- **Google** – Gemini Pro, Gemini Flash -- **Meta** – Llama 3.1, Llama 3 -- **Mistral** – Mistral Large, Mixtral -- **And many more** – DeepSeek, Qwen, Cohere, etc. - -Models are dynamically fetched from OpenRouter's API. - -## Troubleshooting - -### Models Not Loading - -1. Check API key is valid in endpoint settings -2. Verify account has credits on OpenRouter -3. Check browser console for network errors -4. Try refreshing the page - -### Slow Responses - -1. Use a faster model (e.g., Haiku instead of Opus) -2. Reduce `max_tokens` parameter -3. Check OpenRouter status page for outages - -### High Costs - -1. Monitor usage in the Usage tab -2. Use cheaper models for simple tasks -3. Reduce `max_tokens` to limit output length -4. Implement caching for repeated queries - -## Getting Help - -1. Check this README for API documentation -2. Review OpenRouter docs: https://openrouter.ai/docs -3. Enable debug logging: `Logger.configure(level: :debug)` -4. Check request logs in `phoenix_kit_ai_requests` table - -## Future Plans - -### Usage Charts - -We plan to add interactive charts to the Usage page showing: - -- **Requests Over Time** – Line/area chart of daily request volume (30 days) -- **Tokens by Model** – Donut/pie chart showing token distribution across models -- **Cost Trends** – Cost breakdown over time - -**Implementation Notes:** - -Since PhoenixKit is a library dependency, charts must be self-contained without requiring parent app changes. Two approaches were evaluated: - -1. **Server-side SVG (Contex)** – Pure Elixir charting library that generates SVG. No JavaScript required. Works but adds a dependency and has limited interactivity. - -2. **Client-side (ApexCharts)** – Modern JavaScript charting with rich interactivity (tooltips, click events, animations). Challenges: - - LiveView strips ` - - - diff --git a/lib/modules/ai/web/prompt_form.ex b/lib/modules/ai/web/prompt_form.ex deleted file mode 100644 index 3d8cba154..000000000 --- a/lib/modules/ai/web/prompt_form.ex +++ /dev/null @@ -1,130 +0,0 @@ -defmodule PhoenixKit.Modules.AI.Web.PromptForm do - @moduledoc """ - LiveView for creating and editing AI prompts. - - A prompt is a reusable text template with variable substitution support. - Variables use the `{{VariableName}}` syntax. - """ - - use PhoenixKitWeb, :live_view - - alias PhoenixKit.Modules.AI - alias PhoenixKit.Modules.AI.Prompt - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - # =========================================== - # LIFECYCLE - # =========================================== - - @impl true - def mount(params, _session, socket) do - if AI.enabled?() do - project_title = Settings.get_project_title() - - socket = - socket - |> assign(:project_title, project_title) - |> assign(:current_path, Routes.path("/admin/ai")) - |> assign(:extracted_variables, []) - |> load_prompt(params["id"]) - - {:ok, socket} - else - {:ok, - socket - |> put_flash(:error, "AI module is not enabled") - |> push_navigate(to: Routes.path("/admin/modules"))} - end - end - - defp load_prompt(socket, nil) do - changeset = AI.change_prompt(%Prompt{}) - - socket - |> assign(:page_title, "New AI Prompt") - |> assign(:prompt, nil) - |> assign(:form, to_form(changeset)) - end - - defp load_prompt(socket, id) do - case AI.get_prompt(id) do - nil -> - socket - |> put_flash(:error, "Prompt not found") - |> push_navigate(to: Routes.ai_path() <> "/prompts") - - prompt -> - changeset = AI.change_prompt(prompt) - - socket - |> assign(:page_title, "Edit AI Prompt") - |> assign(:prompt, prompt) - |> assign(:form, to_form(changeset)) - |> assign(:extracted_variables, prompt.variables || []) - end - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket} - end - - # =========================================== - # EVENT HANDLERS - # =========================================== - - @impl true - def handle_event("validate", %{"prompt" => params}, socket) do - changeset = - (socket.assigns.prompt || %Prompt{}) - |> AI.change_prompt(params) - - # Extract variables from content for preview - content = params["content"] || "" - extracted_variables = Prompt.extract_variables(content) - - socket = - socket - |> assign(:form, to_form(changeset)) - |> assign(:extracted_variables, extracted_variables) - - {:noreply, socket} - end - - @impl true - def handle_event("save", %{"prompt" => params}, socket) do - save_prompt(socket, params) - end - - # =========================================== - # PRIVATE HELPERS - # =========================================== - - defp save_prompt(socket, params) do - result = - if socket.assigns.prompt do - AI.update_prompt(socket.assigns.prompt, params) - else - AI.create_prompt(params) - end - - case result do - {:ok, _prompt} -> - action = if socket.assigns.prompt, do: "updated", else: "created" - - {:noreply, - socket - |> put_flash(:info, "Prompt #{action} successfully") - |> push_navigate(to: Routes.ai_path() <> "/prompts")} - - {:error, changeset} -> - {:noreply, assign(socket, :form, to_form(changeset))} - end - rescue - e -> - require Logger - Logger.error("Prompt save failed: #{Exception.message(e)}") - {:noreply, put_flash(socket, :error, gettext("Something went wrong. Please try again."))} - end -end diff --git a/lib/modules/ai/web/prompt_form.html.heex b/lib/modules/ai/web/prompt_form.html.heex deleted file mode 100644 index 57eba2a94..000000000 --- a/lib/modules/ai/web/prompt_form.html.heex +++ /dev/null @@ -1,217 +0,0 @@ - -
- <.admin_page_header back={PhoenixKit.Utils.Routes.ai_path() <> "/prompts"}> -

{@page_title}

-

- Create reusable prompts with variable substitution -

- - - <%!-- Form Content (constrained width) --%> -
- <%!-- Form --%> -
-
- <.form for={@form} phx-change="validate" phx-submit="save" class="space-y-5"> - <%!-- Name --%> -
- - - <%= if @form.errors[:name] do %> -

{elem(@form.errors[:name], 0)}

- <% end %> -
- - <%!-- Slug --%> -
- - -
- - <%!-- Description --%> -
- - -
- - <%!-- System Prompt --%> -
- - -

- Sent as the system message before the user prompt. Supports {"{{variables}}"} too. -

- <%= if @form.errors[:system_prompt] do %> -

{elem(@form.errors[:system_prompt], 0)}

- <% end %> -
- - <%!-- Content --%> -
- - - <%= if @form.errors[:content] do %> -

{elem(@form.errors[:content], 0)}

- <% end %> -
- - <%!-- Extracted Variables Preview --%> - <%= if length(@extracted_variables) > 0 do %> -
- <.icon name="hero-variable" class="w-5 h-5" /> -
-
Detected Variables
-
- <%= for var <- @extracted_variables do %> - - {"{{#{var}}}"} - - <% end %> -
-
-
- <% end %> - - <%!-- Enabled Toggle --%> -
- -

- Disabled prompts cannot be used via the API -

-
- - <%!-- Actions --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts"} - class="btn btn-ghost" - > - Cancel - - -
- -
-
- - <%!-- Help Section --%> -
-
-

- <.icon name="hero-question-mark-circle" class="w-5 h-5" /> Variable Syntax -

-
-
-

Use double curly braces for variables:

-
- Translate to {"{{Language}}"}:
- {"{{Text}}"} -
-
- -
-

Naming rules:

-
    -
  • - Use letters, numbers, and underscores only — - no spaces -
  • -
  • - {"{{UserLanguage}}"} - or {"{{user_language}}"} - — both work -
  • -
  • - {"{{User Language}}"} - — won't be detected as a variable -
  • -
-
- -
-

Missing variables:

-

- If a variable isn't provided when the prompt is used, it stays as-is in the output - (e.g., {"{{Language}}"} - remains unchanged). -

-
-
-
-
-
-
-
diff --git a/lib/modules/ai/web/prompts.ex b/lib/modules/ai/web/prompts.ex deleted file mode 100644 index 59626c07a..000000000 --- a/lib/modules/ai/web/prompts.ex +++ /dev/null @@ -1,242 +0,0 @@ -defmodule PhoenixKit.Modules.AI.Web.Prompts do - @moduledoc """ - LiveView for AI prompts management. - - This module provides an interface for managing reusable AI prompt templates - with variable substitution support. - - ## Features - - - **Prompt Management**: Add, edit, delete, enable/disable AI prompts - - **Variable Display**: Shows extracted variables from prompt content - - **Usage Tracking**: View usage count and last used time - - ## Route - - This LiveView is mounted at `{prefix}/admin/ai/prompts` and requires - appropriate admin permissions. - """ - - use PhoenixKitWeb, :live_view - use Gettext, backend: PhoenixKitWeb.Gettext - - alias PhoenixKit.Modules.AI - alias PhoenixKit.Settings - alias PhoenixKit.Utils.Routes - - @sort_options [ - {:sort_order, "Order"}, - {:name, "Name"}, - {:usage_count, "Usage"}, - {:last_used_at, "Last Used"}, - {:inserted_at, "Created"} - ] - - @page_size 20 - - @impl true - def mount(_params, session, socket) do - current_path = get_current_path(socket, session) - project_title = Settings.get_project_title() - - # Subscribe to real-time updates - if connected?(socket) do - AI.subscribe_prompts() - end - - socket = - socket - |> assign(:current_path, current_path) - |> assign(:page_title, "AI Prompts") - |> assign(:project_title, project_title) - |> assign(:prompts, []) - |> assign(:sort_by, :sort_order) - |> assign(:sort_dir, :asc) - |> assign(:sort_options, @sort_options) - |> assign(:page, 1) - |> assign(:page_size, @page_size) - |> assign(:total_prompts, 0) - - {:ok, socket} - end - - @impl true - def handle_params(params, uri, socket) do - {sort_by, sort_dir, page} = parse_sort_params(params) - current_path = URI.parse(uri).path - - socket = - socket - |> assign(:sort_by, sort_by) - |> assign(:sort_dir, sort_dir) - |> assign(:page, page) - |> assign(:current_path, current_path) - |> reload_prompts() - - {:noreply, socket} - end - - @valid_sort_fields Enum.map(@sort_options, fn {field, _} -> Atom.to_string(field) end) - - defp parse_sort_params(params) do - { - parse_sort_field(params["sort"], @valid_sort_fields, :sort_order), - parse_sort_dir(params["dir"]), - parse_page(params["page"]) - } - end - - defp parse_sort_field(field, valid_fields, default) when is_binary(field) do - if field in valid_fields, do: String.to_existing_atom(field), else: default - end - - defp parse_sort_field(_, _valid_fields, default), do: default - - defp parse_sort_dir("asc"), do: :asc - defp parse_sort_dir("desc"), do: :desc - defp parse_sort_dir(_), do: :asc - - defp parse_page(nil), do: 1 - defp parse_page(""), do: 1 - - defp parse_page(p) when is_binary(p) do - case Integer.parse(p) do - {n, ""} when n > 0 -> n - _ -> 1 - end - end - - defp parse_page(_), do: 1 - - # =========================================== - # PROMPT ACTIONS - # =========================================== - - @impl true - def handle_event("toggle_prompt", %{"uuid" => uuid}, socket) do - prompt = AI.get_prompt!(uuid) - - case AI.update_prompt(prompt, %{enabled: !prompt.enabled}) do - {:ok, _updated} -> - {:noreply, - socket - |> reload_prompts() - |> put_flash(:info, "Prompt #{if prompt.enabled, do: "disabled", else: "enabled"}")} - - {:error, _changeset} -> - {:noreply, put_flash(socket, :error, "Failed to update prompt")} - end - end - - @impl true - def handle_event("delete_prompt", %{"uuid" => uuid}, socket) do - prompt = AI.get_prompt!(uuid) - - case AI.delete_prompt(prompt) do - {:ok, _} -> - {:noreply, - socket - |> reload_prompts() - |> put_flash(:info, "Prompt deleted")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to delete prompt")} - end - end - - @impl true - def handle_event("sort", %{"by" => field}, socket) do - # Validate field before converting to atom to prevent crashes from malicious input - field = - if field in @valid_sort_fields do - String.to_existing_atom(field) - else - :sort_order - end - - current_sort_by = socket.assigns.sort_by - current_sort_dir = socket.assigns.sort_dir - - # Toggle direction if same field, otherwise default to desc for usage/last_used, asc for others - sort_dir = - if field == current_sort_by do - if current_sort_dir == :asc, do: :desc, else: :asc - else - if field in [:usage_count, :last_used_at, :inserted_at], do: :desc, else: :asc - end - - # Reset to page 1 when sorting changes - path = Routes.ai_path() <> "/prompts?sort=#{field}&dir=#{sort_dir}" - {:noreply, push_patch(socket, to: path)} - end - - @impl true - def handle_event("goto_page", %{"page" => page_str}, socket) do - case Integer.parse(page_str) do - {page, ""} when page > 0 -> - sort_by = socket.assigns.sort_by - sort_dir = socket.assigns.sort_dir - - path = build_prompts_url(sort_by, sort_dir, page) - {:noreply, push_patch(socket, to: path)} - - _ -> - {:noreply, socket} - end - end - - # =========================================== - # PUBSUB HANDLERS - Real-time updates - # =========================================== - - @impl true - def handle_info({event, _prompt}, socket) - when event in [:prompt_created, :prompt_updated, :prompt_deleted] do - # Reload prompts list when any prompt changes - {:noreply, reload_prompts(socket)} - end - - # Catch-all for other PubSub messages - @impl true - def handle_info(_msg, socket), do: {:noreply, socket} - - # =========================================== - # PRIVATE HELPERS - # =========================================== - - defp reload_prompts(socket) do - sort_by = socket.assigns.sort_by - sort_dir = socket.assigns.sort_dir - page = socket.assigns.page - page_size = socket.assigns.page_size - - {prompts, total} = - AI.list_prompts( - sort_by: sort_by, - sort_dir: sort_dir, - page: page, - page_size: page_size - ) - - socket - |> assign(:prompts, prompts) - |> assign(:total_prompts, total) - end - - defp build_prompts_url(sort_by, sort_dir, page) do - base = Routes.ai_path() <> "/prompts?sort=#{sort_by}&dir=#{sort_dir}" - - if page > 1 do - base <> "&page=#{page}" - else - base - end - end - - defp get_current_path(socket, session) do - case socket.assigns do - %{__changed__: _, current_path: path} when is_binary(path) -> path - _ -> session["current_path"] || Routes.ai_path() <> "/prompts" - end - end -end diff --git a/lib/modules/ai/web/prompts.html.heex b/lib/modules/ai/web/prompts.html.heex deleted file mode 100644 index 56547e5ce..000000000 --- a/lib/modules/ai/web/prompts.html.heex +++ /dev/null @@ -1,222 +0,0 @@ - -
- <.admin_page_header - back={PhoenixKit.Utils.Routes.path("/admin")} - title="AI Prompts" - subtitle="Reusable prompt templates with variable substitution" - /> - - <%!-- Controls --%> -
-
- <%!-- Tabs --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/endpoints"} - class="tab" - > - <.icon name="hero-server-stack" class="w-4 h-4 mr-2" /> Endpoints - - <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts"} - class="tab tab-active" - > - <.icon name="hero-document-text" class="w-4 h-4 mr-2" /> Prompts - - <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/playground"} - class="tab" - > - <.icon name="hero-beaker" class="w-4 h-4 mr-2" /> Playground - - <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/usage"} - class="tab" - > - <.icon name="hero-chart-bar" class="w-4 h-4 mr-2" /> Usage - -
- - <%!-- Quick Actions --%> - <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts/new"} - class="btn btn-primary" - > - <.icon name="hero-plus" class="w-5 h-5 mr-2" /> New Prompt - -
-
- - <%!-- Content --%> - <%= if Enum.empty?(@prompts) do %> - <%!-- Empty State --%> -
-
- <.icon name="hero-document-text" class="w-16 h-16 text-base-content/30" /> -

No Prompts Yet

-

- Create reusable prompt templates with variable substitution. - Use {"{{VariableName}}"} - syntax for dynamic content. -

- <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts/new"} - class="btn btn-primary mt-4" - > - <.icon name="hero-plus" class="w-5 h-5 mr-2" /> Create First Prompt - -
-
- <% else %> - <%!-- Sort Controls --%> -
- Sort by: - <%= for {field, label} <- @sort_options do %> - - <% end %> -
- - <%!-- Prompts Grid --%> -
- <%= for prompt <- @prompts do %> -
-
-
-
-
- <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts/#{prompt.uuid}/edit"} - class="font-semibold text-lg hover:text-primary hover:underline" - > - {prompt.name} - - <.enabled_badge enabled={prompt.enabled} /> - {prompt.slug} -
- - <%!-- Variables --%> - <%= if prompt.variables && length(prompt.variables) > 0 do %> -
- Variables: - <%= for var <- prompt.variables do %> - - {"{{#{var}}}"} - - <% end %> -
- <% end %> - - <%!-- Content Preview --%> -
- {PhoenixKit.Modules.AI.Prompt.content_preview(prompt.content)} -
- - <%!-- Usage Stats --%> -
-
- <.icon name="hero-arrow-path" class="w-4 h-4" /> - {prompt.usage_count} uses -
-
- <.icon name="hero-clock" class="w-4 h-4" /> - - <%= if prompt.last_used_at do %> - <.time_ago datetime={prompt.last_used_at} /> - <% else %> - Never used - <% end %> - -
-
- <.icon name="hero-calendar" class="w-4 h-4" /> - - <.time_ago datetime={prompt.inserted_at} /> - -
-
- - <%= if prompt.description do %> -

{prompt.description}

- <% end %> -
- - <%!-- Actions --%> -
- <.link - navigate={PhoenixKit.Utils.Routes.ai_path() <> "/prompts/#{prompt.uuid}/edit"} - class="btn btn-xs btn-outline btn-info tooltip tooltip-bottom" - data-tip={gettext("Edit")} - > - <.icon name="hero-pencil" class="w-4 h-4 hidden sm:inline" /> - {gettext("Edit")} - - - -
-
-
-
- <% end %> -
- - <%!-- Pagination --%> - <% total_pages = ceil(@total_prompts / @page_size) %> - <%= if total_pages > 1 do %> -
-
- <%= for page_num <- 1..total_pages do %> - - <% end %> -
-
- <% end %> - <% end %> -
-
diff --git a/lib/phoenix_kit/module_registry.ex b/lib/phoenix_kit/module_registry.ex index c8aa67e5f..3593dc2e1 100644 --- a/lib/phoenix_kit/module_registry.ex +++ b/lib/phoenix_kit/module_registry.ex @@ -401,7 +401,6 @@ defmodule PhoenixKit.ModuleRegistry do # remove it from this list and add it to :modules config instead. defp internal_modules do [ - PhoenixKit.Modules.AI, PhoenixKit.Modules.Billing, PhoenixKit.Modules.Comments, PhoenixKit.Modules.Connections, @@ -483,6 +482,16 @@ defmodule PhoenixKit.ModuleRegistry do "Custom data entities with fields, forms, multilingual support, and data navigation.", icon: "🧩", hex_url: "https://hex.pm/packages/phoenix_kit_entities" + }, + %{ + module: PhoenixKitAI, + key: "ai", + hex_package: "phoenix_kit_ai", + name: "AI", + description: + "AI endpoint management, prompt templates, completions via OpenRouter, and usage tracking.", + icon: "🤖", + hex_url: "https://hex.pm/packages/phoenix_kit_ai" } ] end diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index 420c77623..24834937c 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -624,31 +624,6 @@ defmodule PhoenixKitWeb.Integration do live "/admin/shop/test", PhoenixKit.Modules.Shop.Web.TestShop, :index, as: :shop_test - # AI module routes - live "/admin/ai", PhoenixKit.Modules.AI.Web.Endpoints, :index, as: :ai_index - - live "/admin/ai/endpoints", PhoenixKit.Modules.AI.Web.Endpoints, :endpoints, - as: :ai_endpoints - - live "/admin/ai/usage", PhoenixKit.Modules.AI.Web.Endpoints, :usage, as: :ai_usage - - live "/admin/ai/endpoints/new", PhoenixKit.Modules.AI.Web.EndpointForm, :new, - as: :ai_endpoint_new - - live "/admin/ai/endpoints/:id/edit", PhoenixKit.Modules.AI.Web.EndpointForm, :edit, - as: :ai_endpoint_edit - - live "/admin/ai/prompts", PhoenixKit.Modules.AI.Web.Prompts, :index, as: :ai_prompts - - live "/admin/ai/prompts/new", PhoenixKit.Modules.AI.Web.PromptForm, :new, - as: :ai_prompt_new - - live "/admin/ai/prompts/:id/edit", PhoenixKit.Modules.AI.Web.PromptForm, :edit, - as: :ai_prompt_edit - - live "/admin/ai/playground", PhoenixKit.Modules.AI.Web.Playground, :index, - as: :ai_playground - # Routes from external route modules unquote(tickets_admin) unquote(referrals_admin) diff --git a/test/modules/ai/prompt_test.exs b/test/modules/ai/prompt_test.exs deleted file mode 100644 index df2a5e1df..000000000 --- a/test/modules/ai/prompt_test.exs +++ /dev/null @@ -1,298 +0,0 @@ -defmodule PhoenixKit.Modules.AI.PromptTest do - use ExUnit.Case, async: true - - alias PhoenixKit.Modules.AI.Prompt - - # ============================================================================ - # extract_variables/1 - # ============================================================================ - - describe "extract_variables/1" do - test "extracts single variable" do - assert Prompt.extract_variables("Hello {{Name}}!") == ["Name"] - end - - test "extracts multiple variables" do - assert Prompt.extract_variables("{{A}} and {{B}}") == ["A", "B"] - end - - test "deduplicates variables" do - assert Prompt.extract_variables("{{A}} and {{A}}") == ["A"] - end - - test "returns empty list for no variables" do - assert Prompt.extract_variables("No variables here") == [] - end - - test "returns empty list for nil" do - assert Prompt.extract_variables(nil) == [] - end - - test "handles underscores in variable names" do - assert Prompt.extract_variables("{{user_name}}") == ["user_name"] - end - - test "handles numbers in variable names" do - assert Prompt.extract_variables("{{item1}}") == ["item1"] - end - - test "ignores invalid variable syntax with spaces" do - assert Prompt.extract_variables("{{User Name}}") == [] - end - end - - # ============================================================================ - # render/2 - # ============================================================================ - - describe "render/2" do - test "replaces variables with string key values" do - prompt = %Prompt{content: "Hello {{Name}}!"} - assert Prompt.render(prompt, %{"Name" => "World"}) == {:ok, "Hello World!"} - end - - test "replaces variables with atom key values" do - prompt = %Prompt{content: "Hello {{Name}}!"} - assert Prompt.render(prompt, %{Name: "World"}) == {:ok, "Hello World!"} - end - - test "leaves unmatched variables as-is" do - prompt = %Prompt{content: "Hello {{Name}}!"} - assert Prompt.render(prompt, %{}) == {:ok, "Hello {{Name}}!"} - end - - test "replaces multiple variables" do - prompt = %Prompt{content: "{{A}} + {{B}} = result"} - assert Prompt.render(prompt, %{"A" => "1", "B" => "2"}) == {:ok, "1 + 2 = result"} - end - - test "handles content without variables" do - prompt = %Prompt{content: "No variables here"} - assert Prompt.render(prompt, %{}) == {:ok, "No variables here"} - end - - test "returns content when variables is not a map" do - prompt = %Prompt{content: "Hello {{Name}}!"} - assert Prompt.render(prompt, nil) == {:ok, "Hello {{Name}}!"} - end - end - - # ============================================================================ - # render_system_prompt/2 - # ============================================================================ - - describe "render_system_prompt/2" do - test "returns {:ok, nil} when system_prompt is nil" do - prompt = %Prompt{system_prompt: nil, content: "test"} - assert Prompt.render_system_prompt(prompt, %{}) == {:ok, nil} - end - - test "returns {:ok, nil} when system_prompt is empty string" do - prompt = %Prompt{system_prompt: "", content: "test"} - assert Prompt.render_system_prompt(prompt, %{}) == {:ok, nil} - end - - test "renders system prompt with variables" do - prompt = %Prompt{system_prompt: "You are a {{Role}}", content: "test"} - - assert Prompt.render_system_prompt(prompt, %{"Role" => "translator"}) == - {:ok, "You are a translator"} - end - - test "renders system prompt with atom key variables" do - prompt = %Prompt{system_prompt: "Speak {{Language}}", content: "test"} - - assert Prompt.render_system_prompt(prompt, %{Language: "French"}) == - {:ok, "Speak French"} - end - - test "leaves unmatched variables in system prompt" do - prompt = %Prompt{system_prompt: "You are a {{Role}}", content: "test"} - assert Prompt.render_system_prompt(prompt, %{}) == {:ok, "You are a {{Role}}"} - end - - test "renders system prompt without variables" do - prompt = %Prompt{system_prompt: "You are helpful", content: "test"} - assert Prompt.render_system_prompt(prompt, %{}) == {:ok, "You are helpful"} - end - - test "renders system prompt when variables is not a map" do - prompt = %Prompt{system_prompt: "You are a {{Role}}", content: "test"} - assert Prompt.render_system_prompt(prompt, nil) == {:ok, "You are a {{Role}}"} - end - end - - # ============================================================================ - # changeset/2 - variable extraction from both fields - # ============================================================================ - - describe "changeset/2 variable extraction" do - test "extracts variables from content only" do - changeset = Prompt.changeset(%Prompt{}, %{name: "Test", content: "Hello {{Name}}"}) - assert Ecto.Changeset.get_change(changeset, :variables) == ["Name"] - end - - test "extracts variables from system_prompt only" do - changeset = - Prompt.changeset(%Prompt{}, %{ - name: "Test", - content: "Hello", - system_prompt: "You are {{Role}}" - }) - - assert Ecto.Changeset.get_change(changeset, :variables) == ["Role"] - end - - test "extracts variables from both fields and deduplicates" do - changeset = - Prompt.changeset(%Prompt{}, %{ - name: "Test", - content: "Hello {{Name}}", - system_prompt: "You are {{Role}} speaking {{Name}}" - }) - - variables = Ecto.Changeset.get_change(changeset, :variables) - assert "Role" in variables - assert "Name" in variables - assert length(variables) == 2 - end - - test "extracts variables preserving order from system_prompt first" do - changeset = - Prompt.changeset(%Prompt{}, %{ - name: "Test", - content: "{{C}} and {{D}}", - system_prompt: "{{A}} and {{B}}" - }) - - assert Ecto.Changeset.get_change(changeset, :variables) == ["A", "B", "C", "D"] - end - - test "returns empty list when no variables in either field" do - changeset = - Prompt.changeset(%Prompt{}, %{ - name: "Test", - content: "No vars", - system_prompt: "Also no vars" - }) - - assert Ecto.Changeset.get_field(changeset, :variables) == [] - end - end - - # ============================================================================ - # validate_variables/2 - # ============================================================================ - - describe "validate_variables/2" do - test "returns :ok when all variables provided" do - prompt = %Prompt{variables: ["Name", "Age"]} - assert Prompt.validate_variables(prompt, %{"Name" => "John", "Age" => "30"}) == :ok - end - - test "returns error with missing variables" do - prompt = %Prompt{variables: ["Name", "Age"]} - assert Prompt.validate_variables(prompt, %{"Name" => "John"}) == {:error, ["Age"]} - end - - test "returns :ok for empty variables list" do - prompt = %Prompt{variables: []} - assert Prompt.validate_variables(prompt, %{}) == :ok - end - end - - # ============================================================================ - # has_variables?/1 - # ============================================================================ - - describe "has_variables?/1" do - test "returns true when variables exist" do - assert Prompt.has_variables?(%Prompt{variables: ["A"]}) - end - - test "returns false when variables empty" do - refute Prompt.has_variables?(%Prompt{variables: []}) - end - - test "returns false for non-prompt" do - refute Prompt.has_variables?(nil) - end - end - - # ============================================================================ - # valid_content?/1 - # ============================================================================ - - describe "valid_content?/1" do - test "returns true for valid variable syntax" do - assert Prompt.valid_content?("Hello {{Name}}!") - end - - test "returns true for no variables" do - assert Prompt.valid_content?("Hello world!") - end - - test "returns false for invalid variable with spaces" do - refute Prompt.valid_content?("Hello {{User Name}}!") - end - - test "returns false for nil" do - refute Prompt.valid_content?(nil) - end - end - - # ============================================================================ - # content_preview/1 - # ============================================================================ - - describe "content_preview/1" do - test "returns empty string for nil" do - assert Prompt.content_preview(nil) == "" - end - - test "returns short content as-is" do - assert Prompt.content_preview("Hello") == "Hello" - end - - test "truncates long content with ellipsis" do - long = String.duplicate("a", 150) - preview = Prompt.content_preview(long) - assert String.ends_with?(preview, "...") - assert String.length(preview) <= 103 - end - end - - # ============================================================================ - # generate_slug/1 - # ============================================================================ - - describe "generate_slug/1" do - test "generates slug from name" do - assert Prompt.generate_slug("My Cool Prompt!") == "my-cool-prompt" - end - - test "returns empty string for nil" do - assert Prompt.generate_slug(nil) == "" - end - - test "returns empty string for empty string" do - assert Prompt.generate_slug("") == "" - end - end - - # ============================================================================ - # format_variables_for_display/1 - # ============================================================================ - - describe "format_variables_for_display/1" do - test "formats variables with curly braces" do - prompt = %Prompt{variables: ["Name", "Age"]} - assert Prompt.format_variables_for_display(prompt) == "{{Name}}, {{Age}}" - end - - test "returns empty string for no variables" do - prompt = %Prompt{variables: []} - assert Prompt.format_variables_for_display(prompt) == "" - end - end -end diff --git a/test/phoenix_kit/module_discovery_test.exs b/test/phoenix_kit/module_discovery_test.exs index b7d9a9c11..b61234750 100644 --- a/test/phoenix_kit/module_discovery_test.exs +++ b/test/phoenix_kit/module_discovery_test.exs @@ -18,7 +18,6 @@ defmodule PhoenixKit.ModuleDiscoveryTest do test "does not include internal PhoenixKit modules" do modules = ModuleDiscovery.discover_external_modules() - refute PhoenixKit.Modules.AI in modules refute PhoenixKit.Modules.CustomerService in modules refute PhoenixKit.Modules.Billing in modules refute PhoenixKit.Jobs in modules diff --git a/test/phoenix_kit/module_registry_test.exs b/test/phoenix_kit/module_registry_test.exs index ae6563ee2..78a913331 100644 --- a/test/phoenix_kit/module_registry_test.exs +++ b/test/phoenix_kit/module_registry_test.exs @@ -3,7 +3,7 @@ defmodule PhoenixKit.ModuleRegistryTest do alias PhoenixKit.ModuleRegistry - # The registry is started in test_helper.exs with all 16 internal modules loaded. + # The registry is started in test_helper.exs with all 15 internal modules loaded. describe "all_modules/0" do test "returns a non-empty list" do @@ -12,9 +12,9 @@ defmodule PhoenixKit.ModuleRegistryTest do assert modules != [] end - test "contains all 16 internal modules" do + test "contains all 15 internal modules" do modules = ModuleRegistry.all_modules() - assert length(modules) >= 16 + assert length(modules) >= 15 end test "all entries are atoms" do @@ -25,7 +25,6 @@ defmodule PhoenixKit.ModuleRegistryTest do test "contains known internal modules" do modules = ModuleRegistry.all_modules() - assert PhoenixKit.Modules.AI in modules assert PhoenixKit.Modules.CustomerService in modules assert PhoenixKit.Modules.Billing in modules assert PhoenixKit.Jobs in modules @@ -95,7 +94,6 @@ defmodule PhoenixKit.ModuleRegistryTest do describe "get_by_key/1" do test "finds module by key string" do - assert ModuleRegistry.get_by_key("ai") == PhoenixKit.Modules.AI assert ModuleRegistry.get_by_key("customer_service") == PhoenixKit.Modules.CustomerService assert ModuleRegistry.get_by_key("billing") == PhoenixKit.Modules.Billing end @@ -142,7 +140,7 @@ defmodule PhoenixKit.ModuleRegistryTest do test "returns a list of permission metadata maps" do metadata = ModuleRegistry.all_permission_metadata() assert is_list(metadata) - assert length(metadata) >= 15 + assert length(metadata) >= 14 for meta <- metadata do assert is_map(meta) @@ -157,22 +155,20 @@ defmodule PhoenixKit.ModuleRegistryTest do keys = Enum.map(ModuleRegistry.all_permission_metadata(), & &1.key) assert "customer_service" in keys assert "billing" in keys - assert "ai" in keys assert "shop" in keys end end describe "all_feature_keys/0" do - test "returns sorted list of 15 feature keys" do + test "returns sorted list of 14 feature keys" do keys = ModuleRegistry.all_feature_keys() assert is_list(keys) - assert length(keys) == 15 + assert length(keys) == 14 assert keys == Enum.sort(keys) end test "contains expected keys" do keys = ModuleRegistry.all_feature_keys() - assert "ai" in keys assert "billing" in keys assert "shop" in keys assert "customer_service" in keys @@ -193,7 +189,7 @@ defmodule PhoenixKit.ModuleRegistryTest do test "returns a map of key => {module, :enabled?}" do checks = ModuleRegistry.feature_enabled_checks() assert is_map(checks) - assert map_size(checks) >= 15 + assert map_size(checks) >= 14 for {key, {mod, fun}} <- checks do assert is_binary(key) @@ -205,7 +201,6 @@ defmodule PhoenixKit.ModuleRegistryTest do test "maps known keys to correct modules" do checks = ModuleRegistry.feature_enabled_checks() assert checks["customer_service"] == {PhoenixKit.Modules.CustomerService, :enabled?} - assert checks["ai"] == {PhoenixKit.Modules.AI, :enabled?} assert checks["billing"] == {PhoenixKit.Modules.Billing, :enabled?} end end @@ -215,7 +210,6 @@ defmodule PhoenixKit.ModuleRegistryTest do labels = ModuleRegistry.permission_labels() assert is_map(labels) assert labels["customer_service"] == "Customer Service" - assert labels["ai"] == "AI" assert labels["shop"] == "E-Commerce" end end diff --git a/test/phoenix_kit/module_test.exs b/test/phoenix_kit/module_test.exs index c0793824e..02061e91e 100644 --- a/test/phoenix_kit/module_test.exs +++ b/test/phoenix_kit/module_test.exs @@ -4,7 +4,6 @@ defmodule PhoenixKit.ModuleTest do alias PhoenixKit.ModuleRegistry @all_internal_modules [ - PhoenixKit.Modules.AI, PhoenixKit.Modules.Billing, PhoenixKit.Modules.Comments, PhoenixKit.Modules.Connections, @@ -29,7 +28,7 @@ defmodule PhoenixKit.ModuleTest do :ok end - describe "all 16 modules implement PhoenixKit.Module behaviour" do + describe "all 15 modules implement PhoenixKit.Module behaviour" do test "all modules are loadable" do for mod <- @all_internal_modules do assert Code.ensure_loaded?(mod), "#{inspect(mod)} should be loadable" diff --git a/test/phoenix_kit/users/permissions_test.exs b/test/phoenix_kit/users/permissions_test.exs index 617026aa7..6e2b2d493 100644 --- a/test/phoenix_kit/users/permissions_test.exs +++ b/test/phoenix_kit/users/permissions_test.exs @@ -50,7 +50,6 @@ defmodule PhoenixKit.Users.PermissionsTest do assert is_list(keys) assert "billing" in keys assert "shop" in keys - assert "ai" in keys end test "does not include core keys" do @@ -67,8 +66,8 @@ defmodule PhoenixKit.Users.PermissionsTest do assert MapSet.new(all) == MapSet.new(expected) end - test "has 20 built-in keys" do - assert length(Permissions.all_module_keys()) == 20 + test "has 19 built-in keys" do + assert length(Permissions.all_module_keys()) == 19 end end @@ -102,7 +101,6 @@ defmodule PhoenixKit.Users.PermissionsTest do assert Permissions.module_label("dashboard") == "Dashboard" assert Permissions.module_label("users") == "Users" assert Permissions.module_label("shop") == "E-Commerce" - assert Permissions.module_label("ai") == "AI" assert Permissions.module_label("db") == "DB" end @@ -121,7 +119,6 @@ defmodule PhoenixKit.Users.PermissionsTest do test "returns correct icons for built-in keys" do assert Permissions.module_icon("dashboard") == "hero-home" assert Permissions.module_icon("users") == "hero-users" - assert Permissions.module_icon("ai") == "hero-sparkles" end test "returns default icon for unknown keys" do @@ -281,7 +278,7 @@ defmodule PhoenixKit.Users.PermissionsTest do assert Permissions.valid_module_key?("billing") end - test "returns true for all 20 built-in keys" do + test "returns true for all 19 built-in keys" do for key <- Permissions.core_section_keys() ++ Permissions.feature_module_keys() do assert Permissions.valid_module_key?(key), "Expected #{key} to be valid" end From cecd89b285d2702c218fc02b93c243babe6bb9bd Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 24 Mar 2026 08:46:02 +0200 Subject: [PATCH 5/7] Exclude external module namespaces from Credo alias usage check PhoenixKitEntities, PhoenixKitAI, PhoenixKitPosts, Multilang, and HtmlSanitizer are optional external modules that cannot be aliased at the top of invoking modules. Co-Authored-By: Claude Opus 4.6 (1M context) --- .credo.exs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.credo.exs b/.credo.exs index 57aa8acc2..15074d6b7 100644 --- a/.credo.exs +++ b/.credo.exs @@ -35,7 +35,22 @@ ## Design Checks # {Credo.Check.Design.AliasUsage, - [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + [ + priority: :low, + if_nested_deeper_than: 2, + if_called_more_often_than: 0, + excluded_namespaces: [ + # External optional modules — can't be aliased because they may not be installed + "PhoenixKitEntities", + "PhoenixKitAI", + "PhoenixKitPosts" + ], + excluded_lastnames: [ + # Extracted utility modules used with full paths for clarity + "Multilang", + "HtmlSanitizer" + ] + ]}, {Credo.Check.Design.TagTODO, [priority: :low]}, {Credo.Check.Design.TagFIXME, []}, From 16f655d6e9668d17c35f2ecf3ab53f24ad58ac4b Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 24 Mar 2026 08:48:53 +0200 Subject: [PATCH 6/7] Fix Credo strict: exclude Registry and Igniter from alias usage check These modules are used behind Code.ensure_loaded? guards and cannot be aliased at the top of invoking modules. Co-Authored-By: Claude Opus 4.6 (1M context) --- .credo.exs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.credo.exs b/.credo.exs index 15074d6b7..0363cb5c7 100644 --- a/.credo.exs +++ b/.credo.exs @@ -43,12 +43,16 @@ # External optional modules — can't be aliased because they may not be installed "PhoenixKitEntities", "PhoenixKitAI", - "PhoenixKitPosts" + "PhoenixKitPosts", + # Internal modules used behind Code.ensure_loaded? guards + "Igniter" ], excluded_lastnames: [ # Extracted utility modules used with full paths for clarity "Multilang", - "HtmlSanitizer" + "HtmlSanitizer", + # Used behind Code.ensure_loaded? in module enable/disable + "Registry" ] ]}, {Credo.Check.Design.TagTODO, [priority: :low]}, From 74b91f07cb3238afb8ac27b2e4e1b58ea89aba06 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 24 Mar 2026 08:56:07 +0200 Subject: [PATCH 7/7] Suppress warnings for optional external modules with @compile no_warn_undefined Add @compile {:no_warn_undefined, ...} to all files that reference extracted Publishing, Entities, and AI modules so that --warnings-as-errors passes in CI prod builds. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/legal/legal.ex | 14 ++++++++++++++ lib/modules/legal/web/settings.ex | 7 +++++++ lib/modules/pages/renderer.ex | 2 ++ lib/modules/sitemap/sources/publishing.ex | 7 +++++++ lib/phoenix_kit/dashboard/registry.ex | 6 ++++++ lib/phoenix_kit_web/live/modules/languages.ex | 7 +++++++ 6 files changed, 43 insertions(+) diff --git a/lib/modules/legal/legal.ex b/lib/modules/legal/legal.ex index 7a2525857..7f7d209f4 100644 --- a/lib/modules/legal/legal.ex +++ b/lib/modules/legal/legal.ex @@ -33,6 +33,20 @@ defmodule PhoenixKit.Modules.Legal do use PhoenixKit.Module + @compile {:no_warn_undefined, + [ + {PhoenixKit.Modules.Publishing, :enabled?, 0}, + {PhoenixKit.Modules.Publishing, :get_primary_language, 0}, + {PhoenixKit.Modules.Publishing, :get_group, 1}, + {PhoenixKit.Modules.Publishing, :add_group, 2}, + {PhoenixKit.Modules.Publishing, :list_posts, 1}, + {PhoenixKit.Modules.Publishing, :read_post, 2}, + {PhoenixKit.Modules.Publishing, :read_post, 4}, + {PhoenixKit.Modules.Publishing, :create_post, 2}, + {PhoenixKit.Modules.Publishing, :update_post, 4}, + {PhoenixKit.Modules.Publishing, :add_language_to_post, 4} + ]} + alias PhoenixKit.Dashboard.Tab alias PhoenixKit.Modules.Legal.LegalFramework alias PhoenixKit.Modules.Legal.PageType diff --git a/lib/modules/legal/web/settings.ex b/lib/modules/legal/web/settings.ex index 3c5f07cb2..249852d33 100644 --- a/lib/modules/legal/web/settings.ex +++ b/lib/modules/legal/web/settings.ex @@ -15,6 +15,13 @@ defmodule PhoenixKitWeb.Live.Modules.Legal.Settings do use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext + @compile {:no_warn_undefined, + [ + {PhoenixKit.Modules.Publishing, :enabled?, 0}, + {PhoenixKit.Modules.Publishing, :enabled_language_codes, 0}, + {PhoenixKit.Modules.Publishing, :get_primary_language, 0} + ]} + alias PhoenixKit.Modules.Legal alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes diff --git a/lib/modules/pages/renderer.ex b/lib/modules/pages/renderer.ex index 4afd22d01..ca3c3412c 100644 --- a/lib/modules/pages/renderer.ex +++ b/lib/modules/pages/renderer.ex @@ -6,6 +6,8 @@ defmodule PhoenixKit.Modules.Pages.Renderer do Cache keys include content hashes for automatic invalidation. """ + @compile {:no_warn_undefined, [{PhoenixKitEntities.Components.EntityForm, :render, 1}]} + require Logger alias Phoenix.HTML.Safe diff --git a/lib/modules/sitemap/sources/publishing.ex b/lib/modules/sitemap/sources/publishing.ex index 165cebfb3..356c4d646 100644 --- a/lib/modules/sitemap/sources/publishing.ex +++ b/lib/modules/sitemap/sources/publishing.ex @@ -39,6 +39,13 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do @behaviour PhoenixKit.Modules.Sitemap.Sources.Source + @compile {:no_warn_undefined, + [ + {PhoenixKit.Modules.Publishing, :enabled?, 0}, + {PhoenixKit.Modules.Publishing, :list_groups, 0}, + {PhoenixKit.Modules.Publishing, :list_posts, 2} + ]} + require Logger alias PhoenixKit.Config diff --git a/lib/phoenix_kit/dashboard/registry.ex b/lib/phoenix_kit/dashboard/registry.ex index ab7420872..d23de6797 100644 --- a/lib/phoenix_kit/dashboard/registry.ex +++ b/lib/phoenix_kit/dashboard/registry.ex @@ -60,6 +60,12 @@ defmodule PhoenixKit.Dashboard.Registry do use GenServer + @compile {:no_warn_undefined, + [ + {PhoenixKitEntities, :invalidate_entities_cache, 0}, + {PhoenixKitEntities.Events, :subscribe_to_entities, 0} + ]} + require Logger alias PhoenixKit.Dashboard.{AdminTabs, Badge, Group, Tab} diff --git a/lib/phoenix_kit_web/live/modules/languages.ex b/lib/phoenix_kit_web/live/modules/languages.ex index ca8256071..e9d1eea59 100644 --- a/lib/phoenix_kit_web/live/modules/languages.ex +++ b/lib/phoenix_kit_web/live/modules/languages.ex @@ -6,6 +6,13 @@ defmodule PhoenixKitWeb.Live.Modules.Languages do """ use PhoenixKitWeb, :live_view + @compile {:no_warn_undefined, + [ + {PhoenixKit.Modules.Publishing, :enabled?, 0}, + {PhoenixKit.Modules.Publishing, :list_groups, 0}, + {PhoenixKit.Modules.Publishing.ListingCache, :regenerate, 1} + ]} + alias PhoenixKit.Config alias PhoenixKit.Modules.Languages alias PhoenixKit.Settings