Files
NetMesh/lib/searchMatcher.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

370 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { full: string; initials: string }>();
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<string | null | undefined>
): 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<string | null | undefined> | 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<string | null | undefined> | 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);
}