flycal-cli 0.7.7 → 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 -181
- 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_cli → flycal}/calendar_service.rb +48 -4
- data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +253 -109
- 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_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 +2 -2
- 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 -13
- 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 +9 -8
- 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 -17
- data/mcp/tools.json +21 -8
- metadata +36 -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
|
@@ -9,8 +9,9 @@ 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)"
|
|
16
17
|
class_option :format, type: :string, default: "text",
|
|
@@ -44,7 +45,7 @@ module FlycalCli
|
|
|
44
45
|
Auth.login
|
|
45
46
|
puts "\n✓ Authentication completed successfully!"
|
|
46
47
|
puts "\nRun 'flycal config' to set the default calendar."
|
|
47
|
-
rescue
|
|
48
|
+
rescue Flycal::Error => e
|
|
48
49
|
puts "Error: #{e.message}"
|
|
49
50
|
exit 1
|
|
50
51
|
end
|
|
@@ -137,16 +138,20 @@ module FlycalCli
|
|
|
137
138
|
flycal search -i 1months --description placeholder
|
|
138
139
|
flycal search -f 2025-03-01 --in 2months
|
|
139
140
|
flycal search --mockTemplate mock1 --description work --calendar mock1
|
|
140
|
-
flycal search
|
|
141
|
+
flycal search --groupBy "rui|solver" --format json
|
|
142
|
+
flycal search -c "Work|Personal"
|
|
143
|
+
flycal search --calendar Work --calendar Personal
|
|
141
144
|
LONGDESC
|
|
142
|
-
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
|
+
|
|
143
148
|
option :from, type: :string, aliases: "-f", desc: "Start (default: today midnight)"
|
|
144
149
|
option :to, type: :string, aliases: "-t", desc: "End (default: 23:59 of day 30)"
|
|
145
150
|
option :in, type: :string, aliases: "-i", desc: "Duration: 30days, 48hours, 2months, 1year (overrides --to)"
|
|
146
151
|
option :description, type: :string, aliases: "-d",
|
|
147
152
|
desc: "Filter text in event (OR with |, e.g. rui|solver)"
|
|
148
153
|
option :groupBy, type: :string,
|
|
149
|
-
desc: "Grouping: day, week, month, or
|
|
154
|
+
desc: "Grouping: day, week, month, or a string like rui|solver (default: auto from timeframe)"
|
|
150
155
|
option :mockTemplate, type: :string, desc: "Load mock defaults from mocks/<name>.json"
|
|
151
156
|
option :mockCalendar, type: :string, desc: "Use a generated mock calendar (skips Google API)"
|
|
152
157
|
option :mockSeed, type: :numeric, desc: "Seed for reproducible mock events"
|
|
@@ -158,6 +163,8 @@ module FlycalCli
|
|
|
158
163
|
option :mockEventDurationMax, type: :string, desc: "Max mock event duration (e.g. 4h)"
|
|
159
164
|
option :mockEventHoursFrom, type: :string, desc: "Daily start window for mock events (HH:MM)"
|
|
160
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"
|
|
161
168
|
def search
|
|
162
169
|
apply_locale_override
|
|
163
170
|
|
|
@@ -166,7 +173,7 @@ module FlycalCli
|
|
|
166
173
|
if mock_mode
|
|
167
174
|
begin
|
|
168
175
|
mock_config = Mock::Config.from_options(options)
|
|
169
|
-
rescue
|
|
176
|
+
rescue Flycal::Error => e
|
|
170
177
|
puts "Error: #{e.message}"
|
|
171
178
|
exit 1
|
|
172
179
|
end
|
|
@@ -182,7 +189,7 @@ module FlycalCli
|
|
|
182
189
|
else
|
|
183
190
|
DurationParser.add_to_time(options[:in], time_min)
|
|
184
191
|
end
|
|
185
|
-
rescue
|
|
192
|
+
rescue Flycal::Error => e
|
|
186
193
|
puts "Error: #{e.message}"
|
|
187
194
|
exit 1
|
|
188
195
|
end
|
|
@@ -196,7 +203,7 @@ module FlycalCli
|
|
|
196
203
|
if mock_config
|
|
197
204
|
Mock::CalendarService.new(mock_config)
|
|
198
205
|
else
|
|
199
|
-
|
|
206
|
+
flycal_client.calendar
|
|
200
207
|
end
|
|
201
208
|
|
|
202
209
|
calendar_ids =
|
|
@@ -229,10 +236,49 @@ module FlycalCli
|
|
|
229
236
|
mock_template: mock_config&.template_name
|
|
230
237
|
)
|
|
231
238
|
|
|
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
|
+
|
|
232
261
|
begin
|
|
233
|
-
output =
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
236
282
|
puts "Error: #{e.message}"
|
|
237
283
|
exit 1
|
|
238
284
|
end
|
|
@@ -262,7 +308,11 @@ module FlycalCli
|
|
|
262
308
|
desc: "Start (default: now). Dates, or relative: monday, next monday, tomorrow, lunedi..."
|
|
263
309
|
option :template, type: :string, aliases: "-T",
|
|
264
310
|
desc: "Slot template name from config (default: first template, usually work)"
|
|
265
|
-
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
|
+
|
|
266
316
|
def slots
|
|
267
317
|
apply_locale_override
|
|
268
318
|
unless Auth.logged_in?
|
|
@@ -292,7 +342,7 @@ module FlycalCli
|
|
|
292
342
|
time_max = DurationParser.add_to_time(in_value, time_min)
|
|
293
343
|
hours = parse_workhours(template["hours"])
|
|
294
344
|
days = parse_template_days(template["days"])
|
|
295
|
-
rescue
|
|
345
|
+
rescue Flycal::Error => e
|
|
296
346
|
puts "Error: #{e.message}"
|
|
297
347
|
exit 1
|
|
298
348
|
end
|
|
@@ -302,8 +352,8 @@ module FlycalCli
|
|
|
302
352
|
exit 1
|
|
303
353
|
end
|
|
304
354
|
|
|
305
|
-
|
|
306
|
-
service =
|
|
355
|
+
client = flycal_client
|
|
356
|
+
service = client.calendar
|
|
307
357
|
|
|
308
358
|
exclude_calendar_ids = resolve_slots_exclude_calendar_ids(service, slot_cfg, options[:calendar])
|
|
309
359
|
if exclude_calendar_ids.empty?
|
|
@@ -311,53 +361,118 @@ module FlycalCli
|
|
|
311
361
|
exit 1
|
|
312
362
|
end
|
|
313
363
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
fetch_from = time_min - free_before
|
|
322
|
-
events = exclude_calendar_ids.flat_map do |calendar_id|
|
|
323
|
-
service.list_events(
|
|
324
|
-
calendar_id,
|
|
325
|
-
time_min: fetch_from,
|
|
326
|
-
time_max: time_max
|
|
327
|
-
)
|
|
328
|
-
rescue Google::Apis::Errors::Error => e
|
|
329
|
-
warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
|
|
330
|
-
[]
|
|
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
|
|
331
370
|
end
|
|
332
371
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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],
|
|
338
383
|
hours: hours,
|
|
339
384
|
days: days,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
343
402
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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?
|
|
361
476
|
end
|
|
362
477
|
end
|
|
363
478
|
|
|
@@ -375,7 +490,7 @@ module FlycalCli
|
|
|
375
490
|
desc "version", "Show current flycal-cli version"
|
|
376
491
|
def version
|
|
377
492
|
apply_locale_override
|
|
378
|
-
puts "flycal #{
|
|
493
|
+
puts "flycal #{Flycal::VERSION}"
|
|
379
494
|
end
|
|
380
495
|
|
|
381
496
|
default_task :help
|
|
@@ -394,6 +509,38 @@ module FlycalCli
|
|
|
394
509
|
Locale.override!(options[:locale])
|
|
395
510
|
end
|
|
396
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
|
+
|
|
397
544
|
def with_config_interrupt_handling
|
|
398
545
|
yield
|
|
399
546
|
rescue Interrupt
|
|
@@ -402,12 +549,11 @@ module FlycalCli
|
|
|
402
549
|
end
|
|
403
550
|
|
|
404
551
|
def load_calendars
|
|
405
|
-
|
|
406
|
-
service = CalendarService.new(creds)
|
|
552
|
+
client = flycal_client
|
|
407
553
|
|
|
408
554
|
spinner = TTY::Spinner.new("#{Locale.t('common.loading_calendars')} ", format: :dots)
|
|
409
555
|
spinner.auto_spin
|
|
410
|
-
calendars =
|
|
556
|
+
calendars = client.list_calendars
|
|
411
557
|
spinner.stop("✓")
|
|
412
558
|
|
|
413
559
|
if calendars.empty?
|
|
@@ -418,6 +564,13 @@ module FlycalCli
|
|
|
418
564
|
calendars
|
|
419
565
|
end
|
|
420
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
|
+
|
|
421
574
|
def calendar_choices(calendars, highlight_ids: [])
|
|
422
575
|
highlights = Array(highlight_ids).compact
|
|
423
576
|
calendars.to_h do |cal|
|
|
@@ -491,24 +644,11 @@ module FlycalCli
|
|
|
491
644
|
end
|
|
492
645
|
|
|
493
646
|
def resolve_calendar_refs(calendars, refs)
|
|
494
|
-
|
|
495
|
-
resolve_calendar_ref(calendars, ref)
|
|
496
|
-
end.uniq
|
|
647
|
+
Flycal::CalendarQuery.resolve_ids(calendars, refs)
|
|
497
648
|
end
|
|
498
649
|
|
|
499
650
|
def resolve_calendar_ref(calendars, ref)
|
|
500
|
-
|
|
501
|
-
return nil if ref.empty?
|
|
502
|
-
|
|
503
|
-
exact = calendars.find { |cal| cal.id == ref }
|
|
504
|
-
return exact.id if exact
|
|
505
|
-
|
|
506
|
-
matches = calendars.select do |cal|
|
|
507
|
-
cal.id == ref ||
|
|
508
|
-
(cal.summary && cal.summary.downcase.include?(ref.downcase))
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
matches.first&.id
|
|
651
|
+
Flycal::CalendarQuery.resolve_ref(calendars, ref)
|
|
512
652
|
end
|
|
513
653
|
|
|
514
654
|
def resolve_slots_exclude_calendar_ids(service, slot_cfg, calendar_override)
|
|
@@ -516,21 +656,27 @@ module FlycalCli
|
|
|
516
656
|
calendars = service.list_calendars
|
|
517
657
|
|
|
518
658
|
refs = if configured.nil? || (configured.is_a?(Array) && configured.empty?)
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
521
666
|
else
|
|
522
667
|
configured
|
|
523
668
|
end
|
|
524
669
|
|
|
525
|
-
ids =
|
|
670
|
+
ids = Flycal::CalendarQuery.resolve_ids(calendars, refs)
|
|
526
671
|
return ids unless ids.empty?
|
|
527
672
|
|
|
528
|
-
|
|
529
|
-
if
|
|
530
|
-
|
|
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)
|
|
531
677
|
else
|
|
532
678
|
primary = calendars.find(&:primary)
|
|
533
|
-
primary ? [primary.id] : calendars.first(1).map(&:id)
|
|
679
|
+
primary ? [ primary.id ] : calendars.first(1).map(&:id)
|
|
534
680
|
end
|
|
535
681
|
end
|
|
536
682
|
|
|
@@ -542,7 +688,7 @@ module FlycalCli
|
|
|
542
688
|
|
|
543
689
|
def resolve_slots_template(slot_cfg, requested_name)
|
|
544
690
|
templates = slot_cfg["templates"]
|
|
545
|
-
raise
|
|
691
|
+
raise Flycal::Error, "slots.templates is missing in config.yml." unless templates.is_a?(Hash) && !templates.empty?
|
|
546
692
|
|
|
547
693
|
name = requested_name.to_s.strip
|
|
548
694
|
name = templates.keys.first.to_s if name.empty?
|
|
@@ -550,7 +696,7 @@ module FlycalCli
|
|
|
550
696
|
template = templates[name]
|
|
551
697
|
unless template.is_a?(Hash)
|
|
552
698
|
available = templates.keys.join(", ")
|
|
553
|
-
raise
|
|
699
|
+
raise Flycal::Error, "Unknown slots template #{name.inspect}. Available: #{available}"
|
|
554
700
|
end
|
|
555
701
|
|
|
556
702
|
[name, template]
|
|
@@ -558,10 +704,10 @@ module FlycalCli
|
|
|
558
704
|
|
|
559
705
|
def parse_template_days(values)
|
|
560
706
|
days = Array(values).map(&:to_i)
|
|
561
|
-
raise
|
|
707
|
+
raise Flycal::Error, "slots template days cannot be empty." if days.empty?
|
|
562
708
|
|
|
563
709
|
invalid = days.reject { |d| d.between?(1, 7) }
|
|
564
|
-
raise
|
|
710
|
+
raise Flycal::Error, "Invalid template days #{invalid.inspect}. Use 1 (Mon) .. 7 (Sun)." unless invalid.empty?
|
|
565
711
|
|
|
566
712
|
days.uniq
|
|
567
713
|
end
|
|
@@ -576,12 +722,12 @@ module FlycalCli
|
|
|
576
722
|
|
|
577
723
|
def parse_hour_minute(value)
|
|
578
724
|
match = value.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/)
|
|
579
|
-
raise
|
|
725
|
+
raise Flycal::Error, "Invalid time format #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)." unless match
|
|
580
726
|
|
|
581
727
|
hour = match[1].to_i
|
|
582
728
|
minute = match[2].to_i
|
|
583
729
|
unless hour.between?(0, 23) && minute.between?(0, 59)
|
|
584
|
-
raise
|
|
730
|
+
raise Flycal::Error, "Invalid time #{value.inspect}. Hour must be 0-23 and minute 0-59."
|
|
585
731
|
end
|
|
586
732
|
|
|
587
733
|
[hour, minute]
|
|
@@ -590,18 +736,18 @@ module FlycalCli
|
|
|
590
736
|
def parse_workhours(values)
|
|
591
737
|
ranges = Array(values).map do |item|
|
|
592
738
|
start_str, end_str = item.to_s.strip.split("-", 2)
|
|
593
|
-
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?
|
|
594
740
|
|
|
595
741
|
sh, sm = parse_hour_minute_flexible(start_str)
|
|
596
742
|
eh, em = parse_hour_minute_flexible(end_str)
|
|
597
743
|
start_minutes = (sh * 60) + sm
|
|
598
744
|
end_minutes = (eh * 60) + em
|
|
599
|
-
raise
|
|
745
|
+
raise Flycal::Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
|
|
600
746
|
|
|
601
747
|
[sh, sm, eh, em]
|
|
602
748
|
end
|
|
603
749
|
|
|
604
|
-
raise
|
|
750
|
+
raise Flycal::Error, "template hours cannot be empty in config.yml." if ranges.empty?
|
|
605
751
|
|
|
606
752
|
ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
|
|
607
753
|
end
|
|
@@ -610,7 +756,7 @@ module FlycalCli
|
|
|
610
756
|
str = value.to_s.strip
|
|
611
757
|
if str.match?(/\A\d{1,2}\z/)
|
|
612
758
|
hour = str.to_i
|
|
613
|
-
raise
|
|
759
|
+
raise Flycal::Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
|
|
614
760
|
|
|
615
761
|
return [hour, 0]
|
|
616
762
|
end
|
|
@@ -618,29 +764,27 @@ module FlycalCli
|
|
|
618
764
|
parse_hour_minute(str)
|
|
619
765
|
end
|
|
620
766
|
|
|
621
|
-
def resolve_calendar_ids(service,
|
|
767
|
+
def resolve_calendar_ids(service, calendar_option)
|
|
622
768
|
calendar_lists = service.list_calendars
|
|
769
|
+
refs = Flycal::CalendarQuery.refs(calendar_option)
|
|
623
770
|
|
|
624
|
-
if
|
|
771
|
+
if refs.empty?
|
|
625
772
|
default_id = Config.calendar_default
|
|
626
|
-
if default_id
|
|
627
|
-
|
|
628
|
-
end
|
|
629
|
-
# Use primary calendar if available
|
|
773
|
+
return [ default_id ] if default_id
|
|
774
|
+
|
|
630
775
|
primary = calendar_lists.find(&:primary)
|
|
631
|
-
return [primary.id] if primary
|
|
632
|
-
|
|
633
|
-
|
|
776
|
+
return [ primary.id ] if primary
|
|
777
|
+
return [ calendar_lists.first.id ] if calendar_lists.any?
|
|
778
|
+
|
|
634
779
|
return []
|
|
635
780
|
end
|
|
636
781
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
cal.id == calendar_name ||
|
|
640
|
-
(cal.summary && cal.summary.downcase.include?(calendar_name.downcase))
|
|
641
|
-
end
|
|
782
|
+
ids = Flycal::CalendarQuery.resolve_ids(calendar_lists, refs)
|
|
783
|
+
return ids unless ids.empty?
|
|
642
784
|
|
|
643
|
-
|
|
785
|
+
# Fall back to treating unresolved refs as literal calendar IDs
|
|
786
|
+
refs
|
|
644
787
|
end
|
|
645
788
|
end
|
|
646
789
|
end
|
|
790
|
+
end
|
|
@@ -5,7 +5,8 @@ require "googleauth"
|
|
|
5
5
|
require "googleauth/stores/file_token_store"
|
|
6
6
|
require "fileutils"
|
|
7
7
|
|
|
8
|
-
module
|
|
8
|
+
module Flycal
|
|
9
|
+
module Cli
|
|
9
10
|
class Auth
|
|
10
11
|
SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
|
|
11
12
|
REDIRECT_PORT = 9292
|
|
@@ -42,7 +43,7 @@ module FlycalCli
|
|
|
42
43
|
|
|
43
44
|
def login
|
|
44
45
|
unless Config.credentials_exist?
|
|
45
|
-
raise
|
|
46
|
+
raise Flycal::Error,
|
|
46
47
|
"Credentials file not found. Create #{Config.credentials_path} with OAuth credentials from Google Cloud Console.\n" \
|
|
47
48
|
"Go to: https://console.cloud.google.com/apis/credentials\n" \
|
|
48
49
|
"Create 'Desktop app' credentials and download the JSON as credentials.json"
|
|
@@ -65,7 +66,7 @@ module FlycalCli
|
|
|
65
66
|
|
|
66
67
|
# Start local server to receive the auth code
|
|
67
68
|
code = start_redirect_server(authorizer)
|
|
68
|
-
raise
|
|
69
|
+
raise Flycal::Error, "Authentication cancelled or failed" if code.nil? || code.empty?
|
|
69
70
|
|
|
70
71
|
authorizer.get_and_store_credentials_from_code(
|
|
71
72
|
user_id: "flycal_user",
|
|
@@ -130,3 +131,4 @@ module FlycalCli
|
|
|
130
131
|
end
|
|
131
132
|
end
|
|
132
133
|
end
|
|
134
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Flycal
|
|
4
|
+
module Cli
|
|
4
5
|
# Cross-platform clipboard helper.
|
|
5
6
|
# Prefers: pbcopy (macOS) → wl-copy (Wayland) → xclip (X11) → clip (Windows).
|
|
6
7
|
module Clipboard
|
|
@@ -52,3 +53,4 @@ module FlycalCli
|
|
|
52
53
|
end
|
|
53
54
|
end
|
|
54
55
|
end
|
|
56
|
+
end
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
|
-
module
|
|
6
|
+
module Flycal
|
|
7
|
+
module Cli
|
|
7
8
|
class Config
|
|
8
9
|
CONFIG_DIR = File.expand_path("~/.flycal")
|
|
9
10
|
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
|
10
11
|
CREDENTIALS_FILE = File.join(CONFIG_DIR, "credentials.json")
|
|
11
12
|
TOKENS_FILE = File.join(CONFIG_DIR, "tokens.yml")
|
|
12
|
-
DEFAULTS_FILE = File.expand_path("
|
|
13
|
+
DEFAULTS_FILE = File.expand_path("../../../config/defaults.yml", __dir__)
|
|
13
14
|
|
|
14
15
|
class << self
|
|
15
16
|
def load
|
|
@@ -195,3 +196,4 @@ module FlycalCli
|
|
|
195
196
|
end
|
|
196
197
|
end
|
|
197
198
|
end
|
|
199
|
+
end
|