@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.
@@ -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.1.0",
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
-
@@ -1,6 +1,6 @@
1
1
  const axios = require('axios');
2
- const fs = require('fs-extra');
3
- const path = require('path');
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
- // Make sure storage directory exists
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 apiUrl = `${apiUrlOverride || configNode.apiUrl}/v1/user/pins/${pin.id}`;
249
+ const baseApiUrl = apiUrlOverride || configNode.apiUrl;
263
250
  const timelineToken = tokenOverride || configNode.credentials.timelineToken;
264
251
 
265
- if (!timelineToken) {
266
- const errMsg = "Timeline token is required";
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
- // Final validation of the pin object
278
- validatePin(pin, node);
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
- axios.put(apiUrl, pin, {
281
- headers: {
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
- // Store the pin in our local storage
291
- storePin(pin, timelineToken);
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: true,
296
- pin: pin,
297
- response: response.data
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
- .catch(error => {
304
- // Set error status
305
- node.status({fill: "red", shape: "dot", text: "Error: " + (error.response ? error.response.status : error.message)});
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
- // Debug: Log detailed error information
308
- if (error.response) {
309
- node.debug(`Error response: ${JSON.stringify(error.response.data)}`);
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
- // Prepare error output without throwing an error to done callback
313
- msg.payload = {
314
- success: false,
315
- error: error.message,
316
- response: error.response ? error.response.data : null
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
- send(msg);
320
- // Don't use done callback for errors from API, as we're handling them in the output
321
- if (done) done();
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: true }
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 fs = require('fs-extra');
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
- // Make sure storage directory exists
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
- // Process parameters in sequence
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
- node.apiUrlOverride = result;
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
- node.tokenOverride = result;
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
- // Use overrides if provided, otherwise use config node values
87
- const apiUrl = `${node.apiUrlOverride || configNode.apiUrl}/v1/user/pins/${pinId}`;
88
- const timelineToken = node.tokenOverride || configNode.credentials.timelineToken;
89
-
90
- if (!timelineToken) {
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
- axios.delete(apiUrl, {
97
- headers: {
98
- 'X-User-Token': timelineToken
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
- // Remove the pin from our local storage
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
- response: response.data
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
- .catch(error => {
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
- // Remove from local storage anyway
124
- removePin(pinId, timelineToken);
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
- alreadyDeleted: true,
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
- } else {
136
- // Other errors
137
- node.status({fill: "red", shape: "dot", text: "Error: " + (error.response ? error.response.status : error.message)});
138
-
139
- msg.payload = {
140
- success: false,
141
- pinId: pinId,
142
- error: error.message,
143
- response: error.response ? error.response.data : null
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
- send(msg);
147
- if (done) done(error);
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
 
@@ -1,25 +1,19 @@
1
- const fs = require('fs-extra');
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
- // Make sure storage directory exists
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
- // Load the pins
98
- let pinsData = {};
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
- let include = true;
128
-
129
- if (startTime !== null) {
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
+
@@ -1,11 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(xargs:*)",
5
- "Bash(find:*)",
6
- "WebSearch",
7
- "WebFetch(domain:developer.rebble.io)",
8
- "WebFetch(domain:github.com)"
9
- ]
10
- }
11
- }
@@ -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
- ]