This commit is contained in:
2026-06-05 17:22:38 +02:00
parent cf9dbf26f3
commit 88623f55df
21 changed files with 2123 additions and 50 deletions
+243
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"time"
)
@@ -582,6 +583,248 @@ func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
return out, rows.Err()
}
// ── Advanced filter builder ──────────────────────────────────────────────
//
// QueryFilter powers the UI's filter builder: a list of field/operator/value
// conditions joined by AND or OR, plus an always-ANDed quick callsign search.
// Every field is validated against filterableColumns so user input can never
// reach the SQL string — only parameterised values do.
// Condition is one "field OP value" clause.
type Condition struct {
Field string `json:"field"` // db column name (validated against whitelist)
Op string `json:"op"` // eq|ne|gt|lt|ge|le|contains|startswith|endswith|empty|notempty
Value string `json:"value"`
}
// QueryFilter is a full filter expression.
type QueryFilter struct {
QuickCallsign string `json:"quick_callsign,omitempty"` // always-ANDed contains-match
Conditions []Condition `json:"conditions,omitempty"`
Match string `json:"match,omitempty"` // "AND" (default) | "OR"
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
// filterableColumns whitelists the columns the filter builder may reference.
// Keep field names identical to DB columns so the frontend can send them
// directly; anything not in this set is rejected.
var filterableColumns = map[string]bool{
"callsign": true, "qso_date": true, "qso_date_off": true, "band": true, "band_rx": true,
"mode": true, "submode": true, "freq_hz": true, "freq_rx_hz": true,
"rst_sent": true, "rst_rcvd": true,
"name": true, "qth": true, "address": true, "email": true,
"grid": true, "country": true, "state": true, "cnty": true,
"dxcc": true, "cont": true, "cqz": true, "ituz": true,
"iota": true, "sota_ref": true, "pota_ref": true, "rig": true, "ant": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true,
"contest_id": true, "srx": true, "stx": true,
"prop_mode": true, "sat_name": true,
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
"tx_pwr": true, "comment": true, "notes": true,
}
// filterableExtras whitelists virtual filter fields stored inside extras_json
// (valid ADIF fields we don't promote to columns). The value is the uppercase
// ADIF/Extras key; the SQL expression uses json_extract.
var filterableExtras = map[string]string{
"owner_callsign": "OWNER_CALLSIGN",
}
// FilterableFields returns the whitelist (for the frontend to build its field
// dropdown and stay in sync with the backend).
func FilterableFields() []string {
out := make([]string, 0, len(filterableColumns)+len(filterableExtras))
for c := range filterableColumns {
out = append(out, c)
}
for c := range filterableExtras {
out = append(out, c)
}
sort.Strings(out)
return out
}
// columnExpr resolves a filter field to a safe SQL expression — either a
// whitelisted column name or a json_extract over extras_json.
func columnExpr(field string) (string, bool) {
f := strings.ToLower(strings.TrimSpace(field))
if filterableColumns[f] {
return f, true
}
if key, ok := filterableExtras[f]; ok {
return "json_extract(extras_json, '$." + key + "')", true
}
return "", false
}
// conditionSQL turns one condition into a parameterised predicate.
func conditionSQL(c Condition) (string, []any, error) {
col, ok := columnExpr(c.Field)
if !ok {
return "", nil, fmt.Errorf("unknown filter field %q", c.Field)
}
v := c.Value
switch c.Op {
case "eq":
return col + " = ?", []any{v}, nil
case "ne":
return col + " <> ?", []any{v}, nil
case "gt":
return col + " > ?", []any{v}, nil
case "lt":
return col + " < ?", []any{v}, nil
case "ge":
return col + " >= ?", []any{v}, nil
case "le":
return col + " <= ?", []any{v}, nil
case "contains":
return col + " LIKE ?", []any{"%" + v + "%"}, nil
case "startswith":
return col + " LIKE ?", []any{v + "%"}, nil
case "endswith":
return col + " LIKE ?", []any{"%" + v}, nil
case "empty":
return "IFNULL(" + col + ",'') = ''", nil, nil
case "notempty":
return "IFNULL(" + col + ",'') <> ''", nil, nil
default:
return "", nil, fmt.Errorf("unknown operator %q", c.Op)
}
}
// 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+"%")
}
if len(f.Conditions) > 0 {
joiner := " AND "
if strings.EqualFold(strings.TrimSpace(f.Match), "OR") {
joiner = " OR "
}
parts := make([]string, 0, len(f.Conditions))
for _, c := range f.Conditions {
if strings.TrimSpace(c.Field) == "" {
continue
}
p, a, err := conditionSQL(c)
if err != nil {
return "", nil, err
}
parts = append(parts, p)
args = append(args, a...)
}
if len(parts) > 0 {
pred += " AND (" + strings.Join(parts, joiner) + ")"
}
}
return pred, args, nil
}
// ListFiltered returns QSOs matching a QueryFilter, newest first, limited.
func (r *Repo) ListFiltered(ctx context.Context, f QueryFilter) ([]QSO, error) {
pred, args, err := buildWhere(f)
if err != nil {
return nil, err
}
q := `SELECT ` + selectCols + ` FROM qso WHERE ` + pred + ` ORDER BY qso_date DESC, id DESC`
limit := f.Limit
if limit <= 0 {
limit = 500
}
if limit > 1_000_000 {
limit = 1_000_000
}
q += " LIMIT ? OFFSET ?"
args = append(args, limit, f.Offset)
rows, err := r.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("query qso: %w", err)
}
defer rows.Close()
out := make([]QSO, 0, 64)
for rows.Next() {
qrow, err := scanQSO(rows)
if err != nil {
return nil, err
}
out = append(out, qrow)
}
return out, rows.Err()
}
// CountFiltered returns how many QSOs match a filter (ignoring limit/offset).
func (r *Repo) CountFiltered(ctx context.Context, f QueryFilter) (int64, error) {
pred, args, err := buildWhere(f)
if err != nil {
return 0, err
}
var n int64
err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM qso WHERE `+pred, args...).Scan(&n)
return n, err
}
// IterateFiltered streams all QSOs matching a filter (no limit), chronological,
// for an ADIF export of "the current filtered view, no row limit".
func (r *Repo) IterateFiltered(ctx context.Context, f QueryFilter, fn func(QSO) error) error {
pred, args, err := buildWhere(f)
if err != nil {
return err
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE `+pred+` ORDER BY qso_date ASC, id ASC`, args...)
if err != nil {
return fmt.Errorf("query qso: %w", err)
}
defer rows.Close()
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return err
}
if err := fn(q); err != nil {
return err
}
}
return rows.Err()
}
// IterateByIDs streams the QSOs with the given ids, chronological — for
// "export the rows I selected with the mouse".
func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error) error {
if len(ids) == 0 {
return nil
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`) ORDER BY qso_date ASC, id ASC`, args...)
if err != nil {
return fmt.Errorf("query qso: %w", err)
}
defer rows.Close()
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return err
}
if err := fn(q); err != nil {
return err
}
}
return rows.Err()
}
// WorkedBefore summarises prior contacts at two granularities:
// - by exact callsign → shown as "this call worked N×"
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT