main
  1// Package flux provides the core types and logic for the personal activity stream.
  2package flux
  3
  4import (
  5	"encoding/json"
  6	"fmt"
  7	"os"
  8	"sort"
  9	"time"
 10)
 11
 12// EntryKind identifies the type of stream entry.
 13type EntryKind string
 14
 15const (
 16	KindGitHubPR      EntryKind = "github-pr"
 17	KindGitHubIssue   EntryKind = "github-issue"
 18	KindGitHubRelease EntryKind = "github-release"
 19	KindPageNew       EntryKind = "page-new"
 20	KindPageUpdated   EntryKind = "page-updated"
 21	KindBookmark      EntryKind = "bookmark"
 22	KindNote          EntryKind = "note"
 23)
 24
 25// Entry is a single item in the activity stream.
 26type Entry struct {
 27	ID       string            `json:"id"`
 28	Kind     EntryKind         `json:"kind"`
 29	Title    string            `json:"title"`
 30	URL      string            `json:"url,omitempty"`
 31	Body     string            `json:"body,omitempty"`
 32	Tags     []string          `json:"tags,omitempty"`
 33	Date     time.Time         `json:"date"`
 34	Source   string            `json:"source"`
 35	Metadata map[string]string `json:"metadata,omitempty"`
 36}
 37
 38// Store holds all entries, serialized to/from entries.json.
 39type Store struct {
 40	Entries []Entry `json:"entries"`
 41}
 42
 43// LoadStore reads entries from a JSON file. Returns an empty store if the file doesn't exist.
 44func LoadStore(path string) (*Store, error) {
 45	data, err := os.ReadFile(path)
 46	if err != nil {
 47		if os.IsNotExist(err) {
 48			return &Store{}, nil
 49		}
 50		return nil, fmt.Errorf("reading store: %w", err)
 51	}
 52	var s Store
 53	if err := json.Unmarshal(data, &s); err != nil {
 54		return nil, fmt.Errorf("parsing store: %w", err)
 55	}
 56	return &s, nil
 57}
 58
 59// Save writes the store to a JSON file.
 60func (s *Store) Save(path string) error {
 61	data, err := json.MarshalIndent(s, "", "  ")
 62	if err != nil {
 63		return fmt.Errorf("marshaling store: %w", err)
 64	}
 65	if err := os.WriteFile(path, data, 0644); err != nil {
 66		return fmt.Errorf("writing store: %w", err)
 67	}
 68	return nil
 69}
 70
 71// Merge adds new entries, deduplicating by ID. Returns the number of new entries added.
 72func (s *Store) Merge(entries []Entry) int {
 73	seen := make(map[string]bool, len(s.Entries))
 74	for _, e := range s.Entries {
 75		seen[e.ID] = true
 76	}
 77	added := 0
 78	for _, e := range entries {
 79		if !seen[e.ID] {
 80			s.Entries = append(s.Entries, e)
 81			seen[e.ID] = true
 82			added++
 83		}
 84	}
 85	return added
 86}
 87
 88// Sort orders entries by date, most recent first.
 89func (s *Store) Sort() {
 90	sort.Slice(s.Entries, func(i, j int) bool {
 91		return s.Entries[i].Date.After(s.Entries[j].Date)
 92	})
 93}
 94
 95// Latest returns the most recent n entries.
 96func (s *Store) Latest(n int) []Entry {
 97	if n >= len(s.Entries) {
 98		return s.Entries
 99	}
100	return s.Entries[:n]
101}
102
103// ByYear groups entries by year.
104func (s *Store) ByYear() map[int][]Entry {
105	result := make(map[int][]Entry)
106	for _, e := range s.Entries {
107		y := e.Date.Year()
108		result[y] = append(result[y], e)
109	}
110	return result
111}
112
113// AllTags returns all unique tags with their counts.
114func (s *Store) AllTags() map[string]int {
115	tags := make(map[string]int)
116	for _, e := range s.Entries {
117		for _, t := range e.Tags {
118			tags[t]++
119		}
120	}
121	return tags
122}
123
124// ContentOnly returns entries that are pages, notes, bookmarks — no GitHub noise.
125func (s *Store) ContentOnly() []Entry {
126	var result []Entry
127	for _, e := range s.Entries {
128		switch e.Kind {
129		case KindPageNew, KindPageUpdated, KindNote, KindBookmark:
130			result = append(result, e)
131		}
132	}
133	return result
134}
135
136// ByTag returns entries matching a given tag.
137func (s *Store) ByTag(tag string) []Entry {
138	var result []Entry
139	for _, e := range s.Entries {
140		for _, t := range e.Tags {
141			if t == tag {
142				result = append(result, e)
143				break
144			}
145		}
146	}
147	return result
148}