0

Assuming that I have a hashtable:

$tokens = @{
    Id=9999; 
    Title="Lorem ipsum dolor sit amet";
    Author=@{Name="John Doe"; Email='john.doe@foo.xyz'};
    Analyst=@{Name="Jane Doe"; Email='jane.doe@foo.xyz'}
}

And a template that I would like to populate, replacing the tokens (e.g. __Title__) with the corresponding hashtable's value:

/*
Author:     __Author.Name__ <__Author.Email__>
Analyst:    __Analyst.Name__ <__Analyst.Email__>
Request:    __Title__ [__Id__]
*/
...

Should become:

/*
Author:     John Doe <john.doe@foo.xyz>
Analyst:    Jane Doe <jane.doe@foo.xyz>
Request:    Lorem ipsum dolor sit amet [9999]
*/

Is there a way to refer to an embedded hashtable's elements in the 'parent' hashtable? $tokens['Author.Email'], for example, doesn't work.

The code:

...
return [regex]::Replace( $template, '__(?<tokenName>\w+)__', {
  # __TOKEN__
  param($match)

  $tokenName = $match.Groups['tokenName'].Value

  if ($tokens[$tokenName]) {
    # matching token returns value from hashtable;
    works for simple keys `$tokens['Title']`, not complex keys `$tokens['Author.Name']`
    return $tokens[$tokenName]
  } 
  else {
    # non-matching token returns token
    return $match
  }
})
craig
  • 25,664
  • 27
  • 119
  • 205

2 Answers2

2

You can just reference the element with dot notation

$tokens.author.email

Then you could do things like this as well if you wanted to check if the name was empty for example. Note that there is a caveat: Author should exist for this to work exactly as intended.)

If(!$tokens.author.name){$tokens.author.name = "Awesome Sauce"; }
Write-Host ("Author Name: {0}" -f $tokens.author.name)

You can also use hashtable notation as suggested by briantist

$tokens['Author']['Email']

Dynamic replacement

You use the word dynamic but I am not sure how far you want to take that. For now lets assume that the $tokens elements all exist and we are going to replace the text from a here-string.

$text = @"
/*
Author:     __Author.Name__ <__Author.Email__>
Analyst:    __Analyst.Name__ <__Analyst.Email__>
Request:    __Title__ [__Id__]
*/
"@

$text -replace "__Author\.Name__",$tokens.Author.Name -replace "__Author\.Email__",$tokens.Author.Email `
        -replace "__Analyst\.Name__",$tokens.Analyst.Name -replace "__Analyst\.Email__",$tokens.Analyst.Email `
        -replace "__Title__",$tokens.Title -replace "__Id__",$tokens.Id

But I feel you mean more dynamic since all of this requires knowing information about the $Tokens and the the source string. Let me know how we stand now. We could get deeper with this.

Lets get freaky

Let say you know that the hashtable $tokens and the source $text have values in common but you don't know the names of them. This will dynamically populate text based on the key names on the hashtables. Currently this only works if there is only one hashtable depth.

ForEach($childKey in $tokens.Keys){ 
    If($tokens[$childKey] -is [System.Collections.Hashtable]){
        ForEach($grandChildKey in $tokens[$childKey].Keys){ 
            Write-Host "GrandChildKey = $childKey"
            $text = $text -replace "__$childKey\.$($grandChildKey)__", $tokens.$childKey.$grandChildKey
        }
    } Else {
        $text = $text -replace "__$($childKey)__", $tokens.$childKey
    }
}

$text

Something else

This borrows from mike z suggestion about Invoke-Expression as it makes less guess work involved.

$output = $text
$placeHolders = $text | Select-String '__([\w.]+)__' -AllMatches | ForEach-Object{$_.matches} | ForEach-Object{$_.Value}
$placeHolders.count
$placeHolders | ForEach-Object {
    $output = $output -replace [regex]::Escape($_), (Invoke-Expression "`$tokens.$($_ -replace "_")")
}
$output

Search the $text for all strings like something. For every match replace that text with its dot notation equivalent.

Output from either samples should match what you have for Should become:

Community
  • 1
  • 1
Matt
  • 45,022
  • 8
  • 78
  • 119
  • How would I dynamically construct `$token.author.name` from values found in the template file? – craig Mar 13 '15 at 19:10
  • Ah so I understand now. Can you show me a sample of some text for reference? This would not be hard to do. Sample will help give you context. – Matt Mar 13 '15 at 19:11
  • The text is in the question, but `Author: __Author.Name__ <__Author.Email__>` should get converted to `Author: John Doe ` – craig Mar 13 '15 at 19:15
  • @craig Need to see what you are looking for. Answer now would get you your results but are you looking for something actually dynamic where the values are not hard coded? – Matt Mar 13 '15 at 19:33
  • The second option is better as doesn't require as much knowledge of `$tokens` hash and the template file. – craig Mar 13 '15 at 20:15
  • @craig. Ok if that is in the right direction lets see if we can take it a step further. – Matt Mar 13 '15 at 20:40
2

A couple things:

  1. You need to fix the regular expression to actually match the nested properties. Right now it doesn't you need it to be __(?<tokenName>[\w\.]+)__

  2. Use Invoke-Expression to dynamically expand the nested properties. Just build a string the represents the expression you want to evaluate. This is good because it doesn't rely on the model objects, $tokens and its properties, being hashtables at all. All it needs is for the properties to resolve on the objects that are there.

A short example is below. Note: if the template is coming from an unsecure source, be careful with this and sanitize the input first:

$tokens = @{
    Id=9999; 
    Title="Lorem ipsum dolor sit amet";
    Author=@{Name="John Doe"; Email='john.doe@foo.xyz'};
    Analyst=@{Name="Jane Doe"; Email='jane.doe@foo.xyz'};
    '3PTY' = "A";
    Test=@{'Name with space' = 'x' }
}

$template = @"
/*
Author:     __Author.Name__ <__Author.Email__>
Analyst:    __Analyst.Name__ <__Analyst.Email__>
Request:    __Title__ [__Id__]
3PTY: __"3PTY"__
Name:__Test.'Name with space'__
*/
"@

function Replace-Template {

    param ([string]$template, $model)

    [regex]::Replace( $template, '__(?<tokenName>[\w .\''\"]+)__', {
      # __TOKEN__
      # Note that TOKEN should be a valid PS property name. It may need to be enclosed in quotes
      # if it starts with a number or has spaces in the name. See the example above for usage.
      param($match)

      $tokenName = $match.Groups['tokenName'].Value
      Write-Verbose "Replacing '$tokenName'"
      $tokenValue = Invoke-Expression "`$model.$tokenName" -ErrorAction SilentlyContinue

      if ($tokenValue) {
        # there was a value. return it.
        return $tokenValue
      } 
      else {
        # non-matching token returns token
        return $match
      }
    })
}

Replace-Template $template $tokens

Output:

/*
Author: John Doe
Analyst: Jane Doe
Request: Lorem ipsum dolor sit amet [9999]
3PTY: A
Name:x
*/

Mike Zboray
  • 39,828
  • 3
  • 90
  • 122
  • Really-nice solution; +1. I can see how `Invoke-Expression` could be dangerous. Any recommendations on how to sanitize the template? – craig Mar 13 '15 at 21:14
  • @craig Actually, I think its fine, as long as the token names are restricted to A-Z,a-z,0-9,_, and .. I don't think there's anyway to break out of the expression context. I was thinking the token name regex was a little more expansive. – Mike Zboray Mar 13 '15 at 21:25
  • I'm passing `$tokens` as a `HashTable` parameter, so I modified your code to `Invoke-Expression "\`$tokens.$tokenName"`. Otherwise, it works exactly as I would like. As an aside, is the backtick character syntactic sugar for `$()`? – craig Mar 13 '15 at 21:39
  • Backtick is the escape character. You want to evaluate an expression like `$tokens.Author.Name`. If we don't escape the $ then it also interpolates $tokens giving you something like `System.Collections.Hashtable.Author.Name` which is not what you want to evaluate. See my update. I modified it to take another parameter, the "model" for the template. – Mike Zboray Mar 13 '15 at 21:41
  • FYI this script has a bug: $tokenValue = Invoke-Expression "`$model.$tokenName" throws "Missing property name after reference operator." with certain token names. Changing it to: $tokenValue = $model.Get_Item($tokenName) ...works – Thomas Johnson Jul 11 '18 at 21:01
  • @ThomasJohnson For the example given, I'm pretty sure this worked when I wrote it and it works for me now. The suggested fix breaks it for the scenario of accessing subproperties that the OP asked for (e.g. Author.Name). – Mike Zboray Jul 11 '18 at 21:23
  • @mike-z it Hmmm - try it with a $tokenName starting with a numeric character – Thomas Johnson Jul 12 '18 at 15:26
  • $tokens = @{}; $tokens.Add("3PTY", "pop"); $template = "This or that __3PTY__ blah blah'/>"; Replace-Template $template $tokens; Replace-Template $template $tokens; ...you end up getting: Missing property name after reference operator. (There are underscores wrapping the tokenName in the $template assignment call, but they don't show up in the comment) – Thomas Johnson Jul 12 '18 at 15:37
  • @ThomasJohnson Ah I see. Yes there is a restriction here that the template properties have to have valid powershell "simple" property names which can't start with a number. However, I made a small tweak to allow the quote characters, which would allow you to quote those in the template. See the updated code/example. – Mike Zboray Jul 12 '18 at 19:39