tiPS.psm1

#Requires -Version 3.0
Set-StrictMode -Version Latest

# All module functions that reference a file path in the module should use this variable, rather than PSScriptRoot.
New-Variable -Name 'PSModuleRoot' -Value $PSScriptRoot -Option Constant -Scope Script

if (-not ('tiPS.PowerShellTip' -as [type]))
{
    [string] $assemblyFilePath = Resolve-Path -Path "$script:PSModuleRoot/Classes/tiPSClasses.dll"
    Add-Type -Path $assemblyFilePath
}

function StartModuleUpdateIfNeeded
{
    [CmdletBinding()]
    [OutputType([void])]
    Param
    (
        [Parameter(Mandatory = $true, HelpMessage = 'The tiPS configuration to use to determine if the module should be updated.')]
        [ValidateNotNullOrEmpty()]
        [tiPS.Configuration] $Config
    )

    # For performance reasons, check if we should never update the module before doing anything else.
    if ($Config.AutoUpdateCadence -eq [tiPS.ModuleAutoUpdateCadence]::Never)
    {
        return
    }

    [DateTime] $modulesLastUpdateDate = ReadModulesLastUpdateDateOrDefault
    [TimeSpan] $timeSinceLastUpdate = [DateTime]::Now - $modulesLastUpdateDate
    [int] $daysSinceLastUpdate = $timeSinceLastUpdate.Days

    [bool] $moduleUpdateNeeded = $false
    switch ($Config.AutoUpdateCadence)
    {
        ([tiPS.ModuleAutoUpdateCadence]::Never) { $moduleUpdateNeeded = $false; break }
        ([tiPS.ModuleAutoUpdateCadence]::Daily) { $moduleUpdateNeeded = $daysSinceLastUpdate -ge 1; break }
        ([tiPS.ModuleAutoUpdateCadence]::Weekly) { $moduleUpdateNeeded = $daysSinceLastUpdate -ge 7 ; break }
        ([tiPS.ModuleAutoUpdateCadence]::BiWeekly) { $moduleUpdateNeeded = $daysSinceLastUpdate -ge 14; break }
        ([tiPS.ModuleAutoUpdateCadence]::Monthly) { $moduleUpdateNeeded = $daysSinceLastUpdate -ge 30; break }
    }

    if ($moduleUpdateNeeded)
    {
        UpdateModule
    }
    else
    {
        Write-Debug "An auto-update of the tiPS module is not needed at this time."
    }
}

function UpdateModule
{
    [CmdletBinding()]
    [OutputType([void])]
    Param()

    Write-Verbose "Updating the tiPS module in a background job."
    Start-Job -ScriptBlock {
        Write-Verbose "Updating the tiPS module."
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        Update-Module -Name 'tiPS' -Force

        Write-Verbose "Removing all but the latest version of the tiPS module to keep the modules directory clean."
        $latestModuleVersion = Get-InstalledModule -Name 'tiPS'
        $allModuleVersions = Get-InstalledModule -Name 'tiPS' -AllVersions
        $allModuleVersions |
            Where-Object { $_.Version -ne $latestModuleVersion.Version } |
            Uninstall-Module -Force
    }

    [DateTime] $todayWithoutTime = [DateTime]::Now.Date # Exclude the time for a better user experience.
    WriteModulesLastUpdateDate -ModulesLastUpdateDate $todayWithoutTime
}

function ReadModulesLastUpdateDateOrDefault
{
    [CmdletBinding()]
    [OutputType([DateTime])]
    Param()

    [DateTime] $modulesLastUpdateDate = [DateTime]::MinValue
    [string] $moduleUpdateDateFilePath = GetModulesLastUpdateDateFilePath
    if (Test-Path -Path $moduleUpdateDateFilePath -PathType Leaf)
    {
        [string] $modulesLastUpdateDateString = [System.IO.File]::ReadAllText($moduleUpdateDateFilePath)
        $modulesLastUpdateDate = [DateTime]::Parse($modulesLastUpdateDateString)
    }
    return $modulesLastUpdateDate
}

function WriteModulesLastUpdateDate
{
    [CmdletBinding()]
    [OutputType([void])]
    Param
    (
        [DateTime] $ModulesLastUpdateDate
    )

    [string] $moduleUpdateDateFilePath = GetModulesLastUpdateDateFilePath
    Write-Verbose "Writing modules last update date '$ModulesLastUpdateDate' to '$moduleUpdateDateFilePath'."
    [System.IO.File]::WriteAllText($moduleUpdateDateFilePath, $ModulesLastUpdateDate.ToString('o'))
}

function GetModulesLastUpdateDateFilePath
{
    [CmdletBinding()]
    [OutputType([string])]
    Param()

    [string] $appDataDirectoryPath = Get-TiPSDataDirectoryPath
    [string] $moduleUpdateDateFilePath = Join-Path -Path $appDataDirectoryPath -ChildPath 'ModulesLastUpdateDate.txt'
    return $moduleUpdateDateFilePath
}

function WriteAutomaticPowerShellTipIfNeeded
{
    [CmdletBinding()]
    [OutputType([void])]
    Param
    (
        [Parameter(Mandatory = $true, HelpMessage = 'The tiPS configuration used to determine if a tip should be written.')]
        [ValidateNotNullOrEmpty()]
        [tiPS.Configuration] $Config
    )

    # For performance reasons, check if we should never write a tip before doing anything else.
    if ($Config.AutoWritePowerShellTipCadence -eq [tiPS.WritePowerShellTipCadence]::Never)
    {
        return
    }

    [DateTime] $lastAutomaticTipWrittenDate = ReadLastAutomaticTipWrittenDateOrDefault
    [TimeSpan] $timeSinceLastAutomaticTipWritten = [DateTime]::Now - $lastAutomaticTipWrittenDate
    [int] $daysSinceLastAutomaticTipWritten = $timeSinceLastAutomaticTipWritten.Days

    [bool] $shouldShowTip = $false
    switch ($Config.AutoWritePowerShellTipCadence)
    {
        ([tiPS.WritePowerShellTipCadence]::Never) { $shouldShowTip = $false; break }
        ([tiPS.WritePowerShellTipCadence]::EverySession) { $shouldShowTip = $true; break }
        ([tiPS.WritePowerShellTipCadence]::Daily) { $shouldShowTip = $daysSinceLastAutomaticTipWritten -ge 1; break }
        ([tiPS.WritePowerShellTipCadence]::Weekly) { $shouldShowTip = $daysSinceLastAutomaticTipWritten -ge 7; break }
    }

    if ($shouldShowTip)
    {
        [bool] $isSessionInteractive = TestPowerShellSessionIsInteractive
        if (-not $isSessionInteractive)
        {
            Write-Verbose "tiPS is configured to write an automatic tip now, but this session is non-interactive. tiPS will only write automatic tips when it is imported into an interactive PowerShell session. This prevents a tip from being written at unexpected times, such as when the user or an automated process runs PowerShell scripts."
            return
        }

        WriteAutomaticPowerShellTip
    }
    else
    {
        Write-Debug "Showing a tiPS PowerShell tip is not needed at this time."
    }
}

function WriteAutomaticPowerShellTip
{
    [CmdletBinding()]
    [OutputType([void])]
    Param()

    Write-PowerShellTip

    [DateTime] $todayWithoutTime = [DateTime]::Now.Date # Exclude the time for a better user experience.
    WriteLastAutomaticTipWrittenDate -LastAutomaticTipWrittenDate $todayWithoutTime
}

function TestPowerShellSessionIsInteractive
{
    [CmdletBinding()]
    [OutputType([bool])]
    Param()

    if (-not [Environment]::UserInteractive)
    {
        Write-Debug "The [Environment]::UserInteractive property shows this PowerShell session is not interactive."
        return $false
    }

    [string[]] $typicalNonInteractiveCommandLineArguments = @(
        '-Command'
        '-c'
        '-EncodedCommand'
        '-e'
        '-ec'
        '-File'
        '-f'
        '-NonInteractive'
    )

    [string[]] $commandLineArgs = [Environment]::GetCommandLineArgs()
    Write-Debug "The PowerShell command line arguments are '$commandLineArgs'."

    [string[]] $nonInteractiveArgMatches = $commandLineArgs |
        Where-Object { $_ -in $typicalNonInteractiveCommandLineArguments }
    [bool] $isNonInteractive = $null -ne $nonInteractiveArgMatches -and $nonInteractiveArgMatches.Count -gt 0

    if ($isNonInteractive)
    {
        return $false
    }

    return $true
}

function ReadLastAutomaticTipWrittenDateOrDefault
{
    [CmdletBinding()]
    [OutputType([DateTime])]
    Param()

    [DateTime] $lastAutomaticTipWrittenDate = [DateTime]::MinValue
    [string] $lastAutomaticTipWrittenDateFilePath = GetLastAutomaticTipWrittenDateFilePath
    if (Test-Path -Path $lastAutomaticTipWrittenDateFilePath -PathType Leaf)
    {
        [string] $lastAutomaticTipWrittenDateString = [System.IO.File]::ReadAllText($lastAutomaticTipWrittenDateFilePath)
        $lastAutomaticTipWrittenDate = [DateTime]::Parse($lastAutomaticTipWrittenDateString)
    }
    return $lastAutomaticTipWrittenDate
}

function WriteLastAutomaticTipWrittenDate
{
    [CmdletBinding()]
    [OutputType([void])]
    Param
    (
        [DateTime] $LastAutomaticTipWrittenDate
    )

    [string] $lastAutomaticTipWrittenDateFilePath = GetLastAutomaticTipWrittenDateFilePath
    Write-Verbose "Writing last automatic tip Written date '$LastAutomaticTipWrittenDate' to '$lastAutomaticTipWrittenDateFilePath'."
    [System.IO.File]::WriteAllText($lastAutomaticTipWrittenDateFilePath, $LastAutomaticTipWrittenDate.ToString('o'))
}

function GetLastAutomaticTipWrittenDateFilePath
{
    [CmdletBinding()]
    [OutputType([string])]
    Param()

    [string] $appDataDirectoryPath = Get-TiPSDataDirectoryPath
    [string] $lastAutomaticTipWrittenDateFilePath = Join-Path -Path $appDataDirectoryPath -ChildPath 'LastAutomaticTipWrittenDate.txt'
    return $lastAutomaticTipWrittenDateFilePath
}

function GetConfigurationFilePath
{
    [CmdletBinding()]
    [OutputType([string])]
    Param()

    [string] $appDataDirectoryPath = Get-TiPSDataDirectoryPath
    [string] $configFilePath = Join-Path -Path $appDataDirectoryPath -ChildPath 'tiPSConfiguration.json'
    return $configFilePath
}

function ReadConfigurationFromFileOrDefault
{
    [CmdletBinding()]
    [OutputType([tiPS.Configuration])]
    Param()

    $config = [tiPS.Configuration]::new()
    [string] $configFilePath = GetConfigurationFilePath
    if (Test-Path -Path $configFilePath -PathType Leaf)
    {
        Write-Verbose "Reading configuration from '$configFilePath'."
        $config = [System.IO.File]::ReadAllText($configFilePath) | ConvertFrom-Json
    }
    return $config
}

function WriteConfigurationToFile
{
    [CmdletBinding()]
    [OutputType([void])]
    Param
    (
        [tiPS.Configuration] $Config
    )

    [string] $configFilePath = GetConfigurationFilePath

    if (-not (Test-Path -Path $configFilePath -PathType Leaf))
    {
        New-Item -Path $configFilePath -ItemType File -Force > $null
    }

    Write-Verbose "Writing configuration to '$configFilePath'."
    $Config | ConvertTo-Json -Depth 100 | Set-Content -Path $configFilePath -Force
}

function InitializeModule
{
    [CmdletBinding()]
    [OutputType([void])]
    Param()

    Write-Debug "Ensuring the tiPS data directory exists."
    EnsureTiPSAppDataDirectoryExists

    Write-Debug 'Reading in configuration from JSON file and storing it in a $TiPSConfiguration variable for access by other module functions.'
    [tiPS.Configuration] $config = ReadConfigurationFromFileOrDefault
    New-Variable -Name 'TiPSConfiguration' -Value $config -Scope Script

    Write-Debug 'Reading all tips from JSON file and storing them in a $UnshownTips variable for access by other module functions.'
    [System.Collections.Specialized.OrderedDictionary] $tipDictionary = ReadAllPowerShellTipsFromJsonFile
    New-Variable -Name 'UnshownTips' -Value $tipDictionary -Scope Script

    Write-Debug 'Removing tips that have already been shown from the $UnshownTips variable.'
    RemoveTipsAlreadyShown -Tips $script:UnshownTips

    Write-Debug "Checking if we should write a PowerShell tip, and writing one if needed."
    WriteAutomaticPowerShellTipIfNeeded -Config $script:TiPSConfiguration

    Write-Debug 'Checking if the module needs to be updated, and updating it if needed.'
    StartModuleUpdateIfNeeded -Config $script:TiPSConfiguration
}

function EnsureTiPSAppDataDirectoryExists
{
    [string] $appDataDirectoryPath = Get-TiPSDataDirectoryPath
    [bool] $directoryDoesNotExist = -not (Test-Path -Path $appDataDirectoryPath -PathType Container)
    if ($directoryDoesNotExist)
    {
        Write-Verbose "Creating tiPS data directory '$appDataDirectoryPath'."
        New-Item -Path $appDataDirectoryPath -ItemType Directory -Force > $null
    }
}

function GetPowerShellProfileFilePaths
{
    [string[]] $profileFilePaths = @()

    # The $PROFILE variable may not exist depending on the host or the context in which PowerShell was started.
    if (Test-Path -Path variable:PROFILE)
    {
        $profileFilePaths = @(
            $PROFILE.CurrentUserAllHosts
            $PROFILE.CurrentUserCurrentHost
            $PROFILE.AllUsersAllHosts
            $PROFILE.AllUsersCurrentHost
        )
    }

    return ,$profileFilePaths
}

function GetPowerShellProfileFilePathsThatExist
{
    [string[]] $powerShellProfileFilePaths = GetPowerShellProfileFilePaths
    [string[]] $profileFilePathsThatExist =
        $powerShellProfileFilePaths |
        Where-Object { Test-Path -Path $_ -PathType Leaf }

    return ,$profileFilePathsThatExist
}

function GetPowerShellProfileFilePathToAddImportTo
{
    [string] $profileFilePath = [string]::Empty

    # The $PROFILE variable may not exist depending on the host or the context in which PowerShell was started.
    if (Test-Path -Path variable:PROFILE)
    {
        $profileFilePath = $PROFILE.CurrentUserAllHosts
    }

    return $profileFilePath
}

function GetImportStatementToAddToPowerShellProfile
{
    return 'Import-Module -Name tiPS # Added by tiPS to get automatic tips and updates.'
}

function ReadAllPowerShellTipsFromJsonFile
{
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    Param()

    [string] $powerShellTipsJsonFilePath = Join-Path -Path $script:PSModuleRoot -ChildPath 'PowerShellTips.json'

    Write-Verbose "Reading PowerShell tips from '$powerShellTipsJsonFilePath'."
    [tiPS.PowerShellTip[]] $tipObjects =
        [System.IO.File]::ReadAllText($powerShellTipsJsonFilePath) | # Use .NET method instead of Get-Content for performance.
        ConvertFrom-Json
        # We assume the tips are sorted by CreatedDate when added to the json file, so we don't need to sort them again here.
        # Otherwise we would just append '| Sort-Object -Property CreatedDate' here.

    [System.Collections.Specialized.OrderedDictionary] $tipDictionary = [System.Collections.Specialized.OrderedDictionary]::new()
    foreach ($tip in $tipObjects)
    {
        if ($tip.ExpiryDate -lt [DateTime]::Today)
        {
            Write-Verbose "Skipping adding expired tip: $($tip.Title)"
            continue
        }
        $tipDictionary[$tip.Id] = $tip
    }

    return $tipDictionary
}

function RemoveTipsAlreadyShown
{
    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    Param
    (
        [Parameter(Mandatory = $true, HelpMessage = 'The hashtable of tips to remove tips that have already been shown from.')]
        [System.Collections.Specialized.OrderedDictionary] $Tips
    )

    [string[]] $tipIdsAlreadyShown = ReadTipIdsAlreadyShownOrDefault
    if ($tipIdsAlreadyShown.Count -gt 0)
    {
        Write-Verbose "Removing $($tipIdsAlreadyShown.Count) tips that have already been shown."
        foreach ($tipId in $tipIdsAlreadyShown)
        {
            $Tips.Remove($tipId)
        }
    }
}

function ReadTipIdsAlreadyShownOrDefault
{
    [CmdletBinding()]
    [OutputType([string[]])]
    # PSScriptAnalyzer does not properly handle the OutputType attribute for string arrays, so just
    # suppress the warning: https://github.com/PowerShell/PSScriptAnalyzer/issues/1471#issuecomment-1735962402
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
    Param()

    [string[]] $tipIdsAlreadyShown = @()
    [string] $tipIdsAlreadyShownFilePath = GetTipIdsAlreadyShownFilePath
    if (Test-Path -Path $tipIdsAlreadyShownFilePath -PathType Leaf)
    {
        $tipIdsAlreadyShown = [System.IO.File]::ReadAllLines($tipIdsAlreadyShownFilePath)
    }

    return ,$tipIdsAlreadyShown
}

function AppendTipIdToTipIdsAlreadyShown
{
    [CmdletBinding()]
    [OutputType([void])]
    Param
    (
        [string] $TipId
    )

    [string] $tipIdsAlreadyShownFilePath = GetTipIdsAlreadyShownFilePath
    [string[]] $tipIdAsArray = @($TipId)
    Write-Verbose "Appending Tip ID '$TipId' to '$tipIdsAlreadyShownFilePath'."
    [System.IO.File]::AppendAllLines($tipIdsAlreadyShownFilePath, $tipIdAsArray)
}

function ClearTipIdsAlreadyShown
{
    [CmdletBinding()]
    [OutputType([void])]
    Param()

    [string] $tipIdsAlreadyShownFilePath = GetTipIdsAlreadyShownFilePath
    Write-Verbose "Clearing '$tipIdsAlreadyShownFilePath'."
    [System.IO.File]::WriteAllText($tipIdsAlreadyShownFilePath, [string]::Empty)
}

function GetTipIdsAlreadyShownFilePath
{
    [CmdletBinding()]
    [OutputType([string])]
    Param()

    [string] $appDataDirectoryPath = Get-TiPSDataDirectoryPath
    [string] $tipIdsAlreadyShownFilePath = Join-Path -Path $appDataDirectoryPath -ChildPath 'TipIdsAlreadyShown.txt'
    return $tipIdsAlreadyShownFilePath
}

function WritePowerShellTipToTerminal
{
    [CmdletBinding()]
    [OutputType([void])]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')]
    Param
    (
        [Parameter(Mandatory = $true, HelpMessage = 'The PowerShell Tip to write to the terminal.')]
        [ValidateNotNullOrEmpty()]
        [tiPS.PowerShellTip] $Tip
    )

    [ConsoleColor] $headerColor = [ConsoleColor]::Cyan
    [ConsoleColor] $tipTextColor = [ConsoleColor]::White
    [ConsoleColor] $exampleColor = [ConsoleColor]::Yellow
    [ConsoleColor] $urlsColor = [ConsoleColor]::Green
    [ConsoleColor] $authorColor = [ConsoleColor]::Gray

    # Calculate how many header characters to put on each side of the title to make it look nice.
    [int] $numberOfCharactersInHeader = 90
    [int] $headerContentLength = $Tip.Title.Length + 2 + $Tip.Category.ToString().Length + 3
    [int] $numberOfHeaderCharactersOnEachSideOfTitle =
        [Math]::Floor(($numberOfCharactersInHeader - ($headerContentLength)) / 2)
    [int] $additionalHeaderCharacterNeeded = 0
    if ($headerContentLength % 2 -eq 1)
    {
        $additionalHeaderCharacterNeeded = 1
    }

    [string] $header =
        ('-' * $numberOfHeaderCharactersOnEachSideOfTitle) +
        ' ' + $Tip.Title + ' ' +
        '[' + $Tip.Category + '] ' +
        ('-' * ($numberOfHeaderCharactersOnEachSideOfTitle + $additionalHeaderCharacterNeeded))
    Write-Host $header -ForegroundColor $headerColor

    Write-Host $Tip.TipText -ForegroundColor $tipTextColor

    if ($Tip.ExampleIsProvided)
    {
        Write-Host 'Example:' -ForegroundColor $exampleColor
        Write-Host $Tip.Example -ForegroundColor $exampleColor
    }

    if ($Tip.UrlsAreProvided)
    {
        Write-Host 'More information: ' -ForegroundColor $urlsColor
        Write-Host ($Tip.Urls -join [System.Environment]::NewLine) -ForegroundColor $urlsColor
    }

    if ($Tip.AuthorIsProvided)
    {
        Write-Host "Tip submitted by: $($Tip.Author)" -ForegroundColor $authorColor
    }

    Write-Host ('-' * $numberOfCharactersInHeader) -ForegroundColor $headerColor
}

function Add-TiPSImportToPowerShellProfile
{
<#
    .SYNOPSIS
    Adds the tiPS Import-Module statement to the user's PowerShell profile file.
 
    .DESCRIPTION
    This function edits the user's PowerShell profile file to import the tiPS module, which can provide
    automatic tips and updates. If the profile already imports the tiPS module, then no changes are made.
    Only the default PowerShell profile paths are searched to see if the tiPS module is already imported; if
    it is imported from a dot-sourced script, the function will not detect that and will add an import statement
    directly to the profile file.
 
    .INPUTS
    None. You cannot pipe objects to the function.
 
    .OUTPUTS
    None. The function does not return any objects.
 
    .EXAMPLE
    Add-TiPSImportToPowerShellProfile
 
    This example edits the PowerShell profile to add a tiPS Import-Module statement.
#>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([void])]
    Param()

    Process
    {
        [bool] $moduleImportStatementIsAlreadyInProfile = Test-PowerShellProfileImportsTiPS
        if ($moduleImportStatementIsAlreadyInProfile)
        {
            Write-Verbose "PowerShell profile already imports the tiPS module, so no changes are necessary."
            return
        }

        [string] $profileFilePath = GetPowerShellProfileFilePathToAddImportTo
        [string] $contentToAddToProfile = GetImportStatementToAddToPowerShellProfile

        if ([string]::IsNullOrWhiteSpace($profileFilePath))
        {
            Write-Error "Could not determine the PowerShell profile file path."
            return
        }

        if (-not (Test-Path -Path $profileFilePath -PathType Leaf))
        {
            if ($PSCmdlet.ShouldProcess("PowerShell profile '$profileFilePath'", 'Create'))
            {
                Write-Verbose "Creating PowerShell profile '$profileFilePath'."
                New-Item -Path $profileFilePath -ItemType File -Force > $null
            }
        }

        if ($PSCmdlet.ShouldProcess("PowerShell profile '$profileFilePath'", 'Update'))
        {
            Write-Verbose "Adding '$contentToAddToProfile' to PowerShell profile '$profileFilePath'."
            Add-Content -Path $profileFilePath -Value $contentToAddToProfile -Force
        }
    }
}

function Get-PowerShellTip
{
<#
    .SYNOPSIS
    Get a PowerShellTip object. If no parameters are specified, a random tip is returned.
 
    .DESCRIPTION
    Get a PowerShellTip object. If no parameters are specified, a random tip is returned.
 
    The list of tips already shown is stored in a file in the TiPS data directory.
    If no parameters are provided, a random tip is returned from the list of tips that have not yet been shown.
    When a tip is shown, it is added to the list.
    If all tips have been shown, the list is reset.
 
    .PARAMETER Id
    The ID of the tip to retrieve. If not supplied, a random tip will be returned.
 
    .PARAMETER AllTips
    Return all tips.
 
    When this parameter is used, the list of tips shown is not updated.
 
    .INPUTS
    You can pipe a [string] of the ID of the tip to retrieve, or a PSCustomObject with a [string] 'Id' property.
 
    .OUTPUTS
    A [tiPS.PowerShellTip] object representing the PowerShell tip.
 
    If the -AllTips switch is provided, a [System.Collections.Specialized.OrderedDictionary] is returned.
 
    .EXAMPLE
    Get-PowerShellTip
 
    Get a random tip that has not been shown yet.
 
    .EXAMPLE
    Get-PowerShellTip -Id '2023-07-16-powershell-is-open-source'
 
    Get the tip with the specified ID. If no tip with the specified ID exists, an error is written.
 
    .EXAMPLE
    Get-PowerShellTip -AllTips
 
    Get all tips.
 
    .EXAMPLE
    '2023-07-16-powershell-is-open-source' | Get-PowerShellTip
 
    Pipe a [string] of the ID of the tip to retrieve.
 
    .EXAMPLE
    [PSCustomObject]@{ Id = '2023-07-16-powershell-is-open-source' } | Get-PowerShellTip
 
    Pipe an object with a [string] 'Id' property of the tip to retrieve.
#>


    [CmdletBinding(DefaultParameterSetName = 'Default')]
    [OutputType([tiPS.PowerShellTip], ParameterSetName = 'Default')]
    [OutputType([System.Collections.Specialized.OrderedDictionary], ParameterSetName = 'AllTips')]
    Param
    (
        [Parameter(ParameterSetName = 'Default', Mandatory = $false,
            ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'The ID of the tip to retrieve. If not supplied, a random tip will be returned.')]
        [string] $Id,

        [Parameter(ParameterSetName = 'AllTips', Mandatory = $false, HelpMessage = 'Return all tips.')]
        [switch] $AllTips
    )

    Process
    {
        if ($AllTips)
        {
            return ReadAllPowerShellTipsFromJsonFile
        }

        [bool] $allTipsHaveBeenShown = $script:UnshownTips.Count -eq 0
        if ($allTipsHaveBeenShown)
        {
            ResetUnshownTips
        }

        [bool] $tipIdWasProvided = (-not [string]::IsNullOrWhiteSpace($Id))
        if ($tipIdWasProvided)
        {
            [bool] $unshownTipsDoesNotContainTipId = (-not $script:UnshownTips.Contains($Id))
            if ($unshownTipsDoesNotContainTipId)
            {
                [hashtable] $allTips = ReadAllPowerShellTipsFromJsonFile
                [bool] $tipIdDoesNotExist = (-not $allTips.Contains($Id))
                if ($tipIdDoesNotExist)
                {
                    Write-Error "A tip with ID '$Id' does not exist."
                    return
                }
                [tiPS.PowerShellTip] $tip = $allTips[$Id]
                return $tip
            }
        }
        else
        {
            Write-Verbose "A Tip ID was not provided, so getting an unshown tip based on the user's configuration."
            switch ($script:TiPSConfiguration.TipRetrievalOrder)
            {
                ([tiPS.TipRetrievalOrder]::NewestFirst) { $Id = $script:UnshownTips.Keys | Select-Object -Last 1; break }
                ([tiPS.TipRetrievalOrder]::OldestFirst) { $Id = $script:UnshownTips.Keys | Select-Object -First 1; break }
                ([tiPS.TipRetrievalOrder]::Random) { $Id = $script:UnshownTips.Keys | Get-Random -Count 1; break }
            }
        }

        [tiPS.PowerShellTip] $tip = $script:UnshownTips[$Id]
        MarkTipIdAsShown -TipId $Id
        return $tip
    }
}

function ResetUnshownTips
{
    [CmdletBinding()]
    [OutputType([void])]
    Param()

    Write-Verbose "Resetting the list of unshown tips, and clearing the list of shown tips."
    $script:UnshownTips = ReadAllPowerShellTipsFromJsonFile
    ClearTipIdsAlreadyShown
}

function MarkTipIdAsShown
{
    [CmdletBinding()]
    [OutputType([void])]
    Param
    (
        [Parameter(Mandatory = $true, HelpMessage = 'The ID of the tip to mark as shown.')]
        [string] $TipId
    )

    $script:UnshownTips.Remove($TipId)
    if ($script:UnshownTips.Count -eq 0)
    {
        ResetUnshownTips
    }
    else
    {
        AppendTipIdToTipIdsAlreadyShown -TipId $TipId
    }
}

function Get-TiPSConfiguration
{
<#
    .SYNOPSIS
    Get the tiPS module configuration for the current user.
 
    .DESCRIPTION
    Get the tiPS module configuration for the current user.
 
    .INPUTS
    None. You cannot pipe objects to the function.
 
    .OUTPUTS
    A [tiPS.Configuration] object containing all of the tiPS module configuration for the current user.
 
    .EXAMPLE
    Get-TiPSConfiguration
 
    Get the tiPS module configuration.
#>


    [CmdletBinding()]
    [OutputType([tiPS.Configuration])]
    Param()

    return $script:TiPSConfiguration
}

function Get-TiPSDataDirectoryPath
{
<#
    .SYNOPSIS
    Get the tiPS data directory path.
 
    .DESCRIPTION
    Get the tiPS data directory path where the tiPS module stores all of its data for the current user.
 
    .INPUTS
    None. You cannot pipe objects to the function.
 
    .OUTPUTS
    A [string] of the directory path.
 
    .EXAMPLE
    Get-TiPSDataDirectoryPath
 
    Get the tiPS data directory path.
#>

    [CmdletBinding()]
    [OutputType([string])]
    Param()

    [string] $usersLocalAppDataPath =
        [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)
    [string] $appDataDirectoryPath =
        Join-Path -Path $usersLocalAppDataPath -ChildPath (
        Join-Path -Path 'PowerShell' -ChildPath 'tiPS')

    return $appDataDirectoryPath
}

function Remove-TiPSImportFromPowerShellProfile
{
<#
    .SYNOPSIS
    Removes the tiPS Import-Module statement from the user's PowerShell profile file.
 
    .DESCRIPTION
    This function edits the user's PowerShell profile file to remove the Import-Module statement that is
    used to import the tiPS module. If the profile does not import the tiPS module, then no changes are made.
    Only the default PowerShell profile paths are searched to see if the tiPS module is imported; if
    it is imported from a dot-sourced script, the function will not detect the import statement and it will
    not be removed.
 
    This function will only remove the tiPS import statement added by the Add-TiPSImportToPowerShellProfile
    function. If you manually added the import statement to your profile, this function may not remove it.
 
    .INPUTS
    None. You cannot pipe objects to the function.
 
    .OUTPUTS
    None. The function does not return any objects.
 
    .EXAMPLE
    Remove-TiPSImportFromPowerShellProfile
 
    This example edits the PowerShell profile to remove the tiPS Import-Module statement.
#>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([void])]
    Param()

    Process
    {
        [bool] $moduleImportStatementIsInProfile = Test-PowerShellProfileImportsTiPS
        if (-not $moduleImportStatementIsInProfile)
        {
            Write-Verbose "The PowerShell profiles do not import the tiPS module, so no changes are necessary."
            return
        }

        [string[]] $profileFilePathsThatExist = GetPowerShellProfileFilePathsThatExist
        [string[]] $importStatementsToRemoveFromProfile = @(
            GetImportStatementToAddToPowerShellProfile
            'Import-Module -Name tiPS -Force'
            'Import-Module -Name tiPS'
            'Import-Module tiPS -Force'
            'Import-Module tiPS'
        )

        [bool] $atLeastOneProfileFileModified = $false
        foreach ($profileFilePath in $profileFilePathsThatExist)
        {
            [string] $fileContents = Get-Content -Path $profileFilePath -Raw
            foreach ($importStatement in $importStatementsToRemoveFromProfile)
            {
                [regex] $importLineRegex =
                    '(?mi)' + # Enable multiline (match against newlines in the middle of the string) and case-insensitive matching.
                    '^' + # Match against the beginning of the line.
                    '\s*' + # Match any whitespace at the beginning of the line.
                    $importStatement + # Match the import statement.
                    '\s*' + # Match any whitespace at the end of the line.
                    '$' # Match against the end of the line.
                if ($fileContents -match $importLineRegex)
                {
                    if ($PSCmdlet.ShouldProcess("PowerShell profile '$profileFilePath'", 'Update'))
                    {
                        Write-Verbose "Removing '$($matches.Values)' from PowerShell profile '$profileFilePath'."
                        [string] $updatedFileContents = $fileContents -replace $importLineRegex, ''
                        Set-Content -Path $profileFilePath -Value $updatedFileContents -Force
                    }

                    $atLeastOneProfileFileModified = $true
                }
            }
        }

        if (-not $atLeastOneProfileFileModified)
        {
            Write-Warning "One of the PowerShell profiles does import the tiPS module, but not with the expected import statement. Run 'Test-PowerShellProfileImportsTiPS -Verbose' to see which profile files import the tiPS module, and then manually remove the statement from the file."
        }
    }
}

function Set-TiPSConfiguration
{
<#
    .SYNOPSIS
    Set the tiPS configuration.
 
    .DESCRIPTION
    Set the entire or partial tiPS configuration.
 
    .PARAMETER Configuration
    The tiPS configuration object to set.
    All configuration properties are updated to match the provided object.
    No other properties may be provided when this parameter is used.
 
    .PARAMETER AutomaticallyUpdateModule
    Whether to automatically update the tiPS module at session startup.
    The module update is performed in a background job, so it does not block the PowerShell session from starting.
    This also means that the new module version will not be used until the next time the module is imported, or
    the next time a PowerShell session is started.
    Old versions of the module are automatically deleted after a successful update.
    Valid values are Never, Daily, Weekly, Monthly, and Yearly. Default is Never.
 
    .PARAMETER AutomaticallyWritePowerShellTip
    Whether to automatically write a PowerShell tip at session startup.
    Valid values are Never, Daily, Weekly, Monthly, and Yearly. Default is Never.
 
    .PARAMETER TipRetrievalOrder
    The order in which to retrieve PowerShell tips.
    Valid values are NewestFirst, OldestFirst, and Random. Default is NewestFirst.
 
    .INPUTS
    You can pipe a [tiPS.Configuration] object containing the tiPS configuration to set, or
    a PSCustomObject with the individual properties to set
    (e.g. AutomaticallyUpdateModule and/or AutomaticallyWritePowerShellTip).
 
    .OUTPUTS
    None. The function does not return any objects.
 
    .EXAMPLE
    Set-TiPSConfiguration -Configuration $config
 
    Set the tiPS configuration.
 
    .EXAMPLE
    Set-TiPSConfiguration -AutomaticallyUpdateModule Weekly
 
    Set the tiPS configuration to automatically update the tiPS module every 7 days.
 
    .EXAMPLE
    Set-TiPSConfiguration -AutomaticallyWritePowerShellTip Daily
 
    Set the tiPS configuration to automatically write a PowerShell tip every day.
 
    .EXAMPLE
    Set-TiPSConfiguration -AutomaticallyUpdateModule Never -AutomaticallyWritePowerShellTip Never
 
    Set the tiPS configuration to never automatically update the tiPS module or write a PowerShell tip.
 
    .EXAMPLE
    Set-TiPSConfiguration -TipRetrievalOrder Random
 
    Set the tiPS configuration to retrieve PowerShell tips in random order.
#>

    [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'PartialConfiguration')]
    [OutputType([void])]
    Param
    (
        [Parameter(Mandatory = $true, ParameterSetName = 'EntireConfiguration', ValueFromPipeline = $true)]
        [ValidateNotNullOrEmpty()]
        [tiPS.Configuration] $Configuration,

        [Parameter(Mandatory = $false, ParameterSetName = 'PartialConfiguration', ValueFromPipelineByPropertyName = $true)]
        [tiPS.ModuleAutoUpdateCadence] $AutomaticallyUpdateModule = [tiPS.ModuleAutoUpdateCadence]::Never,

        [Parameter(Mandatory = $false, ParameterSetName = 'PartialConfiguration', ValueFromPipelineByPropertyName = $true)]
        [tiPS.WritePowerShellTipCadence] $AutomaticallyWritePowerShellTip = [tiPS.WritePowerShellTipCadence]::Never,

        [Parameter(Mandatory = $false, ParameterSetName = 'PartialConfiguration', ValueFromPipelineByPropertyName = $true)]
        [tiPS.TipRetrievalOrder] $TipRetrievalOrder = [tiPS.TipRetrievalOrder]::NewestFirst
    )

    Process
    {
        # If the entire Configuration object parameter is passed in, set it and return.
        if ($PSBoundParameters.ContainsKey('Configuration'))
        {
            if ($PSCmdlet.ShouldProcess('tiPS configuration', 'Set'))
            {
                $script:TiPSConfiguration = $Configuration
            }
        }

        # If the AutomaticallyUpdateModule parameter is passed in, set it.
        if ($PSBoundParameters.ContainsKey('AutomaticallyUpdateModule'))
        {
            if ($PSCmdlet.ShouldProcess('tiPS configuration AutoUpdateCadence property', 'Set'))
            {
                $script:TiPSConfiguration.AutoUpdateCadence = $AutomaticallyUpdateModule
            }
        }

        # If the AutomaticallyWritePowerShellTip parameter is passed in, set it.
        if ($PSBoundParameters.ContainsKey('AutomaticallyWritePowerShellTip'))
        {
            if ($PSCmdlet.ShouldProcess('tiPS configuration AutoWritePowerShellTipCadence property', 'Set'))
            {
                $script:TiPSConfiguration.AutoWritePowerShellTipCadence = $AutomaticallyWritePowerShellTip
            }
        }

        # If the TipRetrievalOrder parameter is passed in, set it.
        if ($PSBoundParameters.ContainsKey('TipRetrievalOrder'))
        {
            if ($PSCmdlet.ShouldProcess('tiPS configuration TipRetrievalOrder property', 'Set'))
            {
                $script:TiPSConfiguration.TipRetrievalOrder = $TipRetrievalOrder
            }
        }

        Write-Debug "Saving the tiPS configuration to the configuration file."
        WriteConfigurationToFile -Config $script:TiPSConfiguration

        Write-Debug "Ensuring the user's PowerShell profile imports the tiPS module if their config expects it."
        [bool] $automaticActionsAreConfigured =
            $script:TiPSConfiguration.AutoUpdateCadence -ne [tiPS.ModuleAutoUpdateCadence]::Never -or
            $script:TiPSConfiguration.AutoWritePowerShellTipCadence -ne [tiPS.WritePowerShellTipCadence]::Never
        if ($automaticActionsAreConfigured)
        {
            [bool] $tiPSModuleIsImportedByPowerShellProfile = Test-PowerShellProfileImportsTiPS
            if (-not $tiPSModuleIsImportedByPowerShellProfile)
            {
                Write-Warning "tiPS can only perform automatic actions when it is imported into the current PowerShell session. Run 'Add-TiPSImportToPowerShellProfile' to update your PowerShell profile import tiPS automatically when a new session starts, or manually add 'Import-Module -Name tiPS' to your profile file. If you are importing the module in a different way, such as in a script that is dot-sourced into your profile, you can ignore this warning."
            }
        }
    }
}

function Test-PowerShellProfileImportsTiPS
{
<#
    .SYNOPSIS
    Tests whether the PowerShell profile imports the tiPS module.
 
    .DESCRIPTION
    Tests whether the PowerShell profile imports the tiPS module.
    Returns true if it finds an 'Import-Module -Name tiPS' statement in the profile, false otherwise.
    This only looks in the default PowerShell profile paths.
    If the tiPS module is imported from a dot-sourced file then this will return false.
 
    .INPUTS
    None. You cannot pipe objects to the function.
 
    .OUTPUTS
    System.Boolean representing if the tiPS module is imported by the PowerShell profile or not.
 
    .EXAMPLE
    Test-PowerShellProfileImportsTiPS
 
    Tests whether the PowerShell profile imports the tiPS module, returning true if it does and false otherwise.
 
    .EXAMPLE
    Test-PowerShellProfileImportsTiPS -Verbose
 
    Tests whether the PowerShell profile imports the tiPS module, returning true if it does and false otherwise.
    If true, the verbose output will list the profile file paths and the lines that import the tiPS module.
    If false, the verbose output will list the profile file paths that it checked.
#>

    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param()

    [string[]] $profileFilePathsThatExist = GetPowerShellProfileFilePathsThatExist

    if ($null -eq $profileFilePathsThatExist -or $profileFilePathsThatExist.Count -eq 0)
    {
        Write-Verbose "No PowerShell profile files exist."
        return $false
    }

    [string] $requiredContentRegex = 'Import-Module\s.*tiPS'
    [Microsoft.PowerShell.Commands.MatchInfo] $results =
        Select-String -Path $profileFilePathsThatExist -Pattern $requiredContentRegex
    if ($null -ne $results)
    {
        Write-Verbose "The tiPS module is imported by the following profile lines:"
        $results | ForEach-Object {
            Write-Verbose " $($_.Path): $($_.Line)"
        }
        return $true
    }

    Write-Verbose "The tiPS module is not imported directly by any of the PowerShell profiles: $profileFilePathsThatExist"
    return $false
}

function Write-PowerShellTip
{
<#
    .SYNOPSIS
    Write a PowerShell tip to the terminal.
 
    .DESCRIPTION
    Write a PowerShell tip to the terminal. If no tip is specified, a random tip will be written.
    The tip is written to the terminal using the Write-Host cmdlet so that colours can be applied.
    Thus, the tip is not written to the pipeline and cannot be captured in a variable.
    If you want to capture the tip in a variable, use the Get-PowerShellTip function.
 
    .PARAMETER Id
    The ID of the tip to write. If not supplied, a random tip will be written.
    If no tip with the specified ID exists, an error is written.
 
    .INPUTS
    You can pipe a [string] of the ID of the tip to write, or a PSCustomObject with a [string] 'Id' property.
 
    .OUTPUTS
    None. The function does not return any objects.
 
    .EXAMPLE
    Write-PowerShellTip
 
    Write a random tip to the terminal.
 
    .EXAMPLE
    Write-PowerShellTip -Id '2023-07-16-powershell-is-open-source'
 
    Write the tip with the specified ID.
#>

    [CmdletBinding()]
    [Alias('Tips')]
    [OutputType([void])]
    Param
    (
        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true,
            HelpMessage = 'The ID of the tip to write. If not supplied, a random tip will be written.')]
        [string] $Id
    )

    Process
    {
        [tiPS.PowerShellTip] $tip = Get-PowerShellTip -Id $Id
        if ($null -ne $tip)
        {
            WritePowerShellTipToTerminal -Tip $tip
        }
    }
}



Write-Debug 'Now that all types and functions are imported, initializing the module.'
InitializeModule

# Function and Alias exports are defined in the modules manifest (.psd1) file.