thatcher 1.0.52 → 1.0.54

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.52",
3
+ "version": "1.0.54",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,93 @@
1
+ import { getColumns } from '@/ui/grid-view-renderer.js';
2
+ import { validateEntity, sanitizeData } from '@/lib/validation/index.js';
3
+ import { create } from '@/lib/busybase/store.js';
4
+
5
+ const MAX_IMPORT_ROWS = 10000;
6
+
7
+ function parseCsvRows(text) {
8
+ const rows = [];
9
+ let row = [];
10
+ let field = '';
11
+ let inQuotes = false;
12
+ let i = 0;
13
+ const n = text.length;
14
+
15
+ while (i < n) {
16
+ const c = text[i];
17
+ if (inQuotes) {
18
+ if (c === '"') {
19
+ if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
20
+ inQuotes = false; i++; continue;
21
+ }
22
+ field += c; i++; continue;
23
+ }
24
+ if (c === '"') { inQuotes = true; i++; continue; }
25
+ if (c === ',') { row.push(field); field = ''; i++; continue; }
26
+ if (c === '\r') { i++; continue; }
27
+ if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
28
+ field += c; i++;
29
+ }
30
+ if (field.length || row.length) { row.push(field); rows.push(row); }
31
+ return rows.filter(r => !(r.length === 1 && r[0] === ''));
32
+ }
33
+
34
+ export function parseCsv(text) {
35
+ const rows = parseCsvRows(text);
36
+ if (!rows.length) return { header: [], records: [] };
37
+ const [header, ...dataRows] = rows;
38
+ const malformedRows = [];
39
+ const records = dataRows.map((cells, idx) => {
40
+ if (cells.length !== header.length) {
41
+ malformedRows.push({ row: idx + 2, error: `Expected ${header.length} columns, got ${cells.length}` });
42
+ return null;
43
+ }
44
+ const obj = {};
45
+ header.forEach((key, i) => { obj[key] = cells[i]; });
46
+ return obj;
47
+ });
48
+ return { header, records, malformedRows };
49
+ }
50
+
51
+ export async function importCsv(entityName, spec, csvText, user) {
52
+ if (csvText.length > 5 * 1024 * 1024) {
53
+ return { ok: false, error: 'CSV exceeds 5MB import limit' };
54
+ }
55
+ const columns = getColumns(spec);
56
+ const labelToKey = {};
57
+ columns.forEach(([key, field]) => { labelToKey[field?.label || key] = key; });
58
+
59
+ const { header, records, malformedRows } = parseCsv(csvText);
60
+ if (!header.length) return { ok: false, error: 'CSV has no header row' };
61
+ if (records.length > MAX_IMPORT_ROWS) {
62
+ return { ok: false, error: `CSV exceeds ${MAX_IMPORT_ROWS}-row import limit` };
63
+ }
64
+
65
+ const results = malformedRows.map(m => ({ row: m.row, success: false, error: m.error }));
66
+ let rowNum = 1;
67
+ for (const raw of records) {
68
+ rowNum++;
69
+ if (raw === null) continue;
70
+ const mapped = {};
71
+ for (const [csvKey, value] of Object.entries(raw)) {
72
+ const fieldKey = labelToKey[csvKey] || csvKey;
73
+ mapped[fieldKey] = value;
74
+ }
75
+ try {
76
+ const errors = await validateEntity(entityName, mapped);
77
+ if (Object.keys(errors).length > 0) {
78
+ results.push({ row: rowNum, success: false, error: JSON.stringify(errors) });
79
+ continue;
80
+ }
81
+ const sanitized = sanitizeData(entityName, mapped, spec);
82
+ const record = await create(entityName, sanitized, user);
83
+ results.push({ row: rowNum, success: true, id: record.id });
84
+ } catch (err) {
85
+ results.push({ row: rowNum, success: false, error: err.message });
86
+ }
87
+ }
88
+
89
+ results.sort((a, b) => a.row - b.row);
90
+ const succeeded = results.filter(r => r.success).length;
91
+ const failed = results.length - succeeded;
92
+ return { ok: true, total: results.length, succeeded, failed, results };
93
+ }
@@ -102,6 +102,10 @@ export function createServer(options) {
102
102
  const id = parts[1] || null;
103
103
  const action = parts[2] || null;
104
104
 
105
+ if (req.method === 'POST' && id === 'import' && !action) {
106
+ return await handleCsvImport(req, res, entity, thatcher, configEngine);
107
+ }
108
+
105
109
  // Check if user has custom route for this
106
110
  const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
107
111
  const routeExists = await fileExists(userRoutePath);
@@ -329,6 +333,69 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
329
333
  }
330
334
  }
331
335
 
336
+ async function handleCsvImport(req, res, entity, thatcher, configEngineArg) {
337
+ const user = await resolveRequestUser(req);
338
+ if (!user) {
339
+ res.writeHead(401, { 'Content-Type': 'application/json' });
340
+ res.end(JSON.stringify({ error: 'Authentication required' }));
341
+ return;
342
+ }
343
+
344
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
345
+ if (!configEngine) {
346
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
347
+ configEngine = getConfigEngineSync();
348
+ }
349
+ let spec;
350
+ try {
351
+ spec = configEngine.generateEntitySpec(entity);
352
+ } catch (e) {
353
+ res.writeHead(404);
354
+ res.end(JSON.stringify({ error: `Entity '${entity}' not found` }));
355
+ return;
356
+ }
357
+
358
+ try {
359
+ await requirePermission(user, spec, 'create');
360
+ } catch (e) {
361
+ res.writeHead(e?.status || 403, { 'Content-Type': 'application/json' });
362
+ res.end(JSON.stringify({ error: e?.message || 'Forbidden' }));
363
+ return;
364
+ }
365
+
366
+ let body;
367
+ try {
368
+ body = await readBody(req);
369
+ } catch (e) {
370
+ res.writeHead(400);
371
+ res.end(JSON.stringify({ error: e.message }));
372
+ return;
373
+ }
374
+
375
+ const csvText = typeof body === 'string' ? body : (body?.csv || '');
376
+ if (!csvText) {
377
+ res.writeHead(400);
378
+ res.end(JSON.stringify({ error: 'CSV body required' }));
379
+ return;
380
+ }
381
+
382
+ try {
383
+ const { importCsv } = await import('../lib/csv-import.js');
384
+ const result = await importCsv(entity, spec, csvText, user);
385
+ if (!result.ok) {
386
+ res.writeHead(400);
387
+ res.end(JSON.stringify({ error: result.error }));
388
+ return;
389
+ }
390
+ res.writeHead(200, { 'Content-Type': 'application/json' });
391
+ res.end(JSON.stringify(result));
392
+ } catch (err) {
393
+ apiLog.error(err.message);
394
+ res.writeHead(500);
395
+ res.end(JSON.stringify({ error: err.message }));
396
+ }
397
+ }
398
+
332
399
  async function readBody(req) {
333
400
  return new Promise((resolve, reject) => {
334
401
  let data = '';