diff --git a/frontend/src/cps-anytone.test.ts b/frontend/src/cps-anytone.test.ts index f1f46e3..8cd49cf 100644 --- a/frontend/src/cps-anytone.test.ts +++ b/frontend/src/cps-anytone.test.ts @@ -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"); diff --git a/frontend/src/cps-anytone.ts b/frontend/src/cps-anytone.ts index f3689d9..91676a5 100644 --- a/frontend/src/cps-anytone.ts +++ b/frontend/src/cps-anytone.ts @@ -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, diff --git a/frontend/src/cps-motorola.test.ts b/frontend/src/cps-motorola.test.ts index 374abc3..f14e730 100644 --- a/frontend/src/cps-motorola.test.ts +++ b/frontend/src/cps-motorola.test.ts @@ -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('"); expect(xml).toContain(""); @@ -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("439.500000"); + expect(xml).toContain("438.200000"); + }); }); describe("generateContactsCsv", () => { diff --git a/frontend/src/cps-motorola.ts b/frontend/src/cps-motorola.ts index 0a35929..e81722e 100644 --- a/frontend/src/cps-motorola.ts +++ b/frontend/src/cps-motorola.ts @@ -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); }); }); diff --git a/frontend/src/format.test.ts b/frontend/src/format.test.ts index eceac6a..dd3b08c 100644 --- a/frontend/src/format.test.ts +++ b/frontend/src/format.test.ts @@ -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); + }); }); diff --git a/frontend/src/format.ts b/frontend/src/format.ts index fc30b10..db9d402 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -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); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e073be2..82ec210 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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"); diff --git a/frontend/static/index.html b/frontend/static/index.html index ddbd785..635d718 100644 --- a/frontend/static/index.html +++ b/frontend/static/index.html @@ -92,6 +92,7 @@