Einstein.Progress.psm1

##############################################################################
#.SYNOPSIS
# Performs an operation against each of a set of input objects with the aid
# of a progress indicator.
#
#.DESCRIPTION
# This function is very similar to the ForEach-Object command in that it takes
# a ScriptBlock as a parameter and executes that ScriptBlock once for each
# item on the pipeline. Progress-Object however, presents an automatic
# progress bar which is often useful but can be frustrating to implement for
# every function.
#
# Due to the way Measure-Progress operates, it is not ideal for all scenarios.
# Particularly, this function must buffer all input objects first before any
# of them can be processed. Until all the input objects are gathered, it is
# impossible to know how far along you are in processing them.
#
# Do not use Measure-Progress when the size of the input pipeline cannot be
# reasonably estimated. If the input pipeline is very large, you may run out
# of memory. If the input pipeline takes a long time to produce or runs
# indefinitely, Measure-Progress is not ideal because gathering objects will
# take longer than actually processing them.
#
# Measure-Progress is very useful for churning a bunch of files. Since you can
# usually list the files pretty quickly, you would put Get-ChildItem to the
# left of Measure-Progress and the processing commands to the right.
#
#.EXAMPLE
# Dir C:\largefiles\* | Measure-Progress | Copy-Item -dest c:\archive
#
#.EXAMPLE
# # assumes alias %%
# 1..10 | %% { Sleep $_ } -Activity 'Sleeping' -Status {"for $_ seconds"}
#
#.LINK
# ForEach-Object
##############################################################################
function Measure-Progress {

    [Alias('%%')]
    [CmdletBinding()]
    param(
    
        # Specifies the input objects.
        # Measure-Progress runs the script block on each input object. Enter a variable that contains the objects, or
        # type a command or expression that gets the objects. When you use the InputObject parameter with Measure-Progress,
        # instead of piping command results to Measure-Progress, the InputObject value even if the value is a collection
        # that is the result of a command, such as -InputObject (Get-Process) is treated as a single object.
        [Parameter(ValueFromPipeline=$true)]
        [Object] $InputObject,
        
        # A ScriptBlock that is called once for each item on the pipeline, after all
        # of the input objects have been gathered and counted. Use the automatic
        # variable $_ to refer to the pipeline object.
        [Parameter(Mandatory=$false, Position=1)]
        [ScriptBlock[]] $Process,
    
        # A ScriptBlock or static value that will be used as the Activity message
        # for Write-Progress calls. When using a ScriptBlock, $_ can be used to
        # refer to the current input object.
        [Alias('ProgressActivity', 'pa')]
        [Parameter()]
        [Object] $Activity = 'Processing',
        
        # A ScriptBlock or static value that will be used as the Status message
        # for Write-Progress calls. When using a ScriptBlock, $_ can be used to
        # refer to the current input object. If Status is not specified, the current
        # pipeline object is used as the status message.
        [Alias('ProgressStatus', 'ps')]
        [Parameter()]
        [Object] $Status,
        
        # Specifies an ID that distinguishes each progress bar from the others. Use this
        # parameter when you are creating more than one progress bar in a single command.
        # If the progress bars do not have different IDs, they are superimposed instead
        # of being displayed in series.
        [Alias('ProgressID', 'pn')]
        [Parameter()]
        [ValidateRange(0, 0x7FFFFFFF )]
        [Int32] $Id = $(Get-Random -Min 100000 -Max 1000000),

        # Identifies the parent activity of the current activity. Use the value -1 if
        # the current activity has no parent activity.
        [Alias('ProgressParentID', 'pp')]
        [Parameter()]
        [ValidateRange(-1, 0x7FFFFFFF)]        
        [Int32] $ParentId = -1,
        
        # Minimum delay, in milliseconds, between progress updates. Higher values can
        # drastically speed up performance when dealing with a large number of inputs.
        [Alias('ProgressInterval', 'pi')]
        [Parameter()]
        [ValidateRange(0, 0x7FFFFFFF)]
        [Int32] $ProgressDelay = 500,

        # By default, Measure-Progress estimates the time remaining based on the average
        # time it took to reach the current progress point. If this simple calculation is
        # known to be inaccurate (i.e. because each item is known to take longer than the
        # previous), -NoEstimate will suppress the estimated time remaining from progress
        # messages.
        [Parameter()]
        [Switch] $NoEstimate
        
    )

    begin { 

        function InvokeScriptBlock {
            [CmdletBinding()]
            param(
                [Parameter(Position=1)]
                [ScriptBlock[]]$ScriptBlock,
                [Parameter(ValueFromPipeline=$true)]
                [Object]$InputObject
            )
            process {
                if ($InputObject -ne $Null) {
                    $Variables = [PSVariable[]]@(
                        New-Object PSVariable @('_', $InputObject)
                    )
                    foreach ($SB in $ScriptBlock) {
                        $SB.InvokeWithContext($Null, $Variables, $Null)
                    }
                }
                else {
                    foreach ($SB in $ScriptBlock) {
                        $SB.Invoke()
                    }
                }
            }
        }

        function GetActivity($UnderBar) {
            if ($Activity -is [ScriptBlock]) {
                $Variables = [PSVariable[]]@(New-Object PSVariable @('_', $UnderBar))
                $Result = "$($Activity.InvokeWithContext($Null, $Variables, $Null))"
                Return $Result
            }
            else {
                $Result = "$Activity"
                if ( [String]::IsNullOrEmpty($Result) ) { $Result = 'Processing' }
                Return $Result
            }
        }

        function GetStatus($UnderBar) {
            if ($Status -is [ScriptBlock]) {
                $Variables = [PSVariable[]]@(New-Object PSVariable @('_', $UnderBar))
                $Result = "$($Status.InvokeWithContext($Null, $Variables, $Null))"
                Return $Result
            }
            else {
                $Result = "$UnderBar"
                if ( [String]::IsNullOrEmpty($Result) ) { $Result = '(empty)' }
                Return $Result
            }
        }

        $StopWatch = New-Object System.Diagnostics.Stopwatch        # throttles progress
        $Items = New-Object System.Collections.Generic.List[Object] # holds inputobjects

        # we may be gathering for a while so write a message to that effect
        Write-Progress -Id $Id -ParentId $ParentId `
                       -Activity $(GetActivity) `
                       -Status 'Gathering input...'

        $StopWatch.Reset()
        $StopWatch.Start()
        
    }

    process {

        $Items.Add($InputObject)

        # Write a progress record but not more than once every 200ms
        # (otherwise this slows down the processing anyway)
        if ( (-not $StopWatch.IsRunning) -or ($StopWatch.ElapsedMilliseconds -gt $ProgressDelay) ) {

            Write-Progress -Id $Id -ParentId $ParentId `
                           -Activity $(GetActivity) `
                           -Status "Gathering input... ($($Items.Count))"
                           
            $StopWatch.Reset()
            $StopWatch.Start()

        }
        
    }
    
    end {

        $StartTime = [DateTime]::UtcNow

        $StopWatch.Reset()

        function FeedItemsWithProgress($ItemsSource) {

            [Double]$Count = $ItemsSource.Count

            for ($i = 0; $i -lt $Items.Count; $i++) {

                $Item = $ItemsSource[$i]

                # Write a progress record but not more than once every 500ms
                # (otherwise this slows down the processing anyway)
                if ( (-not $StopWatch.IsRunning) -or ($StopWatch.ElapsedMilliseconds -gt $ProgressDelay) ) {

                    $Percent = (($i / $Count) * 100)

                    # Calculate seconds remaining based on the average time it
                    # took to process the items we've processed so far.
                    $SecondsRemaining = -1
                    if (!$NoEstimate -and $i -gt 0) {

                        $Elapsed = [DateTime]::UtcNow.Subtract($StartTime).TotalSeconds
                        $SecondsRemaining = ($Count - $i) * ($Elapsed / $i)

                    }
                
                    Write-Progress -Id $Id -ParentId $ParentId `
                                   -Activity $(GetActivity $Item) `
                                   -Status $(GetStatus $Item) `
                                   -PercentComplete $Percent `
                                   -SecondsRemaining $SecondsRemaining 

                    $StopWatch.Reset()
                    $StopWatch.Start()

                }
                
                Write-Output $Item
                
            }

        } 
        
        if ($Process) { 
            FeedItemsWithProgress $Items |
            ForEach-Object {
                $Variables = [PSVariable[]]@(New-Object PSVariable @('_', $_))
                foreach ($SB in $Process) {
                    $SB.InvokeWithContext($Null,$Variables,$Null)
                }
            } 
        }
        else { 
            FeedItemsWithProgress $Items 
        }
        
        # write a completion message
        Write-Progress -Id $Id -ParentId $ParentId `
                       -Activity $(GetActivity) `
                       -Status 'Done' `
                       -PercentComplete 100 -Completed
    
    }

}