flycal-cli 0.7.7 → 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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -181
  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_cli → flycal}/calendar_service.rb +48 -4
  8. data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +253 -109
  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_cli → flycal}/description_query.rb +3 -3
  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_cli → flycal}/mock/calendar_service.rb +1 -1
  24. data/lib/{flycal_cli → flycal}/mock/config.rb +14 -14
  25. data/lib/{flycal_cli → flycal}/mock/event_generator.rb +2 -2
  26. data/lib/flycal/mock.rb +6 -0
  27. data/lib/{flycal_cli → flycal}/pipeline/aggregator.rb +21 -24
  28. data/lib/{flycal_cli → flycal}/pipeline/json_renderer.rb +16 -13
  29. data/lib/{flycal_cli → flycal}/pipeline/params.rb +1 -1
  30. data/lib/{flycal_cli → flycal}/pipeline/renderer.rb +4 -4
  31. data/lib/{flycal_cli → flycal}/pipeline/retriever.rb +1 -1
  32. data/lib/{flycal_cli → flycal}/pipeline/search_pipeline.rb +1 -1
  33. data/lib/{flycal_cli → flycal}/pipeline/text_renderer.rb +9 -8
  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 -17
  40. data/mcp/tools.json +21 -8
  41. metadata +36 -25
  42. data/lib/flycal_cli/mock.rb +0 -10
  43. data/lib/flycal_cli/pipeline.rb +0 -14
  44. data/lib/flycal_cli/slot_formatter.rb +0 -68
  45. data/lib/flycal_cli/version.rb +0 -5
@@ -9,8 +9,9 @@ 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)"
16
17
  class_option :format, type: :string, default: "text",
@@ -44,7 +45,7 @@ module FlycalCli
44
45
  Auth.login
45
46
  puts "\n✓ Authentication completed successfully!"
46
47
  puts "\nRun 'flycal config' to set the default calendar."
47
- rescue FlycalCli::Error => e
48
+ rescue Flycal::Error => e
48
49
  puts "Error: #{e.message}"
49
50
  exit 1
50
51
  end
@@ -137,16 +138,20 @@ module FlycalCli
137
138
  flycal search -i 1months --description placeholder
138
139
  flycal search -f 2025-03-01 --in 2months
139
140
  flycal search --mockTemplate mock1 --description work --calendar mock1
140
- flycal search -d "rui|solver" --groupBy description --format json
141
+ flycal search --groupBy "rui|solver" --format json
142
+ flycal search -c "Work|Personal"
143
+ flycal search --calendar Work --calendar Personal
141
144
  LONGDESC
142
- 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
+
143
148
  option :from, type: :string, aliases: "-f", desc: "Start (default: today midnight)"
144
149
  option :to, type: :string, aliases: "-t", desc: "End (default: 23:59 of day 30)"
145
150
  option :in, type: :string, aliases: "-i", desc: "Duration: 30days, 48hours, 2months, 1year (overrides --to)"
146
151
  option :description, type: :string, aliases: "-d",
147
152
  desc: "Filter text in event (OR with |, e.g. rui|solver)"
148
153
  option :groupBy, type: :string,
149
- desc: "Grouping: day, week, month, or description (default: auto from timeframe)"
154
+ desc: "Grouping: day, week, month, or a string like rui|solver (default: auto from timeframe)"
150
155
  option :mockTemplate, type: :string, desc: "Load mock defaults from mocks/<name>.json"
151
156
  option :mockCalendar, type: :string, desc: "Use a generated mock calendar (skips Google API)"
152
157
  option :mockSeed, type: :numeric, desc: "Seed for reproducible mock events"
@@ -158,6 +163,8 @@ module FlycalCli
158
163
  option :mockEventDurationMax, type: :string, desc: "Max mock event duration (e.g. 4h)"
159
164
  option :mockEventHoursFrom, type: :string, desc: "Daily start window for mock events (HH:MM)"
160
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"
161
168
  def search
162
169
  apply_locale_override
163
170
 
@@ -166,7 +173,7 @@ module FlycalCli
166
173
  if mock_mode
167
174
  begin
168
175
  mock_config = Mock::Config.from_options(options)
169
- rescue FlycalCli::Error => e
176
+ rescue Flycal::Error => e
170
177
  puts "Error: #{e.message}"
171
178
  exit 1
172
179
  end
@@ -182,7 +189,7 @@ module FlycalCli
182
189
  else
183
190
  DurationParser.add_to_time(options[:in], time_min)
184
191
  end
185
- rescue FlycalCli::Error => e
192
+ rescue Flycal::Error => e
186
193
  puts "Error: #{e.message}"
187
194
  exit 1
188
195
  end
@@ -196,7 +203,7 @@ module FlycalCli
196
203
  if mock_config
197
204
  Mock::CalendarService.new(mock_config)
198
205
  else
199
- CalendarService.new(Auth.credentials)
206
+ flycal_client.calendar
200
207
  end
201
208
 
202
209
  calendar_ids =
@@ -229,10 +236,49 @@ module FlycalCli
229
236
  mock_template: mock_config&.template_name
230
237
  )
231
238
 
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
+
232
261
  begin
233
- output = Pipeline::SearchPipeline.new(service).run(params)
234
- print output
235
- rescue FlycalCli::Error => e
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
236
282
  puts "Error: #{e.message}"
237
283
  exit 1
238
284
  end
@@ -262,7 +308,11 @@ module FlycalCli
262
308
  desc: "Start (default: now). Dates, or relative: monday, next monday, tomorrow, lunedi..."
263
309
  option :template, type: :string, aliases: "-T",
264
310
  desc: "Slot template name from config (default: first template, usually work)"
265
- 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
+
266
316
  def slots
267
317
  apply_locale_override
268
318
  unless Auth.logged_in?
@@ -292,7 +342,7 @@ module FlycalCli
292
342
  time_max = DurationParser.add_to_time(in_value, time_min)
293
343
  hours = parse_workhours(template["hours"])
294
344
  days = parse_template_days(template["days"])
295
- rescue FlycalCli::Error => e
345
+ rescue Flycal::Error => e
296
346
  puts "Error: #{e.message}"
297
347
  exit 1
298
348
  end
@@ -302,8 +352,8 @@ module FlycalCli
302
352
  exit 1
303
353
  end
304
354
 
305
- creds = Auth.credentials
306
- service = CalendarService.new(creds)
355
+ client = flycal_client
356
+ service = client.calendar
307
357
 
308
358
  exclude_calendar_ids = resolve_slots_exclude_calendar_ids(service, slot_cfg, options[:calendar])
309
359
  if exclude_calendar_ids.empty?
@@ -311,53 +361,118 @@ module FlycalCli
311
361
  exit 1
312
362
  end
313
363
 
314
- calendars = service.list_calendars
315
- calendar_meta = exclude_calendar_ids.filter_map do |id|
316
- cal = calendars.find { |c| c.id == id }
317
- name = cal&.summary || id
318
- { id: id, name: name }
319
- end
320
-
321
- fetch_from = time_min - free_before
322
- events = exclude_calendar_ids.flat_map do |calendar_id|
323
- service.list_events(
324
- calendar_id,
325
- time_min: fetch_from,
326
- time_max: time_max
327
- )
328
- rescue Google::Apis::Errors::Error => e
329
- warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
330
- []
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
331
370
  end
332
371
 
333
- finder = SlotFinder.new(
334
- events: events,
335
- time_min: time_min,
336
- time_max: time_max,
337
- 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],
338
383
  hours: hours,
339
384
  days: days,
340
- free_before_seconds: free_before,
341
- free_after_seconds: free_after
342
- )
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
343
402
 
344
- slots_by_day = finder.slots_by_day
345
- slot_count = slots_by_day.values.sum(&:size)
346
- puts SlotFormatter.format_header(
347
- from: time_min,
348
- to: time_max,
349
- duration: duration_value,
350
- calendars: calendar_meta,
351
- count: slot_count,
352
- template: template_name
353
- )
354
- puts ""
355
- output = SlotFormatter.format_output(slots_by_day)
356
- if output.empty?
357
- puts Locale.t("slots.no_available")
358
- else
359
- puts output
360
- 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?
361
476
  end
362
477
  end
363
478
 
@@ -375,7 +490,7 @@ module FlycalCli
375
490
  desc "version", "Show current flycal-cli version"
376
491
  def version
377
492
  apply_locale_override
378
- puts "flycal #{FlycalCli::VERSION}"
493
+ puts "flycal #{Flycal::VERSION}"
379
494
  end
380
495
 
381
496
  default_task :help
@@ -394,6 +509,38 @@ module FlycalCli
394
509
  Locale.override!(options[:locale])
395
510
  end
396
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
+
397
544
  def with_config_interrupt_handling
398
545
  yield
399
546
  rescue Interrupt
@@ -402,12 +549,11 @@ module FlycalCli
402
549
  end
403
550
 
404
551
  def load_calendars
405
- creds = Auth.credentials
406
- service = CalendarService.new(creds)
552
+ client = flycal_client
407
553
 
408
554
  spinner = TTY::Spinner.new("#{Locale.t('common.loading_calendars')} ", format: :dots)
409
555
  spinner.auto_spin
410
- calendars = service.list_calendars
556
+ calendars = client.list_calendars
411
557
  spinner.stop("✓")
412
558
 
413
559
  if calendars.empty?
@@ -418,6 +564,13 @@ module FlycalCli
418
564
  calendars
419
565
  end
420
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
+
421
574
  def calendar_choices(calendars, highlight_ids: [])
422
575
  highlights = Array(highlight_ids).compact
423
576
  calendars.to_h do |cal|
@@ -491,24 +644,11 @@ module FlycalCli
491
644
  end
492
645
 
493
646
  def resolve_calendar_refs(calendars, refs)
494
- Array(refs).filter_map do |ref|
495
- resolve_calendar_ref(calendars, ref)
496
- end.uniq
647
+ Flycal::CalendarQuery.resolve_ids(calendars, refs)
497
648
  end
498
649
 
499
650
  def resolve_calendar_ref(calendars, ref)
500
- ref = ref.to_s.strip
501
- return nil if ref.empty?
502
-
503
- exact = calendars.find { |cal| cal.id == ref }
504
- return exact.id if exact
505
-
506
- matches = calendars.select do |cal|
507
- cal.id == ref ||
508
- (cal.summary && cal.summary.downcase.include?(ref.downcase))
509
- end
510
-
511
- matches.first&.id
651
+ Flycal::CalendarQuery.resolve_ref(calendars, ref)
512
652
  end
513
653
 
514
654
  def resolve_slots_exclude_calendar_ids(service, slot_cfg, calendar_override)
@@ -516,21 +656,27 @@ module FlycalCli
516
656
  calendars = service.list_calendars
517
657
 
518
658
  refs = if configured.nil? || (configured.is_a?(Array) && configured.empty?)
519
- fallback = calendar_override || Config.calendar_default
520
- 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
521
666
  else
522
667
  configured
523
668
  end
524
669
 
525
- ids = resolve_calendar_refs(calendars, refs)
670
+ ids = Flycal::CalendarQuery.resolve_ids(calendars, refs)
526
671
  return ids unless ids.empty?
527
672
 
528
- fallback = calendar_override || Config.calendar_default
529
- if fallback
530
- 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)
531
677
  else
532
678
  primary = calendars.find(&:primary)
533
- primary ? [primary.id] : calendars.first(1).map(&:id)
679
+ primary ? [ primary.id ] : calendars.first(1).map(&:id)
534
680
  end
535
681
  end
536
682
 
@@ -542,7 +688,7 @@ module FlycalCli
542
688
 
543
689
  def resolve_slots_template(slot_cfg, requested_name)
544
690
  templates = slot_cfg["templates"]
545
- 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?
546
692
 
547
693
  name = requested_name.to_s.strip
548
694
  name = templates.keys.first.to_s if name.empty?
@@ -550,7 +696,7 @@ module FlycalCli
550
696
  template = templates[name]
551
697
  unless template.is_a?(Hash)
552
698
  available = templates.keys.join(", ")
553
- raise FlycalCli::Error, "Unknown slots template #{name.inspect}. Available: #{available}"
699
+ raise Flycal::Error, "Unknown slots template #{name.inspect}. Available: #{available}"
554
700
  end
555
701
 
556
702
  [name, template]
@@ -558,10 +704,10 @@ module FlycalCli
558
704
 
559
705
  def parse_template_days(values)
560
706
  days = Array(values).map(&:to_i)
561
- 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?
562
708
 
563
709
  invalid = days.reject { |d| d.between?(1, 7) }
564
- 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?
565
711
 
566
712
  days.uniq
567
713
  end
@@ -576,12 +722,12 @@ module FlycalCli
576
722
 
577
723
  def parse_hour_minute(value)
578
724
  match = value.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/)
579
- 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
580
726
 
581
727
  hour = match[1].to_i
582
728
  minute = match[2].to_i
583
729
  unless hour.between?(0, 23) && minute.between?(0, 59)
584
- 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."
585
731
  end
586
732
 
587
733
  [hour, minute]
@@ -590,18 +736,18 @@ module FlycalCli
590
736
  def parse_workhours(values)
591
737
  ranges = Array(values).map do |item|
592
738
  start_str, end_str = item.to_s.strip.split("-", 2)
593
- 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?
594
740
 
595
741
  sh, sm = parse_hour_minute_flexible(start_str)
596
742
  eh, em = parse_hour_minute_flexible(end_str)
597
743
  start_minutes = (sh * 60) + sm
598
744
  end_minutes = (eh * 60) + em
599
- 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
600
746
 
601
747
  [sh, sm, eh, em]
602
748
  end
603
749
 
604
- 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?
605
751
 
606
752
  ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
607
753
  end
@@ -610,7 +756,7 @@ module FlycalCli
610
756
  str = value.to_s.strip
611
757
  if str.match?(/\A\d{1,2}\z/)
612
758
  hour = str.to_i
613
- 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)
614
760
 
615
761
  return [hour, 0]
616
762
  end
@@ -618,29 +764,27 @@ module FlycalCli
618
764
  parse_hour_minute(str)
619
765
  end
620
766
 
621
- def resolve_calendar_ids(service, calendar_name)
767
+ def resolve_calendar_ids(service, calendar_option)
622
768
  calendar_lists = service.list_calendars
769
+ refs = Flycal::CalendarQuery.refs(calendar_option)
623
770
 
624
- if calendar_name.nil? || calendar_name.empty?
771
+ if refs.empty?
625
772
  default_id = Config.calendar_default
626
- if default_id
627
- return [default_id]
628
- end
629
- # Use primary calendar if available
773
+ return [ default_id ] if default_id
774
+
630
775
  primary = calendar_lists.find(&:primary)
631
- return [primary.id] if primary
632
- # Otherwise first calendar
633
- return [calendar_lists.first.id] if calendar_lists.any?
776
+ return [ primary.id ] if primary
777
+ return [ calendar_lists.first.id ] if calendar_lists.any?
778
+
634
779
  return []
635
780
  end
636
781
 
637
- # Search by name or ID
638
- matches = calendar_lists.select do |cal|
639
- cal.id == calendar_name ||
640
- (cal.summary && cal.summary.downcase.include?(calendar_name.downcase))
641
- end
782
+ ids = Flycal::CalendarQuery.resolve_ids(calendar_lists, refs)
783
+ return ids unless ids.empty?
642
784
 
643
- matches.map(&:id)
785
+ # Fall back to treating unresolved refs as literal calendar IDs
786
+ refs
644
787
  end
645
788
  end
646
789
  end
790
+ end
@@ -5,7 +5,8 @@ require "googleauth"
5
5
  require "googleauth/stores/file_token_store"
6
6
  require "fileutils"
7
7
 
8
- module FlycalCli
8
+ module Flycal
9
+ module Cli
9
10
  class Auth
10
11
  SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
11
12
  REDIRECT_PORT = 9292
@@ -42,7 +43,7 @@ module FlycalCli
42
43
 
43
44
  def login
44
45
  unless Config.credentials_exist?
45
- raise FlycalCli::Error,
46
+ raise Flycal::Error,
46
47
  "Credentials file not found. Create #{Config.credentials_path} with OAuth credentials from Google Cloud Console.\n" \
47
48
  "Go to: https://console.cloud.google.com/apis/credentials\n" \
48
49
  "Create 'Desktop app' credentials and download the JSON as credentials.json"
@@ -65,7 +66,7 @@ module FlycalCli
65
66
 
66
67
  # Start local server to receive the auth code
67
68
  code = start_redirect_server(authorizer)
68
- raise FlycalCli::Error, "Authentication cancelled or failed" if code.nil? || code.empty?
69
+ raise Flycal::Error, "Authentication cancelled or failed" if code.nil? || code.empty?
69
70
 
70
71
  authorizer.get_and_store_credentials_from_code(
71
72
  user_id: "flycal_user",
@@ -130,3 +131,4 @@ module FlycalCli
130
131
  end
131
132
  end
132
133
  end
134
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module FlycalCli
3
+ module Flycal
4
+ module Cli
4
5
  # Cross-platform clipboard helper.
5
6
  # Prefers: pbcopy (macOS) → wl-copy (Wayland) → xclip (X11) → clip (Windows).
6
7
  module Clipboard
@@ -52,3 +53,4 @@ module FlycalCli
52
53
  end
53
54
  end
54
55
  end
56
+ end
@@ -3,13 +3,14 @@
3
3
  require "yaml"
4
4
  require "fileutils"
5
5
 
6
- module FlycalCli
6
+ module Flycal
7
+ module Cli
7
8
  class Config
8
9
  CONFIG_DIR = File.expand_path("~/.flycal")
9
10
  CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
10
11
  CREDENTIALS_FILE = File.join(CONFIG_DIR, "credentials.json")
11
12
  TOKENS_FILE = File.join(CONFIG_DIR, "tokens.yml")
12
- DEFAULTS_FILE = File.expand_path("../../config/defaults.yml", __dir__)
13
+ DEFAULTS_FILE = File.expand_path("../../../config/defaults.yml", __dir__)
13
14
 
14
15
  class << self
15
16
  def load
@@ -195,3 +196,4 @@ module FlycalCli
195
196
  end
196
197
  end
197
198
  end
199
+ end