tztr 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/tztr.rb CHANGED
@@ -1,26 +1,152 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
3
4
  require 'time'
4
5
  require_relative 'tztr/version'
5
6
 
6
7
  module Tztr
8
+ Error = Class.new(StandardError)
9
+
10
+ # The tzdb Ruby's Time reads through $TZ, and the Rust port reads through
11
+ # jiff -- the authority on whether a zone name means anything.
12
+ ZONEINFO_DIRS = [ENV['TZDIR'], '/usr/share/zoneinfo', '/etc/zoneinfo'].compact.freeze
13
+
14
+ # Abbreviations Ruby's Time.parse resolves on its own.
15
+ NATIVE_ABBREVIATIONS = %w[UT UTC GMT Z EST EDT CST CDT MST MDT PST PDT].freeze
16
+
17
+ # Abbreviations Time.parse silently ignores -- it would read them as local
18
+ # time, so we resolve them through TIMEZONE_ALIASES ourselves.
19
+ ALIASED_ABBREVIATIONS = %w[
20
+ ET CT MT PT HST AKST AKDT CET CEST BST IST JST KST HKT AEST AEDT NZST NZDT
21
+ ].freeze
22
+
23
+ # Only these count as a zone inside text. A bare [A-Z]{2,4} swallows the next
24
+ # word instead -- INFO, WARN, ERROR, PM.
25
+ ZONE_ABBREVIATIONS = (NATIVE_ABBREVIATIONS + ALIASED_ABBREVIATIONS).freeze
26
+
27
+ # Lowercase spellings that are also words likely to follow a time -- French
28
+ # "est"/"cet"/"et", German "ist" -- so "à 15:30 est annulée" is left alone.
29
+ WORD_ABBREVIATIONS = %w[EST CET ET IST UT Z].freeze
30
+
31
+ # Uppercase, or wholly lowercase unless that is also a word. Not mixed case.
32
+ ZONE_SPELLINGS = (ZONE_ABBREVIATIONS + (ZONE_ABBREVIATIONS - WORD_ABBREVIATIONS).map(&:downcase)).freeze
33
+
34
+ # Longest first, so UTC is not read as UT.
35
+ ABBREVIATION = Regexp.union(ZONE_SPELLINGS.sort_by { |abbr| [-abbr.length, abbr] })
36
+ # A numeric offset within the -12..+14 real zones occupy.
37
+ NUM_OFFSET = /[+-](?:0\d|1[0-3])[0-5]\d\b|[+-]1400\b/
38
+ COLON_OFFSET = /[+-](?:0\d|1[0-3]):[0-5]\d\b|[+-]14:00\b/
39
+ ZONE = /(?:#{ABBREVIATION})\b|#{NUM_OFFSET}/
40
+ # After a dated clock's seconds, glued or not: -07:00, -0700, and the -07
41
+ # Postgres writes. Without seconds, 2026-04-03 9:00-10:00 is a range.
42
+ DATED_OFFSET = /[+-](?:(?:0\d|1[0-3]):?[0-5]\d|14:?00|0\d|1[0-4])\b/
43
+
44
+ # The offset each abbreviation names. Standard and daylight ones are fixed
45
+ # whatever the date -- CEST is +02:00 even in January -- as Time.parse reads
46
+ # the US ones. Only the generic ET, CT, MT and PT follow DST.
47
+ ZONE_OFFSETS = {
48
+ 'UTC' => '+00:00', 'GMT' => '+00:00', 'UT' => '+00:00', 'Z' => '+00:00',
49
+ 'EST' => '-05:00', 'EDT' => '-04:00', 'CST' => '-06:00', 'CDT' => '-05:00',
50
+ 'MST' => '-07:00', 'MDT' => '-06:00', 'PST' => '-08:00', 'PDT' => '-07:00',
51
+ 'HST' => '-10:00', 'AKST' => '-09:00', 'AKDT' => '-08:00',
52
+ 'CET' => '+01:00', 'CEST' => '+02:00', 'BST' => '+01:00', 'IST' => '+05:30',
53
+ 'JST' => '+09:00', 'KST' => '+09:00', 'HKT' => '+08:00',
54
+ 'AEST' => '+10:00', 'AEDT' => '+11:00', 'NZST' => '+12:00', 'NZDT' => '+13:00',
55
+ }.freeze
56
+ # A dotted meridiem takes its closing dot; an undotted one leaves a
57
+ # following full stop to the sentence.
58
+ MERIDIEM = /[AaPp](?:\.[Mm]\.|\.?[Mm]\b)/
59
+ # Before a meridiem: a space, or the no-break spaces ICU writes (3:45 PM).
60
+ MERIDIEM_GAP = /[ \u00A0\u202F]?/
61
+
62
+ DAY = /(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)/
63
+ MONTH_ABBRS = %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec].freeze
64
+ MON = /(?:#{MONTH_ABBRS.join('|')})/
65
+
66
+ # The zone a date(1)-shaped line may carry: any capitalized abbreviation --
67
+ # one we can't resolve leaves the line alone rather than half-converted --
68
+ # or a numeric one as tzdb writes it for zones without a name (+03).
69
+ DATE_ZONE = /(?:#{ABBREVIATION}|[A-Z][A-Za-z]?[A-Z]{1,3}|[+-]\d{2}(?:\d{2})?)/
70
+ # A date, with dashes or slashes throughout (Go's log package, nginx).
71
+ DATE = %r{\d{4}(?:-\d{2}-\d{2}|/\d{2}/\d{2})}
72
+ # Seconds' fraction: any number of digits after a dot, or Python logging's
73
+ # comma and three (checked again after the match: not before a CSV comma).
74
+ FRAC = /(?:\.\d+|,\d{3}\b)/
75
+ # ISO 8601 allows a comma before any number of digits.
76
+ ISO_FRAC = /[.,]\d+/
77
+
78
+ # Dates with named months, read whole so the weekday and day roll over with
79
+ # the clock. date(1)/ctime: Fri Sep 25 22:14:42 PDT 2026, weekday and zone
80
+ # optional (ls -lT has neither).
81
+ UNIX_DATE = /\A(?<weekday>#{DAY} )?(?<mon>#{MON}) ?(?<day>\d{1,2}) (?<time>\S+) (?:(?<zone>\S+) )?(?<year>\d{4})\z/
82
+ # RFC 2822 / HTTP: Fri, 25 Sep 2026 22:14:42 -0700
83
+ RFC_DATE = /\A(?<weekday>#{DAY}, )?(?<day>\d{1,2}) (?<mon>#{MON}) (?<year>\d{4}) (?<time>\S+)(?<mer> [AP]M)? (?<zone>\S+)\z/
84
+ # glibc's locale date(1): Fri 25 Sep 2026 10:14:42 PM PDT, zone optional
85
+ LOCALE_DATE = /\A#{DAY} (?<day>\d{1,2}) (?<mon>#{MON}) (?<year>\d{4}) (?<time>\S+)(?<mer> [AP]M)?(?: (?<zone>\S+))?\z/
86
+ # nginx/Apache access log: 15/Jan/2015:12:31:01 -0700
87
+ CLF_DATE = %r{\A(?<day>\d{2})/(?<mon>#{MON})/(?<year>\d{4}):(?<time>\S+) (?<zone>\S+)\z}
88
+ NAMED_DATES = [UNIX_DATE, RFC_DATE, LOCALE_DATE, CLF_DATE].freeze
89
+
7
90
  PATTERNS = [
91
+ # nginx/Apache access log
92
+ %r{\b\d{2}/#{MON}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\b},
93
+ # glibc's locale date(1)
94
+ /\b#{DAY} \d{1,2} #{MON} \d{4} \d{1,2}:\d{2}(?::\d{2})?(?: [AP]M)?(?: #{DATE_ZONE})?\b/,
95
+ # date(1), ctime and ls -lT
96
+ /\b(?:#{DAY} )?#{MON} ?\d{1,2} \d{1,2}:\d{2}(?::\d{2})? (?:#{DATE_ZONE} )?\d{4}\b/,
97
+ # RFC 2822
98
+ /\b(?:#{DAY}, )?\d{1,2} #{MON} \d{4} \d{1,2}:\d{2}(?::\d{2})?(?: [AP]M)? (?:[+-]\d{4}\b|(?:#{ABBREVIATION})\b)/,
8
99
  # ISO 8601 with Z or offset: 2026-04-03T12:34:56Z, 2026-04-03T12:34:56.123+00:00
9
- /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/,
100
+ /\d{4}-\d{2}-\d{2}T\d{1,2}:\d{2}(?::\d{2}#{ISO_FRAC}?)?(?:Z|[+-]\d{2}:?\d{2})/,
10
101
  # ISO 8601 without timezone: 2026-04-03T12:34:56
11
- /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?/,
12
- # Date space time with tz: 2026-04-03 12:34:56 UTC
13
- /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)? ?(?:UTC|GMT|[A-Z]{2,4}|[+-]\d{4})/,
14
- # Date space time: 2026-04-03 12:34:56
15
- /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?/,
16
- # Time with tz: 12:34:56 UTC, 12:34 PST
17
- /\b\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)? ?(?:UTC|GMT|[A-Z]{2,4}|[+-]\d{4})\b/,
18
- # Time with offset: 12:34:56+00:00
19
- /\b\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?[+-]\d{2}:?\d{2}\b/,
102
+ /\d{4}-\d{2}-\d{2}T\d{1,2}:\d{2}(?::\d{2}#{ISO_FRAC}?)?/,
103
+ # Date space 12-hour time: 2026-04-03 03:45:00 PM, 2026-04-03 03:45 PM PST, 2026-01-15 9am
104
+ /#{DATE} \d{1,2}(?::\d{2}(?::\d{2}#{FRAC}?)?)?#{MERIDIEM_GAP}#{MERIDIEM}(?: ?#{ZONE})?/,
105
+ # Date space time with tz: 2026-04-03 12:34:56 UTC, 2026-04-03 12:34:56-07:00
106
+ /#{DATE} \d{1,2}:\d{2}(?::\d{2}#{FRAC}? ?(?:#{DATED_OFFSET}|(?:#{ABBREVIATION})\b)| ?(?:#{ABBREVIATION})\b| (?:#{NUM_OFFSET}|#{COLON_OFFSET}))/,
107
+ # Date space time: 2026-04-03 12:34:56, 2026/04/03 12:34:56
108
+ /#{DATE} \d{1,2}:\d{2}(?::\d{2}#{FRAC}?)?/,
109
+ # Time with tz: 12:34:56 UTC, 12:34 PST, 12:34 +0530. A numeric offset
110
+ # glued to the clock needs seconds, or 15:30-1645 would read as one.
111
+ /\b\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)? ?(?:#{ZONE}|#{COLON_OFFSET})| ?(?:#{ABBREVIATION})\b| (?:#{NUM_OFFSET}|#{COLON_OFFSET}))/,
112
+ # Time with offset: 12:34:56+00:00. Seconds required and the offset in range,
113
+ # so the hyphen of a range like 15:30-16:45 is not read as one.
114
+ /\b\d{1,2}:\d{2}:\d{2}(?:\.\d+)?[+-](?:(?:0\d|1[0-3]):?[0-5]\d|14:?00)\b/,
115
+ # 12-hour time: 11:30 PM, 3:45 p.m., 3:45 PM PST
116
+ /\b\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?#{MERIDIEM_GAP}#{MERIDIEM}(?: ?#{ZONE})?/,
117
+ # Hour with a meridiem: 9am, 9 PM PST
118
+ /\b\d{1,2}#{MERIDIEM_GAP}#{MERIDIEM}(?: ?#{ZONE})?/,
20
119
  # Bare time: 12:34:56, 12:34
21
120
  /\b\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?\b/,
22
121
  ].freeze
23
122
 
123
+ # One pass over the line: at each position the alternatives are tried in the
124
+ # order above, so a longer format wins over a shorter one inside it, and every
125
+ # timestamp on the line converts whatever its format. Every format starts
126
+ # with a digit or a capital; saying so up front lets Onigmo skip other
127
+ # positions instead of trying each alternative there (2-3x faster scans).
128
+ TIMESTAMP = /(?=[0-9A-Z])(?:#{Regexp.union(PATTERNS).source})/
129
+ BARE_TIME = /\A(?:#{PATTERNS.last})\z/
130
+
131
+ # A time-only timestamp in pieces, for sharing a range's zone and meridiem.
132
+ TIME_PARTS = /\A(?<clock>(?<hour>\d{1,2}):\d{2}(?::\d{2}(?:\.\d+)?)?)(?: ?(?<meridiem>#{MERIDIEM}))?(?: ?(?<zone>#{ZONE}|#{COLON_OFFSET}))?\z/
133
+ RANGE_WORDS = '-|–|—|to|until|till|through|thru'
134
+ # What joins the ends of a range (15:30-16:45, 3:30 to 4:45 PM) or the items
135
+ # of a list (3:00, 4:00 or 5:00 PM).
136
+ RANGE_JOIN = /\A[ \t]*(?:#{RANGE_WORDS})[ \t]*\z/i
137
+ LIST_JOIN = /\A[ \t]*(?:,|(?:,[ \t]*)?(?:or|and))[ \t]*\z/i
138
+ # A bare hour starting a range, the 9 of "9-10am" or "9 to 10am": at the
139
+ # start of the text or after a space or "(", and hyphenated tight or joined
140
+ # by a word. A spaced hyphen ("Room 7 - 3pm", "Apr 3 - 5pm") is not enough.
141
+ RANGE_HOUR = /(?:\A|[ \t(])(?<hour>\d{1,2})(?:[-–—]|[ \t]+(?:to|until|till|through|thru)[ \t]+)\z/i
142
+ # A day of the month, not an hour: the 1 of "Oct 1 thru 5pm".
143
+ AFTER_MONTH = /\b(?:#{MONTH_ABBRS.join('|')})[a-z]*\.?[ \t]*\z/i
144
+
145
+ # A timestamp found in a line: its byte offset, its text, what it is read as
146
+ # (the text plus any zone or meridiem it shares with the end of its range or
147
+ # list), and that range or list for -j.
148
+ Stamp = Data.define(:offset, :text, :effective, :group, :days)
149
+
24
150
  TIMEZONE_ALIASES = {
25
151
  # UTC
26
152
  'utc' => 'UTC', 'gmt' => 'UTC', 'z' => 'UTC',
@@ -40,7 +166,7 @@ module Tztr
40
166
  'hst' => 'Pacific/Honolulu', 'akst' => 'America/Anchorage', 'akdt' => 'America/Anchorage',
41
167
  # Europe
42
168
  'cet' => 'Europe/Berlin', 'cest' => 'Europe/Berlin',
43
- 'gmt' => 'Europe/London', 'bst' => 'Europe/London',
169
+ 'bst' => 'Europe/London',
44
170
  'ist' => 'Asia/Kolkata',
45
171
  # Asia/Pacific
46
172
  'jst' => 'Asia/Tokyo', 'kst' => 'Asia/Seoul',
@@ -72,85 +198,610 @@ module Tztr
72
198
 
73
199
  module_function
74
200
 
201
+ MONTHS = %w[
202
+ january february march april may june
203
+ july august september october november december
204
+ ].freeze
205
+
206
+ # The -d forms the README documents. Date.parse accepts far more than the
207
+ # Rust port's hand-rolled parser does, so both narrow to this set.
208
+ def normalize_date(input)
209
+ parts =
210
+ case input
211
+ when /\A(\d{4})-(\d{2})-(\d{2})\z/, %r{\A(\d{4})/(\d{2})/(\d{2})\z}, /\A(\d{4})(\d{2})(\d{2})\z/
212
+ [$1.to_i, $2.to_i, $3.to_i]
213
+ when /\A([A-Za-z]+)\.? (\d{1,2}),? (\d{4})\z/ # January 15, 2026 / Jan 15 2026
214
+ [$3.to_i, month_number($1), $2.to_i]
215
+ when /\A(\d{1,2}) ([A-Za-z]+)\.?,? (\d{4})\z/ # 15 January 2026
216
+ [$3.to_i, month_number($2), $1.to_i]
217
+ end
218
+
219
+ raise Error, "invalid date: #{input}" unless parts&.all? && Date.valid_date?(*parts)
220
+
221
+ format('%04d-%02d-%02d', *parts)
222
+ end
223
+
224
+ def month_number(name)
225
+ name = name.downcase
226
+ index = MONTHS.index { |month| month == name || (name.length == 3 && month.start_with?(name)) }
227
+ index && index + 1
228
+ end
229
+
230
+ # Cached: resolving an IANA name reads its zoneinfo file, once per line
231
+ # otherwise. Failures raise every time.
75
232
  def resolve_tz(input)
76
- return input if input.nil?
233
+ return if input.nil?
234
+
235
+ @resolved_tz ||= {}
236
+ @resolved_tz.fetch(input) { @resolved_tz[input] = resolve_tz!(input) }
237
+ end
238
+
239
+ def resolve_tz!(input)
240
+ input = input.delete_prefix(':') # POSIX spells it TZ=:America/New_York
77
241
 
78
242
  # Numeric offset: -7 -> Etc/GMT+7 (POSIX sign is inverted)
79
243
  if input.match?(/\A[+-]?\d{1,2}\z/)
80
244
  n = input.to_i
81
- return 'UTC' if n == 0
245
+ return 'UTC' if n.zero?
246
+ raise Error, "offset out of range: #{input} (expected -12..14)" unless (-12..14).cover?(n)
247
+
248
+ return "Etc/GMT#{n.positive? ? '-' : '+'}#{n.abs}"
249
+ end
82
250
 
83
- return "Etc/GMT#{n > 0 ? '-' : '+'}#{n.abs}"
251
+ alias_zone = TIMEZONE_ALIASES[input.downcase.tr(' ', '_')]
252
+ return alias_zone if alias_zone
253
+ raise Error, "unknown timezone: #{input}" unless known_zone?(input)
254
+
255
+ input
256
+ end
257
+
258
+ # The zone the machine runs in, when $TZ doesn't say: /etc/localtime's link
259
+ # into the tzdb (macOS, most Linux), else Debian's /etc/timezone.
260
+ def system_zone
261
+ name = begin
262
+ File.readlink('/etc/localtime').split('zoneinfo/', 2)[1]
263
+ rescue SystemCallError
264
+ nil
84
265
  end
266
+ name ||= begin
267
+ File.read('/etc/timezone').strip
268
+ rescue SystemCallError
269
+ nil
270
+ end
271
+ name if name && known_zone?(name)
272
+ end
85
273
 
86
- TIMEZONE_ALIASES[input.downcase.tr(' ', '_')] || input
274
+ def known_zone?(name)
275
+ return false unless name.match?(%r{\A[A-Za-z0-9_+-]+(?:/[A-Za-z0-9_+-]+)*\z})
276
+
277
+ # A zone file, not the tzdb's other files beside them (leapseconds, +VERSION).
278
+ ZONEINFO_DIRS.any? do |dir|
279
+ path = File.join(dir, name)
280
+ File.file?(path) && File.binread(path, 4) == 'TZif'
281
+ end
87
282
  end
88
283
 
89
- def translate(line, to: 'UTC', from: nil, format: nil)
284
+ def translate(line, to: 'UTC', from: nil, format: nil, date: nil)
90
285
  to = resolve_tz(to)
91
286
  from = resolve_tz(from)
92
- ENV['TZ'] = to
93
- result = line.dup
94
-
95
- PATTERNS.each do |pattern|
96
- next unless result.match?(pattern)
97
-
98
- result.gsub!(pattern) do |match|
99
- begin
100
- time = parse(match, from:, to:)
101
- format_time(time.localtime, format, match)
102
- rescue ArgumentError
103
- match
287
+ use_zone(to)
288
+ out = line.byteslice(0, 0)
289
+ pos = 0
290
+
291
+ timestamps(scannable(line)).each do |stamp|
292
+ out << line.byteslice(pos, stamp.offset - pos) << (convert_stamp(stamp, from:, to:, format:, date:) || stamp.text)
293
+ pos = stamp.offset + stamp.text.bytesize
294
+ end
295
+
296
+ out << line.byteslice(pos, line.bytesize - pos)
297
+ end
298
+
299
+ # Per-match structured analysis of a line. Returns an array of hashes, one
300
+ # per detected timestamp: { original:, detected_format:, detected_tz:,
301
+ # translated: }. With detect: true, translation is skipped and :translated is
302
+ # omitted.
303
+ def matches(line, to: 'UTC', from: nil, format: nil, detect: false, date: nil)
304
+ to = resolve_tz(to)
305
+ from = resolve_tz(from)
306
+ use_zone(to)
307
+ timestamps(scannable(line)).map do |stamp|
308
+ info = {
309
+ # Patterns only ever match ASCII, whatever the rest of the line is.
310
+ original: stamp.text.dup.force_encoding(Encoding::UTF_8),
311
+ detected_format: detect_format(reading(stamp.text)),
312
+ detected_tz: zone_token(stamp.effective, from),
313
+ }
314
+ info[:translated] = convert_stamp(stamp, from:, to:, format:, date:) unless detect
315
+ info[:group] = stamp.group if stamp.group
316
+ info
317
+ end
318
+ end
319
+
320
+ # Zone-shaped words a date(1) line carries that tztr doesn't know (EEST),
321
+ # for -v to name as the reason the line was left alone.
322
+ def unknown_zones(line, from: nil)
323
+ timestamps(scannable(line)).filter_map { |stamp| unknown_zone(stamp.effective) unless zone_token(stamp.effective, from) }.uniq
324
+ end
325
+
326
+ # Zone abbreviations written right after a timestamp but not read as one
327
+ # because of their case (Pst), for -v to point out.
328
+ def ignored_zones(line)
329
+ line = scannable(line)
330
+ tokens = []
331
+ line.scan(TIMESTAMP) do
332
+ token = $~.post_match[/\A ?([A-Za-z]{3,4})\b/, 1]
333
+ next unless token && token != token.upcase && token != token.downcase
334
+ # Capitalized words first: "Ist" (German "is"), "Est", "Cet". Two-letter
335
+ # ones ("Mt.", "Et al") are never flagged.
336
+ next if WORD_ABBREVIATIONS.include?(token.upcase)
337
+
338
+ tokens << token if ZONE_ABBREVIATIONS.include?(token.upcase)
339
+ end
340
+ tokens.uniq
341
+ end
342
+
343
+ # Which assumptions a line's timestamps force on us, for -v to disclose.
344
+ # A timestamp with no date needs one to resolve DST in the *target* zone,
345
+ # whether or not it names its own; without a zone as well, the source zone
346
+ # comes from $TZ too.
347
+ def assumptions(line, from: nil)
348
+ # A timestamp left alone for its unknown zone assumes nothing.
349
+ readings = timestamps(scannable(line)).map(&:effective).reject { |time| unknown_zone(time) && !zone_token(time, from) }
350
+ [
351
+ (:zone if readings.any? { |time| zone_token(time, from).nil? }),
352
+ (:date if readings.any? { |time| time_only?(time) }),
353
+ ].compact
354
+ end
355
+
356
+ # The timestamps in a line, in order. A bare time beside one that names a
357
+ # date, zone or meridiem is most likely a duration ("took 0:05"), and is left
358
+ # out: that zone belongs to the timestamp naming it.
359
+ def timestamps(line)
360
+ stamps = with_range_hours(line, scan_stamps(line))
361
+ joins = stamps.each_cons(2).map { |head, tail| join_between(line, head, tail) }
362
+
363
+ # Each member takes the zone and meridiem written after the one it joins:
364
+ # "3:30 to 4:45 PM PST" starts at 3:30 PM PST. Walked backwards, so a chain
365
+ # passes them all the way down.
366
+ (joins.length - 1).downto(0) do |i|
367
+ next unless joins[i]
368
+
369
+ date, clock = split_date(stamps[i].effective)
370
+ stamps[i] = stamps[i].with(effective: "#{date}#{range_start(clock, stamps[i + 1].effective)}")
371
+ end
372
+
373
+ # Forwards, each member in the same zone as the one before it takes that
374
+ # one's date, and the next day if it is earlier on the clock: 11:30 PM to
375
+ # 12:30 AM ends tomorrow.
376
+ joins.each_with_index do |join, i|
377
+ head, tail = stamps[i], stamps[i + 1]
378
+ next unless join && detect_zone(head.effective) == detect_zone(tail.effective)
379
+
380
+ date, clock = split_date(head.effective)
381
+ rolls = minute_of_day(tail.effective) < minute_of_day(clock) ? 1 : 0
382
+ stamps[i + 1] =
383
+ if date
384
+ tail.with(effective: "#{(Date.parse(date) + rolls).strftime('%F')} #{tail.effective}")
385
+ else
386
+ tail.with(days: head.days + rolls)
104
387
  end
105
- end
388
+ end
389
+
390
+ group_ids = joins.reduce([0]) { |ids, join| ids << (join ? ids.last : ids.last + 1) }
391
+ kept = stamps.each_index.reject { |i| stamps[i].effective.match?(BARE_TIME) }
392
+ kept = stamps.each_index.to_a if kept.empty?
393
+
394
+ groups = kept.group_by { |i| group_ids[i] }.transform_values do |members|
395
+ next if members.size < 2
396
+
397
+ type = members[0...-1].all? { |m| joins[m] == :range } ? 'range' : 'list'
398
+ { type:, members: members.map { |m| stamps[m].text } }
399
+ end
400
+ kept.map { |i| (group = groups[group_ids[i]]) ? stamps[i].with(group:) : stamps[i] }
401
+ end
402
+
403
+ # A reading split into its date with separator, if any, and its clock.
404
+ def split_date(time)
405
+ m = time.match(/\A(\d{4}-\d{2}-\d{2}[T ])?(.*)\z/m)
406
+ [m[1], m[2]]
407
+ end
408
+
409
+ # A clock followed by a unit of time is a duration: "Finished in 1:05
410
+ # minutes". Only units spelled out enough not to be a word of their own.
411
+ DURATION_UNIT = /\A[ \t]+(?:secs?|seconds?|mins?|minutes?|hrs?|hours?)\b/i
412
+
413
+ # Every format has a digit followed by a colon and digit, or by a meridiem.
414
+ MAYBE_TIME = /\d(?::\d|[   ]?[AaPp])/
415
+
416
+ def scan_stamps(line)
417
+ stamps = []
418
+ return stamps unless line.match?(MAYBE_TIME)
419
+
420
+ line.scan(TIMESTAMP) do
421
+ offset, finish = $~.byteoffset(0)
422
+ text = $~[0]
423
+ # Neighbouring bytes only: pre_match/post_match would copy the rest of
424
+ # the line for every match.
425
+ before = offset.positive? ? line.byteslice(offset - 1, 1) : nil
426
+ after = line.byteslice(finish, 12)
427
+ next if text.match?(BARE_TIME) && after.match?(DURATION_UNIT)
428
+ # A clock inside a longer run of colons: IPv6 (fe80::1:23:45), SMPTE
429
+ # timecodes (01:02:03:04).
430
+ next if text.match?(/\A\d{1,2}:/) && (before == ':' || after.match?(/\A:\d/))
431
+
432
+ # ,200 before another comma is a CSV column, not milliseconds.
433
+ text = text.delete_suffix(text[-4..]) if text.match?(/,\d{3}\z/) && !after.match?(/\A(?:[ \t\]\r\n]|\z)/)
434
+ stamps << Stamp.new(offset:, text:, effective: reading(text), group: nil, days: 0)
435
+ end
436
+ stamps
437
+ end
438
+
439
+ # What a timestamp is parsed as: a named-month date as YYYY-MM-DD, an hour
440
+ # alone as its o'clock (9am is 9:00am), a slashed date dashed and a comma
441
+ # fraction dotted; anything else as written.
442
+ def reading(text)
443
+ text = text.tr("\u00A0\u202F", ' ')
444
+ if (m = NAMED_DATES.lazy.filter_map { |date| text.match(date) }.first)
445
+ time = m[:time].count(':') == 1 ? "#{m[:time]}:00" : m[:time]
446
+ time += m[:mer] if m.names.include?("mer") && m[:mer]
447
+ zone = m[:zone]&.sub(/\A[+-]\d{2}\z/) { "#{_1}00" }
448
+ date = format('%s-%02d-%02d', m[:year], MONTH_ABBRS.index(m[:mon]) + 1, m[:day].to_i)
449
+ [date, time, zone].compact.join(' ')
450
+ else
451
+ text.sub(%r{\A(\d{4})/(\d{2})/}) { "#{$1}-#{$2}-" }
452
+ .sub(/(:\d{2}),(\d)/) { "#{$1}.#{$2}" }
453
+ .sub(/\A(\S+ )?(\d{1,2})(?= ?[AaPp])/) { "#{$1}#{$2}:00" }
454
+ .sub(/(\d)([+-]\d{2})\z/) { "#{$1}#{$2}:00" }
455
+ end
456
+ end
106
457
 
107
- break result
458
+ # A bare hour is only a time as the start of a range whose end has a
459
+ # meridiem: the 9 of "9-9:15am". Anywhere else it is just a number.
460
+ def with_range_hours(line, stamps)
461
+ prev_end = 0
462
+ stamps.flat_map do |stamp|
463
+ gap = text_between(line, prev_end, stamp.offset)
464
+ gap_start = prev_end
465
+ prev_end = stamp.offset + stamp.text.bytesize
466
+ next [stamp] unless stamp.effective.match(TIME_PARTS)&.[](:meridiem)
467
+
468
+ m = gap&.match(RANGE_HOUR)
469
+ next [stamp] unless m && (1..12).cover?(m[:hour].to_i)
470
+ next [stamp] if gap.byteslice(0, m.byteoffset(:hour)[0]).match?(AFTER_MONTH)
471
+
472
+ head = Stamp.new(offset: gap_start + m.byteoffset(:hour)[0], text: m[:hour], effective: "#{m[:hour]}:00", group: nil, days: 0)
473
+ [head, stamp]
474
+ end
475
+ end
476
+
477
+ # How two neighbouring time-only timestamps are joined: :range, :list or nil.
478
+ def join_between(line, head, tail)
479
+ return unless split_date(head.effective)[1].match?(TIME_PARTS) && tail.effective.match?(TIME_PARTS)
480
+
481
+ gap = text_between(line, head.offset + head.text.bytesize, tail.offset)
482
+ if gap&.match?(RANGE_JOIN) then :range
483
+ elsif gap&.match?(LIST_JOIN) then :list
108
484
  end
485
+ end
486
+
487
+ # The text between two byte offsets, or nil if it is not valid UTF-8.
488
+ def text_between(line, from, to)
489
+ text = line.byteslice(from, to - from).force_encoding(Encoding::UTF_8)
490
+ text if text.valid_encoding?
491
+ end
492
+
493
+ def minute_of_day(time)
494
+ m = time.match(TIME_PARTS)
495
+ hour = m[:hour].to_i
496
+ hour = hour % 12 + (m[:meridiem].start_with?('P', 'p') ? 12 : 0) if m[:meridiem]
497
+ hour * 60 + m[:clock].split(':')[1].to_i
498
+ end
499
+
500
+ def range_start(head, tail)
501
+ h = head.match(TIME_PARTS)
502
+ t = tail.match(TIME_PARTS)
503
+ return head unless h && t && h[:zone].nil?
504
+
505
+ [h[:clock], h[:meridiem] || shared_meridiem(h[:hour].to_i, t), t[:zone]].compact.join(' ')
506
+ end
507
+
508
+ # The end's meridiem, unless that would run the range backwards: 11:30 to
509
+ # 1:00 PM starts in the morning. A 24-hour start takes none.
510
+ def shared_meridiem(hour, tail)
511
+ return unless tail[:meridiem] && (1..12).cover?(hour)
512
+
513
+ pm = tail[:meridiem].start_with?('P', 'p')
514
+ pm = !pm if hour % 12 > tail[:hour].to_i % 12
515
+ pm ? 'PM' : 'AM'
516
+ end
517
+
518
+ # Parsed as its effective reading, formatted to mirror what was written.
519
+ def convert_stamp(stamp, from:, to:, format:, date:)
520
+ if time_only?(stamp.effective)
521
+ base = date ? Date.parse(date) : today_where(stamp.effective, from, to)
522
+ date = (base + stamp.days).strftime('%F')
523
+ end
524
+ time = parse(stamp.effective, from:, to:, date:)
525
+ format_time(time.localtime, format, stamp.text)
526
+ rescue ArgumentError
527
+ nil
528
+ end
529
+
530
+ # Today where a dateless timestamp was written: in the zone it names, or else
531
+ # the source zone. What -d stands in for, and what -v names when it is absent.
532
+ def today_where(time, from, to)
533
+ abbr = zone_token(time, from)&.upcase
534
+ offset = zone_offset(abbr, from)
535
+ return Time.now.getlocal(offset).to_date if offset
109
536
 
110
- result
537
+ zone = aliased_zone(abbr, from) || from || to
538
+ # Cached for the minute: finding it switches zones, and back.
539
+ @today ||= {}
540
+ @today[[zone, Time.now.to_i / 60]] ||= begin
541
+ use_zone(zone)
542
+ today = Time.now.to_date
543
+ use_zone(to)
544
+ today
545
+ end
546
+ end
547
+
548
+ # Set $TZ only when it changes: every assignment, even of the same name,
549
+ # makes the next local-time call reload the zone.
550
+ def use_zone(zone)
551
+ ENV['TZ'] = zone unless ENV['TZ'] == zone
552
+ end
553
+
554
+ # The abbreviations the source zone itself uses this year, with the offset
555
+ # each stands for there: in Shanghai CST is +08:00, not US Central. Read in
556
+ # that sense only when the source zone uses them; otherwise the table's
557
+ # (US-centric) meaning stands, so a Los Angeles or UTC source changes nothing.
558
+ def local_abbreviations(zone)
559
+ @local_abbreviations ||= {}
560
+ @local_abbreviations[zone] ||= begin
561
+ old = ENV['TZ']
562
+ ENV['TZ'] = zone
563
+ year = Time.now.year
564
+ seasons = [Time.local(year, 1, 15, 12), Time.local(year, 7, 15, 12)]
565
+ ENV['TZ'] = old
566
+ seasons.to_h { |t| [t.strftime('%Z').upcase, t.strftime('%:z')] }.select { |abbr, _| abbr.match?(/\A[A-Z]+\z/) }
567
+ end
568
+ end
569
+
570
+ def local_offset(abbr, from)
571
+ from && abbr && local_abbreviations(from)[abbr.upcase]
572
+ end
573
+
574
+ # The zone a reading carries: one tztr knows, or one it doesn't that the
575
+ # source zone uses (EEST in Helsinki).
576
+ def zone_token(time, from)
577
+ detect_zone(time) || unknown_zone(time)&.then { |token| token if local_offset(token, from) }
578
+ end
579
+
580
+ # The fixed offset an abbreviation or numeric zone names, as +HH:MM: in the
581
+ # source zone's sense if it uses it, else the table's.
582
+ def zone_offset(abbr, from = nil)
583
+ return if abbr.nil?
584
+ return local_offset(abbr, from) if local_offset(abbr, from)
585
+ return abbr.sub(/\A([+-]\d{2}):?(\d{2})?\z/) { "#{$1}:#{$2 || '00'}" } if abbr.match?(/\A[+-]\d{2}(?::?\d{2})?\z/)
586
+
587
+ ZONE_OFFSETS[abbr]
588
+ end
589
+
590
+ # The date -v says it assumed for this line's first dateless timestamp.
591
+ def assumed_date(line, from: nil, to: 'UTC')
592
+ to = resolve_tz(to)
593
+ from = resolve_tz(from)
594
+ stamp = timestamps(scannable(line)).find { |s| time_only?(s.effective) }
595
+ stamp && today_where(stamp.effective, from, to).strftime('%F')
596
+ end
597
+
598
+ def detect_format(str)
599
+ case str
600
+ when /\A\d{4}-\d{2}-\d{2}T/ then 'iso'
601
+ when /\A\d{4}-\d{2}-\d{2} / then 'datetime'
602
+ else 'time'
603
+ end
111
604
  end
112
605
 
113
- def parse(str, from: nil, to: 'UTC')
114
- if has_timezone?(str)
606
+ # The zone a reading ends with. It must start the text or follow a space or
607
+ # digit: EEST is not EST with an E in front.
608
+ ZONE_AT_END = /(?:\A|[ \d])(#{ABBREVIATION}|[+-]\d{2}(?::?\d{2})?)\z/
609
+
610
+ def detect_zone(str)
611
+ m = str.match(ZONE_AT_END)
612
+ m && m[1]
613
+ end
614
+
615
+ # A zone-shaped word a reading ends with that isn't one tztr knows (EEST,
616
+ # WIB): the timestamp is left as written, and -v says why.
617
+ def unknown_zone(time)
618
+ token = time[/ ([A-Za-z]{2,5})\z/, 1]
619
+ token if token && !token.match?(/\A[AaPp][Mm]\z/) && !detect_zone(time)
620
+ end
621
+
622
+ def parse(str, from: nil, to: 'UTC', date: nil)
623
+ # Time-only inputs carry no date, so DST can't be resolved correctly. A
624
+ # reference date supplies the missing context (see README caveat).
625
+ str = "#{date} #{str}" if date && time_only?(str)
626
+ # Time.parse rolls 2026-02-30 forward to 2026-03-02, moving a logged event
627
+ # to another day with no signal. Leave impossible dates untranslated.
628
+ raise ArgumentError, "impossible date: #{str}" unless real_date?(str)
629
+
630
+ # Time.parse reads the "p" of "3:45 p.m." as the military zone P (-03:00).
631
+ str = str.sub(/([AaPp])\.([Mm])\.?/, '\1\2')
632
+
633
+ # A zone we can't resolve (a date(1) line's WIB): leave the text alone
634
+ # rather than read it as local time.
635
+ unknown = unknown_zone(str)
636
+ abbr = zone_token(str, from)
637
+ raise ArgumentError, "unknown zone: #{unknown}" if unknown && !abbr
638
+
639
+ # Time.parse accepts 99:14, and 22:14 AM, in some shapes; the Rust port
640
+ # never does.
641
+ h, m, sec = str.match(/(\d{1,2}):(\d{2})(?::(\d{2}))?/)&.captures&.map(&:to_i)
642
+ meridiem = str.match?(/\d ?[AaPp][Mm]\b/)
643
+ unless h && m <= 59 && sec.to_i <= 60 && (h < 24 || (h == 24 && m.zero? && sec.to_i.zero?)) && !(meridiem && h > 12)
644
+ raise ArgumentError, "impossible clock: #{str}"
645
+ end
646
+
647
+ # A fixed abbreviation becomes the offset it names, in whatever case it was
648
+ # written, so Time.parse's own zone table never decides.
649
+ if abbr&.match?(/\A[A-Za-z]+\z/) && (offset = zone_offset(abbr.upcase, from))
650
+ str = str.delete_suffix(abbr) + offset
651
+ abbr = offset
652
+ end
653
+ zone = aliased_zone(abbr&.upcase, from)
654
+
655
+ if zone
656
+ # Strip the abbreviation: left in place, Time.parse's own zone table
657
+ # would win over the IANA zone we just resolved it to.
658
+ in_zone(str.delete_suffix(abbr).rstrip, zone, to)
659
+ elsif abbr
115
660
  Time.parse(str)
116
661
  elsif from
117
- ENV['TZ'] = from
118
- t = Time.parse(str).utc
119
- ENV['TZ'] = to
120
- t.localtime
662
+ in_zone(str, from, to)
121
663
  else
122
- ENV['TZ'] = to
123
- Time.parse(str)
664
+ use_zone(to)
665
+ earliest_occurrence(Time.parse(str))
666
+ end
667
+ end
668
+
669
+ # The IANA zone a generic abbreviation (ET, PT) names; fixed ones have none.
670
+ def aliased_zone(abbr, from = nil)
671
+ return if abbr.nil? || zone_offset(abbr, from)
672
+
673
+ TIMEZONE_ALIASES[abbr.downcase]
674
+ end
675
+
676
+ # A wall clock in `zone`, as a time in `to`. Read as if UTC, then shifted by
677
+ # the zone's offset at that moment: switching $TZ to zone and back instead
678
+ # costs ~400us a timestamp, most of a run.
679
+ def in_zone(str, zone, to)
680
+ wall = Time.parse("#{str} UTC")
681
+ use_zone(to)
682
+ wall_to_utc(wall, zone).localtime
683
+ end
684
+
685
+ # The instant a wall clock in `zone` names. Across a fall-back overlap, the
686
+ # earlier one (earliest_occurrence's rule); in a spring-forward gap, the
687
+ # offset from before it, which moves the clock forward as Time.local does.
688
+ def wall_to_utc(wall, zone)
689
+ before = offset_at(zone, wall - 86_400)
690
+ after = offset_at(zone, wall + 86_400)
691
+ return wall - before if before == after
692
+
693
+ fits = [before, after].select { |offset| offset_at(zone, wall - offset) == offset }
694
+ wall - (fits.max || before)
695
+ end
696
+
697
+ # A zone's UTC offset at an instant, cached per 15-minute bucket: every tzdb
698
+ # transition falls on a quarter hour, so a bucket has one offset, and a log's
699
+ # timestamps cluster into few buckets.
700
+ def offset_at(zone, time)
701
+ bucket = time.to_i / 900
702
+ @offsets ||= {}
703
+ @offsets[[zone, bucket]] ||= begin
704
+ previous = ENV['TZ']
705
+ use_zone(zone)
706
+ offset = Time.at(bucket * 900).utc_offset
707
+ use_zone(previous)
708
+ offset
124
709
  end
125
710
  end
126
711
 
127
- def has_timezone?(str)
128
- str.match?(/Z$|[+-]\d{2}:?\d{2}$| ?(?:UTC|GMT|[A-Z]{2,4}|[+-]\d{4})$/)
712
+ # A wall clock repeated by a DST fall-back resolves to the earlier
713
+ # (daylight) occurrence, as Temporal, ICU, RFC 5545 and date(1) do.
714
+ # Time.parse picks the later one.
715
+ def earliest_occurrence(time)
716
+ earlier = time - 3600
717
+ earlier.strftime('%F %T') == time.strftime('%F %T') ? earlier : time
718
+ end
719
+
720
+ # A UTF-8 copy safe to match against, byte for byte the same length. Each
721
+ # invalid byte becomes "?", which like the Rust port's reading of a stray
722
+ # byte is neither letter nor digit; letters around it stay letters.
723
+ def scannable(line)
724
+ text = line.dup.force_encoding(Encoding::UTF_8)
725
+ text.valid_encoding? ? text : text.scrub { |bytes| '?' * bytes.bytesize }
726
+ end
727
+
728
+ def real_date?(str)
729
+ m = str.match(/\A(\d{4})-(\d{2})-(\d{2})/)
730
+ m.nil? || Date.valid_date?(m[1].to_i, m[2].to_i, m[3].to_i)
731
+ end
732
+
733
+ def time_only?(str)
734
+ str.match?(/\A\d{1,2}:/)
735
+ end
736
+
737
+ # A clock to the precision it was written with, fraction and its separator
738
+ # included: 22:14:42,123 stays three digits after a comma.
739
+ def written_clock(original)
740
+ m = original.match(/\d{1,2}:\d{2}(?<secs>:\d{2}(?<frac>[.,]\d+)?)?/)
741
+ return '%H:%M' unless m&.[](:secs)
742
+
743
+ clock = '%H:%M:%S'
744
+ # Nanoseconds are as fine as a clock here goes.
745
+ clock += "#{m[:frac][0]}%#{[m[:frac].size - 1, 9].min}N" if m[:frac]
746
+ clock
747
+ end
748
+
749
+ # A zone written back the way the input wrote it: numeric in the same shape
750
+ # (-0700, -07:00, -07) if it was, else an abbreviation.
751
+ def written_zone(time, zone)
752
+ return time.utc? ? 'UTC' : time.strftime('%Z') unless zone&.match?(/\A[+-]\d/)
753
+
754
+ sign = time.utc_offset.negative? ? '-' : '+'
755
+ hours, minutes = time.utc_offset.abs.divmod(3600)
756
+ minutes /= 60
757
+ case zone
758
+ when /:/ then format('%s%02d:%02d', sign, hours, minutes)
759
+ when /\A[+-]\d{2}\z/ then minutes.zero? ? format('%s%02d', sign, hours) : format('%s%02d:%02d', sign, hours, minutes)
760
+ else format('%s%02d%02d', sign, hours, minutes)
761
+ end
129
762
  end
130
763
 
131
764
  def format_time(time, fmt, original)
765
+ tz = time.utc_offset == 0 ? 'Z' : time.strftime('%:z')
766
+
132
767
  case fmt
133
- when :short then return time.strftime('%Y-%m-%d %H:%M')
134
768
  when :time then return time.strftime('%H:%M:%S')
135
- when :iso then return time.strftime('%Y-%m-%d %H:%M:%S')
769
+ when :iso then return time.strftime('%Y-%m-%d %H:%M:%S') + tz
770
+ when :short
771
+ # Always labelled: a cross-timezone tool whose output doesn't say which
772
+ # zone it is in gets pasted into a ticket and read wrong.
773
+ return time.strftime('%Y-%m-%d %H:%M') + " " + (time.utc? ? 'UTC' : time.strftime('%Z'))
136
774
  end
137
775
 
138
776
  # Preserve input format
139
- tz = time.utc_offset == 0 ? 'Z' : time.strftime('%:z')
140
777
 
141
778
  case original
142
779
  when /^\d{4}-\d{2}-\d{2}T/
143
- has_frac = original.match?(/T\d{2}:\d{2}:\d{2}\.\d+/)
144
- base = has_frac ? time.strftime('%Y-%m-%dT%H:%M:%S.%L') : time.strftime('%Y-%m-%dT%H:%M:%S')
145
- base + tz
146
- when /^\d{4}-\d{2}-\d{2} /
147
- has_frac = original.match?(/ \d{2}:\d{2}:\d{2}\.\d+/)
148
- base = has_frac ? time.strftime('%Y-%m-%d %H:%M:%S.%L') : time.strftime('%Y-%m-%d %H:%M:%S')
149
- base + " " + (time.utc? ? 'UTC' : time.strftime('%Z'))
150
- when /^\d{1,2}:\d{2}(?::\d{2})/
151
- time.strftime('%H:%M:%S') + " " + (time.utc? ? 'UTC' : time.strftime('%Z'))
780
+ time.strftime("%Y-%m-%dT#{written_clock(original)}") + tz
781
+ when %r{^\d{4}([-/])\d{2}[-/]\d{2} }
782
+ date = time.strftime("%Y#{$1}%m#{$1}%d #{written_clock(original)}")
783
+ offset = original.match(/(?<gap> ?)(?<zone>[+-]\d{2}(?::?\d{2})?)\z/)
784
+ offset ? date + offset[:gap] + written_zone(time, offset[:zone]) : "#{date} #{written_zone(time, nil)}"
785
+ when CLF_DATE
786
+ time.strftime('%d/%b/%Y:%H:%M:%S %z')
787
+ when UNIX_DATE
788
+ m = $~
789
+ day = original.match?(/#{MON} /) ? '%e' : '%-d'
790
+ time.strftime("#{'%a ' if m[:weekday]}%b #{day} %H:%M#{':%S' if m[:time].count(':') == 2} ") + written_zone(time, m[:zone]) + time.strftime(' %Y')
791
+ when LOCALE_DATE
792
+ m = $~
793
+ clock = m[:time].count(':') == 2 ? '%H:%M:%S' : '%H:%M'
794
+ clock = clock.sub('%H', '%I') + ' %p' if m[:mer]
795
+ time.strftime("%a #{m[:day].length == 2 ? '%d' : '%-d'} %b %Y #{clock} ") + written_zone(time, m[:zone])
796
+ when RFC_DATE
797
+ m = $~
798
+ clock = "%H:%M#{':%S' if m[:time].count(':') == 2}"
799
+ clock = clock.sub('%H', '%I') + ' %p' if m[:mer]
800
+ time.strftime("#{'%a, ' if m[:weekday]}#{m[:day].length == 2 ? '%d' : '%-d'} %b %Y #{clock} ") + written_zone(time, m[:zone])
152
801
  when /^\d{1,2}:\d{2}/
153
- time.strftime('%H:%M') + " " + (time.utc? ? 'UTC' : time.strftime('%Z'))
802
+ time.strftime(written_clock(original)) + " " + written_zone(time, nil)
803
+ when /\A\d{1,2}(?:\z|[ \u00A0\u202F]?[AaPp])/
804
+ time.strftime('%H:%M') + " " + written_zone(time, nil)
154
805
  else
155
806
  time.strftime('%Y-%m-%d %H:%M:%S') + " " + (time.utc? ? 'UTC' : time.strftime('%Z'))
156
807
  end