summaryrefslogtreecommitdiffstats
path: root/internal/store
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/store.go232
-rw-r--r--internal/store/store_test.go34
2 files changed, 266 insertions, 0 deletions
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 0000000..f851dce
--- /dev/null
+++ b/internal/store/store.go
@@ -0,0 +1,232 @@
+// Package store contains the local, non-secret Aegis cache.
+package store
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "aegis/internal/filter"
+)
+
+const (
+ stateVersion = 1
+ maxArticleBytes = 16 << 20
+ maxDraftBytes = 2 << 20
+)
+
+type Article struct {
+ Key string `json:"key"`
+ Group string `json:"group"`
+ Number int64 `json:"number"`
+ Subject string `json:"subject,omitempty"`
+ From string `json:"from,omitempty"`
+ MessageID string `json:"message_id,omitempty"`
+ Raw string `json:"raw,omitempty"`
+ Read bool `json:"read"`
+ Bookmarked bool `json:"bookmarked"`
+ Tags []string `json:"tags,omitempty"`
+ FetchedAt time.Time `json:"fetched_at"`
+}
+
+type Draft struct {
+ ID string `json:"id"`
+ Groups string `json:"groups,omitempty"`
+ From string `json:"from,omitempty"`
+ Subject string `json:"subject,omitempty"`
+ Body string `json:"body,omitempty"`
+ References string `json:"references,omitempty"`
+ FollowupTo string `json:"followup_to,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type State struct {
+ Version int `json:"version"`
+ Articles map[string]Article `json:"articles"`
+ Drafts map[string]Draft `json:"drafts"`
+ Filters []filter.Rule `json:"filters,omitempty"`
+}
+
+type Store struct {
+ mu sync.RWMutex
+ path string
+ state State
+}
+
+func DefaultPath() (string, error) {
+ dir, err := os.UserConfigDir()
+ if err != nil {
+ return "", fmt.Errorf("locate user configuration directory: %w", err)
+ }
+ return filepath.Join(dir, "aegis", "state.json"), nil
+}
+
+func Open(path string) (*Store, error) {
+ if path == "" {
+ return nil, errors.New("state path is required")
+ }
+ s := &Store{path: path, state: emptyState()}
+ data, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return s, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read local state: %w", err)
+ }
+ if len(data) > 32<<20 {
+ return nil, errors.New("local state is too large")
+ }
+ if err := json.Unmarshal(data, &s.state); err != nil {
+ return nil, fmt.Errorf("decode local state: %w", err)
+ }
+ if s.state.Version == 0 {
+ s.state.Version = stateVersion
+ }
+ if s.state.Version != stateVersion {
+ return nil, fmt.Errorf("unsupported local state version %d", s.state.Version)
+ }
+ if s.state.Articles == nil {
+ s.state.Articles = make(map[string]Article)
+ }
+ if s.state.Drafts == nil {
+ s.state.Drafts = make(map[string]Draft)
+ }
+ return s, nil
+}
+
+func emptyState() State {
+ return State{Version: stateVersion, Articles: make(map[string]Article), Drafts: make(map[string]Draft)}
+}
+
+func (s *Store) Save() error {
+ s.mu.RLock()
+ data, err := json.MarshalIndent(s.state, "", " ")
+ s.mu.RUnlock()
+ if err != nil {
+ return fmt.Errorf("encode local state: %w", err)
+ }
+ dir := filepath.Dir(s.path)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return fmt.Errorf("create local state directory: %w", err)
+ }
+ if err := os.Chmod(dir, 0o700); err != nil {
+ return fmt.Errorf("protect local state directory: %w", err)
+ }
+ tmp, err := os.CreateTemp(dir, ".state-*.tmp")
+ if err != nil {
+ return fmt.Errorf("create local state temporary file: %w", err)
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName)
+ if err := tmp.Chmod(0o600); err != nil {
+ _ = tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(append(data, '\n')); err != nil {
+ _ = tmp.Close()
+ return fmt.Errorf("write local state: %w", err)
+ }
+ if err := tmp.Sync(); err != nil {
+ _ = tmp.Close()
+ return fmt.Errorf("sync local state: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(tmpName, s.path); err != nil {
+ return fmt.Errorf("commit local state: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) Snapshot() State {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ result := emptyState()
+ result.Filters = append(result.Filters, s.state.Filters...)
+ for key, article := range s.state.Articles {
+ article.Tags = append([]string(nil), article.Tags...)
+ result.Articles[key] = article
+ }
+ for key, draft := range s.state.Drafts {
+ result.Drafts[key] = draft
+ }
+ return result
+}
+
+func (s *Store) UpsertArticle(article Article) error {
+ if article.Key == "" {
+ return errors.New("article key is required")
+ }
+ if len(article.Raw) > maxArticleBytes {
+ return errors.New("article exceeds local cache limit")
+ }
+ s.mu.Lock()
+ if old, ok := s.state.Articles[article.Key]; ok {
+ article.Read = old.Read
+ article.Bookmarked = old.Bookmarked
+ if len(article.Tags) == 0 {
+ article.Tags = append([]string(nil), old.Tags...)
+ }
+ }
+ if article.FetchedAt.IsZero() {
+ article.FetchedAt = time.Now().UTC()
+ }
+ s.state.Articles[article.Key] = article
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *Store) SetRead(key string, read bool) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ article, ok := s.state.Articles[key]
+ if !ok {
+ return errors.New("article not found")
+ }
+ article.Read = read
+ s.state.Articles[key] = article
+ return nil
+}
+
+func (s *Store) SetBookmarked(key string, bookmarked bool) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ article, ok := s.state.Articles[key]
+ if !ok {
+ return errors.New("article not found")
+ }
+ article.Bookmarked = bookmarked
+ s.state.Articles[key] = article
+ return nil
+}
+
+func (s *Store) SaveDraft(draft Draft) error {
+ if draft.ID == "" {
+ return errors.New("draft ID is required")
+ }
+ if len(draft.Body) > maxDraftBytes {
+ return errors.New("draft exceeds local size limit")
+ }
+ draft.UpdatedAt = time.Now().UTC()
+ s.mu.Lock()
+ s.state.Drafts[draft.ID] = draft
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *Store) DeleteDraft(id string) {
+ s.mu.Lock()
+ delete(s.state.Drafts, id)
+ s.mu.Unlock()
+}
+
+func (s *Store) SetFilters(rules []filter.Rule) {
+ s.mu.Lock()
+ s.state.Filters = append([]filter.Rule(nil), rules...)
+ s.mu.Unlock()
+}
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
new file mode 100644
index 0000000..40efef1
--- /dev/null
+++ b/internal/store/store_test.go
@@ -0,0 +1,34 @@
+package store
+
+import (
+ "path/filepath"
+ "testing"
+
+ "aegis/internal/filter"
+)
+
+func TestStoreRoundTrip(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.json")
+ s, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.UpsertArticle(Article{Key: "comp.test:1", Group: "comp.test", Number: 1, Raw: "Subject: test\r\n\r\nbody"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SaveDraft(Draft{ID: "draft-1", Body: "hello"}); err != nil {
+ t.Fatal(err)
+ }
+ s.SetFilters([]filter.Rule{{ID: "r", Enabled: true, Field: filter.FieldSubject, Operator: filter.OperatorContains, Pattern: "x", Action: filter.ActionHide}})
+ if err := s.Save(); err != nil {
+ t.Fatal(err)
+ }
+ reloaded, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ snapshot := reloaded.Snapshot()
+ if len(snapshot.Articles) != 1 || len(snapshot.Drafts) != 1 || len(snapshot.Filters) != 1 {
+ t.Fatalf("unexpected snapshot: %#v", snapshot)
+ }
+}