"use client"; import { useRef, useState } from "react"; import { useSystems } from "@/lib/useSystems"; import { c2api } from "@/lib/c2api"; import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types"; // ── P25 structured config types ─────────────────────────────────────────────── interface TalkgroupEntry { id: string; name: string; tag: string; } interface P25Config { nac: string; system_id: string; wacn: string; control_channels: string; voice_channels: string; talkgroups: TalkgroupEntry[]; } const DEFAULT_P25: P25Config = { nac: "", system_id: "", wacn: "", control_channels: "", voice_channels: "", talkgroups: [], }; const TG_TAGS = ["fire", "police", "ems", "transit", "public works", "other"]; function recordToP25Config(c: Record): P25Config { return { nac: String(c.nac ?? ""), system_id: String(c.system_id ?? ""), wacn: String(c.wacn ?? ""), control_channels: Array.isArray(c.control_channels) ? (c.control_channels as number[]).join(", ") : "", voice_channels: Array.isArray(c.voice_channels) ? (c.voice_channels as number[]).join(", ") : "", talkgroups: Array.isArray(c.talkgroups) ? (c.talkgroups as Array<{ id: number; name: string; tag: string }>).map((tg) => ({ id: String(tg.id), name: tg.name, tag: tg.tag ?? "other", })) : [], }; } function p25ConfigToRecord(p: P25Config): Record { const parseFreqs = (s: string) => s .split(",") .map((f) => parseFloat(f.trim())) .filter((f) => !isNaN(f)); return { nac: p.nac, system_id: p.system_id ? parseInt(p.system_id, 10) : undefined, wacn: p.wacn, control_channels: parseFreqs(p.control_channels), voice_channels: parseFreqs(p.voice_channels), talkgroups: p.talkgroups .filter((tg) => tg.id && tg.name) .map((tg) => ({ id: parseInt(tg.id, 10), name: tg.name, tag: tg.tag })), }; } // ── RadioReference parser types ─────────────────────────────────────────────── interface RRTalkgroup { dec: number; alphaTag: string; description: string; tag: string; } interface RRCategory { name: string; talkgroups: RRTalkgroup[]; } interface RRSystem { name: string; location: string; sysIds: string; systemType: string; categories: RRCategory[]; } function mapRRTag(rrTag: string): string { const t = rrTag.toLowerCase(); if (t.includes("fire")) return "fire"; if (t.includes("law") || t.includes("police")) return "police"; if (t.includes("ems") || t.includes("emergency medical")) return "ems"; if (t.includes("transport") || t.includes("transit")) return "transit"; if (t.includes("public works")) return "public works"; return "other"; } function parseRadioReference(html: string): RRSystem | null { const doc = new DOMParser().parseFromString(html, "text/html"); // Validate: RadioReference system pages have rrlblue header cells if (!doc.querySelector(".rrlblue")) return null; // System info table (first table with rrlblue headers) const infoMap: Record = {}; const infoTable = doc.querySelector("table.table-sm.table-bordered"); if (infoTable) { infoTable.querySelectorAll("tr").forEach((row) => { const th = row.querySelector("th.rrlblue"); const td = row.querySelector("td"); if (th && td) infoMap[th.textContent?.trim() ?? ""] = td.textContent?.trim() ?? ""; }); } const name = infoMap["System Name"] ?? doc.title ?? "Unknown System"; const location = infoMap["Location"] ?? ""; const sysIds = infoMap["System IDs"] ?? ""; const systemType = infoMap["System Type"] ?? ""; // Talkgroup tables — find all with class rrdbTable or datatable-lite // For each, find the nearest preceding h5 to use as category name const tgTables = Array.from( doc.querySelectorAll("table.rrdbTable, table.datatable-lite") ) as HTMLTableElement[]; const allH5s = Array.from(doc.querySelectorAll("h5")) as HTMLElement[]; function categoryForTable(table: HTMLTableElement): string { // Find the last h5 that appears before this table in document order let best: HTMLElement | null = null; for (const h5 of allH5s) { const pos = h5.compareDocumentPosition(table); if (pos & Node.DOCUMENT_POSITION_FOLLOWING) best = h5; } if (!best) return "Uncategorized"; const clone = best.cloneNode(true) as HTMLElement; clone.querySelectorAll("div, button, span.badge").forEach((el) => el.remove()); return clone.textContent?.trim() || "Uncategorized"; } const categories: RRCategory[] = []; for (const table of tgTables) { // Confirm it has the expected talkgroup columns (DEC, HEX, Mode, Alpha Tag, …) const headers = Array.from(table.querySelectorAll("thead th")).map((th) => th.textContent?.trim().toLowerCase() ); if (!headers.includes("dec") && !headers.includes("hex")) continue; const catName = categoryForTable(table); const talkgroups: RRTalkgroup[] = []; table.querySelectorAll("tbody tr").forEach((row) => { const cells = Array.from(row.querySelectorAll("td")); if (cells.length < 6) return; // DEC cell may wrap in a Broadcastify link const decText = cells[0].querySelector("a")?.textContent?.trim() ?? cells[0].textContent?.trim() ?? ""; const dec = parseInt(decText.replace(/\D/g, ""), 10); if (isNaN(dec)) return; const alphaTag = cells[3].textContent?.trim() ?? ""; const description = cells[4].textContent?.trim() ?? ""; const tag = cells[5].textContent?.trim() ?? ""; talkgroups.push({ dec, alphaTag, description, tag }); }); if (talkgroups.length > 0) { // Merge into an existing category with same name if present const existing = categories.find((c) => c.name === catName); if (existing) { existing.talkgroups.push(...talkgroups); } else { categories.push({ name: catName, talkgroups }); } } } if (categories.length === 0) return null; return { name, location, sysIds, systemType, categories }; } // ── RadioReference import modal ─────────────────────────────────────────────── function RRImportModal({ system, onImport, onCancel, }: { system: RRSystem; onImport: (tgs: TalkgroupEntry[]) => void; onCancel: () => void; }) { const [selected, setSelected] = useState>( () => new Set(system.categories.map((c) => c.name)) ); function toggle(name: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); } function handleImport() { const tgs: TalkgroupEntry[] = []; for (const cat of system.categories) { if (!selected.has(cat.name)) continue; for (const tg of cat.talkgroups) { tgs.push({ id: String(tg.dec), name: `${cat.name.split(" - ")[0]} - ${tg.description || tg.alphaTag}`, tag: mapRRTag(tg.tag), }); } } onImport(tgs); } const total = system.categories.reduce((s, c) => s + c.talkgroups.length, 0); const selectedCount = system.categories .filter((c) => selected.has(c.name)) .reduce((s, c) => s + c.talkgroups.length, 0); return (
{/* Header */}

{system.name}

{system.systemType}{system.location ? ` · ${system.location}` : ""}

{system.sysIds && (

System IDs: {system.sysIds}

)}

{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups

{/* Category list */}

Talkgroup Categories

{system.categories.map((cat) => ( ))}
{/* Footer */}
); } // ── Talkgroup table editor ──────────────────────────────────────────────────── function TalkgroupEditor({ talkgroups, onChange, }: { talkgroups: TalkgroupEntry[]; onChange: (tgs: TalkgroupEntry[]) => void; }) { const [showPaste, setShowPaste] = useState(false); const [pasteText, setPasteText] = useState(""); const [rrSystem, setRrSystem] = useState(null); const [rrError, setRrError] = useState(null); const rrInputRef = useRef(null); function addRow() { onChange([...talkgroups, { id: "", name: "", tag: "other" }]); } function removeRow(i: number) { onChange(talkgroups.filter((_, idx) => idx !== i)); } function updateRow(i: number, field: keyof TalkgroupEntry, value: string) { const updated = [...talkgroups]; updated[i] = { ...updated[i], [field]: value }; onChange(updated); } function handlePasteImport() { const lines = pasteText.trim().split("\n"); const parsed: TalkgroupEntry[] = lines .map((line) => { const parts = line.includes("\t") ? line.split("\t") : line.split(","); const id = parts[0]?.trim() ?? ""; const name = parts[1]?.trim() ?? ""; const rawTag = parts[2]?.trim()?.toLowerCase() ?? "other"; const tag = TG_TAGS.includes(rawTag) ? rawTag : "other"; return { id, name, tag }; }) .filter((e) => e.id && e.name); onChange([...talkgroups, ...parsed]); setPasteText(""); setShowPaste(false); } async function handleRRFile(file: File) { setRrError(null); const html = await file.text(); const parsed = parseRadioReference(html); if (!parsed) { setRrError( "This doesn't look like a RadioReference trunked system page. " + "Download the HTML from a system page on radioreference.com and try again." ); return; } setRrSystem(parsed); } function handleRRImport(newTgs: TalkgroupEntry[]) { onChange([...talkgroups, ...newTgs]); setRrSystem(null); setRrError(null); } return (
{rrSystem && ( setRrSystem(null)} /> )}
{/* RadioReference file import */} { const f = e.target.files?.[0]; if (f) handleRRFile(f); e.target.value = ""; }} />
{rrError && (

{rrError}

)} {showPaste && (

Paste rows from RadioReference — tab- or comma-separated:{" "} ID, Name, Tag
Tags: fire · police · ems · transit · public works · other