Programming Journey Entry 9: Planning the script out (an attempt was made)

All of these Programming Journey posts can be found in the associated category of this blog.


PowerShell, again

As mentioned in the last post and several others, I started a simple-sounding PowerShell script to zip my Steam game library. And mostly finished up until it collapsed like a house of cards (checkmate). At which time I decided to start over but more correctly.


Pre Planner Explainer

Now I thought I’d talk my way through this first part, breaking the sections of the script into chunks.

The first part should be a list of parameters the script takes in. Here I can either paste in all my planned parameters and comment out the ones I don’t want yet or just put in the ones I need so far. I have the prior version for reference either way, it’s just a matter of when I feel like copy/pasting (now or later).

For now all I need is the source and destination folders. The only question is whether I need the [CmdletBinding()] line.

[CmdletBinding()]
param (
    [Parameter(ParameterSetName="Manual")][string]$sourceFolder,
    [Parameter(ParameterSetName="Manual")][string]$destinationFolder
)

I did some searching I think I am going to need [CmdletBinding()] eventually so I’ll start out the way. I’ll need if I want to use things like the the -WhatIf, -Verbose or -Debug standard parameters of PowerShell cmdlets.

And since I want to eventually try and use the -WhatIf standard parameter I’ll make this [CmdletBinding(SupportsShouldProcess)] by default. At least, that’s what I’m reading is telling me to do.

Actually, I’ll just paste in what my prior script version has: this cmdletbinding line
[CmdletBinding(DefaultParameterSetName=”Manual”, SupportsShouldProcess=$true)]
then the comment block documentation, then the actual parameters declaration. Okay, lets put that together.

[CmdletBinding(DefaultParameterSetName="Manual", SupportsShouldProcess=$true)]
<# 
.SYNOPSIS
    Compresses Steam game folders into dated zip archives with parallel processing and duplicate management.

.DESCRIPTION
    SteamZipper scans a ...
#>
param (
    [Parameter(ParameterSetName="Manual")][string]$sourceFolder,
    [Parameter(ParameterSetName="Manual")][string]$destinationFolder
)

This is more of placeholder right now. It looks like good though.

Bailing, but conditional

After the params definition is a section I like to call “reasons to bail”. This includes required paths not existing or no subfolders in the source folder or version of PS earlier the 7.

So lets start there: if not PS 7 then bail. As I’m writing this the latest version of PS is 7. The logic is “less than 7” so it works.

if ($PSVersionTable.PSVersion.Major -lt 7) {
    Write-Host "Error: This script requires PowerShell 7 or later. You are running PowerShell $($PSVersionTable.PSVersion.ToString())." -ForegroundColor Red
    Write-Host "Please upgrade to PowerShell 7 or higher. Download it from: https://aka.ms/powershell (or try install via winget)" -ForegroundColor Yellow
    Write-Host "Exiting now." -ForegroundColor Red
    exit 1
}

The next reason to bail to related to the source and destination paths being validated. I have a function for that already I see no reason to change:

function Validate-ScriptParameters {
    $cleanSource = $sourceFolder.Trim('"', "'")
    $cleanDest = $destinationFolder.Trim('"', "'")
    if (-not (Test-Path -Path $cleanSource -PathType Container)) {
        Write-Host "Error: Source folder '$cleanSource' does not exist. Please provide a valid path." -ForegroundColor Red
        exit 1
    }
    if (-not (Test-Path -Path $cleanDest -PathType Container)) {
        try {
            New-Item -Path $cleanDest -ItemType Directory -ErrorAction Stop | Out-Null
            Write-Host "Successfully created destination folder: $cleanDest" -ForegroundColor Green
        } catch {
            Write-Host "Error: Failed to create destination folder '$cleanDest': $($_.Exception.Message)" -ForegroundColor Red
            exit 1
        }
    }
}

This is as close to perfect as its ever going to get. If the script passes this function I can assume both paths provided are valid.

And I thought of a new “bail condition” – if the source path is valid but has zero subfolders. No need to continue that point. Or combine such a condition with anything else.

I haven’t written this one yet. I’ll involve a variable assign to a simple get-childitem that’s gets the $sourceFolder path fed to it followed by conditional with that variable .count being greater than 0.

# hypothetical/rough/untested function not written yet
function validate-SourcePopulation {
$SrcFolderCount = Get-ChildItem -Path $sourceFolder
if ( $SrcFolderCount.count -le 0 ) {
write-host "no subfolders found in source path provided ($sourceFolder), bailing..." -foregroundcolor Red
exit 1
}
}

I actually also have these lines that came from somewhere:

$ModuleRoot = $PSScriptRoot
if ( -not (Test-Path ($ModuleRoot)) ) {
    Write-Host "Unable to establish the path of this script, exiting..."
    exit 1
}

I mentioned having an issue with the $PSScriptRoot in the prior post. This is the path to the script itself as is supposedly already defined when a script is run. So there’s no reason to think it will be invalid, except when it is, which happened to me. Anyway I’m not sure why I need both $ModuleRoot and $PSScriptRoot variables, but I’m just going it. And using an invalid return as a reason to bail.

I think that’s all the reasons to bail. Unless I think of others. I guess if I look at all the subfolders and decided their invalid I would bail. But that won’t be until another section.


Getting started with variables

First I have some globals. And also I’m commenting out the parts I don’t need yet or any more.

# $global:maxJobsDefine = [System.Environment]::ProcessorCount
# $global:PreferredDateFormat = "MMddyyyy"
# $global:CompressionExtension = "zip"
# $global:sizeLimitKB = 50
$global:logBaseName = "Start-GameLibraryArchive-log.txt"

Which I guess is all of them except for the name of the transcript file.

Side note on structure (one of many)

I’m going with defining lots of functions and utilizing a main function to call what I need. I usually associate a main function with other languages but nothing wrong with using one here. And there’s good reason to do so. For one thing I can put a start-transcript as the first thing in the main and end-transcript as the last thing. And that way I have a PowerShell-provided log file.

Actually I just did a little digging on a feature I noticed the Copy-Item cmdlet has that involves saving the parameters to a hash table with a variable then just passing the cmdlet the name of that variable. I found out this is called “splatting” and can be added to a script relatively trivially. And the same for reading those parameters in from a text file containing that hash table. So I’m have to put that on the back burner and make sure it’s added when the time comes.

Here is my main function so far:

function Main {

    # probably?
    Start-Transcript -Path "$ModuleRoot\$global:logBaseName"
    #Start-GameLibraryArchive -LibraryPath $libpath -DestPath $zipDestpath
    Validate-ScriptParameters
    Validate-SourcePathPopulation
    Stop-Transcript
}
Main

Not much to it yet. More of skeleton of a Main function than a main function.

Lets work on some functions

Next are two very short yet very important functions: one for defining the platform and one for returning whether or not the source subfolder meets the minimum size requirements.

First let look at the platforms function:

function Get-PlatformShortName {
    $platforms = @{
        "epic games"   = "epic"
        "Amazon Games" = "amazon"
        "GOG"          = "gog"
        "Steam"        = "steam"
        #"Origin"      = "origin"
    }
    foreach ($platform in $platforms.Keys) {
        if ($sourceFolder -like "*$platform*") {
            return $platforms[$platform]
        }
    }
    return "unknown"  # Default value if no match
}

This simply defines some platforms as strings and create a string equivalent for use as part of the naming convention of the zip files.

The snippet $sourceFolder -like “$platform I think demonstrates how relatively easy PS makes things like this: look at the source folder name (passed in by the user) and see if it is “like” any of the strings in the hash table. If Amazon Games came up it would return the value “amazon”, for instance.

Next is that folder size function. Which I may have to modify from what I have now.

Basically, this function is intended to be called in a loop. It’s fed a path to a folder, it determines if it meets the minimum size and returns a true/false for the calling function to do something with.

The minimum is established with the variables via this declaration:

$global:sizeLimitKB = 50 # arbitrary folder size

And the Get-FolderSizeKB function is supposed to start gathering the size of all the files in the a given folder, breaking when the size exceeds the minimum.

function Get-FolderSizeKB {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$folderPath,
        [int]$sizeLimitKB = 50
    )
    
    $FolderSizeKBTracker = 0
    foreach ($file in Get-ChildItem -Path $folderPath -Recurse -File -ErrorAction SilentlyContinue -ErrorVariable err) {
        $FolderSizeKBTracker += $file.Length / 1KB
        if ($FolderSizeKBTracker -gt $sizeLimitKB) {
            return $true
        }
    }
    
    if ($err) { Write-Debug "Errors during enumeration: $($err | Out-String)" }
    return $false
}

Well I had to re-write it slightly, but I believe this is now working correctly: True if the folder size is over 50 KB and False if it’s smaller. Should be ready now for another function to use it.

Naming the zips

The last thing I’ll go through for this post is the naming convention for the zip files, and the last detail of the names, the date code.

This is covered by the variable $global:PreferredDateFormat = “MMddyyyy”.

And technically the file extension variable zip as well but I’m not worried about that at this stage.

The naming convention is

the source subfolder name, like Horizon Chase, having its spaces replaced with underscores – horizon_chase. Next is a date code as grabbed from the source folders “last write date” (which file explorer calls the “date modified). And lastly in the platform, as defined by the function above.

So put it together and it’s:

Horizon_Chase_12152024_steam.zip

The real trivia comes in with the conversion of dates back and forth between the date the OS returns and the date code equivalent as a string.

More on the structure

This leads to some questions about handling the structure of the opening of the script.

I could just go to town and start elimating folders and skipping zip files, come up with two lists (one for folders and one for destination zips) and send both the compress-archive. Then say “see, what’s the big deal”.

But I decided on a more causious, hopefully more management approach: creating arrays and/or tables and gradually removing entries that dno’t need a new zip file until a final list of folders and destination zip files is all that’s left.

For instance I do a Get-ChildItem on the source folder straight away and for-loop through each folder sending it to the Get-FolderSizeKB function. And based on what  Get-FolderSizeKB returns add the folder to an array or not. This I’m starting with at least a partially validated source folder list before anything has even been done.

This approach has some flaws though. Because I need both the folder name and the LastWriteDate of the folders. Would I for loop through the array to get the LastWriteDate of each and put those two things in a new hash table? Seems like something that could be done in one step. So skip the array, just use a hashtable or only the folders that meet that minimum size make into the hashtable.

Then I’d have a partially validated hashtable of source folders and their LastWriteDates.

From there this could go multiple ways. Using my Horizon Chase example from earlier, I could search the destination folder for any zips that start with horizon_chase. The result would be either 0, 1 or greater than 1. If it’s 0 I can put that folder into the “zip pending list, if it’s one I need to validate the date code for comparison with the source folder date and if it’s more than 1 than I have to deal with duplicates separately.

Or…as before I could combine this idea with the which folders made into the hashtable idea. If Horizon Chase has zero matches (and made it past the minimum size filter) it’s automatically going to the “final” table. And I haven’t even made it to the dates yet.

Could this be done in one step: list of source folders -> only folders that exceed minimum size hash table -> hashtable of folders that have no matching zip files.

That brings up a possible edge case: what if there’s lots of folders but they were all under the minimum size so the table would be empty. Well I need to test for an empty hashtable at some point and gracefully exist the way it did if the subfolder count came back 0.

I had more to explore but I’m going to end this post here in favor of the next post.


Game Library Auto Archiver
GitHub Repo:
https://github.com/tildesarecool/Game-Library-Auto-Archiver

For those who may also be trying to learn PowerShell, I wanted to point out this the sticky thread on the PowerShell subreddit, What have you done with PowerShell this month?. You can go back through the months worth of these threads and find a lot of tricks and scripts you would not have otherwise come across. I don’t know if there’s a listing of them some place. The “beginner resources” page is pretty great, too.


Reference links:


Leave a comment