Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions priv/assets/css/ldf.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,14 @@ body::after {
label.seg { display: inline-flex; align-items: center; gap: 7px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 4px; color: var(--dim); cursor: pointer; font-size: 12px; }
label.seg:has(input:checked) { color: var(--live); border-color: var(--live-dim); background: rgba(46,230,166,.05); }
input[type=radio] { accent-color: var(--live); }
input[type=text] {
input[type=text], input[type="datetime-local"] {
flex: 1; min-width: 220px; background: var(--bg); border: 1px solid var(--line);
color: var(--text); font-family: var(--mono); font-size: 13px; padding: 10px 12px; border-radius: 4px;
}
input[type=text]:focus { outline: none; border-color: var(--live-dim); box-shadow: 0 0 0 3px rgba(46,230,166,.12); }
input[type="datetime-local"] { color-scheme: dark; }
input[type=text]:focus, input[type="datetime-local"]:focus { outline: none; border-color: var(--live-dim); box-shadow: 0 0 0 3px rgba(46,230,166,.12); }
input::placeholder { color: var(--faint); }
.hint { color: var(--faint); font-size: 11px; margin: 8px 0 0; }

.btn {
font-family: var(--mono); font-size: 12px; letter-spacing: .1em; text-transform: uppercase;
Expand Down Expand Up @@ -150,6 +152,17 @@ tbody tr.new { animation: flashin .7s ease-out; }
.rec-meta i { color: var(--faint); font-style: normal; text-transform: uppercase; letter-spacing: .08em; margin-right: 6px; }
.rec-meta .rec-agent { flex-basis: 100%; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* per-message ETSI conversion */
.rec-etsi { display: flex; gap: 8px; margin-top: 10px; }
.etsi-btn { padding: 5px 11px; font-size: 10.5px; letter-spacing: .08em; }
.etsi:empty { display: none; }
.etsi-bar { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.etsi-label { color: var(--live); font-size: 10.5px; letter-spacing: .12em; text-transform: uppercase; }
.etsi-label.etsi-err { color: var(--danger); }
.etsi-hide { margin-left: auto; padding: 4px 10px; font-size: 10.5px; }
.etsi-pre { margin: 8px 0 0; padding: 12px 14px; background: var(--bg); border: 1px solid var(--line); border-radius: 4px; max-height: 360px; overflow: auto; font-size: 11.5px; line-height: 1.45; white-space: pre; }
.etsi-pre code { color: var(--text); }

.empty { padding: 40px 18px; text-align: center; color: var(--faint); font-size: 12.5px; }
.empty::before { content: "▱ "; color: var(--live-dim); }

Expand Down
59 changes: 56 additions & 3 deletions src/controllers/ldf_www_controller.erl
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
add_listener/1,
remove_listener/1,
submit_history/1,
message_etsi/1,
publish_message/2
]).

Expand Down Expand Up @@ -66,7 +67,7 @@ remove_listener(#{bindings := #{~"callbackid" := CallbackId}}) ->
submit_history(Req0) ->
case datastar_nova:read_signals(Req0) of
{{ok, #{~"type" := Type, ~"value" := Value} = Signals}, _Req} when Value =/= ~"" ->
Timestamp = maps:get(~"timestamp", Signals, ~""),
Timestamp = ldf_format:datetime_to_ms(maps:get(~"timestamp", Signals, ~"")),
ldf_srv:get_history(
thoas:encode(#{type => Type, value => Value, timestamp => Timestamp})
),
Expand All @@ -75,6 +76,22 @@ submit_history(Req0) ->
status_patch(~"Target type and value are required.")
end.

%% Convert a stored message to the requested ETSI XML and inject it, pretty
%% printed, under its row. Any other format (e.g. "hide") clears the block.
message_etsi(#{bindings := #{~"messageid" := MessageId, ~"format" := Format}}) when
Format =:= ~"103707"; Format =:= ~"103120"
->
Block =
case ldf_db:get_message(MessageId) of
{ok, [#{payload := Payload} | _]} ->
render_etsi(Format, MessageId, safe_decode(Payload));
_ ->
etsi_error(~"Message not found.")
end,
{datastar, [patch_xml(MessageId, Block)]};
message_etsi(#{bindings := #{~"messageid" := MessageId}}) ->
{datastar, [patch_xml(MessageId, ~"")]}.

%% --- published by the message API on each intercepted message -------------

publish_message(_MessageId, Payload) ->
Expand Down Expand Up @@ -126,7 +143,8 @@ fields(Msg) ->
{sender, short(maps:get(~"sender", Msg, ~""))},
{recipient, short(maps:get(~"to", Msg, ~""))},
{chat, short(maps:get(~"chat_id", Msg, ~""))},
{id, short(maps:get(~"id", Msg, ~""))}
{id, short(maps:get(~"id", Msg, ~""))},
{full_id, maps:get(~"id", Msg, ~"")}
| Payload
].

Expand All @@ -141,7 +159,8 @@ blank_fields() ->
{sender, ~""},
{recipient, ~""},
{chat, ~""},
{id, ~""}
{id, ~""},
{full_id, ~""}
].

safe_decode(Bin) ->
Expand All @@ -156,6 +175,40 @@ fmt_time(Ms) when is_integer(Ms) ->
fmt_time(_) ->
~"".

patch_xml(MessageId, Block) ->
datastar:patch_elements(Block, #{selector => <<"#xml-", MessageId/binary>>, mode => inner}).

render_etsi(Format, MessageId, Msg) when is_map(Msg) ->
try to_etsi(Format, Msg) of
Xml ->
etsi_block(Format, MessageId, ldf_format:html_escape(ldf_format:pretty_xml(Xml)))
catch
_:_ -> etsi_error(~"Could not convert this message.")
end;
render_etsi(_Format, _MessageId, _Msg) ->
etsi_error(~"Malformed message payload.").

to_etsi(~"103120", Msg) ->
etsi103120:json_to_xml(Msg, ~"undefined", ~"undefined", ~"undefined");
to_etsi(_, Msg) ->
etsi103707:json_to_xml(Msg).

etsi_block(Format, MessageId, EscapedXml) ->
iolist_to_binary([
~"<div class=\"etsi-bar\"><span class=\"etsi-label\">ETSI ",
Format,
~"</span><button class=\"btn-ghost etsi-hide\" data-on:click=\"@get('/www/message/",
MessageId,
~"/hide')\">hide</button></div><pre class=\"etsi-pre\"><code>",
EscapedXml,
~"</code></pre>"
]).

etsi_error(Text) ->
iolist_to_binary([
~"<div class=\"etsi-bar\"><span class=\"etsi-label etsi-err\">", Text, ~"</span></div>"
]).

short(<<Eight:8/binary, _/binary>>) -> Eight;
short(Bin) when is_binary(Bin) -> Bin;
short(_) -> ~"".
Expand Down
57 changes: 57 additions & 0 deletions src/ldf_format.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
-module(ldf_format).

-export([pretty_xml/1, html_escape/1, datetime_to_ms/1]).

pretty_xml(Xml) ->
Spaced = binary:replace(Xml, ~"><", ~">\n<", [global]),
Lines = binary:split(Spaced, ~"\n", [global]),
iolist_to_binary(lists:join(~"\n", reindent(Lines, 0))).

reindent([], _Depth) ->
[];
reindent([Line | Rest], Depth) ->
{Out, Next} = format_line(Line, Depth),
[Out | reindent(Rest, Next)].

format_line(<<"</", _/binary>> = Line, Depth) ->
Indent = max(Depth - 1, 0),
{[pad(Indent), Line], Indent};
format_line(<<"<?", _/binary>> = Line, Depth) ->
{[pad(Depth), Line], Depth};
format_line(<<"<", _/binary>> = Line, Depth) ->
case self_contained(Line) of
true -> {[pad(Depth), Line], Depth};
false -> {[pad(Depth), Line], Depth + 1}
end;
format_line(Line, Depth) ->
{[pad(Depth), Line], Depth}.

self_contained(Line) ->
Closes = binary:match(Line, ~"</") =/= nomatch,
Empty = byte_size(Line) >= 2 andalso binary:part(Line, byte_size(Line) - 2, 2) =:= ~"/>",
Closes orelse Empty.

pad(N) ->
binary:copy(~" ", N).

html_escape(Bin) ->
Amp = binary:replace(Bin, ~"&", ~"&amp;", [global]),
Lt = binary:replace(Amp, ~"<", ~"&lt;", [global]),
binary:replace(Lt, ~">", ~"&gt;", [global]).

datetime_to_ms(
<<Y:4/binary, "-", Mo:2/binary, "-", D:2/binary, "T", H:2/binary, ":", Mi:2/binary, Rest/binary>>
) ->
Seconds =
case Rest of
<<":", S:2/binary>> -> binary_to_integer(S);
_ -> 0
end,
DateTime = {
{binary_to_integer(Y), binary_to_integer(Mo), binary_to_integer(D)},
{binary_to_integer(H), binary_to_integer(Mi), Seconds}
},
Epoch = 62167219200,
(calendar:datetime_to_gregorian_seconds(DateTime) - Epoch) * 1000;
datetime_to_ms(_) ->
0.
3 changes: 3 additions & 0 deletions src/ldf_router.erl
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ routes(_Env) ->
{"/receiver", fun ldf_receiver_controller:create_message/1, #{methods => [post]}},
{"/receiver", fun ldf_receiver_controller:get_message/1, #{methods => [get]}},
{"/message/:messageid", fun ldf_message_controller:message/1, #{methods => [get]}},
{"/www/message/:messageid/:format", fun ldf_www_controller:message_etsi/1, #{
methods => [get]
}},
{"/li", fun ldf_li_controller:create_li/1, #{methods => [post]}},
{"/li", fun ldf_li_controller:manage_li/1, #{methods => [get]}},
{"/history", fun ldf_li_controller:manage_history/1, #{methods => [post]}},
Expand Down
3 changes: 2 additions & 1 deletion src/views/history.dtl
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
<input type="text" data-bind:value placeholder="target@example.com">
</div>
<div class="row" style="margin-top:12px">
<input type="text" data-bind:timestamp placeholder="from timestamp · ms since epoch">
<input type="datetime-local" step="1" data-bind:timestamp>
<button class="btn" data-on:click="@post('/www/history')">Submit request</button>
</div>
<p class="hint">Replays messages from this point in time onward (UTC).</p>
<div id="hist-status" style="margin-top:16px;color:var(--dim);font-size:12.5px">&nbsp;</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/views/message_row.dtl
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<div class="rec{% if new %} new{% endif %}"><div class="rec-head"><span class="rec-from">{% if email %}{{ email }}{% endif %}{% if phone %}<span class="rec-phone">{{ phone }}</span>{% endif %}{% if not email %}{% if not phone %}<span class="rec-phone">unidentified</span>{% endif %}{% endif %}</span><span class="rec-time">{{ time }}</span></div><div class="rec-body">{% if link %}<a href="{{ link }}" target="_blank" rel="noopener">{{ link }}</a>{% else %}{{ text }}{% endif %}</div><div class="rec-meta"><span><i>type</i>{{ type }}{% if action %} / {{ action }}{% endif %}</span><span><i>msg</i>{{ id }}</span><span><i>chat</i>{{ chat }}</span><span><i>sender</i>{{ sender }}</span><span><i>to</i>{{ recipient }}</span>{% if agent %}<span class="rec-agent"><i>agent</i>{{ agent }}</span>{% endif %}</div></div>
<div class="rec{% if new %} new{% endif %}"><div class="rec-head"><span class="rec-from">{% if email %}{{ email }}{% endif %}{% if phone %}<span class="rec-phone">{{ phone }}</span>{% endif %}{% if not email %}{% if not phone %}<span class="rec-phone">unidentified</span>{% endif %}{% endif %}</span><span class="rec-time">{{ time }}</span></div><div class="rec-body">{% if link %}<a href="{{ link }}" target="_blank" rel="noopener">{{ link }}</a>{% else %}{{ text }}{% endif %}</div><div class="rec-meta"><span><i>type</i>{{ type }}{% if action %} / {{ action }}{% endif %}</span><span><i>msg</i>{{ id }}</span><span><i>chat</i>{{ chat }}</span><span><i>sender</i>{{ sender }}</span><span><i>to</i>{{ recipient }}</span>{% if agent %}<span class="rec-agent"><i>agent</i>{{ agent }}</span>{% endif %}</div>{% if full_id %}<div class="rec-etsi"><button class="btn-ghost etsi-btn" data-on:click="@get('/www/message/{{ full_id }}/103707')">103707 XML</button><button class="btn-ghost etsi-btn" data-on:click="@get('/www/message/{{ full_id }}/103120')">103120 XML</button></div><div id="xml-{{ full_id }}" class="etsi"></div>{% endif %}</div>
33 changes: 33 additions & 0 deletions test/ldf_format_tests.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-module(ldf_format_tests).

-include_lib("eunit/include/eunit.hrl").

pretty_xml_test() ->
?assertEqual(
~"<a>\n <b>x</b>\n</a>",
ldf_format:pretty_xml(~"<a><b>x</b></a>")
).

pretty_xml_with_prolog_test() ->
?assertEqual(
~"<?xml version=\"1.0\"?>\n<a>\n <b>1</b>\n</a>",
ldf_format:pretty_xml(~"<?xml version=\"1.0\"?><a><b>1</b></a>")
).

html_escape_test() ->
?assertEqual(~"&lt;a&gt;&amp;b", ldf_format:html_escape(~"<a>&b")).

datetime_to_ms_with_seconds_test() ->
Expected =
(calendar:datetime_to_gregorian_seconds({{2024, 6, 25}, {15, 0, 30}}) - 62167219200) *
1000,
?assertEqual(Expected, ldf_format:datetime_to_ms(~"2024-06-25T15:00:30")).

datetime_to_ms_without_seconds_test() ->
Expected =
(calendar:datetime_to_gregorian_seconds({{2024, 6, 25}, {15, 0, 0}}) - 62167219200) *
1000,
?assertEqual(Expected, ldf_format:datetime_to_ms(~"2024-06-25T15:00")).

datetime_to_ms_empty_test() ->
?assertEqual(0, ldf_format:datetime_to_ms(~"")).
Loading