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
|
@@ -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)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
4
|
-
# Shared helpers for --description filtering and --groupBy
|
|
3
|
+
module Flycal
|
|
4
|
+
# Shared helpers for --description filtering and --groupBy string patterns.
|
|
5
5
|
#
|
|
6
|
-
# OR terms are separated by "|" (also ","
|
|
6
|
+
# OR / group terms are separated by "|" (also ",").
|
|
7
7
|
# Matching is case-insensitive against event summary and description.
|
|
8
8
|
module DescriptionQuery
|
|
9
9
|
module_function
|
|
@@ -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
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module Flycal
|
|
6
6
|
module Mock
|
|
7
7
|
# Resolves mock options from CLI and/or a JSON template under mocks/.
|
|
8
8
|
class Config
|
|
@@ -45,7 +45,7 @@ module FlycalCli
|
|
|
45
45
|
calendar = merged["mockCalendar"].to_s
|
|
46
46
|
calendar = options[:mockCalendar].to_s if calendar.empty?
|
|
47
47
|
@calendar_name = calendar
|
|
48
|
-
raise
|
|
48
|
+
raise Flycal::Error,
|
|
49
49
|
"Mock mode requires mockCalendar in the template (or --mockCalendar)." if @calendar_name.empty?
|
|
50
50
|
|
|
51
51
|
@raw = merged
|
|
@@ -84,14 +84,14 @@ module FlycalCli
|
|
|
84
84
|
return {} if name.to_s.empty?
|
|
85
85
|
|
|
86
86
|
path = find_template_path(name)
|
|
87
|
-
raise
|
|
87
|
+
raise Flycal::Error, "Mock template not found: #{name.inspect} (looked in ./mocks, ./mockTemplates, and gem paths)." unless path
|
|
88
88
|
|
|
89
89
|
data = JSON.parse(File.read(path))
|
|
90
|
-
raise
|
|
90
|
+
raise Flycal::Error, "Mock template #{name.inspect} must be a JSON object." unless data.is_a?(Hash)
|
|
91
91
|
|
|
92
92
|
data
|
|
93
93
|
rescue JSON::ParserError => e
|
|
94
|
-
raise
|
|
94
|
+
raise Flycal::Error, "Invalid mock template JSON (#{name}): #{e.message}"
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
def find_template_path(name)
|
|
@@ -108,13 +108,13 @@ module FlycalCli
|
|
|
108
108
|
|
|
109
109
|
def split_patterns(value)
|
|
110
110
|
list = value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
111
|
-
raise
|
|
111
|
+
raise Flycal::Error, "mockEventDescriptionPatterns must list at least one pattern." if list.empty?
|
|
112
112
|
|
|
113
113
|
list
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
def parse_date(value, end_of_day:)
|
|
117
|
-
raise
|
|
117
|
+
raise Flycal::Error, "Missing mock date." if value.to_s.empty?
|
|
118
118
|
|
|
119
119
|
DateTimeParser.parse(value.to_s, end_of_day: end_of_day)
|
|
120
120
|
end
|
|
@@ -122,12 +122,12 @@ module FlycalCli
|
|
|
122
122
|
def parse_hour_minute(value)
|
|
123
123
|
str = value.to_s.strip
|
|
124
124
|
match = str.match(/\A(\d{1,2}):(\d{2})\z/)
|
|
125
|
-
raise
|
|
125
|
+
raise Flycal::Error, "Invalid mock hour #{value.inspect}. Use HH:MM (e.g. 09:00)." unless match
|
|
126
126
|
|
|
127
127
|
hour = match[1].to_i
|
|
128
128
|
min = match[2].to_i
|
|
129
129
|
unless hour.between?(0, 23) && min.between?(0, 59)
|
|
130
|
-
raise
|
|
130
|
+
raise Flycal::Error, "Invalid mock hour #{value.inspect}. Use HH:MM (e.g. 09:00)."
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
[hour, min]
|
|
@@ -147,22 +147,22 @@ module FlycalCli
|
|
|
147
147
|
missing << "mockEventTo" if range_to.nil?
|
|
148
148
|
missing << "mockEventDurationMin" if duration_min_seconds <= 0
|
|
149
149
|
missing << "mockEventDurationMax" if duration_max_seconds <= 0
|
|
150
|
-
raise
|
|
150
|
+
raise Flycal::Error, "Missing mock parameters: #{missing.join(", ")}" unless missing.empty?
|
|
151
151
|
|
|
152
152
|
if duration_min_seconds > duration_max_seconds
|
|
153
|
-
raise
|
|
153
|
+
raise Flycal::Error, "mockEventDurationMin must be <= mockEventDurationMax."
|
|
154
154
|
end
|
|
155
155
|
if range_from > range_to
|
|
156
|
-
raise
|
|
156
|
+
raise Flycal::Error, "mockEventFrom must be before mockEventTo."
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
from_mins = hours_from[0] * 60 + hours_from[1]
|
|
160
160
|
to_mins = hours_to[0] * 60 + hours_to[1]
|
|
161
161
|
if from_mins >= to_mins
|
|
162
|
-
raise
|
|
162
|
+
raise Flycal::Error, "mockEventHoursFrom must be before mockEventHoursTo."
|
|
163
163
|
end
|
|
164
164
|
if duration_min_seconds > (to_mins - from_mins) * 60
|
|
165
|
-
raise
|
|
165
|
+
raise Flycal::Error,
|
|
166
166
|
"mockEventDurationMin does not fit in mockEventHoursFrom..mockEventHoursTo window."
|
|
167
167
|
end
|
|
168
168
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Flycal
|
|
4
4
|
module Mock
|
|
5
5
|
# Builds deterministic fake calendar events from a Mock::Config.
|
|
6
6
|
class EventGenerator
|
|
@@ -60,7 +60,7 @@ module FlycalCli
|
|
|
60
60
|
window_end = day.to_time + (to_h * 3600) + (to_m * 60)
|
|
61
61
|
latest_start = window_end - duration_seconds
|
|
62
62
|
if latest_start < window_start
|
|
63
|
-
raise
|
|
63
|
+
raise Flycal::Error, "Mock event duration does not fit in the daily hours window."
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
span = (latest_start - window_start).to_i
|