DailyBackup.psm1
<# .SYNOPSIS Daily Backup PowerShell Module - Automated file and directory backup solution. .DESCRIPTION The DailyBackup module provides automated backup functionality with date-organized storage, automatic cleanup, and comprehensive error handling. It creates compressed ZIP archives from specified files and directories, organizing them into folders named by date (yyyy-MM-dd format). Key Features: - Automated daily backup creation with ZIP compression - Date-organized folder structure (yyyy-MM-dd) - Automatic cleanup of old backups based on retention policies - Support for multiple source paths in a single operation - Progress reporting for long-running operations - WhatIf/ShouldProcess support for safe testing - Cloud storage compatibility (OneDrive, iCloud, etc.) - Unique filename generation to prevent overwrites .NOTES Module Name: DailyBackup Author: Jon LaBelle Version: Latest Repository: https://github.com/jonlabelle/pwsh-daily-backup .LINK https://github.com/jonlabelle/pwsh-daily-backup #> $script:ErrorActionPreference = 'Stop' $script:ProgressPreference = 'SilentlyContinue' # ----------------------------------------------- # - Date format: yyyy-mm-dd # - Date range: 1900-01-01 through 2099-12-31 # - Matches invalid dates such as February 31st # - Accepts dashes, forward slashes and dots as date separators. # ----------------------------------------------- $script:DefaultFolderDateFormat = 'yyyy-MM-dd' $script:DefaultFolderDateRegex = '\b(19|20)[0-9]{2}[-/.](0[1-9]|1[012])[-/.](0[1-9]|[12][0-9]|3[01])\b' # ----------------------------------------------- function GetRandomFileName { <# .SYNOPSIS Generates a random file name without extension for uniqueness. .DESCRIPTION Creates a random file name by using the .NET System.IO.Path.GetRandomFileName() method and removing the file extension part. This is used internally to ensure backup file uniqueness when duplicate names are detected. .OUTPUTS [String] Returns a random filename string without the file extension (e.g., "kdjf3k2j"). .NOTES This is an internal helper function used by GenerateBackupPath to create unique backup filenames when duplicates are detected. .EXAMPLE PS > $randomName = GetRandomFileName Returns something like "kdjf3k2j" #> $randomFileName = [System.IO.Path]::GetRandomFileName() return $randomFileName.Substring(0, $randomFileName.IndexOf('.')) } function GenerateBackupPath { <# .SYNOPSIS Generates a unique backup file path from a source path. .DESCRIPTION Creates a backup file path by transforming the source path into a safe filename. Directory separators and drive prefixes are replaced with underscores to create a flat naming structure. If a file with the same name already exists, a random suffix is automatically appended to ensure uniqueness. .PARAMETER Path The source file or directory path that will be backed up. This path is used to generate the backup filename. .PARAMETER DestinationPath The destination directory where the backup file will be created. This is used to check for existing files and construct the full backup path. .OUTPUTS [String] Returns the full path to the backup file (without the .zip extension). The filename will be unique within the destination directory. .NOTES - Drive prefixes (e.g., 'C:') are removed from the source path - Directory separators ('\' and '/') are replaced with double underscores ('__') - If the generated path would exceed 255 characters, an error is thrown - Duplicate filenames are handled by appending a random suffix .EXAMPLE PS > GenerateBackupPath -Path 'C:\Users\John\Documents' -DestinationPath 'C:\Backups\2025-08-24' Returns: C:\Backups\2025-08-24\Users__John__Documents .EXAMPLE PS > GenerateBackupPath -Path '/home/user/photos' -DestinationPath '/backups/daily' Returns: /backups/daily/home__user__photos #> param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $DestinationPath ) # Removes the drive part (e.g. 'C:') $pathWithoutPrefix = (Split-Path -Path $Path -NoQualifier) # replace directory separators with underscores $backupName = ($pathWithoutPrefix -replace '[\\/]', '__').Trim('__') $backupPath = Join-Path -Path $DestinationPath -ChildPath $backupName if ((Test-Path -Path "$backupPath.zip")) { $randomFileName = (GetRandomFileName) $backupPath = ('{0}__{1}' -f $backupPath, $randomFileName) Write-Warning ("New-DailyBackup:GenerateBackupPath> A backup with the same filename '{0}' already exists in destination path '{1}', '{2}' was automatically appended to the backup filename for uniqueness" -f "$backupName.zip", $DestinationPath, $randomFileName) } if ($backupPath.Length -ge 255) { Write-Error ('New-DailyBackup:GenerateBackupPath> The backup file path ''{0}'' is greater than or equal the maximum allowed filename length (255)' -f $backupPath) -ErrorAction Stop } return $backupPath } function CompressBackup { <# .SYNOPSIS Creates a compressed archive (.zip) from a file or directory. .DESCRIPTION Compresses a specified file or directory into a ZIP archive using PowerShell's Compress-Archive cmdlet. The function supports WhatIf/ShouldProcess for safe testing and generates a unique backup filename automatically. If WhatIf is specified, the operation is simulated without creating the actual archive. .PARAMETER Path The path of the file or directory to compress into the backup archive. This can be a single file or an entire directory structure. .PARAMETER DestinationPath The destination directory where the compressed backup file will be created. The actual filename is generated automatically based on the source path. .PARAMETER VerboseEnabled Controls whether verbose output is displayed during the compression operation. When $true, detailed progress information is shown. .OUTPUTS None. This function creates a .zip file but does not return any objects. .NOTES - Uses SupportsShouldProcess for WhatIf and Confirm support - Automatically generates unique filenames to prevent overwrites - Leverages PowerShell's built-in Compress-Archive cmdlet - Continues on individual file errors rather than stopping completely .EXAMPLE PS > CompressBackup -Path 'C:\Documents' -DestinationPath 'C:\Backups\2025-08-24' -VerboseEnabled $true Creates a backup archive of the Documents folder with verbose output .EXAMPLE PS > CompressBackup -Path 'C:\MyFile.txt' -DestinationPath 'C:\Backups\2025-08-24' -WhatIf Shows what would be compressed without actually creating the archive #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $DestinationPath, [Parameter(Mandatory = $false)] [bool] $VerboseEnabled = $false ) $backupPath = GenerateBackupPath -Path $Path -DestinationPath $DestinationPath if ($PSCmdlet.ShouldProcess("$backupPath.zip", 'Compress-Archive')) { Write-Verbose ('New-DailyBackup:CompressBackup> Compressing backup ''{0}''' -f "$backupPath.zip") Compress-Archive -LiteralPath $Path -DestinationPath "$backupPath.zip" -WhatIf:$WhatIfPreference -Verbose:$VerboseEnabled -ErrorAction Continue } else { Write-Verbose ('New-DailyBackup:CompressBackup> Dry-run only, backup ''{0}'' will not be created' -f "$backupPath.zip") } } function ResolveUnverifiedPath { <# .SYNOPSIS Resolves file paths whether they exist or not, unlike Resolve-Path. .DESCRIPTION A wrapper around PowerShell's Resolve-Path cmdlet that handles both existing and non-existing paths gracefully. While Resolve-Path throws an exception for non-existing paths, this function returns the resolved path string regardless of whether the path exists on the filesystem. .PARAMETER Path The path to resolve. Can be relative or absolute, existing or non-existing. Supports pipeline input for processing multiple paths. .INPUTS [String] Path string that can be piped to this function. .OUTPUTS [String] The fully resolved path string. For existing paths, returns the provider path. For non-existing paths, returns the resolved target path that would exist. .NOTES This function was originally from the PowerShellForGitHub module. It's particularly useful for backup operations where destination paths may not exist yet but need to be resolved for path construction. .EXAMPLE PS > ResolveUnverifiedPath -Path 'c:\windows\notepad.exe' Returns: C:\Windows\notepad.exe (if it exists) .EXAMPLE PS > ResolveUnverifiedPath -Path '..\notepad.exe' Returns: C:\Windows\notepad.exe (resolved relative to current directory) .EXAMPLE PS > ResolveUnverifiedPath -Path '..\nonexistent.txt' Returns: C:\Windows\nonexistent.txt (resolved even though file doesn't exist) .EXAMPLE PS > 'file1.txt', 'file2.txt' | ResolveUnverifiedPath Resolves multiple paths from pipeline input .LINK https://aka.ms/PowerShellForGitHub #> [CmdletBinding()] param( [Parameter(ValueFromPipeline)] [string] $Path ) process { $resolvedPath = Resolve-Path -Path $Path -ErrorVariable resolvePathError -ErrorAction SilentlyContinue if ($null -eq $resolvedPath) { Write-Output -InputObject ($resolvePathError[0].TargetObject) } else { Write-Output -InputObject ($resolvedPath.ProviderPath) } } } function RemoveItemAlternative { <# .SYNOPSIS Removes files and folders using an alternative method for cloud storage compatibility. .DESCRIPTION Removes all files and folders within a specified path using the .NET Delete() methods instead of PowerShell's Remove-Item cmdlet. This approach resolves access denied issues commonly encountered when deleting items from cloud-synced folders like Apple iCloud, Microsoft OneDrive, or Google Drive. The function supports ShouldProcess for safe testing. .PARAMETER LiteralPath The path to the directory to remove. The value is used exactly as typed without wildcard interpretation. If the path contains escape characters, enclose it in single quotes to prevent PowerShell from interpreting them as escape sequences. .PARAMETER SkipTopLevelFolder When specified, only the contents of the folder are deleted, leaving the top-level folder intact. This is useful when you want to clear a directory but keep the folder structure. .INPUTS None. This function does not accept pipeline input. .OUTPUTS None. This function does not return any objects. .NOTES - Uses SupportsShouldProcess for WhatIf and Confirm support - Specifically designed to work with cloud storage providers (iCloud, OneDrive) - Falls back to .NET Delete() methods when PowerShell Remove-Item fails - Processes files first, then directories, then the root folder if not skipped - Continues processing even if individual items fail to delete .EXAMPLE PS > RemoveItemAlternative -LiteralPath "C:\Users\John\OneDrive\OldBackups" Removes the entire OldBackups folder and all its contents .EXAMPLE PS > RemoveItemAlternative -LiteralPath "C:\Users\John\iCloud\TempFiles" -SkipTopLevelFolder Clears the TempFiles folder contents but keeps the folder itself .EXAMPLE PS > RemoveItemAlternative -LiteralPath "C:\CloudFolder\Data" -WhatIf Shows what would be deleted without actually removing anything .LINK https://evotec.xyz/remove-item-access-to-the-cloud-file-is-denied-while-deleting-files-from-onedrive/ .LINK https://jonlabelle.com/snippets/view/powershell/powershell-remove-item-access-denied-fix #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter()] [string] $LiteralPath, [Parameter()] [switch] $SkipTopLevelFolder ) if ($LiteralPath -and (Test-Path -LiteralPath $LiteralPath)) { $items = Get-ChildItem -LiteralPath $LiteralPath -Recurse foreach ($item in $items) { if ($item.PSIsContainer -eq $false) { try { if ($PSCmdlet.ShouldProcess($item.Name)) { $item.Delete() } } catch { Write-Warning "New-DailyBackup:RemoveItemAlternative> Couldn't delete $($item.FullName), error: $($_.Exception.Message)" } } } $items = Get-ChildItem -LiteralPath $LiteralPath -Recurse foreach ($item in $items) { try { if ($PSCmdlet.ShouldProcess($item.Name)) { $item.Delete() } } catch { Write-Warning "New-DailyBackup:RemoveItemAlternative> Couldn't delete '$($item.FullName)', Error: $($_.Exception.Message)" } } if (-not $SkipTopLevelFolder) { $item = Get-Item -LiteralPath $LiteralPath try { if ($PSCmdlet.ShouldProcess($item.Name)) { $item.Delete($true) } } catch { Write-Warning "New-DailyBackup:RemoveItemAlternative> Couldn't delete '$($item.FullName)', Error: $($_.Exception.Message)" } } } else { Write-Warning "New-DailyBackup:RemoveItemAlternative> Path '$LiteralPath' doesn't exist. Skipping." } } function RemoveDailyBackup { <# .SYNOPSIS Removes old daily backup directories while keeping a specified number of recent backups. .DESCRIPTION Cleans up old daily backup directories by deleting the oldest backup folders first, while preserving a specified minimum number of recent backups. Only directories with date-formatted names (yyyy-MM-dd pattern) are considered for deletion. The function supports ShouldProcess for safe testing and will skip deletion if the number of existing backups doesn't exceed the retention limit. .PARAMETER Path The root directory path where daily backup folders are stored. This should be the parent directory containing date-named subdirectories (e.g., '2025-08-24'). .PARAMETER BackupsToKeep The minimum number of backup directories to retain. Older backups beyond this number will be deleted. Must be a positive integer. .PARAMETER VerboseEnabled Controls whether verbose output is displayed during the cleanup operation. When $true, detailed information about the deletion process is shown. .INPUTS None. This function does not accept pipeline input. .OUTPUTS None. This function does not return any objects. .NOTES - Only directories matching the yyyy-MM-dd date pattern are processed - Backups are sorted by date (parsed from folder name) before deletion - Uses SupportsShouldProcess for WhatIf and Confirm support - Continues operation even if individual directory deletions fail - Skips cleanup if total backups don't exceed the retention limit .EXAMPLE PS > RemoveDailyBackup -Path 'C:\Backups' -BackupsToKeep 7 -VerboseEnabled $true Keeps the 7 most recent daily backup folders, removes older ones .EXAMPLE PS > RemoveDailyBackup -Path '/home/user/backups' -BackupsToKeep 3 -WhatIf Shows which backup directories would be deleted without actually removing them .EXAMPLE PS > RemoveDailyBackup -Path 'C:\DailyBackups' -BackupsToKeep 14 Maintains a 2-week retention policy (14 days) for backup directories #> [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [int] $BackupsToKeep, [Parameter(Mandatory = $false)] [bool] $VerboseEnabled = $false ) $qualifiedBackupDirs = @(Get-ChildItem -LiteralPath $Path -Directory -ErrorAction 'SilentlyContinue' | Where-Object { $_.Name -cmatch $script:DefaultFolderDateRegex }) if ($qualifiedBackupDirs.Length -eq 0) { Write-Verbose ('New-DailyBackup:RemoveDailyBackup> No qualified backup directories to delete were detected in: {0}' -f $Path) -Verbose:$VerboseEnabled return } # Create a hashtable so we can sort backup directories based on their date-formatted folder name ('yyyy-MM-dd') $backups = @{ } foreach ($backupDir in $qualifiedBackupDirs) { $backups.Add($backupDir.FullName, [System.DateTime]$backupDir.Name) } $sortedBackupPaths = ($backups.GetEnumerator() | Sort-Object -Property Value | ForEach-Object { $_.Key }) if ($sortedBackupPaths.Count -gt $BackupsToKeep) { for ($i = 0; $i -lt ($sortedBackupPaths.Count - $BackupsToKeep); $i++) { $backupPath = $sortedBackupPaths[$i] if ($PSCmdlet.ShouldProcess($backupPath, 'Remove backup directory')) { Write-Verbose ('New-DailyBackup:RemoveDailyBackup> Removing old backup directory: {0}' -f $backupPath) -Verbose:$VerboseEnabled RemoveItemAlternative -LiteralPath $backupPath -WhatIf:$WhatIfPreference -Verbose:$VerboseEnabled Write-Verbose ('New-DailyBackup:RemoveDailyBackup> Successfully removed: {0}' -f $backupPath) -Verbose:$VerboseEnabled } } } else { Write-Verbose 'New-DailyBackup:RemoveDailyBackup> No surplus daily backups to delete' -Verbose:$VerboseEnabled } } function New-DailyBackup { <# .SYNOPSIS Creates daily backups by compressing files and directories into date-organized ZIP archives. .DESCRIPTION Creates compressed backup archives (.zip) from specified files and directories, organizing them into date-stamped folders (yyyy-MM-dd format). The function supports multiple source paths, automatic cleanup of old backups, progress reporting, and WhatIf/ShouldProcess for safe testing. Each source path is compressed into a separate ZIP file with automatically generated unique names. .PARAMETER Path One or more source file or directory paths to backup. Supports pipeline input, relative paths (resolved from current directory), and wildcard patterns. Each path will be compressed into a separate ZIP archive. .PARAMETER Destination The root directory where daily backup folders will be created. A subdirectory named with today's date (yyyy-MM-dd) will be created to store the backup archives. Defaults to the current working directory if not specified. .PARAMETER Keep The number of daily backup folders to retain when cleaning up old backups. Older backup folders beyond this number will be automatically deleted. Set to 0 (default) to disable automatic cleanup. Must be 0 or greater. Note: DailyBackupsToKeep is an alias for this parameter. .INPUTS [String[]] File or directory paths can be piped to this function. Supports pipeline input from Get-ChildItem, Get-Item, or any command that outputs path strings. .OUTPUTS None. This function creates backup files but does not return objects. Progress information is displayed during operation. .NOTES - Supports ShouldProcess for WhatIf and Confirm scenarios - Creates date-stamped subdirectories (yyyy-MM-dd format) - Generates unique backup filenames to prevent overwrites - Automatically resolves relative paths from current directory - Continues processing remaining paths if individual items fail - Uses cloud-storage-compatible deletion methods for cleanup - Displays progress bar for multiple source paths .EXAMPLE PS > New-DailyBackup -Path 'C:\Documents' -Destination 'D:\Backups' Creates a backup of Documents folder in D:\Backups\2025-08-24\ .EXAMPLE PS > New-DailyBackup -Path 'file1.txt', 'C:\Photos', 'D:\Projects' -Destination 'E:\DailyBackups' -Verbose Backs up multiple paths with detailed output .EXAMPLE PS > New-DailyBackup -Path 'C:\Data' -Destination 'D:\Backups' -Keep 7 Creates backup and keeps only the last 7 days of backups .EXAMPLE PS > New-DailyBackup -Path 'C:\ImportantFiles' -WhatIf Shows what would be backed up without actually creating archives .EXAMPLE PS > Get-ChildItem 'C:\Projects' -Directory | New-DailyBackup -Destination 'D:\ProjectBackups' Backs up all subdirectories from C:\Projects using pipeline input .EXAMPLE PS > New-DailyBackup -Path '.\src', '.\docs' -Destination '\\server\backups' -Keep 14 Backs up relative paths to network location with 2-week retention .LINK https://github.com/jonlabelle/pwsh-daily-backup #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter( Position = 0, Mandatory = $true, ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $true, HelpMessage = 'The source file or directory path(s) to backup.') ] [Alias('PSPath', 'FullName', 'SourcePath')] [string[]] $Path, [Parameter( Position = 1, HelpMessage = 'The root directory path where daily backups will be stored.') ] [Alias('DestinationPath', 'TargetPath')] [string] $Destination = '.', [Parameter( HelpMessage = 'The number of daily backups to keep when purging old backups.' )] [ValidateRange(0, [int]::MaxValue)] [Alias('DailyBackupsToKeep')] [int] $Keep = 0 ) begin { $verboseEnabled = $false if ($VerbosePreference -eq 'Continue') { $verboseEnabled = $true Write-Verbose 'New-DailyBackup:Begin> Verbose mode is enabled' -Verbose:$verboseEnabled } if ($PSCmdlet.ShouldProcess('New-DailyBackup', 'Begin')) { Write-Verbose 'New-DailyBackup:Begin> Dry-run is not enabled' -Verbose:$verboseEnabled } else { Write-Verbose 'New-DailyBackup:Begin> Dry-run is enabled' -Verbose:$verboseEnabled } $Destination = ResolveUnverifiedPath -Path $Destination $folderName = (Get-Date -Format $script:DefaultFolderDateFormat) $datedDestinationDir = (Join-Path -Path $Destination -ChildPath $folderName) if ((Test-Path -Path $datedDestinationDir -PathType Container)) { Write-Verbose ('New-DailyBackup:Begin> Removing existing backup destination directory: {0}' -f $datedDestinationDir) -Verbose:$verboseEnabled RemoveItemAlternative -LiteralPath $datedDestinationDir -WhatIf:$WhatIfPreference -Verbose:$verboseEnabled } Write-Verbose ('New-DailyBackup:Begin> Creating backup destination directory: {0}' -f $datedDestinationDir) -Verbose:$verboseEnabled New-Item -Path $datedDestinationDir -ItemType Directory -WhatIf:$WhatIfPreference -Verbose:$verboseEnabled -ErrorAction 'SilentlyContinue' | Out-Null } process { $totalPaths = $Path.Count $currentPath = 0 foreach ($item in $Path) { $currentPath++ Write-Progress -Activity 'Creating Daily Backup' -Status "Processing path $currentPath of $totalPaths" -PercentComplete (($currentPath / $totalPaths) * 100) if (-not [System.IO.Path]::IsPathRooted($item)) { Write-Verbose ('New-DailyBackup:Process> {0} is not a full path, prepending current directory: {1}' -f $item, $pwd) -Verbose:$verboseEnabled $item = (Join-Path -Path $pwd -ChildPath $item) } try { $resolvedPath = (Resolve-Path $item -ErrorAction SilentlyContinue -Verbose:$verboseEnabled).ProviderPath if ($null -eq $resolvedPath) { Write-Warning ('New-DailyBackup:Process> Failed to resolve path for: {0}' -f $item) continue } if ($resolvedPath.Count -gt 1) { foreach ($globItem in $resolvedPath) { Write-Verbose ('New-DailyBackup:Process> Processing glob item: {0}' -f $globItem) -Verbose:$verboseEnabled CompressBackup -Path $globItem -DestinationPath $datedDestinationDir -VerboseEnabled $verboseEnabled -WhatIf:$WhatIfPreference } } else { if (!(Test-Path -Path $resolvedPath -IsValid)) { Write-Warning ('New-DailyBackup:Process> Backup source path does not exist: {0}' -f $resolvedPath) } else { Write-Verbose ('New-DailyBackup:Process> Processing single item: {0}' -f $resolvedPath) -Verbose:$verboseEnabled CompressBackup -Path $resolvedPath -DestinationPath $datedDestinationDir -VerboseEnabled $verboseEnabled -WhatIf:$WhatIfPreference } } } catch { Write-Error ('New-DailyBackup:Process> Error processing path {0}: {1}' -f $item, $_.Exception.Message) -ErrorAction Continue } } Write-Progress -Activity 'Creating Daily Backup' -Completed } end { Write-Verbose 'New-DailyBackup:End> Running post backup operations' -Verbose:$verboseEnabled if ($Keep -gt 0) { RemoveDailyBackup -Path $Destination -BackupsToKeep $Keep -VerboseEnabled $verboseEnabled -WhatIf:$WhatIfPreference } Write-Verbose 'New-DailyBackup:End> Finished' -Verbose:$verboseEnabled } } Export-ModuleMember -Function New-DailyBackup |