mirror of
https://github.com/taigrr/wtf
synced 2025-01-18 04:03:14 -08:00
108
modules/gcal/cal_event.go
Normal file
108
modules/gcal/cal_event.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/wtfutil/wtf/wtf"
|
||||
"google.golang.org/api/calendar/v3"
|
||||
)
|
||||
|
||||
type CalEvent struct {
|
||||
event *calendar.Event
|
||||
}
|
||||
|
||||
func NewCalEvent(event *calendar.Event) *CalEvent {
|
||||
calEvent := CalEvent{
|
||||
event: event,
|
||||
}
|
||||
|
||||
return &calEvent
|
||||
}
|
||||
|
||||
/* -------------------- Exported Functions -------------------- */
|
||||
|
||||
func (calEvent *CalEvent) AllDay() bool {
|
||||
return len(calEvent.event.Start.Date) > 0
|
||||
}
|
||||
|
||||
func (calEvent *CalEvent) ConflictsWith(otherEvents []*CalEvent) bool {
|
||||
hasConflict := false
|
||||
|
||||
for _, otherEvent := range otherEvents {
|
||||
if calEvent.event == otherEvent.event {
|
||||
continue
|
||||
}
|
||||
|
||||
if calEvent.Start().Before(otherEvent.End()) && calEvent.End().After(otherEvent.Start()) {
|
||||
hasConflict = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return hasConflict
|
||||
}
|
||||
|
||||
func (calEvent *CalEvent) Now() bool {
|
||||
return time.Now().After(calEvent.Start()) && time.Now().Before(calEvent.End())
|
||||
}
|
||||
|
||||
func (calEvent *CalEvent) Past() bool {
|
||||
if calEvent.AllDay() {
|
||||
// FIXME: This should calculate properly
|
||||
return false
|
||||
}
|
||||
|
||||
return (calEvent.Now() == false) && calEvent.Start().Before(time.Now())
|
||||
}
|
||||
|
||||
func (calEvent *CalEvent) ResponseFor(email string) string {
|
||||
for _, attendee := range calEvent.event.Attendees {
|
||||
if attendee.Email == email {
|
||||
return attendee.ResponseStatus
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/* -------------------- DateTimes -------------------- */
|
||||
|
||||
func (calEvent *CalEvent) End() time.Time {
|
||||
var calcTime string
|
||||
var end time.Time
|
||||
|
||||
if calEvent.AllDay() {
|
||||
calcTime = calEvent.event.End.Date
|
||||
end, _ = time.ParseInLocation("2006-01-02", calcTime, time.Local)
|
||||
} else {
|
||||
calcTime = calEvent.event.End.DateTime
|
||||
end, _ = time.Parse(time.RFC3339, calcTime)
|
||||
}
|
||||
|
||||
return end
|
||||
}
|
||||
|
||||
func (calEvent *CalEvent) Start() time.Time {
|
||||
var calcTime string
|
||||
var start time.Time
|
||||
|
||||
if calEvent.AllDay() {
|
||||
calcTime = calEvent.event.Start.Date
|
||||
start, _ = time.ParseInLocation("2006-01-02", calcTime, time.Local)
|
||||
} else {
|
||||
calcTime = calEvent.event.Start.DateTime
|
||||
start, _ = time.Parse(time.RFC3339, calcTime)
|
||||
}
|
||||
|
||||
return start
|
||||
}
|
||||
|
||||
func (calEvent *CalEvent) Timestamp() string {
|
||||
if calEvent.AllDay() {
|
||||
startTime, _ := time.ParseInLocation("2006-01-02", calEvent.event.Start.Date, time.Local)
|
||||
return startTime.Format(wtf.FriendlyDateFormat)
|
||||
}
|
||||
|
||||
startTime, _ := time.Parse(time.RFC3339, calEvent.event.Start.DateTime)
|
||||
return startTime.Format(wtf.MinimumTimeFormat)
|
||||
}
|
||||
219
modules/gcal/client.go
Normal file
219
modules/gcal/client.go
Normal file
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* This butt-ugly code is direct from Google itself
|
||||
* https://developers.google.com/calendar/quickstart/go
|
||||
*
|
||||
* With some changes by me to improve things a bit.
|
||||
*/
|
||||
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/wtfutil/wtf/cfg"
|
||||
"github.com/wtfutil/wtf/wtf"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/calendar/v3"
|
||||
)
|
||||
|
||||
/* -------------------- Exported Functions -------------------- */
|
||||
|
||||
func Fetch() ([]*CalEvent, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
secretPath, _ := wtf.ExpandHomeDir(wtf.Config.UString("wtf.mods.gcal.secretFile"))
|
||||
|
||||
b, err := ioutil.ReadFile(secretPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := google.ConfigFromJSON(b, calendar.CalendarReadonlyScope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := getClient(ctx, config)
|
||||
|
||||
srv, err := calendar.New(client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
calendarIds, err := getCalendarIdList(srv)
|
||||
|
||||
// Get calendar events
|
||||
var events calendar.Events
|
||||
|
||||
startTime := fromMidnight().Format(time.RFC3339)
|
||||
eventLimit := int64(wtf.Config.UInt("wtf.mods.gcal.eventCount", 10))
|
||||
|
||||
for _, calendarId := range calendarIds {
|
||||
calendarEvents, err := srv.Events.List(calendarId).ShowDeleted(false).TimeMin(startTime).MaxResults(eventLimit).SingleEvents(true).OrderBy("startTime").Do()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
events.Items = append(events.Items, calendarEvents.Items...)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sort events
|
||||
timeDateChooser := func(event *calendar.Event) (time.Time, error) {
|
||||
if len(event.Start.Date) > 0 {
|
||||
return time.Parse("2006-01-02", event.Start.Date)
|
||||
}
|
||||
|
||||
return time.Parse(time.RFC3339, event.Start.DateTime)
|
||||
}
|
||||
|
||||
sort.Slice(events.Items, func(i, j int) bool {
|
||||
dateA, _ := timeDateChooser(events.Items[i])
|
||||
dateB, _ := timeDateChooser(events.Items[j])
|
||||
return dateA.Before(dateB)
|
||||
})
|
||||
|
||||
// Wrap the calendar events in our custom CalEvent
|
||||
calEvents := []*CalEvent{}
|
||||
for _, event := range events.Items {
|
||||
calEvents = append(calEvents, NewCalEvent(event))
|
||||
}
|
||||
|
||||
return calEvents, err
|
||||
}
|
||||
|
||||
/* -------------------- Unexported Functions -------------------- */
|
||||
|
||||
func fromMidnight() time.Time {
|
||||
now := time.Now()
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
// getClient uses a Context and Config to retrieve a Token
|
||||
// then generate a Client. It returns the generated Client.
|
||||
func getClient(ctx context.Context, config *oauth2.Config) *http.Client {
|
||||
cacheFile, err := tokenCacheFile()
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to get path to cached credential file. %v", err)
|
||||
}
|
||||
tok, err := tokenFromFile(cacheFile)
|
||||
if err != nil {
|
||||
tok = getTokenFromWeb(config)
|
||||
saveToken(cacheFile, tok)
|
||||
}
|
||||
return config.Client(ctx, tok)
|
||||
}
|
||||
|
||||
func isAuthenticated() bool {
|
||||
cacheFile, err := tokenCacheFile()
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to get path to cached credential file. %v", err)
|
||||
}
|
||||
_, err = tokenFromFile(cacheFile)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func authenticate() {
|
||||
filename := wtf.Config.UString("wtf.mods.gcal.secretFile")
|
||||
secretPath, _ := wtf.ExpandHomeDir(filename)
|
||||
|
||||
b, err := ioutil.ReadFile(secretPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to read secret file. %v", filename)
|
||||
}
|
||||
|
||||
config, err := google.ConfigFromJSON(b, calendar.CalendarReadonlyScope)
|
||||
tok := getTokenFromWeb(config)
|
||||
cacheFile, err := tokenCacheFile()
|
||||
saveToken(cacheFile, tok)
|
||||
}
|
||||
|
||||
// getTokenFromWeb uses Config to request a Token.
|
||||
// It returns the retrieved Token.
|
||||
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
|
||||
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
|
||||
fmt.Printf("Go to the following link in your browser then type the "+
|
||||
"authorization code: \n%v (press 'return' before inserting the code)", authURL)
|
||||
|
||||
var code string
|
||||
if _, err := fmt.Scan(&code); err != nil {
|
||||
log.Fatalf("Unable to read authorization code %v", err)
|
||||
}
|
||||
|
||||
tok, err := config.Exchange(oauth2.NoContext, code)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to retrieve token from web %v", err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// tokenCacheFile generates credential file path/filename.
|
||||
// It returns the generated credential path/filename.
|
||||
func tokenCacheFile() (string, error) {
|
||||
return cfg.CreateFile("gcal-auth.json")
|
||||
}
|
||||
|
||||
// tokenFromFile retrieves a Token from a given file path.
|
||||
// It returns the retrieved Token and any read error encountered.
|
||||
func tokenFromFile(file string) (*oauth2.Token, error) {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := &oauth2.Token{}
|
||||
err = json.NewDecoder(f).Decode(t)
|
||||
defer f.Close()
|
||||
return t, err
|
||||
}
|
||||
|
||||
// saveToken uses a file path to create a file and store the
|
||||
// token in it.
|
||||
func saveToken(file string, token *oauth2.Token) {
|
||||
fmt.Printf("Saving credential file to: %s\n", file)
|
||||
f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to cache oauth token: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
json.NewEncoder(f).Encode(token)
|
||||
}
|
||||
|
||||
func getCalendarIdList(srv *calendar.Service) ([]string, error) {
|
||||
// Return single calendar if settings specify we should
|
||||
if !wtf.Config.UBool("wtf.mods.gcal.multiCalendar", false) {
|
||||
id, err := srv.CalendarList.Get("primary").Do()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{id.Id}, nil
|
||||
}
|
||||
|
||||
// Get all user calendars with at the least writing access
|
||||
var calendarIds []string
|
||||
var pageToken string
|
||||
for {
|
||||
calendarList, err := srv.CalendarList.List().ShowHidden(false).MinAccessRole("writer").PageToken(pageToken).Do()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, calendarListItem := range calendarList.Items {
|
||||
calendarIds = append(calendarIds, calendarListItem.Id)
|
||||
}
|
||||
|
||||
pageToken = calendarList.NextPageToken
|
||||
if pageToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return calendarIds, nil
|
||||
}
|
||||
240
modules/gcal/display.go
Normal file
240
modules/gcal/display.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wtfutil/wtf/wtf"
|
||||
)
|
||||
|
||||
func (widget *Widget) sortedEvents() ([]*CalEvent, []*CalEvent) {
|
||||
allDayEvents := []*CalEvent{}
|
||||
timedEvents := []*CalEvent{}
|
||||
|
||||
for _, calEvent := range widget.calEvents {
|
||||
if calEvent.AllDay() {
|
||||
allDayEvents = append(allDayEvents, calEvent)
|
||||
} else {
|
||||
timedEvents = append(timedEvents, calEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return allDayEvents, timedEvents
|
||||
}
|
||||
|
||||
func (widget *Widget) display() {
|
||||
if widget.calEvents == nil || len(widget.calEvents) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
widget.mutex.Lock()
|
||||
defer widget.mutex.Unlock()
|
||||
|
||||
widget.View.SetTitle(widget.ContextualTitle(widget.Name))
|
||||
widget.View.SetText(widget.contentFrom(widget.calEvents))
|
||||
}
|
||||
|
||||
func (widget *Widget) contentFrom(calEvents []*CalEvent) string {
|
||||
if (calEvents == nil) || (len(calEvents) == 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
var str string
|
||||
var prevEvent *CalEvent
|
||||
|
||||
if !wtf.Config.UBool("wtf.mods.gcal.showDeclined", false) {
|
||||
calEvents = removeDeclined(calEvents)
|
||||
}
|
||||
|
||||
for _, calEvent := range calEvents {
|
||||
timestamp := fmt.Sprintf("[%s]%s", widget.descriptionColor(calEvent), calEvent.Timestamp())
|
||||
if calEvent.AllDay() {
|
||||
timestamp = ""
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("[%s]%s",
|
||||
widget.titleColor(calEvent),
|
||||
widget.eventSummary(calEvent, calEvent.ConflictsWith(calEvents)),
|
||||
)
|
||||
|
||||
lineOne := fmt.Sprintf(
|
||||
"%s %s %s %s[white]\n",
|
||||
widget.dayDivider(calEvent, prevEvent),
|
||||
widget.responseIcon(calEvent),
|
||||
timestamp,
|
||||
title,
|
||||
)
|
||||
|
||||
str = str + fmt.Sprintf("%s %s%s\n",
|
||||
lineOne,
|
||||
widget.location(calEvent),
|
||||
widget.timeUntil(calEvent),
|
||||
)
|
||||
|
||||
if (widget.location(calEvent) != "") || (widget.timeUntil(calEvent) != "") {
|
||||
str = str + "\n"
|
||||
}
|
||||
|
||||
prevEvent = calEvent
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
func (widget *Widget) dayDivider(event, prevEvent *CalEvent) string {
|
||||
var prevStartTime time.Time
|
||||
|
||||
if prevEvent != nil {
|
||||
prevStartTime = prevEvent.Start()
|
||||
}
|
||||
|
||||
// round times to midnight for comparison
|
||||
toMidnight := func(t time.Time) time.Time {
|
||||
t = t.Local()
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
prevStartDay := toMidnight(prevStartTime)
|
||||
eventStartDay := toMidnight(event.Start())
|
||||
|
||||
if !eventStartDay.Equal(prevStartDay) {
|
||||
|
||||
return fmt.Sprintf("[%s::b]",
|
||||
wtf.Config.UString("wtf.mods.gcal.colors.day", "forestgreen")) +
|
||||
event.Start().Format(wtf.FullDateFormat) +
|
||||
"\n"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (widget *Widget) descriptionColor(calEvent *CalEvent) string {
|
||||
if calEvent.Past() {
|
||||
return wtf.Config.UString("wtf.mods.gcal.colors.past", "gray")
|
||||
}
|
||||
|
||||
return wtf.Config.UString("wtf.mods.gcal.colors.description", "white")
|
||||
}
|
||||
|
||||
func (widget *Widget) eventSummary(calEvent *CalEvent, conflict bool) string {
|
||||
summary := calEvent.event.Summary
|
||||
|
||||
if calEvent.Now() {
|
||||
summary = fmt.Sprintf(
|
||||
"%s %s",
|
||||
wtf.Config.UString("wtf.mods.gcal.currentIcon", "🔸"),
|
||||
summary,
|
||||
)
|
||||
}
|
||||
|
||||
if conflict {
|
||||
return fmt.Sprintf("%s %s", wtf.Config.UString("wtf.mods.gcal.conflictIcon", "🚨"), summary)
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// timeUntil returns the number of hours or days until the event
|
||||
// If the event is in the past, returns nil
|
||||
func (widget *Widget) timeUntil(calEvent *CalEvent) string {
|
||||
duration := time.Until(calEvent.Start()).Round(time.Minute)
|
||||
|
||||
if duration < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
days := duration / (24 * time.Hour)
|
||||
duration -= days * (24 * time.Hour)
|
||||
|
||||
hours := duration / time.Hour
|
||||
duration -= hours * time.Hour
|
||||
|
||||
mins := duration / time.Minute
|
||||
|
||||
untilStr := ""
|
||||
|
||||
color := "[lightblue]"
|
||||
if days > 0 {
|
||||
untilStr = fmt.Sprintf("%dd", days)
|
||||
} else if hours > 0 {
|
||||
untilStr = fmt.Sprintf("%dh", hours)
|
||||
} else {
|
||||
untilStr = fmt.Sprintf("%dm", mins)
|
||||
if mins < 30 {
|
||||
color = "[red]"
|
||||
}
|
||||
}
|
||||
|
||||
return color + untilStr + "[white]"
|
||||
}
|
||||
|
||||
func (widget *Widget) titleColor(calEvent *CalEvent) string {
|
||||
color := wtf.Config.UString("wtf.mods.gcal.colors.title", "white")
|
||||
|
||||
for _, untypedArr := range wtf.Config.UList("wtf.mods.gcal.colors.highlights") {
|
||||
highlightElements := wtf.ToStrs(untypedArr.([]interface{}))
|
||||
|
||||
match, _ := regexp.MatchString(
|
||||
strings.ToLower(highlightElements[0]),
|
||||
strings.ToLower(calEvent.event.Summary),
|
||||
)
|
||||
|
||||
if match == true {
|
||||
color = highlightElements[1]
|
||||
}
|
||||
}
|
||||
|
||||
if calEvent.Past() {
|
||||
color = wtf.Config.UString("wtf.mods.gcal.colors.past", "gray")
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
|
||||
func (widget *Widget) location(calEvent *CalEvent) string {
|
||||
if wtf.Config.UBool("wtf.mods.gcal.displayLocation", true) == false {
|
||||
return ""
|
||||
}
|
||||
|
||||
if calEvent.event.Location == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"[%s]%s ",
|
||||
widget.descriptionColor(calEvent),
|
||||
calEvent.event.Location,
|
||||
)
|
||||
}
|
||||
|
||||
func (widget *Widget) responseIcon(calEvent *CalEvent) string {
|
||||
if false == wtf.Config.UBool("wtf.mods.gcal.displayResponseStatus", true) {
|
||||
return ""
|
||||
}
|
||||
|
||||
icon := "[gray]"
|
||||
|
||||
switch calEvent.ResponseFor(wtf.Config.UString("wtf.mods.gcal.email")) {
|
||||
case "accepted":
|
||||
return icon + "✔︎"
|
||||
case "declined":
|
||||
return icon + "✘"
|
||||
case "needsAction":
|
||||
return icon + "?"
|
||||
case "tentative":
|
||||
return icon + "~"
|
||||
default:
|
||||
return icon + " "
|
||||
}
|
||||
}
|
||||
|
||||
func removeDeclined(events []*CalEvent) []*CalEvent {
|
||||
var ret []*CalEvent
|
||||
for _, e := range events {
|
||||
if e.ResponseFor(wtf.Config.UString("wtf.mods.gcal.email")) != "declined" {
|
||||
ret = append(ret, e)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
77
modules/gcal/widget.go
Normal file
77
modules/gcal/widget.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package gcal
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
"github.com/wtfutil/wtf/wtf"
|
||||
)
|
||||
|
||||
type Widget struct {
|
||||
wtf.TextWidget
|
||||
|
||||
calEvents []*CalEvent
|
||||
ch chan struct{}
|
||||
mutex sync.Mutex
|
||||
app *tview.Application
|
||||
}
|
||||
|
||||
func NewWidget(app *tview.Application) *Widget {
|
||||
widget := Widget{
|
||||
TextWidget: wtf.NewTextWidget(app, "Calendar", "gcal", true),
|
||||
ch: make(chan struct{}),
|
||||
app: app,
|
||||
}
|
||||
|
||||
go updateLoop(&widget)
|
||||
|
||||
return &widget
|
||||
}
|
||||
|
||||
/* -------------------- Exported Functions -------------------- */
|
||||
|
||||
func (widget *Widget) Disable() {
|
||||
close(widget.ch)
|
||||
widget.TextWidget.Disable()
|
||||
}
|
||||
|
||||
func (widget *Widget) Refresh() {
|
||||
if isAuthenticated() {
|
||||
widget.fetchAndDisplayEvents()
|
||||
return
|
||||
}
|
||||
widget.app.Suspend(authenticate)
|
||||
widget.Refresh()
|
||||
}
|
||||
|
||||
/* -------------------- Unexported Functions -------------------- */
|
||||
|
||||
func (widget *Widget) fetchAndDisplayEvents() {
|
||||
calEvents, err := Fetch()
|
||||
if err != nil {
|
||||
widget.calEvents = []*CalEvent{}
|
||||
} else {
|
||||
widget.calEvents = calEvents
|
||||
}
|
||||
widget.display()
|
||||
}
|
||||
|
||||
func updateLoop(widget *Widget) {
|
||||
interval := wtf.Config.UInt("wtf.mods.gcal.textInterval", 30)
|
||||
if interval == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tick := time.NewTicker(time.Duration(interval) * time.Second)
|
||||
defer tick.Stop()
|
||||
outer:
|
||||
for {
|
||||
select {
|
||||
case <-tick.C:
|
||||
widget.display()
|
||||
case <-widget.ch:
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user