webspresso 0.0.94 → 0.0.96

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 (83) hide show
  1. package/.agents/ADMIN.md +225 -0
  2. package/.agents/AGENTS.md +72 -0
  3. package/.agents/ARCHITECTURE.md +119 -0
  4. package/.agents/AUTH.md +103 -0
  5. package/.agents/CLI.md +115 -0
  6. package/.agents/COMPRESSION.md +59 -0
  7. package/.agents/CONVENTIONS.md +46 -0
  8. package/.agents/EMAIL.md +141 -0
  9. package/.agents/ERRORS.md +201 -0
  10. package/.agents/MCP.md +103 -0
  11. package/.agents/ORM.md +230 -0
  12. package/.agents/PLUGINS.md +419 -0
  13. package/.agents/QUEUE.md +113 -0
  14. package/.agents/REALTIME.md +213 -0
  15. package/.agents/ROUTING.md +292 -0
  16. package/.agents/SERVICES.md +341 -0
  17. package/.agents/SHUTDOWN.md +67 -0
  18. package/.agents/STREAMING.md +73 -0
  19. package/.agents/WORKFLOW.md +27 -0
  20. package/.agents/internal/DOCUMENTATION_REVIEW.md +485 -0
  21. package/.agents/internal/TECHNICAL_REVIEW.md +428 -0
  22. package/.cursorrules +14 -0
  23. package/AGENTS.md +175 -0
  24. package/CLAUDE.md +54 -0
  25. package/README.md +18 -14
  26. package/bin/commands/agents-init.js +169 -0
  27. package/bin/commands/api.js +114 -48
  28. package/bin/commands/doctor.js +35 -0
  29. package/bin/commands/new.js +13 -2
  30. package/bin/commands/page.js +75 -89
  31. package/bin/commands/service.js +77 -0
  32. package/bin/webspresso.js +2 -2
  33. package/core/compileSchema.js +1 -1
  34. package/core/errors/middleware.js +22 -2
  35. package/core/errors/normalize.js +29 -2
  36. package/{utils/schemaCache.js → core/schema-cache.js} +2 -3
  37. package/index.d.ts +306 -0
  38. package/index.js +35 -1
  39. package/package.json +19 -6
  40. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
  41. package/plugins/csrf/index.js +3 -2
  42. package/plugins/csv/engine.js +447 -0
  43. package/plugins/csv/index.js +94 -0
  44. package/plugins/csv/middleware.js +80 -0
  45. package/plugins/csv/services.js +138 -0
  46. package/plugins/file-manager/admin-component.js +613 -0
  47. package/plugins/file-manager/api-handlers.js +261 -0
  48. package/plugins/file-manager/core.js +601 -0
  49. package/plugins/file-manager/index.js +139 -0
  50. package/plugins/file-manager/security.js +207 -0
  51. package/plugins/index.js +8 -0
  52. package/plugins/polar/CHANGELOG.md +18 -0
  53. package/plugins/polar/README.md +30 -7
  54. package/plugins/polar/index.js +6 -1
  55. package/plugins/polar/src/billing-hooks.js +51 -0
  56. package/plugins/polar/src/config.js +2 -0
  57. package/plugins/polar/src/rate-limit.js +96 -28
  58. package/plugins/polar/src/routes.js +3 -2
  59. package/plugins/polar/src/urls.js +26 -2
  60. package/plugins/polar/src/user-resolver.js +5 -1
  61. package/plugins/rate-limit/index.js +5 -1
  62. package/plugins/xlsx/engine.js +397 -0
  63. package/plugins/xlsx/index.js +91 -0
  64. package/plugins/xlsx/middleware.js +87 -0
  65. package/plugins/xlsx/services.js +118 -0
  66. package/src/api/api-loader.js +233 -0
  67. package/src/api/define-api.js +35 -0
  68. package/src/discovery/file-route-parser.js +201 -0
  69. package/src/discovery/scan-routes.js +265 -0
  70. package/src/file-router.js +6 -0
  71. package/src/modules/define-module.js +99 -0
  72. package/src/modules/module-discovery.js +117 -0
  73. package/src/pages/define-page.js +36 -0
  74. package/src/pages/page-loader.js +214 -0
  75. package/src/routing/mount-discovered-routes.js +78 -0
  76. package/src/routing/route-table.js +191 -0
  77. package/src/server.js +112 -15
  78. package/src/services/builtins/file-manager.js +178 -0
  79. package/src/services/index.js +2 -0
  80. package/bin/commands/skill.js +0 -187
  81. package/templates/skills/webspresso-usage/REFERENCE-framework.md +0 -289
  82. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +0 -93
  83. package/templates/skills/webspresso-usage/SKILL.md +0 -48
@@ -1,36 +1,86 @@
1
1
  'use strict';
2
2
 
3
- const DEFAULT_RATE_LIMITS = {
4
- webhook: {
5
- limit: 120,
6
- windowMs: 60_000,
7
- message: { error: 'Too many webhook requests' },
8
- },
9
- checkout: {
10
- limit: 5,
11
- windowMs: 60_000,
12
- message: { error: 'Too many checkout attempts. Please try again later.' },
13
- keyGenerator: (req) => `polar:checkout:${req.user?.id ?? req.ip}`,
14
- },
15
- portal: {
16
- limit: 10,
17
- windowMs: 60_000,
18
- message: { error: 'Too many portal requests. Please try again later.' },
19
- keyGenerator: (req) => `polar:portal:${req.user?.id ?? req.ip}`,
20
- },
21
- status: {
22
- limit: 60,
23
- windowMs: 60_000,
24
- message: { error: 'Too many billing status requests' },
25
- keyGenerator: (req) => `polar:status:${req.user?.id ?? req.ip}`,
26
- },
27
- };
3
+ const { loadPeer } = require('../../rate-limit/index.js');
4
+
5
+ const DEFAULT_IPV6_SUBNET = 56;
6
+
7
+ /**
8
+ * @param {Function} ipKeyGenerator
9
+ * @param {string} prefix
10
+ */
11
+ function polarIpKey(ipKeyGenerator, prefix) {
12
+ return (req) => `${prefix}:${ipKeyGenerator(req.ip, DEFAULT_IPV6_SUBNET)}`;
13
+ }
14
+
15
+ /**
16
+ * Prefer authenticated user id; fall back to IPv6-safe IP key (express-rate-limit v8).
17
+ * @param {Function} ipKeyGenerator
18
+ * @param {string} prefix
19
+ */
20
+ function polarUserOrIpKey(ipKeyGenerator, prefix) {
21
+ const ipKey = polarIpKey(ipKeyGenerator, prefix);
22
+ return (req) => (req.user?.id != null ? `${prefix}:${req.user.id}` : ipKey(req));
23
+ }
24
+
25
+ /**
26
+ * @param {Function} ipKeyGenerator
27
+ */
28
+ function buildDefaultRateLimits(ipKeyGenerator) {
29
+ return {
30
+ webhook: {
31
+ limit: 120,
32
+ windowMs: 60_000,
33
+ message: { error: 'Too many webhook requests' },
34
+ keyGenerator: polarIpKey(ipKeyGenerator, 'polar:webhook'),
35
+ },
36
+ checkout: {
37
+ limit: 5,
38
+ windowMs: 60_000,
39
+ message: { error: 'Too many checkout attempts. Please try again later.' },
40
+ keyGenerator: polarUserOrIpKey(ipKeyGenerator, 'polar:checkout'),
41
+ },
42
+ portal: {
43
+ limit: 10,
44
+ windowMs: 60_000,
45
+ message: { error: 'Too many portal requests. Please try again later.' },
46
+ keyGenerator: polarUserOrIpKey(ipKeyGenerator, 'polar:portal'),
47
+ },
48
+ status: {
49
+ limit: 60,
50
+ windowMs: 60_000,
51
+ message: { error: 'Too many billing status requests' },
52
+ keyGenerator: polarUserOrIpKey(ipKeyGenerator, 'polar:status'),
53
+ },
54
+ };
55
+ }
56
+
57
+ /** @type {ReturnType<typeof buildDefaultRateLimits>|null} */
58
+ let cachedDefaults = null;
59
+
60
+ function loadIpKeyGenerator() {
61
+ try {
62
+ const { ipKeyGenerator } = loadPeer();
63
+ return ipKeyGenerator;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function getDefaultRateLimits() {
70
+ const ipKeyGenerator = loadIpKeyGenerator();
71
+ if (!ipKeyGenerator) {
72
+ return null;
73
+ }
74
+ if (!cachedDefaults) {
75
+ cachedDefaults = buildDefaultRateLimits(ipKeyGenerator);
76
+ }
77
+ return cachedDefaults;
78
+ }
28
79
 
29
80
  /**
30
81
  * Build per-route limiter middleware from rateLimitPlugin factory.
31
82
  * @param {Object} ctx - plugin onRoutesReady context
32
83
  * @param {import('./config').PolarConfig} config
33
- * @returns {{ webhook?: Function, checkout?: Function, portal?: Function, status?: Function }}
34
84
  */
35
85
  function resolvePolarRateLimiters(ctx, config) {
36
86
  const rlOpt = config.rateLimit;
@@ -48,6 +98,14 @@ function resolvePolarRateLimiters(ctx, config) {
48
98
  return {};
49
99
  }
50
100
 
101
+ const defaults = getDefaultRateLimits();
102
+ if (!defaults) {
103
+ console.warn(
104
+ '[polar] rateLimit is enabled but express-rate-limit >= 8 is not installed — skipping polar rate limiters'
105
+ );
106
+ return {};
107
+ }
108
+
51
109
  const overrides = rlOpt === true ? {} : (rlOpt || {});
52
110
  const routes = ['webhook', 'checkout', 'portal', 'status'];
53
111
  const out = {};
@@ -55,7 +113,7 @@ function resolvePolarRateLimiters(ctx, config) {
55
113
  for (const route of routes) {
56
114
  if (overrides[route] === false) continue;
57
115
  const opts = {
58
- ...DEFAULT_RATE_LIMITS[route],
116
+ ...defaults[route],
59
117
  ...(typeof overrides[route] === 'object' ? overrides[route] : {}),
60
118
  };
61
119
  out[route] = factory(opts);
@@ -65,6 +123,16 @@ function resolvePolarRateLimiters(ctx, config) {
65
123
  }
66
124
 
67
125
  module.exports = {
68
- DEFAULT_RATE_LIMITS,
126
+ loadIpKeyGenerator,
127
+ polarIpKey,
128
+ polarUserOrIpKey,
129
+ buildDefaultRateLimits,
130
+ getDefaultRateLimits,
69
131
  resolvePolarRateLimiters,
132
+ /** @deprecated use getDefaultRateLimits() */
133
+ get DEFAULT_RATE_LIMITS() {
134
+ return getDefaultRateLimits() || buildDefaultRateLimits(
135
+ loadIpKeyGenerator() || ((ip) => String(ip))
136
+ );
137
+ },
70
138
  };
@@ -5,6 +5,7 @@ const { getPolarCheckoutUrl } = require('./checkout');
5
5
  const { getPolarPortalUrl } = require('./portal');
6
6
  const { syncPolarBillingForUser } = require('./sync');
7
7
  const { userHasProTier } = require('./user-resolver');
8
+ const { userIsPaid } = require('./billing-hooks');
8
9
 
9
10
  function resolveKnex(db) {
10
11
  return db?.knex || (typeof db?.raw === 'function' ? db : null);
@@ -81,12 +82,12 @@ function createCheckoutHandler(config) {
81
82
  if (knex) {
82
83
  const dbUser = await knex(config.userTable).where(f.id, req.user[f.id] || req.user.id).first();
83
84
  if (dbUser) user = dbUser;
84
- if (config.accessToken) {
85
+ if (config.accessToken && config.syncBeforeCheckout) {
85
86
  user = await syncPolarBillingForUser(user, knex, config);
86
87
  }
87
88
  }
88
89
 
89
- if (userHasProTier(user, config)) {
90
+ if (await userIsPaid(user, knex, config)) {
90
91
  const alreadyUrl = config.urls.alreadySubscribed.startsWith('http')
91
92
  ? config.urls.alreadySubscribed
92
93
  : config.urls.alreadySubscribed;
@@ -92,6 +92,7 @@ function extractCheckoutUrlFromResponse(data, sandbox = false) {
92
92
  }
93
93
 
94
94
  function polarCspDirectives(opts = {}) {
95
+ const self = "'self'";
95
96
  const domains = opts.domains || [
96
97
  'https://buy.polar.sh',
97
98
  'https://sandbox-buy.polar.sh',
@@ -101,8 +102,30 @@ function polarCspDirectives(opts = {}) {
101
102
  'https://sandbox-api.polar.sh',
102
103
  ];
103
104
  return {
104
- formAction: domains,
105
- connectSrc: domains,
105
+ formAction: [self, ...domains],
106
+ connectSrc: [self, ...domains],
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Union Polar CSP sources into an existing Helmet/plugin CSP config (does not replace).
112
+ * @param {Object} [existing]
113
+ * @param {Object} [opts] - passed to polarCspDirectives
114
+ */
115
+ function mergePolarCspDirectives(existing = {}, opts = {}) {
116
+ const polar = polarCspDirectives(opts);
117
+ const mergeList = (key) => {
118
+ const base = existing[key];
119
+ const items = [
120
+ ...(Array.isArray(base) ? base : base ? [base] : []),
121
+ ...polar[key],
122
+ ];
123
+ return [...new Set(items)];
124
+ };
125
+ return {
126
+ ...existing,
127
+ formAction: mergeList('formAction'),
128
+ connectSrc: mergeList('connectSrc'),
106
129
  };
107
130
  }
108
131
 
@@ -114,4 +137,5 @@ module.exports = {
114
137
  isValidPortalRedirectUrl,
115
138
  extractCheckoutUrlFromResponse,
116
139
  polarCspDirectives,
140
+ mergePolarCspDirectives,
117
141
  };
@@ -113,7 +113,11 @@ async function resolveUserFromPolarData(data, knex, config) {
113
113
  const table = config.userTable;
114
114
 
115
115
  if (metadata.user_id) {
116
- const user = await knex(table).where(f.id, Number(metadata.user_id)).first();
116
+ const uid = String(metadata.user_id).trim();
117
+ let user = await knex(table).where(f.id, uid).first();
118
+ if (!user && /^\d+$/.test(uid)) {
119
+ user = await knex(table).where(f.id, Number(uid)).first();
120
+ }
117
121
  if (user) return user;
118
122
  }
119
123
 
@@ -175,4 +175,8 @@ function rateLimitPlugin(options = {}) {
175
175
  return plugin;
176
176
  }
177
177
 
178
- module.exports = { rateLimitPlugin };
178
+ module.exports = {
179
+ rateLimitPlugin,
180
+ loadPeer,
181
+ resolveLimiterConfig,
182
+ };
@@ -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
+ };