import type { WorkflowSearchMatch, WorkflowSearchMatchKind, WorkflowSearchReplacementOption, WorkflowSearchResourceMeta, WorkflowSearchValuePath, } from '@/lib/workflows/search-replace/types' import type { SelectorContext } from '@/hooks/selectors/types' const OVERLAPPING_MATCH_KIND_PRIORITY: Record = { text: 0, environment: 1, 'workflow-reference': 2, 'oauth-credential': 3, 'knowledge-base': 3, 'knowledge-document': 3, workflow: 3, 'mcp-server': 3, 'mcp-tool': 3, table: 3, file: 3, 'selector-resource': 3, } export function stableStringifyWorkflowSearchValue(value: unknown): string { if (!value || typeof value !== 'object') return JSON.stringify(value) if (Array.isArray(value)) { return `[${value.map((item) => stableStringifyWorkflowSearchValue(item)).join(',')}]` } return `{${Object.entries(value as Record) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, item]) => `${JSON.stringify(key)}:${stableStringifyWorkflowSearchValue(item)}`) .join(',')}}` } export function buildWorkflowSearchResourceGroupKey( resource: Pick< WorkflowSearchResourceMeta, 'kind' | 'providerId' | 'serviceId' | 'selectorKey' | 'selectorContext' > ): string { const provider = resource.providerId ?? resource.serviceId ?? '' const selectorKey = resource.selectorKey ?? '' const selectorContext = resource.selectorContext ? stableStringifyWorkflowSearchValue(resource.selectorContext) : '' return [resource.kind, provider, selectorKey, selectorContext].join(':') } export function getWorkflowSearchMatchResourceGroupKey(match: WorkflowSearchMatch): string { return ( match.resource?.resourceGroupKey ?? buildWorkflowSearchResourceGroupKey({ kind: match.kind as WorkflowSearchResourceMeta['kind'], providerId: match.resource?.providerId, serviceId: match.resource?.serviceId, selectorKey: match.resource?.selectorKey, selectorContext: match.resource?.selectorContext, }) ) } export function selectorContextMatches( left: SelectorContext | undefined, right: SelectorContext | undefined ): boolean { return ( stableStringifyWorkflowSearchValue(left ?? {}) === stableStringifyWorkflowSearchValue(right ?? {}) ) } export function replacementOptionMatchesResourceMatch( option: WorkflowSearchReplacementOption, match: WorkflowSearchMatch ): boolean { if (option.kind !== match.kind) return false const optionGroupKey = option.resourceGroupKey ?? buildWorkflowSearchResourceGroupKey({ kind: option.kind as WorkflowSearchResourceMeta['kind'], providerId: option.providerId, serviceId: option.serviceId, selectorKey: option.selectorKey, selectorContext: option.selectorContext, }) return optionGroupKey === getWorkflowSearchMatchResourceGroupKey(match) } export function getWorkflowSearchCompatibleResourceMatches( activeMatch: WorkflowSearchMatch | null, matches: WorkflowSearchMatch[] ): WorkflowSearchMatch[] { if (!activeMatch?.resource) return [] const activeGroupKey = getWorkflowSearchMatchResourceGroupKey(activeMatch) return matches.filter( (match) => match.editable && Boolean(match.resource) && getWorkflowSearchMatchResourceGroupKey(match) === activeGroupKey ) } function searchValuePathKey(path: WorkflowSearchValuePath): string { return path.map((segment) => `${typeof segment}:${String(segment)}`).join('/') } function getRangeMatchScopeKey(match: WorkflowSearchMatch): string | null { if (!match.range) return null if (match.target.kind !== 'subblock') return null return [match.blockId, match.subBlockId, searchValuePathKey(match.valuePath)].join(':') } function rangesOverlap( left: NonNullable, right: NonNullable ): boolean { return left.start < right.end && right.start < left.end } function getRangeLength(match: WorkflowSearchMatch): number { return match.range ? match.range.end - match.range.start : Number.POSITIVE_INFINITY } function shouldPreferOverlappingMatch( candidate: WorkflowSearchMatch, current: WorkflowSearchMatch ): boolean { const candidateLength = getRangeLength(candidate) const currentLength = getRangeLength(current) if (candidateLength !== currentLength) return candidateLength < currentLength const candidatePriority = OVERLAPPING_MATCH_KIND_PRIORITY[candidate.kind] const currentPriority = OVERLAPPING_MATCH_KIND_PRIORITY[current.kind] if (candidatePriority !== currentPriority) return candidatePriority > currentPriority return false } export function dedupeOverlappingWorkflowSearchMatches( matches: T[] ): T[] { const deduped: T[] = [] for (const match of matches) { const scopeKey = getRangeMatchScopeKey(match) const matchRange = match.range const existingIndex = scopeKey && matchRange ? deduped.findIndex( (candidate) => getRangeMatchScopeKey(candidate) === scopeKey && candidate.range && rangesOverlap(candidate.range, matchRange) ) : -1 if (existingIndex === -1) { deduped.push(match) continue } if (shouldPreferOverlappingMatch(match, deduped[existingIndex])) { deduped[existingIndex] = match } } return deduped } export function workflowSearchMatchMatchesQuery( match: WorkflowSearchMatch & { displayLabel?: string }, query: string, caseSensitive = false ): boolean { const trimmedQuery = query.trim() if (!trimmedQuery) return false if (match.kind === 'text') return true const normalize = (value: string) => (caseSensitive ? value : value.toLowerCase()) const searchable = match.resource?.kind === 'workflow-reference' || match.resource?.kind === 'environment' ? [match.displayLabel, match.rawValue, match.searchText] : [match.displayLabel] const searchableText = searchable.filter(Boolean).join(' ') return normalize(searchableText).includes(normalize(trimmedQuery)) }