@casualoffice/sheets 0.9.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.
Files changed (92) hide show
  1. package/LICENSE +200 -0
  2. package/dist/embed/embed-runtime.js +537 -0
  3. package/dist/embed/embed.html +29 -0
  4. package/dist/embed/parser.worker.js +48474 -0
  5. package/dist/embed.cjs +225 -0
  6. package/dist/embed.cjs.map +1 -0
  7. package/dist/embed.d.cts +100 -0
  8. package/dist/embed.d.ts +100 -0
  9. package/dist/embed.js +204 -0
  10. package/dist/embed.js.map +1 -0
  11. package/dist/index.cjs +1549 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +9 -0
  14. package/dist/index.d.ts +9 -0
  15. package/dist/index.js +1530 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/parser.worker.cjs +48469 -0
  18. package/dist/parser.worker.cjs.map +1 -0
  19. package/dist/parser.worker.js +48474 -0
  20. package/dist/parser.worker.js.map +1 -0
  21. package/dist/protocol--KyBQUjU.d.cts +171 -0
  22. package/dist/protocol-cEzy7S0i.d.ts +171 -0
  23. package/dist/sheets.cjs +677 -0
  24. package/dist/sheets.cjs.map +1 -0
  25. package/dist/sheets.d.cts +177 -0
  26. package/dist/sheets.d.ts +177 -0
  27. package/dist/sheets.js +658 -0
  28. package/dist/sheets.js.map +1 -0
  29. package/dist/signing.cjs +706 -0
  30. package/dist/signing.cjs.map +1 -0
  31. package/dist/signing.d.cts +141 -0
  32. package/dist/signing.d.ts +141 -0
  33. package/dist/signing.js +683 -0
  34. package/dist/signing.js.map +1 -0
  35. package/dist/styles.cjs +10 -0
  36. package/dist/styles.cjs.map +1 -0
  37. package/dist/styles.d.cts +2 -0
  38. package/dist/styles.d.ts +2 -0
  39. package/dist/styles.js +8 -0
  40. package/dist/styles.js.map +1 -0
  41. package/dist/types-s_O0u6Cg.d.cts +90 -0
  42. package/dist/types-s_O0u6Cg.d.ts +90 -0
  43. package/dist/univer.cjs +220 -0
  44. package/dist/univer.cjs.map +1 -0
  45. package/dist/univer.d.cts +60 -0
  46. package/dist/univer.d.ts +60 -0
  47. package/dist/univer.js +187 -0
  48. package/dist/univer.js.map +1 -0
  49. package/dist/xlsx.cjs +3388 -0
  50. package/dist/xlsx.cjs.map +1 -0
  51. package/dist/xlsx.d.cts +383 -0
  52. package/dist/xlsx.d.ts +383 -0
  53. package/dist/xlsx.js +3383 -0
  54. package/dist/xlsx.js.map +1 -0
  55. package/package.json +293 -0
  56. package/src/embed/EmbedHostTransport.ts +226 -0
  57. package/src/embed/EmbedTransport.ts +323 -0
  58. package/src/embed/EmbedTransport.unit.test.ts +161 -0
  59. package/src/embed/index.ts +40 -0
  60. package/src/embed/protocol.ts +258 -0
  61. package/src/embed-runtime/embed.html +29 -0
  62. package/src/embed-runtime/index.tsx +440 -0
  63. package/src/index.ts +16 -0
  64. package/src/sheets/CasualSheets.tsx +319 -0
  65. package/src/sheets/CasualSheetsIframe.tsx +220 -0
  66. package/src/sheets/api.ts +108 -0
  67. package/src/sheets/index.ts +11 -0
  68. package/src/signing/SigningPane.tsx +374 -0
  69. package/src/signing/SigningProvider.tsx +126 -0
  70. package/src/signing/captures.tsx +316 -0
  71. package/src/signing/controller.ts +151 -0
  72. package/src/signing/controller.unit.test.ts +133 -0
  73. package/src/signing/index.ts +44 -0
  74. package/src/signing/types.ts +89 -0
  75. package/src/styles.ts +16 -0
  76. package/src/univer/index.ts +17 -0
  77. package/src/univer/lazy-plugins.ts +280 -0
  78. package/src/xlsx/_perf.ts +14 -0
  79. package/src/xlsx/_snapshot-constants.ts +15 -0
  80. package/src/xlsx/comments-resource.ts +209 -0
  81. package/src/xlsx/constants.ts +9 -0
  82. package/src/xlsx/data-validation-resource.ts +219 -0
  83. package/src/xlsx/import.ts +35 -0
  84. package/src/xlsx/index.ts +40 -0
  85. package/src/xlsx/page-setup-resource.ts +205 -0
  86. package/src/xlsx/parse-impl.ts +418 -0
  87. package/src/xlsx/parse-in-worker.ts +82 -0
  88. package/src/xlsx/parser.worker.ts +39 -0
  89. package/src/xlsx/passthrough-resource.ts +175 -0
  90. package/src/xlsx/pivot-passthrough.ts +359 -0
  91. package/src/xlsx/style-mapping.ts +171 -0
  92. package/src/xlsx/tables-resource.ts +211 -0
@@ -0,0 +1,418 @@
1
+ import ExcelJS from 'exceljs';
2
+ import {
3
+ CustomRangeType,
4
+ LocaleType,
5
+ type ICellData,
6
+ type IRange,
7
+ type IStyleData,
8
+ type IWorkbookData,
9
+ } from '@univerjs/core';
10
+ import { excelStyleToUniver } from './style-mapping';
11
+ import { INITIAL_COLUMNS, INITIAL_ROWS, UNIVER_VERSION } from './_snapshot-constants';
12
+ import { RESOURCES_SHEET } from './constants';
13
+ import {
14
+ mergeCommentsIntoResources,
15
+ readCommentsFromXlsx,
16
+ } from './comments-resource';
17
+ import {
18
+ mergePageSetupIntoResources,
19
+ readPageSetupFromXlsx,
20
+ } from './page-setup-resource';
21
+ import {
22
+ mergeDataValidationIntoResources,
23
+ readDataValidationFromXlsx,
24
+ } from './data-validation-resource';
25
+ import {
26
+ mergeTablesIntoResources,
27
+ readTablesFromXlsx,
28
+ } from './tables-resource';
29
+ import {
30
+ capturePassthroughFromBuffer,
31
+ mergePassthroughIntoResources,
32
+ } from './passthrough-resource';
33
+ import type { ImportedWorkbook } from './import';
34
+
35
+ /**
36
+ * Pure conversion: ExcelJS workbook → Univer IWorkbookData snapshot.
37
+ * Imported by both `parser.worker.ts` (where it actually runs) and the
38
+ * type-only `import.ts` (for the public types). Splitting this out keeps
39
+ * ExcelJS — which is large — out of the main bundle; only the worker
40
+ * chunk pays for it.
41
+ *
42
+ * Behavior is byte-for-byte equivalent to the previous in-place impl;
43
+ * see import.ts header for the fidelity scope.
44
+ */
45
+
46
+ let hyperlinkIdCounter = 0;
47
+ const nextHyperlinkId = () =>
48
+ `hl-${Date.now().toString(36)}-${(hyperlinkIdCounter++).toString(36)}`;
49
+
50
+ /**
51
+ * Build a rich-text doc body that encodes a hyperlink at the cell. The
52
+ * shape mirrors what `AddHyperLinkCommand` writes into `cell.p.body`
53
+ * (see vendor: sheets-hyper-link's AddHyperLinkCommand impl). Putting
54
+ * hyperlinks here at import time avoids the previous per-link serial
55
+ * `executeCommand` round-trip after mount — for a workbook with
56
+ * thousands of links that path took multiple seconds.
57
+ */
58
+ function buildHyperlinkBody(display: string, url: string, id: string): ICellData['p'] {
59
+ // Univer doc bodies end with `\r\n` (paragraph + section break). The
60
+ // customRange covers only the visible text [0, display.length - 1].
61
+ const dataStream = `${display}\r\n`;
62
+ return {
63
+ id: '__INTERNAL_EDITOR__DOCS_NORMAL',
64
+ documentStyle: {},
65
+ body: {
66
+ dataStream,
67
+ customRanges: [
68
+ {
69
+ startIndex: 0,
70
+ endIndex: display.length - 1,
71
+ rangeType: CustomRangeType.HYPERLINK,
72
+ rangeId: id,
73
+ properties: { url },
74
+ },
75
+ ],
76
+ paragraphs: [{ startIndex: display.length }],
77
+ sectionBreaks: [{ startIndex: display.length + 1 }],
78
+ textRuns: [],
79
+ },
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
+ } as any;
82
+ }
83
+
84
+ function readResourcesSheet(ws: ExcelJS.Worksheet): IWorkbookData['resources'] {
85
+ const parts: string[] = [];
86
+ ws.eachRow({ includeEmpty: false }, (row) => {
87
+ const v = row.getCell(1).value;
88
+ if (typeof v === 'string') parts.push(v);
89
+ });
90
+ if (parts.length === 0) return undefined;
91
+ try {
92
+ const parsed = JSON.parse(parts.join(''));
93
+ if (Array.isArray(parsed)) return parsed as IWorkbookData['resources'];
94
+ } catch {
95
+ /* corrupt blob — drop silently, the workbook still opens */
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * Pull `<definedName>` entries out of the xlsx workbook part and merge
102
+ * them into the SHEET_DEFINED_NAME_PLUGIN resource Univer reads on
103
+ * load. Our own sidecar (`__casual_sheets_resources__`) round-trips the
104
+ * full Univer shape (id, comment, hidden, etc.); xlsx-native defined
105
+ * names only carry `name` + `ranges`, so we synthesise the missing
106
+ * fields on the fly. Skipped when the sidecar already provided the
107
+ * resource — it has the richer payload.
108
+ */
109
+ const DEFINED_NAMES_RESOURCE = 'SHEET_DEFINED_NAME_PLUGIN';
110
+ function mergeDefinedNamesFromXlsx(
111
+ wb: ExcelJS.Workbook,
112
+ resources: IWorkbookData['resources'],
113
+ ): IWorkbookData['resources'] {
114
+ const existing = resources?.find((r) => r.name === DEFINED_NAMES_RESOURCE);
115
+ if (existing) return resources;
116
+
117
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
+ const model = (wb.definedNames as any).model as
119
+ | Array<{ name: string; ranges: string[] }>
120
+ | undefined;
121
+ if (!model?.length) return resources;
122
+
123
+ const map: Record<string, {
124
+ id: string;
125
+ name: string;
126
+ formulaOrRefString: string;
127
+ }> = {};
128
+ let i = 0;
129
+ for (const dn of model) {
130
+ if (!dn?.name || !Array.isArray(dn.ranges) || dn.ranges.length === 0) continue;
131
+ const id = `dn-${i++}`;
132
+ // Multi-range defined names get comma-joined; Univer's formula
133
+ // engine accepts that shape (range A, range B → "A,B").
134
+ map[id] = {
135
+ id,
136
+ name: dn.name,
137
+ formulaOrRefString: dn.ranges.join(','),
138
+ };
139
+ }
140
+ if (Object.keys(map).length === 0) return resources;
141
+
142
+ const next = [...(resources ?? [])];
143
+ next.push({ name: DEFINED_NAMES_RESOURCE, data: JSON.stringify(map) });
144
+ return next;
145
+ }
146
+
147
+ function lettersToCol(letters: string): number {
148
+ let col = 0;
149
+ for (let i = 0; i < letters.length; i++) {
150
+ col = col * 26 + (letters.charCodeAt(i) - 64);
151
+ }
152
+ return col - 1;
153
+ }
154
+
155
+ export async function workbookFromExcelJs(buffer: ArrayBuffer): Promise<ImportedWorkbook> {
156
+ // Capture raw OOXML parts ExcelJS drops (today: xl/vbaProject.bin) before
157
+ // it consumes the buffer. Reads the same bytes twice — once as zip here,
158
+ // once as ExcelJS below — but the second JSZip pass is ~tens of ms even
159
+ // for big files and means we don't lose macros on round-trip.
160
+ const passthrough = await capturePassthroughFromBuffer(buffer);
161
+
162
+ const wb = new ExcelJS.Workbook();
163
+ await wb.xlsx.load(buffer);
164
+
165
+ const id = `wb-${Date.now()}`;
166
+ const sheetOrder: string[] = [];
167
+ const sheets: IWorkbookData['sheets'] = {};
168
+ const styles: Record<string, IStyleData | null> = {};
169
+ let styleCounter = 0;
170
+
171
+ const styleByKey = new Map<string, string>();
172
+ const internStyle = (style: IStyleData | undefined): string | undefined => {
173
+ if (!style) return undefined;
174
+ const key = JSON.stringify(style);
175
+ const existing = styleByKey.get(key);
176
+ if (existing) return existing;
177
+ const styleId = `s${styleCounter++}`;
178
+ styleByKey.set(key, styleId);
179
+ styles[styleId] = style;
180
+ return styleId;
181
+ };
182
+
183
+ const PX_PER_CHAR = 7;
184
+ const charsToPx = (chars: number) => Math.round(chars * PX_PER_CHAR);
185
+ const pointsToPx = (pt: number) => Math.round((pt * 96) / 72);
186
+
187
+ let resources: IWorkbookData['resources'] | undefined;
188
+ for (const ws of wb.worksheets) {
189
+ if (ws.name === RESOURCES_SHEET) {
190
+ resources = readResourcesSheet(ws);
191
+ break;
192
+ }
193
+ }
194
+
195
+ // Mirror xlsx-native defined names into the Univer plugin resource so
196
+ // a file authored in real Excel keeps its named ranges. The hidden
197
+ // sidecar (`__casual_sheets_resources__`) carries the full Univer
198
+ // shape on our own round-trip — only fall back to xlsx defined names
199
+ // when the sidecar didn't already register them.
200
+ resources = mergeDefinedNamesFromXlsx(wb, resources);
201
+
202
+ // Same fall-back path for xlsx-native cell comments — files
203
+ // authored in real Excel keep their comments when opened here,
204
+ // and the audit's round-trip probe on `B2 comment text` passes.
205
+ const xlsxComments = readCommentsFromXlsx(wb, id, (excelId) => `sheet-${excelId}`);
206
+ resources = mergeCommentsIntoResources(resources, xlsxComments);
207
+
208
+ // Page-setup chrome (orientation, paper, margins, header/footer)
209
+ // is a passthrough sidecar — Univer doesn't model it; the
210
+ // exporter re-applies on save so the print metadata isn't lost.
211
+ const xlsxPageSetup = readPageSetupFromXlsx(wb, (excelId) => `sheet-${excelId}`);
212
+ resources = mergePageSetupIntoResources(resources, xlsxPageSetup);
213
+
214
+ // xlsx-native data validations → Univer's data-validation plugin
215
+ // resource. Using the plugin's exact resource name means the rule
216
+ // live-loads into the model on re-open (visible in the validation
217
+ // panel + enforced on edit), not just sitting in our sidecar.
218
+ const xlsxDv = readDataValidationFromXlsx(wb, (excelId) => `sheet-${excelId}`);
219
+ resources = mergeDataValidationIntoResources(resources, xlsxDv);
220
+
221
+ // xlsx ListObjects → passthrough table sidecar. Round-trips via
222
+ // `ws.addTable(...)` on save so the table definition survives even
223
+ // though Univer can't render the table chrome yet.
224
+ const xlsxTables = readTablesFromXlsx(wb, (excelId) => `sheet-${excelId}`);
225
+ resources = mergeTablesIntoResources(resources, xlsxTables);
226
+
227
+ // Raw OOXML parts we captured up top — stash them on the snapshot so
228
+ // the exporter can re-inject them and the file round-trips as .xlsm
229
+ // when it carried macros.
230
+ resources = mergePassthroughIntoResources(resources, passthrough);
231
+
232
+ for (const ws of wb.worksheets) {
233
+ if (ws.name === RESOURCES_SHEET) continue;
234
+ const sheetId = `sheet-${ws.id}`;
235
+ sheetOrder.push(sheetId);
236
+
237
+ const cellData: Record<number, Record<number, ICellData>> = {};
238
+ const mergeData: IRange[] = [];
239
+ const columnData: Record<number, { w?: number; hd?: number }> = {};
240
+ const rowData: Record<number, { h?: number; hd?: number }> = {};
241
+
242
+ let maxRow = 0;
243
+ let maxCol = 0;
244
+
245
+ ws.eachRow({ includeEmpty: false }, (row, rowNumber) => {
246
+ row.eachCell({ includeEmpty: false }, (cell, colNumber) => {
247
+ const r = rowNumber - 1;
248
+ const c = colNumber - 1;
249
+ maxRow = Math.max(maxRow, r);
250
+ maxCol = Math.max(maxCol, c);
251
+
252
+ const cd: ICellData = {};
253
+ const raw = cell.value;
254
+ if (raw && typeof raw === 'object' && 'formula' in raw) {
255
+ const f = (raw as { formula: string }).formula;
256
+ cd.f = f.startsWith('=') ? f : `=${f}`;
257
+ const result = (raw as { result?: unknown }).result;
258
+ if (result !== undefined && result !== null && typeof result !== 'object') {
259
+ cd.v = result as ICellData['v'];
260
+ }
261
+ } else if (raw && typeof raw === 'object' && 'richText' in raw) {
262
+ cd.v = (raw as { richText: { text: string }[] }).richText.map((t) => t.text).join('');
263
+ } else if (raw && typeof raw === 'object' && 'text' in raw && 'hyperlink' in raw) {
264
+ const display = (raw as { text: string }).text ?? '';
265
+ cd.v = display;
266
+ const url = (raw as { hyperlink: string }).hyperlink;
267
+ if (typeof url === 'string' && url && display.length > 0) {
268
+ // Inline the link into cell.p so it ships in the snapshot.
269
+ // No need to replay through AddHyperLinkCommand at mount.
270
+ cd.p = buildHyperlinkBody(display, url, nextHyperlinkId());
271
+ }
272
+ } else if (raw && typeof raw === 'object' && 'sharedFormula' in raw) {
273
+ const sf = (raw as { sharedFormula: string; result?: unknown }).sharedFormula;
274
+ cd.f = sf.startsWith('=') ? sf : `=${sf}`;
275
+ const result = (raw as { result?: unknown }).result;
276
+ if (result !== undefined && result !== null && typeof result !== 'object') {
277
+ cd.v = result as ICellData['v'];
278
+ }
279
+ } else if (raw instanceof Date) {
280
+ cd.v = raw.toISOString();
281
+ } else if (typeof raw === 'number' || typeof raw === 'boolean' || typeof raw === 'string') {
282
+ cd.v = raw;
283
+ }
284
+
285
+ const styleId = internStyle(excelStyleToUniver(cell));
286
+ if (styleId) cd.s = styleId;
287
+
288
+ if (cd.v !== undefined || cd.f || cd.s) {
289
+ cellData[r] ??= {};
290
+ cellData[r][c] = cd;
291
+ }
292
+ });
293
+ });
294
+
295
+ const merges = (ws.model as { merges?: string[] }).merges ?? [];
296
+ for (const range of merges) {
297
+ const m = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/.exec(range);
298
+ if (!m) continue;
299
+ const [, startColL, startRowS, endColL, endRowS] = m;
300
+ const start = { row: Number(startRowS) - 1, col: lettersToCol(startColL) };
301
+ const end = { row: Number(endRowS) - 1, col: lettersToCol(endColL) };
302
+ mergeData.push({
303
+ startRow: start.row,
304
+ startColumn: start.col,
305
+ endRow: end.row,
306
+ endColumn: end.col,
307
+ });
308
+ }
309
+
310
+ const wsColumns = (ws as { columns?: Array<{ width?: number; hidden?: boolean } | null> })
311
+ .columns ?? [];
312
+ wsColumns.forEach((col, i) => {
313
+ if (!col) return;
314
+ const entry: { w?: number; hd?: number } = {};
315
+ if (typeof col.width === 'number') entry.w = charsToPx(col.width);
316
+ if (col.hidden) entry.hd = 1;
317
+ if (entry.w !== undefined || entry.hd !== undefined) columnData[i] = entry;
318
+ });
319
+
320
+ ws.eachRow({ includeEmpty: true }, (row, rowNumber) => {
321
+ const entry: { h?: number; hd?: number } = {};
322
+ if (typeof row.height === 'number') entry.h = pointsToPx(row.height);
323
+ if (row.hidden) entry.hd = 1;
324
+ if (entry.h !== undefined || entry.hd !== undefined) rowData[rowNumber - 1] = entry;
325
+ });
326
+
327
+ let freeze: { xSplit: number; ySplit: number; startRow: number; startColumn: number } | undefined;
328
+ const view = (ws as { views?: Array<{ state?: string; xSplit?: number; ySplit?: number }> })
329
+ .views?.[0];
330
+ if (view?.state === 'frozen') {
331
+ const xSplit = view.xSplit ?? 0;
332
+ const ySplit = view.ySplit ?? 0;
333
+ if (xSplit > 0 || ySplit > 0) {
334
+ freeze = {
335
+ xSplit,
336
+ ySplit,
337
+ startRow: ySplit > 0 ? ySplit : -1,
338
+ startColumn: xSplit > 0 ? xSplit : -1,
339
+ };
340
+ }
341
+ }
342
+
343
+ const argb = ws.properties?.tabColor?.argb;
344
+ const tabColor =
345
+ argb && /^[0-9A-Fa-f]{8}$/.test(argb) ? `#${argb.slice(2).toLowerCase()}` : undefined;
346
+
347
+ const defaultColumnWidth =
348
+ typeof ws.properties?.defaultColWidth === 'number'
349
+ ? charsToPx(ws.properties.defaultColWidth)
350
+ : undefined;
351
+ const defaultRowHeight =
352
+ typeof ws.properties?.defaultRowHeight === 'number'
353
+ ? pointsToPx(ws.properties.defaultRowHeight)
354
+ : undefined;
355
+
356
+ const hidden = (ws as { state?: string }).state === 'hidden' ? 1 : undefined;
357
+
358
+ sheets[sheetId] = {
359
+ id: sheetId,
360
+ name: ws.name,
361
+ cellData,
362
+ mergeData,
363
+ columnData,
364
+ rowData,
365
+ rowCount: Math.max(INITIAL_ROWS, maxRow + 1),
366
+ columnCount: Math.max(INITIAL_COLUMNS, maxCol + 1),
367
+ ...(freeze ? { freeze } : {}),
368
+ ...(tabColor ? { tabColor } : {}),
369
+ ...(defaultColumnWidth !== undefined ? { defaultColumnWidth } : {}),
370
+ ...(defaultRowHeight !== undefined ? { defaultRowHeight } : {}),
371
+ ...(hidden ? { hidden } : {}),
372
+ };
373
+ }
374
+
375
+ if (sheetOrder.length === 0) {
376
+ sheetOrder.push('sheet-1');
377
+ sheets['sheet-1'] = {
378
+ id: 'sheet-1',
379
+ name: 'Sheet1',
380
+ cellData: {},
381
+ rowCount: INITIAL_ROWS,
382
+ columnCount: INITIAL_COLUMNS,
383
+ };
384
+ }
385
+
386
+ return {
387
+ id,
388
+ rev: 1,
389
+ name: wb.title || 'Untitled',
390
+ appVersion: UNIVER_VERSION,
391
+ locale: LocaleType.EN_US,
392
+ styles,
393
+ sheetOrder,
394
+ sheets,
395
+ ...(resources ? { resources } : {}),
396
+ custom: {
397
+ // Map xlsx Core/App properties back into our `custom.properties`
398
+ // slot so the Properties dialog shows what the file actually
399
+ // carried. The exporter mirrors the same names in reverse.
400
+ properties: {
401
+ ...(wb.title ? { title: wb.title } : {}),
402
+ ...(wb.subject ? { subject: wb.subject } : {}),
403
+ ...(wb.description ? { description: wb.description } : {}),
404
+ ...(wb.keywords ? { tags: wb.keywords } : {}),
405
+ ...(wb.category ? { category: wb.category } : {}),
406
+ ...(wb.company ? { company: wb.company } : {}),
407
+ ...(wb.manager ? { manager: wb.manager } : {}),
408
+ ...(wb.creator ? { author: wb.creator } : {}),
409
+ ...(wb.created instanceof Date && !isNaN(wb.created.getTime())
410
+ ? { createdAt: wb.created.toISOString() }
411
+ : {}),
412
+ ...(wb.modified instanceof Date && !isNaN(wb.modified.getTime())
413
+ ? { modifiedAt: wb.modified.toISOString() }
414
+ : {}),
415
+ },
416
+ },
417
+ };
418
+ }
@@ -0,0 +1,82 @@
1
+ import type { ImportedWorkbook } from './import';
2
+
3
+ /**
4
+ * Main-thread entry point for xlsx parsing. Spawns a Web Worker,
5
+ * transfers the buffer in (zero-copy), awaits a single response,
6
+ * terminates the worker. The buffer becomes detached on the main
7
+ * side after transfer — callers must not reuse it.
8
+ *
9
+ * Why a fresh worker per parse: parses are rare (open / drop /
10
+ * collab seed) and the worker carries a ~600 KB ExcelJS payload —
11
+ * keeping it alive between parses would idle that memory. A pool
12
+ * is a follow-up optimization if we see many sequential opens.
13
+ */
14
+
15
+ let nextId = 0;
16
+
17
+ // Cap the wait at 3 minutes. A multi-hundred-MB ExcelJS parse can take
18
+ // over a minute on slow hardware; anything past 3 minutes is almost
19
+ // certainly a hung/deadlocked worker (e.g. crashed without firing
20
+ // `error`). Reject so the UI shows an actionable message instead of
21
+ // spinning forever.
22
+ const PARSE_TIMEOUT_MS = 180_000;
23
+
24
+ export function parseXlsxInWorker(buffer: ArrayBuffer): Promise<ImportedWorkbook> {
25
+ return new Promise((resolve, reject) => {
26
+ const worker = new Worker(new URL('./parser.worker.ts', import.meta.url), {
27
+ type: 'module',
28
+ name: 'xlsx-parser',
29
+ });
30
+ const id = ++nextId;
31
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
32
+ const cleanup = () => {
33
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle);
34
+ worker.terminate();
35
+ };
36
+ timeoutHandle = setTimeout(() => {
37
+ cleanup();
38
+ reject(
39
+ new Error(
40
+ `xlsx parser worker timed out after ${Math.round(PARSE_TIMEOUT_MS / 1000)}s. The workbook may be corrupted or too large to parse in this browser.`,
41
+ ),
42
+ );
43
+ }, PARSE_TIMEOUT_MS);
44
+ worker.addEventListener(
45
+ 'message',
46
+ (e: MessageEvent<{ id: number; ok: true; data: ImportedWorkbook } | { id: number; ok: false; error: string }>) => {
47
+ const msg = e.data;
48
+ if (msg.id !== id) return;
49
+ cleanup();
50
+ if (msg.ok) resolve(msg.data);
51
+ else reject(new Error(msg.error));
52
+ },
53
+ );
54
+ worker.addEventListener('error', (e) => {
55
+ cleanup();
56
+ // ErrorEvent.message is "" when the worker crashes from OOM in
57
+ // some browsers; with no message AND no filename it's almost
58
+ // always OOM mid-allocation. Emit a clear hint instead of the
59
+ // useless "(unknown)" we used to show.
60
+ const hasDetail = Boolean(e.message || e.filename);
61
+ if (!hasDetail) {
62
+ reject(
63
+ new Error(
64
+ 'xlsx parser worker ran out of memory parsing this file. Try a smaller workbook or close other browser tabs.',
65
+ ),
66
+ );
67
+ return;
68
+ }
69
+ const detail = e.message || `${e.filename ?? ''}:${e.lineno ?? ''}`;
70
+ reject(
71
+ new Error(
72
+ `xlsx parser worker crashed (${detail}). The workbook may be too large for this browser to allocate.`,
73
+ ),
74
+ );
75
+ });
76
+ worker.addEventListener('messageerror', () => {
77
+ cleanup();
78
+ reject(new Error('xlsx parser worker returned an unserializable result.'));
79
+ });
80
+ worker.postMessage({ id, buffer }, [buffer]);
81
+ });
82
+ }
@@ -0,0 +1,39 @@
1
+ /// <reference lib="webworker" />
2
+
3
+ import { workbookFromExcelJs } from './parse-impl';
4
+ import type { ImportedWorkbook } from './import';
5
+
6
+ /**
7
+ * Worker side of the xlsx parser. Receives an ArrayBuffer via the
8
+ * transferable list (zero-copy in), runs the pure ExcelJS conversion,
9
+ * sends back the IWorkbookData. Errors are stringified — Workbook
10
+ * objects don't structured-clone, but plain JSON does.
11
+ *
12
+ * The worker is single-shot per parse: parse-in-worker.ts spawns,
13
+ * posts, awaits, terminates. Keeping it stateless side-steps any
14
+ * worker-lifecycle bookkeeping for now; if we end up parsing many
15
+ * small workbooks in succession we can switch to a pooled model.
16
+ */
17
+
18
+ type ParseRequest = { id: number; buffer: ArrayBuffer };
19
+ type ParseResponse =
20
+ | { id: number; ok: true; data: ImportedWorkbook }
21
+ | { id: number; ok: false; error: string };
22
+
23
+ const ctx = self as unknown as DedicatedWorkerGlobalScope;
24
+
25
+ ctx.addEventListener('message', async (e: MessageEvent<ParseRequest>) => {
26
+ const { id, buffer } = e.data;
27
+ try {
28
+ const data = await workbookFromExcelJs(buffer);
29
+ const reply: ParseResponse = { id, ok: true, data };
30
+ ctx.postMessage(reply);
31
+ } catch (err) {
32
+ // Preserve the underlying error type + stack so the main-thread
33
+ // overlay can show something more useful than "Error code: 5".
34
+ const error =
35
+ err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err);
36
+ const reply: ParseResponse = { id, ok: false, error };
37
+ ctx.postMessage(reply);
38
+ }
39
+ });