src/Public/Move-OldFilesToArchive.ps1

#TODO: Test thoroughly (~95% confident that it works as intended)
#TODO: Add Param to maintain permissions on moved files and folders
#TODO: Refactor to use CablersPowershell-Base functions where possible

<#
.SYNOPSIS
 
Moves files older than a specified date to an archive folder.
 
.DESCRIPTION
 
This function will move files older than a specified date to an archive folder. This is based on the last time the files were accessed, not created/modified.
The archive date is specified as a parameter and can be any date in the past.
The archive date can be specified as a string (e.g. "01/01/2020") or as a [datetime] object (e.g. "(Get-Date).AddDays(-30)").
The function will recursively search through all subfolders of the source folder and move any files that meet the criteria to the
destination folder while maintaining the existing folder structure.
The function will also take ownership of the files before moving them to ensure that the move operation is successful.
 
It is recommended to run the function as a dry run first to ensure that the correct files are being moved.
It is also recommended to run the Remove-EmptyFolders function after running this to clean up the source directory.
 
.PARAMETER Source
 
The source folder containing the files to be archived.
 
.PARAMETER Destination
 
The destination folder where the files will be moved to.
 
.PARAMETER ArchiveDate
 
The date before which files will be moved to the archive folder.
 
.PARAMETER Quiet
 
Suppresses the timer and total file size output. Will still return the full list of moved files.
(Required in the recursive call to prevent output spam)
 
.PARAMETER DryRun
 
Performs a dry run of the operation without actually moving any files. Will return the full list of files that would have been moved.
Will still output the time taken (this is not indicative of the actual time it would take to move the files).
Will still output the total file size of the files that would have been moved.
Both outputs will still be suppressed if the Quiet switch is used.
 
.EXAMPLE
 
Move-OldFilesToArchive -Source "C:\Temp" -Destination "C:\Archive" -ArchiveDate "01/01/2020" -DryRun
 
This example will perform a dry run of the operation, moving files not accessed since 01/01/2020 from the "C:\Temp" folder to the "C:\Archive" folder.
 
.EXAMPLE
 
Move-OldFilesToArchive -Source "C:\Temp" -Destination "C:\Archive" -ArchiveDate (Get-Date).AddDays(-30) -Quiet
 
This example will move files not accessed in the last 30 days from the "C:\Temp" folder to the "C:\Archive" folder.
This will not display the total time taken or the total file size moved.
 
.OUTPUTS
 
A list of moved files including the source path, destination path, file size, and last access time.
 
.NOTES
 
Author: Brad Bullock, Cablers Ltd
Date: 17/10/2024
 
#>

function Move-OldFilesToArchive {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Source,
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Destination,
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [object]$ArchiveDate,
        [Parameter(Mandatory = $false)]
        [switch]$Quiet, # Suppress timer and total file size output. Will still return the full list of moved files.
        [Parameter(Mandatory = $false)]
        [switch]$DryRun # Similar to whatif but without the whatif output, still returns full list of files at the end
    )

    begin {

        # Initialize an array to store moved files
        $MovedFiles = @()

        # Validate that the source path exists and that it is a folder, not a file
        if (-not (Test-Path -Path $Source -PathType Container)) {
            Throw "Source path '$Source' does not exist or is not a folder."
        }

        # Validate that the destination path exists and that it is a folder, not a file. Create the folder if missing.
        if (Test-Path -Path $Destination -PathType Leaf) {
            Throw "Destination path '$Destination' is not a folder."
        }
        If (-not (Test-Path -Path $Destination)) {
            New-Item -Path $Destination -ItemType Directory | Out-Null
        }

        # Convert ArchiveDate to [datetime] if it is a string
        if ($ArchiveDate -is [string]) {
            try {
                $ArchiveDate = Get-Date $ArchiveDate
                #$ArchiveDate = [datetime]::Parse($ArchiveDate) # Alternative method, may be needed depending on testing
            }
            catch {
                Throw "ArchiveDate '$ArchiveDate' is not a valid datetime string."
            }
        }

        # Validate that the archive date is in the past
        if ($ArchiveDate -gt (Get-Date)) {
            Throw "Archive date must be in the past."
        }
    }

    process {
        if (-not ($Quiet)) {
            Write-Host "It is recommended to run this comman as a dry run first! Use `'Get-Help Move-OldFilesToArchive`' for documentation." -ForegroundColor Yellow
            Write-Host "Press Ctrl + C to cancel now if you need to do a dry run. Command will start in 5 seconds." -ForegroundColor Yellow
            Write-Host "Warning: This may move files that are not intended to be moved if the last access time is inaccurate."
            Start-Sleep -Seconds 5
            $StartTime = Get-Date
            Write-Host "Beginning archive process from $Source to $Destination for files older than $ArchiveDate"
            Write-Host "Start Time: $StartTime"
        }

        $Files = Get-ChildItem -LiteralPath $Source -File

        foreach ($File in $Files) {
            if ($File.LastAccessTime -lt $ArchiveDate) {
                Write-Verbose "Moving $($File.fullname) - Last Access Time: $($File.lastaccesstime)"
                $RelativePath = $File.FullName.Replace($Source, "")
                $DestinationPath = Join-Path -Path $Destination -ChildPath $RelativePath
                $DestinationFolder = Split-Path -Path $DestinationPath -Parent
                Write-Verbose "Destination Path: $DestinationPath"

                # Take ownership of the file
                if (-not ($DryRun)) {

                    if (-not (Test-Path -LiteralPath $DestinationFolder)) {
                        New-Item -ItemType Directory -Path $DestinationFolder | Out-Null
                    }

                    $Acl = Get-Acl -LiteralPath $File.FullName
                    $Acl.SetOwner([System.Security.Principal.WindowsIdentity]::GetCurrent().User)
                    Set-Acl -LiteralPath $File.FullName -AclObject $Acl

                    Move-Item -LiteralPath $File.FullName -Destination $DestinationPath
                }
                # Add the moved file to the array including length
                $Obj = [PSCustomObject]@{
                    Source         = $File.FullName
                    'Size (Bytes)' = $File.Length
                    LastAccessTime = $File.LastAccessTime
                    Destination    = $DestinationPath
                }
                $MovedFiles += $Obj
            }
            Else {
                Write-Verbose "Skipping $($File.fullname) - Last Access Time: $($File.lastaccesstime)" -ForegroundColor Yellow
            }
        }

        $Subfolders = Get-ChildItem -LiteralPath $Source -Directory
        foreach ($Subfolder in $Subfolders) {
            $SubfolderDestination = Join-Path -Path $Destination -ChildPath $Subfolder.Name
            $MovedFiles += Move-OldFilesToArchive -source $Subfolder.FullName -Destination $SubfolderDestination -ArchiveDate $ArchiveDate -Quiet -DryRun:$DryRun -Verbose:$VerbosePreference
        }

        return $MovedFiles

    }

    end {

        If (-Not $Quiet) {

            # Output time taken with seconds to 2 decimal places
            Write-Host "Archive Complete. Time taken: $(([math]::Round((New-TimeSpan -Start $StartTime -End (Get-Date)).TotalSeconds, 2))) seconds"
            If ($DryRun -or $WhatIf) {
                Write-Host "This was a dry run, no files were actually moved." -ForegroundColor Yellow
            }
            # Sum up the size of all moved files in bytes
            $totalSizeBytes = ($MovedFiles | Measure-Object -Property 'Size (Bytes)' -Sum).Sum

            # Convert total size to gigabytes
            $totalSizeGB = [math]::Round($totalSizeBytes / 1GB, 2)

            Write-Host "Total size of moved files: $totalSizeGB GB`n"
        }

    }
}