dsh-plugin-manager-companion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +144 -0
- package/README.md +142 -0
- package/cordis.patch.yml +9 -0
- package/dist/about.d.ts +77 -0
- package/dist/about.js +179 -0
- package/dist/cli.d.ts +226 -0
- package/dist/cli.js +856 -0
- package/dist/client/AboutPage.d.ts +75 -0
- package/dist/client/ConsolePage.d.ts +79 -0
- package/dist/client/KindsPage.d.ts +21 -0
- package/dist/client/MarketplacePage.d.ts +36 -0
- package/dist/client/OfficialSlots.d.ts +35 -0
- package/dist/client/UpgradeRow.d.ts +108 -0
- package/dist/client/index.d.ts +26 -0
- package/dist/client/locales.d.ts +475 -0
- package/dist/client/pmSelect.d.ts +38 -0
- package/dist/client/shared.d.ts +928 -0
- package/dist/client/upgradeView.d.ts +278 -0
- package/dist/client/wire.d.ts +401 -0
- package/dist/client.js +9194 -0
- package/dist/diagnostics.d.ts +332 -0
- package/dist/diagnostics.js +2631 -0
- package/dist/envManager.d.ts +1047 -0
- package/dist/envManager.js +3214 -0
- package/dist/fix.d.ts +60 -0
- package/dist/fix.js +168 -0
- package/dist/guard.d.ts +133 -0
- package/dist/guard.js +232 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +1150 -0
- package/dist/installSession.d.ts +111 -0
- package/dist/installSession.js +150 -0
- package/dist/kinds.d.ts +464 -0
- package/dist/kinds.js +1029 -0
- package/dist/marketView.d.ts +261 -0
- package/dist/marketView.js +406 -0
- package/dist/marketplace.d.ts +248 -0
- package/dist/marketplace.js +500 -0
- package/dist/match.d.ts +67 -0
- package/dist/match.js +203 -0
- package/dist/net.d.ts +108 -0
- package/dist/net.js +163 -0
- package/dist/official.d.ts +145 -0
- package/dist/official.js +205 -0
- package/dist/paths.d.ts +108 -0
- package/dist/paths.js +236 -0
- package/dist/presets.d.ts +299 -0
- package/dist/presets.js +578 -0
- package/dist/qualityGate.d.ts +66 -0
- package/dist/qualityGate.js +247 -0
- package/dist/rank.d.ts +88 -0
- package/dist/rank.js +164 -0
- package/dist/registry.d.ts +295 -0
- package/dist/registry.js +686 -0
- package/dist/rest.d.ts +122 -0
- package/dist/rest.js +219 -0
- package/dist/scan.d.ts +134 -0
- package/dist/scan.js +396 -0
- package/dist/settings.d.ts +447 -0
- package/dist/settings.js +263 -0
- package/dist/tags.d.ts +119 -0
- package/dist/tags.js +166 -0
- package/dist/tools.d.ts +131 -0
- package/dist/tools.js +377 -0
- package/dist/types.d.ts +651 -0
- package/dist/types.js +13 -0
- package/dist/upgrade.d.ts +428 -0
- package/dist/upgrade.js +1100 -0
- package/dist/upgradeView.d.ts +313 -0
- package/dist/upgradeView.js +273 -0
- package/docs/images/readme/01-console-health.png +0 -0
- package/docs/images/readme/02-console-envs.png +0 -0
- package/docs/images/readme/03-marketplace.png +0 -0
- package/docs/images/readme/04-official-plugin-page.png +0 -0
- package/package.json +104 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* marketView.ts — 市场页的视图决策(**DOM-free 纯函数**:无 fs / 无 ctx / 无网络)。
|
|
3
|
+
*
|
|
4
|
+
* 归属:B 类·参考重写(旧 src/marketView.ts 仅作意图参考,未复制代码;用例语义在 tests/marketplace.test.mjs 重写)。
|
|
5
|
+
* 旧实现参考:dsh-web-plugin-manager/src/marketView.ts(理解意图用,未复制代码)——它解决的问题是:
|
|
6
|
+
* 页面原来把"排什么序、筛掉什么、显示什么文案"写在组件里,于是排序契约无法测试,
|
|
7
|
+
* 方向按钮写着一个方向、列表却在另一个方向上(用户实测反馈),0 星条目还会跑到首屏。
|
|
8
|
+
* 把决策抽到 DOM-free 模块后,测试 import 构建产物即可覆盖排序契约——"被验的代码就是线上跑的代码"。
|
|
9
|
+
* 官方复用:无。官方没有市场概念,也没有排序/筛选模型;本模块的标签数据来自 tags.ts,
|
|
10
|
+
* 渲染由 client 用官方 ui-primitives 完成(这里只产出数据与字典键)。
|
|
11
|
+
* 前提检查:旧实现的两条教训继续成立并被固化进实现:
|
|
12
|
+
* 1) **升序极化 + 单一 descending 取反**:四个模式共用同一套比较器,绝不混用极性
|
|
13
|
+
* (旧 stars 分支按降序写、又被无条件取反,就是"方向与按钮相反"的根因);
|
|
14
|
+
* 2) **已安装是优先级而不是排序键**:任何模式、任何方向都恒在最前,翻转方向不得把它压到底部
|
|
15
|
+
* (旧实现只在 stars 分支里兑现了这件事)。
|
|
16
|
+
* 与旧实现的差异(由新契约决定,非随意变更):
|
|
17
|
+
* - 排序模式由 4 个(stars/az/updated/created)改为 stars/az/updated/category:新 wire 契约
|
|
18
|
+
* (src/types.ts 的 MarketItem)只有 updatedAt 一个时间字段,没有 createdAt,也没有 url/status;
|
|
19
|
+
* 凭空造一个 created 模式就是"扩数据源",被任务明确禁止。category 是索引自带的分类字段,
|
|
20
|
+
* 按它聚拢是筛选器表达不了的浏览顺序。
|
|
21
|
+
* - task-41 起本模块还负责**检索**(prepareMarketSearch / searchMarket):搜索键从"只有名称"
|
|
22
|
+
* 扩到 name + repo + 主题 + 描述,中文走子串(旧实现里中文查询命中恒为 0)。键击预算靠三条
|
|
23
|
+
* 预计算撑住:小写字段、名称掩码(子序列的必要条件)、以及"纯 ASCII 名称 + CJK 查询直接跳过
|
|
24
|
+
* 名称通道"。实测 13,998 条上每键击 1.4–4.1 ms(改动前只搜名称是 2.7–24 ms)。
|
|
25
|
+
* - 未知值(stars=null / updatedAt=null / 无 category)在**默认方向**下永远排在末尾:
|
|
26
|
+
* stars / updated 默认降序,未知取最小值自然落到末尾;category 默认升序,未知显式排在
|
|
27
|
+
* 有分类之后。"无星条目越位到首屏"因此不可能再发生,排序仍是"升序极化 + 单次取反"的纯形状
|
|
28
|
+
* (方向翻转时它们回到队首,那是"反转"的诚实含义)。
|
|
29
|
+
*/
|
|
30
|
+
import type { MarketItem } from './types.ts';
|
|
31
|
+
import { type MarketTag } from './tags.ts';
|
|
32
|
+
import { type CharMask } from './rank.ts';
|
|
33
|
+
/**
|
|
34
|
+
* 排序模式。
|
|
35
|
+
* - `stars`:星数;`trending`:近 7 天 star 增量;`az`:名称;`updated`:最后更新;`category`:上游分类。
|
|
36
|
+
*/
|
|
37
|
+
export type MarketSort = 'stars' | 'trending' | 'az' | 'updated' | 'category';
|
|
38
|
+
/**
|
|
39
|
+
* 工具栏里的模式顺序(也是循环切换的顺序)。
|
|
40
|
+
*
|
|
41
|
+
* `trending` 用上游的 stars_delta_7d(近 7 天增量,12,921 条有值):星数排序对新插件不利
|
|
42
|
+
* (全体星数中位 1、6,466 条 0 星),"最近在涨"是另一件事。政策规定它**只做排序、不做徽标**
|
|
43
|
+
* (7,735 条增量为 0,做成徽标就是噪声)。
|
|
44
|
+
*/
|
|
45
|
+
export declare const SORT_MODES: readonly MarketSort[];
|
|
46
|
+
/**
|
|
47
|
+
* 每个模式的**默认方向**(切换模式时套用;用户按方向按钮可再翻转)。
|
|
48
|
+
* 星数/时间读"越大越前",名称读 A→Z,分类读 A→Z。
|
|
49
|
+
*/
|
|
50
|
+
export declare const SORT_DEFAULT_DESCENDING: Readonly<Record<MarketSort, boolean>>;
|
|
51
|
+
/** 该模式的默认方向。 */
|
|
52
|
+
export declare function defaultDescendingFor(sort: MarketSort): boolean;
|
|
53
|
+
/** "全部分类"的选项值(上游不存在空分类 id,所以空串是安全的哨兵值)。 */
|
|
54
|
+
export declare const ALL_CATEGORIES = "";
|
|
55
|
+
/** 分类筛选下拉里被钉在最后的兜底分类(上游分类器的大杂烩桶)。 */
|
|
56
|
+
export declare const CATCH_ALL_CATEGORY = "other";
|
|
57
|
+
/** 本模块产出的字典键(client 侧字典必须有这些键;文案不在这里硬编码)。 */
|
|
58
|
+
export type MarketLabelKey = 'sortStars' | 'sortTrending' | 'sortAz' | 'sortUpdated' | 'sortCategory' | 'sortAsc' | 'sortDesc' | 'filterCategory' | 'typeCordisPlugin' | 'typeSkill' | 'typeAgentPreset' | 'statusVerified' | 'statusArchived' | 'statusPending' | 'securityLow' | 'securityMedium' | 'securityHigh' | 'securityUnknown';
|
|
59
|
+
/** 模式的字典键。 */
|
|
60
|
+
export declare function sortLabelKey(sort: MarketSort): MarketLabelKey;
|
|
61
|
+
/** 安装形态的字典键。 */
|
|
62
|
+
export declare function typeLabelKey(kind: string | undefined): MarketLabelKey;
|
|
63
|
+
/** 安全风险等级的字典键(无法识别的等级走"未知",绝不折叠成 low)。 */
|
|
64
|
+
export declare function securityLabelKey(riskLevel: string): MarketLabelKey;
|
|
65
|
+
/** 策展状态的字典键。 */
|
|
66
|
+
export declare function statusLabelKey(status: string): MarketLabelKey;
|
|
67
|
+
/** 比较器结果 → 升序/降序标签。 */
|
|
68
|
+
export type SortDirection = 'asc' | 'desc';
|
|
69
|
+
/** ISO 时间戳升序;未知按空串(= 最小值,降序时落到末尾)。 */
|
|
70
|
+
export declare function compareStamp(left: string | null, right: string | null): number;
|
|
71
|
+
/**
|
|
72
|
+
* 模式的**主键**比较,四个分支一律升序极化(正数 = 升序时更靠后)。
|
|
73
|
+
* 方向由 {@link rowComparator} 用同一个 descending 标志统一施加——绝不在分支里混用极性。
|
|
74
|
+
*/
|
|
75
|
+
export declare function compareByMode(left: MarketItem, right: MarketItem, sort: MarketSort): number;
|
|
76
|
+
/**
|
|
77
|
+
* 并列时的 tie-break:名称 → repo,**两个方向都保持升序**。
|
|
78
|
+
*
|
|
79
|
+
* 保持升序是刻意的:13k 条目里星数并列是常态,翻转 tie-break 会把整条 0 星尾巴按 Z→A 倒过来,
|
|
80
|
+
* 没有任何用户价值。它同时保证全序(repo 在索引里是唯一键;即便相同,`compareCodeUnits`
|
|
81
|
+
* 也让结果只依赖值而不依赖输入顺序,剩余并列由 {@link sortRows} 的稳定排序与输入顺序决定)。
|
|
82
|
+
*/
|
|
83
|
+
export declare function compareTie(left: MarketItem, right: MarketItem): number;
|
|
84
|
+
/**
|
|
85
|
+
* 一个 (模式, 方向) 组合的比较器。
|
|
86
|
+
*
|
|
87
|
+
* @param sort - 排序模式。
|
|
88
|
+
* @param descending - 主键方向;tie-break 不受它影响(见 {@link compareTie})。
|
|
89
|
+
* @returns 比较器;installed 恒在最前,与模式、方向都无关。
|
|
90
|
+
*/
|
|
91
|
+
export declare function rowComparator(sort: MarketSort, descending: boolean): (left: MarketItem, right: MarketItem) => number;
|
|
92
|
+
/**
|
|
93
|
+
* 排序(不修改入参)。
|
|
94
|
+
*
|
|
95
|
+
* @param items - 条目。
|
|
96
|
+
* @param sort - 排序模式。
|
|
97
|
+
* @param descending - 主键方向。
|
|
98
|
+
* @returns 新数组。
|
|
99
|
+
*/
|
|
100
|
+
export declare function sortRows(items: readonly MarketItem[], sort: MarketSort, descending: boolean): MarketItem[];
|
|
101
|
+
/**
|
|
102
|
+
* 状态筛选:全部 / 只看已安装 / 只看可更新。
|
|
103
|
+
*
|
|
104
|
+
* 「可更新」用与卡片徽标同一个判据({@link updateAvailable}),不在第二处重新判断"什么算可更新"。
|
|
105
|
+
*/
|
|
106
|
+
export type MarketStateFilter = 'all' | 'installed' | 'updatable';
|
|
107
|
+
/** 状态筛选的选项顺序(工具栏顺序)。 */
|
|
108
|
+
export declare const STATE_FILTERS: readonly MarketStateFilter[];
|
|
109
|
+
/**
|
|
110
|
+
* 状态筛选。
|
|
111
|
+
*
|
|
112
|
+
* @param items - 条目。
|
|
113
|
+
* @param filter - 选中的状态;`all` 原样返回。
|
|
114
|
+
* @returns 筛后的条目(不筛时原样返回入参)。
|
|
115
|
+
*/
|
|
116
|
+
export declare function filterByState(items: readonly MarketItem[], filter: MarketStateFilter): readonly MarketItem[];
|
|
117
|
+
/**
|
|
118
|
+
* 上游可装性过滤(政策 §3.2 第 1 条)。
|
|
119
|
+
*
|
|
120
|
+
* **只过滤一种**:installable = non-plugin(上游明说"不是插件",1,018 条,样本里有 96,949★ 的蹭话题仓库)。
|
|
121
|
+
* 其余一律照常显示——上游没标记(12,256 条)不等于"可一键安装",我们**不替上游补这个结论**;
|
|
122
|
+
* installable = manual 也不隐藏,它由「需手动安装」徽标如实说明。
|
|
123
|
+
*
|
|
124
|
+
* 过滤是**展示决策**,所以放在客户端:REST 结果里这些条目仍然在,host 从不替用户做取舍。
|
|
125
|
+
*
|
|
126
|
+
* @param items - 条目。
|
|
127
|
+
* @param includeNonPlugin - 用户是否主动打开「显示非插件条目」。
|
|
128
|
+
* @returns 筛后的条目。
|
|
129
|
+
*/
|
|
130
|
+
export declare function filterInstallable(items: readonly MarketItem[], includeNonPlugin: boolean): readonly MarketItem[];
|
|
131
|
+
/**
|
|
132
|
+
* 分类筛选。
|
|
133
|
+
*
|
|
134
|
+
* 精确匹配(两侧都 trim):选项 id 来自 categoryOptions(已 trim),
|
|
135
|
+
* 上游偶尔带空格的条目也必须能被自己的分类找到;空分类条目**不会**被混进任何桶。
|
|
136
|
+
*
|
|
137
|
+
* @param items - 条目。
|
|
138
|
+
* @param category - 分类 id;{@link ALL_CATEGORIES} 表示不筛。
|
|
139
|
+
* @returns 筛后的条目(不筛时原样返回入参)。
|
|
140
|
+
*/
|
|
141
|
+
export declare function filterByCategory(items: readonly MarketItem[], category: string): readonly MarketItem[];
|
|
142
|
+
/**
|
|
143
|
+
* 分类下拉的顺序:计数降序 → id 升序,兜底分类钉在最后。
|
|
144
|
+
*
|
|
145
|
+
* "兜底分类最后"是有意的:它是分类器的 catch-all 桶、体量最大,按计数排会永远占住菜单第一项,
|
|
146
|
+
* 把真正的功能分类挤下去。
|
|
147
|
+
*
|
|
148
|
+
* @param counts - {@link import('./tags.ts').categoryCounts} 的产出(或 wire 上的 categories)。
|
|
149
|
+
* @returns `{id, count}` 列表。
|
|
150
|
+
*/
|
|
151
|
+
export declare function categoryOptions(counts: Readonly<Record<string, number>>): {
|
|
152
|
+
readonly id: string;
|
|
153
|
+
readonly count: number;
|
|
154
|
+
}[];
|
|
155
|
+
/**
|
|
156
|
+
* 工具栏模型:按钮必须显示的字典键。
|
|
157
|
+
*
|
|
158
|
+
* `direction` 由**比较器用的同一个标志**推导,因此按钮永远不会宣称一个列表不在的方向
|
|
159
|
+
* (这是旧仓库被用户实测反馈过的缺陷面,tests/marketplace.test.mjs 对 8 个组合逐一断言)。
|
|
160
|
+
*/
|
|
161
|
+
export interface MarketToolbarModel {
|
|
162
|
+
readonly sort: MarketSort;
|
|
163
|
+
readonly descending: boolean;
|
|
164
|
+
readonly direction: SortDirection;
|
|
165
|
+
readonly sortOptions: readonly MarketSort[];
|
|
166
|
+
readonly sortLabelKey: MarketLabelKey;
|
|
167
|
+
readonly directionLabelKey: MarketLabelKey;
|
|
168
|
+
readonly category: string;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* 构建工具栏模型。
|
|
172
|
+
*
|
|
173
|
+
* @param sort - 当前模式。
|
|
174
|
+
* @param descending - 当前方向(必须与传给 {@link sortRows} 的是同一个值)。
|
|
175
|
+
* @param category - 当前分类(默认全部)。
|
|
176
|
+
* @returns 模型。
|
|
177
|
+
*/
|
|
178
|
+
export declare function marketToolbarModel(sort: MarketSort, descending: boolean, category?: string): MarketToolbarModel;
|
|
179
|
+
/**
|
|
180
|
+
* 是否有可用更新:已安装 + 两侧版本都可比较 + 已安装版本**严格小于**索引版本。
|
|
181
|
+
*
|
|
182
|
+
* 严格小于是有意的:仓库回滚(索引版本变旧)不该被报成"可更新"。
|
|
183
|
+
*
|
|
184
|
+
* @param item - 市场条目。
|
|
185
|
+
* @returns 是否存在更新。
|
|
186
|
+
*/
|
|
187
|
+
export declare function updateAvailable(item: MarketItem): boolean;
|
|
188
|
+
/** 一个条目的标签(每次列表构建一次,作为卡片的 memo 依赖)。 */
|
|
189
|
+
export declare function tagsOf(item: MarketItem): readonly MarketTag[];
|
|
190
|
+
/**
|
|
191
|
+
* 一条**预备好检索**的条目:原条目 + 预计算的小写字段。
|
|
192
|
+
*
|
|
193
|
+
* 为什么要预备:14,000 条 × 4 字段若每键击都 toLowerCase 一遍,键击预算直接爆掉
|
|
194
|
+
* (实测只搜名称时是 2.7–24 ms/键击,扩字段后 naive 版本成倍上升)。预备一次(每次列表变化一次),
|
|
195
|
+
* 键击时只剩"扫一遍已小写的串"。
|
|
196
|
+
*/
|
|
197
|
+
export interface MarketSearchEntry {
|
|
198
|
+
readonly item: MarketItem;
|
|
199
|
+
/** 输入顺序:同分时的稳定 tie-break。 */
|
|
200
|
+
readonly index: number;
|
|
201
|
+
/** 小写名称(名称命中优先于字段命中)。 */
|
|
202
|
+
readonly name: string;
|
|
203
|
+
/** 小写 owner/repo。 */
|
|
204
|
+
readonly repo: string;
|
|
205
|
+
/** 小写主题。 */
|
|
206
|
+
readonly topics: readonly string[];
|
|
207
|
+
/** 小写描述。 */
|
|
208
|
+
readonly description: string;
|
|
209
|
+
/** `名称\nrepo\n主题\n描述` 的小写拼接:兜底通道一次扫完。 */
|
|
210
|
+
readonly haystack: string;
|
|
211
|
+
/** haystack 的 ASCII 字母掩码:字段通道的 O(1) 预筛。 */
|
|
212
|
+
readonly mask: CharMask;
|
|
213
|
+
/**
|
|
214
|
+
* **名称**的 ASCII 字母掩码:名称通道的 O(1) 预筛。
|
|
215
|
+
*
|
|
216
|
+
* 必须与 {@link mask} 分开:子序列命中要求 needle 的每个字符都出现在**名称**里,
|
|
217
|
+
* 用整条 haystack 的掩码去筛几乎筛不掉什么(描述里的字符太杂),实测名称模糊那一步
|
|
218
|
+
* 因此要跑 30 ms;换成名称掩码后同一批数据 2.4–4.0 ms,命中集合逐条相同。
|
|
219
|
+
*/
|
|
220
|
+
readonly nameMask: CharMask;
|
|
221
|
+
/**
|
|
222
|
+
* 名称是否只含 ASCII。
|
|
223
|
+
*
|
|
224
|
+
* 用途:中文查询时**整条名称通道可以直接跳过**(一个 CJK 查询字符不可能匹配 ASCII 名称字符,
|
|
225
|
+
* 子序列必然不成立)。不跳的话每条都要跑一次 DP 并分配两行 Float64Array——实测 CJK 查询
|
|
226
|
+
* 因此要 30–56 ms/键击,跳过之后降到个位数(命中集合不变:那些条目本来就命中不了名称)。
|
|
227
|
+
*/
|
|
228
|
+
readonly nameAsciiOnly: boolean;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* 预备检索索引(每次列表/筛选变化调用一次,**不要**放进每键击路径)。
|
|
232
|
+
*
|
|
233
|
+
* @param items - 已按分类/类型筛过的条目。
|
|
234
|
+
* @returns 预备条目(顺序与入参一致)。
|
|
235
|
+
*/
|
|
236
|
+
export declare function prepareMarketSearch(items: readonly MarketItem[]): MarketSearchEntry[];
|
|
237
|
+
/**
|
|
238
|
+
* 检索条目:**名称优先**,再按字段命中(repo / 主题 / 描述)。
|
|
239
|
+
*
|
|
240
|
+
* 语义(每条都有独立的理由):
|
|
241
|
+
* - 空查询返回 null——排序交给调用方的排序契约,搜索不抢排序的活;
|
|
242
|
+
* - 名称通道沿用 rank.ts 的有序子序列模糊匹配(前缀 → 对齐分 → 原顺序),
|
|
243
|
+
* 所以英文名的行为与改动前**逐条一致**;
|
|
244
|
+
* - 字段通道是**子串**匹配(不是子序列):中文没有"子序列"概念,而中文查询在旧实现里
|
|
245
|
+
* 命中数为 0("记忆" 0 / "记忆" 在描述与主题里有 159 条)——这是本次修的核心;
|
|
246
|
+
* - 权重沿用 host 侧 match.ts 的口径(名称 3 / 主题 2 / 描述 1,repo 同名称计 3);
|
|
247
|
+
* - 字段命中排在名称命中之后,并按星数降序(用户搜"memory"时,先看到名字里带 memory 的,
|
|
248
|
+
* 再看到"讲 memory"的,而不是让一个 0 星长尾压过热门条目)。
|
|
249
|
+
*
|
|
250
|
+
* 预算(13,998 条实测,改动前后都量过):
|
|
251
|
+
* - 名称通道用**名称掩码**预筛(不是整条 haystack 的掩码):子序列必须全部落在名称里,
|
|
252
|
+
* 用 haystack 掩码几乎筛不掉东西,实测那一步要 30 ms,换名称掩码后 2.4–4.0 ms 且命中集合不变;
|
|
253
|
+
* - 纯 ASCII 名称遇到 CJK 查询直接跳过名称通道(CJK 字符不可能匹配 ASCII 字符);
|
|
254
|
+
* - 字段通道每个 token 一次 includes,掩码先淘汰,命中后才做几次短串检查定位权重。
|
|
255
|
+
* 合起来:ASCII 查询 1.4–3.5 ms、CJK 查询 1.6–4.1 ms(含两 token 的 "记忆 检索")。
|
|
256
|
+
*
|
|
257
|
+
* @param entries - {@link prepareMarketSearch} 的产物。
|
|
258
|
+
* @param query - 用户输入。
|
|
259
|
+
* @returns 命中条目(已排好序);空查询返回 null。
|
|
260
|
+
*/
|
|
261
|
+
export declare function searchMarket(entries: readonly MarketSearchEntry[], query: string): MarketItem[] | null;
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* marketView.ts — 市场页的视图决策(**DOM-free 纯函数**:无 fs / 无 ctx / 无网络)。
|
|
3
|
+
*
|
|
4
|
+
* 归属:B 类·参考重写(旧 src/marketView.ts 仅作意图参考,未复制代码;用例语义在 tests/marketplace.test.mjs 重写)。
|
|
5
|
+
* 旧实现参考:dsh-web-plugin-manager/src/marketView.ts(理解意图用,未复制代码)——它解决的问题是:
|
|
6
|
+
* 页面原来把"排什么序、筛掉什么、显示什么文案"写在组件里,于是排序契约无法测试,
|
|
7
|
+
* 方向按钮写着一个方向、列表却在另一个方向上(用户实测反馈),0 星条目还会跑到首屏。
|
|
8
|
+
* 把决策抽到 DOM-free 模块后,测试 import 构建产物即可覆盖排序契约——"被验的代码就是线上跑的代码"。
|
|
9
|
+
* 官方复用:无。官方没有市场概念,也没有排序/筛选模型;本模块的标签数据来自 tags.ts,
|
|
10
|
+
* 渲染由 client 用官方 ui-primitives 完成(这里只产出数据与字典键)。
|
|
11
|
+
* 前提检查:旧实现的两条教训继续成立并被固化进实现:
|
|
12
|
+
* 1) **升序极化 + 单一 descending 取反**:四个模式共用同一套比较器,绝不混用极性
|
|
13
|
+
* (旧 stars 分支按降序写、又被无条件取反,就是"方向与按钮相反"的根因);
|
|
14
|
+
* 2) **已安装是优先级而不是排序键**:任何模式、任何方向都恒在最前,翻转方向不得把它压到底部
|
|
15
|
+
* (旧实现只在 stars 分支里兑现了这件事)。
|
|
16
|
+
* 与旧实现的差异(由新契约决定,非随意变更):
|
|
17
|
+
* - 排序模式由 4 个(stars/az/updated/created)改为 stars/az/updated/category:新 wire 契约
|
|
18
|
+
* (src/types.ts 的 MarketItem)只有 updatedAt 一个时间字段,没有 createdAt,也没有 url/status;
|
|
19
|
+
* 凭空造一个 created 模式就是"扩数据源",被任务明确禁止。category 是索引自带的分类字段,
|
|
20
|
+
* 按它聚拢是筛选器表达不了的浏览顺序。
|
|
21
|
+
* - task-41 起本模块还负责**检索**(prepareMarketSearch / searchMarket):搜索键从"只有名称"
|
|
22
|
+
* 扩到 name + repo + 主题 + 描述,中文走子串(旧实现里中文查询命中恒为 0)。键击预算靠三条
|
|
23
|
+
* 预计算撑住:小写字段、名称掩码(子序列的必要条件)、以及"纯 ASCII 名称 + CJK 查询直接跳过
|
|
24
|
+
* 名称通道"。实测 13,998 条上每键击 1.4–4.1 ms(改动前只搜名称是 2.7–24 ms)。
|
|
25
|
+
* - 未知值(stars=null / updatedAt=null / 无 category)在**默认方向**下永远排在末尾:
|
|
26
|
+
* stars / updated 默认降序,未知取最小值自然落到末尾;category 默认升序,未知显式排在
|
|
27
|
+
* 有分类之后。"无星条目越位到首屏"因此不可能再发生,排序仍是"升序极化 + 单次取反"的纯形状
|
|
28
|
+
* (方向翻转时它们回到队首,那是"反转"的诚实含义)。
|
|
29
|
+
*/
|
|
30
|
+
import { buildMarketTags } from "./tags.js";
|
|
31
|
+
import { compareVersions, tokenize } from "./match.js";
|
|
32
|
+
import { charMask, fuzzyScoreLowered } from "./rank.js";
|
|
33
|
+
/**
|
|
34
|
+
* 工具栏里的模式顺序(也是循环切换的顺序)。
|
|
35
|
+
*
|
|
36
|
+
* `trending` 用上游的 stars_delta_7d(近 7 天增量,12,921 条有值):星数排序对新插件不利
|
|
37
|
+
* (全体星数中位 1、6,466 条 0 星),"最近在涨"是另一件事。政策规定它**只做排序、不做徽标**
|
|
38
|
+
* (7,735 条增量为 0,做成徽标就是噪声)。
|
|
39
|
+
*/
|
|
40
|
+
export const SORT_MODES = ['stars', 'trending', 'az', 'updated', 'category'];
|
|
41
|
+
/**
|
|
42
|
+
* 每个模式的**默认方向**(切换模式时套用;用户按方向按钮可再翻转)。
|
|
43
|
+
* 星数/时间读"越大越前",名称读 A→Z,分类读 A→Z。
|
|
44
|
+
*/
|
|
45
|
+
export const SORT_DEFAULT_DESCENDING = {
|
|
46
|
+
stars: true,
|
|
47
|
+
trending: true,
|
|
48
|
+
az: false,
|
|
49
|
+
updated: true,
|
|
50
|
+
category: false,
|
|
51
|
+
};
|
|
52
|
+
/** 该模式的默认方向。 */
|
|
53
|
+
export function defaultDescendingFor(sort) {
|
|
54
|
+
return SORT_DEFAULT_DESCENDING[sort];
|
|
55
|
+
}
|
|
56
|
+
/** "全部分类"的选项值(上游不存在空分类 id,所以空串是安全的哨兵值)。 */
|
|
57
|
+
export const ALL_CATEGORIES = '';
|
|
58
|
+
/** 分类筛选下拉里被钉在最后的兜底分类(上游分类器的大杂烩桶)。 */
|
|
59
|
+
export const CATCH_ALL_CATEGORY = 'other';
|
|
60
|
+
/** 模式的字典键。 */
|
|
61
|
+
export function sortLabelKey(sort) {
|
|
62
|
+
switch (sort) {
|
|
63
|
+
case 'trending': return 'sortTrending';
|
|
64
|
+
case 'az': return 'sortAz';
|
|
65
|
+
case 'updated': return 'sortUpdated';
|
|
66
|
+
case 'category': return 'sortCategory';
|
|
67
|
+
default: return 'sortStars';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** 安装形态的字典键。 */
|
|
71
|
+
export function typeLabelKey(kind) {
|
|
72
|
+
if (kind === 'skill')
|
|
73
|
+
return 'typeSkill';
|
|
74
|
+
if (kind === 'agent-preset')
|
|
75
|
+
return 'typeAgentPreset';
|
|
76
|
+
return 'typeCordisPlugin';
|
|
77
|
+
}
|
|
78
|
+
/** 安全风险等级的字典键(无法识别的等级走"未知",绝不折叠成 low)。 */
|
|
79
|
+
export function securityLabelKey(riskLevel) {
|
|
80
|
+
switch (riskLevel.trim().toLowerCase()) {
|
|
81
|
+
case 'low': return 'securityLow';
|
|
82
|
+
case 'medium': return 'securityMedium';
|
|
83
|
+
case 'high': return 'securityHigh';
|
|
84
|
+
case 'critical': return 'securityHigh';
|
|
85
|
+
default: return 'securityUnknown';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** 策展状态的字典键。 */
|
|
89
|
+
export function statusLabelKey(status) {
|
|
90
|
+
if (status.includes('✅'))
|
|
91
|
+
return 'statusVerified';
|
|
92
|
+
return status.toLowerCase().includes('archiv') ? 'statusArchived' : 'statusPending';
|
|
93
|
+
}
|
|
94
|
+
/** 共享 ICU collator;null 表示环境不支持(退化到码元序)。惰性创建:host 侧也会 import 本模块。 */
|
|
95
|
+
let collator;
|
|
96
|
+
/** 码元序:确定、与环境无关,用作 collator 不可用时的回退,以及所有并列时的最终 tie-break。 */
|
|
97
|
+
function compareCodeUnits(left, right) {
|
|
98
|
+
if (left === right)
|
|
99
|
+
return 0;
|
|
100
|
+
return left < right ? -1 : 1;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 文本比较(名称用)。
|
|
104
|
+
*
|
|
105
|
+
* 用固定的 `en` + `numeric` 选项创建一次 collator 并复用:`localeCompare` 每次调用都会
|
|
106
|
+
* 新建 collator(旧 perf 审计 §2 量化过这个开销),而且默认 locale 随环境变化、结果不稳定。
|
|
107
|
+
* `numeric: true` 让 `plugin-2` 排在 `plugin-10` 前面(人读的顺序)。
|
|
108
|
+
*/
|
|
109
|
+
function compareText(left, right) {
|
|
110
|
+
if (collator === undefined) {
|
|
111
|
+
try {
|
|
112
|
+
collator = new Intl.Collator('en', { numeric: true });
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
collator = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return collator === null ? compareCodeUnits(left, right) : collator.compare(left, right);
|
|
119
|
+
}
|
|
120
|
+
/** 星数排序键:未知按 0(= 该模式的最小值)。 */
|
|
121
|
+
function starsOf(item) {
|
|
122
|
+
return item.stars ?? 0;
|
|
123
|
+
}
|
|
124
|
+
/** ISO 时间戳升序;未知按空串(= 最小值,降序时落到末尾)。 */
|
|
125
|
+
export function compareStamp(left, right) {
|
|
126
|
+
return compareCodeUnits(left ?? '', right ?? '');
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 模式的**主键**比较,四个分支一律升序极化(正数 = 升序时更靠后)。
|
|
130
|
+
* 方向由 {@link rowComparator} 用同一个 descending 标志统一施加——绝不在分支里混用极性。
|
|
131
|
+
*/
|
|
132
|
+
export function compareByMode(left, right, sort) {
|
|
133
|
+
switch (sort) {
|
|
134
|
+
case 'az':
|
|
135
|
+
return compareText(left.name, right.name);
|
|
136
|
+
case 'updated':
|
|
137
|
+
return compareStamp(left.updatedAt, right.updatedAt);
|
|
138
|
+
case 'trending':
|
|
139
|
+
// 近 7 天 star 增量;增量为 0 与"上游没给"在这里同值——政策只把它当排序维度,
|
|
140
|
+
// 不承担"有没有数据"的表达(60% 为 0,做成徽标就是噪声)。
|
|
141
|
+
return (left.starsDelta7d ?? 0) - (right.starsDelta7d ?? 0);
|
|
142
|
+
case 'category': {
|
|
143
|
+
const leftCategory = (left.category ?? '').trim();
|
|
144
|
+
const rightCategory = (right.category ?? '').trim();
|
|
145
|
+
if (leftCategory === rightCategory)
|
|
146
|
+
return 0;
|
|
147
|
+
// 无分类的条目排在有分类的之后(本模式默认升序):未知值不占首屏。
|
|
148
|
+
if (leftCategory.length === 0)
|
|
149
|
+
return 1;
|
|
150
|
+
if (rightCategory.length === 0)
|
|
151
|
+
return -1;
|
|
152
|
+
return compareCodeUnits(leftCategory, rightCategory);
|
|
153
|
+
}
|
|
154
|
+
default:
|
|
155
|
+
return starsOf(left) - starsOf(right);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 并列时的 tie-break:名称 → repo,**两个方向都保持升序**。
|
|
160
|
+
*
|
|
161
|
+
* 保持升序是刻意的:13k 条目里星数并列是常态,翻转 tie-break 会把整条 0 星尾巴按 Z→A 倒过来,
|
|
162
|
+
* 没有任何用户价值。它同时保证全序(repo 在索引里是唯一键;即便相同,`compareCodeUnits`
|
|
163
|
+
* 也让结果只依赖值而不依赖输入顺序,剩余并列由 {@link sortRows} 的稳定排序与输入顺序决定)。
|
|
164
|
+
*/
|
|
165
|
+
export function compareTie(left, right) {
|
|
166
|
+
return compareText(left.name, right.name) || compareCodeUnits(left.name, right.name) || compareCodeUnits(left.repo, right.repo);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 一个 (模式, 方向) 组合的比较器。
|
|
170
|
+
*
|
|
171
|
+
* @param sort - 排序模式。
|
|
172
|
+
* @param descending - 主键方向;tie-break 不受它影响(见 {@link compareTie})。
|
|
173
|
+
* @returns 比较器;installed 恒在最前,与模式、方向都无关。
|
|
174
|
+
*/
|
|
175
|
+
export function rowComparator(sort, descending) {
|
|
176
|
+
return (left, right) => {
|
|
177
|
+
const priority = Number(right.installed === true) - Number(left.installed === true);
|
|
178
|
+
if (priority !== 0)
|
|
179
|
+
return priority;
|
|
180
|
+
const primary = compareByMode(left, right, sort);
|
|
181
|
+
if (primary !== 0)
|
|
182
|
+
return descending ? -primary : primary;
|
|
183
|
+
return compareTie(left, right);
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* 排序(不修改入参)。
|
|
188
|
+
*
|
|
189
|
+
* @param items - 条目。
|
|
190
|
+
* @param sort - 排序模式。
|
|
191
|
+
* @param descending - 主键方向。
|
|
192
|
+
* @returns 新数组。
|
|
193
|
+
*/
|
|
194
|
+
export function sortRows(items, sort, descending) {
|
|
195
|
+
return [...items].sort(rowComparator(sort, descending));
|
|
196
|
+
}
|
|
197
|
+
/** 状态筛选的选项顺序(工具栏顺序)。 */
|
|
198
|
+
export const STATE_FILTERS = ['all', 'installed', 'updatable'];
|
|
199
|
+
/**
|
|
200
|
+
* 状态筛选。
|
|
201
|
+
*
|
|
202
|
+
* @param items - 条目。
|
|
203
|
+
* @param filter - 选中的状态;`all` 原样返回。
|
|
204
|
+
* @returns 筛后的条目(不筛时原样返回入参)。
|
|
205
|
+
*/
|
|
206
|
+
export function filterByState(items, filter) {
|
|
207
|
+
if (filter === 'all')
|
|
208
|
+
return items;
|
|
209
|
+
if (filter === 'installed')
|
|
210
|
+
return items.filter((item) => item.installed === true);
|
|
211
|
+
return items.filter((item) => updateAvailable(item));
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* 上游可装性过滤(政策 §3.2 第 1 条)。
|
|
215
|
+
*
|
|
216
|
+
* **只过滤一种**:installable = non-plugin(上游明说"不是插件",1,018 条,样本里有 96,949★ 的蹭话题仓库)。
|
|
217
|
+
* 其余一律照常显示——上游没标记(12,256 条)不等于"可一键安装",我们**不替上游补这个结论**;
|
|
218
|
+
* installable = manual 也不隐藏,它由「需手动安装」徽标如实说明。
|
|
219
|
+
*
|
|
220
|
+
* 过滤是**展示决策**,所以放在客户端:REST 结果里这些条目仍然在,host 从不替用户做取舍。
|
|
221
|
+
*
|
|
222
|
+
* @param items - 条目。
|
|
223
|
+
* @param includeNonPlugin - 用户是否主动打开「显示非插件条目」。
|
|
224
|
+
* @returns 筛后的条目。
|
|
225
|
+
*/
|
|
226
|
+
export function filterInstallable(items, includeNonPlugin) {
|
|
227
|
+
if (includeNonPlugin)
|
|
228
|
+
return items;
|
|
229
|
+
return items.filter((item) => item.installable !== 'non-plugin');
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* 分类筛选。
|
|
233
|
+
*
|
|
234
|
+
* 精确匹配(两侧都 trim):选项 id 来自 categoryOptions(已 trim),
|
|
235
|
+
* 上游偶尔带空格的条目也必须能被自己的分类找到;空分类条目**不会**被混进任何桶。
|
|
236
|
+
*
|
|
237
|
+
* @param items - 条目。
|
|
238
|
+
* @param category - 分类 id;{@link ALL_CATEGORIES} 表示不筛。
|
|
239
|
+
* @returns 筛后的条目(不筛时原样返回入参)。
|
|
240
|
+
*/
|
|
241
|
+
export function filterByCategory(items, category) {
|
|
242
|
+
if (category === ALL_CATEGORIES)
|
|
243
|
+
return items;
|
|
244
|
+
const wanted = category.trim();
|
|
245
|
+
return items.filter((item) => (item.category ?? '').trim() === wanted);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* 分类下拉的顺序:计数降序 → id 升序,兜底分类钉在最后。
|
|
249
|
+
*
|
|
250
|
+
* "兜底分类最后"是有意的:它是分类器的 catch-all 桶、体量最大,按计数排会永远占住菜单第一项,
|
|
251
|
+
* 把真正的功能分类挤下去。
|
|
252
|
+
*
|
|
253
|
+
* @param counts - {@link import('./tags.ts').categoryCounts} 的产出(或 wire 上的 categories)。
|
|
254
|
+
* @returns `{id, count}` 列表。
|
|
255
|
+
*/
|
|
256
|
+
export function categoryOptions(counts) {
|
|
257
|
+
const known = [];
|
|
258
|
+
const catchAll = [];
|
|
259
|
+
for (const [rawId, rawCount] of Object.entries(counts)) {
|
|
260
|
+
const id = rawId.trim();
|
|
261
|
+
if (id.length === 0 || !Number.isFinite(rawCount))
|
|
262
|
+
continue;
|
|
263
|
+
const entry = { id, count: rawCount };
|
|
264
|
+
if (id.toLowerCase() === CATCH_ALL_CATEGORY)
|
|
265
|
+
catchAll.push(entry);
|
|
266
|
+
else
|
|
267
|
+
known.push(entry);
|
|
268
|
+
}
|
|
269
|
+
known.sort((left, right) => right.count - left.count || compareCodeUnits(left.id, right.id));
|
|
270
|
+
catchAll.sort((left, right) => right.count - left.count || compareCodeUnits(left.id, right.id));
|
|
271
|
+
return [...known, ...catchAll];
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* 构建工具栏模型。
|
|
275
|
+
*
|
|
276
|
+
* @param sort - 当前模式。
|
|
277
|
+
* @param descending - 当前方向(必须与传给 {@link sortRows} 的是同一个值)。
|
|
278
|
+
* @param category - 当前分类(默认全部)。
|
|
279
|
+
* @returns 模型。
|
|
280
|
+
*/
|
|
281
|
+
export function marketToolbarModel(sort, descending, category = ALL_CATEGORIES) {
|
|
282
|
+
return {
|
|
283
|
+
sort,
|
|
284
|
+
descending,
|
|
285
|
+
direction: descending ? 'desc' : 'asc',
|
|
286
|
+
sortOptions: SORT_MODES,
|
|
287
|
+
sortLabelKey: sortLabelKey(sort),
|
|
288
|
+
directionLabelKey: descending ? 'sortDesc' : 'sortAsc',
|
|
289
|
+
category,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* 是否有可用更新:已安装 + 两侧版本都可比较 + 已安装版本**严格小于**索引版本。
|
|
294
|
+
*
|
|
295
|
+
* 严格小于是有意的:仓库回滚(索引版本变旧)不该被报成"可更新"。
|
|
296
|
+
*
|
|
297
|
+
* @param item - 市场条目。
|
|
298
|
+
* @returns 是否存在更新。
|
|
299
|
+
*/
|
|
300
|
+
export function updateAvailable(item) {
|
|
301
|
+
if (item.installed !== true)
|
|
302
|
+
return false;
|
|
303
|
+
const installed = item.installedVersion;
|
|
304
|
+
const latest = item.latestVersion;
|
|
305
|
+
if (installed === undefined || latest === undefined)
|
|
306
|
+
return false;
|
|
307
|
+
return compareVersions(installed, latest) < 0;
|
|
308
|
+
}
|
|
309
|
+
/** 一个条目的标签(每次列表构建一次,作为卡片的 memo 依赖)。 */
|
|
310
|
+
export function tagsOf(item) {
|
|
311
|
+
return buildMarketTags(item);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* 预备检索索引(每次列表/筛选变化调用一次,**不要**放进每键击路径)。
|
|
315
|
+
*
|
|
316
|
+
* @param items - 已按分类/类型筛过的条目。
|
|
317
|
+
* @returns 预备条目(顺序与入参一致)。
|
|
318
|
+
*/
|
|
319
|
+
export function prepareMarketSearch(items) {
|
|
320
|
+
return items.map((item, index) => {
|
|
321
|
+
const name = item.name.toLowerCase();
|
|
322
|
+
const repo = item.repo.toLowerCase();
|
|
323
|
+
const topics = item.topics.map((topic) => topic.toLowerCase());
|
|
324
|
+
const description = item.description.toLowerCase();
|
|
325
|
+
const haystack = name + '\n' + repo + '\n' + topics.join(' ') + '\n' + description;
|
|
326
|
+
return {
|
|
327
|
+
item, index, name, repo, topics, description, haystack,
|
|
328
|
+
mask: charMask(haystack), nameMask: charMask(name), nameAsciiOnly: ASCII_ONLY.test(name),
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
/** 只含 ASCII 的串(用于判断名称通道能否被整条跳过)。 */
|
|
333
|
+
const ASCII_ONLY = /^[\u0000-\u007f]*$/;
|
|
334
|
+
/** 主题命中:短 token(≤3)只认精确相等——反向子串会让 2–3 字母主题命中一堆无关条目。 */
|
|
335
|
+
function topicHit(topics, token) {
|
|
336
|
+
if (token.length <= 3)
|
|
337
|
+
return topics.includes(token);
|
|
338
|
+
for (const topic of topics) {
|
|
339
|
+
if (topic.includes(token))
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* 检索条目:**名称优先**,再按字段命中(repo / 主题 / 描述)。
|
|
346
|
+
*
|
|
347
|
+
* 语义(每条都有独立的理由):
|
|
348
|
+
* - 空查询返回 null——排序交给调用方的排序契约,搜索不抢排序的活;
|
|
349
|
+
* - 名称通道沿用 rank.ts 的有序子序列模糊匹配(前缀 → 对齐分 → 原顺序),
|
|
350
|
+
* 所以英文名的行为与改动前**逐条一致**;
|
|
351
|
+
* - 字段通道是**子串**匹配(不是子序列):中文没有"子序列"概念,而中文查询在旧实现里
|
|
352
|
+
* 命中数为 0("记忆" 0 / "记忆" 在描述与主题里有 159 条)——这是本次修的核心;
|
|
353
|
+
* - 权重沿用 host 侧 match.ts 的口径(名称 3 / 主题 2 / 描述 1,repo 同名称计 3);
|
|
354
|
+
* - 字段命中排在名称命中之后,并按星数降序(用户搜"memory"时,先看到名字里带 memory 的,
|
|
355
|
+
* 再看到"讲 memory"的,而不是让一个 0 星长尾压过热门条目)。
|
|
356
|
+
*
|
|
357
|
+
* 预算(13,998 条实测,改动前后都量过):
|
|
358
|
+
* - 名称通道用**名称掩码**预筛(不是整条 haystack 的掩码):子序列必须全部落在名称里,
|
|
359
|
+
* 用 haystack 掩码几乎筛不掉东西,实测那一步要 30 ms,换名称掩码后 2.4–4.0 ms 且命中集合不变;
|
|
360
|
+
* - 纯 ASCII 名称遇到 CJK 查询直接跳过名称通道(CJK 字符不可能匹配 ASCII 字符);
|
|
361
|
+
* - 字段通道每个 token 一次 includes,掩码先淘汰,命中后才做几次短串检查定位权重。
|
|
362
|
+
* 合起来:ASCII 查询 1.4–3.5 ms、CJK 查询 1.6–4.1 ms(含两 token 的 "记忆 检索")。
|
|
363
|
+
*
|
|
364
|
+
* @param entries - {@link prepareMarketSearch} 的产物。
|
|
365
|
+
* @param query - 用户输入。
|
|
366
|
+
* @returns 命中条目(已排好序);空查询返回 null。
|
|
367
|
+
*/
|
|
368
|
+
export function searchMarket(entries, query) {
|
|
369
|
+
const trimmed = query.trim();
|
|
370
|
+
if (trimmed.length === 0)
|
|
371
|
+
return null;
|
|
372
|
+
const needle = trimmed.toLowerCase();
|
|
373
|
+
const tokens = tokenize(trimmed);
|
|
374
|
+
if (tokens.length === 0)
|
|
375
|
+
return null;
|
|
376
|
+
const needleMask = charMask(needle);
|
|
377
|
+
const needleAsciiOnly = ASCII_ONLY.test(needle);
|
|
378
|
+
const tokenMasks = tokens.map((token) => charMask(token));
|
|
379
|
+
const named = [];
|
|
380
|
+
const fielded = [];
|
|
381
|
+
for (const entry of entries) {
|
|
382
|
+
// 纯 ASCII 名称 + 含非 ASCII 的查询 → 名称通道必然空手,直接跳过(省掉一趟 DP 与两次分配)
|
|
383
|
+
const nameScore = entry.nameAsciiOnly && !needleAsciiOnly
|
|
384
|
+
? null
|
|
385
|
+
: fuzzyScoreLowered(entry.name, needle, entry.nameMask, needleMask);
|
|
386
|
+
if (nameScore !== null) {
|
|
387
|
+
named.push({ entry, score: nameScore, prefix: entry.name.startsWith(needle) });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
let score = 0;
|
|
391
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
392
|
+
const token = tokens[index];
|
|
393
|
+
const tokenMask = tokenMasks[index];
|
|
394
|
+
if (tokenMask !== 0 && (entry.mask & tokenMask) !== tokenMask)
|
|
395
|
+
continue;
|
|
396
|
+
if (!entry.haystack.includes(token))
|
|
397
|
+
continue;
|
|
398
|
+
score += entry.repo.includes(token) ? 3 : topicHit(entry.topics, token) ? 2 : 1;
|
|
399
|
+
}
|
|
400
|
+
if (score > 0)
|
|
401
|
+
fielded.push({ entry, score });
|
|
402
|
+
}
|
|
403
|
+
named.sort((left, right) => Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.entry.index - right.entry.index);
|
|
404
|
+
fielded.sort((left, right) => right.score - left.score || starsOf(right.entry.item) - starsOf(left.entry.item) || left.entry.index - right.entry.index);
|
|
405
|
+
return [...named.map((hit) => hit.entry.item), ...fielded.map((hit) => hit.entry.item)];
|
|
406
|
+
}
|