Upgrade/Get-NAVCumulativeUpdateFile.ps1

<#
    .Synopsis
        Downloads a Cumulative Update from the Microsoft Download Center
    .DESCRIPTION
        Based on the Microsoft Dynamics NAV Team Blog, this function is going to download a cumulative update to a download folder.
        The function uses the download helper (Load-NAVCumulativeUpdateHelper)
    .PARAMETER CountryCode
        Optional, default is <blank> otherwise a NAV/BC supported country code
    .PARAMETER Version
        Required. A NAV/BC version. Acceptable values are 2013, 2013 R2, 2015, 2016, 2017, 2018, BC13, BC14, BC15.
        This uses pattern matching so there are no defaults, so future versions (e.g. BC17, BC20) are supported and can be used once the version is available and set up in the Versions.json (see SettingsFile)
    .PARAMETER CUNo
        Optional, default is <blank> otherwise a valid (cumulative) update number. If blank the latest update is downloaded
        If the update does not exist for the version specified then an warning is issued
    .PARAMETER Locale
        Optional, default is <blank> otherwise a valid NAV/BC supported locale
    .PARAMETER DownloadFolder
        Optional, default is $env:TEMP otherwise an existing folder where the downloaded file will be placed.
    .PARAMETER SettingsFile
        Optional, default is versions.json located in the same folder as the script.
            versions.json entries
            DefaultDownloadURL : the base url used for each update specific web page,
                "https://support.microsoft.com/help/"
            BuildRegex : the regular expression used to parse the build number from the update page title
                "Build (([\\d\\.]+))"
            ArticleIDRegex : the regular expression used to identify the script wherein the download link is located. The article number is appended to this,
                "\/"
            CountryCodeRegex : the regular expression used to parse the country code from the download file name from downloads link,
                "\\.([A-Z]{2}|W1).DVD|(?:\\.)?([A-Z]{2}|W1)\\.ZIP"
            CULinkRegex : the regular expression used to parse the links for the specific update pages from the main released updates page
                "<a.+?(?:data-content-id=\\\\\"(\\d+)\\\\\"|https?:\\\/\\\/support\\.microsoft\\.com\\\/(?:help|[a-z]{2}-[a-z]{2}\\\/)?help\\\/(\\d+)|\\\\\"\\\/[a-z]{2}-[a-z]{2}\\\/help\\\/(\\d+))+.+?(?:Cumulative )?Update (\\d\\d?\\.?\\d?) .+? "
            downloadLinkRegex : the regular expression used to parse the download link information from the specific update page
                "(https?\\:\\\/\\\/www\\.microsoft\\.com\\\/(?:[a-z]{2}-[a-z]{2}\\\/)?download\\\/details.aspx\\?(?:familyid=[\\da-zA-Z]{8}-(?:[\\da-zA-Z]{4}-){3}[\\da-zA-Z]{12}|id(?:%3d|=)?(\\d+)))(?:.+?)?(?:>)?.+?(?:Cumulative )?update(?:&nbsp;| )?(?:CU)?(?: )?(\\d\\d?\\.?\\d?)"
            versions : Array of version objects, one object is required for each version to be downloaded
            version object
                version: required, the version number, must match the Version parameter used
                url : reguired, the url to the main released updates page
                    for example
                        the release cumulative updates page for NAV2013 is "https://support.microsoft.com/en-us/help/2842257"
                        the Released Updates for page for BC16 is "https://support.microsoft.com/en-us/help/4549687"
                fileNamePrefix : required the prefix for the downloaded filename e.g. NAV for NAV.7.0.34587.DE.DVD.CU01.zip or BC for BC.16.0.12805.AT.DVD.CU16.1.ZIP
                CULinkRegex : optional, if not specified the root CULinkRegex will be used. If there is some unique string that does not allow the default regular expression to be used.
                downloadLinkRegex" : optional, if not specified the root downloadLinkRegex will be used. If there is some unique string that does not allow the default regular expression to be used.
                e.g.
                versions : [
                    {
                        "version": "2013 R2",
                        "url" : "https://support.microsoft.com/en-us/help/2914930",
                        "fileNamePrefix" : "NAV",
                        "CULinkRegex" : "",
                        "downloadLinkRegex" : ""
                    },
                    {
                        "version": "BC16",
                        "url": "https://support.microsoft.com/en-us/help/4549687",
                        "fileNamePrefix" : "BC",
                        "CULinkRegex" : "",
                        "downloadLinkRegex" : ""
                    }
                ],
            "Exceptions" : array of exeption objects. These are version CU combinations where there is a known problem.
                version : required, the version number in which the exception applies, must match the Version parameter used
                CU : required, the update number in which the exception applies.
                description : required, a description of what the exception is. This will be displayed as a warning.
                reference : optional, if present must be the url to a corrected download page
                productID : optional, if present must be the product id of the download version
                e.g.
                Exceptions : [
                    {
                        "version" : "2013 R2",
                        "CU" : 19,
                        "description" : "Not available from Download Center. It is a hotfix file request instead.",
                        "reference" : "",
                        "productID" : ""
                    },
                    {
                        "version" :"2017",
                        "CU" : 1,
                        "description" : "Not available from Download Center. It is a hotfix file request instead.",
                        "reference" : "",
                        "productID" : ""
                    }
                ]
            helpers : optional, array of websites that assisted with the creation of the required regular expressions and formatting them for JSON
                    Thought they might be useful to others.
                    {
                        "name" : "Free Formatter Free Online Tools For Developers",
                        "Description" : "Use this to escape/unescape strings for storage in JSON file",
                        "url" : "https://www.freeformatter.com/json-escape.html"
                    },
                    {
                        "name" : "RegEx Editor",
                        "Description" : "RegExr is an online tool to learn, build, & test Regular Expressions (RegEx / RegExp).",
                        "url" : "https://regexr.com/"
                    }
    .PARAMETER LogFile
        Optional, location and file name of where to put information from the web pages used to debug issues with regular expressions.
        Defaults to "Log\Version $Version Update $CUNo Country $CountryeCode Log.txt" (e.g. Log\Version 2015 Update 27 Country GB Log.txt) which is a sub-folder of the script folder
        If specified then the CreateLog parameter must also be set
    .PARAMETER ShowDownloadFolder
        Optional switch, Shows the download folder in File Explorer when the download is complete.
    .PARAMETER GetInfoOnly
        Optional switch, Do not download the file, only display the download information
    .PARAMETER SaveInfoJSON
        Optional switch, Saves the download information JSON file regardless of the GetInfoOnly parameter
    .PARAMETER PadEdition
        Optional switch, zero pads the Edition portion of the filename. Sorts better in File listing
        e.g. NAV.09.0.43402.CZ.DVD.CU01.zip vs. NAV.9.0.43402.CZ.DVD.CU01
    .PARAMETER CreateLog
        Optional switch, creates log file specified in LogFile,
        If specified then the LogFile parameter must also be set
    .EXAMPLE
        Get-NAVCumulativeUpdateFile -version 2018 -CountryCode BE
 
        This example gets the latest update for Country Code BE (Belgium) for NAV2018
    .EXAMPLE
        Get-NAVCumulativeUpdateFile -version BC15 -CUNo 7 -CountryCode FR -DownloadFolder 'C:\NAV DVDS\BC15'
 
        This example gets update 7 for Country Code GB (Great Britain) for Business Central 2019 Wave 2 (15) and puts it in the C:\NAV DVDS\BC15 folder
    .OUTPUT
        - Objects with info about the downloaded cumulative updates
        - optionally downloads update ZIP file
        - optionally a JSON file with details about the download
#>

function Get-NAVCumulativeUpdateFile {
    param (
        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName)]
        [String]$CountryCode,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName)]
        [ValidatePattern("^2013 R2$|^201[3,5-8]{1}$|^BC1[3-9]{1}$|^BC2[0-9]{1}$")]
        [String]$Version = '2018',

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName)]
        [String]$CUNo,

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName)]
        [String]$Locale,

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName)]
        [String]$DownloadFolder = $env:TEMP,        

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName)]
        [String]$SettingsFile = (Join-Path $PSScriptRoot 'Versions.json'),        

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName)]
        [String]$Logfile = (Join-Path $PSScriptRoot $("Log\Version $Version Update $CUNo Country $CountryeCode Log.txt")),

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName)]
        [Switch]$ShowDownloadFolder,

        [Parameter(Mandatory = $false)]        
        [Switch]$GetInfoOnly,

        [Parameter(Mandatory = $false)]        
        [Switch]$SaveInfoJSON,

        [Parameter(Mandatory = $false)]        
        [Switch]$padEdition,

        [Parameter(Mandatory = $false)]        
        [Switch]$CreateLog = $false
    )

    begin {
    }
    process {

        if (-not (Test-Path $SettingsFile)) {
            Write-Warning "Settings file cannot be found at $SettingsFile."
            return
        }

        if (Test-Path $Logfile) {
            Remove-Item -Path $Logfile -Force
        }

        $CUNo = $CUNo.PadLeft(2, "0")

        Write-Verbose ""            
        Write-Verbose "Processing parameters CountryCode: $CountryCode Version: $Version Update: $CUNo"
        
        Write-Verbose "Reading settings from $SettingsFile"
        $SettingsJSON = Get-Content -Raw -Path $SettingsFile | ConvertFrom-Json
        $VersionSettings = $SettingsJSON.Versions | Where-Object { $_.Version -eq $Version }
        if ($null -eq $VersionSettings) {
            Write-Warning "No version information found for Version $Version in $SettingsFile."
            return
        }

        $CUReleasesResponse = (Invoke-WebRequest -Uri $VersionSettings.url)
            
        $parts = ([system.uri]($VersionSettings.url)).AbsolutePath.Split('/')
        $articleId = $parts[$parts.Count - 1]
        $script = $CUReleasesResponse.Scripts | Where-object { $_.innerHtml -match '\/' + $articleId }

        switch($true) {
            ($VersionSettings.cuLinkRegex.Length -gt 0) {$regex = $VersionSettings.cuLinkRegex}
            ($SettingsJson.cuLinkRegex.Length -gt 0) {$regex = $SettingsJson.cuLinkRegex}
            default {
                Write-Error "CULinkRegex must be specified a default for $Version in $SettingsFile"    
            }
        }
        Write-Verbose "CU Regex: $regex"

        if($CreateLog) {
            ("$Version $CU - Update Links") | Set-Content -Path $Logfile
            $regex | Add-Content -Path $Logfile
            "" | Add-Content -Path $Logfile
            $script.innerText | Add-Content -Path $Logfile
        }

        $CULinkMatches = [regex]::Matches($script.innerText, $regex, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
                
        $updateLinks = [ordered]@{}
        foreach ($CULinkMatch in $CULinkMatches) {
            switch ($true) {
                ($CULinkMatch.Groups.count -eq 5) {
                    switch ($true) {
                        ($CULinkMatch.Groups[1].success) { $value = $CULinkMatch.groups[1].Value }
                        ($CULinkMatch.Groups[2].success) { $value = $CULinkMatch.groups[2].Value }
                        ($CULinkMatch.Groups[3].success) { $value = $CULinkMatch.groups[3].Value }
                    }
                    $value = $SettingsJSON.DefaultDownloadURL + $value
                    $key = $CULinkMatch.groups[4].Value.split('.') | Select-Object -Last 1
                }
                default {
                    Write-Error "Unexpected group count $($CULinkMatch.Groups.count)"
                    return
                }
            }
            Write-Verbose "Key: '$key' Value: '$value'"
            $updateLinks.Add($key.PadLeft(2, "0"), $value)
        }

        if ($CUNo -eq '00') {
            $updateLink = $updateLinks[0]
            $CUNo = $updateLinks.Keys | Select-Object -First 1
        }
        else {
            $updateLink = $updateLinks[$CUNo]
        }
        if (!$updateLink) {
            Write-Warning "Update Release not found for $Version update $CUNo. Check $($VersionSettings.url) for correct update no."
            return
        }
            
        Write-Verbose "Reading blog page $updateLink"
        $UpdateResponse = Invoke-WebRequest -Uri $updateLink

        Write-Verbose 'Searching KB Url'
        $articleId = ([system.uri]($updateLink)).AbsolutePath.Split('/') | Select-Object -Last 1
        $script = $UpdateResponse.Scripts | Where-object { $_.innerHtml -match $SettingsJSON.ArticleIDRegex + $articleId }

        $buildMatch = [regex]::Match($script.innerText, $SettingsJSON.BuildRegex, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        if ($buildMatch.Groups[1].Success) {
            $build = $buildMatch.groups[1].Value.split(".") | Select-Object -Last 1
        }
        else {
            Write-Warning "Build not found in $updateLink"
        }
        Write-Verbose "Build No.: $build"

        switch($true) {
            ($VersionSettings.downloadlinkRegex.Length -gt 0) {$regex = $VersionSettings.downloadlinkRegex}
            ($SettingsJson.downloadlinkRegex.Length -gt 0) {$regex = $SettingsJson.downloadlinkRegex}
            default {
                Write-Error "DownloadLinkRegex must be specified a default for $Version in $SettingsFile"    
            }
        }
        Write-Verbose "Download Link Regex: $regex"

        if($CreateLog) {
            "" | Add-Content -Path $Logfile
            ("$Version $CU - Download Link") | Add-Content -Path $Logfile
            $regex | Add-Content -Path $Logfile
            "" | Add-Content -Path $Logfile
            $script.innerText | Add-Content -Path $Logfile
        }

        $DownLoadLinkMatches = [regex]::Matches($script.innerText, $regex, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        try {
            $UpdateNo = $DownLoadLinkMatches.Groups[3].Value.Split(".") | Select-Object -Last 1
            $ProductID = $DownLoadLinkMatches.Groups[2].Value
            $kbLink = $DownLoadLinkMatches.Groups[1].Value   
        }
        catch {
            $versionException = $SettingsJSON.Exceptions | Where-Object { ($_.Version -eq $Version) -and ($_.CU -eq $CUNo) }
            if ($null -ne $versionException) {
                Write-Warning "Version $($versionException.version) Update $($versionException.CU) $($versionException.description)"
                if ($versionException.reference.Length -eq 0) {
                    return
                }
                else {
                    $UpdateNo = $versionException.CU.ToString().PadLeft(2,"0")
                    $ProductID = $versionException.Product.ToString()
                    $kbLink = $versionException.reference
                }
            }
            else {
                Write-Warning "DownloadRegex for $Version $CUNo returned no results. Refer to $updateLink to verify download available."
                return
            }
        }
        if (!($kbLink)) {
            Write-Warning "No link to Download Center found for $Version $CUNo. Refer to $updateLink to verify download available."
            return
        }

        if ($kbLink.Contains('familyid=')) {
            $dlPage = Invoke-WebRequest -Uri $kbLink
            $ProductID = $dlPage.BaseResponse.ResponseUri.Query.Substring(1).Split('=') | Select-Object -Last 1
        }
        Write-Verbose "Found Product ID $ProductID"

        Write-Verbose "Loading NAVCumulativeUpdateHelper"
        Load-NAVCumulativeUpdateHelper
        if ($Locale) {
            $DownloadLinks = [MicrosoftDownload.MicrosoftDownloadParser]::GetDownloadDetail($ProductID, $Locale) | Select-Object *
        }
        else {
            $DownloadLinks = [MicrosoftDownload.MicrosoftDownloadParser]::GetDownloadLocales($ProductID) | Select-Object *
        }
        if ($CountryCode) {
            $DownloadLink = $DownloadLinks | Where-Object Code -eq $CountryCode
            if ($null -eq $Downloadlink) {
                
                $DownloadLink = $DownloadLinks | Where-Object DownloadUrl -match $CountryCode+".zip"
                if (-not ($null -eq $DownloadLink)) {
                    $DownloadLink.Code = $CountryCode
                }
                else {
                    Write-Warning "Download link not found for Version: $Version Update: $CUNo Country Code: $CountryCode. Available Links:"
                    foreach ($Link in $DownloadLinks) {
                        Write-Warning " $Link.DownloadUrl"
                    }
                    return
                }
            }
            $DownloadLinks = $DownloadLink
        }

        foreach ($DownloadLink in $DownloadLinks) {
            Write-Verbose "Download Link: $($DownloadLink.DownloadUrl)"
            if ($CountryCode.Length -eq 0) {
                $CountryCodeMatches = [regex]::Matches($DownloadLink.DownloadUrl, $settingsJSON.CountryCodeRegex, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
                if ($CountryCodeMatches.Length -eq 0) {
                    Write-Error "Country Code not found in $($DownloadLink.DownloadUrl)"
                    return
                }
                $Value = ''
                switch ($true) {
                    ($CountryCodeMatches.groups[1].success) { $value = $CountryCodeMatches.groups[1].Value }
                    ($CountryCodeMatches.groups[2].success) { $value = $CountryCodeMatches.groups[2].Value }
                }
                $DownloadLink.Code = $value
            }

            switch ($Version) {
                '2013' { $Edition = '7.0' }
                '2013 R2' { $Edition = '7.1' }
                '2015' { $Edition = '8.0' }
                '2016' { $Edition = '9.0' }
                '2017' { $Edition = '10.0' }
                '2018' { $Edition = '11.0' }
                'BC13' { $Edition = '13.0' }
                'BC14' { $Edition = '14.0' }
                'BC15' { $Edition = '15.0' }
                'BC16' { $Edition = '16.0' }
                'BC17' { $Edition = '17.0' }
                Default { $Edition = ([regex]::Match($Version,'[0-9]{1,2}')).Value + ".0" }
            }
            if ($padEdition) {
                $Edition = $Edition.PadLeft(4, "0")
            }                

            $filename = (Join-Path -Path $DownloadFolder -ChildPath ("$($VersionSettings.fileNamePrefix).$($Edition).$($Build).$($DownloadLink.Code).DVD.CU$($UpdateNo.PadLeft(2,"0"))$([io.path]::GetExtension($DownloadLink.DownloadUrl))"))
            $filenameJSON = ([io.path]::ChangeExtension($filename, 'json'))
                    
            if (!($GetInfoOnly)) {
                if (-not(Test-Path $filenameJSON)) {
                    Write-Verbose "Downloading $($DownloadLink.Code) to $filename" 
                    Write-Information -Message "Downloading Version: $Version Update: $CUNo CountryCode: $CountryCode to $filename."
                    $null = Start-BitsTransfer -Source $DownloadLink.DownloadUrl -Destination $filename 
                    Write-Verbose 'Update downloaded' 
                }
                else {
                    write-warning "File $([io.path]::GetFileName($filenameJSON)) already exists. Nothing downloaded!"
                }                                           
            }

            if (Test-Path $filename) {
                $null = Unblock-File -Path $filename
            }

            $result = New-Object -TypeName System.Object
            $null = $result | Add-Member -MemberType NoteProperty -Name NAVVersion -Value $Version
            $null = $result | Add-Member -MemberType NoteProperty -Name CountryCode -Value $DownloadLink.Code
            $null = $result | Add-Member -MemberType NoteProperty -Name CUNo -Value "$UpdateNo"
            $null = $result | Add-Member -MemberType NoteProperty -Name Build -Value $Build
            $null = $result | Add-Member -MemberType NoteProperty -Name KBUrl -Value "$kbLink"
            $null = $result | Add-Member -MemberType NoteProperty -Name ProductID -Value "$ProductID"
            $null = $result | Add-Member -MemberType NoteProperty -Name DownloadURL -Value $DownloadLink.DownloadUrl
            $null = $result | Add-Member -MemberType NoteProperty -Name filename -Value "$filename"
            
            if (!$GetInfoOnly -or $SaveInfoJSON) {
                $result | ConvertTo-Json | Set-Content -Path $filenameJSON
            }

            Write-Output -InputObject $result
        }                        
    }
    end {
        if ($ShowDownloadFolder) {
            Start-Process $DownloadFolder 
        }
    }
}