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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
|
// Package store provides the CardStore type, a single shared repository for
// locating and creating on-disk card directories. Both the processor and gui
// packages use CardStore so the directory-scanning logic lives in exactly one
// place (DRY / SRP).
//
// Dependency position: store imports only the standard library, so every other
// internal package may import it without creating import cycles.
package store
import (
"crypto/md5"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// CardStore manages the on-disk layout of word card directories under a single
// output directory. It is safe to create multiple instances pointing at the
// same directory; all operations are stateless file-system reads/writes.
type CardStore struct {
outputDir string
}
// CardDirectory describes one discovered on-disk card directory and the word
// stored inside it.
type CardDirectory struct {
Path string
Word string
}
// New constructs a CardStore rooted at outputDir.
func New(outputDir string) *CardStore {
return &CardStore{outputDir: outputDir}
}
// OutputDir returns the root output directory this store operates on.
func (cs *CardStore) OutputDir() string {
return cs.outputDir
}
// FindCardDirectory searches the output directory for a subdirectory whose
// word.txt (or legacy _word.txt) matches word. Returns the directory path or
// an empty string when no matching directory is found.
func (cs *CardStore) FindCardDirectory(word string) string {
return FindCardDirectory(cs.outputDir, word)
}
// FindOrCreateCardDirectory returns the existing card directory for word, or
// creates a new one with a generated card ID and writes word.txt so subsequent
// calls can locate the directory.
func (cs *CardStore) FindOrCreateCardDirectory(word string) string {
return FindOrCreateCardDirectory(cs.outputDir, word)
}
// ScanWords scans the output directory for subdirectories that contain at
// least one content file (word.txt or legacy _word.txt) and passes a basic
// content check provided by the caller. It returns a sorted list of Bulgarian
// words found. The hasContent predicate receives the full path of each
// candidate directory; pass nil to accept all directories that have a word
// file.
func (cs *CardStore) ScanWords(hasContent func(wordDir string) bool) []string {
cards := cs.ListCardDirectories(hasContent)
words := make([]string, 0, len(cards))
for _, card := range cards {
words = append(words, card.Word)
}
sort.Strings(words)
return words
}
// ListCardDirectories scans the output directory for non-hidden card
// subdirectories that contain word metadata. The result is sorted by directory
// path so callers can process cards in deterministic creation order.
func (cs *CardStore) ListCardDirectories(hasContent func(wordDir string) bool) []CardDirectory {
entries, err := os.ReadDir(cs.outputDir)
if err != nil {
// Output directory does not exist yet; return empty list silently.
return []CardDirectory{}
}
cards := make([]CardDirectory, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
wordDir := filepath.Join(cs.outputDir, entry.Name())
word, ok := readWordFromDir(wordDir)
if !ok {
continue
}
if hasContent == nil || hasContent(wordDir) {
cards = append(cards, CardDirectory{Path: wordDir, Word: word})
}
}
sort.Slice(cards, func(i, j int) bool {
return cards[i].Path < cards[j].Path
})
return cards
}
// GenerateCardID creates a unique ID for a card based on the current timestamp
// and an MD5 hash of the Bulgarian word.
// Format: epochMillis_md5(word)[:8]
func GenerateCardID(bulgarianWord string) string {
epochMillis := time.Now().UnixNano() / 1_000_000
hash := md5.Sum([]byte(bulgarianWord))
hashStr := hex.EncodeToString(hash[:])[:8]
return fmt.Sprintf("%d_%s", epochMillis, hashStr)
}
// FindCardDirectory is the package-level (non-method) version of the directory
// search. It searches outputDir for a subdirectory whose word.txt (or legacy
// _word.txt) content matches word and returns its path, or "" if not found.
func FindCardDirectory(outputDir, word string) string {
entries, err := os.ReadDir(outputDir)
if err != nil {
return ""
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
dirPath := filepath.Join(outputDir, entry.Name())
if found := readWordMatch(dirPath, word); found {
return dirPath
}
}
return ""
}
// FindOrCreateCardDirectory returns the existing card directory for word inside
// outputDir, or creates a new one with a generated card ID. It also writes
// word.txt so subsequent calls can find the directory.
func FindOrCreateCardDirectory(outputDir, word string) string {
if dir := FindCardDirectory(outputDir, word); dir != "" {
return dir
}
cardID := GenerateCardID(word)
wordDir := filepath.Join(outputDir, cardID)
if err := os.MkdirAll(wordDir, 0755); err != nil {
fmt.Printf("Warning: failed to create word directory: %v\n", err)
return outputDir
}
if err := os.WriteFile(filepath.Join(wordDir, "word.txt"), []byte(word), 0644); err != nil {
fmt.Printf("Warning: failed to save word metadata: %v\n", err)
}
return wordDir
}
// readWordFromDir reads the Bulgarian word stored in word.txt (or the legacy
// _word.txt) inside a card directory. Returns the word and true on success.
func readWordFromDir(wordDir string) (string, bool) {
wordFile := filepath.Join(wordDir, "word.txt")
data, err := os.ReadFile(wordFile)
if err != nil {
// Backward-compatible fallback: old format used _word.txt.
wordFile = filepath.Join(wordDir, "_word.txt")
data, err = os.ReadFile(wordFile)
if err != nil {
return "", false
}
}
word := strings.TrimSpace(string(data))
if word == "" {
return "", false
}
return word, true
}
// readWordMatch returns true when the card directory at dirPath contains a
// word file whose trimmed content equals word.
func readWordMatch(dirPath, word string) bool {
found, ok := readWordFromDir(dirPath)
return ok && found == word
}
|