@fuguejs/xlsx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/package.json +39 -0
- package/src/index.ts +199 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @fuguejs/xlsx
|
|
2
|
+
|
|
3
|
+
Pure workbook parsing for Fugue DAGs. `parseWorkbook` turns `.xlsx` bytes into
|
|
4
|
+
Zod-validated typed rows. It is a pure function (deterministic, no I/O) — fetching
|
|
5
|
+
the bytes is a [`documents`](../document-source) capability concern
|
|
6
|
+
([`@fuguejs/fs`](../adapter-fs), [`@fuguejs/ms-graph`](../adapter-ms-graph)); parsing
|
|
7
|
+
stays here so it is fixture-testable and provider-agnostic (ADR-0052).
|
|
8
|
+
|
|
9
|
+
- **AI/usage guide:** [`docs/llm-document-source.md`](../../docs/llm-document-source.md)
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { parseWorkbook } from "@fuguejs/xlsx";
|
|
16
|
+
|
|
17
|
+
const RowSchema = z.object({ customerId: z.string(), revenue: z.coerce.number() });
|
|
18
|
+
|
|
19
|
+
// inside a createFetchNode `fetch`, after ctx.documents.getContent(ref):
|
|
20
|
+
const parsed = await parseWorkbook(bytes, RowSchema);
|
|
21
|
+
// Promise<Result<{ rows: readonly { customerId: string; revenue: number }[] }, FrameworkError>>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## API
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
parseWorkbook<T>(
|
|
28
|
+
bytes: Uint8Array,
|
|
29
|
+
rowSchema: z.ZodType<T>,
|
|
30
|
+
opts?: { sheet?: string | number; headerRow?: number }, // default: first sheet, header row 1
|
|
31
|
+
): Promise<Result<{ rows: readonly T[] }, FrameworkError>>
|
|
32
|
+
|
|
33
|
+
normalizeCell(value: unknown): string | number | boolean | Date | null // exported for testing
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- Rows are objects keyed by the header-row cells.
|
|
37
|
+
- Cells are normalised to primitives: formula → its result, rich text /
|
|
38
|
+
hyperlink → text, error cells → `null`, dates kept as `Date`. Use
|
|
39
|
+
`z.coerce.*` for columns stored as text. Fully-blank rows are skipped.
|
|
40
|
+
|
|
41
|
+
## Errors
|
|
42
|
+
|
|
43
|
+
| Situation | `FrameworkError` |
|
|
44
|
+
|---|---|
|
|
45
|
+
| Bytes are not a readable workbook | non-retriable `node-crash` |
|
|
46
|
+
| Requested worksheet absent | non-retriable `node-crash` |
|
|
47
|
+
| A row violates `rowSchema` | `validation` (message names the row) |
|
|
48
|
+
|
|
49
|
+
## Tests
|
|
50
|
+
|
|
51
|
+
Unit tests build `.xlsx` fixtures in memory (no committed binaries). An
|
|
52
|
+
end-to-end test reads a real file from disk through `@fuguejs/fs` and parses it,
|
|
53
|
+
proving the `getContent → parseWorkbook` path.
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fuguejs/xlsx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "bun test"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@fuguejs/framework": "0.1.0",
|
|
16
|
+
"exceljs": "^4.4.0",
|
|
17
|
+
"jszip": "^3.10.1"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"zod": "^4.3.6"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"zod": {
|
|
24
|
+
"optional": false
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/bun": "latest",
|
|
29
|
+
"@fuguejs/fs": "0.1.0",
|
|
30
|
+
"zod": "^4.3.6"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"!src/__tests__"
|
|
38
|
+
]
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fuguejs/xlsx — pure workbook parsing for Fugue DAGs.
|
|
3
|
+
*
|
|
4
|
+
* `parseWorkbook` turns `.xlsx` bytes into Zod-validated typed rows. It is a
|
|
5
|
+
* pure function (deterministic, no I/O) — the byte fetching is a `documents`
|
|
6
|
+
* capability concern (`@fuguejs/ms-graph`, `@fuguejs/fs`), and parsing stays here
|
|
7
|
+
* so it is fixture-testable and provider-agnostic. See ADR-0052.
|
|
8
|
+
*
|
|
9
|
+
* ## Usage
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { z } from "zod";
|
|
13
|
+
* import { parseWorkbook } from "@fuguejs/xlsx";
|
|
14
|
+
*
|
|
15
|
+
* const RowSchema = z.object({ customerId: z.string(), revenue: z.coerce.number() });
|
|
16
|
+
*
|
|
17
|
+
* // inside a createFetchNode `fetch`, after ctx.documents.getContent(ref):
|
|
18
|
+
* const parsed = await parseWorkbook(bytes, RowSchema); // Result<{ rows }, FrameworkError>
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Rows are objects keyed by the header-row cells. Cells are normalised to
|
|
22
|
+
* primitives (formula → result, rich text / hyperlink → text, dates kept as
|
|
23
|
+
* `Date`); pair numeric or date columns with `z.coerce.*` if your source stores
|
|
24
|
+
* them as text.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import ExcelJS from "exceljs";
|
|
28
|
+
import type { z } from "zod";
|
|
29
|
+
import type { Result, FrameworkError } from "@fuguejs/framework";
|
|
30
|
+
import { ok, err, nodeId } from "@fuguejs/framework";
|
|
31
|
+
|
|
32
|
+
/** Sentinel node ID for parse errors (parsing is a lib, not a DAG node). */
|
|
33
|
+
const XLSX_NODE_ID = nodeId("xlsx-parse");
|
|
34
|
+
|
|
35
|
+
const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
36
|
+
|
|
37
|
+
const crashErr = (message: string): FrameworkError => ({
|
|
38
|
+
kind: "node-crash",
|
|
39
|
+
nodeId: XLSX_NODE_ID,
|
|
40
|
+
message,
|
|
41
|
+
retriability: "non-retriable",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const validationErr = (message: string, path?: string): FrameworkError => ({
|
|
45
|
+
kind: "validation",
|
|
46
|
+
nodeId: XLSX_NODE_ID,
|
|
47
|
+
message,
|
|
48
|
+
...(path !== undefined ? { path } : {}),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Options for `parseWorkbook`. */
|
|
52
|
+
export interface ParseWorkbookOpts {
|
|
53
|
+
/** Worksheet to read: name (string) or 1-based index (number). Default: first sheet. */
|
|
54
|
+
readonly sheet?: string | number;
|
|
55
|
+
/** 1-based row holding the column headers. Default: 1. */
|
|
56
|
+
readonly headerRow?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Strip `<dateGroupItem …/>` elements from `xl/tables/*.xml`.
|
|
61
|
+
*
|
|
62
|
+
* Real-world exports (Dynamics 365, BI tools) save date-grouped table
|
|
63
|
+
* autofilters as `dateGroupItem` nodes, which ExcelJS's table parser does not
|
|
64
|
+
* know and crashes on ("Unexpected xml node in parseOpen"). The nodes only
|
|
65
|
+
* describe a UI filter selection — never cell data — so removing them is
|
|
66
|
+
* lossless for row extraction. `ignoreNodes` can't reach them (tables are
|
|
67
|
+
* parsed outside the worksheet xform), hence the zip-level rewrite. Uses
|
|
68
|
+
* jszip, which ExcelJS already depends on.
|
|
69
|
+
*/
|
|
70
|
+
const stripDateGroupItems = async (bytes: Uint8Array): Promise<Uint8Array> => {
|
|
71
|
+
const { default: JSZip } = await import("jszip");
|
|
72
|
+
const zip = await JSZip.loadAsync(bytes);
|
|
73
|
+
const tableFiles = zip.file(/^xl\/tables\/.*\.xml$/);
|
|
74
|
+
for (const file of tableFiles) {
|
|
75
|
+
const xml = await file.async("string");
|
|
76
|
+
if (xml.includes("<dateGroupItem")) {
|
|
77
|
+
zip.file(file.name, xml.replace(/<dateGroupItem\b[^>]*\/>/g, ""));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return zip.generateAsync({ type: "uint8array" });
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const isDateGroupItemCrash = (e: unknown): boolean =>
|
|
84
|
+
e instanceof Error && e.message.includes("parseOpen") && e.message.includes("dateGroupItem");
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Normalise an ExcelJS cell value to a primitive (or `null`). Handles formulas
|
|
88
|
+
* (`{ formula, result }` → the computed result), rich text (`{ richText }`),
|
|
89
|
+
* hyperlinks (`{ text, hyperlink }`), and error cells (`#REF!`, `#DIV/0!`, … →
|
|
90
|
+
* `null`); passes through string/number/boolean/Date unchanged.
|
|
91
|
+
*/
|
|
92
|
+
export const normalizeCell = (value: unknown): string | number | boolean | Date | null => {
|
|
93
|
+
if (value === null || value === undefined) return null;
|
|
94
|
+
if (value instanceof Date) return value;
|
|
95
|
+
if (typeof value === "object") {
|
|
96
|
+
const v = value as Record<string, unknown>;
|
|
97
|
+
if (Array.isArray(v.richText)) {
|
|
98
|
+
return (v.richText as { text?: string }[]).map((p) => p.text ?? "").join("");
|
|
99
|
+
}
|
|
100
|
+
if (typeof v.text === "string") return v.text; // hyperlink cell
|
|
101
|
+
if ("result" in v) return normalizeCell(v.result); // formula → its computed result
|
|
102
|
+
if ("error" in v) return null; // error cell (#REF!, #DIV/0!, …)
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse `.xlsx` bytes into rows validated against `rowSchema`.
|
|
113
|
+
*
|
|
114
|
+
* Returns:
|
|
115
|
+
* - `node-crash` (non-retriable) when the bytes aren't a readable workbook or
|
|
116
|
+
* the requested worksheet is absent — deterministic, so not retried.
|
|
117
|
+
* - `validation` when a row does not match `rowSchema` (message names the row).
|
|
118
|
+
* - `ok({ rows })` otherwise. Rows are skipped when every cell normalises to
|
|
119
|
+
* empty — this includes rows whose cells are all error cells (`#REF!`,
|
|
120
|
+
* `#DIV/0!`, …), since `normalizeCell` maps those to `null`. A row mixing an
|
|
121
|
+
* error cell with real values is kept and fails `rowSchema` validation unless
|
|
122
|
+
* the offending column is nullable.
|
|
123
|
+
*/
|
|
124
|
+
export const parseWorkbook = async <T>(
|
|
125
|
+
bytes: Uint8Array,
|
|
126
|
+
rowSchema: z.ZodType<T>,
|
|
127
|
+
opts: ParseWorkbookOpts = {},
|
|
128
|
+
): Promise<Result<{ rows: readonly T[] }, FrameworkError>> => {
|
|
129
|
+
const wb = new ExcelJS.Workbook();
|
|
130
|
+
try {
|
|
131
|
+
// exceljs types `load` as the global Buffer; recent @types/node makes
|
|
132
|
+
// Buffer.from return Buffer<ArrayBuffer> — cast to exceljs's exact param.
|
|
133
|
+
await wb.xlsx.load(Buffer.from(bytes) as unknown as Parameters<typeof wb.xlsx.load>[0]);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (!isDateGroupItemCrash(e)) {
|
|
136
|
+
return err(crashErr(`failed to parse workbook: ${msg(e)}`));
|
|
137
|
+
}
|
|
138
|
+
// Date-grouped table autofilter (Dynamics/BI exports) — strip the
|
|
139
|
+
// UI-only filter nodes and retry once. See stripDateGroupItems.
|
|
140
|
+
try {
|
|
141
|
+
const cleaned = await stripDateGroupItems(bytes);
|
|
142
|
+
await wb.xlsx.load(Buffer.from(cleaned) as unknown as Parameters<typeof wb.xlsx.load>[0]);
|
|
143
|
+
} catch (e2) {
|
|
144
|
+
return err(crashErr(`failed to parse workbook: ${msg(e2)}`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const ws =
|
|
149
|
+
typeof opts.sheet === "string"
|
|
150
|
+
? wb.getWorksheet(opts.sheet)
|
|
151
|
+
: wb.worksheets[(typeof opts.sheet === "number" ? opts.sheet : 1) - 1];
|
|
152
|
+
if (!ws) {
|
|
153
|
+
const which = opts.sheet ?? "(first)";
|
|
154
|
+
return err(crashErr(`worksheet not found: ${which}`));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const headerRowNum = opts.headerRow ?? 1;
|
|
158
|
+
const colCount = ws.columnCount;
|
|
159
|
+
const headerRow = ws.getRow(headerRowNum);
|
|
160
|
+
const headers: string[] = [];
|
|
161
|
+
const seenHeaders = new Set<string>();
|
|
162
|
+
for (let c = 1; c <= colCount; c++) {
|
|
163
|
+
const h = normalizeCell(headerRow.getCell(c).value);
|
|
164
|
+
const key = h === null ? "" : String(h).trim();
|
|
165
|
+
// A duplicate non-empty header would silently overwrite the earlier
|
|
166
|
+
// column when rows are keyed by header (`obj[key] = val`), dropping a whole
|
|
167
|
+
// column of data. Fail loudly instead. Blank headers are legitimately
|
|
168
|
+
// skipped (multiple empty columns are fine), so they're exempt.
|
|
169
|
+
if (key !== "" && seenHeaders.has(key)) {
|
|
170
|
+
return err(crashErr(`duplicate header column: '${key}' (header row ${headerRowNum})`));
|
|
171
|
+
}
|
|
172
|
+
if (key !== "") seenHeaders.add(key);
|
|
173
|
+
headers[c] = key;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rows: T[] = [];
|
|
177
|
+
for (let r = headerRowNum + 1; r <= ws.rowCount; r++) {
|
|
178
|
+
const row = ws.getRow(r);
|
|
179
|
+
const obj: Record<string, unknown> = {};
|
|
180
|
+
let hasValue = false;
|
|
181
|
+
for (let c = 1; c <= colCount; c++) {
|
|
182
|
+
const key = headers[c];
|
|
183
|
+
if (!key) continue;
|
|
184
|
+
const val = normalizeCell(row.getCell(c).value);
|
|
185
|
+
if (val !== null && val !== "") hasValue = true;
|
|
186
|
+
obj[key] = val;
|
|
187
|
+
}
|
|
188
|
+
if (!hasValue) continue; // skip fully-blank rows
|
|
189
|
+
|
|
190
|
+
const parsed = rowSchema.safeParse(obj);
|
|
191
|
+
if (!parsed.success) {
|
|
192
|
+
const issue = parsed.error.issues[0];
|
|
193
|
+
return err(validationErr(`row ${r}: ${parsed.error.message}`, issue?.path.join(".")));
|
|
194
|
+
}
|
|
195
|
+
rows.push(parsed.data);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return ok({ rows });
|
|
199
|
+
};
|