@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
377 lines (323 loc) • 10.2 kB
text/typescript
/**
* 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;
}