import type { NvsPartition, NvsEntry, NvsEncoding } from './types'; import { NvsType, NvsVersion, ENCODING_TO_TYPE } from './types'; import { generateEntryId } from './nvs-partition'; /** * Parse a line respecting quoted fields. * Handles fields with commas inside double quotes. */ function splitCsvLine(line: string): string[] { const fields: string[] = []; let current = ''; let inQuotes = false; let wasQuoted = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (ch === '"') { if (inQuotes && i + 1 < line.length && line[i + 1] === '"') { current += '"'; i++; // skip escaped quote } else { inQuotes = !inQuotes; if (inQuotes) wasQuoted = true; } } else if (ch === ',' && !inQuotes) { fields.push(wasQuoted ? current : current.trim()); current = ''; wasQuoted = false; } else { current += ch; } } fields.push(wasQuoted ? current : current.trim()); return fields; } /** Parse an integer value from CSV, supporting decimal and 0x hex. Rejects partial matches like "12abc". */ function parseIntValue(str: string): number { str = str.trim(); let val: number; if (str.startsWith('0x') || str.startsWith('0X')) { if (!/^-?0[xX][0-9a-fA-F]+$/.test(str)) { throw new Error(`无效的整数值: "${str}"`); } val = parseInt(str, 16); } else { if (!/^-?\d+$/.test(str)) { throw new Error(`无效的整数值: "${str}"`); } val = parseInt(str, 10); } if (Number.isNaN(val)) { throw new Error(`无效的整数值: "${str}"`); } return val; } /** Parse a bigint value from CSV, supporting decimal and 0x hex (including negative hex like -0x1A). */ function parseBigIntValue(str: string): bigint { str = str.trim(); // JS BigInt() accepts decimal and positive hex (0x...) but throws on negative hex (-0x...). // Handle negative hex explicitly. if (str.startsWith('-0x') || str.startsWith('-0X')) { if (!/^-0[xX][0-9a-fA-F]+$/.test(str)) { throw new Error(`无效的整数值: "${str}"`); } return -BigInt(str.slice(1)); } try { return BigInt(str); } catch { throw new Error(`无效的整数值: "${str}"`); } } /** Decode hex string (e.g. "48656c6c6f") to Uint8Array */ function hexToBytes(hex: string): Uint8Array { hex = hex.replace(/\s/g, ''); const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); } return bytes; } /** * Try to decode a base64 string to Uint8Array. * Returns null if the string doesn't look like valid base64. */ function tryBase64Decode(str: string): Uint8Array | null { try { if (!/^[A-Za-z0-9+/=]+$/.test(str.trim())) return null; const bin = atob(str.trim()); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } catch { return null; } } /** * Parse ESP-IDF NVS CSV format into NvsPartition. * * CSV format: * key,type,encoding,value * namespace_name,namespace,, * wifi_ssid,data,string,MyNetwork * boot_count,data,u8,0 */ /** * Split CSV text into logical lines, respecting double-quoted fields that may * span multiple physical lines (RFC 4180 multiline support). */ function splitCsvLines(text: string): string[] { const result: string[] = []; let current = ''; let inQuotes = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '"') { // Check for escaped quote "" if (inQuotes && i + 1 < text.length && text[i + 1] === '"') { current += '""'; i++; } else { inQuotes = !inQuotes; current += ch; } } else if ((ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) && !inQuotes) { if (ch === '\r') i++; // consume \n of \r\n result.push(current); current = ''; } else { current += ch; } } if (current) result.push(current); return result; } export function parseCsv(text: string): NvsPartition { const lines = splitCsvLines(text); const entries: NvsEntry[] = []; const namespaces: string[] = []; let currentNamespace = ''; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line || line.startsWith('#')) continue; const fields = splitCsvLine(line); if (fields.length < 2) continue; // Skip header line if (fields[0] === 'key' && fields[1] === 'type') continue; const key = fields[0]; const type = fields[1]; const encoding = (fields[2] || '').toLowerCase() as NvsEncoding | ''; const value = fields[3] || ''; if (type === 'namespace') { currentNamespace = key; if (!namespaces.includes(key)) { namespaces.push(key); } continue; } if (!currentNamespace) { throw new Error(`行 ${i + 1}: 数据条目 "${key}" 出现在任何命名空间之前`); } if (type !== 'data' && type !== 'file') { throw new Error(`行 ${i + 1}: 未知类型 "${type}"`); } if (!encoding) { throw new Error(`行 ${i + 1}: 键 "${key}" 缺少编码类型`); } const nvsType = ENCODING_TO_TYPE[encoding as NvsEncoding]; if (nvsType === undefined) { throw new Error(`行 ${i + 1}: 未知编码 "${encoding}"`); } let parsedValue: number | bigint | string | Uint8Array; switch (encoding) { case 'u8': case 'u16': case 'u32': case 'i8': case 'i16': case 'i32': parsedValue = parseIntValue(value); break; case 'u64': case 'i64': parsedValue = parseBigIntValue(value); break; case 'string': parsedValue = value; break; case 'blob': case 'binary': { if (type === 'file') { // In browser context, file paths can't be resolved. // Store an empty Uint8Array — the UI should handle file picking. parsedValue = new Uint8Array(0); } else { // Try hex decode first (strip whitespace before checking), then base64 const hexClean = value.replace(/\s/g, ''); if (/^[0-9a-fA-F]+$/.test(hexClean) && hexClean.length % 2 === 0 && hexClean.length > 0) { parsedValue = hexToBytes(value); } else { const b64 = tryBase64Decode(value); parsedValue = b64 ?? new TextEncoder().encode(value); } } break; } default: parsedValue = value; } entries.push({ id: generateEntryId(), namespace: currentNamespace, key, type: nvsType, value: parsedValue, }); } return { entries, namespaces, version: NvsVersion.V2 }; }