flycal-cli 0.4.1 → 0.6.0

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: 61b9a15ce2341c56bbac3c59bd465e02a13082e817a367cc19467dc88e874b76
4
- data.tar.gz: d6cf5b95459a56289a5e9d8b22c706be82f85cc51ddcb42dbcb30b4c7b0b1e66
3
+ metadata.gz: d3dde0dd31d735707a1fc4e9f1e125c2f8a189749e3a3458ba3ff5e54eb9b47b
4
+ data.tar.gz: edc5fcb47573491a2b23ca784bd263a6f920c60cbdb497bc2cad9e28fca6e0f8
5
5
  SHA512:
6
- metadata.gz: 58585691471f02638b1490300ce22a722bad34bcb3e53f2069975697385d8e4cb313b7311fd68dbbdd1fbfc38bcd77ee1f95a600f5c2b56394c7317e70b9b029
7
- data.tar.gz: 6b2b9fd98a5e629b27e4117ba135c72de9e348cfbba04b494b68b3291554b29b3a5c7233d32a330de4879da213870a17bb537d7e3f23fd39a81219607a8b501c
6
+ metadata.gz: 0f86a8a043423dab082ba55e203ab87d8e04053e4aa667fd0db3af829afe7296a528af8f7be895a69b8dce851d3e515538771ee50d13ad01d0ae073a775cd597
7
+ data.tar.gz: 48149823ec2ce21517a1c9479b4c9579390a699ece675813cf3e4cccadfb5693eb4ee5886c02c45410353afd4fa8b1d0f100ce68b536757e6f6eb99143d62b49
data/README.md CHANGED
@@ -54,7 +54,7 @@ Connect to your Google account. Opens a browser for OAuth authentication when no
54
54
  flycal login
55
55
  ```
56
56
 
57
- If already connected, the command reports the current status and suggests running `flycal calendars` to set the default calendar.
57
+ If already connected, the command reports the current status and suggests running `flycal config` to set the default calendar.
58
58
 
59
59
  ### logout
60
60
 
@@ -84,12 +84,35 @@ flycal -v
84
84
 
85
85
  ### calendars
86
86
 
87
- List available calendars and set the default one. Uses an interactive scrollable menu (arrow keys to navigate, type to filter).
87
+ List available calendars with name and ID.
88
88
 
89
89
  ```bash
90
90
  flycal calendars
91
91
  ```
92
92
 
93
+ Example output:
94
+
95
+ ```
96
+ Work user@example.com
97
+ Personal user@gmail.com
98
+ ```
99
+
100
+ ### config
101
+
102
+ Interactive configuration menu.
103
+
104
+ ```bash
105
+ flycal config
106
+ ```
107
+
108
+ Options:
109
+
110
+ - `calendar_default` — choose the default calendar and save it to `~/.flycal/config.yml`
111
+ - `exclude_calendars` — multi-select one or more calendars whose events block free slots (default selection includes the current default calendar)
112
+ - `edit config` — open `~/.flycal/config.yml` with `$EDITOR` (or `vi`)
113
+
114
+ Press Ctrl+C during configuration to cancel without error (`config cancelled...`).
115
+
93
116
  The default calendar is used by the search command when no calendar is specified.
94
117
 
95
118
  ### search
@@ -105,10 +128,10 @@ flycal search -i 2months -d placeholder
105
128
 
106
129
  **Options:**
107
130
 
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`
131
+ - `--from` / `-f` — Start date/time. Default: midnight of current day. Formats: `YYYY-MM-DD`, locale forms (`DD-MM-YYYY` for `it`, `MM-DD-YYYY` for `en`, `/` or `-`), or with time `YYYY-MM-DDTHH:MM`
132
+ - `--to` / `-t` — End date/time. Default: 23:59 of the 30th day from today. Same formats as `--from`
110
133
  - `--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`
134
+ - `--calendar` / `-c` — Calendar name or ID. Default: calendar set via `flycal config`
112
135
  - `--description` / `-d` — Filter events by text. Matches events where the string appears in title or description (case-insensitive, contains)
113
136
 
114
137
  **Time range behavior:**
@@ -130,36 +153,72 @@ For time frames longer than 7 days, a weekly breakdown is added (week number, st
130
153
 
131
154
  ### slots
132
155
 
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.
156
+ Find free time slots in your calendar. Output is a simple list for copy/paste into email or other tools.
134
157
 
135
158
  ```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
159
+ flycal slots
160
+ flycal slots --duration 1h
161
+ flycal slots --from 2026-08-01 --in "2 weeks" --duration 1h
162
+ flycal slots --from 01/08/2026 --in 3days --duration 30min
163
+ flycal slots --in 1week -c Work
139
164
  ```
140
165
 
141
166
  **Options:**
142
167
 
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)
168
+ - `--duration` — Slot length. Default: `slots.defaults.default_duration` in config (`45min`). Examples: `1h`, `30 minutes`
169
+ - `--from` / `-f` — Start date/time. Default: `slots.defaults.from` in config (`now`). If a date without time is today, starts from the current time.
170
+ - `--in` / `-i` — Search window from `--from`. Default: `1 week`.
171
+ - `--calendar` / `-c` — Calendar name or ID used as fallback when `exclude_calendars` is not configured
172
+
173
+ **Output:**
174
+
175
+ Always starts with a header and clickable calendar links (OSC 8), then the availability list:
176
+
177
+ ```
178
+ found 3 slots
179
+ from wed 29 July 2026 to wed 5 August 2026
180
+ with duration 45min
181
+ for calendars
182
+ Incode - Antonio
183
+ Personal
184
+ https://calendar.google.com/calendar/r/day/2026/7/29
185
+
186
+ friday 15/7
187
+ 10-12
188
+ 13-15.30
189
+ ```
190
+
191
+ If `pbcopy` is available (macOS), the slot list without the header is also copied to the clipboard.
146
192
 
147
193
  Slot search windows are configured in `~/.flycal/config.yml`:
148
194
 
149
195
  ```yaml
150
196
  slots:
151
- workhours:
197
+ defaults:
198
+ from: now
199
+ default_duration: 45min
200
+ free_before: 0m
201
+ free_after: 15m
202
+ exclude_calendars:
203
+ - user@example.com
204
+ - user@gmail.com
205
+ hours:
152
206
  - 9:30-13:00
153
207
  - 14:00-18:30
154
- weekdays-only: true
208
+ weekdays_only: true
155
209
  locale: en
156
210
  ```
157
211
 
158
212
  Missing keys are filled from `config/defaults.yml` in the gem and saved to your `config.yml` on first read.
159
213
 
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
214
+ - `defaults.from` default `--from` for slots (`now` or a date string)
215
+ - `defaults.default_duration` default slot length when `--duration` is omitted
216
+ - `free_before` buffer before each slot; busy intervals are extended backward so no event may fall in this window before a slot
217
+ - `free_after` — buffer after each slot; gaps must fit `duration + free_after`, but output shows slots of `duration` only
218
+ - `exclude_calendars` lists calendars whose events block free slots; if empty or not set, events from `calendar_default` are used
219
+ - `hours` accepts one or more ranges (`H-H`, `HH:MM-HH:MM`, mixed)
220
+ - `weekdays_only: true` limits slots to Monday-Friday
221
+ - `weekdays_only: false` includes weekends
163
222
  - `locale` supports `en` and `it` (default is `en`)
164
223
 
165
224
  **Output example:**
data/config/defaults.yml CHANGED
@@ -1,6 +1,12 @@
1
1
  locale: en
2
2
  slots:
3
- workhours:
3
+ defaults:
4
+ from: now
5
+ default_duration: 45min
6
+ free_before: 0m
7
+ free_after: 15m
8
+ exclude_calendars: []
9
+ hours:
4
10
  - 9:30-13:00
5
11
  - 14:00-18:30
6
- weekdays-only: true
12
+ weekdays_only: true
@@ -23,7 +23,7 @@ module FlycalCli
23
23
  apply_locale_override
24
24
  if Auth.logged_in?
25
25
  puts "✓ You are already connected to your Google account."
26
- puts "\nRun 'flycal calendars' to set the default calendar."
26
+ puts "\nRun 'flycal config' to set the default calendar."
27
27
  return
28
28
  end
29
29
 
@@ -41,7 +41,7 @@ module FlycalCli
41
41
  begin
42
42
  Auth.login
43
43
  puts "\n✓ Authentication completed successfully!"
44
- puts "\nRun 'flycal calendars' to set the default calendar."
44
+ puts "\nRun 'flycal config' to set the default calendar."
45
45
  rescue FlycalCli::Error => e
46
46
  puts "Error: #{e.message}"
47
47
  exit 1
@@ -60,49 +60,51 @@ module FlycalCli
60
60
  puts "✓ Disconnected successfully."
61
61
  end
62
62
 
63
- desc "calendars", "List available calendars and set the default one"
63
+ desc "calendars", "List available calendars"
64
64
  def calendars
65
65
  apply_locale_override
66
66
  unless Auth.logged_in?
67
- puts "You are not connected. Run 'flycal login' first."
67
+ puts Locale.t("errors.not_connected")
68
68
  exit 1
69
69
  end
70
70
 
71
- creds = Auth.credentials
72
- service = CalendarService.new(creds)
73
-
74
- spinner = TTY::Spinner.new("Loading calendars... ", format: :dots)
75
- spinner.auto_spin
76
- calendars = service.list_calendars
77
- spinner.stop("✓")
71
+ calendars = load_calendars
72
+ return if calendars.nil?
78
73
 
79
- if calendars.empty?
80
- puts "No calendars found."
81
- return
82
- end
83
-
84
- # Build selection list (display => calendar_id)
85
- default_id = Config.calendar_default
86
- choices = calendars.to_h do |cal|
87
- primary = cal.primary ? " (primary)" : ""
88
- default = (cal.id == default_id) ? " [default]" : ""
74
+ calendars.each do |cal|
89
75
  summary = cal.summary || cal.id
90
- label = "#{summary}#{primary}#{default}"
91
- [label, cal.id]
76
+ puts "#{summary} #{cal.id}"
92
77
  end
78
+ end
93
79
 
94
- prompt = TTY::Prompt.new
95
- selected_id = prompt.select(
96
- "Choose the default calendar:",
97
- choices,
98
- per_page: 15,
99
- filter: true
100
- )
80
+ desc "config", "Configure flycal settings"
81
+ def config
82
+ apply_locale_override
83
+ unless Auth.logged_in?
84
+ puts Locale.t("errors.not_connected")
85
+ exit 1
86
+ end
101
87
 
102
- calendar = calendars.find { |c| c.id == selected_id }
103
- if calendar
104
- Config.calendar_default = calendar.id
105
- puts "\n✓ Default calendar set to: #{calendar.summary}"
88
+ with_config_interrupt_handling do
89
+ prompt = TTY::Prompt.new
90
+ choice = prompt.select(
91
+ Locale.t("config.prompt"),
92
+ {
93
+ Locale.t("config.options.calendar_default") => :calendar_default,
94
+ Locale.t("config.options.exclude_calendars") => :exclude_calendars,
95
+ Locale.t("config.options.edit_config") => :edit_config
96
+ },
97
+ per_page: 10
98
+ )
99
+
100
+ case choice
101
+ when :calendar_default
102
+ config_calendar_default
103
+ when :exclude_calendars
104
+ config_exclude_calendars
105
+ when :edit_config
106
+ config_edit
107
+ end
106
108
  end
107
109
  end
108
110
 
@@ -135,12 +137,12 @@ module FlycalCli
135
137
  exit 1
136
138
  end
137
139
 
138
- time_min = options[:from].to_s.empty? ? Date.today.to_time : parse_datetime(options[:from])
140
+ time_min = options[:from].to_s.empty? ? Date.today.to_time : DateTimeParser.parse(options[:from])
139
141
  begin
140
142
  time_max = if options[:in].to_s.empty?
141
- options[:to].to_s.empty? ? (Date.today + 30).to_time + 86400 - 1 : parse_datetime(options[:to], end_of_day: true)
143
+ options[:to].to_s.empty? ? (Date.today + 30).to_time + 86400 - 1 : DateTimeParser.parse(options[:to], end_of_day: true)
142
144
  else
143
- parse_duration_in(options[:in], time_min)
145
+ DurationParser.add_to_time(options[:in], time_min)
144
146
  end
145
147
  rescue FlycalCli::Error => e
146
148
  puts "Error: #{e.message}"
@@ -176,14 +178,22 @@ module FlycalCli
176
178
  long_desc <<-LONGDESC
177
179
  List free time slots long enough for a given duration.
178
180
 
181
+ Defaults:
182
+ --from from config (default: now)
183
+ --in 1 week
184
+ --duration from config (default: 45min)
185
+
179
186
  Examples:
180
- flycal slots --in "3 days" --duration 1h
181
- flycal slots --in 1week --duration 30min -c Work
187
+ flycal slots
188
+ flycal slots --duration 1h
189
+ flycal slots --from 2026-08-01 --in "2 weeks" --duration 1h
182
190
  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)"
191
+ option :duration, type: :string,
192
+ desc: "Slot length (default from config: slots.defaults.default_duration)"
193
+ option :in, type: :string, aliases: "-i", default: "1 week",
194
+ desc: "Search window from --from (default: 1 week)"
195
+ option :from, type: :string, aliases: "-f",
196
+ desc: "Start date/time (default from config: slots.defaults.from)"
187
197
  option :calendar, type: :string, aliases: "-c", desc: "Calendar name or ID"
188
198
  def slots
189
199
  apply_locale_override
@@ -192,13 +202,27 @@ module FlycalCli
192
202
  exit 1
193
203
  end
194
204
 
205
+ slot_cfg = Config.slots_config
206
+ defaults = slot_cfg.fetch("defaults", {})
207
+ in_value = options[:in].to_s.strip
208
+ in_value = "1 week" if in_value.empty?
209
+
210
+ duration_value = options[:duration].to_s.strip
211
+ duration_value = defaults["default_duration"].to_s.strip if duration_value.empty?
212
+ duration_value = "45min" if duration_value.empty?
213
+
214
+ from_value = options[:from].to_s.strip
215
+ from_value = defaults["from"].to_s.strip if from_value.empty?
216
+ from_value = "now" if from_value.empty?
217
+
195
218
  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"]
219
+ slot_duration = DurationParser.to_seconds(duration_value)
220
+ free_before = DurationParser.to_seconds(slot_cfg["free_before"] || "0m")
221
+ free_after = DurationParser.to_seconds(slot_cfg["free_after"] || "0m")
222
+ time_min = parse_slots_from(from_value)
223
+ time_max = DurationParser.add_to_time(in_value, time_min)
224
+ workhours = parse_workhours(slot_cfg["hours"])
225
+ weekdays_only = !!slot_cfg["weekdays_only"]
202
226
  rescue FlycalCli::Error => e
203
227
  puts "Error: #{e.message}"
204
228
  exit 1
@@ -212,17 +236,30 @@ module FlycalCli
212
236
  creds = Auth.credentials
213
237
  service = CalendarService.new(creds)
214
238
 
215
- calendar_id = resolve_single_calendar_id(service, options[:calendar])
216
- if calendar_id.nil?
239
+ exclude_calendar_ids = resolve_slots_exclude_calendar_ids(service, slot_cfg, options[:calendar])
240
+ if exclude_calendar_ids.empty?
217
241
  puts Locale.t("slots.no_calendar")
218
242
  exit 1
219
243
  end
220
244
 
221
- events = service.list_events(
222
- calendar_id,
223
- time_min: time_min,
224
- time_max: time_max
225
- )
245
+ calendars = service.list_calendars
246
+ calendar_meta = exclude_calendar_ids.filter_map do |id|
247
+ cal = calendars.find { |c| c.id == id }
248
+ name = cal&.summary || id
249
+ { id: id, name: name }
250
+ end
251
+
252
+ fetch_from = time_min - free_before
253
+ events = exclude_calendar_ids.flat_map do |calendar_id|
254
+ service.list_events(
255
+ calendar_id,
256
+ time_min: fetch_from,
257
+ time_max: time_max
258
+ )
259
+ rescue Google::Apis::Errors::Error => e
260
+ warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
261
+ []
262
+ end
226
263
 
227
264
  finder = SlotFinder.new(
228
265
  events: events,
@@ -230,11 +267,28 @@ module FlycalCli
230
267
  time_max: time_max,
231
268
  slot_duration_seconds: slot_duration,
232
269
  workhours: workhours,
233
- weekdays_only: weekdays_only
270
+ weekdays_only: weekdays_only,
271
+ free_before_seconds: free_before,
272
+ free_after_seconds: free_after
234
273
  )
235
274
 
236
- output = SlotFormatter.format_output(finder.slots_by_day)
237
- puts output.empty? ? Locale.t("slots.no_available") : output
275
+ slots_by_day = finder.slots_by_day
276
+ slot_count = slots_by_day.values.sum(&:size)
277
+ puts SlotFormatter.format_header(
278
+ from: time_min,
279
+ to: time_max,
280
+ duration: duration_value,
281
+ calendars: calendar_meta,
282
+ count: slot_count
283
+ )
284
+ puts ""
285
+ output = SlotFormatter.format_output(slots_by_day)
286
+ if output.empty?
287
+ puts Locale.t("slots.no_available")
288
+ else
289
+ puts output
290
+ copy_slots_to_clipboard(output)
291
+ end
238
292
  end
239
293
 
240
294
  desc "update", "Update flycal-cli to the latest gem version"
@@ -264,44 +318,174 @@ module FlycalCli
264
318
  "\e[1m#{str}\e[0m"
265
319
  end
266
320
 
321
+ def bold_underline(str)
322
+ "\e[1;4m#{str}\e[0m"
323
+ end
324
+
267
325
  def apply_locale_override
268
326
  Locale.override!(options[:locale])
269
327
  end
270
328
 
271
- def parse_datetime(str, end_of_day: false)
272
- return nil if str.nil? || str.empty?
329
+ def with_config_interrupt_handling
330
+ yield
331
+ rescue Interrupt
332
+ puts "\nconfig cancelled..."
333
+ exit 0
334
+ end
273
335
 
274
- if str.include?("T")
275
- Time.parse(str)
276
- else
277
- d = Date.parse(str)
278
- t = d.to_time
279
- end_of_day ? t + 86400 - 1 : t # 23:59:59 for --to when date-only
336
+ def load_calendars
337
+ creds = Auth.credentials
338
+ service = CalendarService.new(creds)
339
+
340
+ spinner = TTY::Spinner.new("#{Locale.t('common.loading_calendars')} ", format: :dots)
341
+ spinner.auto_spin
342
+ calendars = service.list_calendars
343
+ spinner.stop("✓")
344
+
345
+ if calendars.empty?
346
+ puts Locale.t("errors.no_calendars")
347
+ return nil
348
+ end
349
+
350
+ calendars
351
+ end
352
+
353
+ def calendar_choices(calendars, highlight_ids: [])
354
+ highlights = Array(highlight_ids).compact
355
+ calendars.to_h do |cal|
356
+ primary = cal.primary ? " (primary)" : ""
357
+ summary = cal.summary || cal.id
358
+ label = "#{summary}#{primary}"
359
+ label = bold_underline(label) if highlights.include?(cal.id)
360
+ [label, cal.id]
361
+ end
362
+ end
363
+
364
+ def choice_labels_for_ids(choices, calendar_ids)
365
+ ids = Array(calendar_ids).compact
366
+ choices.select { |_label, id| ids.include?(id) }.keys
367
+ end
368
+
369
+ def config_calendar_default
370
+ calendars = load_calendars
371
+ return if calendars.nil?
372
+
373
+ prompt = TTY::Prompt.new
374
+ default_id = Config.calendar_default
375
+ selected_id = prompt.select(
376
+ Locale.t("config.calendar_default.prompt"),
377
+ calendar_choices(calendars, highlight_ids: [default_id]),
378
+ per_page: 15,
379
+ filter: true
380
+ )
381
+
382
+ calendar = calendars.find { |c| c.id == selected_id }
383
+ return unless calendar
384
+
385
+ Config.calendar_default = calendar.id
386
+ puts Locale.t("config.calendar_default.saved", name: calendar.summary || calendar.id)
387
+ end
388
+
389
+ def config_exclude_calendars
390
+ calendars = load_calendars
391
+ return if calendars.nil?
392
+
393
+ prompt = TTY::Prompt.new
394
+ default_id = Config.calendar_default
395
+ configured = Config.exclude_calendars
396
+ current_ids = resolve_calendar_refs(calendars, configured)
397
+ preselected_ids = current_ids.empty? && default_id ? [default_id] : current_ids
398
+ choices = calendar_choices(calendars, highlight_ids: current_ids)
399
+ preselected_labels = choice_labels_for_ids(choices, preselected_ids)
400
+
401
+ selected_ids = prompt.multi_select(
402
+ Locale.t("config.exclude_calendars.prompt"),
403
+ choices,
404
+ default: preselected_labels,
405
+ min: 1,
406
+ per_page: 15,
407
+ filter: true
408
+ )
409
+
410
+ Config.exclude_calendars = selected_ids
411
+ names = selected_ids.map do |id|
412
+ calendars.find { |c| c.id == id }&.summary || id
280
413
  end
414
+ puts Locale.t("config.exclude_calendars.saved", names: names.join(", "))
281
415
  end
282
416
 
283
- def parse_duration_in(str, from_time)
284
- # Support both "30days" and "30 days" (no space avoids shell splitting)
285
- m = str.to_s.strip.match(/\A(\d+)\s*(day|days|d|hour|hours|h|month|months|m|year|years|y)\z/i)
286
- raise FlycalCli::Error, "Invalid --in format. Use: 30days, 48hours, 2months, 1year (no space, or quote: --in \"30 days\")" unless m
417
+ def config_edit
418
+ editor = ENV["EDITOR"].to_s.strip
419
+ editor = "vi" if editor.empty?
287
420
 
288
- n = m[1].to_i
289
- unit = m[2].downcase
421
+ success = system(editor, Config.config_file)
422
+ puts Locale.t("config.edit.failed", editor: editor) unless success
423
+ end
424
+
425
+ def resolve_calendar_refs(calendars, refs)
426
+ Array(refs).filter_map do |ref|
427
+ resolve_calendar_ref(calendars, ref)
428
+ end.uniq
429
+ end
430
+
431
+ def resolve_calendar_ref(calendars, ref)
432
+ ref = ref.to_s.strip
433
+ return nil if ref.empty?
290
434
 
291
- case unit
292
- when "hour", "hours", "h"
293
- from_time + n * 3600
294
- when "day", "days", "d"
295
- from_time + n * 86400
296
- when "month", "months", "m"
297
- (from_time.to_date >> n).to_time + (from_time.hour * 3600 + from_time.min * 60 + from_time.sec)
298
- when "year", "years", "y"
299
- (from_time.to_date >> (n * 12)).to_time + (from_time.hour * 3600 + from_time.min * 60 + from_time.sec)
435
+ exact = calendars.find { |cal| cal.id == ref }
436
+ return exact.id if exact
437
+
438
+ matches = calendars.select do |cal|
439
+ cal.id == ref ||
440
+ (cal.summary && cal.summary.downcase.include?(ref.downcase))
441
+ end
442
+
443
+ matches.first&.id
444
+ end
445
+
446
+ def resolve_slots_exclude_calendar_ids(service, slot_cfg, calendar_override)
447
+ configured = slot_cfg["exclude_calendars"]
448
+ calendars = service.list_calendars
449
+
450
+ refs = if configured.nil? || (configured.is_a?(Array) && configured.empty?)
451
+ fallback = calendar_override || Config.calendar_default
452
+ fallback ? [fallback] : []
453
+ else
454
+ configured
455
+ end
456
+
457
+ ids = resolve_calendar_refs(calendars, refs)
458
+ return ids unless ids.empty?
459
+
460
+ fallback = calendar_override || Config.calendar_default
461
+ if fallback
462
+ resolve_calendar_refs(calendars, [fallback])
300
463
  else
301
- raise FlycalCli::Error, "Invalid unit: #{unit}. Use: days, hours, months, years"
464
+ primary = calendars.find(&:primary)
465
+ primary ? [primary.id] : calendars.first(1).map(&:id)
302
466
  end
303
467
  end
304
468
 
469
+ def parse_slots_from(value)
470
+ return Time.now if value.to_s.strip.downcase == "now"
471
+
472
+ DateTimeParser.parse(value)
473
+ end
474
+
475
+ def copy_slots_to_clipboard(text)
476
+ return unless pbcopy_available?
477
+
478
+ IO.popen("pbcopy", "w") { |io| io.write(text) }
479
+ puts ""
480
+ puts Locale.t("slots.copied_to_clipboard")
481
+ rescue StandardError
482
+ nil
483
+ end
484
+
485
+ def pbcopy_available?
486
+ system("which", "pbcopy", out: File::NULL, err: File::NULL)
487
+ end
488
+
305
489
  def parse_hour_minute(value)
306
490
  match = value.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/)
307
491
  raise FlycalCli::Error, "Invalid time format #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)." unless match
@@ -318,18 +502,18 @@ module FlycalCli
318
502
  def parse_workhours(values)
319
503
  ranges = Array(values).map do |item|
320
504
  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?
505
+ raise FlycalCli::Error, "Invalid hours item #{item.inspect}. Use format like '9-13' or '14:30-18:00'." if start_str.nil? || end_str.nil?
322
506
 
323
507
  sh, sm = parse_hour_minute_flexible(start_str)
324
508
  eh, em = parse_hour_minute_flexible(end_str)
325
509
  start_minutes = (sh * 60) + sm
326
510
  end_minutes = (eh * 60) + em
327
- raise FlycalCli::Error, "Invalid workhours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
511
+ raise FlycalCli::Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
328
512
 
329
513
  [sh, sm, eh, em]
330
514
  end
331
515
 
332
- raise FlycalCli::Error, "slots.workhours cannot be empty in config.yml." if ranges.empty?
516
+ raise FlycalCli::Error, "slots.hours cannot be empty in config.yml." if ranges.empty?
333
517
 
334
518
  ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
335
519
  end
@@ -371,22 +555,6 @@ module FlycalCli
371
555
  matches.map(&:id)
372
556
  end
373
557
 
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
-
390
558
  def print_events(service, events)
391
559
  # Use calendar list for names (avoids extra API calls)
392
560
  calendar_list = service.list_calendars