@es-labs/jslib 0.0.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/CHANGELOG.md +4 -0
- package/README.md +42 -0
- package/__test__/services.test.js +32 -0
- package/auth/index.js +226 -0
- package/auth/keyv.js +23 -0
- package/auth/knex.js +29 -0
- package/auth/redis.js +23 -0
- package/comms/email.js +123 -0
- package/comms/nexmo.js +44 -0
- package/comms/telegram.js +43 -0
- package/comms/telegram2/inbound.js +314 -0
- package/comms/telegram2/outbound.js +574 -0
- package/comms/webpush.js +60 -0
- package/config.js +37 -0
- package/express/controller/auth/oauth.js +39 -0
- package/express/controller/auth/oidc.js +87 -0
- package/express/controller/auth/own.js +100 -0
- package/express/controller/auth/saml.js +74 -0
- package/express/upload.js +48 -0
- package/index.js +1 -0
- package/iso/README.md +4 -0
- package/iso/__tests__/csv-utils.spec.js +128 -0
- package/iso/__tests__/datetime.spec.js +101 -0
- package/iso/__tests__/fetch.spec.js +270 -0
- package/iso/csv-utils.js +206 -0
- package/iso/datetime.js +103 -0
- package/iso/fetch.js +129 -0
- package/iso/fetch2.js +180 -0
- package/iso/log-filter.js +17 -0
- package/iso/sleep.js +6 -0
- package/iso/ws.js +63 -0
- package/node/oss-files/oss-uploader-client-fetch.js +258 -0
- package/node/oss-files/oss-uploader-client-fetch.md +31 -0
- package/node/oss-files/oss-uploader-client.js +219 -0
- package/node/oss-files/oss-uploader-server.js +199 -0
- package/node/oss-files/oss-uploader-usage.js +121 -0
- package/node/oss-files/oss-uploader-usage.md +34 -0
- package/node/oss-files/s3-uploader-client.js +217 -0
- package/node/oss-files/s3-uploader-server.js +123 -0
- package/node/oss-files/s3-uploader-usage.js +77 -0
- package/node/oss-files/s3-uploader-usage.md +34 -0
- package/package.json +53 -0
- package/packageInfo.js +9 -0
- package/services/ali.js +279 -0
- package/services/aws.js +194 -0
- package/services/db/__tests__/keyv.spec.js +31 -0
- package/services/db/keyv.js +14 -0
- package/services/db/knex.js +67 -0
- package/services/db/redis.js +51 -0
- package/services/index.js +57 -0
- package/services/mq/README.md +8 -0
- package/services/websocket.js +139 -0
- package/t4t/README.md +1 -0
- package/traps.js +20 -0
- package/utils/__tests__/aes.spec.js +52 -0
- package/utils/aes.js +23 -0
- package/web/UI.md +71 -0
- package/web/bwc-autocomplete.js +211 -0
- package/web/bwc-combobox.js +343 -0
- package/web/bwc-fileupload.js +87 -0
- package/web/bwc-loading-overlay.js +54 -0
- package/web/bwc-t4t-form.js +511 -0
- package/web/bwc-table.js +756 -0
- package/web/fetch.js +129 -0
- package/web/i18n.js +24 -0
- package/web/idle.js +49 -0
- package/web/parse-jwt.js +15 -0
- package/web/pwa.js +84 -0
- package/web/sign-pad.js +164 -0
- package/web/t4t-fe.js +164 -0
- package/web/util.js +126 -0
- package/web/web-cam.js +182 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
3
|
+
import Fetch from '../fetch.js';
|
|
4
|
+
|
|
5
|
+
function createFetchMock() {
|
|
6
|
+
const calls = [];
|
|
7
|
+
const responses = [];
|
|
8
|
+
const fn = async (...args) => {
|
|
9
|
+
calls.push(args);
|
|
10
|
+
const res = responses.shift();
|
|
11
|
+
if (!res) throw new Error('No mock response configured');
|
|
12
|
+
return res;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
fn.calls = calls;
|
|
16
|
+
fn.mockResolvedValueOnce = (value) => { responses.push(value); return fn; };
|
|
17
|
+
fn.mockResolvedValue = (value) => { responses.length = 0; responses.push(value); return fn; };
|
|
18
|
+
fn.mockClear = () => { calls.length = 0; responses.length = 0; };
|
|
19
|
+
return fn;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe.skip('fetch.js', () => {
|
|
23
|
+
let originalFetch;
|
|
24
|
+
let fetchInstance;
|
|
25
|
+
let fetchMock;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
originalFetch = global.fetch;
|
|
29
|
+
fetchMock = createFetchMock();
|
|
30
|
+
global.fetch = fetchMock;
|
|
31
|
+
fetchInstance = new Fetch();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
global.fetch = originalFetch;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('constructor', () => {
|
|
39
|
+
it('should set default options and tokens', () => {
|
|
40
|
+
assert.strictEqual(fetchInstance.options.baseUrl, '');
|
|
41
|
+
assert.strictEqual(fetchInstance.options.credentials, 'same-origin');
|
|
42
|
+
assert.strictEqual(typeof fetchInstance.options.forceLogoutFn, 'function');
|
|
43
|
+
assert.strictEqual(fetchInstance.options.refreshUrl, '');
|
|
44
|
+
assert.strictEqual(fetchInstance.options.timeoutMs, 0);
|
|
45
|
+
assert.strictEqual(fetchInstance.options.maxRetry, 0);
|
|
46
|
+
assert.deepStrictEqual(fetchInstance.tokens, { access: '', refresh: '' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should override options and tokens', () => {
|
|
50
|
+
const options = { baseUrl: 'http://localhost', timeoutMs: 5000 };
|
|
51
|
+
const tokens = { access: 'token123', refresh: 'refresh123' };
|
|
52
|
+
fetchInstance = new Fetch(options, tokens);
|
|
53
|
+
assert.strictEqual(fetchInstance.options.baseUrl, 'http://localhost');
|
|
54
|
+
assert.strictEqual(fetchInstance.options.timeoutMs, 5000);
|
|
55
|
+
assert.strictEqual(fetchInstance.tokens.access, 'token123');
|
|
56
|
+
assert.strictEqual(fetchInstance.tokens.refresh, 'refresh123');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('parseUrl', () => {
|
|
61
|
+
it('should parse relative URL', () => {
|
|
62
|
+
const result = Fetch.parseUrl('/api/test', 'http://localhost');
|
|
63
|
+
assert.deepStrictEqual(result, {
|
|
64
|
+
urlOrigin: 'http://localhost',
|
|
65
|
+
urlPath: '/api/test',
|
|
66
|
+
urlFull: 'http://localhost/api/test',
|
|
67
|
+
urlSearch: ''
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should parse absolute URL with query', () => {
|
|
72
|
+
const result = Fetch.parseUrl('http://localhost/api/test?key=value');
|
|
73
|
+
assert.deepStrictEqual(result, {
|
|
74
|
+
urlOrigin: 'http://localhost',
|
|
75
|
+
urlPath: '/api/test',
|
|
76
|
+
urlFull: 'http://localhost/api/test',
|
|
77
|
+
urlSearch: '?key=value'
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle invalid URL', () => {
|
|
82
|
+
const result = Fetch.parseUrl('invalid-url', 'http://localhost');
|
|
83
|
+
assert.strictEqual(result.urlOrigin, 'http://localhost');
|
|
84
|
+
assert.strictEqual(result.urlPath, 'invalid-url');
|
|
85
|
+
assert.strictEqual(result.urlFull, 'http://localhostinvalid-url');
|
|
86
|
+
assert.strictEqual(result.urlSearch, '');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('setOptions and getOptions', () => {
|
|
91
|
+
it('should set and get options', () => {
|
|
92
|
+
fetchInstance.setOptions({ baseUrl: 'http://test.com' });
|
|
93
|
+
assert.strictEqual(fetchInstance.getOptions().baseUrl, 'http://test.com');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('setTokens and getTokens', () => {
|
|
98
|
+
it('should set and get tokens', () => {
|
|
99
|
+
fetchInstance.setTokens({ access: 'newtoken' });
|
|
100
|
+
assert.strictEqual(fetchInstance.getTokens().access, 'newtoken');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('http', () => {
|
|
105
|
+
it('should make a successful GET request', async () => {
|
|
106
|
+
fetchMock.mockResolvedValueOnce({
|
|
107
|
+
status: 200,
|
|
108
|
+
text: () => Promise.resolve('{"data": "test"}')
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const result = await fetchInstance.http('GET', 'http://localhost/api/test');
|
|
112
|
+
assert.strictEqual(result.status, 200);
|
|
113
|
+
assert.deepStrictEqual(result.data, { data: 'test' });
|
|
114
|
+
assert.strictEqual(fetchMock.calls.length, 1);
|
|
115
|
+
assert.strictEqual(fetchMock.calls[0][0], 'http://localhost/api/test');
|
|
116
|
+
assert.strictEqual(fetchMock.calls[0][1].method, 'GET');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should handle query parameters', async () => {
|
|
120
|
+
fetchMock.mockResolvedValueOnce({
|
|
121
|
+
status: 200,
|
|
122
|
+
text: () => Promise.resolve('')
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await fetchInstance.http('GET', 'http://localhost/api/test', null, { key: 'value' });
|
|
126
|
+
assert.strictEqual(fetchMock.calls[0][0], 'http://localhost/api/test?key=value');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should handle POST with JSON body', async () => {
|
|
130
|
+
fetchMock.mockResolvedValueOnce({
|
|
131
|
+
status: 201,
|
|
132
|
+
text: () => Promise.resolve('')
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await fetchInstance.http('POST', 'http://localhost/api/test', { name: 'test' });
|
|
136
|
+
assert.strictEqual(fetchMock.calls[0][0], 'http://localhost/api/test');
|
|
137
|
+
assert.strictEqual(fetchMock.calls[0][1].method, 'POST');
|
|
138
|
+
assert.strictEqual(fetchMock.calls[0][1].body, '{"name":"test"}');
|
|
139
|
+
assert.strictEqual(fetchMock.calls[0][1].headers['Content-Type'], 'application/json');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should handle POST with URL-encoded body', async () => {
|
|
143
|
+
fetchMock.mockResolvedValueOnce({
|
|
144
|
+
status: 201,
|
|
145
|
+
text: () => Promise.resolve('')
|
|
146
|
+
});
|
|
147
|
+
await fetchInstance.http('POST', 'http://localhost/api/test', { name: 'test' }, null, { 'Content-Type': 'application/x-www-form-urlencoded' });
|
|
148
|
+
assert.strictEqual(fetchMock.calls[0][0], 'http://localhost/api/test');
|
|
149
|
+
assert.strictEqual(fetchMock.calls[0][1].method, 'POST');
|
|
150
|
+
assert.strictEqual(fetchMock.calls[0][1].body.toString(), 'name=test');
|
|
151
|
+
assert.strictEqual(fetchMock.calls[0][1].headers['Content-Type'], 'application/x-www-form-urlencoded');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should handle POST with octet-stream', async () => {
|
|
155
|
+
fetchMock.mockResolvedValueOnce({ status: 201, text: () => Promise.resolve('') });
|
|
156
|
+
|
|
157
|
+
const binaryData = new Uint8Array(Buffer.from('Hello', 'utf-8'));
|
|
158
|
+
await fetchInstance.http('POST', 'http://localhost/api/test', binaryData, null, { 'Content-Type': 'application/octet-stream' });
|
|
159
|
+
assert.strictEqual(fetchMock.calls[0][0], 'http://localhost/api/test');
|
|
160
|
+
assert.strictEqual(fetchMock.calls[0][1].method, 'POST');
|
|
161
|
+
assert.strictEqual(fetchMock.calls[0][1].body.toString(), binaryData.toString());
|
|
162
|
+
assert.strictEqual(fetchMock.calls[0][1].headers['Content-Type'], 'application/octet-stream');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should handle FormData body', async () => {
|
|
166
|
+
const formData = new FormData();
|
|
167
|
+
formData.append('file', 'test');
|
|
168
|
+
fetchMock.mockResolvedValueOnce({ status: 200, text: () => Promise.resolve('') });
|
|
169
|
+
await fetchInstance.http('POST', 'http://localhost/api/test', formData);
|
|
170
|
+
assert.strictEqual(fetchMock.calls[0][0], 'http://localhost/api/test');
|
|
171
|
+
assert.strictEqual(fetchMock.calls[0][1].method, 'POST');
|
|
172
|
+
assert.strictEqual(fetchMock.calls[0][1].body, formData);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should handle 401 with token refresh', async () => {
|
|
176
|
+
fetchMock.mockResolvedValueOnce({
|
|
177
|
+
status: 401,
|
|
178
|
+
text: () => Promise.resolve('{"message": "Token Expired Error"}')
|
|
179
|
+
});
|
|
180
|
+
fetchMock.mockResolvedValueOnce({
|
|
181
|
+
status: 200,
|
|
182
|
+
text: () => Promise.resolve('{"access_token": "new_access", "refresh_token": "new_refresh"}')
|
|
183
|
+
});
|
|
184
|
+
fetchMock.mockResolvedValueOnce({
|
|
185
|
+
status: 200,
|
|
186
|
+
text: () => Promise.resolve('{"data": "success"}')
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
fetchInstance.setOptions({ refreshUrl: '/refresh' });
|
|
190
|
+
fetchInstance.setTokens({ access: 'old_access', refresh: 'old_refresh' });
|
|
191
|
+
|
|
192
|
+
const result = await fetchInstance.http('GET', 'http://127.0.0.1/api/test');
|
|
193
|
+
assert.strictEqual(result.status, 200);
|
|
194
|
+
assert.deepStrictEqual(result.data, { data: 'success' });
|
|
195
|
+
assert.strictEqual(fetchInstance.tokens.access, 'new_access');
|
|
196
|
+
assert.strictEqual(fetchMock.calls.length, 3);
|
|
197
|
+
assert.strictEqual(fetchMock.calls[1][0], 'http://127.0.0.1/refresh');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should throw when token refresh fails', { only: false }, async () => {
|
|
201
|
+
fetchMock.mockResolvedValueOnce({
|
|
202
|
+
status: 401, text: () => Promise.resolve('{"message": "Token Expired Error"}')
|
|
203
|
+
});
|
|
204
|
+
fetchMock.mockResolvedValueOnce({ status: 404, text: () => Promise.resolve('{"message": "error"}') });
|
|
205
|
+
fetchInstance.setOptions({ refreshUrl: '/refresh' });
|
|
206
|
+
fetchInstance.setTokens({ access: 'old_access', refresh: 'old_refresh' });
|
|
207
|
+
|
|
208
|
+
await assert.rejects(async () => {
|
|
209
|
+
await fetchInstance.http('GET', 'http://localhost/api/test');
|
|
210
|
+
}, (err) => {
|
|
211
|
+
assert.strictEqual(err.status, 404);
|
|
212
|
+
assert.deepStrictEqual(err.data, { message: 'error' });
|
|
213
|
+
return true;
|
|
214
|
+
});
|
|
215
|
+
assert.strictEqual(fetchInstance.tokens.access, 'old_access');
|
|
216
|
+
assert.strictEqual(fetchMock.calls.length, 2);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should throw on error status', async () => {
|
|
220
|
+
fetchMock.mockResolvedValueOnce({ status: 404, text: () => Promise.resolve('') });
|
|
221
|
+
await assert.rejects(async () => { await fetchInstance.http('GET', 'http://localhost/api/test'); });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should call forceLogoutFn on 401/403 without token error', async () => {
|
|
225
|
+
let logoutCalled = false;
|
|
226
|
+
fetchInstance.setOptions({ forceLogoutFn: () => { logoutCalled = true; } });
|
|
227
|
+
fetchMock.mockResolvedValueOnce({
|
|
228
|
+
status: 401,
|
|
229
|
+
text: () => Promise.resolve('{"message": "Unauthorized"}')
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await assert.rejects(async () => { await fetchInstance.http('GET', 'http://localhost/api/test'); });
|
|
233
|
+
assert.strictEqual(logoutCalled, true);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('convenience methods', () => {
|
|
238
|
+
beforeEach(() => {
|
|
239
|
+
fetchMock.mockResolvedValue({
|
|
240
|
+
status: 200,
|
|
241
|
+
text: () => Promise.resolve('')
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should call http with correct method for get', async () => {
|
|
246
|
+
await fetchInstance.get('/api/test');
|
|
247
|
+
assert.strictEqual(fetchMock.calls[0][1].method, 'GET');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should call http with correct method for post', async () => {
|
|
251
|
+
await fetchInstance.post('/api/test', { data: 'test' });
|
|
252
|
+
assert.strictEqual(fetchMock.calls[0][1].method, 'POST');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should call http with correct method for put', async () => {
|
|
256
|
+
await fetchInstance.put('/api/test', { data: 'test' });
|
|
257
|
+
assert.strictEqual(fetchMock.calls[0][1].method, 'PUT');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should call http with correct method for patch', async () => {
|
|
261
|
+
await fetchInstance.patch('/api/test', { data: 'test' });
|
|
262
|
+
assert.strictEqual(fetchMock.calls[0][1].method, 'PATCH');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should call http with correct method for del', async () => {
|
|
266
|
+
await fetchInstance.del('/api/test');
|
|
267
|
+
assert.strictEqual(fetchMock.calls[0][1].method, 'DELETE');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
package/iso/csv-utils.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// RFC 4180 CSV parser and generator
|
|
3
|
+
// https://stackoverflow.com/a/41563966
|
|
4
|
+
// https://www.convertcsv.com/json-to-csv.htm
|
|
5
|
+
// - instead of using npm libraries: csv-parse and @json2csv/plainjs
|
|
6
|
+
// - use process.stdout.write(...) if piping output so no extra carraige return is added
|
|
7
|
+
// - double quote only required if field contains newline characters
|
|
8
|
+
// - check if valid json key syntax
|
|
9
|
+
// - PROBLEMS
|
|
10
|
+
// 1. what if columns are not same (use less or use more) ?
|
|
11
|
+
// 2. what if row is missing
|
|
12
|
+
|
|
13
|
+
const DELIM_ROW = "\n" // end of line \r\n for Windows \n for Linux
|
|
14
|
+
const DELIM_COL = ','
|
|
15
|
+
const ESCAPE_CHAR = '""' // this should remain as "" for RFC4180 compliance
|
|
16
|
+
const QUOTE_CHAR = '"'
|
|
17
|
+
const CHAR_CR = '\r'
|
|
18
|
+
const CHAR_LF = '\n'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* RFC 4180-compliant CSV parser (handles quoted fields with embedded commas/newlines)
|
|
22
|
+
* - 1. escaped correctly
|
|
23
|
+
* - 2. same number of columns in each row (TBD?)
|
|
24
|
+
* - 3. trims white space around fields (TBD?)
|
|
25
|
+
* @param {string} str - CSV string to parse
|
|
26
|
+
* @returns {string[][]} - array of rows, each row is an array of fields
|
|
27
|
+
* @throws {Error} - if CSV is invalid (e.g. unclosed quotes)
|
|
28
|
+
*/
|
|
29
|
+
const parseCSV = (str, delimCol=DELIM_COL) => {
|
|
30
|
+
const rows = [];
|
|
31
|
+
let row = [];
|
|
32
|
+
let field = '';
|
|
33
|
+
let inQuotes = false;
|
|
34
|
+
let i = 0;
|
|
35
|
+
|
|
36
|
+
while (i < str.length) {
|
|
37
|
+
const ch = str[i];
|
|
38
|
+
const next = str[i + 1];
|
|
39
|
+
|
|
40
|
+
if (inQuotes) {
|
|
41
|
+
if (ch === QUOTE_CHAR && next === QUOTE_CHAR) {
|
|
42
|
+
field += QUOTE_CHAR; // "" → single "
|
|
43
|
+
i += 2;
|
|
44
|
+
} else if (ch === QUOTE_CHAR) {
|
|
45
|
+
inQuotes = false;
|
|
46
|
+
i++;
|
|
47
|
+
} else {
|
|
48
|
+
field += ch;
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
if (ch === QUOTE_CHAR) {
|
|
53
|
+
inQuotes = true;
|
|
54
|
+
i++;
|
|
55
|
+
} else if (ch === delimCol) {
|
|
56
|
+
row.push(field);
|
|
57
|
+
field = '';
|
|
58
|
+
i++;
|
|
59
|
+
} else if (ch === CHAR_LF || ch === CHAR_CR) {
|
|
60
|
+
row.push(field);
|
|
61
|
+
rows.push(row);
|
|
62
|
+
row = [];
|
|
63
|
+
field = '';
|
|
64
|
+
if (ch === CHAR_CR && next === CHAR_LF) i++; // handle \r\n
|
|
65
|
+
i++;
|
|
66
|
+
} else {
|
|
67
|
+
field += ch;
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Push last field/row
|
|
74
|
+
if (field || row.length) {
|
|
75
|
+
row.push(field);
|
|
76
|
+
rows.push(row);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (inQuotes) throw new Error('Unclosed quoted field — invalid CSV');
|
|
80
|
+
return rows;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse a string as CSV and also validate JSON if a field is a JSON field
|
|
85
|
+
* @param {string[][]} csvString - CSV parsed as array of rows, each row is an array of fields
|
|
86
|
+
* @returns {object} - { valid: boolean, reason?: string, rows?: string[][] }
|
|
87
|
+
* @throws {Error} - if CSV is invalid (e.g. unclosed quotes)
|
|
88
|
+
*/
|
|
89
|
+
const parseAndValidateCsv = (csvString) => {
|
|
90
|
+
try {
|
|
91
|
+
const rows = parseCSV(csvString);
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
for (const field of row) {
|
|
94
|
+
// Catches objects {}, arrays [], strings "...", numbers, booleans, null
|
|
95
|
+
const looksLikeJSON = /^[[{"'\-\d]|^(true|false|null)$/.test(field.trim());
|
|
96
|
+
if (looksLikeJSON) {
|
|
97
|
+
try {
|
|
98
|
+
JSON.parse(field);
|
|
99
|
+
} catch {
|
|
100
|
+
return { valid: false, reason: `Corrupted JSON in field: ${field.slice(0, 50)}` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { valid: true, rows };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return { valid: false, reason: err.message };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* convert CSV to array of JSON object
|
|
113
|
+
* @param {object} input - conversion inputs
|
|
114
|
+
* @param {string} input._text - CSV text to be converted to array
|
|
115
|
+
* @param {string} input.delimCol - CSV column delimiter, default is comma (,)
|
|
116
|
+
* @param {boolean} input.ignoreColumnMismatch - whether to ignore column count mismatches
|
|
117
|
+
* @returns
|
|
118
|
+
*/
|
|
119
|
+
const csvToJson = ({ _text, delimCol = DELIM_COL, ignoreColumnMismatch = false}) => {
|
|
120
|
+
const arr = parseCSV(_text, delimCol);
|
|
121
|
+
const headers = arr.shift() // 1st row is the headers
|
|
122
|
+
return arr.map((row) => {
|
|
123
|
+
const rv = {}
|
|
124
|
+
if (headers.length != row.length && !ignoreColumnMismatch) throw new Error(`Mismatch headers(${headers.length}) != columns (${row.length})`)
|
|
125
|
+
headers.forEach((_, index) => {
|
|
126
|
+
rv[headers[index]] = row[index]
|
|
127
|
+
})
|
|
128
|
+
return rv
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Converts an array of fields to a CSV row, escaping values as needed.
|
|
134
|
+
* escape for Excel, Google Sheets, and RFC 4180-compliant parsers
|
|
135
|
+
*
|
|
136
|
+
* @param {*[]} fields - array of field values to convert to a CSV row
|
|
137
|
+
* @param {string} delimCol - CSV column delimiter, default is comma (,)
|
|
138
|
+
* @returns {string} - CSV datarow string
|
|
139
|
+
*/
|
|
140
|
+
const arrayToCSVRow = (fields, delimCol = DELIM_COL) =>{
|
|
141
|
+
return fields.map(field => {
|
|
142
|
+
if (field === null || field === undefined) return '';
|
|
143
|
+
if (typeof field === 'object') {
|
|
144
|
+
const jsonStr = JSON.stringify(field).replace(/"/g, ESCAPE_CHAR);
|
|
145
|
+
return `"${jsonStr}"`;
|
|
146
|
+
}
|
|
147
|
+
// Wrap any plain string containing commas/quotes/newlines too
|
|
148
|
+
if (typeof field === 'string' && /[",\n]/.test(field)) {
|
|
149
|
+
return `"${field.replace(/"/g, ESCAPE_CHAR)}"`;
|
|
150
|
+
}
|
|
151
|
+
return field;
|
|
152
|
+
}).join(delimCol);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Converts JSON object values to a CSV data row, escaping values as needed.
|
|
157
|
+
* escape for Excel, Google Sheets, and RFC 4180-compliant parsers
|
|
158
|
+
*
|
|
159
|
+
* @param {Object} jsonObj - JSON object to convert to a CSV row
|
|
160
|
+
* @param {string} delimCol - CSV column delimiter, default is comma (,)
|
|
161
|
+
* @returns {string} - CSV data row string
|
|
162
|
+
*/
|
|
163
|
+
const jsonToCSVRow = (jsonObj, delimCol = DELIM_COL) => arrayToCSVRow(Object.values(jsonObj), delimCol);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Converts JSON object keys to a CSV header row, escaping values as needed.
|
|
167
|
+
* escape for Excel, Google Sheets, and RFC 4180-compliant parsers
|
|
168
|
+
*
|
|
169
|
+
* @param {Object} jsonObj - JSON object to convert to a CSV row
|
|
170
|
+
* @param {string} delimCol - CSV column delimiter, default is comma (,)
|
|
171
|
+
* @returns {string} - CSV header row string
|
|
172
|
+
*/
|
|
173
|
+
const jsonToCSVHeader = (jsonObj, delimCol = DELIM_COL) => arrayToCSVRow(Object.keys(jsonObj), delimCol);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* convert array of JSON objects to CSV
|
|
177
|
+
* @param {Object[]} _json - array of JS objects to be converted to CSV
|
|
178
|
+
* @param {string} delimRow - CSV row delimiter, default is newline (\n)
|
|
179
|
+
* @param {string} delimCol - CSV column delimiter, default is comma (,)
|
|
180
|
+
* @param {boolean} ignoreColumnMismatch - whether to ignore column count mismatches
|
|
181
|
+
* @returns
|
|
182
|
+
*/
|
|
183
|
+
const jsonToCsv = (_json, delimCol=DELIM_COL, delimRow=DELIM_ROW, ignoreColumnMismatch=false) => {
|
|
184
|
+
let csv = ''
|
|
185
|
+
let headers = []
|
|
186
|
+
if (Array.isArray(_json)) _json.forEach((row, index) => {
|
|
187
|
+
if (index === 0) { // create 1st row as header
|
|
188
|
+
headers = Object.keys(row)
|
|
189
|
+
csv += (jsonToCSVHeader(row, delimCol) + delimRow)
|
|
190
|
+
}
|
|
191
|
+
const data = Object.values(row)
|
|
192
|
+
if (headers.length !== data.length && !ignoreColumnMismatch) throw new Error(`Mismatch headers(${headers.length}) != columns (${data.length})`)
|
|
193
|
+
else csv += (arrayToCSVRow(data, delimCol) + delimRow)
|
|
194
|
+
})
|
|
195
|
+
return csv
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export {
|
|
199
|
+
arrayToCSVRow,
|
|
200
|
+
jsonToCSVHeader,
|
|
201
|
+
jsonToCSVRow,
|
|
202
|
+
jsonToCsv,
|
|
203
|
+
parseAndValidateCsv,
|
|
204
|
+
parseCSV, // to array of arrays, non validating
|
|
205
|
+
csvToJson,
|
|
206
|
+
}
|
package/iso/datetime.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get array of strings with datetime elements after offseting input date by X days
|
|
3
|
+
* @param {string} isoString - ISO datetime string
|
|
4
|
+
* @param {Number} days - ISO datetime string
|
|
5
|
+
* @returns {string[]}} - return array or YYYY, MM, DD strings - not so useful...
|
|
6
|
+
*/
|
|
7
|
+
export const dateStrAddDay = (dateStr, days = 0) => {
|
|
8
|
+
const d = new Date(Date.parse(dateStr) - new Date().getTimezoneOffset());
|
|
9
|
+
d.setDate(d.getDate() + days); // add the days
|
|
10
|
+
return [d.getFullYear().toString(), (d.getMonth() + 1).toString().padStart(2, 0), d.getDate().toString().padStart(2, 0)];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get new ISO datetime string date after offseting input date by X days
|
|
15
|
+
* @param {string} isoString - ISO datetime string
|
|
16
|
+
* @param {Number} days - ISO datetime string
|
|
17
|
+
* @returns {string} - return ISO datetime string
|
|
18
|
+
*/
|
|
19
|
+
export const dateStrAddDayISO = (isoString, days = 0) => {
|
|
20
|
+
const d = new Date(isoString);
|
|
21
|
+
d.setDate(d.getDate() + days);
|
|
22
|
+
return d.toISOString();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get local,date time and TZ in ISO format
|
|
27
|
+
* @param {Date|string} dt - Date object or ISO datetime string
|
|
28
|
+
* @param {string} tz - IANA time zone name, e.g. 'Asia/Singapore'
|
|
29
|
+
* @returns {string} - return string format: 2023-10-24 11:40:15 GMT+8
|
|
30
|
+
*/
|
|
31
|
+
export const getLocaleDateTimeTzISO = (dt, tz) => {
|
|
32
|
+
const opts = {
|
|
33
|
+
timeZoneName: 'short',
|
|
34
|
+
hour12: false,
|
|
35
|
+
year: 'numeric',
|
|
36
|
+
month: '2-digit',
|
|
37
|
+
day: '2-digit',
|
|
38
|
+
hour: '2-digit',
|
|
39
|
+
minute: '2-digit',
|
|
40
|
+
second: '2-digit',
|
|
41
|
+
}
|
|
42
|
+
if (tz) opts.timeZone = tz;
|
|
43
|
+
if (!(dt instanceof Date)) dt = new Date(dt);
|
|
44
|
+
return dt.toLocaleString('sv', opts);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get local date in ISO format
|
|
49
|
+
* @param {string} isoString - ISO datetime string
|
|
50
|
+
* @param {string} tz - IANA time zone name, e.g. 'Asia/Singapore'
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export const getLocaleDateISO = (isoString, tz) => getLocaleDateTimeTzISO(isoString, tz).substring(0, 10);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get local date in ISO format
|
|
57
|
+
* @param {string} isoString - ISO datetime string
|
|
58
|
+
* @param {string} tz - IANA time zone name, e.g. 'Asia/Singapore'
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export const getLocaleTimeISO = (isoString, tz) => getLocaleDateTimeTzISO(isoString, tz).substring(11, 19);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get timezone offset in ISO format (+hh:mm or -hh:mm)
|
|
65
|
+
* @param {Date|undefined} date - if undefined, will create a date object and use that timezone
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export const getTzOffsetISO = (date) => {
|
|
69
|
+
const pad = n => `${Math.floor(Math.abs(n))}`.padStart(2, '0'); // Pad a number to 2 digits
|
|
70
|
+
if (!date) date = new Date();
|
|
71
|
+
const tzOffset = -date.getTimezoneOffset();
|
|
72
|
+
return (tzOffset >= 0 ? '+' : '-') + pad(tzOffset / 60) + ':' + pad(tzOffset % 60);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get return current UTC timestamp YmdHms
|
|
77
|
+
* @param {Date|undefined} date - if undefined, will create a date object and use that timezone
|
|
78
|
+
* @returns {string} YYYYMMDD_HHmmssZ
|
|
79
|
+
*/
|
|
80
|
+
export const getYmdhmsUtc = (date) => {
|
|
81
|
+
if (!date) date = new Date();
|
|
82
|
+
const d = date.toISOString();
|
|
83
|
+
return d.substring(0,4) + d.substring(5,7) + d.substring(8,10) + '_' + d.substring(11,13) + d.substring(14,16) + d.substring(17,19) + 'Z';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get day of week index (0-6) for a given date and timezone
|
|
88
|
+
* @param {Date|string} date - Date object or ISO datetime string
|
|
89
|
+
* @param {string} tz - IANA time zone name, e.g. 'Asia/Singapore'
|
|
90
|
+
* @returns {Number}
|
|
91
|
+
*/
|
|
92
|
+
export const getDayOfWeek = (date, tz) => {
|
|
93
|
+
if (!(date instanceof Date)) date = new Date(date)
|
|
94
|
+
const opts = { weekday: 'short' }
|
|
95
|
+
if (tz) opts.timeZone = tz
|
|
96
|
+
const shortDayName = new Intl.DateTimeFormat('en-US', opts).format(date) // Get short day name string for specified timezone
|
|
97
|
+
const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] // Map the short name back to a numeric index
|
|
98
|
+
return daysOfWeek.indexOf(shortDayName);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Obsolete use getLocaleDateISO and getLocaleTimeISO instead
|
|
102
|
+
// export const dateISO = (date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().substring(0, 10);
|
|
103
|
+
// export const timeISO = (date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().substring(11, 19);
|