@skylord123/node-red-pebble-timeline 1.0.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/LICENSE +21 -0
- package/README.md +73 -0
- package/examples/README.md +7 -0
- package/examples/example.json +598 -0
- package/examples/example.png +0 -0
- package/package.json +25 -0
- package/pebble-timeline-add.html +926 -0
- package/pebble-timeline-add.js +744 -0
- package/pebble-timeline-config.html +63 -0
- package/pebble-timeline-config.js +13 -0
- package/pebble-timeline-delete.html +122 -0
- package/pebble-timeline-delete.js +190 -0
- package/pebble-timeline-list.html +151 -0
- package/pebble-timeline-list.js +195 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const fs = require('fs-extra');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Node-RED node for adding pins to the Pebble Timeline API
|
|
7
|
+
*
|
|
8
|
+
* This node supports the Rebble Timeline API for creating pins
|
|
9
|
+
* with all the features described in the Pebble/Rebble documentation.
|
|
10
|
+
*
|
|
11
|
+
* Required pin fields:
|
|
12
|
+
* - id: String (max 64 chars) - Unique identifier for the pin
|
|
13
|
+
* - time: String (ISO date-time) - Start time of the event
|
|
14
|
+
* - layout: Object - Description of the pin's visual appearance
|
|
15
|
+
* - type: String - The type of layout to use
|
|
16
|
+
* - title: String - The title of the pin
|
|
17
|
+
* - tinyIcon: String - URI of the pin's tiny icon
|
|
18
|
+
*/
|
|
19
|
+
module.exports = function(RED) {
|
|
20
|
+
function PebbleTimelineAddNode(config) {
|
|
21
|
+
RED.nodes.createNode(this, config);
|
|
22
|
+
const node = this;
|
|
23
|
+
|
|
24
|
+
// Get the config node
|
|
25
|
+
const configNode = RED.nodes.getNode(config.config);
|
|
26
|
+
if (!configNode) {
|
|
27
|
+
node.error("No Pebble Timeline configuration found");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
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
|
+
}
|
|
45
|
+
|
|
46
|
+
node.on('input', async function(msg, send, done) {
|
|
47
|
+
// Backwards compatibility with Node-RED 0.x
|
|
48
|
+
send = send || function() { node.send.apply(node, arguments) };
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Set initial status
|
|
52
|
+
node.status({fill: "blue", shape: "dot", text: "Processing..."});
|
|
53
|
+
|
|
54
|
+
// Create the base pin object from the incoming message
|
|
55
|
+
const pin = {};
|
|
56
|
+
|
|
57
|
+
// Add basic required properties from input message if available
|
|
58
|
+
if (msg.payload) {
|
|
59
|
+
if (typeof msg.payload === 'object') {
|
|
60
|
+
// Copy relevant properties from payload
|
|
61
|
+
if (msg.payload.id) pin.id = String(msg.payload.id); // Convert id to string
|
|
62
|
+
if (msg.payload.time) pin.time = msg.payload.time;
|
|
63
|
+
if (msg.payload.duration) pin.duration = msg.payload.duration;
|
|
64
|
+
|
|
65
|
+
// Start building the layout
|
|
66
|
+
if (!pin.layout) pin.layout = {};
|
|
67
|
+
if (!pin.layout.type) pin.layout.type = "genericPin";
|
|
68
|
+
|
|
69
|
+
// Add layout properties if present in payload
|
|
70
|
+
if (msg.payload.title) pin.layout.title = msg.payload.title;
|
|
71
|
+
if (msg.payload.body) pin.layout.body = msg.payload.body;
|
|
72
|
+
if (msg.payload.subtitle) pin.layout.subtitle = msg.payload.subtitle;
|
|
73
|
+
if (msg.payload.tinyIcon) pin.layout.tinyIcon = msg.payload.tinyIcon;
|
|
74
|
+
} else {
|
|
75
|
+
// If payload is not an object, use it as the body text
|
|
76
|
+
if (!pin.layout) pin.layout = {};
|
|
77
|
+
pin.layout.body = String(msg.payload);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Use topic as title if available and not already set
|
|
82
|
+
if (msg.topic && !pin.layout?.title) {
|
|
83
|
+
if (!pin.layout) pin.layout = {};
|
|
84
|
+
pin.layout.title = msg.topic;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Now override with node configuration if provided
|
|
88
|
+
await applyNodeConfiguration(pin, config, msg, node);
|
|
89
|
+
|
|
90
|
+
// Ensure required fields are present
|
|
91
|
+
if (!pin.id) {
|
|
92
|
+
// Generate a random ID if none provided - IMPORTANT: as a string
|
|
93
|
+
// ID must be max 64 chars according to the API docs
|
|
94
|
+
pin.id = `node-red-pin-${Date.now()}`;
|
|
95
|
+
} else {
|
|
96
|
+
// Ensure ID is a string and max 64 chars
|
|
97
|
+
pin.id = String(pin.id).substring(0, 64);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!pin.time) {
|
|
101
|
+
// Use current time if none provided
|
|
102
|
+
pin.time = new Date().toISOString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Ensure layout exists
|
|
106
|
+
if (!pin.layout) {
|
|
107
|
+
pin.layout = { type: "genericPin" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Ensure layout has type
|
|
111
|
+
if (!pin.layout.type) {
|
|
112
|
+
pin.layout.type = "genericPin";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Ensure layout has required fields based on type
|
|
116
|
+
if (!pin.layout.title) {
|
|
117
|
+
pin.layout.title = msg.topic || "Node-RED Pin";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Default tinyIcon if not set
|
|
121
|
+
if (!pin.layout.tinyIcon) {
|
|
122
|
+
// Set default icons based on layout type
|
|
123
|
+
switch (pin.layout.type) {
|
|
124
|
+
case "genericPin":
|
|
125
|
+
pin.layout.tinyIcon = "system://images/NOTIFICATION_FLAG";
|
|
126
|
+
break;
|
|
127
|
+
case "calendarPin":
|
|
128
|
+
pin.layout.tinyIcon = "system://images/TIMELINE_CALENDAR";
|
|
129
|
+
break;
|
|
130
|
+
case "sportsPin":
|
|
131
|
+
pin.layout.tinyIcon = "system://images/TIMELINE_SPORTS";
|
|
132
|
+
break;
|
|
133
|
+
case "weatherPin":
|
|
134
|
+
pin.layout.tinyIcon = "system://images/TIMELINE_WEATHER";
|
|
135
|
+
break;
|
|
136
|
+
default:
|
|
137
|
+
pin.layout.tinyIcon = "system://images/NOTIFICATION_FLAG";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Ensure layout-specific required fields are present
|
|
142
|
+
switch (pin.layout.type) {
|
|
143
|
+
case "weatherPin":
|
|
144
|
+
// weatherPin requires locationName
|
|
145
|
+
if (!pin.layout.locationName) {
|
|
146
|
+
pin.layout.locationName = "Unknown Location";
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
case "sportsPin":
|
|
150
|
+
// Ensure sports pin has required fields
|
|
151
|
+
if (!pin.layout.sportsGameState) {
|
|
152
|
+
// Default to pre-game if not specified
|
|
153
|
+
pin.layout.sportsGameState = "pre-game";
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Validate body text length (max 512 characters according to docs)
|
|
159
|
+
if (pin.layout.body && pin.layout.body.length > 512) {
|
|
160
|
+
pin.layout.body = pin.layout.body.substring(0, 512);
|
|
161
|
+
node.warn("Body text truncated to 512 characters");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Validate headings and paragraphs
|
|
165
|
+
if (pin.layout.headings && pin.layout.paragraphs) {
|
|
166
|
+
// Ensure paragraphs equals the number of headings
|
|
167
|
+
if (pin.layout.headings.length !== pin.layout.paragraphs.length) {
|
|
168
|
+
node.warn("Number of paragraphs must equal number of headings - adjusting");
|
|
169
|
+
|
|
170
|
+
// Adjust to make them equal
|
|
171
|
+
if (pin.layout.headings.length > pin.layout.paragraphs.length) {
|
|
172
|
+
// Add empty paragraphs
|
|
173
|
+
while (pin.layout.headings.length > pin.layout.paragraphs.length) {
|
|
174
|
+
pin.layout.paragraphs.push("");
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
// Trim paragraphs
|
|
178
|
+
pin.layout.paragraphs = pin.layout.paragraphs.slice(0, pin.layout.headings.length);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check total length of headings (max 128 chars)
|
|
183
|
+
let headingsLength = pin.layout.headings.join('').length + pin.layout.headings.length - 1;
|
|
184
|
+
if (headingsLength > 128) {
|
|
185
|
+
node.warn("Headings total length exceeds 128 characters - truncating");
|
|
186
|
+
// Truncate headings to fit
|
|
187
|
+
let newHeadings = [];
|
|
188
|
+
let totalLength = 0;
|
|
189
|
+
for (let i = 0; i < pin.layout.headings.length; i++) {
|
|
190
|
+
let heading = pin.layout.headings[i];
|
|
191
|
+
if (totalLength + heading.length + 1 > 128) {
|
|
192
|
+
// Truncate this heading
|
|
193
|
+
let remaining = 128 - totalLength - 1;
|
|
194
|
+
if (remaining > 0) {
|
|
195
|
+
newHeadings.push(heading.substring(0, remaining) + "...");
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
newHeadings.push(heading);
|
|
200
|
+
totalLength += heading.length + 1;
|
|
201
|
+
}
|
|
202
|
+
pin.layout.headings = newHeadings;
|
|
203
|
+
// Also adjust paragraphs to match
|
|
204
|
+
pin.layout.paragraphs = pin.layout.paragraphs.slice(0, pin.layout.headings.length);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check total length of paragraphs (max 1024 chars)
|
|
208
|
+
let paragraphsLength = pin.layout.paragraphs.join('').length + pin.layout.paragraphs.length - 1;
|
|
209
|
+
if (paragraphsLength > 1024) {
|
|
210
|
+
node.warn("Paragraphs total length exceeds 1024 characters - truncating");
|
|
211
|
+
// Truncate paragraphs to fit
|
|
212
|
+
let newParagraphs = [];
|
|
213
|
+
let totalLength = 0;
|
|
214
|
+
for (let i = 0; i < pin.layout.paragraphs.length; i++) {
|
|
215
|
+
let paragraph = pin.layout.paragraphs[i];
|
|
216
|
+
if (totalLength + paragraph.length + 1 > 1024) {
|
|
217
|
+
// Truncate this paragraph
|
|
218
|
+
let remaining = 1024 - totalLength - 1;
|
|
219
|
+
if (remaining > 0) {
|
|
220
|
+
newParagraphs.push(paragraph.substring(0, remaining) + "...");
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
newParagraphs.push(paragraph);
|
|
225
|
+
totalLength += paragraph.length + 1;
|
|
226
|
+
}
|
|
227
|
+
pin.layout.paragraphs = newParagraphs;
|
|
228
|
+
// Also adjust headings to match
|
|
229
|
+
pin.layout.headings = pin.layout.headings.slice(0, pin.layout.paragraphs.length);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Validate reminders (max 3 according to docs)
|
|
234
|
+
if (pin.reminders && pin.reminders.length > 3) {
|
|
235
|
+
pin.reminders = pin.reminders.slice(0, 3);
|
|
236
|
+
node.warn("Number of reminders truncated to maximum of 3");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check for server override options
|
|
240
|
+
let apiUrlOverride = null;
|
|
241
|
+
let tokenOverride = null;
|
|
242
|
+
|
|
243
|
+
// Process API URL override
|
|
244
|
+
if (config.apiUrl && config.apiUrl !== "null") {
|
|
245
|
+
try {
|
|
246
|
+
apiUrlOverride = await evaluateSingleProperty(config.apiUrl, config.apiUrlType, node, msg);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
node.warn(`Error evaluating API URL override: ${err.message}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Process token override
|
|
253
|
+
if (config.token && config.token !== "null") {
|
|
254
|
+
try {
|
|
255
|
+
tokenOverride = await evaluateSingleProperty(config.token, config.tokenType, node, msg);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
node.warn(`Error evaluating token override: ${err.message}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Use overrides if provided, otherwise use config node values
|
|
262
|
+
const apiUrl = `${apiUrlOverride || configNode.apiUrl}/v1/user/pins/${pin.id}`;
|
|
263
|
+
const timelineToken = tokenOverride || configNode.credentials.timelineToken;
|
|
264
|
+
|
|
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}`);
|
|
276
|
+
|
|
277
|
+
// Final validation of the pin object
|
|
278
|
+
validatePin(pin, node);
|
|
279
|
+
|
|
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"});
|
|
289
|
+
|
|
290
|
+
// Store the pin in our local storage
|
|
291
|
+
storePin(pin);
|
|
292
|
+
|
|
293
|
+
// Prepare the output message
|
|
294
|
+
msg.payload = {
|
|
295
|
+
success: true,
|
|
296
|
+
pin: pin,
|
|
297
|
+
response: response.data
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
send(msg);
|
|
301
|
+
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)});
|
|
306
|
+
|
|
307
|
+
// Debug: Log detailed error information
|
|
308
|
+
if (error.response) {
|
|
309
|
+
node.debug(`Error response: ${JSON.stringify(error.response.data)}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
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
|
+
};
|
|
318
|
+
|
|
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
|
+
});
|
|
323
|
+
} catch (err) {
|
|
324
|
+
// For unexpected errors, use both the done callback and send the error
|
|
325
|
+
node.status({fill: "red", shape: "dot", text: "Error: " + err.message});
|
|
326
|
+
|
|
327
|
+
msg.payload = {
|
|
328
|
+
success: false,
|
|
329
|
+
error: err.message
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Send the error in the message but DON'T pass it to done
|
|
333
|
+
send(msg);
|
|
334
|
+
if (done) done();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Helper to store a pin in local storage
|
|
339
|
+
function storePin(pin) {
|
|
340
|
+
const timelineToken = tokenOverride || configNode.credentials.timelineToken;
|
|
341
|
+
|
|
342
|
+
// Initialize the token's pins array if it doesn't exist
|
|
343
|
+
if (!pinsData[timelineToken]) {
|
|
344
|
+
pinsData[timelineToken] = [];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Remove any existing pin with the same ID for this token
|
|
348
|
+
pinsData[timelineToken] = pinsData[timelineToken].filter(p => p.id !== pin.id);
|
|
349
|
+
|
|
350
|
+
// Add the new pin with a timestamp for when it was added
|
|
351
|
+
pinsData[timelineToken].push({
|
|
352
|
+
...pin,
|
|
353
|
+
_stored: new Date().toISOString()
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Clean up old pins (older than 1 month) from all tokens
|
|
357
|
+
cleanupOldPins();
|
|
358
|
+
|
|
359
|
+
// Write the pins to the file
|
|
360
|
+
try {
|
|
361
|
+
fs.writeFileSync(pinsFile, JSON.stringify(pinsData, null, 2));
|
|
362
|
+
} catch (error) {
|
|
363
|
+
node.warn(`Error saving pins to file: ${error.message}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Helper to clean up pins older than 1 month
|
|
368
|
+
function cleanupOldPins() {
|
|
369
|
+
const oneMonthAgo = new Date();
|
|
370
|
+
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
|
371
|
+
|
|
372
|
+
// Iterate through all tokens
|
|
373
|
+
Object.keys(pinsData).forEach(token => {
|
|
374
|
+
// Filter out pins older than 1 month
|
|
375
|
+
const initialCount = pinsData[token].length;
|
|
376
|
+
pinsData[token] = pinsData[token].filter(pin => {
|
|
377
|
+
const storedDate = new Date(pin._stored);
|
|
378
|
+
return storedDate >= oneMonthAgo;
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Log if pins were removed
|
|
382
|
+
if (pinsData[token].length < initialCount) {
|
|
383
|
+
node.debug(`Removed ${initialCount - pinsData[token].length} old pins for token ${token.substring(0, 8)}...`);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Apply node configuration to the pin
|
|
389
|
+
async function applyNodeConfiguration(pin, config, msg, node) {
|
|
390
|
+
try {
|
|
391
|
+
// Basic pin properties from configuration
|
|
392
|
+
const configId = await evaluateSingleProperty(config.pinId, config.pinIdType, node, msg);
|
|
393
|
+
if (configId !== undefined && configId !== null) pin.id = String(configId); // Convert to string
|
|
394
|
+
|
|
395
|
+
const configTime = await evaluateSingleProperty(config.time, config.timeType, node, msg);
|
|
396
|
+
if (configTime !== undefined && configTime !== null) pin.time = configTime;
|
|
397
|
+
|
|
398
|
+
const configDuration = await evaluateSingleProperty(config.duration, config.durationType, node, msg);
|
|
399
|
+
if (configDuration !== undefined && configDuration !== null) pin.duration = Number(configDuration);
|
|
400
|
+
|
|
401
|
+
// Ensure layout exists
|
|
402
|
+
if (!pin.layout) pin.layout = {};
|
|
403
|
+
|
|
404
|
+
// Set layout type from configuration
|
|
405
|
+
pin.layout.type = config.layoutType;
|
|
406
|
+
|
|
407
|
+
// Add layout properties from configuration
|
|
408
|
+
const configTitle = await evaluateSingleProperty(config.title, config.titleType, node, msg);
|
|
409
|
+
if (configTitle !== undefined && configTitle !== null) pin.layout.title = configTitle;
|
|
410
|
+
|
|
411
|
+
const configSubtitle = await evaluateSingleProperty(config.subtitle, config.subtitleType, node, msg);
|
|
412
|
+
if (configSubtitle !== undefined && configSubtitle !== null) pin.layout.subtitle = configSubtitle;
|
|
413
|
+
|
|
414
|
+
const configBody = await evaluateSingleProperty(config.body, config.bodyType, node, msg);
|
|
415
|
+
if (configBody !== undefined && configBody !== null) pin.layout.body = configBody;
|
|
416
|
+
|
|
417
|
+
const configTinyIcon = await evaluateSingleProperty(config.tinyIcon, config.tinyIconType, node, msg);
|
|
418
|
+
if (configTinyIcon !== undefined && configTinyIcon !== null) pin.layout.tinyIcon = configTinyIcon;
|
|
419
|
+
|
|
420
|
+
const configSmallIcon = await evaluateSingleProperty(config.smallIcon, config.smallIconType, node, msg);
|
|
421
|
+
if (configSmallIcon !== undefined && configSmallIcon !== null) pin.layout.smallIcon = configSmallIcon;
|
|
422
|
+
|
|
423
|
+
const configLargeIcon = await evaluateSingleProperty(config.largeIcon, config.largeIconType, node, msg);
|
|
424
|
+
if (configLargeIcon !== undefined && configLargeIcon !== null) pin.layout.largeIcon = configLargeIcon;
|
|
425
|
+
|
|
426
|
+
// Colors
|
|
427
|
+
const configPrimaryColor = await evaluateSingleProperty(config.primaryColor, config.primaryColorType, node, msg);
|
|
428
|
+
if (configPrimaryColor !== undefined && configPrimaryColor !== null) pin.layout.primaryColor = configPrimaryColor;
|
|
429
|
+
|
|
430
|
+
const configSecondaryColor = await evaluateSingleProperty(config.secondaryColor, config.secondaryColorType, node, msg);
|
|
431
|
+
if (configSecondaryColor !== undefined && configSecondaryColor !== null) pin.layout.secondaryColor = configSecondaryColor;
|
|
432
|
+
|
|
433
|
+
const configBackgroundColor = await evaluateSingleProperty(config.backgroundColor, config.backgroundColorType, node, msg);
|
|
434
|
+
if (configBackgroundColor !== undefined && configBackgroundColor !== null) pin.layout.backgroundColor = configBackgroundColor;
|
|
435
|
+
|
|
436
|
+
// Layout specific properties
|
|
437
|
+
if (config.layoutType === 'calendarPin' || config.layoutType === 'weatherPin') {
|
|
438
|
+
const configLocationName = await evaluateSingleProperty(config.locationName, config.locationNameType, node, msg);
|
|
439
|
+
if (configLocationName !== undefined && configLocationName !== null) pin.layout.locationName = configLocationName;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (config.layoutType === 'weatherPin') {
|
|
443
|
+
const configShortTitle = await evaluateSingleProperty(config.shortTitle, config.shortTitleType, node, msg);
|
|
444
|
+
if (configShortTitle !== undefined && configShortTitle !== null) pin.layout.shortTitle = configShortTitle;
|
|
445
|
+
|
|
446
|
+
const configShortSubtitle = await evaluateSingleProperty(config.shortSubtitle, config.shortSubtitleType, node, msg);
|
|
447
|
+
if (configShortSubtitle !== undefined && configShortSubtitle !== null) pin.layout.shortSubtitle = configShortSubtitle;
|
|
448
|
+
|
|
449
|
+
if (config.displayTime !== 'pin') {
|
|
450
|
+
pin.layout.displayTime = config.displayTime;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (config.layoutType === 'sportsPin') {
|
|
455
|
+
const configRankAway = await evaluateSingleProperty(config.rankAway, config.rankAwayType, node, msg);
|
|
456
|
+
if (configRankAway !== undefined && configRankAway !== null) pin.layout.rankAway = String(configRankAway);
|
|
457
|
+
|
|
458
|
+
const configRankHome = await evaluateSingleProperty(config.rankHome, config.rankHomeType, node, msg);
|
|
459
|
+
if (configRankHome !== undefined && configRankHome !== null) pin.layout.rankHome = String(configRankHome);
|
|
460
|
+
|
|
461
|
+
const configNameAway = await evaluateSingleProperty(config.nameAway, config.nameAwayType, node, msg);
|
|
462
|
+
if (configNameAway !== undefined && configNameAway !== null) pin.layout.nameAway = String(configNameAway);
|
|
463
|
+
|
|
464
|
+
const configNameHome = await evaluateSingleProperty(config.nameHome, config.nameHomeType, node, msg);
|
|
465
|
+
if (configNameHome !== undefined && configNameHome !== null) pin.layout.nameHome = String(configNameHome);
|
|
466
|
+
|
|
467
|
+
const configRecordAway = await evaluateSingleProperty(config.recordAway, config.recordAwayType, node, msg);
|
|
468
|
+
if (configRecordAway !== undefined && configRecordAway !== null) pin.layout.recordAway = String(configRecordAway);
|
|
469
|
+
|
|
470
|
+
const configRecordHome = await evaluateSingleProperty(config.recordHome, config.recordHomeType, node, msg);
|
|
471
|
+
if (configRecordHome !== undefined && configRecordHome !== null) pin.layout.recordHome = String(configRecordHome);
|
|
472
|
+
|
|
473
|
+
const configScoreAway = await evaluateSingleProperty(config.scoreAway, config.scoreAwayType, node, msg);
|
|
474
|
+
if (configScoreAway !== undefined && configScoreAway !== null) pin.layout.scoreAway = String(configScoreAway);
|
|
475
|
+
|
|
476
|
+
const configScoreHome = await evaluateSingleProperty(config.scoreHome, config.scoreHomeType, node, msg);
|
|
477
|
+
if (configScoreHome !== undefined && configScoreHome !== null) pin.layout.scoreHome = String(configScoreHome);
|
|
478
|
+
|
|
479
|
+
pin.layout.sportsGameState = config.sportsGameState;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Advanced options
|
|
483
|
+
const configHeadings = await evaluateSingleProperty(config.headings, config.headingsType, node, msg);
|
|
484
|
+
if (configHeadings !== undefined && configHeadings !== null && configHeadings !== "null") {
|
|
485
|
+
pin.layout.headings = Array.isArray(configHeadings) ? configHeadings : JSON.parse(configHeadings);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const configParagraphs = await evaluateSingleProperty(config.paragraphs, config.paragraphsType, node, msg);
|
|
489
|
+
if (configParagraphs !== undefined && configParagraphs !== null && configParagraphs !== "null") {
|
|
490
|
+
pin.layout.paragraphs = Array.isArray(configParagraphs) ? configParagraphs : JSON.parse(configParagraphs);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const configLastUpdated = await evaluateSingleProperty(config.lastUpdated, config.lastUpdatedType, node, msg);
|
|
494
|
+
if (configLastUpdated !== undefined && configLastUpdated !== null && configLastUpdated !== "null") {
|
|
495
|
+
pin.layout.lastUpdated = configLastUpdated;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Handle create notification
|
|
499
|
+
if (config.createNotification) {
|
|
500
|
+
const createNotification = {
|
|
501
|
+
layout: {
|
|
502
|
+
type: 'genericNotification'
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const configCreateTitle = await evaluateSingleProperty(config.createNotificationTitle, config.createNotificationTitleType, node, msg);
|
|
507
|
+
if (configCreateTitle !== undefined && configCreateTitle !== null) createNotification.layout.title = configCreateTitle;
|
|
508
|
+
|
|
509
|
+
const configCreateBody = await evaluateSingleProperty(config.createNotificationBody, config.createNotificationBodyType, node, msg);
|
|
510
|
+
if (configCreateBody !== undefined && configCreateBody !== null) createNotification.layout.body = configCreateBody;
|
|
511
|
+
|
|
512
|
+
const configCreateIcon = await evaluateSingleProperty(config.createNotificationTinyIcon, config.createNotificationTinyIconType, node, msg);
|
|
513
|
+
if (configCreateIcon !== undefined && configCreateIcon !== null) {
|
|
514
|
+
createNotification.layout.tinyIcon = configCreateIcon;
|
|
515
|
+
} else {
|
|
516
|
+
// Default tinyIcon for notification
|
|
517
|
+
createNotification.layout.tinyIcon = "system://images/NOTIFICATION_FLAG";
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Set default title if not provided
|
|
521
|
+
if (!createNotification.layout.title) {
|
|
522
|
+
createNotification.layout.title = "New Event";
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Validate notification body length
|
|
526
|
+
if (createNotification.layout.body && createNotification.layout.body.length > 512) {
|
|
527
|
+
createNotification.layout.body = createNotification.layout.body.substring(0, 512);
|
|
528
|
+
node.warn("Notification body text truncated to 512 characters");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
pin.createNotification = createNotification;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Handle update notification
|
|
535
|
+
if (config.updateNotification) {
|
|
536
|
+
const updateNotification = {
|
|
537
|
+
layout: {
|
|
538
|
+
type: 'genericNotification'
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const configUpdateTitle = await evaluateSingleProperty(config.updateNotificationTitle, config.updateNotificationTitleType, node, msg);
|
|
543
|
+
if (configUpdateTitle !== undefined && configUpdateTitle !== null) updateNotification.layout.title = configUpdateTitle;
|
|
544
|
+
|
|
545
|
+
const configUpdateBody = await evaluateSingleProperty(config.updateNotificationBody, config.updateNotificationBodyType, node, msg);
|
|
546
|
+
if (configUpdateBody !== undefined && configUpdateBody !== null) updateNotification.layout.body = configUpdateBody;
|
|
547
|
+
|
|
548
|
+
const configUpdateIcon = await evaluateSingleProperty(config.updateNotificationTinyIcon, config.updateNotificationTinyIconType, node, msg);
|
|
549
|
+
if (configUpdateIcon !== undefined && configUpdateIcon !== null) {
|
|
550
|
+
updateNotification.layout.tinyIcon = configUpdateIcon;
|
|
551
|
+
} else {
|
|
552
|
+
// Default tinyIcon for notification
|
|
553
|
+
updateNotification.layout.tinyIcon = "system://images/NOTIFICATION_FLAG";
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const configUpdateTime = await evaluateSingleProperty(config.updateNotificationTime, config.updateNotificationTimeType, node, msg);
|
|
557
|
+
if (configUpdateTime !== undefined && configUpdateTime !== null) updateNotification.time = configUpdateTime;
|
|
558
|
+
|
|
559
|
+
// Set default title if not provided
|
|
560
|
+
if (!updateNotification.layout.title) {
|
|
561
|
+
updateNotification.layout.title = "Event Updated";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Validate notification body length
|
|
565
|
+
if (updateNotification.layout.body && updateNotification.layout.body.length > 512) {
|
|
566
|
+
updateNotification.layout.body = updateNotification.layout.body.substring(0, 512);
|
|
567
|
+
node.warn("Update notification body text truncated to 512 characters");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Ensure update notification has a time field
|
|
571
|
+
if (!updateNotification.time) {
|
|
572
|
+
updateNotification.time = new Date().toISOString();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
pin.updateNotification = updateNotification;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Handle reminders
|
|
579
|
+
if (config.reminders) {
|
|
580
|
+
let reminderData = await evaluateSingleProperty(config.reminderData, config.reminderDataType, node, msg);
|
|
581
|
+
if (reminderData !== undefined && reminderData !== null && reminderData !== "null") {
|
|
582
|
+
if (typeof reminderData === 'string') {
|
|
583
|
+
try {
|
|
584
|
+
reminderData = JSON.parse(reminderData);
|
|
585
|
+
} catch (e) {
|
|
586
|
+
node.warn(`Failed to parse reminders: ${e.message}`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (Array.isArray(reminderData)) {
|
|
591
|
+
// Limit to max 3 reminders as per API docs
|
|
592
|
+
if (reminderData.length > 3) {
|
|
593
|
+
reminderData = reminderData.slice(0, 3);
|
|
594
|
+
node.warn("Number of reminders limited to 3 as per API requirements");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Validate and fix each reminder
|
|
598
|
+
const processedReminders = reminderData.map(reminder => {
|
|
599
|
+
// Ensure required fields
|
|
600
|
+
if (!reminder.time) {
|
|
601
|
+
node.warn("Reminder missing required 'time' field - using current time");
|
|
602
|
+
reminder.time = new Date().toISOString();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (!reminder.layout) reminder.layout = {};
|
|
606
|
+
if (!reminder.layout.type) reminder.layout.type = 'genericReminder';
|
|
607
|
+
if (!reminder.layout.title) reminder.layout.title = 'Reminder';
|
|
608
|
+
if (!reminder.layout.tinyIcon) reminder.layout.tinyIcon = 'system://images/NOTIFICATION_REMINDER';
|
|
609
|
+
return reminder;
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
pin.reminders = processedReminders;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Handle actions
|
|
618
|
+
if (config.actions) {
|
|
619
|
+
let actionData = await evaluateSingleProperty(config.actionData, config.actionDataType, node, msg);
|
|
620
|
+
if (actionData !== undefined && actionData !== null && actionData !== "null") {
|
|
621
|
+
if (typeof actionData === 'string') {
|
|
622
|
+
try {
|
|
623
|
+
actionData = JSON.parse(actionData);
|
|
624
|
+
} catch (e) {
|
|
625
|
+
node.warn(`Failed to parse actions: ${e.message}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (Array.isArray(actionData)) {
|
|
630
|
+
// Validate each action
|
|
631
|
+
const processedActions = actionData.map(action => {
|
|
632
|
+
// Ensure required fields
|
|
633
|
+
if (!action.title) {
|
|
634
|
+
node.warn("Action missing required 'title' field - adding default");
|
|
635
|
+
action.title = "Action";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (!action.type) {
|
|
639
|
+
node.warn("Action missing required 'type' field - defaulting to openWatchApp");
|
|
640
|
+
action.type = "openWatchApp";
|
|
641
|
+
|
|
642
|
+
// Add launchCode if it's openWatchApp type and missing
|
|
643
|
+
if (!action.launchCode) {
|
|
644
|
+
action.launchCode = 0;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Validate HTTP action
|
|
649
|
+
if (action.type === "http") {
|
|
650
|
+
if (!action.url) {
|
|
651
|
+
node.warn("HTTP action missing required 'url' field");
|
|
652
|
+
action.url = "https://example.com";
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Set default method if not provided
|
|
656
|
+
if (!action.method) {
|
|
657
|
+
action.method = "POST";
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Validate method with body
|
|
661
|
+
if ((action.bodyText || action.bodyJSON) &&
|
|
662
|
+
(action.method === "GET" || action.method === "DELETE")) {
|
|
663
|
+
node.warn(`HTTP ${action.method} method cannot have a body - removing body`);
|
|
664
|
+
delete action.bodyText;
|
|
665
|
+
delete action.bodyJSON;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Ensure bodyText and bodyJSON are not both present
|
|
669
|
+
if (action.bodyText && action.bodyJSON) {
|
|
670
|
+
node.warn("HTTP action cannot have both bodyText and bodyJSON - removing bodyText");
|
|
671
|
+
delete action.bodyText;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return action;
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
pin.actions = processedActions;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
} catch (err) {
|
|
683
|
+
node.warn(`Error applying configuration: ${err.message}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Helper function to evaluate a single property and return a Promise
|
|
688
|
+
function evaluateSingleProperty(value, type, node, msg) {
|
|
689
|
+
return new Promise((resolve, reject) => {
|
|
690
|
+
if (!value || value === "null" || !type) {
|
|
691
|
+
resolve(undefined);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
RED.util.evaluateNodeProperty(value, type, node, msg, (err, result) => {
|
|
696
|
+
if (err) {
|
|
697
|
+
reject(err);
|
|
698
|
+
} else {
|
|
699
|
+
resolve(result);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Helper function to validate the final pin object
|
|
706
|
+
function validatePin(pin, node) {
|
|
707
|
+
// Check required fields
|
|
708
|
+
if (!pin.id) {
|
|
709
|
+
node.warn("Pin missing required 'id' field");
|
|
710
|
+
} else if (pin.id.length > 64) {
|
|
711
|
+
pin.id = pin.id.substring(0, 64);
|
|
712
|
+
node.warn("Pin ID truncated to 64 characters");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (!pin.time) {
|
|
716
|
+
node.warn("Pin missing required 'time' field");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (!pin.layout) {
|
|
720
|
+
node.warn("Pin missing required 'layout' field");
|
|
721
|
+
} else {
|
|
722
|
+
if (!pin.layout.type) {
|
|
723
|
+
node.warn("Pin layout missing required 'type' field");
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (!pin.layout.title) {
|
|
727
|
+
node.warn("Pin layout missing required 'title' field");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!pin.layout.tinyIcon) {
|
|
731
|
+
node.warn("Pin layout missing required 'tinyIcon' field");
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
node.on('close', function() {
|
|
737
|
+
// Clean up any resources
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
RED.nodes.registerType("pebble-timeline-add", PebbleTimelineAddNode, {
|
|
742
|
+
credentials: {}
|
|
743
|
+
});
|
|
744
|
+
};
|