commit 99ed05e33dafeb84968e397f636c0090d85f691d from: Oliver Lowe date: Sun Oct 15 09:21:18 2023 UTC add Lemmy command for the acme text editor commit - fcf319e83dbec9b824aae4d62e9a4d99cc473dd7 commit + 99ed05e33dafeb84968e397f636c0090d85f691d blob - ab829bf31417d6c995617b0ad6a0b2fa01e421a9 blob + f17aae80d38fe908fefe427d4ce6513d2f0584a6 --- auth.go +++ auth.go @@ -34,4 +34,8 @@ func (c *Client) Login(name, password string) error { } c.authToken = response.JWT return nil -} \ No newline at end of file +} + +func (c *Client) Authenticated() bool { + return c.authToken != "" +} blob - 934c074d7f631ab82c3d20cbb7fed934b5a413f2 blob + 3a1bbd61539a774aec3760301b0e3a5fbbc12703 --- fs/file.go +++ fs/file.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "io/fs" - "os" "strconv" "strings" "time" blob - a2e673c958113926567be080fcc56290cd5dda17 blob + 635e8a6a94b392f31739c7fe63046b5eb64392f0 --- fs/fs.go +++ fs/fs.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "io/fs" - "os" "path" "strconv" "strings" @@ -29,17 +28,15 @@ func (fsys *FS) start() error { if fsys.Communities == nil { fsys.Communities = make(map[string]lemmy.Community) - /* - if fsys.Client.authToken != "" { - subscribed, err := fsys.Client.Communities(lemmy.ListSubscribed) - if err != nil { - return fmt.Errorf("load subscriptions: %w", err) - } - for _, c := range subscribed { - fsys.Communities[c.Name()] = c - } + if fsys.Client.Authenticated() { + subscribed, err := fsys.Client.Communities(lemmy.ListSubscribed) + if err != nil { + return fmt.Errorf("load subscriptions: %w", err) } - */ + for _, c := range subscribed { + fsys.Communities[c.Name()] = c + } + } } if fsys.Posts == nil { @@ -144,6 +141,7 @@ func (fsys *FS) Open(name string) (fs.File, error) { func (fsys *FS) openRoot() (fs.File, error) { dirinfo := new(dirInfo) for _, c := range fsys.Communities { + c := c de := fs.FileInfoToDirEntry(&c) dirinfo.entries = append(dirinfo.entries, de) } blob - /dev/null blob + 1bf408994b782ce9c379ac83c8ec227c149918e4 (mode 644) --- /dev/null +++ cmd/Lemmy/Lemmy.go @@ -0,0 +1,266 @@ +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "io/fs" + "log" + "os" + "path" + "strings" + + "9fans.net/go/acme" + "olowe.co/lemmy" + lemmyfs "olowe.co/lemmy/fs" +) + +type awin struct { + *acme.Win +} + +func (win *awin) Look(text string) bool { + if acme.Show(text) != nil { + return true + } + + text = strings.TrimSpace(text) + f, err := fsys.Open(text) + if err == nil { + f.Close() + return open(text) + } else if errors.Is(err, fs.ErrNotExist) { + // maybe we're looking for a post in a community? + if win.community() != "" { + name := path.Join(win.community(), text) + f, err = fsys.Open(name) + if err == nil { + f.Close() + return open(name) + } + } + } + if !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, fs.ErrInvalid) { + win.Err(err.Error()) + } + return false +} + +func (win *awin) Execute(cmd string) bool { + switch cmd { + case "Reply": + return open("reply") + case "Post": + body, err := win.ReadAll("body") + if err != nil { + win.Err(err.Error()) + return false + } + comment, err := parseReply(bytes.NewReader(body)) + if err != nil { + win.Err("parse comment reply: " + err.Error()) + return false + } + client := fsys.(*lemmyfs.FS).Client + if err := client.Reply(comment.PostID, 0, comment.Content); err != nil { + emsg := fmt.Sprintf("reply to %d: %v", comment.PostID, err) + win.Err(emsg) + return false + } + win.Ctl("clean") + return true + case "Del": + default: + log.Println("unsupported execute", cmd) + } + return false +} + +func (w *awin) community() string { + elems := strings.Split(w.name(), "/") + switch len(elems) { + case 2: + // /lemmy/community + return elems[1] + case 3: + // /lemmy/community/post + return elems[2] + } + return "" +} + +func (w *awin) name() string { + buf, err := w.ReadAll("tag") + if err != nil { + w.Err(err.Error()) + return "" + } + name := strings.Fields(string(buf))[0] + return path.Clean(name) +} + +var fsys fs.FS + +func loadPostList(dir string) ([]byte, error) { + buf := &bytes.Buffer{} + dirents, err := fs.ReadDir(fsys, dir) + if err != nil { + return buf.Bytes(), err + } + for _, d := range dirents { + title, err := fs.ReadFile(fsys, path.Join(dir, d.Name(), "title")) + if err != nil { + return nil, err + } + creator, err := fs.ReadFile(fsys, path.Join(dir, d.Name(), "creator")) + if err != nil { + return buf.Bytes(), err + } + // 1234/ User + // Hello world! + // 5678/ Pengguna + // Halo Dunia! + fmt.Fprintf(buf, "%s/\t%s\n\t%s\n", d.Name(), string(creator), string(title)) + } + return buf.Bytes(), err +} + +func loadPost(pathname string) ([]byte, error) { + buf := &bytes.Buffer{} + for _, fname := range []string{"title", "creator", "url"} { + b, err := fs.ReadFile(fsys, path.Join(pathname, fname)) + if err != nil { + return buf.Bytes(), err + } + key := fmt.Sprintf("%s:", strings.Title(fname)) + fmt.Fprintln(buf, key, string(b)) + } + /* + stat, err := fs.Stat(fsys, pathname) + if err != nil { + return nil, err + } + fmt.Fprintln(buf, "Date:", stat.ModTime()) + */ + fmt.Fprintln(buf) + + body, err := fs.ReadFile(fsys, path.Join(pathname, "body")) + if err != nil { + return buf.Bytes(), err + } + fmt.Fprintln(buf, string(body)) + fmt.Fprintln(buf, "") + dirents, err := fs.ReadDir(fsys, pathname) + if err != nil { + return buf.Bytes(), err + } + for _, ent := range dirents { + if !strings.ContainsAny(ent.Name(), "0123456789") { + continue + } + comment, err := fs.ReadFile(fsys, path.Join(pathname, ent.Name())) + if err != nil { + return buf.Bytes(), err + } + fmt.Fprintln(buf, string(comment)) + } + return buf.Bytes(), nil +} + +func open(name string) bool { + win, err := acme.New() + if err != nil { + log.Fatal(err) + } + win.Name(path.Join("/lemmy", name)) + if name == "." || name == "" { + win.Name("/lemmy/") + } + win.Ctl("dirty") + defer win.Ctl("clean") + + var body []byte + elems := strings.Split(name, "/") + switch len(elems) { + case 1: + if name == "." { + body, err = loadCommunityList() + break + } else if path.Base(name) == "reply" { + win.Write("tag", []byte("Post")) + body = loadNewReply("") + break + } + body, err = loadPostList(name) + case 2: + win.Write("tag", []byte("Reply")) + body, err = loadPost(name) + } + if errors.Is(err, fs.ErrNotExist) { + return false + } else if err != nil { + log.Print(err) + return false + } + + awin := &awin{win} + awin.Write("body", body) + go awin.EventLoop(awin) + return true +} + +const Usage string = "usage: Lemmy [host]" + +func init() { + log.SetFlags(0) + log.SetPrefix("Lemmy: ") +} + +func main() { + debug := flag.Bool("d", false, "enable debug output to stderr") + login := flag.Bool("l", false, "log in to Lemmy") + flag.Parse() + + addr := "lemmy.sdf.org" + if len(flag.Args()) > 1 { + fmt.Fprintln(os.Stderr, Usage) + os.Exit(2) + } else if len(flag.Args()) == 1 { + addr = flag.Arg(0) + } + client := &lemmy.Client{ + Address: addr, + Debug: *debug, + } + + if *login { + config, err := os.UserConfigDir() + if err != nil { + log.Fatalln(err) + } + username, password, err := readCreds(path.Join(config, "Lemmy")) + if err != nil { + log.Fatalln("read lemmy credentials:", err) + } + if err := client.Login(username, password); err != nil { + log.Fatalln("login:", err) + } + } + + fsys = &lemmyfs.FS{ + Client: client, + } + + open(".") + acme.AutoExit(true) + select {} +} + +func mustPathMatch(pattern, name string) bool { + match, err := path.Match(pattern, name) + if err != nil { + panic(err) + } + return match +} blob - /dev/null blob + ca5c746091babfdbb6fa7f483f1cef7244233ca2 (mode 644) --- /dev/null +++ cmd/Lemmy/comment.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "io" + "net/mail" + "path" + "strconv" + + "olowe.co/lemmy" +) + +func loadNewReply(pathname string) []byte { + if pathname == "" { + return []byte("To: ") + } + return []byte(fmt.Sprintf("To: %s\n\n", path.Base(pathname))) +} + +func parseReply(r io.Reader) (*lemmy.Comment, error) { + msg, err := mail.ReadMessage(r) + if err != nil { + return nil, err + } + var comment lemmy.Comment + b, err := io.ReadAll(msg.Body) + if err != nil { + return nil, err + } + comment.Content = string(b) + if comment.PostID, err = strconv.Atoi(msg.Header.Get("To")); err != nil { + return nil, fmt.Errorf("parse post id: %w", err) + } + return &comment, nil +} blob - /dev/null blob + 39c4b9ec297eb71e9ad3740c62dec96eb7e160c6 (mode 644) --- /dev/null +++ cmd/Lemmy/config.go @@ -0,0 +1,20 @@ +package main + +import ( + "bufio" + "os" +) + +func readCreds(name string) (username, password string, err error) { + f, err := os.Open(name) + if err != nil { + return "", "", err + } + defer f.Close() + sc := bufio.NewScanner(f) + sc.Scan() + username = sc.Text() + sc.Scan() + password = sc.Text() + return username, password, sc.Err() +} blob - /dev/null blob + e321a7039b95ea3a1ff5ee6d40ebdbc7dd1982e9 (mode 644) --- /dev/null +++ cmd/Lemmy/fmt.go @@ -0,0 +1,51 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "path" + "strings" +) + +func printDirEntries(w io.Writer, dirs []fs.DirEntry) { + for _, entry := range dirs { + name := entry.Name() + if entry.IsDir() { + name = name + "/" + } + fmt.Fprintln(w, name) + } +} + +func listDir(name string) (string, error) { + entries, err := fs.ReadDir(fsys, name) + if err != nil { + return "", err + } + var builder strings.Builder + for _, entry := range entries { + filename := path.Join(name, entry.Name(), "title") + title, err := fs.ReadFile(fsys, filename) + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + line := fmt.Sprintf("%s/\t%s\n", entry.Name(), string(title)) + builder.WriteString(line) + } + return builder.String(), nil +} + +func loadCommunityList() ([]byte, error) { + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return nil, err + } + buf := &bytes.Buffer{} + for _, dirent := range entries { + fmt.Fprintf(buf, "%s/\n", dirent.Name()) + } + return buf.Bytes(), nil +} blob - 18cc639701c8112104fc28817abac957b11744b8 blob + 5576477559ec98812d2c2a8539b45bf133252331 --- lemmy.go +++ lemmy.go @@ -11,18 +11,18 @@ import ( ) type Community struct { - ID int `json:"id"` - FName string `json:"name"` - Title string `json:"title"` - Local bool - ActorID string `json:"actor_id"` - // Updated time.Time `json:"updated"` + ID int `json:"id"` + FName string `json:"name"` + Title string `json:"title"` + Local bool + ActorID string `json:"actor_id"` + Published time.Time `json:"TODO"` } func (c *Community) Name() string { return c.String() } func (c *Community) Size() int64 { return 0 } func (c *Community) Mode() fs.FileMode { return fs.ModeDir | 0o0555 } -func (c *Community) ModTime() time.Time { return time.Unix(0, 0) } +func (c *Community) ModTime() time.Time { return c.Published } func (c *Community) IsDir() bool { return true } func (c *Community) Sys() interface{} { return nil } @@ -41,7 +41,7 @@ type Post struct { Body string CreatorID int `json:"creator_id"` URL string - // Published time.Time + Published time.Time `json:"TODO"` // Updated time.Time } @@ -52,7 +52,7 @@ func (p *Post) Size() int64 { } func (p *Post) Mode() fs.FileMode { return fs.ModeDir | 0o0444 } -func (p *Post) ModTime() time.Time { return time.Unix(0, 0) } +func (p *Post) ModTime() time.Time { return p.Published } func (p *Post) IsDir() bool { return true } func (p *Post) Sys() interface{} { return nil }