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