flycal-cli 0.7.4 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1bfdf39ef3561cb1c12829bd2d28dfaf9c2062df7f4c4a163ee99e057af9d413
4
- data.tar.gz: 8f0ee70cc004bfd8e29a1fd4c5d7b5d852c22a3958f2de596cc2c77f9ed044f8
3
+ metadata.gz: db8d0ab49b0872b92c469825aff6a0a944f9012eed76c8bc0768bc82156de1f9
4
+ data.tar.gz: 16e758564b7adee8f7ea53b79c856865105afc26aa8489abe73313906f35ceb5
5
5
  SHA512:
6
- metadata.gz: 8069638ac5162722bc332a577833da48fb92161ee0b2ca39d6c7c31d1c4aeedecaebcc6bf696f0f9945306bbc4e99b8921718867356b5be87a9d1c22b10ff53e
7
- data.tar.gz: 0bdca914927e92b873e91d42b7a061502a9558a858e392d1df1c93509c7ec85a281c5f47e309e011fd1ef734259c991a6d3fa761ecd520b5e6913a4d6f83590f
6
+ metadata.gz: 9485b33d254189f6f96d4c96f9e8867b047b35354fc60f63da86b8bd84612686afb559b6d5d937b98f41ac091777fd32a25ef1d0827a0cc6f4eb7f11be2287e5
7
+ data.tar.gz: c9b338282c8fa1ecc7a6b979d6c7c8632fcfe03ae598affdb27b98e603545dfb85e688d9d1180569bea2106094322fb9b664f48db89a731dade13d6c80ed2fe9
data/README.md CHANGED
@@ -141,9 +141,16 @@ flycal config
141
141
  | `--from` / `-f` | today midnight | Absolute or relative date |
142
142
  | `--to` / `-t` | +30 days | Ignored if `--in` is set |
143
143
  | `--in` / `-i` | — | Duration from `--from` |
144
- | `--description` / `-d` | — | Contains filter on title/description |
144
+ | `--description` / `-d` | — | Contains filter; OR terms with `\|` (e.g. `rui\|solver`) |
145
+ | `--groupBy` | auto | `day`, `week`, `month`, or `description` (split `--description` terms) |
145
146
  | `--calendar` / `-c` | `calendar_default` | Name or ID |
146
147
  | `--locale` | config / `en` | `en` or `it` |
148
+ | `--format` | `text` | Output format: `text` or `json` |
149
+ | `--mockTemplate` | — | Load mock defaults from `mocks/` or `mockTemplates/<name>.json` (no Google API) |
150
+ | `--mockCalendar` | from template | Generated mock calendar name/id (required in template or CLI) |
151
+ | `--mockSeed` | random | Reproducible mock distribution (printed in summary) |
152
+
153
+ Mock templates live in `mocks/` or `mockTemplates/` (example: `mock1.json` with `mockCalendar`). CLI mock flags override template values. Search `--from` / `--to` keep the normal defaults (today → +30 days), independent of the mock generation window.
147
154
 
148
155
  Relative dates work in English and Italian: `today`/`oggi`, `tomorrow`/`domani`, `monday`/`lunedi`, `next monday`/`prossimo lunedi`, `last friday`/`scorso venerdi`.
149
156
 
@@ -57,14 +57,11 @@ module FlycalCli
57
57
  end
58
58
  end
59
59
 
60
- # Filter: only events where summary or description contains the search string (case-insensitive)
60
+ # Filter: summary/description contains any OR term from query ("a|b"), case-insensitive.
61
61
  if query && !query.to_s.strip.empty?
62
- q = query.strip.downcase
63
62
  all_events.select! do |item|
64
63
  event = item[:event]
65
- summary = (event.summary || "").downcase
66
- description = (event.description || "").downcase
67
- summary.include?(q) || description.include?(q)
64
+ DescriptionQuery.match?(event.summary, event.description, query)
68
65
  end
69
66
  end
70
67
 
@@ -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", desc: "Filter by text in event"
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
- unless Auth.logged_in?
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
- creds = Auth.credentials
158
- service = CalendarService.new(creds)
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 = resolve_calendar_ids(service, options[:calendar])
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
- events = service.list_all_events(
167
- calendar_ids,
213
+ params = Pipeline::Params.new(
214
+ command: "search",
168
215
  time_min: time_min,
169
216
  time_max: time_max,
170
- query: options[:description]
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
- print_events(service, events)
174
- print_search_summary(events, time_min: time_min, time_max: time_max)
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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flycal_cli/mock/config"
4
+ require "flycal_cli/mock/event_generator"
5
+ require "flycal_cli/mock/calendar_service"
6
+
7
+ module FlycalCli
8
+ module Mock
9
+ end
10
+ end