@20syldev/api 4.7.0 → 4.8.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.
- package/README.md +3 -3
- package/dist/app.js +6 -6
- package/dist/app.js.map +1 -1
- package/dist/config/env.js.map +1 -1
- package/dist/config/versions.js +22 -3
- package/dist/config/versions.js.map +1 -1
- package/dist/constants.js +27 -12
- package/dist/constants.js.map +1 -1
- package/dist/middleware/error.js +2 -2
- package/dist/middleware/error.js.map +1 -1
- package/dist/middleware/logger.js.map +1 -1
- package/dist/middleware/ratelimit.js +2 -2
- package/dist/middleware/ratelimit.js.map +1 -1
- package/dist/middleware/version.js.map +1 -1
- package/dist/modules/v3.js +1 -1
- package/dist/modules/v3.js.map +1 -1
- package/dist/modules/v4/address.js +381 -0
- package/dist/modules/v4/address.js.map +1 -0
- package/dist/modules/v4/agent.js +92 -0
- package/dist/modules/v4/agent.js.map +1 -0
- package/dist/modules/v4/algorithms.js +79 -0
- package/dist/modules/v4/algorithms.js.map +1 -1
- package/dist/modules/v4/avatar.js +136 -0
- package/dist/modules/v4/avatar.js.map +1 -0
- package/dist/modules/v4/barcode.js +440 -0
- package/dist/modules/v4/barcode.js.map +1 -0
- package/dist/modules/v4/captcha.js +7 -0
- package/dist/modules/v4/captcha.js.map +1 -1
- package/dist/modules/v4/chat.js +9 -1
- package/dist/modules/v4/chat.js.map +1 -1
- package/dist/modules/v4/color.js +8 -1
- package/dist/modules/v4/color.js.map +1 -1
- package/dist/modules/v4/convert.js +9 -0
- package/dist/modules/v4/convert.js.map +1 -1
- package/dist/modules/v4/credit.js +104 -0
- package/dist/modules/v4/credit.js.map +1 -0
- package/dist/modules/v4/cron.js +202 -0
- package/dist/modules/v4/cron.js.map +1 -0
- package/dist/modules/v4/dice.js +7 -0
- package/dist/modules/v4/dice.js.map +1 -1
- package/dist/modules/v4/domain.js +6 -1
- package/dist/modules/v4/domain.js.map +1 -1
- package/dist/modules/v4/encode.js +71 -0
- package/dist/modules/v4/encode.js.map +1 -1
- package/dist/modules/v4/geo.js +11 -1
- package/dist/modules/v4/geo.js.map +1 -1
- package/dist/modules/v4/hash.js +10 -1
- package/dist/modules/v4/hash.js.map +1 -1
- package/dist/modules/v4/hyperplanning.js +9 -1
- package/dist/modules/v4/hyperplanning.js.map +1 -1
- package/dist/modules/v4/ip.js +132 -0
- package/dist/modules/v4/ip.js.map +1 -0
- package/dist/modules/v4/levenshtein.js +8 -0
- package/dist/modules/v4/levenshtein.js.map +1 -1
- package/dist/modules/v4/palette.js +9 -1
- package/dist/modules/v4/palette.js.map +1 -1
- package/dist/modules/v4/password.js +343 -0
- package/dist/modules/v4/password.js.map +1 -0
- package/dist/modules/v4/personal.js +14 -18
- package/dist/modules/v4/personal.js.map +1 -1
- package/dist/modules/v4/placeholder.js +8 -0
- package/dist/modules/v4/placeholder.js.map +1 -1
- package/dist/modules/v4/qrcode.js +10 -3
- package/dist/modules/v4/qrcode.js.map +1 -1
- package/dist/modules/v4/regex.js +48 -0
- package/dist/modules/v4/regex.js.map +1 -0
- package/dist/modules/v4/statistics.js +7 -0
- package/dist/modules/v4/statistics.js.map +1 -1
- package/dist/modules/v4/text.js +30 -0
- package/dist/modules/v4/text.js.map +1 -1
- package/dist/modules/v4/tic_tac_toe.js +9 -1
- package/dist/modules/v4/tic_tac_toe.js.map +1 -1
- package/dist/modules/v4/time.js +62 -11
- package/dist/modules/v4/time.js.map +1 -1
- package/dist/modules/v4/token.js +17 -8
- package/dist/modules/v4/token.js.map +1 -1
- package/dist/modules/v4/username.js +5 -0
- package/dist/modules/v4/username.js.map +1 -1
- package/dist/modules/v4/validate.js +21 -0
- package/dist/modules/v4/validate.js.map +1 -1
- package/dist/modules/v4.js +10 -1
- package/dist/modules/v4.js.map +1 -1
- package/dist/routes/delete.js.map +1 -1
- package/dist/routes/get.js +208 -48
- package/dist/routes/get.js.map +1 -1
- package/dist/routes/index.js +2 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/patch.js.map +1 -1
- package/dist/routes/post.js +2 -2
- package/dist/routes/post.js.map +1 -1
- package/dist/utils/response.js.map +1 -1
- package/package.json +1 -1
- package/src/config/versions.ts +3 -1
- package/src/constants.ts +27 -12
- package/src/modules/v4/address.ts +6 -7
- package/src/modules/v4/cron.ts +221 -0
- package/src/modules/v4/geo.ts +2 -1
- package/src/modules/v4/password.ts +5 -7
- package/src/modules/v4/qrcode.ts +2 -2
- package/src/modules/v4/regex.ts +67 -0
- package/src/modules/v4/time.ts +63 -17
- package/src/modules/v4.ts +2 -0
- package/src/routes/get.ts +67 -46
- package/tests/integration/api.test.ts +56 -0
- package/tests/unit/cron.test.ts +110 -0
- package/tests/unit/regex.test.ts +95 -0
- package/tests/unit/time.test.ts +47 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { MAX_CRON_ITERATIONS, MAX_CRON_RESULTS } from '../../constants.js';
|
|
2
|
+
import { VALID_TIMEZONES } from './time.js';
|
|
3
|
+
|
|
4
|
+
type Timezone = (typeof VALID_TIMEZONES)[number];
|
|
5
|
+
|
|
6
|
+
const FIELD_RANGES = [
|
|
7
|
+
{ min: 0, max: 59 }, // minute
|
|
8
|
+
{ min: 0, max: 23 }, // hour
|
|
9
|
+
{ min: 1, max: 31 }, // day of month
|
|
10
|
+
{ min: 1, max: 12 }, // month
|
|
11
|
+
{ min: 0, max: 7 }, // day of week (0 and 7 = Sunday)
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
const DOW_NAMES: Record<string, number> = {
|
|
15
|
+
Sunday: 0,
|
|
16
|
+
Monday: 1,
|
|
17
|
+
Tuesday: 2,
|
|
18
|
+
Wednesday: 3,
|
|
19
|
+
Thursday: 4,
|
|
20
|
+
Friday: 5,
|
|
21
|
+
Saturday: 6,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const DOW_DISPLAY = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
25
|
+
|
|
26
|
+
export interface CronResult {
|
|
27
|
+
expression: string;
|
|
28
|
+
description: string;
|
|
29
|
+
next: string[];
|
|
30
|
+
timezone: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseField(field: string, range: { min: number; max: number }): Set<number> {
|
|
34
|
+
const result = new Set<number>();
|
|
35
|
+
|
|
36
|
+
for (const part of field.split(',')) {
|
|
37
|
+
if (part === '*') {
|
|
38
|
+
for (let i = range.min; i <= range.max; i++) result.add(i);
|
|
39
|
+
} else if (part.startsWith('*/')) {
|
|
40
|
+
const step = parseInt(part.slice(2), 10);
|
|
41
|
+
if (isNaN(step) || step < 1) throw new Error(`Invalid step in "${part}"`);
|
|
42
|
+
for (let i = range.min; i <= range.max; i += step) result.add(i);
|
|
43
|
+
} else if (part.includes('-')) {
|
|
44
|
+
const [s, e] = part.split('-');
|
|
45
|
+
const start = parseInt(s!, 10);
|
|
46
|
+
const end = parseInt(e!, 10);
|
|
47
|
+
if (isNaN(start) || isNaN(end) || start < range.min || end > range.max || start > end) {
|
|
48
|
+
throw new Error(`Invalid range "${part}" (valid: ${range.min}-${range.max})`);
|
|
49
|
+
}
|
|
50
|
+
for (let i = start; i <= end; i++) result.add(i);
|
|
51
|
+
} else {
|
|
52
|
+
const val = parseInt(part, 10);
|
|
53
|
+
if (isNaN(val) || val < range.min || val > range.max) {
|
|
54
|
+
throw new Error(`Invalid value "${part}" (valid: ${range.min}-${range.max})`);
|
|
55
|
+
}
|
|
56
|
+
result.add(val);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getComponents(
|
|
64
|
+
date: Date,
|
|
65
|
+
formatter: Intl.DateTimeFormat,
|
|
66
|
+
): { minute: number; hour: number; dom: number; month: number; dow: number } {
|
|
67
|
+
const parts = Object.fromEntries(formatter.formatToParts(date).map((p) => [p.type, p.value]));
|
|
68
|
+
return {
|
|
69
|
+
minute: parseInt(parts.minute!, 10),
|
|
70
|
+
hour: parseInt(parts.hour!, 10) % 24,
|
|
71
|
+
dom: parseInt(parts.day!, 10),
|
|
72
|
+
month: parseInt(parts.month!, 10),
|
|
73
|
+
dow: DOW_NAMES[parts.weekday!] ?? 0,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pad(n: number): string {
|
|
78
|
+
return String(n).padStart(2, '0');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatDow(field: string): string {
|
|
82
|
+
if (field === '1-5') return 'Monday through Friday';
|
|
83
|
+
if (field === '0' || field === '7') return 'Sunday';
|
|
84
|
+
if (field === '6') return 'Saturday';
|
|
85
|
+
const n = parseInt(field, 10);
|
|
86
|
+
if (!isNaN(n)) return DOW_DISPLAY[n % 7] ?? field;
|
|
87
|
+
return field;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function describe(fields: string[]): string {
|
|
91
|
+
const [mF, hF, domF, monthF, dowF] = fields as [string, string, string, string, string];
|
|
92
|
+
|
|
93
|
+
const isAll = (f: string) => f === '*';
|
|
94
|
+
const isFixed = (f: string) => /^\d+$/.test(f);
|
|
95
|
+
const isStep = (f: string) => /^\*\/\d+$/.test(f);
|
|
96
|
+
|
|
97
|
+
// Every minute
|
|
98
|
+
if (isAll(mF) && isAll(hF) && isAll(domF) && isAll(monthF) && isAll(dowF)) {
|
|
99
|
+
return 'Every minute';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Every N minutes (whole day, every day)
|
|
103
|
+
if (isStep(mF) && isAll(hF) && isAll(domF) && isAll(monthF) && isAll(dowF)) {
|
|
104
|
+
const n = mF.slice(2);
|
|
105
|
+
return `Every ${n} minute${n === '1' ? '' : 's'}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Every hour at minute 0
|
|
109
|
+
if (mF === '0' && isAll(hF) && isAll(domF) && isAll(monthF) && isAll(dowF)) {
|
|
110
|
+
return 'Every hour';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Every N hours at minute 0
|
|
114
|
+
if (mF === '0' && isStep(hF) && isAll(domF) && isAll(monthF) && isAll(dowF)) {
|
|
115
|
+
const n = hF.slice(2);
|
|
116
|
+
return `Every ${n} hour${n === '1' ? '' : 's'}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// At specific time daily
|
|
120
|
+
if (isFixed(mF) && isFixed(hF) && isAll(domF) && isAll(monthF) && isAll(dowF)) {
|
|
121
|
+
return `At ${pad(parseInt(hF))}:${pad(parseInt(mF))} daily`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// At specific time on specific weekday(s)
|
|
125
|
+
if (isFixed(mF) && isFixed(hF) && isAll(domF) && isAll(monthF) && !isAll(dowF)) {
|
|
126
|
+
return `At ${pad(parseInt(hF))}:${pad(parseInt(mF))}, ${formatDow(dowF)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// At specific time on specific day of month
|
|
130
|
+
if (isFixed(mF) && isFixed(hF) && isFixed(domF) && isAll(monthF) && isAll(dowF)) {
|
|
131
|
+
return `At ${pad(parseInt(hF))}:${pad(parseInt(mF))}, on day ${domF} of the month`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Every N minutes, within hour range, optionally filtered by weekday
|
|
135
|
+
if (isStep(mF) && !isAll(hF) && isAll(domF) && isAll(monthF)) {
|
|
136
|
+
const n = mF.slice(2);
|
|
137
|
+
const parts = [`Every ${n} minute${n === '1' ? '' : 's'}`];
|
|
138
|
+
if (hF.includes('-')) {
|
|
139
|
+
const [h1, h2] = hF.split('-');
|
|
140
|
+
parts.push(`between ${pad(parseInt(h1!))}:00 and ${pad(parseInt(h2!))}:59`);
|
|
141
|
+
}
|
|
142
|
+
if (!isAll(dowF)) parts.push(formatDow(dowF));
|
|
143
|
+
return parts.join(', ');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return `At cron schedule: ${fields.join(' ')}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parses a cron expression and returns the next scheduled execution dates.
|
|
151
|
+
*
|
|
152
|
+
* @param expr - Cron expression with 5 space-separated fields (minute hour dom month dow)
|
|
153
|
+
* @param count - Number of next executions to return (1–20, default 5)
|
|
154
|
+
* @param from - Starting date in ISO 8601 format (defaults to now)
|
|
155
|
+
* @param timezone - Timezone to evaluate the expression in (default "UTC")
|
|
156
|
+
* @returns Parsed expression with description and list of next execution timestamps
|
|
157
|
+
* @throws Error if the expression is invalid, count is out of range, or timezone is unsupported
|
|
158
|
+
*/
|
|
159
|
+
export default function cron(expr: string, count: number = 5, from?: string, timezone: string = 'UTC'): CronResult {
|
|
160
|
+
if (!expr) throw new Error('Please provide a cron expression (?expr=* * * * *)');
|
|
161
|
+
|
|
162
|
+
if (!VALID_TIMEZONES.includes(timezone as Timezone)) {
|
|
163
|
+
throw new Error(`Please provide a valid timezone. Options: ${VALID_TIMEZONES.join(', ')}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const fields = expr.trim().split(/\s+/);
|
|
167
|
+
if (fields.length !== 5) {
|
|
168
|
+
throw new Error('Cron expression must have exactly 5 fields (minute hour dom month dow)');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (count < 1 || count > MAX_CRON_RESULTS) {
|
|
172
|
+
throw new Error(`Count must be between 1 and ${MAX_CRON_RESULTS}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const fromMs = from ? new Date(from).getTime() : Date.now();
|
|
176
|
+
if (from && isNaN(fromMs)) throw new Error('Please provide a valid from date (ISO 8601)');
|
|
177
|
+
|
|
178
|
+
const sets = fields.map((f, i) => parseField(f, FIELD_RANGES[i]!));
|
|
179
|
+
const [minuteSet, hourSet, domSet, monthSet, dowSet] = sets as [
|
|
180
|
+
Set<number>,
|
|
181
|
+
Set<number>,
|
|
182
|
+
Set<number>,
|
|
183
|
+
Set<number>,
|
|
184
|
+
Set<number>,
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
// Normalize DOW: treat 7 as Sunday (0)
|
|
188
|
+
if (dowSet.has(7)) {
|
|
189
|
+
dowSet.add(0);
|
|
190
|
+
dowSet.delete(7);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
194
|
+
timeZone: timezone,
|
|
195
|
+
month: 'numeric',
|
|
196
|
+
day: 'numeric',
|
|
197
|
+
hour: 'numeric',
|
|
198
|
+
minute: 'numeric',
|
|
199
|
+
weekday: 'long',
|
|
200
|
+
hour12: false,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Always start from the next minute (never include the current minute)
|
|
204
|
+
let currentMs = (Math.floor(fromMs / 60000) + 1) * 60000;
|
|
205
|
+
|
|
206
|
+
const next: string[] = [];
|
|
207
|
+
let iterations = 0;
|
|
208
|
+
|
|
209
|
+
while (next.length < count && iterations < MAX_CRON_ITERATIONS) {
|
|
210
|
+
const { minute, hour, dom, month, dow } = getComponents(new Date(currentMs), formatter);
|
|
211
|
+
|
|
212
|
+
if (minuteSet.has(minute) && hourSet.has(hour) && domSet.has(dom) && monthSet.has(month) && dowSet.has(dow)) {
|
|
213
|
+
next.push(new Date(currentMs).toISOString());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
currentMs += 60000;
|
|
217
|
+
iterations++;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { expression: expr.trim(), description: describe(fields), next, timezone };
|
|
221
|
+
}
|
package/src/modules/v4/geo.ts
CHANGED
|
@@ -5,7 +5,8 @@ export interface GeoResult {
|
|
|
5
5
|
to: { lat: number; lon: number };
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import { EARTH_RADIUS_KM } from '../../constants.js';
|
|
9
|
+
|
|
9
10
|
const CARDINALS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
|
10
11
|
|
|
11
12
|
function toRad(deg: number): number {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { randomInt } from 'crypto';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
const MAX_LENGTH = 128;
|
|
5
|
-
const MAX_COUNT = 20;
|
|
3
|
+
import { MAX_PASSWORD_COUNT, MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from '../../constants.js';
|
|
6
4
|
|
|
7
5
|
const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
8
6
|
const LOWER = 'abcdefghijklmnopqrstuvwxyz';
|
|
@@ -327,8 +325,8 @@ export default function password(
|
|
|
327
325
|
separator = '-',
|
|
328
326
|
} = options;
|
|
329
327
|
|
|
330
|
-
if (count < 1 || count >
|
|
331
|
-
throw new Error(`Count must be between 1 and ${
|
|
328
|
+
if (count < 1 || count > MAX_PASSWORD_COUNT) {
|
|
329
|
+
throw new Error(`Count must be between 1 and ${MAX_PASSWORD_COUNT}`);
|
|
332
330
|
}
|
|
333
331
|
|
|
334
332
|
if (type === 'passphrase') {
|
|
@@ -348,8 +346,8 @@ export default function password(
|
|
|
348
346
|
throw new Error('Type must be "random" or "passphrase"');
|
|
349
347
|
}
|
|
350
348
|
|
|
351
|
-
if (isNaN(length) || length <
|
|
352
|
-
throw new Error(`Length must be between ${
|
|
349
|
+
if (isNaN(length) || length < MIN_PASSWORD_LENGTH || length > MAX_PASSWORD_LENGTH) {
|
|
350
|
+
throw new Error(`Length must be between ${MIN_PASSWORD_LENGTH} and ${MAX_PASSWORD_LENGTH}`);
|
|
353
351
|
}
|
|
354
352
|
|
|
355
353
|
let charset = '';
|
package/src/modules/v4/qrcode.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createCanvas, loadImage } from 'canvas';
|
|
2
2
|
import { toBuffer, toCanvas as qrToCanvas, toDataURL } from 'qrcode';
|
|
3
3
|
|
|
4
|
+
import { MAX_QRCODE_LOGO_BYTES } from '../../constants.js';
|
|
4
5
|
import { normalizeColor } from '../../utils/colors.js';
|
|
5
6
|
|
|
6
7
|
export interface QRCodeOptions {
|
|
@@ -24,7 +25,6 @@ export interface QRCodeResult {
|
|
|
24
25
|
|
|
25
26
|
const CORRECTIONS = new Set(['L', 'M', 'Q', 'H']);
|
|
26
27
|
const FORMATS = new Set(['png', 'base64']);
|
|
27
|
-
const MAX_LOGO_BYTES = 2 * 1024 * 1024;
|
|
28
28
|
|
|
29
29
|
function clamp(value: number | undefined, name: string, def: number, min: number, max: number): number {
|
|
30
30
|
if (value === undefined) return def;
|
|
@@ -44,7 +44,7 @@ async function fetchIcon(url: string): Promise<Buffer> {
|
|
|
44
44
|
if (!contentType.startsWith('image/')) throw new Error('Icon URL must point to an image');
|
|
45
45
|
|
|
46
46
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
47
|
-
if (buffer.length >
|
|
47
|
+
if (buffer.length > MAX_QRCODE_LOGO_BYTES) throw new Error('Icon must be under 2MB');
|
|
48
48
|
|
|
49
49
|
return buffer;
|
|
50
50
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { MAX_PATTERN_LENGTH, MAX_REGEX_MATCHES, MAX_STRING_LENGTH } from '../../constants.js';
|
|
2
|
+
|
|
3
|
+
const VALID_FLAGS = new Set(['g', 'i', 'm', 's', 'u']);
|
|
4
|
+
|
|
5
|
+
export interface RegexMatch {
|
|
6
|
+
match: string;
|
|
7
|
+
index: number;
|
|
8
|
+
groups: string[];
|
|
9
|
+
namedGroups: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RegexResult {
|
|
13
|
+
valid: boolean;
|
|
14
|
+
pattern: string;
|
|
15
|
+
flags: string;
|
|
16
|
+
matches: RegexMatch[];
|
|
17
|
+
count: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tests a regular expression pattern against a text and returns structured match results.
|
|
22
|
+
*
|
|
23
|
+
* @param pattern - The regex pattern to test (max 200 characters)
|
|
24
|
+
* @param text - The text to match against (max 1000 characters)
|
|
25
|
+
* @param flags - Optional regex flags; only g, i, m, s, u are accepted (g is always forced)
|
|
26
|
+
* @returns Match results with groups and named groups, or { valid: false } if the pattern is invalid
|
|
27
|
+
* @throws Error if pattern or text is missing or exceeds the maximum length
|
|
28
|
+
*/
|
|
29
|
+
export default function regex(pattern: string, text: string, flags: string = 'g'): RegexResult {
|
|
30
|
+
if (!pattern) {
|
|
31
|
+
throw new Error('Please provide a pattern (?pattern={regex})');
|
|
32
|
+
}
|
|
33
|
+
if (!text) {
|
|
34
|
+
throw new Error('Please provide a text (&text={string})');
|
|
35
|
+
}
|
|
36
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
37
|
+
throw new Error(`Pattern must be under ${MAX_PATTERN_LENGTH} characters`);
|
|
38
|
+
}
|
|
39
|
+
if (text.length > MAX_STRING_LENGTH) {
|
|
40
|
+
throw new Error(`Text must be under ${MAX_STRING_LENGTH} characters`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const cleanedFlags = [...new Set(['g', ...flags.split('').filter((f) => VALID_FLAGS.has(f))])].join('');
|
|
44
|
+
|
|
45
|
+
let re: RegExp;
|
|
46
|
+
try {
|
|
47
|
+
re = new RegExp(pattern, cleanedFlags);
|
|
48
|
+
} catch {
|
|
49
|
+
return { valid: false, pattern, flags: cleanedFlags, matches: [], count: 0 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const matches: RegexMatch[] = [];
|
|
53
|
+
let result: RegExpExecArray | null;
|
|
54
|
+
|
|
55
|
+
while ((result = re.exec(text)) !== null && matches.length < MAX_REGEX_MATCHES) {
|
|
56
|
+
matches.push({
|
|
57
|
+
match: result[0],
|
|
58
|
+
index: result.index,
|
|
59
|
+
groups: result.slice(1).map((g) => g ?? ''),
|
|
60
|
+
namedGroups: result.groups ? { ...result.groups } : {},
|
|
61
|
+
});
|
|
62
|
+
// Prevent infinite loop on zero-length matches
|
|
63
|
+
if (result[0].length === 0) re.lastIndex++;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { valid: true, pattern, flags: cleanedFlags, matches, count: matches.length };
|
|
67
|
+
}
|
package/src/modules/v4/time.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { MAX_COUNTDOWN_YEARS } from '../../constants.js';
|
|
2
|
+
|
|
3
|
+
const VALID_FORMATS = [
|
|
2
4
|
'iso',
|
|
3
5
|
'utc',
|
|
4
6
|
'timestamp',
|
|
@@ -19,21 +21,23 @@ const validFormats = [
|
|
|
19
21
|
'timezoneOffset',
|
|
20
22
|
] as const;
|
|
21
23
|
|
|
22
|
-
const
|
|
24
|
+
export const VALID_TIMEZONES = ['UTC', 'America/New_York', 'Europe/Paris', 'Asia/Tokyo', 'Australia/Sydney'] as const;
|
|
23
25
|
|
|
24
|
-
type TimeFormat = (typeof
|
|
25
|
-
type Timezone = (typeof
|
|
26
|
+
type TimeFormat = (typeof VALID_FORMATS)[number];
|
|
27
|
+
type Timezone = (typeof VALID_TIMEZONES)[number];
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
|
-
* Returns the current or a random date/time in various formats and timezones
|
|
30
|
+
* Returns the current or a random date/time in various formats and timezones,
|
|
31
|
+
* or computes a countdown/elapsed time to/from a target date.
|
|
29
32
|
*
|
|
30
|
-
* @param type - "live" for the current time
|
|
33
|
+
* @param type - "live" for the current time, "random" for a random date, or "countdown" for a time diff
|
|
31
34
|
* @param start - Optional start date for random mode (YYYY-MM-DD)
|
|
32
35
|
* @param end - Optional end date for random mode (YYYY-MM-DD)
|
|
33
36
|
* @param format - Optional specific format to return (e.g. "iso", "timestamp", "year")
|
|
34
37
|
* @param timezone - Optional timezone (e.g. "UTC", "Europe/Paris")
|
|
35
|
-
* @
|
|
36
|
-
* @
|
|
38
|
+
* @param target - Required for countdown mode: target date in ISO 8601 or YYYY-MM-DD format
|
|
39
|
+
* @returns Object containing all time formats, a single format, or a countdown result
|
|
40
|
+
* @throws Error if type, format, timezone, or target is invalid
|
|
37
41
|
*/
|
|
38
42
|
export default function time(
|
|
39
43
|
type: string = 'live',
|
|
@@ -41,9 +45,55 @@ export default function time(
|
|
|
41
45
|
end?: string,
|
|
42
46
|
format?: string,
|
|
43
47
|
timezone?: string,
|
|
48
|
+
target?: string,
|
|
44
49
|
): Record<string, unknown> {
|
|
45
|
-
if (type !== 'live' && type !== 'random') {
|
|
46
|
-
throw new Error('Please provide a valid type (live or
|
|
50
|
+
if (type !== 'live' && type !== 'random' && type !== 'countdown') {
|
|
51
|
+
throw new Error('Please provide a valid type (live, random or countdown)');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (timezone && !VALID_TIMEZONES.includes(timezone as Timezone)) {
|
|
55
|
+
throw new Error(`Please provide a valid timezone. Options: ${VALID_TIMEZONES.join(', ')}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (type === 'countdown') {
|
|
59
|
+
if (!target) {
|
|
60
|
+
throw new Error('Please provide a target date (?target=YYYY-MM-DD)');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const targetDate = new Date(target);
|
|
64
|
+
if (isNaN(targetDate.getTime())) {
|
|
65
|
+
throw new Error('Please provide a valid target date (ISO 8601 or YYYY-MM-DD)');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const now = new Date();
|
|
69
|
+
const maxMs = MAX_COUNTDOWN_YEARS * 365.25 * 24 * 3600 * 1000;
|
|
70
|
+
|
|
71
|
+
if (Math.abs(targetDate.getTime() - now.getTime()) > maxMs) {
|
|
72
|
+
throw new Error(`Target date must be within ${MAX_COUNTDOWN_YEARS} years from now`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const direction: 'future' | 'past' = targetDate > now ? 'future' : 'past';
|
|
76
|
+
const diffMs = Math.abs(targetDate.getTime() - now.getTime());
|
|
77
|
+
const total_seconds = Math.floor(diffMs / 1000);
|
|
78
|
+
|
|
79
|
+
const days = Math.floor(total_seconds / 86400);
|
|
80
|
+
const hours = Math.floor((total_seconds % 86400) / 3600);
|
|
81
|
+
const minutes = Math.floor((total_seconds % 3600) / 60);
|
|
82
|
+
const seconds = total_seconds % 60;
|
|
83
|
+
|
|
84
|
+
const parts: string[] = [];
|
|
85
|
+
if (days > 0) parts.push(`${days} day${days !== 1 ? 's' : ''}`);
|
|
86
|
+
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`);
|
|
87
|
+
if (minutes > 0) parts.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`);
|
|
88
|
+
if (seconds > 0) parts.push(`${seconds} second${seconds !== 1 ? 's' : ''}`);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
target: targetDate.toISOString(),
|
|
92
|
+
now: now.toISOString(),
|
|
93
|
+
direction,
|
|
94
|
+
remaining: { total_seconds, days, hours, minutes, seconds },
|
|
95
|
+
human: parts.length > 0 ? parts.join(', ') : '0 seconds',
|
|
96
|
+
};
|
|
47
97
|
}
|
|
48
98
|
|
|
49
99
|
if (start && !Date.parse(start)) {
|
|
@@ -54,12 +104,8 @@ export default function time(
|
|
|
54
104
|
throw new Error('Please provide a valid end date (YYYY-MM-DD)');
|
|
55
105
|
}
|
|
56
106
|
|
|
57
|
-
if (format && !
|
|
58
|
-
throw new Error(`Please provide a valid format. Options: ${
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (timezone && !validTimezones.includes(timezone as Timezone)) {
|
|
62
|
-
throw new Error(`Please provide a valid timezone. Options: ${validTimezones.join(', ')}`);
|
|
107
|
+
if (format && !VALID_FORMATS.includes(format as TimeFormat)) {
|
|
108
|
+
throw new Error(`Please provide a valid format. Options: ${VALID_FORMATS.join(', ')}`);
|
|
63
109
|
}
|
|
64
110
|
|
|
65
111
|
const getTimeFormats = (date: Date, tz: string): Record<string, unknown> => {
|
|
@@ -89,7 +135,7 @@ export default function time(
|
|
|
89
135
|
const startDate = start ? new Date(start).getTime() : new Date('1900-01-01').getTime();
|
|
90
136
|
const endDate = end ? new Date(end).getTime() : new Date('2100-12-31').getTime();
|
|
91
137
|
const randomDate = new Date(startDate + Math.random() * (endDate - startDate));
|
|
92
|
-
const tz = timezone ||
|
|
138
|
+
const tz = timezone || VALID_TIMEZONES[Math.floor(Math.random() * VALID_TIMEZONES.length)]!;
|
|
93
139
|
const formats = getTimeFormats(randomDate, tz);
|
|
94
140
|
|
|
95
141
|
return format ? { date: formats[format] } : formats;
|
package/src/modules/v4.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { default as captcha } from './v4/captcha.js';
|
|
|
5
5
|
export { default as chat } from './v4/chat.js';
|
|
6
6
|
export { default as color } from './v4/color.js';
|
|
7
7
|
export { default as convert } from './v4/convert.js';
|
|
8
|
+
export { default as cron } from './v4/cron.js';
|
|
8
9
|
export { default as dice } from './v4/dice.js';
|
|
9
10
|
export { default as domain } from './v4/domain.js';
|
|
10
11
|
export * as encode from './v4/encode.js';
|
|
@@ -18,6 +19,7 @@ export { default as password } from './v4/password.js';
|
|
|
18
19
|
export { default as personal } from './v4/personal.js';
|
|
19
20
|
export { default as placeholder } from './v4/placeholder.js';
|
|
20
21
|
export { default as qrcode } from './v4/qrcode.js';
|
|
22
|
+
export { default as regex } from './v4/regex.js';
|
|
21
23
|
export { default as statistics } from './v4/statistics.js';
|
|
22
24
|
export * as text from './v4/text.js';
|
|
23
25
|
export { default as tic_tac_toe } from './v4/tic_tac_toe.js';
|
package/src/routes/get.ts
CHANGED
|
@@ -257,6 +257,34 @@ router.get('/:version/domain', (req: Request, res: Response) => {
|
|
|
257
257
|
}
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
+
// Parse a cron expression and compute next execution dates
|
|
261
|
+
router.get('/:version/cron', (req: Request, res: Response) => {
|
|
262
|
+
const { expr, count, from, timezone } = req.query;
|
|
263
|
+
const { version } = req.params;
|
|
264
|
+
|
|
265
|
+
const cronFn = (req.module as { cron?: (e: string, n?: number, f?: string, tz?: string) => unknown }).cron;
|
|
266
|
+
if (!cronFn) {
|
|
267
|
+
error(res, 404, `Endpoint not available in ${version}.`, `${version}/cron`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!expr) {
|
|
271
|
+
error(res, 400, 'Please provide a cron expression (?expr=* * * * *)', `${version}/cron`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const result = cronFn(
|
|
277
|
+
expr as string,
|
|
278
|
+
count !== undefined ? parseInt(count as string, 10) : 5,
|
|
279
|
+
from as string | undefined,
|
|
280
|
+
(timezone as string) ?? 'UTC',
|
|
281
|
+
);
|
|
282
|
+
res.jsonResponse(result);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
error(res, 400, (err as Error).message, `${req.version}/cron`);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
260
288
|
// RPG Dice roller
|
|
261
289
|
router.get('/:version/dice', (req: Request, res: Response) => {
|
|
262
290
|
const { roll } = req.query;
|
|
@@ -509,6 +537,33 @@ router.get('/:version/qrcode', async (req: Request, res: Response) => {
|
|
|
509
537
|
}
|
|
510
538
|
});
|
|
511
539
|
|
|
540
|
+
// Test a regex pattern against a text
|
|
541
|
+
router.get('/:version/regex', (req: Request, res: Response) => {
|
|
542
|
+
const { pattern, text, flags } = req.query;
|
|
543
|
+
const { version } = req.params;
|
|
544
|
+
|
|
545
|
+
const regexFn = (req.module as { regex?: (p: string, t: string, f?: string) => unknown }).regex;
|
|
546
|
+
if (!regexFn) {
|
|
547
|
+
error(res, 404, `Endpoint not available in ${version}.`, `${version}/regex`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (!pattern) {
|
|
551
|
+
error(res, 400, 'Please provide a pattern (?pattern={regex})', `${version}/regex`);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (!text) {
|
|
555
|
+
error(res, 400, 'Please provide a text (&text={string})', `${version}/regex`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
const result = regexFn(pattern as string, text as string, flags as string | undefined);
|
|
561
|
+
res.jsonResponse(result);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
error(res, 400, (err as Error).message, `${req.version}/regex`);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
512
567
|
// Statistics on a list of numbers
|
|
513
568
|
router.get('/:version/statistics', (req: Request, res: Response) => {
|
|
514
569
|
const { values } = req.query;
|
|
@@ -609,60 +664,26 @@ router.get('/:version/tic-tac-toe/list', (_req: Request, res: Response) => {
|
|
|
609
664
|
error(res, 405, 'This endpoint only supports POST requests.');
|
|
610
665
|
});
|
|
611
666
|
|
|
612
|
-
// Display or generate time informations
|
|
667
|
+
// Display or generate time informations, or compute a countdown
|
|
613
668
|
router.get('/:version/time', (req: Request, res: Response) => {
|
|
614
|
-
const { type = 'live', start, end, format, timezone } = req.query;
|
|
615
|
-
|
|
616
|
-
const validFormats = [
|
|
617
|
-
'iso',
|
|
618
|
-
'utc',
|
|
619
|
-
'timestamp',
|
|
620
|
-
'locale',
|
|
621
|
-
'date',
|
|
622
|
-
'time',
|
|
623
|
-
'year',
|
|
624
|
-
'month',
|
|
625
|
-
'day',
|
|
626
|
-
'hour',
|
|
627
|
-
'minute',
|
|
628
|
-
'second',
|
|
629
|
-
'ms',
|
|
630
|
-
'dayOfWeek',
|
|
631
|
-
'dayOfYear',
|
|
632
|
-
'weekNumber',
|
|
633
|
-
'timezone',
|
|
634
|
-
'timezoneOffset',
|
|
635
|
-
];
|
|
636
|
-
const validTimezones = ['UTC', 'America/New_York', 'Europe/Paris', 'Asia/Tokyo', 'Australia/Sydney'];
|
|
637
|
-
|
|
638
|
-
if (type !== 'live' && type !== 'random') {
|
|
639
|
-
error(res, 400, 'Please provide a valid type (?type={type})', `${req.version}/time`);
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
if (start && !Date.parse(start as string)) {
|
|
643
|
-
error(res, 400, 'Please provide a valid start date (?start={YYYY-MM-DD})', `${req.version}/time`);
|
|
644
|
-
return;
|
|
645
|
-
}
|
|
646
|
-
if (end && !Date.parse(end as string)) {
|
|
647
|
-
error(res, 400, 'Please provide a valid end date (?end={YYYY-MM-DD})', `${req.version}/time`);
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
if (format && !validFormats.includes(format as string)) {
|
|
651
|
-
error(res, 400, 'Please provide a valid format (?format={format})', `${req.version}/time`);
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
if (timezone && !validTimezones.includes(timezone as string)) {
|
|
655
|
-
error(res, 400, 'Please provide a valid timezone (?timezone={timezone})', `${req.version}/time`);
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
669
|
+
const { type = 'live', start, end, format, timezone, target } = req.query;
|
|
658
670
|
|
|
659
671
|
try {
|
|
660
|
-
const
|
|
672
|
+
const timeFn = req.module.time as (
|
|
673
|
+
t: string,
|
|
674
|
+
s?: string,
|
|
675
|
+
e?: string,
|
|
676
|
+
f?: string,
|
|
677
|
+
tz?: string,
|
|
678
|
+
target?: string,
|
|
679
|
+
) => Record<string, unknown>;
|
|
680
|
+
const time = timeFn(
|
|
661
681
|
type as string,
|
|
662
682
|
start as string,
|
|
663
683
|
end as string,
|
|
664
684
|
format as string,
|
|
665
685
|
timezone as string,
|
|
686
|
+
target as string,
|
|
666
687
|
);
|
|
667
688
|
res.jsonResponse(time);
|
|
668
689
|
} catch (err) {
|