package services import ( "fmt" "os" "path/filepath" "sync" "time" "voicesnap/internal/correctioncsv" "voicesnap/internal/history" "voicesnap/internal/userdict" "github.com/wailsapp/wails/v3/pkg/application" ) // CorrectionCSVService handles history correction CSV export and import preview. type CorrectionCSVService struct { history *history.Store userdict *userdict.Store app *application.App win application.Window mu sync.Mutex sequence int64 previews map[string]pendingCorrectionPreview } type pendingCorrectionPreview struct { rules []userdict.Replacement } // CorrectionCSVPreviewResponse is returned to the frontend after CSV preview parsing. type CorrectionCSVPreviewResponse struct { PreviewID string `json:"previewId"` FileName string `json:"fileName"` Canceled bool `json:"canceled"` Summary correctioncsv.Summary `json:"summary"` Items []correctioncsv.PreviewItem `json:"items"` TotalSlots int `json:"totalSlots"` VisibleLimit int `json:"visibleLimit"` DetailsTruncated bool `json:"detailsTruncated"` } // CorrectionCSVExportResult describes a correction CSV export attempt. type CorrectionCSVExportResult struct { Path string `json:"path"` Canceled bool `json:"canceled"` NoEntries bool `json:"noEntries"` } // CorrectionCSVImportResult describes a confirmed import write. type CorrectionCSVImportResult struct { Added int `json:"added"` } func NewCorrectionCSVService(historyStore *history.Store, userDictStore *userdict.Store) *CorrectionCSVService { return &CorrectionCSVService{ history: historyStore, userdict: userDictStore, previews: map[string]pendingCorrectionPreview{}, } } // SetApp sets the Wails app and parent window used for native dialogs. func (s *CorrectionCSVService) SetApp(app *application.App, win application.Window) { s.app = app s.win = win } // ExportHistoryCorrectionCSVToFile asks the user where to save a correction CSV and writes unexported entries. func (s *CorrectionCSVService) ExportHistoryCorrectionCSVToFile(scopeValue string) (CorrectionCSVExportResult, error) { if s.app == nil { return CorrectionCSVExportResult{}, os.ErrInvalid } scope, err := history.ParseExportScope(scopeValue) if err != nil { return CorrectionCSVExportResult{}, err } entries := s.history.GetUnexported(scope, time.Now(), time.Local) if len(entries) == 0 { return CorrectionCSVExportResult{NoEntries: true}, nil } home, _ := os.UserHomeDir() dialog := s.app.Dialog.SaveFile(). SetMessage("导出勘误 CSV"). SetButtonText("导出"). SetDirectory(filepath.Join(home, "Downloads")). SetFilename("privatevoice-correction-history-"+time.Now().Format("20060102-1504")+".csv"). AddFilter("CSV Files", "*.csv"). CanCreateDirectories(true) if s.win != nil { dialog.AttachToWindow(s.win) } path, err := dialog.PromptForSingleSelection() if err != nil { return CorrectionCSVExportResult{}, err } if path == "" { return CorrectionCSVExportResult{Canceled: true}, nil } if filepath.Ext(path) == "" { path += ".csv" } data, err := correctioncsv.BuildHistoryCSV(entries, time.Local) if err != nil { return CorrectionCSVExportResult{}, err } if err := os.WriteFile(path, data, 0600); err != nil { return CorrectionCSVExportResult{}, err } timestamps := make([]int64, 0, len(entries)) for _, entry := range entries { timestamps = append(timestamps, entry.Timestamp) } if _, err := s.history.MarkExported(timestamps, time.Now().UnixMilli()); err != nil { return CorrectionCSVExportResult{}, err } return CorrectionCSVExportResult{Path: path}, nil } // PreviewCorrectionCSVFromFile asks the user to pick a CSV and returns a non-writing preview. func (s *CorrectionCSVService) PreviewCorrectionCSVFromFile() (CorrectionCSVPreviewResponse, error) { if s.app == nil { return CorrectionCSVPreviewResponse{}, os.ErrInvalid } home, _ := os.UserHomeDir() dialog := s.app.Dialog.OpenFile(). SetMessage("导入勘误表"). SetButtonText("选择"). SetDirectory(filepath.Join(home, "Downloads")). AddFilter("CSV Files", "*.csv") if s.win != nil { dialog.AttachToWindow(s.win) } path, err := dialog.PromptForSingleSelection() if err != nil { return CorrectionCSVPreviewResponse{}, err } if path == "" { return CorrectionCSVPreviewResponse{Canceled: true}, nil } data, err := os.ReadFile(path) if err != nil { return CorrectionCSVPreviewResponse{}, err } preview, err := correctioncsv.ParseAndPreviewCSV(data, s.userdict.GetAll(), correctioncsv.Options{}) if err != nil { return CorrectionCSVPreviewResponse{}, err } previewID := s.storePreview(preview.NewRules) return CorrectionCSVPreviewResponse{ PreviewID: previewID, FileName: filepath.Base(path), Summary: preview.Summary, Items: preview.Items, TotalSlots: preview.TotalSlots, VisibleLimit: preview.VisibleLimit, DetailsTruncated: preview.DetailsTruncated, }, nil } // ConfirmCorrectionCSVImport appends only the previously previewed new rules. func (s *CorrectionCSVService) ConfirmCorrectionCSVImport(previewID string) (CorrectionCSVImportResult, error) { rules, ok := s.takePreview(previewID) if !ok { return CorrectionCSVImportResult{}, fmt.Errorf("correction CSV preview expired") } if len(rules) == 0 { return CorrectionCSVImportResult{Added: 0}, nil } added, err := s.userdict.AppendNewReplacements(rules) if err != nil { return CorrectionCSVImportResult{}, err } return CorrectionCSVImportResult{Added: len(added)}, nil } func (s *CorrectionCSVService) storePreview(rules []correctioncsv.Rule) string { s.mu.Lock() defer s.mu.Unlock() s.sequence++ id := fmt.Sprintf("%d-%d", time.Now().UnixNano(), s.sequence) replacements := make([]userdict.Replacement, 0, len(rules)) for _, rule := range rules { replacements = append(replacements, userdict.Replacement{ From: rule.From, To: rule.To, }) } s.previews[id] = pendingCorrectionPreview{ rules: replacements, } return id } func (s *CorrectionCSVService) takePreview(id string) ([]userdict.Replacement, bool) { s.mu.Lock() defer s.mu.Unlock() preview, ok := s.previews[id] if !ok { return nil, false } delete(s.previews, id) return preview.rules, true }