flycal-cli 0.7.7 → 1.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -181
  3. data/bin/flycal +2 -2
  4. data/config/defaults.yml +2 -28
  5. data/lib/flycal/cache_annotator.rb +83 -0
  6. data/lib/flycal/cache_key.rb +82 -0
  7. data/lib/flycal/calendar_query.rb +60 -0
  8. data/lib/{flycal_cli → flycal}/calendar_service.rb +48 -4
  9. data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +246 -171
  10. data/lib/{flycal_cli → flycal/cli}/auth.rb +5 -3
  11. data/lib/{flycal_cli → flycal/cli}/clipboard.rb +3 -1
  12. data/lib/{flycal_cli → flycal/cli}/config.rb +6 -3
  13. data/lib/flycal/cli/file_cache.rb +44 -0
  14. data/lib/flycal/cli/locale.rb +58 -0
  15. data/lib/flycal/cli.rb +14 -0
  16. data/lib/flycal/client.rb +141 -0
  17. data/lib/flycal/credentials.rb +34 -0
  18. data/lib/{flycal_cli → flycal}/date_time_parser.rb +4 -4
  19. data/lib/flycal/default_slots.rb +77 -0
  20. data/lib/{flycal_cli → flycal}/description_query.rb +3 -3
  21. data/lib/{flycal_cli → flycal}/duration_parser.rb +3 -3
  22. data/lib/flycal/error.rb +5 -0
  23. data/lib/flycal/event_mapper.rb +58 -0
  24. data/lib/{flycal_cli → flycal}/locale.rb +35 -3
  25. data/lib/{flycal_cli → flycal}/mock/calendar_service.rb +1 -1
  26. data/lib/{flycal_cli → flycal}/mock/config.rb +14 -14
  27. data/lib/{flycal_cli → flycal}/mock/event_generator.rb +6 -4
  28. data/lib/flycal/mock/slot_scenario.rb +75 -0
  29. data/lib/flycal/mock.rb +6 -0
  30. data/lib/{flycal_cli → flycal}/pipeline/aggregator.rb +21 -24
  31. data/lib/{flycal_cli → flycal}/pipeline/json_renderer.rb +16 -17
  32. data/lib/{flycal_cli → flycal}/pipeline/params.rb +1 -1
  33. data/lib/{flycal_cli → flycal}/pipeline/renderer.rb +4 -4
  34. data/lib/{flycal_cli → flycal}/pipeline/retriever.rb +1 -1
  35. data/lib/{flycal_cli → flycal}/pipeline/search_pipeline.rb +1 -1
  36. data/lib/{flycal_cli → flycal}/pipeline/text_renderer.rb +14 -12
  37. data/lib/flycal/pipeline.rb +6 -0
  38. data/lib/{flycal_cli → flycal}/slot_finder.rb +12 -12
  39. data/lib/flycal/slot_formatter.rb +196 -0
  40. data/lib/flycal/slot_query.rb +74 -0
  41. data/lib/flycal/slot_templates.rb +129 -0
  42. data/lib/flycal/time_format.rb +36 -0
  43. data/lib/flycal/version.rb +11 -0
  44. data/lib/flycal.rb +12 -0
  45. data/lib/flycal_cli.rb +42 -17
  46. data/mcp/tools.json +21 -8
  47. metadata +41 -25
  48. data/lib/flycal_cli/mock.rb +0 -10
  49. data/lib/flycal_cli/pipeline.rb +0 -14
  50. data/lib/flycal_cli/slot_formatter.rb +0 -68
  51. 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?
@@ -284,15 +334,16 @@ module FlycalCli
284
334
  from_value = "now" if from_value.empty?
285
335
 
286
336
  begin
287
- template_name, template = resolve_slots_template(slot_cfg, options[:template])
337
+ resolved = SlotTemplates.resolve(name: options[:template], catalog: slot_cfg["templates"])
338
+ template_name = resolved[:name]
339
+ hours = resolved[:hours]
340
+ days = resolved[:days]
288
341
  slot_duration = DurationParser.to_seconds(duration_value)
289
342
  free_before = DurationParser.to_seconds(slot_cfg["free_before"] || "0m")
290
343
  free_after = DurationParser.to_seconds(slot_cfg["free_after"] || "0m")
291
344
  time_min = parse_slots_from(from_value)
292
345
  time_max = DurationParser.add_to_time(in_value, time_min)
293
- hours = parse_workhours(template["hours"])
294
- days = parse_template_days(template["days"])
295
- rescue FlycalCli::Error => e
346
+ rescue Flycal::Error => e
296
347
  puts "Error: #{e.message}"
297
348
  exit 1
298
349
  end
@@ -302,8 +353,8 @@ module FlycalCli
302
353
  exit 1
303
354
  end
304
355
 
305
- creds = Auth.credentials
306
- service = CalendarService.new(creds)
356
+ client = flycal_client
357
+ service = client.calendar
307
358
 
308
359
  exclude_calendar_ids = resolve_slots_exclude_calendar_ids(service, slot_cfg, options[:calendar])
309
360
  if exclude_calendar_ids.empty?
@@ -311,53 +362,118 @@ module FlycalCli
311
362
  exit 1
312
363
  end
313
364
 
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
365
+ format = (options[:format] || "text").to_s.strip.downcase
366
+ format = "text" if format.empty?
320
367
 
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
- []
368
+ unless %w[json text].include?(format)
369
+ puts "Error: Unsupported format #{format.inspect}. Available: text, json"
370
+ exit 1
331
371
  end
332
372
 
333
- finder = SlotFinder.new(
334
- events: events,
335
- time_min: time_min,
336
- time_max: time_max,
337
- slot_duration_seconds: slot_duration,
373
+ fingerprint = {
374
+ command: "slots",
375
+ calendar_ids: exclude_calendar_ids,
376
+ duration: duration_value,
377
+ from: from_value,
378
+ in: in_value,
379
+ free_before: slot_cfg["free_before"] || "0m",
380
+ free_after: slot_cfg["free_after"] || "0m",
381
+ template: template_name,
382
+ locale: Locale.current_locale,
383
+ calendar: options[:calendar],
338
384
  hours: hours,
339
385
  days: days,
340
- free_before_seconds: free_before,
341
- free_after_seconds: free_after
342
- )
386
+ time_min: time_min,
387
+ time_max: time_max,
388
+ format: format
389
+ }
390
+ cache_key = Flycal::CacheKey.generate("slots", fingerprint)
391
+ ttl = Flycal::CacheKey::DEFAULT_TTL_MINUTES
392
+ nocache = options[:nocache]
393
+
394
+ output = nil
395
+ status = nil
396
+ unless nocache
397
+ cached = FileCache.read(cache_key, ttl_minutes: ttl)
398
+ if cached
399
+ output = cached
400
+ status = "HIT"
401
+ end
402
+ end
343
403
 
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)
404
+ if output.nil?
405
+ FileCache.delete(cache_key) if nocache
406
+
407
+ calendars = client.list_calendars
408
+ calendar_meta = exclude_calendar_ids.filter_map do |id|
409
+ cal = calendars.find { |c| c.id == id }
410
+ name = cal&.summary || id
411
+ { id: id, name: name }
412
+ end
413
+
414
+ fetch_from = time_min - free_before
415
+ events = exclude_calendar_ids.flat_map do |calendar_id|
416
+ client.list_events(
417
+ calendar_id,
418
+ time_min: fetch_from,
419
+ time_max: time_max
420
+ )
421
+ rescue Google::Apis::Error => e
422
+ warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
423
+ []
424
+ end
425
+
426
+ finder = SlotFinder.new(
427
+ events: events,
428
+ time_min: time_min,
429
+ time_max: time_max,
430
+ slot_duration_seconds: slot_duration,
431
+ hours: hours,
432
+ days: days,
433
+ free_before_seconds: free_before,
434
+ free_after_seconds: free_after
435
+ )
436
+
437
+ slots_by_day = finder.slots_by_day
438
+
439
+ output =
440
+ if format == "json"
441
+ SlotFormatter.format_json(
442
+ slots_by_day: slots_by_day,
443
+ time_min: time_min,
444
+ time_max: time_max,
445
+ duration: duration_value,
446
+ template: template_name,
447
+ calendars: calendar_meta,
448
+ locale: Locale.current_locale,
449
+ calendar_option: options[:calendar],
450
+ from_option: options[:from],
451
+ in_option: options[:in],
452
+ free_before: slot_cfg["free_before"],
453
+ free_after: slot_cfg["free_after"],
454
+ empty_message: Locale.t("slots.no_available")
455
+ )
456
+ else
457
+ SlotFormatter.format_text(
458
+ slots_by_day: slots_by_day,
459
+ time_min: time_min,
460
+ time_max: time_max,
461
+ duration: duration_value,
462
+ calendars: calendar_meta,
463
+ template: template_name,
464
+ empty_message: Locale.t("slots.no_available")
465
+ )
466
+ end
467
+
468
+ FileCache.write(cache_key, strip_cache_marker_from_output(output, format))
469
+ status = "MISS"
470
+ end
471
+
472
+ print annotate_cli_output(output, status, cache_key, format)
473
+
474
+ if format == "text"
475
+ body = extract_slots_body_for_clipboard(output)
476
+ copy_slots_to_clipboard(body) unless body.empty?
361
477
  end
362
478
  end
363
479
 
@@ -375,7 +491,7 @@ module FlycalCli
375
491
  desc "version", "Show current flycal-cli version"
376
492
  def version
377
493
  apply_locale_override
378
- puts "flycal #{FlycalCli::VERSION}"
494
+ puts "flycal #{Flycal::VERSION}"
379
495
  end
380
496
 
381
497
  default_task :help
@@ -394,6 +510,38 @@ module FlycalCli
394
510
  Locale.override!(options[:locale])
395
511
  end
396
512
 
513
+ def annotate_cli_output(output, status, cache_key, format)
514
+ fmt = format.to_s.strip.downcase
515
+ if fmt == "json"
516
+ payload = JSON.parse(output)
517
+ Flycal::CacheAnnotator.annotate_payload!(payload, status, cache_key)
518
+ JSON.pretty_generate(payload) + "\n"
519
+ else
520
+ Flycal::CacheAnnotator.annotate_text(output, status, cache_key)
521
+ end
522
+ end
523
+
524
+ def strip_cache_marker_from_output(output, format)
525
+ fmt = format.to_s.strip.downcase
526
+ if fmt == "json"
527
+ payload = JSON.parse(output)
528
+ JSON.pretty_generate(Flycal::CacheAnnotator.strip_for_storage(payload)) + "\n"
529
+ else
530
+ lines = output.to_s.sub(/\n\z/, "").split("\n")
531
+ lines.reject! { |l| l.start_with?("Cache:") }
532
+ "#{lines.join("\n")}\n"
533
+ end
534
+ end
535
+
536
+ def extract_slots_body_for_clipboard(text)
537
+ lines = text.to_s.split("\n")
538
+ lines.reject! { |l| l.start_with?("Cache:") }
539
+ blank_idx = lines.index { |l| l == "" }
540
+ return "" unless blank_idx
541
+
542
+ lines[(blank_idx + 1)..].join("\n").strip
543
+ end
544
+
397
545
  def with_config_interrupt_handling
398
546
  yield
399
547
  rescue Interrupt
@@ -402,12 +550,11 @@ module FlycalCli
402
550
  end
403
551
 
404
552
  def load_calendars
405
- creds = Auth.credentials
406
- service = CalendarService.new(creds)
553
+ client = flycal_client
407
554
 
408
555
  spinner = TTY::Spinner.new("#{Locale.t('common.loading_calendars')} ", format: :dots)
409
556
  spinner.auto_spin
410
- calendars = service.list_calendars
557
+ calendars = client.list_calendars
411
558
  spinner.stop("✓")
412
559
 
413
560
  if calendars.empty?
@@ -418,6 +565,13 @@ module FlycalCli
418
565
  calendars
419
566
  end
420
567
 
568
+ def flycal_client
569
+ creds = Auth.credentials
570
+ raise Flycal::Error, Locale.t("errors.not_connected") if creds.nil?
571
+
572
+ Flycal.connect(credentials: creds)
573
+ end
574
+
421
575
  def calendar_choices(calendars, highlight_ids: [])
422
576
  highlights = Array(highlight_ids).compact
423
577
  calendars.to_h do |cal|
@@ -491,24 +645,11 @@ module FlycalCli
491
645
  end
492
646
 
493
647
  def resolve_calendar_refs(calendars, refs)
494
- Array(refs).filter_map do |ref|
495
- resolve_calendar_ref(calendars, ref)
496
- end.uniq
648
+ Flycal::CalendarQuery.resolve_ids(calendars, refs)
497
649
  end
498
650
 
499
651
  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
652
+ Flycal::CalendarQuery.resolve_ref(calendars, ref)
512
653
  end
513
654
 
514
655
  def resolve_slots_exclude_calendar_ids(service, slot_cfg, calendar_override)
@@ -516,21 +657,27 @@ module FlycalCli
516
657
  calendars = service.list_calendars
517
658
 
518
659
  refs = if configured.nil? || (configured.is_a?(Array) && configured.empty?)
519
- fallback = calendar_override || Config.calendar_default
520
- fallback ? [fallback] : []
660
+ override_refs = Flycal::CalendarQuery.refs(calendar_override)
661
+ if override_refs.any?
662
+ override_refs
663
+ else
664
+ fallback = Config.calendar_default
665
+ fallback ? [ fallback ] : []
666
+ end
521
667
  else
522
668
  configured
523
669
  end
524
670
 
525
- ids = resolve_calendar_refs(calendars, refs)
671
+ ids = Flycal::CalendarQuery.resolve_ids(calendars, refs)
526
672
  return ids unless ids.empty?
527
673
 
528
- fallback = calendar_override || Config.calendar_default
529
- if fallback
530
- resolve_calendar_refs(calendars, [fallback])
674
+ fallback_refs = Flycal::CalendarQuery.refs(calendar_override)
675
+ fallback_refs = [ Config.calendar_default ].compact if fallback_refs.empty?
676
+ if fallback_refs.any?
677
+ Flycal::CalendarQuery.resolve_ids(calendars, fallback_refs)
531
678
  else
532
679
  primary = calendars.find(&:primary)
533
- primary ? [primary.id] : calendars.first(1).map(&:id)
680
+ primary ? [ primary.id ] : calendars.first(1).map(&:id)
534
681
  end
535
682
  end
536
683
 
@@ -540,32 +687,6 @@ module FlycalCli
540
687
  DateTimeParser.parse(value)
541
688
  end
542
689
 
543
- def resolve_slots_template(slot_cfg, requested_name)
544
- templates = slot_cfg["templates"]
545
- raise FlycalCli::Error, "slots.templates is missing in config.yml." unless templates.is_a?(Hash) && !templates.empty?
546
-
547
- name = requested_name.to_s.strip
548
- name = templates.keys.first.to_s if name.empty?
549
-
550
- template = templates[name]
551
- unless template.is_a?(Hash)
552
- available = templates.keys.join(", ")
553
- raise FlycalCli::Error, "Unknown slots template #{name.inspect}. Available: #{available}"
554
- end
555
-
556
- [name, template]
557
- end
558
-
559
- def parse_template_days(values)
560
- days = Array(values).map(&:to_i)
561
- raise FlycalCli::Error, "slots template days cannot be empty." if days.empty?
562
-
563
- 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?
565
-
566
- days.uniq
567
- end
568
-
569
690
  def copy_slots_to_clipboard(text)
570
691
  tool = Clipboard.copy(SlotFormatter.strip_ansi(text))
571
692
  return unless tool
@@ -574,73 +695,27 @@ module FlycalCli
574
695
  puts Locale.t("slots.copied_to_clipboard", tool: tool)
575
696
  end
576
697
 
577
- def parse_hour_minute(value)
578
- 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
580
-
581
- hour = match[1].to_i
582
- minute = match[2].to_i
583
- 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."
585
- end
586
-
587
- [hour, minute]
588
- end
589
-
590
- def parse_workhours(values)
591
- ranges = Array(values).map do |item|
592
- 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?
594
-
595
- sh, sm = parse_hour_minute_flexible(start_str)
596
- eh, em = parse_hour_minute_flexible(end_str)
597
- start_minutes = (sh * 60) + sm
598
- end_minutes = (eh * 60) + em
599
- raise FlycalCli::Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
600
-
601
- [sh, sm, eh, em]
602
- end
603
-
604
- raise FlycalCli::Error, "template hours cannot be empty in config.yml." if ranges.empty?
605
-
606
- ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
607
- end
608
-
609
- def parse_hour_minute_flexible(value)
610
- str = value.to_s.strip
611
- if str.match?(/\A\d{1,2}\z/)
612
- hour = str.to_i
613
- raise FlycalCli::Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
614
-
615
- return [hour, 0]
616
- end
617
-
618
- parse_hour_minute(str)
619
- end
620
-
621
- def resolve_calendar_ids(service, calendar_name)
698
+ def resolve_calendar_ids(service, calendar_option)
622
699
  calendar_lists = service.list_calendars
700
+ refs = Flycal::CalendarQuery.refs(calendar_option)
623
701
 
624
- if calendar_name.nil? || calendar_name.empty?
702
+ if refs.empty?
625
703
  default_id = Config.calendar_default
626
- if default_id
627
- return [default_id]
628
- end
629
- # Use primary calendar if available
704
+ return [ default_id ] if default_id
705
+
630
706
  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?
707
+ return [ primary.id ] if primary
708
+ return [ calendar_lists.first.id ] if calendar_lists.any?
709
+
634
710
  return []
635
711
  end
636
712
 
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
713
+ ids = Flycal::CalendarQuery.resolve_ids(calendar_lists, refs)
714
+ return ids unless ids.empty?
642
715
 
643
- matches.map(&:id)
716
+ # Fall back to treating unresolved refs as literal calendar IDs
717
+ refs
644
718
  end
645
719
  end
646
720
  end
721
+ 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
@@ -158,7 +159,8 @@ module FlycalCli
158
159
  @default_values ||= begin
159
160
  raise "Defaults file not found: #{DEFAULTS_FILE}" unless File.exist?(DEFAULTS_FILE)
160
161
 
161
- YAML.load_file(DEFAULTS_FILE) || {}
162
+ file = YAML.load_file(DEFAULTS_FILE) || {}
163
+ file.merge("slots" => Flycal::DefaultSlots.config_fragment)
162
164
  end
163
165
  end
164
166
 
@@ -195,3 +197,4 @@ module FlycalCli
195
197
  end
196
198
  end
197
199
  end
200
+ end