chore: release v0.26.0
This commit is contained in:
+186
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package qso
|
||||
|
||||
import "testing"
|
||||
|
||||
// The search box is the one place an operator types a callsign fragment, and
|
||||
// the three shapes below are what they expect from every other logger. A plain
|
||||
// word is a PREFIX: it used to be an unconditional contains-match, so asking
|
||||
// for "calls starting with 4S" was impossible.
|
||||
func TestQuickCallsignLike(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want, why string }{
|
||||
{"4S", "4S%", "plain text is a prefix"},
|
||||
{"*4S", "%4S", "a leading star means ends-with"},
|
||||
{"*4S*", "%4S%", "stars both ends mean contains"},
|
||||
{"4S*", "4S%", "a trailing star is the same prefix, written out"},
|
||||
{"4S?", "4S_", "? is exactly one character"},
|
||||
{"*", "%", "a lone star matches everything"},
|
||||
{" 4S ", "4S%", "surrounding space is not part of the call"},
|
||||
// Escaping: a LIKE metacharacter the operator typed is a literal.
|
||||
{"A_B", "A!_B%", "_ typed by hand is a literal underscore"},
|
||||
{"50%", "50!%%", "% typed by hand is a literal percent"},
|
||||
{"A!B", "A!!B%", "the escape character escapes itself"},
|
||||
{"*A_B*", "%A!_B%", "escaping still applies inside a wildcard pattern"},
|
||||
} {
|
||||
if got := quickCallsignLike(tc.in); got != tc.want {
|
||||
t.Errorf("quickCallsignLike(%q) = %q, want %q — %s", tc.in, got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user