package history import ( "encoding/json" "fmt" "os" "path/filepath" "sync" "time" "unicode/utf8" "voicesnap/internal/logger" "voicesnap/internal/paths" ) const defaultRetentionDays = 30 const defaultPageSize = 100 const maxPageSize = 500 // Entry represents a single recognition history item. type Entry struct { Text string `json:"text"` Timestamp int64 `json:"timestamp"` // Unix milliseconds ExportedAt int64 `json:"exportedAt,omitempty"` // Unix milliseconds; 0 means unexported } // ExportScope limits which unexported entries are returned for correction CSV export. type ExportScope string const ( ExportScopeToday ExportScope = "today" ExportScopeLast7Days ExportScope = "last7Days" ExportScopeAll ExportScope = "all" ) // 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"` } // PageResult contains one page of history entries plus pagination metadata. type PageResult struct { Entries []Entry `json:"entries"` Total int `json:"total"` Page int `json:"page"` PageSize int `json:"pageSize"` TotalPages int `json:"totalPages"` } // Counts summarizes history state across the full retained dataset. type Counts struct { TotalCount int `json:"totalCount"` ExportedCount int `json:"exportedCount"` UnexportedTodayCount int `json:"unexportedTodayCount"` UnexportedLast7DaysCount int `json:"unexportedLast7DaysCount"` UnexportedAllCount int `json:"unexportedAllCount"` } // ClearExportedResult reports how many exported entries were removed. type ClearExportedResult struct { DeletedCount int `json:"deletedCount"` RemainingCount int `json:"remainingCount"` } // New creates a new history store, loading existing data from disk. func New() *Store { s := &Store{ retentionDays: defaultRetentionDays, path: historyPath(), } s.load() s.pruneAndSave() 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...) s.pruneUnlocked() s.save() } // GetAll returns all history entries (newest first). func (s *Store) GetAll() []Entry { s.mu.Lock() defer s.mu.Unlock() if s.pruneUnlocked() { s.save() } result := make([]Entry, len(s.entries)) copy(result, s.entries) return result } // GetPage returns a clamped page of history entries (newest first). func (s *Store) GetPage(page int, pageSize int) PageResult { s.mu.Lock() defer s.mu.Unlock() if s.pruneUnlocked() { s.save() } pageSize = normalizePageSize(pageSize) total := len(s.entries) totalPages := 0 if total > 0 { totalPages = (total + pageSize - 1) / pageSize } page = normalizePage(page, totalPages) result := PageResult{ Total: total, Page: page, PageSize: pageSize, TotalPages: totalPages, Entries: []Entry{}, } if total == 0 { return result } start := (page - 1) * pageSize if start >= total { start = 0 result.Page = 1 } end := start + pageSize if end > total { end = total } result.Entries = make([]Entry, end-start) copy(result.Entries, s.entries[start:end]) return result } // Counts returns full-history counters used by paginated frontends. func (s *Store) Counts(now time.Time, loc *time.Location) Counts { if loc == nil { loc = time.Local } s.mu.Lock() defer s.mu.Unlock() if s.pruneUnlocked() { s.save() } todayCutoff := exportScopeCutoff(ExportScopeToday, now, loc) last7DaysCutoff := exportScopeCutoff(ExportScopeLast7Days, now, loc) counts := Counts{TotalCount: len(s.entries)} for _, entry := range s.entries { if entry.ExportedAt > 0 { counts.ExportedCount++ continue } counts.UnexportedAllCount++ if entry.Timestamp >= todayCutoff { counts.UnexportedTodayCount++ } if entry.Timestamp >= last7DaysCutoff { counts.UnexportedLast7DaysCount++ } } return counts } // GetUnexported returns unexported history entries within the requested export scope. func (s *Store) GetUnexported(scope ExportScope, now time.Time, loc *time.Location) []Entry { if loc == nil { loc = time.Local } s.mu.Lock() defer s.mu.Unlock() if s.pruneUnlocked() { s.save() } cutoff := exportScopeCutoff(scope, now, loc) result := make([]Entry, 0, len(s.entries)) for _, entry := range s.entries { if entry.ExportedAt > 0 { continue } if cutoff > 0 && entry.Timestamp < cutoff { continue } result = append(result, entry) } return result } // MarkExported marks entries as exported after a correction CSV has been written successfully. func (s *Store) MarkExported(timestamps []int64, exportedAt int64) (int, error) { if len(timestamps) == 0 { return 0, nil } if exportedAt <= 0 { exportedAt = time.Now().UnixMilli() } targets := make(map[int64]struct{}, len(timestamps)) for _, timestamp := range timestamps { targets[timestamp] = struct{}{} } s.mu.Lock() defer s.mu.Unlock() updated := 0 previous := map[int]int64{} for i := range s.entries { if _, ok := targets[s.entries[i].Timestamp]; !ok { continue } if s.entries[i].ExportedAt > 0 { continue } previous[i] = s.entries[i].ExportedAt s.entries[i].ExportedAt = exportedAt updated++ } if updated > 0 { if err := s.save(); err != nil { for index, value := range previous { s.entries[index].ExportedAt = value } return 0, err } } return updated, nil } // 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() } // ClearExported removes all entries that have been successfully exported. func (s *Store) ClearExported() (ClearExportedResult, error) { s.mu.Lock() defer s.mu.Unlock() previous := make([]Entry, len(s.entries)) copy(previous, s.entries) kept := s.entries[:0] deleted := 0 for _, entry := range s.entries { if entry.ExportedAt > 0 { deleted++ continue } kept = append(kept, entry) } if deleted == 0 { return ClearExportedResult{DeletedCount: 0, RemainingCount: len(s.entries)}, nil } s.entries = kept if err := s.save(); err != nil { s.entries = previous return ClearExportedResult{}, err } return ClearExportedResult{DeletedCount: deleted, RemainingCount: len(s.entries)}, nil } // 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) error { if !validRetentionDays(days) { return fmt.Errorf("unsupported history retention days: %d", days) } s.mu.Lock() defer s.mu.Unlock() s.retentionDays = days s.pruneUnlocked() s.save() return nil } // Path returns the persisted history file path. func (s *Store) Path() string { return s.path } // prune removes entries older than the retention period. func (s *Store) pruneAndSave() { s.mu.Lock() defer s.mu.Unlock() if s.pruneUnlocked() { s.save() } } func (s *Store) pruneUnlocked() bool { if s.retentionDays <= 0 { return false // 0 = keep forever } before := len(s.entries) 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 return len(s.entries) != before } 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 = normalizeRetentionDays(*fd.RetentionDays) } } func (s *Store) save() error { if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { logger.Error("Failed to create history dir: %v", err) return err } 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 err } if err := os.WriteFile(s.path, data, 0600); err != nil { logger.Error("Failed to save history: %v", err) return err } if err := os.Chmod(s.path, 0600); err != nil { logger.Error("Failed to set history permissions: %v", err) return err } return nil } func historyPath() string { return paths.File("history.json") } func normalizeRetentionDays(days int) int { if validRetentionDays(days) { return days } return defaultRetentionDays } func validRetentionDays(days int) bool { switch days { case 0, 7, 30, 90: return true default: return false } } func normalizePageSize(pageSize int) int { if pageSize <= 0 { return defaultPageSize } if pageSize > maxPageSize { return maxPageSize } return pageSize } func normalizePage(page int, totalPages int) int { if totalPages <= 0 { return 1 } if page < 1 { return 1 } if page > totalPages { return totalPages } return page } // ParseExportScope normalizes frontend export scope values. func ParseExportScope(value string) (ExportScope, error) { scope := ExportScope(value) switch scope { case "", ExportScopeAll: return ExportScopeAll, nil case ExportScopeToday, ExportScopeLast7Days: return scope, nil default: return "", fmt.Errorf("unsupported history export scope: %s", value) } } func exportScopeCutoff(scope ExportScope, now time.Time, loc *time.Location) int64 { switch scope { case ExportScopeToday: localNow := now.In(loc) year, month, day := localNow.Date() return time.Date(year, month, day, 0, 0, 0, 0, loc).UnixMilli() case ExportScopeLast7Days: return now.In(loc).AddDate(0, 0, -7).UnixMilli() default: return 0 } }