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
370 lines
9.9 KiB
TypeScript
370 lines
9.9 KiB
TypeScript
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);
|
||
}
|