dyzur-bot/sheet.go

137 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package dyzurbot
import (
"context"
"fmt"
"os"
"time"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
)
// Column indices in the "Dyżury" sheet (see the header in row 0).
const (
colWeek = 0 // "Tydzień zaczynający się od" — week start date (serial)
colPerson1 = 1 // "Osoba 1"
colPerson2 = 2 // "Osoba 2"
colName = 4 // "Lista osób"
colHandle = 5 // Telegram handle (e.g. "@ala")
colCount = 6 // "Liczba dyżurów"
)
// sheetEpoch is the date corresponding to serial number 0 in Google Sheets.
var sheetEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
// DutyWeek is a single week of the duty rota (columns AC).
type DutyWeek struct {
WeekStart time.Time // Monday that starts the week
Person1 string // Osoba 1 — "" if unassigned
Person2 string // Osoba 2 — "" if unassigned
}
// Person is a single entry from the people list (columns EG).
type Person struct {
Name string // Lista osób
Handle string // Telegram handle, verbatim from the sheet ("" if none)
DutyCount int // Liczba dyżurów
}
// Rota is the parsed sheet contents: the week schedule and the people list.
type Rota struct {
Weeks []DutyWeek
People []Person
}
// ReadSheet reads the given range from the sheet and returns its rows.
// spreadsheetID is the ID from the sheet URL, readRange e.g. "Dyżury!A1:C30".
// The API key is read from GOOGLE_API_KEY (loaded into the environment by
// LoadConfig, which calls godotenv.Load at startup).
func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interface{}, error) {
apiKey := os.Getenv("GOOGLE_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("missing GOOGLE_API_KEY in environment/.env")
}
srv, err := sheets.NewService(ctx, option.WithAPIKey(apiKey))
if err != nil {
return nil, fmt.Errorf("init sheets service: %w", err)
}
resp, err := srv.Spreadsheets.Values.
Get(spreadsheetID, readRange).
ValueRenderOption("UNFORMATTED_VALUE"). // raw values, without cell formatting
Context(ctx).
Do()
if err != nil {
return nil, fmt.Errorf("get values %q: %w", readRange, err)
}
return resp.Values, nil
}
// ParseRota interprets the raw rows from ReadSheet as two independent tables:
// the duty schedule (columns AC) and the people list (columns EG).
// The header row is skipped; malformed cells are ignored, not reported.
func ParseRota(rows [][]interface{}) Rota {
var rota Rota
for i, row := range rows {
if i == 0 {
continue // header
}
// Schedule: a row belongs to the weeks table if column A holds a
// date serial number.
if serial, ok := cellFloat(row, colWeek); ok {
rota.Weeks = append(rota.Weeks, DutyWeek{
WeekStart: serialToDate(serial),
Person1: cellString(row, colPerson1),
Person2: cellString(row, colPerson2),
})
}
// People list: add only non-empty entries (empty roster slots are
// skipped).
if name := cellString(row, colName); name != "" {
count, _ := cellFloat(row, colCount)
rota.People = append(rota.People, Person{
Name: name,
Handle: cellString(row, colHandle),
DutyCount: int(count),
})
}
}
return rota
}
// serialToDate converts a Google Sheets serial number to a date.
func serialToDate(serial float64) time.Time {
return sheetEpoch.AddDate(0, 0, int(serial))
}
// cellString returns the cell value as text, accounting for trailing empty
// cells that the API truncates. A missing cell yields "".
func cellString(row []interface{}, i int) string {
if i >= len(row) {
return ""
}
if s, ok := row[i].(string); ok {
return s
}
return ""
}
// cellFloat returns the numeric value of a cell. ok=false when the cell is
// missing or not a number (e.g. an empty string).
func cellFloat(row []interface{}, i int) (float64, bool) {
if i >= len(row) {
return 0, false
}
if f, ok := row[i].(float64); ok {
return f, true
}
return 0, false
}