defmodule BDS.Menu do
@moduledoc false
require Record
alias BDS.Persistence
alias BDS.Projects
Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl"))
Record.defrecord(
:xmlAttribute,
Record.extract(:xmlAttribute, from_lib: "xmerl/include/xmerl.hrl")
)
@valid_kinds [:page, :submenu, :category_archive, :home]
def get_menu(project_id) do
project = Projects.get_project!(project_id)
{:ok, load_menu(project)}
end
def update_menu(project_id, items) do
project = Projects.get_project!(project_id)
menu = %{items: normalize_menu_items(items)}
:ok = write_menu_file(project, menu)
{:ok, menu}
end
def sync_menu_from_filesystem(project_id) do
project = Projects.get_project!(project_id)
menu = load_menu(project)
:ok = write_menu_file(project, menu)
{:ok, menu}
end
defp load_menu(project) do
case File.read(menu_path(project)) do
{:ok, contents} ->
%{items: parse_opml(contents) |> normalize_menu_items()}
{:error, :enoent} ->
%{items: normalize_menu_items([])}
end
end
defp write_menu_file(project, menu) do
path = menu_path(project)
:ok = Persistence.atomic_write(path, serialize_opml(project, menu.items))
end
defp menu_path(project) do
Path.join([Projects.project_data_dir(project), "meta", "menu.opml"])
end
defp normalize_menu_items(items) do
without_home =
items
|> Enum.map(&normalize_menu_item/1)
|> Enum.reject(&(&1.kind == :home))
[%{kind: :home, label: "Home", slug: nil} | without_home]
end
defp normalize_menu_item(item) do
kind = normalize_kind(attr(item, :kind))
children = attr(item, :children)
base = %{
kind: kind,
label: attr(item, :label) || "",
slug: normalize_optional_string(attr(item, :slug))
}
if kind == :submenu do
Map.put(base, :children, Enum.map(children || [], &normalize_menu_item/1))
else
base
end
end
defp serialize_opml(project, items) do
timestamp = project.updated_at || project.created_at || Persistence.now_ms()
rendered_items =
items
|> Enum.map(&render_item(&1, 2))
|> Enum.join("\n")
[
~s(),
~s(),
~s( ),
~s( #{xml_escape(project.name)}),
~s( #{Persistence.timestamp_to_iso8601(timestamp)}),
~s( #{Persistence.timestamp_to_iso8601(timestamp)}),
~s( ),
~s( ),
rendered_items,
~s( ),
~s(),
""
]
|> Enum.join("\n")
end
defp render_item(item, level) do
indent = String.duplicate(" ", level)
attrs = render_attributes(item)
case Map.get(item, :children) do
children when is_list(children) and children != [] ->
child_markup =
children
|> Enum.map(&render_item(&1, level + 1))
|> Enum.join("\n")
[
"#{indent}",
child_markup,
"#{indent}"
]
|> Enum.join("\n")
_children ->
"#{indent}"
end
end
defp render_attributes(item) do
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
defp parse_opml(contents) do
{document, _rest} = :xmerl_scan.string(String.to_charlist(contents))
:xmerl_xpath.string(~c"/opml/body/outline", document)
|> Enum.map(&parse_outline/1)
end
defp parse_outline(element) do
kind = element |> outline_kind() |> normalize_kind()
base = %{
kind: kind,
label: xml_attr(element, :text) || "",
slug: element |> outline_slug(kind) |> normalize_optional_string()
}
children =
:xmerl_xpath.string(~c"./outline", element)
|> Enum.map(&parse_outline/1)
if kind == :submenu do
Map.put(base, :children, children)
else
base
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)
|> Enum.find_value(fn attribute ->
if xmlAttribute(attribute, :name) == name do
attribute |> xmlAttribute(:value) |> to_string()
end
end)
end
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
BDS.BoundedAtoms.menu_kind(kind, :page)
end
defp normalize_optional_string(nil), do: nil
defp normalize_optional_string(""), do: nil
defp normalize_optional_string(value), do: to_string(value)
defp xml_escape(value) do
value
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.replace(~s("), """)
end
defp attr(attrs, key) do
cond do
Map.has_key?(attrs, key) -> Map.get(attrs, key)
Map.has_key?(attrs, Atom.to_string(key)) -> Map.get(attrs, Atom.to_string(key))
true -> nil
end
end
end