commit - 796e17a92de54043f25d21e24bf89098def4f9c3
commit + fcf319e83dbec9b824aae4d62e9a4d99cc473dd7
blob - 27a30aa6d4197d6488e180e6605d6f569200b5ae (mode 644)
blob + /dev/null
--- fs.go
+++ /dev/null
-package lemmy
-
-import (
- "bytes"
- "errors"
- "fmt"
- "io"
- "io/fs"
- "path"
- "strconv"
- "strings"
- "time"
-)
-
-/*
-FS is a read-only filesystem interface to a Lemmy instance.
-The root of the filesystem holds directories for each community known to the filesystem.
-Local communities are named by their plain name verbatim.
-Remote communities have the instance address as a suffix. For example:
-
- golang/
- plan9@lemmy.sdf.org/
- openbsd@lemmy.sdf.org/
-
-Each community directory holds posts.
-Each post has associated a directory numbered by its ID.
-Within each post are the following entries:
-
- body Text describing, or accompanying, the post.
- creator The numeric user ID of the post's author.
- title The post's title.
- url A URL pointing to a picture or website, usually as the
- subject of the post if present.
- comments Directory holding user-generated discussion.
- Described in more detail below.
-
-The comments directory consists of one numbered directory per comment,
-numbered by its unique comment ID.
-Each of these directories contains the following entries:
-
- content The text body.
- creator User ID of the comment's author.
- references A list of comment IDs referenced by this comment, one
- per line. The first line is the immediately referenced
- comment (the parent); the second is the grandparent and
- so on. This can be used by readers to render discussion
- threads.
-
-FS satisfies io/fs.FS.
-*/
-type FS struct {
- Client *Client
- // Communities holds a cache of communities.
- Communities map[string]Community
- 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 {
- 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.Client.authToken != "" {
- subscribed, err := fsys.Client.Communities(ListSubscribed)
- if err != nil {
- return fmt.Errorf("load subscriptions: %w", err)
- }
- for _, c := range subscribed {
- fsys.Communities[c.Name()] = c
- }
- }
- }
- 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}
- }
- name = path.Clean(name)
-
- 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: ".",
- dummy: &dummy{
- name: ".",
- isDir: true,
- },
- children: []node{},
- }
- for _, c := range fsys.Communities {
- c := c
- root.children = append(root.children, c.fsNode())
- }
- if pathname == "." {
- return root, nil
- }
-
- // First element is a community.
- comname, leftover, _ := strings.Cut(pathname, "/")
- var community Community
- 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.Name()] = 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)
- }
- post, err := fsys.Client.LookupPost(id)
- if errors.Is(err, ErrNotFound) {
- return nil, fs.ErrNotExist
- } else if err != nil {
- return nil, err
- }
- 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)
- }
- comment, err := fsys.Client.LookupComment(id)
- if err != nil {
- return nil, err
- }
-
- 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: "url",
- dummy: &dummy{
- name: "url",
- contents: []byte(p.URL),
- },
- },
- 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 + e8e734b105bceb685fcf884777f81a0de929c2cf (mode 644)
--- /dev/null
+++ fs/dir.go
+package fs
+
+import (
+ "io"
+ "io/fs"
+)
+
+type dirInfo struct {
+ entries []fs.DirEntry
+ entryp int
+}
+
+func (d *dirInfo) ReadDir(n int) ([]fs.DirEntry, error) {
+ 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 d.entryp >= len(d.entries) {
+ err = io.EOF
+ } else {
+ entries = entries[:n-1]
+ }
+ d.entryp += n
+ return entries, err
+}
blob - /dev/null
blob + 9227e6b58f1c63117d90ac3e10dae531fa84ec3f (mode 644)
--- /dev/null
+++ fs/doc.go
+/*
+FS is a read-only filesystem interface to a Lemmy instance.
+The root of the filesystem holds directories for each community known to the filesystem.
+Local communities are named by their plain name verbatim.
+Remote communities have the instance address as a suffix. For example:
+
+ golang/
+ plan9@lemmy.sdf.org/
+ openbsd@lemmy.sdf.org/
+
+Each community directory holds posts.
+Each post has associated a directory numbered by its ID.
+Within each post are the following entries:
+
+ body Text describing, or accompanying, the post.
+ creator The numeric user ID of the post's author.
+ title The post's title.
+ url A URL pointing to a picture or website, usually as the
+ subject of the post if present.
+ 123... Numbered files containing user discussion.
+ Described in more detail below.
+
+A comment file is named by its unique comment ID.
+Its contents are a RFC 5322 message.
+The message body contains the text content of the comment.
+The header contains the following fields:
+
+ From User ID of the comment's author.
+ References A list of comment IDs referenced by this comment, one
+ per line. The first line is the immediately referenced
+ comment (the parent); the second is the grandparent and
+ so on. This can be used by readers to render discussion
+ threads.
+
+FS satisfies io/fs.FS.
+*/
+package fs
blob - /dev/null
blob + 934c074d7f631ab82c3d20cbb7fed934b5a413f2 (mode 644)
--- /dev/null
+++ fs/file.go
+package fs
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "olowe.co/lemmy"
+)
+
+type fakeStat struct {
+ name string
+ size int64
+ mode fs.FileMode
+ mtime time.Time
+}
+
+func (s *fakeStat) Name() string { return s.name }
+func (s *fakeStat) Size() int64 { return s.size }
+func (s *fakeStat) Mode() fs.FileMode { return s.mode }
+func (s *fakeStat) ModTime() time.Time { return s.mtime }
+func (s *fakeStat) IsDir() bool { return s.mode.IsDir() }
+func (s *fakeStat) Sys() any { return nil }
+
+type dummy struct {
+ name string
+ mode fs.FileMode
+ mtime time.Time
+ contents []byte
+ dirinfo *dirInfo
+ buf io.ReadCloser
+}
+
+func (f *dummy) Name() string { return f.name }
+func (f *dummy) IsDir() bool { return f.mode.IsDir() }
+func (f *dummy) Type() fs.FileMode { return f.mode.Type() }
+func (f *dummy) Info() (fs.FileInfo, error) { return f.Stat() }
+
+func (f *dummy) Stat() (fs.FileInfo, error) {
+ return &fakeStat{
+ name: f.name,
+ mode: f.mode,
+ size: int64(len(f.contents)),
+ mtime: f.mtime,
+ }, nil
+}
+
+func (f *dummy) Read(p []byte) (int, error) {
+ if f.buf == nil {
+ f.buf = io.NopCloser(bytes.NewReader(f.contents))
+ }
+ return f.buf.Read(p)
+}
+
+func (f *dummy) Close() error {
+ if f.buf == nil {
+ return nil
+ }
+ err := f.buf.Close()
+ f.buf = nil
+ return err
+}
+
+func (f *dummy) ReadDir(n int) ([]fs.DirEntry, error) {
+ if !f.mode.IsDir() {
+ return nil, &fs.PathError{"readdir", f.name, fmt.Errorf("not a directory")}
+ } else if f.dirinfo == nil {
+ // TODO(otl): is this accidental? maybe return an error here.
+ return nil, &fs.PathError{"readdir", f.name, fmt.Errorf("no dirinfo to track reads")}
+ }
+
+ return f.dirinfo.ReadDir(n)
+}
+
+type comFile struct {
+ name string
+ dirinfo *dirInfo
+ client *lemmy.Client
+ buf io.ReadCloser
+}
+
+func (f *comFile) Read(p []byte) (int, error) {
+ if f.buf == nil {
+ f.buf = io.NopCloser(strings.NewReader("directory"))
+ }
+ return f.buf.Read(p)
+}
+
+func (f *comFile) Close() error {
+ if f.buf == nil || f.dirinfo == nil {
+ return fs.ErrClosed
+ }
+ f.dirinfo = nil
+ err := f.buf.Close()
+ f.buf = nil
+ return err
+}
+
+func (f *comFile) Stat() (fs.FileInfo, error) {
+ community, err := f.client.LookupCommunity(f.name)
+ if err != nil {
+ return nil, &fs.PathError{"stat", f.name, err}
+ }
+ return &community, nil
+}
+
+func (f *comFile) ReadDir(n int) ([]fs.DirEntry, error) {
+ if f.dirinfo == nil {
+ f.dirinfo = new(dirInfo)
+ posts, err := f.client.Posts(f.name, lemmy.ListAll)
+ if err != nil {
+ return nil, &fs.PathError{"readdir", f.name, err}
+ }
+ for i := range posts {
+ f.dirinfo.entries = append(f.dirinfo.entries, fs.FileInfoToDirEntry(&posts[i]))
+ }
+ }
+ return f.dirinfo.ReadDir(n)
+}
+
+type postFile struct {
+ post lemmy.Post
+ dirinfo *dirInfo
+ client *lemmy.Client
+ buf io.ReadCloser
+}
+
+func (f *postFile) Stat() (fs.FileInfo, error) {
+ name := strconv.Itoa(f.post.ID)
+ var err error
+ f.post, err = f.client.LookupPost(f.post.ID)
+ if errors.Is(err, lemmy.ErrNotFound) {
+ return nil, &fs.PathError{"stat", name, fs.ErrNotExist}
+ } else if err != nil {
+ return nil, &fs.PathError{"stat", name, err}
+ }
+ return &f.post, nil
+}
+
+func (f *postFile) Read(p []byte) (int, error) {
+ if f.buf == nil {
+ return 0, io.EOF
+ }
+ return f.buf.Read(p)
+}
+
+func (f *postFile) Close() error {
+ if f.buf == nil || f.dirinfo == nil {
+ return fs.ErrClosed
+ }
+ f.dirinfo = nil
+ err := f.buf.Close()
+ f.buf = nil
+ return err
+}
+
+func (f *postFile) ReadDir(n int) ([]fs.DirEntry, error) {
+ name := strconv.Itoa(f.post.ID)
+ if f.dirinfo == nil {
+ f.dirinfo = new(dirInfo)
+ for _, v := range postFiles(f.post) {
+ f.dirinfo.entries = append(f.dirinfo.entries, v)
+ }
+ comments, err := f.client.Comments(f.post.ID, lemmy.ListAll)
+ if err != nil {
+ return nil, &fs.PathError{"readdir", name, err}
+ }
+ for i := range comments {
+ f.dirinfo.entries = append(f.dirinfo.entries, fs.FileInfoToDirEntry(&comments[i]))
+ }
+ }
+ return f.dirinfo.ReadDir(n)
+}
+
+func postFiles(post lemmy.Post) map[string]*dummy {
+ m := make(map[string]*dummy)
+ m["body"] = &dummy{
+ name: "body",
+ mode: 0444,
+ contents: []byte(post.Body),
+ mtime: post.ModTime(),
+ }
+ m["creator"] = &dummy{
+ name: "creator",
+ mode: 0444,
+ contents: []byte(strconv.Itoa(post.CreatorID)),
+ mtime: post.ModTime(),
+ }
+ m["title"] = &dummy{
+ name: "title",
+ mode: 0444,
+ contents: []byte(post.Title),
+ mtime: post.ModTime(),
+ }
+ m["url"] = &dummy{
+ name: "url",
+ mode: 0444,
+ contents: []byte(post.URL),
+ mtime: post.ModTime(),
+ }
+ return m
+}
+
+func commentFile(c *lemmy.Comment) *dummy {
+ buf := &bytes.Buffer{}
+ fmt.Fprintf(buf, "From: %d\n", c.CreatorID)
+ fmt.Fprintln(buf)
+ fmt.Fprintln(buf, c.Content)
+ return &dummy{
+ name: c.Name(),
+ mode: c.Mode(),
+ mtime: c.ModTime(),
+ buf: io.NopCloser(buf),
+ }
+}
blob - /dev/null
blob + a2e673c958113926567be080fcc56290cd5dda17 (mode 644)
--- /dev/null
+++ fs/fs.go
+package fs
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+
+ "olowe.co/lemmy"
+)
+
+type FS struct {
+ Client *lemmy.Client
+ // Communities holds a cache of communities.
+ Communities map[string]lemmy.Community
+ Posts map[int]lemmy.Post
+ started bool
+}
+
+func (fsys *FS) start() error {
+ if fsys.Client == nil {
+ fsys.Client = &lemmy.Client{}
+ }
+
+ 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.Posts == nil {
+ fsys.Posts = make(map[int]lemmy.Post)
+ }
+
+ 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}
+ }
+ name = path.Clean(name)
+
+ if !fsys.started {
+ if err := fsys.start(); err != nil {
+ return nil, fmt.Errorf("start fs: %w", err)
+ }
+ }
+
+ if name == "." {
+ return fsys.openRoot()
+ }
+
+ elems := strings.Split(name, "/")
+
+ community, ok := fsys.Communities[elems[0]]
+ if !ok {
+ var err error
+ community, err = fsys.Client.LookupCommunity(elems[0])
+ if errors.Is(err, lemmy.ErrNotFound) {
+ return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+ } else if err != nil {
+ return nil, &fs.PathError{"open", name, err}
+ }
+ fsys.Communities[elems[0]] = community
+ }
+ if len(elems) == 1 {
+ return &comFile{
+ name: community.Name(),
+ buf: io.NopCloser(strings.NewReader(community.Name())),
+ client: fsys.Client,
+ }, nil
+ }
+
+ id, err := strconv.Atoi(elems[1])
+ if err != nil {
+ return nil, &fs.PathError{"open", name, fmt.Errorf("bad post id")}
+ }
+ post, ok := fsys.Posts[id]
+ if !ok || len(elems) == 2 {
+ post, err = fsys.Client.LookupPost(id)
+ if errors.Is(err, lemmy.ErrNotFound) {
+ delete(fsys.Posts, id)
+ return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+ } else if err != nil {
+ return nil, &fs.PathError{"open", name, err}
+ }
+ fsys.Posts[id] = post
+ }
+
+ if len(elems) == 2 {
+ return &postFile{
+ post: post,
+ buf: io.NopCloser(strings.NewReader("")),
+ client: fsys.Client,
+ }, nil
+ }
+
+ // At the bottom of the tree. If there's more than one element left then
+ // it cannot exist.
+ if len(elems) > 3 {
+ return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+ }
+
+ // if it's not a numbered directory (comment) it must be a regular file.
+ if !strings.ContainsAny(elems[2], "0123456789") {
+ files := postFiles(post)
+ if f, ok := files[elems[2]]; ok {
+ return f, nil
+ }
+ return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+ }
+
+ id, err = strconv.Atoi(elems[2])
+ if err != nil {
+ return nil, &fs.PathError{"open", name, fmt.Errorf("bad comment id")}
+ }
+ comment, err := fsys.Client.LookupComment(id)
+ if errors.Is(err, lemmy.ErrNotFound) {
+ return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+ } else if err != nil {
+ return nil, &fs.PathError{"open", name, err}
+ }
+ return commentFile(&comment), nil
+}
+
+func (fsys *FS) openRoot() (fs.File, error) {
+ dirinfo := new(dirInfo)
+ for _, c := range fsys.Communities {
+ de := fs.FileInfoToDirEntry(&c)
+ dirinfo.entries = append(dirinfo.entries, de)
+ }
+ return &dummy{
+ name: ".",
+ mode: fs.ModeDir | 0444,
+ contents: []byte("hello, world!"),
+ dirinfo: dirinfo,
+ mtime: time.Now(),
+ }, nil
+}
blob - /dev/null
blob + 318c46eb97c837bb2f9b4c64285b56c8d7aaaa1c (mode 644)
--- /dev/null
+++ fs/fs_test.go
+package fs
+
+import (
+ "testing"
+ "testing/fstest"
+
+ "olowe.co/lemmy"
+)
+
+func TestFS(t *testing.T) {
+ fsys := &FS{
+ Client: &lemmy.Client{
+ Address: "ds9.lemmy.ml",
+ Debug: true,
+ },
+ }
+ _, err := fsys.Open("zzztestcommunity1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := fstest.TestFS(fsys, "zzztestcommunity1", "zzztestcommunity1/447/title", "zzztestcommunity1/447/331"); err != nil {
+ t.Fatal(err)
+ }
+}
blob - ff18fba3be4189956d8901b3a968fcdee9b4c39d
blob + 18cc639701c8112104fc28817abac957b11744b8
--- lemmy.go
+++ lemmy.go
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) Mode() fs.FileMode { return 0444 }
func (c *Comment) ModTime() time.Time { return time.Unix(0, 0) } // TODO c.Updated
-func (c *Comment) IsDir() bool { return true }
+func (c *Comment) IsDir() bool { return c.Mode().IsDir() }
func (c *Comment) Sys() interface{} { return nil }
// parseCommentPath returns the comment IDs from the path field of a Comment.