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
|
@@ -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
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Flycal
|
|
6
|
+
module Cli
|
|
7
|
+
# File-backed cache under ~/.flycal/cache (stores rendered text/json strings).
|
|
8
|
+
class FileCache
|
|
9
|
+
class << self
|
|
10
|
+
def dir
|
|
11
|
+
File.join(Config.config_dir, "cache")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def path_for(cache_key)
|
|
15
|
+
File.join(dir, sanitize(cache_key))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read(cache_key, ttl_minutes:)
|
|
19
|
+
path = path_for(cache_key)
|
|
20
|
+
return nil unless File.file?(path)
|
|
21
|
+
|
|
22
|
+
age = Time.now - File.mtime(path)
|
|
23
|
+
return nil if age > ttl_minutes.to_f * 60
|
|
24
|
+
|
|
25
|
+
File.read(path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(cache_key, content)
|
|
29
|
+
FileUtils.mkdir_p(dir)
|
|
30
|
+
File.write(path_for(cache_key), content.to_s)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def delete(cache_key)
|
|
34
|
+
path = path_for(cache_key)
|
|
35
|
+
File.delete(path) if File.file?(path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def sanitize(cache_key)
|
|
39
|
+
cache_key.to_s.gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flycal
|
|
4
|
+
module Cli
|
|
5
|
+
# CLI-facing locale facade: prefers ~/.flycal config locale, delegates to Flycal::Locale.
|
|
6
|
+
module Locale
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def t(key, vars = {})
|
|
10
|
+
ensure_default_provider!
|
|
11
|
+
Flycal::Locale.t(key, vars)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def day_name(date_or_time)
|
|
15
|
+
ensure_default_provider!
|
|
16
|
+
Flycal::Locale.day_name(date_or_time)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def day_abbr(date_or_time)
|
|
20
|
+
ensure_default_provider!
|
|
21
|
+
Flycal::Locale.day_abbr(date_or_time)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def month_name(date_or_time)
|
|
25
|
+
ensure_default_provider!
|
|
26
|
+
Flycal::Locale.month_name(date_or_time)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_long_date(date_or_time)
|
|
30
|
+
ensure_default_provider!
|
|
31
|
+
Flycal::Locale.format_long_date(date_or_time)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def current_locale
|
|
35
|
+
ensure_default_provider!
|
|
36
|
+
Flycal::Locale.current_locale
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def override!(locale)
|
|
40
|
+
ensure_default_provider!
|
|
41
|
+
Flycal::Locale.override!(locale)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ensure_default_provider!
|
|
45
|
+
return if Thread.current[:flycal_locale_default_provider]
|
|
46
|
+
|
|
47
|
+
Flycal::Locale.default_locale_provider = lambda {
|
|
48
|
+
begin
|
|
49
|
+
Config.locale
|
|
50
|
+
rescue StandardError
|
|
51
|
+
Flycal::Locale::FALLBACK_LOCALE
|
|
52
|
+
end
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
private_class_method :ensure_default_provider!
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/flycal/cli.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "flycal"
|
|
4
|
+
require "flycal/cli/config"
|
|
5
|
+
require "flycal/cli/file_cache"
|
|
6
|
+
require "flycal/cli/locale"
|
|
7
|
+
require "flycal/cli/auth"
|
|
8
|
+
require "flycal/cli/clipboard"
|
|
9
|
+
require "flycal/cli/app"
|
|
10
|
+
|
|
11
|
+
module Flycal
|
|
12
|
+
module Cli
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
5
|
+
|
|
6
|
+
module Flycal
|
|
7
|
+
# Entry point for Flycal core. Auth credentials are injected by the caller
|
|
8
|
+
# (FlycalApp / Flycal::Cli), never obtained inside core.
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :credentials, :calendar
|
|
11
|
+
|
|
12
|
+
def initialize(credentials:)
|
|
13
|
+
raise ArgumentError, "credentials are required" if credentials.nil?
|
|
14
|
+
|
|
15
|
+
@credentials = Credentials.wrap(credentials)
|
|
16
|
+
@calendar = CalendarService.new(@credentials)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def list_calendars
|
|
20
|
+
calendar.list_calendars
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def list_events(calendar_id, time_min:, time_max:, query: nil)
|
|
24
|
+
calendar.list_events(calendar_id, time_min: time_min, time_max: time_max, query: query)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def list_all_events(calendar_ids, time_min:, time_max:, query: nil)
|
|
28
|
+
calendar.list_all_events(calendar_ids, time_min: time_min, time_max: time_max, query: query)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create_event(calendar_id: "primary", summary:, start_time:, end_time:, all_day: false)
|
|
32
|
+
calendar.create_event(
|
|
33
|
+
calendar_id: calendar_id,
|
|
34
|
+
summary: summary,
|
|
35
|
+
start_time: start_time,
|
|
36
|
+
end_time: end_time,
|
|
37
|
+
all_day: all_day
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def update_event(calendar_id: "primary", event_id:, summary:, start_time:, end_time:, all_day: false)
|
|
42
|
+
calendar.update_event(
|
|
43
|
+
calendar_id: calendar_id,
|
|
44
|
+
event_id: event_id,
|
|
45
|
+
summary: summary,
|
|
46
|
+
start_time: start_time,
|
|
47
|
+
end_time: end_time,
|
|
48
|
+
all_day: all_day
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns Rails/web-friendly event hashes (skips cancelled events).
|
|
53
|
+
def fetch_events(calendar_id:, time_min:, time_max:)
|
|
54
|
+
list_events(calendar_id, time_min: time_min, time_max: time_max)
|
|
55
|
+
.reject { |event| event.status == "cancelled" }
|
|
56
|
+
.filter_map { |event| EventMapper.to_h(event) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def search(params = {})
|
|
60
|
+
bag = params.is_a?(Pipeline::Params) ? params : Pipeline::Params.new(params)
|
|
61
|
+
bag[:format] ||= "json"
|
|
62
|
+
bag[:locale] ||= Locale.current_locale
|
|
63
|
+
Pipeline::SearchPipeline.new(calendar).run(bag)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Resolved slots inputs (defaults applied) suitable for CacheKey.generate.
|
|
67
|
+
# Does not call Google APIs.
|
|
68
|
+
def resolve_slots_query(params = {})
|
|
69
|
+
p = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params
|
|
70
|
+
calendar_ids = Array(p[:calendar_ids]).map(&:to_s).reject(&:empty?)
|
|
71
|
+
raise Flycal::Error, "calendar_ids are required" if calendar_ids.empty?
|
|
72
|
+
|
|
73
|
+
duration_value = p[:duration].presence || "45min"
|
|
74
|
+
in_value = p[:in].presence || "1 week"
|
|
75
|
+
from_value = p[:from].presence || "now"
|
|
76
|
+
free_before_value = p[:free_before].presence || "0m"
|
|
77
|
+
free_after_value = p[:free_after].presence || "0m"
|
|
78
|
+
template_name = p[:template].presence || "work"
|
|
79
|
+
locale = p[:locale].presence || Locale.current_locale
|
|
80
|
+
|
|
81
|
+
slot_duration = DurationParser.to_seconds(duration_value)
|
|
82
|
+
free_before = DurationParser.to_seconds(free_before_value)
|
|
83
|
+
free_after = DurationParser.to_seconds(free_after_value)
|
|
84
|
+
time_min = DateTimeParser.parse(from_value.to_s, locale: locale)
|
|
85
|
+
time_max = DurationParser.add_to_time(in_value, time_min)
|
|
86
|
+
raise Flycal::Error, "'from' must be before end of window" if time_min >= time_max
|
|
87
|
+
|
|
88
|
+
hours = normalize_hours(p[:hours]) || SlotFinder::DEFAULT_HOURS
|
|
89
|
+
days = normalize_days(p[:days]) || SlotFinder::DEFAULT_DAYS
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
command: "slots",
|
|
93
|
+
calendar_ids: calendar_ids,
|
|
94
|
+
duration: duration_value,
|
|
95
|
+
from: from_value,
|
|
96
|
+
in: in_value,
|
|
97
|
+
free_before: free_before_value,
|
|
98
|
+
free_after: free_after_value,
|
|
99
|
+
template: template_name,
|
|
100
|
+
locale: locale,
|
|
101
|
+
calendar: p[:calendar],
|
|
102
|
+
hours: hours,
|
|
103
|
+
days: days,
|
|
104
|
+
time_min: time_min,
|
|
105
|
+
time_max: time_max,
|
|
106
|
+
slot_duration_seconds: slot_duration,
|
|
107
|
+
free_before_seconds: free_before,
|
|
108
|
+
free_after_seconds: free_after,
|
|
109
|
+
format: p[:format].presence || "json"
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Find free slots. +params+ keys (string/symbol):
|
|
114
|
+
# calendar_ids (required), duration (e.g. "45min"), from, in,
|
|
115
|
+
# free_before, free_after, hours ([[9,0,18,0]]), days ([1..5]),
|
|
116
|
+
# template (label), locale, calendar (label for meta)
|
|
117
|
+
# Returns a Hash suitable for JSON rendering.
|
|
118
|
+
def slots(params = {})
|
|
119
|
+
p = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params
|
|
120
|
+
q = resolve_slots_query(p)
|
|
121
|
+
|
|
122
|
+
calendars = list_calendars
|
|
123
|
+
calendar_meta = q[:calendar_ids].filter_map do |id|
|
|
124
|
+
cal = calendars.find { |c| c.id == id }
|
|
125
|
+
{ id: id, name: cal&.summary || id }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
fetch_from = q[:time_min] - q[:free_before_seconds]
|
|
129
|
+
events = q[:calendar_ids].flat_map do |calendar_id|
|
|
130
|
+
list_events(calendar_id, time_min: fetch_from, time_max: q[:time_max])
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
warn "Flycal slots: skip calendar #{calendar_id}: #{e.message}"
|
|
133
|
+
[]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
finder = SlotFinder.new(
|
|
137
|
+
events: events,
|
|
138
|
+
time_min: q[:time_min],
|
|
139
|
+
time_max: q[:time_max],
|
|
140
|
+
slot_duration_seconds: q[:slot_duration_seconds],
|
|
141
|
+
hours: q[:hours],
|
|
142
|
+
days: q[:days],
|
|
143
|
+
free_before_seconds: q[:free_before_seconds],
|
|
144
|
+
free_after_seconds: q[:free_after_seconds]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
SlotFormatter.payload_hash(
|
|
148
|
+
slots_by_day: finder.slots_by_day,
|
|
149
|
+
time_min: q[:time_min],
|
|
150
|
+
time_max: q[:time_max],
|
|
151
|
+
duration: q[:duration],
|
|
152
|
+
template: q[:template],
|
|
153
|
+
calendars: calendar_meta,
|
|
154
|
+
locale: q[:locale],
|
|
155
|
+
calendar_option: q[:calendar],
|
|
156
|
+
from_option: p[:from],
|
|
157
|
+
in_option: p[:in],
|
|
158
|
+
free_before: q[:free_before],
|
|
159
|
+
free_after: q[:free_after],
|
|
160
|
+
empty_message: Locale.t("slots.no_available")
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def normalize_hours(value)
|
|
167
|
+
return nil if value.blank?
|
|
168
|
+
return value if value.is_a?(Array) && value.first.is_a?(Array)
|
|
169
|
+
|
|
170
|
+
# "9:00-18:00" or ["9:00-18:00", "14:00-17:00"]
|
|
171
|
+
Array(value).map do |window|
|
|
172
|
+
start_s, end_s = window.to_s.split("-", 2).map(&:strip)
|
|
173
|
+
sh, sm = start_s.split(":").map(&:to_i)
|
|
174
|
+
eh, em = end_s.split(":").map(&:to_i)
|
|
175
|
+
[ sh, sm, eh, em ]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def normalize_days(value)
|
|
180
|
+
return nil if value.blank?
|
|
181
|
+
return value.map(&:to_i) if value.is_a?(Array)
|
|
182
|
+
|
|
183
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?).map(&:to_i)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "googleauth"
|
|
4
|
+
|
|
5
|
+
module Flycal
|
|
6
|
+
# Builds Google auth credentials from externally supplied tokens
|
|
7
|
+
# (Rails OAuth session, CLI token store, etc.).
|
|
8
|
+
class Credentials
|
|
9
|
+
DEFAULT_SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
|
|
10
|
+
|
|
11
|
+
def self.from_tokens(
|
|
12
|
+
access_token:,
|
|
13
|
+
refresh_token: nil,
|
|
14
|
+
client_id:,
|
|
15
|
+
client_secret:,
|
|
16
|
+
expires_at: nil,
|
|
17
|
+
scope: DEFAULT_SCOPE
|
|
18
|
+
)
|
|
19
|
+
opts = {
|
|
20
|
+
client_id: client_id,
|
|
21
|
+
client_secret: client_secret,
|
|
22
|
+
scope: scope,
|
|
23
|
+
access_token: access_token,
|
|
24
|
+
refresh_token: refresh_token
|
|
25
|
+
}
|
|
26
|
+
opts[:expires_at] = expires_at.to_i if expires_at
|
|
27
|
+
Google::Auth::UserRefreshCredentials.new(**opts)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.wrap(credentials)
|
|
31
|
+
credentials
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -5,7 +5,7 @@ require "time"
|
|
|
5
5
|
require "active_support/core_ext/time"
|
|
6
6
|
require "active_support/core_ext/date"
|
|
7
7
|
|
|
8
|
-
module
|
|
8
|
+
module Flycal
|
|
9
9
|
# Parses CLI date/time arguments in a locale-aware way.
|
|
10
10
|
#
|
|
11
11
|
# Always accepts ISO-like forms:
|
|
@@ -39,7 +39,7 @@ module FlycalCli
|
|
|
39
39
|
class << self
|
|
40
40
|
def parse(str, end_of_day: false, locale: nil)
|
|
41
41
|
value = str.to_s.strip
|
|
42
|
-
raise
|
|
42
|
+
raise Flycal::Error, invalid_message(str) if value.empty?
|
|
43
43
|
|
|
44
44
|
relative = parse_relative(value, end_of_day: end_of_day)
|
|
45
45
|
return relative if relative
|
|
@@ -65,7 +65,7 @@ module FlycalCli
|
|
|
65
65
|
parsed = Time.parse(value)
|
|
66
66
|
return end_of_day && !time_component?(value) ? end_of_day_for(parsed.to_date) : parsed
|
|
67
67
|
rescue ArgumentError
|
|
68
|
-
raise
|
|
68
|
+
raise Flycal::Error, invalid_message(str)
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
71
|
|
|
@@ -194,7 +194,7 @@ module FlycalCli
|
|
|
194
194
|
|
|
195
195
|
Time.local(year, month, day, hour, min || 0, sec || 0)
|
|
196
196
|
rescue ArgumentError
|
|
197
|
-
raise
|
|
197
|
+
raise Flycal::Error, "Invalid date: #{year}-#{month}-#{day}"
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
def end_of_day_for(date)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flycal
|
|
4
|
+
# Shared helpers for --description filtering and --groupBy string patterns.
|
|
5
|
+
#
|
|
6
|
+
# OR / group terms are separated by "|" (also ",").
|
|
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
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "active_support/core_ext/numeric/time"
|
|
4
4
|
require "active_support/core_ext/integer/time"
|
|
5
5
|
|
|
6
|
-
module
|
|
6
|
+
module Flycal
|
|
7
7
|
class DurationParser
|
|
8
8
|
UNITS = {
|
|
9
9
|
"s" => :seconds, "sec" => :seconds, "second" => :seconds, "seconds" => :seconds,
|
|
@@ -20,12 +20,12 @@ module FlycalCli
|
|
|
20
20
|
normalized = str.to_s.strip.downcase
|
|
21
21
|
match = normalized.match(/\A(\d+(?:\.\d+)?)\s*([a-z]+)\z/) ||
|
|
22
22
|
normalized.match(/\A(\d+(?:\.\d+)?)([a-z]+)\z/)
|
|
23
|
-
raise
|
|
23
|
+
raise Flycal::Error, invalid_message(str) unless match
|
|
24
24
|
|
|
25
25
|
value = match[1].to_f
|
|
26
26
|
unit_key = match[2]
|
|
27
27
|
unit = UNITS[unit_key]
|
|
28
|
-
raise
|
|
28
|
+
raise Flycal::Error, invalid_message(str) unless unit
|
|
29
29
|
# Ambiguous bare "m": treat as minutes (not months)
|
|
30
30
|
unit = :minutes if unit_key == "m"
|
|
31
31
|
|
data/lib/flycal/error.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Flycal
|
|
6
|
+
# Normalizes Google Calendar API event objects into plain hashes.
|
|
7
|
+
module EventMapper
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def to_h(event)
|
|
11
|
+
start_time = extract_start(event)
|
|
12
|
+
end_time = extract_end(event, start_time)
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
id: event.id || SecureRandom.uuid,
|
|
16
|
+
summary: event.summary || "(No title)",
|
|
17
|
+
description: event.description,
|
|
18
|
+
location: event.location,
|
|
19
|
+
start_time: start_time,
|
|
20
|
+
end_time: end_time,
|
|
21
|
+
all_day: !event.start&.date.nil? && event.start&.date_time.nil?,
|
|
22
|
+
status: event.status || "confirmed",
|
|
23
|
+
html_link: event.html_link,
|
|
24
|
+
creator: event.creator&.email,
|
|
25
|
+
organizer: event.organizer&.email,
|
|
26
|
+
attendees: Array(event.attendees).map do |attendee|
|
|
27
|
+
{ email: attendee.email, response_status: attendee.response_status }
|
|
28
|
+
end
|
|
29
|
+
}
|
|
30
|
+
rescue StandardError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def extract_start(event)
|
|
35
|
+
if event.start&.date_time
|
|
36
|
+
event.start.date_time
|
|
37
|
+
elsif event.start&.date
|
|
38
|
+
Date.parse(event.start.date.to_s)
|
|
39
|
+
else
|
|
40
|
+
Time.now
|
|
41
|
+
end
|
|
42
|
+
rescue ArgumentError
|
|
43
|
+
Time.now
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def extract_end(event, start_time)
|
|
47
|
+
if event.end&.date_time
|
|
48
|
+
event.end.date_time
|
|
49
|
+
elsif event.end&.date
|
|
50
|
+
Date.parse(event.end.date.to_s)
|
|
51
|
+
else
|
|
52
|
+
start_time + 3600
|
|
53
|
+
end
|
|
54
|
+
rescue ArgumentError
|
|
55
|
+
start_time + 3600
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module Flycal
|
|
6
|
+
# Shared i18n helpers used by core formatters/pipelines.
|
|
7
|
+
# Locale JSON files live with the CLI package (flycal_cli/locales) by default.
|
|
6
8
|
module Locale
|
|
7
9
|
module_function
|
|
8
10
|
|
|
@@ -38,20 +40,50 @@ module FlycalCli
|
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
def current_locale
|
|
41
|
-
Thread.current[:flycal_locale_override] ||
|
|
43
|
+
Thread.current[:flycal_locale_override] || default_locale
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def default_locale
|
|
47
|
+
provider = Thread.current[:flycal_locale_default_provider]
|
|
48
|
+
value = provider.call if provider.respond_to?(:call)
|
|
49
|
+
value = value.to_s.strip if value
|
|
50
|
+
value && !value.empty? ? value : FALLBACK_LOCALE
|
|
42
51
|
rescue StandardError
|
|
43
52
|
FALLBACK_LOCALE
|
|
44
53
|
end
|
|
45
54
|
|
|
55
|
+
def default_locale_provider=(provider)
|
|
56
|
+
Thread.current[:flycal_locale_default_provider] = provider
|
|
57
|
+
end
|
|
58
|
+
|
|
46
59
|
def override!(locale)
|
|
47
60
|
Thread.current[:flycal_locale_override] = locale.to_s if locale && !locale.to_s.strip.empty?
|
|
48
61
|
end
|
|
49
62
|
|
|
63
|
+
def locales_path
|
|
64
|
+
Thread.current[:flycal_locales_path] || default_locales_path
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def locales_path=(path)
|
|
68
|
+
Thread.current[:flycal_locales_path] = path
|
|
69
|
+
@cache = nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def default_locales_path
|
|
73
|
+
# Monorepo: flycal_cli/locales; gem package: lib/../locales
|
|
74
|
+
candidates = [
|
|
75
|
+
File.expand_path("../../flycal_cli/locales", __dir__),
|
|
76
|
+
File.expand_path("../../../locales", __dir__),
|
|
77
|
+
File.expand_path("../../locales", __dir__)
|
|
78
|
+
]
|
|
79
|
+
candidates.find { |path| Dir.exist?(path) } || candidates.first
|
|
80
|
+
end
|
|
81
|
+
|
|
50
82
|
def translations(locale)
|
|
51
83
|
@cache ||= {}
|
|
52
84
|
loc = locale.to_s
|
|
53
85
|
@cache[loc] ||= begin
|
|
54
|
-
path = File.
|
|
86
|
+
path = File.join(locales_path, "#{loc}.json")
|
|
55
87
|
File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
56
88
|
end
|
|
57
89
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flycal
|
|
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
|