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