|
2 | 2 | //// |
3 | 3 | //// Parses date strings in the `YYYY-MM-DD HH:MM:SS` format into |
4 | 4 | //// `gleam/time/timestamp.Timestamp` values using the tempo library. |
5 | | -//// All dates are interpreted as UTC. |
| 5 | +//// Dates without a timezone are interpreted as UTC. |
| 6 | +//// An optional timezone can be specified as a UTC offset (e.g. `+02:00`) |
| 7 | +//// or as an IANA timezone name (e.g. `Europe/Helsinki`). |
6 | 8 |
|
7 | 9 | import blogatto/error |
| 10 | +import gleam/int |
| 11 | +import gleam/list |
8 | 12 | import gleam/result |
| 13 | +import gleam/string |
| 14 | +import gleam/time/duration |
9 | 15 | import gleam/time/timestamp.{type Timestamp} |
10 | 16 | import tempo |
11 | 17 | import tempo/datetime |
12 | 18 | import tempo/naive_datetime |
| 19 | +import tzif/database |
13 | 20 |
|
14 | | -/// Parse a date string in `YYYY-MM-DD HH:MM:SS` format into a UTC timestamp. |
| 21 | +/// Parse a date string into a UTC timestamp. |
15 | 22 | /// |
16 | | -/// The date and time components are separated by a single space. The time |
17 | | -/// portion uses 24-hour format. Returns `FrontmatterInvalidDate` if the |
18 | | -/// string does not match the expected format or contains out-of-range values. |
| 23 | +/// Accepted formats: |
| 24 | +/// - `YYYY-MM-DD HH:MM:SS` — interpreted as UTC |
| 25 | +/// - `YYYY-MM-DD HH:MM:SS +HH:MM` or `YYYY-MM-DD HH:MM:SS -HH:MM` — UTC offset |
| 26 | +/// - `YYYY-MM-DD HH:MM:SS Continent/City` — IANA timezone name |
| 27 | +/// |
| 28 | +/// Returns `FrontmatterInvalidDate` if the string does not match any |
| 29 | +/// expected format or contains out-of-range values. |
19 | 30 | /// |
20 | 31 | /// ## Examples |
21 | 32 | /// |
22 | 33 | /// ```gleam |
23 | 34 | /// parse("2023-01-25 00:00:00") |
24 | 35 | /// // -> Ok(timestamp representing 2023-01-25T00:00:00Z) |
| 36 | +/// |
| 37 | +/// parse("2023-01-25 02:00:00 +02:00") |
| 38 | +/// // -> Ok(timestamp representing 2023-01-25T00:00:00Z) |
| 39 | +/// |
| 40 | +/// parse("2023-01-25 02:00:00 Europe/Helsinki") |
| 41 | +/// // -> Ok(timestamp representing 2023-01-25T00:00:00Z) |
25 | 42 | /// ``` |
26 | 43 | pub fn parse(raw: String) -> Result(Timestamp, error.BlogattoError) { |
| 44 | + let parts = string.split(raw, " ") |
| 45 | + case list.length(parts) { |
| 46 | + // "YYYY-MM-DD HH:MM:SS" — no timezone, interpret as UTC |
| 47 | + 2 -> parse_naive(raw) |
| 48 | + // "YYYY-MM-DD HH:MM:SS <tz>" — has timezone part |
| 49 | + 3 -> parse_with_tz(raw) |
| 50 | + _ -> Error(error.FrontmatterInvalidDate(raw)) |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +/// Parse a date string without a timezone (e.g. "2023-01-25 00:00:00") into a UTC timestamp. |
| 55 | +fn parse_naive(raw: String) -> Result(Timestamp, error.BlogattoError) { |
27 | 56 | let fmt = tempo.CustomNaive(format: "YYYY-MM-DD HH:mm:ss") |
28 | 57 | raw |
29 | 58 | |> naive_datetime.parse(fmt) |
30 | 59 | |> result.map(naive_datetime.as_utc) |
31 | 60 | |> result.map(datetime.to_timestamp) |
32 | 61 | |> result.map_error(fn(_) { error.FrontmatterInvalidDate(raw) }) |
33 | 62 | } |
| 63 | + |
| 64 | +/// Parse a date string with a timezone at the end. |
| 65 | +/// Handles both UTC offsets (e.g. "+02:00") and IANA timezone names |
| 66 | +/// (e.g. "Europe/Helsinki") by first resolving names to offsets. |
| 67 | +fn parse_with_tz(raw: String) -> Result(Timestamp, error.BlogattoError) { |
| 68 | + let fmt = tempo.Custom(format: "YYYY-MM-DD HH:mm:ss Z") |
| 69 | + raw |
| 70 | + |> resolve_tz_name |
| 71 | + |> datetime.parse(fmt) |
| 72 | + |> result.map(datetime.to_timestamp) |
| 73 | + |> result.map_error(fn(_) { error.FrontmatterInvalidDate(raw) }) |
| 74 | +} |
| 75 | + |
| 76 | +/// If the string ends with an IANA timezone name (e.g. "Europe/Helsinki"), |
| 77 | +/// replaces it with the equivalent UTC offset (e.g. "+02:00"). |
| 78 | +/// If the last token does not contain a "/" it is returned unchanged, |
| 79 | +/// allowing offset formats like "+02:00" to pass through to tempo. |
| 80 | +fn resolve_tz_name(raw: String) -> String { |
| 81 | + case string.split(raw, " ") { |
| 82 | + [date_part, time_part, tz_part] -> |
| 83 | + case string.contains(tz_part, "/") { |
| 84 | + True -> { |
| 85 | + let datetime_part = date_part <> " " <> time_part |
| 86 | + case resolve_offset(datetime_part, tz_part) { |
| 87 | + Ok(offset_str) -> datetime_part <> " " <> offset_str |
| 88 | + // If resolution fails, return unchanged so parse_with_tz fails |
| 89 | + // with FrontmatterInvalidDate |
| 90 | + Error(_) -> raw |
| 91 | + } |
| 92 | + } |
| 93 | + // Not a tz name, might be an offset like "+02:00" — pass through |
| 94 | + False -> raw |
| 95 | + } |
| 96 | + _ -> raw |
| 97 | + } |
| 98 | +} |
| 99 | + |
| 100 | +/// Look up the UTC offset for a timezone name at the given approximate datetime. |
| 101 | +/// The datetime string is parsed as naive (UTC) to get a reference timestamp |
| 102 | +/// for the lookup, which is close enough for correct DST resolution in |
| 103 | +/// nearly all cases. |
| 104 | +fn resolve_offset(datetime_str: String, tz_name: String) -> Result(String, Nil) { |
| 105 | + let fmt = tempo.CustomNaive(format: "YYYY-MM-DD HH:mm:ss") |
| 106 | + |
| 107 | + use naive <- result.try( |
| 108 | + naive_datetime.parse(datetime_str, fmt) |> result.replace_error(Nil), |
| 109 | + ) |
| 110 | + let approximate_ts = naive |> naive_datetime.as_utc |> datetime.to_timestamp |
| 111 | + |
| 112 | + let db = get_tz_database() |
| 113 | + use zone_params <- result.try( |
| 114 | + database.get_zone_parameters(approximate_ts, tz_name, db) |
| 115 | + |> result.replace_error(Nil), |
| 116 | + ) |
| 117 | + |
| 118 | + Ok(format_offset(zone_params.offset)) |
| 119 | +} |
| 120 | + |
| 121 | +/// Format a Duration offset as "+HH:MM" or "-HH:MM". |
| 122 | +fn format_offset(offset: duration.Duration) -> String { |
| 123 | + let total_seconds = { |
| 124 | + let #(seconds, _) = duration.to_seconds_and_nanoseconds(offset) |
| 125 | + seconds |
| 126 | + } |
| 127 | + let sign = case total_seconds >= 0 { |
| 128 | + True -> "+" |
| 129 | + False -> "-" |
| 130 | + } |
| 131 | + let abs_seconds = int.absolute_value(total_seconds) |
| 132 | + let hours = abs_seconds / 3600 |
| 133 | + let minutes = { abs_seconds % 3600 } / 60 |
| 134 | + |
| 135 | + sign |
| 136 | + <> int.to_string(hours) |
| 137 | + |> string.pad_start(2, "0") |
| 138 | + <> ":" |
| 139 | + <> int.to_string(minutes) |> string.pad_start(2, "0") |
| 140 | +} |
| 141 | + |
| 142 | +/// Get the timezone database, cached via persistent_term. |
| 143 | +/// The database is loaded from the prebuilt zones package on first access. |
| 144 | +@external(erlang, "blogatto_ffi", "get_tz_database") |
| 145 | +fn get_tz_database() -> database.TzDatabase |
0 commit comments