flycal-cli 0.7.7 → 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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -181
  3. data/bin/flycal +2 -2
  4. data/config/defaults.yml +2 -28
  5. data/lib/flycal/cache_annotator.rb +83 -0
  6. data/lib/flycal/cache_key.rb +82 -0
  7. data/lib/flycal/calendar_query.rb +60 -0
  8. data/lib/{flycal_cli → flycal}/calendar_service.rb +48 -4
  9. data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +246 -171
  10. data/lib/{flycal_cli → flycal/cli}/auth.rb +5 -3
  11. data/lib/{flycal_cli → flycal/cli}/clipboard.rb +3 -1
  12. data/lib/{flycal_cli → flycal/cli}/config.rb +6 -3
  13. data/lib/flycal/cli/file_cache.rb +44 -0
  14. data/lib/flycal/cli/locale.rb +58 -0
  15. data/lib/flycal/cli.rb +14 -0
  16. data/lib/flycal/client.rb +141 -0
  17. data/lib/flycal/credentials.rb +34 -0
  18. data/lib/{flycal_cli → flycal}/date_time_parser.rb +4 -4
  19. data/lib/flycal/default_slots.rb +77 -0
  20. data/lib/{flycal_cli → flycal}/description_query.rb +3 -3
  21. data/lib/{flycal_cli → flycal}/duration_parser.rb +3 -3
  22. data/lib/flycal/error.rb +5 -0
  23. data/lib/flycal/event_mapper.rb +58 -0
  24. data/lib/{flycal_cli → flycal}/locale.rb +35 -3
  25. data/lib/{flycal_cli → flycal}/mock/calendar_service.rb +1 -1
  26. data/lib/{flycal_cli → flycal}/mock/config.rb +14 -14
  27. data/lib/{flycal_cli → flycal}/mock/event_generator.rb +6 -4
  28. data/lib/flycal/mock/slot_scenario.rb +75 -0
  29. data/lib/flycal/mock.rb +6 -0
  30. data/lib/{flycal_cli → flycal}/pipeline/aggregator.rb +21 -24
  31. data/lib/{flycal_cli → flycal}/pipeline/json_renderer.rb +16 -17
  32. data/lib/{flycal_cli → flycal}/pipeline/params.rb +1 -1
  33. data/lib/{flycal_cli → flycal}/pipeline/renderer.rb +4 -4
  34. data/lib/{flycal_cli → flycal}/pipeline/retriever.rb +1 -1
  35. data/lib/{flycal_cli → flycal}/pipeline/search_pipeline.rb +1 -1
  36. data/lib/{flycal_cli → flycal}/pipeline/text_renderer.rb +14 -12
  37. data/lib/flycal/pipeline.rb +6 -0
  38. data/lib/{flycal_cli → flycal}/slot_finder.rb +12 -12
  39. data/lib/flycal/slot_formatter.rb +196 -0
  40. data/lib/flycal/slot_query.rb +74 -0
  41. data/lib/flycal/slot_templates.rb +129 -0
  42. data/lib/flycal/time_format.rb +36 -0
  43. data/lib/flycal/version.rb +11 -0
  44. data/lib/flycal.rb +12 -0
  45. data/lib/flycal_cli.rb +42 -17
  46. data/mcp/tools.json +21 -8
  47. metadata +41 -25
  48. data/lib/flycal_cli/mock.rb +0 -10
  49. data/lib/flycal_cli/pipeline.rb +0 -14
  50. data/lib/flycal_cli/slot_formatter.rb +0 -68
  51. data/lib/flycal_cli/version.rb +0 -5
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module FlycalCli
3
+ module Flycal
4
4
  module Mock
5
5
  # Builds deterministic fake calendar events from a Mock::Config.
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 FlycalCli
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
@@ -60,7 +62,7 @@ module FlycalCli
60
62
  window_end = day.to_time + (to_h * 3600) + (to_m * 60)
61
63
  latest_start = window_end - duration_seconds
62
64
  if latest_start < window_start
63
- raise FlycalCli::Error, "Mock event duration does not fit in the daily hours window."
65
+ raise Flycal::Error, "Mock event duration does not fit in the daily hours window."
64
66
  end
65
67
 
66
68
  span = (latest_start - window_start).to_i
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flycal
4
+ module Mock
5
+ end
6
+ end
@@ -4,7 +4,7 @@ require "active_support/core_ext/date"
4
4
  require "active_support/core_ext/integer"
5
5
  require "active_support/core_ext/time"
6
6
 
7
- module FlycalCli
7
+ module Flycal
8
8
  module Pipeline
9
9
  # Second pipeline layer: group events and compute aggregate metrics.
10
10
  #
@@ -14,10 +14,10 @@ module FlycalCli
14
14
  # month — timeframe > 30 days
15
15
  #
16
16
  # Override with params[:group_by_option] / --groupBy:
17
- # day | week | month | description
17
+ # day | week | month | <any other string> (split on | for string grouping)
18
18
  class Aggregator
19
19
  HOURS_PER_WORKING_DAY = 8
20
- ALLOWED_GROUP_BY = %w[day week month description].freeze
20
+ TIME_GROUP_BY = %w[day week month].freeze
21
21
 
22
22
  def call(params)
23
23
  time_min = params[:time_min]
@@ -49,7 +49,7 @@ module FlycalCli
49
49
  case group_by
50
50
  when "week" then weekly_groups(events, ranges, time_min, time_max)
51
51
  when "month" then monthly_groups(events, ranges, time_min, time_max)
52
- when "description" then description_groups(events, params[:description])
52
+ when "string" then string_groups(events, params[:group_by_option])
53
53
  else daily_groups(events, ranges, time_min, time_max)
54
54
  end
55
55
 
@@ -57,21 +57,18 @@ module FlycalCli
57
57
  end
58
58
 
59
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(", ")}"
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"
65
68
  end
66
- return key
67
- end
68
-
69
- if timeframe_days > 30
70
- "month"
71
- elsif timeframe_days > 7
72
- "week"
73
69
  else
74
- "day"
70
+ key = raw.downcase
71
+ TIME_GROUP_BY.include?(key) ? key : "string"
75
72
  end
76
73
  end
77
74
 
@@ -163,11 +160,11 @@ module FlycalCli
163
160
  groups
164
161
  end
165
162
 
166
- def description_groups(events, description_query)
167
- patterns = DescriptionQuery.patterns(description_query)
163
+ def string_groups(events, group_by_string)
164
+ patterns = DescriptionQuery.patterns(group_by_string)
168
165
  if patterns.empty?
169
- raise FlycalCli::Error,
170
- "groupBy description requires --description with at least one term (e.g. rui|solver)."
166
+ raise Flycal::Error,
167
+ "groupBy string requires at least one term (e.g. --groupBy \"rui|solver\")."
171
168
  end
172
169
 
173
170
  patterns.each_with_index.map do |pattern, idx|
@@ -183,13 +180,13 @@ module FlycalCli
183
180
  period_label_end: nil,
184
181
  total_minutes: minutes,
185
182
  events: matched,
186
- description: pattern
183
+ string: pattern
187
184
  )
188
185
  end
189
186
  end
190
187
 
191
188
  def build_group(index:, key:, start_at:, end_at:, period_label_end:, total_minutes:, events:,
192
- month_name: nil, description: nil)
189
+ month_name: nil, string: nil)
193
190
  {
194
191
  index: index,
195
192
  key: key,
@@ -197,7 +194,7 @@ module FlycalCli
197
194
  end_at: end_at,
198
195
  period_label_end: period_label_end,
199
196
  month_name: month_name,
200
- description: description,
197
+ string: string,
201
198
  event_count: events.size,
202
199
  total_minutes: total_minutes,
203
200
  hours: (total_minutes / 60.0).round(1),
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "json"
4
4
 
5
- module FlycalCli
5
+ module Flycal
6
6
  module Pipeline
7
7
  # Pretty-printed JSON output for search (API / tooling friendly).
8
8
  class JsonRenderer < Renderer
@@ -10,6 +10,7 @@ module FlycalCli
10
10
  payload = {
11
11
  "params" => build_params(params),
12
12
  "info" => build_info(params),
13
+ "text" => lines_for_output(TextRenderer.new.render(params)),
13
14
  "items" => Array(params[:events]).map { |ev| serialize_event(ev) },
14
15
  "groups" => build_groups(params)
15
16
  }
@@ -19,6 +20,14 @@ module FlycalCli
19
20
 
20
21
  private
21
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
+
22
31
  def build_params(params)
23
32
  out = {
24
33
  "command" => params[:command] || "search",
@@ -57,6 +66,7 @@ module FlycalCli
57
66
  "total_working_days" => totals[:working_days].to_f
58
67
  }
59
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]
60
70
  info
61
71
  end
62
72
 
@@ -72,11 +82,11 @@ module FlycalCli
72
82
  "events_found" => group[:event_count] || Array(group[:events]).size,
73
83
  "items" => Array(group[:events]).map { |ev| serialize_event(ev) }
74
84
  }.tap do |g|
75
- if type == "description"
76
- g["description"] = group[:description] || group[:key]
85
+ if type == "string"
86
+ g["string"] = group[:string] || group[:key]
77
87
  else
78
- g["from"] = format_group_boundary(group[:start_at])
79
- g["to"] = format_group_boundary(group[:end_at])
88
+ g["from"] = format_time(group[:start_at])
89
+ g["to"] = format_time(group[:end_at])
80
90
  end
81
91
  g["month_name"] = group[:month_name] if group[:month_name]
82
92
  end
@@ -119,18 +129,7 @@ module FlycalCli
119
129
  end
120
130
 
121
131
  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")
132
+ TimeFormat.iso8601(value)
134
133
  end
135
134
 
136
135
  def blank_to_nil(value)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module FlycalCli
3
+ module Flycal
4
4
  module Pipeline
5
5
  # Mutable bag of CLI + derived parameters passed through the pipeline.
6
6
  class Params
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module FlycalCli
3
+ module Flycal
4
4
  module Pipeline
5
5
  # Third pipeline layer: render aggregated results to a chosen format.
6
6
  class Renderer
7
7
  FORMATS = {
8
- "text" => "FlycalCli::Pipeline::TextRenderer",
9
- "json" => "FlycalCli::Pipeline::JsonRenderer"
8
+ "text" => "Flycal::Pipeline::TextRenderer",
9
+ "json" => "Flycal::Pipeline::JsonRenderer"
10
10
  }.freeze
11
11
 
12
12
  def self.for(format)
13
13
  key = format.to_s.strip.downcase
14
14
  key = "text" if key.empty?
15
15
  class_name = FORMATS[key]
16
- raise FlycalCli::Error, "Unsupported format #{format.inspect}. Available: #{FORMATS.keys.join(", ")}" unless class_name
16
+ raise Flycal::Error, "Unsupported format #{format.inspect}. Available: #{FORMATS.keys.join(", ")}" unless class_name
17
17
 
18
18
  Object.const_get(class_name).new
19
19
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module FlycalCli
3
+ module Flycal
4
4
  module Pipeline
5
5
  # First pipeline layer: fetch calendar events for the requested timeframe.
6
6
  class Retriever
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module FlycalCli
3
+ module Flycal
4
4
  module Pipeline
5
5
  # Orchestrates Retriever → Aggregator → Renderer for search.
6
6
  class SearchPipeline
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module FlycalCli
3
+ module Flycal
4
4
  module Pipeline
5
5
  # Shell text output — preserves the historical flycal search formatting.
6
6
  class TextRenderer < Renderer
@@ -32,14 +32,15 @@ module FlycalCli
32
32
  totals = params[:totals] || {}
33
33
  time_min = params[:time_min]
34
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")
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 << "---"
40
40
  lines << "From: #{from_str} | To: #{to_str}"
41
41
  lines << "Events found: #{totals[:event_count]}"
42
42
  lines << "Total time occupied: #{format_duration(totals[:total_minutes].to_f)}"
43
+ lines << "Cache: #{params[:cache_status]}" if params[:cache_status]
43
44
  lines << "Mock seed: #{params[:mock_seed]}" if params[:use_mock] && !params[:mock_seed].nil?
44
45
 
45
46
  case params[:group_by]
@@ -52,21 +53,21 @@ module FlycalCli
52
53
  lines << "By month:"
53
54
  lines.concat(month_group_lines(params[:groups]))
54
55
  when "day"
55
- if explicit_group_by?(params, "day")
56
+ if explicit_time_group_by?(params, "day")
56
57
  lines << ""
57
58
  lines << "By day:"
58
59
  lines.concat(day_group_lines(params[:groups]))
59
60
  end
60
- when "description"
61
+ when "string"
61
62
  lines << ""
62
- lines << "By description:"
63
- lines.concat(description_group_lines(params[:groups]))
63
+ lines << "By string:"
64
+ lines.concat(string_group_lines(params[:groups]))
64
65
  end
65
66
 
66
67
  lines
67
68
  end
68
69
 
69
- def explicit_group_by?(params, value)
70
+ def explicit_time_group_by?(params, value)
70
71
  params[:group_by_option].to_s.strip.downcase == value
71
72
  end
72
73
 
@@ -93,9 +94,9 @@ module FlycalCli
93
94
  end
94
95
  end
95
96
 
96
- def description_group_lines(groups)
97
+ def string_group_lines(groups)
97
98
  Array(groups).map do |g|
98
- label = g[:description] || g[:key]
99
+ label = g[:string] || g[:key]
99
100
  " #{bold(g[:index])}. #{label}: #{g[:event_count]} events, #{format_hours_and_days(g[:hours], g[:working_days])}"
100
101
  end
101
102
  end
@@ -104,13 +105,14 @@ module FlycalCli
104
105
  return "-" if dt.nil?
105
106
  return dt if dt.is_a?(String)
106
107
 
107
- "#{Locale.day_abbr(dt)} #{dt.strftime("%Y-%m-%d %H:%M")}"
108
+ t = TimeFormat.unified(dt)
109
+ "#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d %H:%M")}"
108
110
  end
109
111
 
110
112
  def format_date_with_day(dt)
111
113
  return "-" if dt.nil?
112
114
 
113
- t = dt.respond_to?(:to_time) ? dt.to_time : dt
115
+ t = TimeFormat.unified(dt)
114
116
  "#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d")}"
115
117
  end
116
118
 
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flycal
4
+ module Pipeline
5
+ end
6
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module FlycalCli
3
+ module Flycal
4
4
  class SlotFinder
5
5
  DEFAULT_HOURS = [[9, 0, 18, 0]].freeze
6
6
  DEFAULT_DAYS = [1, 2, 3, 4, 5].freeze
@@ -17,8 +17,8 @@ module FlycalCli
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 FlycalCli
151
151
  end
152
152
 
153
153
  def round_up_15(time)
154
- sec = time.to_i
155
- remainder = sec % STEP_SECONDS
154
+ time = to_time(time)
155
+ remainder = time.to_i % STEP_SECONDS
156
156
  return time if remainder.zero?
157
157
 
158
- Time.at(sec + (STEP_SECONDS - remainder))
158
+ time + (STEP_SECONDS - remainder)
159
159
  end
160
160
 
161
161
  def round_down_15(time)
162
- sec = time.to_i
163
- Time.at(sec - (sec % STEP_SECONDS))
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
- return value if value.is_a?(Time)
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
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flycal
6
+ class SlotFormatter
7
+ class << self
8
+ def format_header(from:, to:, duration:, calendars:, count:, template: nil)
9
+ lines = []
10
+ lines << Locale.t(
11
+ "slots.header",
12
+ count: underline(count),
13
+ from: underline(Locale.format_long_date(from)),
14
+ to: underline(Locale.format_long_date(to)),
15
+ duration: underline(duration),
16
+ template: underline(template.to_s)
17
+ )
18
+ calendars.each do |cal|
19
+ lines << "- #{cal[:name]}"
20
+ end
21
+ lines << "link: #{google_calendar_day_url(from)}"
22
+ lines.join("\n")
23
+ end
24
+
25
+ def format_output(slots_by_day)
26
+ lines = []
27
+
28
+ slots_by_day.sort_by(&:first).each do |date, slots|
29
+ lines << "" unless lines.empty?
30
+ lines << underline(day_header(date))
31
+ slots.each do |start_at, end_at|
32
+ lines << slot_range(start_at, end_at)
33
+ end
34
+ end
35
+
36
+ lines.join("\n")
37
+ end
38
+
39
+ # Full shell text for slots (header + body). Used by CLI and JSON `text` key.
40
+ def format_text(
41
+ slots_by_day:,
42
+ time_min:,
43
+ time_max:,
44
+ duration:,
45
+ calendars:,
46
+ template: nil,
47
+ empty_message: nil
48
+ )
49
+ count = slots_by_day.values.sum(&:size)
50
+ header = format_header(
51
+ from: time_min,
52
+ to: time_max,
53
+ duration: duration,
54
+ calendars: calendars,
55
+ count: count,
56
+ template: template
57
+ )
58
+ body = format_output(slots_by_day)
59
+ body = empty_message.to_s if body.empty? && empty_message
60
+
61
+ parts = [ header, "" ]
62
+ parts << body unless body.to_s.empty?
63
+ "#{parts.join("\n").rstrip}\n"
64
+ end
65
+
66
+ # Pretty JSON for --format json (EmCP / API friendly).
67
+ def format_json(**kwargs)
68
+ JSON.pretty_generate(payload_hash(**kwargs)) + "\n"
69
+ end
70
+
71
+ # Structured hash used by CLI JSON output and HTTP API.
72
+ def payload_hash(
73
+ slots_by_day:,
74
+ time_min:,
75
+ time_max:,
76
+ duration:,
77
+ template:,
78
+ calendars:,
79
+ locale: nil,
80
+ calendar_option: nil,
81
+ from_option: nil,
82
+ to_option: nil,
83
+ in_option: nil,
84
+ free_before: nil,
85
+ free_after: nil,
86
+ empty_message: nil
87
+ )
88
+ items = []
89
+ groups = slots_by_day.sort_by(&:first).map do |date, slots|
90
+ slot_items = slots.map { |start_at, end_at| serialize_slot(start_at, end_at, date) }
91
+ items.concat(slot_items)
92
+ {
93
+ "type" => "day",
94
+ "key" => date.iso8601,
95
+ "date" => date.iso8601,
96
+ "from" => format_iso(slots.map(&:first).min),
97
+ "to" => format_iso(slots.map(&:last).max),
98
+ "slots_found" => slot_items.size,
99
+ "items" => slot_items
100
+ }
101
+ end
102
+
103
+ text = format_text(
104
+ slots_by_day: slots_by_day,
105
+ time_min: time_min,
106
+ time_max: time_max,
107
+ duration: duration,
108
+ calendars: calendars,
109
+ template: template,
110
+ empty_message: empty_message
111
+ )
112
+
113
+ {
114
+ "params" => {
115
+ "command" => "slots",
116
+ "from" => format_iso(time_min),
117
+ "to" => format_iso(time_max),
118
+ "duration" => duration,
119
+ "template" => template,
120
+ "calendar" => blank_to_nil(calendar_option),
121
+ "calendar_ids" => Array(calendars).map { |c| c[:id] },
122
+ "format" => "json",
123
+ "locale" => locale || Locale.current_locale,
124
+ "from_option" => blank_to_nil(from_option),
125
+ "to_option" => blank_to_nil(to_option),
126
+ "in_option" => blank_to_nil(in_option),
127
+ "free_before" => free_before,
128
+ "free_after" => free_after
129
+ }.compact,
130
+ "info" => {
131
+ "slots_found" => items.size,
132
+ "from" => format_iso(time_min),
133
+ "to" => format_iso(time_max),
134
+ "duration" => duration,
135
+ "template" => template,
136
+ "calendars" => Array(calendars).map { |c| { "id" => c[:id], "name" => c[:name] } },
137
+ "link" => google_calendar_day_url(time_min)
138
+ },
139
+ "text" => strip_ansi(text).split("\n"),
140
+ "items" => items,
141
+ "groups" => groups
142
+ }
143
+ end
144
+
145
+ def strip_ansi(text)
146
+ text.to_s.gsub(/\e\[[0-9;]*m/, "")
147
+ end
148
+
149
+ private
150
+
151
+ def serialize_slot(start_at, end_at, date)
152
+ {
153
+ "date" => date.iso8601,
154
+ "start" => { "dateTime" => format_iso(start_at) },
155
+ "end" => { "dateTime" => format_iso(end_at) },
156
+ "duration_seconds" => (end_at - start_at).to_i
157
+ }
158
+ end
159
+
160
+ def format_iso(value)
161
+ TimeFormat.iso8601(value)
162
+ end
163
+
164
+ def blank_to_nil(value)
165
+ str = value.to_s
166
+ str.empty? ? nil : str
167
+ end
168
+
169
+ def underline(str)
170
+ "\e[4m#{str}\e[0m"
171
+ end
172
+
173
+ def google_calendar_day_url(from)
174
+ t = from.respond_to?(:to_time) ? from.to_time : from
175
+ "https://calendar.google.com/calendar/r/day/#{t.year}/#{t.month}/#{t.day}"
176
+ end
177
+
178
+ def day_header(date)
179
+ "#{Locale.day_name(date)} #{date.day}/#{date.month}"
180
+ end
181
+
182
+ def slot_range(start_at, end_at)
183
+ "#{format_time(start_at)} - #{format_time(end_at)}"
184
+ end
185
+
186
+ def format_time(time)
187
+ time = TimeFormat.unified(time)
188
+ return "" unless time
189
+ return time.strftime("%-H") if time.min.zero?
190
+
191
+ minutes = sprintf("%02d", time.min)
192
+ "#{time.hour}.#{minutes}"
193
+ end
194
+ end
195
+ end
196
+ end