figranium 0.9.1 → 0.9.6

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,316 @@
1
+ /**
2
+ * Zero-dependency cron expression parser, builder, and scheduler utility.
3
+ * Supports standard 5-field cron: minute hour day-of-month month day-of-week
4
+ * Plus presets: @yearly, @monthly, @weekly, @daily, @hourly
5
+ */
6
+
7
+ const PRESETS = {
8
+ '@yearly': '0 0 1 1 *',
9
+ '@annually': '0 0 1 1 *',
10
+ '@monthly': '0 0 1 * *',
11
+ '@weekly': '0 0 * * 0',
12
+ '@daily': '0 0 * * *',
13
+ '@midnight': '0 0 * * *',
14
+ '@hourly': '0 * * * *',
15
+ };
16
+
17
+ const FIELD_RANGES = [
18
+ { name: 'minute', min: 0, max: 59 },
19
+ { name: 'hour', min: 0, max: 23 },
20
+ { name: 'dayOfMonth', min: 1, max: 31 },
21
+ { name: 'month', min: 1, max: 12 },
22
+ { name: 'dayOfWeek', min: 0, max: 7 }, // 0 and 7 both represent Sunday
23
+ ];
24
+
25
+ /**
26
+ * Parse a single cron field into a Set of valid integer values.
27
+ */
28
+ function parseField(field, min, max) {
29
+ const values = new Set();
30
+
31
+ for (const part of field.split(',')) {
32
+ const stepMatch = part.match(/^(.+)\/(\d+)$/);
33
+ let base = stepMatch ? stepMatch[1] : part;
34
+ const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
35
+
36
+ if (step < 1) throw new Error(`Invalid step: ${part}`);
37
+
38
+ let rangeStart = min;
39
+ let rangeEnd = max;
40
+
41
+ if (base === '*') {
42
+ // full range
43
+ } else {
44
+ const rangeMatch = base.match(/^(\d+)-(\d+)$/);
45
+ if (rangeMatch) {
46
+ rangeStart = parseInt(rangeMatch[1], 10);
47
+ rangeEnd = parseInt(rangeMatch[2], 10);
48
+ } else {
49
+ const val = parseInt(base, 10);
50
+ if (isNaN(val)) throw new Error(`Invalid cron field value: ${base}`);
51
+ if (!stepMatch) {
52
+ // single value, clamp dayOfWeek 7 → 0
53
+ values.add(val === 7 && max === 7 ? 0 : val);
54
+ continue;
55
+ }
56
+ rangeStart = val;
57
+ }
58
+ }
59
+
60
+ for (let i = rangeStart; i <= rangeEnd; i += step) {
61
+ values.add(i === 7 && max === 7 ? 0 : i);
62
+ }
63
+ }
64
+
65
+ return values;
66
+ }
67
+
68
+ /**
69
+ * Parse a full cron expression into an object with Sets for each field.
70
+ * @param {string} expression
71
+ * @returns {{ minute: Set<number>, hour: Set<number>, dayOfMonth: Set<number>, month: Set<number>, dayOfWeek: Set<number> }}
72
+ */
73
+ function parseCron(expression) {
74
+ if (!expression || typeof expression !== 'string') {
75
+ throw new Error('Invalid cron expression');
76
+ }
77
+
78
+ const expr = expression.trim().toLowerCase();
79
+ const resolved = PRESETS[expr] || expr;
80
+ const parts = resolved.split(/\s+/);
81
+
82
+ if (parts.length !== 5) {
83
+ throw new Error(`Cron expression must have 5 fields, got ${parts.length}: "${expression}"`);
84
+ }
85
+
86
+ const result = {};
87
+ for (let i = 0; i < 5; i++) {
88
+ const { name, min, max } = FIELD_RANGES[i];
89
+ result[name] = parseField(parts[i], min, max);
90
+ }
91
+ return result;
92
+ }
93
+
94
+ /**
95
+ * Check if a cron expression is valid.
96
+ * @param {string} expression
97
+ * @returns {boolean}
98
+ */
99
+ function isValidCron(expression) {
100
+ try {
101
+ parseCron(expression);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Get the next run time after `from` that matches the cron expression.
110
+ * @param {string} expression
111
+ * @param {Date} [from]
112
+ * @returns {Date}
113
+ */
114
+ function getNextRun(expression, from) {
115
+ const fields = parseCron(expression);
116
+ const d = from ? new Date(from) : new Date();
117
+ // Start from the next minute
118
+ d.setSeconds(0, 0);
119
+ d.setMinutes(d.getMinutes() + 1);
120
+
121
+ // Safety: limit iterations to avoid infinite loop (covers ~4 years)
122
+ const maxIterations = 366 * 24 * 60;
123
+ for (let i = 0; i < maxIterations; i++) {
124
+ if (
125
+ fields.month.has(d.getMonth() + 1) &&
126
+ fields.dayOfMonth.has(d.getDate()) &&
127
+ fields.dayOfWeek.has(d.getDay()) &&
128
+ fields.hour.has(d.getHours()) &&
129
+ fields.minute.has(d.getMinutes())
130
+ ) {
131
+ return d;
132
+ }
133
+ d.setMinutes(d.getMinutes() + 1);
134
+ }
135
+
136
+ throw new Error(`Could not find next run for expression: ${expression}`);
137
+ }
138
+
139
+ /**
140
+ * Convert a no-code schedule config object into a cron expression.
141
+ * @param {object} config
142
+ * @param {string} config.frequency - 'interval' | 'hourly' | 'daily' | 'weekly' | 'monthly'
143
+ * @param {number} [config.intervalMinutes] - for 'interval' frequency
144
+ * @param {number} [config.hour] - hour (0-23)
145
+ * @param {number} [config.minute] - minute (0-59)
146
+ * @param {number[]} [config.daysOfWeek] - for 'weekly' (0=Sun..6=Sat)
147
+ * @param {number} [config.dayOfMonth] - for 'monthly' (1-31)
148
+ * @returns {string}
149
+ */
150
+ function scheduleToCron(config) {
151
+ if (!config || !config.frequency) {
152
+ throw new Error('Schedule config must include a frequency');
153
+ }
154
+
155
+ const min = config.minute ?? 0;
156
+ const hr = config.hour ?? 0;
157
+
158
+ switch (config.frequency) {
159
+ case 'interval': {
160
+ const interval = config.intervalMinutes || 5;
161
+ if (interval <= 0 || interval > 1440) throw new Error('Interval must be 1-1440 minutes');
162
+
163
+ if (interval <= 60) {
164
+ if (60 % interval === 0) {
165
+ return `*/${interval} * * * *`;
166
+ }
167
+ const minutes = [];
168
+ for (let m = 0; m < 60; m += interval) {
169
+ minutes.push(m);
170
+ }
171
+ return `${minutes.join(',')} * * * *`;
172
+ } else {
173
+ // For intervals > 60m, use hours/minutes
174
+ const hrs = Math.floor(interval / 60);
175
+ const mins = interval % 60;
176
+ if (hrs < 24 && 24 % hrs === 0 && mins === 0) {
177
+ return `0 */${hrs} * * *`;
178
+ }
179
+ // Fallback to daily if too complex, or just return hourly with 0 min
180
+ return `0 */${Math.max(1, hrs)} * * *`;
181
+ }
182
+ }
183
+ case 'hourly':
184
+ return `${min} * * * *`;
185
+ case 'daily':
186
+ return `${min} ${hr} * * *`;
187
+ case 'weekly': {
188
+ const days = Array.isArray(config.daysOfWeek) && config.daysOfWeek.length > 0
189
+ ? config.daysOfWeek.sort().join(',')
190
+ : '*';
191
+ return `${min} ${hr} * * ${days}`;
192
+ }
193
+ case 'monthly': {
194
+ const dom = config.dayOfMonth || 1;
195
+ return `${min} ${hr} ${dom} * *`;
196
+ }
197
+ default:
198
+ throw new Error(`Unknown frequency: ${config.frequency}`);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Produce a human-readable description of a cron expression.
204
+ * @param {string} expression
205
+ * @returns {string}
206
+ */
207
+ function describeCron(expression) {
208
+ if (!expression || typeof expression !== 'string') return '';
209
+
210
+ const expr = expression.trim().toLowerCase();
211
+ if (PRESETS[expr]) {
212
+ const labels = {
213
+ '@yearly': 'Every year on January 1st at midnight',
214
+ '@annually': 'Every year on January 1st at midnight',
215
+ '@monthly': 'First day of every month at midnight',
216
+ '@weekly': 'Every Sunday at midnight',
217
+ '@daily': 'Every day at midnight',
218
+ '@midnight': 'Every day at midnight',
219
+ '@hourly': 'Every hour',
220
+ };
221
+ return labels[expr] || expr;
222
+ }
223
+
224
+ try {
225
+ const parts = expr.split(/\s+/);
226
+ if (parts.length !== 5) return expression;
227
+
228
+ const [minPart, hrPart, domPart, monPart, dowPart] = parts;
229
+
230
+ const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
231
+ const monthNames = ['', 'January', 'February', 'March', 'April', 'May', 'June',
232
+ 'July', 'August', 'September', 'October', 'November', 'December'];
233
+
234
+ const formatTime = (h, m) => {
235
+ const hour = parseInt(h, 10);
236
+ const minute = parseInt(m, 10);
237
+ if (isNaN(hour) || isNaN(minute)) return '';
238
+ const ampm = hour >= 12 ? 'PM' : 'AM';
239
+ const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
240
+ return `${h12}:${String(minute).padStart(2, '0')} ${ampm}`;
241
+ };
242
+
243
+ // Every N minutes
244
+ const stepMatch = minPart.match(/^\*\/(\d+)$/);
245
+ if (stepMatch && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
246
+ const n = parseInt(stepMatch[1], 10);
247
+ if (n === 1) return 'Every minute';
248
+ return `Every ${n} minutes`;
249
+ }
250
+
251
+ // Every minute
252
+ if (minPart === '*' && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
253
+ return 'Every minute';
254
+ }
255
+
256
+ // Comma separated minutes (common for non-divisible intervals)
257
+ if (hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*' && minPart.includes(',')) {
258
+ const mins = minPart.split(',');
259
+ if (mins.every(m => /^\d+$/.test(m))) {
260
+ const diffs = [];
261
+ for (let i = 1; i < mins.length; i++) diffs.push(parseInt(mins[i]) - parseInt(mins[i-1]));
262
+ const uniqueDiffs = new Set(diffs);
263
+ if (uniqueDiffs.size === 1) {
264
+ return `Every ${uniqueDiffs.values().next().value} minutes`;
265
+ }
266
+ }
267
+ }
268
+
269
+ // Specific minute, every hour
270
+ if (/^\d+$/.test(minPart) && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
271
+ const m = parseInt(minPart, 10);
272
+ return `Every hour at :${String(m).padStart(2, '0')}`;
273
+ }
274
+
275
+ // Daily at specific time
276
+ if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && domPart === '*' && monPart === '*' && dowPart === '*') {
277
+ return `Every day at ${formatTime(hrPart, minPart)}`;
278
+ }
279
+
280
+ // Weekly
281
+ if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && domPart === '*' && monPart === '*' && dowPart !== '*') {
282
+ const dows = dowPart.split(',').map(d => {
283
+ const n = parseInt(d, 10);
284
+ return dayNames[n === 7 ? 0 : n] || d;
285
+ });
286
+ if (dows.length === 5 && !dows.includes('Saturday') && !dows.includes('Sunday')) {
287
+ return `Every weekday at ${formatTime(hrPart, minPart)}`;
288
+ }
289
+ if (dows.length === 2 && dows.includes('Saturday') && dows.includes('Sunday')) {
290
+ return `Every weekend at ${formatTime(hrPart, minPart)}`;
291
+ }
292
+ return `Every ${dows.join(', ')} at ${formatTime(hrPart, minPart)}`;
293
+ }
294
+
295
+ // Monthly
296
+ if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && /^\d+$/.test(domPart) && monPart === '*' && dowPart === '*') {
297
+ const dom = parseInt(domPart, 10);
298
+ const suffix = dom === 1 ? 'st' : dom === 2 ? 'nd' : dom === 3 ? 'rd' : 'th';
299
+ return `Monthly on the ${dom}${suffix} at ${formatTime(hrPart, minPart)}`;
300
+ }
301
+
302
+ // Fallback: return a structured description
303
+ return expression;
304
+ } catch {
305
+ return expression;
306
+ }
307
+ }
308
+
309
+ module.exports = {
310
+ parseCron,
311
+ isValidCron,
312
+ getNextRun,
313
+ scheduleToCron,
314
+ describeCron,
315
+ PRESETS,
316
+ };
@@ -0,0 +1,171 @@
1
+ const express = require('express');
2
+ const { requireAuth } = require('../middleware');
3
+ const { loadTasks, saveTasks, getTaskById } = require('../storage');
4
+ const { taskMutex } = require('../state');
5
+ const { refreshSchedule, removeSchedule, getSchedulerStatus, resolveCron } = require('../scheduler');
6
+ const { isValidCron, describeCron, getNextRun } = require('../cron-parser');
7
+
8
+ const router = express.Router();
9
+
10
+ /**
11
+ * GET /api/schedules
12
+ * List all tasks that have schedules (enabled or not), with status info.
13
+ */
14
+ router.get('/', requireAuth, async (req, res) => {
15
+ const tasks = await loadTasks();
16
+ const schedules = tasks
17
+ .filter(t => t.schedule)
18
+ .map(t => ({
19
+ taskId: t.id,
20
+ taskName: t.name,
21
+ mode: t.mode,
22
+ schedule: t.schedule
23
+ }));
24
+ res.json({ schedules });
25
+ });
26
+
27
+ /**
28
+ * POST /api/schedules/:taskId
29
+ * Create or update a schedule on a task.
30
+ * Body: { enabled, frequency?, intervalMinutes?, hour?, minute?, daysOfWeek?, dayOfMonth?, cron? }
31
+ */
32
+ router.post('/:taskId', requireAuth, async (req, res) => {
33
+ await taskMutex.lock();
34
+ try {
35
+ const tasks = await loadTasks();
36
+ const task = tasks.find(t => t.id === req.params.taskId);
37
+ if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
38
+
39
+ const body = req.body || {};
40
+ const schedule = {
41
+ ...(task.schedule || {}),
42
+ ...body,
43
+ };
44
+
45
+ // If body explicitly provides one mode, clear the other to avoid mode-switching bugs
46
+ if (body.cron && !body.frequency) {
47
+ delete schedule.frequency;
48
+ delete schedule.intervalMinutes;
49
+ delete schedule.hour;
50
+ delete schedule.minute;
51
+ delete schedule.daysOfWeek;
52
+ delete schedule.dayOfMonth;
53
+ } else if (body.frequency && !body.cron) {
54
+ delete schedule.cron;
55
+ }
56
+
57
+ // Handle explicit nulls (JSON doesn't support undefined, so null is common)
58
+ if (body.cron === null) delete schedule.cron;
59
+ if (body.frequency === null) delete schedule.frequency;
60
+
61
+ // Validate the resulting cron
62
+ const cron = resolveCron(schedule);
63
+ if (schedule.enabled && !cron) {
64
+ return res.status(400).json({ error: 'INVALID_SCHEDULE', message: 'Cannot resolve a valid cron expression from the provided schedule config.' });
65
+ }
66
+
67
+ // Compute metadata
68
+ if (cron) {
69
+ schedule.cron = cron;
70
+ try {
71
+ const nextRun = getNextRun(cron);
72
+ schedule.nextRun = nextRun.getTime();
73
+ } catch {
74
+ schedule.nextRun = null;
75
+ }
76
+ }
77
+
78
+ task.schedule = schedule;
79
+ await saveTasks(tasks);
80
+
81
+ // Notify scheduler
82
+ await refreshSchedule(task.id);
83
+
84
+ res.json({
85
+ schedule: task.schedule,
86
+ description: cron ? describeCron(cron) : null,
87
+ nextRun: cron ? getNextRun(cron).getTime() : null
88
+ });
89
+ } finally {
90
+ taskMutex.unlock();
91
+ }
92
+ });
93
+
94
+ /**
95
+ * DELETE /api/schedules/:taskId
96
+ * Remove/disable schedule from a task.
97
+ */
98
+ router.delete('/:taskId', requireAuth, async (req, res) => {
99
+ await taskMutex.lock();
100
+ try {
101
+ const tasks = await loadTasks();
102
+ const task = tasks.find(t => t.id === req.params.taskId);
103
+ if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
104
+
105
+ if (task.schedule) {
106
+ task.schedule.enabled = false;
107
+ }
108
+
109
+ await saveTasks(tasks);
110
+ removeSchedule(task.id);
111
+
112
+ res.json({ success: true });
113
+ } finally {
114
+ taskMutex.unlock();
115
+ }
116
+ });
117
+
118
+ /**
119
+ * GET /api/schedules/:taskId/status
120
+ * Get schedule status for a specific task.
121
+ */
122
+ router.get('/:taskId/status', requireAuth, async (req, res) => {
123
+ await loadTasks();
124
+ const task = getTaskById(req.params.taskId);
125
+ if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
126
+
127
+ const schedule = task.schedule || {};
128
+ const cron = resolveCron(schedule);
129
+
130
+ res.json({
131
+ schedule,
132
+ cron,
133
+ description: cron ? describeCron(cron) : null,
134
+ isValid: cron ? isValidCron(cron) : false
135
+ });
136
+ });
137
+
138
+ /**
139
+ * POST /api/schedules/:taskId/describe
140
+ * Validate and describe a schedule config without saving it.
141
+ */
142
+ router.post('/:taskId/describe', requireAuth, async (req, res) => {
143
+ const body = req.body || {};
144
+ const cron = resolveCron(body);
145
+
146
+ if (!cron) {
147
+ return res.json({ valid: false, description: null, cron: null, nextRun: null });
148
+ }
149
+
150
+ let nextRun = null;
151
+ try {
152
+ nextRun = getNextRun(cron).getTime();
153
+ } catch { }
154
+
155
+ res.json({
156
+ valid: true,
157
+ cron,
158
+ description: describeCron(cron),
159
+ nextRun
160
+ });
161
+ });
162
+
163
+ /**
164
+ * GET /api/schedules/status/all
165
+ * Get overall scheduler status.
166
+ */
167
+ router.get('/status/all', requireAuth, async (_req, res) => {
168
+ res.json(getSchedulerStatus());
169
+ });
170
+
171
+ module.exports = router;