From 66a2db7be1beb943c2307bae15c3ed0a239fa488 Mon Sep 17 00:00:00 2001 From: Jordan Del Pilar Date: Tue, 14 Jul 2026 11:14:46 -0700 Subject: [PATCH] initial commit --- .gitignore | 75 +++++++++++++++++++++++++++++++++++++++++++++++ api.go | 50 +++++++++++++++++++++++++++++++ data.go | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 ++ main.go | 53 +++++++++++++++++++++++++++++++++ notfiy.go | 37 +++++++++++++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 .gitignore create mode 100644 api.go create mode 100644 data.go create mode 100644 go.mod create mode 100644 main.go create mode 100644 notfiy.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b942b5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go,linux,windows +# Edit at https://www.toptal.com/developers/gitignore?templates=go,linux,windows + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/go,linux,windows + +# Env files +.env + +# Run Files +sunset.json +sunset.txt diff --git a/api.go b/api.go new file mode 100644 index 0000000..2056a35 --- /dev/null +++ b/api.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +type ResponseData struct { + SunsetTime time.Time `json:"sunset"` +} + +func getSunsetData(lat string, lng string) (*ResponseData, error) { + u, _ := url.Parse("https://api.sunrise-sunset.org/v2") + + q := u.Query() + q.Set("lat", lat) + q.Set("lng", lng) + q.Set("formatted", "0") + + u.RawQuery = q.Encode() + + URL := u.String() + + r, err := http.Get(URL) + if err != nil { + return nil, err + } + defer r.Body.Close() + + if r.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad status: %d", r.StatusCode) + } + + var apiResponse struct { + SunsetTime string `json:"sunset"` + } + + if err := json.NewDecoder(r.Body).Decode(&apiResponse); err != nil { + return nil, err + } + + parsedTime, err := time.Parse(time.RFC3339, apiResponse.SunsetTime) + if err != nil { + return nil, err + } + return &ResponseData{SunsetTime: parsedTime}, nil +} diff --git a/data.go b/data.go new file mode 100644 index 0000000..a3bac09 --- /dev/null +++ b/data.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +type Data struct { + SunsetTime time.Time `json:"sunset"` + TenMinuteWarning time.Time `json:"ten_minute_warning"` + LastUpdated time.Time `json:"last_updated"` + MorningNotifySent bool `json:"morning_notify_sent"` + TenMinuteWarningSent bool `json:"ten_minute_warning_sent"` +} + +func NewData() *Data { + return &Data{ + MorningNotifySent: false, + TenMinuteWarningSent: false, + } +} + +func loadData(path string) (*Data, error) { + state, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return NewData(), nil + } else { + return nil, err + } + } + if len(state) == 0 { + return NewData(), nil + } + + var d Data + err = json.Unmarshal(state, &d) + if err != nil { + return nil, err + } + return &d, err +} + +func (d *Data) Save(path string) error { + b, err := json.Marshal(d) + if err != nil { + return err + } + + tmpPath := path + ".path" + defer os.Remove(tmpPath) + + if err := os.WriteFile(tmpPath, b, 0644); err != nil { + return err + } + + if err := os.Rename(tmpPath, path); err != nil { + return err + } + + return nil +} + +func (d *Data) HandleSunsetNotifications(path string) error { + if !d.MorningNotifySent { + t := d.SunsetTime.Format("15:04") + err := SendNotification(fmt.Sprintf("Sunset is at %s", t)) + if err != nil { + return fmt.Errorf("Unable to send notification | %s", err) + } + d.MorningNotifySent = true + d.Save(path) + } + + if time.Now().After(d.TenMinuteWarning) && !d.TenMinuteWarningSent { + err := SendNotification("Sunset is Approaching!") + if err != nil { + return fmt.Errorf("Unable to send notification | %s", err) + } + d.TenMinuteWarningSent = true + d.Save(path) + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e2d9600 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.delpilar.net/jdelpilar/sundown + +go 1.26.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..4617e4f --- /dev/null +++ b/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "log" + "log/slog" + "os" + "time" +) + +var logger *slog.Logger + +func init() { + logger = slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) +} + +func isToday(t time.Time) bool { + now := time.Now() + return t.Year() == now.Year() && t.Month() == now.Month() && t.Day() == now.Day() +} + +func main() { + sunsetJSONPath := os.Getenv("SUNSET_JSON_PATH") + sunset, err := loadData(sunsetJSONPath) + if err != nil { + log.Fatalln(err) + return + } + + if !isToday(sunset.LastUpdated) { + slog.Info("Sunset Data out of date. Refreshing.", "last_update", sunset.LastUpdated) + + lat := os.Getenv("LAT") + lng := os.Getenv("LNG") + apiResponse, err := getSunsetData(lat, lng) + if err != nil { + slog.Error("Unable to get Sunset data from API", "err", err) + return + } + + sunset = &Data{ + SunsetTime: apiResponse.SunsetTime, + TenMinuteWarning: apiResponse.SunsetTime.Add(-10 * time.Minute), + LastUpdated: time.Now(), + MorningNotifySent: false, + TenMinuteWarningSent: false, + } + sunset.Save(sunsetJSONPath) + slog.Info("Sunset Data Saved", "path", sunsetJSONPath) + } + + sunset.HandleSunsetNotifications(sunsetJSONPath) +} diff --git a/notfiy.go b/notfiy.go new file mode 100644 index 0000000..cda62dc --- /dev/null +++ b/notfiy.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "strings" +) + +func SendNotification(m string) error { + req, err := http.NewRequest(http.MethodPost, "https://ntfy.delpilar.net/test", strings.NewReader(m)) + if err != nil { + return err + } + + ntfyUser := os.Getenv("NTFY_USER") + ntfyPass := os.Getenv("NTFY_PASS") + + if ntfyUser == "" || ntfyPass == "" { + return fmt.Errorf("ntfy username or password not set in environment variables. Please set them and try again") + } + + req.SetBasicAuth(ntfyUser, ntfyPass) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + return nil +}