@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.
Files changed (107) hide show
  1. package/README.md +3 -3
  2. package/dist/app.js +6 -6
  3. package/dist/app.js.map +1 -1
  4. package/dist/config/env.js.map +1 -1
  5. package/dist/config/versions.js +22 -3
  6. package/dist/config/versions.js.map +1 -1
  7. package/dist/constants.js +27 -12
  8. package/dist/constants.js.map +1 -1
  9. package/dist/middleware/error.js +2 -2
  10. package/dist/middleware/error.js.map +1 -1
  11. package/dist/middleware/logger.js.map +1 -1
  12. package/dist/middleware/ratelimit.js +2 -2
  13. package/dist/middleware/ratelimit.js.map +1 -1
  14. package/dist/middleware/version.js.map +1 -1
  15. package/dist/modules/v3.js +1 -1
  16. package/dist/modules/v3.js.map +1 -1
  17. package/dist/modules/v4/address.js +381 -0
  18. package/dist/modules/v4/address.js.map +1 -0
  19. package/dist/modules/v4/agent.js +92 -0
  20. package/dist/modules/v4/agent.js.map +1 -0
  21. package/dist/modules/v4/algorithms.js +79 -0
  22. package/dist/modules/v4/algorithms.js.map +1 -1
  23. package/dist/modules/v4/avatar.js +136 -0
  24. package/dist/modules/v4/avatar.js.map +1 -0
  25. package/dist/modules/v4/barcode.js +440 -0
  26. package/dist/modules/v4/barcode.js.map +1 -0
  27. package/dist/modules/v4/captcha.js +7 -0
  28. package/dist/modules/v4/captcha.js.map +1 -1
  29. package/dist/modules/v4/chat.js +9 -1
  30. package/dist/modules/v4/chat.js.map +1 -1
  31. package/dist/modules/v4/color.js +8 -1
  32. package/dist/modules/v4/color.js.map +1 -1
  33. package/dist/modules/v4/convert.js +9 -0
  34. package/dist/modules/v4/convert.js.map +1 -1
  35. package/dist/modules/v4/credit.js +104 -0
  36. package/dist/modules/v4/credit.js.map +1 -0
  37. package/dist/modules/v4/cron.js +202 -0
  38. package/dist/modules/v4/cron.js.map +1 -0
  39. package/dist/modules/v4/dice.js +7 -0
  40. package/dist/modules/v4/dice.js.map +1 -1
  41. package/dist/modules/v4/domain.js +6 -1
  42. package/dist/modules/v4/domain.js.map +1 -1
  43. package/dist/modules/v4/encode.js +71 -0
  44. package/dist/modules/v4/encode.js.map +1 -1
  45. package/dist/modules/v4/geo.js +11 -1
  46. package/dist/modules/v4/geo.js.map +1 -1
  47. package/dist/modules/v4/hash.js +10 -1
  48. package/dist/modules/v4/hash.js.map +1 -1
  49. package/dist/modules/v4/hyperplanning.js +9 -1
  50. package/dist/modules/v4/hyperplanning.js.map +1 -1
  51. package/dist/modules/v4/ip.js +132 -0
  52. package/dist/modules/v4/ip.js.map +1 -0
  53. package/dist/modules/v4/levenshtein.js +8 -0
  54. package/dist/modules/v4/levenshtein.js.map +1 -1
  55. package/dist/modules/v4/palette.js +9 -1
  56. package/dist/modules/v4/palette.js.map +1 -1
  57. package/dist/modules/v4/password.js +343 -0
  58. package/dist/modules/v4/password.js.map +1 -0
  59. package/dist/modules/v4/personal.js +14 -18
  60. package/dist/modules/v4/personal.js.map +1 -1
  61. package/dist/modules/v4/placeholder.js +8 -0
  62. package/dist/modules/v4/placeholder.js.map +1 -1
  63. package/dist/modules/v4/qrcode.js +10 -3
  64. package/dist/modules/v4/qrcode.js.map +1 -1
  65. package/dist/modules/v4/regex.js +48 -0
  66. package/dist/modules/v4/regex.js.map +1 -0
  67. package/dist/modules/v4/statistics.js +7 -0
  68. package/dist/modules/v4/statistics.js.map +1 -1
  69. package/dist/modules/v4/text.js +30 -0
  70. package/dist/modules/v4/text.js.map +1 -1
  71. package/dist/modules/v4/tic_tac_toe.js +9 -1
  72. package/dist/modules/v4/tic_tac_toe.js.map +1 -1
  73. package/dist/modules/v4/time.js +62 -11
  74. package/dist/modules/v4/time.js.map +1 -1
  75. package/dist/modules/v4/token.js +17 -8
  76. package/dist/modules/v4/token.js.map +1 -1
  77. package/dist/modules/v4/username.js +5 -0
  78. package/dist/modules/v4/username.js.map +1 -1
  79. package/dist/modules/v4/validate.js +21 -0
  80. package/dist/modules/v4/validate.js.map +1 -1
  81. package/dist/modules/v4.js +10 -1
  82. package/dist/modules/v4.js.map +1 -1
  83. package/dist/routes/delete.js.map +1 -1
  84. package/dist/routes/get.js +208 -48
  85. package/dist/routes/get.js.map +1 -1
  86. package/dist/routes/index.js +2 -2
  87. package/dist/routes/index.js.map +1 -1
  88. package/dist/routes/patch.js.map +1 -1
  89. package/dist/routes/post.js +2 -2
  90. package/dist/routes/post.js.map +1 -1
  91. package/dist/utils/response.js.map +1 -1
  92. package/package.json +1 -1
  93. package/src/config/versions.ts +3 -1
  94. package/src/constants.ts +27 -12
  95. package/src/modules/v4/address.ts +6 -7
  96. package/src/modules/v4/cron.ts +221 -0
  97. package/src/modules/v4/geo.ts +2 -1
  98. package/src/modules/v4/password.ts +5 -7
  99. package/src/modules/v4/qrcode.ts +2 -2
  100. package/src/modules/v4/regex.ts +67 -0
  101. package/src/modules/v4/time.ts +63 -17
  102. package/src/modules/v4.ts +2 -0
  103. package/src/routes/get.ts +67 -46
  104. package/tests/integration/api.test.ts +56 -0
  105. package/tests/unit/cron.test.ts +110 -0
  106. package/tests/unit/regex.test.ts +95 -0
  107. 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
+ }
@@ -5,7 +5,8 @@ export interface GeoResult {
5
5
  to: { lat: number; lon: number };
6
6
  }
7
7
 
8
- const EARTH_RADIUS_KM = 6371;
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
- const MIN_LENGTH = 8;
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 > MAX_COUNT) {
331
- throw new Error(`Count must be between 1 and ${MAX_COUNT}`);
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 < MIN_LENGTH || length > MAX_LENGTH) {
352
- throw new Error(`Length must be between ${MIN_LENGTH} and ${MAX_LENGTH}`);
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 = '';
@@ -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 > MAX_LOGO_BYTES) throw new Error('Icon must be under 2MB');
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
+ }
@@ -1,4 +1,6 @@
1
- const validFormats = [
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 validTimezones = ['UTC', 'America/New_York', 'Europe/Paris', 'Asia/Tokyo', 'Australia/Sydney'] as const;
24
+ export const VALID_TIMEZONES = ['UTC', 'America/New_York', 'Europe/Paris', 'Asia/Tokyo', 'Australia/Sydney'] as const;
23
25
 
24
- type TimeFormat = (typeof validFormats)[number];
25
- type Timezone = (typeof validTimezones)[number];
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 or "random" for a random date within a range
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
- * @returns Object containing all time formats, or a single format if specified
36
- * @throws Error if type, format, or timezone is invalid
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 random)');
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 && !validFormats.includes(format as TimeFormat)) {
58
- throw new Error(`Please provide a valid format. Options: ${validFormats.join(', ')}`);
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 || validTimezones[Math.floor(Math.random() * validTimezones.length)]!;
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 time = req.module.time(
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) {