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 @@
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, %{