Skip to content

Commit 73fad27

Browse files
committed
feat(rest): native server-side xlsx parsing in /import
Accept format:'xlsx' with a base64-encoded workbook (xlsxBase64) and an optional sheet selector. The server flattens ExcelJS cells to their human-visible text (formula results, hyperlink text, rich-text runs, dates -> ISO) so a parsed xlsx yields the same cells a CSV export would, then feeds them through the existing coercion + upsert pipeline. - spec: extend ImportRequestSchema format enum with 'xlsx'; add xlsxBase64 + sheet fields. - rest: xlsxCellToString + parseXlsxToRows helpers (dynamic exceljs import so csv/json imports don't pay for it); wire the xlsx branch into the /import body parser; 400 on malformed workbooks. - tests: 3 integration cases (coercion parity with csv, named-sheet selection + format inference, malformed-payload 400).
1 parent b7ec536 commit 73fad27

3 files changed

Lines changed: 135 additions & 4 deletions

File tree

packages/rest/src/import-integration.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,49 @@ describe('import route — real engine + protocol integration', () => {
237237
expect(res._status).toBe(400);
238238
expect(res._json.code).toBe('INVALID_REQUEST');
239239
});
240+
241+
it('parses a native xlsx workbook server-side and coerces cells like csv', async () => {
242+
const ExcelJS: any = (await import('exceljs')).default ?? (await import('exceljs'));
243+
const wb = new ExcelJS.Workbook();
244+
const ws = wb.addWorksheet('Sheet1');
245+
ws.addRow(['ID', '标题', '完成', '优先级', '分数', '截止', '负责人']);
246+
ws.addRow(['1', '写代码', '是', '高', 1200, new Date('2026-06-30T00:00:00Z'), '张三']);
247+
ws.addRow(['2', '测试', '否', '低', 3, '2026/07/01', '李四']);
248+
const buf = await wb.xlsx.writeBuffer();
249+
const xlsxBase64 = Buffer.from(buf).toString('base64');
250+
251+
const res = await call(route, {
252+
format: 'xlsx', xlsxBase64,
253+
mapping: { ID: 'id', 标题: 'title', 完成: 'done', 优先级: 'priority', 分数: 'score', 截止: 'due', 负责人: 'owner' },
254+
});
255+
expect(res._json).toMatchObject({ total: 2, ok: 2, errors: 0, created: 2 });
256+
const one = await engine.findOne('task', { where: { id: '1' } });
257+
expect(one).toMatchObject({ title: '写代码', done: true, priority: 'high', score: 1200, owner: 'u1' });
258+
expect(String(one.due)).toContain('2026-06-30');
259+
const two = await engine.findOne('task', { where: { id: '2' } });
260+
expect(two).toMatchObject({ title: '测试', done: false, priority: 'low', score: 3, owner: 'u2' });
261+
});
262+
263+
it('reads xlsxBase64 without an explicit format and honors the sheet selector', async () => {
264+
const ExcelJS: any = (await import('exceljs')).default ?? (await import('exceljs'));
265+
const wb = new ExcelJS.Workbook();
266+
wb.addWorksheet('Empty'); // decoy first sheet
267+
const ws = wb.addWorksheet('Data');
268+
ws.addRow(['id', 'title', 'score']);
269+
ws.addRow(['x1', 'from-named-sheet', 7]);
270+
const buf = await wb.xlsx.writeBuffer();
271+
const xlsxBase64 = Buffer.from(buf).toString('base64');
272+
273+
const res = await call(route, { xlsxBase64, sheet: 'Data' });
274+
expect(res._json).toMatchObject({ total: 1, ok: 1, created: 1 });
275+
const row = await engine.findOne('task', { where: { id: 'x1' } });
276+
expect(row).toMatchObject({ title: 'from-named-sheet', score: 7 });
277+
});
278+
279+
it('rejects a malformed xlsx payload with 400', async () => {
280+
const res = await call(route, { format: 'xlsx', xlsxBase64: Buffer.from('not a workbook').toString('base64') });
281+
expect(res._status).toBe(400);
282+
expect(res._json.code).toBe('INVALID_REQUEST');
283+
expect(String(res._json.error)).toMatch(/xlsx/i);
284+
});
240285
});

packages/rest/src/rest-server.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,75 @@ function parseCsvToRows(csv: string, mapping: Record<string, string> = {}): Arra
382382
return out;
383383
}
384384

385+
/**
386+
* Flatten one ExcelJS cell value to the raw string the coercion layer expects.
387+
* ExcelJS hands back rich objects for formulas / hyperlinks / rich text / dates;
388+
* we reduce each to the human-visible text so a server-parsed xlsx yields the
389+
* same cells a CSV export would (dates → ISO, so parseDateCell can re-read them).
390+
*/
391+
function xlsxCellToString(value: any): string {
392+
if (value === null || value === undefined) return '';
393+
if (typeof value === 'string') return value;
394+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value);
395+
if (value instanceof Date) return value.toISOString();
396+
if (typeof value === 'object') {
397+
// Formula cell → prefer its computed result.
398+
if ('result' in value && value.result !== undefined && value.result !== null) return xlsxCellToString(value.result);
399+
// Hyperlink cell → the visible text, not the target.
400+
if ('text' in value && typeof value.text === 'string') return value.text;
401+
if ('hyperlink' in value && typeof value.hyperlink === 'string') return value.hyperlink;
402+
// Rich text → concatenate runs.
403+
if (Array.isArray(value.richText)) return value.richText.map((r: any) => r?.text ?? '').join('');
404+
if ('error' in value && value.error) return String(value.error);
405+
}
406+
try { return String(value); } catch { return ''; }
407+
}
408+
409+
/**
410+
* Parse an .xlsx workbook (raw bytes) into row objects, mirroring
411+
* {@link parseCsvToRows}: first non-empty row is the header, each subsequent row
412+
* becomes `{ header→cell }` with the optional `mapping` renaming columns. Reads
413+
* the named/indexed `sheet` when given, else the first worksheet. Dynamically
414+
* imports ExcelJS (already a dependency of the export path) so CSV/JSON imports
415+
* don't pay for it.
416+
*/
417+
async function parseXlsxToRows(
418+
buffer: Buffer | ArrayBuffer,
419+
mapping: Record<string, string> = {},
420+
sheet?: string | number,
421+
): Promise<Array<Record<string, any>>> {
422+
const ExcelJS: any = (await import('exceljs')).default ?? (await import('exceljs'));
423+
const wb = new ExcelJS.Workbook();
424+
await wb.xlsx.load(buffer);
425+
const ws = sheet !== undefined ? wb.getWorksheet(sheet as any) : wb.worksheets[0];
426+
if (!ws) return [];
427+
428+
const cells: string[][] = [];
429+
ws.eachRow({ includeEmpty: false }, (row: any) => {
430+
const values = row.values as any[]; // 1-based; index 0 is unused
431+
const line: string[] = [];
432+
for (let c = 1; c < values.length; c++) line.push(xlsxCellToString(values[c]));
433+
cells.push(line);
434+
});
435+
while (cells.length > 0 && cells[cells.length - 1].every(c => c === '')) cells.pop();
436+
if (cells.length < 2) return [];
437+
438+
const header = cells[0].map(h => h.trim());
439+
const fields = header.map(h => mapping[h] ?? h);
440+
const out: Array<Record<string, any>> = [];
441+
for (let r = 1; r < cells.length; r++) {
442+
const line = cells[r];
443+
const obj: Record<string, any> = {};
444+
for (let c = 0; c < fields.length; c++) {
445+
const key = fields[c];
446+
if (!key) continue;
447+
obj[key] = line[c] ?? '';
448+
}
449+
out.push(obj);
450+
}
451+
return out;
452+
}
453+
385454
/**
386455
* Escape a single value into an RFC-4180 CSV cell. Values containing
387456
* commas, quotes, CR, or LF are wrapped in double-quotes with embedded
@@ -3259,19 +3328,32 @@ export class RestServer {
32593328
return out;
32603329
};
32613330

3262-
// Build rows[] from either explicit JSON array or CSV text.
3331+
// Build rows[] from JSON array, CSV text, or a base64 xlsx.
32633332
let rows: Array<Record<string, any>> = [];
32643333
if (body.format === 'json' && Array.isArray(body.rows)) {
32653334
rows = (body.rows as Array<Record<string, any>>).map(applyMapping);
32663335
} else if ((body.format === 'csv' || typeof body.csv === 'string') && typeof body.csv === 'string') {
32673336
rows = parseCsvToRows(body.csv, mapping);
3337+
} else if ((body.format === 'xlsx' || typeof body.xlsxBase64 === 'string') && typeof body.xlsxBase64 === 'string') {
3338+
// Native server-side xlsx parse — the client uploads raw
3339+
// workbook bytes (base64) instead of pre-flattening to CSV.
3340+
try {
3341+
const buf = Buffer.from(body.xlsxBase64, 'base64');
3342+
rows = await parseXlsxToRows(buf, mapping, body.sheet);
3343+
} catch (e: any) {
3344+
res.status(400).json({
3345+
code: 'INVALID_REQUEST',
3346+
error: `Failed to parse xlsx: ${e?.message ?? String(e)}`,
3347+
});
3348+
return;
3349+
}
32683350
} else if (Array.isArray(body)) {
32693351
// Permissive: a bare JSON array at the top level.
32703352
rows = (body as Array<Record<string, any>>).map(applyMapping);
32713353
} else {
32723354
res.status(400).json({
32733355
code: 'INVALID_REQUEST',
3274-
error: 'Provide either format:"csv" with csv text or format:"json" with rows[]',
3356+
error: 'Provide format:"csv" with csv text, format:"json" with rows[], or format:"xlsx" with xlsxBase64',
32753357
});
32763358
return;
32773359
}

packages/spec/src/api/export.zod.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,11 +300,15 @@ export type ImportMapping = z.infer<typeof ImportMappingSchema>;
300300
* }
301301
*/
302302
export const ImportRequestSchema = lazySchema(() => z.object({
303-
format: z.enum(['csv', 'json']).optional()
304-
.describe('Payload shape: csv text or a rows[] array (inferred when omitted)'),
303+
format: z.enum(['csv', 'json', 'xlsx']).optional()
304+
.describe('Payload shape: csv text, a rows[] array, or a base64 xlsx (inferred when omitted)'),
305305
csv: z.string().optional().describe('CSV text (when format = csv)'),
306306
rows: z.array(z.record(z.string(), z.unknown())).optional()
307307
.describe('Row objects (when format = json)'),
308+
xlsxBase64: z.string().optional()
309+
.describe('Base64-encoded .xlsx workbook bytes (when format = xlsx); parsed server-side'),
310+
sheet: z.union([z.string(), z.number().int()]).optional()
311+
.describe('Worksheet name or 1-based index to read (xlsx; defaults to the first sheet)'),
308312
mapping: ImportMappingSchema.optional()
309313
.describe('Source column → target field mapping'),
310314
dryRun: z.boolean().default(false)

0 commit comments

Comments
 (0)