zone 0.1.1 → 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.
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'timestamp_patterns'
4
+
5
+ module Zone
6
+ module Pattern
7
+ module_function
8
+
9
+ def process(input, output, transformation, logger)
10
+ input.each_line do |line|
11
+ process_line(line, input.from_arguments?, output, transformation, logger)
12
+ end
13
+ end
14
+
15
+ def process_line(line, from_arguments, output, transformation, logger)
16
+ case line
17
+ in ""
18
+ logger.warn("Could not parse time from empty line") if from_arguments
19
+ output.puts(line) unless from_arguments
20
+ else
21
+ result = replace_timestamps(line, output, transformation, logger)
22
+
23
+ case [result == line, from_arguments]
24
+ in [true, true]
25
+ parse_as_argument(line, output, transformation)
26
+ in [true, false]
27
+ output.puts(line)
28
+ in [false, _]
29
+ output.puts(result)
30
+ end
31
+ end
32
+ end
33
+ private_class_method :process_line
34
+
35
+ def replace_timestamps(line, output, transformation, logger)
36
+ TimestampPatterns.replace_all(line, logger: logger) do |match, _pattern|
37
+ transform_timestamp(match, output, transformation, logger)
38
+ end
39
+ end
40
+ private_class_method :replace_timestamps
41
+
42
+ def transform_timestamp(match, output, transformation, logger)
43
+ formatted = transformation.call(match)
44
+ output.colorize_timestamp(formatted)
45
+ rescue StandardError => e
46
+ logger.warn("Could not parse time: #{e.message}")
47
+ match
48
+ end
49
+ private_class_method :transform_timestamp
50
+
51
+ def parse_as_argument(text, output, transformation)
52
+ formatted = transformation.call(text)
53
+ raise ArgumentError, "Could not parse time '#{text}'" if formatted.nil?
54
+
55
+ output.puts(output.colorize_timestamp(formatted))
56
+ end
57
+ private_class_method :parse_as_argument
58
+ end
59
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'date'
5
+
6
+ module Zone
7
+ class Timestamp
8
+ attr_reader :time, :zone
9
+
10
+ def self.parse(input)
11
+ time = case input
12
+ in Time
13
+ input
14
+ in DateTime
15
+ input.to_time
16
+ in Date
17
+ input.to_time
18
+ in /^[0-9\.]+$/
19
+ parse_unix(input)
20
+ in /^(?<amount>[0-9\.]+) (?<unit>second|minute|hour|day|week|month|year|decade)s? (?<direction>ago|from now)$/
21
+ parse_relative($~)
22
+ in /^(?<dow>[A-Z][a-z]{2}) (?<mon>[A-Z][a-z]{2}) (?<day>\d{1,2}) (?<time>\d{2}:\d{2}:\d{2}) (?<year>\d{4}) (?<offset>[+-]\d{4})$/
23
+ # Git log format: "Fri Nov 14 14:54:35 2025 -0500" or "Wed Nov 5 11:24:19 2025 -0500"
24
+ # Reorder to: "Fri Nov 14 14:54:35 -0500 2025" for DateTime.parse
25
+ parse_git_log($~)
26
+ else
27
+ DateTime.parse(input).to_time
28
+ end
29
+
30
+ new(time)
31
+ rescue StandardError
32
+ raise ArgumentError, "Could not parse time '#{input}'"
33
+ end
34
+
35
+ def initialize(time, zone: nil)
36
+ @time = time
37
+ @zone = zone
38
+ end
39
+
40
+ def in_zone(zone_name)
41
+ tz = Zone.find(zone_name)
42
+
43
+ raise ArgumentError, "Could not find timezone '#{zone_name}'" if tz.nil?
44
+
45
+ converted = tz.to_local(@time)
46
+
47
+ self.class.new(
48
+ converted,
49
+ zone: zone_name
50
+ )
51
+ end
52
+
53
+ def in_utc
54
+ self.class.new(
55
+ @time.utc,
56
+ zone: 'UTC'
57
+ )
58
+ end
59
+
60
+ def in_local
61
+ self.class.new(
62
+ @time.localtime,
63
+ zone: 'local'
64
+ )
65
+ end
66
+
67
+ def to_iso8601
68
+ @time.utc_offset.zero? ? @time.strftime('%Y-%m-%dT%H:%M:%SZ') : @time.iso8601
69
+ end
70
+
71
+ def to_unix
72
+ @time.to_i
73
+ end
74
+
75
+ def to_pretty(style = 1)
76
+ case style
77
+ when 1
78
+ @time.strftime('%b %d, %Y - %l:%M %p %Z')
79
+ when 2
80
+ @time.strftime('%b %d, %Y - %H:%M %Z')
81
+ when 3
82
+ @time.strftime('%Y-%m-%d %H:%M %Z')
83
+ else
84
+ raise ArgumentError, "Invalid pretty style '#{style}' (must be 1, 2, or 3)"
85
+ end
86
+ end
87
+
88
+ def strftime(format)
89
+ @time.strftime(format)
90
+ end
91
+
92
+ private
93
+
94
+ def self.parse_unix(str)
95
+ precision = str.length - 10
96
+ time_float = str.to_f / 10**precision
97
+ Time.at(time_float)
98
+ end
99
+
100
+ def self.parse_relative(match_data)
101
+ match_data => { amount:, unit:, direction: }
102
+
103
+ seconds = case unit
104
+ in 'second'
105
+ amount.to_i
106
+ in 'minute'
107
+ amount.to_i * 60
108
+ in 'hour'
109
+ amount.to_i * 3600
110
+ in 'day'
111
+ amount.to_i * 86400
112
+ in 'week'
113
+ amount.to_i * 604800
114
+ in 'month'
115
+ amount.to_i * 2592000
116
+ in 'year'
117
+ amount.to_i * 31536000
118
+ in 'decade'
119
+ amount.to_i * 315360000
120
+ end
121
+
122
+ case direction
123
+ in 'ago'
124
+ Time.now - seconds
125
+ in 'from now'
126
+ Time.now + seconds
127
+ end
128
+ end
129
+
130
+ def self.parse_git_log(match_data)
131
+ # Git log format: "Fri Nov 14 14:54:35 2025 -0500"
132
+ # Reorder to parseable format: "Fri Nov 14 14:54:35 -0500 2025"
133
+ match_data => { dow:, mon:, day:, time:, year:, offset: }
134
+ reordered = "#{dow} #{mon} #{day} #{time} #{offset} #{year}"
135
+ DateTime.parse(reordered).to_time
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zone
4
+ #
5
+ # Pattern matching for timestamps in arbitrary text.
6
+ #
7
+ # Provides regex patterns and utilities for finding and replacing
8
+ # timestamps embedded in unstructured text.
9
+ #
10
+ module TimestampPatterns
11
+ # Patterns are prefixed with P## to define priority order (most specific first).
12
+ # To add a new pattern, simply define a new Regexp constant with appropriate priority number.
13
+ # It will be automatically included in pattern matching.
14
+
15
+ # ISO 8601 with timezone offset (e.g., 2025-01-15T10:30:00+09:00)
16
+ P01_ISO8601_WITH_TZ = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}\b/
17
+
18
+ # ISO 8601 with Zulu (UTC) timezone (e.g., 2025-01-15T10:30:00Z)
19
+ P02_ISO8601_ZULU = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/
20
+
21
+ # ISO 8601 with space separator and offset (e.g., 2025-01-15 10:30:00 -0500) - Ruby Time.now.to_s format
22
+ P03_ISO8601_SPACE_WITH_OFFSET = /\b\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)? [+-]\d{4}\b/
23
+
24
+ # ISO 8601 with space separator, no timezone (e.g., 2025-01-15 10:30:00) - common SQL/database format
25
+ # Use negative lookahead to ensure not followed by timezone offset
26
+ P04_ISO8601_SPACE = /\b\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?(?! [+-]\d)/
27
+
28
+ # Zone pretty1 format: 12hr with AM/PM (e.g., "Jan 15, 2025 - 10:30 AM UTC" or "Jan 15, 2025 - 1:30 AM UTC")
29
+ P05_PRETTY1_12HR = /\b[A-Z][a-z]{2} \d{2}, \d{4} - \s?\d{1,2}:\d{2} [AP]M [A-Z]{3,4}\b/
30
+
31
+ # Zone pretty2 format: 24hr without AM/PM (e.g., "Jan 15, 2025 - 10:30 UTC")
32
+ P06_PRETTY2_24HR = /\b[A-Z][a-z]{2} \d{2}, \d{4} - \d{2}:\d{2} [A-Z]{3,4}\b/
33
+
34
+ # Zone pretty3 format: ISO-style compact (e.g., "2025-01-15 10:30 UTC")
35
+ P07_PRETTY3_ISO = /\b\d{4}-\d{2}-\d{2} \d{2}:\d{2} [A-Z]{3,4}\b/
36
+
37
+ # Unix timestamp (10 digits, 2001-2036, e.g., 1736937000)
38
+ # Matches timestamps starting with 1 (2001-2033) or 20-21 (2033-2039)
39
+ # Avoids false positives from phone numbers, order IDs, hex strings (commit hashes), etc.
40
+ # Uses hex boundary check to prevent matching within commit hashes
41
+ P08_UNIX_TIMESTAMP = /(?<![0-9a-fA-F])(?:1\d{9}|2[0-1]\d{8})(?![0-9a-fA-F])/
42
+
43
+ # Relative time expressions (e.g., "5 hours ago", "3 days from now")
44
+ P09_RELATIVE_TIME = /\b\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+(?:ago|from now)\b/i
45
+
46
+ # Git log format (e.g., "Fri Nov 14 23:48:24 2025 +0000", "Wed Nov 5 11:24:19 2025 -0500")
47
+ P10_GIT_LOG = /\b[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}:\d{2} \d{4} [+-]\d{4}\b/
48
+
49
+ # Date command output format (e.g., "Wed Nov 12 19:13:17 UTC 2025")
50
+ P11_DATE_COMMAND = /\b[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}:\d{2} [A-Z]{3,4} \d{4}\b/
51
+
52
+ module_function
53
+
54
+ #
55
+ # Returns all timestamp patterns in priority order.
56
+ #
57
+ # @return [Array<Regexp>]
58
+ # All Regexp constants defined in this module, sorted by P## prefix
59
+ #
60
+ def patterns
61
+ constants
62
+ .select { |c| c.to_s.match?(/^P\d+_/) }
63
+ .sort_by { |c| c.to_s[/^P(\d+)_/, 1].to_i }
64
+ .map { |c| const_get(c) }
65
+ end
66
+
67
+ #
68
+ # Check if text contains any timestamp patterns.
69
+ #
70
+ # @param [String] text
71
+ # The text to check
72
+ #
73
+ # @return [Boolean]
74
+ # true if text matches any timestamp pattern
75
+ #
76
+ def match?(text)
77
+ patterns.any? { |pattern| pattern.match?(text) }
78
+ end
79
+
80
+ #
81
+ # Replace all timestamp patterns in text.
82
+ #
83
+ # @param [String] text
84
+ # The text to search for timestamps
85
+ #
86
+ # @param [Logger, nil] logger
87
+ # Optional logger for debug output
88
+ #
89
+ # @yield [match, pattern]
90
+ # Block receives each matched timestamp string and its pattern
91
+ #
92
+ # @yieldparam [String] match
93
+ # The matched timestamp string
94
+ #
95
+ # @yieldparam [Regexp] pattern
96
+ # The pattern that matched
97
+ #
98
+ # @yieldreturn [String]
99
+ # The replacement string
100
+ #
101
+ # @return [String]
102
+ # Text with all timestamps replaced
103
+ #
104
+ # @example
105
+ # text = "Logged in at 2025-01-15T10:30:00Z"
106
+ # result = replace_all(text) do |match, pattern|
107
+ # timestamp = Timestamp.parse(match)
108
+ # timestamp.to_pretty
109
+ # end
110
+ # # => "Logged in at Jan 15, 2025 - 10:30 AM UTC"
111
+ #
112
+ def replace_all(text, logger: nil)
113
+ result = text.dup
114
+ matches = 0
115
+
116
+ patterns.each do |pattern|
117
+ result.gsub!(pattern) do |match|
118
+ next match unless valid_timestamp?(match, pattern)
119
+
120
+ matches += 1
121
+
122
+ begin
123
+ yield(match, pattern)
124
+ rescue StandardError => e
125
+ logger&.debug("Failed to transform '#{match}': #{e.message}")
126
+ match # Keep original if transformation fails
127
+ end
128
+ end
129
+ end
130
+
131
+ logger&.debug("Matched #{matches} timestamp(s)") if logger && matches > 0
132
+
133
+ result
134
+ end
135
+
136
+ #
137
+ # Validate that a matched string is actually a timestamp.
138
+ #
139
+ # @param [String] str
140
+ # The matched string
141
+ #
142
+ # @param [Regexp] pattern
143
+ # The pattern that matched
144
+ #
145
+ # @return [Boolean]
146
+ # true if valid timestamp
147
+ #
148
+ def valid_timestamp?(str, pattern)
149
+ return valid_unix?(str) if pattern == P08_UNIX_TIMESTAMP
150
+ true
151
+ end
152
+
153
+ #
154
+ # Validate unix timestamp is in reasonable range.
155
+ #
156
+ # @param [String] str
157
+ # The matched string
158
+ #
159
+ # @return [Boolean]
160
+ # true if valid unix timestamp (2001-2036)
161
+ #
162
+ def valid_unix?(str)
163
+ int = str.to_i
164
+ # Range: 2001-09-09 (first 10-digit) to 2036-07-11 (11 years ahead)
165
+ # This avoids false positives from phone numbers, order IDs, etc.
166
+ int >= 1_000_000_000 && int <= 2_100_000_000
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'timestamp'
4
+
5
+ module Zone
6
+ module Transform
7
+ module_function
8
+
9
+ #
10
+ # Build a transformation lambda.
11
+ #
12
+ # @param [String, Symbol] zone
13
+ # Zone name to convert to
14
+ #
15
+ # @param [Symbol, Hash] format
16
+ # Format specification (:to_iso8601, :to_unix, {pretty: 1}, {strftime: "..."})
17
+ #
18
+ # @return [Proc]
19
+ # Lambda that transforms a timestamp string
20
+ #
21
+ def build(zone:, format:)
22
+ ->(value) do
23
+ timestamp = Timestamp.parse(value)
24
+ converted = convert_zone(timestamp, zone)
25
+ format_timestamp(converted, format)
26
+ rescue ArgumentError
27
+ nil
28
+ end
29
+ end
30
+
31
+ #
32
+ # Convert timestamp to specified zone.
33
+ #
34
+ # @param [Timestamp] timestamp
35
+ # @param [String, Symbol] zone_name
36
+ #
37
+ # @return [Timestamp]
38
+ #
39
+ def convert_zone(timestamp, zone_name)
40
+ case zone_name
41
+ in 'utc' | 'UTC'
42
+ timestamp.in_utc
43
+ in 'local'
44
+ timestamp.in_local
45
+ else
46
+ timestamp.in_zone(zone_name)
47
+ end
48
+ end
49
+
50
+ #
51
+ # Format timestamp according to format specification.
52
+ #
53
+ # @param [Timestamp] timestamp
54
+ # @param [Symbol, Hash] format_spec
55
+ #
56
+ # @return [String, Integer]
57
+ #
58
+ def format_timestamp(timestamp, format_spec)
59
+ case format_spec
60
+ in :to_iso8601 | :to_unix
61
+ timestamp.send(format_spec)
62
+ in { pretty: Integer => style }
63
+ timestamp.to_pretty(style)
64
+ in { strftime: String => fmt }
65
+ timestamp.strftime(fmt)
66
+ end
67
+ end
68
+ end
69
+ end
data/lib/zone/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zone
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/zone.rb CHANGED
@@ -1,8 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'tzinfo'
3
4
  require_relative "zone/version"
5
+ require_relative "zone/field_mapping"
6
+ require_relative "zone/field_line"
7
+ require_relative "zone/timestamp"
8
+ require_relative "zone/cli"
4
9
 
5
10
  module Zone
6
11
  class Error < StandardError; end
7
- # Your code goes here...
12
+
13
+ def self.find(keyword)
14
+ TZInfo::Timezone.get(keyword)
15
+ rescue TZInfo::InvalidTimezoneIdentifier
16
+ search_fuzzy(keyword)
17
+ end
18
+
19
+ def self.search_fuzzy(keyword)
20
+ all_zones = TZInfo::Timezone.all_identifiers
21
+
22
+ # Normalize keyword: replace spaces with wildcards for matching
23
+ normalized_keyword = keyword.gsub(/\s+/, '.*')
24
+
25
+ # Try US wildcard pattern first
26
+ us_pattern = Regexp.new(
27
+ normalized_keyword.gsub(/^(?:US)?\/?/, 'US/').gsub(/$/,'.*'),
28
+ Regexp::IGNORECASE
29
+ )
30
+
31
+ # Then try global pattern
32
+ global_pattern = Regexp.new(
33
+ ".*#{normalized_keyword}.*",
34
+ Regexp::IGNORECASE
35
+ )
36
+
37
+ found = case all_zones
38
+ in [*, ^(us_pattern) => zone, *]
39
+ zone
40
+ in [*, ^(global_pattern) => zone, *]
41
+ zone
42
+ else
43
+ nil
44
+ end
45
+
46
+ return nil unless found
47
+
48
+ TZInfo::Timezone.get(found)
49
+ end
50
+
51
+ private_class_method :search_fuzzy
8
52
  end
data/todo.md ADDED
@@ -0,0 +1,85 @@
1
+ ## Rules
2
+ 1. VERY IMPORTANT .Before using ANY Ruby class, method, builtin, expression, you are to read the latest docs on it. This can be found by executing `ri --no-pager <subject>`. For example:
3
+
4
+ ```
5
+ ri --no-pager 'Array#zip'
6
+ ```
7
+
8
+ Additionally, more comprehensive documentation can be found by accessing these documents, using the syntax:
9
+
10
+ ```
11
+ ri --no-pager 'syntax/pattern_matching:'
12
+ ```
13
+ which renders the document syntax/pattern_matching.rdoc. All other advanced documentation can be found below:
14
+
15
+
16
+ bsearch.rdoc
17
+ bug_triaging.rdoc
18
+ case_mapping.rdoc
19
+ character_selectors.rdoc
20
+ command_injection.rdoc
21
+ contributing.md
22
+ contributing/building_ruby.md
23
+ contributing/documentation_guide.md
24
+ contributing/glossary.md
25
+ contributing/making_changes_to_ruby.md
26
+ contributing/making_changes_to_stdlibs.md
27
+ contributing/reporting_issues.md
28
+ contributing/testing_ruby.md
29
+ date/calendars.rdoc
30
+ dig_methods.rdoc
31
+ distribution.md
32
+ dtrace_probes.rdoc
33
+ encodings.rdoc
34
+ exceptions.md
35
+ extension.ja.rdoc
36
+ extension.rdoc
37
+ fiber.md
38
+ format_specifications.rdoc
39
+ globals.rdoc
40
+ implicit_conversion.rdoc
41
+ index.md
42
+ maintainers.md
43
+ marshal.rdoc
44
+ memory_view.md
45
+ optparse/argument_converters.rdoc
46
+ optparse/creates_option.rdoc
47
+ optparse/option_params.rdoc
48
+ optparse/tutorial.rdoc
49
+ packed_data.rdoc
50
+ ractor.md
51
+ regexp/methods.rdoc
52
+ regexp/unicode_properties.rdoc
53
+ rjit/rjit.md
54
+ ruby/option_dump.md
55
+ ruby/options.md
56
+ security.rdoc
57
+ signals.rdoc
58
+ standard_library.md
59
+ strftime_formatting.rdoc
60
+ syntax.rdoc
61
+ syntax/assignment.rdoc
62
+ syntax/calling_methods.rdoc
63
+ syntax/comments.rdoc
64
+ syntax/control_expressions.rdoc
65
+ syntax/exceptions.rdoc
66
+ syntax/keywords.rdoc
67
+ syntax/literals.rdoc
68
+ syntax/methods.rdoc
69
+ syntax/miscellaneous.rdoc
70
+ syntax/modules_and_classes.rdoc
71
+ syntax/operators.rdoc
72
+ syntax/pattern_matching.rdoc
73
+ syntax/precedence.rdoc
74
+ syntax/refinements.rdoc
75
+ windows.md
76
+ yjit/yjit.md
77
+
78
+
79
+ You must do this for EVERY method, class, etc when you use it in this session for the first time
80
+
81
+ 2. Prefer "in" to "when". See `ri --no-pager syntax/pattern_matching:`
82
+
83
+ 3. When using OptionParser, prefer the implict, blockless declarations that utilize `OptionParser#parse!(into: options)`
84
+
85
+ 4. Prefer a functional chaining style when doing consecutive operations on enumerables
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zone
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Gillis
@@ -76,9 +76,27 @@ files:
76
76
  - LICENSE.txt
77
77
  - README.md
78
78
  - Rakefile
79
+ - TEST_PLAN.md
80
+ - completions/README.md
81
+ - completions/_zone
82
+ - docs/user-experience-review.md
79
83
  - exe/zone
80
84
  - lib/zone.rb
85
+ - lib/zone/cli.rb
86
+ - lib/zone/colors.rb
87
+ - lib/zone/field.rb
88
+ - lib/zone/field_line.rb
89
+ - lib/zone/field_mapping.rb
90
+ - lib/zone/input.rb
91
+ - lib/zone/logging.rb
92
+ - lib/zone/options.rb
93
+ - lib/zone/output.rb
94
+ - lib/zone/pattern.rb
95
+ - lib/zone/timestamp.rb
96
+ - lib/zone/timestamp_patterns.rb
97
+ - lib/zone/transform.rb
81
98
  - lib/zone/version.rb
99
+ - todo.md
82
100
  homepage: https://github.com/gillisd/zone
83
101
  licenses:
84
102
  - MIT