commit - 9ee1d615947173ba7967275d061395ed7b47ac72
commit + fd79aa766e936d1767d6fd61fa62e448f1de1bc2
blob - /dev/null
blob + 7cd6f5a89adc50af9df979a5a614218e810038ea (mode 644)
--- /dev/null
+++ src/hn/hn.go
+// Package hn provides a filesystem interface to items on Hacker News.
+package hn
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "html"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const APIRoot = "https://hacker-news.firebaseio.com/v0"
+
+type Item struct {
+ ID int
+ Type string
+ By string
+ Time int
+ Text string
+ Parent int
+ URL string
+ Title string
+}
+
+func (it *Item) Name() string { return strconv.Itoa(it.ID) }
+func (it *Item) Size() int64 { r := toMessage(it); return r.Size() }
+func (it *Item) Mode() fs.FileMode { return 0o444 }
+func (it *Item) ModTime() time.Time { return time.Unix(int64(it.Time), 0) }
+func (it *Item) IsDir() bool { return false }
+func (it *Item) Sys() any { return nil }
+
+type FS struct {
+ cache fs.FS
+}
+
+func CacheDirFS(name string) *FS {
+ return &FS{cache: os.DirFS(name)}
+}
+
+func (fsys *FS) Open(name string) (fs.File, error) {
+ if !fs.ValidPath(name) {
+ return nil, &fs.PathError{"open", name, fs.ErrInvalid}
+ }
+ name = path.Clean(name)
+ switch name {
+ case ".":
+ return nil, fmt.Errorf("TODO")
+ default:
+ if _, err := strconv.Atoi(name); err != nil {
+ return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+ }
+ }
+ if fsys.cache != nil {
+ if f, err := fsys.cache.Open(name); err == nil {
+ return f, nil
+ }
+ }
+
+ u := fmt.Sprintf("%s/item/%s.json", APIRoot, name)
+ resp, err := http.Get(u)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, err
+ }
+ return &file{rc: resp.Body}, nil
+}
+
+type file struct {
+ rc io.ReadCloser
+ item *Item
+ msg *bytes.Reader
+}
+
+func (f *file) Read(p []byte) (int, error) {
+ var n int
+ if f.item == nil {
+ if err := json.NewDecoder(f.rc).Decode(&f.item); err != nil {
+ return n, fmt.Errorf("decode item: %v", err)
+ }
+ }
+ if f.msg == nil {
+ f.msg = toMessage(f.item)
+ }
+ return f.msg.Read(p)
+}
+
+func (f *file) Stat() (fs.FileInfo, error) { return f.item, nil }
+
+func (f *file) Close() error {
+ f.msg = nil
+ return f.rc.Close()
+}
+
+func toMessage(item *Item) *bytes.Reader {
+ buf := &bytes.Buffer{}
+ fmt.Fprintf(buf, "From: %s\n", item.By)
+ fmt.Fprintf(buf, "Message-ID: <%d@news.ycombinator.com>\n", item.ID)
+ fmt.Fprintf(buf, "Date: %s\n", time.Unix(int64(item.Time), 0).Format(time.RFC1123Z))
+ if item.Parent != 0 {
+ fmt.Fprintf(buf, "References: <%d@news.ycombinator.com>\n", item.Parent)
+ }
+ if item.Title != "" {
+ fmt.Fprintf(buf, "Subject: %s\n", item.Title)
+ }
+ fmt.Fprintln(buf)
+ if item.URL != "" {
+ fmt.Fprintln(buf, item.URL)
+ }
+ if item.Text != "" {
+ fmt.Fprintln(buf, strings.ReplaceAll(html.UnescapeString(item.Text), "<p>", "\n\n"))
+ }
+ return bytes.NewReader(buf.Bytes())
+}
blob - /dev/null
blob + cd77eb626540f20621904d81d32281846ba20376 (mode 644)
--- /dev/null
+++ src/hn/hn_test.go
+package hn
+
+import (
+ "io"
+ "os"
+ "testing"
+)
+
+func TestFS(t *testing.T) {
+ fsys := &FS{}
+ f, err := fsys.Open("39845126")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if _, err := io.Copy(os.Stderr, f); err != nil {
+ t.Fatal(err)
+ }
+}