Streak

Google APIs Client and OAuth2

Andrew Gerrand

Streak

Streak is a command-line productivity tool based around the "Seinfeld method".

[Seinfeld] revealed a unique calendar system he uses to pressure
himself to write. Here's how it works.

He told me to get a big wall calendar that has a whole year on one page
and hang it on a prominent wall. The next step was to get a big red
magic marker.

He said for each day that I do my task of writing, I get to put a big
red X over that day. "After a few days you'll have a chain. Just keep
at it and the chain will grow longer every day. You'll like seeing that
chain, especially when you get a few weeks under your belt. Your only
job next is to not break the chain."

"Don't break the chain," he said again for emphasis.

Streak maintains a Google Calendar named "Streaks", adding and extending multi-day events to represent a chain or "streak."

My streaks

User interface

Add today to a streak (or create a streak if none exists):

$ streak

Remove today from a streak:

$ streak -remove

Add yesterday to a streak (or create if none exists):

$ streak -offset -1

Remove yesterday from a streak:

$ streak -offset -1 -remove

How it works

At startup:

When adding a day to a streak:

When removing a day from a streak:

Inside Streak

Beyond the Go standard library, Streak has a two dependencies:

Using the Calendar API

Import the relevant package from the google-api-go-client repository:

import "code.google.com/p/google-api-go-client/calendar/v3"

With an OAuth-authenticated HTTP client (more on this later), create a calendar service:

service, err := calendar.New(transport.Client())
if err != nil {
    log.Fatal(err)
}

Anatomy of an API call

To list the user's calendars, first build an *calendar.CalendarListCall value:

call := service.CalendarList.List()

Invoke the call's Do method, which returns a *calendar.CalendarList value:

list, err := call.Do()
if err != nil {
    return "", err
}

Then finally we can use the result:

for _, entry := range list.Items {
    fmt.Println(entry.Summary)
}

Finding the "Streaks" calendar

const calSummary = "Streaks"
func streakCalendarId(service *calendar.Service) (string, error) {
    list, err := service.CalendarList.List().Do()
    if err != nil {
        return "", err
    }
    for _, entry := range list.Items {
        if entry.Summary == calSummary {
            return entry.Id, nil
        }
    }
    return "", errors.New("couldn't find calendar named 'Streaks'")
}

The Calendar type

All operations require a calendar service and the relevant calendar ID, so we'll put them in a Calendar type:

type Calendar struct {
    Id string
    *calendar.Service
}

At startup we create a service and find the Calendar ID, and store them in a Calendar:

    service, err := calendar.New(transport.Client())
    calId, err := streakCalendarId(service)
    cal := &Calendar{
        Id:      calId,
        Service: service,
    }

Listing events (1/2)

Similar to listing calendars.

First build an *calendar.EventsListCall value, and use its "method chaining" API to request only non-recurring events in chronological order:

call := service.Events.List(calId).SingleEvents(true).OrderBy("startTime")

Invoke the call's Do method, which returns a *calendar.Events value:

events, err := call.Do()
if err != nil {
    return err
}

Then we can use the result:

for _, e := range events.Items {
    // Do something with the event, e.
}

Listing events (2/2)

Only 100 Events may be returned per API call, so we may need to make multiple calls to retrieve the full list. The *Events struct has a NextPageToken field for pagination.

var pageToken string
for {
    call := service.Events.List(calId).SingleEvents(true).OrderBy("startTime")
    if pageToken != "" {
        call.PageToken(pageToken)
    }
    events, err := call.Do()
    if err != nil {
        return err
    }
    for _, e := range events.Items {
        // Do something with the event, e.
    }
    pageToken = events.NextPageToken
    if pageToken == "" {
        break // This is the last page.
    }
}

Iterating over events (interface)

We need to iterate through the events for both the add and remove operations, so it would be nice to abstract away this functionality somehow.

What if we could just write this instead?

cal.iterateEvents(func(e *calendar.Event) error {
    // Do something with the event, e.
})

Iterating over events (implementation)

func (c *Calendar) iterateEvents(fn func(e *calendar.Event) error) error {
    var pageToken string
    for {
        call := c.Events.List(c.Id).SingleEvents(true).OrderBy("startTime")
        if pageToken != "" {
            call.PageToken(pageToken)
        }
        events, err := call.Do()
        if err != nil {
            return err
        }
        for _, e := range events.Items {
            if err := fn(e); err != nil {
                return err
            }
        }
        pageToken = events.NextPageToken
        if pageToken == "" {
            return nil
        }
    }
    panic("unreachable")
}

Iterating over events (explicit continue)

We don't always want to iterate over the entire list, so we add a Continue error value that the iterator function must return to continue the iteration (otherwise the iterator returns with the given error).

var Continue = errors.New("continue")

func (c *Calendar) iterateEvents(fn func(e *calendar.Event) error) error {
    // ...
        for _, e := range events.Items {
            if err := fn(e); err != Continue {
                return err
            }
        }
    // ...
}
cal.iterateEvents(func(e *calendar.Event) error {
    if e.Summary == "Foo" {
        return nil // stop iterating
    }
    return Continue
})

Iterating over events (more specialization)

There's some other common behavior we can put in the iterator.
We only want all-day events named "Streak", and since we use the time package to perform computations on dates, we must convert the Calendar API's date strings to Go time.Time values.

type iteratorFunc func(e *calendar.Event, start, end time.Time) error

func (c *Calendar) iterateEvents(fn iteratorFunc) error {
    // ...
        for _, e := range events.Items {
            if e.Start.Date == "" || e.End.Date == "" || e.Summary != evtSummary {
                // Skip non-all-day event or non-streak events.
                continue
            }
            start, end := parseDate(e.Start.Date), parseDate(e.End.Date)
            if err := fn(e, start, end); err != Continue {
                return err
            }
        }
    // ...
}

Iterating over events (final form in use)

This code to uses the iterator to find the duration of the longest event:

var longest time.Duration
cal.iterateEvents(func(e *calendar.Event, start, end time.Time) error {
    if d := end.Sub(start); d > longest {
        longest = d
    }
    return Continue
})
fmt.Println("Longest streak:", longest)

Output:

Longest streak: 360h0m0s

Adding days to a streak (1/2)

The addToStreak function adds the given date to a streak in the Calendar.

func (c *Calendar) addToStreak(today time.Time) (err error) {
    create := true
    err = c.iterateEvents(func(e *calendar.Event, start, end time.Time) error {
        if start.After(today) {
            if start.Add(-day).Equal(today) {
                // This event starts tomorrow, update it to start today.
                create = false
                e.Start.Date = today.Format(dateFormat)
                _, err = c.Events.Update(c.Id, e.Id, e).Do()
                return err
            }
            // This event is too far in the future.
            return Continue
        }
        if end.After(today) {
            // Today fits inside this event, nothing to do.
            create = false
            return nil
        }
// ...

Adding days to a streak (2/2)

// ...
        if end.Equal(today) {
            // This event ends today, update it to end tomorrow.
            create = false
            e.End.Date = today.Add(day).Format(dateFormat)
            _, err = c.Events.Update(c.Id, e.Id, e).Do()
            if err != nil {
                return err
            }
        }
        return Continue
    })
    if err == nil && create {
        // No existing events cover or are adjacent to today, so create one.
        err = c.createEvent(today, today.Add(day))
    }
    return
}

(This code is slightly abridged; the real program also combines adjacent events.)

Removing days from a streak (1/2)

The removeFromStreak function is just like addToStreak.

func (c *Calendar) removeFromStreak(today time.Time) (err error) {
    err = c.iterateEvents(func(e *calendar.Event, start, end time.Time) error {
        if start.After(today) || end.Before(today) || end.Equal(today) {
            // This event is too far in the future or past.
            return Continue
        }
        if start.Equal(today) {
            if end.Equal(today.Add(day)) {
                // Single day event; remove it.
                return c.Events.Delete(c.Id, e.Id).Do()
            }
            // Starts today; shorten to begin tomorrow.
            e.Start.Date = start.Add(day).Format(dateFormat)
            _, err := c.Events.Update(c.Id, e.Id, e).Do()
            return err
        }
        if end.Equal(today.Add(day)) {
            // Ends tomorrow; shorten to end today.
            e.End.Date = today.Format(dateFormat)
            _, err := c.Events.Update(c.Id, e.Id, e).Do()
            return err
        }

Removing days from a streak (2/2)

// ...
        // Split into two events.
        // Shorten first event to end today.
        e.End.Date = today.Format(dateFormat)
        _, err = c.Events.Update(c.Id, e.Id, e).Do()
        if err != nil {
            return err
        }
        // Create second event that starts tomorrow.
        return c.createEvent(today.Add(day), end)
    })
    return
}

Authentication

Streak uses OAuth2 to authenticate with the Calendar API.

OAuth2 in a nutshell:

Streak uses the goauth2 package, which helps with the first, fourth, and final steps.

import "code.google.com/p/goauth2/oauth"

OAuth2 configuration

The client ID and secret are obtained from the Google APIs Console. The Scope specifies the service to access (Calendar), while the AuthURL and TokenURL point to Google's OAuth2 service.

    config := &oauth.Config{
        ClientId:     "120233572441-d8vmojicfgje467joivr5a7j52dg2gnc.apps.googleusercontent.com",
        ClientSecret: "vfZkluBV6PTfGBWxfIIyXbMS",
        Scope:        "https://www.googleapis.com/auth/calendar",
        AuthURL:      "https://accounts.google.com/o/oauth2/auth",
        TokenURL:     "https://accounts.google.com/o/oauth2/token",
        TokenCache:   oauth.CacheFile(*cachefile),
    }

The TokenCache field and oauth.CacheFile helpers transparently store the access token on disk. A flag specifies the location of the cache file:

defaultCacheFile = filepath.Join(os.Getenv("HOME"), ".streak-request-token")
cachefile        = flag.String("cachefile", defaultCacheFile, "Authentication token cache file")

Setting up an authenticated service

At startup, try to read the cached token.
If that fails for any reason, call authenticate to go do the OAuth2 flow.
If it succeeds, store the token in the transport and build a Calendar service.

    transport := &oauth.Transport{Config: config}

    if token, err := config.TokenCache.Token(); err != nil {
        err = authenticate(transport)
        if err != nil {
            log.Fatalln("authenticate:", err)
        }
    } else {
        transport.Token = token
    }

    service, err := calendar.New(transport.Client())

The authenticate function

func authenticate(transport *oauth.Transport) error {
    code := make(chan string)

    listener, err := net.Listen("tcp", "localhost:0")
    if err != nil {
        return err
    }
    go http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, closeMessage)
        code <- r.FormValue("code") // send code to OAuth flow
        listener.Close()            // shut down HTTP server
    }))

    transport.Config.RedirectURL = fmt.Sprintf("http://%s/", listener.Addr())
    url := transport.Config.AuthCodeURL("")
    if err := openURL(url); err != nil {
        fmt.Fprintln(os.Stderr, visitMessage)
    } else {
        fmt.Fprintln(os.Stderr, openedMessage)
    }
    fmt.Fprintf(os.Stderr, "\n%s\n\n", url)
    fmt.Fprintln(os.Stderr, resumeMessage)

    _, err = transport.Exchange(<-code)
    return err
}

Demo

Thank you

Andrew Gerrand