From 56c5ec18616da92ad49ed30dda1a66de90981c0d Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 27 Apr 2026 10:53:56 +0200 Subject: [PATCH] fix: more work on metadata-diff --- lib/bds/desktop/shell_live/misc_editor.ex | 5 ++ .../misc_editor_html/misc_editor.html.heex | 2 +- lib/bds/maintenance.ex | 55 ++++++++++++----- specs/frontmatter.allium | 31 +++++++++- specs/metadata_diff.allium | 14 +++++ specs/translation.allium | 9 +++ test/bds/desktop/shell_live_test.exs | 5 +- test/bds/maintenance_test.exs | 59 +++++++++++++++++++ 8 files changed, 162 insertions(+), 18 deletions(-) diff --git a/lib/bds/desktop/shell_live/misc_editor.ex b/lib/bds/desktop/shell_live/misc_editor.ex index 65e9ed4..72c659d 100644 --- a/lib/bds/desktop/shell_live/misc_editor.ex +++ b/lib/bds/desktop/shell_live/misc_editor.ex @@ -377,6 +377,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do entity_type: entity_type, entity_id: entity_id, label: metadata_diff_item_label(item, entity_id), + meta_label: metadata_diff_item_meta_label(item, entity_id), display_entity_type: metadata_diff_item_type_label(entity_type), differences: differences } @@ -407,6 +408,10 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do Map.get(item, :label) || Map.get(item, "label") || Map.get(item, :title) || Map.get(item, "title") || Map.get(item, :slug) || Map.get(item, "slug") || entity_id end + defp metadata_diff_item_meta_label(item, entity_id) do + Map.get(item, :meta_label) || Map.get(item, "meta_label") || entity_id + end + defp metadata_diff_item_type_label("post"), do: translated("Post") defp metadata_diff_item_type_label("post_translation"), do: translated("Translations") defp metadata_diff_item_type_label("media"), do: translated("Media") diff --git a/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex b/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex index c98749c..9566295 100644 --- a/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex +++ b/lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex @@ -112,7 +112,7 @@
<%= item.label %> -
<%= item.display_entity_type %> · <%= item.entity_id %>
+
<%= item.display_entity_type %> · <%= item.meta_label %>
diff --git a/lib/bds/maintenance.ex b/lib/bds/maintenance.ex index d2b28b9..9b1f328 100644 --- a/lib/bds/maintenance.ex +++ b/lib/bds/maintenance.ex @@ -11,6 +11,7 @@ defmodule BDS.Maintenance do alias BDS.Embeddings alias BDS.Posts.Post alias BDS.Posts.Translation, as: PostTranslation + alias BDS.Persistence alias BDS.Projects alias BDS.Repo alias BDS.Scripts.Script @@ -219,7 +220,12 @@ defmodule BDS.Maintenance do if differences == [] do [] else - [%{entity_type: "post", entity_id: post.id, differences: differences}] + [ + build_diff_report("post", post.id, differences, + label: metadata_diff_entity_label(post.title, post.slug, post.id), + meta_label: metadata_diff_timestamp_label(post.created_at) + ) + ] end {:error, _reason} -> @@ -278,18 +284,10 @@ defmodule BDS.Maintenance do diff_field("title", translation.title, Map.get(fields, "title")), diff_field("excerpt", translation.excerpt, Map.get(fields, "excerpt")), diff_field("language", translation.language, Map.get(fields, "language")), - diff_field("status", translation.status, DocumentFields.get(fields, "status")), diff_field( "translation_for", translation.translation_for, DocumentFields.get(fields, "translationFor") - ), - diff_field("created_at", translation.created_at, DocumentFields.get(fields, "createdAt")), - diff_field("updated_at", translation.updated_at, DocumentFields.get(fields, "updatedAt")), - diff_field( - "published_at", - translation.published_at, - DocumentFields.get(fields, "publishedAt") ) ] |> Enum.reject(&is_nil/1) @@ -298,11 +296,10 @@ defmodule BDS.Maintenance do [] else [ - %{ - entity_type: "post_translation", - entity_id: translation.id, - differences: differences - } + build_diff_report("post_translation", translation.id, differences, + label: metadata_diff_entity_label(translation.title, nil, translation.id), + meta_label: translation.language + ) ] end @@ -502,15 +499,43 @@ defmodule BDS.Maintenance do end defp build_diff_report(entity_type, entity_id, differences) do + build_diff_report(entity_type, entity_id, differences, []) + end + + defp build_diff_report(entity_type, entity_id, differences, opts) do normalized = Enum.reject(differences, &is_nil/1) if normalized == [] do nil else - %{entity_type: entity_type, entity_id: entity_id, differences: normalized} + %{ + entity_type: entity_type, + entity_id: entity_id, + differences: normalized, + label: Keyword.get(opts, :label), + meta_label: Keyword.get(opts, :meta_label) + } end end + defp metadata_diff_entity_label(title, slug, fallback_id) do + blank_to_nil(title) || blank_to_nil(slug) || fallback_id + end + + defp metadata_diff_timestamp_label(nil), do: nil + defp metadata_diff_timestamp_label(timestamp), do: Persistence.timestamp_to_iso8601(timestamp) + + defp blank_to_nil(nil), do: nil + + defp blank_to_nil(value) when is_binary(value) do + case String.trim(value) do + "" -> nil + trimmed -> trimmed + end + end + + defp blank_to_nil(value), do: value + defp diff_field(name, db_value, file_value) do if equal_diff_values?(db_value, file_value) do nil diff --git a/specs/frontmatter.allium b/specs/frontmatter.allium index 1febe5c..740839d 100644 --- a/specs/frontmatter.allium +++ b/specs/frontmatter.allium @@ -88,7 +88,6 @@ config { value PostFrontmatter { -- File path: posts/{YYYY}/{MM}/{slug}.md - -- For translations: posts/{YYYY}/{MM}/{slug}.{language}.md id: String -- UUID v4 title: String slug: String @@ -105,6 +104,29 @@ value PostFrontmatter { categories: List -- Always written, even if empty } +value TranslationFrontmatter { + -- File path: posts/{YYYY}/{MM}/{slug}.{language}.md + -- Translation files only store language-specific metadata. + -- Shared publication state and timestamps are inherited from the + -- canonical post file and are not duplicated here. + id: String -- UUID v4 + translation_for: String -- Canonical post UUID + language: String -- ISO 639-1 language code + title: String -- Translated title + excerpt: String? -- Only written when the translated excerpt differs +} + +surface TranslationFrontmatterSurface { + context frontmatter: TranslationFrontmatter + + exposes: + frontmatter.id + frontmatter.translation_for + frontmatter.language + frontmatter.title + frontmatter.excerpt when frontmatter.excerpt != null +} + invariant PostFileLayout { -- Posts are stored in date-based directory structure -- YYYY and MM derived from created_at (zero-padded) @@ -125,6 +147,13 @@ invariant PostTranslationFileLayout { lang: t.language) } +invariant TranslationFilesInheritCanonicalMetadata { + -- Missing status and timestamp fields in translation files are expected. + -- Rebuild and metadata diff must resolve those values from the canonical post. + for t in PostTranslations where file_path != "": + parse_frontmatter(read_file(t.file_path)) = translation_frontmatter_fields(t) +} + rule WritePostFile { when: PublishPostRequested(post) ensures: FileWritten( diff --git a/specs/metadata_diff.allium b/specs/metadata_diff.allium index 0b546fb..2584046 100644 --- a/specs/metadata_diff.allium +++ b/specs/metadata_diff.allium @@ -46,6 +46,20 @@ rule RunMetadataDiff { if diffs.count > 0: ensures: DiffReport.created(entity_type: "post", entity_id: post.id, differences: diffs) + -- Translation files only carry language-specific metadata. Shared status and + -- timestamp fields come from the canonical post and must not be reported as + -- missing when they are absent from the translation file. + for translation in project.post_translations: + let translation_file_data = parse_post_file(translation.file_path) + let translation_diffs = compare_translation_specific_fields(translation, translation_file_data) + if translation_diffs.count > 0: + ensures: + DiffReport.created( + entity_type: "post_translation", + entity_id: translation.id, + differences: translation_diffs + ) + -- Detect orphan files (on disk but not in DB) for file in scan_directory(project.effective_data_dir + "/posts", "*.md"): let matching = Posts where file_path = file diff --git a/specs/translation.allium b/specs/translation.allium index b9e16ff..f8ebad1 100644 --- a/specs/translation.allium +++ b/specs/translation.allium @@ -64,6 +64,15 @@ entity PostTranslation { } } +invariant TranslationFilesStoreOnlyLanguageSpecificMetadata { + -- Translation markdown files persist only fields that differ by language. + -- Shared metadata such as publication status and timestamps belongs to the + -- canonical post file and is inherited from the canonical post when + -- rebuilding or diffing translation files. + for t in PostTranslations where file_path != "": + translation_file(t).omits_shared_metadata = true +} + surface PostTranslationSurface { context translation: PostTranslation diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 066c09b..99bcf17 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -869,6 +869,8 @@ defmodule BDS.Desktop.ShellLiveTest do %{ entity_type: "post", entity_id: "post-1", + label: "Hello DB", + meta_label: "2026-04-05T12:00:00Z", differences: [ %{field: "slug", db_value: "hello-db", file_value: "hello-file"}, %{field: "title", db_value: "Hello DB", file_value: "Hello File"} @@ -931,10 +933,11 @@ defmodule BDS.Desktop.ShellLiveTest do assert html =~ ~s(data-testid="metadata-diff-field-pill") assert html =~ "slug" assert html =~ "title" - assert html =~ "slug" + assert html =~ "2026-04-05T12:00:00Z" assert html =~ "hello-db" assert html =~ "hello-file" assert html =~ "posts/2026/04/orphan.md" + refute html =~ "Beitrag · post-1" html = view diff --git a/test/bds/maintenance_test.exs b/test/bds/maintenance_test.exs index c6b2c06..5edcdbb 100644 --- a/test/bds/maintenance_test.exs +++ b/test/bds/maintenance_test.exs @@ -647,6 +647,8 @@ defmodule BDS.MaintenanceTest do assert Enum.any?(diff_reports, fn report -> report.entity_type == "post" and report.entity_id == published_post.id and + report.label == "Original Post" and + report.meta_label == BDS.Persistence.timestamp_to_iso8601(published_post.created_at) and Enum.any?( report.differences, &(&1.name == "title" and &1.db_value == "Original Post" and @@ -837,6 +839,63 @@ defmodule BDS.MaintenanceTest do end) end + test "metadata_diff treats translation status and timestamps as inherited from the canonical post" do + legacy_dir = + Path.join(System.tmp_dir!(), "bds-maintenance-legacy-translation-#{System.unique_integer([:positive])}") + + File.mkdir_p!(legacy_dir) + on_exit(fn -> File.rm_rf(legacy_dir) end) + + assert {:ok, project} = + BDS.Projects.create_project(%{name: "Legacy Translation Diff", data_path: legacy_dir}) + + posts_dir = Path.join([BDS.Projects.project_data_dir(project), "posts", "2026", "04"]) + File.mkdir_p!(posts_dir) + + File.write!( + Path.join(posts_dir, "chimera.md"), + [ + "---", + "id: post-from-old-app", + "title: Chimera Source", + "slug: chimera", + "status: published", + "language: de", + "createdAt: 2024-03-30T21:20:00.000Z", + "updatedAt: 2024-03-31T21:20:00.000Z", + "publishedAt: 2024-04-01T21:20:00.000Z", + "---", + "Quelle", + "" + ] + |> Enum.join("\n") + ) + + File.write!( + Path.join(posts_dir, "chimera.en.md"), + [ + "---", + "id: translation-from-old-app", + "translationFor: post-from-old-app", + "language: en", + "title: Chimera", + "excerpt: Imported translation", + "---", + "Translated body", + "" + ] + |> Enum.join("\n") + ) + + assert {:ok, _posts} = BDS.Posts.rebuild_posts_from_files(project.id) + assert {:ok, %{diff_reports: diff_reports}} = BDS.Maintenance.metadata_diff(project.id) + + refute Enum.any?(diff_reports, fn report -> + report.entity_type == "post_translation" and + Enum.any?(report.differences, &(&1.name in ["status", "created_at", "updated_at", "published_at"])) + end) + end + test "metadata_diff includes project-level metadata drift", %{project: project, temp_dir: temp_dir} do assert {:ok, _metadata} = BDS.Metadata.update_project_metadata(project.id, %{