Compare commits

...

10 Commits

Author SHA1 Message Date
Bel LaPointe
cf9f50f583 only enough 2025-10-03 13:47:33 -06:00
Bel LaPointe
d17993453e alert at event START 2025-06-23 14:23:18 -06:00
Bel LaPointe
572f129ddb less 2025-06-23 12:26:10 -06:00
Bel LaPointe
d05789b36b better 2025-05-14 09:19:45 -04:00
Bel LaPointe
5f38a36027 exit with err if cancelled and newline 2025-05-14 09:15:37 -04:00
Bel LaPointe
d7d888453b shhh 2025-05-14 09:13:16 -04:00
Bel LaPointe
7d2b9764c3 no alert on focusTime, outOfOffice 2025-05-06 21:18:18 -06:00
Bel LaPointe
aeb575e621 alert at end of event too 2025-02-24 12:30:46 -07:00
Bel LaPointe
f1846ed93c gcal refreshes next event list every 13m 2025-02-05 15:37:28 -07:00
Bel LaPointe
ca20b2a052 gr 2024-10-31 14:26:43 -06:00
3 changed files with 150 additions and 43 deletions

26
gcal.go
View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"os" "os"
"path" "path"
"slices"
"time" "time"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -26,6 +27,7 @@ func NewGCal() *GCal {
type Event struct { type Event struct {
Name string Name string
Time time.Time Time time.Time
Duration time.Duration
} }
func (gcal *GCal) EventsToday(ctx context.Context) ([]Event, error) { func (gcal *GCal) EventsToday(ctx context.Context) ([]Event, error) {
@@ -36,6 +38,7 @@ func (gcal *GCal) EventsToday(ctx context.Context) ([]Event, error) {
today := time.Now().Add(-1 * time.Hour * time.Duration(time.Now().Hour())) today := time.Now().Add(-1 * time.Hour * time.Duration(time.Now().Hour()))
tomorrow := today.Add(24 * time.Hour) tomorrow := today.Add(24 * time.Hour)
events, err := srv.Events. events, err := srv.Events.
List("primary"). List("primary").
ShowDeleted(false). ShowDeleted(false).
@@ -53,16 +56,33 @@ func (gcal *GCal) EventsToday(ctx context.Context) ([]Event, error) {
if events.Items[i].Start.DateTime == "" { if events.Items[i].Start.DateTime == "" {
continue continue
} }
if slices.Contains([]string{"focusTime", "outOfOffice"}, events.Items[i].EventType) {
continue
}
t, err := time.Parse(time.RFC3339, events.Items[i].Start.DateTime) t, err := time.Parse(time.RFC3339, events.Items[i].Start.DateTime)
if err != nil { if err != nil {
return nil, err return nil, err
} else if t.After(tomorrow) { } else if t.After(tomorrow) {
continue continue
} }
result = append(result, Event{
Name: events.Items[i].Summary, 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, Time: t,
}) Duration: d,
},
)
} }
return result, nil return result, nil
} }

View File

@@ -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])
}
}
} }

144
main.go
View File

@@ -9,12 +9,12 @@ import (
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"slices"
"strings" "strings"
"syscall" "syscall"
"time" "time"
"github.com/gen2brain/beeep" "github.com/gen2brain/beeep"
"github.com/schollz/progressbar/v3"
) )
func main() { func main() {
@@ -28,70 +28,148 @@ func main() {
panic(err) panic(err)
} }
if *gcal { alerts, err := alerts(ctx, *gcal, fs.Args())
if err := alertGCal(ctx, *ntfy); err != nil { if err != nil {
panic(err) panic(err)
} }
} else {
if err := alertAfter(ctx, *ntfy, fs.Args()[0]); err != nil { for alert := range alerts {
if ctx.Err() != nil {
break
}
if err := alertAt(ctx, *ntfy, time.Now(), alert, time.Now().Format("15:04")); err != nil && ctx.Err() == nil {
panic(err) panic(err)
} }
if ctx.Err() != nil {
break
} }
} }
func alertGCal(ctx context.Context, ntfy string) error { if err := ctx.Err(); err != nil {
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() gcal := NewGCal()
if err := gcal.Login(ctx); err != nil { 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 return err
} }
if err := refresh(); err != nil {
events, err := gcal.EventsToday(ctx) return nil, err
if err != nil {
return err
} }
for _, event := range events { ch := make(chan string)
until := event.Time.Add(-1 * time.Minute) go func() {
if until.Sub(time.Now()) < 1 { defer close(ch)
continue
}
log.Println("alerting about", event.Name, "at", until.Format("15:04"), "(", time.Until(until), ")") c := time.NewTicker(time.Minute * 13)
if err := alertAt(ctx, ntfy, until, event.Name, until.Format("15:04")); err != nil { defer c.Stop()
return err 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 nil return ch, nil
}
func alertAfter(ctx context.Context, ntfy string, duration string) error {
d, err := time.ParseDuration(duration)
if err != nil {
return err
}
return alertAt(ctx, ntfy, time.Now().Add(d), os.Args[0], "alerting after "+d.String())
} }
func alertAt(ctx context.Context, ntfy string, deadline time.Time, title, msg string) error { func alertAt(ctx context.Context, ntfy string, deadline time.Time, title, msg string) error {
n := int64(time.Until(deadline) / time.Second)
bar := progressbar.Default(n)
c := time.NewTicker(time.Second) c := time.NewTicker(time.Second)
defer c.Stop() defer c.Stop()
func() { func() {
ctx, can := context.WithDeadline(ctx, deadline) ctx, can := context.WithDeadline(ctx, deadline)
defer can() defer can()
for { 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()
if err := ctx.Err(); err == nil { if err := ctx.Err(); err == nil {
beeep.Alert(title, msg, "/dev/null") beeep.Alert(title, msg, "/dev/null")