summaryrefslogtreecommitdiff
path: root/internal/processor/batch_processor.go
blob: faa515fc6076c161af26d64db975a0ae95ce5248 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
package processor

// BatchProcessor runs multi-word batch files: translation pass, validation,
// per-word processing with timeouts, and summary output. It delegates
// per-word work and skip detection to Processor.

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"codeberg.org/snonux/totalrecall/internal/audio"
	"codeberg.org/snonux/totalrecall/internal/batch"
)

// BatchProcessor orchestrates batch file processing. It holds a reference to
// the main Processor for shared services (translation, card directories,
// ProcessWordWithTranslationAndType).
type BatchProcessor struct {
	p *Processor
}

// ProcessBatch processes multiple words from a batch file.
// It first translates any entries that have English-to-Bulgarian translation
// needs, then validates all Bulgarian words, and finally processes each word
// with a per-word timeout to prevent a single hung API call from stalling the batch.
func (b *BatchProcessor) ProcessBatch() error {
	p := b.p
	entries, err := batch.ReadBatchFile(p.Flags.BatchFile)
	if err != nil {
		return err
	}

	if err := os.MkdirAll(p.Flags.OutputDir, 0755); err != nil {
		return fmt.Errorf("failed to create output directory: %w", err)
	}

	if err := b.translateBatchEntries(entries); err != nil {
		return err
	}

	if err := b.validateBatchEntries(entries); err != nil {
		return err
	}

	skipped, processed, errCount := b.processBatchEntries(entries)

	b.printBatchSummary(len(entries), processed, skipped, errCount)
	return nil
}

// translateBatchEntries runs the first pass over entries that need English→Bulgarian
// translation and mutates the slice in place with the result.
func (b *BatchProcessor) translateBatchEntries(entries []batch.WordEntry) error {
	p := b.p
	for i, entry := range entries {
		if !entry.NeedsTranslation || entry.Translation == "" {
			continue
		}
		bulgarian, err := p.translator.TranslateEnglishToBulgarian(entry.Translation)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error translating '%s' to Bulgarian: %v\n", entry.Translation, err)
			continue
		}
		entries[i].Bulgarian = bulgarian
		fmt.Printf("Translated '%s' to Bulgarian: %s\n", entry.Translation, bulgarian)
	}
	return nil
}

// validateBatchEntries checks that every entry with a Bulgarian word contains
// only valid Bulgarian text. Returns on the first validation failure.
func (b *BatchProcessor) validateBatchEntries(entries []batch.WordEntry) error {
	for _, entry := range entries {
		if entry.Bulgarian == "" {
			continue
		}
		if err := audio.ValidateBulgarianText(entry.Bulgarian); err != nil {
			return fmt.Errorf("invalid word '%s': %w", entry.Bulgarian, err)
		}
	}
	return nil
}

// processBatchEntries iterates the validated entries and processes each word,
// skipping words that are already fully processed. Returns skip, process, and
// error counts for the summary.
func (b *BatchProcessor) processBatchEntries(entries []batch.WordEntry) (skipped, processed, errCount int) {
	p := b.p
	for i, entry := range entries {
		if entry.Bulgarian == "" {
			continue
		}

		fmt.Printf("\nProcessing %d/%d: %s\n", i+1, len(entries), entry.Bulgarian)

		if p.isWordFullyProcessed(entry.Bulgarian) {
			wordDir := p.findCardDirectory(entry.Bulgarian)
			fmt.Printf("  ✓ Skipping '%s' - already fully processed in %s\n", entry.Bulgarian, filepath.Base(wordDir))
			skipped++
			continue
		}

		wordCtx, wordCancel := context.WithTimeout(context.Background(), 5*time.Minute)
		err := p.ProcessWordWithTranslationAndType(wordCtx, entry.Bulgarian, entry.Translation, entry.CardType)
		wordCancel()
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", entry.Bulgarian, err)
			errCount++
		} else {
			processed++
		}
	}
	return
}

// printBatchSummary prints a human-readable summary of the batch run.
func (b *BatchProcessor) printBatchSummary(total, processed, skipped, errCount int) {
	fmt.Printf("\n=== Batch Processing Summary ===\n")
	fmt.Printf("Total words: %d\n", total)
	fmt.Printf("Processed: %d\n", processed)
	fmt.Printf("Skipped (already complete): %d\n", skipped)
	if errCount > 0 {
		fmt.Printf("Errors: %d\n", errCount)
	}
	fmt.Printf("================================\n")
}