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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/CLAUDE.md +86 -0
- data/Gemfile.lock +10 -4
- data/Makefile +65 -0
- data/README.md +298 -24
- data/bin/tztr +222 -26
- data/lib/tztr/version.rb +1 -1
- data/lib/tztr.rb +705 -54
- data/tztr.gemspec +8 -2
- metadata +32 -2
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}
|
|
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}
|
|
12
|
-
# Date space time
|
|
13
|
-
|
|
14
|
-
# Date space time: 2026-04-03 12:34:56
|
|
15
|
-
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
# Time with
|
|
19
|
-
|
|
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
|
-
'
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
t = Time.parse(str).utc
|
|
119
|
-
ENV['TZ'] = to
|
|
120
|
-
t.localtime
|
|
662
|
+
in_zone(str, from, to)
|
|
121
663
|
else
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
when
|
|
151
|
-
|
|
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(
|
|
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
|