flycal-cli 1.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0683f1fd5316c424662349a75956309890f9fe5d38fd0cd50a4e158f9a7085e1'
4
- data.tar.gz: b7507dee7df050d2e4f11bfb80002f82c2d4cb2689daff89c1d4be62f244b5e1
3
+ metadata.gz: fcbd8c2cfb6f90272cf8cc8cfa489bc99a7487efca11f68560b2d7f96ac92f34
4
+ data.tar.gz: 21e92239db0dc5b3f587abdd1e859b5e67e67293706f6ec1f50ca34ca4fe0e1d
5
5
  SHA512:
6
- metadata.gz: ccabe113d4314bacfd16f3072986d9686919acf4c489a7d8cf9ec05e2bb6dddec59576a859cc210485f9ef56afe7ad9a8522432ba37f0976ae10e5385ffe6e93
7
- data.tar.gz: 6fb81eb48cb1bc8ed7ea84b8764eb8c677f1a6b346c5a0841a9498aecd63a06b73f1c801e8dee0f915d3ebf20901e74195356d83a3323edf3ef2725c85fdb4d3
6
+ metadata.gz: 2524fb58d1faecdf6ac6643761315f2586f6fe10b4535f2f50b87418f871665f13c84110c3d627ad2131bb9b2ef95aa171c0e6e1e41b52f87d7b76fd7261493c
7
+ data.tar.gz: 906c85b9dd3711aa16874a6f41be9851be1accbd07d131b09fb9f6173ed26d0e081f6607b457d606f538ea8861ab98dcff89b6cdab4f482b79ffe0c8af41eb7b
data/config/defaults.yml CHANGED
@@ -1,30 +1,4 @@
1
1
  calendar_default: ~
2
2
  locale: en
3
- slots:
4
- templates:
5
- work:
6
- days:
7
- - 1
8
- - 2
9
- - 3
10
- - 4
11
- - 5
12
- hours:
13
- - 9:30-13:00
14
- - 14:00-18:30
15
- dinner:
16
- days:
17
- - 1
18
- - 2
19
- - 3
20
- - 4
21
- - 5
22
- - 6
23
- hours:
24
- - 19-23
25
- exclude_calendars: []
26
- defaults:
27
- from: now
28
- default_duration: 45min
29
- free_before: 0m
30
- free_after: 15m
3
+ # `slots` is injected from Flycal::DefaultSlots at load/setup time.
4
+ # After first write, the CLI reads templates from this file on disk.
@@ -334,14 +334,15 @@ module Cli
334
334
  from_value = "now" if from_value.empty?
335
335
 
336
336
  begin
337
- 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]
338
341
  slot_duration = DurationParser.to_seconds(duration_value)
339
342
  free_before = DurationParser.to_seconds(slot_cfg["free_before"] || "0m")
340
343
  free_after = DurationParser.to_seconds(slot_cfg["free_after"] || "0m")
341
344
  time_min = parse_slots_from(from_value)
342
345
  time_max = DurationParser.add_to_time(in_value, time_min)
343
- hours = parse_workhours(template["hours"])
344
- days = parse_template_days(template["days"])
345
346
  rescue Flycal::Error => e
346
347
  puts "Error: #{e.message}"
347
348
  exit 1
@@ -686,32 +687,6 @@ module Cli
686
687
  DateTimeParser.parse(value)
687
688
  end
688
689
 
689
- def resolve_slots_template(slot_cfg, requested_name)
690
- templates = slot_cfg["templates"]
691
- raise Flycal::Error, "slots.templates is missing in config.yml." unless templates.is_a?(Hash) && !templates.empty?
692
-
693
- name = requested_name.to_s.strip
694
- name = templates.keys.first.to_s if name.empty?
695
-
696
- template = templates[name]
697
- unless template.is_a?(Hash)
698
- available = templates.keys.join(", ")
699
- raise Flycal::Error, "Unknown slots template #{name.inspect}. Available: #{available}"
700
- end
701
-
702
- [name, template]
703
- end
704
-
705
- def parse_template_days(values)
706
- days = Array(values).map(&:to_i)
707
- raise Flycal::Error, "slots template days cannot be empty." if days.empty?
708
-
709
- invalid = days.reject { |d| d.between?(1, 7) }
710
- raise Flycal::Error, "Invalid template days #{invalid.inspect}. Use 1 (Mon) .. 7 (Sun)." unless invalid.empty?
711
-
712
- days.uniq
713
- end
714
-
715
690
  def copy_slots_to_clipboard(text)
716
691
  tool = Clipboard.copy(SlotFormatter.strip_ansi(text))
717
692
  return unless tool
@@ -720,50 +695,6 @@ module Cli
720
695
  puts Locale.t("slots.copied_to_clipboard", tool: tool)
721
696
  end
722
697
 
723
- def parse_hour_minute(value)
724
- match = value.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/)
725
- raise Flycal::Error, "Invalid time format #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)." unless match
726
-
727
- hour = match[1].to_i
728
- minute = match[2].to_i
729
- unless hour.between?(0, 23) && minute.between?(0, 59)
730
- raise Flycal::Error, "Invalid time #{value.inspect}. Hour must be 0-23 and minute 0-59."
731
- end
732
-
733
- [hour, minute]
734
- end
735
-
736
- def parse_workhours(values)
737
- ranges = Array(values).map do |item|
738
- start_str, end_str = item.to_s.strip.split("-", 2)
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?
740
-
741
- sh, sm = parse_hour_minute_flexible(start_str)
742
- eh, em = parse_hour_minute_flexible(end_str)
743
- start_minutes = (sh * 60) + sm
744
- end_minutes = (eh * 60) + em
745
- raise Flycal::Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
746
-
747
- [sh, sm, eh, em]
748
- end
749
-
750
- raise Flycal::Error, "template hours cannot be empty in config.yml." if ranges.empty?
751
-
752
- ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
753
- end
754
-
755
- def parse_hour_minute_flexible(value)
756
- str = value.to_s.strip
757
- if str.match?(/\A\d{1,2}\z/)
758
- hour = str.to_i
759
- raise Flycal::Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
760
-
761
- return [hour, 0]
762
- end
763
-
764
- parse_hour_minute(str)
765
- end
766
-
767
698
  def resolve_calendar_ids(service, calendar_option)
768
699
  calendar_lists = service.list_calendars
769
700
  refs = Flycal::CalendarQuery.refs(calendar_option)
@@ -159,7 +159,8 @@ module Cli
159
159
  @default_values ||= begin
160
160
  raise "Defaults file not found: #{DEFAULTS_FILE}" unless File.exist?(DEFAULTS_FILE)
161
161
 
162
- YAML.load_file(DEFAULTS_FILE) || {}
162
+ file = YAML.load_file(DEFAULTS_FILE) || {}
163
+ file.merge("slots" => Flycal::DefaultSlots.config_fragment)
163
164
  end
164
165
  end
165
166
 
data/lib/flycal/client.rb CHANGED
@@ -66,75 +66,24 @@ module Flycal
66
66
  # Resolved slots inputs (defaults applied) suitable for CacheKey.generate.
67
67
  # Does not call Google APIs.
68
68
  def resolve_slots_query(params = {})
69
- p = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params
70
- calendar_ids = Array(p[:calendar_ids]).map(&:to_s).reject(&:empty?)
71
- raise Flycal::Error, "calendar_ids are required" if calendar_ids.empty?
72
-
73
- duration_value = p[:duration].presence || "45min"
74
- in_value = p[:in].presence || "1 week"
75
- from_value = p[:from].presence || "now"
76
- free_before_value = p[:free_before].presence || "0m"
77
- free_after_value = p[:free_after].presence || "0m"
78
- template_name = p[:template].presence || "work"
79
- locale = p[:locale].presence || Locale.current_locale
80
-
81
- slot_duration = DurationParser.to_seconds(duration_value)
82
- free_before = DurationParser.to_seconds(free_before_value)
83
- free_after = DurationParser.to_seconds(free_after_value)
84
- time_min = DateTimeParser.parse(from_value.to_s, locale: locale)
85
- time_max = DurationParser.add_to_time(in_value, time_min)
86
- raise Flycal::Error, "'from' must be before end of window" if time_min >= time_max
87
-
88
- hours = normalize_hours(p[:hours]) || SlotFinder::DEFAULT_HOURS
89
- days = normalize_days(p[:days]) || SlotFinder::DEFAULT_DAYS
90
-
91
- {
92
- command: "slots",
93
- calendar_ids: calendar_ids,
94
- duration: duration_value,
95
- from: from_value,
96
- in: in_value,
97
- free_before: free_before_value,
98
- free_after: free_after_value,
99
- template: template_name,
100
- locale: locale,
101
- calendar: p[:calendar],
102
- hours: hours,
103
- days: days,
104
- time_min: time_min,
105
- time_max: time_max,
106
- slot_duration_seconds: slot_duration,
107
- free_before_seconds: free_before,
108
- free_after_seconds: free_after,
109
- format: p[:format].presence || "json"
110
- }
69
+ SlotQuery.resolve(params)
111
70
  end
112
71
 
113
72
  # Find free slots. +params+ keys (string/symbol):
114
- # calendar_ids (required), duration (e.g. "45min"), from, in,
115
- # free_before, free_after, hours ([[9,0,18,0]]), days ([1..5]),
116
- # template (label), locale, calendar (label for meta)
117
- # Returns a Hash suitable for JSON rendering.
118
- def slots(params = {})
73
+ # calendar_ids (required), duration, from, to, in,
74
+ # free_before, free_after, hours, days,
75
+ # template (work|dinner), template_custom ({ days:, hours: } YAML shape),
76
+ # locale, calendar (label for meta)
77
+ # Pass +events:+ to skip Google (tests / mock).
78
+ def slots(params = {}, events: nil)
79
+ previous_locale = Thread.current[:flycal_locale_override]
119
80
  p = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params
120
81
  q = resolve_slots_query(p)
82
+ Locale.override!(q[:locale])
121
83
 
122
- calendars = list_calendars
123
- calendar_meta = q[:calendar_ids].filter_map do |id|
124
- cal = calendars.find { |c| c.id == id }
125
- { id: id, name: cal&.summary || id }
126
- end
127
-
128
- fetch_from = q[:time_min] - q[:free_before_seconds]
129
- events = q[:calendar_ids].flat_map do |calendar_id|
130
- list_events(calendar_id, time_min: fetch_from, time_max: q[:time_max])
131
- rescue StandardError => e
132
- warn "Flycal slots: skip calendar #{calendar_id}: #{e.message}"
133
- []
134
- end
135
-
84
+ calendar_meta, loaded_events = load_slot_events(q, events)
136
85
  finder = SlotFinder.new(
137
- events: events,
86
+ events: loaded_events,
138
87
  time_min: q[:time_min],
139
88
  time_max: q[:time_max],
140
89
  slot_duration_seconds: q[:slot_duration_seconds],
@@ -154,33 +103,39 @@ module Flycal
154
103
  locale: q[:locale],
155
104
  calendar_option: q[:calendar],
156
105
  from_option: p[:from],
106
+ to_option: p[:to],
157
107
  in_option: p[:in],
158
108
  free_before: q[:free_before],
159
109
  free_after: q[:free_after],
160
110
  empty_message: Locale.t("slots.no_available")
161
111
  )
112
+ ensure
113
+ Thread.current[:flycal_locale_override] = previous_locale
162
114
  end
163
115
 
164
116
  private
165
117
 
166
- def normalize_hours(value)
167
- return nil if value.blank?
168
- return value if value.is_a?(Array) && value.first.is_a?(Array)
118
+ def load_slot_events(query, events)
119
+ if events
120
+ meta = query[:calendar_ids].map { |id| { id: id, name: id } }
121
+ return [ meta, Array(events) ]
122
+ end
169
123
 
170
- # "9:00-18:00" or ["9:00-18:00", "14:00-17:00"]
171
- Array(value).map do |window|
172
- start_s, end_s = window.to_s.split("-", 2).map(&:strip)
173
- sh, sm = start_s.split(":").map(&:to_i)
174
- eh, em = end_s.split(":").map(&:to_i)
175
- [ sh, sm, eh, em ]
124
+ calendars = list_calendars
125
+ meta = query[:calendar_ids].filter_map do |id|
126
+ cal = calendars.find { |c| c.id == id }
127
+ { id: id, name: cal&.summary || id }
176
128
  end
177
- end
178
129
 
179
- def normalize_days(value)
180
- return nil if value.blank?
181
- return value.map(&:to_i) if value.is_a?(Array)
130
+ fetch_from = query[:time_min] - query[:free_before_seconds]
131
+ loaded = query[:calendar_ids].flat_map do |calendar_id|
132
+ list_events(calendar_id, time_min: fetch_from, time_max: query[:time_max])
133
+ rescue StandardError => e
134
+ warn "Flycal slots: skip calendar #{calendar_id}: #{e.message}"
135
+ []
136
+ end
182
137
 
183
- value.to_s.split(",").map(&:strip).reject(&:empty?).map(&:to_i)
138
+ [ meta, loaded ]
184
139
  end
185
140
  end
186
141
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flycal
4
+ # Built-in slot windows shared by Flycal core, HTTP API, and CLI setup.
5
+ #
6
+ # CLI writes this fragment into ~/.flycal/config.yml on first load/setup, then
7
+ # reads templates from disk. API and MCP always read from this class, never
8
+ # from the user's home file.
9
+ module DefaultSlots
10
+ DEFAULTS = {
11
+ "from" => "now",
12
+ "default_duration" => "45min"
13
+ }.freeze
14
+
15
+ FREE_BEFORE = "0m"
16
+ FREE_AFTER = "15m"
17
+ EXCLUDE_CALENDARS = [].freeze
18
+
19
+ module_function
20
+
21
+ def templates
22
+ SlotTemplates::BUILTIN
23
+ end
24
+
25
+ def names
26
+ SlotTemplates.names(templates)
27
+ end
28
+
29
+ def defaults
30
+ DEFAULTS
31
+ end
32
+
33
+ def free_before
34
+ FREE_BEFORE
35
+ end
36
+
37
+ def free_after
38
+ FREE_AFTER
39
+ end
40
+
41
+ def exclude_calendars
42
+ EXCLUDE_CALENDARS
43
+ end
44
+
45
+ # YAML-shaped Hash written into ~/.flycal/config.yml under `slots:`.
46
+ def config_fragment
47
+ {
48
+ "templates" => deep_dup(templates),
49
+ "exclude_calendars" => exclude_calendars.dup,
50
+ "defaults" => deep_dup(defaults),
51
+ "free_before" => free_before,
52
+ "free_after" => free_after
53
+ }
54
+ end
55
+
56
+ # JSON-friendly payload for API / MCP `slots_templates`.
57
+ def as_json
58
+ {
59
+ "templates" => deep_dup(templates),
60
+ "defaults" => deep_dup(defaults),
61
+ "free_before" => free_before,
62
+ "free_after" => free_after
63
+ }
64
+ end
65
+
66
+ def deep_dup(value)
67
+ case value
68
+ when Hash
69
+ value.transform_values { |item| deep_dup(item) }
70
+ when Array
71
+ value.map { |item| deep_dup(item) }
72
+ else
73
+ value
74
+ end
75
+ end
76
+ end
77
+ end
@@ -6,7 +6,7 @@ module Flycal
6
6
  class EventGenerator
7
7
  EventStart = Struct.new(:date_time, :date, keyword_init: true)
8
8
  EventEnd = Struct.new(:date_time, :date, keyword_init: true)
9
- Event = Struct.new(:summary, :description, :start, :end, keyword_init: true)
9
+ Event = Struct.new(:summary, :description, :start, :end, :status, :transparency, keyword_init: true)
10
10
 
11
11
  def initialize(config)
12
12
  @config = config
@@ -24,7 +24,9 @@ module Flycal
24
24
  summary: title,
25
25
  description: title,
26
26
  start: EventStart.new(date_time: start_at, date: nil),
27
- end: EventEnd.new(date_time: end_at, date: nil)
27
+ end: EventEnd.new(date_time: end_at, date: nil),
28
+ status: "confirmed",
29
+ transparency: "opaque"
28
30
  )
29
31
  end.sort_by { |e| e.start.date_time }
30
32
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flycal
4
+ module Mock
5
+ # Generates mock busy events, then computes the free slots those events imply.
6
+ class SlotScenario
7
+ DEFAULT_MOCK = {
8
+ mockCalendar: "mock-calendar",
9
+ mockEventCount: 8,
10
+ mockEventFrom: "2026-08-24",
11
+ mockEventTo: "2026-08-28",
12
+ mockEventDurationMin: "30m",
13
+ mockEventDurationMax: "90m",
14
+ mockEventHoursFrom: "09:00",
15
+ mockEventHoursTo: "20:00",
16
+ mockEventDescriptionPatterns: "standup,review"
17
+ }.freeze
18
+
19
+ attr_reader :seed, :events, :query, :slots_by_day, :expected_count, :config
20
+
21
+ def self.generate(mock: {}, slots: {})
22
+ new(mock: mock, slots: slots)
23
+ end
24
+
25
+ def initialize(mock: {}, slots: {})
26
+ mock_opts = DEFAULT_MOCK.merge(symbolize(mock))
27
+ @seed = Integer(mock_opts[:mockSeed] || (Random.new_seed & 0xFFFFFFFF))
28
+ mock_opts[:mockSeed] = @seed
29
+
30
+ @config = Config.from_options(mock_opts)
31
+ @events = EventGenerator.new(@config).generate
32
+ @query = SlotQuery.resolve(
33
+ { calendar_ids: [ @config.calendar_name ] }.merge(symbolize(slots))
34
+ )
35
+ finder = SlotFinder.new(
36
+ events: @events,
37
+ time_min: @query[:time_min],
38
+ time_max: @query[:time_max],
39
+ slot_duration_seconds: @query[:slot_duration_seconds],
40
+ hours: @query[:hours],
41
+ days: @query[:days],
42
+ free_before_seconds: @query[:free_before_seconds],
43
+ free_after_seconds: @query[:free_after_seconds]
44
+ )
45
+ @slots_by_day = finder.slots_by_day
46
+ @expected_count = @slots_by_day.values.sum(&:size)
47
+ end
48
+
49
+ def to_h
50
+ {
51
+ seed: seed,
52
+ expected_count: expected_count,
53
+ events: events.map { |event| serialize_event(event) },
54
+ slots_by_day: slots_by_day
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def serialize_event(event)
61
+ {
62
+ summary: event.summary,
63
+ start: event.start.date_time,
64
+ end: event.end.date_time
65
+ }
66
+ end
67
+
68
+ def symbolize(hash)
69
+ hash.each_with_object({}) do |(key, value), out|
70
+ out[key.to_sym] = value
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -128,12 +128,8 @@ module Flycal
128
128
  end
129
129
  end
130
130
 
131
- # Google Calendar-style ISO-8601 datetime, e.g. 2026-07-29T14:41:00+00:00
132
131
  def format_time(value)
133
- return nil if value.nil?
134
- return value.iso8601 if value.respond_to?(:iso8601)
135
-
136
- value.to_s
132
+ TimeFormat.iso8601(value)
137
133
  end
138
134
 
139
135
  def blank_to_nil(value)
@@ -32,8 +32,8 @@ module Flycal
32
32
  totals = params[:totals] || {}
33
33
  time_min = params[:time_min]
34
34
  time_max = params[:time_max]
35
- from_str = time_min.strftime("%a %Y-%m-%d %H:%M")
36
- to_str = time_max.strftime("%a %Y-%m-%d %H:%M")
35
+ from_str = TimeFormat.unified(time_min)&.strftime("%a %Y-%m-%d %H:%M") || "-"
36
+ to_str = TimeFormat.unified(time_max)&.strftime("%a %Y-%m-%d %H:%M") || "-"
37
37
 
38
38
  lines = []
39
39
  lines << "---"
@@ -105,13 +105,14 @@ module Flycal
105
105
  return "-" if dt.nil?
106
106
  return dt if dt.is_a?(String)
107
107
 
108
- "#{Locale.day_abbr(dt)} #{dt.strftime("%Y-%m-%d %H:%M")}"
108
+ t = TimeFormat.unified(dt)
109
+ "#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d %H:%M")}"
109
110
  end
110
111
 
111
112
  def format_date_with_day(dt)
112
113
  return "-" if dt.nil?
113
114
 
114
- t = dt.respond_to?(:to_time) ? dt.to_time : dt
115
+ t = TimeFormat.unified(dt)
115
116
  "#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d")}"
116
117
  end
117
118
 
@@ -17,8 +17,8 @@ module Flycal
17
17
  free_after_seconds: 0
18
18
  )
19
19
  @events = events
20
- @time_min = time_min
21
- @time_max = time_max
20
+ @time_min = TimeFormat.unified(time_min)
21
+ @time_max = TimeFormat.unified(time_max)
22
22
  @slot_duration_seconds = slot_duration_seconds
23
23
  @hours = hours
24
24
  @days = Array(days).map(&:to_i)
@@ -151,23 +151,23 @@ module Flycal
151
151
  end
152
152
 
153
153
  def round_up_15(time)
154
- sec = time.to_i
155
- remainder = sec % STEP_SECONDS
154
+ time = to_time(time)
155
+ remainder = time.to_i % STEP_SECONDS
156
156
  return time if remainder.zero?
157
157
 
158
- Time.at(sec + (STEP_SECONDS - remainder))
158
+ time + (STEP_SECONDS - remainder)
159
159
  end
160
160
 
161
161
  def round_down_15(time)
162
- sec = time.to_i
163
- Time.at(sec - (sec % STEP_SECONDS))
162
+ time = to_time(time)
163
+ remainder = time.to_i % STEP_SECONDS
164
+ return time if remainder.zero?
165
+
166
+ time - remainder
164
167
  end
165
168
 
166
169
  def to_time(value)
167
- return value if value.is_a?(Time)
168
- return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String)
169
-
170
- Time.parse(value.to_s)
170
+ TimeFormat.unified(value)
171
171
  end
172
172
  end
173
173
  end
@@ -79,6 +79,7 @@ module Flycal
79
79
  locale: nil,
80
80
  calendar_option: nil,
81
81
  from_option: nil,
82
+ to_option: nil,
82
83
  in_option: nil,
83
84
  free_before: nil,
84
85
  free_after: nil,
@@ -121,6 +122,7 @@ module Flycal
121
122
  "format" => "json",
122
123
  "locale" => locale || Locale.current_locale,
123
124
  "from_option" => blank_to_nil(from_option),
125
+ "to_option" => blank_to_nil(to_option),
124
126
  "in_option" => blank_to_nil(in_option),
125
127
  "free_before" => free_before,
126
128
  "free_after" => free_after
@@ -156,10 +158,7 @@ module Flycal
156
158
  end
157
159
 
158
160
  def format_iso(value)
159
- return nil if value.nil?
160
- return value.iso8601 if value.respond_to?(:iso8601)
161
-
162
- value.to_s
161
+ TimeFormat.iso8601(value)
163
162
  end
164
163
 
165
164
  def blank_to_nil(value)
@@ -185,6 +184,8 @@ module Flycal
185
184
  end
186
185
 
187
186
  def format_time(time)
187
+ time = TimeFormat.unified(time)
188
+ return "" unless time
188
189
  return time.strftime("%-H") if time.min.zero?
189
190
 
190
191
  minutes = sprintf("%02d", time.min)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "active_support/core_ext/hash/indifferent_access"
5
+
6
+ module Flycal
7
+ # Resolves slots search params (template, window, duration) without calling Google.
8
+ class SlotQuery
9
+ class << self
10
+ def resolve(params = {})
11
+ p = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params
12
+ calendar_ids = Array(p[:calendar_ids]).map(&:to_s).reject(&:empty?)
13
+ raise Error, "calendar_ids are required" if calendar_ids.empty?
14
+
15
+ duration_value = p[:duration].presence || "45min"
16
+ from_value = p[:from].presence || "now"
17
+ free_before_value = p[:free_before].presence || "0m"
18
+ free_after_value = p[:free_after].presence || "0m"
19
+ locale = p[:locale].presence || Locale.current_locale
20
+
21
+ template = SlotTemplates.resolve(
22
+ name: p[:template],
23
+ custom: p[:template_custom],
24
+ catalog: p[:template_catalog]
25
+ )
26
+
27
+ slot_duration = DurationParser.to_seconds(duration_value)
28
+ free_before = DurationParser.to_seconds(free_before_value)
29
+ free_after = DurationParser.to_seconds(free_after_value)
30
+ time_min = DateTimeParser.parse(from_value.to_s, locale: locale)
31
+ time_max = resolve_time_max(p, time_min, locale)
32
+ raise Error, "'from' must be before end of window" if time_min >= time_max
33
+
34
+ hours = p[:hours].present? ? SlotTemplates.parse_hours(p[:hours]) : template[:hours]
35
+ days = p[:days].present? ? SlotTemplates.parse_days(p[:days]) : template[:days]
36
+ in_value = p[:in].presence
37
+
38
+ {
39
+ command: "slots",
40
+ calendar_ids: calendar_ids,
41
+ duration: duration_value,
42
+ from: from_value,
43
+ to: p[:to],
44
+ in: in_value,
45
+ free_before: free_before_value,
46
+ free_after: free_after_value,
47
+ template: template[:name],
48
+ locale: locale,
49
+ calendar: p[:calendar],
50
+ hours: hours,
51
+ days: days,
52
+ time_min: time_min,
53
+ time_max: time_max,
54
+ slot_duration_seconds: slot_duration,
55
+ free_before_seconds: free_before,
56
+ free_after_seconds: free_after,
57
+ format: p[:format].presence || "json"
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def resolve_time_max(params, time_min, locale)
64
+ if params[:in].present?
65
+ DurationParser.add_to_time(params[:in], time_min)
66
+ elsif params[:to].present?
67
+ DateTimeParser.parse(params[:to].to_s, end_of_day: true, locale: locale)
68
+ else
69
+ DurationParser.add_to_time("1 week", time_min)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flycal
4
+ # Named and custom slot windows. YAML/JSON shape matches ~/.flycal/config.yml:
5
+ #
6
+ # days: [1, 2, 3, 4, 5]
7
+ # hours:
8
+ # - 9:30-13:00
9
+ # - 14:00-18:30
10
+ module SlotTemplates
11
+ BUILTIN = {
12
+ "work" => {
13
+ "days" => [ 1, 2, 3, 4, 5 ],
14
+ "hours" => [ "9:30-13:00", "14:00-18:30" ]
15
+ },
16
+ "dinner" => {
17
+ "days" => [ 1, 2, 3, 4, 5, 6 ],
18
+ "hours" => [ "19-23" ]
19
+ }
20
+ }.freeze
21
+
22
+ module_function
23
+
24
+ def names(catalog = nil)
25
+ normalize_catalog(catalog || DefaultSlots.templates).keys
26
+ end
27
+
28
+ # Resolve a named template, or a custom YAML-shaped hash.
29
+ # +custom+ wins over +name+ when present.
30
+ def resolve(name: nil, custom: nil, catalog: nil)
31
+ if custom_present?(custom)
32
+ parsed = parse(custom)
33
+ return parsed.merge(name: "custom")
34
+ end
35
+
36
+ source = normalize_catalog(catalog || DefaultSlots.templates)
37
+ raise Error, "slots.templates is missing." if source.empty?
38
+ key = name.to_s.strip
39
+ key = source.keys.first.to_s if key.empty?
40
+ definition = source[key]
41
+ unless definition
42
+ raise Error, "Unknown slots template #{key.inspect}. Available: #{source.keys.join(", ")}"
43
+ end
44
+
45
+ parse(definition).merge(name: key)
46
+ end
47
+
48
+ def parse(definition)
49
+ data = indifferent(definition)
50
+ days = parse_days(data[:days])
51
+ hours = parse_hours(data[:hours])
52
+ { days: days, hours: hours }
53
+ end
54
+
55
+ def parse_days(values)
56
+ days = Array(values).map(&:to_i)
57
+ raise Error, "slots template days cannot be empty." if days.empty?
58
+
59
+ invalid = days.reject { |d| d.between?(1, 7) }
60
+ raise Error, "Invalid template days #{invalid.inspect}. Use 1 (Mon) .. 7 (Sun)." unless invalid.empty?
61
+
62
+ days.uniq
63
+ end
64
+
65
+ def parse_hours(values)
66
+ return values if values.is_a?(Array) && values.first.is_a?(Array)
67
+
68
+ ranges = Array(values).map { |item| parse_hour_range(item) }
69
+ raise Error, "template hours cannot be empty." if ranges.empty?
70
+
71
+ ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
72
+ end
73
+
74
+ def parse_hour_range(item)
75
+ start_str, end_str = item.to_s.strip.split("-", 2)
76
+ if start_str.nil? || end_str.nil? || start_str.empty? || end_str.empty?
77
+ raise Error, "Invalid hours item #{item.inspect}. Use format like '9-13' or '14:30-18:00'."
78
+ end
79
+
80
+ sh, sm = parse_clock(start_str)
81
+ eh, em = parse_clock(end_str)
82
+ start_minutes = (sh * 60) + sm
83
+ end_minutes = (eh * 60) + em
84
+ raise Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
85
+
86
+ [ sh, sm, eh, em ]
87
+ end
88
+
89
+ def parse_clock(value)
90
+ str = value.to_s.strip
91
+ if str.match?(/\A\d{1,2}\z/)
92
+ hour = str.to_i
93
+ raise Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
94
+
95
+ return [ hour, 0 ]
96
+ end
97
+
98
+ hour_s, minute_s = str.split(":", 2)
99
+ hour = hour_s.to_i
100
+ minute = minute_s.to_i
101
+ unless hour.between?(0, 23) && minute.between?(0, 59)
102
+ raise Error, "Invalid time #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)."
103
+ end
104
+
105
+ [ hour, minute ]
106
+ end
107
+
108
+ def custom_present?(custom)
109
+ return false if custom.nil?
110
+ return false if custom.respond_to?(:blank?) && custom.blank?
111
+ return false if custom.respond_to?(:empty?) && custom.empty?
112
+
113
+ true
114
+ end
115
+
116
+ def normalize_catalog(catalog)
117
+ indifferent(catalog).each_with_object({}) do |(key, value), out|
118
+ out[key.to_s] = value
119
+ end
120
+ end
121
+
122
+ def indifferent(value)
123
+ return {} if value.nil?
124
+ return value.with_indifferent_access if value.respond_to?(:with_indifferent_access)
125
+
126
+ value
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "date"
5
+
6
+ module Flycal
7
+ # Single clock for every datetime Flycal emits (JSON + text).
8
+ #
9
+ # Instants are converted to process-local Time so start/end/from/to never mix
10
+ # offsets (e.g. +02:00 vs +00:00) in the same payload.
11
+ module TimeFormat
12
+ module_function
13
+
14
+ def iso8601(value)
15
+ time = unified(value)
16
+ time&.iso8601
17
+ end
18
+
19
+ def unified(value)
20
+ time = coerce(value)
21
+ return nil unless time
22
+
23
+ Time.at(time.to_f)
24
+ end
25
+
26
+ def coerce(value)
27
+ return nil if value.nil?
28
+ return value if value.is_a?(Time)
29
+ return value.to_time if value.respond_to?(:to_time)
30
+
31
+ Time.parse(value.to_s)
32
+ rescue ArgumentError
33
+ nil
34
+ end
35
+ end
36
+ end
data/lib/flycal.rb CHANGED
@@ -4,7 +4,7 @@
4
4
  # Under Rails, Zeitwerk autoloads lib/flycal/*.
5
5
  # The CLI gem entrypoint (flycal_cli/lib/flycal_cli.rb) eager-requires what it needs.
6
6
  module Flycal
7
- VERSION = "1.0"
7
+ VERSION = "1.1"
8
8
 
9
9
  def self.connect(credentials:)
10
10
  Client.new(credentials: credentials)
data/lib/flycal_cli.rb CHANGED
@@ -10,6 +10,7 @@ $LOAD_PATH.unshift(cli_lib) unless $LOAD_PATH.include?(cli_lib)
10
10
  require "flycal/version"
11
11
  require "flycal/error"
12
12
  require "flycal/locale"
13
+ require "flycal/time_format"
13
14
  require "flycal/credentials"
14
15
  require "flycal/event_mapper"
15
16
  require "flycal/cache_key"
@@ -21,11 +22,15 @@ require "flycal/description_query"
21
22
  require "flycal/calendar_query"
22
23
  require "flycal/calendar_service"
23
24
  require "flycal/slot_finder"
25
+ require "flycal/slot_templates"
26
+ require "flycal/default_slots"
27
+ require "flycal/slot_query"
24
28
  require "flycal/slot_formatter"
25
29
  require "flycal/mock"
26
30
  require "flycal/mock/config"
27
31
  require "flycal/mock/event_generator"
28
32
  require "flycal/mock/calendar_service"
33
+ require "flycal/mock/slot_scenario"
29
34
  require "flycal/pipeline"
30
35
  require "flycal/pipeline/params"
31
36
  require "flycal/pipeline/retriever"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flycal-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: '1.0'
4
+ version: '1.1'
5
5
  platform: ruby
6
6
  authors:
7
7
  - flycal-cli
@@ -149,6 +149,7 @@ files:
149
149
  - lib/flycal/client.rb
150
150
  - lib/flycal/credentials.rb
151
151
  - lib/flycal/date_time_parser.rb
152
+ - lib/flycal/default_slots.rb
152
153
  - lib/flycal/description_query.rb
153
154
  - lib/flycal/duration_parser.rb
154
155
  - lib/flycal/error.rb
@@ -158,6 +159,7 @@ files:
158
159
  - lib/flycal/mock/calendar_service.rb
159
160
  - lib/flycal/mock/config.rb
160
161
  - lib/flycal/mock/event_generator.rb
162
+ - lib/flycal/mock/slot_scenario.rb
161
163
  - lib/flycal/pipeline.rb
162
164
  - lib/flycal/pipeline/aggregator.rb
163
165
  - lib/flycal/pipeline/json_renderer.rb
@@ -168,6 +170,9 @@ files:
168
170
  - lib/flycal/pipeline/text_renderer.rb
169
171
  - lib/flycal/slot_finder.rb
170
172
  - lib/flycal/slot_formatter.rb
173
+ - lib/flycal/slot_query.rb
174
+ - lib/flycal/slot_templates.rb
175
+ - lib/flycal/time_format.rb
171
176
  - lib/flycal/version.rb
172
177
  - lib/flycal_cli.rb
173
178
  - locales/en.json