@saltcorn/qlik-qvd 0.1.0 → 0.1.1
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/.claude/settings.local.json +7 -0
- package/README.md +11 -0
- package/index.js +86 -12
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# qlik-qvd
|
|
2
|
+
|
|
3
|
+
Provides the import_qvd_file function, which takes as argumnets:
|
|
4
|
+
|
|
5
|
+
- a filename (a file in the file store)
|
|
6
|
+
- optionally a table name (otherwise it will use the name found in the QVD file)
|
|
7
|
+
|
|
8
|
+
and when run:
|
|
9
|
+
|
|
10
|
+
1. If the table does not exist, creates it with the fields in the QVD file
|
|
11
|
+
2. Imports the rows in the QVD file into the table
|
package/index.js
CHANGED
|
@@ -2,17 +2,49 @@ const File = require("@saltcorn/data/models/file");
|
|
|
2
2
|
const Table = require("@saltcorn/data/models/table");
|
|
3
3
|
const Field = require("@saltcorn/data/models/field");
|
|
4
4
|
const { getState } = require("@saltcorn/data/db/state");
|
|
5
|
+
const db = require("@saltcorn/data/db");
|
|
6
|
+
|
|
7
|
+
const { Readable } = require("stream");
|
|
5
8
|
|
|
6
9
|
const { QvdDataFrame, QvdFileReader } = require("qvd4js");
|
|
7
10
|
|
|
11
|
+
// Serialise a single value into a CSV cell suitable for a Postgres
|
|
12
|
+
// `COPY ... FROM STDIN CSV HEADER` ingest. An empty cell becomes NULL.
|
|
13
|
+
const csvCell = (v) => {
|
|
14
|
+
if (v === null || v === undefined) return "";
|
|
15
|
+
let s;
|
|
16
|
+
if (v instanceof Date) s = v.toISOString();
|
|
17
|
+
else s = String(v);
|
|
18
|
+
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
|
19
|
+
return s;
|
|
20
|
+
};
|
|
21
|
+
|
|
8
22
|
const numberFormatToType = (nf) => {
|
|
9
|
-
if (nf.Type === "REAL") return "Float";
|
|
10
|
-
if (nf.Type === "INTEGER") return "Integer";
|
|
11
|
-
if (nf.Type === "TIME") return "String";
|
|
12
|
-
if (nf.Type === "UNKNOWN") return "String";
|
|
23
|
+
if (nf.Type === "REAL") return { type: "Float" };
|
|
24
|
+
if (nf.Type === "INTEGER") return { type: "Integer" };
|
|
25
|
+
if (nf.Type === "TIME") return { type: "String" };
|
|
26
|
+
if (nf.Type === "UNKNOWN") return { type: "String" };
|
|
27
|
+
if (nf.Type === "DATE")
|
|
28
|
+
return {
|
|
29
|
+
type: "Date",
|
|
30
|
+
attributes: nf.Fmt === "DD.MM.YYYY" ? { day_only: true } : {},
|
|
31
|
+
};
|
|
32
|
+
if (nf.Type === "TIMESTAMP")
|
|
33
|
+
return {
|
|
34
|
+
type: "Date",
|
|
35
|
+
};
|
|
13
36
|
throw new Error("Unknown NumberFormat: " + JSON.stringify(nf));
|
|
14
37
|
};
|
|
15
38
|
|
|
39
|
+
const QLIK_EPOCH_MS = Date.UTC(1899, 11, 30); // 1899-12-30 00:00:00 (month is 0-indexed)
|
|
40
|
+
const MS_PER_DAY = 86400000;
|
|
41
|
+
|
|
42
|
+
function fromQlik(serial, { roundToSeconds = true } = {}) {
|
|
43
|
+
let ms = QLIK_EPOCH_MS + serial * MS_PER_DAY;
|
|
44
|
+
if (roundToSeconds) ms = Math.round(ms / 1000) * 1000;
|
|
45
|
+
return new Date(ms);
|
|
46
|
+
}
|
|
47
|
+
|
|
16
48
|
module.exports = {
|
|
17
49
|
sc_plugin_api_version: 1,
|
|
18
50
|
plugin_name: "qlik-qvd",
|
|
@@ -36,23 +68,65 @@ module.exports = {
|
|
|
36
68
|
const fld = {
|
|
37
69
|
table,
|
|
38
70
|
label: field.FieldName,
|
|
39
|
-
|
|
40
|
-
attributes: {},
|
|
71
|
+
...numberFormatToType(field.NumberFormat),
|
|
41
72
|
};
|
|
42
73
|
|
|
43
74
|
const f = await Field.create(fld);
|
|
44
75
|
field_names.push(f.name);
|
|
45
76
|
}
|
|
46
|
-
await getState.refresh_tables()
|
|
77
|
+
await getState().refresh_tables();
|
|
47
78
|
} else {
|
|
48
79
|
field_names = fields.map((f) => Field.labelToName(f.FieldName));
|
|
49
80
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
81
|
+
const timeStampFields = new Set(
|
|
82
|
+
table.fields
|
|
83
|
+
.filter((f) => f.type?.name === "Date" && !f.attributes?.day_only)
|
|
84
|
+
.map((f) => f.name),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Convert the raw QVD value for a given target field to the value we
|
|
88
|
+
// want stored, applying the Qlik-serial -> Date conversion for
|
|
89
|
+
// timestamp fields (mirrors the row-by-row path below).
|
|
90
|
+
const convertValue = (fname, val) => {
|
|
91
|
+
if (timeStampFields.has(fname) && typeof val === "number")
|
|
92
|
+
return fromQlik(val);
|
|
93
|
+
return val;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Fast path: bulk-load via Postgres COPY when the driver supports it.
|
|
97
|
+
// db.copyFrom is only defined on the Postgres driver, not SQLite.
|
|
98
|
+
if (db.copyFrom) {
|
|
99
|
+
// Stream a CSV of the QVD data, using the plugin's field names as the
|
|
100
|
+
// header, instead of issuing one insertRow per row.
|
|
101
|
+
const csvStream = Readable.from(
|
|
102
|
+
(function* () {
|
|
103
|
+
yield field_names.map(csvCell).join(",") + "\n";
|
|
104
|
+
for (const row of df.data) {
|
|
105
|
+
const cells = new Array(field_names.length);
|
|
106
|
+
for (let index = 0; index < field_names.length; index++) {
|
|
107
|
+
const fname = field_names[index];
|
|
108
|
+
cells[index] = csvCell(convertValue(fname, row[index]));
|
|
109
|
+
}
|
|
110
|
+
yield cells.join(",") + "\n";
|
|
111
|
+
}
|
|
112
|
+
})(),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const client = await db.getClient();
|
|
116
|
+
try {
|
|
117
|
+
await db.copyFrom(csvStream, table.name, field_names, client);
|
|
118
|
+
} finally {
|
|
119
|
+
await client.release(true);
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
for (const row of df.data) {
|
|
123
|
+
const o = {};
|
|
124
|
+
for (let index = 0; index < field_names.length; index++) {
|
|
125
|
+
const fname = field_names[index];
|
|
126
|
+
o[fname] = convertValue(fname, row[index]);
|
|
127
|
+
}
|
|
128
|
+
await table.insertRow(o);
|
|
54
129
|
}
|
|
55
|
-
await table.insertRow(o);
|
|
56
130
|
}
|
|
57
131
|
},
|
|
58
132
|
description: "Convert a list of JSON objects to a CSV string",
|