diff --git a/priv/assets/css/ldf.css b/priv/assets/css/ldf.css index 5de2ef8..af9c7ea 100644 --- a/priv/assets/css/ldf.css +++ b/priv/assets/css/ldf.css @@ -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; @@ -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); } diff --git a/src/controllers/ldf_www_controller.erl b/src/controllers/ldf_www_controller.erl index 068bfcf..4d5aac7 100644 --- a/src/controllers/ldf_www_controller.erl +++ b/src/controllers/ldf_www_controller.erl @@ -12,6 +12,7 @@ add_listener/1, remove_listener/1, submit_history/1, + message_etsi/1, publish_message/2 ]). @@ -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}) ), @@ -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) -> @@ -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 ]. @@ -141,7 +159,8 @@ blank_fields() -> {sender, ~""}, {recipient, ~""}, {chat, ~""}, - {id, ~""} + {id, ~""}, + {full_id, ~""} ]. safe_decode(Bin) -> @@ -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([ + ~"
ETSI ", + Format, + ~"
",
+        EscapedXml,
+        ~"
" + ]). + +etsi_error(Text) -> + iolist_to_binary([ + ~"
", Text, ~"
" + ]). + short(<>) -> Eight; short(Bin) when is_binary(Bin) -> Bin; short(_) -> ~"". diff --git a/src/ldf_format.erl b/src/ldf_format.erl new file mode 100644 index 0000000..05f9be4 --- /dev/null +++ b/src/ldf_format.erl @@ -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(<<"> = Line, Depth) -> + Indent = max(Depth - 1, 0), + {[pad(Indent), Line], Indent}; +format_line(<<"> = 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, ~"= 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, ~"&", ~"&", [global]), + Lt = binary:replace(Amp, ~"<", ~"<", [global]), + binary:replace(Lt, ~">", ~">", [global]). + +datetime_to_ms( + <> +) -> + 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. diff --git a/src/ldf_router.erl b/src/ldf_router.erl index 5455ba7..e7bd709 100644 --- a/src/ldf_router.erl +++ b/src/ldf_router.erl @@ -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]}}, diff --git a/src/views/history.dtl b/src/views/history.dtl index 50a1f51..f486f74 100644 --- a/src/views/history.dtl +++ b/src/views/history.dtl @@ -13,9 +13,10 @@
- +
+

Replays messages from this point in time onward (UTC).

 
diff --git a/src/views/message_row.dtl b/src/views/message_row.dtl index 4a1353f..abd6a9e 100644 --- a/src/views/message_row.dtl +++ b/src/views/message_row.dtl @@ -1 +1 @@ -
{% if email %}{{ email }}{% endif %}{% if phone %}{{ phone }}{% endif %}{% if not email %}{% if not phone %}unidentified{% endif %}{% endif %}{{ time }}
{% if link %}{{ link }}{% else %}{{ text }}{% endif %}
type{{ type }}{% if action %} / {{ action }}{% endif %}msg{{ id }}chat{{ chat }}sender{{ sender }}to{{ recipient }}{% if agent %}agent{{ agent }}{% endif %}
+
{% if email %}{{ email }}{% endif %}{% if phone %}{{ phone }}{% endif %}{% if not email %}{% if not phone %}unidentified{% endif %}{% endif %}{{ time }}
{% if link %}{{ link }}{% else %}{{ text }}{% endif %}
type{{ type }}{% if action %} / {{ action }}{% endif %}msg{{ id }}chat{{ chat }}sender{{ sender }}to{{ recipient }}{% if agent %}agent{{ agent }}{% endif %}
{% if full_id %}
{% endif %}
diff --git a/test/ldf_format_tests.erl b/test/ldf_format_tests.erl new file mode 100644 index 0000000..9c71716 --- /dev/null +++ b/test/ldf_format_tests.erl @@ -0,0 +1,33 @@ +-module(ldf_format_tests). + +-include_lib("eunit/include/eunit.hrl"). + +pretty_xml_test() -> + ?assertEqual( + ~"\n x\n", + ldf_format:pretty_xml(~"x") + ). + +pretty_xml_with_prolog_test() -> + ?assertEqual( + ~"\n\n 1\n", + ldf_format:pretty_xml(~"1") + ). + +html_escape_test() -> + ?assertEqual(~"<a>&b", ldf_format:html_escape(~"&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(~"")).