Last active
February 9, 2024 06:24
-
-
Save swbbl/59f8825da3272b57c4a439fb50227f9a to your computer and use it in GitHub Desktop.
Backup/Copy files and folders while remaining folder structure
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Place the file into the source root directory. All files will be recursively copied to target directory. | |
$ErrorActionPreference = 'STOP' | |
$sourceRootDirPath = $PSScriptRoot | |
# Set the target directory. Ensure that you don't select a parent directory. | |
# If you place the script to e.g. "C:\ThisIsMySource", you must provide "ThisIsMySourceDirectory" in the target directory path -> "D:\ThisIsMySourceDirectory" | |
# Don't use just "D:\" | |
$targetRootDirPath = 'E:\Games\Steam\steamapps\common\' + ([System.IO.DirectoryInfo]$sourceRootDirPath).Name | |
# if $useFileChecksum is $false, "LastWriteTimeUtc" and "Size" is used. | |
# WARNING: huge performance impact due file content reading and hashing for even already identical files. Use it only if really necessary. | |
$useFileChecksum = $false | |
$backupBeforeOverwrite = $false # includes handling of orphaned files, where $true means MOVE to backup dir and $false just remove | |
# backup all files recursively based from the sourceRootDirPath | |
$recursiveBackup = $true | |
# include system and hidden files. This will just set the -Force parameter for Get-ChildItem and Get-Item | |
$includeSystemFiles = $true | |
$logProgress = $true | |
function Get-LogTime([switch]$BasicFormat) { | |
$logTime = [datetime]::UtcNow.ToString('o') | |
if ($BasicFormat) { | |
# ISO 8601 Basic Format | |
$logTime -replace '[:-]' | |
} else { | |
# ISO 8601 Extended Format | |
$logTime | |
} | |
} | |
$fsDateTimeStamp = Get-LogTime -BasicFormat | |
$backupDirName = '_backup' | |
$backupRootDirPath = Join-Path -Path $targetRootDirPath -ChildPath $backupDirName | |
$backupDirPath = Join-Path -Path $backupRootDirPath -ChildPath $fsDateTimeStamp | |
$backupLogFilePath = Join-Path -Path $backupRootDirPath -ChildPath "$fsDateTimeStamp.log" | |
$logFormat = '{0} {1} "{2}" {3}' | |
if ($useFileChecksum) { | |
$logFormatFile = $logFormat + ' {4}:{5}' | |
} else { | |
$logFormatFile = $logFormat | |
} | |
trap { | |
# for all unhandled exceptions | |
$_ | |
if ($logProgress) { | |
# log | |
$logFormat -f (Get-LogTime), '[ERROR.UNHANDLED]', $_, $_.ScriptStackTrace >> $backupLogFilePath | |
} | |
break | |
} | |
$scriptExecutionTimer = [System.Diagnostics.Stopwatch]::StartNew() | |
if ($targetRootDirPath -in $null, '') { | |
$targetRootDirPath = Read-Host -Prompt 'Please define the target root directory' | |
} | |
if (-not (Test-Path -LiteralPath $targetRootDirPath)) { | |
try { | |
[void] (New-Item -Path $targetRootDirPath -ItemType Directory -Force) | |
} catch { | |
Write-Error -ErrorRecord $_ | |
return | |
} | |
} | |
if (($backupBeforeOverwrite -or $logProgress) -and -not (Test-Path -LiteralPath $backupRootDirPath)) { | |
try { | |
[void] (New-Item -Path $backupRootDirPath -ItemType Directory -Force) | |
} catch { | |
Write-Error -ErrorRecord $_ | |
return | |
} | |
} | |
# target files in advance to figure out which files aren't necessary anymore. Will be moved to backup dir if flag is set. | |
$targetFiles = Get-ChildItem -LiteralPath $targetRootDirPath -File -Recurse:$recursiveBackup -Force:$includeSystemFiles | |
$orphanedTargetFilesLookup = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) | |
# Using Set-Location to easily use Resolve-Path with param -Relative (alternative: Push-Location w/ or w/o Pop-Location) | |
try { | |
Set-Location -LiteralPath $targetRootDirPath -ErrorAction Stop | |
} catch { | |
Write-Error -ErrorRecord $_ | |
return | |
} | |
foreach ($targetFile in $targetFiles) { | |
$relativeFilePath = (Resolve-Path -LiteralPath $targetFile.FullName -Relative) -replace '^\.' | |
if ($targetFile.FullName -notlike "$backupRootDirPath\*") { | |
$null = $orphanedTargetFilesLookup.Add($relativeFilePath) | |
} | |
} | |
$sourceFiles = Get-ChildItem -LiteralPath $sourceRootDirPath -File -Recurse:$recursiveBackup -Force:$includeSystemFiles | |
# Using Set-Location to easily use Resolve-Path with param -Relative (alternative: Push-Location w/ or w/o Pop-Location) | |
try { | |
Set-Location -LiteralPath $sourceRootDirPath -ErrorAction Stop | |
} catch { | |
Write-Error -ErrorRecord $_ | |
return | |
} | |
[uint64]$aggregatedSize = [System.Linq.Enumerable]::Sum([uint64[]]$sourceFiles.GetEnumerator().Length) | |
[uint64]$aggregatedSizeMb = $aggregatedSize / 1MB | |
[uint64]$sourceFilesCount = ([array]$sourceFiles).Count | |
$aggregatedSizeMbStringLength = $aggregatedSizeMb.ToString('#,##0.00').Length | |
$sourceFilesCountStringLength = $sourceFilesCount.Count.ToString('#,##0').Length | |
[uint64]$processedSize = 0 | |
if ($logProgress) { | |
# log | |
"Src: $sourceRootDirPath" >> $backupLogFilePath | |
"Dst: $targetRootDirPath" >> $backupLogFilePath | |
'' >> $backupLogFilePath | |
"SrcCount: $sourceFilesCount" >> $backupLogFilePath | |
"SrcSize : $aggregatedSizeMb MB" >> $backupLogFilePath | |
'' >> $backupLogFilePath | |
"UseFileChecksum : $useFileChecksum" >> $backupLogFilePath | |
"BackupBeforeOverwrite: $backupBeforeOverwrite" >> $backupLogFilePath | |
"RecursiveBackup : $recursiveBackup" >> $backupLogFilePath | |
"IncludeSystemFiles : $includeSystemFiles" >> $backupLogFilePath | |
'' >> $backupLogFilePath | |
"Start: $(Get-LogTime)" >> $backupLogFilePath | |
} | |
#region Progress bar definition | |
#! https://github.com/PowerShell/PowerShell/issues/18848 | |
#! https://github.com/PowerShell/PowerShell/issues/13005 | |
$progressId = 0 | |
$progressActivity = 'Copy new and modified files' | |
$progressCount = ([array]$sourceFiles).Count | |
$progressUpdateInterval = 0 # seconds (0 means for each file) | |
$progressItemSizeThreshold = 100MB | |
$progressCounter = 0 # init 0 | |
$progressLastUpdateTime = [timespan]0 # init 0 | |
$progressUpdateTimer = [System.Diagnostics.Stopwatch]::StartNew() | |
#endregion | |
foreach ($sourceFile in $sourceFiles) { | |
$relativeFilePath = (Resolve-Path -LiteralPath $sourceFile.FullName -Relative) -replace '^\.' | |
# remove target from lookup as there is a corresponding file in source | |
$null = $orphanedTargetFilesLookup.Remove($relativeFilePath) | |
#region Progress bar execution | |
$progressCounter++ | |
# this condition would only update the progress after defined update interval or if it exceeds the defined threshold size | |
if (-not $progressUpdateInterval -or ($sourceFile.Length -ge $progressItemSizeThreshold) -or ($progressUpdateTimer.Elapsed.TotalSeconds - $progressLastUpdateTime.TotalSeconds -ge $progressUpdateInterval) -or ($progressLastUpdateTime.Ticks -eq 0)) { | |
<# | |
$fileSize = switch ($sourceFile.Length) { | |
{ $_ % 1KB -eq $_ } { '{0:#,##0.00} {1}' -f ($_), 'B' ; break } | |
{ $_ % 1MB -eq $_ } { '{0:#,##0.00} {1}' -f ($_ / 1KB), 'KB'; break } | |
{ $_ % 1GB -eq $_ } { '{0:#,##0.00} {1}' -f ($_ / 1MB), 'MB'; break } | |
{ $_ % 1TB -eq $_ } { '{0:#,##0.00} {1}' -f ($_ / 1GB), 'GB'; break } | |
{ $_ % 1PB -eq $_ } { '{0:#,##0.00} {1}' -f ($_ / 1TB), 'TB'; break } | |
} | |
#> | |
$num = $sourceFile.Length | |
$units = 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' | |
$format = '{0:N2} {1}' | |
if ($num -lt 0) { | |
$num*= -1 | |
$format = $format.Insert(0, '-') | |
} | |
$pow = [math]::Min([double]$units.Count - 1, [math]::Floor([math]::Log($num, 1KB))) | |
if ($pow -in ([double]::NegativeInfinity, [double]::PositiveInfinity)) { | |
$pow = 0 | |
} | |
$fileSize = $format -f ($num / [math]::Pow(1KB, $pow)), $units[$pow] | |
$itemProgressStatus = "{0,$sourceFilesCountStringLength} / {1} files" -f $progressCounter.ToString('#,##0'), $progressCount.ToString('#,##0') | |
$sizeProgressStatus = "{0,$aggregatedSizeMbStringLength} / {1} MB" -f ($processedSize / 1MB).ToString('#,##0.00'), ($aggregatedSize / 1MB).ToString('#,##0.00') | |
Write-Progress -Activity $progressActivity -Status $itemProgressStatus -PercentComplete ($progressCounter / $progressCount * 100) -Id $progressId | |
# PS6+ compatible (to show current file) | |
Write-Progress -Activity 'Progress by file size' -Status "$sizeProgressStatus - $relativeFilePath [$fileSize]" -PercentComplete ($processedSize / $aggregatedSize * 100) -Id ($progressId + 1) -ParentId $progressId | |
#Write-Progress -Activity 'Progress by file size' -CurrentOperation "Current File: $relativeFilePath [$fileSize]" -Status $sizeProgressStatus -PercentComplete ($processedSize / $aggregatedSize * 100) -Id ($progressId + 1) -ParentId $progressId | |
$progresslastUpdateTime = $progressUpdateTimer.Elapsed | |
} | |
# skip this script and any backup directories located in source directory | |
if (($sourceFile.FullName -eq $PSCommandPath) -or ($relativeFilePath -like "\$backupDirName\*")) { | |
if ($logProgress) { | |
# log | |
$logFormat -f (Get-LogTime), '[SKIP.EXCLUDE]', $relativeFilePath, $sourceFile.Length >> $backupLogFilePath | |
} | |
continue | |
} | |
$targetFilePath = Join-Path -Path $targetRootDirPath -ChildPath $relativeFilePath | |
$targetDirPath = Split-Path -Path $targetFilePath -Parent | |
try { | |
$targetFileIsIdentical = $false | |
$sourceFileChecksum = $targetFileChecksum = $null | |
# in case the source file already exists in target directory, compare and create a backup if necessary and defined | |
if (Test-Path -LiteralPath $targetFilePath -PathType Leaf) { | |
$targetFile = Get-Item -LiteralPath $targetFilePath -Force:$includeSystemFiles | |
if ($useFileChecksum) { | |
$sourceFileChecksum = Get-FileHash -LiteralPath $sourceFile.FullName -Algorithm SHA1 | |
$targetFileChecksum = Get-FileHash -LiteralPath $targetFilePath -Algorithm SHA1 | |
$targetFileIsIdentical = $sourceFileChecksum.Hash -eq $targetFileChecksum.Hash | |
} else { | |
# check whether source and target file are identical (based on LastWriteTimeUtc and Size) | |
$targetFileIsIdentical = ($sourceFile.LastWriteTimeUtc -eq $targetFile.LastWriteTimeUtc) -and ($sourceFile.Length -eq $targetFile.Length) | |
} | |
if ($backupBeforeOverwrite -and -not $targetFileIsIdentical) { | |
$backupFilePath = Join-Path -Path $backupDirPath -ChildPath $relativeFilePath | |
$backupFileDirPath = Split-Path -LiteralPath $backupFilePath | |
if (-not (Test-Path -LiteralPath $backupFileDirPath)) { | |
[void](New-Item -Path $backupFileDirPath -ItemType Directory -Force) | |
} | |
if ($logProgress) { | |
# log | |
$logFormatFile -f (Get-LogTime), '[BACKUP]', $relativeFilePath, $targetFile.Length, $targetFileChecksum.Algorithm, $targetFileChecksum.Hash >> $backupLogFilePath | |
} | |
Copy-Item -LiteralPath $targetFile.FullName -Destination $backupFilePath -Force | |
} | |
} | |
if (-not $targetFileIsIdentical) { | |
if (-not (Test-Path -LiteralPath $targetDirPath -PathType Container)) { | |
[void](New-Item -Path $targetDirPath -ItemType Directory -Force) | |
} | |
if ($logProgress) { | |
# log | |
$logFormatFile -f (Get-LogTime), '[COPY]', $relativeFilePath, $sourceFile.Length, $sourceFileChecksum.Algorithm, $sourceFileChecksum.Hash >> $backupLogFilePath | |
} | |
Copy-Item -LiteralPath $sourceFile.FullName -Destination $targetFilePath -Force | |
} else { | |
if ($logProgress) { | |
# log | |
$logFormatFile -f (Get-LogTime), '[SKIP.IDENTICAL]', $relativeFilePath, $sourceFile.Length, $sourceFileChecksum.Algorithm, $sourceFileChecksum.Hash >> $backupLogFilePath | |
} | |
} | |
} catch { | |
if ($logProgress) { | |
# log | |
$logFormat -f (Get-LogTime), '[ERROR]', $relativeFilePath, $_ >> $backupLogFilePath | |
} | |
Write-Error -ErrorRecord $_ -ErrorAction Continue | |
# return | |
} | |
$processedSize += $sourceFile.Length | |
} | |
#region Progress completed | |
Write-Progress -Activity $progressActivity -Completed -Id ($progressId + 1) | |
Write-Progress -Activity $progressActivity -Completed -Id $progressId | |
$progressUpdateTimer.Stop() | |
#region Progress bar definition | |
$progressId = 0 | |
$progressActivity = 'Remove/move orphaned target files' | |
$progressCount = ([array]$sourceFiles).Count | |
$progressUpdateInterval = 0 # seconds (0 means for each file) | |
$progressItemSizeThreshold = 100MB | |
$progressCounter = 0 # init 0 | |
$progressLastUpdateTime = [timespan]0 # init 0 | |
$progressUpdateTimer = [System.Diagnostics.Stopwatch]::StartNew() | |
#endregion | |
foreach ($relativeFilePath in $orphanedTargetFilesLookup) { | |
$targetFilePath = Join-Path -Path $targetRootDirPath -ChildPath $relativeFilePath | |
$targetFile = Get-Item -LiteralPath $targetFilePath -Force:$includeSystemFiles | |
if ($backupBeforeOverwrite) { | |
$backupFilePath = Join-Path -Path $backupDirPath -ChildPath $relativeFilePath | |
$backupFileDirPath = Split-Path -LiteralPath $backupFilePath | |
if (-not (Test-Path -LiteralPath $backupFileDirPath)) { | |
[void](New-Item -Path $backupFileDirPath -ItemType Directory -Force) | |
} | |
if ($logProgress) { | |
# log | |
$logFormat -f (Get-LogTime), '[BACKUP.ORPHANED]', $relativeFilePath, $targetFile.Length >> $backupLogFilePath | |
$logFormat -f (Get-LogTime), '[REMOVE.ORPHANED]', $relativeFilePath, $targetFile.Length >> $backupLogFilePath | |
} | |
Move-Item -LiteralPath $targetFile.FullName -Destination $backupFilePath -Force | |
} else { | |
if ($logProgress) { | |
# log | |
$logFormat -f (Get-LogTime), '[REMOVE.ORPHANED]', $relativeFilePath, $targetFile.Length >> $backupLogFilePath | |
} | |
Remove-Item -LiteralPath $targetFile.FullName -Force | |
} | |
} | |
$scriptExecutionTimer.Stop() | |
if ($logProgress) { | |
# log | |
"End: $(Get-LogTime)" >> $backupLogFilePath | |
"`nDuration: $($scriptExecutionTimer.Elapsed.ToString('dd\.hh\:mm\:ss\.fff'))" >> $backupLogFilePath | |
} | |
#endregion |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment