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.
- checksums.yaml +4 -4
- data/config/defaults.yml +3 -28
- data/config/oauth_client.json +14 -0
- data/lib/flycal/cli/app.rb +112 -143
- data/lib/flycal/cli/auth.rb +113 -42
- data/lib/flycal/cli/config.rb +24 -1
- data/lib/flycal/client.rb +73 -98
- data/lib/flycal/date_time_parser.rb +11 -10
- data/lib/flycal/default_slots.rb +77 -0
- data/lib/flycal/mock/event_generator.rb +6 -4
- data/lib/flycal/mock/slot_scenario.rb +78 -0
- data/lib/flycal/pipeline/json_renderer.rb +1 -5
- data/lib/flycal/pipeline/text_renderer.rb +5 -4
- data/lib/flycal/slot_finder.rb +16 -16
- data/lib/flycal/slot_formatter.rb +8 -4
- data/lib/flycal/slot_query.rb +80 -0
- data/lib/flycal/slot_templates.rb +129 -0
- data/lib/flycal/time_format.rb +114 -0
- data/lib/flycal.rb +1 -1
- data/lib/flycal_cli.rb +5 -0
- data/locales/en.json +18 -0
- data/locales/it.json +18 -0
- data/mcp/tools.json +15 -3
- metadata +7 -1
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
|
-
|
|
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
|
|
115
|
-
# free_before, free_after, hours
|
|
116
|
-
# template (
|
|
117
|
-
#
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
86
|
+
return TimeFormat.now
|
|
87
87
|
when "today", "oggi"
|
|
88
|
-
return time_for_date(
|
|
88
|
+
return time_for_date(TimeFormat.today, end_of_day: end_of_day)
|
|
89
89
|
when "tomorrow", "domani"
|
|
90
|
-
return time_for_date(
|
|
90
|
+
return time_for_date(TimeFormat.today + 1, end_of_day: end_of_day)
|
|
91
91
|
when "yesterday", "ieri"
|
|
92
|
-
return time_for_date(
|
|
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 =
|
|
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
|
-
|
|
145
|
+
midnight = TimeFormat.local(date.year, date.month, date.day)
|
|
146
|
+
return [ midnight, TimeFormat.now ].max if date == TimeFormat.today
|
|
146
147
|
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
60
|
-
window_end = day.
|
|
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
|
-
|
|
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
|
|
36
|
-
to_str = time_max
|
|
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
|
-
|
|
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 =
|
|
115
|
+
t = TimeFormat.unified(dt)
|
|
115
116
|
"#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d")}"
|
|
116
117
|
end
|
|
117
118
|
|
data/lib/flycal/slot_finder.rb
CHANGED
|
@@ -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 =
|
|
73
|
-
end_at =
|
|
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
|
-
|
|
155
|
-
remainder =
|
|
154
|
+
time = to_time(time)
|
|
155
|
+
remainder = time.to_i % STEP_SECONDS
|
|
156
156
|
return time if remainder.zero?
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
time + (STEP_SECONDS - remainder)
|
|
159
159
|
end
|
|
160
160
|
|
|
161
161
|
def round_down_15(time)
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|