data-primals-engine 1.7.1 → 1.7.3

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 (99) hide show
  1. package/README.md +9 -9
  2. package/client/package-lock.json +8430 -8121
  3. package/client/package.json +7 -4
  4. package/client/src/APIInfo.jsx +1 -1
  5. package/client/src/App.jsx +2 -1
  6. package/client/src/App.scss +1635 -1626
  7. package/client/src/AssistantChat.jsx +2 -3
  8. package/client/src/CalendarView.jsx +1 -0
  9. package/client/src/ContentView.jsx +3 -3
  10. package/client/src/Dashboard.jsx +5 -2
  11. package/client/src/DashboardChart.jsx +1 -0
  12. package/client/src/DashboardFlexViewItem.jsx +1 -0
  13. package/client/src/DashboardHtmlViewItem.jsx +1 -0
  14. package/client/src/DashboardView.jsx +2 -0
  15. package/client/src/DataEditor.jsx +0 -1
  16. package/client/src/DataImporter.jsx +489 -468
  17. package/client/src/DataLayout.jsx +25 -23
  18. package/client/src/DataTable.jsx +6 -5
  19. package/client/src/Dialog.jsx +92 -90
  20. package/client/src/Dialog.scss +122 -116
  21. package/client/src/DisplayFlexNodeRenderer.jsx +1 -0
  22. package/client/src/DocumentationPageLayout.scss +1 -1
  23. package/client/src/FlexBuilderControls.jsx +1 -0
  24. package/client/src/FlexBuilderPreview.jsx +1 -1
  25. package/client/src/FlexNode.jsx +1 -1
  26. package/client/src/HistoryDialog.jsx +3 -2
  27. package/client/src/KPIWidget.jsx +1 -1
  28. package/client/src/KanbanView.jsx +1 -0
  29. package/client/src/ModelCreator.jsx +4 -0
  30. package/client/src/ModelList.jsx +1 -0
  31. package/client/src/PackGallery.jsx +5 -4
  32. package/client/src/RTETrans.jsx +1 -1
  33. package/client/src/RelationField.jsx +2 -0
  34. package/client/src/RelationSelectorWidget.jsx +2 -0
  35. package/client/src/RelationValue.jsx +1 -0
  36. package/client/src/RestoreDialog.jsx +1 -0
  37. package/client/src/ViewSwitcher.jsx +1 -1
  38. package/client/src/WorkflowEditor.jsx +3 -0
  39. package/client/src/contexts/CommandContext.jsx +2 -1
  40. package/client/src/contexts/ModelContext.jsx +3 -3
  41. package/client/src/hooks/data.js +1 -0
  42. package/client/src/hooks/useValidation.js +1 -0
  43. package/client/src/translations.js +24 -24
  44. package/client/vite.config.js +31 -30
  45. package/doc/AI-assistance.md +87 -63
  46. package/doc/Concepts.md +122 -0
  47. package/doc/Custom-Endpoints.md +31 -0
  48. package/doc/Event-system.md +13 -14
  49. package/doc/Home.md +33 -0
  50. package/doc/Modules.md +83 -0
  51. package/doc/Packs-gallery.md +8 -23
  52. package/doc/Workflows.md +32 -0
  53. package/doc/automation-workflows.md +141 -102
  54. package/doc/dashboards-kpis-charts.md +47 -49
  55. package/doc/data-management.md +126 -120
  56. package/doc/data-models.md +68 -75
  57. package/doc/roles-permissions.md +144 -43
  58. package/doc/sharding-replication.md +158 -0
  59. package/doc/users.md +54 -30
  60. package/package.json +27 -17
  61. package/server.js +37 -37
  62. package/src/ai.jobs.js +135 -0
  63. package/src/constants.js +560 -545
  64. package/src/data.js +521 -518
  65. package/src/email.js +157 -154
  66. package/src/engine.js +167 -49
  67. package/src/gameObject.js +6 -0
  68. package/src/i18n.js +0 -1
  69. package/src/modules/assistant/assistant.js +782 -763
  70. package/src/modules/assistant/constants.js +23 -16
  71. package/src/modules/assistant/providers.js +77 -37
  72. package/src/modules/auth-google/index.js +53 -50
  73. package/src/modules/bucket.js +346 -335
  74. package/src/modules/data/data.backup.js +400 -376
  75. package/src/modules/data/data.cluster.js +559 -0
  76. package/src/modules/data/data.core.js +11 -8
  77. package/src/modules/data/data.js +311 -311
  78. package/src/modules/data/data.operations.js +3666 -3555
  79. package/src/modules/data/data.relations.js +1 -0
  80. package/src/modules/data/data.replication.js +125 -0
  81. package/src/modules/data/data.routes.js +2206 -1879
  82. package/src/modules/data/data.scheduling.js +2 -1
  83. package/src/modules/file.js +248 -247
  84. package/src/modules/mongodb.js +76 -73
  85. package/src/modules/user.js +18 -2
  86. package/src/modules/worker-script-runner.js +97 -0
  87. package/src/modules/workflow.js +177 -52
  88. package/src/packs.js +5701 -5701
  89. package/src/providers.js +298 -297
  90. package/src/sso.js +91 -25
  91. package/test/assistant.test.js +207 -206
  92. package/test/cluster.test.js +221 -0
  93. package/test/core.test.js +0 -2
  94. package/test/data.integration.test.js +1425 -1416
  95. package/test/import_export.integration.test.js +210 -210
  96. package/test/replication.test.js +163 -0
  97. package/test/workflow.actions.integration.test.js +487 -475
  98. package/test/workflow.integration.test.js +332 -329
  99. package/doc/core-concepts.md +0 -33
package/src/data.js CHANGED
@@ -1,519 +1,522 @@
1
- import {getObjectHash} from "./core.js";
2
- import process from "node:process";
3
- import crypto from "node:crypto";
4
- import {mainFieldsTypes} from "./constants.js";
5
-
6
- const IV_LENGTH = 16;
7
- export function encryptValue(text) {
8
- if (!process.env.ENCRYPTION_KEY) throw new Error("ENCRYPTION_KEY is not set");
9
- let iv = crypto.randomBytes(IV_LENGTH);
10
- let cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY), iv);
11
- let encrypted = cipher.update(text);
12
- encrypted = Buffer.concat([encrypted, cipher.final()]);
13
- return iv.toString('hex') + ':' + encrypted.toString('hex');
14
- }
15
-
16
- export function decryptValue(text) {
17
- if (!process.env.ENCRYPTION_KEY) throw new Error("ENCRYPTION_KEY is not set");
18
- if (!text || typeof text !== 'string' || !text.includes(':')) return text; // ou throw error
19
- let textParts = text.split(':');
20
- let iv = Buffer.from(textParts.shift(), 'hex');
21
- let encryptedText = Buffer.from(textParts.join(':'), 'hex');
22
- let decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY), iv);
23
- let decrypted = decipher.update(encryptedText);
24
- decrypted = Buffer.concat([decrypted, decipher.final()]);
25
- return decrypted.toString();
26
- }
27
-
28
- export const isLocalUser = (user) => {
29
- return user && user._model === 'user' && typeof(user._user) === 'string' && user._user.trim() !== '';
30
- };
31
-
32
- export const isDemoUser = (user) => {
33
- return user?.temporary;
34
- }
35
-
36
- export function getUserHash(user) {
37
- if( isDemoUser(user) ){
38
- return user.username;
39
- }
40
- return user ? (
41
- isLocalUser(user) ? getObjectHash({id: 'LOCAL_USER'+user._user+user.username}) : getObjectHash({id: user.hash})
42
- ) : 0;
43
- }
44
- export function getUserId(user) {
45
- return user ? (
46
- isLocalUser(user) ? ('LOCAL_USER'+user.username)?.hashCode() : user.username
47
- ) : 0;
48
- }
49
-
50
- export const getUserName = (user) => {
51
- return isLocalUser(user) ? user._id + '_'+user._user : user.username;
52
- }
53
-
54
- /**
55
- * Crée une fonction de génération de nombres pseudo-aléatoires basée sur une graine (seed).
56
- * Utilise un simple générateur congruentiel linéaire (LCG).
57
- * @param {number} seed - La graine initiale.
58
- * @returns {function(): number} Une fonction qui retourne un nombre entre 0 et 1.
59
- */
60
- function createSeededRandom(seed) {
61
- let state = seed;
62
- return function() {
63
- // Algorithme LCG simple. Pas pour la cryptographie, mais suffisant pour du "hasard" déterministe.
64
- state = (state * 9301 + 49297) % 233280;
65
- return state / 233280.0;
66
- };
67
- }
68
-
69
- /**
70
- * Anonymise une chaîne de caractères en remplaçant chaque caractère
71
- * par un caractère aléatoire d'un jeu défini.
72
- * Cette méthode est déterministe si une graine (seed) est fournie.
73
- *
74
- * @param {string} text Le texte à anonymiser.
75
- * @param {boolean} [preserveSpaces=false] Si true, les espaces ne seront pas remplacés.
76
- * @param {string|number|null} [seed=null] Une graine pour rendre l'anonymisation déterministe. Peut être le _hash de la donnée.
77
- * @returns {string} Le texte anonymisé, ou une chaîne vide si l'entrée est invalide.
78
- */
79
- export function anonymizeText(text, preserveSpaces = false, seed = null) {
80
- if (typeof text !== 'string' || text.length === 0) {
81
- return "";
82
- }
83
-
84
- text = encryptValue(text);
85
-
86
- // Jeu de caractères pour le remplacement (vous pouvez l'adapter)
87
- const charSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789*#@!?";
88
- let anonymizedResult = "";
89
-
90
- let random;
91
- if (seed !== null) {
92
- // Convertir la graine (qui peut être une chaîne comme _hash) en un nombre simple.
93
- let numericSeed = 0;
94
- if (typeof seed === 'string') {
95
- for (let i = 0; i < seed.length; i++) {
96
- // Simple hash to number. Using a bitwise operation to keep it a 32-bit integer.
97
- numericSeed = (((numericSeed << 5) - numericSeed) + seed.charCodeAt(i)) | 0;
98
- }
99
- } else if (typeof seed === 'number') {
100
- numericSeed = seed;
101
- } else {
102
- // Fallback pour d'autres types, bien que non attendu
103
- numericSeed = new Date().getTime();
104
- }
105
- random = createSeededRandom(numericSeed);
106
- } else {
107
- // Si aucune graine n'est fournie, utiliser le générateur aléatoire standard.
108
- random = Math.random;
109
- }
110
-
111
- for (let i = 0; i < text.length; i++) {
112
- const originalChar = text[i];
113
-
114
- // Conserver les espaces si demandé
115
- if (preserveSpaces && (originalChar === ' ' || originalChar === '\t' || originalChar === '\n' || originalChar === '\r')) {
116
- anonymizedResult += originalChar;
117
- } else {
118
- // Remplacer par un caractère aléatoire du jeu en utilisant le générateur choisi
119
- const randomIndex = Math.floor(random() * charSet.length);
120
- anonymizedResult += charSet[randomIndex];
121
- }
122
- }
123
-
124
- return anonymizedResult;
125
- }
126
-
127
-
128
- /**
129
- * Get the value from an object based on a dot-separated path.
130
- * @param {object} obj - The object to traverse.
131
- * @param {string} path - The dot-separated path (e.g., "user.profile.name").
132
- * @returns {*} The value at the given path, or undefined if not found.
133
- */
134
- export const getFieldPathValue = (obj, path) => {
135
- if (!obj || !path) return undefined;
136
- const properties = path.split('.');
137
- return properties.reduce((prev, curr) => (prev && prev[curr] !== undefined) ? prev[curr] : undefined, obj);
138
- };
139
-
140
- /**
141
- * Apply a filter condition to an array of data objects.
142
- * Filter format is a simple object mapping field paths to values or filter operators.
143
- * Supported operators: $eq, $ne, $in (array), $exists (boolean).
144
- * @param {array} data - The array of data objects.
145
- * @param {object|null} filter - The filter object.
146
- * @returns {array} The filtered array.
147
- */
148
- export const applyFilter = (data, filter) => {
149
- if (!filter || Object.keys(filter).length === 0 || !Array.isArray(data)) return data;
150
- return data.filter(item => {
151
- for (const key in filter) {
152
- // Ensure the key exists in the filter and is not from prototype chain
153
- if (Object.prototype.hasOwnProperty.call(filter, key)) {
154
- const filterValue = filter[key];
155
- const itemValue = getFieldPathValue(item, key);
156
-
157
- if (typeof filterValue === 'object' && filterValue !== null) {
158
- if (filterValue.$in && Array.isArray(filterValue.$in)) {
159
- if (!filterValue.$in.includes(itemValue)) return false;
160
- } else if (filterValue.$eq !== undefined) {
161
- if (itemValue !== filterValue.$eq) return false;
162
- } else if (filterValue.$ne !== undefined) {
163
- if (itemValue === filterValue.$ne) return false;
164
- } else if (filterValue.$exists !== undefined) {
165
- const hasValue = itemValue !== undefined && itemValue !== null;
166
- if (filterValue.$exists && !hasValue) return false;
167
- if (!filterValue.$exists && hasValue) return false;
168
- }
169
- // Add support for other operators if needed
170
- // else if (filterValue.$gt !== undefined) { ... }
171
- } else {
172
- // Default equality check for non-object filter values
173
- if (itemValue !== filterValue) return false;
174
- }
175
- }
176
- }
177
- return true;
178
- });
179
- };
180
-
181
- export function getDefaultForType(field) {
182
- if(field.default)
183
- return typeof(field.default) === 'function' ? field.default() : field.default;
184
- switch (field.type) {
185
- case 'string':
186
- case 'string_t':
187
- case 'richtext':
188
- case 'password':
189
- case 'email':
190
- case 'phone':
191
- case 'url':
192
- return '';
193
- case 'model':
194
- return '';
195
- case 'modelField':
196
- return { model: '', field: '' };
197
- case 'color':
198
- return null;
199
- case 'number':
200
- return 0;
201
- case 'date':
202
- case 'datetime':
203
- return null;
204
- case 'boolean':
205
- return false;
206
- case 'file':
207
- return null;
208
- case 'array':
209
- return [];
210
- case 'enum':
211
- return field.required && field.items && field.items.length > 0 ? field.items[0] : null;
212
- case 'object':
213
- return {};
214
- case 'relation':
215
- return field.multiple ? [] : null;
216
- default:
217
-
218
- return undefined;
219
- }
220
- }
221
-
222
-
223
- /**
224
- * Maps ConditionBuilder operators to MongoDB aggregation expression operators
225
- * or indicates special handling.
226
- */
227
- const operatorToExprOperatorMap = {
228
- '$eq': '$eq',
229
- '$ne': '$ne',
230
- '$gt': '$gt',
231
- '$gte': '$gte',
232
- '$lt': '$lt',
233
- '$lte': '$lte',
234
- '$in': '$in',
235
- '$nin': '$nin', // Special handling with $not/$in
236
- '$regex': '$regexMatch', // Use aggregation regex operator
237
- '$exists': '$exists' // Special handling with $type or similar
238
- };
239
-
240
- // Helper to build the nested $find structure
241
- function buildNestedFindStructure(pathSegments, finalPayload) {
242
- // Base case: If no segments left, return the final condition payload
243
- if (!pathSegments || pathSegments.length === 0) {
244
- return finalPayload;
245
- }
246
-
247
- const currentSegment = pathSegments[0];
248
- const remainingSegments = pathSegments.slice(1);
249
-
250
- // Recursively build the structure
251
- return {
252
- [currentSegment]: {
253
- '$find': buildNestedFindStructure(remainingSegments, finalPayload)
254
- }
255
- };
256
- }
257
- // ... (imports and other functions) ...
258
-
259
- /**
260
- * Builds the core condition payload (operators and values, or logical groups)
261
- * from a ConditionBuilder condition object or sub-object.
262
- * It handles the transformation of simple conditions and logical groups into
263
- * the format expected by the API filter, using $expr for comparisons and $find for nesting.
264
- *
265
- * @param {object} conditionNode - A node from the ConditionBuilder structure.
266
- * @returns {object | null} The condition payload object or null if invalid.
267
- */
268
- function buildApiConditionPayloadRecursive(conditionNode) {
269
- if (!conditionNode || typeof conditionNode !== 'object') {
270
- console.warn("Invalid node in buildApiConditionPayloadRecursive:", conditionNode);
271
- return null;
272
- }
273
-
274
- // --- Handle Logical Operators ($and, $or) ---
275
- if (conditionNode.$and && Array.isArray(conditionNode.$and)) {
276
- const subPayloads = conditionNode.$and.map(buildApiConditionPayloadRecursive).filter(p => p !== null);
277
- if (subPayloads.length === 0) return null;
278
- if (subPayloads.length === 1) return subPayloads[0];
279
- return { '$and': subPayloads };
280
- }
281
- if (conditionNode.$or && Array.isArray(conditionNode.$or)) {
282
- const subPayloads = conditionNode.$or.map(buildApiConditionPayloadRecursive).filter(p => p !== null);
283
- if (subPayloads.length === 0) return null;
284
- if (subPayloads.length === 1) return subPayloads[0];
285
- return { '$or': subPayloads };
286
- }
287
-
288
- // --- Handle Simple Condition { model?, path, op, value } ---
289
- if (conditionNode.path && Array.isArray(conditionNode.path) && conditionNode.op) {
290
- const path = conditionNode.path;
291
-
292
- if (path.length === 0) {
293
- console.warn("Simple condition node has empty path:", conditionNode);
294
- return null;
295
- }
296
-
297
- // --- Build the core $expr payload ---
298
- const finalFieldName = path[path.length - 1];
299
- const segmentsForNesting = path.slice(0, -1); // Path segments before the last one
300
-
301
- // ***** CORRECTED LOGIC *****
302
- // Determine the field path for the $expr based on nesting level
303
- const fieldPathForExpr = segmentsForNesting.length === 0
304
- ? `$${finalFieldName}` // Top-level field: $fieldName
305
- : `$$this.${finalFieldName}`; // Nested field (inside $find): $$this.fieldName
306
- // ***** END CORRECTION *****
307
-
308
- const operator = conditionNode.op;
309
- const value = conditionNode.value;
310
- const exprOperator = operatorToExprOperatorMap[operator];
311
-
312
- let exprPayload; // This will hold the { $operator: [ field, value ] } part
313
-
314
- if (!exprOperator) {
315
- console.warn(`Unsupported operator for $expr conversion: ${operator}`);
316
- return null;
317
- }
318
-
319
- // --- Build the specific $expr based on the operator ---
320
- switch (exprOperator) {
321
- case '$nin':
322
- const ninValueArray = Array.isArray(value) ? value : String(value).split(',').map(s => s.trim()).filter(Boolean);
323
- // Note: $nin at the top level is often better handled *outside* $expr
324
- // but for consistency with $find, we keep it inside $expr here.
325
- // If segmentsForNesting.length === 0, a standard query { field: { $nin: [...] } } might be more efficient.
326
- exprPayload = { '$not': { '$in': [fieldPathForExpr, ninValueArray] } };
327
- break;
328
- case '$in':
329
- const inValueArray = Array.isArray(value) ? value : String(value).split(',').map(s => s.trim()).filter(Boolean);
330
- // Similar note as $nin regarding top-level efficiency.
331
- exprPayload = { '$in': [fieldPathForExpr, inValueArray] };
332
- break;
333
- case '$regexMatch':
334
- exprPayload = {
335
- '$regexMatch': {
336
- input: fieldPathForExpr,
337
- regex: String(value),
338
- options: 'i'
339
- }
340
- };
341
- break;
342
- case '$exists':
343
- const shouldExist = typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true';
344
- // Using $ne/$eq with $type is a common way to check existence in $expr
345
- exprPayload = {
346
- [shouldExist ? '$ne' : '$eq']: [ { $type: fieldPathForExpr }, "undefined" ]
347
- };
348
- // Note: For top-level, { field: { $exists: true/false } } is standard.
349
- break;
350
- default:
351
- // Standard binary operators ($eq, $ne, $gt, $gte, $lt, $lte)
352
- let processedValue = value;
353
- // Basic type guessing for common string values
354
- if (operator === '$eq' || operator === '$ne') {
355
- if (value === 'true') processedValue = true;
356
- else if (value === 'false') processedValue = false;
357
- else if (value === 'null') processedValue = null;
358
- // Avoid automatic number conversion for eq/ne unless explicitly needed
359
- } else if (['$gt', '$gte', '$lt', '$lte'].includes(operator)) {
360
- // Attempt number conversion for comparison operators
361
- const num = Number(value);
362
- if (!isNaN(num) && String(value).trim() !== '') {
363
- processedValue = num;
364
- }
365
- // TODO: Consider adding date conversion if field type is known
366
- }
367
- exprPayload = { [exprOperator]: [fieldPathForExpr, processedValue] };
368
- break;
369
- }
370
-
371
- // The final payload for the innermost level is the $expr object
372
- const corePayload = exprPayload;
373
-
374
- // --- Structure the filter using nested $find based on the path segments before the last one ---
375
- return buildNestedFindStructure(segmentsForNesting, corePayload);
376
- }
377
-
378
- // Log warning for unknown structures
379
- console.warn("Unknown structure encountered in buildApiConditionPayloadRecursive:", conditionNode);
380
- return null;
381
- }
382
-
383
-
384
- /**
385
- * Transforms a ConditionBuilder condition object into the specific filter format
386
- * for the /api/data/search endpoint.
387
- * The final format is { filter: { ... } }.
388
- *
389
- * @param {object | null | undefined} condition - The condition object from ConditionBuilder.
390
- * @returns {object} The filter object in the format { filter: { ... } }.
391
- * Returns { filter: {} } if the condition is invalid/empty.
392
- */
393
- export function conditionToApiSearchFilter(condition) {
394
- // Handle null or invalid input condition
395
- if (!condition || typeof condition !== 'object') {
396
- return { filter: {} }; // Return empty filter for invalid input
397
- }
398
-
399
- // Build the main filter content using the recursive payload builder
400
- const filterContent = buildApiConditionPayloadRecursive(condition);
401
-
402
- // Return the final structure, ensuring filterContent is not null
403
- return { filter: filterContent || {} };
404
- }
405
-
406
-
407
- export const getFieldValueHash = (model, data) => {
408
- // Prioritize composite unique constraints if they exist
409
- const uniqueConstraints = model.constraints?.filter(c => c.type === 'unique') || [];
410
-
411
- if (uniqueConstraints.length > 0) {
412
- // Combine keys from all unique constraints to create a truly unique hash.
413
- const constraintKeys = [...new Set(uniqueConstraints.flatMap(c => c.keys))];
414
- return getObjectHash(data, constraintKeys, 'constraint');
415
- }
416
- const uniqueFields = model.fields.filter(f => f.unique).map(m => m.name);
417
- const fs = model.fields.map(m => m.name);
418
- const fields = ['_model', '_user', ...(uniqueFields.length ? uniqueFields : fs)];
419
- return getObjectHash(data, fields);
420
- }
421
-
422
-
423
- /**
424
- * Obtient une représentation textuelle lisible d'un enregistrement de données.
425
- * Gère les relations de manière récursive pour afficher des informations pertinentes.
426
- *
427
- * @param {object} model - La définition du modèle pour la `data` actuelle.
428
- * @param {object} data - L'enregistrement de données à convertir en chaîne de caractères.
429
- * @param {function} t - La fonction de traduction i18next.
430
- * @param {array} allModels - Un tableau contenant les définitions de TOUS les modèles du système.
431
- * @returns {string} - La chaîne de caractères représentant la donnée.
432
- */
433
- export const getDataAsString = (model, data, tr, allModels, extended=false) => {
434
- const { t, i18n} = tr;
435
- const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
436
- // Cas de base : si le modèle ou les données sont manquants, on ne peut rien faire.
437
- if (!model || !data) {
438
- return '';
439
- }
440
-
441
- // 1. Déterminer les champs à afficher
442
- // On privilégie les champs marqués comme "asMain"
443
- let parts;
444
- if( extended )
445
- parts = model.fields.map(m => m.name);
446
- else
447
- parts = model.fields.filter(f => f.asMain).map(m => m.name);
448
-
449
- // 2. Si aucun champ "asMain", on cherche des champs standards (nom, titre, etc.)
450
- if (!parts.length) {
451
- for (const main of mainFieldsTypes) {
452
- const f = model.fields?.find((f) => f.type === main)?.name;
453
- if (f) {
454
- parts.push(f);
455
- break; // On ne prend que le premier trouvé
456
- }
457
- }
458
- }
459
-
460
- // 3. Si toujours rien, on se rabat sur l'_id
461
- if (!parts.length) {
462
- parts.push('_id');
463
- }
464
-
465
- // 4. Construire la chaîne de caractères finale
466
- const resultString = parts.map(fieldName => {
467
- const fieldDef = model.fields.find(f => f.name === fieldName);
468
- const value = data[fieldName];
469
-
470
- if (!fieldDef || value === null || value === undefined) {
471
- return null; // Ignore les champs ou valeurs non définis
472
- }
473
-
474
- // --- NOUVELLE LOGIQUE POUR LES RELATIONS ---
475
- if (fieldDef.type === 'relation' && value) {
476
- const relatedModel = allModels?.find(m => m.name === fieldDef.relation);
477
- if (!relatedModel) return `[${fieldDef.relation}]`; // Modnon trouvé
478
-
479
- // Si la relation est multiple (un tableau d'objets)
480
- if (Array.isArray(value)) {
481
- return value
482
- .map(item => getDataAsString(relatedModel, item, t, allModels)) // Appel récursif pour chaque item
483
- .filter(Boolean)
484
- .join(', ');
485
- }
486
- // Si la relation est simple (un seul objet)
487
- else if (typeof value === 'object') {
488
- return getDataAsString(relatedModel, value, t, allModels); // Appel récursif
489
- }
490
- }
491
-
492
- if(fieldDef.type === 'datetime'){
493
- return new Date(value).toLocaleDateString(lang, {year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric'});
494
- }
495
- if(fieldDef.type === 'date'){
496
- return new Date(value).toLocaleDateString(lang, {year: 'numeric', month: 'numeric', day: 'numeric'});
497
- }
498
- // --- FIN DE LA NOUVELLE LOGIQUE ---
499
-
500
- // Logique existante pour les autres types de champs
501
- if (value.value !== undefined) {
502
- return value.value; // Champs traduits
503
- }
504
-
505
- if( typeof(value) === 'string' ) {
506
- const translatedValue = t(value, {defaultValue: value});
507
- return translatedValue || value.toString();
508
- }
509
- if( typeof(value) === 'object' ) {
510
- return JSON.stringify(value, null, 2);
511
- }
512
- return value;
513
-
514
- }).filter(Boolean).join(', ');
515
-
516
- // Si après tout ça la chaîne est vide (ex: l'ID est le seul champ mais on ne veut pas l'afficher seul)
517
- // On retourne l'ID pour avoir au moins un identifiant.
518
- return resultString || data._id;
1
+ import {getObjectHash} from "./core.js";
2
+ import process from "node:process";
3
+ import crypto from "node:crypto";
4
+ import {mainFieldsTypes} from "./constants.js";
5
+
6
+ const IV_LENGTH = 16;
7
+ export function encryptValue(text) {
8
+ if (!process.env.ENCRYPTION_KEY) throw new Error("ENCRYPTION_KEY is not set");
9
+ let iv = crypto.randomBytes(IV_LENGTH);
10
+ let cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY), iv);
11
+ let encrypted = cipher.update(text);
12
+ encrypted = Buffer.concat([encrypted, cipher.final()]);
13
+ return iv.toString('hex') + ':' + encrypted.toString('hex');
14
+ }
15
+
16
+ export function decryptValue(text) {
17
+ if (!process.env.ENCRYPTION_KEY) throw new Error("ENCRYPTION_KEY is not set");
18
+ if (!text || typeof text !== 'string' || !text.includes(':')) return text; // ou throw error
19
+ let textParts = text.split(':');
20
+ let iv = Buffer.from(textParts.shift(), 'hex');
21
+ let encryptedText = Buffer.from(textParts.join(':'), 'hex');
22
+ let decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(process.env.ENCRYPTION_KEY), iv);
23
+ let decrypted = decipher.update(encryptedText);
24
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
25
+ return decrypted.toString();
26
+ }
27
+
28
+ export const isLocalUser = (user) => {
29
+ return user && user._model === 'user' && typeof(user._user) === 'string' && user._user.trim() !== '';
30
+ };
31
+
32
+ export const isDemoUser = (user) => {
33
+ return user?.temporary;
34
+ }
35
+
36
+ export function getUserHash(user) {
37
+ if( isDemoUser(user) ){
38
+ return user.username;
39
+ }
40
+ if( user.hash )
41
+ return user.hash;
42
+ return user ? (
43
+ isLocalUser(user) ? getObjectHash({id: 'LOCAL_USER'+user._user+user.username}) : getObjectHash({id: user.hash})
44
+ ) : 0;
45
+ }
46
+ export function getUserId(user) {
47
+ return user ? (
48
+ isLocalUser(user) ? ('LOCAL_USER'+user.username)?.hashCode() : user.username
49
+ ) : 0;
50
+ }
51
+
52
+ export const getUserName = (user) => {
53
+ return isLocalUser(user) ? user._id + '_'+user._user : user.username;
54
+ }
55
+
56
+ /**
57
+ * Crée une fonction de génération de nombres pseudo-aléatoires basée sur une graine (seed).
58
+ * Utilise un simple générateur congruentiel linéaire (LCG).
59
+ * @param {number} seed - La graine initiale.
60
+ * @returns {function(): number} Une fonction qui retourne un nombre entre 0 et 1.
61
+ */
62
+ function createSeededRandom(seed) {
63
+ let state = seed;
64
+ return function() {
65
+ // Algorithme LCG simple. Pas pour la cryptographie, mais suffisant pour du "hasard" déterministe.
66
+ state = (state * 9301 + 49297) % 233280;
67
+ return state / 233280.0;
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Anonymise une chaîne de caractères en remplaçant chaque caractère
73
+ * par un caractère aléatoire d'un jeu défini.
74
+ * Cette méthode est déterministe si une graine (seed) est fournie.
75
+ *
76
+ * @param {string} text Le texte à anonymiser.
77
+ * @param {boolean} [preserveSpaces=false] Si true, les espaces ne seront pas remplacés.
78
+ * @param {string|number|null} [seed=null] Une graine pour rendre l'anonymisation déterministe. Peut être le _hash de la donnée.
79
+ * @returns {string} Le texte anonymisé, ou une chaîne vide si l'entrée est invalide.
80
+ */
81
+ export function anonymizeText(text, preserveSpaces = false, seed = null) {
82
+ if (typeof text !== 'string' || text.length === 0) {
83
+ return "";
84
+ }
85
+
86
+ text = encryptValue(text);
87
+
88
+ // Jeu de caractères pour le remplacement (vous pouvez l'adapter)
89
+ const charSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789*#@!?";
90
+ let anonymizedResult = "";
91
+
92
+ let random;
93
+ if (seed !== null) {
94
+ // Convertir la graine (qui peut être une chaîne comme _hash) en un nombre simple.
95
+ let numericSeed = 0;
96
+ if (typeof seed === 'string') {
97
+ for (let i = 0; i < seed.length; i++) {
98
+ // Simple hash to number. Using a bitwise operation to keep it a 32-bit integer.
99
+ numericSeed = (((numericSeed << 5) - numericSeed) + seed.charCodeAt(i)) | 0;
100
+ }
101
+ } else if (typeof seed === 'number') {
102
+ numericSeed = seed;
103
+ } else {
104
+ // Fallback pour d'autres types, bien que non attendu
105
+ numericSeed = new Date().getTime();
106
+ }
107
+ random = createSeededRandom(numericSeed);
108
+ } else {
109
+ // Si aucune graine n'est fournie, utiliser le générateur aléatoire standard.
110
+ random = Math.random;
111
+ }
112
+
113
+ for (let i = 0; i < text.length; i++) {
114
+ const originalChar = text[i];
115
+
116
+ // Conserver les espaces si demandé
117
+ if (preserveSpaces && (originalChar === ' ' || originalChar === '\t' || originalChar === '\n' || originalChar === '\r')) {
118
+ anonymizedResult += originalChar;
119
+ } else {
120
+ // Remplacer par un caractère aléatoire du jeu en utilisant le générateur choisi
121
+ const randomIndex = Math.floor(random() * charSet.length);
122
+ anonymizedResult += charSet[randomIndex];
123
+ }
124
+ }
125
+
126
+ return anonymizedResult;
127
+ }
128
+
129
+
130
+ /**
131
+ * Get the value from an object based on a dot-separated path.
132
+ * @param {object} obj - The object to traverse.
133
+ * @param {string} path - The dot-separated path (e.g., "user.profile.name").
134
+ * @returns {*} The value at the given path, or undefined if not found.
135
+ */
136
+ export const getFieldPathValue = (obj, path) => {
137
+ if (!obj || !path) return undefined;
138
+ const properties = path.split('.');
139
+ return properties.reduce((prev, curr) => (prev && prev[curr] !== undefined) ? prev[curr] : undefined, obj);
140
+ };
141
+
142
+ /**
143
+ * Apply a filter condition to an array of data objects.
144
+ * Filter format is a simple object mapping field paths to values or filter operators.
145
+ * Supported operators: $eq, $ne, $in (array), $exists (boolean).
146
+ * @param {array} data - The array of data objects.
147
+ * @param {object|null} filter - The filter object.
148
+ * @returns {array} The filtered array.
149
+ */
150
+ export const applyFilter = (data, filter) => {
151
+ if (!filter || Object.keys(filter).length === 0 || !Array.isArray(data)) return data;
152
+ return data.filter(item => {
153
+ for (const key in filter) {
154
+ // Ensure the key exists in the filter and is not from prototype chain
155
+ if (Object.prototype.hasOwnProperty.call(filter, key)) {
156
+ const filterValue = filter[key];
157
+ const itemValue = getFieldPathValue(item, key);
158
+
159
+ if (typeof filterValue === 'object' && filterValue !== null) {
160
+ if (filterValue.$in && Array.isArray(filterValue.$in)) {
161
+ if (!filterValue.$in.includes(itemValue)) return false;
162
+ } else if (filterValue.$eq !== undefined) {
163
+ if (itemValue !== filterValue.$eq) return false;
164
+ } else if (filterValue.$ne !== undefined) {
165
+ if (itemValue === filterValue.$ne) return false;
166
+ } else if (filterValue.$exists !== undefined) {
167
+ const hasValue = itemValue !== undefined && itemValue !== null;
168
+ if (filterValue.$exists && !hasValue) return false;
169
+ if (!filterValue.$exists && hasValue) return false;
170
+ }
171
+ // Add support for other operators if needed
172
+ // else if (filterValue.$gt !== undefined) { ... }
173
+ } else {
174
+ // Default equality check for non-object filter values
175
+ if (itemValue !== filterValue) return false;
176
+ }
177
+ }
178
+ }
179
+ return true;
180
+ });
181
+ };
182
+
183
+ export function getDefaultForType(field) {
184
+ if(field.default)
185
+ return typeof(field.default) === 'function' ? field.default() : field.default;
186
+ switch (field.type) {
187
+ case 'string':
188
+ case 'string_t':
189
+ case 'richtext':
190
+ case 'password':
191
+ case 'email':
192
+ case 'phone':
193
+ case 'url':
194
+ return '';
195
+ case 'model':
196
+ return '';
197
+ case 'modelField':
198
+ return { model: '', field: '' };
199
+ case 'color':
200
+ return null;
201
+ case 'number':
202
+ return 0;
203
+ case 'date':
204
+ case 'datetime':
205
+ return null;
206
+ case 'boolean':
207
+ return false;
208
+ case 'file':
209
+ return null;
210
+ case 'array':
211
+ return [];
212
+ case 'enum':
213
+ return field.required && field.items && field.items.length > 0 ? field.items[0] : null;
214
+ case 'object':
215
+ return {};
216
+ case 'relation':
217
+ return field.multiple ? [] : null;
218
+ default:
219
+
220
+ return undefined;
221
+ }
222
+ }
223
+
224
+
225
+ /**
226
+ * Maps ConditionBuilder operators to MongoDB aggregation expression operators
227
+ * or indicates special handling.
228
+ */
229
+ const operatorToExprOperatorMap = {
230
+ '$eq': '$eq',
231
+ '$ne': '$ne',
232
+ '$gt': '$gt',
233
+ '$gte': '$gte',
234
+ '$lt': '$lt',
235
+ '$lte': '$lte',
236
+ '$in': '$in',
237
+ '$nin': '$nin', // Special handling with $not/$in
238
+ '$regex': '$regexMatch', // Use aggregation regex operator
239
+ '$exists': '$exists' // Special handling with $type or similar
240
+ };
241
+
242
+ // Helper to build the nested $find structure
243
+ function buildNestedFindStructure(pathSegments, finalPayload) {
244
+ // Base case: If no segments left, return the final condition payload
245
+ if (!pathSegments || pathSegments.length === 0) {
246
+ return finalPayload;
247
+ }
248
+
249
+ const currentSegment = pathSegments[0];
250
+ const remainingSegments = pathSegments.slice(1);
251
+
252
+ // Recursively build the structure
253
+ return {
254
+ [currentSegment]: {
255
+ '$find': buildNestedFindStructure(remainingSegments, finalPayload)
256
+ }
257
+ };
258
+ }
259
+ // ... (imports and other functions) ...
260
+
261
+ /**
262
+ * Builds the core condition payload (operators and values, or logical groups)
263
+ * from a ConditionBuilder condition object or sub-object.
264
+ * It handles the transformation of simple conditions and logical groups into
265
+ * the format expected by the API filter, using $expr for comparisons and $find for nesting.
266
+ *
267
+ * @param {object} conditionNode - A node from the ConditionBuilder structure.
268
+ * @returns {object | null} The condition payload object or null if invalid.
269
+ */
270
+ function buildApiConditionPayloadRecursive(conditionNode) {
271
+ if (!conditionNode || typeof conditionNode !== 'object') {
272
+ console.warn("Invalid node in buildApiConditionPayloadRecursive:", conditionNode);
273
+ return null;
274
+ }
275
+
276
+ // --- Handle Logical Operators ($and, $or) ---
277
+ if (conditionNode.$and && Array.isArray(conditionNode.$and)) {
278
+ const subPayloads = conditionNode.$and.map(buildApiConditionPayloadRecursive).filter(p => p !== null);
279
+ if (subPayloads.length === 0) return null;
280
+ if (subPayloads.length === 1) return subPayloads[0];
281
+ return { '$and': subPayloads };
282
+ }
283
+ if (conditionNode.$or && Array.isArray(conditionNode.$or)) {
284
+ const subPayloads = conditionNode.$or.map(buildApiConditionPayloadRecursive).filter(p => p !== null);
285
+ if (subPayloads.length === 0) return null;
286
+ if (subPayloads.length === 1) return subPayloads[0];
287
+ return { '$or': subPayloads };
288
+ }
289
+
290
+ // --- Handle Simple Condition { model?, path, op, value } ---
291
+ if (conditionNode.path && Array.isArray(conditionNode.path) && conditionNode.op) {
292
+ const path = conditionNode.path;
293
+
294
+ if (path.length === 0) {
295
+ console.warn("Simple condition node has empty path:", conditionNode);
296
+ return null;
297
+ }
298
+
299
+ // --- Build the core $expr payload ---
300
+ const finalFieldName = path[path.length - 1];
301
+ const segmentsForNesting = path.slice(0, -1); // Path segments before the last one
302
+
303
+ // ***** CORRECTED LOGIC *****
304
+ // Determine the field path for the $expr based on nesting level
305
+ const fieldPathForExpr = segmentsForNesting.length === 0
306
+ ? `$${finalFieldName}` // Top-level field: $fieldName
307
+ : `$$this.${finalFieldName}`; // Nested field (inside $find): $$this.fieldName
308
+ // ***** END CORRECTION *****
309
+
310
+ const operator = conditionNode.op;
311
+ const value = conditionNode.value;
312
+ const exprOperator = operatorToExprOperatorMap[operator];
313
+
314
+ let exprPayload; // This will hold the { $operator: [ field, value ] } part
315
+
316
+ if (!exprOperator) {
317
+ console.warn(`Unsupported operator for $expr conversion: ${operator}`);
318
+ return null;
319
+ }
320
+
321
+ // --- Build the specific $expr based on the operator ---
322
+ switch (exprOperator) {
323
+ case '$regex': // Ajout pour gérer l'alias
324
+ case '$nin':
325
+ const ninValueArray = Array.isArray(value) ? value : String(value).split(',').map(s => s.trim()).filter(Boolean);
326
+ // Note: $nin at the top level is often better handled *outside* $expr
327
+ // but for consistency with $find, we keep it inside $expr here.
328
+ // If segmentsForNesting.length === 0, a standard query { field: { $nin: [...] } } might be more efficient.
329
+ exprPayload = { '$not': { '$in': [fieldPathForExpr, ninValueArray] } };
330
+ break;
331
+ case '$in':
332
+ const inValueArray = Array.isArray(value) ? value : String(value).split(',').map(s => s.trim()).filter(Boolean);
333
+ // Similar note as $nin regarding top-level efficiency.
334
+ exprPayload = { '$in': [fieldPathForExpr, inValueArray] };
335
+ break;
336
+ case '$regexMatch':
337
+ exprPayload = {
338
+ '$regexMatch': {
339
+ input: fieldPathForExpr,
340
+ regex: String(value),
341
+ options: 'i'
342
+ }
343
+ };
344
+ break;
345
+ case '$exists':
346
+ const shouldExist = typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true';
347
+ // Using $ne/$eq with $type is a common way to check existence in $expr
348
+ exprPayload = {
349
+ [shouldExist ? '$ne' : '$eq']: [ { $type: fieldPathForExpr }, "undefined" ]
350
+ };
351
+ // Note: For top-level, { field: { $exists: true/false } } is standard.
352
+ break;
353
+ default:
354
+ // Standard binary operators ($eq, $ne, $gt, $gte, $lt, $lte)
355
+ let processedValue = value;
356
+ // Basic type guessing for common string values
357
+ if (operator === '$eq' || operator === '$ne') {
358
+ if (value === 'true') processedValue = true;
359
+ else if (value === 'false') processedValue = false;
360
+ else if (value === 'null') processedValue = null;
361
+ // Avoid automatic number conversion for eq/ne unless explicitly needed
362
+ } else if (['$gt', '$gte', '$lt', '$lte'].includes(operator)) {
363
+ // Attempt number conversion for comparison operators
364
+ const num = Number(value);
365
+ if (!isNaN(num) && String(value).trim() !== '') {
366
+ processedValue = num;
367
+ }
368
+ // TODO: Consider adding date conversion if field type is known
369
+ }
370
+ exprPayload = { [exprOperator]: [fieldPathForExpr, processedValue] };
371
+ break;
372
+ }
373
+
374
+ // The final payload for the innermost level is the $expr object
375
+ const corePayload = exprPayload;
376
+
377
+ // --- Structure the filter using nested $find based on the path segments before the last one ---
378
+ return buildNestedFindStructure(segmentsForNesting, corePayload);
379
+ }
380
+
381
+ // Log warning for unknown structures
382
+ console.warn("Unknown structure encountered in buildApiConditionPayloadRecursive:", conditionNode);
383
+ return null;
384
+ }
385
+
386
+
387
+ /**
388
+ * Transforms a ConditionBuilder condition object into the specific filter format
389
+ * for the /api/data/search endpoint.
390
+ * The final format is { filter: { ... } }.
391
+ *
392
+ * @param {object | null | undefined} condition - The condition object from ConditionBuilder.
393
+ * @returns {object} The filter object in the format { filter: { ... } }.
394
+ * Returns { filter: {} } if the condition is invalid/empty.
395
+ */
396
+ export function conditionToApiSearchFilter(condition) {
397
+ // Handle null or invalid input condition
398
+ if (!condition || typeof condition !== 'object') {
399
+ return { filter: {} }; // Return empty filter for invalid input
400
+ }
401
+
402
+ // Build the main filter content using the recursive payload builder
403
+ const filterContent = buildApiConditionPayloadRecursive(condition);
404
+
405
+ // Return the final structure, ensuring filterContent is not null
406
+ return { filter: filterContent || {} };
407
+ }
408
+
409
+
410
+ export const getFieldValueHash = (model, data) => {
411
+ // Prioritize composite unique constraints if they exist
412
+ const uniqueConstraints = model.constraints?.filter(c => c.type === 'unique') || [];
413
+
414
+ if (uniqueConstraints.length > 0) {
415
+ // Combine keys from all unique constraints to create a truly unique hash.
416
+ const constraintKeys = [...new Set(uniqueConstraints.flatMap(c => c.keys))];
417
+ return getObjectHash(data, constraintKeys, 'constraint');
418
+ }
419
+ const uniqueFields = model.fields.filter(f => f.unique).map(m => m.name);
420
+ const fs = model.fields.map(m => m.name);
421
+ const fields = ['_model', '_user', ...(uniqueFields.length ? uniqueFields : fs)];
422
+ return getObjectHash(data, fields);
423
+ }
424
+
425
+
426
+ /**
427
+ * Obtient une représentation textuelle lisible d'un enregistrement de données.
428
+ * Gère les relations de manière récursive pour afficher des informations pertinentes.
429
+ *
430
+ * @param {object} model - La définition du modèle pour la `data` actuelle.
431
+ * @param {object} data - L'enregistrement de données à convertir en chaîne de caractères.
432
+ * @param {function} t - La fonction de traduction i18next.
433
+ * @param {array} allModels - Un tableau contenant les définitions de TOUS les modèles du système.
434
+ * @returns {string} - La chaîne de caractères représentant la donnée.
435
+ */
436
+ export const getDataAsString = (model, data, tr, allModels, extended=false) => {
437
+ const { t, i18n} = tr;
438
+ const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
439
+ // Cas de base : si le modèle ou les données sont manquants, on ne peut rien faire.
440
+ if (!model || !data) {
441
+ return '';
442
+ }
443
+
444
+ // 1. Déterminer les champs à afficher
445
+ // On privilégie les champs marqués comme "asMain"
446
+ let parts;
447
+ if( extended )
448
+ parts = model.fields.map(m => m.name);
449
+ else
450
+ parts = model.fields.filter(f => f.asMain).map(m => m.name);
451
+
452
+ // 2. Si aucun champ "asMain", on cherche des champs standards (nom, titre, etc.)
453
+ if (!parts.length) {
454
+ for (const main of mainFieldsTypes) {
455
+ const f = model.fields?.find((f) => f.type === main)?.name;
456
+ if (f) {
457
+ parts.push(f);
458
+ break; // On ne prend que le premier trouvé
459
+ }
460
+ }
461
+ }
462
+
463
+ // 3. Si toujours rien, on se rabat sur l'_id
464
+ if (!parts.length) {
465
+ parts.push('_id');
466
+ }
467
+
468
+ // 4. Construire la chaîne de caractères finale
469
+ const resultString = parts.map(fieldName => {
470
+ const fieldDef = model.fields.find(f => f.name === fieldName);
471
+ const value = data[fieldName];
472
+
473
+ if (!fieldDef || value === null || value === undefined) {
474
+ return null; // Ignore les champs ou valeurs non définis
475
+ }
476
+
477
+ // --- NOUVELLE LOGIQUE POUR LES RELATIONS ---
478
+ if (fieldDef.type === 'relation' && value) {
479
+ const relatedModel = allModels?.find(m => m.name === fieldDef.relation);
480
+ if (!relatedModel) return `[${fieldDef.relation}]`; // Modnon trouvé
481
+
482
+ // Si la relation est multiple (un tableau d'objets)
483
+ if (Array.isArray(value)) {
484
+ return value
485
+ .map(item => getDataAsString(relatedModel, item, t, allModels)) // Appel récursif pour chaque item
486
+ .filter(Boolean)
487
+ .join(', ');
488
+ }
489
+ // Si la relation est simple (un seul objet)
490
+ else if (typeof value === 'object') {
491
+ return getDataAsString(relatedModel, value, t, allModels); // Appel récursif
492
+ }
493
+ }
494
+
495
+ if(fieldDef.type === 'datetime'){
496
+ return new Date(value).toLocaleDateString(lang, {year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric'});
497
+ }
498
+ if(fieldDef.type === 'date'){
499
+ return new Date(value).toLocaleDateString(lang, {year: 'numeric', month: 'numeric', day: 'numeric'});
500
+ }
501
+ // --- FIN DE LA NOUVELLE LOGIQUE ---
502
+
503
+ // Logique existante pour les autres types de champs
504
+ if (value.value !== undefined) {
505
+ return value.value; // Champs traduits
506
+ }
507
+
508
+ if( typeof(value) === 'string' ) {
509
+ const translatedValue = t(value, {defaultValue: value});
510
+ return translatedValue || value.toString();
511
+ }
512
+ if( typeof(value) === 'object' ) {
513
+ return JSON.stringify(value, null, 2);
514
+ }
515
+ return value;
516
+
517
+ }).filter(Boolean).join(', ');
518
+
519
+ // Si après tout ça la chaîne est vide (ex: l'ID est le seul champ mais on ne veut pas l'afficher seul)
520
+ // On retourne l'ID pour avoir au moins un identifiant.
521
+ return resultString || data._id;
519
522
  }