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
@@ -5,7 +5,8 @@ require "googleauth"
5
5
  require "googleauth/stores/file_token_store"
6
6
  require "fileutils"
7
7
 
8
- module FlycalCli
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 FlycalCli::Error,
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 FlycalCli::Error, "Authentication cancelled or failed" if code.nil? || code.empty?
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 FlycalCli
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 FlycalCli
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("../../config/defaults.yml", __dir__)
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 FlycalCli
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 FlycalCli::Error, invalid_message(str) if value.empty?
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 FlycalCli::Error, invalid_message(str)
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 FlycalCli::Error, "Invalid date: #{year}-#{month}-#{day}"
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 FlycalCli
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 FlycalCli::Error, invalid_message(str) unless match
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 FlycalCli::Error, invalid_message(str) unless unit
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
 
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flycal
4
+ class Error < StandardError; end
5
+ end
@@ -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 FlycalCli
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] || Config.locale
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.expand_path("../../locales/#{loc}.json", __dir__)
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