From 873b21f4d48b5d68122d778d1a0ac51d1564b690 Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Tue, 10 Feb 2026 17:00:04 +0100 Subject: [PATCH] Implement multi network support --- bmdevice.go | 2 +- db.go | 131 ++++++++++++++++++++++++------ migrations/003_networks_array.sql | 13 +++ seed.go | 42 +++++++--- static/admin.html | 34 ++++++-- static/app.js | 4 +- 6 files changed, 181 insertions(+), 45 deletions(-) create mode 100644 migrations/003_networks_array.sql diff --git a/bmdevice.go b/bmdevice.go index e192210..1f524c5 100644 --- a/bmdevice.go +++ b/bmdevice.go @@ -56,7 +56,7 @@ func runBMDeviceSync(db *sql.DB) { // Only poll repeaters that haven't been polled in the last 3 days rows, err := db.Query(`SELECT id, callsign, freq_tx, freq_rx FROM repeaters - WHERE network = 'Brandmeister' AND hotspot = 0 + WHERE networks @> ARRAY['Brandmeister'] AND hotspot = 0 AND (last_polled IS NULL OR last_polled < NOW() - INTERVAL '3 days') ORDER BY CASE WHEN LEFT(id::text, 3) = '262' THEN 0 WHEN LEFT(id::text, 1) = '2' THEN 1 ELSE 2 END, callsign`) if err != nil { diff --git a/db.go b/db.go index b6e364a..817c3b4 100644 --- a/db.go +++ b/db.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "database/sql/driver" "fmt" "log" "math" @@ -12,6 +13,84 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" ) +// StringArray maps a PostgreSQL TEXT[] column to a Go []string via database/sql. +type StringArray []string + +func (a *StringArray) Scan(src interface{}) error { + if src == nil { + *a = StringArray{} + return nil + } + switch v := src.(type) { + case []byte: + parsed, err := parsePostgresArray(string(v)) + if err != nil { + return err + } + *a = parsed + case string: + parsed, err := parsePostgresArray(v) + if err != nil { + return err + } + *a = parsed + default: + return fmt.Errorf("StringArray.Scan: unsupported type %T", src) + } + return nil +} + +func (a StringArray) Value() (driver.Value, error) { + if a == nil || len(a) == 0 { + return "{}", nil + } + elems := make([]string, len(a)) + for i, s := range a { + escaped := strings.ReplaceAll(s, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + elems[i] = `"` + escaped + `"` + } + return "{" + strings.Join(elems, ",") + "}", nil +} + +func parsePostgresArray(s string) ([]string, error) { + s = strings.TrimSpace(s) + if s == "{}" || s == "" { + return []string{}, nil + } + if s[0] != '{' || s[len(s)-1] != '}' { + return nil, fmt.Errorf("invalid postgres array literal: %q", s) + } + inner := s[1 : len(s)-1] + var result []string + var current strings.Builder + inQuote := false + escaped := false + for _, r := range inner { + if escaped { + current.WriteRune(r) + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + if r == '"' { + inQuote = !inQuote + continue + } + if r == ',' && !inQuote { + result = append(result, current.String()) + current.Reset() + continue + } + current.WriteRune(r) + } + result = append(result, current.String()) + return result, nil +} + type Repeater struct { ID int `json:"id"` Callsign string `json:"callsign"` @@ -28,7 +107,7 @@ type Repeater struct { TsLinked string `json:"ts_linked"` Trustee string `json:"trustee"` IpscNetwork string `json:"ipsc_network"` - Network string `json:"network"` + Networks StringArray `json:"networks"` Hotspot int `json:"hotspot"` Status string `json:"status"` LastSeen *time.Time `json:"last_seen"` @@ -82,7 +161,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str } query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, - color_code, ts_linked, trustee, ipsc_network, network, hotspot, status, + color_code, ts_linked, trustee, ipsc_network, networks, hotspot, status, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, import_freq_inconsistent, last_polled FROM repeaters WHERE lat BETWEEN ` + nextParam() + ` AND ` + nextParam() + ` AND lng BETWEEN ` + nextParam() + ` AND ` + nextParam() @@ -106,25 +185,29 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str // Network filter: only apply when not all 4 categories are selected if len(networks) > 0 && len(networks) < 4 { - var placeholders []string + var networkValues []string + hasOther := false for _, n := range networks { switch n { case "BM": - placeholders = append(placeholders, nextParam()) - args = append(args, "Brandmeister") + networkValues = append(networkValues, "Brandmeister") case "DMR+": - placeholders = append(placeholders, nextParam()) - args = append(args, "DMR+") + networkValues = append(networkValues, "DMR+") case "TGIF": - placeholders = append(placeholders, nextParam()) - args = append(args, "TGIF") + networkValues = append(networkValues, "TGIF") case "Other": - placeholders = append(placeholders, nextParam(), nextParam(), nextParam(), nextParam()) - args = append(args, "DMR-MARC", "FreeDMR", "Other", "") + hasOther = true + networkValues = append(networkValues, "DMR-MARC", "FreeDMR", "Other") } } - if len(placeholders) > 0 { - query += " AND network IN (" + strings.Join(placeholders, ",") + ")" + if len(networkValues) > 0 { + p := nextParam() + if hasOther { + query += " AND (networks && " + p + "::text[] OR networks = '{}')" + } else { + query += " AND networks && " + p + "::text[]" + } + args = append(args, StringArray(networkValues)) } } @@ -147,7 +230,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str if err := rows.Scan(&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.ColorCode, &r.TsLinked, &r.Trustee, - &r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status, + &r.IpscNetwork, &r.Networks, &r.Hotspot, &r.Status, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.ImportFreqInconsistent, &r.LastPolled); err != nil { @@ -268,7 +351,7 @@ func queryAdminRepeaters(db *sql.DB, search string, page, perPage int) ([]Repeat " OR city ILIKE " + p + " OR state ILIKE " + p + " OR country ILIKE " + p + - " OR network ILIKE " + p + + " OR array_to_string(networks, ',') ILIKE " + p + " OR id::text = " + nextParam() pattern := "%" + search + "%" args = append(args, pattern, search) @@ -282,7 +365,7 @@ func queryAdminRepeaters(db *sql.DB, search string, page, perPage int) ([]Repeat offset := (page - 1) * perPage query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, - color_code, ts_linked, trustee, ipsc_network, network, hotspot, status, + color_code, ts_linked, trustee, ipsc_network, networks, hotspot, status, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, import_freq_inconsistent, last_polled FROM repeaters` + where + ` ORDER BY callsign LIMIT ` + nextParam() + ` OFFSET ` + nextParam() @@ -301,7 +384,7 @@ func queryAdminRepeaters(db *sql.DB, search string, page, perPage int) ([]Repeat if err := rows.Scan(&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.ColorCode, &r.TsLinked, &r.Trustee, - &r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status, + &r.IpscNetwork, &r.Networks, &r.Hotspot, &r.Status, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.ImportFreqInconsistent, &r.LastPolled); err != nil { @@ -373,7 +456,7 @@ func queryRepeaterSearch(db *sql.DB, search string, limit int) ([]Repeater, int, limit = 25 } pattern := "%" + search + "%" - where := `WHERE callsign ILIKE $1 OR city ILIKE $1 OR state ILIKE $1 OR country ILIKE $1 OR network ILIKE $1 OR id::text = $2` + where := `WHERE callsign ILIKE $1 OR city ILIKE $1 OR state ILIKE $1 OR country ILIKE $1 OR array_to_string(networks, ',') ILIKE $1 OR id::text = $2` var total int err := db.QueryRow("SELECT COUNT(*) FROM repeaters "+where, pattern, search).Scan(&total) @@ -382,7 +465,7 @@ func queryRepeaterSearch(db *sql.DB, search string, limit int) ([]Repeater, int, } rows, err := db.Query(`SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, - color_code, ts_linked, trustee, ipsc_network, network, hotspot, status, + color_code, ts_linked, trustee, ipsc_network, networks, hotspot, status, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, import_freq_inconsistent, last_polled FROM repeaters `+where+` ORDER BY callsign LIMIT $3`, pattern, search, limit) @@ -398,7 +481,7 @@ func queryRepeaterSearch(db *sql.DB, search string, limit int) ([]Repeater, int, if err := rows.Scan(&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.ColorCode, &r.TsLinked, &r.Trustee, - &r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status, + &r.IpscNetwork, &r.Networks, &r.Hotspot, &r.Status, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.ImportFreqInconsistent, &r.LastPolled); err != nil { @@ -416,14 +499,14 @@ func queryRepeaterSearch(db *sql.DB, search string, limit int) ([]Repeater, int, func queryRepeaterByID(db *sql.DB, id int) (*Repeater, error) { var r Repeater err := db.QueryRow(`SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, - color_code, ts_linked, trustee, ipsc_network, network, hotspot, status, + color_code, ts_linked, trustee, ipsc_network, networks, hotspot, status, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, import_freq_inconsistent, last_polled FROM repeaters WHERE id = $1`, id).Scan( &r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.ColorCode, &r.TsLinked, &r.Trustee, - &r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status, + &r.IpscNetwork, &r.Networks, &r.Hotspot, &r.Status, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.ImportFreqInconsistent, &r.LastPolled) @@ -440,14 +523,14 @@ func updateRepeater(db *sql.DB, r Repeater) error { callsign=$2, freq_tx=$3, freq_rx=$4, freq_offset=$5, band=$6, lat=$7, lng=$8, city=$9, state=$10, country=$11, color_code=$12, ts_linked=$13, trustee=$14, ipsc_network=$15, - network=$16, hotspot=$17, status=$18, + networks=$16, hotspot=$17, status=$18, hardware=$19, firmware=$20, pep=$21, agl=$22, website=$23, description=$24 WHERE id=$1`, r.ID, r.Callsign, r.FreqTx, r.FreqRx, r.FreqOffset, r.Band, r.Lat, r.Lng, r.City, r.State, r.Country, r.ColorCode, r.TsLinked, r.Trustee, r.IpscNetwork, - r.Network, r.Hotspot, r.Status, + r.Networks, r.Hotspot, r.Status, r.Hardware, r.Firmware, r.Pep, r.Agl, r.Website, r.Description) return err diff --git a/migrations/003_networks_array.sql b/migrations/003_networks_array.sql new file mode 100644 index 0000000..74cecad --- /dev/null +++ b/migrations/003_networks_array.sql @@ -0,0 +1,13 @@ +-- +goose Up +ALTER TABLE repeaters ADD COLUMN networks TEXT[] NOT NULL DEFAULT '{}'; +UPDATE repeaters SET networks = ARRAY[network] WHERE network != ''; +DROP INDEX IF EXISTS idx_repeaters_network; +ALTER TABLE repeaters DROP COLUMN network; +CREATE INDEX idx_repeaters_networks ON repeaters USING GIN (networks); + +-- +goose Down +ALTER TABLE repeaters ADD COLUMN network TEXT NOT NULL DEFAULT ''; +UPDATE repeaters SET network = networks[1] WHERE array_length(networks, 1) > 0; +DROP INDEX IF EXISTS idx_repeaters_networks; +ALTER TABLE repeaters DROP COLUMN networks; +CREATE INDEX idx_repeaters_network ON repeaters (network); diff --git a/seed.go b/seed.go index 005f16c..f400c7b 100644 --- a/seed.go +++ b/seed.go @@ -146,7 +146,7 @@ func seedDatabase(db *sql.DB, jsonPath, bmrptrsPath string) error { stmt, err := tx.Prepare(`INSERT INTO repeaters (id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, - color_code, ts_linked, trustee, ipsc_network, network, hotspot, status) + color_code, ts_linked, trustee, ipsc_network, networks, hotspot, status) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) ON CONFLICT (id) DO NOTHING`) if err != nil { @@ -175,18 +175,23 @@ func seedDatabase(db *sql.DB, jsonPath, bmrptrsPath string) error { freqRx = freq + off } - network := classifyNetwork(r.IpscNetwork) + networks := classifyNetworks(r.IpscNetwork) + callsignUpper := strings.ToUpper(strings.TrimSpace(r.Callsign)) + // If found in BrandMeister repeater list, ensure Brandmeister is in networks + if len(bmSet) > 0 && bmSet[callsignUpper] && !containsNetwork(networks, "Brandmeister") { + networks = append(networks, "Brandmeister") + } hotspot := 0 if isHotspot(r.Offset, r.City, r.MapInfo, r.Callsign, r.Country, r.Trustee) { hotspot = 1 hotspots++ - } else if network == "Brandmeister" && len(bmSet) > 0 && !bmSet[strings.ToUpper(strings.TrimSpace(r.Callsign))] { + } else if containsNetwork(networks, "Brandmeister") && len(bmSet) > 0 && !bmSet[callsignUpper] { hotspot = 1 hotspots++ } if _, err := stmt.Exec(r.ID, r.Callsign, freq, freqRx, r.Offset, band, lat, lng, r.City, r.State, r.Country, r.ColorCode, - r.TsLinked, r.Trustee, r.IpscNetwork, network, hotspot, r.Status); err != nil { + r.TsLinked, r.Trustee, r.IpscNetwork, StringArray(networks), hotspot, r.Status); err != nil { log.Printf("Warning: skipping repeater %d (%s): %v", r.ID, r.Callsign, err) skipped++ continue @@ -395,32 +400,45 @@ func isPersonalCallsign(callsign, country string) bool { return false } -func classifyNetwork(raw string) string { +func classifyNetworks(raw string) []string { s := strings.ToLower(strings.TrimSpace(raw)) if s == "" || s == "none" || s == "n/a" || s == "?" || s == "-" || s == "no" { - return "" + return []string{} } + var networks []string 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" + networks = append(networks, "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+" + networks = append(networks, "DMR+") } if strings.Contains(s, "dmr-marc") || strings.Contains(s, "dmr marc") || s == "marc" || strings.Contains(s, "dmrmarc") { - return "DMR-MARC" + networks = append(networks, "DMR-MARC") } if strings.Contains(s, "tgif") { - return "TGIF" + networks = append(networks, "TGIF") } if strings.Contains(s, "freedmr") || strings.Contains(s, "free-dmr") || strings.Contains(s, "free dmr") { - return "FreeDMR" + networks = append(networks, "FreeDMR") } - return "Other" + if len(networks) == 0 { + return []string{"Other"} + } + return networks +} + +func containsNetwork(networks []string, name string) bool { + for _, n := range networks { + if n == name { + return true + } + } + return false } diff --git a/static/admin.html b/static/admin.html index 0f48d8b..588eac0 100644 --- a/static/admin.html +++ b/static/admin.html @@ -431,6 +431,13 @@ .save-status.success { color: var(--tag-green-text); } .save-status.error { color: var(--danger); } + + .multicheck-group { display: flex; flex-wrap: wrap; gap: 8px; } + .multicheck-label { + display: flex; align-items: center; gap: 4px; + font-size: 13px; cursor: pointer; color: var(--text); + } + .multicheck-label input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; } @@ -631,7 +638,7 @@ '' + (r.freq_rx ? r.freq_rx.toFixed(4) : '') + '' + '' + escapeHtml(r.freq_offset) + '' + '' + r.color_code + '' + - '' + (r.network ? '' + escapeHtml(r.network) + '' : '') + '' + + '' + (r.networks && r.networks.length ? r.networks.map(function(n) { return '' + escapeHtml(n) + ''; }).join(' ') : '') + '' + '' + escapeHtml(r.city) + '' + '' + escapeHtml(r.state) + '' + '' + escapeHtml(r.country) + '' + @@ -681,7 +688,7 @@ { key: "ts_linked", label: "Timeslots Linked", type: "text" }, { key: "trustee", label: "Trustee", type: "text" }, { key: "ipsc_network", label: "IPSC Network", type: "text" }, - { key: "network", label: "Network", type: "select", options: [{ v: "", l: "(none)" }, "Brandmeister", "DMR+", "DMR-MARC", "TGIF", "FreeDMR", "Other"] }, + { key: "networks", label: "Networks", type: "multicheck", options: ["Brandmeister", "DMR+", "DMR-MARC", "TGIF", "FreeDMR", "Other"] }, { key: "hotspot", label: "Hotspot", type: "select", options: [{ v: 0, l: "No" }, { v: 1, l: "Yes" }] }, { key: "status", label: "Status", type: "text" }, { key: "last_seen", label: "Last Seen", type: "readonly" }, @@ -738,6 +745,14 @@ html += ''; } html += ''; + } else if (f.type === "multicheck") { + var currentVals = val || []; + html += '
'; + for (var j = 0; j < f.options.length; j++) { + var checked = currentVals.indexOf(f.options[j]) !== -1 ? " checked" : ""; + html += ''; + } + html += '
'; } else if (f.type === "textarea") { html += ''; } else { @@ -790,23 +805,30 @@ var inputs = detailBody.querySelectorAll("[data-key]"); for (var i = 0; i < inputs.length; i++) { var key = inputs[i].getAttribute("data-key"); - var val = inputs[i].value; var field = null; for (var j = 0; j < fields.length; j++) { if (fields[j].key === key) { field = fields[j]; break; } } if (!field) continue; - if (field.type === "number") { + if (field.type === "multicheck") { + var checkboxes = inputs[i].querySelectorAll('input[type="checkbox"]'); + var selected = []; + for (var k = 0; k < checkboxes.length; k++) { + if (checkboxes[k].checked) selected.push(checkboxes[k].value); + } + data[key] = selected; + } else if (field.type === "number") { + var val = inputs[i].value; if (field.step && field.step.indexOf(".") !== -1) { data[key] = parseFloat(val) || 0; } else { data[key] = parseInt(val, 10) || 0; } } else if (field.type === "select" && typeof field.options[0] === "object" && typeof field.options[0].v === "number") { - data[key] = parseInt(val, 10) || 0; + data[key] = parseInt(inputs[i].value, 10) || 0; } else { - data[key] = val; + data[key] = inputs[i].value; } } return data; diff --git a/static/app.js b/static/app.js index 7cc4926..e75f8eb 100644 --- a/static/app.js +++ b/static/app.js @@ -260,10 +260,10 @@ if (r.state) loc += ", " + escapeHtml(r.state); if (r.country) loc += ", " + escapeHtml(r.country); html += "Location" + loc + ""; - if (r.network) + if (r.networks && r.networks.length) html += "Network" + - escapeHtml(r.network) + + r.networks.map(escapeHtml).join(", ") + ""; if (r.trustee) html +=