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
|
}
|
}
|