Class: Timely::Sources::Google

Inherits:
Object
  • Object
show all
Defined in:
lib/timely/sources/google.rb

Constant Summary collapse

API_BASE =
'https://www.googleapis.com'
TOKEN_URL =
'https://oauth2.googleapis.com/token'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(email, safe_dir: '~/.config/timely/credentials') ⇒ Google

Returns a new instance of Google.



14
15
16
17
18
19
20
# File 'lib/timely/sources/google.rb', line 14

def initialize(email, safe_dir: '~/.config/timely/credentials')
  @email = email
  @safe_dir = File.expand_path(safe_dir)
  @access_token = nil
  @token_expires_at = 0
  @last_error = nil
end

Instance Attribute Details

#last_errorObject (readonly)

Returns the value of attribute last_error.



12
13
14
# File 'lib/timely/sources/google.rb', line 12

def last_error
  @last_error
end

Instance Method Details

#create_event(calendar_id, event_data) ⇒ Object

Create event on Google Calendar



120
121
122
123
124
125
# File 'lib/timely/sources/google.rb', line 120

def create_event(calendar_id, event_data)
  body = to_google_format(event_data)
  cal_encoded = URI.encode_www_form_component(calendar_id)
  data = api_post("/calendar/v3/calendars/#{cal_encoded}/events", body)
  data ? data['id'] : nil
end

#delete_event(calendar_id, event_id) ⇒ Object

Delete event



136
137
138
139
140
# File 'lib/timely/sources/google.rb', line 136

def delete_event(calendar_id, event_id)
  cal_encoded = URI.encode_www_form_component(calendar_id)
  evt_encoded = URI.encode_www_form_component(event_id)
  api_delete("/calendar/v3/calendars/#{cal_encoded}/events/#{evt_encoded}")
end

#fetch_events(calendar_id, time_min, time_max) ⇒ Object

Fetch events in a date range



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/timely/sources/google.rb', line 89

def fetch_events(calendar_id, time_min, time_max)
  events = []
  page_token = nil

  loop do
    params = {
      'timeMin' => Time.at(time_min).utc.strftime('%Y-%m-%dT%H:%M:%SZ'),
      'timeMax' => Time.at(time_max).utc.strftime('%Y-%m-%dT%H:%M:%SZ'),
      'singleEvents' => 'true',
      'maxResults' => '250',
      'orderBy' => 'startTime'
    }
    params['pageToken'] = page_token if page_token

    query = params.map { |k, v| "#{k}=#{URI.encode_www_form_component(v)}" }.join('&')
    cal_encoded = URI.encode_www_form_component(calendar_id)
    data = api_get("/calendar/v3/calendars/#{cal_encoded}/events?#{query}")
    break unless data && data['items']

    data['items'].each do |item|
      events << normalize_event(item)
    end

    page_token = data['nextPageToken']
    break unless page_token
  end

  events
end

#get_access_tokenObject

Get or refresh access token



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/timely/sources/google.rb', line 23

def get_access_token
  return @access_token if @access_token && Time.now.to_i < @token_expires_at

  # Use the JSON credentials matching this email, or fall back to first available
  json_file = File.join(@safe_dir, "#{@email}.json")
  unless File.exist?(json_file)
    json_file = Dir.glob(File.join(@safe_dir, '*.json')).first
  end
  return nil unless json_file && File.exist?(json_file)

  creds = JSON.parse(File.read(json_file))
  client_id = creds.dig('web', 'client_id') || creds.dig('installed', 'client_id')
  client_secret = creds.dig('web', 'client_secret') || creds.dig('installed', 'client_secret')

  # Look for calendar-specific refresh token, fall back to general
  cal_token_file = File.join(@safe_dir, "#{@email}.calendar.txt")
  gen_token_file = File.join(@safe_dir, "#{@email}.txt")
  token_file = File.exist?(cal_token_file) ? cal_token_file : gen_token_file
  return nil unless File.exist?(token_file)

  refresh_token = File.read(token_file).strip
  return nil if refresh_token.empty?

  # Token refresh via API
  uri = URI(TOKEN_URL)
  res = Net::HTTP.post_form(uri, {
    'client_id' => client_id,
    'client_secret' => client_secret,
    'refresh_token' => refresh_token,
    'grant_type' => 'refresh_token'
  })

  if res.is_a?(Net::HTTPSuccess)
    data = JSON.parse(res.body)
    @access_token = data['access_token']
    @token_expires_at = Time.now.to_i + (data['expires_in'] || 3600).to_i - 60
    @access_token
  else
    err = JSON.parse(res.body) rescue {}
    @last_error = "Token refresh failed: #{err['error_description'] || err['error'] || res.code}"
    if err['error'] == 'invalid_grant'
      @last_error += ". Token may need calendar scope. Run: oauth2.py --generate_oauth2_token --client_id=<ID> --client_secret=<SECRET> with calendar scope"
    end
    nil
  end
rescue => e
  @last_error = "Token error: #{e.message}"
  nil
end

#list_calendarsObject

List all calendars



74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/timely/sources/google.rb', line 74

def list_calendars
  data = api_get('/calendar/v3/users/me/calendarList')
  return [] unless data && data['items']
  data['items'].map do |cal|
    {
      id: cal['id'],
      summary: cal['summary'],
      primary: cal['primary'] || false,
      color: cal['backgroundColor'],
      access_role: cal['accessRole']
    }
  end
end

#update_event(calendar_id, event_id, event_data) ⇒ Object

Update event



128
129
130
131
132
133
# File 'lib/timely/sources/google.rb', line 128

def update_event(calendar_id, event_id, event_data)
  body = to_google_format(event_data)
  cal_encoded = URI.encode_www_form_component(calendar_id)
  evt_encoded = URI.encode_www_form_component(event_id)
  api_put("/calendar/v3/calendars/#{cal_encoded}/events/#{evt_encoded}", body)
end