Program.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.Generic;
  7  using System.Diagnostics;
  8  using System.IO;
  9  using System.Linq;
 10  using System.Text.Json;
 11  using System.Xml.Linq;
 12  
 13  namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
 14  {
 15      public class Program
 16      {
 17          private static readonly HashSet<string> ExcludedXamlFiles = new(StringComparer.OrdinalIgnoreCase)
 18          {
 19              "ShellPage.xaml",
 20          };
 21  
 22          // Hardcoded panel-to-page mapping (temporary until generic panel host mapping is needed)
 23          // Key: panel file base name (without .xaml), Value: owning page base name
 24          private static readonly Dictionary<string, string> PanelPageMapping = new(StringComparer.OrdinalIgnoreCase)
 25          {
 26              { "MouseJumpPanel", "MouseUtilsPage" },
 27          };
 28  
 29          private static JsonSerializerOptions serializeOption = new()
 30          {
 31              WriteIndented = true,
 32              PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
 33          };
 34  
 35          public static void Main(string[] args)
 36          {
 37              if (args.Length < 2)
 38              {
 39                  Debug.WriteLine("Usage: XamlIndexBuilder <xaml-directory> <output-json-file>");
 40                  Environment.Exit(1);
 41              }
 42  
 43              string xamlRootDirectory = args[0];
 44              string outputFile = args[1];
 45  
 46              if (!Directory.Exists(xamlRootDirectory))
 47              {
 48                  Debug.WriteLine($"Error: Directory '{xamlRootDirectory}' does not exist.");
 49                  Environment.Exit(1);
 50              }
 51  
 52              try
 53              {
 54                  var searchableElements = new List<SettingEntry>();
 55                  var processedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 56  
 57                  void ScanDirectory(string root)
 58                  {
 59                      if (!Directory.Exists(root))
 60                      {
 61                          return;
 62                      }
 63  
 64                      Debug.WriteLine($"[XamlIndexBuilder] Scanning root: {root}");
 65                      var xamlFilesLocal = Directory.GetFiles(root, "*.xaml", SearchOption.AllDirectories);
 66                      foreach (var xamlFile in xamlFilesLocal)
 67                      {
 68                          var fullPath = Path.GetFullPath(xamlFile);
 69                          if (processedFiles.Contains(fullPath))
 70                          {
 71                              continue; // already handled (can happen if overlapping directories)
 72                          }
 73  
 74                          var fileName = Path.GetFileName(xamlFile);
 75                          if (ExcludedXamlFiles.Contains(fileName))
 76                          {
 77                              continue; // explicitly excluded
 78                          }
 79  
 80                          Debug.WriteLine($"Processing: {fileName}");
 81                          var elements = ExtractSearchableElements(xamlFile);
 82  
 83                          // Apply hardcoded panel mapping override
 84                          var baseName = Path.GetFileNameWithoutExtension(xamlFile);
 85                          if (PanelPageMapping.TryGetValue(baseName, out var hostPage))
 86                          {
 87                              for (int i = 0; i < elements.Count; i++)
 88                              {
 89                                  var entry = elements[i];
 90                                  entry.PageTypeName = hostPage;
 91                                  elements[i] = entry;
 92                              }
 93                          }
 94  
 95                          searchableElements.AddRange(elements);
 96                          processedFiles.Add(fullPath);
 97                      }
 98                  }
 99  
100                  // Scan well-known subdirectories under the provided root
101                  var subDirs = new[] { "Views", "Panels" };
102                  foreach (var sub in subDirs)
103                  {
104                      ScanDirectory(Path.Combine(xamlRootDirectory, sub));
105                  }
106  
107                  // Fallback: also scan root directly (in case some XAML lives at root level)
108                  ScanDirectory(xamlRootDirectory);
109  
110                  // -----------------------------------------------------------------------------
111                  // Explicit include section: add specific XAML files that we always want indexed
112                  // even if future logic excludes them or they live outside typical scan patterns.
113                  // Add future files to the ExplicitExtraXamlFiles array below.
114                  // -----------------------------------------------------------------------------
115                  string[] explicitExtraXamlFiles = new[]
116                  {
117                      "MouseJumpPanel.xaml", // Mouse Jump settings panel
118                  };
119  
120                  foreach (var extraFileName in explicitExtraXamlFiles)
121                  {
122                      try
123                      {
124                          var matches = Directory.GetFiles(xamlRootDirectory, extraFileName, SearchOption.AllDirectories);
125                          foreach (var match in matches)
126                          {
127                              var full = Path.GetFullPath(match);
128                              if (processedFiles.Contains(full))
129                              {
130                                  continue; // already processed in general scan
131                              }
132  
133                              Debug.WriteLine($"Processing (explicit include): {extraFileName}");
134                              var elements = ExtractSearchableElements(full);
135                              var baseName = Path.GetFileNameWithoutExtension(full);
136                              if (PanelPageMapping.TryGetValue(baseName, out var hostPage))
137                              {
138                                  for (int i = 0; i < elements.Count; i++)
139                                  {
140                                      var entry = elements[i];
141                                      entry.PageTypeName = hostPage;
142                                      elements[i] = entry;
143                                  }
144                              }
145  
146                              searchableElements.AddRange(elements);
147                              processedFiles.Add(full);
148                          }
149                      }
150                      catch (Exception ex)
151                      {
152                          Debug.WriteLine($"Explicit include failed for {extraFileName}: {ex.Message}");
153                      }
154                  }
155  
156                  searchableElements = searchableElements.OrderBy(e => e.PageTypeName).ThenBy(e => e.ElementName).ToList();
157  
158                  string json = JsonSerializer.Serialize(searchableElements, serializeOption);
159                  File.WriteAllText(outputFile, json);
160  
161                  Debug.WriteLine($"Successfully generated index with {searchableElements.Count} elements.");
162                  Debug.WriteLine($"Output written to: {outputFile}");
163              }
164              catch (Exception ex)
165              {
166                  Debug.WriteLine($"Error: {ex.Message}");
167                  Environment.Exit(1);
168              }
169          }
170  
171          public static List<SettingEntry> ExtractSearchableElements(string xamlFile)
172          {
173              var elements = new List<SettingEntry>();
174              string pageName = Path.GetFileNameWithoutExtension(xamlFile);
175  
176              try
177              {
178                  // Load XAML as XML
179                  var doc = XDocument.Load(xamlFile);
180  
181                  // Define namespaces
182                  XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
183                  XNamespace controls = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
184  
185                  // Extract SettingsPageControl elements
186                  var settingsPageElements = doc.Descendants()
187                      .Where(e => e.Name.LocalName == "SettingsPageControl")
188                      .Where(e => e.Attribute(x + "Uid") != null);
189  
190                  // Extract SettingsCard elements (support both Name and x:Name)
191                  var settingsElements = doc.Descendants()
192                      .Where(e => e.Name.LocalName == "SettingsCard")
193                      .Where(e => e.Attribute("Name") != null || e.Attribute(x + "Name") != null || e.Attribute(x + "Uid") != null);
194  
195                  // Extract SettingsExpander elements (support both Name and x:Name)
196                  var settingsExpanderElements = doc.Descendants()
197                      .Where(e => e.Name.LocalName == "SettingsExpander")
198                      .Where(e => e.Attribute("Name") != null || e.Attribute(x + "Name") != null || e.Attribute(x + "Uid") != null);
199  
200                  // Process SettingsPageControl elements
201                  foreach (var element in settingsPageElements)
202                  {
203                      var elementUid = GetElementUid(element, x);
204  
205                      // Prefer the first SettingsCard.HeaderIcon as the module icon
206                      var moduleImageSource = ModuleIconResolver.ResolveIconFromFirstSettingsCard(xamlFile);
207  
208                      if (!string.IsNullOrEmpty(elementUid))
209                      {
210                          elements.Add(new SettingEntry
211                          {
212                              PageTypeName = pageName,
213                              Type = EntryType.SettingsPage,
214                              ParentElementName = string.Empty,
215                              ElementName = string.Empty,
216                              ElementUid = elementUid,
217                              Icon = moduleImageSource,
218                          });
219                      }
220                  }
221  
222                  // Process SettingsCard elements
223                  foreach (var element in settingsElements)
224                  {
225                      var elementName = GetElementName(element, x);
226                      var elementUid = GetElementUid(element, x);
227                      var headerIcon = ExtractIconValue(element);
228  
229                      if (!string.IsNullOrEmpty(elementName) || !string.IsNullOrEmpty(elementUid))
230                      {
231                          var parentElementName = GetParentElementName(element, x);
232  
233                          elements.Add(new SettingEntry
234                          {
235                              PageTypeName = pageName,
236                              Type = EntryType.SettingsCard,
237                              ParentElementName = parentElementName,
238                              ElementName = elementName,
239                              ElementUid = elementUid,
240                              Icon = headerIcon,
241                          });
242                      }
243                  }
244  
245                  // Process SettingsExpander elements
246                  foreach (var element in settingsExpanderElements)
247                  {
248                      var elementName = GetElementName(element, x);
249                      var elementUid = GetElementUid(element, x);
250                      var headerIcon = ExtractIconValue(element);
251  
252                      if (!string.IsNullOrEmpty(elementName) || !string.IsNullOrEmpty(elementUid))
253                      {
254                          var parentElementName = GetParentElementName(element, x);
255  
256                          elements.Add(new SettingEntry
257                          {
258                              PageTypeName = pageName,
259                              Type = EntryType.SettingsExpander,
260                              ParentElementName = parentElementName,
261                              ElementName = elementName,
262                              ElementUid = elementUid,
263                              Icon = headerIcon,
264                          });
265                      }
266                  }
267              }
268              catch (Exception ex)
269              {
270                  Debug.WriteLine($"Error processing {xamlFile}: {ex.Message}");
271              }
272  
273              return elements;
274          }
275  
276          public static string GetElementName(XElement element, XNamespace x)
277          {
278              var name = element.Attribute("Name")?.Value;
279              if (string.IsNullOrEmpty(name))
280              {
281                  name = element.Attribute(x + "Name")?.Value;
282              }
283  
284              return name;
285          }
286  
287          public static string GetElementUid(XElement element, XNamespace x)
288          {
289              // Try x:Uid on the element itself
290              var uid = element.Attribute(x + "Uid")?.Value;
291              if (!string.IsNullOrWhiteSpace(uid))
292              {
293                  return uid;
294              }
295  
296              // Fallback: check the first direct child element's x:Uid
297              var firstChild = element.Elements().FirstOrDefault();
298              if (firstChild != null)
299              {
300                  var childUid = firstChild.Attribute(x + "Uid")?.Value;
301                  if (!string.IsNullOrWhiteSpace(childUid))
302                  {
303                      return childUid;
304                  }
305              }
306  
307              return null;
308          }
309  
310          public static string GetParentElementName(XElement element, XNamespace x)
311          {
312              // Look for parent SettingsExpander
313              var current = element.Parent;
314              while (current != null)
315              {
316                  // Check if we're inside a SettingsExpander.Items or just directly inside SettingsExpander
317                  if (current.Name.LocalName == "Items")
318                  {
319                      // Check if the parent of Items is SettingsExpander
320                      var expanderParent = current.Parent;
321                      if (expanderParent?.Name.LocalName == "SettingsExpander")
322                      {
323                          var expanderName = expanderParent.Attribute("Name")?.Value;
324                          if (string.IsNullOrEmpty(expanderName))
325                          {
326                              expanderName = expanderParent.Attribute(x + "Name")?.Value;
327                          }
328  
329                          if (!string.IsNullOrEmpty(expanderName))
330                          {
331                              return expanderName;
332                          }
333                      }
334                  }
335                  else if (current.Name.LocalName == "SettingsExpander")
336                  {
337                      // Direct child of SettingsExpander
338                      var expanderName = current.Attribute("Name")?.Value;
339                      if (string.IsNullOrEmpty(expanderName))
340                      {
341                          expanderName = current.Attribute(x + "Name")?.Value;
342                      }
343  
344                      if (!string.IsNullOrEmpty(expanderName))
345                      {
346                          return expanderName;
347                      }
348                  }
349  
350                  current = current.Parent;
351              }
352  
353              return string.Empty;
354          }
355  
356          public static string ExtractIconValue(XElement element)
357          {
358              var headerIconAttribute = element.Attribute("HeaderIcon")?.Value;
359  
360              if (string.IsNullOrEmpty(headerIconAttribute))
361              {
362                  // Try nested property element: <SettingsCard.HeaderIcon> ... </SettingsCard.HeaderIcon>
363                  var headerIconProperty = element.Elements()
364                      .FirstOrDefault(e => e.Name.LocalName.EndsWith(".HeaderIcon", StringComparison.OrdinalIgnoreCase));
365  
366                  if (headerIconProperty != null)
367                  {
368                      // Prefer explicit icon elements within the HeaderIcon property
369                      var pathIcon = headerIconProperty.Descendants().FirstOrDefault(d => d.Name.LocalName == "PathIcon");
370                      if (pathIcon != null)
371                      {
372                          var dataAttr = pathIcon.Attribute("Data")?.Value;
373                          if (!string.IsNullOrWhiteSpace(dataAttr))
374                          {
375                              return dataAttr.Trim();
376                          }
377                      }
378  
379                      var fontIcon = headerIconProperty.Descendants().FirstOrDefault(d => d.Name.LocalName == "FontIcon");
380                      if (fontIcon != null)
381                      {
382                          var glyphAttr = fontIcon.Attribute("Glyph")?.Value;
383                          if (!string.IsNullOrWhiteSpace(glyphAttr))
384                          {
385                              return glyphAttr.Trim();
386                          }
387                      }
388  
389                      var bitmapIcon = headerIconProperty.Descendants().FirstOrDefault(d => d.Name.LocalName == "BitmapIcon");
390                      if (bitmapIcon != null)
391                      {
392                          var sourceAttr = bitmapIcon.Attribute("Source")?.Value;
393                          if (!string.IsNullOrWhiteSpace(sourceAttr))
394                          {
395                              return sourceAttr.Trim();
396                          }
397                      }
398                  }
399  
400                  return null;
401              }
402  
403              // Parse different icon markup extensions
404              // Example: {ui:BitmapIcon Source=/Assets/Settings/Icons/AlwaysOnTop.png}
405              if (headerIconAttribute.Contains("BitmapIcon") && headerIconAttribute.Contains("Source="))
406              {
407                  var sourceStart = headerIconAttribute.IndexOf("Source=", StringComparison.OrdinalIgnoreCase) + "Source=".Length;
408                  var sourceEnd = headerIconAttribute.IndexOf('}', sourceStart);
409                  if (sourceEnd == -1)
410                  {
411                      sourceEnd = headerIconAttribute.Length;
412                  }
413  
414                  return headerIconAttribute.Substring(sourceStart, sourceEnd - sourceStart).Trim();
415              }
416  
417              // Example: {ui:FontIcon Glyph=&#xEDA7;}
418              if (headerIconAttribute.Contains("FontIcon") && headerIconAttribute.Contains("Glyph="))
419              {
420                  var glyphStart = headerIconAttribute.IndexOf("Glyph=", StringComparison.OrdinalIgnoreCase) + "Glyph=".Length;
421                  var glyphEnd = headerIconAttribute.IndexOf('}', glyphStart);
422                  if (glyphEnd == -1)
423                  {
424                      glyphEnd = headerIconAttribute.Length;
425                  }
426  
427                  return headerIconAttribute.Substring(glyphStart, glyphEnd - glyphStart).Trim();
428              }
429  
430              // If it doesn't match known patterns, return the original value
431              return headerIconAttribute;
432          }
433      }
434  }