Add another CPS export channel name format
This commit is contained in:
parent
fade468ee0
commit
7a1a970f3b
8 changed files with 81 additions and 20 deletions
|
|
@ -61,7 +61,7 @@ describe("generateAnytoneChannelsCsv", () => {
|
||||||
const repeaters = [makeRepeater()];
|
const repeaters = [makeRepeater()];
|
||||||
|
|
||||||
it("generates CSV with correct header", () => {
|
it("generates CSV with correct header", () => {
|
||||||
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
|
const csv = generateAnytoneChannelsCsv(repeaters, tgs, "tg-name");
|
||||||
const lines = csv.split("\r\n");
|
const lines = csv.split("\r\n");
|
||||||
expect(lines[0]).toBe(ANYTONE_CH_HEADER);
|
expect(lines[0]).toBe(ANYTONE_CH_HEADER);
|
||||||
});
|
});
|
||||||
|
|
@ -71,13 +71,13 @@ describe("generateAnytoneChannelsCsv", () => {
|
||||||
{ id: 262, name: "TG262", slot: "1" },
|
{ id: 262, name: "TG262", slot: "1" },
|
||||||
{ id: 2628, name: "TG2628", slot: "2" },
|
{ id: 2628, name: "TG2628", slot: "2" },
|
||||||
];
|
];
|
||||||
const csv = generateAnytoneChannelsCsv(repeaters, tgs2);
|
const csv = generateAnytoneChannelsCsv(repeaters, tgs2, "tg-name");
|
||||||
const lines = csv.split("\r\n");
|
const lines = csv.split("\r\n");
|
||||||
expect(lines).toHaveLength(3); // header + 2 channels
|
expect(lines).toHaveLength(3); // header + 2 channels
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has 56 columns per row (matching header)", () => {
|
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 lines = csv.split("\r\n");
|
||||||
const headerCols = lines[0].split(",").length;
|
const headerCols = lines[0].split(",").length;
|
||||||
expect(headerCols).toBe(56);
|
expect(headerCols).toBe(56);
|
||||||
|
|
@ -87,7 +87,7 @@ describe("generateAnytoneChannelsCsv", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("all values are double-quoted", () => {
|
it("all values are double-quoted", () => {
|
||||||
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
|
const csv = generateAnytoneChannelsCsv(repeaters, tgs, "tg-name");
|
||||||
const lines = csv.split("\r\n");
|
const lines = csv.split("\r\n");
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const fields = lines[i].split(",");
|
const fields = lines[i].split(",");
|
||||||
|
|
@ -98,13 +98,13 @@ describe("generateAnytoneChannelsCsv", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes correct frequencies with 5 decimal places", () => {
|
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("439.50000");
|
||||||
expect(csv).toContain("431.90000");
|
expect(csv).toContain("431.90000");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes color code and timeslot", () => {
|
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 lines = csv.split("\r\n");
|
||||||
const cols = parseCsvRow(lines[1]);
|
const cols = parseCsvRow(lines[1]);
|
||||||
// RX Color Code at index 20, Slot at index 21
|
// RX Color Code at index 20, Slot at index 21
|
||||||
|
|
@ -113,21 +113,21 @@ describe("generateAnytoneChannelsCsv", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has empty Radio ID field", () => {
|
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 lines = csv.split("\r\n");
|
||||||
const cols = parseCsvRow(lines[1]);
|
const cols = parseCsvRow(lines[1]);
|
||||||
expect(cols[12]).toBe("");
|
expect(cols[12]).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has Through Mode set to On", () => {
|
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 lines = csv.split("\r\n");
|
||||||
const cols = parseCsvRow(lines[1]);
|
const cols = parseCsvRow(lines[1]);
|
||||||
expect(cols[36]).toBe("On");
|
expect(cols[36]).toBe("On");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has TxCC as last column set to 1", () => {
|
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 lines = csv.split("\r\n");
|
||||||
const cols = parseCsvRow(lines[1]);
|
const cols = parseCsvRow(lines[1]);
|
||||||
expect(cols[55]).toBe("1");
|
expect(cols[55]).toBe("1");
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Repeater, CpsTalkgroup } from "./types";
|
import type { Repeater, CpsTalkgroup } from "./types";
|
||||||
|
import { formatChannelAlias } from "./format";
|
||||||
|
|
||||||
function q(v: string | number): string {
|
function q(v: string | number): string {
|
||||||
return '"' + v + '"';
|
return '"' + v + '"';
|
||||||
|
|
@ -37,7 +38,7 @@ function formatFreq5(mhz: number | string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate an Anytone CPS channels CSV. */
|
/** 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];
|
const rows = [ANYTONE_CH_HEADER];
|
||||||
let num = 0;
|
let num = 0;
|
||||||
repeaters.forEach(function (r) {
|
repeaters.forEach(function (r) {
|
||||||
|
|
@ -46,7 +47,7 @@ export function generateAnytoneChannelsCsv(repeaters: Repeater[], talkgroups: Cp
|
||||||
const cc = r.color_code || 1;
|
const cc = r.color_code || 1;
|
||||||
talkgroups.forEach(function (tg) {
|
talkgroups.forEach(function (tg) {
|
||||||
num++;
|
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);
|
const contactName = (tg.name || "TG" + tg.id).trim().substring(0, 16);
|
||||||
rows.push([
|
rows.push([
|
||||||
num, chName, rxFreq, txFreq,
|
num, chName, rxFreq, txFreq,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ describe("generateCpsXml", () => {
|
||||||
it("generates a valid XML document", () => {
|
it("generates a valid XML document", () => {
|
||||||
const repeaters = [makeRepeater()];
|
const repeaters = [makeRepeater()];
|
||||||
const tgs: CpsTalkgroup[] = [{ id: 262, name: "TG262 Deutschlan", slot: "1" }];
|
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('<?xml version="1.0"');
|
||||||
expect(xml).toContain("<config>");
|
expect(xml).toContain("<config>");
|
||||||
expect(xml).toContain("</config>");
|
expect(xml).toContain("</config>");
|
||||||
|
|
@ -52,10 +52,30 @@ describe("generateCpsXml", () => {
|
||||||
{ id: 262, name: "TG262", slot: "1" },
|
{ id: 262, name: "TG262", slot: "1" },
|
||||||
{ id: 2628, name: "TG2628", 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);
|
const matches = xml.match(/ConventionalPersonality/g);
|
||||||
expect(matches).toHaveLength(4); // 2 repeaters × 2 TGs
|
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", () => {
|
describe("generateContactsCsv", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Repeater, CpsTalkgroup } from "./types";
|
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," +
|
export const CPS_CSV_HEADER = "ContactName,Delete_Contact,Rename_Contact,Comments," +
|
||||||
"Delete_FiveToneCalls,FiveToneCalls-S5CLDLL_5TTELEGRAM,FiveToneCalls-S5CLDLL_5TCALLADD," +
|
"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. */
|
/** 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 = "";
|
let channels = "";
|
||||||
repeaters.forEach(function (r) {
|
repeaters.forEach(function (r) {
|
||||||
const txFreq = formatFreq(r.freq_tx);
|
const txFreq = formatFreq(r.freq_tx);
|
||||||
|
|
@ -70,7 +70,7 @@ export function generateCpsXml(repeaters: Repeater[], talkgroups: CpsTalkgroup[]
|
||||||
const cc = r.color_code;
|
const cc = r.color_code;
|
||||||
talkgroups.forEach(function (tg) {
|
talkgroups.forEach(function (tg) {
|
||||||
const slot = tg.slot === "1" ? "SLOT1" : "SLOT2";
|
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);
|
channels += buildChannel(alias, slot, cc, txFreq, rxFreq);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { escapeHtml, escapeXml, formatFreq, formatTgAlias } from "./format";
|
import { escapeHtml, escapeXml, formatFreq, formatTgAlias, formatChannelAlias } from "./format";
|
||||||
|
|
||||||
describe("escapeHtml", () => {
|
describe("escapeHtml", () => {
|
||||||
it("escapes &, <, >, and quotes", () => {
|
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)).toBe("TG12345 Very Lon");
|
||||||
expect(formatTgAlias(12345, "1", "tg-name", longName)).toHaveLength(16);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,25 @@ export function formatTgAlias(
|
||||||
format: string,
|
format: string,
|
||||||
tgNameFn: (id: number) => string,
|
tgNameFn: (id: number) => string,
|
||||||
): string {
|
): string {
|
||||||
|
if (format === "call-tg-ts") {
|
||||||
|
return (tgId + "-" + slot).substring(0, 16);
|
||||||
|
}
|
||||||
if (format === "tg-ts") {
|
if (format === "tg-ts") {
|
||||||
return ("TG" + tgId + "-TS" + slot).substring(0, 16);
|
return ("TG" + tgId + "-TS" + slot).substring(0, 16);
|
||||||
}
|
}
|
||||||
return ("TG" + tgId + " " + (tgNameFn(tgId) || "")).trim().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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -426,7 +426,7 @@ import { buildPopup as _buildPopup } from "./popup";
|
||||||
function downloadCpsXml() {
|
function downloadCpsXml() {
|
||||||
var active = getActiveRepeaters();
|
var active = getActiveRepeaters();
|
||||||
if (!cpsTalkgroups.length || !active.length) return;
|
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 blob = new Blob([xml], { type: "application/xml" });
|
||||||
var url = URL.createObjectURL(blob);
|
var url = URL.createObjectURL(blob);
|
||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
|
|
@ -441,7 +441,7 @@ import { buildPopup as _buildPopup } from "./popup";
|
||||||
function copyCpsXml() {
|
function copyCpsXml() {
|
||||||
var active = getActiveRepeaters();
|
var active = getActiveRepeaters();
|
||||||
if (!cpsTalkgroups.length || !active.length) return;
|
if (!cpsTalkgroups.length || !active.length) return;
|
||||||
var xml = generateCpsXml(active, cpsTalkgroups);
|
var xml = generateCpsXml(active, cpsTalkgroups, cpsAliasFormat.value);
|
||||||
var origText = cpsCopyBtn.textContent;
|
var origText = cpsCopyBtn.textContent;
|
||||||
navigator.clipboard.writeText(xml).then(function () {
|
navigator.clipboard.writeText(xml).then(function () {
|
||||||
cpsCopyBtn.textContent = t("cps_copied");
|
cpsCopyBtn.textContent = t("cps_copied");
|
||||||
|
|
@ -479,7 +479,7 @@ import { buildPopup as _buildPopup } from "./popup";
|
||||||
function downloadAnytoneChannelsCsv() {
|
function downloadAnytoneChannelsCsv() {
|
||||||
var active = getActiveRepeaters();
|
var active = getActiveRepeaters();
|
||||||
if (!cpsTalkgroups.length || !active.length) return;
|
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 blob = new Blob([csv], { type: "text/csv" });
|
||||||
var url = URL.createObjectURL(blob);
|
var url = URL.createObjectURL(blob);
|
||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@
|
||||||
<select id="cps-alias-format">
|
<select id="cps-alias-format">
|
||||||
<option value="tg-name" selected>TG262 Deutschland</option>
|
<option value="tg-name" selected>TG262 Deutschland</option>
|
||||||
<option value="tg-ts">TG262-TS2</option>
|
<option value="tg-ts">TG262-TS2</option>
|
||||||
|
<option value="call-tg-ts">DB0XYZ 262-2</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="cps-motorola-settings">
|
<div id="cps-motorola-settings">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue