feat(gcal): Google Calendar kan læses, og kalenderen får sine egne tabeller

Klienten henter kalenderlisten og begivenhederne i et vindue, og laver Googles
to former om til den ene, et gitter kan spørge om: dage i begge ender, begge
med. Googles heldags-slutdato er eksklusiv, og et møde, der slutter ved midnat,
slutter aftenen før — begge dele rettes ét sted frem for hos hver læser.

Egne tabeller frem for `mailboxes`. De to ligner hinanden på afstand og er
uenige om hver eneste kolonne bagefter: `trigger_kind`, `seen`, `last_uid` og
`project_id` svarer alle på "hvad laver en besked om til en opgave", og intet
her bliver til en opgave. Én konto rummer desuden mange kalendere, og en
en-til-mange er der ikke plads til. Det afgørende er mindre og mere praktisk:
`mailboxes.kind` har en CHECK, og SQLite kan ikke ændre en CHECK — en tredje
værdi koster en tabelombygning med fremmednøgler slået fra. Derfor er der ingen
CHECK på `provider` her: CalDAV skal koste en række, ikke en ombygning.
This commit is contained in:
Kristian Wind
2026-08-20 06:50:57 +02:00
parent a46d9158e1
commit 7828c1e6ed
5 changed files with 1432 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
// Package gcal reads a Google Calendar. It never writes one.
//
// The scope is calendar.readonly, and that is the whole of the decision: verdande
// already is a CalDAV server, so a Google event shown here is a second calendar
// laid over the first, not a copy Google and verdande now both have to agree
// about. Writing needs `calendar.events`, and it needs an answer to what happens
// when both sides moved the same meeting — which is a synchronisation model, not a
// scope. Read first; write later and to one calendar at a time.
//
// What the API gives back and what this returns are deliberately different shapes.
// Google answers with RFC 3339 instants for a timed event and bare dates for an
// all-day one, and with an *exclusive* end date for the second. A calendar grid
// asks one question — "which cells does this event cover" — so that is what an
// Event answers, in days, worked out once here rather than in every caller.
package gcal
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/kristianwind/verdande/internal/google"
)
// Scope is read-only, and only the calendar.
//
// Google also offers calendar.calendarlist.readonly and calendar.events.readonly,
// which together are strictly less than this — they leave out access rules and
// account settings, which verdande has no use for. They are the tighter pair and
// worth moving to; they are also newer, and this one has been documented and
// accepted unchanged for a decade. A consent screen that fails with `invalid_scope`
// is not a place to be adventurous on somebody else's Google account.
const Scope = "https://www.googleapis.com/auth/calendar.readonly"
const defaultAPIBase = "https://www.googleapis.com/calendar/v3"
// ErrUnauthorized means the tokens are no longer good — access was revoked, or the
// password changed. Re-exported so a caller need not know the flow lives elsewhere.
var ErrUnauthorized = google.ErrUnauthorized
type Client struct {
accessToken string
// base is where the Calendar API is. A field rather than a constant so a test
// can point it at a server it controls; empty is Google. There is no other way
// to exercise a calendar that is slow, empty, or refuses.
base string
http *http.Client
}
func NewClient(accessToken string) *Client {
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
}
// At points the client at another server. Tests only; the zero value is Google.
func (c *Client) At(base string) *Client {
c.base = base
return c
}
func (c *Client) api() string {
if c.base != "" {
return c.base
}
return defaultAPIBase
}
// Calendar is one entry in the person's calendar list.
type Calendar struct {
ID string
// Name is the summary Google holds. A calendar somebody renamed for themselves
// carries summaryOverride instead, and that is the name they will look for.
Name string
// Colour is Google's own hex value for the calendar, which is what makes two
// calendars in one grid tellable apart. Their own colour rather than one picked
// here: it is the colour they already know the calendar by.
Colour string
TimeZone string
Primary bool
// Owner means the connected account can write to it. Nothing writes today —
// it is stored so the day something does, the interface knows which calendars
// could even be offered.
Owner bool
}
// Calendars lists what the connected account can see.
func (c *Client) Calendars(ctx context.Context) ([]Calendar, error) {
var out []Calendar
page := ""
// Bounded rather than "until Google stops": a page token that never empties is
// a loop that never ends, and the difference between a bug at Google and a
// hung sync should not be this server hanging.
for range 10 {
v := url.Values{}
v.Set("minAccessRole", "reader")
v.Set("maxResults", "250")
if page != "" {
v.Set("pageToken", page)
}
var parsed struct {
Items []struct {
ID string `json:"id"`
Summary string `json:"summary"`
SummaryOverride string `json:"summaryOverride"`
BackgroundColor string `json:"backgroundColor"`
TimeZone string `json:"timeZone"`
Primary bool `json:"primary"`
AccessRole string `json:"accessRole"`
Deleted bool `json:"deleted"`
} `json:"items"`
NextPageToken string `json:"nextPageToken"`
}
if err := c.get(ctx, c.api()+"/users/me/calendarList?"+v.Encode(), &parsed); err != nil {
return nil, err
}
for _, item := range parsed.Items {
if item.Deleted {
continue
}
name := item.SummaryOverride
if name == "" {
name = item.Summary
}
out = append(out, Calendar{
ID: item.ID, Name: name, Colour: item.BackgroundColor,
TimeZone: item.TimeZone, Primary: item.Primary,
Owner: item.AccessRole == "owner" || item.AccessRole == "writer",
})
}
if parsed.NextPageToken == "" {
break
}
page = parsed.NextPageToken
}
return out, nil
}
// Event is one occurrence, in the shape a calendar grid asks about.
type Event struct {
ID string
Summary string
// StartsAt and EndsAt are RFC 3339 for a timed event and empty for an all-day
// one — an all-day event has no instant, only a day, and inventing midnight for
// it is how an event moves a day every time a server changes time zone.
StartsAt string
EndsAt string
// StartDay and EndDay are the first and last day the event covers, inclusive at
// both ends. Google's all-day end date is exclusive; that is corrected here,
// once, rather than in every reader.
StartDay string
EndDay string
AllDay bool
Location string
// URL opens the event in Google's own interface. Same reasoning as the Gmail
// link on a task made from a mail: what verdande shows is a pointer to the
// event, not a second copy of it that somebody could try to edit.
URL string
}
// Events lists what falls inside a window, one calendar at a time.
//
// `singleEvents` expands a recurrence into its occurrences, which is the only form
// a grid can draw: an RRULE is an instruction, and a Tuesday cell needs the Tuesday
// it produced. It also means verdande does not have to grow a second recurrence
// engine that agrees with Google's about Danish summer time.
//
// from and to are dates, inclusive. The window is the caller's business; this
// turns it into the half-open interval Google wants.
func (c *Client) Events(ctx context.Context, calendarID, from, to string) ([]Event, error) {
start, err := time.Parse(dateLayout, from)
if err != nil {
return nil, fmt.Errorf("gcal: from: %w", err)
}
end, err := time.Parse(dateLayout, to)
if err != nil {
return nil, fmt.Errorf("gcal: to: %w", err)
}
var out []Event
page := ""
for range 20 {
v := url.Values{}
v.Set("singleEvents", "true")
v.Set("orderBy", "startTime")
v.Set("maxResults", "2500")
// UTC, and a day of slack at each end. An event at 23:30 on the last day in
// Copenhagen is 21:30 UTC that day, but one at 00:30 on the first day is
// 22:30 UTC the day *before* — so a window cut exactly at the requested
// dates loses the events at both edges. The extra day is thrown away again
// by the day fields below, which are computed from the event's own offset.
v.Set("timeMin", start.AddDate(0, 0, -1).Format(time.RFC3339))
v.Set("timeMax", end.AddDate(0, 0, 2).Format(time.RFC3339))
if page != "" {
v.Set("pageToken", page)
}
var parsed struct {
Items []rawEvent `json:"items"`
NextPageToken string `json:"nextPageToken"`
}
url := c.api() + "/calendars/" + url.PathEscape(calendarID) + "/events?" + v.Encode()
if err := c.get(ctx, url, &parsed); err != nil {
return nil, err
}
for _, item := range parsed.Items {
// A cancelled occurrence of a recurring event still comes back, so that
// a client keeping its own copy knows to drop it. verdande replaces the
// window wholesale, so it only has to not add it.
if item.Status == "cancelled" {
continue
}
event, ok := item.event()
if !ok {
continue
}
if event.EndDay < from || event.StartDay > to {
continue // the slack above, thrown away
}
out = append(out, event)
}
if parsed.NextPageToken == "" {
break
}
page = parsed.NextPageToken
}
return out, nil
}
const dateLayout = "2006-01-02"
type rawEvent struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Location string `json:"location"`
HTMLLink string `json:"htmlLink"`
Start when `json:"start"`
End when `json:"end"`
}
type when struct {
Date string `json:"date"`
DateTime string `json:"dateTime"`
}
// event turns Google's two shapes into the one a grid can read.
//
// The second return is false for an event with neither a date nor a dateTime —
// which Google does return, for an event somebody is still being asked about. It
// belongs on no day, so there is no cell to draw it in.
func (r rawEvent) event() (Event, bool) {
e := Event{
ID: r.ID, Summary: strings.TrimSpace(r.Summary),
Location: r.Location, URL: r.HTMLLink,
}
if e.Summary == "" {
// Google's own interface says this for an event with no title, and a blank
// chip in a grid reads as a rendering fault rather than as an empty title.
e.Summary = "(uden titel)"
}
switch {
case r.Start.Date != "":
e.AllDay = true
e.StartDay = r.Start.Date
// Google's all-day end is the day *after* the last one, so a one-day event
// ends tomorrow. Subtracting a day is what makes "covers this cell" a
// comparison rather than a special case at every call site.
e.EndDay = r.Start.Date
if day, err := time.Parse(dateLayout, r.End.Date); err == nil {
if last := day.AddDate(0, 0, -1); !last.Before(mustDay(r.Start.Date)) {
e.EndDay = last.Format(dateLayout)
}
}
case r.Start.DateTime != "":
e.StartsAt = r.Start.DateTime
e.EndsAt = r.End.DateTime
// The day is the date part of the timestamp Google returned, which carries
// the event's own offset. Parsing it into a time.Time and formatting it
// back would ask *this process* what day it is — and a container running in
// UTC would then move a 23:30 meeting in Copenhagen to the day before.
e.StartDay = dayOf(r.Start.DateTime)
e.EndDay = dayOf(r.End.DateTime)
if e.EndDay == "" {
e.EndDay = e.StartDay
}
// An event ending at midnight ends the evening before, as far as a grid is
// concerned. Without this, every meeting that runs to 24:00 puts a chip in
// the following day as well.
if e.EndDay > e.StartDay && strings.Contains(r.End.DateTime, "T00:00:00") {
if day, err := time.Parse(dateLayout, e.EndDay); err == nil {
e.EndDay = day.AddDate(0, 0, -1).Format(dateLayout)
}
}
default:
return Event{}, false
}
if e.EndDay < e.StartDay {
e.EndDay = e.StartDay
}
return e, true
}
// dayOf takes the date out of an RFC 3339 timestamp without interpreting it.
func dayOf(stamp string) string {
if len(stamp) < len(dateLayout) {
return ""
}
day := stamp[:len(dateLayout)]
if _, err := time.Parse(dateLayout, day); err != nil {
return ""
}
return day
}
func mustDay(s string) time.Time {
day, _ := time.Parse(dateLayout, s)
return day
}
func (c *Client) get(ctx context.Context, url string, out any) error {
return google.Get(ctx, c.http, c.accessToken, url, out)
}
+255
View File
@@ -0,0 +1,255 @@
package gcal
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeGoogle answers the two calls this package makes, out of whatever the test
// hands it. There is no other way to exercise this: the real Calendar needs an
// account, a registered client and somebody's consent, and none of the three can be
// had inside a test — so the shapes Google returns are written down here and the
// parsing is checked against them.
func fakeGoogle(t *testing.T, calendarList any, events map[string]any) *Client {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"):
_ = json.NewEncoder(w).Encode(calendarList)
case strings.HasPrefix(r.URL.Path, "/calendars/"):
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events")
body, ok := events[id]
if !ok {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(body)
default:
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return NewClient("token").At(srv.URL)
}
func TestCalendarsPrefersTheNameThePersonGaveIt(t *testing.T) {
client := fakeGoogle(t, map[string]any{
"items": []map[string]any{
{"id": "primary", "summary": "kw@nolimit.dk", "backgroundColor": "#3f51b5",
"timeZone": "Europe/Copenhagen", "primary": true, "accessRole": "owner"},
{"id": "fam", "summary": "Family", "summaryOverride": "Familien",
"backgroundColor": "#0b8043", "accessRole": "reader"},
// A calendar removed from the list still arrives, flagged.
{"id": "gone", "summary": "Væk", "deleted": true, "accessRole": "reader"},
},
}, nil)
got, err := client.Calendars(t.Context())
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d calendars, want 2 — a deleted one is not one you can show", len(got))
}
if got[1].Name != "Familien" {
t.Errorf("name = %q; summaryOverride is the name the person will look for", got[1].Name)
}
if !got[0].Primary || !got[0].Owner {
t.Errorf("the primary calendar came back as %+v", got[0])
}
if got[1].Owner {
t.Error("a reader was reported as an owner; nothing may offer to write to it")
}
if got[0].Colour != "#3f51b5" {
t.Errorf("colour = %q; two calendars in one grid are told apart by it", got[0].Colour)
}
}
// The three shapes an event arrives in, and the one shape a grid can read.
func TestEventsBecomeDaysAGridCanAskAbout(t *testing.T) {
client := fakeGoogle(t, nil, map[string]any{
"primary": map[string]any{
"items": []map[string]any{
{
"id": "timed", "summary": "Bestyrelsesmøde", "htmlLink": "https://cal/1",
"start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"},
"end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"},
},
{
// All-day, one day. Google's end is the day after.
"id": "allday", "summary": "Fridag",
"start": map[string]string{"date": "2026-08-21"},
"end": map[string]string{"date": "2026-08-22"},
},
{
// All-day, three days.
"id": "trip", "summary": "Aarhus",
"start": map[string]string{"date": "2026-08-24"},
"end": map[string]string{"date": "2026-08-27"},
},
{
// Runs to midnight, which is the evening before as far as a
// grid is concerned.
"id": "late", "summary": "Nattevagt",
"start": map[string]string{"dateTime": "2026-08-22T20:00:00+02:00"},
"end": map[string]string{"dateTime": "2026-08-23T00:00:00+02:00"},
},
{
// A cancelled occurrence of a recurring event.
"id": "off", "status": "cancelled", "summary": "Aflyst",
"start": map[string]string{"date": "2026-08-25"},
"end": map[string]string{"date": "2026-08-26"},
},
{
// Neither a date nor a dateTime: belongs on no day at all.
"id": "nowhere", "summary": "Uden tid",
},
},
},
})
got, err := client.Events(t.Context(), "primary", "2026-08-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
byID := map[string]Event{}
for _, e := range got {
byID[e.ID] = e
}
if len(got) != 4 {
t.Fatalf("got %d events, want 4: %+v", len(got), got)
}
if _, ok := byID["off"]; ok {
t.Error("a cancelled occurrence was kept; it is an event that is not happening")
}
if _, ok := byID["nowhere"]; ok {
t.Error("an event with no time was kept; there is no cell to draw it in")
}
timed := byID["timed"]
if timed.AllDay || timed.StartDay != "2026-08-20" || timed.EndDay != "2026-08-20" {
t.Errorf("timed = %+v", timed)
}
if timed.StartsAt != "2026-08-20T14:00:00+02:00" {
t.Errorf("the instant was rewritten: %q", timed.StartsAt)
}
allday := byID["allday"]
if !allday.AllDay || allday.StartDay != "2026-08-21" || allday.EndDay != "2026-08-21" {
t.Errorf("allday = %+v; Google's end date is exclusive and has to be corrected here",
allday)
}
if allday.StartsAt != "" {
t.Errorf("an all-day event was given an instant: %q — it has a day, not a moment",
allday.StartsAt)
}
if trip := byID["trip"]; trip.StartDay != "2026-08-24" || trip.EndDay != "2026-08-26" {
t.Errorf("trip = %+v, want 24th to 26th inclusive", trip)
}
if late := byID["late"]; late.EndDay != "2026-08-22" {
t.Errorf("late ends on %q; an event running to midnight ends the evening before, "+
"or every one of them puts a chip in the following day", late.EndDay)
}
}
// An event with no title has to say so. A blank chip in a grid reads as a
// rendering fault rather than as an empty title.
func TestAnEventWithNoTitleStillSaysSomething(t *testing.T) {
client := fakeGoogle(t, nil, map[string]any{
"c": map[string]any{"items": []map[string]any{{
"id": "x",
"start": map[string]string{"date": "2026-08-20"},
"end": map[string]string{"date": "2026-08-21"},
}}},
})
got, err := client.Events(t.Context(), "c", "2026-08-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Summary == "" {
t.Fatalf("got %+v", got)
}
}
// The window is asked for with a day of slack at each end, because an event late
// in the evening in Copenhagen is the next day in UTC. The slack must not leak
// out: an event on the day before the window is not in the window.
func TestTheSlackAtTheEdgesIsThrownAwayAgain(t *testing.T) {
client := fakeGoogle(t, nil, map[string]any{
"c": map[string]any{"items": []map[string]any{
{"id": "before", "summary": "For tidligt",
"start": map[string]string{"date": "2026-08-09"},
"end": map[string]string{"date": "2026-08-10"}},
{"id": "inside", "summary": "Med",
"start": map[string]string{"date": "2026-08-10"},
"end": map[string]string{"date": "2026-08-11"}},
{"id": "after", "summary": "For sent",
"start": map[string]string{"date": "2026-08-21"},
"end": map[string]string{"date": "2026-08-22"}},
}},
})
got, err := client.Events(t.Context(), "c", "2026-08-10", "2026-08-20")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != "inside" {
t.Fatalf("got %+v, want only the one inside the window", got)
}
}
// A page token has to be followed, or a busy calendar silently shows its first
// page and nothing else — which is a calendar that lies about a day being clear.
func TestEveryPageIsRead(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body := map[string]any{
"items": []map[string]any{{"id": "a", "summary": "Første",
"start": map[string]string{"date": "2026-08-11"},
"end": map[string]string{"date": "2026-08-12"}}},
"nextPageToken": "more",
}
if r.URL.Query().Get("pageToken") == "more" {
body = map[string]any{"items": []map[string]any{{"id": "b", "summary": "Anden",
"start": map[string]string{"date": "2026-08-12"},
"end": map[string]string{"date": "2026-08-13"}}}}
}
_ = json.NewEncoder(w).Encode(body)
}))
defer srv.Close()
got, err := NewClient("t").At(srv.URL).Events(t.Context(), "c", "2026-08-01", "2026-08-31")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d events, want both pages: %+v", len(got), got)
}
}
// A 401 is not a failure to retry; it is a connection that has ended. Telling the
// two apart is what stops a revoked account being polled every ten minutes for ever.
func TestARefusedTokenIsItsOwnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized)
}))
defer srv.Close()
_, err := NewClient("spent").At(srv.URL).Calendars(t.Context())
if err != ErrUnauthorized {
t.Fatalf("err = %v, want ErrUnauthorized", err)
}
}
// Read-only, and only the calendar. The consent screen is what somebody agrees to,
// and a scope that quietly grew is a promise quietly broken.
func TestTheScopeIsReadOnly(t *testing.T) {
if !strings.HasSuffix(Scope, "calendar.readonly") {
t.Errorf("scope = %q; nothing here writes a calendar", Scope)
}
}