flycal-cli 0.7.4 → 1.0
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 +6 -174
- data/bin/flycal +2 -2
- data/lib/flycal/cache_annotator.rb +83 -0
- data/lib/flycal/cache_key.rb +82 -0
- data/lib/flycal/calendar_query.rb +60 -0
- data/lib/flycal/calendar_service.rb +115 -0
- data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +320 -250
- data/lib/{flycal_cli → flycal/cli}/auth.rb +5 -3
- data/lib/{flycal_cli → flycal/cli}/clipboard.rb +3 -1
- data/lib/{flycal_cli → flycal/cli}/config.rb +4 -2
- data/lib/flycal/cli/file_cache.rb +44 -0
- data/lib/flycal/cli/locale.rb +58 -0
- data/lib/flycal/cli.rb +14 -0
- data/lib/flycal/client.rb +186 -0
- data/lib/flycal/credentials.rb +34 -0
- data/lib/{flycal_cli → flycal}/date_time_parser.rb +4 -4
- data/lib/flycal/description_query.rb +32 -0
- data/lib/{flycal_cli → flycal}/duration_parser.rb +3 -3
- data/lib/flycal/error.rb +5 -0
- data/lib/flycal/event_mapper.rb +58 -0
- data/lib/{flycal_cli → flycal}/locale.rb +35 -3
- data/lib/flycal/mock/calendar_service.rb +43 -0
- data/lib/flycal/mock/config.rb +171 -0
- data/lib/flycal/mock/event_generator.rb +71 -0
- data/lib/flycal/mock.rb +6 -0
- data/lib/flycal/pipeline/aggregator.rb +226 -0
- data/lib/flycal/pipeline/json_renderer.rb +145 -0
- data/lib/flycal/pipeline/params.rb +46 -0
- data/lib/flycal/pipeline/renderer.rb +27 -0
- data/lib/flycal/pipeline/retriever.rb +72 -0
- data/lib/flycal/pipeline/search_pipeline.rb +18 -0
- data/lib/flycal/pipeline/text_renderer.rb +134 -0
- data/lib/flycal/pipeline.rb +6 -0
- data/lib/{flycal_cli → flycal}/slot_finder.rb +1 -1
- data/lib/flycal/slot_formatter.rb +195 -0
- data/lib/flycal/version.rb +11 -0
- data/lib/flycal.rb +12 -0
- data/lib/flycal_cli.rb +37 -14
- data/mcp/tools.json +36 -9
- data/mockTemplates/mock1.json +11 -0
- data/mocks/mock1.json +11 -0
- metadata +38 -12
- data/lib/flycal_cli/calendar_service.rb +0 -74
- data/lib/flycal_cli/slot_formatter.rb +0 -68
- data/lib/flycal_cli/version.rb +0 -5
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Flycal
|
|
6
|
+
module Mock
|
|
7
|
+
# Resolves mock options from CLI and/or a JSON template under mocks/.
|
|
8
|
+
class Config
|
|
9
|
+
TEMPLATE_KEYS = %w[
|
|
10
|
+
mockCalendar
|
|
11
|
+
mockEventDescriptionPatterns
|
|
12
|
+
mockEventCount
|
|
13
|
+
mockEventFrom
|
|
14
|
+
mockEventTo
|
|
15
|
+
mockEventDurationMin
|
|
16
|
+
mockEventDurationMax
|
|
17
|
+
mockEventHoursFrom
|
|
18
|
+
mockEventHoursTo
|
|
19
|
+
mockSeed
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :calendar_name, :seed, :patterns, :count,
|
|
23
|
+
:range_from, :range_to, :duration_min_seconds, :duration_max_seconds,
|
|
24
|
+
:hours_from, :hours_to, :template_name, :raw
|
|
25
|
+
|
|
26
|
+
def self.enabled?(options)
|
|
27
|
+
!options[:mockTemplate].to_s.empty? || !options[:mockCalendar].to_s.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.from_options(options)
|
|
31
|
+
new(options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(options)
|
|
35
|
+
merged = load_template(options[:mockTemplate])
|
|
36
|
+
TEMPLATE_KEYS.each do |key|
|
|
37
|
+
sym = key.to_sym
|
|
38
|
+
value = options[sym]
|
|
39
|
+
next if value.nil? || value.to_s.empty?
|
|
40
|
+
|
|
41
|
+
merged[key] = value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@template_name = options[:mockTemplate].to_s.empty? ? nil : options[:mockTemplate].to_s
|
|
45
|
+
calendar = merged["mockCalendar"].to_s
|
|
46
|
+
calendar = options[:mockCalendar].to_s if calendar.empty?
|
|
47
|
+
@calendar_name = calendar
|
|
48
|
+
raise Flycal::Error,
|
|
49
|
+
"Mock mode requires mockCalendar in the template (or --mockCalendar)." if @calendar_name.empty?
|
|
50
|
+
|
|
51
|
+
@raw = merged
|
|
52
|
+
@patterns = split_patterns(merged["mockEventDescriptionPatterns"])
|
|
53
|
+
@count = Integer(merged["mockEventCount"])
|
|
54
|
+
@range_from = parse_date(merged["mockEventFrom"], end_of_day: false)
|
|
55
|
+
@range_to = parse_date(merged["mockEventTo"], end_of_day: true)
|
|
56
|
+
@duration_min_seconds = DurationParser.to_seconds(merged["mockEventDurationMin"].to_s)
|
|
57
|
+
@duration_max_seconds = DurationParser.to_seconds(merged["mockEventDurationMax"].to_s)
|
|
58
|
+
@hours_from = parse_hour_minute(merged["mockEventHoursFrom"])
|
|
59
|
+
@hours_to = parse_hour_minute(merged["mockEventHoursTo"])
|
|
60
|
+
@seed = resolve_seed(merged["mockSeed"])
|
|
61
|
+
|
|
62
|
+
validate!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_h
|
|
66
|
+
{
|
|
67
|
+
mock_calendar: calendar_name,
|
|
68
|
+
mock_seed: seed,
|
|
69
|
+
mock_template: template_name,
|
|
70
|
+
mock_event_description_patterns: patterns.join(","),
|
|
71
|
+
mock_event_count: count,
|
|
72
|
+
mock_event_from: range_from,
|
|
73
|
+
mock_event_to: range_to,
|
|
74
|
+
mock_event_duration_min: duration_min_seconds,
|
|
75
|
+
mock_event_duration_max: duration_max_seconds,
|
|
76
|
+
mock_event_hours_from: hours_from,
|
|
77
|
+
mock_event_hours_to: hours_to
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def load_template(name)
|
|
84
|
+
return {} if name.to_s.empty?
|
|
85
|
+
|
|
86
|
+
path = find_template_path(name)
|
|
87
|
+
raise Flycal::Error, "Mock template not found: #{name.inspect} (looked in ./mocks, ./mockTemplates, and gem paths)." unless path
|
|
88
|
+
|
|
89
|
+
data = JSON.parse(File.read(path))
|
|
90
|
+
raise Flycal::Error, "Mock template #{name.inspect} must be a JSON object." unless data.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
data
|
|
93
|
+
rescue JSON::ParserError => e
|
|
94
|
+
raise Flycal::Error, "Invalid mock template JSON (#{name}): #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def find_template_path(name)
|
|
98
|
+
base = name.to_s.sub(/\.json\z/, "")
|
|
99
|
+
gem_root = File.expand_path("../../..", __dir__)
|
|
100
|
+
candidates = [
|
|
101
|
+
File.join(Dir.pwd, "mocks", "#{base}.json"),
|
|
102
|
+
File.join(Dir.pwd, "mockTemplates", "#{base}.json"),
|
|
103
|
+
File.join(gem_root, "mocks", "#{base}.json"),
|
|
104
|
+
File.join(gem_root, "mockTemplates", "#{base}.json")
|
|
105
|
+
]
|
|
106
|
+
candidates.find { |p| File.file?(p) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def split_patterns(value)
|
|
110
|
+
list = value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
111
|
+
raise Flycal::Error, "mockEventDescriptionPatterns must list at least one pattern." if list.empty?
|
|
112
|
+
|
|
113
|
+
list
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_date(value, end_of_day:)
|
|
117
|
+
raise Flycal::Error, "Missing mock date." if value.to_s.empty?
|
|
118
|
+
|
|
119
|
+
DateTimeParser.parse(value.to_s, end_of_day: end_of_day)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def parse_hour_minute(value)
|
|
123
|
+
str = value.to_s.strip
|
|
124
|
+
match = str.match(/\A(\d{1,2}):(\d{2})\z/)
|
|
125
|
+
raise Flycal::Error, "Invalid mock hour #{value.inspect}. Use HH:MM (e.g. 09:00)." unless match
|
|
126
|
+
|
|
127
|
+
hour = match[1].to_i
|
|
128
|
+
min = match[2].to_i
|
|
129
|
+
unless hour.between?(0, 23) && min.between?(0, 59)
|
|
130
|
+
raise Flycal::Error, "Invalid mock hour #{value.inspect}. Use HH:MM (e.g. 09:00)."
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
[hour, min]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def resolve_seed(value)
|
|
137
|
+
return Integer(value) unless value.nil? || value.to_s.empty?
|
|
138
|
+
|
|
139
|
+
Random.new_seed & 0xFFFFFFFF
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate!
|
|
143
|
+
missing = []
|
|
144
|
+
missing << "mockEventDescriptionPatterns" if patterns.empty?
|
|
145
|
+
missing << "mockEventCount" if count <= 0
|
|
146
|
+
missing << "mockEventFrom" if range_from.nil?
|
|
147
|
+
missing << "mockEventTo" if range_to.nil?
|
|
148
|
+
missing << "mockEventDurationMin" if duration_min_seconds <= 0
|
|
149
|
+
missing << "mockEventDurationMax" if duration_max_seconds <= 0
|
|
150
|
+
raise Flycal::Error, "Missing mock parameters: #{missing.join(", ")}" unless missing.empty?
|
|
151
|
+
|
|
152
|
+
if duration_min_seconds > duration_max_seconds
|
|
153
|
+
raise Flycal::Error, "mockEventDurationMin must be <= mockEventDurationMax."
|
|
154
|
+
end
|
|
155
|
+
if range_from > range_to
|
|
156
|
+
raise Flycal::Error, "mockEventFrom must be before mockEventTo."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
from_mins = hours_from[0] * 60 + hours_from[1]
|
|
160
|
+
to_mins = hours_to[0] * 60 + hours_to[1]
|
|
161
|
+
if from_mins >= to_mins
|
|
162
|
+
raise Flycal::Error, "mockEventHoursFrom must be before mockEventHoursTo."
|
|
163
|
+
end
|
|
164
|
+
if duration_min_seconds > (to_mins - from_mins) * 60
|
|
165
|
+
raise Flycal::Error,
|
|
166
|
+
"mockEventDurationMin does not fit in mockEventHoursFrom..mockEventHoursTo window."
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flycal
|
|
4
|
+
module Mock
|
|
5
|
+
# Builds deterministic fake calendar events from a Mock::Config.
|
|
6
|
+
class EventGenerator
|
|
7
|
+
EventStart = Struct.new(:date_time, :date, keyword_init: true)
|
|
8
|
+
EventEnd = Struct.new(:date_time, :date, keyword_init: true)
|
|
9
|
+
Event = Struct.new(:summary, :description, :start, :end, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
@rng = Random.new(config.seed)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def generate
|
|
17
|
+
counters = Hash.new(0)
|
|
18
|
+
Array.new(@config.count) do
|
|
19
|
+
pattern = @config.patterns.sample(random: @rng)
|
|
20
|
+
counters[pattern] += 1
|
|
21
|
+
title = "#{pattern}#{counters[pattern]}"
|
|
22
|
+
start_at, end_at = random_range
|
|
23
|
+
Event.new(
|
|
24
|
+
summary: title,
|
|
25
|
+
description: title,
|
|
26
|
+
start: EventStart.new(date_time: start_at, date: nil),
|
|
27
|
+
end: EventEnd.new(date_time: end_at, date: nil)
|
|
28
|
+
)
|
|
29
|
+
end.sort_by { |e| e.start.date_time }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def random_range
|
|
35
|
+
day = random_day
|
|
36
|
+
duration = random_duration_seconds
|
|
37
|
+
start_at = random_start_on(day, duration)
|
|
38
|
+
[start_at, start_at + duration]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def random_day
|
|
42
|
+
from_date = @config.range_from.to_date
|
|
43
|
+
to_date = @config.range_to.to_date
|
|
44
|
+
span = (to_date - from_date).to_i
|
|
45
|
+
from_date + @rng.rand(0..span)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def random_duration_seconds
|
|
49
|
+
min = @config.duration_min_seconds
|
|
50
|
+
max = @config.duration_max_seconds
|
|
51
|
+
return min if min == max
|
|
52
|
+
|
|
53
|
+
@rng.rand(min..max)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def random_start_on(day, duration_seconds)
|
|
57
|
+
from_h, from_m = @config.hours_from
|
|
58
|
+
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
|
+
latest_start = window_end - duration_seconds
|
|
62
|
+
if latest_start < window_start
|
|
63
|
+
raise Flycal::Error, "Mock event duration does not fit in the daily hours window."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
span = (latest_start - window_start).to_i
|
|
67
|
+
window_start + @rng.rand(0..span)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/flycal/mock.rb
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
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 Flycal
|
|
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 | <any other string> (split on | for string grouping)
|
|
18
|
+
class Aggregator
|
|
19
|
+
HOURS_PER_WORKING_DAY = 8
|
|
20
|
+
TIME_GROUP_BY = %w[day week month].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 "string" then string_groups(events, params[:group_by_option])
|
|
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
|
+
raw = explicit.to_s.strip
|
|
61
|
+
if raw.empty?
|
|
62
|
+
if timeframe_days > 30
|
|
63
|
+
"month"
|
|
64
|
+
elsif timeframe_days > 7
|
|
65
|
+
"week"
|
|
66
|
+
else
|
|
67
|
+
"day"
|
|
68
|
+
end
|
|
69
|
+
else
|
|
70
|
+
key = raw.downcase
|
|
71
|
+
TIME_GROUP_BY.include?(key) ? key : "string"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def resolve_group_by(timeframe_days, explicit)
|
|
78
|
+
self.class.resolve_group_by(timeframe_days, explicit)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def daily_groups(events, ranges, time_min, time_max)
|
|
82
|
+
groups = []
|
|
83
|
+
index = 1
|
|
84
|
+
current = time_min.to_date
|
|
85
|
+
end_date = time_max.to_date
|
|
86
|
+
|
|
87
|
+
while current <= end_date
|
|
88
|
+
day_start = [current.to_time, time_min].max
|
|
89
|
+
day_end = [(current + 1).to_time, time_max].min
|
|
90
|
+
mins = minutes_in_period(ranges, day_start, day_end)
|
|
91
|
+
groups << build_group(
|
|
92
|
+
index: index,
|
|
93
|
+
key: current.strftime("%Y-%m-%d"),
|
|
94
|
+
start_at: day_start,
|
|
95
|
+
end_at: day_end,
|
|
96
|
+
period_label_end: current,
|
|
97
|
+
total_minutes: mins,
|
|
98
|
+
events: events_in_period(events, day_start, day_end)
|
|
99
|
+
)
|
|
100
|
+
index += 1
|
|
101
|
+
current += 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
groups
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def weekly_groups(events, ranges, time_min, time_max)
|
|
108
|
+
groups = []
|
|
109
|
+
index = 1
|
|
110
|
+
current = time_min.to_date.beginning_of_week(:monday)
|
|
111
|
+
|
|
112
|
+
while current.to_time < time_max
|
|
113
|
+
week_start = [current.to_time, time_min].max
|
|
114
|
+
week_end_date = current.end_of_week(:monday)
|
|
115
|
+
week_end = [week_end_date.to_time + 1.day, time_max].min
|
|
116
|
+
mins = minutes_in_period(ranges, week_start, week_end)
|
|
117
|
+
groups << build_group(
|
|
118
|
+
index: index,
|
|
119
|
+
key: "W#{index}",
|
|
120
|
+
start_at: week_start,
|
|
121
|
+
end_at: week_end,
|
|
122
|
+
period_label_end: week_end_date,
|
|
123
|
+
total_minutes: mins,
|
|
124
|
+
events: events_in_period(events, week_start, week_end)
|
|
125
|
+
)
|
|
126
|
+
index += 1
|
|
127
|
+
current += 7
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
groups
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def monthly_groups(events, ranges, time_min, time_max)
|
|
134
|
+
groups = []
|
|
135
|
+
index = 1
|
|
136
|
+
current = time_min.to_date.beginning_of_month
|
|
137
|
+
end_date = time_max.to_date
|
|
138
|
+
|
|
139
|
+
while current <= end_date
|
|
140
|
+
month_start = current.beginning_of_month.to_time
|
|
141
|
+
month_end = (current.end_of_month + 1.day).to_time
|
|
142
|
+
period_start = [month_start, time_min].max
|
|
143
|
+
period_end = [month_end, time_max].min
|
|
144
|
+
mins = minutes_in_period(ranges, period_start, period_end)
|
|
145
|
+
last_day = period_end.to_date - 1
|
|
146
|
+
groups << build_group(
|
|
147
|
+
index: index,
|
|
148
|
+
key: current.strftime("%Y-%m"),
|
|
149
|
+
start_at: period_start,
|
|
150
|
+
end_at: period_end,
|
|
151
|
+
period_label_end: last_day,
|
|
152
|
+
total_minutes: mins,
|
|
153
|
+
month_name: "#{Locale.month_name(current)} #{current.year}",
|
|
154
|
+
events: events_in_period(events, period_start, period_end)
|
|
155
|
+
)
|
|
156
|
+
index += 1
|
|
157
|
+
current = current.next_month.beginning_of_month
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
groups
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def string_groups(events, group_by_string)
|
|
164
|
+
patterns = DescriptionQuery.patterns(group_by_string)
|
|
165
|
+
if patterns.empty?
|
|
166
|
+
raise Flycal::Error,
|
|
167
|
+
"groupBy string requires at least one term (e.g. --groupBy \"rui|solver\")."
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
patterns.each_with_index.map do |pattern, idx|
|
|
171
|
+
matched = events.select do |ev|
|
|
172
|
+
DescriptionQuery.match_term?(ev[:summary], ev[:description], pattern)
|
|
173
|
+
end
|
|
174
|
+
minutes = matched.sum { |ev| ev[:duration_minutes].to_f }
|
|
175
|
+
build_group(
|
|
176
|
+
index: idx + 1,
|
|
177
|
+
key: pattern,
|
|
178
|
+
start_at: nil,
|
|
179
|
+
end_at: nil,
|
|
180
|
+
period_label_end: nil,
|
|
181
|
+
total_minutes: minutes,
|
|
182
|
+
events: matched,
|
|
183
|
+
string: pattern
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_group(index:, key:, start_at:, end_at:, period_label_end:, total_minutes:, events:,
|
|
189
|
+
month_name: nil, string: nil)
|
|
190
|
+
{
|
|
191
|
+
index: index,
|
|
192
|
+
key: key,
|
|
193
|
+
start_at: start_at,
|
|
194
|
+
end_at: end_at,
|
|
195
|
+
period_label_end: period_label_end,
|
|
196
|
+
month_name: month_name,
|
|
197
|
+
string: string,
|
|
198
|
+
event_count: events.size,
|
|
199
|
+
total_minutes: total_minutes,
|
|
200
|
+
hours: (total_minutes / 60.0).round(1),
|
|
201
|
+
working_days: (total_minutes / 60.0 / HOURS_PER_WORKING_DAY).round(1),
|
|
202
|
+
events: events
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def events_in_period(events, period_start, period_end)
|
|
207
|
+
events.select do |ev|
|
|
208
|
+
start_at = ev[:start_at]
|
|
209
|
+
end_at = ev[:end_at]
|
|
210
|
+
next false if start_at.nil? || end_at.nil?
|
|
211
|
+
|
|
212
|
+
end_at > period_start && start_at < period_end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def minutes_in_period(ranges, period_start, period_end)
|
|
217
|
+
ranges.sum do |ev_start, ev_end|
|
|
218
|
+
overlap_start = [ev_start, period_start].max
|
|
219
|
+
overlap_end = [ev_end, period_end].min
|
|
220
|
+
overlap_sec = overlap_end - overlap_start
|
|
221
|
+
overlap_sec > 0 ? overlap_sec / 60.0 : 0
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Flycal
|
|
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
|
+
"text" => lines_for_output(TextRenderer.new.render(params)),
|
|
14
|
+
"items" => Array(params[:events]).map { |ev| serialize_event(ev) },
|
|
15
|
+
"groups" => build_groups(params)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
JSON.pretty_generate(payload) + "\n"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def strip_ansi(text)
|
|
24
|
+
text.to_s.gsub(/\e\[[0-9;]*m/, "")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def lines_for_output(text)
|
|
28
|
+
strip_ansi(text).split("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_params(params)
|
|
32
|
+
out = {
|
|
33
|
+
"command" => params[:command] || "search",
|
|
34
|
+
"from" => format_time(params[:time_min]),
|
|
35
|
+
"to" => format_time(params[:time_max]),
|
|
36
|
+
"calendar" => params[:calendar],
|
|
37
|
+
"calendar_ids" => Array(params[:calendar_ids]),
|
|
38
|
+
"description" => params[:description],
|
|
39
|
+
"format" => params[:format] || "json",
|
|
40
|
+
"locale" => params[:locale] || Locale.current_locale,
|
|
41
|
+
"group_by" => params[:group_by],
|
|
42
|
+
"group_by_option" => blank_to_nil(params[:group_by_option]),
|
|
43
|
+
"from_option" => blank_to_nil(params[:from_option]),
|
|
44
|
+
"to_option" => blank_to_nil(params[:to_option]),
|
|
45
|
+
"in_option" => blank_to_nil(params[:in_option])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if params[:use_mock]
|
|
49
|
+
out["use_mock"] = true
|
|
50
|
+
out["mock_calendar"] = params[:mock_calendar]
|
|
51
|
+
out["mock_template"] = params[:mock_template]
|
|
52
|
+
out["mock_seed"] = params[:mock_seed]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
out
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_info(params)
|
|
59
|
+
totals = params[:totals] || {}
|
|
60
|
+
total_minutes = totals[:total_minutes].to_f
|
|
61
|
+
info = {
|
|
62
|
+
"events_found" => totals[:event_count].to_i,
|
|
63
|
+
"from" => format_time(params[:time_min]),
|
|
64
|
+
"to" => format_time(params[:time_max]),
|
|
65
|
+
"total_hours" => (total_minutes / 60.0).round(2),
|
|
66
|
+
"total_working_days" => totals[:working_days].to_f
|
|
67
|
+
}
|
|
68
|
+
info["mock_seed"] = params[:mock_seed] if params[:use_mock] && !params[:mock_seed].nil?
|
|
69
|
+
info["cache"] = params[:cache_status] if params[:cache_status]
|
|
70
|
+
info
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_groups(params)
|
|
74
|
+
type = params[:group_by].to_s
|
|
75
|
+
Array(params[:groups]).map do |group|
|
|
76
|
+
{
|
|
77
|
+
"type" => type,
|
|
78
|
+
"key" => group[:key],
|
|
79
|
+
"index" => group[:index],
|
|
80
|
+
"total_hours" => group[:hours],
|
|
81
|
+
"total_working_days" => group[:working_days],
|
|
82
|
+
"events_found" => group[:event_count] || Array(group[:events]).size,
|
|
83
|
+
"items" => Array(group[:events]).map { |ev| serialize_event(ev) }
|
|
84
|
+
}.tap do |g|
|
|
85
|
+
if type == "string"
|
|
86
|
+
g["string"] = group[:string] || group[:key]
|
|
87
|
+
else
|
|
88
|
+
g["from"] = format_time(group[:start_at])
|
|
89
|
+
g["to"] = format_time(group[:end_at])
|
|
90
|
+
end
|
|
91
|
+
g["month_name"] = group[:month_name] if group[:month_name]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Shape inspired by Google Calendar API event resources.
|
|
97
|
+
def serialize_event(ev)
|
|
98
|
+
raw = ev[:raw]
|
|
99
|
+
payload = {
|
|
100
|
+
"summary" => ev[:summary],
|
|
101
|
+
"description" => ev[:description],
|
|
102
|
+
"start" => boundary_hash(ev[:start_at], all_day: ev[:all_day]),
|
|
103
|
+
"end" => boundary_hash(ev[:end_at], all_day: ev[:all_day]),
|
|
104
|
+
"calendarId" => ev[:calendar_id],
|
|
105
|
+
"calendarSummary" => ev[:calendar_name]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if raw
|
|
109
|
+
payload["id"] = raw.id if raw.respond_to?(:id) && raw.id
|
|
110
|
+
payload["status"] = raw.status if raw.respond_to?(:status) && raw.status
|
|
111
|
+
payload["htmlLink"] = raw.html_link if raw.respond_to?(:html_link) && raw.html_link
|
|
112
|
+
payload["created"] = format_time(raw.created) if raw.respond_to?(:created) && raw.created
|
|
113
|
+
payload["updated"] = format_time(raw.updated) if raw.respond_to?(:updated) && raw.updated
|
|
114
|
+
payload["location"] = raw.location if raw.respond_to?(:location) && raw.location
|
|
115
|
+
payload["iCalUID"] = raw.i_cal_uid if raw.respond_to?(:i_cal_uid) && raw.i_cal_uid
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
payload.compact
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def boundary_hash(time, all_day:)
|
|
122
|
+
return nil if time.nil?
|
|
123
|
+
|
|
124
|
+
if all_day
|
|
125
|
+
{ "date" => time.to_date.iso8601 }
|
|
126
|
+
else
|
|
127
|
+
{ "dateTime" => format_time(time) }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Google Calendar-style ISO-8601 datetime, e.g. 2026-07-29T14:41:00+00:00
|
|
132
|
+
def format_time(value)
|
|
133
|
+
return nil if value.nil?
|
|
134
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
135
|
+
|
|
136
|
+
value.to_s
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def blank_to_nil(value)
|
|
140
|
+
str = value.to_s
|
|
141
|
+
str.empty? ? nil : str
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flycal
|
|
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 Flycal
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Third pipeline layer: render aggregated results to a chosen format.
|
|
6
|
+
class Renderer
|
|
7
|
+
FORMATS = {
|
|
8
|
+
"text" => "Flycal::Pipeline::TextRenderer",
|
|
9
|
+
"json" => "Flycal::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 Flycal::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
|