@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.
@@ -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
+ };