diff --git a/compose.dev.yml b/compose.dev.yml index ed7af17..06feee0 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -9,7 +9,7 @@ services: - .:/app - go-mod-cache:/go/pkg/mod - go-build-cache:/root/.cache/go-build - - repeater-data:/app/data + - ./data:/app/data environment: - DB_PATH=/app/data/repeaters.db - JSON_PATH=/app/rptrs.json @@ -18,4 +18,3 @@ services: volumes: go-mod-cache: go-build-cache: - repeater-data: diff --git a/db.go b/db.go index 3584416..e42292e 100644 --- a/db.go +++ b/db.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "math" + "strings" _ "modernc.org/sqlite" ) @@ -23,11 +24,13 @@ CREATE TABLE IF NOT EXISTS repeaters ( ts_linked TEXT NOT NULL DEFAULT '', trustee TEXT NOT NULL DEFAULT '', ipsc_network TEXT NOT NULL DEFAULT '', + network TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'ACTIVE' ); CREATE INDEX IF NOT EXISTS idx_repeaters_lat_band ON repeaters (lat, band); CREATE INDEX IF NOT EXISTS idx_repeaters_lng ON repeaters (lng); +CREATE INDEX IF NOT EXISTS idx_repeaters_network ON repeaters (network); ` type Repeater struct { @@ -45,6 +48,7 @@ type Repeater struct { TsLinked string `json:"ts_linked"` Trustee string `json:"trustee"` IpscNetwork string `json:"ipsc_network"` + Network string `json:"network"` Status string `json:"status"` } @@ -60,22 +64,49 @@ func openDB(path string) (*sql.DB, error) { return db, nil } -func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string) ([]Repeater, error) { - baseQuery := `SELECT id, callsign, frequency, band, lat, lng, city, state, country, - color_code, offset, ts_linked, trustee, ipsc_network, status +func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string, networks []string) ([]Repeater, error) { + query := `SELECT id, callsign, frequency, band, lat, lng, city, state, country, + color_code, offset, ts_linked, trustee, ipsc_network, network, status FROM repeaters WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?` - var rows *sql.Rows - var err error + args := []interface{}{minLat, maxLat, minLng, maxLng} switch band { case "2m": - rows, err = db.Query(baseQuery+" AND band = ?", minLat, maxLat, minLng, maxLng, "2m") + query += " AND band = ?" + args = append(args, "2m") case "70cm": - rows, err = db.Query(baseQuery+" AND band = ?", minLat, maxLat, minLng, maxLng, "70cm") + query += " AND band = ?" + args = append(args, "70cm") default: - rows, err = db.Query(baseQuery+" AND band IN ('2m', '70cm')", minLat, maxLat, minLng, maxLng) + query += " AND band IN ('2m', '70cm')" } + + // Network filter: only apply when not all 4 categories are selected + if len(networks) > 0 && len(networks) < 4 { + var placeholders []string + for _, n := range networks { + switch n { + case "BM": + placeholders = append(placeholders, "?") + args = append(args, "Brandmeister") + case "DMR+": + placeholders = append(placeholders, "?") + args = append(args, "DMR+") + case "TGIF": + placeholders = append(placeholders, "?") + args = append(args, "TGIF") + case "Other": + placeholders = append(placeholders, "?", "?", "?", "?") + args = append(args, "DMR-MARC", "FreeDMR", "Other", "") + } + } + if len(placeholders) > 0 { + query += " AND network IN (" + strings.Join(placeholders, ",") + ")" + } + } + + rows, err := db.Query(query, args...) if err != nil { return nil, err } @@ -87,7 +118,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str if err := rows.Scan(&r.ID, &r.Callsign, &r.Frequency, &r.Band, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.ColorCode, &r.Offset, &r.TsLinked, &r.Trustee, - &r.IpscNetwork, &r.Status); err != nil { + &r.IpscNetwork, &r.Network, &r.Status); err != nil { return nil, err } results = append(results, r) @@ -100,7 +131,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str } // Route corridor query: find repeaters within corridorKm of a polyline. -func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string) ([]Repeater, error) { +func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string, networks []string) ([]Repeater, error) { if len(points) == 0 { return []Repeater{}, nil } @@ -131,7 +162,7 @@ func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float6 maxLng += lngPad // Fetch candidates from bounding box - candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band) + candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks) if err != nil { return nil, err } diff --git a/handlers.go b/handlers.go index 2105944..7e3929a 100644 --- a/handlers.go +++ b/handlers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" ) type apiResponse struct { @@ -16,6 +17,7 @@ type routeRequest struct { Points [][2]float64 `json:"points"` Band string `json:"band"` Corridor float64 `json:"corridor"` + Network []string `json:"network"` } func handleRepeaters(db *sql.DB) http.HandlerFunc { @@ -45,7 +47,12 @@ func handleRepeaters(db *sql.DB) http.HandlerFunc { return } - repeaters, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band) + var networks []string + if net := q.Get("network"); net != "" && net != "all" { + networks = strings.Split(net, ",") + } + + repeaters, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks) if err != nil { http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) return @@ -92,7 +99,7 @@ func handleRouteRepeaters(db *sql.DB) http.HandlerFunc { req.Corridor = 10 } - repeaters, err := queryRepeatersAlongRoute(db, req.Points, req.Corridor, req.Band) + repeaters, err := queryRepeatersAlongRoute(db, req.Points, req.Corridor, req.Band, req.Network) if err != nil { http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) return diff --git a/seed.go b/seed.go index 41992dc..e2af855 100644 --- a/seed.go +++ b/seed.go @@ -7,6 +7,7 @@ import ( "math" "os" "strconv" + "strings" ) type rawRepeater struct { @@ -41,6 +42,12 @@ func seedDatabase(dbPath, jsonPath string) error { return fmt.Errorf("create schema: %w", err) } + // Migration: add network column for existing databases + if _, err := db.Exec("ALTER TABLE repeaters ADD COLUMN network TEXT NOT NULL DEFAULT ''"); err == nil { + db.Exec("DELETE FROM repeaters") + log.Println("Added network column, re-seeding database") + } + var count int if err := db.QueryRow("SELECT COUNT(*) FROM repeaters").Scan(&count); err != nil { return fmt.Errorf("count check: %w", err) @@ -68,8 +75,8 @@ func seedDatabase(dbPath, jsonPath string) error { stmt, err := tx.Prepare(`INSERT INTO repeaters (id, callsign, frequency, band, lat, lng, city, state, country, - color_code, offset, ts_linked, trustee, ipsc_network, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + color_code, offset, ts_linked, trustee, ipsc_network, network, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { tx.Rollback() return fmt.Errorf("prepare stmt: %w", err) @@ -90,9 +97,10 @@ func seedDatabase(dbPath, jsonPath string) error { } band := classifyBand(freq) + network := classifyNetwork(r.IpscNetwork) if _, err := stmt.Exec(r.ID, r.Callsign, freq, band, lat, lng, r.City, r.State, r.Country, r.ColorCode, r.Offset, - r.TsLinked, r.Trustee, r.IpscNetwork, r.Status); err != nil { + r.TsLinked, r.Trustee, r.IpscNetwork, network, r.Status); err != nil { log.Printf("Warning: skipping repeater %d (%s): %v", r.ID, r.Callsign, err) skipped++ continue @@ -163,3 +171,33 @@ func classifyBand(freq float64) string { } return "other" } + +func classifyNetwork(raw string) string { + s := strings.ToLower(strings.TrimSpace(raw)) + if s == "" || s == "none" || s == "n/a" || s == "?" || s == "-" || s == "no" { + return "" + } + if strings.Contains(s, "brandm") || s == "bm" || strings.HasPrefix(s, "bm ") || + strings.HasPrefix(s, "bm,") || strings.HasPrefix(s, "bm/") || + strings.Contains(s, "brand m") || strings.Contains(s, "braindm") || + strings.Contains(s, "branme") || strings.Contains(s, "bramm") || + strings.Contains(s, "brewmister") || s == "bmr" { + return "Brandmeister" + } + if strings.Contains(s, "dmr-plus") || strings.Contains(s, "dmr+") || + strings.Contains(s, "dmrplus") || s == "dmr plus" || + strings.HasPrefix(s, "ipsc2") || s == "ipsc" { + return "DMR+" + } + if strings.Contains(s, "dmr-marc") || strings.Contains(s, "dmr marc") || + s == "marc" || strings.Contains(s, "dmrmarc") { + return "DMR-MARC" + } + if strings.Contains(s, "tgif") { + return "TGIF" + } + if strings.Contains(s, "freedmr") || strings.Contains(s, "free-dmr") || strings.Contains(s, "free dmr") { + return "FreeDMR" + } + return "Other" +} diff --git a/static/app.js b/static/app.js index 2e31af3..1a76348 100644 --- a/static/app.js +++ b/static/app.js @@ -41,6 +41,10 @@ // === DOM === var band2m = document.getElementById("band-2m"); var band70cm = document.getElementById("band-70cm"); + var netBm = document.getElementById("net-bm"); + var netDmrplus = document.getElementById("net-dmrplus"); + var netTgif = document.getElementById("net-tgif"); + var netOther = document.getElementById("net-other"); var countEl = document.getElementById("count"); var fromInput = document.getElementById("route-from"); var toInput = document.getElementById("route-to"); @@ -200,6 +204,17 @@ return "all"; } + function getSelectedNetworks() { + var nets = []; + if (netBm.checked) nets.push("BM"); + if (netDmrplus.checked) nets.push("DMR+"); + if (netTgif.checked) nets.push("TGIF"); + if (netOther.checked) nets.push("Other"); + if (nets.length === 4) return "all"; + if (nets.length === 0) return "none"; + return nets.join(","); + } + function escapeHtml(str) { if (!str) return ""; return str @@ -241,10 +256,10 @@ if (r.state) loc += ", " + escapeHtml(r.state); if (r.country) loc += ", " + escapeHtml(r.country); html += "