import { pinyin } from "pinyin-pro"; const SEARCH_SPLIT_REGEX = /[\s\p{Pd}_/\\|.,,。;;::!!??()()[\]{}<>《》、"'`~·]+/u; const SEARCH_REMOVE_REGEX = /[\s\p{Pd}_/\\|.,,。;;::!!??()()[\]{}<>《》、"'`~·]+/gu; const SEARCH_QUERY_SEGMENT_SPLIT_REGEX = /\s+/u; const SEARCH_QUERY_PUNCT_REGEX = /[\p{Pd}_/\\|.,,。;;::!!??()()[\]{}<>《》、"'`~·]/u; const DASH_SEPARATOR_REGEX = /[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/gu; export const PINYIN_CACHE_MAX_ENTRIES = 16_384; const PINYIN_CACHE = new Map(); const IPV4_LIKE_REGEX = /^\d{1,3}(?:\.\d{1,3})+$/; const HOST_PHASE_SCORE_BONUS = { strict: 1_000_000, loose: 100_000, } as const; const HOST_FIELD_SCORE = { label: 500, hostname: 380, group: 280, tag: 220, } as const; const HOST_METHOD_SCORE = { literal: 50, compact: 25, pinyinFull: 12, pinyinInitials: 8, } as const; function normalizeText(input: string): string { return input.normalize("NFKC").replace(DASH_SEPARATOR_REGEX, "-").toLowerCase().trim(); } function compactText(input: string): string { return normalizeText(input).replace(SEARCH_REMOVE_REGEX, ""); } function getPinyinVariants(sourceText: string): { full: string; initials: string } { const cacheKey = normalizeText(sourceText); const cached = PINYIN_CACHE.get(cacheKey); if (cached) { PINYIN_CACHE.delete(cacheKey); PINYIN_CACHE.set(cacheKey, cached); return cached; } let full = ""; let initials = ""; try { full = compactText( pinyin(sourceText, { toneType: "none", }), ); initials = compactText( pinyin(sourceText, { pattern: "first", toneType: "none", }), ); } catch { // ignore conversion failures } const next = { full, initials }; PINYIN_CACHE.delete(cacheKey); PINYIN_CACHE.set(cacheKey, next); while (PINYIN_CACHE.size > PINYIN_CACHE_MAX_ENTRIES) { const oldestKey = PINYIN_CACHE.keys().next().value as string | undefined; if (oldestKey === undefined) break; PINYIN_CACHE.delete(oldestKey); } return next; } export function resetPinyinCacheForTests(): void { PINYIN_CACHE.clear(); } export function getPinyinCacheStatsForTests(): { size: number; keys: string[] } { return { size: PINYIN_CACHE.size, keys: [...PINYIN_CACHE.keys()] }; } export function tokenizeSearchQuery(query: string): string[] { const normalized = normalizeText(query); if (!normalized) return []; return normalized.split(SEARCH_SPLIT_REGEX).filter(Boolean); } export function matchesSearchQuery( query: string, ...fields: Array ): boolean { const normalizedQuery = normalizeText(query); if (!normalizedQuery) return true; const normalizedFields = fields .filter((field): field is string => typeof field === "string" && field.trim().length > 0) .map((field) => normalizeText(field)); if (normalizedFields.length === 0) return false; // For dotted numeric input (IPv4-like), require contiguous literal match. if (IPV4_LIKE_REGEX.test(normalizedQuery)) { return normalizedFields.some((field) => field.includes(normalizedQuery)); } const tokens = tokenizeSearchQuery(normalizedQuery); if (tokens.length === 0) return false; const sourceText = normalizedFields.join(" "); const haystack = sourceText; if (haystack.includes(normalizedQuery)) { return true; } const haystackCompact = compactText(sourceText); const compactQuery = compactText(normalizedQuery); if (compactQuery && haystackCompact.includes(compactQuery)) { return true; } if (tokens.every((token) => haystack.includes(token))) { return true; } const hasLatinToken = tokens.some((token) => /[a-z]/i.test(token)); if (!hasLatinToken) return false; const { full, initials } = getPinyinVariants(sourceText); if (!full && !initials) return false; return tokens.every((token) => { if (haystack.includes(token)) return true; const compactToken = compactText(token); return ( (full && full.includes(compactToken)) || (initials && initials.includes(compactToken)) ); }); } /** * Host search should avoid mixing label/group tokens with hostname/IP tokens. * Otherwise queries like "山东 6-1" can accidentally match: * - "山东" from group/label * - "6" / "1" from hostname IP * across different fields. */ export function matchesHostSearchQuery( query: string, hostLike: { label?: string | null; hostname?: string | null; group?: string | null; tags?: Array | null; }, ): boolean { return getHostSearchMatch(query, hostLike).matched; } type HostMatchField = "label" | "hostname" | "group" | "tag"; type HostMatchMethod = "literal" | "compact" | "pinyinFull" | "pinyinInitials"; type HostMatchPhase = "strict" | "loose"; export type HostSearchMatchDetail = { segment: string; field: HostMatchField; method: HostMatchMethod; phase: HostMatchPhase; }; export type HostSearchMatchResult = { matched: boolean; phase: HostMatchPhase | "none"; score: number; details: HostSearchMatchDetail[]; }; type HostSearchFieldSource = { field: HostMatchField; allowPinyin: boolean; text: string; compact: string; }; function scoreHostMatch(detail: HostSearchMatchDetail): number { return HOST_FIELD_SCORE[detail.field] + HOST_METHOD_SCORE[detail.method]; } function selectBetterHostMatch( left: HostSearchMatchResult, right: HostSearchMatchResult, ): HostSearchMatchResult { if (!left.matched) return right; if (!right.matched) return left; if (left.score !== right.score) { return left.score > right.score ? left : right; } if (left.details.length !== right.details.length) { return left.details.length < right.details.length ? left : right; } return left; } function matchSegmentAgainstSource( segment: string, source: HostSearchFieldSource, ): HostSearchMatchDetail | null { if (source.text.includes(segment)) { return { segment, field: source.field, method: "literal", phase: "strict", }; } // Keep punctuation semantic (e.g. 6-, 6-1, 10.6.1.8): no compact fallback. if (SEARCH_QUERY_PUNCT_REGEX.test(segment)) return null; const compactSegment = compactText(segment); if ( compactSegment && source.field !== "hostname" && source.compact.includes(compactSegment) ) { return { segment, field: source.field, method: "compact", phase: "loose", }; } if (!source.allowPinyin || !/[a-z]/i.test(segment)) return null; const { full, initials } = getPinyinVariants(source.text); if (full && full.includes(compactSegment)) { return { segment, field: source.field, method: "pinyinFull", phase: "loose", }; } if (initials && initials.includes(compactSegment)) { return { segment, field: source.field, method: "pinyinInitials", phase: "loose", }; } return null; } function evaluateHostFieldGroup( segments: string[], sources: HostSearchFieldSource[], ): HostSearchMatchResult { if (sources.length === 0) { return { matched: false, phase: "none", score: 0, details: [] }; } const details: HostSearchMatchDetail[] = []; let isStrict = true; for (const segment of segments) { let best: HostSearchMatchDetail | null = null; let bestScore = -1; for (const source of sources) { const detail = matchSegmentAgainstSource(segment, source); if (!detail) continue; const score = scoreHostMatch(detail); if (score > bestScore) { best = detail; bestScore = score; } } if (!best) { return { matched: false, phase: "none", score: 0, details: [] }; } if (best.phase !== "strict") { isStrict = false; } details.push(best); } const phase: HostMatchPhase = isStrict ? "strict" : "loose"; const baseScore = details.reduce((sum, detail) => sum + scoreHostMatch(detail), 0); return { matched: true, phase, score: baseScore + HOST_PHASE_SCORE_BONUS[phase], details, }; } export function getHostSearchMatch( query: string, hostLike: { label?: string | null; hostname?: string | null; group?: string | null; tags?: Array | null; }, ): HostSearchMatchResult { const normalizedQuery = normalizeText(query); if (!normalizedQuery) { return { matched: true, phase: "strict", score: HOST_PHASE_SCORE_BONUS.strict, details: [] }; } const querySegments = normalizedQuery .split(SEARCH_QUERY_SEGMENT_SPLIT_REGEX) .map((segment) => segment.trim()) .filter(Boolean); if (querySegments.length === 0) { return { matched: true, phase: "strict", score: HOST_PHASE_SCORE_BONUS.strict, details: [] }; } const label = normalizeText(hostLike.label ?? ""); const group = normalizeText(hostLike.group ?? ""); const hostname = normalizeText(hostLike.hostname ?? ""); const tags = (hostLike.tags ?? []) .filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0) .map((tag) => normalizeText(tag)); const humanSources: HostSearchFieldSource[] = []; if (label) { humanSources.push({ field: "label", allowPinyin: true, text: label, compact: compactText(label), }); } if (group) { humanSources.push({ field: "group", allowPinyin: true, text: group, compact: compactText(group), }); } for (const tag of tags) { humanSources.push({ field: "tag", allowPinyin: true, text: tag, compact: compactText(tag), }); } const networkSources: HostSearchFieldSource[] = hostname ? [{ field: "hostname", allowPinyin: false, text: hostname, compact: compactText(hostname), }] : []; const humanResult = evaluateHostFieldGroup(querySegments, humanSources); const networkResult = evaluateHostFieldGroup(querySegments, networkSources); return selectBetterHostMatch(humanResult, networkResult); }