commit fcf319e83dbec9b824aae4d62e9a4d99cc473dd7 from: Oliver Lowe date: Sun Oct 15 07:06:16 2023 UTC Rework, move filesystem into its own package Major rework which keeps Stat, Read and ReadDir away from the filesystem tree and more to the file. The fs package implements extra functionality that doesn't really have anything to do with the core package. It also does not need to know how the core lemmy package is implemented to do anything, so may as well enforce the boundary explicitly. commit - 796e17a92de54043f25d21e24bf89098def4f9c3 commit + fcf319e83dbec9b824aae4d62e9a4d99cc473dd7 blob - 27a30aa6d4197d6488e180e6605d6f569200b5ae (mode 644) blob + /dev/null --- fs.go +++ /dev/null @@ -1,474 +0,0 @@ -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 @@ -0,0 +1,33 @@ +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 @@ -0,0 +1,37 @@ +/* +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 @@ -0,0 +1,221 @@ +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 @@ -0,0 +1,157 @@ +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 @@ -0,0 +1,25 @@ +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 @@ -70,9 +70,9 @@ type Comment struct { 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.