flycal-cli 0.7.4 → 1.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -174
  3. data/bin/flycal +2 -2
  4. data/lib/flycal/cache_annotator.rb +83 -0
  5. data/lib/flycal/cache_key.rb +82 -0
  6. data/lib/flycal/calendar_query.rb +60 -0
  7. data/lib/flycal/calendar_service.rb +115 -0
  8. data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +320 -250
  9. data/lib/{flycal_cli → flycal/cli}/auth.rb +5 -3
  10. data/lib/{flycal_cli → flycal/cli}/clipboard.rb +3 -1
  11. data/lib/{flycal_cli → flycal/cli}/config.rb +4 -2
  12. data/lib/flycal/cli/file_cache.rb +44 -0
  13. data/lib/flycal/cli/locale.rb +58 -0
  14. data/lib/flycal/cli.rb +14 -0
  15. data/lib/flycal/client.rb +186 -0
  16. data/lib/flycal/credentials.rb +34 -0
  17. data/lib/{flycal_cli → flycal}/date_time_parser.rb +4 -4
  18. data/lib/flycal/description_query.rb +32 -0
  19. data/lib/{flycal_cli → flycal}/duration_parser.rb +3 -3
  20. data/lib/flycal/error.rb +5 -0
  21. data/lib/flycal/event_mapper.rb +58 -0
  22. data/lib/{flycal_cli → flycal}/locale.rb +35 -3
  23. data/lib/flycal/mock/calendar_service.rb +43 -0
  24. data/lib/flycal/mock/config.rb +171 -0
  25. data/lib/flycal/mock/event_generator.rb +71 -0
  26. data/lib/flycal/mock.rb +6 -0
  27. data/lib/flycal/pipeline/aggregator.rb +226 -0
  28. data/lib/flycal/pipeline/json_renderer.rb +145 -0
  29. data/lib/flycal/pipeline/params.rb +46 -0
  30. data/lib/flycal/pipeline/renderer.rb +27 -0
  31. data/lib/flycal/pipeline/retriever.rb +72 -0
  32. data/lib/flycal/pipeline/search_pipeline.rb +18 -0
  33. data/lib/flycal/pipeline/text_renderer.rb +134 -0
  34. data/lib/flycal/pipeline.rb +6 -0
  35. data/lib/{flycal_cli → flycal}/slot_finder.rb +1 -1
  36. data/lib/flycal/slot_formatter.rb +195 -0
  37. data/lib/flycal/version.rb +11 -0
  38. data/lib/flycal.rb +12 -0
  39. data/lib/flycal_cli.rb +37 -14
  40. data/mcp/tools.json +36 -9
  41. data/mockTemplates/mock1.json +11 -0
  42. data/mocks/mock1.json +11 -0
  43. metadata +38 -12
  44. data/lib/flycal_cli/calendar_service.rb +0 -74
  45. data/lib/flycal_cli/slot_formatter.rb +0 -68
  46. data/lib/flycal_cli/version.rb +0 -5
@@ -9,10 +9,13 @@ require "active_support/core_ext/time"
9
9
  require "tty-prompt"
10
10
  require "tty-spinner"
11
11
 
12
- module FlycalCli
13
- class Cli < Thor
12
+ module Flycal
13
+ module Cli
14
+ class App < Thor
14
15
  package_name "flycal"
15
16
  class_option :locale, type: :string, desc: "Override locale for this command (e.g. en, it)"
17
+ class_option :format, type: :string, default: "text",
18
+ desc: "Output format: text or json"
16
19
 
17
20
  def self.exit_on_failure?
18
21
  true
@@ -42,7 +45,7 @@ module FlycalCli
42
45
  Auth.login
43
46
  puts "\n✓ Authentication completed successfully!"
44
47
  puts "\nRun 'flycal config' to set the default calendar."
45
- rescue FlycalCli::Error => e
48
+ rescue Flycal::Error => e
46
49
  puts "Error: #{e.message}"
47
50
  exit 1
48
51
  end
@@ -119,20 +122,62 @@ module FlycalCli
119
122
  Format: 30days, 48hours, 2months, 1year (no space).
120
123
  With space use quotes: --in "30 days"
121
124
 
125
+ Mock mode (no Google API; triggered by --mockCalendar or --mockTemplate):
126
+ --mockTemplate NAME Load defaults from mocks/ or mockTemplates/NAME.json
127
+ --mockCalendar NAME Mock calendar id/name (required here or in template)
128
+ --mockSeed N Reproducible random distribution
129
+ --mockEventCount N
130
+ --mockEventFrom/--mockEventTo
131
+ --mockEventDescriptionPatterns a,b,c
132
+ --mockEventDurationMin/--mockEventDurationMax
133
+ --mockEventHoursFrom/--mockEventHoursTo
134
+
122
135
  Examples:
123
136
  flycal search
124
137
  flycal search --in 30days -d placeholder
125
138
  flycal search -i 1months --description placeholder
126
139
  flycal search -f 2025-03-01 --in 2months
140
+ flycal search --mockTemplate mock1 --description work --calendar mock1
141
+ flycal search --groupBy "rui|solver" --format json
142
+ flycal search -c "Work|Personal"
143
+ flycal search --calendar Work --calendar Personal
127
144
  LONGDESC
128
- option :calendar, type: :string, aliases: "-c", desc: "Calendar name or ID"
145
+ option :calendar, type: :string, aliases: "-c", repeatable: true,
146
+ desc: "Calendar name or ID (repeatable, or pipe-separated: Work|Personal)"
147
+
129
148
  option :from, type: :string, aliases: "-f", desc: "Start (default: today midnight)"
130
149
  option :to, type: :string, aliases: "-t", desc: "End (default: 23:59 of day 30)"
131
150
  option :in, type: :string, aliases: "-i", desc: "Duration: 30days, 48hours, 2months, 1year (overrides --to)"
132
- option :description, type: :string, aliases: "-d", desc: "Filter by text in event"
151
+ option :description, type: :string, aliases: "-d",
152
+ desc: "Filter text in event (OR with |, e.g. rui|solver)"
153
+ option :groupBy, type: :string,
154
+ desc: "Grouping: day, week, month, or a string like rui|solver (default: auto from timeframe)"
155
+ option :mockTemplate, type: :string, desc: "Load mock defaults from mocks/<name>.json"
156
+ option :mockCalendar, type: :string, desc: "Use a generated mock calendar (skips Google API)"
157
+ option :mockSeed, type: :numeric, desc: "Seed for reproducible mock events"
158
+ option :mockEventDescriptionPatterns, type: :string, desc: "Comma-separated title patterns (e.g. work,personal)"
159
+ option :mockEventCount, type: :numeric, desc: "Number of mock events to generate"
160
+ option :mockEventFrom, type: :string, desc: "Mock events start date"
161
+ option :mockEventTo, type: :string, desc: "Mock events end date"
162
+ option :mockEventDurationMin, type: :string, desc: "Min mock event duration (e.g. 4h)"
163
+ option :mockEventDurationMax, type: :string, desc: "Max mock event duration (e.g. 4h)"
164
+ option :mockEventHoursFrom, type: :string, desc: "Daily start window for mock events (HH:MM)"
165
+ option :mockEventHoursTo, type: :string, desc: "Daily end window for mock events (HH:MM)"
166
+ option :nocache, type: :boolean, default: false,
167
+ desc: "Bypass cache and refresh from Google"
133
168
  def search
134
169
  apply_locale_override
135
- unless Auth.logged_in?
170
+
171
+ mock_mode = Mock::Config.enabled?(options)
172
+ mock_config = nil
173
+ if mock_mode
174
+ begin
175
+ mock_config = Mock::Config.from_options(options)
176
+ rescue Flycal::Error => e
177
+ puts "Error: #{e.message}"
178
+ exit 1
179
+ end
180
+ elsif !Auth.logged_in?
136
181
  puts "You are not connected. Run 'flycal login' first."
137
182
  exit 1
138
183
  end
@@ -144,7 +189,7 @@ module FlycalCli
144
189
  else
145
190
  DurationParser.add_to_time(options[:in], time_min)
146
191
  end
147
- rescue FlycalCli::Error => e
192
+ rescue Flycal::Error => e
148
193
  puts "Error: #{e.message}"
149
194
  exit 1
150
195
  end
@@ -154,24 +199,89 @@ module FlycalCli
154
199
  exit 1
155
200
  end
156
201
 
157
- creds = Auth.credentials
158
- service = CalendarService.new(creds)
202
+ service =
203
+ if mock_config
204
+ Mock::CalendarService.new(mock_config)
205
+ else
206
+ flycal_client.calendar
207
+ end
159
208
 
160
- calendar_ids = resolve_calendar_ids(service, options[:calendar])
209
+ calendar_ids =
210
+ if mock_config && options[:calendar].to_s.empty?
211
+ [mock_config.calendar_name]
212
+ else
213
+ resolve_calendar_ids(service, options[:calendar])
214
+ end
161
215
  if calendar_ids.empty?
162
216
  puts "No calendars found."
163
217
  exit 1
164
218
  end
165
219
 
166
- events = service.list_all_events(
167
- calendar_ids,
220
+ params = Pipeline::Params.new(
221
+ command: "search",
168
222
  time_min: time_min,
169
223
  time_max: time_max,
170
- query: options[:description]
224
+ calendar_ids: calendar_ids,
225
+ description: options[:description],
226
+ calendar: options[:calendar],
227
+ from_option: options[:from],
228
+ to_option: options[:to],
229
+ in_option: options[:in],
230
+ group_by_option: options[:groupBy],
231
+ format: options[:format] || "text",
232
+ locale: Locale.current_locale,
233
+ use_mock: !mock_config.nil?,
234
+ mock_seed: mock_config&.seed,
235
+ mock_calendar: mock_config&.calendar_name,
236
+ mock_template: mock_config&.template_name
171
237
  )
172
238
 
173
- print_events(service, events)
174
- print_search_summary(events, time_min: time_min, time_max: time_max)
239
+ fingerprint = {
240
+ command: "search",
241
+ time_min: time_min,
242
+ time_max: time_max,
243
+ calendar_ids: calendar_ids,
244
+ description: options[:description],
245
+ calendar: options[:calendar],
246
+ from_option: options[:from],
247
+ to_option: options[:to],
248
+ in_option: options[:in],
249
+ group_by_option: options[:groupBy],
250
+ format: options[:format] || "text",
251
+ locale: Locale.current_locale,
252
+ use_mock: !mock_config.nil?,
253
+ mock_seed: mock_config&.seed,
254
+ mock_calendar: mock_config&.calendar_name,
255
+ mock_template: mock_config&.template_name
256
+ }
257
+ cache_key = Flycal::CacheKey.generate("search", fingerprint)
258
+ ttl = Flycal::CacheKey::DEFAULT_TTL_MINUTES
259
+ nocache = options[:nocache]
260
+
261
+ begin
262
+ output = nil
263
+ status = nil
264
+
265
+ unless nocache
266
+ cached = FileCache.read(cache_key, ttl_minutes: ttl)
267
+ if cached
268
+ output = cached
269
+ status = "HIT"
270
+ end
271
+ end
272
+
273
+ if output.nil?
274
+ FileCache.delete(cache_key) if nocache
275
+ output = Pipeline::SearchPipeline.new(service).run(params)
276
+ FileCache.write(cache_key, strip_cache_marker_from_output(output, params[:format]))
277
+ status = "MISS"
278
+ end
279
+
280
+ print annotate_cli_output(output, status, cache_key, params[:format])
281
+ rescue Flycal::Error => e
282
+ puts "Error: #{e.message}"
283
+ exit 1
284
+ end
175
285
  end
176
286
 
177
287
  desc "slots", "Find available time slots in your calendar"
@@ -198,7 +308,11 @@ module FlycalCli
198
308
  desc: "Start (default: now). Dates, or relative: monday, next monday, tomorrow, lunedi..."
199
309
  option :template, type: :string, aliases: "-T",
200
310
  desc: "Slot template name from config (default: first template, usually work)"
201
- option :calendar, type: :string, aliases: "-c", desc: "Calendar name or ID"
311
+ option :calendar, type: :string, aliases: "-c", repeatable: true,
312
+ desc: "Calendar name or ID (repeatable, or pipe-separated: Work|Personal)"
313
+ option :nocache, type: :boolean, default: false,
314
+ desc: "Bypass cache and refresh from Google"
315
+
202
316
  def slots
203
317
  apply_locale_override
204
318
  unless Auth.logged_in?
@@ -228,7 +342,7 @@ module FlycalCli
228
342
  time_max = DurationParser.add_to_time(in_value, time_min)
229
343
  hours = parse_workhours(template["hours"])
230
344
  days = parse_template_days(template["days"])
231
- rescue FlycalCli::Error => e
345
+ rescue Flycal::Error => e
232
346
  puts "Error: #{e.message}"
233
347
  exit 1
234
348
  end
@@ -238,8 +352,8 @@ module FlycalCli
238
352
  exit 1
239
353
  end
240
354
 
241
- creds = Auth.credentials
242
- service = CalendarService.new(creds)
355
+ client = flycal_client
356
+ service = client.calendar
243
357
 
244
358
  exclude_calendar_ids = resolve_slots_exclude_calendar_ids(service, slot_cfg, options[:calendar])
245
359
  if exclude_calendar_ids.empty?
@@ -247,53 +361,118 @@ module FlycalCli
247
361
  exit 1
248
362
  end
249
363
 
250
- calendars = service.list_calendars
251
- calendar_meta = exclude_calendar_ids.filter_map do |id|
252
- cal = calendars.find { |c| c.id == id }
253
- name = cal&.summary || id
254
- { id: id, name: name }
255
- end
256
-
257
- fetch_from = time_min - free_before
258
- events = exclude_calendar_ids.flat_map do |calendar_id|
259
- service.list_events(
260
- calendar_id,
261
- time_min: fetch_from,
262
- time_max: time_max
263
- )
264
- rescue Google::Apis::Errors::Error => e
265
- warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
266
- []
364
+ format = (options[:format] || "text").to_s.strip.downcase
365
+ format = "text" if format.empty?
366
+
367
+ unless %w[json text].include?(format)
368
+ puts "Error: Unsupported format #{format.inspect}. Available: text, json"
369
+ exit 1
267
370
  end
268
371
 
269
- finder = SlotFinder.new(
270
- events: events,
271
- time_min: time_min,
272
- time_max: time_max,
273
- slot_duration_seconds: slot_duration,
372
+ fingerprint = {
373
+ command: "slots",
374
+ calendar_ids: exclude_calendar_ids,
375
+ duration: duration_value,
376
+ from: from_value,
377
+ in: in_value,
378
+ free_before: slot_cfg["free_before"] || "0m",
379
+ free_after: slot_cfg["free_after"] || "0m",
380
+ template: template_name,
381
+ locale: Locale.current_locale,
382
+ calendar: options[:calendar],
274
383
  hours: hours,
275
384
  days: days,
276
- free_before_seconds: free_before,
277
- free_after_seconds: free_after
278
- )
385
+ time_min: time_min,
386
+ time_max: time_max,
387
+ format: format
388
+ }
389
+ cache_key = Flycal::CacheKey.generate("slots", fingerprint)
390
+ ttl = Flycal::CacheKey::DEFAULT_TTL_MINUTES
391
+ nocache = options[:nocache]
392
+
393
+ output = nil
394
+ status = nil
395
+ unless nocache
396
+ cached = FileCache.read(cache_key, ttl_minutes: ttl)
397
+ if cached
398
+ output = cached
399
+ status = "HIT"
400
+ end
401
+ end
279
402
 
280
- slots_by_day = finder.slots_by_day
281
- slot_count = slots_by_day.values.sum(&:size)
282
- puts SlotFormatter.format_header(
283
- from: time_min,
284
- to: time_max,
285
- duration: duration_value,
286
- calendars: calendar_meta,
287
- count: slot_count,
288
- template: template_name
289
- )
290
- puts ""
291
- output = SlotFormatter.format_output(slots_by_day)
292
- if output.empty?
293
- puts Locale.t("slots.no_available")
294
- else
295
- puts output
296
- copy_slots_to_clipboard(output)
403
+ if output.nil?
404
+ FileCache.delete(cache_key) if nocache
405
+
406
+ calendars = client.list_calendars
407
+ calendar_meta = exclude_calendar_ids.filter_map do |id|
408
+ cal = calendars.find { |c| c.id == id }
409
+ name = cal&.summary || id
410
+ { id: id, name: name }
411
+ end
412
+
413
+ fetch_from = time_min - free_before
414
+ events = exclude_calendar_ids.flat_map do |calendar_id|
415
+ client.list_events(
416
+ calendar_id,
417
+ time_min: fetch_from,
418
+ time_max: time_max
419
+ )
420
+ rescue Google::Apis::Error => e
421
+ warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
422
+ []
423
+ end
424
+
425
+ finder = SlotFinder.new(
426
+ events: events,
427
+ time_min: time_min,
428
+ time_max: time_max,
429
+ slot_duration_seconds: slot_duration,
430
+ hours: hours,
431
+ days: days,
432
+ free_before_seconds: free_before,
433
+ free_after_seconds: free_after
434
+ )
435
+
436
+ slots_by_day = finder.slots_by_day
437
+
438
+ output =
439
+ if format == "json"
440
+ SlotFormatter.format_json(
441
+ slots_by_day: slots_by_day,
442
+ time_min: time_min,
443
+ time_max: time_max,
444
+ duration: duration_value,
445
+ template: template_name,
446
+ calendars: calendar_meta,
447
+ locale: Locale.current_locale,
448
+ calendar_option: options[:calendar],
449
+ from_option: options[:from],
450
+ in_option: options[:in],
451
+ free_before: slot_cfg["free_before"],
452
+ free_after: slot_cfg["free_after"],
453
+ empty_message: Locale.t("slots.no_available")
454
+ )
455
+ else
456
+ SlotFormatter.format_text(
457
+ slots_by_day: slots_by_day,
458
+ time_min: time_min,
459
+ time_max: time_max,
460
+ duration: duration_value,
461
+ calendars: calendar_meta,
462
+ template: template_name,
463
+ empty_message: Locale.t("slots.no_available")
464
+ )
465
+ end
466
+
467
+ FileCache.write(cache_key, strip_cache_marker_from_output(output, format))
468
+ status = "MISS"
469
+ end
470
+
471
+ print annotate_cli_output(output, status, cache_key, format)
472
+
473
+ if format == "text"
474
+ body = extract_slots_body_for_clipboard(output)
475
+ copy_slots_to_clipboard(body) unless body.empty?
297
476
  end
298
477
  end
299
478
 
@@ -311,15 +490,13 @@ module FlycalCli
311
490
  desc "version", "Show current flycal-cli version"
312
491
  def version
313
492
  apply_locale_override
314
- puts "flycal #{FlycalCli::VERSION}"
493
+ puts "flycal #{Flycal::VERSION}"
315
494
  end
316
495
 
317
496
  default_task :help
318
497
 
319
498
  private
320
499
 
321
- HOURS_PER_WORKING_DAY = 8
322
-
323
500
  def bold(str)
324
501
  "\e[1m#{str}\e[0m"
325
502
  end
@@ -332,6 +509,38 @@ module FlycalCli
332
509
  Locale.override!(options[:locale])
333
510
  end
334
511
 
512
+ def annotate_cli_output(output, status, cache_key, format)
513
+ fmt = format.to_s.strip.downcase
514
+ if fmt == "json"
515
+ payload = JSON.parse(output)
516
+ Flycal::CacheAnnotator.annotate_payload!(payload, status, cache_key)
517
+ JSON.pretty_generate(payload) + "\n"
518
+ else
519
+ Flycal::CacheAnnotator.annotate_text(output, status, cache_key)
520
+ end
521
+ end
522
+
523
+ def strip_cache_marker_from_output(output, format)
524
+ fmt = format.to_s.strip.downcase
525
+ if fmt == "json"
526
+ payload = JSON.parse(output)
527
+ JSON.pretty_generate(Flycal::CacheAnnotator.strip_for_storage(payload)) + "\n"
528
+ else
529
+ lines = output.to_s.sub(/\n\z/, "").split("\n")
530
+ lines.reject! { |l| l.start_with?("Cache:") }
531
+ "#{lines.join("\n")}\n"
532
+ end
533
+ end
534
+
535
+ def extract_slots_body_for_clipboard(text)
536
+ lines = text.to_s.split("\n")
537
+ lines.reject! { |l| l.start_with?("Cache:") }
538
+ blank_idx = lines.index { |l| l == "" }
539
+ return "" unless blank_idx
540
+
541
+ lines[(blank_idx + 1)..].join("\n").strip
542
+ end
543
+
335
544
  def with_config_interrupt_handling
336
545
  yield
337
546
  rescue Interrupt
@@ -340,12 +549,11 @@ module FlycalCli
340
549
  end
341
550
 
342
551
  def load_calendars
343
- creds = Auth.credentials
344
- service = CalendarService.new(creds)
552
+ client = flycal_client
345
553
 
346
554
  spinner = TTY::Spinner.new("#{Locale.t('common.loading_calendars')} ", format: :dots)
347
555
  spinner.auto_spin
348
- calendars = service.list_calendars
556
+ calendars = client.list_calendars
349
557
  spinner.stop("✓")
350
558
 
351
559
  if calendars.empty?
@@ -356,6 +564,13 @@ module FlycalCli
356
564
  calendars
357
565
  end
358
566
 
567
+ def flycal_client
568
+ creds = Auth.credentials
569
+ raise Flycal::Error, Locale.t("errors.not_connected") if creds.nil?
570
+
571
+ Flycal.connect(credentials: creds)
572
+ end
573
+
359
574
  def calendar_choices(calendars, highlight_ids: [])
360
575
  highlights = Array(highlight_ids).compact
361
576
  calendars.to_h do |cal|
@@ -429,24 +644,11 @@ module FlycalCli
429
644
  end
430
645
 
431
646
  def resolve_calendar_refs(calendars, refs)
432
- Array(refs).filter_map do |ref|
433
- resolve_calendar_ref(calendars, ref)
434
- end.uniq
647
+ Flycal::CalendarQuery.resolve_ids(calendars, refs)
435
648
  end
436
649
 
437
650
  def resolve_calendar_ref(calendars, ref)
438
- ref = ref.to_s.strip
439
- return nil if ref.empty?
440
-
441
- exact = calendars.find { |cal| cal.id == ref }
442
- return exact.id if exact
443
-
444
- matches = calendars.select do |cal|
445
- cal.id == ref ||
446
- (cal.summary && cal.summary.downcase.include?(ref.downcase))
447
- end
448
-
449
- matches.first&.id
651
+ Flycal::CalendarQuery.resolve_ref(calendars, ref)
450
652
  end
451
653
 
452
654
  def resolve_slots_exclude_calendar_ids(service, slot_cfg, calendar_override)
@@ -454,21 +656,27 @@ module FlycalCli
454
656
  calendars = service.list_calendars
455
657
 
456
658
  refs = if configured.nil? || (configured.is_a?(Array) && configured.empty?)
457
- fallback = calendar_override || Config.calendar_default
458
- fallback ? [fallback] : []
659
+ override_refs = Flycal::CalendarQuery.refs(calendar_override)
660
+ if override_refs.any?
661
+ override_refs
662
+ else
663
+ fallback = Config.calendar_default
664
+ fallback ? [ fallback ] : []
665
+ end
459
666
  else
460
667
  configured
461
668
  end
462
669
 
463
- ids = resolve_calendar_refs(calendars, refs)
670
+ ids = Flycal::CalendarQuery.resolve_ids(calendars, refs)
464
671
  return ids unless ids.empty?
465
672
 
466
- fallback = calendar_override || Config.calendar_default
467
- if fallback
468
- resolve_calendar_refs(calendars, [fallback])
673
+ fallback_refs = Flycal::CalendarQuery.refs(calendar_override)
674
+ fallback_refs = [ Config.calendar_default ].compact if fallback_refs.empty?
675
+ if fallback_refs.any?
676
+ Flycal::CalendarQuery.resolve_ids(calendars, fallback_refs)
469
677
  else
470
678
  primary = calendars.find(&:primary)
471
- primary ? [primary.id] : calendars.first(1).map(&:id)
679
+ primary ? [ primary.id ] : calendars.first(1).map(&:id)
472
680
  end
473
681
  end
474
682
 
@@ -480,7 +688,7 @@ module FlycalCli
480
688
 
481
689
  def resolve_slots_template(slot_cfg, requested_name)
482
690
  templates = slot_cfg["templates"]
483
- raise FlycalCli::Error, "slots.templates is missing in config.yml." unless templates.is_a?(Hash) && !templates.empty?
691
+ raise Flycal::Error, "slots.templates is missing in config.yml." unless templates.is_a?(Hash) && !templates.empty?
484
692
 
485
693
  name = requested_name.to_s.strip
486
694
  name = templates.keys.first.to_s if name.empty?
@@ -488,7 +696,7 @@ module FlycalCli
488
696
  template = templates[name]
489
697
  unless template.is_a?(Hash)
490
698
  available = templates.keys.join(", ")
491
- raise FlycalCli::Error, "Unknown slots template #{name.inspect}. Available: #{available}"
699
+ raise Flycal::Error, "Unknown slots template #{name.inspect}. Available: #{available}"
492
700
  end
493
701
 
494
702
  [name, template]
@@ -496,10 +704,10 @@ module FlycalCli
496
704
 
497
705
  def parse_template_days(values)
498
706
  days = Array(values).map(&:to_i)
499
- raise FlycalCli::Error, "slots template days cannot be empty." if days.empty?
707
+ raise Flycal::Error, "slots template days cannot be empty." if days.empty?
500
708
 
501
709
  invalid = days.reject { |d| d.between?(1, 7) }
502
- raise FlycalCli::Error, "Invalid template days #{invalid.inspect}. Use 1 (Mon) .. 7 (Sun)." unless invalid.empty?
710
+ raise Flycal::Error, "Invalid template days #{invalid.inspect}. Use 1 (Mon) .. 7 (Sun)." unless invalid.empty?
503
711
 
504
712
  days.uniq
505
713
  end
@@ -514,12 +722,12 @@ module FlycalCli
514
722
 
515
723
  def parse_hour_minute(value)
516
724
  match = value.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/)
517
- raise FlycalCli::Error, "Invalid time format #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)." unless match
725
+ raise Flycal::Error, "Invalid time format #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)." unless match
518
726
 
519
727
  hour = match[1].to_i
520
728
  minute = match[2].to_i
521
729
  unless hour.between?(0, 23) && minute.between?(0, 59)
522
- raise FlycalCli::Error, "Invalid time #{value.inspect}. Hour must be 0-23 and minute 0-59."
730
+ raise Flycal::Error, "Invalid time #{value.inspect}. Hour must be 0-23 and minute 0-59."
523
731
  end
524
732
 
525
733
  [hour, minute]
@@ -528,18 +736,18 @@ module FlycalCli
528
736
  def parse_workhours(values)
529
737
  ranges = Array(values).map do |item|
530
738
  start_str, end_str = item.to_s.strip.split("-", 2)
531
- 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?
739
+ raise Flycal::Error, "Invalid hours item #{item.inspect}. Use format like '9-13' or '14:30-18:00'." if start_str.nil? || end_str.nil?
532
740
 
533
741
  sh, sm = parse_hour_minute_flexible(start_str)
534
742
  eh, em = parse_hour_minute_flexible(end_str)
535
743
  start_minutes = (sh * 60) + sm
536
744
  end_minutes = (eh * 60) + em
537
- raise FlycalCli::Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
745
+ raise Flycal::Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
538
746
 
539
747
  [sh, sm, eh, em]
540
748
  end
541
749
 
542
- raise FlycalCli::Error, "template hours cannot be empty in config.yml." if ranges.empty?
750
+ raise Flycal::Error, "template hours cannot be empty in config.yml." if ranges.empty?
543
751
 
544
752
  ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
545
753
  end
@@ -548,7 +756,7 @@ module FlycalCli
548
756
  str = value.to_s.strip
549
757
  if str.match?(/\A\d{1,2}\z/)
550
758
  hour = str.to_i
551
- raise FlycalCli::Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
759
+ raise Flycal::Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
552
760
 
553
761
  return [hour, 0]
554
762
  end
@@ -556,165 +764,27 @@ module FlycalCli
556
764
  parse_hour_minute(str)
557
765
  end
558
766
 
559
- def resolve_calendar_ids(service, calendar_name)
767
+ def resolve_calendar_ids(service, calendar_option)
560
768
  calendar_lists = service.list_calendars
769
+ refs = Flycal::CalendarQuery.refs(calendar_option)
561
770
 
562
- if calendar_name.nil? || calendar_name.empty?
771
+ if refs.empty?
563
772
  default_id = Config.calendar_default
564
- if default_id
565
- return [default_id]
566
- end
567
- # Use primary calendar if available
568
- primary = calendar_lists.find(&:primary)
569
- return [primary.id] if primary
570
- # Otherwise first calendar
571
- return [calendar_lists.first.id] if calendar_lists.any?
572
- return []
573
- end
574
-
575
- # Search by name or ID
576
- matches = calendar_lists.select do |cal|
577
- cal.id == calendar_name ||
578
- (cal.summary && cal.summary.downcase.include?(calendar_name.downcase))
579
- end
580
-
581
- matches.map(&:id)
582
- end
583
-
584
- def print_events(service, events)
585
- # Use calendar list for names (avoids extra API calls)
586
- calendar_list = service.list_calendars
587
- calendar_names = calendar_list.to_h { |c| [c.id, c.summary || c.id] }
588
-
589
- events.each do |item|
590
- cal_id = item[:calendar_id]
591
- event = item[:event]
592
- cal_name = calendar_names[cal_id] || cal_id
593
-
594
- start_time = event.start&.date_time || event.start&.date
595
- end_time = event.end&.date_time || event.end&.date
596
-
597
- start_str = format_datetime(start_time)
598
- end_str = format_datetime(end_time)
599
- desc = event.summary || "(no title)"
600
- desc = event.description&.slice(0, 80) if event.summary.nil? && event.description
601
-
602
- puts "#{cal_name} | #{start_str} | #{end_str} | #{desc}"
603
- end
604
- end
605
-
606
- def format_datetime(dt)
607
- return "-" if dt.nil?
608
-
609
- if dt.is_a?(String)
610
- dt
611
- else
612
- "#{Locale.day_abbr(dt)} #{dt.strftime("%Y-%m-%d %H:%M")}"
613
- end
614
- end
615
-
616
- def format_date_with_day(dt)
617
- return "-" if dt.nil?
618
-
619
- t = dt.respond_to?(:to_time) ? dt.to_time : dt
620
- "#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d")}"
621
- end
622
-
623
- def print_search_summary(events, time_min:, time_max:)
624
- event_ranges = extract_event_ranges(events)
625
- total_minutes = event_ranges.sum { |s, e| (e - s) / 60 }
626
-
627
- from_str = time_min.strftime("%a %Y-%m-%d %H:%M")
628
- to_str = time_max.strftime("%a %Y-%m-%d %H:%M")
629
-
630
- puts "\n---"
631
- puts "From: #{from_str} | To: #{to_str}"
632
- puts "Events found: #{events.size}"
633
- puts "Total time occupied: #{format_duration(total_minutes)}"
634
-
635
- frame_days = (time_max - time_min) / 86400.0
636
- if frame_days > 30
637
- print_monthly_breakdown(event_ranges, time_min, time_max)
638
- elsif frame_days > 7
639
- print_weekly_breakdown(event_ranges, time_min, time_max)
640
- end
641
- end
642
-
643
- def extract_event_ranges(events)
644
- events.filter_map do |item|
645
- event = item[:event]
646
- start_t = event.start&.date_time || event.start&.date
647
- end_t = event.end&.date_time || event.end&.date
648
- next if start_t.nil? || end_t.nil?
649
-
650
- start_t = start_t.to_time if start_t.respond_to?(:to_time)
651
- end_t = end_t.to_time if end_t.respond_to?(:to_time)
652
- [start_t, end_t]
653
- end
654
- end
655
-
656
- def format_duration(total_minutes)
657
- hours = (total_minutes / 60).floor
658
- mins = (total_minutes % 60).round
659
- working_days = (total_minutes / 60.0 / HOURS_PER_WORKING_DAY).round(1)
660
- "#{bold(hours)}h #{bold(mins)}min (#{bold(working_days)} working days)"
661
- end
773
+ return [ default_id ] if default_id
662
774
 
663
- def format_hours_and_days(hours, working_days)
664
- "#{bold(hours)}h (#{bold(working_days)} working days)"
665
- end
666
-
667
- def minutes_in_period(event_ranges, period_start, period_end)
668
- event_ranges.sum do |ev_start, ev_end|
669
- overlap_start = [ev_start, period_start].max
670
- overlap_end = [ev_end, period_end].min
671
- overlap_sec = overlap_end - overlap_start
672
- overlap_sec > 0 ? overlap_sec / 60.0 : 0
673
- end
674
- end
775
+ primary = calendar_lists.find(&:primary)
776
+ return [ primary.id ] if primary
777
+ return [ calendar_lists.first.id ] if calendar_lists.any?
675
778
 
676
- def print_weekly_breakdown(event_ranges, time_min, time_max)
677
- puts "\nBy week:"
678
- week_num = 1
679
- current = time_min.to_date.beginning_of_week(:monday)
680
- while current.to_time < time_max
681
- week_start = [current.to_time, time_min].max
682
- week_end_date = current.end_of_week(:monday)
683
- week_end = [week_end_date.to_time + 1.day, time_max].min
684
- mins = minutes_in_period(event_ranges, week_start, week_end)
685
- hours = (mins / 60).round(1)
686
- working_days = (mins / 60.0 / HOURS_PER_WORKING_DAY).round(1)
687
- start_str = format_date_with_day(week_start)
688
- end_str = format_date_with_day(week_end_date)
689
- puts " #{bold(week_num)}. #{start_str} - #{end_str}: #{format_hours_and_days(hours, working_days)}"
690
- week_num += 1
691
- current = current + 7.days
779
+ return []
692
780
  end
693
- end
694
-
695
- def print_monthly_breakdown(event_ranges, time_min, time_max)
696
- puts "\nBy month:"
697
- month_num = 1
698
- current = time_min.to_date.beginning_of_month
699
- end_date = time_max.to_date
700
781
 
701
- while current <= end_date
702
- month_start = current.beginning_of_month.to_time
703
- month_end = (current.end_of_month + 1.day).to_time
704
- period_start = [month_start, time_min].max
705
- period_end = [month_end, time_max].min
782
+ ids = Flycal::CalendarQuery.resolve_ids(calendar_lists, refs)
783
+ return ids unless ids.empty?
706
784
 
707
- mins = minutes_in_period(event_ranges, period_start, period_end)
708
- hours = (mins / 60).round(1)
709
- working_days = (mins / 60.0 / HOURS_PER_WORKING_DAY).round(1)
710
- month_name = "#{Locale.month_name(current)} #{current.year}"
711
- start_str = format_date_with_day(period_start)
712
- last_day = (period_end.to_date - 1.day)
713
- end_str = format_date_with_day(last_day)
714
- puts " #{month_num}. #{month_name} (#{start_str} - #{end_str}): #{format_hours_and_days(hours, working_days)}"
715
- month_num += 1
716
- current = current.next_month.beginning_of_month
717
- end
785
+ # Fall back to treating unresolved refs as literal calendar IDs
786
+ refs
718
787
  end
719
788
  end
720
789
  end
790
+ end