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=} 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 }