UNPKG

just-exercise-parser

Version:

A TypeScript library for parsing, normalizing, and generating standardized exercise names

741 lines (740 loc) 31.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ExerciseNameFormatter = void 0; const exercise_parser_config_json_1 = __importDefault(require("./exercise-parser.config.json")); /** * Enhanced system for parsing, normalizing, and generating standardized exercise names */ class ExerciseNameFormatter { /** * Constructor that initializes the processor with JSON configuration * @param config Optional configuration object to use instead of the default */ constructor(config) { /** * Dictionary of exercise component categories and their terms */ this.exerciseComponentDefinitions = {}; /** * Dictionary of templates for standardized exercise naming */ this.templates = {}; this.exerciseComponentPatterns = {}; this.regexReplacements = []; // Load the configuration this.loadConfig(config || exercise_parser_config_json_1.default); // Initialize regex patterns this.initializePatterns(); } /** * Checks if debugging is enabled via environment variable. * This method is static so the `isDebugging` flag can be static. * @returns True if debugging is enabled, false otherwise. */ static isDebugEnabled() { //return true; const debugValue = process.env[ExerciseNameFormatter.DEBUG_ENV_VAR]; return debugValue === 'true' || debugValue === '1'; } /** * Logs debug messages if debugging is enabled. * @param message The message to log. * @param optionalParams Optional parameters to log. */ static debugLog(message, ...optionalParams) { if (ExerciseNameFormatter.isDebugging) { console.log(`[DEBUG] ${message}`, ...optionalParams); } } /** * Logs error messages. Always logs errors regardless of debug mode. * @param message The error message. * @param optionalParams Optional parameters */ static errorLog(message, ...optionalParams) { console.error(`[ERROR] ${message}`, ...optionalParams); } /** * Load configuration from a JSON object * @param config The configuration object */ loadConfig(config) { ExerciseNameFormatter.debugLog('Loading configuration:', config); try { this.templates = config.templates; this.exerciseComponentDefinitions = config.components; // Initialize regex replacements from the configuration this.regexReplacements = []; if (config.replacements) { for (const replacement of config.replacements) { try { let regexOptions = ''; if (replacement.ignoreCase) { regexOptions += 'i'; } const regex = new RegExp(replacement.pattern, regexOptions); this.regexReplacements.push({ pattern: regex, replacement: replacement.replacement }); } catch (ex) { ExerciseNameFormatter.errorLog(`Error creating regex for pattern '${replacement.pattern}':`, ex); } } ExerciseNameFormatter.debugLog('Regex replacements:', this.regexReplacements); } // Add "others" category if not present if (!this.exerciseComponentDefinitions['others']) { this.exerciseComponentDefinitions['others'] = []; ExerciseNameFormatter.debugLog('Added "others" category to component definitions.'); } // Reinitialize patterns after loading new config this.initializePatterns(); } catch (ex) { ExerciseNameFormatter.errorLog(`Error loading config:`, ex); throw new Error(`Failed to load configuration: ${ex}`); } } /** * Initialize regex patterns from component definitions */ initializePatterns() { ExerciseNameFormatter.debugLog('Initializing regex patterns.'); this.exerciseComponentPatterns = {}; // Initialize categories in patterns dictionary for (const category of Object.keys(this.exerciseComponentDefinitions)) { this.exerciseComponentPatterns[category] = []; } // Add patterns for each term in the categories for (const category of Object.keys(this.exerciseComponentDefinitions)) { if (category !== 'others') { // Skip the others category for (const term of this.exerciseComponentDefinitions[category]) { // Create a regex pattern that matches term boundaries const expression = `\\b${term}s?\\b`; const pattern = new RegExp(expression, 'ig'); this.exerciseComponentPatterns[category].push({ term, pattern }); } } } ExerciseNameFormatter.debugLog('Initialized exercise component patterns:', this.exerciseComponentPatterns); } /** * Standardize an exercise name by applying regex replacements and normalization * @param exerciseName The exercise name to standardize * @returns Standardized exercise name */ standardizeName(exerciseName) { ExerciseNameFormatter.debugLog(`Standardizing exercise name: ${exerciseName}`); if (!exerciseName) { return ''; } let normalized = exerciseName.toLowerCase(); // Check if exercise is alternating const alternatingResult = this.isAlternatingExercise(normalized); const isAlternating = alternatingResult.isAlternating; normalized = alternatingResult.cleanedName; // If alternating, append "alternating" if not already present if (isAlternating && !normalized.includes('alternating')) { normalized = normalized + ' alternating'; } // Clean the exercise name using regex replacements normalized = this.cleanExerciseName(normalized); // Split by delimiters and handle each part separately const delimiters = [' with ', ' from ', ' to ']; const segmentPairs = []; let remaining = normalized; let lastDelimiter = ''; // Identify all segments and their associated delimiters for (const delimiter of delimiters) { // Split the remaining text by the current delimiter const parts = remaining.split(delimiter); // If there's only one part, no split happened if (parts.length <= 1) { continue; } // Save all but the last segment with this delimiter for (let i = 0; i < parts.length - 1; i++) { segmentPairs.push({ segment: parts[i].trim(), delimiter }); } // The last part becomes our new remaining text remaining = parts[parts.length - 1].trim(); lastDelimiter = delimiter; } // Add the final remaining segment if (remaining) { segmentPairs.push({ segment: remaining, delimiter: '' }); } // Process each segment const standardizedSegments = []; ExerciseNameFormatter.debugLog(`Segment pairs:`, segmentPairs); for (const { segment, delimiter } of segmentPairs) { const parts = this.parseExerciseName(segment); const normalizedSegment = this.generateExerciseName(parts.components); standardizedSegments.push(normalizedSegment + (delimiter ? delimiter : '')); } // Join the segments back together normalized = standardizedSegments.join('').trim(); normalized = this.moveWordsToSpecificPositions(normalized); // Replace standalone numbers or numbers at beginning with number- normalized = normalized.replace(/(\s|^)(\d+)\s+(\w+)/g, (_, prefix, number, nextWord) => { return prefix + number + '-' + nextWord; }); normalized = normalized.replace(' and ', ' & '); ExerciseNameFormatter.debugLog(`Standardized name: ${normalized}`); return ExerciseNameFormatter.toTitleCase(normalized); } /** * Clean an exercise name using regex replacements * @param exerciseName Exercise name to clean * @returns Cleaned exercise name */ cleanExerciseName(exerciseName) { ExerciseNameFormatter.debugLog(`Cleaning exercise name: ${exerciseName}`); if (!exerciseName || exerciseName.trim() === '') { return ''; } let normalized = exerciseName.toLowerCase(); // Apply all regex replacements for (const { pattern, replacement } of this.regexReplacements) { normalized = normalized.replace(pattern, ' ' + replacement + ' '); } // Normalize digit+letter combinations (1d, 1-d, 1 d) to standardized 1-D format normalized = normalized.replace(/\b(\d+)[ -]?([a-zA-Z])\b/gi, (_, number, letter) => { return `${number}-${letter.toLowerCase()}`; }); // Clean up extra spaces normalized = normalized.replace(/\s+/g, ' ').trim(); ExerciseNameFormatter.debugLog(`Cleaned exercise name: ${normalized}`); return normalized; } /** * Normalize an exercise name for comparison * @param exerciseName Exercise name to normalize * @param internalName Whether this is an internal name * @returns Normalized exercise name */ normalizeName(exerciseName, internalName = false) { ExerciseNameFormatter.debugLog(`Normalizing exercise name: ${exerciseName}, internalName: ${internalName}`); if (!exerciseName || exerciseName.trim() === '') { return ''; } let normalized = exerciseName.toLowerCase(); let isAlternating = false; // Handle alternating exercises if (!internalName) { const alternatingResult = this.isAlternatingExercise(exerciseName); isAlternating = alternatingResult.isAlternating; normalized = alternatingResult.cleanedName; // If alternating, append "alternating" if not already present if (isAlternating) { normalized = normalized + ' alternating'; } } normalized = this.cleanExerciseName(normalized); normalized = ExerciseNameFormatter.cleanPluralizationUnsafe(normalized); normalized = ExerciseNameFormatter.sortWords(normalized); // sort words alphabetically - so "bench press incline" == "incline bench press" ExerciseNameFormatter.debugLog(`Normalized exercise name: ${normalized}`); return normalized; } /** * Check if an exercise is alternating * @param exerciseName Exercise name to check * @returns Object with isAlternating flag and cleanedName */ isAlternatingExercise(exerciseName) { ExerciseNameFormatter.debugLog(`Checking if exercise is alternating: ${exerciseName}`); if (!exerciseName || exerciseName.trim() === '') { return { isAlternating: false, cleanedName: '' }; } // Normalize the input string let normalized = exerciseName.toLowerCase(); let hasDirectional = false; // Patterns to identify directional indicators const directionalPatterns = [ '\\s*[-–—]\\s*(?:left|right|other|switch)(?:\\s+(?:leg|side|arm)(?:s?))?\\b', // " - left" " - right" " - other side" "- switch arms '(?:\\(|\\[)(?:\\s*(?:left|right|other|change|switch)\\s*.*?)(?:\\)|\\])', //(left leg) ( left side ) (right) ( right ) [ left ] '(?:left|right|other|change|switch|alternate|alternating)(?:\\s+(?:leg|side|arm)(?:s?))?\\s*$', // "left", "left leg", "left side" "switch" "switch legs" - At the end '\\b(?:alternate|alternating)\\b', '\\b(?:alt)\\b', // alt alt. *alt* ]; for (const pattern of directionalPatterns) { const regex = new RegExp(pattern, 'i'); if (regex.test(normalized)) { hasDirectional = true; // Remove the directional part and associated words normalized = normalized.replace(regex, ' '); break; } } // Clean up extra spaces const cleanedName = normalized.replace(/\s+/g, ' ').trim(); ExerciseNameFormatter.debugLog(`isAlternating: ${hasDirectional}, cleanedName: ${cleanedName}`); // Return true if explicitly alternating or has directional indicators return { isAlternating: hasDirectional, cleanedName }; } /** * Helper method to move specific words to beginning or end * @param text Text to process * @returns Text with words moved to specific positions */ moveWordsToSpecificPositions(text) { ExerciseNameFormatter.debugLog(`Moving words to specific positions in: ${text}`); // Define words that should be moved to the beginning const wordsToBeginning = ['pregnancy', 'prenatal', 'postnatal', 'alternating']; // Define words that should be moved to the end const wordsToEnd = ['progression']; const words = text.split(' ').filter((w) => w); const beginningWords = []; const endWords = []; const remainingWords = []; for (const word of words) { let processed = false; // Check if word should be moved to beginning for (const prefix of wordsToBeginning) { if (word.toLowerCase() === prefix.toLowerCase()) { beginningWords.push(word); processed = true; break; } } if (processed) { continue; } // Check if word should be moved to end for (const suffix of wordsToEnd) { if (word.toLowerCase() === suffix.toLowerCase()) { endWords.push(word); processed = true; break; } } if (!processed) { remainingWords.push(word); } } // Combine all words in the desired order const result = [...beginningWords, ...remainingWords, ...endWords].join(' '); ExerciseNameFormatter.debugLog(`Result after moving words: ${result}`); return result; } /** * Remove pluralization from words in an exercise name * @param exerciseName Exercise name to process * @returns Exercise name with pluralization removed */ static cleanPluralizationUnsafe(exerciseName) { ExerciseNameFormatter.debugLog(`Cleaning pluralization in: ${exerciseName}`); // Split into words, remove pluralization from each word, and sort alphabetically const words = exerciseName .split(' ') .filter((w) => w) .map((word) => ExerciseNameFormatter.removePluralization(word)); const result = words.join(' '); ExerciseNameFormatter.debugLog(`Result after cleaning pluralization: ${result}`); return result; } /** * Remove pluralization from a single word * @param word Word to process * @returns Word with pluralization removed */ static removePluralization(word) { if (!word || word.length <= 2) { return word; } // Handle special pluralization cases first if (word.toLowerCase().endsWith('ies') && word.length > 3) { // e.g., "bodies" to "body" if (word.length > 4 && !ExerciseNameFormatter.isVowel(word[word.length - 4])) { return word.substring(0, word.length - 3) + 'y'; } // Otherwise just remove 's' return word.substring(0, word.length - 1); } else if (word.toLowerCase().endsWith('es') && word.length > 2) { // Check for -ches, -shes, -sses, -xes if (word.length >= 4) { const suffix = word .substring(Math.max(0, word.length - 4), Math.min(word.length - 2, word.length)) .toLowerCase(); if (suffix === 'ch' || suffix === 'sh' || (word.length >= 3 && (word[word.length - 3] === 's' || word[word.length - 3] === 'x'))) { return word.substring(0, word.length - 2); } } // Just remove 's' for regular -es cases return word.substring(0, word.length - 1); } else if (word.toLowerCase().endsWith('s') && word.length > 1) { // Don't singularize words that are naturally singular despite ending in 's' const singularEndingInS = [ 'abs', 'biceps', 'triceps', 'quads', 'glutes', 'delts', 'lats', 'traps', ]; if (singularEndingInS.some((s) => s.toLowerCase() === word.toLowerCase())) { return word; } // Check if the word has 'ss' at the end (like "press") if (word.length >= 2 && word[word.length - 2] === 's') { return word; } return word.substring(0, word.length - 1); } return word; } /** * Check if a character is a vowel * @param c Character to check * @returns True if the character is a vowel */ static isVowel(c) { const lower = c.toLowerCase(); return lower === 'a' || lower === 'e' || lower === 'i' || lower === 'o' || lower === 'u'; } /** * Sort words in an exercise name alphabetically * @param exerciseName Exercise name to sort * @returns Exercise name with words sorted alphabetically */ static sortWords(exerciseName) { ExerciseNameFormatter.debugLog(`Sorting words in: ${exerciseName}`); const sorted = exerciseName .split(' ') .filter((word) => word.trim() !== '') .sort(); const result = sorted.join(' '); ExerciseNameFormatter.debugLog(`Result after sorting: ${result}`); return result; } /** * Convert text to title case * @param input Text to convert * @param customExceptions Custom exceptions to title case rules * @returns Text in title case */ static toTitleCase(input, customExceptions) { ExerciseNameFormatter.debugLog(`Converting to title case: ${input}`); if (!input || input.trim() === '') { return ''; } // Common words to keep lowercase in titles const defaultExceptions = new Set([ // Articles 'a', 'an', 'the', // Coordinating conjunctions 'and', 'but', 'or', 'nor', 'for', 'so', 'yet', // Prepositions 'at', 'by', 'for', 'from', 'in', 'into', 'of', 'off', 'on', 'onto', 'out', 'over', 'to', 'up', 'upon', 'with', 'within', 'without', ].map((e) => e.toLowerCase())); // Add any custom exceptions if (customExceptions) { for (const exception of customExceptions) { defaultExceptions.add(exception.toLowerCase()); } } // Split the input into words const words = input.split(' ').filter((w) => w); // Process each word for (let i = 0; i < words.length; i++) { const word = words[i]; if (!word) { continue; } // Always capitalize the first word and the last word if (i === 0 || i === words.length - 1) { words[i] = ExerciseNameFormatter.capitalizeFirstLetter(word); } else { words[i] = ExerciseNameFormatter.capitalizeFirstLetter(word); } // Check if the word is in the exceptions list if (defaultExceptions.has(word.toLowerCase())) { words[i] = word.toLowerCase(); } } // Join the words back together let result = words.join(' '); // Capitalize Roman Numerals ie: [iii] = [III] result = result.replace(/(\[)?(\b[ivxlcdm]+\b)(\])?/gi, (_, prefix, romanNumeral, suffix) => { const prefixStr = prefix ? '[' : ''; const suffixStr = suffix ? ']' : ''; return prefixStr + romanNumeral.toUpperCase() + suffixStr; }); // Quick Fixes result = result.replace('Trx', 'TRX'); ExerciseNameFormatter.debugLog(`Result after title casing: ${result}`); return result; } /** * Capitalize the first letter of a word * @param word Word to capitalize * @returns Word with first letter capitalized */ static capitalizeFirstLetter(word) { if (!word) { return word; } // Handle single-character words if (word.length === 1) { return word.toUpperCase(); } // Split the word by dashes const parts = word.split('-'); for (let i = 0; i < parts.length; i++) { if (parts[i]) { if (['ez', 'jm', 'trx'].includes(parts[i].toLowerCase())) { parts[i] = parts[i].toUpperCase(); continue; } // Capitalize the first letter of each part parts[i] = parts[i][0].toUpperCase() + (parts[i].length > 1 ? parts[i].substring(1).toLowerCase() : ''); } } // Join the parts back with dashes return parts.join('-'); } /** * Parses an exercise name into its component parts using regex pattern matching * @param exerciseName The full exercise name to parse * @returns Component parts of the exercise */ parseExerciseName(exerciseName) { ExerciseNameFormatter.debugLog(`Parsing exercise name: ${exerciseName}`); const result = { originalName: exerciseName, components: {}, }; // Initialize component lists for (const category of Object.keys(this.exerciseComponentPatterns)) { result.components[category] = []; } // Collect all matched segments to exclude them later const excludedSegments = []; // Check for each component category using regex patterns for (const category of Object.keys(this.exerciseComponentPatterns)) { if (category !== 'others') { // Skip "others" category for now for (const { term, pattern } of this.exerciseComponentPatterns[category]) { let match; ExerciseNameFormatter.debugLog(`Checking for term: ${term} - pattern: ${pattern}`); while ((match = pattern.exec(exerciseName)) !== null) { // Store the actual matched text instead of the base term const matchedText = match[0]; result.components[category].push(matchedText); excludedSegments.push({ start: match.index, end: match.index + match[0].length, category, matchedText, }); // Move lastIndex to after the ENTIRE match, not just one character after the start pattern.lastIndex = match.index + match[0].length; } } } } ExerciseNameFormatter.debugLog(`Excluded segments after parsing components:`, excludedSegments); // Process unknown words - we only need the start/end positions for exclusion const positionExcludeSegments = excludedSegments.map((x) => ({ start: x.start, end: x.end })); // Merge overlapping segments const mergedExcludedSegments = ExerciseNameFormatter.mergeSegments(positionExcludeSegments); // Extract remaining words (potential unknown terms) const wordRegex = /\b[\w']+(?:(?:[-]|\s*[&]\s*)[\w']+)*\b/g; const words = []; let wordMatch; while ((wordMatch = wordRegex.exec(exerciseName)) !== null) { const start = wordMatch.index; const end = start + wordMatch[0].length; if (!ExerciseNameFormatter.isInExcludedSegment(start, end, mergedExcludedSegments)) { words.push(wordMatch[0].toLowerCase()); } } result.components['others'] = [...new Set(words)]; // Remove duplicates ExerciseNameFormatter.debugLog(`Parsed exercise components:`, result); return result; } /** * Checks if a position falls within any excluded segment * @param start Start position * @param end End position * @param excludedSegments List of excluded segments * @returns True if the position is within an excluded segment */ static isInExcludedSegment(start, end, excludedSegments) { for (const { start: segStart, end: segEnd } of excludedSegments) { // Check if there is any overlap between the segments if (!(end <= segStart || start >= segEnd)) { return true; } } return false; } /** * Merges overlapping segments * @param segments List of segments to merge * @returns List of merged segments */ static mergeSegments(segments) { if (segments.length === 0) { return []; } // Sort segments by start position const sortedSegments = [...segments].sort((a, b) => a.start - b.start); const result = []; let current = sortedSegments[0]; for (let i = 1; i < sortedSegments.length; i++) { if (sortedSegments[i].start <= current.end) { // Merge overlapping segments current = { start: current.start, end: Math.max(current.end, sortedSegments[i].end), }; } else { result.push(current); current = sortedSegments[i]; } } result.push(current); ExerciseNameFormatter.debugLog(`Merged segments:`, result); return result; } /** * Generate a new exercise name from components using a template * @param components The component parts to use * @param templateName Template name to use (defaults to "standard") * @returns Generated exercise name */ generateExerciseName(components, templateName = 'standard') { ExerciseNameFormatter.debugLog(`Generating exercise name with components:`, components, `templateName: ${templateName}`); let template = this.templates[templateName]; if (!template) { // If the requested template doesn't exist, use the first available template or an empty string template = Object.values(this.templates)[0] || ''; } // Find which component categories are used in the template const usedCategories = new Set(); for (const category of Object.keys(this.exerciseComponentDefinitions)) { if (template.includes(`\${${category}}`)) { usedCategories.add(category); } } // Gather words from unused categories to add to "others" const additionalOthers = []; for (const category of Object.keys(components)) { // If this category is not used in the template and it's not "others" if (category !== 'others' && !usedCategories.has(category) && components[category].length > 0) { additionalOthers.push(...components[category]); } } // Create a copy of the components with enhanced "others" section const enhancedComponents = Object.assign({}, components); // Add words from unused categories to the "others" category if (additionalOthers.length > 0) { if (!enhancedComponents['others']) { enhancedComponents['others'] = []; } enhancedComponents['others'] = [...enhancedComponents['others'], ...additionalOthers]; } const getItem = (list) => (list && list.length > 0 ? list.join(' ') : ''); // Prepare replacements dictionary with all possible component categories const replacements = {}; // Add all component categories that exist in our definition for (const category of Object.keys(this.exerciseComponentDefinitions)) { replacements[category] = getItem(enhancedComponents[category] || []); } // Perform the string interpolation let result = template; for (const key of Object.keys(replacements)) { result = result.replace(`\${${key}}`, replacements[key]); } // Clean up any extra spaces, including multi-spaces result = result.replace(/\s+/g, ' ').trim(); result = result.replace(/\s+from$/i, ''); ExerciseNameFormatter.debugLog(`Generated exercise name: ${result}`); return result; } /** * Process a single exercise name with normalization and standardization * @param exerciseName Exercise name to process * @param templateNames List of template names to generate variations * @returns Processed exercise */ processExerciseName(exerciseName, templateNames) { ExerciseNameFormatter.debugLog(`Processing exercise name: ${exerciseName}, templateNames:`, templateNames); // First, standardize the exercise name const standardizedName = this.standardizeName(exerciseName); // Then parse it into components const parsed = this.parseExerciseName(standardizedName); const variations = []; // If no template names provided, use all available templates if (!templateNames || !templateNames.length) { // Use the first template as default if (Object.keys(this.templates).length > 0) { variations.push(this.generateExerciseName(parsed.components, Object.keys(this.templates)[0])); } } else { // Generate variations for each provided template for (const templateName of templateNames) { variations.push(this.generateExerciseName(parsed.components, templateName)); } } ExerciseNameFormatter.debugLog(`Processed exercise:`, { original: exerciseName, parsed: parsed.components, variations, }); return { original: exerciseName, parsed: parsed.components, variations, }; } } exports.ExerciseNameFormatter = ExerciseNameFormatter; ExerciseNameFormatter.DEBUG_ENV_VAR = 'EXERCISE_NAME_FORMATTER_DEBUG'; ExerciseNameFormatter.isDebugging = ExerciseNameFormatter.isDebugEnabled();