-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtable.go
More file actions
343 lines (289 loc) · 8.17 KB
/
table.go
File metadata and controls
343 lines (289 loc) · 8.17 KB
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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 WoozyMasta
// Source: github.com/woozymasta/stringtable
package stringtable
import (
"bytes"
"encoding/csv"
"fmt"
"io"
"maps"
"os"
"strings"
)
const (
keyColumnName = "Language"
originalColumnName = "original"
)
// Table is a DayZ stringtable CSV model.
type Table struct {
// Languages defines translation column order after key+original columns.
Languages []string `json:"languages,omitempty" yaml:"languages,omitempty"`
// Rows stores key/original and per-language values.
Rows []Row `json:"rows,omitempty" yaml:"rows,omitempty"`
}
// WriteOptions controls CSV serialization behavior.
type WriteOptions struct {
// UseTableLanguagesOnly writes only table header languages as-is.
// When false, writer emits all default DayZ languages plus any extra
// languages found in table data.
UseTableLanguagesOnly bool `json:"use_table_languages_only,omitempty" yaml:"use_table_languages_only,omitempty"`
}
// Row is one stringtable.csv entry.
type Row struct {
// Translations stores values per language column.
Translations map[string]string `json:"translations,omitempty" yaml:"translations,omitempty"`
// Key is value from "Language" column.
Key string `json:"key" yaml:"key"`
// Original is value from "original" column.
Original string `json:"original" yaml:"original"`
}
// NewTable creates an empty table with language order.
func NewTable(languages []string) *Table {
return &Table{
Languages: normalizeLanguageOrder(languages),
Rows: make([]Row, 0),
}
}
// ParseCSV parses stringtable CSV bytes.
func ParseCSV(data []byte) (*Table, error) {
return ParseCSVReader(bytes.NewReader(data))
}
// ParseCSVReader parses stringtable CSV from reader.
func ParseCSVReader(reader io.Reader) (*Table, error) {
records, err := csv.NewReader(reader).ReadAll()
if err != nil {
return nil, fmt.Errorf("read csv: %w", err)
}
if len(records) == 0 {
return NewTable(nil), nil
}
header := records[0]
if err := validateHeader(header); err != nil {
return nil, err
}
languages := normalizeLanguageOrder(header[2:])
table := NewTable(languages)
seen := make(map[string]int, len(records)-1)
for index, record := range records[1:] {
if len(record) == 0 {
continue
}
key := cellAt(record, 0)
if key == "" {
continue
}
if first, ok := seen[key]; ok {
return nil, fmt.Errorf(
"row %d key %q duplicates row %d: %w",
index+2,
key,
first+2,
ErrDuplicateKey,
)
}
seen[key] = index + 1
row := Row{
Key: key,
Original: cellAt(record, 1),
Translations: make(map[string]string, len(languages)),
}
for langIndex, language := range languages {
row.Translations[language] = cellAt(record, langIndex+2)
}
table.Rows = append(table.Rows, row)
}
return table, nil
}
// ParseCSVFile parses stringtable CSV file from disk.
func ParseCSVFile(path string) (*Table, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open csv file: %w", err)
}
defer func() { _ = file.Close() }()
table, err := ParseCSVReader(file)
if err != nil {
return nil, fmt.Errorf("parse csv file: %w", err)
}
return table, nil
}
// FormatCSV serializes table into stringtable CSV bytes.
func FormatCSV(table *Table) ([]byte, error) {
return FormatCSVWithOptions(table, nil)
}
// FormatCSVWithOptions serializes table into stringtable CSV bytes.
func FormatCSVWithOptions(table *Table, options *WriteOptions) ([]byte, error) {
if table == nil {
return nil, ErrNilTable
}
var builder strings.Builder
writer := csv.NewWriter(&builder)
writer.UseCRLF = true
languages := resolveWriteLanguages(table, options)
header := make([]string, 0, len(languages)+2)
header = append(header, keyColumnName, originalColumnName)
header = append(header, languages...)
if err := writer.Write(header); err != nil {
return nil, fmt.Errorf("write csv header: %w", err)
}
for _, row := range table.Rows {
record := make([]string, 0, len(languages)+2)
record = append(record, row.Key, row.Original)
for _, language := range languages {
record = append(record, row.Translations[language])
}
if err := writer.Write(record); err != nil {
return nil, fmt.Errorf("write csv row: %w", err)
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, fmt.Errorf("flush csv writer: %w", err)
}
return []byte(builder.String()), nil
}
// WriteCSVFile writes table as stringtable CSV file.
func WriteCSVFile(path string, table *Table) error {
return WriteCSVFileWithOptions(path, table, nil)
}
// WriteCSVFileWithOptions writes table as stringtable CSV file.
func WriteCSVFileWithOptions(path string, table *Table, options *WriteOptions) error {
data, err := FormatCSVWithOptions(table, options)
if err != nil {
return err
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write csv file: %w", err)
}
return nil
}
// Clone deep-copies table.
func (t *Table) Clone() *Table {
if t == nil {
return nil
}
out := NewTable(t.Languages)
out.Rows = make([]Row, 0, len(t.Rows))
for _, row := range t.Rows {
copied := Row{
Key: row.Key,
Original: row.Original,
Translations: maps.Clone(row.Translations),
}
out.Rows = append(out.Rows, copied)
}
return out
}
// EnsureLanguage appends missing language to column order.
func (t *Table) EnsureLanguage(language string) {
if t == nil {
return
}
if language == "" || ContainsLanguage(t.Languages, language) {
return
}
t.Languages = append(t.Languages, language)
}
// Validate checks structural consistency.
func (t *Table) Validate() error {
if t == nil {
return ErrNilTable
}
seen := make(map[string]int, len(t.Rows))
for index, row := range t.Rows {
if row.Key == "" {
continue
}
if first, ok := seen[row.Key]; ok {
return fmt.Errorf(
"rows[%d] key %q duplicates rows[%d]: %w",
index,
row.Key,
first,
ErrDuplicateKey,
)
}
seen[row.Key] = index
}
return nil
}
// cellAt returns record value at index or empty string.
func cellAt(record []string, index int) string {
if index < 0 || index >= len(record) {
return ""
}
return record[index]
}
// validateHeader checks required stringtable header columns.
func validateHeader(header []string) error {
if len(header) < 2 {
return fmt.Errorf("header requires at least 2 columns: %w", ErrInvalidHeader)
}
if header[0] != keyColumnName || header[1] != originalColumnName {
return fmt.Errorf(
`header must start with %q,%q: %w`,
keyColumnName,
originalColumnName,
ErrInvalidHeader,
)
}
return nil
}
// normalizeLanguageOrder deduplicates languages while preserving first order.
func normalizeLanguageOrder(languages []string) []string {
out := make([]string, 0, len(languages))
seen := make(map[string]struct{}, len(languages))
for _, language := range languages {
trimmed := strings.TrimSpace(language)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
// resolveWriteLanguages selects output language columns for serialization.
func resolveWriteLanguages(table *Table, options *WriteOptions) []string {
if options != nil && options.UseTableLanguagesOnly {
return normalizeLanguageOrder(table.Languages)
}
estimated := len(DefaultLanguages) + len(table.Languages)
for rowIndex := range table.Rows {
estimated += len(table.Rows[rowIndex].Translations)
}
out := make([]string, 0, estimated)
seen := make(map[string]struct{}, estimated)
for _, language := range DefaultLanguages {
out = appendUniqueTrimmedLanguage(out, seen, language)
}
for _, language := range table.Languages {
out = appendUniqueTrimmedLanguage(out, seen, language)
}
for _, row := range table.Rows {
for language := range row.Translations {
out = appendUniqueTrimmedLanguage(out, seen, language)
}
}
return out
}
// appendUniqueTrimmedLanguage appends non-empty unique language names.
func appendUniqueTrimmedLanguage(
out []string,
seen map[string]struct{},
language string,
) []string {
trimmed := strings.TrimSpace(language)
if trimmed == "" {
return out
}
if _, ok := seen[trimmed]; ok {
return out
}
seen[trimmed] = struct{}{}
return append(out, trimmed)
}