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 +4 -4
- data/CHANGELOG.md +43 -0
- data/Gemfile.lock +2 -2
- data/lib/rcal/adapters/auth/base.rb +12 -0
- data/lib/rcal/adapters/auth/google.rb +54 -18
- data/lib/rcal/adapters/calendar/google.rb +51 -26
- data/lib/rcal/auth.rb +13 -1
- data/lib/rcal/calendar_service.rb +18 -28
- data/lib/rcal/commands/init.rb +144 -18
- data/lib/rcal/recurrence_builder.rb +1 -1
- data/lib/rcal/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d4cff3f1a013f82eff5da8efdac73b13a54a635114548761e5cfc1e2fb7a640
|
|
4
|
+
data.tar.gz: ce3a5dc855b2cb920d9b0c1049d90e4f816ea6dc9e2ba38e2641a401d661fee3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
15
|
-
@
|
|
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
|
-
"
|
|
30
|
+
"scope" => Array(credentials.scope),
|
|
31
|
+
"expiration_time_millis" => (credentials.expires_at.to_i * 1000)
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
|
|
28
|
-
File.
|
|
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?(
|
|
40
|
+
return nil unless File.exist?(token_path)
|
|
33
41
|
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
53
|
-
return true if
|
|
66
|
+
expiration = creds["expiration_time_millis"]
|
|
67
|
+
return true if expiration.nil?
|
|
54
68
|
|
|
55
|
-
Time.at(
|
|
69
|
+
Time.at(expiration / 1000) <= Time.now
|
|
56
70
|
end
|
|
57
71
|
|
|
58
72
|
def clear_credentials
|
|
59
|
-
FileUtils.rm_f(
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
19
|
+
with_auth_handling do
|
|
20
|
+
response = @service.list_calendar_lists
|
|
21
|
+
items = response.items || []
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
items.map { |cal| build_calendar(cal) }
|
|
24
|
+
end
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
def get_calendar(calendar_id:)
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
44
|
+
items = response.items || []
|
|
37
45
|
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
client_creds = Auth.load_client_credentials
|
|
76
|
+
return nil if client_creds.nil?
|
|
73
77
|
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
99
|
-
|
|
87
|
+
authorizer = Google::Auth::UserAuthorizer.new(
|
|
88
|
+
client_id,
|
|
89
|
+
Adapters::Auth::Google::SCOPES,
|
|
90
|
+
token_store
|
|
91
|
+
)
|
|
100
92
|
|
|
101
|
-
|
|
102
|
-
rescue JSON::ParserError, Psych::SyntaxError
|
|
103
|
-
nil
|
|
93
|
+
authorizer.get_credentials("default")
|
|
104
94
|
end
|
|
105
95
|
end
|
|
106
96
|
end
|
data/lib/rcal/commands/init.rb
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
129
|
-
open_browser(url)
|
|
133
|
+
code = obtain_authorization_code(url)
|
|
130
134
|
|
|
131
|
-
|
|
132
|
-
|
|
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:
|
|
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
|
-
|
|
273
|
+
false
|
|
148
274
|
end
|
|
149
275
|
|
|
150
276
|
def store_client_credentials(client_id, client_secret)
|
|
151
|
-
credentials_file =
|
|
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
|
|
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
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.
|
|
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
|