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.
- checksums.yaml +4 -4
- data/README.md +6 -181
- data/bin/flycal +2 -2
- data/config/defaults.yml +2 -28
- 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_cli → flycal}/calendar_service.rb +48 -4
- data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +246 -171
- 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 +6 -3
- 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 +141 -0
- data/lib/flycal/credentials.rb +34 -0
- data/lib/{flycal_cli → flycal}/date_time_parser.rb +4 -4
- data/lib/flycal/default_slots.rb +77 -0
- data/lib/{flycal_cli → flycal}/description_query.rb +3 -3
- 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_cli → flycal}/mock/calendar_service.rb +1 -1
- data/lib/{flycal_cli → flycal}/mock/config.rb +14 -14
- data/lib/{flycal_cli → flycal}/mock/event_generator.rb +6 -4
- data/lib/flycal/mock/slot_scenario.rb +75 -0
- data/lib/flycal/mock.rb +6 -0
- data/lib/{flycal_cli → flycal}/pipeline/aggregator.rb +21 -24
- data/lib/{flycal_cli → flycal}/pipeline/json_renderer.rb +16 -17
- data/lib/{flycal_cli → flycal}/pipeline/params.rb +1 -1
- data/lib/{flycal_cli → flycal}/pipeline/renderer.rb +4 -4
- data/lib/{flycal_cli → flycal}/pipeline/retriever.rb +1 -1
- data/lib/{flycal_cli → flycal}/pipeline/search_pipeline.rb +1 -1
- data/lib/{flycal_cli → flycal}/pipeline/text_renderer.rb +14 -12
- data/lib/flycal/pipeline.rb +6 -0
- data/lib/{flycal_cli → flycal}/slot_finder.rb +12 -12
- data/lib/flycal/slot_formatter.rb +196 -0
- data/lib/flycal/slot_query.rb +74 -0
- data/lib/flycal/slot_templates.rb +129 -0
- data/lib/flycal/time_format.rb +36 -0
- data/lib/flycal/version.rb +11 -0
- data/lib/flycal.rb +12 -0
- data/lib/flycal_cli.rb +42 -17
- data/mcp/tools.json +21 -8
- metadata +41 -25
- data/lib/flycal_cli/mock.rb +0 -10
- data/lib/flycal_cli/pipeline.rb +0 -14
- data/lib/flycal_cli/slot_formatter.rb +0 -68
- data/lib/flycal_cli/version.rb +0 -5
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
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
|
|
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
|
data/lib/flycal/mock.rb
ADDED
|
@@ -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
|
|
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 |
|
|
17
|
+
# day | week | month | <any other string> (split on | for string grouping)
|
|
18
18
|
class Aggregator
|
|
19
19
|
HOURS_PER_WORKING_DAY = 8
|
|
20
|
-
|
|
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 "
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
patterns = DescriptionQuery.patterns(
|
|
163
|
+
def string_groups(events, group_by_string)
|
|
164
|
+
patterns = DescriptionQuery.patterns(group_by_string)
|
|
168
165
|
if patterns.empty?
|
|
169
|
-
raise
|
|
170
|
-
"groupBy
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 == "
|
|
76
|
-
g["
|
|
85
|
+
if type == "string"
|
|
86
|
+
g["string"] = group[:string] || group[:key]
|
|
77
87
|
else
|
|
78
|
-
g["from"] =
|
|
79
|
-
g["to"] =
|
|
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
|
-
|
|
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,19 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
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" => "
|
|
9
|
-
"json" => "
|
|
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
|
|
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
|
|
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
|
|
36
|
-
to_str = time_max
|
|
35
|
+
from_str = TimeFormat.unified(time_min)&.strftime("%a %Y-%m-%d %H:%M") || "-"
|
|
36
|
+
to_str = TimeFormat.unified(time_max)&.strftime("%a %Y-%m-%d %H:%M") || "-"
|
|
37
37
|
|
|
38
38
|
lines = []
|
|
39
39
|
lines << "---"
|
|
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
|
|
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 "
|
|
61
|
+
when "string"
|
|
61
62
|
lines << ""
|
|
62
|
-
lines << "By
|
|
63
|
-
lines.concat(
|
|
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
|
|
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
|
|
97
|
+
def string_group_lines(groups)
|
|
97
98
|
Array(groups).map do |g|
|
|
98
|
-
label = g[:
|
|
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
|
-
|
|
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 =
|
|
115
|
+
t = TimeFormat.unified(dt)
|
|
114
116
|
"#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d")}"
|
|
115
117
|
end
|
|
116
118
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
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
|
-
|
|
155
|
-
remainder =
|
|
154
|
+
time = to_time(time)
|
|
155
|
+
remainder = time.to_i % STEP_SECONDS
|
|
156
156
|
return time if remainder.zero?
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
time + (STEP_SECONDS - remainder)
|
|
159
159
|
end
|
|
160
160
|
|
|
161
161
|
def round_down_15(time)
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
time = to_time(time)
|
|
163
|
+
remainder = time.to_i % STEP_SECONDS
|
|
164
|
+
return time if remainder.zero?
|
|
165
|
+
|
|
166
|
+
time - remainder
|
|
164
167
|
end
|
|
165
168
|
|
|
166
169
|
def to_time(value)
|
|
167
|
-
|
|
168
|
-
return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String)
|
|
169
|
-
|
|
170
|
-
Time.parse(value.to_s)
|
|
170
|
+
TimeFormat.unified(value)
|
|
171
171
|
end
|
|
172
172
|
end
|
|
173
173
|
end
|
|
@@ -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
|