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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -174
  3. data/bin/flycal +2 -2
  4. data/lib/flycal/cache_annotator.rb +83 -0
  5. data/lib/flycal/cache_key.rb +82 -0
  6. data/lib/flycal/calendar_query.rb +60 -0
  7. data/lib/flycal/calendar_service.rb +115 -0
  8. data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +320 -250
  9. data/lib/{flycal_cli → flycal/cli}/auth.rb +5 -3
  10. data/lib/{flycal_cli → flycal/cli}/clipboard.rb +3 -1
  11. data/lib/{flycal_cli → flycal/cli}/config.rb +4 -2
  12. data/lib/flycal/cli/file_cache.rb +44 -0
  13. data/lib/flycal/cli/locale.rb +58 -0
  14. data/lib/flycal/cli.rb +14 -0
  15. data/lib/flycal/client.rb +186 -0
  16. data/lib/flycal/credentials.rb +34 -0
  17. data/lib/{flycal_cli → flycal}/date_time_parser.rb +4 -4
  18. data/lib/flycal/description_query.rb +32 -0
  19. data/lib/{flycal_cli → flycal}/duration_parser.rb +3 -3
  20. data/lib/flycal/error.rb +5 -0
  21. data/lib/flycal/event_mapper.rb +58 -0
  22. data/lib/{flycal_cli → flycal}/locale.rb +35 -3
  23. data/lib/flycal/mock/calendar_service.rb +43 -0
  24. data/lib/flycal/mock/config.rb +171 -0
  25. data/lib/flycal/mock/event_generator.rb +71 -0
  26. data/lib/flycal/mock.rb +6 -0
  27. data/lib/flycal/pipeline/aggregator.rb +226 -0
  28. data/lib/flycal/pipeline/json_renderer.rb +145 -0
  29. data/lib/flycal/pipeline/params.rb +46 -0
  30. data/lib/flycal/pipeline/renderer.rb +27 -0
  31. data/lib/flycal/pipeline/retriever.rb +72 -0
  32. data/lib/flycal/pipeline/search_pipeline.rb +18 -0
  33. data/lib/flycal/pipeline/text_renderer.rb +134 -0
  34. data/lib/flycal/pipeline.rb +6 -0
  35. data/lib/{flycal_cli → flycal}/slot_finder.rb +1 -1
  36. data/lib/flycal/slot_formatter.rb +195 -0
  37. data/lib/flycal/version.rb +11 -0
  38. data/lib/flycal.rb +12 -0
  39. data/lib/flycal_cli.rb +37 -14
  40. data/mcp/tools.json +36 -9
  41. data/mockTemplates/mock1.json +11 -0
  42. data/mocks/mock1.json +11 -0
  43. metadata +38 -12
  44. data/lib/flycal_cli/calendar_service.rb +0 -74
  45. data/lib/flycal_cli/slot_formatter.rb +0 -68
  46. data/lib/flycal_cli/version.rb +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1bfdf39ef3561cb1c12829bd2d28dfaf9c2062df7f4c4a163ee99e057af9d413
4
- data.tar.gz: 8f0ee70cc004bfd8e29a1fd4c5d7b5d852c22a3958f2de596cc2c77f9ed044f8
3
+ metadata.gz: '0683f1fd5316c424662349a75956309890f9fe5d38fd0cd50a4e158f9a7085e1'
4
+ data.tar.gz: b7507dee7df050d2e4f11bfb80002f82c2d4cb2689daff89c1d4be62f244b5e1
5
5
  SHA512:
6
- metadata.gz: 8069638ac5162722bc332a577833da48fb92161ee0b2ca39d6c7c31d1c4aeedecaebcc6bf696f0f9945306bbc4e99b8921718867356b5be87a9d1c22b10ff53e
7
- data.tar.gz: 0bdca914927e92b873e91d42b7a061502a9558a858e392d1df1c93509c7ec85a281c5f47e309e011fd1ef734259c991a6d3fa761ecd520b5e6913a4d6f83590f
6
+ metadata.gz: ccabe113d4314bacfd16f3072986d9686919acf4c489a7d8cf9ec05e2bb6dddec59576a859cc210485f9ef56afe7ad9a8522432ba37f0976ae10e5385ffe6e93
7
+ data.tar.gz: 6fb81eb48cb1bc8ed7ea84b8764eb8c677f1a6b346c5a0841a9498aecd63a06b73f1c801e8dee0f915d3ebf20901e74195356d83a3323edf3ef2725c85fdb4d3
data/README.md CHANGED
@@ -1,181 +1,13 @@
1
1
  # flycal-cli
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/flycal-cli.svg)](https://badge.fury.io/rb/flycal-cli)
3
+ CLI wrapper around **Flycal** core (`lib/flycal` in the monorepo).
4
4
 
5
- Read your Google Calendar from the terminal: search events, find free slots, copy them into an email. **Read-only** — nothing is modified on the calendar.
6
-
7
- ## Quick start
8
-
9
- ```bash
10
- gem install flycal-cli
11
- flycal login
12
- ```
13
-
14
- During the test phase the project already provides the Google API credentials.
15
- Open the browser, approve access, done.
16
-
17
- Optional but useful once:
18
-
19
- ```bash
20
- flycal config # set default calendar + calendars that block free slots
21
- flycal calendars # list calendars (name + id)
22
- ```
23
-
24
- ## Common examples
25
-
26
- **Find free slots (most useful day-to-day)**
27
-
28
- ```bash
29
- flycal slots
30
- flycal slots --in "5 days"
31
- flycal slots --from monday --in "5 days"
32
- flycal slots --from "next monday" --in "2 weeks" --duration 1h
33
- flycal slots --in "12 days" --template dinner
34
- flycal slots --locale it --from lunedi --in "5 days"
35
- ```
36
-
37
- Output is a short header plus a clean list ready to paste (and copied to the clipboard when `pbcopy` / `clip` / `wl-copy` / `xclip` is available):
38
-
39
- ```
40
- found 6 slots
41
- from sat 1 August 2026 to sat 8 August 2026
42
- with duration 45min, template dinner
43
- considering calendars
44
- - Work
45
- - Personal
46
- link: https://calendar.google.com/calendar/r/day/2026/8/1
47
-
48
- saturday 1/8
49
- 19 - 23
50
-
51
- monday 3/8
52
- 19 - 23
53
- ```
54
-
55
- **Search events**
5
+ - Core namespace: `Flycal`
6
+ - CLI namespace: `Flycal::Cli`
7
+ - Gem name: `flycal-cli`
56
8
 
57
9
  ```bash
58
- flycal search
59
- flycal search --in 30days
60
- flycal search --in 30days --description meeting
61
- flycal search --from 2026-08-01 --to 2026-08-31 -c Work
62
- flycal search --from monday --in "2 weeks" --locale it
10
+ ruby -Ilib -Iflycal_cli/lib flycal_cli/bin/flycal --help
63
11
  ```
64
12
 
65
- **Other commands**
66
-
67
- ```bash
68
- flycal version
69
- flycal update
70
- flycal logout
71
- ```
72
-
73
- All commands accept `--locale en|it`.
74
-
75
- ---
76
-
77
- ## Configuration (`~/.flycal/config.yml`)
78
-
79
- Defaults are created automatically on first run. Edit with `flycal config` → `edit config`, or by hand.
80
-
81
- ```yaml
82
- calendar_default: ~
83
- locale: en
84
- slots:
85
- templates:
86
- work:
87
- days: [1, 2, 3, 4, 5] # 1=Mon … 7=Sun
88
- hours:
89
- - 9:30-13:00
90
- - 14:00-18:30
91
- dinner:
92
- days: [1, 2, 3, 4, 5, 6, 7]
93
- hours:
94
- - 19-23
95
- exclude_calendars: [] # calendars whose events block free slots
96
- defaults:
97
- from: now
98
- default_duration: 45min
99
- free_before: 0m
100
- free_after: 15m
101
- ```
102
-
103
- | Key | Meaning |
104
- |---|---|
105
- | `calendar_default` | Default calendar (`~` = unset) |
106
- | `locale` | `en` or `it` |
107
- | `slots.templates` | Named schedules; `flycal slots` uses the first one unless `--template` is set |
108
- | `slots.exclude_calendars` | Events here block free slots (fallback: `calendar_default`) |
109
- | `slots.defaults.*` | Defaults for `--from` / `--duration` |
110
- | `slots.free_before` / `free_after` | Buffers around busy events |
111
-
112
- Interactive helpers:
113
-
114
- ```bash
115
- flycal config
116
- ```
117
-
118
- - `calendar_default` — pick the default calendar
119
- - `exclude_calendars` — multi-select calendars that block slots
120
- - `edit config` — open the YAML in `$EDITOR`
121
-
122
- ---
123
-
124
- ## Parameters (reference)
125
-
126
- ### `flycal slots`
127
-
128
- | Option | Default | Notes |
129
- |---|---|---|
130
- | `--duration` | `45min` | Min free range length (`1h`, `90min`, …) |
131
- | `--from` / `-f` | `now` | Absolute date or relative: `monday`, `next monday`, `tomorrow`, `lunedi`, … |
132
- | `--in` / `-i` | `1 week` | Window from `--from` (`"5 days"`, `2weeks`, …) |
133
- | `--template` / `-T` | first template (`work`) | e.g. `dinner` |
134
- | `--calendar` / `-c` | — | Fallback if `exclude_calendars` is empty |
135
- | `--locale` | config / `en` | `en` or `it` |
136
-
137
- ### `flycal search`
138
-
139
- | Option | Default | Notes |
140
- |---|---|---|
141
- | `--from` / `-f` | today midnight | Absolute or relative date |
142
- | `--to` / `-t` | +30 days | Ignored if `--in` is set |
143
- | `--in` / `-i` | — | Duration from `--from` |
144
- | `--description` / `-d` | — | Contains filter on title/description |
145
- | `--calendar` / `-c` | `calendar_default` | Name or ID |
146
- | `--locale` | config / `en` | `en` or `it` |
147
-
148
- Relative dates work in English and Italian: `today`/`oggi`, `tomorrow`/`domani`, `monday`/`lunedi`, `next monday`/`prossimo lunedi`, `last friday`/`scorso venerdi`.
149
-
150
- ---
151
-
152
- ## Optional: your own Google Cloud credentials
153
-
154
- **Not needed** for the default test API — `flycal login` is enough.
155
-
156
- Use this only if you want flycal to talk to **your own** Google Cloud OAuth client instead of the project-provided test credentials.
157
-
158
- 1. [Google Cloud Console → Credentials](https://console.cloud.google.com/apis/credentials)
159
- 2. Enable **Google Calendar API**
160
- 3. Create **Desktop app** OAuth credentials
161
- 4. Authorized redirect URI: `http://127.0.0.1:9292/oauth2callback`
162
- 5. Save the JSON as `~/.flycal/credentials.json`
163
- 6. Run `flycal login`
164
-
165
- You can also ask a teammate who manages the Cloud project to share that JSON with you.
166
-
167
- ---
168
-
169
- ## Files
170
-
171
- | Path | Purpose |
172
- |---|---|
173
- | `~/.flycal/config.yml` | Settings (calendars, templates, locale, …) |
174
- | `~/.flycal/credentials.json` | Own OAuth client (optional) |
175
- | `~/.flycal/tokens.yml` | Login tokens (managed automatically) |
176
-
177
- MCP catalog for agents: [`mcp/tools.json`](mcp/tools.json)
178
-
179
- ## License
180
-
181
- MIT
13
+ Release from the Rails app root: `bin/release-cli`
data/bin/flycal CHANGED
@@ -4,8 +4,8 @@
4
4
  require "flycal_cli"
5
5
 
6
6
  if ARGV.include?("--version") || ARGV.include?("-v")
7
- puts "flycal #{FlycalCli::VERSION}"
7
+ puts "flycal #{Flycal::VERSION}"
8
8
  exit 0
9
9
  end
10
10
 
11
- FlycalCli::Cli.start(ARGV)
11
+ Flycal::Cli::App.start(ARGV)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flycal
4
+ # Injects cache HIT/MISS metadata into text / JSON payloads.
5
+ module CacheAnnotator
6
+ module_function
7
+
8
+ def line(status, _cache_key = nil)
9
+ "Cache: #{status}"
10
+ end
11
+
12
+ def annotate_text(text, status, cache_key = nil)
13
+ lines = text.to_s.sub(/\n\z/, "").split("\n")
14
+ insert_cache_line!(lines, status, cache_key, fallback: :append)
15
+ "#{lines.join("\n")}\n"
16
+ end
17
+
18
+ def annotate_payload!(payload, status, cache_key)
19
+ payload = stringify_keys(payload)
20
+ info = payload["info"] ||= {}
21
+ info["cache"] = status
22
+ info.delete("cache_key")
23
+
24
+ if payload["text"].is_a?(Array)
25
+ lines = payload["text"].map(&:to_s)
26
+ insert_cache_line!(lines, status, cache_key, fallback: :unshift)
27
+ payload["text"] = lines
28
+ end
29
+
30
+ payload
31
+ end
32
+
33
+ def strip_for_storage(payload)
34
+ copy = Marshal.load(Marshal.dump(stringify_keys(payload)))
35
+ if copy["info"].is_a?(Hash)
36
+ copy["info"] = copy["info"].dup
37
+ copy["info"].delete("cache")
38
+ copy["info"].delete("cache_key")
39
+ end
40
+ if copy["text"].is_a?(Array)
41
+ copy["text"] = copy["text"].reject { |l| l.to_s.start_with?("Cache:") }
42
+ end
43
+ copy
44
+ end
45
+
46
+ def stringify_keys(value)
47
+ case value
48
+ when Hash
49
+ value.each_with_object({}) { |(k, v), out| out[k.to_s] = stringify_keys(v) }
50
+ when Array
51
+ value.map { |v| stringify_keys(v) }
52
+ else
53
+ value
54
+ end
55
+ end
56
+
57
+ def insert_cache_line!(lines, status, cache_key = nil, fallback: :append)
58
+ lines.reject! { |l| l.to_s.start_with?("Cache:") }
59
+ marker = line(status, cache_key)
60
+
61
+ occupied_idx = lines.index { |l| l.to_s.start_with?("Total time occupied:") }
62
+ if occupied_idx
63
+ lines.insert(occupied_idx + 1, marker)
64
+ return lines
65
+ end
66
+
67
+ idx = lines.index("---")
68
+ if idx
69
+ lines.insert(idx + 1, marker)
70
+ else
71
+ blank_idx = lines.index { |l| l == "" }
72
+ if blank_idx
73
+ lines.insert(blank_idx, marker)
74
+ elsif fallback == :unshift
75
+ lines.unshift(marker)
76
+ else
77
+ lines << "---" << marker
78
+ end
79
+ end
80
+ lines
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Flycal
7
+ # Builds stable cache keys for search/slots from fully resolved params
8
+ # (defaults applied, just before Google API calls).
9
+ class CacheKey
10
+ DEFAULT_TTL_MINUTES = 5
11
+
12
+ # Commands / CLI verbs / API paths that use response caching.
13
+ # Everything else (version, calendars, login, config, …) is uncached.
14
+ CACHED_COMMANDS = %w[search slots].freeze
15
+
16
+ SKIP_KEYS = %w[
17
+ nocache cache_status cache_key events totals groups calendar_names raw
18
+ ].freeze
19
+
20
+ class << self
21
+ def cached_commands
22
+ CACHED_COMMANDS
23
+ end
24
+
25
+ def cached?(command)
26
+ CACHED_COMMANDS.include?(command.to_s)
27
+ end
28
+
29
+ def generate(command, params)
30
+ digest = Digest::SHA256.hexdigest(canonical_json(params))
31
+ "flycal-#{command}-#{digest}"
32
+ end
33
+
34
+ def canonical_json(params)
35
+ JSON.generate(deep_sort(normalize(params)))
36
+ end
37
+
38
+ def normalize(value)
39
+ case value
40
+ when Hash
41
+ value.each_with_object({}) do |(k, v), out|
42
+ key = k.to_s
43
+ next if SKIP_KEYS.include?(key)
44
+
45
+ out[key] = normalize(v)
46
+ end
47
+ when Array
48
+ value.map { |v| normalize(v) }
49
+ when Time, DateTime
50
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
51
+ when Date
52
+ value.iso8601
53
+ when nil, TrueClass, FalseClass, Numeric, String
54
+ value
55
+ else
56
+ if value.respond_to?(:iso8601)
57
+ value.iso8601
58
+ elsif value.respond_to?(:to_h) && !value.is_a?(String)
59
+ normalize(value.to_h)
60
+ else
61
+ value.to_s
62
+ end
63
+ end
64
+ end
65
+
66
+ def deep_sort(value)
67
+ case value
68
+ when Hash
69
+ value.keys.map(&:to_s).sort.each_with_object({}) do |k, out|
70
+ # Hash may have symbol keys after normalize we use strings
71
+ raw_key = value.key?(k) ? k : value.keys.find { |x| x.to_s == k }
72
+ out[k] = deep_sort(value[raw_key])
73
+ end
74
+ when Array
75
+ value.map { |v| deep_sort(v) }
76
+ else
77
+ value
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flycal
4
+ # Parses and resolves calendar selection options for CLI and HTTP API.
5
+ #
6
+ # Accepts:
7
+ # - a single string: "Work"
8
+ # - pipe/comma OR list: "Work|Personal" or "Work,Personal"
9
+ # - an array (e.g. repeated --calendar flags): ["Work", "Personal"]
10
+ # - mixed: ["Work|Gym", "Personal"]
11
+ module CalendarQuery
12
+ module_function
13
+
14
+ # Expand raw option value(s) into calendar reference strings.
15
+ def refs(value)
16
+ Array(value).flat_map { |entry| entry.to_s.split(/[|,]/) }
17
+ .map(&:strip)
18
+ .reject(&:empty?)
19
+ .uniq
20
+ end
21
+
22
+ # Resolve refs to calendar IDs against a list of calendar objects
23
+ # (must respond to #id / #summary, or hash with those keys).
24
+ def resolve_ids(calendars, value)
25
+ refs(value).filter_map { |ref| resolve_ref(calendars, ref) }.uniq
26
+ end
27
+
28
+ def resolve_ref(calendars, ref)
29
+ ref = ref.to_s.strip
30
+ return nil if ref.empty?
31
+
32
+ exact = calendars.find { |cal| calendar_id(cal) == ref }
33
+ return calendar_id(exact) if exact
34
+
35
+ exact_name = calendars.find { |cal| calendar_summary(cal).to_s.casecmp?(ref) }
36
+ return calendar_id(exact_name) if exact_name
37
+
38
+ needle = ref.downcase
39
+ partial = calendars.find do |cal|
40
+ summary = calendar_summary(cal).to_s
41
+ !summary.empty? && summary.downcase.include?(needle)
42
+ end
43
+ calendar_id(partial)
44
+ end
45
+
46
+ def calendar_id(cal)
47
+ return nil if cal.nil?
48
+ return cal.id if cal.respond_to?(:id)
49
+
50
+ cal[:id] || cal["id"]
51
+ end
52
+
53
+ def calendar_summary(cal)
54
+ return nil if cal.nil?
55
+ return cal.summary if cal.respond_to?(:summary)
56
+
57
+ cal[:summary] || cal["summary"] || cal[:name] || cal["name"]
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "google/apis/calendar_v3"
4
+
5
+ module Flycal
6
+ class CalendarService
7
+ def initialize(credentials)
8
+ @service = Google::Apis::CalendarV3::CalendarService.new
9
+ @service.authorization = credentials
10
+ end
11
+
12
+ def list_calendars
13
+ result = @service.list_calendar_lists
14
+ result.items || []
15
+ end
16
+
17
+ def get_calendar(calendar_id)
18
+ @service.get_calendar(calendar_id)
19
+ end
20
+
21
+ def get_event(calendar_id, event_id)
22
+ @service.get_event(calendar_id, event_id)
23
+ end
24
+
25
+ def list_events(calendar_id, time_min:, time_max:, query: nil)
26
+ events = []
27
+ page_token = nil
28
+
29
+ loop do
30
+ result = @service.list_events(
31
+ calendar_id,
32
+ time_min: iso8601(time_min),
33
+ time_max: iso8601(time_max),
34
+ q: query,
35
+ single_events: true,
36
+ order_by: "startTime",
37
+ page_token: page_token
38
+ )
39
+
40
+ events.concat(result.items || [])
41
+
42
+ page_token = result.next_page_token
43
+ break if page_token.nil? || page_token.empty?
44
+ end
45
+
46
+ events
47
+ end
48
+
49
+ def list_all_events(calendar_ids, time_min:, time_max:, query: nil)
50
+ all_events = []
51
+
52
+ # When --description is used: fetch all events and filter client-side to guarantee
53
+ # contains/like behavior. We do not use the API's q param so we can ensure we
54
+ # return all events where the string appears in summary or description (case-insensitive).
55
+ calendar_ids.each do |cal_id|
56
+ begin
57
+ events = list_events(cal_id, time_min: time_min, time_max: time_max, query: nil)
58
+ events.each { |e| all_events << { calendar_id: cal_id, event: e } }
59
+ rescue Google::Apis::Error => e
60
+ warn "Error in calendar #{cal_id}: #{e.message}"
61
+ end
62
+ end
63
+
64
+ # Filter: summary/description contains any OR term from query ("a|b"), case-insensitive.
65
+ if query && !query.to_s.strip.empty?
66
+ all_events.select! do |item|
67
+ event = item[:event]
68
+ DescriptionQuery.match?(event.summary, event.description, query)
69
+ end
70
+ end
71
+
72
+ all_events
73
+ end
74
+
75
+ def create_event(calendar_id: "primary", summary:, start_time:, end_time:, all_day: false)
76
+ event = Google::Apis::CalendarV3::Event.new(summary: summary)
77
+ apply_event_times!(event, start_time: start_time, end_time: end_time, all_day: all_day)
78
+ @service.insert_event(calendar_id, event)
79
+ end
80
+
81
+ def update_event(calendar_id: "primary", event_id:, summary:, start_time:, end_time:, all_day: false)
82
+ event = get_event(calendar_id, event_id)
83
+ event.summary = summary if summary && !summary.to_s.empty?
84
+ apply_event_times!(event, start_time: start_time, end_time: end_time, all_day: all_day)
85
+ @service.update_event(calendar_id, event_id, event)
86
+ end
87
+
88
+ private
89
+
90
+ def iso8601(value)
91
+ return value.iso8601 if value.respond_to?(:iso8601)
92
+ return value.to_time.iso8601 if value.respond_to?(:to_time)
93
+
94
+ value.to_s
95
+ end
96
+
97
+ def apply_event_times!(event, start_time:, end_time:, all_day:)
98
+ if all_day
99
+ event.start = Google::Apis::CalendarV3::EventDateTime.new(
100
+ date: Date.parse(start_time.to_s).to_s
101
+ )
102
+ event.end = Google::Apis::CalendarV3::EventDateTime.new(
103
+ date: Date.parse((end_time || start_time).to_s).to_s
104
+ )
105
+ else
106
+ event.start = Google::Apis::CalendarV3::EventDateTime.new(
107
+ date_time: DateTime.parse(start_time.to_s).rfc3339
108
+ )
109
+ event.end = Google::Apis::CalendarV3::EventDateTime.new(
110
+ date_time: DateTime.parse((end_time || start_time).to_s).rfc3339
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end