import { ObjectId } from 'mongodb'; import { auditLogModel } from './database/schemas/management/auditlog.schema.js'; import exifr from 'exifr'; import { natsServer } from './database/nats.js'; import log4js from 'log4js'; import config from './config.js'; import crypto from 'crypto'; import canonicalize from 'canonical-json'; const logger = log4js.getLogger('Utils'); logger.level = config.server.logLevel; function buildWildcardRegexPattern(input) { // Escape all regex special chars except * (which we treat as a wildcard) const escaped = input.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); // Convert * to "match anything" const withWildcards = escaped.replace(/\*/g, '.*'); // Anchor so that, without *, this is an exact match return `^${withWildcards}$`; } function parseFilter(property, value) { if (typeof value === 'string') { var trimmed = value.trim(); if (trimmed.charAt(3) == ':') { trimmed = value.split(':')[1]; } // Handle booleans if (trimmed.toLowerCase() === 'true') return { [property]: true }; if (trimmed.toLowerCase() === 'false') return { [property]: false }; // Handle ObjectId (24-char hex) if (/^[a-f\d]{24}$/i.test(trimmed) && trimmed.length >= 24) { return { [property]: new ObjectId(trimmed) }; } // Handle numbers if (!isNaN(trimmed)) { return { [property]: parseFloat(trimmed) }; } // Default to case-insensitive regex for non-numeric strings. // Supports * as a wildcard (e.g. "filament*" matches "filament stock"). const pattern = buildWildcardRegexPattern(trimmed); return { [property]: { $regex: pattern, $options: 'i', }, }; } // Handle actual booleans, numbers, objects, etc. return { [property]: value }; } function convertToCamelCase(obj) { const result = {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { const value = obj[key]; // Convert the key to camelCase let camelKey = key // First handle special cases with spaces, brackets and other characters .replace(/\s*\[.*?\]\s*/g, '') // Remove brackets and their contents .replace(/\s+/g, ' ') // Normalize spaces .trim() // Split by common separators (space, underscore, hyphen) .split(/[\s_-]/) // Convert to camelCase .map((word, index) => { // Remove any non-alphanumeric characters word = word.replace(/[^a-zA-Z0-9]/g, ''); // Lowercase first word, uppercase others return index === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }) .join(''); // Handle values that are objects recursively if (value !== null && typeof value === 'object' && !Array.isArray(value)) { result[camelKey] = convertToCamelCase(value); } else { result[camelKey] = value; } } } return result; } function extractGCodeConfigBlock(fileContent, useCamelCase = true) { const configObject = {}; // Extract header information const headerBlockRegex = /; HEADER_BLOCK_START([\s\S]*?)(?:; HEADER_BLOCK_END|$)/; const headerBlockMatch = fileContent.match(headerBlockRegex); if (headerBlockMatch && headerBlockMatch[1]) { const headerLines = headerBlockMatch[1].split('\n'); headerLines.forEach((line) => { const keyValueRegex = /^\s*;\s*([^:]+?):\s*(.*?)\s*$/; const simpleValueRegex = /^\s*;\s*(.*?)\s*$/; // Try key-value format first let match = line.match(keyValueRegex); if (match) { const key = match[1].trim(); let value = match[2].trim(); // Try to convert value to appropriate type if (!isNaN(value) && value !== '') { value = Number(value); } configObject[key] = value; } else { // Try the simple format like "; generated by OrcaSlicer 2.1.1 on 2025-04-28 at 13:30:11" match = line.match(simpleValueRegex); if (match && match[1] && !match[1].includes('HEADER_BLOCK')) { const text = match[1].trim(); // Extract slicer info const slicerMatch = text.match(/generated by (.*?) on (.*?) at (.*?)$/); if (slicerMatch) { configObject['slicer'] = slicerMatch[1].trim(); configObject['date'] = slicerMatch[2].trim(); configObject['time'] = slicerMatch[3].trim(); } else { // Just add as a general header entry if it doesn't match any specific pattern const key = `header_${Object.keys(configObject).length}`; configObject[key] = text; } } } }); } // Extract thumbnail data const thumbnailBlockRegex = /; THUMBNAIL_BLOCK_START([\s\S]*?)(?:; THUMBNAIL_BLOCK_END|$)/; const thumbnailBlockMatch = fileContent.match(thumbnailBlockRegex); if (thumbnailBlockMatch && thumbnailBlockMatch[1]) { const thumbnailLines = thumbnailBlockMatch[1].split('\n'); let base64Data = ''; let thumbnailInfo = {}; thumbnailLines.forEach((line) => { // Extract thumbnail dimensions and size from the line "thumbnail begin 640x640 27540" const thumbnailHeaderRegex = /^\s*;\s*thumbnail begin (\d+)x(\d+) (\d+)/; const match = line.match(thumbnailHeaderRegex); if (match) { thumbnailInfo.width = parseInt(match[1], 10); thumbnailInfo.height = parseInt(match[2], 10); thumbnailInfo.size = parseInt(match[3], 10); } else if (line.trim().startsWith('; ') && !line.includes('THUMBNAIL_BLOCK')) { // Collect base64 data (remove the leading semicolon and space and thumbnail end) const dataLine = line.trim().substring(2); if (dataLine && dataLine != 'thumbnail end') { base64Data += dataLine; } } }); // Add thumbnail data to config object if (base64Data) { configObject.thumbnail = { data: base64Data, ...thumbnailInfo, }; } } // Extract CONFIG_BLOCK const configBlockRegex = /; CONFIG_BLOCK_START([\s\S]*?)(?:; CONFIG_BLOCK_END|$)/; const configBlockMatch = fileContent.match(configBlockRegex); if (configBlockMatch && configBlockMatch[1]) { // Extract each config line const configLines = configBlockMatch[1].split('\n'); // Process each line configLines.forEach((line) => { // Check if the line starts with a semicolon and has an equals sign const configLineRegex = /^\s*;\s*([^=]+?)\s*=\s*(.*?)\s*$/; const match = line.match(configLineRegex); if (match) { const key = match[1].trim(); let value = match[2].trim(); // Try to convert value to appropriate type if (value === 'true' || value === 'false') { value = value === 'true'; } else if (!isNaN(value) && value !== '') { // Check if it's a number (but not a percentage) if (!value.includes('%')) { value = Number(value); } } configObject[key] = value; } }); } // Extract additional variables that appear after EXECUTABLE_BLOCK_END const additionalVarsRegex = /; EXECUTABLE_BLOCK_(?:START|END)([\s\S]*?)(?:; CONFIG_BLOCK_START|$)/i; const additionalVarsMatch = fileContent.match(additionalVarsRegex); if (additionalVarsMatch && additionalVarsMatch[1]) { const additionalLines = additionalVarsMatch[1].split('\n'); additionalLines.forEach((line) => { // Match both standard format and the special case for "total filament cost" const varRegex = /^\s*;\s*((?:filament used|filament cost|total filament used|total filament cost|total layers count|estimated printing time)[^=]*?)\s*=\s*(.*?)\s*$/; const match = line.match(varRegex); if (match) { const key = match[1].replace(/\[([^\]]+)\]/g, '$1').trim(); let value = match[2].trim(); // Clean up values - remove units in brackets and handle special cases if (key.includes('filament used')) { // Extract just the numeric value, ignoring units in brackets const numMatch = value.match(/(\d+\.\d+)/); if (numMatch) { value = parseFloat(numMatch[1]); } } else if (key.includes('filament cost')) { // Extract just the numeric value const numMatch = value.match(/(\d+\.\d+)/); if (numMatch) { value = parseFloat(numMatch[1]); } } else if (key.includes('total layers count')) { value = parseInt(value, 10); } else if (key.includes('estimated printing time')) { // Keep as string but trim any additional whitespace value = value.trim(); } configObject[key] = value; } }); } // Also extract extrusion width settings const extrusionWidthRegex = /;\s*(.*?)\s*extrusion width\s*=\s*(.*?)mm/g; let extrusionMatch; while ((extrusionMatch = extrusionWidthRegex.exec(fileContent)) !== null) { const settingName = extrusionMatch[1].trim(); const settingValue = parseFloat(extrusionMatch[2].trim()); configObject[`${settingName} extrusion width`] = settingValue; } // Extract additional parameters after CONFIG_BLOCK_END if they exist const postConfigParams = /; CONFIG_BLOCK_END\s*\n([\s\S]*?)$/; const postConfigMatch = fileContent.match(postConfigParams); if (postConfigMatch && postConfigMatch[1]) { const postConfigLines = postConfigMatch[1].split('\n'); postConfigLines.forEach((line) => { // Match lines with format "; parameter_name = value" const paramRegex = /^\s*;\s*([^=]+?)\s*=\s*(.*?)\s*$/; const match = line.match(paramRegex); if (match) { const key = match[1].trim(); let value = match[2].trim(); // Try to convert value to appropriate type if (value === 'true' || value === 'false') { value = value === 'true'; } else if (!isNaN(value) && value !== '') { // Check if it's a number (but not a percentage) if (!value.includes('%')) { value = Number(value); } } // Add to config object if not already present if (!configObject[key]) { configObject[key] = value; } } }); } // Apply camelCase conversion if requested return useCamelCase ? convertToCamelCase(configObject) : configObject; } function getChangedValues(oldObj, newObj, old = false) { const changes = {}; const combinedObj = { ...oldObj, ...newObj }; // Check all keys in the new object for (const key in combinedObj) { // Skip if the key is _id or timestamps if (key === 'createdAt' || key === 'updatedAt' || key === '_id') continue; const oldVal = oldObj ? oldObj[key] : undefined; const newVal = newObj ? newObj[key] : undefined; // If both values are objects (but not arrays or null), recurse if ( oldVal && newVal && typeof oldVal === 'object' && typeof newVal === 'object' && !Array.isArray(oldVal) && !Array.isArray(newVal) && oldVal !== null && newVal !== null ) { if (oldVal?._id || newVal?._id) { if (JSON.stringify(oldVal?._id) !== JSON.stringify(newVal?._id)) { changes[key] = old ? oldVal : newVal; } } else { const nestedChanges = getChangedValues(oldVal, newVal, old); if (Object.keys(nestedChanges).length > 0) { changes[key] = nestedChanges; } } } else { // Check if both values are numbers (or can be converted to numbers) const oldIsNumber = typeof oldVal === 'number' || (oldVal !== null && oldVal !== undefined && !isNaN(Number(oldVal)) && oldVal !== ''); const newIsNumber = typeof newVal === 'number' || (newVal !== null && newVal !== undefined && !isNaN(Number(newVal)) && newVal !== ''); let valuesDiffer; if (oldIsNumber && newIsNumber) { // Compare numbers directly (this normalizes 7.50 to 7.5) valuesDiffer = Number(oldVal) !== Number(newVal); } else { // Use JSON.stringify for non-number comparisons valuesDiffer = JSON.stringify(oldVal) !== JSON.stringify(newVal); } if (valuesDiffer) { // If the old value is different from the new value, include it changes[key] = old ? oldVal : newVal; } } } return changes; } async function newAuditLog(newValue, parentId, parentType, user) { // Filter out createdAt and updatedAt from newValue const filteredNewValue = { ...newValue }; delete filteredNewValue.createdAt; delete filteredNewValue.updatedAt; const auditLog = new auditLogModel({ changes: { new: filteredNewValue, }, parent: parentId, parentType, owner: user._id, ownerType: 'user', operation: 'new', }); await auditLog.save(); await distributeNew(auditLog._id, 'auditLog'); } async function editAuditLog(oldValue, newValue, parentId, parentType, user) { // Get only the changed values const changedOldValues = getChangedValues(oldValue, newValue, true); const changedNewValues = getChangedValues(oldValue, newValue, false); // If no values changed, don't create an audit log if (Object.keys(changedOldValues).length === 0 || Object.keys(changedNewValues).length === 0) { return; } const auditLog = new auditLogModel({ changes: { old: changedOldValues, new: changedNewValues, }, parent: parentId, parentType, owner: user._id, ownerType: 'user', operation: 'edit', }); await auditLog.save(); await distributeNew(auditLog._id, 'auditLog'); } async function deleteAuditLog(deleteValue, parentId, parentType, user) { const auditLog = new auditLogModel({ changes: { old: deleteValue, }, parent: parentId, parentType, owner: user._id, ownerType: 'user', operation: 'delete', }); await auditLog.save(); await distributeNew(auditLog._id, 'auditLog'); } async function getAuditLogs(idOrIds) { if (Array.isArray(idOrIds)) { return auditLogModel.find({ parent: { $in: idOrIds } }).populate('owner'); } else { return auditLogModel.find({ parent: idOrIds }).populate('owner'); } } async function distributeUpdate(value, id, type) { await natsServer.publish(`${type}s.${id}.object`, value); } async function distributeStats(value, type) { await natsServer.publish(`${type}s.stats`, value); } async function distributeNew(value, type) { await natsServer.publish(`${type}s.new`, value); } async function distributeDelete(value, type) { await natsServer.publish(`${type}s.delete`, value); } async function distributeChildUpdate(oldValue, newValue, id, model) { const oldPopulatedObjects = populateObjects(oldValue, model) || []; const oldPopulatedObjectIds = oldPopulatedObjects.map((populate) => populate._id.toString()); const newPopulatedObjects = populateObjects(newValue, model) || []; const newPopulatedObjectIds = newPopulatedObjects.map((populate) => populate._id.toString()); for (const populated of oldPopulatedObjects) { if (!newPopulatedObjectIds.includes(populated._id.toString())) { logger.debug( `Distributing child update for ${populated.ref}s.${populated._id}.events.childUpdate` ); await natsServer.publish(`${populated.ref}s.${populated._id}.events.childUpdate`, { type: 'childUpdate', data: { parentId: id, parentType: model.modelName }, }); } } for (const populated of newPopulatedObjects) { if (!oldPopulatedObjectIds.includes(populated._id.toString())) { logger.debug( `Distributing child update for ${populated.ref}s.${populated._id}.events.childUpdate` ); await natsServer.publish(`${populated.ref}s.${populated._id}.events.childUpdate`, { type: 'childUpdate', data: { parentId: id, parentType: model.modelName }, }); } } } async function distributeChildDelete(value, id, model) { const populatedObjects = populateObjects(value, model) || []; for (const populated of populatedObjects) { logger.debug( `Distributing child delete for ${populated.ref}s.${populated._id}.events.childDelete` ); await natsServer.publish(`${populated.ref}s.${populated._id}.events.childDelete`, { type: 'childDelete', data: { parentId: id, parentType: model.modelName }, }); } } async function distributeChildNew(value, id, model) { const populatedObjects = populateObjects(value, model) || []; for (const populated of populatedObjects) { logger.debug(`Distributing child new for ${populated.ref}s.${populated._id}.events.childNew`); await natsServer.publish(`${populated.ref}s.${populated._id}.events.childNew`, { type: 'childNew', data: { parentId: id, parentType: model.modelName }, }); } } function flatternObjectIds(object) { if (!object || typeof object !== 'object') { return object; } const result = {}; for (const [key, value] of Object.entries(object)) { if (value && typeof value === 'object' && value._id) { // If the value is an object with _id, convert to just the _id result[key] = value._id; } else { // Keep primitive values as is result[key] = value; } } return result; } function expandObjectIds(input) { // Helper to check if a value is an ObjectId or a 24-char hex string function isObjectId(val) { // Check for MongoDB ObjectId instance if (val instanceof ObjectId) return true; // Check for exactly 24 hex characters (no special characters) if (typeof val === 'string' && /^[a-fA-F\d]{24}$/.test(val)) return true; return false; } // Recursive function function expand(value) { if (Array.isArray(value)) { return value.map(expand); } else if (value && typeof value === 'object' && !(value instanceof ObjectId)) { var result = {}; for (const [key, val] of Object.entries(value)) { if (key === '_id') { // Do not expand keys that are already named _id result[key] = val; } else if (isObjectId(val)) { result[key] = { _id: val }; } else if (Array.isArray(val)) { result[key] = val.map(expand); } else if (val instanceof Date) { result[key] = val; } else if (val && typeof val === 'object') { result[key] = expand(val); } else { result[key] = val; } } return result; } else if (isObjectId(value)) { return { _id: value }; } else { return value; } } return expand(input); } // Returns a filter object based on allowed filters and req.query function getFilter(query, allowedFilters, parse = true) { let filter = {}; for (const [key, value] of Object.entries(query)) { if (allowedFilters.includes(key)) { console.log('key', key); console.log('value', value); const parsedFilter = parse ? parseFilter(key, value) : { [key]: value }; filter = { ...filter, ...parsedFilter }; } } return filter; } // Converts a properties argument (string or array) to an array of strings function convertPropertiesString(properties) { if (typeof properties === 'string') { return properties.split(','); } else if (!Array.isArray(properties)) { return []; } return properties; } async function getFileMeta(file) { try { if (!file) return {}; const originalName = file.originalname || ''; const lowerName = originalName.toLowerCase(); if (lowerName.endsWith('.g') || lowerName.endsWith('.gcode')) { const content = file.buffer ? file.buffer.toString('utf8') : ''; if (!content) return {}; return extractGCodeConfigBlock(content); } // Image EXIF metadata if (file.mimetype && file.mimetype.startsWith('image/') && file.buffer) { try { const exif = await exifr.parse(file.buffer); return exif || {}; } catch (_) { // Ignore EXIF parse errors and fall through } } return {}; } catch (_) { return {}; } } function modelHasRef(model, refName) { if (!model || !model.schema) { return false; } let hasRef = false; model.schema.eachPath((pathName, schemaType) => { const directRef = schemaType?.options?.ref; const arrayRef = schemaType?.caster?.options?.ref; const ref = directRef || arrayRef; if (ref === refName) { hasRef = true; } }); return hasRef; } function getFieldsByRef(model, refName) { if (!model || !model.schema) { return []; } const fields = []; model.schema.eachPath((pathName, schemaType) => { const directRef = schemaType?.options?.ref; const arrayRef = schemaType?.caster?.options?.ref; const ref = directRef || arrayRef; if (ref === refName) { fields.push(pathName); } }); return fields; } // Build a nested populate specification by walking the schema graph, // instead of recursing over already-populated documents. function buildDeepPopulateSpec(object, model, populated = new Set()) { // prevent infinite recursion across cyclic model relationships if (populated.has(model.modelName)) return []; populated.add(model.modelName); const schema = model.schema; const populateSpec = []; schema.eachPath((pathname, schemaType) => { const directRef = schemaType.options?.ref; const arrayRef = schemaType.caster?.options?.ref; const ref = directRef || arrayRef; if (!ref) return; const refModel = model.db.model(ref); const childPopulate = buildDeepPopulateSpec(object, refModel, populated); const id = object[pathname]?._id || object[pathname]; if (id == null || !id) return; if (childPopulate.length > 0) { populateSpec.push({ path: pathname, populate: childPopulate, ref: ref, _id: id }); } else { populateSpec.push({ path: pathname, ref: ref, _id: id }); } }); return populateSpec; } function populateObjects(object, model, populated = new Set()) { const populateSpec = buildDeepPopulateSpec(object, model, populated); return populateSpec; } function jsonToCacheKey(obj) { const normalized = canonicalize(obj); const hash = crypto.createHash('sha256').update(normalized).digest('hex'); return hash; } export function getQueryToCacheKey({ model, id, populate }) { const populateKey = []; if (populate) { const populateArray = Array.isArray(populate) ? populate : [populate]; for (const pop of populateArray) { if (typeof pop === 'string') { populateKey.push(pop); } else if (typeof pop === 'object' && pop.path) { populateKey.push(pop.path); } } } return `${model}:${id?.toString()}-${populateKey.join(',')}`; } export { parseFilter, convertToCamelCase, newAuditLog, editAuditLog, deleteAuditLog, getAuditLogs, flatternObjectIds, expandObjectIds, distributeUpdate, distributeStats, distributeNew, distributeDelete, distributeChildUpdate, distributeChildDelete, distributeChildNew, getFilter, // <-- add here convertPropertiesString, getFileMeta, modelHasRef, getFieldsByRef, jsonToCacheKey, };