@skylord123/node-red-pebble-timeline 1.1.0 → 1.3.0

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/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.3.0",
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",
@@ -1,6 +1,7 @@
1
1
  const axios = require('axios');
2
2
  const fs = require('fs-extra');
3
3
  const path = require('path');
4
+ const { pinValid } = require('./pebble-timeline-validation');
4
5
 
5
6
  /**
6
7
  * Node-RED node for adding pins to the Pebble Timeline API
@@ -259,67 +260,111 @@ module.exports = function(RED) {
259
260
  }
260
261
 
261
262
  // Use overrides if provided, otherwise use config node values
262
- const apiUrl = `${apiUrlOverride || configNode.apiUrl}/v1/user/pins/${pin.id}`;
263
+ const baseApiUrl = apiUrlOverride || configNode.apiUrl;
263
264
  const timelineToken = tokenOverride || configNode.credentials.timelineToken;
264
265
 
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}`);
266
+ // Check if we're in local emulation mode (empty API URL)
267
+ const isLocalMode = !baseApiUrl || baseApiUrl.trim() === '';
276
268
 
277
- // Final validation of the pin object
278
- validatePin(pin, node);
269
+ if (isLocalMode) {
270
+ // Local emulation mode - validate and store locally
271
+ node.debug(`Local emulation mode - validating pin locally`);
272
+ node.debug(`Pin data: ${JSON.stringify(pin, null, 2)}`);
279
273
 
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"});
274
+ // Validate the pin using local validation
275
+ const validationResult = pinValid(pin.id, pin);
289
276
 
290
- // Store the pin in our local storage
291
- storePin(pin, timelineToken);
277
+ if (!validationResult.valid) {
278
+ const errMsg = `Pin validation failed: ${validationResult.error}`;
279
+ node.status({fill: "red", shape: "dot", text: "Validation failed"});
280
+ node.error(errMsg, msg);
292
281
 
293
- // Prepare the output message
294
282
  msg.payload = {
295
- success: true,
296
- pin: pin,
297
- response: response.data
283
+ success: false,
284
+ error: errMsg,
285
+ validationError: validationResult.error
298
286
  };
299
287
 
300
288
  send(msg);
301
289
  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)});
290
+ return;
291
+ }
306
292
 
307
- // Debug: Log detailed error information
308
- if (error.response) {
309
- node.debug(`Error response: ${JSON.stringify(error.response.data)}`);
310
- }
293
+ // Pin is valid - store it locally
294
+ node.status({fill: "green", shape: "dot", text: "OK (local)"});
295
+ storePin(pin, timelineToken || 'local');
311
296
 
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
- };
297
+ msg.payload = {
298
+ success: true,
299
+ pin: pin,
300
+ mode: 'local',
301
+ message: 'Pin validated and stored locally'
302
+ };
318
303
 
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
- });
304
+ send(msg);
305
+ if (done) done();
306
+ } else {
307
+ // Remote API mode
308
+ const apiUrl = `${baseApiUrl}/v1/user/pins/${pin.id}`;
309
+
310
+ if (!timelineToken) {
311
+ const errMsg = "Timeline token is required";
312
+ node.status({fill: "red", shape: "dot", text: "Missing token"});
313
+ if (done) done(errMsg);
314
+ return;
315
+ }
316
+
317
+ // Debug: Log final pin data
318
+ node.debug(`Sending pin: ${JSON.stringify(pin, null, 2)}`);
319
+ node.debug(`API URL: ${apiUrl}`);
320
+
321
+ // Final validation of the pin object
322
+ validatePin(pin, node);
323
+
324
+ axios.put(apiUrl, pin, {
325
+ headers: {
326
+ 'Content-Type': 'application/json',
327
+ 'X-User-Token': timelineToken
328
+ }
329
+ })
330
+ .then(response => {
331
+ // Set successful status - using "OK" as requested
332
+ node.status({fill: "green", shape: "dot", text: "OK"});
333
+
334
+ // Store the pin in our local storage
335
+ storePin(pin, timelineToken);
336
+
337
+ // Prepare the output message
338
+ msg.payload = {
339
+ success: true,
340
+ pin: pin,
341
+ response: response.data
342
+ };
343
+
344
+ send(msg);
345
+ if (done) done();
346
+ })
347
+ .catch(error => {
348
+ // Set error status
349
+ node.status({fill: "red", shape: "dot", text: "Error: " + (error.response ? error.response.status : error.message)});
350
+
351
+ // Debug: Log detailed error information
352
+ if (error.response) {
353
+ node.debug(`Error response: ${JSON.stringify(error.response.data)}`);
354
+ }
355
+
356
+ // Prepare error output without throwing an error to done callback
357
+ msg.payload = {
358
+ success: false,
359
+ error: error.message,
360
+ response: error.response ? error.response.data : null
361
+ };
362
+
363
+ send(msg);
364
+ // Don't use done callback for errors from API, as we're handling them in the output
365
+ if (done) done();
366
+ });
367
+ }
323
368
  } catch (err) {
324
369
  // For unexpected errors, use both the done callback and send the error
325
370
  node.status({fill: "red", shape: "dot", text: "Error: " + err.message});
@@ -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>
@@ -84,69 +84,95 @@ module.exports = function(RED) {
84
84
  })
85
85
  ]).then(() => {
86
86
  // Use overrides if provided, otherwise use config node values
87
- const apiUrl = `${node.apiUrlOverride || configNode.apiUrl}/v1/user/pins/${pinId}`;
87
+ const baseApiUrl = node.apiUrlOverride || configNode.apiUrl;
88
88
  const timelineToken = node.tokenOverride || configNode.credentials.timelineToken;
89
89
 
90
- if (!timelineToken) {
91
- node.error("Timeline token is required", msg);
92
- if (done) done("Timeline token is required");
93
- return;
94
- }
90
+ // Check if we're in local emulation mode (empty API URL)
91
+ const isLocalMode = !baseApiUrl || baseApiUrl.trim() === '';
95
92
 
96
- axios.delete(apiUrl, {
97
- headers: {
98
- 'X-User-Token': timelineToken
99
- }
100
- })
101
- .then(response => {
102
- node.status({fill: "green", shape: "dot", text: "Pin deleted"});
93
+ if (isLocalMode) {
94
+ // Local emulation mode - delete from local storage only
95
+ node.debug(`Local emulation mode - deleting pin locally`);
103
96
 
104
97
  // Remove the pin from our local storage
105
- removePin(pinId, timelineToken);
98
+ removePin(pinId, timelineToken || 'local');
99
+
100
+ node.status({fill: "green", shape: "dot", text: "Pin deleted (local)"});
106
101
 
107
- // Prepare the output message
108
102
  msg.payload = {
109
103
  success: true,
110
104
  pinId: pinId,
111
- response: response.data
105
+ mode: 'local',
106
+ message: 'Pin deleted from local storage'
112
107
  };
113
108
 
114
109
  send(msg);
115
110
  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"});
111
+ } else {
112
+ // Remote API mode
113
+ const apiUrl = `${baseApiUrl}/v1/user/pins/${pinId}`;
114
+
115
+ if (!timelineToken) {
116
+ node.error("Timeline token is required", msg);
117
+ if (done) done("Timeline token is required");
118
+ return;
119
+ }
120
+
121
+ axios.delete(apiUrl, {
122
+ headers: {
123
+ 'X-User-Token': timelineToken
124
+ }
125
+ })
126
+ .then(response => {
127
+ node.status({fill: "green", shape: "dot", text: "Pin deleted"});
122
128
 
123
- // Remove from local storage anyway
129
+ // Remove the pin from our local storage
124
130
  removePin(pinId, timelineToken);
125
131
 
132
+ // Prepare the output message
126
133
  msg.payload = {
127
134
  success: true,
128
135
  pinId: pinId,
129
- alreadyDeleted: true,
130
- message: "Pin not found on server, removed from local storage"
136
+ response: response.data
131
137
  };
132
138
 
133
139
  send(msg);
134
140
  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
- };
145
-
146
- send(msg);
147
- if (done) done(error);
148
- }
149
- });
141
+ })
142
+ .catch(error => {
143
+ // Handle 404 - pin already deleted
144
+ if (error.response && error.response.status === 404) {
145
+ node.warn(`Pin ${pinId} not found on server (404) - assuming already deleted`);
146
+ node.status({fill: "yellow", shape: "dot", text: "Pin already deleted"});
147
+
148
+ // Remove from local storage anyway
149
+ removePin(pinId, timelineToken);
150
+
151
+ msg.payload = {
152
+ success: true,
153
+ pinId: pinId,
154
+ alreadyDeleted: true,
155
+ message: "Pin not found on server, removed from local storage"
156
+ };
157
+
158
+ send(msg);
159
+ if (done) done();
160
+ } else {
161
+ // Other errors
162
+ node.status({fill: "red", shape: "dot", text: "Error: " + (error.response ? error.response.status : error.message)});
163
+
164
+ msg.payload = {
165
+ success: false,
166
+ pinId: pinId,
167
+ error: error.message,
168
+ response: error.response ? error.response.data : null
169
+ };
170
+
171
+ send(msg);
172
+ if (done) done(error);
173
+ }
174
+ });
175
+ }
150
176
  }).catch(err => {
151
177
  if (done) done(err);
152
178
  });
@@ -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
+