@talex-touch/utils 1.0.38 → 1.0.40
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/core-box/tuff/tuff-dsl.ts +69 -55
- package/package.json +1 -1
- package/plugin/index.ts +5 -0
- package/plugin/sdk/README.md +54 -6
- package/plugin/sdk/clipboard.ts +198 -27
- package/plugin/sdk/enum/bridge-event.ts +1 -0
- package/plugin/sdk/feature-sdk.ts +85 -6
- package/plugin/sdk/flow.ts +246 -0
- package/plugin/sdk/hooks/bridge.ts +18 -1
- package/plugin/sdk/index.ts +2 -0
- package/plugin/sdk/performance.ts +201 -0
- package/plugin/widget.ts +5 -0
- package/renderer/storage/base-storage.ts +98 -15
- package/renderer/storage/storage-subscription.ts +17 -9
- package/search/fuzzy-match.ts +254 -0
- package/types/flow.ts +283 -0
- package/types/index.ts +1 -0
|
@@ -113,6 +113,15 @@ export function createStorageProxy<T extends object>(key: string, factory: () =>
|
|
|
113
113
|
})
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Save result from main process
|
|
118
|
+
*/
|
|
119
|
+
export interface SaveResult {
|
|
120
|
+
success: boolean
|
|
121
|
+
version: number
|
|
122
|
+
conflict?: boolean
|
|
123
|
+
}
|
|
124
|
+
|
|
116
125
|
/**
|
|
117
126
|
* A reactive storage utility with optional auto-save and update subscriptions.
|
|
118
127
|
*
|
|
@@ -127,6 +136,8 @@ export class TouchStorage<T extends object> {
|
|
|
127
136
|
private readonly _onUpdate: Array<() => void> = []
|
|
128
137
|
#channelInitialized = false
|
|
129
138
|
#skipNextWatchTrigger = false
|
|
139
|
+
#currentVersion = 0
|
|
140
|
+
#isRemoteUpdate = false
|
|
130
141
|
|
|
131
142
|
/**
|
|
132
143
|
* The reactive data exposed to users.
|
|
@@ -189,17 +200,29 @@ export class TouchStorage<T extends object> {
|
|
|
189
200
|
|
|
190
201
|
this.#channelInitialized = true
|
|
191
202
|
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
203
|
+
// Try to get versioned data first, fallback to legacy
|
|
204
|
+
const versionedResult = channel!.sendSync('storage:get-versioned', this.#qualifiedName) as { data: Partial<T>, version: number } | null
|
|
205
|
+
if (versionedResult) {
|
|
206
|
+
this.#currentVersion = versionedResult.version
|
|
207
|
+
this.assignData(versionedResult.data, true, true)
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const result = channel!.sendSync('storage:get', this.#qualifiedName)
|
|
211
|
+
const parsed = result ? (result as Partial<T>) : {}
|
|
212
|
+
this.#currentVersion = 1
|
|
213
|
+
this.assignData(parsed, true, true)
|
|
214
|
+
}
|
|
196
215
|
|
|
197
|
-
// Register update listener
|
|
216
|
+
// Register update listener - only triggered for OTHER windows' changes
|
|
217
|
+
// (source window is excluded by main process)
|
|
198
218
|
channel!.regChannel('storage:update', ({ data }) => {
|
|
199
|
-
const { name } = data
|
|
219
|
+
const { name, version } = data as { name: string, version?: number }
|
|
200
220
|
|
|
201
221
|
if (name === this.#qualifiedName) {
|
|
202
|
-
|
|
222
|
+
// Only reload if remote version is newer
|
|
223
|
+
if (version === undefined || version > this.#currentVersion) {
|
|
224
|
+
this.#loadFromRemoteWithVersion()
|
|
225
|
+
}
|
|
203
226
|
}
|
|
204
227
|
})
|
|
205
228
|
|
|
@@ -209,6 +232,24 @@ export class TouchStorage<T extends object> {
|
|
|
209
232
|
}
|
|
210
233
|
}
|
|
211
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Load from remote and update version
|
|
237
|
+
* @private
|
|
238
|
+
*/
|
|
239
|
+
#loadFromRemoteWithVersion(): void {
|
|
240
|
+
if (!channel)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
const versionedResult = channel.sendSync('storage:get-versioned', this.#qualifiedName) as { data: Partial<T>, version: number } | null
|
|
244
|
+
if (versionedResult && versionedResult.version > this.#currentVersion) {
|
|
245
|
+
this.#currentVersion = versionedResult.version
|
|
246
|
+
// Mark as remote update to skip auto-save
|
|
247
|
+
this.#isRemoteUpdate = true
|
|
248
|
+
this.assignData(versionedResult.data, true, true)
|
|
249
|
+
this.#isRemoteUpdate = false
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
212
253
|
/**
|
|
213
254
|
* Returns the unique identifier of this storage.
|
|
214
255
|
*
|
|
@@ -253,11 +294,26 @@ export class TouchStorage<T extends object> {
|
|
|
253
294
|
return
|
|
254
295
|
}
|
|
255
296
|
|
|
256
|
-
|
|
297
|
+
// Skip save if this is a remote update (to avoid echo)
|
|
298
|
+
if (this.#isRemoteUpdate) {
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const result = await channel.send('storage:save', {
|
|
257
303
|
key: this.#qualifiedName,
|
|
258
304
|
content: JSON.stringify(this.data),
|
|
259
305
|
clear: false,
|
|
260
|
-
|
|
306
|
+
version: this.#currentVersion,
|
|
307
|
+
}) as SaveResult
|
|
308
|
+
|
|
309
|
+
if (result.success) {
|
|
310
|
+
this.#currentVersion = result.version
|
|
311
|
+
}
|
|
312
|
+
else if (result.conflict) {
|
|
313
|
+
// Conflict detected - reload from remote
|
|
314
|
+
console.warn(`[TouchStorage] Conflict detected for "${this.#qualifiedName}", reloading...`)
|
|
315
|
+
this.#loadFromRemoteWithVersion()
|
|
316
|
+
}
|
|
261
317
|
}, 300)
|
|
262
318
|
|
|
263
319
|
/**
|
|
@@ -364,8 +420,9 @@ export class TouchStorage<T extends object> {
|
|
|
364
420
|
*
|
|
365
421
|
* @param newData Partial update data
|
|
366
422
|
* @param stopWatch Whether to stop the watcher during assignment
|
|
423
|
+
* @param skipSave Whether to skip saving (for remote updates)
|
|
367
424
|
*/
|
|
368
|
-
private assignData(newData: Partial<T>, stopWatch: boolean = true): void {
|
|
425
|
+
private assignData(newData: Partial<T>, stopWatch: boolean = true, skipSave: boolean = false): void {
|
|
369
426
|
if (stopWatch && this.#autoSave) {
|
|
370
427
|
this.#assigning = true
|
|
371
428
|
}
|
|
@@ -385,7 +442,10 @@ export class TouchStorage<T extends object> {
|
|
|
385
442
|
Promise.resolve().then(resetAssigning)
|
|
386
443
|
}
|
|
387
444
|
|
|
388
|
-
|
|
445
|
+
// Only run auto-save pipeline if not a remote update
|
|
446
|
+
if (!skipSave && !this.#isRemoteUpdate) {
|
|
447
|
+
this.#runAutoSavePipeline({ force: true })
|
|
448
|
+
}
|
|
389
449
|
}
|
|
390
450
|
}
|
|
391
451
|
|
|
@@ -444,13 +504,36 @@ export class TouchStorage<T extends object> {
|
|
|
444
504
|
return this
|
|
445
505
|
}
|
|
446
506
|
|
|
447
|
-
|
|
448
|
-
const parsed = result ? (result as Partial<T>) : {}
|
|
449
|
-
this.assignData(parsed, true)
|
|
450
|
-
|
|
507
|
+
this.#loadFromRemoteWithVersion()
|
|
451
508
|
return this
|
|
452
509
|
}
|
|
453
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Get current version number
|
|
513
|
+
* @returns Current version
|
|
514
|
+
*/
|
|
515
|
+
getVersion(): number {
|
|
516
|
+
return this.#currentVersion
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Save data synchronously (for window close)
|
|
521
|
+
* This bypasses debouncing and saves immediately
|
|
522
|
+
*/
|
|
523
|
+
saveSync(): void {
|
|
524
|
+
if (!channel)
|
|
525
|
+
return
|
|
526
|
+
if (this.#isRemoteUpdate)
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
channel.sendSync('storage:save-sync', {
|
|
530
|
+
key: this.#qualifiedName,
|
|
531
|
+
content: JSON.stringify(this.data),
|
|
532
|
+
clear: false,
|
|
533
|
+
version: this.#currentVersion,
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
|
|
454
537
|
/**
|
|
455
538
|
* Gets the current data state.
|
|
456
539
|
*
|
|
@@ -14,6 +14,7 @@ class StorageSubscriptionManager {
|
|
|
14
14
|
private subscribers = new Map<string, Set<StorageSubscriptionCallback>>()
|
|
15
15
|
private channelListenerRegistered = false
|
|
16
16
|
private pendingUpdates = new Map<string, NodeJS.Timeout>()
|
|
17
|
+
private configVersions = new Map<string, number>()
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Initialize the subscription manager with a channel
|
|
@@ -24,8 +25,15 @@ class StorageSubscriptionManager {
|
|
|
24
25
|
if (!this.channelListenerRegistered) {
|
|
25
26
|
// Listen to storage:update events from main process
|
|
26
27
|
this.channel.regChannel('storage:update', ({ data }) => {
|
|
27
|
-
const { name } = data as { name: string }
|
|
28
|
-
|
|
28
|
+
const { name, version } = data as { name: string, version?: number }
|
|
29
|
+
// Only handle update if version is newer or unknown
|
|
30
|
+
const currentVersion = this.configVersions.get(name) ?? 0
|
|
31
|
+
if (version === undefined || version > currentVersion) {
|
|
32
|
+
if (version !== undefined) {
|
|
33
|
+
this.configVersions.set(name, version)
|
|
34
|
+
}
|
|
35
|
+
this.handleStorageUpdate(name)
|
|
36
|
+
}
|
|
29
37
|
})
|
|
30
38
|
this.channelListenerRegistered = true
|
|
31
39
|
}
|
|
@@ -36,13 +44,13 @@ class StorageSubscriptionManager {
|
|
|
36
44
|
* @param configName - The configuration file name (e.g., 'app-setting.ini')
|
|
37
45
|
* @param callback - Callback function to receive updates
|
|
38
46
|
* @returns Unsubscribe function
|
|
39
|
-
*
|
|
47
|
+
*
|
|
40
48
|
* @example
|
|
41
49
|
* ```typescript
|
|
42
50
|
* const unsubscribe = subscribeStorage('app-setting.ini', (data) => {
|
|
43
51
|
* console.log('Config updated:', data)
|
|
44
52
|
* })
|
|
45
|
-
*
|
|
53
|
+
*
|
|
46
54
|
* // Later:
|
|
47
55
|
* unsubscribe()
|
|
48
56
|
* ```
|
|
@@ -155,7 +163,7 @@ const subscriptionManager = new StorageSubscriptionManager()
|
|
|
155
163
|
/**
|
|
156
164
|
* Initialize storage subscription system with channel
|
|
157
165
|
* Must be called before using subscribeStorage
|
|
158
|
-
*
|
|
166
|
+
*
|
|
159
167
|
* @param channel - The storage channel
|
|
160
168
|
*/
|
|
161
169
|
export function initStorageSubscription(channel: IStorageChannel): void {
|
|
@@ -164,19 +172,19 @@ export function initStorageSubscription(channel: IStorageChannel): void {
|
|
|
164
172
|
|
|
165
173
|
/**
|
|
166
174
|
* Subscribe to storage configuration changes
|
|
167
|
-
*
|
|
175
|
+
*
|
|
168
176
|
* @param configName - Configuration file name (e.g., 'app-setting.ini')
|
|
169
177
|
* @param callback - Callback function that receives updated data
|
|
170
178
|
* @returns Unsubscribe function
|
|
171
|
-
*
|
|
179
|
+
*
|
|
172
180
|
* @example
|
|
173
181
|
* ```typescript
|
|
174
182
|
* import { subscribeStorage } from '@talex-touch/utils/renderer/storage/storage-subscription'
|
|
175
|
-
*
|
|
183
|
+
*
|
|
176
184
|
* const unsubscribe = subscribeStorage('app-setting.ini', (data) => {
|
|
177
185
|
* console.log('Settings updated:', data)
|
|
178
186
|
* })
|
|
179
|
-
*
|
|
187
|
+
*
|
|
180
188
|
* // Clean up when no longer needed
|
|
181
189
|
* unsubscribe()
|
|
182
190
|
* ```
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy matching utilities for typo-tolerant search
|
|
3
|
+
* Supports matching queries like "helol" to "hello"
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface FuzzyMatchResult {
|
|
7
|
+
/** Whether the match was successful */
|
|
8
|
+
matched: boolean
|
|
9
|
+
/** Match score (0-1, higher is better) */
|
|
10
|
+
score: number
|
|
11
|
+
/** Indices of matched characters in the target string */
|
|
12
|
+
matchedIndices: number[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Performs fuzzy matching with typo tolerance
|
|
17
|
+
* Uses a combination of subsequence matching and edit distance
|
|
18
|
+
*
|
|
19
|
+
* @param target - The string to match against (e.g., "hello")
|
|
20
|
+
* @param query - The search query (e.g., "helol")
|
|
21
|
+
* @param maxErrors - Maximum allowed errors (default: 2)
|
|
22
|
+
* @returns FuzzyMatchResult with match info and indices
|
|
23
|
+
*/
|
|
24
|
+
export function fuzzyMatch(
|
|
25
|
+
target: string,
|
|
26
|
+
query: string,
|
|
27
|
+
maxErrors = 2
|
|
28
|
+
): FuzzyMatchResult {
|
|
29
|
+
if (!query || !target) {
|
|
30
|
+
return { matched: false, score: 0, matchedIndices: [] }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const targetLower = target.toLowerCase()
|
|
34
|
+
const queryLower = query.toLowerCase()
|
|
35
|
+
|
|
36
|
+
// Exact match - highest score
|
|
37
|
+
if (targetLower === queryLower) {
|
|
38
|
+
return {
|
|
39
|
+
matched: true,
|
|
40
|
+
score: 1,
|
|
41
|
+
matchedIndices: Array.from({ length: target.length }, (_, i) => i)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Substring match
|
|
46
|
+
const substringIndex = targetLower.indexOf(queryLower)
|
|
47
|
+
if (substringIndex !== -1) {
|
|
48
|
+
return {
|
|
49
|
+
matched: true,
|
|
50
|
+
score: 0.95,
|
|
51
|
+
matchedIndices: Array.from({ length: query.length }, (_, i) => substringIndex + i)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Try subsequence matching first (for cases like "vsc" -> "Visual Studio Code")
|
|
56
|
+
const subsequenceResult = subsequenceMatch(targetLower, queryLower)
|
|
57
|
+
if (subsequenceResult.matched && subsequenceResult.matchedIndices.length === queryLower.length) {
|
|
58
|
+
return {
|
|
59
|
+
matched: true,
|
|
60
|
+
score: 0.8 + (subsequenceResult.matchedIndices.length / target.length) * 0.1,
|
|
61
|
+
matchedIndices: subsequenceResult.matchedIndices
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Fuzzy match with edit distance for typo tolerance
|
|
66
|
+
const fuzzyResult = fuzzyMatchWithErrors(targetLower, queryLower, maxErrors)
|
|
67
|
+
if (fuzzyResult.matched) {
|
|
68
|
+
return fuzzyResult
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { matched: false, score: 0, matchedIndices: [] }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Subsequence matching - matches characters in order but not necessarily consecutive
|
|
76
|
+
* e.g., "vsc" matches "Visual Studio Code" at indices [0, 7, 14]
|
|
77
|
+
*/
|
|
78
|
+
function subsequenceMatch(
|
|
79
|
+
target: string,
|
|
80
|
+
query: string
|
|
81
|
+
): { matched: boolean; matchedIndices: number[] } {
|
|
82
|
+
const matchedIndices: number[] = []
|
|
83
|
+
let queryIdx = 0
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
|
|
86
|
+
if (target[i] === query[queryIdx]) {
|
|
87
|
+
matchedIndices.push(i)
|
|
88
|
+
queryIdx++
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
matched: queryIdx === query.length,
|
|
94
|
+
matchedIndices
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fuzzy matching with error tolerance using dynamic programming
|
|
100
|
+
* Finds the best matching substring allowing for insertions, deletions, and substitutions
|
|
101
|
+
*/
|
|
102
|
+
function fuzzyMatchWithErrors(
|
|
103
|
+
target: string,
|
|
104
|
+
query: string,
|
|
105
|
+
maxErrors: number
|
|
106
|
+
): FuzzyMatchResult {
|
|
107
|
+
const m = query.length
|
|
108
|
+
const n = target.length
|
|
109
|
+
|
|
110
|
+
if (m === 0) return { matched: true, score: 1, matchedIndices: [] }
|
|
111
|
+
if (n === 0) return { matched: false, score: 0, matchedIndices: [] }
|
|
112
|
+
|
|
113
|
+
// Allow more errors for longer queries
|
|
114
|
+
const allowedErrors = Math.min(maxErrors, Math.floor(m / 3) + 1)
|
|
115
|
+
|
|
116
|
+
// Find best matching window using sliding window with edit distance
|
|
117
|
+
let bestScore = 0
|
|
118
|
+
let bestStart = -1
|
|
119
|
+
let bestMatchedIndices: number[] = []
|
|
120
|
+
|
|
121
|
+
// Try different window sizes around query length
|
|
122
|
+
const minWindowSize = Math.max(1, m - allowedErrors)
|
|
123
|
+
const maxWindowSize = Math.min(n, m + allowedErrors)
|
|
124
|
+
|
|
125
|
+
for (let windowSize = minWindowSize; windowSize <= maxWindowSize; windowSize++) {
|
|
126
|
+
for (let start = 0; start <= n - windowSize; start++) {
|
|
127
|
+
const window = target.substring(start, start + windowSize)
|
|
128
|
+
const { distance, matchedIndices } = editDistanceWithPath(window, query)
|
|
129
|
+
|
|
130
|
+
if (distance <= allowedErrors) {
|
|
131
|
+
// Calculate score based on edit distance and position
|
|
132
|
+
const score = calculateFuzzyScore(distance, m, start, n)
|
|
133
|
+
|
|
134
|
+
if (score > bestScore) {
|
|
135
|
+
bestScore = score
|
|
136
|
+
bestStart = start
|
|
137
|
+
// Adjust indices to be relative to the full target string
|
|
138
|
+
bestMatchedIndices = matchedIndices.map((i) => start + i)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (bestStart !== -1) {
|
|
145
|
+
return {
|
|
146
|
+
matched: true,
|
|
147
|
+
score: bestScore,
|
|
148
|
+
matchedIndices: bestMatchedIndices
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { matched: false, score: 0, matchedIndices: [] }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Computes edit distance and tracks which characters matched
|
|
157
|
+
*/
|
|
158
|
+
function editDistanceWithPath(
|
|
159
|
+
s1: string,
|
|
160
|
+
s2: string
|
|
161
|
+
): { distance: number; matchedIndices: number[] } {
|
|
162
|
+
const m = s1.length
|
|
163
|
+
const n = s2.length
|
|
164
|
+
|
|
165
|
+
// DP table
|
|
166
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
|
|
167
|
+
|
|
168
|
+
// Initialize
|
|
169
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i
|
|
170
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j
|
|
171
|
+
|
|
172
|
+
// Fill DP table
|
|
173
|
+
for (let i = 1; i <= m; i++) {
|
|
174
|
+
for (let j = 1; j <= n; j++) {
|
|
175
|
+
if (s1[i - 1] === s2[j - 1]) {
|
|
176
|
+
dp[i][j] = dp[i - 1][j - 1]
|
|
177
|
+
} else {
|
|
178
|
+
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Backtrack to find matched indices
|
|
184
|
+
const matchedIndices: number[] = []
|
|
185
|
+
let i = m
|
|
186
|
+
let j = n
|
|
187
|
+
|
|
188
|
+
while (i > 0 && j > 0) {
|
|
189
|
+
if (s1[i - 1] === s2[j - 1]) {
|
|
190
|
+
matchedIndices.unshift(i - 1)
|
|
191
|
+
i--
|
|
192
|
+
j--
|
|
193
|
+
} else if (dp[i - 1][j - 1] <= dp[i - 1][j] && dp[i - 1][j - 1] <= dp[i][j - 1]) {
|
|
194
|
+
// Substitution
|
|
195
|
+
i--
|
|
196
|
+
j--
|
|
197
|
+
} else if (dp[i - 1][j] <= dp[i][j - 1]) {
|
|
198
|
+
// Deletion from s1
|
|
199
|
+
i--
|
|
200
|
+
} else {
|
|
201
|
+
// Insertion into s1
|
|
202
|
+
j--
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { distance: dp[m][n], matchedIndices }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Calculate fuzzy match score based on various factors
|
|
211
|
+
*/
|
|
212
|
+
function calculateFuzzyScore(
|
|
213
|
+
editDistance: number,
|
|
214
|
+
queryLength: number,
|
|
215
|
+
matchStart: number,
|
|
216
|
+
targetLength: number
|
|
217
|
+
): number {
|
|
218
|
+
// Base score from edit distance (0.5 - 0.7 range for fuzzy matches)
|
|
219
|
+
const distanceScore = Math.max(0, 1 - editDistance / queryLength) * 0.3 + 0.4
|
|
220
|
+
|
|
221
|
+
// Bonus for matching at the start
|
|
222
|
+
const positionBonus = matchStart === 0 ? 0.15 : 0
|
|
223
|
+
|
|
224
|
+
// Bonus for shorter targets (more specific matches)
|
|
225
|
+
const lengthBonus = Math.min(0.1, queryLength / targetLength * 0.1)
|
|
226
|
+
|
|
227
|
+
return Math.min(0.75, distanceScore + positionBonus + lengthBonus)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Convert matched indices to Range array for highlighting
|
|
232
|
+
*/
|
|
233
|
+
export function indicesToRanges(indices: number[]): Array<{ start: number; end: number }> {
|
|
234
|
+
if (!indices.length) return []
|
|
235
|
+
|
|
236
|
+
const sorted = Array.from(new Set(indices)).sort((a, b) => a - b)
|
|
237
|
+
const ranges: Array<{ start: number; end: number }> = []
|
|
238
|
+
|
|
239
|
+
let start = sorted[0]
|
|
240
|
+
let end = sorted[0] + 1
|
|
241
|
+
|
|
242
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
243
|
+
if (sorted[i] === end) {
|
|
244
|
+
end++
|
|
245
|
+
} else {
|
|
246
|
+
ranges.push({ start, end })
|
|
247
|
+
start = sorted[i]
|
|
248
|
+
end = sorted[i] + 1
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
ranges.push({ start, end })
|
|
252
|
+
|
|
253
|
+
return ranges
|
|
254
|
+
}
|