@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.
@@ -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
- const result = channel!.sendSync('storage:get', this.#qualifiedName)
193
- const parsed = result ? (result as Partial<T>) : {}
194
-
195
- this.assignData(parsed)
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
- this.loadFromRemote()
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
- await channel.send('storage:save', {
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
- this.#runAutoSavePipeline({ force: true })
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
- const result = channel.sendSync('storage:get', this.#qualifiedName)
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
- this.handleStorageUpdate(name)
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
+ }