summaryrefslogtreecommitdiff
path: root/internal/processor/card_store.go
blob: ef93d0eb4dbfa62eeea048f9100d354556333d10 (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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
package processor

// CardStore manages the on-disk layout of word card directories.
// It delegates to the shared store.CardStore (internal/store) for all
// directory-discovery and creation logic, so those algorithms live in exactly
// one place (DRY). The methods here add the higher-level isWordFullyProcessed
// check that is specific to the batch processor.
//
// All methods are on *Processor rather than a separate struct to keep the
// existing call sites unchanged while still separating concerns at the file
// level (SRP at the file level, as recommended for Go packages).

import (
	"os"
	"path/filepath"
	"strings"

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

// findOrCreateWordDirectory returns the existing card directory for word
// inside the configured output directory, creating it when absent. Delegates
// to the shared CardStore so the directory-creation algorithm is not duplicated.
func (p *Processor) findOrCreateWordDirectory(word string) string {
	return p.cardStore.FindOrCreateCardDirectory(word)
}

// findCardDirectory searches the configured output directory for an existing
// card directory that contains the given word. Returns an empty string when
// no matching directory is found. Delegates to the shared CardStore.
func (p *Processor) findCardDirectory(word string) string {
	return p.cardStore.FindCardDirectory(word)
}

// isWordFullyProcessed returns true when the word's card directory already
// contains all expected output files (audio, image, translation, phonetic).
// The exact set of required files depends on the --skip-audio / --skip-images
// flags so partially-generated cards are still re-processed when relevant.
func (p *Processor) isWordFullyProcessed(word string) bool {
	wordDir := p.findCardDirectory(word)
	if wordDir == "" {
		return false // No directory exists yet.
	}

	// Base set of required files for every card type.
	requiredFiles := []string{
		"word.txt",
		"translation.txt",
		"phonetic.txt",
	}

	if !p.Flags.SkipAudio {
		if !p.hasRequiredAudioFiles(wordDir, &requiredFiles) {
			return false
		}
	}

	if !p.Flags.SkipImages {
		if !p.hasRequiredImageFiles(wordDir, &requiredFiles) {
			return false
		}
	}

	// Verify that every file in the required list actually exists on disk.
	for _, file := range requiredFiles {
		if _, err := os.Stat(filepath.Join(wordDir, file)); os.IsNotExist(err) {
			return false
		}
	}

	return true
}

// hasRequiredAudioFiles checks that all expected audio files and their
// attribution sidecars exist in wordDir. It appends extra filenames to
// requiredFiles as a side-effect so they are validated by the caller.
// Returns false as soon as a required audio file is determined to be missing.
func (p *Processor) hasRequiredAudioFiles(wordDir string, requiredFiles *[]string) bool {
	cardType := internal.LoadCardType(wordDir)
	audioFormat := p.EffectiveAudioFormat()

	if cardType.IsBgBg() {
		return p.hasBgBgAudioFiles(wordDir, audioFormat)
	}

	return p.hasEnBgAudioFiles(wordDir, audioFormat, requiredFiles)
}

// hasBgBgAudioFiles verifies that both audio_front and audio_back files exist
// along with their attribution sidecars. Used for bg-bg (definition) cards.
func (p *Processor) hasBgBgAudioFiles(wordDir, audioFormat string) bool {
	frontAudioFiles := anki.ResolveAudioPaths(wordDir, "audio_front", audioFormat)
	backAudioFiles := anki.ResolveAudioPaths(wordDir, "audio_back", audioFormat)
	if len(frontAudioFiles) == 0 || len(backAudioFiles) == 0 {
		return false
	}
	for _, audioFile := range append(frontAudioFiles, backAudioFiles...) {
		if _, err := os.Stat(audio.AttributionPath(audioFile)); os.IsNotExist(err) {
			return false
		}
	}
	return true
}

// hasEnBgAudioFiles verifies that at least one resolved audio file exists
// along with its attribution sidecar. Used for en-bg (translation) cards.
// It also appends "audio_metadata.txt" to requiredFiles so the caller checks it.
func (p *Processor) hasEnBgAudioFiles(wordDir, audioFormat string, requiredFiles *[]string) bool {
	*requiredFiles = append(*requiredFiles, "audio_metadata.txt")

	audioFiles := anki.ResolveAudioPaths(wordDir, "audio", audioFormat)
	if len(audioFiles) == 0 {
		return false
	}
	for _, audioFile := range audioFiles {
		if _, err := os.Stat(audio.AttributionPath(audioFile)); os.IsNotExist(err) {
			return false
		}
	}
	return true
}

// hasRequiredImageFiles checks that at least one image file exists and that
// the expected image sidecar files are present. It appends those sidecar
// filenames to requiredFiles as a side-effect.
// Returns false when no image file can be found.
func (p *Processor) hasRequiredImageFiles(wordDir string, requiredFiles *[]string) bool {
	*requiredFiles = append(*requiredFiles,
		"image_attribution.txt",
		"image_prompt.txt",
	)

	// Accept any of the common image extensions and naming conventions.
	imagePatterns := []string{"image_*.jpg", "image_*.png", "image_*.webp", "image.jpg", "image.png", "image.webp"}
	for _, pattern := range imagePatterns {
		if strings.Contains(pattern, "*") {
			matches, _ := filepath.Glob(filepath.Join(wordDir, pattern))
			if len(matches) > 0 {
				return true
			}
		} else {
			if _, err := os.Stat(filepath.Join(wordDir, pattern)); err == nil {
				return true
			}
		}
	}
	return false
}