Compare commits
12 Commits
79cf212eb6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf9f50f583 | ||
|
|
d17993453e | ||
|
|
572f129ddb | ||
|
|
d05789b36b | ||
|
|
5f38a36027 | ||
|
|
d7d888453b | ||
|
|
7d2b9764c3 | ||
|
|
aeb575e621 | ||
|
|
f1846ed93c | ||
|
|
ca20b2a052 | ||
|
|
4e85f48d9e | ||
|
|
8e7b4a4f5f |
91
.gcal.go
91
.gcal.go
@@ -1,91 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
"golang.org/x/oauth2/google"
|
|
||||||
"google.golang.org/api/calendar/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GCal struct {
|
|
||||||
httpc interface {
|
|
||||||
Do(*http.Request) (*http.Response, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGCal() *GCal {
|
|
||||||
return &GCal{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gcal *GCal) Login(ctx context.Context) error {
|
|
||||||
token, err := gcal.getToken(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
conf, err := gcal.oauth2Config()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
gcal.httpc = conf.Client(ctx, &token)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gcal *GCal) getToken(ctx context.Context) (oauth2.Token, error) {
|
|
||||||
var token oauth2.Token
|
|
||||||
if b, _ := os.ReadFile(gcal.tokenFile()); len(b) == 0 {
|
|
||||||
} else if err := json.Unmarshal(b, &token); err != nil {
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
token, err := gcal.newToken(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return token, err
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(token)
|
|
||||||
return token, os.WriteFile(gcal.tokenFile(), b, os.ModePerm)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gcal *GCal) newToken(ctx context.Context) (oauth2.Token, error) {
|
|
||||||
conf, err := gcal.oauth2Config()
|
|
||||||
if err != nil {
|
|
||||||
return oauth2.Token{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
u := conf.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
|
|
||||||
fmt.Printf("visit %s\n", u)
|
|
||||||
var code string
|
|
||||||
if _, err := fmt.Scan(&code); err != nil {
|
|
||||||
return oauth2.Token{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := conf.Exchange(ctx, code)
|
|
||||||
if err != nil {
|
|
||||||
return oauth2.Token{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return *token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gcal *GCal) tokenFile() string {
|
|
||||||
return path.Join(os.Getenv("HOME"), "timer.gcal")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gcal *GCal) oauth2Config() (*oauth2.Config, error) {
|
|
||||||
b, err := os.ReadFile(path.Join(os.Getenv("HOME"), ".config", "gcloud", "application_default_credentials.json"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
conf, err := google.ConfigFromJSON(b, calendar.CalendarReadonlyScope)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return conf, nil
|
|
||||||
}
|
|
||||||
158
gcal.go
Normal file
158
gcal.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
"google.golang.org/api/calendar/v3"
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GCal struct {
|
||||||
|
httpc *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGCal() *GCal {
|
||||||
|
return &GCal{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Name string
|
||||||
|
Time time.Time
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gcal *GCal) EventsToday(ctx context.Context) ([]Event, error) {
|
||||||
|
srv, err := calendar.NewService(ctx, option.WithHTTPClient(gcal.httpc))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().Add(-1 * time.Hour * time.Duration(time.Now().Hour()))
|
||||||
|
tomorrow := today.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
events, err := srv.Events.
|
||||||
|
List("primary").
|
||||||
|
ShowDeleted(false).
|
||||||
|
SingleEvents(true).
|
||||||
|
TimeMin(today.Format(time.RFC3339)).
|
||||||
|
MaxResults(100).
|
||||||
|
OrderBy("startTime").
|
||||||
|
Do()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]Event, 0, len(events.Items))
|
||||||
|
for i := range events.Items {
|
||||||
|
if events.Items[i].Start.DateTime == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains([]string{"focusTime", "outOfOffice"}, events.Items[i].EventType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, events.Items[i].Start.DateTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if t.After(tomorrow) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var d time.Duration
|
||||||
|
if t2, err := time.Parse(time.RFC3339, events.Items[i].End.DateTime); err == nil {
|
||||||
|
d = t2.Sub(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result,
|
||||||
|
Event{
|
||||||
|
Name: "Soon: " + events.Items[i].Summary,
|
||||||
|
Time: t.Add(-2 * time.Minute),
|
||||||
|
Duration: 0,
|
||||||
|
},
|
||||||
|
Event{
|
||||||
|
Name: "Now: " + events.Items[i].Summary,
|
||||||
|
Time: t,
|
||||||
|
Duration: d,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gcal *GCal) Login(ctx context.Context) error {
|
||||||
|
token, err := gcal.getToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
conf, err := gcal.oauth2Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcal.httpc = conf.Client(ctx, &token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gcal *GCal) getToken(ctx context.Context) (oauth2.Token, error) {
|
||||||
|
var token oauth2.Token
|
||||||
|
if b, _ := os.ReadFile(gcal.tokenFile()); len(b) == 0 {
|
||||||
|
} else if err := json.Unmarshal(b, &token); err == nil {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := gcal.newToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b, _ := json.Marshal(token)
|
||||||
|
return token, os.WriteFile(gcal.tokenFile(), b, os.ModePerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gcal *GCal) newToken(ctx context.Context) (oauth2.Token, error) {
|
||||||
|
conf, err := gcal.oauth2Config()
|
||||||
|
if err != nil {
|
||||||
|
return oauth2.Token{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u := conf.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
|
||||||
|
fmt.Printf("visit %s\n", u)
|
||||||
|
var code string
|
||||||
|
if _, err := fmt.Scan(&code); err != nil {
|
||||||
|
return oauth2.Token{}, fmt.Errorf("failed to read oauth confirmation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := conf.Exchange(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return oauth2.Token{}, fmt.Errorf("failed to use code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return *token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gcal *GCal) tokenFile() string {
|
||||||
|
return path.Join(os.Getenv("HOME"), "timer.gcal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (gcal *GCal) oauth2Config() (*oauth2.Config, error) {
|
||||||
|
p := path.Join(os.Getenv("HOME"), ".config", "gcloud", "calendar.json")
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read %s: %w", p, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conf, err := google.ConfigFromJSON(b, calendar.CalendarReadonlyScope)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse %s: %w", p, err)
|
||||||
|
}
|
||||||
|
return conf, nil
|
||||||
|
}
|
||||||
@@ -13,4 +13,13 @@ func TestGCal(t *testing.T) {
|
|||||||
if err := gcal.Login(context.Background()); err != nil {
|
if err := gcal.Login(context.Background()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
today, err := gcal.EventsToday(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else {
|
||||||
|
for i := range today {
|
||||||
|
t.Logf("[%d] %+v", i, today[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
171
main.go
171
main.go
@@ -2,41 +2,184 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gen2brain/beeep"
|
"github.com/gen2brain/beeep"
|
||||||
"github.com/schollz/progressbar/v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
d, err := time.ParseDuration(os.Args[1])
|
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
|
||||||
|
defer can()
|
||||||
|
|
||||||
|
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||||
|
gcal := fs.Bool("gcal", false, "wait for google cal events")
|
||||||
|
ntfy := fs.String("http", "", "https://squeaky2x3:dYbtypGkHXFtq1E00k2H42SPGUowi@ntfy.home.blapointe.com/alerts-render")
|
||||||
|
if err := fs.Parse(os.Args[1:]); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts, err := alerts(ctx, *gcal, fs.Args())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline := time.Now().Add(d)
|
for alert := range alerts {
|
||||||
ctx, can := context.WithDeadline(context.Background(), deadline)
|
if ctx.Err() != nil {
|
||||||
defer can()
|
break
|
||||||
|
}
|
||||||
|
if err := alertAt(ctx, *ntfy, time.Now(), alert, time.Now().Format("15:04")); err != nil && ctx.Err() == nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
n := int64(time.Until(deadline) / time.Second)
|
if err := ctx.Err(); err != nil {
|
||||||
bar := progressbar.Default(n)
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alerts(ctx context.Context, gcal bool, args []string) (chan string, error) {
|
||||||
|
if gcal {
|
||||||
|
return alertsGCal(ctx)
|
||||||
|
}
|
||||||
|
duration, err := time.ParseDuration(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msg := "alerting after " + duration.String()
|
||||||
|
if len(args) > 1 {
|
||||||
|
msg = fmt.Sprintf("%s (%s)", args[1], duration.String())
|
||||||
|
}
|
||||||
|
return alertsAfter(ctx, duration, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertsAfter(ctx context.Context, dur time.Duration, msg string) (chan string, error) {
|
||||||
|
ch := make(chan string)
|
||||||
|
deadline := time.Now().Add(dur)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
var prev string
|
||||||
|
for ctx.Err() == nil && time.Now().Before(deadline) {
|
||||||
|
seconds := int(time.Until(deadline).Seconds())
|
||||||
|
|
||||||
|
cur := fmt.Sprintf("%v", time.Duration(seconds)*time.Second)
|
||||||
|
if prev != "" {
|
||||||
|
fmt.Printf("\r%s\r", strings.Repeat(" ", len(prev)))
|
||||||
|
}
|
||||||
|
fmt.Printf("%s", cur)
|
||||||
|
prev = cur
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx.Err() == nil {
|
||||||
|
fmt.Println(msg)
|
||||||
|
}
|
||||||
|
ch <- msg
|
||||||
|
}()
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertsGCal(ctx context.Context) (chan string, error) {
|
||||||
|
gcal := NewGCal()
|
||||||
|
if err := gcal.Login(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var events []Event
|
||||||
|
refresh := func() error {
|
||||||
|
es, err := gcal.EventsToday(ctx)
|
||||||
|
es = slices.DeleteFunc(es, func(s Event) bool {
|
||||||
|
return time.Now().After(s.Time)
|
||||||
|
})
|
||||||
|
if !slices.Equal(es, events) {
|
||||||
|
events = es
|
||||||
|
if len(events) > 0 {
|
||||||
|
log.Println("alerting about", events[0].Name, "at", events[0].Time.Format("15:04"), "(", time.Until(events[0].Time), ")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := refresh(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan string)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
|
||||||
|
c := time.NewTicker(time.Minute * 13)
|
||||||
|
defer c.Stop()
|
||||||
|
for ctx.Err() == nil && len(events) > 0 {
|
||||||
|
select {
|
||||||
|
case <-c.C:
|
||||||
|
if err := refresh(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Until(events[0].Time)):
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case ch <- events[0].Name:
|
||||||
|
if events[0].Duration > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-time.After(events[0].Duration):
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case ch <- "/" + events[0].Name:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events = events[1:]
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func alertAt(ctx context.Context, ntfy string, deadline time.Time, title, msg string) error {
|
||||||
c := time.NewTicker(time.Second)
|
c := time.NewTicker(time.Second)
|
||||||
defer c.Stop()
|
defer c.Stop()
|
||||||
func() {
|
func() {
|
||||||
for {
|
ctx, can := context.WithDeadline(ctx, deadline)
|
||||||
|
defer can()
|
||||||
|
for ctx.Err() == nil {
|
||||||
select {
|
select {
|
||||||
case <-c.C:
|
case <-c.C:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bar.Add(1)
|
fmt.Printf("\r%s ", deadline.Sub(time.Now()))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
bar.Finish()
|
fmt.Println()
|
||||||
bar.Exit()
|
|
||||||
|
|
||||||
msg := "alerting after " + d.String()
|
if err := ctx.Err(); err == nil {
|
||||||
beeep.Alert(os.Args[0], msg, "/dev/null")
|
beeep.Alert(title, msg, "/dev/null")
|
||||||
time.Sleep(time.Second)
|
if ntfy != "" {
|
||||||
|
u, _ := url.Parse(ntfy)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(fmt.Sprintf("%s: %s", title, msg)))
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user