|
| 1 | +package anki |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/csv" |
| 5 | + "fmt" |
| 6 | + "io" |
| 7 | + "math/rand/v2" |
| 8 | + "strings" |
| 9 | +) |
| 10 | + |
| 11 | +// Record is how anki CSV file should look like |
| 12 | +type Record struct { |
| 13 | + Text string |
| 14 | + HelperText string |
| 15 | + TextA, TextB, TextC, TextD string |
| 16 | + AudioA, AudioB, AudioC, AudioD string |
| 17 | + AudioAnswer string |
| 18 | +} |
| 19 | + |
| 20 | +// Row returns Record in correct order |
| 21 | +func (r Record) Row() []string { |
| 22 | + return []string{ |
| 23 | + r.Text, |
| 24 | + r.HelperText, |
| 25 | + r.TextA, |
| 26 | + r.TextB, |
| 27 | + r.TextC, |
| 28 | + r.TextD, |
| 29 | + r.AudioA, |
| 30 | + r.AudioB, |
| 31 | + r.AudioC, |
| 32 | + r.AudioD, |
| 33 | + r.AudioAnswer, |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +// RandomizedRow returns Record with shuffled order of TextA, TextB, TextC, TextD |
| 38 | +func (r Record) RandomizedRow() []string { |
| 39 | + type pair struct { |
| 40 | + text string |
| 41 | + audio string |
| 42 | + } |
| 43 | + pairs := []pair{ |
| 44 | + {r.TextA, r.AudioA}, |
| 45 | + {r.TextB, r.AudioB}, |
| 46 | + {r.TextC, r.AudioC}, |
| 47 | + {r.TextD, r.AudioD}, |
| 48 | + } |
| 49 | + |
| 50 | + rand.Shuffle(len(pairs), func(i, j int) { |
| 51 | + pairs[i], pairs[j] = pairs[j], pairs[i] |
| 52 | + }) |
| 53 | + |
| 54 | + return []string{ |
| 55 | + r.Text, |
| 56 | + r.HelperText, |
| 57 | + pairs[0].text, |
| 58 | + pairs[1].text, |
| 59 | + pairs[2].text, |
| 60 | + pairs[3].text, |
| 61 | + pairs[0].audio, |
| 62 | + pairs[1].audio, |
| 63 | + pairs[2].audio, |
| 64 | + pairs[3].audio, |
| 65 | + r.AudioAnswer, |
| 66 | + } |
| 67 | +} |
| 68 | + |
| 69 | +const ( |
| 70 | + startCloze = "{{c1::" |
| 71 | + endCloze = "}}" |
| 72 | +) |
| 73 | + |
| 74 | +// CleanedText returns the cleaned text without "{{c1::}}" |
| 75 | +func (r Record) CleanedText() string { |
| 76 | + start := strings.Index(r.Text, startCloze) |
| 77 | + if start == -1 { |
| 78 | + return "" |
| 79 | + } |
| 80 | + |
| 81 | + end := strings.Index(r.Text[start:], endCloze) |
| 82 | + if end == -1 { |
| 83 | + return "" |
| 84 | + } |
| 85 | + |
| 86 | + // set to absolute end |
| 87 | + end = start + end |
| 88 | + answer := r.Text[start+len(startCloze) : end] |
| 89 | + |
| 90 | + cleanedText := r.Text[:start] + answer + r.Text[end+len(endCloze):] |
| 91 | + cleanedText = strings.ReplaceAll(cleanedText, " ", " ") |
| 92 | + if strings.Contains(cleanedText, startCloze) || strings.Contains(cleanedText, endCloze) { |
| 93 | + return "" |
| 94 | + } |
| 95 | + return cleanedText |
| 96 | +} |
| 97 | + |
| 98 | +const inputHeaderSize = 6 // only won't include audio fields |
| 99 | + |
| 100 | +// ReadCSVRecords reads records from anki CSV file |
| 101 | +func ReadCSVRecords(r io.Reader) ([]Record, error) { |
| 102 | + reader := csv.NewReader(r) |
| 103 | + reader.TrimLeadingSpace = true |
| 104 | + reader.FieldsPerRecord = inputHeaderSize |
| 105 | + |
| 106 | + header, err := reader.Read() |
| 107 | + if err != nil { |
| 108 | + return nil, fmt.Errorf("%T.Read(): %w", reader, err) |
| 109 | + } |
| 110 | + h := []string{"Text", "HelperText", "TextA", "TextB", "TextC", "TextD"} |
| 111 | + for i := range len(header) { |
| 112 | + if header[i] != h[i] { |
| 113 | + return nil, fmt.Errorf("invalid header(%q) must be %q", header[i], h[i]) |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + var records []Record |
| 118 | + for { |
| 119 | + record, err := reader.Read() |
| 120 | + if err == io.EOF { |
| 121 | + break |
| 122 | + } |
| 123 | + if err != nil { |
| 124 | + return nil, fmt.Errorf("%T.Read(): %w", reader, err) |
| 125 | + } |
| 126 | + |
| 127 | + text := record[0] |
| 128 | + if !strings.Contains(text, startCloze) && !strings.Contains(text, endCloze) { |
| 129 | + return nil, fmt.Errorf("text(%q) must contain cloze deletion format with %sWORD%s", text, startCloze, endCloze) |
| 130 | + } |
| 131 | + |
| 132 | + records = append(records, Record{ |
| 133 | + Text: record[0], |
| 134 | + HelperText: record[1], |
| 135 | + TextA: record[2], |
| 136 | + TextB: record[3], |
| 137 | + TextC: record[4], |
| 138 | + TextD: record[5], |
| 139 | + }) |
| 140 | + } |
| 141 | + return records, nil |
| 142 | +} |
| 143 | + |
| 144 | +// WriteCSVRecords writes records into anki CSV file |
| 145 | +func WriteCSVRecords(w io.Writer, records []Record, stripCSVHeader, shuffle bool) error { |
| 146 | + writer := csv.NewWriter(w) |
| 147 | + |
| 148 | + if !stripCSVHeader { |
| 149 | + h := []string{ |
| 150 | + "Text", "HelperText", "TextA", "TextB", "TextC", "TextD", |
| 151 | + "AudioA", "AudioB", "AudioC", "AudioD", "AudioAnswer", |
| 152 | + } |
| 153 | + if err := writer.Write(h); err != nil { |
| 154 | + return fmt.Errorf("%T.Write(): %w", writer, err) |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + for _, r := range records { |
| 159 | + var row []string |
| 160 | + if shuffle { |
| 161 | + row = r.RandomizedRow() |
| 162 | + } else { |
| 163 | + row = r.Row() |
| 164 | + } |
| 165 | + |
| 166 | + if err := writer.Write(row); err != nil { |
| 167 | + return fmt.Errorf("%T.Write(): %w", writer, err) |
| 168 | + } |
| 169 | + } |
| 170 | + |
| 171 | + writer.Flush() |
| 172 | + if err := writer.Error(); err != nil { |
| 173 | + return fmt.Errorf("%T.Error(): %w", writer, err) |
| 174 | + } |
| 175 | + return nil |
| 176 | +} |
0 commit comments