@xeplr/actions 1.1.20 → 1.1.21

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 CHANGED
@@ -348,10 +348,16 @@ Upserts need no index: they are staged and match keys in the move. A read-only o
348
348
  | format | reads | notes |
349
349
  |---|---|---|
350
350
  | `csv` | stream | `formatConfig`: `columns` (default first row), `delimiter` (`,`), `cast` (true), `skipEmptyLines`, `trim`. |
351
- | `excel` | file path | `.xlsx` via `unzipper` + `saxes`; `sheet`, `startRow`. Date-styled cells arrive as `Date`. |
351
+ | `excel` | file path | `.xlsx` via `unzipper` + `saxes`; `sheet`, `startRow`. Date-styled cells arrive as `Date`. Excel's own escapes in cell text are decoded (`_x000D_` → a real line break; `_x005F_` → `_`), and Japanese phonetic runs (`<rPh>`, furigana) are not joined into the value. |
352
352
  | `json` | stream | A JSON array or NDJSON; buffers the whole file. |
353
353
  | `txt` | — | Placeholder, not implemented. |
354
354
 
355
+ **Rows pasted into one cell** (`lib/formats/pastedRows.js`). Data copied out of an ERP and pasted into Excel: a value starting with `"` (an inch mark) makes Excel put everything up to the next `"` — tabs, line breaks, whole rows — into that one cell. The rows it swallowed exist nowhere else. Seen for real: 465 rows folded into 11 cells.
356
+ - `formats.scanPastedRows(format, filePath, { sheet })` → `{ cells, rows, examples: [{ row, column, rows, first }] }` — found, nothing changed. For an import's inspect step.
357
+ - `formatConfig.repairPastedRows: true` on `file-upload` puts them back: the cell splits on its line breaks into the rest of its own row, whole rows, and the start of the last row, whose remaining values are the merged row's later cells. The eaten quotes go back where they opened and closed. The action's output says what it did: `repairedPastedRows: { cells, rows, at }`.
358
+ - **Only an exact shape is repaired:** the pieces must have the sheet's column counts (and the cells the last row's values would leave empty must be empty). Ordinary multi-line text never matches, so a genuine note with line breaks is never touched. On the client's real file: 11 of 11 cells, 465 of 465 rows, no recovered entry number duplicating an existing one.
359
+ - `pastedRows.decodeOoxml`, `splitPastedRows(rec, headers)`, `expandPastedRows(rows)`, `scanForPastedRows(rows)` for other readers.
360
+
355
361
  File sources: only `local` is implemented (`path`). `sftp`, `sharepoint` and `google` are placeholders.
356
362
 
357
363
  ## Movement metadata (`xeplr_configs`)
@@ -448,7 +454,7 @@ node --test test/uploader/reconcile.test.js
448
454
 
449
455
  | needs nothing | needs a database |
450
456
  |---|---|
451
- | `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/write-progress.test.js`, `test/stop-movement.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root, so `@xeplr/db` must be resolvable) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js`, `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
457
+ | `test/formats/pasted-rows.test.js` (its last case loads into Postgres when `PG_HOST`/`PG_PORT`/`PG_USER`/`PG_PASSWORD`/`PG_DATABASE` are set, else skipped), `test/procedure-call.test.js`, `test/streaming/spool.test.js`, `test/uploader/reconcile.test.js`, `test/write-progress.test.js`, `test/stop-movement.test.js`, `test/formats/csv-gnarly.test.js`, `test/drivers/mysql.test.js`, `test/drivers/mssql.test.js`, `test/drivers/query-columns.test.js`, `test/builtins/email.test.js` (loads the package root, so `@xeplr/db` must be resolvable) | Postgres at `localhost:5435` (`PG_PASSWORD`, default `postgres`; database `xeplr_actions_test`): `test/drivers/postgres.test.js`, `test/builtins/db-push.test.js`, `test/builtins/file-upload.test.js`, `test/uploader/upload.test.js`; plus `@xeplr/db` for `test/uploader/meta-store-knex.test.js` |
452
458
  | `test/list-columns-length.test.js` (`XEPLR_TEST_PG_URL`, `MYSQL_TEST_URL`, `MSSQL_TEST_URL`; each skipped when unset), `test/drivers/duckdb.test.js`, `test/drivers/duckdb-values.test.js`, `test/duckdb-index-guard.test.js`, `test/staged-load.test.js` (needs `@duckdb/node-api`) | MySQL `localhost:3306` (`MYSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mysql-integration.test.js` |
453
459
  | | SQL Server `localhost:1433` (`MSSQL_HOST/PORT/USER/PASSWORD`): `test/drivers/mssql-integration.test.js` |
454
460
  | | Postgres + MySQL + SQL Server (`PG_*`, `MYSQL_*`, `MSSQL_*`): `test/builtins/db-replication.test.js` |
@@ -11,6 +11,7 @@
11
11
  var { upload } = require('../../uploader');
12
12
  var fileSources = require('../../drivers/file');
13
13
  var formats = require('../../formats');
14
+ var { expandPastedRows } = require('../../formats/pastedRows');
14
15
  var dbDrivers = require('../../drivers/db');
15
16
  var { SqlQueue } = require('@xeplr/utils/lib/queue');
16
17
  var { generateId } = require('@xeplr/utils/lib/helpers');
@@ -82,6 +83,13 @@ module.exports = {
82
83
  var stream = await source.openStream(sourceCfg);
83
84
  rowsIterable = format.parseStream(stream, input.formatConfig || {});
84
85
  }
86
+ // Rows that a paste folded into one cell, put back — only when asked
87
+ // (formatConfig.repairPastedRows), after the person saw what was found.
88
+ // See lib/formats/pastedRows.js.
89
+ var repaired = [];
90
+ if (input.formatConfig && input.formatConfig.repairPastedRows) {
91
+ rowsIterable = expandPastedRows(rowsIterable, function(r) { repaired.push(r); });
92
+ }
85
93
 
86
94
  // Set up target
87
95
  var movementId = input.movementId || system.occurrenceId || ('mv_' + generateId());
@@ -97,7 +105,7 @@ module.exports = {
97
105
  });
98
106
 
99
107
  try {
100
- return await upload({
108
+ var result = await upload({
101
109
  source: rowsIterable,
102
110
  driver: driver,
103
111
  connection: pool,
@@ -120,6 +128,15 @@ module.exports = {
120
128
  onProgress: system.onProgress || null,
121
129
  signal: system.signal || null
122
130
  });
131
+ // What the repair put back, for the import's report.
132
+ if (repaired.length) {
133
+ result = Object.assign({}, result, { repairedPastedRows: {
134
+ cells: repaired.length,
135
+ rows: repaired.reduce(function(n, r) { return n + r.rows; }, 0),
136
+ at: repaired
137
+ } });
138
+ }
139
+ return result;
123
140
  } finally {
124
141
  queue.stop();
125
142
  await driver.close(pool);
@@ -28,6 +28,7 @@
28
28
  var unzipper = require('unzipper');
29
29
  var { SaxesParser } = require('saxes');
30
30
  var { StringDecoder } = require('string_decoder');
31
+ var { decodeOoxml } = require('./pastedRows');
31
32
 
32
33
  // Strip an XML namespace prefix: "x:row" -> "row", "row" -> "row".
33
34
  function localName(n) {
@@ -194,18 +195,25 @@ async function parseSharedStrings(directory) {
194
195
  var f = findFile(directory, 'xl/sharedStrings.xml');
195
196
  if (!f) return null;
196
197
  var strings = [];
197
- var cur = null, inT = 0, buf = '';
198
+ // inRph: inside a phonetic run (<rPh>, the furigana Japanese Excel stores
199
+ // beside a cell's text). Its <t> is a reading aid, not the cell's value —
200
+ // joining it in gave "東京とうきょう" for 東京.
201
+ var cur = null, inT = 0, inRph = 0, buf = '';
198
202
  await saxParseStream(f.stream(), {
199
203
  opentag: function(tag) {
200
204
  var ln = localName(tag.name);
201
- if (ln === 'si') { cur = ''; }
202
- else if (ln === 't') { inT++; buf = ''; }
205
+ if (ln === 'si') { cur = ''; }
206
+ else if (ln === 'rPh') { inRph++; }
207
+ else if (ln === 't') { inT++; buf = ''; }
203
208
  },
204
209
  text: function(t) { if (inT > 0) buf += t; },
205
210
  closetag: function(tag) {
206
211
  var ln = localName(tag.name);
207
- if (ln === 't') { inT--; cur = (cur || '') + buf; buf = ''; }
208
- else if (ln === 'si') { strings.push(cur || ''); cur = null; }
212
+ if (ln === 't') { inT--; if (!inRph) cur = (cur || '') + buf; buf = ''; }
213
+ else if (ln === 'rPh') { inRph--; }
214
+ // Excel's own escapes (_x000D_ for a line break inside the cell) decoded
215
+ // here, once per distinct string.
216
+ else if (ln === 'si') { strings.push(decodeOoxml(cur || '')); cur = null; }
209
217
  }
210
218
  });
211
219
  return strings;
@@ -288,7 +296,7 @@ async function stream_xlsx_sheet_rows(filePath, sheet_name, handlers) {
288
296
  } else if (curType === 'b') {
289
297
  val = textBuf === '1';
290
298
  } else if (curType === 'str' || curType === 'inlineStr' || closingLocal === 't') {
291
- val = textBuf;
299
+ val = decodeOoxml(textBuf);
292
300
  } else {
293
301
  var n = Number(textBuf);
294
302
  val = (textBuf !== '' && !Number.isNaN(n)) ? n : (textBuf === '' ? null : textBuf);
@@ -33,8 +33,27 @@ function checkFormatRequires(actionName, format, mod) {
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Which cells of a file hold rows a paste folded into them (pastedRows.js),
38
+ * read without changing anything — for an import's inspect step.
39
+ * opts: the format's own options (`sheet` for excel), plus `examples`.
40
+ */
41
+ async function scanPastedRows(type, filePath, opts) {
42
+ var format = getFormat(type);
43
+ var config = opts || {};
44
+ var rows = format.inputMode === 'path'
45
+ ? format.parsePath(filePath, config)
46
+ : format.parseStream(require('fs').createReadStream(filePath), config);
47
+ return pastedRows.scanForPastedRows(rows, { examples: config.examples });
48
+ }
49
+
50
+ var pastedRows = require('./pastedRows');
51
+
36
52
  module.exports = {
37
53
  getFormat: getFormat,
38
54
  checkFormatRequires: checkFormatRequires,
39
- SUPPORTED: Object.keys(FORMATS)
55
+ SUPPORTED: Object.keys(FORMATS),
56
+ scanPastedRows: scanPastedRows,
57
+ // decodeOoxml, splitPastedRows, expandPastedRows, scanForPastedRows
58
+ pastedRows: pastedRows
40
59
  };
@@ -0,0 +1,146 @@
1
+ // ROWS PASTED INTO ONE CELL — found, and put back.
2
+ //
3
+ // Data copied out of an ERP is tab-separated text, a row per line. Pasted into
4
+ // Excel, a value that STARTS with a double quote (an inch mark: `"4 INCH`)
5
+ // switches Excel into quoted mode, and everything up to the next `"` — tabs,
6
+ // line breaks, whole rows — lands in that one cell. The sheet then holds one
7
+ // row whose cell carries the next N rows, and those rows exist nowhere else.
8
+ // Seen for real: 465 rows folded into 11 Item Description cells.
9
+ //
10
+ // The damage has an exact shape, which is what makes it safe to undo:
11
+ //
12
+ // cell = <its own row's value> \t <its own row's remaining columns>
13
+ // \n <a whole row> \n <a whole row> …
14
+ // \n <the last row's columns up to and including this one>
15
+ //
16
+ // and the merged row's OTHER columns after the cell belong to that last row.
17
+ // So the cell splits on its line breaks into a first piece of (n - c) values,
18
+ // whole rows of n values, and a last piece of L values — n columns, the cell
19
+ // at column c. Usually L is c + 1 (the closing quote was in the same column);
20
+ // when it closed in a later column k, L is k + 1, and the last row's
21
+ // remaining (n - L) values sit in the merged row's cells right after c, the
22
+ // cells beyond them empty. A cell that does not split exactly like that is
23
+ // left alone: ordinary multi-line text never has those counts, so a genuine
24
+ // note with line breaks is never "repaired".
25
+ //
26
+ // The quote that opened the cell and the one that closed it were eaten by the
27
+ // paste; they are put back — at the start of the first row's value, where
28
+ // quoted mode began, and at the end of the last row's value where it ended
29
+ // (an inch mark: `Painting brush 4"`).
30
+
31
+ // Excel's own escape inside cell text: `_xHHHH_` is the character with that
32
+ // hex code. A line break inside a cell is saved as `_x000D_` + a real newline;
33
+ // `_x005F_` is a literal underscore, which is how text that really says
34
+ // "_x000D_" is stored ("_x005F_x000D_") — left to right, that decodes right.
35
+ var ESCAPE = /_x([0-9A-Fa-f]{4})_/g;
36
+
37
+ function decodeOoxml(s) {
38
+ if (typeof s !== 'string' || s.indexOf('_x') === -1) return s;
39
+ return s.replace(ESCAPE, function(_, hex) { return String.fromCharCode(parseInt(hex, 16)); });
40
+ }
41
+
42
+ var LINE = /\r\n|\r|\n/;
43
+ var NUMBER = /^-?\d+(\.\d+)?$/;
44
+
45
+ // A value from inside the cell: text, typed as the sheet's own cells would be.
46
+ function cast(v) {
47
+ if (v === '') return null;
48
+ return NUMBER.test(v) ? Number(v) : v;
49
+ }
50
+
51
+ /**
52
+ * The rows folded into one of `rec`'s cells, put back — or null when no cell
53
+ * has that exact shape. `headers` in sheet order.
54
+ *
55
+ * @returns {{ column: string, rows: object[] } | null} rows[0] is `rec` itself, rebuilt
56
+ */
57
+ function splitPastedRows(rec, headers) {
58
+ var n = headers.length;
59
+ for (var c = 0; c < n; c++) {
60
+ var cell = decodeOoxml(rec[headers[c]]);
61
+ if (typeof cell !== 'string' || cell.indexOf('\t') === -1 || !LINE.test(cell)) continue;
62
+ var lines = cell.split(LINE);
63
+ var first = lines[0].split('\t');
64
+ var last = lines[lines.length - 1].split('\t');
65
+ var middle = lines.slice(1, -1).map(function(l) { return l.split('\t'); });
66
+ var L = last.length;
67
+ if (first.length !== n - c || L < c + 1 || L > n) continue;
68
+ if (middle.some(function(m) { return m.length !== n; })) continue;
69
+ // The last row's values after the closing quote: the merged row's cells
70
+ // right after c — and every cell beyond those must be empty, or this is
71
+ // not the shape a paste leaves.
72
+ var tail = headers.slice(c + 1, c + 1 + (n - L));
73
+ var beyond = headers.slice(c + 1 + (n - L));
74
+ if (beyond.some(function(h) { return rec[h] !== null && rec[h] !== undefined && rec[h] !== ''; })) continue;
75
+
76
+ var rows = [];
77
+ var top = {};
78
+ headers.forEach(function(h, i) { top[h] = i < c ? rec[h] : cast(first[i - c]); });
79
+ top[headers[c]] = '"' + first[0];
80
+ rows.push(top);
81
+ middle.forEach(function(m) {
82
+ var r = {};
83
+ headers.forEach(function(h, i) { r[h] = cast(m[i]); });
84
+ rows.push(r);
85
+ });
86
+ var end = {};
87
+ headers.forEach(function(h, i) { end[h] = i < L ? cast(last[i]) : rec[tail[i - L]]; });
88
+ // The closing quote, at the end of the value it closed.
89
+ end[headers[L - 1]] = String(last[L - 1]) + '"';
90
+ rows.push(end);
91
+ return { column: headers[c], rows: rows };
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Rows as they were before the paste folded them: each record whose cell
98
+ * holds pasted rows becomes those rows. Everything else passes through.
99
+ * `onRepair({ row, column, rows })` hears each one (row: 1-based data row).
100
+ */
101
+ async function* expandPastedRows(records, onRepair) {
102
+ var headers = null;
103
+ var index = 0;
104
+ for await (var rec of records) {
105
+ index++;
106
+ if (!headers) headers = Object.keys(rec);
107
+ var split = splitPastedRows(rec, headers);
108
+ if (!split) { yield rec; continue; }
109
+ if (onRepair) onRepair({ row: index, column: split.column, rows: split.rows.length - 1 });
110
+ for (var r of split.rows) yield r;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Read a whole file's rows and say which cells hold pasted rows, without
116
+ * changing anything — for the inspect step, before anything is loaded.
117
+ *
118
+ * @returns {{ cells: number, rows: number, examples: [{ row, column, rows, first }] }}
119
+ * rows: how many rows the repair would recover
120
+ */
121
+ async function scanForPastedRows(records, opts) {
122
+ var limit = (opts && opts.examples) || 5;
123
+ var headers = null;
124
+ var index = 0;
125
+ var found = { cells: 0, rows: 0, examples: [] };
126
+ for await (var rec of records) {
127
+ index++;
128
+ if (!headers) headers = Object.keys(rec);
129
+ var split = splitPastedRows(rec, headers);
130
+ if (!split) continue;
131
+ found.cells++;
132
+ found.rows += split.rows.length - 1;
133
+ if (found.examples.length < limit) {
134
+ found.examples.push({ row: index, column: split.column, rows: split.rows.length - 1,
135
+ first: String(split.rows[0][split.column]).slice(0, 80) });
136
+ }
137
+ }
138
+ return found;
139
+ }
140
+
141
+ module.exports = {
142
+ decodeOoxml: decodeOoxml,
143
+ splitPastedRows: splitPastedRows,
144
+ expandPastedRows: expandPastedRows,
145
+ scanForPastedRows: scanForPastedRows
146
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "1.1.20",
3
+ "version": "1.1.21",
4
4
  "description": "Named-action registry with typed inputs and safe invocation — plus built-in actions for HTTP, DB, files, email, spawn",
5
5
  "main": "index.js",
6
6
  "files": [