Category Archives: Quick Learn

Practical examples of PowerShell concepts gathered from specific projects, forum replies, or general PowerShell use.

Linux Prompt X


Notice: The following post was originally published on another website. As the post is no longer accessible, it is being republished here on tommymaynard.com. The post was originally published on March 30, 2019.


Note: Because this is republished post, I know there is an update coming on this topic. Be sure to watch for this on Wednesday, February 17, 2021. I will do my best to remember to link to it once it is available.

“This is the one.” -Me (about ten seconds before starting this article)

For a decent amount of time now, I’ve used a personalized prompt. On several occasions even, I’ve written and shared it with the PowerShell community. Well, I’m about to do the same thing today. That said, I’ve never quite felt what I’m feeling now. After all those iterations, I feel that this version — version X (ten) — is what I was always after. It makes sense on Windows and Linux, and it provides everything I could want in a prompt.

For full disclosure, I’ve actually… said that last sentence before.

Let me explain a bit about it for those that may be new. I’ve always appreciated the Unix/Linux prompt, as it includes with it, the user name, the host/computer name, and often, an indication of whether or not I’m running as root ($ vs. # [# is root/admin]). Over time, I brought this and more into my prompt on Windows. It was the Linux prompt on Windows. And now, it’s even the Linux prompt on Linux, too. You know, that whole PowerShell Core, cross-platform thing. No kidding, but I was actually kind of surprised when I installed PowerShell Core for the first time and it used the same, standard PowerShell prompt. I suppose it’s something we’ve come to expect, however.

Before we discuss how to get the code, let’s discuss what I’ve done. I’ve now included everything, inside of a single advanced function called Switch-Prompt. If you run it with just the command name, you’ll get the standard, PowerShell prompt. That’s because the default value for the Type parameter is “Standard.” It can also be run using that parameter and parameter value, as seen in the below example.

PS > Switch-Prompt -Type Standard
PS C:\Program Files\7-Zip\Lang>

When using the Type parameter value of “Standard,” the prompt will look and function just as it typically does in PowerShell. Until Switch-Prompt is invoked again, this will be the prompt each time a new prompt is created (Enter is pressed).

The fun begins when we change out the “Standard” parameter value to “Linux.” This prompt will … well, let me just show you.

PS > Switch-Prompt -Type Linux
[tommymaynard@tmlaptop Lang]$ Switch-Prompt
PS C:\Program Files\7-Zip\Lang>

This option will create the prompt as open square bracket, current user name, @ symbol, current computer name, current directory, close square bracket, and either a dollar sign ($) or hash symbol (#). Remember, the hash symbol indicates that the user is running as root, or admin and the dollar sign indicates that the user is running as a normal user.

Using “Linux” as the value for the Type parameter creates two dynamic parameters. The FullPath parameter will include the full path to the current directory, and the Version parameter will include the version of PowerShell between the closing square bracket and the dollar sign or hash symbol. These two parameters can be used one at a time, or together — it makes no difference. In the below examples, I’ve pressed Enter a couple of times between commands, so you can see that the new prompt stays around until Switch-Prompt is invoked again, in a different manner than it was previously.

PS C:\Program Files\7-Zip\Lang> Switch-Prompt -Type Linux
[tommymaynard@tmlaptop Lang]$
[tommymaynard@tmlaptop Lang]$
[tommymaynard@tmlaptop Lang]$ Switch-Prompt -Type Linux -FullPath
[tommymaynard@tmlaptop c/Program Files/7-Zip/Lang]$
[tommymaynard@tmlaptop c/Program Files/7-Zip/Lang]$
[tommymaynard@tmlaptop c/Program Files/7-Zip/Lang]$ Switch-Prompt -Type Linux -Version
[tommymaynard@tmlaptop Lang]5.1.1$
[tommymaynard@tmlaptop Lang]5.1.1$
[tommymaynard@tmlaptop Lang]5.1.1$ Switch-Prompt -Type Linux -FullPath -Version
[tommymaynard@tmlaptop c/Program Files/7-Zip/Lang]5.1.1$
[tommymaynard@tmlaptop c/Program Files/7-Zip/Lang]5.1.1$

At an earlier time in this prompt’s development, I opted to have it allow me to choose the username and computer name. That’s still with us. When we use “LinuxCustom” as the value for the Type parameter, we get even more dynamic parameters. We still have FullPath and Version, but now, we also have UserName and ComputerName. Unlike FullPath and Version, these are not switch parameters; they require a value to be included along with them. Take a look at these final examples, and then you can get the code for yourself!

[tommymaynard@tmlaptop c/Program Files/7-Zip/Lang]5.1.1$ Switch-Prompt -Type LinuxCustom
[fake_user@fake_computer Lang]$
[fake_user@fake_computer Lang]$
[fake_user@fake_computer Lang]$ Switch-Prompt -Type LinuxCustom -UserName tm
[tm@fake_computer Lang]$
[tm@fake_computer Lang]$
[tm@fake_computer Lang]$ Switch-Prompt -Type LinuxCustom -UserName tm -ComputerName srvx
[tm@srvx Lang]$
[tm@srvx Lang]$
[tm@srvx Lang]$ Switch-Prompt -Type LinuxCustom -UserName tm -ComputerName srvx -FullPath -Version
[tm@srvx c/Program Files/7-Zip/Lang]5.1.1$

Now, it’s time for you to try it out yourself. In the past, I opted to save it in a public Gist, but this time, I think it’s good enough to go straight to the PowerShell Gallery. Here’s the Switch-Prompt’s page on the gallery, and here’s the command to issue in PowerShell to install it on your computer, as the current user. Remember, it’s a script, not a module. Therefore, you need to dot-source it, to use it, which is something that could be easily added to your profile script. That command is below, as well.

PS > Install-Script -Name Switch-Prompt -Scope CurrentUser
PS > . "$HOME\Documents\WindowsPowerShell\Scripts\Switch-Prompt.ps1"

Be sure to swap out “WindowsPowerShell” for “PowerShell” in the above path, if you installed it using PowerShell Core. It works there too! Even in the recently released 6.2 version.

Really, Remove the Module


Notice: The following post was originally published on another website. As the post is no longer accessible, it is being republished here on tommymaynard.com. The post was originally published on March 13, 2019.


I’m on a project. As a part of that project, I’m to deliver a PowerShell module that does — I don’t know — ten different things, we’ll say. The final, final thing that module does is remove itself from the computer. For real, it. deletes. itself.

The desktop admin, or whomever, will issue the Remove-Module command and via that function, the module will be removed from the system, as well as the session. Maybe you just had the thought I did: Yes, this module will live in the user’s module path. Therefore, the user will have the ability to delete the module from the system.

Let’s call our function Remove-MyModule and let’s assume it’s a member of the module called MyModule. I’ll also include a second function in MyModule called Get-MyModuleType. This way we have a command that offers some proof that my module is working, before the module is gone. Okay, let’s start by importing our module and using Get-Command to prove what I said we’d have up to this point. Remember, our Get-MyModuleType function tells us what type of module we have, and our Remove-MyModule function removes the whole module — including both commands — from existence.

PS> Import-Module -Name MyModule
PS> Get-Command -Module MyModule | Format-Table -AutoSize
CommandType Name             Version Source
----------- ----             ------- ------
Function    Get-MyModuleType 1.0.0   MyModule
Function    Remove-MyModule  1.0.0   MyModule

Before we move on, here’s the function code that makes up the Get-MyModuleType function — simple stuff. We collect the command name, and from it, the name of the containing module. And finally, we return the ModuleType property.

Function Get-MyModuleType {
    [CmdletBinding()]
    Param (
    )
 
    Begin {
        $CmdName = "$($MyInvocation.MyCommand.Name)"
        $CurrentModule = (Get-Command -Name $CmdName).Source
    } # End Begin.
 
    Process {
        (Get-Module -Name $CurrentModule).ModuleType
    } # End Process.
 
    End {
    } # End End.
} # End Function: Get-MyModuleType.

Now, let’s prove that our module’s Get function works. We invoke Get-MyModuleType and it outputs an indication that my module is a script module. Sure, we knew that, but with this first function, we have some indication that things are working, before we try the function in the module, that removes the module.

PS> Get-MyModuleType
Script

Let’s take a look at the second function in our module. As you look over the included code shortly, keep in mind that we have a few things going on. In our Begin block, we mostly do the same thing we did in the above function: return the name of the function and use it to return the name of the module. In addition to this, the Remove-MyModule function returns the module’s path, as well.

Once we exit the Begin block and enter the Process block, three tasks take place in succession. First, the function deletes the module folder from my computer (so have a backup if you’re playing along). It’s okay that the module is gone though. The function can continue to execute for now, as the module and its functions are still loaded in our current PowerShell session, even though its source is no longer on the disk. Next, it removes the module from the PowerShell session (think: from memory, where it’s still being stored). Lastly, our function removes the two functions from within the module (Get-MyModuleType and Remove-MyModule) from the Function PSDrive — we’ll discuss more shortly, after you’ve taken a look at the code that makes up the Remove-MyModule function.

Function Remove-MyModule {
    [CmdletBinding()]
    Param (
    )
 
    Begin {
        $CmdName = "$($MyInvocation.MyCommand.Name)"
        $CurrentModule = (Get-Command -Name $CmdName).Source
        $CurrentModulePath = (Get-Module -Name (Get-Command -Name $CmdName).Source).Path
    } # End Begin.
 
    Process {
        #region Remove (delete) module.
        try {
            $Path = "$(($CurrentModulePath -split $CurrentModule)[0])$CurrentModule"
            Remove-Item -Path $Path -Recurse -ErrorAction Stop
        } catch {
            Write-Warning -Message "Unable to remove (delete) the $CurrentModule PowerShell module."
        } # End try-catch.
        #endregion.
 
        #region Remove (unload) module.
        try {
            Remove-Module -Name $CurrentModule -ErrorAction Stop
        } catch {
            Write-Verbose -Message "Unable to remove (unload) the $CurrentModule PowerShell module."
        } # End try-catch.
        #endregion.
 
        #region Remove module functions from function PSDrive.
        Get-ChildItem -Path 'Function:\*-MyModule*' | Remove-Item
        #endregion.
    } # End Process.
 
    End {
    } # End End.
} # End Function: Remove-MyModule.

Typically, when we remove a PowerShell module from the PowerShell session using Remove-Module, it takes all the parts and pieces along. But, because we’ve already removed the module from the system, removing the module from the PowerShell session leaves the functions in the Function PSDrive. That’s one thought. The other, although I don’t believe this one so strongly (because both functions are left behind), is that they are left behind because we’re right in the middle of the Remove-MyModule function invocation.

Regardless of knowing exactly how this works, it’s fair to believe that traditional clean-up doesn’t work so well in this situation. The function we were executing was just removed from the computer in every way, and the function was completed without a problem. Sometimes, like this time, that’s good enough for me. The project is done. Okay fine, just that function part anyway. Let me hear your thoughts and ideas!

Background Jobs and Recursive Functions

I recently had a thought: I have not written much on background jobs over the last nearly eight years. I should do more of that. Part of it stemmed from recently preparing an old post that was published elsewhere to be brought back to life here on tommymaynard.com. That one was Keeping a Continuous Total.

In that post, I wrote, “Next up is likely putting this code into a background job; it’s not the quickest thing I’ve written (although I blame that on the speed of the PowerShell Gallery lookup process, perhaps). Maybe a background job that runs in, or starts at, the end of the profile script. This, in order that these slow-to-obtain results are available sooner and with minimal impact.”

Well, I did that. While the function to do lookups in the PowerShell Gallery already existed in my profile script, I added a new function to my profile script that includes various background job commands that is invoked by my profile script. And, it turns out I was able to make it a recursive function, as well. You never think you will need one of those until the opportunity presents itself. I am so glad I noticed this opportunity.

Let’s start with my Show-PSGalleryProject function. You are welcome to use this as well. This function goes out to the PowerShell Gallery and determines the download count of each of my scripts and modules published there. This function takes some time to run, therefore, I want it to run in the background, so the results are available quicker than they would be otherwise.

Function Show-PSGalleryProject {
    [CmdletBinding()]
    Param (
        [System.Array]$Projects = ('TMOutput','Start-1to100Game3.0','Get-TMVerbSynonym',
            'SinkProfile','Show-PSDriveMenu','Switch-Prompt')
    )

    Foreach ($Project in $Projects) {
        If (Find-Module -Name $Project -ErrorAction SilentlyContinue) {
            $TempVar = Find-Module -Name $Project; $Type = 'Module'
        } ElseIf (Find-Script -Name $Project) {
            $TempVar = Find-Script -Name $Project; $Type = 'Script'
        }
        $TotalDownloads = [int]$TotalDownloads + [int]$TempVar.AdditionalMetadata.downloadCount
        [PSCustomObject]@{
            Name = $TempVar.Name
            Type = $Type
            Version = $TempVar.Version
            Downloads = $TempVar.AdditionalMetadata.downloadCount
            TotalDownloads = $TotalDownloads
        }
    } # End Foreach.
} # End Function: Show-PSGalleryProject.

The invocation of the above function is controlled by the below function. So, the Show-PSGalleryProject function goes out to the gallery to collect information, and the Show-PSGalleryProjectJob function orchestrates this process. Let me explain what this section function does. But first, take a look at it and see what you can extract yourself.

Set-Alias -Name psgal-Value Show-PSGalleryProjectJob
Function Show-PSGalleryProjectJob {
	$FunctionName = $MyInvocation.MyCommand.Name
	if (-Not(Get-Job -Name $FunctionName -ErrorAction SilentlyContinue)) {
		Start-Job -Name $FunctionName -ScriptBlock ${Function:Show-PSGalleryProject} | Out-Null
	} else {
		if ((Get-Job -Name $FunctionName).State -eq 'Completed') {
			$JobEndTime = (Get-Job -Name $FunctionName).PSEndTime
			Receive-Job -Name $FunctionName |
				Select-Object -Property *,@{Name='EndTime'; Expression={$JobEndTime}} -ExcludeProperty RunspaceId |
				Format-Table -AutoSize
			Remove-Job -Name $FunctionName
			& $FunctionName
		} else {
			Write-Warning -Message "Please wait. The $FunctionName background job is $((Get-Job -Name Show-PSGalleryProjectJob).State.ToLower()) (Id: $((Get-Job -Name Show-PSGalleryProjectJob).Id))."
		}
	}
}
psgal

Buckle up; here we go.

Line 1: Create the psgal alias for the function.
Line 2 (and 18): Define the Show-PSGalleryProjectJob function.
Line 3: Create the $FunctionName variable and assign it the name of this function using the $MyInvocation variable. The function’s name is used repeatedly throughout the function, so it made sense to store it in a variable.
Line 4: Include an if-else language construct. The if portion determines if there is a background job called Show-PSGalleryProjectJobor not.
Line 5: If that background job does not exist, it should be created and started. The Start-Job cmdlet’s ScriptBlock property invokes the Show-PSGalleryProject function.
Line 6: This is the beginning of else portion. The lines beneath it will run if there is already a background job running called Show-PSGalleryProjectJob.
Line 7 – 13: Nested in the else portion is another if-else construct. If the job is complete we run the commands in the if portion. If the job is not yet complete, we run the commands in the else portion below. The if portion does all of the following: it collects the end time of the job and stores it in $JobEndTime, it uses Receive-Job to collect the results of the completed background job, it pipes those results to Select-Object and displays all the default properties, as well as the end time we add using a calculated property. It takes those results and pipes them to Format-Table -AutoSize . Once it is done with those steps, it uses Remove-Job to remove/delete the background job.

Now, for the recursion. The final step inside the if portion of this nested if-else is to invoke the Show-PSGalleryProjectJob function. That is right. The function invokes or calls itself. It starts this whole process over again. It does this using the call operator (&) and the $FunctionName variable. Remember, that variable holds the name of the function. Without the call operator, it would just echo the value in the variable. That operator also called the invocation operator, invokes the function again. Every time the job is completed and the values are returned, the process starts over. On a side note, I have written about recursive functions once before.

Line 14 – 16: The else portion issues a Write-Warning message indicating that the job is not yet complete.
Line 19: The alias invokes the Show-PSGalleryProjectJob function.

The below results show the functions working together. Consider background jobs for longer running tasks and consider recursive functions when a situation presents itself where a function should call, or invoke, itself.

[PS7.2.1][C:\] psgal
The Show-PSGalleryProjectJob background job is running (Id: 1).
[PS7.2.1][C:\] 
[PS7.2.1][C:\] psgal
The Show-PSGalleryProjectJob background job is running (Id: 1).
[PS7.2.1][C:\] 
[PS7.2.1][C:\] # Here is where I waited from some time to pass...
[PS7.2.1][C:\] 
[PS7.2.1][C:\] psgal

Name                Type   Version Downloads TotalDownloads EndTime
----                ----   ------- --------- -------------- -------
TMOutput            Module 1.1     2994                2994 2/10/2022 5:07:50 PM
Start-1to100Game3.0 Script 3.0     266                 3260 2/10/2022 5:07:50 PM 
Get-TMVerbSynonym   Script 1.4     293                 3553 2/10/2022 5:07:50 PM 
SinkProfile         Module 1.0     304                 3857 2/10/2022 5:07:50 PM 
Show-PSDriveMenu    Script 1.1     186                 4043 2/10/2022 5:07:50 PM 
Switch-Prompt       Script 1.2.0   236                 4279 2/10/2022 5:07:50 PM 

[PS7.2.1][C:\] psgal
The Show-PSGalleryProjectJob background job is running (Id: 3).
[PS7.2.1][C:\] 
[PS7.2.1][C:\] psgal
The Show-PSGalleryProjectJob background job is running (Id: 3).
[PS7.2.1][C:\]

Looking Busy with PowerShell


Notice: The following post was originally published on another website. As the post is no longer accessible, it is being republished here on tommymaynard.com. The post was originally published on February 27, 2019.


Every once in a while, I write a short little article — if, I can even call it that — and then it sits around for a month or so doing nothing. It just hangs out in my drafts and stares at me. It’s happened again. Instead of focusing on it any longer, I’m writing this paragraph as my excuse, so I can publish this now and move on, already.

I saw the below Tweet late last year and I had a thought.

I love how having several windows of #Powershell open makes you look busy and/or like you know what you are doing.
— John Dalek (@DeckerDalek) November 20, 2018

I’ll go ahead and agree; why not?

I can see how this may give off that impression. With that quick and internal agreement, I had an idea. If PowerShell can make you look busy, then let’s use PowerShell to make it appear, you’re busy. We’ll open some consoles and execute some “work,” all with a single invocation of a single function. I’ll just be over here waiting for PowerShell to catch up with me.

The below function creates this illusion, just in case this is something you’re after. Hopefully, it’s not, as we all likely have some real PowerShell to read, write, and review. Me included.

Function Show-MeBeingSuperBusy {
    [CmdletBinding()]
    Param (
        [Parameter()]
        [ValidateRange(1,10)]
        [int]$ConsoleCount = 1
    )
    
    Begin {
        $Argument = '-NoProfile -Command & {1..50 | ForEach-Object {Get-PSDrive}}',
            '-NoProfile -Command & {1..50 | ForEach-Object {Get-Process}}',
            '-NoProfile -Command & {1..50 | ForEach-Object {Get-Service}}',
            '-NoProfile -Command & {1..50 | ForEach-Object {Get-Item -Path env:\}}'
    } # End Begin.
    
    Process {
        For ($i = 1; $i -le $ConsoleCount; $i++) {
            Start-Process -FilePath powershell.exe -ArgumentList ($Argument | Get-Random)
        } # End For.
    } # End Process.
    
    End {
    } # End End.
} # End Function: Show-MeBeingSuperBusy.
 
Show-MeBeingSuperBusy -ConsoleCount 5

And that’s it. Short, simple, and hardly very helpful. Now that this “article” is gone from my drafts, I should be able to focus on something a little more helpful — we’ll see. This might be all the help someone needed from me today, however.

Edit: As a part of bringing this old content back to tommymaynard.com, I tried out this function. It works, but be sure to give it a moment before you begin to think it is not working!

Clear-Host Deconstructed


Notice: The following post was originally published on another website. As the post is no longer accessible, it is being republished here on tommymaynard.com. The post was originally published on January 31, 2019.


Sometimes, just sitting inside my console, I issue a few consecutive commands, and suddenly, I have something worth sharing. Recently, as I prepared to work with something other than PowerShell, I cleared the host. It’s a common occurrence — probably for us both, even. After doing so, I decided to take a closer look at the command I had just issued.

PS> Get-Command -Name cls
CommandType     Name                   Version    Source
-----------     ----                   -------    ------
Alias           cls -> Clear-Host
PS> Get-Command -Name Clear-Host
CommandType     Name                   Version    Source
-----------     ----                   -------    ------
Function        Clear-Host

In the first above command, we ran Get-Command against cls and determined that it’s an alias for Clear-Host. In the second command, we followed up by determining that Clear-Host is a function. For me, the absolute first thing I do when I realize a command is a function, and not a cmdlet, is peer into the code that makes it do, what it does.

In the below example, we return the ScriptBlock property of our Clear-Host function. While you can instead return the Definition property, I’ve always just used ScriptBlock. It makes more sense to me and is easier to remember. But in my experience, functions have the same code twice, in two different properties.

PS> (Get-Command -Name Clear-Host).ScriptBlock

$RawUI = $Host.UI.RawUI
$RawUI.CursorPosition = @{X=0;Y=0}
$RawUI.SetBufferContents(
    @{Top = -1; Bottom = -1; Right = -1; Left = -1},
    @{Character = ' '; ForegroundColor = $rawui.ForegroundColor; BackgroundColor = $rawui.BackgroundColor})
# .Link
# https://go.microsoft.com/fwlink/?LinkID=225747
# .ExternalHelp System.Management.Automation.dll-help.xml

Edit: The link in the above code no longer works. It just ends up at Bing. The current link, when viewing this ScriptBlock now, is https://go.microsoft.com/fwlink/?LinkID=2096480. Other than that, it does not appear anything else has changed since 2019 when this post was originally published.

The Clear-Host function includes a few things I decided to go over myself, and then share here, as well. We essentially have three commands inside this function. The very first command sets the variable $RawUI to the values stored in the host’s RawUI property. The host in the discussion here refers to the host program that’s hosting the PowerShell engine and has nothing to do with a computer host. These settings include things like the ForegroudColor and BackgroundColor, the WindowSize, WindowsTitle, and more. The Idea, however, is that only the Foreground and Background color will be used in the third command discussed below.

After the $RawUI variable is created and populated, we then alter the location of the cursor within the host program. It’s moved from its current location in the host program, wherever that might be, to the topmost and leftmost position within the host (0,0). While it’ll be for a millisecond or two, do keep in mind that it will eventually be forced into its final position by the execution of the prompt function, moving it to the right as is necessary. Even so, this movement won’t happen until after the Clear-Host script block is complete, and there’s still a final command to execute.

The third and final command to execute when the Clear-Host function is invoked uses the SetBufferContents method. This method requires two arguments. The first argument is the Rectangle. As Clear-Host uses -1 for the Top, Bottom, Right, and Left, the entire screen of the host program will be filled in. Think of the console screen — this host program — as the rectangle. And yes, you read that correctly. We’re going to fill in our host program’s screen.

The second argument, which is also a hash table such as the first argument was, includes three keys, not four as we just saw. The first is Character. This indicates what single character should be used to fill in the screen. Clear-Host uses an empty space, such as ‘ ‘. As you start testing (or planning pranks, duh), be sure to change this to other single characters, such I did to create the below image.

Note: The originally published image was unrecoverable.

Finally, the last two keys in the hash table that make up this second argument, allow us to choose the foreground and background colors to be used inside the host. The default that my Windows PowerShell host wants to use is black, over the Windows PowerShell standard blue, so this needs to be included.

And that’s it. After the variable assignment, the Clear-Host function essentially has two commands. To review, it positions the cursor at the top-left corner of the host, fills in the screen with an empty space at every position inside the host program, and sets the foreground and background color to what it already was.

Before we wrap it up here, I had an idea (that I would never really implement). Instead of using the default colors used by Clear-Host, you can create your own Clear-Host function that uses random colors for the foreground and background colors. I won’t bother providing a demonstration, but take the below code and supplant it into the above code and enjoy!

ForegroundColor = ([System.Enum]::GetNames([System.ConsoleColor]) | Get-Random); BackgroundColor = ([System.Enum]
::GetNames([System.ConsoleColor]) | Get-Random)})

Return Only the Fixed Disks


Notice: The following post was originally published on another website. As the post is no longer accessible, it is being republished here on tommymaynard.com. The post was originally published on January 15, 2019.


As a part of a recent engagement (with a company you’ve likely heard of), we had some code written and provided to us. In my review of what was provided to scan drives using Windows Defender, I noticed that there were some problems. One, there was an assumption that a computer would only ever have a single optical drive, and two, mapped network drives may have ended up being scanned, as well. Now, I’m not sure if Defender would actually scan a network drive, but I assume it would and don’t really care to find out.

We needed a way to filter out optical drives and network mapped drives regardless of the count of either before we started a Windows Defender scan. I’ll start with the code I used, followed by a second option that occurred more recently — it was a would this work idea. It does, so I’ll explain them both and perhaps we’ll all be better off seeing two different options. Before we get deeper into this, take a look at the output provided by Get-PSDrive (when piped to Format-Table and the AutoSize parameter). Clearly, it’s found a good number of drives on my system.

PS> Get-PSDrive | Format-Table -AutoSize
Name     Used (GB) Free (GB) Provider    Root                      CurrentLocation
----     --------- --------- --------    ----                      ---------------
Alias                        Alias
C           698.18    232.15 FileSystem  C:\                           Users\tommy
Cert                         Certificate \
D                            FileSystem  D:\
E                            FileSystem  E:\
Env                          Environment
Function                     Function
HKCU                         Registry    HKEY_CURRENT_USER
HKLM                         Registry    HKEY_LOCAL_MACHINE
MDI                          FileSystem  \\mydomain.com\data\in...
MDT                          FileSystem  \\mydomain.com\data\to...
I           112.80    352.96 FileSystem  I:\
P             0.15    465.60 FileSystem  P:\
Variable                     Variable
W           676.59    254.92 FileSystem  W:\
WSMan                        WSMan

Now, let’s modify our command and only return our FileSystem drives. Unfortunately, there are a couple of optical drives (although you don’t really know that yet), and two mapped network drives that we don’t want or need in our results.

PS> Get-PSDrive | Format-Table -AutoSize
Name     Used (GB) Free (GB) Provider    Root                      CurrentLocation
----     --------- --------- --------    ----                      ---------------
Alias                        Alias
C           698.18    232.15 FileSystem  C:\                           Users\tommy
Cert                         Certificate \
D                            FileSystem  D:\
E                            FileSystem  E:\
Env                          Environment
Function                     Function
HKCU                         Registry    HKEY_CURRENT_USER
HKLM                         Registry    HKEY_LOCAL_MACHINE
MDI                          FileSystem  \\mydomain.com\data\in...
MDT                          FileSystem  \\mydomain.com\data\to...
I           112.80    352.96 FileSystem  I:\
P             0.15    465.60 FileSystem  P:\
Variable                     Variable
W           676.59    254.92 FileSystem  W:\
WSMan                        WSMan

In this next example, we’ll remove the mapped drives from our results. In the end, we have our fixed drives and the D:\ and E:\ drives that have no used or free space. Perhaps those are the optical drives and there are no actual disks in either one. Before we move past this example, however, let’s get the results of this command into a variable, as well. The $FixedDrives variable is assigned toward the bottom of the below example.

PS> Get-PSDrive -PSProvider FileSystem | Where-Object -Property Root -notlike '\\*' | Format-Table -Autosize

Name     Used (GB) Free (GB) Provider    Root CurrentLocation
----     --------- --------- --------    ---- ---------------
C           698.18    232.15 FileSystem  C:\      Users\tommy
D                            FileSystem  D:\
E                            FileSystem  E:\
I           112.80    352.96 FileSystem  I:\
P             0.15    465.60 FileSystem  P:\
W           676.59    254.92 FileSystem  W:\

PS> $FixedDrives = Get-PSDrive -PSProvider FileSystem | Where-Object -Property Root -notlike '\\*'
PS> # Noticed we removed Format-Table -- that was _only_ there for the onscreen display.

Now, let’s get a hold of our optical drives. Because we’ll need them in a variable, we’ll go ahead and make that assignment in this next example, as well.

PS> Get-WmiObject -Class Win32_CDROMDrive | Format-Table -AutoSize

Caption                           Drive Manufacturer             VolumeName
-------                           ----- ------------             ----------
ASUS DRW-1814BLT ATA Device       D:    (Standard CD-ROM drives)
ELBY CLONEDRIVE SCSI CdRom Device E:    (Standard CD-ROM drives)

PS> $OpticalDrives = Get-WmiObject -Class Win32_CDROMDrive

Now that that’s done — oh look, it is the D:\ and E:\ drives — let’s run a comparison against the fixed drives and optical drives we’ve returned. Again, this first example is how I rewrote the code that was provided to us. Once we’ve seen this, then we’ll try the comparison I considered over some recent weekend. This example compares the Get-PSDrive‘s Root property (with the backslash removed) against the Get-WmiObject‘s Drive property. If they don’t match, it stays. Otherwise, it’s filtered out. As you’ll see, when we assign and return our new $DrivesToTest variable, we can see that our D:\ and E:\ drives — our optical drives — have been removed. Perfect.

PS> $DrivesToTest = ($FixedDrives.Root).TrimEnd('\') | Where-Object {$OpticalDrives.Drive -notcontains $_}
PS> $DrivesToTest
C:
I:
P:
W:

Let’s use our $FixedDrives and $OpticalDrives variables again, but this time with the Compare-Object cmdlet. This was the additional idea I had to determine if we can simplify things even more. As some have noticed — I have not shied away from the fact that I tend to do things the hard way, the first time. In case it makes a difference, and I hope it doesn’t, Compare-Object was first introduced in PowerShell 3.0.

PS> (Compare-Object -ReferenceObject ($FixedDrives.Root).TrimEnd('\') -DifferenceObject $OpticalDrives.Drive).InputObject
C:
I:
P:
W:

Just like that, we’ve got the same results, with less work. Now, I can get back to doing whatever it was before I began reviewing this code. That and our users can safely get to work scanning machines with Windows Defender, without the concern anyone will scan against any number of optical drives, or mapped network drives.

CIDR Notation Host Count


Notice: The following post was originally published on another website. As the post is no longer accessible, it is being republished here on tommymaynard.com. The post was originally published on January 15, 2019.


Late last year, I spent some time studying for the Amazon Web Services (AWS) Solutions Architect — Associate exam. In doing so, I briefly ended up covering CIDR again and the two-step math problem required to determine the number of available hosts in a CIDR IP address range.

As a recap, it works this way. Let’s consider the largest VPC (Virtual Private Cloud), or a virtual network, one can define in AWS. That’s 10.0.0.0/16. The way to determine the number of available host IPs is to subtract 16 (as indicated by the /16) from 32 — a constant value. In this example, that total is also 16. We then raise 2 (the base — another constant) to the power of 16 (the exponent/our difference), which results in 65,536 possible hosts. Because PowerShell can often distract me from AWS, let’s take a look at a small function I quickly wrote out — yes, during my AWS study time — to do the conversion for me.

Function Get-CidrHostCount {
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory)]
        [ValidateRange(1,32)]
        $Cidr
    )

    Begin {
    } # End Begin.

    Process {
        "Number of hosts for /$Cidr`: $([System.Math]::Pow(2,32-$Cidr))"
    } # End Process.

    End {
    } # End End.
} # End Function: Get-CidrHostCount.

When invoked, the above Get-CidrHostCount function will accept any numeric value from 1 through 32 and determine how many hosts the CIDR range would allow. This 1 to 32 value is equivalent to the number after the forward-slash in the CIDR notation. Do notice our two-step math problem. The System namespace’s Math class includes a method called Pow. This method accepts two values. The first value is our base again — 2 — and the second number is the exponent. In the function, we use the constant value of 32 and subtract the CIDR value that’s passed in when the function is invoked. These two values are then used to complete the calculation.

The below examples display a few results but eventually return all the possible results.

PS> Get-CidrHostCount -Cidr 16

Number of hosts for /16: 65536

PS> Get-CidrHostCount -Cidr 20

Number of hosts for /20: 4096

PS> Get-CidrHostCount -Cidr 24

Number of hosts for /24: 256

PS> Get-CidrHostCount -Cidr 28

Number of hosts for /28: 16

PS> 1..32 | ForEach-Object {
>> Get-CidrHostCount -Cidr $_
>> }

Number of hosts for /1: 2147483648
Number of hosts for /2: 1073741824
Number of hosts for /3: 536870912
Number of hosts for /4: 268435456
Number of hosts for /5: 134217728
Number of hosts for /6: 67108864
Number of hosts for /7: 33554432
Number of hosts for /8: 16777216
Number of hosts for /9: 8388608
Number of hosts for /10: 4194304
Number of hosts for /11: 2097152
Number of hosts for /12: 1048576
Number of hosts for /13: 524288
Number of hosts for /14: 262144
Number of hosts for /15: 131072
Number of hosts for /16: 65536
Number of hosts for /17: 32768
Number of hosts for /18: 16384
Number of hosts for /19: 8192
Number of hosts for /20: 4096
Number of hosts for /21: 2048
Number of hosts for /22: 1024
Number of hosts for /23: 512
Number of hosts for /24: 256
Number of hosts for /25: 128
Number of hosts for /26: 64
Number of hosts for /27: 32
Number of hosts for /28: 16
Number of hosts for /29: 8
Number of hosts for /30: 4
Number of hosts for /31: 2
Number of hosts for /32: 1

Before we really wrap it up here, let’s change the results as many of us would prefer to see them. Here’s a mildly modified version of the function and the last above command run again.

Function Get-CidrHostCount {
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory)]
        [ValidateRange(1,32)]
        $Cidr
    )

    Begin {
    } # End Begin.

    Process {
        "Number of hosts for /$Cidr`: $('{0:N0}' -f [System.Math]::Pow(2,32-$Cidr))"
    } # End Process.

    End {
    } # End End.
} # End Function: Get-CidrHostCount.
PS> 1..32 | ForEach-Object {
>> Get-CidrHostCount -Cidr $_
>> }

Number of hosts for /1: 2,147,483,648
Number of hosts for /2: 1,073,741,824
Number of hosts for /3: 536,870,912
Number of hosts for /4: 268,435,456
Number of hosts for /5: 134,217,728
Number of hosts for /6: 67,108,864
Number of hosts for /7: 33,554,432
Number of hosts for /8: 16,777,216
Number of hosts for /9: 8,388,608
Number of hosts for /10: 4,194,304
Number of hosts for /11: 2,097,152
Number of hosts for /12: 1,048,576
Number of hosts for /13: 524,288
Number of hosts for /14: 262,144
Number of hosts for /15: 131,072
Number of hosts for /16: 65,536
Number of hosts for /17: 32,768
Number of hosts for /18: 16,384
Number of hosts for /19: 8,192
Number of hosts for /20: 4,096
Number of hosts for /21: 2,048
Number of hosts for /22: 1,024
Number of hosts for /23: 512
Number of hosts for /24: 256
Number of hosts for /25: 128
Number of hosts for /26: 64
Number of hosts for /27: 32
Number of hosts for /28: 16
Number of hosts for /29: 8
Number of hosts for /30: 4
Number of hosts for /31: 2
Number of hosts for /32: 1

And that’s it — numbers I can actually read. And yes, I finally did get back to studying for my exam.

Top of the Next Hour


Notice: The following post was originally published on another website. As the post is no longer accessible, it is being republished here on tommymaynard.com. The post was originally published on January 7, 2019.


The Get-Date cmdlet has always been helpful, but just when I thought it had me fully covered, I determined it fell short. That said, it does have enough to get me what I want, even if there isn’t a simple, single built-in method for it.

I’m working on a project that requires me to add an additional trigger to a previously created scheduled task. When the scheduled task was initially deployed, it only had a single trigger. It was to run at midnight the following day (from the day in which the task was first created) and then every hour forever (at the top of the hour), until the end of time. Well, for the next 10,675,199 days at least.*

As a part of updating this project with a new trigger, I’m also going to be overwriting the trigger that begins at midnight. I’m doing that now because, at this point in this project, I don’t want to wait until tomorrow morning (at midnight) to have the task up and running again, I need a better starting time now that the task has been operating successfully for months. While I can do an hour from now — whatever time that may be — for the New-ScheduledTaskTrigger’s At parameter…

PS> (Get-Date).AddHours(1)

Saturday, January 5, 2019 8:14:29 PM

I don’t want that.

What this would mean, is that my fleet of AWS EC2 instances would all have random times in which they execute this task, dependent on when the task was updated. To better complete this picture, this task downloads a PowerShell module from AWS S3 and plops it on the EC2 instance running the task. Historically, or currently, rather, I’ve greatly appreciated knowing that any modifications to the PowerShell module uploaded to S3, are downloaded to all the EC2 instances, at the top of any, and every, hour. At 10:00 a.m., the module is replaced. At 11:00 a.m., the module is replaced, and so on. A collection of random, unknown times would be horrible going forward. I need to know that any modifications to the PowerShell module in S3 will be on all the instances, every hour, at the same time. And even if there are no modifications to the module, it gets downloaded anyway. It’s just easier that way (for now, perhaps).

Therefore, I need to know the top of the next hour. You know, if it’s 7:16 p.m., I need Get-Date to return 8:00 p.m. (on the same day, of course). If it’s 4:30 a.m, I need 5:00 a.m. returned. While I didn’t find a built-in method to accomplish this, as stated, I was able to write something myself, after a short amount of time head down in the console. Take a look at the below commands, and then let’s discuss them.

PS> Get-Date

Saturday, January 5, 2019 7:19:17 PM

PS> (Get-Date).AddMinutes(59 - (Get-Date).Minute).AddSeconds(60 - (Get-Date).Second)

Saturday, January 5, 2019 8:00:00 PM

The first above example returns the current date and time, as we’d expect that it would. The second, above example, indicates how we can ensure the value returned by Get-Date is the same date as today — no changes wanted there — with the time set in the future, at the top of the next hour.

For fun, we’ll pretend the time is 10:43:19 a.m.

Here’s what would happen, if the second command ran against this time. First, it would use Get-Date‘s AddMintues method. That makes sense, as no matter what time it is, we’ll need to add time to the current time, to get to the top of the next hour. Therefore, within the AddMinutes method, we take 59 and subtract the current minute of the current time.

10:43:19

59 – 43 = 16 minutes

Next, we’d add some seconds to our time, as well. We would take the value of 60 and subtract the current second of the current time.

10:43:19

60 – 19 = 41 seconds

Adding 41 seconds to 10:43:19 makes it 10:44:00. Adding in those 16 minutes takes us to 11:00:00. If you didn’t catch it, the command uses 59, not 60, when calculating AddMinutes. This is because the AddSeconds method is going to make up our “missing” minute.

Take a look at the following two examples. I won’t bother to explain them, but perhaps at this point, you can understand why they produce the results they do.

PS> (Get-Date).AddMinutes(60 - (Get-Date).Minute).AddSeconds(59 - (Get-Date).Second)

Saturday, January 5, 2019 8:00:59 PM

PS> (Get-Date).AddMinutes(60 - (Get-Date).Minute).AddSeconds(60 - (Get-Date).Second)

Saturday, January 5, 2019 8:01:00 PM

Now, no matter when my instances have their trigger updated, for the same task across each of them, I can ensure this task is back to updating my PowerShell module on those instances, at the top of every hour.

I took this one step further, and not necessarily because it had anything to do with scheduled tasks. What if I wanted my own method to do this? I quickly wrote a ScriptMethod for my own instance of a Get-Date object. I don’t have an opportunity, or need to do this often, so every little bit of practice is helpful.

$Date = Get-Date
 
Add-Member -InputObject $Date -MemberType ScriptMethod -Name GetNextTopHour -Value {
    $this.AddMinutes(59 - (Get-Date).Minute).AddSeconds(60 - (Get-Date).Second)
}
 
$Date = $Date.GetNextTopHour()

Now we can use our datetime object, and return the top of the next hour.

PS> $Date

Saturday, January 5, 2019 8:00:00

Now back to thinking through the task that started this whole line of thought, anyway.

* For anyone curious, when the scheduled task’s, task trigger was originally created using the New-ScheduledTaskTrigger function, the value used for the RepetitionDuration parameter was set as [System.TimeSpan]::MaxValue. Take a look at the below example, and you’ll see where this 10 million-plus day count is derived.

PS> [System.TimeSpan]::MaxValue
Days              : 10675199
Hours             : 2
Minutes           : 48
Seconds           : 5
Milliseconds      : 477
Ticks             : 9223372036854775807
TotalDays         : 10675199.1167301
TotalHours        : 256204778.801522
TotalMinutes      : 15372286728.0913
TotalSeconds      : 922337203685.478
TotalMilliseconds : 922337203685477

In order that we’re all on the same page, those 10 million-plus days, equate to “indefinitely” in a scheduled task’s Triggers tab, when viewing the task in the GUI. If you’re ever after a scheduled task repetition that never ends, and you’re using PowerShell to piece your task together, then MaxValue is the property to use.

Use PowerShell to Edit a CSV, Revisited

1. Back in January 2019, I started writing on another website. In December of that same year, I modified how I was doing things. Instead of just writing a full post there, I would start a new post there and then finish the post here, on my site. I did that through June 2020. All the posts from January 2019 to November 2019 are gone and all the posts from December 2019 to June 2020 are partially gone (as only a portion was written away from tommymaynard.com).

2. I have recently mentioned that if I hit 416 posts by the end of June 2022, I will have an average of one post per week over an eight-year timeframe. Bringing those posts back to life and publishing them on my site would get me to my goal much quicker. Beyond that, I have long wanted to recapture that work anyway, and realistically, those posts could have just as easily — maybe even more easily — been published here, to begin with.

As it is time to fix this, it would not make sense to do things manually unless it was an absolute requirement. Automation is kind of why we are all here. I intend to recover as many of my posts as I can and publish them here with help from archive.org — the Internet Archive — and their API.

I have been down the CSV road before. While not as popular as my Hash Table to CSV post, with greater than 21,000 all-time views, my Use PowerShell to Edit a CSV post has greater than 15,000 all-time views. Hash Table to CSV has part II or revised post, and now Use PowerShell to Edit a CSV does, too.

At first, I started to manually collect data — the data in the below image. I did not want to do that any more than I had already done. That decision was all I needed to save some time that was better spent prepping and preparing for this post. In this first image, you can see the CSV from which I began.

Do notice a few things: One, we’re missing some dates on the left, two, we’re missing all the URLs from the WB_URL column, and three, all the Notes are empty too. Our goal is to add dates on the left where we can, add URLs into the WB_URL column, and Notes when that’s necessary. There’s nothing to add to the TM_URL column. Those empty lines indicate posts that were never written and published on my site.

The first thing to do is ensure I can import the above CSV file. We will use the $CsvBasePath variable to hold the path to the file we will import with the Import-Csv command. The values returned from this command will be stored in the $CsvFileContents variable. Then they will be output to a formatted table, so they are easier to read. This is just a quick check to ensure the CSV file can be imported. As you can see below, the file’s contents could be imported. This is the identical data we saw in the previous image.

$CsvBasePath = 'C:\users\tommymaynard\Desktop\tommymaynard.com'
$CsvFileContents = Import-Csv -Path "$CsvBasePath/Corrections.csv"
$CsvFileContents | Format-Table -AutoSize

If you want to read about it, and it is short, here is information on the API that we will be using: https://archive.org/help/wayback_api.php. It is too important not to show you. The below image is what a response looks like when it is returned from using the API. We will use this API with Invoke-RestMethod. It may not mean much now, but you may end up referring to it as you progress further into this post. As you will see shortly, I will use …status -eq 200, I will use and edit the timestamp, and I will collect the URL. Having seen this image may make understanding the PowerShell much more straightforward.

I now know the CSV file can be imported and I understand the structure of the API response.

In the below PowerShell, I removed outputting the contents of the $CsvFileContents variable to the host, and instead, I set up a ForEach-Object looping construct. The first thing done inside this loop is put the current PS_URL value into the $UriRemainder variable in line 5. In line 6, we concatenate the $UriBase variable and the $UriRemainder variable and use them as the URI supplied to the Invoke-RestMethod command. We will continue working through this code below.

$CsvBasePath = 'C:\users\tommymaynard\Desktop\tommymaynard.com'
$UriBase = 'http://archive.org/wayback/available?url='
$CsvFileContents = Import-Csv -Path "$CsvBasePath\Corrections.csv"
$CsvFileContents | ForEach-Object {
	$UriRemainder = $_.PS_URL
	$WBInfoFull = Invoke-RestMethod -Uri "$UriBase$UriRemainder"
	If ($WBInfoFull.archived_snapshots.closest.status -eq 200) {
		$WBInfoDate = $WBInfoFull.archived_snapshots.closest.timestamp
		$WBInfoDate = -join $WBInfoDate[0..7]
		$WBInfoUrl = $WBInfoFull.archived_snapshots.closest.url
		$WBInfoFull
		$WBInfoDate
		$WBInfoUrl
		<#
		$_.Date = $WBInfoDate
		$_.WB_URL = $WBInfoUrl
		#>
		# Remove-Variable -Name WBInfoFull,WBInfoDate,WBInfoUrl
	} Else {
		'[[[[[NOPE]]]]]'
	} # End If-Else.
	$_
} # | Export-Csv -Path "$CsvBasePath\Corrections-temp.csv" -NoTypeInformation

The Invoke-RestMethod command reaches out to archive.org and stores the result — remember the response object — in the $WBInfoFull variable. For each iteration through the loop, this variable is repeatedly filled with data from each lookup against the Wayback Machine — another name for archive.org if I did not say that already. If the status is 200, we know our Invoke-RestMethod command was a success and so we progress further into the If portion of our nested If-Else construct.

We will then set $WBInfoDate by returning the timestamp property such as 20201022005417 and then joining the first eight “digits” [lines 8 and 9]. We then set, or assign, the $WBInfoURL variable. In the remainder of this code, we just dump our values to the screen, clear the variables, and then move onto the next line in the CSV file. We have yet to actually write to a CSV yet.

The below image shows a portion of the output generated by the above commands. Again, I’m not writing to the CSV yet; I’m only making sure the values in my variables are accurate.

It is here where the working PowerShell will be modified in such a way, that we can begin writing to the CSV file. I should make something clear. I did not edit an existing CSV file as much as I created a new one. I suppose I could have written back to the same file…or maybe I could not. That is probably worth finding out someday, but I do suspect that an open CSV can be written to. In the first iteration of this post, I created a temporary file and then did a remove/rename, so it appeared I actually edited a file.  But this whole time, I have not really been editing anything. Such a fraud, I know.

Moving along though. The below changes include having removed the code that outputs the values in the variables to the screen. Instead, these values are being written to the Date and WB_URL columns. When the status isn’t 200, instead of writing [[[[[NOPE]]]]] to the screen as I did above, something more pleasant and professional is written to the Notes column. In order to write to a new CSV, I uncommented the Export-CSV command, as well. In the post’s final image, you can view the “updated” CSV file. By my count, there are over 40 lines in this CSV that I didn’t have to type or paste in after manually doing the search myself. I’ll take it!

Always search for an API to use. Always.

$CsvBasePath = 'C:\users\tommymaynard\Desktop\tommymaynard.com'
$UriBase = 'http://archive.org/wayback/available?url='
$CsvFileContents = Import-Csv -Path "$CsvBasePath/Corrections.csv"
$CsvFileContents | ForEach-Object {
	$UriRemainder = $_.PS_URL
	$WBInfoFull = Invoke-RestMethod -Uri "$UriBase$UriRemainder"
	If ($WBInfoFull.archived_snapshots.closest.status -eq 200) {
		$WBInfoDate = $WBInfoFull.archived_snapshots.closest.timestamp
		$WBInfoDate = -join $WBInfoDate[0..7]
		$WBInfoUrl = $WBInfoFull.archived_snapshots.closest.url
		$_.Date = $WBInfoDate
		$_.WB_URL = $WBInfoUrl
		Remove-Variable -Name WBInfoFull,WBInfoDate,WBInfoUrl
	} Else { 
		$_.Notes = 'Unable to locate an archived webpage for that URL.'
	} # End If-Else.
	$_
} | Export-Csv -Path "$CsvBasePath\Corrections-temp.csv" -NoTypeInformation

While the above image includes the updated data, there have been some unexpected changes from the CSV image earlier in this post. This was worrisome for me at first — why were the dates changing!? My PowerShell worked before, so why not now? It turns out that it still is working. The date changes are because newer snapshots of the pages have been taken by the Wayback Machine since I began this post back in October — yeah, it has been a long time coming. Therefore, no worries. What clued me in was the above code and the response. Take a look at this for a brief moment.

$WBInfoFull.archived_snapshots.closest.timestamp

The keyword is “closest,” as in the most recent snapshot. My most recent snapshots changed between the creation of my original CSV file, and the updated one.

There may be a way to use PowerShell and an API to gather the old content, but for now, I am collecting it manually. I want to reclaim this content ASAP in order to line up getting these posts — the ones that are still relevant — republished here on tommymaynard.com. It is a lot of work, but I am after 416 posts by the end of June 2022. It is fair to say that I will be back with some new, old content very soon.

I am TechNet Gallery Years Old, Part III

Well, it is time for Part III of this series of posts. If you did not read the first one, or the second one, I would recommend starting with those. And now, it is time to cover the final four entries.

8. I love how the 1 to 100 game turned out. It is on the PowerShell Gallery and more information can be found on the post I wrote about it here, including what you need to know to get it on your computer. Here is an old screen capture of some gameplay. Just because it is the 1 to 100 game, does not mean you cannot choose your own numbers. This is the 1 to 3 game.

9. “Convert Alphanumeric String to the NATO Phonetic Alphabet Equivalent”

This script does not come with its own post, however, it was mentioned in this post about my Convert-TextToSpeech function. Essentially, it did exactly what this quote says from the post: “It takes a string made up of numbers and letters and writes it out in the Nato phonetic alphabet.” Since it does not have its own post to read, head straight to the GitHub Gist if you are interested. I have opted to take it for a spin this evening and included an image, too. You cannot tell, but including the Speak parameter will ensure the computer says the results out loud.

10. “Check Email Address Domain Against the Top-Level Domain (TLD) List from IANA”

Uh, this one was already covered in Part II. Weird it is on the list twice. That said, there were two different TechNet Gallery URLs. I attempted to use archive.org and the second of the two URLs was gone, gone. That means that what I wrote was 100% accurate: it must have been deleted. Either way, visit Part II if you want to learn more about this script, and you might.

11. “Map Drive to Drive Letter Using the Win32_DiskDrive Interface Type Property”

We have made it; it is the last one. This script does not have its own post here, but you can get the code from its Gist. The most I remember, at the moment, is that it was written for someone that may have needed help finding the drive letter of a USB drive — it has been a while. I may be totally wrong, but the person it was written for was appreciative. And really, it was kind of a monumental moment in my PowerShell community career. It was the first time I wrote something for someone else out there on the Internet. I am thinking that it may have been the beginning of my desire to learn and write about PowerShell here at tommymaynard.com.