PsBackupUtil.psm1
#------------------------------------------------------------------------------ # PSBackupUtil™ # Copyright (c) 2019-2023 Zareh DerGevorkian #------------------------------------------------------------------------------ # Creates a Zip file containing the contents of the specified folder, # excluding any specified subfolders, files, and file types. # # Each time the script executes, it determines whether a full or partial # backup should be performed based on the date/time and type of the last # backup. Full backups are created initially, then every N days. Partial # backups are created in the interim to archive only files that have # changed since the last full or partial backup. # # Zip file names are formated as follows: # <base-name>-<yyyy>-<MM>-<dd>-<HH>-<mm>-<ss>[-<archive-mode-marker>].<extension> # Where: # <base-name> => User specified, or name of folder to be archived # y, M, d, H, m, s => components of current date/time in 24-hour format # <archive-mode-Marker> => Full | Part | <blank> # <extension> => User sepcified (default: 'zip') #------------------------------------------------------------------------------ using namespace System.IO function Backup-FolderContents { [CmdletBinding()] param ( # Folder (contents of which are) to be archived. [parameter(Mandatory, HelpMessage = "The folder (contents of which are) to be archived.")] [ValidateScript( { Test-Path $_ } )] [ValidateNotNullOrEmpty()] [string] $SourceFolder, # Folder where the archive file for the backup will be placed. #[ValidateScript( { Test-Path $_ } )] [parameter(Mandatory, HelpMessage = "Folder where the archive file for the backup will be placed.")] [ValidateNotNullOrEmpty()] [string] $DestinationFolder, # The base name to use for naming the backup file (default: source folder name). [parameter(HelpMessage = "The base name to use for naming the backup file (default: source folder name).")] [string] $BaseName, # Extension for backup file name (default: 'zip'). [parameter(HelpMessage = "Extension for backup file name (default: 'zip').")] [string] $Extension = "zip", # Number of days between full backups. Default = 7 [Parameter(HelpMessage = "Number of days between full backups. Defaults to 7.")] [int] $FullBackupInterval = 7, # Remove prior period partial backup file(s) after creating a full-backup. [Parameter(HelpMessage = "Remove prior period partial backup file(s) after creating a full-backup.")] [switch] $RemovePartialsAfterFull, # Marker text for Full backup archive-file names (default: 'Full'). [Parameter(HelpMessage = "Marker text for Full backup archive-file names (default: 'Full').")] [string] $FullBackupMarker = "Full", # Marker text for Partial backup archive-file names (default: 'Part'). [Parameter(HelpMessage = "Marker text for Partial backup archive-file names (default: 'Part').")] [string] $PartialBackupMarker = "Part", # Names of folders to omit from the backup. [string[]] $IgnoreFolders, # File extensions for file types to omit from the backup, [string[]] $IgnoreFileTypes, # Names of files (name & extensions, no path) to omit from the backup. [string[]] $IgnoreFiles ) try { $sw = [System.Diagnostics.Stopwatch]::StartNew() $src = $SourceFolder $dst = $DestinationFolder $base = $BaseName Invoke-UpdateNameFormatOfExistingFiles $dst $Extension if ([string]::IsNullOrWhiteSpace($base)) { $base = Split-Path $src -Leaf } $base = $base -replace "-", "_" $Mode = Get-NextBackupMode $base $dst $FullBackupInterval $Extension $type = if ($Mode -eq ([ArchiveMode]::Unknown)) { $null } else { "$Mode " } Write-Information "Performing $($type)Backup for $base" -InformationAction Continue Invoke-RunBackup ` $src $dst $Mode $base $Extension ` -IgnoreFolders $IgnoreFolders ` -IgnoreFileTypes $IgnoreFileTypes ` -IgnoreFiles $IgnoreFiles Write-Information ` "Operation completed in $($sw.Elapsed.TotalSeconds) seconds for $base ($SourceFolder)" ` -InformationAction Continue } catch { Write-Error $_ } } function Invoke-RunBackup { param ( # Folder containing fiels to be backed up. [parameter(Mandatory)] [ValidateScript( { Test-Path $_ } )] [ValidateNotNullOrEmpty()] [string] $SourceFolder, # Folder where the archive file for the backup will be created/placed. #[ValidateScript( { Test-Path $_ } )] [parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $DestinationFolder, # Backup mode: Full or Partial? [parameter(Mandatory)] [ValidateScript( { $_ -ne ([ArchiveMode]::Unknown) } )] [ArchiveMode] $ArchiveMode, # Base name for backup file. [parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Basename, # File extension (default: 'zip'). [string] $Extension = "zip", [string[]] $IgnoreFolders, [string[]] $IgnoreFileTypes, [string[]] $IgnoreFiles ) $ArchiveName = Get-NewArchiveName $Basename $ArchiveMode $Extension $ArchiveName = join-path -Path $DestinationFolder -ChildPath $ArchiveName Write-Verbose "Invoke-RunBackup: ArchiveName = $ArchiveName" $Files = Get-FilesForBackup ` $SourceFolder ` -IgnoreFolders $IgnoreFolders ` -IgnoreFileTypes $IgnoreFileTypes ` -IgnoreFiles $IgnoreFiles $CountFiles = ($Files).Count Write-Debug "Invoke-RunBackup: Count All Files = $CountFiles" # $Files | Select-Object -Property FullName if (($null -eq $Files) -or (0 -ge $CountFiles)) { Write-Warning "Invoke-RunBackup: Source folder contains no files that can be archived." return } $DtLastFull = Get-DateMostRecentArchive $DestinationFolder $Basename ([ArchiveMode]::Full) $Extension $DtChangedAfter = $DtLastFull if ($null -eq $DtLastFull) { $DtChangedAfter = [datetime]::MinValue } if ([ArchiveMode]::Partial -eq $ArchiveMode) { $DtLastPartial = Get-DateMostRecentArchive $DestinationFolder $Basename ([ArchiveMode]::Partial) $Extension if (($null -ne $DtLastPartial) -and ($DtLastPartial -gt $DtLastFull)) { $DtChangedAfter = $DtLastPartial } $Files = $Files | Where-Object { $_.PSIsContainer -or $(!$_.PSIsContainer -and $_.LastWriteTime -gt $DtChangedAfter) } $CountFiles = ($Files).Count Write-Debug "Invoke-RunBackup: ArchiveMode = $ArchiveMode, DtChangedAfter = $DtChangedAfter, Count Changed Files = $CountFiles" if (($null -eq $Files) -or (0 -ge $CountFiles)) { Write-Information "Nothing to do. No changed files since last backup." -InformationAction Continue return } } if ([ArchiveMode]::Partial -ne $ArchiveMode) { if ([datetime]::MinValue -ne $DtChangedAfter) { $CountChangedSinceLastFull = $($Files | Where-Object { $_.PSIsContainer -or $(!$_.PSIsContainer -and $_.LastWriteTime -gt $DtChangedAfter) }).Count # } | Measure-Object -Property Length).Count Write-Debug "Invoke-RunBackup: ArchiveMode = $ArchiveMode, DtChangedAfter = $DtChangedAfter" if (0 -ge $CountChangedSinceLastFull) { Write-Information "Nothing to do. No changed files since last full backup." -InformationAction Continue return } } } Write-Information "Backing Up $CountFiles file(s)..." -InformationAction Continue Invoke-CreateArchive $SourceFolder $DestinationFolder $ArchiveName $Files } function Get-FilesForBackup { param ( # Folder containing fiels to be backed up. [parameter(Mandatory)] [ValidateScript( { Test-Path $_ } )] [ValidateNotNullOrEmpty()] [string] $SourceFolder, [string[]] $IgnoreFolders, [string[]] $IgnoreFileTypes, [string[]] $IgnoreFiles ) $ignore = $IgnoreFiles + $IgnoreFileTypes $Files = ` Get-ChildItem $SourceFolder -File -Exclude $ignore -Recurse -Force | ` Where-Object { $( # $(!$_.PSIsContainer -and $_.LastWriteTime -gt $DtChangedAfter) -or !$_.PSIsContainer -or $($_.PSIsContainer -and $_.Name -inotin $IgnoreFolders) ) -and $( foreach ($fig in $IgnoreFolders) { if (($_ -imatch "\\$fig\\" ) -or ($_ -imatch "/$fig/" )) { return $false } } $true # return $true ) } | ` Select-Object | ` Sort-Object -Property FullName, Name return $Files } function Invoke-CreateArchive { param ( [parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $SourceFolder, [parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $DestinationFolder, [parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ArchiveName, [parameter(Mandatory)] [string[]] $Files ) if (0 -ge $Files.Length) { Write-Warning "Nothing to do: No files to archive." return } $FolderSeparator = [Path]::DirectorySeparatorChar.ToString() [bool] $haveFiles = $false $TempFolder = Join-Path -Path $DestinationFolder -ChildPath $(Split-Path $SourceFolder -Leaf) if (Test-path $TempFolder) { Remove-Item $TempFolder -Recurse -Force } New-Item -Path $TempFolder -ItemType Directory -ErrorAction Stop | Out-Null $Files | ForEach-Object { $relPath = $_.SubString($SourceFolder.Length) | Split-Path -Parent if (-not $relPath.Equals($FolderSeparator)) { $tempPath = Join-Path -Path $TempFolder -ChildPath $relPath if (-not (Test-Path $tempPath)) { New-Item -ItemType Directory -Path $tempPath -ErrorAction Stop | Out-Null } } else { $tempPath = $TempFolder } Copy-Item $_ -Destination $tempPath -ErrorAction Stop if ($haveFiles -ne $true) { $haveFiles = $true } } if ($haveFiles) { Compress-Archive -Path $TempFolder -DestinationPath $ArchiveName -CompressionLevel Optimal } Remove-Item $TempFolder -Recurse -Force } function Get-NewArchiveName { param ( # Base name for archive file. [parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $BaseName, # Optional archive mode (default: Unknown). [parameter()] [ArchiveMode] $ArchiveType, # Optional file extension (default: 'zip'). [parameter()] [string] $Extension = "zip" ) $DtNow = [datetime]::Now $Marker = Get-ArchiveModeMarker $ArchiveType if (-not [string]::IsNullOrWhiteSpace($Marker)) { $Marker = "-$Marker" } return "$($BaseName)-$($DtNow.ToString('yyyy-MM-dd-HH-mm-ss'))$($Marker).$($Extension)" } function Get-NextBackupMode { param ( # The base name for the archived files. [parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Basename, # Folder in which backups are stored. [parameter(Mandatory)] [string] $BackupsFolder, # Number of days between full backups. [parameter(Mandatory)] [int] $DaysBetweenFullBackups, # The archive file extension (default: 'zip'). [string] $Extension = "zip" ) if (-not $(Test-Path $BackupsFolder)) { return [ArchiveMode]::Full } $DtLastFullBackup = Get-DateMostRecentArchive $BackupsFolder $Basename ([ArchiveMode]::Full) $Extension if ($null -eq $DtLastFullBackup) { return [ArchiveMode]::Full } $NumDaysSince = ([timespan] ([datetime]::Now - $DtLastFullBackup)).Days if ($NumDaysSince -ge $DaysBetweenFullBackups) { return [ArchiveMode]::Full } else { return [ArchiveMode]::Partial } } function Get-DateMostRecentArchive { param ( # Folder where backup files are stored. #[ValidateScript( { Test-Path $_ } )] [parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $BackupsFolder, # Base name of archive file. [parameter(Mandatory)] [string] $BaseName, # Archive type to look for: Full or Partial? [parameter(Mandatory)] [ArchiveMode] $ArchiveType, # Archive file name extension (defaults to 'zip'). [parameter()] [string] $Extension = "zip" ) if (-not $(Test-Path $BackupsFolder)) { return $null } $OldCwd = [Directory]::GetCurrentDirectory() $Cwd = Get-Location [Directory]::SetCurrentDirectory($Cwd) try { $Marker = Get-ArchiveModeMarker $ArchiveType if (-not [string]::IsNullOrWhiteSpace($Marker)) { $Marker = "-$Marker" } $Path = Join-Path -Path $BackupsFolder -ChildPath "$($BaseName)*$($Marker).$($Extension)" $Path = [Path]::GetFullPath($Path) # if (0 -ge (Get-ChildItem $Path | Measure-Object -Property Length).Count) { if (0 -ge (Get-ChildItem $Path).Count) { return $null } $ArchiveName = Get-ChildItem $Path | Sort-Object -Property FullName | Select-Object -Last 1 | Split-Path -Leaf Write-Verbose "Get-DateMostRecentArchive: Mode = $ArchiveType, Archive = $ArchiveName" return Get-DateFromArchiveName $ArchiveName } finally { [Directory]::SetCurrentDirectory($OldCwd) } } function Get-DateFromArchiveName { param ( # The name of the archive file (expected format: [<base-name>][-<marker>]-<yyyy>-<MM>-<dd>-<HH>-<mm>.<extension>). #[ValidateRegEx()] [parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ArchiveName ) $Name = [Path]::GetFileNameWithoutExtension($ArchiveName) $DtParts = $Name.Split("-") | Select-Object -Skip 1 -First 6 $DtString = $DtParts | Select-Object -First 3 | Join-String -Separator "-" $DtString += " " + $($DtParts | Select-Object -Last 3 | Join-String -Separator ":") Write-Verbose "Get-DateFromArchiveName: DtString = $DtString" return [datetime]::ParseExact($DtString, "yyyy-MM-dd HH:mm:ss", $null) } function Get-ArchiveModeMarker { param ( [parameter(Mandatory)] [ArchiveMode] $ArchiveMode ) $Marker = [ArchiveMode]::Unknown switch ($ArchiveMode) { ([ArchiveMode]::Full) { $Marker = "Full" } ([ArchiveMode]::Partial) { $Marker = "Part" } Default { $Marker = "" } } return $Marker } enum ArchiveMode { Unknown Full Partial } function Invoke-UpdateNameFormatOfExistingFiles { [CmdletBinding()] param ( # Folder where archive files are located [parameter(Mandatory, HelpMessage = "Folder containing archive files with old naming style.")] # [ValidateNotNullOrEmpty()] # [ValidateScript( { Test-Path $_ } )] [string] $ArchiveFolder, # Extension for backup file name (default: 'zip'). [parameter(HelpMessage = "Extension for backup file name (default: 'zip').")] [string] $Extension = "zip" ) try { if ([string]::IsNullOrWhiteSpace($ArchiveFolder) -or ($true -ne (Test-Path $ArchiveFolder))) { return } $MarkerFull = Get-ArchiveModeMarker ([ArchiveMode]::Full) $MarkerPart = Get-ArchiveModeMarker ([ArchiveMode]::Partial) $FileSpec = Join-Path $ArchiveFolder "*.$($Extension)" $Files = Get-ChildItem $FileSpec -File -Recurse -Force $Files | ForEach-Object { $PathName = $_.FullName $FileName = [Path]::GetFileNameWithoutExtension($PathName) $FileExt = [Path]::GetExtension($PathName) $NameParts = $FileName.Split("-") $BaseName = $NameParts | Select-Object -First 1 $Mode = $NameParts | Select-Object -Skip 1 -First 1 $IsMode = $Mode -iin ($MarkerFull, $MarkerPart) -and -not "$Mode".StartsWith("20") if ($IsMode) { $DtParts = $NameParts | Select-Object -Last 5 $DtString = $DtParts | Join-String -Separator "-" $NewName = "$BaseName-$DtString-00-$Mode$FileExt" Rename-Item -Path $PathName $NewName } } } catch { Write-Error $_ } } Export-ModuleMember "Backup-FolderContents" |