flycal-cli 0.7.4 → 1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +6 -174
- data/bin/flycal +2 -2
- 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/calendar_service.rb +115 -0
- data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +320 -250
- 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 +4 -2
- 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 +186 -0
- data/lib/flycal/credentials.rb +34 -0
- data/lib/{flycal_cli → flycal}/date_time_parser.rb +4 -4
- data/lib/flycal/description_query.rb +32 -0
- 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/mock/calendar_service.rb +43 -0
- data/lib/flycal/mock/config.rb +171 -0
- data/lib/flycal/mock/event_generator.rb +71 -0
- data/lib/flycal/mock.rb +6 -0
- data/lib/flycal/pipeline/aggregator.rb +226 -0
- data/lib/flycal/pipeline/json_renderer.rb +145 -0
- data/lib/flycal/pipeline/params.rb +46 -0
- data/lib/flycal/pipeline/renderer.rb +27 -0
- data/lib/flycal/pipeline/retriever.rb +72 -0
- data/lib/flycal/pipeline/search_pipeline.rb +18 -0
- data/lib/flycal/pipeline/text_renderer.rb +134 -0
- data/lib/flycal/pipeline.rb +6 -0
- data/lib/{flycal_cli → flycal}/slot_finder.rb +1 -1
- data/lib/flycal/slot_formatter.rb +195 -0
- data/lib/flycal/version.rb +11 -0
- data/lib/flycal.rb +12 -0
- data/lib/flycal_cli.rb +37 -14
- data/mcp/tools.json +36 -9
- data/mockTemplates/mock1.json +11 -0
- data/mocks/mock1.json +11 -0
- metadata +38 -12
- data/lib/flycal_cli/calendar_service.rb +0 -74
- data/lib/flycal_cli/slot_formatter.rb +0 -68
- data/lib/flycal_cli/version.rb +0 -5
|
@@ -9,10 +9,13 @@ require "active_support/core_ext/time"
|
|
|
9
9
|
require "tty-prompt"
|
|
10
10
|
require "tty-spinner"
|
|
11
11
|
|
|
12
|
-
module
|
|
13
|
-
|
|
12
|
+
module Flycal
|
|
13
|
+
module Cli
|
|
14
|
+
class App < Thor
|
|
14
15
|
package_name "flycal"
|
|
15
16
|
class_option :locale, type: :string, desc: "Override locale for this command (e.g. en, it)"
|
|
17
|
+
class_option :format, type: :string, default: "text",
|
|
18
|
+
desc: "Output format: text or json"
|
|
16
19
|
|
|
17
20
|
def self.exit_on_failure?
|
|
18
21
|
true
|
|
@@ -42,7 +45,7 @@ module FlycalCli
|
|
|
42
45
|
Auth.login
|
|
43
46
|
puts "\n✓ Authentication completed successfully!"
|
|
44
47
|
puts "\nRun 'flycal config' to set the default calendar."
|
|
45
|
-
rescue
|
|
48
|
+
rescue Flycal::Error => e
|
|
46
49
|
puts "Error: #{e.message}"
|
|
47
50
|
exit 1
|
|
48
51
|
end
|
|
@@ -119,20 +122,62 @@ module FlycalCli
|
|
|
119
122
|
Format: 30days, 48hours, 2months, 1year (no space).
|
|
120
123
|
With space use quotes: --in "30 days"
|
|
121
124
|
|
|
125
|
+
Mock mode (no Google API; triggered by --mockCalendar or --mockTemplate):
|
|
126
|
+
--mockTemplate NAME Load defaults from mocks/ or mockTemplates/NAME.json
|
|
127
|
+
--mockCalendar NAME Mock calendar id/name (required here or in template)
|
|
128
|
+
--mockSeed N Reproducible random distribution
|
|
129
|
+
--mockEventCount N
|
|
130
|
+
--mockEventFrom/--mockEventTo
|
|
131
|
+
--mockEventDescriptionPatterns a,b,c
|
|
132
|
+
--mockEventDurationMin/--mockEventDurationMax
|
|
133
|
+
--mockEventHoursFrom/--mockEventHoursTo
|
|
134
|
+
|
|
122
135
|
Examples:
|
|
123
136
|
flycal search
|
|
124
137
|
flycal search --in 30days -d placeholder
|
|
125
138
|
flycal search -i 1months --description placeholder
|
|
126
139
|
flycal search -f 2025-03-01 --in 2months
|
|
140
|
+
flycal search --mockTemplate mock1 --description work --calendar mock1
|
|
141
|
+
flycal search --groupBy "rui|solver" --format json
|
|
142
|
+
flycal search -c "Work|Personal"
|
|
143
|
+
flycal search --calendar Work --calendar Personal
|
|
127
144
|
LONGDESC
|
|
128
|
-
option :calendar, type: :string, aliases: "-c",
|
|
145
|
+
option :calendar, type: :string, aliases: "-c", repeatable: true,
|
|
146
|
+
desc: "Calendar name or ID (repeatable, or pipe-separated: Work|Personal)"
|
|
147
|
+
|
|
129
148
|
option :from, type: :string, aliases: "-f", desc: "Start (default: today midnight)"
|
|
130
149
|
option :to, type: :string, aliases: "-t", desc: "End (default: 23:59 of day 30)"
|
|
131
150
|
option :in, type: :string, aliases: "-i", desc: "Duration: 30days, 48hours, 2months, 1year (overrides --to)"
|
|
132
|
-
option :description, type: :string, aliases: "-d",
|
|
151
|
+
option :description, type: :string, aliases: "-d",
|
|
152
|
+
desc: "Filter text in event (OR with |, e.g. rui|solver)"
|
|
153
|
+
option :groupBy, type: :string,
|
|
154
|
+
desc: "Grouping: day, week, month, or a string like rui|solver (default: auto from timeframe)"
|
|
155
|
+
option :mockTemplate, type: :string, desc: "Load mock defaults from mocks/<name>.json"
|
|
156
|
+
option :mockCalendar, type: :string, desc: "Use a generated mock calendar (skips Google API)"
|
|
157
|
+
option :mockSeed, type: :numeric, desc: "Seed for reproducible mock events"
|
|
158
|
+
option :mockEventDescriptionPatterns, type: :string, desc: "Comma-separated title patterns (e.g. work,personal)"
|
|
159
|
+
option :mockEventCount, type: :numeric, desc: "Number of mock events to generate"
|
|
160
|
+
option :mockEventFrom, type: :string, desc: "Mock events start date"
|
|
161
|
+
option :mockEventTo, type: :string, desc: "Mock events end date"
|
|
162
|
+
option :mockEventDurationMin, type: :string, desc: "Min mock event duration (e.g. 4h)"
|
|
163
|
+
option :mockEventDurationMax, type: :string, desc: "Max mock event duration (e.g. 4h)"
|
|
164
|
+
option :mockEventHoursFrom, type: :string, desc: "Daily start window for mock events (HH:MM)"
|
|
165
|
+
option :mockEventHoursTo, type: :string, desc: "Daily end window for mock events (HH:MM)"
|
|
166
|
+
option :nocache, type: :boolean, default: false,
|
|
167
|
+
desc: "Bypass cache and refresh from Google"
|
|
133
168
|
def search
|
|
134
169
|
apply_locale_override
|
|
135
|
-
|
|
170
|
+
|
|
171
|
+
mock_mode = Mock::Config.enabled?(options)
|
|
172
|
+
mock_config = nil
|
|
173
|
+
if mock_mode
|
|
174
|
+
begin
|
|
175
|
+
mock_config = Mock::Config.from_options(options)
|
|
176
|
+
rescue Flycal::Error => e
|
|
177
|
+
puts "Error: #{e.message}"
|
|
178
|
+
exit 1
|
|
179
|
+
end
|
|
180
|
+
elsif !Auth.logged_in?
|
|
136
181
|
puts "You are not connected. Run 'flycal login' first."
|
|
137
182
|
exit 1
|
|
138
183
|
end
|
|
@@ -144,7 +189,7 @@ module FlycalCli
|
|
|
144
189
|
else
|
|
145
190
|
DurationParser.add_to_time(options[:in], time_min)
|
|
146
191
|
end
|
|
147
|
-
rescue
|
|
192
|
+
rescue Flycal::Error => e
|
|
148
193
|
puts "Error: #{e.message}"
|
|
149
194
|
exit 1
|
|
150
195
|
end
|
|
@@ -154,24 +199,89 @@ module FlycalCli
|
|
|
154
199
|
exit 1
|
|
155
200
|
end
|
|
156
201
|
|
|
157
|
-
|
|
158
|
-
|
|
202
|
+
service =
|
|
203
|
+
if mock_config
|
|
204
|
+
Mock::CalendarService.new(mock_config)
|
|
205
|
+
else
|
|
206
|
+
flycal_client.calendar
|
|
207
|
+
end
|
|
159
208
|
|
|
160
|
-
calendar_ids =
|
|
209
|
+
calendar_ids =
|
|
210
|
+
if mock_config && options[:calendar].to_s.empty?
|
|
211
|
+
[mock_config.calendar_name]
|
|
212
|
+
else
|
|
213
|
+
resolve_calendar_ids(service, options[:calendar])
|
|
214
|
+
end
|
|
161
215
|
if calendar_ids.empty?
|
|
162
216
|
puts "No calendars found."
|
|
163
217
|
exit 1
|
|
164
218
|
end
|
|
165
219
|
|
|
166
|
-
|
|
167
|
-
|
|
220
|
+
params = Pipeline::Params.new(
|
|
221
|
+
command: "search",
|
|
168
222
|
time_min: time_min,
|
|
169
223
|
time_max: time_max,
|
|
170
|
-
|
|
224
|
+
calendar_ids: calendar_ids,
|
|
225
|
+
description: options[:description],
|
|
226
|
+
calendar: options[:calendar],
|
|
227
|
+
from_option: options[:from],
|
|
228
|
+
to_option: options[:to],
|
|
229
|
+
in_option: options[:in],
|
|
230
|
+
group_by_option: options[:groupBy],
|
|
231
|
+
format: options[:format] || "text",
|
|
232
|
+
locale: Locale.current_locale,
|
|
233
|
+
use_mock: !mock_config.nil?,
|
|
234
|
+
mock_seed: mock_config&.seed,
|
|
235
|
+
mock_calendar: mock_config&.calendar_name,
|
|
236
|
+
mock_template: mock_config&.template_name
|
|
171
237
|
)
|
|
172
238
|
|
|
173
|
-
|
|
174
|
-
|
|
239
|
+
fingerprint = {
|
|
240
|
+
command: "search",
|
|
241
|
+
time_min: time_min,
|
|
242
|
+
time_max: time_max,
|
|
243
|
+
calendar_ids: calendar_ids,
|
|
244
|
+
description: options[:description],
|
|
245
|
+
calendar: options[:calendar],
|
|
246
|
+
from_option: options[:from],
|
|
247
|
+
to_option: options[:to],
|
|
248
|
+
in_option: options[:in],
|
|
249
|
+
group_by_option: options[:groupBy],
|
|
250
|
+
format: options[:format] || "text",
|
|
251
|
+
locale: Locale.current_locale,
|
|
252
|
+
use_mock: !mock_config.nil?,
|
|
253
|
+
mock_seed: mock_config&.seed,
|
|
254
|
+
mock_calendar: mock_config&.calendar_name,
|
|
255
|
+
mock_template: mock_config&.template_name
|
|
256
|
+
}
|
|
257
|
+
cache_key = Flycal::CacheKey.generate("search", fingerprint)
|
|
258
|
+
ttl = Flycal::CacheKey::DEFAULT_TTL_MINUTES
|
|
259
|
+
nocache = options[:nocache]
|
|
260
|
+
|
|
261
|
+
begin
|
|
262
|
+
output = nil
|
|
263
|
+
status = nil
|
|
264
|
+
|
|
265
|
+
unless nocache
|
|
266
|
+
cached = FileCache.read(cache_key, ttl_minutes: ttl)
|
|
267
|
+
if cached
|
|
268
|
+
output = cached
|
|
269
|
+
status = "HIT"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
if output.nil?
|
|
274
|
+
FileCache.delete(cache_key) if nocache
|
|
275
|
+
output = Pipeline::SearchPipeline.new(service).run(params)
|
|
276
|
+
FileCache.write(cache_key, strip_cache_marker_from_output(output, params[:format]))
|
|
277
|
+
status = "MISS"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
print annotate_cli_output(output, status, cache_key, params[:format])
|
|
281
|
+
rescue Flycal::Error => e
|
|
282
|
+
puts "Error: #{e.message}"
|
|
283
|
+
exit 1
|
|
284
|
+
end
|
|
175
285
|
end
|
|
176
286
|
|
|
177
287
|
desc "slots", "Find available time slots in your calendar"
|
|
@@ -198,7 +308,11 @@ module FlycalCli
|
|
|
198
308
|
desc: "Start (default: now). Dates, or relative: monday, next monday, tomorrow, lunedi..."
|
|
199
309
|
option :template, type: :string, aliases: "-T",
|
|
200
310
|
desc: "Slot template name from config (default: first template, usually work)"
|
|
201
|
-
option :calendar, type: :string, aliases: "-c",
|
|
311
|
+
option :calendar, type: :string, aliases: "-c", repeatable: true,
|
|
312
|
+
desc: "Calendar name or ID (repeatable, or pipe-separated: Work|Personal)"
|
|
313
|
+
option :nocache, type: :boolean, default: false,
|
|
314
|
+
desc: "Bypass cache and refresh from Google"
|
|
315
|
+
|
|
202
316
|
def slots
|
|
203
317
|
apply_locale_override
|
|
204
318
|
unless Auth.logged_in?
|
|
@@ -228,7 +342,7 @@ module FlycalCli
|
|
|
228
342
|
time_max = DurationParser.add_to_time(in_value, time_min)
|
|
229
343
|
hours = parse_workhours(template["hours"])
|
|
230
344
|
days = parse_template_days(template["days"])
|
|
231
|
-
rescue
|
|
345
|
+
rescue Flycal::Error => e
|
|
232
346
|
puts "Error: #{e.message}"
|
|
233
347
|
exit 1
|
|
234
348
|
end
|
|
@@ -238,8 +352,8 @@ module FlycalCli
|
|
|
238
352
|
exit 1
|
|
239
353
|
end
|
|
240
354
|
|
|
241
|
-
|
|
242
|
-
service =
|
|
355
|
+
client = flycal_client
|
|
356
|
+
service = client.calendar
|
|
243
357
|
|
|
244
358
|
exclude_calendar_ids = resolve_slots_exclude_calendar_ids(service, slot_cfg, options[:calendar])
|
|
245
359
|
if exclude_calendar_ids.empty?
|
|
@@ -247,53 +361,118 @@ module FlycalCli
|
|
|
247
361
|
exit 1
|
|
248
362
|
end
|
|
249
363
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
fetch_from = time_min - free_before
|
|
258
|
-
events = exclude_calendar_ids.flat_map do |calendar_id|
|
|
259
|
-
service.list_events(
|
|
260
|
-
calendar_id,
|
|
261
|
-
time_min: fetch_from,
|
|
262
|
-
time_max: time_max
|
|
263
|
-
)
|
|
264
|
-
rescue Google::Apis::Errors::Error => e
|
|
265
|
-
warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
|
|
266
|
-
[]
|
|
364
|
+
format = (options[:format] || "text").to_s.strip.downcase
|
|
365
|
+
format = "text" if format.empty?
|
|
366
|
+
|
|
367
|
+
unless %w[json text].include?(format)
|
|
368
|
+
puts "Error: Unsupported format #{format.inspect}. Available: text, json"
|
|
369
|
+
exit 1
|
|
267
370
|
end
|
|
268
371
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
372
|
+
fingerprint = {
|
|
373
|
+
command: "slots",
|
|
374
|
+
calendar_ids: exclude_calendar_ids,
|
|
375
|
+
duration: duration_value,
|
|
376
|
+
from: from_value,
|
|
377
|
+
in: in_value,
|
|
378
|
+
free_before: slot_cfg["free_before"] || "0m",
|
|
379
|
+
free_after: slot_cfg["free_after"] || "0m",
|
|
380
|
+
template: template_name,
|
|
381
|
+
locale: Locale.current_locale,
|
|
382
|
+
calendar: options[:calendar],
|
|
274
383
|
hours: hours,
|
|
275
384
|
days: days,
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
385
|
+
time_min: time_min,
|
|
386
|
+
time_max: time_max,
|
|
387
|
+
format: format
|
|
388
|
+
}
|
|
389
|
+
cache_key = Flycal::CacheKey.generate("slots", fingerprint)
|
|
390
|
+
ttl = Flycal::CacheKey::DEFAULT_TTL_MINUTES
|
|
391
|
+
nocache = options[:nocache]
|
|
392
|
+
|
|
393
|
+
output = nil
|
|
394
|
+
status = nil
|
|
395
|
+
unless nocache
|
|
396
|
+
cached = FileCache.read(cache_key, ttl_minutes: ttl)
|
|
397
|
+
if cached
|
|
398
|
+
output = cached
|
|
399
|
+
status = "HIT"
|
|
400
|
+
end
|
|
401
|
+
end
|
|
279
402
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
403
|
+
if output.nil?
|
|
404
|
+
FileCache.delete(cache_key) if nocache
|
|
405
|
+
|
|
406
|
+
calendars = client.list_calendars
|
|
407
|
+
calendar_meta = exclude_calendar_ids.filter_map do |id|
|
|
408
|
+
cal = calendars.find { |c| c.id == id }
|
|
409
|
+
name = cal&.summary || id
|
|
410
|
+
{ id: id, name: name }
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
fetch_from = time_min - free_before
|
|
414
|
+
events = exclude_calendar_ids.flat_map do |calendar_id|
|
|
415
|
+
client.list_events(
|
|
416
|
+
calendar_id,
|
|
417
|
+
time_min: fetch_from,
|
|
418
|
+
time_max: time_max
|
|
419
|
+
)
|
|
420
|
+
rescue Google::Apis::Error => e
|
|
421
|
+
warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
|
|
422
|
+
[]
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
finder = SlotFinder.new(
|
|
426
|
+
events: events,
|
|
427
|
+
time_min: time_min,
|
|
428
|
+
time_max: time_max,
|
|
429
|
+
slot_duration_seconds: slot_duration,
|
|
430
|
+
hours: hours,
|
|
431
|
+
days: days,
|
|
432
|
+
free_before_seconds: free_before,
|
|
433
|
+
free_after_seconds: free_after
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
slots_by_day = finder.slots_by_day
|
|
437
|
+
|
|
438
|
+
output =
|
|
439
|
+
if format == "json"
|
|
440
|
+
SlotFormatter.format_json(
|
|
441
|
+
slots_by_day: slots_by_day,
|
|
442
|
+
time_min: time_min,
|
|
443
|
+
time_max: time_max,
|
|
444
|
+
duration: duration_value,
|
|
445
|
+
template: template_name,
|
|
446
|
+
calendars: calendar_meta,
|
|
447
|
+
locale: Locale.current_locale,
|
|
448
|
+
calendar_option: options[:calendar],
|
|
449
|
+
from_option: options[:from],
|
|
450
|
+
in_option: options[:in],
|
|
451
|
+
free_before: slot_cfg["free_before"],
|
|
452
|
+
free_after: slot_cfg["free_after"],
|
|
453
|
+
empty_message: Locale.t("slots.no_available")
|
|
454
|
+
)
|
|
455
|
+
else
|
|
456
|
+
SlotFormatter.format_text(
|
|
457
|
+
slots_by_day: slots_by_day,
|
|
458
|
+
time_min: time_min,
|
|
459
|
+
time_max: time_max,
|
|
460
|
+
duration: duration_value,
|
|
461
|
+
calendars: calendar_meta,
|
|
462
|
+
template: template_name,
|
|
463
|
+
empty_message: Locale.t("slots.no_available")
|
|
464
|
+
)
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
FileCache.write(cache_key, strip_cache_marker_from_output(output, format))
|
|
468
|
+
status = "MISS"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
print annotate_cli_output(output, status, cache_key, format)
|
|
472
|
+
|
|
473
|
+
if format == "text"
|
|
474
|
+
body = extract_slots_body_for_clipboard(output)
|
|
475
|
+
copy_slots_to_clipboard(body) unless body.empty?
|
|
297
476
|
end
|
|
298
477
|
end
|
|
299
478
|
|
|
@@ -311,15 +490,13 @@ module FlycalCli
|
|
|
311
490
|
desc "version", "Show current flycal-cli version"
|
|
312
491
|
def version
|
|
313
492
|
apply_locale_override
|
|
314
|
-
puts "flycal #{
|
|
493
|
+
puts "flycal #{Flycal::VERSION}"
|
|
315
494
|
end
|
|
316
495
|
|
|
317
496
|
default_task :help
|
|
318
497
|
|
|
319
498
|
private
|
|
320
499
|
|
|
321
|
-
HOURS_PER_WORKING_DAY = 8
|
|
322
|
-
|
|
323
500
|
def bold(str)
|
|
324
501
|
"\e[1m#{str}\e[0m"
|
|
325
502
|
end
|
|
@@ -332,6 +509,38 @@ module FlycalCli
|
|
|
332
509
|
Locale.override!(options[:locale])
|
|
333
510
|
end
|
|
334
511
|
|
|
512
|
+
def annotate_cli_output(output, status, cache_key, format)
|
|
513
|
+
fmt = format.to_s.strip.downcase
|
|
514
|
+
if fmt == "json"
|
|
515
|
+
payload = JSON.parse(output)
|
|
516
|
+
Flycal::CacheAnnotator.annotate_payload!(payload, status, cache_key)
|
|
517
|
+
JSON.pretty_generate(payload) + "\n"
|
|
518
|
+
else
|
|
519
|
+
Flycal::CacheAnnotator.annotate_text(output, status, cache_key)
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def strip_cache_marker_from_output(output, format)
|
|
524
|
+
fmt = format.to_s.strip.downcase
|
|
525
|
+
if fmt == "json"
|
|
526
|
+
payload = JSON.parse(output)
|
|
527
|
+
JSON.pretty_generate(Flycal::CacheAnnotator.strip_for_storage(payload)) + "\n"
|
|
528
|
+
else
|
|
529
|
+
lines = output.to_s.sub(/\n\z/, "").split("\n")
|
|
530
|
+
lines.reject! { |l| l.start_with?("Cache:") }
|
|
531
|
+
"#{lines.join("\n")}\n"
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def extract_slots_body_for_clipboard(text)
|
|
536
|
+
lines = text.to_s.split("\n")
|
|
537
|
+
lines.reject! { |l| l.start_with?("Cache:") }
|
|
538
|
+
blank_idx = lines.index { |l| l == "" }
|
|
539
|
+
return "" unless blank_idx
|
|
540
|
+
|
|
541
|
+
lines[(blank_idx + 1)..].join("\n").strip
|
|
542
|
+
end
|
|
543
|
+
|
|
335
544
|
def with_config_interrupt_handling
|
|
336
545
|
yield
|
|
337
546
|
rescue Interrupt
|
|
@@ -340,12 +549,11 @@ module FlycalCli
|
|
|
340
549
|
end
|
|
341
550
|
|
|
342
551
|
def load_calendars
|
|
343
|
-
|
|
344
|
-
service = CalendarService.new(creds)
|
|
552
|
+
client = flycal_client
|
|
345
553
|
|
|
346
554
|
spinner = TTY::Spinner.new("#{Locale.t('common.loading_calendars')} ", format: :dots)
|
|
347
555
|
spinner.auto_spin
|
|
348
|
-
calendars =
|
|
556
|
+
calendars = client.list_calendars
|
|
349
557
|
spinner.stop("✓")
|
|
350
558
|
|
|
351
559
|
if calendars.empty?
|
|
@@ -356,6 +564,13 @@ module FlycalCli
|
|
|
356
564
|
calendars
|
|
357
565
|
end
|
|
358
566
|
|
|
567
|
+
def flycal_client
|
|
568
|
+
creds = Auth.credentials
|
|
569
|
+
raise Flycal::Error, Locale.t("errors.not_connected") if creds.nil?
|
|
570
|
+
|
|
571
|
+
Flycal.connect(credentials: creds)
|
|
572
|
+
end
|
|
573
|
+
|
|
359
574
|
def calendar_choices(calendars, highlight_ids: [])
|
|
360
575
|
highlights = Array(highlight_ids).compact
|
|
361
576
|
calendars.to_h do |cal|
|
|
@@ -429,24 +644,11 @@ module FlycalCli
|
|
|
429
644
|
end
|
|
430
645
|
|
|
431
646
|
def resolve_calendar_refs(calendars, refs)
|
|
432
|
-
|
|
433
|
-
resolve_calendar_ref(calendars, ref)
|
|
434
|
-
end.uniq
|
|
647
|
+
Flycal::CalendarQuery.resolve_ids(calendars, refs)
|
|
435
648
|
end
|
|
436
649
|
|
|
437
650
|
def resolve_calendar_ref(calendars, ref)
|
|
438
|
-
|
|
439
|
-
return nil if ref.empty?
|
|
440
|
-
|
|
441
|
-
exact = calendars.find { |cal| cal.id == ref }
|
|
442
|
-
return exact.id if exact
|
|
443
|
-
|
|
444
|
-
matches = calendars.select do |cal|
|
|
445
|
-
cal.id == ref ||
|
|
446
|
-
(cal.summary && cal.summary.downcase.include?(ref.downcase))
|
|
447
|
-
end
|
|
448
|
-
|
|
449
|
-
matches.first&.id
|
|
651
|
+
Flycal::CalendarQuery.resolve_ref(calendars, ref)
|
|
450
652
|
end
|
|
451
653
|
|
|
452
654
|
def resolve_slots_exclude_calendar_ids(service, slot_cfg, calendar_override)
|
|
@@ -454,21 +656,27 @@ module FlycalCli
|
|
|
454
656
|
calendars = service.list_calendars
|
|
455
657
|
|
|
456
658
|
refs = if configured.nil? || (configured.is_a?(Array) && configured.empty?)
|
|
457
|
-
|
|
458
|
-
|
|
659
|
+
override_refs = Flycal::CalendarQuery.refs(calendar_override)
|
|
660
|
+
if override_refs.any?
|
|
661
|
+
override_refs
|
|
662
|
+
else
|
|
663
|
+
fallback = Config.calendar_default
|
|
664
|
+
fallback ? [ fallback ] : []
|
|
665
|
+
end
|
|
459
666
|
else
|
|
460
667
|
configured
|
|
461
668
|
end
|
|
462
669
|
|
|
463
|
-
ids =
|
|
670
|
+
ids = Flycal::CalendarQuery.resolve_ids(calendars, refs)
|
|
464
671
|
return ids unless ids.empty?
|
|
465
672
|
|
|
466
|
-
|
|
467
|
-
if
|
|
468
|
-
|
|
673
|
+
fallback_refs = Flycal::CalendarQuery.refs(calendar_override)
|
|
674
|
+
fallback_refs = [ Config.calendar_default ].compact if fallback_refs.empty?
|
|
675
|
+
if fallback_refs.any?
|
|
676
|
+
Flycal::CalendarQuery.resolve_ids(calendars, fallback_refs)
|
|
469
677
|
else
|
|
470
678
|
primary = calendars.find(&:primary)
|
|
471
|
-
primary ? [primary.id] : calendars.first(1).map(&:id)
|
|
679
|
+
primary ? [ primary.id ] : calendars.first(1).map(&:id)
|
|
472
680
|
end
|
|
473
681
|
end
|
|
474
682
|
|
|
@@ -480,7 +688,7 @@ module FlycalCli
|
|
|
480
688
|
|
|
481
689
|
def resolve_slots_template(slot_cfg, requested_name)
|
|
482
690
|
templates = slot_cfg["templates"]
|
|
483
|
-
raise
|
|
691
|
+
raise Flycal::Error, "slots.templates is missing in config.yml." unless templates.is_a?(Hash) && !templates.empty?
|
|
484
692
|
|
|
485
693
|
name = requested_name.to_s.strip
|
|
486
694
|
name = templates.keys.first.to_s if name.empty?
|
|
@@ -488,7 +696,7 @@ module FlycalCli
|
|
|
488
696
|
template = templates[name]
|
|
489
697
|
unless template.is_a?(Hash)
|
|
490
698
|
available = templates.keys.join(", ")
|
|
491
|
-
raise
|
|
699
|
+
raise Flycal::Error, "Unknown slots template #{name.inspect}. Available: #{available}"
|
|
492
700
|
end
|
|
493
701
|
|
|
494
702
|
[name, template]
|
|
@@ -496,10 +704,10 @@ module FlycalCli
|
|
|
496
704
|
|
|
497
705
|
def parse_template_days(values)
|
|
498
706
|
days = Array(values).map(&:to_i)
|
|
499
|
-
raise
|
|
707
|
+
raise Flycal::Error, "slots template days cannot be empty." if days.empty?
|
|
500
708
|
|
|
501
709
|
invalid = days.reject { |d| d.between?(1, 7) }
|
|
502
|
-
raise
|
|
710
|
+
raise Flycal::Error, "Invalid template days #{invalid.inspect}. Use 1 (Mon) .. 7 (Sun)." unless invalid.empty?
|
|
503
711
|
|
|
504
712
|
days.uniq
|
|
505
713
|
end
|
|
@@ -514,12 +722,12 @@ module FlycalCli
|
|
|
514
722
|
|
|
515
723
|
def parse_hour_minute(value)
|
|
516
724
|
match = value.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/)
|
|
517
|
-
raise
|
|
725
|
+
raise Flycal::Error, "Invalid time format #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)." unless match
|
|
518
726
|
|
|
519
727
|
hour = match[1].to_i
|
|
520
728
|
minute = match[2].to_i
|
|
521
729
|
unless hour.between?(0, 23) && minute.between?(0, 59)
|
|
522
|
-
raise
|
|
730
|
+
raise Flycal::Error, "Invalid time #{value.inspect}. Hour must be 0-23 and minute 0-59."
|
|
523
731
|
end
|
|
524
732
|
|
|
525
733
|
[hour, minute]
|
|
@@ -528,18 +736,18 @@ module FlycalCli
|
|
|
528
736
|
def parse_workhours(values)
|
|
529
737
|
ranges = Array(values).map do |item|
|
|
530
738
|
start_str, end_str = item.to_s.strip.split("-", 2)
|
|
531
|
-
raise
|
|
739
|
+
raise Flycal::Error, "Invalid hours item #{item.inspect}. Use format like '9-13' or '14:30-18:00'." if start_str.nil? || end_str.nil?
|
|
532
740
|
|
|
533
741
|
sh, sm = parse_hour_minute_flexible(start_str)
|
|
534
742
|
eh, em = parse_hour_minute_flexible(end_str)
|
|
535
743
|
start_minutes = (sh * 60) + sm
|
|
536
744
|
end_minutes = (eh * 60) + em
|
|
537
|
-
raise
|
|
745
|
+
raise Flycal::Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
|
|
538
746
|
|
|
539
747
|
[sh, sm, eh, em]
|
|
540
748
|
end
|
|
541
749
|
|
|
542
|
-
raise
|
|
750
|
+
raise Flycal::Error, "template hours cannot be empty in config.yml." if ranges.empty?
|
|
543
751
|
|
|
544
752
|
ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
|
|
545
753
|
end
|
|
@@ -548,7 +756,7 @@ module FlycalCli
|
|
|
548
756
|
str = value.to_s.strip
|
|
549
757
|
if str.match?(/\A\d{1,2}\z/)
|
|
550
758
|
hour = str.to_i
|
|
551
|
-
raise
|
|
759
|
+
raise Flycal::Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
|
|
552
760
|
|
|
553
761
|
return [hour, 0]
|
|
554
762
|
end
|
|
@@ -556,165 +764,27 @@ module FlycalCli
|
|
|
556
764
|
parse_hour_minute(str)
|
|
557
765
|
end
|
|
558
766
|
|
|
559
|
-
def resolve_calendar_ids(service,
|
|
767
|
+
def resolve_calendar_ids(service, calendar_option)
|
|
560
768
|
calendar_lists = service.list_calendars
|
|
769
|
+
refs = Flycal::CalendarQuery.refs(calendar_option)
|
|
561
770
|
|
|
562
|
-
if
|
|
771
|
+
if refs.empty?
|
|
563
772
|
default_id = Config.calendar_default
|
|
564
|
-
if default_id
|
|
565
|
-
return [default_id]
|
|
566
|
-
end
|
|
567
|
-
# Use primary calendar if available
|
|
568
|
-
primary = calendar_lists.find(&:primary)
|
|
569
|
-
return [primary.id] if primary
|
|
570
|
-
# Otherwise first calendar
|
|
571
|
-
return [calendar_lists.first.id] if calendar_lists.any?
|
|
572
|
-
return []
|
|
573
|
-
end
|
|
574
|
-
|
|
575
|
-
# Search by name or ID
|
|
576
|
-
matches = calendar_lists.select do |cal|
|
|
577
|
-
cal.id == calendar_name ||
|
|
578
|
-
(cal.summary && cal.summary.downcase.include?(calendar_name.downcase))
|
|
579
|
-
end
|
|
580
|
-
|
|
581
|
-
matches.map(&:id)
|
|
582
|
-
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
|
|
773
|
+
return [ default_id ] if default_id
|
|
662
774
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
|
775
|
+
primary = calendar_lists.find(&:primary)
|
|
776
|
+
return [ primary.id ] if primary
|
|
777
|
+
return [ calendar_lists.first.id ] if calendar_lists.any?
|
|
675
778
|
|
|
676
|
-
|
|
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
|
|
779
|
+
return []
|
|
692
780
|
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
781
|
|
|
701
|
-
|
|
702
|
-
|
|
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
|
|
782
|
+
ids = Flycal::CalendarQuery.resolve_ids(calendar_lists, refs)
|
|
783
|
+
return ids unless ids.empty?
|
|
706
784
|
|
|
707
|
-
|
|
708
|
-
|
|
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
|
|
785
|
+
# Fall back to treating unresolved refs as literal calendar IDs
|
|
786
|
+
refs
|
|
718
787
|
end
|
|
719
788
|
end
|
|
720
789
|
end
|
|
790
|
+
end
|