package userdict import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" "voicesnap/internal/logger" "voicesnap/internal/paths" ) const defaultPageSize = 100 const maxPageSize = 500 // Entry describes one user dictionary replacement rule. type Entry struct { ID int64 `json:"id"` From string `json:"from"` To string `json:"to"` Enabled bool `json:"enabled"` CreatedAt int64 `json:"createdAt"` UpdatedAt int64 `json:"updatedAt"` } // Replacement is a new replacement rule to append to the dictionary. type Replacement struct { From string To string } type fileData struct { Replacements []Entry `json:"replacements"` } type storedEntry struct { ID int64 `json:"id,omitempty"` From string `json:"from"` To string `json:"to"` Enabled *bool `json:"enabled,omitempty"` CreatedAt int64 `json:"createdAt,omitempty"` UpdatedAt int64 `json:"updatedAt,omitempty"` } type storedFileData struct { Replacements []storedEntry `json:"replacements"` } type exportEntry struct { From string `json:"from"` To string `json:"to"` } type exportData struct { Replacements []exportEntry `json:"replacements"` } type importEntry struct { ID int64 `json:"id,omitempty"` From string `json:"from"` To string `json:"to"` Enabled *bool `json:"enabled,omitempty"` CreatedAt int64 `json:"createdAt,omitempty"` UpdatedAt int64 `json:"updatedAt,omitempty"` } type importData struct { Replacements []importEntry `json:"replacements"` } // PageResult contains one page of dictionary 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"` } // Store manages user dictionary rules persisted in userdict.json. type Store struct { mu sync.RWMutex entries []Entry path string } // New creates a user dictionary store and creates userdict.json if needed. func New() *Store { s := &Store{ path: userDictPath(), } s.load() if err := s.save(); err != nil { logger.Error("Failed to initialize user dictionary: %v", err) } return s } func userDictPath() string { return paths.File("userdict.json") } // Path returns the backing JSON file path. func (s *Store) Path() string { return s.path } // GetAll returns all dictionary entries newest first for display. func (s *Store) GetAll() []Entry { s.mu.RLock() defer s.mu.RUnlock() return sortedEntries(s.entries) } // GetPage returns a clamped page of dictionary entries newest first. func (s *Store) GetPage(page int, pageSize int) PageResult { s.mu.RLock() defer s.mu.RUnlock() pageSize = normalizePageSize(pageSize) total := len(s.entries) totalPages := 0 if total > 0 { totalPages = (total + pageSize - 1) / pageSize } page = normalizePage(page, totalPages) result := PageResult{ Entries: []Entry{}, Total: total, Page: page, PageSize: pageSize, TotalPages: totalPages, } if total == 0 { return result } sorted := sortedEntries(s.entries) 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, sorted[start:end]) return result } // Add creates a new enabled replacement rule. func (s *Store) Add(from, to string) (Entry, error) { from = strings.TrimSpace(from) to = strings.TrimSpace(to) if from == "" { return Entry{}, fmt.Errorf("source text is required") } now := time.Now().UnixMilli() entry := Entry{ ID: now, From: from, To: to, Enabled: true, CreatedAt: now, UpdatedAt: now, } s.mu.Lock() defer s.mu.Unlock() for s.hasIDLocked(entry.ID) { entry.ID++ } entries := append(cloneEntries(s.entries), entry) if err := s.saveEntriesLocked(entries); err != nil { return Entry{}, err } s.entries = entries return entry, nil } // Update modifies an existing replacement rule. func (s *Store) Update(id int64, from, to string, enabled bool) (Entry, error) { from = strings.TrimSpace(from) to = strings.TrimSpace(to) if from == "" { return Entry{}, fmt.Errorf("source text is required") } s.mu.Lock() defer s.mu.Unlock() for i := range s.entries { if s.entries[i].ID == id { entries := cloneEntries(s.entries) entries[i].From = from entries[i].To = to entries[i].Enabled = enabled entries[i].UpdatedAt = time.Now().UnixMilli() if err := s.saveEntriesLocked(entries); err != nil { return Entry{}, err } s.entries = entries return entries[i], nil } } return Entry{}, fmt.Errorf("entry not found") } // AppendNewReplacements appends enabled replacement rules as one all-or-nothing write. func (s *Store) AppendNewReplacements(rules []Replacement) ([]Entry, error) { now := time.Now().UnixMilli() prepared := make([]Replacement, 0, len(rules)) for i, rule := range rules { from := strings.TrimSpace(rule.From) to := strings.TrimSpace(rule.To) if from == "" { return nil, fmt.Errorf("entry %d source text is required", i+1) } prepared = append(prepared, Replacement{From: from, To: to}) } s.mu.Lock() defer s.mu.Unlock() existingPairs := map[string]struct{}{} existingFrom := map[string]string{} for _, entry := range s.entries { from := strings.TrimSpace(entry.From) to := strings.TrimSpace(entry.To) if from == "" { continue } existingPairs[pairKey(from, to)] = struct{}{} if _, ok := existingFrom[from]; !ok { existingFrom[from] = to } } entries := cloneEntries(s.entries) added := make([]Entry, 0, len(prepared)) for i, rule := range prepared { if _, ok := existingPairs[pairKey(rule.From, rule.To)]; ok { return nil, fmt.Errorf("dictionary already contains replacement %q", rule.From) } if to, ok := existingFrom[rule.From]; ok && to != rule.To { return nil, fmt.Errorf("dictionary contains conflicting replacement %q", rule.From) } id := now + int64(i) for hasID(entries, id) { id++ } entry := Entry{ ID: id, From: rule.From, To: rule.To, Enabled: true, CreatedAt: now, UpdatedAt: now, } entries = append(entries, entry) added = append(added, entry) existingPairs[pairKey(rule.From, rule.To)] = struct{}{} existingFrom[rule.From] = rule.To } if err := s.saveEntriesLocked(entries); err != nil { return nil, err } s.entries = entries return added, nil } // Delete removes one entry by id. func (s *Store) Delete(id int64) { s.mu.Lock() defer s.mu.Unlock() for i, entry := range s.entries { if entry.ID == id { entries := append(cloneEntries(s.entries[:i]), s.entries[i+1:]...) if err := s.saveEntriesLocked(entries); err != nil { logger.Error("Failed to delete user dictionary entry: %v", err) return } s.entries = entries return } } } // ClearAll removes all entries. func (s *Store) ClearAll() { s.mu.Lock() defer s.mu.Unlock() if err := s.saveEntriesLocked(nil); err != nil { logger.Error("Failed to clear user dictionary: %v", err) return } s.entries = nil } // Apply replaces text using all enabled user dictionary rules. func (s *Store) Apply(text string) string { if text == "" { return text } s.mu.RLock() entries := make([]Entry, len(s.entries)) copy(entries, s.entries) s.mu.RUnlock() for _, entry := range entries { if !entry.Enabled || entry.From == "" { continue } text = strings.ReplaceAll(text, entry.From, entry.To) } return text } // ExportJSON returns the dictionary file contents as formatted JSON. func (s *Store) ExportJSON() string { s.mu.RLock() defer s.mu.RUnlock() replacements := make([]exportEntry, 0, len(s.entries)) for _, entry := range s.entries { replacements = append(replacements, exportEntry{ From: entry.From, To: entry.To, }) } data, err := json.MarshalIndent(exportData{Replacements: replacements}, "", " ") if err != nil { logger.Error("Failed to export user dictionary: %v", err) return "{\n \"replacements\": []\n}" } return string(data) } // ImportJSON replaces the dictionary with entries from JSON. func (s *Store) ImportJSON(content string) error { var fd importData if err := json.Unmarshal([]byte(content), &fd); err != nil { return err } now := time.Now().UnixMilli() seen := map[int64]bool{} entries := make([]Entry, 0, len(fd.Replacements)) for i := range fd.Replacements { imported := fd.Replacements[i] from := strings.TrimSpace(imported.From) to := strings.TrimSpace(imported.To) if from == "" { return fmt.Errorf("entry %d source text is required", i+1) } id := imported.ID if id <= 0 || seen[id] { id = now + int64(i) } seen[id] = true enabled := true if imported.Enabled != nil { enabled = *imported.Enabled } createdAt := imported.CreatedAt if createdAt <= 0 { createdAt = now } entries = append(entries, Entry{ ID: id, From: from, To: to, Enabled: enabled, CreatedAt: createdAt, UpdatedAt: now, }) } s.mu.Lock() defer s.mu.Unlock() if err := s.saveEntriesLocked(entries); err != nil { return err } s.entries = entries return nil } func (s *Store) load() { data, err := os.ReadFile(s.path) if err != nil { return } var fd storedFileData if err := json.Unmarshal(data, &fd); err != nil { logger.Error("Failed to parse user dictionary: %v", err) return } now := time.Now().UnixMilli() s.entries = make([]Entry, 0, len(fd.Replacements)) seen := map[int64]bool{} for i, stored := range fd.Replacements { from := strings.TrimSpace(stored.From) if from == "" { continue } id := stored.ID if id <= 0 || seen[id] { id = now + int64(i) } seen[id] = true enabled := true if stored.Enabled != nil { enabled = *stored.Enabled } createdAt := stored.CreatedAt if createdAt <= 0 { createdAt = now } updatedAt := stored.UpdatedAt if updatedAt <= 0 { updatedAt = createdAt } s.entries = append(s.entries, Entry{ ID: id, From: from, To: strings.TrimSpace(stored.To), Enabled: enabled, CreatedAt: createdAt, UpdatedAt: updatedAt, }) } } func (s *Store) save() error { s.mu.Lock() defer s.mu.Unlock() return s.saveEntriesLocked(s.entries) } func (s *Store) saveEntriesLocked(entries []Entry) error { if err := paths.Ensure(); err != nil { logger.Error("Failed to create app data dir: %v", err) return err } data, err := json.MarshalIndent(fileData{Replacements: entries}, "", " ") if err != nil { logger.Error("Failed to marshal user dictionary: %v", err) return err } dir := filepath.Dir(s.path) temp, err := os.CreateTemp(dir, ".userdict-*.tmp") if err != nil { logger.Error("Failed to create user dictionary temp file: %v", err) return err } tempPath := temp.Name() defer os.Remove(tempPath) if _, err := temp.Write(data); err != nil { temp.Close() logger.Error("Failed to write user dictionary temp file: %v", err) return err } if err := temp.Chmod(0600); err != nil { temp.Close() logger.Error("Failed to set user dictionary permissions: %v", err) return err } if err := temp.Close(); err != nil { logger.Error("Failed to close user dictionary temp file: %v", err) return err } if err := os.Rename(tempPath, s.path); err != nil { logger.Error("Failed to save user dictionary: %v", err) return err } return nil } func (s *Store) hasIDLocked(id int64) bool { return hasID(s.entries, id) } func cloneEntries(entries []Entry) []Entry { cloned := make([]Entry, len(entries)) copy(cloned, entries) return cloned } func sortedEntries(entries []Entry) []Entry { result := cloneEntries(entries) sort.SliceStable(result, func(i, j int) bool { if result[i].CreatedAt == result[j].CreatedAt { return result[i].ID > result[j].ID } return result[i].CreatedAt > result[j].CreatedAt }) return result } 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 } func hasID(entries []Entry, id int64) bool { for _, entry := range entries { if entry.ID == id { return true } } return false } func pairKey(from, to string) string { return from + "\x00" + to }