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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -174
  3. data/bin/flycal +2 -2
  4. data/lib/flycal/cache_annotator.rb +83 -0
  5. data/lib/flycal/cache_key.rb +82 -0
  6. data/lib/flycal/calendar_query.rb +60 -0
  7. data/lib/flycal/calendar_service.rb +115 -0
  8. data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +320 -250
  9. data/lib/{flycal_cli → flycal/cli}/auth.rb +5 -3
  10. data/lib/{flycal_cli → flycal/cli}/clipboard.rb +3 -1
  11. data/lib/{flycal_cli → flycal/cli}/config.rb +4 -2
  12. data/lib/flycal/cli/file_cache.rb +44 -0
  13. data/lib/flycal/cli/locale.rb +58 -0
  14. data/lib/flycal/cli.rb +14 -0
  15. data/lib/flycal/client.rb +186 -0
  16. data/lib/flycal/credentials.rb +34 -0
  17. data/lib/{flycal_cli → flycal}/date_time_parser.rb +4 -4
  18. data/lib/flycal/description_query.rb +32 -0
  19. data/lib/{flycal_cli → flycal}/duration_parser.rb +3 -3
  20. data/lib/flycal/error.rb +5 -0
  21. data/lib/flycal/event_mapper.rb +58 -0
  22. data/lib/{flycal_cli → flycal}/locale.rb +35 -3
  23. data/lib/flycal/mock/calendar_service.rb +43 -0
  24. data/lib/flycal/mock/config.rb +171 -0
  25. data/lib/flycal/mock/event_generator.rb +71 -0
  26. data/lib/flycal/mock.rb +6 -0
  27. data/lib/flycal/pipeline/aggregator.rb +226 -0
  28. data/lib/flycal/pipeline/json_renderer.rb +145 -0
  29. data/lib/flycal/pipeline/params.rb +46 -0
  30. data/lib/flycal/pipeline/renderer.rb +27 -0
  31. data/lib/flycal/pipeline/retriever.rb +72 -0
  32. data/lib/flycal/pipeline/search_pipeline.rb +18 -0
  33. data/lib/flycal/pipeline/text_renderer.rb +134 -0
  34. data/lib/flycal/pipeline.rb +6 -0
  35. data/lib/{flycal_cli → flycal}/slot_finder.rb +1 -1
  36. data/lib/flycal/slot_formatter.rb +195 -0
  37. data/lib/flycal/version.rb +11 -0
  38. data/lib/flycal.rb +12 -0
  39. data/lib/flycal_cli.rb +37 -14
  40. data/mcp/tools.json +36 -9
  41. data/mockTemplates/mock1.json +11 -0
  42. data/mocks/mock1.json +11 -0
  43. metadata +38 -12
  44. data/lib/flycal_cli/calendar_service.rb +0 -74
  45. data/lib/flycal_cli/slot_formatter.rb +0 -68
  46. 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flycal
4
+ module Mock
5
+ end
6
+ end
@@ -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