fix: more work on metadata-diff

This commit is contained in:
2026-04-27 10:53:56 +02:00
parent 07730dc93e
commit 56c5ec1861
8 changed files with 162 additions and 18 deletions

View File

@@ -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")

View File

@@ -112,7 +112,7 @@
<header class="diff-item-header">
<div>
<strong><%= item.label %></strong>
<div class="diff-item-meta"><%= item.display_entity_type %> · <%= item.entity_id %></div>
<div class="diff-item-meta"><%= item.display_entity_type %> · <%= item.meta_label %></div>
</div>
</header>

View File

@@ -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

View File

@@ -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<String> -- 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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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