@appius-fr/apx 2.5.0 → 2.6.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.
@@ -0,0 +1,549 @@
1
+ /**
2
+ * Convertit un formulaire HTML en objet JSON
3
+ * @param {HTMLFormElement} form - Le formulaire à convertir
4
+ * @returns {Object} L'objet JSON résultant
5
+ * @throws {TypeError} Si form n'est pas un HTMLFormElement
6
+ */
7
+ export const packFormToJSON = (form) => {
8
+ // Validation de l'entrée
9
+ if (!form || !(form instanceof HTMLFormElement)) {
10
+ throw new TypeError('packFormToJSON expects an HTMLFormElement');
11
+ }
12
+
13
+ const formData = new FormData(form);
14
+ const jsonData = {};
15
+
16
+ /**
17
+ * Construit une représentation string du chemin pour les messages d'erreur
18
+ * @param {Array} parts - Les parties du chemin
19
+ * @returns {string} Le chemin formaté
20
+ */
21
+ const buildPathString = (parts) => {
22
+ return parts.reduce((path, part) => {
23
+ if (part.type === 'key') {
24
+ return path ? `${path}[${part.name}]` : part.name;
25
+ }
26
+ if (part.type === 'numeric') {
27
+ return `${path}[${part.index}]`;
28
+ }
29
+ if (part.type === 'array') {
30
+ return `${path}[]`;
31
+ }
32
+ return path;
33
+ }, '');
34
+ };
35
+
36
+ // ============================================================================
37
+ // PHASE 1 : ANALYSE ET DÉTECTION DES CONFLITS
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Structure pour stocker l'utilisation d'un chemin
42
+ * @typedef {Object} PathUsage
43
+ * @property {boolean} isFinal - Le chemin est utilisé comme valeur finale
44
+ * @property {boolean} isIntermediate - Le chemin est utilisé comme chemin intermédiaire
45
+ * @property {boolean} hasArraySuffix - Le chemin se termine par [] (crée des valeurs primitives)
46
+ * @property {string} key - La clé du formulaire qui utilise ce chemin
47
+ */
48
+
49
+ const pathUsage = new Map(); // Map<pathString, PathUsage>
50
+ const keyAnalysis = new Map(); // Map<basePath, {hasNumeric: boolean, hasString: boolean}>
51
+ const allEntries = [];
52
+
53
+ /**
54
+ * Enregistre l'utilisation d'un chemin dans pathUsage
55
+ * @param {string} currentPath - Le chemin à enregistrer
56
+ * @param {boolean} isFinal - Si c'est une valeur finale
57
+ * @param {boolean} isArraySuffix - Si le chemin se termine par []
58
+ * @param {string} key - La clé du formulaire
59
+ */
60
+ const recordPathUsage = (currentPath, isFinal, isArraySuffix, key) => {
61
+ const usage = pathUsage.get(currentPath) ?? {
62
+ isFinal: false,
63
+ isIntermediate: false,
64
+ hasArraySuffix: false,
65
+ key
66
+ };
67
+
68
+ if (!pathUsage.has(currentPath)) {
69
+ pathUsage.set(currentPath, usage);
70
+ }
71
+
72
+ if (isFinal) {
73
+ usage.isFinal = true;
74
+ usage.key = key;
75
+ if (isArraySuffix) {
76
+ usage.hasArraySuffix = true;
77
+ }
78
+ } else {
79
+ usage.isIntermediate = true;
80
+ usage.key ??= key;
81
+ }
82
+ };
83
+
84
+ // Première passe : analyser toutes les clés et enregistrer les chemins
85
+ for (const [key, value] of formData.entries()) {
86
+ if (!key) continue;
87
+
88
+ allEntries.push({ key, value });
89
+ const parts = parseKey(key);
90
+
91
+ // Construire tous les chemins intermédiaires et le chemin final
92
+ let currentPath = '';
93
+
94
+ for (let i = 0; i < parts.length; i++) {
95
+ const part = parts[i];
96
+ const isLast = i === parts.length - 1;
97
+ const isArraySuffix = part.type === 'array';
98
+
99
+ // Construire le chemin jusqu'à ce niveau
100
+ if (part.type === 'key') {
101
+ currentPath = currentPath ? `${currentPath}[${part.name}]` : part.name;
102
+ } else if (part.type === 'numeric') {
103
+ currentPath = `${currentPath}[${part.index}]`;
104
+ } else if (part.type === 'array') {
105
+ currentPath = `${currentPath}[]`;
106
+ }
107
+
108
+ // Enregistrer l'utilisation du chemin
109
+ recordPathUsage(currentPath, isLast, isArraySuffix && isLast, key);
110
+ }
111
+
112
+ // Analyser chaque niveau de la hiérarchie pour détecter les conflits (indices numériques vs clés de chaîne)
113
+ for (let i = 0; i < parts.length - 1; i++) {
114
+ const part = parts[i];
115
+ const nextPart = parts[i + 1];
116
+
117
+ // Analyser seulement les clés (pas les indices numériques)
118
+ if (part.type === 'key') {
119
+ const basePath = part.name; // Le nom de la première clé dans le chemin
120
+
121
+ const analysis = keyAnalysis.get(basePath) ?? { hasNumeric: false, hasString: false };
122
+ if (!keyAnalysis.has(basePath)) {
123
+ keyAnalysis.set(basePath, analysis);
124
+ }
125
+
126
+ if (nextPart) {
127
+ if (nextPart.type === 'numeric') {
128
+ analysis.hasNumeric = true;
129
+ } else if (nextPart.type === 'key' || nextPart.type === 'array') {
130
+ analysis.hasString = true;
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Détecte et lève une exception pour tous les conflits de chemins
139
+ *
140
+ * Principe simple : pour chaque chemin final (valeur primitive), vérifier si un chemin
141
+ * intermédiaire commence par ce chemin suivi de '['. Si c'est le cas, c'est un conflit.
142
+ *
143
+ * Cette approche unifiée couvre tous les cas :
144
+ * - items[] (final) vs items[0][name] (intermédiaire)
145
+ * - array[3] (final) vs array[3][nested] (intermédiaire)
146
+ * - data[key] (final) vs data[key][sub] (intermédiaire)
147
+ */
148
+ const detectPathConflicts = () => {
149
+ // 1. Collecter tous les chemins finaux (valeurs primitives)
150
+ const finalPaths = new Set();
151
+ const finalPathKeys = new Map(); // Pour les messages d'erreur
152
+
153
+ for (const [path, usage] of pathUsage.entries()) {
154
+ if (usage.isFinal) {
155
+ finalPaths.add(path);
156
+ finalPathKeys.set(path, usage.key);
157
+ }
158
+ }
159
+
160
+ // 2. Pour chaque chemin intermédiaire, vérifier s'il commence par un chemin final
161
+ for (const [path, usage] of pathUsage.entries()) {
162
+ if (usage.isIntermediate) {
163
+ for (const finalPath of finalPaths) {
164
+ // Vérifier si ce chemin intermédiaire commence par un chemin final suivi de '['
165
+ // Exemple: finalPath = "array[3]", path = "array[3][nested]" → conflit
166
+ if (path.startsWith(`${finalPath}[`)) {
167
+ throw new Error(
168
+ `Path conflict: "${finalPath}" is used as a final value (in field "${finalPathKeys.get(finalPath)}"), ` +
169
+ `but "${path}" tries to use it as an intermediate path (in field "${usage.key}"). ` +
170
+ `This creates incompatible data structures. ` +
171
+ `You cannot use "${finalPath}" as a primitive value and then access it as an object.`
172
+ );
173
+ }
174
+ }
175
+ }
176
+ }
177
+ };
178
+
179
+ // Exécuter la détection des conflits
180
+ detectPathConflicts();
181
+
182
+ /**
183
+ * Vérifie si un chemin de base a un conflit (indices numériques ET clés de chaîne)
184
+ * @param {string} basePath - Le chemin de base à vérifier
185
+ * @returns {boolean}
186
+ */
187
+ const hasConflict = (basePath) => {
188
+ if (!basePath) return false;
189
+ const analysis = keyAnalysis.get(basePath);
190
+ return analysis?.hasNumeric && analysis?.hasString;
191
+ };
192
+
193
+ /**
194
+ * Convertit un tableau en objet en préservant les indices numériques comme propriétés
195
+ * @param {Array} arr - Le tableau à convertir
196
+ * @returns {Object} L'objet résultant
197
+ */
198
+ const arrayToObject = (arr) => {
199
+ const obj = {};
200
+ // Copier les indices numériques
201
+ for (let idx = 0; idx < arr.length; idx++) {
202
+ obj[idx] = arr[idx];
203
+ }
204
+ // Copier les propriétés non-numériques
205
+ for (const [k, v] of Object.entries(arr)) {
206
+ const numKey = Number.parseInt(k, 10);
207
+ if (Number.isNaN(numKey) || k !== String(numKey)) {
208
+ obj[k] = v;
209
+ }
210
+ }
211
+ return obj;
212
+ };
213
+
214
+ /**
215
+ * Convertit un objet en tableau en préservant les indices numériques
216
+ * @param {Object} obj - L'objet à convertir
217
+ * @returns {Array} Le tableau résultant
218
+ */
219
+ const objectToArray = (obj) => {
220
+ if (Array.isArray(obj)) return obj;
221
+ const arr = [];
222
+ if (typeof obj === 'object' && obj !== null) {
223
+ for (const [k, v] of Object.entries(obj)) {
224
+ const numKey = Number.parseInt(k, 10);
225
+ if (!Number.isNaN(numKey)) {
226
+ arr[numKey] = v;
227
+ }
228
+ }
229
+ }
230
+ return arr;
231
+ };
232
+
233
+ /**
234
+ * Assure qu'un conteneur est un objet, en convertissant si nécessaire
235
+ * @param {*} container - Le conteneur à vérifier
236
+ * @param {string|null} key - La clé dans le conteneur parent
237
+ * @param {Object|null} parent - Le conteneur parent
238
+ * @param {boolean} forceObject - Forcer la conversion en objet même s'il n'y a pas de conflit
239
+ * @returns {Object} Le conteneur (converti en objet si nécessaire)
240
+ */
241
+ const ensureObject = (container, key, parent, forceObject = false) => {
242
+ if (Array.isArray(container)) {
243
+ const obj = arrayToObject(container);
244
+ if (parent && key !== null) {
245
+ parent[key] = obj;
246
+ }
247
+ return obj;
248
+ }
249
+ if (typeof container !== 'object' || container === null) {
250
+ const obj = {};
251
+ if (parent && key !== null) {
252
+ parent[key] = obj;
253
+ }
254
+ return obj;
255
+ }
256
+ return container;
257
+ };
258
+
259
+ /**
260
+ * Assure qu'un conteneur est un tableau, en convertissant si nécessaire
261
+ * @param {*} container - Le conteneur à vérifier
262
+ * @param {string|null} key - La clé dans le conteneur parent
263
+ * @param {Object|null} parent - Le conteneur parent
264
+ * @returns {Array} Le conteneur (converti en tableau si nécessaire)
265
+ */
266
+ const ensureArray = (container, key, parent) => {
267
+ if (!Array.isArray(container)) {
268
+ const arr = objectToArray(container);
269
+ if (parent && key !== null) {
270
+ parent[key] = arr;
271
+ }
272
+ return arr;
273
+ }
274
+ return container;
275
+ };
276
+
277
+ // ============================================================================
278
+ // PHASE 2 : TRAITEMENT DES VALEURS
279
+ // ============================================================================
280
+
281
+ /**
282
+ * Traite une valeur finale (dernière partie du chemin)
283
+ * @param {*} container - Le conteneur actuel
284
+ * @param {Object} part - La partie à traiter
285
+ * @param {*} value - La valeur à assigner
286
+ * @param {Object} parent - Le conteneur parent
287
+ * @param {string} basePath - Le chemin de base pour la détection de conflit
288
+ */
289
+ const processFinalValue = (container, part, value, parent, basePath) => {
290
+ if (part.type === 'array') {
291
+ // Tableau explicite avec []
292
+ container[part.name] ??= [];
293
+ if (Array.isArray(container[part.name])) {
294
+ container[part.name].push(value);
295
+ } else {
296
+ container[part.name] = [container[part.name], value];
297
+ }
298
+ } else if (part.type === 'numeric') {
299
+ // Indice numérique final
300
+ const { index } = part;
301
+ container = ensureArray(container, parent.key, parent.container);
302
+ while (container.length <= index) {
303
+ container.push(undefined);
304
+ }
305
+ container[index] = value;
306
+ } else {
307
+ // Clé simple finale
308
+ const conflict = hasConflict(basePath);
309
+
310
+ if (conflict) {
311
+ container = ensureObject(container, parent.key, parent.container, true);
312
+ if (Array.isArray(container[part.name])) {
313
+ container[part.name] = arrayToObject(container[part.name]);
314
+ }
315
+ if (container[part.name] !== undefined &&
316
+ (typeof container[part.name] !== 'object' || container[part.name] === null)) {
317
+ container[part.name] = value;
318
+ } else if (container[part.name] === undefined) {
319
+ container[part.name] = value;
320
+ } else if (Array.isArray(container[part.name])) {
321
+ container[part.name].push(value);
322
+ } else {
323
+ container[part.name] = [container[part.name], value];
324
+ }
325
+ } else {
326
+ if (container[part.name] === undefined) {
327
+ container[part.name] = value;
328
+ } else if (Array.isArray(container[part.name])) {
329
+ container[part.name].push(value);
330
+ } else {
331
+ container[part.name] = [container[part.name], value];
332
+ }
333
+ }
334
+ }
335
+ };
336
+
337
+ /**
338
+ * Traite une partie intermédiaire (crée la structure)
339
+ * @param {*} container - Le conteneur actuel
340
+ * @param {Object} part - La partie à traiter
341
+ * @param {Object|null} nextPart - La partie suivante (peut être null)
342
+ * @param {Object} parent - Le conteneur parent
343
+ * @param {string} basePath - Le chemin de base pour la détection de conflit
344
+ * @param {Array} parts - Toutes les parties du chemin (pour construire le chemin complet dans les erreurs)
345
+ * @param {number} i - L'index de la partie actuelle (pour construire le chemin complet dans les erreurs)
346
+ * @param {string} key - La clé du formulaire (pour les messages d'erreur)
347
+ * @returns {*} Le nouveau conteneur après traitement
348
+ */
349
+ const processIntermediatePart = (container, part, nextPart, parent, basePath, parts, i, key) => {
350
+ if (part.type === 'numeric') {
351
+ // Indice numérique : le container doit être un tableau ou un objet (selon conflit)
352
+ const { index } = part;
353
+ const conflict = hasConflict(basePath);
354
+
355
+ if (conflict) {
356
+ // Conflit : utiliser un objet (les indices seront des propriétés)
357
+ container = ensureObject(container, parent.key, parent.container, true);
358
+ container[index] ??= {};
359
+ if (typeof container[index] !== 'object' || container[index] === null) {
360
+ // Cette erreur ne devrait jamais se produire si la détection fonctionne correctement
361
+ const pathParts = parts.slice(0, i + 1);
362
+ const currentPath = buildPathString(pathParts);
363
+ throw new Error(
364
+ `Cannot access property on primitive value. ` +
365
+ `Key "${key}" tries to access "${currentPath}" but it is already a ${typeof container[index]} value: ${JSON.stringify(container[index])}. ` +
366
+ `This should have been detected during conflict detection phase.`
367
+ );
368
+ }
369
+ } else {
370
+ // Pas de conflit : utiliser un tableau
371
+ container = ensureArray(container, parent.key, parent.container);
372
+ while (container.length <= index) {
373
+ container.push(undefined);
374
+ }
375
+ container[index] ??= {};
376
+ if (typeof container[index] !== 'object' || container[index] === null) {
377
+ // Cette erreur ne devrait jamais se produire si la détection fonctionne correctement
378
+ const pathParts = parts.slice(0, i + 1);
379
+ const currentPath = buildPathString(pathParts);
380
+ throw new Error(
381
+ `Cannot access property on primitive value. ` +
382
+ `Key "${key}" tries to access "${currentPath}" but it is already a ${typeof container[index]} value: ${JSON.stringify(container[index])}. ` +
383
+ `This should have been detected during conflict detection phase.`
384
+ );
385
+ }
386
+ }
387
+ return container[index];
388
+ }
389
+
390
+ // Clé normale : créer objet ou tableau selon la partie suivante
391
+ const nextIsNumeric = nextPart?.type === 'numeric';
392
+ const conflict = hasConflict(basePath);
393
+
394
+ if (conflict) {
395
+ // Conflit : toujours utiliser un objet
396
+ container = ensureObject(container, parent.key, parent.container, true);
397
+ container[part.name] ??= {};
398
+ if (Array.isArray(container[part.name])) {
399
+ container[part.name] = arrayToObject(container[part.name]);
400
+ } else if (typeof container[part.name] !== 'object' || container[part.name] === null) {
401
+ // Cette erreur ne devrait jamais se produire si la détection fonctionne correctement
402
+ const pathParts = parts.slice(0, i + 1);
403
+ const currentPath = buildPathString(pathParts);
404
+ throw new Error(
405
+ `Cannot access property on primitive value. ` +
406
+ `Key "${key}" tries to access "${currentPath}" but it is already a ${typeof container[part.name]} value: ${JSON.stringify(container[part.name])}. ` +
407
+ `This should have been detected during conflict detection phase.`
408
+ );
409
+ }
410
+ } else if (container[part.name] === undefined) {
411
+ // Pas de conflit, créer selon la partie suivante
412
+ container[part.name] = nextIsNumeric ? [] : {};
413
+ } else if (nextIsNumeric && !Array.isArray(container[part.name])) {
414
+ // On a besoin d'un tableau mais c'est un objet
415
+ const hasStringKeys = Object.keys(container[part.name]).some(k => {
416
+ const numKey = Number.parseInt(k, 10);
417
+ return Number.isNaN(numKey) || k !== String(numKey);
418
+ });
419
+ if (!hasStringKeys) {
420
+ container[part.name] = objectToArray(container[part.name]);
421
+ }
422
+ } else if (!nextIsNumeric && !isPlainObject(container[part.name])) {
423
+ // On a besoin d'un objet mais c'est un tableau
424
+ if (Array.isArray(container[part.name])) {
425
+ container[part.name] = arrayToObject(container[part.name]);
426
+ } else {
427
+ container[part.name] = {};
428
+ }
429
+ }
430
+
431
+ // S'assurer que container[part.name] est bien un objet après toutes les conversions
432
+ if (container[part.name] !== undefined &&
433
+ (typeof container[part.name] !== 'object' || container[part.name] === null)) {
434
+ // Cette erreur ne devrait jamais se produire si la détection fonctionne correctement
435
+ const pathParts = parts.slice(0, i + 1);
436
+ const currentPath = buildPathString(pathParts);
437
+ throw new Error(
438
+ `Cannot access property on primitive value. ` +
439
+ `Key "${key}" tries to access "${currentPath}" but it is already a ${typeof container[part.name]} value: ${JSON.stringify(container[part.name])}. ` +
440
+ `This should have been detected during conflict detection phase.`
441
+ );
442
+ }
443
+ return container[part.name];
444
+ };
445
+
446
+ // Traiter toutes les entrées
447
+ for (const { value, key } of allEntries) {
448
+ if (!key) continue;
449
+
450
+ const parts = parseKey(key);
451
+ const path = [{ container: jsonData, key: null }];
452
+ let container = jsonData;
453
+ const basePath = parts[0]?.type === 'key' ? parts[0].name : '';
454
+
455
+ for (let i = 0; i < parts.length; i++) {
456
+ const part = parts[i];
457
+ const isLast = i === parts.length - 1;
458
+ const nextPart = i + 1 < parts.length ? parts[i + 1] : null;
459
+ const parent = path[path.length - 1];
460
+
461
+ if (isLast) {
462
+ // Dernière partie : assigner la valeur
463
+ processFinalValue(container, part, value, parent, basePath);
464
+ } else {
465
+ // Partie intermédiaire : créer la structure
466
+ const newContainer = processIntermediatePart(container, part, nextPart, parent, basePath, parts, i, key);
467
+ path.push({ container, key: part.type === 'numeric' ? part.index : part.name });
468
+ container = newContainer;
469
+ }
470
+ }
471
+ }
472
+
473
+ return jsonData;
474
+ };
475
+
476
+ /**
477
+ * Parse une clé de formulaire pour extraire les parties (nom, indices, etc.)
478
+ * @param {string} key - La clé à parser (ex: "user[email]", "tags[]", "items[0][name]")
479
+ * @returns {Array<{name: string, type: string, index?: number}>} Tableau des parties parsées
480
+ * @private
481
+ */
482
+ const parseKey = (key) => {
483
+ const parts = [];
484
+ let current = '';
485
+ let i = 0;
486
+ const len = key.length;
487
+
488
+ while (i < len) {
489
+ const char = key[i];
490
+
491
+ if (char === '[') {
492
+ // Sauvegarder la partie précédente si elle existe
493
+ if (current) {
494
+ parts.push({ name: current, type: 'key' });
495
+ current = '';
496
+ }
497
+
498
+ // Trouver la fin du crochet
499
+ i++;
500
+ let bracketContent = '';
501
+ while (i < len && key[i] !== ']') {
502
+ bracketContent += key[i];
503
+ i++;
504
+ }
505
+
506
+ if (bracketContent === '') {
507
+ // [] vide = tableau
508
+ const lastPart = parts[parts.length - 1];
509
+ if (lastPart) {
510
+ lastPart.type = 'array';
511
+ } else {
512
+ // [] au début, créer une partie spéciale
513
+ parts.push({ name: '', type: 'array' });
514
+ }
515
+ } else {
516
+ // Contenu dans les crochets
517
+ const numIndex = Number.parseInt(bracketContent, 10);
518
+ if (!Number.isNaN(numIndex) && bracketContent === String(numIndex)) {
519
+ // Indice numérique
520
+ parts.push({ name: String(numIndex), type: 'numeric', index: numIndex });
521
+ } else {
522
+ // Nom de propriété
523
+ parts.push({ name: bracketContent, type: 'key' });
524
+ }
525
+ }
526
+ i++; // Passer le ']'
527
+ } else {
528
+ current += char;
529
+ i++;
530
+ }
531
+ }
532
+
533
+ // Ajouter la dernière partie si elle existe
534
+ if (current) {
535
+ parts.push({ name: current, type: 'key' });
536
+ }
537
+
538
+ return parts;
539
+ };
540
+
541
+ /**
542
+ * Vérifie si une valeur est un objet simple (pas un tableau, pas null)
543
+ * @param {*} obj - La valeur à vérifier
544
+ * @returns {boolean}
545
+ * @private
546
+ */
547
+ const isPlainObject = (obj) => {
548
+ return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
549
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appius-fr/apx",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Appius Extended JS - A powerful JavaScript extension library",
5
5
  "main": "dist/APX.prod.mjs",
6
6
  "module": "dist/APX.prod.mjs",