acme-misc/feed/store.go

719 lines
17 KiB
Go

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])
}