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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Zsh Completion for Zone
|
|
2
|
+
|
|
3
|
+
Intelligent command-line completion for the `zone` timezone conversion tool.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Completes all command-line flags and options
|
|
8
|
+
- Suggests common timezone names (UTC, local, America/New_York, etc.)
|
|
9
|
+
- Provides example timestamp formats
|
|
10
|
+
- Handles mutually exclusive options (e.g., --iso8601 vs --unix)
|
|
11
|
+
- Context-aware completions for option values
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
### Method 1: User-specific installation
|
|
16
|
+
|
|
17
|
+
1. Create a completions directory if it doesn't exist:
|
|
18
|
+
```bash
|
|
19
|
+
mkdir -p ~/.zsh/completions
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
2. Copy the completion script:
|
|
23
|
+
```bash
|
|
24
|
+
cp completions/_zone ~/.zsh/completions/
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
3. Add to your `~/.zshrc` (before `compinit`):
|
|
28
|
+
```bash
|
|
29
|
+
fpath=(~/.zsh/completions $fpath)
|
|
30
|
+
autoload -U compinit && compinit
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
4. Reload your shell:
|
|
34
|
+
```bash
|
|
35
|
+
exec zsh
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Method 2: System-wide installation
|
|
39
|
+
|
|
40
|
+
1. Copy to the system completions directory:
|
|
41
|
+
```bash
|
|
42
|
+
sudo cp completions/_zone /usr/local/share/zsh/site-functions/
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
2. Rebuild completion cache:
|
|
46
|
+
```bash
|
|
47
|
+
rm -f ~/.zcompdump && compinit
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Method 3: Oh My Zsh
|
|
51
|
+
|
|
52
|
+
1. Copy to Oh My Zsh completions:
|
|
53
|
+
```bash
|
|
54
|
+
cp completions/_zone ~/.oh-my-zsh/completions/
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
2. Reload:
|
|
58
|
+
```bash
|
|
59
|
+
exec zsh
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
Once installed, press `<Tab>` after typing `zone` to see available completions:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
zone <Tab> # Shows all options
|
|
68
|
+
zone --zone <Tab> # Shows timezone suggestions
|
|
69
|
+
zone --strftime <Tab> # Prompts for strftime format
|
|
70
|
+
zone 2025 <Tab> # Shows timestamp format examples
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Examples
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Complete timezone names
|
|
77
|
+
zone "$(date)" --zone <Tab>
|
|
78
|
+
# Suggests: UTC, local, America/New_York, Europe/London, Asia/Tokyo, etc.
|
|
79
|
+
|
|
80
|
+
# Complete output formats
|
|
81
|
+
zone "now" --<Tab>
|
|
82
|
+
# Shows: --iso8601, --pretty, --unix, --strftime, etc.
|
|
83
|
+
|
|
84
|
+
# Mutually exclusive options
|
|
85
|
+
zone "now" --unix --<Tab>
|
|
86
|
+
# Won't suggest --iso8601, --pretty, or --strftime (incompatible)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Testing
|
|
90
|
+
|
|
91
|
+
Verify the completion is loaded:
|
|
92
|
+
```bash
|
|
93
|
+
which _zone
|
|
94
|
+
# Should output the path to the completion function
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Test syntax:
|
|
98
|
+
```bash
|
|
99
|
+
zsh -n completions/_zone
|
|
100
|
+
# Should output nothing (syntax valid)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Troubleshooting
|
|
104
|
+
|
|
105
|
+
**Completions not working:**
|
|
106
|
+
1. Ensure `fpath` includes your completions directory:
|
|
107
|
+
```bash
|
|
108
|
+
echo $fpath
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
2. Verify the completion function is loaded:
|
|
112
|
+
```bash
|
|
113
|
+
which _zone
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
3. Rebuild completion cache:
|
|
117
|
+
```bash
|
|
118
|
+
rm -f ~/.zcompdump && exec zsh
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Completions are outdated:**
|
|
122
|
+
```bash
|
|
123
|
+
# Force rebuild
|
|
124
|
+
rm -f ~/.zcompdump*
|
|
125
|
+
compinit
|
|
126
|
+
```
|
data/completions/_zone
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#compdef zone
|
|
2
|
+
|
|
3
|
+
# Zsh completion script for zone
|
|
4
|
+
# https://github.com/anthropics/zone
|
|
5
|
+
|
|
6
|
+
_zone() {
|
|
7
|
+
local context state state_descr line
|
|
8
|
+
typeset -A opt_args
|
|
9
|
+
|
|
10
|
+
local -a output_formats timezones_opts data_opts other_opts
|
|
11
|
+
|
|
12
|
+
output_formats=(
|
|
13
|
+
'(--pretty --unix --strftime)--iso8601[Output in ISO 8601 format (default)]'
|
|
14
|
+
'(--iso8601 --unix --strftime)-p[Output in pretty format]'
|
|
15
|
+
'(--iso8601 --unix --strftime)--pretty[Output in pretty format (e.g., "Jan 02 - 03:04 PM")]'
|
|
16
|
+
'(--iso8601 --pretty --strftime)--unix[Output as Unix timestamp]'
|
|
17
|
+
'(--iso8601 --pretty --unix)-f[Output format using strftime]:format string'
|
|
18
|
+
'(--iso8601 --pretty --unix)--strftime[Output format using strftime]:format string'
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
timezones_opts=(
|
|
22
|
+
'--require[Require external library]:library name'
|
|
23
|
+
'(-z --utc --local)-z[Convert to specified timezone]:timezone:_zone_timezones'
|
|
24
|
+
'(-z --utc --local)--zone[Convert to specified timezone]:timezone:_zone_timezones'
|
|
25
|
+
'(-z --zone --local)--utc[Convert to UTC timezone]'
|
|
26
|
+
'(-z --zone --utc)--local[Convert to local timezone]'
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
data_opts=(
|
|
30
|
+
'(-F)-F[Field index or field name]:field:(1 2 3 4 5)'
|
|
31
|
+
'(-F)--field[Field index or field name]:field:(1 2 3 4 5)'
|
|
32
|
+
'(-d)-d[Field delimiter]:delimiter'
|
|
33
|
+
'(-d)--delimiter[Field delimiter]:delimiter'
|
|
34
|
+
'--headers[Treat first line as headers]'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
other_opts=(
|
|
38
|
+
'(-v)'{-v,--verbose}'[Enable verbose/debug output]'
|
|
39
|
+
'(-h)'{-h,--help}'[Show help message]'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_arguments -C -s \
|
|
43
|
+
$output_formats \
|
|
44
|
+
$timezones_opts \
|
|
45
|
+
$data_opts \
|
|
46
|
+
$other_opts \
|
|
47
|
+
'*:timestamp string:_zone_timestamp'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_zone_timezones() {
|
|
51
|
+
local -a timezones
|
|
52
|
+
|
|
53
|
+
# Common timezones for quick completion
|
|
54
|
+
timezones=(
|
|
55
|
+
'UTC:Coordinated Universal Time'
|
|
56
|
+
'local:Local system timezone'
|
|
57
|
+
'America/New_York:Eastern Time'
|
|
58
|
+
'America/Chicago:Central Time'
|
|
59
|
+
'America/Denver:Mountain Time'
|
|
60
|
+
'America/Los_Angeles:Pacific Time'
|
|
61
|
+
'Europe/London:British Time'
|
|
62
|
+
'Europe/Paris:Central European Time'
|
|
63
|
+
'Asia/Tokyo:Japan Standard Time'
|
|
64
|
+
'Asia/Shanghai:China Standard Time'
|
|
65
|
+
'Australia/Sydney:Australian Eastern Time'
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_describe -t timezones 'timezone' timezones
|
|
69
|
+
|
|
70
|
+
# Also allow arbitrary timezone input
|
|
71
|
+
_message -r 'timezone name or fuzzy match'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_zone_timestamp() {
|
|
75
|
+
# Suggest common timestamp formats
|
|
76
|
+
local -a formats
|
|
77
|
+
formats=(
|
|
78
|
+
'now:Current time'
|
|
79
|
+
'$(date):Current date/time from date command'
|
|
80
|
+
'2025-01-15T10:30:00Z:ISO 8601 format'
|
|
81
|
+
'1736937000:Unix timestamp (seconds)'
|
|
82
|
+
'"5 minutes ago":Relative time'
|
|
83
|
+
'"1 hour from now":Relative future time'
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
_describe -t formats 'timestamp format' formats
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_zone "$@"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# User Experience Review of Zone
|
|
2
|
+
|
|
3
|
+
## My Thoughts on Zone as a User
|
|
4
|
+
|
|
5
|
+
### What Zone Does Really Well
|
|
6
|
+
|
|
7
|
+
**1. Solves a Real Pain Point**
|
|
8
|
+
Timezone conversion is genuinely annoying. Looking at `2025-01-15T10:30:00Z` in logs and mentally calculating "what time was that for me?" is cognitive overhead. Zone eliminates that instantly. The name is perfect - simple, memorable, and describes exactly what it does.
|
|
9
|
+
|
|
10
|
+
**2. The Dual-Mode Architecture is Brilliant**
|
|
11
|
+
The pattern/field mode split is exactly right:
|
|
12
|
+
- **Pattern mode** feels like `bat` for timestamps - magical and effortless. Just pipe text through and timestamps become readable. No configuration needed.
|
|
13
|
+
- **Field mode** handles the structured data case without polluting the simple case with complexity.
|
|
14
|
+
|
|
15
|
+
Requiring explicit `--field` AND `--delimiter` was the right call. Auto-detection adds complexity and surprising behavior. Explicit is better.
|
|
16
|
+
|
|
17
|
+
**3. The New Defaults are Perfect**
|
|
18
|
+
Before: `zone "2025-01-15T10:30:00Z"` → `2025-01-15T10:30:00Z` (no visible change)
|
|
19
|
+
After: `zone "2025-01-15T10:30:00Z"` → `Jan 15, 2025 - 5:30 AM EST` (immediate value)
|
|
20
|
+
|
|
21
|
+
This is *excellent* UX design. New users see the tool working immediately. The "principle of least surprise" - of course I want my local time, that's why I'm using a timezone tool!
|
|
22
|
+
|
|
23
|
+
**4. Idempotency is Underrated**
|
|
24
|
+
Being able to pipe zone output back through zone is surprisingly powerful. It means zone becomes composable - you can chain transformations without worrying about format compatibility. This is Unix philosophy done right.
|
|
25
|
+
|
|
26
|
+
**5. The Three Pretty Formats are Well-Chosen**
|
|
27
|
+
- `-p 1`: North American default, maximum readability
|
|
28
|
+
- `-p 2`: International/technical users, 24-hour
|
|
29
|
+
- `-p 3`: Sortable, machine-friendly but still readable
|
|
30
|
+
|
|
31
|
+
The progression makes sense and covers real use cases without format proliferation.
|
|
32
|
+
|
|
33
|
+
### What Could Be Even Better
|
|
34
|
+
|
|
35
|
+
**1. Discovery of Pretty Format Variants**
|
|
36
|
+
Running `zone --help` shows:
|
|
37
|
+
```
|
|
38
|
+
-p, --pretty [STYLE] Pretty format (1=12hr, 2=24hr, 3=ISO-compact, default: 1)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This is compact but not discoverable. A user has to know to try `-p 2` to see what it looks like. Could consider:
|
|
42
|
+
- Showing example output in `--help` for each format
|
|
43
|
+
- Or: `zone --formats` command that prints all format examples
|
|
44
|
+
|
|
45
|
+
**2. Relative Time Parsing Feels Incomplete**
|
|
46
|
+
Zone parses "5 hours ago" but the pattern doesn't match common formats like:
|
|
47
|
+
- "2 hours ago" (GitHub, Twitter, etc.)
|
|
48
|
+
- "in 3 days" (Google Calendar)
|
|
49
|
+
- "yesterday" / "tomorrow"
|
|
50
|
+
|
|
51
|
+
Either commit fully to natural language parsing (integrate with `chronic` gem?) or remove it entirely. Half-done features are confusing.
|
|
52
|
+
|
|
53
|
+
**3. Color Choice is Good but Could Be Configurable**
|
|
54
|
+
Cyan for timestamps is reasonable, but some users might want:
|
|
55
|
+
- Different colors for different timezones (red=past, green=future)
|
|
56
|
+
- Bold instead of color
|
|
57
|
+
- User-configurable via env var
|
|
58
|
+
|
|
59
|
+
Not critical, but would be nice for power users.
|
|
60
|
+
|
|
61
|
+
**4. Error Messages Could Be More Helpful**
|
|
62
|
+
```bash
|
|
63
|
+
$ echo "invalid" | zone --field 2 --delimiter ','
|
|
64
|
+
Error: Could not parse time 'invalid'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Could suggest: "Does not look like a timestamp. Expected formats: ISO8601, Unix timestamp, etc."
|
|
68
|
+
|
|
69
|
+
**5. The Name "Pretty" Format**
|
|
70
|
+
Calling it "pretty" format is subjective. Some users might think ISO8601 is "pretty" (it's certainly more standardized). Consider:
|
|
71
|
+
- "human" format (human-readable)
|
|
72
|
+
- "readable" format
|
|
73
|
+
- Just "format 1/2/3" without the "pretty" label
|
|
74
|
+
|
|
75
|
+
Minor bikeshedding, but naming matters.
|
|
76
|
+
|
|
77
|
+
### Outstanding Design Decisions
|
|
78
|
+
|
|
79
|
+
**1. No Magic, Clear Contracts**
|
|
80
|
+
- Field mode requires explicit delimiter ✓
|
|
81
|
+
- Pattern mode is clearly the default ✓
|
|
82
|
+
- Flags do one thing ✓
|
|
83
|
+
|
|
84
|
+
No surprises, no hidden behavior.
|
|
85
|
+
|
|
86
|
+
**2. Fuzzy Timezone Matching**
|
|
87
|
+
```bash
|
|
88
|
+
zone --zone tokyo # Just works
|
|
89
|
+
zone --zone pacific # Just works
|
|
90
|
+
zone --zone "new york" # Just works
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This is *delightful*. Makes the tool feel intelligent without being magical.
|
|
94
|
+
|
|
95
|
+
**3. No External Dependencies (Base)**
|
|
96
|
+
Pure Ruby stdlib (until you `--require active_support`). Fast startup, easy installation, no surprises.
|
|
97
|
+
|
|
98
|
+
**4. The Pattern Numbering System (P01_, P02_)**
|
|
99
|
+
This is clever engineering:
|
|
100
|
+
- Easy to add new patterns (just add P08_)
|
|
101
|
+
- Priority is explicit and visible
|
|
102
|
+
- Self-documenting code
|
|
103
|
+
|
|
104
|
+
Really nice.
|
|
105
|
+
|
|
106
|
+
### Use Cases Where Zone Shines
|
|
107
|
+
|
|
108
|
+
1. **Log Analysis**: `tail -f app.log | zone` - instant readability
|
|
109
|
+
2. **API Development**: Converting timestamps in JSON responses during debugging
|
|
110
|
+
3. **Data Migration**: Converting CSV/TSV timestamp columns
|
|
111
|
+
4. **Slack/IRC Bots**: Processing messages with embedded timestamps
|
|
112
|
+
5. **International Teams**: Converting meeting times across timezones
|
|
113
|
+
|
|
114
|
+
### Competitive Analysis
|
|
115
|
+
|
|
116
|
+
**vs `date` command**: Zone is *way* easier for interactive use. Compare:
|
|
117
|
+
```bash
|
|
118
|
+
# date
|
|
119
|
+
date -d "2025-01-15T10:30:00Z" "+%b %d, %Y - %I:%M %p %Z"
|
|
120
|
+
|
|
121
|
+
# zone
|
|
122
|
+
zone "2025-01-15T10:30:00Z"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Zone wins on ergonomics by a mile.
|
|
126
|
+
|
|
127
|
+
**vs `dateutils`**: More powerful but also more complex. Zone targets the 80% use case perfectly.
|
|
128
|
+
|
|
129
|
+
**vs Online Converters**: Zone works offline, in pipelines, is automatable. Different category.
|
|
130
|
+
|
|
131
|
+
### Final Verdict
|
|
132
|
+
|
|
133
|
+
Zone is a **10/10 tool for its intended use case**. It's:
|
|
134
|
+
- Simple enough for beginners (just type `zone` with a timestamp)
|
|
135
|
+
- Powerful enough for experts (field mode, regex delimiters, chaining)
|
|
136
|
+
- Well-documented
|
|
137
|
+
- Follows Unix philosophy
|
|
138
|
+
- Actually useful in daily work
|
|
139
|
+
|
|
140
|
+
The recent changes (colors, pretty formats, better defaults) transformed it from "neat utility" to "essential tool I'd add to every machine."
|
|
141
|
+
|
|
142
|
+
### One More Thing
|
|
143
|
+
|
|
144
|
+
The pattern mode really feels like magic the first time you use it:
|
|
145
|
+
```bash
|
|
146
|
+
echo "Server started at 1736937000 and crashed at 1736940600" | zone
|
|
147
|
+
# Server started at Jan 15, 2025 - 5:30 AM EST and crashed at Jan 15, 2025 - 6:30 AM EST
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
That's the kind of "just works" experience that makes a tool memorable. Well done.
|
data/exe/zone
CHANGED
|
@@ -2,257 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
require 'logger'
|
|
7
|
-
require 'optparse'
|
|
8
|
-
require 'tzinfo'
|
|
9
|
-
require 'time'
|
|
10
|
-
require 'date'
|
|
5
|
+
require_relative '../lib/zone'
|
|
11
6
|
|
|
12
|
-
|
|
13
|
-
parser = OptionParser.new do |parser|
|
|
14
|
-
parser.on '--index N', '-i N', Integer, 'Index of the field to convert (default: 1)'
|
|
15
|
-
parser.on '--delimiter PATTERN', '-d', 'Field delimiter (default: space)'
|
|
16
|
-
parser.on '--iso8601', 'Output in ISO 8601 (default: true)'
|
|
17
|
-
parser.on '--strftime FORMAT', '-f', 'Output format using strftime (default: none)'
|
|
18
|
-
parser.on '--pretty', 'Output in pretty format (e.g., "Jan 02 - 03:04 PM")'
|
|
19
|
-
parser.on '--unix', 'Output as Unix timestamp (default: false)'
|
|
20
|
-
parser.on '--zone TZ', 'Convert to time zone (default: local time zone)'
|
|
21
|
-
parser.on '--local', 'Convert to local time zone (alias for --zone local)'
|
|
22
|
-
parser.on '--utc', 'Convert to UTC time zone (alias for --zone UTC)'
|
|
23
|
-
parser.on '--headers', 'Skip the first line as headers'
|
|
24
|
-
parser.on '--verbose', '-v', 'Enable verbose/debug output'
|
|
25
|
-
parser.on '--help', '-h', 'Show this help message' do
|
|
26
|
-
puts parser
|
|
27
|
-
exit
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
parser.parse!(into: options)
|
|
32
|
-
|
|
33
|
-
COLORS = {
|
|
34
|
-
reset: "\e[0m",
|
|
35
|
-
bold: "\e[1m",
|
|
36
|
-
cyan: "\e[36m",
|
|
37
|
-
yellow: "\e[33m",
|
|
38
|
-
red: "\e[31m",
|
|
39
|
-
gray: "\e[90m"
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
$logger = Logger.new($stderr).tap do |l|
|
|
43
|
-
l.formatter = ->(severity, _datetime, _progname, message) {
|
|
44
|
-
color = case severity
|
|
45
|
-
when "INFO" then COLORS[:cyan]
|
|
46
|
-
when "WARN" then COLORS[:yellow]
|
|
47
|
-
when "ERROR" then COLORS[:red]
|
|
48
|
-
else COLORS[:gray]
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
prefix = case severity
|
|
52
|
-
when "INFO" then "→"
|
|
53
|
-
when "WARN" then "⚠"
|
|
54
|
-
when "ERROR" then "✗"
|
|
55
|
-
else "·"
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
"#{color}#{prefix} #{message}#{COLORS[:reset]}\n"
|
|
59
|
-
}
|
|
60
|
-
l.level = options.delete(:verbose) ? Logger::INFO : Logger::WARN
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
format = (
|
|
64
|
-
case options
|
|
65
|
-
in { strftime: nil, iso8601: true, unix: false, pretty: false } then :iso8601
|
|
66
|
-
in { strftime: nil, iso8601: false, unix: true, pretty: false } then :unix
|
|
67
|
-
in { strftime: String, iso8601: false, unix: false, pretty: false } then :strftime
|
|
68
|
-
in { strftime: nil, iso8601: false, unix: false, pretty: true } then :pretty
|
|
69
|
-
in { strftime: nil, iso8601: false, unix: false, pretty: false } then :iso8601
|
|
70
|
-
else
|
|
71
|
-
$logger.error 'Error: Only one of --strftime, --iso8601, or --unix can be specified.'
|
|
72
|
-
exit 1
|
|
73
|
-
end
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
zone = (
|
|
77
|
-
case options
|
|
78
|
-
in { utc: true, local: false, zone: nil } then 'utc'
|
|
79
|
-
in { utc: false, local: true, zone: nil } then 'local'
|
|
80
|
-
in { zone: nil, utc: false, local: false } then 'utc'
|
|
81
|
-
in { zone: String => z, utc: false, local: false } then z
|
|
82
|
-
else
|
|
83
|
-
$logger.error 'Error: Only one of --zone, --local, or --utc can be specified.'
|
|
84
|
-
exit 1
|
|
85
|
-
end
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
class TimezoneSearch
|
|
89
|
-
attr_reader :keyword, :debug
|
|
90
|
-
|
|
91
|
-
def self.all_zones
|
|
92
|
-
TZInfo::Timezone.all_identifiers
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def initialize(
|
|
96
|
-
keyword,
|
|
97
|
-
debug: false,
|
|
98
|
-
logger: $logger || Logger.new($stderr)
|
|
99
|
-
)
|
|
100
|
-
@keyword = keyword
|
|
101
|
-
@debug = debug
|
|
102
|
-
@logger = logger
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def execute
|
|
106
|
-
begin
|
|
107
|
-
TZInfo::Timezone.get(keyword)
|
|
108
|
-
rescue TZInfo::InvalidTimezoneIdentifier
|
|
109
|
-
search_wildcard
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def us_wildcard
|
|
114
|
-
keyword
|
|
115
|
-
.gsub(/^(?:US)?\/?/, 'US/')
|
|
116
|
-
.gsub(/$/,'.*')
|
|
117
|
-
.then { Regexp.new(it, Regexp::IGNORECASE) }
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def all_wildcard
|
|
121
|
-
Regexp.new(".*#{keyword}.*", Regexp::IGNORECASE)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def search_wildcard
|
|
125
|
-
case self.class.all_zones
|
|
126
|
-
in [*, ^(us_wildcard) => found_zone, *]
|
|
127
|
-
in [*, ^(all_wildcard) => found_zone, *]
|
|
128
|
-
else nil
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
return unless found_zone
|
|
132
|
-
|
|
133
|
-
TZInfo::Timezone.get(found_zone).tap do
|
|
134
|
-
@logger.info "Using time zone '#{found_zone}' matching pattern '#{@keyword}'."
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
private
|
|
139
|
-
|
|
140
|
-
def log(message)
|
|
141
|
-
warn message if @debug
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
zone_callables = Hash.new do |hash, key|
|
|
146
|
-
hash[key] = ->(time) {
|
|
147
|
-
search = TimezoneSearch.new(key)
|
|
148
|
-
if zone = search.execute
|
|
149
|
-
zone.to_local(time)
|
|
150
|
-
else
|
|
151
|
-
$logger.warn "Error: Invalid time zone identifier '#{key}'."
|
|
152
|
-
time
|
|
153
|
-
end
|
|
154
|
-
}
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
zone_callables.merge!(
|
|
158
|
-
'utc' => ->(t) { t.utc },
|
|
159
|
-
'local' => ->(t) { t.localtime },
|
|
160
|
-
'UTC' => ->(t) { t.utc },
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
actual_index = options[:index] - 1
|
|
164
|
-
zone_callable = zone_callables[zone]
|
|
165
|
-
|
|
166
|
-
# Detect if args are timestamps (not filenames)
|
|
167
|
-
timestamps = []
|
|
168
|
-
if ARGV.any? && ARGV.all? { |arg| arg.match?(/^\d/) || arg.match?(/[A-Z][a-z]{2}/) || arg.match?(/:/) }
|
|
169
|
-
$logger.info "Treating arguments as timestamp strings."
|
|
170
|
-
timestamps = ARGV.dup
|
|
171
|
-
ARGV.clear # Clear so ARGF will read from STDIN if piped
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# Build input enumerable
|
|
175
|
-
input = (
|
|
176
|
-
if timestamps.any?
|
|
177
|
-
timestamps.each
|
|
178
|
-
elsif ARGV.any? || !STDIN.tty?
|
|
179
|
-
ARGF.each_line(chomp: true)
|
|
180
|
-
else
|
|
181
|
-
[Time.now.to_s].each
|
|
182
|
-
end
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
input.each do |line|
|
|
186
|
-
case [options, $.]
|
|
187
|
-
in { headers: true }, 1 then next
|
|
188
|
-
in { delimiter: String => delimiter }
|
|
189
|
-
in { delimiter: nil }
|
|
190
|
-
$logger.info "Auto-detecting delimiter for line: #{line.inspect}"
|
|
191
|
-
options[:delimiter] = (
|
|
192
|
-
case line
|
|
193
|
-
in /,\s*/
|
|
194
|
-
$logger.info "Using comma with whitespace as delimiter."
|
|
195
|
-
/,\s*/
|
|
196
|
-
in /\t/
|
|
197
|
-
$logger.info "Using tab as delimiter."
|
|
198
|
-
"\t"
|
|
199
|
-
in /,/
|
|
200
|
-
$logger.info "Using comma as delimiter."
|
|
201
|
-
','
|
|
202
|
-
else
|
|
203
|
-
$logger.info "Could not detect delimiter. Using whitespace."
|
|
204
|
-
/\s+/
|
|
205
|
-
end
|
|
206
|
-
)
|
|
207
|
-
else
|
|
208
|
-
""
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
delimiter = options[:delimiter] || ""
|
|
212
|
-
|
|
213
|
-
fields = (
|
|
214
|
-
case [line, delimiter]
|
|
215
|
-
in String, "" then [line]
|
|
216
|
-
in String, /^.+$/ then line.split(delimiter)
|
|
217
|
-
else [line]
|
|
218
|
-
end
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
target = fields[actual_index]
|
|
222
|
-
|
|
223
|
-
time = (
|
|
224
|
-
begin
|
|
225
|
-
case target
|
|
226
|
-
in Time then target
|
|
227
|
-
in DateTime then target.to_time
|
|
228
|
-
in Date then target.to_time
|
|
229
|
-
in /^[0-9\.]+$/
|
|
230
|
-
Time.at(target.to_f)
|
|
231
|
-
else
|
|
232
|
-
DateTime.parse(target).to_time
|
|
233
|
-
end
|
|
234
|
-
rescue StandardError => e
|
|
235
|
-
$logger.warn "Warning: Could not parse time '#{target}'. Skipping line."
|
|
236
|
-
$logger.warn " #{e.class}: #{e.message}"
|
|
237
|
-
next
|
|
238
|
-
end
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
converted = zone_callable.call(time)
|
|
242
|
-
formatted = (
|
|
243
|
-
case format
|
|
244
|
-
in :pretty
|
|
245
|
-
if (Time.now - converted).abs > 30 * 24 * 60 * 60
|
|
246
|
-
converted.strftime("%b %d, %Y - %I:%M %p %Z")
|
|
247
|
-
else
|
|
248
|
-
converted.strftime("%b %d - %I:%M %p %Z")
|
|
249
|
-
end
|
|
250
|
-
in :strftime then converted.strftime(options[:strftime])
|
|
251
|
-
in :iso8601 then converted.iso8601
|
|
252
|
-
in :unix then converted.to_i
|
|
253
|
-
end
|
|
254
|
-
)
|
|
255
|
-
|
|
256
|
-
fields[actual_index] = formatted
|
|
257
|
-
puts fields.join(delimiter)
|
|
258
|
-
end
|
|
7
|
+
Zone::CLI.run(ARGV)
|
data/lib/zone/cli.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'options'
|
|
4
|
+
require_relative 'input'
|
|
5
|
+
require_relative 'output'
|
|
6
|
+
require_relative 'transform'
|
|
7
|
+
require_relative 'colors'
|
|
8
|
+
require_relative 'logging'
|
|
9
|
+
require_relative 'pattern'
|
|
10
|
+
require_relative 'field'
|
|
11
|
+
|
|
12
|
+
module Zone
|
|
13
|
+
class CLI
|
|
14
|
+
def self.run(argv)
|
|
15
|
+
new(argv).run
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(argv)
|
|
19
|
+
@argv = argv
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run
|
|
23
|
+
options = Options.new
|
|
24
|
+
options.parse!(@argv)
|
|
25
|
+
options.validate!
|
|
26
|
+
|
|
27
|
+
setup_logger!(options.verbose)
|
|
28
|
+
setup_active_support!
|
|
29
|
+
|
|
30
|
+
input = Input.new(@argv)
|
|
31
|
+
output = Output.new(color_mode: options.color)
|
|
32
|
+
transformation = Transform.build(zone: options.zone, format: options.format)
|
|
33
|
+
|
|
34
|
+
if options.field
|
|
35
|
+
Field.process(input, output, transformation, options, @logger)
|
|
36
|
+
else
|
|
37
|
+
Pattern.process(input, output, transformation, @logger)
|
|
38
|
+
end
|
|
39
|
+
rescue OptionParser::MissingArgument, OptionParser::InvalidOption, OptionParser::InvalidArgument => e
|
|
40
|
+
$stderr.puts Colors.colors($stderr).red("Error:") + " #{e.message}"
|
|
41
|
+
$stderr.puts "Run 'zone --help' for usage information."
|
|
42
|
+
exit 1
|
|
43
|
+
rescue ArgumentError, StandardError => e
|
|
44
|
+
message = e.message.gsub(/'([^']+)'/) do
|
|
45
|
+
"'#{Colors.colors($stderr).bold($1)}'"
|
|
46
|
+
end
|
|
47
|
+
$stderr.puts Colors.colors($stderr).red("Error:") + " #{message}"
|
|
48
|
+
exit 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def setup_logger!(verbose)
|
|
54
|
+
@logger = Logging.build(verbose: verbose)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def setup_active_support!
|
|
58
|
+
if defined?(ActiveSupport)
|
|
59
|
+
ActiveSupport.to_time_preserves_timezone = true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
end
|
|
64
|
+
end
|