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 +4 -4
- data/config/defaults.yml +2 -28
- data/lib/flycal/cli/app.rb +4 -73
- data/lib/flycal/cli/config.rb +2 -1
- data/lib/flycal/client.rb +31 -76
- data/lib/flycal/default_slots.rb +77 -0
- data/lib/flycal/mock/event_generator.rb +4 -2
- data/lib/flycal/mock/slot_scenario.rb +75 -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 +11 -11
- data/lib/flycal/slot_formatter.rb +5 -4
- data/lib/flycal/slot_query.rb +74 -0
- data/lib/flycal/slot_templates.rb +129 -0
- data/lib/flycal/time_format.rb +36 -0
- data/lib/flycal.rb +1 -1
- data/lib/flycal_cli.rb +5 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fcbd8c2cfb6f90272cf8cc8cfa489bc99a7487efca11f68560b2d7f96ac92f34
|
|
4
|
+
data.tar.gz: 21e92239db0dc5b3f587abdd1e859b5e67e67293706f6ec1f50ca34ca4fe0e1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
data/lib/flycal/cli/app.rb
CHANGED
|
@@ -334,14 +334,15 @@ module Cli
|
|
|
334
334
|
from_value = "now" if from_value.empty?
|
|
335
335
|
|
|
336
336
|
begin
|
|
337
|
-
|
|
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)
|
data/lib/flycal/cli/config.rb
CHANGED
|
@@ -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
|
-
|
|
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, 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
|
-
|
|
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:
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
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.
|
|
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
|