@skylord123/node-red-pebble-timeline 1.3.0 → 1.4.0-beta.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/.github/workflows/publish.yml +81 -0
- package/package.json +1 -3
- package/pebble-timeline-add.js +15 -97
- package/pebble-timeline-delete.js +28 -74
- package/pebble-timeline-list.js +7 -58
- package/pebble-timeline-store.js +93 -0
- package/.claude/settings.local.json +0 -11
- package/node-red-sync-flow.json +0 -124
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
# Publishes the package to npm whenever a GitHub Release is published.
|
|
4
|
+
# It publishes the exact commit the release tag points to, so a pre-release
|
|
5
|
+
# can be cut from any branch (e.g. a beta off `dev`) without that branch
|
|
6
|
+
# having to be merged into master first.
|
|
7
|
+
#
|
|
8
|
+
# The release tag is the source of truth for the version:
|
|
9
|
+
# - Stable tag (e.g. v1.2.3) -> published to the "latest"
|
|
10
|
+
# dist-tag; the version bump is
|
|
11
|
+
# committed back to master.
|
|
12
|
+
# - Pre-release tag (e.g. v1.2.3-beta.1) -> published to a matching dist-tag
|
|
13
|
+
# ("beta", "rc", ...); does NOT
|
|
14
|
+
# become "latest" and is NOT
|
|
15
|
+
# committed back to master.
|
|
16
|
+
#
|
|
17
|
+
# Authentication uses npm Trusted Publishing (OIDC) - no token or secret is
|
|
18
|
+
# needed. Configure a trusted publisher for this package on npmjs.com:
|
|
19
|
+
# Repository: skylord123/node-red-pebble-timeline
|
|
20
|
+
# Workflow: publish.yml
|
|
21
|
+
|
|
22
|
+
on:
|
|
23
|
+
release:
|
|
24
|
+
types: [published]
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
publish:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
permissions:
|
|
30
|
+
contents: write # commit the version bump back to master
|
|
31
|
+
id-token: write # npm Trusted Publishing (OIDC) + provenance
|
|
32
|
+
steps:
|
|
33
|
+
- name: Check out the released commit
|
|
34
|
+
uses: actions/checkout@v4
|
|
35
|
+
|
|
36
|
+
- name: Set up Node.js
|
|
37
|
+
uses: actions/setup-node@v4
|
|
38
|
+
with:
|
|
39
|
+
node-version: 22
|
|
40
|
+
registry-url: https://registry.npmjs.org
|
|
41
|
+
|
|
42
|
+
- name: Update npm
|
|
43
|
+
# Trusted Publishing requires npm 11.5.1 or newer; Node 22 ships npm 10.
|
|
44
|
+
run: npm install -g npm@latest
|
|
45
|
+
|
|
46
|
+
- name: Determine version and dist-tag
|
|
47
|
+
id: ver
|
|
48
|
+
run: |
|
|
49
|
+
VERSION="${GITHUB_REF_NAME#v}"
|
|
50
|
+
if [[ "$VERSION" == *-* ]]; then
|
|
51
|
+
# pre-release, e.g. 1.0.0-beta.1 -> dist-tag "beta"
|
|
52
|
+
DIST_TAG="${VERSION#*-}"
|
|
53
|
+
DIST_TAG="${DIST_TAG%%.*}"
|
|
54
|
+
PRERELEASE=true
|
|
55
|
+
else
|
|
56
|
+
DIST_TAG=latest
|
|
57
|
+
PRERELEASE=false
|
|
58
|
+
fi
|
|
59
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
60
|
+
echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT"
|
|
61
|
+
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
|
62
|
+
echo "Publishing $VERSION to npm dist-tag '$DIST_TAG' (prerelease=$PRERELEASE)"
|
|
63
|
+
|
|
64
|
+
- name: Set version
|
|
65
|
+
run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version
|
|
66
|
+
|
|
67
|
+
- name: Publish to npm
|
|
68
|
+
run: npm publish --provenance --access public --tag "${{ steps.ver.outputs.dist_tag }}"
|
|
69
|
+
|
|
70
|
+
- name: Commit version bump back to master
|
|
71
|
+
if: steps.ver.outputs.prerelease == 'false'
|
|
72
|
+
run: |
|
|
73
|
+
if git diff --quiet; then
|
|
74
|
+
echo "package.json already at ${{ steps.ver.outputs.version }}; nothing to commit."
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
git config user.name "github-actions[bot]"
|
|
78
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
79
|
+
git commit -am "Set version to ${{ steps.ver.outputs.version }}"
|
|
80
|
+
git push origin HEAD:master \
|
|
81
|
+
|| echo "::warning::Could not push the version bump to master (branch protection?). The package was still published."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skylord123/node-red-pebble-timeline",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0-beta.1",
|
|
4
4
|
"description": "Node-RED nodes for interacting with the Pebble Timeline API",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,5 +27,3 @@
|
|
|
27
27
|
"fs-extra": "^11.1.0"
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
package/pebble-timeline-add.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
|
-
const fs = require('fs-extra');
|
|
3
|
-
const path = require('path');
|
|
4
2
|
const { pinValid } = require('./pebble-timeline-validation');
|
|
3
|
+
const store = require('./pebble-timeline-store');
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Node-RED node for adding pins to the Pebble Timeline API
|
|
@@ -29,20 +28,7 @@ module.exports = function(RED) {
|
|
|
29
28
|
return;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
const storageDir = path.join(RED.settings.userDir, 'pebble-timeline');
|
|
34
|
-
fs.ensureDirSync(storageDir);
|
|
35
|
-
const pinsFile = path.join(storageDir, 'timeline-pins.json');
|
|
36
|
-
|
|
37
|
-
// Load existing pins (organized by token)
|
|
38
|
-
let pinsData = {};
|
|
39
|
-
try {
|
|
40
|
-
if (fs.existsSync(pinsFile)) {
|
|
41
|
-
pinsData = JSON.parse(fs.readFileSync(pinsFile, 'utf8'));
|
|
42
|
-
}
|
|
43
|
-
} catch (error) {
|
|
44
|
-
node.warn(`Error loading pins file: ${error.message}`);
|
|
45
|
-
}
|
|
31
|
+
store.init(RED.settings.userDir);
|
|
46
32
|
|
|
47
33
|
node.on('input', async function(msg, send, done) {
|
|
48
34
|
// Backwards compatibility with Node-RED 0.x
|
|
@@ -291,8 +277,12 @@ module.exports = function(RED) {
|
|
|
291
277
|
}
|
|
292
278
|
|
|
293
279
|
// Pin is valid - store it locally
|
|
280
|
+
try {
|
|
281
|
+
await store.addPin(store.resolveKey(configNode, tokenOverride), pin);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
node.warn(`Error saving pin to local storage: ${e.message}`);
|
|
284
|
+
}
|
|
294
285
|
node.status({fill: "green", shape: "dot", text: "OK (local)"});
|
|
295
|
-
storePin(pin, timelineToken || 'local');
|
|
296
286
|
|
|
297
287
|
msg.payload = {
|
|
298
288
|
success: true,
|
|
@@ -327,13 +317,17 @@ module.exports = function(RED) {
|
|
|
327
317
|
'X-User-Token': timelineToken
|
|
328
318
|
}
|
|
329
319
|
})
|
|
330
|
-
.then(response => {
|
|
320
|
+
.then(async response => {
|
|
321
|
+
// Store the pin in our local storage
|
|
322
|
+
try {
|
|
323
|
+
await store.addPin(store.resolveKey(configNode, tokenOverride), pin);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
node.warn(`Error saving pin to local storage: ${e.message}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
331
328
|
// Set successful status - using "OK" as requested
|
|
332
329
|
node.status({fill: "green", shape: "dot", text: "OK"});
|
|
333
330
|
|
|
334
|
-
// Store the pin in our local storage
|
|
335
|
-
storePin(pin, timelineToken);
|
|
336
|
-
|
|
337
331
|
// Prepare the output message
|
|
338
332
|
msg.payload = {
|
|
339
333
|
success: true,
|
|
@@ -380,82 +374,6 @@ module.exports = function(RED) {
|
|
|
380
374
|
}
|
|
381
375
|
});
|
|
382
376
|
|
|
383
|
-
// Helper to store a pin in local storage
|
|
384
|
-
function storePin(pin, timelineToken) {
|
|
385
|
-
// Ensure we have a valid token
|
|
386
|
-
if (!timelineToken) {
|
|
387
|
-
node.warn("Cannot store pin: No valid timeline token provided");
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Convert token to string to ensure it can be used as an object key
|
|
392
|
-
timelineToken = String(timelineToken);
|
|
393
|
-
|
|
394
|
-
// Initialize the token's pins array if it doesn't exist
|
|
395
|
-
if (!pinsData[timelineToken]) {
|
|
396
|
-
pinsData[timelineToken] = [];
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// Remove any existing pin with the same ID for this token
|
|
400
|
-
pinsData[timelineToken] = pinsData[timelineToken].filter(p => p.id !== pin.id);
|
|
401
|
-
|
|
402
|
-
// Add the new pin with a timestamp for when it was added
|
|
403
|
-
pinsData[timelineToken].push({
|
|
404
|
-
...pin,
|
|
405
|
-
_stored: new Date().toISOString()
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
// Clean up old pins (older than 1 month) from all tokens
|
|
409
|
-
cleanupOldPins();
|
|
410
|
-
|
|
411
|
-
// Write the pins to the file
|
|
412
|
-
try {
|
|
413
|
-
fs.writeFileSync(pinsFile, JSON.stringify(pinsData, null, 2));
|
|
414
|
-
} catch (error) {
|
|
415
|
-
node.warn(`Error saving pins to file: ${error.message}`);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Helper to clean up pins older than 1 month
|
|
420
|
-
function cleanupOldPins() {
|
|
421
|
-
const oneMonthAgo = new Date();
|
|
422
|
-
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
|
423
|
-
let changed = false;
|
|
424
|
-
|
|
425
|
-
// Iterate through all tokens
|
|
426
|
-
Object.keys(pinsData).forEach(token => {
|
|
427
|
-
// Make sure the token's data is an array
|
|
428
|
-
if (!Array.isArray(pinsData[token])) {
|
|
429
|
-
pinsData[token] = [];
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Filter out pins older than 1 month
|
|
434
|
-
const initialCount = pinsData[token].length;
|
|
435
|
-
pinsData[token] = pinsData[token].filter(pin => {
|
|
436
|
-
// Make sure pin has _stored property
|
|
437
|
-
if (!pin || !pin._stored) return false;
|
|
438
|
-
|
|
439
|
-
try {
|
|
440
|
-
const storedDate = new Date(pin._stored);
|
|
441
|
-
return storedDate >= oneMonthAgo;
|
|
442
|
-
} catch (e) {
|
|
443
|
-
// If date parsing fails, remove the pin
|
|
444
|
-
return false;
|
|
445
|
-
}
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
// Log if pins were removed
|
|
449
|
-
if (pinsData[token].length < initialCount) {
|
|
450
|
-
node.debug(`Removed ${initialCount - pinsData[token].length} old pins for token ${token.substring(0, 8)}...`);
|
|
451
|
-
changed = true;
|
|
452
|
-
}
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
// No need to save here as the calling function will save the file
|
|
456
|
-
return changed;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
377
|
// Apply node configuration to the pin
|
|
460
378
|
async function applyNodeConfiguration(pin, config, msg, node) {
|
|
461
379
|
try {
|
|
@@ -1,41 +1,26 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
|
-
const
|
|
3
|
-
const path = require('path');
|
|
2
|
+
const store = require('./pebble-timeline-store');
|
|
4
3
|
|
|
5
4
|
module.exports = function(RED) {
|
|
6
5
|
function PebbleTimelineDeleteNode(config) {
|
|
7
6
|
RED.nodes.createNode(this, config);
|
|
8
7
|
const node = this;
|
|
9
8
|
|
|
10
|
-
// Get the config node
|
|
11
9
|
const configNode = RED.nodes.getNode(config.config);
|
|
12
10
|
if (!configNode) {
|
|
13
11
|
node.error("No Pebble Timeline configuration found");
|
|
14
12
|
return;
|
|
15
13
|
}
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
const storageDir = path.join(RED.settings.userDir, 'pebble-timeline');
|
|
19
|
-
fs.ensureDirSync(storageDir);
|
|
20
|
-
const pinsFile = path.join(storageDir, 'timeline-pins.json');
|
|
21
|
-
|
|
22
|
-
// Load existing pins (organized by token)
|
|
23
|
-
let pinsData = {};
|
|
24
|
-
try {
|
|
25
|
-
if (fs.existsSync(pinsFile)) {
|
|
26
|
-
pinsData = JSON.parse(fs.readFileSync(pinsFile, 'utf8'));
|
|
27
|
-
}
|
|
28
|
-
} catch (error) {
|
|
29
|
-
node.warn(`Error loading pins file: ${error.message}`);
|
|
30
|
-
}
|
|
15
|
+
store.init(RED.settings.userDir);
|
|
31
16
|
|
|
32
17
|
node.on('input', function(msg, send, done) {
|
|
33
|
-
// Backwards compatibility with Node-RED 0.x
|
|
34
18
|
send = send || function() { node.send.apply(node, arguments) };
|
|
35
19
|
|
|
36
|
-
// Get the pin ID to delete
|
|
37
20
|
let pinId;
|
|
38
|
-
|
|
21
|
+
let apiUrlOverride = null;
|
|
22
|
+
let tokenOverride = null;
|
|
23
|
+
|
|
39
24
|
Promise.all([
|
|
40
25
|
new Promise(resolve => {
|
|
41
26
|
RED.util.evaluateNodeProperty(config.pinId, config.pinIdType, node, msg, (err, result) => {
|
|
@@ -56,12 +41,11 @@ module.exports = function(RED) {
|
|
|
56
41
|
});
|
|
57
42
|
}),
|
|
58
43
|
|
|
59
|
-
// Check for server override options
|
|
60
44
|
new Promise(resolve => {
|
|
61
45
|
if (config.apiUrl) {
|
|
62
46
|
RED.util.evaluateNodeProperty(config.apiUrl, config.apiUrlType, node, msg, (err, result) => {
|
|
63
47
|
if (!err && result) {
|
|
64
|
-
|
|
48
|
+
apiUrlOverride = result;
|
|
65
49
|
}
|
|
66
50
|
resolve();
|
|
67
51
|
});
|
|
@@ -74,7 +58,7 @@ module.exports = function(RED) {
|
|
|
74
58
|
if (config.token) {
|
|
75
59
|
RED.util.evaluateNodeProperty(config.token, config.tokenType, node, msg, (err, result) => {
|
|
76
60
|
if (!err && result) {
|
|
77
|
-
|
|
61
|
+
tokenOverride = result;
|
|
78
62
|
}
|
|
79
63
|
resolve();
|
|
80
64
|
});
|
|
@@ -82,20 +66,21 @@ module.exports = function(RED) {
|
|
|
82
66
|
resolve();
|
|
83
67
|
}
|
|
84
68
|
})
|
|
85
|
-
]).then(() => {
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
69
|
+
]).then(async () => {
|
|
70
|
+
const baseApiUrl = apiUrlOverride || configNode.apiUrl;
|
|
71
|
+
const timelineToken = tokenOverride || configNode.credentials.timelineToken;
|
|
72
|
+
const storeKey = store.resolveKey(configNode, tokenOverride);
|
|
89
73
|
|
|
90
|
-
// Check if we're in local emulation mode (empty API URL)
|
|
91
74
|
const isLocalMode = !baseApiUrl || baseApiUrl.trim() === '';
|
|
92
75
|
|
|
93
76
|
if (isLocalMode) {
|
|
94
|
-
// Local emulation mode - delete from local storage only
|
|
95
77
|
node.debug(`Local emulation mode - deleting pin locally`);
|
|
96
78
|
|
|
97
|
-
|
|
98
|
-
|
|
79
|
+
try {
|
|
80
|
+
await store.removePin(storeKey, pinId);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
node.warn(`Error removing pin from local storage: ${e.message}`);
|
|
83
|
+
}
|
|
99
84
|
|
|
100
85
|
node.status({fill: "green", shape: "dot", text: "Pin deleted (local)"});
|
|
101
86
|
|
|
@@ -109,7 +94,6 @@ module.exports = function(RED) {
|
|
|
109
94
|
send(msg);
|
|
110
95
|
if (done) done();
|
|
111
96
|
} else {
|
|
112
|
-
// Remote API mode
|
|
113
97
|
const apiUrl = `${baseApiUrl}/v1/user/pins/${pinId}`;
|
|
114
98
|
|
|
115
99
|
if (!timelineToken) {
|
|
@@ -123,13 +107,15 @@ module.exports = function(RED) {
|
|
|
123
107
|
'X-User-Token': timelineToken
|
|
124
108
|
}
|
|
125
109
|
})
|
|
126
|
-
.then(response => {
|
|
110
|
+
.then(async response => {
|
|
127
111
|
node.status({fill: "green", shape: "dot", text: "Pin deleted"});
|
|
128
112
|
|
|
129
|
-
|
|
130
|
-
|
|
113
|
+
try {
|
|
114
|
+
await store.removePin(storeKey, pinId);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
node.warn(`Error removing pin from local storage: ${e.message}`);
|
|
117
|
+
}
|
|
131
118
|
|
|
132
|
-
// Prepare the output message
|
|
133
119
|
msg.payload = {
|
|
134
120
|
success: true,
|
|
135
121
|
pinId: pinId,
|
|
@@ -139,14 +125,16 @@ module.exports = function(RED) {
|
|
|
139
125
|
send(msg);
|
|
140
126
|
if (done) done();
|
|
141
127
|
})
|
|
142
|
-
.catch(error => {
|
|
143
|
-
// Handle 404 - pin already deleted
|
|
128
|
+
.catch(async error => {
|
|
144
129
|
if (error.response && error.response.status === 404) {
|
|
145
130
|
node.warn(`Pin ${pinId} not found on server (404) - assuming already deleted`);
|
|
146
131
|
node.status({fill: "yellow", shape: "dot", text: "Pin already deleted"});
|
|
147
132
|
|
|
148
|
-
|
|
149
|
-
|
|
133
|
+
try {
|
|
134
|
+
await store.removePin(storeKey, pinId);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
node.warn(`Error removing pin from local storage: ${e.message}`);
|
|
137
|
+
}
|
|
150
138
|
|
|
151
139
|
msg.payload = {
|
|
152
140
|
success: true,
|
|
@@ -158,7 +146,6 @@ module.exports = function(RED) {
|
|
|
158
146
|
send(msg);
|
|
159
147
|
if (done) done();
|
|
160
148
|
} else {
|
|
161
|
-
// Other errors
|
|
162
149
|
node.status({fill: "red", shape: "dot", text: "Error: " + (error.response ? error.response.status : error.message)});
|
|
163
150
|
|
|
164
151
|
msg.payload = {
|
|
@@ -178,40 +165,7 @@ module.exports = function(RED) {
|
|
|
178
165
|
});
|
|
179
166
|
});
|
|
180
167
|
|
|
181
|
-
// Helper to remove a pin from local storage
|
|
182
|
-
function removePin(pinId, timelineToken) {
|
|
183
|
-
// Ensure we have a valid token
|
|
184
|
-
if (!timelineToken) {
|
|
185
|
-
node.warn("Cannot remove pin: No valid timeline token provided");
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Convert token to string to ensure it can be used as an object key
|
|
190
|
-
timelineToken = String(timelineToken);
|
|
191
|
-
|
|
192
|
-
// Check if this token has any pins
|
|
193
|
-
if (!pinsData[timelineToken]) {
|
|
194
|
-
return; // No pins for this token
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Remove the pin with the specified ID from this token's pins
|
|
198
|
-
const initialCount = pinsData[timelineToken].length;
|
|
199
|
-
pinsData[timelineToken] = pinsData[timelineToken].filter(p => p.id !== pinId);
|
|
200
|
-
|
|
201
|
-
// Note: Cleanup of old pins is handled in the add node
|
|
202
|
-
|
|
203
|
-
// Only write if we actually removed something
|
|
204
|
-
if (pinsData[timelineToken].length !== initialCount) {
|
|
205
|
-
try {
|
|
206
|
-
fs.writeFileSync(pinsFile, JSON.stringify(pinsData, null, 2));
|
|
207
|
-
} catch (error) {
|
|
208
|
-
node.warn(`Error saving pins to file: ${error.message}`);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
168
|
node.on('close', function() {
|
|
214
|
-
// Clean up any resources
|
|
215
169
|
});
|
|
216
170
|
}
|
|
217
171
|
|
package/pebble-timeline-list.js
CHANGED
|
@@ -1,25 +1,19 @@
|
|
|
1
|
-
const
|
|
2
|
-
const path = require('path');
|
|
1
|
+
const store = require('./pebble-timeline-store');
|
|
3
2
|
|
|
4
3
|
module.exports = function(RED) {
|
|
5
4
|
function PebbleTimelineListNode(config) {
|
|
6
5
|
RED.nodes.createNode(this, config);
|
|
7
6
|
const node = this;
|
|
8
7
|
|
|
9
|
-
// Get the config node
|
|
10
8
|
const configNode = RED.nodes.getNode(config.config);
|
|
11
9
|
if (!configNode) {
|
|
12
10
|
node.error("No Pebble Timeline configuration found");
|
|
13
11
|
return;
|
|
14
12
|
}
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
const storageDir = path.join(RED.settings.userDir, 'pebble-timeline');
|
|
18
|
-
fs.ensureDirSync(storageDir);
|
|
19
|
-
const pinsFile = path.join(storageDir, 'timeline-pins.json');
|
|
14
|
+
store.init(RED.settings.userDir);
|
|
20
15
|
|
|
21
16
|
node.on('input', function(msg, send, done) {
|
|
22
|
-
// Backwards compatibility with Node-RED 0.x
|
|
23
17
|
send = send || function() { node.send.apply(node, arguments) };
|
|
24
18
|
|
|
25
19
|
let startTime = null;
|
|
@@ -27,7 +21,6 @@ module.exports = function(RED) {
|
|
|
27
21
|
let apiUrlOverride = null;
|
|
28
22
|
let tokenOverride = null;
|
|
29
23
|
|
|
30
|
-
// Process filter parameters in sequence
|
|
31
24
|
Promise.resolve()
|
|
32
25
|
.then(() => {
|
|
33
26
|
return new Promise((resolve) => {
|
|
@@ -94,58 +87,15 @@ module.exports = function(RED) {
|
|
|
94
87
|
});
|
|
95
88
|
})
|
|
96
89
|
.then(() => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
if (fs.existsSync(pinsFile)) {
|
|
101
|
-
pinsData = JSON.parse(fs.readFileSync(pinsFile, 'utf8'));
|
|
102
|
-
}
|
|
103
|
-
} catch (error) {
|
|
104
|
-
node.warn(`Error loading pins file: ${error.message}`);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Get the timeline token to use
|
|
108
|
-
let timelineToken = tokenOverride || configNode.credentials.timelineToken;
|
|
109
|
-
|
|
110
|
-
// Ensure we have a valid token
|
|
111
|
-
if (!timelineToken) {
|
|
112
|
-
node.warn("No valid timeline token provided");
|
|
113
|
-
timelineToken = "default"; // Use a default key to avoid errors
|
|
114
|
-
}
|
|
90
|
+
const key = store.resolveKey(configNode, tokenOverride);
|
|
91
|
+
const pins = store.getPins(key);
|
|
115
92
|
|
|
116
|
-
// Convert token to string to ensure it can be used as an object key
|
|
117
|
-
timelineToken = String(timelineToken);
|
|
118
|
-
|
|
119
|
-
// Get pins for this token only
|
|
120
|
-
let pins = [];
|
|
121
|
-
if (pinsData[timelineToken] && Array.isArray(pinsData[timelineToken])) {
|
|
122
|
-
pins = pinsData[timelineToken];
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Apply filters
|
|
126
93
|
const filteredPins = pins.filter(pin => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const pinTime = new Date(pin.time);
|
|
131
|
-
if (pinTime < startTime) {
|
|
132
|
-
include = false;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (endTime !== null) {
|
|
137
|
-
const pinTime = new Date(pin.time);
|
|
138
|
-
if (pinTime > endTime) {
|
|
139
|
-
include = false;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return include;
|
|
94
|
+
if (startTime !== null && new Date(pin.time) < startTime) return false;
|
|
95
|
+
if (endTime !== null && new Date(pin.time) > endTime) return false;
|
|
96
|
+
return true;
|
|
144
97
|
});
|
|
145
98
|
|
|
146
|
-
// Note: Cleanup of old pins is handled in the add node
|
|
147
|
-
|
|
148
|
-
// Create output message
|
|
149
99
|
msg.payload = filteredPins;
|
|
150
100
|
msg.count = filteredPins.length;
|
|
151
101
|
|
|
@@ -161,7 +111,6 @@ module.exports = function(RED) {
|
|
|
161
111
|
});
|
|
162
112
|
|
|
163
113
|
node.on('close', function() {
|
|
164
|
-
// Clean up any resources
|
|
165
114
|
});
|
|
166
115
|
}
|
|
167
116
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
let pinsFile = null;
|
|
5
|
+
let pinsData = null;
|
|
6
|
+
let writeQueue = Promise.resolve();
|
|
7
|
+
|
|
8
|
+
function init(userDir) {
|
|
9
|
+
if (pinsFile) return;
|
|
10
|
+
const storageDir = path.join(userDir, 'pebble-timeline');
|
|
11
|
+
fs.ensureDirSync(storageDir);
|
|
12
|
+
pinsFile = path.join(storageDir, 'timeline-pins.json');
|
|
13
|
+
try {
|
|
14
|
+
pinsData = fs.existsSync(pinsFile)
|
|
15
|
+
? JSON.parse(fs.readFileSync(pinsFile, 'utf8'))
|
|
16
|
+
: {};
|
|
17
|
+
} catch (e) {
|
|
18
|
+
pinsData = {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Compute the storage bucket key for a given config node, preferring the
|
|
23
|
+
// timeline token (or a per-message override) and falling back to the config
|
|
24
|
+
// node's own id so each config is isolated even without a token.
|
|
25
|
+
function resolveKey(configNode, override) {
|
|
26
|
+
const token = override
|
|
27
|
+
|| (configNode && configNode.credentials && configNode.credentials.timelineToken);
|
|
28
|
+
if (token) return String(token);
|
|
29
|
+
if (configNode && configNode.id) return String(configNode.id);
|
|
30
|
+
return 'local';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getPins(key) {
|
|
34
|
+
if (!pinsData) return [];
|
|
35
|
+
return Array.isArray(pinsData[key]) ? pinsData[key].slice() : [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function addPin(key, pin) {
|
|
39
|
+
return enqueue(() => {
|
|
40
|
+
if (!Array.isArray(pinsData[key])) pinsData[key] = [];
|
|
41
|
+
pinsData[key] = pinsData[key].filter(p => p.id !== pin.id);
|
|
42
|
+
pinsData[key].push({ ...pin, _stored: new Date().toISOString() });
|
|
43
|
+
cleanupOldPins();
|
|
44
|
+
return writeFile();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function removePin(key, pinId) {
|
|
49
|
+
return enqueue(() => {
|
|
50
|
+
if (!Array.isArray(pinsData[key])) return false;
|
|
51
|
+
const before = pinsData[key].length;
|
|
52
|
+
pinsData[key] = pinsData[key].filter(p => p.id !== pinId);
|
|
53
|
+
if (pinsData[key].length === before) return false;
|
|
54
|
+
return writeFile().then(() => true);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Serialize all mutating operations through a single promise chain so
|
|
59
|
+
// concurrent add/delete invocations cannot race on the read-modify-write.
|
|
60
|
+
function enqueue(fn) {
|
|
61
|
+
const next = writeQueue.then(fn, fn);
|
|
62
|
+
writeQueue = next.catch(() => {});
|
|
63
|
+
return next;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cleanupOldPins() {
|
|
67
|
+
const oneMonthAgo = new Date();
|
|
68
|
+
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
|
69
|
+
for (const k of Object.keys(pinsData)) {
|
|
70
|
+
if (!Array.isArray(pinsData[k])) {
|
|
71
|
+
pinsData[k] = [];
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
pinsData[k] = pinsData[k].filter(pin => {
|
|
75
|
+
if (!pin || !pin._stored) return false;
|
|
76
|
+
const d = new Date(pin._stored);
|
|
77
|
+
return !isNaN(d.getTime()) && d >= oneMonthAgo;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeFile() {
|
|
83
|
+
const tmp = pinsFile + '.tmp';
|
|
84
|
+
const data = JSON.stringify(pinsData, null, 2);
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
fs.writeFile(tmp, data, (err) => {
|
|
87
|
+
if (err) return reject(err);
|
|
88
|
+
fs.rename(tmp, pinsFile, (err2) => err2 ? reject(err2) : resolve());
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { init, resolveKey, getPins, addPin, removePin };
|
package/node-red-sync-flow.json
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
[
|
|
2
|
-
{
|
|
3
|
-
"id": "sync-flow-tab",
|
|
4
|
-
"type": "tab",
|
|
5
|
-
"label": "Rebble Timeline Sync",
|
|
6
|
-
"disabled": false,
|
|
7
|
-
"info": "Flow to fetch timeline pins from Rebble sync endpoint"
|
|
8
|
-
},
|
|
9
|
-
{
|
|
10
|
-
"id": "inject-token",
|
|
11
|
-
"type": "inject",
|
|
12
|
-
"z": "sync-flow-tab",
|
|
13
|
-
"name": "Set Access Token",
|
|
14
|
-
"props": [
|
|
15
|
-
{
|
|
16
|
-
"p": "access_token",
|
|
17
|
-
"v": "YOUR_ACCESS_TOKEN_HERE",
|
|
18
|
-
"vt": "str"
|
|
19
|
-
}
|
|
20
|
-
],
|
|
21
|
-
"repeat": "",
|
|
22
|
-
"crontab": "",
|
|
23
|
-
"once": false,
|
|
24
|
-
"onceDelay": 0.1,
|
|
25
|
-
"topic": "",
|
|
26
|
-
"x": 140,
|
|
27
|
-
"y": 100,
|
|
28
|
-
"wires": [
|
|
29
|
-
["setup-request"]
|
|
30
|
-
]
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
"id": "setup-request",
|
|
34
|
-
"type": "function",
|
|
35
|
-
"z": "sync-flow-tab",
|
|
36
|
-
"name": "Setup Request",
|
|
37
|
-
"func": "msg.url = 'https://timeline-sync.rebble.io/v1/sync';\nmsg.headers = {\n 'Authorization': 'Bearer ' + msg.access_token\n};\n\n// Optional: pass timeline/glance cursor for incremental sync\nif (msg.timeline) {\n msg.url += '?timeline=' + msg.timeline;\n if (msg.glance) {\n msg.url += '&glance=' + msg.glance;\n }\n} else if (msg.glance) {\n msg.url += '?glance=' + msg.glance;\n}\n\nreturn msg;",
|
|
38
|
-
"outputs": 1,
|
|
39
|
-
"timeout": "",
|
|
40
|
-
"noerr": 0,
|
|
41
|
-
"initialize": "",
|
|
42
|
-
"finalize": "",
|
|
43
|
-
"libs": [],
|
|
44
|
-
"x": 340,
|
|
45
|
-
"y": 100,
|
|
46
|
-
"wires": [
|
|
47
|
-
["http-request"]
|
|
48
|
-
]
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
"id": "http-request",
|
|
52
|
-
"type": "http request",
|
|
53
|
-
"z": "sync-flow-tab",
|
|
54
|
-
"name": "GET /v1/sync",
|
|
55
|
-
"method": "GET",
|
|
56
|
-
"ret": "obj",
|
|
57
|
-
"paytoqs": "ignore",
|
|
58
|
-
"url": "",
|
|
59
|
-
"tls": "",
|
|
60
|
-
"persist": false,
|
|
61
|
-
"proxy": "",
|
|
62
|
-
"insecureHTTPParser": false,
|
|
63
|
-
"authType": "",
|
|
64
|
-
"senderr": false,
|
|
65
|
-
"headers": [],
|
|
66
|
-
"x": 530,
|
|
67
|
-
"y": 100,
|
|
68
|
-
"wires": [
|
|
69
|
-
["debug-output", "parse-response"]
|
|
70
|
-
]
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
"id": "debug-output",
|
|
74
|
-
"type": "debug",
|
|
75
|
-
"z": "sync-flow-tab",
|
|
76
|
-
"name": "Sync Response",
|
|
77
|
-
"active": true,
|
|
78
|
-
"tosidebar": true,
|
|
79
|
-
"console": false,
|
|
80
|
-
"tostatus": false,
|
|
81
|
-
"complete": "payload",
|
|
82
|
-
"targetType": "msg",
|
|
83
|
-
"statusVal": "",
|
|
84
|
-
"statusType": "auto",
|
|
85
|
-
"x": 740,
|
|
86
|
-
"y": 60,
|
|
87
|
-
"wires": []
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
"id": "parse-response",
|
|
91
|
-
"type": "function",
|
|
92
|
-
"z": "sync-flow-tab",
|
|
93
|
-
"name": "Parse Updates",
|
|
94
|
-
"func": "// Extract updates array and syncURL for next request\nlet response = msg.payload;\n\nif (response.updates && response.updates.length > 0) {\n node.status({fill: 'green', shape: 'dot', text: response.updates.length + ' updates'});\n} else {\n node.status({fill: 'yellow', shape: 'ring', text: 'No updates'});\n}\n\n// Store syncURL for subsequent requests\nmsg.syncURL = response.syncURL;\nmsg.updates = response.updates || [];\n\nreturn msg;",
|
|
95
|
-
"outputs": 1,
|
|
96
|
-
"timeout": "",
|
|
97
|
-
"noerr": 0,
|
|
98
|
-
"initialize": "",
|
|
99
|
-
"finalize": "",
|
|
100
|
-
"libs": [],
|
|
101
|
-
"x": 740,
|
|
102
|
-
"y": 140,
|
|
103
|
-
"wires": [
|
|
104
|
-
["debug-updates"]
|
|
105
|
-
]
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
"id": "debug-updates",
|
|
109
|
-
"type": "debug",
|
|
110
|
-
"z": "sync-flow-tab",
|
|
111
|
-
"name": "Parsed Updates",
|
|
112
|
-
"active": true,
|
|
113
|
-
"tosidebar": true,
|
|
114
|
-
"console": false,
|
|
115
|
-
"tostatus": false,
|
|
116
|
-
"complete": "true",
|
|
117
|
-
"targetType": "full",
|
|
118
|
-
"statusVal": "",
|
|
119
|
-
"statusType": "auto",
|
|
120
|
-
"x": 940,
|
|
121
|
-
"y": 140,
|
|
122
|
-
"wires": []
|
|
123
|
-
}
|
|
124
|
-
]
|