flycal-cli 1.0 → 1.2

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.
data/lib/flycal/client.rb CHANGED
@@ -66,121 +66,96 @@ 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, timezone (IANA, default Europe/Rome or the Google calendar TZ),
77
+ # calendar (label for meta)
78
+ # Pass +events:+ to skip Google (tests / mock).
79
+ def slots(params = {}, events: nil)
80
+ previous_locale = Thread.current[:flycal_locale_override]
119
81
  p = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params
120
- q = resolve_slots_query(p)
121
82
 
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 }
83
+ calendar_list = nil
84
+ unless events
85
+ calendar_list = begin
86
+ list_calendars
87
+ rescue StandardError
88
+ nil
89
+ end
126
90
  end
127
91
 
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
-
136
- finder = SlotFinder.new(
137
- events: events,
138
- time_min: q[:time_min],
139
- time_max: q[:time_max],
140
- slot_duration_seconds: q[:slot_duration_seconds],
141
- hours: q[:hours],
142
- days: q[:days],
143
- free_before_seconds: q[:free_before_seconds],
144
- free_after_seconds: q[:free_after_seconds]
92
+ timezone = TimeFormat.resolve(
93
+ explicit: p[:timezone],
94
+ calendars: calendar_list,
95
+ calendar_ids: p[:calendar_ids]
145
96
  )
146
97
 
147
- SlotFormatter.payload_hash(
148
- slots_by_day: finder.slots_by_day,
149
- time_min: q[:time_min],
150
- time_max: q[:time_max],
151
- duration: q[:duration],
152
- template: q[:template],
153
- calendars: calendar_meta,
154
- locale: q[:locale],
155
- calendar_option: q[:calendar],
156
- from_option: p[:from],
157
- in_option: p[:in],
158
- free_before: q[:free_before],
159
- free_after: q[:free_after],
160
- empty_message: Locale.t("slots.no_available")
161
- )
98
+ TimeFormat.with_zone(timezone) do
99
+ q = resolve_slots_query(p.merge(timezone: timezone))
100
+ Locale.override!(q[:locale])
101
+
102
+ calendar_meta, loaded_events = load_slot_events(q, events, calendars: calendar_list)
103
+ finder = SlotFinder.new(
104
+ events: loaded_events,
105
+ time_min: q[:time_min],
106
+ time_max: q[:time_max],
107
+ slot_duration_seconds: q[:slot_duration_seconds],
108
+ hours: q[:hours],
109
+ days: q[:days],
110
+ free_before_seconds: q[:free_before_seconds],
111
+ free_after_seconds: q[:free_after_seconds]
112
+ )
113
+
114
+ SlotFormatter.payload_hash(
115
+ slots_by_day: finder.slots_by_day,
116
+ time_min: q[:time_min],
117
+ time_max: q[:time_max],
118
+ duration: q[:duration],
119
+ template: q[:template],
120
+ calendars: calendar_meta,
121
+ locale: q[:locale],
122
+ timezone: timezone,
123
+ calendar_option: q[:calendar],
124
+ from_option: p[:from],
125
+ to_option: p[:to],
126
+ in_option: p[:in],
127
+ free_before: q[:free_before],
128
+ free_after: q[:free_after],
129
+ empty_message: Locale.t("slots.no_available")
130
+ )
131
+ end
132
+ ensure
133
+ Thread.current[:flycal_locale_override] = previous_locale
162
134
  end
163
135
 
164
136
  private
165
137
 
166
- def normalize_hours(value)
167
- return nil if value.blank?
168
- return value if value.is_a?(Array) && value.first.is_a?(Array)
138
+ def load_slot_events(query, events, calendars: nil)
139
+ if events
140
+ meta = query[:calendar_ids].map { |id| { id: id, name: id } }
141
+ return [ meta, Array(events) ]
142
+ end
169
143
 
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 ]
144
+ calendars ||= list_calendars
145
+ meta = query[:calendar_ids].filter_map do |id|
146
+ cal = calendars.find { |c| c.id == id }
147
+ { id: id, name: cal&.summary || id }
176
148
  end
177
- end
178
149
 
179
- def normalize_days(value)
180
- return nil if value.blank?
181
- return value.map(&:to_i) if value.is_a?(Array)
150
+ fetch_from = query[:time_min] - query[:free_before_seconds]
151
+ loaded = query[:calendar_ids].flat_map do |calendar_id|
152
+ list_events(calendar_id, time_min: fetch_from, time_max: query[:time_max])
153
+ rescue StandardError => e
154
+ warn "Flycal slots: skip calendar #{calendar_id}: #{e.message}"
155
+ []
156
+ end
182
157
 
183
- value.to_s.split(",").map(&:strip).reject(&:empty?).map(&:to_i)
158
+ [ meta, loaded ]
184
159
  end
185
160
  end
186
161
  end
@@ -69,7 +69,7 @@ module Flycal
69
69
  end
70
70
  end
71
71
 
72
- def parse_or_default(str, default: Time.now, end_of_day: false, locale: nil)
72
+ def parse_or_default(str, default: TimeFormat.now, end_of_day: false, locale: nil)
73
73
  return default if str.nil? || str.to_s.strip.empty?
74
74
 
75
75
  parse(str, end_of_day: end_of_day, locale: locale)
@@ -83,13 +83,13 @@ module Flycal
83
83
 
84
84
  case normalized
85
85
  when "now", "adesso", "ora"
86
- return Time.now
86
+ return TimeFormat.now
87
87
  when "today", "oggi"
88
- return time_for_date(Date.today, end_of_day: end_of_day)
88
+ return time_for_date(TimeFormat.today, end_of_day: end_of_day)
89
89
  when "tomorrow", "domani"
90
- return time_for_date(Date.today + 1, end_of_day: end_of_day)
90
+ return time_for_date(TimeFormat.today + 1, end_of_day: end_of_day)
91
91
  when "yesterday", "ieri"
92
- return time_for_date(Date.today - 1, end_of_day: end_of_day)
92
+ return time_for_date(TimeFormat.today - 1, end_of_day: end_of_day)
93
93
  end
94
94
 
95
95
  weekday = parse_weekday_phrase(normalized)
@@ -117,7 +117,7 @@ module Flycal
117
117
  wday = WEEKDAYS[day_token]
118
118
  return nil unless wday
119
119
 
120
- today = Date.today
120
+ today = TimeFormat.today
121
121
 
122
122
  if qualifier.nil? || THIS_WORDS.include?(qualifier)
123
123
  next_weekday(today, wday, inclusive: true)
@@ -142,9 +142,10 @@ module Flycal
142
142
 
143
143
  def time_for_date(date, end_of_day:)
144
144
  return end_of_day_for(date) if end_of_day
145
- return [date.to_time, Time.now].max if date == Date.today
145
+ midnight = TimeFormat.local(date.year, date.month, date.day)
146
+ return [ midnight, TimeFormat.now ].max if date == TimeFormat.today
146
147
 
147
- date.to_time
148
+ midnight
148
149
  end
149
150
 
150
151
  def formats_for(locale)
@@ -192,13 +193,13 @@ module Flycal
192
193
  return time_for_date(date, end_of_day: end_of_day)
193
194
  end
194
195
 
195
- Time.local(year, month, day, hour, min || 0, sec || 0)
196
+ TimeFormat.local(year, month, day, hour, min || 0, sec || 0)
196
197
  rescue ArgumentError
197
198
  raise Flycal::Error, "Invalid date: #{year}-#{month}-#{day}"
198
199
  end
199
200
 
200
201
  def end_of_day_for(date)
201
- Time.local(date.year, date.month, date.day, 23, 59, 59)
202
+ TimeFormat.local(date.year, date.month, date.day, 23, 59, 59)
202
203
  end
203
204
 
204
205
  def time_component?(value)
@@ -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
@@ -56,8 +58,8 @@ module Flycal
56
58
  def random_start_on(day, duration_seconds)
57
59
  from_h, from_m = @config.hours_from
58
60
  to_h, to_m = @config.hours_to
59
- window_start = day.to_time + (from_h * 3600) + (from_m * 60)
60
- window_end = day.to_time + (to_h * 3600) + (to_m * 60)
61
+ window_start = TimeFormat.local(day.year, day.month, day.day, from_h, from_m)
62
+ window_end = TimeFormat.local(day.year, day.month, day.day, to_h, to_m)
61
63
  latest_start = window_end - duration_seconds
62
64
  if latest_start < window_start
63
65
  raise Flycal::Error, "Mock event duration does not fit in the daily hours window."
@@ -0,0 +1,78 @@
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
+ timezone = TimeFormat.resolve(explicit: slots[:timezone] || slots["timezone"])
32
+ TimeFormat.with_zone(timezone) do
33
+ @events = EventGenerator.new(@config).generate
34
+ @query = SlotQuery.resolve(
35
+ { calendar_ids: [ @config.calendar_name ], timezone: timezone }.merge(symbolize(slots))
36
+ )
37
+ finder = SlotFinder.new(
38
+ events: @events,
39
+ time_min: @query[:time_min],
40
+ time_max: @query[:time_max],
41
+ slot_duration_seconds: @query[:slot_duration_seconds],
42
+ hours: @query[:hours],
43
+ days: @query[:days],
44
+ free_before_seconds: @query[:free_before_seconds],
45
+ free_after_seconds: @query[:free_after_seconds]
46
+ )
47
+ @slots_by_day = finder.slots_by_day
48
+ @expected_count = @slots_by_day.values.sum(&:size)
49
+ end
50
+ end
51
+
52
+ def to_h
53
+ {
54
+ seed: seed,
55
+ expected_count: expected_count,
56
+ events: events.map { |event| serialize_event(event) },
57
+ slots_by_day: slots_by_day
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def serialize_event(event)
64
+ {
65
+ summary: event.summary,
66
+ start: event.start.date_time,
67
+ end: event.end.date_time
68
+ }
69
+ end
70
+
71
+ def symbolize(hash)
72
+ hash.each_with_object({}) do |(key, value), out|
73
+ out[key.to_sym] = value
74
+ end
75
+ end
76
+ end
77
+ end
78
+ 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)
@@ -69,11 +69,11 @@ module Flycal
69
69
 
70
70
  def day_intervals(date)
71
71
  @hours.map do |start_h, start_m, end_h, end_m|
72
- start_at = Time.local(date.year, date.month, date.day, start_h, start_m, 0)
73
- end_at = Time.local(date.year, date.month, date.day, end_h, end_m, 0)
74
- start_at = [@time_min, start_at].max
75
- end_at = [@time_max, end_at].min
76
- [start_at, end_at]
72
+ start_at = TimeFormat.local(date.year, date.month, date.day, start_h, start_m, 0)
73
+ end_at = TimeFormat.local(date.year, date.month, date.day, end_h, end_m, 0)
74
+ start_at = [ @time_min, start_at ].max
75
+ end_at = [ @time_max, end_at ].min
76
+ [ start_at, end_at ]
77
77
  end
78
78
  end
79
79
 
@@ -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
@@ -77,8 +77,10 @@ module Flycal
77
77
  template:,
78
78
  calendars:,
79
79
  locale: nil,
80
+ timezone: nil,
80
81
  calendar_option: nil,
81
82
  from_option: nil,
83
+ to_option: nil,
82
84
  in_option: nil,
83
85
  free_before: nil,
84
86
  free_after: nil,
@@ -120,7 +122,9 @@ module Flycal
120
122
  "calendar_ids" => Array(calendars).map { |c| c[:id] },
121
123
  "format" => "json",
122
124
  "locale" => locale || Locale.current_locale,
125
+ "timezone" => timezone || TimeFormat.zone_name,
123
126
  "from_option" => blank_to_nil(from_option),
127
+ "to_option" => blank_to_nil(to_option),
124
128
  "in_option" => blank_to_nil(in_option),
125
129
  "free_before" => free_before,
126
130
  "free_after" => free_after
@@ -131,6 +135,7 @@ module Flycal
131
135
  "to" => format_iso(time_max),
132
136
  "duration" => duration,
133
137
  "template" => template,
138
+ "timezone" => timezone || TimeFormat.zone_name,
134
139
  "calendars" => Array(calendars).map { |c| { "id" => c[:id], "name" => c[:name] } },
135
140
  "link" => google_calendar_day_url(time_min)
136
141
  },
@@ -156,10 +161,7 @@ module Flycal
156
161
  end
157
162
 
158
163
  def format_iso(value)
159
- return nil if value.nil?
160
- return value.iso8601 if value.respond_to?(:iso8601)
161
-
162
- value.to_s
164
+ TimeFormat.iso8601(value)
163
165
  end
164
166
 
165
167
  def blank_to_nil(value)
@@ -185,6 +187,8 @@ module Flycal
185
187
  end
186
188
 
187
189
  def format_time(time)
190
+ time = TimeFormat.unified(time)
191
+ return "" unless time
188
192
  return time.strftime("%-H") if time.min.zero?
189
193
 
190
194
  minutes = sprintf("%02d", time.min)
@@ -0,0 +1,80 @@
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
+ timezone = TimeFormat.resolve(explicit: p[:timezone])
16
+ TimeFormat.with_zone(timezone) { build(p, calendar_ids, timezone) }
17
+ end
18
+
19
+ private
20
+
21
+ def build(p, calendar_ids, timezone)
22
+ duration_value = p[:duration].presence || "45min"
23
+ from_value = p[:from].presence || "now"
24
+ free_before_value = p[:free_before].presence || "0m"
25
+ free_after_value = p[:free_after].presence || "0m"
26
+ locale = p[:locale].presence || Locale.current_locale
27
+
28
+ template = SlotTemplates.resolve(
29
+ name: p[:template],
30
+ custom: p[:template_custom],
31
+ catalog: p[:template_catalog]
32
+ )
33
+
34
+ slot_duration = DurationParser.to_seconds(duration_value)
35
+ free_before = DurationParser.to_seconds(free_before_value)
36
+ free_after = DurationParser.to_seconds(free_after_value)
37
+ time_min = DateTimeParser.parse(from_value.to_s, locale: locale)
38
+ time_max = resolve_time_max(p, time_min, locale)
39
+ raise Error, "'from' must be before end of window" if time_min >= time_max
40
+
41
+ hours = p[:hours].present? ? SlotTemplates.parse_hours(p[:hours]) : template[:hours]
42
+ days = p[:days].present? ? SlotTemplates.parse_days(p[:days]) : template[:days]
43
+ in_value = p[:in].presence
44
+
45
+ {
46
+ command: "slots",
47
+ calendar_ids: calendar_ids,
48
+ duration: duration_value,
49
+ from: from_value,
50
+ to: p[:to],
51
+ in: in_value,
52
+ free_before: free_before_value,
53
+ free_after: free_after_value,
54
+ template: template[:name],
55
+ locale: locale,
56
+ timezone: timezone,
57
+ calendar: p[:calendar],
58
+ hours: hours,
59
+ days: days,
60
+ time_min: time_min,
61
+ time_max: time_max,
62
+ slot_duration_seconds: slot_duration,
63
+ free_before_seconds: free_before,
64
+ free_after_seconds: free_after,
65
+ format: p[:format].presence || "json"
66
+ }
67
+ end
68
+
69
+ def resolve_time_max(params, time_min, locale)
70
+ if params[:in].present?
71
+ DurationParser.add_to_time(params[:in], time_min)
72
+ elsif params[:to].present?
73
+ DateTimeParser.parse(params[:to].to_s, end_of_day: true, locale: locale)
74
+ else
75
+ DurationParser.add_to_time("1 week", time_min)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end