UNPKG

@the_cfdude/productboard-mcp

Version:

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

377 lines (323 loc) 10.2 kB
/** * Utilities for handling wildcard patterns and advanced search functionality */ export type PatternMatchMode = 'exact' | 'wildcard' | 'regex'; export interface WildcardPattern { pattern: string; isWildcard: boolean; regex: RegExp; mode: PatternMatchMode; } export interface PatternMatchOptions { caseSensitive?: boolean; maxComplexity?: number; timeout?: number; } /** * Compile a pattern string into a reusable pattern matcher */ export function compilePattern( pattern: string, mode: PatternMatchMode = 'wildcard', options: PatternMatchOptions = {} ): WildcardPattern { 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: RegExp; 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: string | null | undefined, compiledPattern: WildcardPattern ): boolean { if (!value) return false; const stringValue = String(value); return compiledPattern.regex.test(stringValue); } /** * Convert wildcard pattern to regex pattern */ function convertWildcardToRegex(pattern: string): string { // 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: string): void { // 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: any, field: string, pattern: WildcardPattern, operator: string = 'contains' ): boolean { 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: any, path: string): any { return path.split('.').reduce((current, key) => current?.[key], obj); } /** * Generate search suggestions based on partial matches and typos */ export function generateSearchSuggestions( searchTerm: string, availableFields: string[], maxSuggestions: number = 5 ): string[] { const suggestions: Array<{ field: string; score: number }> = []; 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: string, str2: string): number { // 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: string, str2: string): number { const matrix: number[][] = []; // 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: string): boolean { return fieldName.includes('*') || fieldName.includes('?'); } /** * Expand field patterns to matching field names */ export function expandFieldPatterns( patterns: string[], availableFields: string[] ): string[] { const expandedFields = new Set<string>(); 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: string, maxStars: number = 10, maxQuestions: number = 20 ): boolean { const starCount = (pattern.match(/\*/g) || []).length; const questionCount = (pattern.match(/\?/g) || []).length; return starCount <= maxStars && questionCount <= maxQuestions; }