package userdict import ( "encoding/json" "fmt" "os" "sort" "strings" "sync" "time" "voicesnap/internal/logger" "voicesnap/internal/paths" ) // 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"` } type fileData struct { Replacements []Entry `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"` } // 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() s.save() 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() result := make([]Entry, len(s.entries)) copy(result, s.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 } // 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++ } s.entries = append(s.entries, entry) s.saveLocked() 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 { s.entries[i].From = from s.entries[i].To = to s.entries[i].Enabled = enabled s.entries[i].UpdatedAt = time.Now().UnixMilli() s.saveLocked() return s.entries[i], nil } } return Entry{}, fmt.Errorf("entry not found") } // 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 { s.entries = append(s.entries[:i], s.entries[i+1:]...) s.saveLocked() return } } } // ClearAll removes all entries. func (s *Store) ClearAll() { s.mu.Lock() defer s.mu.Unlock() s.entries = nil s.saveLocked() } // 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() s.entries = entries s.saveLocked() return nil } 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 user dictionary: %v", err) return } s.entries = fd.Replacements } func (s *Store) save() { s.mu.Lock() defer s.mu.Unlock() s.saveLocked() } func (s *Store) saveLocked() { if err := paths.Ensure(); err != nil { logger.Error("Failed to create app data dir: %v", err) return } data, err := json.MarshalIndent(fileData{Replacements: s.entries}, "", " ") if err != nil { logger.Error("Failed to marshal user dictionary: %v", err) return } if err := os.WriteFile(s.path, data, 0644); err != nil { logger.Error("Failed to save user dictionary: %v", err) } } func (s *Store) hasIDLocked(id int64) bool { for _, entry := range s.entries { if entry.ID == id { return true } } return false }