Skip to content

Commit

Permalink
Add supports for multiple accounts. Closes #2
Browse files Browse the repository at this point in the history
  • Loading branch information
deanishe committed Jan 24, 2019
1 parent 92dd728 commit 5c14fe7
Show file tree
Hide file tree
Showing 40 changed files with 1,268 additions and 511 deletions.
33 changes: 21 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
Google Calendar for Alfred
==========================

View Google Calendar events in [Alfred][alfred].
View Google Calendar events in [Alfred][alfred]. Supports multiple accounts.

<!-- MarkdownTOC autolink="true" bracket="round" depth="3" autoanchor="true" -->

Expand All @@ -32,32 +32,40 @@ Usage

When run, the workflow will open Google Calendar in your browser and ask for permission to read your calendars. If you do not grant permission, it won't work.

You will also be prompted to activate some calendars (the workflow will show events from these calendars).
You will also be prompted to activate some calendars (the workflow will show events from these calendars). You can alter the active calendars or add/remove Google accounts in the settings using keyword `gcalconf`.

- `gcal` — Show upcoming events.
- `<query>` — Filter list of events.
- `` — Open event in browser or day in workflow.
- `⌘↩` — Open event in Google or Apple Maps (if event has a location).
- `⌘↩` — Open event in Google Maps or Apple Maps (if event has a location).
- `` / `⌘Y` — Quicklook event details.
- `today` / `tomorrow` / `yesterday` — Show events for the given day.
- `<query>` / `` / `⌘↩` / `` / `⌘Y` — As above.
- `gdate [<date>]` — Show one or more dates. See below for query format.
- `` — Show events for the given day.
- `gcalconf [<query>]` — Show workflow configuration.
- `Active Calendars` — Turn calendars on/off.
- `Active Calendars` — Turn calendars on/off.
- `` — Toggle calendar on/off.
- `Add Account…` — Add a Google account.
- `` — Open Google login in browser to authorise an account.
- `[email protected]` — Your logged in Google account(s).
- `` — Remove account.
- `Open Locations in Google Maps/Apple Maps` — Choose app to open event locations.
- `` — Toggle setting between Google Maps & Apple Maps.
- `Workflow is up to Date` / `An Update is Available` — Whether a newer version of the workflow is available.
- `` — Check for or install update.
- `Open Locations in XYZ` — Open locations in Google Maps or Apple Maps.
- `` — Toggle between applications.
- `Open Documentation` — Open this page in your brower.
- `Get Help` — Visit [the thread for this workflow][forumthread] on [AlfredForum.com][alfredforum].
- `Report Issue`[Open an issue][issues] on GitHub.
- `Clear Cached Calendars & Events` — Remove cached data.
- `Clear Cached Calendars & Events` — Remove cached lists of calendars and events.


<a name="date-format"></a>
### Date format ###

The keyword `gdate` supports an optional date. This can be specified in a number of format:
When viewing dates/events, you can specify and jump to a particular date using the following input format:

- `YYYY-MM-DD` — e.g. `2017-12-01`
- `YYYYMMDD` — e.g. `20180101`
Expand All @@ -67,18 +75,19 @@ The keyword `gdate` supports an optional date. This can be specified in a number
- `3w` for 21 days from now
- `-4w` for 4 weeks ago


<a name="configuration"></a>
Configuration
-------------

There are a couple of options in the workflow's configuration sheet (the `[x]` button in Alfred Preferences):

| Setting | Description |
|--------------------|-------------------------------------------------------------------------------------------------------------|
| `APPLE_MAPS` | Set to `1` to open map links in Apple Maps instead of Google Maps. |
| `CALENDAR_APP` | Name of application to open Google Calendar URLs (not map URLs) in. If blank, your default browser is used. |
| `EVENT_CACHE_MINS` | Number of minutes to cache event lists before updating from the server. |
| `SCHEDULE_DAYS` | The number of days' events to show with the `gcal` keyword. |
| Setting | Description |
|---------|-------------|
| `CALENDAR_APP` | Name of application to open Google Calendar URLs (not map URLs) in. If blank, your default browser is used. |
| `EVENT_CACHE_MINS` | Number of minutes to cache event lists before updating from the server. |
| `SCHEDULE_DAYS` | The number of days' events to show with the `gcal` keyword. |
| `APPLE_MAPS` | Set to `1` to open map links in Apple Maps instead of Google Maps. This option can be toggled from within the workflow's configuration with keyword `gcalconf`. |


<a name="licensing--thanks"></a>
Expand Down
303 changes: 303 additions & 0 deletions account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
// Copyright (c) 2019 Dean Jackson <[email protected]>
// MIT Licence applies http://opensource.org/licenses/MIT

package main

import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"

aw "github.com/deanishe/awgo"
"github.com/deanishe/awgo/util"
"github.com/pkg/errors"
"golang.org/x/oauth2"
"google.golang.org/api/calendar/v3"
)

// Account is a Google account. It contains user's email, avatar URL and OAuth2
// token.
type Account struct {
Name string // Directory account data is stored in
Email string // User's email address
AvatarURL string // URL of user's Google avatar

Calendars []*Calendar // Calendars contained by account

Token *oauth2.Token // OAuth2 authentication token
auth *Authenticator
}

// NewAccount creates a new account or loads an existing one.
func NewAccount(name string) (*Account, error) {

var (
a = &Account{Name: name}
err error
)

if name != "" {
if err = wf.Cache.LoadJSON(a.CacheName(), a); err != nil {
return nil, errors.Wrap(err, "load account")
}
}

return a, nil
}

// LoadAccounts reads saved accounts from disk.
func LoadAccounts() ([]*Account, error) {

var (
accounts = []*Account{}
infos []os.FileInfo
err error
)

if infos, err = ioutil.ReadDir(wf.CacheDir()); err != nil {
return nil, errors.Wrap(err, "read accountsDir")
}

for _, fi := range infos {
if fi.IsDir() ||
!strings.HasSuffix(fi.Name(), ".json") ||
!strings.HasPrefix(fi.Name(), "account-") {
continue
}

acc := &Account{}
if err := wf.Cache.LoadJSON(fi.Name(), acc); err != nil {
return nil, errors.Wrap(err, "load account")
}
log.Printf("[account] loaded %q", acc.Name)

accounts = append(accounts, acc)
}

return accounts, nil
}

// CacheName returns the name of Account's cache file.
func (a *Account) CacheName() string { return "account-" + a.Name + ".json" }

// IconPath returns the path to the cached user avatar.
func (a *Account) IconPath() string {
return filepath.Join(cacheDirIcons, a.Name+filepath.Ext(a.AvatarURL))
}

// Icon returns Account user avatar.
func (a *Account) Icon() *aw.Icon {
p := a.IconPath()
if util.PathExists(p) {
return &aw.Icon{Value: p}
}

return iconAccount
}

// Authenticator creates a new Authenticator for Account.
func (a *Account) Authenticator() *Authenticator {
if a.auth == nil {
a.auth = NewAuthenticator(a, []byte(secret))
}

return a.auth
}

// Save saves authentication token.
func (a *Account) Save() error {
if err := wf.Cache.StoreJSON(a.CacheName(), a); err != nil {
return errors.Wrap(err, "save account")
}
log.Printf("[account] saved %q", a.Name)
return nil
}

// Service returns a Calendar Service for this Account.
func (a *Account) Service() (*calendar.Service, error) {

var (
client *http.Client
srv *calendar.Service
err error
)

if client, err = a.Authenticator().GetClient(); err != nil {
return nil, errors.Wrap(err, "get authenticator client")
}

if srv, err = calendar.New(client); err != nil {
return nil, errors.Wrap(err, "create new calendar client")
}

return srv, nil
}

// FetchCalendars retrieves a list of all calendars in Account.
func (a *Account) FetchCalendars() error {

var (
srv *calendar.Service
ls *calendar.CalendarList
cals []*Calendar
err error
)

if srv, err = a.Service(); err != nil {
return errors.Wrap(err, "create service")
}

if ls, err = srv.CalendarList.List().Do(); err != nil {
return errors.Wrap(err, "retrieve calendar list")
}

for _, entry := range ls.Items {
if entry.Hidden {
log.Printf("[account] ignoring hidden calendar %q in %q", entry.Summary, a.Name)
continue
}

c := &Calendar{
ID: entry.Id,
Title: entry.Summary,
Description: entry.Description,
Colour: entry.BackgroundColor,
AccountName: a.Name,
}
if entry.SummaryOverride != "" {
c.Title = entry.SummaryOverride
}
cals = append(cals, c)
}

sort.Sort(CalsByTitle(cals))
a.Calendars = cals
return a.Save()
}

// FetchEvents returns events from the specified calendar.
func (a *Account) FetchEvents(cal *Calendar, start time.Time) ([]*Event, error) {

var (
end = start.Add(opts.ScheduleDuration())
events = []*Event{}
startTime = start.Format(time.RFC3339)
endTime = end.Format(time.RFC3339)
srv *calendar.Service
err error
)

log.Printf("[account] account=%q, cal=%q, start=%s, end=%s", a.Name, cal.Title, start, end)

if srv, err = a.Service(); err != nil {
return nil, a.handleAPIError(err)
}

evs, err := srv.Events.List(cal.ID).
SingleEvents(true).
MaxResults(2500).
TimeMin(startTime).
TimeMax(endTime).
OrderBy("startTime").Do()

if err != nil {
return nil, a.handleAPIError(err)
}

for _, e := range evs.Items {
if e.Start.DateTime == "" { // all-day event
continue
}

var (
start time.Time
end time.Time
err error
)

if start, err = time.Parse(time.RFC3339, e.Start.DateTime); err != nil {
log.Printf("[events] ERR: parse start time (%s): %v", e.Start.DateTime, err)
continue
}
if end, err = time.Parse(time.RFC3339, e.End.DateTime); err != nil {
log.Printf("[events] ERR: parse end time (%s): %v", e.End.DateTime, err)
continue
}

events = append(events, &Event{
ID: e.Id,
IcalUID: e.ICalUID,
Title: e.Summary,
Description: e.Description,
URL: e.HtmlLink,
Location: e.Location,
Start: start,
End: end,
Colour: cal.Colour,
CalendarID: cal.ID,
CalendarTitle: cal.Title,
})
}

return events, nil
}

// Check for OAuth2 error and remove tokens if they've expired/been revoked.
func (a *Account) handleAPIError(err error) error {

if err2, ok := err.(*url.Error); ok {

if err3, ok := err2.Err.(*oauth2.RetrieveError); ok {
var resp errorResponse
if err4 := json.Unmarshal([]byte(err3.Body), &resp); err4 == nil {

log.Printf("[events] ERR: OAuth: %s (%s)", resp.Name, resp.Description)

err := errorAuthentication{
Name: resp.Name,
Description: resp.Description,
Err: err3,
}

if err.Name == "invalid_grant" {

log.Printf("[account] clearing invalid token for %q", a.Name)

a.Token = nil
if err := a.Save(); err != nil {
log.Printf("[account] ERR: save %q: %v", a.Name, err)
}
}

return err
}
}
}

return err
}

// for unmarshalling API errors.
type errorResponse struct {
Name string `json:"error"`
Description string `json:"error_description"`
}

type errorAuthentication struct {
Name string
Description string
Err error
}

// Error implements error.
func (err errorAuthentication) Error() string {
return fmt.Sprintf("authentication error: %s (%s)", err.Name, err.Description)
}
Loading

0 comments on commit 5c14fe7

Please sign in to comment.