yunsi-toolbox-vue/lib/nvs/nvs-csv-parser.ts

234 lines
6.6 KiB
TypeScript

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 };
}