zone 0.1.0 → 0.1.2
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 +77 -0
- data/README.md +208 -13
- data/TEST_PLAN.md +911 -0
- data/completions/README.md +126 -0
- data/completions/_zone +89 -0
- data/docs/user-experience-review.md +150 -0
- data/exe/zone +2 -253
- data/lib/zone/cli.rb +64 -0
- data/lib/zone/colors.rb +179 -0
- data/lib/zone/field.rb +67 -0
- data/lib/zone/field_line.rb +97 -0
- data/lib/zone/field_mapping.rb +51 -0
- data/lib/zone/input.rb +52 -0
- data/lib/zone/logging.rb +38 -0
- data/lib/zone/options.rb +142 -0
- data/lib/zone/output.rb +39 -0
- data/lib/zone/pattern.rb +59 -0
- data/lib/zone/timestamp.rb +138 -0
- data/lib/zone/timestamp_patterns.rb +169 -0
- data/lib/zone/transform.rb +69 -0
- data/lib/zone/version.rb +1 -1
- data/lib/zone.rb +45 -1
- data/todo.md +85 -0
- metadata +19 -1
data/lib/zone/colors.rb
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Extracted from command_kit gem (https://github.com/postmodern/command_kit.rb)
|
|
4
|
+
# Copyright (c) 2021-2024 Hal Brodigan
|
|
5
|
+
# Licensed under the MIT License
|
|
6
|
+
#
|
|
7
|
+
# This is a simplified version containing only the ANSI color functionality.
|
|
8
|
+
# The full command_kit gem provides many additional features.
|
|
9
|
+
|
|
10
|
+
module Zone
|
|
11
|
+
#
|
|
12
|
+
# ANSI color codes for terminal output.
|
|
13
|
+
#
|
|
14
|
+
# @see https://en.wikipedia.org/wiki/ANSI_escape_code
|
|
15
|
+
#
|
|
16
|
+
module Colors
|
|
17
|
+
#
|
|
18
|
+
# Applies ANSI formatting to text.
|
|
19
|
+
#
|
|
20
|
+
module ANSI
|
|
21
|
+
# ANSI reset code
|
|
22
|
+
RESET = "\e[0m"
|
|
23
|
+
|
|
24
|
+
# ANSI code for bold text
|
|
25
|
+
BOLD = "\e[1m"
|
|
26
|
+
|
|
27
|
+
# ANSI code to disable boldness
|
|
28
|
+
RESET_INTENSITY = "\e[22m"
|
|
29
|
+
|
|
30
|
+
# ANSI color code for red text
|
|
31
|
+
RED = "\e[31m"
|
|
32
|
+
|
|
33
|
+
# ANSI color code for yellow text
|
|
34
|
+
YELLOW = "\e[33m"
|
|
35
|
+
|
|
36
|
+
# ANSI color code for green text
|
|
37
|
+
GREEN = "\e[32m"
|
|
38
|
+
|
|
39
|
+
# ANSI color code for cyan text
|
|
40
|
+
CYAN = "\e[36m"
|
|
41
|
+
|
|
42
|
+
# ANSI color for the default foreground color
|
|
43
|
+
RESET_FG = "\e[39m"
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
#
|
|
48
|
+
# Bolds the text.
|
|
49
|
+
#
|
|
50
|
+
# @param [String, nil] string
|
|
51
|
+
# An optional string.
|
|
52
|
+
#
|
|
53
|
+
# @return [String, BOLD]
|
|
54
|
+
# The bolded string or just {BOLD} if no arguments were given.
|
|
55
|
+
#
|
|
56
|
+
def bold(string=nil)
|
|
57
|
+
if string then "#{BOLD}#{string}#{RESET_INTENSITY}"
|
|
58
|
+
else BOLD
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#
|
|
63
|
+
# Sets the text color to red.
|
|
64
|
+
#
|
|
65
|
+
# @param [String, nil] string
|
|
66
|
+
# An optional string.
|
|
67
|
+
#
|
|
68
|
+
# @return [String, RED]
|
|
69
|
+
# The colorized string or just {RED} if no arguments were given.
|
|
70
|
+
#
|
|
71
|
+
def red(string=nil)
|
|
72
|
+
if string then "#{RED}#{string}#{RESET_FG}"
|
|
73
|
+
else RED
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#
|
|
78
|
+
# Sets the text color to yellow.
|
|
79
|
+
#
|
|
80
|
+
# @param [String, nil] string
|
|
81
|
+
# An optional string.
|
|
82
|
+
#
|
|
83
|
+
# @return [String, YELLOW]
|
|
84
|
+
# The colorized string or just {YELLOW} if no arguments were given.
|
|
85
|
+
#
|
|
86
|
+
def yellow(string=nil)
|
|
87
|
+
if string then "#{YELLOW}#{string}#{RESET_FG}"
|
|
88
|
+
else YELLOW
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
#
|
|
93
|
+
# Sets the text color to green.
|
|
94
|
+
#
|
|
95
|
+
# @param [String, nil] string
|
|
96
|
+
# An optional string.
|
|
97
|
+
#
|
|
98
|
+
# @return [String, GREEN]
|
|
99
|
+
# The colorized string or just {GREEN} if no arguments were given.
|
|
100
|
+
#
|
|
101
|
+
def green(string=nil)
|
|
102
|
+
if string then "#{GREEN}#{string}#{RESET_FG}"
|
|
103
|
+
else GREEN
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
#
|
|
108
|
+
# Sets the text color to cyan.
|
|
109
|
+
#
|
|
110
|
+
# @param [String, nil] string
|
|
111
|
+
# An optional string.
|
|
112
|
+
#
|
|
113
|
+
# @return [String, CYAN]
|
|
114
|
+
# The colorized string or just {CYAN} if no arguments were given.
|
|
115
|
+
#
|
|
116
|
+
def cyan(string=nil)
|
|
117
|
+
if string then "#{CYAN}#{string}#{RESET_FG}"
|
|
118
|
+
else CYAN
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
#
|
|
124
|
+
# Dummy module with the same interface as {ANSI}, but for when ANSI is not
|
|
125
|
+
# supported.
|
|
126
|
+
#
|
|
127
|
+
module PlainText
|
|
128
|
+
ANSI.constants(false).each do |name|
|
|
129
|
+
const_set(name,'')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
module_function
|
|
133
|
+
|
|
134
|
+
[:bold, :red, :yellow, :green, :cyan].each do |name|
|
|
135
|
+
define_method(name) do |string=nil|
|
|
136
|
+
string || ''
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
module_function
|
|
142
|
+
|
|
143
|
+
#
|
|
144
|
+
# Checks if the stream supports ANSI output.
|
|
145
|
+
#
|
|
146
|
+
# @param [IO] stream
|
|
147
|
+
#
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
#
|
|
150
|
+
# @note
|
|
151
|
+
# When the env variable `TERM` is set to `dumb` or when the `NO_COLOR`
|
|
152
|
+
# env variable is set, it will disable color output. Color output will
|
|
153
|
+
# also be disabled if the given stream is not a TTY.
|
|
154
|
+
#
|
|
155
|
+
def ansi?(stream=$stdout)
|
|
156
|
+
ENV['TERM'] != 'dumb' && !ENV['NO_COLOR'] && stream.tty?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
#
|
|
160
|
+
# Returns the colors available for the given stream.
|
|
161
|
+
#
|
|
162
|
+
# @param [IO] stream
|
|
163
|
+
#
|
|
164
|
+
# @return [ANSI, PlainText]
|
|
165
|
+
# The ANSI module or PlainText dummy module.
|
|
166
|
+
#
|
|
167
|
+
# @example
|
|
168
|
+
# puts colors.green("Hello world")
|
|
169
|
+
#
|
|
170
|
+
# @example Using colors with stderr output:
|
|
171
|
+
# stderr.puts colors(stderr).green("Hello world")
|
|
172
|
+
#
|
|
173
|
+
def colors(stream=$stdout)
|
|
174
|
+
if ansi?(stream) then ANSI
|
|
175
|
+
else PlainText
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
data/lib/zone/field.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'field_line'
|
|
4
|
+
require_relative 'field_mapping'
|
|
5
|
+
|
|
6
|
+
module Zone
|
|
7
|
+
module Field
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def process(input, output, transformation, options, logger)
|
|
11
|
+
mapping, header_line = build_mapping(input, options)
|
|
12
|
+
|
|
13
|
+
# Output header line if present
|
|
14
|
+
output.puts(header_line) if header_line
|
|
15
|
+
|
|
16
|
+
input.each_line do |line|
|
|
17
|
+
process_line(line, input.skip_headers?, output, transformation, options, mapping, logger)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def process_line(line, skip, output, transformation, options, mapping, logger)
|
|
22
|
+
return if skip
|
|
23
|
+
|
|
24
|
+
field_line = FieldLine.parse(line, delimiter: options.delimiter, mapping: mapping, logger: logger)
|
|
25
|
+
|
|
26
|
+
# Check if field exists before transforming
|
|
27
|
+
original_value = field_line[options.field]
|
|
28
|
+
if original_value.nil?
|
|
29
|
+
logger.warn("Field '#{options.field}' not found or out of bounds in line: #{line}")
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Transform the field
|
|
34
|
+
field_line.transform(options.field, &transformation)
|
|
35
|
+
|
|
36
|
+
# Get transformed value
|
|
37
|
+
transformed_value = field_line[options.field]
|
|
38
|
+
if transformed_value.nil?
|
|
39
|
+
logger.warn("Could not parse timestamp in field '#{options.field}': #{original_value.inspect}")
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
output.puts_highlighted(field_line.to_s, highlight: transformed_value)
|
|
44
|
+
end
|
|
45
|
+
private_class_method :process_line
|
|
46
|
+
|
|
47
|
+
def build_mapping(input, options)
|
|
48
|
+
return [FieldMapping.numeric, nil] unless options.headers
|
|
49
|
+
|
|
50
|
+
input.mark_skip_headers!
|
|
51
|
+
header_line = input.each_line.first
|
|
52
|
+
|
|
53
|
+
parsed = FieldLine.parse_delimiter(options.delimiter)
|
|
54
|
+
fields = FieldLine.split_line(header_line, parsed)
|
|
55
|
+
|
|
56
|
+
# Format header line the same way as data lines
|
|
57
|
+
formatted_header = FieldLine.new(
|
|
58
|
+
fields: fields,
|
|
59
|
+
delimiter: parsed,
|
|
60
|
+
mapping: FieldMapping.numeric
|
|
61
|
+
).to_s
|
|
62
|
+
|
|
63
|
+
[FieldMapping.from_fields(fields.map(&:strip)), formatted_header]
|
|
64
|
+
end
|
|
65
|
+
private_class_method :build_mapping
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'field_mapping'
|
|
4
|
+
|
|
5
|
+
module Zone
|
|
6
|
+
class FieldLine
|
|
7
|
+
def self.parse(text, delimiter:, mapping: nil, logger: nil)
|
|
8
|
+
parsed_delimiter = parse_delimiter(delimiter)
|
|
9
|
+
|
|
10
|
+
fields = split_line(text, parsed_delimiter)
|
|
11
|
+
|
|
12
|
+
new(
|
|
13
|
+
fields: fields,
|
|
14
|
+
delimiter: parsed_delimiter,
|
|
15
|
+
mapping: mapping
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.parse_delimiter(delimiter_string)
|
|
20
|
+
case delimiter_string
|
|
21
|
+
in /^\/(.*)\/$/
|
|
22
|
+
# Regex delimiter wrapped in slashes: "/\s+/" -> /\s+/
|
|
23
|
+
Regexp.new($1)
|
|
24
|
+
in String => d
|
|
25
|
+
# String delimiter: "," -> ","
|
|
26
|
+
d
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError, "Invalid delimiter: #{delimiter_string.inspect}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.split_line(line, delimiter)
|
|
33
|
+
case [line, delimiter]
|
|
34
|
+
in [String, ""]
|
|
35
|
+
[line]
|
|
36
|
+
in [String, String | Regexp]
|
|
37
|
+
line.split(delimiter)
|
|
38
|
+
else
|
|
39
|
+
raise ArgumentError, "Invalid delimiter type: #{delimiter.class}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(fields:, delimiter:, mapping: nil)
|
|
44
|
+
@fields = fields.map(&:strip)
|
|
45
|
+
@delimiter = delimiter
|
|
46
|
+
@mapping = mapping || FieldMapping.numeric
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def [](key)
|
|
50
|
+
index = @mapping.resolve(key)
|
|
51
|
+
@fields[index]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def transform(key, &block)
|
|
55
|
+
index = @mapping.resolve(key)
|
|
56
|
+
@fields[index] = block.call(@fields[index])
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def transform_all(&block)
|
|
61
|
+
@fields.map!(&block)
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_s
|
|
66
|
+
output_delim = case @delimiter
|
|
67
|
+
when Regexp
|
|
68
|
+
"\t"
|
|
69
|
+
when ","
|
|
70
|
+
"\t"
|
|
71
|
+
else
|
|
72
|
+
@delimiter
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
case @fields.count
|
|
76
|
+
in 1
|
|
77
|
+
@fields[0].to_s
|
|
78
|
+
else
|
|
79
|
+
@fields.join(output_delim)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_a
|
|
84
|
+
@fields.dup
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_h
|
|
88
|
+
return {} unless @mapping.has_names?
|
|
89
|
+
|
|
90
|
+
@mapping.names.zip(@fields).to_h
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def fields
|
|
94
|
+
@fields.dup
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zone
|
|
4
|
+
class FieldMapping
|
|
5
|
+
def self.from_fields(fields)
|
|
6
|
+
name_to_index = fields.each_with_index.to_h
|
|
7
|
+
new(name_to_index: name_to_index)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.numeric
|
|
11
|
+
new(name_to_index: nil)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(name_to_index:)
|
|
15
|
+
@name_to_index = name_to_index
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolve(key)
|
|
19
|
+
case key
|
|
20
|
+
in String => name if name.match?(/^\d+$/)
|
|
21
|
+
# Numeric string - convert to integer and resolve
|
|
22
|
+
name.to_i - 1
|
|
23
|
+
in String => name
|
|
24
|
+
if @name_to_index
|
|
25
|
+
@name_to_index.fetch(name) do
|
|
26
|
+
raise KeyError, "Field '#{name}' not found in mapping"
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
raise KeyError, "Cannot access field by name without headers. Use --headers or numeric field index."
|
|
30
|
+
end
|
|
31
|
+
in Integer => index
|
|
32
|
+
# Convert 1-based user input to 0-based array index
|
|
33
|
+
index - 1
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError, "Key must be String or Integer, got #{key.class}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def [](key)
|
|
40
|
+
resolve(key)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def names
|
|
44
|
+
@name_to_index&.keys || []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def has_names?
|
|
48
|
+
!@name_to_index.nil?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/zone/input.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'timestamp_patterns'
|
|
4
|
+
|
|
5
|
+
module Zone
|
|
6
|
+
class Input
|
|
7
|
+
def initialize(argv, stdin: $stdin)
|
|
8
|
+
@argv = argv
|
|
9
|
+
@stdin = stdin
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def each_line(&block)
|
|
13
|
+
source.each(&block)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def skip_headers?
|
|
17
|
+
@skip_first ||= false
|
|
18
|
+
if @skip_first
|
|
19
|
+
@skip_first = false
|
|
20
|
+
true
|
|
21
|
+
else
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def mark_skip_headers!
|
|
27
|
+
@skip_first = true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def from_arguments?
|
|
31
|
+
@argv.any?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def source
|
|
37
|
+
@source ||= begin
|
|
38
|
+
if @argv.any?
|
|
39
|
+
# Arguments provided - use them as timestamps
|
|
40
|
+
@argv
|
|
41
|
+
elsif !@stdin.tty?
|
|
42
|
+
# No arguments but stdin is piped - read from stdin
|
|
43
|
+
# Convert to array so it can be iterated multiple times (e.g., for headers)
|
|
44
|
+
@stdin.each_line(chomp: true).to_a
|
|
45
|
+
else
|
|
46
|
+
# Interactive mode with no arguments - use current time
|
|
47
|
+
[Time.now.to_s]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/zone/logging.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require_relative 'colors'
|
|
5
|
+
|
|
6
|
+
module Zone
|
|
7
|
+
module Logging
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def build(verbose:)
|
|
11
|
+
Logger.new($stderr).tap do |l|
|
|
12
|
+
l.formatter = formatter
|
|
13
|
+
l.level = verbose ? Logger::DEBUG : Logger::WARN
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def formatter
|
|
18
|
+
->(severity, _datetime, _progname, message) {
|
|
19
|
+
prefix, color = log_style(severity)
|
|
20
|
+
formatted = "#{prefix} #{message}"
|
|
21
|
+
colored = color ? Colors.colors($stderr).send(color, formatted) : formatted
|
|
22
|
+
"#{colored}\n"
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
private_class_method :formatter
|
|
26
|
+
|
|
27
|
+
def log_style(severity)
|
|
28
|
+
case severity
|
|
29
|
+
in "INFO" then ["→", :cyan]
|
|
30
|
+
in "WARN" then ["⚠", :yellow]
|
|
31
|
+
in "ERROR" then ["✗", :red]
|
|
32
|
+
in "DEBUG" then ["DEBUG:", nil]
|
|
33
|
+
else ["?", nil]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
private_class_method :log_style
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/zone/options.rb
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Zone
|
|
6
|
+
class Options < OptionParser
|
|
7
|
+
attr_reader :field, :delimiter, :zone, :format, :color, :headers
|
|
8
|
+
attr_accessor :verbose
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
super
|
|
12
|
+
|
|
13
|
+
@field = nil
|
|
14
|
+
@delimiter = nil
|
|
15
|
+
@zone = 'local'
|
|
16
|
+
@format = { pretty: 1 }
|
|
17
|
+
@color = 'auto'
|
|
18
|
+
@headers = false
|
|
19
|
+
@verbose = false
|
|
20
|
+
|
|
21
|
+
setup_options
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
#
|
|
25
|
+
# Validate options and their combinations.
|
|
26
|
+
#
|
|
27
|
+
# @return [self]
|
|
28
|
+
# Returns self for method chaining
|
|
29
|
+
#
|
|
30
|
+
# @raise [ArgumentError]
|
|
31
|
+
# If validation fails
|
|
32
|
+
#
|
|
33
|
+
def validate!
|
|
34
|
+
validate_timezone!
|
|
35
|
+
validate_field_mode!
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def validate_timezone!
|
|
42
|
+
case @zone
|
|
43
|
+
in 'utc' | 'UTC' | 'local'
|
|
44
|
+
# Valid special timezone keywords
|
|
45
|
+
else
|
|
46
|
+
tz = Zone.find(@zone)
|
|
47
|
+
raise ArgumentError, "Could not find timezone '#{@zone}'" if tz.nil?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_field_mode!
|
|
52
|
+
if @field && !@delimiter
|
|
53
|
+
raise ArgumentError, "--field requires --delimiter\nExample: zone --field 2 --delimiter ','"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if @headers && !@field
|
|
57
|
+
raise ArgumentError, "--headers requires --field"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def setup_options
|
|
62
|
+
self.banner = "Usage: zone [options] [timestamps...]"
|
|
63
|
+
|
|
64
|
+
separator ""
|
|
65
|
+
separator "Modes:"
|
|
66
|
+
separator " Pattern Mode (default): Finds and converts timestamps in arbitrary text"
|
|
67
|
+
separator " Example: echo 'Event at 2025-01-15T10:30:00Z' | zone"
|
|
68
|
+
separator ""
|
|
69
|
+
separator " Field Mode: Converts specific field in delimited data (requires --field and --delimiter)"
|
|
70
|
+
separator " Example: echo 'alice,1736937000,active' | zone --field 2 --delimiter ','"
|
|
71
|
+
|
|
72
|
+
separator ""
|
|
73
|
+
separator "Output Formats:"
|
|
74
|
+
on '--iso8601', 'Output in ISO 8601' do
|
|
75
|
+
@format = :to_iso8601
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
on '-f', '--strftime FORMAT', 'Output format using strftime' do |fmt|
|
|
79
|
+
@format = { strftime: fmt }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
on '-p', '--pretty [STYLE]', Integer, 'Pretty format (1=12hr, 2=24hr, 3=ISO-compact, default: 1)' do |style|
|
|
83
|
+
style ||= 1
|
|
84
|
+
unless [1, 2, 3].include?(style)
|
|
85
|
+
raise ArgumentError, "Invalid pretty format -p#{style} (must be 1, 2, or 3)"
|
|
86
|
+
end
|
|
87
|
+
@format = { pretty: style }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
on '--unix', 'Output as Unix timestamp' do
|
|
91
|
+
@format = :to_unix
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
separator ""
|
|
95
|
+
separator "Timezones:"
|
|
96
|
+
on '--require STRING', 'Require external library like "active_support" for more powerful parsing' do |requirement|
|
|
97
|
+
require requirement
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
on '--zone TZ', '-z', 'Convert to time zone (default: local)' do |tz|
|
|
101
|
+
@zone = tz
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
on '--local', 'Convert to local time zone (alias for --zone local)' do
|
|
105
|
+
@zone = 'local'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
on '--utc', 'Convert to UTC time zone (alias for --zone UTC)' do
|
|
109
|
+
@zone = 'utc'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
separator ""
|
|
113
|
+
separator "Field Mode Options:"
|
|
114
|
+
on '--field FIELD', String, 'Field index or name to convert (requires --delimiter)' do |field|
|
|
115
|
+
@field = field
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
on '-d', '--delimiter PATTERN', 'Field separator (string or /regex/, required for --field)' do |delim|
|
|
119
|
+
@delimiter = delim
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
on '--headers', 'Skip first line as headers (requires --field)' do
|
|
123
|
+
@headers = true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
separator ""
|
|
127
|
+
separator "Other:"
|
|
128
|
+
on '--color MODE', ['auto', 'always', 'never'], 'Colorize output (auto, always, never, default: auto)' do |mode|
|
|
129
|
+
@color = mode
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
on '--verbose', '-v', 'Enable verbose/debug output' do
|
|
133
|
+
@verbose = true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
on '--help', '-h', 'Show this help message' do
|
|
137
|
+
puts self
|
|
138
|
+
exit
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/zone/output.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'colors'
|
|
4
|
+
|
|
5
|
+
module Zone
|
|
6
|
+
class Output
|
|
7
|
+
def initialize(color_mode: 'auto', stream: $stdout)
|
|
8
|
+
@stream = stream
|
|
9
|
+
@colors = colorize(color_mode)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def puts(text)
|
|
13
|
+
@stream.puts(text)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def puts_highlighted(text, highlight:)
|
|
17
|
+
highlight_str = highlight.to_s
|
|
18
|
+
output = text.sub(highlight_str, @colors.cyan(highlight_str))
|
|
19
|
+
@stream.puts(output)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def colorize_timestamp(timestamp)
|
|
23
|
+
@colors.cyan(timestamp)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def colorize(mode)
|
|
29
|
+
case mode
|
|
30
|
+
when 'always'
|
|
31
|
+
Colors::ANSI
|
|
32
|
+
when 'never'
|
|
33
|
+
Colors::PlainText
|
|
34
|
+
when 'auto'
|
|
35
|
+
Colors.colors(@stream)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|