Elizium.Loopz.psm1
Set-StrictMode -Version 1.0 $global:LoopzHelpers = @{ # Helper Script Blocks # WhItemDecoratorBlock = [scriptblock] { param( [Parameter(Mandatory)] $_underscore, [Parameter(Mandatory)] [int]$_index, [Parameter(Mandatory)] [System.Collections.Hashtable]$_passThru, [Parameter(Mandatory)] [boolean]$_trigger ) return Write-HostFeItemDecorator -Underscore $_underscore ` -Index $_index ` -PassThru $_passThru ` -Trigger $_trigger } SimpleSummaryBlock = [scriptblock] { param( [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [int]$Count, [int]$Skipped, [boolean]$Triggered, [System.Collections.Hashtable]$PassThru = @{} ) [System.Collections.Hashtable]$krayolaTheme = $PassThru.ContainsKey( 'LOOPZ.WH-FOREACH-DECORATOR.KRAYOLA-THEME') ` ? $PassThru['LOOPZ.WH-FOREACH-DECORATOR.KRAYOLA-THEME'] : $(Get-KrayolaTheme); $metaColours = $krayolaTheme['META-COLOURS']; $line = $colouredLine = $null; if ($PassThru.ContainsKey('LOOPZ.SUMMARY-BLOCK.LINE')) { $line = $PassThru['LOOPZ.SUMMARY-BLOCK.LINE']; $colouredLine = @($line) + $metaColours; Write-InColour -TextSnippets @(, $colouredLine); } [string[][]]$properties = @( @('Count', $Count), @('Skipped', $Skipped), @('Triggered', $Triggered) ) [string]$message = $PassThru.ContainsKey('LOOPZ.SUMMARY-BLOCK.MESSAGE') ` ? $PassThru['LOOPZ.SUMMARY-BLOCK.MESSAGE'] : 'Summary'; Write-ThemedPairsInColour -Pairs $properties -Theme $krayolaTheme -Message $message; if ($colouredLine) { Write-InColour -TextSnippets @(, $colouredLine); } } } # Session UI state # [int]$global:_LineLength = 121; [int]$global:_SmallLineLength = 81; # $global:LoopzUI = [ordered]@{ # Line definitions: # UnderscoreLine = (New-Object String("_", $_LineLength)); EqualsLine = (New-Object String("=", $_LineLength)); DotsLine = (New-Object String(".", $_LineLength)); LightDotsLine = ((New-Object String(".", (($_LineLength - 1) / 2))).Replace(".", ". ") + "."); TildeLine = (New-Object String("~", $_LineLength)); SmallUnderscoreLine = (New-Object String("_", $_SmallLineLength)); SmallEqualsLine = (New-Object String("=", $_SmallLineLength)); SmallDotsLine = (New-Object String(".", $_SmallLineLength)); SmallLightDotsLine = ((New-Object String(".", (($_SmallLineLength - 1) / 2))).Replace(".", ". ") + "."); SmallTildeLine = (New-Object String("~", $_SmallLineLength)); } function Invoke-ForeachFsItem { <# .NAME Invoke-ForeachFsItem .SYNOPSIS Allows a custom defined scriptblock or function to be invoked for all file system objects delivered through the pipeline. .DESCRIPTION 2 parameters sets are defined, one for invoking a named function (InvokeFunction) and the other (InvokeScriptBlock, the default) for invoking a script-block. An optional Summary script block can be specified which will be invoked at the end of the pipeline batch. The user should assemble the candidate items from the file system, be they files or directories typically using Get-ChildItem, or can be any other function that delivers file systems items via the PowerShell pipeline. For each item in the pipeline, Invoke-ForeachFsItem will invoke the script-block/function specified. Invoke-ForeachFsItem will deliver what ever is returned from the script-block/function, so the result of Invoke-ForeachFsItem can be piped to another command. .PARAMETER pipelineItem This is the pipeline object, so should not be specified explicitly and can represent a file object (System.IO.FileInfo) or a directory object (System.IO.DirectoryInfo). .PARAMETER Condition This is a predicate scriptblock, which is invoked with either a DirectoryInfo or FileInfo object presented as a result of invoking Get-ChildItem. It provides a filtering mechanism that is defined by the user to define which file system objects are selected for function/scriptblock invocation. .PARAMETER Block The script block to be invoked. The script block is invoked for each item in the pipeline that satisfy the Condition with the following positional parameters: * pipelineItem: the item from the pipeline * index: the 0 based index representing current pipeline item * PassThru: a hash table containing miscellaneous information gathered internally throughout the pipeline batch. This can be of use to the user, because it is the way the user can perform bi-directional communication between the invoked custom script block and client side logic. * trigger: a boolean value, useful for state changing idempotent operations. At the end of the batch, the state of the trigger indicates whether any of the items were actioned. When the script block is invoked, the trigger should indicate if the trigger was pulled for any of the items so far processed in the pipeline. This is the responsibility of the client's block implementation. The trigger is only of use for state changing operations and can be ignored otherwise. In addition to these fixed positional parameters, if the invoked scriptblock is defined with additional parameters, then these will also be passed in. In order to achieve this, the client has to provide excess parameters in BlockParam and these parameters must be defined as the same type and in the same order as the additional parameters in the scriptblock. .PARAMETER BlockParams Optional array containing the excess parameters to pass into the script block. .PARAMETER Functee String defining the function to be invoked. Works in a similar way to the Block parameter for script-blocks. The Function's base signature is as follows: "Underscore": (See pipelineItem described above) "Index": (See index described above) "PassThru": (See PathThru described above) "Trigger": (See trigger described above) .PARAMETER FuncteeParams Optional hash-table containing the named parameters which are splatted into the Functee function invoke. As it's a hash table, order is not significant. .PARAMETER PassThru A hash table containing miscellaneous information gathered internally throughout the pipeline batch. This can be of use to the user, because it is the way the user can perform bi-directional communication between the invoked custom script block and client side logic. .PARAMETER Summary A script-block that is invoked at the end of the pipeline batch. The script-block is invoked with the following positional parameters: * count: the number of items processed in the pipeline batch. * skipped: the number of items skipped in the pipeline batch. An item is skipped if it fails the defined condition or is not of the correct type (eg if its a directory but we have specified the -File flag). Also note that, if the script-block/function sets the Break flag causing further iteration to stop, then those subsequent items in the pipeline which have not been processed are not reflected in the skip count. * trigger: Flag set by the script-block/function, but should typically be used to indicate whether any of the items processed were actively updated/written in this batch. This helps in written idempotent operations that can be re-run without adverse consequences. * PassThru: (see PassThru previously described) .PARAMETER File Switch to indicate that the invoked function/script-block (invokee) is to handle FileInfo objects. Is mutually exclusive with the Directory switch. If neither switch is specified, then the invokee must be able to handle both therefore the Underscore parameter it defines must be declared as FileSystemInfo. .PARAMETER Directory Switch to indicate that the invoked function/script-block (invokee) is to handle Directory objects. .PARAMETER StartIndex Some calling functions interact with Invoke-ForeachFsItem in a way that may require that there is external control of the starting index. For example, Invoke-TraverseDirectory (which invokes Invoke-ForeachFsItem) handles the root Directory separately from its descendants and to ensure that the allocated indices are correct, the starting index should be set to 1, because the root Directory has already been allocated index 0, outside of the ForeachFsItem batch. Normal use of ForeachFsItem does not require StartIndex to be specified. .EXAMPLE 1 Invoke a script-block to handle .txt file objects from the same directory (without -Recurse): (NB: first parameter is of type FileInfo, -File specified on Get-ChildItem and Invoke-ForeachFsItem. If Get-ChildItem is missing -File, then any Directory objects passed in are filtered out by Invoke-ForeachFsItem. If -File is missing from Invoke-ForeachFsItem, then the script-block's first parameter, must be a FileSystemInfo to handle both types) [scriptblock]$block = { param( [System.IO.FileInfo]$FileInfo, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger ) ... } Get-ChildItem './Tests/Data/fefsi' -Recurse -Filter '*.txt' -File | ` Invoke-ForeachFsItem -File -Block $block; .EXAMPLE 2 Invoke a function with additional parameters to handle directory objects from multiple directories (with -Recurse): function invoke-Target { param( [System.IO.DirectoryInfo]$Underscore, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger, [string]$Format ) ... } [System.Collections.Hashtable]$parameters = @{ 'Format' } Get-ChildItem './Tests/Data/fefsi' -Recurse -Directory | ` Invoke-ForeachFsItem -Directory -Functee 'invoke-Target' -FuncteeParams $parameters .EXAMPLE 3 Invoke a script-block to handle empty .txt file objects from the same directory (without -Recurse): [scriptblock]$block = { param( [System.IO.FileInfo]$FileInfo, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger ) ... } [scriptblock]$fileIsEmpty = { param( [System.IO.FileInfo]$FileInfo ) return (0 -eq $FileInfo.Length) } Get-ChildItem './Tests/Data/fefsi' -Recurse -Filter '*.txt' -File | Invoke-ForeachFsItem ` -Block $block -File -condition $fileIsEmpty; .EXAMPLE 4 Invoke a script-block only for directories whose name starts with "A" from the same directory (without -Recurse); Note the use of the LOOPZ function "Select-FsItem" in the directory include filter: [scriptblock]$block = { param( [System.IO.FileInfo]$FileInfo, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger ) ... } [scriptblock]$filterDirectories = { [OutputType([boolean])] param( [System.IO.DirectoryInfo]$directoryInfo ) Select-FsItem -Name $directoryInfo.Name -Includes 'A*'; } Get-ChildItem './Tests/Data/fefsi' -Directory | Invoke-ForeachFsItem ` -Block $block -Directory -DirectoryIncludes $filterDirectories; #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [CmdletBinding(DefaultParameterSetName = 'InvokeScriptBlock')] [Alias('ife', 'Foreach-FsItem')] param( [Parameter(ParameterSetName = 'InvokeScriptBlock', Mandatory, ValueFromPipeline = $true)] [Parameter(ParameterSetName = 'InvokeFunction', Mandatory, ValueFromPipeline = $true)] [System.IO.FileSystemInfo]$pipelineItem, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [scriptblock]$Condition = ( { return $true; }), [Parameter(ParameterSetName = 'InvokeScriptBlock', Mandatory)] [scriptblock]$Block, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [ValidateScript( { $_ -is [Array] })] $BlockParams = @(), [Parameter(ParameterSetName = 'InvokeFunction', Mandatory)] [ValidateScript( { -not([string]::IsNullOrEmpty($_)); })] [string]$Functee, [Parameter(ParameterSetName = 'InvokeFunction')] [System.Collections.Hashtable]$FuncteeParams = @{}, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [System.Collections.Hashtable]$PassThru = @{}, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [scriptblock]$Summary = ( { param( [int]$count, [int]$skipped, [boolean]$trigger, [System.Collections.Hashtable]$passThru ) }), [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [ValidateScript( { -not($PSBoundParameters.ContainsKey('Directory')) })] [switch]$File, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [ValidateScript( { -not($PSBoundParameters.ContainsKey('File')) })] [switch]$Directory, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [int]$StartIndex = 0 ) # param begin { [boolean]$manageIndex = -not($PassThru.ContainsKey('LOOPZ.FOREACH.INDEX')); [int]$index = $manageIndex ? $StartIndex : $PassThru['LOOPZ.FOREACH.INDEX']; [int]$skipped = 0; [boolean]$broken = $false; [boolean]$trigger = $PassThru.ContainsKey('LOOPZ.FOREACH.TRIGGER'); } process { [boolean]$itemIsDirectory = ($pipelineItem.Attributes -band [System.IO.FileAttributes]::Directory) -eq [System.IO.FileAttributes]::Directory; [boolean]$acceptAll = -not($File.ToBool()) -and -not($Directory.ToBool()); if (-not($broken)) { if ( $acceptAll -or ($Directory.ToBool() -and $itemIsDirectory) -or ($File.ToBool() -and -not($itemIsDirectory)) ) { if ($Condition.Invoke($pipelineItem)) { $result = $null; try { if ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) { $positional = @($pipelineItem, $index, $PassThru, $trigger); if ($BlockParams.Length -gt 0) { $BlockParams | ForEach-Object { $positional += $_; } } $result = Invoke-Command -ScriptBlock $Block -ArgumentList $positional; } elseif ('InvokeFunction' -eq $PSCmdlet.ParameterSetName) { [System.Collections.Hashtable]$parameters = $FuncteeParams.Clone(); $parameters['Underscore'] = $pipelineItem; $parameters['Index'] = $index; $parameters['PassThru'] = $PassThru; $parameters['Trigger'] = $trigger; $result = & $Functee @parameters; } } catch { Write-Error "Foreach Error: ($_), for item: '$($pipelineItem.Name)'"; } finally { if ($manageIndex) { $index++; } else { $index = $PassThru['LOOPZ.FOREACH.INDEX']; } if ($result) { if ($result.psobject.properties.match('Trigger') -and $result.Trigger) { $PassThru['LOOPZ.FOREACH.TRIGGER'] = $true; $trigger = $true; } if ($result.psobject.properties.match('Break') -and $result.Break) { $broken = $true; } if ($result.psobject.properties.match('Product') -and $result.Product) { $result.Product; } } } } else { # IDEA! We could allow the user to provide an extra script block which we # invoke for skipped items and set a string containing the reason why it was # skipped. $null = $skipped++; } } else { $null = $skipped++; } } else { $null = $skipped++; } } end { $PassThru['LOOPZ.FOREACH.TRIGGER'] = $trigger; if ($manageIndex) { $Summary.Invoke($index, $skipped, $trigger, $PassThru); } } } # Invoke-ForeachFsItem function Invoke-MirrorDirectoryTree { <# .NAME Invoke-MirrorDirectoryTree .SYNOPSIS Mirrors a directory tree to a new location, invoking a custom defined scriptblock or function as it goes. .DESCRIPTION Copies a source directory tree to a new location applying custom functionality for each directory. 2 parameters set are defined, one for invoking a named function (InvokeFunction) and the other (InvokeScriptBlock, the default) for invoking a scriptblock. An optional Summary script block can be specified which will be invoked at the end of the mirroring batch. .PARAMETER Path The source Path denoting the root of the directory tree to be mirrored. .PARAMETER DestinationPath The destination Path denoting the root of the directory tree where the source tree will be mirrored to. .PARAMETER DirectoryIncludes An array containing a list of filters, each must contain a wild-card ('*'). If a particular filter does not contain a wild-card, then it will be ignored. If the directory matches any of the filters in the list, it will be mirrored in the destination tree. If DirectoryIncludes contains just a single element which is the empty string, this means that nothing is included (rather than everything being included). .PARAMETER DirectoryExcludes An array containing a list of filters, each must contain a wild-card ('*'). If a particular filter does not contain a wild-card, then it will be ignored. If the directory matches any of the filters in the list, it will NOT be mirrored in the destination tree. Any match in the DirectoryExcludes overrides a match in DirectoryIncludes, so a directory that is matched in Include, can be excluded by the Exclude. .PARAMETER FileIncludes An array containing a list of filters, each may contain a wild-card ('*'). If a particular filter does not contain a wild-card, then it will be treated as a file suffix. If the file in the source tree matches any of the filters in the list, it will be mirrored in the destination tree. If FileIncludes contains just a single element which is the empty string, this means that nothing is included (rather than everything being included). .PARAMETER FileExcludes An array containing a list of filters, each may contain a wild-card ('*'). If a particular filter does not contain a wild-card, then it will be treated as a file suffix. If the file in the source tree matches any of the filters in the list, it will NOT be mirrored in the destination tree. Any match in the FileExcludes overrides a match in FileIncludes, so a file that is matched in Include, can be excluded by the Exclude. .PARAMETER PassThru A hash table containing miscellaneous information gathered internally throughout the pipeline batch. This can be of use to the user, because it is the way the user can perform bi-directional communication between the invoked custom script block and client side logic. .PARAMETER Block The script block to be invoked. The script block is invoked for each directory in the source directory tree that satisfy the specified Directory Include/Exclude filters with the following positional parameters: * underscore: the DirectoryInfo object representing the directory in the source tree * index: the 0 based index representing current directory in the source tree * PassThru object: a hash table containing miscellaneous information gathered internally throughout the mirroring batch. This can be of use to the user, because it is the way the user can perform bi-directional communication between the invoked custom script block and client side logic. * trigger: a boolean value, useful for state changing idempotent operations. At the end of the batch, the state of the trigger indicates whether any of the items were actioned. When the script block is invoked, the trigger should indicate if the trigger was pulled for any of the items so far processed in the batch. This is the responsibility of the client's script-block/function implementation. In addition to these fixed positional parameters, if the invoked scriptblock is defined with additional parameters, then these will also be passed in. In order to achieve this, the client has to provide excess parameters in BlockParams and these parameters must be defined as the same type and in the same order as the additional parameters in the script-block. The destination DirectoryInfo object can be accessed via the PassThru denoted by the 'LOOPZ.MIRROR.DESTINATION' entry. .PARAMETER BlockParams Optional array containing the excess parameters to pass into the script-block/function. .PARAMETER Functee String defining the function to be invoked. Works in a similar way to the Block parameter for script-blocks. The Function's base signature is as follows: "Underscore": (See underscore described above) "Index": (See index described above) "PassThru": (See PathThru described above) "Trigger": (See trigger described above) The destination DirectoryInfo object can be accessed via the PassThru denoted by the 'LOOPZ.MIRROR.DESTINATION' entry. .PARAMETER FuncteeParams Optional hash-table containing the named parameters which are splatted into the Functee function invoke. As it's a hash table, order is not significant. .PARAMETER CreateDirs Switch parameter indicates that directories should be created in the destination tree. If not set, then Invoke-MirrorDirectoryTree turns into a function that traverses the source directory invoking the function/script-block for matching directories. .PARAMETER CopyFiles Switch parameter that indicates that files matching the specified filters should be copied .PARAMETER Hoist Switch parameter. Without Hoist being specified, the filters can prove to be too restrictive on matching against directories. If a directory does not match the filters then none of its descendants will be considered to be mirrored in the destination tree. When Hoist is specified then a descendant directory that does match the filters will be mirrored even though any of its ancestors may not match the filters. .PARAMETER Summary A script-block that is invoked at the end of the mirroring batch. The script-block is invoked with the following positional parameters: * count: the number of items processed in the mirroring batch. * skipped: the number of items skipped in the mirroring batch. An item is skipped if it fails the defined condition or is not of the correct type (eg if its a directory but we have specified the -File flag). * trigger: Flag set by the script-block/function, but should typically be used to indicate whether any of the items processed were actively updated/written in this batch. This helps in written idempotent operations that can be re-run without adverse consequences. * PassThru: (see PassThru previously described) .EXAMPLE 1 Invoke a named function for every directory in the source tree and mirror every directory in the destination tree. The invoked function has an extra parameter in it's signature, so the extra parameters must be passed in via FuncteeParams (the standard signature being the first 4 parameters shown.) function Test-Mirror { param( [System.IO.DirectoryInfo]$Underscore, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger, [string]$Format ) ... } [System.Collections.Hashtable]$parameters = @{ 'Format' = '---- {0} ----'; } Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' ` -DestinationPath './Tests/Data/mirror' -CreateDirs ` -Functee 'Test-Mirror' -FuncteeParams $parameters; .EXAMPLE 2 Invoke a script-block for every directory in the source tree and copy all files Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' ` -DestinationPath './Tests/Data/mirror' -CreateDirs -CopyFiles -block { param( [System.IO.DirectoryInfo]$Underscore, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger ) ... }; .EXAMPLE 3 Mirror a directory tree, including only directories beginning with A (filter A*) Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' -DestinationPath './Tests/Data/mirror' ` -DirectoryIncludes @('A*') Note the possible issue with this example is that any descendants named A... which are located under an ancestor which is not named A..., will not be mirrored; eg './Tests/Data/fefsi/Audio/mp3/A/Amorphous Androgynous', even though "Audio", "A" and "Amorphous Androgynous" clearly match the A* filter, they will not be mirrored because the "mp3" directory, would be filtered out. See the following example for a resolution. .EXAMPLE 4 Mirror a directory tree, including only directories beginning with A (filter A*) regardless of the matching of intermediate ancestors (specifying -Hoist flag resolves the possible issue in the previous example) Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' -DestinationPath './Tests/Data/mirror' ` -DirectoryIncludes @('A*') -CreateDirs -CopyFiles -Hoist Note that the directory filter must include a wild-card, otherwise it will be ignored. So a directory include of @('A'), is problematic, because A is not a valid directory filter so its ignored and there are no remaining filters that are able to include any directory, so no directory passes the filter. .EXAMPLE 5 Mirror a directory tree, including files with either .flac or .wav suffix Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' -DestinationPath './Tests/Data/mirror' ` -FileIncludes @('flac', '*.wav') -CreateDirs -CopyFiles -Hoist Note that for files, a filter may or may not contain a wild-card. If the wild-card is missing then it is automatically treated as a file suffix; so 'flac' means '*.flac'. .EXAMPLE 6 Mirror a directory tree copying over just flac files [scriptblock]$summary = { param( [int]$_count, [int]$_skipped, [boolean]$_triggered, [System.Collections.Hashtable]$_passThru ) ... } Invoke-MirrorDirectoryTree -Path './Tests/Data/fefsi' -DestinationPath './Tests/Data/mirror' ` -FileIncludes @('flac') -CopyFiles -Hoist -Summary $summary Note that -CreateDirs is missing which means directories will not be mirrored by default. They are only mirrored as part of the process of copying over flac files, so in the end the resultant mirror directory tree will contain directories that include flac files. #> [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'InvokeScriptBlock')] [Alias('imdt', 'Mirror-Directory')] param ( [Parameter(Mandatory, ParameterSetName = 'InvokeScriptBlock')] [Parameter(Mandatory, ParameterSetName = 'InvokeFunction')] [ValidateScript( { Test-path -Path $_; })] [String]$Path, [Parameter(Mandatory, ParameterSetName = 'InvokeScriptBlock')] [Parameter(Mandatory, ParameterSetName = 'InvokeFunction')] [String]$DestinationPath, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [String[]]$DirectoryIncludes = @('*'), [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [String[]]$DirectoryExcludes = @(), [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [String[]]$FileIncludes = @('*'), [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [String[]]$FileExcludes = @(), [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [System.Collections.Hashtable]$PassThru = @{}, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [scriptblock]$Block = ( { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param( [System.IO.DirectoryInfo]$underscore, [int]$index, [System.Collections.Hashtable]$passThru, [boolean]$trigger ) } ), [Parameter(ParameterSetName = 'InvokeScriptBlock')] [ValidateScript( { $_ -is [Array] })] $BlockParams = @(), [Parameter(ParameterSetName = 'InvokeFunction', Mandatory)] [ValidateScript( { -not([string]::IsNullOrEmpty($_)); })] [string]$Functee, [Parameter(ParameterSetName = 'InvokeFunction')] [ValidateScript( { $_.Count -gt 0; })] [System.Collections.Hashtable]$FuncteeParams = @{}, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [switch]$CreateDirs, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [switch]$CopyFiles, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [switch]$Hoist, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [scriptblock]$Summary = ( { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param( [int]$index, [int]$skipped, [boolean]$trigger, [System.Collections.Hashtable]$_passThru ) }) ) # param # ================================================================== [doMirrorBlock] === # [scriptblock]$doMirrorBlock = { param( [Parameter(Mandatory)] [System.IO.DirectoryInfo]$_underscore, [Parameter(Mandatory)] [int]$_index, [Parameter(Mandatory)] [System.Collections.Hashtable]$_passThru, [Parameter(Mandatory)] [boolean]$_trigger ) # Write-Host "[+] >>> doMirrorBlock: $($_underscore.Name)"; [string]$rootSource = $_passThru['LOOPZ.MIRROR.ROOT-SOURCE']; [string]$rootDestination = $_passThru['LOOPZ.MIRROR.ROOT-DESTINATION']; $sourceDirectoryFullName = $_underscore.FullName; # sourceDirectoryFullName must end with directory separator # if (-not($sourceDirectoryFullName.EndsWith([System.IO.Path]::DirectorySeparatorChar))) { $sourceDirectoryFullName += [System.IO.Path]::DirectorySeparatorChar; } $destinationBranch = edit-RemoveSingleSubString -Target $sourceDirectoryFullName -Subtract $rootSource; $destinationDirectory = Join-Path -Path $rootDestination -ChildPath $destinationBranch; [boolean]$whatIf = $_passThru.ContainsKey('LOOPZ.MIRROR.WHAT-IF') -and ($_passThru['LOOPZ.MIRROR.WHAT-IF']); Write-Debug "[+] >>> doMirrorBlock: destinationDirectory: '$destinationDirectory'"; if ($CreateDirs.ToBool()) { Write-Debug " [-] Creating destination branch directory: '$destinationBranch'"; $destinationInfo = (Test-Path -Path $destinationDirectory) ` ? (Get-Item -Path $destinationDirectory) ` : (New-Item -ItemType 'Directory' -Path $destinationDirectory -WhatIf:$whatIf); } else { Write-Debug " [-] Creating destination branch directory INFO obj: '$destinationBranch'"; $destinationInfo = New-Object -TypeName System.IO.DirectoryInfo ($destinationDirectory); } if ($CopyFiles.ToBool()) { Write-Debug " [-] Creating files for branch directory: '$destinationBranch'"; # To use the include/exclude parameters on Copy-Item, the Path specified # must end in /*. We only need to add the star though because we added the / # previously. # [string]$sourceDirectoryWithWildCard = $sourceDirectoryFullName + '*'; [string[]]$adjustedFileIncludes = $FileIncludes | ForEach-Object { $_.Contains('*') ? $_ : "*.$_".Replace('..', '.'); } [string[]]$adjustedFileExcludes = $FileExcludes | ForEach-Object { $_.Contains('*') ? $_ : "*.$_".Replace('..', '.'); } # Ensure that the destination directory exists, but only if there are # files to copy over which pass the include/exclude filters. This is # required in the case where CreateDirs has not been specified. # if (Get-ChildItem $sourceDirectoryWithWildCard ` -Include $adjustedFileIncludes -Exclude $adjustedFileExcludes) { if (-not(Test-Path -Path $destinationDirectory)) { New-Item -ItemType 'Directory' -Path $destinationDirectory -WhatIf:$whatIf } } Copy-Item -Path $sourceDirectoryWithWildCard ` -Include $adjustedFileIncludes -Exclude $adjustedFileExcludes ` -Destination $destinationDirectory -WhatIf:$whatIf; } # To be consistent with Invoke-ForeachFsItem, the user function/block is invoked # with the source directory info. The destination for this mirror operation is # returned via 'LOOPZ.MIRROR.DESTINATION' within the PassThru. # $_passThru['LOOPZ.MIRROR.DESTINATION'] = $destinationInfo; $invokee = $_passThru['LOOPZ.MIRROR.INVOKEE']; try { if ($invokee -is [scriptblock]) { $positional = @($_underscore, $_index, $_passThru, $_trigger); if ($_passThru.ContainsKey('LOOPZ.MIRROR.INVOKEE.PARAMS')) { $_passThru['LOOPZ.MIRROR.INVOKEE.PARAMS'] | ForEach-Object { $positional += $_; } } $invokee.Invoke($positional); } elseif ($invokee -is [string]) { [System.Collections.Hashtable]$parameters = $_passThru.ContainsKey('LOOPZ.MIRROR.INVOKEE.PARAMS') ` ? $_passThru['LOOPZ.MIRROR.INVOKEE.PARAMS'] : @{}; $parameters['Underscore'] = $_underscore; $parameters['Index'] = $_index; $parameters['PassThru'] = $_passThru; $parameters['Trigger'] = $_trigger; & $invokee @parameters; } else { Write-Warning "User defined function/block not valid, not invoking."; } } catch { Write-Error "function invoke error doMirrorBlock: error ($_) occurred for '$destinationBranch'"; } @{ Product = $destinationInfo } } #doMirrorBlock # ===================================================== [Invoke-MirrorDirectoryTree] === [string]$resolvedSourcePath = Convert-Path $Path; [string]$resolvedDestinationPath = Convert-Path $DestinationPath; $PassThru['LOOPZ.MIRROR.ROOT-SOURCE'] = $resolvedSourcePath; $PassThru['LOOPZ.MIRROR.ROOT-DESTINATION'] = $resolvedDestinationPath; if ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) { $PassThru['LOOPZ.MIRROR.INVOKEE'] = $Block; if ($BlockParams.Count -gt 0) { $PassThru['LOOPZ.MIRROR.INVOKEE.PARAMS'] = $BlockParams; } } else { $PassThru['LOOPZ.MIRROR.INVOKEE'] = $Functee; if ($FuncteeParams.Count -gt 0) { $PassThru['LOOPZ.MIRROR.INVOKEE.PARAMS'] = $FuncteeParams.Clone(); } } if ($PSBoundParameters.ContainsKey('WhatIf') -and ($true -eq $PSBoundParameters['WhatIf'])) { $PassThru['LOOPZ.MIRROR.WHAT-IF'] = $true; } [scriptblock]$filterDirectories = { [OutputType([boolean])] param( [System.IO.DirectoryInfo]$directoryInfo ) Select-FsItem -Name $directoryInfo.Name ` -Includes $DirectoryIncludes -Excludes $DirectoryExcludes; } Invoke-TraverseDirectory -Path $resolvedSourcePath ` -Block $doMirrorBlock -PassThru $PassThru -Summary $Summary ` -Condition $filterDirectories -Hoist:$Hoist; } # Invoke-MirrorDirectoryTree function Invoke-TraverseDirectory { <# .NAME Invoke-TraverseDirectory .SYNOPSIS Traverses a directory tree invoking a custom defined script-block or named function as it goes. .DESCRIPTION Navigates a directory tree applying custom functionality for each directory. A Condition script-block can be applied for conditional functionality. 2 parameters set are defined, one for invoking a named function (InvokeFunction) and the other (InvokeScriptBlock, the default) for invoking a scriptblock. An optional Summary script block can be specified which will be invoked at the end of the traversal batch. .PARAMETER Path The source Path denoting the root of the directory tree to be traversed. .PARAMETER Condition This is a predicate scriptblock, which is invoked with a DirectoryInfo object presented as a result of invoking Get-ChildItem. It provides a filtering mechanism that is defined by the user to define which directories are selected for function/scriptblock invocation. .PARAMETER PassThru A hash table containing miscellaneous information gathered internally throughout the traversal batch. This can be of use to the user, because it is the way the user can perform bi-directional communication between the invoked custom script block and client side logic. .PARAMETER Block The script block to be invoked. The script block is invoked for each directory in the source directory tree that satisfy the specified Condition predicate with the following positional parameters: * underscore: the DirectoryInfo object representing the directory in the source tree * index: the 0 based index representing current directory in the source tree * PassThru object: a hash table containing miscellaneous information gathered internally throughout the mirroring batch. This can be of use to the user, because it is the way the user can perform bi-directional communication between the invoked custom script block and client side logic. * trigger: a boolean value, useful for state changing idempotent operations. At the end of the batch, the state of the trigger indicates whether any of the items were actioned. When the script block is invoked, the trigger should indicate if the trigger was pulled for any of the items so far processed in the batch. This is the responsibility of the client's script-block/function implementation. In addition to these fixed positional parameters, if the invoked scriptblock is defined with additional parameters, then these will also be passed in. In order to achieve this, the client has to provide excess parameters in BlockParams and these parameters must be defined as the same type and in the same order as the additional parameters in the script-block. .PARAMETER BlockParams Optional array containing the excess parameters to pass into the script-block. .PARAMETER Functee String defining the function to be invoked. Works in a similar way to the Block parameter for script-blocks. The Function's base signature is as follows: "Underscore": (See underscore described above) "Index": (See index described above) "PassThru": (See PathThru described above) "Trigger": (See trigger described above) The destination DirectoryInfo object can be accessed via the PassThru denoted by the 'LOOPZ.MIRROR.DESTINATION' entry. .PARAMETER FuncteeParams Optional hash-table containing the named parameters which are splatted into the Functee function invoke. As it's a hash table, order is not significant. .PARAMETER Summary A script-block that is invoked at the end of the traversal batch. The script-block is invoked with the following positional parameters: * count: the number of items processed in the mirroring batch. * skipped: the number of items skipped in the mirroring batch. An item is skipped if it fails the defined condition or is not of the correct type (eg if its a directory but we have specified the -File flag). * trigger: Flag set by the script-block/function, but should typically be used to indicate whether any of the items processed were actively updated/written in this batch. This helps in written idempotent operations that can be re-run without adverse consequences. * PassThru: (see PassThru previously described) .PARAMETER Hoist Switch parameter. Without Hoist being specified, the Condition can prove to be too restrictive on matching against directories. If a directory does not match the Condition then none of its descendants will be considered to be traversed. When Hoist is specified then a descendant directory that does match the Condition will be traversed even though any of its ancestors may not match the same Condition. .EXAMPLE 1 Invoke a script-block for every directory in the source tree. [scriptblock]$block = { param( $underscore, [int]$index, [System.Collections.Hashtable]$passThru, [boolean]$trigger ) ... } Invoke-TraverseDirectory -Path './Tests/Data/fefsi' -Block $block .EXAMPLE 2 Invoke a named function with extra parameters for every directory in the source tree. function Test-Traverse { param( [System.IO.DirectoryInfo]$Underscore, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger, [string]$Format ) ... } [System.Collections.Hashtable]$parameters = @{ 'Format' = "=== {0} ==="; } Invoke-TraverseDirectory -Path './Tests/Data/fefsi' ` -Functee 'Test-Traverse' -FuncteeParams $parameters; .EXAMPLE 3 Invoke a named function, including only directories beginning with A (filter A*) function Test-Traverse { param( [System.IO.DirectoryInfo]$Underscore, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger ) ... } [scriptblock]$filterDirectories = { [OutputType([boolean])] param( [System.IO.DirectoryInfo]$directoryInfo ) Select-FsItem -Name $directoryInfo.Name -Includes @('A*'); } Invoke-TraverseDirectory -Path './Tests/Data/fefsi' -Functee 'Test-Traverse' ` -Condition $filterDirectories; Note the possible issue with this example is that any descendants named A... which are located under an ancestor which is not named A..., will not be processed by the provided function .EXAMPLE 4 Mirror a directory tree, including only directories beginning with A (filter A*) regardless of the matching of intermediate ancestors (specifying -Hoist flag resolves the possible issue in the previous example) function Test-Traverse { param( [System.IO.DirectoryInfo]$Underscore, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger ) ... } [scriptblock]$filterDirectories = { [OutputType([boolean])] param( [System.IO.DirectoryInfo]$directoryInfo ) Select-FsItem -Name $directoryInfo.Name -Includes @('A*'); } Invoke-TraverseDirectory -Path './Tests/Data/fefsi' -Functee 'Test-Traverse' ` -Condition $filterDirectories -Hoist; Note that the directory filter must include a wild-card, otherwise it will be ignored. So a directory include of @('A'), is problematic, because A is not a valid directory filter so its ignored and there are no remaining filters that are able to include any directory, so no directory passes the filter. #> [CmdletBinding(DefaultParameterSetName = 'InvokeScriptBlock')] [Alias('itd', 'Traverse-Directory')] param ( [Parameter(ParameterSetName = 'InvokeScriptBlock', Mandatory)] [Parameter(ParameterSetName = 'InvokeFunction', Mandatory)] [ValidateScript( { Test-path -Path $_ })] [String]$Path, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [ValidateScript( { -not($_ -eq $null) })] [scriptblock]$Condition = ( { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param([System.IO.DirectoryInfo]$directoryInfo) return $true; } ), [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [System.Collections.Hashtable]$PassThru = @{}, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [ValidateScript( { -not($_ -eq $null) })] [scriptblock]$Block, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [ValidateScript( { $_ -is [Array] })] $BlockParams = @(), [Parameter(ParameterSetName = 'InvokeFunction', Mandatory)] [ValidateScript( { -not([string]::IsNullOrEmpty($_)); })] [string]$Functee, [Parameter(ParameterSetName = 'InvokeFunction')] [ValidateScript( { $_.Length -gt 0; })] [System.Collections.Hashtable]$FuncteeParams = @{}, [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [scriptblock]$Summary = ( { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param( [int]$Count, [int]$Skipped, [boolean]$Triggered, [System.Collections.Hashtable]$PassThru ) }), [Parameter(ParameterSetName = 'InvokeScriptBlock')] [Parameter(ParameterSetName = 'InvokeFunction')] [switch]$Hoist ) # param # ======================================================= [recurseTraverseDirectory] === # [scriptblock]$recurseTraverseDirectory = { # Invoked by adapter [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param( [Parameter(Position = 0, Mandatory)] [System.IO.DirectoryInfo]$directoryInfo, [Parameter(Position = 1)] [ValidateScript( { -not($_ -eq $null) })] [scriptblock]$condition, [Parameter(Position = 2, Mandatory)] [ValidateScript( { -not($_ -eq $null) })] [System.Collections.Hashtable]$passThru, [Parameter(Position = 3)] [ValidateScript( { ($_ -is [scriptblock]) -or ($_ -is [string]) })] $invokee, # (scriptblock or function name; hence un-typed parameter) [Parameter(Position = 4)] [boolean]$trigger ) $result = $null; $index = $passThru['LOOPZ.FOREACH.INDEX']; try { # This is the invoke, for the current directory # if ($invokee -is [scriptblock]) { $positional = @($directoryInfo, $index, $passThru, $trigger); if ($passThru.ContainsKey('LOOPZ.TRAVERSE.INVOKEE.PARAMS') -and ($passThru['LOOPZ.TRAVERSE.INVOKEE.PARAMS'] -gt 0)) { $passThru['LOOPZ.TRAVERSE.INVOKEE.PARAMS'] | ForEach-Object { $positional += $_; } } $result = $invokee.Invoke($positional); } else { [System.Collections.Hashtable]$parameters = $passThru.ContainsKey('LOOPZ.TRAVERSE.INVOKEE.PARAMS') ` ? $passThru['LOOPZ.TRAVERSE.INVOKEE.PARAMS'] : @{}; # These are directory specific overwrites. The custom parameters # will still be present # $parameters['Underscore'] = $directoryInfo; $parameters['Index'] = $index; $parameters['PassThru'] = $passThru; $parameters['Trigger'] = $trigger; $result = & $invokee @parameters; } } catch { Write-Error "recurseTraverseDirectory Error: ($_), for item: '$($directoryInfo.Name)'"; } finally { $passThru['LOOPZ.FOREACH.INDEX']++; } [string]$fullName = $directoryInfo.FullName; [System.IO.DirectoryInfo[]]$directoryInfos = Get-ChildItem -Path $fullName ` -Directory | Where-Object { $condition.Invoke($_) }; [scriptblock]$adapter = $PassThru['LOOPZ.TRAVERSE.ADAPTOR']; if ($directoryInfos) { # adapter is always a script block, this has nothing to do with the invokee, # which may be a script block or a named function(functee) # $directoryInfos | Invoke-ForeachFsItem -Directory -Block $adapter ` -PassThru $PassThru -Condition $condition -Summary $Summary; } return $result; } # recurseTraverseDirectory # ======================================================================== [adapter] === # [scriptblock]$adapter = { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param( [Parameter(Mandatory)] [System.IO.DirectoryInfo]$_underscore, [Parameter(Mandatory)] [int]$_index, [Parameter(Mandatory)] [System.Collections.Hashtable]$_passThru, [Parameter(Mandatory)] [boolean]$_trigger ) [scriptblock]$adapted = $_passThru['LOOPZ.TRAVERSE.ADAPTED']; $adapted.Invoke( $_underscore, $_passThru['LOOPZ.TRAVERSE.CONDITION'], $_passThru, $PassThru['LOOPZ.TRAVERSE.INVOKEE'], $_trigger ); } # adapter # ======================================================= [Invoke-TraverseDirectory] === # Handle top level directory, before recursing through child directories # [System.IO.DirectoryInfo]$directory = Get-Item -Path $Path; [boolean]$itemIsDirectory = ($directory.Attributes -band [System.IO.FileAttributes]::Directory) -eq [System.IO.FileAttributes]::Directory; if ($itemIsDirectory) { [boolean]$trigger = $PassThru.ContainsKey('LOOPZ.FOREACH.TRIGGER'); [boolean]$broken = $false; # The index of the top level directory is always 0 # [int]$index = 0; if ('InvokeFunction' -eq $PSCmdlet.ParameterSetName) { # set-up custom parameters # [System.Collections.Hashtable]$parameters = $FuncteeParams.Clone(); $parameters['Underscore'] = $directory; $parameters['Index'] = $index; $parameters['PassThru'] = $PassThru; $parameters['Trigger'] = $trigger; $PassThru['LOOPZ.TRAVERSE.INVOKEE.PARAMS'] = $parameters; } elseif ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) { $positional = @($directory, $index, $PassThru, $trigger); if ($BlockParams.Count -gt 0) { $BlockParams | Foreach-Object { $positional += $_; } } # Note, for the positional parameters, we can only pass in the additional # custom parameters provided by the client here via the PassThru otherwise # we could accidentally build up the array of positional parameters with # duplicated entries. This is in contrast to splatted arguments for function # invokes where parameter names are paired with parameter values in a # hashtable and naturally prevent duplicated entries. This is why we set # 'LOOPZ.TRAVERSE.INVOKEE.PARAMS' to $BlockParams and not $positional. # $PassThru['LOOPZ.TRAVERSE.INVOKEE.PARAMS'] = $BlockParams; } if (-not($Hoist.ToBool())) { # We only want to manage the index via $PassThru when we are recursing # $PassThru['LOOPZ.FOREACH.INDEX'] = $index; } $result = $null; # This is the top level invoke # if ($Condition.Invoke($directory)) { try { if ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) { $result = $Block.Invoke($positional); } elseif ('InvokeFunction' -eq $PSCmdlet.ParameterSetName) { $result = & $Functee @parameters; } } catch { Write-Error "Invoke-TraverseDirectory(top-level) Error: ($_), for item: '$($directory.Name)'"; } finally { if ($Hoist.ToBool()) { $index++; } else { $PassThru['LOOPZ.FOREACH.INDEX']++; $index = $PassThru['LOOPZ.FOREACH.INDEX']; } } if ($result.psobject.properties.match('Trigger') -and $result.Trigger) { $PassThru['LOOPZ.FOREACH.TRIGGER'] = $true; $trigger = $true; } if ($result.psobject.properties.match('Break') -and $result.Break) { $broken = $true; } } # --- end of top level invoke ---------------------------------------------------------- if ($Hoist.ToBool()) { # Perform non-recursive retrieval of descendant directories # [System.IO.DirectoryInfo[]]$directoryInfos = Get-ChildItem -Path $Path ` -Directory -Recurse | Where-Object { $Condition.Invoke($_) } if ($directoryInfos) { # No need to manage the index, let Invoke-ForeachFsItem do this for us, # except we do need to inform Invoke-ForeachFsItem to start the index at # +1, because 0 is for the top level directory which has already been # handled. # [System.Collections.Hashtable]$parametersFeFsItem = @{ 'Directory' = $true; 'PassThru' = $PassThru; 'StartIndex' = $index; 'Summary' = $Summary; } if ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) { $parametersFeFsItem['Block'] = $Block; $parametersFeFsItem['BlockParams'] = $BlockParams; } else { $parametersFeFsItem['Functee'] = $Functee; $parametersFeFsItem['FuncteeParams'] = $FuncteeParams; } $directoryInfos | & 'Invoke-ForeachFsItem' @parametersFeFsItem; } } else { # Set up the adapter. (NB, can't use splatting because we're invoking a script block # as opposed to a named function.) # $PassThru['LOOPZ.TRAVERSE.CONDITION'] = $Condition; $PassThru['LOOPZ.TRAVERSE.ADAPTED'] = $recurseTraverseDirectory; $PassThru['LOOPZ.TRAVERSE.ADAPTOR'] = $adapter; if ('InvokeScriptBlock' -eq $PSCmdlet.ParameterSetName) { $PassThru['LOOPZ.TRAVERSE.INVOKEE'] = $Block; } elseif ('InvokeFunction' -eq $PSCmdlet.ParameterSetName) { $PassThru['LOOPZ.TRAVERSE.INVOKEE'] = $Functee; } # Now perform start of recursive traversal # [System.IO.DirectoryInfo[]]$directoryInfos = Get-ChildItem -Path $Path ` -Directory | Where-Object { $Condition.Invoke($_) } if ($directoryInfos) { $directoryInfos | Invoke-ForeachFsItem -Directory -Block $adapter ` -StartIndex $index -PassThru $PassThru -Condition $Condition -Summary $Summary; } [int]$skipped = 0; $index = $PassThru['LOOPZ.FOREACH.INDEX']; $trigger = $PassThru['LOOPZ.FOREACH.TRIGGER']; $Summary.Invoke($index, $skipped, $trigger, $PassThru); } } else { Write-Error "Path specified '$($Path)' is not a directory"; } } # Invoke-TraverseDirectory function Select-FsItem { <# .NAME Select-FsItem .SYNOPSIS A predicate function that indicates whether an item identified by the Name matches the include/exclude filters specified. .DESCRIPTION Use this utility function to help specify a Condition for Invoke-TraverseDirectory. This function is partly required because the Include/Exclude parameters on functions such as Get-ChildItems/Copy-Item/Get-Item etc only work on files not directories. .PARAMETER Name A string to be matched against the filters. .PARAMETER Includes An array containing a list of filters, each must contain a wild-card ('*'). If a particular filter does not contain a wild-card, then it will be ignored. If Name matches any of the filters in Includes, and are not Excluded, the result will be true. .PARAMETER Excludes An array containing a list of filters, each must contain a wild-card ('*'). If a particular filter does not contain a wild-card, then it will be ignored. If the Name matches any of the filters in the list, will cause the end result to be false. Any match in the Excludes overrides a match in Includes, so an item that is matched in Include, can be excluded by the Exclude. .PARAMETER Case Switch parameter which controls case sensitivity of inclusion/exclusion. By default filtering is case insensitive. When The Case switch is specified, filtering is case sensitive. .EXAMPLE 1 Define a Condition that allows only directories beginning with A, but also excludes any directory containing '_' or '-'. [scriptblock]$filterDirectories = { [OutputType([boolean])] param( [System.IO.DirectoryInfo]$directoryInfo ) [string[]]$directoryIncludes = @('A*'); [string[]]$directoryExcludes = @('*_*', '*-*'); Select-FsItem -Name $directoryInfo.Name ` -Includes $directoryIncludes -Excludes $directoryExcludes; Invoke-TraverseDirectory -Path <path> -Block <block> -Condition $filterDirectories; } #> [OutputType([boolean])] param( [Parameter(Mandatory)] [string]$Name, [Parameter()] [string[]]$Includes = @(), [Parameter()] [string[]]$Excludes = @(), [Parameter()] [switch]$Case ) # Note we wrap the result inside @() array designator just in-case the where-object # returns just a single item in which case the array would be flattened out into # an individual scalar value which is what we don't want, damn you powershell for # doing this and making life just so much more difficult. Actually, on further # investigation, we don't need to wrap inside @(), because we've explicitly defined # the type of the includes variables to be arrays, which would preserve the type # even in the face of powershell annoyingly flattening the single item array. @() # being left in for clarity and show of intent. # [string[]]$validIncludes = @($Includes | Where-Object { $_.Contains('*') }) [string[]]$validExcludes = @($Excludes | Where-Object { $_.Contains('*') }) [boolean]$resolvedInclude = $validIncludes ` ? (select-ResolvedFsItem -FsItem $Name -Filter $Includes -Case:$Case) ` : $false; [boolean]$resolvedExclude = $validExcludes ` ? (select-ResolvedFsItem -FsItem $Name -Filter $Excludes -Case:$Case) ` : $false; ($resolvedInclude) -and -not($resolvedExclude) } # Select-FsItem function Write-HostFeItemDecorator { <# .NAME Write-HostFeItemDecorator .SYNOPSIS Wraps a function or scriptblock as a decorator writing appropriate user interface info to the host for each entry in the pipeline. .DESCRIPTION The script-block/function (invokee) being decorated may or may not Support ShouldProcess. If it does, then the client should add 'WHAT-IF' to the pass through, set to the current value of WhatIf; or more accurately the existence of 'WhatIf' in PSBoundParameters. Or another way of putting it is, the presence of WHAT-IF indicates SupportsShouldProcess, and the value of 'WHAT-IF' dictates the value of WhatIf. This way, we only need a single value in the PassThru, rather than having to represent SupportShouldProcess explicitly with another value. The PastThru must contain either a 'LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME' entry meaning a named function is being decorated or 'LOOPZ.WH-FOREACH-DECORATOR.BLOCK' meaning a script block is being decorated, but not both. By default, Write-HostFeItemDecorator will display an item no for each object in the pipeline and a property representing the Product. The Product is a property that the invokee can set on the PSCustomObject it returns. However, additional properties can be displayed. This can be achieved by the invokee populating another property Pairs, which is an array of string based key/value pairs. All properties found in Pairs will be written out by Write-HostFeItemDecorator. By default, to render the value displayed (ie the 'Product' property item on the PSCustomObject returned by the invokee), ToString() is called. However, the 'Product' property may not have a ToString() method, in this case (you will see an error indicating ToString method not being available), the user should provide a custom script-block to determine how the value is constructed. This can be done by assigning a custom script-block to the 'LOOPZ.WH-FOREACH-DECORATOR.GET-RESULT' entry in PassThru. eg: [scriptblock]$customGetResult = { param($result) $result.SomeCustomPropertyOfRelevanceThatIsAString; } $PassThru['LOOPZ.WH-FOREACH-DECORATOR.GET-RESULT'] = $customGetResult; ... Note also, the user can provide a custom 'GET-RESULT' in order to control what is displayed by Write-HostFeItemDecorator. This function is designed to be used with Invoke-ForeachFsItem and as such, it's signature needs to match that required by Invoke-ForeachFsItem. Any additional parameters can be passed in via the PassThru. The rationale behind Write-HostFeItemDecorator is to maintain separation of concerns that allows development of functions that could be used with Invoke-ForeachFsItem which do not contain any UI related code. This strategy also helps for the development of different commands that produce output to the terminal in a consistent manner. .PARAMETER $Underscore The current pipeline object. .PARAMETER $Index The 0 based index representing current item in the pipeline. .PARAMETER $PassThru A hash table containing miscellaneous information gathered internally throughout the iteration batch. This can be of use to the user, because it is the way the user can perform bi-directional communication between the invoked custom script block and client side logic. .PARAMETER $Trigger A boolean value, useful for state changing idempotent operations. At the end of the batch, the state of the trigger indicates whether any of the items were actioned. When the script block is invoked, the trigger should indicate if the trigger was pulled for any of the items so far processed in the batch. This is the responsibility of the client's block implementation. .RETURNS The result of invoking the decorated script-block. .EXAMPLE 1 function Test-FN { param( [System.IO.DirectoryInfo]$Underscore, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger, ) $format = $PassThru['CLIENT.FORMAT']; @{ Product = $format -f $Underscore.Name, $Underscore.Exists } ... } [Systems.Collection.Hashtable]$passThru = @{ 'LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME' = 'Test-FN'; 'CLIENT.FORMAT' = '=== [{0}] -- [{1}] ===' } Get-ChildItem ... | Invoke-ForeachFsItem -Path <path> -PassThru $passThru -Functee 'Write-HostFeItemDecorator' So, Test-FN is not concerned about writing any output to the console, it simply does what it does silently and Write-HostFeItemDecorator handles generation of output. It invokes the function defined in 'LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME' and generates corresponding output. It happens to use the console colouring facility provided by a a dependency Elizium.Krayola to create colourful output in a predefined format via the Krayola Theme. Note, Write-HostFeItemDecorator does not forward additional parameters to the decorated function (Test-FN), but this can be circumvented via the PassThru as illustrated by the 'CLIENT.FORMAT' parameter in this example. #> [OutputType([PSCustomObject])] [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] [Alias('wife', 'Decorate-Foreach')] param ( [Parameter( Mandatory = $true )] $Underscore, [Parameter( Mandatory = $true )] [int]$Index, [Parameter( Mandatory = $true )] [ValidateScript( { return ($_.ContainsKey('LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME') -xor $_.ContainsKey('LOOPZ.WH-FOREACH-DECORATOR.BLOCK')) })] [System.Collections.Hashtable] $PassThru, [Parameter()] [boolean]$Trigger ) [scriptblock]$defaultGetResult = { param($result) try { $result.ToString(); } catch { Write-Error "Default get-result function failed, consider defining custom function as 'LOOPZ.WH-FOREACH-DECORATOR.GET-RESULT' in PassThru"; } } [scriptblock]$decorator = { param ($_underscore, $_index, $_passthru, $_trigger) if ($_passthru.Contains('LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME')) { [string]$functee = $_passthru['LOOPZ.WH-FOREACH-DECORATOR.FUNCTION-NAME']; [System.Collections.Hashtable]$parameters = @{ 'Underscore' = $_underscore; 'Index' = $_index; 'PassThru' = $_passthru; 'Trigger' = $_trigger; } if ($_passthru.Contains('WHAT-IF')) { $parameters['WhatIf'] = $_passthru['WHAT-IF']; } return & $functee @parameters; } elseif ($_passthru.Contains('LOOPZ.WH-FOREACH-DECORATOR.BLOCK')) { [scriptblock]$block = $_passthru['LOOPZ.WH-FOREACH-DECORATOR.BLOCK']; return $block.Invoke($_underscore, $_index, $_passthru, $_trigger); } } $invokeResult = $decorator.Invoke($Underscore, $Index, $PassThru, $Trigger); [string]$message = $PassThru['LOOPZ.WH-FOREACH-DECORATOR.MESSAGE']; [string]$productValue = [string]::Empty; [boolean]$ifTriggered = $PassThru.ContainsKey('LOOPZ.WH-FOREACH-DECORATOR.IF-TRIGGERED'); [boolean]$resultIsTriggered = $invokeResult.psobject.properties.match('Trigger') -and $invokeResult.Trigger; # Suppress the write if client has set IF-TRIGGERED and the result is not triggered. # This makes re-runs of a state changing operation less verbose if that's required. # if (-not($ifTriggered) -or ($resultIsTriggered)) { $getResult = $PassThru.Contains('LOOPZ.WH-FOREACH-DECORATOR.GET-RESULT') ` ? $PassThru['LOOPZ.WH-FOREACH-DECORATOR.GET-RESULT'] : $defaultGetResult; [string[][]]$themedPairs = @(, @('No', $("{0,3}" -f ($Index + 1)))); # Get Product if it exists # [string]$productLabel = [string]::Empty; if ($invokeResult -and $invokeResult.psobject.properties.match('Product') -and $invokeResult.Product) { $productValue = $getResult.Invoke($invokeResult.Product); $productLabel = $PassThru.ContainsKey('LOOPZ.WH-FOREACH-DECORATOR.PRODUCT-LABEL') ` ? $PassThru['LOOPZ.WH-FOREACH-DECORATOR.PRODUCT-LABEL'] : 'Product'; if (-not([string]::IsNullOrWhiteSpace($productLabel))) { $themedPairs += , @($productLabel, $productValue); } } # Get Key/Value Pairs # if ($invokeResult -and $invokeResult.psobject.properties.match('Pairs') -and $invokeResult.Pairs -and ($invokeResult.Pairs -is [Array]) -and ($invokeResult.Pairs.Count -gt 0)) { $themedPairs += $invokeResult.Pairs; } # Write with a Krayola Theme # [System.Collections.Hashtable]$krayolaTheme = ` $PassThru.ContainsKey('LOOPZ.WH-FOREACH-DECORATOR.KRAYOLA-THEME') ` ? $PassThru['LOOPZ.WH-FOREACH-DECORATOR.KRAYOLA-THEME'] : (Get-KrayolaTheme); [System.Collections.Hashtable]$parameters = @{} $parameters['Pairs'] = $themedPairs; $parameters['Theme'] = $krayolaTheme; [string]$writerFn = 'Write-ThemedPairsInColour'; if (-not([string]::IsNullOrWhiteSpace($message))) { $parameters['Message'] = $message; } if (-not([string]::IsNullOrWhiteSpace($writerFn))) { & $writerFn @parameters; } } return $invokeResult; } # Write-HostFeItemDecorator function edit-RemoveSingleSubString { <# .NAME edit-RemoveSingleSubString .SYNOPSIS Removes a sub-string from the target string provided. .DESCRIPTION Either the first or the last occurrence of a single can be removed depending on whether the Last flag has been set. .PARAMETER Target The string from which the subtraction is to occur. .PARAMETER Subtract The sub string to subtract from the Target. .PARAMETER Insensitive Flag to indicate if the search is case sensitive or not. By default, search is case sensitive. .PARAMETER Last Flag to indicate whether the last occurrence of a sub string is to be removed from the Target. #> [CmdletBinding(DefaultParameterSetName = 'Single')] [OutputType([string])] param ( [Parameter(ParameterSetName = 'Single')] [String]$Target, [Parameter(ParameterSetName = 'Single')] [String]$Subtract, [Parameter(ParameterSetName = 'Single')] [switch]$Insensitive, [Parameter(ParameterSetName = 'Single')] [Parameter(ParameterSetName = 'LastOnly')] [switch]$Last ) [StringComparison]$comparison = $Insensitive.ToBool() ? ` [StringComparison]::OrdinalIgnoreCase : [StringComparison]::Ordinal; $result = $Target; # https://docs.microsoft.com/en-us/dotnet/standard/base-types/best-practices-strings # if (($Subtract.Length -gt 0) -and ($Target.Contains($Subtract, $comparison))) { $slen = $Subtract.Length; $foundAt = $Last.ToBool() ? $Target.LastIndexOf($Subtract, $comparison) : ` $Target.IndexOf($Subtract, $comparison); if ($foundAt -eq 0) { $result = $Target.Substring($slen); } elseif ($foundAt -gt 0) { $result = $Target.Substring(0, $foundAt); $result += $Target.Substring($foundAt + $slen); } } $result; } function select-ResolvedFsItem { [OutputType([boolean])] param( [Parameter(Mandatory)] [string]$FsItem, [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Filter, [Parameter()] [switch]$Case ) [boolean]$liked = $false; [int]$counter = 0; do { $liked = $Case.ToBool() ` ? $FsItem -CLike $Filter[$counter] ` : $FsItem -Like $Filter[$counter]; $counter++; } while (-not($liked) -and ($counter -lt $Filter.Count)); $liked; } function get-AnswerAdvancedFn { # This function is only required because the tests using the invoke operator # on a string can not correctly pick up the local function name (ie defined as part # of the test fixture) and see its definition to be invoked. # [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] $Underscore, [Parameter(Mandatory)] [int]$Index, [Parameter(Mandatory)] [System.Collections.Hashtable]$PassThru, [Parameter(Mandatory)] [boolean]$Trigger ) [PSCustomObject]@{ Product = "{0}: {1}" -f $Underscore, $PassThru['ANSWER'] } } function get-AnswerAdvancedFnWithTrigger { # This function is only required because the tests using the invoke operator # on a string can not correctly pick up the local function name (ie defined as part # of the test fixture) and see its definition to be invoked. # [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] $Underscore, [Parameter(Mandatory)] [int]$Index, [Parameter(Mandatory)] [System.Collections.Hashtable]$PassThru, [Parameter(Mandatory)] [boolean]$Trigger ) [PSCustomObject]@{ Product = ("{0}: {1}" -f $Underscore, $PassThru['ANSWER']); Trigger = $true } } # WTF, this should be a helper file in Tests/Helpers, but putting this function there and trying to # dynamically invoke the function with the & operator from invoke-ForachFile doesnt find the # function definition, regardless of wether its sourced inside BeforeEach, or defined inline or # any other work-around. The only temporary shit solution, is to include the test helper function # inside the module implementation, which fucks me off to high fucking heaven. # function invoke-Dummy { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] param( [Alias('Underscore')] [System.IO.FileInfo]$FileInfo, [int]$Index, [System.Collections.Hashtable]$PassThru, [boolean]$Trigger ) Write-Warning "These aren't the droids you're looking for, ..., move along, move along!"; [PSCustomObject]@{ Product = $FileInfo; } } function Test-FileResult { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param( [Parameter(Mandatory)] [System.IO.FileInfo]$Underscore, [Parameter(Mandatory)] [int]$Index, [Parameter(Mandatory)] [System.Collections.Hashtable]$PassThru, [Parameter(Mandatory)] [boolean]$Trigger, [Parameter(Mandatory)] [string]$Format ) [string]$result = $Format -f ($Underscore.Name); Write-Debug "Custom function; Test-FileResult: '$result'"; @{ Product = $Underscore } } function Test-FireEXBreak { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param( [Parameter(Mandatory)] [System.IO.DirectoryInfo]$Underscore, [Parameter(Mandatory)] [int]$Index, [Parameter(Mandatory)] [System.Collections.Hashtable]$PassThru, [Parameter(Mandatory)] [boolean]$Trigger ) $break = ('EX' -eq $Underscore.Name); Write-Host " [-] Test-FireEXBreak(index: $Index): directory: $($Underscore.Name)"; @{ Product = $Underscore; Break = $break } } function Test-FireEXTrigger { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param( [Parameter(Mandatory)] [System.IO.DirectoryInfo]$Underscore, [Parameter(Mandatory)] [int]$Index, [Parameter(Mandatory)] [System.Collections.Hashtable]$PassThru, [Parameter(Mandatory)] [boolean]$Trigger ) $localTrigger = ('EX' -eq $Underscore.Name); Write-Host " [-] Test-FireEXTrigger(index: $Index, local trigger: $localTrigger, Trigger: $Trigger): directory: $($Underscore.Name)"; @{ Product = $Underscore; Trigger = $localTrigger } } function Test-HoistResult { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param( [Parameter(Mandatory)] [System.IO.DirectoryInfo]$Underscore, [Parameter(Mandatory)] [int]$Index, [Parameter(Mandatory)] [System.Collections.Hashtable]$PassThru, [Parameter(Mandatory)] [boolean]$Trigger, [Parameter(Mandatory = $false)] [string]$Format = "These aren't the droids you're looking for, ..., move along, move along!:___{0}___" ) [string]$result = $Format -f ($Underscore.Name); Write-Debug "Custom function; Test-HoistResult: '$result'"; @{ Product = $Underscore } } function Test-ShowMirror { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] param( [Parameter(Mandatory)] [System.IO.DirectoryInfo]$Underscore, [Parameter(Mandatory)] [int]$Index, [Parameter(Mandatory)] [System.Collections.Hashtable]$PassThru, [Parameter(Mandatory)] [boolean]$Trigger, [Parameter(Mandatory)] [string]$Format ) [string]$result = $Format -f ($Underscore.Name); Write-Debug "Custom function; Show-Mirror: '$result'"; @{ Product = $Underscore } } Export-ModuleMember -Variable LoopzHelpers, LoopzUI Export-ModuleMember -Function Invoke-ForeachFsItem, Invoke-MirrorDirectoryTree, Invoke-TraverseDirectory, Select-FsItem, Write-HostFeItemDecorator |