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.
- 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/.cursorrules +14 -0
- package/AGENTS.md +175 -0
- package/CLAUDE.md +54 -0
- package/README.md +18 -14
- package/bin/commands/agents-init.js +169 -0
- package/bin/commands/api.js +114 -48
- package/bin/commands/doctor.js +35 -0
- package/bin/commands/new.js +13 -2
- package/bin/commands/page.js +75 -89
- package/bin/commands/service.js +77 -0
- package/bin/webspresso.js +2 -2
- 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 +19 -6
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
- 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 +207 -0
- package/plugins/index.js +8 -0
- package/plugins/polar/CHANGELOG.md +18 -0
- package/plugins/polar/README.md +30 -7
- package/plugins/polar/index.js +6 -1
- package/plugins/polar/src/billing-hooks.js +51 -0
- package/plugins/polar/src/config.js +2 -0
- package/plugins/polar/src/rate-limit.js +96 -28
- package/plugins/polar/src/routes.js +3 -2
- package/plugins/polar/src/urls.js +26 -2
- package/plugins/polar/src/user-resolver.js +5 -1
- package/plugins/rate-limit/index.js +5 -1
- 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 +6 -0
- 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/index.js +2 -0
- 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
|
@@ -1,36 +1,86 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
|
@@ -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
|
+
};
|