flycal-cli 0.7.3 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +105 -227
- data/lib/flycal_cli/calendar_service.rb +2 -5
- data/lib/flycal_cli/cli.rb +74 -148
- data/lib/flycal_cli/description_query.rb +32 -0
- data/lib/flycal_cli/mock/calendar_service.rb +43 -0
- data/lib/flycal_cli/mock/config.rb +171 -0
- data/lib/flycal_cli/mock/event_generator.rb +71 -0
- data/lib/flycal_cli/mock.rb +10 -0
- data/lib/flycal_cli/pipeline/aggregator.rb +229 -0
- data/lib/flycal_cli/pipeline/json_renderer.rb +142 -0
- data/lib/flycal_cli/pipeline/params.rb +46 -0
- data/lib/flycal_cli/pipeline/renderer.rb +27 -0
- data/lib/flycal_cli/pipeline/retriever.rb +72 -0
- data/lib/flycal_cli/pipeline/search_pipeline.rb +18 -0
- data/lib/flycal_cli/pipeline/text_renderer.rb +133 -0
- data/lib/flycal_cli/pipeline.rb +14 -0
- data/lib/flycal_cli/version.rb +1 -1
- data/lib/flycal_cli.rb +3 -0
- data/mcp/README.md +48 -9
- data/mcp/tools.json +322 -53
- data/mockTemplates/mock1.json +11 -0
- data/mocks/mock1.json +11 -0
- metadata +16 -1
data/lib/flycal_cli/cli.rb
CHANGED
|
@@ -13,6 +13,8 @@ module FlycalCli
|
|
|
13
13
|
class Cli < Thor
|
|
14
14
|
package_name "flycal"
|
|
15
15
|
class_option :locale, type: :string, desc: "Override locale for this command (e.g. en, it)"
|
|
16
|
+
class_option :format, type: :string, default: "text",
|
|
17
|
+
desc: "Output format: text or json"
|
|
16
18
|
|
|
17
19
|
def self.exit_on_failure?
|
|
18
20
|
true
|
|
@@ -119,20 +121,56 @@ module FlycalCli
|
|
|
119
121
|
Format: 30days, 48hours, 2months, 1year (no space).
|
|
120
122
|
With space use quotes: --in "30 days"
|
|
121
123
|
|
|
124
|
+
Mock mode (no Google API; triggered by --mockCalendar or --mockTemplate):
|
|
125
|
+
--mockTemplate NAME Load defaults from mocks/ or mockTemplates/NAME.json
|
|
126
|
+
--mockCalendar NAME Mock calendar id/name (required here or in template)
|
|
127
|
+
--mockSeed N Reproducible random distribution
|
|
128
|
+
--mockEventCount N
|
|
129
|
+
--mockEventFrom/--mockEventTo
|
|
130
|
+
--mockEventDescriptionPatterns a,b,c
|
|
131
|
+
--mockEventDurationMin/--mockEventDurationMax
|
|
132
|
+
--mockEventHoursFrom/--mockEventHoursTo
|
|
133
|
+
|
|
122
134
|
Examples:
|
|
123
135
|
flycal search
|
|
124
136
|
flycal search --in 30days -d placeholder
|
|
125
137
|
flycal search -i 1months --description placeholder
|
|
126
138
|
flycal search -f 2025-03-01 --in 2months
|
|
139
|
+
flycal search --mockTemplate mock1 --description work --calendar mock1
|
|
140
|
+
flycal search -d "rui|solver" --groupBy description --format json
|
|
127
141
|
LONGDESC
|
|
128
142
|
option :calendar, type: :string, aliases: "-c", desc: "Calendar name or ID"
|
|
129
143
|
option :from, type: :string, aliases: "-f", desc: "Start (default: today midnight)"
|
|
130
144
|
option :to, type: :string, aliases: "-t", desc: "End (default: 23:59 of day 30)"
|
|
131
145
|
option :in, type: :string, aliases: "-i", desc: "Duration: 30days, 48hours, 2months, 1year (overrides --to)"
|
|
132
|
-
option :description, type: :string, aliases: "-d",
|
|
146
|
+
option :description, type: :string, aliases: "-d",
|
|
147
|
+
desc: "Filter text in event (OR with |, e.g. rui|solver)"
|
|
148
|
+
option :groupBy, type: :string,
|
|
149
|
+
desc: "Grouping: day, week, month, or description (default: auto from timeframe)"
|
|
150
|
+
option :mockTemplate, type: :string, desc: "Load mock defaults from mocks/<name>.json"
|
|
151
|
+
option :mockCalendar, type: :string, desc: "Use a generated mock calendar (skips Google API)"
|
|
152
|
+
option :mockSeed, type: :numeric, desc: "Seed for reproducible mock events"
|
|
153
|
+
option :mockEventDescriptionPatterns, type: :string, desc: "Comma-separated title patterns (e.g. work,personal)"
|
|
154
|
+
option :mockEventCount, type: :numeric, desc: "Number of mock events to generate"
|
|
155
|
+
option :mockEventFrom, type: :string, desc: "Mock events start date"
|
|
156
|
+
option :mockEventTo, type: :string, desc: "Mock events end date"
|
|
157
|
+
option :mockEventDurationMin, type: :string, desc: "Min mock event duration (e.g. 4h)"
|
|
158
|
+
option :mockEventDurationMax, type: :string, desc: "Max mock event duration (e.g. 4h)"
|
|
159
|
+
option :mockEventHoursFrom, type: :string, desc: "Daily start window for mock events (HH:MM)"
|
|
160
|
+
option :mockEventHoursTo, type: :string, desc: "Daily end window for mock events (HH:MM)"
|
|
133
161
|
def search
|
|
134
162
|
apply_locale_override
|
|
135
|
-
|
|
163
|
+
|
|
164
|
+
mock_mode = Mock::Config.enabled?(options)
|
|
165
|
+
mock_config = nil
|
|
166
|
+
if mock_mode
|
|
167
|
+
begin
|
|
168
|
+
mock_config = Mock::Config.from_options(options)
|
|
169
|
+
rescue FlycalCli::Error => e
|
|
170
|
+
puts "Error: #{e.message}"
|
|
171
|
+
exit 1
|
|
172
|
+
end
|
|
173
|
+
elsif !Auth.logged_in?
|
|
136
174
|
puts "You are not connected. Run 'flycal login' first."
|
|
137
175
|
exit 1
|
|
138
176
|
end
|
|
@@ -154,24 +192,50 @@ module FlycalCli
|
|
|
154
192
|
exit 1
|
|
155
193
|
end
|
|
156
194
|
|
|
157
|
-
|
|
158
|
-
|
|
195
|
+
service =
|
|
196
|
+
if mock_config
|
|
197
|
+
Mock::CalendarService.new(mock_config)
|
|
198
|
+
else
|
|
199
|
+
CalendarService.new(Auth.credentials)
|
|
200
|
+
end
|
|
159
201
|
|
|
160
|
-
calendar_ids =
|
|
202
|
+
calendar_ids =
|
|
203
|
+
if mock_config && options[:calendar].to_s.empty?
|
|
204
|
+
[mock_config.calendar_name]
|
|
205
|
+
else
|
|
206
|
+
resolve_calendar_ids(service, options[:calendar])
|
|
207
|
+
end
|
|
161
208
|
if calendar_ids.empty?
|
|
162
209
|
puts "No calendars found."
|
|
163
210
|
exit 1
|
|
164
211
|
end
|
|
165
212
|
|
|
166
|
-
|
|
167
|
-
|
|
213
|
+
params = Pipeline::Params.new(
|
|
214
|
+
command: "search",
|
|
168
215
|
time_min: time_min,
|
|
169
216
|
time_max: time_max,
|
|
170
|
-
|
|
217
|
+
calendar_ids: calendar_ids,
|
|
218
|
+
description: options[:description],
|
|
219
|
+
calendar: options[:calendar],
|
|
220
|
+
from_option: options[:from],
|
|
221
|
+
to_option: options[:to],
|
|
222
|
+
in_option: options[:in],
|
|
223
|
+
group_by_option: options[:groupBy],
|
|
224
|
+
format: options[:format] || "text",
|
|
225
|
+
locale: Locale.current_locale,
|
|
226
|
+
use_mock: !mock_config.nil?,
|
|
227
|
+
mock_seed: mock_config&.seed,
|
|
228
|
+
mock_calendar: mock_config&.calendar_name,
|
|
229
|
+
mock_template: mock_config&.template_name
|
|
171
230
|
)
|
|
172
231
|
|
|
173
|
-
|
|
174
|
-
|
|
232
|
+
begin
|
|
233
|
+
output = Pipeline::SearchPipeline.new(service).run(params)
|
|
234
|
+
print output
|
|
235
|
+
rescue FlycalCli::Error => e
|
|
236
|
+
puts "Error: #{e.message}"
|
|
237
|
+
exit 1
|
|
238
|
+
end
|
|
175
239
|
end
|
|
176
240
|
|
|
177
241
|
desc "slots", "Find available time slots in your calendar"
|
|
@@ -318,8 +382,6 @@ module FlycalCli
|
|
|
318
382
|
|
|
319
383
|
private
|
|
320
384
|
|
|
321
|
-
HOURS_PER_WORKING_DAY = 8
|
|
322
|
-
|
|
323
385
|
def bold(str)
|
|
324
386
|
"\e[1m#{str}\e[0m"
|
|
325
387
|
end
|
|
@@ -580,141 +642,5 @@ module FlycalCli
|
|
|
580
642
|
|
|
581
643
|
matches.map(&:id)
|
|
582
644
|
end
|
|
583
|
-
|
|
584
|
-
def print_events(service, events)
|
|
585
|
-
# Use calendar list for names (avoids extra API calls)
|
|
586
|
-
calendar_list = service.list_calendars
|
|
587
|
-
calendar_names = calendar_list.to_h { |c| [c.id, c.summary || c.id] }
|
|
588
|
-
|
|
589
|
-
events.each do |item|
|
|
590
|
-
cal_id = item[:calendar_id]
|
|
591
|
-
event = item[:event]
|
|
592
|
-
cal_name = calendar_names[cal_id] || cal_id
|
|
593
|
-
|
|
594
|
-
start_time = event.start&.date_time || event.start&.date
|
|
595
|
-
end_time = event.end&.date_time || event.end&.date
|
|
596
|
-
|
|
597
|
-
start_str = format_datetime(start_time)
|
|
598
|
-
end_str = format_datetime(end_time)
|
|
599
|
-
desc = event.summary || "(no title)"
|
|
600
|
-
desc = event.description&.slice(0, 80) if event.summary.nil? && event.description
|
|
601
|
-
|
|
602
|
-
puts "#{cal_name} | #{start_str} | #{end_str} | #{desc}"
|
|
603
|
-
end
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
def format_datetime(dt)
|
|
607
|
-
return "-" if dt.nil?
|
|
608
|
-
|
|
609
|
-
if dt.is_a?(String)
|
|
610
|
-
dt
|
|
611
|
-
else
|
|
612
|
-
"#{Locale.day_abbr(dt)} #{dt.strftime("%Y-%m-%d %H:%M")}"
|
|
613
|
-
end
|
|
614
|
-
end
|
|
615
|
-
|
|
616
|
-
def format_date_with_day(dt)
|
|
617
|
-
return "-" if dt.nil?
|
|
618
|
-
|
|
619
|
-
t = dt.respond_to?(:to_time) ? dt.to_time : dt
|
|
620
|
-
"#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d")}"
|
|
621
|
-
end
|
|
622
|
-
|
|
623
|
-
def print_search_summary(events, time_min:, time_max:)
|
|
624
|
-
event_ranges = extract_event_ranges(events)
|
|
625
|
-
total_minutes = event_ranges.sum { |s, e| (e - s) / 60 }
|
|
626
|
-
|
|
627
|
-
from_str = time_min.strftime("%a %Y-%m-%d %H:%M")
|
|
628
|
-
to_str = time_max.strftime("%a %Y-%m-%d %H:%M")
|
|
629
|
-
|
|
630
|
-
puts "\n---"
|
|
631
|
-
puts "From: #{from_str} | To: #{to_str}"
|
|
632
|
-
puts "Events found: #{events.size}"
|
|
633
|
-
puts "Total time occupied: #{format_duration(total_minutes)}"
|
|
634
|
-
|
|
635
|
-
frame_days = (time_max - time_min) / 86400.0
|
|
636
|
-
if frame_days > 30
|
|
637
|
-
print_monthly_breakdown(event_ranges, time_min, time_max)
|
|
638
|
-
elsif frame_days > 7
|
|
639
|
-
print_weekly_breakdown(event_ranges, time_min, time_max)
|
|
640
|
-
end
|
|
641
|
-
end
|
|
642
|
-
|
|
643
|
-
def extract_event_ranges(events)
|
|
644
|
-
events.filter_map do |item|
|
|
645
|
-
event = item[:event]
|
|
646
|
-
start_t = event.start&.date_time || event.start&.date
|
|
647
|
-
end_t = event.end&.date_time || event.end&.date
|
|
648
|
-
next if start_t.nil? || end_t.nil?
|
|
649
|
-
|
|
650
|
-
start_t = start_t.to_time if start_t.respond_to?(:to_time)
|
|
651
|
-
end_t = end_t.to_time if end_t.respond_to?(:to_time)
|
|
652
|
-
[start_t, end_t]
|
|
653
|
-
end
|
|
654
|
-
end
|
|
655
|
-
|
|
656
|
-
def format_duration(total_minutes)
|
|
657
|
-
hours = (total_minutes / 60).floor
|
|
658
|
-
mins = (total_minutes % 60).round
|
|
659
|
-
working_days = (total_minutes / 60.0 / HOURS_PER_WORKING_DAY).round(1)
|
|
660
|
-
"#{bold(hours)}h #{bold(mins)}min (#{bold(working_days)} working days)"
|
|
661
|
-
end
|
|
662
|
-
|
|
663
|
-
def format_hours_and_days(hours, working_days)
|
|
664
|
-
"#{bold(hours)}h (#{bold(working_days)} working days)"
|
|
665
|
-
end
|
|
666
|
-
|
|
667
|
-
def minutes_in_period(event_ranges, period_start, period_end)
|
|
668
|
-
event_ranges.sum do |ev_start, ev_end|
|
|
669
|
-
overlap_start = [ev_start, period_start].max
|
|
670
|
-
overlap_end = [ev_end, period_end].min
|
|
671
|
-
overlap_sec = overlap_end - overlap_start
|
|
672
|
-
overlap_sec > 0 ? overlap_sec / 60.0 : 0
|
|
673
|
-
end
|
|
674
|
-
end
|
|
675
|
-
|
|
676
|
-
def print_weekly_breakdown(event_ranges, time_min, time_max)
|
|
677
|
-
puts "\nBy week:"
|
|
678
|
-
week_num = 1
|
|
679
|
-
current = time_min.to_date.beginning_of_week(:monday)
|
|
680
|
-
while current.to_time < time_max
|
|
681
|
-
week_start = [current.to_time, time_min].max
|
|
682
|
-
week_end_date = current.end_of_week(:monday)
|
|
683
|
-
week_end = [week_end_date.to_time + 1.day, time_max].min
|
|
684
|
-
mins = minutes_in_period(event_ranges, week_start, week_end)
|
|
685
|
-
hours = (mins / 60).round(1)
|
|
686
|
-
working_days = (mins / 60.0 / HOURS_PER_WORKING_DAY).round(1)
|
|
687
|
-
start_str = format_date_with_day(week_start)
|
|
688
|
-
end_str = format_date_with_day(week_end_date)
|
|
689
|
-
puts " #{bold(week_num)}. #{start_str} - #{end_str}: #{format_hours_and_days(hours, working_days)}"
|
|
690
|
-
week_num += 1
|
|
691
|
-
current = current + 7.days
|
|
692
|
-
end
|
|
693
|
-
end
|
|
694
|
-
|
|
695
|
-
def print_monthly_breakdown(event_ranges, time_min, time_max)
|
|
696
|
-
puts "\nBy month:"
|
|
697
|
-
month_num = 1
|
|
698
|
-
current = time_min.to_date.beginning_of_month
|
|
699
|
-
end_date = time_max.to_date
|
|
700
|
-
|
|
701
|
-
while current <= end_date
|
|
702
|
-
month_start = current.beginning_of_month.to_time
|
|
703
|
-
month_end = (current.end_of_month + 1.day).to_time
|
|
704
|
-
period_start = [month_start, time_min].max
|
|
705
|
-
period_end = [month_end, time_max].min
|
|
706
|
-
|
|
707
|
-
mins = minutes_in_period(event_ranges, period_start, period_end)
|
|
708
|
-
hours = (mins / 60).round(1)
|
|
709
|
-
working_days = (mins / 60.0 / HOURS_PER_WORKING_DAY).round(1)
|
|
710
|
-
month_name = "#{Locale.month_name(current)} #{current.year}"
|
|
711
|
-
start_str = format_date_with_day(period_start)
|
|
712
|
-
last_day = (period_end.to_date - 1.day)
|
|
713
|
-
end_str = format_date_with_day(last_day)
|
|
714
|
-
puts " #{month_num}. #{month_name} (#{start_str} - #{end_str}): #{format_hours_and_days(hours, working_days)}"
|
|
715
|
-
month_num += 1
|
|
716
|
-
current = current.next_month.beginning_of_month
|
|
717
|
-
end
|
|
718
|
-
end
|
|
719
645
|
end
|
|
720
646
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlycalCli
|
|
4
|
+
# Shared helpers for --description filtering and --groupBy description.
|
|
5
|
+
#
|
|
6
|
+
# OR terms are separated by "|" (also "," for grouping convenience).
|
|
7
|
+
# Matching is case-insensitive against event summary and description.
|
|
8
|
+
module DescriptionQuery
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def patterns(query)
|
|
12
|
+
query.to_s.split(/[|,]/).map(&:strip).reject(&:empty?)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def match?(event_summary, event_description, query)
|
|
16
|
+
terms = patterns(query)
|
|
17
|
+
return true if terms.empty?
|
|
18
|
+
|
|
19
|
+
summary = event_summary.to_s.downcase
|
|
20
|
+
description = event_description.to_s.downcase
|
|
21
|
+
terms.any? { |term| summary.include?(term.downcase) || description.include?(term.downcase) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def match_term?(event_summary, event_description, term)
|
|
25
|
+
needle = term.to_s.downcase
|
|
26
|
+
return false if needle.empty?
|
|
27
|
+
|
|
28
|
+
event_summary.to_s.downcase.include?(needle) ||
|
|
29
|
+
event_description.to_s.downcase.include?(needle)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FlycalCli
|
|
4
|
+
module Mock
|
|
5
|
+
# Duck-typed CalendarService backed by generated mock events (no Google API).
|
|
6
|
+
class CalendarService
|
|
7
|
+
Calendar = Struct.new(:id, :summary, :primary, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
@calendar_id = config.calendar_name
|
|
12
|
+
@events = EventGenerator.new(config).generate
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def list_calendars
|
|
16
|
+
[Calendar.new(id: @calendar_id, summary: @calendar_id, primary: true)]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def list_all_events(calendar_ids, time_min:, time_max:, query: nil)
|
|
20
|
+
ids = Array(calendar_ids)
|
|
21
|
+
return [] unless ids.empty? || ids.include?(@calendar_id)
|
|
22
|
+
|
|
23
|
+
items = @events.filter_map do |event|
|
|
24
|
+
start_at = event.start.date_time
|
|
25
|
+
end_at = event.end.date_time
|
|
26
|
+
next if start_at.nil? || end_at.nil?
|
|
27
|
+
next if end_at <= time_min || start_at >= time_max
|
|
28
|
+
|
|
29
|
+
{ calendar_id: @calendar_id, event: event }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if query && !query.to_s.strip.empty?
|
|
33
|
+
items.select! do |item|
|
|
34
|
+
event = item[:event]
|
|
35
|
+
DescriptionQuery.match?(event.summary, event.description, query)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
items
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FlycalCli
|
|
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 FlycalCli::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 FlycalCli::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 FlycalCli::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 FlycalCli::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 FlycalCli::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 FlycalCli::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 FlycalCli::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 FlycalCli::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 FlycalCli::Error, "Missing mock parameters: #{missing.join(", ")}" unless missing.empty?
|
|
151
|
+
|
|
152
|
+
if duration_min_seconds > duration_max_seconds
|
|
153
|
+
raise FlycalCli::Error, "mockEventDurationMin must be <= mockEventDurationMax."
|
|
154
|
+
end
|
|
155
|
+
if range_from > range_to
|
|
156
|
+
raise FlycalCli::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 FlycalCli::Error, "mockEventHoursFrom must be before mockEventHoursTo."
|
|
163
|
+
end
|
|
164
|
+
if duration_min_seconds > (to_mins - from_mins) * 60
|
|
165
|
+
raise FlycalCli::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 FlycalCli
|
|
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 FlycalCli::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
|