fix: more work on metadata-diff
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, %{
|
||||
|
||||
Reference in New Issue
Block a user