Implement multi network support
This commit is contained in:
parent
aaaf26861d
commit
873b21f4d4
6 changed files with 181 additions and 45 deletions
|
|
@ -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
131
db.go
|
|
@ -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
|
||||||
|
|
|
||||||
13
migrations/003_networks_array.sql
Normal file
13
migrations/003_networks_array.sql
Normal 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
42
seed.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 +=
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue