flycal-cli 0.7.4 → 0.7.7
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/README.md +8 -1
- data/lib/flycal_cli/calendar_service.rb +2 -5
- data/lib/flycal_cli/cli.rb +74 -148
- data/lib/flycal_cli/description_query.rb +32 -0
- data/lib/flycal_cli/mock/calendar_service.rb +43 -0
- data/lib/flycal_cli/mock/config.rb +171 -0
- data/lib/flycal_cli/mock/event_generator.rb +71 -0
- data/lib/flycal_cli/mock.rb +10 -0
- data/lib/flycal_cli/pipeline/aggregator.rb +229 -0
- data/lib/flycal_cli/pipeline/json_renderer.rb +142 -0
- data/lib/flycal_cli/pipeline/params.rb +46 -0
- data/lib/flycal_cli/pipeline/renderer.rb +27 -0
- data/lib/flycal_cli/pipeline/retriever.rb +72 -0
- data/lib/flycal_cli/pipeline/search_pipeline.rb +18 -0
- data/lib/flycal_cli/pipeline/text_renderer.rb +133 -0
- data/lib/flycal_cli/pipeline.rb +14 -0
- data/lib/flycal_cli/version.rb +1 -1
- data/lib/flycal_cli.rb +3 -0
- data/mcp/tools.json +15 -1
- data/mockTemplates/mock1.json +11 -0
- data/mocks/mock1.json +11 -0
- metadata +16 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/date"
|
|
4
|
+
require "active_support/core_ext/integer"
|
|
5
|
+
require "active_support/core_ext/time"
|
|
6
|
+
|
|
7
|
+
module FlycalCli
|
|
8
|
+
module Pipeline
|
|
9
|
+
# Second pipeline layer: group events and compute aggregate metrics.
|
|
10
|
+
#
|
|
11
|
+
# Default group_by from timeframe:
|
|
12
|
+
# day — timeframe <= 7 days
|
|
13
|
+
# week — timeframe > 7 days and <= 30 days
|
|
14
|
+
# month — timeframe > 30 days
|
|
15
|
+
#
|
|
16
|
+
# Override with params[:group_by_option] / --groupBy:
|
|
17
|
+
# day | week | month | description
|
|
18
|
+
class Aggregator
|
|
19
|
+
HOURS_PER_WORKING_DAY = 8
|
|
20
|
+
ALLOWED_GROUP_BY = %w[day week month description].freeze
|
|
21
|
+
|
|
22
|
+
def call(params)
|
|
23
|
+
time_min = params[:time_min]
|
|
24
|
+
time_max = params[:time_max]
|
|
25
|
+
events = Array(params[:events])
|
|
26
|
+
|
|
27
|
+
timeframe_days = (time_max - time_min) / 86400.0
|
|
28
|
+
group_by = resolve_group_by(timeframe_days, params[:group_by_option])
|
|
29
|
+
|
|
30
|
+
params[:timeframe_days] = timeframe_days
|
|
31
|
+
params[:group_by] = group_by
|
|
32
|
+
|
|
33
|
+
ranges = events.filter_map do |ev|
|
|
34
|
+
next if ev[:start_at].nil? || ev[:end_at].nil?
|
|
35
|
+
|
|
36
|
+
[ev[:start_at], ev[:end_at]]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
total_minutes = ranges.sum { |s, e| (e - s) / 60.0 }
|
|
40
|
+
params[:totals] = {
|
|
41
|
+
event_count: events.size,
|
|
42
|
+
total_minutes: total_minutes,
|
|
43
|
+
hours: (total_minutes / 60.0).floor,
|
|
44
|
+
minutes: (total_minutes % 60).round,
|
|
45
|
+
working_days: (total_minutes / 60.0 / HOURS_PER_WORKING_DAY).round(1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
params[:groups] =
|
|
49
|
+
case group_by
|
|
50
|
+
when "week" then weekly_groups(events, ranges, time_min, time_max)
|
|
51
|
+
when "month" then monthly_groups(events, ranges, time_min, time_max)
|
|
52
|
+
when "description" then description_groups(events, params[:description])
|
|
53
|
+
else daily_groups(events, ranges, time_min, time_max)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
params
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.resolve_group_by(timeframe_days, explicit = nil)
|
|
60
|
+
key = explicit.to_s.strip.downcase
|
|
61
|
+
unless key.empty?
|
|
62
|
+
unless ALLOWED_GROUP_BY.include?(key)
|
|
63
|
+
raise FlycalCli::Error,
|
|
64
|
+
"Unsupported groupBy #{explicit.inspect}. Available: #{ALLOWED_GROUP_BY.join(", ")}"
|
|
65
|
+
end
|
|
66
|
+
return key
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if timeframe_days > 30
|
|
70
|
+
"month"
|
|
71
|
+
elsif timeframe_days > 7
|
|
72
|
+
"week"
|
|
73
|
+
else
|
|
74
|
+
"day"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def resolve_group_by(timeframe_days, explicit)
|
|
81
|
+
self.class.resolve_group_by(timeframe_days, explicit)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def daily_groups(events, ranges, time_min, time_max)
|
|
85
|
+
groups = []
|
|
86
|
+
index = 1
|
|
87
|
+
current = time_min.to_date
|
|
88
|
+
end_date = time_max.to_date
|
|
89
|
+
|
|
90
|
+
while current <= end_date
|
|
91
|
+
day_start = [current.to_time, time_min].max
|
|
92
|
+
day_end = [(current + 1).to_time, time_max].min
|
|
93
|
+
mins = minutes_in_period(ranges, day_start, day_end)
|
|
94
|
+
groups << build_group(
|
|
95
|
+
index: index,
|
|
96
|
+
key: current.strftime("%Y-%m-%d"),
|
|
97
|
+
start_at: day_start,
|
|
98
|
+
end_at: day_end,
|
|
99
|
+
period_label_end: current,
|
|
100
|
+
total_minutes: mins,
|
|
101
|
+
events: events_in_period(events, day_start, day_end)
|
|
102
|
+
)
|
|
103
|
+
index += 1
|
|
104
|
+
current += 1
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
groups
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def weekly_groups(events, ranges, time_min, time_max)
|
|
111
|
+
groups = []
|
|
112
|
+
index = 1
|
|
113
|
+
current = time_min.to_date.beginning_of_week(:monday)
|
|
114
|
+
|
|
115
|
+
while current.to_time < time_max
|
|
116
|
+
week_start = [current.to_time, time_min].max
|
|
117
|
+
week_end_date = current.end_of_week(:monday)
|
|
118
|
+
week_end = [week_end_date.to_time + 1.day, time_max].min
|
|
119
|
+
mins = minutes_in_period(ranges, week_start, week_end)
|
|
120
|
+
groups << build_group(
|
|
121
|
+
index: index,
|
|
122
|
+
key: "W#{index}",
|
|
123
|
+
start_at: week_start,
|
|
124
|
+
end_at: week_end,
|
|
125
|
+
period_label_end: week_end_date,
|
|
126
|
+
total_minutes: mins,
|
|
127
|
+
events: events_in_period(events, week_start, week_end)
|
|
128
|
+
)
|
|
129
|
+
index += 1
|
|
130
|
+
current += 7
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
groups
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def monthly_groups(events, ranges, time_min, time_max)
|
|
137
|
+
groups = []
|
|
138
|
+
index = 1
|
|
139
|
+
current = time_min.to_date.beginning_of_month
|
|
140
|
+
end_date = time_max.to_date
|
|
141
|
+
|
|
142
|
+
while current <= end_date
|
|
143
|
+
month_start = current.beginning_of_month.to_time
|
|
144
|
+
month_end = (current.end_of_month + 1.day).to_time
|
|
145
|
+
period_start = [month_start, time_min].max
|
|
146
|
+
period_end = [month_end, time_max].min
|
|
147
|
+
mins = minutes_in_period(ranges, period_start, period_end)
|
|
148
|
+
last_day = period_end.to_date - 1
|
|
149
|
+
groups << build_group(
|
|
150
|
+
index: index,
|
|
151
|
+
key: current.strftime("%Y-%m"),
|
|
152
|
+
start_at: period_start,
|
|
153
|
+
end_at: period_end,
|
|
154
|
+
period_label_end: last_day,
|
|
155
|
+
total_minutes: mins,
|
|
156
|
+
month_name: "#{Locale.month_name(current)} #{current.year}",
|
|
157
|
+
events: events_in_period(events, period_start, period_end)
|
|
158
|
+
)
|
|
159
|
+
index += 1
|
|
160
|
+
current = current.next_month.beginning_of_month
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
groups
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def description_groups(events, description_query)
|
|
167
|
+
patterns = DescriptionQuery.patterns(description_query)
|
|
168
|
+
if patterns.empty?
|
|
169
|
+
raise FlycalCli::Error,
|
|
170
|
+
"groupBy description requires --description with at least one term (e.g. rui|solver)."
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
patterns.each_with_index.map do |pattern, idx|
|
|
174
|
+
matched = events.select do |ev|
|
|
175
|
+
DescriptionQuery.match_term?(ev[:summary], ev[:description], pattern)
|
|
176
|
+
end
|
|
177
|
+
minutes = matched.sum { |ev| ev[:duration_minutes].to_f }
|
|
178
|
+
build_group(
|
|
179
|
+
index: idx + 1,
|
|
180
|
+
key: pattern,
|
|
181
|
+
start_at: nil,
|
|
182
|
+
end_at: nil,
|
|
183
|
+
period_label_end: nil,
|
|
184
|
+
total_minutes: minutes,
|
|
185
|
+
events: matched,
|
|
186
|
+
description: pattern
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def build_group(index:, key:, start_at:, end_at:, period_label_end:, total_minutes:, events:,
|
|
192
|
+
month_name: nil, description: nil)
|
|
193
|
+
{
|
|
194
|
+
index: index,
|
|
195
|
+
key: key,
|
|
196
|
+
start_at: start_at,
|
|
197
|
+
end_at: end_at,
|
|
198
|
+
period_label_end: period_label_end,
|
|
199
|
+
month_name: month_name,
|
|
200
|
+
description: description,
|
|
201
|
+
event_count: events.size,
|
|
202
|
+
total_minutes: total_minutes,
|
|
203
|
+
hours: (total_minutes / 60.0).round(1),
|
|
204
|
+
working_days: (total_minutes / 60.0 / HOURS_PER_WORKING_DAY).round(1),
|
|
205
|
+
events: events
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def events_in_period(events, period_start, period_end)
|
|
210
|
+
events.select do |ev|
|
|
211
|
+
start_at = ev[:start_at]
|
|
212
|
+
end_at = ev[:end_at]
|
|
213
|
+
next false if start_at.nil? || end_at.nil?
|
|
214
|
+
|
|
215
|
+
end_at > period_start && start_at < period_end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def minutes_in_period(ranges, period_start, period_end)
|
|
220
|
+
ranges.sum do |ev_start, ev_end|
|
|
221
|
+
overlap_start = [ev_start, period_start].max
|
|
222
|
+
overlap_end = [ev_end, period_end].min
|
|
223
|
+
overlap_sec = overlap_end - overlap_start
|
|
224
|
+
overlap_sec > 0 ? overlap_sec / 60.0 : 0
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FlycalCli
|
|
6
|
+
module Pipeline
|
|
7
|
+
# Pretty-printed JSON output for search (API / tooling friendly).
|
|
8
|
+
class JsonRenderer < Renderer
|
|
9
|
+
def render(params)
|
|
10
|
+
payload = {
|
|
11
|
+
"params" => build_params(params),
|
|
12
|
+
"info" => build_info(params),
|
|
13
|
+
"items" => Array(params[:events]).map { |ev| serialize_event(ev) },
|
|
14
|
+
"groups" => build_groups(params)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
JSON.pretty_generate(payload) + "\n"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def build_params(params)
|
|
23
|
+
out = {
|
|
24
|
+
"command" => params[:command] || "search",
|
|
25
|
+
"from" => format_time(params[:time_min]),
|
|
26
|
+
"to" => format_time(params[:time_max]),
|
|
27
|
+
"calendar" => params[:calendar],
|
|
28
|
+
"calendar_ids" => Array(params[:calendar_ids]),
|
|
29
|
+
"description" => params[:description],
|
|
30
|
+
"format" => params[:format] || "json",
|
|
31
|
+
"locale" => params[:locale] || Locale.current_locale,
|
|
32
|
+
"group_by" => params[:group_by],
|
|
33
|
+
"group_by_option" => blank_to_nil(params[:group_by_option]),
|
|
34
|
+
"from_option" => blank_to_nil(params[:from_option]),
|
|
35
|
+
"to_option" => blank_to_nil(params[:to_option]),
|
|
36
|
+
"in_option" => blank_to_nil(params[:in_option])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if params[:use_mock]
|
|
40
|
+
out["use_mock"] = true
|
|
41
|
+
out["mock_calendar"] = params[:mock_calendar]
|
|
42
|
+
out["mock_template"] = params[:mock_template]
|
|
43
|
+
out["mock_seed"] = params[:mock_seed]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
out
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_info(params)
|
|
50
|
+
totals = params[:totals] || {}
|
|
51
|
+
total_minutes = totals[:total_minutes].to_f
|
|
52
|
+
info = {
|
|
53
|
+
"events_found" => totals[:event_count].to_i,
|
|
54
|
+
"from" => format_time(params[:time_min]),
|
|
55
|
+
"to" => format_time(params[:time_max]),
|
|
56
|
+
"total_hours" => (total_minutes / 60.0).round(2),
|
|
57
|
+
"total_working_days" => totals[:working_days].to_f
|
|
58
|
+
}
|
|
59
|
+
info["mock_seed"] = params[:mock_seed] if params[:use_mock] && !params[:mock_seed].nil?
|
|
60
|
+
info
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_groups(params)
|
|
64
|
+
type = params[:group_by].to_s
|
|
65
|
+
Array(params[:groups]).map do |group|
|
|
66
|
+
{
|
|
67
|
+
"type" => type,
|
|
68
|
+
"key" => group[:key],
|
|
69
|
+
"index" => group[:index],
|
|
70
|
+
"total_hours" => group[:hours],
|
|
71
|
+
"total_working_days" => group[:working_days],
|
|
72
|
+
"events_found" => group[:event_count] || Array(group[:events]).size,
|
|
73
|
+
"items" => Array(group[:events]).map { |ev| serialize_event(ev) }
|
|
74
|
+
}.tap do |g|
|
|
75
|
+
if type == "description"
|
|
76
|
+
g["description"] = group[:description] || group[:key]
|
|
77
|
+
else
|
|
78
|
+
g["from"] = format_group_boundary(group[:start_at])
|
|
79
|
+
g["to"] = format_group_boundary(group[:end_at])
|
|
80
|
+
end
|
|
81
|
+
g["month_name"] = group[:month_name] if group[:month_name]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Shape inspired by Google Calendar API event resources.
|
|
87
|
+
def serialize_event(ev)
|
|
88
|
+
raw = ev[:raw]
|
|
89
|
+
payload = {
|
|
90
|
+
"summary" => ev[:summary],
|
|
91
|
+
"description" => ev[:description],
|
|
92
|
+
"start" => boundary_hash(ev[:start_at], all_day: ev[:all_day]),
|
|
93
|
+
"end" => boundary_hash(ev[:end_at], all_day: ev[:all_day]),
|
|
94
|
+
"calendarId" => ev[:calendar_id],
|
|
95
|
+
"calendarSummary" => ev[:calendar_name]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if raw
|
|
99
|
+
payload["id"] = raw.id if raw.respond_to?(:id) && raw.id
|
|
100
|
+
payload["status"] = raw.status if raw.respond_to?(:status) && raw.status
|
|
101
|
+
payload["htmlLink"] = raw.html_link if raw.respond_to?(:html_link) && raw.html_link
|
|
102
|
+
payload["created"] = format_time(raw.created) if raw.respond_to?(:created) && raw.created
|
|
103
|
+
payload["updated"] = format_time(raw.updated) if raw.respond_to?(:updated) && raw.updated
|
|
104
|
+
payload["location"] = raw.location if raw.respond_to?(:location) && raw.location
|
|
105
|
+
payload["iCalUID"] = raw.i_cal_uid if raw.respond_to?(:i_cal_uid) && raw.i_cal_uid
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
payload.compact
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def boundary_hash(time, all_day:)
|
|
112
|
+
return nil if time.nil?
|
|
113
|
+
|
|
114
|
+
if all_day
|
|
115
|
+
{ "date" => time.to_date.iso8601 }
|
|
116
|
+
else
|
|
117
|
+
{ "dateTime" => format_time(time) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def format_time(value)
|
|
122
|
+
return nil if value.nil?
|
|
123
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
124
|
+
|
|
125
|
+
value.to_s
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Compact local datetime used on group boundaries: YYYYMMDDTHHMMSS
|
|
129
|
+
def format_group_boundary(value)
|
|
130
|
+
return nil if value.nil?
|
|
131
|
+
|
|
132
|
+
t = value.respond_to?(:to_time) ? value.to_time : value
|
|
133
|
+
t.strftime("%Y%m%dT%H%M%S")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def blank_to_nil(value)
|
|
137
|
+
str = value.to_s
|
|
138
|
+
str.empty? ? nil : str
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlycalCli
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Mutable bag of CLI + derived parameters passed through the pipeline.
|
|
6
|
+
class Params
|
|
7
|
+
def initialize(initial = {})
|
|
8
|
+
@data = {}
|
|
9
|
+
initial.each { |k, v| self[k] = v }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def [](key)
|
|
13
|
+
@data[key.to_sym]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def []=(key, value)
|
|
17
|
+
@data[key.to_sym] = value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fetch(key, default = nil, &block)
|
|
21
|
+
if block
|
|
22
|
+
@data.fetch(key.to_sym, &block)
|
|
23
|
+
else
|
|
24
|
+
@data.fetch(key.to_sym, default)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def key?(key)
|
|
29
|
+
@data.key?(key.to_sym)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def merge!(other)
|
|
33
|
+
other.each { |k, v| self[k] = v }
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
@data.dup
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def each(&block)
|
|
42
|
+
@data.each(&block)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlycalCli
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Third pipeline layer: render aggregated results to a chosen format.
|
|
6
|
+
class Renderer
|
|
7
|
+
FORMATS = {
|
|
8
|
+
"text" => "FlycalCli::Pipeline::TextRenderer",
|
|
9
|
+
"json" => "FlycalCli::Pipeline::JsonRenderer"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def self.for(format)
|
|
13
|
+
key = format.to_s.strip.downcase
|
|
14
|
+
key = "text" if key.empty?
|
|
15
|
+
class_name = FORMATS[key]
|
|
16
|
+
raise FlycalCli::Error, "Unsupported format #{format.inspect}. Available: #{FORMATS.keys.join(", ")}" unless class_name
|
|
17
|
+
|
|
18
|
+
Object.const_get(class_name).new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [String] rendered output
|
|
22
|
+
def render(params)
|
|
23
|
+
raise NotImplementedError, "#{self.class}#render must be implemented"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlycalCli
|
|
4
|
+
module Pipeline
|
|
5
|
+
# First pipeline layer: fetch calendar events for the requested timeframe.
|
|
6
|
+
class Retriever
|
|
7
|
+
def initialize(calendar_service)
|
|
8
|
+
@service = calendar_service
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Expects params: :calendar_ids, :time_min, :time_max, optional :description
|
|
12
|
+
# Writes: :events (normalized), :calendar_names
|
|
13
|
+
def call(params)
|
|
14
|
+
calendar_ids = Array(params[:calendar_ids])
|
|
15
|
+
time_min = params[:time_min]
|
|
16
|
+
time_max = params[:time_max]
|
|
17
|
+
query = params[:description]
|
|
18
|
+
|
|
19
|
+
calendar_list = @service.list_calendars
|
|
20
|
+
calendar_names = calendar_list.to_h { |c| [c.id, c.summary || c.id] }
|
|
21
|
+
params[:calendar_names] = calendar_names
|
|
22
|
+
|
|
23
|
+
raw = @service.list_all_events(
|
|
24
|
+
calendar_ids,
|
|
25
|
+
time_min: time_min,
|
|
26
|
+
time_max: time_max,
|
|
27
|
+
query: query
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
params[:events] = raw.map { |item| normalize_event(item, calendar_names) }
|
|
31
|
+
params
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def normalize_event(item, calendar_names)
|
|
37
|
+
event = item[:event]
|
|
38
|
+
cal_id = item[:calendar_id]
|
|
39
|
+
start_at = to_time(event.start&.date_time || event.start&.date)
|
|
40
|
+
end_at = to_time(event.end&.date_time || event.end&.date)
|
|
41
|
+
minutes =
|
|
42
|
+
if start_at && end_at && end_at > start_at
|
|
43
|
+
(end_at - start_at) / 60.0
|
|
44
|
+
else
|
|
45
|
+
0.0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
calendar_id: cal_id,
|
|
50
|
+
calendar_name: calendar_names[cal_id] || cal_id,
|
|
51
|
+
summary: event.summary,
|
|
52
|
+
description: event.description,
|
|
53
|
+
start_at: start_at,
|
|
54
|
+
end_at: end_at,
|
|
55
|
+
duration_minutes: minutes,
|
|
56
|
+
all_day: !event.start&.date.nil? && event.start&.date_time.nil?,
|
|
57
|
+
raw: event
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_time(value)
|
|
62
|
+
return nil if value.nil?
|
|
63
|
+
return value if value.is_a?(Time)
|
|
64
|
+
return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String)
|
|
65
|
+
|
|
66
|
+
Time.parse(value.to_s)
|
|
67
|
+
rescue ArgumentError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlycalCli
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Orchestrates Retriever → Aggregator → Renderer for search.
|
|
6
|
+
class SearchPipeline
|
|
7
|
+
def initialize(calendar_service)
|
|
8
|
+
@service = calendar_service
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run(params)
|
|
12
|
+
Retriever.new(@service).call(params)
|
|
13
|
+
Aggregator.new.call(params)
|
|
14
|
+
Renderer.for(params[:format]).render(params)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlycalCli
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Shell text output — preserves the historical flycal search formatting.
|
|
6
|
+
class TextRenderer < Renderer
|
|
7
|
+
HOURS_PER_WORKING_DAY = Aggregator::HOURS_PER_WORKING_DAY
|
|
8
|
+
|
|
9
|
+
def render(params)
|
|
10
|
+
lines = []
|
|
11
|
+
lines.concat(event_lines(params))
|
|
12
|
+
lines << ""
|
|
13
|
+
lines.concat(summary_lines(params))
|
|
14
|
+
lines.join("\n") + "\n"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def event_lines(params)
|
|
20
|
+
Array(params[:events]).map do |ev|
|
|
21
|
+
start_str = format_datetime(ev[:start_at])
|
|
22
|
+
end_str = format_datetime(ev[:end_at])
|
|
23
|
+
desc = ev[:summary] || "(no title)"
|
|
24
|
+
if ev[:summary].nil? && ev[:description]
|
|
25
|
+
desc = ev[:description].to_s.slice(0, 80)
|
|
26
|
+
end
|
|
27
|
+
"#{ev[:calendar_name]} | #{start_str} | #{end_str} | #{desc}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def summary_lines(params)
|
|
32
|
+
totals = params[:totals] || {}
|
|
33
|
+
time_min = params[:time_min]
|
|
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")
|
|
37
|
+
|
|
38
|
+
lines = []
|
|
39
|
+
lines << "---"
|
|
40
|
+
lines << "From: #{from_str} | To: #{to_str}"
|
|
41
|
+
lines << "Events found: #{totals[:event_count]}"
|
|
42
|
+
lines << "Total time occupied: #{format_duration(totals[:total_minutes].to_f)}"
|
|
43
|
+
lines << "Mock seed: #{params[:mock_seed]}" if params[:use_mock] && !params[:mock_seed].nil?
|
|
44
|
+
|
|
45
|
+
case params[:group_by]
|
|
46
|
+
when "week"
|
|
47
|
+
lines << ""
|
|
48
|
+
lines << "By week:"
|
|
49
|
+
lines.concat(week_group_lines(params[:groups]))
|
|
50
|
+
when "month"
|
|
51
|
+
lines << ""
|
|
52
|
+
lines << "By month:"
|
|
53
|
+
lines.concat(month_group_lines(params[:groups]))
|
|
54
|
+
when "day"
|
|
55
|
+
if explicit_group_by?(params, "day")
|
|
56
|
+
lines << ""
|
|
57
|
+
lines << "By day:"
|
|
58
|
+
lines.concat(day_group_lines(params[:groups]))
|
|
59
|
+
end
|
|
60
|
+
when "description"
|
|
61
|
+
lines << ""
|
|
62
|
+
lines << "By description:"
|
|
63
|
+
lines.concat(description_group_lines(params[:groups]))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
lines
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def explicit_group_by?(params, value)
|
|
70
|
+
params[:group_by_option].to_s.strip.downcase == value
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def day_group_lines(groups)
|
|
74
|
+
Array(groups).map do |g|
|
|
75
|
+
day_str = format_date_with_day(g[:start_at])
|
|
76
|
+
" #{bold(g[:index])}. #{day_str}: #{format_hours_and_days(g[:hours], g[:working_days])}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def week_group_lines(groups)
|
|
81
|
+
Array(groups).map do |g|
|
|
82
|
+
start_str = format_date_with_day(g[:start_at])
|
|
83
|
+
end_str = format_date_with_day(g[:period_label_end])
|
|
84
|
+
" #{bold(g[:index])}. #{start_str} - #{end_str}: #{format_hours_and_days(g[:hours], g[:working_days])}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def month_group_lines(groups)
|
|
89
|
+
Array(groups).map do |g|
|
|
90
|
+
start_str = format_date_with_day(g[:start_at])
|
|
91
|
+
end_str = format_date_with_day(g[:period_label_end])
|
|
92
|
+
" #{g[:index]}. #{g[:month_name]} (#{start_str} - #{end_str}): #{format_hours_and_days(g[:hours], g[:working_days])}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def description_group_lines(groups)
|
|
97
|
+
Array(groups).map do |g|
|
|
98
|
+
label = g[:description] || g[:key]
|
|
99
|
+
" #{bold(g[:index])}. #{label}: #{g[:event_count]} events, #{format_hours_and_days(g[:hours], g[:working_days])}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def format_datetime(dt)
|
|
104
|
+
return "-" if dt.nil?
|
|
105
|
+
return dt if dt.is_a?(String)
|
|
106
|
+
|
|
107
|
+
"#{Locale.day_abbr(dt)} #{dt.strftime("%Y-%m-%d %H:%M")}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def format_date_with_day(dt)
|
|
111
|
+
return "-" if dt.nil?
|
|
112
|
+
|
|
113
|
+
t = dt.respond_to?(:to_time) ? dt.to_time : dt
|
|
114
|
+
"#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d")}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def format_duration(total_minutes)
|
|
118
|
+
hours = (total_minutes / 60).floor
|
|
119
|
+
mins = (total_minutes % 60).round
|
|
120
|
+
working_days = (total_minutes / 60.0 / HOURS_PER_WORKING_DAY).round(1)
|
|
121
|
+
"#{bold(hours)}h #{bold(mins)}min (#{bold(working_days)} working days)"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def format_hours_and_days(hours, working_days)
|
|
125
|
+
"#{bold(hours)}h (#{bold(working_days)} working days)"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def bold(str)
|
|
129
|
+
"\e[1m#{str}\e[0m"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "flycal_cli/pipeline/params"
|
|
4
|
+
require "flycal_cli/pipeline/retriever"
|
|
5
|
+
require "flycal_cli/pipeline/aggregator"
|
|
6
|
+
require "flycal_cli/pipeline/renderer"
|
|
7
|
+
require "flycal_cli/pipeline/text_renderer"
|
|
8
|
+
require "flycal_cli/pipeline/json_renderer"
|
|
9
|
+
require "flycal_cli/pipeline/search_pipeline"
|
|
10
|
+
|
|
11
|
+
module FlycalCli
|
|
12
|
+
module Pipeline
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/flycal_cli/version.rb
CHANGED