aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristopher R. Nelson <christopher.nelson@languidnights.com>2026-07-21 10:54:00 -0400
committerChristopher R. Nelson <christopher.nelson@languidnights.com>2026-07-21 10:54:00 -0400
commit46b72a375101b54819f8750750017bd71e7c3798 (patch)
treee8d72906b59252bd01939a5806235ccc75934f56
parent6bbc784b4cf0717ddc7f5c3ad32c1d5e2d486877 (diff)
Group menu items into per-category submenus
Parse the Categories key from .desktop files and have the renderer emit one <menu> submenu per category (via xmerl), with items sorted within each; entries in multiple categories appear in each submenu, and uncategorized entries fall into a catch-all "Other" submenu. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--include/er_xdg_pipe_menu.hrl1
-rw-r--r--src/er_xdg_pipe_menu.app.src3
-rw-r--r--src/er_xdg_pipe_menu_desktop_entry.erl16
-rw-r--r--src/er_xdg_pipe_menu_renderer.erl94
-rw-r--r--test/er_xdg_pipe_menu_desktop_entry_tests.erl41
-rw-r--r--test/er_xdg_pipe_menu_renderer_tests.erl129
6 files changed, 206 insertions, 78 deletions
diff --git a/include/er_xdg_pipe_menu.hrl b/include/er_xdg_pipe_menu.hrl
index 2cee607..6d50b8d 100644
--- a/include/er_xdg_pipe_menu.hrl
+++ b/include/er_xdg_pipe_menu.hrl
@@ -2,5 +2,6 @@
name :: binary(),
exec :: binary(),
icon :: binary() | undefined,
+ categories :: [binary()],
hidden :: boolean()
}).
diff --git a/src/er_xdg_pipe_menu.app.src b/src/er_xdg_pipe_menu.app.src
index 7013bc7..1649ec3 100644
--- a/src/er_xdg_pipe_menu.app.src
+++ b/src/er_xdg_pipe_menu.app.src
@@ -5,7 +5,8 @@
{mod, {er_xdg_pipe_menu_app, []}},
{applications, [
kernel,
- stdlib
+ stdlib,
+ xmerl
]},
{env, []},
{modules, []},
diff --git a/src/er_xdg_pipe_menu_desktop_entry.erl b/src/er_xdg_pipe_menu_desktop_entry.erl
index bbd2594..9a71013 100644
--- a/src/er_xdg_pipe_menu_desktop_entry.erl
+++ b/src/er_xdg_pipe_menu_desktop_entry.erl
@@ -3,8 +3,9 @@
%%
%% Reads the `[Desktop Entry]' group of a desktop entry file and
%% extracts the fields needed to build a menu item: `Name', `Exec'
-%% and (optionally) `Icon'. Other groups (e.g. `[Desktop Action ...]')
-%% and localized keys (e.g. `Name[fr]') are ignored.
+%% and (optionally) `Icon' and `Categories'. Other groups (e.g.
+%% `[Desktop Action ...]') and localized keys (e.g. `Name[fr]') are
+%% ignored.
%% @end
%%%-------------------------------------------------------------------
@@ -16,7 +17,7 @@
-define(GROUP, <<"Desktop Entry">>).
-define(WANTED_KEYS, [
- <<"Name">>, <<"Exec">>, <<"Icon">>, <<"NoDisplay">>, <<"Hidden">>, <<"Type">>
+ <<"Name">>, <<"Exec">>, <<"Icon">>, <<"Categories">>, <<"NoDisplay">>, <<"Hidden">>, <<"Type">>
]).
-define(FIELD_CODES, "fFuUdDnNickvm").
@@ -99,11 +100,20 @@ to_entry(Fields) ->
name = Name,
exec = strip_field_codes(Exec),
icon = maps:get(<<"Icon">>, Fields, undefined),
+ categories = parse_categories(maps:get(<<"Categories">>, Fields, undefined)),
hidden = is_hidden(Fields)
}}
end
end.
+%% `Categories' is a `;'-separated list, conventionally with a
+%% trailing separator (e.g. `Utility;Development;'); empty segments
+%% from that trailing separator (or repeated ones) are dropped.
+parse_categories(undefined) ->
+ [];
+parse_categories(Bin) ->
+ [C || C <- binary:split(Bin, <<";">>, [global]), C =/= <<>>].
+
%% Niche variations (e.g. a Type-less override dropped into
%% ~/.local/share/applications) are left to be special-cased there
%% rather than accounted for here.
diff --git a/src/er_xdg_pipe_menu_renderer.erl b/src/er_xdg_pipe_menu_renderer.erl
index fafed45..f89d868 100644
--- a/src/er_xdg_pipe_menu_renderer.erl
+++ b/src/er_xdg_pipe_menu_renderer.erl
@@ -2,9 +2,13 @@
%% @doc Renders parsed desktop entries as an Openbox pipe menu.
%%
%% Given a list of `#desktop_entry{}' records, produces the XML that
-%% Openbox expects on stdout from a pipe menu: one `<item>' per
-%% visible entry (entries with `hidden = true' are dropped), sorted
-%% case-insensitively by name, each wrapping an `Execute' action.
+%% Openbox expects on stdout from a pipe menu: one `<menu>' submenu
+%% per category, each containing an `<item>' per visible entry in
+%% that category (entries with `hidden = true' are dropped). An entry
+%% belonging to several categories appears in each of their submenus;
+%% an entry with no categories is placed in a catch-all `Other'
+%% submenu. Categories are sorted case-insensitively, and entries
+%% within a submenu are sorted case-insensitively by name.
%% @end
%%%-------------------------------------------------------------------
@@ -14,45 +18,61 @@
-export([render/1]).
+-define(OTHER_CATEGORY, <<"Other">>).
+
-spec render([#desktop_entry{}]) -> binary().
render(Entries) ->
Visible = [E || E <- Entries, not E#desktop_entry.hidden],
- Sorted = lists:sort(fun by_name/2, Visible),
- iolist_to_binary([
- <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n">>,
- <<"<openbox_pipe_menu>\n">>,
- [render_item(E) || E <- Sorted],
- <<"</openbox_pipe_menu>\n">>
- ]).
+ Grouped = group_by_category(Visible),
+ Categories = lists:sort(fun category_lt/2, maps:keys(Grouped)),
+ Menu = {openbox_pipe_menu, [], submenus_content(Categories, Grouped)},
+ Xml = xmerl:export_simple(
+ [Menu],
+ xmerl_xml,
+ [{prolog, ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"]}]
+ ),
+ iolist_to_binary([Xml, "\n"]).
+
+group_by_category(Entries) ->
+ lists:foldl(fun add_entry_to_categories/2, #{}, Entries).
+
+add_entry_to_categories(#desktop_entry{categories = []} = E, Acc) ->
+ add_entry_to_category(?OTHER_CATEGORY, E, Acc);
+add_entry_to_categories(#desktop_entry{categories = Cats} = E, Acc) ->
+ lists:foldl(fun(Cat, Acc0) -> add_entry_to_category(Cat, E, Acc0) end, Acc, Cats).
+
+add_entry_to_category(Cat, E, Acc) ->
+ maps:update_with(Cat, fun(Es) -> [E | Es] end, [E], Acc).
+
+category_lt(A, B) -> string:lowercase(A) =< string:lowercase(B).
by_name(#desktop_entry{name = A}, #desktop_entry{name = B}) ->
string:lowercase(A) =< string:lowercase(B).
-render_item(#desktop_entry{name = Name, exec = Exec, icon = Icon}) ->
- [
- <<" <item label=\"">>,
- escape_attr(Name),
- <<"\"">>,
- icon_attr(Icon),
- <<">\n">>,
- <<" <action name=\"Execute\">\n">>,
- <<" <command>">>,
- escape_text(Exec),
- <<"</command>\n">>,
- <<" </action>\n">>,
- <<" </item>\n">>
- ].
-
-icon_attr(undefined) -> <<>>;
-icon_attr(Icon) -> [<<" icon=\"">>, escape_attr(Icon), <<"\"">>].
-
-%% Attribute values additionally need '"' escaped; text content doesn't.
-escape_attr(Bin) -> escape(Bin, [{<<"\"">>, <<"&quot;">>}]).
-escape_text(Bin) -> escape(Bin, []).
-
-escape(Bin, Extra) ->
- lists:foldl(
- fun({Pat, Repl}, Acc) -> binary:replace(Acc, Pat, Repl, [global]) end,
- Bin,
- [{<<"&">>, <<"&amp;">>}, {<<"<">>, <<"&lt;">>}, {<<">">>, <<"&gt;">>} | Extra]
+submenus_content(Categories, Grouped) ->
+ lists:foldr(
+ fun(Cat, Acc) -> ["\n ", submenu_element(Cat, Grouped) | Acc] end, ["\n"], Categories
).
+
+submenu_element(Cat, Grouped) ->
+ Entries = lists:sort(fun by_name/2, maps:get(Cat, Grouped)),
+ {menu, [{id, Cat}, {label, Cat}], items_content(Entries)}.
+
+items_content(Entries) ->
+ lists:foldr(fun(E, Acc) -> ["\n ", item_element(E) | Acc] end, ["\n "], Entries).
+
+item_element(#desktop_entry{name = Name, exec = Exec, icon = Icon}) ->
+ {item, item_attrs(Name, Icon), [
+ "\n ",
+ {action, [{name, <<"Execute">>}], [
+ "\n ",
+ {command, [], [text(Exec)]},
+ "\n "
+ ]},
+ "\n "
+ ]}.
+
+item_attrs(Name, undefined) -> [{label, Name}];
+item_attrs(Name, Icon) -> [{label, Name}, {icon, Icon}].
+
+text(Bin) -> unicode:characters_to_list(Bin).
diff --git a/test/er_xdg_pipe_menu_desktop_entry_tests.erl b/test/er_xdg_pipe_menu_desktop_entry_tests.erl
index 399e3a5..bcea976 100644
--- a/test/er_xdg_pipe_menu_desktop_entry_tests.erl
+++ b/test/er_xdg_pipe_menu_desktop_entry_tests.erl
@@ -13,7 +13,7 @@ basic_entry_test() ->
>>,
?assertEqual(
{ok, #desktop_entry{
- name = <<"Firefox">>, exec = <<"firefox">>, icon = <<"firefox">>, hidden = false
+ name = <<"Firefox">>, exec = <<"firefox">>, icon = <<"firefox">>, categories = [], hidden = false
}},
er_xdg_pipe_menu_desktop_entry:parse(Bin)
).
@@ -25,10 +25,35 @@ icon_is_optional_test() ->
"Exec=foo\n"
>>,
?assertEqual(
- {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}},
+ {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}},
er_xdg_pipe_menu_desktop_entry:parse(Bin)
).
+categories_are_split_on_semicolon_test() ->
+ Bin = <<
+ "[Desktop Entry]\n"
+ "Name=Foo\n"
+ "Exec=foo\n"
+ "Categories=Utility;Development;\n"
+ >>,
+ {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin),
+ ?assertEqual([<<"Utility">>, <<"Development">>], Entry#desktop_entry.categories).
+
+categories_is_empty_list_when_absent_test() ->
+ Bin = <<"[Desktop Entry]\nName=Foo\nExec=foo\n">>,
+ {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin),
+ ?assertEqual([], Entry#desktop_entry.categories).
+
+repeated_category_separators_are_ignored_test() ->
+ Bin = <<
+ "[Desktop Entry]\n"
+ "Name=Foo\n"
+ "Exec=foo\n"
+ "Categories=Utility;;Development\n"
+ >>,
+ {ok, Entry} = er_xdg_pipe_menu_desktop_entry:parse(Bin),
+ ?assertEqual([<<"Utility">>, <<"Development">>], Entry#desktop_entry.categories).
+
missing_name_test() ->
Bin = <<"[Desktop Entry]\nExec=foo\n">>,
?assertEqual({error, missing_name}, er_xdg_pipe_menu_desktop_entry:parse(Bin)).
@@ -48,7 +73,7 @@ ignores_other_groups_test() ->
"Exec=foo --new-window\n"
>>,
?assertEqual(
- {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}},
+ {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}},
er_xdg_pipe_menu_desktop_entry:parse(Bin)
).
@@ -63,7 +88,7 @@ ignores_comments_and_blank_lines_test() ->
"Exec=foo\n"
>>,
?assertEqual(
- {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}},
+ {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}},
er_xdg_pipe_menu_desktop_entry:parse(Bin)
).
@@ -74,7 +99,7 @@ strips_multiple_field_codes_test() ->
"Exec=foo %f --flag %i %c\n"
>>,
?assertEqual(
- {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo --flag">>, icon = undefined, hidden = false}},
+ {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo --flag">>, icon = undefined, categories = [], hidden = false}},
er_xdg_pipe_menu_desktop_entry:parse(Bin)
).
@@ -86,7 +111,7 @@ literal_percent_is_unescaped_test() ->
>>,
?assertEqual(
{ok, #desktop_entry{
- name = <<"Foo">>, exec = <<"foo --progress=%">>, icon = undefined, hidden = false
+ name = <<"Foo">>, exec = <<"foo --progress=%">>, icon = undefined, categories = [], hidden = false
}},
er_xdg_pipe_menu_desktop_entry:parse(Bin)
).
@@ -94,7 +119,7 @@ literal_percent_is_unescaped_test() ->
unescapes_value_test() ->
Bin = <<"[Desktop Entry]\nName=Foo\\sBar\nExec=foo\n">>,
?assertEqual(
- {ok, #desktop_entry{name = <<"Foo Bar">>, exec = <<"foo">>, icon = undefined, hidden = false}},
+ {ok, #desktop_entry{name = <<"Foo Bar">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}},
er_xdg_pipe_menu_desktop_entry:parse(Bin)
).
@@ -141,7 +166,7 @@ parse_file_test() ->
Result = er_xdg_pipe_menu_desktop_entry:parse_file(Path),
ok = file:delete(Path),
?assertEqual(
- {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, hidden = false}},
+ {ok, #desktop_entry{name = <<"Foo">>, exec = <<"foo">>, icon = undefined, categories = [], hidden = false}},
Result
).
diff --git a/test/er_xdg_pipe_menu_renderer_tests.erl b/test/er_xdg_pipe_menu_renderer_tests.erl
index 1fc0192..7273625 100644
--- a/test/er_xdg_pipe_menu_renderer_tests.erl
+++ b/test/er_xdg_pipe_menu_renderer_tests.erl
@@ -5,10 +5,15 @@
-include("er_xdg_pipe_menu.hrl").
entry(Name, Exec, Icon) ->
- entry(Name, Exec, Icon, false).
+ entry(Name, Exec, Icon, []).
-entry(Name, Exec, Icon, Hidden) ->
- #desktop_entry{name = Name, exec = Exec, icon = Icon, hidden = Hidden}.
+entry(Name, Exec, Icon, Categories) ->
+ entry(Name, Exec, Icon, Categories, false).
+
+entry(Name, Exec, Icon, Categories, Hidden) ->
+ #desktop_entry{
+ name = Name, exec = Exec, icon = Icon, categories = Categories, hidden = Hidden
+ }.
empty_list_renders_empty_menu_test() ->
?assertEqual(
@@ -19,47 +24,76 @@ empty_list_renders_empty_menu_test() ->
).
single_entry_with_icon_test() ->
- Rendered = er_xdg_pipe_menu_renderer:render([entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>)]),
+ Rendered = er_xdg_pipe_menu_renderer:render([
+ entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>, [<<"Network">>])
+ ]),
?assertEqual(
<<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<openbox_pipe_menu>\n"
- " <item label=\"Firefox\" icon=\"firefox\">\n"
- " <action name=\"Execute\">\n"
- " <command>firefox</command>\n"
- " </action>\n"
- " </item>\n"
+ " <menu id=\"Network\" label=\"Network\">\n"
+ " <item label=\"Firefox\" icon=\"firefox\">\n"
+ " <action name=\"Execute\">\n"
+ " <command>firefox</command>\n"
+ " </action>\n"
+ " </item>\n"
+ " </menu>\n"
"</openbox_pipe_menu>\n">>,
Rendered
).
entry_without_icon_omits_icon_attr_test() ->
+ Rendered = er_xdg_pipe_menu_renderer:render([
+ entry(<<"Foo">>, <<"foo">>, undefined, [<<"Utility">>])
+ ]),
+ ?assertEqual(
+ <<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<openbox_pipe_menu>\n"
+ " <menu id=\"Utility\" label=\"Utility\">\n"
+ " <item label=\"Foo\">\n"
+ " <action name=\"Execute\">\n"
+ " <command>foo</command>\n"
+ " </action>\n"
+ " </item>\n"
+ " </menu>\n"
+ "</openbox_pipe_menu>\n">>,
+ Rendered
+ ).
+
+entries_without_categories_go_in_other_submenu_test() ->
Rendered = er_xdg_pipe_menu_renderer:render([entry(<<"Foo">>, <<"foo">>, undefined)]),
?assertEqual(
<<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<openbox_pipe_menu>\n"
- " <item label=\"Foo\">\n"
- " <action name=\"Execute\">\n"
- " <command>foo</command>\n"
- " </action>\n"
- " </item>\n"
+ " <menu id=\"Other\" label=\"Other\">\n"
+ " <item label=\"Foo\">\n"
+ " <action name=\"Execute\">\n"
+ " <command>foo</command>\n"
+ " </action>\n"
+ " </item>\n"
+ " </menu>\n"
"</openbox_pipe_menu>\n">>,
Rendered
).
hidden_entries_are_dropped_test() ->
Entries = [
- entry(<<"Visible">>, <<"visible">>, undefined),
- entry(<<"Invisible">>, <<"invisible">>, undefined, true)
+ entry(<<"Visible">>, <<"visible">>, undefined, [<<"Utility">>]),
+ entry(<<"Invisible">>, <<"invisible">>, undefined, [<<"Utility">>], true)
],
Rendered = er_xdg_pipe_menu_renderer:render(Entries),
?assertNot(binary:match(Rendered, <<"Invisible">>) =/= nomatch),
?assert(binary:match(Rendered, <<"Visible">>) =/= nomatch).
-entries_are_sorted_case_insensitively_test() ->
+hidden_entry_leaves_no_empty_submenu_test() ->
+ Entries = [entry(<<"Invisible">>, <<"invisible">>, undefined, [<<"Utility">>], true)],
+ Rendered = er_xdg_pipe_menu_renderer:render(Entries),
+ ?assertNot(binary:match(Rendered, <<"Utility">>) =/= nomatch).
+
+entries_are_sorted_case_insensitively_within_a_submenu_test() ->
Entries = [
- entry(<<"zeta">>, <<"zeta">>, undefined),
- entry(<<"Alpha">>, <<"alpha">>, undefined),
- entry(<<"beta">>, <<"beta">>, undefined)
+ entry(<<"zeta">>, <<"zeta">>, undefined, [<<"Utility">>]),
+ entry(<<"Alpha">>, <<"alpha">>, undefined, [<<"Utility">>]),
+ entry(<<"beta">>, <<"beta">>, undefined, [<<"Utility">>])
],
Rendered = er_xdg_pipe_menu_renderer:render(Entries),
Positions = [
@@ -69,28 +103,65 @@ entries_are_sorted_case_insensitively_test() ->
],
?assertEqual(Positions, lists:sort(Positions)).
+submenus_are_sorted_case_insensitively_test() ->
+ Entries = [
+ entry(<<"Zed App">>, <<"zed">>, undefined, [<<"zeta">>]),
+ entry(<<"Alpha App">>, <<"alpha">>, undefined, [<<"Alpha">>]),
+ entry(<<"Beta App">>, <<"beta">>, undefined, [<<"beta">>])
+ ],
+ Rendered = er_xdg_pipe_menu_renderer:render(Entries),
+ Positions = [
+ element(1, binary:match(Rendered, <<"label=\"Alpha\"">>)),
+ element(1, binary:match(Rendered, <<"label=\"beta\"">>)),
+ element(1, binary:match(Rendered, <<"label=\"zeta\"">>))
+ ],
+ ?assertEqual(Positions, lists:sort(Positions)).
+
+entry_with_multiple_categories_appears_in_each_submenu_test() ->
+ Entries = [entry(<<"Foo">>, <<"foo">>, undefined, [<<"Utility">>, <<"Development">>])],
+ Rendered = er_xdg_pipe_menu_renderer:render(Entries),
+ {Doc, _Rest} = xmerl_scan:string(binary_to_list(Rendered)),
+ Menus = [E || E = #xmlElement{name = menu} <- Doc#xmlElement.content],
+ ?assertEqual([<<"Development">>, <<"Utility">>], lists:sort([menu_id(M) || M <- Menus])),
+ Items = [I || M <- Menus, I = #xmlElement{name = item} <- M#xmlElement.content],
+ ?assertEqual(2, length(Items)).
+
escapes_special_characters_test() ->
- Entry = entry(<<"Foo & <Bar> \"Baz\"">>, <<"foo --title=\"<x>&y\"">>, <<"icon\"&<>">>),
+ Entry = entry(
+ <<"Foo & <Bar> \"Baz\"">>,
+ <<"foo --title=\"<x>&y\"">>,
+ <<"icon\"&<>">>,
+ [<<"Foo & <Bar>">>]
+ ),
Rendered = er_xdg_pipe_menu_renderer:render([Entry]),
?assertEqual(
<<"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<openbox_pipe_menu>\n"
- " <item label=\"Foo &amp; &lt;Bar&gt; &quot;Baz&quot;\" icon=\"icon&quot;&amp;&lt;&gt;\">\n"
- " <action name=\"Execute\">\n"
- " <command>foo --title=\"&lt;x&gt;&amp;y\"</command>\n"
- " </action>\n"
- " </item>\n"
+ " <menu id=\"Foo &amp; &lt;Bar>\" label=\"Foo &amp; &lt;Bar>\">\n"
+ " <item label=\"Foo &amp; &lt;Bar> &quot;Baz&quot;\" icon=\"icon&quot;&amp;&lt;>\">\n"
+ " <action name=\"Execute\">\n"
+ " <command>foo --title=\"&lt;x&gt;&amp;y\"</command>\n"
+ " </action>\n"
+ " </item>\n"
+ " </menu>\n"
"</openbox_pipe_menu>\n">>,
Rendered
).
output_is_well_formed_xml_test() ->
Entries = [
- entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>),
- entry(<<"Foo & Bar">>, <<"foo">>, undefined)
+ entry(<<"Firefox">>, <<"firefox">>, <<"firefox">>, [<<"Network">>]),
+ entry(<<"Foo & Bar">>, <<"foo">>, undefined, [<<"Network">>])
],
Rendered = er_xdg_pipe_menu_renderer:render(Entries),
{Doc, _Rest} = xmerl_scan:string(binary_to_list(Rendered)),
?assertEqual(openbox_pipe_menu, Doc#xmlElement.name),
- Items = [E || E = #xmlElement{name = item} <- Doc#xmlElement.content],
+ Menus = [E || E = #xmlElement{name = menu} <- Doc#xmlElement.content],
+ ?assertEqual(1, length(Menus)),
+ [Menu] = Menus,
+ Items = [E || E = #xmlElement{name = item} <- Menu#xmlElement.content],
?assertEqual(2, length(Items)).
+
+menu_id(#xmlElement{attributes = Attrs}) ->
+ [Value] = [V || #xmlAttribute{name = id, value = V} <- Attrs],
+ list_to_binary(Value).