Commit Diff


commit - /dev/null
commit + 4a5e46556d98c2f01ca29f5f7d42dcc46f549154
blob - /dev/null
blob + c1c9cb50f164d80716c4c7488f1d479ccd6e2b33 (mode 644)
--- /dev/null
+++ LICENSE
@@ -0,0 +1,13 @@
+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
@@ -0,0 +1,37 @@
+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
@@ -0,0 +1,315 @@
+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
@@ -0,0 +1,450 @@
+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
@@ -0,0 +1,5 @@
+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
@@ -0,0 +1,2 @@
+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
@@ -0,0 +1,99 @@
+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"`
+}