package history import ( "encoding/json" "os" "sync" "time" "unicode/utf8" "voicesnap/internal/logger" "voicesnap/internal/paths" ) const maxEntries = 50 // Entry represents a single recognition history item. type Entry struct { Text string `json:"text"` Timestamp int64 `json:"timestamp"` // Unix milliseconds } // Store manages recognition history with JSON file persistence. type Store struct { mu sync.Mutex entries []Entry retentionDays int path string } type fileData struct { RetentionDays *int `json:"retentionDays"` Entries []Entry `json:"entries"` } // New creates a new history store, loading existing data from disk. func New() *Store { s := &Store{ retentionDays: 30, path: historyPath(), } s.load() s.prune() return s } // Add inserts a new entry at the top and persists to disk. // Single-character results (e.g. "." "。") are noise and skipped. func (s *Store) Add(text string) { if utf8.RuneCountInString(text) <= 1 { return } s.mu.Lock() defer s.mu.Unlock() entry := Entry{ Text: text, Timestamp: time.Now().UnixMilli(), } // Prepend s.entries = append([]Entry{entry}, s.entries...) // Cap at max if len(s.entries) > maxEntries { s.entries = s.entries[:maxEntries] } s.save() } // GetAll returns all history entries (newest first). func (s *Store) GetAll() []Entry { s.mu.Lock() defer s.mu.Unlock() result := make([]Entry, len(s.entries)) copy(result, s.entries) return result } // Delete removes an entry by its timestamp. func (s *Store) Delete(timestamp int64) { s.mu.Lock() defer s.mu.Unlock() for i, e := range s.entries { if e.Timestamp == timestamp { s.entries = append(s.entries[:i], s.entries[i+1:]...) s.save() return } } } // ClearAll removes all entries. func (s *Store) ClearAll() { s.mu.Lock() defer s.mu.Unlock() s.entries = nil s.save() } // GetRetentionDays returns the current retention period. func (s *Store) GetRetentionDays() int { s.mu.Lock() defer s.mu.Unlock() return s.retentionDays } // SetRetentionDays sets the retention period and prunes old entries. func (s *Store) SetRetentionDays(days int) { s.mu.Lock() defer s.mu.Unlock() s.retentionDays = days s.pruneUnlocked() s.save() } // prune removes entries older than the retention period. func (s *Store) prune() { s.mu.Lock() defer s.mu.Unlock() s.pruneUnlocked() } func (s *Store) pruneUnlocked() { if s.retentionDays <= 0 { return // 0 = keep forever } cutoff := time.Now().Add(-time.Duration(s.retentionDays) * 24 * time.Hour).UnixMilli() kept := s.entries[:0] for _, e := range s.entries { if e.Timestamp >= cutoff { kept = append(kept, e) } } s.entries = kept } func (s *Store) load() { data, err := os.ReadFile(s.path) if err != nil { return } var fd fileData if err := json.Unmarshal(data, &fd); err != nil { logger.Error("Failed to parse history: %v", err) return } s.entries = fd.Entries if fd.RetentionDays != nil { s.retentionDays = *fd.RetentionDays } } func (s *Store) save() { if err := paths.Ensure(); err != nil { logger.Error("Failed to create app data dir: %v", err) return } days := s.retentionDays fd := fileData{ RetentionDays: &days, Entries: s.entries, } data, err := json.MarshalIndent(fd, "", " ") if err != nil { logger.Error("Failed to marshal history: %v", err) return } if err := os.WriteFile(s.path, data, 0644); err != nil { logger.Error("Failed to save history: %v", err) } } func historyPath() string { return paths.File("history.json") }