/ src / settings-ui / Settings.UI / Services / SearchIndexService.cs
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  }