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/bin/tztr
CHANGED
|
@@ -2,11 +2,52 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'optparse'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'date'
|
|
7
|
+
require 'stringio'
|
|
5
8
|
|
|
6
9
|
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
7
10
|
require 'tztr'
|
|
8
11
|
|
|
12
|
+
# Structured description of the CLI, emitted by `-h -j` / `-h -J` so agents can
|
|
13
|
+
# read the option schema instead of scraping the text help.
|
|
14
|
+
HELP_DOC = {
|
|
15
|
+
name: 'tztr',
|
|
16
|
+
version: Tztr::VERSION,
|
|
17
|
+
usage: 'tztr [options] [file | now]',
|
|
18
|
+
summary: 'Timezone Translator - convert timestamps between timezones. Reads from stdin, files, or the clock (now).',
|
|
19
|
+
options: [
|
|
20
|
+
{ short: '-f', long: '--from', arg: 'TZ', description: 'Input timezone for timestamps that name none (default: $TZ, else the system zone)' },
|
|
21
|
+
{ short: '-t', long: '--to', arg: 'TZ', description: 'Output timezone (default: $TZ, else UTC)' },
|
|
22
|
+
{ short: '-l', long: '--list', arg: nil, description: 'List timezone aliases' },
|
|
23
|
+
{ short: '-i', long: '--in-place', arg: nil, description: 'Edit files in place' },
|
|
24
|
+
{ short: '-F', long: '--format', arg: 'FMT', description: 'Output format: iso, short, time (default: preserve input)' },
|
|
25
|
+
{ short: '-d', long: '--date', arg: 'DATE', description: 'Reference date for time-only inputs (resolves DST)' },
|
|
26
|
+
{ short: '-j', long: '--json', arg: nil, description: 'Emit a JSON array of matches' },
|
|
27
|
+
{ short: '-J', long: '--ndjson', arg: nil, description: 'Emit newline-delimited JSON (one object per match)' },
|
|
28
|
+
{ short: nil, long: '--detect', arg: nil, description: 'Report detected format/zone without converting' },
|
|
29
|
+
{ short: '-v', long: '--verbose', arg: nil, description: 'Print diagnostics to stderr' },
|
|
30
|
+
{ short: '-V', long: '--version', arg: nil, description: 'Show version' },
|
|
31
|
+
{ short: '-h', long: '--help', arg: nil, description: 'Show this help' },
|
|
32
|
+
],
|
|
33
|
+
environment: [
|
|
34
|
+
{ name: 'TZ', description: 'Default timezone for input and output (overridden by -f / -t)' },
|
|
35
|
+
],
|
|
36
|
+
examples: [
|
|
37
|
+
"echo '2026-04-03T12:00:00Z' | tztr -t sf",
|
|
38
|
+
"echo '15:30 UTC' | tztr -t pst",
|
|
39
|
+
"echo '12:00 EST' | tztr -t -8",
|
|
40
|
+
"tail -f app.log | tztr -t nyc",
|
|
41
|
+
"echo '15:30 UTC' | tztr -t pst -j",
|
|
42
|
+
"tail -f app.log | tztr -t nyc -J",
|
|
43
|
+
"echo '2026-04-03T12:00:00Z' | tztr --detect -j",
|
|
44
|
+
"echo '15:30' | tztr -f pacific -t utc -d 2026-01-15",
|
|
45
|
+
"tztr now -t tokyo",
|
|
46
|
+
],
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
9
49
|
local_tz = ENV['TZ']
|
|
50
|
+
local_tz = nil if local_tz&.empty? # TZ="" means unset, not "a zone named ''"
|
|
10
51
|
|
|
11
52
|
options = {
|
|
12
53
|
from: nil,
|
|
@@ -14,58 +55,213 @@ options = {
|
|
|
14
55
|
format: nil
|
|
15
56
|
}
|
|
16
57
|
|
|
58
|
+
FORMATS = %w[iso short time].freeze
|
|
59
|
+
|
|
60
|
+
# Validated as the flag is parsed, not afterwards, so `-h -F bogus` still
|
|
61
|
+
# fails -- the order the Rust port parses in.
|
|
62
|
+
parse_format = lambda do |value|
|
|
63
|
+
unless FORMATS.include?(value)
|
|
64
|
+
abort "tztr: invalid format: #{value} (expected #{FORMATS.join(', ')})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
value.to_sym
|
|
68
|
+
end
|
|
69
|
+
|
|
17
70
|
parser = OptionParser.new do |opts|
|
|
18
|
-
opts.banner = "Usage: tztr [options] [file]"
|
|
71
|
+
opts.banner = "Usage: tztr [options] [file | now]"
|
|
19
72
|
opts.separator ""
|
|
20
|
-
opts.separator "Timezone Translator - convert timestamps
|
|
73
|
+
opts.separator "Timezone Translator - convert timestamps between timezones. Reads from stdin, files, or the clock (now)."
|
|
21
74
|
opts.separator ""
|
|
22
75
|
|
|
23
|
-
opts.on("-f", "--from TZ", "Input timezone (default:
|
|
24
|
-
opts.on("-t", "--to TZ", "Output timezone (default: UTC)") { |v| options[:to] = v }
|
|
76
|
+
opts.on("-f", "--from TZ", "Input timezone for timestamps that name none (default: $TZ, else the system zone)") { |v| options[:from] = v }
|
|
77
|
+
opts.on("-t", "--to TZ", "Output timezone (default: $TZ, else UTC)") { |v| options[:to] = v }
|
|
25
78
|
opts.on("-l", "--list", "List timezone aliases") { Tztr::TIMEZONE_ALIASES.sort.each { |k, v| puts "%-12s %s" % [k, v] }; exit }
|
|
26
|
-
opts.on("-i", "--in-place", "Edit
|
|
27
|
-
opts.on("-F", "--format FMT",
|
|
28
|
-
opts.on("-
|
|
29
|
-
opts.on("-
|
|
79
|
+
opts.on("-i", "--in-place", "Edit files in place") { options[:inplace] = true }
|
|
80
|
+
opts.on("-F", "--format FMT", "Output format: iso, short, time (default: preserve input)") { |v| options[:format] = parse_format.call(v) }
|
|
81
|
+
opts.on("-d", "--date DATE", "Reference date for time-only inputs (resolves DST)") { |v| options[:date] = v }
|
|
82
|
+
opts.on("-j", "--json", "Emit a JSON array of matches") { options[:json] = true }
|
|
83
|
+
opts.on("-J", "--ndjson", "Emit newline-delimited JSON (one object per match)") { options[:ndjson] = true }
|
|
84
|
+
opts.on("--detect", "Report detected format/zone without converting") { options[:detect] = true }
|
|
85
|
+
opts.on("-v", "--verbose", "Print diagnostics to stderr") { options[:verbose] = true }
|
|
86
|
+
opts.on("-V", "--version", "Show version") { puts Tztr::VERSION; exit }
|
|
87
|
+
opts.on("-h", "--help", "Show this help") { options[:help] = true }
|
|
30
88
|
|
|
31
89
|
opts.separator ""
|
|
32
90
|
opts.separator "Environment:"
|
|
33
|
-
opts.separator " TZ
|
|
91
|
+
opts.separator " TZ Default timezone for input and output (overridden by -f / -t)"
|
|
34
92
|
opts.separator ""
|
|
35
93
|
opts.separator "Examples:"
|
|
36
94
|
opts.separator " echo '2026-04-03T12:00:00Z' | tztr -t sf"
|
|
37
95
|
opts.separator " echo '15:30 UTC' | tztr -t pst"
|
|
38
96
|
opts.separator " echo '12:00 EST' | tztr -t -8"
|
|
39
97
|
opts.separator " tail -f app.log | tztr -t nyc"
|
|
98
|
+
opts.separator " echo '15:30 UTC' | tztr -t pst -j"
|
|
99
|
+
opts.separator " tail -f app.log | tztr -t nyc -J"
|
|
100
|
+
opts.separator " echo '2026-04-03T12:00:00Z' | tztr --detect -j"
|
|
101
|
+
opts.separator " echo '15:30' | tztr -f pacific -t utc -d 2026-01-15"
|
|
102
|
+
opts.separator " tztr now -t tokyo"
|
|
103
|
+
end
|
|
104
|
+
begin
|
|
105
|
+
# No prefix abbreviation: --jso must not quietly mean --json, because the
|
|
106
|
+
# Rust binary most users install rejects it.
|
|
107
|
+
parser.require_exact = true
|
|
108
|
+
parser.parse!
|
|
109
|
+
rescue OptionParser::MissingArgument => e
|
|
110
|
+
abort "#{parser.program_name}: missing argument for #{e.args.first}"
|
|
111
|
+
rescue OptionParser::NeedlessArgument => e
|
|
112
|
+
# args carries the "=value" tail; the user only needs the flag named.
|
|
113
|
+
abort "#{parser.program_name}: #{e.args.first.split('=').first} takes no argument"
|
|
114
|
+
rescue OptionParser::InvalidOption, OptionParser::AmbiguousOption => e
|
|
115
|
+
# Built from args rather than e.message, which appends a "Did you mean?" line.
|
|
116
|
+
abort "#{parser.program_name}: invalid option: #{e.args.first}"
|
|
117
|
+
rescue OptionParser::ParseError => e
|
|
118
|
+
abort "#{parser.program_name}: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
json_mode = options[:json] || options[:ndjson]
|
|
122
|
+
|
|
123
|
+
if options[:help]
|
|
124
|
+
if json_mode
|
|
125
|
+
puts(options[:ndjson] ? JSON.generate(HELP_DOC) : JSON.pretty_generate(HELP_DOC))
|
|
126
|
+
else
|
|
127
|
+
puts parser
|
|
128
|
+
end
|
|
129
|
+
exit
|
|
40
130
|
end
|
|
41
|
-
parser.parse!
|
|
42
131
|
|
|
43
|
-
|
|
44
|
-
|
|
132
|
+
# A timestamp with no zone is read in -f, else $TZ, else the machine's own
|
|
133
|
+
# zone; only when none of those is known is it taken to be in -t already.
|
|
134
|
+
system_tz = Tztr.system_zone unless local_tz
|
|
135
|
+
implicit_source =
|
|
136
|
+
if options[:from] then nil
|
|
137
|
+
elsif local_tz then '$TZ'
|
|
138
|
+
elsif system_tz then 'the system zone'
|
|
139
|
+
end
|
|
140
|
+
implicit_from = !implicit_source.nil?
|
|
141
|
+
|
|
142
|
+
begin
|
|
143
|
+
options[:to] = Tztr.resolve_tz(options[:to])
|
|
144
|
+
options[:from] = Tztr.resolve_tz(options[:from]) || Tztr.resolve_tz(local_tz) || system_tz
|
|
145
|
+
options[:date] = Tztr.normalize_date(options[:date]) if options[:date]
|
|
146
|
+
rescue Tztr::Error => e
|
|
147
|
+
abort "#{parser.program_name}: #{e.message}"
|
|
148
|
+
end
|
|
45
149
|
ENV['TZ'] = options[:to]
|
|
46
150
|
|
|
151
|
+
if options[:inplace] && (json_mode || options[:detect])
|
|
152
|
+
abort "#{parser.program_name}: -i cannot be combined with --json/--ndjson/--detect"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# An implicit from is announced by `disclose` below instead, and only if some
|
|
156
|
+
# timestamp actually uses it -- naming it here would claim a source zone that a
|
|
157
|
+
# timestamp carrying its own never consults.
|
|
158
|
+
if options[:verbose] && !implicit_from
|
|
159
|
+
warn "#{parser.program_name}: from=#{options[:from] || 'auto'} to=#{options[:to]}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# A bare time silently borrows its source zone from $TZ and resolves DST
|
|
163
|
+
# against today. Say so, once, the first time it actually happens.
|
|
164
|
+
disclosed = []
|
|
165
|
+
disclose = lambda do |line|
|
|
166
|
+
# A date(1) zone we don't know (EEST): the reason its line was left alone.
|
|
167
|
+
(Tztr.unknown_zones(line, from: options[:from]) - disclosed).each do |token|
|
|
168
|
+
disclosed << token
|
|
169
|
+
warn %(#{parser.program_name}: ignored "#{token}": not a zone tztr knows)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A zone the user wrote but we didn't read, because of its case (Pst).
|
|
173
|
+
(Tztr.ignored_zones(line) - disclosed).each do |token|
|
|
174
|
+
disclosed << token
|
|
175
|
+
warn %(#{parser.program_name}: ignored "#{token}": a zone abbreviation is matched in all uppercase or all lowercase)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
assumed = Tztr.assumptions(line, from: options[:from]) - disclosed
|
|
179
|
+
return if assumed.empty?
|
|
180
|
+
|
|
181
|
+
disclosed.concat(assumed)
|
|
182
|
+
warn "#{parser.program_name}: from=#{options[:from]} (implicit, from #{implicit_source}) to=#{options[:to]}" if assumed.include?(:zone) && implicit_from
|
|
183
|
+
return unless assumed.include?(:date) && !options[:date]
|
|
184
|
+
|
|
185
|
+
warn "#{parser.program_name}: no -d given, assuming #{Tztr.assumed_date(line, from: options[:from], to: options[:to])} for DST resolution"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# An error about one of our inputs names it, and the run carries on with the
|
|
189
|
+
# rest, as cat and sed -i do -- stopping would leave -i half-applied. A broken
|
|
190
|
+
# pipe is about stdout and stdin has no name, so neither is about an input
|
|
191
|
+
# file: both fall through to the bare form below.
|
|
192
|
+
failed = false
|
|
193
|
+
file_error = lambda do |path, error|
|
|
194
|
+
raise error if path.nil? || error.is_a?(Errno::EPIPE)
|
|
195
|
+
|
|
196
|
+
warn "#{parser.program_name}: #{path}: #{SystemCallError.new(error.errno).message} (os error #{error.errno})"
|
|
197
|
+
failed = true
|
|
198
|
+
end
|
|
199
|
+
|
|
47
200
|
$stdout.sync = true
|
|
48
201
|
|
|
202
|
+
# `tztr now`: the current time, read as though piped in, so every flag applies.
|
|
203
|
+
if ARGV == ['now']
|
|
204
|
+
abort "#{parser.program_name}: -i cannot be combined with now" if options[:inplace]
|
|
205
|
+
|
|
206
|
+
ARGV.clear
|
|
207
|
+
$stdin = StringIO.new(Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ\n"))
|
|
208
|
+
end
|
|
209
|
+
|
|
49
210
|
if ARGV.empty? && $stdin.tty?
|
|
50
211
|
puts parser
|
|
51
212
|
exit
|
|
52
213
|
end
|
|
53
214
|
|
|
54
|
-
|
|
55
|
-
|
|
215
|
+
begin
|
|
216
|
+
if options[:inplace]
|
|
217
|
+
abort "#{parser.program_name}: -i requires a file argument" if ARGV.empty?
|
|
56
218
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
inputs.each do |input|
|
|
67
|
-
input.each_line do |line|
|
|
68
|
-
print Tztr.translate(line, to: options[:to], from: options[:from], format: options[:format])
|
|
219
|
+
ARGV.each do |file|
|
|
220
|
+
content = File.read(file)
|
|
221
|
+
translated = content.each_line.map do |line|
|
|
222
|
+
disclose.call(line) if options[:verbose] && !options[:detect]
|
|
223
|
+
Tztr.translate(line, to: options[:to], from: options[:from], format: options[:format], date: options[:date])
|
|
224
|
+
end.join
|
|
225
|
+
File.write(file, translated) if translated != content
|
|
226
|
+
rescue SystemCallError => e
|
|
227
|
+
file_error.call(file, e)
|
|
69
228
|
end
|
|
229
|
+
else
|
|
230
|
+
collected = [] if options[:json]
|
|
231
|
+
|
|
232
|
+
# Opened one at a time: a filter should start emitting as soon as it has
|
|
233
|
+
# something, rather than stat-ing every argument first. nil is stdin.
|
|
234
|
+
paths = ARGV.empty? ? [nil] : ARGV
|
|
235
|
+
paths.each do |path|
|
|
236
|
+
input = path ? File.open(path) : $stdin
|
|
237
|
+
|
|
238
|
+
input.each_line do |line|
|
|
239
|
+
disclose.call(line) if options[:verbose] && !options[:detect]
|
|
240
|
+
|
|
241
|
+
if json_mode
|
|
242
|
+
ms = Tztr.matches(line, to: options[:to], from: options[:from], format: options[:format], detect: options[:detect], date: options[:date])
|
|
243
|
+
if options[:ndjson]
|
|
244
|
+
ms.each { |m| puts JSON.generate(m) }
|
|
245
|
+
else
|
|
246
|
+
collected.concat(ms)
|
|
247
|
+
end
|
|
248
|
+
elsif options[:detect]
|
|
249
|
+
Tztr.matches(line, to: options[:to], from: options[:from], detect: true).each do |m|
|
|
250
|
+
puts [m[:original], m[:detected_format], m[:detected_tz]].join("\t")
|
|
251
|
+
end
|
|
252
|
+
else
|
|
253
|
+
print Tztr.translate(line, to: options[:to], from: options[:from], format: options[:format], date: options[:date])
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
rescue SystemCallError => e
|
|
257
|
+
file_error.call(path, e)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
puts JSON.pretty_generate(collected) if options[:json]
|
|
70
261
|
end
|
|
262
|
+
exit 1 if failed
|
|
263
|
+
rescue SystemCallError => e
|
|
264
|
+
# Ruby's own message carries the syscall and path; the Rust port prints the
|
|
265
|
+
# bare strerror, and the two must read the same.
|
|
266
|
+
abort "#{parser.program_name}: #{SystemCallError.new(e.errno).message} (os error #{e.errno})"
|
|
71
267
|
end
|
data/lib/tztr/version.rb
CHANGED