@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 +5 -1
- package/pebble-timeline-add.js +92 -47
- package/pebble-timeline-config.html +6 -2
- package/pebble-timeline-delete.js +66 -40
- package/pebble-timeline-validation.js +145 -0
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skylord123/node-red-pebble-timeline",
|
|
3
|
-
"version": "1.
|
|
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",
|
package/pebble-timeline-add.js
CHANGED
|
@@ -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
|
|
263
|
+
const baseApiUrl = apiUrlOverride || configNode.apiUrl;
|
|
263
264
|
const timelineToken = tokenOverride || configNode.credentials.timelineToken;
|
|
264
265
|
|
|
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}`);
|
|
266
|
+
// Check if we're in local emulation mode (empty API URL)
|
|
267
|
+
const isLocalMode = !baseApiUrl || baseApiUrl.trim() === '';
|
|
276
268
|
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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"});
|
|
274
|
+
// Validate the pin using local validation
|
|
275
|
+
const validationResult = pinValid(pin.id, pin);
|
|
289
276
|
|
|
290
|
-
|
|
291
|
-
|
|
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:
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
297
|
+
msg.payload = {
|
|
298
|
+
success: true,
|
|
299
|
+
pin: pin,
|
|
300
|
+
mode: 'local',
|
|
301
|
+
message: 'Pin validated and stored locally'
|
|
302
|
+
};
|
|
318
303
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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:
|
|
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
|
|
87
|
+
const baseApiUrl = node.apiUrlOverride || configNode.apiUrl;
|
|
88
88
|
const timelineToken = node.tokenOverride || configNode.credentials.timelineToken;
|
|
89
89
|
|
|
90
|
-
if (
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
node.
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
|