wrote raven, a twtxt client
This commit is contained in:
commit
057771467d
6 changed files with 421 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/raven
|
176
config.go
Normal file
176
config.go
Normal file
|
@ -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
|
||||
}
|
153
feed.go
Normal file
153
feed.go
Normal file
|
@ -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
|
||||
}
|
3
go.mod
Normal file
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
|||
module nilfm.cc/git/raven
|
||||
|
||||
go 1.19
|
55
raven.go
Normal file
55
raven.go
Normal file
|
@ -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 <ARG>\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)
|
||||
}
|
||||
}
|
33
types.go
Normal file
33
types.go
Normal file
|
@ -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)
|
||||
}
|
Loading…
Reference in a new issue