flycal-cli 0.3.2 → 0.4.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/README.md +79 -8
- data/config/defaults.yml +6 -0
- data/lib/flycal_cli/cli.rb +154 -3
- data/lib/flycal_cli/config.rb +63 -2
- data/lib/flycal_cli/duration_parser.rb +46 -0
- data/lib/flycal_cli/locale.rb +67 -0
- data/lib/flycal_cli/slot_finder.rb +159 -0
- data/lib/flycal_cli/slot_formatter.rb +38 -0
- data/lib/flycal_cli/version.rb +1 -1
- data/lib/flycal_cli.rb +4 -0
- data/locales/en.json +19 -0
- data/locales/it.json +19 -0
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61b9a15ce2341c56bbac3c59bd465e02a13082e817a367cc19467dc88e874b76
|
|
4
|
+
data.tar.gz: d6cf5b95459a56289a5e9d8b22c706be82f85cc51ddcb42dbcb30b4c7b0b1e66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58585691471f02638b1490300ce22a722bad34bcb3e53f2069975697385d8e4cb313b7311fd68dbbdd1fbfc38bcd77ee1f95a600f5c2b56394c7317e70b9b029
|
|
7
|
+
data.tar.gz: 6b2b9fd98a5e629b27e4117ba135c72de9e348cfbba04b494b68b3291554b29b3a5c7233d32a330de4879da213870a17bb537d7e3f23fd39a81219607a8b501c
|
data/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# flycal-cli
|
|
2
2
|
|
|
3
|
+
[](https://badge.fury.io/rb/flycal-cli)
|
|
4
|
+
|
|
3
5
|
A command-line tool to access and search Google Calendar events. Connect your Google account, choose a default calendar, and search events with flexible date ranges and text filters.
|
|
4
6
|
|
|
5
7
|
## Requirements
|
|
@@ -37,6 +39,13 @@ Before using flycal, you need OAuth credentials from Google Cloud Console:
|
|
|
37
39
|
|
|
38
40
|
## Commands
|
|
39
41
|
|
|
42
|
+
All commands support a per-invocation locale override:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
flycal search --locale it --in 7days
|
|
46
|
+
flycal slots --locale en --in "3 days" --duration 1h
|
|
47
|
+
```
|
|
48
|
+
|
|
40
49
|
### login
|
|
41
50
|
|
|
42
51
|
Connect to your Google account. Opens a browser for OAuth authentication when not yet connected.
|
|
@@ -55,6 +64,24 @@ Disconnect from your Google account and remove stored tokens.
|
|
|
55
64
|
flycal logout
|
|
56
65
|
```
|
|
57
66
|
|
|
67
|
+
### update
|
|
68
|
+
|
|
69
|
+
Update `flycal-cli` to the latest published gem version.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
flycal update
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### version
|
|
76
|
+
|
|
77
|
+
Show the current installed `flycal-cli` version.
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
flycal version
|
|
81
|
+
flycal --version
|
|
82
|
+
flycal -v
|
|
83
|
+
```
|
|
84
|
+
|
|
58
85
|
### calendars
|
|
59
86
|
|
|
60
87
|
List available calendars and set the default one. Uses an interactive scrollable menu (arrow keys to navigate, type to filter).
|
|
@@ -78,13 +105,11 @@ flycal search -i 2months -d placeholder
|
|
|
78
105
|
|
|
79
106
|
**Options:**
|
|
80
107
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
| `--calendar` | `-c` | Calendar name or ID. Default: calendar set via `flycal calendars` |
|
|
87
|
-
| `--description` | `-d` | Filter events by text. Matches events where the string appears in the title or description (case-insensitive, contains) |
|
|
108
|
+
- `--from` / `-f` — Start date/time. Default: midnight of current day. Format: `2025-01-01` or `2025-01-01T09:00`
|
|
109
|
+
- `--to` / `-t` — End date/time. Default: 23:59 of the 30th day from today. Format: same as `--from`
|
|
110
|
+
- `--in` / `-i` — Duration from `--from`, overrides `--to`. Format: `30days`, `48hours`, `2months`, `1year` (no space). With space use quotes: `--in "30 days"`
|
|
111
|
+
- `--calendar` / `-c` — Calendar name or ID. Default: calendar set via `flycal calendars`
|
|
112
|
+
- `--description` / `-d` — Filter events by text. Matches events where the string appears in title or description (case-insensitive, contains)
|
|
88
113
|
|
|
89
114
|
**Time range behavior:**
|
|
90
115
|
|
|
@@ -103,12 +128,58 @@ The summary shows:
|
|
|
103
128
|
|
|
104
129
|
For time frames longer than 7 days, a weekly breakdown is added (week number, start/end dates, hours, working days per week). For time frames longer than 30 days, a monthly breakdown is shown instead (month number, month name, hours, working days per month).
|
|
105
130
|
|
|
131
|
+
### slots
|
|
132
|
+
|
|
133
|
+
Find free time slots in your calendar (weekdays, 9:00–18:00). Output is a simple list for copy/paste into email or other tools.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
flycal slots --in "3 days" --duration 1h
|
|
137
|
+
flycal slots --in 1week --duration 30min
|
|
138
|
+
flycal slots --in "48 hours" --duration 1hour -c Work
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Options:**
|
|
142
|
+
|
|
143
|
+
- `--duration` — Minimum slot length. Examples: `1h`, `1 hour`, `30 minutes`, `90min`
|
|
144
|
+
- `--in` / `-i` — Search window from now. Examples: `3 days`, `1 week`, `48 hours` (use quotes when the value contains a space)
|
|
145
|
+
- `--calendar` / `-c` — Calendar name or ID (optional; uses default calendar)
|
|
146
|
+
|
|
147
|
+
Slot search windows are configured in `~/.flycal/config.yml`:
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
slots:
|
|
151
|
+
workhours:
|
|
152
|
+
- 9:30-13:00
|
|
153
|
+
- 14:00-18:30
|
|
154
|
+
weekdays-only: true
|
|
155
|
+
locale: en
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Missing keys are filled from `config/defaults.yml` in the gem and saved to your `config.yml` on first read.
|
|
159
|
+
|
|
160
|
+
- `workhours` accepts one or more ranges (`H-H`, `HH:MM-HH:MM`, mixed)
|
|
161
|
+
- `weekdays-only: true` limits slots to Monday-Friday
|
|
162
|
+
- `weekdays-only: false` includes weekends
|
|
163
|
+
- `locale` supports `en` and `it` (default is `en`)
|
|
164
|
+
|
|
165
|
+
**Output example:**
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
friday 15/7
|
|
169
|
+
10-12
|
|
170
|
+
13-15.30
|
|
171
|
+
|
|
172
|
+
monday 21/7
|
|
173
|
+
12-13
|
|
174
|
+
14-15
|
|
175
|
+
```
|
|
176
|
+
|
|
106
177
|
## Configuration
|
|
107
178
|
|
|
108
179
|
Data is stored in `~/.flycal/`:
|
|
109
180
|
|
|
110
181
|
| File | Purpose |
|
|
111
|
-
|
|
182
|
+
|:-----|---------|
|
|
112
183
|
| `config.yml` | Default calendar ID and other settings |
|
|
113
184
|
| `credentials.json` | OAuth credentials (created manually from Google Cloud Console) |
|
|
114
185
|
| `tokens.yml` | Access tokens (managed automatically) |
|
data/config/defaults.yml
ADDED
data/lib/flycal_cli/cli.rb
CHANGED
|
@@ -12,6 +12,7 @@ require "tty-spinner"
|
|
|
12
12
|
module FlycalCli
|
|
13
13
|
class Cli < Thor
|
|
14
14
|
package_name "flycal"
|
|
15
|
+
class_option :locale, type: :string, desc: "Override locale for this command (e.g. en, it)"
|
|
15
16
|
|
|
16
17
|
def self.exit_on_failure?
|
|
17
18
|
true
|
|
@@ -19,6 +20,7 @@ module FlycalCli
|
|
|
19
20
|
|
|
20
21
|
desc "login", "Connect to your Google account"
|
|
21
22
|
def login
|
|
23
|
+
apply_locale_override
|
|
22
24
|
if Auth.logged_in?
|
|
23
25
|
puts "✓ You are already connected to your Google account."
|
|
24
26
|
puts "\nRun 'flycal calendars' to set the default calendar."
|
|
@@ -48,6 +50,7 @@ module FlycalCli
|
|
|
48
50
|
|
|
49
51
|
desc "logout", "Disconnect from Google account"
|
|
50
52
|
def logout
|
|
53
|
+
apply_locale_override
|
|
51
54
|
unless Auth.logged_in?
|
|
52
55
|
puts "You are not connected to any Google account."
|
|
53
56
|
return
|
|
@@ -59,6 +62,7 @@ module FlycalCli
|
|
|
59
62
|
|
|
60
63
|
desc "calendars", "List available calendars and set the default one"
|
|
61
64
|
def calendars
|
|
65
|
+
apply_locale_override
|
|
62
66
|
unless Auth.logged_in?
|
|
63
67
|
puts "You are not connected. Run 'flycal login' first."
|
|
64
68
|
exit 1
|
|
@@ -125,6 +129,7 @@ module FlycalCli
|
|
|
125
129
|
option :in, type: :string, aliases: "-i", desc: "Duration: 30days, 48hours, 2months, 1year (overrides --to)"
|
|
126
130
|
option :description, type: :string, aliases: "-d", desc: "Filter by text in event"
|
|
127
131
|
def search
|
|
132
|
+
apply_locale_override
|
|
128
133
|
unless Auth.logged_in?
|
|
129
134
|
puts "You are not connected. Run 'flycal login' first."
|
|
130
135
|
exit 1
|
|
@@ -167,6 +172,88 @@ module FlycalCli
|
|
|
167
172
|
print_search_summary(events, time_min: time_min, time_max: time_max)
|
|
168
173
|
end
|
|
169
174
|
|
|
175
|
+
desc "slots", "Find available time slots in your calendar"
|
|
176
|
+
long_desc <<-LONGDESC
|
|
177
|
+
List free time slots long enough for a given duration.
|
|
178
|
+
|
|
179
|
+
Examples:
|
|
180
|
+
flycal slots --in "3 days" --duration 1h
|
|
181
|
+
flycal slots --in 1week --duration 30min -c Work
|
|
182
|
+
LONGDESC
|
|
183
|
+
option :duration, type: :string, required: true,
|
|
184
|
+
desc: "Minimum slot length (e.g. 1h, 30 minutes, 1 hour)"
|
|
185
|
+
option :in, type: :string, aliases: "-i", required: true,
|
|
186
|
+
desc: "Search window from now (e.g. 3 days, 1 week, 48 hours)"
|
|
187
|
+
option :calendar, type: :string, aliases: "-c", desc: "Calendar name or ID"
|
|
188
|
+
def slots
|
|
189
|
+
apply_locale_override
|
|
190
|
+
unless Auth.logged_in?
|
|
191
|
+
puts Locale.t("errors.not_connected")
|
|
192
|
+
exit 1
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
begin
|
|
196
|
+
slot_duration = DurationParser.to_seconds(options[:duration])
|
|
197
|
+
time_min = Time.now
|
|
198
|
+
time_max = DurationParser.add_to_time(options[:in], time_min)
|
|
199
|
+
slot_cfg = Config.slots_config
|
|
200
|
+
workhours = parse_workhours(slot_cfg["workhours"])
|
|
201
|
+
weekdays_only = !!slot_cfg["weekdays-only"]
|
|
202
|
+
rescue FlycalCli::Error => e
|
|
203
|
+
puts "Error: #{e.message}"
|
|
204
|
+
exit 1
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
if time_min >= time_max
|
|
208
|
+
puts Locale.t("errors.duration_positive")
|
|
209
|
+
exit 1
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
creds = Auth.credentials
|
|
213
|
+
service = CalendarService.new(creds)
|
|
214
|
+
|
|
215
|
+
calendar_id = resolve_single_calendar_id(service, options[:calendar])
|
|
216
|
+
if calendar_id.nil?
|
|
217
|
+
puts Locale.t("slots.no_calendar")
|
|
218
|
+
exit 1
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
events = service.list_events(
|
|
222
|
+
calendar_id,
|
|
223
|
+
time_min: time_min,
|
|
224
|
+
time_max: time_max
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
finder = SlotFinder.new(
|
|
228
|
+
events: events,
|
|
229
|
+
time_min: time_min,
|
|
230
|
+
time_max: time_max,
|
|
231
|
+
slot_duration_seconds: slot_duration,
|
|
232
|
+
workhours: workhours,
|
|
233
|
+
weekdays_only: weekdays_only
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
output = SlotFormatter.format_output(finder.slots_by_day)
|
|
237
|
+
puts output.empty? ? Locale.t("slots.no_available") : output
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
desc "update", "Update flycal-cli to the latest gem version"
|
|
241
|
+
def update
|
|
242
|
+
apply_locale_override
|
|
243
|
+
puts "Updating flycal-cli..."
|
|
244
|
+
success = system("gem", "update", "flycal-cli")
|
|
245
|
+
return if success
|
|
246
|
+
|
|
247
|
+
puts "Error: failed to update flycal-cli."
|
|
248
|
+
exit 1
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
desc "version", "Show current flycal-cli version"
|
|
252
|
+
def version
|
|
253
|
+
apply_locale_override
|
|
254
|
+
puts "flycal #{FlycalCli::VERSION}"
|
|
255
|
+
end
|
|
256
|
+
|
|
170
257
|
default_task :help
|
|
171
258
|
|
|
172
259
|
private
|
|
@@ -177,6 +264,10 @@ module FlycalCli
|
|
|
177
264
|
"\e[1m#{str}\e[0m"
|
|
178
265
|
end
|
|
179
266
|
|
|
267
|
+
def apply_locale_override
|
|
268
|
+
Locale.override!(options[:locale])
|
|
269
|
+
end
|
|
270
|
+
|
|
180
271
|
def parse_datetime(str, end_of_day: false)
|
|
181
272
|
return nil if str.nil? || str.empty?
|
|
182
273
|
|
|
@@ -211,6 +302,50 @@ module FlycalCli
|
|
|
211
302
|
end
|
|
212
303
|
end
|
|
213
304
|
|
|
305
|
+
def parse_hour_minute(value)
|
|
306
|
+
match = value.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/)
|
|
307
|
+
raise FlycalCli::Error, "Invalid time format #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)." unless match
|
|
308
|
+
|
|
309
|
+
hour = match[1].to_i
|
|
310
|
+
minute = match[2].to_i
|
|
311
|
+
unless hour.between?(0, 23) && minute.between?(0, 59)
|
|
312
|
+
raise FlycalCli::Error, "Invalid time #{value.inspect}. Hour must be 0-23 and minute 0-59."
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
[hour, minute]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def parse_workhours(values)
|
|
319
|
+
ranges = Array(values).map do |item|
|
|
320
|
+
start_str, end_str = item.to_s.strip.split("-", 2)
|
|
321
|
+
raise FlycalCli::Error, "Invalid workhours item #{item.inspect}. Use format like '9-13' or '14:30-18:00'." if start_str.nil? || end_str.nil?
|
|
322
|
+
|
|
323
|
+
sh, sm = parse_hour_minute_flexible(start_str)
|
|
324
|
+
eh, em = parse_hour_minute_flexible(end_str)
|
|
325
|
+
start_minutes = (sh * 60) + sm
|
|
326
|
+
end_minutes = (eh * 60) + em
|
|
327
|
+
raise FlycalCli::Error, "Invalid workhours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
|
|
328
|
+
|
|
329
|
+
[sh, sm, eh, em]
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
raise FlycalCli::Error, "slots.workhours cannot be empty in config.yml." if ranges.empty?
|
|
333
|
+
|
|
334
|
+
ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def parse_hour_minute_flexible(value)
|
|
338
|
+
str = value.to_s.strip
|
|
339
|
+
if str.match?(/\A\d{1,2}\z/)
|
|
340
|
+
hour = str.to_i
|
|
341
|
+
raise FlycalCli::Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
|
|
342
|
+
|
|
343
|
+
return [hour, 0]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
parse_hour_minute(str)
|
|
347
|
+
end
|
|
348
|
+
|
|
214
349
|
def resolve_calendar_ids(service, calendar_name)
|
|
215
350
|
calendar_lists = service.list_calendars
|
|
216
351
|
|
|
@@ -236,6 +371,22 @@ module FlycalCli
|
|
|
236
371
|
matches.map(&:id)
|
|
237
372
|
end
|
|
238
373
|
|
|
374
|
+
def resolve_single_calendar_id(service, calendar_name)
|
|
375
|
+
if calendar_name && !calendar_name.empty?
|
|
376
|
+
ids = resolve_calendar_ids(service, calendar_name)
|
|
377
|
+
return ids.first
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
default_id = Config.calendar_default
|
|
381
|
+
return default_id if default_id
|
|
382
|
+
|
|
383
|
+
calendars = service.list_calendars
|
|
384
|
+
primary = calendars.find(&:primary)
|
|
385
|
+
return primary.id if primary
|
|
386
|
+
|
|
387
|
+
calendars.first&.id
|
|
388
|
+
end
|
|
389
|
+
|
|
239
390
|
def print_events(service, events)
|
|
240
391
|
# Use calendar list for names (avoids extra API calls)
|
|
241
392
|
calendar_list = service.list_calendars
|
|
@@ -264,7 +415,7 @@ module FlycalCli
|
|
|
264
415
|
if dt.is_a?(String)
|
|
265
416
|
dt
|
|
266
417
|
else
|
|
267
|
-
dt.strftime("%
|
|
418
|
+
"#{Locale.day_abbr(dt)} #{dt.strftime("%Y-%m-%d %H:%M")}"
|
|
268
419
|
end
|
|
269
420
|
end
|
|
270
421
|
|
|
@@ -272,7 +423,7 @@ module FlycalCli
|
|
|
272
423
|
return "-" if dt.nil?
|
|
273
424
|
|
|
274
425
|
t = dt.respond_to?(:to_time) ? dt.to_time : dt
|
|
275
|
-
t.strftime("%
|
|
426
|
+
"#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d")}"
|
|
276
427
|
end
|
|
277
428
|
|
|
278
429
|
def print_search_summary(events, time_min:, time_max:)
|
|
@@ -362,7 +513,7 @@ module FlycalCli
|
|
|
362
513
|
mins = minutes_in_period(event_ranges, period_start, period_end)
|
|
363
514
|
hours = (mins / 60).round(1)
|
|
364
515
|
working_days = (mins / 60.0 / HOURS_PER_WORKING_DAY).round(1)
|
|
365
|
-
month_name =
|
|
516
|
+
month_name = "#{Locale.month_name(current)} #{current.year}"
|
|
366
517
|
start_str = format_date_with_day(period_start)
|
|
367
518
|
last_day = (period_end.to_date - 1.day)
|
|
368
519
|
end_str = format_date_with_day(last_day)
|
data/lib/flycal_cli/config.rb
CHANGED
|
@@ -9,12 +9,20 @@ module FlycalCli
|
|
|
9
9
|
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
|
10
10
|
CREDENTIALS_FILE = File.join(CONFIG_DIR, "credentials.json")
|
|
11
11
|
TOKENS_FILE = File.join(CONFIG_DIR, "tokens.yml")
|
|
12
|
+
DEFAULTS_FILE = File.expand_path("../../config/defaults.yml", __dir__)
|
|
12
13
|
|
|
13
14
|
class << self
|
|
14
15
|
def load
|
|
15
|
-
|
|
16
|
+
user_data = if File.exist?(CONFIG_FILE)
|
|
17
|
+
YAML.load_file(CONFIG_FILE) || {}
|
|
18
|
+
else
|
|
19
|
+
{}
|
|
20
|
+
end
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
merged, changed = merge_missing_defaults(user_data, default_values)
|
|
23
|
+
save(merged) if changed || !File.exist?(CONFIG_FILE)
|
|
24
|
+
|
|
25
|
+
merged
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
def save(data)
|
|
@@ -51,6 +59,59 @@ module FlycalCli
|
|
|
51
59
|
def clear_all
|
|
52
60
|
FileUtils.rm_rf(CONFIG_DIR)
|
|
53
61
|
end
|
|
62
|
+
|
|
63
|
+
def slots_config
|
|
64
|
+
load.fetch("slots", {})
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def locale
|
|
68
|
+
load["locale"].to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def locale=(value)
|
|
72
|
+
data = load
|
|
73
|
+
data["locale"] = value.to_s
|
|
74
|
+
save(data)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def default_values
|
|
78
|
+
@default_values ||= begin
|
|
79
|
+
raise "Defaults file not found: #{DEFAULTS_FILE}" unless File.exist?(DEFAULTS_FILE)
|
|
80
|
+
|
|
81
|
+
YAML.load_file(DEFAULTS_FILE) || {}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def merge_missing_defaults(target, defaults)
|
|
86
|
+
merged = target.dup
|
|
87
|
+
changed = false
|
|
88
|
+
|
|
89
|
+
defaults.each do |key, default_value|
|
|
90
|
+
if !merged.key?(key)
|
|
91
|
+
merged[key] = deep_dup(default_value)
|
|
92
|
+
changed = true
|
|
93
|
+
elsif default_value.is_a?(Hash) && merged[key].is_a?(Hash)
|
|
94
|
+
nested, nested_changed = merge_missing_defaults(merged[key], default_value)
|
|
95
|
+
merged[key] = nested
|
|
96
|
+
changed ||= nested_changed
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
[merged, changed]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def deep_dup(value)
|
|
104
|
+
case value
|
|
105
|
+
when Hash
|
|
106
|
+
value.transform_values { |v| deep_dup(v) }
|
|
107
|
+
when Array
|
|
108
|
+
value.map { |v| deep_dup(v) }
|
|
109
|
+
else
|
|
110
|
+
value
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private :default_values, :merge_missing_defaults, :deep_dup
|
|
54
115
|
end
|
|
55
116
|
end
|
|
56
117
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/numeric/time"
|
|
4
|
+
require "active_support/core_ext/integer/time"
|
|
5
|
+
|
|
6
|
+
module FlycalCli
|
|
7
|
+
class DurationParser
|
|
8
|
+
UNITS = {
|
|
9
|
+
"s" => :seconds, "sec" => :seconds, "second" => :seconds, "seconds" => :seconds,
|
|
10
|
+
"m" => :minutes, "min" => :minutes, "mins" => :minutes, "minute" => :minutes, "minutes" => :minutes,
|
|
11
|
+
"h" => :hours, "hr" => :hours, "hour" => :hours, "hours" => :hours,
|
|
12
|
+
"d" => :days, "day" => :days, "days" => :days,
|
|
13
|
+
"w" => :weeks, "week" => :weeks, "weeks" => :weeks
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def parse(str)
|
|
18
|
+
normalized = str.to_s.strip.downcase
|
|
19
|
+
match = normalized.match(/\A(\d+(?:\.\d+)?)\s*([a-z]+)\z/) ||
|
|
20
|
+
normalized.match(/\A(\d+(?:\.\d+)?)([a-z]+)\z/)
|
|
21
|
+
raise FlycalCli::Error, invalid_message(str) unless match
|
|
22
|
+
|
|
23
|
+
value = match[1].to_f
|
|
24
|
+
unit = UNITS[match[2]]
|
|
25
|
+
raise FlycalCli::Error, invalid_message(str) unless unit
|
|
26
|
+
|
|
27
|
+
duration = value.public_send(unit)
|
|
28
|
+
duration
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def to_seconds(str)
|
|
32
|
+
parse(str).to_i
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_to_time(str, from_time)
|
|
36
|
+
from_time + parse(str)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def invalid_message(str)
|
|
42
|
+
"Invalid duration: #{str.inspect}. Examples: 1h, 1 hour, 30 minutes, 3 days"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FlycalCli
|
|
6
|
+
module Locale
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
FALLBACK_LOCALE = "en"
|
|
10
|
+
|
|
11
|
+
def t(key, vars = {})
|
|
12
|
+
value = lookup(translations(current_locale), key) || lookup(translations(FALLBACK_LOCALE), key) || key
|
|
13
|
+
interpolate(value, vars)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def day_name(date_or_time)
|
|
17
|
+
idx = date_or_time.to_date.wday
|
|
18
|
+
days = t("common.weekdays.full")
|
|
19
|
+
days[idx] || date_or_time.strftime("%A").downcase
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def day_abbr(date_or_time)
|
|
23
|
+
idx = date_or_time.to_date.wday
|
|
24
|
+
days = t("common.weekdays.abbr")
|
|
25
|
+
days[idx] || date_or_time.strftime("%a")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def month_name(date_or_time)
|
|
29
|
+
idx = date_or_time.to_date.month - 1
|
|
30
|
+
months = t("common.months.full")
|
|
31
|
+
months[idx] || date_or_time.strftime("%B")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def current_locale
|
|
35
|
+
Thread.current[:flycal_locale_override] || Config.locale
|
|
36
|
+
rescue StandardError
|
|
37
|
+
FALLBACK_LOCALE
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def override!(locale)
|
|
41
|
+
Thread.current[:flycal_locale_override] = locale.to_s if locale && !locale.to_s.strip.empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def translations(locale)
|
|
45
|
+
@cache ||= {}
|
|
46
|
+
loc = locale.to_s
|
|
47
|
+
@cache[loc] ||= begin
|
|
48
|
+
path = File.expand_path("../../locales/#{loc}.json", __dir__)
|
|
49
|
+
File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def lookup(hash, dotted_key)
|
|
54
|
+
dotted_key.to_s.split(".").reduce(hash) do |acc, part|
|
|
55
|
+
acc.is_a?(Hash) ? acc[part] : nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def interpolate(value, vars)
|
|
60
|
+
return value unless value.is_a?(String)
|
|
61
|
+
|
|
62
|
+
value.gsub(/%\{(\w+)\}/) do
|
|
63
|
+
vars.fetch(Regexp.last_match(1).to_sym, Regexp.last_match(0)).to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlycalCli
|
|
4
|
+
class SlotFinder
|
|
5
|
+
DEFAULT_WORKHOURS = [[9, 0, 18, 0]].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(
|
|
8
|
+
events:,
|
|
9
|
+
time_min:,
|
|
10
|
+
time_max:,
|
|
11
|
+
slot_duration_seconds:,
|
|
12
|
+
workhours: DEFAULT_WORKHOURS,
|
|
13
|
+
weekdays_only: true
|
|
14
|
+
)
|
|
15
|
+
@events = events
|
|
16
|
+
@time_min = time_min
|
|
17
|
+
@time_max = time_max
|
|
18
|
+
@slot_duration_seconds = slot_duration_seconds
|
|
19
|
+
@workhours = workhours
|
|
20
|
+
@weekdays_only = weekdays_only
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def slots_by_day
|
|
24
|
+
result = {}
|
|
25
|
+
|
|
26
|
+
each_workday do |date|
|
|
27
|
+
slots = []
|
|
28
|
+
day_intervals(date).each do |day_start, day_end|
|
|
29
|
+
next if day_end <= day_start
|
|
30
|
+
|
|
31
|
+
busy = busy_intervals_for(date, day_start, day_end)
|
|
32
|
+
gaps = free_gaps(day_start, day_end, busy)
|
|
33
|
+
slots.concat(gaps.filter_map { |start_at, end_at| slot_if_fits(start_at, end_at) })
|
|
34
|
+
end
|
|
35
|
+
result[date] = slots unless slots.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def each_workday
|
|
44
|
+
date = @time_min.to_date
|
|
45
|
+
end_date = @time_max.to_date
|
|
46
|
+
|
|
47
|
+
while date <= end_date
|
|
48
|
+
yield date if include_day?(date)
|
|
49
|
+
date += 1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def include_day?(date)
|
|
54
|
+
return true unless @weekdays_only
|
|
55
|
+
|
|
56
|
+
workday?(date)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def workday?(date)
|
|
60
|
+
!date.saturday? && !date.sunday?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def day_intervals(date)
|
|
64
|
+
@workhours.map do |start_h, start_m, end_h, end_m|
|
|
65
|
+
start_at = Time.local(date.year, date.month, date.day, start_h, start_m, 0)
|
|
66
|
+
end_at = Time.local(date.year, date.month, date.day, end_h, end_m, 0)
|
|
67
|
+
start_at = [@time_min, start_at].max
|
|
68
|
+
end_at = [@time_max, end_at].min
|
|
69
|
+
[start_at, end_at]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def busy_intervals_for(date, day_start, day_end)
|
|
74
|
+
intervals = @events.filter_map do |event|
|
|
75
|
+
interval_for_event(event, date, day_start, day_end)
|
|
76
|
+
end
|
|
77
|
+
merge_intervals(intervals)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def interval_for_event(event, date, day_start, day_end)
|
|
81
|
+
return nil if event.status == "cancelled"
|
|
82
|
+
return nil if event.transparency == "transparent"
|
|
83
|
+
|
|
84
|
+
if event.start&.date
|
|
85
|
+
event_date = Date.parse(event.start.date.to_s)
|
|
86
|
+
event_end_date = Date.parse(event.end.date.to_s)
|
|
87
|
+
return nil unless date >= event_date && date < event_end_date
|
|
88
|
+
|
|
89
|
+
return [day_start, day_end]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
start_at = to_time(event.start.date_time)
|
|
93
|
+
end_at = to_time(event.end.date_time)
|
|
94
|
+
return nil if end_at <= day_start || start_at >= day_end
|
|
95
|
+
|
|
96
|
+
[[start_at, day_start].max, [end_at, day_end].min]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def merge_intervals(intervals)
|
|
100
|
+
return [] if intervals.empty?
|
|
101
|
+
|
|
102
|
+
sorted = intervals.sort_by(&:first)
|
|
103
|
+
merged = [sorted.first]
|
|
104
|
+
|
|
105
|
+
sorted[1..].each do |start_at, end_at|
|
|
106
|
+
last_start, last_end = merged.last
|
|
107
|
+
if start_at <= last_end
|
|
108
|
+
merged[-1] = [last_start, [last_end, end_at].max]
|
|
109
|
+
else
|
|
110
|
+
merged << [start_at, end_at]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
merged
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def free_gaps(day_start, day_end, busy)
|
|
118
|
+
gaps = []
|
|
119
|
+
cursor = day_start
|
|
120
|
+
|
|
121
|
+
busy.each do |start_at, end_at|
|
|
122
|
+
gaps << [cursor, start_at] if start_at > cursor
|
|
123
|
+
cursor = [cursor, end_at].max
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
gaps << [cursor, day_end] if cursor < day_end
|
|
127
|
+
gaps
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def slot_if_fits(start_at, end_at)
|
|
131
|
+
rounded_start = round_up_15(start_at)
|
|
132
|
+
rounded_end = round_down_15(end_at)
|
|
133
|
+
return nil if rounded_start >= rounded_end
|
|
134
|
+
return nil if (rounded_end - rounded_start) < @slot_duration_seconds
|
|
135
|
+
|
|
136
|
+
[rounded_start, rounded_end]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def round_up_15(time)
|
|
140
|
+
sec = time.to_i
|
|
141
|
+
remainder = sec % 900
|
|
142
|
+
return time if remainder.zero?
|
|
143
|
+
|
|
144
|
+
Time.at(sec + (900 - remainder))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def round_down_15(time)
|
|
148
|
+
sec = time.to_i
|
|
149
|
+
Time.at(sec - (sec % 900))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def to_time(value)
|
|
153
|
+
return value if value.is_a?(Time)
|
|
154
|
+
return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String)
|
|
155
|
+
|
|
156
|
+
Time.parse(value.to_s)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlycalCli
|
|
4
|
+
class SlotFormatter
|
|
5
|
+
class << self
|
|
6
|
+
def format_output(slots_by_day)
|
|
7
|
+
lines = []
|
|
8
|
+
|
|
9
|
+
slots_by_day.sort_by(&:first).each do |date, slots|
|
|
10
|
+
lines << "" unless lines.empty?
|
|
11
|
+
lines << day_header(date)
|
|
12
|
+
slots.each do |start_at, end_at|
|
|
13
|
+
lines << slot_range(start_at, end_at)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
lines.join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def day_header(date)
|
|
23
|
+
"#{Locale.day_name(date)} #{date.day}/#{date.month}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def slot_range(start_at, end_at)
|
|
27
|
+
"#{format_time(start_at)}-#{format_time(end_at)}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def format_time(time)
|
|
31
|
+
return time.strftime("%-H") if time.min.zero?
|
|
32
|
+
|
|
33
|
+
minutes = sprintf("%02d", time.min)
|
|
34
|
+
"#{time.hour}.#{minutes}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/flycal_cli/version.rb
CHANGED
data/lib/flycal_cli.rb
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "flycal_cli/version"
|
|
4
4
|
require "flycal_cli/config"
|
|
5
|
+
require "flycal_cli/locale"
|
|
5
6
|
require "flycal_cli/auth"
|
|
7
|
+
require "flycal_cli/duration_parser"
|
|
6
8
|
require "flycal_cli/calendar_service"
|
|
9
|
+
require "flycal_cli/slot_finder"
|
|
10
|
+
require "flycal_cli/slot_formatter"
|
|
7
11
|
require "flycal_cli/cli"
|
|
8
12
|
|
|
9
13
|
module FlycalCli
|
data/locales/en.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": {
|
|
3
|
+
"weekdays": {
|
|
4
|
+
"full": ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"],
|
|
5
|
+
"abbr": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
|
6
|
+
},
|
|
7
|
+
"months": {
|
|
8
|
+
"full": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"slots": {
|
|
12
|
+
"no_available": "No available slots found.",
|
|
13
|
+
"no_calendar": "No calendar found. Run 'flycal calendars' to set a default."
|
|
14
|
+
},
|
|
15
|
+
"errors": {
|
|
16
|
+
"not_connected": "You are not connected. Run 'flycal login' first.",
|
|
17
|
+
"duration_positive": "Error: --in must be a positive duration."
|
|
18
|
+
}
|
|
19
|
+
}
|
data/locales/it.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": {
|
|
3
|
+
"weekdays": {
|
|
4
|
+
"full": ["domenica", "lunedi", "martedi", "mercoledi", "giovedi", "venerdi", "sabato"],
|
|
5
|
+
"abbr": ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"]
|
|
6
|
+
},
|
|
7
|
+
"months": {
|
|
8
|
+
"full": ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"]
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"slots": {
|
|
12
|
+
"no_available": "Nessuno slot disponibile.",
|
|
13
|
+
"no_calendar": "Nessun calendario trovato. Esegui 'flycal calendars' per impostare il predefinito."
|
|
14
|
+
},
|
|
15
|
+
"errors": {
|
|
16
|
+
"not_connected": "Non sei connesso. Esegui prima 'flycal login'.",
|
|
17
|
+
"duration_positive": "Errore: --in deve essere una durata positiva."
|
|
18
|
+
}
|
|
19
|
+
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: flycal-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- flycal-cli
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '6.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: pstore
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.1'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: google-apis-calendar_v3
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -119,12 +133,19 @@ files:
|
|
|
119
133
|
- LICENSE
|
|
120
134
|
- README.md
|
|
121
135
|
- bin/flycal
|
|
136
|
+
- config/defaults.yml
|
|
122
137
|
- lib/flycal_cli.rb
|
|
123
138
|
- lib/flycal_cli/auth.rb
|
|
124
139
|
- lib/flycal_cli/calendar_service.rb
|
|
125
140
|
- lib/flycal_cli/cli.rb
|
|
126
141
|
- lib/flycal_cli/config.rb
|
|
142
|
+
- lib/flycal_cli/duration_parser.rb
|
|
143
|
+
- lib/flycal_cli/locale.rb
|
|
144
|
+
- lib/flycal_cli/slot_finder.rb
|
|
145
|
+
- lib/flycal_cli/slot_formatter.rb
|
|
127
146
|
- lib/flycal_cli/version.rb
|
|
147
|
+
- locales/en.json
|
|
148
|
+
- locales/it.json
|
|
128
149
|
homepage: https://github.com/magnum/flycal-cli
|
|
129
150
|
licenses:
|
|
130
151
|
- MIT
|
|
@@ -146,7 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
146
167
|
- !ruby/object:Gem::Version
|
|
147
168
|
version: '0'
|
|
148
169
|
requirements: []
|
|
149
|
-
rubygems_version: 4.0.
|
|
170
|
+
rubygems_version: 4.0.17
|
|
150
171
|
specification_version: 4
|
|
151
172
|
summary: CLI to access Google calendars
|
|
152
173
|
test_files: []
|