DailyBackup.psm1

$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.

    .DESCRIPTION
        Generates a random file name without the file extension.

    .OUTPUTS
        [String]
    #>

    $randomFileName = [System.IO.Path]::GetRandomFileName()
    return $randomFileName.Substring(0, $randomFileName.IndexOf('.'))
}

function GenerateBackupPath
{
    <#
    .SYNOPSIS
        Generates a backup file name.

    .DESCRIPTION
        Generates a backup file name by replacing directory seperator
        characters and spaces with underscores.

    .PARAMETER Path
        The source path for the backup.

    .PARAMETER DestinationPath
        The destination path of the compressed file.

    .PARAMETER VerboseEnabled
        Whether or not invoke commands with the -Verbose parameter.

    .OUTPUTS
        [String]
    #>

    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 seperators 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.

    .DESCRIPTION
        Creates a compressed archive, or zipped file, from specified files
        and or directories.

    .PARAMETER Path
        The path of the file or directory to compress.

    .PARAMETER DestinationPath
        The destination path of the compressed file.

    .PARAMETER DryRun
        Whether or not to perform the Compress-Archive operation.
        Internally sets the value of the -WhatIf parameter when running the Compress-Archive cmdlet.

    .PARAMETER VerboseEnabled
        Whether or not invoke commands with the -Verbose parameter.
    #>

    param
    (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $DestinationPath,

        [Parameter(Mandatory = $false)]
        [bool] $DryRun = $false,

        [Parameter(Mandatory = $false)]
        [bool] $VerboseEnabled = $false
    )

    $backupPath = GenerateBackupPath -Path $Path -DestinationPath $DestinationPath

    if ($DryRun)
    {
        Write-Verbose ('New-DailyBackup:CompressBackup> Dry-run only, backup ''{0}'' will not be created' -f "$backupPath.zip")
    }
    else
    {
        Write-Verbose ('New-DailyBackup:CompressBackup> Compressing backup ''{0}''' -f "$backupPath.zip")
        Compress-Archive -LiteralPath $Path -DestinationPath "$backupPath.zip" -WhatIf:$DryRun -Verbose:$VerboseEnabled -ErrorAction Continue
    }
}

function ResolveUnverifiedPath
{
    <#
    .SYNOPSIS
        A wrapper around Resolve-Path that works for paths that exist as well
        as for paths that don't (Resolve-Path normally throws an exception if
        the path doesn't exist.)

    .DESCRIPTION
        A wrapper around Resolve-Path that works for paths that exist as well
        as for paths that don't (Resolve-Path normally throws an exception if
        the path doesn't exist.)

        The Git repo for this module can be found here:
        https://aka.ms/PowerShellForGitHub

    .EXAMPLE
        ResolveUnverifiedPath -Path 'c:\windows\notepad.exe'

        Returns the string 'c:\windows\notepad.exe'.

    .EXAMPLE
        ResolveUnverifiedPath -Path '..\notepad.exe'

        Returns the string 'c:\windows\notepad.exe', assuming that it's
        executed from within 'c:\windows\system32' or some other sub-directory.

    .EXAMPLE
        ResolveUnverifiedPath -Path '..\foo.exe'

        Returns the string 'c:\windows\foo.exe', assuming that it's executed
        from within 'c:\windows\system32' or some other sub-directory, evenÎ
        though this file doesn't exist.

    .OUTPUTS
        [String]. The fully resolved path
    #>

    [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 all files and folders within given path.

    .DESCRIPTION
        Removes all files and folders within given path.
        A workaround for the access denied issue when attempting to Remove-Item(s) from an Apple iCloud or OneDrive path.

    .PARAMETER LiteralPath
        Path to location.
        The value of LiteralPath is used exactly as it's typed.
        No characters are interpreted as wildcards.
        If the path includes escape characters, enclose it in single quotation marks.
        Single quotation marks tell PowerShell not to interpret any characters as escape sequences.

    .PARAMETER SkipTopLevelFolder
        If present, the top-level folder will not be deleted.

    .EXAMPLE
        RemoveItemAlternative -LiteralPath "C:\Support\GitHub\GpoZaurr\Docs"

    .NOTES
        https://evotec.xyz/remove-item-access-to-the-cloud-file-is-denied-while-deleting-files-from-onedrive/
        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 '$Path' doesn't exist. Skipping."
    }
}

function RemoveDailyBackup
{
    <#
    .SYNOPSIS
        Delete daily backups.

    .DESCRIPTION
        Delete daily backups with an option to keep minimum number of previous
        backups, deleting the oldest backups first.

    .PARAMETER Path
        The root path where backups are stored.

    .PARAMETER BackupsToKeep
        The minimum number of backups to keep before deleting.

    .PARAMETER DryRun
        Whether or not to perform the actual delete operation.
        Internally sets the value of the -WhatIf parameter when running the Remove-Item cmdlet.

    .PARAMETER VerboseEnabled
        Whether or not invoke commands with the -Verbose parameter.
    #>

    param
    (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [int] $BackupsToKeep,

        [Parameter(Mandatory = $false)]
        [bool] $DryRun = $false,

        [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]
            RemoveItemAlternative -LiteralPath $backupPath -WhatIf:$dryRun -Verbose:$verboseEnabled
        }
    }
    else
    {
        Write-Verbose 'New-DailyBackup:RemoveDailyBackup> No surplus daily backups to delete' -Verbose:$VerboseEnabled
    }
}

function New-DailyBackup
{
    <#
    .SYNOPSIS
        Perform a daily backup.

    .DESCRIPTION
        Create a new daily backup storing the compressed (.zip) contents in
        a destination folder formatted by day ('yyyy-MM-dd').

    .PARAMETER Path
        The source file or directory path(s) to backup.

    .PARAMETER Destination
        The root directory path where daily backups will be stored.
        The default destination is the current working directory.

    .PARAMETER DailyBackupsToKeep
        The number of daily backups to keep when purging old backups.
        The oldest backups will be deleted first.
        This value cannot be less than zero.
        The default value is 0, which will not remove any backups.

    .EXAMPLE
        To import the DailyBackup module in your session:

        Import-Module DailyBackup

    .EXAMPLE
        To create a new daily backup from a list of paths:

        New-DailyBackup -Path 'source/path/1', 'source/path/2' -Destination 'root/destination/directory' -Verbose

    .EXAMPLE
        To perform a dry-run/what-if of the daily backup operations:

        New-DailyBackup -Path source/path -Destination destination/path -WhatIf

    .EXAMPLE
        To delete old backups, keeping only the last 3 folder/dates of backup:

        New-DailyBackup -Path source/path -Destination destination/path -Keep 3

    .EXAMPLE
        To backup files to the current working directory:

        New-DailyBackup -Path source/path

    .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.'
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('Keep')]
        [int] $DailyBackupsToKeep = 0
    )
    begin
    {
        $verboseEnabled = $false
        if ($VerbosePreference -eq 'Continue')
        {
            $verboseEnabled = $true
            Write-Verbose 'New-DailyBackup:Begin> Verbose mode is enabled' -Verbose:$verboseEnabled
        }

        $dryRun = $true
        if ($PSCmdlet.ShouldProcess('New-DailyBackup', 'Begin'))
        {
            Write-Verbose 'New-DailyBackup:Begin> Dry-run is not enabled' -Verbose:$verboseEnabled
            $dryRun = $false
        }
        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:$dryRun -Verbose:$verboseEnabled
        }

        Write-Verbose ('New-DailyBackup:Begin> Creating backup destination directory: {0}' -f $datedDestinationDir) -Verbose:$verboseEnabled
        New-Item -Path $datedDestinationDir -ItemType Directory -WhatIf:$dryRun -Verbose:$verboseEnabled -ErrorAction 'SilentlyContinue' | Out-Null
    }
    process
    {
        foreach ($item in $Path)
        {
            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)
            }

            $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)
                {
                    CompressBackup -Path $globItem -DestinationPath $datedDestinationDir -DryRun $dryRun -VerboseEnabled $verboseEnabled
                }
            }
            else
            {
                if (!(Test-Path -Path $resolvedPath -IsValid))
                {
                    Write-Warning ('New-DailyBackup:Process> Backup source path does not exist: {0}' -f $resolvedPath)
                }
                else
                {
                    CompressBackup -Path $resolvedPath -DestinationPath $datedDestinationDir -DryRun $dryRun -VerboseEnabled $verboseEnabled
                }
            }
        }
    }
    end
    {
        Write-Verbose 'New-DailyBackup:End> Running post backup operations' -Verbose:$verboseEnabled

        if ($DailyBackupsToKeep -gt 0)
        {
            RemoveDailyBackup -Path $Destination -BackupsToKeep $DailyBackupsToKeep -DryRun $dryRun -VerboseEnabled $verboseEnabled
        }

        Write-Verbose 'New-DailyBackup:End> Finished' -Verbose:$verboseEnabled
    }
}

Export-ModuleMember -Function New-DailyBackup