@20syldev/api 4.1.0 → 4.2.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 +9 -4
- package/package.json +1 -1
- package/src/config/versions.ts +3 -0
- package/src/middleware/cors.ts +1 -1
- package/src/modules/v4/chat.ts +1 -24
- package/src/modules/v4/tic_tac_toe.ts +1 -33
- package/src/modules/v4.ts +3 -0
- package/src/routes/get.ts +87 -0
- package/tests/integration/api.test.ts +55 -0
- package/docs/changelog.md +0 -333
- package/src/modules/v4/geo.ts +0 -53
- package/src/modules/v4/palette.ts +0 -103
- package/src/modules/v4/placeholder.ts +0 -122
- package/src/routes/delete.ts +0 -72
- package/src/routes/patch.ts +0 -45
- package/src/utils/version.ts +0 -5
- package/tests/unit/geo.test.ts +0 -45
- package/tests/unit/palette.test.ts +0 -53
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<a href="https://api.sylvain.sh"><img src="https://api.sylvain.sh/favicon.ico" alt="Logo" width="25%" height="auto"/></a>
|
|
3
3
|
|
|
4
4
|
# API Personnelle
|
|
5
|
-
[](https://github.com/20syldev/api/releases/latest)
|
|
6
6
|
</div>
|
|
7
7
|
|
|
8
8
|
---
|
|
@@ -32,10 +32,10 @@ Pour démarrer un serveur local avec tous les endpoints :
|
|
|
32
32
|
$ npm run build && npm start
|
|
33
33
|
```
|
|
34
34
|
```console
|
|
35
|
-
> @20syldev/api@4.
|
|
35
|
+
> @20syldev/api@4.2.0 build
|
|
36
36
|
> tsc
|
|
37
37
|
|
|
38
|
-
> @20syldev/api@4.
|
|
38
|
+
> @20syldev/api@4.2.0 start
|
|
39
39
|
> node dist/app.js
|
|
40
40
|
|
|
41
41
|
API is running on
|
|
@@ -93,16 +93,21 @@ import {
|
|
|
93
93
|
chat, // Système de chat temporaire
|
|
94
94
|
color, // Génération de couleurs aléatoires
|
|
95
95
|
convert, // Conversions d'unités
|
|
96
|
+
dice, // Lanceur de dés RPG
|
|
96
97
|
domain, // Informations de domaine aléatoires
|
|
98
|
+
encode, // Encodage / décodage (base64, morse, rot13, caesar, binaire)
|
|
97
99
|
hash, // Hachage de texte
|
|
98
100
|
hyperplanning, // Analyse de calendriers
|
|
99
101
|
levenshtein, // Distance entre chaînes
|
|
100
102
|
personal, // Informations personnelles aléatoires
|
|
101
103
|
qrcode, // Génération de QR codes
|
|
104
|
+
statistics, // Statistiques descriptives
|
|
102
105
|
tic_tac_toe, // Jeu de morpion
|
|
103
106
|
time, // Informations temporelles
|
|
107
|
+
text, // Utilitaires texte (slug, stats, lorem, nombre en lettres)
|
|
104
108
|
token, // Génération de jetons sécurisés
|
|
105
|
-
username
|
|
109
|
+
username, // Génération de noms d'utilisateur
|
|
110
|
+
validate // Validation (Luhn, IBAN, email)
|
|
106
111
|
} from '@20syldev/api/v4';
|
|
107
112
|
```
|
|
108
113
|
|
package/package.json
CHANGED
package/src/config/versions.ts
CHANGED
|
@@ -79,7 +79,10 @@ const v3 = {
|
|
|
79
79
|
const v4 = {
|
|
80
80
|
get: merge(v3.get, [
|
|
81
81
|
{ name: 'dice', path: '/dice?roll={NdX+M}' },
|
|
82
|
+
{ name: 'encode', path: '/encode?method={method}&text={text}(&shift={shift})' },
|
|
82
83
|
{ name: 'statistics', path: '/statistics?values={n1,n2,n3,...}' },
|
|
84
|
+
{ name: 'text', path: '/text?method={method}(&value={value}&type={type}&count={count}&lang={lang})' },
|
|
85
|
+
{ name: 'validate', path: '/validate?type={type}&value={value}' },
|
|
83
86
|
]),
|
|
84
87
|
post: [...v3.post],
|
|
85
88
|
};
|
package/src/middleware/cors.ts
CHANGED
|
@@ -8,7 +8,7 @@ const __dirname = dirname(__filename);
|
|
|
8
8
|
|
|
9
9
|
export function setupCors(app: Express): void {
|
|
10
10
|
app.set('trust proxy', 1);
|
|
11
|
-
app.use(cors({ methods: ['GET', 'POST'
|
|
11
|
+
app.use(cors({ methods: ['GET', 'POST'] }));
|
|
12
12
|
app.use(express.urlencoded({ extended: true }));
|
|
13
13
|
app.use(express.json());
|
|
14
14
|
|
package/src/modules/v4/chat.ts
CHANGED
|
@@ -34,10 +34,8 @@ export default function chat(action: string, params: ChatParams): ChatMessage[]
|
|
|
34
34
|
return getPrivateChat(params, privateChats);
|
|
35
35
|
} else if (action === 'fetch') {
|
|
36
36
|
return fetchMessages(messages);
|
|
37
|
-
} else if (action === 'clear') {
|
|
38
|
-
return clearPrivateChat(params, privateChats, sessions, u);
|
|
39
37
|
} else {
|
|
40
|
-
throw new Error('Invalid action. Use "message", "private",
|
|
38
|
+
throw new Error('Invalid action. Use "message", "private", or "fetch"');
|
|
41
39
|
}
|
|
42
40
|
}
|
|
43
41
|
|
|
@@ -99,24 +97,3 @@ function fetchMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
|
99
97
|
if (messages.length > 0) return messages;
|
|
100
98
|
throw new Error('No messages stored.');
|
|
101
99
|
}
|
|
102
|
-
|
|
103
|
-
function clearPrivateChat(
|
|
104
|
-
params: ChatParams,
|
|
105
|
-
privateChats: Record<string, ChatMessage[]>,
|
|
106
|
-
sessions: Record<string, { user: string; last: number }>,
|
|
107
|
-
u: string,
|
|
108
|
-
): { message: string } {
|
|
109
|
-
const { token, session } = params;
|
|
110
|
-
|
|
111
|
-
if (!token) throw new Error('Please provide a valid token');
|
|
112
|
-
if (!session) throw new Error('Please provide a valid session ID');
|
|
113
|
-
if (sessions[u] && sessions[u].user !== session) {
|
|
114
|
-
throw new Error('Session ID mismatch');
|
|
115
|
-
}
|
|
116
|
-
if (!privateChats[token]) throw new Error('Invalid or expired token.');
|
|
117
|
-
|
|
118
|
-
delete privateChats[token];
|
|
119
|
-
delete sessions[u];
|
|
120
|
-
|
|
121
|
-
return { message: 'Private chat cleared successfully' };
|
|
122
|
-
}
|
|
@@ -44,43 +44,11 @@ export default function tic_tac_toe(action: string, params: TicTacToeParams): Re
|
|
|
44
44
|
return playMove(params, games, sessions, u, now);
|
|
45
45
|
} else if (action === 'fetch') {
|
|
46
46
|
return fetchGame(params, games, u);
|
|
47
|
-
} else if (action === 'forfeit') {
|
|
48
|
-
return forfeitGame(params, games, sessions);
|
|
49
47
|
} else {
|
|
50
|
-
throw new Error('Invalid action. Use "play", "fetch",
|
|
48
|
+
throw new Error('Invalid action. Use "play", "fetch", or "list"');
|
|
51
49
|
}
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
function forfeitGame(
|
|
55
|
-
params: TicTacToeParams,
|
|
56
|
-
games: Record<string, TicTacToeGame>,
|
|
57
|
-
sessions: Record<string, { user: string; last: number }>,
|
|
58
|
-
): Record<string, unknown> {
|
|
59
|
-
const { game, session } = params;
|
|
60
|
-
|
|
61
|
-
if (!game) throw new Error('Please provide a valid game ID');
|
|
62
|
-
if (!session) throw new Error('Please provide a valid session ID');
|
|
63
|
-
if (!games[game]) throw new Error('Game not found');
|
|
64
|
-
|
|
65
|
-
const u = params.username!.toLowerCase();
|
|
66
|
-
if (sessions[u] && sessions[u].user !== session) {
|
|
67
|
-
throw new Error('Session ID mismatch');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const players = games[game]!.players;
|
|
71
|
-
if (!players.includes(u)) throw new Error('You are not a player in this game');
|
|
72
|
-
|
|
73
|
-
const winner = players.find((p) => p !== u) ?? null;
|
|
74
|
-
delete games[game];
|
|
75
|
-
delete sessions[u];
|
|
76
|
-
|
|
77
|
-
return {
|
|
78
|
-
message: `${params.username} forfeited the game.${winner ? ` ${winner} wins.` : ''}`,
|
|
79
|
-
winner,
|
|
80
|
-
loser: params.username,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
52
|
function playMove(
|
|
85
53
|
params: TicTacToeParams,
|
|
86
54
|
games: Record<string, TicTacToeGame>,
|
package/src/modules/v4.ts
CHANGED
|
@@ -5,13 +5,16 @@ export { default as color } from './v4/color.js';
|
|
|
5
5
|
export { default as convert } from './v4/convert.js';
|
|
6
6
|
export { default as dice } from './v4/dice.js';
|
|
7
7
|
export { default as domain } from './v4/domain.js';
|
|
8
|
+
export * as encode from './v4/encode.js';
|
|
8
9
|
export { default as hash } from './v4/hash.js';
|
|
9
10
|
export { default as hyperplanning } from './v4/hyperplanning.js';
|
|
10
11
|
export { default as levenshtein } from './v4/levenshtein.js';
|
|
11
12
|
export { default as personal } from './v4/personal.js';
|
|
12
13
|
export { default as qrcode } from './v4/qrcode.js';
|
|
13
14
|
export { default as statistics } from './v4/statistics.js';
|
|
15
|
+
export * as text from './v4/text.js';
|
|
14
16
|
export { default as tic_tac_toe } from './v4/tic_tac_toe.js';
|
|
15
17
|
export { default as time } from './v4/time.js';
|
|
16
18
|
export { default as token } from './v4/token.js';
|
|
17
19
|
export { default as username } from './v4/username.js';
|
|
20
|
+
export * as validate from './v4/validate.js';
|
package/src/routes/get.ts
CHANGED
|
@@ -173,6 +173,29 @@ router.get('/:version/dice', (req: Request, res: Response) => {
|
|
|
173
173
|
}
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
// Encode / decode text
|
|
177
|
+
router.get('/:version/encode', (req: Request, res: Response) => {
|
|
178
|
+
const { method, text, shift } = req.query;
|
|
179
|
+
const { version } = req.params;
|
|
180
|
+
|
|
181
|
+
const encode = (req.module as { encode?: Record<string, (v: string, v2?: string) => string> }).encode;
|
|
182
|
+
if (!encode) {
|
|
183
|
+
error(res, 404, `Endpoint not available in ${version}.`, `${version}/encode`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!method || !encode[method as string]) {
|
|
187
|
+
error(res, 400, 'Please provide a valid method (?method={method})', `${version}/encode`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const result = encode[method as string]!(text as string, shift as string);
|
|
193
|
+
res.jsonResponse({ method, result });
|
|
194
|
+
} catch (err) {
|
|
195
|
+
error(res, 400, (err as Error).message, `${req.version}/encode`);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
176
199
|
// GET planning error
|
|
177
200
|
router.get('/:version/hyperplanning', (_req: Request, res: Response) => {
|
|
178
201
|
error(res, 405, 'This endpoint only supports POST requests.');
|
|
@@ -269,6 +292,70 @@ router.get('/:version/statistics', (req: Request, res: Response) => {
|
|
|
269
292
|
}
|
|
270
293
|
});
|
|
271
294
|
|
|
295
|
+
// Text utilities (slug, stats, lorem, number)
|
|
296
|
+
router.get('/:version/text', (req: Request, res: Response) => {
|
|
297
|
+
const { method, value, type, count, lang, text } = req.query;
|
|
298
|
+
const { version } = req.params;
|
|
299
|
+
|
|
300
|
+
const textMod = (req.module as { text?: Record<string, (...args: string[]) => unknown> }).text;
|
|
301
|
+
if (!textMod) {
|
|
302
|
+
error(res, 404, `Endpoint not available in ${version}.`, `${version}/text`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!method || !textMod[method as string]) {
|
|
306
|
+
error(res, 400, 'Please provide a valid method (?method={slug|stats|lorem|number})', `${version}/text`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
let result: unknown;
|
|
312
|
+
switch (method) {
|
|
313
|
+
case 'slug':
|
|
314
|
+
case 'stats':
|
|
315
|
+
result = textMod[method as string]!((value ?? text) as string);
|
|
316
|
+
break;
|
|
317
|
+
case 'lorem':
|
|
318
|
+
result = textMod.lorem!((type as string) || 'words', (count as string) || '5');
|
|
319
|
+
break;
|
|
320
|
+
case 'number':
|
|
321
|
+
result = textMod.number!(value as string, (lang as string) || 'en');
|
|
322
|
+
break;
|
|
323
|
+
default:
|
|
324
|
+
throw new Error('Unknown method');
|
|
325
|
+
}
|
|
326
|
+
res.jsonResponse({ method, result });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
error(res, 400, (err as Error).message, `${req.version}/text`);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Validate data (luhn, iban, email)
|
|
333
|
+
router.get('/:version/validate', (req: Request, res: Response) => {
|
|
334
|
+
const { type, value } = req.query;
|
|
335
|
+
const { version } = req.params;
|
|
336
|
+
|
|
337
|
+
const validate = (req.module as { validate?: Record<string, (v: string) => unknown> }).validate;
|
|
338
|
+
if (!validate) {
|
|
339
|
+
error(res, 404, `Endpoint not available in ${version}.`, `${version}/validate`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!type || !validate[type as string]) {
|
|
343
|
+
error(res, 400, 'Please provide a valid type (?type={luhn|iban|email})', `${version}/validate`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (!value) {
|
|
347
|
+
error(res, 400, 'Please provide a value (&value={value})', `${version}/validate`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const result = validate[type as string]!(value as string);
|
|
353
|
+
res.jsonResponse(result);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
error(res, 400, (err as Error).message, `${req.version}/validate`);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
272
359
|
// GET tic-tac-toe errors
|
|
273
360
|
router.get('/:version/tic-tac-toe', (_req: Request, res: Response) => {
|
|
274
361
|
error(res, 405, 'This endpoint only supports POST requests.');
|
|
@@ -51,7 +51,10 @@ describe('GET / (version listing)', () => {
|
|
|
51
51
|
assert.equal(body.version, 'v4');
|
|
52
52
|
const endpoints = body.endpoints as Record<string, Record<string, string>>;
|
|
53
53
|
assert.ok('dice' in endpoints.get!);
|
|
54
|
+
assert.ok('encode' in endpoints.get!);
|
|
54
55
|
assert.ok('statistics' in endpoints.get!);
|
|
56
|
+
assert.ok('text' in endpoints.get!);
|
|
57
|
+
assert.ok('validate' in endpoints.get!);
|
|
55
58
|
});
|
|
56
59
|
|
|
57
60
|
test('invalid version returns 404', async () => {
|
|
@@ -148,6 +151,24 @@ describe('GET /v4/dice', () => {
|
|
|
148
151
|
});
|
|
149
152
|
});
|
|
150
153
|
|
|
154
|
+
describe('GET /v4/encode', () => {
|
|
155
|
+
test('base64 encode', async () => {
|
|
156
|
+
const { status, body } = await getJson('/v4/encode?method=base64encode&text=hello');
|
|
157
|
+
assert.equal(status, 200);
|
|
158
|
+
assert.equal(body.result, 'aGVsbG8=');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('rot13', async () => {
|
|
162
|
+
const { body } = await getJson('/v4/encode?method=rot13&text=Hello');
|
|
163
|
+
assert.equal(body.result, 'Uryyb');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('missing method returns 400', async () => {
|
|
167
|
+
const { status } = await getJson('/v4/encode?text=hello');
|
|
168
|
+
assert.equal(status, 400);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
151
172
|
describe('GET /v4/domain', () => {
|
|
152
173
|
test('has TLD', async () => {
|
|
153
174
|
const { status, body } = await getJson('/v4/domain');
|
|
@@ -205,6 +226,23 @@ describe('GET /v4/statistics', () => {
|
|
|
205
226
|
});
|
|
206
227
|
});
|
|
207
228
|
|
|
229
|
+
describe('GET /v4/text', () => {
|
|
230
|
+
test('slug', async () => {
|
|
231
|
+
const { body } = await getJson('/v4/text?method=slug&value=Hello%20World');
|
|
232
|
+
assert.equal(body.result, 'hello-world');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('number en', async () => {
|
|
236
|
+
const { body } = await getJson('/v4/text?method=number&value=42&lang=en');
|
|
237
|
+
assert.equal(body.result, 'forty-two');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('lorem words', async () => {
|
|
241
|
+
const { body } = await getJson('/v4/text?method=lorem&type=words&count=5');
|
|
242
|
+
assert.equal((body.result as string).split(' ').length, 5);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
208
246
|
describe('GET /v4/time', () => {
|
|
209
247
|
test('live', async () => {
|
|
210
248
|
const { status, body } = await getJson('/v4/time');
|
|
@@ -222,6 +260,23 @@ describe('GET /v4/time', () => {
|
|
|
222
260
|
});
|
|
223
261
|
});
|
|
224
262
|
|
|
263
|
+
describe('GET /v4/validate', () => {
|
|
264
|
+
test('valid luhn', async () => {
|
|
265
|
+
const { body } = await getJson('/v4/validate?type=luhn&value=4111111111111111');
|
|
266
|
+
assert.equal(body.valid, true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('valid email', async () => {
|
|
270
|
+
const { body } = await getJson('/v4/validate?type=email&value=hello%40example.com');
|
|
271
|
+
assert.equal(body.valid, true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('invalid type returns 400', async () => {
|
|
275
|
+
const { status } = await getJson('/v4/validate?type=foo&value=bar');
|
|
276
|
+
assert.equal(status, 400);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
225
280
|
describe('GET /v4/username', () => {
|
|
226
281
|
test('has fields', async () => {
|
|
227
282
|
const { status, body } = await getJson('/v4/username');
|
package/docs/changelog.md
DELETED
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## Exemples curl
|
|
4
|
-
|
|
5
|
-
```bash
|
|
6
|
-
# dice
|
|
7
|
-
curl -X GET "http://127.0.0.1:3000/v4/dice?roll=2d6%2B3"
|
|
8
|
-
|
|
9
|
-
# encode
|
|
10
|
-
curl -X GET "http://127.0.0.1:3000/v4/encode?method=base64encode&text=hello"
|
|
11
|
-
curl -X GET "http://127.0.0.1:3000/v4/encode?method=morse&text=SOS"
|
|
12
|
-
curl -X GET "http://127.0.0.1:3000/v4/encode?method=caesar&text=hello&shift=3"
|
|
13
|
-
|
|
14
|
-
# geo
|
|
15
|
-
curl -X GET "http://127.0.0.1:3000/v4/geo?lat1=48.8566&lon1=2.3522&lat2=43.2965&lon2=5.3698"
|
|
16
|
-
|
|
17
|
-
# palette
|
|
18
|
-
curl -X GET "http://127.0.0.1:3000/v4/palette?color=%23ff6600&type=triadic"
|
|
19
|
-
|
|
20
|
-
# statistics
|
|
21
|
-
curl -X GET "http://127.0.0.1:3000/v4/statistics?values=1,2,3,4,5"
|
|
22
|
-
|
|
23
|
-
# text - stats
|
|
24
|
-
curl -X GET "http://127.0.0.1:3000/v4/text?method=stats&value=Hello+world"
|
|
25
|
-
|
|
26
|
-
# text - slug
|
|
27
|
-
curl -X GET "http://127.0.0.1:3000/v4/text?method=slug&value=Héllo+Wörld%21"
|
|
28
|
-
|
|
29
|
-
# text - lorem
|
|
30
|
-
curl -X GET "http://127.0.0.1:3000/v4/text?method=lorem&type=sentences&count=3"
|
|
31
|
-
|
|
32
|
-
# text - number
|
|
33
|
-
curl -X GET "http://127.0.0.1:3000/v4/text?method=number&value=42&lang=fr"
|
|
34
|
-
|
|
35
|
-
# validate - luhn
|
|
36
|
-
curl -X GET "http://127.0.0.1:3000/v4/validate?type=luhn&value=4532015112830366"
|
|
37
|
-
|
|
38
|
-
# validate - iban
|
|
39
|
-
curl -X GET "http://127.0.0.1:3000/v4/validate?type=iban&value=FR7630006000011234567890189"
|
|
40
|
-
|
|
41
|
-
# validate - email
|
|
42
|
-
curl -X GET "http://127.0.0.1:3000/v4/validate?type=email&value=user%40example.com"
|
|
43
|
-
|
|
44
|
-
# algorithms - roman
|
|
45
|
-
curl -X GET "http://127.0.0.1:3000/v4/algorithms?method=roman&value=42"
|
|
46
|
-
curl -X GET "http://127.0.0.1:3000/v4/algorithms?method=roman&value=XLII"
|
|
47
|
-
|
|
48
|
-
# REST v4.2 - jouer un coup (PATCH)
|
|
49
|
-
curl -X PATCH "http://127.0.0.1:3000/v4/tic-tac-toe/{gameId}" \
|
|
50
|
-
-H "Content-Type: application/json" \
|
|
51
|
-
-d '{"username":"alice","move":"A1","session":"abc123"}'
|
|
52
|
-
|
|
53
|
-
# REST v4.2 - forfait (DELETE tic-tac-toe)
|
|
54
|
-
curl -X DELETE "http://127.0.0.1:3000/v4/tic-tac-toe/{gameId}" \
|
|
55
|
-
-H "Content-Type: application/json" \
|
|
56
|
-
-d '{"username":"alice","session":"abc123"}'
|
|
57
|
-
|
|
58
|
-
# REST v4.2 - vider un chat (DELETE chat)
|
|
59
|
-
curl -X DELETE "http://127.0.0.1:3000/v4/chat/{token}" \
|
|
60
|
-
-H "Content-Type: application/json" \
|
|
61
|
-
-d '{"username":"alice","session":"abc123"}'
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
## v4.2.0
|
|
67
|
-
|
|
68
|
-
### REST endpoints pour tic-tac-toe et chat
|
|
69
|
-
|
|
70
|
-
Les endpoints existants ont été étendus avec des méthodes HTTP REST. Uniquement disponible en `v4+`.
|
|
71
|
-
|
|
72
|
-
#### PATCH `/v4/tic-tac-toe/:game`
|
|
73
|
-
|
|
74
|
-
Jouer un coup dans une partie.
|
|
75
|
-
|
|
76
|
-
**Body (JSON) :**
|
|
77
|
-
| Param | Type | Description |
|
|
78
|
-
|-------|------|-------------|
|
|
79
|
-
| `username` | string | Nom du joueur |
|
|
80
|
-
| `move` | string | Case jouée (ex: `A1`) |
|
|
81
|
-
| `session` | string | ID de session |
|
|
82
|
-
|
|
83
|
-
---
|
|
84
|
-
|
|
85
|
-
#### DELETE `/v4/tic-tac-toe/:game`
|
|
86
|
-
|
|
87
|
-
Abandonner une partie (forfeit).
|
|
88
|
-
|
|
89
|
-
**Body (JSON) :**
|
|
90
|
-
| Param | Type | Description |
|
|
91
|
-
|-------|------|-------------|
|
|
92
|
-
| `username` | string | Nom du joueur |
|
|
93
|
-
| `session` | string | ID de session |
|
|
94
|
-
|
|
95
|
-
---
|
|
96
|
-
|
|
97
|
-
#### DELETE `/v4/chat/:token`
|
|
98
|
-
|
|
99
|
-
Effacer tous les messages d'un chat privé (clear).
|
|
100
|
-
|
|
101
|
-
**Body (JSON) :**
|
|
102
|
-
| Param | Type | Description |
|
|
103
|
-
|-------|------|-------------|
|
|
104
|
-
| `username` | string | Nom de l'utilisateur |
|
|
105
|
-
| `session` | string | ID de session |
|
|
106
|
-
|
|
107
|
-
---
|
|
108
|
-
|
|
109
|
-
### Autres changements v4.2.0
|
|
110
|
-
|
|
111
|
-
- CORS : ajout de `PATCH`, `DELETE`, `OPTIONS` dans les headers autorisés
|
|
112
|
-
- Nouveau helper `supportsRest()` dans `src/utils/version.ts`
|
|
113
|
-
|
|
114
|
-
---
|
|
115
|
-
|
|
116
|
-
## v4.1.0
|
|
117
|
-
|
|
118
|
-
### Nouveaux endpoints
|
|
119
|
-
|
|
120
|
-
#### GET `/v4/dice`
|
|
121
|
-
|
|
122
|
-
Lance des dés en notation RPG.
|
|
123
|
-
|
|
124
|
-
**Query params :**
|
|
125
|
-
| Param | Requis | Description | Exemple |
|
|
126
|
-
|-------|--------|-------------|---------|
|
|
127
|
-
| `roll` | oui | Notation NdX ou NdX+M | `2d6+3` |
|
|
128
|
-
|
|
129
|
-
**Note :** le `+` dans l'URL peut être encodé en espace (`2d6 3`) — les deux sont acceptés.
|
|
130
|
-
|
|
131
|
-
**Réponse :**
|
|
132
|
-
```json
|
|
133
|
-
{
|
|
134
|
-
"roll": "2d6+3",
|
|
135
|
-
"count": 2,
|
|
136
|
-
"sides": 6,
|
|
137
|
-
"modifier": 3,
|
|
138
|
-
"results": [4, 2],
|
|
139
|
-
"total": 9
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
Limites : 1–100 dés, 2–1000 faces.
|
|
144
|
-
|
|
145
|
-
---
|
|
146
|
-
|
|
147
|
-
#### GET `/v4/encode`
|
|
148
|
-
|
|
149
|
-
Encode ou décode du texte dans différents formats.
|
|
150
|
-
|
|
151
|
-
**Query params :**
|
|
152
|
-
| Param | Requis | Description |
|
|
153
|
-
|-------|--------|-------------|
|
|
154
|
-
| `method` | oui | Voir liste ci-dessous |
|
|
155
|
-
| `value` | oui | Texte à traiter |
|
|
156
|
-
| `shift` | non | Décalage pour Caesar (défaut : 13) |
|
|
157
|
-
|
|
158
|
-
**Méthodes disponibles :**
|
|
159
|
-
| `method` | Description |
|
|
160
|
-
|----------|-------------|
|
|
161
|
-
| `base64encode` | Encode en Base64 |
|
|
162
|
-
| `base64decode` | Décode du Base64 |
|
|
163
|
-
| `urlencode` | Encode pour URL |
|
|
164
|
-
| `urldecode` | Décode une URL |
|
|
165
|
-
| `morse` | Texte → Morse |
|
|
166
|
-
| `unmorse` | Morse → Texte |
|
|
167
|
-
| `rot13` | ROT-13 |
|
|
168
|
-
| `caesar` | Chiffre de César (nécessite `shift`) |
|
|
169
|
-
| `binary` | Texte → binaire 8 bits |
|
|
170
|
-
| `unbinary` | Binaire → texte |
|
|
171
|
-
|
|
172
|
-
---
|
|
173
|
-
|
|
174
|
-
#### GET `/v4/geo`
|
|
175
|
-
|
|
176
|
-
Calcule la distance et le cap entre deux coordonnées GPS (Haversine).
|
|
177
|
-
|
|
178
|
-
**Query params :**
|
|
179
|
-
| Param | Requis | Description |
|
|
180
|
-
|-------|--------|-------------|
|
|
181
|
-
| `lat1` | oui | Latitude point A (-90/+90) |
|
|
182
|
-
| `lon1` | oui | Longitude point A (-180/+180) |
|
|
183
|
-
| `lat2` | oui | Latitude point B |
|
|
184
|
-
| `lon2` | oui | Longitude point B |
|
|
185
|
-
|
|
186
|
-
**Réponse :**
|
|
187
|
-
```json
|
|
188
|
-
{
|
|
189
|
-
"distance": { "km": 1214.652, "miles": 754.801, "nauticalMiles": 655.879 },
|
|
190
|
-
"bearing": { "degrees": 157.34, "cardinal": "SSE" },
|
|
191
|
-
"from": { "lat": 48.8566, "lon": 2.3522 },
|
|
192
|
-
"to": { "lat": 43.2965, "lon": 5.3698 }
|
|
193
|
-
}
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
---
|
|
197
|
-
|
|
198
|
-
#### GET `/v4/palette`
|
|
199
|
-
|
|
200
|
-
Génère une palette de couleurs harmonieuse à partir d'une couleur HEX.
|
|
201
|
-
|
|
202
|
-
**Query params :**
|
|
203
|
-
| Param | Requis | Description |
|
|
204
|
-
|-------|--------|-------------|
|
|
205
|
-
| `color` | oui | Couleur base en HEX (`#RRGGBB`) |
|
|
206
|
-
| `type` | oui | Type de palette |
|
|
207
|
-
|
|
208
|
-
**Types disponibles :**
|
|
209
|
-
| `type` | Couleurs générées |
|
|
210
|
-
|--------|------------------|
|
|
211
|
-
| `complementary` | 2 couleurs (opposées) |
|
|
212
|
-
| `triadic` | 3 couleurs (120°) |
|
|
213
|
-
| `analogous` | 5 couleurs (-60° à +60°) |
|
|
214
|
-
| `tetradic` | 4 couleurs (90°) |
|
|
215
|
-
| `split-complementary` | 3 couleurs |
|
|
216
|
-
|
|
217
|
-
Chaque couleur retournée a `hex`, `rgb`, `hsl`.
|
|
218
|
-
|
|
219
|
-
---
|
|
220
|
-
|
|
221
|
-
#### GET `/v4/statistics`
|
|
222
|
-
|
|
223
|
-
Calcule des statistiques descriptives sur une liste de nombres.
|
|
224
|
-
|
|
225
|
-
**Query params :**
|
|
226
|
-
| Param | Requis | Description |
|
|
227
|
-
|-------|--------|-------------|
|
|
228
|
-
| `values` | oui | Nombres séparés par des virgules |
|
|
229
|
-
|
|
230
|
-
**Réponse :**
|
|
231
|
-
```json
|
|
232
|
-
{
|
|
233
|
-
"count": 5,
|
|
234
|
-
"sum": 15,
|
|
235
|
-
"min": 1,
|
|
236
|
-
"max": 5,
|
|
237
|
-
"range": 4,
|
|
238
|
-
"mean": 3,
|
|
239
|
-
"median": 3,
|
|
240
|
-
"mode": [],
|
|
241
|
-
"variance": 2,
|
|
242
|
-
"stddev": 1.414214
|
|
243
|
-
}
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
---
|
|
247
|
-
|
|
248
|
-
#### GET `/v4/text`
|
|
249
|
-
|
|
250
|
-
Manipulation et génération de texte.
|
|
251
|
-
|
|
252
|
-
**Query params :**
|
|
253
|
-
| Param | Requis | Description |
|
|
254
|
-
|-------|--------|-------------|
|
|
255
|
-
| `action` | oui | Voir liste ci-dessous |
|
|
256
|
-
| `value` | non | Texte source (selon action) |
|
|
257
|
-
| `type` | non | `words`, `sentences`, `paragraphs` (pour `lorem`) |
|
|
258
|
-
| `count` | non | Nombre d'éléments (pour `lorem`, défaut 5) |
|
|
259
|
-
| `lang` | non | `fr` ou `en` (pour `number`) |
|
|
260
|
-
|
|
261
|
-
**Actions disponibles :**
|
|
262
|
-
| `action` | Description | Params requis |
|
|
263
|
-
|----------|-------------|---------------|
|
|
264
|
-
| `stats` | Statistiques du texte | `value` |
|
|
265
|
-
| `slug` | Convertit en slug URL | `value` |
|
|
266
|
-
| `lorem` | Génère du Lorem Ipsum | `type`, `count` |
|
|
267
|
-
| `number` | Nombre en lettres | `value`, `lang` |
|
|
268
|
-
|
|
269
|
-
**Réponse `stats` :**
|
|
270
|
-
```json
|
|
271
|
-
{
|
|
272
|
-
"characters": 42,
|
|
273
|
-
"charactersNoSpaces": 35,
|
|
274
|
-
"words": 8,
|
|
275
|
-
"sentences": 2,
|
|
276
|
-
"paragraphs": 1,
|
|
277
|
-
"readingTime": "3s",
|
|
278
|
-
"mostFrequentChar": "e"
|
|
279
|
-
}
|
|
280
|
-
```
|
|
281
|
-
|
|
282
|
-
---
|
|
283
|
-
|
|
284
|
-
#### GET `/v4/validate`
|
|
285
|
-
|
|
286
|
-
Valide différents formats de données.
|
|
287
|
-
|
|
288
|
-
**Query params :**
|
|
289
|
-
| Param | Requis | Description |
|
|
290
|
-
|-------|--------|-------------|
|
|
291
|
-
| `type` | oui | `luhn`, `iban`, `email` |
|
|
292
|
-
| `value` | oui | Valeur à valider |
|
|
293
|
-
|
|
294
|
-
**Réponse `luhn` (numéro de carte) :**
|
|
295
|
-
```json
|
|
296
|
-
{ "valid": true, "value": "4532015112830366" }
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
**Réponse `iban` :**
|
|
300
|
-
```json
|
|
301
|
-
{ "valid": true, "value": "FR7630006000011234567890189", "country": "FR" }
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
**Réponse `email` :**
|
|
305
|
-
```json
|
|
306
|
-
{ "valid": true, "value": "user@example.com" }
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
---
|
|
310
|
-
|
|
311
|
-
### Ajout à un endpoint existant
|
|
312
|
-
|
|
313
|
-
#### GET `/v4/algorithms` — conversion de chiffres romains
|
|
314
|
-
|
|
315
|
-
Nouvelle action `roman` sur l'endpoint algorithms.
|
|
316
|
-
|
|
317
|
-
**Query params :**
|
|
318
|
-
| Param | Requis | Description |
|
|
319
|
-
|-------|--------|-------------|
|
|
320
|
-
| `method` | oui | `roman` |
|
|
321
|
-
| `value` | oui | Nombre entier (1–3999) ou chiffre romain |
|
|
322
|
-
|
|
323
|
-
Bidirectionnel : `42` → `XLII`, `XLII` → `42`.
|
|
324
|
-
|
|
325
|
-
---
|
|
326
|
-
|
|
327
|
-
### Infrastructure v4.1.0
|
|
328
|
-
|
|
329
|
-
- **`src/app.ts`** : `export default app` + guard `NODE_ENV !== 'test'` pour les tests d'intégration
|
|
330
|
-
- **`src/config/versions.ts`** : helper `merge()` pour dédoublonner les endpoints par `name`
|
|
331
|
-
- **`tsconfig.test.json`** : étend le tsconfig principal, inclut `src/` et `tests/`
|
|
332
|
-
- **`tests/tsconfig.json`** : pointe vers `tsconfig.test.json` pour la résolution IDE
|
|
333
|
-
- **`package.json`** : script `npm test` avec `--test-force-exit` (requis car `setTimeout` dans chat/tic-tac-toe)
|
package/src/modules/v4/geo.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
export interface GeoResult {
|
|
2
|
-
distance: { km: number; miles: number; nauticalMiles: number };
|
|
3
|
-
bearing: { degrees: number; cardinal: string };
|
|
4
|
-
from: { lat: number; lon: number };
|
|
5
|
-
to: { lat: number; lon: number };
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const EARTH_RADIUS_KM = 6371;
|
|
9
|
-
const CARDINALS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
|
10
|
-
|
|
11
|
-
function toRad(deg: number): number {
|
|
12
|
-
return (deg * Math.PI) / 180;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function toDeg(rad: number): number {
|
|
16
|
-
return (rad * 180) / Math.PI;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function parseCoord(value: string, name: string, min: number, max: number): number {
|
|
20
|
-
const n = Number(value);
|
|
21
|
-
if (isNaN(n)) throw new Error(`${name} must be a number`);
|
|
22
|
-
if (n < min || n > max) throw new Error(`${name} must be between ${min} and ${max}`);
|
|
23
|
-
return n;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export default function geo(lat1: string, lon1: string, lat2: string, lon2: string): GeoResult {
|
|
27
|
-
const a = parseCoord(lat1, 'lat1', -90, 90);
|
|
28
|
-
const b = parseCoord(lon1, 'lon1', -180, 180);
|
|
29
|
-
const c = parseCoord(lat2, 'lat2', -90, 90);
|
|
30
|
-
const d = parseCoord(lon2, 'lon2', -180, 180);
|
|
31
|
-
|
|
32
|
-
const dLat = toRad(c - a);
|
|
33
|
-
const dLon = toRad(d - b);
|
|
34
|
-
const sa = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a)) * Math.cos(toRad(c)) * Math.sin(dLon / 2) ** 2;
|
|
35
|
-
const distance = 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(sa));
|
|
36
|
-
|
|
37
|
-
const y = Math.sin(toRad(d - b)) * Math.cos(toRad(c));
|
|
38
|
-
const x =
|
|
39
|
-
Math.cos(toRad(a)) * Math.sin(toRad(c)) - Math.sin(toRad(a)) * Math.cos(toRad(c)) * Math.cos(toRad(d - b));
|
|
40
|
-
const bearing = (toDeg(Math.atan2(y, x)) + 360) % 360;
|
|
41
|
-
const cardinal = CARDINALS[Math.round(bearing / 22.5) % 16]!;
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
distance: {
|
|
45
|
-
km: +distance.toFixed(3),
|
|
46
|
-
miles: +(distance * 0.621371).toFixed(3),
|
|
47
|
-
nauticalMiles: +(distance * 0.539957).toFixed(3),
|
|
48
|
-
},
|
|
49
|
-
bearing: { degrees: +bearing.toFixed(2), cardinal },
|
|
50
|
-
from: { lat: a, lon: b },
|
|
51
|
-
to: { lat: c, lon: d },
|
|
52
|
-
};
|
|
53
|
-
}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
export interface PaletteColor {
|
|
2
|
-
hex: string;
|
|
3
|
-
rgb: string;
|
|
4
|
-
hsl: string;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export interface PaletteResult {
|
|
8
|
-
base: PaletteColor;
|
|
9
|
-
type: string;
|
|
10
|
-
colors: PaletteColor[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function hexToRgb(hex: string): [number, number, number] {
|
|
14
|
-
const clean = hex.replace('#', '');
|
|
15
|
-
if (!/^[0-9a-fA-F]{6}$/.test(clean)) throw new Error('Invalid HEX color (use #RRGGBB)');
|
|
16
|
-
return [parseInt(clean.slice(0, 2), 16), parseInt(clean.slice(2, 4), 16), parseInt(clean.slice(4, 6), 16)];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
|
20
|
-
const r1 = r / 255,
|
|
21
|
-
g1 = g / 255,
|
|
22
|
-
b1 = b / 255;
|
|
23
|
-
const max = Math.max(r1, g1, b1),
|
|
24
|
-
min = Math.min(r1, g1, b1);
|
|
25
|
-
const l = (max + min) / 2;
|
|
26
|
-
|
|
27
|
-
if (max === min) return [0, 0, l];
|
|
28
|
-
|
|
29
|
-
const d = max - min;
|
|
30
|
-
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
31
|
-
let h = 0;
|
|
32
|
-
if (max === r1) h = (g1 - b1) / d + (g1 < b1 ? 6 : 0);
|
|
33
|
-
else if (max === g1) h = (b1 - r1) / d + 2;
|
|
34
|
-
else h = (r1 - g1) / d + 4;
|
|
35
|
-
|
|
36
|
-
return [(h * 60 + 360) % 360, s, l];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
40
|
-
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
41
|
-
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
42
|
-
const m = l - c / 2;
|
|
43
|
-
let r1 = 0,
|
|
44
|
-
g1 = 0,
|
|
45
|
-
b1 = 0;
|
|
46
|
-
if (h < 60) [r1, g1, b1] = [c, x, 0];
|
|
47
|
-
else if (h < 120) [r1, g1, b1] = [x, c, 0];
|
|
48
|
-
else if (h < 180) [r1, g1, b1] = [0, c, x];
|
|
49
|
-
else if (h < 240) [r1, g1, b1] = [0, x, c];
|
|
50
|
-
else if (h < 300) [r1, g1, b1] = [x, 0, c];
|
|
51
|
-
else [r1, g1, b1] = [c, 0, x];
|
|
52
|
-
return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function format(r: number, g: number, b: number): PaletteColor {
|
|
56
|
-
const [h, s, l] = rgbToHsl(r, g, b);
|
|
57
|
-
return {
|
|
58
|
-
hex: `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`,
|
|
59
|
-
rgb: `rgb(${r}, ${g}, ${b})`,
|
|
60
|
-
hsl: `hsl(${h.toFixed(1)}, ${(s * 100).toFixed(1)}%, ${(l * 100).toFixed(1)}%)`,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function fromHueShifts(base: [number, number, number], shifts: number[]): PaletteColor[] {
|
|
65
|
-
const [h, s, l] = base;
|
|
66
|
-
return shifts.map((shift) => {
|
|
67
|
-
const newH = (h + shift + 360) % 360;
|
|
68
|
-
const [r, g, b] = hslToRgb(newH, s, l);
|
|
69
|
-
return format(r, g, b);
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export default function palette(color: string, type: string): PaletteResult {
|
|
74
|
-
if (!color) throw new Error('A base color is required');
|
|
75
|
-
if (!type) throw new Error('A palette type is required');
|
|
76
|
-
|
|
77
|
-
const [r, g, b] = hexToRgb(color);
|
|
78
|
-
const base = format(r, g, b);
|
|
79
|
-
const hsl = rgbToHsl(r, g, b);
|
|
80
|
-
|
|
81
|
-
let colors: PaletteColor[];
|
|
82
|
-
switch (type) {
|
|
83
|
-
case 'complementary':
|
|
84
|
-
colors = fromHueShifts(hsl, [0, 180]);
|
|
85
|
-
break;
|
|
86
|
-
case 'triadic':
|
|
87
|
-
colors = fromHueShifts(hsl, [0, 120, 240]);
|
|
88
|
-
break;
|
|
89
|
-
case 'analogous':
|
|
90
|
-
colors = fromHueShifts(hsl, [-60, -30, 0, 30, 60]);
|
|
91
|
-
break;
|
|
92
|
-
case 'tetradic':
|
|
93
|
-
colors = fromHueShifts(hsl, [0, 90, 180, 270]);
|
|
94
|
-
break;
|
|
95
|
-
case 'split-complementary':
|
|
96
|
-
colors = fromHueShifts(hsl, [0, 150, 210]);
|
|
97
|
-
break;
|
|
98
|
-
default:
|
|
99
|
-
throw new Error('Type must be one of: complementary, triadic, analogous, tetradic, split-complementary');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return { base, type, colors };
|
|
103
|
-
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { createCanvas } from 'canvas';
|
|
2
|
-
|
|
3
|
-
export interface PlaceholderOptions {
|
|
4
|
-
width: number;
|
|
5
|
-
height: number;
|
|
6
|
-
bg?: string;
|
|
7
|
-
color?: string;
|
|
8
|
-
text?: string;
|
|
9
|
-
rows?: number;
|
|
10
|
-
avatar?: boolean;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function parseSize(value: string | undefined, name: string, def: number): number {
|
|
14
|
-
if (value === undefined) return def;
|
|
15
|
-
const n = Number(value);
|
|
16
|
-
if (isNaN(n)) throw new Error(`${name} must be a number`);
|
|
17
|
-
if (n < 1 || n > 4000) throw new Error(`${name} must be between 1 and 4000`);
|
|
18
|
-
return Math.floor(n);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function normalizeColor(value: string | undefined, def: string): string {
|
|
22
|
-
if (!value) return def;
|
|
23
|
-
const clean = value.startsWith('#') ? value : `#${value}`;
|
|
24
|
-
if (!/^#[0-9a-fA-F]{3,6}$/.test(clean)) throw new Error('Invalid color (use hex like #ff6600)');
|
|
25
|
-
return clean;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function generateImage(opts: PlaceholderOptions): Buffer {
|
|
29
|
-
const { width, height } = opts;
|
|
30
|
-
const bg = normalizeColor(opts.bg, '#cccccc');
|
|
31
|
-
const color = normalizeColor(opts.color, '#333333');
|
|
32
|
-
const text = opts.text ?? `${width}×${height}`;
|
|
33
|
-
|
|
34
|
-
const canvas = createCanvas(width, height);
|
|
35
|
-
const ctx = canvas.getContext('2d');
|
|
36
|
-
|
|
37
|
-
ctx.fillStyle = bg;
|
|
38
|
-
ctx.fillRect(0, 0, width, height);
|
|
39
|
-
|
|
40
|
-
const fontSize = Math.max(12, Math.min(width, height) / 8);
|
|
41
|
-
ctx.font = `bold ${fontSize}px sans-serif`;
|
|
42
|
-
ctx.fillStyle = color;
|
|
43
|
-
ctx.textAlign = 'center';
|
|
44
|
-
ctx.textBaseline = 'middle';
|
|
45
|
-
ctx.fillText(text, width / 2, height / 2);
|
|
46
|
-
|
|
47
|
-
return canvas.toBuffer('image/png');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function generateSkeleton(opts: PlaceholderOptions): string {
|
|
51
|
-
const { width, height } = opts;
|
|
52
|
-
const bg = normalizeColor(opts.bg, '#e2e8f0');
|
|
53
|
-
const shimmer = normalizeColor(opts.color, '#f1f5f9');
|
|
54
|
-
const rows = Math.max(1, Math.min(20, opts.rows ?? 3));
|
|
55
|
-
const avatar = !!opts.avatar;
|
|
56
|
-
|
|
57
|
-
const padding = Math.min(width, height) * 0.05;
|
|
58
|
-
const avatarSize = avatar ? Math.min(width, height) * 0.2 : 0;
|
|
59
|
-
const lineHeight = (height - padding * 2 - avatarSize - (avatar ? padding : 0)) / rows;
|
|
60
|
-
const lineThickness = Math.max(8, lineHeight * 0.5);
|
|
61
|
-
const radius = lineThickness / 2;
|
|
62
|
-
|
|
63
|
-
const lines: string[] = [];
|
|
64
|
-
const startY = padding + (avatar ? avatarSize + padding : 0);
|
|
65
|
-
for (let i = 0; i < rows; i++) {
|
|
66
|
-
const y = startY + i * lineHeight + (lineHeight - lineThickness) / 2;
|
|
67
|
-
const lineWidth = (width - padding * 2) * (i === rows - 1 ? 0.6 : 0.95);
|
|
68
|
-
lines.push(
|
|
69
|
-
`<rect x="${padding}" y="${y}" width="${lineWidth}" height="${lineThickness}" rx="${radius}" fill="url(#shimmer)" />`,
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const avatarShape = avatar
|
|
74
|
-
? `<circle cx="${padding + avatarSize / 2}" cy="${padding + avatarSize / 2}" r="${avatarSize / 2}" fill="url(#shimmer)" />`
|
|
75
|
-
: '';
|
|
76
|
-
|
|
77
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
78
|
-
<defs>
|
|
79
|
-
<linearGradient id="shimmer" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
80
|
-
<stop offset="0%" stop-color="${bg}">
|
|
81
|
-
<animate attributeName="offset" values="-2; 1" dur="1.5s" repeatCount="indefinite" />
|
|
82
|
-
</stop>
|
|
83
|
-
<stop offset="50%" stop-color="${shimmer}">
|
|
84
|
-
<animate attributeName="offset" values="-1.5; 1.5" dur="1.5s" repeatCount="indefinite" />
|
|
85
|
-
</stop>
|
|
86
|
-
<stop offset="100%" stop-color="${bg}">
|
|
87
|
-
<animate attributeName="offset" values="-1; 2" dur="1.5s" repeatCount="indefinite" />
|
|
88
|
-
</stop>
|
|
89
|
-
</linearGradient>
|
|
90
|
-
</defs>
|
|
91
|
-
<rect width="${width}" height="${height}" fill="${bg}" />
|
|
92
|
-
${avatarShape}
|
|
93
|
-
${lines.join('\n ')}
|
|
94
|
-
</svg>`;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface PlaceholderResult {
|
|
98
|
-
type: string;
|
|
99
|
-
contentType: string;
|
|
100
|
-
body: Buffer | string;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export default function placeholder(type: string, query: Record<string, string | undefined>): PlaceholderResult {
|
|
104
|
-
const opts: PlaceholderOptions = {
|
|
105
|
-
width: parseSize(query.width, 'width', 800),
|
|
106
|
-
height: parseSize(query.height, 'height', 600),
|
|
107
|
-
bg: query.bg,
|
|
108
|
-
color: query.color,
|
|
109
|
-
text: query.text,
|
|
110
|
-
rows: query.rows ? parseInt(query.rows, 10) : undefined,
|
|
111
|
-
avatar: query.avatar === 'true' || query.avatar === '1',
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
switch (type) {
|
|
115
|
-
case 'image':
|
|
116
|
-
return { type, contentType: 'image/png', body: generateImage(opts) };
|
|
117
|
-
case 'skeleton':
|
|
118
|
-
return { type, contentType: 'image/svg+xml', body: generateSkeleton(opts) };
|
|
119
|
-
default:
|
|
120
|
-
throw new Error('Type must be one of: image, skeleton');
|
|
121
|
-
}
|
|
122
|
-
}
|
package/src/routes/delete.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { Router, type Request, type Response } from 'express';
|
|
2
|
-
import { chatStorage, ticTacToeStorage } from '../storage/index.js';
|
|
3
|
-
import { error } from '../utils/response.js';
|
|
4
|
-
import { supportsRest } from '../utils/version.js';
|
|
5
|
-
|
|
6
|
-
const router = Router();
|
|
7
|
-
|
|
8
|
-
// Forfeit a tic-tac-toe game (REST: DELETE /tic-tac-toe/:game)
|
|
9
|
-
router.delete('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
|
|
10
|
-
if (!supportsRest(req)) {
|
|
11
|
-
error(res, 405, 'DELETE is only supported in v4+.', `${req.version}/tic-tac-toe`);
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const game = req.params.game as string;
|
|
16
|
-
const { username, session } = (req.body as Record<string, string>) || {};
|
|
17
|
-
|
|
18
|
-
if (!username) {
|
|
19
|
-
error(res, 400, 'Please provide a username (?username={username})');
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
if (!session) {
|
|
23
|
-
error(res, 400, 'Please provide a valid session ID (&session={ID})');
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
const result = req.module.tic_tac_toe('forfeit', {
|
|
29
|
-
username,
|
|
30
|
-
session,
|
|
31
|
-
game,
|
|
32
|
-
storage: ticTacToeStorage,
|
|
33
|
-
});
|
|
34
|
-
res.jsonResponse(result);
|
|
35
|
-
} catch (err) {
|
|
36
|
-
error(res, 400, (err as Error).message);
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// Clear a private chat (REST: DELETE /chat/:token)
|
|
41
|
-
router.delete('/:version/chat/:token', (req: Request, res: Response) => {
|
|
42
|
-
if (!supportsRest(req)) {
|
|
43
|
-
error(res, 405, 'DELETE is only supported in v4+.', `${req.version}/chat`);
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const token = req.params.token as string;
|
|
48
|
-
const { username, session } = (req.body as Record<string, string>) || {};
|
|
49
|
-
|
|
50
|
-
if (!username) {
|
|
51
|
-
error(res, 400, 'Please provide a username (?username={username})');
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
if (!session) {
|
|
55
|
-
error(res, 400, 'Please provide a valid session ID (&session={ID})');
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const result = req.module.chat('clear', {
|
|
61
|
-
username,
|
|
62
|
-
token,
|
|
63
|
-
session,
|
|
64
|
-
storage: chatStorage,
|
|
65
|
-
});
|
|
66
|
-
res.jsonResponse(result);
|
|
67
|
-
} catch (err) {
|
|
68
|
-
error(res, 400, (err as Error).message);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
export default router;
|
package/src/routes/patch.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { Router, type Request, type Response } from 'express';
|
|
2
|
-
import { ticTacToeStorage } from '../storage/index.js';
|
|
3
|
-
import { error } from '../utils/response.js';
|
|
4
|
-
import { supportsRest } from '../utils/version.js';
|
|
5
|
-
|
|
6
|
-
const router = Router();
|
|
7
|
-
|
|
8
|
-
// Play a tic-tac-toe move (REST: PATCH /tic-tac-toe/:game)
|
|
9
|
-
router.patch('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
|
|
10
|
-
if (!supportsRest(req)) {
|
|
11
|
-
error(res, 405, 'PATCH is only supported in v4+.', `${req.version}/tic-tac-toe`);
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const game = req.params.game as string;
|
|
16
|
-
const { username, move, session } = (req.body as Record<string, string>) || {};
|
|
17
|
-
|
|
18
|
-
if (!username) {
|
|
19
|
-
error(res, 400, 'Please provide a username (?username={username})');
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
if (!move) {
|
|
23
|
-
error(res, 400, 'Please provide a valid move (&move={move})');
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
if (!session) {
|
|
27
|
-
error(res, 400, 'Please provide a valid session ID (&session={ID})');
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const result = req.module.tic_tac_toe('play', {
|
|
33
|
-
username,
|
|
34
|
-
move,
|
|
35
|
-
session,
|
|
36
|
-
game,
|
|
37
|
-
storage: ticTacToeStorage,
|
|
38
|
-
});
|
|
39
|
-
res.jsonResponse(result);
|
|
40
|
-
} catch (err) {
|
|
41
|
-
error(res, 400, (err as Error).message);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
export default router;
|
package/src/utils/version.ts
DELETED
package/tests/unit/geo.test.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { test, describe } from 'node:test';
|
|
2
|
-
import { strict as assert } from 'node:assert';
|
|
3
|
-
import geo from '../../src/modules/v4/geo.js';
|
|
4
|
-
|
|
5
|
-
describe('geo', () => {
|
|
6
|
-
test('Paris to NYC distance ~5837 km', () => {
|
|
7
|
-
const result = geo('48.8566', '2.3522', '40.7128', '-74.006');
|
|
8
|
-
assert.ok(Math.abs(result.distance.km - 5837) < 50, `expected ~5837, got ${result.distance.km}`);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test('same point returns 0 km', () => {
|
|
12
|
-
const result = geo('48.8566', '2.3522', '48.8566', '2.3522');
|
|
13
|
-
assert.equal(result.distance.km, 0);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test('miles and nautical miles match km conversion', () => {
|
|
17
|
-
const result = geo('0', '0', '0', '90');
|
|
18
|
-
assert.ok(Math.abs(result.distance.miles - result.distance.km * 0.621371) < 0.01);
|
|
19
|
-
assert.ok(Math.abs(result.distance.nauticalMiles - result.distance.km * 0.539957) < 0.01);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test('bearing N for due north', () => {
|
|
23
|
-
const result = geo('0', '0', '10', '0');
|
|
24
|
-
assert.equal(result.bearing.degrees, 0);
|
|
25
|
-
assert.equal(result.bearing.cardinal, 'N');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('bearing E for due east', () => {
|
|
29
|
-
const result = geo('0', '0', '0', '10');
|
|
30
|
-
assert.equal(result.bearing.degrees, 90);
|
|
31
|
-
assert.equal(result.bearing.cardinal, 'E');
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('throws on invalid latitude', () => {
|
|
35
|
-
assert.throws(() => geo('100', '0', '0', '0'), /lat1 must be between/);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test('throws on invalid longitude', () => {
|
|
39
|
-
assert.throws(() => geo('0', '200', '0', '0'), /lon1 must be between/);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('throws on non-numeric coords', () => {
|
|
43
|
-
assert.throws(() => geo('abc', '0', '0', '0'), /must be a number/);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { test, describe } from 'node:test';
|
|
2
|
-
import { strict as assert } from 'node:assert';
|
|
3
|
-
import palette from '../../src/modules/v4/palette.js';
|
|
4
|
-
|
|
5
|
-
describe('palette', () => {
|
|
6
|
-
test('complementary returns 2 colors', () => {
|
|
7
|
-
const result = palette('#ff6600', 'complementary');
|
|
8
|
-
assert.equal(result.colors.length, 2);
|
|
9
|
-
assert.equal(result.type, 'complementary');
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test('triadic returns 3 colors', () => {
|
|
13
|
-
const result = palette('#ff6600', 'triadic');
|
|
14
|
-
assert.equal(result.colors.length, 3);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test('analogous returns 5 colors', () => {
|
|
18
|
-
const result = palette('#ff6600', 'analogous');
|
|
19
|
-
assert.equal(result.colors.length, 5);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test('tetradic returns 4 colors', () => {
|
|
23
|
-
const result = palette('#0066ff', 'tetradic');
|
|
24
|
-
assert.equal(result.colors.length, 4);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('split-complementary returns 3 colors', () => {
|
|
28
|
-
const result = palette('#0066ff', 'split-complementary');
|
|
29
|
-
assert.equal(result.colors.length, 3);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test('each color has hex/rgb/hsl', () => {
|
|
33
|
-
const result = palette('#ff6600', 'triadic');
|
|
34
|
-
for (const c of result.colors) {
|
|
35
|
-
assert.match(c.hex, /^#[0-9a-f]{6}$/);
|
|
36
|
-
assert.match(c.rgb, /^rgb\(/);
|
|
37
|
-
assert.match(c.hsl, /^hsl\(/);
|
|
38
|
-
}
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test('base color is included', () => {
|
|
42
|
-
const result = palette('#ff6600', 'complementary');
|
|
43
|
-
assert.equal(result.base.hex, '#ff6600');
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test('throws on invalid hex', () => {
|
|
47
|
-
assert.throws(() => palette('#zzz', 'triadic'), /Invalid HEX/);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('throws on unknown type', () => {
|
|
51
|
-
assert.throws(() => palette('#ff6600', 'rainbow'), /must be one of/);
|
|
52
|
-
});
|
|
53
|
-
});
|