From 5c12fed8c18c23af5bafd1da820e34117b5024bb Mon Sep 17 00:00:00 2001 From: Max Don Date: Sat, 14 Mar 2026 07:19:52 +0200 Subject: [PATCH 01/99] Remove legacy blogging code and harden publishing module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove deprecated blogging.ex shim, redirect controller, and migration tasks - Remove all backward-compat aliases (pubsub, renderer, publishing.ex) - Rename "blogging" type to "blog", remove legacy settings key fallbacks - Move group storage from JSON settings to DB-only - Rename tokens (blog-editor/blog-preview → publishing-editor/publishing-preview) - Update cache key prefix, sitemap terminology, dialyzer config Add stale value fixers with status reconciliation: - fix_stale_group (mode, type, item names) - fix_stale_post (language, status, mode, date/time) - fix_stale_version/content (status, language) - reconcile_post_status (cross-level post↔version↔content consistency) Improve concurrency safety: - Wrap publish_version and create_post in Repo.transaction - Use INSERT ON CONFLICT for upsert_content - Add FOR UPDATE lock to next_version_number - Check DB return values inside transactions Harden public controller: - Expand fallback chain to handle all error reasons gracefully - Fix timestamp comparison in fallback (Time struct formatting) - Filter list_times_on_date to published posts only - Cap fallback language chain at 5 languages - Guard against nil language in URL builder, nil page_title - Add pagination safety (division-by-zero, page clamping) - Nil guards on filter functions (available_languages, language_statuses, metadata) Harden editor and admin pages: - Rescue autosave to prevent LiveView crash - Cancel autosave timer on language switch - Guard re_read_post against nil post - Debounce dashboard refresh on index page (500ms) - Fix migration modal nil crash on empty groups - Eliminate double DB read in listing refresh - Cancel timers on group switch and terminate Add comprehensive error logging: - All rescue/catch blocks now log exception + context identifiers - Generic save error handlers log actual error reason - Preview token verification failures logged with group context - Cache miss fallback logged Additional safety: - remove_group checks post count before cascade delete - set_translation_status validates status enum - normalize_version_number rejects non-positive values - publish_version validates target version exists - safe_mode_atom replaces String.to_existing_atom - ListingCache capped at 5000 posts per group Co-Authored-By: Claude Opus 4.6 (1M context) --- .dialyzer_ignore.exs | 9 +- .../phoenix_kit.migrate_blog_versions.ex | 20 - ...enix_kit.migrate_blogging_to_publishing.ex | 178 --- lib/modules/blogging.ex | 93 -- lib/modules/publishing/README.md | 29 +- lib/modules/publishing/db_storage.ex | 58 +- lib/modules/publishing/db_storage/mapper.ex | 12 +- lib/modules/publishing/listing_cache.ex | 49 +- lib/modules/publishing/presence_helpers.ex | 2 +- lib/modules/publishing/publishing.ex | 1038 +++++++++++------ lib/modules/publishing/pubsub.ex | 38 - lib/modules/publishing/renderer.ex | 41 +- .../publishing/schemas/publishing_group.ex | 6 +- .../publishing/schemas/publishing_post.ex | 2 +- .../web/components/language_switcher.ex | 5 +- lib/modules/publishing/web/controller.ex | 8 +- .../publishing/web/controller/fallback.ex | 42 +- .../publishing/web/controller/language.ex | 4 +- .../publishing/web/controller/listing.ex | 48 +- .../web/controller/post_fetching.ex | 11 +- .../web/controller/post_rendering.ex | 8 +- .../publishing/web/controller/translations.ex | 12 +- lib/modules/publishing/web/editor.ex | 38 +- lib/modules/publishing/web/editor.html.heex | 4 +- lib/modules/publishing/web/editor/forms.ex | 2 +- lib/modules/publishing/web/editor/helpers.ex | 6 +- .../publishing/web/editor/persistence.ex | 14 +- .../publishing/web/editor/translation.ex | 3 +- lib/modules/publishing/web/html.ex | 4 +- lib/modules/publishing/web/index.ex | 70 +- lib/modules/publishing/web/listing.ex | 156 ++- lib/modules/publishing/web/new.ex | 21 +- lib/modules/publishing/web/new.html.heex | 8 +- lib/modules/publishing/web/preview.ex | 30 +- lib/modules/publishing/web/settings.ex | 53 +- lib/modules/sitemap/sources/publishing.ex | 120 +- lib/phoenix_kit/cache/cache.ex | 2 +- lib/phoenix_kit/config/config.ex | 1 - lib/phoenix_kit/utils/routes.ex | 2 +- .../publishing_redirect_controller.ex | 78 -- lib/phoenix_kit_web/live/modules/languages.ex | 16 +- lib/phoenix_kit_web/routes/publishing.ex | 33 +- test/modules/publishing/mapper_test.exs | 235 +++- test/modules/publishing/schema_test.exs | 2 +- 44 files changed, 1392 insertions(+), 1219 deletions(-) delete mode 100644 lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex delete mode 100644 lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex delete mode 100644 lib/modules/blogging.ex delete mode 100644 lib/phoenix_kit_web/controllers/redirects/publishing_redirect_controller.ex diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 7924c1814..b3252d1a7 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -5,8 +5,6 @@ {"lib/mix/tasks/phoenix_kit.update.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.gen.admin_page.ex", :unknown_function}, {"lib/mix/tasks/phoenix_kit.gen.dashboard_tab.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex", :unknown_function}, - {"lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex", :unknown_function}, # Conditional compilation pattern match in update.ex (Code.ensure_loaded?) {"lib/mix/tasks/phoenix_kit.update.ex", :pattern_match, 1}, {"lib/mix/tasks/phoenix_kit.modernize_layouts.ex", :unknown_function}, @@ -61,13 +59,10 @@ {"lib/mix/tasks/phoenix_kit.sync_email_status.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.fix_missing_events.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.process_sqs.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex", :callback_info_missing, 1}, - {"lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex", :callback_info_missing, 1}, {"lib/mix/tasks/phoenix_kit.cleanup_orphaned_files.ex", :callback_info_missing, 1}, # False positive pattern match warnings (runtime behavior differs from static analysis) {"lib/mix/tasks/phoenix_kit/email_cleanup.ex", :pattern_match, 1}, - {"lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex", :pattern_match_cov}, # ExAws library type definition issues (false positives from incomplete type specs) ~r/lib\/modules\/emails\/archiver\.ex:.*pattern_match/, ~r/lib\/modules\/emails\/archiver\.ex:.*unused_fun/, @@ -75,8 +70,8 @@ # Ecto.Multi opaque type false positives (code works correctly) ~r/lib\/phoenix_kit\/users\/auth\.ex:.*call_without_opaque/, - # Legal module - dynamic dispatch to Blogging module - # Dialyzer can't infer types through blogging_module() helper + # Legal module - dynamic dispatch to Publishing module + # Dialyzer can't infer types through publishing_module() helper ~r/lib\/modules\/legal\/legal\.ex:.*pattern_match/, # ConsentLog schema - changeset type spec with empty struct diff --git a/lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex b/lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex deleted file mode 100644 index ca1481ad8..000000000 --- a/lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.MigrateBlogVersions do - @shortdoc "Legacy task — filesystem storage has been removed" - @moduledoc """ - This task previously migrated blog posts from flat to versioned folder structure. - - Filesystem storage has been removed — all content is now in the database. - This task is no longer needed and is kept only as a no-op stub. - """ - - use Mix.Task - - @impl Mix.Task - def run(_args) do - Mix.shell().info(""" - This task migrated legacy filesystem blog posts to versioned folder structure. - Filesystem storage has been removed — all content is now in the database. - This task is no longer needed. - """) - end -end diff --git a/lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex b/lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex deleted file mode 100644 index 856518b9a..000000000 --- a/lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex +++ /dev/null @@ -1,178 +0,0 @@ -defmodule Mix.Tasks.PhoenixKit.MigrateBloggingToPublishing do - @shortdoc "Migrates blogging module to publishing (settings)" - @moduledoc """ - Migrates the PhoenixKit blogging module to the new publishing module. - - ## What this task does - - Migrates settings keys from legacy names to new names: - - `blogging_enabled` → `publishing_enabled` - - `blogging_blogs` → `publishing_groups` - - `blogging_memory_cache_enabled` → `publishing_memory_cache_enabled` - - `blogging_render_cache_enabled` → `publishing_render_cache_enabled` - - `blogging_render_cache_enabled_*` → `publishing_render_cache_enabled_*` - - ## Usage - - mix phoenix_kit.migrate_blogging_to_publishing - mix phoenix_kit.migrate_blogging_to_publishing --dry-run - mix phoenix_kit.migrate_blogging_to_publishing --verbose - - ## Options - - - `--dry-run` - Shows what would be changed without making any changes - - `--verbose` - Shows detailed output of each operation - - ## Safety - - This task is idempotent - it's safe to run multiple times: - - If `priv/publishing` already exists, directory rename is skipped - - If new settings keys already have values, those values are preserved - - Legacy settings are not deleted (kept for backward compatibility) - - ## Rollback - - To rollback, manually: - 1. Rename `priv/publishing` back to `priv/blogging` - 2. The application will automatically read from legacy settings keys - """ - - use Mix.Task - - alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Settings - - @impl Mix.Task - def run(args) do - {opts, _, _} = OptionParser.parse(args, switches: [dry_run: :boolean, verbose: :boolean]) - dry_run = Keyword.get(opts, :dry_run, false) - verbose = Keyword.get(opts, :verbose, false) - - Mix.shell().info("PhoenixKit Blogging → Publishing Migration") - Mix.shell().info(String.duplicate("=", 50)) - - if dry_run do - Mix.shell().info("Running in DRY RUN mode - no changes will be made\n") - end - - # Start the application to access Settings - unless dry_run do - Mix.Task.run("app.start") - end - - # Step 1: Migrate storage directory - migrate_storage_directory(dry_run, verbose) - - # Step 2: Migrate settings keys - migrate_settings_keys(dry_run, verbose) - - Mix.shell().info("\nMigration complete!") - end - - defp migrate_storage_directory(_dry_run, _verbose) do - Mix.shell().info("\n1. Storage Directory Migration") - Mix.shell().info(String.duplicate("-", 30)) - Mix.shell().info("✓ Filesystem storage has been removed — all content is in the database.") - end - - defp migrate_settings_keys(dry_run, verbose) do - Mix.shell().info("\n2. Settings Key Migration") - Mix.shell().info(String.duplicate("-", 30)) - - if dry_run do - Mix.shell().info("Settings migration requires database access.") - Mix.shell().info("Run without --dry-run to migrate settings.") - return_settings_to_migrate() - else - migrate_settings(verbose) - end - end - - defp return_settings_to_migrate do - settings = [ - {"blogging_enabled", "publishing_enabled"}, - {"blogging_blogs", "publishing_groups"}, - {"blogging_file_cache_enabled", "publishing_file_cache_enabled"}, - {"blogging_memory_cache_enabled", "publishing_memory_cache_enabled"}, - {"blogging_render_cache_enabled", "publishing_render_cache_enabled"} - ] - - Mix.shell().info("\nSettings that would be migrated:") - - for {old_key, new_key} <- settings do - Mix.shell().info(" #{old_key} → #{new_key}") - end - - Mix.shell().info(" blogging_render_cache_enabled_* → publishing_render_cache_enabled_*") - end - - defp migrate_settings(verbose) do - # Core settings to migrate - core_settings = [ - {"blogging_enabled", "publishing_enabled"}, - {"blogging_blogs", "publishing_groups"}, - {"blogging_file_cache_enabled", "publishing_file_cache_enabled"}, - {"blogging_memory_cache_enabled", "publishing_memory_cache_enabled"}, - {"blogging_render_cache_enabled", "publishing_render_cache_enabled"} - ] - - # Migrate core settings - for {old_key, new_key} <- core_settings do - migrate_setting(old_key, new_key, verbose) - end - - # Migrate per-blog render cache settings - migrate_per_blog_render_cache_settings(verbose) - end - - defp migrate_setting(old_key, new_key, verbose) do - # Check if new key already has a value - case Settings.get_setting(new_key, nil) do - nil -> - # New key doesn't exist, check legacy key - case Settings.get_setting(old_key, nil) do - nil -> - if verbose do - Mix.shell().info(" - #{old_key}: not set, skipping") - end - - value -> - Settings.update_setting(new_key, value) - Mix.shell().info("✓ Migrated: #{old_key} → #{new_key}") - - if verbose do - Mix.shell().info(" Value: #{inspect(value)}") - end - end - - _value -> - Mix.shell().info("✓ #{new_key}: already exists, keeping existing value") - end - end - - defp migrate_per_blog_render_cache_settings(verbose) do - # Find all per-blog render cache settings - # These match the pattern blogging_render_cache_enabled_{slug} - prefix = "blogging_render_cache_enabled_" - new_prefix = "publishing_render_cache_enabled_" - - # We need to query the settings table for keys matching the pattern - # Since we don't have a direct query function, we'll use the blogs list - # to determine which per-blog settings might exist - - case Publishing.list_groups() do - blogs when is_list(blogs) -> - for blog <- blogs do - slug = blog["slug"] - old_key = prefix <> slug - new_key = new_prefix <> slug - migrate_setting(old_key, new_key, verbose) - end - - _ -> - if verbose do - Mix.shell().info(" - No blogs found for per-blog settings migration") - end - end - end -end diff --git a/lib/modules/blogging.ex b/lib/modules/blogging.ex deleted file mode 100644 index 3ced19205..000000000 --- a/lib/modules/blogging.ex +++ /dev/null @@ -1,93 +0,0 @@ -defmodule PhoenixKit.Modules.Blogging do - @moduledoc """ - DEPRECATED: This module has been renamed to `PhoenixKit.Modules.Publishing`. - - All functions in this module delegate to `PhoenixKit.Modules.Publishing`. - Please update your code to use the new module name directly. - - This module will be removed in a future major version. - """ - - @deprecated "Use PhoenixKit.Modules.Publishing instead" - - # Core functions - delegate to new names - defdelegate enabled?(), to: PhoenixKit.Modules.Publishing - defdelegate enable_system(), to: PhoenixKit.Modules.Publishing - defdelegate disable_system(), to: PhoenixKit.Modules.Publishing - defdelegate list_blogs(), to: PhoenixKit.Modules.Publishing, as: :list_groups - defdelegate blog_name(slug), to: PhoenixKit.Modules.Publishing, as: :group_name - defdelegate get_blog_mode(slug), to: PhoenixKit.Modules.Publishing, as: :get_group_mode - defdelegate add_blog(name, opts \\ []), to: PhoenixKit.Modules.Publishing, as: :add_group - defdelegate remove_blog(slug), to: PhoenixKit.Modules.Publishing, as: :remove_group - defdelegate update_blog(slug, params), to: PhoenixKit.Modules.Publishing, as: :update_group - defdelegate trash_blog(slug), to: PhoenixKit.Modules.Publishing, as: :trash_group - defdelegate slugify(text), to: PhoenixKit.Modules.Publishing - defdelegate valid_slug?(slug), to: PhoenixKit.Modules.Publishing - defdelegate preset_types(), to: PhoenixKit.Modules.Publishing - - # Post functions - defdelegate list_posts(blog_slug, locale), to: PhoenixKit.Modules.Publishing - defdelegate read_post(blog_slug, path), to: PhoenixKit.Modules.Publishing - defdelegate read_post(blog_slug, slug, language), to: PhoenixKit.Modules.Publishing - defdelegate read_post(blog_slug, slug, language, version), to: PhoenixKit.Modules.Publishing - defdelegate create_post(blog_slug, params), to: PhoenixKit.Modules.Publishing - defdelegate update_post(blog_slug, post, params), to: PhoenixKit.Modules.Publishing - defdelegate update_post(blog_slug, post, params, opts), to: PhoenixKit.Modules.Publishing - - defdelegate add_language_to_post(blog_slug, path, new_language), - to: PhoenixKit.Modules.Publishing - - defdelegate add_language_to_post(blog_slug, path, new_language, opts), - to: PhoenixKit.Modules.Publishing - - # Version functions - defdelegate list_versions(blog_slug, post_slug), to: PhoenixKit.Modules.Publishing - - defdelegate get_live_version(blog_slug, post_slug), - to: PhoenixKit.Modules.Publishing, - as: :get_published_version - - defdelegate get_version_status(blog_slug, post_slug, version, language), - to: PhoenixKit.Modules.Publishing - - defdelegate should_create_new_version?(post, params, editing_language), - to: PhoenixKit.Modules.Publishing - - # Slug utilities (delegated from Storage) - defdelegate validate_slug(slug), to: PhoenixKit.Modules.Publishing - defdelegate slug_exists?(blog_slug, post_slug), to: PhoenixKit.Modules.Publishing - defdelegate generate_unique_slug(blog_slug, title), to: PhoenixKit.Modules.Publishing - - defdelegate generate_unique_slug(blog_slug, title, preferred_slug), - to: PhoenixKit.Modules.Publishing - - defdelegate generate_unique_slug(blog_slug, title, preferred_slug, opts), - to: PhoenixKit.Modules.Publishing - - # Language utilities (delegated from Storage) - defdelegate enabled_language_codes(), to: PhoenixKit.Modules.Publishing - defdelegate get_primary_language(), to: PhoenixKit.Modules.Publishing - - @doc false - @deprecated "Use get_primary_language/0 instead" - def get_master_language, do: get_primary_language() - - defdelegate get_language_info(language_code), to: PhoenixKit.Modules.Publishing - - defdelegate language_enabled?(language_code, enabled_languages), - to: PhoenixKit.Modules.Publishing - - defdelegate get_display_code(language_code, enabled_languages), - to: PhoenixKit.Modules.Publishing - - defdelegate order_languages_for_display(available_languages, enabled_languages), - to: PhoenixKit.Modules.Publishing - - # Version metadata (delegated from Storage) - defdelegate get_version_metadata(blog_slug, post_slug, version, language), - to: PhoenixKit.Modules.Publishing - - # Storage migration functions - delegate to new names - defdelegate legacy_blog?(blog_slug), to: PhoenixKit.Modules.Publishing, as: :legacy_group? - defdelegate has_legacy_blogs?(), to: PhoenixKit.Modules.Publishing, as: :has_legacy_groups? -end diff --git a/lib/modules/publishing/README.md b/lib/modules/publishing/README.md index 64036b977..39f9af2bf 100644 --- a/lib/modules/publishing/README.md +++ b/lib/modules/publishing/README.md @@ -52,7 +52,7 @@ The publishing module includes public-facing routes for displaying published pos - **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:blog_post:...`) +- **Performance** - Content-hash-based caching with versioned keys (`v1:publishing_post:...`) ### Language Detection @@ -114,8 +114,6 @@ 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:** Legacy `blogging_*` settings keys are still supported for backward compatibility. The module checks `publishing_*` keys first, then falls back to `blogging_*`. - **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 @@ -146,7 +144,7 @@ PhoenixKit ships two cache layers: `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:blog_post:docs:getting-started:en:a1b2c3d4` +Example render cache key: `v1:publishing_post:docs:getting-started:en:a1b2c3d4` Manual cache operations remain available when scripting: @@ -174,7 +172,7 @@ Renderer.clear_all_cache() - **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 legacy map format consumed by web layer +- **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):** @@ -326,7 +324,7 @@ 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` (with legacy `blogging_settings_module` fallback). +- Module settings live in `PhoenixKit.Settings`. ### Managing publishing groups @@ -665,12 +663,12 @@ versions = DBStorage.list_versions(post_uuid) contents = DBStorage.list_contents(version_uuid) ``` -The `Mapper` module converts DB records to the legacy map format that the web layer and templates consume: +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_legacy_map(group, post, version, content, available_languages) +post_map = Mapper.to_post_map(group, post, version, content, available_languages) listing_map = Mapper.to_listing_map(group, post, version, primary_content) ``` @@ -795,7 +793,7 @@ publishing_contents table: 4. Fill in translated content and save 5. All translations share same post/version, each with its own `url_slug` -**Post Map Fields (Legacy Format):** +**Post Map Fields:** The `Mapper` module converts DB records into this map format consumed by templates and the web layer: @@ -1023,7 +1021,7 @@ Admin chooses mode at creation time: |------|-------|----------| | `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_legacy_map`, `to_listing_map`, field mapping, edge cases | +| `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? | @@ -1052,19 +1050,16 @@ Publishing.enable_system() Publishing.disable_system() Publishing.enabled?() # => true/false -# Publishing groups stored as JSON setting -# Key: "publishing_groups" (with legacy "blogging_blogs" fallback) -# Value: %{"blogs" => [%{"name" => "...", "slug" => "...", "mode" => "...", "type" => "..."}]} -# Note: The inner "blogs" key is a legacy JSON key preserved for backward compatibility +# Publishing groups are stored in the database (publishing_groups table) -# Cache toggles (with legacy blogging_* fallback) +# Cache toggles PhoenixKit.Settings.update_setting("publishing_memory_cache_enabled", "true") -# Render cache (global + per group, with legacy fallback) +# 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, with legacy blogging_settings_module fallback) +# Custom settings backend (optional) config :phoenix_kit, publishing_settings_module: MyApp.CustomSettings ``` diff --git a/lib/modules/publishing/db_storage.ex b/lib/modules/publishing/db_storage.ex index cff72610c..1694153f3 100644 --- a/lib/modules/publishing/db_storage.ex +++ b/lib/modules/publishing/db_storage.ex @@ -100,7 +100,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do 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 legacy posts with non-zero + 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 @@ -119,7 +119,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do if result do result else - # Fallback for legacy posts stored with non-zero seconds + # Fallback for older posts stored with non-zero seconds hour = time.hour minute = time.minute @@ -170,6 +170,16 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do |> repo().all() end + @doc "Counts 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, + select: count(p.uuid) + ) + |> repo().one() || 0 + end + @doc "Lists posts in timestamp mode (ordered by date/time desc)." def list_posts_timestamp_mode(group_slug, status \\ nil) do query = @@ -208,7 +218,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do @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 legacy fallback + # Delegate to get_post_by_datetime which handles normalization and fallback get_post_by_datetime(group_slug, date, time) end @@ -239,7 +249,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do 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 legacy maps (with `:primary_language` key). + 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 -> @@ -329,12 +339,18 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do |> repo().all() end - @doc "Gets the next version number for a post." + @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 result = from(v in PublishingVersion, where: v.post_uuid == ^post_uuid, - select: max(v.version_number) + select: max(v.version_number), + lock: "FOR UPDATE" ) |> repo().one() @@ -410,6 +426,12 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do |> 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, @@ -512,15 +534,15 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do end end - @doc "Upserts content by version_id + language." + @doc "Upserts content by version_id + language using ON CONFLICT." def upsert_content(attrs) do - version_uuid = Map.get(attrs, :version_uuid) || Map.get(attrs, "version_uuid") - language = Map.get(attrs, :language) || Map.get(attrs, "language") + changeset = PublishingContent.changeset(%PublishingContent{}, attrs) - case get_content(version_uuid, language) do - nil -> create_content(attrs) - content -> update_content(content, attrs) - end + repo().insert(changeset, + on_conflict: {:replace, [:title, :content, :status, :url_slug, :data, :updated_at]}, + conflict_target: [:version_uuid, :language], + returning: true + ) end # =========================================================================== @@ -530,7 +552,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do @doc """ Reads a full post with its latest version and content for a specific language. - Returns a map suitable for the legacy mapper or nil if not found. + 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), @@ -539,7 +561,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do content when not is_nil(content) <- resolve_content(contents, language, post) do all_versions = list_versions(post.uuid) - {:ok, Mapper.to_legacy_map(post, version, content, contents, all_versions)} + {:ok, Mapper.to_post_map(post, version, content, contents, all_versions)} else nil -> {:error, :not_found} end @@ -555,7 +577,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do content when not is_nil(content) <- resolve_content(contents, language, post) do all_versions = list_versions(post.uuid) - {:ok, Mapper.to_legacy_map(post, version, content, contents, all_versions)} + {:ok, Mapper.to_post_map(post, version, content, contents, all_versions)} else nil -> {:error, :not_found} end @@ -564,7 +586,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do @doc """ Lists all posts in a group with their latest version metadata. - Returns a list of legacy-format maps suitable for listing pages. + Returns a list of post maps suitable for listing pages. """ def list_posts_with_metadata(group_slug) do posts = list_posts(group_slug) @@ -614,7 +636,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do primary_content = resolve_content(contents, nil, post) if primary_content do - Mapper.to_legacy_map(post, version, primary_content, contents, all_versions, + Mapper.to_post_map(post, version, primary_content, contents, all_versions, published_language_statuses: published_statuses ) else diff --git a/lib/modules/publishing/db_storage/mapper.ex b/lib/modules/publishing/db_storage/mapper.ex index fd9f85c57..e47bbf9b6 100644 --- a/lib/modules/publishing/db_storage/mapper.ex +++ b/lib/modules/publishing/db_storage/mapper.ex @@ -30,9 +30,9 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.Mapper do @doc """ Converts a full post read (post + version + content + all contents + all versions) - into the legacy map format expected by the web layer. + into the map format expected by the web layer. """ - def to_legacy_map( + def to_post_map( %PublishingPost{} = post, %PublishingVersion{} = version, %PublishingContent{} = content, @@ -65,7 +65,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.Mapper do url_slug: presence(content.url_slug) || post.slug, date: post.post_date, time: post.post_time, - mode: String.to_existing_atom(post.mode), + mode: safe_mode_atom(post.mode), language: content.language, available_languages: available_languages, language_statuses: language_statuses, @@ -116,7 +116,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.Mapper do url_slug: presence(primary_content && primary_content.url_slug) || post.slug, date: post.post_date, time: post.post_time, - mode: String.to_existing_atom(post.mode), + mode: safe_mode_atom(post.mode), language: post.primary_language, available_languages: available_languages, language_statuses: language_statuses, @@ -242,6 +242,10 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.Mapper do 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 diff --git a/lib/modules/publishing/listing_cache.ex b/lib/modules/publishing/listing_cache.ex index d043fc86b..bff44acf7 100644 --- a/lib/modules/publishing/listing_cache.ex +++ b/lib/modules/publishing/listing_cache.ex @@ -51,20 +51,16 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do # ETS table for regeneration locks (provides atomic test-and-set via insert_new) @lock_table :phoenix_kit_listing_cache_locks - # Settings keys for memory cache toggle + # Settings key for memory cache toggle @memory_cache_key "publishing_memory_cache_enabled" - # Legacy settings key (read as fallback) - @legacy_memory_cache_key "blogging_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 - (with fallback to legacy `blogging_memory_cache_enabled` key). + Respects the `publishing_memory_cache_enabled` setting. """ @spec read(String.t()) :: {:ok, [map()]} | {:error, :cache_miss} def read(group_slug) do @@ -125,11 +121,27 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do {: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 - posts = DBStorage.list_posts_for_listing(group_slug) + 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) @@ -496,14 +508,7 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do end defp find_post_by_url_slug(posts, language, url_slug) do - # Search by language_slugs map first - by_language_slug = - Enum.find(posts, &(Map.get(&1.language_slugs || %{}, language) == url_slug)) - - # Fallback: match by directory slug (backward compatibility) - by_directory_slug = Enum.find(posts, &(&1.slug == url_slug)) - - case by_language_slug || by_directory_slug do + case Enum.find(posts, &(Map.get(&1.language_slugs || %{}, language) == url_slug)) do nil -> {:error, :not_found} post -> {:ok, post} end @@ -537,14 +542,10 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do end defp post_has_previous_slug?(post, language, url_slug) do - # Check the per-language previous slugs map first lang_previous_slugs = Map.get(post, :language_previous_slugs) || %{} previous_for_lang = Map.get(lang_previous_slugs, language) || [] - # Fallback: check metadata.previous_url_slugs for backward compatibility - metadata_previous = Map.get(post.metadata || %{}, :previous_url_slugs) || [] - - url_slug in previous_for_lang or url_slug in metadata_previous + url_slug in previous_for_lang end @doc """ @@ -596,14 +597,10 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do @doc """ Returns whether memory caching (:persistent_term) is enabled. Uses cached settings to avoid database queries on every call. - Checks new key first, falls back to legacy key. """ @spec memory_cache_enabled?() :: boolean() def memory_cache_enabled? do - case Settings.get_setting_cached(@memory_cache_key, nil) do - nil -> Settings.get_setting_cached(@legacy_memory_cache_key, "true") == "true" - value -> value == "true" - end + Settings.get_setting_cached(@memory_cache_key, "true") == "true" end @doc """ @@ -647,7 +644,7 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do 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 (legacy posts) + - `needs_backfill` - posts with no primary_language stored """ @spec count_primary_language_status(String.t()) :: map() def count_primary_language_status(group_slug) do diff --git a/lib/modules/publishing/presence_helpers.ex b/lib/modules/publishing/presence_helpers.ex index 487e71cb8..416543d43 100644 --- a/lib/modules/publishing/presence_helpers.ex +++ b/lib/modules/publishing/presence_helpers.ex @@ -124,7 +124,7 @@ defmodule PhoenixKit.Modules.Publishing.PresenceHelpers do Enum.filter(metas, fn meta -> case Map.get(meta, :pid) do pid when is_pid(pid) -> Process.alive?(pid) - # Keep metas without PID for backward compatibility + # Keep metas without PID _ -> true end end) diff --git a/lib/modules/publishing/publishing.ex b/lib/modules/publishing/publishing.ex index 9c801d5fd..10f01b7df 100644 --- a/lib/modules/publishing/publishing.ex +++ b/lib/modules/publishing/publishing.ex @@ -16,6 +16,8 @@ defmodule PhoenixKit.Modules.Publishing do alias PhoenixKit.Modules.Publishing.LanguageHelpers alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.Metadata + alias PhoenixKit.Modules.Publishing.PublishingGroup + alias PhoenixKit.Modules.Publishing.PublishingPost alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Modules.Publishing.SlugHelpers alias PhoenixKit.Users.Auth.Scope @@ -64,7 +66,12 @@ defmodule PhoenixKit.Modules.Publishing do post -> post.primary_language || LanguageHelpers.get_primary_language() end rescue - _ -> LanguageHelpers.get_primary_language() + e -> + Logger.warning( + "[Publishing] get_post_primary_language failed for #{group_slug}/#{post_slug}: #{inspect(e)}" + ) + + LanguageHelpers.get_primary_language() end @doc "Checks the primary language migration status for a post." @@ -85,7 +92,12 @@ defmodule PhoenixKit.Modules.Publishing do {:needs_migration, stored} end rescue - _ -> {:needs_backfill, nil} + e -> + Logger.warning( + "[Publishing] check_primary_language_status failed for #{group_slug}/#{post_slug}: #{inspect(e)}" + ) + + {:needs_backfill, nil} end @doc "Lists version numbers for a post." @@ -100,7 +112,12 @@ defmodule PhoenixKit.Modules.Publishing do |> Enum.map(& &1.version_number) end rescue - _ -> [] + e -> + Logger.warning( + "[Publishing] list_versions failed for #{group_slug}/#{post_slug}: #{inspect(e)}" + ) + + [] end @doc "Gets the published version number for a post." @@ -119,7 +136,12 @@ defmodule PhoenixKit.Modules.Publishing do end end rescue - _ -> {:error, :not_found} + e -> + Logger.warning( + "[Publishing] get_published_version failed for #{group_slug}/#{post_slug}: #{inspect(e)}" + ) + + {:error, :not_found} end @doc "Gets the status of a specific version/language." @@ -133,7 +155,12 @@ defmodule PhoenixKit.Modules.Publishing do _ -> "draft" end rescue - _ -> "draft" + e -> + Logger.warning( + "[Publishing] get_version_status failed for #{group_slug}/#{post_slug}/v#{version_number}/#{language}: #{inspect(e)}" + ) + + "draft" end @doc "Counts posts on a specific date for a group." @@ -148,13 +175,18 @@ defmodule PhoenixKit.Modules.Publishing do date = if is_binary(date), do: Date.from_iso8601!(date), else: date group_slug - |> DBStorage.list_posts_timestamp_mode() + |> DBStorage.list_posts_timestamp_mode("published") |> Enum.filter(&(&1.post_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 """ @@ -253,7 +285,12 @@ defmodule PhoenixKit.Modules.Publishing do _ -> nil end rescue - _ -> nil + e -> + Logger.warning( + "[Publishing] get_version_metadata failed for #{group_slug}/#{post_slug}/v#{version_number}: #{inspect(e)}" + ) + + nil end # Delegate cache operations to ListingCache @@ -274,7 +311,7 @@ defmodule PhoenixKit.Modules.Publishing do 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_legacy_post(content)} + content -> {:ok, db_content_to_post_map(content)} end end @@ -286,12 +323,12 @@ defmodule PhoenixKit.Modules.Publishing do 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_legacy_post(content)} + content -> {:ok, db_content_to_post_map(content)} end end - # Converts a DBStorage content record (with preloaded version/post/group) to a legacy post map - defp db_content_to_legacy_post(content) do + # 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 @@ -307,31 +344,17 @@ defmodule PhoenixKit.Modules.Publishing do } end - @doc "Always returns false — DB-only mode has no legacy groups." - def legacy_group?(_group_slug), do: false - - @doc "Always returns false — DB-only mode has no legacy groups." - def has_legacy_groups?, do: false - - # New settings keys (write to these) @publishing_enabled_key "publishing_enabled" - @publishing_groups_key "publishing_groups" - - # Legacy settings keys (read from these as fallback) - @legacy_enabled_key "blogging_enabled" - @legacy_blogs_key "blogging_blogs" - @legacy_categories_key "blogging_categories" @default_group_mode "timestamp" - @default_group_type "blogging" - @preset_types ["blogging", "faq", "legal"] + @default_group_type "blog" + @preset_types ["blog", "faq", "legal"] + @valid_types ["blog", "faq", "legal", "custom"] @slug_regex ~r/^[a-z0-9]+(?:-[a-z0-9]+)*$/ @type_regex ~r/^[a-z][a-z0-9-]{0,31}$/ - # Default item names for preset types (singular, plural) - # Note: "blogging" type value kept for backward compatibility @type_item_names %{ - "blogging" => {"post", "posts"}, + "blog" => {"post", "posts"}, "faq" => {"question", "questions"}, "legal" => {"document", "documents"} } @@ -343,14 +366,10 @@ defmodule PhoenixKit.Modules.Publishing do @impl PhoenixKit.Module @doc """ Returns true when the publishing module is enabled. - Checks new key first, falls back to legacy key. """ @spec enabled?() :: boolean() def enabled? do - # Check new key first, then fall back to legacy key - # Uses get_boolean_setting (cached) to avoid DB queries on every sidebar render - settings_call(:get_boolean_setting, [@publishing_enabled_key, false]) or - settings_call(:get_boolean_setting, [@legacy_enabled_key, false]) + settings_call(:get_boolean_setting, [@publishing_enabled_key, false]) end @impl PhoenixKit.Module @@ -452,15 +471,15 @@ defmodule PhoenixKit.Modules.Publishing do } 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) or - Settings.get_boolean_setting("blogging_enabled", false) + publishing_enabled = Settings.get_boolean_setting("publishing_enabled", false) if publishing_enabled do alias PhoenixKit.Modules.Publishing.DBStorage @@ -471,7 +490,9 @@ defmodule PhoenixKit.Modules.Publishing do [] end rescue - _ -> [] + e -> + Logger.warning("[Publishing] load_publishing_groups_for_tabs failed: #{inspect(e)}") + [] end @impl PhoenixKit.Module @@ -497,44 +518,12 @@ defmodule PhoenixKit.Modules.Publishing do def route_module, do: PhoenixKitWeb.Routes.PublishingRoutes @doc """ - Returns all configured publishing groups. - Checks new key first, falls back to legacy keys. + Returns all publishing groups from the database. """ @spec list_groups() :: [group()] def list_groups do - # Try new key first - case settings_call(:get_json_setting_cached, [@publishing_groups_key, nil]) do - %{"publishing_groups" => groups} when is_list(groups) -> - normalize_groups(groups) - - %{"blogs" => groups} when is_list(groups) -> - # Handle if someone wrote with old structure to new key - normalize_groups(groups) - - list when is_list(list) -> - normalize_groups(list) - - _ -> - # Fall back to legacy blogging_blogs key - case settings_call(:get_json_setting_cached, [@legacy_blogs_key, nil]) do - %{"blogs" => groups} when is_list(groups) -> - normalize_groups(groups) - - list when is_list(list) -> - normalize_groups(list) - - _ -> - # Fall back to oldest legacy key (blogging_categories) - legacy = - case settings_call(:get_json_setting_cached, [@legacy_categories_key, nil]) do - %{"types" => types} when is_list(types) -> types - other when is_list(other) -> other - _ -> [] - end - - normalize_groups(legacy) - end - end + DBStorage.list_groups() + |> Enum.map(fn group -> group |> fix_stale_group() |> db_group_to_map() end) end @doc """ @@ -565,14 +554,14 @@ defmodule PhoenixKit.Modules.Publishing do * `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: "blogging", "faq", "legal", or custom (default: "blogging") + * `: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> Publishing.add_group("News") - {:ok, %{"name" => "News", "slug" => "news", "mode" => "timestamp", "type" => "blogging", ...}} + {:ok, %{"name" => "News", "slug" => "news", "mode" => "timestamp", "type" => "blog", ...}} iex> Publishing.add_group("FAQ", type: "faq", mode: "slug") {:ok, %{"name" => "FAQ", "slug" => "faq", "mode" => "slug", "type" => "faq", "item_singular" => "question", ...}} @@ -606,7 +595,6 @@ defmodule PhoenixKit.Modules.Publishing do :ok <- check_slug_availability(requested_slug, groups, preferred_slug) do slug = ensure_unique_slug(requested_slug, groups) - # Get item names - use provided values or defaults based on type {default_singular, default_plural} = default_item_names(normalized_type) item_singular = @@ -619,60 +607,68 @@ defmodule PhoenixKit.Modules.Publishing do |> fetch_option(:item_plural) |> normalize_item_name(default_plural) - group = %{ - "name" => trimmed, - "slug" => slug, - "mode" => mode, - "type" => normalized_type, - "item_singular" => item_singular, - "item_plural" => item_plural + db_attrs = %{ + name: trimmed, + slug: slug, + mode: mode, + data: %{ + "type" => normalized_type, + "item_singular" => item_singular, + "item_plural" => item_plural + } } - updated = groups ++ [group] - payload = %{"publishing_groups" => updated} - - # Always write to new key - with {:ok, _} <- settings_call(:update_json_setting, [@publishing_groups_key, payload]) do - # Sync to DB table so create_post can find the group - DBStorage.upsert_group(build_group_attrs(group)) + case DBStorage.create_group(db_attrs) do + {:ok, db_group} -> + group = db_group_to_map(db_group) + PublishingPubSub.broadcast_group_created(group) + {:ok, group} - PublishingPubSub.broadcast_group_created(group) - {:ok, group} + {:error, _changeset} -> + {:error, :already_exists} end end end end - # Legacy 4-arity version for backward compatibility - @doc false - @spec add_group(String.t(), String.t(), String.t() | nil, String.t() | nil) :: - {:ok, group()} | {:error, atom()} - def add_group(name, mode, preferred_slug, type) - when is_binary(name) and is_binary(mode) do - add_group(name, mode: mode, slug: preferred_slug, type: type) - 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 - updated = - list_groups() - |> Enum.reject(&(&1["slug"] == slug)) + remove_group(slug, force: false) + end - result = - settings_call(:update_json_setting, [ - @publishing_groups_key, - %{"publishing_groups" => updated} - ]) + @doc """ + Removes a publishing group by slug. - # Broadcast after successful deletion - if match?({:ok, _}, result) do - PublishingPubSub.broadcast_group_deleted(slug) - end + 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) - result + 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 """ @@ -680,44 +676,47 @@ defmodule PhoenixKit.Modules.Publishing do """ @spec update_group(String.t(), map() | keyword()) :: {:ok, group()} | {:error, atom()} def update_group(slug, params) when is_binary(slug) do - groups = list_groups() - - case Enum.find(groups, &(&1["slug"] == slug)) do - nil -> {:error, :not_found} - group -> process_group_update(group, groups, params) - end - end + case DBStorage.get_group_by_slug(slug) do + nil -> + {:error, :not_found} - defp process_group_update(group, groups, params) do - with {:ok, name} <- extract_and_validate_name(group, params), - {:ok, sanitized_slug} <- extract_and_validate_slug(group, params, name), - :ok <- check_slug_uniqueness(group, groups, sanitized_slug) do - apply_group_update(group, groups, name, sanitized_slug) + 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 - defp extract_and_validate_name(group, params) do + defp extract_and_validate_name(db_group, params) do name = params |> fetch_option(:name) |> case do - nil -> group["name"] + 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(group, params, name) do + defp extract_and_validate_slug(db_group, params, name) do desired_slug = params |> fetch_option(:slug) |> case do - nil -> group["slug"] + nil -> db_group.slug value -> String.trim(to_string(value || "")) end - # If slug is empty, auto-generate from name; otherwise validate as-is cond do desired_slug == "" -> auto_slug = slugify(name) @@ -731,35 +730,12 @@ defmodule PhoenixKit.Modules.Publishing do end end - defp check_slug_uniqueness(group, groups, sanitized_slug) do - if sanitized_slug != group["slug"] and Enum.any?(groups, &(&1["slug"] == sanitized_slug)) do - {:error, :already_exists} - else - :ok - end - end - - defp apply_group_update(group, groups, name, sanitized_slug) do - updated_group = - group - |> Map.put("name", name) - |> Map.put("slug", sanitized_slug) - - with {:ok, _} <- persist_group_update(groups, group["slug"], updated_group) do - PublishingPubSub.broadcast_group_updated(updated_group) - {:ok, updated_group} - end - end - @doc """ - Removes a publishing group. The group is removed from the active groups list - and soft-deleted in the database. + Removes a publishing group and all its posts. """ @spec trash_group(String.t()) :: {:ok, String.t()} | {:error, any()} def trash_group(slug) when is_binary(slug) do - with {:ok, _} <- remove_group(slug) do - {:ok, slug} - end + remove_group(slug) end @doc """ @@ -782,6 +758,327 @@ defmodule PhoenixKit.Modules.Publishing do |> Map.get("mode", @default_group_mode) end + # =========================================================================== + # Stale Value Correction + # =========================================================================== + + @valid_group_modes ["timestamp", "slug"] + @valid_post_statuses ["draft", "published", "archived", "scheduled"] + @valid_version_statuses ["draft", "published", "archived"] + + @doc """ + Fixes stale or invalid values on a publishing group record. + + Checks and corrects: + - `mode` — must be "timestamp" or "slug" (defaults to "timestamp") + - `data.type` — must be in valid_types (defaults to "custom") + - `data.item_singular` — must be a non-empty string (defaults based on type) + - `data.item_plural` — must be a non-empty string (defaults based on type) + + Can be called explicitly or runs lazily when groups are loaded in the admin. + Returns the group unchanged if no fixes are needed. + """ + @spec fix_stale_group(PublishingGroup.t()) :: PublishingGroup.t() + def fix_stale_group(%PublishingGroup{} = group) do + attrs = build_group_fixes(group) + apply_stale_fix(group, attrs, &DBStorage.update_group/2) + end + + defp build_group_fixes(group) do + data = group.data || %{} + type = Map.get(data, "type", @default_group_type) + fixed_type = if type in @valid_types, do: type, else: "custom" + fixed_mode = if group.mode in @valid_group_modes, do: group.mode, else: @default_group_mode + + {default_singular, default_plural} = default_item_names(fixed_type) + item_singular = Map.get(data, "item_singular") + item_plural = Map.get(data, "item_plural") + + fixed_singular = valid_string_or_default(item_singular, default_singular) + fixed_plural = valid_string_or_default(item_plural, default_plural) + + data_changes = + data + |> maybe_update("type", type, fixed_type) + |> maybe_update("item_singular", item_singular, fixed_singular) + |> maybe_update("item_plural", item_plural, fixed_plural) + + attrs = if data_changes != data, do: %{data: data_changes}, else: %{} + if fixed_mode != group.mode, do: Map.put(attrs, :mode, fixed_mode), else: attrs + end + + defp valid_string_or_default(val, default) do + if is_binary(val) and val != "", do: val, else: default + end + + @doc """ + Fixes stale or invalid values on a publishing post record. + + Checks and corrects: + - `primary_language` — must be a recognized language code. Resolution order: + 1. Tries to resolve a dialect (e.g., "en" → "en-US") + 2. Falls back to the first available language on the post + 3. Falls back to the system primary language + - `status` — must be a valid post status (defaults to "draft") + - `mode` — must be "timestamp" or "slug" (defaults to "timestamp") + - `post_date`/`post_time` — must be present for timestamp mode posts + + Only fixes languages not in the master predefined list — languages that were + added, used, then removed from enabled are left untouched. + """ + @spec fix_stale_post(PublishingPost.t()) :: PublishingPost.t() + def fix_stale_post(%PublishingPost{} = post) do + attrs = build_post_fixes(post) + apply_stale_fix(post, attrs, &DBStorage.update_post/2) + end + + defp build_post_fixes(post) do + %{} + |> maybe_fix_post_language(post) + |> maybe_fix_post_status(post) + |> maybe_fix_post_mode(post) + |> maybe_fix_post_timestamp(post) + end + + defp maybe_fix_post_language(attrs, post) do + case fix_stale_language(post) do + nil -> attrs + fixed_lang -> Map.put(attrs, :primary_language, fixed_lang) + end + end + + defp maybe_fix_post_status(attrs, post) do + if post.status in @valid_post_statuses, do: attrs, else: Map.put(attrs, :status, "draft") + end + + defp maybe_fix_post_mode(attrs, post) do + fixed_mode = if post.mode in @valid_group_modes, do: post.mode, else: @default_group_mode + if fixed_mode != post.mode, do: Map.put(attrs, :mode, fixed_mode), else: attrs + end + + defp maybe_fix_post_timestamp(attrs, post) do + if (attrs[:mode] || post.mode) == "timestamp" do + now = DateTime.utc_now() + + attrs + |> then(fn a -> + if is_nil(post.post_date), do: Map.put(a, :post_date, DateTime.to_date(now)), else: a + end) + |> then(fn a -> + if is_nil(post.post_time), + do: Map.put(a, :post_time, Time.new!(now.hour, now.minute, 0)), + else: a + end) + else + attrs + end + end + + # Returns the fixed language or nil if no fix needed. + defp fix_stale_language(post) do + lang = post.primary_language + + if lang && Languages.get_predefined_language(lang) do + nil + else + fixed = resolve_stale_language(lang, post) + if fixed != lang, do: fixed, else: nil + end + end + + defp resolve_stale_language(lang, post) do + dialect = if lang, do: Languages.DialectMapper.base_to_dialect(lang), else: nil + + if dialect && Languages.get_predefined_language(dialect) do + dialect + else + available = post_available_languages(post) + + if available != [] do + Enum.find(available, hd(available), fn code -> + Languages.get_predefined_language(code) != nil + end) + else + LanguageHelpers.get_primary_language() + end + end + end + + defp post_available_languages(post) do + case DBStorage.list_versions(post.uuid) do + [] -> [] + versions -> DBStorage.list_languages(hd(versions).uuid) + end + end + + defp apply_stale_fix(record, attrs, _update_fn) when attrs == %{}, do: record + + defp apply_stale_fix(record, attrs, update_fn) do + identifier = Map.get(record, :uuid) || Map.get(record, :slug) || "unknown" + + Logger.info( + "[Publishing] Fixing stale values for #{record.__struct__} #{identifier}: #{inspect(attrs)}" + ) + + case update_fn.(record, attrs) do + {:ok, updated} -> + updated + + {:error, reason} -> + Logger.warning( + "[Publishing] Failed to fix stale values for #{identifier}: #{inspect(reason)}" + ) + + record + end + end + + defp maybe_update(data, key, old_val, new_val) do + if old_val != new_val, do: Map.put(data, key, new_val), else: data + end + + @doc """ + Fixes stale values across all groups, posts, versions, and content. + Also reconciles status consistency between posts, versions, and content. + Callable via internal API or IEx. + """ + @spec fix_all_stale_values() :: :ok + def fix_all_stale_values do + groups = DBStorage.list_groups() + Enum.each(groups, &fix_stale_group/1) + + for group <- groups do + posts = DBStorage.list_posts(group.slug) + Enum.each(posts, &fix_stale_post/1) + + # Fix versions, content, and status consistency for each post + for post <- posts do + versions = DBStorage.list_versions(post.uuid) + + for version <- versions do + fix_stale_version(version) + + contents = DBStorage.list_contents(version.uuid) + Enum.each(contents, &fix_stale_content/1) + end + + # Reconcile status consistency after individual fixes + reconcile_post_status(post) + end + end + + :ok + end + + @doc false + def fix_stale_version(version) do + if version.status not in @valid_version_statuses do + Logger.info( + "[Publishing] Fixing stale version #{version.uuid}: status #{inspect(version.status)} → \"draft\"" + ) + + DBStorage.update_version(version, %{status: "draft"}) + end + end + + @doc false + def fix_stale_content(content) do + attrs = + %{} + |> maybe_fix_content_status(content) + |> maybe_fix_content_language(content) + + if attrs != %{} do + Logger.info( + "[Publishing] Fixing stale content #{content.uuid} (#{content.language}): #{inspect(attrs)}" + ) + + DBStorage.update_content(content, attrs) + end + end + + defp maybe_fix_content_status(attrs, content) do + if content.status in @valid_version_statuses, + do: attrs, + else: Map.put(attrs, :status, "draft") + end + + defp maybe_fix_content_language(attrs, content) do + if is_binary(content.language) and content.language != "" do + attrs + else + Map.put(attrs, :language, LanguageHelpers.get_primary_language()) + end + end + + # Reconciles status consistency between a post, its versions, and content. + # + # Rules enforced: + # 1. Post "published" requires at least one "published" version → else demote to "draft" + # 2. Version "published" requires its post to be "published" → else archive the version + # 3. Content "published" requires its version to be "published" → else demote to "draft" + # 4. Non-published versions cannot have "published" content → demote content to "draft" + # + # Note: individual translations CAN be "draft" while the version is "published" — + # this is the normal state for untranslated languages. We only fix content that + # claims to be "published" when it shouldn't be. + @doc false + def reconcile_post_status(%PublishingPost{} = post) do + # Re-read to get current state after individual fixes + post = DBStorage.get_post_by_uuid(post.uuid) || post + versions = DBStorage.list_versions(post.uuid) + + published_versions = Enum.filter(versions, &(&1.status == "published")) + + cond do + # Post says published but no version backs it up + post.status == "published" and published_versions == [] -> + Logger.info( + "[Publishing] Reconcile: post #{post.uuid} is published but has no published versions, demoting to draft" + ) + + DBStorage.update_post(post, %{status: "draft"}) + + # Post is not published but a version claims to be — archive the version + post.status in ["draft", "archived"] and published_versions != [] -> + Logger.info( + "[Publishing] Reconcile: post #{post.uuid} is #{inspect(post.status)} but has #{length(published_versions)} published versions, archiving" + ) + + for v <- published_versions do + DBStorage.update_version(v, %{status: "archived"}) + demote_published_content(v.uuid) + end + + true -> + :ok + end + + # For ALL non-published versions, no content should be "published" + non_published_versions = Enum.reject(versions, &(&1.status == "published")) + + for v <- non_published_versions do + demote_published_content(v.uuid) + end + end + + # Demotes any "published" content rows to "draft" within a version. + # Leaves "draft" and "archived" content untouched. + defp demote_published_content(version_uuid) do + contents = DBStorage.list_contents(version_uuid) + published = Enum.filter(contents, &(&1.status == "published")) + + if published != [] do + Logger.info( + "[Publishing] Demoting #{length(published)} published content row(s) to \"draft\" in version #{version_uuid}" + ) + + for content <- published do + DBStorage.update_content(content, %{status: "draft"}) + end + end + end + @doc """ Lists posts for a given publishing group slug. @@ -855,24 +1152,35 @@ defmodule PhoenixKit.Modules.Publishing do post_attrs end - with {:ok, db_post} <- DBStorage.create_post(post_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: fetch_option(opts, :title) || "", - content: fetch_option(opts, :content) || "", - status: "draft", - url_slug: post_slug - }) do - # Read back via mapper to get a proper legacy map with UUID + repo = PhoenixKit.RepoHelper.repo() + + tx_result = + repo.transaction(fn -> + with {:ok, db_post} <- DBStorage.create_post(post_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: fetch_option(opts, :title) || "", + content: 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( @@ -898,7 +1206,9 @@ defmodule PhoenixKit.Modules.Publishing do end end catch - {:error, reason} -> {:error, reason} + {:error, reason} -> + Logger.warning("[Publishing] create_post failed for #{group_slug}: #{inspect(reason)}") + {:error, reason} end defp resolve_scope_user_uuids(nil), do: nil @@ -919,6 +1229,7 @@ defmodule PhoenixKit.Modules.Publishing do {:error, :not_found} db_post -> + db_post = fix_stale_post(db_post) group_slug = db_post.group.slug version_number = if version, do: normalize_version_number(version), else: nil @@ -935,8 +1246,9 @@ defmodule PhoenixKit.Modules.Publishing do end end rescue - Ecto.QueryError -> {:error, :not_found} - DBConnection.ConnectionError -> {:error, :not_found} + e in [Ecto.QueryError, DBConnection.ConnectionError] -> + Logger.warning("[Publishing] read_post_by_uuid failed for #{post_uuid}: #{inspect(e)}") + {:error, :not_found} end @doc """ @@ -1002,12 +1314,13 @@ defmodule PhoenixKit.Modules.Publishing do defp normalize_version_number(nil), do: nil - defp normalize_version_number(v) when is_integer(v), do: v + 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, _} -> n - :error -> nil + {n, _} when n > 0 -> n + _ -> nil end end @@ -1072,7 +1385,7 @@ defmodule PhoenixKit.Modules.Publishing do |> case do nil -> nil "" -> nil - lang_file -> lang_file + lang_code -> lang_code end {:ok, date, time, version, lang} @@ -1094,7 +1407,7 @@ defmodule PhoenixKit.Modules.Publishing do end # Adds a language to a post. - # Creates a new content row in the database and returns the legacy map. + # Creates a new content row in the database and returns the post map. @doc false def add_language_to_db(group_slug, post_uuid, language_code, version_number) do with db_post when not is_nil(db_post) <- DBStorage.get_post_by_uuid(post_uuid, [:group]), @@ -1113,7 +1426,7 @@ defmodule PhoenixKit.Modules.Publishing do content: "", status: "draft" }) do - # Read the post back from DB to return a proper legacy map + # Read the post back from DB to return a proper post map read_back_post(group_slug, post_uuid, db_post, language_code, version.version_number) else nil -> @@ -1127,8 +1440,12 @@ defmodule PhoenixKit.Modules.Publishing do {:error, reason} end rescue - Ecto.QueryError -> {:error, :not_found} - DBConnection.ConnectionError -> {:error, :not_found} + e in [Ecto.QueryError, DBConnection.ConnectionError] -> + Logger.warning( + "[Publishing] add_language_to_db failed for #{group_slug}/#{post_uuid}/#{language_code}: #{inspect(e)}" + ) + + {:error, :not_found} end defp uuid_format?(str) when is_binary(str), do: match?({:ok, _}, UUIDv7.cast(str)) @@ -1176,7 +1493,7 @@ defmodule PhoenixKit.Modules.Publishing do end # Updates a post in the database. - # Writes directly to the database and returns the updated legacy map. + # 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) @@ -1236,8 +1553,16 @@ defmodule PhoenixKit.Modules.Publishing do {:ok, _} <- DBStorage.update_post(db_post, %{slug: valid_slug}) do {:ok, valid_slug} else - true -> {:error, :slug_already_exists} - {:error, reason} -> {:error, reason} + true -> + {:error, :slug_already_exists} + + {:error, %Ecto.Changeset{errors: errors}} -> + if Keyword.has_key?(errors, :slug), + do: {:error, :slug_already_exists}, + else: {:error, :db_update_failed} + + {:error, reason} -> + {:error, reason} end end @@ -1247,7 +1572,8 @@ defmodule PhoenixKit.Modules.Publishing do if version do language = post[:language] || db_post.primary_language - new_status = Map.get(params, "status", post[:metadata][:status] || "draft") + 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) @@ -1279,15 +1605,18 @@ defmodule PhoenixKit.Modules.Publishing do {:error, :not_found} end catch - {:post_update_failed, reason} -> {:error, reason} + {:post_update_failed, reason} -> + Logger.warning("[Publishing] update_post failed for #{group_slug}: #{inspect(reason)}") + {:error, reason} end defp resolve_post_title(params, post, content) do extracted_title = Metadata.extract_title_from_content(content) + post_metadata = post[:metadata] || %{} Map.get(params, "title") || if(extracted_title != "Untitled", do: extracted_title) || - post[:metadata][:title] || + post_metadata[:title] || "Untitled" end @@ -1312,15 +1641,18 @@ defmodule PhoenixKit.Modules.Publishing do :error -> existing_url_slug end - 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) - }) + 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} -> throw({:post_update_failed, reason}) + end end defp maybe_propagate_status(version, language, db_post, new_status, old_db_status) do @@ -1344,7 +1676,12 @@ defmodule PhoenixKit.Modules.Publishing do end end rescue - _ -> {:error, :post_not_found} + e -> + Logger.warning( + "[Publishing] update_primary_language_in_db failed for #{post_uuid}: #{inspect(e)}" + ) + + {:error, :post_not_found} end defp resolve_db_version(db_post, nil), do: DBStorage.get_latest_version(db_post.uuid) @@ -1385,7 +1722,9 @@ defmodule PhoenixKit.Modules.Publishing do id -> Map.put(data, "featured_image_uuid", id) end - case Map.get(params, "description", post[:metadata][:description]) do + post_metadata = post[:metadata] || %{} + + case Map.get(params, "description", post_metadata[:description]) do nil -> data desc -> Map.put(data, "description", desc) end @@ -1437,9 +1776,9 @@ defmodule PhoenixKit.Modules.Publishing do @doc """ Publishes a version, making it the only published version. - - All files in the target version (primary and translations) → `status: "published"` - - All files in other versions that were "published" → `status: "archived"` - - Draft/archived files in other versions keep their current status + - All content in the target version (primary and translations) → `status: "published"` + - All content in other versions that were "published" → `status: "archived"` + - Draft/archived content in other versions keeps its current status ## Options @@ -1462,39 +1801,82 @@ defmodule PhoenixKit.Modules.Publishing do db_post = DBStorage.get_post_by_uuid(post_uuid, [:group]) unless db_post, do: throw({:error, :not_found}) - # Set target version to published, archive previously-published versions - for v <- DBStorage.list_versions(db_post.uuid) do + # Wrap the entire publish operation in a transaction for atomicity + repo = PhoenixKit.RepoHelper.repo() + + tx_result = + repo.transaction(fn -> + # Validate target version exists + versions = DBStorage.list_versions(db_post.uuid) + + unless Enum.any?(versions, &(&1.version_number == version)) do + repo.rollback(:version_not_found) + end + + # Set target version to published, archive previously-published versions + # Also update content status to match so public rendering works correctly + update_version_statuses!(repo, versions, version) + + # Update post status and published_at + update_post_published!(repo, db_post) + end) + + case tx_result do + {:ok, _} -> + source_id = Keyword.get(opts, :source_id) + broadcast_id = db_post.slug || db_post.uuid + ListingCache.regenerate(group_slug) + PublishingPubSub.broadcast_version_live_changed(group_slug, broadcast_id, version) + + PublishingPubSub.broadcast_post_version_published( + group_slug, + broadcast_id, + version, + source_id + ) + + :ok + + {:error, reason} -> + {:error, reason} + end + catch + {:error, reason} = err -> + Logger.warning( + "[Publishing] publish_version failed for #{group_slug}/#{post_uuid}/v#{version}: #{inspect(reason)}" + ) + + err + end + + defp update_version_statuses!(repo, versions, target_version) do + for v <- versions do new_status = cond do - v.version_number == version -> "published" + v.version_number == target_version -> "published" v.status == "published" -> "archived" true -> v.status end - if new_status != v.status, do: DBStorage.update_version(v, %{status: new_status}) - end - - # Update post status and published_at - DBStorage.update_post(db_post, %{ - status: "published", - published_at: db_post.published_at || UtilsDate.utc_now() - }) - - source_id = Keyword.get(opts, :source_id) - broadcast_id = db_post.slug || db_post.uuid - ListingCache.regenerate(group_slug) - PublishingPubSub.broadcast_version_live_changed(group_slug, broadcast_id, version) + if new_status != v.status do + case DBStorage.update_version(v, %{status: new_status}) do + {:ok, _} -> :ok + {:error, reason} -> repo.rollback(reason) + end - PublishingPubSub.broadcast_post_version_published( - group_slug, - broadcast_id, - version, - source_id - ) + DBStorage.update_content_status(v.uuid, new_status) + end + end + end - :ok - catch - {:error, _} = err -> err + defp update_post_published!(repo, db_post) do + case DBStorage.update_post(db_post, %{ + status: "published", + published_at: db_post.published_at || UtilsDate.utc_now() + }) do + {:ok, _} -> :ok + {:error, reason} -> repo.rollback(reason) + end end @doc """ @@ -1569,7 +1951,12 @@ defmodule PhoenixKit.Modules.Publishing do end end catch - {:error, reason} -> {:error, reason} + {:error, reason} -> + Logger.warning( + "[Publishing] create_version failed for #{group_slug}/#{post_uuid}: #{inspect(reason)}" + ) + + {:error, reason} end @doc false @@ -1599,7 +1986,8 @@ defmodule PhoenixKit.Modules.Publishing do """ @spec set_translation_status(String.t(), String.t(), integer(), String.t(), String.t()) :: :ok | {:error, any()} - def set_translation_status(group_slug, post_identifier, version, language, status) do + def set_translation_status(group_slug, post_identifier, version, language, status) + when status in ["draft", "published", "archived"] do db_post = if uuid_format?(post_identifier) do DBStorage.get_post_by_uuid(post_identifier) @@ -1623,11 +2011,8 @@ defmodule PhoenixKit.Modules.Publishing do end end - @doc false - @deprecated "Use publish_version/3 instead" - @spec set_version_live(String.t(), String.t(), integer()) :: :ok | {:error, any()} - def set_version_live(group_slug, post_slug, version) do - publish_version(group_slug, post_slug, version) + def set_translation_status(_group_slug, _post_identifier, _version, _language, _status) do + {:error, :invalid_status} end @doc """ @@ -1684,8 +2069,8 @@ defmodule PhoenixKit.Modules.Publishing do @doc """ Deletes a specific language translation from a post. - For versioned posts, specify the version. For legacy posts, version is ignored. - Refuses to delete the last remaining language file. + For versioned posts, specify the version. For unversioned posts, version is ignored. + Refuses to delete the last remaining language content. Returns :ok on success or {:error, reason} on failure. """ @@ -1716,7 +2101,12 @@ defmodule PhoenixKit.Modules.Publishing do nil -> {:error, :not_found} end catch - {:error, _} = err -> err + {:error, reason} = err -> + Logger.warning( + "[Publishing] delete_language failed for #{group_slug}/#{post_uuid}/#{language_code}: #{inspect(reason)}" + ) + + err end @doc """ @@ -1755,21 +2145,13 @@ defmodule PhoenixKit.Modules.Publishing do nil -> {:error, :not_found} end catch - {:error, _} = err -> err - end - - # Legacy wrappers (deprecated) - def list_entries(group_slug, preferred_language \\ nil), - do: list_posts(group_slug, preferred_language) - - def create_entry(group_slug), do: create_post(group_slug) - - def read_entry(group_slug, relative_path), do: read_post(group_slug, relative_path) - - def update_entry(group_slug, post, params), do: update_post(group_slug, post, params) + {:error, reason} = err -> + Logger.warning( + "[Publishing] delete_version failed for #{group_slug}/#{post_uuid}/v#{version}: #{inspect(reason)}" + ) - def add_language_to_entry(group_slug, post_path, language_code), - do: add_language_to_post(group_slug, post_path, language_code) + err + end @doc """ Generates a slug from a user-provided group name. @@ -1804,7 +2186,12 @@ defmodule PhoenixKit.Modules.Publishing do 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 @@ -1812,7 +2199,7 @@ defmodule PhoenixKit.Modules.Publishing do # Determines if a post update should trigger cache regeneration. # For versioned posts (slug mode with version info), only regenerate if the post is published. - # For non-versioned posts (timestamp mode or legacy), always regenerate. + # For non-versioned posts (timestamp mode), always regenerate. defp should_regenerate_cache?(post) do mode = Map.get(post, :mode) metadata = Map.get(post, :metadata, %{}) @@ -1822,7 +2209,7 @@ defmodule PhoenixKit.Modules.Publishing do cond do # Timestamp mode posts always regenerate (no versioning) mode == :timestamp -> true - # Slug mode posts without version info (legacy) always regenerate + # Slug mode posts without version info always regenerate is_nil(version) -> true # Slug mode posts: always regenerate to keep language_slugs current # The cache stores url_slugs for ALL translations, so any edit @@ -1836,9 +2223,8 @@ defmodule PhoenixKit.Modules.Publishing do end defp settings_module do - # Check new key first, fall back to legacy key for backward compatibility case PhoenixKit.Config.get(:publishing_settings_module) do - :not_found -> PhoenixKit.Config.get(:blogging_settings_module, PhoenixKit.Settings) + :not_found -> PhoenixKit.Settings {:ok, module} -> module end end @@ -1860,49 +2246,6 @@ defmodule PhoenixKit.Modules.Publishing do end end - defp normalize_groups(groups) do - groups - |> Enum.map(&normalize_group_keys/1) - |> Enum.map(fn group -> - group - |> ensure_mode() - |> ensure_type() - end) - end - - defp ensure_mode(%{"mode" => mode} = group) when mode in ["timestamp", "slug"], do: group - defp ensure_mode(group), do: Map.put(group, "mode", @default_group_mode) - - # Ensure type field exists, defaulting to "blogging" for backward compatibility - # Also ensures item_singular and item_plural are set based on the type - defp ensure_type(group) do - type = Map.get(group, "type") - type = if is_binary(type) and type != "", do: type, else: @default_group_type - - {default_singular, default_plural} = - Map.get(@type_item_names, type, {@default_item_singular, @default_item_plural}) - - group - |> Map.put("type", type) - |> Map.put_new("item_singular", default_singular) - |> Map.put_new("item_plural", default_plural) - end - - defp normalize_group_keys(group) when is_map(group) do - Enum.reduce(group, %{}, fn - {key, value}, acc when is_binary(key) -> - Map.put(acc, key, value) - - {key, value}, acc when is_atom(key) -> - Map.put(acc, Atom.to_string(key), value) - - {key, value}, acc -> - Map.put(acc, to_string(key), value) - end) - end - - defp normalize_group_keys(other), do: other - defp normalize_mode(mode) when is_binary(mode) do mode |> String.downcase() @@ -2005,26 +2348,22 @@ defmodule PhoenixKit.Modules.Publishing do @doc """ Returns the preset content types with their default item names. - - ## Examples - - iex> Publishing.preset_types() - [ - %{type: "blogging", 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"} - ] """ @spec preset_types() :: [map()] def preset_types do [ - # Note: type value "blogging" kept for backward compatibility, label is "Blog" - %{type: "blogging", label: "Blog", item_singular: "post", item_plural: "posts"}, + %{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 + @doc false def fetch_option(opts, key) when is_map(opts) do Map.get(opts, key) || Map.get(opts, Atom.to_string(key)) @@ -2081,17 +2420,15 @@ defmodule PhoenixKit.Modules.Publishing do defp maybe_put_audit(map, _key, nil), do: map defp maybe_put_audit(map, key, value), do: Map.put(map, key, value) - defp persist_group_update(groups, slug, updated_group) do - updated = - Enum.map(groups, fn - %{"slug" => ^slug} -> updated_group - other -> other - end) - - settings_call(:update_json_setting, [ - @publishing_groups_key, - %{"publishing_groups" => updated} - ]) + defp db_group_to_map(%{name: name, slug: slug, mode: mode, data: data}) do + %{ + "name" => name, + "slug" => slug, + "mode" => mode || @default_group_mode, + "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 @@ -2178,7 +2515,7 @@ defmodule PhoenixKit.Modules.Publishing do |> case do nil -> nil <<>> -> nil - lang_file -> lang_file + lang_code -> lang_code end {slug, version, language} @@ -2223,7 +2560,7 @@ defmodule PhoenixKit.Modules.Publishing do This creates a background job that will: 1. Read the source post in the primary language 2. Translate the content to each target language using the AI module - 3. Create or update translation files for each language + 3. Create or update translation content for each language ## Options @@ -2266,49 +2603,4 @@ defmodule PhoenixKit.Modules.Publishing do def translate_post_to_all_languages(group_slug, post_uuid, opts \\ []) do TranslatePostWorker.enqueue(group_slug, post_uuid, opts) end - - # ============================================================================ - # Backward compatibility aliases (deprecated) - # These functions delegate to the new "group" terminology functions - # ============================================================================ - - @doc false - @deprecated "Use list_groups/0 instead" - def list_blogs, do: list_groups() - - @doc false - @deprecated "Use get_group/1 instead" - def get_blog(slug), do: get_group(slug) - - @doc false - @deprecated "Use add_group/2 instead" - def add_blog(name, opts \\ []), do: add_group(name, opts) - - @doc false - @deprecated "Use remove_group/1 instead" - def remove_blog(slug), do: remove_group(slug) - - @doc false - @deprecated "Use update_group/2 instead" - def update_blog(slug, params), do: update_group(slug, params) - - @doc false - @deprecated "Use trash_group/1 instead" - def trash_blog(slug), do: trash_group(slug) - - @doc false - @deprecated "Use group_name/1 instead" - def blog_name(slug), do: group_name(slug) - - @doc false - @deprecated "Use get_group_mode/1 instead" - def get_blog_mode(group_slug), do: get_group_mode(group_slug) - - @doc false - @deprecated "Use legacy_group?/1 instead" - def legacy_blog?(group_slug), do: legacy_group?(group_slug) - - @doc false - @deprecated "Use has_legacy_groups?/0 instead" - def has_legacy_blogs?, do: has_legacy_groups?() end diff --git a/lib/modules/publishing/pubsub.ex b/lib/modules/publishing/pubsub.ex index cfd971653..8cca35d80 100644 --- a/lib/modules/publishing/pubsub.ex +++ b/lib/modules/publishing/pubsub.ex @@ -62,31 +62,6 @@ defmodule PhoenixKit.Modules.Publishing.PubSub do Manager.broadcast(groups_topic(), {:group_updated, group}) end - # Backward compatibility aliases - @doc false - @deprecated "Use groups_topic/0 instead" - def blogs_topic, do: groups_topic() - - @doc false - @deprecated "Use subscribe_to_groups/0 instead" - def subscribe_to_blogs, do: subscribe_to_groups() - - @doc false - @deprecated "Use unsubscribe_from_groups/0 instead" - def unsubscribe_from_blogs, do: unsubscribe_from_groups() - - @doc false - @deprecated "Use broadcast_group_created/1 instead" - def broadcast_blog_created(group), do: broadcast_group_created(group) - - @doc false - @deprecated "Use broadcast_group_deleted/1 instead" - def broadcast_blog_deleted(group_slug), do: broadcast_group_deleted(group_slug) - - @doc false - @deprecated "Use broadcast_group_updated/1 instead" - def broadcast_blog_updated(group), do: broadcast_group_updated(group) - # ============================================================================ # Post List Updates (simple refresh) # ============================================================================ @@ -491,19 +466,6 @@ defmodule PhoenixKit.Modules.Publishing.PubSub do ) end - # Deprecated shims for backward compatibility - @doc false - @deprecated "Use group_editors_topic/1 instead" - def blog_editors_topic(group_slug), do: group_editors_topic(group_slug) - - @doc false - @deprecated "Use subscribe_to_group_editors/1 instead" - def subscribe_to_blog_editors(group_slug), do: subscribe_to_group_editors(group_slug) - - @doc false - @deprecated "Use unsubscribe_from_group_editors/1 instead" - def unsubscribe_from_blog_editors(group_slug), do: unsubscribe_from_group_editors(group_slug) - # ============================================================================ # Bulk Operations Progress # ============================================================================ diff --git a/lib/modules/publishing/renderer.ex b/lib/modules/publishing/renderer.ex index fcfc7e660..12391e3ad 100644 --- a/lib/modules/publishing/renderer.ex +++ b/lib/modules/publishing/renderer.ex @@ -18,14 +18,9 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do @cache_name :publishing_posts @cache_version "v1" - # New settings keys (write to these) @global_cache_key "publishing_render_cache_enabled" @per_group_cache_prefix "publishing_render_cache_enabled_" - # Legacy settings keys (read from these as fallback) - @legacy_global_cache_key "blogging_render_cache_enabled" - @legacy_per_group_cache_prefix "blogging_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 @@ -65,7 +60,6 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do Checks both the global setting and per-group setting. Both must be enabled (or default to enabled) for caching to work. - Checks new keys first, falls back to legacy keys. """ @spec render_cache_enabled?(String.t()) :: boolean() def render_cache_enabled?(group_slug) do @@ -77,30 +71,20 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do @doc """ Returns whether the global render cache is enabled. - Checks new key first, falls back to legacy key. """ @spec global_render_cache_enabled?() :: boolean() def global_render_cache_enabled? do - case Settings.get_setting_cached(@global_cache_key, nil) do - nil -> Settings.get_setting_cached(@legacy_global_cache_key, "true") == "true" - value -> value == "true" - end + 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. - Checks new key first, falls back to legacy key. """ @spec group_render_cache_enabled?(String.t()) :: boolean() def group_render_cache_enabled?(group_slug) do - new_key = @per_group_cache_prefix <> group_slug - legacy_key = @legacy_per_group_cache_prefix <> group_slug - - case Settings.get_setting_cached(new_key, nil) do - nil -> Settings.get_setting_cached(legacy_key, "true") == "true" - value -> value == "true" - end + key = @per_group_cache_prefix <> group_slug + Settings.get_setting_cached(key, "true") == "true" end @doc """ @@ -110,15 +94,6 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do @spec per_group_cache_key(String.t()) :: String.t() def per_group_cache_key(group_slug), do: @per_group_cache_prefix <> group_slug - # Deprecated shims for backward compatibility - @doc false - @deprecated "Use group_render_cache_enabled?/1 instead" - def blog_render_cache_enabled?(group_slug), do: group_render_cache_enabled?(group_slug) - - @doc false - @deprecated "Use per_group_cache_key/1 instead" - def per_blog_cache_key(group_slug), do: per_group_cache_key(group_slug) - @doc """ Renders markdown or PHK content directly without caching. @@ -396,7 +371,7 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do def invalidate_cache(group_slug, identifier, language) do # Build pattern to match all cache keys for this post # We don't know the content hash, so we invalidate by prefix - pattern = "#{@cache_version}:blog_post:#{group_slug}:#{identifier}:#{language}:" + pattern = "#{@cache_version}:publishing_post:#{group_slug}:#{identifier}:#{language}:" # Since PhoenixKit.Cache doesn't support pattern matching, # we'll just log this for now and rely on content hash changes @@ -440,7 +415,7 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do """ @spec clear_group_cache(String.t()) :: {:ok, non_neg_integer()} | {:error, any()} def clear_group_cache(group_slug) do - prefix = "#{@cache_version}:blog_post:#{group_slug}:" + prefix = "#{@cache_version}:publishing_post:#{group_slug}:" case PhoenixKit.Cache.clear_by_prefix(@cache_name, prefix) do {:ok, count} = result -> @@ -456,10 +431,6 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do {:ok, 0} end - @doc false - @deprecated "Use clear_group_cache/1 instead" - def clear_blog_cache(group_slug), do: clear_group_cache(group_slug) - # Private Functions defp render_and_cache(post, cache_key) do @@ -482,7 +453,7 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do identifier = post[:uuid] || post.slug - "#{@cache_version}:blog_post:#{post.group}:#{identifier}:#{post.language}:#{content_hash}" + "#{@cache_version}:publishing_post:#{post.group}:#{identifier}:#{post.language}:#{content_hash}" end defp get_cached(key) do diff --git a/lib/modules/publishing/schemas/publishing_group.ex b/lib/modules/publishing/schemas/publishing_group.ex index 9cbf8c003..a668aae4b 100644 --- a/lib/modules/publishing/schemas/publishing_group.ex +++ b/lib/modules/publishing/schemas/publishing_group.ex @@ -7,7 +7,7 @@ defmodule PhoenixKit.Modules.Publishing.PublishingGroup do ## Data JSONB Keys - - `type` - Group type: "blogging", "faq", "legal", or custom string + - `type` - Group type: "blog", "faq", "legal", or "custom" - `item_singular` - Display name for single item (e.g., "Post", "Article") - `item_plural` - Display name for multiple items (e.g., "Posts", "Articles") - `description` - Group description @@ -63,8 +63,8 @@ defmodule PhoenixKit.Modules.Publishing.PublishingGroup do # Data JSONB accessors - @doc "Returns the group type from data (blogging/faq/legal/custom)." - def get_type(%__MODULE__{data: data}), do: Map.get(data, "type", "blogging") + @doc "Returns the group type from data (blog/faq/legal/custom)." + def get_type(%__MODULE__{data: data}), do: Map.get(data, "type", "blog") @doc "Returns the singular item name (e.g., 'Post')." def get_item_singular(%__MODULE__{data: data}), do: Map.get(data, "item_singular", "Post") diff --git a/lib/modules/publishing/schemas/publishing_post.ex b/lib/modules/publishing/schemas/publishing_post.ex index 4dd94ba7e..9e5783a99 100644 --- a/lib/modules/publishing/schemas/publishing_post.ex +++ b/lib/modules/publishing/schemas/publishing_post.ex @@ -15,7 +15,7 @@ defmodule PhoenixKit.Modules.Publishing.PublishingPost do ## Data JSONB Keys - `allow_version_access` - Whether older versions are publicly accessible - - `featured_image` - Featured image reference (file UUID or URL) + - `featured_image` - Featured image reference (media UUID or URL) - `tags` - List of tag strings - `seo` - SEO metadata map (og_title, og_description, og_image, etc.) """ diff --git a/lib/modules/publishing/web/components/language_switcher.ex b/lib/modules/publishing/web/components/language_switcher.ex index 10288d6c1..bde7b787d 100644 --- a/lib/modules/publishing/web/components/language_switcher.ex +++ b/lib/modules/publishing/web/components/language_switcher.ex @@ -255,17 +255,14 @@ defmodule PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher do defp lang_exists?(%{"status" => status}) when is_binary(status), do: true defp lang_exists?(_), do: false - # Check if language is enabled in the Languages module (default: true for backwards compat) defp lang_enabled?(%{enabled: enabled}) when is_boolean(enabled), do: enabled defp lang_enabled?(%{"enabled" => enabled}) when is_boolean(enabled), do: enabled defp lang_enabled?(_), do: true - # Check if language code is a recognized/known language (default: true for backwards compat) defp lang_known?(%{known: known}) when is_boolean(known), do: known defp lang_known?(%{"known" => known}) when is_boolean(known), do: known defp lang_known?(_), do: true - # Check if language is the primary language (default: false for backwards compat) defp lang_is_primary?(%{is_primary: is_primary}) when is_boolean(is_primary), do: is_primary defp lang_is_primary?(%{"is_primary" => is_primary}) when is_boolean(is_primary), do: is_primary defp lang_is_primary?(_), do: false @@ -419,7 +416,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher do # Build title for admin mode with status indicators defp build_admin_title(name, exists, status, enabled, known) do cond do - !known and exists -> gettext("%{language} (Unknown language file)", language: name) + !known and exists -> gettext("%{language} (Unknown language)", language: name) !enabled and exists -> build_disabled_title(name, status) !exists -> gettext("Add %{language} translation", language: name) true -> build_status_title(name, status) diff --git a/lib/modules/publishing/web/controller.ex b/lib/modules/publishing/web/controller.ex index 48da425e0..a9fe7692a 100644 --- a/lib/modules/publishing/web/controller.ex +++ b/lib/modules/publishing/web/controller.ex @@ -274,13 +274,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do # ============================================================================ defp public_enabled? do - # Check new key first, fallback to legacy - case Settings.get_setting("publishing_public_enabled") do - nil -> Settings.get_boolean_setting("blogging_public_enabled", true) - "true" -> true - "false" -> false - _ -> true - end + Settings.get_boolean_setting("publishing_public_enabled", true) end defp set_gettext_locale(language) do diff --git a/lib/modules/publishing/web/controller/fallback.ex b/lib/modules/publishing/web/controller/fallback.ex index 84d20daf9..73ae2870d 100644 --- a/lib/modules/publishing/web/controller/fallback.ex +++ b/lib/modules/publishing/web/controller/fallback.ex @@ -56,19 +56,20 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do # Fallback Case Handlers # ============================================================================ - # Slug mode posts (2-element path) - try other languages before group listing + # Slug mode posts (2-element path) - try other languages, then group listing defp handle_fallback_case(reason, [group_slug, post_slug], language) - when reason in [:post_not_found, :unpublished] do + when reason in [:post_not_found, :unpublished, :not_found, :version_access_disabled] do fallback_to_default_language(group_slug, post_slug, language) end - # Timestamp mode posts (3-element path) - try other languages before group listing + # Timestamp mode posts (3-element path) - try other languages, then group listing defp handle_fallback_case(reason, [group_slug, date, time], language) - when reason in [:post_not_found, :unpublished] do + when reason in [:post_not_found, :unpublished, :not_found, :version_access_disabled] do fallback_timestamp_to_other_language(group_slug, date, time, language) end - defp handle_fallback_case(:group_not_found, [_group_slug], language) do + # Group not found with a path - try default group + defp handle_fallback_case(:group_not_found, [_group_slug | _], language) do fallback_to_default_group(language) end @@ -76,6 +77,16 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do fallback_to_default_group(language) end + # Any post-level error with a 2+ segment path — fall back to group listing + # Catches errors like :invalid_version, unknown reasons from read_post, etc. + defp handle_fallback_case(_reason, [group_slug | _rest], language) do + if group_exists?(group_slug) do + {:ok, PublishingHTML.group_listing_path(language, group_slug)} + else + fallback_to_default_group(language) + end + end + defp handle_fallback_case(_reason, _path, _language), do: :no_fallback # ============================================================================ @@ -86,7 +97,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do if group_exists?(group_slug) do find_any_available_language_version(group_slug, post_slug, requested_language) else - :no_fallback + fallback_to_default_group(requested_language) end end @@ -135,6 +146,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do end # Tries other languages when requested language has no published versions + # Cap on how many languages to try in fallback chain to prevent excessive DB queries + @max_fallback_languages 5 + defp try_other_languages(group_slug, post_slug, post, requested_language, default_lang) do available = post.available_languages || [] @@ -142,6 +156,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do languages_to_try = ([default_lang | available] -- [requested_language]) |> Enum.uniq() + |> Enum.take(@max_fallback_languages) find_first_published_version(group_slug, post_slug, post, languages_to_try, default_lang) end @@ -196,7 +211,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do # Use DB to get available languages for this timestamp post available = get_available_languages_for_timestamp(group_slug, date, time) - # Time exists with language files - try other languages + # Time exists with language content - try other languages if available != [] do languages_to_try = ([default_lang | available] -- [requested_language]) @@ -215,7 +230,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do fallback_to_other_time_on_date(group_slug, date, time, default_lang) end else - :no_fallback + fallback_to_default_group(requested_language) end end @@ -311,12 +326,19 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do # Gets available languages for a timestamp post from the DB defp get_available_languages_for_timestamp(group_slug, date, time) do - identifier = "#{date}/#{time}" posts = Publishing.list_posts(group_slug, nil) - case Enum.find(posts, fn p -> "#{p.date}/#{p.time}" == identifier or p.slug == identifier end) do + case Enum.find(posts, fn p -> format_date(p.date) == date and format_time(p.time) == time end) do nil -> [] post -> post.available_languages || [] end end + + defp format_date(%Date{} = d), do: Date.to_iso8601(d) + defp format_date(d) when is_binary(d), do: d + defp format_date(_), do: "" + + defp format_time(%Time{} = t), do: t |> Time.to_string() |> String.slice(0, 5) + defp format_time(t) when is_binary(t), do: String.slice(t, 0, 5) + defp format_time(_), do: "" end diff --git a/lib/modules/publishing/web/controller/language.ex b/lib/modules/publishing/web/controller/language.ex index aa496ad76..50577ab07 100644 --- a/lib/modules/publishing/web/controller/language.ex +++ b/lib/modules/publishing/web/controller/language.ex @@ -3,7 +3,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do Language detection and resolution for the publishing controller. Handles detecting whether URL parameters represent language codes, - resolving language codes to file languages, and determining + resolving language codes to content languages, and determining canonical URL language codes. """ @@ -209,7 +209,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do @doc """ Gets the canonical URL language code for a post's language. - This uses the actual file language (e.g., "en-US") to determine the canonical URL code. + This uses the actual content language (e.g., "en-US") to determine the canonical URL code. """ def get_canonical_url_language_for_post(post_language) do enabled_languages = get_enabled_languages() diff --git a/lib/modules/publishing/web/controller/listing.ex b/lib/modules/publishing/web/controller/listing.ex index 5e1c000d5..507c3f8b9 100644 --- a/lib/modules/publishing/web/controller/listing.ex +++ b/lib/modules/publishing/web/controller/listing.ex @@ -127,13 +127,16 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do """ def render_group_index(_conn, ctx, all_posts) do total_count = length(all_posts) + per_page = max(ctx.per_page, 1) + total_pages = if total_count > 0, do: ceil(total_count / per_page), else: 0 + page = min(ctx.page, max(total_pages, 1)) posts = all_posts - |> paginate(ctx.page, ctx.per_page) + |> paginate(page, per_page) |> resolve_posts_for_language(ctx.canonical_language) - breadcrumbs = [%{label: ctx.group["name"], url: nil}] + breadcrumbs = [%{label: ctx.group["name"] || ctx.group_slug, url: nil}] translations = Translations.build_listing_translations( @@ -144,15 +147,15 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do {:ok, %{ - page_title: ctx.group["name"], + page_title: ctx.group["name"] || ctx.group_slug, group: ctx.group, posts: posts, current_language: ctx.canonical_language, translations: translations, - page: ctx.page, - per_page: ctx.per_page, + page: page, + per_page: per_page, total_count: total_count, - total_pages: ceil(total_count / ctx.per_page), + total_pages: total_pages, breadcrumbs: breadcrumbs }} end @@ -173,12 +176,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do defp resolve_post_for_language(post, language) do lang_titles = post[:language_titles] || %{} lang_excerpts = post[:language_excerpts] || %{} + metadata = post[:metadata] || %{} - title = Map.get(lang_titles, language, post.metadata.title) + title = Map.get(lang_titles, language, metadata[:title] || "Untitled") excerpt = Map.get(lang_excerpts, language) post - |> put_in([:metadata, :title], title) + |> Map.update(:metadata, %{title: title}, &Map.put(&1, :title, title)) |> then(fn p -> if excerpt, do: Map.put(p, :content, excerpt), else: p end) @@ -193,23 +197,22 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do """ def filter_published(posts) do Enum.filter(posts, fn post -> - post.metadata.status == "published" + post[:metadata] && post.metadata.status == "published" end) end @doc """ - Filter posts to only include those that have a matching language file. + Filter posts to only include those that have matching language content. Handles both exact matches and base code matches (e.g., "en" matches "en-US"). - Uses preloaded language_statuses to avoid redundant file reads. + Uses preloaded language_statuses to avoid redundant queries. """ def filter_by_exact_language(posts, _group_slug, language) do Enum.filter(posts, fn post -> - # Find the matching language file (exact or base code match) - matching_language = find_matching_language(language, post.available_languages) + available = post[:available_languages] || [] + statuses = post[:language_statuses] || %{} + matching_language = find_matching_language(language, available) - # Use preloaded status from language_statuses map - matching_language != nil and - Map.get(post.language_statuses, matching_language) == "published" + matching_language != nil and Map.get(statuses, matching_language) == "published" end) end @@ -218,8 +221,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do """ def filter_by_exact_language_strict(posts, language) do Enum.filter(posts, fn post -> - language in post.available_languages and - Map.get(post.language_statuses, language) == "published" + available = post[:available_languages] || [] + statuses = post[:language_statuses] || %{} + + language in available and Map.get(statuses, language) == "published" end) end @@ -296,12 +301,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do Gets the posts per page setting. """ def get_per_page_setting do - # Check new key first, fallback to legacy - value = - case Settings.get_setting_cached("publishing_posts_per_page") do - nil -> Settings.get_setting_cached("blogging_posts_per_page") - v -> v - end + value = Settings.get_setting_cached("publishing_posts_per_page") case value do nil -> diff --git a/lib/modules/publishing/web/controller/post_fetching.ex b/lib/modules/publishing/web/controller/post_fetching.ex index e3e8aa537..c2aa9a583 100644 --- a/lib/modules/publishing/web/controller/post_fetching.ex +++ b/lib/modules/publishing/web/controller/post_fetching.ex @@ -76,8 +76,15 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostFetching do # Read from freshly populated cache case ListingCache.read(group_slug) do - {:ok, posts} -> posts - {:error, _} -> Publishing.list_posts(group_slug, nil) + {:ok, posts} -> + posts + + {:error, reason} -> + Logger.warning( + "[PublishingController] Cache read failed after regeneration for #{group_slug}: #{inspect(reason)}" + ) + + Publishing.list_posts(group_slug, nil) end :already_in_progress -> diff --git a/lib/modules/publishing/web/controller/post_rendering.ex b/lib/modules/publishing/web/controller/post_rendering.ex index d957cb475..d0f16d09c 100644 --- a/lib/modules/publishing/web/controller/post_rendering.ex +++ b/lib/modules/publishing/web/controller/post_rendering.ex @@ -79,7 +79,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do {:ok, %{ - page_title: post.metadata.title, + page_title: post.metadata.title || "Untitled", group_slug: group_slug, post: post, html_content: html_content, @@ -143,7 +143,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do {:ok, %{ - page_title: post.metadata.title, + page_title: post.metadata.title || "Untitled", group_slug: group_slug, post: post, html_content: html_content, @@ -231,10 +231,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do # The cache stores the live version with all version metadata {allow_access, live_version} = get_cached_version_info(group_slug, post) - version_statuses = Map.get(post, :version_statuses, %{}) + version_statuses = Map.get(post, :version_statuses) || %{} current_version = Map.get(post, :version, 1) - if allow_access and map_size(version_statuses) > 0 do + if allow_access and version_statuses != %{} do # Filter to only published versions published_versions = version_statuses diff --git a/lib/modules/publishing/web/controller/translations.ex b/lib/modules/publishing/web/controller/translations.ex index e243074e2..d32089ea4 100644 --- a/lib/modules/publishing/web/controller/translations.ex +++ b/lib/modules/publishing/web/controller/translations.ex @@ -125,10 +125,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Translations do translation_published_exact?(group_slug, post, lang) end) - # Remove legacy base code files when dialect files exist + # Remove legacy base codes when dialect content exists # e.g., if both "en" and "en-CA" exist, remove "en" to avoid duplicates deduplicated = - deduplicate_base_and_dialect_files(available_and_published, enabled_languages) + deduplicate_base_and_dialect_codes(available_and_published, enabled_languages) # Order: primary first (if present), then enabled languages, then disabled ones languages = order_languages_for_public(deduplicated, enabled_languages, primary_language) @@ -182,13 +182,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Translations do primary ++ Enum.sort(other_enabled) ++ Enum.sort(disabled) end - # Remove legacy base code files when dialect files of the same language exist + # Remove legacy base codes when dialect content of the same language exists # This prevents showing both "en" and "en-CA" in the switcher - defp deduplicate_base_and_dialect_files(languages, _enabled_languages) do + defp deduplicate_base_and_dialect_codes(languages, _enabled_languages) do # Separate base codes and dialect codes {base_codes, dialect_codes} = Enum.split_with(languages, &Language.base_code?/1) - # For each base code, check if any dialect files exist for it + # For each base code, check if any dialect content exists for it # If so, exclude the base code filtered_base_codes = Enum.reject(base_codes, fn base -> @@ -251,7 +251,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Translations do # Strict check for public display - only shows languages that are: # 1. Directly in the enabled languages list, OR - # 2. Base code files where any dialect of that base is enabled + # 2. Base codes where any dialect of that base is enabled # This prevents showing en-US, en-GB etc when only en-CA is enabled defp language_enabled_for_public?(language, enabled_languages) do cond do diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index 45cd8abe7..6428e9213 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -82,7 +82,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do live_source = socket.id || - "blog-editor-" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false) + "publishing-editor-" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false) socket = socket @@ -172,7 +172,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do def handle_params(%{"preview_token" => token} = params, uri, socket) do endpoint = socket.endpoint || PhoenixKitWeb.Endpoint - case Phoenix.Token.verify(endpoint, "blog-preview", token, max_age: 300) do + case Phoenix.Token.verify(endpoint, "publishing-preview", token, max_age: 300) do {:ok, data} -> old_form_key = socket.assigns[:form_key] old_post_slug = socket.assigns[:post] && socket.assigns.post[:slug] @@ -205,7 +205,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do {:noreply, socket} - {:error, _reason} -> + {:error, reason} -> + Logger.warning( + "[Publishing.Editor] Preview token verification failed: #{inspect(reason)}" + ) + handle_params(Map.delete(params, "preview_token"), uri, socket) end end @@ -1008,7 +1012,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do def handle_event("preview", _params, socket) do preview_payload = Preview.build_preview_payload(socket) endpoint = socket.endpoint || PhoenixKitWeb.Endpoint - token = Phoenix.Token.sign(endpoint, "blog-preview", preview_payload, max_age: 300) + token = Phoenix.Token.sign(endpoint, "publishing-preview", preview_payload, max_age: 300) query_params = Preview.build_preview_query_params(preview_payload, token) @@ -1065,6 +1069,15 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do else {:noreply, assign(socket, :autosave_timer, nil)} end + rescue + e -> + Logger.error("[Publishing.Editor] Autosave failed: #{Exception.message(e)}") + + {:noreply, + socket + |> assign(:is_autosaving, false) + |> assign(:autosave_timer, nil) + |> push_event("autosave-status", %{saving: false})} end # ============================================================================ @@ -1489,16 +1502,25 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do end defp re_read_post(socket) do - post = socket.assigns.post - Publishing.read_post_by_uuid(post.uuid) + case socket.assigns[:post] do + nil -> {:error, :no_post} + %{uuid: nil} -> {:error, :no_uuid} + post -> Publishing.read_post_by_uuid(post.uuid) + end end defp do_switch_language(socket, new_language) do + # Cancel any pending autosave before switching language context + if timer = socket.assigns[:autosave_timer] do + Process.cancel_timer(timer) + end + + socket = assign(socket, :autosave_timer, nil) post = socket.assigns.post group_slug = socket.assigns.group_slug - file_exists = new_language in post.available_languages + content_exists = new_language in post.available_languages - if file_exists do + if content_exists do switch_to_existing_language(socket, group_slug, new_language) else switch_to_new_translation(socket, post, group_slug, new_language) diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index deefcc79f..0ac260016 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -951,7 +951,7 @@ class="w-3 h-3 transition-transform [[open]>&]:rotate-90" /> - {gettext("Advanced: Manual File ID")} + {gettext("Advanced: Manual Media ID")} @@ -1036,7 +1036,7 @@ />

{gettext( - "Updating the publication time moves the file to the matching folder." + "Updating the publication time changes the post URL to match the new date." )}

diff --git a/lib/modules/publishing/web/editor/forms.ex b/lib/modules/publishing/web/editor/forms.ex index 58c76af64..3a2fcdd84 100644 --- a/lib/modules/publishing/web/editor/forms.ex +++ b/lib/modules/publishing/web/editor/forms.ex @@ -28,7 +28,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do Build form for a post, handling new translations appropriately. For new translations (no content in DB yet), inherits status from the primary language. - For existing files, uses the file's own status to avoid confusion between + For existing content, uses the record's own status to avoid confusion between what the dropdown shows and what the language switcher shows. """ def post_form_with_primary_status(_group_slug, post, version) do diff --git a/lib/modules/publishing/web/editor/helpers.ex b/lib/modules/publishing/web/editor/helpers.ex index aa0a1fbfd..80ecd9c08 100644 --- a/lib/modules/publishing/web/editor/helpers.ex +++ b/lib/modules/publishing/web/editor/helpers.ex @@ -112,7 +112,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do Enum.map(all_languages, fn lang_code -> lang_info = Publishing.get_language_info(lang_code) - file_exists = lang_code in (post.available_languages || []) + content_exists = lang_code in (post.available_languages || []) is_current = lang_code == current_language is_enabled = Publishing.language_enabled?(lang_code, enabled_languages) is_known = lang_info != nil @@ -127,7 +127,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do name: if(lang_info, do: lang_info.name, else: lang_code), flag: if(lang_info, do: lang_info.flag, else: ""), status: status, - exists: file_exists, + exists: content_exists, is_current: is_current, enabled: is_enabled, known: is_known, @@ -182,7 +182,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do end @doc """ - Gets the URL for a file from storage. + Gets the URL for a media asset from storage. """ def get_file_url(file_uuid) do URLSigner.signed_url(file_uuid, "original") diff --git a/lib/modules/publishing/web/editor/persistence.ex b/lib/modules/publishing/web/editor/persistence.ex index 13c9cdc71..945aa99a2 100644 --- a/lib/modules/publishing/web/editor/persistence.ex +++ b/lib/modules/publishing/web/editor/persistence.ex @@ -259,7 +259,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do Phoenix.LiveView.put_flash( socket, :error, - gettext("Failed to create translation file") + gettext("Failed to create translation") )} end end @@ -540,7 +540,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do )} end - defp handle_post_in_place_error(socket, _reason) do + defp handle_post_in_place_error(socket, reason) do + post_id = socket.assigns[:post] && socket.assigns.post[:uuid] + Logger.warning("[Publishing.Editor] Save failed for post #{post_id}: #{inspect(reason)}") {:noreply, Phoenix.LiveView.put_flash(socket, :error, gettext("Failed to save post"))} end @@ -634,7 +636,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do )} end - defp handle_post_update_error(socket, _reason) do + defp handle_post_update_error(socket, reason) do + post_id = socket.assigns[:post] && socket.assigns.post[:uuid] + Logger.warning("[Publishing.Editor] Update failed for post #{post_id}: #{inspect(reason)}") {:noreply, Phoenix.LiveView.put_flash(socket, :error, "Failed to save post")} end @@ -658,7 +662,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do )} end - defp handle_post_creation_error(socket, _reason, fallback_message) do + defp handle_post_creation_error(socket, reason, fallback_message) do + group = socket.assigns[:group_slug] + Logger.warning("[Publishing.Editor] Post creation failed in #{group}: #{inspect(reason)}") {:noreply, Phoenix.LiveView.put_flash(socket, :error, fallback_message)} end diff --git a/lib/modules/publishing/web/editor/translation.ex b/lib/modules/publishing/web/editor/translation.ex index a1f4c044b..65edff5b9 100644 --- a/lib/modules/publishing/web/editor/translation.ex +++ b/lib/modules/publishing/web/editor/translation.ex @@ -298,8 +298,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Translation do warnings = if source_content_blank?(socket) do [ - {:warning, - gettext("The source content is empty. This will create empty translation files.")} + {:warning, gettext("The source content is empty. This will create empty translations.")} | warnings ] else diff --git a/lib/modules/publishing/web/html.ex b/lib/modules/publishing/web/html.ex index 9d42a6589..7eed1e924 100644 --- a/lib/modules/publishing/web/html.ex +++ b/lib/modules/publishing/web/html.ex @@ -45,6 +45,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do - If multiple posts exist on the date, includes time (e.g., /group/2025-12-09/16:26) """ def build_post_url(group_slug, post, language, date_counts \\ nil) do + language = language || "en" + case post.mode do :slug -> # Use language-specific URL slug for SEO-friendly localized URLs @@ -99,7 +101,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do # Priority: # 1. Direct url_slug field on post (set by controller for specific language) # 2. language_slugs map (from cache, contains all languages) - # 3. metadata.url_slug (from file, current language only) + # 3. metadata.url_slug (from content record, current language only) # 4. post.slug (directory name fallback) defp get_url_slug_for_language(post, language) do cond do diff --git a/lib/modules/publishing/web/index.ex b/lib/modules/publishing/web/index.ex index 0dd4a4686..e556e3997 100644 --- a/lib/modules/publishing/web/index.ex +++ b/lib/modules/publishing/web/index.ex @@ -6,6 +6,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext + require Logger + alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.DBStorage alias PhoenixKit.Modules.Publishing.ListingCache @@ -70,6 +72,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do |> assign(:migration_modal_count, 0) |> assign(:primary_language_name, get_language_name(Publishing.get_primary_language())) |> assign(:migrations_in_progress, %{}) + |> assign(:dashboard_refresh_timer, nil) {:ok, socket} end @@ -79,30 +82,42 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do {:noreply, assign(socket, :endpoint_url, extract_endpoint_url(uri))} end - # PubSub handlers for live updates + # PubSub handlers for live updates — debounced to prevent rapid re-renders + @dashboard_debounce_ms 500 + @impl true - def handle_info({:post_created, _post}, socket), do: {:noreply, refresh_dashboard(socket)} - def handle_info({:post_updated, _post}, socket), do: {:noreply, refresh_dashboard(socket)} + def handle_info({:post_created, _post}, socket), + do: {:noreply, schedule_dashboard_refresh(socket)} + + def handle_info({:post_updated, _post}, socket), + do: {:noreply, schedule_dashboard_refresh(socket)} def handle_info({:post_status_changed, _post}, socket), - do: {:noreply, refresh_dashboard(socket)} + do: {:noreply, schedule_dashboard_refresh(socket)} def handle_info({:post_deleted, _post_identifier}, socket), - do: {:noreply, refresh_dashboard(socket)} + do: {:noreply, schedule_dashboard_refresh(socket)} - def handle_info({:group_created, _group}, socket), do: {:noreply, refresh_dashboard(socket)} + def handle_info({:group_created, _group}, socket), + do: {:noreply, schedule_dashboard_refresh(socket)} def handle_info({:group_deleted, _group_slug}, socket), - do: {:noreply, refresh_dashboard(socket)} + do: {:noreply, schedule_dashboard_refresh(socket)} - def handle_info({:group_updated, _group}, socket), do: {:noreply, refresh_dashboard(socket)} - def handle_info({:version_created, _post}, socket), do: {:noreply, refresh_dashboard(socket)} + def handle_info({:group_updated, _group}, socket), + do: {:noreply, schedule_dashboard_refresh(socket)} + + def handle_info({:version_created, _post}, socket), + do: {:noreply, schedule_dashboard_refresh(socket)} def handle_info({:version_live_changed, _uuid, _version}, socket), - do: {:noreply, refresh_dashboard(socket)} + do: {:noreply, schedule_dashboard_refresh(socket)} def handle_info({:version_deleted, _slug, _version}, socket), - do: {:noreply, refresh_dashboard(socket)} + do: {:noreply, schedule_dashboard_refresh(socket)} + + def handle_info(:debounced_dashboard_refresh, socket), + do: {:noreply, socket |> assign(:dashboard_refresh_timer, nil) |> refresh_dashboard()} # Primary language migration progress handlers def handle_info({:primary_language_migration_started, _group_slug, _total_count}, socket) do @@ -181,7 +196,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do |> assign(:show_migration_modal, true) |> assign(:migration_modal_slug, group_slug) |> assign(:migration_modal_name, group_name) - |> assign(:migration_modal_count, String.to_integer(count))} + |> assign(:migration_modal_count, parse_integer(count, 0))} end def handle_event("close_migration_modal", _params, socket) do @@ -226,6 +241,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do )} {:error, reason} -> + Logger.error( + "[Publishing] Failed to enqueue migration for #{group_slug}: #{inspect(reason)}" + ) + {:noreply, socket |> assign(:show_migration_modal, false) @@ -253,6 +272,14 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do ) )} end + rescue + e -> + Logger.error("[Publishing.Index] Migration failed: #{Exception.message(e)}") + + {:noreply, + socket + |> assign(:show_migration_modal, false) + |> put_flash(:error, gettext("Failed to update primary language"))} end defp get_language_name(language_code) do @@ -262,6 +289,25 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do end end + defp schedule_dashboard_refresh(socket) do + if timer = socket.assigns[:dashboard_refresh_timer] do + Process.cancel_timer(timer) + end + + timer = Process.send_after(self(), :debounced_dashboard_refresh, @dashboard_debounce_ms) + assign(socket, :dashboard_refresh_timer, timer) + end + + defp parse_integer(val, default) when is_binary(val) do + case Integer.parse(val) do + {n, _} -> n + :error -> default + end + end + + defp parse_integer(val, _default) when is_integer(val), do: val + defp parse_integer(_, default), do: default + defp refresh_dashboard(socket) do {groups, insights, summary} = dashboard_snapshot( diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index c4cf2101d..f8673f7ba 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -215,60 +215,65 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do group_slug = socket.assigns.group_slug primary_language = Publishing.get_primary_language() status = socket.assigns.primary_language_status - total_count = status.needs_backfill + status.needs_migration - - # Use background job for large migrations - if total_count > @migration_async_threshold do - case MigratePrimaryLanguageWorker.enqueue(group_slug, primary_language) do - {:ok, _job} -> - {:noreply, - socket - |> assign(:show_migration_modal, false) - |> assign(:migration_in_progress, true) - |> assign(:migration_progress, %{current: 0, total: total_count}) - |> put_flash( - :info, - gettext("Migration started for %{count} posts. You can continue working.", - count: total_count - ) - )} - - {:error, reason} -> - {:noreply, - socket - |> assign(:show_migration_modal, false) - |> put_flash( - :error, - gettext("Failed to start migration: %{reason}", reason: inspect(reason)) - )} - end + + if is_nil(status) do + {:noreply, put_flash(socket, :error, gettext("No posts to migrate"))} else - # Synchronous migration for small counts - case Publishing.migrate_posts_to_current_primary_language(group_slug) do - {:ok, count} -> - posts = DBStorage.list_posts_with_metadata(group_slug) - - {:noreply, - socket - |> assign(:show_migration_modal, false) - |> assign(:posts, posts) - |> assign(:primary_language_status, primary_language_status_from_posts(posts)) - |> put_flash( - :info, - gettext("Updated %{count} posts to primary language: %{lang}", - count: count, - lang: get_language_name(primary_language) - ) - )} - - {:error, reason} -> - {:noreply, - socket - |> assign(:show_migration_modal, false) - |> put_flash( - :error, - gettext("Failed to migrate: %{reason}", reason: inspect(reason)) - )} + total_count = Map.get(status, :needs_backfill, 0) + Map.get(status, :needs_migration, 0) + + # Use background job for large migrations + if total_count > @migration_async_threshold do + case MigratePrimaryLanguageWorker.enqueue(group_slug, primary_language) do + {:ok, _job} -> + {:noreply, + socket + |> assign(:show_migration_modal, false) + |> assign(:migration_in_progress, true) + |> assign(:migration_progress, %{current: 0, total: total_count}) + |> put_flash( + :info, + gettext("Migration started for %{count} posts. You can continue working.", + count: total_count + ) + )} + + {:error, reason} -> + {:noreply, + socket + |> assign(:show_migration_modal, false) + |> put_flash( + :error, + gettext("Failed to start migration: %{reason}", reason: inspect(reason)) + )} + end + else + # Synchronous migration for small counts + case Publishing.migrate_posts_to_current_primary_language(group_slug) do + {:ok, count} -> + posts = DBStorage.list_posts_with_metadata(group_slug) + + {:noreply, + socket + |> assign(:show_migration_modal, false) + |> assign(:posts, posts) + |> assign(:primary_language_status, primary_language_status_from_posts(posts)) + |> put_flash( + :info, + gettext("Updated %{count} posts to primary language: %{lang}", + count: count, + lang: get_language_name(primary_language) + ) + )} + + {:error, reason} -> + {:noreply, + socket + |> assign(:show_migration_modal, false) + |> put_flash( + :error, + gettext("Failed to migrate: %{reason}", reason: inspect(reason)) + )} + end end end end @@ -564,6 +569,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do end end + @impl true + def terminate(_reason, socket) do + pending = socket.assigns[:pending_post_updates] || %{} + + for {_slug, timer_ref} <- pending do + Process.cancel_timer(timer_ref) + end + + :ok + end + # Execute debounced update when timer fires defp do_debounced_update(socket, post_slug) do # Clear the timer from pending @@ -598,8 +614,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do {:ok, fresh_post} -> replace_post_in_list(socket, post_slug, fresh_post) - {:error, _} -> - # Post may have been deleted or unreadable - full refresh + {:error, reason} -> + Logger.warning( + "[Publishing.Listing] fetch_and_update_post failed for #{post_slug}: #{inspect(reason)}, doing full refresh" + ) + refresh_posts(socket) end end @@ -636,10 +655,14 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do group_slug -> case Publishing.read_post(group_slug, post_slug, socket.assigns.current_locale_base, nil) do {:ok, fresh_post} -> - update_post_in_list(socket, fresh_post) + # Replace directly — data is already fresh from DB, no need to re-read + replace_post_in_list(socket, post_slug, fresh_post) + + {:error, reason} -> + Logger.warning( + "[Publishing.Listing] Post #{post_slug} not found during refresh: #{inspect(reason)}, doing full refresh" + ) - {:error, _} -> - # Post might have been deleted - refresh all refresh_posts(socket) end end @@ -673,6 +696,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do PublishingPubSub.subscribe_to_group_editors(new_slug) end + # Cancel any pending debounce timers before switching groups + pending = socket.assigns[:pending_post_updates] || %{} + + for {_slug, timer_ref} <- pending do + Process.cancel_timer(timer_ref) + end + socket |> assign(:group_slug, new_slug) |> assign(:active_editors, %{}) @@ -842,13 +872,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do Enum.map(all_languages, fn lang_code -> lang_info = Publishing.get_language_info(lang_code) - file_exists = lang_code in available_languages + content_exists = lang_code in available_languages is_enabled = Publishing.language_enabled?(lang_code, enabled_languages) is_known = lang_info != nil is_primary = lang_code == primary_lang # Status matches the version's status - lang_status = if file_exists, do: status, else: nil + lang_status = if content_exists, do: status, else: nil # Get display code (base or full dialect depending on enabled languages) display_code = Publishing.get_display_code(lang_code, enabled_languages) @@ -859,7 +889,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do name: if(lang_info, do: lang_info.name, else: lang_code), flag: if(lang_info, do: lang_info.flag, else: ""), status: lang_status, - exists: file_exists, + exists: content_exists, enabled: is_enabled, known: is_known, is_primary: is_primary, @@ -922,7 +952,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do defp build_language_entry(lang_code, post, enabled_languages, primary_lang) do lang_info = Publishing.get_language_info(lang_code) available = post.available_languages || [] - file_exists = lang_code in available + content_exists = lang_code in available language_statuses = Map.get(post, :language_statuses) || %{} %{ @@ -931,7 +961,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do name: if(lang_info, do: lang_info.name, else: lang_code), flag: if(lang_info, do: lang_info.flag, else: ""), status: Map.get(language_statuses, lang_code), - exists: file_exists, + exists: content_exists, enabled: Publishing.language_enabled?(lang_code, enabled_languages), known: lang_info != nil, is_primary: lang_code == primary_lang, diff --git a/lib/modules/publishing/web/new.ex b/lib/modules/publishing/web/new.ex index 68785da78..acc135d42 100644 --- a/lib/modules/publishing/web/new.ex +++ b/lib/modules/publishing/web/new.ex @@ -6,7 +6,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.New do use Gettext, backend: PhoenixKitWeb.Gettext alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes @@ -115,7 +114,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.New do if custom_type != "" do custom_type else - params |> Map.get("type", "blogging") + params |> Map.get("type", "blog") end item_singular = params |> Map.get("item_singular", "") |> String.trim() @@ -141,9 +140,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.New do case Publishing.add_group(name, opts) do {:ok, group} -> - # Broadcast group created for live dashboard updates - PublishingPubSub.broadcast_group_created(group) - {:noreply, socket |> put_flash(:info, gettext("Publishing group \"%{name}\" created", name: group["name"])) @@ -208,17 +204,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.New do "Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-group-name)" ) )} - - {:error, _reason} -> - {:noreply, - socket - |> assign_form_state( - normalized_params, - auto_slug?, - last_generated_slug, - auto_item_names? - ) - |> put_flash(:error, gettext("Failed to create publishing group"))} end end @@ -273,7 +258,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.New do "name" => "", "slug" => "", "mode" => "timestamp", - "type" => "blogging", + "type" => "blog", "item_singular" => "post", "item_plural" => "posts" } @@ -336,7 +321,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.New do String.downcase(custom_type) else params - |> Map.get("type", "blogging") + |> Map.get("type", "blog") |> to_string() |> String.downcase() end diff --git a/lib/modules/publishing/web/new.html.heex b/lib/modules/publishing/web/new.html.heex index 0b0270f88..ece26c7b6 100644 --- a/lib/modules/publishing/web/new.html.heex +++ b/lib/modules/publishing/web/new.html.heex @@ -124,7 +124,7 @@

{preset.label}

<%= case preset.type do %> - <% "blogging" -> %> + <% "blog" -> %> {gettext( "Posts, news articles, and updates. Items called \"%{plural}\".", plural: preset.item_plural @@ -152,7 +152,7 @@ name={@form[:type].name} value="custom" class="radio radio-primary mt-1" - checked={@form[:type].value not in ["blogging", "faq", "legal"]} + checked={@form[:type].value not in ["blog", "faq", "legal"]} phx-click="type_changed" phx-value-type="custom" /> @@ -161,7 +161,7 @@

{gettext("Define your own content type and item names.")}

- <%= if @form[:type].value not in ["blogging", "faq", "legal"] do %> + <%= if @form[:type].value not in ["blog", "faq", "legal"] do %>
token} = params, socket) do endpoint = socket.endpoint || PhoenixKitWeb.Endpoint - case Phoenix.Token.verify(endpoint, "blog-preview", token, max_age: 300) do + case Phoenix.Token.verify(endpoint, "publishing-preview", token, max_age: 300) do {:ok, data} -> + # Validate token belongs to the requested group + token_group = data[:group_slug] + url_group = socket.assigns.group_slug + + if token_group && url_group && token_group != url_group do + Logger.warning( + "[Publishing.Preview] Token group mismatch: token=#{token_group}, url=#{url_group}" + ) + end + post = build_preview_post(data, socket.assigns.group_slug, socket.assigns.current_locale_base) @@ -86,7 +96,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do |> assign(:preview_source, :unsaved)} end - {:error, _reason} -> + {:error, reason} -> + Logger.warning( + "[Publishing.Preview] Token verification failed for group #{socket.assigns.group_slug}: #{inspect(reason)}" + ) + params |> Map.delete("preview_token") |> handle_saved_preview(socket) @@ -126,7 +140,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do |> assign(:preview_source, :saved)} end - {:error, _reason} -> + {:error, reason} -> + Logger.warning( + "[Publishing.Preview] UUID preview failed for #{post_uuid}: #{inspect(reason)}" + ) + {:noreply, socket |> put_flash(:error, gettext("Post not found")) @@ -161,7 +179,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do |> assign(:preview_source, :saved)} end - {:error, _reason} -> + {:error, reason} -> + Logger.warning( + "[Publishing.Preview] Saved preview failed for #{group_slug}/#{path}: #{inspect(reason)}" + ) + {:noreply, socket |> put_flash(:error, gettext("Post not found")) diff --git a/lib/modules/publishing/web/settings.ex b/lib/modules/publishing/web/settings.ex index 938a60c8b..7e6c3a153 100644 --- a/lib/modules/publishing/web/settings.ex +++ b/lib/modules/publishing/web/settings.ex @@ -18,10 +18,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do @memory_cache_key "publishing_memory_cache_enabled" @render_cache_key "publishing_render_cache_enabled" - # Legacy settings keys (read from these as fallback) - @legacy_memory_cache_key "blogging_memory_cache_enabled" - @legacy_render_cache_key "blogging_render_cache_enabled" - def mount(_params, _session, socket) do # Subscribe to group changes for live updates if connected?(socket) do @@ -48,11 +44,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do |> assign(:global_primary_language, Publishing.get_primary_language()) |> assign( :memory_cache_enabled, - get_cache_setting(@memory_cache_key, @legacy_memory_cache_key) + Settings.get_setting(@memory_cache_key, "true") == "true" ) |> assign( :render_cache_enabled, - get_cache_setting(@render_cache_key, @legacy_render_cache_key) + Settings.get_setting(@render_cache_key, "true") == "true" ) |> assign(:cache_status, build_cache_status(cache_groups)) |> assign(:render_cache_stats, get_render_cache_stats()) @@ -64,31 +60,29 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do def handle_params(_params, _uri, socket), do: {:noreply, socket} def handle_event("remove_group", %{"slug" => slug}, socket) do - case Publishing.trash_group(slug) do - {:ok, trashed_name} -> - # The `Publishing.trash_group` call triggers `remove_group`, which handles - # the broadcast. This LiveView will catch the event and update its state. + case Publishing.remove_group(slug) do + {:ok, _} -> + {:noreply, + socket + |> refresh_groups() + |> put_flash(:info, gettext("Group removed"))} + + {:error, {:has_posts, count}} -> {:noreply, put_flash( socket, - :info, - gettext("Group moved to trash as: %{name}", name: trashed_name) + :error, + gettext( + "Cannot remove group with %{count} posts. Delete all posts first or use force remove.", + count: count + ) )} {:error, :not_found} -> - # Group not found in DB, just remove from config - case Publishing.remove_group(slug) do - {:ok, _} -> - # The `Publishing.remove_group` call handles the broadcast. This - # LiveView will catch the event and update its state. - {:noreply, put_flash(socket, :info, gettext("Group removed from configuration"))} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, gettext("Failed to remove group"))} - end + {:noreply, put_flash(socket, :error, gettext("Group not found"))} {:error, _reason} -> - {:noreply, put_flash(socket, :error, gettext("Failed to move group to trash"))} + {:noreply, put_flash(socket, :error, gettext("Failed to remove group"))} end end @@ -128,6 +122,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do lang: primary_lang ) )} + rescue + _ -> + {:noreply, put_flash(socket, :error, gettext("Failed to migrate primary language"))} end def handle_event("regenerate_all_caches", _params, socket) do @@ -261,14 +258,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do end) end - # Helper for dual-key cache setting reads - defp get_cache_setting(new_key, legacy_key) do - case Settings.get_setting(new_key, nil) do - nil -> Settings.get_setting(legacy_key, "true") == "true" - value -> value == "true" - end - end - defp cache_toggle_message(cache_type, enabled) do if enabled do gettext("%{type} enabled", type: cache_type) @@ -304,7 +293,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do %{ exists: in_memory, - file_size: 0, + content_size: 0, modified_at: nil, post_count: post_count, in_memory: in_memory diff --git a/lib/modules/sitemap/sources/publishing.ex b/lib/modules/sitemap/sources/publishing.ex index 06f0052ca..afa5f6b20 100644 --- a/lib/modules/sitemap/sources/publishing.ex +++ b/lib/modules/sitemap/sources/publishing.ex @@ -8,16 +8,16 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do ## URL Structure Uses PhoenixKit URL prefix from config: - - Blog listing: `/{prefix}/{blog_slug}` (default language) - - Blog listing: `/{prefix}/{lang}/{blog_slug}` (non-default language) + - Group listing: `/{prefix}/{group_slug}` (default language) + - Group listing: `/{prefix}/{lang}/{group_slug}` (non-default language) For slug mode posts: - - `/{prefix}/{blog_slug}/{post_slug}` (default language) - - `/{prefix}/{lang}/{blog_slug}/{post_slug}` (non-default language) + - `/{prefix}/{group_slug}/{post_slug}` (default language) + - `/{prefix}/{lang}/{group_slug}/{post_slug}` (non-default language) For timestamp mode posts: - - Single post on date: `/{prefix}/{blog_slug}/{date}` (e.g., /blog/2025-12-09) - - Multiple posts on date: `/{prefix}/{blog_slug}/{date}/{time}` (e.g., /blog/2025-12-09/16:26) + - Single post on date: `/{prefix}/{group_slug}/{date}` (e.g., /blog/2025-12-09) + - Multiple posts on date: `/{prefix}/{group_slug}/{date}/{time}` (e.g., /blog/2025-12-09/16:26) ## Exclusion @@ -25,15 +25,15 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do ## Sitemap Properties - - Blog listings: + - Group listings: - Priority: 0.7 - Change frequency: daily - - Category: Blog name + - Category: Group name - Individual posts: - Priority: 0.8 - Change frequency: weekly - - Category: Blog name + - Category: Group name - Last modified: Post's date_updated or timestamp """ @@ -57,7 +57,7 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do def sitemap_filename, do: "sitemap-publishing" @doc """ - When `sitemap_publishing_split_by_group` is enabled, returns per-blog sub-sitemaps. + When `sitemap_publishing_split_by_group` is enabled, returns per-group sub-sitemaps. Otherwise returns nil (single file). """ @impl true @@ -67,17 +67,17 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do language = Keyword.get(opts, :language) is_default = Keyword.get(opts, :is_default_language, true) - blogs = Publishing.list_groups() - included_blogs = Enum.reject(blogs, &blog_excluded?/1) + groups = Publishing.list_groups() + included_groups = Enum.reject(groups, &group_excluded?/1) sub_maps = - included_blogs - |> Enum.map(fn blog -> - slug = blog["slug"] + included_groups + |> Enum.map(fn group -> + slug = group["slug"] entries = - collect_blog_listings([blog], language, is_default, base_url) ++ - collect_blog_posts(blog, language, is_default, base_url) + collect_group_listings([group], language, is_default, base_url) ++ + collect_group_posts(group, language, is_default, base_url) {slug, entries} end) @@ -111,19 +111,19 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do language = Keyword.get(opts, :language) is_default = Keyword.get(opts, :is_default_language, true) - blogs = Publishing.list_groups() + groups = Publishing.list_groups() - # Filter out blogs with sitemap_exclude setting - included_blogs = Enum.reject(blogs, &blog_excluded?/1) + # Filter out groups with sitemap_exclude setting + included_groups = Enum.reject(groups, &group_excluded?/1) - blog_listings = collect_blog_listings(included_blogs, language, is_default, base_url) + group_listings = collect_group_listings(included_groups, language, is_default, base_url) - blog_posts = - Enum.flat_map(included_blogs, fn blog -> - collect_blog_posts(blog, language, is_default, base_url) + group_posts = + Enum.flat_map(included_groups, fn group -> + collect_group_posts(group, language, is_default, base_url) end) - blog_listings ++ blog_posts + group_listings ++ group_posts else [] end @@ -134,9 +134,9 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do [] end - # Check if blog is excluded from sitemap via settings - defp blog_excluded?(blog) do - case blog do + # Check if group is excluded from sitemap via settings + defp group_excluded?(group) do + case group do %{"sitemap_exclude" => true} -> true %{"sitemap_exclude" => "true"} -> true %{"settings" => %{"sitemap_exclude" => true}} -> true @@ -145,15 +145,15 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do end end - defp collect_blog_listings(blogs, language, is_default, base_url) do - blogs - |> Enum.filter(fn blog -> blog_has_posts_for_language?(blog, language) end) - |> Enum.map(fn blog -> - slug = blog["slug"] - name = blog["name"] + defp collect_group_listings(groups, language, is_default, base_url) do + groups + |> Enum.filter(fn group -> group_has_posts_for_language?(group, language) end) + |> Enum.map(fn group -> + slug = group["slug"] + name = group["name"] # Canonical path without language prefix (for hreflang grouping) - canonical_path = build_blog_path([slug], nil, true) - path = build_blog_path([slug], language, is_default) + canonical_path = build_group_path([slug], nil, true) + path = build_group_path([slug], language, is_default) url = build_url(path, base_url) UrlEntry.new(%{ @@ -161,7 +161,7 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do lastmod: nil, changefreq: "daily", priority: 0.7, - title: "#{name} - Blog", + title: name, category: name, source: :publishing, canonical_path: canonical_path @@ -169,14 +169,14 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do end) rescue error -> - Logger.warning("Failed to collect blog listings: #{inspect(error)}") + Logger.warning("Failed to collect group listings: #{inspect(error)}") [] end - # Check if a blog has at least one published post for the given language - defp blog_has_posts_for_language?(blog, language) do - slug = blog["slug"] + # Check if a group has at least one published post for the given language + defp group_has_posts_for_language?(group, language) do + slug = group["slug"] post_language = language || get_default_language() Publishing.list_posts(slug, post_language) @@ -187,9 +187,9 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do _ -> false end - defp collect_blog_posts(blog, language, is_default, base_url) do - slug = blog["slug"] - name = blog["name"] + defp collect_group_posts(group, language, is_default, base_url) do + slug = group["slug"] + name = group["name"] post_language = language || get_default_language() posts = @@ -199,7 +199,6 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do |> Enum.filter(fn post -> has_translation?(post, language) end) # Optimization: Pre-compute date counts for timestamp mode posts - # This avoids N filesystem reads (one per post) by doing one pass date_counts = build_date_counts_cache(posts) Enum.map(posts, fn post -> @@ -208,14 +207,13 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do rescue error -> Logger.warning( - "Failed to collect posts for blog #{inspect(blog["slug"])}: #{inspect(error)}" + "Failed to collect posts for group #{inspect(group["slug"])}: #{inspect(error)}" ) [] end # Build a map of date -> post count for timestamp mode posts - # This replaces N calls to Storage.count_posts_on_date with O(n) in-memory operation defp build_date_counts_cache(posts) do posts |> Enum.filter(fn post -> post.mode == :timestamp end) @@ -256,10 +254,10 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do end) end - defp build_post_entry(post, blog_slug, blog_name, language, is_default, base_url, date_counts) do + defp build_post_entry(post, group_slug, group_name, language, is_default, base_url, date_counts) do # Canonical path without language prefix (for hreflang grouping) - canonical_path = build_post_path(post, blog_slug, nil, true, date_counts) - path = build_post_path(post, blog_slug, language, is_default, date_counts) + canonical_path = build_post_path(post, group_slug, nil, true, date_counts) + path = build_post_path(post, group_slug, language, is_default, date_counts) url = build_url(path, base_url) title = get_post_title(post) @@ -271,40 +269,39 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do changefreq: "weekly", priority: 0.8, title: title, - category: blog_name, + category: group_name, source: :publishing, canonical_path: canonical_path }) end # Build post path based on mode (slug vs timestamp) - # Uses pre-computed date_counts cache instead of Storage.count_posts_on_date - defp build_post_path(post, blog_slug, language, is_default, date_counts) do + # Uses pre-computed date_counts cache instead of per-post queries + defp build_post_path(post, group_slug, language, is_default, date_counts) do case post.mode do :timestamp -> # For timestamp mode, use date (and time if multiple posts on same date) date = extract_date_for_url(post) - # Use cached count instead of filesystem read post_count = Map.get(date_counts, date, 1) if post_count > 1 do # Multiple posts on this date - include time time = extract_time_for_url(post) - build_blog_path([blog_slug, date, time], language, is_default) + build_group_path([group_slug, date, time], language, is_default) else # Single post on this date - date only - build_blog_path([blog_slug, date], language, is_default) + build_group_path([group_slug, date], language, is_default) end :slug -> # For slug mode, use the post slug post_slug = post.slug || extract_slug_from_path(post.path) - build_blog_path([blog_slug, post_slug], language, is_default) + build_group_path([group_slug, post_slug], language, is_default) _ -> # Fallback to slug mode behavior post_slug = post.slug || extract_slug_from_path(post.path) - build_blog_path([blog_slug, post_slug], language, is_default) + build_group_path([group_slug, post_slug], language, is_default) end end @@ -383,7 +380,7 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do %{metadata: %{title: title}} when is_binary(title) -> title %{metadata: %{"title" => title}} when is_binary(title) -> title %{slug: slug} when is_binary(slug) -> format_slug(slug) - _ -> "Blog Post" + _ -> "Post" end end @@ -459,11 +456,11 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do |> Enum.map_join(" ", &String.capitalize/1) end - # Build blog path with PhoenixKit prefix and optional language + # Build group path with PhoenixKit prefix and optional language # Format: /{prefix}/{lang?}/{segments...} # When in single language mode, no language prefix is added for anyone # When in multi-language mode, ALL languages get prefix (including default) - defp build_blog_path(segments, language, _is_default) do + defp build_group_path(segments, language, _is_default) do prefix_parts = url_prefix_segments() # Add language prefix when: @@ -521,7 +518,6 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do # Get the display code for a language, matching the controller's canonical URL logic. # Returns base code ("en") when only one dialect is enabled, # or full code ("en-US") when multiple dialects of same language are enabled. - # This mirrors Storage.get_display_code/2 to ensure sitemap URLs match canonical URLs. defp get_display_code(language_code) do base_code = Languages.DialectMapper.extract_base(language_code) enabled_languages = Languages.get_enabled_languages() diff --git a/lib/phoenix_kit/cache/cache.ex b/lib/phoenix_kit/cache/cache.ex index f518cb52c..e1e92b58a 100644 --- a/lib/phoenix_kit/cache/cache.ex +++ b/lib/phoenix_kit/cache/cache.ex @@ -277,7 +277,7 @@ defmodule PhoenixKit.Cache do ## Examples - PhoenixKit.Cache.clear_by_prefix(:publishing_posts, "v1:blog_post:my-blog:") + PhoenixKit.Cache.clear_by_prefix(:publishing_posts, "v1:publishing_post:my-blog:") # => {:ok, 15} """ diff --git a/lib/phoenix_kit/config/config.ex b/lib/phoenix_kit/config/config.ex index 975d8de63..9cf659c59 100644 --- a/lib/phoenix_kit/config/config.ex +++ b/lib/phoenix_kit/config/config.ex @@ -151,7 +151,6 @@ defmodule PhoenixKit.Config do # Module-specific settings users_module: PhoenixKit.Users.Auth.User, publishing_settings_module: PhoenixKit.Settings, - blogging_settings_module: PhoenixKit.Settings, # Dashboard settings user_dashboard_enabled: true, # User dashboard tabs - list of tab configs for the user dashboard sidebar diff --git a/lib/phoenix_kit/utils/routes.ex b/lib/phoenix_kit/utils/routes.ex index 13e85a24e..7797eb109 100644 --- a/lib/phoenix_kit/utils/routes.ex +++ b/lib/phoenix_kit/utils/routes.ex @@ -27,7 +27,7 @@ defmodule PhoenixKit.Utils.Routes do @spec url_prefix() :: String.t() def url_prefix, do: Config.get_url_prefix() - # NOTE: Locale override logic below exists for the temporary blogging component system integration. + # NOTE: Locale override logic below exists for the publishing component system integration. # Switch to the upcoming media/storage helpers once they land. def path(url_path, opts \\ []) diff --git a/lib/phoenix_kit_web/controllers/redirects/publishing_redirect_controller.ex b/lib/phoenix_kit_web/controllers/redirects/publishing_redirect_controller.ex deleted file mode 100644 index 4151545c6..000000000 --- a/lib/phoenix_kit_web/controllers/redirects/publishing_redirect_controller.ex +++ /dev/null @@ -1,78 +0,0 @@ -defmodule PhoenixKitWeb.Controllers.Redirects.PublishingRedirectController do - @moduledoc """ - Handles redirects from legacy /admin/blogging/* routes to new /admin/publishing/* routes. - - This controller ensures backward compatibility for bookmarked URLs and external links - while the module is being renamed from "blogging" to "publishing". - - All redirects use 301 (Moved Permanently) status to inform browsers and search engines - that the new URLs are the canonical locations. - """ - use PhoenixKitWeb, :controller - - alias PhoenixKit.Utils.Routes - - @doc """ - Redirects /admin/blogging to /admin/publishing - """ - def index(conn, params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/publishing", locale) - end - - @doc """ - Redirects /admin/blogging/:blog to /admin/publishing/:blog - """ - def blog(conn, %{"blog" => blog} = params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/publishing/#{blog}", locale) - end - - @doc """ - Redirects /admin/blogging/:blog/edit to /admin/publishing/:blog/edit - """ - def edit(conn, %{"blog" => blog} = params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/publishing/#{blog}/edit", locale) - end - - @doc """ - Redirects /admin/blogging/:blog/preview to /admin/publishing/:blog/preview - """ - def preview(conn, %{"blog" => blog} = params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/publishing/#{blog}/preview", locale) - end - - @doc """ - Redirects /admin/settings/blogging to /admin/settings/publishing - """ - def settings(conn, params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/settings/publishing", locale) - end - - @doc """ - Redirects /admin/settings/blogging/new to /admin/settings/publishing/new - """ - def new(conn, params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/settings/publishing/new", locale) - end - - @doc """ - Redirects /admin/settings/blogging/:blog/edit to /admin/settings/publishing/:blog/edit - """ - def settings_edit(conn, %{"blog" => blog} = params) do - locale = Map.get(params, "locale") - redirect_to(conn, "/admin/settings/publishing/#{blog}/edit", locale) - end - - defp redirect_to(conn, path, locale) do - full_path = Routes.path(path, locale: locale) - - conn - |> put_status(:moved_permanently) - |> redirect(to: full_path) - end -end diff --git a/lib/phoenix_kit_web/live/modules/languages.ex b/lib/phoenix_kit_web/live/modules/languages.ex index b0757d0bd..bf0c8e42c 100644 --- a/lib/phoenix_kit_web/live/modules/languages.ex +++ b/lib/phoenix_kit_web/live/modules/languages.ex @@ -101,8 +101,8 @@ defmodule PhoenixKitWeb.Live.Modules.Languages do case result do {:ok, _} -> - # Regenerate all blog caches since language availability changed - regenerate_all_blog_caches() + # Regenerate all publishing group caches since language availability changed + regenerate_all_group_caches() # Reload configuration to get fresh data socket = @@ -178,8 +178,8 @@ defmodule PhoenixKitWeb.Live.Modules.Languages do case result do {:ok, _config} -> - # Regenerate all blog caches since language availability changed - regenerate_all_blog_caches() + # Regenerate all publishing group caches since language availability changed + regenerate_all_group_caches() predefined_lang = Languages.get_predefined_language(code) language_name = (predefined_lang && predefined_lang.name) || code @@ -409,12 +409,12 @@ defmodule PhoenixKitWeb.Live.Modules.Languages do |> Enum.sort_by(fn {country, _} -> country end) end - # Regenerate listing caches for all blogs when language settings change - defp regenerate_all_blog_caches do + # Regenerate listing caches for all publishing groups when language settings change + defp regenerate_all_group_caches do if Publishing.enabled?() do Publishing.list_groups() - |> Enum.each(fn blog -> - ListingCache.regenerate(blog["slug"]) + |> Enum.each(fn group -> + ListingCache.regenerate(group["slug"]) end) end end diff --git a/lib/phoenix_kit_web/routes/publishing.ex b/lib/phoenix_kit_web/routes/publishing.ex index be1b41b42..f8430ac70 100644 --- a/lib/phoenix_kit_web/routes/publishing.ex +++ b/lib/phoenix_kit_web/routes/publishing.ex @@ -2,40 +2,15 @@ defmodule PhoenixKitWeb.Routes.PublishingRoutes do @moduledoc """ Publishing module routes. - Provides route definitions for blog/content management including - both new publishing routes and legacy blogging redirects. + Provides route definitions for content management (publishing groups and posts). """ @doc """ - Returns quoted code for publishing non-LiveView routes (legacy redirects). + Returns quoted code for publishing non-LiveView routes. + Currently a no-op — reserved for future non-LiveView routes. """ - def generate(url_prefix) do + def generate(_url_prefix) do quote do - # Legacy blogging redirects (localized) - alias PhoenixKitWeb.Controllers.Redirects.PublishingRedirectController - - scope "#{unquote(url_prefix)}/:locale" do - pipe_through [:browser] - get "/admin/blogging", PublishingRedirectController, :index - get "/admin/blogging/:blog", PublishingRedirectController, :blog - get "/admin/blogging/:blog/edit", PublishingRedirectController, :edit - get "/admin/blogging/:blog/preview", PublishingRedirectController, :preview - get "/admin/settings/blogging", PublishingRedirectController, :settings - get "/admin/settings/blogging/new", PublishingRedirectController, :new - get "/admin/settings/blogging/:blog/edit", PublishingRedirectController, :settings_edit - end - - # Legacy blogging redirects (non-localized) - scope unquote(url_prefix) do - pipe_through [:browser] - get "/admin/blogging", PublishingRedirectController, :index - get "/admin/blogging/:blog", PublishingRedirectController, :blog - get "/admin/blogging/:blog/edit", PublishingRedirectController, :edit - get "/admin/blogging/:blog/preview", PublishingRedirectController, :preview - get "/admin/settings/blogging", PublishingRedirectController, :settings - get "/admin/settings/blogging/new", PublishingRedirectController, :new - get "/admin/settings/blogging/:blog/edit", PublishingRedirectController, :settings_edit - end end end diff --git a/test/modules/publishing/mapper_test.exs b/test/modules/publishing/mapper_test.exs index 5716a259e..fe9ec291e 100644 --- a/test/modules/publishing/mapper_test.exs +++ b/test/modules/publishing/mapper_test.exs @@ -67,17 +67,17 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do end # ============================================================================ - # to_legacy_map/5 + # to_post_map/5 # ============================================================================ - describe "to_legacy_map/5" do - test "converts DB records to legacy map format" do + describe "to_post_map/5" do + test "converts DB records to post map format" do group = build_group() post = build_post(group) version = build_version(post) content = build_content(version) - result = Mapper.to_legacy_map(post, version, content, [content], [version]) + result = Mapper.to_post_map(post, version, content, [content], [version]) assert result.uuid == post.uuid assert result.group == "blog" @@ -97,7 +97,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do es_content = build_content(version, %{language: "es", status: "draft"}) result = - Mapper.to_legacy_map(post, version, en_content, [en_content, es_content], [version]) + Mapper.to_post_map(post, version, en_content, [en_content, es_content], [version]) assert result.available_languages == ["en", "es"] assert result.language_statuses == %{"en" => "published", "es" => "draft"} @@ -110,7 +110,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do v2 = build_version(post, %{version_number: 2, status: "published"}) content = build_content(v2) - result = Mapper.to_legacy_map(post, v2, content, [content], [v1, v2]) + result = Mapper.to_post_map(post, v2, content, [content], [v1, v2]) assert result.available_versions == [1, 2] assert result.version_statuses == %{1 => "archived", 2 => "published"} @@ -129,7 +129,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do version = build_version(post) content = build_content(version) - result = Mapper.to_legacy_map(post, version, content, [content], [version]) + result = Mapper.to_post_map(post, version, content, [content], [version]) assert result.mode == :timestamp assert result.date == ~D[2025-06-15] @@ -142,7 +142,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do version = build_version(post) content = build_content(version, %{url_slug: nil}) - result = Mapper.to_legacy_map(post, version, content, [content], [version]) + result = Mapper.to_post_map(post, version, content, [content], [version]) assert result.url_slug == "hello-world" end @@ -153,7 +153,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do version = build_version(post) content = build_content(version, %{url_slug: "custom-url"}) - result = Mapper.to_legacy_map(post, version, content, [content], [version]) + result = Mapper.to_post_map(post, version, content, [content], [version]) assert result.url_slug == "custom-url" end @@ -172,7 +172,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do } }) - result = Mapper.to_legacy_map(post, version, content, [content], [version]) + result = Mapper.to_post_map(post, version, content, [content], [version]) assert result.metadata.title == "Hello World" assert result.metadata.description == "A test post" @@ -192,10 +192,91 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do en = build_content(version, %{language: "en", url_slug: "hello"}) es = build_content(version, %{language: "es", url_slug: "hola"}) - result = Mapper.to_legacy_map(post, version, en, [en, es], [version]) + result = Mapper.to_post_map(post, version, en, [en, es], [version]) assert result.language_slugs == %{"en" => "hello", "es" => "hola"} end + + test "builds version_dates from all versions" do + group = build_group() + post = build_post(group) + v1 = build_version(post, %{version_number: 1, inserted_at: ~U[2025-06-10 10:00:00Z]}) + v2 = build_version(post, %{version_number: 2, inserted_at: ~U[2025-06-15 14:30:00Z]}) + content = build_content(v2) + + result = Mapper.to_post_map(post, v2, content, [content], [v1, v2]) + + assert result.version_dates == %{ + 1 => "2025-06-10T10:00:00Z", + 2 => "2025-06-15T14:30:00Z" + } + end + + test "builds language_previous_slugs from all contents" do + group = build_group() + post = build_post(group) + version = build_version(post) + + en = + build_content(version, %{ + language: "en", + data: %{"previous_url_slugs" => ["old-hello"]} + }) + + es = build_content(version, %{language: "es", data: %{}}) + + result = Mapper.to_post_map(post, version, en, [en, es], [version]) + + assert result.language_previous_slugs["en"] == ["old-hello"] + assert result.language_previous_slugs["es"] == [] + end + + test "merges published_language_statuses via opts" do + group = build_group() + post = build_post(group) + version = build_version(post) + en = build_content(version, %{language: "en", status: "draft"}) + es = build_content(version, %{language: "es", status: "draft"}) + + result = + Mapper.to_post_map(post, version, en, [en, es], [version], + published_language_statuses: %{"en" => "published"} + ) + + assert result.language_statuses["en"] == "published" + assert result.language_statuses["es"] == "draft" + end + + test "group slug is nil when group is not preloaded" do + group = build_group() + + post = + build_post(group, %{ + group: %Ecto.Association.NotLoaded{ + __field__: :group, + __cardinality__: :one, + __owner__: PublishingPost + } + }) + + version = build_version(post) + content = build_content(version) + + result = Mapper.to_post_map(post, version, content, [content], [version]) + + assert result.group == nil + end + + test "url_slug falls back to post slug when empty string" do + group = build_group() + post = build_post(group) + version = build_version(post) + content = build_content(version, %{url_slug: ""}) + + result = Mapper.to_post_map(post, version, content, [content], [version]) + + assert result.url_slug == "hello-world" + end end # ============================================================================ @@ -271,5 +352,137 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.MapperTest do assert result.metadata.title == nil assert result.content == nil end + + test "falls back to first content when primary language not found" do + group = build_group() + post = build_post(group, %{primary_language: "en"}) + version = build_version(post) + es = build_content(version, %{language: "es", title: "Titulo Espanol"}) + + result = Mapper.to_listing_map(post, version, [es], [version]) + + assert result.metadata.title == "Titulo Espanol" + end + + test "uses description as excerpt fallback when no custom excerpt" do + group = build_group() + post = build_post(group) + version = build_version(post) + + content = + build_content(version, %{ + content: "Full content here", + data: %{"description" => "A meta description"} + }) + + result = Mapper.to_listing_map(post, version, [content], [version]) + + assert result.content == "A meta description" + end + + test "builds language_titles and language_excerpts" do + group = build_group() + post = build_post(group) + version = build_version(post) + + en = + build_content(version, %{ + language: "en", + title: "Hello", + data: %{"excerpt" => "EN excerpt"} + }) + + es = + build_content(version, %{ + language: "es", + title: "Hola", + data: %{"excerpt" => "ES excerpt"} + }) + + result = Mapper.to_listing_map(post, version, [en, es], [version]) + + assert result.language_titles == %{"en" => "Hello", "es" => "Hola"} + assert result.language_excerpts == %{"en" => "EN excerpt", "es" => "ES excerpt"} + end + + test "builds version_dates from all versions" do + group = build_group() + post = build_post(group) + v1 = build_version(post, %{version_number: 1, inserted_at: ~U[2025-06-10 10:00:00Z]}) + v2 = build_version(post, %{version_number: 2, inserted_at: ~U[2025-06-15 14:30:00Z]}) + content = build_content(v2) + + result = Mapper.to_listing_map(post, v2, [content], [v1, v2]) + + assert result.version_dates == %{ + 1 => "2025-06-10T10:00:00Z", + 2 => "2025-06-15T14:30:00Z" + } + end + + test "url_slug falls back to post slug when content url_slug is nil" do + group = build_group() + post = build_post(group) + version = build_version(post) + content = build_content(version, %{url_slug: nil}) + + result = Mapper.to_listing_map(post, version, [content], [version]) + + assert result.url_slug == "hello-world" + end + + test "merges published_language_statuses via opts" do + group = build_group() + post = build_post(group) + version = build_version(post) + en = build_content(version, %{language: "en", status: "draft"}) + + result = + Mapper.to_listing_map(post, version, [en], [version], + published_language_statuses: %{"en" => "published"} + ) + + assert result.language_statuses["en"] == "published" + end + + test "version defaults to 1 when version is nil" do + group = build_group() + post = build_post(group) + version = build_version(post) + content = build_content(version) + + result = Mapper.to_listing_map(post, nil, [content], [version]) + + assert result.version == 1 + end + + test "builds available_versions and version_statuses" do + group = build_group() + post = build_post(group) + v1 = build_version(post, %{version_number: 1, status: "archived"}) + v2 = build_version(post, %{version_number: 2, status: "published"}) + content = build_content(v2) + + result = Mapper.to_listing_map(post, v2, [content], [v1, v2]) + + assert result.available_versions == [1, 2] + assert result.version_statuses == %{1 => "archived", 2 => "published"} + end + + test "extracts first non-heading paragraph when no excerpt or description" do + group = build_group() + post = build_post(group) + version = build_version(post) + + content = + build_content(version, %{ + content: "## Heading\n\nActual paragraph here.\n\nMore content.", + data: %{} + }) + + result = Mapper.to_listing_map(post, version, [content], [version]) + + assert result.content == "Actual paragraph here." + end end end diff --git a/test/modules/publishing/schema_test.exs b/test/modules/publishing/schema_test.exs index 73e7ec8d5..d1ff70896 100644 --- a/test/modules/publishing/schema_test.exs +++ b/test/modules/publishing/schema_test.exs @@ -63,7 +63,7 @@ defmodule PhoenixKit.Modules.Publishing.SchemaTest do test "data JSONB accessors return defaults" do group = %PublishingGroup{data: %{}} - assert PublishingGroup.get_type(group) == "blogging" + assert PublishingGroup.get_type(group) == "blog" assert PublishingGroup.get_item_singular(group) == "Post" assert PublishingGroup.get_item_plural(group) == "Posts" assert PublishingGroup.get_description(group) == nil From d3fa2b8381ba265cdef7ab24cc4612a9fad86d97 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sat, 14 Mar 2026 07:22:40 +0200 Subject: [PATCH 02/99] Update module system guide Co-Authored-By: Claude Opus 4.6 (1M context) --- .../guides/2026-02-24-module-system-guide.md | 1040 ++++++++++++++--- 1 file changed, 903 insertions(+), 137 deletions(-) diff --git a/dev_docs/guides/2026-02-24-module-system-guide.md b/dev_docs/guides/2026-02-24-module-system-guide.md index 6b70184a7..598e10dc0 100644 --- a/dev_docs/guides/2026-02-24-module-system-guide.md +++ b/dev_docs/guides/2026-02-24-module-system-guide.md @@ -9,11 +9,16 @@ - [Optional Callbacks](#optional-callbacks) - [Folder Structure Convention](#folder-structure-convention) - [Admin Tabs](#admin-tabs) +- [Subtabs and Hidden Pages](#subtabs-and-hidden-pages) - [Settings Tabs](#settings-tabs) - [Permission Metadata](#permission-metadata) - [Supervisor Children](#supervisor-children) - [Route Integration](#route-integration) +- [Navigation System (Paths Module)](#navigation-system-paths-module) +- [Component Reuse](#component-reuse) +- [JavaScript in External Modules](#javascript-in-external-modules) - [Enable / Disable Patterns](#enable--disable-patterns) +- [Database and Migrations](#database-and-migrations) - [External Hex Packages](#external-hex-packages) - [Pitfalls for Developers and Agents](#pitfalls-for-developers-and-agents) - [Reference Files](#reference-files) @@ -165,9 +170,15 @@ def module_name, do: "Analytics" Whether the module is currently active. Called frequently — keep it cheap. The settings cache handles the DB read. ```elixir -def enabled?, do: Settings.get_boolean_setting("analytics_enabled", false) +def enabled? do + Settings.get_boolean_setting("analytics_enabled", false) +rescue + _ -> false +end ``` +The `rescue` clause is required — `enabled?/0` is called before migrations run, so the settings table may not exist yet. + ### `enable_system/0` and `disable_system/0` Enable or disable the module system-wide. Must return `:ok | {:ok, term()} | {:error, term()}`. @@ -285,6 +296,8 @@ See `PhoenixKitDocumentCreator.Migration` for a production example. ## Folder Structure Convention +### Internal modules (inside PhoenixKit) + All modules live in `lib/modules/` with the `PhoenixKit.Modules.` namespace. ``` @@ -302,31 +315,88 @@ lib/modules/analytics/ - The main context file (`analytics.ex`) is the one that `use PhoenixKit.Module` - Do not use `lib/phoenix_kit/modules/`, `lib/phoenix_kit_web/live/modules/`, or `lib/phoenix_kit/.ex` +### External modules (standalone packages) + +``` +lib/ + my_phoenix_kit_module.ex # Main module (behaviour callbacks) + my_phoenix_kit_module/ + paths.ex # Centralized path helpers + documents.ex # Context / business logic + schemas/ + item.ex # Ecto schemas + migration.ex # Migration coordinator + migration/postgres/ + v01.ex # Initial tables + v02.ex # Schema changes + web/ + index_live.ex # Main admin page + detail_live.ex # Detail/edit page + components/ + my_scripts.ex # JS hook component + item_card.ex # Shared UI component + editor_panel.ex # Shared editor component +mix/ + tasks/ + my_phoenix_kit_module.install.ex # Install task +``` + --- ## Admin Tabs -Admin sidebar tabs are defined in `admin_tabs/0` as `Tab.new!/1` structs. +Admin sidebar tabs are defined in `admin_tabs/0` as `%Tab{}` structs. ```elixir def admin_tabs do [ - Tab.new!( + %Tab{ id: :admin_analytics, # Atom — must be unique across ALL modules label: "Analytics", icon: "hero-chart-bar", - path: "/admin/analytics", # Must start with "/" - priority: 600, # Higher = appears higher in group + path: "analytics", # Relative slug — core prepends /admin/ + priority: 600, # Lower = higher in sidebar level: :admin, # Always :admin for admin sidebar permission: "analytics", # Must match module_key and permission_metadata.key match: :prefix, # :prefix or :exact - group: :admin_modules # :admin_main or :admin_modules - ) + group: :admin_modules, # :admin_main or :admin_modules + live_view: {MyModule.Web.IndexLive, :index} + } ] end ``` -**Priority reference** (existing modules, for insertion guidance): +### Tab struct complete reference + +| Field | Type | Default | Description | +|---|---|---|---| +| `:id` | atom | *required* | Unique identifier (prefix with `:admin_yourmodule`) | +| `:label` | string | *required* | Display text in sidebar | +| `:icon` | string | `nil` | Heroicon name (e.g., `"hero-chart-bar"`) | +| `:path` | string | *required* | Relative slug (`"my-module"`) or absolute (`"/admin/my-module"`) | +| `:priority` | integer | `500` | Sort order (lower = higher in sidebar) | +| `:level` | atom | `:user` | `:admin`, `:settings`, `:user`, or `:all` | +| `:permission` | string | `nil` | Permission key (use `module_key()`) | +| `:group` | atom | `nil` | Sidebar group (`:admin_modules` for module tabs) | +| `:match` | atom/fn | `:prefix` | `:exact`, `:prefix`, `{:regex, ~r/...}`, or `fn path -> bool end` | +| `:live_view` | tuple | `nil` | `{Module, :action}` for auto-routing | +| `:parent` | atom | `nil` | Parent tab ID (for subtabs or hidden sub-pages) | +| `:visible` | bool/fn | `true` | Show in sidebar. `false` hides it. Can be `fn scope -> bool end` | +| `:badge` | `Badge` | `nil` | Badge indicator (count, dot, status) | +| `:tooltip` | string | `nil` | Hover text | +| `:external` | bool | `false` | Whether this links to an external site | +| `:new_tab` | bool | `false` | Whether to open in a new browser tab | +| `:attention` | atom | `nil` | Animation: `:pulse`, `:bounce`, `:shake`, `:glow` | +| `:metadata` | map | `%{}` | Custom metadata for advanced use cases | +| `:subtab_display` | atom | `:when_active` | When to show subtabs: `:when_active` or `:always` | +| `:subtab_indent` | string | `nil` | Tailwind padding class (e.g., `"pl-6"`) | +| `:subtab_icon_size` | string | `nil` | Icon size class (e.g., `"w-3 h-3"`) | +| `:subtab_text_size` | string | `nil` | Text size class (e.g., `"text-xs"`) | +| `:subtab_animation` | atom | `nil` | `:none`, `:slide`, `:fade`, `:collapse` | +| `:redirect_to_first_subtab` | bool | `false` | Navigate to first subtab when clicking parent | +| `:highlight_with_subtabs` | bool | `false` | Keep parent highlighted when subtab is active | + +### Priority reference | Priority | Module | |----------|--------| @@ -341,15 +411,180 @@ end Use a value in an existing gap or adjust nearby modules if needed. -**Groups:** +### Groups + - `:admin_main` — core platform tabs (Dashboard, Users, Media, Settings) - `:admin_modules` — feature module tabs (everything else) -**Match modes:** +### Match modes + - `:prefix` — tab highlighted for the path and all sub-paths (use for modules with sub-pages) - `:exact` — tab highlighted only for the exact path -**Paths use hyphens, not underscores:** `/admin/magic-link`, not `/admin/magic_link`. +### Paths use hyphens, not underscores + +`/admin/magic-link`, not `/admin/magic_link`. + +--- + +## Subtabs and Hidden Pages + +For modules with multiple pages, use a combination of visible subtabs and hidden pages. + +### Visible subtabs + +Subtabs appear indented under their parent in the sidebar when the parent is active: + +```elixir +def admin_tabs do + [ + # Parent tab with subtab configuration + %Tab{ + id: :admin_my_module, + label: "My Module", + icon: "hero-puzzle-piece", + path: "my-module", + priority: 650, + level: :admin, + permission: module_key(), + match: :prefix, + group: :admin_modules, + subtab_display: :when_active, # Show subtabs only when parent is active + highlight_with_subtabs: false, # Don't highlight parent when subtab is active + live_view: {MyModule.Web.IndexLive, :index} + }, + # Visible subtab (appears in sidebar under parent) + %Tab{ + id: :admin_my_module_reports, + label: "Reports", + icon: "hero-chart-bar", + path: "my-module/reports", + priority: 651, + level: :admin, + permission: module_key(), + parent: :admin_my_module, + live_view: {MyModule.Web.ReportsLive, :index} + }, + # Another visible subtab + %Tab{ + id: :admin_my_module_settings, + label: "Settings", + icon: "hero-cog-6-tooth", + path: "my-module/settings", + priority: 652, + level: :admin, + permission: module_key(), + parent: :admin_my_module, + live_view: {MyModule.Web.SettingsLive, :index} + } + ] +end +``` + +**Subtab display modes:** +- `:when_active` — subtabs visible only when the parent tab or one of its subtabs is active +- `:always` — subtabs always visible regardless of parent state + +**`highlight_with_subtabs`:** +- `false` (default) — parent is not highlighted when a subtab is active +- `true` — parent stays highlighted when any subtab is active + +### Hidden pages + +For pages that need routes but shouldn't appear in the sidebar (edit pages, detail views, creation forms): + +```elixir +%Tab{ + id: :admin_my_module_item_edit, + path: "my-module/items/:uuid/edit", # Path parameters work + level: :admin, + permission: module_key(), + parent: :admin_my_module, # Keeps parent highlighted + visible: false, # Not shown in sidebar + live_view: {MyModule.Web.ItemEditorLive, :edit} +} +``` + +### Conditional tabs via config flags + +Gate tabs behind compile-time configuration: + +```elixir +@testing_mode Application.compile_env(:my_module, :testing_mode, false) + +@impl PhoenixKit.Module +def admin_tabs do + base_tabs() ++ testing_tabs() +end + +defp base_tabs do + [%Tab{id: :admin_my_module, ...}] +end + +defp testing_tabs do + if @testing_mode do + [%Tab{id: :admin_my_module_testing, label: "Testing", icon: "hero-beaker", ...}] + else + [] + end +end +``` + +Users enable via config: + +```elixir +config :my_module, :testing_mode, true +``` + +### Real-world example: Document Creator (14 tabs) + +```elixir +def admin_tabs do + [ + # Main landing page (visible, with subtab config) + %Tab{id: :admin_document_creator, path: "document-creator", + subtab_display: :when_active, highlight_with_subtabs: false, ...}, + + # Hidden CRUD pages (route exists, no sidebar entry) + %Tab{id: :admin_document_creator_template_new, + path: "document-creator/templates/new", + visible: false, parent: :admin_document_creator, ...}, + %Tab{id: :admin_document_creator_template_edit, + path: "document-creator/templates/:uuid/edit", + visible: false, parent: :admin_document_creator, ...}, + %Tab{id: :admin_document_creator_document_edit, + path: "document-creator/documents/:uuid/edit", + visible: false, parent: :admin_document_creator, ...}, + + # Visible subtabs (appear under parent in sidebar) + %Tab{id: :admin_document_creator_headers, + path: "document-creator/headers", + parent: :admin_document_creator, ...}, + %Tab{id: :admin_document_creator_footers, + path: "document-creator/footers", + parent: :admin_document_creator, ...}, + + # Hidden CRUD pages for subtabs + %Tab{id: :admin_document_creator_header_new, + path: "document-creator/headers/new", + visible: false, parent: :admin_document_creator, ...}, + %Tab{id: :admin_document_creator_header_edit, + path: "document-creator/headers/:uuid/edit", + visible: false, parent: :admin_document_creator, ...}, + # ... footer_new, footer_edit similarly + + # Conditional testing tabs (behind :testing_editors config flag) + # ... only included when config is true + ] +end +``` + +Key patterns: +- **One main tab** visible in the sidebar with `subtab_display: :when_active` +- **Subtabs** for major sections (Headers, Footers) — visible, with `parent` +- **Hidden tabs** for CRUD pages — `visible: false`, still auto-routed +- **Path parameters** work in tab paths: `"document-creator/templates/:uuid/edit"` +- **All tabs** share the same `permission: module_key()` for consistent access control --- @@ -393,15 +628,51 @@ def permission_metadata do end ``` -**How permissions work:** +### How permissions work -- **Owner** — always full access, hard-coded, cannot be restricted -- **Admin** — all 25 permission keys by default, including new module keys -- **Custom roles** — no permissions initially; must be granted explicitly via UI or `Permissions.set_permissions/3` +| Role type | Default access | Can be changed? | +|---|---|---| +| **Owner** | Full access to everything | No — hardcoded, cannot be restricted | +| **Admin** | All permission keys by default | Yes — per key via Admin > Roles | +| **Custom roles** | No permissions initially | Yes — must be granted explicitly | Without `permission_metadata/0` (returns `nil`), the module has no dedicated permission key. Admins and owners still see it; custom roles never will. -**Startup validation:** The registry warns at boot if `permission_metadata.key` ≠ `module_key`. This mismatch causes toggle events and permission checks to use different keys, which is always a bug. +### Checking permissions in code + +```elixir +alias PhoenixKit.Users.Auth.Scope + +# In a LiveView +scope = socket.assigns.phoenix_kit_current_scope + +Scope.has_module_access?(scope, "my_module") # does user have this permission? +Scope.admin?(scope) # is user Owner or Admin? +Scope.system_role?(scope) # Owner, Admin, or User (not custom)? +Scope.owner?(scope) # is user Owner? +Scope.user_roles(scope) # list of role names +``` + +### Access guards on admin tabs + +PhoenixKit's `on_mount` hook automatically checks the `:permission` field on each tab before rendering the LiveView. If the user's role doesn't have the permission, they get a 302 redirect. You don't need manual guards — just set `:permission` correctly. + +For fine-grained checks within a page: + +```elixir + +``` + +### Startup validation + +The registry warns at boot if: +- `permission_metadata.key` does not match `module_key` +- Tabs have no `:permission` field but the module has `permission_metadata` +- Duplicate tab IDs exist across modules + +These are warnings, not crashes — a misconfigured module won't take down the app. --- @@ -418,25 +689,65 @@ def children do end ``` -**Important details:** +### Important details - `static_children/0` is called from `PhoenixKit.Supervisor.init/1` — before the ModuleRegistry GenServer starts. It builds the list directly from the internal module list. This means `children/0` must not rely on the registry being initialized. - Individual module failures in `children/0` are caught by `static_children/0` and logged as warnings — they do not crash the supervisor. - Children start with the PhoenixKit supervisor regardless of whether the module is "enabled". If you only want a process running when enabled, check `enabled?/0` inside the child's `start_link/1` and return `:ignore`. +### Conditional children with optional dependencies + +Guard child specs on optional library availability: + +```elixir +def children do + if Code.ensure_loaded?(ChromicPDF) do + [{MyModule.PdfSupervisor, []}] + else + [] + end +end +``` + +This ensures the module loads even when the optional dependency isn't installed. + +### Worker that respects enabled state + +```elixir +def start_link(_opts) do + if PhoenixKit.Modules.Analytics.enabled?() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + else + :ignore + end +end +``` + --- ## Route Integration -If your module has admin LiveViews, declare them as tabs with a `live_view` field (for external packages) or register routes directly in `phoenix_kit_web/router.ex` (for internal modules). +### Auto-routing via `live_view` field -**For internal modules:** +For most modules, auto-routing via the tab's `:live_view` field is sufficient. No manual router entries needed. -Add routes directly to the router. The module's admin tab `path` must match the route path. +A tab like: -**For external packages:** +```elixir +%Tab{path: "my-module", live_view: {MyModule.Web.IndexLive, :index}} +``` -Implement `route_module/0` pointing to a module that contains a `phoenix_kit_admin_routes/0` macro: +Generates at compile time: + +```elixir +live "/admin/my-module", MyModule.Web.IndexLive, :index +``` + +inside the admin `live_session` with the admin layout applied. + +### Custom route module + +For complex routing needs (non-LiveView routes, custom pipelines), implement `route_module/0`: ```elixir def route_module, do: PhoenixKitAnalytics.Router @@ -455,41 +766,395 @@ end Routes are generated at compile time via `compile_plugin_admin_routes/0` in `integration.ex`. A recompile is required after adding a new external module. -### Navigation Paths in Templates +### Assigns available in admin LiveViews + +PhoenixKit's `on_mount` hooks inject these assigns: + +| Assign | Type | Description | +|---|---|---| +| `@phoenix_kit_current_scope` | `Scope` | Authenticated user's scope (role, permissions) | +| `@current_locale` | `String` | Current locale string (e.g., `"en"`, `"ja"`) | +| `@url_path` | `String` | Current URL path (used for active nav highlighting) | + +Set `@page_title` in `mount/3` — it appears in the browser tab. + +--- -Tab struct `path` fields use a relative convention (`"my-module"` → core prepends `/admin/`). But `href` attributes in HEEx templates and `redirect/2` calls are raw — the browser or Phoenix sends the path as-is. These must go through `PhoenixKit.Utils.Routes.path/1`, which handles the URL prefix and locale prefix. +## Navigation System (Paths Module) -**Create a Paths module** for your module to centralize all path construction: +Every path your module generates — in templates, redirects, or LiveView navigation — **must** go through `PhoenixKit.Utils.Routes.path/1`. This handles the configurable URL prefix and locale prefix. + +### Create a Paths module + +Centralize all path construction in one file: ```elixir -defmodule PhoenixKitAnalytics.Paths do +# lib/my_module/paths.ex +defmodule MyModule.Paths do + @moduledoc """ + Centralized path helpers for My Module. + + All navigation paths go through `PhoenixKit.Utils.Routes.path/1`, which + handles the configurable URL prefix and locale prefix automatically. + """ + alias PhoenixKit.Utils.Routes - @base "/admin/analytics" + @base "/admin/my-module" + # ── Main ────────────────────────────────────────────────────────── def index, do: Routes.path(@base) - def show(id), do: Routes.path("#{@base}/#{id}") + + # ── Items ───────────────────────────────────────────────────────── + def item_new, do: Routes.path("#{@base}/items/new") + def item_edit(uuid), do: Routes.path("#{@base}/items/#{uuid}/edit") + def item_show(uuid), do: Routes.path("#{@base}/items/#{uuid}") + + # ── Settings ────────────────────────────────────────────────────── def settings, do: Routes.path("#{@base}/settings") end ``` -Then in templates and LiveViews: +### Usage in LiveViews and templates ```elixir -alias PhoenixKitAnalytics.Paths +alias MyModule.Paths -# Template href: -View +# Redirect after save +{:noreply, redirect(socket, to: Paths.index())} -# Server-side redirect: -redirect(socket, to: Paths.index()) +# Handle not-found +case get_item(uuid) do + nil -> + socket |> put_flash(:error, "Not found") |> redirect(to: Paths.index()) + item -> + assign(socket, item: item) +end +``` + +```heex +Edit +Back ``` -This gives the same "single point of change" benefit that Tab structs have for sidebar registration — if the admin path changes, you update `@base` in one file instead of every template. +### Tab paths vs template paths -**Why not use relative paths in templates?** +| Where | How to specify paths | +|---|---| +| Tab struct `path` | `"my-module"` (relative — core prepends `/admin/`) | +| Template `href` / `redirect` | `Paths.index()` (wraps `Routes.path/1`) | +| Email URLs | `Routes.url("/path")` (full URL) | -Tab struct `path` fields go through `Tab.resolve_path/2` at registration time. Template `href` attributes and `redirect(to:)` calls do not — they're raw HTML/Phoenix. Relative paths like `"my-module/items"` would be resolved by the browser relative to the current URL, which breaks when locale segments (e.g., `/ja/`) are in the path. +### Why relative paths break + +The browser resolves relative paths relative to the current URL. When locale segments (e.g., `/ja/`) are in the path, relative paths resolve incorrectly. Always use absolute paths via `Routes.path/1`. + +--- + +## Component Reuse + +As modules grow, extract shared UI into reusable function components. This keeps LiveViews focused on business logic. + +### Creating a shared component + +```elixir +# lib/my_module/web/components/item_card.ex +defmodule MyModule.Web.Components.ItemCard do + use Phoenix.Component + + attr :item, :map, required: true + attr :on_edit, :string, default: nil + attr :on_delete, :string, default: nil + + def item_card(assigns) do + ~H""" +
+
+

{@item.name}

+

{@item.description}

+
+ + +
+
+
+ """ + end +end +``` + +### Using components across LiveViews + +```elixir +defmodule MyModule.Web.IndexLive do + use Phoenix.LiveView + import MyModule.Web.Components.ItemCard + + def render(assigns) do + ~H""" +
+ <.item_card :for={item <- @items} item={item} + on_edit="edit_item" on_delete="delete_item" /> +
+ """ + end +end +``` + +### Shared editor panel pattern + +For modules with multiple editor pages that share the same shell: + +```elixir +# lib/my_module/web/components/editor_panel.ex +defmodule MyModule.Web.Components.EditorPanel do + use Phoenix.Component + + attr :id, :string, required: true, doc: "Unique prefix for element IDs" + attr :hook, :string, required: true, doc: "Phoenix hook name" + attr :save_event, :string, required: true, doc: "LiveView event for saving" + attr :show_toolbar, :boolean, default: true + + def editor_panel(assigns) do + ~H""" +
+
+
+
+
+
+ """ + end +end +``` + +Used by multiple LiveViews with different parameters: + +```elixir +# Template editor +import MyModule.Web.Components.EditorPanel +<.editor_panel id="template" hook="TemplateEditor" save_event="save_template" /> + +# Document editor +<.editor_panel id="document" hook="DocumentEditor" save_event="save_document" + show_toolbar={false} /> +``` + +### Multi-step modal component + +```elixir +# lib/my_module/web/components/create_modal.ex +defmodule MyModule.Web.Components.CreateModal do + use Phoenix.Component + + attr :open, :boolean, required: true + attr :step, :string, default: "choose" + attr :templates, :list, default: [] + attr :creating, :boolean, default: false + + def modal(assigns) do + ~H""" + + """ + end +end +``` + +### Component design guidelines + +1. **Use `attr` declarations** — they provide documentation, validation, and compile-time warnings +2. **Use daisyUI semantic classes** — `bg-base-100`, `text-base-content`, `btn btn-primary` (never hardcode colors) +3. **Use `text-base-content/70`** for muted text, not `text-gray-500` +4. **Prefix element IDs** with the component's `@id` to avoid collisions +5. **Pass event names as attrs** (e.g., `on_edit="edit_item"`) — makes components reusable across LiveViews + +--- + +## JavaScript in External Modules + +External modules **cannot inject into the parent app's asset pipeline** (`app.js`, `esbuild`, `node_modules`). All JavaScript must be delivered as **inline ` + """ + end +end +``` + +Usage: + +```heex +<.my_scripts /> +
+ ... +
+``` + +**Rules:** +- Register hooks on `window.PhoenixKitHooks` — PhoenixKit spreads this object into the LiveSocket +- Pages using hooks must use **full page load** (`redirect/2`, not `navigate/2`) so inline scripts execute +- Never assume access to `node_modules`, `esbuild`, or the parent app's JS build + +### Base64-encoded JS delivery (for large scripts) + +Large inline `` boundaries** — DOM patching can corrupt the boundary between `` and subsequent HTML +2. **HTML strings inside JS confuse rendering** — JS code containing `'

Title

'` can be parsed as HTML +3. **Browser extensions block inline eval()** — MetaMask's hardened JS, etc. + +The solution is **compile-time base64 encoding**: + +```elixir +defmodule MyModule.Web.Components.MyScripts do + use Phoenix.Component + + # Read and encode JS at compile time + @external_resource Path.join(__DIR__, "my_hooks.js") + @js_source __DIR__ |> Path.join("my_hooks.js") |> File.read!() + @js_base64 Base.encode64(@js_source) + @js_version to_string(:erlang.phash2(@js_source)) + + def my_scripts(assigns) do + assigns = + assigns + |> assign(:js_base64, @js_base64) + |> assign(:js_version, @js_version) + + ~H""" + + + """ + end +end +``` + +The JS source file lives alongside the component: + +```javascript +// lib/my_module/web/components/my_hooks.js +(function() { + "use strict"; + if (window.__MyModuleInitialized) return; + window.__MyModuleInitialized = true; + + window.PhoenixKitHooks = window.PhoenixKitHooks || {}; + window.PhoenixKitHooks.MyEditor = { + mounted() { + this.handleEvent("load-data", (data) => { /* handle server events */ }); + }, + destroyed() { /* cleanup */ } + }; +})(); +``` + +**Why base64 works better:** +- No HTML-significant characters in base64 → no morphdom corruption +- `document.createElement("script")` bypasses extension blocks on `eval()` +- Content hash (`@js_version`) ensures re-execution on LiveView navigations +- `@external_resource` tells Mix to track the JS file for recompilation + +**Editing workflow:** +1. Edit `my_hooks.js` +2. From parent app: `mix deps.compile my_module --force` +3. Restart Phoenix server + +### Loading vendor libraries from CDN + +```javascript +var _libLoaded = false; +var _libCallbacks = []; + +function ensureLibrary(callback) { + if (typeof MyLibrary !== "undefined") { callback(); return; } + _libCallbacks.push(callback); + if (_libLoaded) return; + _libLoaded = true; + + var link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "https://cdn.jsdelivr.net/npm/my-library@1.0/dist/style.min.css"; + document.head.appendChild(link); + + var script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/my-library@1.0/dist/lib.min.js"; + script.onload = function() { + var cbs = _libCallbacks.slice(); + _libCallbacks = []; + cbs.forEach(function(cb) { cb(); }); + }; + document.head.appendChild(script); +} +``` + +### LiveView JS interop + +```javascript +// JS → Elixir +this.pushEvent("save_content", {html: editor.getHtml()}); + +// Elixir → JS +this.handleEvent("load-content", ({html}) => { editor.setContent(html); }); +``` + +```elixir +# In LiveView handle_event +{:noreply, push_event(socket, "load-content", %{html: content.html})} +``` --- @@ -516,7 +1181,6 @@ If disabling your module must also disable a dependent module, do the primary op ```elixir def disable_system do result = Settings.update_boolean_setting_with_module("analytics_enabled", false, "analytics") - # Cascade only after primary succeeds case result do {:ok, _} -> PhoenixKit.Modules.Reports.disable_system() error -> error @@ -548,82 +1212,95 @@ end --- -## External Hex Packages +## Database and Migrations -Creating a standalone `phoenix_kit_analytics` hex package: +### Table naming -**1. Add `phoenix_kit` as a dependency:** +Prefix all tables with `phoenix_kit_` followed by your module key: -```elixir -# mix.exs -{:phoenix_kit, "~> 1.7"} +``` +phoenix_kit_my_module_items +phoenix_kit_my_module_categories ``` -**2. Implement the behaviour:** +### Schemas ```elixir -defmodule PhoenixKitAnalytics do - use PhoenixKit.Module +defmodule MyModule.Schemas.Item do + use Ecto.Schema + import Ecto.Changeset - def module_key, do: "analytics" - def module_name, do: "Analytics" - # ... rest of callbacks + alias PhoenixKit.Schemas.UUIDv7 + + @primary_key {:uuid, UUIDv7, autogenerate: true} + + schema "phoenix_kit_my_module_items" do + field :name, :string + field :status, :string, default: "active" + + belongs_to :user, PhoenixKit.Users.Auth.User, + foreign_key: :user_uuid, references: :uuid, type: UUIDv7 + + timestamps(type: :utc_datetime) + end end ``` -**3. No config needed.** Auto-discovery finds the module via beam scanning because your app depends on `:phoenix_kit`. +### Versioned migrations -**4. Optional explicit config (backwards compat):** +See the hello_world README for a complete migration coordinator example with V01, V02, install task, and upgrade workflow. -```elixir -config :phoenix_kit, :modules, [PhoenixKitAnalytics] -``` +Key rules: +- Version modules are immutable — never edit a shipped V01 +- Use `create_if_not_exists` and `add_if_not_exists` for idempotency +- Track version via SQL comment: `COMMENT ON TABLE {table} IS '{version}'` +- Use `uuid_generate_v7()` for new UUID columns (not `gen_random_uuid()`) -**5. Create a Paths module** to centralize all navigation paths (see [Navigation Paths in Templates](#navigation-paths-in-templates)): +### Foreign keys -```elixir -defmodule PhoenixKitAnalytics.Paths do - alias PhoenixKit.Utils.Routes - @base "/admin/analytics" +Safe to reference: - def index, do: Routes.path(@base) - def show(id), do: Routes.path("#{@base}/#{id}") -end -``` +| Table | Primary key | +|---|---| +| `phoenix_kit_users` | `uuid` (UUIDv7) | +| `phoenix_kit_user_roles` | `uuid` (UUIDv7) | +| `phoenix_kit_settings` | `uuid` (UUIDv7) | -**6. Routes require recompile** after adding the dependency (standard Phoenix constraint). +Always reference `uuid`, not `id` (integer IDs are deprecated). --- -## JavaScript in External Modules - -External modules **cannot inject into the parent app's asset pipeline** (`app.js`, `esbuild`, `node_modules`). All JavaScript must be delivered as **inline ` - """ - end +**2. Implement the behaviour:** + +```elixir +defmodule PhoenixKitAnalytics do + use PhoenixKit.Module + # ... callbacks end ``` -**Rules:** -- Register hooks on `window.PhoenixKitHooks` — PhoenixKit spreads this object into the LiveSocket -- Pages using hooks must use **full page load** (`redirect/2`, not `navigate/2`) so the inline script executes -- For large vendor libraries, ship minified files in `priv/static/vendor/` and load via ` """ end From 10ca999206b7282c6beabd44c3f03a76ab85584d Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 17:06:26 +0200 Subject: [PATCH 64/99] Add inline status control to publishing listing post cards Add a status dropdown next to the primary language button on each post card, styled to match with status-colored background and border. Changing status switches to the target tab and reloads the view. Use status in the form id to force morphdom to fully replace the select element, preventing browser state desync on rapid changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/listing.ex | 2 + lib/modules/publishing/web/listing.html.heex | 160 ++++++++++++------- 2 files changed, 103 insertions(+), 59 deletions(-) diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index 1d9c868f4..b5ce2bd55 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -283,6 +283,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do {:noreply, socket + |> assign(:post_view_mode, new_status) |> put_flash(:info, gettext("Status updated to %{status}", status: new_status)) |> reload_current_view()} @@ -329,6 +330,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do {:noreply, socket + |> assign(:post_view_mode, new_status) |> put_flash(:info, gettext("Status updated to %{status}", status: new_status)) |> reload_current_view()} diff --git a/lib/modules/publishing/web/listing.html.heex b/lib/modules/publishing/web/listing.html.heex index 832fb277d..e1d1ebffb 100644 --- a/lib/modules/publishing/web/listing.html.heex +++ b/lib/modules/publishing/web/listing.html.heex @@ -161,6 +161,7 @@ <%!-- Version info (only for slug-mode posts) --%> <% version_count = length(Map.get(post, :available_versions) || []) %> <% is_versioned = post.mode in [:slug, "slug"] %> + <% post_status = post.metadata.status %>
<%!-- Post info section --%> @@ -200,7 +201,7 @@ {post.metadata.title || gettext("Untitled post")} - <%= if post.metadata.status == "published" do %> + <%= if post_status == "published" do %>

{gettext("Public URL")}: @@ -228,44 +229,83 @@ <% primary_lang = Enum.find(all_languages, & &1.is_primary) %> <% other_languages = Enum.reject(all_languages, & &1.is_primary) %> - <%!-- Primary language (clickable, prominent) --%> + <%!-- Primary language + status control --%> <%= if primary_lang do %> - +

+ + <%= if @post_view_mode != "trashed" do %> +
+ +
+ +
+
+ <% end %> +
<% end %> <%!-- Other translations (excluding primary) --%> @@ -311,27 +351,29 @@ <% end %> <%!-- Post actions --%>
- <%= if @post_view_mode == "trashed" do %> - - <% else %> - - <% end %> +
+ <%= if @post_view_mode == "trashed" do %> + + <% else %> + + <% end %> +
From 11317de1830c11fb61f0f22206568bcd50e00ade Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 17:27:29 +0200 Subject: [PATCH 65/99] Add clear translation button to publishing editor Add a button in the editor sidebar for non-primary languages that hard-deletes the translation content row from the database. The language disappears from the post as if no translation was ever created, and can be re-added later. Prevents deleting the last remaining language. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/editor.ex | 64 +++++++++++++++++++++ lib/modules/publishing/web/editor.html.heex | 17 ++++++ 2 files changed, 81 insertions(+) diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index 5d07950d1..7c824e43c 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -655,6 +655,70 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do def handle_event("noop", _params, socket), do: {:noreply, socket} + def handle_event("clear_translation", _params, socket) do + alias PhoenixKit.Modules.Publishing.DBStorage + alias PhoenixKit.Modules.Publishing.ListingCache + alias PhoenixKit.Modules.Publishing.Shared + + group_slug = socket.assigns.group_slug + post = socket.assigns.post + language = socket.assigns.current_language + post_uuid = post[:uuid] + + result = + with db_post when not is_nil(db_post) <- DBStorage.get_post_by_uuid(post_uuid, [:group]), + db_version when not is_nil(db_version) <- Shared.resolve_db_version(db_post, nil), + content when not is_nil(content) <- + DBStorage.get_content(db_version.uuid, language) do + # Don't delete the last language + remaining = + DBStorage.list_contents(db_version.uuid) + |> Enum.reject(&(&1.language == language)) + + if remaining == [] do + {:error, :last_language} + else + repo = PhoenixKit.RepoHelper.repo() + + case repo.delete(content) do + {:ok, _} -> + ListingCache.regenerate(group_slug) + + PublishingPubSub.broadcast_translation_deleted( + group_slug, + db_post.slug || db_post.uuid, + language + ) + + :ok + + {:error, reason} -> + {:error, reason} + end + end + else + nil -> {:error, :not_found} + end + + case result do + :ok -> + primary_lang = post[:primary_language] || Publishing.get_primary_language() + url = Helpers.build_edit_url(group_slug, post, lang: primary_lang) + + {:noreply, + socket + |> put_flash(:info, gettext("Translation cleared")) + |> push_navigate(to: url)} + + {:error, :last_language} -> + {:noreply, + put_flash(socket, :error, gettext("Cannot remove the last language from a post"))} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, gettext("Failed to clear translation"))} + end + end + # ============================================================================ # Handle Events - Media # ============================================================================ diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index e8280be56..78f1140e5 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -1047,6 +1047,23 @@ {gettext("Not visible — publish the primary language first")}
<% end %> + <%!-- Clear translation button --%> + <%= if has_content do %> + + <% end %> <% end %> <%!-- Publication date: only editable by primary language --%> From 9e8139d1d7c1e360a4392846bf42e975af64164a Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 18:47:12 +0200 Subject: [PATCH 66/99] Add skeleton loading placeholders to publishing editor and listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor: show pulsing skeleton placeholders when switching between language translations. Uses a deferred message pattern — the handler sets @editor_loading=true and sends itself a message to do the actual push_patch, ensuring LiveView renders the skeleton before starting the server round-trip. Uses bg-base-200 with animate-pulse instead of DaisyUI skeleton class which depends on --color-base-300 (broken in some PhoenixKit themes). Listing: replace the loading spinner with skeleton card placeholders that match the post card layout. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/editor.ex | 61 +++++++++------ lib/modules/publishing/web/editor.html.heex | 79 ++++++++++---------- lib/modules/publishing/web/listing.html.heex | 24 +++++- 3 files changed, 100 insertions(+), 64 deletions(-) diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index 7c824e43c..11c6b37a6 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -59,15 +59,15 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do defdelegate build_editor_languages(post, enabled_languages, current_language), to: Helpers - # JS command for language switching: instantly shows skeleton, hides fields + # JS command for language switching. Skeleton visibility is controlled + # server-side via @editor_loading assign — the switch_language handler sets + # it to true (showing skeleton, hiding fields), and handle_params sets it + # back to false when the new language data is ready. defp switch_lang_js(lang_code, current_lang) do if lang_code == current_lang do - # Already on this language — no-op to prevent skeleton ghosts %JS{} else JS.push("switch_language", value: %{language: lang_code}) - |> JS.add_class("hidden", to: "[data-translatable=fields]") - |> JS.remove_class("hidden", to: "[data-translatable=skeletons]") end end @@ -122,6 +122,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do |> assign(:has_pending_changes, false) |> assign(:is_new_post, false) |> assign(:is_new_translation, false) + |> assign(:editor_loading, false) |> assign(:public_url, nil) |> assign(:current_version, nil) |> assign(:available_versions, []) @@ -237,12 +238,14 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do old_post_slug: old_post_slug ) |> Translation.maybe_restore_translation_status() + |> assign(:editor_loading, false) {:noreply, socket} {:error, _reason} -> {:noreply, socket + |> assign(:editor_loading, false) |> put_flash(:error, gettext("Post not found")) |> push_navigate(to: Routes.path("/admin/publishing/#{group_slug}"))} end @@ -288,12 +291,14 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do old_post_slug: old_post_slug ) |> Translation.maybe_restore_translation_status() + |> assign(:editor_loading, false) {:noreply, socket} {:error, _reason} -> {:noreply, socket + |> assign(:editor_loading, false) |> put_flash(:error, gettext("Post not found")) |> push_navigate(to: Routes.path("/admin/publishing/#{group_slug}"))} end @@ -1086,6 +1091,28 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do # Handle Info - Autosave # ============================================================================ + @impl true + def handle_info({:deferred_language_switch, group_slug, target_language}, socket) do + old_form_key = socket.assigns[:form_key] + + if old_form_key && connected?(socket) do + alias PhoenixKit.Modules.Publishing.PresenceHelpers + PresenceHelpers.untrack_editing_session(old_form_key, socket) + PresenceHelpers.unsubscribe_from_editing(old_form_key) + PublishingPubSub.unsubscribe_from_editor_form(old_form_key) + end + + post = socket.assigns.post + + url = + Helpers.build_edit_url(group_slug, post, + lang: target_language, + version: socket.assigns[:current_version] + ) + + {:noreply, push_patch(socket, to: url)} + end + @impl true def handle_info(:autosave, socket) do if socket.assigns.has_pending_changes and not socket.assigns.translation_locked? do @@ -1564,24 +1591,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do end defp switch_to_existing_language(socket, group_slug, target_language) do - old_form_key = socket.assigns[:form_key] - - if old_form_key && connected?(socket) do - alias PhoenixKit.Modules.Publishing.PresenceHelpers - PresenceHelpers.untrack_editing_session(old_form_key, socket) - PresenceHelpers.unsubscribe_from_editing(old_form_key) - PublishingPubSub.unsubscribe_from_editor_form(old_form_key) - end - - post = socket.assigns.post - - url = - Helpers.build_edit_url(group_slug, post, - lang: target_language, - version: socket.assigns[:current_version] - ) + # Set loading state first, then defer the actual patch so LiveView + # sends the skeleton-visible diff before starting the patch round-trip. + send(self(), {:deferred_language_switch, group_slug, target_language}) - {:noreply, push_patch(socket, to: url)} + {:noreply, assign(socket, :editor_loading, true)} end defp switch_to_new_translation(socket, post, group_slug, new_language) do @@ -1620,7 +1634,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do url = Helpers.build_edit_url(group_slug, post, lang: new_language, version: current_version) - {:noreply, push_patch(socket, to: url, replace: true)} + {:noreply, + socket + |> assign(:editor_loading, true) + |> push_patch(to: url, replace: true)} end defp handle_media_selected(socket, file_ids) do diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index 78f1140e5..feb4d0246 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -233,7 +233,12 @@ - - <% end %> + )} + + + + <% end %> -
- <%= if @view_mode == "active" do %> - <.link - navigate={Routes.path("/admin/publishing/#{insight.slug}")} - class="btn btn-outline btn-sm" - > - <.icon name="hero-arrow-right" class="w-4 h-4 mr-1" /> - {gettext("Open")} - - <.link - navigate={Routes.path("/admin/publishing/edit-group/#{insight.slug}")} - class="btn btn-outline btn-sm" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> - {gettext("Settings")} - - <%= if insight.published_count > 0 do %> - <% group_slug = insight.slug %> - <% url_prefix = - PhoenixKit.Config.get_url_prefix() - |> case do - "/" -> "" - prefix -> prefix - end %> - <% default_language = List.first(@enabled_languages) %> - <% public_url = - if length(@enabled_languages) == 1 do - @endpoint_url <> url_prefix <> "/" <> group_slug - else - @endpoint_url <> url_prefix <> "/#{default_language}/" <> group_slug - end %> - + <%= if @view_mode == "active" do %> + <.link + navigate={Routes.path("/admin/publishing/#{insight.slug}")} class="btn btn-outline btn-sm" - aria-label={gettext("View public site")} > - <.icon name="hero-eye" class="w-4 h-4 mr-1" /> - {gettext("Public")} - + <.icon name="hero-arrow-right" class="w-4 h-4 mr-1" /> + {gettext("Open")} + + <.link + navigate={Routes.path("/admin/publishing/edit-group/#{insight.slug}")} + class="btn btn-outline btn-sm" + > + <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> + {gettext("Settings")} + + <%= if insight.published_count > 0 do %> + <% group_slug = insight.slug %> + <% url_prefix = + PhoenixKit.Config.get_url_prefix() + |> case do + "/" -> "" + prefix -> prefix + end %> + <% default_language = List.first(@enabled_languages) %> + <% public_url = + if length(@enabled_languages) == 1 do + @endpoint_url <> url_prefix <> "/" <> group_slug + else + @endpoint_url <> url_prefix <> "/#{default_language}/" <> group_slug + end %> + + <.icon name="hero-eye" class="w-4 h-4 mr-1" /> + {gettext("Public")} + + <% end %> + + <% else %> + + <% end %> - - <% else %> - - - <% end %> +
- - <% end %> - + <% end %> + + <% end %> - <%= if @dashboard_insights == [] and @view_mode == "trashed" do %> + <%= if not @loading and @dashboard_insights == [] and @view_mode == "trashed" do %>
<.icon name="hero-trash" class="w-8 h-8 mx-auto mb-2 opacity-40" />

{gettext("Trash is empty")}

diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index b5ce2bd55..cd8c0cffd 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -188,11 +188,14 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do def handle_event("switch_post_view", %{"mode" => mode}, socket) when mode in @valid_post_views do + send(self(), {:deferred_tab_switch, mode}) + {:noreply, socket |> assign(:post_view_mode, mode) + |> assign(:posts, []) |> assign(:visible_count, 20) - |> load_posts_for_view()} + |> assign(:loading, true)} end def handle_event("trash_post", %{"uuid" => post_uuid}, socket) do @@ -398,6 +401,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do socket |> assign(:posts, posts) |> assign(:post_status_counts, build_status_counts(all_posts, trashed_count)) + |> assign(:loading, false) end defp build_status_counts(posts, trashed_count) do @@ -430,6 +434,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do } end + @impl true + def handle_info({:deferred_tab_switch, _mode}, socket) do + {:noreply, load_posts_for_view(socket)} + end + # PubSub handlers for live updates @impl true def handle_info({:post_created, _post}, socket) do From 06cea910f5deae52c4ee803af1183b8b74447e43 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 19:14:38 +0200 Subject: [PATCH 69/99] Hard-delete empty posts instead of trashing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty posts (no content in any version) are abandoned drafts with no recoverable content. Trashing them created a restore → auto-trash loop. Now they get hard-deleted via cascade (post + versions + contents). Also scan trashed posts during the stale fixer pass so previously trashed empty posts get cleaned up too. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/stale_fixer.ex | 10 ++++++---- lib/modules/publishing/web/listing.ex | 9 ++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/modules/publishing/stale_fixer.ex b/lib/modules/publishing/stale_fixer.ex index 8eb58059b..7be249795 100644 --- a/lib/modules/publishing/stale_fixer.ex +++ b/lib/modules/publishing/stale_fixer.ex @@ -95,11 +95,13 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do """ @spec fix_stale_post(PublishingPost.t()) :: PublishingPost.t() def fix_stale_post(%PublishingPost{} = post) do - # Trash empty posts (no content rows at all across any version) + # Hard-delete empty posts (no content in any version) — they're abandoned + # drafts with no recoverable content, so trashing them just creates a + # restore → auto-trash loop. if empty_post?(post) do - Logger.info("[Publishing] Trashing empty post #{post.uuid} (no content in any version)") - DBStorage.update_post(post, %{status: "trashed"}) - DBStorage.get_post_by_uuid(post.uuid, [:group]) || post + Logger.info("[Publishing] Deleting empty post #{post.uuid} (no content in any version)") + DBStorage.delete_post(post) + post else post = apply_stale_fix(post, build_post_fixes(post), &DBStorage.update_post/2) diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index cd8c0cffd..bdc036b2f 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -100,11 +100,14 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do socket = handle_subscription_change(socket, old_group_slug, new_group_slug) - # Run stale fixer for this group's posts on first connected load + # Run stale fixer for this group's posts on first connected load. + # Includes trashed posts so empty ones get hard-deleted instead of + # sitting in trash with no recoverable content. if connected?(socket) and new_group_slug do Task.start(fn -> - DBStorage.list_posts(new_group_slug) - |> Enum.each(&StaleFixer.fix_stale_post/1) + active = DBStorage.list_posts(new_group_slug) + trashed = DBStorage.list_posts(new_group_slug, "trashed") + Enum.each(active ++ trashed, &StaleFixer.fix_stale_post/1) end) end From 748d4d81aa5afb822b4cb40a19781913a7e8e459 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 19:31:21 +0200 Subject: [PATCH 70/99] Fix group slug overwrite on name-only edit and remove folder references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix maybe_generate_slug to only auto-generate for new records — on updates where the slug hasn't changed, Ecto sees no change and the function was incorrectly regenerating from the (modified) name. Remove all folder/directory references from user-facing text, code comments, and internal function names. Publishing is fully DB-backed now — rename directory_slug to post_slug, parse_version_directory to parse_version_segment, and update all related docs and error messages. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/db_storage/mapper.ex | 2 +- .../publishing/schemas/publishing_group.ex | 9 +++-- lib/modules/publishing/shared.ex | 6 ++-- lib/modules/publishing/slug_helpers.ex | 34 +++++++++---------- .../web/controller/slug_resolution.ex | 2 +- lib/modules/publishing/web/edit.ex | 2 +- lib/modules/publishing/web/edit.html.heex | 11 ++---- lib/modules/publishing/web/editor.html.heex | 2 +- .../publishing/web/editor/persistence.ex | 2 +- lib/modules/publishing/web/new.html.heex | 2 +- test/modules/publishing/shared_test.exs | 22 ++++++------ 11 files changed, 46 insertions(+), 48 deletions(-) diff --git a/lib/modules/publishing/db_storage/mapper.ex b/lib/modules/publishing/db_storage/mapper.ex index e47bbf9b6..e68956362 100644 --- a/lib/modules/publishing/db_storage/mapper.ex +++ b/lib/modules/publishing/db_storage/mapper.ex @@ -7,7 +7,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.Mapper do The web layer expects maps with these keys: - `:group` - group slug - - `:slug` - post directory slug + - `:slug` - post slug identifier - `:url_slug` - per-language URL slug - `:date` - Date struct (timestamp mode) - `:time` - Time struct (timestamp mode) diff --git a/lib/modules/publishing/schemas/publishing_group.ex b/lib/modules/publishing/schemas/publishing_group.ex index ed42a63f5..7866a90ed 100644 --- a/lib/modules/publishing/schemas/publishing_group.ex +++ b/lib/modules/publishing/schemas/publishing_group.ex @@ -91,8 +91,13 @@ defmodule PhoenixKit.Modules.Publishing.PublishingGroup do def views_enabled?(%__MODULE__{data: data}), do: Map.get(data, "views_enabled", false) defp maybe_generate_slug(changeset) do + # Only auto-generate slug for new records (no existing slug). + # On updates, Ecto won't register the slug as a "change" if it's the same, + # and we must NOT regenerate from the (potentially changed) name. + existing_slug = get_field(changeset, :slug) + case get_change(changeset, :slug) do - nil -> + nil when is_nil(existing_slug) or existing_slug == "" -> name = get_field(changeset, :name) if name do @@ -108,7 +113,7 @@ defmodule PhoenixKit.Modules.Publishing.PublishingGroup do changeset end - _slug -> + _ -> changeset end end diff --git a/lib/modules/publishing/shared.ex b/lib/modules/publishing/shared.ex index fff50448c..78bdd82c3 100644 --- a/lib/modules/publishing/shared.ex +++ b/lib/modules/publishing/shared.ex @@ -198,21 +198,21 @@ defmodule PhoenixKit.Modules.Publishing.Shared do def extract_version_from_parts([]), do: {nil, []} def extract_version_from_parts([first | rest] = parts) do - case parse_version_directory(first) do + case parse_version_segment(first) do {:ok, version} -> {version, rest} :error -> {nil, parts} end end @doc false - def parse_version_directory(segment) when is_binary(segment) do + def parse_version_segment(segment) when is_binary(segment) do case Regex.run(~r/^v(\d+)$/, segment) do [_, num_str] -> {:ok, String.to_integer(num_str)} nil -> :error end end - def parse_version_directory(_), do: :error + def parse_version_segment(_), do: :error # ============================================================================ # Version Resolution diff --git a/lib/modules/publishing/slug_helpers.ex b/lib/modules/publishing/slug_helpers.ex index 3a2f2f2ea..76955eec4 100644 --- a/lib/modules/publishing/slug_helpers.ex +++ b/lib/modules/publishing/slug_helpers.ex @@ -63,8 +63,8 @@ defmodule PhoenixKit.Modules.Publishing.SlugHelpers do url_slug in @reserved_route_words -> {:error, :reserved_route_word} - conflicts_with_directory_slug?(group_slug, url_slug, exclude_post_slug) -> - {:error, :conflicts_with_directory_slug} + conflicts_with_post_slug?(group_slug, url_slug, exclude_post_slug) -> + {:error, :conflicts_with_post_slug} url_slug_exists?(group_slug, url_slug, language, exclude_post_slug) -> {:error, :slug_already_exists} @@ -88,15 +88,15 @@ defmodule PhoenixKit.Modules.Publishing.SlugHelpers do end @doc """ - Clears custom url_slugs that conflict with a given directory slug. + Clears custom url_slugs that conflict with a given post slug. """ @spec clear_conflicting_url_slugs(String.t(), String.t()) :: [{String.t(), String.t()}] - def clear_conflicting_url_slugs(group_slug, directory_slug) do + def clear_conflicting_url_slugs(group_slug, post_slug) do case ListingCache.read(group_slug) do {:ok, posts} -> - conflicts = find_conflicting_url_slugs(posts, directory_slug) - clear_url_slugs_for_conflicts(group_slug, directory_slug, conflicts) - log_cleared_conflicts(conflicts, directory_slug) + conflicts = find_conflicting_url_slugs(posts, post_slug) + clear_url_slugs_for_conflicts(group_slug, post_slug, conflicts) + log_cleared_conflicts(conflicts, post_slug) conflicts {:error, _} -> @@ -160,7 +160,7 @@ defmodule PhoenixKit.Modules.Publishing.SlugHelpers do # Private Helpers # =========================================================================== - defp conflicts_with_directory_slug?(group_slug, url_slug, exclude_post_slug) do + defp conflicts_with_post_slug?(group_slug, url_slug, exclude_post_slug) do if url_slug == exclude_post_slug do false else @@ -200,29 +200,29 @@ defmodule PhoenixKit.Modules.Publishing.SlugHelpers do slug_exists?(group_slug, candidate) end - defp find_conflicting_url_slugs(posts, directory_slug) do + defp find_conflicting_url_slugs(posts, post_slug) do Enum.flat_map(posts, fn post -> - if post.slug == directory_slug do + if post.slug == post_slug do [] else (post.language_slugs || %{}) - |> Enum.filter(fn {_lang, url_slug} -> url_slug == directory_slug end) + |> Enum.filter(fn {_lang, url_slug} -> url_slug == post_slug end) |> Enum.map(fn {lang, _} -> {post.slug, lang} end) end end) end - defp clear_url_slugs_for_conflicts(group_slug, directory_slug, conflicts) do - Enum.each(conflicts, fn {post_slug, _language} -> - DBStorage.clear_url_slug_from_post(group_slug, post_slug, directory_slug) + defp clear_url_slugs_for_conflicts(group_slug, slug_to_clear, conflicts) do + Enum.each(conflicts, fn {conflicting_post_slug, _language} -> + DBStorage.clear_url_slug_from_post(group_slug, conflicting_post_slug, slug_to_clear) end) end - defp log_cleared_conflicts([], _directory_slug), do: :ok + defp log_cleared_conflicts([], _post_slug), do: :ok - defp log_cleared_conflicts(conflicts, directory_slug) do + defp log_cleared_conflicts(conflicts, post_slug) do Logger.warning( - "[Slugs] Cleared conflicting url_slugs for directory slug '#{directory_slug}': #{inspect(conflicts)}" + "[Slugs] Cleared conflicting url_slugs for post slug '#{post_slug}': #{inspect(conflicts)}" ) end end diff --git a/lib/modules/publishing/web/controller/slug_resolution.ex b/lib/modules/publishing/web/controller/slug_resolution.ex index c97ea4564..b0e10b653 100644 --- a/lib/modules/publishing/web/controller/slug_resolution.ex +++ b/lib/modules/publishing/web/controller/slug_resolution.ex @@ -59,7 +59,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution do def resolve_url_slug(_group_slug, _identifier, _language), do: :passthrough @doc """ - Resolves a URL slug to the internal directory slug. + Resolves a URL slug to the internal post slug. Used by versioned URL handler and other places that need the internal slug. """ def resolve_url_slug_to_internal(group_slug, url_slug, language) do diff --git a/lib/modules/publishing/web/edit.ex b/lib/modules/publishing/web/edit.ex index 408922057..be4424e44 100644 --- a/lib/modules/publishing/web/edit.ex +++ b/lib/modules/publishing/web/edit.ex @@ -89,7 +89,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Edit do {:error, :destination_exists} -> {:noreply, socket - |> put_flash(:error, gettext("A directory already exists for that slug.")) + |> put_flash(:error, gettext("Another group already uses that slug.")) |> assign(:form, Component.to_form(params, as: :group))} {:error, reason} -> diff --git a/lib/modules/publishing/web/edit.html.heex b/lib/modules/publishing/web/edit.html.heex index baa662093..b874a4073 100644 --- a/lib/modules/publishing/web/edit.html.heex +++ b/lib/modules/publishing/web/edit.html.heex @@ -11,11 +11,6 @@ <.admin_page_header back={Routes.path("/admin/publishing")} title={gettext("Edit Group")} - subtitle={ - gettext( - "Update the group's display name and slug. The slug controls the folder structure used for storing posts, so changing it will rename the underlying directory." - ) - } />
@@ -46,9 +41,7 @@ />

- {gettext( - "Controls the folder structure for storing posts. Changing this will rename the directory." - )} + {gettext("The slug is used in URLs for this group's public pages.")}

{gettext("Format")}: @@ -83,7 +76,7 @@ )} <% _ -> %> {gettext("Timestamp-based")} · {gettext( - "Chronological folders ideal for news and updates." + "Chronological URLs ideal for news and updates." )} <% end %>

diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index feb4d0246..c74c521b5 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -794,7 +794,7 @@
<%= if @group_mode == "slug" do %> <%= if @is_primary_language do %> - <%!-- Primary language: editable slug that sets the directory name --%> + <%!-- Primary language: editable slug used in the post URL --%>
<% end %> +
<% end %> @@ -371,32 +381,20 @@ <% end %>
<% end %> - <%!-- Post actions --%> -
-
- <%= if @post_view_mode == "trashed" do %> - - <% else %> - - <% end %> + <%!-- Restore button (trash view only) --%> + <%= if @post_view_mode == "trashed" do %> +
+
-
+ <% end %>
<% end %> From 1d2022994a3fa8e843b66de4ec8f4297f0288d7b Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 20:34:55 +0200 Subject: [PATCH 74/99] Improve trashed posts display and load full metadata for trash tab Load trashed posts with full metadata (titles, languages, versions) instead of bare struct-to-map conversion that showed everything as untitled. Make trashed post cards non-interactive: title is plain text, primary language is a static badge with status dot, translations shown dimmed with pointer-events-none, no public URL. Move restore button next to primary language with clear green styling. Add cursor-pointer to tab buttons on listing and index pages. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/db_storage.ex | 4 +- lib/modules/publishing/web/index.html.heex | 4 +- lib/modules/publishing/web/listing.ex | 32 +-- lib/modules/publishing/web/listing.html.heex | 193 +++++++++++-------- 4 files changed, 124 insertions(+), 109 deletions(-) diff --git a/lib/modules/publishing/db_storage.ex b/lib/modules/publishing/db_storage.ex index d6bf98e95..bb30c327c 100644 --- a/lib/modules/publishing/db_storage.ex +++ b/lib/modules/publishing/db_storage.ex @@ -635,8 +635,8 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do Returns a list of post maps suitable for listing pages. """ - def list_posts_with_metadata(group_slug) do - posts = list_posts(group_slug) + 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 diff --git a/lib/modules/publishing/web/index.html.heex b/lib/modules/publishing/web/index.html.heex index 6c6b40cc3..e647f6793 100644 --- a/lib/modules/publishing/web/index.html.heex +++ b/lib/modules/publishing/web/index.html.heex @@ -55,7 +55,7 @@ type="button" phx-click="switch_view" phx-value-mode="active" - class={"px-3 py-1 text-xs font-medium border-b-2 transition-colors #{if @view_mode == "active", do: "border-primary text-primary", else: "border-transparent text-base-content/50 hover:text-base-content"}"} + class={"px-3 py-1 text-xs font-medium border-b-2 transition-colors cursor-pointer #{if @view_mode == "active", do: "border-primary text-primary", else: "border-transparent text-base-content/50 hover:text-base-content"}"} > {gettext("Active")} @@ -63,7 +63,7 @@ type="button" phx-click="switch_view" phx-value-mode="trashed" - class={"px-3 py-1 text-xs font-medium border-b-2 transition-colors #{if @view_mode == "trashed", do: "border-error text-error", else: "border-transparent text-base-content/50 hover:text-base-content"}"} + class={"px-3 py-1 text-xs font-medium border-b-2 transition-colors cursor-pointer #{if @view_mode == "trashed", do: "border-error text-error", else: "border-transparent text-base-content/50 hover:text-base-content"}"} > {gettext("Trash")} diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index 7bb87a3f8..674000122 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -59,8 +59,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do filtered_posts = case default_mode do "trashed" -> - DBStorage.list_posts(group_slug, "trashed") - |> Enum.map(&post_struct_to_map/1) + DBStorage.list_posts_with_metadata(group_slug, "trashed") status -> Enum.filter(all_posts, fn p -> p[:metadata] && p.metadata.status == status end) @@ -150,8 +149,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do filtered_posts = case default_mode do "trashed" -> - DBStorage.list_posts(new_group_slug, "trashed") - |> Enum.map(&post_struct_to_map/1) + DBStorage.list_posts_with_metadata(new_group_slug, "trashed") status -> Enum.filter(posts, fn p -> p[:metadata] && p.metadata.status == status end) @@ -392,8 +390,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do posts = case mode do "trashed" -> - DBStorage.list_posts(group_slug, "trashed") - |> Enum.map(&post_struct_to_map/1) + DBStorage.list_posts_with_metadata(group_slug, "trashed") status -> Enum.filter(all_posts, fn p -> p[:metadata] && p.metadata.status == status end) @@ -415,26 +412,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do Map.put(counts, "trashed", trashed_count) end - defp post_struct_to_map(%{} = post) do - %{ - uuid: post.uuid, - slug: post.slug, - group: post.group && post.group.slug, - mode: if(post.mode == "timestamp", do: :timestamp, else: :slug), - date: post.post_date, - time: post.post_time, - metadata: %{ - title: nil, - status: post.status - }, - available_languages: [], - language_statuses: %{}, - available_versions: [], - version: nil, - primary_language: post.primary_language - } - end - @impl true def handle_info({:deferred_tab_switch, _mode}, socket) do {:noreply, load_posts_for_view(socket)} @@ -648,8 +625,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do filtered_posts = case mode do "trashed" -> - DBStorage.list_posts(group_slug, "trashed") - |> Enum.map(&post_struct_to_map/1) + DBStorage.list_posts_with_metadata(group_slug, "trashed") status -> Enum.filter(all_posts, fn p -> p[:metadata] && p.metadata.status == status end) diff --git a/lib/modules/publishing/web/listing.html.heex b/lib/modules/publishing/web/listing.html.heex index 6120bc24a..9542dc548 100644 --- a/lib/modules/publishing/web/listing.html.heex +++ b/lib/modules/publishing/web/listing.html.heex @@ -112,7 +112,7 @@ type="button" phx-click="switch_post_view" phx-value-mode={mode} - class={"px-3 py-1 text-xs font-medium border-b-2 transition-colors whitespace-nowrap #{cond do + class={"px-3 py-1 text-xs font-medium border-b-2 transition-colors whitespace-nowrap cursor-pointer #{cond do @post_view_mode == mode and color == "error" -> "border-error text-error" @post_view_mode == mode -> "border-primary text-primary" true -> "border-transparent text-base-content/50 hover:text-base-content" @@ -212,18 +212,22 @@ <% end %>

- <.link - href={ - PhoenixKit.Utils.Routes.path( - "/admin/publishing/#{group_slug}/#{post.uuid}/edit?lang=#{post_primary_lang}" - ) - } - class="hover:text-primary transition-colors" - > + <%= if @post_view_mode == "trashed" do %> {post.metadata.title || gettext("Untitled post")} - + <% else %> + <.link + href={ + PhoenixKit.Utils.Routes.path( + "/admin/publishing/#{group_slug}/#{post.uuid}/edit?lang=#{post_primary_lang}" + ) + } + class="hover:text-primary transition-colors" + > + {post.metadata.title || gettext("Untitled post")} + + <% end %>

- <%= if post_status == "published" do %> + <%= if post_status == "published" and @post_view_mode != "trashed" do %>

{gettext("Public URL")}: @@ -254,42 +258,67 @@ <%!-- Primary language + status control --%> <%= if primary_lang do %>

- + <% else %> + + <% end %> <%= if @post_view_mode != "trashed" do %>
<% end %> - + <%= if @post_view_mode != "trashed" do %> + + <% else %> + + <% end %>
<% end %> <%!-- Other translations (excluding primary) --%> - <.publishing_language_switcher - languages={other_languages} - show_status={true} - show_add={true} - on_click="language_action" - size={:sm} - /> + <%= if @post_view_mode == "trashed" do %> +
+ <.publishing_language_switcher + languages={other_languages} + show_status={true} + show_add={false} + size={:sm} + /> +
+ <% else %> + <.publishing_language_switcher + languages={other_languages} + show_status={true} + show_add={true} + on_click="language_action" + size={:sm} + /> + <% end %> <%!-- Translation Progress Bar --%> <%= if translation_status = Map.get(@translating_posts, post.slug) do %> @@ -381,20 +434,6 @@ <% end %> <% end %> - <%!-- Restore button (trash view only) --%> - <%= if @post_view_mode == "trashed" do %> -
- -
- <% end %> <% end %> From 4ab8cea24354cd919df23b16eff29d0880f84aaf Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 20:47:00 +0200 Subject: [PATCH 75/99] Make publishing buttons responsive on mobile screens Index page: group action buttons show icon-only on mobile with flex-1 to share space evenly, full labels on desktop. Editor page: preview and view public buttons show icon-only on mobile, header row uses flex-wrap for clean wrapping. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/editor.html.heex | 14 +++++---- lib/modules/publishing/web/index.html.heex | 34 ++++++++++----------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index 6f2e25842..3fbe0eaa4 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -152,26 +152,27 @@
-
+
-
+
<%= if @form["status"] == "published" && @public_url do %> - <.icon name="hero-globe-alt" class="w-4 h-4 mr-1" /> View Public + <.icon name="hero-globe-alt" class="w-4 h-4 sm:mr-1" /> + <% end %>
diff --git a/lib/modules/publishing/web/index.html.heex b/lib/modules/publishing/web/index.html.heex index e647f6793..4adec148b 100644 --- a/lib/modules/publishing/web/index.html.heex +++ b/lib/modules/publishing/web/index.html.heex @@ -208,21 +208,21 @@
<% end %> -
+
<%= if @view_mode == "active" do %> <.link navigate={Routes.path("/admin/publishing/#{insight.slug}")} - class="btn btn-outline btn-sm" + class="btn btn-outline btn-sm btn-xs sm:btn-sm flex-1 sm:flex-none min-w-0" > - <.icon name="hero-arrow-right" class="w-4 h-4 mr-1" /> - {gettext("Open")} + <.icon name="hero-arrow-right" class="w-4 h-4 sm:mr-1" /> + <.link navigate={Routes.path("/admin/publishing/edit-group/#{insight.slug}")} - class="btn btn-outline btn-sm" + class="btn btn-outline btn-sm btn-xs sm:btn-sm flex-1 sm:flex-none min-w-0" > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> - {gettext("Settings")} + <.icon name="hero-cog-6-tooth" class="w-4 h-4 sm:mr-1" /> + <%= if insight.published_count > 0 do %> <% group_slug = insight.slug %> @@ -243,18 +243,18 @@ href={public_url} target="_blank" rel="noopener" - class="btn btn-outline btn-sm" + class="btn btn-outline btn-sm btn-xs sm:btn-sm flex-1 sm:flex-none min-w-0" aria-label={gettext("View public site")} > - <.icon name="hero-eye" class="w-4 h-4 mr-1" /> - {gettext("Public")} + <.icon name="hero-eye" class="w-4 h-4 sm:mr-1" /> + <% end %> <% end %>
From aec05600400600e282804957d837ba14bc2db4b4 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 20:51:05 +0200 Subject: [PATCH 76/99] Filter trashed posts from URL slug lookups Add p.status != "trashed" filter to find_by_url_slug and find_by_previous_url_slug queries. These public-facing functions were leaking trashed posts that could still be accessed via custom URL slugs or previous URL slug redirects. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/db_storage.ex | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/modules/publishing/db_storage.ex b/lib/modules/publishing/db_storage.ex index bb30c327c..51ed77d09 100644 --- a/lib/modules/publishing/db_storage.ex +++ b/lib/modules/publishing/db_storage.ex @@ -511,7 +511,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do |> repo().all() end - @doc "Finds content by URL slug across all versions in a group." + @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 = @@ -519,7 +519,9 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do 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, + 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() @@ -533,13 +535,14 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do 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)." + @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), @@ -548,6 +551,7 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do 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}}] ) From 6710ff1943fac7e5a62804b224f8dac13435845b Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 20:56:07 +0200 Subject: [PATCH 77/99] Fix trashed post data integrity issues found in audit - count_posts: exclude trashed posts so groups with only trashed posts can be deleted without force flag - publish_version: reject publishing versions on trashed posts - restore_group: check for active slug collision before restoring a trashed group to prevent duplicate slugs Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/db_storage.ex | 4 ++-- lib/modules/publishing/groups.ex | 28 +++++++++++++++++++++------- lib/modules/publishing/versions.ex | 1 + 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/lib/modules/publishing/db_storage.ex b/lib/modules/publishing/db_storage.ex index 51ed77d09..ac1dedfb6 100644 --- a/lib/modules/publishing/db_storage.ex +++ b/lib/modules/publishing/db_storage.ex @@ -187,11 +187,11 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do |> repo().all() end - @doc "Counts posts in a group." + @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, + where: g.slug == ^group_slug and p.status != "trashed", select: count(p.uuid) ) |> repo().one() || 0 diff --git a/lib/modules/publishing/groups.ex b/lib/modules/publishing/groups.ex index f524b63a4..9b00125df 100644 --- a/lib/modules/publishing/groups.ex +++ b/lib/modules/publishing/groups.ex @@ -244,14 +244,28 @@ defmodule PhoenixKit.Modules.Publishing.Groups do {:error, :not_found} db_group -> - case DBStorage.restore_group(db_group) do - {:ok, _} -> - ListingCache.regenerate(slug) - PublishingPubSub.broadcast_group_created(%{"slug" => slug, "name" => db_group.name}) - {:ok, slug} + # 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) - {:error, reason} -> - {:error, reason} + 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 diff --git a/lib/modules/publishing/versions.ex b/lib/modules/publishing/versions.ex index 8abec4635..f2a87d661 100644 --- a/lib/modules/publishing/versions.ex +++ b/lib/modules/publishing/versions.ex @@ -143,6 +143,7 @@ defmodule PhoenixKit.Modules.Publishing.Versions do def publish_version(group_slug, post_uuid, version, opts \\ []) do db_post = DBStorage.get_post_by_uuid(post_uuid, [:group]) unless db_post, do: throw({:error, :not_found}) + if db_post.status == "trashed", do: throw({:error, :post_trashed}) # Wrap the entire publish operation in a transaction for atomicity repo = PhoenixKit.RepoHelper.repo() From 77dc87e4f90106603351bc4c6c3dead59c606005 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 21:07:45 +0200 Subject: [PATCH 78/99] Update publishing README with new admin UI features and corrected delete behavior Add documentation for inline status controls, skeleton loading, trash management, and clear translation. Fix outdated references to soft_delete_post, deleted_at timestamps, and folder-based storage. Update DBStorage API examples to match current function signatures. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/README.md | 76 +++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/lib/modules/publishing/README.md b/lib/modules/publishing/README.md index 39f9af2bf..7968fa1d0 100644 --- a/lib/modules/publishing/README.md +++ b/lib/modules/publishing/README.md @@ -184,8 +184,10 @@ Renderer.clear_all_cache() **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 and featured images (DB-only, gated by migration) +- **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:** @@ -219,6 +221,41 @@ Renderer.clear_all_cache() - **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. @@ -391,7 +428,8 @@ 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 `deleted_at` timestamp). Trashed posts can be restored via DB if needed. +- `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 @@ -488,24 +526,29 @@ posts = Publishing.list_posts("docs", "es") # With language preference ### Delete Operations -Delete operations use DB soft-deletes (setting `status` to `"archived"` or `deleted_at` timestamp): +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 -# Soft-delete post (sets deleted_at, all versions and languages) +# Trash post (soft-delete — sets status to "trashed") {:ok, _} = Publishing.trash_post("docs", "getting-started") -# For timestamp mode, use the date/time path -{:ok, _} = Publishing.trash_post("news", "2025-01-15/14:30") +# For timestamp mode, use the post UUID +{:ok, _} = Publishing.trash_post("news", post_uuid) -# Archive a specific translation (refuses if last active language) -:ok = Publishing.delete_language("docs", "getting-started", "es") -:ok = Publishing.delete_language("docs", "getting-started", "es", 2) # specific version -{:error, :last_language} = Publishing.delete_language("docs", "post", "en") +# Clear a translation (hard-deletes the content row, 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") # Archive a version (refuses if live or last active version) -:ok = Publishing.delete_version("docs", "getting-started", 1) -{:error, :cannot_delete_live} = Publishing.delete_version("docs", "post", 2) -{:error, :last_version} = Publishing.delete_version("docs", "post", 1) +: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 @@ -647,10 +690,11 @@ All create/update/delete operations go through `DBStorage`: alias PhoenixKit.Modules.Publishing.DBStorage # Posts -{:ok, post} = DBStorage.create_post(group_uuid, %{slug: "hello", status: "draft", mode: "slug"}) -{:ok, post} = DBStorage.get_post("docs", "hello") +{: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, deleted} = DBStorage.soft_delete_post(post) +{: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"}) From a89032430937f55ab625a76e2631661197155f10 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 21:21:06 +0200 Subject: [PATCH 79/99] Fix credo complexity and dialyzer warnings in publishing module Extract load_initial_data, apply_full_reload, default_tab_mode, and filter_posts_for_mode from listing mount/handle_params to reduce cyclomatic complexity. Extract primary_content_missing?, fix_version_primary_content, find_best_translation, copy_translation_to_primary, and maybe_promote_post_to_published from stale_fixer fix_missing_primary_content to reduce nesting depth. Suppress dialyzer false positives for defensive catch-all pattern matches and dynamic settings_call dispatch. Co-Authored-By: Claude Opus 4.6 (1M context) --- .dialyzer_ignore.exs | 6 + lib/modules/publishing/shared.ex | 1 + lib/modules/publishing/stale_fixer.ex | 120 +++++++++++--------- lib/modules/publishing/web/controller.ex | 2 + lib/modules/publishing/web/listing.ex | 138 ++++++++++------------- 5 files changed, 135 insertions(+), 132 deletions(-) diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index b3252d1a7..e42bafa06 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -63,6 +63,12 @@ # False positive pattern match warnings (runtime behavior differs from static analysis) {"lib/mix/tasks/phoenix_kit/email_cleanup.ex", :pattern_match, 1}, + + # Publishing module defensive fallbacks and settings_call dynamic dispatch + {"lib/modules/publishing/publishing.ex", :guard_fail}, + {"lib/modules/publishing/publishing.ex", :pattern_match_cov}, + {"lib/modules/publishing/publishing.ex", :pattern_match}, + {"lib/modules/publishing/shared.ex", :guard_fail}, # ExAws library type definition issues (false positives from incomplete type specs) ~r/lib\/modules\/emails\/archiver\.ex:.*pattern_match/, ~r/lib\/modules\/emails\/archiver\.ex:.*unused_fun/, diff --git a/lib/modules/publishing/shared.ex b/lib/modules/publishing/shared.ex index 78bdd82c3..ad07ad410 100644 --- a/lib/modules/publishing/shared.ex +++ b/lib/modules/publishing/shared.ex @@ -69,6 +69,7 @@ defmodule PhoenixKit.Modules.Publishing.Shared do |> maybe_put_audit(:updated_by_email, user_email) end + @dialyzer {:nowarn_function, normalize_audit_value: 1} defp normalize_audit_value(nil), do: nil defp normalize_audit_value(value) when is_binary(value), do: String.trim(value) defp normalize_audit_value(value), do: to_string(value) diff --git a/lib/modules/publishing/stale_fixer.ex b/lib/modules/publishing/stale_fixer.ex index 7be249795..7bcc06573 100644 --- a/lib/modules/publishing/stale_fixer.ex +++ b/lib/modules/publishing/stale_fixer.ex @@ -329,69 +329,79 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do """ def fix_missing_primary_content(%PublishingPost{} = post) do primary_lang = post.primary_language - versions = DBStorage.list_versions(post.uuid) - for version <- versions do + for version <- DBStorage.list_versions(post.uuid) do contents = DBStorage.list_contents(version.uuid) primary_content = Enum.find(contents, &(&1.language == primary_lang)) - primary_missing? = - is_nil(primary_content) or - (primary_content.content in [nil, ""] and primary_content.title in [nil, "", "Untitled"]) - - if primary_missing? and contents != [] do - # Find the best translation — prefer published, then any with content - source = - Enum.find(contents, fn c -> - c.language != primary_lang and c.status == "published" and - c.content not in [nil, ""] - end) || - Enum.find(contents, fn c -> - c.language != primary_lang and c.content not in [nil, ""] - end) - - if source do - Logger.info( - "[Publishing] Fixing missing primary content for post #{post.uuid}/v#{version.version_number}: " <> - "copying from #{source.language} to #{primary_lang}" - ) - - if primary_content do - # Update existing empty primary content - DBStorage.update_content(primary_content, %{ - title: source.title, - content: source.content, - status: source.status, - url_slug: primary_content.url_slug || source.url_slug - }) - else - # Create primary content from translation - DBStorage.create_content(%{ - version_uuid: version.uuid, - language: primary_lang, - title: source.title, - content: source.content, - status: source.status, - url_slug: source.url_slug - }) - end - - # If source was published, make sure post status matches - if source.status == "published" and post.status != "published" do - Logger.info( - "[Publishing] Promoting post #{post.uuid} to published (primary content now has published content)" - ) - - DBStorage.update_post(post, %{ - status: "published", - published_at: post.published_at || DateTime.utc_now() - }) - end - end + if primary_content_missing?(primary_content) and contents != [] do + fix_version_primary_content(post, version, primary_lang, primary_content, contents) end end end + defp primary_content_missing?(nil), do: true + + defp primary_content_missing?(content) do + content.content in [nil, ""] and content.title in [nil, "", "Untitled"] + end + + defp fix_version_primary_content(post, version, primary_lang, primary_content, contents) do + source = find_best_translation(contents, primary_lang) + + if source do + Logger.info( + "[Publishing] Fixing missing primary content for post #{post.uuid}/v#{version.version_number}: " <> + "copying from #{source.language} to #{primary_lang}" + ) + + copy_translation_to_primary(version, primary_lang, primary_content, source) + maybe_promote_post_to_published(post, source) + end + end + + defp find_best_translation(contents, primary_lang) do + Enum.find(contents, fn c -> + c.language != primary_lang and c.status == "published" and c.content not in [nil, ""] + end) || + Enum.find(contents, fn c -> + c.language != primary_lang and c.content not in [nil, ""] + end) + end + + defp copy_translation_to_primary(version, primary_lang, nil, source) do + DBStorage.create_content(%{ + version_uuid: version.uuid, + language: primary_lang, + title: source.title, + content: source.content, + status: source.status, + url_slug: source.url_slug + }) + end + + defp copy_translation_to_primary(_version, _primary_lang, primary_content, source) do + DBStorage.update_content(primary_content, %{ + title: source.title, + content: source.content, + status: source.status, + url_slug: primary_content.url_slug || source.url_slug + }) + end + + defp maybe_promote_post_to_published(post, source) do + if source.status == "published" and post.status != "published" do + Logger.info( + "[Publishing] Promoting post #{post.uuid} to published (primary content now has published content)" + ) + + DBStorage.update_post(post, %{ + status: "published", + published_at: post.published_at || DateTime.utc_now() + }) + end + end + @doc """ Ensures only one version is published per post. diff --git a/lib/modules/publishing/web/controller.ex b/lib/modules/publishing/web/controller.ex index f161f6de6..46efab952 100644 --- a/lib/modules/publishing/web/controller.ex +++ b/lib/modules/publishing/web/controller.ex @@ -148,6 +148,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do end end + # Suppress dialyzer warning — catch-all is defensive fallback for unexpected route formats + @dialyzer {:nowarn_function, extract_group_slug: 1} defp extract_group_slug({_, group_slug}), do: group_slug defp extract_group_slug({_, group_slug, _}), do: group_slug defp extract_group_slug({_, group_slug, _, _}), do: group_slug diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index 674000122..5b39a7353 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -31,39 +31,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do # Load initial data in mount so the connected render's join reply matches # the dead render output, preventing the visual flash caused by morphdom # patching empty assigns before handle_params fills them. - {groups, current_group} = load_groups_and_current(group_slug) - - all_posts = - case group_slug do - nil -> [] - slug -> DBStorage.list_posts_with_metadata(slug) - end - - trashed_count = - case group_slug do - nil -> 0 - slug -> length(DBStorage.list_posts(slug, "trashed")) - end - - status_counts = build_status_counts(all_posts, trashed_count) - - default_mode = - cond do - Map.get(status_counts, "published", 0) > 0 -> "published" - Map.get(status_counts, "draft", 0) > 0 -> "draft" - Map.get(status_counts, "archived", 0) > 0 -> "archived" - Map.get(status_counts, "trashed", 0) > 0 -> "trashed" - true -> "published" - end - - filtered_posts = - case default_mode do - "trashed" -> - DBStorage.list_posts_with_metadata(group_slug, "trashed") - - status -> - Enum.filter(all_posts, fn p -> p[:metadata] && p.metadata.status == status end) - end + {groups, current_group, filtered_posts, default_mode, status_counts, all_posts} = + load_initial_data(group_slug) socket = socket @@ -121,50 +90,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do |> assign(:endpoint_url, extract_endpoint_url(uri)) |> assign(:mount_group_slug, nil) else - {groups, current_group} = load_groups_and_current(new_group_slug) - - posts = - case new_group_slug do - nil -> [] - slug -> DBStorage.list_posts_with_metadata(slug) - end - - trashed_count = - case new_group_slug do - nil -> 0 - slug -> length(DBStorage.list_posts(slug, "trashed")) - end - - status_counts = build_status_counts(posts, trashed_count) - - default_mode = - cond do - Map.get(status_counts, "published", 0) > 0 -> "published" - Map.get(status_counts, "draft", 0) > 0 -> "draft" - Map.get(status_counts, "archived", 0) > 0 -> "archived" - Map.get(status_counts, "trashed", 0) > 0 -> "trashed" - true -> "published" - end - - filtered_posts = - case default_mode do - "trashed" -> - DBStorage.list_posts_with_metadata(new_group_slug, "trashed") - - status -> - Enum.filter(posts, fn p -> p[:metadata] && p.metadata.status == status end) - end - - socket - |> assign(:groups, groups) - |> assign(:current_group, current_group) - |> assign(:posts, filtered_posts) - |> assign(:post_view_mode, default_mode) - |> assign(:visible_count, 20) - |> assign(:endpoint_url, extract_endpoint_url(uri)) - |> assign(:primary_language_status, primary_language_status_from_posts(posts)) - |> assign(:post_status_counts, status_counts) - |> assign(:loading, false) + apply_full_reload(socket, new_group_slug, uri) end {:noreply, redirect_if_missing(socket)} @@ -806,6 +732,64 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do {groups, current_group} end + defp load_initial_data(group_slug) do + {groups, current_group} = load_groups_and_current(group_slug) + + all_posts = + case group_slug do + nil -> [] + slug -> DBStorage.list_posts_with_metadata(slug) + end + + trashed_count = + case group_slug do + nil -> 0 + slug -> length(DBStorage.list_posts(slug, "trashed")) + end + + status_counts = build_status_counts(all_posts, trashed_count) + default_mode = default_tab_mode(status_counts) + filtered_posts = filter_posts_for_mode(group_slug, default_mode, all_posts) + + {groups, current_group, filtered_posts, default_mode, status_counts, all_posts} + end + + defp apply_full_reload(socket, group_slug, uri) do + {groups, current_group, filtered_posts, default_mode, status_counts, all_posts} = + load_initial_data(group_slug) + + socket + |> assign(:groups, groups) + |> assign(:current_group, current_group) + |> assign(:posts, filtered_posts) + |> assign(:post_view_mode, default_mode) + |> assign(:visible_count, 20) + |> assign(:endpoint_url, extract_endpoint_url(uri)) + |> assign(:primary_language_status, primary_language_status_from_posts(all_posts)) + |> assign(:post_status_counts, status_counts) + |> assign(:loading, false) + end + + defp default_tab_mode(status_counts) do + cond do + Map.get(status_counts, "published", 0) > 0 -> "published" + Map.get(status_counts, "draft", 0) > 0 -> "draft" + Map.get(status_counts, "archived", 0) > 0 -> "archived" + Map.get(status_counts, "trashed", 0) > 0 -> "trashed" + true -> "published" + end + end + + defp filter_posts_for_mode(group_slug, mode, all_posts) do + case mode do + "trashed" -> + DBStorage.list_posts_with_metadata(group_slug, "trashed") + + status -> + Enum.filter(all_posts, fn p -> p[:metadata] && p.metadata.status == status end) + end + end + defp load_db_groups do DBStorage.list_groups() |> Enum.map(fn g -> From 9acb20d213d563a83e2f91863c72638dca8d0f7f Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 21:28:13 +0200 Subject: [PATCH 80/99] Remove TODO comment that fails credo strict check Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/listing.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index 5b39a7353..22b5478b9 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -101,8 +101,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do {:noreply, push_navigate(socket, to: Helpers.build_new_post_url(group_slug))} end - # TODO: Smart mode — if total posts is only slightly over the limit (e.g., 23 out of 20), - # show all at once instead of making the user click "Load more" for just 3 posts. def handle_event("load_more", _params, socket) do {:noreply, assign(socket, :visible_count, socket.assigns.visible_count + 20)} end From c6d49f1a2653ee91947f6aee24df091b76b777fb Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 21:59:06 +0200 Subject: [PATCH 81/99] Fix language route constraint to accept BCP-47 dialect codes The blog route constraint only matched 2-letter language codes (en, fr) but not dialect codes like en-US or zh-CN. URLs with dialect codes fell through to the non-localized catch-all route which treated the language as a group slug, causing broken redirects. Update language regex from ^[a-z]{2}$ to ^[a-z]{2,3}(-[A-Za-z]{2,4})?$ in blog routes, sitemap generator, layout wrapper locale stripping, dashboard tab matching, and tab item locale detection. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/sitemap/generator.ex | 2 +- lib/phoenix_kit/dashboard/tab.ex | 2 +- lib/phoenix_kit_web/components/dashboard/tab_item.ex | 2 +- lib/phoenix_kit_web/components/layout_wrapper.ex | 2 +- lib/phoenix_kit_web/routes/blog.ex | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/modules/sitemap/generator.ex b/lib/modules/sitemap/generator.ex index 443a94557..85447992e 100644 --- a/lib/modules/sitemap/generator.ex +++ b/lib/modules/sitemap/generator.ex @@ -548,7 +548,7 @@ defmodule PhoenixKit.Modules.Sitemap.Generator do entry.loc end - case Regex.run(~r/^\/([a-z]{2})(?:\/|$)/, path) do + case Regex.run(~r/^\/([a-z]{2,3}(?:-[A-Za-z]{2,4})?)(?:\/|$)/, path) do [_, lang] -> lang _ -> Routes.get_default_admin_locale() end diff --git a/lib/phoenix_kit/dashboard/tab.ex b/lib/phoenix_kit/dashboard/tab.ex index f2c04e85e..9e0ed2623 100644 --- a/lib/phoenix_kit/dashboard/tab.ex +++ b/lib/phoenix_kit/dashboard/tab.ex @@ -676,7 +676,7 @@ defmodule PhoenixKit.Dashboard.Tab do defp normalize_path(_), do: "" defp remove_locale_prefix(path) do - case Regex.run(~r/^\/[a-z]{2}(-[A-Z]{2})?(\/.*)?$/, path) do + case Regex.run(~r/^\/[a-z]{2,3}(-[A-Za-z]{2,4})?(\/.*)?$/, path) do [_, _locale, rest] when is_binary(rest) -> rest [_, _locale] -> "/" _ -> path diff --git a/lib/phoenix_kit_web/components/dashboard/tab_item.ex b/lib/phoenix_kit_web/components/dashboard/tab_item.ex index 01843e39e..ca0cb9f01 100644 --- a/lib/phoenix_kit_web/components/dashboard/tab_item.ex +++ b/lib/phoenix_kit_web/components/dashboard/tab_item.ex @@ -316,7 +316,7 @@ defmodule PhoenixKitWeb.Components.Dashboard.TabItem do """ def path_has_locale_prefix?(path) when is_binary(path) do # Matches: /uk/, /en/, /zh-Hans/, /pt-BR/ etc. - String.match?(path, ~r/^\/[a-z]{2}(-[A-Z][a-z]{2,3})?\//u) + String.match?(path, ~r/^\/[a-z]{2,3}(-[A-Za-z]{2,4})?\//u) end def path_has_locale_prefix?(_), do: false diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index 976fcac5f..39e8f5e07 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -204,7 +204,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do end defp strip_locale_prefix(path) do - case Regex.run(~r/^\/[a-z]{2}(-[A-Z]{2})?(\/.*)?$/, path) do + case Regex.run(~r/^\/[a-z]{2,3}(-[A-Za-z]{2,4})?(\/.*)?$/, path) do [_, _locale, rest] when is_binary(rest) -> rest [_, _locale] -> "/" _ -> path diff --git a/lib/phoenix_kit_web/routes/blog.ex b/lib/phoenix_kit_web/routes/blog.ex index e6c4949fc..282461c98 100644 --- a/lib/phoenix_kit_web/routes/blog.ex +++ b/lib/phoenix_kit_web/routes/blog.ex @@ -23,13 +23,13 @@ defmodule PhoenixKitWeb.Routes.BlogRoutes do get "/:group", PhoenixKit.Modules.Publishing.Web.Controller, :show, constraints: %{ "group" => ~r/^(?!admin$|assets$|images$|fonts$|js$|css$|favicon)/, - "language" => ~r/^[a-z]{2}$/ + "language" => ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/ } get "/:group/*path", PhoenixKit.Modules.Publishing.Web.Controller, :show, constraints: %{ "group" => ~r/^(?!admin$|assets$|images$|fonts$|js$|css$|favicon)/, - "language" => ~r/^[a-z]{2}$/ + "language" => ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/ } end From 19f50b7bb5c171e9abdf4d2545590072c7c93325 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 22:06:24 +0200 Subject: [PATCH 82/99] Pass current version to preview URL to preserve version context The preview handler only passed the language parameter, causing read_post_by_uuid to load the latest version (which could be a draft). When navigating back from preview, the editor loaded the draft version where status was "draft", hiding the View Public button. Now the current version is preserved through the preview round-trip. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/editor.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index 11c6b37a6..acae76ff8 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -1059,8 +1059,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do post = socket.assigns.post post_uuid = post[:uuid] language = socket.assigns.current_language + version = socket.assigns[:current_version] - query = URI.encode_query(%{"lang" => language}) + query_params = %{"lang" => language} + query_params = if version, do: Map.put(query_params, "v", version), else: query_params + query = URI.encode_query(query_params) {:noreply, push_navigate(socket, From d6ef89f5e22d26d86c0b8cdffa75287bc9105bc5 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 22:17:57 +0200 Subject: [PATCH 83/99] Fix infinite redirect loop between dialect and base language codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When only one dialect is enabled (e.g., en-US), the canonical URL uses the base code (en). The listing page used filter_by_exact_language_strict which only matched exact codes — "en" didn't match posts stored as "en-US", triggering a fallback redirect back to en-US, creating an infinite redirect loop. Switch to filter_by_exact_language which does base-code matching, so "en" correctly finds posts with "en-US" content. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/controller/listing.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/modules/publishing/web/controller/listing.ex b/lib/modules/publishing/web/controller/listing.ex index 98ab5992f..cdf5fb2f5 100644 --- a/lib/modules/publishing/web/controller/listing.ex +++ b/lib/modules/publishing/web/controller/listing.ex @@ -75,7 +75,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do Resolves posts for the requested language, handling exact match vs fallback. """ def resolve_listing_posts_for_language(conn, ctx) do - exact_language_posts = filter_by_exact_language_strict(ctx.published_posts, ctx.language) + exact_language_posts = + filter_by_exact_language(ctx.published_posts, ctx.group_slug, ctx.language) case resolve_language_posts( exact_language_posts, From 20088ff13d7fa00da740447a2222793240599227 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 22:27:04 +0200 Subject: [PATCH 84/99] Standardize all locale regex patterns to support BCP-47 codes Update all language/locale regex patterns across the codebase from 2-letter-only to 2-3 letter base codes with optional 2-4 letter dialect suffix. Fixes admin routes rejecting 3-letter language codes, user locale validation rejecting valid codes, and inconsistent matching between blog routes and admin routes. Files updated: - integration.ex: admin scope locale constraint and dynamic pattern - language.ex: looks_like_language_code?, base_code?, valid_language? - user_dashboard_nav.ex: looks_like_locale? - admin_nav.ex: locale_candidate? - user.ex: validate_locale_value and doc string Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/controller/language.ex | 12 +++++------- lib/phoenix_kit/users/auth/user.ex | 4 ++-- lib/phoenix_kit_web/components/admin_nav.ex | 2 +- lib/phoenix_kit_web/components/user_dashboard_nav.ex | 2 +- lib/phoenix_kit_web/integration.ex | 4 ++-- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/lib/modules/publishing/web/controller/language.ex b/lib/modules/publishing/web/controller/language.ex index 50577ab07..5b354d686 100644 --- a/lib/modules/publishing/web/controller/language.ex +++ b/lib/modules/publishing/web/controller/language.ex @@ -105,7 +105,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do true # Base code that maps to an enabled dialect - String.length(code) == 2 and not String.contains?(code, "-") -> + base_code?(code) -> dialect = DialectMapper.base_to_dialect(code) if Languages.language_enabled?(dialect) do @@ -139,10 +139,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do Matches: 2-letter codes (en, fr), or dialect codes (en-US, pt-BR) """ def looks_like_language_code?(code) when is_binary(code) do - # 2-letter base code - # Dialect code pattern (xx-XX or xx-XXX) - (String.length(code) == 2 and String.match?(code, ~r/^[a-z]{2}$/i)) or - String.match?(code, ~r/^[a-z]{2,3}-[A-Za-z]{2,4}$/i) + # 2-3 letter base code or dialect code pattern (xx-XX, xxx-XXXX) + String.match?(code, ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/i) end # ============================================================================ @@ -230,10 +228,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do end @doc """ - Checks if a code is a base (2-letter) language code. + Checks if a code is a base language code (2-3 letters, no dialect suffix). """ def base_code?(code) when is_binary(code) do - String.length(code) == 2 and not String.contains?(code, "-") + String.length(code) in [2, 3] and not String.contains?(code, "-") end def base_code?(_), do: false diff --git a/lib/phoenix_kit/users/auth/user.ex b/lib/phoenix_kit/users/auth/user.ex index cf8c24344..9945b3e57 100644 --- a/lib/phoenix_kit/users/auth/user.ex +++ b/lib/phoenix_kit/users/auth/user.ex @@ -338,7 +338,7 @@ defmodule PhoenixKit.Users.Auth.User do ## Validation - - Format: Must match ~r/^[a-z]{2}(-[A-Z]{2})?$/ + - Format: Must match ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/ - Existence: Must exist in predefined language list - NULL/empty allowed: Indicates "use system default" @@ -396,7 +396,7 @@ defmodule PhoenixKit.Users.Auth.User do def validate_locale_value(locale) when is_binary(locale) do cond do - !Regex.match?(~r/^[a-z]{2}(-[A-Z]{2})?$/, locale) -> + !Regex.match?(~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/, locale) -> {:error, "must be a valid locale format (e.g., en-US, es-MX)"} !Languages.get_predefined_language(locale) -> diff --git a/lib/phoenix_kit_web/components/admin_nav.ex b/lib/phoenix_kit_web/components/admin_nav.ex index 847d10834..92cb65957 100644 --- a/lib/phoenix_kit_web/components/admin_nav.ex +++ b/lib/phoenix_kit_web/components/admin_nav.ex @@ -568,7 +568,7 @@ defmodule PhoenixKitWeb.Components.AdminNav do defp locale_candidate?(locale) do # Match both base language codes (en) and full dialect codes (en-US) - Regex.match?(~r/^[a-z]{2}(-[A-Z]{2})?$/i, locale) + Regex.match?(~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/i, locale) end # Helper function to get admin languages from settings diff --git a/lib/phoenix_kit_web/components/user_dashboard_nav.ex b/lib/phoenix_kit_web/components/user_dashboard_nav.ex index 47d24aafc..20c4386f6 100644 --- a/lib/phoenix_kit_web/components/user_dashboard_nav.ex +++ b/lib/phoenix_kit_web/components/user_dashboard_nav.ex @@ -230,7 +230,7 @@ defmodule PhoenixKitWeb.Components.UserDashboardNav do # Check if it looks like a locale code defp looks_like_locale?(locale) do - String.length(locale) <= 6 and String.match?(locale, ~r/^[a-z]{2}(-[A-Z]{2})?$/) + String.length(locale) <= 8 and String.match?(locale, ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/) end # Legacy helper - kept for backward compatibility diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index d5a9aa69c..8d0abd304 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -152,7 +152,7 @@ defmodule PhoenixKitWeb.Integration do # This ensures backward compatibility with old URLs while enforcing base code standard scope "#{unquote(url_prefix)}/:locale", PhoenixKitWeb, - Keyword.put(unquote(opts), :locale, ~r/^[a-z]{2}(?:-[A-Za-z0-9]{2,})?$/) do + Keyword.put(unquote(opts), :locale, ~r/^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/) do pipe_through [:browser, :phoenix_kit_auto_setup, :phoenix_kit_locale_validation] unquote(block) @@ -1338,7 +1338,7 @@ defmodule PhoenixKitWeb.Integration do # Use a generic locale pattern that accepts any valid language code format # This allows switching to any of the 80+ predefined languages # Actual validation of whether the locale is supported happens in the validation plug - pattern = "[a-z]{2}(?:-[A-Za-z0-9]{2,})?" + pattern = "[a-z]{2,3}(?:-[A-Za-z]{2,4})?" # Call route generators BEFORE quote block (aliases work in this context) # Uses safe_route_call/3 so modules can be safely extracted to separate packages From ed674c1d7e6341223a8021d63ea869bf09d4f376 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 22:45:35 +0200 Subject: [PATCH 85/99] Fall back to slug lookup when date-only URL has no timestamp posts A slug-mode post with a date-formatted slug (e.g., "2026-03-13") was misrouted as a date-only timestamp URL. When list_times_on_date returned empty, it returned :post_not_found instead of trying the slug. Now attempts a slug lookup before giving up. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/controller/post_rendering.ex | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/modules/publishing/web/controller/post_rendering.ex b/lib/modules/publishing/web/controller/post_rendering.ex index 167cb741b..dff450ba4 100644 --- a/lib/modules/publishing/web/controller/post_rendering.ex +++ b/lib/modules/publishing/web/controller/post_rendering.ex @@ -182,8 +182,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do case times do [] -> - # No posts on this date - {:error, :post_not_found} + # No timestamp posts on this date — try as a slug in case + # a slug-mode post happens to look like a date (e.g., "2026-03-13") + render_post(conn, group_slug, {:slug, date}, language) [single_time] -> # Only one post - render it directly From ce4a827c771c3520c4af8d73081bf4e329e1e391 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 22:55:52 +0200 Subject: [PATCH 86/99] Sync post mode with group mode in stale fixer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a group's URL mode is changed (e.g., timestamp → slug), existing posts retain their old mode. The stale fixer now detects this mismatch and updates the post mode to match the group, preventing misrouted URLs where timestamp-mode posts in slug-mode groups generate date-based URLs instead of slug-based ones. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/stale_fixer.ex | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/modules/publishing/stale_fixer.ex b/lib/modules/publishing/stale_fixer.ex index 7bcc06573..8a33c0089 100644 --- a/lib/modules/publishing/stale_fixer.ex +++ b/lib/modules/publishing/stale_fixer.ex @@ -155,7 +155,20 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do end defp maybe_fix_post_mode(attrs, post) do + # First ensure the mode is a valid value fixed_mode = if post.mode in @valid_group_modes, do: post.mode, else: @default_group_mode + + # Then sync with the group's mode if they differ (e.g., group switched from + # timestamp to slug but existing posts were never updated) + group = if post.group, do: post.group, else: DBStorage.get_group(post.group_uuid) + + fixed_mode = + if group && group.mode in @valid_group_modes do + group.mode + else + fixed_mode + end + if fixed_mode != post.mode, do: Map.put(attrs, :mode, fixed_mode), else: attrs end From 326c401c0ce63e459a8cdac4aa58a7c4a7a36e36 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 23:03:39 +0200 Subject: [PATCH 87/99] Auto-generate slug on mode change and reconcile status on restore Stale fixer: when a post's mode changes from timestamp to slug, auto- generate a slug from the post title, date, or UUID if the post has no slug. Prevents broken URLs where slug-mode posts have nil slugs. Restore handler: call reconcile_post_status immediately after restoring a trashed post to draft, ensuring version/content statuses are demoted from published to match the draft post status right away instead of waiting for the next stale fixer pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/stale_fixer.ex | 56 +++++++++++++++++++++++++++ lib/modules/publishing/web/listing.ex | 2 + 2 files changed, 58 insertions(+) diff --git a/lib/modules/publishing/stale_fixer.ex b/lib/modules/publishing/stale_fixer.ex index 8a33c0089..ab680d216 100644 --- a/lib/modules/publishing/stale_fixer.ex +++ b/lib/modules/publishing/stale_fixer.ex @@ -135,6 +135,7 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do |> maybe_fix_post_language(post) |> maybe_fix_post_status(post) |> maybe_fix_post_mode(post) + |> maybe_fix_post_slug(post) |> maybe_fix_post_timestamp(post) end @@ -172,6 +173,61 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do if fixed_mode != post.mode, do: Map.put(attrs, :mode, fixed_mode), else: attrs end + defp maybe_fix_post_slug(attrs, post) do + effective_mode = attrs[:mode] || post.mode + + if effective_mode == "slug" and (is_nil(post.slug) or post.slug == "") do + # Post switched to slug mode but has no slug — generate from title or date + slug = generate_slug_for_post(post) + + if slug && slug != "" do + Logger.info( + "[Publishing] Generating slug for post #{post.uuid}: #{inspect(slug)} (mode changed to slug)" + ) + + Map.put(attrs, :slug, slug) + else + attrs + end + else + attrs + end + end + + defp generate_slug_for_post(post) do + # Try to get title from the latest version's primary content + title = + case DBStorage.list_versions(post.uuid) do + [] -> + nil + + versions -> + latest = List.last(versions) + + case DBStorage.get_content(latest.uuid, post.primary_language) do + nil -> nil + content -> content.title + end + end + + base = + cond do + is_binary(title) and title not in ["", "Untitled"] -> + title + + post.post_date -> + Date.to_iso8601(post.post_date) + + true -> + "post-#{String.slice(post.uuid || "", 0, 8)}" + end + + base + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/u, "-") + |> String.trim("-") + end + defp maybe_fix_post_timestamp(attrs, post) do if (attrs[:mode] || post.mode) == "timestamp" do now = DateTime.utc_now() diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index 22b5478b9..372a0d9be 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -149,6 +149,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do db_post -> case DBStorage.update_post(db_post, %{status: "draft"}) do {:ok, _} -> + # Reconcile version/content statuses immediately (don't wait for stale fixer) + StaleFixer.reconcile_post_status(db_post) ListingCache.regenerate(group_slug) {:noreply, From 0a0e0ef639221bb97a3abb87617040a7d5835893 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 23:09:45 +0200 Subject: [PATCH 88/99] Fix atom vs string mode comparison in cache lookups and URL builders Three locations used pure atom pattern matching on post.mode (:timestamp) which fails when mode is a string ("timestamp"). The mapper converts DB strings to atoms, but cached posts or direct DB reads may have strings. Changed all three to use defensive dual checks: mode in [:timestamp, "timestamp"] Files fixed: - post_rendering.ex: find_cached_post - translations.ex: find_cached_post - editor/helpers.ex: build_url_for_mode Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/controller/post_rendering.ex | 32 ++++++++--------- .../publishing/web/controller/translations.ex | 34 +++++++++---------- lib/modules/publishing/web/editor/helpers.ex | 10 +++--- 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/lib/modules/publishing/web/controller/post_rendering.ex b/lib/modules/publishing/web/controller/post_rendering.ex index dff450ba4..b050551d5 100644 --- a/lib/modules/publishing/web/controller/post_rendering.ex +++ b/lib/modules/publishing/web/controller/post_rendering.ex @@ -301,23 +301,23 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do # Find cached post using appropriate method based on post mode defp find_cached_post(group_slug, post) do - case Map.get(post, :mode) do - :timestamp -> - # For timestamp mode, use date/time lookup - 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) - ListingCache.find_post_by_path(group_slug, date_str, time_str) - else - {:error, :not_found} - end + mode = Map.get(post, :mode) + + if mode in [:timestamp, "timestamp"] do + # For timestamp mode, use date/time lookup + date = post[:date] + time = post[:time] - _ -> - # For slug mode, use slug lookup - ListingCache.find_post(group_slug, post.slug) + 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) + ListingCache.find_post_by_path(group_slug, date_str, time_str) + else + {:error, :not_found} + end + else + # For slug mode, use slug lookup + ListingCache.find_post(group_slug, post.slug) end end diff --git a/lib/modules/publishing/web/controller/translations.ex b/lib/modules/publishing/web/controller/translations.ex index 3786d81ca..435572a02 100644 --- a/lib/modules/publishing/web/controller/translations.ex +++ b/lib/modules/publishing/web/controller/translations.ex @@ -219,23 +219,23 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Translations do # Find cached post using appropriate method based on post mode defp find_cached_post(group_slug, post) do - case Map.get(post, :mode) do - :timestamp -> - # For timestamp mode, use date/time lookup - 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) - ListingCache.find_post_by_path(group_slug, date_str, time_str) - else - {:error, :not_found} - end - - _ -> - # For slug mode, use slug lookup - ListingCache.find_post(group_slug, post.slug) + mode = Map.get(post, :mode) + + if mode in [:timestamp, "timestamp"] do + # For timestamp mode, use date/time lookup + 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) + ListingCache.find_post_by_path(group_slug, date_str, time_str) + else + {:error, :not_found} + end + else + # For slug mode, use slug lookup + ListingCache.find_post(group_slug, post.slug) end end diff --git a/lib/modules/publishing/web/editor/helpers.ex b/lib/modules/publishing/web/editor/helpers.ex index 80ecd9c08..8dbc73e97 100644 --- a/lib/modules/publishing/web/editor/helpers.ex +++ b/lib/modules/publishing/web/editor/helpers.ex @@ -155,10 +155,12 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do defp build_url_for_mode(post, language) do group_slug = post.group || "group" - case Map.get(post, :mode) do - :slug -> build_slug_mode_url(group_slug, post, language) - :timestamp -> build_timestamp_mode_url(group_slug, post, language) - _ -> nil + mode = Map.get(post, :mode) + + cond do + mode in [:slug, "slug"] -> build_slug_mode_url(group_slug, post, language) + mode in [:timestamp, "timestamp"] -> build_timestamp_mode_url(group_slug, post, language) + true -> nil end end From ed2869fddb0e70d67f01212a4036b5bdd28b7565 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 23:15:18 +0200 Subject: [PATCH 89/99] Improve error handling for silent failures in publishing module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stale fixer background task: wrap in try/rescue so crashes are logged instead of silently swallowed by Task.start - Autosave failure: show flash error to user instead of silently resetting the saving indicator — user now sees "Autosave failed" - Slug generation failure: log warning when slug can't be generated for a post that switched to slug mode Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/stale_fixer.ex | 4 ++++ lib/modules/publishing/web/editor.ex | 3 ++- lib/modules/publishing/web/listing.ex | 13 ++++++++++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/modules/publishing/stale_fixer.ex b/lib/modules/publishing/stale_fixer.ex index ab680d216..219416485 100644 --- a/lib/modules/publishing/stale_fixer.ex +++ b/lib/modules/publishing/stale_fixer.ex @@ -187,6 +187,10 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do Map.put(attrs, :slug, slug) else + Logger.warning( + "[Publishing] Failed to generate slug for post #{post.uuid} — post will be unreachable in slug mode" + ) + attrs end else diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index acae76ff8..0f0ae90da 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -1142,7 +1142,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do socket |> assign(:is_autosaving, false) |> assign(:autosave_timer, nil) - |> push_event("autosave-status", %{saving: false})} + |> push_event("autosave-status", %{saving: false}) + |> put_flash(:error, gettext("Autosave failed — click Save to retry"))} end # ============================================================================ diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index 372a0d9be..3b5236cec 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -73,9 +73,16 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do # sitting in trash with no recoverable content. if connected?(socket) and new_group_slug do Task.start(fn -> - active = DBStorage.list_posts(new_group_slug) - trashed = DBStorage.list_posts(new_group_slug, "trashed") - Enum.each(active ++ trashed, &StaleFixer.fix_stale_post/1) + try do + active = DBStorage.list_posts(new_group_slug) + trashed = DBStorage.list_posts(new_group_slug, "trashed") + Enum.each(active ++ trashed, &StaleFixer.fix_stale_post/1) + rescue + e -> + Logger.error( + "[Publishing] Stale fixer task crashed for #{new_group_slug}: #{Exception.message(e)}" + ) + end end) end From b7a7ecea57d304de200369f4a05e2e7a91d854d0 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 23:36:10 +0200 Subject: [PATCH 90/99] Code review fixes: aliases, duplication, and empty post safety - Move inline aliases (DBStorage, ListingCache, Shared) from clear_translation handler to module-level aliases in editor.ex - Extract shared do_update_post_status helper to deduplicate change_status and toggle_status handlers in listing.ex - Make empty_post? check safer: only auto-delete draft/trashed posts, never published or archived ones even if they appear empty Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/stale_fixer.ex | 6 ++ lib/modules/publishing/web/editor.ex | 7 +- lib/modules/publishing/web/listing.ex | 99 +++++++++------------------ 3 files changed, 43 insertions(+), 69 deletions(-) diff --git a/lib/modules/publishing/stale_fixer.ex b/lib/modules/publishing/stale_fixer.ex index 219416485..4a0a270b9 100644 --- a/lib/modules/publishing/stale_fixer.ex +++ b/lib/modules/publishing/stale_fixer.ex @@ -114,6 +114,12 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do end defp empty_post?(post) do + # Only consider draft/trashed posts for auto-deletion — published/archived + # posts should never be silently removed even if they appear empty + post.status in ["draft", "trashed"] and all_versions_empty?(post) + end + + defp all_versions_empty?(post) do versions = DBStorage.list_versions(post.uuid) if versions == [] do diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index 0f0ae90da..d58be9648 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -28,8 +28,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do alias Phoenix.LiveView.JS alias PhoenixKit.Modules.AI alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.DBStorage + alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.Metadata alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub + alias PhoenixKit.Modules.Publishing.Shared alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes @@ -661,10 +664,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do def handle_event("noop", _params, socket), do: {:noreply, socket} def handle_event("clear_translation", _params, socket) do - alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Modules.Publishing.ListingCache - alias PhoenixKit.Modules.Publishing.Shared - group_slug = socket.assigns.group_slug post = socket.assigns.post language = socket.assigns.current_language diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index 3b5236cec..8bd50a308 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -196,40 +196,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do end def handle_event("change_status", %{"uuid" => post_uuid, "status" => new_status}, socket) do - scope = socket.assigns[:phoenix_kit_current_scope] - - case Publishing.read_post_by_uuid(post_uuid) do - {:ok, post} -> - # Determine if this is the primary language for status propagation - primary_language = post[:primary_language] || Publishing.get_primary_language() - is_primary_language = post.language == primary_language - - case Publishing.update_post(socket.assigns.group_slug, post, %{"status" => new_status}, %{ - scope: scope, - is_primary_language: is_primary_language - }) do - {:ok, updated_post} -> - # Invalidate cache for this post - invalidate_post_cache(socket.assigns.group_slug, updated_post) - - # Broadcast status change to other connected clients - PublishingPubSub.broadcast_post_status_changed( - socket.assigns.group_slug, - updated_post - ) - - {:noreply, - socket - |> put_flash(:info, gettext("Status updated to %{status}", status: new_status)) - |> reload_current_view()} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, gettext("Failed to update status"))} - end - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, gettext("Post not found"))} - end + do_update_post_status(socket, post_uuid, new_status) end def handle_event( @@ -237,8 +204,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do %{"uuid" => post_uuid, "current-status" => current_status}, socket ) do - scope = socket.assigns[:phoenix_kit_current_scope] - new_status = case current_status do "draft" -> "published" @@ -247,35 +212,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do _ -> "draft" end - case Publishing.read_post_by_uuid(post_uuid) do - {:ok, post} -> - # Determine if this is the primary language for status propagation - primary_language = post[:primary_language] || Publishing.get_primary_language() - is_primary_language = post.language == primary_language - - case Publishing.update_post(socket.assigns.group_slug, post, %{"status" => new_status}, %{ - scope: scope, - is_primary_language: is_primary_language - }) do - {:ok, updated_post} -> - # Broadcast status change to other connected clients - PublishingPubSub.broadcast_post_status_changed( - socket.assigns.group_slug, - updated_post - ) - - {:noreply, - socket - |> put_flash(:info, gettext("Status updated to %{status}", status: new_status)) - |> reload_current_view()} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, gettext("Failed to update status"))} - end - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, gettext("Post not found"))} - end + do_update_post_status(socket, post_uuid, new_status) end def handle_event("update_primary_language", _params, socket) do @@ -977,6 +914,38 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do defp extract_endpoint_url(_), do: "" + defp do_update_post_status(socket, post_uuid, new_status) do + scope = socket.assigns[:phoenix_kit_current_scope] + group_slug = socket.assigns.group_slug + + case Publishing.read_post_by_uuid(post_uuid) do + {:ok, post} -> + primary_language = post[:primary_language] || Publishing.get_primary_language() + is_primary_language = post.language == primary_language + + case Publishing.update_post(group_slug, post, %{"status" => new_status}, %{ + scope: scope, + is_primary_language: is_primary_language + }) do + {:ok, updated_post} -> + invalidate_post_cache(group_slug, updated_post) + + PublishingPubSub.broadcast_post_status_changed(group_slug, updated_post) + + {:noreply, + socket + |> put_flash(:info, gettext("Status updated to %{status}", status: new_status)) + |> reload_current_view()} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, gettext("Failed to update status"))} + end + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, gettext("Post not found"))} + end + end + defp invalidate_post_cache(group_slug, post) do identifier = post[:uuid] || post.slug From 7b813a56034f30225c66527feb42a8be012bccf3 Mon Sep 17 00:00:00 2001 From: Max Don Date: Sun, 15 Mar 2026 23:46:04 +0200 Subject: [PATCH 91/99] =?UTF-8?q?Revert=20empty=5Fpost=3F=20status=20restr?= =?UTF-8?q?iction=20=E2=80=94=20delete=20all=20truly=20empty=20posts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty posts (no content across any version) should be cleaned up regardless of status. A published post with zero content is legacy corruption, not intentional — keeping it just confuses users. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/stale_fixer.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/modules/publishing/stale_fixer.ex b/lib/modules/publishing/stale_fixer.ex index 4a0a270b9..3a5bf4bab 100644 --- a/lib/modules/publishing/stale_fixer.ex +++ b/lib/modules/publishing/stale_fixer.ex @@ -114,9 +114,7 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do end defp empty_post?(post) do - # Only consider draft/trashed posts for auto-deletion — published/archived - # posts should never be silently removed even if they appear empty - post.status in ["draft", "trashed"] and all_versions_empty?(post) + all_versions_empty?(post) end defp all_versions_empty?(post) do From 704674ef25f2ab2a3fd55d1764f413cfd27f3a66 Mon Sep 17 00:00:00 2001 From: Max Don Date: Mon, 16 Mar 2026 00:38:10 +0200 Subject: [PATCH 92/99] Fix blank page on public blog posts in Phoenix 1.8+ parent apps Two bugs caused blank content on controller-rendered pages: 1. get_layout_config/0 hardcoded :app function for :modern strategy, ignoring the explicit layout: config. Parent apps configuring layout: {MyAppWeb.Layouts, :full_width} got :app instead. Now checks layout: config first, falls back to {layouts_module, :app}. 2. Controller templates were double-wrapped: LayoutWrapper in the template AND Phoenix's automatic app layout both applied the parent layout. The second wrap passed inner_block: [] (truthy in Elixir), causing render_slot([]) to return empty output. Fixed by adding plug :put_layout, html: false to the publishing controller so only LayoutWrapper handles layout wrapping. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/controller.ex | 6 ++++++ lib/phoenix_kit_web/components/layout_wrapper.ex | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/modules/publishing/web/controller.ex b/lib/modules/publishing/web/controller.ex index 46efab952..33aa46b83 100644 --- a/lib/modules/publishing/web/controller.ex +++ b/lib/modules/publishing/web/controller.ex @@ -25,6 +25,12 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do use PhoenixKitWeb, :controller use Gettext, backend: PhoenixKitWeb.Gettext + # Disable Phoenix's automatic app layout — LayoutWrapper in the templates + # handles layout wrapping. Without this, controller templates get double-wrapped: + # once by LayoutWrapper (inside the template) and once by Phoenix's layout system, + # causing blank pages when the parent layout checks inner_block ([] is truthy). + plug :put_layout, html: false + alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.Web.Controller.Fallback alias PhoenixKit.Modules.Publishing.Web.Controller.Language diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index 39e8f5e07..5f77299a7 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -749,10 +749,18 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do defp get_layout_config do case Config.get(:phoenix_version_strategy, nil) do :modern -> - # Phoenix v1.8+ - get layouts_module and assume :app function - case Config.get(:layouts_module, nil) do - nil -> nil - module -> {module, :app} + # Phoenix v1.8+ - respect explicit layout: config first, then fall back + # to {layouts_module, :app}. The layout: config allows parent apps to + # specify a different layout function (e.g., :full_width instead of :app). + case Config.get(:layout, nil) do + {module, function} when is_atom(module) and is_atom(function) -> + {module, function} + + _ -> + case Config.get(:layouts_module, nil) do + nil -> nil + module -> {module, :app} + end end :legacy -> From b78187bf49634d9ad07cbb75cea303c474de90dd Mon Sep 17 00:00:00 2001 From: Max Don Date: Mon, 16 Mar 2026 00:40:43 +0200 Subject: [PATCH 93/99] =?UTF-8?q?Revert=20put=5Flayout=20false=20=E2=80=94?= =?UTF-8?q?=20double-wrap=20fix=20is=20parent=20app=20responsibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blank page was caused by the parent app's layout checking inner_block ([] is truthy) before inner_content. This is a parent app layout pattern issue, not a PhoenixKit bug. Disabling Phoenix's controller layout system breaks parent apps that rely on it. Parent apps experiencing double-wrapping should add put_layout(conn, html: false) in their own pipeline or fix their layout to handle inner_block: [] correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/controller.ex | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/modules/publishing/web/controller.ex b/lib/modules/publishing/web/controller.ex index 33aa46b83..46efab952 100644 --- a/lib/modules/publishing/web/controller.ex +++ b/lib/modules/publishing/web/controller.ex @@ -25,12 +25,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do use PhoenixKitWeb, :controller use Gettext, backend: PhoenixKitWeb.Gettext - # Disable Phoenix's automatic app layout — LayoutWrapper in the templates - # handles layout wrapping. Without this, controller templates get double-wrapped: - # once by LayoutWrapper (inside the template) and once by Phoenix's layout system, - # causing blank pages when the parent layout checks inner_block ([] is truthy). - plug :put_layout, html: false - alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.Web.Controller.Fallback alias PhoenixKit.Modules.Publishing.Web.Controller.Language From dab6e845113f6c7649d5b0d0c2430999a8e52cb8 Mon Sep 17 00:00:00 2001 From: Max Don Date: Mon, 16 Mar 2026 01:09:09 +0200 Subject: [PATCH 94/99] Fix translated blog URLs returning 302 for base language codes Resolve base language codes (de, en, fr) to stored BCP-47 dialect codes (de-DE, en-US, fr-FR) before URL slug DB queries, since content rows store full dialect codes but URL paths use base codes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/controller/slug_resolution.ex | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/modules/publishing/web/controller/slug_resolution.ex b/lib/modules/publishing/web/controller/slug_resolution.ex index b0e10b653..3df0a5e31 100644 --- a/lib/modules/publishing/web/controller/slug_resolution.ex +++ b/lib/modules/publishing/web/controller/slug_resolution.ex @@ -8,7 +8,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution do - DB-based slug lookups """ + alias PhoenixKit.Modules.Languages.DialectMapper alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Web.Controller.Language alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML # ============================================================================ @@ -24,7 +26,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution do - `:passthrough` for direct use """ def resolve_url_slug(group_slug, {:slug, url_slug}, language) do - case Publishing.find_by_url_slug(group_slug, language, url_slug) do + # Resolve base language codes (de, en) to stored dialect codes (de-DE, en-US) + # before DB queries, since content rows store full BCP-47 dialect codes + db_language = resolve_language_for_db(language) + + case Publishing.find_by_url_slug(group_slug, db_language, url_slug) do {:ok, cached_post} -> internal_slug = cached_post.slug @@ -38,11 +44,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution do {:error, :not_found} -> # Not found in current slugs - check previous slugs for 301 redirect - case Publishing.find_by_previous_url_slug(group_slug, language, url_slug) do + case Publishing.find_by_previous_url_slug(group_slug, db_language, url_slug) do {:ok, cached_post} -> # Found in previous slugs - redirect to current URL current_url_slug = - Map.get(cached_post[:language_slugs] || %{}, language, cached_post.slug) + Map.get(cached_post[:language_slugs] || %{}, db_language, cached_post.slug) redirect_url = build_post_redirect_url(group_slug, cached_post, language, current_url_slug) @@ -63,7 +69,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution do Used by versioned URL handler and other places that need the internal slug. """ def resolve_url_slug_to_internal(group_slug, url_slug, language) do - case Publishing.find_by_url_slug(group_slug, language, url_slug) do + db_language = resolve_language_for_db(language) + + case Publishing.find_by_url_slug(group_slug, db_language, url_slug) do {:ok, cached_post} -> cached_post.slug || cached_post[:slug] @@ -108,4 +116,20 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution do PublishingHTML.build_post_url(group_slug, post, language) end + + # ============================================================================ + # Language Resolution + # ============================================================================ + + # Resolves a URL language code to the stored dialect code for DB queries. + # URL paths use base codes (de, en, fr) but content rows store full + # BCP-47 dialect codes (de-DE, en-US, fr-FR). + defp resolve_language_for_db(language) do + if Language.base_code?(language) do + Language.find_dialect_for_base(language, Language.get_enabled_languages()) || + DialectMapper.base_to_dialect(language) + else + language + end + end end From 30933ad05f4b488fb86ac1bd366f06fa69975fe1 Mon Sep 17 00:00:00 2001 From: Max Don Date: Mon, 16 Mar 2026 01:17:11 +0200 Subject: [PATCH 95/99] Fix resolve_content returning wrong language for base codes Resolve base language codes (de, en) to stored BCP-47 dialect codes before passing to DBStorage.read_post, so resolve_content's exact match finds the correct language content instead of falling back to primary language and triggering a redirect. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/posts.ex | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/modules/publishing/posts.ex b/lib/modules/publishing/posts.ex index 91da8a76b..31225d9eb 100644 --- a/lib/modules/publishing/posts.ex +++ b/lib/modules/publishing/posts.ex @@ -8,6 +8,7 @@ defmodule PhoenixKit.Modules.Publishing.Posts do require Logger + alias PhoenixKit.Modules.Languages.DialectMapper alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.DBStorage alias PhoenixKit.Modules.Publishing.LanguageHelpers @@ -113,6 +114,7 @@ defmodule PhoenixKit.Modules.Publishing.Posts do 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 @@ -120,11 +122,11 @@ defmodule PhoenixKit.Modules.Publishing.Posts do group_slug, db_post.post_date, db_post.post_time, - language, + resolved_language, version_number ) else - DBStorage.read_post(group_slug, db_post.slug, language, version_number) + DBStorage.read_post(group_slug, db_post.slug, resolved_language, version_number) end end rescue @@ -401,7 +403,7 @@ defmodule PhoenixKit.Modules.Publishing.Posts do 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 = language || inferred_language + final_language = resolve_language_to_dialect(language || inferred_language) final_version = version || inferred_version version_number = normalize_version_number(final_version) @@ -423,7 +425,7 @@ defmodule PhoenixKit.Modules.Publishing.Posts do {post_slug, inferred_version, inferred_language} = extract_slug_version_and_language(group_slug, identifier) - final_language = language || inferred_language + final_language = resolve_language_to_dialect(language || inferred_language) final_version = version || inferred_version version_number = normalize_version_number(final_version) @@ -442,6 +444,20 @@ defmodule PhoenixKit.Modules.Publishing.Posts do 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. From 5a5fd207d52dc37afd4d8deed455dfca5b795bc5 Mon Sep 17 00:00:00 2001 From: Max Don Date: Mon, 16 Mar 2026 01:31:54 +0200 Subject: [PATCH 96/99] Add post title to public post template Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/web/templates/show.html.heex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/modules/publishing/web/templates/show.html.heex b/lib/modules/publishing/web/templates/show.html.heex index 87165c81d..38a6b2de1 100644 --- a/lib/modules/publishing/web/templates/show.html.heex +++ b/lib/modules/publishing/web/templates/show.html.heex @@ -92,6 +92,7 @@
<% end %>
+

{@post.metadata.title || "Untitled"}

<%!-- Post Content --%>
From 73b9f9ebef38ac0f6e194c246ad152592a796263 Mon Sep 17 00:00:00 2001 From: Max Don Date: Mon, 16 Mar 2026 01:55:19 +0200 Subject: [PATCH 97/99] Fix dialyzer warning for redundant nil check in slug generation Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/stale_fixer.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/modules/publishing/stale_fixer.ex b/lib/modules/publishing/stale_fixer.ex index 3a5bf4bab..25ae11138 100644 --- a/lib/modules/publishing/stale_fixer.ex +++ b/lib/modules/publishing/stale_fixer.ex @@ -184,7 +184,7 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do # Post switched to slug mode but has no slug — generate from title or date slug = generate_slug_for_post(post) - if slug && slug != "" do + if slug != "" do Logger.info( "[Publishing] Generating slug for post #{post.uuid}: #{inspect(slug)} (mode changed to slug)" ) From 5b9234b93c65873fd52ebc842fa84a1d84124180 Mon Sep 17 00:00:00 2001 From: Max Don Date: Mon, 16 Mar 2026 02:40:48 +0200 Subject: [PATCH 98/99] Extract hardcoded status, mode, and type values into Publishing.Constants Centralizes duplicated inline lists (post statuses, content statuses, group statuses, modes, types) into a single Constants module. Replaces 20+ scattered hardcoded arrays across schemas, business logic, controllers, and templates with references to the shared constants. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/constants.ex | 82 +++++++++++++++++++ lib/modules/publishing/groups.ex | 10 ++- lib/modules/publishing/posts.ex | 7 +- .../publishing/schemas/publishing_content.ex | 4 +- .../publishing/schemas/publishing_group.ex | 6 +- .../publishing/schemas/publishing_post.ex | 6 +- .../publishing/schemas/publishing_version.ex | 4 +- lib/modules/publishing/shared.ex | 8 +- lib/modules/publishing/stale_fixer.ex | 16 ++-- lib/modules/publishing/translation_manager.ex | 5 +- .../web/controller/post_rendering.ex | 7 +- .../publishing/web/controller/translations.ex | 5 +- lib/modules/publishing/web/editor/helpers.ex | 5 +- lib/modules/publishing/web/html.ex | 14 ++-- lib/modules/publishing/web/index.ex | 5 +- lib/modules/publishing/web/listing.html.heex | 2 +- lib/modules/publishing/web/new.html.heex | 8 +- 17 files changed, 157 insertions(+), 37 deletions(-) create mode 100644 lib/modules/publishing/constants.ex diff --git a/lib/modules/publishing/constants.ex b/lib/modules/publishing/constants.ex new file mode 100644 index 000000000..d446cc5c3 --- /dev/null +++ b/lib/modules/publishing/constants.ex @@ -0,0 +1,82 @@ +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" + + @doc "Default group mode." + def default_mode, do: @default_mode + + @doc "Default group type." + def default_type, do: @default_type +end diff --git a/lib/modules/publishing/groups.ex b/lib/modules/publishing/groups.ex index 9b00125df..8adca0eb1 100644 --- a/lib/modules/publishing/groups.ex +++ b/lib/modules/publishing/groups.ex @@ -14,10 +14,12 @@ defmodule PhoenixKit.Modules.Publishing.Groups do alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Modules.Publishing.StaleFixer - @default_group_mode "timestamp" - @default_group_type "blog" - @preset_types ["blog", "faq", "legal"] - @valid_types ["blog", "faq", "legal", "custom"] + 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 %{ diff --git a/lib/modules/publishing/posts.ex b/lib/modules/publishing/posts.ex index 31225d9eb..34286a522 100644 --- a/lib/modules/publishing/posts.ex +++ b/lib/modules/publishing/posts.ex @@ -10,6 +10,9 @@ defmodule PhoenixKit.Modules.Publishing.Posts do 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 @@ -495,7 +498,7 @@ defmodule PhoenixKit.Modules.Publishing.Posts do db_post = find_db_post_for_update(group_slug, post) if db_post do - if post[:mode] in [:timestamp, "timestamp"] || db_post.mode == "timestamp" 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) else @@ -527,7 +530,7 @@ defmodule PhoenixKit.Modules.Publishing.Posts do DBStorage.get_post_by_uuid(post[:uuid], [:group]) # Timestamp-mode: use date/time - post[:mode] in [:timestamp, "timestamp"] && post[:date] && post[: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 diff --git a/lib/modules/publishing/schemas/publishing_content.ex b/lib/modules/publishing/schemas/publishing_content.ex index 976cb44fe..fc4303040 100644 --- a/lib/modules/publishing/schemas/publishing_content.ex +++ b/lib/modules/publishing/schemas/publishing_content.ex @@ -19,6 +19,8 @@ defmodule PhoenixKit.Modules.Publishing.PublishingContent do use Ecto.Schema import Ecto.Changeset + alias PhoenixKit.Modules.Publishing + @primary_key {:uuid, UUIDv7, autogenerate: true} @foreign_key_type UUIDv7 @@ -63,7 +65,7 @@ defmodule PhoenixKit.Modules.Publishing.PublishingContent do |> default_if_nil(:title, "") |> default_if_nil(:content, "") |> normalize_empty_to_nil(:url_slug) - |> validate_inclusion(:status, ["draft", "published", "archived"]) + |> validate_inclusion(:status, Publishing.Constants.content_statuses()) |> validate_length(:language, max: 10) |> validate_length(:title, max: 500) |> validate_length(:url_slug, max: 500) diff --git a/lib/modules/publishing/schemas/publishing_group.ex b/lib/modules/publishing/schemas/publishing_group.ex index 7866a90ed..8d295fd0a 100644 --- a/lib/modules/publishing/schemas/publishing_group.ex +++ b/lib/modules/publishing/schemas/publishing_group.ex @@ -21,6 +21,8 @@ defmodule PhoenixKit.Modules.Publishing.PublishingGroup do use Ecto.Schema import Ecto.Changeset + alias PhoenixKit.Modules.Publishing + @primary_key {:uuid, UUIDv7, autogenerate: true} @foreign_key_type UUIDv7 @@ -56,8 +58,8 @@ defmodule PhoenixKit.Modules.Publishing.PublishingGroup do group |> cast(attrs, [:name, :slug, :mode, :status, :position, :data]) |> validate_required([:name, :slug, :mode]) - |> validate_inclusion(:mode, ["timestamp", "slug"]) - |> validate_inclusion(:status, ["active", "trashed"]) + |> validate_inclusion(:mode, Publishing.Constants.valid_modes()) + |> validate_inclusion(:status, Publishing.Constants.group_statuses()) |> validate_length(:name, max: 255) |> validate_length(:slug, max: 255) |> unique_constraint(:slug, name: :idx_publishing_groups_slug) diff --git a/lib/modules/publishing/schemas/publishing_post.ex b/lib/modules/publishing/schemas/publishing_post.ex index 090ddd9ac..892a48c22 100644 --- a/lib/modules/publishing/schemas/publishing_post.ex +++ b/lib/modules/publishing/schemas/publishing_post.ex @@ -22,6 +22,8 @@ defmodule PhoenixKit.Modules.Publishing.PublishingPost do use Ecto.Schema import Ecto.Changeset + alias PhoenixKit.Modules.Publishing + @primary_key {:uuid, UUIDv7, autogenerate: true} @foreign_key_type UUIDv7 @@ -91,8 +93,8 @@ defmodule PhoenixKit.Modules.Publishing.PublishingPost do :data ]) |> validate_required([:group_uuid, :status, :mode, :primary_language]) - |> validate_inclusion(:status, ["draft", "published", "archived", "trashed"]) - |> validate_inclusion(:mode, ["timestamp", "slug"]) + |> validate_inclusion(:status, Publishing.Constants.post_statuses()) + |> validate_inclusion(:mode, Publishing.Constants.valid_modes()) |> maybe_require_slug() |> validate_length(:slug, max: 500) |> validate_length(:primary_language, max: 10) diff --git a/lib/modules/publishing/schemas/publishing_version.ex b/lib/modules/publishing/schemas/publishing_version.ex index 4f17d6b1a..4e7eef772 100644 --- a/lib/modules/publishing/schemas/publishing_version.ex +++ b/lib/modules/publishing/schemas/publishing_version.ex @@ -20,6 +20,8 @@ defmodule PhoenixKit.Modules.Publishing.PublishingVersion do use Ecto.Schema import Ecto.Changeset + alias PhoenixKit.Modules.Publishing + @primary_key {:uuid, UUIDv7, autogenerate: true} @foreign_key_type UUIDv7 @@ -68,7 +70,7 @@ defmodule PhoenixKit.Modules.Publishing.PublishingVersion do :data ]) |> validate_required([:post_uuid, :version_number, :status]) - |> validate_inclusion(:status, ["draft", "published", "archived"]) + |> validate_inclusion(:status, Publishing.Constants.content_statuses()) |> validate_number(:version_number, greater_than: 0) |> unique_constraint([:post_uuid, :version_number], name: :idx_publishing_versions_post_number diff --git a/lib/modules/publishing/shared.ex b/lib/modules/publishing/shared.ex index ad07ad410..98e5d5bc2 100644 --- a/lib/modules/publishing/shared.ex +++ b/lib/modules/publishing/shared.ex @@ -8,9 +8,13 @@ defmodule PhoenixKit.Modules.Publishing.Shared do require Logger + alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.DBStorage alias PhoenixKit.Users.Auth.Scope + @timestamp_modes Constants.timestamp_modes() + @slug_modes Constants.slug_modes() + # ============================================================================ # UUID Validation # ============================================================================ @@ -237,9 +241,9 @@ defmodule PhoenixKit.Modules.Publishing.Shared do version = Map.get(metadata, :version) || Map.get(post, :version) cond do - mode in [:timestamp, "timestamp"] -> true + mode in @timestamp_modes -> true is_nil(version) -> true - mode in [:slug, "slug"] -> true + mode in @slug_modes -> true status == "published" -> true true -> false end diff --git a/lib/modules/publishing/stale_fixer.ex b/lib/modules/publishing/stale_fixer.ex index 25ae11138..79bd4cea7 100644 --- a/lib/modules/publishing/stale_fixer.ex +++ b/lib/modules/publishing/stale_fixer.ex @@ -15,13 +15,15 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do alias PhoenixKit.Modules.Publishing.PublishingGroup alias PhoenixKit.Modules.Publishing.PublishingPost - @valid_types ["blog", "faq", "legal", "custom"] - @valid_group_modes ["timestamp", "slug"] - @valid_post_statuses ["draft", "published", "archived", "trashed"] - @valid_group_statuses ["active", "trashed"] - @valid_version_statuses ["draft", "published", "archived"] - @default_group_mode "timestamp" - @default_group_type "blog" + alias PhoenixKit.Modules.Publishing.Constants + + @valid_types Constants.valid_types() + @valid_group_modes Constants.valid_modes() + @valid_post_statuses Constants.post_statuses() + @valid_group_statuses Constants.group_statuses() + @valid_version_statuses Constants.content_statuses() + @default_group_mode Constants.default_mode() + @default_group_type Constants.default_type() @type_item_names %{ "blog" => {"post", "posts"}, diff --git a/lib/modules/publishing/translation_manager.ex b/lib/modules/publishing/translation_manager.ex index 2871c6d89..1e2c2f097 100644 --- a/lib/modules/publishing/translation_manager.ex +++ b/lib/modules/publishing/translation_manager.ex @@ -8,7 +8,10 @@ defmodule PhoenixKit.Modules.Publishing.TranslationManager do require Logger + alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.DBStorage + + @content_statuses Constants.content_statuses() alias PhoenixKit.Modules.Publishing.LanguageHelpers alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub @@ -246,7 +249,7 @@ defmodule PhoenixKit.Modules.Publishing.TranslationManager do @spec set_translation_status(String.t(), String.t(), integer(), String.t(), String.t()) :: :ok | {:error, any()} def set_translation_status(group_slug, post_identifier, version, language, status) - when status in ["draft", "published", "archived"] do + when status in @content_statuses do db_post = if Shared.uuid_format?(post_identifier) do DBStorage.get_post_by_uuid(post_identifier) diff --git a/lib/modules/publishing/web/controller/post_rendering.ex b/lib/modules/publishing/web/controller/post_rendering.ex index b050551d5..73684154e 100644 --- a/lib/modules/publishing/web/controller/post_rendering.ex +++ b/lib/modules/publishing/web/controller/post_rendering.ex @@ -12,7 +12,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do require Logger alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.ListingCache + + @timestamp_modes Constants.timestamp_modes() alias PhoenixKit.Modules.Publishing.Renderer alias PhoenixKit.Modules.Publishing.Web.Controller.Language alias PhoenixKit.Modules.Publishing.Web.Controller.Listing @@ -303,7 +306,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do defp find_cached_post(group_slug, post) do mode = Map.get(post, :mode) - if mode in [:timestamp, "timestamp"] do + if mode in @timestamp_modes do # For timestamp mode, use date/time lookup date = post[:date] time = post[:time] @@ -428,7 +431,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do end defp future_post?(post) do - post[:mode] in [:timestamp, "timestamp"] and post[:date] != nil and + post[:mode] in @timestamp_modes and post[:date] != nil and Date.compare(post[:date], Date.utc_today()) == :gt end end diff --git a/lib/modules/publishing/web/controller/translations.ex b/lib/modules/publishing/web/controller/translations.ex index 435572a02..2cbd1b53b 100644 --- a/lib/modules/publishing/web/controller/translations.ex +++ b/lib/modules/publishing/web/controller/translations.ex @@ -10,6 +10,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Translations do alias PhoenixKit.Modules.Languages alias PhoenixKit.Modules.Languages.DialectMapper alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Constants + + @timestamp_modes Constants.timestamp_modes() alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.Web.Controller.Language alias PhoenixKit.Modules.Publishing.Web.Controller.PostRendering @@ -221,7 +224,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Translations do defp find_cached_post(group_slug, post) do mode = Map.get(post, :mode) - if mode in [:timestamp, "timestamp"] do + if mode in @timestamp_modes do # For timestamp mode, use date/time lookup date = post[:date] time = post[:time] diff --git a/lib/modules/publishing/web/editor/helpers.ex b/lib/modules/publishing/web/editor/helpers.ex index 8dbc73e97..705a3865b 100644 --- a/lib/modules/publishing/web/editor/helpers.ex +++ b/lib/modules/publishing/web/editor/helpers.ex @@ -7,6 +7,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do """ alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.Web.Editor.Translation alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML alias PhoenixKit.Modules.Storage.URLSigner @@ -158,8 +159,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do mode = Map.get(post, :mode) cond do - mode in [:slug, "slug"] -> build_slug_mode_url(group_slug, post, language) - mode in [:timestamp, "timestamp"] -> build_timestamp_mode_url(group_slug, post, language) + Constants.slug_mode?(mode) -> build_slug_mode_url(group_slug, post, language) + Constants.timestamp_mode?(mode) -> build_timestamp_mode_url(group_slug, post, language) true -> nil end end diff --git a/lib/modules/publishing/web/html.ex b/lib/modules/publishing/web/html.ex index a604fd6ea..94e99b039 100644 --- a/lib/modules/publishing/web/html.ex +++ b/lib/modules/publishing/web/html.ex @@ -7,9 +7,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do alias PhoenixKit.Config alias PhoenixKit.Modules.Languages alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.Renderer alias PhoenixKit.Modules.Storage + @timestamp_modes Constants.timestamp_modes() + @slug_modes Constants.slug_modes() + # Import publishing-specific components for templates import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher @@ -48,7 +52,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do language = language || "en" case post.mode do - mode when mode in [:slug, "slug"] -> + mode when mode in @slug_modes -> # Use language-specific URL slug for SEO-friendly localized URLs url_slug = get_url_slug_for_language(post, language) @@ -59,7 +63,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do build_public_path(segments) - mode when mode in [:timestamp, "timestamp"] -> + mode when mode in @timestamp_modes -> # For timestamp mode, use the date/time from the DB fields # (stored in post.date and post.time), not from metadata.published_at date = get_timestamp_date(post) @@ -190,7 +194,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do """ def has_publication_date?(post) do case post.mode do - mode when mode in [:timestamp, "timestamp"] -> + mode when mode in @timestamp_modes -> # Timestamp mode always has a date (from DB fields) post[:date] != nil @@ -206,7 +210,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do """ def format_post_date(post, group_slug, date_counts \\ nil) do case post.mode do - mode when mode in [:timestamp, "timestamp"] -> + mode when mode in @timestamp_modes -> # For timestamp mode, use date/time from DB fields date = get_timestamp_date(post) post_count = lookup_date_count(date_counts, group_slug, date) @@ -413,7 +417,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do """ def build_date_counts(posts) do posts - |> Enum.filter(&(&1.mode in [:timestamp, "timestamp"])) + |> Enum.filter(&(&1.mode in @timestamp_modes)) |> Enum.map(&get_timestamp_date/1) |> Enum.frequencies() end diff --git a/lib/modules/publishing/web/index.ex b/lib/modules/publishing/web/index.ex index 7cf3e63a7..c0f747e19 100644 --- a/lib/modules/publishing/web/index.ex +++ b/lib/modules/publishing/web/index.ex @@ -9,7 +9,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do require Logger alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.DBStorage + + @group_statuses Constants.group_statuses() alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Settings @@ -164,7 +167,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do {:noreply, put_flash(socket, :error, gettext("Failed to update primary language"))} end - def handle_event("switch_view", %{"mode" => mode}, socket) when mode in ["active", "trashed"] do + def handle_event("switch_view", %{"mode" => mode}, socket) when mode in @group_statuses do send(self(), {:deferred_view_switch, mode}) {:noreply, diff --git a/lib/modules/publishing/web/listing.html.heex b/lib/modules/publishing/web/listing.html.heex index 9542dc548..3e011fabe 100644 --- a/lib/modules/publishing/web/listing.html.heex +++ b/lib/modules/publishing/web/listing.html.heex @@ -182,7 +182,7 @@ <% full_public_url = @endpoint_url <> public_url %> <%!-- Version info (only for slug-mode posts) --%> <% version_count = length(Map.get(post, :available_versions) || []) %> - <% is_versioned = post.mode in [:slug, "slug"] %> + <% is_versioned = PhoenixKit.Modules.Publishing.Constants.slug_mode?(post.mode) %> <% post_status = post.metadata.status %>
diff --git a/lib/modules/publishing/web/new.html.heex b/lib/modules/publishing/web/new.html.heex index 5852705e8..bbc04ed11 100644 --- a/lib/modules/publishing/web/new.html.heex +++ b/lib/modules/publishing/web/new.html.heex @@ -147,7 +147,9 @@ name={@form[:type].name} value="custom" class="radio radio-primary mt-1" - checked={@form[:type].value not in ["blog", "faq", "legal"]} + checked={ + @form[:type].value not in PhoenixKit.Modules.Publishing.Constants.preset_types() + } phx-click="type_changed" phx-value-type="custom" /> @@ -156,7 +158,7 @@

{gettext("Define your own content type and item names.")}

- <%= if @form[:type].value not in ["blog", "faq", "legal"] do %> + <%= if @form[:type].value not in PhoenixKit.Modules.Publishing.Constants.preset_types() do %>
Date: Mon, 16 Mar 2026 03:24:52 +0200 Subject: [PATCH 99/99] Consolidate remaining duplicated values and helpers in publishing module - Add default_title ("Untitled") and schema max lengths to Constants - Replace 20+ hardcoded "Untitled" strings with Constants.default_title() - Fix missed inline mode check in controller/listing.ex - Deduplicate get_language_name (remove private copies from index.ex and listing.ex, reuse public version from Editor.Helpers) - Consolidate find_dialect_for_base_in_languages as delegate to find_dialect_for_base (identical implementations) - Extract normalize_string/normalize_slug helpers in editor forms Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/modules/publishing/constants.ex | 29 ++++++++++++ lib/modules/publishing/metadata.ex | 14 +++--- lib/modules/publishing/posts.ex | 6 +-- .../publishing/schemas/publishing_content.ex | 6 +-- .../publishing/schemas/publishing_group.ex | 4 +- .../publishing/schemas/publishing_post.ex | 4 +- lib/modules/publishing/stale_fixer.ex | 6 +-- lib/modules/publishing/translation_manager.ex | 2 +- .../publishing/web/controller/language.ex | 11 ++--- .../publishing/web/controller/listing.ex | 7 ++- .../web/controller/post_rendering.ex | 4 +- lib/modules/publishing/web/editor.ex | 7 ++- lib/modules/publishing/web/editor/forms.ex | 46 ++++++++----------- lib/modules/publishing/web/index.ex | 17 +++---- lib/modules/publishing/web/listing.ex | 16 +++---- .../publishing/web/templates/show.html.heex | 4 +- .../workers/translate_post_worker.ex | 3 +- 17 files changed, 104 insertions(+), 82 deletions(-) diff --git a/lib/modules/publishing/constants.ex b/lib/modules/publishing/constants.ex index d446cc5c3..73260deb7 100644 --- a/lib/modules/publishing/constants.ex +++ b/lib/modules/publishing/constants.ex @@ -73,10 +73,39 @@ defmodule PhoenixKit.Modules.Publishing.Constants do @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/metadata.ex b/lib/modules/publishing/metadata.ex index 0fba30051..1f6ea630f 100644 --- a/lib/modules/publishing/metadata.ex +++ b/lib/modules/publishing/metadata.ex @@ -5,6 +5,8 @@ defmodule PhoenixKit.Modules.Publishing.Metadata do 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. @@ -17,23 +19,23 @@ defmodule PhoenixKit.Modules.Publishing.Metadata do |> do_extract_title() end - def extract_title_from_content(_), do: "Untitled" + def extract_title_from_content(_), do: @default_title - defp do_extract_title(""), do: "Untitled" + defp do_extract_title(""), do: @default_title defp do_extract_title(content) do content |> extract_title_from_lines() |> case do - "Untitled" -> - extract_title_from_components(content) || "Untitled" + @default_title -> + extract_title_from_components(content) || @default_title title -> title end end - defp extract_title_from_lines(""), do: "Untitled" + defp extract_title_from_lines(""), do: @default_title defp extract_title_from_lines(content) do lines = @@ -58,7 +60,7 @@ defmodule PhoenixKit.Modules.Publishing.Metadata do |> String.slice(0, 100) true -> - "Untitled" + @default_title end end diff --git a/lib/modules/publishing/posts.ex b/lib/modules/publishing/posts.ex index 34286a522..1d76d57e1 100644 --- a/lib/modules/publishing/posts.ex +++ b/lib/modules/publishing/posts.ex @@ -579,7 +579,7 @@ defmodule PhoenixKit.Modules.Publishing.Posts do # Title is required for primary language when publishing (drafts can be untitled) if language == db_post.primary_language and new_status == "published" and - new_title in ["", "Untitled"] do + new_title in ["", Constants.default_title()] do throw({:post_update_failed, :title_required}) end @@ -615,9 +615,9 @@ defmodule PhoenixKit.Modules.Publishing.Posts do post_metadata = post[:metadata] || %{} Map.get(params, "title") || - if(extracted_title != "Untitled", do: extracted_title) || + if(extracted_title != Constants.default_title(), do: extracted_title) || post_metadata[:title] || - "Untitled" + Constants.default_title() end defp update_post_level_fields!(db_post, new_status, params) do diff --git a/lib/modules/publishing/schemas/publishing_content.ex b/lib/modules/publishing/schemas/publishing_content.ex index fc4303040..d11d26a15 100644 --- a/lib/modules/publishing/schemas/publishing_content.ex +++ b/lib/modules/publishing/schemas/publishing_content.ex @@ -66,9 +66,9 @@ defmodule PhoenixKit.Modules.Publishing.PublishingContent do |> default_if_nil(:content, "") |> normalize_empty_to_nil(:url_slug) |> validate_inclusion(:status, Publishing.Constants.content_statuses()) - |> validate_length(:language, max: 10) - |> validate_length(:title, max: 500) - |> validate_length(:url_slug, max: 500) + |> validate_length(:language, max: Publishing.Constants.max_language_code_length()) + |> validate_length(:title, max: Publishing.Constants.max_title_length()) + |> validate_length(:url_slug, max: Publishing.Constants.max_slug_length()) |> unique_constraint([:version_uuid, :language], name: :idx_publishing_contents_version_language ) diff --git a/lib/modules/publishing/schemas/publishing_group.ex b/lib/modules/publishing/schemas/publishing_group.ex index 8d295fd0a..2cc7f760d 100644 --- a/lib/modules/publishing/schemas/publishing_group.ex +++ b/lib/modules/publishing/schemas/publishing_group.ex @@ -60,8 +60,8 @@ defmodule PhoenixKit.Modules.Publishing.PublishingGroup do |> validate_required([:name, :slug, :mode]) |> validate_inclusion(:mode, Publishing.Constants.valid_modes()) |> validate_inclusion(:status, Publishing.Constants.group_statuses()) - |> validate_length(:name, max: 255) - |> validate_length(:slug, max: 255) + |> validate_length(:name, max: Publishing.Constants.max_group_name_length()) + |> validate_length(:slug, max: Publishing.Constants.max_group_slug_length()) |> unique_constraint(:slug, name: :idx_publishing_groups_slug) |> maybe_generate_slug() end diff --git a/lib/modules/publishing/schemas/publishing_post.ex b/lib/modules/publishing/schemas/publishing_post.ex index 892a48c22..faf16b4fb 100644 --- a/lib/modules/publishing/schemas/publishing_post.ex +++ b/lib/modules/publishing/schemas/publishing_post.ex @@ -96,8 +96,8 @@ defmodule PhoenixKit.Modules.Publishing.PublishingPost do |> validate_inclusion(:status, Publishing.Constants.post_statuses()) |> validate_inclusion(:mode, Publishing.Constants.valid_modes()) |> maybe_require_slug() - |> validate_length(:slug, max: 500) - |> validate_length(:primary_language, max: 10) + |> validate_length(:slug, max: Publishing.Constants.max_slug_length()) + |> validate_length(:primary_language, max: Publishing.Constants.max_language_code_length()) |> maybe_require_timestamp_fields() |> unique_constraint([:group_uuid, :slug], name: :idx_publishing_posts_group_slug) |> unique_constraint([:group_uuid, :post_date, :post_time], diff --git a/lib/modules/publishing/stale_fixer.ex b/lib/modules/publishing/stale_fixer.ex index 79bd4cea7..aa04c7368 100644 --- a/lib/modules/publishing/stale_fixer.ex +++ b/lib/modules/publishing/stale_fixer.ex @@ -130,7 +130,7 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do contents == [] or Enum.all?(contents, fn c -> - (c.content || "") == "" and (c.title || "") in ["", "Untitled"] + (c.content || "") == "" and (c.title || "") in ["", Constants.default_title()] end) end) end @@ -222,7 +222,7 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do base = cond do - is_binary(title) and title not in ["", "Untitled"] -> + is_binary(title) and title not in ["", Constants.default_title()] -> title post.post_date -> @@ -422,7 +422,7 @@ defmodule PhoenixKit.Modules.Publishing.StaleFixer do defp primary_content_missing?(nil), do: true defp primary_content_missing?(content) do - content.content in [nil, ""] and content.title in [nil, "", "Untitled"] + content.content in [nil, ""] and content.title in [nil, "", Constants.default_title()] end defp fix_version_primary_content(post, version, primary_lang, primary_content, contents) do diff --git a/lib/modules/publishing/translation_manager.ex b/lib/modules/publishing/translation_manager.ex index 1e2c2f097..a50d83aa6 100644 --- a/lib/modules/publishing/translation_manager.ex +++ b/lib/modules/publishing/translation_manager.ex @@ -152,7 +152,7 @@ defmodule PhoenixKit.Modules.Publishing.TranslationManager do DBStorage.create_content(%{ version_uuid: version.uuid, language: language_code, - title: "Untitled", + title: Constants.default_title(), content: "", status: "draft" }) do diff --git a/lib/modules/publishing/web/controller/language.ex b/lib/modules/publishing/web/controller/language.ex index 5b354d686..90201ed8c 100644 --- a/lib/modules/publishing/web/controller/language.ex +++ b/lib/modules/publishing/web/controller/language.ex @@ -170,15 +170,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do end @doc """ - Find a dialect in available languages that matches the given base code. + Find a dialect in a list of languages that matches the given base code. """ - def find_dialect_for_base_in_languages(base_code, available_languages) do - base_lower = String.downcase(base_code) - - Enum.find(available_languages, fn lang -> - DialectMapper.extract_base(lang) == base_lower - end) - end + def find_dialect_for_base_in_languages(base_code, languages), + do: find_dialect_for_base(base_code, languages) # ============================================================================ # Canonical URL Language diff --git a/lib/modules/publishing/web/controller/listing.ex b/lib/modules/publishing/web/controller/listing.ex index cdf5fb2f5..57c1f2fee 100644 --- a/lib/modules/publishing/web/controller/listing.ex +++ b/lib/modules/publishing/web/controller/listing.ex @@ -10,6 +10,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do alias PhoenixKit.Modules.Languages.DialectMapper alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Constants + + @timestamp_modes Constants.timestamp_modes() alias PhoenixKit.Modules.Publishing.Web.Controller.Language alias PhoenixKit.Modules.Publishing.Web.Controller.PostFetching alias PhoenixKit.Modules.Publishing.Web.Controller.Translations @@ -179,7 +182,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do lang_excerpts = post[:language_excerpts] || %{} metadata = post[:metadata] || %{} - title = Map.get(lang_titles, language, metadata[:title] || "Untitled") + title = Map.get(lang_titles, language, metadata[:title] || Constants.default_title()) excerpt = Map.get(lang_excerpts, language) post @@ -206,7 +209,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do end defp future_post?(post, today) do - post[:mode] in [:timestamp, "timestamp"] and post[:date] != nil and + post[:mode] in @timestamp_modes and post[:date] != nil and Date.compare(post[:date], today) == :gt end diff --git a/lib/modules/publishing/web/controller/post_rendering.ex b/lib/modules/publishing/web/controller/post_rendering.ex index 73684154e..4355b1a29 100644 --- a/lib/modules/publishing/web/controller/post_rendering.ex +++ b/lib/modules/publishing/web/controller/post_rendering.ex @@ -82,7 +82,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do {:ok, %{ - page_title: post.metadata.title || "Untitled", + page_title: post.metadata.title || Constants.default_title(), group_slug: group_slug, post: post, html_content: html_content, @@ -146,7 +146,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do {:ok, %{ - page_title: post.metadata.title || "Untitled", + page_title: post.metadata.title || Constants.default_title(), group_slug: group_slug, post: post, html_content: html_content, diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index d58be9648..2526e6dc0 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -28,6 +28,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do alias Phoenix.LiveView.JS alias PhoenixKit.Modules.AI alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.DBStorage alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.Metadata @@ -442,7 +443,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do # Seed auto-title from existing content for manual-set detection extracted_title = Metadata.extract_title_from_content(post.content || "") - auto_title = if extracted_title == "Untitled", do: "", else: extracted_title + auto_title = if extracted_title == Constants.default_title(), do: "", else: extracted_title form_title = Map.get(form, "title", "") title_manually_set = form_title != "" and auto_title != "" and form_title != auto_title @@ -1528,7 +1529,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do {:ok, post} -> form = Forms.post_form(post) extracted_title = Metadata.extract_title_from_content(post.content || "") - auto_title = if extracted_title == "Untitled", do: "", else: extracted_title + + auto_title = + if extracted_title == Constants.default_title(), do: "", else: extracted_title socket |> assign(:post, %{post | group: socket.assigns.group_slug}) diff --git a/lib/modules/publishing/web/editor/forms.ex b/lib/modules/publishing/web/editor/forms.ex index 3a2fcdd84..f6a61bc98 100644 --- a/lib/modules/publishing/web/editor/forms.ex +++ b/lib/modules/publishing/web/editor/forms.ex @@ -7,6 +7,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do """ alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.Metadata alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Slug @@ -65,7 +66,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do defp get_title_for_form(post) do title = Map.get(post.metadata, :title) || Map.get(post.metadata, "title") || "" - if title == "Untitled", do: "", else: title + if title == Constants.default_title(), do: "", else: title end defp get_published_at(post) do @@ -114,26 +115,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do Normalizes a form map to ensure consistent values. """ def normalize_form(form) when is_map(form) do - # Normalize title: trim only (preserve case) - title = - form - |> Map.get("title", "") - |> to_string() - |> String.trim() - - featured_image_uuid = - form - |> Map.get("featured_image_uuid", "") - |> to_string() - |> String.trim() - - # Normalize url_slug: trim and downcase, empty string if nil - url_slug = - form - |> Map.get("url_slug", "") - |> to_string() - |> String.trim() - |> String.downcase() + title = normalize_string(form, "title") + featured_image_uuid = normalize_string(form, "featured_image_uuid") + url_slug = normalize_slug(form, "url_slug") base = %{ @@ -146,9 +130,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do case Map.fetch(form, "slug") do {:ok, slug} -> - # Normalize slug: trim and downcase to match validation requirements - normalized_slug = slug |> to_string() |> String.trim() |> String.downcase() - Map.put(base, "slug", normalized_slug) + Map.put(base, "slug", slug |> to_string() |> String.trim() |> String.downcase()) :error -> base @@ -424,7 +406,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do {socket, socket.assigns.form, []} else extracted = Metadata.extract_title_from_content(content) - new_title = if extracted == "Untitled", do: "", else: extracted + new_title = if extracted == Constants.default_title(), do: "", else: extracted current_title = Map.get(socket.assigns.form, "title", "") if new_title != "" and new_title != current_title do @@ -490,7 +472,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do """ def revert_title_to_auto(form, socket) do extracted = Metadata.extract_title_from_content(socket.assigns.content || "") - auto_title = if extracted == "Untitled", do: "", else: extracted + auto_title = if extracted == Constants.default_title(), do: "", else: extracted if auto_title != "" do {Map.put(form, "title", auto_title), false} @@ -570,4 +552,16 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do def update_form_with_media(form, file_uuid) do Map.put(form, "featured_image_uuid", file_uuid) end + + # ============================================================================ + # String Normalization + # ============================================================================ + + defp normalize_string(form, key) do + form |> Map.get(key, "") |> to_string() |> String.trim() + end + + defp normalize_slug(form, key) do + form |> Map.get(key, "") |> to_string() |> String.trim() |> String.downcase() + end end diff --git a/lib/modules/publishing/web/index.ex b/lib/modules/publishing/web/index.ex index c0f747e19..ef5fbf4f7 100644 --- a/lib/modules/publishing/web/index.ex +++ b/lib/modules/publishing/web/index.ex @@ -11,6 +11,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.DBStorage + alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers @group_statuses Constants.group_statuses() alias PhoenixKit.Modules.Publishing.ListingCache @@ -65,7 +66,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do |> assign(:enabled_languages, Publishing.enabled_language_codes()) |> assign(:endpoint_url, nil) |> assign(:date_time_settings, date_time_settings) - |> assign(:primary_language_name, get_language_name(Publishing.get_primary_language())) + |> assign( + :primary_language_name, + Helpers.get_language_name(Publishing.get_primary_language()) + ) |> assign(:dashboard_refresh_timer, nil) |> assign(:view_mode, "active") |> assign(:loading, false) @@ -135,7 +139,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do :info, gettext("Updated %{count} posts to primary language: %{lang}", count: count, - lang: get_language_name(primary_language) + lang: Helpers.get_language_name(primary_language) ) )} end @@ -157,7 +161,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do :info, gettext("Updated %{count} posts to primary language: %{lang}", count: count, - lang: get_language_name(Publishing.get_primary_language()) + lang: Helpers.get_language_name(Publishing.get_primary_language()) ) )} end @@ -220,13 +224,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do end end - defp get_language_name(language_code) do - case Publishing.get_language_info(language_code) do - %{name: name} -> name - _ -> String.upcase(language_code) - end - end - defp schedule_dashboard_refresh(socket) do if timer = socket.assigns[:dashboard_refresh_timer] do Process.cancel_timer(timer) diff --git a/lib/modules/publishing/web/listing.ex b/lib/modules/publishing/web/listing.ex index 8bd50a308..42bce5790 100644 --- a/lib/modules/publishing/web/listing.ex +++ b/lib/modules/publishing/web/listing.ex @@ -44,7 +44,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do |> assign(:group_slug, group_slug) |> assign(:enabled_languages, Publishing.enabled_language_codes()) |> assign(:primary_language, Publishing.get_primary_language()) - |> assign(:primary_language_name, get_language_name(Publishing.get_primary_language())) + |> assign( + :primary_language_name, + Helpers.get_language_name(Publishing.get_primary_language()) + ) |> assign(:posts, filtered_posts) |> assign(:loading, false) |> assign(:endpoint_url, "") @@ -230,7 +233,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do :info, gettext("Updated %{count} posts to primary language: %{lang}", count: count, - lang: get_language_name(Publishing.get_primary_language()) + lang: Helpers.get_language_name(Publishing.get_primary_language()) ) )} end @@ -448,7 +451,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do :info, gettext("Updated %{count} posts to primary language: %{lang}", count: count, - lang: get_language_name(primary_language) + lang: Helpers.get_language_name(primary_language) ) )} else @@ -1011,11 +1014,4 @@ defmodule PhoenixKit.Modules.Publishing.Web.Listing do global_primary = Publishing.get_primary_language() DBStorage.count_primary_language_status_from_posts(posts, global_primary) end - - defp get_language_name(language_code) do - case Publishing.get_language_info(language_code) do - %{name: name} -> name - _ -> String.upcase(language_code) - end - end end diff --git a/lib/modules/publishing/web/templates/show.html.heex b/lib/modules/publishing/web/templates/show.html.heex index 38a6b2de1..604c7bfea 100644 --- a/lib/modules/publishing/web/templates/show.html.heex +++ b/lib/modules/publishing/web/templates/show.html.heex @@ -92,7 +92,9 @@
<% end %>
-

{@post.metadata.title || "Untitled"}

+

+ {@post.metadata.title || PhoenixKit.Modules.Publishing.Constants.default_title()} +

<%!-- Post Content --%>
diff --git a/lib/modules/publishing/workers/translate_post_worker.ex b/lib/modules/publishing/workers/translate_post_worker.ex index 1cf845cf8..f56100c09 100644 --- a/lib/modules/publishing/workers/translate_post_worker.ex +++ b/lib/modules/publishing/workers/translate_post_worker.ex @@ -70,6 +70,7 @@ defmodule PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker do alias PhoenixKit.Modules.AI alias PhoenixKit.Modules.Publishing + alias PhoenixKit.Modules.Publishing.Constants alias PhoenixKit.Modules.Publishing.LanguageHelpers alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Settings @@ -354,7 +355,7 @@ defmodule PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker do case Regex.run(~r/^#\s+(.+)$/m, content) do [_, title] -> String.trim(title) - nil -> Map.get(post.metadata || %{}, :title, "Untitled") + nil -> Map.get(post.metadata || %{}, :title, Constants.default_title()) end end