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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bf3465bededbb5ea79788ae636545efa83710701dfad008317dff95db661cfb
4
- data.tar.gz: 9ba431d04731b33bff46b4e6abf19e36f167d9750faf54eb547bf92696664d4c
3
+ metadata.gz: 61b9a15ce2341c56bbac3c59bd465e02a13082e817a367cc19467dc88e874b76
4
+ data.tar.gz: d6cf5b95459a56289a5e9d8b22c706be82f85cc51ddcb42dbcb30b4c7b0b1e66
5
5
  SHA512:
6
- metadata.gz: 2d705bd0ae05ace6e411d6d798d27c80b474d06c5ebc1a9a89ed5adec53c61d2cb928c58d63429b27708ceb3dfef19d439fa96433317e55a983835aa66b41dcc
7
- data.tar.gz: ba81a344d0e36865120a84174ade7532bac11bcf86e767e111dcb128196f34fc99adeae2623496dcb1e8e3bc26061d8e6d087ebfe7e886f806f53896b080df49
6
+ metadata.gz: 58585691471f02638b1490300ce22a722bad34bcb3e53f2069975697385d8e4cb313b7311fd68dbbdd1fbfc38bcd77ee1f95a600f5c2b56394c7317e70b9b029
7
+ data.tar.gz: 6b2b9fd98a5e629b27e4117ba135c72de9e348cfbba04b494b68b3291554b29b3a5c7233d32a330de4879da213870a17bb537d7e3f23fd39a81219607a8b501c
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # flycal-cli
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/flycal-cli.svg)](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
- | Option | Short | Description |
82
- |--------|-------|-------------|
83
- | `--from` | `-f` | Start date/time. Default: midnight of current day. Format: `2025-01-01` or `2025-01-01T09:00` |
84
- | `--to` | `-t` | End date/time. Default: 23:59 of the 30th day from today. Format: same as `--from` |
85
- | `--in` | `-i` | Duration from `--from`, overrides `--to`. Format: `30days`, `48hours`, `2months`, `1year` (no space between number and unit). With space, use quotes: `--in "30 days"` |
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) |
@@ -0,0 +1,6 @@
1
+ locale: en
2
+ slots:
3
+ workhours:
4
+ - 9:30-13:00
5
+ - 14:00-18:30
6
+ weekdays-only: true
@@ -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("%a %Y-%m-%d %H:%M")
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("%a %Y-%m-%d")
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 = current.strftime("%B %Y")
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)
@@ -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
- return {} unless File.exist?(CONFIG_FILE)
16
+ user_data = if File.exist?(CONFIG_FILE)
17
+ YAML.load_file(CONFIG_FILE) || {}
18
+ else
19
+ {}
20
+ end
16
21
 
17
- YAML.load_file(CONFIG_FILE) || {}
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlycalCli
4
- VERSION = '0.3.2'
4
+ VERSION = '0.4.1'
5
5
  end
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.3.2
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.6
170
+ rubygems_version: 4.0.17
150
171
  specification_version: 4
151
172
  summary: CLI to access Google calendars
152
173
  test_files: []