r_cal 0.2.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3197ee3a981b8c987ffc5aaf6e2676a91e602359562e7143ed9158d712e43711
4
- data.tar.gz: 26d484aa6eb13877e489215f353c6f035d208e054201643ca346aa19ba8c74fd
3
+ metadata.gz: 4d4cff3f1a013f82eff5da8efdac73b13a54a635114548761e5cfc1e2fb7a640
4
+ data.tar.gz: ce3a5dc855b2cb920d9b0c1049d90e4f816ea6dc9e2ba38e2641a401d661fee3
5
5
  SHA512:
6
- metadata.gz: 507cdd4df0c1ac7c015632e230a34230bc3925c824e4398b25127ba8077f30595e842318d6d5dbafe4ed2f1090c68ad4eeea7f262162d54ee25592295da3da56
7
- data.tar.gz: 36b299e87193a9fae11871056449ff89d552e8cbec669ebce391cd133591473300ff0d8968b9ceae09555bcc4fca2ef87ff5b4c0ff6e67ffbe98a105f1bc5d42
6
+ metadata.gz: 34284778d797f4ab14c4d61d11bb165b92e79a1afec75e8d1a8ce31ce65d4312901d252b18e1a014e8f92ba603c30947d00b062df5ba14196e01c40e1d10c43c
7
+ data.tar.gz: 41a75ef2f8135fe1d7294d4dfb056eb09d89e316739525ea19f744e79b7db6282b316fe88c43abaeaa1c0aaef137cb9e8d82e6bd67e420e564bc6fb4d278a039
data/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.2.1] - 2026-02-26
6
+
7
+ ### Fixed
8
+
9
+ - Improved auth flow: replaced deprecated OOB OAuth with loopback (with headless fallback), unified token storage, fixed auto-refresh for stored tokens, and improved error handling ([#12])
10
+
11
+ ## [0.2.0] - 2026-02-26
12
+
13
+ ### Added
14
+
15
+ - Ability to specify or change an event's color when adding or editing ([#4])
16
+ - Recurring event support ([#5])
17
+ - Ability to mark events as "free" for scheduling/planning purposes ([#6])
18
+ - Basic CI workflow
19
+
20
+ ### Fixed
21
+
22
+ - Time parsing now uses the user's local timezone instead of system timezone (UTC), which caused events to appear hours off for non-UTC users ([#1])
23
+
24
+ ## [0.1.1] - 2026-02-26
25
+
26
+ ### Fixed
27
+
28
+ - `--days` flag and date range arguments causing "no implicit conversion of Date into String" error ([#3])
29
+
30
+ ## [0.1.0] - 2026-02-06
31
+
32
+ ### Added
33
+
34
+ - Initial release
35
+ - Google Calendar CLI with natural language date parsing (via Chronic)
36
+ - Event management: create (`add`, `quick`), edit, and view (`agenda`) events
37
+ - ICS file import with calendar selection
38
+ - Predicate filtering (`--must-be`, `--must-not-be`) for recurring, declined, all-day, one-on-one, and more
39
+ - Multiple calendar support
40
+ - Duration parsing (`30m`, `1h30m`, `1.5h`)
41
+ - Calendar listing
42
+ - OAuth 2.0 authentication via `rcal init`
43
+ - XDG Base Directory compliant configuration
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- r_cal (0.1.1)
4
+ r_cal (0.2.0)
5
5
  chronic (~> 0.10)
6
6
  chronic_duration (~> 0.10)
7
7
  cli-kit (~> 5.2.0)
@@ -210,7 +210,7 @@ CHECKSUMS
210
210
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
211
211
  pstore (0.2.0) sha256=d6e5c7e8e22392235e88bbe82959059ba768a797b5bd0ebf5ac80a3311ce74a8
212
212
  public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857
213
- r_cal (0.1.1)
213
+ r_cal (0.2.0)
214
214
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
215
215
  rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
216
216
  rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
@@ -10,6 +10,10 @@ module Rcal
10
10
  raise NotImplementedError, "#{self.class} must implement #load_credentials"
11
11
  end
12
12
 
13
+ def load_client_credentials
14
+ raise NotImplementedError, "#{self.class} must implement #load_client_credentials"
15
+ end
16
+
13
17
  def authenticated?
14
18
  raise NotImplementedError, "#{self.class} must implement #authenticated?"
15
19
  end
@@ -21,6 +25,14 @@ module Rcal
21
25
  def clear_credentials
22
26
  raise NotImplementedError, "#{self.class} must implement #clear_credentials"
23
27
  end
28
+
29
+ def token_path
30
+ raise NotImplementedError, "#{self.class} must implement #token_path"
31
+ end
32
+
33
+ def client_credentials_path
34
+ raise NotImplementedError, "#{self.class} must implement #client_credentials_path"
35
+ end
24
36
  end
25
37
  end
26
38
  end
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "yaml"
2
3
  require "fileutils"
3
4
  require_relative "base"
4
5
 
@@ -6,33 +7,43 @@ module Rcal
6
7
  module Adapters
7
8
  module Auth
8
9
  class Google < Base
9
- def self.default_token_path
10
- base = ENV.fetch("XDG_DATA_HOME") { File.join(ENV.fetch("HOME"), ".local", "share") }
11
- File.join(base, "rcal", "tokens.json")
12
- end
10
+ SCOPES = [
11
+ "https://www.googleapis.com/auth/calendar.readonly",
12
+ "https://www.googleapis.com/auth/calendar.events"
13
+ ].freeze
14
+
15
+ TOKEN_FILENAME = "google_tokens.yaml"
16
+ CLIENT_CREDENTIALS_FILENAME = "client_credentials.json"
17
+ LEGACY_TOKEN_FILENAME = "tokens.json"
13
18
 
14
- def initialize(token_path: self.class.default_token_path)
15
- @token_path = token_path
19
+ def initialize(data_dir:)
20
+ @data_dir = data_dir
16
21
  end
17
22
 
18
23
  def store_credentials(credentials)
19
24
  ensure_directory_exists
20
25
 
21
26
  token_data = {
27
+ "client_id" => credentials.client_id,
22
28
  "access_token" => credentials.access_token,
23
29
  "refresh_token" => credentials.refresh_token,
24
- "expires_at" => credentials.expires_at.to_i
30
+ "scope" => Array(credentials.scope),
31
+ "expiration_time_millis" => (credentials.expires_at.to_i * 1000)
25
32
  }
26
33
 
27
- File.write(@token_path, JSON.generate(token_data))
28
- File.chmod(0o600, @token_path)
34
+ yaml_data = {"default" => JSON.generate(token_data)}
35
+ File.write(token_path, YAML.dump(yaml_data))
36
+ File.chmod(0o600, token_path)
29
37
  end
30
38
 
31
39
  def load_credentials
32
- return nil unless File.exist?(@token_path)
40
+ return nil unless File.exist?(token_path)
33
41
 
34
- JSON.parse(File.read(@token_path))
35
- rescue JSON::ParserError
42
+ yaml_data = YAML.safe_load_file(token_path)
43
+ return nil unless yaml_data&.key?("default")
44
+
45
+ JSON.parse(yaml_data["default"])
46
+ rescue JSON::ParserError, Psych::SyntaxError
36
47
  nil
37
48
  end
38
49
 
@@ -40,6 +51,9 @@ module Rcal
40
51
  creds = load_credentials
41
52
  return false if creds.nil?
42
53
 
54
+ client_creds = load_client_credentials
55
+ return false if client_creds.nil?
56
+
43
57
  # Need at least a refresh token to be considered authenticated
44
58
  # (we can refresh expired access tokens)
45
59
  !creds["refresh_token"].nil? && !creds["refresh_token"].empty?
@@ -49,21 +63,43 @@ module Rcal
49
63
  creds = load_credentials
50
64
  return true if creds.nil?
51
65
 
52
- expires_at = creds["expires_at"]
53
- return true if expires_at.nil?
66
+ expiration = creds["expiration_time_millis"]
67
+ return true if expiration.nil?
54
68
 
55
- Time.at(expires_at) <= Time.now
69
+ Time.at(expiration / 1000) <= Time.now
56
70
  end
57
71
 
58
72
  def clear_credentials
59
- FileUtils.rm_f(@token_path)
73
+ FileUtils.rm_f(token_path)
74
+ # Clean up legacy token file if it exists from a previous version
75
+ FileUtils.rm_f(legacy_token_path)
76
+ end
77
+
78
+ def load_client_credentials
79
+ creds_file = client_credentials_path
80
+ return nil unless File.exist?(creds_file)
81
+
82
+ JSON.parse(File.read(creds_file))
83
+ rescue JSON::ParserError
84
+ nil
85
+ end
86
+
87
+ def token_path
88
+ File.join(@data_dir, TOKEN_FILENAME)
89
+ end
90
+
91
+ def client_credentials_path
92
+ File.join(@data_dir, CLIENT_CREDENTIALS_FILENAME)
60
93
  end
61
94
 
62
95
  private
63
96
 
97
+ def legacy_token_path
98
+ File.join(@data_dir, LEGACY_TOKEN_FILENAME)
99
+ end
100
+
64
101
  def ensure_directory_exists
65
- dir = File.dirname(@token_path)
66
- FileUtils.mkdir_p(dir) unless File.directory?(dir)
102
+ FileUtils.mkdir_p(@data_dir) unless File.directory?(@data_dir)
67
103
  end
68
104
  end
69
105
  end
@@ -1,5 +1,6 @@
1
1
  require "time"
2
2
  require "google/apis/calendar_v3"
3
+ require "signet"
3
4
  require_relative "base"
4
5
  require_relative "../../models/calendar"
5
6
  require_relative "../../models/event"
@@ -8,65 +9,89 @@ module Rcal
8
9
  module Adapters
9
10
  module Calendar
10
11
  class Google < Base
12
+ AUTH_ERROR_MESSAGE = "Authentication expired or revoked. Run 'rcal init' to re-authenticate."
13
+
11
14
  def initialize(service:)
12
15
  @service = service
13
16
  end
14
17
 
15
18
  def list_calendars
16
- response = @service.list_calendar_lists
17
- items = response.items || []
19
+ with_auth_handling do
20
+ response = @service.list_calendar_lists
21
+ items = response.items || []
18
22
 
19
- items.map { |cal| build_calendar(cal) }
23
+ items.map { |cal| build_calendar(cal) }
24
+ end
20
25
  end
21
26
 
22
27
  def get_calendar(calendar_id:)
23
- google_calendar = @service.get_calendar(calendar_id)
24
- build_calendar(google_calendar)
28
+ with_auth_handling do
29
+ google_calendar = @service.get_calendar(calendar_id)
30
+ build_calendar(google_calendar)
31
+ end
25
32
  end
26
33
 
27
34
  def list_events(calendar_id:, time_min:, time_max:)
28
- response = @service.list_events(
29
- calendar_id,
30
- time_min: time_min.iso8601,
31
- time_max: time_max.iso8601,
32
- single_events: true,
33
- order_by: "startTime"
34
- )
35
+ with_auth_handling do
36
+ response = @service.list_events(
37
+ calendar_id,
38
+ time_min: time_min.iso8601,
39
+ time_max: time_max.iso8601,
40
+ single_events: true,
41
+ order_by: "startTime"
42
+ )
35
43
 
36
- items = response.items || []
44
+ items = response.items || []
37
45
 
38
- items.map { |evt| build_event(evt, calendar_id: calendar_id) }
46
+ items.map { |evt| build_event(evt, calendar_id: calendar_id) }
47
+ end
39
48
  end
40
49
 
41
50
  def get_event(calendar_id:, event_id:)
42
- google_event = @service.get_event(calendar_id, event_id)
43
- build_event(google_event, calendar_id: calendar_id)
51
+ with_auth_handling do
52
+ google_event = @service.get_event(calendar_id, event_id)
53
+ build_event(google_event, calendar_id: calendar_id)
54
+ end
44
55
  end
45
56
 
46
57
  def create_event(calendar_id:, event:)
47
- google_event = build_google_event(event)
48
- response = @service.insert_event(calendar_id, google_event)
49
- build_event(response, calendar_id: calendar_id)
58
+ with_auth_handling do
59
+ google_event = build_google_event(event)
60
+ response = @service.insert_event(calendar_id, google_event)
61
+ build_event(response, calendar_id: calendar_id)
62
+ end
50
63
  end
51
64
 
52
65
  def update_event(calendar_id:, event_id:, event:)
53
- google_event = build_google_event(event)
54
- response = @service.update_event(calendar_id, event_id, google_event)
55
- build_event(response, calendar_id: calendar_id)
66
+ with_auth_handling do
67
+ google_event = build_google_event(event)
68
+ response = @service.update_event(calendar_id, event_id, google_event)
69
+ build_event(response, calendar_id: calendar_id)
70
+ end
56
71
  end
57
72
 
58
73
  def delete_event(calendar_id:, event_id:)
59
- @service.delete_event(calendar_id, event_id)
60
- true
74
+ with_auth_handling do
75
+ @service.delete_event(calendar_id, event_id)
76
+ true
77
+ end
61
78
  end
62
79
 
63
80
  def quick_add(calendar_id:, text:)
64
- response = @service.quick_add_event(calendar_id, text)
65
- build_event(response, calendar_id: calendar_id)
81
+ with_auth_handling do
82
+ response = @service.quick_add_event(calendar_id, text)
83
+ build_event(response, calendar_id: calendar_id)
84
+ end
66
85
  end
67
86
 
68
87
  private
69
88
 
89
+ def with_auth_handling
90
+ yield
91
+ rescue ::Google::Apis::AuthorizationError, Signet::AuthorizationError
92
+ raise CLI::Kit::Abort, AUTH_ERROR_MESSAGE
93
+ end
94
+
70
95
  def build_calendar(google_calendar)
71
96
  Rcal::Calendar.new(
72
97
  id: google_calendar.id,
data/lib/rcal/auth.rb CHANGED
@@ -24,6 +24,18 @@ module Rcal
24
24
  adapter.clear_credentials
25
25
  end
26
26
 
27
+ def load_client_credentials
28
+ adapter.load_client_credentials
29
+ end
30
+
31
+ def token_path
32
+ adapter.token_path
33
+ end
34
+
35
+ def client_credentials_path
36
+ adapter.client_credentials_path
37
+ end
38
+
27
39
  def adapter
28
40
  @adapter ||= default_adapter
29
41
  end
@@ -38,7 +50,7 @@ module Rcal
38
50
 
39
51
  def default_adapter
40
52
  Adapters::Auth::Google.new(
41
- token_path: File.join(Configuration.data_dir, "tokens.json")
53
+ data_dir: Configuration.data_dir
42
54
  )
43
55
  end
44
56
  end
@@ -2,6 +2,7 @@ require "json"
2
2
  require "yaml"
3
3
  require "google/apis/calendar_v3"
4
4
  require "googleauth"
5
+ require "googleauth/stores/file_token_store"
5
6
  require_relative "adapters/calendar/google"
6
7
  require_relative "auth"
7
8
  require_relative "config"
@@ -67,40 +68,29 @@ module Rcal
67
68
  service
68
69
  end
69
70
 
71
+ # Load credentials via UserAuthorizer so refreshed tokens are
72
+ # automatically persisted back to google_tokens.yaml via the
73
+ # on_refresh callback (see UserAuthorizer#monitor_credentials).
70
74
  def load_credentials
71
- client_creds = load_client_credentials
72
- token_data = load_google_token_data
75
+ client_creds = Auth.load_client_credentials
76
+ return nil if client_creds.nil?
73
77
 
74
- return nil if client_creds.nil? || token_data.nil?
75
-
76
- Google::Auth::UserRefreshCredentials.new(
77
- client_id: client_creds["client_id"],
78
- client_secret: client_creds["client_secret"],
79
- access_token: token_data["access_token"],
80
- refresh_token: token_data["refresh_token"],
81
- expires_at: token_data["expiration_time_millis"] ? Time.at(token_data["expiration_time_millis"] / 1000) : nil
78
+ token_store = Google::Auth::Stores::FileTokenStore.new(
79
+ file: Auth.token_path
82
80
  )
83
- end
84
81
 
85
- def load_client_credentials
86
- creds_file = File.join(Configuration.data_dir, "client_credentials.json")
87
- return nil unless File.exist?(creds_file)
88
-
89
- JSON.parse(File.read(creds_file))
90
- rescue JSON::ParserError
91
- nil
92
- end
93
-
94
- def load_google_token_data
95
- token_file = File.join(Configuration.data_dir, "google_tokens.yaml")
96
- return nil unless File.exist?(token_file)
82
+ client_id = Google::Auth::ClientId.new(
83
+ client_creds["client_id"],
84
+ client_creds["client_secret"]
85
+ )
97
86
 
98
- yaml_data = YAML.safe_load_file(token_file)
99
- return nil unless yaml_data&.key?("default")
87
+ authorizer = Google::Auth::UserAuthorizer.new(
88
+ client_id,
89
+ Adapters::Auth::Google::SCOPES,
90
+ token_store
91
+ )
100
92
 
101
- JSON.parse(yaml_data["default"])
102
- rescue JSON::ParserError, Psych::SyntaxError
103
- nil
93
+ authorizer.get_credentials("default")
104
94
  end
105
95
  end
106
96
  end
@@ -1,18 +1,17 @@
1
1
  require "json"
2
+ require "socket"
3
+ require "uri"
2
4
  require "rcal"
3
5
  require "googleauth"
4
6
  require "googleauth/stores/file_token_store"
5
7
  require_relative "../auth"
8
+ require_relative "../adapters/auth/google"
6
9
 
7
10
  module Rcal
8
11
  module Commands
9
12
  class Init < Rcal::Command
10
- SCOPES = [
11
- "https://www.googleapis.com/auth/calendar.readonly",
12
- "https://www.googleapis.com/auth/calendar.events"
13
- ].freeze
14
-
15
- OOB_URI = "urn:ietf:wg:oauth:2.0:oob".freeze
13
+ CALLBACK_PATH = "/oauth2callback"
14
+ LISTEN_ADDRESS = "127.0.0.1"
16
15
 
17
16
  def self.help
18
17
  <<~HELP
@@ -104,51 +103,178 @@ module Rcal
104
103
  client_id_obj = Google::Auth::ClientId.new(client_id, client_secret)
105
104
 
106
105
  token_store = Google::Auth::Stores::FileTokenStore.new(
107
- file: File.join(Rcal::Configuration.data_dir, "google_tokens.yaml")
106
+ file: Auth.token_path
108
107
  )
109
108
 
109
+ redirect_uri = start_loopback_server
110
+
110
111
  authorizer = Google::Auth::UserAuthorizer.new(
111
112
  client_id_obj,
112
- SCOPES,
113
- token_store
113
+ Adapters::Auth::Google::SCOPES,
114
+ token_store,
115
+ callback_uri: redirect_uri
114
116
  )
115
117
 
116
118
  # Check if we already have credentials
117
119
  credentials = authorizer.get_credentials("default")
118
- return authorizer if credentials
120
+ if credentials
121
+ stop_loopback_server
122
+ return authorizer
123
+ end
119
124
 
120
125
  # Need to perform OAuth flow
121
- url = authorizer.get_authorization_url(base_url: OOB_URI)
126
+ url = authorizer.get_authorization_url(base_url: redirect_uri)
122
127
 
123
128
  puts CLI::UI.fmt("{{bold:Open this URL in your browser to authorize rcal:}}")
124
129
  puts ""
125
130
  puts url
126
131
  puts ""
127
132
 
128
- # Open browser automatically if possible
129
- open_browser(url)
133
+ code = obtain_authorization_code(url)
130
134
 
131
- puts CLI::UI.fmt("{{bold:Enter the authorization code:}}")
132
- code = CLI::UI.ask("")
135
+ if code.nil? || code.empty?
136
+ stop_loopback_server
137
+ raise CLI::Kit::Abort, "No authorization code received."
138
+ end
133
139
 
134
140
  authorizer.get_and_store_credentials_from_code(
135
141
  user_id: "default",
136
142
  code: code,
137
- base_url: OOB_URI
143
+ base_url: redirect_uri
138
144
  )
139
145
 
140
146
  authorizer
147
+ ensure
148
+ stop_loopback_server
149
+ end
150
+
151
+ def start_loopback_server
152
+ @server = TCPServer.new(LISTEN_ADDRESS, 0)
153
+ port = @server.addr[1]
154
+ "http://#{LISTEN_ADDRESS}:#{port}"
155
+ end
156
+
157
+ def stop_loopback_server
158
+ @server&.close
159
+ @server = nil
160
+ rescue IOError
161
+ @server = nil
162
+ end
163
+
164
+ def obtain_authorization_code(url)
165
+ browser_opened = open_browser(url)
166
+
167
+ if browser_opened
168
+ obtain_code_via_loopback
169
+ else
170
+ obtain_code_via_manual_entry
171
+ end
172
+ end
173
+
174
+ # Wait for Google to redirect back to our loopback server and
175
+ # extract the authorization code from the request.
176
+ def obtain_code_via_loopback
177
+ puts CLI::UI.fmt("{{info:Waiting for authorization in your browser...}}")
178
+
179
+ client = @server.accept
180
+ request_line = client.gets
181
+
182
+ # Parse the code from the GET request
183
+ code = extract_code_from_request(request_line)
184
+
185
+ if code
186
+ client.print "HTTP/1.1 200 OK\r\n"
187
+ client.print "Content-Type: text/html\r\n"
188
+ client.print "\r\n"
189
+ client.print success_html
190
+ else
191
+ error = extract_error_from_request(request_line)
192
+ client.print "HTTP/1.1 200 OK\r\n"
193
+ client.print "Content-Type: text/html\r\n"
194
+ client.print "\r\n"
195
+ client.print failure_html(error)
196
+ end
197
+
198
+ client.close
199
+ code
200
+ end
201
+
202
+ # For headless environments: ask the user to paste the redirect URL
203
+ # from their browser's address bar after authorizing.
204
+ def obtain_code_via_manual_entry
205
+ puts ""
206
+ puts CLI::UI.fmt("{{bold:After authorizing, your browser will redirect to a localhost URL.}}")
207
+ puts CLI::UI.fmt("{{bold:Copy the full URL from your browser's address bar and paste it here:}}")
208
+ response = CLI::UI.ask("")
209
+
210
+ # The user may paste a full URL or just the code
211
+ if response.include?("code=")
212
+ uri = URI.parse(response)
213
+ params = URI.decode_www_form(uri.query || "")
214
+ params.assoc("code")&.last
215
+ else
216
+ response.strip
217
+ end
218
+ rescue URI::InvalidURIError
219
+ # If the URL can't be parsed, try treating the whole thing as a code
220
+ response&.strip
221
+ end
222
+
223
+ def extract_code_from_request(request_line)
224
+ return nil unless request_line
225
+
226
+ path = request_line.split(" ")[1]
227
+ return nil unless path
228
+
229
+ uri = URI.parse("http://localhost#{path}")
230
+ params = URI.decode_www_form(uri.query || "")
231
+ params.assoc("code")&.last
232
+ rescue URI::InvalidURIError
233
+ nil
234
+ end
235
+
236
+ def extract_error_from_request(request_line)
237
+ return "Unknown error" unless request_line
238
+
239
+ path = request_line.split(" ")[1]
240
+ return "Unknown error" unless path
241
+
242
+ uri = URI.parse("http://localhost#{path}")
243
+ params = URI.decode_www_form(uri.query || "")
244
+ params.assoc("error")&.last || "Unknown error"
245
+ rescue URI::InvalidURIError
246
+ "Unknown error"
247
+ end
248
+
249
+ def success_html
250
+ <<~HTML
251
+ <html><body>
252
+ <h1>Authorization successful!</h1>
253
+ <p>You can close this window and return to rcal.</p>
254
+ </body></html>
255
+ HTML
256
+ end
257
+
258
+ def failure_html(error)
259
+ <<~HTML
260
+ <html><body>
261
+ <h1>Authorization failed</h1>
262
+ <p>Error: #{error}</p>
263
+ <p>Please try again with <code>rcal init</code>.</p>
264
+ </body></html>
265
+ HTML
141
266
  end
142
267
 
143
268
  def open_browser(url)
144
269
  require "launchy"
145
270
  Launchy.open(url)
271
+ true
146
272
  rescue LoadError, StandardError
147
- # Launchy not available or failed, user will need to copy URL manually
273
+ false
148
274
  end
149
275
 
150
276
  def store_client_credentials(client_id, client_secret)
151
- credentials_file = File.join(Rcal::Configuration.data_dir, "client_credentials.json")
277
+ credentials_file = Auth.client_credentials_path
152
278
  data = {"client_id" => client_id, "client_secret" => client_secret}
153
279
  File.write(credentials_file, JSON.generate(data))
154
280
  File.chmod(0o600, credentials_file)
@@ -104,7 +104,7 @@ module Rcal
104
104
  # Google Calendar accepts both; we use the date-only form for simplicity.
105
105
  date = Date.parse(until_date.to_s)
106
106
  date.strftime("%Y%m%dT235959Z")
107
- rescue Date::Error, ArgumentError
107
+ rescue ArgumentError
108
108
  raise Rcal::Error,
109
109
  "Could not parse until date: #{until_date}. Use a date like '2024-12-31'."
110
110
  end
data/lib/rcal/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rcal
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: r_cal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Drew Bragg
@@ -257,6 +257,7 @@ executables:
257
257
  extensions: []
258
258
  extra_rdoc_files: []
259
259
  files:
260
+ - CHANGELOG.md
260
261
  - Gemfile
261
262
  - Gemfile.lock
262
263
  - LICENSE