diff --git a/README.md b/README.md index 128a5b0..353fb1e 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,12 @@ Program your DMR radio directly from your browser—no software installation req ### 📡 Channel Configuration - **Smart Import** - Location-based channel wizard using repeater databases - **Bulk Editing** - Powerful table interface for editing multiple channels at once -- **CSV Support** - Import/export channels from CHIRP and custom CSV formats +- **Codeplug backup** - Save and load a full codeplug as a `.neonplug` file (zipped JSON) +- **Chirp CSV** - Import and export channels in CHIRP CSV format; custom CSV import also supported - **Auto-Configuration** - Automatic offset, CTCSS, and color code detection +The `.neonplug` file is a zipped JSON archive. You can unzip it to inspect the contents in a semi-human-readable way (e.g. `codeplug.json` inside the zip). Editing the JSON directly is not recommended—use NeonPlug’s import/export and in-app editing instead to avoid invalid data or corruption. + ### 👥 Contact & Group Management - **Digital Contacts** - Manage DMR contacts with full talk group support - **RX Groups** - Create and organize receive groups diff --git a/package.json b/package.json index d7162cc..99461ea 100644 --- a/package.json +++ b/package.json @@ -1,40 +1 @@ -{ - "name": "neonplug", - "version": "0.1.0", - "type": "module", - "description": "Cyberpunk-themed Radio CPS for Baofeng DM-32UV", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "build:prod": "tsc && vite build --mode production", - "build:single": "tsc && vite build --mode singlefile", - "preview": "vite preview", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "test": "vitest" - }, - "dependencies": { - "exceljs": "^4.4.0", - "jszip": "^3.10.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-easy-crop": "^5.5.6", - "zustand": "^4.4.7" - }, - "devDependencies": { - "@types/react": "^18.2.43", - "@types/react-dom": "^18.2.17", - "@typescript-eslint/eslint-plugin": "^6.14.0", - "@typescript-eslint/parser": "^6.14.0", - "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.16", - "eslint": "^8.55.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.5", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", - "typescript": "^5.2.2", - "vite": "^7.3.0", - "vite-plugin-singlefile": "^2.3.0", - "vitest": "^4.0.16" - } -} +{"name":"neonplug","version":"0.1.0","type":"module","description":"Cyberpunk-themed Radio CPS for Baofeng DM-32UV","scripts":{"dev":"vite","build":"tsc && vite build","build:prod":"tsc && vite build --mode production","build:single":"tsc && vite build --mode singlefile","preview":"vite preview","lint":"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0","test":"vitest"},"dependencies":{"jszip":"^3.10.1","react":"^18.2.0","react-dom":"^18.2.0","react-easy-crop":"^5.5.6","zustand":"^4.4.7"},"devDependencies":{"@types/react":"^18.2.43","@types/react-dom":"^18.2.17","@typescript-eslint/eslint-plugin":"^6.14.0","@typescript-eslint/parser":"^6.14.0","@vitejs/plugin-react":"^4.2.1","autoprefixer":"^10.4.16","eslint":"^8.55.0","eslint-plugin-react-hooks":"^4.6.0","eslint-plugin-react-refresh":"^0.4.5","postcss":"^8.4.32","tailwindcss":"^3.4.0","typescript":"^5.2.2","vite":"^7.3.0","vite-plugin-singlefile":"^2.3.0","vitest":"^4.0.16"}} diff --git a/src/App.tsx b/src/App.tsx index 871cac4..fba842f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,12 @@ import { useScanListsStore } from './store/scanListsStore'; import { useRadioSettingsStore } from './store/radioSettingsStore'; import { useDigitalEmergencyStore } from './store/digitalEmergencyStore'; import { useAnalogEmergencyStore } from './store/analogEmergencyStore'; +import { useQuickMessagesStore } from './store/quickMessagesStore'; +import { useDMRRadioIDsStore } from './store/dmrRadioIdsStore'; +import { useQuickContactsStore } from './store/quickContactsStore'; +import { useRXGroupsStore } from './store/rxGroupsStore'; +import { useEncryptionKeysStore } from './store/encryptionKeysStore'; +import { useRadioStore } from './store/radioStore'; import { useRadioConnection } from './hooks/useRadioConnection'; import { importChannelsFromCSV, importContactsFromCSV } from './services/csv'; import { sampleChannels, sampleContacts, sampleZones } from './utils/sampleData'; @@ -38,6 +44,12 @@ function App() { const { setSettings: setRadioSettings } = useRadioSettingsStore(); const { setSystems: setDigitalEmergencies, setConfig: setDigitalEmergencyConfig } = useDigitalEmergencyStore(); const { setSystems: setAnalogEmergencies } = useAnalogEmergencyStore(); + const { setMessages } = useQuickMessagesStore(); + const { setRadioIds } = useDMRRadioIDsStore(); + const { setContacts: setQuickContacts } = useQuickContactsStore(); + const { setGroups: setRXGroups } = useRXGroupsStore(); + const { setKeys: setEncryptionKeys } = useEncryptionKeysStore(); + const { setRadioInfo } = useRadioStore(); const { isConnecting, error: radioError } = useRadioConnection(); const fileInputRef = useRef(null); @@ -119,10 +131,9 @@ function App() { const fileName = file.name.toLowerCase(); const fileExtension = fileName.split('.').pop()?.toLowerCase(); - // Check if it's a codeplug XLSX file (ExcelJS supports .xlsx only; .xls is not supported) - if (fileExtension === 'xlsx') { + // Check if it's a codeplug file (.neonplug = zipped JSON) + if (fileExtension === 'neonplug') { try { - // Lazy load Excel library only when needed const { importCodeplug } = await import('./services/codeplugExport'); const codeplugData = await importCodeplug(file); @@ -139,15 +150,29 @@ function App() { if (codeplugData.radioSettings) { setRadioSettings(codeplugData.radioSettings); } + setRadioInfo(codeplugData.radioInfo ?? null); + setMessages(codeplugData.messages ?? []); + setRadioIds(codeplugData.radioIds ?? []); + setQuickContacts(codeplugData.quickContacts ?? []); + setRXGroups(codeplugData.rxGroups ?? []); + setEncryptionKeys(codeplugData.encryptionKeys ?? []); setShowStartupModal(false); - setAlertMessage( - `Successfully imported codeplug!\n\n` + - `• ${codeplugData.channels.length} channels\n` + - `• ${codeplugData.zones.length} zones\n` + - `• ${codeplugData.scanLists.length} scan lists\n` + - `• ${codeplugData.contacts.length} contacts` - ); + const lines = [ + `• ${codeplugData.channels.length} channels`, + `• ${codeplugData.zones.length} zones`, + `• ${codeplugData.scanLists.length} scan lists`, + `• ${codeplugData.contacts.length} contacts`, + `• ${codeplugData.digitalEmergencies?.length ?? 0} digital emergency system(s)`, + `• ${codeplugData.analogEmergencies?.length ?? 0} analog emergency system(s)`, + codeplugData.radioSettings ? '• Radio settings' : null, + `• ${codeplugData.messages?.length ?? 0} quick message(s)`, + `• ${codeplugData.radioIds?.length ?? 0} DMR radio ID(s)`, + `• ${codeplugData.quickContacts?.length ?? 0} talk group(s)`, + `• ${codeplugData.rxGroups?.length ?? 0} RX group(s)`, + `• ${codeplugData.encryptionKeys?.length ?? 0} encryption key(s)`, + ].filter(Boolean); + setAlertMessage(`Successfully imported codeplug!\n\n${lines.join('\n')}`); setAlertOpen(true); } catch (error) { setAlertMessage(`Failed to import codeplug: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -180,7 +205,7 @@ function App() { setAlertOpen(true); } } else { - setAlertMessage('File must be a codeplug (.xlsx) or CSV file containing "channel" or "contact" in the filename'); + setAlertMessage('File must be a codeplug (.neonplug) or CSV file containing "channel" or "contact" in the filename'); setAlertOpen(true); } } @@ -247,7 +272,7 @@ function App() { diff --git a/src/components/about/AboutTab.tsx b/src/components/about/AboutTab.tsx index d1abf14..799774b 100644 --- a/src/components/about/AboutTab.tsx +++ b/src/components/about/AboutTab.tsx @@ -112,6 +112,14 @@ npm run build:single + {/* Codeplug format */} + + Codeplug format (.neonplug) +

+ The codeplug file is a zipped JSON archive. You can unzip it to inspect the contents in a semi-human-readable way (e.g. codeplug.json inside the zip). Editing the JSON directly is not recommended—use NeonPlug’s import/export and in-app editing instead, to avoid invalid data or corruption. +

+
+ {/* Links */} Links diff --git a/src/components/diagnostics/DiagnosticsTab.tsx b/src/components/diagnostics/DiagnosticsTab.tsx index 6a2a889..cafbb18 100644 --- a/src/components/diagnostics/DiagnosticsTab.tsx +++ b/src/components/diagnostics/DiagnosticsTab.tsx @@ -11,6 +11,7 @@ import { useRXGroupsStore } from '../../store/rxGroupsStore'; import { useQuickMessagesStore } from '../../store/quickMessagesStore'; import { useQuickContactsStore } from '../../store/quickContactsStore'; import { useDMRRadioIDsStore } from '../../store/dmrRadioIdsStore'; +import { useEncryptionKeysStore } from '../../store/encryptionKeysStore'; import { useLogStore } from '../../store/logStore'; import { getCapabilitiesForModel } from '../../radios/capabilities'; import { @@ -46,6 +47,7 @@ export const DiagnosticsTab: React.FC = () => { const { messages: quickMessages } = useQuickMessagesStore(); const { contacts: quickContacts } = useQuickContactsStore(); const { radioIds: dmrRadioIds } = useDMRRadioIDsStore(); + const { keys: encryptionKeys } = useEncryptionKeysStore(); const caps = useMemo(() => getCapabilitiesForModel(radioInfo?.model), [radioInfo?.model]); const [showMetadataBlock, setShowMetadataBlock] = useState(false); const [showMetadataBlock41, setShowMetadataBlock41] = useState(false); @@ -530,7 +532,7 @@ export const DiagnosticsTab: React.FC = () => { writeFolder.file('expected-write-data.json', JSON.stringify(expectedWrite, null, 2)); } - // Add codeplug XLSX + // Add codeplug (.neonplug = zipped JSON) const codeplugData = { channels, zones, @@ -541,13 +543,18 @@ export const DiagnosticsTab: React.FC = () => { analogEmergencies, radioSettings, radioInfo, + messages: quickMessages, + radioIds: dmrRadioIds, + quickContacts, + rxGroups, + encryptionKeys, exportDate: new Date().toISOString(), version: '1.0.0', }; - const xlsxBlob = await exportCodeplug(codeplugData, true); - if (xlsxBlob instanceof Blob) { - zip.file('codeplug.xlsx', xlsxBlob); + const codeplugBlob = await exportCodeplug(codeplugData, true); + if (codeplugBlob instanceof Blob) { + zip.file('codeplug.neonplug', codeplugBlob); } // Generate and download zip diff --git a/src/components/digital/DigitalTab.tsx b/src/components/digital/DigitalTab.tsx index a642429..a29b998 100644 --- a/src/components/digital/DigitalTab.tsx +++ b/src/components/digital/DigitalTab.tsx @@ -447,9 +447,9 @@ export const DigitalTab: React.FC = () => { )} - {!block10Data ? ( + {!block10Data && digitalEmergencies.length === 0 ? ( - + ) : digitalEmergencies.length === 0 ? ( @@ -526,9 +526,9 @@ export const DigitalTab: React.FC = () => { )} - {!block10Data ? ( + {!block10Data && keys.length === 0 ? ( - + ) : ( diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index 4101102..4f32791 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -8,10 +8,14 @@ import { useRadioSettingsStore } from '../../store/radioSettingsStore'; import { useDigitalEmergencyStore } from '../../store/digitalEmergencyStore'; import { useAnalogEmergencyStore } from '../../store/analogEmergencyStore'; import { useRadioStore } from '../../store/radioStore'; +import { useQuickMessagesStore } from '../../store/quickMessagesStore'; import { useDMRRadioIDsStore } from '../../store/dmrRadioIdsStore'; +import { useQuickContactsStore } from '../../store/quickContactsStore'; +import { useRXGroupsStore } from '../../store/rxGroupsStore'; +import { useEncryptionKeysStore } from '../../store/encryptionKeysStore'; import { getCapabilitiesForModel } from '../../radios/capabilities'; import { validateCodeplugForWrite } from '../../services/validation/codeplugValidator'; -// XLSX functions will be lazy loaded when needed +// Codeplug export/import are lazy loaded when needed import { useRadioConnection } from '../../hooks/useRadioConnection'; import { ReadProgressModal } from '../ui/ReadProgressModal'; import { ConfirmModal } from '../ui/ConfirmModal'; @@ -25,15 +29,17 @@ export const Toolbar: React.FC = () => { const { settings: radioSettings, setSettings: setRadioSettings } = useRadioSettingsStore(); const { systems: digitalEmergencies, config: digitalEmergencyConfig, setSystems: setDigitalEmergencies, setConfig: setDigitalEmergencyConfig } = useDigitalEmergencyStore(); const { systems: analogEmergencies, setSystems: setAnalogEmergencies } = useAnalogEmergencyStore(); - const { radioInfo } = useRadioStore(); - const { radioIds: dmrRadioIds } = useDMRRadioIDsStore(); + const { radioInfo, setRadioInfo } = useRadioStore(); + const { messages, setMessages } = useQuickMessagesStore(); + const { radioIds: dmrRadioIds, setRadioIds } = useDMRRadioIDsStore(); + const { contacts: quickContacts, setContacts: setQuickContacts } = useQuickContactsStore(); + const { groups: rxGroups, setGroups: setRXGroups } = useRXGroupsStore(); + const { keys: encryptionKeys, setKeys: setEncryptionKeys } = useEncryptionKeysStore(); const fileInputRef = useRef(null); const { readFromRadio, writeChannelsToRadio, isConnecting, error, readSteps, writeChannelsSteps } = useRadioConnection(); const [progress, setProgress] = useState(0); const [progressMessage, setProgressMessage] = useState(''); const [currentStep, setCurrentStep] = useState(''); - const [importError, setImportError] = useState(null); - const [importSuccess, setImportSuccess] = useState(null); const [connectionError, setConnectionError] = useState(null); const [isWriting, setIsWriting] = useState(false); const [lastOperationMode, setLastOperationMode] = useState<'read' | 'write' | null>(null); @@ -41,6 +47,7 @@ export const Toolbar: React.FC = () => { const [writeWarningMessage, setWriteWarningMessage] = useState(''); const [alertOpen, setAlertOpen] = useState(false); const [alertMessage, setAlertMessage] = useState(''); + const [alertTitle, setAlertTitle] = useState('Notice'); const webSerialSupported = isWebSerialSupported(); const handleImport = () => { @@ -51,11 +58,8 @@ export const Toolbar: React.FC = () => { const file = event.target.files?.[0]; if (!file) return; - setImportError(null); - setImportSuccess(null); - try { - // Lazy load XLSX library only when needed + // Lazy load codeplug import when needed const { importCodeplug } = await import('../../services/codeplugExport'); const codeplugData = await importCodeplug(file); @@ -72,18 +76,41 @@ export const Toolbar: React.FC = () => { if (codeplugData.radioSettings) { setRadioSettings(codeplugData.radioSettings); } + setRadioInfo(codeplugData.radioInfo ?? null); + setMessages(codeplugData.messages ?? []); + setRadioIds(codeplugData.radioIds ?? []); + setQuickContacts(codeplugData.quickContacts ?? []); + setRXGroups(codeplugData.rxGroups ?? []); + setEncryptionKeys(codeplugData.encryptionKeys ?? []); - setImportSuccess( - `Successfully imported: ${codeplugData.channels.length} channels, ` + - `${codeplugData.zones.length} zones, ${codeplugData.scanLists.length} scan lists, ` + - `${codeplugData.contacts.length} contacts` - ); - - // Show success message briefly - setTimeout(() => setImportSuccess(null), 5000); + const digCount = codeplugData.digitalEmergencies?.length ?? 0; + const analogCount = codeplugData.analogEmergencies?.length ?? 0; + const msgCount = codeplugData.messages?.length ?? 0; + const idCount = codeplugData.radioIds?.length ?? 0; + const tgCount = codeplugData.quickContacts?.length ?? 0; + const rxCount = codeplugData.rxGroups?.length ?? 0; + const encCount = codeplugData.encryptionKeys?.length ?? 0; + const lines = [ + `• ${codeplugData.channels.length} channels`, + `• ${codeplugData.zones.length} zones`, + `• ${codeplugData.scanLists.length} scan lists`, + `• ${codeplugData.contacts.length} contacts`, + `• ${digCount} digital emergency system(s)`, + `• ${analogCount} analog emergency system(s)`, + codeplugData.radioSettings ? '• Radio settings' : null, + `• ${msgCount} quick message(s)`, + `• ${idCount} DMR radio ID(s)`, + `• ${tgCount} talk group(s)`, + `• ${rxCount} RX group(s)`, + `• ${encCount} encryption key(s)`, + ].filter(Boolean); + setAlertTitle('Import'); + setAlertMessage(`Successfully imported codeplug!\n\n${lines.join('\n')}`); + setAlertOpen(true); } catch (error) { - setImportError(error instanceof Error ? error.message : 'Failed to import codeplug'); - setTimeout(() => setImportError(null), 5000); + setAlertTitle('Import'); + setAlertMessage(error instanceof Error ? error.message : 'Failed to import codeplug'); + setAlertOpen(true); } // Reset file input @@ -103,10 +130,15 @@ export const Toolbar: React.FC = () => { analogEmergencies, radioSettings, radioInfo, + messages, + radioIds: dmrRadioIds, + quickContacts, + rxGroups, + encryptionKeys, exportDate: new Date().toISOString(), version: '1.0.0', }; - // Lazy load Excel library only when needed + // Lazy load codeplug export when needed const { exportCodeplug } = await import('../../services/codeplugExport'); await exportCodeplug(codeplugData); }; @@ -260,7 +292,7 @@ export const Toolbar: React.FC = () => { @@ -274,14 +306,14 @@ export const Toolbar: React.FC = () => { @@ -310,12 +342,6 @@ export const Toolbar: React.FC = () => { {error && !error.includes('Please click the button directly') && ( {error} )} - {importError && ( - {importError} - )} - {importSuccess && ( - {importSuccess} - )} { /> setAlertOpen(false)} - title="Notice" + onClose={() => { setAlertOpen(false); setAlertTitle('Notice'); }} + title={alertTitle} message={alertMessage} confirmLabel="OK" variant="alert" diff --git a/src/components/ui/DebugPanel.tsx b/src/components/ui/DebugPanel.tsx index 90a3757..a971390 100644 --- a/src/components/ui/DebugPanel.tsx +++ b/src/components/ui/DebugPanel.tsx @@ -202,7 +202,7 @@ export const DebugPanel: React.FC = () => { writeFolder.file('expected-write-data.json', JSON.stringify(expectedWrite, null, 2)); } - // Add codeplug XLSX + // Add codeplug (.neonplug = zipped JSON) const { exportCodeplug } = await import('../../services/codeplugExport'); const { useRadioStore } = await import('../../store/radioStore'); const { useRadioSettingsStore } = await import('../../store/radioSettingsStore'); @@ -210,6 +210,11 @@ export const DebugPanel: React.FC = () => { const { useAnalogEmergencyStore } = await import('../../store/analogEmergencyStore'); const { useScanListsStore } = await import('../../store/scanListsStore'); const { useContactsStore } = await import('../../store/contactsStore'); + const { useQuickMessagesStore } = await import('../../store/quickMessagesStore'); + const { useDMRRadioIDsStore } = await import('../../store/dmrRadioIdsStore'); + const { useQuickContactsStore } = await import('../../store/quickContactsStore'); + const { useRXGroupsStore } = await import('../../store/rxGroupsStore'); + const { useEncryptionKeysStore } = await import('../../store/encryptionKeysStore'); const radioStore = useRadioStore.getState(); const radioSettingsStore = useRadioSettingsStore.getState(); @@ -217,6 +222,11 @@ export const DebugPanel: React.FC = () => { const analogEmergencyStore = useAnalogEmergencyStore.getState(); const scanListsStore = useScanListsStore.getState(); const contactsStore = useContactsStore.getState(); + const quickMessagesStore = useQuickMessagesStore.getState(); + const dmrRadioIDsStore = useDMRRadioIDsStore.getState(); + const quickContactsStore = useQuickContactsStore.getState(); + const rxGroupsStore = useRXGroupsStore.getState(); + const encryptionKeysStore = useEncryptionKeysStore.getState(); const codeplugData = { channels, @@ -228,14 +238,18 @@ export const DebugPanel: React.FC = () => { analogEmergencies: analogEmergencyStore.systems, radioSettings: radioSettingsStore.settings, radioInfo: radioStore.radioInfo, + messages: quickMessagesStore.messages, + radioIds: dmrRadioIDsStore.radioIds, + quickContacts: quickContactsStore.contacts, + rxGroups: rxGroupsStore.groups, + encryptionKeys: encryptionKeysStore.keys, exportDate: new Date().toISOString(), version: '1.0.0', }; - // Export codeplug returns a blob, we need to get it and add to zip - const xlsxBlob = await exportCodeplug(codeplugData, true); // Pass true to return blob - if (xlsxBlob instanceof Blob) { - zip.file('codeplug.xlsx', xlsxBlob); + const codeplugBlob = await exportCodeplug(codeplugData, true); + if (codeplugBlob instanceof Blob) { + zip.file('codeplug.neonplug', codeplugBlob); } // Generate and download zip diff --git a/src/components/ui/StartupModal.tsx b/src/components/ui/StartupModal.tsx index 99c7ebe..00c77dd 100644 --- a/src/components/ui/StartupModal.tsx +++ b/src/components/ui/StartupModal.tsx @@ -85,7 +85,7 @@ export const StartupModal: React.FC = ({ Import Codeplug

- Import from XLSX codeplug file + Import from codeplug file (.neonplug)

diff --git a/src/services/codeplugExport.ts b/src/services/codeplugExport.ts index f156bbb..44ed7cb 100644 --- a/src/services/codeplugExport.ts +++ b/src/services/codeplugExport.ts @@ -1,35 +1,9 @@ /** * Codeplug Export/Import Service - * Exports and imports full codeplug data to/from XLSX format (ExcelJS) + * Exports and imports full codeplug data to/from a zipped JSON file (.neonplug) */ -// ExcelJS is CJS/UMD; resolve lazily when first needed so the chunk is fully loaded -import type { Worksheet } from 'exceljs'; - -let excelJSCache: Record | null = null; - -export async function loadExcelJS(): Promise> { - if (excelJSCache != null && typeof excelJSCache.Workbook === 'function') return excelJSCache; - const mod = await import('exceljs'); - const ns = mod as Record; - const fromDefault = ns?.default as Record | undefined; - if (fromDefault && typeof fromDefault.Workbook === 'function') { - excelJSCache = fromDefault; - return fromDefault; - } - if (typeof ns.Workbook === 'function') { - excelJSCache = ns; - return ns; - } - const global = typeof globalThis !== 'undefined' ? (globalThis as Record).ExcelJS : undefined; - if (global && typeof (global as Record).Workbook === 'function') { - excelJSCache = global as Record; - return excelJSCache; - } - throw new Error( - 'ExcelJS: Workbook not found. Restart the dev server (npm run dev). If it persists, delete node_modules/.vite and restart.' - ); -} +import JSZip from 'jszip'; import type { Channel } from '../models/Channel'; import type { Zone } from '../models/Zone'; import type { ScanList } from '../models/ScanList'; @@ -38,6 +12,12 @@ import type { DigitalEmergency, DigitalEmergencyConfig } from '../models/Digital import type { AnalogEmergency } from '../models/AnalogEmergency'; import type { RadioSettings } from '../models/RadioSettings'; import type { RadioInfo } from '../types/radio'; +import type { QuickTextMessage } from '../models/QuickTextMessage'; +import type { DMRRadioID } from '../models/DMRRadioID'; +import type { QuickContact } from '../models/QuickContact'; +import type { RXGroup } from '../models/RXGroup'; +import type { EncryptionKey } from '../models/EncryptionKey'; +import { generateZoneId } from '../utils/zoneHelpers'; export interface CodeplugData { channels: Channel[]; @@ -49,309 +29,131 @@ export interface CodeplugData { analogEmergencies: AnalogEmergency[]; radioSettings: RadioSettings | null; radioInfo: RadioInfo | null; + messages: QuickTextMessage[]; + radioIds: DMRRadioID[]; + quickContacts: QuickContact[]; + rxGroups: RXGroup[]; + encryptionKeys: EncryptionKey[]; exportDate: string; version: string; } const CODEPLUG_VERSION = '1.0.0'; +const CODEPLUG_JSON_FILENAME = 'codeplug.json'; -/** Convert ExcelJS worksheet to array of row objects (first row = headers). Exported for use by smartImporter. */ -export function sheetToJson(worksheet: Worksheet): Record[] { - const rows: Record[] = []; - const rowCount = worksheet.rowCount ?? 0; - if (rowCount < 2) return rows; - - const headerRow = worksheet.getRow(1); - const headers: string[] = []; - headerRow.eachCell({ includeEmpty: true }, (cell: { value: unknown }, colNumber: number) => { - const v = cell.value; - headers[colNumber - 1] = v != null ? String(v) : ''; - }); - - for (let r = 2; r <= rowCount; r++) { - const row = worksheet.getRow(r); - const obj: Record = {}; - headers.forEach((h, i) => { - const cell = row.getCell(i + 1); - const val = cell.value; - if (val != null && typeof val === 'object' && 'result' in val) { - obj[h] = (val as { result: unknown }).result; - } else { - obj[h] = val; - } - }); - rows.push(obj); - } - return rows; +/** Convert CodeplugData to a JSON-serializable object (Uint8Array → number[]) */ +function codeplugToJsonSafe(data: CodeplugData): Record { + return { + ...data, + channels: data.channels, + zones: data.zones, + scanLists: data.scanLists, + contacts: data.contacts, + digitalEmergencies: data.digitalEmergencies.map((de) => ({ + ...de, + fields: Array.from(de.fields), + })), + digitalEmergencyConfig: data.digitalEmergencyConfig + ? { + ...data.digitalEmergencyConfig, + entryArray: data.digitalEmergencyConfig.entryArray + ? Array.from(data.digitalEmergencyConfig.entryArray) + : undefined, + additionalConfig: data.digitalEmergencyConfig.additionalConfig + ? Array.from(data.digitalEmergencyConfig.additionalConfig) + : undefined, + } + : null, + analogEmergencies: data.analogEmergencies, + radioSettings: data.radioSettings, + radioInfo: data.radioInfo, + messages: data.messages ?? [], + radioIds: (data.radioIds ?? []).map((r) => ({ + ...r, + dmrIdBytes: Array.from(r.dmrIdBytes ?? new Uint8Array(0)), + })), + quickContacts: (data.quickContacts ?? []).map((q) => ({ + ...q, + rawData: Array.from(q.rawData ?? new Uint8Array(0)), + })), + rxGroups: data.rxGroups ?? [], + encryptionKeys: data.encryptionKeys ?? [], + exportDate: data.exportDate, + version: data.version, + }; } -/** Get worksheet as array of arrays (row-major), for Radio Settings style sheets */ -function sheetToArrays(worksheet: Worksheet): unknown[][] { - const out: unknown[][] = []; - const rowCount = worksheet.rowCount ?? 0; - for (let r = 1; r <= rowCount; r++) { - const row = worksheet.getRow(r); - const arr: unknown[] = []; - row.eachCell({ includeEmpty: true }, (cell: { value: unknown }) => { - const v = cell.value; - if (v != null && typeof v === 'object' && 'result' in v) { - arr.push((v as { result: unknown }).result); - } else { - arr.push(v); - } - }); - out.push(arr); - } - return out; +/** Parse JSON object back to CodeplugData (number[] → Uint8Array, ensure zone ids) */ +function jsonSafeToCodeplug(raw: Record): CodeplugData { + const dig = (raw.digitalEmergencies as Record[] | undefined) ?? []; + const config = raw.digitalEmergencyConfig as Record | null | undefined; + const radioIdsRaw = (raw.radioIds as Record[] | undefined) ?? []; + const quickContactsRaw = (raw.quickContacts as Record[] | undefined) ?? []; + return { + channels: (raw.channels as Channel[]) ?? [], + zones: ((raw.zones as Zone[]) ?? []).map((z) => ({ + ...z, + id: (z as Zone).id ?? generateZoneId(), + })), + scanLists: (raw.scanLists as ScanList[]) ?? [], + contacts: (raw.contacts as Contact[]) ?? [], + digitalEmergencies: dig.map((de) => ({ + ...de, + fields: new Uint8Array((de.fields as number[]) ?? []), + })) as DigitalEmergency[], + digitalEmergencyConfig: config + ? { + ...config, + entryArray: config.entryArray ? new Uint8Array(config.entryArray as number[]) : undefined, + additionalConfig: config.additionalConfig + ? new Uint8Array(config.additionalConfig as number[]) + : undefined, + } as DigitalEmergencyConfig + : null, + analogEmergencies: (raw.analogEmergencies as AnalogEmergency[]) ?? [], + radioSettings: (raw.radioSettings as RadioSettings | null) ?? null, + radioInfo: (raw.radioInfo as RadioInfo | null) ?? null, + messages: (raw.messages as QuickTextMessage[]) ?? [], + radioIds: radioIdsRaw.map((r) => ({ + ...r, + dmrIdBytes: new Uint8Array((r.dmrIdBytes as number[]) ?? []), + })) as DMRRadioID[], + quickContacts: quickContactsRaw.map((q) => ({ + ...q, + rawData: new Uint8Array((q.rawData as number[]) ?? []), + })) as QuickContact[], + rxGroups: (raw.rxGroups as RXGroup[]) ?? [], + encryptionKeys: (raw.encryptionKeys as EncryptionKey[]) ?? [], + exportDate: String(raw.exportDate ?? new Date().toISOString()), + version: String(raw.version ?? CODEPLUG_VERSION), + }; } /** - * Export codeplug data to XLSX file + * Export codeplug data to a zipped JSON file (.neonplug) * @param data Codeplug data to export * @param returnBlob If true, returns a Blob instead of downloading. For use in zip archives. */ export async function exportCodeplug(data: CodeplugData, returnBlob?: boolean): Promise { - const ExcelJS = (await loadExcelJS()) as any; - const workbook = new ExcelJS.Workbook(); - - // Sheet 1: Channels - if (data.channels.length > 0) { - const channelRows = data.channels.map(ch => ({ - 'Channel #': ch.number, - 'Name': ch.name, - 'RX Freq (MHz)': ch.rxFrequency, - 'TX Freq (MHz)': ch.txFrequency, - 'Mode': ch.mode, - 'Bandwidth': ch.bandwidth, - 'Power': ch.power, - 'RX CTCSS/DCS': ch.rxCtcssDcs.type === 'None' ? 'None' : - ch.rxCtcssDcs.type === 'CTCSS' ? `CTCSS ${ch.rxCtcssDcs.value ?? 0}` : - ch.rxCtcssDcs.type === 'DCS' ? `DCS ${ch.rxCtcssDcs.value ?? 0}${ch.rxCtcssDcs.polarity === 'P' ? 'P' : 'N'}` : 'None', - 'TX CTCSS/DCS': ch.txCtcssDcs.type === 'None' ? 'None' : - ch.txCtcssDcs.type === 'CTCSS' ? `CTCSS ${ch.txCtcssDcs.value ?? 0}` : - ch.txCtcssDcs.type === 'DCS' ? `DCS ${ch.txCtcssDcs.value ?? 0}${ch.txCtcssDcs.polarity === 'P' ? 'P' : 'N'}` : 'None', - 'Color Code': ch.colorCode ?? 0, - 'Contact ID': ch.contactId ?? 0, - 'Scan List': ch.scanListId, - 'Forbid TX': ch.forbidTx ? 'Yes' : 'No', - 'Forbid Talkaround': ch.forbidTalkaround ? 'Yes' : 'No', - 'Lone Worker': ch.loneWorker ? 'Yes' : 'No', - 'APRS Receive': ch.aprsReceive ? 'Yes' : 'No', - 'APRS Report': ch.aprsReportMode, - 'Squelch': ch.squelchLevel ?? 0, - 'Emergency ID': ch.emergencySystemId ?? 0, - 'Emergency': ch.emergencyIndicator ? 'Yes' : 'No', - 'Emergency Ack': ch.emergencyAck ? 'Yes' : 'No', - 'VOX': ch.voxFunction ? 'Yes' : 'No', - 'Scramble': ch.scramble ? 'Yes' : 'No', - 'Compander': ch.compander ? 'Yes' : 'No', - 'Talkback': ch.talkback ? 'Yes' : 'No', - 'PTT ID Display': ch.pttIdDisplay ? 'Yes' : 'No', - 'PTT ID': ch.pttId ?? 0, - 'PTT ID Type': ch.pttIdType, - 'RX Squelch Mode': ch.rxSquelchMode, - 'Step Frequency': ch.stepFrequency ?? 0, - 'Signaling Type': ch.signalingType, - 'Compander Dup': ch.companderDup ? 'Yes' : 'No', - 'VOX Related': ch.voxRelated ? 'Yes' : 'No', - })); - const headers = Object.keys(channelRows[0]!); - const ws = workbook.addWorksheet('Channels'); - const channelColWidths = [10, 20, 12, 12, 12, 10, 8, 15, 15, 10, 10, 10, 10, 15, 12, 12, 12, 10, 12, 10, 12, 8, 10, 10, 10, 12, 8, 12, 15, 12, 15, 12, 12]; - channelColWidths.forEach((w, i) => { ws.getColumn(i + 1).width = w; }); - ws.addRow(headers); - channelRows.forEach(row => ws.addRow(headers.map(h => (row as Record)[h]))); - } - - // Sheet 2: Zones - if (data.zones.length > 0) { - const zoneRows = data.zones.map(zone => ({ - 'Zone Name': zone.name, - 'Channel Count': zone.channels.length, - 'Channels': zone.channels.join(', '), - })); - const headers = Object.keys(zoneRows[0]!); - const ws = workbook.addWorksheet('Zones'); - ws.getColumn(1).width = 20; - ws.getColumn(2).width = 12; - ws.getColumn(3).width = 50; - ws.addRow(headers); - zoneRows.forEach(row => ws.addRow(headers.map(h => (row as Record)[h]))); - } - - // Sheet 3: Scan Lists - if (data.scanLists.length > 0) { - const scanListRows = data.scanLists.map(sl => ({ - 'Scan List Name': sl.name, - 'CTC Scan Mode': sl.ctcScanMode, - 'Scan TX Mode': sl.scanTxMode, - 'Hang Time (tenths)': sl.hangTime ?? '', - 'Priority 1 Type': sl.priority1Type ?? 0, - 'Priority 2 Type': sl.priority2Type ?? 0, - 'Priority Channel 1': sl.priorityChannel1 ?? '', - 'Priority Channel 2': sl.priorityChannel2 ?? '', - 'Designated TX Channel': sl.designatedTxChannel ?? '', - 'Channel Count': sl.channels.length, - 'Channels': sl.channels.join(', '), - })); - const headers = Object.keys(scanListRows[0]!); - const ws = workbook.addWorksheet('Scan Lists'); - [20, 12, 12, 12, 12, 18, 18, 20, 12, 50].forEach((w, i) => { ws.getColumn(i + 1).width = w; }); - ws.addRow(headers); - scanListRows.forEach(row => ws.addRow(headers.map(h => (row as Record)[h]))); - } - - // Sheet 4: Contacts - if (data.contacts.length > 0) { - const contactRows = data.contacts.map(contact => ({ - 'ID': contact.id, - 'Name': contact.name, - 'Call Sign': contact.callSign ?? '', - 'DMR ID': contact.dmrId ?? '', - })); - const headers = Object.keys(contactRows[0]!); - const ws = workbook.addWorksheet('Contacts'); - ws.getColumn(1).width = 8; - ws.getColumn(2).width = 25; - ws.getColumn(3).width = 15; - ws.getColumn(4).width = 12; - ws.addRow(headers); - contactRows.forEach(row => ws.addRow(headers.map(h => (row as Record)[h]))); - } - - // Sheet 5: Digital Emergency - if (data.digitalEmergencies.length > 0) { - const digitalEmergencyRows = data.digitalEmergencies.map(de => ({ - 'Index': de.index, - 'Name': de.name, - 'Fields (Hex)': Array.from(de.fields).map(b => b.toString(16).padStart(2, '0')).join(' ').toUpperCase(), - })); - const headers = Object.keys(digitalEmergencyRows[0]!); - const ws = workbook.addWorksheet('Digital Emergency'); - ws.addRow(headers); - digitalEmergencyRows.forEach(row => ws.addRow(headers.map(h => (row as Record)[h]))); - if (data.digitalEmergencyConfig) { - const configRows = [{ - 'Count/Index': data.digitalEmergencyConfig.countIndex, - 'Unknown': data.digitalEmergencyConfig.unknown, - 'Numeric Field 1': data.digitalEmergencyConfig.numericFields[0], - 'Numeric Field 2': data.digitalEmergencyConfig.numericFields[1], - 'Numeric Field 3': data.digitalEmergencyConfig.numericFields[2], - 'Byte Field 1': data.digitalEmergencyConfig.byteFields[0], - 'Byte Field 2': data.digitalEmergencyConfig.byteFields[1], - '16-bit Value 1': data.digitalEmergencyConfig.values16bit[0], - '16-bit Value 2': data.digitalEmergencyConfig.values16bit[1], - '16-bit Value 3': data.digitalEmergencyConfig.values16bit[2], - '16-bit Value 4': data.digitalEmergencyConfig.values16bit[3], - 'Bit Flags': data.digitalEmergencyConfig.bitFlags, - 'Index/Count': data.digitalEmergencyConfig.indexCount, - }]; - const configHeaders = Object.keys(configRows[0]!); - const wsConfig = workbook.addWorksheet('Digital Emergency Config'); - wsConfig.addRow(configHeaders); - wsConfig.addRow(configHeaders.map(h => (configRows[0] as Record)[h])); - } - } + const jsonSafe = codeplugToJsonSafe(data); + const jsonString = JSON.stringify(jsonSafe, null, 0); - // Sheet 6: Analog Emergency - if (data.analogEmergencies.length > 0) { - const analogEmergencyRows = data.analogEmergencies.map(ae => ({ - 'Index': ae.index, - 'Name': ae.name, - 'Enabled': ae.enabled ? 'Yes' : 'No', - 'Alarm Type': ae.alarmType, - 'Alarm Mode': ae.alarmMode, - 'Signalling': ae.signalling, - 'Revert Channel': ae.revertChannel, - 'Squelch Mode': ae.squelchMode, - 'ID Type': ae.idType, - 'Flags': ae.flags, - 'Frequency/ID': ae.frequencyId, - })); - const headers = Object.keys(analogEmergencyRows[0]!); - const ws = workbook.addWorksheet('Analog Emergency'); - ws.addRow(headers); - analogEmergencyRows.forEach(row => ws.addRow(headers.map(h => (row as Record)[h]))); - } - - // Sheet 7: Radio Settings (array of arrays) - if (data.radioSettings) { - const radioSettingsRows = [ - ['Field', 'Value'], - ['Power On Display Line 1', data.radioSettings.powerOnDisplayLine1], - ['Power On Display Line 2', data.radioSettings.powerOnDisplayLine2], - ['Unknown Flag (0x00)', data.radioSettings.unknownFlag], - ['Allow Reset (0x1D)', data.radioSettings.allowReset ? 'Yes' : 'No'], - ['Power On Interface (0x1E)', data.radioSettings.powerOnInterface], - ['Alert Tone Flags (0x20)', data.radioSettings.alertToneFlags], - ['Alert Tone Flags Cont (0x21)', data.radioSettings.alertToneFlagsCont], - ['Unknown Radio Setting (0x301)', data.radioSettings.unknownRadioSetting], - ['Radio Enabled (0x302)', data.radioSettings.radioEnabled ? 'Yes' : 'No'], - ['Latitude', data.radioSettings.latitude], - ['Latitude Direction', data.radioSettings.latitudeDirection], - ['Longitude', data.radioSettings.longitude], - ['Longitude Direction', data.radioSettings.longitudeDirection], - ['Current Channel A', data.radioSettings.currentChannelA > 0 ? data.radioSettings.currentChannelA : 'None'], - ['Current Channel B', data.radioSettings.currentChannelB > 0 ? data.radioSettings.currentChannelB : 'None'], - ['Channel Setting 3', data.radioSettings.channelSetting3], - ['Channel Setting 4', data.radioSettings.channelSetting4], - ['Channel Setting 5', data.radioSettings.channelSetting5], - ['Channel Setting 6', data.radioSettings.channelSetting6], - ['Channel Setting 7', data.radioSettings.channelSetting7], - ['Channel Setting 8', data.radioSettings.channelSetting8], - ['Current Zone', data.radioSettings.currentZone > 0 ? data.radioSettings.currentZone : 'None'], - ['Zone Enabled', data.radioSettings.zoneEnabled ? 'Yes' : 'No'], - ['Unknown Value (0x332)', data.radioSettings.unknownValue], - ]; - const ws = workbook.addWorksheet('Radio Settings'); - ws.getColumn(1).width = 30; - ws.getColumn(2).width = 25; - ws.addRows(radioSettingsRows); - } - - // Sheet 8: Radio Info - if (data.radioInfo) { - const radioInfoRows = [{ - 'Model': data.radioInfo.model ?? '', - 'Firmware': data.radioInfo.firmware ?? '', - 'Build Date': data.radioInfo.buildDate ?? '', - 'DSP Version': data.radioInfo.dspVersion ?? '', - 'Radio Version': data.radioInfo.radioVersion ?? '', - 'Codeplug Version': data.radioInfo.codeplugVersion ?? '', - 'Config Start': data.radioInfo.memoryLayout?.configStart != null ? `0x${data.radioInfo.memoryLayout.configStart.toString(16)}` : '', - 'Config End': data.radioInfo.memoryLayout?.configEnd != null ? `0x${data.radioInfo.memoryLayout.configEnd.toString(16)}` : '', - }]; - const headers = Object.keys(radioInfoRows[0]!); - const ws = workbook.addWorksheet('Radio Info'); - ws.addRow(headers); - ws.addRow(headers.map(h => (radioInfoRows[0] as Record)[h])); - } - - // Sheet 9: Export Info - const metadataRows = [{ - 'Export Date': data.exportDate, - 'Codeplug Version': data.version, - 'Channel Count': data.channels.length, - 'Zone Count': data.zones.length, - 'Scan List Count': data.scanLists.length, - 'Contact Count': data.contacts.length, - 'Digital Emergency Count': data.digitalEmergencies.length, - 'Analog Emergency Count': data.analogEmergencies.length, - }]; - const metaHeaders = Object.keys(metadataRows[0]!); - const wsMeta = workbook.addWorksheet('Export Info'); - wsMeta.addRow(metaHeaders); - wsMeta.addRow(metaHeaders.map(h => (metadataRows[0] as Record)[h])); - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); - const filename = `codeplug-export-${timestamp}.xlsx`; + const zip = new JSZip(); + zip.file(CODEPLUG_JSON_FILENAME, jsonString); - const buffer = await workbook.xlsx.writeBuffer(); - const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + const blob = await zip.generateAsync({ + type: 'blob', + mimeType: 'application/zip', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); if (returnBlob) { return blob; } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const filename = `codeplug-export-${timestamp}.neonplug`; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -363,326 +165,24 @@ export async function exportCodeplug(data: CodeplugData, returnBlob?: boolean): } /** - * Import codeplug data from XLSX file + * Import codeplug data from a .neonplug file (zip containing codeplug.json) */ export async function importCodeplug(file: File): Promise { - const ExcelJS = (await loadExcelJS()) as any; const buffer = await file.arrayBuffer(); - const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(buffer); - - const result: CodeplugData = { - channels: [], - zones: [], - scanLists: [], - contacts: [], - digitalEmergencies: [], - digitalEmergencyConfig: null, - analogEmergencies: [], - radioSettings: null, - radioInfo: null, - exportDate: new Date().toISOString(), - version: CODEPLUG_VERSION, - }; - - const getSheet = (name: string) => workbook.getWorksheet(name); - - // Import Channels - const channelsSheet = getSheet('Channels'); - if (channelsSheet) { - const rows = sheetToJson(channelsSheet) as Record[]; - const parseCTCSSDCS = (str: string) => { - if (!str || str === 'None') return { type: 'None' as const }; - const ctcssMatch = String(str).match(/CTCSS\s+(\d+\.?\d*)/); - if (ctcssMatch) { - return { type: 'CTCSS' as const, value: parseFloat(ctcssMatch[1]) }; - } - const dcsMatch = String(str).match(/DCS\s+(\d+)([NP])?/); - if (dcsMatch) { - return { type: 'DCS' as const, value: parseInt(dcsMatch[1], 10), polarity: (dcsMatch[2] === 'P' ? 'P' : 'N') as 'N' | 'P' }; - } - return { type: 'None' as const }; - }; - result.channels = rows.map(row => { - const rxCTCSSDCS = parseCTCSSDCS(String(row['RX CTCSS/DCS'] ?? '')); - const txCTCSSDCS = parseCTCSSDCS(String(row['TX CTCSS/DCS'] ?? '')); - return { - number: Number(row['Channel #'] ?? row['Channel Number'] ?? 0), - name: String(row['Name'] ?? ''), - rxFrequency: parseFloat(String(row['RX Freq (MHz)'] ?? row['RX Frequency (MHz)'] ?? '0')) || 0, - txFrequency: parseFloat(String(row['TX Freq (MHz)'] ?? row['TX Frequency (MHz)'] ?? '0')) || 0, - mode: String(row['Mode'] ?? 'Analog'), - bandwidth: String(row['Bandwidth'] ?? '12.5kHz'), - rxCtcssDcs: rxCTCSSDCS, - txCtcssDcs: txCTCSSDCS, - power: String(row['Power'] ?? 'High'), - scanListId: parseInt(String(row['Scan List'] ?? row['Scan List ID'] ?? '0'), 10) || 0, - forbidTalkaround: row['Forbid Talkaround'] === 'Yes', - forbidTx: row['Forbid TX'] === 'Yes', - loneWorker: row['Lone Worker'] === 'Yes', - aprsReceive: row['APRS Receive'] === 'Yes', - aprsReportMode: String(row['APRS Report'] ?? row['APRS Report Mode'] ?? 'Off'), - contactId: parseInt(String(row['Contact ID'] ?? '0'), 10) || 0, - colorCode: parseInt(String(row['Color Code'] ?? '0'), 10) || 0, - squelchLevel: parseInt(String(row['Squelch'] ?? row['Squelch Level'] ?? '3'), 10) || 3, - emergencySystemId: parseInt(String(row['Emergency ID'] ?? row['Emergency System ID'] ?? '0'), 10) || 0, - emergencyIndicator: row['Emergency'] === 'Yes', - emergencyAck: row['Emergency Ack'] === 'Yes', - voxFunction: row['VOX'] === 'Yes', - scramble: row['Scramble'] === 'Yes', - compander: row['Compander'] === 'Yes', - talkback: row['Talkback'] === 'Yes', - pttIdDisplay: row['PTT ID Display'] === 'Yes', - pttId: parseInt(String(row['PTT ID'] ?? '0'), 10) || 0, - companderDup: row['Compander Dup'] === 'Yes', - voxRelated: row['VOX Related'] === 'Yes', - rxSquelchMode: String(row['RX Squelch Mode'] ?? 'Carrier/CTC'), - stepFrequency: parseInt(String(row['Step Frequency'] ?? '0'), 10) || 0, - signalingType: String(row['Signaling Type'] ?? 'None'), - pttIdType: String(row['PTT ID Type'] ?? 'Off'), - scanAdd: false, - } as Channel; - }); - } - - // Import Zones - const zonesSheet = getSheet('Zones'); - if (zonesSheet) { - const rows = sheetToJson(zonesSheet) as Record[]; - result.zones = rows.map(row => ({ - name: String(row['Zone Name'] ?? ''), - channels: String(row['Channels'] ?? '').split(',').map((c: string) => parseInt(c.trim(), 10)).filter((n: number) => !isNaN(n)), - } as Zone)); - } - - // Import Scan Lists - const scanListsSheet = getSheet('Scan Lists'); - if (scanListsSheet) { - const rows = sheetToJson(scanListsSheet) as Record[]; - result.scanLists = rows.map(row => { - const priorityCh1 = row['Priority Channel 1']; - const priorityCh2 = row['Priority Channel 2']; - const designatedTx = row['Designated TX Channel']; - return { - name: String(row['Scan List Name'] ?? ''), - ctcScanMode: parseInt(String(row['CTC Scan Mode'] ?? '0'), 10) || 0, - scanTxMode: parseInt(String(row['Scan TX Mode'] ?? '0'), 10) || 0, - hangTime: row['Hang Time (tenths)'] != null && row['Hang Time (tenths)'] !== '' ? parseInt(String(row['Hang Time (tenths)']), 10) : undefined, - priority1Type: row['Priority 1 Type'] != null ? parseInt(String(row['Priority 1 Type']), 10) : 0, - priority2Type: row['Priority 2 Type'] != null ? parseInt(String(row['Priority 2 Type']), 10) : 0, - priorityChannel1: priorityCh1 != null && priorityCh1 !== '' ? parseInt(String(priorityCh1), 10) : undefined, - priorityChannel2: priorityCh2 != null && priorityCh2 !== '' ? parseInt(String(priorityCh2), 10) : undefined, - designatedTxChannel: designatedTx != null && designatedTx !== '' ? parseInt(String(designatedTx), 10) : undefined, - channels: String(row['Channels'] ?? '').split(',').map((c: string) => parseInt(c.trim(), 10)).filter((n: number) => !isNaN(n)), - } as ScanList; - }); - } - - // Import Contacts - const contactsSheet = getSheet('Contacts'); - if (contactsSheet) { - const rows = sheetToJson(contactsSheet) as Record[]; - result.contacts = rows.map(row => ({ - id: parseInt(String(row['ID'] ?? '0'), 10) || 0, - name: String(row['Name'] ?? ''), - callSign: String(row['Call Sign'] ?? ''), - dmrId: parseInt(String(row['DMR ID'] ?? '0'), 10) || 0, - } as Contact)); - } - - // Import Digital Emergency - const digitalEmergencySheet = getSheet('Digital Emergency'); - if (digitalEmergencySheet) { - const rows = sheetToJson(digitalEmergencySheet) as Record[]; - result.digitalEmergencies = rows.map(row => { - const fieldsHex = String(row['Fields (Hex)'] ?? '').replace(/[^0-9A-Fa-f]/g, '').slice(0, 20); - const fields = new Uint8Array(10); - for (let i = 0; i < fieldsHex.length && i < 20; i += 2) { - const hexByte = fieldsHex.slice(i, i + 2); - if (hexByte.length === 2) fields[i / 2] = parseInt(hexByte, 16); - } - return { - index: parseInt(String(row['Index'] ?? '0'), 10) || 0, - name: String(row['Name'] ?? ''), - fields, - }; - }); - } - - // Import Digital Emergency Config - const digitalConfigSheet = getSheet('Digital Emergency Config'); - if (digitalConfigSheet) { - const rows = sheetToJson(digitalConfigSheet) as Record[]; - if (rows.length > 0) { - const row = rows[0]; - result.digitalEmergencyConfig = { - countIndex: parseInt(String(row['Count/Index'] ?? '0'), 10) || 0, - unknown: parseInt(String(row['Unknown'] ?? '0'), 10) || 0, - numericFields: [ - parseInt(String(row['Numeric Field 1'] ?? '0'), 10) || 0, - parseInt(String(row['Numeric Field 2'] ?? '0'), 10) || 0, - parseInt(String(row['Numeric Field 3'] ?? '0'), 10) || 0, - ] as [number, number, number], - byteFields: [ - parseInt(String(row['Byte Field 1'] ?? '0'), 10) || 0, - parseInt(String(row['Byte Field 2'] ?? '0'), 10) || 0, - ] as [number, number], - values16bit: [ - parseInt(String(row['16-bit Value 1'] ?? '0'), 10) || 0, - parseInt(String(row['16-bit Value 2'] ?? '0'), 10) || 0, - parseInt(String(row['16-bit Value 3'] ?? '0'), 10) || 0, - parseInt(String(row['16-bit Value 4'] ?? '0'), 10) || 0, - ] as [number, number, number, number], - bitFlags: parseInt(String(row['Bit Flags'] ?? '0'), 10) || 0, - indexCount: parseInt(String(row['Index/Count'] ?? '0'), 10) || 0, - entryArray: [], - additionalConfig: new Uint8Array(192), - }; - } - } - - // Import Analog Emergency - const analogSheet = getSheet('Analog Emergency'); - if (analogSheet) { - const rows = sheetToJson(analogSheet) as Record[]; - result.analogEmergencies = rows.map(row => ({ - index: parseInt(String(row['Index'] ?? '0'), 10) || 0, - name: String(row['Name'] ?? ''), - enabled: row['Enabled'] === 'Yes', - alarmType: parseInt(String(row['Alarm Type'] ?? '0'), 10) || 0, - alarmMode: parseInt(String(row['Alarm Mode'] ?? '0'), 10) || 0, - signalling: parseInt(String(row['Signalling'] ?? '0'), 10) || 0, - revertChannel: parseInt(String(row['Revert Channel'] ?? '0'), 10) || 0, - squelchMode: parseInt(String(row['Squelch Mode'] ?? '0'), 10) || 0, - idType: parseInt(String(row['ID Type'] ?? '0'), 10) || 0, - flags: parseInt(String(row['Flags'] ?? '0'), 10) || 0, - frequencyId: parseInt(String(row['Frequency/ID'] ?? '0'), 10) || 0, - } as AnalogEmergency)); - } - - // Import Radio Settings (row layout) - const radioSettingsSheet = getSheet('Radio Settings') ?? getSheet('VFO Settings'); - if (radioSettingsSheet) { - const rows = sheetToArrays(radioSettingsSheet) as (string | number)[][]; - const firstCell = rows[0]?.[0]; - const settingsData: Record = {}; - if (firstCell === 'Field' && rows.length > 1) { - for (let i = 1; i < rows.length; i++) { - const [field, value] = rows[i] ?? []; - if (field && value !== undefined && value !== '') { - const f = String(field); - const v = value; - if (f.includes('Power On Display Line 1')) settingsData.powerOnDisplayLine1 = String(v); - else if (f.includes('Power On Display Line 2')) settingsData.powerOnDisplayLine2 = String(v); - else if (f.includes('Unknown Flag')) settingsData.unknownFlag = parseInt(String(v), 10) || 0; - else if (f.includes('Allow Reset')) settingsData.allowReset = String(v).toLowerCase() === 'yes'; - else if (f.includes('Power On Interface')) settingsData.powerOnInterface = parseInt(String(v), 10) || 0; - else if (f.includes('Alert Tone Flags') && !f.includes('Cont')) settingsData.alertToneFlags = parseInt(String(v), 10) || 0; - else if (f.includes('Alert Tone Flags Cont')) settingsData.alertToneFlagsCont = parseInt(String(v), 10) || 0; - else if (f.includes('Unknown Radio Setting')) settingsData.unknownRadioSetting = parseInt(String(v), 10) || 0; - else if (f.includes('Radio Enabled')) settingsData.radioEnabled = String(v).toLowerCase() === 'yes'; - else if (f === 'Latitude') settingsData.latitude = String(v); - else if (f === 'Latitude Direction') settingsData.latitudeDirection = String(v) === 'S' ? 'S' : 'N'; - else if (f === 'Longitude') settingsData.longitude = String(v); - else if (f === 'Longitude Direction') settingsData.longitudeDirection = String(v) === 'W' ? 'W' : 'E'; - else if (f === 'Current Channel A') settingsData.currentChannelA = String(v) === 'None' ? 0 : parseInt(String(v), 10) || 0; - else if (f === 'Current Channel B') settingsData.currentChannelB = String(v) === 'None' ? 0 : parseInt(String(v), 10) || 0; - else if (f === 'Channel Setting 3') settingsData.channelSetting3 = parseInt(String(v), 10) || 0; - else if (f === 'Channel Setting 4') settingsData.channelSetting4 = parseInt(String(v), 10) || 0; - else if (f === 'Channel Setting 5') settingsData.channelSetting5 = parseInt(String(v), 10) || 0; - else if (f === 'Channel Setting 6') settingsData.channelSetting6 = parseInt(String(v), 10) || 0; - else if (f === 'Channel Setting 7') settingsData.channelSetting7 = parseInt(String(v), 10) || 0; - else if (f === 'Channel Setting 8') settingsData.channelSetting8 = parseInt(String(v), 10) || 0; - else if (f === 'Current Zone') settingsData.currentZone = String(v) === 'None' ? 0 : parseInt(String(v), 10) || 0; - else if (f === 'Zone Enabled') settingsData.zoneEnabled = String(v).toLowerCase() === 'yes'; - else if (f.includes('Unknown Value')) settingsData.unknownValue = String(v); - } - } - } - result.radioSettings = { - unknownFlag: (settingsData.unknownFlag as number) ?? 0, - powerOnDisplayLine1: (settingsData.powerOnDisplayLine1 as string) ?? '', - powerOnDisplayLine2: (settingsData.powerOnDisplayLine2 as string) ?? '', - allowReset: (settingsData.allowReset as boolean) ?? false, - powerOnInterface: (settingsData.powerOnInterface as number) ?? 0, - alertToneFlags: (settingsData.alertToneFlags as number) ?? 0, - alertToneFlagsCont: (settingsData.alertToneFlagsCont as number) ?? 0, - channelAColor: (settingsData.channelAColor as number) ?? 0, - channelBColor: (settingsData.channelBColor as number) ?? 0, - unknownDisplay: (settingsData.unknownDisplay as number) ?? 0, - displayFlags: (settingsData.displayFlags as number) ?? 0, - backlightBrightness: (settingsData.backlightBrightness as number) ?? 3, - autoBacklightDuration: (settingsData.autoBacklightDuration as number) ?? 10, - menuExitTime: (settingsData.menuExitTime as number) ?? 5, - standbyCharacterColor1: (settingsData.standbyCharacterColor1 as number) ?? 0, - standbyCharacterColor2: (settingsData.standbyCharacterColor2 as number) ?? 0, - zoneAColor: (settingsData.zoneAColor as number) ?? 0, - zoneBColor: (settingsData.zoneBColor as number) ?? 0, - workModeFlags: (settingsData.workModeFlags as number) ?? 0, - utcZone: (settingsData.utcZone as number) ?? 0, - measurePeriodInterval: (settingsData.measurePeriodInterval as number) ?? 5, - unknownFlags: (settingsData.unknownFlags as number) ?? 0, - gpsAprsFlags: (settingsData.gpsAprsFlags as number) ?? 0, - callHoldTime: (settingsData.callHoldTime as number) ?? 0, - activeWaitTime: (settingsData.activeWaitTime as number) ?? 1, - activeRetriesTime: (settingsData.activeRetriesTime as number) ?? 1, - preCarrierTime: (settingsData.preCarrierTime as number) ?? 0, - digitalSettingsFlags: (settingsData.digitalSettingsFlags as number) ?? 0, - remoteMonitorTime: (settingsData.remoteMonitorTime as number) ?? 0, - digitalSettingsCont: (settingsData.digitalSettingsCont as number) ?? 0, - vfoEmbeddedFlags: (settingsData.vfoEmbeddedFlags as number) ?? 0, - txDwellTime: (settingsData.txDwellTime as number) ?? 0, - languageOtherSettings: (settingsData.languageOtherSettings as Uint8Array) ?? new Uint8Array(8), - unknownRadioSetting: (settingsData.unknownRadioSetting as number) ?? 0, - radioEnabled: (settingsData.radioEnabled as boolean) ?? false, - latitude: (settingsData.latitude as string) ?? '', - latitudeDirection: (settingsData.latitudeDirection as string) ?? 'N', - longitude: (settingsData.longitude as string) ?? '', - longitudeDirection: (settingsData.longitudeDirection as string) ?? 'E', - currentChannelA: (settingsData.currentChannelA as number) ?? 0, - currentChannelB: (settingsData.currentChannelB as number) ?? 0, - channelSetting3: (settingsData.channelSetting3 as number) ?? 0, - channelSetting4: (settingsData.channelSetting4 as number) ?? 0, - channelSetting5: (settingsData.channelSetting5 as number) ?? 0, - channelSetting6: (settingsData.channelSetting6 as number) ?? 0, - channelSetting7: (settingsData.channelSetting7 as number) ?? 0, - channelSetting8: (settingsData.channelSetting8 as number) ?? 0, - currentZone: (settingsData.currentZone as number) ?? 0, - zoneEnabled: (settingsData.zoneEnabled as boolean) ?? false, - unknownValue: (settingsData.unknownValue as string) ?? '000000', - } as RadioSettings; - } + const zip = await JSZip.loadAsync(buffer); - // Import Radio Info - const radioInfoSheet = getSheet('Radio Info'); - if (radioInfoSheet) { - const rows = sheetToJson(radioInfoSheet) as Record[]; - if (rows.length > 0) { - const row = rows[0]; - const parseHex = (str: string) => { - if (!str) return undefined; - const match = String(str).match(/0x([0-9a-fA-F]+)/); - return match ? parseInt(match[1], 16) : undefined; - }; - const configStart = parseHex(String(row['Config Start'] ?? '')); - const configEnd = parseHex(String(row['Config End'] ?? '')); - result.radioInfo = { - model: String(row['Model'] ?? ''), - firmware: String(row['Firmware'] ?? ''), - buildDate: String(row['Build Date'] ?? ''), - dspVersion: row['DSP Version'] != null ? String(row['DSP Version']) : undefined, - radioVersion: row['Radio Version'] != null ? String(row['Radio Version']) : undefined, - codeplugVersion: row['Codeplug Version'] != null ? String(row['Codeplug Version']) : undefined, - ...(configStart !== undefined && configEnd !== undefined && { memoryLayout: { configStart, configEnd } }), - } as RadioInfo; - } + const entry = zip.file(CODEPLUG_JSON_FILENAME); + if (!entry) { + throw new Error(`Invalid codeplug file: missing ${CODEPLUG_JSON_FILENAME}`); } - return result; + const text = await entry.async('string'); + const raw = JSON.parse(text) as Record; + return jsonSafeToCodeplug(raw); } /** - * Get codeplug data from all stores + * Get codeplug data from all stores (stub - callers build from stores when needed) */ export function getCodeplugDataFromStores(): CodeplugData { return { @@ -695,6 +195,11 @@ export function getCodeplugDataFromStores(): CodeplugData { analogEmergencies: [], radioSettings: null, radioInfo: null, + messages: [], + radioIds: [], + quickContacts: [], + rxGroups: [], + encryptionKeys: [], exportDate: new Date().toISOString(), version: CODEPLUG_VERSION, }; diff --git a/src/services/smartImporter.ts b/src/services/smartImporter.ts index 38ab8d5..721ccb9 100644 --- a/src/services/smartImporter.ts +++ b/src/services/smartImporter.ts @@ -1,16 +1,12 @@ /** * Smart Codeplug Importer - * Enhanced importer with validation, error reporting, and flexible field matching + * Imports from the app's codeplug format (.neonplug = zip with codeplug.json) and returns a structured result. */ -import { sheetToJson, loadExcelJS } from './codeplugExport'; -import type { Worksheet } from 'exceljs'; -import type { Channel, Zone, ScanList, Contact } from '../models'; +import { importCodeplug } from './codeplugExport'; import type { CodeplugData } from './codeplugExport'; import { generateZoneId } from '../utils/zoneHelpers'; -const CODEPLUG_VERSION = '1.0.0'; - export interface ImportResult { data: CodeplugData; warnings: ImportWarning[]; @@ -46,454 +42,101 @@ export interface ImportSummary { export interface ImportOptions { onProgress?: (progress: number, message: string) => void; - strictMode?: boolean; // If true, fail on errors instead of continuing - autoCorrect?: boolean; // If true, attempt to auto-correct common issues - validateRanges?: boolean; // If true, validate data ranges -} - -/** - * Smart column name matcher - finds columns by fuzzy matching - */ -function findColumn(columns: string[], patterns: string[]): string | null { - const normalizedColumns = columns.map(c => c.toLowerCase().trim()); - - for (const pattern of patterns) { - const normalizedPattern = pattern.toLowerCase().trim(); - - // Exact match - const exactIndex = normalizedColumns.indexOf(normalizedPattern); - if (exactIndex >= 0) { - return columns[exactIndex]; - } - - // Contains match - for (let i = 0; i < normalizedColumns.length; i++) { - if (normalizedColumns[i].includes(normalizedPattern) || normalizedPattern.includes(normalizedColumns[i])) { - return columns[i]; - } - } - } - - return null; -} - -/** - * Get all column names from first row of an ExcelJS worksheet - */ -function getColumnNames(worksheet: Worksheet): string[] { - const columns: string[] = []; - const headerRow = worksheet.getRow(1); - headerRow.eachCell({ includeEmpty: true }, (cell: { value: unknown }) => { - const v = cell.value; - if (v != null && typeof v === 'object' && 'result' in v) { - columns.push(String((v as { result: unknown }).result)); - } else { - columns.push(v != null ? String(v) : ''); - } - }); - return columns; -} - -/** - * Validate frequency range - */ -function validateFrequency(freq: number, field: string): { valid: boolean; corrected?: number; warning?: string } { - if (isNaN(freq) || freq <= 0) { - return { valid: false, warning: `${field} is invalid: ${freq}` }; - } - - // Common ham radio bands - if (freq < 136 || freq > 174) { - if (freq < 400 || freq > 480) { - return { valid: true, warning: `${field} (${freq} MHz) is outside common ham bands` }; - } - } - - return { valid: true }; + strictMode?: boolean; + autoCorrect?: boolean; + validateRanges?: boolean; } /** - * Validate channel number - */ -function validateChannelNumber(num: number): { valid: boolean; corrected?: number; warning?: string } { - if (isNaN(num) || num < 1 || num > 4000) { - return { valid: false, warning: `Channel number ${num} is out of range (1-4000)` }; - } - - return { valid: true }; -} - -/** - * Parse CTCSS/DCS with validation - */ -function parseCTCSSDCS(str: string | undefined, field: string): { - result: { type: 'None' } | { type: 'CTCSS'; value: number } | { type: 'DCS'; value: number; polarity: 'N' | 'P' }; - warning?: string; -} { - if (!str || str === 'None' || str === '' || str === '0') { - return { result: { type: 'None' } }; - } - - const ctcssMatch = String(str).match(/CTCSS\s*(\d+\.?\d*)/i); - if (ctcssMatch) { - const value = parseFloat(ctcssMatch[1]); - if (value >= 67.0 && value <= 254.1) { - return { result: { type: 'CTCSS', value } }; - } else { - return { - result: { type: 'None' }, - warning: `${field} CTCSS value ${value} is out of range (67.0-254.1 Hz)`, - }; - } - } - - const dcsMatch = String(str).match(/DCS\s*(\d+)([NP])?/i); - if (dcsMatch) { - const value = parseInt(dcsMatch[1], 10); - if (value >= 1 && value <= 754) { - return { - result: { - type: 'DCS', - value, - polarity: (dcsMatch[2]?.toUpperCase() === 'P' ? 'P' : 'N') as 'N' | 'P', - }, - }; - } else { - return { - result: { type: 'None' }, - warning: `${field} DCS value ${value} is out of range (1-754)`, - }; - } - } - - return { - result: { type: 'None' }, - warning: `${field} could not parse CTCSS/DCS value: ${str}`, - }; -} - -/** - * Smart codeplug importer with validation and error reporting + * Import codeplug from a .neonplug file (zipped JSON). + * Returns the same ImportResult shape for compatibility with any UI that uses smart import. */ export async function smartImportCodeplug( file: File, options: ImportOptions = {} ): Promise { - const { onProgress, strictMode = false, validateRanges = true } = options; - + const { onProgress } = options; const warnings: ImportWarning[] = []; const errors: ImportError[] = []; onProgress?.(0, 'Reading file...'); - const ExcelJS = (await loadExcelJS()) as any; - const buffer = await file.arrayBuffer(); - onProgress?.(10, 'Parsing Excel file...'); - const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(buffer); - - const result: CodeplugData = { - channels: [], - zones: [], - scanLists: [], - contacts: [], - digitalEmergencies: [], - digitalEmergencyConfig: null, - analogEmergencies: [], - radioSettings: null, - radioInfo: null, - exportDate: new Date().toISOString(), - version: CODEPLUG_VERSION, - }; - - const summary: ImportSummary = { - channels: { total: 0, valid: 0, warnings: 0, errors: 0 }, - zones: { total: 0, valid: 0, warnings: 0, errors: 0 }, - scanLists: { total: 0, valid: 0, warnings: 0, errors: 0 }, - contacts: { total: 0, valid: 0, warnings: 0, errors: 0 }, - sheets: { found: [], missing: [] }, - }; - - const sheetNames = workbook.worksheets.map((ws: { name: string }) => ws.name); - const expectedSheets = ['Channels', 'Zones', 'Scan Lists', 'Contacts', 'Digital Emergency', 'Analog Emergency', 'Radio Settings', 'Radio Info']; - summary.sheets.found = sheetNames.filter((name: string) => expectedSheets.includes(name)); - summary.sheets.missing = expectedSheets.filter((name: string) => !sheetNames.includes(name)); - - for (const sheetName of summary.sheets.missing) { - warnings.push({ - type: 'missing_sheet', - sheet: sheetName, - message: `Sheet "${sheetName}" not found in file`, - }); - } - - onProgress?.(20, 'Importing channels...'); - - const channelsSheet = workbook.getWorksheet('Channels'); - if (channelsSheet) { - const columns = getColumnNames(channelsSheet); - const rows = sheetToJson(channelsSheet) as Record[]; - summary.channels.total = rows.length; - - for (let i = 0; i < rows.length; i++) { - const row = rows[i]!; - const rowNum = i + 2; - - try { - const channelNumCol = findColumn(columns, ['Channel #', 'Channel Number', 'Channel', '#', 'Number']); - const nameCol = findColumn(columns, ['Name', 'Channel Name']); - const rxFreqCol = findColumn(columns, ['RX Freq (MHz)', 'RX Frequency (MHz)', 'RX Freq', 'RX Frequency', 'Receive Frequency']); - const txFreqCol = findColumn(columns, ['TX Freq (MHz)', 'TX Frequency (MHz)', 'TX Freq', 'TX Frequency', 'Transmit Frequency']); - const modeCol = findColumn(columns, ['Mode', 'Channel Mode']); - const rxCtcssCol = findColumn(columns, ['RX CTCSS/DCS', 'RX CTCSS', 'RX DCS', 'Receive CTCSS/DCS']); - const txCtcssCol = findColumn(columns, ['TX CTCSS/DCS', 'TX CTCSS', 'TX DCS', 'Transmit CTCSS/DCS']); - - const channelNum = parseInt(String(row[channelNumCol ?? ''] ?? row['Channel #'] ?? row['Channel Number'] ?? '0'), 10); - const channelValidation = validateChannelNumber(channelNum); - - if (!channelValidation.valid) { - if (strictMode) { - errors.push({ - type: 'validation_error', - sheet: 'Channels', - row: rowNum, - field: 'Channel Number', - message: channelValidation.warning ?? 'Invalid channel number', - }); - continue; - } else { - warnings.push({ - type: 'invalid_value', - sheet: 'Channels', - row: rowNum, - field: 'Channel Number', - message: channelValidation.warning ?? 'Invalid channel number', - originalValue: channelNum, - }); - } - } - - const rxFreq = parseFloat(String(row[rxFreqCol ?? ''] ?? row['RX Freq (MHz)'] ?? row['RX Frequency (MHz)'] ?? '0')); - const txFreq = parseFloat(String(row[txFreqCol ?? ''] ?? row['TX Freq (MHz)'] ?? row['TX Frequency (MHz)'] ?? '0')); - - let rxValidation: { valid: boolean; warning?: string } = { valid: true }; - let txValidation: { valid: boolean; warning?: string } = { valid: true }; - - if (validateRanges) { - rxValidation = validateFrequency(rxFreq, 'RX Frequency'); - txValidation = validateFrequency(txFreq, 'TX Frequency'); - - if (rxValidation.warning) { - warnings.push({ - type: 'invalid_value', - sheet: 'Channels', - row: rowNum, - field: 'RX Frequency', - message: rxValidation.warning, - originalValue: rxFreq, - }); - } - - if (txValidation.warning) { - warnings.push({ - type: 'invalid_value', - sheet: 'Channels', - row: rowNum, - field: 'TX Frequency', - message: txValidation.warning, - originalValue: txFreq, - }); - } - } - - const rxCTCSSDCS = parseCTCSSDCS(String(row[rxCtcssCol ?? ''] ?? row['RX CTCSS/DCS'] ?? ''), 'RX'); - const txCTCSSDCS = parseCTCSSDCS(String(row[txCtcssCol ?? ''] ?? row['TX CTCSS/DCS'] ?? ''), 'TX'); - - if (rxCTCSSDCS.warning) { - warnings.push({ - type: 'invalid_value', - sheet: 'Channels', - row: rowNum, - field: 'RX CTCSS/DCS', - message: rxCTCSSDCS.warning, - originalValue: row[rxCtcssCol ?? ''] ?? row['RX CTCSS/DCS'], - }); - } - - if (txCTCSSDCS.warning) { - warnings.push({ - type: 'invalid_value', - sheet: 'Channels', - row: rowNum, - field: 'TX CTCSS/DCS', - message: txCTCSSDCS.warning, - originalValue: row[txCtcssCol ?? ''] ?? row['TX CTCSS/DCS'], - }); - } - - const channel: Channel = { - number: channelNum, - name: String(row[nameCol ?? ''] ?? row['Name'] ?? ''), - rxFrequency: rxFreq, - txFrequency: txFreq, - mode: (row[modeCol ?? ''] ?? row['Mode'] ?? 'Analog') as Channel['mode'], - bandwidth: (row['Bandwidth'] ?? '12.5kHz') as Channel['bandwidth'], - rxCtcssDcs: rxCTCSSDCS.result, - txCtcssDcs: txCTCSSDCS.result, - power: (row['Power'] ?? 'High') as Channel['power'], - scanAdd: false, - scanListId: parseInt(String(row['Scan List'] ?? row['Scan List ID'] ?? '0'), 10) || 0, - forbidTalkaround: row['Forbid Talkaround'] === 'Yes' || row['Forbid Talkaround'] === true, - forbidTx: row['Forbid TX'] === 'Yes' || row['Forbid TX'] === true, - loneWorker: row['Lone Worker'] === 'Yes' || row['Lone Worker'] === true, - aprsReceive: row['APRS Receive'] === 'Yes' || row['APRS Receive'] === true, - aprsReportMode: (row['APRS Report'] ?? row['APRS Report Mode'] ?? 'Off') as Channel['aprsReportMode'], - contactId: parseInt(String(row['Contact ID'] ?? '0'), 10) || 0, - colorCode: parseInt(String(row['Color Code'] ?? '0'), 10) || 0, - squelchLevel: parseInt(String(row['Squelch'] ?? row['Squelch Level'] ?? '3'), 10) || 3, - digitalEmergencySystemId: parseInt(String(row['Digital Emergency System ID'] ?? '0'), 10) || 0, - emergencySystemId: parseInt(String(row['Emergency ID'] ?? row['Emergency System ID'] ?? '0'), 10) || 0, - emergencyIndicator: row['Emergency'] === 'Yes' || row['Emergency'] === true, - emergencyAck: row['Emergency Ack'] === 'Yes' || row['Emergency Ack'] === true, - voxFunction: row['VOX'] === 'Yes' || row['VOX'] === true, - scramble: row['Scramble'] === 'Yes' || row['Scramble'] === true, - compander: row['Compander'] === 'Yes' || row['Compander'] === true, - talkback: row['Talkback'] === 'Yes' || row['Talkback'] === true, - pttIdDisplay: row['PTT ID Display'] === 'Yes' || row['PTT ID Display'] === true, - pttId: parseInt(String(row['PTT ID'] ?? '0'), 10) || 0, - companderDup: row['Compander Dup'] === 'Yes' || row['Compander Dup'] === true, - voxRelated: row['VOX Related'] === 'Yes' || row['VOX Related'] === true, - rxSquelchMode: (row['RX Squelch Mode'] ?? 'Carrier/CTC') as Channel['rxSquelchMode'], - stepFrequency: parseInt(String(row['Step Frequency'] ?? '0'), 10) || 0, - signalingType: (row['Signaling Type'] ?? 'None') as Channel['signalingType'], - pttIdType: (row['PTT ID Type'] ?? 'Off') as Channel['pttIdType'], - unknown1A_6_4: 0, - unknown1A_3: false, - unknown1C_1_0: 0, - unknown1D_3_0: 0, - unknown25_7_6: 0, - unknown25_3_0: 0, - unknown26_3_1: 0, - unknown26_0: false, - unknown29_3_2: 0, - unknown29_1_0: 0, - unknown2A: 0, - pttIdDisplay2: false, - }; - - result.channels.push(channel); - summary.channels.valid++; - - if (rxCTCSSDCS.warning || txCTCSSDCS.warning || rxValidation.warning || txValidation.warning) { - summary.channels.warnings++; - } - } catch (err) { - summary.channels.errors++; - const errorMsg = err instanceof Error ? err.message : 'Unknown error'; - - if (strictMode) { - errors.push({ - type: 'parse_error', - sheet: 'Channels', - row: rowNum, - message: `Failed to parse channel: ${errorMsg}`, - }); - } else { - warnings.push({ - type: 'invalid_value', - sheet: 'Channels', - row: rowNum, - message: `Channel parsing issue: ${errorMsg}`, - }); - } - } - } - } - - onProgress?.(60, 'Importing zones and scan lists...'); - - const zonesSheet = workbook.getWorksheet('Zones'); - if (zonesSheet) { - const rows = sheetToJson(zonesSheet) as Record[]; - summary.zones.total = rows.length; - - for (const row of rows) { - try { - const zone: Zone = { - id: generateZoneId(), - name: String(row['Zone Name'] ?? ''), - channels: String(row['Channels'] ?? '').split(',').map((c: string) => parseInt(c.trim(), 10)).filter((n: number) => !isNaN(n)), - }; - result.zones.push(zone); - summary.zones.valid++; - } catch { - summary.zones.errors++; - } - } - } - - const scanListsSheet = workbook.getWorksheet('Scan Lists'); - if (scanListsSheet) { - const rows = sheetToJson(scanListsSheet) as Record[]; - summary.scanLists.total = rows.length; - - for (const row of rows) { - try { - const priorityCh1 = row['Priority Channel 1']; - const priorityCh2 = row['Priority Channel 2']; - const designatedTx = row['Designated TX Channel']; - - const scanList: ScanList = { - name: String(row['Scan List Name'] ?? ''), - ctcScanMode: parseInt(String(row['CTC Scan Mode'] ?? '0'), 10) || 0, - scanTxMode: parseInt(String(row['Scan TX Mode'] ?? '0'), 10) || 0, - hangTime: row['Hang Time (tenths)'] != null && row['Hang Time (tenths)'] !== '' ? parseInt(String(row['Hang Time (tenths)']), 10) : undefined, - priority1Type: row['Priority 1 Type'] != null ? parseInt(String(row['Priority 1 Type']), 10) : 0, - priority2Type: row['Priority 2 Type'] != null ? parseInt(String(row['Priority 2 Type']), 10) : 0, - priorityChannel1: priorityCh1 != null && priorityCh1 !== '' ? parseInt(String(priorityCh1), 10) : undefined, - priorityChannel2: priorityCh2 != null && priorityCh2 !== '' ? parseInt(String(priorityCh2), 10) : undefined, - designatedTxChannel: designatedTx != null && designatedTx !== '' ? parseInt(String(designatedTx), 10) : undefined, - channels: String(row['Channels'] ?? '').split(',').map((c: string) => parseInt(c.trim(), 10)).filter((n: number) => !isNaN(n)), - }; - result.scanLists.push(scanList); - summary.scanLists.valid++; - } catch { - summary.scanLists.errors++; - } - } - } - - onProgress?.(80, 'Importing contacts and settings...'); - - const contactsSheet = workbook.getWorksheet('Contacts'); - if (contactsSheet) { - const rows = sheetToJson(contactsSheet) as Record[]; - summary.contacts.total = rows.length; - - for (const row of rows) { - try { - const contact: Contact = { - id: parseInt(String(row['ID'] ?? '0'), 10) || 0, - name: String(row['Name'] ?? ''), - callSign: String(row['Call Sign'] ?? ''), - dmrId: parseInt(String(row['DMR ID'] ?? '0'), 10) || 0, - }; - result.contacts.push(contact); - summary.contacts.valid++; - } catch { - summary.contacts.errors++; - } - } + let data: CodeplugData; + try { + data = await importCodeplug(file); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to read codeplug file'; + errors.push({ type: 'file_error', message }); + onProgress?.(100, 'Import failed'); + return { + data: { + channels: [], + zones: [], + scanLists: [], + contacts: [], + digitalEmergencies: [], + digitalEmergencyConfig: null, + analogEmergencies: [], + radioSettings: null, + radioInfo: null, + messages: [], + radioIds: [], + quickContacts: [], + rxGroups: [], + encryptionKeys: [], + exportDate: new Date().toISOString(), + version: '1.0.0', + }, + warnings, + errors, + summary: { + channels: { total: 0, valid: 0, warnings: 0, errors: 0 }, + zones: { total: 0, valid: 0, warnings: 0, errors: 0 }, + scanLists: { total: 0, valid: 0, warnings: 0, errors: 0 }, + contacts: { total: 0, valid: 0, warnings: 0, errors: 0 }, + sheets: { found: [], missing: [] }, + }, + }; } onProgress?.(100, 'Import complete'); - if (strictMode && errors.length > 0) { - throw new Error(`Import failed with ${errors.length} error(s). First error: ${errors[0].message}`); - } + // Ensure zones have ids + data.zones = data.zones.map((z) => ({ + ...z, + id: z.id ?? generateZoneId(), + })); + + const summary: ImportSummary = { + channels: { + total: data.channels.length, + valid: data.channels.length, + warnings: 0, + errors: 0, + }, + zones: { + total: data.zones.length, + valid: data.zones.length, + warnings: 0, + errors: 0, + }, + scanLists: { + total: data.scanLists.length, + valid: data.scanLists.length, + warnings: 0, + errors: 0, + }, + contacts: { + total: data.contacts.length, + valid: data.contacts.length, + warnings: 0, + errors: 0, + }, + sheets: { found: ['codeplug.json'], missing: [] }, + }; return { - data: result, + data, warnings, errors, summary, diff --git a/src/services/validation/codeplugValidator.ts b/src/services/validation/codeplugValidator.ts index 5111884..c9323ee 100644 --- a/src/services/validation/codeplugValidator.ts +++ b/src/services/validation/codeplugValidator.ts @@ -49,13 +49,14 @@ export function getChannelsNotInZones(channels: Channel[], zones: Zone[]): Chann return channels.filter((ch) => !channelNumbersInZones.has(ch.number)); } -/** Channels that reference a DMR Radio ID index that is not in the current radio IDs list (e.g. after a delete). */ +/** Channels that reference a DMR Radio ID index that is not in the current radio IDs list (e.g. after a delete). Analog channels are skipped. */ export function getChannelsReferencingDeletedDmrRadioId( channels: Channel[], radioIds: DMRRadioID[] ): Channel[] { const validIndices = new Set(radioIds.map((r) => r.index)); return channels.filter((ch) => { + if (ch.mode === 'Analog' || ch.mode === 'Fixed Analog') return false; const idx = ch.dmrRadioIdIndex; if (idx === undefined || idx === 255) return false; return !validIndices.has(idx);