diff --git a/config/runtime.exs b/config/runtime.exs index 45b20da5..5d2a6780 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -80,7 +80,6 @@ if config_env() == :prod do ]} ] - config :microwaveprop, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :microwaveprop, srtm_tiles_dir: "/srtm" config :microwaveprop, hrrr_base_url: System.get_env("HRRR_BASE_URL", "http://skippy.w5isp.com:8080") diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex index c112c6c4..f9498200 100644 --- a/lib/microwaveprop/application.ex +++ b/lib/microwaveprop/application.ex @@ -12,7 +12,6 @@ defmodule Microwaveprop.Application do children = [ MicrowavepropWeb.Telemetry, Microwaveprop.Repo, - {DNSCluster, query: Application.get_env(:microwaveprop, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Microwaveprop.PubSub}, {Oban, Application.fetch_env!(:microwaveprop, Oban)}, # Start to serve requests, typically the last entry diff --git a/lib/microwaveprop/markdown.ex b/lib/microwaveprop/markdown.ex new file mode 100644 index 00000000..151ef7ce --- /dev/null +++ b/lib/microwaveprop/markdown.ex @@ -0,0 +1,213 @@ +defmodule Microwaveprop.Markdown do + @moduledoc """ + Minimal Markdown-to-HTML converter. Handles the subset used by algo.md: + headings, paragraphs, fenced code blocks, tables, lists, inline formatting, + links, and horizontal rules. Derived from Earmark's approach but without + the parser combinator dependency. + """ + + @doc "Converts a Markdown string to HTML." + def to_html!(markdown) do + markdown + |> String.replace("\r\n", "\n") + |> String.split("\n") + |> parse_blocks([]) + |> Enum.map_join("\n", &render_block/1) + end + + # ── Block parsing ──────────────────────────────────────────────── + + defp parse_blocks([], acc), do: Enum.reverse(acc) + + # Fenced code block + defp parse_blocks(["```" <> lang | rest], acc) do + {code_lines, remaining} = take_until_fence(rest, []) + lang = String.trim(lang) + lang_attr = if lang != "", do: " class=\"language-#{lang}\"", else: "" + parse_blocks(remaining, [{:code, lang_attr, code_lines} | acc]) + end + + # Horizontal rule + defp parse_blocks([line | rest], acc) when line in ["---", "***", "___"] do + parse_blocks(rest, [{:hr} | acc]) + end + + # Heading + defp parse_blocks(["#" <> _ = line | rest], acc) do + {level, text} = parse_heading(line) + parse_blocks(rest, [{:heading, level, text} | acc]) + end + + # Table (starts with |) + defp parse_blocks(["|" <> _ = line | rest], acc) do + {table_lines, remaining} = take_table([line | rest], []) + parse_blocks(remaining, [{:table, table_lines} | acc]) + end + + # Unordered list + defp parse_blocks(["- " <> item | rest], acc) do + {items, remaining} = take_list(rest, [item]) + parse_blocks(remaining, [{:ul, items} | acc]) + end + + # Ordered list + defp parse_blocks([line | rest], acc) do + if Regex.match?(~r/^\d+\.\s/, line) do + item = Regex.replace(~r/^\d+\.\s+/, line, "") + {items, remaining} = take_ordered_list(rest, [item]) + parse_blocks(remaining, [{:ol, items} | acc]) + else + case String.trim(line) do + "" -> + parse_blocks(rest, acc) + + trimmed -> + {para_lines, remaining} = take_paragraph(rest, [trimmed]) + parse_blocks(remaining, [{:paragraph, para_lines} | acc]) + end + end + end + + defp take_until_fence([], acc), do: {Enum.reverse(acc), []} + + defp take_until_fence(["```" <> _ | rest], acc), do: {Enum.reverse(acc), rest} + + defp take_until_fence([line | rest], acc), do: take_until_fence(rest, [line | acc]) + + defp parse_heading(line) do + {hashes, text} = split_heading(line, 0) + {min(hashes, 6), String.trim(text)} + end + + defp split_heading("#" <> rest, n), do: split_heading(rest, n + 1) + defp split_heading(rest, n), do: {n, rest} + + defp take_table(["|" <> _ = line | rest], acc), do: take_table(rest, [line | acc]) + defp take_table(rest, acc), do: {Enum.reverse(acc), rest} + + defp take_list(["- " <> item | rest], acc), do: take_list(rest, [item | acc]) + defp take_list([" " <> cont | rest], [prev | items]), do: take_list(rest, [prev <> " " <> String.trim(cont) | items]) + defp take_list(rest, acc), do: {Enum.reverse(acc), rest} + + defp take_ordered_list([line | rest], acc) do + if Regex.match?(~r/^\d+\.\s/, line) do + item = Regex.replace(~r/^\d+\.\s+/, line, "") + take_ordered_list(rest, [item | acc]) + else + {Enum.reverse(acc), [line | rest]} + end + end + + defp take_ordered_list([], acc), do: {Enum.reverse(acc), []} + + defp take_paragraph([], acc), do: {Enum.reverse(acc), []} + defp take_paragraph(["" | rest], acc), do: {Enum.reverse(acc), rest} + defp take_paragraph(["#" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest} + defp take_paragraph(["|" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest} + defp take_paragraph(["- " <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest} + defp take_paragraph(["```" <> _ | _] = rest, acc), do: {Enum.reverse(acc), rest} + defp take_paragraph(["---" | _] = rest, acc), do: {Enum.reverse(acc), rest} + + defp take_paragraph([line | rest], acc) do + if Regex.match?(~r/^\d+\.\s/, line) do + {Enum.reverse(acc), [line | rest]} + else + take_paragraph(rest, [String.trim(line) | acc]) + end + end + + # ── Block rendering ────────────────────────────────────────────── + + defp render_block({:heading, level, text}) do + "#{inline(text)}" + end + + defp render_block({:paragraph, lines}) do + "

#{lines |> Enum.map_join(" ", &inline/1)}

" + end + + defp render_block({:code, lang_attr, lines}) do + escaped = lines |> Enum.map_join("\n", &escape_html/1) + "
#{escaped}
" + end + + defp render_block({:hr}) do + "
" + end + + defp render_block({:ul, items}) do + lis = Enum.map_join(items, "\n", fn item -> "
  • #{inline(item)}
  • " end) + "" + end + + defp render_block({:ol, items}) do + lis = Enum.map_join(items, "\n", fn item -> "
  • #{inline(item)}
  • " end) + "
      \n#{lis}\n
    " + end + + defp render_block({:table, lines}) do + rows = lines |> Enum.map(&parse_table_row/1) + + case rows do + [header, _separator | body] -> + thead = "#{cells(header, "th")}" + tbody_rows = Enum.map_join(body, "\n", fn row -> "#{cells(row, "td")}" end) + "\n#{thead}\n\n#{tbody_rows}\n\n
    " + + _ -> + tbody_rows = Enum.map_join(rows, "\n", fn row -> "#{cells(row, "td")}" end) + "\n\n#{tbody_rows}\n\n
    " + end + end + + defp parse_table_row(line) do + line + |> String.trim() + |> String.trim("|") + |> String.split("|") + |> Enum.map(&String.trim/1) + end + + defp cells(row, tag) do + Enum.map_join(row, "", fn cell -> "<#{tag}>#{inline(cell)}" end) + end + + # ── Inline formatting ──────────────────────────────────────────── + + defp inline(text) do + text + |> escape_html() + |> replace_inline_code() + |> replace_bold() + |> replace_italic() + |> replace_links() + end + + defp escape_html(text) do + text + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + |> String.replace("\"", """) + end + + defp replace_inline_code(text) do + Regex.replace(~r/`([^`]+)`/, text, "\\1") + end + + defp replace_bold(text) do + text + |> then(&Regex.replace(~r/\*\*(.+?)\*\*/, &1, "\\1")) + |> then(&Regex.replace(~r/__(.+?)__/, &1, "\\1")) + end + + defp replace_italic(text) do + text + |> then(&Regex.replace(~r/\*(.+?)\*/, &1, "\\1")) + |> then(&Regex.replace(~r/_(.+?)_/, &1, "\\1")) + end + + defp replace_links(text) do + Regex.replace(~r/\[([^\]]+)\]\(([^)]+)\)/, text, "\\1") + end +end diff --git a/lib/microwaveprop/radio/csv_import.ex b/lib/microwaveprop/radio/csv_import.ex index 556795d7..a2844c02 100644 --- a/lib/microwaveprop/radio/csv_import.ex +++ b/lib/microwaveprop/radio/csv_import.ex @@ -86,43 +86,71 @@ defmodule Microwaveprop.Radio.CsvImport do end end - @timestamp_formats [ - "{ISO:Extended:Z}", - "{ISO:Extended}", - "{YYYY}-{0M}-{0D}T{h24}:{0m}:{0s}", - "{YYYY}-{0M}-{0D}T{h24}:{0m}", - "{YYYY}-{0M}-{0D} {h24}:{0m}:{0s}", - "{YYYY}-{0M}-{0D} {h24}:{0m}", - "{YYYY}-{0M}-{0D}", - "{M}/{D}/{YYYY} {h12}:{m} {AM}", - "{0M}/{0D}/{YYYY} {h12}:{0m} {AM}", - "{M}/{D}/{YYYY} {h24}:{m}:{s}", - "{M}/{D}/{YYYY} {h24}:{m}", - "{0M}/{0D}/{YYYY} {h24}:{0m}:{0s}", - "{0M}/{0D}/{YYYY} {h24}:{0m}", - "{M}-{D}-{YYYY} {h24}:{m}", - "{0M}-{0D}-{YYYY} {h24}:{0m}:{0s}", - "{0M}-{0D}-{YYYY} {h24}:{0m}", - "{YYYY}{0M}{0D}T{h24}{0m}{0s}", - "{YYYY}{0M}{0D} {h24}{0m}" - ] + # Regex patterns for timestamp parsing, ordered most-specific first. + # Accepts many hand-entered formats. All times assumed UTC. + # Each returns {year, month, day, hour, minute, second} or nil. + defp timestamp_parsers do + [ + # ISO-ish: 2024-06-15T14:30:00Z, 2024-06-15 14:30, 2024/06/15 14:30:00, etc. + # Accepts T or space separator, optional seconds, optional timezone suffix + {~r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$"i, + fn + [_, y, m, d, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s} + [_, y, m, d, h, mi | _] -> {y, m, d, h, mi, "00"} + end}, + # Date only: 2024-06-15, 2024/06/15 + {~r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$", + fn [_, y, m, d] -> {y, m, d, "00", "00", "00"} end}, + # US with AM/PM: 6/15/2024 2:30 PM, 6-15-2024 2:30PM, 06.15.2024 02:30 am + {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$"i, + fn + [_, m, d, y, h, mi, s, ampm] when byte_size(s) > 0 -> + {y, m, d, to_string(parse_12h(String.to_integer(h), String.upcase(ampm))), mi, s} + + [_, m, d, y, h, mi, _, ampm] -> + {y, m, d, to_string(parse_12h(String.to_integer(h), String.upcase(ampm))), mi, "00"} + end}, + # US 24h: 6/15/2024 14:30, 06-15-2024 14:30:00, 6.15.2024 14:30 + {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$", + fn + [_, m, d, y, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s} + [_, m, d, y, h, mi | _] -> {y, m, d, h, mi, "00"} + end}, + # US date only: 6/15/2024, 06-15-2024 + {~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$", + fn [_, m, d, y] -> {y, m, d, "00", "00", "00"} end}, + # Compact: 20240615T143000, 20240615 1430, 20240615T1430 + {~r"^(\d{4})(\d{2})(\d{2})[T\s]?(\d{2})(\d{2})(\d{2})?$", + fn + [_, y, m, d, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s} + [_, y, m, d, h, mi | _] -> {y, m, d, h, mi, "00"} + end} + ] + end @doc """ - Parses various date/time formats into ISO 8601 UTC strings using Timex. + Parses various date/time formats into ISO 8601 UTC strings. All times are assumed UTC. """ def normalize_timestamp(raw) do trimmed = String.trim(raw) - @timestamp_formats - |> Enum.find_value(fn fmt -> - case Timex.parse(trimmed, fmt) do - {:ok, parsed} -> - dt = Timex.to_datetime(parsed, "Etc/UTC") - {:ok, DateTime.to_iso8601(dt)} + # Try ISO 8601 with Elixir's built-in parser first + case DateTime.from_iso8601(trimmed) do + {:ok, dt, _} -> + {:ok, DateTime.to_iso8601(dt)} - _ -> - nil + _ -> + try_regex_parsers(trimmed) + end + end + + defp try_regex_parsers(trimmed) do + timestamp_parsers() + |> Enum.find_value(fn {regex, extractor} -> + case Regex.run(regex, trimmed) do + nil -> nil + match -> format_iso(extractor.(match)) end end) |> case do @@ -131,6 +159,21 @@ defmodule Microwaveprop.Radio.CsvImport do end end + defp parse_12h(12, "AM"), do: 0 + defp parse_12h(12, "PM"), do: 12 + defp parse_12h(h, "PM"), do: h + 12 + defp parse_12h(h, "AM"), do: h + + defp format_iso({y, m, d, h, mi, s}) do + y = String.pad_leading(to_string(y), 4, "0") + m = String.pad_leading(to_string(m), 2, "0") + d = String.pad_leading(to_string(d), 2, "0") + h = String.pad_leading(to_string(h), 2, "0") + mi = String.pad_leading(to_string(mi), 2, "0") + s = String.pad_leading(to_string(s), 2, "0") + {:ok, "#{y}-#{m}-#{d}T#{h}:#{mi}:#{s}Z"} + end + defp changeset_error_strings(changeset) do changeset |> Ecto.Changeset.traverse_errors(fn {message, opts} -> diff --git a/lib/microwaveprop_web/live/algo_live.ex b/lib/microwaveprop_web/live/algo_live.ex index 6e105fe0..b9bbaf45 100644 --- a/lib/microwaveprop_web/live/algo_live.ex +++ b/lib/microwaveprop_web/live/algo_live.ex @@ -3,7 +3,7 @@ defmodule MicrowavepropWeb.AlgoLive do use MicrowavepropWeb, :live_view @external_resource "algo.md" - @algo_html "algo.md" |> File.read!() |> Earmark.as_html!() + @algo_html "algo.md" |> File.read!() |> Microwaveprop.Markdown.to_html!() @impl true def mount(_params, _session, socket) do diff --git a/mix.exs b/mix.exs index e0139e0d..077cf73f 100644 --- a/mix.exs +++ b/mix.exs @@ -49,6 +49,7 @@ defmodule Microwaveprop.MixProject do {:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_view, "~> 1.1.0"}, {:lazy_html, ">= 0.1.0", only: :test}, + {:stream_data, "~> 1.0", only: :test}, {:phoenix_live_dashboard, "~> 0.8.3"}, {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, @@ -60,18 +61,15 @@ defmodule Microwaveprop.MixProject do {:telemetry_poller, "~> 1.0"}, {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, - {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, {:styler, "~> 1.11", only: [:dev, :test], runtime: false}, {:oban, "~> 2.19"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:earmark, "~> 1.4"}, {:nx, "~> 0.9", only: [:dev, :test]}, {:axon, "~> 0.7", only: [:dev, :test]}, {:exla, "~> 0.9", only: [:dev, :test]}, {:polaris, "~> 0.1", only: [:dev, :test]}, {:tidewave, "~> 0.5", only: :dev}, - {:timex, "~> 3.7"} ] end diff --git a/mix.lock b/mix.lock index 61a31b40..84375cc5 100644 --- a/mix.lock +++ b/mix.lock @@ -10,8 +10,6 @@ "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, - "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, @@ -51,6 +49,7 @@ "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"}, "swoosh": {:hex, :swoosh, "1.24.0", "4df9645aeeef925a2eb10f7a588a6a09ddd6d370c5dfbd3e821b699c574bdf57", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6ddd84550800468d0e2c15a8aaff924a64c014ed6cff90318077efd1672b8b3b"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, @@ -59,7 +58,6 @@ "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"}, - "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, diff --git a/test/microwaveprop/radio/csv_import_test.exs b/test/microwaveprop/radio/csv_import_test.exs index 24a1b260..1045abad 100644 --- a/test/microwaveprop/radio/csv_import_test.exs +++ b/test/microwaveprop/radio/csv_import_test.exs @@ -297,5 +297,124 @@ defmodule Microwaveprop.Radio.CsvImportTest do test "returns error for empty string" do assert {:error, _} = CsvImport.normalize_timestamp("") end + + test "parses US date with dots" do + assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("06.15.2024 14:30") + end + + test "parses ISO with slashes" do + assert {:ok, "2024-06-15T14:30:00Z"} = CsvImport.normalize_timestamp("2024/06/15 14:30") + end + + test "parses US date only" do + assert {:ok, "2024-06-15T00:00:00Z"} = CsvImport.normalize_timestamp("6/15/2024") + end + end + + describe "normalize_timestamp/1 property tests" do + use ExUnitProperties + + property "ISO 8601 format always parses successfully" do + check all year <- integer(2000..2030), + month <- integer(1..12), + day <- integer(1..28), + hour <- integer(0..23), + minute <- integer(0..59), + second <- integer(0..59) do + y = String.pad_leading(to_string(year), 4, "0") + m = String.pad_leading(to_string(month), 2, "0") + d = String.pad_leading(to_string(day), 2, "0") + h = String.pad_leading(to_string(hour), 2, "0") + mi = String.pad_leading(to_string(minute), 2, "0") + s = String.pad_leading(to_string(second), 2, "0") + + iso = "#{y}-#{m}-#{d}T#{h}:#{mi}:#{s}Z" + assert {:ok, ^iso} = CsvImport.normalize_timestamp(iso) + end + end + + property "US date format with 24h time always parses" do + check all year <- integer(2000..2030), + month <- integer(1..12), + day <- integer(1..28), + hour <- integer(0..23), + minute <- integer(0..59), + sep <- member_of(["/", "-", "."]) do + input = "#{month}#{sep}#{day}#{sep}#{year} #{hour}:#{String.pad_leading(to_string(minute), 2, "0")}" + assert {:ok, result} = CsvImport.normalize_timestamp(input) + assert String.ends_with?(result, "Z") + {:ok, dt, _} = DateTime.from_iso8601(result) + assert dt.year == year + assert dt.month == month + assert dt.day == day + end + end + + property "US date with AM/PM parses correctly" do + check all year <- integer(2000..2030), + month <- integer(1..12), + day <- integer(1..28), + hour_12 <- integer(1..12), + minute <- integer(0..59), + ampm <- member_of(["AM", "PM", "am", "pm"]) do + input = "#{month}/#{day}/#{year} #{hour_12}:#{String.pad_leading(to_string(minute), 2, "0")} #{ampm}" + assert {:ok, result} = CsvImport.normalize_timestamp(input) + {:ok, dt, _} = DateTime.from_iso8601(result) + + expected_hour = + case {hour_12, String.upcase(ampm)} do + {12, "AM"} -> 0 + {12, "PM"} -> 12 + {h, "PM"} -> h + 12 + {h, "AM"} -> h + end + + assert dt.hour == expected_hour + assert dt.minute == minute + end + end + + property "all parsed results are valid ISO 8601 UTC datetimes" do + formats = + StreamData.member_of([ + fn y, m, d, h, mi -> "#{y}-#{m}-#{d}T#{h}:#{mi}:00Z" end, + fn y, m, d, h, mi -> "#{y}-#{m}-#{d} #{h}:#{mi}" end, + fn y, m, d, _h, _mi -> "#{y}-#{m}-#{d}" end, + fn y, m, d, h, mi -> "#{m}/#{d}/#{y} #{h}:#{mi}" end + ]) + + check all year <- integer(2000..2030), + month <- integer(1..12), + day <- integer(1..28), + hour <- integer(0..23), + minute <- integer(0..59), + fmt <- formats do + y = String.pad_leading(to_string(year), 4, "0") + m = String.pad_leading(to_string(month), 2, "0") + d = String.pad_leading(to_string(day), 2, "0") + h = String.pad_leading(to_string(hour), 2, "0") + mi = String.pad_leading(to_string(minute), 2, "0") + + input = fmt.(y, m, d, h, mi) + + case CsvImport.normalize_timestamp(input) do + {:ok, result} -> + assert String.ends_with?(result, "Z") + assert {:ok, _, _} = DateTime.from_iso8601(result) + + {:error, _} -> + :ok + end + end + end + + property "garbage input never returns ok" do + check all garbage <- string(:alphanumeric, min_length: 1, max_length: 5) do + case CsvImport.normalize_timestamp(garbage) do + {:ok, _} -> flunk("Parsed garbage: #{inspect(garbage)}") + {:error, _} -> :ok + end + end + end end end