Implement multi network support

This commit is contained in:
Marcus Kida 2026-02-10 17:00:04 +01:00
parent aaaf26861d
commit 873b21f4d4
6 changed files with 181 additions and 45 deletions

View file

@ -56,7 +56,7 @@ func runBMDeviceSync(db *sql.DB) {
// Only poll repeaters that haven't been polled in the last 3 days // 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 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') 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`) 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 { if err != nil {

131
db.go
View file

@ -2,6 +2,7 @@ package main
import ( import (
"database/sql" "database/sql"
"database/sql/driver"
"fmt" "fmt"
"log" "log"
"math" "math"
@ -12,6 +13,84 @@ import (
_ "github.com/jackc/pgx/v5/stdlib" _ "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 { type Repeater struct {
ID int `json:"id"` ID int `json:"id"`
Callsign string `json:"callsign"` Callsign string `json:"callsign"`
@ -28,7 +107,7 @@ type Repeater struct {
TsLinked string `json:"ts_linked"` TsLinked string `json:"ts_linked"`
Trustee string `json:"trustee"` Trustee string `json:"trustee"`
IpscNetwork string `json:"ipsc_network"` IpscNetwork string `json:"ipsc_network"`
Network string `json:"network"` Networks StringArray `json:"networks"`
Hotspot int `json:"hotspot"` Hotspot int `json:"hotspot"`
Status string `json:"status"` Status string `json:"status"`
LastSeen *time.Time `json:"last_seen"` 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, 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, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description,
import_freq_inconsistent, last_polled import_freq_inconsistent, last_polled
FROM repeaters WHERE lat BETWEEN ` + nextParam() + ` AND ` + nextParam() + ` AND lng BETWEEN ` + nextParam() + ` AND ` + nextParam() 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 // Network filter: only apply when not all 4 categories are selected
if len(networks) > 0 && len(networks) < 4 { if len(networks) > 0 && len(networks) < 4 {
var placeholders []string var networkValues []string
hasOther := false
for _, n := range networks { for _, n := range networks {
switch n { switch n {
case "BM": case "BM":
placeholders = append(placeholders, nextParam()) networkValues = append(networkValues, "Brandmeister")
args = append(args, "Brandmeister")
case "DMR+": case "DMR+":
placeholders = append(placeholders, nextParam()) networkValues = append(networkValues, "DMR+")
args = append(args, "DMR+")
case "TGIF": case "TGIF":
placeholders = append(placeholders, nextParam()) networkValues = append(networkValues, "TGIF")
args = append(args, "TGIF")
case "Other": case "Other":
placeholders = append(placeholders, nextParam(), nextParam(), nextParam(), nextParam()) hasOther = true
args = append(args, "DMR-MARC", "FreeDMR", "Other", "") networkValues = append(networkValues, "DMR-MARC", "FreeDMR", "Other")
} }
} }
if len(placeholders) > 0 { if len(networkValues) > 0 {
query += " AND network IN (" + strings.Join(placeholders, ",") + ")" 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, 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.Lat, &r.Lng, &r.City, &r.State, &r.Country,
&r.ColorCode, &r.TsLinked, &r.Trustee, &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.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware,
&r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description,
&r.ImportFreqInconsistent, &r.LastPolled); err != nil { &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 city ILIKE " + p +
" OR state ILIKE " + p + " OR state ILIKE " + p +
" OR country ILIKE " + p + " OR country ILIKE " + p +
" OR network ILIKE " + p + " OR array_to_string(networks, ',') ILIKE " + p +
" OR id::text = " + nextParam() " OR id::text = " + nextParam()
pattern := "%" + search + "%" pattern := "%" + search + "%"
args = append(args, 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 offset := (page - 1) * perPage
query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, 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, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description,
import_freq_inconsistent, last_polled import_freq_inconsistent, last_polled
FROM repeaters` + where + ` ORDER BY callsign LIMIT ` + nextParam() + ` OFFSET ` + nextParam() 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, 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.Lat, &r.Lng, &r.City, &r.State, &r.Country,
&r.ColorCode, &r.TsLinked, &r.Trustee, &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.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware,
&r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description,
&r.ImportFreqInconsistent, &r.LastPolled); err != nil { &r.ImportFreqInconsistent, &r.LastPolled); err != nil {
@ -373,7 +456,7 @@ func queryRepeaterSearch(db *sql.DB, search string, limit int) ([]Repeater, int,
limit = 25 limit = 25
} }
pattern := "%" + search + "%" 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 var total int
err := db.QueryRow("SELECT COUNT(*) FROM repeaters "+where, pattern, search).Scan(&total) 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, 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, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description,
import_freq_inconsistent, last_polled import_freq_inconsistent, last_polled
FROM repeaters `+where+` ORDER BY callsign LIMIT $3`, pattern, search, limit) 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, 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.Lat, &r.Lng, &r.City, &r.State, &r.Country,
&r.ColorCode, &r.TsLinked, &r.Trustee, &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.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware,
&r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description,
&r.ImportFreqInconsistent, &r.LastPolled); err != nil { &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) { func queryRepeaterByID(db *sql.DB, id int) (*Repeater, error) {
var r Repeater var r Repeater
err := db.QueryRow(`SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, 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, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description,
import_freq_inconsistent, last_polled import_freq_inconsistent, last_polled
FROM repeaters WHERE id = $1`, id).Scan( FROM repeaters WHERE id = $1`, id).Scan(
&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band, &r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band,
&r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country,
&r.ColorCode, &r.TsLinked, &r.Trustee, &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.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware,
&r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description,
&r.ImportFreqInconsistent, &r.LastPolled) &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, callsign=$2, freq_tx=$3, freq_rx=$4, freq_offset=$5, band=$6,
lat=$7, lng=$8, city=$9, state=$10, country=$11, lat=$7, lng=$8, city=$9, state=$10, country=$11,
color_code=$12, ts_linked=$13, trustee=$14, ipsc_network=$15, 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, hardware=$19, firmware=$20, pep=$21, agl=$22,
website=$23, description=$24 website=$23, description=$24
WHERE id=$1`, WHERE id=$1`,
r.ID, r.Callsign, r.FreqTx, r.FreqRx, r.FreqOffset, r.Band, r.ID, r.Callsign, r.FreqTx, r.FreqRx, r.FreqOffset, r.Band,
r.Lat, r.Lng, r.City, r.State, r.Country, r.Lat, r.Lng, r.City, r.State, r.Country,
r.ColorCode, r.TsLinked, r.Trustee, r.IpscNetwork, 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.Hardware, r.Firmware, r.Pep, r.Agl,
r.Website, r.Description) r.Website, r.Description)
return err return err

View file

@ -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);

42
seed.go
View file

@ -146,7 +146,7 @@ func seedDatabase(db *sql.DB, jsonPath, bmrptrsPath string) error {
stmt, err := tx.Prepare(`INSERT INTO repeaters stmt, err := tx.Prepare(`INSERT INTO repeaters
(id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, (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) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (id) DO NOTHING`) ON CONFLICT (id) DO NOTHING`)
if err != nil { if err != nil {
@ -175,18 +175,23 @@ func seedDatabase(db *sql.DB, jsonPath, bmrptrsPath string) error {
freqRx = freq + off 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 hotspot := 0
if isHotspot(r.Offset, r.City, r.MapInfo, r.Callsign, r.Country, r.Trustee) { if isHotspot(r.Offset, r.City, r.MapInfo, r.Callsign, r.Country, r.Trustee) {
hotspot = 1 hotspot = 1
hotspots++ 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 hotspot = 1
hotspots++ hotspots++
} }
if _, err := stmt.Exec(r.ID, r.Callsign, freq, freqRx, r.Offset, band, lat, lng, if _, err := stmt.Exec(r.ID, r.Callsign, freq, freqRx, r.Offset, band, lat, lng,
r.City, r.State, r.Country, r.ColorCode, 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) log.Printf("Warning: skipping repeater %d (%s): %v", r.ID, r.Callsign, err)
skipped++ skipped++
continue continue
@ -395,32 +400,45 @@ func isPersonalCallsign(callsign, country string) bool {
return false return false
} }
func classifyNetwork(raw string) string { func classifyNetworks(raw string) []string {
s := strings.ToLower(strings.TrimSpace(raw)) s := strings.ToLower(strings.TrimSpace(raw))
if s == "" || s == "none" || s == "n/a" || s == "?" || s == "-" || s == "no" { 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 ") || if strings.Contains(s, "brandm") || s == "bm" || strings.HasPrefix(s, "bm ") ||
strings.HasPrefix(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, "brand m") || strings.Contains(s, "braindm") ||
strings.Contains(s, "branme") || strings.Contains(s, "bramm") || strings.Contains(s, "branme") || strings.Contains(s, "bramm") ||
strings.Contains(s, "brewmister") || s == "bmr" { strings.Contains(s, "brewmister") || s == "bmr" {
return "Brandmeister" networks = append(networks, "Brandmeister")
} }
if strings.Contains(s, "dmr-plus") || strings.Contains(s, "dmr+") || if strings.Contains(s, "dmr-plus") || strings.Contains(s, "dmr+") ||
strings.Contains(s, "dmrplus") || s == "dmr plus" || strings.Contains(s, "dmrplus") || s == "dmr plus" ||
strings.HasPrefix(s, "ipsc2") || s == "ipsc" { strings.HasPrefix(s, "ipsc2") || s == "ipsc" {
return "DMR+" networks = append(networks, "DMR+")
} }
if strings.Contains(s, "dmr-marc") || strings.Contains(s, "dmr marc") || if strings.Contains(s, "dmr-marc") || strings.Contains(s, "dmr marc") ||
s == "marc" || strings.Contains(s, "dmrmarc") { s == "marc" || strings.Contains(s, "dmrmarc") {
return "DMR-MARC" networks = append(networks, "DMR-MARC")
} }
if strings.Contains(s, "tgif") { 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") { 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
} }

View file

@ -431,6 +431,13 @@
.save-status.success { color: var(--tag-green-text); } .save-status.success { color: var(--tag-green-text); }
.save-status.error { color: var(--danger); } .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; }
</style> </style>
</head> </head>
<body> <body>
@ -631,7 +638,7 @@
'<td>' + (r.freq_rx ? r.freq_rx.toFixed(4) : '') + '</td>' + '<td>' + (r.freq_rx ? r.freq_rx.toFixed(4) : '') + '</td>' +
'<td>' + escapeHtml(r.freq_offset) + '</td>' + '<td>' + escapeHtml(r.freq_offset) + '</td>' +
'<td>' + r.color_code + '</td>' + '<td>' + r.color_code + '</td>' +
'<td>' + (r.network ? '<span class="tag tag-gray">' + escapeHtml(r.network) + '</span>' : '') + '</td>' + '<td>' + (r.networks && r.networks.length ? r.networks.map(function(n) { return '<span class="tag tag-gray">' + escapeHtml(n) + '</span>'; }).join(' ') : '') + '</td>' +
'<td>' + escapeHtml(r.city) + '</td>' + '<td>' + escapeHtml(r.city) + '</td>' +
'<td>' + escapeHtml(r.state) + '</td>' + '<td>' + escapeHtml(r.state) + '</td>' +
'<td>' + escapeHtml(r.country) + '</td>' + '<td>' + escapeHtml(r.country) + '</td>' +
@ -681,7 +688,7 @@
{ key: "ts_linked", label: "Timeslots Linked", type: "text" }, { key: "ts_linked", label: "Timeslots Linked", type: "text" },
{ key: "trustee", label: "Trustee", type: "text" }, { key: "trustee", label: "Trustee", type: "text" },
{ key: "ipsc_network", label: "IPSC Network", 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: "hotspot", label: "Hotspot", type: "select", options: [{ v: 0, l: "No" }, { v: 1, l: "Yes" }] },
{ key: "status", label: "Status", type: "text" }, { key: "status", label: "Status", type: "text" },
{ key: "last_seen", label: "Last Seen", type: "readonly" }, { key: "last_seen", label: "Last Seen", type: "readonly" },
@ -738,6 +745,14 @@
html += '<option value="' + escapeHtml(String(optVal)) + '"' + sel + '>' + escapeHtml(String(optLabel)) + '</option>'; html += '<option value="' + escapeHtml(String(optVal)) + '"' + sel + '>' + escapeHtml(String(optLabel)) + '</option>';
} }
html += '</select>'; html += '</select>';
} else if (f.type === "multicheck") {
var currentVals = val || [];
html += '<div data-key="' + f.key + '" class="multicheck-group">';
for (var j = 0; j < f.options.length; j++) {
var checked = currentVals.indexOf(f.options[j]) !== -1 ? " checked" : "";
html += '<label class="multicheck-label"><input type="checkbox" value="' + escapeHtml(f.options[j]) + '"' + checked + '> ' + escapeHtml(f.options[j]) + '</label>';
}
html += '</div>';
} else if (f.type === "textarea") { } else if (f.type === "textarea") {
html += '<textarea data-key="' + f.key + '" rows="3">' + escapeHtml(String(val)) + '</textarea>'; html += '<textarea data-key="' + f.key + '" rows="3">' + escapeHtml(String(val)) + '</textarea>';
} else { } else {
@ -790,23 +805,30 @@
var inputs = detailBody.querySelectorAll("[data-key]"); var inputs = detailBody.querySelectorAll("[data-key]");
for (var i = 0; i < inputs.length; i++) { for (var i = 0; i < inputs.length; i++) {
var key = inputs[i].getAttribute("data-key"); var key = inputs[i].getAttribute("data-key");
var val = inputs[i].value;
var field = null; var field = null;
for (var j = 0; j < fields.length; j++) { for (var j = 0; j < fields.length; j++) {
if (fields[j].key === key) { field = fields[j]; break; } if (fields[j].key === key) { field = fields[j]; break; }
} }
if (!field) continue; 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) { if (field.step && field.step.indexOf(".") !== -1) {
data[key] = parseFloat(val) || 0; data[key] = parseFloat(val) || 0;
} else { } else {
data[key] = parseInt(val, 10) || 0; data[key] = parseInt(val, 10) || 0;
} }
} else if (field.type === "select" && typeof field.options[0] === "object" && typeof field.options[0].v === "number") { } 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 { } else {
data[key] = val; data[key] = inputs[i].value;
} }
} }
return data; return data;

View file

@ -260,10 +260,10 @@
if (r.state) loc += ", " + escapeHtml(r.state); if (r.state) loc += ", " + escapeHtml(r.state);
if (r.country) loc += ", " + escapeHtml(r.country); if (r.country) loc += ", " + escapeHtml(r.country);
html += "<tr><td>Location</td><td>" + loc + "</td></tr>"; html += "<tr><td>Location</td><td>" + loc + "</td></tr>";
if (r.network) if (r.networks && r.networks.length)
html += html +=
"<tr><td>Network</td><td>" + "<tr><td>Network</td><td>" +
escapeHtml(r.network) + r.networks.map(escapeHtml).join(", ") +
"</td></tr>"; "</td></tr>";
if (r.trustee) if (r.trustee)
html += html +=