just-exercise-parser
Version:
A TypeScript library for parsing, normalizing, and generating standardized exercise names
741 lines (740 loc) • 31.9 kB
JavaScript
;
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();