diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 6a2baf7c..edafa0e8 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -61,6 +61,7 @@ {"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}, @@ -89,8 +90,6 @@ # Publishing module - with-chain type inference false positives # Dialyzer incorrectly infers read_post/update_post only return errors in certain contexts # The actual functions return both {:ok, post} and {:error, reason} at runtime - ~r/lib\/modules\/publishing\/storage\/.*\.ex:.*pattern_match/, - ~r/lib\/modules\/publishing\/storage\/.*\.ex:.*call/, ~r/lib\/modules\/publishing\/listing_cache\.ex:.*pattern_match/, ~r/lib\/modules\/publishing\/web\/listing\.ex:.*pattern_match/, ~r/lib\/modules\/publishing\/web\/listing\.ex:.*unused_fun/, @@ -111,14 +110,6 @@ # Dialyzer incorrectly infers read_post only returns errors in certain contexts ~r/lib\/modules\/publishing\/workers\/translate_post_worker\.ex:.*pattern_match/, ~r/lib\/modules\/publishing\/workers\/translate_post_worker\.ex:.*unused_fun/, - ~r/lib\/modules\/publishing\/workers\/migrate_to_database_worker\.ex:.*pattern_match/, - ~r/lib\/modules\/publishing\/workers\/migrate_to_database_worker\.ex:.*unused_fun/, - ~r/lib\/modules\/publishing\/workers\/validate_migration_worker\.ex:.*pattern_match/, - ~r/lib\/modules\/publishing\/workers\/validate_migration_worker\.ex:.*unused_fun/, - - # Publishing DB Importer - read_post type inference false positives - ~r/lib\/modules\/publishing\/db_importer\.ex:.*pattern_match/, - # Pages module - same type inference false positives as Publishing (copied codebase) ~r/lib\/modules\/pages\/listing_cache\.ex:.*pattern_match/, ~r/lib\/modules\/pages\/storage\/.*\.ex:.*pattern_match/, diff --git a/lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex b/lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex index 914671ef..ca1481ad 100644 --- a/lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex +++ b/lib/mix/tasks/phoenix_kit.migrate_blog_versions.ex @@ -1,317 +1,20 @@ defmodule Mix.Tasks.PhoenixKit.MigrateBlogVersions do - # Suppress dialyzer warnings for Mix module functions not recognized at analysis time - @dialyzer :no_undefined_callbacks - @dialyzer {:no_unknown, run: 1} - + @shortdoc "Legacy task β€” filesystem storage has been removed" @moduledoc """ - Migrates existing blog posts to the new versioned folder structure. - - This task moves blog posts from the legacy flat structure to the versioned structure: - - - Legacy: `blog-slug/post-slug/en.phk` - - Versioned: `blog-slug/post-slug/v1/en.phk` - - All existing posts are treated as version 1 with the appropriate metadata fields added. - - ## Usage - - mix phoenix_kit.migrate_blog_versions - - ## Options - - --dry-run Show what would be changed without making changes - --verbose Show detailed output during migration - --blog SLUG Only migrate a specific blog (default: all blogs) - - ## Examples + This task previously migrated blog posts from flat to versioned folder structure. - # See what would be changed - mix phoenix_kit.migrate_blog_versions --dry-run - - # Migrate all blogs with detailed output - mix phoenix_kit.migrate_blog_versions --verbose - - # Migrate a specific blog - mix phoenix_kit.migrate_blog_versions --blog my-blog - - The migration is idempotent - posts already in versioned structure are skipped. + 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. """ - @shortdoc "Migrates blog posts to versioned folder structure" - use Mix.Task - alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Metadata - alias PhoenixKit.Modules.Publishing.Storage - alias PhoenixKit.Utils.Date, as: UtilsDate - @impl Mix.Task - def run(args) do - {opts, _args, _invalid} = - OptionParser.parse(args, - switches: [ - dry_run: :boolean, - verbose: :boolean, - blog: :string - ], - aliases: [ - d: :dry_run, - v: :verbose, - b: :blog - ] - ) - - # Start the application - Mix.Task.run("app.start", []) - - # Welcome message - Mix.shell().info([ - :bright, - :blue, - "\nπŸ“¦ PhoenixKit Blog Version Migration Tool\n", - :normal, - "Migrating blog posts to versioned folder structure\n" - ]) - - if opts[:dry_run] do - Mix.shell().info([:yellow, "πŸ” DRY RUN MODE - No files will be modified\n"]) - end - - {:ok, stats} = run_migration(opts) - display_success_summary(stats, opts) - end - - defp run_migration(opts) do - blogs = get_blogs_to_migrate(opts) - - if blogs == [] do - Mix.shell().info([:yellow, "No blogs found to migrate."]) - {:ok, %{migrated: 0, skipped: 0, errors: 0}} - else - stats = %{migrated: 0, skipped: 0, errors: 0} - - stats = - Enum.reduce(blogs, stats, fn blog, acc_stats -> - migrate_blog(blog, opts, acc_stats) - end) - - {:ok, stats} - end - end - - defp get_blogs_to_migrate(opts) do - all_blogs = Publishing.list_groups() - - case opts[:blog] do - nil -> - # Migrate all blogs - all_blogs - - slug -> - # Find specific blog - case Enum.find(all_blogs, fn b -> b["slug"] == slug end) do - nil -> - Mix.shell().error("Blog '#{slug}' not found") - [] - - blog -> - [blog] - end - end - end - - defp migrate_blog(blog, opts, stats) do - blog_slug = blog["slug"] - blog_mode = blog["mode"] || "timestamp" - - if opts[:verbose] do - Mix.shell().info("\nπŸ“ Processing blog: #{blog["name"]} (#{blog_slug})") - Mix.shell().info(" Mode: #{blog_mode}") - end - - # Only slug-mode blogs support versioning - if blog_mode != "slug" do - if opts[:verbose] do - Mix.shell().info([ - :yellow, - " Skipping: Timestamp-mode blogs don't require version migration" - ]) - end - - stats - else - migrate_slug_mode_blog(blog_slug, opts, stats) - end - end - - defp migrate_slug_mode_blog(blog_slug, opts, stats) do - blog_path = Storage.group_path(blog_slug) - - case File.ls(blog_path) do - {:ok, entries} -> - post_slugs = - entries - |> Enum.filter(&File.dir?(Path.join(blog_path, &1))) - |> Enum.reject(&(String.starts_with?(&1, ".") or String.starts_with?(&1, "_trash"))) - - if opts[:verbose] do - Mix.shell().info(" Found #{length(post_slugs)} post directories") - end - - Enum.reduce(post_slugs, stats, fn post_slug, acc_stats -> - migrate_post(blog_slug, post_slug, opts, acc_stats) - end) - - {:error, reason} -> - Mix.shell().error(" Error reading blog directory: #{reason}") - %{stats | errors: stats.errors + 1} - end - end - - defp migrate_post(blog_slug, post_slug, opts, stats) do - post_path = Path.join(Storage.group_path(blog_slug), post_slug) - - # Check the post structure - case Storage.detect_post_structure(post_path) do - :versioned -> - # Already versioned, skip - if opts[:verbose] do - Mix.shell().info(" βœ“ #{post_slug}: Already versioned (skipped)") - end - - %{stats | skipped: stats.skipped + 1} - - :legacy -> - # Needs migration - if opts[:dry_run] do - Mix.shell().info(" β†’ #{post_slug}: Would migrate to v1/") - %{stats | migrated: stats.migrated + 1} - else - case migrate_legacy_post(blog_slug, post_slug, opts) do - :ok -> - Mix.shell().info(" βœ“ #{post_slug}: Migrated to v1/") - %{stats | migrated: stats.migrated + 1} - - {:error, reason} -> - Mix.shell().error(" βœ— #{post_slug}: Failed - #{reason}") - %{stats | errors: stats.errors + 1} - end - end - - :empty -> - if opts[:verbose] do - Mix.shell().info(" - #{post_slug}: Empty directory (skipped)") - end - - %{stats | skipped: stats.skipped + 1} - end - end - - defp migrate_legacy_post(blog_slug, post_slug, opts) do - post_path = Path.join(Storage.group_path(blog_slug), post_slug) - v1_path = Path.join(post_path, "v1") - - with {:ok, phk_files} <- list_phk_files_for_migration(post_path), - :ok <- File.mkdir_p(v1_path), - :ok <- migrate_phk_files(post_path, v1_path, phk_files, opts) do - :ok - else - {:error, :no_files} -> {:error, "No .phk files found"} - {:error, reason} when is_binary(reason) -> {:error, reason} - {:error, reason} -> {:error, "Failed to create v1 directory: #{inspect(reason)}"} - end - end - - defp list_phk_files_for_migration(post_path) do - case File.ls(post_path) do - {:ok, files} -> - phk_files = Enum.filter(files, &String.ends_with?(&1, ".phk")) - if phk_files == [], do: {:error, :no_files}, else: {:ok, phk_files} - - {:error, reason} -> - {:error, "Failed to list files: #{inspect(reason)}"} - end - end - - defp migrate_phk_files(post_path, v1_path, phk_files, opts) do - results = Enum.map(phk_files, &migrate_file(post_path, v1_path, &1, opts)) - - if Enum.all?(results, &(&1 == :ok)) do - :ok - else - errors = Enum.filter(results, &match?({:error, _}, &1)) - {:error, inspect(errors)} - end - end - - defp migrate_file(post_path, v1_path, file, opts) do - source = Path.join(post_path, file) - dest = Path.join(v1_path, file) - - with {:ok, content} <- File.read(source), - {:ok, updated_content} <- update_metadata_for_v1(content), - :ok <- File.write(dest, updated_content), - :ok <- File.rm(source) do - if opts[:verbose], do: Mix.shell().info(" Moved: #{file} β†’ v1/#{file}") - :ok - else - {:error, :enoent} -> {:error, "Failed to read: file not found"} - {:error, reason} when is_atom(reason) -> {:error, "File operation failed: #{reason}"} - {:error, reason} -> {:error, reason} - end - end - - defp update_metadata_for_v1(content) do - # parse_with_content always returns {:ok, metadata, body_content} - {:ok, metadata, body_content} = Metadata.parse_with_content(content) - - # Add version fields if missing - now = UtilsDate.utc_now() |> DateTime.truncate(:second) - - updated_metadata = - metadata - |> Map.put_new(:version, 1) - |> Map.put_new(:version_created_at, DateTime.to_iso8601(now)) - |> Map.put_new(:version_created_from, nil) - |> Map.put_new(:is_live, metadata[:status] == "published") - - # Serialize back to content - frontmatter = Metadata.serialize(updated_metadata) - updated_content = frontmatter <> "\n" <> body_content - - {:ok, updated_content} - end - - defp display_success_summary(stats, _opts) do - Mix.shell().info([ - :bright, - :green, - "\nβœ… Blog version migration completed!\n" - ]) - - Mix.shell().info("πŸ“Š Summary:") - Mix.shell().info(" Migrated: #{stats.migrated} posts") - Mix.shell().info(" Skipped: #{stats.skipped} posts (already versioned or empty)") - - if stats.errors > 0 do - Mix.shell().info([ - :red, - " Errors: #{stats.errors} posts" - ]) - end - - Mix.shell().info([ - :bright, - "\nπŸŽ‰ Posts are now in versioned folder structure!" - ]) - - Mix.shell().info([ - :normal, - "\nNext steps:", - "\n β€’ New posts will automatically use v1/ structure", - "\n β€’ Editing a published post will create a new version", - "\n β€’ Use the version switcher in the editor to navigate versions" - ]) + 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 index 756e46e7..4dc72ae1 100644 --- a/lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex +++ b/lib/mix/tasks/phoenix_kit.migrate_blogging_to_publishing.ex @@ -42,7 +42,6 @@ defmodule Mix.Tasks.PhoenixKit.MigrateBloggingToPublishing do use Mix.Task alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Settings @impl Mix.Task @@ -72,43 +71,10 @@ defmodule Mix.Tasks.PhoenixKit.MigrateBloggingToPublishing do Mix.shell().info("\nMigration complete!") end - defp migrate_storage_directory(dry_run, _verbose) do + defp migrate_storage_directory(_dry_run, _verbose) do Mix.shell().info("\n1. Storage Directory Migration") Mix.shell().info(String.duplicate("-", 30)) - - # Get paths - legacy_path = Storage.legacy_root_path() - new_path = Storage.new_root_path() - - legacy_exists = File.dir?(legacy_path) - new_exists = File.dir?(new_path) - - cond do - new_exists -> - Mix.shell().info("βœ“ New path already exists: #{new_path}") - Mix.shell().info(" Skipping directory rename") - - legacy_exists -> - if dry_run do - Mix.shell().info("Would rename: #{legacy_path}") - Mix.shell().info(" to: #{new_path}") - else - case File.rename(legacy_path, new_path) do - :ok -> - Mix.shell().info("βœ“ Renamed: #{legacy_path}") - Mix.shell().info(" to: #{new_path}") - - {:error, reason} -> - Mix.shell().error("βœ— Failed to rename directory: #{inspect(reason)}") - Mix.shell().error(" You may need to rename manually:") - Mix.shell().error(" mv #{legacy_path} #{new_path}") - end - end - - true -> - Mix.shell().info("βœ“ Neither path exists - fresh installation") - Mix.shell().info(" New installations will use: #{new_path}") - end + Mix.shell().info("βœ“ Filesystem storage has been removed β€” all content is in the database.") end defp migrate_settings_keys(dry_run, verbose) do diff --git a/lib/modules/blogging.ex b/lib/modules/blogging.ex index 84360c6b..3ced1920 100644 --- a/lib/modules/blogging.ex +++ b/lib/modules/blogging.ex @@ -40,12 +40,8 @@ defmodule PhoenixKit.Modules.Blogging do defdelegate add_language_to_post(blog_slug, path, new_language, opts), to: PhoenixKit.Modules.Publishing - # Version functions (delegated from Storage) + # Version functions defdelegate list_versions(blog_slug, post_slug), to: PhoenixKit.Modules.Publishing - defdelegate get_latest_version(blog_slug, post_slug), to: PhoenixKit.Modules.Publishing - - defdelegate get_latest_published_version(blog_slug, post_slug), - to: PhoenixKit.Modules.Publishing defdelegate get_live_version(blog_slug, post_slug), to: PhoenixKit.Modules.Publishing, @@ -54,10 +50,6 @@ defmodule PhoenixKit.Modules.Blogging do defdelegate get_version_status(blog_slug, post_slug, version, language), to: PhoenixKit.Modules.Publishing - defdelegate detect_post_structure(post_path), to: PhoenixKit.Modules.Publishing - defdelegate content_changed?(post, params), to: PhoenixKit.Modules.Publishing - defdelegate status_change_only?(post, params), to: PhoenixKit.Modules.Publishing - defdelegate should_create_new_version?(post, params, editing_language), to: PhoenixKit.Modules.Publishing diff --git a/lib/modules/publishing/README.md b/lib/modules/publishing/README.md index e951c42c..64036b97 100644 --- a/lib/modules/publishing/README.md +++ b/lib/modules/publishing/README.md @@ -1,6 +1,6 @@ # Publishing Module -The PhoenixKit Publishing module provides a database-backed content management system with multi-language support and dual URL modes (slug-based or timestamp-based). Posts are stored in PostgreSQL via four normalized tables (`publishing_groups`, `publishing_posts`, `publishing_versions`, `publishing_contents`) with UUIDv7 primary keys. A legacy filesystem reader remains for backward compatibility during migration from the older `.phk` file format. +The PhoenixKit Publishing module provides a database-backed content management system with multi-language support and dual URL modes (slug-based or timestamp-based). Posts are stored in PostgreSQL via four normalized tables (`publishing_groups`, `publishing_posts`, `publishing_versions`, `publishing_contents`) with UUIDv7 primary keys. ## Quick Links @@ -8,7 +8,7 @@ The PhoenixKit Publishing module provides a database-backed content management s - **Public Content**: `/{prefix}/{language}/{group-slug}` (listing) or `/{prefix}/{group-slug}` (single-language) - **Settings**: Configure via `publishing_public_enabled` and `publishing_posts_per_page` in Settings - **Enable Module**: Activate via Admin β†’ Modules or run `PhoenixKit.Modules.Publishing.enable_system/0` -- **Cache Settings**: Toggle `publishing_file_cache_enabled`, `publishing_memory_cache_enabled`, and `publishing_render_cache_enabled[_]` +- **Cache Settings**: Toggle `publishing_memory_cache_enabled` and `publishing_render_cache_enabled[_]` - **What it ships**: Listing cache, render cache, collaborative editor, public fallback routing, and optional per-post version history ## Public Content Display @@ -48,7 +48,7 @@ The publishing module includes public-facing routes for displaying published pos - **Status-Based Access Control** - Only `status: published` posts are visible - **Markdown Rendering** - GitHub-style markdown CSS with syntax highlighting - **Language Support** - Multi-language posts with language switcher -- **Content-Based Language Detection** - Custom language files (e.g., `af.phk`) work without predefinition +- **Content-Based Language Detection** - Custom language content records work without predefinition - **Flexible Fallbacks** - Missing language versions redirect to available alternatives - **Pagination** - Configurable posts per page (default: 20) - **SEO Ready** - Clean URLs, breadcrumbs, responsive design @@ -67,9 +67,9 @@ The publishing module uses a multi-step detection process to determine if a URL **Supported Language Types:** - **Predefined Languages** - Languages configured in the Languages module (e.g., `en`, `fr`, `es`) -- **Content-Based Languages** - Any `.phk` file in a post directory is treated as a valid language +- **Content-Based Languages** - Any content record with a language code is treated as a valid language -This allows custom language files like `af.phk` (Afrikaans) or `test.phk` to work correctly even if not predefined in the Languages module. In the **admin interface**, the language switcher shows these with a strikethrough to indicate they're not officially enabled. In the **public display**, only enabled languages appear in the language switcher, but custom language URLs remain accessible via direct link. +This allows custom language content records (e.g., Afrikaans `af`) to work correctly even if not predefined in the Languages module. In the **admin interface**, the language switcher shows these with a strikethrough to indicate they're not officially enabled. In the **public display**, only enabled languages appear in the language switcher, but custom language URLs remain accessible via direct link. **Single-Language Mode:** When only one language is enabled, URLs don't require the language segment: @@ -137,11 +137,10 @@ When editing a post in the admin interface: PhoenixKit ships two cache layers: -1. **Listing cache** – `PhoenixKit.Modules.Publishing.ListingCache` writes summary JSON to - `priv/publishing//.listing_cache.json` and mirrors parsed data into `:persistent_term` - for sub-microsecond reads. File vs memory caching can be toggled via the - `publishing_file_cache_enabled` / `publishing_memory_cache_enabled` settings or from the Publishing - Settings UI, which also offers regenerate/clear actions per group. +1. **Listing cache** – `PhoenixKit.Modules.Publishing.ListingCache` stores parsed listing data + in `:persistent_term` for sub-microsecond reads. Memory caching can be toggled via the + `publishing_memory_cache_enabled` setting or from the Publishing Settings UI, which also + offers regenerate/clear actions per group. 2. **Render cache** – `PhoenixKit.Modules.Publishing.Renderer` stores rendered HTML for published posts in the `:publishing_posts` cache (6-hour TTL) with content-hash keys, a global `publishing_render_cache_enabled` toggle, and per-group overrides (`publishing_render_cache_enabled_`) @@ -176,8 +175,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.Storage** – Read-only filesystem layer (legacy `.phk` file reads for pre-migration content) -- **PhoenixKit.Modules.Publishing.Metadata** – YAML frontmatter parsing and serialization (used by FS reader and import workers) +- **PhoenixKit.Modules.Publishing.Metadata** – Metadata parsing and serialization **Schemas (V59 migration):** @@ -188,7 +186,7 @@ Renderer.clear_all_cache() **Admin Interfaces:** -- **PhoenixKit.Modules.Publishing.Web.Settings** – Admin interface for group configuration and DB import +- **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.Preview** – Live preview for posts @@ -199,7 +197,7 @@ Renderer.clear_all_cache() **Rendering & Caching:** -- **PhoenixKit.Modules.Publishing.ListingCache** – File + memory listing cache +- **PhoenixKit.Modules.Publishing.ListingCache** – Memory listing cache - **PhoenixKit.Modules.Publishing.Renderer** – Markdown/PHK rendering with content-hash caching **Collaborative Editing:** @@ -211,7 +209,6 @@ Renderer.clear_all_cache() **Workers:** - **MigratePrimaryLanguageWorker** – Ensures primary language metadata consistency -- **MigrateLegacyStructureWorker** – Upgrades legacy non-versioned posts to versioned structure ## Core Features @@ -219,7 +216,7 @@ Renderer.clear_all_cache() - **Mode Immutability** – URL mode locked at group creation, cannot be changed - **Slug Mutability** – Post slugs can be changed after creation (DB update, no file movement) - **Multi-Language Support** – Separate content records per language, all stored in `publishing_contents` table -- **Database Storage** – All writes go to PostgreSQL; legacy `.phk` file reading preserved for migration +- **Database Storage** – All reads and writes go to PostgreSQL - **Markdown Content** – Full Markdown support with syntax highlighting - **JSONB Metadata** – Flexible metadata via `data` JSONB columns on all four schemas - **Backward Compatibility** – Legacy groups without mode field default to "timestamp" @@ -261,39 +258,20 @@ Posts addressed by semantic slug: **Example URL:** `/phoenix_kit/en/docs/getting-started` -## File Format (.phk files) +## Content Format -> **Note:** The `.phk` format is the legacy filesystem format. New posts are stored in the database. This section documents the format for migration purposes and for understanding the `Metadata` module. - -PhoenixKit posts use YAML frontmatter followed by Markdown content: - -```yaml ---- -slug: getting-started -status: published -published_at: 2025-01-15T09:30:00Z -created_at: 2025-01-15T09:30:00Z ---- - -# Getting Started Guide - -This is the **Markdown content** of your post. - -- Supports all standard Markdown features -- Code blocks with syntax highlighting -- Images, links, tables, etc. -``` +Post content is stored in the `publishing_contents` table as Markdown text with metadata tracked in the schema fields and `data` JSONB column. **Title Extraction:** -The post title is **extracted from the first Markdown heading** (`# Title`), not stored in frontmatter. This approach: +The post title is **extracted from the first Markdown heading** (`# Title`) in the content body. This approach: - Keeps the title visible in the content for authors -- Avoids duplication between frontmatter and content -- Makes the rendered output match the source file +- Avoids duplication between metadata and content +- Makes the rendered output match the source -**Frontmatter Fields:** +**Metadata Fields:** -- `slug` – Post slug (required, used for file path in slug mode) +- `slug` – Post slug (used for URL in slug mode) - `status` – Publication status: `draft`, `published`, or `archived` - `published_at` – Publication timestamp (ISO8601 format) - `featured_image_uuid` – Optional reference to a featured image asset @@ -309,17 +287,11 @@ The post title is **extracted from the first Markdown heading** (`# Title`), not - `updated_by_uuid` – User ID who last updated the post - `updated_by_email` – Email of user who last updated the post -**Advanced: PHK Component Format** +**PHK Component Format** -In addition to Markdown, `.phk` files can contain PHK components for structured page layouts: +In addition to Markdown, post content can contain PHK components for structured page layouts: ```html ---- -slug: landing-page -status: published -published_at: 2025-01-15T09:30:00Z ---- - # Introduction @@ -394,7 +366,7 @@ iex> {:ok, post_es} = Publishing.read_post("docs", "getting-started", "es") iex> {:ok, updated} = Publishing.update_post("docs", post, %{"content" => "# v2"}, scope: scope) ``` -- Slug-mode identifiers can include versions, e.g. `"getting-started/v2/en.phk"`. +- Slug-mode identifiers can include versions, e.g. `"getting-started"` with version number. - Timestamp-mode identifiers are `"YYYY-MM-DD/HH:MM"` paths. - `update_post/4` updates the DB record directly; slug changes are tracked via `previous_url_slugs`. @@ -420,13 +392,13 @@ iex> {:ok, cached} = Publishing.find_cached_post("docs", "getting-started") iex> {:ok, _} = Publishing.trash_post("docs", "getting-started") ``` -- Listing cache uses file + `:persistent_term` for sub-microsecond reads. +- 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. ### Group Management ```elixir -# Create group with storage mode +# Create group with URL mode {:ok, group} = Publishing.add_group("Documentation", mode: "slug") {:ok, group} = Publishing.add_group("Company News", mode: "timestamp") @@ -437,7 +409,7 @@ iex> {:ok, _} = Publishing.trash_post("docs", "getting-started") groups = Publishing.list_groups() # => [%{"name" => "Docs", "slug" => "docs", "mode" => "slug"}, ...] -# Get group storage mode +# Get group URL mode mode = Publishing.get_group_mode("docs") # => "slug" # Update group name/slug @@ -642,11 +614,7 @@ Public URLs always show the published version's content: **Version Browsing (Opt-in):** By default, only the published version is accessible. To enable public access to older -published versions, set `allow_version_access: true` in the post's metadata: - -```yaml -allow_version_access: true -``` +published versions, set `allow_version_access: true` in the post's metadata (stored in the `data` JSONB column). When enabled, versioned URLs become accessible: - `/{prefix}/{language}/{group}/{post}/v/{version}` - Direct version access @@ -706,34 +674,6 @@ post_map = Mapper.to_legacy_map(group, post, version, content, available_languag listing_map = Mapper.to_listing_map(group, post, version, primary_content) ``` -### Filesystem Storage (Read-Only β€” Legacy Migration) - -The `Storage` module provides read-only access to legacy `.phk` files for pre-migration content: - -```elixir -alias PhoenixKit.Modules.Publishing.Storage - -# Read-only operations (used by public rendering before DB migration completes) -{:ok, post} = Storage.read_post_slug_mode("docs", "hello", "en") -posts = Storage.list_posts_slug_mode("docs", "en") -posts = Storage.list_posts("news", "en") - -# Utility functions (still active) -Storage.valid_slug?("hello-world") # => true -{:ok, "hello-world"} = Storage.validate_slug("hello-world") -Storage.slug_exists?("docs", "getting-started") # => true/false -{:ok, slug} = Storage.generate_unique_slug("docs", "Getting Started") -Storage.content_changed?(post, params) # => true/false -Storage.status_change_only?(post, params) # => true/false - -# Language helpers -Storage.enabled_language_codes() # => ["en", "es", "fr"] -Storage.get_language_info("en") # => %{code: "en", name: "English", flag: "πŸ‡ΊπŸ‡Έ"} -Storage.language_enabled?("en", ["en-US", "es"]) # => true -``` - -**Note:** All write functions (`create_post`, `update_post`, `trash_post`, `delete_language`, `delete_version`, etc.) have been removed from `Storage`. Use the context layer (`Publishing.*`) which routes to `DBStorage`. - ## LiveView Interfaces ### Settings (`settings.ex`) @@ -776,11 +716,11 @@ Posts can have an optional featured image: - Click "Select Featured Image" to open the media picker - Preview displays below the image selector - Click "Clear" to remove the featured image -- Stored as `featured_image_uuid` in frontmatter +- Stored as `featured_image_uuid` in metadata **Migration Gate:** -The editor requires DB storage mode to be active. If filesystem posts exist but haven't been migrated, the editor redirects to the Publishing admin with a prompt to run the DB import. For fresh installs (no FS posts), DB mode is enabled automatically. +The editor requires the V59 database migration to be applied. The editor is available immediately on fresh installs. **Mode-Specific Behavior:** @@ -863,10 +803,10 @@ The `Mapper` module converts DB records into this map format consumed by templat %{ group: "docs", # Publishing group slug slug: "getting-started", # Slug mode only - uuid: "01234567-...", # UUIDv7 from DB (nil for FS-only posts) + uuid: "01234567-...", # UUIDv7 from DB date: ~D[2025-01-15], # Timestamp mode only time: ~T[09:30:00], # Timestamp mode only - path: "docs/getting-started/v1/en.phk", # Virtual path for compatibility + path: "docs/getting-started/v1/en", # Virtual path for compatibility metadata: %{ title: "Getting Started", status: "published", # "published", "draft", or "archived" @@ -884,12 +824,11 @@ The `Mapper` module converts DB records into this map format consumed by templat mode: :slug, # :slug or :timestamp version: 1, # Current version number available_versions: [1, 2, 3], # All versions for this post - version_statuses: %{1 => "archived", 2 => "archived", 3 => "published"}, - is_legacy_structure: false + version_statuses: %{1 => "archived", 2 => "archived", 3 => "published"} } ``` -**Note on `uuid`:** Posts read from the database have a non-nil `uuid` field. The helper `Publishing.db_post?(post)` checks this to distinguish DB posts from legacy FS posts during the migration period. +**Note on `uuid`:** All posts have a non-nil `uuid` field. The helper `Publishing.db_post?(post)` checks this. ## AI Translation @@ -910,7 +849,7 @@ When prerequisites are met, a collapsible **AI Translation** panel appears in th 3. Select an AI endpoint from the dropdown 4. Click one of the translation buttons: - **Translate All Languages** - Translates to ALL enabled languages - - **Translate Missing Only** - Only translates languages that don't have a translation file yet + - **Translate Missing Only** - Only translates languages that don't have a content record yet The translation runs as a background job. Progress can be monitored in the Oban dashboard or via logs. @@ -1020,7 +959,7 @@ Example log output: - **Partial Failures**: If some languages fail, the job reports which languages succeeded and which failed - **Retries**: Jobs retry up to 3 times with exponential backoff - **Timeout**: Jobs have a 10-minute timeout for large posts or many languages -- **Language Fallback Protection**: The worker verifies each translation is saved to the correct language file (prevents overwriting primary) +- **Language Fallback Protection**: The worker verifies each translation is saved to the correct language record (prevents overwriting primary) ### Programmatic Usage @@ -1037,7 +976,7 @@ job = TranslatePostWorker.create_job("docs", "getting-started", endpoint_id: 1) {:ok, oban_job} = TranslatePostWorker.enqueue("docs", "getting-started", endpoint_id: 1) # Translate only missing languages -missing_langs = ["de", "ja", "zh"] # Languages without translation files +missing_langs = ["de", "ja", "zh"] # Languages without content records {:ok, job} = TranslatePostWorker.enqueue("docs", "getting-started", endpoint_id: 1, target_languages: missing_langs @@ -1048,13 +987,7 @@ missing_langs = ["de", "ja", "zh"] # Languages without translation files ### Fresh Installs -New installs start with DB storage mode immediately. No filesystem content exists, so the editor is available right away. - -### Existing Installs (Filesystem β†’ Database) - -Publishing groups and posts are stored in the database. The `publishing_storage` setting -controls which storage backend is used for reads (`"filesystem"` or `"db"`). When set to `"db"`, -the admin editor becomes available and all reads use the database tables. +New installs start with database storage immediately. The editor is available right away after the V59 migration is applied. ### Existing Groups (Pre-Dual-Mode) @@ -1125,7 +1058,6 @@ Publishing.enabled?() # => true/false # Note: The inner "blogs" key is a legacy JSON key preserved for backward compatibility # Cache toggles (with legacy blogging_* fallback) -PhoenixKit.Settings.update_setting("publishing_file_cache_enabled", "true") PhoenixKit.Settings.update_setting("publishing_memory_cache_enabled", "true") # Render cache (global + per group, with legacy fallback) @@ -1136,9 +1068,9 @@ PhoenixKit.Settings.update_setting("publishing_render_cache_enabled_docs", "fals config :phoenix_kit, publishing_settings_module: MyApp.CustomSettings ``` -### Storage +### Database Tables -**Primary (DB):** All content is stored in PostgreSQL via the V59 migration tables: +All content is stored in PostgreSQL via the V59 migration tables: | Table | Purpose | |-------|---------| @@ -1147,15 +1079,11 @@ config :phoenix_kit, publishing_settings_module: MyApp.CustomSettings | `phoenix_kit_publishing_versions` | Versions (post FK, version_number, status) | | `phoenix_kit_publishing_contents` | Content/translations (version FK, language, title, content, url_slug) | -**Legacy (FS, read-only):** Pre-migration `.phk` files in `priv/publishing/` (with `priv/blogging/` fallback) are read-only. When `publishing_storage` is set to `"db"`, all reads and writes use the database and the editor becomes available. - -**Feature flag:** The `publishing_storage` setting controls the read path: -- `"filesystem"` (default) β€” Public rendering reads from FS; editor is gated/blocked -- `"db"` β€” All reads and writes use the database +All reads and writes use the database. The editor is available once the V59 migration is applied. ## Best Practices -### Choosing Storage Mode +### Choosing URL Mode **Use Timestamp Mode when:** - Content is time-sensitive (news, announcements, changelogs) @@ -1186,8 +1114,8 @@ config :phoenix_kit, publishing_settings_module: MyApp.CustomSettings 1. **Always create English first** – Establish primary content structure 2. **Use consistent slugs** – All translations share the same slug/path -3. **Translate titles** – Each language file has its own `# Title` heading -4. **Don't mix languages** – One language per `.phk` file +3. **Translate titles** – Each language content record has its own `# Title` heading +4. **Don't mix languages** – One language per content record 5. **Test translations** – Use language switcher in editor/preview ## Troubleshooting @@ -1310,7 +1238,7 @@ Mode field is read-only in settings UI. **Root Cause:** -Mode immutability is by design – storage mode is locked at group creation. +Mode immutability is by design – URL mode is locked at group creation. **Solution:** @@ -1427,14 +1355,7 @@ Each language translation can have its own SEO-friendly URL slug, enabling local **In Database:** -The `url_slug` is stored on the `publishing_contents` record. For legacy `.phk` files, it's in the YAML frontmatter: - -```yaml ---- -slug: getting-started -url_slug: primeros-pasos ---- -``` +The `url_slug` is stored on the `publishing_contents` record. **Auto-Generation:** @@ -1455,19 +1376,12 @@ URL slugs are validated before saving: ### 301 Redirects for Changed Slugs -When you change a URL slug, the old slug is automatically stored in `previous_url_slugs` for 301 redirects: - -```yaml ---- -url_slug: nuevo-slug -previous_url_slugs: antiguo-slug,otro-slug-viejo ---- -``` +When you change a URL slug, the old slug is automatically stored in `previous_url_slugs` (in the content record's `data` JSONB) for 301 redirects. **Redirect Behavior:** - Old URLs automatically 301 redirect to the new URL -- Multiple previous slugs are supported (comma-separated) -- Works even on cold starts (no cache) via filesystem fallback +- Multiple previous slugs are supported +- Works even on cold starts (no cache) via database query **Example:** ``` @@ -1518,8 +1432,8 @@ The listing cache stores per-language slug mappings for O(1) lookups: # => Returns post so you can build redirect URL # Validate URL slug before saving -{:ok, "primeros-pasos"} = Storage.validate_url_slug("docs", "primeros-pasos", "es", "getting-started") -{:error, :slug_already_exists} = Storage.validate_url_slug("docs", "existing-slug", "es", nil) +{:ok, "primeros-pasos"} = SlugHelpers.validate_url_slug("docs", "primeros-pasos", "es", "getting-started") +{:error, :slug_already_exists} = SlugHelpers.validate_url_slug("docs", "existing-slug", "es", nil) ``` ### Cold Start Fallback @@ -1528,7 +1442,6 @@ On cold starts (no cache), the system queries the database to resolve URL slugs: 1. Queries `publishing_contents` for matching `url_slug` or entries in `data->previous_url_slugs` 2. Returns redirect for previous slugs, resolution for current slugs -3. For pre-migration installs, falls back to scanning `.phk` files on the filesystem This ensures localized URLs work immediately after deployment without waiting for cache warm-up. diff --git a/lib/modules/publishing/db_storage.ex b/lib/modules/publishing/db_storage.ex index 5e628b81..5e447c3e 100644 --- a/lib/modules/publishing/db_storage.ex +++ b/lib/modules/publishing/db_storage.ex @@ -1,14 +1,9 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage do @moduledoc """ - Database storage adapter for the Publishing module. + Database storage layer for the Publishing module. - Provides CRUD operations for publishing groups, posts, versions, and contents. - Works alongside the existing filesystem `Storage` module during the transition. - - ## Usage - - This module is used by the dual-write layer (Phase 3) and becomes the primary - storage when `publishing_storage` is set to `:db`. + Provides CRUD operations for publishing groups, posts, versions, and contents + via PostgreSQL with Ecto. """ import Ecto.Query diff --git a/lib/modules/publishing/db_storage/mapper.ex b/lib/modules/publishing/db_storage/mapper.ex index 808f8635..80ccee52 100644 --- a/lib/modules/publishing/db_storage/mapper.ex +++ b/lib/modules/publishing/db_storage/mapper.ex @@ -1,15 +1,11 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.Mapper do @moduledoc """ - Transitional mapper: converts DB records to the legacy map format - expected by Publishing's web layer (LiveViews, templates, controllers). + Mapper: converts DB records to the map format expected by + Publishing's web layer (LiveViews, templates, controllers). - This exists so the web layer doesn't need changes during the transition. - Once the DB migration is complete and verified, the web layer can be - updated to use DB records directly and this mapper can be removed. + ## Map Shape - ## Legacy Map Shape - - The filesystem `Storage` module returns maps with these keys: + The web layer expects maps with these keys: - `:group` - group slug - `:slug` - post directory slug - `:url_slug` - per-language URL slug @@ -87,7 +83,6 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.Mapper do version_statuses: version_statuses, version_dates: version_dates, version_languages: version_languages, - is_legacy_structure: false, content: content.content, metadata: build_metadata(post, version, content), primary_language: post.primary_language @@ -141,7 +136,6 @@ defmodule PhoenixKit.Modules.Publishing.DBStorage.Mapper do version_statuses: version_statuses, version_dates: version_dates, version_languages: %{}, - is_legacy_structure: false, content: primary_content && extract_excerpt(primary_content), metadata: build_listing_metadata(post, primary_content), primary_language: post.primary_language, diff --git a/lib/modules/publishing/dual_write.ex b/lib/modules/publishing/dual_write.ex deleted file mode 100644 index 786863c0..00000000 --- a/lib/modules/publishing/dual_write.ex +++ /dev/null @@ -1,456 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.DualWrite do - @moduledoc """ - Dual-write layer: mirrors filesystem writes to the database. - - Every function in this module is fail-safe β€” if the DB write fails, - it logs a warning and returns `:ok`. The filesystem write (which already - succeeded) is never blocked. - - ## Usage - - Called from `publishing.ex` after each successful filesystem operation: - - case Storage.create_post(...) do - {:ok, post} -> - DualWrite.sync_post_created(group_slug, post, opts) - {:ok, post} - error -> error - end - - ## Feature Flag - - All operations check `publishing_storage` setting. If set to "filesystem" - (default), dual-write is active. If set to "db", reads come from DB and - dual-write is no longer needed (but harmless). - """ - - alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Utils.Date, as: UtilsDate - - require Logger - - @doc """ - Syncs a newly created group to the database. - """ - def sync_group_created(group_map) do - safe_write("sync_group_created", fn -> - DBStorage.upsert_group(%{ - name: group_map[:name] || group_map["name"], - slug: group_map[:slug] || group_map["slug"], - mode: to_string(group_map[:mode] || group_map["mode"] || "timestamp"), - position: group_map[:position] || group_map["position"] || 0, - data: - Map.take(group_map, [ - :type, - :item_singular, - :item_plural, - :description, - :icon, - "type", - "item_singular", - "item_plural", - "description", - "icon" - ]) - |> stringify_keys() - }) - end) - end - - @doc """ - Syncs a group update to the database. - """ - def sync_group_updated(slug, group_map) do - safe_write("sync_group_updated", fn -> - case DBStorage.get_group_by_slug(slug) do - nil -> - # Group doesn't exist in DB yet β€” create it - sync_group_created(group_map) - - group -> - DBStorage.update_group(group, %{ - name: group_map[:name] || group_map["name"] || group.name, - slug: group_map[:slug] || group_map["slug"] || group.slug, - mode: to_string(group_map[:mode] || group_map["mode"] || group.mode), - position: group_map[:position] || group_map["position"] || group.position, - data: Map.merge(group.data, extract_group_data(group_map)) - }) - end - end) - end - - @doc """ - Syncs a group deletion to the database. - """ - def sync_group_deleted(slug) do - safe_write("sync_group_deleted", fn -> - case DBStorage.get_group_by_slug(slug) do - nil -> :ok - group -> DBStorage.delete_group(group) - end - end) - end - - @doc """ - Syncs a newly created post to the database. - - Creates the post, its first version, and the initial content row. - """ - def sync_post_created(group_slug, post_map, opts \\ %{}) do - safe_write("sync_post_created", fn -> - group = DBStorage.get_group_by_slug(group_slug) - - unless group do - Logger.warning("[DualWrite] Group #{group_slug} not found in DB, skipping post sync") - throw(:skip) - end - - # Resolve user UUID for dual-write - created_by_uuid = resolve_user_uuids(opts) - - # Create the post - {:ok, db_post} = - DBStorage.create_post(%{ - group_uuid: group.uuid, - slug: post_map[:slug], - status: post_map[:metadata][:status] || "draft", - mode: to_string(post_map[:mode] || group.mode), - primary_language: post_map[:primary_language] || post_map[:language] || "en", - published_at: parse_datetime(post_map[:metadata][:published_at]), - post_date: post_map[:date], - post_time: post_map[:time], - created_by_uuid: created_by_uuid, - updated_by_uuid: created_by_uuid, - data: extract_post_data(post_map) - }) - - # Create version 1 - version_number = post_map[:version] || 1 - - {:ok, db_version} = - DBStorage.create_version(%{ - post_uuid: db_post.uuid, - version_number: version_number, - status: post_map[:metadata][:status] || "draft", - created_by_uuid: created_by_uuid - }) - - # Create content for the language - language = post_map[:language] || "en" - - DBStorage.create_content(%{ - version_uuid: db_version.uuid, - language: language, - title: post_map[:metadata][:title] || "Untitled", - content: post_map[:content], - status: post_map[:metadata][:status] || "draft", - url_slug: post_map[:url_slug], - data: extract_content_data(post_map) - }) - end) - end - - @doc """ - Syncs a post update to the database. - """ - def sync_post_updated(group_slug, post_map, _opts \\ %{}) do - safe_write("sync_post_updated", fn -> - db_post = DBStorage.get_post(group_slug, post_map[:slug]) - - unless db_post do - Logger.debug("[DualWrite] Post #{group_slug}/#{post_map[:slug]} not in DB, skipping") - throw(:skip) - end - - # Update post-level fields - DBStorage.update_post(db_post, %{ - status: post_map[:metadata][:status] || db_post.status, - published_at: parse_datetime(post_map[:metadata][:published_at]) || db_post.published_at, - post_date: post_map[:date] || db_post.post_date, - post_time: post_map[:time] || db_post.post_time, - data: Map.merge(db_post.data, extract_post_data(post_map)) - }) - - # Update content for the current version/language - version = DBStorage.get_version(db_post.uuid, post_map[:version] || 1) - - if version do - language = post_map[:language] || db_post.primary_language - - DBStorage.upsert_content(%{ - version_uuid: version.uuid, - language: language, - title: post_map[:metadata][:title] || "Untitled", - content: post_map[:content], - status: post_map[:metadata][:status] || "draft", - url_slug: post_map[:url_slug], - data: extract_content_data(post_map) - }) - end - end) - end - - @doc """ - Syncs a new version creation to the database. - """ - def sync_version_created(group_slug, post_map, opts \\ %{}) do - safe_write("sync_version_created", fn -> - db_post = DBStorage.get_post(group_slug, post_map[:slug]) - - unless db_post do - Logger.debug("[DualWrite] Post #{group_slug}/#{post_map[:slug]} not in DB, skipping") - throw(:skip) - end - - created_by_uuid = resolve_user_uuids(opts) - version_number = post_map[:version] || DBStorage.next_version_number(db_post.uuid) - - {:ok, db_version} = - DBStorage.create_version(%{ - post_uuid: db_post.uuid, - version_number: version_number, - status: "draft", - created_by_uuid: created_by_uuid, - data: %{"created_from" => opts[:source_version]} - }) - - # Create content rows for each language in the new version - languages = post_map[:available_languages] || [post_map[:language] || "en"] - - for lang <- languages do - DBStorage.create_content(%{ - version_uuid: db_version.uuid, - language: lang, - title: post_map[:metadata][:title] || "Untitled", - content: post_map[:content], - status: "draft", - url_slug: post_map[:url_slug] - }) - end - end) - end - - @doc """ - Syncs a language addition to the database. - """ - def sync_language_added(group_slug, post_slug, language_code, version_number) do - safe_write("sync_language_added", fn -> - db_post = DBStorage.get_post(group_slug, post_slug) - - unless db_post do - throw(:skip) - end - - version = DBStorage.get_version(db_post.uuid, version_number || 1) - - if version do - DBStorage.upsert_content(%{ - version_uuid: version.uuid, - language: language_code, - title: "Untitled", - content: "", - status: "draft" - }) - end - end) - end - - @doc """ - Syncs a language deletion to the database. - """ - def sync_language_deleted(group_slug, post_slug, language_code, version_number) do - safe_write("sync_language_deleted", fn -> - db_post = DBStorage.get_post(group_slug, post_slug) - - unless db_post do - throw(:skip) - end - - version = DBStorage.get_version(db_post.uuid, version_number || 1) - - if version do - content = DBStorage.get_content(version.uuid, language_code) - - if content do - DBStorage.update_content(content, %{status: "archived"}) - end - end - end) - end - - @doc """ - Syncs a version publish to the database. - """ - def sync_version_published(group_slug, post_slug, version_number) do - safe_write("sync_version_published", fn -> - db_post = DBStorage.get_post(group_slug, post_slug) - - unless db_post do - throw(:skip) - end - - # Archive all other versions, publish the target one - all_versions = DBStorage.list_versions(db_post.uuid) - Enum.each(all_versions, &publish_or_archive_version(&1, version_number)) - - # Update post status and published_at - DBStorage.update_post(db_post, %{ - status: "published", - published_at: db_post.published_at || UtilsDate.utc_now() - }) - end) - end - - @doc """ - Syncs a translation status change to the database. - """ - def sync_translation_status(group_slug, post_slug, version_number, language, status) do - safe_write("sync_translation_status", fn -> - db_post = DBStorage.get_post(group_slug, post_slug) - - unless db_post do - throw(:skip) - end - - version = DBStorage.get_version(db_post.uuid, version_number) - - if version do - content = DBStorage.get_content(version.uuid, language) - - if content do - DBStorage.update_content(content, %{status: status}) - end - end - end) - end - - @doc """ - Syncs a post deletion (trash) to the database. - """ - def sync_post_deleted(group_slug, post_slug) do - safe_write("sync_post_deleted", fn -> - db_post = DBStorage.get_post(group_slug, post_slug) - - if db_post do - DBStorage.soft_delete_post(db_post) - end - end) - end - - @doc """ - Syncs a version deletion to the database. - """ - def sync_version_deleted(group_slug, post_slug, version_number) do - safe_write("sync_version_deleted", fn -> - db_post = DBStorage.get_post(group_slug, post_slug) - - unless db_post do - throw(:skip) - end - - version = DBStorage.get_version(db_post.uuid, version_number) - - if version do - DBStorage.update_version(version, %{status: "archived"}) - end - end) - end - - @doc """ - Syncs a primary language update to the database. - """ - def sync_primary_language(group_slug, post_slug, primary_language) do - safe_write("sync_primary_language", fn -> - db_post = DBStorage.get_post(group_slug, post_slug) - - if db_post do - DBStorage.update_post(db_post, %{primary_language: primary_language}) - end - end) - end - - # =========================================================================== - # Private Helpers - # =========================================================================== - - defp publish_or_archive_version(version, target_number) - when version.version_number == target_number do - DBStorage.update_version(version, %{status: "published"}) - - for c <- DBStorage.list_contents(version.uuid) do - DBStorage.update_content(c, %{status: "published"}) - end - end - - defp publish_or_archive_version(%{status: "published"} = version, _target_number) do - DBStorage.update_version(version, %{status: "archived"}) - end - - defp publish_or_archive_version(_version, _target_number), do: :noop - - defp safe_write(operation, func) do - func.() - :ok - rescue - error -> - Logger.warning("[DualWrite] #{operation} failed: #{inspect(error)}") - :ok - catch - :skip -> :ok - end - - defp resolve_user_uuids(opts) do - opts[:user_uuid] || opts[:created_by_uuid] - end - - defp parse_datetime(nil), do: nil - defp parse_datetime(%DateTime{} = dt), do: dt - - defp parse_datetime(str) when is_binary(str) do - case DateTime.from_iso8601(str) do - {:ok, dt, _offset} -> dt - _ -> nil - end - end - - defp parse_datetime(_), do: nil - - defp extract_post_data(post_map) do - metadata = post_map[:metadata] || %{} - - %{} - |> maybe_put("allow_version_access", metadata[:allow_version_access]) - |> maybe_put("featured_image", metadata[:featured_image_uuid]) - |> maybe_put("tags", metadata[:tags]) - end - - defp extract_content_data(post_map) do - metadata = post_map[:metadata] || %{} - - %{} - |> maybe_put("description", metadata[:description]) - |> maybe_put("previous_url_slugs", metadata[:previous_url_slugs]) - |> maybe_put("featured_image_uuid", metadata[:featured_image_uuid]) - |> maybe_put("seo_title", metadata[:seo_title]) - |> maybe_put("excerpt", metadata[:excerpt]) - end - - defp extract_group_data(group_map) do - %{} - |> maybe_put("type", group_map[:type] || group_map["type"]) - |> maybe_put("item_singular", group_map[:item_singular] || group_map["item_singular"]) - |> maybe_put("item_plural", group_map[:item_plural] || group_map["item_plural"]) - |> maybe_put("description", group_map[:description] || group_map["description"]) - |> maybe_put("icon", group_map[:icon] || group_map["icon"]) - end - - defp maybe_put(map, _key, nil), do: map - defp maybe_put(map, key, value), do: Map.put(map, key, value) - - defp stringify_keys(map) when is_map(map) do - Map.new(map, fn - {k, v} when is_atom(k) -> {Atom.to_string(k), v} - {k, v} -> {k, v} - end) - end -end diff --git a/lib/modules/publishing/language_helpers.ex b/lib/modules/publishing/language_helpers.ex new file mode 100644 index 00000000..a2226da1 --- /dev/null +++ b/lib/modules/publishing/language_helpers.ex @@ -0,0 +1,210 @@ +defmodule PhoenixKit.Modules.Publishing.LanguageHelpers do + @moduledoc """ + Pure language utility functions for the Publishing module. + + Provides language detection, display ordering, language info lookup, + and primary language management. + """ + + alias PhoenixKit.Modules.Languages + alias PhoenixKit.Modules.Languages.DialectMapper + alias PhoenixKit.Settings + + @doc """ + Returns all enabled language codes for multi-language support. + Falls back to content language if Languages module is disabled. + """ + @spec enabled_language_codes() :: [String.t()] + def enabled_language_codes do + if Languages.enabled?() do + Languages.get_enabled_language_codes() + else + [Settings.get_content_language()] + end + end + + @doc """ + Returns the primary/canonical language for versioning. + Uses Settings.get_content_language(). + """ + @spec get_primary_language() :: String.t() + def get_primary_language do + Settings.get_content_language() + end + + @doc """ + Gets language details (name, flag) for a given language code. + + Searches in order: + 1. Predefined languages (BeamLabCountries) - for full locale details + 2. User-configured languages - for custom/less common languages + """ + @spec get_language_info(String.t()) :: + %{code: String.t(), name: String.t(), flag: String.t()} | nil + def get_language_info(language_code) do + find_in_predefined_languages(language_code) || + find_in_configured_languages(language_code) + end + + @doc """ + Checks if a language code is enabled, considering base code matching. + + Handles cases where: + - The code is `en` and enabled languages has `"en-US"` -> matches + - The code is `en-US` and enabled languages has `"en"` -> matches + """ + @spec language_enabled?(String.t(), [String.t()]) :: boolean() + def language_enabled?(language_code, enabled_languages) do + if language_code in enabled_languages do + true + else + base_code = DialectMapper.extract_base(language_code) + + Enum.any?(enabled_languages, fn enabled_lang -> + enabled_lang == language_code or + DialectMapper.extract_base(enabled_lang) == base_code + end) + end + end + + @doc """ + Determines the display code for a language based on whether multiple dialects + of the same base language are enabled. + + If only one dialect of a base language is enabled (e.g., just "en-US"), + returns the base code ("en") for cleaner display. + + If multiple dialects are enabled (e.g., "en-US" and "en-GB"), + returns the full dialect code ("en-US") to distinguish them. + """ + @spec get_display_code(String.t(), [String.t()]) :: String.t() + def get_display_code(language_code, enabled_languages) do + base_code = DialectMapper.extract_base(language_code) + + dialects_count = + Enum.count(enabled_languages, fn lang -> + DialectMapper.extract_base(lang) == base_code + end) + + if dialects_count > 1 do + language_code + else + base_code + end + end + + @doc """ + Orders languages for display in the language switcher. + + Order: primary language first, then languages with translations (sorted), + then languages without translations (sorted). + """ + @spec order_languages_for_display([String.t()], [String.t()], String.t() | nil) :: [String.t()] + def order_languages_for_display(available_languages, enabled_languages, primary_language \\ nil) do + primary_lang = primary_language || get_primary_language() + + langs_with_content = + available_languages + |> Enum.reject(&(&1 == primary_lang)) + |> Enum.sort() + + langs_without_content = + enabled_languages + |> Enum.reject(&(&1 in available_languages or &1 == primary_lang)) + |> Enum.sort() + + [primary_lang] ++ langs_with_content ++ langs_without_content + end + + @doc """ + Checks if a language code is reserved (cannot be used as a slug). + """ + @spec reserved_language_code?(String.t()) :: boolean() + def reserved_language_code?(slug) do + language_codes = + try do + Languages.get_language_codes() + rescue + _ -> [] + end + + slug in language_codes + end + + # =========================================================================== + # Private Helpers + # =========================================================================== + + defp find_in_predefined_languages(language_code) do + case Languages.get_available_language_by_code(language_code) do + nil -> + base_code = DialectMapper.extract_base(language_code) + is_base_code = language_code == base_code and not String.contains?(language_code, "-") + default_dialect = DialectMapper.base_to_dialect(base_code) + + case Languages.get_available_language_by_code(default_dialect) do + nil -> + all_languages = Languages.get_available_languages() + + Enum.find(all_languages, fn lang -> + DialectMapper.extract_base(lang.code) == base_code + end) + + default_match -> + if is_base_code do + %{default_match | name: extract_base_language_name(default_match.name)} + else + default_match + end + end + + exact_match -> + exact_match + end + end + + defp extract_base_language_name(name) when is_binary(name) do + case String.split(name, " (", parts: 2) do + [base_name, _region] -> base_name + [base_name] -> base_name + end + end + + defp extract_base_language_name(name), do: name + + defp find_in_configured_languages(language_code) do + configured_languages = Languages.get_languages() + + exact_match = + Enum.find(configured_languages, fn lang -> lang.code == language_code end) + + result = + if exact_match do + exact_match + else + base_code = DialectMapper.extract_base(language_code) + default_dialect = DialectMapper.base_to_dialect(base_code) + + default_match = + Enum.find(configured_languages, fn lang -> lang.code == default_dialect end) + + if default_match do + default_match + else + Enum.find(configured_languages, fn lang -> + DialectMapper.extract_base(lang.code) == base_code + end) + end + end + + if result do + %{ + code: result.code, + name: result.name || result.code, + flag: result.flag || "" + } + else + nil + end + end +end diff --git a/lib/modules/publishing/listing_cache.ex b/lib/modules/publishing/listing_cache.ex index 906dd568..d043fc86 100644 --- a/lib/modules/publishing/listing_cache.ex +++ b/lib/modules/publishing/listing_cache.ex @@ -1,27 +1,21 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do @moduledoc """ - Caches publishing group listing metadata to avoid expensive filesystem scans on every request. + Caches publishing group listing metadata in :persistent_term for sub-millisecond reads. - Instead of scanning 50+ files per request, the listing page reads a single - `.listing_cache.json` file containing all post metadata. + Instead of querying the database on every request, the listing page reads from + an in-memory cache populated from the database. ## How It Works 1. When a post is created/updated/published, `regenerate/1` is called - 2. This scans all posts and writes metadata to `.listing_cache.json` - 3. `render_group_listing` reads from cache instead of scanning filesystem + 2. This queries the database and stores post metadata in :persistent_term + 3. `render_group_listing` reads from the in-memory cache 4. Cache includes: title, slug, date, status, languages, versions (no content) - ## Cache File Location - - priv/publishing/{group-slug}/.listing_cache.json - - (With legacy fallback to `priv/blogging/{group-slug}/.listing_cache.json`) - ## Performance - - Before: ~500ms (50+ file operations) - - After: ~20ms (1 file read + JSON parse) + - Cache miss: ~20ms (DB query + store in :persistent_term) + - Cache hit: ~0.1ΞΌs (direct memory access, no variance) ## Cache Invalidation @@ -36,94 +30,62 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do For sub-millisecond performance, parsed cache data is stored in `:persistent_term`. - - First read after restart: loads from file, parses JSON, stores in :persistent_term (~2ms) + - First read after restart: queries DB, stores in :persistent_term (~20ms) - Subsequent reads: direct memory access (~0.1ΞΌs, no variance) - - On regenerate: updates both file and :persistent_term - - On invalidate: clears :persistent_term entry - - The JSON file provides persistence across restarts. :persistent_term provides - zero-copy, sub-microsecond reads during runtime. + - On regenerate: updates :persistent_term from DB + - On invalidate: clears :persistent_term entry (next read triggers regeneration) """ - alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.DBStorage + alias PhoenixKit.Modules.Publishing.LanguageHelpers alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Settings alias PhoenixKit.Utils.Date, as: UtilsDate require Logger - # Suppress dialyzer false positive for guard clause in read_from_file_and_cache - @dialyzer {:nowarn_function, read_from_file_and_cache: 3} - - @cache_filename ".listing_cache.json" @persistent_term_prefix :phoenix_kit_group_listing_cache @persistent_term_loaded_at_prefix :phoenix_kit_group_listing_cache_loaded_at - @persistent_term_file_generated_at_prefix :phoenix_kit_group_listing_cache_file_generated_at + @persistent_term_cache_generated_at_prefix :phoenix_kit_group_listing_cache_generated_at # ETS table for regeneration locks (provides atomic test-and-set via insert_new) @lock_table :phoenix_kit_listing_cache_locks - # New settings keys (write to these) - @file_cache_key "publishing_file_cache_enabled" + # Settings keys for memory cache toggle @memory_cache_key "publishing_memory_cache_enabled" - # Legacy settings keys (read from these as fallback) - @legacy_file_cache_key "blogging_file_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, is corrupt, or caching is disabled. + Returns `{:error, :cache_miss}` if cache doesn't exist or caching is disabled. - Respects the `publishing_file_cache_enabled` and `publishing_memory_cache_enabled` settings - (with fallback to legacy `blogging_*` keys). + Respects the `publishing_memory_cache_enabled` setting + (with fallback to legacy `blogging_memory_cache_enabled` key). """ @spec read(String.t()) :: {:ok, [map()]} | {:error, :cache_miss} def read(group_slug) do - memory_enabled = memory_cache_enabled?() - file_enabled = file_cache_enabled?() - term_key = persistent_term_key(group_slug) - - cond do - # Both caches disabled - not memory_enabled and not file_enabled -> - {:error, :cache_miss} + if memory_cache_enabled?() do + term_key = persistent_term_key(group_slug) - # Memory enabled and found in persistent_term - memory_enabled and memory_cache_hit?(term_key) -> - safe_persistent_term_get(term_key) + case safe_persistent_term_get(term_key) do + {:ok, _} = hit -> + hit - # Memory enabled, DB mode β€” regenerate from database on cache miss - memory_enabled and Publishing.storage_mode() == :db -> - regenerate(group_slug) - safe_persistent_term_get(term_key) + :not_found -> + # Cache miss β€” regenerate from database + regenerate(group_slug) - # Memory enabled but not found, try file - memory_enabled and file_enabled -> - read_from_file_and_cache(group_slug, term_key, true) - - # Memory enabled, file disabled, not in memory - memory_enabled -> - {:error, :cache_miss} - - # Memory disabled, file enabled - file_enabled -> - read_from_file_only(group_slug) - - # Fallback - true -> - {:error, :cache_miss} - end - end - - defp memory_cache_hit?(term_key) do - case safe_persistent_term_get(term_key) do - {:ok, _} -> true - :not_found -> false + case safe_persistent_term_get(term_key) do + {:ok, _} = hit -> hit + :not_found -> {:error, :cache_miss} + end + end + else + {:error, :cache_miss} end end @@ -134,82 +96,10 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do ArgumentError -> :not_found end - # Read from JSON file and optionally store in :persistent_term - defp read_from_file_and_cache(group_slug, term_key, store_in_memory) do - cache_path = cache_path(group_slug) - - with {:ok, content} <- read_cache_file(cache_path), - {:ok, normalized_posts, generated_at} <- parse_cache_content(content, group_slug) do - if store_in_memory do - store_posts_in_memory(group_slug, term_key, normalized_posts, generated_at) - end - - {:ok, normalized_posts} - end - end - - defp read_cache_file(cache_path) do - case File.read(cache_path) do - {:ok, content} -> {:ok, content} - {:error, :enoent} -> {:error, :cache_miss} - {:error, _reason} -> {:error, :cache_miss} - end - end - - defp parse_cache_content(content, group_slug) do - case Jason.decode(content) do - {:ok, %{"posts" => posts} = data} -> - normalized_posts = Enum.map(posts, &normalize_post/1) - generated_at = Map.get(data, "generated_at") - {:ok, normalized_posts, generated_at} - - {:ok, _} -> - Logger.warning("[ListingCache] Invalid cache format for #{group_slug}") - {:error, :cache_miss} - - {:error, reason} -> - Logger.warning( - "[ListingCache] Failed to parse cache for #{group_slug}: #{inspect(reason)}" - ) - - {:error, :cache_miss} - end - end - - defp store_posts_in_memory(group_slug, term_key, normalized_posts, generated_at) do - safe_persistent_term_put(term_key, normalized_posts) - - safe_persistent_term_put( - loaded_at_key(group_slug), - UtilsDate.utc_now() |> DateTime.to_iso8601() - ) - - if generated_at do - safe_persistent_term_put(file_generated_at_key(group_slug), generated_at) - end - - Logger.debug( - "[ListingCache] Loaded #{group_slug} from file into :persistent_term (#{length(normalized_posts)} posts)" - ) - - PublishingPubSub.broadcast_cache_changed(group_slug) - end - - # Read from JSON file only (no :persistent_term storage) - defp read_from_file_only(group_slug) do - cache_path = cache_path(group_slug) - - with {:ok, content} <- read_cache_file(cache_path), - {:ok, normalized_posts, _generated_at} <- parse_cache_content(content, group_slug) do - {:ok, normalized_posts} - end - end - @doc """ Regenerates the listing cache for a group. - Scans all posts using the standard `list_posts` function and writes - the metadata to `.listing_cache.json`. + Queries the database for all posts and stores the metadata in :persistent_term. This should be called after any post operation that changes the listing: - create_post @@ -221,14 +111,10 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do """ @spec regenerate(String.t()) :: :ok | {:error, any()} def regenerate(group_slug) do - file_enabled = file_cache_enabled?() - memory_enabled = memory_cache_enabled?() - - # If both caches are disabled, nothing to do - if not file_enabled and not memory_enabled do - :ok + if memory_cache_enabled?() do + do_regenerate(group_slug) else - do_regenerate(group_slug, file_enabled, memory_enabled) + :ok end rescue error -> @@ -239,23 +125,16 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do {:error, {:regenerate_failed, error}} end - defp do_regenerate(group_slug, _file_enabled, memory_enabled) do + defp do_regenerate(group_slug) do start_time = System.monotonic_time(:millisecond) - do_regenerate_from_db(group_slug, memory_enabled, start_time) - end - - # DB mode: fetch from database, store directly in persistent_term (no JSON file) - defp do_regenerate_from_db(group_slug, memory_enabled, start_time) do # Posts from to_listing_map are already atom-key maps with excerpts posts = DBStorage.list_posts_for_listing(group_slug) generated_at = UtilsDate.utc_now() |> DateTime.to_iso8601() - if memory_enabled do - safe_persistent_term_put(persistent_term_key(group_slug), posts) - safe_persistent_term_put(loaded_at_key(group_slug), generated_at) - safe_persistent_term_put(file_generated_at_key(group_slug), generated_at) - end + safe_persistent_term_put(persistent_term_key(group_slug), posts) + safe_persistent_term_put(loaded_at_key(group_slug), generated_at) + safe_persistent_term_put(cache_generated_at_key(group_slug), generated_at) elapsed = System.monotonic_time(:millisecond) - start_time @@ -268,7 +147,7 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do rescue error -> Logger.error( - "[ListingCache] Failed to regenerate DB cache for #{group_slug}: #{inspect(error)}" + "[ListingCache] Failed to regenerate cache for #{group_slug}: #{inspect(error)}" ) {:error, {:regenerate_failed, error}} @@ -300,8 +179,8 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do case ListingCache.regenerate_if_not_in_progress(group_slug) do :ok -> # Cache is ready, read from it - :already_in_progress -> # Fall back to filesystem scan - {:error, _} -> # Fall back to filesystem scan + :already_in_progress -> # Another process is regenerating, try again later + {:error, _} -> # Regeneration failed, query DB directly end """ @spec regenerate_if_not_in_progress(String.t()) :: :ok | :already_in_progress | {:error, any()} @@ -417,22 +296,9 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do end @doc """ - Regenerates only the file cache without loading into memory. + Loads the cache from the database into :persistent_term. - This scans all posts and writes to `.listing_cache.json` but does not - update :persistent_term. Use `load_into_memory/1` separately if needed. - """ - @spec regenerate_file_only(String.t()) :: :ok | {:error, any()} - def regenerate_file_only(_group_slug) do - # No file cache β€” DB is the persistence layer - :ok - end - - @doc """ - Loads the cache from file into :persistent_term without regenerating the file. - - Returns `:ok` if successful, `{:error, :no_file}` if file doesn't exist, - or `{:error, reason}` for other failures. + Returns `:ok` if successful or `{:error, reason}` on failure. """ @spec load_into_memory(String.t()) :: :ok | {:error, any()} def load_into_memory(group_slug) do @@ -445,7 +311,7 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do safe_persistent_term_put(persistent_term_key(group_slug), posts) safe_persistent_term_put(loaded_at_key(group_slug), generated_at) - safe_persistent_term_put(file_generated_at_key(group_slug), generated_at) + safe_persistent_term_put(cache_generated_at_key(group_slug), generated_at) Logger.debug( "[ListingCache] Loaded #{group_slug} from DB into :persistent_term (#{length(posts)} posts)" @@ -461,11 +327,10 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do end @doc """ - Invalidates (deletes) the cache for a group. + Invalidates (clears) the cache for a group. - Clears both the :persistent_term entry and the JSON file. - The next read will return `:cache_miss`, triggering a fallback to - the filesystem scan. + Clears the :persistent_term entries. The next read will trigger + a regeneration from the database. """ @spec invalidate(String.t()) :: :ok def invalidate(group_slug) do @@ -485,39 +350,23 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do end try do - :persistent_term.erase(file_generated_at_key(group_slug)) + :persistent_term.erase(cache_generated_at_key(group_slug)) rescue ArgumentError -> :ok end - # Then delete the file - cache_path = cache_path(group_slug) - - case File.rm(cache_path) do - :ok -> - Logger.debug("[ListingCache] Invalidated cache for #{group_slug}") - :ok - - {:error, :enoent} -> - :ok - - {:error, reason} -> - Logger.warning( - "[ListingCache] Failed to delete cache for #{group_slug}: #{inspect(reason)}" - ) - - :ok - end + Logger.debug("[ListingCache] Invalidated cache for #{group_slug}") + :ok end @doc """ - Checks if a cache exists for a group (in :persistent_term or file). + Checks if a cache exists for a group in :persistent_term. """ @spec exists?(String.t()) :: boolean() def exists?(group_slug) do case safe_persistent_term_get(persistent_term_key(group_slug)) do {:ok, _} -> true - :not_found -> cache_path(group_slug) |> File.exists?() + :not_found -> false end end @@ -525,7 +374,7 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do Finds a post by slug in the cache. This is useful for single post views where we need metadata (language_statuses, - version_statuses, allow_version_access) without reading multiple files. + version_statuses, allow_version_access) without a separate DB query. Returns `{:ok, cached_post}` if found, `{:error, :not_found}` otherwise. """ @@ -633,7 +482,7 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do - `url_slug` - The URL slug to find ## Returns - - `{:ok, cached_post}` - Found post (includes internal `slug` for file lookup) + - `{:ok, cached_post}` - Found post (includes internal `slug` for DB lookup) - `{:error, :not_found}` - No post with this URL slug for this language - `{:error, :cache_miss}` - Cache not available """ @@ -698,14 +547,6 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do url_slug in previous_for_lang or url_slug in metadata_previous end - @doc """ - Returns the cache file path for a publishing group. - """ - @spec cache_path(String.t()) :: String.t() - def cache_path(group_slug) do - Path.join(Storage.group_path(group_slug), @cache_filename) - end - @doc """ Returns the :persistent_term key for a publishing group's cache. """ @@ -734,38 +575,24 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do end @doc """ - Returns the :persistent_term key for tracking the file's generated_at when loaded into memory. + Returns the :persistent_term key for tracking when the cache was last generated. """ - @spec file_generated_at_key(String.t()) :: tuple() - def file_generated_at_key(group_slug) do - {@persistent_term_file_generated_at_prefix, group_slug} + @spec cache_generated_at_key(String.t()) :: tuple() + def cache_generated_at_key(group_slug) do + {@persistent_term_cache_generated_at_prefix, group_slug} end @doc """ - Returns the file's generated_at timestamp that was stored when the memory cache was loaded. - This tells us what version of the file data is currently in memory. + Returns the timestamp of when the cache was last generated from the database. """ - @spec memory_file_generated_at(String.t()) :: String.t() | nil - def memory_file_generated_at(group_slug) do - case safe_persistent_term_get(file_generated_at_key(group_slug)) do + @spec cache_generated_at(String.t()) :: String.t() | nil + def cache_generated_at(group_slug) do + case safe_persistent_term_get(cache_generated_at_key(group_slug)) do {:ok, generated_at} -> generated_at :not_found -> nil end end - @doc """ - Returns whether file caching is enabled. - Uses cached settings to avoid database queries on every call. - Checks new key first, falls back to legacy key. - """ - @spec file_cache_enabled?() :: boolean() - def file_cache_enabled? do - case Settings.get_setting_cached(@file_cache_key, nil) do - nil -> Settings.get_setting_cached(@legacy_file_cache_key, "true") == "true" - value -> value == "true" - end - end - @doc """ Returns whether memory caching (:persistent_term) is enabled. Uses cached settings to avoid database queries on every call. @@ -790,7 +617,7 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do def posts_needing_primary_language_migration(group_slug) do case read(group_slug) do {:ok, posts} -> - global_primary = Storage.get_primary_language() + global_primary = LanguageHelpers.get_primary_language() Enum.filter(posts, fn post -> # Use atom key since normalized posts use atoms @@ -799,31 +626,19 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do end) {:error, _} -> - # If cache doesn't exist, scan filesystem directly + # If cache doesn't exist, query DB directly scan_posts_needing_migration(group_slug) end end defp scan_posts_needing_migration(group_slug) do - global_primary = Storage.get_primary_language() - - # Try slug mode first, then timestamp mode (list_posts handles timestamp) - posts = Storage.list_posts_slug_mode(group_slug) - - posts = - if posts == [] do - Storage.list_posts(group_slug) - else - posts - end + global_primary = LanguageHelpers.get_primary_language() - posts + DBStorage.list_posts_for_listing(group_slug) |> Enum.filter(fn post -> - stored_primary = Map.get(post[:metadata] || %{}, :primary_language) + stored_primary = post[:primary_language] stored_primary == nil or stored_primary != global_primary end) - |> Enum.map(&serialize_post/1) - |> Enum.map(&normalize_post/1) end @doc """ @@ -838,7 +653,7 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do def count_primary_language_status(group_slug) do case read(group_slug) do {:ok, posts} -> - global_primary = Storage.get_primary_language() + global_primary = LanguageHelpers.get_primary_language() Enum.reduce(posts, %{current: 0, needs_migration: 0, needs_backfill: 0}, fn post, acc -> # Use atom key since normalized posts use atoms @@ -857,26 +672,17 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do end) {:error, _} -> - # If cache doesn't exist, scan filesystem directly + # If cache doesn't exist, query DB directly scan_primary_language_status(group_slug) end end defp scan_primary_language_status(group_slug) do - global_primary = Storage.get_primary_language() - - # Try slug mode first, then timestamp mode (list_posts handles timestamp) - posts = Storage.list_posts_slug_mode(group_slug) - - posts = - if posts == [] do - Storage.list_posts(group_slug) - else - posts - end + global_primary = LanguageHelpers.get_primary_language() - Enum.reduce(posts, %{current: 0, needs_migration: 0, needs_backfill: 0}, fn post, acc -> - stored_primary = Map.get(post[:metadata] || %{}, :primary_language) + DBStorage.list_posts_for_listing(group_slug) + |> Enum.reduce(%{current: 0, needs_migration: 0, needs_backfill: 0}, fn post, acc -> + stored_primary = post[:primary_language] cond do stored_primary == nil -> @@ -890,375 +696,4 @@ defmodule PhoenixKit.Modules.Publishing.ListingCache do end end) end - - # =========================================================================== - # Legacy Structure Migration Status - # =========================================================================== - - @doc """ - Counts posts by version structure status for a group. - - Returns `%{versioned: n, legacy: n}` where: - - `versioned` - posts with v1/, v2/, etc. structure - - `legacy` - posts with flat file structure (need migration) - """ - @spec count_legacy_structure_status(String.t()) :: map() - def count_legacy_structure_status(group_slug) do - case read(group_slug) do - {:ok, posts} -> - Enum.reduce(posts, %{versioned: 0, legacy: 0}, fn post, acc -> - if post[:is_legacy_structure] do - %{acc | legacy: acc.legacy + 1} - else - %{acc | versioned: acc.versioned + 1} - end - end) - - {:error, _} -> - # If cache doesn't exist, scan filesystem directly - scan_legacy_structure_status(group_slug) - end - end - - @doc """ - Returns list of posts that need version structure migration. - """ - @spec posts_needing_version_migration(String.t()) :: [map()] - def posts_needing_version_migration(group_slug) do - case read(group_slug) do - {:ok, posts} -> - Enum.filter(posts, & &1[:is_legacy_structure]) - - {:error, _} -> - # If cache doesn't exist, scan filesystem directly - scan_posts_needing_version_migration(group_slug) - end - end - - defp scan_legacy_structure_status(group_slug) do - posts = get_posts_for_scan(group_slug) - - Enum.reduce(posts, %{versioned: 0, legacy: 0}, fn post, acc -> - if Map.get(post, :is_legacy_structure, false) do - %{acc | legacy: acc.legacy + 1} - else - %{acc | versioned: acc.versioned + 1} - end - end) - end - - defp scan_posts_needing_version_migration(group_slug) do - posts = get_posts_for_scan(group_slug) - Enum.filter(posts, &Map.get(&1, :is_legacy_structure, false)) - end - - defp get_posts_for_scan(group_slug) do - # Try slug mode first, then timestamp mode - posts = Storage.list_posts_slug_mode(group_slug) - - if posts == [] do - Storage.list_posts(group_slug) - else - posts - end - end - - # Private functions - - defp serialize_post(post) do - # Build both current and previous slugs for all languages - {language_slugs, language_previous_slugs} = build_all_language_slugs(post) - - %{ - "uuid" => post[:uuid], - "group" => post[:group], - "slug" => post[:slug], - "url_slug" => post[:url_slug] || post[:slug], - "date" => serialize_date(post[:date]), - "time" => serialize_time(post[:time]), - "path" => post[:path], - "full_path" => post[:full_path], - "mode" => to_string(post[:mode]), - "language" => post[:language], - "available_languages" => post[:available_languages] || [], - "language_statuses" => post[:language_statuses] || %{}, - # Per-language URL slugs for SEO-friendly localized URLs - "language_slugs" => language_slugs, - # Per-language previous URL slugs for 301 redirects - "language_previous_slugs" => language_previous_slugs, - "version" => post[:version], - "available_versions" => post[:available_versions] || [], - "version_statuses" => serialize_version_statuses(post[:version_statuses]), - "version_dates" => post[:version_dates] || %{}, - "version_languages" => serialize_version_languages(post[:version_languages]), - "is_legacy_structure" => post[:is_legacy_structure] || false, - "metadata" => serialize_metadata(post[:metadata]), - # Pre-compute excerpt for listing page (avoids needing full content) - "excerpt" => extract_excerpt(post[:content], post[:metadata]), - # Primary language for this post (controls versioning/status inheritance) - # NOTE: Do NOT fall back to global setting - we need nil to detect posts needing backfill - "primary_language" => Map.get(post[:metadata] || %{}, :primary_language) - } - end - - # Build both language_slugs and language_previous_slugs maps - # Returns {language_slugs, language_previous_slugs} - # language_slugs: language -> current url_slug - # language_previous_slugs: language -> [previous_url_slugs] - defp build_all_language_slugs(post) do - current_lang = post[:language] - current_url_slug = post[:url_slug] || post[:slug] - current_previous = Map.get(post[:metadata] || %{}, :previous_url_slugs) || [] - available_langs = post[:available_languages] || [] - group_slug = post[:group] || post[:blog] - post_slug = post[:slug] - - # Start with the current language's data - base_slugs = %{current_lang => current_url_slug} - base_previous = %{current_lang => current_previous} - - # For each available language, read its url_slug and previous_url_slugs - {final_slugs, final_previous} = - Enum.reduce(available_langs, {base_slugs, base_previous}, fn lang, {slugs_acc, prev_acc} -> - if Map.has_key?(slugs_acc, lang) do - {slugs_acc, prev_acc} - else - # Read both url_slug and previous_url_slugs from this language's file - {url_slug, prev_slugs} = get_slugs_for_language(group_slug, post_slug, lang, post) - {Map.put(slugs_acc, lang, url_slug), Map.put(prev_acc, lang, prev_slugs)} - end - end) - - {final_slugs, final_previous} - end - - # Gets both url_slug and previous_url_slugs for a specific language - # Handles both slug mode and timestamp mode posts correctly - defp get_slugs_for_language(group_slug, post_slug, lang, post) do - # Use appropriate read function based on post mode - result = - case post[:mode] do - :timestamp -> - # For timestamp mode, use the path-based read - post_identifier = extract_timestamp_identifier_for_cache(post[:path]) - - if post_identifier do - path = Path.join([group_slug, post_identifier, Storage.language_filename(lang)]) - Storage.read_post(group_slug, path) - else - {:error, :invalid_path} - end - - _ -> - # For slug mode, use the slug-based read - Storage.read_post_slug_mode(group_slug, post_slug, lang, nil) - end - - case result do - {:ok, lang_post} -> - url_slug = lang_post.url_slug - previous = Map.get(lang_post.metadata, :previous_url_slugs) || [] - {url_slug, previous} - - {:error, _} -> - # File doesn't exist or can't be read - use defaults - {post_slug, []} - end - rescue - _ -> {post[:slug] || post_slug, []} - end - - # Extract timestamp identifier (date/time) from a timestamp mode path - defp extract_timestamp_identifier_for_cache(path) when is_binary(path) do - case Regex.run(~r/(\d{4}-\d{2}-\d{2}\/\d{2}:\d{2})/, path) do - [_, timestamp] -> timestamp - nil -> nil - end - end - - defp extract_timestamp_identifier_for_cache(_), do: nil - - # Extract excerpt: use description if available, otherwise extract from content - defp extract_excerpt(_content, %{description: desc}) when is_binary(desc) and desc != "", - do: desc - - defp extract_excerpt(content, _metadata) when is_binary(content) do - # Get first paragraph or content before tag - excerpt_text = - if String.contains?(content, "") do - content - |> String.split("") - |> List.first() - |> String.trim() - else - content - |> String.split(~r/\n\n+/) - |> Enum.reject(&String.starts_with?(&1, "#")) - |> List.first() - |> case do - nil -> "" - text -> String.trim(text) - end - end - - # Strip markdown formatting and limit length - excerpt_text - |> String.replace(~r/[#*_`\[\]()]/, "") - |> String.replace(~r/\s+/, " ") - |> String.trim() - |> String.slice(0, 300) - end - - defp extract_excerpt(_, _), do: nil - - defp serialize_metadata(nil), do: %{} - - defp serialize_metadata(metadata) when is_map(metadata) do - %{ - "title" => Map.get(metadata, :title), - "description" => Map.get(metadata, :description), - "slug" => Map.get(metadata, :slug), - "status" => Map.get(metadata, :status), - "published_at" => Map.get(metadata, :published_at), - "featured_image_uuid" => Map.get(metadata, :featured_image_uuid), - "version" => Map.get(metadata, :version), - "allow_version_access" => Map.get(metadata, :allow_version_access), - "url_slug" => Map.get(metadata, :url_slug), - "previous_url_slugs" => Map.get(metadata, :previous_url_slugs) - } - end - - defp serialize_date(nil), do: nil - defp serialize_date(%Date{} = date), do: Date.to_iso8601(date) - defp serialize_date(date) when is_binary(date), do: date - - defp serialize_time(nil), do: nil - defp serialize_time(%Time{} = time), do: Time.to_string(time) - defp serialize_time(time) when is_binary(time), do: time - - defp serialize_version_statuses(nil), do: %{} - - defp serialize_version_statuses(statuses) when is_map(statuses) do - # Convert integer keys to strings for JSON - Map.new(statuses, fn {k, v} -> {to_string(k), v} end) - end - - defp serialize_version_languages(nil), do: %{} - - defp serialize_version_languages(version_languages) when is_map(version_languages) do - # Convert integer keys to strings for JSON - Map.new(version_languages, fn {k, v} -> {to_string(k), v} end) - end - - defp normalize_post(post) when is_map(post) do - slug = post["slug"] - - %{ - uuid: post["uuid"], - # Support both "group" (new) and "blog" (old cache) keys - group: post["group"] || post["blog"], - slug: slug, - url_slug: post["url_slug"] || slug, - date: parse_date(post["date"]), - time: parse_time(post["time"]), - path: post["path"], - full_path: post["full_path"], - mode: parse_mode(post["mode"]), - language: post["language"], - available_languages: post["available_languages"] || [], - language_statuses: post["language_statuses"] || %{}, - # Per-language URL slugs for SEO-friendly localized URLs - language_slugs: post["language_slugs"] || %{}, - # Per-language previous URL slugs for 301 redirects - language_previous_slugs: post["language_previous_slugs"] || %{}, - version: post["version"], - available_versions: post["available_versions"] || [], - version_statuses: parse_version_statuses(post["version_statuses"]), - version_dates: post["version_dates"] || %{}, - version_languages: parse_version_languages(post["version_languages"]), - is_legacy_structure: post["is_legacy_structure"] || false, - metadata: normalize_metadata(post["metadata"]), - # Primary language for this post (controls versioning/status inheritance) - primary_language: post["primary_language"], - # Use pre-computed excerpt as content for template compatibility - # The template's extract_excerpt() will just return this text - content: post["excerpt"] - } - end - - defp normalize_metadata(nil), do: %{} - - defp normalize_metadata(metadata) when is_map(metadata) do - %{ - title: metadata["title"], - description: metadata["description"], - slug: metadata["slug"], - status: metadata["status"], - published_at: metadata["published_at"], - featured_image_uuid: metadata["featured_image_uuid"], - version: metadata["version"], - allow_version_access: metadata["allow_version_access"], - url_slug: metadata["url_slug"], - previous_url_slugs: metadata["previous_url_slugs"] - } - end - - defp parse_date(nil), do: nil - - defp parse_date(date_str) when is_binary(date_str) do - case Date.from_iso8601(date_str) do - {:ok, date} -> date - _ -> nil - end - end - - defp parse_time(nil), do: nil - - defp parse_time(time_str) when is_binary(time_str) do - case Time.from_iso8601(time_str) do - {:ok, time} -> - time - - # Try parsing without seconds (HH:MM format) - _ -> - case Time.from_iso8601(time_str <> ":00") do - {:ok, time} -> time - _ -> nil - end - end - end - - defp parse_mode("slug"), do: :slug - defp parse_mode("timestamp"), do: :timestamp - defp parse_mode(_), do: :timestamp - - defp parse_version_statuses(nil), do: %{} - - defp parse_version_statuses(statuses) when is_map(statuses) do - # Convert string keys back to integers - Map.new(statuses, fn {k, v} -> - key = - case Integer.parse(k) do - {int, ""} -> int - _ -> k - end - - {key, v} - end) - end - - defp parse_version_languages(nil), do: %{} - - defp parse_version_languages(version_languages) when is_map(version_languages) do - # Convert string keys back to integers - Map.new(version_languages, fn {k, v} -> - key = - case Integer.parse(k) do - {int, ""} -> int - _ -> k - end - - {key, v} - end) - end end diff --git a/lib/modules/publishing/metadata.ex b/lib/modules/publishing/metadata.ex index 7f8bed81..0fba3005 100644 --- a/lib/modules/publishing/metadata.ex +++ b/lib/modules/publishing/metadata.ex @@ -1,167 +1,9 @@ defmodule PhoenixKit.Modules.Publishing.Metadata do @moduledoc """ - Metadata helpers for .phk (PhoenixKit) publishing posts. + Content metadata helpers for the Publishing module. - Metadata is stored as a simple key-value format at the top of the file: - ``` - --- - slug: home - title: Welcome - status: published - published_at: 2025-10-29T18:48:00Z - --- - - Content goes here... - ``` - """ - - alias PhoenixKit.Utils.Date, as: UtilsDate - - @type metadata :: %{ - status: String.t(), - title: String.t(), - description: String.t() | nil, - slug: String.t(), - published_at: String.t(), - featured_image_uuid: String.t() | nil, - created_at: String.t() | nil, - created_by_uuid: String.t() | nil, - created_by_email: String.t() | nil, - updated_by_uuid: String.t() | nil, - updated_by_email: String.t() | nil, - # Version fields - version: integer() | nil, - version_created_at: String.t() | nil, - version_created_from: integer() | nil, - # Per-post version access control (allows public access to older versions) - allow_version_access: boolean() | nil, - # Per-language URL slug (optional, defaults to directory slug) - url_slug: String.t() | nil, - # Previous URL slugs for 301 redirects (list of old slugs) - previous_url_slugs: [String.t()] | nil, - # Primary language for this post (controls versioning/status inheritance) - # If nil, uses the global content language setting - primary_language: String.t() | nil - } - - @doc """ - Parses .phk content, extracting metadata from frontmatter and returning the content. - Title is extracted from the markdown content itself (first H1 heading). - """ - @spec parse_with_content(String.t()) :: {:ok, metadata(), String.t()} - def parse_with_content(content) do - case extract_frontmatter(content) do - {:ok, metadata, body_content} -> - # Extract title from content - title = extract_title_from_content(body_content) - metadata_with_title = Map.put(metadata, :title, title) - {:ok, metadata_with_title, body_content} - - {:error, _} -> - # Fallback: try old XML format for backwards compatibility - metadata = extract_metadata_from_xml(content) - title = extract_title_from_content(content) - metadata_with_title = Map.put(metadata, :title, title) - {:ok, metadata_with_title, content} - end - end - - @doc """ - Serializes metadata as YAML-style frontmatter. - Note: Title is NOT saved in frontmatter - it's extracted from content. - """ - @spec serialize(metadata()) :: String.t() - def serialize(metadata) do - optional_lines = - [ - :featured_image_uuid, - :created_at, - :created_by_uuid, - :created_by_email, - :updated_by_uuid, - :updated_by_email, - # Version fields (optional for backward compatibility) - :version, - :version_created_at, - :version_created_from, - :allow_version_access, - # Per-language URL slug - :url_slug, - # Primary language for this post - :primary_language - ] - |> Enum.flat_map(fn key -> - case metadata_value(metadata, key) do - nil -> [] - "" -> [] - # Handle boolean values for allow_version_access - true -> ["#{Atom.to_string(key)}: true"] - false -> ["#{Atom.to_string(key)}: false"] - value -> ["#{Atom.to_string(key)}: #{value}"] - end - end) - - # Handle previous_url_slugs as a comma-separated list - previous_slugs_line = - case metadata_value(metadata, :previous_url_slugs) do - nil -> [] - [] -> [] - slugs when is_list(slugs) -> ["previous_url_slugs: #{Enum.join(slugs, ",")}"] - _ -> [] - end - - optional_lines = optional_lines ++ previous_slugs_line - - lines = - [ - "slug: #{metadata.slug}", - "status: #{metadata.status}", - "published_at: #{metadata.published_at}" - ] - |> Enum.concat(optional_lines) - |> Enum.join("\n") - - """ - --- - #{lines} - --- - """ - end - - @doc """ - Returns default metadata for a new post. - New posts default to version 1. + Provides title extraction from markdown/component content. """ - @spec default_metadata() :: metadata() - def default_metadata do - now = UtilsDate.utc_now() - - %{ - status: "draft", - title: "", - description: nil, - slug: "", - published_at: DateTime.to_iso8601(now), - featured_image_uuid: nil, - created_at: nil, - created_by_uuid: nil, - created_by_email: nil, - updated_by_uuid: nil, - updated_by_email: nil, - # Version fields - new posts start at v1 - version: 1, - version_created_at: DateTime.to_iso8601(now), - version_created_from: nil, - # Per-post version access - defaults to false (only published version accessible) - allow_version_access: false, - # Per-language URL slug - nil means use directory slug - url_slug: nil, - # Previous URL slugs for 301 redirects - previous_url_slugs: nil, - # Primary language - nil means use global content language setting - primary_language: nil - } - end @doc """ Extracts title from markdown content. @@ -212,7 +54,6 @@ defmodule PhoenixKit.Modules.Publishing.Metadata do |> String.trim() not Enum.empty?(lines) -> - # Fallback to first non-empty line List.first(lines) |> String.slice(0, 100) @@ -316,165 +157,4 @@ defmodule PhoenixKit.Modules.Publishing.Metadata do cleaned -> String.slice(cleaned, 0, 100) end end - - # Extract metadata from YAML-style frontmatter - defp extract_frontmatter(content) do - case Regex.run(~r/^---\n(.*?)\n---\n(.*)$/s, content) do - [_, frontmatter, body] -> - metadata = parse_frontmatter_lines(frontmatter) - {:ok, metadata, String.trim(body)} - - _ -> - {:error, :no_frontmatter} - end - end - - defp parse_frontmatter_lines(frontmatter) do - default = default_metadata() - - lines = - frontmatter - |> String.split("\n") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - - metadata = - Enum.reduce(lines, %{}, fn line, acc -> - case String.split(line, ":", parts: 2) do - [key, value] -> - Map.put(acc, String.trim(key), String.trim(value)) - - _ -> - acc - end - end) - - metadata_map = %{ - # Title is extracted from content, not from frontmatter - # But we keep this for backward compatibility with old files - title: Map.get(metadata, "title", default.title), - status: Map.get(metadata, "status", default.status), - slug: Map.get(metadata, "slug", default.slug), - published_at: Map.get(metadata, "published_at", default.published_at), - featured_image_uuid: Map.get(metadata, "featured_image_uuid", default.featured_image_uuid), - description: Map.get(metadata, "description"), - created_at: Map.get(metadata, "created_at", default.created_at), - created_by_uuid: Map.get(metadata, "created_by_uuid", default.created_by_uuid), - created_by_email: Map.get(metadata, "created_by_email", default.created_by_email), - updated_by_uuid: Map.get(metadata, "updated_by_uuid", default.updated_by_uuid), - updated_by_email: Map.get(metadata, "updated_by_email", default.updated_by_email), - # Version fields - parse with defaults for backward compatibility - version: parse_integer(Map.get(metadata, "version"), default.version), - version_created_at: Map.get(metadata, "version_created_at", default.version_created_at), - version_created_from: parse_integer(Map.get(metadata, "version_created_from"), nil), - # Per-post version access control - allow_version_access: - parse_boolean(Map.get(metadata, "allow_version_access"), default.allow_version_access), - # Per-language URL slug (optional) - url_slug: parse_url_slug(Map.get(metadata, "url_slug")), - # Previous URL slugs for 301 redirects (comma-separated list) - previous_url_slugs: parse_previous_url_slugs(Map.get(metadata, "previous_url_slugs")), - # Primary language for this post - primary_language: Map.get(metadata, "primary_language") - } - - # For backward compatibility, also read legacy is_live field if present - # This allows migration to detect old posts - legacy_is_live = parse_boolean(Map.get(metadata, "is_live"), nil) - - if legacy_is_live != nil do - Map.put(metadata_map, :legacy_is_live, legacy_is_live) - else - metadata_map - end - end - - # Parse integer from string, returning default if nil or invalid - defp parse_integer(nil, default), do: default - defp parse_integer("", default), do: default - - defp parse_integer(value, default) when is_binary(value) do - case Integer.parse(value) do - {int, _} -> int - :error -> default - end - end - - defp parse_integer(value, _default) when is_integer(value), do: value - defp parse_integer(_, default), do: default - - # Parse boolean from string - defp parse_boolean(nil, default), do: default - defp parse_boolean("true", _default), do: true - defp parse_boolean("false", _default), do: false - defp parse_boolean(value, _default) when is_boolean(value), do: value - defp parse_boolean(_, default), do: default - - # Parse url_slug - returns nil if empty - defp parse_url_slug(nil), do: nil - defp parse_url_slug(""), do: nil - defp parse_url_slug(value) when is_binary(value), do: String.trim(value) - - # Parse previous_url_slugs as comma-separated list - defp parse_previous_url_slugs(nil), do: nil - defp parse_previous_url_slugs(""), do: nil - - defp parse_previous_url_slugs(value) when is_binary(value) do - value - |> String.split(",") - |> Enum.map(&String.trim/1) - |> Enum.reject(&(&1 == "")) - |> case do - [] -> nil - slugs -> slugs - end - end - - defp parse_previous_url_slugs(value) when is_list(value), do: value - - # Extract metadata from element attributes (legacy XML format) - defp extract_metadata_from_xml(content) do - default = default_metadata() - - # Simple regex-based extraction (for now) - title = extract_attribute(content, "title") || default.title - status = extract_attribute(content, "status") || default.status - slug = extract_attribute(content, "slug") || default.slug - published_at = extract_attribute(content, "published_at") || default.published_at - description = extract_attribute(content, "description") - - %{ - title: title, - status: status, - slug: slug, - published_at: published_at, - description: description, - created_at: nil, - created_by_uuid: nil, - created_by_email: nil, - updated_by_uuid: nil, - updated_by_email: nil, - # Legacy posts default to v1 (will be migrated) - version: 1, - version_created_at: nil, - version_created_from: nil, - allow_version_access: false, - url_slug: nil, - previous_url_slugs: nil, - primary_language: nil - } - end - - defp extract_attribute(content, attr_name) do - regex = ~r/]*\s#{attr_name}="([^"]*)"/ - - case Regex.run(regex, content) do - [_, value] -> value - _ -> nil - end - end - - defp metadata_value(metadata, key) do - Map.get(metadata, key) || Map.get(metadata, Atom.to_string(key)) - end end diff --git a/lib/modules/publishing/page_builder.ex b/lib/modules/publishing/page_builder.ex index 3d2204f7..eb0409c9 100644 --- a/lib/modules/publishing/page_builder.ex +++ b/lib/modules/publishing/page_builder.ex @@ -1,14 +1,13 @@ defmodule PhoenixKit.Modules.Publishing.PageBuilder do @moduledoc """ - Rendering pipeline for .phk (PhoenixKit) page files. + Rendering pipeline for PHK (PhoenixKit) page content. Processes component-based page definitions through: - 1. Read .phk file - 2. Parse XML to AST - 3. Inject dynamic data ({{variable}} placeholders) - 4. Resolve components (map to actual component modules) - 5. Apply theme/variants - 6. Render to HTML + 1. Parse XML to AST + 2. Inject dynamic data ({{variable}} placeholders) + 3. Resolve components (map to actual component modules) + 4. Apply theme/variants + 5. Render to HTML """ alias PhoenixKit.Modules.Publishing.PageBuilder.Parser @@ -19,29 +18,7 @@ defmodule PhoenixKit.Modules.Publishing.PageBuilder do @type render_result :: {:ok, Phoenix.LiveView.Rendered.t()} | {:error, term()} @doc """ - Renders a .phk page file to HTML. - - ## Examples - - iex> PageBuilder.render_page("/path/to/page.phk", %{user: %{name: "Alice"}}) - {:ok, rendered_html} - """ - @spec render_page(String.t(), assigns()) :: render_result() - def render_page(page_path, assigns \\ %{}) do - with {:ok, content} <- read_page_file(page_path), - {:ok, ast} <- parse_to_ast(content), - {:ok, ast_with_data} <- inject_dynamic_data(ast, assigns), - {:ok, resolved} <- resolve_components(ast_with_data), - {:ok, themed} <- apply_theme(resolved, assigns), - {:ok, html} <- render_to_html(themed, assigns) do - {:ok, html} - else - {:error, reason} -> {:error, reason} - end - end - - @doc """ - Renders .phk content directly (without file path). + Renders PHK content directly from a string. """ @spec render_content(String.t(), assigns()) :: render_result() def render_content(content, assigns \\ %{}) do @@ -56,15 +33,7 @@ defmodule PhoenixKit.Modules.Publishing.PageBuilder do end end - # Step 1: Read .phk file - defp read_page_file(page_path) do - case File.read(page_path) do - {:ok, content} -> {:ok, content} - {:error, reason} -> {:error, {:file_read_error, reason}} - end - end - - # Step 2: Parse XML to AST + # Step 1: Parse XML to AST defp parse_to_ast(content) do Parser.parse(content) end diff --git a/lib/modules/publishing/page_builder/parser.ex b/lib/modules/publishing/page_builder/parser.ex index 864765f1..43639033 100644 --- a/lib/modules/publishing/page_builder/parser.ex +++ b/lib/modules/publishing/page_builder/parser.ex @@ -1,6 +1,6 @@ defmodule PhoenixKit.Modules.Publishing.PageBuilder.Parser do @moduledoc """ - Parses .phk (PhoenixKit) XML-style markup into an AST. + Parses PHK (PhoenixKit) XML-style markup into an AST. Example input: ```xml @@ -34,7 +34,7 @@ defmodule PhoenixKit.Modules.Publishing.PageBuilder.Parser do """ @doc """ - Parses .phk XML content into an AST. + Parses PHK XML content into an AST. """ @spec parse(String.t()) :: {:ok, map()} | {:error, term()} def parse(content) when is_binary(content) do diff --git a/lib/modules/publishing/presence_helpers.ex b/lib/modules/publishing/presence_helpers.ex index 09ec6122..487e71cb 100644 --- a/lib/modules/publishing/presence_helpers.ex +++ b/lib/modules/publishing/presence_helpers.ex @@ -19,7 +19,7 @@ defmodule PhoenixKit.Modules.Publishing.PresenceHelpers do ## Examples - track_editing_session("blog:my-post/en.phk", socket, user) + track_editing_session("blog:my-post:en", socket, user) # => {:ok, ref} """ def track_editing_session(form_key, socket, user) do @@ -183,8 +183,8 @@ defmodule PhoenixKit.Modules.Publishing.PresenceHelpers do ## Examples - editing_topic("docs:my-post/en.phk") - # => "publishing_edit:docs:my-post/en.phk" + editing_topic("docs:my-post:en") + # => "publishing_edit:docs:my-post:en" """ def editing_topic(form_key), do: "publishing_edit:#{form_key}" end diff --git a/lib/modules/publishing/publishing.ex b/lib/modules/publishing/publishing.ex index 355eb033..0f1076c1 100644 --- a/lib/modules/publishing/publishing.ex +++ b/lib/modules/publishing/publishing.ex @@ -2,8 +2,8 @@ defmodule PhoenixKit.Modules.Publishing do @moduledoc """ Publishing module for managing content groups and their posts. - This keeps content in the filesystem while providing an admin-friendly UI - for creating timestamped or slug-based markdown posts with multi-language support. + Database-backed CMS for creating timestamped or slug-based posts + with multi-language support and versioning. """ use PhoenixKit.Module @@ -13,11 +13,11 @@ defmodule PhoenixKit.Modules.Publishing do alias PhoenixKit.Dashboard.Tab alias PhoenixKit.Modules.Languages alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Modules.Publishing.DualWrite + alias PhoenixKit.Modules.Publishing.LanguageHelpers alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.Metadata alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Storage + alias PhoenixKit.Modules.Publishing.SlugHelpers alias PhoenixKit.Users.Auth.Scope alias PhoenixKit.Utils.Date, as: UtilsDate @@ -25,47 +25,129 @@ defmodule PhoenixKit.Modules.Publishing do @dialyzer :no_match @dialyzer {:nowarn_function, create_post: 2} @dialyzer {:nowarn_function, add_language_to_post: 4} - @dialyzer {:nowarn_function, parse_version_directory: 1} - # Delegate language info function to Storage - defdelegate get_language_info(language_code), to: Storage + # Language utility delegates + defdelegate get_language_info(language_code), to: LanguageHelpers + defdelegate enabled_language_codes(), to: LanguageHelpers + defdelegate get_primary_language(), to: LanguageHelpers + defdelegate language_enabled?(language_code, enabled_languages), to: LanguageHelpers + defdelegate get_display_code(language_code, enabled_languages), to: LanguageHelpers + + defdelegate order_languages_for_display(available_languages, enabled_languages), + to: LanguageHelpers + + defdelegate order_languages_for_display(available_languages, enabled_languages, primary), + to: LanguageHelpers + + # Slug utility delegates + defdelegate validate_slug(slug), to: SlugHelpers + defdelegate slug_exists?(group_slug, post_slug), to: SlugHelpers + defdelegate generate_unique_slug(group_slug, title), to: SlugHelpers + defdelegate generate_unique_slug(group_slug, title, preferred_slug), to: SlugHelpers + defdelegate generate_unique_slug(group_slug, title, preferred_slug, opts), to: SlugHelpers + defdelegate validate_url_slug(group_slug, url_slug, language, exclude), to: SlugHelpers + + @doc "Always returns false β€” auto-versioning is disabled." + def should_create_new_version?(_post, _params, _editing_language), do: false + + @doc "Gets the primary language for a specific post from the database." + def get_post_primary_language(group_slug, post_slug, _version \\ nil) do + case DBStorage.get_post(group_slug, post_slug) do + nil -> LanguageHelpers.get_primary_language() + post -> post.primary_language || LanguageHelpers.get_primary_language() + end + rescue + _ -> LanguageHelpers.get_primary_language() + end - # Delegate version functions to Storage - defdelegate list_versions(group_slug, post_slug), to: Storage - defdelegate get_latest_version(group_slug, post_slug), to: Storage - defdelegate get_latest_published_version(group_slug, post_slug), to: Storage - defdelegate get_published_version(group_slug, post_slug), to: Storage - defdelegate get_version_status(group_slug, post_slug, version, language), to: Storage + @doc "Checks the primary language migration status for a post." + def check_primary_language_status(group_slug, post_slug) do + global_primary = LanguageHelpers.get_primary_language() - # Deprecated: Use get_published_version/2 instead - @doc false - @deprecated "Use get_published_version/2 instead" - def get_live_version(group_slug, post_slug), - do: Storage.get_published_version(group_slug, post_slug) - - defdelegate detect_post_structure(post_path), to: Storage - defdelegate content_changed?(post, params), to: Storage - defdelegate status_change_only?(post, params), to: Storage - defdelegate should_create_new_version?(post, params, editing_language), to: Storage - - # Delegate slug utilities to Storage - defdelegate validate_slug(slug), to: Storage - defdelegate slug_exists?(group_slug, post_slug), to: Storage - defdelegate generate_unique_slug(group_slug, title), to: Storage - defdelegate generate_unique_slug(group_slug, title, preferred_slug), to: Storage - defdelegate generate_unique_slug(group_slug, title, preferred_slug, opts), to: Storage - - # Delegate language utilities to Storage - defdelegate enabled_language_codes(), to: Storage - defdelegate get_primary_language(), to: Storage + case DBStorage.get_post(group_slug, post_slug) do + nil -> + {:needs_backfill, nil} - @doc false - @deprecated "Use get_primary_language/0 instead" - def get_master_language, do: get_primary_language() + %{primary_language: nil} -> + {:needs_backfill, nil} + + %{primary_language: ^global_primary} -> + {:ok, :current} + + %{primary_language: stored} -> + {:needs_migration, stored} + end + rescue + _ -> {:needs_backfill, nil} + end + + @doc "Lists version numbers for a post." + def list_versions(group_slug, post_slug) do + case DBStorage.get_post(group_slug, post_slug) do + nil -> + [] + + db_post -> + db_post.uuid + |> DBStorage.list_versions() + |> Enum.map(& &1.version_number) + end + rescue + _ -> [] + end + + @doc "Gets the published version number for a post." + def get_published_version(group_slug, post_slug) do + case DBStorage.get_post(group_slug, post_slug) do + nil -> + {:error, :not_found} + + db_post -> + db_post.uuid + |> DBStorage.list_versions() + |> Enum.find(&(&1.status == "published")) + |> case do + nil -> {:error, :no_published_version} + v -> {:ok, v.version_number} + end + end + rescue + _ -> {:error, :not_found} + end + + @doc "Gets the status of a specific version/language." + def get_version_status(group_slug, post_slug, version_number, language) do + with db_post when not is_nil(db_post) <- DBStorage.get_post(group_slug, post_slug), + db_version when not is_nil(db_version) <- + DBStorage.get_version(db_post.uuid, version_number), + content when not is_nil(content) <- DBStorage.get_content(db_version.uuid, language) do + content.status + else + _ -> "draft" + end + rescue + _ -> "draft" + end + + @doc "Counts posts on a specific date for a group." + def count_posts_on_date(group_slug, date) do + group_slug + |> list_times_on_date(date) + |> length() + end + + @doc "Lists time values for posts on a specific date." + def list_times_on_date(group_slug, date) do + date = if is_binary(date), do: Date.from_iso8601!(date), else: date - # Post-specific primary language functions - defdelegate get_post_primary_language(group_slug, post_slug, version \\ nil), to: Storage - defdelegate check_primary_language_status(group_slug, post_slug), to: Storage + group_slug + |> DBStorage.list_posts_timestamp_mode() + |> Enum.filter(&(&1.post_date == date)) + |> Enum.map(&(Time.to_string(&1.post_time) |> String.slice(0, 5))) + |> Enum.sort() + rescue + _ -> [] + end @doc """ Updates the primary language for a post. Falls back to DB for DB-only posts. @@ -98,7 +180,7 @@ defmodule PhoenixKit.Modules.Publishing do @doc """ Migrates all posts in a group to use the current global primary_language. - This updates the `primary_language` field in all .phk files and regenerates + This updates the `primary_language` field in the database and regenerates the listing cache. The migration is idempotent - running it multiple times is safe and will skip posts that are already at the current primary language. @@ -108,7 +190,7 @@ defmodule PhoenixKit.Modules.Publishing do {:ok, integer()} | {:error, any()} def migrate_posts_to_current_primary_language(group_slug) do require Logger - global_primary = Storage.get_primary_language() + global_primary = LanguageHelpers.get_primary_language() posts = ListingCache.posts_needing_primary_language_migration(group_slug) Logger.debug("[PrimaryLangMigration] Found #{length(posts)} posts needing migration") @@ -165,7 +247,7 @@ defmodule PhoenixKit.Modules.Publishing do end end - # For timestamp mode, extract date/time from path like "group/date/time/version/file.phk" + # For timestamp mode, extract date/time from path identifier defp derive_timestamp_post_dir(nil), do: nil defp derive_timestamp_post_dir(""), do: nil @@ -173,15 +255,12 @@ defmodule PhoenixKit.Modules.Publishing do parts = Path.split(path) case parts do - # Versioned: group/date/time/v1/lang.phk - [_group, date, time, "v" <> _, _lang_file] -> Path.join(date, time) - # Legacy: group/date/time/lang.phk - [_group, date, time, _lang_file] -> Path.join(date, time) + [_group, date, time | _rest] -> Path.join(date, time) _ -> nil end end - # For slug mode, extract slug from path + # For slug mode, extract slug from path identifier defp derive_slug_from_path(nil), do: nil defp derive_slug_from_path(""), do: nil @@ -189,58 +268,30 @@ defmodule PhoenixKit.Modules.Publishing do parts = Path.split(path) case parts do - # Versioned: group/slug/v1/lang.phk - [_group, slug, "v" <> _, _lang_file] -> slug - # Legacy: group/slug/lang.phk - [_group, slug, _lang_file] -> slug + [_group, slug | _rest] -> slug _ -> nil end end - # =========================================================================== - # Legacy Structure Migration - # =========================================================================== - - # Migration detection functions (via ListingCache) - defdelegate posts_needing_version_migration(group_slug), to: ListingCache - defdelegate count_legacy_structure_status(group_slug), to: ListingCache - - @doc """ - Checks if any posts in a group need version structure migration. - """ - @spec posts_need_version_migration?(String.t()) :: boolean() - def posts_need_version_migration?(group_slug) do - ListingCache.posts_needing_version_migration(group_slug) != [] - end - - @doc """ - Returns count of posts by version structure status. - """ - @spec get_legacy_structure_status(String.t()) :: map() - def get_legacy_structure_status(group_slug) do - ListingCache.count_legacy_structure_status(group_slug) - end - - @doc """ - Migrates all legacy structure posts in a group to versioned structure. - - No-op in DB-only mode β€” database posts are inherently versioned. - Kept for API compatibility with listing UI and workers. - """ - @spec migrate_posts_to_versioned_structure(String.t()) :: - {:ok, integer()} | {:error, any()} - def migrate_posts_to_versioned_structure(_group_slug) do - # DB posts are inherently versioned β€” no filesystem migration needed - {:ok, 0} + # Version metadata lookup (DB-based) + def get_version_metadata(group_slug, post_slug, version_number, language) do + with db_post when not is_nil(db_post) <- DBStorage.get_post(group_slug, post_slug), + db_version when not is_nil(db_version) <- + DBStorage.get_version(db_post.uuid, version_number), + content when not is_nil(content) <- DBStorage.get_content(db_version.uuid, language) do + %{ + status: content.status, + title: content.title, + url_slug: content.url_slug, + version: version_number + } + else + _ -> nil + end + rescue + _ -> nil end - defdelegate language_enabled?(language_code, enabled_languages), to: Storage - defdelegate get_display_code(language_code, enabled_languages), to: Storage - defdelegate order_languages_for_display(available_languages, enabled_languages), to: Storage - - # Delegate version metadata to Storage - defdelegate get_version_metadata(group_slug, post_slug, version, language), to: Storage - # Delegate cache operations to ListingCache defdelegate regenerate_cache(group_slug), to: ListingCache, as: :regenerate defdelegate invalidate_cache(group_slug), to: ListingCache, as: :invalidate @@ -252,7 +303,7 @@ defmodule PhoenixKit.Modules.Publishing do as: :find_post_by_path @doc """ - Finds a post by URL slug, checking DB or ListingCache based on storage mode. + Finds a post by URL slug from the database. """ @spec find_by_url_slug(String.t(), String.t(), String.t()) :: {:ok, map()} | {:error, :not_found | :cache_miss} @@ -292,9 +343,11 @@ defmodule PhoenixKit.Modules.Publishing do } end - # Delegate storage path functions - defdelegate legacy_group?(group_slug), to: Storage - defdelegate has_legacy_groups?(), to: Storage + @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" @@ -356,20 +409,6 @@ defmodule PhoenixKit.Modules.Publishing do settings_call(:update_boolean_setting, [@publishing_enabled_key, false]) end - @doc """ - Returns the current storage mode for publishing reads. - Always returns `:db` β€” filesystem storage has been removed. - """ - @spec storage_mode() :: :db - def storage_mode, do: :db - - @doc """ - Returns true when reads are served from the database. - Always returns true β€” filesystem storage has been removed. - """ - @spec db_storage?() :: boolean() - def db_storage?, do: true - @doc """ Returns true when the given post is a DB-backed post (has a UUID). """ @@ -390,7 +429,6 @@ defmodule PhoenixKit.Modules.Publishing do def get_config do %{ enabled: enabled?(), - storage_mode: storage_mode(), groups_count: length(list_groups()) } end @@ -401,7 +439,7 @@ defmodule PhoenixKit.Modules.Publishing do key: "publishing", label: "Publishing", icon: "hero-document-duplicate", - description: "Filesystem-based CMS pages and multi-language content" + description: "Database-backed CMS pages and multi-language content" } end @@ -561,7 +599,7 @@ defmodule PhoenixKit.Modules.Publishing do * `name` - Display name for the group * `opts` - Keyword list or map with options: - * `:mode` - Storage mode: "timestamp" or "slug" (default: "timestamp") + * `: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") * `:item_singular` - Singular name for items (default: based on type, e.g., "post") @@ -631,7 +669,6 @@ defmodule PhoenixKit.Modules.Publishing do # Always write to new key with {:ok, _} <- settings_call(:update_json_setting, [@publishing_groups_key, payload]) do - DualWrite.sync_group_created(group) PublishingPubSub.broadcast_group_created(group) {:ok, group} end @@ -665,7 +702,6 @@ defmodule PhoenixKit.Modules.Publishing do # Broadcast after successful deletion if match?({:ok, _}, result) do - DualWrite.sync_group_deleted(slug) PublishingPubSub.broadcast_group_deleted(slug) end @@ -743,7 +779,6 @@ defmodule PhoenixKit.Modules.Publishing do |> Map.put("slug", sanitized_slug) with {:ok, _} <- persist_group_update(groups, group["slug"], updated_group) do - DualWrite.sync_group_updated(group["slug"], updated_group) PublishingPubSub.broadcast_group_updated(updated_group) {:ok, updated_group} end @@ -771,7 +806,7 @@ defmodule PhoenixKit.Modules.Publishing do end @doc """ - Returns the configured storage mode for a publishing group slug. + Returns the configured post mode for a publishing group slug. """ @spec get_group_mode(String.t()) :: String.t() def get_group_mode(group_slug) do @@ -784,11 +819,9 @@ defmodule PhoenixKit.Modules.Publishing do Lists posts for a given publishing group slug. Accepts optional preferred_language to show titles in user's language. - When `publishing_storage` is `:db`, queries the database directly. - Otherwise uses the ListingCache for fast lookups, falling back to - filesystem scan on cache miss. + Queries the database directly via DBStorage. """ - @spec list_posts(String.t(), String.t() | nil) :: [Storage.post()] + @spec list_posts(String.t(), String.t() | nil) :: [map()] def list_posts(group_slug, _preferred_language \\ nil) do DBStorage.list_posts_with_metadata(group_slug) end @@ -796,20 +829,18 @@ defmodule PhoenixKit.Modules.Publishing do @doc """ Creates a new post for the given publishing group using the current timestamp. """ - @spec create_post(String.t(), map() | keyword()) :: {:ok, Storage.post()} | {:error, any()} + @spec create_post(String.t(), map() | keyword()) :: {:ok, map()} | {:error, any()} def create_post(group_slug, opts \\ %{}) do create_post_in_db(group_slug, opts) end defp create_post_in_db(group_slug, opts) do - alias PhoenixKit.Modules.Publishing.Storage.Slugs - scope = fetch_option(opts, :scope) group = DBStorage.get_group_by_slug(group_slug) unless group, do: throw({:error, :group_not_found}) mode = get_group_mode(group_slug) - primary_language = Storage.get_primary_language() + primary_language = LanguageHelpers.get_primary_language() now = UtilsDate.utc_now() # Resolve user UUID for audit @@ -821,7 +852,7 @@ defmodule PhoenixKit.Modules.Publishing do "slug" -> title = fetch_option(opts, :title) preferred_slug = fetch_option(opts, :slug) - Slugs.generate_unique_slug(group_slug, title || "", preferred_slug) + SlugHelpers.generate_unique_slug(group_slug, title || "", preferred_slug) _ -> {:ok, nil} @@ -941,10 +972,10 @@ defmodule PhoenixKit.Modules.Publishing do For slug-mode groups, accepts an optional version parameter. If version is nil, reads the latest version. - When `publishing_storage` is `:db`, reads from the database. + Reads from the database. """ @spec read_post(String.t(), String.t(), String.t() | nil, integer() | nil) :: - {:ok, Storage.post()} | {:error, any()} + {:ok, map()} | {:error, any()} def read_post(group_slug, identifier, language \\ nil, version \\ nil) do read_post_from_db(group_slug, identifier, language, version) end @@ -1002,7 +1033,7 @@ defmodule PhoenixKit.Modules.Publishing do end end - # Parses timestamp paths like "2026-01-24/04:13/v7/sq.phk" or "2026-01-24/04:13" + # Parses timestamp paths like "2026-01-24/04:13/v7/sq" or "2026-01-24/04:13" defp parse_timestamp_path(identifier) do parts = identifier @@ -1030,7 +1061,7 @@ defmodule PhoenixKit.Modules.Publishing do |> case do nil -> nil "" -> nil - lang_file -> String.replace_suffix(lang_file, ".phk", "") + lang_file -> lang_file end {:ok, date, time, version, lang} @@ -1051,7 +1082,7 @@ defmodule PhoenixKit.Modules.Publishing do end end - # Adds a language to a DB-only post (no filesystem counterpart). + # Adds a language to a post. # Creates a new content row in the database and returns the legacy map. @doc false def add_language_to_db(group_slug, post_slug, language_code, version_number) do @@ -1142,7 +1173,7 @@ defmodule PhoenixKit.Modules.Publishing do end end - # Updates a DB-only post (no filesystem counterpart). + # Updates a post in the database. # Writes directly to the database and returns the updated legacy map. defp update_post_in_db(group_slug, post, params, _audit_meta) do db_post = find_db_post_for_update(group_slug, post) @@ -1198,10 +1229,8 @@ defmodule PhoenixKit.Modules.Publishing do end defp maybe_update_db_slug(db_post, desired_slug, group_slug) do - alias PhoenixKit.Modules.Publishing.Storage.Slugs - - with {:ok, valid_slug} <- Slugs.validate_slug(desired_slug), - false <- Slugs.slug_exists?(group_slug, valid_slug), + with {:ok, valid_slug} <- SlugHelpers.validate_slug(desired_slug), + false <- SlugHelpers.slug_exists?(group_slug, valid_slug), {:ok, _} <- DBStorage.update_post(db_post, %{slug: valid_slug}) do {:ok, valid_slug} else @@ -1367,8 +1396,8 @@ defmodule PhoenixKit.Modules.Publishing do @doc """ Updates a post and moves the file if the publication timestamp changes. """ - @spec update_post(String.t(), Storage.post(), map(), map() | keyword()) :: - {:ok, Storage.post()} | {:error, any()} + @spec update_post(String.t(), map(), map(), map() | keyword()) :: + {:ok, map()} | {:error, any()} def update_post(group_slug, post, params, opts \\ %{}) do # Normalize opts to map (callers may pass keyword list or map) opts_map = if Keyword.keyword?(opts), do: Map.new(opts), else: opts @@ -1400,8 +1429,8 @@ defmodule PhoenixKit.Modules.Publishing do Note: For more control over which version to branch from, use `create_version_from/5`. """ - @spec create_new_version(String.t(), Storage.post(), map(), map() | keyword()) :: - {:ok, Storage.post()} | {:error, any()} + @spec create_new_version(String.t(), map(), map(), map() | keyword()) :: + {:ok, map()} | {:error, any()} def create_new_version(group_slug, source_post, params \\ %{}, opts \\ %{}) do source_version = source_post[:version] || 1 create_version_in_db(group_slug, source_post.slug, source_version, params, opts) @@ -1492,7 +1521,7 @@ defmodule PhoenixKit.Modules.Publishing do {:ok, %{version: 3, ...}} """ @spec create_version_from(String.t(), String.t(), integer() | nil, map(), map() | keyword()) :: - {:ok, Storage.post()} | {:error, any()} + {:ok, map()} | {:error, any()} def create_version_from(group_slug, post_slug, source_version, params \\ %{}, opts \\ %{}) do create_version_in_db(group_slug, post_slug, source_version, params, opts) end @@ -1602,7 +1631,7 @@ defmodule PhoenixKit.Modules.Publishing do the identifier path (if present) or defaults to the latest version. """ @spec add_language_to_post(String.t(), String.t(), String.t(), integer() | nil) :: - {:ok, Storage.post()} | {:error, any()} + {:ok, map()} | {:error, any()} def add_language_to_post(group_slug, identifier, language_code, version \\ nil) do post_slug = extract_slug_from_identifier(group_slug, identifier) result = add_language_to_db(group_slug, post_slug, language_code, version) @@ -2099,9 +2128,9 @@ defmodule PhoenixKit.Modules.Publishing do # Extract slug, version, and language from a path identifier # Handles paths like: # - "post-slug" β†’ {"post-slug", nil, nil} - # - "post-slug/en.phk" β†’ {"post-slug", nil, "en"} - # - "post-slug/v1/en.phk" β†’ {"post-slug", 1, "en"} - # - "group/post-slug/v2/am.phk" β†’ {"post-slug", 2, "am"} + # - "post-slug/en" β†’ {"post-slug", nil, "en"} + # - "post-slug/v1/en" β†’ {"post-slug", 1, "en"} + # - "group/post-slug/v2/am" β†’ {"post-slug", 2, "am"} @doc false def extract_slug_version_and_language(_group_slug, nil), do: {"", nil, nil} @@ -2133,7 +2162,7 @@ defmodule PhoenixKit.Modules.Publishing do |> case do nil -> nil <<>> -> nil - lang_file -> String.replace_suffix(lang_file, ".phk", "") + lang_file -> lang_file end {slug, version, language} diff --git a/lib/modules/publishing/pubsub.ex b/lib/modules/publishing/pubsub.ex index fa84337a..8e527d62 100644 --- a/lib/modules/publishing/pubsub.ex +++ b/lib/modules/publishing/pubsub.ex @@ -266,7 +266,7 @@ defmodule PhoenixKit.Modules.Publishing.PubSub do # ============================================================================ @doc """ - Broadcasts that a post was saved, so other editors can reload from disk. + Broadcasts that a post was saved, so other editors can reload. The `source` is the socket.id of the saver, so they don't reload their own save. """ @@ -560,8 +560,8 @@ defmodule PhoenixKit.Modules.Publishing.PubSub do ## Examples - generate_form_key("blog", %{path: "blog/my-post/v1/en.phk"}) - # => "blog:blog/my-post/v1/en.phk" + generate_form_key("blog", %{path: "blog/my-post/v1/en"}) + # => "blog:blog/my-post/v1/en" generate_form_key("blog", %{slug: "my-post", language: "en"}) # => "blog:my-post:en" @@ -577,7 +577,7 @@ defmodule PhoenixKit.Modules.Publishing.PubSub do "#{group_slug}:#{uuid}:#{lang}" end - # Path already includes language (e.g., "blog/my-post/v1/en.phk") + # Path already includes language (e.g., "blog/my-post/v1/en") def generate_form_key(group_slug, %{path: path}, :edit) when is_binary(path) do "#{group_slug}:#{path}" end @@ -644,38 +644,4 @@ defmodule PhoenixKit.Modules.Publishing.PubSub do primary_language} ) end - - # ============================================================================ - # Legacy Structure Migration Progress - # ============================================================================ - - @doc """ - Broadcasts that legacy structure migration has started. - """ - def broadcast_legacy_structure_migration_started(group_slug, total_count) do - Manager.broadcast( - posts_topic(group_slug), - {:legacy_structure_migration_started, group_slug, total_count} - ) - end - - @doc """ - Broadcasts legacy structure migration progress. - """ - def broadcast_legacy_structure_migration_progress(group_slug, current, total) do - Manager.broadcast( - posts_topic(group_slug), - {:legacy_structure_migration_progress, group_slug, current, total} - ) - end - - @doc """ - Broadcasts that legacy structure migration has completed. - """ - def broadcast_legacy_structure_migration_completed(group_slug, success_count, error_count) do - Manager.broadcast( - posts_topic(group_slug), - {:legacy_structure_migration_completed, group_slug, success_count, error_count} - ) - end end diff --git a/lib/modules/publishing/renderer.ex b/lib/modules/publishing/renderer.ex index 2bb355fd..979ed787 100644 --- a/lib/modules/publishing/renderer.ex +++ b/lib/modules/publishing/renderer.ex @@ -120,9 +120,9 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do def per_blog_cache_key(group_slug), do: per_group_cache_key(group_slug) @doc """ - Renders markdown or .phk content directly without caching. + Renders markdown or PHK content directly without caching. - Automatically detects .phk XML format and routes to PageBuilder. + Automatically detects PHK XML format and routes to PageBuilder. Falls back to Earmark markdown rendering for non-XML content. ## Examples @@ -151,7 +151,7 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do def render_markdown(_), do: "" - # Detect if content is pure .phk XML format (starts with or ) + # Detect if content is pure PHK XML format (starts with or ) defp pure_phk_content?(content) do trimmed = String.trim(content) String.starts_with?(trimmed, " @@ -486,11 +486,11 @@ defmodule PhoenixKit.Modules.Publishing.Renderer do end defp extract_identifier_from_path(path) when is_binary(path) do - # For timestamp mode: "blog/2025-01-15/09:30/en.phk" -> "2025-01-15/09:30" - # For slug mode: "blog/getting-started/en.phk" -> "getting-started" + # For timestamp mode: "blog/2025-01-15/09:30/v1/en" -> "2025-01-15/09:30" + # For slug mode: "blog/getting-started/v1/en" -> "getting-started" path |> String.split("/") - # Remove language.phk + # Remove language suffix |> Enum.drop(-1) # Remove blog name |> Enum.drop(1) diff --git a/lib/modules/publishing/schemas/publishing_content.ex b/lib/modules/publishing/schemas/publishing_content.ex index 9febe3f4..976cb44f 100644 --- a/lib/modules/publishing/schemas/publishing_content.ex +++ b/lib/modules/publishing/schemas/publishing_content.ex @@ -2,7 +2,7 @@ defmodule PhoenixKit.Modules.Publishing.PublishingContent do @moduledoc """ Schema for publishing content β€” one row per language per version. - This mirrors the filesystem's one-`.phk`-file-per-language model. Each content + Stores one content entry per language per version. Each content row has its own title, content body, status, and optional URL slug. ## Data JSONB Keys diff --git a/lib/modules/publishing/slug_helpers.ex b/lib/modules/publishing/slug_helpers.ex new file mode 100644 index 00000000..db221c14 --- /dev/null +++ b/lib/modules/publishing/slug_helpers.ex @@ -0,0 +1,229 @@ +defmodule PhoenixKit.Modules.Publishing.SlugHelpers do + @moduledoc """ + Slug validation and generation for the Publishing module. + + Handles slug format validation, uniqueness checking (DB-only), + URL slug validation for per-language slugs, and slug generation. + """ + + alias PhoenixKit.Modules.Publishing.DBStorage + alias PhoenixKit.Modules.Publishing.LanguageHelpers + alias PhoenixKit.Modules.Publishing.ListingCache + alias PhoenixKit.Utils.Slug + + require Logger + + @slug_pattern ~r/^[a-z0-9]+(?:-[a-z0-9]+)*$/ + + # Reserved route words that cannot be used as URL slugs + @reserved_route_words ~w(admin api assets phoenix_kit auth login logout register settings) + + @doc """ + Validates whether the given string is a valid slug format and not a reserved language code. + """ + @spec validate_slug(String.t()) :: + {:ok, String.t()} | {:error, :invalid_format | :reserved_language_code} + def validate_slug(slug) when is_binary(slug) do + cond do + not Regex.match?(@slug_pattern, slug) -> + {:error, :invalid_format} + + LanguageHelpers.reserved_language_code?(slug) -> + {:error, :reserved_language_code} + + true -> + {:ok, slug} + end + end + + @doc """ + Validates whether the given string is a slug and not a reserved language code. + """ + @spec valid_slug?(String.t()) :: boolean() + def valid_slug?(slug) when is_binary(slug) do + case validate_slug(slug) do + {:ok, _} -> true + {:error, _} -> false + end + end + + @doc """ + Validates a per-language URL slug for uniqueness within a group+language combination. + """ + @spec validate_url_slug(String.t(), String.t(), String.t(), String.t() | nil) :: + {:ok, String.t()} | {:error, atom()} + def validate_url_slug(group_slug, url_slug, language, exclude_post_slug \\ nil) do + cond do + not Regex.match?(@slug_pattern, url_slug) -> + {:error, :invalid_format} + + LanguageHelpers.reserved_language_code?(url_slug) -> + {:error, :reserved_language_code} + + 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} + + url_slug_exists?(group_slug, url_slug, language, exclude_post_slug) -> + {:error, :slug_already_exists} + + true -> + {:ok, url_slug} + end + end + + @doc """ + Checks if a slug already exists within the given publishing group (DB-only). + """ + @spec slug_exists?(String.t(), String.t()) :: boolean() + def slug_exists?(group_slug, post_slug) do + case DBStorage.get_post(group_slug, post_slug) do + nil -> false + _post -> true + end + rescue + _ -> false + end + + @doc """ + Clears custom url_slugs that conflict with a given directory slug. + """ + @spec clear_conflicting_url_slugs(String.t(), String.t()) :: [{String.t(), String.t()}] + def clear_conflicting_url_slugs(group_slug, directory_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, conflicts) + log_cleared_conflicts(conflicts, directory_slug) + conflicts + + {:error, _} -> + [] + end + end + + @doc """ + Clears a specific url_slug from all translations of a single post (DB-only). + """ + @spec clear_url_slug_from_post(String.t(), String.t(), String.t()) :: [String.t()] + def clear_url_slug_from_post(group_slug, post_slug, url_slug_to_clear) do + DBStorage.clear_url_slug_from_post(group_slug, post_slug, url_slug_to_clear) + end + + @doc """ + Generates a unique slug based on title and optional preferred slug. + """ + @spec generate_unique_slug(String.t(), String.t(), String.t() | nil, keyword()) :: + {:ok, String.t()} | {:error, :invalid_format | :reserved_language_code} + def generate_unique_slug(group_slug, title, preferred_slug \\ nil, opts \\ []) do + current_slug = Keyword.get(opts, :current_slug) + + base_slug_result = + case preferred_slug do + nil -> + {:ok, Slug.slugify(title)} + + slug when is_binary(slug) -> + sanitized = Slug.slugify(slug) + + if sanitized == "" do + {:ok, Slug.slugify(title)} + else + case validate_slug(sanitized) do + {:ok, valid_slug} -> {:ok, valid_slug} + {:error, reason} -> {:error, reason} + end + end + end + + case base_slug_result do + {:ok, base_slug} when base_slug != "" -> + {:ok, + Slug.ensure_unique(base_slug, fn candidate -> + slug_exists_for_generation?(group_slug, candidate, current_slug) + end)} + + {:ok, ""} -> + {:ok, + Slug.ensure_unique("untitled", fn candidate -> + slug_exists_for_generation?(group_slug, candidate, current_slug) + end)} + + {:error, reason} -> + {:error, reason} + end + end + + # =========================================================================== + # Private Helpers + # =========================================================================== + + defp conflicts_with_directory_slug?(group_slug, url_slug, exclude_post_slug) do + if url_slug == exclude_post_slug do + false + else + slug_exists?(group_slug, url_slug) + end + end + + defp url_slug_exists?(group_slug, url_slug, language, exclude_post_slug) do + case ListingCache.read(group_slug) do + {:ok, posts} -> + Enum.any?(posts, fn post -> + post.slug != exclude_post_slug and + post.slug != url_slug and + Map.get(post.language_slugs || %{}, language) == url_slug + end) + + {:error, _} -> + # Check via DBStorage + case DBStorage.find_by_url_slug(group_slug, language, url_slug) do + nil -> + false + + content -> + post_slug = content.version.post.slug + post_slug != exclude_post_slug and post_slug != url_slug + end + end + rescue + _ -> false + end + + defp slug_exists_for_generation?(_group_slug, candidate, current_slug) + when not is_nil(current_slug) and candidate == current_slug, + do: false + + defp slug_exists_for_generation?(group_slug, candidate, _current_slug) do + slug_exists?(group_slug, candidate) + end + + defp find_conflicting_url_slugs(posts, directory_slug) do + Enum.flat_map(posts, fn post -> + if post.slug == directory_slug do + [] + else + (post.language_slugs || %{}) + |> Enum.filter(fn {_lang, url_slug} -> url_slug == directory_slug end) + |> Enum.map(fn {lang, _} -> {post.slug, lang} end) + end + end) + end + + defp clear_url_slugs_for_conflicts(group_slug, conflicts) do + Enum.each(conflicts, fn {post_slug, _language} -> + # Clear via DB + DBStorage.clear_url_slug_from_post(group_slug, post_slug, post_slug) + end) + end + + defp log_cleared_conflicts([], _directory_slug), do: :ok + + defp log_cleared_conflicts(conflicts, directory_slug) do + Logger.warning( + "[Slugs] Cleared conflicting url_slugs for directory slug '#{directory_slug}': #{inspect(conflicts)}" + ) + end +end diff --git a/lib/modules/publishing/storage.ex b/lib/modules/publishing/storage.ex deleted file mode 100644 index 80455779..00000000 --- a/lib/modules/publishing/storage.ex +++ /dev/null @@ -1,792 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Storage do - @moduledoc """ - Filesystem storage helpers for publishing posts. - - Content is stored under: - - priv/publishing////.phk - - Where is determined by the site's content language setting. - Files use the .phk (PhoenixKit) format, which supports XML-style - component markup for building pages with swappable design variants. - - ## Submodules - - This module delegates to specialized submodules for better organization: - - - `Storage.Paths` - Path management and resolution - - `Storage.Languages` - Language operations and i18n - - `Storage.Slugs` - Slug validation and generation - - `Storage.Versions` - Version management - - `Storage.Deletion` - Trash and delete operations - - `Storage.Helpers` - Shared utilities - """ - - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Modules.Publishing.Metadata - alias PhoenixKit.Modules.Publishing.Storage.Helpers - alias PhoenixKit.Modules.Publishing.Storage.Languages - alias PhoenixKit.Modules.Publishing.Storage.Paths - alias PhoenixKit.Modules.Publishing.Storage.Slugs - alias PhoenixKit.Modules.Publishing.Storage.Versions - alias PhoenixKit.Settings - - require Logger - - # Suppress dialyzer false positives for pattern matches - @dialyzer {:nowarn_function, list_versioned_timestamp_post: 5} - @dialyzer {:nowarn_function, list_legacy_timestamp_post: 5} - - # ============================================================================ - # Type Definitions - # ============================================================================ - - @typedoc """ - A post with metadata and content. - - The `language_statuses` field is preloaded when fetching posts via `list_posts/2` - or `read_post/2` to avoid redundant file reads. It maps language codes to their - publication status (e.g., `%{"en" => "published", "es" => "draft"}`). - - Version fields: - - `version`: Current version number (1, 2, 3...) - - `available_versions`: List of all version numbers for this post - - `version_statuses`: Map of version => status for quick lookup - """ - @type post :: %{ - group: String.t() | nil, - slug: String.t() | nil, - date: Date.t() | nil, - time: Time.t() | nil, - path: String.t(), - full_path: String.t(), - metadata: map(), - content: String.t(), - language: String.t(), - available_languages: [String.t()], - language_statuses: %{String.t() => String.t() | nil}, - mode: :slug | :timestamp | nil, - version: integer() | nil, - available_versions: [integer()], - version_statuses: %{integer() => String.t()}, - version_dates: %{integer() => String.t() | nil}, - is_legacy_structure: boolean() - } - - # ============================================================================ - # Delegated Functions - Languages - # ============================================================================ - - defdelegate language_filename(language_code), to: Languages - defdelegate language_filename(), to: Languages - defdelegate enabled_language_codes(), to: Languages - defdelegate get_primary_language(), to: Languages - defdelegate get_post_primary_language(group_slug, post_slug, version \\ nil), to: Languages - defdelegate check_primary_language_status(group_slug, post_slug), to: Languages - defdelegate get_language_info(language_code), to: Languages - defdelegate language_enabled?(language_code, enabled_languages), to: Languages - defdelegate get_display_code(language_code, enabled_languages), to: Languages - defdelegate order_languages_for_display(available, enabled, primary \\ nil), to: Languages - defdelegate detect_available_languages(dir_path, primary \\ nil), to: Languages - defdelegate load_language_statuses(post_dir, available_languages), to: Languages - - # ============================================================================ - # Delegated Functions - Paths - # ============================================================================ - - defdelegate root_path(), to: Paths - defdelegate group_path(group_slug), to: Paths - defdelegate write_root_path(), to: Paths - defdelegate new_root_path(), to: Paths - defdelegate legacy_root_path(), to: Paths - defdelegate legacy_group?(group_slug), to: Paths - defdelegate has_legacy_groups?(), to: Paths - defdelegate absolute_path(relative_path), to: Paths - - # ============================================================================ - # Delegated Functions - Slugs - # ============================================================================ - - defdelegate validate_slug(slug), to: Slugs - defdelegate valid_slug?(slug), to: Slugs - defdelegate validate_url_slug(group_slug, url_slug, language, exclude \\ nil), to: Slugs - defdelegate slug_exists?(group_slug, post_slug), to: Slugs - defdelegate generate_unique_slug(group_slug, title, preferred \\ nil, opts \\ []), to: Slugs - - # ============================================================================ - # Delegated Functions - Versions - # ============================================================================ - - defdelegate detect_post_structure(post_path), to: Versions - defdelegate version_dir?(name), to: Versions - defdelegate list_versions(group_slug, post_slug), to: Versions - defdelegate parse_version_number(name), to: Versions - defdelegate get_latest_version(group_slug, post_slug), to: Versions - defdelegate get_latest_published_version(group_slug, post_slug), to: Versions - defdelegate get_published_version(group_slug, post_slug), to: Versions - defdelegate get_version_status(group_slug, post_slug, version, language), to: Versions - defdelegate get_version_metadata(group_slug, post_slug, version, language), to: Versions - defdelegate version_path(group_slug, post_slug, version), to: Versions - defdelegate load_version_statuses(group_slug, post_slug, versions, primary \\ nil), to: Versions - defdelegate load_version_dates(group_slug, post_slug, versions, primary \\ nil), to: Versions - defdelegate get_version_date(group_slug, post_slug, version, language), to: Versions - - # ============================================================================ - # Timestamp Mode Operations - # ============================================================================ - - @doc """ - Counts the number of posts on a specific date for a group. - Used to determine if time should be included in URLs. - """ - @spec count_posts_on_date(String.t(), Date.t() | String.t()) :: non_neg_integer() - def count_posts_on_date(group_slug, %Date{} = date) do - count_posts_on_date(group_slug, Date.to_iso8601(date)) - end - - def count_posts_on_date(group_slug, date_string) when is_binary(date_string) do - date_path = Path.join([Paths.group_path(group_slug), date_string]) - - if File.dir?(date_path) do - case File.ls(date_path) do - {:ok, time_folders} -> - Enum.count(time_folders, fn folder -> - String.match?(folder, ~r/^\d{2}:\d{2}$/) - end) - - {:error, _} -> - 0 - end - else - 0 - end - end - - @doc """ - Lists all time folders (posts) for a specific date in a group. - Returns a list of time strings in HH:MM format, sorted. - """ - @spec list_times_on_date(String.t(), Date.t() | String.t()) :: [String.t()] - def list_times_on_date(group_slug, %Date{} = date) do - list_times_on_date(group_slug, Date.to_iso8601(date)) - end - - def list_times_on_date(group_slug, date_string) when is_binary(date_string) do - date_path = Path.join([Paths.group_path(group_slug), date_string]) - - if File.dir?(date_path) do - case File.ls(date_path) do - {:ok, time_folders} -> - time_folders - |> Enum.filter(fn folder -> String.match?(folder, ~r/^\d{2}:\d{2}$/) end) - |> Enum.sort() - - {:error, _} -> - [] - end - else - [] - end - end - - @doc """ - Lists posts for the given group (timestamp mode). - Accepts optional preferred_language to show titles in user's language. - """ - @spec list_posts(String.t(), String.t() | nil) :: [post()] - def list_posts(group_slug, preferred_language \\ nil) do - group_root = Paths.group_path(group_slug) - - if File.dir?(group_root) do - group_root - |> File.ls!() - |> Enum.flat_map( - &posts_for_date(group_slug, &1, Path.join(group_root, &1), preferred_language) - ) - |> Enum.sort_by(&{&1.date, &1.time}, :desc) - else - [] - end - end - - defp posts_for_date(group_slug, date_folder, date_path, preferred_language) do - case Date.from_iso8601(date_folder) do - {:ok, date} -> - list_times(group_slug, date, date_path, preferred_language) - - _ -> - [] - end - end - - defp list_times(group_slug, date, date_path, preferred_language) do - case File.ls(date_path) do - {:ok, time_folders} -> - Enum.flat_map( - time_folders, - &process_time_folder(&1, group_slug, date, date_path, preferred_language) - ) - - {:error, _} -> - [] - end - end - - defp process_time_folder(time_folder, group_slug, date, date_path, preferred_language) do - time_path = Path.join(date_path, time_folder) - - case Helpers.parse_time_folder(time_folder) do - {:ok, time} -> - list_post_for_structure(group_slug, date, time, time_path, preferred_language) - - _ -> - [] - end - end - - defp list_post_for_structure(group_slug, date, time, time_path, preferred_language) do - case Versions.detect_post_structure(time_path) do - :versioned -> - list_versioned_timestamp_post(group_slug, date, time, time_path, preferred_language) - - :legacy -> - list_legacy_timestamp_post(group_slug, date, time, time_path, preferred_language) - - :empty -> - [] - end - end - - defp list_versioned_timestamp_post(group_slug, date, time, time_path, preferred_language) do - versions = list_versions_for_timestamp(time_path) - primary_language = Languages.get_primary_language() - latest_version = Enum.max(versions, fn -> 1 end) - version_dir = Path.join(time_path, "v#{latest_version}") - - available_languages = Languages.detect_available_languages(version_dir) - - if Enum.empty?(available_languages) do - [] - else - display_language = select_display_language(available_languages, preferred_language) - post_path = Path.join(version_dir, Languages.language_filename(display_language)) - - case File.read(post_path) do - {:ok, file_content} -> - case Metadata.parse_with_content(file_content) do - {:ok, metadata, content} -> - version_statuses = - load_version_statuses_timestamp(time_path, versions, primary_language) - - [ - %{ - group: group_slug, - slug: - Helpers.get_slug_with_fallback(metadata, Helpers.format_time_folder(time)), - date: date, - time: time, - path: - Helpers.relative_path_with_language_versioned( - group_slug, - date, - time, - latest_version, - display_language - ), - full_path: post_path, - metadata: metadata, - content: content, - language: display_language, - available_languages: available_languages, - language_statuses: - Languages.load_language_statuses(version_dir, available_languages), - available_versions: versions, - version_statuses: version_statuses, - version: latest_version, - is_legacy_structure: false, - mode: :timestamp, - primary_language: - Map.get(metadata, :primary_language) || Languages.get_primary_language() - } - ] - - _ -> - [] - end - - {:error, _} -> - [] - end - end - end - - defp list_legacy_timestamp_post(group_slug, date, time, time_path, preferred_language) do - available_languages = Languages.detect_available_languages(time_path) - - if Enum.empty?(available_languages) do - [] - else - display_language = select_display_language(available_languages, preferred_language) - post_path = Path.join(time_path, Languages.language_filename(display_language)) - - case File.read(post_path) do - {:ok, file_content} -> - case Metadata.parse_with_content(file_content) do - {:ok, metadata, content} -> - language_statuses = Languages.load_language_statuses(time_path, available_languages) - - [ - %{ - group: group_slug, - slug: - Helpers.get_slug_with_fallback(metadata, Helpers.format_time_folder(time)), - date: date, - time: time, - path: - Helpers.relative_path_with_language(group_slug, date, time, display_language), - full_path: post_path, - metadata: metadata, - content: content, - language: display_language, - available_languages: available_languages, - language_statuses: language_statuses, - is_legacy_structure: true, - mode: :timestamp, - primary_language: - Map.get(metadata, :primary_language) || Languages.get_primary_language() - } - ] - - _ -> - [] - end - - {:error, _} -> - [] - end - end - end - - defp select_display_language(available_languages, preferred_language) do - cond do - preferred_language && preferred_language in available_languages -> - preferred_language - - Settings.get_content_language() in available_languages -> - Settings.get_content_language() - - true -> - hd(available_languages) - end - end - - defp list_versions_for_timestamp(post_dir) do - case File.ls(post_dir) do - {:ok, entries} -> - entries - |> Enum.filter(&Versions.version_dir?/1) - |> Enum.map(fn dir -> String.replace_prefix(dir, "v", "") |> String.to_integer() end) - |> Enum.sort() - - {:error, _} -> - [] - end - end - - defp load_version_statuses_timestamp(post_dir, versions, language) do - Enum.reduce(versions, %{}, fn version, acc -> - version_dir = Path.join(post_dir, "v#{version}") - status = get_timestamp_version_status(version_dir, language) - Map.put(acc, version, status) - end) - end - - defp get_timestamp_version_status(version_dir, language) do - lang_file = Path.join(version_dir, Languages.language_filename(language)) - - if File.exists?(lang_file) do - read_status_from_file(lang_file) - else - read_status_from_any_language(version_dir) - end - end - - defp read_status_from_file(file_path) do - case File.read(file_path) do - {:ok, content} -> - {:ok, metadata, _} = Metadata.parse_with_content(content) - Map.get(metadata, :status, "draft") - - _ -> - "draft" - end - end - - defp read_status_from_any_language(version_dir) do - case File.ls(version_dir) do - {:ok, files} -> - phk_file = Enum.find(files, &String.ends_with?(&1, ".phk")) - if phk_file, do: read_status_from_file(Path.join(version_dir, phk_file)), else: "draft" - - _ -> - "draft" - end - end - - @doc """ - Reads a post for a specific language (timestamp mode). - """ - @spec read_post(String.t(), String.t()) :: {:ok, post()} | {:error, any()} - def read_post(group_slug, relative_path) do - full_path = Paths.absolute_path(relative_path) - language = Helpers.extract_language_from_path(relative_path) - - {actual_path, actual_language, is_new_translation} = - if File.exists?(full_path) do - {full_path, language, false} - else - lang_dir = Path.dirname(full_path) - - if File.dir?(lang_dir) do - case Languages.detect_available_languages(lang_dir) do - [first_lang | _] -> - fallback_path = Path.join(lang_dir, "#{first_lang}.phk") - {fallback_path, first_lang, true} - - [] -> - {full_path, language, false} - end - else - {full_path, language, false} - end - end - - with true <- File.exists?(actual_path), - {:ok, metadata, content} <- File.read!(actual_path) |> Metadata.parse_with_content(), - {:ok, {date, time}} <- Helpers.date_time_from_path(relative_path) do - lang_dir = Path.dirname(full_path) - {is_versioned, version, post_dir} = detect_version_from_path(relative_path, lang_dir) - available_languages = Languages.detect_available_languages(lang_dir) - - {available_versions, version_statuses} = - if is_versioned do - versions = list_versions_for_timestamp(post_dir) - statuses = load_version_statuses_timestamp(post_dir, versions, language) - {versions, statuses} - else - {[], %{}} - end - - language_statuses = Languages.load_language_statuses(lang_dir, available_languages) - - {final_language, final_content, final_path} = - if is_new_translation do - {language, "", relative_path} - else - {actual_language, content, relative_path} - end - - {:ok, - %{ - group: group_slug, - slug: - Helpers.get_slug_with_fallback(metadata, Path.basename(Path.dirname(relative_path))), - date: date, - time: time, - path: final_path, - full_path: full_path, - metadata: metadata, - content: final_content, - language: final_language, - available_languages: available_languages, - language_statuses: language_statuses, - mode: :timestamp, - version: version, - available_versions: available_versions, - version_statuses: version_statuses, - is_legacy_structure: not is_versioned, - is_new_translation: is_new_translation, - primary_language: - Map.get(metadata, :primary_language) || Languages.get_primary_language() - }} - else - false -> {:error, :not_found} - {:error, reason} -> {:error, reason} - end - end - - defp detect_version_from_path(relative_path, lang_dir) do - path_parts = Path.split(relative_path) - version_part = Enum.find(path_parts, &Versions.version_dir?/1) - - if version_part do - version = String.replace_prefix(version_part, "v", "") |> String.to_integer() - post_dir = Path.dirname(lang_dir) - {true, version, post_dir} - else - {false, 1, lang_dir} - end - end - - # ============================================================================ - # Slug Mode Operations - # ============================================================================ - - @doc """ - Lists slug-mode posts for the given group. - """ - @spec list_posts_slug_mode(String.t(), String.t() | nil) :: [post()] - def list_posts_slug_mode(group_slug, preferred_language \\ nil) do - group_root = Paths.group_path(group_slug) - - if File.dir?(group_root) do - group_root - |> File.ls!() - |> Enum.flat_map( - &posts_for_slug(group_slug, &1, Path.join(group_root, &1), preferred_language) - ) - |> Enum.sort_by(&published_at_sort_key(&1.metadata), {:desc, DateTime}) - else - [] - end - end - - defp posts_for_slug(group_slug, post_slug, post_path, preferred_language) do - if File.dir?(post_path) do - do_posts_for_slug(group_slug, post_slug, post_path, preferred_language) - else - [] - end - end - - defp do_posts_for_slug(group_slug, post_slug, post_path, preferred_language) do - structure = Versions.detect_post_structure(post_path) - - with {:ok, version, content_dir} <- - Versions.resolve_version_dir_for_listing(post_path, structure, group_slug, post_slug), - available_languages when available_languages != [] <- - Languages.detect_available_languages(content_dir), - {:ok, display_language} <- resolve_language(available_languages, preferred_language), - file_path <- Path.join(content_dir, Languages.language_filename(display_language)), - true <- File.exists?(file_path), - {:ok, metadata, content} <- File.read!(file_path) |> Metadata.parse_with_content() do - is_legacy = structure == :legacy - primary_language = Map.get(metadata, :primary_language) || Languages.get_primary_language() - all_versions = Versions.list_versions(group_slug, post_slug) - - version_statuses = - Versions.load_version_statuses(group_slug, post_slug, all_versions, primary_language) - - version_dates = - Versions.load_version_dates(group_slug, post_slug, all_versions, primary_language) - - version_languages = - Versions.load_version_languages(group_slug, post_slug, all_versions) - - [ - %{ - group: group_slug, - slug: post_slug, - date: nil, - time: nil, - path: - build_slug_relative_path(group_slug, post_slug, version, display_language, structure), - full_path: file_path, - metadata: metadata, - content: content, - language: display_language, - available_languages: available_languages, - language_statuses: Languages.load_language_statuses(content_dir, available_languages), - mode: :slug, - version: version, - available_versions: all_versions, - version_statuses: version_statuses, - version_dates: version_dates, - version_languages: version_languages, - is_legacy_structure: is_legacy, - primary_language: primary_language - } - ] - else - _ -> [] - end - end - - defp build_slug_relative_path(group_slug, post_slug, version, display_language, :versioned) do - Path.join([ - group_slug, - post_slug, - "v#{version}", - Languages.language_filename(display_language) - ]) - end - - defp build_slug_relative_path(group_slug, post_slug, _version, display_language, :legacy) do - Path.join([group_slug, post_slug, Languages.language_filename(display_language)]) - end - - defp resolve_language(available_languages, preferred_language) do - code = - cond do - preferred_language && preferred_language in available_languages -> - preferred_language - - preferred_language && base_code?(preferred_language) -> - find_dialect_for_base(available_languages, preferred_language) || - select_display_language(available_languages, preferred_language) - - true -> - select_display_language(available_languages, preferred_language) - end - - {:ok, code} - end - - defp base_code?(code) when is_binary(code) do - String.length(code) == 2 and not String.contains?(code, "-") - end - - defp base_code?(_), do: false - - defp find_dialect_for_base(available_languages, base_code) do - base_lower = String.downcase(base_code) - - Enum.find(available_languages, fn lang -> - DialectMapper.extract_base(lang) == base_lower - end) - end - - defp published_at_sort_key(%{published_at: nil}) do - ~U[1970-01-01 00:00:00Z] - end - - defp published_at_sort_key(%{published_at: published_at}) do - case DateTime.from_iso8601(published_at) do - {:ok, datetime, _} -> datetime - _ -> ~U[1970-01-01 00:00:00Z] - end - end - - @doc """ - Reads a slug-mode post. - """ - @spec read_post_slug_mode(String.t(), String.t(), String.t() | nil, integer() | nil) :: - {:ok, post()} | {:error, any()} - def read_post_slug_mode(group_slug, post_slug, language \\ nil, version \\ nil) do - language = language || Languages.get_primary_language() - post_path = Path.join([Paths.group_path(group_slug), post_slug]) - structure = Versions.detect_post_structure(post_path) - - with {:ok, target_version, version_dir} <- - Versions.resolve_version_dir(post_path, structure, version, group_slug, post_slug), - available_languages when available_languages != [] <- - Languages.detect_available_languages(version_dir), - display_language <- select_language_or_fallback(language, available_languages), - file_path <- Path.join(version_dir, Languages.language_filename(display_language)), - true <- File.exists?(file_path), - {:ok, metadata, content} <- File.read!(file_path) |> Metadata.parse_with_content() do - is_legacy = structure == :legacy - is_new_translation = display_language != language - primary_language = Map.get(metadata, :primary_language) || Languages.get_primary_language() - all_versions = Versions.list_versions(group_slug, post_slug) - - version_statuses = - Versions.load_version_statuses(group_slug, post_slug, all_versions, primary_language) - - version_dates = - Versions.load_version_dates(group_slug, post_slug, all_versions, primary_language) - - {:ok, - %{ - group: group_slug, - slug: post_slug, - date: nil, - time: nil, - path: - build_slug_relative_path( - group_slug, - post_slug, - target_version, - display_language, - structure - ), - full_path: file_path, - metadata: metadata, - content: if(is_new_translation, do: "", else: content), - language: if(is_new_translation, do: language, else: display_language), - available_languages: available_languages, - language_statuses: Languages.load_language_statuses(version_dir, available_languages), - mode: :slug, - version: target_version, - available_versions: all_versions, - version_statuses: version_statuses, - version_dates: version_dates, - is_legacy_structure: is_legacy, - is_new_translation: is_new_translation, - primary_language: primary_language - }} - else - _ -> {:error, :not_found} - end - end - - defp select_language_or_fallback(language, available_languages) do - if language in available_languages do - language - else - hd(available_languages) - end - end - - # ============================================================================ - # Utility Functions - # ============================================================================ - - # ============================================================================ - # Utility Functions - # ============================================================================ - - @doc """ - Checks if the content or title has changed. - """ - @spec content_changed?(post(), map()) :: boolean() - def content_changed?(post, params) do - current_content = post.content || "" - new_content = Map.get(params, "content", current_content) - - current_title = Map.get(post.metadata, :title, "") - new_title = Map.get(params, "title", current_title) - - String.trim(current_content) != String.trim(new_content) or - String.trim(current_title) != String.trim(new_title) - end - - @doc """ - Checks if only the status is being changed (no content or title changes). - Status-only changes don't require a new version. - """ - @spec status_change_only?(post(), map()) :: boolean() - def status_change_only?(post, params) do - current_status = Map.get(post.metadata, :status, "draft") - new_status = Map.get(params, "status", current_status) - status_changing? = current_status != new_status - - content_changing? = content_changed?(post, params) - - current_image = Map.get(post.metadata, :featured_image_uuid) - new_image = Helpers.resolve_featured_image_uuid(params, post.metadata) - image_changing? = current_image != new_image - - status_changing? and not content_changing? and not image_changing? - end - - @doc """ - Checks whether a new version should be created based on changes. - Currently always returns false - users create new versions explicitly. - """ - @spec should_create_new_version?(post(), map(), String.t()) :: boolean() - def should_create_new_version?(_post, _params, _editing_language) do - # Auto-version creation disabled - users create new versions explicitly - false - end -end diff --git a/lib/modules/publishing/storage/deletion.ex b/lib/modules/publishing/storage/deletion.ex deleted file mode 100644 index 8de8228e..00000000 --- a/lib/modules/publishing/storage/deletion.ex +++ /dev/null @@ -1,250 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Storage.Deletion do - @moduledoc """ - Deletion and trash operations for publishing storage. - - Handles moving posts to trash, deleting language files, - and deleting versions. - """ - - alias PhoenixKit.Modules.Publishing.Storage.Paths - alias PhoenixKit.Modules.Publishing.Storage.Versions - alias PhoenixKit.Utils.Date, as: UtilsDate - - @doc """ - Moves a post to the trash folder. - - For slug-mode groups, moves the entire post directory (all versions and languages). - For timestamp-mode groups, moves the time folder. - - The post directory is moved to: - priv/publishing/trash//-/ - (or priv/blogging/trash/... for legacy groups) - - Returns {:ok, trash_path} on success or {:error, reason} on failure. - """ - @spec trash_post(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()} - def trash_post(group_slug, post_identifier) do - post_dir = resolve_post_directory(group_slug, post_identifier) - - if File.dir?(post_dir) do - trash_dir = Path.join([Paths.root_path(), "trash", group_slug]) - File.mkdir_p!(trash_dir) - - timestamp = - UtilsDate.utc_now() - |> Calendar.strftime("%Y-%m-%d-%H-%M-%S") - - sanitized_id = sanitize_for_trash(post_identifier) - new_name = "#{sanitized_id}-#{timestamp}" - destination = Path.join(trash_dir, new_name) - - case File.rename(post_dir, destination) do - :ok -> - Paths.cleanup_empty_dirs(Path.dirname(post_dir)) - {:ok, "trash/#{group_slug}/#{new_name}"} - - {:error, reason} -> - {:error, reason} - end - else - {:error, :not_found} - end - end - - @doc """ - Moves a language file to trash (legacy operation, now just deletes). - """ - @spec trash_language(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()} - def trash_language(group_slug, relative_path) do - full_path = Paths.absolute_path(relative_path) - - if File.exists?(full_path) do - trash_dir = Path.join([Paths.root_path(), "trash", group_slug]) - File.mkdir_p!(trash_dir) - - timestamp = - UtilsDate.utc_now() - |> Calendar.strftime("%Y-%m-%d-%H-%M-%S") - - new_name = "#{Path.basename(relative_path)}-#{timestamp}" - destination = Path.join(trash_dir, new_name) - - case File.rename(full_path, destination) do - :ok -> {:ok, "trash/#{new_name}"} - {:error, reason} -> {:error, reason} - end - else - {:error, :not_found} - end - end - - @doc """ - Deletes a specific language file from a post. - - For versioned posts, specify the version. For legacy posts, version is ignored. - Refuses to delete the last remaining language file. - - Returns :ok on success or {:error, reason} on failure. - """ - @spec delete_language(String.t(), String.t(), String.t(), integer() | nil) :: - :ok | {:error, term()} - def delete_language(group_slug, post_identifier, language_code, version \\ nil) do - post_dir = resolve_post_directory(group_slug, post_identifier) - - if File.dir?(post_dir) do - structure = Versions.detect_post_structure(post_dir) - do_delete_language(post_dir, structure, language_code, version, group_slug, post_identifier) - else - {:error, :post_not_found} - end - end - - defp do_delete_language( - post_dir, - :versioned, - language_code, - version, - group_slug, - post_identifier - ) do - target_version = version || get_latest_version_number(group_slug, post_identifier) - - case target_version do - nil -> - {:error, :version_not_found} - - v -> - version_dir = Path.join(post_dir, "v#{v}") - delete_language_from_directory(version_dir, language_code) - end - end - - defp do_delete_language(post_dir, :legacy, language_code, _version, _group_slug, _post_id) do - delete_language_from_directory(post_dir, language_code) - end - - defp do_delete_language(_post_dir, :empty, _language_code, _version, _group_slug, _post_id) do - {:error, :post_not_found} - end - - defp delete_language_from_directory(dir, language_code) do - file_path = Path.join(dir, "#{language_code}.phk") - - cond do - not File.exists?(file_path) -> - {:error, :language_not_found} - - last_language_file?(dir) -> - {:error, :cannot_delete_last_language} - - true -> - File.rm(file_path) - end - end - - defp last_language_file?(dir) do - case File.ls(dir) do - {:ok, files} -> - phk_count = Enum.count(files, &String.ends_with?(&1, ".phk")) - phk_count <= 1 - - {:error, _} -> - true - end - end - - defp get_latest_version_number(group_slug, post_identifier) do - case Versions.get_latest_version(group_slug, post_identifier) do - {:ok, v} -> v - _ -> nil - end - end - - @doc """ - Deletes an entire version of a post. - - Moves the version folder to trash instead of permanent deletion. - Refuses to delete the last remaining version or the published version. - - Returns :ok on success or {:error, reason} on failure. - """ - @spec delete_version(String.t(), String.t(), integer()) :: :ok | {:error, term()} - def delete_version(group_slug, post_identifier, version) do - post_dir = resolve_post_directory(group_slug, post_identifier) - - if File.dir?(post_dir) do - structure = Versions.detect_post_structure(post_dir) - - case structure do - :versioned -> - do_delete_version(post_dir, group_slug, post_identifier, version) - - :legacy -> - {:error, :not_versioned} - - :empty -> - {:error, :post_not_found} - end - else - {:error, :post_not_found} - end - end - - defp do_delete_version(post_dir, group_slug, post_identifier, version) do - version_dir = Path.join(post_dir, "v#{version}") - - cond do - not File.dir?(version_dir) -> - {:error, :version_not_found} - - Versions.version_is_published?(group_slug, post_identifier, version) -> - {:error, :cannot_delete_published_version} - - Versions.only_version?(post_dir) -> - {:error, :cannot_delete_last_version} - - true -> - trash_dir = Path.join([Paths.root_path(), "trash", group_slug, post_identifier]) - File.mkdir_p!(trash_dir) - - timestamp = - UtilsDate.utc_now() - |> Calendar.strftime("%Y-%m-%d-%H-%M-%S") - - destination = Path.join(trash_dir, "v#{version}-#{timestamp}") - - case File.rename(version_dir, destination) do - :ok -> :ok - {:error, reason} -> {:error, reason} - end - end - end - - # ============================================================================ - # Private Helpers - # ============================================================================ - - # Resolve the post directory path based on the identifier format - defp resolve_post_directory(group_slug, post_identifier) do - if String.contains?(post_identifier, "/") do - parts = String.split(post_identifier, "/", trim: true) - - case parts do - [date, time | _] when byte_size(date) == 10 and byte_size(time) >= 4 -> - Path.join([Paths.group_path(group_slug), date, time]) - - [slug | _] -> - Path.join([Paths.group_path(group_slug), slug]) - end - else - Path.join([Paths.group_path(group_slug), post_identifier]) - end - end - - # Sanitize identifier for use in trash folder name - defp sanitize_for_trash(identifier) do - identifier - |> String.replace("/", "_") - |> String.replace(":", "-") - end -end diff --git a/lib/modules/publishing/storage/helpers.ex b/lib/modules/publishing/storage/helpers.ex deleted file mode 100644 index 0bf24798..00000000 --- a/lib/modules/publishing/storage/helpers.ex +++ /dev/null @@ -1,354 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Storage.Helpers do - @moduledoc """ - Shared helper functions for publishing storage. - - Contains audit metadata helpers, path utilities, and other - common functionality used across storage modules. - """ - - alias PhoenixKit.Modules.Publishing.Metadata - alias PhoenixKit.Modules.Publishing.Storage.Languages - - # ============================================================================ - # Audit Metadata Helpers - # ============================================================================ - - @doc """ - Applies creation audit metadata to a metadata map. - Sets both created_by and updated_by fields. - """ - @spec apply_creation_audit_metadata(map(), map()) :: map() - def apply_creation_audit_metadata(metadata, audit_meta) do - created_uuid = audit_value(audit_meta, :created_by_uuid) - created_email = audit_value(audit_meta, :created_by_email) - updated_uuid = audit_value(audit_meta, :updated_by_uuid) || created_uuid - updated_email = audit_value(audit_meta, :updated_by_email) || created_email - - metadata - |> maybe_put_audit_field(:created_by_uuid, created_uuid) - |> maybe_put_audit_field(:created_by_email, created_email) - |> maybe_put_audit_field(:updated_by_uuid, updated_uuid) - |> maybe_put_audit_field(:updated_by_email, updated_email) - end - - @doc """ - Applies update audit metadata to a metadata map. - Only sets updated_by fields. - """ - @spec apply_update_audit_metadata(map(), map()) :: map() - def apply_update_audit_metadata(metadata, audit_meta) do - metadata - |> maybe_put_audit_field(:updated_by_uuid, audit_value(audit_meta, :updated_by_uuid)) - |> maybe_put_audit_field(:updated_by_email, audit_value(audit_meta, :updated_by_email)) - end - - defp audit_value(audit_meta, key) do - audit_meta - |> Map.get(key) - |> case do - nil -> Map.get(audit_meta, Atom.to_string(key)) - value -> value - end - |> normalize_audit_value() - end - - defp maybe_put_audit_field(metadata, _key, nil), do: metadata - - defp maybe_put_audit_field(metadata, key, value) do - Map.put(metadata, key, value) - end - - defp normalize_audit_value(nil), do: nil - - defp normalize_audit_value(value) when is_binary(value) do - trimmed = String.trim(value) - if trimmed == "", do: nil, else: trimmed - end - - defp normalize_audit_value(value), do: to_string(value) - - # ============================================================================ - # Metadata Helpers - # ============================================================================ - - @doc """ - Gets a value from metadata, checking both atom and string keys. - """ - @spec metadata_value(map(), atom(), any()) :: any() - def metadata_value(metadata, key, fallback \\ nil) do - Map.get(metadata, key) || - Map.get(metadata, Atom.to_string(key)) || - fallback - end - - @doc """ - Resolves featured_image_uuid from params or existing metadata. - """ - @spec resolve_featured_image_uuid(map(), map()) :: String.t() | nil - def resolve_featured_image_uuid(params, metadata) do - case Map.fetch(params, "featured_image_uuid") do - {:ok, value} -> normalize_featured_image_uuid(value) - :error -> metadata_value(metadata, :featured_image_uuid) - end - end - - defp normalize_featured_image_uuid(value) when is_binary(value) do - value - |> String.trim() - |> case do - "" -> nil - trimmed -> trimmed - end - end - - defp normalize_featured_image_uuid(_), do: nil - - @doc """ - Resolves url_slug from params or existing metadata. - Empty string clears the custom slug. - """ - @spec resolve_url_slug(map(), map()) :: String.t() | nil - def resolve_url_slug(params, metadata) do - case Map.get(params, "url_slug") do - nil -> Map.get(metadata, :url_slug) - "" -> nil - slug when is_binary(slug) -> String.trim(slug) - _ -> Map.get(metadata, :url_slug) - end - end - - @doc """ - Resolves previous_url_slugs, tracking old slugs for 301 redirects. - When url_slug changes, the old value is added to previous_url_slugs. - """ - @spec resolve_previous_url_slugs(map(), map()) :: [String.t()] - def resolve_previous_url_slugs(params, metadata) do - current_slugs = Map.get(metadata, :previous_url_slugs) || [] - old_url_slug = Map.get(metadata, :url_slug) - new_url_slug = Map.get(params, "url_slug") - - cond do - new_url_slug == nil -> - current_slugs - - new_url_slug == "" and old_url_slug not in [nil, ""] -> - add_to_previous_slugs(current_slugs, old_url_slug) - - is_binary(new_url_slug) and new_url_slug != "" and old_url_slug not in [nil, ""] and - String.trim(new_url_slug) != old_url_slug -> - add_to_previous_slugs(current_slugs, old_url_slug) - - true -> - current_slugs - end - end - - defp add_to_previous_slugs(current_slugs, slug) do - if slug in current_slugs do - current_slugs - else - current_slugs ++ [slug] - end - end - - @doc """ - Resolves allow_version_access from params or existing metadata. - """ - @spec resolve_allow_version_access(map(), map()) :: boolean() - def resolve_allow_version_access(params, metadata) do - case Map.get(params, "allow_version_access") do - nil -> Map.get(metadata, :allow_version_access, false) - value when is_boolean(value) -> value - "true" -> true - "false" -> false - _ -> Map.get(metadata, :allow_version_access, false) - end - end - - @doc """ - Gets slug from metadata, falling back to provided default if nil or empty. - """ - @spec get_slug_with_fallback(map(), String.t()) :: String.t() - def get_slug_with_fallback(metadata, fallback) do - case Map.get(metadata, :slug) do - nil -> fallback - "" -> fallback - slug -> slug - end - end - - # ============================================================================ - # Time Helpers - # ============================================================================ - - @doc """ - Formats a Time struct as a time folder string (HH:MM). - """ - @spec format_time_folder(Time.t()) :: String.t() - def format_time_folder(%Time{} = time) do - {hour, minute, _second} = Time.to_erl(time) - "#{pad(hour)}:#{pad(minute)}" - end - - @doc """ - Parses a time folder string (HH:MM) into a Time struct. - """ - @spec parse_time_folder(String.t()) :: {:ok, Time.t()} | {:error, :invalid_time} - def parse_time_folder(folder) do - case String.split(folder, ":") do - [hour, minute] -> - with {h, ""} <- Integer.parse(hour), - {m, ""} <- Integer.parse(minute), - true <- h in 0..23, - true <- m in 0..59 do - {:ok, Time.new!(h, m, 0)} - else - _ -> {:error, :invalid_time} - end - - _ -> - {:error, :invalid_time} - end - end - - @doc """ - Floors a DateTime to the minute (sets seconds and microseconds to 0). - """ - @spec floor_to_minute(DateTime.t()) :: DateTime.t() - def floor_to_minute(%DateTime{} = datetime) do - %DateTime{datetime | second: 0, microsecond: {0, 0}} - end - - defp pad(value) when value < 10, do: "0#{value}" - defp pad(value), do: Integer.to_string(value) - - # ============================================================================ - # Path Helpers - # ============================================================================ - - @doc """ - Extracts date and time from a relative path. - Handles both legacy (4 parts) and versioned (5 parts) paths. - """ - @spec date_time_from_path(String.t()) :: {:ok, {Date.t(), Time.t()}} | {:error, :invalid_path} - def date_time_from_path(path) do - parts = String.split(path, "/", trim: true) - - {date_part, time_part} = - case parts do - [_type, date_part, time_part, _file] -> - {date_part, time_part} - - [_type, date_part, time_part, _version, _file] -> - {date_part, time_part} - - _ -> - {nil, nil} - end - - if date_part && time_part do - with {:ok, date} <- Date.from_iso8601(date_part), - {:ok, time} <- parse_time_folder(time_part) do - {:ok, {date, time}} - else - _ -> {:error, :invalid_path} - end - else - {:error, :invalid_path} - end - rescue - _ -> {:error, :invalid_path} - end - - @doc """ - Extracts date and time from a relative path, raising on error. - """ - @spec date_time_from_path!(String.t()) :: {Date.t(), Time.t()} - def date_time_from_path!(path) do - case date_time_from_path(path) do - {:ok, result} -> result - _ -> raise ArgumentError, "invalid blogging path #{inspect(path)}" - end - end - - @doc """ - Extracts language code from a path (e.g., "blog/post/en.phk" -> "en"). - """ - @spec extract_language_from_path(String.t()) :: String.t() - def extract_language_from_path(relative_path) do - relative_path - |> Path.basename() - |> String.replace_suffix(".phk", "") - end - - @doc """ - Builds a relative path for a timestamp-mode post with language. - """ - @spec relative_path_with_language(String.t(), Date.t(), Time.t(), String.t()) :: String.t() - def relative_path_with_language(group_slug, date, time, language_code) do - date_part = Date.to_iso8601(date) - time_part = format_time_folder(time) - - Path.join([group_slug, date_part, time_part, Languages.language_filename(language_code)]) - end - - @doc """ - Builds a relative path for a versioned timestamp-mode post with language. - """ - @spec relative_path_with_language_versioned( - String.t(), - Date.t(), - Time.t(), - integer(), - String.t() - ) :: String.t() - def relative_path_with_language_versioned(group_slug, date, time, version, language_code) do - date_part = Date.to_iso8601(date) - time_part = format_time_folder(time) - - Path.join([ - group_slug, - date_part, - time_part, - "v#{version}", - Languages.language_filename(language_code) - ]) - end - - # ============================================================================ - # Update Metadata Builder - # ============================================================================ - - @doc """ - Builds updated metadata for a post update operation. - """ - @spec build_update_metadata(map(), map(), map(), boolean()) :: map() - def build_update_metadata(post, params, audit_meta, _becoming_published?) do - current_title = - metadata_value(post.metadata, :title) || - Metadata.extract_title_from_content(post.content || "") - - current_status = metadata_value(post.metadata, :status, "draft") - new_status = Map.get(params, "status", current_status) - - post.metadata - |> Map.put(:title, Map.get(params, "title", current_title)) - |> Map.put(:status, new_status) - |> Map.put( - :published_at, - Map.get(params, "published_at", metadata_value(post.metadata, :published_at)) - ) - |> Map.put(:featured_image_uuid, resolve_featured_image_uuid(params, post.metadata)) - |> Map.put(:created_at, Map.get(post.metadata, :created_at)) - |> Map.put(:slug, post.slug) - |> Map.put(:version, Map.get(post.metadata, :version, 1)) - |> Map.put(:version_created_at, Map.get(post.metadata, :version_created_at)) - |> Map.put(:version_created_from, Map.get(post.metadata, :version_created_from)) - |> Map.put(:allow_version_access, resolve_allow_version_access(params, post.metadata)) - |> Map.put(:url_slug, resolve_url_slug(params, post.metadata)) - |> Map.put(:previous_url_slugs, resolve_previous_url_slugs(params, post.metadata)) - |> Map.delete(:is_live) - |> Map.delete(:legacy_is_live) - |> apply_update_audit_metadata(audit_meta) - end -end diff --git a/lib/modules/publishing/storage/languages.ex b/lib/modules/publishing/storage/languages.ex deleted file mode 100644 index 6e884b33..00000000 --- a/lib/modules/publishing/storage/languages.ex +++ /dev/null @@ -1,532 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Storage.Languages do - @moduledoc """ - Language and internationalization operations for publishing storage. - - Handles language detection, display ordering, language info lookup, - and primary language management for posts. - """ - - alias PhoenixKit.Modules.Languages - alias PhoenixKit.Modules.Languages.DialectMapper - alias PhoenixKit.Modules.Publishing.Metadata - alias PhoenixKit.Modules.Publishing.Storage.Paths - alias PhoenixKit.Modules.Publishing.Storage.Versions - alias PhoenixKit.Settings - - @doc """ - Returns the filename for a specific language code. - """ - @spec language_filename(String.t()) :: String.t() - def language_filename(language_code) do - "#{language_code}.phk" - end - - @doc """ - Returns the filename for language-specific posts based on the site's - primary content language setting. - """ - @spec language_filename() :: String.t() - def language_filename do - language_code = Settings.get_content_language() - "#{language_code}.phk" - end - - @doc """ - Returns all enabled language codes for multi-language support. - Falls back to content language if Languages module is disabled. - """ - @spec enabled_language_codes() :: [String.t()] - def enabled_language_codes do - if Languages.enabled?() do - Languages.get_enabled_language_codes() - else - [Settings.get_content_language()] - end - end - - @doc """ - Returns the primary/canonical language for versioning. - - Uses the default language from the Languages module (via Settings.get_content_language). - Falls back to "en" if Languages module is disabled or no default is set. - - This should be used instead of `hd(enabled_language_codes())` when - determining which language controls versioning logic. - """ - @spec get_primary_language() :: String.t() - def get_primary_language do - Settings.get_content_language() - end - - @doc false - @deprecated "Use get_primary_language/0 instead" - @spec get_master_language() :: String.t() - def get_master_language, do: get_primary_language() - - @doc """ - Gets the primary language for a specific post. - - Reads the post's metadata to get its stored `primary_language` field. - Falls back to the global setting if no `primary_language` is stored. - - This ensures posts created before the `primary_language` field was added - continue to work by using the current global setting. - """ - @spec get_post_primary_language(String.t(), String.t(), integer() | nil) :: String.t() - def get_post_primary_language(group_slug, post_slug, version \\ nil) - - def get_post_primary_language(group_slug, post_slug, version) do - post_path = Path.join([Paths.group_path(group_slug), post_slug]) - - case Versions.detect_post_structure(post_path) do - :versioned -> - version_to_use = version || get_latest_version_number(post_path) - version_dir = Path.join(post_path, "v#{version_to_use}") - read_primary_language_from_dir(version_dir) - - :legacy -> - read_primary_language_from_dir(post_path) - - _ -> - get_primary_language() - end - end - - defp get_latest_version_number(post_path) do - case File.ls(post_path) do - {:ok, files} -> - files - |> Enum.filter(&String.starts_with?(&1, "v")) - |> Enum.map(fn "v" <> n -> String.to_integer(n) end) - |> Enum.max(fn -> 1 end) - - _ -> - 1 - end - end - - defp read_primary_language_from_dir(dir) do - case File.ls(dir) do - {:ok, files} -> - files - |> Enum.find(&String.ends_with?(&1, ".phk")) - |> case do - nil -> - get_primary_language() - - file -> - file_path = Path.join(dir, file) - - case File.read(file_path) do - {:ok, content} -> - {:ok, metadata, _} = Metadata.parse_with_content(content) - Map.get(metadata, :primary_language) || get_primary_language() - - _ -> - get_primary_language() - end - end - - _ -> - get_primary_language() - end - end - - @doc """ - Checks if a post needs primary_language migration. - - A post needs migration if: - 1. It has no `primary_language` stored in metadata (needs backfill), OR - 2. Its stored `primary_language` doesn't match the current global setting (needs migration decision) - - Returns: - - `{:ok, :current}` if post matches global setting - - `{:needs_migration, stored_lang}` if post has different primary_language - - `{:needs_backfill, nil}` if post has no primary_language stored - """ - @spec check_primary_language_status(String.t(), String.t()) :: - {:ok, :current} | {:needs_migration, String.t()} | {:needs_backfill, nil} - def check_primary_language_status(group_slug, post_slug) do - global_primary = get_primary_language() - post_path = Path.join([Paths.group_path(group_slug), post_slug]) - - case has_stored_primary_language?(post_path) do - {:ok, stored_primary} when stored_primary == global_primary -> - {:ok, :current} - - {:ok, stored_primary} -> - {:needs_migration, stored_primary} - - :not_stored -> - {:needs_backfill, nil} - end - end - - defp has_stored_primary_language?(post_path) do - case Versions.detect_post_structure(post_path) do - :versioned -> - case File.ls(post_path) do - {:ok, dirs} -> - version_dir = Enum.find(dirs, &String.starts_with?(&1, "v")) - - if version_dir do - check_dir_for_stored_primary_language(Path.join(post_path, version_dir)) - else - :not_stored - end - - _ -> - :not_stored - end - - :legacy -> - check_dir_for_stored_primary_language(post_path) - - _ -> - :not_stored - end - end - - defp check_dir_for_stored_primary_language(dir) do - with {:ok, files} <- File.ls(dir), - file when not is_nil(file) <- Enum.find(files, &String.ends_with?(&1, ".phk")), - file_path <- Path.join(dir, file), - {:ok, content} <- File.read(file_path), - {:ok, metadata, _} <- Metadata.parse_with_content(content) do - case Map.get(metadata, :primary_language) do - nil -> :not_stored - "" -> :not_stored - stored -> {:ok, stored} - end - else - _ -> :not_stored - end - end - - @doc """ - Updates the primary_language field for all language files in a post. - - This is used during migration to set the primary_language to match - the current global setting. Updates all versions. - """ - @spec update_post_primary_language(String.t(), String.t(), String.t()) :: :ok | {:error, any()} - def update_post_primary_language(_group_slug, nil, _new_primary_language), - do: {:error, :invalid_slug} - - def update_post_primary_language(_group_slug, "", _new_primary_language), - do: {:error, :invalid_slug} - - def update_post_primary_language(group_slug, post_slug, new_primary_language) do - post_path = Path.join([Paths.group_path(group_slug), post_slug]) - - case Versions.detect_post_structure(post_path) do - :versioned -> - case File.ls(post_path) do - {:ok, dirs} -> - dirs - |> Enum.filter(&String.starts_with?(&1, "v")) - |> Enum.each(fn version_dir -> - update_primary_language_in_dir( - Path.join(post_path, version_dir), - new_primary_language - ) - end) - - :ok - - {:error, reason} -> - {:error, reason} - end - - :legacy -> - update_primary_language_in_dir(post_path, new_primary_language) - - _ -> - {:error, :post_not_found} - end - end - - defp update_primary_language_in_dir(dir, new_primary_language) do - case File.ls(dir) do - {:ok, files} -> - files - |> Enum.filter(&String.ends_with?(&1, ".phk")) - |> Enum.each(fn file -> - file_path = Path.join(dir, file) - - case File.read(file_path) do - {:ok, content} -> - {:ok, metadata, body} = Metadata.parse_with_content(content) - updated_metadata = Map.put(metadata, :primary_language, new_primary_language) - - new_content = - Metadata.serialize(updated_metadata) <> "\n\n" <> String.trim_leading(body) - - File.write(file_path, new_content <> "\n") - - _ -> - :ok - end - end) - - :ok - - _ -> - :ok - end - end - - @doc """ - Gets language details (name, flag) for a given language code. - - Searches in order: - 1. Predefined languages (BeamLabCountries) - for full locale details - 2. User-configured languages - for custom/less common languages - """ - @spec get_language_info(String.t()) :: - %{code: String.t(), name: String.t(), flag: String.t()} | nil - def get_language_info(language_code) do - predefined = find_in_predefined_languages(language_code) - - if predefined do - predefined - else - find_in_configured_languages(language_code) - end - end - - defp find_in_predefined_languages(language_code) do - case Languages.get_available_language_by_code(language_code) do - nil -> - base_code = DialectMapper.extract_base(language_code) - is_base_code = language_code == base_code and not String.contains?(language_code, "-") - default_dialect = DialectMapper.base_to_dialect(base_code) - - case Languages.get_available_language_by_code(default_dialect) do - nil -> - all_languages = Languages.get_available_languages() - - Enum.find(all_languages, fn lang -> - DialectMapper.extract_base(lang.code) == base_code - end) - - default_match -> - if is_base_code do - %{default_match | name: extract_base_language_name(default_match.name)} - else - default_match - end - end - - exact_match -> - exact_match - end - end - - defp extract_base_language_name(name) when is_binary(name) do - case String.split(name, " (", parts: 2) do - [base_name, _region] -> base_name - [base_name] -> base_name - end - end - - defp extract_base_language_name(name), do: name - - defp find_in_configured_languages(language_code) do - configured_languages = Languages.get_languages() - - exact_match = - Enum.find(configured_languages, fn lang -> lang.code == language_code end) - - result = - if exact_match do - exact_match - else - base_code = DialectMapper.extract_base(language_code) - default_dialect = DialectMapper.base_to_dialect(base_code) - - default_match = - Enum.find(configured_languages, fn lang -> lang.code == default_dialect end) - - if default_match do - default_match - else - Enum.find(configured_languages, fn lang -> - DialectMapper.extract_base(lang.code) == base_code - end) - end - end - - if result do - %{ - code: result.code, - name: result.name || result.code, - flag: result.flag || "" - } - else - nil - end - end - - @doc """ - Checks if a language code is enabled, considering base code matching. - - This handles cases where: - - The file is `en.phk` and enabled languages has `"en-US"` -> matches - - The file is `en-US.phk` and enabled languages has `"en"` -> matches - - The file is `af.phk` and enabled languages has `"af"` -> matches - """ - @spec language_enabled?(String.t(), [String.t()]) :: boolean() - def language_enabled?(language_code, enabled_languages) do - if language_code in enabled_languages do - true - else - base_code = DialectMapper.extract_base(language_code) - - Enum.any?(enabled_languages, fn enabled_lang -> - enabled_lang == language_code or - DialectMapper.extract_base(enabled_lang) == base_code - end) - end - end - - @doc """ - Determines the display code for a language based on whether multiple dialects - of the same base language are enabled. - - If only one dialect of a base language is enabled (e.g., just "en-US"), - returns the base code ("en") for cleaner display. - - If multiple dialects are enabled (e.g., "en-US" and "en-GB"), - returns the full dialect code ("en-US") to distinguish them. - """ - @spec get_display_code(String.t(), [String.t()]) :: String.t() - def get_display_code(language_code, enabled_languages) do - base_code = DialectMapper.extract_base(language_code) - - dialects_count = - Enum.count(enabled_languages, fn lang -> - DialectMapper.extract_base(lang) == base_code - end) - - if dialects_count > 1 do - language_code - else - base_code - end - end - - @doc """ - Orders languages for display in the language switcher. - - Order: primary language first, then languages with translations (sorted), - then languages without translations (sorted). This ensures consistent order - across all views regardless of which language is currently being edited. - """ - @spec order_languages_for_display([String.t()], [String.t()], String.t() | nil) :: [String.t()] - def order_languages_for_display(available_languages, enabled_languages, primary_language \\ nil) do - primary_lang = primary_language || get_primary_language() - - langs_with_content = - available_languages - |> Enum.reject(&(&1 == primary_lang)) - |> Enum.sort() - - langs_without_content = - enabled_languages - |> Enum.reject(&(&1 in available_languages or &1 == primary_lang)) - |> Enum.sort() - - [primary_lang] ++ langs_with_content ++ langs_without_content - end - - @doc """ - Detects available languages in a directory by looking for .phk files. - Returns language codes sorted with primary language first. - """ - @spec detect_available_languages(String.t(), String.t() | nil) :: [String.t()] - def detect_available_languages(dir_path, primary_language \\ nil) do - primary_lang = primary_language || get_primary_language() - - case File.ls(dir_path) do - {:ok, files} -> - languages = - files - |> Enum.filter(&String.ends_with?(&1, ".phk")) - |> Enum.map(&String.replace_suffix(&1, ".phk", "")) - |> Enum.sort() - - if primary_lang in languages do - [primary_lang | Enum.reject(languages, &(&1 == primary_lang))] - else - languages - end - - {:error, _} -> - [] - end - end - - @doc """ - Loads status for all language files in a post directory. - Returns a map of language_code => status. - """ - @spec load_language_statuses(String.t(), [String.t()]) :: %{String.t() => String.t() | nil} - def load_language_statuses(post_dir, available_languages) do - Enum.reduce(available_languages, %{}, fn lang, acc -> - lang_path = Path.join(post_dir, language_filename(lang)) - - status = - case File.read(lang_path) do - {:ok, content} -> - {:ok, metadata, _content} = Metadata.parse_with_content(content) - Map.get(metadata, :status) - - {:error, _} -> - nil - end - - Map.put(acc, lang, status) - end) - end - - @doc """ - Propagates status changes from the primary language to all translations. - - When the primary language post status changes, this updates all other - language files in the same directory to match the new status. - """ - @spec propagate_status_to_translations(String.t(), String.t(), String.t()) :: :ok - def propagate_status_to_translations(post_dir, primary_language, new_status) do - available_languages = detect_available_languages(post_dir) - translation_languages = Enum.reject(available_languages, &(&1 == primary_language)) - - Enum.each(translation_languages, fn lang -> - lang_path = Path.join(post_dir, language_filename(lang)) - - with {:ok, content} <- File.read(lang_path), - {:ok, metadata, body} <- Metadata.parse_with_content(content) do - updated_metadata = Map.put(metadata, :status, new_status) - serialized = Metadata.serialize(updated_metadata) <> "\n\n" <> String.trim_leading(body) - File.write(lang_path, serialized <> "\n") - end - end) - - :ok - end - - @doc """ - Checks if a language code is reserved (cannot be used as a slug). - """ - @spec reserved_language_code?(String.t()) :: boolean() - def reserved_language_code?(slug) do - language_codes = - try do - Languages.get_language_codes() - rescue - _ -> [] - end - - slug in language_codes - end -end diff --git a/lib/modules/publishing/storage/paths.ex b/lib/modules/publishing/storage/paths.ex deleted file mode 100644 index 90350042..00000000 --- a/lib/modules/publishing/storage/paths.ex +++ /dev/null @@ -1,222 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Storage.Paths do - @moduledoc """ - Path management for publishing storage. - - Handles root paths, group paths, legacy/new path resolution, - and path utilities for the filesystem storage system. - """ - - @doc """ - Returns the root path for reading content. - Prefers new "publishing" path, falls back to legacy "blogging" path. - For writing new content, use `write_root_path/0` instead. - """ - @spec root_path() :: String.t() - def root_path do - base_priv = get_parent_app_priv() - new_path = Path.join(base_priv, "publishing") - legacy_path = Path.join(base_priv, "blogging") - - cond do - File.dir?(new_path) -> new_path - File.dir?(legacy_path) -> legacy_path - true -> new_path - end - end - - @doc """ - Returns the path for a specific publishing group, checking both new and legacy locations. - Returns the path where the group actually exists, or the new path if it doesn't exist yet. - """ - @spec group_path(String.t()) :: String.t() - def group_path(group_slug) do - base_priv = get_parent_app_priv() - new_group_path = Path.join([base_priv, "publishing", group_slug]) - legacy_group_path = Path.join([base_priv, "blogging", group_slug]) - - cond do - File.dir?(new_group_path) -> new_group_path - File.dir?(legacy_group_path) -> legacy_group_path - true -> new_group_path - end - end - - @doc """ - Returns the write root path for creating new groups. - Always returns the new "publishing" path. - """ - @spec write_root_path() :: String.t() - def write_root_path do - base_priv = get_parent_app_priv() - path = Path.join(base_priv, "publishing") - File.mkdir_p!(path) - path - end - - @doc """ - Returns the new publishing root path. - """ - @spec new_root_path() :: String.t() - def new_root_path do - base_priv = get_parent_app_priv() - Path.join(base_priv, "publishing") - end - - @doc """ - Returns the legacy blogging root path. - """ - @spec legacy_root_path() :: String.t() - def legacy_root_path do - base_priv = get_parent_app_priv() - Path.join(base_priv, "blogging") - end - - @doc """ - Checks if a specific publishing group is stored in the legacy "blogging" directory. - """ - @spec legacy_group?(String.t()) :: boolean() - def legacy_group?(group_slug) do - legacy_path = Path.join(legacy_root_path(), group_slug) - new_path = Path.join(new_root_path(), group_slug) - - File.dir?(legacy_path) and not File.dir?(new_path) - end - - @doc """ - Migrates a publishing group from the legacy "blogging" directory to the new "publishing" directory. - Returns {:ok, new_path} on success, {:error, reason} on failure. - """ - @spec migrate_group(String.t()) :: {:ok, String.t()} | {:error, term()} - def migrate_group(group_slug) do - legacy_path = Path.join(legacy_root_path(), group_slug) - new_path = Path.join(new_root_path(), group_slug) - - cond do - File.dir?(new_path) -> - {:error, :already_migrated} - - not File.dir?(legacy_path) -> - {:error, :not_found} - - true -> - File.mkdir_p!(new_root_path()) - - case File.rename(legacy_path, new_path) do - :ok -> - cleanup_empty_legacy_root() - {:ok, new_path} - - {:error, reason} -> - {:error, reason} - end - end - end - - @doc """ - Removes the legacy root directory if it's empty. - """ - @spec cleanup_empty_legacy_root() :: :ok - def cleanup_empty_legacy_root do - legacy_root = legacy_root_path() - - if File.dir?(legacy_root) do - case File.ls(legacy_root) do - {:ok, []} -> File.rmdir(legacy_root) - _ -> :ok - end - end - - :ok - end - - @doc """ - Returns whether there are any publishing groups still in the legacy location. - """ - @spec has_legacy_groups?() :: boolean() - def has_legacy_groups? do - legacy_root = legacy_root_path() - - if File.dir?(legacy_root) do - case File.ls(legacy_root) do - {:ok, entries} -> Enum.any?(entries, &File.dir?(Path.join(legacy_root, &1))) - _ -> false - end - else - false - end - end - - @doc """ - Ensures the folder for a publishing group exists. - For new groups, creates in the new "publishing" directory. - For existing groups, uses their current location. - """ - @spec ensure_group_root(String.t()) :: :ok | {:error, term()} - def ensure_group_root(group_slug) do - group_path(group_slug) - |> File.mkdir_p() - end - - @doc """ - Returns the absolute path for a relative blogging path. - Handles per-blog legacy/new path resolution. - """ - @spec absolute_path(String.t()) :: String.t() - def absolute_path(relative_path) do - trimmed = String.trim_leading(relative_path, "/") - - case String.split(trimmed, "/", parts: 2) do - [group_slug, rest] -> - Path.join(group_path(group_slug), rest) - - [group_slug] -> - group_path(group_slug) - end - end - - @doc """ - Cleans up empty directories going up from a path. - Stops at the publishing/blogging root. - """ - @spec cleanup_empty_dirs(String.t()) :: :ok - def cleanup_empty_dirs(path) do - new_root = new_root_path() - legacy_root = legacy_root_path() - - path - |> Path.dirname() - |> Stream.iterate(&Path.dirname/1) - |> Enum.take_while(fn dir -> - String.starts_with?(dir, new_root) or String.starts_with?(dir, legacy_root) - end) - |> Enum.each(fn dir -> - case File.ls(dir) do - {:ok, []} -> File.rmdir(dir) - _ -> :ok - end - end) - - :ok - end - - # Gets the parent application's priv directory - defp get_parent_app_priv do - parent_app = - case PhoenixKit.Config.get_parent_app() do - nil -> - raise """ - PhoenixKit parent app not configured. - Cannot determine storage path for publishing module. - - Please add the following to your config/config.exs: - - config :phoenix_kit, parent_app_name: :your_app_name - """ - - app -> - app - end - - Application.app_dir(parent_app, "priv") - end -end diff --git a/lib/modules/publishing/storage/slugs.ex b/lib/modules/publishing/storage/slugs.ex deleted file mode 100644 index 25baaa2c..00000000 --- a/lib/modules/publishing/storage/slugs.ex +++ /dev/null @@ -1,394 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Storage.Slugs do - @moduledoc """ - Slug validation and generation for publishing storage. - - Handles slug format validation, uniqueness checking, - URL slug validation for per-language slugs, and slug generation. - """ - - alias PhoenixKit.Modules.Publishing.DBStorage - alias PhoenixKit.Modules.Publishing.ListingCache - alias PhoenixKit.Modules.Publishing.Metadata - alias PhoenixKit.Modules.Publishing.Storage - alias PhoenixKit.Modules.Publishing.Storage.Languages - alias PhoenixKit.Modules.Publishing.Storage.Paths - alias PhoenixKit.Modules.Publishing.Storage.Versions - alias PhoenixKit.Utils.Slug - - require Logger - - @slug_pattern ~r/^[a-z0-9]+(?:-[a-z0-9]+)*$/ - - # Reserved route words that cannot be used as URL slugs - @reserved_route_words ~w(admin api assets phoenix_kit auth login logout register settings) - - @doc """ - Validates whether the given string is a valid slug format and not a reserved language code. - - Returns: - - `{:ok, slug}` if valid - - `{:error, :invalid_format}` if format is invalid - - `{:error, :reserved_language_code}` if slug is a language code - - Group slugs cannot be language codes (like 'en', 'es', 'fr') to prevent routing ambiguity. - """ - @spec validate_slug(String.t()) :: - {:ok, String.t()} | {:error, :invalid_format | :reserved_language_code} - def validate_slug(slug) when is_binary(slug) do - cond do - not Regex.match?(@slug_pattern, slug) -> - {:error, :invalid_format} - - Languages.reserved_language_code?(slug) -> - {:error, :reserved_language_code} - - true -> - {:ok, slug} - end - end - - @doc """ - Validates whether the given string is a slug and not a reserved language code. - - Group slugs cannot be language codes (like 'en', 'es', 'fr') to prevent routing ambiguity. - """ - @spec valid_slug?(String.t()) :: boolean() - def valid_slug?(slug) when is_binary(slug) do - case validate_slug(slug) do - {:ok, _} -> true - {:error, _} -> false - end - end - - @doc """ - Validates a per-language URL slug for uniqueness within a group+language combination. - - URL slugs have the same format requirements as directory slugs, plus: - - Cannot be reserved route words (admin, api, assets, etc.) - - Must be unique within the group+language combination - - ## Parameters - - `group_slug` - The publishing group - - `url_slug` - The URL slug to validate - - `language` - The language code - - `exclude_post_slug` - Optional post slug to exclude from uniqueness check (for updates) - - ## Returns - - `{:ok, url_slug}` - Valid and unique - - `{:error, :invalid_format}` - Invalid format - - `{:error, :reserved_language_code}` - Is a language code - - `{:error, :reserved_route_word}` - Is a reserved route word - - `{:error, :slug_already_exists}` - Already in use for this language - - `{:error, :conflicts_with_directory_slug}` - Conflicts with another post's directory slug - """ - @spec validate_url_slug(String.t(), String.t(), String.t(), String.t() | nil) :: - {:ok, String.t()} | {:error, atom()} - def validate_url_slug(group_slug, url_slug, language, exclude_post_slug \\ nil) do - cond do - not Regex.match?(@slug_pattern, url_slug) -> - {:error, :invalid_format} - - Languages.reserved_language_code?(url_slug) -> - {:error, :reserved_language_code} - - url_slug in @reserved_route_words -> - {:error, :reserved_route_word} - - # Directory slugs have priority - can't use another post's directory slug as your url_slug - conflicts_with_directory_slug?(group_slug, url_slug, exclude_post_slug) -> - {:error, :conflicts_with_directory_slug} - - url_slug_exists?(group_slug, url_slug, language, exclude_post_slug) -> - {:error, :slug_already_exists} - - true -> - {:ok, url_slug} - end - end - - # Check if the url_slug matches any other post's directory slug - defp conflicts_with_directory_slug?(group_slug, url_slug, exclude_post_slug) do - # If the url_slug equals the post's own directory slug, that's fine - if url_slug == exclude_post_slug do - false - else - # Check if any other post has this as their directory slug - slug_exists?(group_slug, url_slug) - end - end - - defp url_slug_exists?(group_slug, url_slug, language, exclude_post_slug) do - case ListingCache.read(group_slug) do - {:ok, posts} -> - Enum.any?(posts, fn post -> - # Skip the post being edited and skip posts whose directory slug equals the url_slug - # (directory slugs are checked separately and have priority) - post.slug != exclude_post_slug and - post.slug != url_slug and - Map.get(post.language_slugs || %{}, language) == url_slug - end) - - {:error, _} -> - url_slug_exists_in_filesystem?(group_slug, url_slug, language, exclude_post_slug) - end - end - - defp url_slug_exists_in_filesystem?(group_slug, url_slug, language, exclude_post_slug) do - group_path = Paths.group_path(group_slug) - - if File.dir?(group_path) do - group_path - |> File.ls!() - |> Enum.filter(&File.dir?(Path.join(group_path, &1))) - |> Enum.reject(&(&1 == exclude_post_slug)) - |> Enum.any?(fn post_slug -> - case read_post_url_slug(group_slug, post_slug, language) do - {:ok, existing_url_slug} -> existing_url_slug == url_slug - _ -> false - end - end) - else - false - end - end - - defp read_post_url_slug(group_slug, post_slug, language) do - case Storage.read_post_slug_mode(group_slug, post_slug, language, nil) do - {:ok, post} -> - url_slug = Map.get(post.metadata, :url_slug) || post_slug - {:ok, url_slug} - - error -> - error - end - end - - @doc """ - Checks if a slug already exists within the given publishing group. - Checks both the filesystem and the database. - """ - @spec slug_exists?(String.t(), String.t()) :: boolean() - def slug_exists?(group_slug, post_slug) do - fs_exists = - Path.join([Paths.group_path(group_slug), post_slug]) - |> File.dir?() - - fs_exists || db_slug_exists?(group_slug, post_slug) - end - - defp db_slug_exists?(group_slug, post_slug) do - case DBStorage.get_post(group_slug, post_slug) do - nil -> false - _post -> true - end - rescue - # DB tables may not exist yet (pre-V59 migration) - _ -> false - end - - @doc """ - Clears custom url_slugs that conflict with a given directory slug. - - When a new post is created with directory slug X, any other posts that have - custom url_slug = X need to have their url_slugs cleared (so they fall back - to their own directory slug). Directory slugs have priority. - - Returns the list of {post_slug, language} tuples that were cleared. - """ - @spec clear_conflicting_url_slugs(String.t(), String.t()) :: [{String.t(), String.t()}] - def clear_conflicting_url_slugs(group_slug, directory_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, conflicts) - log_cleared_conflicts(conflicts, directory_slug) - conflicts - - {:error, _} -> - [] - end - end - - defp find_conflicting_url_slugs(posts, directory_slug) do - Enum.flat_map(posts, fn post -> - if post.slug == directory_slug do - [] - else - find_post_language_conflicts(post, directory_slug) - end - end) - end - - defp find_post_language_conflicts(post, directory_slug) do - (post.language_slugs || %{}) - |> Enum.filter(fn {_lang, url_slug} -> url_slug == directory_slug end) - |> Enum.map(fn {lang, _} -> {post.slug, lang} end) - end - - defp clear_url_slugs_for_conflicts(group_slug, conflicts) do - Enum.each(conflicts, fn {post_slug, language} -> - clear_url_slug_for_language(group_slug, post_slug, language) - end) - end - - defp log_cleared_conflicts([], _directory_slug), do: :ok - - defp log_cleared_conflicts(conflicts, directory_slug) do - Logger.warning( - "[Slugs] Cleared conflicting url_slugs for directory slug '#{directory_slug}': #{inspect(conflicts)}" - ) - end - - # Clears the url_slug for a specific post/language by removing it from metadata - defp clear_url_slug_for_language(group_slug, post_slug, language) do - post_path = Path.join([Paths.group_path(group_slug), post_slug]) - structure = Versions.detect_post_structure(post_path) - versions = Versions.list_versions(group_slug, post_slug) - - Enum.each(versions, fn version -> - clear_url_slug_in_version(post_path, structure, version, language) - end) - end - - defp clear_url_slug_in_version(post_path, structure, version, language) do - version_dir = get_version_dir(post_path, structure, version) - - if version_dir do - file_path = Path.join(version_dir, Languages.language_filename(language)) - clear_url_slug_in_file(file_path) - end - end - - defp get_version_dir(post_path, :versioned, version), do: Path.join(post_path, "v#{version}") - defp get_version_dir(post_path, :legacy, _version), do: post_path - defp get_version_dir(_post_path, _structure, _version), do: nil - - defp clear_url_slug_in_file(file_path) do - with true <- File.exists?(file_path), - {:ok, content} <- File.read(file_path), - {:ok, metadata, body} <- Metadata.parse_with_content(content) do - new_metadata = Map.delete(metadata, :url_slug) - new_content = Metadata.serialize(new_metadata) <> body - File.write(file_path, new_content) - else - _ -> :ok - end - end - - @doc """ - Clears a specific url_slug from all translations of a single post. - - This is used when saving a post with a conflicting url_slug - we clear the - url_slug from all translations of the same post that have that value. - - Returns the list of language codes that were cleared. - """ - @spec clear_url_slug_from_post(String.t(), String.t(), String.t()) :: [String.t()] - def clear_url_slug_from_post(group_slug, post_slug, url_slug_to_clear) do - post_path = Path.join([Paths.group_path(group_slug), post_slug]) - structure = Versions.detect_post_structure(post_path) - versions = Versions.list_versions(group_slug, post_slug) - - languages_to_clear = - versions - |> Enum.flat_map(&find_languages_with_url_slug(post_path, structure, &1, url_slug_to_clear)) - |> Enum.uniq() - - Enum.each(languages_to_clear, fn language -> - clear_url_slug_for_language(group_slug, post_slug, language) - end) - - log_cleared_languages(languages_to_clear, url_slug_to_clear, post_slug) - languages_to_clear - end - - defp find_languages_with_url_slug(post_path, structure, version, url_slug_to_clear) do - version_dir = get_version_dir(post_path, structure, version) - - if version_dir && File.dir?(version_dir) do - version_dir - |> Languages.detect_available_languages() - |> Enum.filter(&language_has_url_slug?(version_dir, &1, url_slug_to_clear)) - else - [] - end - end - - defp language_has_url_slug?(version_dir, lang, url_slug_to_clear) do - file_path = Path.join(version_dir, Languages.language_filename(lang)) - - with {:ok, content} <- File.read(file_path), - {:ok, metadata, _body} <- Metadata.parse_with_content(content) do - Map.get(metadata, :url_slug) == url_slug_to_clear - else - _ -> false - end - end - - defp log_cleared_languages([], _url_slug, _post_slug), do: :ok - - defp log_cleared_languages(languages, url_slug, post_slug) do - Logger.info( - "[Slugs] Cleared url_slug '#{url_slug}' from post '#{post_slug}' languages: #{inspect(languages)}" - ) - end - - @doc """ - Generates a unique slug based on title and optional preferred slug. - - Returns `{:ok, slug}` or `{:error, reason}` where reason can be: - - `:invalid_format` - slug has invalid format - - `:reserved_language_code` - slug is a reserved language code - """ - @spec generate_unique_slug(String.t(), String.t(), String.t() | nil, keyword()) :: - {:ok, String.t()} | {:error, :invalid_format | :reserved_language_code} - def generate_unique_slug(group_slug, title, preferred_slug \\ nil, opts \\ []) do - current_slug = Keyword.get(opts, :current_slug) - - base_slug_result = - case preferred_slug do - nil -> - {:ok, Slug.slugify(title)} - - slug when is_binary(slug) -> - sanitized = Slug.slugify(slug) - - if sanitized == "" do - {:ok, Slug.slugify(title)} - else - case validate_slug(sanitized) do - {:ok, valid_slug} -> - {:ok, valid_slug} - - {:error, reason} -> - {:error, reason} - end - end - end - - case base_slug_result do - {:ok, base_slug} when base_slug != "" -> - {:ok, - Slug.ensure_unique(base_slug, fn candidate -> - slug_exists_for_generation?(group_slug, candidate, current_slug) - end)} - - {:ok, ""} -> - {:ok, - Slug.ensure_unique("untitled", fn candidate -> - slug_exists_for_generation?(group_slug, candidate, current_slug) - end)} - - {:error, reason} -> - {:error, reason} - end - end - - defp slug_exists_for_generation?(_group_slug, candidate, current_slug) - when not is_nil(current_slug) and candidate == current_slug, - do: false - - defp slug_exists_for_generation?(group_slug, candidate, _current_slug) do - slug_exists?(group_slug, candidate) - end -end diff --git a/lib/modules/publishing/storage/versions.ex b/lib/modules/publishing/storage/versions.ex deleted file mode 100644 index 9f8465b8..00000000 --- a/lib/modules/publishing/storage/versions.ex +++ /dev/null @@ -1,396 +0,0 @@ -defmodule PhoenixKit.Modules.Publishing.Storage.Versions do - @moduledoc """ - Version management for publishing storage. - - Handles version detection, listing, status management, - and version creation operations for posts. - """ - - alias PhoenixKit.Modules.Publishing.Metadata - alias PhoenixKit.Modules.Publishing.Storage.Languages - alias PhoenixKit.Modules.Publishing.Storage.Paths - - @doc """ - Detects whether a post directory uses versioned structure (v1/, v2/, etc.) - or legacy structure (files directly in post directory). - - Returns: - - `:versioned` if v1/ or any vN/ directory exists - - `:legacy` if .phk files exist directly in the directory - - `:empty` if neither exists - """ - @spec detect_post_structure(String.t()) :: :versioned | :legacy | :empty - def detect_post_structure(post_path) do - if File.dir?(post_path) do - case File.ls(post_path) do - {:ok, entries} -> - has_version_dirs = Enum.any?(entries, &version_dir?/1) - has_phk_files = Enum.any?(entries, &String.ends_with?(&1, ".phk")) - - cond do - has_version_dirs -> :versioned - has_phk_files -> :legacy - true -> :empty - end - - {:error, _} -> - :empty - end - else - :empty - end - end - - @doc """ - Check if a directory name matches version pattern (v1, v2, etc.) - """ - @spec version_dir?(String.t()) :: boolean() - def version_dir?(name) do - Regex.match?(~r/^v\d+$/, name) - end - - @doc """ - Lists all version numbers for a slug-mode post. - Returns sorted list of integers (e.g., [1, 2, 3]). - For legacy posts without version directories, returns [1]. - """ - @spec list_versions(String.t(), String.t()) :: [integer()] - def list_versions(group_slug, post_slug) do - post_path = Path.join([Paths.group_path(group_slug), post_slug]) - - case detect_post_structure(post_path) do - :versioned -> - case File.ls(post_path) do - {:ok, entries} -> - entries - |> Enum.filter(&version_dir?/1) - |> Enum.map(&parse_version_number/1) - |> Enum.reject(&is_nil/1) - |> Enum.sort() - - {:error, _} -> - [] - end - - :legacy -> - [1] - - :empty -> - [] - end - end - - @doc """ - Parse version number from directory name (e.g., "v2" -> 2) - """ - @spec parse_version_number(String.t()) :: integer() | nil - def parse_version_number("v" <> num_str) do - case Integer.parse(num_str) do - {num, ""} -> num - _ -> nil - end - end - - def parse_version_number(_), do: nil - - @doc """ - Gets the latest (highest) version number for a post. - """ - @spec get_latest_version(String.t(), String.t()) :: {:ok, integer()} | {:error, :not_found} - def get_latest_version(group_slug, post_slug) do - case list_versions(group_slug, post_slug) do - [] -> {:error, :not_found} - versions -> {:ok, Enum.max(versions)} - end - end - - @doc """ - Gets the latest published version number for a post. - Checks each version's primary language file for status. - """ - @spec get_latest_published_version(String.t(), String.t()) :: - {:ok, integer()} | {:error, :not_found} - def get_latest_published_version(group_slug, post_slug) do - versions = list_versions(group_slug, post_slug) - primary_language = Languages.get_post_primary_language(group_slug, post_slug, nil) - - published_version = - versions - |> Enum.sort(:desc) - |> Enum.find(fn version -> - case get_version_status(group_slug, post_slug, version, primary_language) do - "published" -> true - _ -> false - end - end) - - case published_version do - nil -> {:error, :not_found} - version -> {:ok, version} - end - end - - @doc """ - Gets the published version number for a post. - Only ONE version can have status: "published" at a time in the variant versioning model. - Falls back to checking legacy is_live field for unmigrated posts. - """ - @spec get_published_version(String.t(), String.t()) :: {:ok, integer()} | {:error, :not_found} - def get_published_version(group_slug, post_slug) do - versions = list_versions(group_slug, post_slug) - primary_language = Languages.get_post_primary_language(group_slug, post_slug, nil) - - published_version = - Enum.find(versions, fn version -> - case get_version_metadata(group_slug, post_slug, version, primary_language) do - {:ok, metadata} -> Map.get(metadata, :status) == "published" - _ -> false - end - end) - - # Fall back to legacy is_live for unmigrated posts - published_version = - published_version || - Enum.find(versions, fn version -> - case get_version_metadata(group_slug, post_slug, version, primary_language) do - {:ok, metadata} -> Map.get(metadata, :legacy_is_live) == true - _ -> false - end - end) - - case published_version do - nil -> {:error, :not_found} - version -> {:ok, version} - end - end - - @doc """ - Deprecated: Use get_published_version/2 instead. - """ - @deprecated "Use get_published_version/2 instead" - @spec get_live_version(String.t(), String.t()) :: {:ok, integer()} | {:error, :not_found} - def get_live_version(group_slug, post_slug) do - get_published_version(group_slug, post_slug) - end - - @doc """ - Gets the status of a specific version for a language. - """ - @spec get_version_status(String.t(), String.t(), integer(), String.t()) :: String.t() | nil - def get_version_status(group_slug, post_slug, version, language) do - case get_version_metadata(group_slug, post_slug, version, language) do - {:ok, metadata} -> Map.get(metadata, :status) - _ -> nil - end - end - - @doc """ - Gets the metadata for a specific version and language. - """ - @spec get_version_metadata(String.t(), String.t(), integer(), String.t()) :: - {:ok, map()} | {:error, :not_found} - def get_version_metadata(group_slug, post_slug, version, language) do - post_path = Path.join([Paths.group_path(group_slug), post_slug]) - - file_path = - case detect_post_structure(post_path) do - :versioned -> - Path.join([post_path, "v#{version}", Languages.language_filename(language)]) - - :legacy when version == 1 -> - Path.join([post_path, Languages.language_filename(language)]) - - _ -> - nil - end - - if file_path && File.exists?(file_path) do - case File.read(file_path) do - {:ok, content} -> - {:ok, metadata, _body} = Metadata.parse_with_content(content) - {:ok, metadata} - - {:error, _} -> - {:error, :not_found} - end - else - {:error, :not_found} - end - end - - @doc """ - Returns the version directory path for a post. - """ - @spec version_path(String.t(), String.t(), integer()) :: String.t() - def version_path(group_slug, post_slug, version) do - Path.join([Paths.group_path(group_slug), post_slug, "v#{version}"]) - end - - @doc """ - Loads version statuses for all versions of a post. - Returns a map of version number => status. - """ - @spec load_version_statuses(String.t(), String.t(), [integer()], String.t() | nil) :: - %{integer() => String.t()} - def load_version_statuses(group_slug, post_slug, versions, primary_language \\ nil) do - primary_lang = primary_language || Languages.get_primary_language() - - Enum.reduce(versions, %{}, fn version, acc -> - status = get_version_status(group_slug, post_slug, version, primary_lang) - Map.put(acc, version, status) - end) - end - - @doc """ - Loads version_created_at dates for all specified versions. - Returns a map of version number => ISO 8601 date string. - """ - @spec load_version_dates(String.t(), String.t(), [integer()], String.t() | nil) :: %{ - integer() => String.t() | nil - } - def load_version_dates(group_slug, post_slug, versions, primary_language \\ nil) do - primary_lang = primary_language || Languages.get_primary_language() - - Enum.reduce(versions, %{}, fn version, acc -> - date = get_version_date(group_slug, post_slug, version, primary_lang) - Map.put(acc, version, date) - end) - end - - @doc """ - Loads available languages for each version of a post. - Returns a map of version number => list of language codes. - - This is useful for showing which translations exist for a specific version, - e.g., to display the published version's translations in the listing. - """ - @spec load_version_languages(String.t(), String.t(), [integer()]) :: %{ - integer() => [String.t()] - } - def load_version_languages(group_slug, post_slug, versions) do - post_path = Path.join([Paths.group_path(group_slug), post_slug]) - structure = detect_post_structure(post_path) - - Enum.reduce(versions, %{}, fn version, acc -> - languages = get_version_languages(post_path, structure, version) - Map.put(acc, version, languages) - end) - end - - # Gets the available languages for a specific version - defp get_version_languages(post_path, :versioned, version) do - version_dir = Path.join(post_path, "v#{version}") - Languages.detect_available_languages(version_dir) - end - - defp get_version_languages(post_path, :legacy, 1) do - Languages.detect_available_languages(post_path) - end - - defp get_version_languages(_post_path, _structure, _version), do: [] - - @doc """ - Gets the version_created_at date for a specific version. - """ - @spec get_version_date(String.t(), String.t(), integer(), String.t()) :: String.t() | nil - def get_version_date(group_slug, post_slug, version, language) do - case get_version_metadata(group_slug, post_slug, version, language) do - {:ok, metadata} -> Map.get(metadata, :version_created_at) - _ -> nil - end - end - - @doc """ - Resolves which version directory to read from. - """ - @spec resolve_version_dir( - String.t(), - :versioned | :legacy | :empty, - integer() | nil, - String.t(), - String.t() - ) :: - {:ok, integer(), String.t()} | {:error, atom()} - def resolve_version_dir(post_dir, :versioned, nil, group_slug, post_slug) do - case get_latest_version(group_slug, post_slug) do - {:ok, version} -> - {:ok, version, Path.join(post_dir, "v#{version}")} - - {:error, _} -> - {:error, :no_versions} - end - end - - def resolve_version_dir(post_dir, :versioned, version, _group_slug, _post_slug) do - version_dir = Path.join(post_dir, "v#{version}") - - if File.dir?(version_dir) do - {:ok, version, version_dir} - else - {:error, :version_not_found} - end - end - - def resolve_version_dir(post_dir, :legacy, _version, _group_slug, _post_slug) do - {:ok, 1, post_dir} - end - - def resolve_version_dir(_post_dir, :empty, _version, _group_slug, _post_slug) do - {:error, :not_found} - end - - @doc """ - Resolves the version directory for listing operations. - Returns {:ok, version, content_dir} or {:error, reason}. - """ - @spec resolve_version_dir_for_listing( - String.t(), - :versioned | :legacy | :empty, - String.t(), - String.t() - ) :: - {:ok, integer(), String.t()} | {:error, atom()} - def resolve_version_dir_for_listing(post_path, :versioned, group_slug, post_slug) do - case get_latest_version(group_slug, post_slug) do - {:ok, version} -> - {:ok, version, Path.join(post_path, "v#{version}")} - - {:error, _} -> - {:error, :no_versions} - end - end - - def resolve_version_dir_for_listing(post_path, :legacy, _group_slug, _post_slug) do - {:ok, 1, post_path} - end - - def resolve_version_dir_for_listing(_post_path, :empty, _group_slug, _post_slug) do - {:error, :empty} - end - - @doc """ - Checks if a version is the published (live) version. - """ - @spec version_is_published?(String.t(), String.t(), integer()) :: boolean() - def version_is_published?(group_slug, post_identifier, version) do - case get_published_version(group_slug, post_identifier) do - {:ok, ^version} -> true - _ -> false - end - end - - @doc """ - Checks if a post has only one version. - """ - @spec only_version?(String.t()) :: boolean() - def only_version?(post_dir) do - case File.ls(post_dir) do - {:ok, entries} -> - version_dirs = Enum.filter(entries, &version_dir?/1) - length(version_dirs) <= 1 - - _ -> - true - end - end -end diff --git a/lib/modules/publishing/web/controller.ex b/lib/modules/publishing/web/controller.ex index e49125d1..48da425e 100644 --- a/lib/modules/publishing/web/controller.ex +++ b/lib/modules/publishing/web/controller.ex @@ -15,7 +15,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do - `Routing` - URL path parsing and segment building - `Language` - Language detection and resolution - `SlugResolution` - URL slug resolution and redirects - - `PostFetching` - Post retrieval from cache/filesystem + - `PostFetching` - Post retrieval from cache/database - `Listing` - Group listing rendering and pagination - `PostRendering` - Post rendering and version handling - `Translations` - Translation link building diff --git a/lib/modules/publishing/web/controller/fallback.ex b/lib/modules/publishing/web/controller/fallback.ex index 0e2ce94f..84d20daf 100644 --- a/lib/modules/publishing/web/controller/fallback.ex +++ b/lib/modules/publishing/web/controller/fallback.ex @@ -12,10 +12,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do use Gettext, backend: PhoenixKitWeb.Gettext alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Modules.Publishing.Web.Controller.Language alias PhoenixKit.Modules.Publishing.Web.Controller.Listing - alias PhoenixKit.Modules.Publishing.Web.Controller.PostFetching alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML # ============================================================================ @@ -121,13 +119,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do # Finds the latest published version for a specific language defp find_published_version_for_language(group_slug, post_slug, language) do - versions = Storage.list_versions(group_slug, post_slug) + versions = Publishing.list_versions(group_slug, post_slug) published_version = versions |> Enum.sort(:desc) |> Enum.find(fn version -> - Storage.get_version_status(group_slug, post_slug, version, language) == "published" + Publishing.get_version_status(group_slug, post_slug, version, language) == "published" end) case published_version do @@ -195,9 +193,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do if group_exists?(group_slug) do # Step 1: Try other languages for this exact time - post_dir = Path.join([Storage.group_path(group_slug), date, time]) - # Use version-aware language detection (handles both versioned and legacy) - available = PostFetching.detect_available_languages_in_timestamp_dir(post_dir) + # 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 if available != [] do @@ -224,7 +221,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do # Fallback to another time on the same date defp fallback_to_other_time_on_date(group_slug, date, exclude_time, default_lang) do - case Storage.list_times_on_date(group_slug, date) do + case Publishing.list_times_on_date(group_slug, date) do [] -> # No posts on this date at all - try other dates or fall back to group listing fallback_to_other_date(group_slug, default_lang) @@ -253,9 +250,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do # Find the first published post at any of the given times defp find_first_published_time(group_slug, date, times, preferred_lang) do Enum.find_value(times, fn time -> - post_dir = Path.join([Storage.group_path(group_slug), date, time]) - # Use version-aware language detection (handles both versioned and legacy) - available = PostFetching.detect_available_languages_in_timestamp_dir(post_dir) + available = get_available_languages_for_timestamp(group_slug, date, time) if available != [] do # Try preferred language first, then others @@ -273,58 +268,12 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do @doc """ Tries each language for timestamp mode until finding a published version. - Handles both versioned and legacy structures. """ def find_first_published_timestamp_version(group_slug, date, time, languages) do - post_dir = Path.join([Storage.group_path(group_slug), date, time]) + identifier = "#{date}/#{time}" - case Storage.detect_post_structure(post_dir) do - :versioned -> - find_first_published_versioned_timestamp(group_slug, date, time, languages, post_dir) - - :legacy -> - find_first_published_legacy_timestamp(group_slug, date, time, languages) - - :empty -> - :not_found - end - end - - # Find first published post in versioned timestamp structure - # Iterates versions from highest to lowest, then tries each language - defp find_first_published_versioned_timestamp(group_slug, date, time, languages, post_dir) do - versions = PostFetching.list_timestamp_versions(post_dir) |> Enum.sort(:desc) - - Enum.find_value(versions, fn version -> - version_dir = Path.join(post_dir, "v#{version}") - available_languages = PostFetching.detect_available_languages_in_dir(version_dir) - - # Try preferred languages first, then fall back to what's available - languages_to_try = - (languages ++ available_languages) - |> Enum.uniq() - |> Enum.filter(&(&1 in available_languages)) - - Enum.find_value(languages_to_try, fn lang -> - path = "#{group_slug}/#{date}/#{time}/v#{version}/#{lang}.phk" - - case Publishing.read_post(group_slug, path) do - {:ok, post} when post.metadata.status == "published" -> - {:ok, build_timestamp_url(group_slug, date, time, lang)} - - _ -> - nil - end - end) - end) || :not_found - end - - # Find first published post in legacy timestamp structure - defp find_first_published_legacy_timestamp(group_slug, date, time, languages) do Enum.find_value(languages, fn lang -> - path = "#{group_slug}/#{date}/#{time}/#{lang}.phk" - - case Publishing.read_post(group_slug, path) do + case Publishing.read_post(group_slug, identifier, lang) do {:ok, post} when post.metadata.status == "published" -> {:ok, build_timestamp_url(group_slug, date, time, lang)} @@ -359,4 +308,15 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Fallback do defp build_timestamp_url(group_slug, date, time, language) do PublishingHTML.build_public_path_with_time(language, group_slug, date, time) end + + # 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 + nil -> [] + post -> post.available_languages || [] + end + end end diff --git a/lib/modules/publishing/web/controller/language.ex b/lib/modules/publishing/web/controller/language.ex index eb905d3b..f4de3272 100644 --- a/lib/modules/publishing/web/controller/language.ex +++ b/lib/modules/publishing/web/controller/language.ex @@ -11,7 +11,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do alias PhoenixKit.Modules.Languages.DialectMapper alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.ListingCache - alias PhoenixKit.Modules.Publishing.Storage # ============================================================================ # Language Detection @@ -37,7 +36,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do {language_param, params} # Unknown language code but content exists for it in this group - # This handles files like af.phk, test.phk, etc. + # This handles content in unknown language codes like "af", "test", etc. group_slug && has_content_for_language?(group_slug, language_param) -> {language_param, params} @@ -122,7 +121,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do true # Check if it looks like a language code pattern (XX or XX-XX format) - # This allows access to unknown files like legacy imports + # This allows access to unknown language codes like legacy imports looks_like_language_code?(code) -> true @@ -205,7 +204,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do end # Now determine if we should use base or full dialect code - Storage.get_display_code(resolved_language, enabled_languages) + Publishing.get_display_code(resolved_language, enabled_languages) end @doc """ @@ -214,7 +213,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do """ def get_canonical_url_language_for_post(post_language) do enabled_languages = get_enabled_languages() - Storage.get_display_code(post_language, enabled_languages) + Publishing.get_display_code(post_language, enabled_languages) end # ============================================================================ @@ -297,7 +296,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Language do end) {:error, _} -> - # Cache miss - fall back to filesystem scan + # Cache miss - fall back to direct DB read posts = Publishing.list_posts(group_slug, nil) Enum.any?(posts, fn post -> diff --git a/lib/modules/publishing/web/controller/listing.ex b/lib/modules/publishing/web/controller/listing.ex index 7c8f0d35..33ae65f7 100644 --- a/lib/modules/publishing/web/controller/listing.ex +++ b/lib/modules/publishing/web/controller/listing.ex @@ -42,7 +42,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Listing do page = get_page_param(params) per_page = get_per_page_setting() - # Try cache first, fall back to filesystem scan + # Try cache first, fall back to DB query all_posts_unfiltered = PostFetching.fetch_posts_with_cache(group_slug) published_posts = filter_published(all_posts_unfiltered) diff --git a/lib/modules/publishing/web/controller/post_fetching.ex b/lib/modules/publishing/web/controller/post_fetching.ex index b9138821..e3e8aa53 100644 --- a/lib/modules/publishing/web/controller/post_fetching.ex +++ b/lib/modules/publishing/web/controller/post_fetching.ex @@ -2,9 +2,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostFetching do @moduledoc """ Post fetching functionality for the publishing controller. - Handles fetching posts from cache and filesystem, including: + Handles fetching posts from cache and database, including: - Slug mode posts (versioned) - - Timestamp mode posts (versioned and legacy) + - Timestamp mode posts - Language fallback logic """ @@ -12,8 +12,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostFetching do alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.ListingCache - alias PhoenixKit.Modules.Publishing.Storage - alias PhoenixKit.Modules.Publishing.Web.Controller.Language # ============================================================================ # Main Fetch Functions @@ -28,185 +26,19 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostFetching do end def fetch_post(group_slug, {:timestamp, date, time}, language) do - # Try cache first for fast lookup (sub-microsecond from :persistent_term) - case fetch_timestamp_post_from_cache(group_slug, date, time, language) do - {:ok, _post} = result -> - result - - {:error, _} -> - # Cache miss - fall back to filesystem scan - post_dir = Path.join([Storage.group_path(group_slug), date, time]) - - case Storage.detect_post_structure(post_dir) do - :versioned -> - fetch_versioned_timestamp_post(group_slug, date, time, language, post_dir) - - :legacy -> - fetch_legacy_timestamp_post(group_slug, date, time, language, post_dir) - - :empty -> - {:error, :post_not_found} - end - end - end - - # ============================================================================ - # Timestamp Mode Post Fetching - # ============================================================================ - - @doc """ - Reads a timestamp post from the database. - """ - def fetch_timestamp_post_from_cache(group_slug, date, time, language) do identifier = "#{date}/#{time}" Publishing.read_post(group_slug, identifier, language) end - # ============================================================================ - # Timestamp Mode Post Fetching (Filesystem) - # ============================================================================ - - @doc """ - Fetch a versioned timestamp post (files in v1/, v2/, etc.). - Iterates from highest version down, returns first published version found. - Falls back to primary language or first available if requested language isn't found. - """ - def fetch_versioned_timestamp_post(group_slug, date, time, language, post_dir) do - versions = list_timestamp_versions(post_dir) |> Enum.sort(:desc) - # Use post's stored primary language for fallback - post_identifier = Path.join(date, time) - primary_language = Storage.get_post_primary_language(group_slug, post_identifier) - - # Find first published version, starting from highest - published_result = - Enum.find_value(versions, fn version -> - version_dir = Path.join(post_dir, "v#{version}") - available_languages = detect_available_languages_in_dir(version_dir) - - # Build priority list of languages to try: - # 1. Resolved version of requested language - # 2. Primary language - # 3. First available language - resolved_language = Language.resolve_language_for_post(language, available_languages) - - languages_to_try = - [resolved_language, primary_language | available_languages] - |> Enum.uniq() - |> Enum.filter(&(&1 in available_languages)) - - Enum.find_value(languages_to_try, fn lang -> - path = "#{group_slug}/#{date}/#{time}/v#{version}/#{lang}.phk" - - case Publishing.read_post(group_slug, path) do - {:ok, post} when post.metadata.status == "published" -> {:ok, post} - _ -> nil - end - end) - end) - - published_result || {:error, :post_not_found} - end - - @doc """ - Fetch a legacy timestamp post (files directly in post directory). - Falls back to primary language or first available if requested language isn't found. - """ - def fetch_legacy_timestamp_post(group_slug, date, time, language, post_dir) do - available_languages = detect_available_languages_in_dir(post_dir) - # Use post's stored primary language for fallback - post_identifier = Path.join(date, time) - primary_language = Storage.get_post_primary_language(group_slug, post_identifier) - resolved_language = Language.resolve_language_for_post(language, available_languages) - - # Build priority list of languages to try - languages_to_try = - [resolved_language, primary_language | available_languages] - |> Enum.uniq() - |> Enum.filter(&(&1 in available_languages)) - - Enum.find_value(languages_to_try, fn lang -> - # Build legacy path: group/date/time/language.phk - path = "#{group_slug}/#{date}/#{time}/#{lang}.phk" - - case Publishing.read_post(group_slug, path) do - {:ok, post} -> {:ok, post} - _ -> nil - end - end) || {:error, :post_not_found} - end - - # ============================================================================ - # Helper Functions - # ============================================================================ - - @doc """ - List version numbers for a timestamp post directory. - """ - def list_timestamp_versions(post_dir) do - case File.ls(post_dir) do - {:ok, entries} -> - entries - |> Enum.filter(&Regex.match?(~r/^v\d+$/, &1)) - |> Enum.map(&(String.replace_prefix(&1, "v", "") |> String.to_integer())) - |> Enum.sort() - - {:error, _} -> - [] - end - end - - @doc """ - Detect available language files in a directory. - """ - def detect_available_languages_in_dir(dir_path) do - case File.ls(dir_path) do - {:ok, files} -> - files - |> Enum.filter(&String.ends_with?(&1, ".phk")) - |> Enum.map(&String.replace_suffix(&1, ".phk", "")) - - {:error, _} -> - [] - end - end - - @doc """ - Detect available languages in a timestamp post directory. - Handles both versioned (files in v1/, v2/) and legacy (files in root) structures. - """ - def detect_available_languages_in_timestamp_dir(post_dir) do - case Storage.detect_post_structure(post_dir) do - :versioned -> - # Get languages from the latest version directory - versions = list_timestamp_versions(post_dir) - - case Enum.max(versions, fn -> nil end) do - nil -> - [] - - latest_version -> - version_dir = Path.join(post_dir, "v#{latest_version}") - detect_available_languages_in_dir(version_dir) - end - - :legacy -> - detect_available_languages_in_dir(post_dir) - - :empty -> - [] - end - end - # ============================================================================ # Cache-Based Listing # ============================================================================ @doc """ - Fetches posts using cache when available, falls back to direct read. + Fetches posts using cache when available, falls back to direct DB read. - In both filesystem and DB modes, tries ListingCache (persistent_term) first - for sub-microsecond reads. On cache miss, regenerates from the appropriate - source (filesystem scan or DB query). + Tries ListingCache (persistent_term) first for sub-microsecond reads. + On cache miss, regenerates from the database. """ def fetch_posts_with_cache(group_slug) do fetch_posts_with_listing_cache(group_slug) @@ -253,10 +85,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostFetching do elapsed_ms = Float.round(elapsed_us / 1000, 1) Logger.info( - "[PublishingController] Cache regeneration in progress for #{group_slug}, using filesystem (#{elapsed_ms}ms)" + "[PublishingController] Cache regeneration in progress for #{group_slug}, using direct DB read (#{elapsed_ms}ms)" ) - # Another request is regenerating, fall back to filesystem + # Another request is regenerating, fall back to direct DB read Publishing.list_posts(group_slug, nil) {:error, reason} -> @@ -264,7 +96,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostFetching do "[PublishingController] Cache regeneration failed for #{group_slug}: #{inspect(reason)}" ) - # Regeneration failed, fall back to filesystem + # Regeneration failed, fall back to direct DB read Publishing.list_posts(group_slug, nil) end end diff --git a/lib/modules/publishing/web/controller/post_rendering.ex b/lib/modules/publishing/web/controller/post_rendering.ex index 62fe30e3..ccb79d54 100644 --- a/lib/modules/publishing/web/controller/post_rendering.ex +++ b/lib/modules/publishing/web/controller/post_rendering.ex @@ -14,7 +14,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.Renderer - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Modules.Publishing.Web.Controller.Language alias PhoenixKit.Modules.Publishing.Web.Controller.Listing alias PhoenixKit.Modules.Publishing.Web.Controller.PostFetching @@ -111,14 +110,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do # Check per-post version access setting (from the live version's metadata) # Each post controls its own version access - no global setting required if post_allows_version_access?(group_slug, internal_slug, language) do - # Resolve language to actual file language (e.g., "en" -> "en-US") - # This matches the behavior in PostFetching.fetch_post - version_dir = Path.join([Storage.group_path(group_slug), internal_slug, "v#{version}"]) - available_languages = PostFetching.detect_available_languages_in_dir(version_dir) - resolved_language = Language.resolve_language_for_post(language, available_languages) - - # Fetch the specific version with resolved language - case Publishing.read_post(group_slug, internal_slug, resolved_language, version) do + # Fetch the specific version - the DB handles language resolution + case Publishing.read_post(group_slug, internal_slug, language, version) do {:ok, post} -> # Check if version is published if post.metadata.status == "published" do @@ -185,7 +178,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do def handle_date_only_url(conn, group_slug, date, language) do case Listing.fetch_group(group_slug) do {:ok, _group} -> - times = Storage.list_times_on_date(group_slug, date) + times = Publishing.list_times_on_date(group_slug, date) case times do [] -> @@ -231,7 +224,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do @doc """ Builds version dropdown data for the public post template. Returns nil if version access is disabled or only one published version exists. - Uses listing cache for fast lookups instead of reading files. + Uses listing cache for fast lookups. """ def build_version_dropdown(group_slug, post, language) do # Try to get cached data first (sub-microsecond from :persistent_term) @@ -277,7 +270,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do @doc """ Gets version info from cache (allow_version_access and live_version). - Falls back to file reads if cache miss. + Falls back to DB reads if cache miss. """ def get_cached_version_info(group_slug, current_post) do # Use appropriate cache lookup based on post mode @@ -291,16 +284,16 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do {allow_access, live_version} {:error, _} -> - # Cache miss - fall back to file reads + # Cache miss - fall back to DB reads post_identifier = get_post_identifier(current_post) # Use post's stored primary language, not global primary_language = current_post[:primary_language] || - Storage.get_post_primary_language(group_slug, post_identifier) + Publishing.get_post_primary_language(group_slug, post_identifier) - allow_access = get_allow_access_from_file(group_slug, current_post, primary_language) - live_version = get_live_version_from_file(group_slug, post_identifier) + allow_access = get_allow_access_from_db(group_slug, current_post, primary_language) + live_version = get_live_version_from_db(group_slug, post_identifier) {allow_access, live_version} end end @@ -352,8 +345,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do defp extract_timestamp_identifier(path), do: path - # Fallback: Gets allow_version_access from file when cache misses - defp get_allow_access_from_file(group_slug, current_post, primary_language) do + # Fallback: Gets allow_version_access from DB when cache misses + defp get_allow_access_from_db(group_slug, current_post, primary_language) do if current_post.language == primary_language do Map.get(current_post.metadata, :allow_version_access, false) else @@ -366,9 +359,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do end end - # Fallback: Gets published version from file when cache misses - defp get_live_version_from_file(group_slug, post_identifier) do - case Storage.get_published_version(group_slug, post_identifier) do + # Fallback: Gets published version from DB when cache misses + defp get_live_version_from_db(group_slug, post_identifier) do + case Publishing.get_published_version(group_slug, post_identifier) do {:ok, version} -> version {:error, _} -> nil end @@ -380,7 +373,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.PostRendering do """ def post_allows_version_access?(group_slug, post_slug, _language) do # Always read from post's stored primary language to ensure per-post behavior - primary_language = Storage.get_post_primary_language(group_slug, post_slug) + primary_language = Publishing.get_post_primary_language(group_slug, post_slug) # Read the live version (version: nil means get latest/live) case Publishing.read_post(group_slug, post_slug, primary_language, nil) do diff --git a/lib/modules/publishing/web/controller/slug_resolution.ex b/lib/modules/publishing/web/controller/slug_resolution.ex index 1746101c..c97ea456 100644 --- a/lib/modules/publishing/web/controller/slug_resolution.ex +++ b/lib/modules/publishing/web/controller/slug_resolution.ex @@ -5,12 +5,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution do Handles resolving URL slugs to internal slugs, including: - Per-language custom URL slugs - Previous URL slugs for 301 redirects - - Filesystem fallback when cache is unavailable + - DB-based slug lookups """ alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Metadata - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML # ============================================================================ @@ -18,7 +16,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution do # ============================================================================ @doc """ - Resolves URL slug to internal slug using cache. + Resolves URL slug to internal slug using cache/DB. Returns: - `{:redirect, url}` for 301 redirect to new URL @@ -70,176 +68,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.SlugResolution do cached_post.slug || cached_post[:slug] {:error, _} -> - # Fallback: try filesystem scan for custom slug - case find_internal_slug_from_filesystem(group_slug, url_slug, language) do - {:ok, internal_slug} -> internal_slug - {:error, _} -> url_slug - end - end - end - - # ============================================================================ - # Filesystem Fallback - # ============================================================================ - - @doc """ - Filesystem fallback for URL slug resolution when cache is unavailable. - Also handles 301 redirects for previous_url_slugs. - """ - def resolve_url_slug_from_filesystem(group_slug, url_slug, language) do - case find_slug_in_filesystem(group_slug, url_slug, language) do - {:current, internal_slug} when internal_slug != url_slug -> - # Found as current url_slug - resolve to internal slug - {:ok, {:slug, internal_slug}} - - {:current, _same_slug} -> - # URL slug matches internal slug - passthrough - :passthrough - - {:previous, internal_slug, current_url_slug} -> - # Found in previous_url_slugs - redirect to current URL - redirect_url = - build_redirect_url_from_slugs(group_slug, internal_slug, language, current_url_slug) - - {:redirect, redirect_url} - - {:error, _} -> - # Not found - passthrough for normal handling - :passthrough - end - end - - @doc """ - Scans filesystem to find a post with matching url_slug or previous_url_slugs. - - Returns: - - `{:current, internal_slug}` - found as current url_slug - - `{:previous, internal_slug, current_url_slug}` - found in previous_url_slugs (for redirect) - - `{:error, reason}` - not found - """ - def find_slug_in_filesystem(group_slug, url_slug, language) do - group_path = Storage.group_path(group_slug) - - with true <- File.dir?(group_path), - dirs <- File.ls!(group_path), - result when not is_nil(result) <- - scan_posts_for_slug(group_path, dirs, url_slug, language) do - result - else - false -> {:error, :group_not_found} - nil -> {:error, :not_found} - end - rescue - _ -> {:error, :scan_failed} - end - - # ============================================================================ - # Slug Scanning Helpers - # ============================================================================ - - defp scan_posts_for_slug(group_path, dirs, url_slug, language) do - Enum.find_value(dirs, fn post_dir -> - post_path = Path.join(group_path, post_dir) - - if File.dir?(post_path) do - check_post_for_slug(post_path, post_dir, url_slug, language) - end - end) - end - - defp check_post_for_slug(post_path, post_dir, url_slug, language) do - case read_slug_data_from_post(post_path, language) do - {:ok, current_slug, previous_slugs} -> - match_slug_data(post_dir, url_slug, current_slug, previous_slugs) - - _ -> - nil - end - end - - defp match_slug_data(post_dir, url_slug, current_slug, previous_slugs) do - cond do - current_slug == url_slug -> - {:current, post_dir} - - url_slug in (previous_slugs || []) -> - {:previous, post_dir, current_slug || post_dir} - - true -> - nil - end - end - - # Legacy function for resolve_url_slug_to_internal (only needs current slug) - defp find_internal_slug_from_filesystem(group_slug, url_slug, language) do - case find_slug_in_filesystem(group_slug, url_slug, language) do - {:current, internal_slug} -> {:ok, internal_slug} - {:previous, internal_slug, _current} -> {:ok, internal_slug} - {:error, reason} -> {:error, reason} - end - end - - # ============================================================================ - # Slug Data Reading - # ============================================================================ - - @doc """ - Reads url_slug and previous_url_slugs from a post's language file metadata. - """ - def read_slug_data_from_post(post_path, language) do - # Try versioned structure first, then legacy - content_dir = - case find_latest_version_dir(post_path) do - {:ok, version_dir} -> version_dir - {:error, _} -> post_path - end - - file_path = Path.join(content_dir, "#{language}.phk") - - if File.exists?(file_path) do - case File.read(file_path) do - {:ok, content} -> - case Metadata.parse_with_content(content) do - {:ok, metadata, _content} -> - url_slug = Map.get(metadata, :url_slug) - previous_slugs = Map.get(metadata, :previous_url_slugs) || [] - {:ok, url_slug, previous_slugs} - - _ -> - {:error, :parse_failed} - end - - _ -> - {:error, :read_failed} - end - else - {:error, :file_not_found} - end - end - - @doc """ - Finds the latest version directory in a versioned post structure. - """ - def find_latest_version_dir(post_path) do - versions = - post_path - |> File.ls!() - |> Enum.filter(&String.starts_with?(&1, "v")) - |> Enum.map(fn dir -> - case Integer.parse(String.trim_leading(dir, "v")) do - {num, ""} -> num - _ -> nil - end - end) - |> Enum.reject(&is_nil/1) - |> Enum.sort(:desc) - - case versions do - [latest | _] -> {:ok, Path.join(post_path, "v#{latest}")} - [] -> {:error, :no_versions} + # Not found in cache/DB - use as-is + url_slug end - rescue - _ -> {:error, :scan_failed} end # ============================================================================ diff --git a/lib/modules/publishing/web/controller/translations.ex b/lib/modules/publishing/web/controller/translations.ex index a92ef8ed..ffdc1eb0 100644 --- a/lib/modules/publishing/web/controller/translations.ex +++ b/lib/modules/publishing/web/controller/translations.ex @@ -9,8 +9,8 @@ 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.ListingCache - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Modules.Publishing.Web.Controller.Language alias PhoenixKit.Modules.Publishing.Web.Controller.PostRendering alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML @@ -48,7 +48,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Translations do end) |> Enum.map(fn lang -> # Use display_code helper to determine if we show base or full code - display_code = Storage.get_display_code(lang, enabled_languages) + display_code = Publishing.get_display_code(lang, enabled_languages) %{ code: display_code, @@ -63,9 +63,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Translations do # Order: primary first, then the rest alphabetically if Enum.any?( translations, - &(&1.code == Storage.get_display_code(primary_language, enabled_languages)) + &(&1.code == Publishing.get_display_code(primary_language, enabled_languages)) ) do - primary_display = Storage.get_display_code(primary_language, enabled_languages) + primary_display = Publishing.get_display_code(primary_language, enabled_languages) {primary, others} = Enum.split_with(translations, &(&1.code == primary_display)) primary ++ Enum.sort_by(others, & &1.code) else @@ -135,7 +135,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Translations do Enum.map(languages, fn lang -> # Use display_code helper to determine if we show base or full code - display_code = Storage.get_display_code(lang, enabled_languages) + display_code = Publishing.get_display_code(lang, enabled_languages) is_enabled = language_enabled_for_public?(lang, enabled_languages) is_known = Languages.get_predefined_language(lang) != nil @@ -273,7 +273,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller.Translations do end # Checks if the exact language file exists and is published - # Uses preloaded language_statuses map to avoid redundant file reads + # Uses preloaded language_statuses map to avoid redundant DB reads defp translation_published_exact?(_group_slug, post, language) do language in (post.available_languages || []) and Map.get(post.language_statuses, language) == "published" diff --git a/lib/modules/publishing/web/edit.html.heex b/lib/modules/publishing/web/edit.html.heex index 71dc0f8b..6559483b 100644 --- a/lib/modules/publishing/web/edit.html.heex +++ b/lib/modules/publishing/web/edit.html.heex @@ -75,7 +75,7 @@

- {gettext("Storage mode")}: + {gettext("URL mode")}: <%= case @group["mode"] do %> <% "slug" -> %> {gettext("Slug-based")} Β· {gettext( diff --git a/lib/modules/publishing/web/editor.ex b/lib/modules/publishing/web/editor.ex index 2c33dba0..346cfc34 100644 --- a/lib/modules/publishing/web/editor.ex +++ b/lib/modules/publishing/web/editor.ex @@ -28,7 +28,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.Metadata alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes @@ -228,7 +227,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do case Publishing.read_post_by_uuid(post_uuid, language, version) do {:ok, post} -> - all_enabled_languages = Storage.enabled_language_codes() + all_enabled_languages = Publishing.enabled_language_codes() old_form_key = socket.assigns[:form_key] old_post_slug = socket.assigns[:post] && socket.assigns.post[:slug] @@ -277,7 +276,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do case Publishing.read_post(group_slug, path) do {:ok, post} -> - all_enabled_languages = Storage.enabled_language_codes() + all_enabled_languages = Publishing.enabled_language_codes() requested_lang = Map.get(params, "lang") old_form_key = socket.assigns[:form_key] @@ -324,8 +323,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do defp handle_new_post(socket) do group_slug = socket.assigns.group_slug group_mode = Publishing.get_group_mode(group_slug) - all_enabled_languages = Storage.enabled_language_codes() - primary_language = Storage.get_primary_language() + all_enabled_languages = Publishing.enabled_language_codes() + primary_language = Publishing.get_primary_language() now = UtilsDate.utc_now() |> DateTime.truncate(:second) |> Forms.floor_datetime_to_minute() virtual_post = Helpers.build_virtual_post(group_slug, group_mode, primary_language, now) @@ -903,7 +902,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor do post = socket.assigns.post if post do - primary_language = Storage.get_primary_language() + primary_language = Publishing.get_primary_language() language_name = Helpers.get_language_name(primary_language) post_identifier = post.slug || post[:uuid] diff --git a/lib/modules/publishing/web/editor.html.heex b/lib/modules/publishing/web/editor.html.heex index f2394e93..156eeac2 100644 --- a/lib/modules/publishing/web/editor.html.heex +++ b/lib/modules/publishing/web/editor.html.heex @@ -250,7 +250,7 @@ <% end %> <%!-- Version Switcher (for versioned posts in both slug and timestamp modes) --%> - <%= if !@is_new_post && @post && !Map.get(@post, :is_legacy_structure, true) do %> + <%= if !@is_new_post && @post do %>

<%= if length(@available_versions) > 1 do %> {gettext("Version:")} @@ -313,10 +313,10 @@ <.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<%= if not @current_language_known do %> - {gettext("Unknown language file:")} + {gettext("Unknown language:")} {gettext( - "This file (%{code}.phk) doesn't match a recognized language code. The publishing status will still be respected.", + "This language (%{code}) doesn't match a recognized language code. The publishing status will still be respected.", code: @current_language )} @@ -333,7 +333,6 @@
<% end %> - <%!-- Legacy structure migration banner --%> <%!-- Primary Language Needs Update Warning (shown on ALL languages when update needed) --%> <% needs_update = not @is_new_post and length(@all_enabled_languages) > 1 and @@ -928,45 +927,6 @@

<% end %> - - <%!-- Version Access Toggle - DISABLED: Boss doesn't want public version history - <% is_versioned_post = @post && not Map.get(@post, :is_legacy_structure, true) %> - <% can_toggle_version_access = - is_versioned_post && @is_primary_language && not @viewing_older_version %> - <%= if can_toggle_version_access do %> -
-
-
- - {gettext("Show Version History")} - -

- {gettext("Display a version dropdown on the public post")} -

-
- -
-
- <% end %> - --%>
diff --git a/lib/modules/publishing/web/editor/forms.ex b/lib/modules/publishing/web/editor/forms.ex index 89dc40eb..bf7be80a 100644 --- a/lib/modules/publishing/web/editor/forms.ex +++ b/lib/modules/publishing/web/editor/forms.ex @@ -8,7 +8,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.Metadata - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Slug @@ -28,13 +27,13 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do @doc """ Build form for a post, handling new translations appropriately. - For new translations (no file on disk yet), inherits status from the primary language. + 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 what the dropdown shows and what the language switcher shows. """ def post_form_with_primary_status(group_slug, post, version) do form = post_form(post) - primary_language = post[:primary_language] || Storage.get_primary_language() + primary_language = post[:primary_language] || Publishing.get_primary_language() original_language = post[:original_language] || post.language is_new_translation = Map.get(post, :is_new_translation, false) @@ -356,7 +355,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Forms do title = Metadata.extract_title_from_content(content) current_slug = socket.assigns.post.slug || Map.get(socket.assigns.form, "slug", "") - case Storage.generate_unique_slug(socket.assigns.group_slug, title, nil, + case Publishing.generate_unique_slug(socket.assigns.group_slug, title, nil, current_slug: current_slug ) do {:ok, ""} -> diff --git a/lib/modules/publishing/web/editor/helpers.ex b/lib/modules/publishing/web/editor/helpers.ex index 6f737ac2..6c0271cf 100644 --- a/lib/modules/publishing/web/editor/helpers.ex +++ b/lib/modules/publishing/web/editor/helpers.ex @@ -7,7 +7,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do """ alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Modules.Publishing.Web.Editor.Translation alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML alias PhoenixKit.Modules.Storage.URLSigner @@ -24,11 +23,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do enabled_languages = socket.assigns[:all_enabled_languages] || [] lang_info = Publishing.get_language_info(language_code) post_primary = socket.assigns[:post] && socket.assigns.post[:primary_language] - primary_language = post_primary || Storage.get_primary_language() + primary_language = post_primary || Publishing.get_primary_language() is_primary = language_code == primary_language # Check if this post's primary language matches the global setting - global_primary = Storage.get_primary_language() + global_primary = Publishing.get_primary_language() primary_lang_status = cond do @@ -49,7 +48,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do |> Phoenix.Component.assign(:global_primary_language_name, global_primary_language_name) |> Phoenix.Component.assign( :current_language_enabled, - Storage.language_enabled?(language_code, enabled_languages) + Publishing.language_enabled?(language_code, enabled_languages) ) |> Phoenix.Component.assign(:current_language_known, lang_info != nil) |> Phoenix.Component.assign(:is_primary_language, is_primary) @@ -93,17 +92,17 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do def editor_language(assigns) do assigns[:current_language] || assigns |> Map.get(:post, %{}) |> Map.get(:language) || - hd(Storage.enabled_language_codes()) + hd(Publishing.enabled_language_codes()) end @doc """ Builds language data for the publishing_language_switcher component. """ def build_editor_languages(post, _group_slug, enabled_languages, current_language) do - post_primary = post[:primary_language] || Storage.get_primary_language() + post_primary = post[:primary_language] || Publishing.get_primary_language() all_languages = - Storage.order_languages_for_display( + Publishing.order_languages_for_display( post.available_languages || [], enabled_languages, post_primary @@ -115,12 +114,12 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do lang_info = Publishing.get_language_info(lang_code) file_exists = lang_code in (post.available_languages || []) is_current = lang_code == current_language - is_enabled = Storage.language_enabled?(lang_code, enabled_languages) + is_enabled = Publishing.language_enabled?(lang_code, enabled_languages) is_known = lang_info != nil is_primary = lang_code == post_primary status = Map.get(language_statuses, lang_code) - display_code = Storage.get_display_code(lang_code, enabled_languages) + display_code = Publishing.get_display_code(lang_code, enabled_languages) %{ code: lang_code, @@ -214,8 +213,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do language: primary_language, available_languages: [], mode: :slug, - slug: nil, - is_legacy_structure: false + slug: nil } end @@ -247,8 +245,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Helpers do content: "", language: primary_language, available_languages: [], - mode: :timestamp, - is_legacy_structure: false + mode: :timestamp } end diff --git a/lib/modules/publishing/web/editor/persistence.ex b/lib/modules/publishing/web/editor/persistence.ex index e7bcbb91..e1f61c36 100644 --- a/lib/modules/publishing/web/editor/persistence.ex +++ b/lib/modules/publishing/web/editor/persistence.ex @@ -13,7 +13,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Modules.Publishing.Renderer - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Modules.Publishing.Web.Editor.Forms alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers @@ -69,7 +68,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do language = editor_language(socket.assigns) post_slug = socket.assigns.post.slug || socket.assigns.post[:uuid] - case Storage.validate_url_slug(group_slug, url_slug, language, post_slug) do + case Publishing.validate_url_slug(group_slug, url_slug, language, post_slug) do {:ok, _} -> {:ok, params} @@ -138,7 +137,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do is_new_post = Map.get(socket.assigns, :is_new_post, false) is_new_translation = Map.get(socket.assigns, :is_new_translation, false) - # Check if translation file was created in background + # Check if translation was created in background {socket, is_new_translation} = if is_new_translation do check_background_translation_creation(socket) @@ -206,7 +205,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do result = case Publishing.update_post(socket.assigns.group_slug, new_post, params, %{scope: scope}) do {:ok, updated_post} -> - # Preserve UUID from create_post (update_post returns FS post without it) + # Preserve UUID from create_post (update_post may not include it) {:ok, if(uuid, do: Map.put(updated_post, :uuid, uuid), else: updated_post)} error -> @@ -285,8 +284,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do # Check if we need to create a new version should_create_version = - not Map.get(post, :is_legacy_structure, false) and - Storage.should_create_new_version?(post, params, language) + Publishing.should_create_new_version?(post, params, language) if should_create_version do create_new_version_from_edit(socket, params, scope) @@ -348,7 +346,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do # not the stale language from when the post was initially loaded post = %{socket.assigns.post | language: socket.assigns.current_language} current_version = socket.assigns[:current_version] - # Use saved_status (on-disk status) not post.metadata.status (form-updated status) + # Use saved_status (stored status) not post.metadata.status (form-updated status) saved_status = socket.assigns[:saved_status] || Map.get(post.metadata, :status, "draft") is_primary_language = socket.assigns[:is_primary_language] == true @@ -397,12 +395,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do end end - defp should_publish_version?(is_primary, new_status, current_status, current_version, post) do + defp should_publish_version?(is_primary, new_status, current_status, current_version, _post) do is_primary and new_status == "published" and current_status != "published" and - current_version != nil and - not Map.get(post, :is_legacy_structure, false) + current_version != nil end # ============================================================================ @@ -719,7 +716,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Persistence do defp editor_language(assigns) do assigns[:current_language] || assigns |> Map.get(:post, %{}) |> Map.get(:language) || - hd(Storage.enabled_language_codes()) + hd(Publishing.enabled_language_codes()) end # ============================================================================ diff --git a/lib/modules/publishing/web/editor/preview.ex b/lib/modules/publishing/web/editor/preview.ex index e4731f75..07663be4 100644 --- a/lib/modules/publishing/web/editor/preview.ex +++ b/lib/modules/publishing/web/editor/preview.ex @@ -9,7 +9,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Preview do require Logger alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Modules.Publishing.Web.Editor.Forms alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers alias PhoenixKit.Utils.Routes @@ -178,16 +177,16 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Preview do metadata = normalize_preview_metadata(data[:metadata] || %{}, mode) post = build_preview_post(data, group_slug, mode, language, metadata) - {post, disk_post} = enrich_from_disk(post, group_slug) - form = build_preview_form(metadata, mode, disk_post) + {post, db_post} = enrich_from_db(post, group_slug) + form = build_preview_form(metadata, mode, db_post) - apply_preview_assigns(socket, post, form, group_slug, mode, data, disk_post) + apply_preview_assigns(socket, post, form, group_slug, mode, data, db_post) end defp build_preview_post(data, group_slug, mode, language, metadata) do {date, time} = derive_datetime_fields(mode, metadata[:published_at]) path = data[:path] || derive_preview_path(group_slug, metadata[:slug], language, mode) - full_path = if path, do: Storage.absolute_path(path), else: nil + full_path = nil available_languages = data[:available_languages] || [] available_languages = @@ -204,12 +203,11 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Preview do content: data[:content] || "", language: language, available_languages: available_languages, - mode: mode, - is_legacy_structure: false + mode: mode } end - defp build_preview_form(metadata, mode, disk_post) do + defp build_preview_form(metadata, mode, db_post) do %{ "title" => metadata[:title] || "", "status" => metadata[:status] || "draft", @@ -218,25 +216,25 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Preview do "url_slug" => metadata[:url_slug] || "" } |> maybe_put_form_slug(metadata[:slug], mode) - |> supplement_form_from_disk(disk_post) + |> supplement_form_from_db(db_post) |> Forms.normalize_form() end - # Fill in any form fields that are empty with on-disk values - defp supplement_form_from_disk(form, nil), do: form + # Fill in any form fields that are empty with DB-stored values + defp supplement_form_from_db(form, nil), do: form - defp supplement_form_from_disk(form, disk_post) do + defp supplement_form_from_db(form, db_post) do Enum.reduce( [ - {"featured_image_uuid", Map.get(disk_post.metadata, :featured_image_uuid)}, - {"url_slug", Map.get(disk_post.metadata, :url_slug) || Map.get(disk_post, :url_slug)} + {"featured_image_uuid", Map.get(db_post.metadata, :featured_image_uuid)}, + {"url_slug", Map.get(db_post.metadata, :url_slug) || Map.get(db_post, :url_slug)} ], form, - fn {key, disk_value}, acc -> + fn {key, db_value}, acc -> current = Map.get(acc, key, "") - if current in [nil, ""] and disk_value not in [nil, ""] do - Map.put(acc, key, to_string(disk_value)) + if current in [nil, ""] and db_value not in [nil, ""] do + Map.put(acc, key, to_string(db_value)) else acc end @@ -244,18 +242,18 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Preview do ) end - defp apply_preview_assigns(socket, post, form, group_slug, mode, data, disk_post) do + defp apply_preview_assigns(socket, post, form, group_slug, mode, data, db_post) do language = post.language has_changes = - case disk_post do + case db_post do nil -> true dp -> Forms.dirty?(dp, form, data[:content] || "") end - # Derive on-disk status for save logic (saved_status tracks what's actually on disk) + # Derive saved status for save logic (saved_status tracks what's actually stored) {saved_status, editing_published} = - case disk_post do + case db_post do nil -> {"draft", false} @@ -271,7 +269,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Preview do |> Forms.assign_form_with_tracking(form, slug_manually_set: false) |> Phoenix.Component.assign(:content, data[:content] || "") |> Phoenix.Component.assign(:available_languages, post.available_languages) - |> Phoenix.Component.assign(:all_enabled_languages, Storage.enabled_language_codes()) + |> Phoenix.Component.assign(:all_enabled_languages, Publishing.enabled_language_codes()) |> Helpers.assign_current_language(language) |> Phoenix.Component.assign(:has_pending_changes, has_changes) |> Phoenix.Component.assign(:is_new_post, data[:is_new_post] || false) @@ -286,34 +284,33 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Preview do |> Phoenix.Component.assign(:saved_status, saved_status) end - defp enrich_from_disk(post, group_slug) do + defp enrich_from_db(post, group_slug) do if post.path do case Publishing.read_post(group_slug, post.path) do - {:ok, disk_post} -> - # Merge disk metadata as base, with preview metadata on top. + {:ok, db_post} -> + # Merge DB metadata as base, with preview metadata on top. # This preserves non-form fields (description, created_at, version_created_at, # previous_url_slugs, etc.) that aren't carried in the preview token, # preventing silent data loss if the user saves after returning from preview. preview_meta_values = post.metadata |> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Map.new() - merged_metadata = Map.merge(disk_post.metadata, preview_meta_values) + merged_metadata = Map.merge(db_post.metadata, preview_meta_values) enriched = post |> Map.put(:metadata, merged_metadata) - |> Map.put(:language_statuses, Map.get(disk_post, :language_statuses, %{})) - |> Map.put(:available_versions, Map.get(disk_post, :available_versions, [])) - |> Map.put(:version_statuses, Map.get(disk_post, :version_statuses, %{})) - |> Map.put(:version_dates, Map.get(disk_post, :version_dates, %{})) - |> Map.put(:version, Map.get(disk_post, :version)) - |> Map.put(:primary_language, Map.get(disk_post, :primary_language)) - |> Map.put(:is_legacy_structure, Map.get(disk_post, :is_legacy_structure, false)) + |> Map.put(:language_statuses, Map.get(db_post, :language_statuses, %{})) + |> Map.put(:available_versions, Map.get(db_post, :available_versions, [])) + |> Map.put(:version_statuses, Map.get(db_post, :version_statuses, %{})) + |> Map.put(:version_dates, Map.get(db_post, :version_dates, %{})) + |> Map.put(:version, Map.get(db_post, :version)) + |> Map.put(:primary_language, Map.get(db_post, :primary_language)) - {enriched, disk_post} + {enriched, db_post} {:error, reason} -> - Logger.debug("Preview enrich_from_disk failed for #{post.path}: #{inspect(reason)}") + Logger.debug("Preview enrich_from_db failed for #{post.path}: #{inspect(reason)}") fallback_status = Map.get(post.metadata, :status, "draft") enriched = Map.put(post, :language_statuses, %{post.language => fallback_status}) {enriched, nil} diff --git a/lib/modules/publishing/web/editor/translation.ex b/lib/modules/publishing/web/editor/translation.ex index 7d8bed70..3468bf0d 100644 --- a/lib/modules/publishing/web/editor/translation.ex +++ b/lib/modules/publishing/web/editor/translation.ex @@ -10,7 +10,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Translation do alias PhoenixKit.Modules.AI alias PhoenixKit.Modules.Publishing - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Modules.Publishing.Workers.TranslatePostWorker alias PhoenixKit.Settings @@ -62,10 +61,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Translation do def get_target_languages_for_translation(socket) do post = socket.assigns.post # Use post's stored primary language for translation source - primary_language = post[:primary_language] || Storage.get_primary_language() + primary_language = post[:primary_language] || Publishing.get_primary_language() available_languages = post.available_languages || [] - Storage.enabled_language_codes() + Publishing.enabled_language_codes() |> Enum.reject(&(&1 == primary_language or &1 in available_languages)) end @@ -75,9 +74,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Translation do def get_all_target_languages(socket) do post = socket.assigns.post # Use post's stored primary language to exclude from targets - primary_language = post[:primary_language] || Storage.get_primary_language() + primary_language = post[:primary_language] || Publishing.get_primary_language() - Storage.enabled_language_codes() + Publishing.enabled_language_codes() |> Enum.reject(&(&1 == primary_language)) end @@ -136,7 +135,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Translation do source_language = post[:primary_language] || socket.assigns[:current_language] || - Storage.get_primary_language() + Publishing.get_primary_language() # For timestamp mode, use date/time identifier; for slug mode, use slug post_identifier = @@ -174,7 +173,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Translation do defp translation_success_socket(socket, target_languages) do lang_names = Enum.map_join(target_languages, ", ", fn code -> - info = Storage.get_language_info(code) + info = Publishing.get_language_info(code) if info, do: info.name, else: code end) @@ -244,7 +243,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Translation do defp format_language_names(language_codes) do Enum.map_join(language_codes, ", ", fn code -> - info = Storage.get_language_info(code) + info = Publishing.get_language_info(code) if info, do: info.name, else: code end) end @@ -259,7 +258,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Translation do source_language = post[:primary_language] || socket.assigns[:current_language] || - Storage.get_primary_language() + Publishing.get_primary_language() current_version = socket.assigns[:current_version] @@ -275,7 +274,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Translation do _ -> post.slug end - # Read the source language content from disk + # Read the source language content from the database case Publishing.read_post(group_slug, post_identifier, source_language, current_version) do {:ok, source_post} -> content = source_post.content || "" diff --git a/lib/modules/publishing/web/editor/versions.ex b/lib/modules/publishing/web/editor/versions.ex index 9d668e27..e2fbf10a 100644 --- a/lib/modules/publishing/web/editor/versions.ex +++ b/lib/modules/publishing/web/editor/versions.ex @@ -10,7 +10,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Versions do alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers # ============================================================================ @@ -25,7 +24,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Versions do post = socket.assigns.post language = socket.assigns.current_language # Use the post's stored primary language for fallback, not global - primary_language = post[:primary_language] || Storage.get_primary_language() + primary_language = post[:primary_language] || Publishing.get_primary_language() read_fn = if socket.assigns.group_mode == "slug" do @@ -46,7 +45,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Versions do # Extract timestamp identifier from current post path timestamp_id = extract_timestamp_identifier(post.path) - # Use Publishing.read_post which has DB fallback for imported groups + # Use Publishing.read_post to fetch the version from the database Publishing.read_post(group_slug, timestamp_id, language, version) end @@ -273,6 +272,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Editor.Versions do defp editor_language(assigns) do assigns[:current_language] || assigns |> Map.get(:post, %{}) |> Map.get(:language) || - hd(Storage.enabled_language_codes()) + hd(Publishing.enabled_language_codes()) end end diff --git a/lib/modules/publishing/web/html.ex b/lib/modules/publishing/web/html.ex index ba7bf825..50cf7d97 100644 --- a/lib/modules/publishing/web/html.ex +++ b/lib/modules/publishing/web/html.ex @@ -6,8 +6,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do alias PhoenixKit.Config alias PhoenixKit.Modules.Languages + alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.Renderer - alias PhoenixKit.Modules.Publishing.Storage, as: PublishingStorage + alias PhoenixKit.Modules.Storage # Import publishing-specific components for templates import PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher @@ -405,7 +406,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do # Counts posts on a given date for a group # Used to determine if time should be included in URLs defp count_posts_on_date(group_slug, date) do - PublishingStorage.count_posts_on_date(group_slug, date) + Publishing.count_posts_on_date(group_slug, date) end @doc """ @@ -421,8 +422,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.HTML do defp resolve_featured_image_url("", _variant), do: nil defp resolve_featured_image_url(file_uuid, variant) when is_binary(file_uuid) do - PhoenixKit.Modules.Storage.get_public_url_by_uuid(file_uuid, variant) || - PhoenixKit.Modules.Storage.get_public_url_by_uuid(file_uuid) + Storage.get_public_url_by_uuid(file_uuid, variant) || + Storage.get_public_url_by_uuid(file_uuid) rescue _ -> nil end diff --git a/lib/modules/publishing/web/index.ex b/lib/modules/publishing/web/index.ex index e0fee3ac..0e9d1a41 100644 --- a/lib/modules/publishing/web/index.ex +++ b/lib/modules/publishing/web/index.ex @@ -9,8 +9,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.DBStorage alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Storage - alias PhoenixKit.Modules.Publishing.Workers.MigrateLegacyStructureWorker alias PhoenixKit.Modules.Publishing.Workers.MigratePrimaryLanguageWorker alias PhoenixKit.Settings alias PhoenixKit.Utils.Date, as: UtilsDate @@ -62,21 +60,15 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do |> assign(:dashboard_insights, insights) |> assign(:dashboard_summary, summary) |> assign(:empty_state?, groups == []) - |> assign(:enabled_languages, Storage.enabled_language_codes()) + |> assign(:enabled_languages, Publishing.enabled_language_codes()) |> assign(:endpoint_url, nil) |> assign(:date_time_settings, date_time_settings) |> assign(:show_migration_modal, false) |> assign(:migration_modal_slug, nil) |> assign(:migration_modal_name, nil) |> assign(:migration_modal_count, 0) - |> assign(:primary_language_name, get_language_name(Storage.get_primary_language())) + |> assign(:primary_language_name, get_language_name(Publishing.get_primary_language())) |> assign(:migrations_in_progress, %{}) - # Version structure migration assigns - |> assign(:show_version_migration_modal, false) - |> assign(:version_migration_modal_slug, nil) - |> assign(:version_migration_modal_name, nil) - |> assign(:version_migration_modal_count, 0) - |> assign(:version_migrations_in_progress, %{}) {:ok, socket} end @@ -181,65 +173,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do {:noreply, socket} end - # Legacy structure (version) migration progress handlers - def handle_info({:legacy_structure_migration_started, _group_slug, _total_count}, socket) do - # Already tracked in version_migrations_in_progress when job was enqueued - {:noreply, socket} - end - - def handle_info({:legacy_structure_migration_progress, group_slug, current, total}, socket) do - # Update progress for the specific group - migrations = - if Map.has_key?(socket.assigns.version_migrations_in_progress, group_slug) do - put_in( - socket.assigns.version_migrations_in_progress, - [group_slug], - %{current: current, total: total} - ) - else - # Migration started elsewhere, add it - Map.put(socket.assigns.version_migrations_in_progress, group_slug, %{ - current: current, - total: total - }) - end - - {:noreply, assign(socket, :version_migrations_in_progress, migrations)} - end - - def handle_info( - {:legacy_structure_migration_completed, group_slug, success_count, error_count}, - socket - ) do - # Remove the completed migration from in-progress - migrations = Map.delete(socket.assigns.version_migrations_in_progress, group_slug) - - socket = - socket - |> assign(:version_migrations_in_progress, migrations) - |> refresh_dashboard() - - socket = - if error_count > 0 do - put_flash( - socket, - :warning, - gettext("Version migration completed: %{success} succeeded, %{errors} failed", - success: success_count, - errors: error_count - ) - ) - else - put_flash( - socket, - :info, - gettext("Migrated %{count} posts to versioned structure", count: success_count) - ) - end - - {:noreply, socket} - end - @impl true def handle_event( "show_migration_modal", @@ -263,90 +196,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do |> assign(:migration_modal_count, 0)} end - # Version migration modal events - def handle_event( - "show_version_migration_modal", - %{"slug" => group_slug, "name" => group_name, "count" => count}, - socket - ) do - {:noreply, - socket - |> assign(:show_version_migration_modal, true) - |> assign(:version_migration_modal_slug, group_slug) - |> assign(:version_migration_modal_name, group_name) - |> assign(:version_migration_modal_count, String.to_integer(count))} - end - - def handle_event("close_version_migration_modal", _params, socket) do - {:noreply, - socket - |> assign(:show_version_migration_modal, false) - |> assign(:version_migration_modal_slug, nil) - |> assign(:version_migration_modal_name, nil) - |> assign(:version_migration_modal_count, 0)} - end - - def handle_event("confirm_migrate_to_versioned", _params, socket) do - group_slug = socket.assigns.version_migration_modal_slug - total_count = socket.assigns.version_migration_modal_count - - # Use background job for large migrations - if total_count > @migration_async_threshold do - # Subscribe to this group's posts for progress updates - PublishingPubSub.subscribe_to_posts(group_slug) - - case MigrateLegacyStructureWorker.enqueue(group_slug) do - {:ok, _job} -> - migrations = - Map.put(socket.assigns.version_migrations_in_progress, group_slug, %{ - current: 0, - total: total_count - }) - - {:noreply, - socket - |> assign(:show_version_migration_modal, false) - |> assign(:version_migration_modal_slug, nil) - |> assign(:version_migration_modal_name, nil) - |> assign(:version_migration_modal_count, 0) - |> assign(:version_migrations_in_progress, migrations) - |> put_flash( - :info, - gettext("Version migration started for %{count} posts. You can continue working.", - count: total_count - ) - )} - - {:error, reason} -> - {:noreply, - socket - |> assign(:show_version_migration_modal, false) - |> put_flash( - :error, - gettext("Failed to start migration: %{reason}", reason: inspect(reason)) - )} - end - else - # Synchronous migration for small counts - {:ok, count} = Publishing.migrate_posts_to_versioned_structure(group_slug) - - {:noreply, - socket - |> assign(:show_version_migration_modal, false) - |> assign(:version_migration_modal_slug, nil) - |> assign(:version_migration_modal_name, nil) - |> assign(:version_migration_modal_count, 0) - |> refresh_dashboard() - |> put_flash( - :info, - gettext("Migrated %{count} posts to versioned structure", count: count) - )} - end - end - def handle_event("confirm_migrate_primary_language", _params, socket) do group_slug = socket.assigns.migration_modal_slug - primary_language = Storage.get_primary_language() + primary_language = Publishing.get_primary_language() total_count = socket.assigns.migration_modal_count # Use background job for large migrations @@ -435,7 +287,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do end defp dashboard_snapshot(_locale, current_user, date_time_settings) do - # Admin side reads from database only β€” groups appear after import + # Admin side reads from database only db_groups = DBStorage.list_groups() groups = @@ -469,7 +321,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do latest_published_at = find_latest_published_at(posts) # Check DB records for primary language issues - global_primary = Storage.get_primary_language() + global_primary = Publishing.get_primary_language() primary_lang_status = DBStorage.count_primary_language_status(db_group.slug, global_primary) lang_migration_count = @@ -489,11 +341,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Index do format_datetime(latest_published_at, current_user, date_time_settings), primary_language_status: primary_lang_status, needs_primary_language_migration: lang_migration_count > 0, - needs_migration_count: lang_migration_count, - # Legacy structure is a filesystem concern β€” not relevant for DB records - legacy_structure_status: %{legacy: 0, versioned: 0}, - needs_version_migration: false, - legacy_count: 0 + needs_migration_count: lang_migration_count } end diff --git a/lib/modules/publishing/web/index.html.heex b/lib/modules/publishing/web/index.html.heex index 6fbd58c5..7a185ed8 100644 --- a/lib/modules/publishing/web/index.html.heex +++ b/lib/modules/publishing/web/index.html.heex @@ -168,50 +168,6 @@ <% end %> - <%!-- Version Structure Migration Progress/Warning --%> - <% version_migration_progress = - Map.get(@version_migrations_in_progress, insight.slug) %> - <%= if version_migration_progress do %> -
- -
- - {gettext("Migrating to versioned structure...")} - - - -
-
- <% else %> - <%= if insight.needs_version_migration do %> -
- <.icon name="hero-arrow-up-circle" class="w-4 h-4 text-info shrink-0" /> - - {ngettext( - "%{count} post needs version migration", - "%{count} posts need version migration", - insight.legacy_count, - count: insight.legacy_count - )} - - -
- <% end %> - <% end %> - <%!-- Primary Language Migration Progress/Warning --%> <% migration_progress = Map.get(@migrations_in_progress, insight.slug) %> <%= if migration_progress do %> @@ -302,77 +258,6 @@ - <%!-- Version Structure Migration Confirmation Modal --%> - <%= if @show_version_migration_modal do %> - - <% end %> - <%!-- Primary Language Migration Confirmation Modal --%> <%= if @show_migration_modal do %> @@ -86,116 +73,20 @@
- <%!-- File Cache Row β€” only shown in filesystem mode --%> - <%= if not @db_storage do %> -
-
-
- <.icon name="hero-document" class="w-4 h-4 text-base-content/70" /> - {gettext("File")} - <%= if @cache_info.file_enabled do %> - <%= if @cache_info.exists do %> - {gettext("cached")} - <%= if @cache_info.generated_at do %> - - {format_cache_time(@cache_info.generated_at)} - - <% end %> - <% else %> - {gettext("empty")} - <% end %> - <% else %> - {gettext("off")} - <% end %> -
-
- <%= if @cache_info.file_enabled do %> - <%= if @cache_info.exists do %> - - <% end %> - - <% end %> - -
-
-

- <%= if @cache_info.file_enabled do %> - {gettext("Persists listing metadata to disk for fast startup.")} - <% else %> - {gettext("Disabled. Listing will scan filesystem on startup.")} - <% end %> -

-
- <% end %> - - <%!-- Memory Cache Row β€” works in both FS and DB mode --%> - <% memory_is_stale = - if @db_storage do - false - else - @cache_info.memory_enabled && @cache_info.in_memory && - @cache_info.file_enabled && - @cache_info.memory_file_generated_at && - @cache_info.generated_at && - @cache_info.memory_file_generated_at != @cache_info.generated_at - end %> + <%!-- Memory Cache Row --%>
-
+
<.icon name="hero-cpu-chip" class="w-4 h-4 text-base-content/70" /> {gettext("Memory")} <%= if @cache_info.memory_enabled do %> <%= if @cache_info.in_memory do %> - <%= if memory_is_stale do %> - {gettext("stale")} - <% else %> - {gettext("loaded")} - <% end %> - <%= if @cache_info.memory_file_generated_at do %> + {gettext("loaded")} + <%= if @cache_info.cache_generated_at do %> - <%= if @db_storage do %> - {gettext("from DB %{time}", - time: format_cache_time(@cache_info.memory_file_generated_at) - )} - <% else %> - <%= if @cache_info.file_enabled do %> - {gettext("from file %{time}", - time: format_cache_time(@cache_info.memory_file_generated_at) - )} - <% else %> - {gettext("scanned %{time}", - time: format_cache_time(@cache_info.memory_file_generated_at) - )} - <% end %> - <% end %> + {gettext("from DB %{time}", + time: format_cache_time(@cache_info.cache_generated_at) + )} <% end %> <% else %> @@ -242,24 +133,14 @@

<%= cond do %> - <% memory_is_stale -> %> - - {gettext("Stale data loaded. Reload or clear to serve fresh content.")} - <% @cache_info.memory_enabled && @cache_info.in_memory -> %> {gettext("Listing requests served in sub-milliseconds.")} - <% @db_storage && @cache_info.memory_enabled && !@cache_info.in_memory -> %> - {gettext("Not loaded. Will load from database on first request.")} - <% @cache_info.memory_enabled && !@cache_info.in_memory && !@cache_info.file_enabled -> %> - {gettext("Not loaded. Click reload to manually scan posts into memory.")} <% @cache_info.memory_enabled && !@cache_info.in_memory -> %> - {gettext("Not loaded yet. Will load from file on first request.")} - <% @db_storage -> %> - {gettext("Disabled. Listing queries database on each request.")} + {gettext("Not loaded. Will load from database on first request.")} <% true -> %> - {gettext("Disabled. Listing reads from file on each request.")} + {gettext("Disabled. Listing queries database on each request.")} <% end %>

@@ -324,65 +205,6 @@
<% end %> - <%!-- Version Structure Migration Progress Banner --%> - <%= if @version_migration_in_progress do %> -
-
- -
-
- - {gettext("Migrating to versioned structure...")} - - <%= if @version_migration_progress do %> -
- - - - {@version_migration_progress.current} / {@version_migration_progress.total} - -
- <% end %> -
-
- <% end %> - - <%!-- Version Structure Migration Banner --%> - <%= if not @version_migration_in_progress and @legacy_structure_status && @legacy_structure_status.legacy > 0 do %> -
-
- <.icon name="hero-arrow-up-circle" class="w-5 h-5" /> -
-
- - {ngettext( - "%{count} post needs version migration", - "%{count} posts need version migration", - @legacy_structure_status.legacy, - count: @legacy_structure_status.legacy - )} - -

- {gettext( - "Migrate to versioned structure to enable version history and safe editing of published content." - )} -

-
- -
- <% end %> - <%!-- Primary Language Migration Progress Banner --%> <%= if @migration_in_progress do %>
@@ -490,15 +312,14 @@ Path.dirname(post.path), "#{post_primary_lang}.phk" ]) %> - <% files_path = @group_files_root <> post.path %> + <% post_path = @group_path_prefix <> post.path %> <%!-- Use post's primary language for the public URL --%> <% public_url = PublishingHTML.build_post_url(group_slug, post, post_primary_lang) %> <% 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 == :slug and not Map.get(post, :is_legacy_structure, true) %> + <% is_versioned = post.mode == :slug %>
<%!-- Post info section --%> @@ -546,9 +367,9 @@ <%= unless post[:uuid] do %>

- {gettext("Files")}: + {gettext("Path")}: - {files_path} + {post_path}

<% end %>

@@ -670,72 +491,6 @@

- <%!-- Version Structure Migration Confirmation Modal --%> - <%= if @show_version_migration_modal do %> - - <% end %> - <%!-- Primary Language Migration Confirmation Modal --%> <%= if @show_migration_modal do %> <% total_needing = diff --git a/lib/modules/publishing/web/new.ex b/lib/modules/publishing/web/new.ex index 8240357b..68785da7 100644 --- a/lib/modules/publishing/web/new.ex +++ b/lib/modules/publishing/web/new.ex @@ -7,7 +7,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.New do alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes @@ -32,7 +31,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.New do |> assign(:form_params, initial_params) |> assign(:form, new_group_form(initial_params)) |> assign(:preset_types, @preset_types) - |> assign(:enabled_languages, Storage.enabled_language_codes()) + |> assign(:enabled_languages, Publishing.enabled_language_codes()) |> assign(:endpoint_url, nil) {:ok, socket} @@ -181,7 +180,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.New do last_generated_slug, auto_item_names? ) - |> put_flash(:error, gettext("Invalid storage mode"))} + |> put_flash(:error, gettext("Invalid URL mode"))} {:error, :invalid_type} -> {:noreply, diff --git a/lib/modules/publishing/web/new.html.heex b/lib/modules/publishing/web/new.html.heex index c93946da..dc4c72ee 100644 --- a/lib/modules/publishing/web/new.html.heex +++ b/lib/modules/publishing/web/new.html.heex @@ -13,7 +13,7 @@ title={gettext("Create a New Publishing Group")} subtitle={ gettext( - "Spin up a dedicated workspace for your content team. Choose the content type and storage approach that fits your publishing needs." + "Spin up a dedicated workspace for your content team. Choose the content type that fits your publishing needs." ) } /> @@ -86,8 +86,6 @@ end %> <%!-- Use first enabled language (default) for preview URLs --%> <% default_language = List.first(@enabled_languages) %> - <% timestamp_file_pattern = - "priv/publishing/#{slug_sample}///.phk" %> <% timestamp_url_pattern = if length(@enabled_languages) == 1 do @endpoint_url <> "#{url_prefix}/#{slug_sample}//" @@ -95,7 +93,6 @@ @endpoint_url <> "#{url_prefix}/#{default_language}/#{slug_sample}//" end %> - <% slug_file_pattern = "priv/publishing/#{slug_sample}//.phk" %> <% slug_url_pattern = if length(@enabled_languages) == 1 do @endpoint_url <> "#{url_prefix}/#{slug_sample}/:post_slug" @@ -235,11 +232,11 @@
- <%!-- Storage Mode Section --%> + <%!-- URL Mode Section --%>
@@ -259,10 +256,6 @@ )}

-

- {gettext("Files")}: - {timestamp_file_pattern} -

{gettext("Public URL")}: @@ -288,10 +281,6 @@ )}

-

- {gettext("Files")}: - {slug_file_pattern} -

{gettext("Public URL")}: @@ -305,7 +294,7 @@ @@ -331,7 +320,7 @@ {gettext("What happens next")}

    -
  • β€’ {gettext("We create a publishing workspace under priv/publishing.")}
  • +
  • β€’ {gettext("We create a publishing group in the database.")}
  • β€’ {gettext("You'll see the new group in the navigation immediately.")}
  • β€’ {gettext("Editors can jump right in and start creating content.")}
diff --git a/lib/modules/publishing/web/post_show.ex b/lib/modules/publishing/web/post_show.ex index e4b1fc78..4d7be0b5 100644 --- a/lib/modules/publishing/web/post_show.ex +++ b/lib/modules/publishing/web/post_show.ex @@ -9,7 +9,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.PostShow do alias PhoenixKit.Modules.Publishing alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Settings alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Routes @@ -40,7 +39,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.PostShow do |> assign(:post, nil) |> assign(:group_name, Publishing.group_name(group_slug) || group_slug) |> assign(:date_time_settings, date_time_settings) - |> assign(:enabled_languages, Storage.enabled_language_codes()) + |> assign(:enabled_languages, Publishing.enabled_language_codes()) |> assign(:page_title, gettext("Post Overview")) {:ok, socket} diff --git a/lib/modules/publishing/web/preview.ex b/lib/modules/publishing/web/preview.ex index b2147822..020b336d 100644 --- a/lib/modules/publishing/web/preview.ex +++ b/lib/modules/publishing/web/preview.ex @@ -1,6 +1,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Preview do @moduledoc """ - Preview rendering for .phk publishing posts. + Preview rendering for publishing posts. """ use PhoenixKitWeb, :live_view use Gettext, backend: PhoenixKitWeb.Gettext diff --git a/lib/modules/publishing/web/settings.ex b/lib/modules/publishing/web/settings.ex index af294d7d..716ad05c 100644 --- a/lib/modules/publishing/web/settings.ex +++ b/lib/modules/publishing/web/settings.ex @@ -11,17 +11,14 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do alias PhoenixKit.Modules.Publishing.ListingCache alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub alias PhoenixKit.Modules.Publishing.Renderer - alias PhoenixKit.Modules.Publishing.Storage alias PhoenixKit.Settings alias PhoenixKit.Utils.Routes - # New settings keys (write to these) - @file_cache_key "publishing_file_cache_enabled" + # Settings keys @memory_cache_key "publishing_memory_cache_enabled" @render_cache_key "publishing_render_cache_enabled" # Legacy settings keys (read from these as fallback) - @legacy_file_cache_key "blogging_file_cache_enabled" @legacy_memory_cache_key "blogging_memory_cache_enabled" @legacy_render_cache_key "blogging_render_cache_enabled" @@ -31,11 +28,9 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do PublishingPubSub.subscribe_to_groups() end - # Admin side reads from database only β€” groups appear after import + # Admin side reads from database only groups = db_groups_to_maps() - fs_groups = fs_groups_to_maps() - # Cache management uses DB groups if imported, else FS groups (caches serve public pages) - cache_groups = if groups != [], do: groups, else: fs_groups + cache_groups = groups languages_enabled = Languages.enabled?() socket = @@ -50,8 +45,7 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do |> assign(:publishing, groups) |> assign(:cache_groups, cache_groups) |> assign(:languages_enabled, languages_enabled) - |> assign(:global_primary_language, Storage.get_primary_language()) - |> assign(:file_cache_enabled, get_cache_setting(@file_cache_key, @legacy_file_cache_key)) + |> assign(:global_primary_language, Publishing.get_primary_language()) |> assign( :memory_cache_enabled, get_cache_setting(@memory_cache_key, @legacy_memory_cache_key) @@ -150,16 +144,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do |> put_flash(:info, gettext("Regenerated %{count} caches", count: success_count))} end - def handle_event("toggle_file_cache", _params, socket) do - new_value = !socket.assigns.file_cache_enabled - Settings.update_setting(@file_cache_key, to_string(new_value)) - - {:noreply, - socket - |> assign(:file_cache_enabled, new_value) - |> put_flash(:info, cache_toggle_message("File cache", new_value))} - end - def handle_event("toggle_memory_cache", _params, socket) do new_value = !socket.assigns.memory_cache_enabled Settings.update_setting(@memory_cache_key, to_string(new_value)) @@ -248,23 +232,19 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do defp refresh_groups(socket) do groups = db_groups_to_maps() - fs_groups = fs_groups_to_maps() - cache_groups = if groups != [], do: groups, else: fs_groups socket |> assign(:publishing, groups) - |> assign(:cache_groups, cache_groups) - |> assign(:fs_group_count, length(fs_groups)) - |> assign(:cache_status, build_cache_status(cache_groups)) - |> assign(:render_cache_per_group, build_render_cache_per_group(cache_groups)) + |> assign(:cache_groups, groups) + |> assign(:cache_status, build_cache_status(groups)) + |> assign(:render_cache_per_group, build_render_cache_per_group(groups)) end defp db_groups_to_maps do - global_primary = Storage.get_primary_language() + global_primary = Publishing.get_primary_language() DBStorage.list_groups() |> Enum.map(fn g -> - # Check DB records for primary language issues (not filesystem) primary_lang_status = DBStorage.count_primary_language_status(g.slug, global_primary) needs_lang_migration = @@ -281,18 +261,6 @@ defmodule PhoenixKit.Modules.Publishing.Web.Settings do end) end - defp fs_groups_to_maps do - Publishing.list_groups() - |> Enum.map(fn g -> - %{ - "name" => g["name"], - "slug" => g["slug"], - "mode" => g["mode"], - "position" => g["position"] - } - 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 diff --git a/lib/modules/publishing/web/settings.html.heex b/lib/modules/publishing/web/settings.html.heex index 3eb5fa6e..1b594a35 100644 --- a/lib/modules/publishing/web/settings.html.heex +++ b/lib/modules/publishing/web/settings.html.heex @@ -68,7 +68,7 @@

{gettext( - "Create dedicated publishing groups like Product Updates, Release Notes, or Company News. Each group stores its content in structured folders on disk." + "Create dedicated publishing groups like Product Updates, Release Notes, or Company News. Each group stores its content in the database." )}

@@ -199,8 +199,7 @@ )}

- <% any_listing_cache = - (@publishing == [] and @file_cache_enabled) or @memory_cache_enabled %> + <% any_listing_cache = @memory_cache_enabled %> <%= if @cache_groups != [] and any_listing_cache do %>