@accede/node-red-contrib-odbcwritenow 1.0.3 → 1.0.5
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 +67 -32
- package/odbcwritenow.html +11 -2
- package/odbcwritenow.js +92 -44
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,52 +1,87 @@
|
|
|
1
1
|
# node-red-odbcwritenow
|
|
2
2
|
|
|
3
|
-
Node-RED node for
|
|
3
|
+
Node-RED node for downloading MYOB data from ODBCWriteNow / MYOBSync.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
This package provides one node type: `odbcwritenow-get`.
|
|
6
|
+
|
|
7
|
+
## What It Does
|
|
8
|
+
|
|
9
|
+
The node builds and calls:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
https://myobsync.accede.com.au/download/{what}/json/{page}?apikey={apikey}[&filters=...][&datefrom=...][&dateto=...][&orderby=...]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
It then:
|
|
16
|
+
|
|
17
|
+
- sends parsed JSON rows to output 1
|
|
18
|
+
- sends "no data" completion messages to output 2
|
|
9
19
|
|
|
10
20
|
## Prerequisites
|
|
11
|
-
|
|
12
|
-
-
|
|
21
|
+
|
|
22
|
+
- Node-RED `>= 4.0.0`
|
|
23
|
+
- Valid ODBCWriteNow / MYOBSync API key
|
|
13
24
|
|
|
14
25
|
## Install
|
|
15
26
|
|
|
27
|
+
Install in your Node-RED user directory:
|
|
28
|
+
|
|
16
29
|
```bash
|
|
17
|
-
# install into your Node-RED user dir
|
|
18
30
|
cd ~/.node-red
|
|
19
|
-
npm install
|
|
31
|
+
npm install @accede/node-red-contrib-odbcwritenow
|
|
20
32
|
```
|
|
21
33
|
|
|
22
|
-
Restart Node-RED.
|
|
34
|
+
Restart Node-RED after installation.
|
|
23
35
|
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
sudo systemctl restart nodered
|
|
27
|
-
```
|
|
36
|
+
## Node Configuration
|
|
28
37
|
|
|
29
|
-
|
|
38
|
+
Editor fields:
|
|
30
39
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
- `Name` (optional)
|
|
41
|
+
- `What` (required): dataset/resource name, for example `sales_invoice_item`
|
|
42
|
+
- `OrderBy` (optional): default sort expression
|
|
43
|
+
- `MaxRetries` (optional, default `3`): number of retries for timeout/token errors
|
|
44
|
+
- `RetryBackoffMs` (optional, default `500`): base delay in milliseconds; each retry uses exponential backoff (`base * 2^attempt`)
|
|
45
|
+
- `APIKey` (required unless provided in `msg.apikey`)
|
|
37
46
|
|
|
38
|
-
|
|
47
|
+
## Runtime Inputs (`msg`)
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
You can override behavior per message:
|
|
50
|
+
|
|
51
|
+
- `msg.page` (default `0`)
|
|
52
|
+
- `msg.apikey` (overrides configured API key)
|
|
53
|
+
- `msg.orderby` (overrides configured order by)
|
|
54
|
+
- `msg.filters`
|
|
55
|
+
- `msg.datefrom`
|
|
56
|
+
- `msg.dateto`
|
|
57
|
+
|
|
58
|
+
## Outputs
|
|
59
|
+
|
|
60
|
+
`odbcwritenow-get` has 2 outputs:
|
|
61
|
+
|
|
62
|
+
1. Data output
|
|
63
|
+
- `msg.payload`: parsed JSON array returned by the API
|
|
64
|
+
- `msg.rows`: number of rows in `payload`
|
|
65
|
+
- `msg.page`, `msg.what`, `msg.retry`
|
|
66
|
+
|
|
67
|
+
2. No-data output
|
|
68
|
+
- emitted when response contains `"no data found"`
|
|
69
|
+
- `msg.payload = []`
|
|
70
|
+
- `msg.nodata = true`
|
|
71
|
+
- `msg.complete = true`
|
|
72
|
+
|
|
73
|
+
## Status Behavior
|
|
74
|
+
|
|
75
|
+
- Blue ring: currently fetching
|
|
76
|
+
- Green dot: rows returned
|
|
77
|
+
- Green ring: no data found
|
|
78
|
+
- Red ring: timeout/token error retries or request/parsing error
|
|
79
|
+
|
|
80
|
+
## Notes
|
|
81
|
+
|
|
82
|
+
- Base URL is currently fixed to `https://myobsync.accede.com.au`.
|
|
83
|
+
- Timeout and token error responses are retried using bounded exponential backoff.
|
|
45
84
|
|
|
46
85
|
## License
|
|
47
|
-
ISC © Accede Holdings PTY LTD. See `LICENSE`.
|
|
48
86
|
|
|
49
|
-
|
|
50
|
-
- Repo: [DarkAxi0m/node-red-odbcwritenow](https://github.com/DarkAxi0m/node-red-odbcwritenow)
|
|
51
|
-
- [ODBC WriteNow – Overview & pricing](https://odbcwritenow.com/)
|
|
52
|
-
- [ODBC WriteNow – Developer docs](https://odbcwritenow.com/developers/)
|
|
87
|
+
ISC. See [LICENSE](LICENSE).
|
package/odbcwritenow.html
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
name: {value:""},
|
|
7
7
|
apikey: {value:""},
|
|
8
8
|
what: {value:""},
|
|
9
|
-
orderby: {value:""}
|
|
9
|
+
orderby: {value:""},
|
|
10
|
+
maxRetries: {value:3},
|
|
11
|
+
retryBackoffMs: {value:500}
|
|
10
12
|
},
|
|
11
13
|
inputs: 1,
|
|
12
14
|
outputs: 2,
|
|
@@ -30,6 +32,14 @@
|
|
|
30
32
|
<label for="node-input-orderby"><i class="fa fa-lock"></i> OrderBy</label>
|
|
31
33
|
<input type="text" id="node-input-orderby" placeholder="OrderBy">
|
|
32
34
|
</div>
|
|
35
|
+
<div class="form-row">
|
|
36
|
+
<label for="node-input-maxRetries"><i class="fa fa-repeat"></i> MaxRetries</label>
|
|
37
|
+
<input type="number" id="node-input-maxRetries" min="0" placeholder="3">
|
|
38
|
+
</div>
|
|
39
|
+
<div class="form-row">
|
|
40
|
+
<label for="node-input-retryBackoffMs"><i class="fa fa-clock-o"></i> RetryBackoffMs</label>
|
|
41
|
+
<input type="number" id="node-input-retryBackoffMs" min="0" placeholder="500">
|
|
42
|
+
</div>
|
|
33
43
|
<div class="form-row">
|
|
34
44
|
<label for="node-input-apikey"><i class="fa fa-lock"></i> APIKey</label>
|
|
35
45
|
<input type="text" id="node-input-apikey" placeholder="APIKey">
|
|
@@ -39,4 +49,3 @@
|
|
|
39
49
|
<script type="text/html" data-help-name="odbcwritenow-get">
|
|
40
50
|
<p>odbcwritenow downloader</p>
|
|
41
51
|
</script>
|
|
42
|
-
|
package/odbcwritenow.js
CHANGED
|
@@ -1,45 +1,67 @@
|
|
|
1
|
-
|
|
1
|
+
function sleep(ms) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
async function DoImport(msg, url, node, maxRetries, baseBackoffMs) {
|
|
2
6
|
console.log('>', url)
|
|
3
7
|
msg.nodata = false
|
|
4
8
|
delete msg.complete
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
try {
|
|
8
|
-
const response = await fetch(url);
|
|
9
|
-
|
|
10
|
-
datastr = await response.text();
|
|
11
|
-
if (datastr.toLowerCase().includes("no data found")) {
|
|
12
|
-
msg.nodata = true
|
|
13
|
-
msg.complete = true
|
|
14
|
-
msg.payload = []
|
|
15
|
-
node.status({ fill: "green", shape: "ring", text: `#${msg.page}: No data found` })
|
|
16
|
-
return node.send([null, msg]);
|
|
17
|
-
}
|
|
9
|
+
let datastr = ""
|
|
10
|
+
let attempt = 0
|
|
18
11
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
12
|
+
while (attempt <= maxRetries) {
|
|
13
|
+
msg.retry = attempt
|
|
14
|
+
node.status({ fill: "blue", shape: "ring", text: `Fetching #${msg.page}: ${msg.what} (try ${attempt + 1}/${maxRetries + 1})` })
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(url);
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`)
|
|
20
|
+
}
|
|
21
|
+
datastr = await response.text();
|
|
22
|
+
|
|
23
|
+
if (datastr.toLowerCase().includes("no data found")) {
|
|
24
|
+
console.log("no data found");
|
|
25
|
+
msg.nodata = true
|
|
26
|
+
msg.complete = true
|
|
27
|
+
msg.payload = []
|
|
28
|
+
node.status({ fill: "green", shape: "ring", text: `#${msg.page}: No data found` })
|
|
29
|
+
return node.send([null, msg]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Retry only for gateway timeout and token error responses.
|
|
33
|
+
if (datastr.toLowerCase().includes("timeout") || datastr.toLowerCase().includes("token error")) {
|
|
34
|
+
const isLastTry = attempt >= maxRetries
|
|
35
|
+
const kind = datastr.toLowerCase().includes("timeout") ? "MYOB Gateway Timeout" : "MYOB Token Error"
|
|
36
|
+
|
|
37
|
+
if (isLastTry) {
|
|
38
|
+
node.status({ fill: "red", shape: "ring", text: `#${msg.page}: ${kind} (max retries reached)` })
|
|
39
|
+
return node.error(`${kind} after ${maxRetries + 1} attempts`, msg)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const delayMs = Math.max(0, baseBackoffMs) * Math.pow(2, attempt)
|
|
43
|
+
node.status({ fill: "red", shape: "ring", text: `#${msg.page}: ${kind}, retrying in ${delayMs}ms` })
|
|
44
|
+
console.error("*******", kind, attempt, msg.page, `retry in ${delayMs}ms`)
|
|
45
|
+
attempt += 1
|
|
46
|
+
await sleep(delayMs)
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
//---------------------------------
|
|
51
|
+
// Everything looks good.
|
|
52
|
+
const data = JSON.parse(datastr);
|
|
53
|
+
msg.rows = data.length
|
|
54
|
+
console.log("Page:", msg.page, "Rows:", data.length)
|
|
55
|
+
|
|
56
|
+
node.status({ fill: "green", shape: "dot", text: `#${msg.page}: ${msg.rows} Rows` })
|
|
57
|
+
msg.payload = data;
|
|
58
|
+
return node.send([msg, null]);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
node.status({ fill: "red", shape: "ring", text: error.message })
|
|
61
|
+
console.error(error.message)
|
|
62
|
+
console.log(datastr)
|
|
63
|
+
return
|
|
31
64
|
}
|
|
32
|
-
//---------------------------------
|
|
33
|
-
//Everything looks good
|
|
34
|
-
const data = JSON.parse(datastr);
|
|
35
|
-
msg.rows = data.length
|
|
36
|
-
node.status({ fill: "green", shape: "dot", text: `#${msg.page}: ${msg.rows} Rows` })
|
|
37
|
-
msg.payload = data;
|
|
38
|
-
node.send([msg, null]);
|
|
39
|
-
} catch (error) {
|
|
40
|
-
node.status({ fill: "red", shape: "ring", text: error.message })
|
|
41
|
-
console.error(error.message)
|
|
42
|
-
console.log(datastr)
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
}
|
|
@@ -51,16 +73,43 @@ module.exports = function(RED) {
|
|
|
51
73
|
var node = this;
|
|
52
74
|
node.status({ text: `` })
|
|
53
75
|
node.on('input', async function(msg) {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
76
|
+
const pageRaw = msg.page === undefined || msg.page === null || msg.page === "" ? 0 : msg.page
|
|
77
|
+
const page = parseInt(pageRaw, 10)
|
|
78
|
+
const apikeyRaw = msg.apikey || config.apikey
|
|
79
|
+
const whatRaw = config.what
|
|
57
80
|
const orderby = encodeURIComponent(msg.orderby || config.orderby);
|
|
58
81
|
|
|
82
|
+
if (!whatRaw || String(whatRaw).trim().length === 0) {
|
|
83
|
+
node.status({ fill: "red", shape: "ring", text: "Invalid config: 'what' is required" })
|
|
84
|
+
node.error("Invalid config: 'what' is required", msg)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!apikeyRaw || String(apikeyRaw).trim().length === 0) {
|
|
89
|
+
node.status({ fill: "red", shape: "ring", text: "Missing API key" })
|
|
90
|
+
node.error("Missing API key: set config.apikey or msg.apikey", msg)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (Number.isNaN(page) || page < 0) {
|
|
95
|
+
node.status({ fill: "red", shape: "ring", text: "Invalid page: must be >= 0" })
|
|
96
|
+
node.error(`Invalid page '${pageRaw}': must be a non-negative integer`, msg)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const apikey = encodeURIComponent(apikeyRaw);
|
|
101
|
+
const what = encodeURIComponent(whatRaw)
|
|
102
|
+
|
|
59
103
|
|
|
60
104
|
|
|
61
105
|
msg.page = page
|
|
62
|
-
msg.what =
|
|
63
|
-
|
|
106
|
+
msg.what = whatRaw
|
|
107
|
+
const maxRetries = Number.isInteger(parseInt(config.maxRetries, 10))
|
|
108
|
+
? Math.max(0, parseInt(config.maxRetries, 10))
|
|
109
|
+
: 3
|
|
110
|
+
const backoffMs = Number.isInteger(parseInt(config.retryBackoffMs, 10))
|
|
111
|
+
? Math.max(0, parseInt(config.retryBackoffMs, 10))
|
|
112
|
+
: 500
|
|
64
113
|
|
|
65
114
|
var orderbystr = "";
|
|
66
115
|
var filtersstr = "";
|
|
@@ -84,11 +133,10 @@ module.exports = function(RED) {
|
|
|
84
133
|
}
|
|
85
134
|
|
|
86
135
|
const url = `https://myobsync.accede.com.au/download/${what}/json/${page}?apikey=${apikey}${filtersstr}${orderbystr}`;
|
|
87
|
-
DoImport(msg, url, node)
|
|
136
|
+
DoImport(msg, url, node, maxRetries, backoffMs)
|
|
88
137
|
|
|
89
138
|
});
|
|
90
139
|
}
|
|
91
140
|
RED.nodes.registerType("odbcwritenow-get", ODBCWriteNowGet);
|
|
92
141
|
|
|
93
142
|
}
|
|
94
|
-
|
package/package.json
CHANGED