webspresso 0.0.95 → 0.0.97
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/.agents/ADMIN.md +225 -0
- package/.agents/AGENTS.md +72 -0
- package/.agents/ARCHITECTURE.md +119 -0
- package/.agents/AUTH.md +103 -0
- package/.agents/CLI.md +115 -0
- package/.agents/COMPRESSION.md +59 -0
- package/.agents/CONVENTIONS.md +46 -0
- package/.agents/EMAIL.md +141 -0
- package/.agents/ERRORS.md +201 -0
- package/.agents/MCP.md +103 -0
- package/.agents/ORM.md +230 -0
- package/.agents/PLUGINS.md +419 -0
- package/.agents/QUEUE.md +113 -0
- package/.agents/REALTIME.md +213 -0
- package/.agents/ROUTING.md +292 -0
- package/.agents/SERVICES.md +341 -0
- package/.agents/SHUTDOWN.md +67 -0
- package/.agents/STREAMING.md +73 -0
- package/.agents/WORKFLOW.md +27 -0
- package/.agents/internal/DOCUMENTATION_REVIEW.md +485 -0
- package/.agents/internal/TECHNICAL_REVIEW.md +428 -0
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
- package/.agents/skills/webspresso-framework/SKILL.md +56 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
- package/.agents/skills/webspresso-services/SKILL.md +101 -0
- package/.cursorrules +14 -0
- package/AGENTS.md +175 -0
- package/CLAUDE.md +54 -0
- package/README.md +18 -14
- package/bin/commands/admin-password.js +2 -1
- package/bin/commands/admin-setup.js +1 -1
- package/bin/commands/agents-init.js +209 -0
- package/bin/commands/api.js +114 -48
- package/bin/commands/audit-prune.js +1 -1
- package/bin/commands/doctor.js +35 -0
- package/bin/commands/favicon-generate.js +1 -1
- package/bin/commands/new.js +13 -2
- package/bin/commands/orm-map.js +5 -4
- package/bin/commands/page.js +75 -89
- package/bin/commands/polar-migrate.js +1 -1
- package/bin/commands/seed.js +1 -1
- package/bin/commands/service.js +77 -0
- package/bin/webspresso.js +2 -2
- package/core/auth/hash.js +10 -5
- package/core/compileSchema.js +1 -1
- package/core/errors/middleware.js +22 -2
- package/core/errors/normalize.js +29 -2
- package/{utils/schemaCache.js → core/schema-cache.js} +2 -3
- package/index.d.ts +306 -0
- package/index.js +35 -1
- package/package.json +36 -14
- package/plugins/admin-panel/app.js +10 -9
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
- package/plugins/admin-panel/core/api-extensions.js +4 -0
- package/plugins/basic-auth/index.js +3 -1
- package/plugins/content/client/inline-edit.js +12 -12
- package/plugins/cors.js +6 -7
- package/plugins/csrf/index.js +3 -2
- package/plugins/csv/engine.js +447 -0
- package/plugins/csv/index.js +94 -0
- package/plugins/csv/middleware.js +80 -0
- package/plugins/csv/services.js +138 -0
- package/plugins/file-manager/admin-component.js +613 -0
- package/plugins/file-manager/api-handlers.js +261 -0
- package/plugins/file-manager/core.js +601 -0
- package/plugins/file-manager/index.js +139 -0
- package/plugins/file-manager/security.js +217 -0
- package/plugins/index.js +45 -79
- package/plugins/mcp/transports/sse.js +4 -2
- package/plugins/site-analytics/admin-component.js +7 -8
- package/plugins/xlsx/engine.js +397 -0
- package/plugins/xlsx/index.js +91 -0
- package/plugins/xlsx/middleware.js +87 -0
- package/plugins/xlsx/services.js +118 -0
- package/src/api/api-loader.js +233 -0
- package/src/api/define-api.js +35 -0
- package/src/discovery/file-route-parser.js +201 -0
- package/src/discovery/scan-routes.js +265 -0
- package/src/file-router.js +9 -1
- package/src/modules/define-module.js +99 -0
- package/src/modules/module-discovery.js +117 -0
- package/src/pages/define-page.js +36 -0
- package/src/pages/page-loader.js +214 -0
- package/src/routing/mount-discovered-routes.js +78 -0
- package/src/routing/route-table.js +191 -0
- package/src/server.js +112 -15
- package/src/services/builtins/file-manager.js +178 -0
- package/src/services/discovery.js +6 -1
- package/src/services/index.js +6 -11
- package/bin/commands/skill.js +0 -187
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +0 -289
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +0 -93
- package/templates/skills/webspresso-usage/SKILL.md +0 -48
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso XLSX Core Engine
|
|
5
|
+
* High-performance Excel spreadsheet generator, parser, and ORM exporter
|
|
6
|
+
* @module plugins/xlsx/engine
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const ExcelJS = require('exceljs');
|
|
10
|
+
const { PassThrough } = require('stream');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Common formula injection trigger characters
|
|
14
|
+
* @type {RegExp}
|
|
15
|
+
*/
|
|
16
|
+
const FORMULA_INJECTION_REGEX = /^([=+\-@\t\r])/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Neutralizes Excel formula injection by escaping dangerous prefixes
|
|
20
|
+
* @param {*} value
|
|
21
|
+
* @returns {*}
|
|
22
|
+
*/
|
|
23
|
+
function sanitizeFormula(value) {
|
|
24
|
+
if (typeof value === 'string' && FORMULA_INJECTION_REGEX.test(value)) {
|
|
25
|
+
return `'${value}`;
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Converts a 6-character hex color (e.g. '4F46E5' or '#4F46E5') to ARGB
|
|
32
|
+
* @param {string} hex
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function hexToArgb(hex) {
|
|
36
|
+
if (!hex || typeof hex !== 'string') return 'FF4F46E5';
|
|
37
|
+
let clean = hex.replace(/^#/, '').trim();
|
|
38
|
+
if (clean.length === 3) {
|
|
39
|
+
clean = clean.split('').map((c) => c + c).join('');
|
|
40
|
+
}
|
|
41
|
+
if (clean.length === 6) {
|
|
42
|
+
return `FF${clean.toUpperCase()}`;
|
|
43
|
+
}
|
|
44
|
+
if (clean.length === 8) {
|
|
45
|
+
return clean.toUpperCase();
|
|
46
|
+
}
|
|
47
|
+
return 'FF4F46E5';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Automatically calculates optimal column widths based on headers and sample rows
|
|
52
|
+
* @param {ExcelJS.Worksheet} worksheet
|
|
53
|
+
* @param {Array<Object>} columns
|
|
54
|
+
* @param {Array<Object>} rows
|
|
55
|
+
*/
|
|
56
|
+
function applyAutoWidths(worksheet, columns, rows) {
|
|
57
|
+
if (!columns || !Array.isArray(columns)) return;
|
|
58
|
+
|
|
59
|
+
columns.forEach((col, index) => {
|
|
60
|
+
const colIndex = index + 1;
|
|
61
|
+
let maxLength = col.header ? String(col.header).length : 10;
|
|
62
|
+
|
|
63
|
+
// Sample up to first 100 rows for performance
|
|
64
|
+
const sampleLimit = Math.min(rows.length, 100);
|
|
65
|
+
for (let i = 0; i < sampleLimit; i++) {
|
|
66
|
+
const val = rows[i] ? rows[i][col.key] : '';
|
|
67
|
+
if (val !== null && val !== undefined) {
|
|
68
|
+
const strVal = val instanceof Date ? 'YYYY-MM-DD HH:mm:ss' : String(val);
|
|
69
|
+
if (strVal.length > maxLength) {
|
|
70
|
+
maxLength = strVal.length;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const column = worksheet.getColumn(colIndex);
|
|
76
|
+
column.width = Math.min(Math.max(maxLength + 4, 12), 60);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Applies styled headers to a worksheet
|
|
82
|
+
* @param {ExcelJS.Worksheet} worksheet
|
|
83
|
+
* @param {Object} [style]
|
|
84
|
+
*/
|
|
85
|
+
function applyHeaderStyle(worksheet, style = {}) {
|
|
86
|
+
const headerRow = worksheet.getRow(1);
|
|
87
|
+
const bgColor = hexToArgb(style.bg || style.fill || '4F46E5');
|
|
88
|
+
const textColor = hexToArgb(style.color || style.textColor || 'FFFFFF');
|
|
89
|
+
|
|
90
|
+
headerRow.height = style.height || 26;
|
|
91
|
+
headerRow.eachCell((cell) => {
|
|
92
|
+
cell.font = {
|
|
93
|
+
name: style.fontName || 'Segoe UI',
|
|
94
|
+
size: style.fontSize || 11,
|
|
95
|
+
bold: style.bold !== false,
|
|
96
|
+
color: { argb: textColor },
|
|
97
|
+
};
|
|
98
|
+
cell.fill = {
|
|
99
|
+
type: 'pattern',
|
|
100
|
+
pattern: 'solid',
|
|
101
|
+
fgColor: { argb: bgColor },
|
|
102
|
+
};
|
|
103
|
+
cell.alignment = {
|
|
104
|
+
vertical: 'middle',
|
|
105
|
+
horizontal: style.align || 'left',
|
|
106
|
+
wrapText: false,
|
|
107
|
+
};
|
|
108
|
+
cell.border = {
|
|
109
|
+
top: { style: 'thin', color: { argb: 'FFE5E7EB' } },
|
|
110
|
+
left: { style: 'thin', color: { argb: 'FFE5E7EB' } },
|
|
111
|
+
bottom: { style: 'medium', color: { argb: 'FF3730A3' } },
|
|
112
|
+
right: { style: 'thin', color: { argb: 'FFE5E7EB' } },
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Generates an Excel XLSX workbook buffer from declarative options
|
|
119
|
+
* @param {Object} options
|
|
120
|
+
* @param {string} [options.creator='Webspresso']
|
|
121
|
+
* @param {string} [options.title]
|
|
122
|
+
* @param {Date} [options.created]
|
|
123
|
+
* @param {boolean} [options.sanitizeFormulas=true]
|
|
124
|
+
* @param {Array<Object>|Object} options.sheets - Sheet configuration(s)
|
|
125
|
+
* @returns {Promise<Buffer>}
|
|
126
|
+
*/
|
|
127
|
+
async function generate(options = {}) {
|
|
128
|
+
const workbook = new ExcelJS.Workbook();
|
|
129
|
+
workbook.creator = options.creator || 'Webspresso';
|
|
130
|
+
workbook.created = options.created || new Date();
|
|
131
|
+
if (options.title) workbook.title = options.title;
|
|
132
|
+
|
|
133
|
+
const sanitize = options.sanitizeFormulas !== false;
|
|
134
|
+
|
|
135
|
+
// Normalize sheets: can be single object or array
|
|
136
|
+
const rawSheets = options.sheets
|
|
137
|
+
? Array.isArray(options.sheets) ? options.sheets : [options.sheets]
|
|
138
|
+
: [{ name: options.name || 'Sheet1', columns: options.columns, rows: options.rows || options.data || [] }];
|
|
139
|
+
|
|
140
|
+
for (let idx = 0; idx < rawSheets.length; idx++) {
|
|
141
|
+
const sheetConfig = rawSheets[idx];
|
|
142
|
+
const sheetName = sheetConfig.name || `Sheet${idx + 1}`;
|
|
143
|
+
const worksheet = workbook.addWorksheet(sheetName, {
|
|
144
|
+
views: sheetConfig.views || [{ state: 'frozen', xSplit: 0, ySplit: 1 }],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const rows = Array.isArray(sheetConfig.rows)
|
|
148
|
+
? sheetConfig.rows
|
|
149
|
+
: Array.isArray(sheetConfig.data)
|
|
150
|
+
? sheetConfig.data
|
|
151
|
+
: [];
|
|
152
|
+
|
|
153
|
+
// Derive columns if not explicitly provided
|
|
154
|
+
let columns = sheetConfig.columns;
|
|
155
|
+
if (!columns && rows.length > 0 && typeof rows[0] === 'object' && rows[0] !== null) {
|
|
156
|
+
columns = Object.keys(rows[0]).map((key) => ({
|
|
157
|
+
header: key,
|
|
158
|
+
key,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (columns && Array.isArray(columns)) {
|
|
163
|
+
worksheet.columns = columns.map((col) => ({
|
|
164
|
+
header: col.header || col.name || col.key,
|
|
165
|
+
key: col.key,
|
|
166
|
+
width: col.width || 15,
|
|
167
|
+
style: col.style || {},
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
// Apply auto-widths if requested (default: true)
|
|
171
|
+
if (sheetConfig.autoWidth !== false) {
|
|
172
|
+
applyAutoWidths(worksheet, columns, rows);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Add rows
|
|
176
|
+
for (const row of rows) {
|
|
177
|
+
const rowData = {};
|
|
178
|
+
for (const col of columns) {
|
|
179
|
+
let val = row ? row[col.key] : '';
|
|
180
|
+
if (sanitize) {
|
|
181
|
+
val = sanitizeFormula(val);
|
|
182
|
+
}
|
|
183
|
+
rowData[col.key] = val !== undefined ? val : null;
|
|
184
|
+
}
|
|
185
|
+
worksheet.addRow(rowData);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Format headers
|
|
189
|
+
applyHeaderStyle(worksheet, sheetConfig.headerStyle || options.headerStyle);
|
|
190
|
+
|
|
191
|
+
// Apply zebra striping or row borders if enabled
|
|
192
|
+
if (sheetConfig.striped !== false) {
|
|
193
|
+
worksheet.eachRow((row, rowNumber) => {
|
|
194
|
+
if (rowNumber === 1) return;
|
|
195
|
+
row.alignment = { vertical: 'middle' };
|
|
196
|
+
if (rowNumber % 2 === 0) {
|
|
197
|
+
row.eachCell((cell) => {
|
|
198
|
+
if (!cell.fill || cell.fill.type !== 'pattern') {
|
|
199
|
+
cell.fill = {
|
|
200
|
+
type: 'pattern',
|
|
201
|
+
pattern: 'solid',
|
|
202
|
+
fgColor: { argb: 'FFF9FAFB' },
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const buffer = await workbook.xlsx.writeBuffer();
|
|
213
|
+
return Buffer.from(buffer);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parses an XLSX buffer or stream into JSON objects
|
|
218
|
+
* @param {Buffer|import('stream').Readable} input
|
|
219
|
+
* @param {Object} [options]
|
|
220
|
+
* @param {string|number} [options.sheet] - Sheet name or 1-based index
|
|
221
|
+
* @param {number} [options.headerRow=1] - Row index of headers
|
|
222
|
+
* @param {number} [options.dataStartRow=2] - Row index where data begins
|
|
223
|
+
* @param {boolean} [options.raw=false] - Return raw cell values without formatting
|
|
224
|
+
* @param {Function} [options.transformRow] - Custom row transformer
|
|
225
|
+
* @returns {Promise<Array<Object>|Object.<string, Array<Object>>>}
|
|
226
|
+
*/
|
|
227
|
+
async function parse(input, options = {}) {
|
|
228
|
+
const workbook = new ExcelJS.Workbook();
|
|
229
|
+
|
|
230
|
+
if (Buffer.isBuffer(input)) {
|
|
231
|
+
await workbook.xlsx.load(input);
|
|
232
|
+
} else if (input && typeof input.pipe === 'function') {
|
|
233
|
+
await workbook.xlsx.read(input);
|
|
234
|
+
} else {
|
|
235
|
+
throw new Error('parseXlsx requires a valid Buffer or Readable Stream');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const headerRowIdx = options.headerRow || 1;
|
|
239
|
+
const dataStartRowIdx = options.dataStartRow || headerRowIdx + 1;
|
|
240
|
+
const transformRow = typeof options.transformRow === 'function' ? options.transformRow : null;
|
|
241
|
+
|
|
242
|
+
function parseWorksheet(ws) {
|
|
243
|
+
const headers = [];
|
|
244
|
+
const headerRow = ws.getRow(headerRowIdx);
|
|
245
|
+
|
|
246
|
+
headerRow.eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
|
247
|
+
let val = cell.value;
|
|
248
|
+
if (typeof val === 'object' && val !== null) {
|
|
249
|
+
val = val.text || val.result || String(val);
|
|
250
|
+
}
|
|
251
|
+
const headerName = String(val || `col_${colNumber}`).trim();
|
|
252
|
+
headers[colNumber] = headerName;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const rows = [];
|
|
256
|
+
const rowCount = ws.rowCount;
|
|
257
|
+
|
|
258
|
+
for (let r = dataStartRowIdx; r <= rowCount; r++) {
|
|
259
|
+
const row = ws.getRow(r);
|
|
260
|
+
if (!row || !row.hasValues) continue;
|
|
261
|
+
|
|
262
|
+
const rowObj = {};
|
|
263
|
+
let hasData = false;
|
|
264
|
+
|
|
265
|
+
headers.forEach((header, colNum) => {
|
|
266
|
+
if (!header) return;
|
|
267
|
+
const cell = row.getCell(colNum);
|
|
268
|
+
let cellVal = cell.value;
|
|
269
|
+
|
|
270
|
+
if (cellVal && typeof cellVal === 'object') {
|
|
271
|
+
if (cellVal instanceof Date) {
|
|
272
|
+
// Keep Date instance or format
|
|
273
|
+
} else if (cellVal.text !== undefined) {
|
|
274
|
+
cellVal = cellVal.text;
|
|
275
|
+
} else if (cellVal.result !== undefined) {
|
|
276
|
+
cellVal = cellVal.result;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (cellVal !== null && cellVal !== undefined && cellVal !== '') {
|
|
281
|
+
hasData = true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
rowObj[header] = cellVal !== undefined ? cellVal : null;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (hasData) {
|
|
288
|
+
rows.push(transformRow ? transformRow(rowObj, rows.length) : rowObj);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return rows;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// If specific sheet requested
|
|
296
|
+
if (options.sheet !== undefined) {
|
|
297
|
+
let ws;
|
|
298
|
+
if (typeof options.sheet === 'number') {
|
|
299
|
+
ws = workbook.getWorksheet(options.sheet);
|
|
300
|
+
} else {
|
|
301
|
+
ws = workbook.getWorksheet(String(options.sheet));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!ws) {
|
|
305
|
+
throw new Error(`Worksheet "${options.sheet}" not found in XLSX workbook`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return parseWorksheet(ws);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// If only 1 sheet exists, return array directly for convenience
|
|
312
|
+
if (workbook.worksheets.length === 1) {
|
|
313
|
+
return parseWorksheet(workbook.worksheets[0]);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Multiple sheets: return dictionary keyed by sheet name
|
|
317
|
+
const result = {};
|
|
318
|
+
for (const ws of workbook.worksheets) {
|
|
319
|
+
result[ws.name] = parseWorksheet(ws);
|
|
320
|
+
}
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Automatically exports records from an ORM Repository into an Excel workbook buffer
|
|
326
|
+
* @param {Object} repository - Model repository from db.getRepository('Model')
|
|
327
|
+
* @param {Object} [filterOrQuery={}] - Query filters
|
|
328
|
+
* @param {Object} [options={}] - Export options
|
|
329
|
+
* @returns {Promise<Buffer>}
|
|
330
|
+
*/
|
|
331
|
+
async function fromModel(repository, filterOrQuery = {}, options = {}) {
|
|
332
|
+
if (!repository || (typeof repository.find !== 'function' && typeof repository.query !== 'function')) {
|
|
333
|
+
throw new Error('fromModel requires a valid Webspresso Model Repository');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let records;
|
|
337
|
+
if (typeof filterOrQuery === 'function') {
|
|
338
|
+
if (typeof repository.query !== 'function') {
|
|
339
|
+
throw new Error('fromModel query callback requires a repository with query() method');
|
|
340
|
+
}
|
|
341
|
+
records = await filterOrQuery(repository.query());
|
|
342
|
+
} else {
|
|
343
|
+
records = typeof repository.find === 'function' ? await repository.find(filterOrQuery) : [];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const modelName = repository.model?.name || 'Records';
|
|
347
|
+
const sheetName = options.sheetName || options.name || modelName;
|
|
348
|
+
|
|
349
|
+
let columns = options.columns;
|
|
350
|
+
if (!columns && repository.model?.schema?.shape) {
|
|
351
|
+
const hidden = new Set(repository.model.hidden || []);
|
|
352
|
+
columns = Object.keys(repository.model.schema.shape)
|
|
353
|
+
.filter((col) => !hidden.has(col))
|
|
354
|
+
.map((col) => ({
|
|
355
|
+
header: col.charAt(0).toUpperCase() + col.slice(1).replace(/_/g, ' '),
|
|
356
|
+
key: col,
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return generate({
|
|
361
|
+
creator: options.creator || 'Webspresso ORM',
|
|
362
|
+
title: options.title || `${modelName} Export`,
|
|
363
|
+
sheets: [
|
|
364
|
+
{
|
|
365
|
+
name: sheetName,
|
|
366
|
+
columns,
|
|
367
|
+
rows: records,
|
|
368
|
+
headerStyle: options.headerStyle,
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Creates a high-performance streaming workbook writer for massive datasets
|
|
376
|
+
* @param {import('stream').Writable} outputStream
|
|
377
|
+
* @param {Object} [options]
|
|
378
|
+
* @returns {ExcelJS.stream.xlsx.WorkbookWriter}
|
|
379
|
+
*/
|
|
380
|
+
function createStreamWriter(outputStream, options = {}) {
|
|
381
|
+
return new ExcelJS.stream.xlsx.WorkbookWriter({
|
|
382
|
+
stream: outputStream,
|
|
383
|
+
useStyles: options.useStyles !== false,
|
|
384
|
+
useSharedStrings: options.useSharedStrings || false,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = {
|
|
389
|
+
generate,
|
|
390
|
+
parse,
|
|
391
|
+
fromModel,
|
|
392
|
+
createStreamWriter,
|
|
393
|
+
sanitizeFormula,
|
|
394
|
+
applyHeaderStyle,
|
|
395
|
+
applyAutoWidths,
|
|
396
|
+
hexToArgb,
|
|
397
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso XLSX Plugin
|
|
5
|
+
* Excel spreadsheet generation, parsing, ORM export, streaming, and res.xlsx() integration
|
|
6
|
+
* @module plugins/xlsx
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const engine = require('./engine');
|
|
10
|
+
const { createXlsxMiddleware, XLSX_MIME_TYPE } = require('./middleware');
|
|
11
|
+
const { createXlsxServices } = require('./services');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Standalone XLSX Toolkit Singleton
|
|
15
|
+
*/
|
|
16
|
+
const xlsx = {
|
|
17
|
+
...engine,
|
|
18
|
+
MIME_TYPE: XLSX_MIME_TYPE,
|
|
19
|
+
createMiddleware: createXlsxMiddleware,
|
|
20
|
+
createServices: createXlsxServices,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* XLSX Plugin for Webspresso
|
|
25
|
+
* @param {Object} [options]
|
|
26
|
+
* @param {boolean} [options.enabled=true]
|
|
27
|
+
* @param {boolean} [options.sanitizeFormulas=true] - Prevent Formula Injection attacks
|
|
28
|
+
* @param {Object} [options.headerStyle] - Default header styling (fill, font, border)
|
|
29
|
+
* @param {Object} [options.db] - Optional database instance for model export services
|
|
30
|
+
* @returns {import('../../index').Plugin}
|
|
31
|
+
*/
|
|
32
|
+
function xlsxPlugin(options = {}) {
|
|
33
|
+
const {
|
|
34
|
+
enabled = true,
|
|
35
|
+
sanitizeFormulas = true,
|
|
36
|
+
headerStyle = { fill: '4F46E5', color: 'FFFFFF', bold: true },
|
|
37
|
+
db: dbOption,
|
|
38
|
+
} = options;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
name: 'xlsx',
|
|
42
|
+
version: '1.0.0',
|
|
43
|
+
description: 'Excel XLSX spreadsheet engine, res.xlsx() response decorator, and ORM export tools',
|
|
44
|
+
enabled,
|
|
45
|
+
|
|
46
|
+
register(ctx) {
|
|
47
|
+
if (!enabled) return;
|
|
48
|
+
|
|
49
|
+
// 1. Expose on PluginContext & global app
|
|
50
|
+
ctx.xlsx = xlsx;
|
|
51
|
+
if (ctx.app) {
|
|
52
|
+
ctx.app.xlsx = xlsx;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Register built-in services (xlsx.generate, xlsx.parse, xlsx.exportModel)
|
|
56
|
+
if (ctx.app && ctx.app.serviceRegistry) {
|
|
57
|
+
const db = dbOption ?? ctx.db ?? ctx.app?.get?.('db') ?? null;
|
|
58
|
+
const services = createXlsxServices({ db, ...options });
|
|
59
|
+
for (const [name, def] of Object.entries(services)) {
|
|
60
|
+
if (!ctx.app.serviceRegistry.has(name)) {
|
|
61
|
+
ctx.app.serviceRegistry.register(name, def);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. Register named middleware & global decorator
|
|
67
|
+
const middleware = createXlsxMiddleware({
|
|
68
|
+
sanitizeFormulas,
|
|
69
|
+
headerStyle,
|
|
70
|
+
...options,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (ctx.middlewares) {
|
|
74
|
+
ctx.middlewares.xlsx = middleware;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (ctx.app && typeof ctx.app.use === 'function') {
|
|
78
|
+
ctx.app.use(middleware);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
xlsxPlugin,
|
|
86
|
+
xlsx,
|
|
87
|
+
createXlsxMiddleware,
|
|
88
|
+
createXlsxServices,
|
|
89
|
+
XLSX_MIME_TYPE,
|
|
90
|
+
...engine,
|
|
91
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* XLSX Express Middleware
|
|
5
|
+
* Decorates res.xlsx() and req.parseXlsx() helpers
|
|
6
|
+
* @module plugins/xlsx/middleware
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { generate, parse, fromModel } = require('./engine');
|
|
10
|
+
|
|
11
|
+
const XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates the XLSX middleware
|
|
15
|
+
* @param {Object} [globalOptions]
|
|
16
|
+
* @returns {Function}
|
|
17
|
+
*/
|
|
18
|
+
function createXlsxMiddleware(globalOptions = {}) {
|
|
19
|
+
return (req, res, next) => {
|
|
20
|
+
// 1. Response Helper: res.xlsx(data, filename, options)
|
|
21
|
+
res.xlsx = async function (data, filename = 'export.xlsx', options = {}) {
|
|
22
|
+
try {
|
|
23
|
+
let buffer;
|
|
24
|
+
|
|
25
|
+
if (Buffer.isBuffer(data)) {
|
|
26
|
+
buffer = data;
|
|
27
|
+
} else if (data && typeof data.find === 'function' && data.model) {
|
|
28
|
+
// Repository instance
|
|
29
|
+
buffer = await fromModel(data, options.filter || options.query || {}, {
|
|
30
|
+
...globalOptions,
|
|
31
|
+
...options,
|
|
32
|
+
});
|
|
33
|
+
} else if (Array.isArray(data)) {
|
|
34
|
+
buffer = await generate({
|
|
35
|
+
...globalOptions,
|
|
36
|
+
...options,
|
|
37
|
+
sheets: [
|
|
38
|
+
{
|
|
39
|
+
name: options.sheetName || 'Data',
|
|
40
|
+
columns: options.columns,
|
|
41
|
+
rows: data,
|
|
42
|
+
headerStyle: options.headerStyle,
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
} else if (data && typeof data === 'object') {
|
|
47
|
+
buffer = await generate({
|
|
48
|
+
...globalOptions,
|
|
49
|
+
...options,
|
|
50
|
+
...data,
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
throw new Error('res.xlsx() expects an Array of rows, a sheets object, a Model Repository, or a Buffer');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const safeFilename = filename.endsWith('.xlsx') ? filename : `${filename}.xlsx`;
|
|
57
|
+
|
|
58
|
+
res.setHeader('Content-Type', XLSX_MIME_TYPE);
|
|
59
|
+
res.setHeader(
|
|
60
|
+
'Content-Disposition',
|
|
61
|
+
`attachment; filename="${encodeURIComponent(safeFilename)}"; filename*=UTF-8''${encodeURIComponent(safeFilename)}`
|
|
62
|
+
);
|
|
63
|
+
res.setHeader('Content-Length', buffer.length);
|
|
64
|
+
|
|
65
|
+
return res.end(buffer);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return next(err);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// 2. Request Helper: req.parseXlsx(fileOrBuffer, options)
|
|
72
|
+
req.parseXlsx = async function (fileOrBuffer, options = {}) {
|
|
73
|
+
const target = fileOrBuffer || req.file?.buffer || req.rawBody || req.body;
|
|
74
|
+
if (!target) {
|
|
75
|
+
throw new Error('req.parseXlsx requires a file Buffer or multipart req.file.buffer');
|
|
76
|
+
}
|
|
77
|
+
return parse(target, options);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
next();
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
createXlsxMiddleware,
|
|
86
|
+
XLSX_MIME_TYPE,
|
|
87
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in XLSX Services
|
|
5
|
+
* Exposed via ctx.service('xlsx.*') and req.service('xlsx.*')
|
|
6
|
+
* @module plugins/xlsx/services
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { z } = require('zod');
|
|
10
|
+
const { generate, parse, fromModel } = require('./engine');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Creates built-in services for the XLSX plugin
|
|
14
|
+
* @param {Object} [options]
|
|
15
|
+
* @param {Object} [options.db]
|
|
16
|
+
* @returns {Object.<string, Object>}
|
|
17
|
+
*/
|
|
18
|
+
function createXlsxServices(options = {}) {
|
|
19
|
+
return {
|
|
20
|
+
'xlsx.generate': {
|
|
21
|
+
schema: z.object({
|
|
22
|
+
sheets: z.array(
|
|
23
|
+
z.object({
|
|
24
|
+
name: z.string().optional(),
|
|
25
|
+
columns: z.array(z.object({ header: z.string(), key: z.string(), width: z.number().optional() })).optional(),
|
|
26
|
+
rows: z.array(z.record(z.any())),
|
|
27
|
+
})
|
|
28
|
+
).or(z.object({
|
|
29
|
+
name: z.string().optional(),
|
|
30
|
+
columns: z.array(z.object({ header: z.string(), key: z.string(), width: z.number().optional() })).optional(),
|
|
31
|
+
rows: z.array(z.record(z.any())),
|
|
32
|
+
})),
|
|
33
|
+
creator: z.string().optional(),
|
|
34
|
+
title: z.string().optional(),
|
|
35
|
+
}),
|
|
36
|
+
handler: async (input) => {
|
|
37
|
+
const buffer = await generate(input);
|
|
38
|
+
return {
|
|
39
|
+
base64: buffer.toString('base64'),
|
|
40
|
+
size: buffer.length,
|
|
41
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
'xlsx.parse': {
|
|
47
|
+
schema: z.object({
|
|
48
|
+
base64: z.string().optional(),
|
|
49
|
+
sheet: z.union([z.string(), z.number()]).optional(),
|
|
50
|
+
headerRow: z.number().optional(),
|
|
51
|
+
}),
|
|
52
|
+
handler: async (input) => {
|
|
53
|
+
if (!input.base64 && !input.buffer) {
|
|
54
|
+
throw new Error('xlsx.parse requires base64 string or buffer');
|
|
55
|
+
}
|
|
56
|
+
const buffer = input.buffer || Buffer.from(input.base64, 'base64');
|
|
57
|
+
const data = await parse(buffer, {
|
|
58
|
+
sheet: input.sheet,
|
|
59
|
+
headerRow: input.headerRow,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const rowCount = Array.isArray(data)
|
|
63
|
+
? data.length
|
|
64
|
+
: Object.values(data).reduce((acc, sheetRows) => acc + sheetRows.length, 0);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
data,
|
|
68
|
+
rowCount,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
'xlsx.exportModel': {
|
|
74
|
+
schema: z.object({
|
|
75
|
+
model: z.string().min(1),
|
|
76
|
+
filter: z.record(z.any()).optional().default({}),
|
|
77
|
+
columns: z.array(z.string()).optional(),
|
|
78
|
+
filename: z.string().optional(),
|
|
79
|
+
}),
|
|
80
|
+
handler: async (input, ctx) => {
|
|
81
|
+
const db = ctx.db || options.db;
|
|
82
|
+
if (!db || typeof db.getRepository !== 'function') {
|
|
83
|
+
throw new Error('xlsx.exportModel requires an active database connection');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const repo = db.getRepository(input.model);
|
|
87
|
+
if (!repo) {
|
|
88
|
+
throw new Error(`Model repository "${input.model}" not found`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let customColumns;
|
|
92
|
+
if (input.columns) {
|
|
93
|
+
customColumns = input.columns.map((c) => ({
|
|
94
|
+
header: c.charAt(0).toUpperCase() + c.slice(1).replace(/_/g, ' '),
|
|
95
|
+
key: c,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const buffer = await fromModel(repo, input.filter, {
|
|
100
|
+
columns: customColumns,
|
|
101
|
+
title: `${input.model} Export`,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const filename = input.filename || `${input.model.toLowerCase()}_export.xlsx`;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
filename: filename.endsWith('.xlsx') ? filename : `${filename}.xlsx`,
|
|
108
|
+
base64: buffer.toString('base64'),
|
|
109
|
+
size: buffer.length,
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
createXlsxServices,
|
|
118
|
+
};
|