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_type: entity_type,
|
||||||
entity_id: entity_id,
|
entity_id: entity_id,
|
||||||
label: metadata_diff_item_label(item, 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),
|
display_entity_type: metadata_diff_item_type_label(entity_type),
|
||||||
differences: differences
|
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
|
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
|
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"), do: translated("Post")
|
||||||
defp metadata_diff_item_type_label("post_translation"), do: translated("Translations")
|
defp metadata_diff_item_type_label("post_translation"), do: translated("Translations")
|
||||||
defp metadata_diff_item_type_label("media"), do: translated("Media")
|
defp metadata_diff_item_type_label("media"), do: translated("Media")
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
<header class="diff-item-header">
|
<header class="diff-item-header">
|
||||||
<div>
|
<div>
|
||||||
<strong><%= item.label %></strong>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ defmodule BDS.Maintenance do
|
|||||||
alias BDS.Embeddings
|
alias BDS.Embeddings
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
alias BDS.Posts.Translation, as: PostTranslation
|
alias BDS.Posts.Translation, as: PostTranslation
|
||||||
|
alias BDS.Persistence
|
||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Scripts.Script
|
alias BDS.Scripts.Script
|
||||||
@@ -219,7 +220,12 @@ defmodule BDS.Maintenance do
|
|||||||
if differences == [] do
|
if differences == [] do
|
||||||
[]
|
[]
|
||||||
else
|
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
|
end
|
||||||
|
|
||||||
{:error, _reason} ->
|
{:error, _reason} ->
|
||||||
@@ -278,18 +284,10 @@ defmodule BDS.Maintenance do
|
|||||||
diff_field("title", translation.title, Map.get(fields, "title")),
|
diff_field("title", translation.title, Map.get(fields, "title")),
|
||||||
diff_field("excerpt", translation.excerpt, Map.get(fields, "excerpt")),
|
diff_field("excerpt", translation.excerpt, Map.get(fields, "excerpt")),
|
||||||
diff_field("language", translation.language, Map.get(fields, "language")),
|
diff_field("language", translation.language, Map.get(fields, "language")),
|
||||||
diff_field("status", translation.status, DocumentFields.get(fields, "status")),
|
|
||||||
diff_field(
|
diff_field(
|
||||||
"translation_for",
|
"translation_for",
|
||||||
translation.translation_for,
|
translation.translation_for,
|
||||||
DocumentFields.get(fields, "translationFor")
|
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)
|
|> Enum.reject(&is_nil/1)
|
||||||
@@ -298,11 +296,10 @@ defmodule BDS.Maintenance do
|
|||||||
[]
|
[]
|
||||||
else
|
else
|
||||||
[
|
[
|
||||||
%{
|
build_diff_report("post_translation", translation.id, differences,
|
||||||
entity_type: "post_translation",
|
label: metadata_diff_entity_label(translation.title, nil, translation.id),
|
||||||
entity_id: translation.id,
|
meta_label: translation.language
|
||||||
differences: differences
|
)
|
||||||
}
|
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -502,15 +499,43 @@ defmodule BDS.Maintenance do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp build_diff_report(entity_type, entity_id, differences) do
|
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)
|
normalized = Enum.reject(differences, &is_nil/1)
|
||||||
|
|
||||||
if normalized == [] do
|
if normalized == [] do
|
||||||
nil
|
nil
|
||||||
else
|
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
|
||||||
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
|
defp diff_field(name, db_value, file_value) do
|
||||||
if equal_diff_values?(db_value, file_value) do
|
if equal_diff_values?(db_value, file_value) do
|
||||||
nil
|
nil
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ config {
|
|||||||
|
|
||||||
value PostFrontmatter {
|
value PostFrontmatter {
|
||||||
-- File path: posts/{YYYY}/{MM}/{slug}.md
|
-- File path: posts/{YYYY}/{MM}/{slug}.md
|
||||||
-- For translations: posts/{YYYY}/{MM}/{slug}.{language}.md
|
|
||||||
id: String -- UUID v4
|
id: String -- UUID v4
|
||||||
title: String
|
title: String
|
||||||
slug: String
|
slug: String
|
||||||
@@ -105,6 +104,29 @@ value PostFrontmatter {
|
|||||||
categories: List<String> -- Always written, even if empty
|
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 {
|
invariant PostFileLayout {
|
||||||
-- Posts are stored in date-based directory structure
|
-- Posts are stored in date-based directory structure
|
||||||
-- YYYY and MM derived from created_at (zero-padded)
|
-- YYYY and MM derived from created_at (zero-padded)
|
||||||
@@ -125,6 +147,13 @@ invariant PostTranslationFileLayout {
|
|||||||
lang: t.language)
|
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 {
|
rule WritePostFile {
|
||||||
when: PublishPostRequested(post)
|
when: PublishPostRequested(post)
|
||||||
ensures: FileWritten(
|
ensures: FileWritten(
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ rule RunMetadataDiff {
|
|||||||
if diffs.count > 0:
|
if diffs.count > 0:
|
||||||
ensures: DiffReport.created(entity_type: "post", entity_id: post.id, differences: diffs)
|
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)
|
-- Detect orphan files (on disk but not in DB)
|
||||||
for file in scan_directory(project.effective_data_dir + "/posts", "*.md"):
|
for file in scan_directory(project.effective_data_dir + "/posts", "*.md"):
|
||||||
let matching = Posts where file_path = file
|
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 {
|
surface PostTranslationSurface {
|
||||||
context translation: PostTranslation
|
context translation: PostTranslation
|
||||||
|
|
||||||
|
|||||||
@@ -869,6 +869,8 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
%{
|
%{
|
||||||
entity_type: "post",
|
entity_type: "post",
|
||||||
entity_id: "post-1",
|
entity_id: "post-1",
|
||||||
|
label: "Hello DB",
|
||||||
|
meta_label: "2026-04-05T12:00:00Z",
|
||||||
differences: [
|
differences: [
|
||||||
%{field: "slug", db_value: "hello-db", file_value: "hello-file"},
|
%{field: "slug", db_value: "hello-db", file_value: "hello-file"},
|
||||||
%{field: "title", 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 =~ ~s(data-testid="metadata-diff-field-pill")
|
||||||
assert html =~ "slug"
|
assert html =~ "slug"
|
||||||
assert html =~ "title"
|
assert html =~ "title"
|
||||||
assert html =~ "slug"
|
assert html =~ "2026-04-05T12:00:00Z"
|
||||||
assert html =~ "hello-db"
|
assert html =~ "hello-db"
|
||||||
assert html =~ "hello-file"
|
assert html =~ "hello-file"
|
||||||
assert html =~ "posts/2026/04/orphan.md"
|
assert html =~ "posts/2026/04/orphan.md"
|
||||||
|
refute html =~ "Beitrag · post-1"
|
||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|
|||||||
@@ -647,6 +647,8 @@ defmodule BDS.MaintenanceTest do
|
|||||||
|
|
||||||
assert Enum.any?(diff_reports, fn report ->
|
assert Enum.any?(diff_reports, fn report ->
|
||||||
report.entity_type == "post" and report.entity_id == published_post.id and
|
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?(
|
Enum.any?(
|
||||||
report.differences,
|
report.differences,
|
||||||
&(&1.name == "title" and &1.db_value == "Original Post" and
|
&(&1.name == "title" and &1.db_value == "Original Post" and
|
||||||
@@ -837,6 +839,63 @@ defmodule BDS.MaintenanceTest do
|
|||||||
end)
|
end)
|
||||||
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
|
test "metadata_diff includes project-level metadata drift", %{project: project, temp_dir: temp_dir} do
|
||||||
assert {:ok, _metadata} =
|
assert {:ok, _metadata} =
|
||||||
BDS.Metadata.update_project_metadata(project.id, %{
|
BDS.Metadata.update_project_metadata(project.id, %{
|
||||||
|
|||||||
Reference in New Issue
Block a user