@goodandready/dsh-cron 0.3.10 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/README.ru.md +19 -0
- package/README.zh.md +13 -0
- package/lib/api-helpers.js +9 -9
- package/lib/api-task-webhook.js +247 -0
- package/lib/api.js +39 -16
- package/lib/cron-tool.js +1 -1
- package/lib/http-utils.js +128 -35
- package/lib/index.js +2 -2
- package/lib/nl-schedule.js +338 -0
- package/lib/prompt-interpolation.js +76 -4
- package/lib/routes.js +27 -19
- package/lib/scheduler.js +21 -8
- package/lib/store.js +6 -0
- package/lib/task-create.js +4 -0
- package/lib/task-patch.js +12 -0
- package/lib/task-transfer.js +5 -0
- package/package.json +1 -1
package/lib/http-utils.js
CHANGED
|
@@ -77,65 +77,142 @@ export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
*
|
|
81
|
-
|
|
80
|
+
* Safe helper to get the DSH connection service from context or request (#357).
|
|
81
|
+
*/
|
|
82
|
+
export function getConnection(ctx) {
|
|
83
|
+
if (!ctx) return null;
|
|
84
|
+
return Reflect.get(ctx, 'connection') || ctx.connection || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getDshConnection(req, options = {}) {
|
|
88
|
+
if (options.connection) return options.connection;
|
|
89
|
+
if (options.ctx) {
|
|
90
|
+
const conn = getConnection(options.ctx);
|
|
91
|
+
if (conn) return conn;
|
|
92
|
+
}
|
|
93
|
+
if (req) {
|
|
94
|
+
if (req.connectionService) return req.connectionService;
|
|
95
|
+
if (req.ctx) {
|
|
96
|
+
const conn = getConnection(req.ctx);
|
|
97
|
+
if (conn) return conn;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Determine if an incoming request should be rejected, and with which HTTP status (401 or 403).
|
|
105
|
+
* Returns undefined if the request is admitted, or 401 / 403 if rejected (#357).
|
|
82
106
|
*
|
|
83
107
|
* Rules:
|
|
84
|
-
* 1.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
108
|
+
* 1. If DSH connection service is available, consult its admission first.
|
|
109
|
+
* - Connection service rejection 403 (untrusted Host / Origin / CSRF) is strictly rejected.
|
|
110
|
+
* - Connection service admission (undefined rejection) admits the request (browser session / Peer).
|
|
111
|
+
* 2. Token authentication: valid Bearer token matching options.apiToken, DSH_AUTH_TOKEN or DSH_TOKEN.
|
|
112
|
+
* 3. Local loopback caller: 127.0.0.1 or ::1 when connection service is not bound (standalone/CLI/test).
|
|
113
|
+
* 4. FAIL-CLOSED: if connection service is bound, request must have DSH admission or valid API token.
|
|
114
|
+
* If connection service is not bound, request must have valid API token or verified loopback IP.
|
|
115
|
+
* Requests with missing socket / missing remoteAddress and no token are rejected (401).
|
|
116
|
+
* 5. Origin and Sec-Fetch-Site: cannot be null, Origin host must match Host, Sec-Fetch-Site must be same-origin or none.
|
|
90
117
|
*/
|
|
91
|
-
export function
|
|
92
|
-
if (!req) return
|
|
118
|
+
export function getRequestRejection(req, options = {}) {
|
|
119
|
+
if (!req) return 403;
|
|
120
|
+
|
|
121
|
+
// 1. Validate Origin header if present: cannot be null, must match Host (CSRF guard)
|
|
93
122
|
const headers = req.headers || {};
|
|
123
|
+
const origin = headers.origin;
|
|
124
|
+
const host = headers.host || '';
|
|
125
|
+
if (origin !== undefined) {
|
|
126
|
+
if (origin === 'null' || !origin) return 403;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = new URL(origin);
|
|
129
|
+
if (!parsed.host || !host || parsed.host !== host) return 403;
|
|
130
|
+
} catch {
|
|
131
|
+
return 403;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
94
134
|
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
135
|
+
// 2. Validate Sec-Fetch-Site header if present
|
|
136
|
+
const site = headers['sec-fetch-site'];
|
|
137
|
+
if (site !== undefined) {
|
|
138
|
+
if (site !== 'same-origin' && site !== 'none') {
|
|
139
|
+
return 403;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 3. Check DSH connection service if available (#357)
|
|
144
|
+
const conn = getDshConnection(req, options);
|
|
145
|
+
let connRejection;
|
|
146
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
147
|
+
connRejection = conn.requestRejection(req);
|
|
148
|
+
if (connRejection === 403) {
|
|
149
|
+
return 403;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
98
152
|
|
|
153
|
+
// 4. Check token authentication
|
|
99
154
|
const token = extractBearerToken(req);
|
|
100
155
|
const expectedToken = options.apiToken || process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
|
|
101
156
|
const isTokenAuthenticated = Boolean(expectedToken && token && token === expectedToken);
|
|
102
157
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
158
|
+
// If DSH connection service admitted the request (undefined rejection)
|
|
159
|
+
const isConnectionAdmitted = Boolean(conn && connRejection === undefined);
|
|
160
|
+
|
|
161
|
+
// Check loopback address
|
|
162
|
+
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
163
|
+
const hasRemote = Boolean(remote && typeof remote === 'string');
|
|
164
|
+
const isLoopback = Boolean(hasRemote && isLoopbackAddress(remote));
|
|
165
|
+
|
|
166
|
+
// FAIL-CLOSED AUTHENTICATION CHECK (#357):
|
|
167
|
+
if (conn) {
|
|
168
|
+
if (!isConnectionAdmitted && !isTokenAuthenticated) {
|
|
169
|
+
return connRejection === 403 ? 403 : 401;
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
if (!isTokenAuthenticated && !isLoopback) {
|
|
173
|
+
if (token || !hasRemote) {
|
|
174
|
+
return 401;
|
|
175
|
+
}
|
|
176
|
+
return 403;
|
|
107
177
|
}
|
|
108
178
|
}
|
|
109
179
|
|
|
110
|
-
|
|
180
|
+
return undefined; // Admitted
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Validate that an incoming request originates from a trusted local caller,
|
|
185
|
+
* an admitted DSH browser session, or an authenticated client (Issue #86, #357).
|
|
186
|
+
*/
|
|
187
|
+
export function isTrustedRequest(req, options = {}) {
|
|
188
|
+
return getRequestRejection(req, options) === undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Backward-compatible helper for checking cross-origin browser headers (Origin / Sec-Fetch-Site).
|
|
193
|
+
*/
|
|
194
|
+
export function isCrossOrigin(req, options) {
|
|
195
|
+
if (!req) return true;
|
|
196
|
+
const headers = req.headers || {};
|
|
111
197
|
const origin = headers.origin;
|
|
112
198
|
const host = headers.host || '';
|
|
113
199
|
if (origin !== undefined) {
|
|
114
|
-
if (origin === 'null' || !origin) return
|
|
200
|
+
if (origin === 'null' || !origin) return true;
|
|
115
201
|
try {
|
|
116
202
|
const parsed = new URL(origin);
|
|
117
|
-
if (!parsed.host || !host || parsed.host !== host) return
|
|
203
|
+
if (!parsed.host || !host || parsed.host !== host) return true;
|
|
118
204
|
} catch {
|
|
119
|
-
return
|
|
205
|
+
return true;
|
|
120
206
|
}
|
|
121
207
|
}
|
|
122
208
|
|
|
123
|
-
// 3. Validate Sec-Fetch-Site header if present
|
|
124
209
|
const site = headers['sec-fetch-site'];
|
|
125
210
|
if (site !== undefined) {
|
|
126
211
|
if (site !== 'same-origin' && site !== 'none') {
|
|
127
|
-
return
|
|
212
|
+
return true;
|
|
128
213
|
}
|
|
129
214
|
}
|
|
130
|
-
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Backward-compatible wrapper for checking cross-origin / untrusted requests.
|
|
136
|
-
*/
|
|
137
|
-
export function isCrossOrigin(req, options) {
|
|
138
|
-
return !isTrustedRequest(req, options);
|
|
215
|
+
return false;
|
|
139
216
|
}
|
|
140
217
|
|
|
141
218
|
/**
|
|
@@ -158,9 +235,13 @@ export function sendJson(res, statusCode, data) {
|
|
|
158
235
|
res.end(JSON.stringify(data));
|
|
159
236
|
}
|
|
160
237
|
|
|
161
|
-
export function rejectCrossOrigin(req, res, options) {
|
|
162
|
-
|
|
163
|
-
|
|
238
|
+
export function rejectCrossOrigin(req, res, options = {}) {
|
|
239
|
+
const rejection = getRequestRejection(req, options);
|
|
240
|
+
if (rejection !== undefined) {
|
|
241
|
+
const errorMsg = rejection === 401
|
|
242
|
+
? 'Unauthorized: authentication required'
|
|
243
|
+
: 'Forbidden: untrusted or cross-origin request';
|
|
244
|
+
sendJson(res, rejection, { ok: false, error: errorMsg });
|
|
164
245
|
return true;
|
|
165
246
|
}
|
|
166
247
|
return false;
|
|
@@ -224,6 +305,12 @@ export function redactTaskSecrets(task) {
|
|
|
224
305
|
if (clone.httpBody) {
|
|
225
306
|
clone.httpBody = REDACTED_SECRET;
|
|
226
307
|
}
|
|
308
|
+
if (clone.webhookToken) {
|
|
309
|
+
clone.webhookToken = REDACTED_SECRET;
|
|
310
|
+
}
|
|
311
|
+
if (clone.webhookSecret) {
|
|
312
|
+
clone.webhookSecret = REDACTED_SECRET;
|
|
313
|
+
}
|
|
227
314
|
|
|
228
315
|
return clone;
|
|
229
316
|
}
|
|
@@ -285,5 +372,11 @@ export function restoreTaskSecrets(incoming, current) {
|
|
|
285
372
|
restored.httpBody = current.httpBody;
|
|
286
373
|
}
|
|
287
374
|
|
|
375
|
+
if (restored.webhookToken === REDACTED_SECRET && current.webhookToken !== undefined) {
|
|
376
|
+
restored.webhookToken = current.webhookToken;
|
|
377
|
+
}
|
|
378
|
+
if (restored.webhookSecret === REDACTED_SECRET && current.webhookSecret !== undefined) {
|
|
379
|
+
restored.webhookSecret = current.webhookSecret;
|
|
380
|
+
}
|
|
288
381
|
return restored;
|
|
289
382
|
}
|
package/lib/index.js
CHANGED
|
@@ -307,7 +307,7 @@ export function apply(ctx, rawConfig) {
|
|
|
307
307
|
try {
|
|
308
308
|
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
309
309
|
const apiToken = settings.apiToken;
|
|
310
|
-
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
310
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
311
311
|
|
|
312
312
|
if (req.method === 'GET') {
|
|
313
313
|
const unwrapped = {
|
|
@@ -324,7 +324,7 @@ export function apply(ctx, rawConfig) {
|
|
|
324
324
|
return;
|
|
325
325
|
}
|
|
326
326
|
if (req.method === 'POST') {
|
|
327
|
-
if (rejectCrossOrigin(req, res)) return;
|
|
327
|
+
if (rejectCrossOrigin(req, res, { ctx })) return;
|
|
328
328
|
const { body, error } = await readBody(req, res);
|
|
329
329
|
if (error) return;
|
|
330
330
|
const payload = sanitizeSettingsPayload(body);
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { Cron } from 'croner';
|
|
2
|
+
|
|
3
|
+
function pad(n) {
|
|
4
|
+
return String(n).padStart(2, '0');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse time string into { hour, minute }.
|
|
9
|
+
* Supports:
|
|
10
|
+
* - "9:00", "09:30", "18:45"
|
|
11
|
+
* - "9am", "9:30am", "6pm", "11:15pm"
|
|
12
|
+
* - "9 утра", "2 дня", "6 вечера", "11 вечера", "1 ночи"
|
|
13
|
+
* - Plain hour "9", "14"
|
|
14
|
+
*/
|
|
15
|
+
export function parseTimeString(raw) {
|
|
16
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
17
|
+
const str = raw.trim().toLowerCase();
|
|
18
|
+
|
|
19
|
+
// 1. English 12h: "9am", "9:30am", "6pm", "6:15pm"
|
|
20
|
+
const ampmMatch = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/);
|
|
21
|
+
if (ampmMatch) {
|
|
22
|
+
let hour = parseInt(ampmMatch[1], 10);
|
|
23
|
+
const minute = ampmMatch[2] ? parseInt(ampmMatch[2], 10) : 0;
|
|
24
|
+
const isPm = ampmMatch[3] === 'pm';
|
|
25
|
+
if (hour < 1 || hour > 12 || minute < 0 || minute > 59) return null;
|
|
26
|
+
if (isPm && hour !== 12) hour += 12;
|
|
27
|
+
if (!isPm && hour === 12) hour = 0;
|
|
28
|
+
return { hour, minute };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 2. Russian qualifiers: "9 утра", "2 дня", "6 вечера", "11 вечера", "1 ночи"
|
|
32
|
+
const ruMatch = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(утра|дня|вечера|ночи)$/);
|
|
33
|
+
if (ruMatch) {
|
|
34
|
+
let hour = parseInt(ruMatch[1], 10);
|
|
35
|
+
const minute = ruMatch[2] ? parseInt(ruMatch[2], 10) : 0;
|
|
36
|
+
const period = ruMatch[3];
|
|
37
|
+
if (minute < 0 || minute > 59) return null;
|
|
38
|
+
if (period === 'вечера' && hour < 12) hour += 12;
|
|
39
|
+
if (period === 'дня' && hour >= 1 && hour <= 11) hour += 12;
|
|
40
|
+
if (period === 'ночи' && hour === 12) hour = 0;
|
|
41
|
+
if (hour < 0 || hour > 23) return null;
|
|
42
|
+
return { hour, minute };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. 24h format: "14:30", "09:00", "9:00"
|
|
46
|
+
const colonMatch = str.match(/^(\d{1,2}):(\d{2})$/);
|
|
47
|
+
if (colonMatch) {
|
|
48
|
+
const hour = parseInt(colonMatch[1], 10);
|
|
49
|
+
const minute = parseInt(colonMatch[2], 10);
|
|
50
|
+
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
|
51
|
+
return { hour, minute };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 4. Plain hour: "9", "14", "22"
|
|
55
|
+
const plainMatch = str.match(/^(\d{1,2})$/);
|
|
56
|
+
if (plainMatch) {
|
|
57
|
+
const hour = parseInt(plainMatch[1], 10);
|
|
58
|
+
if (hour >= 0 && hour <= 23) {
|
|
59
|
+
return { hour, minute: 0 };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DAY_OF_WEEK_MAP = {
|
|
67
|
+
// Sunday (0)
|
|
68
|
+
'воскресенье': 0, 'воскресеньям': 0, 'воскресенья': 0, 'вс': 0,
|
|
69
|
+
'sunday': 0, 'sundays': 0, 'sun': 0,
|
|
70
|
+
// Monday (1)
|
|
71
|
+
'понедельник': 1, 'понедельникам': 1, 'пн': 1,
|
|
72
|
+
'monday': 1, 'mondays': 1, 'mon': 1,
|
|
73
|
+
// Tuesday (2)
|
|
74
|
+
'вторник': 2, 'вторникам': 2, 'вт': 2,
|
|
75
|
+
'tuesday': 2, 'tuesdays': 2, 'tue': 2,
|
|
76
|
+
// Wednesday (3)
|
|
77
|
+
'среда': 3, 'среду': 3, 'средам': 3, 'ср': 3,
|
|
78
|
+
'wednesday': 3, 'wednesdays': 3, 'wed': 3,
|
|
79
|
+
// Thursday (4)
|
|
80
|
+
'четверг': 4, 'четвергам': 4, 'чт': 4,
|
|
81
|
+
'thursday': 4, 'thursdays': 4, 'thu': 4,
|
|
82
|
+
// Friday (5)
|
|
83
|
+
'пятница': 5, 'пятницу': 5, 'пятницам': 5, 'пт': 5,
|
|
84
|
+
'friday': 5, 'fridays': 5, 'fri': 5,
|
|
85
|
+
// Saturday (6)
|
|
86
|
+
'суббота': 6, 'субботу': 6, 'субботам': 6, 'сб': 6,
|
|
87
|
+
'saturday': 6, 'saturdays': 6, 'sat': 6,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const DAY_NAMES_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
91
|
+
|
|
92
|
+
function withNextRun(res) {
|
|
93
|
+
if (res && res.cronPattern && !res.nextRun) {
|
|
94
|
+
try {
|
|
95
|
+
const job = new Cron(res.cronPattern, { sloppyRanges: true });
|
|
96
|
+
const next = job.nextRun();
|
|
97
|
+
res.nextRun = next ? next.getTime() : null;
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return res;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Deterministically parse natural language schedule expression.
|
|
107
|
+
* Returns parsed schedule object compatible with parseScheduleExpression or null.
|
|
108
|
+
*/
|
|
109
|
+
export function parseNaturalSchedule(input) {
|
|
110
|
+
if (!input || typeof input !== 'string') return null;
|
|
111
|
+
const raw = input.trim();
|
|
112
|
+
if (!raw) return null;
|
|
113
|
+
|
|
114
|
+
// Normalize: lower case, single spaces, strip surrounding punctuation
|
|
115
|
+
const s = raw.toLowerCase()
|
|
116
|
+
.replace(/[.,;!]+$/, '')
|
|
117
|
+
.replace(/\s+/g, ' ')
|
|
118
|
+
.trim();
|
|
119
|
+
|
|
120
|
+
// A. Relative delay in Russian: "через 15 минут", "через 2 часа"
|
|
121
|
+
const delayMatch = s.match(/^через\s+(\d+)\s*(секунд[ыа]?|сек|s|минут[ы]?|мин|m|часа?|часов|h|дней|дня|день|d)$/);
|
|
122
|
+
if (delayMatch) {
|
|
123
|
+
const num = parseInt(delayMatch[1], 10);
|
|
124
|
+
const u = delayMatch[2];
|
|
125
|
+
let unit = 'm';
|
|
126
|
+
if (u.startsWith('сек') || u === 's') unit = 's';
|
|
127
|
+
else if (u.startsWith('час') || u === 'h') unit = 'h';
|
|
128
|
+
else if (u.startsWith('дн') || u === 'день' || u === 'd') unit = 'd';
|
|
129
|
+
let delayMs = num * 60 * 1000;
|
|
130
|
+
let unitText = `${num} min`;
|
|
131
|
+
if (unit === 's') { delayMs = num * 1000; unitText = `${num} sec`; }
|
|
132
|
+
else if (unit === 'h') { delayMs = num * 3600 * 1000; unitText = `${num} h`; }
|
|
133
|
+
else if (unit === 'd') { delayMs = num * 86400 * 1000; unitText = `${num} d`; }
|
|
134
|
+
const targetMs = Date.now() + delayMs;
|
|
135
|
+
return {
|
|
136
|
+
cronPattern: null,
|
|
137
|
+
isOneShot: true,
|
|
138
|
+
targetTimestamp: targetMs,
|
|
139
|
+
humanText: `One-shot in ${unitText} (at ${new Date(targetMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
|
|
140
|
+
nextRun: targetMs,
|
|
141
|
+
isNatural: true,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// B. Periodic intervals in Russian: "каждые 15 минут", "каждые 2 часа", "каждые полчаса"
|
|
146
|
+
if (s === 'каждые полчаса' || s === 'каждые пол-часа' || s === 'every half hour') {
|
|
147
|
+
return withNextRun({
|
|
148
|
+
cronPattern: '*/30 * * * *',
|
|
149
|
+
humanText: 'Every 30 minutes',
|
|
150
|
+
isInterval: true,
|
|
151
|
+
isNatural: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const intervalRu = s.match(/^кажды[еяй]\s+(\d+)\s*(минут[ы]?|мин|часа?|часов|дней|дня|день)$/);
|
|
156
|
+
if (intervalRu) {
|
|
157
|
+
const num = parseInt(intervalRu[1], 10);
|
|
158
|
+
const u = intervalRu[2];
|
|
159
|
+
if (u.startsWith('мин')) {
|
|
160
|
+
if (num < 1) return null;
|
|
161
|
+
if (num < 60) {
|
|
162
|
+
return withNextRun({
|
|
163
|
+
cronPattern: `*/${num} * * * *`,
|
|
164
|
+
humanText: `Every ${num} minutes`,
|
|
165
|
+
isInterval: true,
|
|
166
|
+
isNatural: true,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
} else if (u.startsWith('час')) {
|
|
170
|
+
if (num >= 1 && num < 24) {
|
|
171
|
+
return withNextRun({
|
|
172
|
+
cronPattern: `0 */${num} * * *`,
|
|
173
|
+
humanText: `Every ${num} hours`,
|
|
174
|
+
isInterval: true,
|
|
175
|
+
isNatural: true,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
} else if (u.startsWith('дн') || u === 'день') {
|
|
179
|
+
if (num >= 1 && num <= 31) {
|
|
180
|
+
return withNextRun({
|
|
181
|
+
cronPattern: `0 0 */${num} * *`,
|
|
182
|
+
humanText: `Every ${num} days`,
|
|
183
|
+
isInterval: true,
|
|
184
|
+
isNatural: true,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// C. Hourly / Midnight / Noon
|
|
191
|
+
if (s === 'каждый час' || s === 'в начале каждого часа' || s === 'в начале часа' || s === 'every hour' || s === 'hourly') {
|
|
192
|
+
return withNextRun({
|
|
193
|
+
cronPattern: '0 * * * *',
|
|
194
|
+
humanText: 'Every hour',
|
|
195
|
+
isNatural: true,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (s === 'в полночь' || s === 'at midnight' || s === 'midnight') {
|
|
199
|
+
return withNextRun({
|
|
200
|
+
cronPattern: '0 0 * * *',
|
|
201
|
+
humanText: 'Every day at 00:00',
|
|
202
|
+
isNatural: true,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (s === 'в полдень' || s === 'at noon' || s === 'noon') {
|
|
206
|
+
return withNextRun({
|
|
207
|
+
cronPattern: '0 12 * * *',
|
|
208
|
+
humanText: 'Every day at 12:00',
|
|
209
|
+
isNatural: true,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// D. Daily at specific time:
|
|
214
|
+
const dailyMatch = s.match(/^(?:каждый\s+день|ежедневно|every\s+day|daily)\s+(?:в|at)\s+(.+)$/);
|
|
215
|
+
if (dailyMatch) {
|
|
216
|
+
const t = parseTimeString(dailyMatch[1]);
|
|
217
|
+
if (t) {
|
|
218
|
+
return withNextRun({
|
|
219
|
+
cronPattern: `${t.minute} ${t.hour} * * *`,
|
|
220
|
+
humanText: `Every day at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
221
|
+
isNatural: true,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Reverse: "в 9:00 каждый день", "at 9am daily"
|
|
226
|
+
const dailyRevMatch = s.match(/^(?:в|at)\s+(.+?)\s+(?:каждый\s+день|ежедневно|every\s+day|daily)$/);
|
|
227
|
+
if (dailyRevMatch) {
|
|
228
|
+
const t = parseTimeString(dailyRevMatch[1]);
|
|
229
|
+
if (t) {
|
|
230
|
+
return withNextRun({
|
|
231
|
+
cronPattern: `${t.minute} ${t.hour} * * *`,
|
|
232
|
+
humanText: `Every day at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
233
|
+
isNatural: true,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// E. Weekdays at specific time:
|
|
239
|
+
const weekdayMatch = s.match(/^(?:по\s+будням|в\s+будние(?:\s+дни)?|с\s+понедельника\s+по\s+пятницу|every\s+weekday|weekdays|on\s+weekdays|mon-fri)\s+(?:в|at)\s+(.+)$/);
|
|
240
|
+
if (weekdayMatch) {
|
|
241
|
+
const t = parseTimeString(weekdayMatch[1]);
|
|
242
|
+
if (t) {
|
|
243
|
+
return withNextRun({
|
|
244
|
+
cronPattern: `${t.minute} ${t.hour} * * 1-5`,
|
|
245
|
+
humanText: `Weekdays at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
246
|
+
isNatural: true,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Reverse: "в 10:00 по будням"
|
|
251
|
+
const weekdayRevMatch = s.match(/^(?:в|at)\s+(.+?)\s+(?:по\s+будням|в\s+будние(?:\s+дни)?|every\s+weekday|weekdays)$/);
|
|
252
|
+
if (weekdayRevMatch) {
|
|
253
|
+
const t = parseTimeString(weekdayRevMatch[1]);
|
|
254
|
+
if (t) {
|
|
255
|
+
return withNextRun({
|
|
256
|
+
cronPattern: `${t.minute} ${t.hour} * * 1-5`,
|
|
257
|
+
humanText: `Weekdays at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
258
|
+
isNatural: true,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// F. Weekends at specific time:
|
|
264
|
+
const weekendMatch = s.match(/^(?:по\s+выходным|в\s+выходные(?:\s+дни)?|по\s+субботам\s+и\s+воскресеньям|every\s+weekend|weekends|on\s+weekends)\s+(?:в|at)\s+(.+)$/);
|
|
265
|
+
if (weekendMatch) {
|
|
266
|
+
const t = parseTimeString(weekendMatch[1]);
|
|
267
|
+
if (t) {
|
|
268
|
+
return withNextRun({
|
|
269
|
+
cronPattern: `${t.minute} ${t.hour} * * 0,6`,
|
|
270
|
+
humanText: `Weekends at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
271
|
+
isNatural: true,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// G. Specific day of week:
|
|
277
|
+
const dowMatch = s.match(/^(?:по\s+|кажд(?:ый|ую|ое)\s+|every\s+|on\s+)?(понедельник(?:ам)?|вторник(?:ам)?|сред[ау]|средам|четверг(?:ам)?|пятниц[ау]|пятницам|суббот[ау]|субботам|воскресень[ея]|воскресеньям|monday|mondays|tuesday|tuesdays|wednesday|wednesdays|thursday|thursdays|friday|fridays|saturday|saturdays|sunday|sundays)\s+(?:в|at)\s+(.+)$/);
|
|
278
|
+
if (dowMatch) {
|
|
279
|
+
const dayWord = dowMatch[1];
|
|
280
|
+
const dow = DAY_OF_WEEK_MAP[dayWord];
|
|
281
|
+
if (dow !== undefined) {
|
|
282
|
+
const t = parseTimeString(dowMatch[2]);
|
|
283
|
+
if (t) {
|
|
284
|
+
return withNextRun({
|
|
285
|
+
cronPattern: `${t.minute} ${t.hour} * * ${dow}`,
|
|
286
|
+
humanText: `${DAY_NAMES_EN[dow]}s at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
287
|
+
isNatural: true,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// H. Monthly on specific day of month:
|
|
294
|
+
const monthlyRu = s.match(/^(?:каждое\s+(\d+)\s+число|(\d+)\s+числа\s+каждого\s+месяца)\s+(?:в|at)\s+(.+)$/);
|
|
295
|
+
if (monthlyRu) {
|
|
296
|
+
const day = parseInt(monthlyRu[1] || monthlyRu[2], 10);
|
|
297
|
+
if (day >= 1 && day <= 31) {
|
|
298
|
+
const t = parseTimeString(monthlyRu[3]);
|
|
299
|
+
if (t) {
|
|
300
|
+
return withNextRun({
|
|
301
|
+
cronPattern: `${t.minute} ${t.hour} ${day} * *`,
|
|
302
|
+
humanText: `Day ${day} of every month at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
303
|
+
isNatural: true,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const monthlyEn = s.match(/^(?:on\s+the\s+(\d+)(?:st|nd|rd|th)?\s+of\s+every\s+month|every\s+month\s+on\s+the\s+(\d+)(?:st|nd|rd|th)?)\s+at\s+(.+)$/);
|
|
310
|
+
if (monthlyEn) {
|
|
311
|
+
const day = parseInt(monthlyEn[1] || monthlyEn[2], 10);
|
|
312
|
+
if (day >= 1 && day <= 31) {
|
|
313
|
+
const t = parseTimeString(monthlyEn[3]);
|
|
314
|
+
if (t) {
|
|
315
|
+
return withNextRun({
|
|
316
|
+
cronPattern: `${t.minute} ${t.hour} ${day} * *`,
|
|
317
|
+
humanText: `Day ${day} of every month at ${pad(t.hour)}:${pad(t.minute)}`,
|
|
318
|
+
isNatural: true,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// I. Standalone day of week without time (defaults to 09:00):
|
|
325
|
+
const standaloneDow = s.match(/^(?:по\s+|кажд(?:ый|ую|ое)\s+|every\s+|on\s+)(понедельник(?:ам)?|вторник(?:ам)?|сред[ау]|средам|четверг(?:ам)?|пятниц[ау]|пятницам|суббот[ау]|субботам|воскресень[ея]|воскресеньям|monday|mondays|tuesday|tuesdays|wednesday|wednesdays|thursday|thursdays|friday|fridays|saturday|saturdays|sunday|sundays)$/);
|
|
326
|
+
if (standaloneDow) {
|
|
327
|
+
const dow = DAY_OF_WEEK_MAP[standaloneDow[1]];
|
|
328
|
+
if (dow !== undefined) {
|
|
329
|
+
return withNextRun({
|
|
330
|
+
cronPattern: `0 9 * * ${dow}`,
|
|
331
|
+
humanText: `${DAY_NAMES_EN[dow]}s at 09:00`,
|
|
332
|
+
isNatural: true,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return null;
|
|
338
|
+
}
|