jw-automator 2.0.0 → 3.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,237 @@
1
+ /**
2
+ * RecurrenceEngine.js
3
+ *
4
+ * Pure recurrence calculation logic.
5
+ * Handles all DST transitions, local-time semantics, and next-run computations.
6
+ */
7
+
8
+ class RecurrenceEngine {
9
+ /**
10
+ * Calculate the next occurrence time for an action based on its recurrence rule.
11
+ *
12
+ * CRITICAL INVARIANT: nextTime.getTime() > currentTime.getTime()
13
+ *
14
+ * @param {Date} currentTime - The current scheduled time (local)
15
+ * @param {Object} repeat - The repeat configuration
16
+ * @param {string} dstPolicy - 'once' or 'twice' for fall-back behavior
17
+ * @returns {Date} - The next scheduled time (always > currentTime in UTC milliseconds)
18
+ */
19
+ static getNextOccurrence(currentTime, repeat, dstPolicy = 'once') {
20
+ if (!repeat || !repeat.type) {
21
+ return null;
22
+ }
23
+
24
+ const { type, interval = 1 } = repeat;
25
+ const current = new Date(currentTime);
26
+ let next = null;
27
+
28
+ switch (type) {
29
+ case 'second':
30
+ next = this._addSeconds(current, interval);
31
+ break;
32
+ case 'minute':
33
+ next = this._addMinutes(current, interval);
34
+ break;
35
+ case 'hour':
36
+ next = this._addHours(current, interval);
37
+ break;
38
+ case 'day':
39
+ next = this._addDays(current, interval);
40
+ break;
41
+ case 'weekday':
42
+ next = this._addWeekdays(current, interval);
43
+ break;
44
+ case 'weekend':
45
+ next = this._addWeekends(current, interval);
46
+ break;
47
+ case 'week':
48
+ next = this._addWeeks(current, interval);
49
+ break;
50
+ case 'month':
51
+ next = this._addMonths(current, interval);
52
+ break;
53
+ case 'year':
54
+ next = this._addYears(current, interval);
55
+ break;
56
+ default:
57
+ throw new Error(`Unknown recurrence type: ${type}`);
58
+ }
59
+
60
+ // SAFETY: Ensure monotonic progression
61
+ if (next && next.getTime() <= current.getTime()) {
62
+ // Force forward by 1 second to prevent infinite loops
63
+ next = new Date(current.getTime() + 1000);
64
+ }
65
+
66
+ // Handle DST transitions
67
+ next = this._handleDSTTransition(current, next, dstPolicy);
68
+
69
+ return next;
70
+ }
71
+
72
+ /**
73
+ * Add seconds to a date
74
+ */
75
+ static _addSeconds(date, count) {
76
+ return new Date(date.getTime() + (count * 1000));
77
+ }
78
+
79
+ /**
80
+ * Add minutes to a date
81
+ */
82
+ static _addMinutes(date, count) {
83
+ return new Date(date.getTime() + (count * 60 * 1000));
84
+ }
85
+
86
+ /**
87
+ * Add hours to a date
88
+ */
89
+ static _addHours(date, count) {
90
+ return new Date(date.getTime() + (count * 60 * 60 * 1000));
91
+ }
92
+
93
+ /**
94
+ * Add days to a date (preserving local time across DST)
95
+ */
96
+ static _addDays(date, count) {
97
+ const result = new Date(date);
98
+ result.setDate(result.getDate() + count);
99
+ return result;
100
+ }
101
+
102
+ /**
103
+ * Add weekdays (Mon-Fri) to a date
104
+ */
105
+ static _addWeekdays(date, count) {
106
+ const result = new Date(date);
107
+ let added = 0;
108
+
109
+ while (added < count) {
110
+ result.setDate(result.getDate() + 1);
111
+ const day = result.getDay();
112
+ // 0 = Sunday, 6 = Saturday
113
+ if (day !== 0 && day !== 6) {
114
+ added++;
115
+ }
116
+ }
117
+
118
+ return result;
119
+ }
120
+
121
+ /**
122
+ * Add weekend days (Sat-Sun) to a date
123
+ */
124
+ static _addWeekends(date, count) {
125
+ const result = new Date(date);
126
+ let added = 0;
127
+
128
+ while (added < count) {
129
+ result.setDate(result.getDate() + 1);
130
+ const day = result.getDay();
131
+ // 0 = Sunday, 6 = Saturday
132
+ if (day === 0 || day === 6) {
133
+ added++;
134
+ }
135
+ }
136
+
137
+ return result;
138
+ }
139
+
140
+ /**
141
+ * Add weeks to a date
142
+ */
143
+ static _addWeeks(date, count) {
144
+ return this._addDays(date, count * 7);
145
+ }
146
+
147
+ /**
148
+ * Add months to a date (preserving day-of-month when possible)
149
+ */
150
+ static _addMonths(date, count) {
151
+ const result = new Date(date);
152
+ const originalDay = result.getDate();
153
+
154
+ result.setMonth(result.getMonth() + count);
155
+
156
+ // Handle edge case: Jan 31 + 1 month should be Feb 28/29, not Mar 3
157
+ if (result.getDate() !== originalDay) {
158
+ result.setDate(0); // Set to last day of previous month
159
+ }
160
+
161
+ return result;
162
+ }
163
+
164
+ /**
165
+ * Add years to a date
166
+ */
167
+ static _addYears(date, count) {
168
+ const result = new Date(date);
169
+ result.setFullYear(result.getFullYear() + count);
170
+ return result;
171
+ }
172
+
173
+ /**
174
+ * Handle DST transitions between current and next time
175
+ *
176
+ * - Spring forward: missing hour handled naturally by Date constructor
177
+ * - Fall back: prevent duplicate execution based on dstPolicy
178
+ */
179
+ static _handleDSTTransition(currentTime, nextTime, dstPolicy) {
180
+ if (!nextTime) return nextTime;
181
+
182
+ const currentOffset = currentTime.getTimezoneOffset();
183
+ const nextOffset = nextTime.getTimezoneOffset();
184
+
185
+ // No DST transition
186
+ if (currentOffset === nextOffset) {
187
+ return nextTime;
188
+ }
189
+
190
+ // Spring forward (offset decreased - clock jumped ahead)
191
+ // The missing hour is handled naturally by Date arithmetic
192
+ // No special handling needed
193
+
194
+ // Fall back (offset increased - clock fell back)
195
+ if (nextOffset > currentOffset) {
196
+ // We've crossed into the repeated hour
197
+ if (dstPolicy === 'once') {
198
+ // Skip the second occurrence by adding the offset difference
199
+ const offsetDiff = (nextOffset - currentOffset) * 60 * 1000;
200
+ return new Date(nextTime.getTime() + offsetDiff);
201
+ }
202
+ // dstPolicy === 'twice': allow both occurrences (no adjustment needed)
203
+ }
204
+
205
+ return nextTime;
206
+ }
207
+
208
+ /**
209
+ * Check if an action should stop based on limit or endDate
210
+ */
211
+ static shouldStop(action) {
212
+ const { repeat } = action;
213
+
214
+ if (!repeat) return true;
215
+
216
+ // Check count limit
217
+ if (repeat.limit !== null && repeat.limit !== undefined) {
218
+ const count = action.count || 0;
219
+ if (count >= repeat.limit) {
220
+ return true;
221
+ }
222
+ }
223
+
224
+ // Check end date
225
+ if (repeat.endDate) {
226
+ const endDate = new Date(repeat.endDate);
227
+ const nextRun = new Date(action.date);
228
+ if (nextRun > endDate) {
229
+ return true;
230
+ }
231
+ }
232
+
233
+ return false;
234
+ }
235
+ }
236
+
237
+ module.exports = RecurrenceEngine;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * SchedulerHost.js
3
+ *
4
+ * Manages real-time ticking with 1-second alignment.
5
+ * Wraps the core engine and drives it with wall-clock time.
6
+ */
7
+
8
+ const EventEmitter = require('events');
9
+ const CoreEngine = require('../core/CoreEngine');
10
+
11
+ class SchedulerHost extends EventEmitter {
12
+ constructor() {
13
+ super();
14
+ this.state = { actions: [] };
15
+ this.running = false;
16
+ this.lastTick = null;
17
+ this.timer = null;
18
+ this.functions = new Map();
19
+ }
20
+
21
+ /**
22
+ * Start the scheduler
23
+ */
24
+ start() {
25
+ if (this.running) {
26
+ return;
27
+ }
28
+
29
+ this.running = true;
30
+ this.lastTick = new Date();
31
+ this.lastTick.setMilliseconds(0);
32
+
33
+ this._scheduleTick();
34
+ this.emit('ready');
35
+ }
36
+
37
+ /**
38
+ * Stop the scheduler
39
+ */
40
+ stop() {
41
+ this.running = false;
42
+ if (this.timer) {
43
+ clearTimeout(this.timer);
44
+ this.timer = null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Register a command function
50
+ */
51
+ addFunction(name, fn) {
52
+ if (typeof fn !== 'function') {
53
+ throw new Error(`Function ${name} must be a function`);
54
+ }
55
+ this.functions.set(name, fn);
56
+ }
57
+
58
+ /**
59
+ * Remove a command function
60
+ */
61
+ removeFunction(name) {
62
+ this.functions.delete(name);
63
+ }
64
+
65
+ /**
66
+ * Schedule the next tick aligned to whole seconds
67
+ */
68
+ _scheduleTick() {
69
+ if (!this.running) {
70
+ return;
71
+ }
72
+
73
+ const now = new Date();
74
+ const milliseconds = now.getMilliseconds();
75
+
76
+ // Calculate wait time to next whole second
77
+ const waitTime = 1000 - milliseconds;
78
+
79
+ this.timer = setTimeout(() => {
80
+ this._tick();
81
+ }, waitTime);
82
+ }
83
+
84
+ /**
85
+ * Execute one tick
86
+ */
87
+ _tick() {
88
+ if (!this.running) {
89
+ return;
90
+ }
91
+
92
+ const now = new Date();
93
+ now.setMilliseconds(0); // Align to second boundary
94
+
95
+ try {
96
+ // Run the core engine step
97
+ const { newState, events } = CoreEngine.step(this.state, this.lastTick, now);
98
+
99
+ // Update state
100
+ this.state = newState;
101
+ this.lastTick = now;
102
+
103
+ // Process events
104
+ for (const event of events) {
105
+ if (event.type === 'action') {
106
+ this._executeActionEvent(event);
107
+ } else if (event.type === 'error') {
108
+ this.emit('error', event);
109
+ }
110
+ }
111
+ } catch (error) {
112
+ this.emit('error', {
113
+ type: 'error',
114
+ message: `Tick error: ${error.message}`,
115
+ error
116
+ });
117
+ }
118
+
119
+ // Schedule next tick
120
+ this._scheduleTick();
121
+ }
122
+
123
+ /**
124
+ * Execute an action event by calling its registered function
125
+ */
126
+ _executeActionEvent(event) {
127
+ // Emit the action event
128
+ this.emit('action', event);
129
+
130
+ // Execute the command function if registered
131
+ const fn = this.functions.get(event.cmd);
132
+ if (fn) {
133
+ try {
134
+ fn(event.payload, event);
135
+ } catch (error) {
136
+ this.emit('error', {
137
+ type: 'error',
138
+ message: `Error executing command ${event.cmd}: ${error.message}`,
139
+ actionId: event.actionId,
140
+ error
141
+ });
142
+ }
143
+ } else {
144
+ this.emit('debug', {
145
+ type: 'debug',
146
+ message: `No function registered for command: ${event.cmd}`,
147
+ actionId: event.actionId
148
+ });
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Add an action to the state
154
+ */
155
+ addAction(action) {
156
+ this.state.actions.push(action);
157
+ }
158
+
159
+ /**
160
+ * Get current state
161
+ */
162
+ getState() {
163
+ return this.state;
164
+ }
165
+
166
+ /**
167
+ * Set state
168
+ */
169
+ setState(state) {
170
+ this.state = state;
171
+ }
172
+ }
173
+
174
+ module.exports = SchedulerHost;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * FileStorage.js
3
+ *
4
+ * Pluggable file-based storage adapter
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ class FileStorage {
11
+ constructor(filePath) {
12
+ this.filePath = path.resolve(filePath);
13
+ }
14
+
15
+ /**
16
+ * Load state from file
17
+ */
18
+ load() {
19
+ try {
20
+ if (fs.existsSync(this.filePath)) {
21
+ const data = fs.readFileSync(this.filePath, 'utf8');
22
+ const state = JSON.parse(data);
23
+
24
+ // Convert date strings back to Date objects
25
+ if (state.actions) {
26
+ state.actions = state.actions.map(action => {
27
+ if (action.date) {
28
+ action.date = new Date(action.date);
29
+ }
30
+ if (action.repeat && action.repeat.endDate) {
31
+ action.repeat.endDate = new Date(action.repeat.endDate);
32
+ }
33
+ return action;
34
+ });
35
+ }
36
+
37
+ return state;
38
+ }
39
+ } catch (error) {
40
+ throw new Error(`Failed to load from ${this.filePath}: ${error.message}`);
41
+ }
42
+
43
+ return { actions: [] };
44
+ }
45
+
46
+ /**
47
+ * Save state to file
48
+ */
49
+ save(state) {
50
+ try {
51
+ const data = JSON.stringify(state, null, 2);
52
+ fs.writeFileSync(this.filePath, data, 'utf8');
53
+ } catch (error) {
54
+ throw new Error(`Failed to save to ${this.filePath}: ${error.message}`);
55
+ }
56
+ }
57
+ }
58
+
59
+ module.exports = FileStorage;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * MemoryStorage.js
3
+ *
4
+ * In-memory storage adapter (no persistence)
5
+ */
6
+
7
+ class MemoryStorage {
8
+ constructor() {
9
+ this.state = { actions: [] };
10
+ }
11
+
12
+ /**
13
+ * Load state from memory
14
+ */
15
+ load() {
16
+ return JSON.parse(JSON.stringify(this.state));
17
+ }
18
+
19
+ /**
20
+ * Save state to memory
21
+ */
22
+ save(state) {
23
+ this.state = JSON.parse(JSON.stringify(state));
24
+ }
25
+ }
26
+
27
+ module.exports = MemoryStorage;
package/.actions.json DELETED
@@ -1 +0,0 @@
1
- [{"name":"sec","date":"2019-07-18T16:05:38.000Z","cmd":"test","payload":"tick","unBuffered":null,"repeat":{"type":"second","interval":1,"count":34,"limit":null,"endDate":null,"executed":25},"id":1563465903653}]
package/.jshintrc DELETED
@@ -1,16 +0,0 @@
1
- {
2
- "multistr": true,
3
- "undef": true,
4
- "unused": false,
5
- "validthis": true,
6
- "node": true,
7
- "loopfunc": true,
8
- "globals": {
9
- "$": true,
10
- "document": true,
11
- "jQuery": true,
12
- "window": true,
13
- "jwf": true,
14
- "performance": true
15
- }
16
- }
@@ -1,6 +0,0 @@
1
- {
2
- "cSpell.words": [
3
- "Milli",
4
- "automator"
5
- ]
6
- }