From 057771467dce4622513f6ce21a76c6c7f8ccaa69 Mon Sep 17 00:00:00 2001 From: Derek Stevens Date: Mon, 9 Jan 2023 22:23:25 -0700 Subject: [PATCH] wrote raven, a twtxt client --- .gitignore | 1 + config.go | 176 +++++++++++++++++++++++++++++++++++++++++++++++++++++ feed.go | 153 ++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 + raven.go | 55 +++++++++++++++++ types.go | 33 ++++++++++ 6 files changed, 421 insertions(+) create mode 100644 .gitignore create mode 100644 config.go create mode 100644 feed.go create mode 100644 go.mod create mode 100644 raven.go create mode 100644 types.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc301e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/raven \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..b40763a --- /dev/null +++ b/config.go @@ -0,0 +1,176 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +func GetConfigLocation() string { + home := os.Getenv("HOME") + appdata := os.Getenv("APPDATA") + switch runtime.GOOS { + case "windows": + return filepath.Join(appdata, "raven") + case "darwin": + return filepath.Join(home, "Library", "Application Support", "raven") + case "plan9": + return filepath.Join(home, "lib", "raven") + default: + return filepath.Join(home, ".config", "raven") + } +} + +func ensureConfigLocationExists() { + fileInfo, err := os.Stat(GetConfigLocation()) + + if os.IsNotExist(err) { + os.MkdirAll(GetConfigLocation(), os.ModePerm) + } else if !fileInfo.IsDir() { + panic("Config location is not a directory!") + } +} + +func ReadConfig() *Config { + ensureConfigLocationExists() + return parseConfig(filepath.Join(GetConfigLocation(), "raven.conf")) +} + +func (self *Config) Write() error { + ensureConfigLocationExists() + return writeConfig(self, filepath.Join(GetConfigLocation(), "raven.conf")) +} + +func (self *Config) IsNull() bool { + // zero-values for StaticShowHTML, StaticShowHidden, and StaticMaxUploadMB are valid + return len(self.Nick) == 0 || len(self.FeedFile) == 0 || len(self.FriendsFile) == 0 +} + +func (self *Config) RunWizard() { + fmt.Printf("All options are required.\n") + defer func(cfg *Config) { + if r := recover(); r != nil { + fmt.Printf("Invalid selection, starting over...") + cfg.RunWizard() + } + }(self) + inputBuf := "" + + fmt.Printf("your nickname? ") + ensureNonEmptyOption(&inputBuf) + self.Nick = inputBuf + + inputBuf = "" + fmt.Printf("your twtxt file location? ") + ensureNonEmptyOption(&inputBuf) + self.FeedFile = inputBuf + + inputBuf = "" + fmt.Printf("your feed in ascending order? ") + self.FeedAscend = ensureBooleanOption(&inputBuf) + + inputBuf = "" + fmt.Printf("file location containing your friends' feeds? ") + ensureNonEmptyOption(&inputBuf) + self.FriendsFile = inputBuf + + inputBuf = "" + fmt.Printf("number of posts to store in memory? ") + self.MaxPosts = ensureNumberOption(&inputBuf) + + fmt.Printf("Configuration complete!\n") + self.Write() +} + +func ensureNonEmptyOption(buffer *string) { + for { + fmt.Scanln(buffer) + if len(strings.TrimSpace(*buffer)) != 0 { + break + } + fmt.Println("Please enter a nonempty value") + } +} + +func ensureBooleanOption(buffer *string) bool { + for { + fmt.Scanln(buffer) + trimmedBuf := strings.TrimSpace(*buffer) + v, err := strconv.ParseBool(trimmedBuf) + if err == nil { + return v + } + fmt.Println("Please enter a true or false value") + } +} + +func ensureNumberOption(buffer *string) int64 { + for { + fmt.Scanln(buffer) + trimmedBuf := strings.TrimSpace(*buffer) + v, err := strconv.ParseInt(trimmedBuf, 10, 64) + if err == nil && v >= 0 { + return v + } + fmt.Println("Please enter a nonnegative integer") + } +} + +func writeConfig(cfg *Config, configFile string) error { + f, err := os.Create(configFile) + if err != nil { + return err + } + + defer f.Close() + f.WriteString("nick=" + cfg.Nick + "\n") + f.WriteString("feedfile=" + cfg.FeedFile + "\n") + f.WriteString("feed_ascend=" + strconv.FormatBool(cfg.FeedAscend) + "\n") + f.WriteString("friendsfile=" + cfg.FriendsFile + "\n") + f.WriteString("max_posts=" + strconv.FormatInt(cfg.MaxPosts, 10) + "\n") + return nil +} + +func parseConfig(configFile string) *Config { + f, err := os.ReadFile(configFile) + cfg := &Config{} + if err != nil { + fmt.Println(err.Error()) + return cfg + } + + fileData := string(f[:]) + + lines := strings.Split(fileData, "\n") + + for _, l := range lines { + if len(l) == 0 { + continue + } + if !strings.Contains(l, "=") { + panic("Malformed config not in INI format") + } + + kvp := strings.Split(l, "=") + k := strings.TrimSpace(kvp[0]) + v := strings.TrimSpace(kvp[1]) + switch k { + case "feedfile": + cfg.FeedFile = v + case "feed_ascend": + cfg.FeedAscend, _ = strconv.ParseBool(v) + case "friendsfile": + cfg.FriendsFile = v + case "max_posts": + cfg.MaxPosts, _ = strconv.ParseInt(v, 10, 64) + case "nick": + cfg.Nick = v + default: + panic("Unrecognized config option: " + k) + } + } + return cfg +} \ No newline at end of file diff --git a/feed.go b/feed.go new file mode 100644 index 0000000..be23540 --- /dev/null +++ b/feed.go @@ -0,0 +1,153 @@ +package main + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + "time" +) + +func appendToFile(filename, data string) error { + f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return err + } + f.WriteString(data) + f.Close() + return nil +} + +func prependToFile(filename, data string) error { + ogData, err := os.ReadFile(filename) + if err != nil { + return err + } + + fileData := string(ogData[:]) + + f, err := os.Create(filename) + if err != nil { + return err + } + f.WriteString(data + fileData) + f.Close() + return nil +} + +func Post(twt, feedFile string, ascend bool) error { + fileInfo, err := os.Stat(feedFile) + if err != nil { + os.Create(feedFile) + } else if fileInfo.IsDir() { + return errors.New("Feed file is a directory") + } + + if ascend { + return appendToFile(feedFile, time.Now().Format(time.RFC3339)+"\t"+twt+"\n") + } + return prependToFile(feedFile, time.Now().Format(time.RFC3339)+"\t"+twt+"\n") +} + +func buildFeedFromUrl(feed *Feed, nick, url string, max int64) error { + resp, err := http.Get(url) + if err != nil { + return err + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + return buildFeed(feed, nick, string(body[:]), max) +} + +func buildFeedFromPath(feed *Feed, nick, filename string, max int64) error { + f, err := os.ReadFile(filename) + if err != nil { + return err + } + + return buildFeed(feed, nick, string(f[:]), max) +} + +func buildFeed(feed *Feed, nick, data string, max int64) error { + lines := strings.Split(data, "\n") + + for i, l := range lines { + if int64(i) > max { + return nil + } + parts := strings.Split(l, "\t") + t, err := time.Parse(time.RFC3339, parts[0]) + if err != nil { + continue + } + *feed = append(*feed, FeedEntry{ + Nick: nick, + Timestamp: t, + Post: parts[1], + }) + } + return nil +} + +func printFeed(feed *Feed) { + sort.Sort(*feed) + for _, entry := range *feed { + fmt.Printf("[%s] <%s> %s\n", entry.Timestamp.Format(time.Stamp), entry.Nick, entry.Post) + } +} + +func GetFeed(friend, friendsFile string, max int64) error { + feed := &Feed{} + + f, err := os.ReadFile(friendsFile) + if err != nil { + return err + } + + fileData := string(f[:]) + + lines := strings.Split(fileData, "\n") + + for _, l := range lines { + if len(l) == 0 { + continue + } + if !strings.Contains(l, " ") { + return errors.New("Malformed friends file not in the format 'FRIEND_NAME https://friend.site/path/to/feed.txt'") + } + + kvp := strings.Split(l, " ") + k := kvp[0] + v := kvp[1] + + if k == friend || len(friend) == 0 { + err = buildFeedFromUrl(feed, k, v, max) + if err != nil { + return err + } + if len(friend) > 0 { + break + } + } + } + printFeed(feed) + return nil +} + +func GetOwnFeed(nick, feedFile string, max int64) error { + feed := &Feed{} + err := buildFeedFromPath(feed, nick, feedFile, max) + if err != nil { + return err + } + printFeed(feed) + return nil +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f42ee3f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module nilfm.cc/git/raven + +go 1.19 diff --git a/raven.go b/raven.go new file mode 100644 index 0000000..86b7dce --- /dev/null +++ b/raven.go @@ -0,0 +1,55 @@ +package main + +import ( + "errors" + "fmt" + "os" +) + +func helpMe(prog string) { + fmt.Printf("%s: twtxt client\n", prog) + fmt.Printf("usage: %s CMD \n\n", prog) + fmt.Println("available commands are") + fmt.Println(" twt: post to your twtxt file") + fmt.Println(" feed: read the feed from all your friends, or the one named with ARG if given") + fmt.Println(" self: read your own feed\n") + fmt.Println("Config file is stored in " + GetConfigLocation() + " and is initialized automatically with a wizard") +} + +func run(args []string) error { + cfg := ReadConfig() + if cfg.IsNull() { + cfg.RunWizard() + } + + if len(args) == 1 || args[1] == "help" || args[1] == "-h" || args[1] == "--help" { + helpMe(args[0]) + return nil + } else { + switch args[1] { + case "twt": + return Post(args[2], cfg.FeedFile, cfg.FeedAscend) + case "feed": + if len(args) > 2 { + return GetFeed(args[2], cfg.FriendsFile, cfg.MaxPosts) + } else { + return GetFeed("", cfg.FriendsFile, cfg.MaxPosts) + } + case "self": + return GetOwnFeed(cfg.Nick, cfg.FeedFile, cfg.MaxPosts) + default: + helpMe(args[0]) + return errors.New("Unrecognized command line option: " + args[1] + "\n") + } + } +} + +func main() { + err := run(os.Args) + if err == nil { + os.Exit(0) + } else { + fmt.Println(err.Error()) + os.Exit(1) + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..9b2d9df --- /dev/null +++ b/types.go @@ -0,0 +1,33 @@ +package main + +import ( + "time" +) + +type Config struct { + FeedFile string + FeedAscend bool + FriendsFile string + MaxPosts int64 + Nick string +} + +type FeedEntry struct { + Nick string + Timestamp time.Time + Post string +} + +type Feed []FeedEntry + +func (self Feed) Len() int { + return len(self) +} + +func (self Feed) Swap(i, j int) { + self[i], self[j] = self[j], self[i] +} + +func (self Feed) Less(i, j int) bool { + return self[i].Timestamp.After(self[j].Timestamp) +}