chore: release v0.26.0

This commit is contained in:
2026-08-20 17:53:55 +02:00
parent 47992b5f03
commit 1e507225dd
44 changed files with 3197 additions and 240 deletions
+186 -2
View File
@@ -1475,13 +1475,67 @@ func conditionSQL(c Condition) (string, []any, error) {
}
}
// quickCallsignLike turns the Recent-QSOs search box into a SQL LIKE pattern.
//
// The rule, which is the one an operator already has in their head from the
// alert filters:
//
// 4S → starts with 4S (4S%)
// *4S → ends with 4S (%4S)
// *4S* → contains 4S (%4S%)
// 4S? → 4S and one more (4S_)
//
// So a plain word is a PREFIX — the common case, and what makes typing a call
// feel like a call sign lookup rather than a text search. The moment a wildcard
// appears the pattern is taken literally end to end, which is the only way
// "*4S" can mean "ends with" while "4S" means "starts with".
//
// This used to be an unconditional contains-match, so there was no way to ask
// for a prefix at all: searching 4S returned every call with 4S buried in it.
//
// % and _ typed by the operator are escaped to literals — otherwise a search
// for "_" would quietly match everything.
func quickCallsignLike(pattern string) string {
p := strings.TrimSpace(pattern)
var b strings.Builder
esc := func(r rune) {
switch r {
case '%', '_', '!':
b.WriteByte('!')
}
b.WriteRune(r)
}
if !strings.ContainsAny(p, "*?") {
for _, r := range p {
esc(r)
}
b.WriteByte('%') // plain text = prefix
return b.String()
}
for _, r := range p {
switch r {
case '*':
b.WriteByte('%')
case '?':
b.WriteByte('_')
default:
esc(r)
}
}
return b.String()
}
// buildWhere assembles the predicate (everything after WHERE) + args.
func buildWhere(f QueryFilter) (string, []any, error) {
pred := "1=1"
var args []any
if qc := strings.TrimSpace(f.QuickCallsign); qc != "" {
pred += " AND callsign LIKE ?"
args = append(args, "%"+qc+"%")
// ESCAPE '!' rather than the usual backslash: the clause is a literal in
// the SQL text, and '\\' is one character to MySQL but two to SQLite,
// which rejects it. '!' is one character to both and appears in no
// callsign.
pred += " AND callsign LIKE ? ESCAPE '!'"
args = append(args, quickCallsignLike(qc))
}
if len(f.Conditions) > 0 {
joiner := " AND "
@@ -1605,6 +1659,136 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
return rows.Err()
}
// GridKey builds the lookup key for the worked-grid index.
//
// band is "" for a "mix bands" scope, which is why the index stores BOTH forms
// for every contact: asking "worked anywhere" must not mean walking the map.
func GridKey(grid, band, class string) string {
return grid + "|" + strings.ToLower(band) + "|" + class
}
// GridWorkedIndex maps every worked square to whether it is CONFIRMED, under
// every band-and-mode scope the operator can choose between.
//
// One QSO lands in several buckets, because the scopes overlap: an FT8 contact
// counts for "this exact mode", for "any FTx mode" and for "any digital mode",
// and each of those under both its own band and the any-band form. classesOf is
// supplied by the caller — the meaning of DIGI and FTx belongs upstairs with the
// rest of the mode vocabulary, not here.
//
// The value is "confirmed", not merely "present", so the same index answers both
// hunting modes: chase what has never been worked, or chase what is not yet
// confirmed. Confirmed means LoTW, a card or eQSL — the three the award engine
// counts, so a square cannot be confirmed here and unconfirmed there.
func (r *Repo) GridWorkedIndex(ctx context.Context, classesOf func(mode string) []string) (map[string]bool, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), LOWER(COALESCE(band,'')),
COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'')
FROM qso
WHERE grid IS NOT NULL AND grid != ''`)
if err != nil {
return nil, fmt.Errorf("query worked grids: %w", err)
}
defer rows.Close()
out := make(map[string]bool, 8192)
for rows.Next() {
var grid, mode, band, lotw, card, eqsl string
if err := rows.Scan(&grid, &mode, &band, &lotw, &card, &eqsl); err != nil {
return nil, err
}
g := strings.ToUpper(strings.TrimSpace(grid))
if len(g) < 4 {
continue
}
g = g[:4]
confirmed := lotw == "Y" || card == "Y" || eqsl == "Y"
for _, cls := range classesOf(mode) {
if cls == "" {
continue
}
// || is never a downgrade: one confirmed contact confirms the square
// for that scope, whatever the others say.
for _, k := range []string{GridKey(g, band, cls), GridKey(g, "", cls)} {
out[k] = out[k] || confirmed
}
}
}
return out, rows.Err()
}
// GridSquare is one 4-character Maidenhead square in the log.
type GridSquare struct {
Grid string `json:"grid"`
Count int `json:"count"`
Confirmed bool `json:"confirmed"`
// Band and Mode of the most recent contact in the square, for the tooltip.
// The square is the subject here, not the QSO, so one example is enough —
// listing every band a square was worked on turns a map into a table.
Band string `json:"band,omitempty"`
Mode string `json:"mode,omitempty"`
}
// GridSquares aggregates the log into 4-character squares, newest contact
// deciding the example band/mode.
//
// modeOf filters and is supplied by the caller (it owns what "digital" means):
// return false to drop a QSO. Aggregation to 4 characters happens HERE rather
// than in SQL — the column holds 4, 6 and 8-character grids, and lower(substr)
// in SQL would differ between SQLite and MySQL for no gain.
func (r *Repo) GridSquares(ctx context.Context, keep func(mode string) bool) ([]GridSquare, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(grid,''), UPPER(COALESCE(mode,'')), LOWER(COALESCE(band,'')),
COALESCE(lotw_rcvd,''), COALESCE(qsl_rcvd,''), COALESCE(eqsl_rcvd,'')
FROM qso
WHERE grid IS NOT NULL AND grid != ''
ORDER BY qso_date ASC, id ASC`)
if err != nil {
return nil, fmt.Errorf("query grid squares: %w", err)
}
defer rows.Close()
out := map[string]*GridSquare{}
for rows.Next() {
var grid, mode, band, lotw, card, eqsl string
if err := rows.Scan(&grid, &mode, &band, &lotw, &card, &eqsl); err != nil {
return nil, err
}
if keep != nil && !keep(mode) {
continue
}
g := strings.ToUpper(strings.TrimSpace(grid))
if len(g) < 4 {
continue
}
g = g[:4]
// Guard the shape: a malformed grid would draw a rectangle somewhere
// arbitrary, and a map with one square in the sea is a map nobody trusts.
if g[0] < 'A' || g[0] > 'R' || g[1] < 'A' || g[1] > 'R' ||
g[2] < '0' || g[2] > '9' || g[3] < '0' || g[3] > '9' {
continue
}
sq := out[g]
if sq == nil {
sq = &GridSquare{Grid: g}
out[g] = sq
}
sq.Count++
// Ascending order, so the last write wins = the most recent contact.
sq.Band, sq.Mode = band, mode
if lotw == "Y" || card == "Y" || eqsl == "Y" {
sq.Confirmed = true
}
}
if err := rows.Err(); err != nil {
return nil, err
}
list := make([]GridSquare, 0, len(out))
for _, sq := range out {
list = append(list, *sq)
}
sort.Slice(list, func(i, j int) bool { return list[i].Grid < list[j].Grid })
return list, nil
}
// BandSlotQSOs returns every contact on one band that belongs to a slot of the
// entry matrix: the exact callsign, or any callsign in the same DXCC entity.
// Mode is NOT filtered here — the class (phone / CW / digital) is a derived