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