1import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";
2
3export type ModelPickerSearchableModel = {
4 driverKind: string;
5 providerDisplayName: string;
6 name: string;
7 shortName?: string;
8 subProvider?: string;
9 isFavorite?: boolean;
10};
11
12const MODEL_PICKER_FAVORITE_SCORE_BOOST = 24;
13const MODEL_PICKER_EXACT_NAME_SCORE = -1000;
14
15function getModelPickerSearchFields(model: ModelPickerSearchableModel): string[] {
16 return [
17 normalizeSearchQuery(model.name),
18 ...(model.shortName ? [normalizeSearchQuery(model.shortName)] : []),
19 ...(model.subProvider ? [normalizeSearchQuery(model.subProvider)] : []),
20 normalizeSearchQuery(model.driverKind),
21 normalizeSearchQuery(model.providerDisplayName),
22 buildModelPickerSearchText(model),
23 ];
24}
25
26function scoreModelPickerSearchToken(
27 field: string,
28 token: string,
29 fieldBase: number,
30): number | null {
31 return scoreQueryMatch({
32 value: field,
33 query: token,
34 exactBase: fieldBase,
35 prefixBase: fieldBase + 2,
36 boundaryBase: fieldBase + 4,
37 includesBase: fieldBase + 6,
38 ...(token.length >= 3 ? { fuzzyBase: fieldBase + 100 } : {}),
39 ignorePosition: true,
40 });
41}
42
43function normalizeModelName(name: string): string {
44 return normalizeSearchQuery(name).replace(/\s+/gu, " ");
45}
46
47function buildModelPickerSearchText(model: ModelPickerSearchableModel): string {
48 return normalizeSearchQuery(
49 [model.name, model.shortName, model.subProvider, model.driverKind, model.providerDisplayName]
50 .filter((value): value is string => typeof value === "string" && value.length > 0)
51 .join(" "),
52 );
53}
54
55export function scoreModelPickerSearch(
56 model: ModelPickerSearchableModel,
57 query: string,
58): number | null {
59 const tokens = normalizeSearchQuery(query)
60 .split(/\s+/u)
61 .filter((token) => token.length > 0);
62
63 if (tokens.length === 0) {
64 return 0;
65 }
66
67 const normalizedQuery = tokens.join(" ");
68 if (
69 normalizedQuery === normalizeModelName(model.name) ||
70 normalizedQuery === normalizeModelName(model.shortName ?? "")
71 ) {
72 return MODEL_PICKER_EXACT_NAME_SCORE;
73 }
74
75 const fields = getModelPickerSearchFields(model);
76 let score = 0;
77
78 for (const token of tokens) {
79 const tokenScores: Array<number> = [];
80 for (let index = 0; index < fields.length; index += 1) {
81 const fieldScore = scoreModelPickerSearchToken(fields[index]!, token, index * 10);
82 if (fieldScore !== null) {
83 tokenScores.push(fieldScore);
84 }
85 }
86
87 if (tokenScores.length === 0) {
88 return null;
89 }
90
91 score += Math.min(...tokenScores);
92 }
93
94 return model.isFavorite ? score - MODEL_PICKER_FAVORITE_SCORE_BOOST : score;
95}
96
97export function rankModelPickerSearch<T>(
98 items: ReadonlyArray<T>,
99 query: string,
100 toSearchable: (item: T) => ModelPickerSearchableModel,
101): T[] {
102 const ranked: Array<{
103 item: T;
104 score: number;
105 isFavorite: boolean;
106 order: number;
107 }> = [];
108 items.forEach((item, order) => {
109 const searchable = toSearchable(item);
110 const score = scoreModelPickerSearch(searchable, query);
111 if (score !== null) {
112 ranked.push({
113 item,
114 score,
115 isFavorite: searchable.isFavorite === true,
116 order,
117 });
118 }
119 });
120 return ranked
121 .sort(
122 (a, b) =>
123 a.score - b.score || Number(b.isFavorite) - Number(a.isFavorite) || a.order - b.order,
124 )
125 .map((entry) => entry.item);
126}