Commit Diff


commit - 9ee1d615947173ba7967275d061395ed7b47ac72
commit + fd79aa766e936d1767d6fd61fa62e448f1de1bc2
blob - /dev/null
blob + 7cd6f5a89adc50af9df979a5a614218e810038ea (mode 644)
--- /dev/null
+++ src/hn/hn.go
@@ -0,0 +1,122 @@
+// 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
@@ -0,0 +1,19 @@
+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)
+	}
+}