-
Notifications
You must be signed in to change notification settings - Fork 0
/
log.go
321 lines (288 loc) · 8.66 KB
/
log.go
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
package cabrillo
import (
"fmt"
"io"
"log"
"net/mail"
"strconv"
"strings"
)
const (
// maxAddressLines is the maximum number of "ADDRESS:" lines allowed in a log.
maxAddressLines = 6
// maxLengthAddress is the maximum length of an "ADDRESS:" field.
maxLengthAddress = 45
// maxLengthName is the maximum allowed length of the "NAME:" field.
maxLengthName = 75
)
var (
errAddressTooLong = fmt.Errorf("address too long (maximum length %d characters)", maxLengthAddress)
errNameTooLong = fmt.Errorf("name too long (maximum length %d characters)", maxLengthName)
errTooManyAddressLines = fmt.Errorf("only allowed up to %d ADDRESS lines", maxAddressLines)
)
// Log is a data structure representing an entire Cabrillo formatted Log file.
// Data can be parsed into the structure. The long-term plan is to be able to
// generate a Cabrillo formatted log file from the data structure.
type Log struct {
Address Address
CallSign string
Categories []Category
Certificate bool
ClaimedScore int
Club string
Contest string
CreatedBy string
Email string
ExtensibleFields []ExtensibleField
GridLocator string
Location string
Name string
OffTimes []OffTime
//TODO: when generating the line, each line is a max of 75 chars long. Use multiple lines. Host station prefixed with `@`
Operators []string
QSOs []QSO
SoapBox []string
Version string
XQSOs []QSO
}
// Category returns the value for the specified category or an empty string if a
// value has not been specified for that category.
func (l *Log) Category(name string) string {
for i := range l.Categories {
if l.Categories[i].Name == name {
return l.Categories[i].Value
}
}
return ""
}
// ExtendedField returns the values for the specified extended field.
func (l *Log) ExtendedField(name string) []string {
for i := range l.ExtensibleFields {
if l.ExtensibleFields[i].Name == name {
return l.ExtensibleFields[i].Values
}
}
return nil
}
// AddCategory adds a category to the existing Log.
func (l *Log) AddCategory(name, value string) error {
// Validate the category name.
if _, ok := validCategories[name]; !ok {
return fmt.Errorf("unknown category %q", name)
}
for i := range l.Categories {
if l.Categories[i].Name == name {
// If a category is specified twice, last entry wins.
l.Categories[i].Value = value
return nil
}
}
l.Categories = append(l.Categories, Category{Name: name, Value: value})
return nil
}
// AddExtensibleField adds an extenisble field to the existing Log. If the field
// has been added previously, appends to the existing field.
func (l *Log) AddExtensibleField(name, value string) {
for i := range l.ExtensibleFields {
if l.ExtensibleFields[i].Name == name {
l.ExtensibleFields[i].Values = append(l.ExtensibleFields[i].Values, value)
return
}
}
l.ExtensibleFields = append(
l.ExtensibleFields,
ExtensibleField{
Name: name,
Values: []string{value},
},
)
}
// ExtensibleField represents a line prefixed by "X-" in the log other than
// "X-QSO". Each entry in Values represents a separate line on which the field
// was found. Thus if you had:
// X-COMMENT: some comment
// X-COMMENT: some other comment
// You would end up with one ExtensibleField with Name="COMMENT" with two entries in Values.
type ExtensibleField struct {
Name string
Values []string
}
// Address represents someone's physical address.
type Address struct {
// Maximum of 6 lines
Address []string
City string
StateProvince string
PostalCode string
Country string
}
type options struct {
exchangeFields int
}
// ParserOption is used to customize the log parser.
type ParserOption func(*options)
// WithExchangeFields sets how many space delimited fields to expect in the exchange column.
func WithExchangeFields(exchangeFields int) ParserOption {
return func(o *options) {
o.exchangeFields = exchangeFields
}
}
// ParseLog attempts to parse the data from the reader into a Log structure.
func ParseLog(r io.Reader, opts ...ParserOption) (Log, error) {
opt := &options{
exchangeFields: 1,
}
for _, o := range opts {
o(opt)
}
b, err := io.ReadAll(r)
if err != nil {
return Log{}, err
}
l := Log{
Certificate: true, // Defaults to yes per the specification.
}
lines := strings.Split(string(b), "\n")
for lineNum, line := range lines {
lineParts := strings.Fields(line)
if len(lineParts) < 2 {
continue
}
//originalTag := lineParts[0]
lineParts[0] = strings.ToUpper(lineParts[0])
switch lineParts[0] {
case "ADDRESS:":
addr := strings.Join(lineParts[1:], " ")
if len(addr) > maxLengthAddress {
return Log{}, newLineError(errAddressTooLong, lineNum)
}
l.Address.Address = append(l.Address.Address, addr)
if len(l.Address.Address) > maxAddressLines {
return Log{}, newLineError(errTooManyAddressLines, lineNum)
}
case "ADDRESS-CITY:":
l.Address.City = strings.Join(lineParts[1:], " ")
case "ADDRESS-COUNTRY:":
l.Address.Country = strings.Join(lineParts[1:], " ")
case "ADDRESS-POSTALCODE:":
l.Address.PostalCode = strings.Join(lineParts[1:], " ")
case "ADDRESS-STATE-PROVINCE:":
l.Address.StateProvince = strings.Join(lineParts[1:], " ")
case "CALLSIGN:":
l.CallSign = lineParts[1]
case "CERTIFICATE:":
var err error
l.Certificate, err = parseYN(lineParts[1])
if err != nil {
return Log{}, fmt.Errorf("parsing CERTIFICATE field: %w", err)
}
case "CLAIMED-SCORE:":
var err error
l.ClaimedScore, err = strconv.Atoi(lineParts[1])
if err != nil {
return Log{}, fmt.Errorf("parsing CLAIMED-SCORE field: %w", err)
}
case "CLUB:":
l.Club = strings.Join(lineParts[1:], " ")
case "CONTEST:":
l.Contest = strings.Join(lineParts[1:], " ")
case "CREATED-BY:":
l.CreatedBy = strings.Join(lineParts[1:], " ")
case "EMAIL:":
addr, err := mail.ParseAddress(strings.Join(lineParts[1:], " "))
if err != nil {
return Log{}, newLineError(fmt.Errorf("parsing email address: %w", err), lineNum)
}
l.Email = addr.Address
case "END-OF-LOG:":
case "GRID-LOCATOR:":
// TODO: should we validate the grid locator?
l.GridLocator = strings.Join(lineParts[1:], " ")
case "LOCATION:":
l.Location = strings.Join(lineParts[1:], " ")
case "NAME:":
l.Name = strings.Join(lineParts[1:], " ")
if len(l.Name) > maxLengthName {
return Log{}, newLineError(errNameTooLong, lineNum)
}
case "OFFTIME:":
ot, err := parseOffTime(strings.Join(lineParts[1:], " "))
if err != nil {
return Log{}, newLineError(err, lineNum)
}
l.OffTimes = append(l.OffTimes, ot)
case "OPERATORS:":
l.Operators = append(l.Operators, operatorsField(strings.Join(lineParts[1:], " "))...)
case "QSO:":
qso, err := NewQSO(line, opt.exchangeFields)
if err != nil {
return Log{}, newLineError(err, lineNum)
}
l.QSOs = append(l.QSOs, qso)
case "SOAPBOX:":
// max line length 75
l.SoapBox = append(l.SoapBox, strings.Join(lineParts[1:], " "))
case "START-OF-LOG:":
l.Version = lineParts[1]
case "X-QSO:":
qso, err := NewQSO(line, opt.exchangeFields)
if err != nil {
return Log{}, newLineError(err, lineNum)
}
l.XQSOs = append(l.XQSOs, qso)
default:
if strings.HasPrefix(lineParts[0], "CATEGORY-") && strings.HasSuffix(lineParts[0], ":") {
name := strings.TrimSuffix(strings.TrimPrefix(lineParts[0], "CATEGORY-"), ":")
if err := l.AddCategory(name, strings.Join(lineParts[1:], " ")); err != nil {
return Log{}, newLineError(err, lineNum)
}
continue
}
if strings.HasPrefix(lineParts[0], "X-") {
name := strings.TrimSuffix(strings.TrimPrefix(lineParts[0], "X-"), ":")
l.AddExtensibleField(name, strings.Join(lineParts[1:], " "))
continue
}
// we shouldn't be calling out to log here. Should this be an error?
log.Printf("unknown tag %q", line)
}
}
return l, nil
}
// The operators field is a space or comma delimited field
func operatorsField(str string) []string {
str = strings.ReplaceAll(str, ",", " ")
pieces := strings.Fields(str)
var data []string
for _, v := range pieces {
v = strings.TrimSpace(v)
if v == "" {
continue
}
data = append(data, v)
}
return data
}
func parseYN(str string) (bool, error) {
str = strings.TrimSpace(strings.ToUpper(str))
if str == "YES" {
return true, nil
}
if str == "NO" {
return false, nil
}
return false, fmt.Errorf("cannot parse %q as either YES or NO", str)
}
type lineError struct {
error
lineNumber int
}
func (e lineError) Error() string {
return fmt.Sprintf("%d: %s", e.lineNumber, e.error.Error())
}
func newLineError(err error, lineNumber int) error {
return &lineError{
error: err,
lineNumber: lineNumber,
}
}