GoogleFonts.psm1

[CmdletBinding()]
param()
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath)
$script:PSModuleInfo = Import-PowerShellDataFile -Path "$PSScriptRoot\$baseName.psd1"
$script:PSModuleInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Debug $_ }
$scriptName = $script:PSModuleInfo.Name
Write-Debug "[$scriptName] - Importing module"

#region [functions] - [public]
Write-Debug "[$scriptName] - [functions] - [public] - Processing folder"
#region [functions] - [public] - [completers]
Write-Debug "[$scriptName] - [functions] - [public] - [completers] - Importing"
Register-ArgumentCompleter -CommandName Get-GoogleFont, Install-GoogleFont -ParameterName Name -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
    $null = $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter
    Get-GoogleFont -Verbose:$false | Select-Object -ExpandProperty Name | Sort-Object -Unique | Where-Object { $_ -like "$wordToComplete*" } |
        ForEach-Object { [System.Management.Automation.CompletionResult]::new("'$_'", $_, 'ParameterValue', $_) }
}
Write-Debug "[$scriptName] - [functions] - [public] - [completers] - Done"
#endregion [functions] - [public] - [completers]
#region [functions] - [public] - [Get-GoogleFont]
Write-Debug "[$scriptName] - [functions] - [public] - [Get-GoogleFont] - Importing"
function Get-GoogleFont {
    <#
        .SYNOPSIS
        Get GoogleFonts list

        .DESCRIPTION
        Get GoogleFonts list, filtered by name, from the latest release.

        .EXAMPLE
        Get-GoogleFonts

        Get all the GoogleFonts.

        .EXAMPLE
        Get-GoogleFonts -Name 'Roboto'

        Get the GoogleFont with the name 'Roboto'.

        .EXAMPLE
        Get-GoogleFonts -Name 'Noto*'

        Get the GoogleFont with the name starting with 'Noto'.

        .LINK
        https://psmodule.io/GoogleFonts/Functions/Get-GoogleFont

        .NOTES
        More information about the GoogleFonts can be found at:
        [GoogleFonts](https://fonts.google.com/) | [GitHub](https://github.com/google/fonts)
    #>

    [Alias('Get-GoogleFonts')]
    [OutputType([System.Object[]])]
    [CmdletBinding()]
    param (
        # Name of the GoogleFont to get
        [Parameter()]
        [SupportsWildcards()]
        [string] $Name = '*'
    )

    Write-Verbose 'Selecting assets by:'
    Write-Verbose "Name: [$Name]"
    $script:GoogleFonts | Where-Object { $_.Name -like $Name }
}
Write-Debug "[$scriptName] - [functions] - [public] - [Get-GoogleFont] - Done"
#endregion [functions] - [public] - [Get-GoogleFont]
#region [functions] - [public] - [Install-GoogleFont]
Write-Debug "[$scriptName] - [functions] - [public] - [Install-GoogleFont] - Importing"
#Requires -Modules @{ ModuleName = 'Fonts'; RequiredVersion = '1.1.21' }
#Requires -Modules @{ ModuleName = 'Admin'; RequiredVersion = '1.1.6' }

function Install-GoogleFont {
    <#
        .SYNOPSIS
        Installs Google Fonts to the system.

        .DESCRIPTION
        Installs Google Fonts to the system.

        .EXAMPLE
        Install-GoogleFont -Name 'Roboto'

        Installs the font 'Roboto' to the current user.

        .EXAMPLE
        Install-GoogleFont -Name Zen*

        Installs all fonts that match the pattern 'Zen*' to the current user.

        .EXAMPLE
        Install-GoogleFont -Name 'Roboto' -Scope AllUsers

        Installs the font 'Roboto' to all users. This requires to be run as administrator.

        .EXAMPLE
        Install-GoogleFont -All

        Installs all Google Fonts to the current user.

        .LINK
        https://psmodule.io/GoogleFonts/Functions/Install-GoogleFont

        .NOTES
        More information about the GoogleFonts can be found at:
        [GoogleFonts](https://fonts.google.com/) | [GitHub](https://github.com/google/fonts)
    #>

    [CmdletBinding(
        DefaultParameterSetName = 'ByName',
        SupportsShouldProcess
    )]
    [Alias('Install-GoogleFonts')]
    param(
        # Specify the name of the GoogleFont(s) to install.
        [Parameter(
            ParameterSetName = 'ByName',
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [SupportsWildcards()]
        [string[]] $Name,

        # Specify to install all GoogleFont(s).
        [Parameter(
            ParameterSetName = 'All',
            Mandatory
        )]
        [switch] $All,

        # Specify the scope of where to install the font(s).
        [Parameter()]
        [ValidateSet('CurrentUser', 'AllUsers')]
        [string] $Scope = 'CurrentUser',

        # Force will overwrite existing fonts
        [Parameter()]
        [switch] $Force
    )

    begin {
        $previousProgressPreference = $ProgressPreference
        if ($Scope -eq 'AllUsers' -and -not (Test-Admin)) {
            $errorMessage = @'
Administrator rights are required to install fonts.
Please run the command again with elevated rights (Run as Administrator) or provide '-Scope CurrentUser' to your command.
'@

            throw $errorMessage
        }
        $googleFontsToInstall = [System.Collections.Generic.List[object]]::new()
        $seenUrls = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

        $guid = (New-Guid).Guid
        $tempPath = Join-Path -Path $HOME -ChildPath "GoogleFonts-$guid"
    }

    process {
        if ($All) {
            foreach ($googleFont in $script:GoogleFonts) {
                if ($seenUrls.Add($googleFont.URL)) {
                    $googleFontsToInstall.Add($googleFont)
                }
            }
            return
        }
        foreach ($fontName in $Name) {
            foreach ($googleFont in $script:GoogleFonts) {
                if ($googleFont.Name -like $fontName -and $seenUrls.Add($googleFont.URL)) {
                    $googleFontsToInstall.Add($googleFont)
                }
            }
        }
    }

    end {
        Write-Verbose "[$Scope] - Requested [$($googleFontsToInstall.Count)] fonts"

        if (-not $Force) {
            $installedNames = [string[]](Get-Font -Scope $Scope -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name)
            $installedFamilies = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($n in $installedNames) {
                if ($n) { [void]$installedFamilies.Add($n) }
            }
            $knownFamilies = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($gf in $script:GoogleFonts) {
                [void]$knownFamilies.Add($gf.Name)
            }
            $toProcess = [System.Collections.Generic.List[object]]::new()
            foreach ($googleFont in $googleFontsToInstall) {
                $fontName = $googleFont.Name
                $skip = $false
                if ($installedFamilies.Contains($fontName)) {
                    $skip = $true
                } else {
                    $prefix = "$fontName "
                    foreach ($family in $installedFamilies) {
                        if ($family.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase) -and
                            -not $knownFamilies.Contains($family)) {
                            $skip = $true; break
                        }
                    }
                }
                if ($skip) {
                    Write-Verbose "[$fontName] - Already installed, skipping"
                    continue
                }
                $toProcess.Add($googleFont)
            }
            $googleFontsToInstall = $toProcess
        }

        Write-Verbose "[$Scope] - Installing [$($googleFontsToInstall.Count)] fonts"

        $isWin = ($PSVersionTable.PSVersion.Major -lt 6) -or $IsWindows
        $isMac = ($PSVersionTable.PSVersion.Major -ge 6) -and $IsMacOS
        if ($isWin) {
            $cacheRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'PSModule/GoogleFonts/cache'
        } elseif ($isMac) {
            $cacheRoot = Join-Path $HOME 'Library/Caches/PSModule/GoogleFonts'
        } else {
            $linuxCacheBase = if ([string]::IsNullOrWhiteSpace($env:XDG_CACHE_HOME)) {
                Join-Path $HOME '.cache'
            } else {
                $env:XDG_CACHE_HOME
            }
            $cacheRoot = Join-Path $linuxCacheBase 'PSModule/GoogleFonts'
        }
        Write-Verbose "[$Scope] - Cache root: [$cacheRoot]"

        $throttle = [Environment]::ProcessorCount
        $maxRetryCount = 5
        $retryDelaySeconds = 5
        $downloadFailures = [System.Collections.Generic.List[string]]::new()

        $pending = [System.Collections.Generic.List[object]]::new()
        foreach ($googleFont in $googleFontsToInstall) {
            $URL = $googleFont.URL
            $fontName = $googleFont.Name
            if (-not $PSCmdlet.ShouldProcess("[$fontName] to [$Scope]", 'Install font')) {
                continue
            }
            $fontVariant = $googleFont.Variant
            $fileExtension = $URL.Split('.')[-1]
            $downloadFileName = "$fontName-$fontVariant.$fileExtension"
            $downloadPath = Join-Path -Path $tempPath -ChildPath $downloadFileName
            $sha256 = [System.Security.Cryptography.SHA256]::Create()
            try {
                $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($URL))
            } finally {
                $sha256.Dispose()
            }
            $urlHash = ([System.BitConverter]::ToString($hashBytes)).Replace('-', '').ToLowerInvariant().Substring(0, 16)
            $safeDownloadFileName = ($downloadFileName -replace '[^a-zA-Z0-9._-]', '_')
            $cachePath = Join-Path -Path $cacheRoot -ChildPath "$urlHash-$safeDownloadFileName"

            $pending.Add([pscustomobject]@{
                    Name         = $fontName
                    URL          = $URL
                    DownloadPath = $downloadPath
                    CachePath    = $cachePath
                    FromCache    = (-not $Force) -and (Test-Path -LiteralPath $cachePath)
                })
        }

        if ($pending.Count -eq 0) {
            return
        }

        if (-not (Test-Path -Path $tempPath -PathType Container)) {
            Write-Verbose "Create folder [$tempPath]"
            $null = New-Item -Path $tempPath -ItemType Directory
        }
        if (-not (Test-Path -Path $cacheRoot -PathType Container)) {
            $null = New-Item -Path $cacheRoot -ItemType Directory -Force
        }

        foreach ($item in $pending) {
            if ($item.FromCache) {
                try {
                    Write-Verbose "[$($item.Name)] - Cache hit, copying from [$($item.CachePath)]"
                    Copy-Item -LiteralPath $item.CachePath -Destination $item.DownloadPath -Force -ErrorAction Stop
                } catch {
                    Write-Verbose "[$($item.Name)] - Cache copy failed, will download instead: $($_.Exception.Message)"
                    $item.FromCache = $false
                }
            }
        }

        $toDownload = @($pending | Where-Object { -not $_.FromCache })
        if ($toDownload.Count -gt 0) {
            foreach ($item in $toDownload) {
                Write-Verbose "[$($item.Name)] - Cache miss, downloading from [$($item.URL)]"
            }
            $disableParallelDownloads = (
                $script:DisableParallelDownloadsForTests -eq $true -or
                $env:PSMODULE_GOOGLEFONTS_DISABLE_PARALLEL -eq '1'
            )
            $useParallelDownloads = (
                $PSVersionTable.PSVersion.Major -ge 7 -and
                -not $disableParallelDownloads
            )

            if ($useParallelDownloads) {
                $downloadResults = @(
                    $toDownload | ForEach-Object -Parallel {
                        $item = $_
                        $downloadSucceeded = $false
                        $lastError = $null

                        for ($attempt = 1; $attempt -le $using:maxRetryCount -and -not $downloadSucceeded; $attempt++) {
                            try {
                                $currentProgressPreference = $ProgressPreference
                                $ProgressPreference = 'SilentlyContinue'
                                try {
                                    Invoke-WebRequest -Uri $item.URL -OutFile $item.DownloadPath -ErrorAction Stop
                                } finally {
                                    $ProgressPreference = $currentProgressPreference
                                }

                                try {
                                    Copy-Item -LiteralPath $item.DownloadPath -Destination $item.CachePath -Force -ErrorAction Stop
                                } catch {
                                    Write-Verbose "[$($item.Name)] - Cache write failed: $($_.Exception.Message)"
                                }

                                $downloadSucceeded = $true
                            } catch {
                                $lastError = $_.Exception.Message
                                if ($attempt -lt $using:maxRetryCount) {
                                    Start-Sleep -Seconds $using:retryDelaySeconds
                                }
                            }
                        }

                        [pscustomobject]@{
                            Name    = $item.Name
                            URL     = $item.URL
                            Success = $downloadSucceeded
                            Error   = $lastError
                        }
                    } -ThrottleLimit $throttle
                )
            } else {
                $downloadResults = foreach ($item in $toDownload) {
                    $downloadSucceeded = $false
                    $lastError = $null

                    for ($attempt = 1; $attempt -le $maxRetryCount -and -not $downloadSucceeded; $attempt++) {
                        try {
                            $currentProgressPreference = $ProgressPreference
                            $ProgressPreference = 'SilentlyContinue'
                            try {
                                Invoke-WebRequest -Uri $item.URL -OutFile $item.DownloadPath -ErrorAction Stop
                            } finally {
                                $ProgressPreference = $currentProgressPreference
                            }

                            try {
                                Copy-Item -LiteralPath $item.DownloadPath -Destination $item.CachePath -Force -ErrorAction Stop
                            } catch {
                                Write-Verbose "[$($item.Name)] - Cache write failed: $($_.Exception.Message)"
                            }

                            $downloadSucceeded = $true
                        } catch {
                            $lastError = $_.Exception.Message
                            if ($attempt -lt $maxRetryCount) {
                                Start-Sleep -Seconds $retryDelaySeconds
                            }
                        }
                    }

                    [pscustomobject]@{
                        Name    = $item.Name
                        URL     = $item.URL
                        Success = $downloadSucceeded
                        Error   = $lastError
                    }
                }
            }

            foreach ($result in $downloadResults) {
                if (-not $result.Success) {
                    $downloadFailures.Add("$($result.Name): $($result.Error)")
                    Write-Warning "[$($result.Name)] - Download failed after $maxRetryCount attempts: $($result.Error)"
                }
            }
        }

        foreach ($item in $pending) {
            if (-not (Test-Path -LiteralPath $item.DownloadPath)) { continue }
            Write-Verbose "[$($item.Name)] - Install to [$Scope]"
            Install-Font -Path $item.DownloadPath -Scope $Scope -Force:$Force
            Remove-Item -Path $item.DownloadPath -Force -ErrorAction SilentlyContinue
        }

        if ($downloadFailures.Count -gt 0) {
            $failureSummary = $downloadFailures -join '; '
            throw "One or more font downloads failed: $failureSummary"
        }
    }

    clean {
        try {
            if ($tempPath -and (Test-Path -Path $tempPath -PathType Container)) {
                Write-Verbose "Remove folder [$tempPath]"
                Remove-Item -Path $tempPath -Force -Recurse -ErrorAction SilentlyContinue
            }
        } finally {
            $ProgressPreference = $previousProgressPreference
        }
    }
}
Write-Debug "[$scriptName] - [functions] - [public] - [Install-GoogleFont] - Done"
#endregion [functions] - [public] - [Install-GoogleFont]
Write-Debug "[$scriptName] - [functions] - [public] - Done"
#endregion [functions] - [public]
#region [variables] - [private]
Write-Debug "[$scriptName] - [variables] - [private] - Processing folder"
#region [variables] - [private] - [GoogleFonts]
Write-Debug "[$scriptName] - [variables] - [private] - [GoogleFonts] - Importing"
$script:GoogleFonts = Get-Content -Path (Join-Path -Path $PSScriptRoot -ChildPath 'FontsData.json') | ConvertFrom-Json
Write-Debug "[$scriptName] - [variables] - [private] - [GoogleFonts] - Done"
#endregion [variables] - [private] - [GoogleFonts]
Write-Debug "[$scriptName] - [variables] - [private] - Done"
#endregion [variables] - [private]

#region Member exporter
$exports = @{
    Alias    = '*'
    Cmdlet   = ''
    Function = @(
        'Get-GoogleFont'
        'Install-GoogleFont'
    )
    Variable = ''
}
Export-ModuleMember @exports
#endregion Member exporter