Add another CPS export channel name format

This commit is contained in:
Marcus Kida 2026-02-21 09:40:20 +01:00
parent fade468ee0
commit 7a1a970f3b
8 changed files with 81 additions and 20 deletions

View file

@ -61,7 +61,7 @@ describe("generateAnytoneChannelsCsv", () => {
const repeaters = [makeRepeater()];
it("generates CSV with correct header", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const csv = generateAnytoneChannelsCsv(repeaters, tgs, "tg-name");
const lines = csv.split("\r\n");
expect(lines[0]).toBe(ANYTONE_CH_HEADER);
});
@ -71,13 +71,13 @@ describe("generateAnytoneChannelsCsv", () => {
{ id: 262, name: "TG262", slot: "1" },
{ id: 2628, name: "TG2628", slot: "2" },
];
const csv = generateAnytoneChannelsCsv(repeaters, tgs2);
const csv = generateAnytoneChannelsCsv(repeaters, tgs2, "tg-name");
const lines = csv.split("\r\n");
expect(lines).toHaveLength(3); // header + 2 channels
});
it("has 56 columns per row (matching header)", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const csv = generateAnytoneChannelsCsv(repeaters, tgs, "tg-name");
const lines = csv.split("\r\n");
const headerCols = lines[0].split(",").length;
expect(headerCols).toBe(56);
@ -87,7 +87,7 @@ describe("generateAnytoneChannelsCsv", () => {
});
it("all values are double-quoted", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const csv = generateAnytoneChannelsCsv(repeaters, tgs, "tg-name");
const lines = csv.split("\r\n");
for (let i = 0; i < lines.length; i++) {
const fields = lines[i].split(",");
@ -98,13 +98,13 @@ describe("generateAnytoneChannelsCsv", () => {
});
it("includes correct frequencies with 5 decimal places", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const csv = generateAnytoneChannelsCsv(repeaters, tgs, "tg-name");
expect(csv).toContain("439.50000");
expect(csv).toContain("431.90000");
});
it("includes color code and timeslot", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const csv = generateAnytoneChannelsCsv(repeaters, tgs, "tg-name");
const lines = csv.split("\r\n");
const cols = parseCsvRow(lines[1]);
// RX Color Code at index 20, Slot at index 21
@ -113,21 +113,21 @@ describe("generateAnytoneChannelsCsv", () => {
});
it("has empty Radio ID field", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const csv = generateAnytoneChannelsCsv(repeaters, tgs, "tg-name");
const lines = csv.split("\r\n");
const cols = parseCsvRow(lines[1]);
expect(cols[12]).toBe("");
});
it("has Through Mode set to On", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const csv = generateAnytoneChannelsCsv(repeaters, tgs, "tg-name");
const lines = csv.split("\r\n");
const cols = parseCsvRow(lines[1]);
expect(cols[36]).toBe("On");
});
it("has TxCC as last column set to 1", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const csv = generateAnytoneChannelsCsv(repeaters, tgs, "tg-name");
const lines = csv.split("\r\n");
const cols = parseCsvRow(lines[1]);
expect(cols[55]).toBe("1");

View file

@ -1,4 +1,5 @@
import type { Repeater, CpsTalkgroup } from "./types";
import { formatChannelAlias } from "./format";
function q(v: string | number): string {
return '"' + v + '"';
@ -37,7 +38,7 @@ function formatFreq5(mhz: number | string): string {
}
/** Generate an Anytone CPS channels CSV. */
export function generateAnytoneChannelsCsv(repeaters: Repeater[], talkgroups: CpsTalkgroup[]): string {
export function generateAnytoneChannelsCsv(repeaters: Repeater[], talkgroups: CpsTalkgroup[], aliasFormat: string): string {
const rows = [ANYTONE_CH_HEADER];
let num = 0;
repeaters.forEach(function (r) {
@ -46,7 +47,7 @@ export function generateAnytoneChannelsCsv(repeaters: Repeater[], talkgroups: Cp
const cc = r.color_code || 1;
talkgroups.forEach(function (tg) {
num++;
const chName = (r.callsign + " " + tg.name).substring(0, 16);
const chName = formatChannelAlias(r.callsign, tg.name, aliasFormat);
const contactName = (tg.name || "TG" + tg.id).trim().substring(0, 16);
rows.push([
num, chName, rxFreq, txFreq,

View file

@ -39,7 +39,7 @@ describe("generateCpsXml", () => {
it("generates a valid XML document", () => {
const repeaters = [makeRepeater()];
const tgs: CpsTalkgroup[] = [{ id: 262, name: "TG262 Deutschlan", slot: "1" }];
const xml = generateCpsXml(repeaters, tgs);
const xml = generateCpsXml(repeaters, tgs, "tg-name");
expect(xml).toContain('<?xml version="1.0"');
expect(xml).toContain("<config>");
expect(xml).toContain("</config>");
@ -52,10 +52,30 @@ describe("generateCpsXml", () => {
{ id: 262, name: "TG262", slot: "1" },
{ id: 2628, name: "TG2628", slot: "1" },
];
const xml = generateCpsXml(repeaters, tgs);
const xml = generateCpsXml(repeaters, tgs, "tg-name");
const matches = xml.match(/ConventionalPersonality/g);
expect(matches).toHaveLength(4); // 2 repeaters × 2 TGs
});
it("uses tg name as alias with tg-name format", () => {
const repeaters = [makeRepeater()];
const tgs: CpsTalkgroup[] = [{ id: 262, name: "TG262 Deutschlan", slot: "1" }];
const xml = generateCpsXml(repeaters, tgs, "tg-name");
expect(xml).toContain('alias="TG262 Deutschlan"');
});
it("prepends callsign with call-tg-ts format", () => {
const repeaters = [
makeRepeater({ callsign: "DB0ABC", freq_tx: 439.5, freq_rx: 431.9 }),
makeRepeater({ callsign: "DB0XYZ", freq_tx: 438.2, freq_rx: 430.6 }),
];
const tgs: CpsTalkgroup[] = [{ id: 262, name: "262-1", slot: "1" }];
const xml = generateCpsXml(repeaters, tgs, "call-tg-ts");
expect(xml).toContain('alias="DB0ABC 262-1"');
expect(xml).toContain('alias="DB0XYZ 262-1"');
expect(xml).toContain("<field name=\"CP_RXFREQ\">439.500000</field>");
expect(xml).toContain("<field name=\"CP_RXFREQ\">438.200000</field>");
});
});
describe("generateContactsCsv", () => {

View file

@ -1,5 +1,5 @@
import type { Repeater, CpsTalkgroup } from "./types";
import { escapeXml, formatFreq } from "./format";
import { escapeXml, formatFreq, formatChannelAlias } from "./format";
export const CPS_CSV_HEADER = "ContactName,Delete_Contact,Rename_Contact,Comments," +
"Delete_FiveToneCalls,FiveToneCalls-S5CLDLL_5TTELEGRAM,FiveToneCalls-S5CLDLL_5TCALLADD," +
@ -62,7 +62,7 @@ export function buildChannel(alias: string, slot: string, colorCode: number, txF
}
/** Generate a complete Motorola CPS zone XML document. */
export function generateCpsXml(repeaters: Repeater[], talkgroups: CpsTalkgroup[]): string {
export function generateCpsXml(repeaters: Repeater[], talkgroups: CpsTalkgroup[], aliasFormat: string): string {
let channels = "";
repeaters.forEach(function (r) {
const txFreq = formatFreq(r.freq_tx);
@ -70,7 +70,7 @@ export function generateCpsXml(repeaters: Repeater[], talkgroups: CpsTalkgroup[]
const cc = r.color_code;
talkgroups.forEach(function (tg) {
const slot = tg.slot === "1" ? "SLOT1" : "SLOT2";
const alias = tg.name.substring(0, 16);
const alias = formatChannelAlias(r.callsign, tg.name, aliasFormat);
channels += buildChannel(alias, slot, cc, txFreq, rxFreq);
});
});

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { escapeHtml, escapeXml, formatFreq, formatTgAlias } from "./format";
import { escapeHtml, escapeXml, formatFreq, formatTgAlias, formatChannelAlias } from "./format";
describe("escapeHtml", () => {
it("escapes &, <, >, and quotes", () => {
@ -66,4 +66,26 @@ describe("formatTgAlias", () => {
expect(formatTgAlias(12345, "1", "tg-name", longName)).toBe("TG12345 Very Lon");
expect(formatTgAlias(12345, "1", "tg-name", longName)).toHaveLength(16);
});
it("formats as tgid-slot for call-tg-ts format", () => {
expect(formatTgAlias(262, "2", "call-tg-ts", mockTgName)).toBe("262-2");
});
});
describe("formatChannelAlias", () => {
it("prepends callsign for call-tg-ts format", () => {
expect(formatChannelAlias("DB0XYZ", "262-2", "call-tg-ts")).toBe("DB0XYZ 262-2");
});
it("returns tgAlias unchanged for tg-name format", () => {
expect(formatChannelAlias("DB0XYZ", "TG262 Deutschlan", "tg-name")).toBe("TG262 Deutschlan");
});
it("returns tgAlias unchanged for tg-ts format", () => {
expect(formatChannelAlias("DB0XYZ", "TG262-TS1", "tg-ts")).toBe("TG262-TS1");
});
it("truncates to 16 characters", () => {
expect(formatChannelAlias("DB0LONGCALL", "262262-2", "call-tg-ts")).toHaveLength(16);
});
});

View file

@ -37,8 +37,25 @@ export function formatTgAlias(
format: string,
tgNameFn: (id: number) => string,
): string {
if (format === "call-tg-ts") {
return (tgId + "-" + slot).substring(0, 16);
}
if (format === "tg-ts") {
return ("TG" + tgId + "-TS" + slot).substring(0, 16);
}
return ("TG" + tgId + " " + (tgNameFn(tgId) || "")).trim().substring(0, 16);
}
/**
* Build the final channel alias for export, prepending callsign when format requires it.
*/
export function formatChannelAlias(
callsign: string,
tgAlias: string,
format: string,
): string {
if (format === "call-tg-ts") {
return (callsign + " " + tgAlias).substring(0, 16);
}
return tgAlias.substring(0, 16);
}

View file

@ -426,7 +426,7 @@ import { buildPopup as _buildPopup } from "./popup";
function downloadCpsXml() {
var active = getActiveRepeaters();
if (!cpsTalkgroups.length || !active.length) return;
var xml = generateCpsXml(active, cpsTalkgroups);
var xml = generateCpsXml(active, cpsTalkgroups, cpsAliasFormat.value);
var blob = new Blob([xml], { type: "application/xml" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
@ -441,7 +441,7 @@ import { buildPopup as _buildPopup } from "./popup";
function copyCpsXml() {
var active = getActiveRepeaters();
if (!cpsTalkgroups.length || !active.length) return;
var xml = generateCpsXml(active, cpsTalkgroups);
var xml = generateCpsXml(active, cpsTalkgroups, cpsAliasFormat.value);
var origText = cpsCopyBtn.textContent;
navigator.clipboard.writeText(xml).then(function () {
cpsCopyBtn.textContent = t("cps_copied");
@ -479,7 +479,7 @@ import { buildPopup as _buildPopup } from "./popup";
function downloadAnytoneChannelsCsv() {
var active = getActiveRepeaters();
if (!cpsTalkgroups.length || !active.length) return;
var csv = generateAnytoneChannelsCsv(active, cpsTalkgroups);
var csv = generateAnytoneChannelsCsv(active, cpsTalkgroups, cpsAliasFormat.value);
var blob = new Blob([csv], { type: "text/csv" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");

View file

@ -92,6 +92,7 @@
<select id="cps-alias-format">
<option value="tg-name" selected>TG262 Deutschland</option>
<option value="tg-ts">TG262-TS2</option>
<option value="call-tg-ts">DB0XYZ 262-2</option>
</select>
</div>
<div id="cps-motorola-settings">