From 3ee3715ae7777dd6c1804974e2e293406fd1fe54 Mon Sep 17 00:00:00 2001 From: Andinus Date: Wed, 25 Mar 2020 00:13:51 +0530 Subject: Add apod support & fix errors --- apod/json.go | 4 +- cache/getdir_darwin.go | 4 +- cache/getdir_unix.go | 2 +- cmd/cetus/apod.go | 176 ++++++++++++++++++++++++++++++++++++++++++ cmd/cetus/env.go | 18 +++++ cmd/cetus/main.go | 96 +++++++++++++++++++++++ cmd/cetus/usage.go | 15 ++++ notification/notif.go | 8 ++ notification/notify_darwin.go | 25 ++++++ notification/notify_unix.go | 21 +++++ request.go | 70 ----------------- request/request.go | 75 ++++++++++++++++++ 12 files changed, 439 insertions(+), 75 deletions(-) create mode 100644 cmd/cetus/apod.go create mode 100644 cmd/cetus/env.go create mode 100644 cmd/cetus/main.go create mode 100644 cmd/cetus/usage.go create mode 100644 notification/notif.go create mode 100644 notification/notify_darwin.go create mode 100644 notification/notify_unix.go delete mode 100644 request.go create mode 100644 request/request.go diff --git a/apod/json.go b/apod/json.go index c6da815..eae5fb2 100644 --- a/apod/json.go +++ b/apod/json.go @@ -5,7 +5,7 @@ import ( "fmt" "regexp" - "framagit.org/andinus/cetus/pkg/request" + "tildegit.org/andinus/cetus/request" ) // APOD holds the response from the api. Not every field is returned @@ -27,7 +27,7 @@ type APOD struct { } // UnmarshalJson will take body as input & unmarshal it to res. -func UnmarshalJson(res *Res, body string) error { +func UnmarshalJson(res *APOD, body string) error { err := json.Unmarshal([]byte(body), res) if err != nil { err = fmt.Errorf("json.go: unmarshalling json failed\n%s", diff --git a/cache/getdir_darwin.go b/cache/getdir_darwin.go index fc47e7e..733e94e 100644 --- a/cache/getdir_darwin.go +++ b/cache/getdir_darwin.go @@ -10,13 +10,13 @@ import ( // GetDir returns cetus cache directory. Default cache directory on // macOS is $HOME/Library/Caches. func GetDir() string { - cacheDir = fmt.Sprintf("%s/%s/%s", + cacheDir := fmt.Sprintf("%s/%s/%s", os.Getenv("HOME"), "Library", "Caches") // Cetus cache directory is cacheDir/cetus - cetusCacheDir = fmt.Sprintf("%s/%s", cacheDir, + cetusCacheDir := fmt.Sprintf("%s/%s", cacheDir, "cetus") return cetusCacheDir diff --git a/cache/getdir_unix.go b/cache/getdir_unix.go index b1358d7..62dd5ed 100644 --- a/cache/getdir_unix.go +++ b/cache/getdir_unix.go @@ -22,7 +22,7 @@ func GetDir() string { } // Cetus cache directory is cacheDir/cetus. - cetusCacheDir = fmt.Sprintf("%s/%s", cacheDir, + cetusCacheDir := fmt.Sprintf("%s/%s", cacheDir, "cetus") return cetusCacheDir diff --git a/cmd/cetus/apod.go b/cmd/cetus/apod.go new file mode 100644 index 0000000..c1af115 --- /dev/null +++ b/cmd/cetus/apod.go @@ -0,0 +1,176 @@ +package main + +import ( + "fmt" + "io/ioutil" + "log" + "os" + + "tildegit.org/andinus/cetus/apod" + "tildegit.org/andinus/cetus/background" + "tildegit.org/andinus/cetus/cache" + "tildegit.org/andinus/cetus/notification" +) + +var ( + err error + body string + file string + reqInfo map[string]string +) + +func execAPOD() { + apodApi := getEnv("APOD_API", "https://api.nasa.gov/planetary/apod") + apodKey := getEnv("APOD_KEY", "DEMO_KEY") + + // reqInfo holds all the parameters that needs to be sent with + // the request. GetJson() will pack apiKey & date in params + // map before sending it to another function. Adding params + // here will not change the behaviour of the function, changes + // have to be made in GetJson() too. + reqInfo = make(map[string]string) + reqInfo["api"] = apodApi + reqInfo["apiKey"] = apodKey + reqInfo["date"] = apodDate + + if random { + reqInfo["date"] = apod.RandDate() + } + + cacheDir := fmt.Sprintf("%s/%s", cache.GetDir(), "apod") + os.MkdirAll(cacheDir, os.ModePerm) + + // Check if the file is available locally, if it is then don't + // download it again and get it from disk + file = fmt.Sprintf("%s/%s.json", cacheDir, reqInfo["date"]) + + if _, err := os.Stat(file); err == nil { + data, err := ioutil.ReadFile(file) + + // Not being able to read from the cache file is a + // small error and the program shouldn't exit but + // should continue after printing the log so that the + // user can investigate it later. + if err != nil { + err = fmt.Errorf("%s%s\n%s", + "apod.go: failed to read file to data: ", file, + err.Error()) + log.Println(err) + dlAndCacheBody() + } + body = string(data) + + } else if os.IsNotExist(err) { + dlAndCacheBody() + + } else { + // If file existed then that is handled by the if + // block, if it didn't exist then that is handled by + // the else if block. If we reach here then that means + // it's Schrödinger's file & something else went + // wrong. + log.Fatal(err) + } + + if dump { + fmt.Printf(body) + } + + res := apod.APOD{} + err = apod.UnmarshalJson(&res, body) + if err != nil { + log.Fatal(err) + } + + // res.Msg will be returned when there is error on user input + // or the api server. + if len(res.Msg) != 0 { + fmt.Printf("Message: %s", res.Msg) + os.Exit(1) + } + + // Send a desktop notification if notify flag was passed. + if notify { + n := notification.Notif{} + n.Title = res.Title + n.Message = fmt.Sprintf("%s\n\n%s", + res.Date, + res.Explanation) + + err = n.Notify() + if err != nil { + log.Println(err) + } + } + + if print { + fmt.Printf("Title: %s\n\n", res.Title) + if len(res.Copyright) != 0 { + fmt.Printf("Copyright: %s\n", res.Copyright) + } + fmt.Printf("Date: %s\n\n", res.Date) + fmt.Printf("Media Type: %s\n", res.MediaType) + if res.MediaType == "image" { + fmt.Printf("URL: %s\n\n", res.HDURL) + } else { + fmt.Printf("URL: %s\n\n", res.URL) + } + fmt.Printf("Explanation: %s\n", res.Explanation) + } + + // Proceed only if the command was set because if it was fetch + // then it's already finished & should exit now. + if os.Args[1] == "fetch" { + os.Exit(0) + } + + // Try to set background only if the media type is an image. + // First it downloads the image to the cache directory and + // then tries to set it with feh. If the download fails then + // it exits with a non-zero exit code. + if res.MediaType != "image" { + os.Exit(0) + } + imgFile := fmt.Sprintf("%s/%s", cacheDir, res.Title) + + // Check if the file is available locally, if it is then don't + // download it again and set it from disk. + if _, err := os.Stat(imgFile); os.IsNotExist(err) { + err = background.Download(imgFile, res.HDURL) + if err != nil { + log.Fatal(err) + } + } else { + if err != nil { + log.Fatal(err) + } + } + + err = background.SetFromFile(imgFile) + if err != nil { + log.Fatal(err) + } +} + +func dlAndCacheBody() { + body, err = apod.GetJson(reqInfo) + if err != nil { + err = fmt.Errorf("%s\n%s", + "apod.go: failed to get json response from api", + err.Error()) + log.Fatal(err) + } + + // Write body to the cache so that it can be read later. + err = ioutil.WriteFile(file, []byte(body), 0644) + + // Not being able to write to the cache file is a small error + // and the program shouldn't exit but should continue after + // printing the log so that the user can investigate it later. + if err != nil { + err = fmt.Errorf("%s\n%s", + "apod.go: failed to write body to file: ", file, + err.Error()) + log.Println(err) + } +} diff --git a/cmd/cetus/env.go b/cmd/cetus/env.go new file mode 100644 index 0000000..9288c35 --- /dev/null +++ b/cmd/cetus/env.go @@ -0,0 +1,18 @@ +package main + +import "os" + +// getEnv will check if the the key exists, if it does then it'll +// return the value otherwise it will return fallback string. +func getEnv(key, fallback string) string { + // We use os.LookupEnv instead of using os.GetEnv and checking + // if the length equals 0 because environment variable can be + // set and be of length 0. User could've set key="" which + // means the variable was set but the length is 0. There is no + // reason why user would want to do this over here though. + value, exists := os.LookupEnv(key) + if !exists { + value = fallback + } + return value +} diff --git a/cmd/cetus/main.go b/cmd/cetus/main.go new file mode 100644 index 0000000..c7ffd40 --- /dev/null +++ b/cmd/cetus/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "flag" + "fmt" + "math/rand" + "os" + "time" +) + +var ( + version string = "v0.6.0" + dump bool + random bool + notify bool + print bool + + apodDate string + // bpodApi string = getEnv("BPOD_API", "https://www.bing.com/HPImageArchive.aspx") +) + +func main() { + + // Early Check: If command was not passed then print usage and + // exit. Later command & service both are checked, this check + // is for version command. If not checked then running cetus + // without any args will fail because os.Args[1] will panic + // the program & produce runtime error. + if len(os.Args) == 1 { + printUsage() + os.Exit(0) + } + + parseArgs() +} + +// parseArgs will be parsing the arguments, it will verify if they are +// correct. Flag values are also set by parseArgs. +func parseArgs() { + // Running just `cetus` would've paniced the program if length + // of os.Args was not checked beforehand because there would + // be no os.Args[1]. + switch os.Args[1] { + case "version", "-version", "--version", "-v": + fmt.Printf("Cetus %s\n", version) + os.Exit(0) + + case "help", "-help", "--help", "-h": + // If help was passed then the program shouldn't exit + // with non-zero error code. + printUsage() + os.Exit(0) + + case "set", "fetch": + // If command & service was not passed then print + // usage and exit. + if len(os.Args) < 3 { + printUsage() + os.Exit(1) + } + + default: + fmt.Printf("Invalid command: %q\n", os.Args[1]) + printUsage() + os.Exit(1) + } + + rand.Seed(time.Now().Unix()) + + // If the program has reached this far then that means a valid + // command was passed & now we should check if a valid service + // was passed and parse the flags. + cetus := flag.NewFlagSet("cetus", flag.ExitOnError) + + // We first declare common flags then service specific flags. + cetus.BoolVar(&dump, "dump", false, "Dump the response") + cetus.BoolVar(¬ify, "notify", false, "Send a desktop notification with info") + cetus.BoolVar(&print, "print", false, "Print information") + cetus.BoolVar(&random, "random", false, "Choose a random image") + + switch os.Args[2] { + case "apod", "nasa": + defDate := time.Now().UTC().Format("2006-01-02") + cetus.StringVar(&apodDate, "date", defDate, "Date of NASA APOD to retrieve") + cetus.Parse(os.Args[3:]) + + execAPOD() + // case "bpod", "bing": + // parseFlags() + // execBPOD() + default: + fmt.Printf("Invalid service: %q\n", os.Args[2]) + printUsage() + os.Exit(1) + } +} diff --git a/cmd/cetus/usage.go b/cmd/cetus/usage.go new file mode 100644 index 0000000..e9e2f71 --- /dev/null +++ b/cmd/cetus/usage.go @@ -0,0 +1,15 @@ +package main + +import "fmt" + +func printUsage() { + fmt.Println("Usage: cetus []") + fmt.Println("\nCommands: ") + fmt.Println(" set Set the background") + fmt.Println(" fetch Fetch the response only") + fmt.Println(" help Print help") + fmt.Println(" version Print Cetus version") + fmt.Println("\nServices: ") + fmt.Println(" apod NASA Astronomy Picture of the Day") + fmt.Println(" bpod Bing Photo of the Day") +} diff --git a/notification/notif.go b/notification/notif.go new file mode 100644 index 0000000..b436c19 --- /dev/null +++ b/notification/notif.go @@ -0,0 +1,8 @@ +package notification + +// Notif struct holds information about the notification. Other +// parameters like urgency & timeout could be added when required. +type Notif struct { + Title string + Message string +} diff --git a/notification/notify_darwin.go b/notification/notify_darwin.go new file mode 100644 index 0000000..bddaf9e --- /dev/null +++ b/notification/notify_darwin.go @@ -0,0 +1,25 @@ +// +build darwin + +package notification + +import ( + "fmt" + "os/exec" + "strconv" +) + +// Notify sends a desktop notification to the user using osascript. It +// handles information in the form of Notif struct. It returns an +// error (if exists). +func (n Notif) Notify() error { + // This script cuts out parts of notification, this bug was + // confirmed on macOS Catalina 10.15.3, fix not yet known. + err := exec.Command("osascript", "-e", + `display notification `+strconv.Quote(n.Message)+` with title `+strconv.Quote(n.Title)).Run() + if err != nil { + err = fmt.Errorf("%s\n%s", + "notify_darwin.go: failed to sent notification with osascript", + err.Error()) + } + return err +} diff --git a/notification/notify_unix.go b/notification/notify_unix.go new file mode 100644 index 0000000..a6fc3ac --- /dev/null +++ b/notification/notify_unix.go @@ -0,0 +1,21 @@ +// +build linux netbsd openbsd freebsd dragonfly + +package notification + +import ( + "fmt" + "os/exec" +) + +// Notify sends a desktop notification to the user using libnotify. It +// handles information in the form of Notif struct. It returns an +// error (if exists). +func (n Notif) Notify() error { + err := exec.Command("notify-send", n.Title, n.Message).Run() + if err != nil { + err = fmt.Errorf("%s\n%s", + "notify_unix.go: failed to sent notification with notify-send", + err.Error()) + } + return err +} diff --git a/request.go b/request.go deleted file mode 100644 index b5088f9..0000000 --- a/request.go +++ /dev/null @@ -1,70 +0,0 @@ -// Request manages all outgoing requests for cetus projects. -package request - -import ( - "fmt" - "io/ioutil" - "net/http" - "time" -) - -// GetRes takes api and params as input and returns the body and -// error. -func (c http.Client) GetRes(api string, params map[string]string) (string, error) { - var body string - - req, err := http.NewRequest(http.MethodGet, api, nil) - if err != nil { - err = fmt.Errorf("%s\n%s", - "request.go: failed to create request", - err.Error()) - return body, err - } - - // User-Agent should be passed with every request to make work - // easier for the server handler. Include contact information - // along with the project name so they could reach you if - // required. - req.Header.Set("User-Agent", - "Andinus / Cetus - https://andinus.nand.sh/projects/cetus") - - // Params is a simple map[string]string which contains - // parameters that needs to be passed along with the request. - // There is no check involved here & it should be done before - // passing params to this function. - q := req.URL.Query() - for k, v := range params { - q.Add(k, v) - } - req.URL.RawQuery = q.Encode() - - res, err := c.Do(req) - if err != nil { - err = fmt.Errorf("%s\n%s", - "request.go: failed to get response", - err.Error()) - return body, err - } - defer res.Body.Close() - - if res.StatusCode != 200 { - err = fmt.Errorf("Unexpected response status code received: %d %s", - res.StatusCode, - http.StatusText(res.StatusCode)) - return body, err - } - - // This will read everything to memory and is okay to use here - // because the json response received will be small unlike in - // download.go (package background) where it is an image. - out, err := ioutil.ReadAll(res.Body) - if err != nil { - err = fmt.Errorf("%s\n%s", - "request.go: failed to read body to out (var)", - err.Error()) - return body, err - } - - body = string(out) - return body, err -} diff --git a/request/request.go b/request/request.go new file mode 100644 index 0000000..c5a2dd1 --- /dev/null +++ b/request/request.go @@ -0,0 +1,75 @@ +// Request manages all outgoing requests for cetus projects. +package request + +import ( + "fmt" + "io/ioutil" + "net/http" + "time" +) + +// GetRes takes api and params as input and returns the body and +// error. +func GetRes(api string, params map[string]string) (string, error) { + var body string + + c := http.Client{ + // TODO: timeout should be configurable by the user + Timeout: time.Second * 64, + } + + req, err := http.NewRequest(http.MethodGet, api, nil) + if err != nil { + err = fmt.Errorf("%s\n%s", + "request.go: failed to create request", + err.Error()) + return body, err + } + + // User-Agent should be passed with every request to make work + // easier for the server handler. Include contact information + // along with the project name so they could reach you if + // required. + req.Header.Set("User-Agent", + "Andinus / Cetus - https://andinus.nand.sh/projects/cetus") + + // Params is a simple map[string]string which contains + // parameters that needs to be passed along with the request. + // There is no check involved here & it should be done before + // passing params to this function. + q := req.URL.Query() + for k, v := range params { + q.Add(k, v) + } + req.URL.RawQuery = q.Encode() + + res, err := c.Do(req) + if err != nil { + err = fmt.Errorf("%s\n%s", + "request.go: failed to get response", + err.Error()) + return body, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + err = fmt.Errorf("Unexpected response status code received: %d %s", + res.StatusCode, + http.StatusText(res.StatusCode)) + return body, err + } + + // This will read everything to memory and is okay to use here + // because the json response received will be small unlike in + // download.go (package background) where it is an image. + out, err := ioutil.ReadAll(res.Body) + if err != nil { + err = fmt.Errorf("%s\n%s", + "request.go: failed to read body to out (var)", + err.Error()) + return body, err + } + + body = string(out) + return body, err +} -- cgit 1.4.1-2-gfad0