Public/Copy-FileEx.ps1
# .SYNOPSIS # Copies files with advanced progress reporting and Windows API support. # # .DESCRIPTION # Copy-FileEx provides enhanced file copy capabilities with detailed progress reporting. # It leverages the Windows CopyFileEx API when available and falls back to managed file # copy operations when necessary. Features include speed reporting, progress bars, # recursive copying, and special character handling. # # .PARAMETER Path # Path to source file(s) or directory. Supports wildcards. # # .PARAMETER LiteralPath # Path to source file(s) or directory. Does not support wildcards. Use this when path contains special characters. # # .PARAMETER Destination # Destination path where files will be copied to. # # .PARAMETER Include # Optional array of include filters (e.g., "*.txt", "file?.doc"). # # .PARAMETER Exclude # Optional array of exclude filters (e.g., "*.tmp", "~*"). # # .PARAMETER Recurse # If specified, copies subdirectories recursively. Required for directory copies. # # .PARAMETER Force # If specified, overwrites existing files. Without this, existing files are skipped. # # .PARAMETER PassThru # If specified, returns objects representing copied items. # # .PARAMETER UseWinApi # If true (default), uses Windows CopyFileEx API. If false, uses managed file copy. # # .EXAMPLE # Copy-FileEx -Path "C:\source\file.txt" -Destination "D:\backup" # # Copies a single file with progress reporting. # # .EXAMPLE # Copy-FileEx -Path "C:\source\folder" -Destination "D:\backup" -Recurse # # Copies a directory and all its contents recursively. # # .EXAMPLE # Copy-FileEx -Path "C:\source\*.txt" -Destination "D:\backup" -Force # # Copies all .txt files, overwriting any existing files. # # .EXAMPLE # Copy-FileEx -LiteralPath "C:\source\file[1].txt" -Destination "D:\backup" # # Copies a file with special characters in the name. # # .EXAMPLE # Get-ChildItem "C:\source" -Filter "*.txt" | Copy-FileEx -Destination "D:\backup" # # Uses pipeline input for copying multiple files. # # .EXAMPLE # Copy-FileEx -Path "C:\source\large.iso" -Destination "D:\backup" -UseWinApi $false # # Forces use of managed copy method instead of Windows API. # # .NOTES # Author: LordBubbles # Module: PSCopyFileEx # Version: 1.0.0 # # Performance Notes: # - Windows API method is generally faster # - Large files benefit from API buffering # - Network paths use compressed traffic when possible # # .LINK # https://github.com/LordBubblesDev/PSCopyFileEx Function Copy-FileEx { [CmdletBinding(SupportsShouldProcess=$true, DefaultParameterSetName='Path')] param( [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true, ParameterSetName='Path')] [string[]]$Path, [Parameter(Mandatory=$true, Position=0, ValueFromPipelineByPropertyName=$true, ParameterSetName='LiteralPath')] [Alias('LP')] [string[]]$LiteralPath, [Parameter(Position=1, ValueFromPipelineByPropertyName=$true)] [string]$Destination, [Parameter()] [string[]]$Include, [Parameter()] [string[]]$Exclude, [Parameter()] [switch]$Recurse, [Parameter()] [switch]$Force, [Parameter()] [switch]$PassThru, [Parameter()] [bool]$UseWinApi = $true ) begin { # Generate a random progress ID to avoid conflicts $progressId = Get-Random -Minimum 0 -Maximum 1000 $childProgressId = $progressId + 1 # Initialize all variables that will be used across the function $script:speedSampleSize = 100 # Number of samples to average $script:speedSamples = @() $script:lastSpeedCheck = [DateTime]::Now $script:lastBytesForSpeed = 0 $script:lastTime = [DateTime]::Now $script:lastBytes = 0 $script:lastSpeedUpdate = [DateTime]::Now $script:currentSpeed = 0 $script:lastProgressUpdate = [DateTime]::Now $script:progressThreshold = [TimeSpan]::FromMilliseconds(100) function Format-FileSize { param([long]$Size) switch ($Size) { { $_ -gt 1TB } { "{0:n2} TB" -f ($_ / 1TB); Break } { $_ -gt 1GB } { "{0:n2} GB" -f ($_ / 1GB); Break } { $_ -gt 1MB } { "{0:n2} MB" -f ($_ / 1MB); Break } { $_ -gt 1KB } { "{0:n2} KB" -f ($_ / 1KB); Break } default { "{0} B " -f $_ } } } function Get-CurrentSpeed { param ( [DateTime]$now, [long]$currentBytes ) $timeDiff = ($now - $lastSpeedCheck).TotalSeconds if ($timeDiff -gt 0) { $bytesDiff = $currentBytes - $lastBytesForSpeed $speed = $bytesDiff / $timeDiff # Add to rolling samples $speedSamples += $speed if ($speedSamples.Count -gt $speedSampleSize) { $speedSamples = $speedSamples | Select-Object -Last $speedSampleSize } # Calculate average speed $avgSpeed = ($speedSamples | Measure-Object -Average).Average # Update last values $script:lastSpeedCheck = $now $script:lastBytesForSpeed = $currentBytes return $avgSpeed } return 0 } # Attempt to use Windows API for CopyFileEx if UseWinApi is true if ($UseWinApi) { $useWin32Api = $true # Check if type already exists if (-not ([System.Management.Automation.PSTypeName]'Win32Helpers.Win32CopyFileEx').Type) { $signature = @' [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] public static extern bool CopyFileEx( string lpExistingFileName, string lpNewFileName, CopyProgressRoutine lpProgressRoutine, IntPtr lpData, ref bool pbCancel, uint dwCopyFlags ); public delegate uint CopyProgressRoutine( long TotalFileSize, long TotalBytesTransferred, long StreamSize, long StreamBytesTransferred, uint dwStreamNumber, uint dwCallbackReason, IntPtr hSourceFile, IntPtr hDestinationFile, IntPtr lpData ); '@ try { Add-Type -MemberDefinition $signature -Name "Win32CopyFileEx" -Namespace "Win32Helpers" } catch { Write-Warning "Failed to use Windows API for file copy operations, falling back to managed copy method" $useWin32Api = $false } } } } process { # Handle both Path and LiteralPath parameters $pathsToProcess = @() if ($LiteralPath) { $pathsToProcess += $LiteralPath $useWildcards = $false } else { $pathsToProcess += $Path $useWildcards = $true } foreach ($currentPath in $pathsToProcess) { # Process paths based on whether they're literal or support wildcards try { # Handle path resolution differently for files with special characters if (Test-Path -LiteralPath $currentPath) { $resolvedPaths = @([pscustomobject]@{ Path = $currentPath ProviderPath = (Get-Item -LiteralPath $currentPath).FullName }) } else { if ($useWildcards) { $resolvedPaths = Resolve-Path -Path $currentPath -ErrorAction Stop } else { $resolvedPaths = Resolve-Path -LiteralPath $currentPath -ErrorAction Stop } } foreach ($resolvedPath in $resolvedPaths) { # Apply Include/Exclude filters $shouldProcess = $true if ($Include) { $shouldProcess = $resolvedPath.Path | Where-Object { $item = $_ return ($Include | ForEach-Object { $item -like $_ }) -contains $true } } if ($Exclude -and $shouldProcess) { $shouldProcess = $resolvedPath.Path | Where-Object { $item = $_ return ($Exclude | ForEach-Object { $item -like $_ }) -notcontains $true } } if ($shouldProcess) { # Check if we should process this item $targetPath = Join-Path $Destination (Split-Path -Leaf $resolvedPath.ProviderPath) if ($Force -or $PSCmdlet.ShouldProcess($targetPath)) { # Handle wildcards in path $sourcePath = Split-Path -Path $currentPath -Parent $sourceFilter = Split-Path -Path $currentPath -Leaf try { # Initialize variables $isFile = $false $relativePath = $null if ($sourceFilter.Contains('*')) { # Path contains wildcards $files = Get-ChildItem -Path $sourcePath -Filter $sourceFilter -File -Recurse:$Recurse -ErrorAction Stop $basePath = $sourcePath } else { # Single file or directory $item = Get-Item -LiteralPath $currentPath -ErrorAction Stop $isFile = $item -is [System.IO.FileInfo] if ($isFile) { Write-Verbose "Single file: $($item.Name)" $files = @($item) $basePath = Split-Path -Path $item.FullName -Parent # For single files, use the file's directory as base path $relativePath = $item.Name } else { $files = Get-ChildItem -Path $currentPath -File -Recurse:$Recurse -ErrorAction Stop $basePath = $item.FullName } } Write-Verbose "Base Path: $basePath" } catch { Write-Warning "Error accessing path: $_" continue } if ($files.Count -eq 0) { Write-Warning "No files found to copy" continue } # Calculate total size Write-Verbose "Calculating total size..." try { $totalSize = ($files | Measure-Object -Property Length -Sum).Sum Write-Verbose "Total bytes to copy: $totalSize" } catch { Write-Warning "Error calculating size: $_" continue } $totalBytesCopied = 0 $startTime = [DateTime]::Now # Initialize variables for managed copy method if (-not $useWin32Api) { # Set up buffer and timing $bufferSize = 4MB $buffer = New-Object byte[] $bufferSize # Reset speed calculation variables for new copy operation $script:speedSamples = @() $script:lastSpeedCheck = $startTime $script:lastBytesForSpeed = 0 $script:lastProgressUpdate = $startTime } # Show initial progress only for multiple files if ($files.Count -gt 1) { Write-Progress -Activity "Copying files" ` -Status "0 of $($files.Count) files (0 of $(Format-FileSize $totalSize))" ` -PercentComplete 0 ` -Id $progressId } $filesCopied = 0 $verboseOutput = @() # Collect verbose messages foreach ($file in $files) { $filesCopied++ # Calculate relative path for destination if ($isFile) { # Use pre-calculated relative path for single files $destPath = Join-Path $Destination $relativePath } else { # Calculate relative path for files in directories $relativePath = $file.FullName.Substring($basePath.Length).TrimStart('\') $destPath = Join-Path $Destination $relativePath } # Create destination directory if it doesn't exist $destDir = Split-Path -Path $destPath -Parent if (-not (Test-Path -Path $destDir)) { New-Item -Path $destDir -ItemType Directory -Force | Out-Null } # Check if destination file exists and handle Force parameter if (Test-Path -Path $destPath -PathType Leaf) { if (-not $Force) { Write-Warning "Destination file already exists: $destPath. Use -Force to overwrite." continue } Write-Verbose "Overwriting existing file: $destPath" } # Store verbose message $verboseOutput += "Copied '$($file.Name)' to '$destPath'" try { if ($useWin32Api) { $cancel = $false Write-Verbose "Using Windows API for file copy operations" # Create script-scope variables for the callback $script:currentFile = $file $script:filesCount = $files.Count $script:filesCopied = $filesCopied $script:totalBytesCopied = $totalBytesCopied $script:totalSize = $totalSize $script:progressId = $progressId $callback = { param( [long]$TotalFileSize, [long]$TotalBytesTransferred, [long]$StreamSize, [long]$StreamBytesTransferred, [uint32]$StreamNumber, [uint32]$CallbackReason, [IntPtr]$SourceFile, [IntPtr]$DestinationFile, [IntPtr]$Data ) try { # Use API values directly $percent = [math]::Min([math]::Round(($TotalBytesTransferred * 100) / [math]::Max($TotalFileSize, 1), 0), 100) # Update speed once per second $now = [DateTime]::Now if (($now - $script:lastSpeedUpdate).TotalSeconds -ge 1) { $timeDiff = ($now - $script:lastTime).TotalSeconds # Detect new file start (when bytes transferred is less than last bytes) if ($TotalBytesTransferred -lt $script:lastBytes) { $script:lastBytes = 0 $script:lastTime = $now $script:currentSpeed = 0 } else { $bytesDiff = $TotalBytesTransferred - $script:lastBytes # Ensure we never report negative speeds $script:currentSpeed = if ($timeDiff -gt 0) { [math]::Max(0, [math]::Round($bytesDiff / $timeDiff)) } else { 0 } } $script:lastTime = $now $script:lastBytes = $TotalBytesTransferred $script:lastSpeedUpdate = $now } if ($script:filesCount -gt 1) { # Overall progress $totalPercent = [math]::Min([math]::Round((($script:totalBytesCopied + $TotalBytesTransferred) / $script:totalSize * 100), 0), 100) Write-Progress -Activity "Copying files ($totalPercent%)" ` -Status "$($script:filesCopied) of $($script:filesCount) files ($(Format-FileSize $totalBytesCopied) of $(Format-FileSize $script:totalSize)) - $(Format-FileSize $script:currentSpeed)/s" ` -PercentComplete $totalPercent ` -Id $script:progressId # File progress Write-Progress -Activity "Copying $($script:currentFile.Name) ($percent%)" ` -Status "$(Format-FileSize $TotalBytesTransferred) of $(Format-FileSize $TotalFileSize)" ` -PercentComplete $percent ` -ParentId $script:progressId ` -Id ($script:progressId + 1) } else { Write-Progress -Activity "Copying $($script:currentFile.Name) ($percent%)" ` -Status "$(Format-FileSize $TotalBytesTransferred) of $(Format-FileSize $TotalFileSize) - $(Format-FileSize $script:currentSpeed)/s" ` -PercentComplete $percent ` -Id $script:progressId } } catch { Write-Warning "Progress callback error: $_" return [uint32]3 # PROGRESS_QUIET } return [uint32]0 # PROGRESS_CONTINUE } # Determine optimal copy flags $copyFlags = 0 if ($file.Length -gt 10MB) { $copyFlags = $copyFlags -bor 0x00001000 # COPY_FILE_NO_BUFFERING } if ($destPath -like "\\*") { # Network path $copyFlags = $copyFlags -bor 0x10000000 # COPY_FILE_REQUEST_COMPRESSED_TRAFFIC } $result = [Win32Helpers.Win32CopyFileEx]::CopyFileEx( $file.FullName, $destPath, $callback, [IntPtr]::Zero, [ref]$cancel, $copyFlags ) if (-not $result) { $errorCode = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error() throw "CopyFileEx failed with error code: $errorCode" } $totalBytesCopied += $file.Length } else { try { Write-Verbose "Using managed method for file copy operations" $sourceStream = [System.IO.File]::OpenRead($file.FullName) $destStream = [System.IO.File]::Create($destPath) $bytesRead = 0 $fileSize = [Math]::Max($file.Length, 1) $fileBytesCopied = 0 while (($bytesRead = $sourceStream.Read($buffer, 0, $buffer.Length)) -gt 0) { $destStream.Write($buffer, 0, $bytesRead) $fileBytesCopied += $bytesRead $totalBytesCopied += $bytesRead # Update progress less frequently $now = [DateTime]::Now if (($now - $lastProgressUpdate) -gt $progressThreshold) { $totalPercent = [math]::Min([math]::Round(($totalBytesCopied / $totalSize * 100), 0), 100) $filePercent = [math]::Min([math]::Round(($fileBytesCopied / $fileSize * 100), 0), 100) # Calculate current speed $currentSpeed = Get-CurrentSpeed -now $now -currentBytes $totalBytesCopied $speedText = if ($currentSpeed -gt 0) { "$(Format-FileSize $currentSpeed)/s" } else { "0 B/s" } if ($files.Count -gt 1) { # Overall progress for multiple files Write-Progress -Activity "Copying files ($totalPercent%)" ` -Status "$filesCopied of $($files.Count) files ($(Format-FileSize $totalBytesCopied) of $(Format-FileSize $totalSize)) - $speedText" ` -PercentComplete $totalPercent ` -Id $progressId # File progress as child Write-Progress -Activity "Copying $($file.Name) ($filePercent%)" ` -Status "$(Format-FileSize $fileBytesCopied) of $(Format-FileSize $fileSize)" ` -PercentComplete $filePercent ` -ParentId $progressId ` -Id $childProgressId } else { # Single file progress Write-Progress -Activity "Copying $($file.Name) ($filePercent%)" ` -Status "$(Format-FileSize $fileBytesCopied) of $(Format-FileSize $fileSize) - $speedText" ` -PercentComplete $filePercent ` -Id $progressId } $lastProgressUpdate = $now } } } finally { if ($sourceStream) { $sourceStream.Close() } if ($destStream) { $destStream.Close() } } } } catch { Write-Warning "Error copying $($file.Name): $_" } } # Calculate total elapsed time $endTime = [DateTime]::Now $elapsedTime = $endTime - $startTime $elapsedText = if ($elapsedTime.TotalHours -ge 1) { "{0:h'h 'm'm 's's'}" -f $elapsedTime } elseif ($elapsedTime.TotalMinutes -ge 1) { "{0:m'm 's's'}" -f $elapsedTime } else { "{0:s's'}" -f $elapsedTime } if ($files.Count -gt 1) { $verboseOutput += "Total copied: $(Format-FileSize $totalSize) ($($files.Count) files)" } else { $verboseOutput += "Total size: $(Format-FileSize $totalSize)" } $verboseOutput += "Operation completed in $elapsedText" # Write all verbose messages at once after copying is complete if ($PSCmdlet.MyInvocation.BoundParameters["Verbose"].IsPresent) { if ($files.Count -gt 1) { $verboseOutput | ForEach-Object { Write-Verbose $_ } } else { $verboseOutput | ForEach-Object { Write-Output $_ } } } # Complete progress bars if ($files.Count -gt 1) { Write-Progress -Activity "Copying files" -Id $childProgressId -Completed } Write-Progress -Activity "Copying files" -Id $progressId -Completed # Return copied item if PassThru is specified if ($PassThru) { Get-Item -LiteralPath $targetPath } } } } } catch { Write-Error -ErrorRecord $_ } } } } |