UNPKG

@the_cfdude/productboard-mcp

Version:

Model Context Protocol server for Productboard REST API with dynamic tool loading

284 lines (283 loc) 10.4 kB
/** * Utilities for handling wildcard patterns and advanced search functionality */ /** * Compile a pattern string into a reusable pattern matcher */ export function compilePattern(pattern, mode = 'wildcard', options = {}) { const { caseSensitive = false, maxComplexity = 1000 } = options; // Security check: prevent overly complex patterns if (pattern.length > maxComplexity) { throw new Error(`Pattern too complex: ${pattern.length} > ${maxComplexity} characters`); } let regex; let isWildcard = false; switch (mode) { case 'exact': { // Exact match - escape all regex special characters const escapedExact = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); regex = new RegExp(`^${escapedExact}$`, caseSensitive ? '' : 'i'); break; } case 'wildcard': // Check if pattern contains wildcards isWildcard = pattern.includes('*') || pattern.includes('?'); if (!isWildcard) { // No wildcards, treat as exact match const escapedNoWild = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); regex = new RegExp(`^${escapedNoWild}$`, caseSensitive ? '' : 'i'); } else { // Convert wildcards to regex const regexPattern = convertWildcardToRegex(pattern); regex = new RegExp(regexPattern, caseSensitive ? '' : 'i'); } break; case 'regex': // Direct regex - validate for safety validateRegexSafety(pattern); regex = new RegExp(pattern, caseSensitive ? '' : 'i'); isWildcard = true; // Treat regex as wildcard for processing purposes break; default: throw new Error(`Unknown pattern mode: ${mode}`); } return { pattern, isWildcard, regex, mode, }; } /** * Test if a value matches the compiled pattern */ export function matchesPattern(value, compiledPattern) { if (!value) return false; const stringValue = String(value); return compiledPattern.regex.test(stringValue); } /** * Convert wildcard pattern to regex pattern */ function convertWildcardToRegex(pattern) { // Escape all regex special characters except * and ? let escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); // Convert wildcards to regex equivalents escaped = escaped.replace(/\*/g, '.*'); // * becomes .* escaped = escaped.replace(/\?/g, '.'); // ? becomes . // Anchor the pattern to match the entire string return `^${escaped}$`; } /** * Validate regex pattern for safety (prevent ReDoS attacks) */ function validateRegexSafety(pattern) { // Check for potentially dangerous patterns const dangerousPatterns = [ /(\*\+|\+\*)/, // nested quantifiers /(\*\{|\+\{)/, // quantifiers with multipliers /(\(.*\)\{)/, // groups with large multipliers /(\[\^.*\]\{)/, // negated character classes with multipliers ]; for (const dangerous of dangerousPatterns) { if (dangerous.test(pattern)) { throw new Error('Potentially unsafe regex pattern detected. Use wildcard mode for safer pattern matching.'); } } // Check for reasonable length if (pattern.length > 500) { throw new Error('Regex pattern too long (>500 characters)'); } } /** * Enhanced filter application with pattern support */ export function applyPatternFilter(item, field, pattern, operator = 'contains') { const fieldValue = getNestedFieldValue(item, field); if (!fieldValue && operator !== 'isEmpty') { return false; } const stringValue = String(fieldValue || ''); switch (operator) { case 'equals': return matchesPattern(stringValue, pattern); case 'contains': if (pattern.isWildcard) { return matchesPattern(stringValue, pattern); } else { // For non-wildcard contains, use substring matching const searchTerm = pattern.pattern; return stringValue.toLowerCase().includes(searchTerm.toLowerCase()); } case 'startsWith': if (pattern.isWildcard) { // Create a new pattern that only matches at the start const startPattern = compilePattern(pattern.pattern + '*', pattern.mode, { caseSensitive: pattern.regex.flags.includes('i') === false }); return matchesPattern(stringValue, startPattern); } else { return stringValue .toLowerCase() .startsWith(pattern.pattern.toLowerCase()); } case 'endsWith': if (pattern.isWildcard) { // Create a new pattern that only matches at the end const endPattern = compilePattern('*' + pattern.pattern, pattern.mode, { caseSensitive: pattern.regex.flags.includes('i') === false, }); return matchesPattern(stringValue, endPattern); } else { return stringValue .toLowerCase() .endsWith(pattern.pattern.toLowerCase()); } case 'isEmpty': return (!fieldValue || fieldValue === '' || fieldValue === null || fieldValue === undefined); case 'regex': // Force regex mode regardless of compiled pattern mode if (pattern.mode !== 'regex') { const regexPattern = compilePattern(pattern.pattern, 'regex'); return matchesPattern(stringValue, regexPattern); } return matchesPattern(stringValue, pattern); case 'before': case 'after': // Date comparison - convert to dates if possible try { const itemDate = new Date(fieldValue); const compareDate = new Date(pattern.pattern); return operator === 'before' ? itemDate < compareDate : itemDate > compareDate; } catch { return false; } case 'not': // Negation of equals match return !matchesPattern(stringValue, pattern); case 'in': { // Check if value is in comma-separated list const inValues = pattern.pattern .split(',') .map(v => v.trim().toLowerCase()); return inValues.includes(stringValue.toLowerCase()); } case 'not_in': { // Check if value is NOT in comma-separated list const notInValues = pattern.pattern .split(',') .map(v => v.trim().toLowerCase()); return !notInValues.includes(stringValue.toLowerCase()); } default: return matchesPattern(stringValue, pattern); } } /** * Get nested field value using dot notation */ function getNestedFieldValue(obj, path) { return path.split('.').reduce((current, key) => current?.[key], obj); } /** * Generate search suggestions based on partial matches and typos */ export function generateSearchSuggestions(searchTerm, availableFields, maxSuggestions = 5) { const suggestions = []; for (const field of availableFields) { const score = calculateSimilarityScore(searchTerm.toLowerCase(), field.toLowerCase()); if (score > 0.3) { // Minimum similarity threshold suggestions.push({ field, score }); } } // Sort by score (highest first) and return top suggestions return suggestions .sort((a, b) => b.score - a.score) .slice(0, maxSuggestions) .map(s => s.field); } /** * Calculate string similarity score (0-1, where 1 is identical) */ function calculateSimilarityScore(str1, str2) { // Use Levenshtein distance for similarity const distance = levenshteinDistance(str1, str2); const maxLength = Math.max(str1.length, str2.length); if (maxLength === 0) return 1; return 1 - distance / maxLength; } /** * Calculate Levenshtein distance between two strings */ function levenshteinDistance(str1, str2) { const matrix = []; // Initialize matrix for (let i = 0; i <= str2.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= str1.length; j++) { matrix[0][j] = j; } // Calculate distances for (let i = 1; i <= str2.length; i++) { for (let j = 1; j <= str1.length; j++) { if (str2.charAt(i - 1) === str1.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, // substitution matrix[i][j - 1] + 1, // insertion matrix[i - 1][j] + 1 // deletion ); } } } return matrix[str2.length][str1.length]; } /** * Check if a field name might be a wildcard pattern for field selection */ export function isFieldPattern(fieldName) { return fieldName.includes('*') || fieldName.includes('?'); } /** * Expand field patterns to matching field names */ export function expandFieldPatterns(patterns, availableFields) { const expandedFields = new Set(); for (const pattern of patterns) { if (isFieldPattern(pattern)) { const compiledPattern = compilePattern(pattern, 'wildcard', { caseSensitive: false, }); for (const field of availableFields) { if (matchesPattern(field, compiledPattern)) { expandedFields.add(field); } } } else { expandedFields.add(pattern); } } return Array.from(expandedFields); } /** * Validate pattern complexity to prevent performance issues */ export function validatePatternComplexity(pattern, maxStars = 10, maxQuestions = 20) { const starCount = (pattern.match(/\*/g) || []).length; const questionCount = (pattern.match(/\?/g) || []).length; return starCount <= maxStars && questionCount <= maxQuestions; }