SearchIndexService.cs
1 // Copyright (c) Microsoft Corporation 2 // The Microsoft Corporation licenses this file to you under the MIT license. 3 // See the LICENSE file in the project root for more information. 4 5 using System; 6 using System.Collections.Concurrent; 7 using System.Collections.Generic; 8 using System.Collections.Immutable; 9 using System.Diagnostics; 10 using System.Globalization; 11 using System.IO; 12 using System.Linq; 13 using System.Reflection; 14 using System.Text; 15 using System.Text.Json; 16 using System.Threading; 17 using System.Threading.Tasks; 18 using Common.Search.FuzzSearch; 19 using Microsoft.PowerToys.Settings.UI.Helpers; 20 using Microsoft.PowerToys.Settings.UI.Views; 21 using Microsoft.Windows.ApplicationModel.Resources; 22 using Settings.UI.Library; 23 24 namespace Microsoft.PowerToys.Settings.UI.Services 25 { 26 public static class SearchIndexService 27 { 28 private static readonly object _lockObject = new(); 29 private static readonly Dictionary<string, string> _pageNameCache = []; 30 private static readonly Dictionary<string, (string HeaderNorm, string DescNorm)> _normalizedTextCache = new(); 31 private static readonly Dictionary<string, Type> _pageTypeCache = new(); 32 private static ImmutableArray<SettingEntry> _index = []; 33 private static bool _isIndexBuilt; 34 private static bool _isIndexBuilding; 35 private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json"; 36 private static JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true }; 37 38 public static ImmutableArray<SettingEntry> Index 39 { 40 get 41 { 42 lock (_lockObject) 43 { 44 return _index; 45 } 46 } 47 } 48 49 public static bool IsIndexReady 50 { 51 get 52 { 53 lock (_lockObject) 54 { 55 return _isIndexBuilt; 56 } 57 } 58 } 59 60 public static void BuildIndex() 61 { 62 lock (_lockObject) 63 { 64 if (_isIndexBuilt || _isIndexBuilding) 65 { 66 return; 67 } 68 69 _isIndexBuilding = true; 70 71 // Clear caches on rebuild 72 _normalizedTextCache.Clear(); 73 _pageTypeCache.Clear(); 74 } 75 76 try 77 { 78 var builder = ImmutableArray.CreateBuilder<SettingEntry>(); 79 LoadIndexFromPrebuiltData(builder); 80 81 lock (_lockObject) 82 { 83 _index = builder.ToImmutable(); 84 _isIndexBuilt = true; 85 _isIndexBuilding = false; 86 } 87 } 88 catch (Exception ex) 89 { 90 Debug.WriteLine($"[SearchIndexService] CRITICAL ERROR building search index: {ex.Message}\n{ex.StackTrace}"); 91 lock (_lockObject) 92 { 93 _isIndexBuilding = false; 94 _isIndexBuilt = false; 95 } 96 } 97 } 98 99 private static void LoadIndexFromPrebuiltData(ImmutableArray<SettingEntry>.Builder builder) 100 { 101 var assembly = Assembly.GetExecutingAssembly(); 102 var resourceLoader = ResourceLoaderInstance.ResourceLoader; 103 SettingEntry[] metadataList; 104 105 Debug.WriteLine($"[SearchIndexService] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}"); 106 107 try 108 { 109 using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName); 110 if (stream == null) 111 { 112 Debug.WriteLine($"[SearchIndexService] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches."); 113 return; 114 } 115 116 using StreamReader reader = new(stream); 117 string json = reader.ReadToEnd(); 118 if (string.IsNullOrWhiteSpace(json)) 119 { 120 Debug.WriteLine("[SearchIndexService] ERROR: Embedded resource was empty."); 121 return; 122 } 123 124 metadataList = JsonSerializer.Deserialize<SettingEntry[]>(json, _serializerOptions); 125 } 126 catch (Exception ex) 127 { 128 Debug.WriteLine($"[SearchIndexService] ERROR: Failed to load or deserialize prebuilt index: {ex.Message}"); 129 return; 130 } 131 132 if (metadataList == null || metadataList.Length == 0) 133 { 134 Debug.WriteLine("[SearchIndexService] Prebuilt index is empty or deserialization failed."); 135 return; 136 } 137 138 foreach (ref var metadata in metadataList.AsSpan()) 139 { 140 if (metadata.Type == EntryType.SettingsPage) 141 { 142 (metadata.Header, metadata.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, metadata.ElementUid); 143 } 144 else 145 { 146 (metadata.Header, metadata.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, metadata.ElementUid); 147 } 148 149 if (string.IsNullOrEmpty(metadata.Header)) 150 { 151 continue; 152 } 153 154 builder.Add(metadata); 155 156 // Cache the page name mapping for SettingsPage entries 157 if (metadata.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(metadata.Header)) 158 { 159 _pageNameCache[metadata.PageTypeName] = metadata.Header; 160 } 161 } 162 163 Debug.WriteLine($"[SearchIndexService] Finished loading index. Total entries: {builder.Count}"); 164 } 165 166 private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid) 167 { 168 string header = GetString(resourceLoader, $"{elementUid}/Header"); 169 string description = GetString(resourceLoader, $"{elementUid}/Description"); 170 171 if (string.IsNullOrEmpty(header)) 172 { 173 header = GetString(resourceLoader, $"{elementUid}/Content"); 174 } 175 176 return (header, description); 177 } 178 179 private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid) 180 { 181 string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle"); 182 string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription"); 183 184 return (title, description); 185 } 186 187 private static string GetString(ResourceLoader rl, string key) 188 { 189 try 190 { 191 string value = rl.GetString(key); 192 return string.IsNullOrWhiteSpace(value) ? string.Empty : value; 193 } 194 catch (Exception) 195 { 196 return string.Empty; 197 } 198 } 199 200 public static List<SettingEntry> Search(string query) 201 { 202 return Search(query, CancellationToken.None); 203 } 204 205 public static List<SettingEntry> Search(string query, CancellationToken token) 206 { 207 if (string.IsNullOrWhiteSpace(query)) 208 { 209 return []; 210 } 211 212 var currentIndex = Index; 213 if (currentIndex.IsEmpty) 214 { 215 Debug.WriteLine("[SearchIndexService] Search called but index is empty."); 216 return []; 217 } 218 219 var normalizedQuery = NormalizeString(query); 220 var bag = new ConcurrentBag<(SettingEntry Hit, double Score)>(); 221 var po = new ParallelOptions 222 { 223 CancellationToken = token, 224 MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1), 225 }; 226 227 try 228 { 229 Parallel.ForEach(currentIndex, po, entry => 230 { 231 var (headerNorm, descNorm) = GetNormalizedTexts(entry); 232 var captionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, headerNorm); 233 double score = captionScoreResult.Score; 234 235 if (!string.IsNullOrEmpty(descNorm)) 236 { 237 var descriptionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, descNorm); 238 if (descriptionScoreResult.Success) 239 { 240 score = Math.Max(score, descriptionScoreResult.Score * 0.8); 241 } 242 } 243 244 if (score > 0) 245 { 246 var pageType = GetPageTypeFromName(entry.PageTypeName); 247 if (pageType != null) 248 { 249 bag.Add((entry, score)); 250 } 251 } 252 }); 253 } 254 catch (OperationCanceledException) 255 { 256 return []; 257 } 258 259 return bag 260 .OrderByDescending(r => r.Score) 261 .Select(r => r.Hit) 262 .ToList(); 263 } 264 265 private static Type GetPageTypeFromName(string pageTypeName) 266 { 267 if (string.IsNullOrEmpty(pageTypeName)) 268 { 269 return null; 270 } 271 272 lock (_lockObject) 273 { 274 if (_pageTypeCache.TryGetValue(pageTypeName, out var cached)) 275 { 276 return cached; 277 } 278 279 var assembly = typeof(GeneralPage).Assembly; 280 var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}"); 281 _pageTypeCache[pageTypeName] = type; 282 return type; 283 } 284 } 285 286 private static (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry) 287 { 288 if (entry.ElementUid == null && entry.Header == null) 289 { 290 return (NormalizeString(entry.Header), NormalizeString(entry.Description)); 291 } 292 293 var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}"; 294 lock (_lockObject) 295 { 296 if (_normalizedTextCache.TryGetValue(key, out var cached)) 297 { 298 return cached; 299 } 300 } 301 302 var headerNorm = NormalizeString(entry.Header); 303 var descNorm = NormalizeString(entry.Description); 304 lock (_lockObject) 305 { 306 _normalizedTextCache[key] = (headerNorm, descNorm); 307 } 308 309 return (headerNorm, descNorm); 310 } 311 312 private static string NormalizeString(string input) 313 { 314 if (string.IsNullOrEmpty(input)) 315 { 316 return string.Empty; 317 } 318 319 var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD); 320 var stringBuilder = new StringBuilder(); 321 foreach (var c in normalized) 322 { 323 var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); 324 if (unicodeCategory != UnicodeCategory.NonSpacingMark) 325 { 326 stringBuilder.Append(c); 327 } 328 } 329 330 return stringBuilder.ToString(); 331 } 332 333 public static string GetLocalizedPageName(string pageTypeName) 334 { 335 return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty; 336 } 337 } 338 }