diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f3726f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +cache.gob +prayercal +.env +prayercal.ics diff --git a/main.go b/main.go new file mode 100644 index 0000000..99f9929 --- /dev/null +++ b/main.go @@ -0,0 +1,192 @@ +package main + +import ( + "io" + "log" + "net/http" + "os" + "encoding/json" + "github.com/joho/godotenv" + + "time" + "encoding/gob" + "bytes" + + "fmt" + "sync" + _ "time/tzdata" + + ics "github.com/arran4/golang-ical" +) + +type prayerTimes struct { + FetchedAt int64 + Times map[string]string +} + +func main() { + // load the env and check for API key, but its ok if there is no .env as there is (hopefully) env variables in the envrionment itself or whatever + _ = godotenv.Load() + var mu sync.Mutex + + apiKey := os.Getenv("LPT_API_KEY") + if apiKey == "" { + log.Fatal("LPT_API_KEY is not set") + } + url := "https://www.londonprayertimes.com/api/times/?format=json&24hours=true&key=" + apiKey + + // times := getTimes(url, "./cache.gob") + // example {1791571473 map[Asr:15:43 Dhuhr:12:53 Fajr:05:41 Isha:19:45 Maghrib:18:24 Sunrise:07:11]} + + loc, err := time.LoadLocation("Europe/London") + time.Local = loc + if err != nil { + log.Fatal(err) + } + + http.HandleFunc("/prayercal.ics", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + times, err := getTimes(url, "/data/cache.gob") + mu.Unlock() + if err != nil { + log.Println(err) + http.Error(w, "upstream error", http.StatusBadGateway) + return + } + + out, err := buildCal(times, loc) + if err != nil { + log.Println(err) + http.Error(w, "bad prayer data", http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", "text/calendar; charset=utf-8") + io.WriteString(w, out) + }) + log.Fatal(http.ListenAndServe(":5543", nil)) +} + +func buildCal(times prayerTimes, loc *time.Location) (string, error) { + cal := ics.NewCalendar() + cal.SetMethod(ics.MethodPublish) + + for _, name := range prayerNames { + start, err := parseToday(times.Times[name], loc) + if err != nil { + return "", fmt.Errorf("%s: %w", name, err) + } + + ev := cal.AddEvent(name + "-" + start.Format("20060102") + "@prayercal") + ev.SetCreatedTime(time.Now()) + ev.SetDtStampTime(time.Now()) + ev.SetModifiedAt(time.Now()) + ev.SetStartAt(start) + ev.SetEndAt(start.Add(20 * time.Minute)) + ev.SetSummary(name) + ev.SetDescription(name + " prayer time") + + for _, a := range []struct{ trigger, desc string }{ + {"-PT15M", name + " in 15 minutes"}, + {"PT0S", name + " time now"}, + } { + alarm := ev.AddAlarm() + alarm.SetAction(ics.ActionDisplay) + alarm.SetTrigger(a.trigger) + alarm.SetDescription(a.desc) + } + } + + return cal.Serialize(), nil +} + +var prayerNames = []string{"Fajr", "Sunrise", "Dhuhr", "Asr", "Maghrib", "Isha"} + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +func parseToday(hhmm string, loc *time.Location) (time.Time, error) { + t, err := time.Parse("15:04", hhmm) + if err != nil { + return time.Time{}, fmt.Errorf("bad time %q: %w", hhmm, err) + } + now := time.Now().In(loc) + return time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, loc), nil +} + +func readCache(path string) (prayerTimes, bool) { + file, err := os.Open(path) + if err != nil { + if !os.IsNotExist(err) { + log.Println("cache open error:", err) + } + return prayerTimes{}, false + } + defer file.Close() + + var cached prayerTimes + if err := gob.NewDecoder(file).Decode(&cached); err != nil { + log.Println("cache decode error, refetching:", err) + return prayerTimes{}, false + } + y1, m1, d1 := time.Unix(cached.FetchedAt, 0).Date() + y2, m2, d2 := time.Now().Date() + return cached, y1 == y2 && m1 == m2 && d1 == d2 +} + +func getTimes(url, cache string) (prayerTimes, error) { + if cached, ok := readCache(cache); ok { + log.Println("cache hit") + return cached, nil + } + log.Println("cache miss") + + resp, err := httpClient.Get(url) + if err != nil { + return prayerTimes{}, fmt.Errorf("fetch: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return prayerTimes{}, fmt.Errorf("fetch: status %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return prayerTimes{}, fmt.Errorf("read body: %w", err) + } + + var raw map[string]string + if err := json.Unmarshal(body, &raw); err != nil { + return prayerTimes{}, fmt.Errorf("unmarshal: %w", err) + } + + form := prayerTimes{ + FetchedAt: time.Now().Unix(), + Times: map[string]string{ + "Fajr": raw["fajr"], + "Sunrise": raw["sunrise"], + "Dhuhr": raw["dhuhr"], + "Asr": raw["asr"], + "Maghrib": raw["magrib"], + "Isha": raw["isha"], + }, + } + + // don't cache a response with missing fields + for _, name := range prayerNames { + if form.Times[name] == "" { + return prayerTimes{}, fmt.Errorf("api response missing %s", name) + } + } + + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(form); err != nil { + log.Println("cache encode error:", err) // still serve the data + return form, nil + } + if err := os.WriteFile(cache, buf.Bytes(), 0644); err != nil { + log.Println("cache write error:", err) + } + + return form, nil +} \ No newline at end of file