@skylord123/node-red-pebble-timeline 1.1.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 +5 -3
- package/pebble-timeline-add.js +101 -138
- package/pebble-timeline-config.html +6 -2
- package/pebble-timeline-delete.js +80 -100
- package/pebble-timeline-list.js +7 -58
- package/pebble-timeline-store.js +93 -0
- package/pebble-timeline-validation.js +145 -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,7 +1,11 @@
|
|
|
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
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/skylord123/node-red-pebble-timeline.git"
|
|
8
|
+
},
|
|
5
9
|
"keywords": [
|
|
6
10
|
"node-red",
|
|
7
11
|
"pebble",
|
|
@@ -23,5 +27,3 @@
|
|
|
23
27
|
"fs-extra": "^11.1.0"
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
|
-
|
|
27
|
-
|
package/pebble-timeline-add.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
|
-
const
|
|
3
|
-
const
|
|
2
|
+
const { pinValid } = require('./pebble-timeline-validation');
|
|
3
|
+
const store = require('./pebble-timeline-store');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Node-RED node for adding pins to the Pebble Timeline API
|
|
@@ -28,20 +28,7 @@ module.exports = function(RED) {
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
const storageDir = path.join(RED.settings.userDir, 'pebble-timeline');
|
|
33
|
-
fs.ensureDirSync(storageDir);
|
|
34
|
-
const pinsFile = path.join(storageDir, 'timeline-pins.json');
|
|
35
|
-
|
|
36
|
-
// Load existing pins (organized by token)
|
|
37
|
-
let pinsData = {};
|
|
38
|
-
try {
|
|
39
|
-
if (fs.existsSync(pinsFile)) {
|
|
40
|
-
pinsData = JSON.parse(fs.readFileSync(pinsFile, 'utf8'));
|
|
41
|
-
}
|
|
42
|
-
} catch (error) {
|
|
43
|
-
node.warn(`Error loading pins file: ${error.message}`);
|
|
44
|
-
}
|
|
31
|
+
store.init(RED.settings.userDir);
|
|
45
32
|
|
|
46
33
|
node.on('input', async function(msg, send, done) {
|
|
47
34
|
// Backwards compatibility with Node-RED 0.x
|
|
@@ -259,67 +246,119 @@ module.exports = function(RED) {
|
|
|
259
246
|
}
|
|
260
247
|
|
|
261
248
|
// Use overrides if provided, otherwise use config node values
|
|
262
|
-
const
|
|
249
|
+
const baseApiUrl = apiUrlOverride || configNode.apiUrl;
|
|
263
250
|
const timelineToken = tokenOverride || configNode.credentials.timelineToken;
|
|
264
251
|
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
node.status({fill: "red", shape: "dot", text: "Missing token"});
|
|
268
|
-
// Only use done callback for errors, don't include error in message
|
|
269
|
-
if (done) done(errMsg);
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Debug: Log final pin data
|
|
274
|
-
node.debug(`Sending pin: ${JSON.stringify(pin, null, 2)}`);
|
|
275
|
-
node.debug(`API URL: ${apiUrl}`);
|
|
252
|
+
// Check if we're in local emulation mode (empty API URL)
|
|
253
|
+
const isLocalMode = !baseApiUrl || baseApiUrl.trim() === '';
|
|
276
254
|
|
|
277
|
-
|
|
278
|
-
|
|
255
|
+
if (isLocalMode) {
|
|
256
|
+
// Local emulation mode - validate and store locally
|
|
257
|
+
node.debug(`Local emulation mode - validating pin locally`);
|
|
258
|
+
node.debug(`Pin data: ${JSON.stringify(pin, null, 2)}`);
|
|
279
259
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
'Content-Type': 'application/json',
|
|
283
|
-
'X-User-Token': timelineToken
|
|
284
|
-
}
|
|
285
|
-
})
|
|
286
|
-
.then(response => {
|
|
287
|
-
// Set successful status - using "OK" as requested
|
|
288
|
-
node.status({fill: "green", shape: "dot", text: "OK"});
|
|
260
|
+
// Validate the pin using local validation
|
|
261
|
+
const validationResult = pinValid(pin.id, pin);
|
|
289
262
|
|
|
290
|
-
|
|
291
|
-
|
|
263
|
+
if (!validationResult.valid) {
|
|
264
|
+
const errMsg = `Pin validation failed: ${validationResult.error}`;
|
|
265
|
+
node.status({fill: "red", shape: "dot", text: "Validation failed"});
|
|
266
|
+
node.error(errMsg, msg);
|
|
292
267
|
|
|
293
|
-
// Prepare the output message
|
|
294
268
|
msg.payload = {
|
|
295
|
-
success:
|
|
296
|
-
|
|
297
|
-
|
|
269
|
+
success: false,
|
|
270
|
+
error: errMsg,
|
|
271
|
+
validationError: validationResult.error
|
|
298
272
|
};
|
|
299
273
|
|
|
300
274
|
send(msg);
|
|
301
275
|
if (done) done();
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
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
|
+
}
|
|
285
|
+
node.status({fill: "green", shape: "dot", text: "OK (local)"});
|
|
286
|
+
|
|
287
|
+
msg.payload = {
|
|
288
|
+
success: true,
|
|
289
|
+
pin: pin,
|
|
290
|
+
mode: 'local',
|
|
291
|
+
message: 'Pin validated and stored locally'
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
send(msg);
|
|
295
|
+
if (done) done();
|
|
296
|
+
} else {
|
|
297
|
+
// Remote API mode
|
|
298
|
+
const apiUrl = `${baseApiUrl}/v1/user/pins/${pin.id}`;
|
|
299
|
+
|
|
300
|
+
if (!timelineToken) {
|
|
301
|
+
const errMsg = "Timeline token is required";
|
|
302
|
+
node.status({fill: "red", shape: "dot", text: "Missing token"});
|
|
303
|
+
if (done) done(errMsg);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
306
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
307
|
+
// Debug: Log final pin data
|
|
308
|
+
node.debug(`Sending pin: ${JSON.stringify(pin, null, 2)}`);
|
|
309
|
+
node.debug(`API URL: ${apiUrl}`);
|
|
310
|
+
|
|
311
|
+
// Final validation of the pin object
|
|
312
|
+
validatePin(pin, node);
|
|
313
|
+
|
|
314
|
+
axios.put(apiUrl, pin, {
|
|
315
|
+
headers: {
|
|
316
|
+
'Content-Type': 'application/json',
|
|
317
|
+
'X-User-Token': timelineToken
|
|
310
318
|
}
|
|
319
|
+
})
|
|
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
|
+
}
|
|
311
327
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
328
|
+
// Set successful status - using "OK" as requested
|
|
329
|
+
node.status({fill: "green", shape: "dot", text: "OK"});
|
|
330
|
+
|
|
331
|
+
// Prepare the output message
|
|
332
|
+
msg.payload = {
|
|
333
|
+
success: true,
|
|
334
|
+
pin: pin,
|
|
335
|
+
response: response.data
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
send(msg);
|
|
339
|
+
if (done) done();
|
|
340
|
+
})
|
|
341
|
+
.catch(error => {
|
|
342
|
+
// Set error status
|
|
343
|
+
node.status({fill: "red", shape: "dot", text: "Error: " + (error.response ? error.response.status : error.message)});
|
|
344
|
+
|
|
345
|
+
// Debug: Log detailed error information
|
|
346
|
+
if (error.response) {
|
|
347
|
+
node.debug(`Error response: ${JSON.stringify(error.response.data)}`);
|
|
348
|
+
}
|
|
318
349
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
350
|
+
// Prepare error output without throwing an error to done callback
|
|
351
|
+
msg.payload = {
|
|
352
|
+
success: false,
|
|
353
|
+
error: error.message,
|
|
354
|
+
response: error.response ? error.response.data : null
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
send(msg);
|
|
358
|
+
// Don't use done callback for errors from API, as we're handling them in the output
|
|
359
|
+
if (done) done();
|
|
360
|
+
});
|
|
361
|
+
}
|
|
323
362
|
} catch (err) {
|
|
324
363
|
// For unexpected errors, use both the done callback and send the error
|
|
325
364
|
node.status({fill: "red", shape: "dot", text: "Error: " + err.message});
|
|
@@ -335,82 +374,6 @@ module.exports = function(RED) {
|
|
|
335
374
|
}
|
|
336
375
|
});
|
|
337
376
|
|
|
338
|
-
// Helper to store a pin in local storage
|
|
339
|
-
function storePin(pin, timelineToken) {
|
|
340
|
-
// Ensure we have a valid token
|
|
341
|
-
if (!timelineToken) {
|
|
342
|
-
node.warn("Cannot store pin: No valid timeline token provided");
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Convert token to string to ensure it can be used as an object key
|
|
347
|
-
timelineToken = String(timelineToken);
|
|
348
|
-
|
|
349
|
-
// Initialize the token's pins array if it doesn't exist
|
|
350
|
-
if (!pinsData[timelineToken]) {
|
|
351
|
-
pinsData[timelineToken] = [];
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Remove any existing pin with the same ID for this token
|
|
355
|
-
pinsData[timelineToken] = pinsData[timelineToken].filter(p => p.id !== pin.id);
|
|
356
|
-
|
|
357
|
-
// Add the new pin with a timestamp for when it was added
|
|
358
|
-
pinsData[timelineToken].push({
|
|
359
|
-
...pin,
|
|
360
|
-
_stored: new Date().toISOString()
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
// Clean up old pins (older than 1 month) from all tokens
|
|
364
|
-
cleanupOldPins();
|
|
365
|
-
|
|
366
|
-
// Write the pins to the file
|
|
367
|
-
try {
|
|
368
|
-
fs.writeFileSync(pinsFile, JSON.stringify(pinsData, null, 2));
|
|
369
|
-
} catch (error) {
|
|
370
|
-
node.warn(`Error saving pins to file: ${error.message}`);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// Helper to clean up pins older than 1 month
|
|
375
|
-
function cleanupOldPins() {
|
|
376
|
-
const oneMonthAgo = new Date();
|
|
377
|
-
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
|
378
|
-
let changed = false;
|
|
379
|
-
|
|
380
|
-
// Iterate through all tokens
|
|
381
|
-
Object.keys(pinsData).forEach(token => {
|
|
382
|
-
// Make sure the token's data is an array
|
|
383
|
-
if (!Array.isArray(pinsData[token])) {
|
|
384
|
-
pinsData[token] = [];
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Filter out pins older than 1 month
|
|
389
|
-
const initialCount = pinsData[token].length;
|
|
390
|
-
pinsData[token] = pinsData[token].filter(pin => {
|
|
391
|
-
// Make sure pin has _stored property
|
|
392
|
-
if (!pin || !pin._stored) return false;
|
|
393
|
-
|
|
394
|
-
try {
|
|
395
|
-
const storedDate = new Date(pin._stored);
|
|
396
|
-
return storedDate >= oneMonthAgo;
|
|
397
|
-
} catch (e) {
|
|
398
|
-
// If date parsing fails, remove the pin
|
|
399
|
-
return false;
|
|
400
|
-
}
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
// Log if pins were removed
|
|
404
|
-
if (pinsData[token].length < initialCount) {
|
|
405
|
-
node.debug(`Removed ${initialCount - pinsData[token].length} old pins for token ${token.substring(0, 8)}...`);
|
|
406
|
-
changed = true;
|
|
407
|
-
}
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
// No need to save here as the calling function will save the file
|
|
411
|
-
return changed;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
377
|
// Apply node configuration to the pin
|
|
415
378
|
async function applyNodeConfiguration(pin, config, msg, node) {
|
|
416
379
|
try {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
category: 'config',
|
|
4
4
|
defaults: {
|
|
5
5
|
name: { value: "" },
|
|
6
|
-
apiUrl: { value: "https://timeline-api.rebble.io", required:
|
|
6
|
+
apiUrl: { value: "https://timeline-api.rebble.io", required: false }
|
|
7
7
|
},
|
|
8
8
|
credentials: {
|
|
9
9
|
timelineToken: { type: "password" }
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
<input type="text" id="node-config-input-apiUrl" placeholder="https://timeline-api.rebble.io">
|
|
26
26
|
<div class="form-tips">The URL of the Pebble Timeline API. The default URL (https://timeline-api.rebble.io) is
|
|
27
27
|
provided by the Rebble service which maintains Pebble functionality after official support ended.
|
|
28
|
+
<strong>Leave this field empty to emulate the timeline service locally</strong> - pins will be validated and stored
|
|
29
|
+
locally without sending requests to a remote server.
|
|
28
30
|
</div>
|
|
29
31
|
</div>
|
|
30
32
|
<div class="form-row">
|
|
@@ -46,7 +48,9 @@
|
|
|
46
48
|
|
|
47
49
|
<dt>API URL <span class="property-type">string</span></dt>
|
|
48
50
|
<dd>The URL of the Pebble Timeline API. Defaults to https://timeline-api.rebble.io, which is the Rebble service
|
|
49
|
-
that maintains Pebble functionality.
|
|
51
|
+
that maintains Pebble functionality. <strong>Leave this field empty to emulate the timeline service locally</strong> -
|
|
52
|
+
when empty, pins will be validated and stored locally without sending requests to a remote server. This is useful
|
|
53
|
+
for testing or offline operation.
|
|
50
54
|
</dd>
|
|
51
55
|
|
|
52
56
|
<dt>Timeline Token <span class="property-type">string</span></dt>
|
|
@@ -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,110 +66,106 @@ module.exports = function(RED) {
|
|
|
82
66
|
resolve();
|
|
83
67
|
}
|
|
84
68
|
})
|
|
85
|
-
]).then(() => {
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
node.error("Timeline token is required", msg);
|
|
92
|
-
if (done) done("Timeline token is required");
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
69
|
+
]).then(async () => {
|
|
70
|
+
const baseApiUrl = apiUrlOverride || configNode.apiUrl;
|
|
71
|
+
const timelineToken = tokenOverride || configNode.credentials.timelineToken;
|
|
72
|
+
const storeKey = store.resolveKey(configNode, tokenOverride);
|
|
73
|
+
|
|
74
|
+
const isLocalMode = !baseApiUrl || baseApiUrl.trim() === '';
|
|
95
75
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
76
|
+
if (isLocalMode) {
|
|
77
|
+
node.debug(`Local emulation mode - deleting pin locally`);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await store.removePin(storeKey, pinId);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
node.warn(`Error removing pin from local storage: ${e.message}`);
|
|
99
83
|
}
|
|
100
|
-
})
|
|
101
|
-
.then(response => {
|
|
102
|
-
node.status({fill: "green", shape: "dot", text: "Pin deleted"});
|
|
103
84
|
|
|
104
|
-
|
|
105
|
-
removePin(pinId, timelineToken);
|
|
85
|
+
node.status({fill: "green", shape: "dot", text: "Pin deleted (local)"});
|
|
106
86
|
|
|
107
|
-
// Prepare the output message
|
|
108
87
|
msg.payload = {
|
|
109
88
|
success: true,
|
|
110
89
|
pinId: pinId,
|
|
111
|
-
|
|
90
|
+
mode: 'local',
|
|
91
|
+
message: 'Pin deleted from local storage'
|
|
112
92
|
};
|
|
113
93
|
|
|
114
94
|
send(msg);
|
|
115
95
|
if (done) done();
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Handle 404 - pin already deleted
|
|
119
|
-
if (error.response && error.response.status === 404) {
|
|
120
|
-
node.warn(`Pin ${pinId} not found on server (404) - assuming already deleted`);
|
|
121
|
-
node.status({fill: "yellow", shape: "dot", text: "Pin already deleted"});
|
|
96
|
+
} else {
|
|
97
|
+
const apiUrl = `${baseApiUrl}/v1/user/pins/${pinId}`;
|
|
122
98
|
|
|
123
|
-
|
|
124
|
-
|
|
99
|
+
if (!timelineToken) {
|
|
100
|
+
node.error("Timeline token is required", msg);
|
|
101
|
+
if (done) done("Timeline token is required");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
axios.delete(apiUrl, {
|
|
106
|
+
headers: {
|
|
107
|
+
'X-User-Token': timelineToken
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
.then(async response => {
|
|
111
|
+
node.status({fill: "green", shape: "dot", text: "Pin deleted"});
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
await store.removePin(storeKey, pinId);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
node.warn(`Error removing pin from local storage: ${e.message}`);
|
|
117
|
+
}
|
|
125
118
|
|
|
126
119
|
msg.payload = {
|
|
127
120
|
success: true,
|
|
128
121
|
pinId: pinId,
|
|
129
|
-
|
|
130
|
-
message: "Pin not found on server, removed from local storage"
|
|
122
|
+
response: response.data
|
|
131
123
|
};
|
|
132
124
|
|
|
133
125
|
send(msg);
|
|
134
126
|
if (done) done();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
127
|
+
})
|
|
128
|
+
.catch(async error => {
|
|
129
|
+
if (error.response && error.response.status === 404) {
|
|
130
|
+
node.warn(`Pin ${pinId} not found on server (404) - assuming already deleted`);
|
|
131
|
+
node.status({fill: "yellow", shape: "dot", text: "Pin already deleted"});
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await store.removePin(storeKey, pinId);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
node.warn(`Error removing pin from local storage: ${e.message}`);
|
|
137
|
+
}
|
|
145
138
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
139
|
+
msg.payload = {
|
|
140
|
+
success: true,
|
|
141
|
+
pinId: pinId,
|
|
142
|
+
alreadyDeleted: true,
|
|
143
|
+
message: "Pin not found on server, removed from local storage"
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
send(msg);
|
|
147
|
+
if (done) done();
|
|
148
|
+
} else {
|
|
149
|
+
node.status({fill: "red", shape: "dot", text: "Error: " + (error.response ? error.response.status : error.message)});
|
|
150
|
+
|
|
151
|
+
msg.payload = {
|
|
152
|
+
success: false,
|
|
153
|
+
pinId: pinId,
|
|
154
|
+
error: error.message,
|
|
155
|
+
response: error.response ? error.response.data : null
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
send(msg);
|
|
159
|
+
if (done) done(error);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
150
163
|
}).catch(err => {
|
|
151
164
|
if (done) done(err);
|
|
152
165
|
});
|
|
153
166
|
});
|
|
154
167
|
|
|
155
|
-
// Helper to remove a pin from local storage
|
|
156
|
-
function removePin(pinId, timelineToken) {
|
|
157
|
-
// Ensure we have a valid token
|
|
158
|
-
if (!timelineToken) {
|
|
159
|
-
node.warn("Cannot remove pin: No valid timeline token provided");
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Convert token to string to ensure it can be used as an object key
|
|
164
|
-
timelineToken = String(timelineToken);
|
|
165
|
-
|
|
166
|
-
// Check if this token has any pins
|
|
167
|
-
if (!pinsData[timelineToken]) {
|
|
168
|
-
return; // No pins for this token
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Remove the pin with the specified ID from this token's pins
|
|
172
|
-
const initialCount = pinsData[timelineToken].length;
|
|
173
|
-
pinsData[timelineToken] = pinsData[timelineToken].filter(p => p.id !== pinId);
|
|
174
|
-
|
|
175
|
-
// Note: Cleanup of old pins is handled in the add node
|
|
176
|
-
|
|
177
|
-
// Only write if we actually removed something
|
|
178
|
-
if (pinsData[timelineToken].length !== initialCount) {
|
|
179
|
-
try {
|
|
180
|
-
fs.writeFileSync(pinsFile, JSON.stringify(pinsData, null, 2));
|
|
181
|
-
} catch (error) {
|
|
182
|
-
node.warn(`Error saving pins to file: ${error.message}`);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
168
|
node.on('close', function() {
|
|
188
|
-
// Clean up any resources
|
|
189
169
|
});
|
|
190
170
|
}
|
|
191
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 };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local validation module for Pebble Timeline pins
|
|
3
|
+
* Based on the validation logic from rebble-timeline-sync
|
|
4
|
+
* https://github.com/pebble-dev/rebble-timeline-sync
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ISO_FORMAT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
|
8
|
+
const ISO_FORMAT_MSEC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse ISO 8601 time string
|
|
12
|
+
* @param {string} timeStr - ISO 8601 formatted time string
|
|
13
|
+
* @returns {Date} Parsed date object
|
|
14
|
+
*/
|
|
15
|
+
function parseTime(timeStr) {
|
|
16
|
+
if (!timeStr || typeof timeStr !== 'string') {
|
|
17
|
+
throw new Error('Invalid time string');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!ISO_FORMAT.test(timeStr) && !ISO_FORMAT_MSEC.test(timeStr)) {
|
|
21
|
+
throw new Error('Time must be in ISO 8601 format');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const date = new Date(timeStr);
|
|
25
|
+
if (isNaN(date.getTime())) {
|
|
26
|
+
throw new Error('Invalid date');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return date;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate that a time is within acceptable range
|
|
34
|
+
* Time must not be more than two days in the past, or a year in the future
|
|
35
|
+
* @param {Date} time - Date object to validate
|
|
36
|
+
* @returns {boolean} True if time is valid
|
|
37
|
+
*/
|
|
38
|
+
function timeValid(time) {
|
|
39
|
+
const now = new Date();
|
|
40
|
+
const twoDaysAgo = new Date(now.getTime() - (2 * 24 * 60 * 60 * 1000));
|
|
41
|
+
const oneYearFromNow = new Date(now.getTime() + (366 * 24 * 60 * 60 * 1000));
|
|
42
|
+
|
|
43
|
+
if (time < twoDaysAgo || time > oneYearFromNow) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate a timeline pin
|
|
52
|
+
* Based on pin_valid from rebble-timeline-sync/timeline_sync/utils.py
|
|
53
|
+
* @param {string} pinId - The pin ID from the URL/request
|
|
54
|
+
* @param {object} pinJson - The pin object to validate
|
|
55
|
+
* @returns {object} Object with valid (boolean) and error (string) properties
|
|
56
|
+
*/
|
|
57
|
+
function pinValid(pinId, pinJson) {
|
|
58
|
+
try {
|
|
59
|
+
// Check that pin JSON exists
|
|
60
|
+
if (!pinJson || typeof pinJson !== 'object') {
|
|
61
|
+
return { valid: false, error: 'parse_failure_or_missing_pin' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check that pin ID matches
|
|
65
|
+
if (pinJson.id !== pinId) {
|
|
66
|
+
return { valid: false, error: 'id_mismatch' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Validate main pin time
|
|
70
|
+
if (!pinJson.time) {
|
|
71
|
+
return { valid: false, error: 'missing_time' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let pinTime;
|
|
75
|
+
try {
|
|
76
|
+
pinTime = parseTime(pinJson.time);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return { valid: false, error: 'invalid_time_format' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!timeValid(pinTime)) {
|
|
82
|
+
return { valid: false, error: 'invalid_time' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Validate createNotification - should NOT have a time attribute
|
|
86
|
+
if (pinJson.createNotification && pinJson.createNotification.time) {
|
|
87
|
+
return { valid: false, error: 'invalid_time_attribute' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Validate updateNotification time if present
|
|
91
|
+
if (pinJson.updateNotification && pinJson.updateNotification.time) {
|
|
92
|
+
let updateTime;
|
|
93
|
+
try {
|
|
94
|
+
updateTime = parseTime(pinJson.updateNotification.time);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { valid: false, error: 'invalid_update_notification_time_format' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!timeValid(updateTime)) {
|
|
100
|
+
return { valid: false, error: 'invalid_time_for_update' };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate reminders
|
|
105
|
+
if (pinJson.reminders) {
|
|
106
|
+
if (!Array.isArray(pinJson.reminders)) {
|
|
107
|
+
return { valid: false, error: 'reminders_not_array' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (pinJson.reminders.length > 3) {
|
|
111
|
+
return { valid: false, error: 'too_many_reminders' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < pinJson.reminders.length; i++) {
|
|
115
|
+
const reminder = pinJson.reminders[i];
|
|
116
|
+
if (!reminder.time) {
|
|
117
|
+
return { valid: false, error: `reminder_${i}_missing_time` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let reminderTime;
|
|
121
|
+
try {
|
|
122
|
+
reminderTime = parseTime(reminder.time);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { valid: false, error: `reminder_${i}_invalid_time_format` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!timeValid(reminderTime)) {
|
|
128
|
+
return { valid: false, error: 'invalid_reminder_time' };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { valid: true };
|
|
134
|
+
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { valid: false, error: 'miscellaneous_failure: ' + error.message };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
pinValid,
|
|
142
|
+
parseTime,
|
|
143
|
+
timeValid
|
|
144
|
+
};
|
|
145
|
+
|
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
|
-
]
|