defmodule ToweropsWeb.ChangelogParser do @moduledoc """ Reads and parses priv/static/changelog.txt into structured entries. Parses changelog entries with format: - "YYYY-MM-DD — Title" or "YYYY-MM-DD – Title" (with em/en dash) - "YYYY-MM-DD" (date only, no title) - "* Item text" (bullet items belonging to the most recent date) """ @doc "Returns a list of %{date: Date.t(), title: String.t() | nil, items: [String.t()]}" def parse do path = Application.app_dir(:towerops, "priv/static/changelog.txt") case File.read(path) do {:ok, content} -> content |> parse_content() |> Enum.map(&convert_entry/1) {:error, _} -> [] end end @doc """ Parse changelog content string into a list of entries. Returns internal tuples: {:changelog_entry, date, title_opt, items} """ def parse_content(content) when is_binary(content) do content |> String.split("\n") |> Enum.reduce([], fn line, acc -> parse_line(line, acc) end) |> Enum.reverse() |> Enum.map(fn entry -> {:changelog_entry, entry.date, entry.title, Enum.reverse(entry.items)} end) end # Parse a single line and update the accumulator defp parse_line(line, acc) do # Check for date with title: "YYYY-MM-DD [—–] Title" case Regex.run(~r/^(\d{4}-\d{2}-\d{2})\s+(—|–)\s+(.+)/u, line) do [_, date_str, _dash, title] -> add_date_entry(acc, date_str, {:some, String.trim(title)}) nil -> parse_line_no_title(line, acc) end end defp parse_line_no_title(line, acc) do cond do # Check for date only: "YYYY-MM-DD" Regex.match?(~r/^\d{4}-\d{2}-\d{2}\s*$/, line) -> add_date_entry(acc, line, :none) # Check for bullet item: "* Item text" String.starts_with?(String.trim(line), "* ") -> add_item_to_current_entry(acc, line) # Non-matching line - ignore true -> acc end end defp add_date_entry(acc, date_str, title) do [ %{ date: String.trim(date_str), title: title, items: [] } | acc ] end defp add_item_to_current_entry(acc, line) do trimmed = String.trim(line) item = String.slice(trimmed, 2..-1//1) case acc do [current | rest] -> [%{current | items: [item | current.items]} | rest] [] -> # Orphan item before any date header - ignore [] end end defp convert_entry({:changelog_entry, date_str, title_opt, items}) do %{ date: Date.from_iso8601!(date_str), title: unwrap_option(title_opt), items: items } end defp unwrap_option({:some, value}), do: value defp unwrap_option(:none), do: nil end