YorSubs
YorSubs

Reputation: 4080

PowerShell, how to select an xml node

I cannot select the FirstLogonCommands node. Why does $firstLogonCommands always come back as null and how can the FirstLogonCommands section be properly selected?

# Load the XML content
$xml = @"
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
  <settings pass="offlineServicing">
  </settings>
  <settings pass="windowsPE">
    <component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <SetupUILanguage>
        <UILanguage>en-US</UILanguage>
      </SetupUILanguage>
      <InputLocale>0809:00000809</InputLocale>
      <SystemLocale>en-GB</SystemLocale>
      <UILanguage>en-US</UILanguage>
      <UserLocale>en-GB</UserLocale>
    </component>
  </settings>
  <settings pass="oobeSystem">
    <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <InputLocale>0809:00000809</InputLocale>
    </component>
    <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <FirstLogonCommands>
        <!-- Content here -->
      </FirstLogonCommands>
    </component>
  </settings>
</unattend>
"@

# Create an XML document object
$xmlDocument = New-Object System.Xml.XmlDocument
$xmlDocument.LoadXml($xml)

# Select the FirstLogonCommands node
$firstLogonCommands = $xmlDocument.DocumentElement.SelectSingleNode('//settings[@pass="oobeSystem"]/component[@name="Microsoft-Windows-Shell-Setup"]/FirstLogonCommands')

# Check if the FirstLogonCommands node exists
if ($firstLogonCommands -eq $null) {
    Write-Output "FirstLogonCommands section not found under 'Microsoft-Windows-Shell-Setup' component in the 'oobeSystem' settings pass."
} else {
    Write-Output "FirstLogonCommands section found:"
    Write-Output $firstLogonCommands.InnerXml
}

Upvotes: 1

Views: 237

Answers (4)

mklement0
mklement0

Reputation: 440047

To juxtapose and complement the existing answers:

  • derloopkat's helpful answer is the direct solution to your problem: It corrects your Select-Xml call to make it namespace-aware, via a hashtable of namespace-prefix-to-URI mapping(s) passed to -Namespace, and shows that each element name in your XPath query must be namespace-qualified.

    • As an aside:
      • You don't need to load your XML text into an [xml] (System.Xml.XmlDocument) instance first: Select-Xml has a -Content parameter that directly accepts XML text.
      • In cases where you do need an [xml] instance based on in-memory XML text, you can just cast the text (string) directly to [xml], e.g. [xml] '<foo>bar</foo>'
  • Darin's helpful answer shows a namespace-agnostic OO alternative that relies on PowerShell's adaptation of the [xml] DOM:

    • In essence, PowerShell allows you to treat any parsed [xml] document as an object graph that you can drill into using regular dot notation, because PowerShell surfaces XML (child) elements and XML attributes as namespace-less properties on each object (XML node) in the graph.

    • E.g., $xmlDocument.unattend.settings enumerates all <settings> child elements of the <unattend> root element; in other words: it is the equivalent of XPath /unattend/settings, without having to worry about namespaces.

  • jdweng's answer uses an alternative XML-parsing API, System.Linq.Xml.XDocument:

    • While this API is newer than [xml] (System.Xml.XmlDocument) and in many ways more convenient than the latter, it is important to note:
      • Both APIs are and will likely continue to be supported.

      • In the context of PowerShell, given the aforementioned convenient XML DOM adaptation based on [xml], and the fact that Select-Xml is also [xml]-based, it is usually simpler to stick with [xml]:

        • Case in point: jdweng's answer in essence manually recreates (in flattened form) the DOM adaptation of the input XML document that you get for free when you use [xml] (and, as an aside, does so in an inefficient fashion, due to repeated Add-Member calls and the unnecessary use of an [ArrayList] instance to manually create an output list).

        • With respect to XPath support, specifically, the two APIs offer very similar API surfaces, so there's also no advantage to using [System.Xml.Linq.XDocument] - see below.

        • However, when it comes to structural modifications of XML documents, the System.Linq.Xml.XDocument-based API is preferable even in PowerShell; see this answer for an example.


For the sake of completeness: Here's the equivalent [System.Xml.Linq.XDocument] XPath solution:

# Simplified input XML
# 'THE TEXT OF INTEREST.' is what should be extracted.
$xml = @"
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
  <settings pass="offlineServicing"></settings>
  <settings pass="windowsPE"></settings>
  <settings pass="oobeSystem">
    <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <InputLocale>0809:00000809</InputLocale>
    </component>
    <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <FirstLogonCommands>
        THE TEXT OF INTEREST.
      </FirstLogonCommands>
    </component>
  </settings>
</unattend>
"@

# Parse the XML text.
$doc = [System.Xml.Linq.XDocument]::Parse($xml)

# Create an XML namespace manager that declares the input XML's
# default namespace URI and associates it with a self-chosen prefix, 'dns'
# NOTE:
#   * The analogous thing happens behind the scenes for [xml], when you pass
#     a hashtable with prefix-to-URI mappings to Select-Xml -Namespace,
#     as in derloopkat's answer.
$nsm = [System.Xml.XmlNamespaceManager]::new([System.Xml.NameTable]::new())
$nsm.AddNamespace('dns', 'urn:schemas-microsoft-com:unattend')

# Now base your XPath query on the self-chosen namespace prefix, making
# sure that it is used with each element name in the path.
[System.Xml.XPath.Extensions]::XPathSelectElement(
  $doc, 
  '//dns:settings[@pass="oobeSystem"]/dns:component[@name="Microsoft-Windows-Shell-Setup"]/dns:FirstLogonCommands', 
  $nsm
).Value.Trim()

The above prints THE TEXT OF INTEREST., proving that the text content of the element of interest was successfully extracted.

Upvotes: 1

jdweng
jdweng

Reputation: 34421

Try following code which uses XML Linq

using assembly System.Xml.Linq 

# Load the XML content
$xml = @"
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
  <settings pass="offlineServicing">
  </settings>
  <settings pass="windowsPE">
    <component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <SetupUILanguage>
        <UILanguage>en-US</UILanguage>
      </SetupUILanguage>
      <InputLocale>0809:00000809</InputLocale>
      <SystemLocale>en-GB</SystemLocale>
      <UILanguage>en-US</UILanguage>
      <UserLocale>en-GB</UserLocale>
    </component>
  </settings>
  <settings pass="oobeSystem">
    <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <InputLocale>0809:00000809</InputLocale>
    </component>
    <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <FirstLogonCommands>
        <!-- Content here -->
      </FirstLogonCommands>
    </component>
  </settings>
</unattend>
"@
$doc = [System.Xml.Linq.XDocument]:: Parse($xml)
$ns = $doc.Root.GetDefaultNamespace()

$table = [System.Collections.ArrayList]::new()
foreach($setting in $doc.Descendants($ns + 'settings'))
{
   $newRow = [pscustomobject]@{}
   $newRow | Add-Member -NotePropertyName pass -NotePropertyValue $setting.Attribute('pass').Value
   
   $component = $setting.Element($ns + 'component')

   if($component -ne $null)
   {
      foreach($attribute in $component.Attributes())
      {
         $newRow | Add-Member -NotePropertyName $attribute.Name.LocalName -NotePropertyValue $attribute.Value
      }
      foreach($element in $component.Elements())
      {
         if(-not $element.HasElements)
         {
            $newRow | Add-Member -NotePropertyName $element.Name.LocalName -NotePropertyValue $element.Value
         }
      }

   }

   $table.Add($newRow) | out-null
}
$table | Format-List

Here is the results

pass : offlineServicing

pass                  : windowsPE
name                  : Microsoft-Windows-International-Core-WinPE
processorArchitecture : amd64
publicKeyToken        : 31bf3856ad364e35
language              : neutral
versionScope          : nonSxS
InputLocale           : 0809:00000809
SystemLocale          : en-GB
UILanguage            : en-US
UserLocale            : en-GB

pass                  : oobeSystem
name                  : Microsoft-Windows-International-Core
processorArchitecture : amd64
publicKeyToken        : 31bf3856ad364e35
language              : neutral
versionScope          : nonSxS
InputLocale           : 0809:00000809

Upvotes: 0

Daniel Manta
Daniel Manta

Reputation: 6718

You just need to include the dns for each item in the path:

$ns = @{dns="urn:schemas-microsoft-com:unattend"}
Select-Xml -Xml $xmlDocument -XPath '//dns:settings[@pass="oobeSystem"]/dns:component[@name="Microsoft-Windows-Shell-Setup"]/dns:FirstLogonCommands' -Namespace $ns

Upvotes: 3

Darin
Darin

Reputation: 2368

You can drill down where you want this way.

$DesiredSetting = $xmlDocument.unattend.settings | Where-Object {$_.pass -eq 'oobeSystem'}
$DesiredComponent = $DesiredSetting.component | Where-Object {$_.name -eq 'Microsoft-Windows-Shell-Setup'}
$firstLogonCommands = $DesiredComponent.FirstLogonCommands

But make sure you place the $null on the left side of -eq, otherwise it will not work correctly.

if ($null -eq $firstLogonCommands.'#comment') {
   Write-Output "FirstLogonCommands section not found under 'Microsoft-Windows-Shell-Setup' component in the 'oobeSystem' settings pass."
} else {
   Write-Output "FirstLogonCommands section found:"
   Write-Output $firstLogonCommands.InnerXml
}

Upvotes: 2

Related Questions