Friday, November 12, 2010

Select-Multiple

Where I work, we have a policy that before anyone on the team commits changes to the repository, a code review needs to be done. All the other member of my team use TortoiseSVN, so they just use the option to display modifications and then double-click on each file to show a diff with the base. I stubbornly insist on do everything from the command-line, so what I usually do is run this command, which uses WinMerge to show the differences for all of my modified files:



This usually works just fine, but it has a couple of drawbacks. The main problem is that it shows a diff for every modified file, including project files. Sometimes, even if the majority of the files are source files, my code review really only concerns a subset of the files that I have modified, but my loop above dutifully runs WinMerge for every file, forcing my colleagues to waste precious seconds while I dismiss the files they aren't interested in.

Enter the Select-Multiple script. Objects piped into Select-Multiple are displayed in a kind of selection window with a "check box" to the left of each object. The list can be navigated with the arrow keys and options can be selected with the space bar. After the list is dismissed with the enter key, the selected objects are passed through to the pipeline. Here's what it looks like in action:



If you want to display a certain property of the objects passed in to Select-Multiple, you can use the Property parameter:



Even if you use the Property parameter, the complete object is passed through the pipeline. The above example would stop the Internet Explorer process.

There are a few keyboard shortcuts to aid in selecting objects. Page up and page down will navigate long lists a page at a time. Control-A will select everything, and Control-T will toggle the selection. Holding down shift while using the up and down arrow keys will select while moving. Escape will dismiss the window without selecting anything.

Here is the complete script:

# Select-Multiple
#
# Objects piped into Select-Multiple are displayed in a kind of
# selection window with a "check box" to the left of each object.
#
# The list can be navigated with the arrow keys and options 
# can be selected with the space bar.
#
# If the Property parameter is provided, the value of that
# property is displayed in the list, rather than the string
# representation of the object.
#
# After the list is dismissed with the enter key, the selected
# objects are passed through to the pipeline.
#
# Keyboard operations:
#  Up/Down - navigate
#  Space - select current option
#  Page Up/Down - navigate long lists a page at a time
#  Ctrl-A - select all
#  Ctrl-T - toggle selection
#  Shift-Up/Down - select and move
#  Escape - exit with no objects selected

# configuration 
$script:ForegroundColor = $Host.UI.RawUI.ForegroundColor
$script:BackgroundColor = $Host.UI.RawUI.BackgroundColor
$script:BorderForegroundColor = $Host.UI.RawUI.BackgroundColor
$script:BorderBackgroundColor = $Host.UI.RawUI.ForegroundColor
$script:SelectedForegroundColor = [ConsoleColor]"White"
$script:SelectedBackgroundColor = [ConsoleColor]"Red"
$script:StatusFormatString = "[X]: {0}/{1}"

function Select-Multiple( [string] $Property )
{
    begin
    {
        $options = @()
        # leave room for the border and "check box"
        $maxLength = $Host.UI.RawUI.WindowSize.Width - 2 - 6;

        function Make-Option( [object] $object )
        {
            $object | Select-Object `
                @{ Name = "Selected"; Expression = { $false } },
                @{ Name = "Object"; Expression = { $object } },
                @{ Name = "String";
                   Expression = {
                       $string = ""

                       if ( $Property )
                       {
                           $string = ( Invoke-Expression "`$object.$Property" ).ToString()
                       }
                       else
                       {
                           $string = $object.ToString()
                       }

                       if ( $string.Length -gt $maxLength )
                       {
                            # add elipsis...
                            $string = 
                                $string.SubString( 0, $maxLength - 3 ).PadRight( $maxLength, '.' )
                       }

                       $string
                   } }
        }
    }

    process
    {
        if ( $_ )
        {
            $options += Make-Option $_
        }
    }

    end
    {
        if ( $options.Length -gt 0 )
        {
            Show-Options $options
    
            foreach ( $option in $options )
            {
                if ( $option.Selected )
                {
                    $option.Object
                }
            }
        }
    }
}

function Show-Options( [object[]] $options )
{
    begin
    {
        function Get-KeyboardInput( $state )
        {
            $keyOptions = [System.Management.Automation.Host.ReadKeyOptions] `
                "NoEcho, IncludeKeyDown, IncludeKeyUp"

            $done = $false

            while ( $Host.UI.RawUI.KeyAvailable )
            {
                $key = $Host.UI.RawUI.ReadKey( $keyOptions )

                if ( $key.KeyDown )
                {
                    $shiftPressed = $key.ControlKeyState.ToString() -match "ShiftPressed"
                    $controlPressed = $key.ControlKeyState.ToString() -match "CtrlPressed"

                    switch ( $key.VirtualKeyCode )
                    {
                        38 { # up arrow
                            if ( $state.SelectedIndex -gt 0 )
                            {
                                Move-Up $state $shiftPressed
                            }
                            break
                        }
                        40 { # down arrow
                            if ( $state.SelectedIndex -lt $state.TotalOptions - 1 )
                            {
                                Move-Down $state $shiftPressed
                            }
                            break
                        }
                        33 { # page up
                            Page-Up $state
                            break
                        }
                        34 { # page down
                            Page-down $state
                            break
                        }
                        65 { # 'a' - select all
                            if ( $controlPressed )
                            {
                                Select-All $state
                            }
                            break
                        }
                        84 { # 't' - toggle selection
                            if ( $controlPressed )
                            {
                                Toggle-Selection $state
                            }
                            break
                        }
                        32 { # space - toggle current option
                            Select-Current $state $true
                        }
                        13 { # enter
                            $done = $true
                        }
                        27 { # escape
                            foreach ( $option in $state.Options )
                            {
                                $option.Selected = $false
                            }

                            $done = $true
                        }
                    }
                }
            }

            $done
        }

        function Move-Up( $state, $shiftPressed )
        {
            if ( $shiftPressed )
            {
                Select-Current $state $false
            }

            Set-SelectionColors `
                $state `
                $script:ForegroundColor `
                $script:BackgroundColor

            if ( $state.SelectedIndex - $state.Offset -eq 0 )
            {
                 --$state.Offset
                 Draw-Options $state
            }

            --$state.SelectedIndex

            Set-SelectionColors `
                $state `
                $script:SelectedForegroundColor `
                $script:SelectedBackgroundColor

            if ( $shiftPressed )
            {
                Select-Current $state $false
            }
        }

        function Move-Down( $state, $shiftPressed )
        {
            if ( $shiftPressed )
            {
                Select-Current $state $false
            }

            Set-SelectionColors `
                $state `
                $script:ForegroundColor `
                $script:BackgroundColor

            if ( $state.SelectedIndex - $state.Offset -eq $state.MaxOptions )
            {
                 ++$state.Offset
                 Draw-Options $state
            }

            ++$state.SelectedIndex

            Set-SelectionColors `
                $state `
                $script:SelectedForegroundColor `
                $script:SelectedBackgroundColor

            if ( $shiftPressed )
            {
                Select-Current $state $false
            }
        }

        function Page-Up( $state )
        {
            Set-SelectionColors `
               $state `
               $script:ForegroundColor `
               $script:BackgroundColor

            if ( $state.SelectedIndex -eq $state.Offset )
            {
                $state.Offset -= $state.MaxOptions

                $state.Offset = [Math]::Max( $state.Offset, 0 )
            }

            $state.SelectedIndex = $state.Offset

            Draw-Options $state

            Set-SelectionColors `
                $state `
                $script:SelectedForegroundColor `
                $script:SelectedBackgroundColor
        }

        function Page-Down( $state )
        {
            Set-SelectionColors `
               $state `
               $script:ForegroundColor `
               $script:BackgroundColor

            if ( $state.SelectedIndex -eq $state.MaxOptions + $state.Offset )
            {
                $state.Offset += $state.MaxOptions

                $state.Offset = [Math]::Min( 
                    $state.Offset,
                    $state.TotalOptions - $state.MaxOptions - 1 )
            }

            $state.SelectedIndex = $state.MaxOptions + $state.Offset

            $state.SelectedIndex = [Math]::Min( 
                    $state.SelectedIndex,
                    $state.TotalOptions - 1 )

            Draw-Options $state

            Set-SelectionColors `
                $state `
                $script:SelectedForegroundColor `
                $script:SelectedBackgroundColor
        }

        function Select-All( $state )
        {
            foreach ( $option in $state.Options )
            {
                $option.Selected = $true
            }

            $state.SelectedOptions = $state.TotalOptions

            Draw-Options $state

            Set-SelectionColors `
                $state `
                $script:SelectedForegroundColor `
                $script:SelectedBackgroundColor
        }

        function Toggle-Selection( $state )
        {
            $state.SelectedOptions = 0

            foreach ( $option in $state.Options )
            {
                $option.Selected = -not $option.Selected

                if ( $option.Selected )
                {
                    ++$state.SelectedOptions
                }
            }

            Draw-Options $state

            Set-SelectionColors `
                $state `
                $script:SelectedForegroundColor `
                $script:SelectedBackgroundColor
        }

        function Select-Current( $state, $toggleSelection )
        {
            $option = $state.Options[ $state.SelectedIndex ]

            if ( $toggleSelection )
            {
                $option.Selected = -not $option.Selected

                if ( $option.Selected )
                {
                    ++$state.SelectedOptions
                }
                else
                {
                    --$state.SelectedOptions
                }
            }
            else
            {
                if ( -not $option.Selected )
                {
                    ++$state.SelectedOptions
                }

                $option.Selected = $true
            }

            Draw-Options $state

            Set-SelectionColors `
                $state `
                $script:SelectedForegroundColor `
                $script:SelectedBackgroundColor
        }

        function Draw-Options( $state )
        {
            $optionStrings = @( $state.Options )[ 
                $state.Offset .. 
                ( $state.MaxOptions + $state.Offset ) ] | Foreach-Object {
                    if ( $_.Selected )
                    {
                        " [X] $($_.String) ".PadRight( $state.Width ) 
                    }
                    else
                    { 
                        " [ ] $($_.String) ".PadRight( $state.Width )
                    }
                }

            $status = ( $script:StatusFormatString -f 
                $state.SelectedOptions, $state.TotalOptions ).PadRight( $state.Width )

            $statusBuffer = $Host.UI.RawUI.NewBufferCellArray(
                @( $status ),
                $script:BorderForegroundColor, $script:BorderBackgroundColor )

            $Host.UI.RawUI.SetBufferContents( $state.StatusPosition, $statusBuffer )

            $optionsBuffer = $Host.UI.RawUI.NewBufferCellArray(
                @( $optionStrings ),
                $script:ForegroundColor, $script:BackgroundColor )

            $Host.UI.RawUI.SetBufferContents( $state.OptionsPosition, $optionsBuffer )
        }

        function Set-SelectionColors( $state, $foregroundColor, $backgroundColor )
        {
            $position = $state.OptionsPosition
            $position.+= $state.SelectedIndex - $state.Offset

            $rectangle = New-Object System.Management.Automation.Host.Rectangle `
                $position.X, $position.Y, ( $position.+ $state.Width - 1 ), $position.Y

            $optionBuffer = $Host.UI.RawUI.GetBufferContents( $rectangle )

            $contents = @( [string]::Join( "", 
                ( $optionBuffer | ForEach-Object { $_.Character } ) ) )

            $optionBuffer = $Host.UI.RawUI.NewBufferCellArray( 
                $contents, 
                $foregroundColor, 
                $backgroundColor )

            $Host.UI.RawUI.SetBufferContents( $position, $optionBuffer )
        }
    }

    end
    {
        $width = @( $options | Foreach-Object { $_.String } |
            Sort-Object Length -Descending )[ 0 ].Length + 4

        $width = [Math]::Max( 
            $width,    
            ( $script:StatusFormatString -f $options.Length, $options.Length ).Length - 2 )

        $height = $options.Length

        # create border
        $windowPosition  = $Host.UI.RawUI.WindowPosition
        $windowSize = $Host.UI.RawUI.WindowSize
        $cursorPosition = $Host.UI.RawUI.CursorPosition
        $center = [Math]::Truncate( [float]$windowSize.Height / 2 )
        $cursorOffset = $cursorPosition.- $windowPosition.Y
        $cursorOffsetBottom = $windowSize.Height - $cursorOffset

        $optionsBoxWidth = $width + 2
        $optionsBoxHeight = $height + 2

        $x = $cursor.X

        if ( ( $cursorOffset -gt $center ) -and ( $height -ge $cursorOffsetBottom ) )
        {
            $maxHeight = $cursorOffset

            if ( $maxHeight -lt $optionsBoxHeight )
            {
                $optionsBoxHeight = $maxHeight
            }

            $y = $cursorOffset - $optionsBoxHeight
        }
        else
        {
            $maxHeight = $cursorOffsetBottom - 1

            if ( $maxHeight -lt $optionsBoxHeight )
            {
                $optionsBoxHeight = $maxHeight
            }

            $y = $cursorOffSet + 1
        }

        $borderStrings = 1 .. ( $optionsBoxHeight ) | ForEach-Object {
            " " * ( $optionsBoxWidth + 2 )
        }

        $borderBuffer = $Host.UI.RawUI.NewBufferCellArray( 
            $borderStrings, 
            $script:BorderForegroundColor, 
            $script:BorderBackgroundColor )

        $borderPosition = $Host.UI.RawUI.WindowPosition
        $borderPosition.+= $x
        $borderPosition.+= $y

        $borderTop = $borderBottom = $borderPosition
        $borderBottom.+= ( $borderBuffer.GetUpperBound( 1 ) )
        $borderBottom.+= ( $borderBuffer.GetUpperBound( 0 ) )

        $borderRectangle = 
            New-Object System.Management.Automation.Host.Rectangle $borderTop, $borderBottom

        $oldContents = $Host.UI.RawUI.GetBufferContents( $borderRectangle )

        $Host.UI.RawUI.SetBufferContents( $borderTop, $borderBuffer )

        # initialize state object
        $statusPosition = $borderPosition
        $statusPosition.+= 1
        $statusPosition.+= $optionsBoxHeight - 1

        $optionsPosition = $borderPosition
        $optionsPosition.+= 1
        $optionsPosition.+= 1

        $state = "" | Select-Object `
            @{ Name = "Options"; Expression = { ,$options } },
            @{ Name = "SelectedIndex"; Expression = { 0 } },
            @{ Name = "Offset"; Expression = { 0 } },
            @{ Name = "StatusPosition"; Expression = { $statusPosition } },
            @{ Name = "OptionsPosition"; Expression = { $optionsPosition } },
            @{ Name = "Width"; Expression = { $optionsBoxWidth } },
            @{ Name = "Height"; Expression = { $optionsBoxHeight } },
            @{ Name = "MaxOptions"; Expression = { $maxHeight - 3 } },
            @{ Name = "SelectedOptions"; Expression = { 0 } },
            @{ Name = "TotalOptions"; Expression = { $options.Length } }
            
        Draw-Options $state

        Set-SelectionColors `
            $state `
            $script:SelectedForegroundColor `
            $script:SelectedBackgroundColor

        $message = ""

        do
        {
            $finished = Get-KeyboardInput $state
        }
        while ( -not $finished )

        $Host.UI.RawUI.SetBufferContents( $borderTop, $oldContents )
   }
}


Now, when I do a code review and I don't want to show every file, I run this command, which allows me to select only the files I want (I used aliases in this example to make the command shorter):

Thursday, November 5, 2009

Google Account API

This post isn't directly related to PowerShell, but I figured it is better to put it here than nowhere at all. I was looking at my Send-Email and Send-SMS cmdlets the other day, and I figured that the ability to send email or SMS through Gmail and Google Voice might be useful in environments other than PowerShell.

I created a C# class library that provides an API for sending an email through Gmail or an SMS through Google Voice. I added to it a very simple class for dealing with Google Contacts. Google's Contacts API is much more powerful, but I didn't want to add a dependency to my project. This is also partly inspired by my genius little brother, who has developed some similar APIs for Python.

All of the classes in this library use a custom HttpRequester class to make their requests to Google's services. This class keeps track of cookies between requests, handles redirection, and also provides a method for determining the MIME type of a file.

The Gmail class provides a simple way to create an email message and sent it, with full attachment support. Here is the updated code from my Send-Email cmdlet, which, as you can see, is much simpler than the old code:

private void SendGmailMessage()

{

    IntPtr bstr = Marshal.SecureStringToBSTR( googlePassword );

    string plainGooglePassword = Marshal.PtrToStringAuto( bstr );

    Marshal.ZeroFreeBSTR( bstr );

 

    GoogleAccount.Gmail gmailMessage = new GoogleAccount.Gmail( googleUsername, plainGooglePassword );

 

    gmailMessage.OnStatus += new GoogleAccount.Gmail.StatusEventHandler( HandleGmailStatus );

    gmailMessage.OnError += new GoogleAccount.Gmail.ErrorEventHandler( HandleGmailError );

 

    foreach ( string recipient in toArray )

    {

        gmailMessage.To.Add( recipient );

    }

 

    foreach ( string recipient in ccArray )

    {

        gmailMessage.Cc.Add( recipient );

    }

 

    foreach ( string recipient in bccArray )

    {

        gmailMessage.Bcc.Add( recipient );

    }

 

    if ( !string.IsNullOrEmpty( subject ) )

    {

        gmailMessage.Subject = subject;

    }

 

    if ( !string.IsNullOrEmpty( body ) )

    {

        gmailMessage.Body = body;

    }

 

    gmailMessage.BodyIsHtml = bodyIsHtml.IsPresent;

    gmailMessage.Timeout = timeout;

 

    foreach ( FileInfo attachment in inputAttachments )

    {

        gmailMessage.Attachments.Add( attachment );

    }

 

    if ( ShouldProcess( gmailMessage.Subject ) )

    {

        gmailMessage.Send();

    }

}


You might notice the OnStatus and OnError event handlers being set up at the beginning of the function. These are used to report errors and status messages during the several requests necessary to send an email. These handlers are used by the Send-Email cmdlet to provide proper support for the -Verbose parameter.

The SMS class is used in a very similar way. This is how I use it in my Send-SMS cmdlet:

protected override void EndProcessing()

{

    IntPtr bstr = Marshal.SecureStringToBSTR( googlePassword );

    string plainGooglePassword = Marshal.PtrToStringAuto( bstr );

    Marshal.ZeroFreeBSTR( bstr );

 

    GoogleAccount.SMS sms = new GoogleAccount.SMS( googleUsername, plainGooglePassword );

 

    sms.OnStatus += new GoogleAccount.SMS.StatusEventHandler( HandleGoogleVoiceStatus );

    sms.OnError += new GoogleAccount.SMS.ErrorEventHandler( HandleGoogleVoiceError );

 

    sms.Number = this.number;

    sms.Text = this.text;

 

    if ( ShouldProcess( sms.Text ) )

    {

        sms.Send();

    }

}


The Contacts class provides read-only access to each of your Google contacts through a Contact class that has fields for first name, last name, email address, mobile phone, and groups. I create this list by downloading a CSV file and parsing it. Yes, I know I shouldn't parse the CSV data myself, but .NET doesn't have a built-in CSV parser, and I didn't want to add a dependency to a 3rd-party library. It seems to work just fine for its simple purpose.

I wrote a small application to test the Contacts and SMS classes. It is called Mass SMS, and it allows you to choose several contacts to send one SMS message to, a service not provided on the Google Voice site. Here's how it works. First, you log in using your Google username and password:

A pretty self-explanatory dialog for choosing contacts is then displayed. This dialog allows you to filter your contacts by group, such as Family:
Once you have selected the contacts you want to receive your SMS, a dialog is displayed that allows you to specify the message:
The final dialog displays the status of each message as it is being sent:
Pretty simple, but it gets the job done. It was a fun little project to put together, and maybe I will actually use it someday! If you think you might be able to use it, please feel free.