fix: more fixes to file formats

This commit is contained in:
2026-04-25 11:35:36 +02:00
parent 5ecd90ae65
commit b90a4569da
18 changed files with 477 additions and 181 deletions

View File

@@ -175,5 +175,8 @@ defmodule BDS.Frontmatter do
end
end
defp timestamp_key?(key), do: String.ends_with?(to_string(key), "_at")
defp timestamp_key?(key) do
rendered = to_string(key)
String.ends_with?(rendered, "_at") or String.ends_with?(rendered, "At")
end
end

View File

@@ -341,6 +341,7 @@ defmodule BDS.Media do
defp upsert_media_from_sidecar(project, sidecar_path) do
{:ok, fields} = sidecar_path |> File.read!() |> Sidecar.parse_document()
fields = normalize_media_sidecar_fields(fields)
relative_sidecar_path = Path.relative_to(sidecar_path, Projects.project_data_dir(project))
relative_file_path = String.trim_trailing(relative_sidecar_path, ".meta")
filename = Path.basename(relative_file_path)
@@ -386,20 +387,21 @@ defmodule BDS.Media do
atomic_write(
path,
Sidecar.serialize_document([
{:id, media.id},
{:original_name, media.original_name},
{:mime_type, media.mime_type},
{:size, media.size},
{:width, media.width},
{:height, media.height},
{:title, media.title},
{:alt, media.alt},
{:caption, media.caption},
{:author, media.author},
{:language, media.language},
{:created_at, media.created_at},
{:updated_at, media.updated_at},
{:tags, media.tags || []}
{"id", media.id},
{"originalName", media.original_name},
{"mimeType", media.mime_type},
{"size", media.size},
{"width", media.width},
{"height", media.height},
{"title", media.title},
{"alt", media.alt},
{"caption", media.caption},
{"author", media.author},
{"language", media.language},
{"createdAt", media.created_at},
{"updatedAt", media.updated_at},
{"linkedPostIds", linked_post_ids(media.id)},
{"tags", media.tags || []}
])
)
end
@@ -416,11 +418,11 @@ defmodule BDS.Media do
atomic_write(
path,
Sidecar.serialize_document([
{:translation_for, media.id},
{:language, translation.language},
{:title, translation.title},
{:alt, translation.alt},
{:caption, translation.caption}
{"translationFor", media.id},
{"language", translation.language},
{"title", translation.title},
{"alt", translation.alt},
{"caption", translation.caption}
])
)
end
@@ -434,6 +436,7 @@ defmodule BDS.Media do
media ->
{:ok, fields} = sidecar_path |> File.read!() |> Sidecar.parse_document()
fields = normalize_media_sidecar_fields(fields)
now = Persistence.now_ms()
language = Map.fetch!(fields, "language")
@@ -607,6 +610,30 @@ defmodule BDS.Media do
Persistence.atomic_write(path, contents)
end
defp normalize_media_sidecar_fields(fields) when is_map(fields) do
[
{"originalName", "original_name"},
{"mimeType", "mime_type"},
{"createdAt", "created_at"},
{"updatedAt", "updated_at"},
{"translationFor", "translation_for"},
{"linkedPostIds", "linked_post_ids"}
]
|> Enum.reduce(fields, fn {file_key, current_key}, acc ->
case Map.fetch(acc, file_key) do
{:ok, value} -> Map.put_new(acc, current_key, value)
:error -> acc
end
end)
end
defp linked_post_ids(media_id) do
case Repo.query("SELECT post_id FROM post_media WHERE media_id = ? ORDER BY sort_order ASC, post_id ASC", [media_id]) do
{:ok, %{rows: rows}} -> Enum.map(rows, fn [post_id] -> post_id end)
{:error, _reason} -> []
end
end
defp blank_to_nil(nil), do: nil
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value

View File

@@ -128,11 +128,8 @@ defmodule BDS.Menu do
end
defp render_attributes(item) do
[
{"kind", item.kind},
{"text", item.label},
{"slug", item.slug}
]
item
|> render_outline_attributes()
|> Enum.reject(fn {_key, value} -> value in [nil, ""] end)
|> Enum.map_join("", fn {key, value} -> ~s( #{key}="#{xml_escape(to_string(value))}") end)
end
@@ -145,12 +142,12 @@ defmodule BDS.Menu do
end
defp parse_outline(element) do
kind = element |> xml_attr(:kind) |> normalize_kind()
kind = element |> outline_kind() |> normalize_kind()
base = %{
kind: kind,
label: xml_attr(element, :text) || "",
slug: normalize_optional_string(xml_attr(element, :slug))
slug: element |> outline_slug(kind) |> normalize_optional_string()
}
children =
@@ -164,6 +161,33 @@ defmodule BDS.Menu do
end
end
defp render_outline_attributes(item) do
kind = Map.get(item, :kind)
[
{"text", item.label},
{"type", render_outline_kind(kind)},
{"pageSlug", render_page_slug(kind, item.slug)},
{"categoryName", render_category_name(kind, item.slug)}
]
end
defp outline_kind(element), do: xml_attr(element, :type) || xml_attr(element, :kind)
defp outline_slug(element, :category_archive), do: xml_attr(element, :categoryName) || xml_attr(element, :slug)
defp outline_slug(element, :home), do: xml_attr(element, :pageSlug) || xml_attr(element, :slug)
defp outline_slug(element, _kind), do: xml_attr(element, :pageSlug) || xml_attr(element, :slug)
defp render_outline_kind(:category_archive), do: "category-archive"
defp render_outline_kind(kind), do: to_string(kind)
defp render_page_slug(:home, _slug), do: "home"
defp render_page_slug(kind, slug) when kind in [:page], do: slug
defp render_page_slug(_kind, _slug), do: nil
defp render_category_name(:category_archive, slug), do: slug
defp render_category_name(_kind, _slug), do: nil
defp xml_attr(element, name) do
element
|> xmlElement(:attributes)
@@ -176,10 +200,16 @@ defmodule BDS.Menu do
defp normalize_kind(kind) when is_atom(kind) and kind in @valid_kinds, do: kind
defp normalize_kind(nil), do: :page
defp normalize_kind(kind) when is_binary(kind) do
kind
|> String.to_existing_atom()
|> normalize_kind()
case kind do
"category-archive" -> :category_archive
other ->
other
|> String.to_existing_atom()
|> normalize_kind()
end
rescue
_error -> :page
end

View File

@@ -157,8 +157,8 @@ defmodule BDS.Metadata do
persist_setting(project_id, "category_meta", category_meta_from_files, now)
persist_setting(project_id, "publishing", publishing_from_files, now)
write_project_json(updated_project, project_metadata_from_files)
write_categories_json(updated_project, categories_from_files["categories"] || @default_categories)
write_category_meta_json(updated_project, category_meta_from_files["categories"] || %{})
write_categories_json(updated_project, normalized_categories(categories_from_files))
write_category_meta_json(updated_project, normalized_category_settings(category_meta_from_files))
write_publishing_json(updated_project, publishing_from_files)
load_state(updated_project)
end)
@@ -248,7 +248,8 @@ defmodule BDS.Metadata do
"post_template_slug" =>
Map.get(settings, :post_template_slug, Map.get(settings, "post_template_slug")),
"list_template_slug" =>
Map.get(settings, :list_template_slug, Map.get(settings, "list_template_slug"))
Map.get(settings, :list_template_slug, Map.get(settings, "list_template_slug")),
"title" => Map.get(settings, :title, Map.get(settings, "title"))
}
end
@@ -284,18 +285,18 @@ defmodule BDS.Metadata do
end
defp write_project_json(project, project_json),
do: write_json(project, "project.json", project_json)
do: write_json(project, "project.json", render_project_metadata_json(project_json))
defp write_categories_json(project, categories) do
write_json(project, "categories.json", %{"categories" => Enum.sort(categories)})
write_json(project, "categories.json", Enum.sort(categories))
end
defp write_category_meta_json(project, category_settings) do
write_json(project, "category-meta.json", %{"categories" => category_settings})
write_json(project, "category-meta.json", render_category_meta_json(category_settings))
end
defp write_publishing_json(project, publishing_preferences) do
write_json(project, "publishing.json", publishing_preferences)
write_json(project, "publishing.json", render_publishing_json(publishing_preferences))
end
defp write_json(project, file_name, payload) do
@@ -308,11 +309,121 @@ defmodule BDS.Metadata do
path = Path.join([Projects.project_data_dir(project), "meta", file_name])
case File.read(path) do
{:ok, contents} -> Jason.decode!(contents)
{:ok, contents} -> contents |> Jason.decode!() |> normalize_json(file_name)
{:error, :enoent} -> nil
end
end
defp normalize_json(payload, "project.json"), do: parse_project_metadata_json(payload)
defp normalize_json(payload, "categories.json"), do: parse_categories_json(payload)
defp normalize_json(payload, "category-meta.json"), do: parse_category_meta_json(payload)
defp normalize_json(payload, "publishing.json"), do: parse_publishing_json(payload)
defp normalize_json(payload, _file_name), do: payload
defp parse_project_metadata_json(payload) when is_map(payload) do
%{
"name" => Map.get(payload, "name"),
"description" => Map.get(payload, "description"),
"public_url" => Map.get(payload, "publicUrl"),
"main_language" => Map.get(payload, "mainLanguage"),
"default_author" => Map.get(payload, "defaultAuthor"),
"max_posts_per_page" => Map.get(payload, "maxPostsPerPage", @default_max_posts_per_page),
"blogmark_category" => Map.get(payload, "blogmarkCategory"),
"pico_theme" => Map.get(payload, "picoTheme"),
"semantic_similarity_enabled" => Map.get(payload, "semanticSimilarityEnabled", false),
"blog_languages" => Map.get(payload, "blogLanguages", [])
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
end
defp parse_categories_json(payload) when is_list(payload), do: %{"categories" => payload}
defp parse_categories_json(_payload), do: %{"categories" => @default_categories}
defp parse_category_meta_json(payload) when is_map(payload) do
%{"categories" => normalized_category_settings(payload)}
end
defp parse_publishing_json(payload) when is_map(payload) do
%{
"ssh_host" => Map.get(payload, "sshHost"),
"ssh_user" => Map.get(payload, "sshUser"),
"ssh_remote_path" => Map.get(payload, "sshRemotePath"),
"ssh_mode" => Map.get(payload, "sshMode", "scp")
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
end
defp normalized_categories(%{"categories" => categories}) when is_list(categories), do: categories
defp normalized_categories(categories) when is_list(categories), do: categories
defp normalized_categories(_payload), do: @default_categories
defp normalized_category_settings(%{"categories" => settings}) when is_map(settings),
do: normalized_category_settings(settings)
defp normalized_category_settings(settings) when is_map(settings) do
Map.new(settings, fn {category, category_settings} ->
{category,
%{
"render_in_lists" =>
Map.get(category_settings, "render_in_lists", Map.get(category_settings, "renderInLists", true)),
"show_title" =>
Map.get(category_settings, "show_title", Map.get(category_settings, "showTitle", true)),
"post_template_slug" =>
Map.get(category_settings, "post_template_slug", Map.get(category_settings, "postTemplateSlug")),
"list_template_slug" =>
Map.get(category_settings, "list_template_slug", Map.get(category_settings, "listTemplateSlug")),
"title" => Map.get(category_settings, "title")
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()}
end)
end
defp render_project_metadata_json(project_metadata) when is_map(project_metadata) do
%{
"name" => Map.get(project_metadata, "name"),
"description" => Map.get(project_metadata, "description"),
"publicUrl" => Map.get(project_metadata, "public_url"),
"mainLanguage" => Map.get(project_metadata, "main_language"),
"defaultAuthor" => Map.get(project_metadata, "default_author"),
"maxPostsPerPage" => Map.get(project_metadata, "max_posts_per_page", @default_max_posts_per_page),
"blogmarkCategory" => Map.get(project_metadata, "blogmark_category"),
"picoTheme" => Map.get(project_metadata, "pico_theme"),
"semanticSimilarityEnabled" => Map.get(project_metadata, "semantic_similarity_enabled", false),
"blogLanguages" => Map.get(project_metadata, "blog_languages", [])
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
end
defp render_category_meta_json(category_settings) when is_map(category_settings) do
Map.new(category_settings, fn {category, settings} ->
{category,
%{
"renderInLists" => Map.get(settings, "render_in_lists", true),
"showTitle" => Map.get(settings, "show_title", true),
"postTemplateSlug" => Map.get(settings, "post_template_slug"),
"listTemplateSlug" => Map.get(settings, "list_template_slug"),
"title" => Map.get(settings, "title")
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()}
end)
end
defp render_publishing_json(publishing_preferences) when is_map(publishing_preferences) do
%{
"sshHost" => Map.get(publishing_preferences, "ssh_host"),
"sshUser" => Map.get(publishing_preferences, "ssh_user"),
"sshRemotePath" => Map.get(publishing_preferences, "ssh_remote_path"),
"sshMode" => Map.get(publishing_preferences, "ssh_mode", "scp")
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
end
defp load_setting(project_id, suffix) do
case Repo.get(Setting, setting_key(project_id, suffix)) do
nil -> nil

View File

@@ -605,20 +605,20 @@ defmodule BDS.Posts do
defp serialize_post_file(post, published_at) do
Frontmatter.serialize_document(
[
{:id, post.id},
{:title, post.title},
{:slug, post.slug},
{:excerpt, post.excerpt},
{:status, :published},
{:author, post.author},
{:language, post.language},
{:do_not_translate, post.do_not_translate},
{:template_slug, post.template_slug},
{:created_at, post.created_at},
{:updated_at, post.updated_at},
{:published_at, published_at},
{:tags, post.tags || []},
{:categories, post.categories || []}
{"id", post.id},
{"title", post.title},
{"slug", post.slug},
{"excerpt", post.excerpt},
{"status", :published},
{"author", post.author},
{"language", post.language},
{"doNotTranslate", post.do_not_translate},
{"templateSlug", post.template_slug},
{"createdAt", post.created_at},
{"updatedAt", post.updated_at},
{"publishedAt", published_at},
{"tags", post.tags || []},
{"categories", post.categories || []}
],
post.content
)
@@ -641,9 +641,8 @@ defmodule BDS.Posts do
end
defp upsert_post_from_file(project_id, project, path) do
project
|> parse_rebuild_file(path)
|> upsert_post_from_rebuild_file(project_id)
rebuild_file = parse_rebuild_file(project, path)
upsert_post_from_rebuild_file(project_id, rebuild_file)
end
defp upsert_post_from_rebuild_file(project_id, rebuild_file) do
@@ -749,8 +748,8 @@ defmodule BDS.Posts do
{"updatedAt", "updated_at"},
{"publishedAt", "published_at"}
]
|> Enum.reduce(fields, fn {legacy_key, current_key}, acc ->
case Map.fetch(acc, legacy_key) do
|> Enum.reduce(fields, fn {file_key, current_key}, acc ->
case Map.fetch(acc, file_key) do
{:ok, value} -> Map.put_new(acc, current_key, normalize_rebuild_field_value(current_key, value))
:error -> acc
end
@@ -875,15 +874,15 @@ defmodule BDS.Posts do
defp serialize_translation_file(translation, published_at) do
Frontmatter.serialize_document(
[
{:id, translation.id},
{:translation_for, translation.translation_for},
{:language, translation.language},
{:title, translation.title},
{:excerpt, translation.excerpt},
{:status, :published},
{:created_at, translation.created_at},
{:updated_at, translation.updated_at},
{:published_at, published_at}
{"id", translation.id},
{"translationFor", translation.translation_for},
{"language", translation.language},
{"title", translation.title},
{"excerpt", translation.excerpt},
{"status", :published},
{"createdAt", translation.created_at},
{"updatedAt", translation.updated_at},
{"publishedAt", published_at}
],
translation.content
)

View File

@@ -3,6 +3,8 @@ defmodule BDS.Scripting.Capabilities do
import Ecto.Query
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
alias BDS.AI
alias BDS.Desktop.FolderPicker
alias BDS.Desktop.MenuBar
@@ -1704,6 +1706,10 @@ defmodule BDS.Scripting.Capabilities do
end
defp test_mode? do
Application.get_env(:bds, :test_mode, false)
Application.get_env(:bds, :test_mode, false) or current_env() == :test
end
defp current_env do
Application.get_env(:bds, :current_env_override) || @compiled_env
end
end

View File

@@ -174,15 +174,16 @@ defmodule BDS.Scripts do
defp serialize_script_file(script, content) do
Frontmatter.serialize_document(
[
{:id, script.id},
{:slug, script.slug},
{:title, script.title},
{:kind, script.kind},
{:entrypoint, script.entrypoint},
{:enabled, script.enabled},
{:version, script.version},
{:created_at, script.created_at},
{:updated_at, script.updated_at}
{"id", script.id},
{"projectId", script.project_id},
{"slug", script.slug},
{"title", script.title},
{"kind", script.kind},
{"entrypoint", script.entrypoint},
{"enabled", script.enabled},
{"version", script.version},
{"createdAt", script.created_at},
{"updatedAt", script.updated_at}
],
content
)
@@ -203,6 +204,7 @@ defmodule BDS.Scripts do
defp upsert_script_from_file(project_id, project, path) do
contents = File.read!(path)
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
fields = normalize_script_fields(fields)
relative_path = Path.relative_to(path, Projects.project_data_dir(project))
now = Persistence.now_ms()
@@ -234,6 +236,20 @@ defmodule BDS.Scripts do
defp parse_script_kind("utility"), do: :utility
defp parse_script_kind("transform"), do: :transform
defp normalize_script_fields(fields) when is_map(fields) do
[
{"createdAt", "created_at"},
{"updatedAt", "updated_at"},
{"projectId", "project_id"}
]
|> Enum.reduce(fields, fn {file_key, current_key}, acc ->
case Map.fetch(acc, file_key) do
{:ok, value} -> Map.put_new(acc, current_key, value)
:error -> acc
end
end)
end
defp list_matching_files(dir, pattern) do
if File.dir?(dir) do
Path.join(dir, pattern)

View File

@@ -145,5 +145,8 @@ defmodule BDS.Sidecar do
end
end
defp timestamp_key?(key), do: String.ends_with?(to_string(key), "_at")
defp timestamp_key?(key) do
rendered = to_string(key)
String.ends_with?(rendered, "_at") or String.ends_with?(rendered, "At")
end
end

View File

@@ -215,17 +215,15 @@ defmodule BDS.Tags do
path = Path.join([Projects.project_data_dir(project), "meta", "tags.json"])
:ok = File.mkdir_p(Path.dirname(path))
payload = %{
"tags" =>
project_id
|> list_tags()
|> Enum.sort_by(&String.downcase(&1.name))
|> Enum.map(fn tag ->
%{"name" => tag.name}
|> maybe_put("color", tag.color)
|> maybe_put("post_template_slug", tag.post_template_slug)
end)
}
payload =
project_id
|> list_tags()
|> Enum.sort_by(&String.downcase(&1.name))
|> Enum.map(fn tag ->
%{"name" => tag.name}
|> maybe_put("color", tag.color)
|> maybe_put("postTemplateSlug", tag.post_template_slug)
end)
:ok = Persistence.atomic_write(path, Jason.encode!(payload))
end

View File

@@ -219,14 +219,15 @@ defmodule BDS.Templates do
defp serialize_template_file(template, content) do
Frontmatter.serialize_document(
[
{:id, template.id},
{:slug, template.slug},
{:title, template.title},
{:kind, template.kind},
{:enabled, template.enabled},
{:version, template.version},
{:created_at, template.created_at},
{:updated_at, template.updated_at}
{"id", template.id},
{"projectId", template.project_id},
{"slug", template.slug},
{"title", template.title},
{"kind", template.kind},
{"enabled", template.enabled},
{"version", template.version},
{"createdAt", template.created_at},
{"updatedAt", template.updated_at}
],
content
)
@@ -342,6 +343,7 @@ defmodule BDS.Templates do
defp upsert_template_from_file(project_id, project, path) do
contents = File.read!(path)
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
fields = normalize_template_fields(fields)
relative_path = Path.relative_to(path, Projects.project_data_dir(project))
now = Persistence.now_ms()
@@ -373,6 +375,20 @@ defmodule BDS.Templates do
defp parse_template_kind("not_found"), do: :not_found
defp parse_template_kind("partial"), do: :partial
defp normalize_template_fields(fields) when is_map(fields) do
[
{"createdAt", "created_at"},
{"updatedAt", "updated_at"},
{"projectId", "project_id"}
]
|> Enum.reduce(fields, fn {file_key, current_key}, acc ->
case Map.fetch(acc, file_key) do
{:ok, value} -> Map.put_new(acc, current_key, value)
:error -> acc
end
end)
end
defp list_matching_files(dir, pattern) do
if File.dir?(dir) do
Path.join(dir, pattern)