webspresso 0.0.70 → 0.0.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -1
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/package.json +3 -1
- package/plugins/admin-panel/components.js +187 -20
- package/plugins/admin-panel/core/api-extensions.js +2 -0
- package/plugins/admin-panel/core/registry.js +2 -0
- package/plugins/admin-panel/modules/custom-pages.js +75 -14
- package/plugins/admin-panel/modules/dashboard.js +64 -13
- package/plugins/admin-panel/modules/menu.js +43 -0
- package/plugins/audit-log/middleware.js +3 -0
- package/plugins/data-exchange/export-xlsx.js +83 -0
- package/plugins/data-exchange/import.js +289 -0
- package/plugins/data-exchange/index.js +80 -0
- package/plugins/data-exchange/parse-table.js +64 -0
- package/plugins/data-exchange/record-selection.js +64 -0
- package/plugins/index.js +3 -0
- package/plugins/site-analytics/admin-component.js +32 -11
|
@@ -94,6 +94,7 @@ const Icon = {
|
|
|
94
94
|
activity: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />',
|
|
95
95
|
filter: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />',
|
|
96
96
|
search: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />',
|
|
97
|
+
refresh: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />',
|
|
97
98
|
'chevron-down': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />',
|
|
98
99
|
'chevron-right': '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />',
|
|
99
100
|
logout: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />',
|
|
@@ -116,6 +117,48 @@ const Icon = {
|
|
|
116
117
|
},
|
|
117
118
|
};
|
|
118
119
|
|
|
120
|
+
function getAdminAutoRefreshMs() {
|
|
121
|
+
const raw = window.__ADMIN_CONFIG__?.settings?.autoRefreshMs;
|
|
122
|
+
if (raw === 0 || raw === false) return 0;
|
|
123
|
+
const n = Number(raw);
|
|
124
|
+
if (!Number.isFinite(n) || n < 0) return 60000;
|
|
125
|
+
return Math.min(Math.max(n, 10000), 3600000);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function runAdminAutoRefresh(callback) {
|
|
129
|
+
const ms = getAdminAutoRefreshMs();
|
|
130
|
+
if (!ms) return function noop() {};
|
|
131
|
+
const id = setInterval(() => {
|
|
132
|
+
if (document.visibilityState === 'hidden') return;
|
|
133
|
+
try {
|
|
134
|
+
callback();
|
|
135
|
+
} catch (e) {
|
|
136
|
+
console.error(e);
|
|
137
|
+
}
|
|
138
|
+
}, ms);
|
|
139
|
+
return function stopAdminAutoRefresh() {
|
|
140
|
+
clearInterval(id);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const RefreshIconButton = {
|
|
145
|
+
view(vnode) {
|
|
146
|
+
const { title, spinning, onclick, disabled } = vnode.attrs;
|
|
147
|
+
return m('button.inline-flex.items-center.justify-center.p-2.rounded-lg.border.border-gray-200.dark:border-slate-600.bg-white.dark:bg-slate-800.text-gray-600.dark:text-slate-300.hover:bg-gray-50.dark:hover:bg-slate-700.transition-colors.disabled:opacity-50', {
|
|
148
|
+
type: 'button',
|
|
149
|
+
title: title || 'Refresh',
|
|
150
|
+
disabled: !!disabled || !!spinning,
|
|
151
|
+
onclick: (e) => {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
if (onclick) onclick(e);
|
|
154
|
+
},
|
|
155
|
+
}, m(Icon, {
|
|
156
|
+
name: 'refresh',
|
|
157
|
+
class: 'w-4 h-4' + (spinning ? ' animate-spin' : ''),
|
|
158
|
+
}));
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
119
162
|
var ADMIN_THEME_KEY = 'webspresso-admin-theme';
|
|
120
163
|
function getAdminThemePref() {
|
|
121
164
|
try { return localStorage.getItem(ADMIN_THEME_KEY); } catch (e) { return null; }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excel (.xlsx) export using exceljs
|
|
3
|
+
* @module plugins/data-exchange/export-xlsx
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const ExcelJS = require('exceljs');
|
|
7
|
+
const { sanitizeForOutput } = require('../../core/orm/utils');
|
|
8
|
+
const { resolveExportRecords } = require('./record-selection');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
12
|
+
* @param {object[]} records
|
|
13
|
+
* @returns {Promise<Buffer>}
|
|
14
|
+
*/
|
|
15
|
+
async function buildXlsxBuffer(model, records) {
|
|
16
|
+
const clean = sanitizeForOutput(records, model);
|
|
17
|
+
const hiddenSet = new Set(model.hidden || []);
|
|
18
|
+
const columns = Array.from(model.columns.keys()).filter((c) => !hiddenSet.has(c));
|
|
19
|
+
|
|
20
|
+
const wb = new ExcelJS.Workbook();
|
|
21
|
+
const ws = wb.addWorksheet('Export', {
|
|
22
|
+
views: [{ state: 'frozen', ySplit: 1 }],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
ws.addRow(columns);
|
|
26
|
+
const headerRow = ws.getRow(1);
|
|
27
|
+
headerRow.font = { bold: true };
|
|
28
|
+
|
|
29
|
+
for (const rec of clean) {
|
|
30
|
+
const rowValues = columns.map((c) => {
|
|
31
|
+
let v = rec[c];
|
|
32
|
+
if (v === null || v === undefined) return '';
|
|
33
|
+
if (typeof v === 'object') v = JSON.stringify(v);
|
|
34
|
+
if (typeof v === 'string' && /^[=+\-@]/.test(v)) return ` ${v}`;
|
|
35
|
+
return v;
|
|
36
|
+
});
|
|
37
|
+
ws.addRow(rowValues);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const buf = await wb.xlsx.writeBuffer();
|
|
41
|
+
return Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} opts
|
|
46
|
+
* @param {import('../../core/orm/database').Database} opts.db
|
|
47
|
+
* @param {number} opts.maxRows
|
|
48
|
+
*/
|
|
49
|
+
function createExportXlsxHandler(opts) {
|
|
50
|
+
const { db, maxRows } = opts;
|
|
51
|
+
|
|
52
|
+
return async function exportXlsxHandler(req, res) {
|
|
53
|
+
try {
|
|
54
|
+
const modelName = req.params.model || req.query.model;
|
|
55
|
+
const result = await resolveExportRecords(db, modelName, req);
|
|
56
|
+
if (result.error) {
|
|
57
|
+
return res.status(result.error).json({ error: result.message });
|
|
58
|
+
}
|
|
59
|
+
const { model, records } = result;
|
|
60
|
+
if (records.length > maxRows) {
|
|
61
|
+
return res.status(400).json({
|
|
62
|
+
error: `Too many rows to export (${records.length}). Limit is ${maxRows}.`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const buffer = await buildXlsxBuffer(model, records);
|
|
66
|
+
const safeName = String(model.name).replace(/[^\w.-]+/g, '_');
|
|
67
|
+
res.setHeader(
|
|
68
|
+
'Content-Type',
|
|
69
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
70
|
+
);
|
|
71
|
+
res.setHeader('Content-Disposition', `attachment; filename="${safeName}_export.xlsx"`);
|
|
72
|
+
res.send(buffer);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.error('[data-exchange] export xlsx:', err);
|
|
75
|
+
res.status(500).json({ error: err.message || 'Export failed' });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
buildXlsxBuffer,
|
|
82
|
+
createExportXlsxHandler,
|
|
83
|
+
};
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV / XLSX import (admin multipart upload)
|
|
3
|
+
* @module plugins/data-exchange/import
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const multer = require('multer');
|
|
7
|
+
const ExcelJS = require('exceljs');
|
|
8
|
+
const { parse: parseCsv } = require('csv-parse/sync');
|
|
9
|
+
const { buildHeaderMapping, dataRowsToObjects } = require('./parse-table');
|
|
10
|
+
|
|
11
|
+
function allowedImportColumns(model) {
|
|
12
|
+
const hidden = new Set(model.hidden || []);
|
|
13
|
+
return Array.from(model.columns.keys()).filter((c) => !hidden.has(c));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {*} raw
|
|
18
|
+
* @param {import('../../core/orm/types').ColumnMeta|undefined} meta
|
|
19
|
+
* @param {string} column
|
|
20
|
+
*/
|
|
21
|
+
function coerceCell(raw, meta, column) {
|
|
22
|
+
if (raw === null || raw === undefined) return undefined;
|
|
23
|
+
if (typeof raw === 'number' && Number.isNaN(raw)) return undefined;
|
|
24
|
+
|
|
25
|
+
if (typeof raw === 'string') {
|
|
26
|
+
const t = raw.trim();
|
|
27
|
+
if (t === '') return undefined;
|
|
28
|
+
raw = t;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!meta || !meta.type) return raw;
|
|
32
|
+
|
|
33
|
+
switch (meta.type) {
|
|
34
|
+
case 'bigint':
|
|
35
|
+
case 'integer': {
|
|
36
|
+
if (typeof raw === 'number') return Math.trunc(raw);
|
|
37
|
+
const n = parseInt(String(raw), 10);
|
|
38
|
+
if (Number.isNaN(n)) throw new Error(`Invalid integer for ${column}`);
|
|
39
|
+
return n;
|
|
40
|
+
}
|
|
41
|
+
case 'decimal':
|
|
42
|
+
case 'float': {
|
|
43
|
+
if (typeof raw === 'number') return raw;
|
|
44
|
+
const n = parseFloat(String(raw));
|
|
45
|
+
if (Number.isNaN(n)) throw new Error(`Invalid number for ${column}`);
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
case 'boolean': {
|
|
49
|
+
if (typeof raw === 'boolean') return raw;
|
|
50
|
+
const s = String(raw).toLowerCase();
|
|
51
|
+
if (s === 'true' || s === '1' || s === 'yes') return true;
|
|
52
|
+
if (s === 'false' || s === '0' || s === 'no') return false;
|
|
53
|
+
throw new Error(`Invalid boolean for ${column}`);
|
|
54
|
+
}
|
|
55
|
+
case 'json': {
|
|
56
|
+
if (typeof raw === 'object') return raw;
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(String(raw));
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error(`Invalid JSON for ${column}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
return raw;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildPayloadForRow(model, data, mode) {
|
|
69
|
+
const allowed = new Set(allowedImportColumns(model));
|
|
70
|
+
const payload = {};
|
|
71
|
+
for (const [col, raw] of Object.entries(data)) {
|
|
72
|
+
if (!allowed.has(col)) continue;
|
|
73
|
+
const meta = model.columns.get(col);
|
|
74
|
+
let v;
|
|
75
|
+
try {
|
|
76
|
+
v = coerceCell(raw, meta, col);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
if (v === undefined) continue;
|
|
81
|
+
payload[col] = v;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pk = model.primaryKey;
|
|
85
|
+
const pkMeta = model.columns.get(pk);
|
|
86
|
+
if (
|
|
87
|
+
mode === 'insert' &&
|
|
88
|
+
pkMeta?.autoIncrement &&
|
|
89
|
+
(payload[pk] === undefined || payload[pk] === null || payload[pk] === '')
|
|
90
|
+
) {
|
|
91
|
+
delete payload[pk];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return payload;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseCsvToRows(buffer) {
|
|
98
|
+
const text = buffer.toString('utf8');
|
|
99
|
+
const records = parseCsv(text, {
|
|
100
|
+
relaxColumnCount: true,
|
|
101
|
+
skipEmptyLines: true,
|
|
102
|
+
});
|
|
103
|
+
if (records.length === 0) return [];
|
|
104
|
+
return records.map((row) => row.map((c) => (c === '' ? '' : c)));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function parseXlsxToRows(buffer) {
|
|
108
|
+
const wb = new ExcelJS.Workbook();
|
|
109
|
+
await wb.xlsx.load(buffer);
|
|
110
|
+
const ws = wb.worksheets[0];
|
|
111
|
+
if (!ws) return [];
|
|
112
|
+
const rows = [];
|
|
113
|
+
ws.eachRow({ includeEmpty: true }, (row) => {
|
|
114
|
+
let maxCol = 0;
|
|
115
|
+
row.eachCell({ includeEmpty: true }, (_cell, colNumber) => {
|
|
116
|
+
maxCol = Math.max(maxCol, colNumber);
|
|
117
|
+
});
|
|
118
|
+
const arr = [];
|
|
119
|
+
for (let c = 1; c <= maxCol; c++) {
|
|
120
|
+
arr.push(cellValueToPlain(row.getCell(c)));
|
|
121
|
+
}
|
|
122
|
+
rows.push(arr);
|
|
123
|
+
});
|
|
124
|
+
return rows;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function cellValueToPlain(cell) {
|
|
128
|
+
const v = cell.value;
|
|
129
|
+
if (v === null || v === undefined) return '';
|
|
130
|
+
if (v instanceof Date) return v.toISOString();
|
|
131
|
+
if (typeof v === 'object' && v !== null) {
|
|
132
|
+
if ('text' in v && typeof v.text === 'string') return v.text;
|
|
133
|
+
if ('richText' in v && Array.isArray(v.richText)) {
|
|
134
|
+
return v.richText.map((t) => t.text || '').join('');
|
|
135
|
+
}
|
|
136
|
+
if ('result' in v && v.result !== undefined) return v.result;
|
|
137
|
+
}
|
|
138
|
+
return v;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createMulter(maxFileBytes) {
|
|
142
|
+
return multer({
|
|
143
|
+
storage: multer.memoryStorage(),
|
|
144
|
+
limits: { fileSize: maxFileBytes, files: 1 },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {object} opts
|
|
150
|
+
* @param {import('../../core/orm/database').Database} opts.db
|
|
151
|
+
* @param {number} opts.maxRows
|
|
152
|
+
* @param {number} opts.maxFileBytes
|
|
153
|
+
*/
|
|
154
|
+
function createImportHandler(opts) {
|
|
155
|
+
const { db, maxRows, maxFileBytes } = opts;
|
|
156
|
+
const upload = createMulter(maxFileBytes).single('file');
|
|
157
|
+
|
|
158
|
+
return async function importHandler(req, res) {
|
|
159
|
+
upload(req, res, async (multerErr) => {
|
|
160
|
+
try {
|
|
161
|
+
if (multerErr) {
|
|
162
|
+
const code = multerErr.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
|
163
|
+
return res.status(code).json({
|
|
164
|
+
error: multerErr.code === 'LIMIT_FILE_SIZE' ? 'File too large' : multerErr.message,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const modelName = req.params.model;
|
|
169
|
+
const { getModel } = require('../../core/orm/model');
|
|
170
|
+
const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
|
|
171
|
+
if (!model || !model.admin?.enabled) {
|
|
172
|
+
return res.status(404).json({ error: 'Model not found or not enabled' });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const modeRaw = (req.query.mode || req.body?.mode || 'insert').toLowerCase();
|
|
176
|
+
const mode = modeRaw === 'upsert' ? 'upsert' : 'insert';
|
|
177
|
+
let upsertKey = req.query.upsertKey || req.body?.upsertKey || model.primaryKey;
|
|
178
|
+
upsertKey = String(upsertKey);
|
|
179
|
+
|
|
180
|
+
if (!model.columns.has(upsertKey)) {
|
|
181
|
+
return res.status(400).json({ error: `upsertKey "${upsertKey}" is not a column` });
|
|
182
|
+
}
|
|
183
|
+
if (model.hidden?.includes(upsertKey)) {
|
|
184
|
+
return res.status(400).json({ error: `upsertKey "${upsertKey}" is not allowed` });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!req.file || !req.file.buffer) {
|
|
188
|
+
return res.status(400).json({ error: 'Missing file field' });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const name = (req.file.originalname || '').toLowerCase();
|
|
192
|
+
const isCsv = name.endsWith('.csv') || (req.file.mimetype || '').includes('csv');
|
|
193
|
+
let rows;
|
|
194
|
+
if (isCsv) {
|
|
195
|
+
rows = parseCsvToRows(req.file.buffer);
|
|
196
|
+
} else {
|
|
197
|
+
try {
|
|
198
|
+
rows = await parseXlsxToRows(req.file.buffer);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
return res.status(400).json({ error: `Invalid spreadsheet: ${e.message}` });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (rows.length < 2) {
|
|
205
|
+
return res.status(400).json({ error: 'File must include a header row and at least one data row' });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const allowedKeys = allowedImportColumns(model);
|
|
209
|
+
const headerMapping = buildHeaderMapping(rows[0], allowedKeys);
|
|
210
|
+
const objects = dataRowsToObjects(rows, headerMapping);
|
|
211
|
+
if (objects.length > maxRows) {
|
|
212
|
+
return res.status(400).json({ error: `Too many data rows (${objects.length}). Limit is ${maxRows}.` });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const repo = db.getRepository(model.name);
|
|
216
|
+
const summary = {
|
|
217
|
+
success: true,
|
|
218
|
+
created: 0,
|
|
219
|
+
updated: 0,
|
|
220
|
+
failed: 0,
|
|
221
|
+
errors: [],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
for (const { rowNumber, data } of objects) {
|
|
225
|
+
try {
|
|
226
|
+
const payload = buildPayloadForRow(model, data, mode);
|
|
227
|
+
if (Object.keys(payload).length === 0) {
|
|
228
|
+
summary.failed++;
|
|
229
|
+
summary.errors.push({ row: rowNumber, message: 'No mappable columns' });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (mode === 'insert') {
|
|
234
|
+
await repo.create(payload);
|
|
235
|
+
summary.created++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const keyMeta = model.columns.get(upsertKey);
|
|
240
|
+
let keyVal;
|
|
241
|
+
try {
|
|
242
|
+
keyVal = coerceCell(data[upsertKey], keyMeta, upsertKey);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
summary.failed++;
|
|
245
|
+
summary.errors.push({ row: rowNumber, message: e.message || 'Bad upsert key' });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (keyVal === undefined || keyVal === null) {
|
|
250
|
+
const insertPayload = { ...payload };
|
|
251
|
+
delete insertPayload[upsertKey];
|
|
252
|
+
await repo.create(insertPayload);
|
|
253
|
+
summary.created++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const existing = await repo.findOne({ [upsertKey]: keyVal });
|
|
258
|
+
if (existing) {
|
|
259
|
+
const id = existing[model.primaryKey];
|
|
260
|
+
await repo.update(id, payload);
|
|
261
|
+
summary.updated++;
|
|
262
|
+
} else {
|
|
263
|
+
await repo.create({ ...payload, [upsertKey]: keyVal });
|
|
264
|
+
summary.created++;
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
summary.failed++;
|
|
268
|
+
summary.errors.push({ row: rowNumber, message: e.message || String(e) });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
summary.success = summary.failed === 0;
|
|
273
|
+
res.json(summary);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error('[data-exchange] import:', err);
|
|
276
|
+
res.status(500).json({ error: err.message || 'Import failed' });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = {
|
|
283
|
+
createImportHandler,
|
|
284
|
+
createMulter,
|
|
285
|
+
allowedImportColumns,
|
|
286
|
+
parseCsvToRows,
|
|
287
|
+
parseXlsxToRows,
|
|
288
|
+
buildPayloadForRow,
|
|
289
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin-only CSV/Excel import and Excel export.
|
|
3
|
+
*
|
|
4
|
+
* Usage: add after adminPanelPlugin in createApp({ plugins: [...] }) so session
|
|
5
|
+
* middleware and admin-panel registry exist. Same adminPath as admin panel.
|
|
6
|
+
*
|
|
7
|
+
* @module plugins/data-exchange
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { createExportXlsxHandler } = require('./export-xlsx');
|
|
11
|
+
const { createImportHandler } = require('./import');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} [options]
|
|
15
|
+
* @param {boolean} [options.enabled=true]
|
|
16
|
+
* @param {import('../../core/orm/database').Database} [options.db] - defaults to ctx.db
|
|
17
|
+
* @param {string} [options.adminPath='/_admin'] - must match admin panel path
|
|
18
|
+
* @param {number} [options.maxRows=10000] - export/import row cap
|
|
19
|
+
* @param {number} [options.maxFileBytes=10485760] - multipart size (10 MiB)
|
|
20
|
+
*/
|
|
21
|
+
function dataExchangePlugin(options = {}) {
|
|
22
|
+
const {
|
|
23
|
+
enabled = true,
|
|
24
|
+
db: dbOption,
|
|
25
|
+
adminPath = '/_admin',
|
|
26
|
+
maxRows = 10_000,
|
|
27
|
+
maxFileBytes = 10 * 1024 * 1024,
|
|
28
|
+
} = options;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
name: 'data-exchange',
|
|
32
|
+
version: '1.0.0',
|
|
33
|
+
description: 'Admin spreadsheet import (CSV/XLSX) and Excel export',
|
|
34
|
+
enabled,
|
|
35
|
+
|
|
36
|
+
onRoutesReady(ctx) {
|
|
37
|
+
if (!enabled) return;
|
|
38
|
+
|
|
39
|
+
const db = dbOption ?? ctx.db;
|
|
40
|
+
if (!db) {
|
|
41
|
+
console.warn('[data-exchange] Skipping routes: no database (pass options.db or createApp({ db }))');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { requireAuth } = require('../admin-panel/auth');
|
|
46
|
+
const exportHandler = createExportXlsxHandler({ db, maxRows });
|
|
47
|
+
const importHandler = createImportHandler({ db, maxRows, maxFileBytes });
|
|
48
|
+
|
|
49
|
+
const base = `${adminPath}/api/data-exchange`;
|
|
50
|
+
ctx.addRoute('get', `${base}/export/:model`, requireAuth, exportHandler);
|
|
51
|
+
ctx.addRoute('post', `${base}/export/:model`, requireAuth, exportHandler);
|
|
52
|
+
ctx.addRoute('post', `${base}/import/:model`, requireAuth, importHandler);
|
|
53
|
+
|
|
54
|
+
const adminApi = typeof ctx.usePlugin === 'function' ? ctx.usePlugin('admin-panel') : null;
|
|
55
|
+
if (adminApi && typeof adminApi.getRegistry === 'function') {
|
|
56
|
+
const registry = adminApi.getRegistry();
|
|
57
|
+
registry.registerBulkAction('export-xlsx', {
|
|
58
|
+
label: 'Export as Excel',
|
|
59
|
+
icon: 'download',
|
|
60
|
+
color: 'blue',
|
|
61
|
+
models: '*',
|
|
62
|
+
confirm: false,
|
|
63
|
+
handler: async (records, modelName, { db: d }) => {
|
|
64
|
+
const { getModel } = require('../../core/orm/model');
|
|
65
|
+
const model = d.getModel ? d.getModel(modelName) : getModel(modelName);
|
|
66
|
+
const pk = model?.primaryKey || 'id';
|
|
67
|
+
const ids = records.map((r) => r[pk]).join(',');
|
|
68
|
+
return {
|
|
69
|
+
download: true,
|
|
70
|
+
url: `${adminPath}/api/data-exchange/export/${modelName}?ids=${encodeURIComponent(ids)}`,
|
|
71
|
+
filename: `${modelName}_export.xlsx`,
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { dataExchangePlugin };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header row → column index mapping for spreadsheet import.
|
|
3
|
+
* @module plugins/data-exchange/parse-table
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function normalizeHeaderCell(h) {
|
|
7
|
+
if (h == null || h === '') return '';
|
|
8
|
+
return String(h).replace(/^\uFEFF/, '').trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map header labels to model column names (case-insensitive; spaces → underscores).
|
|
13
|
+
* @param {string[]} headerRow
|
|
14
|
+
* @param {Iterable<string>} allowedKeys
|
|
15
|
+
* @returns {(string|null)[]} index → model column or null if unknown
|
|
16
|
+
*/
|
|
17
|
+
function buildHeaderMapping(headerRow, allowedKeys) {
|
|
18
|
+
const lowerToKey = new Map();
|
|
19
|
+
for (const k of allowedKeys) {
|
|
20
|
+
lowerToKey.set(String(k).toLowerCase().replace(/\s+/g, '_'), k);
|
|
21
|
+
}
|
|
22
|
+
const mapping = [];
|
|
23
|
+
for (let i = 0; i < headerRow.length; i++) {
|
|
24
|
+
const norm = normalizeHeaderCell(headerRow[i]).toLowerCase().replace(/\s+/g, '_');
|
|
25
|
+
if (!norm) {
|
|
26
|
+
mapping.push(null);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
mapping.push(lowerToKey.get(norm) ?? null);
|
|
30
|
+
}
|
|
31
|
+
return mapping;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Array<Array<*>>} rows including header as rows[0]
|
|
36
|
+
* @param {(string|null)[]} headerMapping
|
|
37
|
+
* @returns {{ rowNumber: number, data: Record<string, *> }[]} rowNumber = 1-based sheet row
|
|
38
|
+
*/
|
|
39
|
+
function dataRowsToObjects(rows, headerMapping) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (let r = 1; r < rows.length; r++) {
|
|
42
|
+
const row = rows[r] || [];
|
|
43
|
+
const data = {};
|
|
44
|
+
for (let i = 0; i < headerMapping.length; i++) {
|
|
45
|
+
const key = headerMapping[i];
|
|
46
|
+
if (!key) continue;
|
|
47
|
+
if (!(i in row)) continue;
|
|
48
|
+
const cell = row[i];
|
|
49
|
+
if (cell === undefined || cell === null) continue;
|
|
50
|
+
if (typeof cell === 'string' && cell.trim() === '') continue;
|
|
51
|
+
data[key] = cell;
|
|
52
|
+
}
|
|
53
|
+
if (Object.keys(data).length > 0) {
|
|
54
|
+
out.push({ rowNumber: r + 1, data });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
normalizeHeaderCell,
|
|
62
|
+
buildHeaderMapping,
|
|
63
|
+
dataRowsToObjects,
|
|
64
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve which records to export (same semantics as admin export handler).
|
|
3
|
+
* @module plugins/data-exchange/record-selection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { buildFilteredQuery } = require('../admin-panel/core/api-extensions');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {import('../../core/orm/database').Database} db
|
|
10
|
+
* @param {string} modelName
|
|
11
|
+
* @param {import('express').Request} req
|
|
12
|
+
* @returns {Promise<{ model?: import('../../core/orm/types').ModelDefinition, records?: object[], error?: number, message?: string }>}
|
|
13
|
+
*/
|
|
14
|
+
async function resolveExportRecords(db, modelName, req) {
|
|
15
|
+
const body = req.body || {};
|
|
16
|
+
let selectAll = body.selectAll ?? req.query.selectAll;
|
|
17
|
+
let filters = body.filters ?? req.query.filters;
|
|
18
|
+
if (typeof selectAll === 'string') selectAll = selectAll === 'true';
|
|
19
|
+
if (typeof filters === 'string') {
|
|
20
|
+
try {
|
|
21
|
+
filters = JSON.parse(filters);
|
|
22
|
+
} catch {
|
|
23
|
+
filters = undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let idList = null;
|
|
28
|
+
if (req.body?.ids && Array.isArray(req.body.ids)) {
|
|
29
|
+
idList = req.body.ids;
|
|
30
|
+
} else if (req.query.ids) {
|
|
31
|
+
idList = String(req.query.ids).split(',').filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!modelName) {
|
|
35
|
+
return { error: 400, message: 'Model name is required' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { getModel } = require('../../core/orm/model');
|
|
39
|
+
const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
|
|
40
|
+
|
|
41
|
+
if (!model || !model.admin?.enabled) {
|
|
42
|
+
return { error: 404, message: 'Model not found or not enabled' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const repo = db.getRepository(model.name);
|
|
46
|
+
let records;
|
|
47
|
+
|
|
48
|
+
if (selectAll) {
|
|
49
|
+
const query = buildFilteredQuery(repo, filters);
|
|
50
|
+
records = await query.list();
|
|
51
|
+
} else if (idList && idList.length > 0) {
|
|
52
|
+
records = [];
|
|
53
|
+
for (const id of idList) {
|
|
54
|
+
const record = await repo.findById(id);
|
|
55
|
+
if (record) records.push(record);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
records = await repo.findAll();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { model, records };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { resolveExportRecords };
|
package/plugins/index.js
CHANGED
|
@@ -17,6 +17,8 @@ const healthCheckPlugin = require('./health-check');
|
|
|
17
17
|
const restResourcePlugin = require('./rest-resources');
|
|
18
18
|
const ormCacheAdminPlugin = require('./orm-cache-admin');
|
|
19
19
|
const { uploadPlugin, createLocalFileProvider } = require('./upload');
|
|
20
|
+
/** Register after adminPanelPlugin (same db + adminPath) for session and routes. */
|
|
21
|
+
const { dataExchangePlugin } = require('./data-exchange');
|
|
20
22
|
|
|
21
23
|
module.exports = {
|
|
22
24
|
sitemapPlugin,
|
|
@@ -34,5 +36,6 @@ module.exports = {
|
|
|
34
36
|
ormCacheAdminPlugin,
|
|
35
37
|
uploadPlugin,
|
|
36
38
|
createLocalFileProvider,
|
|
39
|
+
dataExchangePlugin,
|
|
37
40
|
};
|
|
38
41
|
|