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}