commit fd79aa766e936d1767d6fd61fa62e448f1de1bc2 from: Oliver Lowe date: Thu Mar 28 02:54:00 2024 UTC add hacker news package 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), "

", "\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) + } +}