Public/Set-VBUserPrinterMigration.ps1

# ============================================================
# FUNCTION : Set-VBUserPrinterMigration
# MODULE : VB.WorkstationReport
# VERSION : 1.0.2
# CHANGED : 23-04-2026 -- Added CSV format sample and step-by-step creation example
# AUTHOR : Vibhu Bhatnagar
# PURPOSE : Migrates user printer mappings between UNC paths and IP addresses
# ENCODING : UTF-8 with BOM
# ------------------------------------------------------------
# CHANGELOG (last 3-5 only -- full history in Git)
# v1.0.2 -- 23-04-2026 -- Added CSV format sample and step-by-step creation example
# v1.0.1 -- 23-04-2026 -- Finalized: expanded help with all migration types and RMM examples
# v1.0.0 -- 23-04-2026 -- Initial release
# ============================================================

function Set-VBUserPrinterMigration {
    <#
    .SYNOPSIS
        Migrates user printer mappings from UNC paths to IP addresses or vice versa
        across all or targeted user profiles on a machine.
 
    .DESCRIPTION
        Set-VBUserPrinterMigration reads printer mapping rules from a CSV file or
        hashtable and applies them to each matching user profile on the target computer.
 
        For each user it mounts the registry hive (if not already loaded), applies the
        mapping changes via Update-VBUserPrinterRegistry, then safely dismounts the hive.
        Machine-level TCP/IP printer ports and printers are added once per machine before
        the per-user loop runs.
 
        Supports four migration scenarios:
          UNC -> UNC Server migration (\\old\printer -> \\new\printer)
          UNC -> IP Replace shared printer with direct IP printer
          IP -> UNC Replace direct IP printer with shared printer
          IP -> IP Port change on direct IP printer
 
        REMOTE EXECUTION NOTE:
        Machine-level printer port and printer additions (required for IP destinations)
        are NOT supported when ComputerName targets a remote machine. The script must
        run locally on each target workstation (e.g. deployed via RMM). A terminating
        error is thrown if a remote target requires machine-level port additions.
 
    .PARAMETER ComputerName
        Target computer(s). Accepts pipeline input. Defaults to local machine.
        Remote targets are supported for user registry changes only.
 
    .PARAMETER Credential
        Credentials for remote execution. Not required for local or domain-joined targets.
 
    .PARAMETER PrinterMappings
        Hashtable of OldPath = NewPath pairs.
        Example: @{ '\\OldServer\HP01' = '10.30.1.50'; '10.30.1.60' = '\\NewServer\Canon02' }
        For IP destinations, supply -DriverName. Use -MappingCsv for per-printer driver control.
 
    .PARAMETER MappingCsv
        Path to a CSV file with columns: OldPath, NewPath, DriverName
        DriverName is required when NewPath is an IP address. CSV takes priority over
        -PrinterMappings if both are supplied.
 
    .PARAMETER DriverName
        Driver name to use when adding IP printers via -PrinterMappings hashtable.
        Applies to all IP destinations in the hashtable. For per-printer driver control
        use -MappingCsv with a DriverName column instead.
 
    .PARAMETER TargetUser
        Username or SID of a specific user to migrate. When omitted all non-system
        user profiles on the machine are processed.
 
    .PARAMETER BackupMappings
        When specified, saves a snapshot of each user's current printer mappings to
        -BackupPath before applying changes. Requires -BackupPath.
 
    .PARAMETER BackupPath
        Full path to the CSV file where backup snapshots are written.
        Required when -BackupMappings is specified.
 
    .EXAMPLE
        # -------------------------------------------------------------------
        # STEP 1 -- Create the mapping CSV (save as C:\Temp\PrinterMappings.csv)
        # -------------------------------------------------------------------
        # Required columns : OldPath, NewPath
        # Optional column : DriverName (required when NewPath is an IP address)
        # DriverName can be left blank for UNC -> UNC rows
        #
        # Sample CSV covering all four migration types:
        #
        # OldPath,NewPath,DriverName
        # \\PrintServer01\HP_Floor2,10.30.1.50,HP LaserJet 400 M401
        # \\PrintServer01\Canon_HR,10.30.1.51,Canon Generic Plus PCL6
        # 10.30.1.60,\\PrintServer02\Ricoh_Reception,
        # \\PrintServer01\Zebra_Labels,\\PrintServer02\Zebra_Labels,
        #
        # Row breakdown:
        # Row 1 -- UNC -> IP (DriverName required)
        # Row 2 -- UNC -> IP (DriverName required)
        # Row 3 -- IP -> UNC (DriverName blank -- not needed)
        # Row 4 -- UNC -> UNC (DriverName blank -- not needed)
        #
        # To create it from PowerShell:
        $csv = @"
OldPath,NewPath,DriverName
\\PrintServer01\HP_Floor2,10.30.1.50,HP LaserJet 400 M401
\\PrintServer01\Canon_HR,10.30.1.51,Canon Generic Plus PCL6
10.30.1.60,\\PrintServer02\Ricoh_Reception,
\\PrintServer01\Zebra_Labels,\\PrintServer02\Zebra_Labels,
"@
        $csv | Out-File -FilePath 'C:\Temp\PrinterMappings.csv' -Encoding UTF8
 
        # -------------------------------------------------------------------
        # STEP 2 -- Run the migration (deploy via RMM, runs locally on machine)
        # -------------------------------------------------------------------
        Set-VBUserPrinterMigration -MappingCsv 'C:\Temp\PrinterMappings.csv'
 
    .EXAMPLE
        Set-VBUserPrinterMigration -MappingCsv 'C:\Temp\PrinterMappings.csv' -WhatIf
 
        Dry run -- shows what would be changed without making any modifications.
        No registry keys, ports, or printers are created or removed.
        Always run -WhatIf first when deploying to a new environment.
 
    .EXAMPLE
        Set-VBUserPrinterMigration -MappingCsv 'C:\Temp\PrinterMappings.csv' `
            -BackupMappings -BackupPath 'C:\Realtime\Reports\PrinterBackup.csv'
 
        Saves a before-snapshot of each user's current printer mappings to CSV first,
        then applies the migration. Append-safe -- multiple machines can write to the
        same backup CSV when deployed via RMM.
 
    .EXAMPLE
        Set-VBUserPrinterMigration -MappingCsv 'C:\Temp\PrinterMappings.csv' -TargetUser 'jdoe'
 
        Migrates printers for a single user only. All other profiles on the machine
        are skipped. Accepts username or SID for -TargetUser.
 
    .EXAMPLE
        # UNC -> UNC server migration via hashtable (no DriverName needed)
        $mappings = @{
            '\\OldPrintServer\HP01' = '\\NewPrintServer\HP01'
            '\\OldPrintServer\Canon02' = '\\NewPrintServer\Canon02'
        }
        Set-VBUserPrinterMigration -PrinterMappings $mappings
 
    .EXAMPLE
        # UNC -> IP migration via hashtable (all printers share the same driver)
        $mappings = @{
            '\\PrintServer\HP01' = '10.30.1.50'
            '\\PrintServer\HP02' = '10.30.1.51'
        }
        Set-VBUserPrinterMigration -PrinterMappings $mappings -DriverName 'HP LaserJet 400 M401'
 
    .EXAMPLE
        # IP -> UNC migration via hashtable
        $mappings = @{
            '10.30.1.50' = '\\NewPrintServer\HP01'
            '10.30.1.51' = '\\NewPrintServer\HP02'
        }
        Set-VBUserPrinterMigration -PrinterMappings $mappings
 
    .EXAMPLE
        # Capture full migration results and export to CSV for reporting
        $results = Set-VBUserPrinterMigration -MappingCsv 'C:\Temp\PrinterMappings.csv'
        $results | Export-Csv -Path 'C:\Realtime\Reports\MigrationResults.csv' -NoTypeInformation -Encoding UTF8
 
        # Review failures only
        $results | Where-Object { $_.Status -eq 'Failed' } | Format-Table
 
    .EXAMPLE
        # RMM deployment pattern -- run locally, log results to network share
        $results = Set-VBUserPrinterMigration -MappingCsv 'C:\Temp\PrinterMappings.csv' `
            -BackupMappings -BackupPath "\\FileServer\Logs\PrinterBackup_$env:COMPUTERNAME.csv"
        $results | Export-Csv -Path "\\FileServer\Logs\PrinterMigration_$env:COMPUTERNAME.csv" `
            -NoTypeInformation -Encoding UTF8
 
    .OUTPUTS
        PSCustomObject
        Returns one object per user per printer mapping action:
          - ComputerName : Target computer
          - Username : User profile name
          - SID : User SID
          - OldPath : Old printer path from mapping rule
          - NewPath : New printer path from mapping rule
          - Action : 'Migrated', 'Skipped', 'AlreadyMigrated', or 'Failed'
          - Details : Registry actions taken or reason for skip/failure
          - Status : 'Success' or 'Failed'
          - Error : Error message (only present on failure)
          - Timestamp : Time of action (dd-MM-yyyy HH:mm:ss)
 
    .NOTES
        Version : 1.0.2
        Author : Vibhu Bhatnagar
        Category : Printer Management
 
        Requirements:
        - PowerShell 5.1
        - Administrative privileges
        - PrintManagement module (built-in on Windows 8 / Server 2012+)
        - Required printer drivers already installed for IP destinations
        - Script must run locally on target machine for IP printer port additions
    #>


    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Name', 'Server')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [PSCredential]$Credential,

        [hashtable]$PrinterMappings,

        [string]$MappingCsv,

        [string]$DriverName,

        [string]$TargetUser,

        [switch]$BackupMappings,

        [string]$BackupPath
    )

    begin {
        $ErrorActionPreference = 'Stop'

        # --- Validate backup parameters ---
        if ($BackupMappings -and -not $BackupPath) {
            throw '-BackupPath is required when -BackupMappings is specified.'
        }

        # --- Step 1: Load and validate mappings ---
        $normalizedMappings = [System.Collections.Generic.List[object]]::new()

        if ($MappingCsv) {
            # CSV takes priority when both supplied
            if (-not (Test-Path -Path $MappingCsv)) {
                throw "Mapping CSV not found: $MappingCsv"
            }

            $csvRows = Import-Csv -Path $MappingCsv -ErrorAction Stop

            foreach ($row in $csvRows) {
                if (-not $row.OldPath -or -not $row.NewPath) {
                    throw "CSV row is missing OldPath or NewPath: $($row | Out-String)"
                }

                $isNewIP = $row.NewPath.Trim() -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}'

                if ($isNewIP -and -not $row.DriverName) {
                    throw "DriverName is required for IP destinations. Missing for: $($row.OldPath.Trim()) -> $($row.NewPath.Trim())"
                }

                $normalizedMappings.Add([PSCustomObject]@{
                    OldPath    = $row.OldPath.Trim()
                    NewPath    = $row.NewPath.Trim()
                    DriverName = if ($row.DriverName) { $row.DriverName.Trim() } else { '' }
                })
            }
        }
        elseif ($PrinterMappings) {
            foreach ($key in $PrinterMappings.Keys) {
                $oldPath  = $key.Trim()
                $newPath  = $PrinterMappings[$key].Trim()
                $isNewIP  = $newPath -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}'

                if ($isNewIP -and -not $DriverName) {
                    throw "Use -DriverName when supplying IP destinations via -PrinterMappings, or use -MappingCsv for per-printer driver control. Missing driver for: $oldPath -> $newPath"
                }

                $normalizedMappings.Add([PSCustomObject]@{
                    OldPath    = $oldPath
                    NewPath    = $newPath
                    DriverName = if ($isNewIP) { $DriverName } else { '' }
                })
            }
        }
        else {
            throw 'Either -PrinterMappings or -MappingCsv must be supplied.'
        }

        if ($normalizedMappings.Count -eq 0) {
            throw 'No valid printer mappings found in the supplied input.'
        }
    }

    process {
        foreach ($computer in $ComputerName) {
            try {
                # --- Step 2: Machine-level TCP/IP port and printer setup (local only) ---
                $ipMappings = $normalizedMappings |
                    Where-Object { $_.NewPath -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}' }

                if ($ipMappings) {
                    if ($computer -ne $env:COMPUTERNAME) {
                        throw "Machine-level printer port additions are not supported for remote targets. Run this script directly on '$computer' via RMM."
                    }

                    foreach ($ipMap in $ipMappings) {
                        $portName = "IP_$($ipMap.NewPath)"

                        # Only derive display name when old path is UNC (IP->IP handled per-user)
                        $printerDisplayName = $null
                        if ($ipMap.OldPath -match '^\\\\') {
                            $printerDisplayName = $ipMap.OldPath.TrimEnd('\').Split('\')[-1]
                        }

                        # Add TCP/IP port if it does not exist
                        if (-not (Get-PrinterPort -Name $portName -ErrorAction SilentlyContinue)) {
                            if ($PSCmdlet.ShouldProcess($portName, 'Add TCP/IP printer port')) {
                                Add-PrinterPort -Name $portName -PrinterHostAddress $ipMap.NewPath -ErrorAction Stop
                                Write-Verbose "Added printer port: $portName"
                            }
                        }

                        # Add machine-level printer if display name is known and printer does not exist
                        if ($printerDisplayName) {
                            if (-not (Get-Printer -Name $printerDisplayName -ErrorAction SilentlyContinue)) {
                                if ($PSCmdlet.ShouldProcess($printerDisplayName, 'Add printer')) {
                                    Add-Printer -Name $printerDisplayName -PortName $portName -DriverName $ipMap.DriverName -ErrorAction Stop
                                    Write-Verbose "Added printer: $printerDisplayName on port $portName"
                                }
                            }
                        }
                    }
                }

                # --- Step 3: Resolve target user profiles ---
                $profileParams = @{ ErrorAction = 'Stop' }

                if ($computer -ne $env:COMPUTERNAME) {
                    $profileParams['ComputerName'] = $computer
                    if ($Credential) { $profileParams['Credential'] = $Credential }
                }

                $profiles = Get-VBUserProfile @profileParams

                if ($TargetUser) {
                    $profiles = @($profiles | Where-Object {
                        $_.Username -eq $TargetUser -or $_.SID -eq $TargetUser
                    })

                    if ($profiles.Count -eq 0) {
                        Write-Warning "No profile found for TargetUser '$TargetUser' on $computer"
                        continue
                    }
                }

                # --- Step 4: Per-user migration ---
                foreach ($profile in $profiles) {

                    $mountParams = @{
                        SID         = $profile.SID
                        ErrorAction = 'Stop'
                    }

                    if ($computer -ne $env:COMPUTERNAME) {
                        $mountParams['ComputerName'] = $computer
                        if ($Credential) { $mountParams['Credential'] = $Credential }
                    }

                    $mountResult = Mount-VBUserHive @mountParams

                    if ($mountResult.Status -ne 'Success') {
                        [PSCustomObject]@{
                            ComputerName = $computer
                            Username     = $profile.Username
                            SID          = $profile.SID
                            OldPath      = 'N/A'
                            NewPath      = 'N/A'
                            Action       = 'Failed'
                            Details      = "Hive mount failed: $($mountResult.Error)"
                            Error        = $mountResult.Error
                            Status       = 'Failed'
                            Timestamp    = (Get-Date).ToString('dd-MM-yyyy HH:mm:ss')
                        }
                        continue
                    }

                    try {
                        # Optional: backup current printer state before changes
                        if ($BackupMappings) {
                            $backupParams = @{ TableOutput = $true; ErrorAction = 'SilentlyContinue' }
                            if ($computer -ne $env:COMPUTERNAME) {
                                $backupParams['ComputerName'] = $computer
                                if ($Credential) { $backupParams['Credential'] = $Credential }
                            }

                            $backupData = Get-VBUserPrinterMappings @backupParams |
                                Where-Object { $_.Username -eq $profile.Username }

                            if ($backupData) {
                                $backupData | Export-Csv -Path $BackupPath -NoTypeInformation -Append -Encoding UTF8
                            }
                        }

                        # Apply registry changes for this user
                        Update-VBUserPrinterRegistry `
                            -SID          $mountResult.SID `
                            -Username     $profile.Username `
                            -ComputerName $computer `
                            -Mappings     $normalizedMappings
                    }
                    finally {
                        # Always dismount -- even if Update-VBUserPrinterRegistry throws
                        $mountResult | Dismount-VBUserHive | Out-Null
                    }
                }
            }
            catch {
                [PSCustomObject]@{
                    ComputerName = $computer
                    Username     = 'N/A'
                    SID          = 'N/A'
                    OldPath      = 'N/A'
                    NewPath      = 'N/A'
                    Action       = 'Failed'
                    Details      = $_.Exception.Message
                    Error        = $_.Exception.Message
                    Status       = 'Failed'
                    Timestamp    = (Get-Date).ToString('dd-MM-yyyy HH:mm:ss')
                }
            }
        }
    }
}