; fall back to .
+ root := findNode(doc, atom.Article)
+ if root == nil {
+ root = findNode(doc, atom.Main)
+ }
+ if root == nil {
+ root = findNode(doc, atom.Body)
+ }
+ if root == nil {
+ root = doc
+ }
+ var sb strings.Builder
+ renderMarkdown(&sb, root, 0)
+ return cleanupMarkdown(sb.String()), nil
+}
+
+// ---------------------------------------------------------------------------
+// HTML → Markdown walker
+// ---------------------------------------------------------------------------
+
+func renderMarkdown(sb *strings.Builder, n *html.Node, depth int) {
+ switch n.Type {
+ case html.TextNode:
+ sb.WriteString(n.Data)
+ return
+ case html.ElementNode:
+ // skip invisible/structural elements
+ switch n.DataAtom {
+ case atom.Script, atom.Style, atom.Nav, atom.Header,
+ atom.Footer, atom.Form, atom.Button, atom.Input,
+ atom.Iframe, atom.Noscript, atom.Aside:
+ return
+ }
+ }
+
+ if n.Type != html.ElementNode {
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ renderMarkdown(sb, c, depth)
+ }
+ return
+ }
+
+ switch n.DataAtom {
+ case atom.H1, atom.H2, atom.H3, atom.H4, atom.H5, atom.H6:
+ var level int
+ switch n.DataAtom {
+ case atom.H1:
+ level = 1
+ case atom.H2:
+ level = 2
+ case atom.H3:
+ level = 3
+ case atom.H4:
+ level = 4
+ case atom.H5:
+ level = 5
+ default:
+ level = 6
+ }
+ sb.WriteString("\n" + strings.Repeat("#", level) + " ")
+ renderChildren(sb, n, depth)
+ sb.WriteString("\n\n")
+
+ case atom.P:
+ sb.WriteString("\n")
+ renderChildren(sb, n, depth)
+ sb.WriteString("\n\n")
+
+ case atom.Br:
+ sb.WriteString("\n")
+
+ case atom.Hr:
+ sb.WriteString("\n---\n\n")
+
+ case atom.A:
+ href := attr(n, "href")
+ var inner strings.Builder
+ renderChildren(&inner, n, depth)
+ text := strings.TrimSpace(inner.String())
+ if text == "" {
+ return // icon-only link — skip (social share buttons, etc.)
+ }
+ if href == "" || href == text {
+ sb.WriteString(text)
+ } else {
+ fmt.Fprintf(sb, "[%s](%s)", text, href)
+ }
+
+ case atom.Strong, atom.B:
+ sb.WriteString("**")
+ renderChildren(sb, n, depth)
+ sb.WriteString("**")
+
+ case atom.Em, atom.I:
+ sb.WriteString("*")
+ renderChildren(sb, n, depth)
+ sb.WriteString("*")
+
+ case atom.Code:
+ // Check if parent is — handled there.
+ if n.Parent != nil && n.Parent.DataAtom == atom.Pre {
+ renderChildren(sb, n, depth)
+ return
+ }
+ sb.WriteString("`")
+ renderChildren(sb, n, depth)
+ sb.WriteString("`")
+
+ case atom.Pre:
+ sb.WriteString("\n```\n")
+ renderChildren(sb, n, depth)
+ sb.WriteString("\n```\n\n")
+
+ case atom.Blockquote:
+ var inner strings.Builder
+ renderChildren(&inner, n, depth+1)
+ for _, line := range strings.Split(strings.TrimRight(inner.String(), "\n"), "\n") {
+ fmt.Fprintf(sb, "> %s\n", line)
+ }
+ sb.WriteString("\n")
+
+ case atom.Ul:
+ sb.WriteString("\n")
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if c.DataAtom == atom.Li {
+ var inner strings.Builder
+ renderChildren(&inner, c, depth)
+ text := strings.TrimSpace(inner.String())
+ fmt.Fprintf(sb, "%s- %s\n", strings.Repeat(" ", depth), text)
+ }
+ }
+ sb.WriteString("\n")
+
+ case atom.Ol:
+ sb.WriteString("\n")
+ i := 1
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if c.DataAtom == atom.Li {
+ var inner strings.Builder
+ renderChildren(&inner, c, depth)
+ text := strings.TrimSpace(inner.String())
+ fmt.Fprintf(sb, "%s%d. %s\n", strings.Repeat(" ", depth), i, text)
+ i++
+ }
+ }
+ sb.WriteString("\n")
+
+ case atom.Img:
+ alt := attr(n, "alt")
+ src := attr(n, "src")
+ if alt != "" || src != "" {
+ fmt.Fprintf(sb, "", alt, src)
+ }
+
+ default:
+ renderChildren(sb, n, depth)
+ }
+}
+
+func renderChildren(sb *strings.Builder, n *html.Node, depth int) {
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ renderMarkdown(sb, c, depth)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+func findNode(n *html.Node, a atom.Atom) *html.Node {
+ if n.Type == html.ElementNode && n.DataAtom == a {
+ return n
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if found := findNode(c, a); found != nil {
+ return found
+ }
+ }
+ return nil
+}
+
+func attr(n *html.Node, key string) string {
+ for _, a := range n.Attr {
+ if a.Key == key {
+ return a.Val
+ }
+ }
+ return ""
+}
+
+var multiNewline = regexp.MustCompile(`\n{3,}`)
+
+func cleanupMarkdown(s string) string {
+ s = multiNewline.ReplaceAllString(s, "\n\n")
+ return strings.TrimSpace(s) + "\n"
+}
diff --git a/feed/fetch.go b/feed/fetch.go
new file mode 100644
index 0000000..bbb08ad
--- /dev/null
+++ b/feed/fetch.go
@@ -0,0 +1,354 @@
+package main
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// nsDecl strips xmlns and xmlns:prefix declarations (both quote styles).
+// Go's encoding/xml only matches unqualified tags against elements with no
+// namespace; Atom feeds put every element in the Atom namespace, so without
+// stripping the declaration none of our struct field tags would match.
+var nsDecl = regexp.MustCompile(`\s+xmlns(?::\w+)?=(?:"[^"]*"|'[^']*')`)
+
+// nsPrefix strips namespace prefixes from element start/end tags
+// (e.g. → , → ).
+// After stripping xmlns declarations the prefixes become undeclared; Go's
+// xml decoder may reject them even in non-strict mode.
+var nsPrefix = regexp.MustCompile(`(?)[A-Za-z][\w-]*:`)
+
+func normalizeXML(data []byte) []byte {
+ data = nsDecl.ReplaceAll(data, nil)
+ data = nsPrefix.ReplaceAll(data, []byte("$1"))
+ return data
+}
+
+// ---------------------------------------------------------------------------
+// RSS / Atom XML structures
+// ---------------------------------------------------------------------------
+
+type rssFeed struct {
+ XMLName xml.Name `xml:"rss"`
+ Channel rssChannel `xml:"channel"`
+}
+
+type rssChannel struct {
+ Title string `xml:"title"`
+ Items []rssItem `xml:"item"`
+}
+
+type rssItem struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ GUID string `xml:"guid"`
+ PubDate string `xml:"pubDate"`
+ Description string `xml:"description"`
+}
+
+type atomFeed struct {
+ XMLName xml.Name `xml:"feed"`
+ Title atomText `xml:"title"`
+ Entries []atomEntry `xml:"entry"`
+}
+
+type atomText struct {
+ Value string `xml:",chardata"`
+}
+
+type atomEntry struct {
+ ID string `xml:"id"`
+ Title atomText `xml:"title"`
+ Updated string `xml:"updated"`
+ Links []struct {
+ Href string `xml:"href,attr"`
+ Rel string `xml:"rel,attr"`
+ } `xml:"link"`
+ Summary atomText `xml:"summary"`
+ Content atomText `xml:"content"`
+}
+
+// ---------------------------------------------------------------------------
+// Fetch result
+// ---------------------------------------------------------------------------
+
+type FetchResult struct {
+ FeedTitle string
+ Entries []*Entry
+ ETag string
+ LastModified string
+ NotModified bool
+}
+
+// ---------------------------------------------------------------------------
+// Fetch and parse
+// ---------------------------------------------------------------------------
+
+func fetchFeed(url, etag, lastModified string) (*FetchResult, error) {
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ if etag != "" {
+ req.Header.Set("If-None-Match", etag)
+ }
+ if lastModified != "" {
+ req.Header.Set("If-Modified-Since", lastModified)
+ }
+ req.Header.Set("User-Agent", "Feeds/1.0 (acme feed reader)")
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusNotModified {
+ return &FetchResult{NotModified: true}, nil
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) // 8 MiB limit
+ if err != nil {
+ return nil, err
+ }
+
+ result, err := parseFeed(body)
+ if err != nil {
+ return nil, err
+ }
+ result.ETag = resp.Header.Get("ETag")
+ result.LastModified = resp.Header.Get("Last-Modified")
+ return result, nil
+}
+
+func parseFeed(data []byte) (*FetchResult, error) {
+ // Normalise: strip xmlns declarations and namespace prefixes from element
+ // names so encoding/xml plain-name tags match regardless of feed format.
+ clean := normalizeXML(data)
+
+ // Peek at root element local name to decide RSS vs Atom.
+ dec := xml.NewDecoder(bytes.NewReader(clean))
+ dec.Strict = false
+ dec.Entity = xml.HTMLEntity
+
+ for {
+ tok, err := dec.Token()
+ if err != nil {
+ return nil, fmt.Errorf("XML parse: %w", err)
+ }
+ se, ok := tok.(xml.StartElement)
+ if !ok {
+ continue
+ }
+ switch se.Name.Local {
+ case "rss":
+ return parseRSS(clean)
+ case "feed":
+ return parseAtom(clean)
+ case "RDF":
+ return parseRDF1(clean)
+ default:
+ return nil, fmt.Errorf("unrecognized feed root element: %s", se.Name.Local)
+ }
+ }
+}
+
+func parseRSS(data []byte) (*FetchResult, error) {
+ var feed rssFeed
+ dec := xml.NewDecoder(bytes.NewReader(data))
+ dec.Strict = false
+ dec.Entity = xml.HTMLEntity
+ if err := dec.Decode(&feed); err != nil {
+ return nil, fmt.Errorf("RSS decode: %w", err)
+ }
+
+ fetchedAt := time.Now().UTC().Format(time.RFC3339)
+ result := &FetchResult{FeedTitle: strings.TrimSpace(feed.Channel.Title)}
+
+ for _, item := range feed.Channel.Items {
+ ts := parseRSSDate(item.PubDate)
+ if ts == "" {
+ ts = fetchedAt
+ }
+ guid := strings.TrimSpace(item.GUID)
+ if guid == "" {
+ guid = synthGUID(item.Link, item.Title)
+ }
+ summary := strings.TrimSpace(item.Description)
+ if len(summary) > 4096 {
+ summary = truncateUTF8(summary, 4096)
+ }
+ result.Entries = append(result.Entries, &Entry{
+ GUID: guid,
+ Timestamp: ts,
+ Title: strings.TrimSpace(item.Title),
+ URL: strings.TrimSpace(item.Link),
+ Summary: summary,
+ FetchedAt: fetchedAt,
+ })
+ }
+ return result, nil
+}
+
+// RSS 1.0 / RDF Site Summary structures.
+// After namespace stripping, the root is , holds the feed
+// title, and - elements are direct children of (not of ).
+// dc:date is stripped to ; dc:description may appear too but we use
+// which is in the core RSS 1.0 namespace.
+type rdf1Feed struct {
+ XMLName xml.Name `xml:"RDF"`
+ Channel rdf1Channel `xml:"channel"`
+ Items []rdf1Item `xml:"item"`
+}
+
+type rdf1Channel struct {
+ Title string `xml:"title"`
+}
+
+type rdf1Item struct {
+ Title string `xml:"title"`
+ Link string `xml:"link"`
+ Description string `xml:"description"`
+ Date string `xml:"date"` // dc:date →
+ Identifier string `xml:"identifier"` // dc:identifier →
+}
+
+func parseRDF1(data []byte) (*FetchResult, error) {
+ var feed rdf1Feed
+ dec := xml.NewDecoder(bytes.NewReader(data))
+ dec.Strict = false
+ dec.Entity = xml.HTMLEntity
+ if err := dec.Decode(&feed); err != nil {
+ return nil, fmt.Errorf("RSS 1.0 decode: %w", err)
+ }
+
+ fetchedAt := time.Now().UTC().Format(time.RFC3339)
+ result := &FetchResult{FeedTitle: strings.TrimSpace(feed.Channel.Title)}
+
+ for _, item := range feed.Items {
+ ts := parseRSSDate(item.Date)
+ if ts == "" {
+ ts = fetchedAt
+ }
+ guid := strings.TrimSpace(item.Identifier)
+ if guid == "" {
+ guid = synthGUID(item.Link, item.Title)
+ }
+ summary := strings.TrimSpace(item.Description)
+ if len(summary) > 4096 {
+ summary = truncateUTF8(summary, 4096)
+ }
+ result.Entries = append(result.Entries, &Entry{
+ GUID: guid,
+ Timestamp: ts,
+ Title: strings.TrimSpace(item.Title),
+ URL: strings.TrimSpace(item.Link),
+ Summary: summary,
+ FetchedAt: fetchedAt,
+ })
+ }
+ return result, nil
+}
+
+func parseAtom(data []byte) (*FetchResult, error) {
+ var feed atomFeed
+ dec := xml.NewDecoder(bytes.NewReader(data))
+ dec.Strict = false
+ dec.Entity = xml.HTMLEntity
+ if err := dec.Decode(&feed); err != nil {
+ return nil, fmt.Errorf("Atom decode: %w", err)
+ }
+
+ fetchedAt := time.Now().UTC().Format(time.RFC3339)
+ result := &FetchResult{FeedTitle: strings.TrimSpace(feed.Title.Value)}
+
+ for _, e := range feed.Entries {
+ ts := strings.TrimSpace(e.Updated)
+ if ts == "" {
+ ts = fetchedAt
+ } else {
+ ts = normalizeTimestamp(ts, fetchedAt)
+ }
+ link := ""
+ for _, l := range e.Links {
+ if l.Rel == "alternate" || l.Rel == "" {
+ link = l.Href
+ break
+ }
+ }
+ guid := strings.TrimSpace(e.ID)
+ if guid == "" {
+ guid = synthGUID(link, e.Title.Value)
+ }
+ summary := strings.TrimSpace(e.Summary.Value)
+ if summary == "" {
+ summary = strings.TrimSpace(e.Content.Value)
+ }
+ if len(summary) > 4096 {
+ summary = truncateUTF8(summary, 4096)
+ }
+ result.Entries = append(result.Entries, &Entry{
+ GUID: guid,
+ Timestamp: ts,
+ Title: strings.TrimSpace(e.Title.Value),
+ URL: link,
+ Summary: summary,
+ FetchedAt: fetchedAt,
+ })
+ }
+ return result, nil
+}
+
+// ---------------------------------------------------------------------------
+// Date helpers
+// ---------------------------------------------------------------------------
+
+var rssDateFormats = []string{
+ time.RFC1123Z,
+ time.RFC1123,
+ "Mon, 2 Jan 2006 15:04:05 -0700",
+ "Mon, 2 Jan 2006 15:04:05 MST",
+ "2006-01-02T15:04:05Z07:00",
+ "2006-01-02T15:04:05Z",
+ "2006-01-02",
+}
+
+func parseRSSDate(s string) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return ""
+ }
+ now := time.Now().UTC()
+ for _, f := range rssDateFormats {
+ if t, err := time.Parse(f, s); err == nil {
+ if t.After(now) {
+ t = now
+ }
+ return t.UTC().Format(time.RFC3339)
+ }
+ }
+ return ""
+}
+
+func normalizeTimestamp(s, fallback string) string {
+ s = strings.TrimSpace(s)
+ now := time.Now().UTC()
+ for _, f := range rssDateFormats {
+ if t, err := time.Parse(f, s); err == nil {
+ if t.After(now) {
+ t = now
+ }
+ return t.UTC().Format(time.RFC3339)
+ }
+ }
+ return fallback
+}
diff --git a/feed/go.mod b/feed/go.mod
new file mode 100644
index 0000000..f5be037
--- /dev/null
+++ b/feed/go.mod
@@ -0,0 +1,7 @@
+module feeds
+
+go 1.25.7
+
+require 9fans.net/go v0.0.7
+
+require golang.org/x/net v0.52.0 // indirect
diff --git a/feed/go.sum b/feed/go.sum
new file mode 100644
index 0000000..9999c1c
--- /dev/null
+++ b/feed/go.sum
@@ -0,0 +1,36 @@
+9fans.net/go v0.0.7 h1:H5CsYJTf99C8EYAQr+uSoEJnLP/iZU8RmDuhyk30iSM=
+9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
+golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
+golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/feed/main.go b/feed/main.go
new file mode 100644
index 0000000..e6cf40e
--- /dev/null
+++ b/feed/main.go
@@ -0,0 +1,186 @@
+// Feeds — RSS/Atom feed reader for the acme text editor.
+// Embeds a 9P server, mounts it at ~/mnt/feeds via 9pfuse,
+// and opens an acme window for browsing and managing feeds.
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ "9fans.net/go/plan9/client"
+)
+
+const serviceName = "feeds"
+
+func main() {
+ // Locate Plan 9 namespace directory.
+ ns := client.Namespace()
+ if ns == "" {
+ log.Fatal("no Plan 9 namespace (NAMESPACE not set?)")
+ }
+ sockPath := filepath.Join(ns, serviceName)
+
+ // Load or create the feed store.
+ store, err := newFeedStore()
+ if err != nil {
+ log.Fatalf("store: %v", err)
+ }
+
+ // Remove stale socket.
+ os.Remove(sockPath)
+
+ // Start 9P server.
+ srv, err := newP9Server(sockPath, store)
+ if err != nil {
+ log.Fatalf("9P listen: %v", err)
+ }
+ go srv.serve()
+ log.Printf("9P server on %s", sockPath)
+
+ // Mount via 9pfuse at ~/mnt/feeds.
+ mnt, fuseCmd := mountFeeds(sockPath)
+ defer unmount(fuseCmd, mnt, sockPath)
+
+ // Open acme main window.
+ if err := openMainWindow(store); err != nil {
+ log.Fatalf("acme: %v", err)
+ }
+
+ // Wait for window close or signal.
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
+ select {
+ case <-mainDone:
+ case <-sigCh:
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Mount / unmount
+// ---------------------------------------------------------------------------
+
+func mountFeeds(sockPath string) (string, *exec.Cmd) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ log.Printf("cannot determine home dir: %v", err)
+ return "", nil
+ }
+ mnt := filepath.Join(home, "mnt", "feeds")
+ if err := os.MkdirAll(mnt, 0755); err != nil {
+ log.Printf("cannot create mount dir %s: %v", mnt, err)
+ return mnt, nil
+ }
+ cmd := exec.Command("9pfuse", sockPath, mnt)
+ if err := cmd.Start(); err != nil {
+ log.Printf("9pfuse: %v (continuing without mount)", err)
+ return mnt, nil
+ }
+ log.Printf("mounted at %s", mnt)
+ return mnt, cmd
+}
+
+func unmount(fuseCmd *exec.Cmd, mnt, sockPath string) {
+ if fuseCmd != nil {
+ exec.Command("fusermount", "-u", mnt).Run()
+ fuseCmd.Wait()
+ }
+ os.Remove(sockPath)
+}
+
+// ---------------------------------------------------------------------------
+// Refresh helpers (called from acme.go and p9.go)
+// ---------------------------------------------------------------------------
+
+// doSubscribe fetches a feed URL, derives a slug, subscribes, and polls entries.
+func doSubscribe(store *FeedStore, url, alias string) error {
+ result, err := fetchFeed(url, "", "")
+ if err != nil {
+ return fmt.Errorf("fetch %s: %w", url, err)
+ }
+ if result.NotModified || result.FeedTitle == "" {
+ result.FeedTitle = url
+ }
+
+ store.mu.Lock()
+ slug := store.feedSlugFromTitle(result.FeedTitle, alias)
+ store.mu.Unlock()
+
+ sub := &Subscription{
+ URL: url,
+ Slug: slug,
+ Title: result.FeedTitle,
+ SubscribedAt: time.Now().UTC().Format(time.RFC3339),
+ // Do not store ETag/LastModified yet. If parsing yields zero entries
+ // the cached validator would cause every subsequent refresh to return
+ // 304 and never recover. They are written by doRefresh after a
+ // successful fetch that produces at least one new entry.
+ }
+ if err := store.subscribe(sub); err != nil {
+ return err
+ }
+ log.Printf("fetched %s: %d raw entries", sub.Slug, len(result.Entries))
+ // Use sub.Slug, not the local slug variable: subscribe() may have
+ // adjusted it for uniqueness.
+ n, err := store.addEntries(sub.Slug, result.Entries)
+ if err != nil {
+ return fmt.Errorf("save entries: %w", err)
+ }
+ log.Printf("subscribed %s (%s): %d entries added", sub.Slug, url, n)
+ return nil
+}
+
+// doRefresh re-polls a single feed.
+func doRefresh(store *FeedStore, slug string) error {
+ store.mu.RLock()
+ sub := store.subscriptionBySlug(slug)
+ store.mu.RUnlock()
+ if sub == nil {
+ return fmt.Errorf("no feed %q", slug)
+ }
+
+ result, err := fetchFeed(sub.URL, sub.ETag, sub.LastModified)
+ if err != nil {
+ return fmt.Errorf("fetch %s: %w", slug, err)
+ }
+ if result.NotModified {
+ log.Printf("refresh %s: not modified", slug)
+ return nil
+ }
+ log.Printf("refresh %s: %d raw entries", slug, len(result.Entries))
+ n, err := store.addEntries(slug, result.Entries)
+ if err != nil {
+ return fmt.Errorf("save entries: %w", err)
+ }
+ // Only advance the cached validators when we actually stored something;
+ // if parsing returned zero entries we want the next refresh to re-fetch.
+ if n > 0 {
+ store.updateSubscriptionHTTPMeta(slug, result.ETag, result.LastModified)
+ }
+ log.Printf("refresh %s: %d new entries", slug, n)
+ return nil
+}
+
+// doRefreshAll re-polls every subscribed feed.
+func doRefreshAll(store *FeedStore) error {
+ store.mu.RLock()
+ slugs := make([]string, len(store.subs))
+ for i, s := range store.subs {
+ slugs[i] = s.Slug
+ }
+ store.mu.RUnlock()
+
+ var lastErr error
+ for _, slug := range slugs {
+ if err := doRefresh(store, slug); err != nil {
+ log.Printf("refresh %s: %v", slug, err)
+ lastErr = err
+ }
+ }
+ return lastErr
+}
diff --git a/feed/mkfile b/feed/mkfile
new file mode 100644
index 0000000..d8ea7ec
--- /dev/null
+++ b/feed/mkfile
@@ -0,0 +1,11 @@
+INSTALL_PATH=$HOME/bin
+
+all:V: install
+
+build:V:
+ go build -o $INSTALL_PATH/Feeds .
+
+install:V: build
+
+clean:V:
+ rm -f $INSTALL_PATH/Feeds
diff --git a/feed/p9.go b/feed/p9.go
new file mode 100644
index 0000000..e8ea2ae
--- /dev/null
+++ b/feed/p9.go
@@ -0,0 +1,723 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "strings"
+ "sync"
+
+ "9fans.net/go/plan9"
+)
+
+// ---------------------------------------------------------------------------
+// Qid constants
+// ---------------------------------------------------------------------------
+
+const (
+ qidRoot uint64 = 0
+ qidCtl uint64 = 1
+ qidUnread uint64 = 2
+ qidAll uint64 = 3
+ qidFeedsDir uint64 = 4
+ qidPinned uint64 = 5
+ // Dynamic qids are computed via pathQid() from the path string.
+ // Static ones above are reserved in the low range.
+)
+
+// pathQid computes a stable Qid path from an arbitrary string.
+// Uses FNV-1a to avoid import of crypto packages.
+func pathQid(s string) uint64 {
+ var h uint64 = 14695981039346656037
+ for i := 0; i < len(s); i++ {
+ h ^= uint64(s[i])
+ h *= 1099511628211
+ }
+ // Keep top bit clear (reserved for QTDIR in some plan9 implementations).
+ return (h & 0x7FFFFFFFFFFFFFFF) | 0x1000
+}
+
+// ---------------------------------------------------------------------------
+// FID state
+// ---------------------------------------------------------------------------
+
+type fidState struct {
+ qid plan9.Qid
+ path string
+ open bool
+ // write buffer — accumulated across Twrite calls, dispatched on Tclunk
+ writeBuf []byte
+}
+
+// ---------------------------------------------------------------------------
+// P9Server
+// ---------------------------------------------------------------------------
+
+type P9Server struct {
+ listener net.Listener
+ store *FeedStore
+ sockPath string
+}
+
+func newP9Server(sockPath string, store *FeedStore) (*P9Server, error) {
+ ln, err := net.Listen("unix", sockPath)
+ if err != nil {
+ return nil, err
+ }
+ return &P9Server{listener: ln, store: store, sockPath: sockPath}, nil
+}
+
+func (srv *P9Server) serve() {
+ for {
+ conn, err := srv.listener.Accept()
+ if err != nil {
+ if !errors.Is(err, net.ErrClosed) {
+ log.Printf("9P accept: %v", err)
+ }
+ return
+ }
+ go srv.handleConn(conn)
+ }
+}
+
+func (srv *P9Server) close() {
+ srv.listener.Close()
+}
+
+// ---------------------------------------------------------------------------
+// Connection handler
+// ---------------------------------------------------------------------------
+
+func (srv *P9Server) handleConn(conn net.Conn) {
+ defer conn.Close()
+
+ fids := make(map[uint32]*fidState)
+ var mu sync.Mutex
+
+ get := func(fid uint32) *fidState {
+ mu.Lock()
+ defer mu.Unlock()
+ return fids[fid]
+ }
+ set := func(fid uint32, f *fidState) {
+ mu.Lock()
+ defer mu.Unlock()
+ fids[fid] = f
+ }
+ del := func(fid uint32) {
+ mu.Lock()
+ defer mu.Unlock()
+ delete(fids, fid)
+ }
+
+ for {
+ fcall, err := plan9.ReadFcall(conn)
+ if err != nil {
+ if err != io.EOF {
+ log.Printf("9P read: %v", err)
+ }
+ return
+ }
+
+ resp := plan9.Fcall{Tag: fcall.Tag}
+
+ switch fcall.Type {
+
+ case plan9.Tversion:
+ resp.Type = plan9.Rversion
+ resp.Msize = fcall.Msize
+ resp.Version = "9P2000"
+
+ case plan9.Tattach:
+ resp.Type = plan9.Rattach
+ resp.Qid = plan9.Qid{Type: plan9.QTDIR, Path: qidRoot}
+ set(fcall.Fid, &fidState{qid: resp.Qid, path: "/"})
+
+ case plan9.Twalk:
+ f := get(fcall.Fid)
+ if f == nil {
+ resp.Type = plan9.Rerror
+ resp.Ename = "bad fid"
+ break
+ }
+ if len(fcall.Wname) == 0 {
+ // Clone.
+ nf := &fidState{qid: f.qid, path: f.path}
+ set(fcall.Newfid, nf)
+ resp.Type = plan9.Rwalk
+ break
+ }
+ cur := f.path
+ var wqids []plan9.Qid
+ for _, name := range fcall.Wname {
+ qid, next, err := srv.walk(cur, name)
+ if err != nil {
+ break
+ }
+ wqids = append(wqids, qid)
+ cur = next
+ }
+ if len(wqids) == 0 {
+ resp.Type = plan9.Rerror
+ resp.Ename = "file not found"
+ break
+ }
+ resp.Type = plan9.Rwalk
+ resp.Wqid = wqids
+ if len(wqids) == len(fcall.Wname) {
+ set(fcall.Newfid, &fidState{
+ qid: wqids[len(wqids)-1],
+ path: cur,
+ })
+ }
+
+ case plan9.Topen:
+ f := get(fcall.Fid)
+ if f == nil {
+ resp.Type = plan9.Rerror
+ resp.Ename = "bad fid"
+ break
+ }
+ f.open = true
+ resp.Type = plan9.Ropen
+ resp.Qid = f.qid
+ resp.Iounit = 0
+
+ case plan9.Tread:
+ f := get(fcall.Fid)
+ if f == nil {
+ resp.Type = plan9.Rerror
+ resp.Ename = "bad fid"
+ break
+ }
+ data, err := srv.read(f, int64(fcall.Offset), fcall.Count)
+ if err != nil {
+ resp.Type = plan9.Rerror
+ resp.Ename = err.Error()
+ break
+ }
+ resp.Type = plan9.Rread
+ resp.Data = data
+
+ case plan9.Twrite:
+ f := get(fcall.Fid)
+ if f == nil {
+ resp.Type = plan9.Rerror
+ resp.Ename = "bad fid"
+ break
+ }
+ // Buffer writes; dispatch on Tclunk.
+ f.writeBuf = append(f.writeBuf, fcall.Data...)
+ resp.Type = plan9.Rwrite
+ resp.Count = uint32(len(fcall.Data))
+
+ case plan9.Tclunk:
+ f := get(fcall.Fid)
+ if f != nil && len(f.writeBuf) > 0 {
+ if err := srv.dispatch(f.path, f.writeBuf); err != nil {
+ // Log but don't fail the clunk.
+ log.Printf("dispatch %s: %v", f.path, err)
+ }
+ }
+ del(fcall.Fid)
+ resp.Type = plan9.Rclunk
+
+ case plan9.Tstat:
+ f := get(fcall.Fid)
+ if f == nil {
+ resp.Type = plan9.Rerror
+ resp.Ename = "bad fid"
+ break
+ }
+ dir, err := srv.stat(f.path)
+ if err != nil {
+ resp.Type = plan9.Rerror
+ resp.Ename = err.Error()
+ break
+ }
+ resp.Type = plan9.Rstat
+ resp.Stat, _ = dir.Bytes()
+
+ default:
+ resp.Type = plan9.Rerror
+ resp.Ename = "not implemented"
+ }
+
+ if err := plan9.WriteFcall(conn, &resp); err != nil {
+ log.Printf("9P write: %v", err)
+ return
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// walk
+// ---------------------------------------------------------------------------
+
+func (srv *P9Server) walk(cur, name string) (plan9.Qid, string, error) {
+ parts := splitPath(cur)
+ depth := len(parts)
+
+ switch {
+ // Root → top-level names.
+ case depth == 0:
+ switch name {
+ case "ctl":
+ return plan9.Qid{Type: plan9.QTFILE, Path: qidCtl}, "/ctl", nil
+ case "unread":
+ return plan9.Qid{Type: plan9.QTFILE, Path: qidUnread}, "/unread", nil
+ case "all":
+ return plan9.Qid{Type: plan9.QTFILE, Path: qidAll}, "/all", nil
+ case "pinned":
+ return plan9.Qid{Type: plan9.QTFILE, Path: qidPinned}, "/pinned", nil
+ case "feeds":
+ return plan9.Qid{Type: plan9.QTDIR, Path: qidFeedsDir}, "/feeds", nil
+ }
+
+ // /feeds → slug directory.
+ case depth == 1 && parts[0] == "feeds":
+ srv.store.mu.RLock()
+ exists := srv.store.slugExists(name)
+ srv.store.mu.RUnlock()
+ if exists {
+ p := "/feeds/" + name
+ return plan9.Qid{Type: plan9.QTDIR, Path: pathQid(p)}, p, nil
+ }
+
+ // /feeds/{slug} → per-feed files and subdirs.
+ case depth == 2 && parts[0] == "feeds":
+ slug := parts[1]
+ p := cur + "/" + name
+ switch name {
+ case "meta", "ctl", "unread", "all":
+ return plan9.Qid{Type: plan9.QTFILE, Path: pathQid(p)}, p, nil
+ case "new", "read":
+ return plan9.Qid{Type: plan9.QTDIR, Path: pathQid(p)}, p, nil
+ }
+ _ = slug
+
+ // /feeds/{slug}/new or /feeds/{slug}/read → entry files.
+ case depth == 3 && parts[0] == "feeds":
+ slug := parts[1]
+ bucket := parts[2] // "new" or "read"
+ if bucket != "new" && bucket != "read" {
+ break
+ }
+ srv.store.mu.RLock()
+ files := srv.store.files[slug]
+ srv.store.mu.RUnlock()
+ for _, ef := range files {
+ if ef.filename == name {
+ wantRead := bucket == "read"
+ if ef.read == wantRead {
+ p := cur + "/" + name
+ return plan9.Qid{Type: plan9.QTFILE, Path: pathQid(p)}, p, nil
+ }
+ }
+ }
+ }
+
+ return plan9.Qid{}, "", errors.New("not found")
+}
+
+// ---------------------------------------------------------------------------
+// read
+// ---------------------------------------------------------------------------
+
+func (srv *P9Server) read(f *fidState, offset int64, count uint32) ([]byte, error) {
+ if f.qid.Type&plan9.QTDIR != 0 {
+ return srv.readDir(f.path, offset, count)
+ }
+ data, err := srv.readFile(f.path)
+ if err != nil {
+ return nil, err
+ }
+ return sliceAt(data, offset, count), nil
+}
+
+func (srv *P9Server) readDir(path string, offset int64, count uint32) ([]byte, error) {
+ parts := splitPath(path)
+ depth := len(parts)
+
+ var entries []plan9.Dir
+
+ switch {
+ case depth == 0: // root
+ entries = []plan9.Dir{
+ fileDir("ctl", 0644, qidCtl),
+ fileDir("unread", 0444, qidUnread),
+ fileDir("all", 0444, qidAll),
+ fileDir("pinned", 0444, qidPinned),
+ dirEntry("feeds", qidFeedsDir),
+ }
+
+ case depth == 1 && parts[0] == "feeds": // /feeds
+ srv.store.mu.RLock()
+ for _, sub := range srv.store.subs {
+ p := "/feeds/" + sub.Slug
+ entries = append(entries, dirEntry(sub.Slug, pathQid(p)))
+ }
+ srv.store.mu.RUnlock()
+
+ case depth == 2 && parts[0] == "feeds": // /feeds/{slug}
+ slug := parts[1]
+ base := path
+ entries = []plan9.Dir{
+ fileDir("meta", 0444, pathQid(base+"/meta")),
+ fileDir("ctl", 0644, pathQid(base+"/ctl")),
+ fileDir("unread", 0444, pathQid(base+"/unread")),
+ fileDir("all", 0444, pathQid(base+"/all")),
+ dirEntry("new", pathQid(base+"/new")),
+ dirEntry("read", pathQid(base+"/read")),
+ }
+ _ = slug
+
+ case depth == 3 && parts[0] == "feeds": // /feeds/{slug}/new or /feeds/{slug}/read
+ slug := parts[1]
+ bucket := parts[2]
+ wantRead := bucket == "read"
+ srv.store.mu.RLock()
+ files := srv.store.files[slug]
+ srv.store.mu.RUnlock()
+ for _, ef := range files {
+ if ef.read == wantRead {
+ p := path + "/" + ef.filename
+ entries = append(entries, fileDir(ef.filename, 0444, pathQid(p)))
+ }
+ }
+ }
+
+ return serializeDirs(entries, offset, count), nil
+}
+
+func (srv *P9Server) readFile(path string) ([]byte, error) {
+ parts := splitPath(path)
+
+ switch {
+ case path == "/ctl":
+ return []byte("sub [slug]\nunsub \nrefresh [slug]\n"), nil
+
+ case path == "/unread":
+ return []byte(srv.globalIndexText(true)), nil
+
+ case path == "/all":
+ return []byte(srv.globalIndexText(false)), nil
+
+ case path == "/pinned":
+ return []byte(srv.pinnedIndexText()), nil
+
+ case len(parts) == 3 && parts[0] == "feeds" && parts[2] == "meta":
+ slug := parts[1]
+ srv.store.mu.RLock()
+ sub := srv.store.subscriptionBySlug(slug)
+ srv.store.mu.RUnlock()
+ if sub == nil {
+ return nil, fmt.Errorf("no feed %q", slug)
+ }
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "url: %s\n", sub.URL)
+ fmt.Fprintf(&sb, "slug: %s\n", sub.Slug)
+ fmt.Fprintf(&sb, "title: %s\n", sub.Title)
+ fmt.Fprintf(&sb, "subscribed: %s\n", sub.SubscribedAt)
+ fmt.Fprintf(&sb, "last_fetched: %s\n", sub.LastFetched)
+ return []byte(sb.String()), nil
+
+ case len(parts) == 3 && parts[0] == "feeds" && parts[2] == "ctl":
+ return []byte("refresh\nunsub\nmark-all-read\nread \n"), nil
+
+ case len(parts) == 3 && parts[0] == "feeds" && parts[2] == "unread":
+ return []byte(srv.feedIndexText(parts[1], true)), nil
+
+ case len(parts) == 3 && parts[0] == "feeds" && parts[2] == "all":
+ return []byte(srv.feedIndexText(parts[1], false)), nil
+
+ case len(parts) == 4 && parts[0] == "feeds":
+ return srv.readEntryFile(parts[1], parts[2], parts[3])
+ }
+
+ return nil, fmt.Errorf("not found: %s", path)
+}
+
+func (srv *P9Server) readEntryFile(slug, bucket, filename string) ([]byte, error) {
+ wantRead := bucket == "read"
+ srv.store.mu.RLock()
+ files := srv.store.files[slug]
+ srv.store.mu.RUnlock()
+
+ for _, ef := range files {
+ if ef.filename == filename && ef.read == wantRead {
+ e := ef.entry
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "%s\n", e.Title)
+ fmt.Fprintf(&sb, "%s\n", e.URL)
+
+ srv.store.mu.RLock()
+ sub := srv.store.subscriptionBySlug(slug)
+ srv.store.mu.RUnlock()
+ feedTitle := slug
+ if sub != nil {
+ feedTitle = sub.Title
+ }
+ fmt.Fprintf(&sb, "%s | %s\n", feedTitle, e.Timestamp)
+
+ if e.Summary != "" {
+ fmt.Fprintf(&sb, "\n%s\n", e.Summary)
+ }
+ return []byte(sb.String()), nil
+ }
+ }
+ return nil, fmt.Errorf("not found")
+}
+
+// ---------------------------------------------------------------------------
+// dispatch — handle writes to ctl files (called on Tclunk)
+// ---------------------------------------------------------------------------
+
+func (srv *P9Server) dispatch(path string, data []byte) error {
+ cmd := strings.TrimSpace(string(data))
+ parts := splitPath(path)
+
+ switch {
+ case path == "/ctl":
+ return srv.handleGlobalCtl(cmd)
+
+ case len(parts) == 3 && parts[0] == "feeds" && parts[2] == "ctl":
+ return srv.handleFeedCtl(parts[1], cmd)
+ }
+ return nil
+}
+
+func (srv *P9Server) handleGlobalCtl(cmd string) error {
+ fields := strings.Fields(cmd)
+ if len(fields) == 0 {
+ return nil
+ }
+ switch fields[0] {
+ case "sub":
+ if len(fields) < 2 {
+ return fmt.Errorf("sub: missing URL")
+ }
+ url := fields[1]
+ alias := ""
+ if len(fields) >= 3 {
+ alias = fields[2]
+ }
+ return doSubscribe(srv.store, url, alias)
+
+ case "unsub":
+ if len(fields) < 2 {
+ return fmt.Errorf("unsub: missing slug")
+ }
+ return srv.store.unsubscribe(fields[1])
+
+ case "refresh":
+ if len(fields) >= 2 {
+ return doRefresh(srv.store, fields[1])
+ }
+ return doRefreshAll(srv.store)
+ }
+ return fmt.Errorf("unknown command: %s", fields[0])
+}
+
+func (srv *P9Server) handleFeedCtl(slug, cmd string) error {
+ fields := strings.Fields(cmd)
+ if len(fields) == 0 {
+ return nil
+ }
+ switch fields[0] {
+ case "refresh":
+ return doRefresh(srv.store, slug)
+ case "unsub":
+ return srv.store.unsubscribe(slug)
+ case "mark-all-read":
+ return srv.store.markAllRead(slug)
+ case "read":
+ if len(fields) < 2 {
+ return fmt.Errorf("read: missing guid")
+ }
+ return srv.store.markRead(slug, fields[1])
+ }
+ return fmt.Errorf("unknown command: %s", fields[0])
+}
+
+// ---------------------------------------------------------------------------
+// stat
+// ---------------------------------------------------------------------------
+
+func (srv *P9Server) stat(path string) (plan9.Dir, error) {
+ parts := splitPath(path)
+ name := "/"
+ if len(parts) > 0 {
+ name = parts[len(parts)-1]
+ }
+ d := plan9.Dir{Name: name, Uid: "feeds", Gid: "feeds", Muid: "feeds"}
+
+ switch {
+ case path == "/":
+ d.Mode = plan9.DMDIR | 0755
+ d.Qid = plan9.Qid{Type: plan9.QTDIR, Path: qidRoot}
+
+ case path == "/ctl":
+ d.Mode = 0644
+ d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: qidCtl}
+
+ case path == "/unread":
+ d.Mode = 0444
+ d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: qidUnread}
+
+ case path == "/all":
+ d.Mode = 0444
+ d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: qidAll}
+
+ case path == "/pinned":
+ d.Mode = 0444
+ d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: qidPinned}
+
+ case path == "/feeds":
+ d.Mode = plan9.DMDIR | 0755
+ d.Qid = plan9.Qid{Type: plan9.QTDIR, Path: qidFeedsDir}
+
+ case len(parts) == 2 && parts[0] == "feeds":
+ slug := parts[1]
+ srv.store.mu.RLock()
+ exists := srv.store.slugExists(slug)
+ srv.store.mu.RUnlock()
+ if !exists {
+ return plan9.Dir{}, fmt.Errorf("not found")
+ }
+ d.Mode = plan9.DMDIR | 0755
+ d.Qid = plan9.Qid{Type: plan9.QTDIR, Path: pathQid(path)}
+
+ case len(parts) == 3 && parts[0] == "feeds":
+ switch parts[2] {
+ case "new", "read":
+ d.Mode = plan9.DMDIR | 0755
+ d.Qid = plan9.Qid{Type: plan9.QTDIR, Path: pathQid(path)}
+ case "meta", "unread", "all":
+ d.Mode = 0444
+ d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: pathQid(path)}
+ case "ctl":
+ d.Mode = 0644
+ d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: pathQid(path)}
+ default:
+ return plan9.Dir{}, fmt.Errorf("not found")
+ }
+
+ case len(parts) == 4 && parts[0] == "feeds":
+ d.Mode = 0444
+ d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: pathQid(path)}
+
+ default:
+ return plan9.Dir{}, fmt.Errorf("not found: %s", path)
+ }
+
+ return d, nil
+}
+
+// ---------------------------------------------------------------------------
+// Index text helpers
+// ---------------------------------------------------------------------------
+
+func (srv *P9Server) globalIndexText(unreadOnly bool) string {
+ rows := srv.store.globalIndex(unreadOnly)
+ var sb strings.Builder
+ for i, row := range rows {
+ e := row.ef.entry
+ fmt.Fprintf(&sb, "%4d %-20s %-20s %s\n",
+ i+1,
+ formatTS(e.Timestamp),
+ row.slug,
+ e.Title,
+ )
+ }
+ return sb.String()
+}
+
+func (srv *P9Server) pinnedIndexText() string {
+ rows := srv.store.globalPinned()
+ var sb strings.Builder
+ for i, row := range rows {
+ e := row.ef.entry
+ fmt.Fprintf(&sb, "%4d %-20s %-20s %s\n",
+ i+1,
+ formatTS(e.Timestamp),
+ row.slug,
+ e.Title,
+ )
+ }
+ return sb.String()
+}
+
+func (srv *P9Server) feedIndexText(slug string, unreadOnly bool) string {
+ srv.store.mu.RLock()
+ files := srv.store.files[slug]
+ srv.store.mu.RUnlock()
+
+ var sb strings.Builder
+ n := 0
+ for _, ef := range files {
+ if unreadOnly && ef.read {
+ continue
+ }
+ n++
+ fmt.Fprintf(&sb, "%4d %-20s %s\n", n, formatTS(ef.entry.Timestamp), ef.entry.Title)
+ }
+ return sb.String()
+}
+
+func formatTS(ts string) string {
+ if len(ts) >= 10 {
+ return ts[:10]
+ }
+ return ts
+}
+
+// ---------------------------------------------------------------------------
+// Utility
+// ---------------------------------------------------------------------------
+
+func splitPath(path string) []string {
+ trimmed := strings.TrimPrefix(path, "/")
+ if trimmed == "" {
+ return nil
+ }
+ return strings.Split(trimmed, "/")
+}
+
+func sliceAt(data []byte, offset int64, count uint32) []byte {
+ if offset >= int64(len(data)) {
+ return []byte{}
+ }
+ end := offset + int64(count)
+ if end > int64(len(data)) {
+ end = int64(len(data))
+ }
+ return data[offset:end]
+}
+
+func serializeDirs(entries []plan9.Dir, offset int64, count uint32) []byte {
+ var buf []byte
+ for _, d := range entries {
+ b, _ := d.Bytes()
+ buf = append(buf, b...)
+ }
+ return sliceAt(buf, offset, count)
+}
+
+func fileDir(name string, mode plan9.Perm, qidPath uint64) plan9.Dir {
+ return plan9.Dir{
+ Name: name,
+ Mode: mode,
+ Qid: plan9.Qid{Type: plan9.QTFILE, Path: qidPath},
+ }
+}
+
+func dirEntry(name string, qidPath uint64) plan9.Dir {
+ return plan9.Dir{
+ Name: name,
+ Mode: plan9.DMDIR | 0755,
+ Qid: plan9.Qid{Type: plan9.QTDIR, Path: qidPath},
+ }
+}
diff --git a/feed/store.go b/feed/store.go
new file mode 100644
index 0000000..cc4b082
--- /dev/null
+++ b/feed/store.go
@@ -0,0 +1,718 @@
+package main
+
+import (
+ "bufio"
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+ "unicode"
+)
+
+// ---------------------------------------------------------------------------
+// Data types
+// ---------------------------------------------------------------------------
+
+type Subscription struct {
+ URL string `json:"url"`
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ SubscribedAt string `json:"subscribed_at"`
+ LastFetched string `json:"last_fetched,omitempty"`
+ ETag string `json:"etag,omitempty"`
+ LastModified string `json:"last_modified,omitempty"`
+}
+
+type Entry struct {
+ GUID string `json:"guid"`
+ Timestamp string `json:"timestamp"`
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Summary string `json:"summary"`
+ FetchedAt string `json:"fetched_at"`
+}
+
+// entryFile pairs an entry with its generated filename and state flags.
+type entryFile struct {
+ filename string
+ entry *Entry
+ read bool
+ pinned bool
+}
+
+// ---------------------------------------------------------------------------
+// FeedStore — in-memory state + disk persistence
+// ---------------------------------------------------------------------------
+
+type FeedStore struct {
+ mu sync.RWMutex
+ subs []*Subscription // ordered by SubscribedAt
+ // slug → entries sorted by timestamp descending
+ entries map[string][]*Entry
+ // slug → set of read GUIDs
+ readSets map[string]map[string]bool
+ // slug → set of pinned GUIDs
+ pinnedSets map[string]map[string]bool
+ // slug → computed entryFile list (rebuilt after any mutation)
+ files map[string][]entryFile
+
+ dataDir string // ~/.local/share/acme-feed
+}
+
+func newFeedStore() (*FeedStore, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, err
+ }
+ dataDir := filepath.Join(home, ".local", "share", "acme-feed")
+ for _, d := range []string{
+ dataDir,
+ filepath.Join(dataDir, "feeds"),
+ filepath.Join(dataDir, "read"),
+ filepath.Join(dataDir, "pinned"),
+ filepath.Join(dataDir, "content"),
+ } {
+ if err := os.MkdirAll(d, 0755); err != nil {
+ return nil, err
+ }
+ }
+
+ fs := &FeedStore{
+ entries: make(map[string][]*Entry),
+ readSets: make(map[string]map[string]bool),
+ pinnedSets: make(map[string]map[string]bool),
+ files: make(map[string][]entryFile),
+ dataDir: dataDir,
+ }
+ if err := fs.load(); err != nil {
+ return nil, err
+ }
+ return fs, nil
+}
+
+// ---------------------------------------------------------------------------
+// Load from disk
+// ---------------------------------------------------------------------------
+
+func (s *FeedStore) load() error {
+ subs, err := s.loadSubscriptions()
+ if err != nil {
+ return err
+ }
+ s.subs = subs
+
+ for _, sub := range subs {
+ entries, err := s.loadEntries(sub.Slug)
+ if err != nil {
+ return fmt.Errorf("load entries %s: %w", sub.Slug, err)
+ }
+ s.entries[sub.Slug] = entries
+
+ readSet, err := s.loadReadSet(sub.Slug)
+ if err != nil {
+ return fmt.Errorf("load read set %s: %w", sub.Slug, err)
+ }
+ s.readSets[sub.Slug] = readSet
+
+ pinnedSet, err := s.loadGUIDFile(filepath.Join(s.dataDir, "pinned", sub.Slug))
+ if err != nil {
+ return fmt.Errorf("load pinned set %s: %w", sub.Slug, err)
+ }
+ s.pinnedSets[sub.Slug] = pinnedSet
+ s.rebuildFiles(sub.Slug)
+ }
+ return nil
+}
+
+func (s *FeedStore) loadSubscriptions() ([]*Subscription, error) {
+ path := filepath.Join(s.dataDir, "subscriptions.jsonl")
+ f, err := os.Open(path)
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ var subs []*Subscription
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" {
+ continue
+ }
+ var sub Subscription
+ if err := json.Unmarshal([]byte(line), &sub); err != nil {
+ continue
+ }
+ subs = append(subs, &sub)
+ }
+ return subs, sc.Err()
+}
+
+func (s *FeedStore) loadEntries(slug string) ([]*Entry, error) {
+ path := filepath.Join(s.dataDir, "feeds", slug+".jsonl")
+ f, err := os.Open(path)
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ seen := make(map[string]bool)
+ var entries []*Entry
+ sc := bufio.NewScanner(f)
+ sc.Buffer(make([]byte, 1<<20), 1<<20)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" {
+ continue
+ }
+ var e Entry
+ if err := json.Unmarshal([]byte(line), &e); err != nil {
+ continue
+ }
+ if seen[e.GUID] {
+ continue
+ }
+ seen[e.GUID] = true
+ entries = append(entries, &e)
+ }
+ // Sort descending by timestamp.
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].Timestamp > entries[j].Timestamp
+ })
+ return entries, sc.Err()
+}
+
+func (s *FeedStore) loadReadSet(slug string) (map[string]bool, error) {
+ return s.loadGUIDFile(filepath.Join(s.dataDir, "read", slug))
+}
+
+// ---------------------------------------------------------------------------
+// Content cache
+// ---------------------------------------------------------------------------
+
+// contentCachePath returns the path for a cached Markdown content file.
+func (s *FeedStore) contentCachePath(slug, entryFilename string) string {
+ dir := filepath.Join(s.dataDir, "content", slug)
+ os.MkdirAll(dir, 0755)
+ return filepath.Join(dir, entryFilename+".md")
+}
+
+// loadCachedContent returns cached Markdown, or ("", false) if not present.
+func (s *FeedStore) loadCachedContent(slug, entryFilename string) (string, bool) {
+ data, err := os.ReadFile(s.contentCachePath(slug, entryFilename))
+ if err != nil {
+ return "", false
+ }
+ return string(data), true
+}
+
+// saveCachedContent writes Markdown to the content cache.
+func (s *FeedStore) saveCachedContent(slug, entryFilename, md string) {
+ os.WriteFile(s.contentCachePath(slug, entryFilename), []byte(md), 0644)
+}
+
+// loadGUIDFile reads a newline-separated file of GUIDs into a set.
+func (s *FeedStore) loadGUIDFile(path string) (map[string]bool, error) {
+ f, err := os.Open(path)
+ if os.IsNotExist(err) {
+ return make(map[string]bool), nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ set := make(map[string]bool)
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ if g := strings.TrimSpace(sc.Text()); g != "" {
+ set[g] = true
+ }
+ }
+ return set, sc.Err()
+}
+
+// rewriteGUIDFile atomically rewrites a GUID set file.
+func rewriteGUIDFile(path string, set map[string]bool) error {
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ for guid := range set {
+ fmt.Fprintln(f, guid)
+ }
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Persist to disk
+// ---------------------------------------------------------------------------
+
+func (s *FeedStore) saveSubscriptions() error {
+ path := filepath.Join(s.dataDir, "subscriptions.jsonl")
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ enc := json.NewEncoder(f)
+ for _, sub := range s.subs {
+ if err := enc.Encode(sub); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *FeedStore) appendEntries(slug string, entries []*Entry) error {
+ if len(entries) == 0 {
+ return nil
+ }
+ path := filepath.Join(s.dataDir, "feeds", slug+".jsonl")
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ enc := json.NewEncoder(f)
+ for _, e := range entries {
+ if err := enc.Encode(e); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *FeedStore) appendGUID(path, guid string) error {
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = fmt.Fprintln(f, guid)
+ return err
+}
+
+func (s *FeedStore) removeSubscriptionFiles(slug string) {
+ os.Remove(filepath.Join(s.dataDir, "feeds", slug+".jsonl"))
+ os.Remove(filepath.Join(s.dataDir, "read", slug))
+ os.Remove(filepath.Join(s.dataDir, "pinned", slug))
+}
+
+// ---------------------------------------------------------------------------
+// Mutations (all hold mu.Lock)
+// ---------------------------------------------------------------------------
+
+// Subscribe adds a new feed. Returns an error if the slug already exists.
+func (s *FeedStore) subscribe(sub *Subscription) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, existing := range s.subs {
+ if existing.Slug == sub.Slug {
+ // Make slug unique by appending -2, -3, etc.
+ for i := 2; ; i++ {
+ candidate := fmt.Sprintf("%s-%d", sub.Slug, i)
+ found := false
+ for _, e := range s.subs {
+ if e.Slug == candidate {
+ found = true
+ break
+ }
+ }
+ if !found {
+ sub.Slug = candidate
+ break
+ }
+ }
+ }
+ if existing.URL == sub.URL {
+ return fmt.Errorf("already subscribed: %s", existing.Slug)
+ }
+ }
+ s.subs = append(s.subs, sub)
+ s.entries[sub.Slug] = nil
+ s.readSets[sub.Slug] = make(map[string]bool)
+ s.pinnedSets[sub.Slug] = make(map[string]bool)
+ s.files[sub.Slug] = nil
+ return s.saveSubscriptions()
+}
+
+// Unsubscribe removes a feed by slug.
+func (s *FeedStore) unsubscribe(slug string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ found := false
+ var remaining []*Subscription
+ for _, sub := range s.subs {
+ if sub.Slug == slug {
+ found = true
+ } else {
+ remaining = append(remaining, sub)
+ }
+ }
+ if !found {
+ return fmt.Errorf("no feed with slug %q", slug)
+ }
+ s.subs = remaining
+ delete(s.entries, slug)
+ delete(s.readSets, slug)
+ delete(s.pinnedSets, slug)
+ delete(s.files, slug)
+ s.removeSubscriptionFiles(slug)
+ return s.saveSubscriptions()
+}
+
+// addEntries deduplicates and appends new entries for a feed.
+func (s *FeedStore) addEntries(slug string, newEntries []*Entry) (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ existing := s.entries[slug]
+ seen := make(map[string]bool, len(existing))
+ for _, e := range existing {
+ seen[e.GUID] = true
+ }
+
+ var toAdd []*Entry
+ for _, e := range newEntries {
+ if !seen[e.GUID] {
+ seen[e.GUID] = true
+ toAdd = append(toAdd, e)
+ }
+ }
+ if len(toAdd) == 0 {
+ return 0, nil
+ }
+
+ if err := s.appendEntries(slug, toAdd); err != nil {
+ return 0, err
+ }
+
+ s.entries[slug] = append(s.entries[slug], toAdd...)
+ sort.Slice(s.entries[slug], func(i, j int) bool {
+ return s.entries[slug][i].Timestamp > s.entries[slug][j].Timestamp
+ })
+ s.rebuildFiles(slug)
+ return len(toAdd), nil
+}
+
+// updateSubscriptionHTTPMeta persists ETag/LastModified after a fetch.
+func (s *FeedStore) updateSubscriptionHTTPMeta(slug, etag, lastModified string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, sub := range s.subs {
+ if sub.Slug == slug {
+ sub.ETag = etag
+ sub.LastModified = lastModified
+ sub.LastFetched = time.Now().UTC().Format(time.RFC3339)
+ break
+ }
+ }
+ s.saveSubscriptions()
+}
+
+// markRead moves a GUID from unread → read for the given slug.
+func (s *FeedStore) markRead(slug, guid string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ rs := s.readSets[slug]
+ if rs == nil {
+ return fmt.Errorf("no feed %q", slug)
+ }
+ if rs[guid] {
+ return nil // already read
+ }
+ rs[guid] = true
+ if err := s.appendGUID(filepath.Join(s.dataDir, "read", slug), guid); err != nil {
+ return err
+ }
+ s.rebuildFiles(slug)
+ return nil
+}
+
+// markAllRead marks every entry in a feed as read.
+func (s *FeedStore) markAllRead(slug string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ rs := s.readSets[slug]
+ if rs == nil {
+ return fmt.Errorf("no feed %q", slug)
+ }
+ path := filepath.Join(s.dataDir, "read", slug)
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ for _, e := range s.entries[slug] {
+ rs[e.GUID] = true
+ fmt.Fprintln(f, e.GUID)
+ }
+ s.rebuildFiles(slug)
+ return nil
+}
+
+// unmarkRead removes a GUID from the read set (marks the entry unread).
+func (s *FeedStore) unmarkRead(slug, guid string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ rs := s.readSets[slug]
+ if rs == nil {
+ return fmt.Errorf("no feed %q", slug)
+ }
+ if !rs[guid] {
+ return nil // already unread
+ }
+ delete(rs, guid)
+ if err := rewriteGUIDFile(filepath.Join(s.dataDir, "read", slug), rs); err != nil {
+ return err
+ }
+ s.rebuildFiles(slug)
+ return nil
+}
+
+// pin adds a GUID to the pinned set for a feed.
+func (s *FeedStore) pin(slug, guid string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ ps := s.pinnedSets[slug]
+ if ps == nil {
+ return fmt.Errorf("no feed %q", slug)
+ }
+ if ps[guid] {
+ return nil // already pinned
+ }
+ ps[guid] = true
+ if err := s.appendGUID(filepath.Join(s.dataDir, "pinned", slug), guid); err != nil {
+ return err
+ }
+ s.rebuildFiles(slug)
+ return nil
+}
+
+// unpin removes a GUID from the pinned set.
+func (s *FeedStore) unpin(slug, guid string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ ps := s.pinnedSets[slug]
+ if ps == nil {
+ return fmt.Errorf("no feed %q", slug)
+ }
+ if !ps[guid] {
+ return nil
+ }
+ delete(ps, guid)
+ if err := rewriteGUIDFile(filepath.Join(s.dataDir, "pinned", slug), ps); err != nil {
+ return err
+ }
+ s.rebuildFiles(slug)
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Read-only accessors (caller must hold mu.RLock or mu.Lock)
+// ---------------------------------------------------------------------------
+
+func (s *FeedStore) slugExists(slug string) bool {
+ for _, sub := range s.subs {
+ if sub.Slug == slug {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *FeedStore) subscriptionBySlug(slug string) *Subscription {
+ for _, sub := range s.subs {
+ if sub.Slug == slug {
+ return sub
+ }
+ }
+ return nil
+}
+
+// IndexRow is one entry in the global or per-feed index.
+type IndexRow struct {
+ slug string
+ ef entryFile
+}
+
+// globalIndex returns all entryFiles across all feeds, sorted by timestamp desc.
+func (s *FeedStore) globalIndex(unreadOnly bool) []IndexRow {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ var rows []IndexRow
+ for slug, files := range s.files {
+ for _, ef := range files {
+ if unreadOnly && ef.read {
+ continue
+ }
+ rows = append(rows, IndexRow{slug: slug, ef: ef})
+ }
+ }
+ sort.Slice(rows, func(i, j int) bool {
+ return rows[i].ef.entry.Timestamp > rows[j].ef.entry.Timestamp
+ })
+ return rows
+}
+
+// ---------------------------------------------------------------------------
+// File list builder
+// ---------------------------------------------------------------------------
+
+// rebuildFiles rebuilds the entryFile list for a slug.
+// Caller must hold mu.Lock.
+func (s *FeedStore) rebuildFiles(slug string) {
+ entries := s.entries[slug]
+ rs := s.readSets[slug]
+ if rs == nil {
+ rs = make(map[string]bool)
+ }
+ ps := s.pinnedSets[slug]
+ if ps == nil {
+ ps = make(map[string]bool)
+ }
+
+ seen := make(map[string]int) // base filename → collision count
+ files := make([]entryFile, 0, len(entries))
+ for _, e := range entries {
+ base := makeEntryFilename(e)
+ n := seen[base]
+ seen[base]++
+ name := base
+ if n > 0 {
+ name = fmt.Sprintf("%s-%d", base, n+1)
+ }
+ files = append(files, entryFile{
+ filename: name,
+ entry: e,
+ read: rs[e.GUID],
+ pinned: ps[e.GUID],
+ })
+ }
+ s.files[slug] = files
+}
+
+// feedFiles returns a snapshot of all entryFiles for a slug, sorted by
+// timestamp descending (newest first). Index 0 is newest; last is oldest.
+func (s *FeedStore) feedFiles(slug string) []entryFile {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ src := s.files[slug]
+ snap := make([]entryFile, len(src))
+ copy(snap, src)
+ return snap
+}
+
+// globalPinned returns all pinned entryFiles across all feeds, sorted by
+// timestamp descending.
+func (s *FeedStore) globalPinned() []IndexRow {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ var rows []IndexRow
+ for slug, files := range s.files {
+ for _, ef := range files {
+ if ef.pinned {
+ rows = append(rows, IndexRow{slug: slug, ef: ef})
+ }
+ }
+ }
+ sort.Slice(rows, func(i, j int) bool {
+ return rows[i].ef.entry.Timestamp > rows[j].ef.entry.Timestamp
+ })
+ return rows
+}
+
+// ---------------------------------------------------------------------------
+// Slug and filename helpers
+// ---------------------------------------------------------------------------
+
+var multiDash = regexp.MustCompile(`-{2,}`)
+
+func slugify(s string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(s) {
+ switch {
+ case unicode.IsLetter(r) || unicode.IsDigit(r):
+ b.WriteRune(r)
+ case unicode.IsSpace(r) || r == '-' || r == '_':
+ b.WriteByte('-')
+ }
+ }
+ return strings.Trim(multiDash.ReplaceAllString(b.String(), "-"), "-")
+}
+
+// feedSlugFromTitle derives a unique slug. Caller must hold mu.Lock.
+func (s *FeedStore) feedSlugFromTitle(title, userAlias string) string {
+ base := userAlias
+ if base == "" {
+ base = slugify(title)
+ }
+ if base == "" {
+ base = "feed"
+ }
+ candidate := base
+ for i := 2; ; i++ {
+ taken := false
+ for _, sub := range s.subs {
+ if sub.Slug == candidate {
+ taken = true
+ break
+ }
+ }
+ if !taken {
+ return candidate
+ }
+ candidate = fmt.Sprintf("%s-%d", base, i)
+ }
+}
+
+func makeEntryFilename(e *Entry) string {
+ ts := "00000000T000000Z"
+ if t, err := time.Parse(time.RFC3339, e.Timestamp); err == nil {
+ ts = t.UTC().Format("20060102T150405Z")
+ }
+ titleSlug := slugify(e.Title)
+ if titleSlug == "" {
+ // Fall back to hash of GUID.
+ h := sha256.Sum256([]byte(e.GUID))
+ titleSlug = fmt.Sprintf("%x", h[:8])
+ }
+ // Max 128 bytes total; ts is 17 bytes + 1 dash = 18.
+ titleSlug = truncateUTF8(titleSlug, 128-18)
+ return ts + "-" + titleSlug
+}
+
+// truncateUTF8 truncates s to at most maxBytes bytes without splitting a rune.
+func truncateUTF8(s string, maxBytes int) string {
+ if len(s) <= maxBytes {
+ return s
+ }
+ b := []byte(s)
+ // Walk back from maxBytes to find a valid rune boundary.
+ for maxBytes > 0 && (b[maxBytes]&0xC0) == 0x80 {
+ maxBytes--
+ }
+ return string(b[:maxBytes])
+}
+
+// synthGUID synthesises a stable GUID when the feed provides none.
+func synthGUID(url, title string) string {
+ h := sha256.Sum256([]byte(url + "\x00" + title))
+ return fmt.Sprintf("synth:%x", h[:16])
+}