@pixlcore/xycli 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.
Files changed (63) hide show
  1. package/README.md +543 -0
  2. package/docs/help.md +2587 -0
  3. package/docs/screenshots.md +219 -0
  4. package/docs/sync.md +920 -0
  5. package/docs/tutorial.md +204 -0
  6. package/index.js +444 -0
  7. package/lib/alerts.js +735 -0
  8. package/lib/apikey.js +425 -0
  9. package/lib/buckets.js +607 -0
  10. package/lib/categories.js +323 -0
  11. package/lib/channels.js +290 -0
  12. package/lib/config.js +86 -0
  13. package/lib/dashboard.js +237 -0
  14. package/lib/doc.js +131 -0
  15. package/lib/events.js +1314 -0
  16. package/lib/groups.js +1038 -0
  17. package/lib/jobs.js +1254 -0
  18. package/lib/log.js +125 -0
  19. package/lib/marketplace.js +386 -0
  20. package/lib/monitors.js +412 -0
  21. package/lib/plugins.js +491 -0
  22. package/lib/secrets.js +441 -0
  23. package/lib/servers.js +1502 -0
  24. package/lib/snapshots.js +498 -0
  25. package/lib/sync.js +963 -0
  26. package/lib/system.js +612 -0
  27. package/lib/tags.js +250 -0
  28. package/lib/tickets.js +681 -0
  29. package/lib/transfer.js +447 -0
  30. package/lib/utils.js +2745 -0
  31. package/lib/webhook.js +414 -0
  32. package/logo.webp +0 -0
  33. package/package.json +38 -0
  34. package/test/README.md +97 -0
  35. package/test/api-validation.test.js +170 -0
  36. package/test/categories.test.js +246 -0
  37. package/test/channels.test.js +209 -0
  38. package/test/config.test.js +96 -0
  39. package/test/doc.test.js +84 -0
  40. package/test/groups.test.js +211 -0
  41. package/test/help.test.js +26 -0
  42. package/test/helpers/common.js +127 -0
  43. package/test/helpers/pagination-preload.cjs +18 -0
  44. package/test/log.test.js +103 -0
  45. package/test/markdown.test.js +61 -0
  46. package/test/marketplace.test.js +229 -0
  47. package/test/monitors.test.js +227 -0
  48. package/test/pagination.test.js +138 -0
  49. package/test/plugins.test.js +351 -0
  50. package/test/secrets.test.js +271 -0
  51. package/test/servers.test.js +303 -0
  52. package/test/snapshots.test.js +167 -0
  53. package/test/sync-unit.test.js +446 -0
  54. package/test/sync.test.js +542 -0
  55. package/test/system.test.js +94 -0
  56. package/test/tags.test.js +230 -0
  57. package/test/tickets.test.js +261 -0
  58. package/test/toast.test.js +73 -0
  59. package/test/transfer-apikey.test.js +96 -0
  60. package/test/transfer-workflow.test.js +122 -0
  61. package/test/transfer.test.js +314 -0
  62. package/test/webhook.test.js +332 -0
  63. package/test.js +49 -0
package/lib/events.js ADDED
@@ -0,0 +1,1314 @@
1
+ // Events Layer
2
+
3
+ const cli = require('pixl-cli');
4
+ const Tools = cli.Tools;
5
+ const chalk = cli.chalk;
6
+
7
+ module.exports = {
8
+
9
+ async cmd_events() {
10
+ // alias for cmd_get_events
11
+ await this.cmd_get_events();
12
+ },
13
+
14
+ async cmd_event() {
15
+ // router, e.g. `xy event get`
16
+ var cmd = this.args.other.shift();
17
+ switch (cmd) {
18
+ case 'list': await this.cmd_get_events(); break;
19
+ case 'get': await this.cmd_get_event(); break;
20
+ case 'run': await this.cmd_run_event(); break;
21
+
22
+ case 'create': await this.cmd_create_event(); break;
23
+ case 'update': await this.cmd_update_event(); break;
24
+ case 'delete': await this.cmd_delete_event(); break;
25
+
26
+ default:
27
+ // assume cmd_get_event, and other arg was the id
28
+ this.args.other.unshift(cmd);
29
+ await this.cmd_get_event();
30
+ break;
31
+ } // switch cmd
32
+ },
33
+
34
+ async cmd_run() {
35
+ // alias for `xy event run`
36
+ await this.cmd_run_event();
37
+ },
38
+
39
+ async cmd_get_events() {
40
+ // show event list
41
+ // args: raw, (any filters)
42
+ await this.getMultiple();
43
+
44
+ // apply user filters
45
+ var is_filtered = false;
46
+ var events = this.events;
47
+
48
+ // xy events foobar == search for title:foobar
49
+ if (this.args.other.length) this.args.title = this.args.other.join(' ');
50
+ delete this.args.other;
51
+
52
+ // special case for category, plugin, target
53
+ if (this.args.category) {
54
+ var cats = this.findObjectsFuzzy( this.categories, { id: this.args.category, title: this.args.category }, 1 );
55
+ events = events.filter( event => !!Tools.findObject(cats, { id: event.category }) );
56
+ is_filtered = true;
57
+ delete this.args.category;
58
+ }
59
+ if (this.args.plugin) {
60
+ var plugs = this.findObjectsFuzzy( this.plugins, { id: this.args.plugin, title: this.args.plugin }, 1 );
61
+ events = events.filter( event => !!Tools.findObject(plugs, { id: event.plugin }) );
62
+ is_filtered = true;
63
+ delete this.args.plugin;
64
+ }
65
+ if (this.args.target) {
66
+ var grps = this.findObjectsFuzzy( this.groups, { id: this.args.target, title: this.args.target }, 1 );
67
+ if (grps.length) {
68
+ events = events.filter( event => this.includesAny(event.targets || [], grps.map( grp => grp.id )) );
69
+ }
70
+ else {
71
+ var srvs = this.findObjectsFuzzy( Object.values(this.servers), { id: this.args.target, title: this.args.target }, 1 );
72
+ events = events.filter( event => this.includesAny(event.targets || [], srvs.map( srv => srv.id )) );
73
+ }
74
+ is_filtered = true;
75
+ delete this.args.target;
76
+ }
77
+
78
+ if (Tools.numKeys(this.args)) {
79
+ // if any args are left, consider them to be filters
80
+ is_filtered = true;
81
+ events = this.findObjectsFuzzy( events, this.args );
82
+ }
83
+
84
+ if (this.format.match(/json/)) {
85
+ // early exit for json mode
86
+ return this.jsonOutput(events);
87
+ }
88
+
89
+ // full cli table mode
90
+ if (is_filtered) println( "\n " + this.color('theme').bold("FILTERED EVENTS") + "\n " + gray('(' + Tools.commify(events.length) + ' of ' + Tools.commify(this.events.length) + ' total events)') );
91
+ else println( "\n " + this.color('theme').bold("ALL EVENTS") + "\n " + gray('(' + Tools.commify(events.length) + ' total events)') );
92
+
93
+ // split up by sorted category so we can drop that column
94
+ Tools.sortBy( this.categories, 'sort_order', { type: 'number', dir: 1 } ).forEach( category => {
95
+ var cat_events = Tools.findObjects( events, { category: category.id } );
96
+ if (!cat_events.length) return;
97
+ Tools.sortBy(cat_events, 'title');
98
+
99
+ println( "\n " + cli.emoji('📂') + ' ' + this.getNiceCategory(category.id) );
100
+
101
+ var rows = [
102
+ ['Event ID', 'Title', 'Plugin', 'Targets', 'Triggers', 'Status', 'Modified']
103
+ ];
104
+ cat_events.forEach( event => {
105
+ rows.push([
106
+ this.getNiceEventID(event.id),
107
+ this.getNiceEvent(event.id),
108
+ this.getNicePlugin(event.plugin),
109
+ this.getNiceTargets(event.targets || []),
110
+ summarize_event_timings(event),
111
+ this.getNiceEventStatus(event),
112
+ // this.getNiceDateTime(event.modified)
113
+ this.getTextFromSecondsShort(this.epoch - event.modified) + ' ago'
114
+ ]);
115
+ } );
116
+ var clr = gray;
117
+ if (category.color && (category.color != 'plain')) clr = this.color(category.color);
118
+ print( "" + this.table(rows, { borderStyles: [clr] }) + "\n" );
119
+ } ); // foreach cat
120
+
121
+ if (!events.length) {
122
+ if (is_filtered) print( gray(" (No events found matching your filters.)") + "\n");
123
+ else print( gray(" (No events found.)") + "\n");
124
+ }
125
+
126
+ // suggested commands
127
+ this.printSuggestedCommands({
128
+ "Specific category": "xy events --category ID_OR_TITLE",
129
+ "Specific plugin": "xy events --plugin ID_OR_TITLE",
130
+ "Specific group": "xy events --target ID_OR_TITLE",
131
+ "Get event details": "xy event ID_OR_TITLE"
132
+ });
133
+ },
134
+
135
+ async cmd_get_event() {
136
+ // Only the related-job variants use pagination. Remove their page controls
137
+ // before resolving an event from named filters.
138
+ if (this.args.upcoming || this.args.completed || this.args.queued || this.args.queue) this.prepSearchArgs();
139
+
140
+ // get single event and display details
141
+ await this.getMultiple();
142
+
143
+ if (!this.events.length) {
144
+ return this.die( "No events found." );
145
+ }
146
+
147
+ var event = null;
148
+ if (this.args.other.length) {
149
+ // assume loose argument is event ID or fuzzy title
150
+ var str = this.args.other.join(' ');
151
+ event = this.findObjectFuzzy( this.events, { id: str, title: str }, 1 );
152
+ if (!event) return this.die("Could not find event based on your criteria: " + str);
153
+ }
154
+ else {
155
+ delete this.args.other;
156
+ event = this.findObjectFuzzy( this.events, this.args );
157
+ if (!event) return this.die("Could not find event based on your criteria: " + JSON.stringify(this.args));
158
+ }
159
+
160
+ if (this.format.match(/json/)) {
161
+ // early exit for json mode
162
+ return this.jsonOutput(event);
163
+ }
164
+
165
+ // summary table
166
+ this.printBoxList({
167
+ title: event.workflow ? "Workflow Summary" : "Event Summary",
168
+ rows: [
169
+ [ 'Event ID', gray(event.id) ],
170
+ [ 'Title', this.color('theme').bold(event.title) ],
171
+ [ 'Status', this.getNiceEnabled(event.enabled) ],
172
+ [ 'Category', this.getNiceCategory(event.category) + ` ${gray('(' + event.category + ')')}` ],
173
+ [ 'Plugin', this.getNicePlugin(event.plugin) + (event.plugin != '_workflow' ? ` ${gray('(' + event.plugin + ')')}` : '') ],
174
+ [ 'Targets', this.getNiceTargets(event.targets) ],
175
+ [ 'Algorithm', this.getNiceAlgo(event.algo) ],
176
+ [ 'Author', event.username ],
177
+ [ 'Created', this.getNiceDateTime(event.created, true, true) ],
178
+ [ 'Modified', this.getNiceDateTime(event.modified, true, true) ],
179
+ [ 'Revision', event.revision ]
180
+ ]
181
+ });
182
+
183
+ // notes
184
+ if (event.notes) {
185
+ this.printUserNotes('EVENT NOTES', event.notes);
186
+ }
187
+
188
+ // upcoming jobs
189
+ if (this.args.upcoming) {
190
+ await this.printUpcomingJobs({
191
+ events: [event]
192
+ });
193
+ return;
194
+ }
195
+
196
+ // completed jobs
197
+ if (this.args.completed) {
198
+ await this.printCompletedJobs({
199
+ query: 'event:' + event.id
200
+ });
201
+ return;
202
+ }
203
+
204
+ // queued jobs
205
+ if (this.args.queued || this.args.queue) {
206
+ await this.printQueuedJobs({
207
+ event: event.id
208
+ });
209
+ return;
210
+ }
211
+
212
+ // triggers
213
+ this.printBoxTable({
214
+ title: "Triggers",
215
+ header: ['Status', 'Description', 'Type', 'Tags'],
216
+ rows: (event.triggers || []).map( item => {
217
+ var { nice_type, nice_desc } = this.getTriggerDisplayArgs(item);
218
+ return [
219
+ this.getNiceEnabled(item.enabled),
220
+ item.enabled ? bold(nice_desc) : gray(nice_desc),
221
+ item.enabled ? nice_type : gray(nice_type),
222
+ this.getNiceTagList( item.tags || [] )
223
+ ];
224
+ } )
225
+ });
226
+
227
+ // actions
228
+ var actions = [].concat( event.actions || [] );
229
+
230
+ // add inherited category actions
231
+ var category = Tools.findObject( this.categories, { id: event.category } ) || {};
232
+ (category.actions || []).forEach( function(action) {
233
+ actions.push({ ...action, source: 'category' });
234
+ } );
235
+
236
+ // add universal actions (not hidden)
237
+ var temp_event_type = event.workflow ? 'workflow' : 'default';
238
+ this.config.job_universal_actions[temp_event_type].forEach( function(action) {
239
+ if (action.condition && !action.hidden) actions.push({ ...action, source: 'universal' });
240
+ } );
241
+
242
+ this.printBoxTable({
243
+ title: "Actions",
244
+ header: ['#', 'Status', 'Condition', 'Type', 'Description', 'Note'],
245
+ rows: actions.map( (item, idx) => {
246
+ var disp = this.getJobActionDisplayArgs(item);
247
+ return [
248
+ idx,
249
+ this.getNiceEnabled(item.enabled),
250
+ item.enabled ? this.color(disp.condition.color).bold(disp.condition.title) : gray(disp.condition.title),
251
+ item.enabled ? disp.type : gray(disp.type),
252
+ item.enabled ? disp.desc : gray(disp.desc),
253
+ item.enabled ? disp.note : gray(disp.note),
254
+ ];
255
+ } )
256
+ });
257
+
258
+ // limits
259
+ var limits = [].concat( event.limits || [] );
260
+
261
+ // add inherited category limits
262
+ var category = Tools.findObject( this.categories, { id: event.category } ) || {};
263
+ (category.limits || []).forEach( function(limit) {
264
+ limits.push({ ...limit, source: 'category' });
265
+ } );
266
+
267
+ // add universal limits (not hidden)
268
+ var temp_event_type = event.workflow ? 'workflow' : 'default';
269
+ this.config.job_universal_limits[temp_event_type].forEach( function(limit) {
270
+ if (limit.type && !limit.hidden) limits.push({ ...limit, source: 'universal' });
271
+ } );
272
+
273
+ this.printBoxTable({
274
+ title: "Limits",
275
+ header: ['#', 'Status', 'Limit', 'Description', 'Note'],
276
+ rows: limits.map( (item, idx) => {
277
+ var { nice_title, nice_desc, note } = this.getResLimitDisplayArgs(item);
278
+ return [
279
+ idx,
280
+ this.getNiceEnabled(item.enabled),
281
+ item.enabled ? bold(nice_title) : gray(nice_title),
282
+ item.enabled ? nice_desc : gray(nice_desc),
283
+ item.enabled ? note : gray(note),
284
+ ];
285
+ } )
286
+ });
287
+
288
+ // plugin param (values)
289
+ if (!event.workflow) {
290
+ var plugin = Tools.findObject( this.plugins, { id: event.plugin } ) || { title: "Plugin" };
291
+ this.printParamValues({
292
+ title: `${plugin.title} Parameters`,
293
+ fields: plugin.params || [],
294
+ values: event.params || {}
295
+ });
296
+ }
297
+
298
+ // user params (field defs)
299
+ this.printParamFields({
300
+ title: "User Parameters",
301
+ fields: event.fields || []
302
+ });
303
+
304
+ // workflow nodes (ugh)
305
+ if (event.workflow && event.workflow.nodes) {
306
+ var nodes = Tools.sortBy( event.workflow.nodes.filter( node => node.type.match(/^(event|job|controller)$/) ), 'type', { type: 'string', dir: 1 } );
307
+ this.printBoxTable({
308
+ title: "Workflow Nodes",
309
+ header: ['Node Title', 'ID', 'Type', 'Description', 'Connections'],
310
+ rows: nodes.map( node => {
311
+ var { title, type, desc, conns } = this.getWFNodeDisplayArgs(event, node);
312
+ return [ bold(title), gray(node.id), type, desc, conns ];
313
+ } )
314
+ });
315
+ }
316
+
317
+ // active jobs
318
+ this.printActiveJobs({
319
+ title: "Active Jobs",
320
+ criteria: {
321
+ event: event.id
322
+ }
323
+ });
324
+
325
+ // suggested commands
326
+ this.printSuggestedCommands({
327
+ "Queued jobs": `xy event ${event.id} --queued`,
328
+ "Upcoming jobs": `xy event ${event.id} --upcoming`,
329
+ "Completed jobs": `xy event ${event.id} --completed`,
330
+ "Run job manually": `xy run ${event.id}`,
331
+ "Update event": `xy event update ${event.id} [--KEY VALUE, ...]`,
332
+ "Export event": `xy event ${event.id} --export event.json`,
333
+ "Delete event": `xy event delete ${event.id}`
334
+ });
335
+ },
336
+
337
+ async cmd_run_event() {
338
+ // launch job for event
339
+ // launch w/params: xy run EVENT_ID --params.foo "bar"
340
+ // launch w/files: xy run EVENT_ID --file FILE1.txt --file FILE2.txt
341
+ // launch w/data: xy run EVENT_ID --input.data.foo "bar"
342
+ // custom actions array from file: xy run EVENT_ID --actions @my-actions.json
343
+ // entire request from stdin: xy run EVENT_ID --json @-
344
+ await this.getMultiple();
345
+
346
+ if (!this.events.length) {
347
+ return this.die( "No events found." );
348
+ }
349
+
350
+ var event = null;
351
+ if (this.args.other.length) {
352
+ // assume loose argument is event ID or fuzzy title
353
+ var str = this.args.other.join(' ');
354
+ event = this.findObjectFuzzy( this.events, { id: str, title: str }, 1 );
355
+ if (!event) return this.die("Could not find event based on your criteria: " + str);
356
+ }
357
+ else if (this.args.id) {
358
+ event = this.findObjectFuzzy( this.events, { id: this.args.id } );
359
+ if (!event) return this.die("Could not find event from ID: " + this.args.id);
360
+ }
361
+ else if (this.args.title) {
362
+ event = this.findObjectFuzzy( this.events, { title: this.args.title } );
363
+ if (!event) return this.die("Could not find event from title: " + this.args.title);
364
+ }
365
+ else this.die("Could not find event.");
366
+
367
+ delete this.args.other;
368
+ delete this.args.id;
369
+ delete this.args.title;
370
+
371
+ var do_follow = this.args.follow;
372
+ delete this.args.follow;
373
+
374
+ var files = Tools.alwaysArray( this.args.file || this.args.files || [] );
375
+ delete this.args.file;
376
+ delete this.args.files;
377
+
378
+ this.args.id = event.id;
379
+
380
+ // resolve dot paths in args
381
+ this.mergeDotArgs( this.args, this.args );
382
+
383
+ var req = this.args;
384
+ var opts = { text: 'Running event...' };
385
+ if (files.length) opts.files = files;
386
+
387
+ var data = await this.callStandardAPI('runEvent', req, opts);
388
+ if (this.dry) return;
389
+
390
+ this.toast('✅', 'green', "Successfully launched job: #" + data.id );
391
+
392
+ if (do_follow) {
393
+ this.gotMultiple = false;
394
+ this.args = { id: data.id };
395
+ await this.cmd_stream_job();
396
+ }
397
+ else if (!this.verbose) {
398
+ println( "\n " + green.bold("Watch job live: ") + bold("xy job " + data.id) );
399
+ }
400
+ },
401
+
402
+ async cmd_create_event() {
403
+ // create new event
404
+ // xy event create --title "Foo" --plugin testplug --params.duration 30
405
+ // xy event create --title "Yoo" --plugin shellplug --params.script "#!/bin/bash\necho 'Hi'"
406
+ // xy event create --title "Bar" --cron "30 4 * * *" --seconds 15
407
+ // xy event create --title "Baz" --interval "5 minutes" --catchup
408
+ await this.getMultiple();
409
+
410
+ // resolve dot paths in args
411
+ this.mergeDotArgs( this.args, this.args );
412
+
413
+ var params = Tools.mergeHashes( Tools.copyHash(this.config.new_event_template, true), this.args );
414
+ delete params.other;
415
+
416
+ if (!params.category) {
417
+ if (Tools.findObject(this.categories, { id: 'general' })) params.category = 'general';
418
+ else if (!this.categories.length) return this.die("You must define at least one category to add events.");
419
+ else params.category = this.categories[0].id;
420
+ }
421
+
422
+ if (!params.plugin && (params.type != 'workflow')) {
423
+ if (Tools.findObject(this.plugins, { id: 'shellplug' })) params.plugin = 'shellplug';
424
+ else if (!this.plugins.length) return this.die("You must create at least one Plugin to add events.");
425
+ else params.plugin = this.plugins[0].id;
426
+ }
427
+
428
+ if (!params.targets || !params.targets.length) {
429
+ if (Tools.findObject(this.groups, { id: 'main' })) params.targets = ['main'];
430
+ else if (!this.groups.length) return this.die("You must define at least one server group to add events.");
431
+ else params.targets = [ this.groups[0].id ];
432
+ }
433
+
434
+ if (!params.algo) params.algo = 'random';
435
+
436
+ this.processEventUpdates(params);
437
+
438
+ var data = await this.callStandardAPI('createEvent', params, {
439
+ text: 'Creating event...'
440
+ });
441
+ if (this.dry) return;
442
+
443
+ this.toast('✅', 'green', "Successfully created event: #" + data.event.id );
444
+
445
+ this.printSuggestedCommands({
446
+ "View event details": `xy event ${data.event.id}`,
447
+ "Run event manually": `xy run ${data.event.id}`,
448
+ "Run and follow": `xy run ${data.event.id} --follow`,
449
+ "Update event": `xy event update ${data.event.id} [--KEY VALUE, ...]`,
450
+ "List all events": "xy events"
451
+ });
452
+ },
453
+
454
+ async cmd_update_event() {
455
+ // update single event
456
+ // xy event update EVENT_ID --title "New Title" --params.duration 15
457
+ // xy event update "Title" --action '{ "type":"email", "enabled":true, "condition":"success", "users":["admin"] }'
458
+ // xy event update EVENT_ID --actions.0.condition "complete" --actions.0.enabled true
459
+ // xy event update EVENT_ID --field '{ "id":"foo", "title":"Foo", "type":"text", "value":"hello" }'
460
+ // xy event update "Title" --magic
461
+ await this.getMultiple();
462
+
463
+ if (!this.events.length) {
464
+ return this.die( "No events found." );
465
+ }
466
+
467
+ var event = null;
468
+ if (this.args.other.length) {
469
+ // assume loose argument is event ID or fuzzy title
470
+ var str = this.args.other.join(' ');
471
+ event = this.findObjectFuzzy( this.events, { id: str, title: str }, 1 );
472
+ if (!event) return this.die("Could not find event based on your criteria: " + str);
473
+ delete this.args.other;
474
+ }
475
+ else {
476
+ delete this.args.other;
477
+ event = this.findObjectFuzzy( this.events, this.args );
478
+ if (!event) return this.die("Could not find event based on your criteria: " + JSON.stringify(this.args));
479
+ }
480
+
481
+ this.printMutationSummary({
482
+ title: event.workflow ? "Update Workflow" : "Update Event",
483
+ rows: [
484
+ [ 'Event ID', gray(event.id) ],
485
+ [ 'Title', this.color('theme').bold(event.title) ]
486
+ ]
487
+ });
488
+
489
+ delete this.args.other;
490
+ if (!await this.confirmSyncUpdate('event', event.id, event.workflow ? 'workflow' : 'event')) return;
491
+ if (!Tools.firstKey(this.args)) return this.die("No updates specified for event.");
492
+
493
+ this.printUpdateData(this.args);
494
+
495
+ var params = this.prepareEventUpdateParams(event, this.args);
496
+ params.id = event.id;
497
+
498
+ var data = await this.callStandardAPI('updateEvent', params, {
499
+ text: 'Updating event...'
500
+ });
501
+ if (this.dry) return;
502
+
503
+ this.toast('✅', 'green', "Successfully updated event: #" + event.id );
504
+ },
505
+
506
+ async cmd_delete_event() {
507
+ // delete single event
508
+ // xy event delete EVENT_ID
509
+ // xy event delete EVENT_ID --delete_jobs
510
+ await this.getMultiple();
511
+
512
+ if (!this.events.length) {
513
+ return this.die( "No events found." );
514
+ }
515
+
516
+ if (this.args.other && this.args.other.length && !this.args.id) {
517
+ this.args.id = this.args.other.shift();
518
+ }
519
+ if (!this.args.id) this.die("Missing required 'id' argument.");
520
+
521
+ delete this.args.other;
522
+
523
+ var event = Tools.findObject(this.events, { id: this.args.id });
524
+ if (!event) return this.die("Could not find event from ID: " + this.args.id);
525
+
526
+ this.printMutationSummary({
527
+ title: event.workflow ? "Delete Workflow" : "Delete Event",
528
+ rows: [
529
+ [ 'Event ID', gray(event.id) ],
530
+ [ 'Title', this.color('theme').bold(event.title) ]
531
+ ]
532
+ });
533
+
534
+ // user must confirm, as this is destructive
535
+ if (this.args.confirm !== true) {
536
+ this.toast('⚠️', 'orange', "Please confirm the delete by adding '--confirm'.");
537
+ return;
538
+ }
539
+ delete this.args.confirm;
540
+
541
+ var data = await this.callStandardAPI('deleteEvent', this.args, {
542
+ text: 'Deleting event...'
543
+ });
544
+ if (this.dry) return;
545
+
546
+ this.toast('✅', 'green', "Successfully deleted event: #" + event.id );
547
+ },
548
+
549
+ getNiceEventID(event) {
550
+ if (!event) return gray('(None)');
551
+ if (typeof(event) == 'string') {
552
+ event = Tools.findObject( this.events, { id: event } ) || { id: event, title: event };
553
+ }
554
+
555
+ if (!event.enabled) return gray(event.id);
556
+
557
+ var category = Tools.findObject( this.categories, { id: event.category } );
558
+ if (category && category.color && (category.color != 'plain')) {
559
+ return this.color(category.color).bold(event.id);
560
+ }
561
+
562
+ return this.color('theme').bold(event.id);
563
+ },
564
+
565
+ prepareEventUpdateParams(event, input) {
566
+ // Build a sparse API request so properties introduced by a newer xyOps server
567
+ // are never echoed back through an older CLI. For dotted edits, clone only
568
+ // the affected saved container because xyOps shallow-merges top-level fields.
569
+ var params = {};
570
+ var dotted = {};
571
+
572
+ Object.keys(input).filter( key => !key.includes('.') ).forEach( key => {
573
+ params[key] = input[key];
574
+ });
575
+ Object.keys(input).filter( key => key.includes('.') ).forEach( key => {
576
+ var root = key.split('.')[0];
577
+ if (!Object.prototype.hasOwnProperty.call(params, root) && Object.prototype.hasOwnProperty.call(event, root)) {
578
+ params[root] = Tools.copyHash({ value: event[root] }, true).value;
579
+ }
580
+ dotted[key] = input[key];
581
+ });
582
+ this.mergeDotArgs(params, dotted);
583
+
584
+ // Singular append options and trigger shortcuts need the saved array so they
585
+ // preserve existing entries. Whole-array options above remain replacements.
586
+ var append_fields = {
587
+ trigger: 'triggers',
588
+ action: 'actions',
589
+ limit: 'limits',
590
+ field: 'fields'
591
+ };
592
+ Object.keys(append_fields).forEach( singular => {
593
+ var plural = append_fields[singular];
594
+ if ((singular in params) && !(plural in params)) {
595
+ params[plural] = Tools.copyHash({ value: event[plural] || [] }, true).value;
596
+ }
597
+ });
598
+
599
+ var trigger_shortcuts = ['cron', 'interval', 'single', 'magic', 'seconds', 'delay', 'invisible', 'ephemeral', 'catchup'];
600
+ if (trigger_shortcuts.some( key => key in params ) && !('triggers' in params)) {
601
+ params.triggers = Tools.copyHash({ value: event.triggers || [] }, true).value;
602
+ }
603
+
604
+ this.processEventUpdates(params, event);
605
+ return params;
606
+ },
607
+
608
+ processEventUpdates(event, saved_event) {
609
+ // convert convenience param syntax into event object
610
+
611
+ // Creation retains the original empty-array defaults. Sparse updates only
612
+ // carry arrays which are explicitly replaced, edited or appended above.
613
+ if (!saved_event) {
614
+ if (!event.triggers) event.triggers = [];
615
+ if (!event.actions) event.actions = [];
616
+ if (!event.limits) event.limits = [];
617
+ if (!event.fields) event.fields = [];
618
+ }
619
+
620
+ // allow convenience singular objects to append
621
+ if (event.trigger) { event.triggers.push(event.trigger); delete event.trigger; }
622
+ if (event.action) { event.actions.push(event.action); delete event.action; }
623
+ if (event.limit) { event.limits.push(event.limit); delete event.limit; }
624
+ if (event.field) { event.fields.push(event.field); delete event.field; }
625
+
626
+ // allow cron syntax to append a schedule trigger
627
+ if (event.cron) {
628
+ var trigger = this.parseCrontab(event.cron, event.title || (saved_event && saved_event.title));
629
+ trigger.type = 'schedule';
630
+ trigger.enabled = true;
631
+ event.triggers.push(trigger);
632
+ delete event.cron;
633
+ }
634
+
635
+ if (event.interval) {
636
+ // add interval trigger
637
+ var trigger = {
638
+ type: 'interval',
639
+ enabled: true,
640
+ start: Tools.normalizeTime( Tools.timeNow(true), { sec: 0 } ),
641
+ duration: Tools.getSecondsFromText( event.interval ) || this.die("Could not parse seconds from interval: " + event.interval)
642
+ };
643
+ event.triggers.push(trigger);
644
+ delete event.interval;
645
+ }
646
+
647
+ if (event.single) {
648
+ // add single-shot
649
+ var trigger = {
650
+ type: 'precision',
651
+ enabled: true,
652
+ epoch: Tools.parseDate(event.single) || this.die("Could not parse date from single-shot: " + event.single)
653
+ };
654
+ event.triggers.push(trigger);
655
+ delete event.single;
656
+ }
657
+
658
+ if (event.magic) {
659
+ // add or refresh magic link
660
+ var trigger = {
661
+ type: 'magic',
662
+ enabled: true,
663
+ key: Tools.generateUniqueID()
664
+ };
665
+ var magic_link = this.config.base_url + "/api/app/magic/v1/" + trigger.key;
666
+ println( "\n " + cyan.bold("Magic Link: ") + " " + gray(magic_link) );
667
+ this.updateOrAdd( event.triggers, { type: 'magic' }, trigger );
668
+ delete event.magic;
669
+ }
670
+
671
+ if ('seconds' in event) {
672
+ // add or update precision
673
+ var trigger = {
674
+ type: 'precision',
675
+ enabled: true,
676
+ seconds: String(event.seconds).split(/\,\s*/).map( sec => parseInt(sec) )
677
+ };
678
+ this.updateOrAdd( event.triggers, { type: 'precision' }, trigger );
679
+ delete event.seconds;
680
+ }
681
+
682
+ if (event.delay) {
683
+ // add or update delay
684
+ var trigger = {
685
+ type: 'delay',
686
+ enabled: true,
687
+ duration: Tools.getSecondsFromText(event.delay) || this.die("Could not parse seconds from delay: " + event.delay)
688
+ };
689
+ this.updateOrAdd( event.triggers, { type: 'delay' }, trigger );
690
+ delete event.delay;
691
+ }
692
+
693
+ if (event.invisible) {
694
+ // add or update quiet (invisible)
695
+ var trigger = {
696
+ type: 'quiet',
697
+ enabled: true,
698
+ invisible: true
699
+ };
700
+ this.updateOrAdd( event.triggers, { type: 'quiet' }, trigger );
701
+ delete event.invisible;
702
+ }
703
+
704
+ if (event.ephemeral) {
705
+ // add or update quiet (ephemeral)
706
+ var trigger = {
707
+ type: 'quiet',
708
+ enabled: true,
709
+ ephemeral: true
710
+ };
711
+ this.updateOrAdd( event.triggers, { type: 'quiet' }, trigger );
712
+ delete event.ephemeral;
713
+ }
714
+
715
+ if (event.catchup) {
716
+ // add or update catch-up
717
+ var trigger = {
718
+ type: 'catchup',
719
+ enabled: true
720
+ };
721
+ this.updateOrAdd( event.triggers, { type: 'catchup' }, trigger );
722
+ delete event.catchup;
723
+ }
724
+ },
725
+
726
+ updateOrAdd(arr, criteria, obj) {
727
+ // update object if found based on criteria, otherwise add new
728
+ var old = Tools.findObject(arr, criteria);
729
+ if (old) Tools.mergeHashInto(old, obj);
730
+ else arr.push(obj);
731
+ },
732
+
733
+ getWFNodeDisplayArgs(event, node) {
734
+ // get display args for any workflow node
735
+ // title, type, desc, conns
736
+ var workflow = event.workflow;
737
+ var title = '';
738
+ var type = '';
739
+ var desc = '';
740
+
741
+ var conns = [];
742
+ (workflow.connections || []).forEach( function(conn) {
743
+ if (conn.source == node.id) {
744
+ var dest_node = Tools.findObject( workflow.nodes, { id: conn.dest } );
745
+ if (dest_node && dest_node.type.match(/^(event|job|controller)$/)) conns.push( conn.dest );
746
+ }
747
+ // else if (conn.dest == node.id) conns.push( conn.source );
748
+ } );
749
+ conns = conns.join(', ') || gray('(None)');
750
+
751
+ switch (node.type) {
752
+ case 'event':
753
+ var event = Tools.findObject( this.events, { id: node.data.event } ) || { title: red("(Event Not Found)") };
754
+ type = "Event";
755
+ title = event.title;
756
+ desc = node.data.replay ? `(Replay Job #${node.data.replay})` : '-';
757
+ break;
758
+
759
+ case 'job':
760
+ var plugin = Tools.findObject( this.plugins, { id: node.data.plugin } ) || { title: red("(Plugin Not Found)") };
761
+ type = "Job";
762
+ title = node.data.label || plugin.title;
763
+ desc = node.data.replay ? `(Replay Job #${node.data.replay})` : '-';
764
+ break;
765
+
766
+ case 'controller':
767
+ type = "Controller";
768
+ title = Tools.ucfirst( node.data.controller ) + " Controller";
769
+ desc = node.data.split || node.data.decision || '-';
770
+ if (node.data.controller == 'repeat') desc = 'Repeat ' + node.data.repeat + " times";
771
+ else if (node.data.controller == 'wait') desc = 'Wait ' + Tools.getTextFromSeconds(node.data.wait, false, false);
772
+ break;
773
+ } // switch node.type
774
+
775
+ return { title, type, desc, conns };
776
+ },
777
+
778
+ getNiceEventStatus(event) {
779
+ // get pretty event status (active jobs or last result)
780
+ var num_jobs = 0;
781
+ var last_job_id = '';
782
+ for (var job_id in this.activeJobs) {
783
+ var job = this.activeJobs[job_id];
784
+ if (job.event == event.id) { num_jobs++; last_job_id = job.id; }
785
+ }
786
+ var nice_status = gray('Idle');
787
+ var event_state = Tools.getPath( this.state, 'events/' + event.id );
788
+
789
+ if (num_jobs) {
790
+ nice_status = cli.emoji('🔄') + ' ' + this.color('theme').bold( num_jobs + ' Active' );
791
+ }
792
+ else if (!num_jobs && event_state && event_state.last_job) {
793
+ switch (event_state.last_code) {
794
+ case 'warning': nice_status = cli.emoji('⚠️') + ' ' + this.color('yellow').bold('Warning'); break;
795
+ case 'critical': nice_status = cli.emoji('☢️') + ' ' + this.color('purple').bold('Critical'); break;
796
+ case 'abort': nice_status = cli.emoji('🚫') + ' ' + this.color('gray').bold('Abort'); break;
797
+ default:
798
+ if (event_state.last_code) nice_status = cli.emoji('🛑') + ' ' + this.color('red').bold('Error');
799
+ else nice_status = cli.emoji('✅') + ' ' + this.color('green').bold('Success');
800
+ break;
801
+ }
802
+ if (event_state.last_completed) {
803
+ nice_status += ' ' + gray( this.getTextFromSecondsShort(this.epoch - event_state.last_completed) + ' ago' );
804
+ }
805
+ }
806
+
807
+ return nice_status;
808
+ },
809
+
810
+ getTriggerDisplayArgs(item) {
811
+ // prep trigger item for display
812
+ var nice_type = '';
813
+ var alt_type = '';
814
+ var nice_desc = '';
815
+ var short_desc = '';
816
+
817
+ var menu_item = Tools.findObject( this.config.ui.event_trigger_type_menu, { id: item.type } );
818
+
819
+ switch (item.type) {
820
+ case 'schedule':
821
+ nice_type = 'Schedule';
822
+ short_desc = summarize_event_timing(item);
823
+ nice_desc = short_desc;
824
+
825
+ // find actual sub-type based on schedule trigger params
826
+ var trigger = item;
827
+ var tmode = 'hourly';
828
+ if (trigger.years && trigger.years.length) tmode = 'custom';
829
+ else if (trigger.months && trigger.months.length && trigger.weekdays && trigger.weekdays.length) tmode = 'custom';
830
+ else if (trigger.days && trigger.days.length && trigger.weekdays && trigger.weekdays.length) tmode = 'custom';
831
+ else if (trigger.months && trigger.months.length) tmode = 'yearly';
832
+ else if (trigger.weekdays && trigger.weekdays.length) tmode = 'weekly';
833
+ else if (trigger.days && trigger.days.length) tmode = 'monthly';
834
+ else if (trigger.hours && trigger.hours.length) tmode = 'daily';
835
+ else if (trigger.minutes && trigger.minutes.length) tmode = 'hourly';
836
+ break;
837
+
838
+ case 'interval':
839
+ nice_type = 'Schedule';
840
+ alt_type = 'Interval';
841
+ short_desc = Tools.getTextFromSeconds(item.duration || 0, true, false);
842
+ nice_desc = Tools.getTextFromSeconds(item.duration || 0, false, false);
843
+ break;
844
+
845
+ case 'startup':
846
+ nice_type = 'Schedule';
847
+ alt_type = 'System';
848
+ nice_desc = 'Run at Startup';
849
+ short_desc = "Run at Startup";
850
+ break;
851
+
852
+ case 'single':
853
+ nice_type = 'Schedule';
854
+ alt_type = 'Single Shot';
855
+ short_desc = summarize_event_timing(item);
856
+ nice_desc = 'Single Shot: ' + short_desc;
857
+ break;
858
+
859
+ case 'manual':
860
+ nice_type = 'On-Demand';
861
+ nice_desc = 'Manual Run';
862
+ short_desc = "Manual Run";
863
+ break;
864
+
865
+ case 'magic':
866
+ nice_type = 'On-Demand';
867
+ nice_desc = 'Magic Link';
868
+ short_desc = "Magic Link";
869
+ break;
870
+
871
+ case 'keyboard':
872
+ nice_type = 'On-Demand';
873
+ alt_type = 'Keyboard';
874
+ nice_desc = 'Keyboard: [' + (item.keys || []).join('], [') + ']';
875
+ short_desc = '[' + (item.keys || []).join('], [') + ']';
876
+ break;
877
+
878
+ case 'catchup':
879
+ nice_type = alt_type = 'Modifier';
880
+ nice_desc = 'Catch-Up';
881
+ short_desc = "Catch-Up";
882
+ break;
883
+
884
+ case 'nth':
885
+ nice_type = alt_type = 'Modifier';
886
+ nice_desc = 'Run Every ' + format_ordinal(item.every);
887
+ short_desc = "Every " + format_ordinal(item.every);
888
+ break;
889
+
890
+ case 'range':
891
+ nice_type = 'Modifier';
892
+ alt_type = 'Range';
893
+ short_desc = (item.start && item.end && (item.end > item.start)) ? Tools.getTextFromSeconds( item.end - item.start, true, true ) : this.summarizeTimingRange(item);
894
+ nice_desc = 'Range: ' + this.summarizeTimingRange(item);
895
+ break;
896
+
897
+ case 'blackout':
898
+ nice_type = 'Modifier';
899
+ alt_type = 'Blackout';
900
+ short_desc = (item.start && item.end && (item.end > item.start)) ? Tools.getTextFromSeconds( item.end - item.start, true, true ) : this.summarizeTimingRange(item);
901
+ nice_desc = 'Blackout: ' + this.summarizeTimingRange(item);
902
+ break;
903
+
904
+ case 'delay':
905
+ nice_type = 'Modifier';
906
+ alt_type = 'Delay';
907
+ short_desc = Tools.getTextFromSeconds(item.duration || 0, false, true);
908
+ nice_desc = 'Delay: ' + short_desc;
909
+ break;
910
+
911
+ case 'precision':
912
+ nice_type = 'Precision';
913
+ alt_type = 'Precision';
914
+ short_desc = 'On the minute';
915
+ if (item.seconds && item.seconds.length) short_desc = item.seconds.map( sec => ':' + zeroPad(sec, 2) ).join(', ');
916
+ nice_desc = 'Seconds: ' + short_desc;
917
+ break;
918
+
919
+ case 'quiet':
920
+ nice_type = 'Modifier';
921
+ alt_type = 'Quiet';
922
+ short_desc = '';
923
+ nice_desc = '';
924
+ if (item.invisible) {
925
+ short_desc += 'Invisible';
926
+ nice_desc += 'Invisible';
927
+ }
928
+ if (item.ephemeral) {
929
+ if (short_desc) short_desc += ', ';
930
+ if (nice_desc) nice_desc += ', ';
931
+ short_desc += 'Ephemeral';
932
+ nice_desc += 'Ephemeral';
933
+ }
934
+ if (!short_desc) short_desc = '(None)';
935
+ if (!nice_desc) nice_desc = '(None)';
936
+ break;
937
+
938
+ case 'plugin':
939
+ nice_type = alt_type = 'Plugin';
940
+ nice_desc = this.getNicePlugin(item.plugin_id);
941
+ var plugin = Tools.findObject( this.plugins, { id: item.plugin_id } ) || { title: item.plugin_id };
942
+ short_desc = plugin.title;
943
+ break;
944
+ } // switch item.type
945
+
946
+ return { nice_type, alt_type, nice_desc, short_desc };
947
+ },
948
+
949
+ summarizeTimingRange(trigger) {
950
+ // summarize date/time range, or single start/end
951
+ var text = '';
952
+ var tz = trigger.timezone || app.config.tz;
953
+ var opts = this.getDateOptions({
954
+ year: 'numeric',
955
+ month: 'short',
956
+ day: 'numeric',
957
+ hour: 'numeric',
958
+ minute: '2-digit',
959
+ timeZone: tz
960
+ });
961
+ var formatter = new Intl.DateTimeFormat(opts.locale, opts);
962
+
963
+ if (trigger.start && trigger.end) {
964
+ // full range
965
+ text = formatter.formatRange( new Date(trigger.start * 1000), new Date(trigger.end * 1000) );
966
+ }
967
+ else if (trigger.start) {
968
+ // start only
969
+ text = "Start on " + formatter.format( new Date(trigger.start * 1000) );
970
+ }
971
+ else if (trigger.end) {
972
+ // end only
973
+ text = "End on " + formatter.format( new Date(trigger.end * 1000) );
974
+ }
975
+ else return "n/a";
976
+
977
+ // show timezone if it differs from user's current
978
+ var ropts = Intl.DateTimeFormat().resolvedOptions();
979
+ var user_tz = ropts.timeZone;
980
+ if (user_tz != tz) text += ' (' + tz + ')';
981
+
982
+ return text;
983
+ }
984
+
985
+ }; // exports
986
+
987
+ function zeroPad(value, len) {
988
+ // Pad a number with zeroes to achieve a desired total length (max 10)
989
+ return ('0000000000' + value).slice(0 - len);
990
+ };
991
+
992
+ function get_pretty_int_list(arr, ranges) {
993
+ // compose int array to string using commas + spaces, and
994
+ // the english "and" to group the final two elements.
995
+ // also detect sequences and collapse those into dashed ranges
996
+ if (!arr || !arr.length) return '';
997
+ if (arr.length == 1) return arr[0].toString();
998
+ arr = Tools.copyHash(arr, true).sort( function(a, b) { return a - b; } );
999
+
1000
+ // check for ranges and collapse them
1001
+ if (ranges) {
1002
+ var groups = [];
1003
+ var group = [];
1004
+ for (var idx = 0, len = arr.length; idx < len; idx++) {
1005
+ var elem = arr[idx];
1006
+ if (!group.length || (elem == group[group.length - 1] + 1)) group.push(elem);
1007
+ else { groups.push(group); group = [elem]; }
1008
+ }
1009
+ if (group.length) groups.push(group);
1010
+ arr = [];
1011
+ for (var idx = 0, len = groups.length; idx < len; idx++) {
1012
+ var group = groups[idx];
1013
+ if (group.length == 1) arr.push( group[0] );
1014
+ else if (group.length == 2) {
1015
+ arr.push( group[0] );
1016
+ arr.push( group[1] );
1017
+ }
1018
+ else {
1019
+ arr.push( group[0] + ' - ' + group[group.length - 1] );
1020
+ }
1021
+ }
1022
+ } // ranges
1023
+
1024
+ if (arr.length == 1) return arr[0].toString();
1025
+ return arr.slice(0, arr.length - 1).join(', ') + ' and ' + arr[ arr.length - 1 ];
1026
+ }
1027
+
1028
+ function get_pretty_str_list(arr) {
1029
+ // compose string array to string using commas + spaces, and
1030
+ // the english "and" to group the final two elements
1031
+ if (!arr || !arr.length) return '';
1032
+ if (arr.length == 1) return arr[0].toString();
1033
+ return arr.slice(0, arr.length - 1).join(', ') + ' and ' + arr[ arr.length - 1 ];
1034
+ }
1035
+
1036
+ function format_ordinal(num) {
1037
+ // keep English ordinal suffixes, but localize digits
1038
+ var date_opts = app.getDateOptions();
1039
+ var num_fmt = new Intl.NumberFormat(date_opts.locale, { useGrouping: false, numberingSystem: date_opts.numberingSystem });
1040
+ var suffix = 'th';
1041
+ if ((num % 100 < 11) || (num % 100 > 13)) {
1042
+ switch (num % 10) {
1043
+ case 1: suffix = 'st'; break;
1044
+ case 2: suffix = 'nd'; break;
1045
+ case 3: suffix = 'rd'; break;
1046
+ }
1047
+ }
1048
+ return num_fmt.format(num) + suffix;
1049
+ };
1050
+
1051
+ function summarize_event_timings(event) {
1052
+ // summarize all event triggers from event into human-readable string
1053
+ // separate schedule items and options
1054
+ var triggers = event.triggers.filter( function(trigger) { return trigger.enabled; } );
1055
+ var schedules = triggers.filter( function(trigger) { return !!(trigger.type || '').match(/^(schedule|interval|single|startup|magic|keyboard)$/); } );
1056
+ var parts = (schedules.length == 1) ? [summarize_event_timing(schedules[0])] : schedules.map( summarize_event_timing );
1057
+ if (!parts.length) {
1058
+ if (Tools.findObject(triggers, { type: 'manual', enabled: true })) return "On Demand";
1059
+ else return "Disabled";
1060
+ }
1061
+ // var summary = (parts.length == 1) ? parts[0] : (parts.slice(0, parts.length - 1).join(', ') + ', and ' + parts[ parts.length - 1 ]);
1062
+ var summary = parts.join(', ');
1063
+
1064
+ var opts = [];
1065
+ triggers.forEach( function(trigger) {
1066
+ switch (trigger.type) {
1067
+ case 'catchup': opts.push("Catch-Up"); break;
1068
+ case 'nth':
1069
+ if (trigger.every > 1) opts.push("Every " + format_ordinal(trigger.every));
1070
+ break;
1071
+ case 'range': opts.push("Date Range"); break;
1072
+ case 'blackout': opts.push("Blackout"); break;
1073
+ case 'delay': opts.push("Delay"); break;
1074
+ case 'precision': opts.push("Precision"); break;
1075
+ case 'quiet': opts.push("Quiet"); break;
1076
+ case 'plugin':
1077
+ var plugin = Tools.findObject( app.plugins, { id: trigger.plugin_id } );
1078
+ if (plugin) opts.push(plugin.title);
1079
+ else opts.push("Plugin");
1080
+ break;
1081
+ }
1082
+ } );
1083
+ var unique_opts = [...new Set(opts)];
1084
+ if (unique_opts.length) summary += ' (' + unique_opts.join(', ') + ')';
1085
+
1086
+ return summary;
1087
+ }
1088
+
1089
+ function summarize_event_timing(trigger, idx) {
1090
+ // summarize event trigger into human-readable string
1091
+ if (trigger.type == 'startup') return "On Startup";
1092
+ if (trigger.type == 'plugin') return "Plugin";
1093
+ if (trigger.type == 'magic') return "Magic";
1094
+ if (trigger.type == 'keyboard') return "Keyboard";
1095
+ if (trigger.type == 'single') {
1096
+ var text = app.getNiceDateTime(trigger.epoch);
1097
+ return text;
1098
+ } // single shot
1099
+
1100
+ if (trigger.type == 'interval') {
1101
+ return "Every " + Tools.getTextFromSeconds(trigger.duration, false, false);
1102
+ }
1103
+
1104
+ // years
1105
+ var year_str = '';
1106
+ var date_opts = app.getDateOptions({ timeZone: 'UTC' });
1107
+ var locale = date_opts.locale;
1108
+ var lang = (locale || '').split(/\-/)[0];
1109
+ var is_english = (lang == 'en');
1110
+ var numbering = date_opts.numberingSystem;
1111
+
1112
+ function build_formatter(extra) {
1113
+ var opts = Object.assign({}, date_opts, extra);
1114
+ var loc = opts.locale;
1115
+ delete opts.locale;
1116
+ return new Intl.DateTimeFormat(loc, opts);
1117
+ }
1118
+ var month_fmt = build_formatter({ month: 'long' });
1119
+ var wday_fmt = build_formatter({ weekday: 'long' });
1120
+ var hour_fmt = build_formatter({ hour: 'numeric' });
1121
+ var hm_fmt = build_formatter({ hour: 'numeric', minute: '2-digit' });
1122
+ var num_fmt = new Intl.NumberFormat(locale, { useGrouping: false, numberingSystem: numbering });
1123
+ var num2_fmt = new Intl.NumberFormat(locale, { useGrouping: false, numberingSystem: numbering, minimumIntegerDigits: 2 });
1124
+
1125
+ function format_number(num, min_digits) {
1126
+ return (min_digits ? num2_fmt : num_fmt).format(num);
1127
+ }
1128
+ function format_ord(num) {
1129
+ // keep English ordinal suffixes, but localize digits
1130
+ var suffix = 'th';
1131
+ if ((num % 100 < 11) || (num % 100 > 13)) {
1132
+ switch (num % 10) {
1133
+ case 1: suffix = 'st'; break;
1134
+ case 2: suffix = 'nd'; break;
1135
+ case 3: suffix = 'rd'; break;
1136
+ }
1137
+ }
1138
+ return format_number(num) + suffix;
1139
+ }
1140
+ function format_month_name(num) {
1141
+ return month_fmt.format( new Date(Date.UTC(2020, num - 1, 1)) );
1142
+ }
1143
+ function format_weekday_name(num) {
1144
+ return wday_fmt.format( new Date(Date.UTC(2020, 7, 2 + num)) );
1145
+ }
1146
+ function normalize_time_label(text) {
1147
+ if (!is_english) return text;
1148
+ return text.replace(/\s+/g, '').replace(/am|pm/i, function(m_all) { return m_all.toLowerCase(); });
1149
+ }
1150
+ function format_hour_name(num) {
1151
+ return normalize_time_label( hour_fmt.format( new Date(Date.UTC(2020, 0, 1, num, 0)) ) );
1152
+ }
1153
+ function format_time_name(hour, minute) {
1154
+ return normalize_time_label( hm_fmt.format( new Date(Date.UTC(2020, 0, 1, hour, minute)) ) );
1155
+ }
1156
+ function format_weekday_label(num) {
1157
+ var name = format_weekday_name(num);
1158
+ return is_english ? (name + 's') : name;
1159
+ }
1160
+ function format_month_day(num) {
1161
+ if (num < 0) {
1162
+ if (num == -1) return 'last day';
1163
+ return format_ord(Math.abs(num)) + ' last day';
1164
+ }
1165
+ return format_ord(num);
1166
+ }
1167
+ function format_minute_label(num) {
1168
+ if (num == 0) return 'hour';
1169
+ if (num == 30) return 'half-hour';
1170
+ return ':' + format_number(num, 2);
1171
+ }
1172
+
1173
+ if (trigger.years && trigger.years.length) {
1174
+ year_str = get_pretty_int_list(trigger.years, true).replace(/(\d+)/g, function(m_all, m_g1) {
1175
+ return format_number( parseInt(m_g1) );
1176
+ });
1177
+ }
1178
+
1179
+ // months
1180
+ var mon_str = '';
1181
+ if (trigger.months && trigger.months.length) {
1182
+ mon_str = get_pretty_int_list(trigger.months, true).replace(/(\d+)/g, function(m_all, m_g1) {
1183
+ return format_month_name( parseInt(m_g1) );
1184
+ });
1185
+ }
1186
+
1187
+ // days
1188
+ var mday_str = '';
1189
+ if (trigger.days && trigger.days.length) {
1190
+ mday_str = get_pretty_int_list(trigger.days, true).replace(/(\-?\d+)/g, function(m_all, m_g1) {
1191
+ return format_month_day( parseInt(m_g1) );
1192
+ });
1193
+ }
1194
+
1195
+ // weekdays
1196
+ var wday_str = '';
1197
+ if (trigger.weekdays && trigger.weekdays.length) {
1198
+ var wdays = Tools.copyHash(trigger.weekdays, true).sort( function(a, b) { return a - b; } );
1199
+ if ((wdays.length == 5) && (wdays[0] == 1) && (wdays[4] == 5)) {
1200
+ wday_str = 'weekdays';
1201
+ }
1202
+ else {
1203
+ wday_str = get_pretty_int_list(wdays, true).replace(/(\d+)/g, function(m_all, m_g1) {
1204
+ return format_weekday_label( parseInt(m_g1) );
1205
+ });
1206
+ }
1207
+ }
1208
+
1209
+ // hours
1210
+ var hour_str = '';
1211
+ if (trigger.hours && trigger.hours.length) {
1212
+ hour_str = get_pretty_int_list(trigger.hours, true).replace(/(\d+)/g, function(m_all, m_g1) {
1213
+ return format_hour_name( parseInt(m_g1) );
1214
+ });
1215
+ }
1216
+
1217
+ // minutes
1218
+ var min_str = '';
1219
+ if (trigger.minutes && trigger.minutes.length) {
1220
+ var mins = Tools.copyHash(trigger.minutes, true).sort( function(a, b) { return a - b; } );
1221
+ min_str = get_pretty_str_list( mins.map( function(min) { return format_minute_label(min); } ) );
1222
+ }
1223
+
1224
+ // construct final string
1225
+ var groups = [];
1226
+ var mday_compressed = false;
1227
+
1228
+ if (year_str) {
1229
+ groups.push( 'in ' + year_str );
1230
+ if (mon_str) groups.push( mon_str );
1231
+ }
1232
+ else if (mon_str) {
1233
+ // compress single month + single day
1234
+ if (trigger.months && trigger.months.length == 1 && trigger.days && trigger.days.length == 1) {
1235
+ groups.push( 'on ' + mon_str + ' ' + mday_str );
1236
+ mday_compressed = true;
1237
+ }
1238
+ else {
1239
+ groups.push( 'in ' + mon_str );
1240
+ }
1241
+ }
1242
+
1243
+ if (mday_str && !mday_compressed) {
1244
+ if (mon_str || wday_str) groups.push( 'on the ' + mday_str );
1245
+ else groups.push( 'monthly on the ' + mday_str );
1246
+ }
1247
+ if (wday_str) groups.push( 'on ' + wday_str );
1248
+
1249
+ // compress single hour + single minute
1250
+ if (trigger.hours && trigger.hours.length == 1 && trigger.minutes && trigger.minutes.length == 1) {
1251
+ var new_str = format_time_name(trigger.hours[0], trigger.minutes[0]);
1252
+
1253
+ if (mday_str || wday_str) groups.push( 'at ' + new_str );
1254
+ else groups.push( 'daily at ' + new_str );
1255
+ }
1256
+ else {
1257
+ var min_added = false;
1258
+ if (hour_str) {
1259
+ if (mday_str || wday_str) groups.push( 'at ' + hour_str );
1260
+ else groups.push( 'daily at ' + hour_str );
1261
+ }
1262
+ else {
1263
+ // check for repeating minute pattern
1264
+ if (trigger.minutes && trigger.minutes.length) {
1265
+ var interval = detect_num_interval( trigger.minutes, 60 );
1266
+ if (interval) {
1267
+ var new_str = 'every ' + interval + ' minutes';
1268
+ if (trigger.minutes[0] > 0) {
1269
+ new_str += ' starting on the :' + format_number(trigger.minutes[0], 2);
1270
+ }
1271
+ groups.push( new_str );
1272
+ min_added = true;
1273
+ }
1274
+ }
1275
+
1276
+ if (!min_added) {
1277
+ if (min_str) groups.push( 'hourly' );
1278
+ }
1279
+ }
1280
+
1281
+ if (!min_added) {
1282
+ if (min_str) groups.push( 'on the ' + min_str );
1283
+ else groups.push( 'every minute' );
1284
+ }
1285
+ }
1286
+
1287
+ var text = (typeof(idx) != 'undefined') ? groups.join(' ') : groups.join(', ');
1288
+ var output = text;
1289
+ if (!idx) output = text.substring(0, 1).toUpperCase() + text.substring(1, text.length);
1290
+ var timing_tz = trigger.timezone || app.config.tz;
1291
+ var user_tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
1292
+ if (timing_tz && user_tz && (timing_tz != user_tz)) output += ' (' + timing_tz + ')';
1293
+
1294
+ return output;
1295
+ };
1296
+
1297
+ function detect_num_interval(arr, max) {
1298
+ // detect interval between array elements, return if found
1299
+ // all elements must have same interval between them
1300
+ if (arr.length < 2) return false;
1301
+ // if (arr[0] > 0) return false;
1302
+
1303
+ var interval = arr[1] - arr[0];
1304
+ for (var idx = 1, len = arr.length; idx < len; idx++) {
1305
+ var temp = arr[idx] - arr[idx - 1];
1306
+ if (temp != interval) return false;
1307
+ }
1308
+
1309
+ // if max is provided, final element + interval must equal max
1310
+ // if (max && (arr[arr.length - 1] + interval != max)) return false;
1311
+ if (max && ((arr[arr.length - 1] + interval) % max != arr[0])) return false;
1312
+
1313
+ return interval;
1314
+ };