commit - /dev/null
commit + 4a5e46556d98c2f01ca29f5f7d42dcc46f549154
blob - /dev/null
blob + c1c9cb50f164d80716c4c7488f1d479ccd6e2b33 (mode 644)
--- /dev/null
+++ LICENSE
+Copyright (c) 2023 Oliver Lowe <o@olowe.co>
+
+Permission to use, copy, modify, and distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
blob - /dev/null
blob + ab829bf31417d6c995617b0ad6a0b2fa01e421a9 (mode 644)
--- /dev/null
+++ auth.go
+package lemmy
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+func (c *Client) Login(name, password string) error {
+ if !c.ready {
+ if err := c.init(); err != nil {
+ return err
+ }
+ }
+
+ params := map[string]interface{}{
+ "username_or_email": name,
+ "password": password,
+ }
+ resp, err := c.post("/user/login", params)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+ }
+
+ var response struct {
+ JWT string
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+ return fmt.Errorf("decode login response: %w", err)
+ }
+ c.authToken = response.JWT
+ return nil
+}
\ No newline at end of file
blob - /dev/null
blob + e6f7d5ef0401971f6b65467886800c93d3f78c2e (mode 644)
--- /dev/null
+++ client.go
+package lemmy
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "strconv"
+)
+
+type Client struct {
+ *http.Client
+ Address string
+ authToken string
+ instance *url.URL
+ ready bool
+}
+
+type ListMode string
+
+const (
+ ListAll ListMode = "All"
+ ListLocal = "Local"
+ ListSubscribed = "Subscribed"
+)
+
+var ErrNotFound error = errors.New("not found")
+
+func (c *Client) init() error {
+ if c.Address == "" {
+ c.Address = "127.0.0.1"
+ }
+ if c.instance == nil {
+ u, err := url.Parse("https://" + c.Address + "/api/v3/")
+ if err != nil {
+ return fmt.Errorf("initialise client: parse instance url: %w", err)
+ }
+ c.instance = u
+ }
+ if c.Client == nil {
+ c.Client = &http.Client{}
+ }
+ c.ready = true
+ return nil
+}
+
+func (c *Client) Communities(mode ListMode) ([]Community, error) {
+ if !c.ready {
+ if err := c.init(); err != nil {
+ return nil, err
+ }
+ }
+
+ params := map[string]string{"type_": string(mode)}
+ resp, err := c.get("community/list", params)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+ }
+
+ var response struct {
+ Communities []struct {
+ Community Community
+ }
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+ return nil, fmt.Errorf("decode community response: %w", err)
+ }
+ var communities []Community
+ for _, c := range response.Communities {
+ communities = append(communities, c.Community)
+ }
+ return communities, nil
+}
+
+func (c *Client) LookupCommunity(name string) (Community, error) {
+ if !c.ready {
+ if err := c.init(); err != nil {
+ return Community{}, err
+ }
+ }
+
+ params := map[string]string{"name": name}
+ resp, err := c.get("community", params)
+ if err != nil {
+ return Community{}, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ return Community{}, ErrNotFound
+ } else if resp.StatusCode != http.StatusOK {
+ return Community{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+ }
+
+ type response struct {
+ View struct {
+ Community Community
+ } `json:"community_view"`
+ }
+ var cres response
+ if err := json.NewDecoder(resp.Body).Decode(&cres); err != nil {
+ return Community{}, fmt.Errorf("decode community response: %w", err)
+ }
+ return cres.View.Community, nil
+}
+
+func (c *Client) Posts(community string, mode ListMode) ([]Post, error) {
+ if !c.ready {
+ if err := c.init(); err != nil {
+ return nil, err
+ }
+ }
+
+ params := map[string]string{
+ "community_name": community,
+ "limit": "30",
+ "type_": string(mode),
+ }
+ resp, err := c.get("post/list", params)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+ }
+
+ var jresponse struct {
+ Posts []struct {
+ Post Post
+ }
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&jresponse); err != nil {
+ return nil, fmt.Errorf("decode posts response: %w", err)
+ }
+ var posts []Post
+ for _, post := range jresponse.Posts {
+ posts = append(posts, post.Post)
+ }
+ return posts, nil
+}
+
+func (c *Client) LookupPost(id int) (Post, error) {
+ if !c.ready {
+ if err := c.init(); err != nil {
+ return Post{}, err
+ }
+ }
+
+ params := map[string]string{"id": strconv.Itoa(id)}
+ resp, err := c.get("post", params)
+ if err != nil {
+ return Post{}, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusNotFound {
+ return Post{}, ErrNotFound
+ } else if resp.StatusCode != http.StatusOK {
+ return Post{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+ }
+
+ type jresponse struct {
+ PostView struct {
+ Post Post
+ } `json:"post_view"`
+ }
+ var jresp jresponse
+ if err := json.NewDecoder(resp.Body).Decode(&jresp); err != nil {
+ return Post{}, fmt.Errorf("decode post: %w", err)
+ }
+ return jresp.PostView.Post, nil
+}
+
+func (c *Client) Comments(post int, mode ListMode) ([]Comment, error) {
+ if !c.ready {
+ if err := c.init(); err != nil {
+ return nil, err
+ }
+ }
+
+ params := map[string]string{
+ "post_id": strconv.Itoa(post),
+ "type_": string(mode),
+ }
+ resp, err := c.get("comment/list", params)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+ }
+
+ var jresponse struct {
+ Comments []struct {
+ Comment Comment
+ }
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&jresponse); err != nil {
+ return nil, fmt.Errorf("decode comments: %w", err)
+ }
+ var comments []Comment
+ for _, comment := range jresponse.Comments {
+ comments = append(comments, comment.Comment)
+ }
+ return comments, nil
+}
+
+func (c *Client) LookupComment(id int) (Comment, error) {
+ if !c.ready {
+ if err := c.init(); err != nil {
+ return Comment{}, err
+ }
+ }
+
+ params := map[string]string{"id": strconv.Itoa(id)}
+ resp, err := c.get("comment", params)
+ if err != nil {
+ return Comment{}, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return Comment{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+ }
+
+ type jresponse struct {
+ CommentView struct {
+ Comment Comment
+ } `json:"comment_view"`
+ }
+ var jresp jresponse
+ if err := json.NewDecoder(resp.Body).Decode(&jresp); err != nil {
+ return Comment{}, fmt.Errorf("decode comment: %w", err)
+ }
+ return jresp.CommentView.Comment, nil
+}
+
+func (c *Client) Reply(post int, parent int, msg string) error {
+ params := map[string]interface{}{
+ "post_id": post,
+ "content": msg,
+ "auth": c.authToken,
+ }
+ if parent > 0 {
+ params["parent_id"] = strconv.Itoa(parent)
+ }
+ resp, err := c.post("/comment", params)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+ }
+ return nil
+}
+
+func (c *Client) post(pathname string, params map[string]interface{}) (*http.Response, error) {
+ u := *c.instance
+ u.Path = path.Join(u.Path, pathname)
+
+ b, err := json.Marshal(params)
+ if err != nil {
+ return nil, fmt.Errorf("encode body: %w", err)
+ }
+ req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(b))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ return c.Do(req)
+}
+
+func (c *Client) get(pathname string, params map[string]string) (*http.Response, error) {
+ u := *c.instance
+ u.Path = path.Join(u.Path, pathname)
+ vals := make(url.Values)
+ for k, v := range params {
+ vals.Set(k, v)
+ }
+ u.RawQuery = vals.Encode()
+ req, err := http.NewRequest(http.MethodGet, u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/json")
+ fmt.Fprintf(os.Stderr, "%s %s\n", req.Method, req.URL)
+ return c.Do(req)
+}
+
+type jError struct {
+ Err string `json:"error"`
+}
+
+func (err jError) Error() string { return err.Err }
+
+func decodeError(r io.Reader) error {
+ var jerr jError
+ buf := &bytes.Buffer{}
+ io.Copy(buf, r)
+ fmt.Fprintln(os.Stderr, "error", buf.String())
+ if err := json.NewDecoder(buf).Decode(&jerr); err != nil {
+ return fmt.Errorf("decode error message: %v", err)
+ }
+ return jerr
+}
blob - /dev/null
blob + 81d98fe53db384beb08bbc7dcb97c7e87c69a523 (mode 644)
--- /dev/null
+++ fs.go
+package lemmy
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type FS struct {
+ Client *Client
+ Communities map[string]Community
+ Posts map[int]Post
+ Comments map[int]Comment
+ root *node
+ baseURL string
+ started bool
+}
+
+type node struct {
+ name string
+ Community *Community
+ Post *Post
+ Comment *Comment
+ dummy *dummy
+ children []node
+}
+
+func (n *node) Info() fs.FileInfo {
+ switch {
+ case n.Community != nil:
+ return n.Community
+ case n.Post != nil:
+ return n.Post
+ case n.Comment != nil:
+ return n.Comment
+ case n.dummy != nil:
+ return n.dummy
+ }
+ return nil
+}
+
+type file struct {
+ name string
+ buf io.ReadCloser
+ dirinfo *dirInfo
+ client *Client
+ Community *Community
+ Post *Post
+ Comment *Comment
+ dummy *dummy
+}
+
+type dirInfo struct {
+ entries []fs.DirEntry
+ entryp int
+}
+
+func (f *file) Stat() (fs.FileInfo, error) {
+ switch {
+ case f.dummy != nil:
+ return f.dummy, nil
+ case f.Community != nil:
+ return f.Community, nil
+ case f.Post != nil:
+ return f.Post, nil
+ case f.Comment != nil:
+ return f.Comment, nil
+ }
+ return &dummy{}, nil
+}
+
+func (f *file) Read(p []byte) (int, error) {
+ if f.buf == nil {
+ return 0, &fs.PathError{"read", f.name, fs.ErrClosed}
+ }
+ n, err := f.buf.Read(p)
+ if errors.Is(err, io.EOF) {
+ return n, io.EOF
+ } else if err != nil {
+ return n, &fs.PathError{"read", f.name, err}
+ }
+ return n, nil
+}
+
+func (f *file) Close() error {
+ if f.buf == nil {
+ return fs.ErrClosed
+ }
+ err := f.buf.Close()
+ f.buf = nil
+ if err != nil {
+ return &fs.PathError{"close", f.name, err}
+ }
+ return nil
+}
+
+func (f *file) ReadDir(n int) ([]fs.DirEntry, error) {
+ if f.dirinfo == nil {
+ return nil, &fs.PathError{"readdir", f.name, errors.New("not a directory")}
+ }
+
+ d := f.dirinfo
+ // generate dir entries if we haven't read any yet
+ if d.entryp == 0 {
+ switch {
+ case f.Community != nil:
+ posts, err := f.client.Posts(f.name, ListAll)
+ if err != nil {
+ return nil, &fs.PathError{"readdir", f.name, err}
+ }
+ for _, post := range posts {
+ post := post
+ d.entries = append(d.entries, fs.FileInfoToDirEntry(&post))
+ }
+ case f.name == "comments":
+ // special case of the comments directory.
+ // Lke a directory in Plan 9, we store directory data as its file contents.
+ // In this case we've stored Post.ID.
+ buf := make([]byte, 8) // big enough for int64
+ n, err := f.Read(buf)
+ if err != nil && !errors.Is(err, io.EOF) {
+ return nil, &fs.PathError{"readdir", f.name, err}
+ }
+ id, err := strconv.Atoi(string(buf[:n]))
+ if err != nil {
+ return nil, &fs.PathError{"readdir", f.name, err}
+ }
+ comments, err := f.client.Comments(id, ListAll)
+ if err != nil {
+ return nil, &fs.PathError{"readdir", f.name, err}
+ }
+ for _, c := range comments {
+ c := c
+ d.entries = append(d.entries, fs.FileInfoToDirEntry(&c))
+ }
+ }
+ }
+ entries := d.entries[d.entryp:]
+ if n < 0 {
+ d.entryp = len(d.entries) // advance to the end
+ if len(entries) == 0 {
+ return nil, nil
+ }
+ return entries, nil
+ }
+
+ var err error
+ if n >= len(entries) {
+ err = io.EOF
+ } else if n < len(entries) {
+ entries = entries[:n-1]
+ }
+ d.entryp += n
+ return entries, err
+}
+
+type dummy struct {
+ name string
+ isDir bool
+ contents []byte
+}
+
+func (f *dummy) Name() string { return f.name }
+func (f *dummy) Size() int64 { return int64(len(f.contents)) }
+
+func (f *dummy) Mode() fs.FileMode {
+ if f.isDir {
+ return fs.ModeDir | 0o0444
+ }
+ return 0o0444
+}
+
+func (f *dummy) ModTime() time.Time { return time.Unix(0, 0) }
+func (f *dummy) IsDir() bool { return f.isDir }
+func (f *dummy) Sys() interface{} { return nil }
+
+func (fsys *FS) init() error {
+ if fsys.Client == nil {
+ fsys.Client = &Client{}
+ }
+ if fsys.Communities == nil {
+ fsys.Communities = make(map[string]Community)
+ }
+ if fsys.Posts == nil {
+ fsys.Posts = make(map[int]Post)
+ }
+ if fsys.Comments == nil {
+ fsys.Comments = make(map[int]Comment)
+ }
+ fsys.started = true
+ return nil
+}
+
+func (fsys *FS) Open(name string) (fs.File, error) {
+ if !fs.ValidPath(name) {
+ return nil, &fs.PathError{"open", name, fs.ErrInvalid}
+ } else if strings.Contains(name, `\`) {
+ return nil, &fs.PathError{"open", name, fs.ErrInvalid}
+ }
+
+ if !fsys.started {
+ if err := fsys.init(); err != nil {
+ return nil, fmt.Errorf("start fs: %w", err)
+ }
+ }
+
+ node, err := fsys.lookupNode(name)
+ if err != nil {
+ return nil, &fs.PathError{"open", name, err}
+ }
+ return fsys.nodeOpen(node)
+}
+
+func (fsys *FS) nodeOpen(node *node) (*file, error) {
+ f := &file{
+ name: node.name,
+ client: fsys.Client,
+ }
+ switch {
+ case node.Community != nil:
+ f.Community = node.Community
+ f.buf = io.NopCloser(strings.NewReader(node.name))
+ case node.Post != nil:
+ f.Post = node.Post
+ f.buf = io.NopCloser(strings.NewReader(node.name))
+ case node.Comment != nil:
+ f.Comment = node.Comment
+ f.buf = io.NopCloser(strings.NewReader(node.Comment.Content))
+ case node.dummy != nil:
+ f.dummy = node.dummy
+ f.buf = io.NopCloser(bytes.NewReader(node.dummy.contents))
+ default:
+ f.buf = io.NopCloser(bytes.NewReader(nil))
+ }
+ if len(node.children) >= 0 && f.dirinfo == nil {
+ f.dirinfo = new(dirInfo)
+ for _, child := range node.children {
+ f.dirinfo.entries = append(f.dirinfo.entries, fs.FileInfoToDirEntry(child.Info()))
+ }
+ }
+ return f, nil
+}
+
+func (fsys *FS) lookupNode(pathname string) (*node, error) {
+ root := &node{name: ".", children: []node{}}
+ if pathname == "." {
+ return root, nil
+ }
+
+ for _, comm := range fsys.Communities {
+ root.children = append(root.children, comm.fsNode())
+ }
+ if i, n := findNode(root, pathname); i >= 0 {
+ return n, nil
+ }
+
+ // First element is a community.
+ comname, leftover, _ := strings.Cut(pathname, "/")
+ var community Community
+ var ok bool
+ community, ok = fsys.Communities[comname]
+ if !ok {
+ var err error
+ community, err = fsys.Client.LookupCommunity(comname)
+ if errors.Is(err, ErrNotFound) {
+ return nil, fs.ErrNotExist
+ } else if err != nil {
+ return nil, err
+ }
+ fsys.Communities[community.String()] = community
+ root.children = append(root.children, community.fsNode())
+ }
+ // punt; maybe we only want the community.
+ if i, n := findNode(root, pathname); i >= 0 {
+ return n, nil
+ }
+
+ // next element is a post.
+ postname, leftover, _ := strings.Cut(leftover, "/")
+ id, err := strconv.Atoi(postname)
+ if err != nil {
+ return nil, fmt.Errorf("get posts: %w", err)
+ }
+ var post Post
+ post, ok = fsys.Posts[id]
+ if !ok {
+ post, err = fsys.Client.LookupPost(id)
+ if errors.Is(err, ErrNotFound) {
+ return nil, fs.ErrNotExist
+ } else if err != nil {
+ return nil, err
+ }
+ fsys.Posts[id] = post
+ }
+ i, cnode := findNode(root, community.String())
+ if i < 0 {
+ return nil, fs.ErrNotExist
+ }
+ cnode.children = append(cnode.children, post.fsNode())
+ root.children[i] = *cnode
+ if i, n := findNode(root, pathname); i >= 0 {
+ return n, nil
+ }
+
+ // community/1234/comments/5678/content
+ // only comments left.
+ // first, trim the "comments" element to get the comment ID.
+ // a comment is a directory file.
+ commentDir := strings.TrimPrefix(leftover, "comments/")
+ commentID, leftover, _ := strings.Cut(commentDir, "/")
+ id, err = strconv.Atoi(path.Clean(commentID))
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", fs.ErrInvalid, err)
+ }
+ var comment Comment
+ comment, ok = fsys.Comments[id]
+ if !ok {
+ // Reading comments can result in too many requests
+ // so try loading comments in bulk first.
+ comments, err := fsys.Client.Comments(post.ID, ListAll)
+ if err != nil {
+ return nil, err
+ }
+ for _, c := range comments {
+ fsys.Comments[c.ID] = c
+ }
+ // Feeling lucky? We may have a match from the recent fetch.
+ // Otherwise, fall back to a slow lookup.
+ if c, ok := fsys.Comments[id]; ok {
+ comment = c
+ } else {
+ comment, err = fsys.Client.LookupComment(id)
+ if err != nil {
+ return nil, err
+ }
+ fsys.Comments[id] = comment
+ }
+ }
+
+ j, pnode := findNode(root, path.Join(community.String(), postname))
+ if j < 0 {
+ return nil, fs.ErrNotExist
+ }
+ for i, child := range pnode.children {
+ if child.name == "comments" {
+ child.children = []node{comment.fsNode()}
+ pnode.children[i] = child
+ }
+ }
+ root.children[i].children[j] = *pnode
+
+ if i, n := findNode(root, pathname); i >= 0 {
+ return n, nil
+ }
+ return nil, fs.ErrNotExist
+}
+
+func findNode(parent *node, pathname string) (int, *node) {
+ dirname, leftover, _ := strings.Cut(pathname, "/")
+ for i, child := range parent.children {
+ if child.name == dirname && leftover == "" {
+ return i, &child
+ } else if child.name == dirname {
+ return findNode(&child, leftover)
+ }
+ }
+ return -1, nil
+}
+
+func (c *Community) fsNode() node {
+ return node{
+ name: c.String(),
+ Community: c,
+ children: []node{},
+ }
+}
+
+func (p *Post) fsNode() node {
+ return node{
+ name: strconv.Itoa(p.ID),
+ Post: p,
+ children: []node{
+ node{
+ name: "title",
+ dummy: &dummy{
+ name: "title",
+ contents: []byte(p.Title),
+ },
+ },
+ node{
+ name: "creator",
+ dummy: &dummy{
+ name: "creator",
+ contents: []byte(strconv.Itoa(p.CreatorID)),
+ },
+ },
+ node{
+ name: "body",
+ dummy: &dummy{
+ name: "body",
+ contents: []byte(p.Body),
+ },
+ },
+ node{
+ name: "comments",
+ dummy: &dummy{
+ name: "comments",
+ isDir: true,
+ contents: []byte(strconv.Itoa(p.ID)),
+ },
+ },
+ },
+ }
+}
+
+func (c *Comment) fsNode() node {
+ return node{
+ name: strconv.Itoa(c.ID),
+ Comment: c,
+ children: []node{
+ node{
+ name: "content",
+ dummy: &dummy{
+ name: "content",
+ contents: []byte(c.Content),
+ },
+ },
+ node{
+ name: "creator",
+ dummy: &dummy{
+ name: "creator",
+ contents: []byte(strconv.Itoa(c.CreatorID)),
+ },
+ },
+ node{
+ name: "references",
+ dummy: &dummy{
+ name: "references",
+ contents: []byte(fmt.Sprintf("%v", c.References)),
+ },
+ },
+ },
+ }
+}
blob - /dev/null
blob + e7c7e0b9fd37c98d54a0584fbe1146fdd940d2c6 (mode 644)
--- /dev/null
+++ go.mod
+module olowe.co/lemmy
+
+go 1.18
+
+require 9fans.net/go v0.0.4 // indirect
blob - /dev/null
blob + 4a6085f1fdc7322b4d59ae74b031a8c10123c6d3 (mode 644)
--- /dev/null
+++ go.sum
+9fans.net/go v0.0.4 h1:g7K+b5I1PlSBFLnjuco3LAx5boK39UUl0Gsrmw6Gl2U=
+9fans.net/go v0.0.4/go.mod h1:lfPdxjq9v8pVQXUMBCx5EO5oLXWQFlKRQgs1kEkjoIM=
blob - /dev/null
blob + 7d45b292a6c8fa92978a1ee5156234729321afbd (mode 644)
--- /dev/null
+++ lemmy.go
+package lemmy
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "strconv"
+ "strings"
+ "time"
+)
+
+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"`
+}
+
+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) IsDir() bool { return true }
+func (c *Community) Sys() interface{} { return nil }
+
+func (c Community) String() string {
+ if c.Local {
+ return c.FName
+ }
+ noscheme := strings.TrimPrefix(c.ActorID, "https://")
+ instance, _, _ := strings.Cut(noscheme, "/")
+ return fmt.Sprintf("%s@%s", c.FName, instance)
+}
+
+type Post struct {
+ ID int
+ Title string `json:"name"`
+ Body string
+ CreatorID int `json:"creator_id"`
+ // Published time.Time
+ // Updated time.Time
+}
+
+func (p *Post) Name() string { return strconv.Itoa(p.ID) }
+
+func (p *Post) Size() int64 {
+ return int64(len(p.Body))
+}
+
+func (p *Post) Mode() fs.FileMode { return fs.ModeDir | 0o0444 }
+func (p *Post) ModTime() time.Time { return time.Unix(0, 0) }
+func (p *Post) IsDir() bool { return true }
+func (p *Post) Sys() interface{} { return nil }
+
+type Comment struct {
+ ID int
+ PostID int `json:"post_id"`
+ // Holds ordered comment IDs referenced by this comment
+ // for threading.
+ References []int
+ Content string
+ CreatorID int `json:"creator_id"`
+ // Published time.Time
+}
+
+func (c *Comment) Name() string { return strconv.Itoa(c.ID) }
+
+func (c *Comment) Size() int64 { return 0 }
+func (c *Comment) Mode() fs.FileMode { return fs.ModeDir | 0o0444 }
+func (c *Comment) ModTime() time.Time { return time.Unix(0, 0) } // TODO c.Updated
+func (c *Comment) IsDir() bool { return true }
+func (c *Comment) Sys() interface{} { return nil }
+
+// parseCommentPath returns the comment IDs from the path field of a Comment.
+func parseCommentPath(s string) ([]int, error) {
+ elems := strings.Split(s, ".")
+ if len(elems) == 1 {
+ return nil, errors.New("only one comment in path")
+ }
+ if elems[0] != "0" {
+ return nil, fmt.Errorf("expected comment id 0, got %s", elems[0])
+ }
+ refs := make([]int, len(elems))
+ for _, ele := range elems {
+ id, err := strconv.Atoi(ele)
+ if err != nil {
+ return nil, fmt.Errorf("parse comment id: %w", err)
+ }
+ refs = append(refs, id)
+ }
+ return refs, nil
+}
+
+type Creator struct {
+ ID int
+ Username string `json:"name"`
+}