-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add supports for multiple accounts. Closes #2
- Loading branch information
Showing
40 changed files
with
1,268 additions
and
511 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" --> | ||
|
||
|
@@ -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` | ||
|
@@ -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> | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.