PSShlink.psm1

#region Private functions
function GetShlinkConnection {
    param (
        [Parameter()]
        [String]$Server,
        
        [Parameter()]
        [SecureString]$ApiKey,

        [Parameter()]
        [Switch]$ServerOnly
    )

    function SetShlinkServer {
        param (
            [Parameter()]
            [String]$Server
        )
        if ($Server -notmatch '^http[s]?:\/\/') {
            Write-Warning ("Rewriting Shlink server address to be 'https://{0}' instead of using http://. To use HTTP, instead of HTTPS, specify 'http://' in your -ShlinkServer." -f $Server) -Verbose
            $Script:ShlinkServer = "https://{0}" -f $Server
        }
        else {
            $Script:ShlinkServer = $Server
        }
    }

    $Script:MinSupportedShlinkVersion = [Version]"2.9.0"

    if (-not ("System.Web.HttpUtility" -as [Type])) {
        Add-Type -AssemblyName "System.Web" -ErrorAction "Stop"
    }

    if ([String]::IsNullOrWhiteSpace($Server) -And -not $Script:ShlinkServer) {
        # User has not yet used use a -ShlinkServer paramater from any of the functions, therefore prompt
        SetShlinkServer -Server (Read-Host -Prompt "Enter your Shlink server URL (e.g. https://example.com)")
    }
    elseif (-not [String]::IsNullOrWhiteSpace($Server) -And $Script:ShlinkServer -ne $Server) {
        # User has previously used a -ShlinkServer parameter and is using it right now, and its value is different to what was used last in any of the functions
        # In other words, it has changed and they wish to use a different server, and that new server will be used for subsequent calls unless they specify a different server again.
        SetShlinkServer -Server $Server
        # Set this to false so we can go through the motions again of checking the new Shlink server's version number
        $Script:GetShlinkConnectionHasRun = $false
        # We no longer know if the new server's Shlink version is supported for PSShlink
        Clear-Variable -Name "ShlinkVersionIsSupported" -Scope "Script" -ErrorAction "SilentlyContinue"
    }

    if ([String]::IsNullOrWhitespace($ApiKey) -And -not $Script:ShlinkApiKey -And -not $ServerOnly) {
        # User has not yet used use a -ShlinkApiKey paramater from any of the functions, therefore prompt
        $Script:ShlinkApiKey = Read-Host -Prompt "Enter your Shlink server API key" -AsSecureString
    }
    elseif (-not [String]::IsNullOrWhiteSpace($ApiKey) -And $Script:ShlinkApiKey -ne $ApiKey) {
        # User has previously used a -ShlinkApiKey parameter and is using it right now, and its value is different to what was used last in any of the functions
        # In other words, it has changed - they wish to use a different API key, and that new API key will be used for subsequent calls unless they specify a different API key again.
        $Script:ShlinkApiKey = $ApiKey
    }

    # Query the Shlink server for version, only on the first run of GetShlinkConnection, otherwise it
    # will enter an infinite loop of recursion and hit the PowerShell recursion limit. I want a user
    # experience of being warned each time they use a function on an unsupported Shlink server version.
    if (-not $Script:GetShlinkConnectionHasRun) {
        $Script:GetShlinkConnectionHasRun = $true
        $Script:ShlinkVersion = Get-ShlinkServer -ShlinkServer $Script:ShlinkServer -ErrorAction "SilentlyContinue" | Select-Object -ExpandProperty Version

        if (-not $Script:ShlinkVersion) {
            $Script:GetShlinkConnectionHasRun = $false
            Write-Error -Message ("Could not determine the version of Shlink on '{0}'" -f $Script:ShlinkServer) -Category "InvalidData" -TargetObject $Script:ShlinkServer -ErrorAction "Stop"
        }
        elseif ([Version]$Script:ShlinkVersion -lt [Version]$Script:MinSupportedShlinkVersion) {
            $Script:ShlinkVersionIsSupported = $false
        }
        else {
            $Script:ShlinkVersionIsSupported = $true
        }
    }

    if ($Script:ShlinkVersionIsSupported -eq $false -And $Script:ShlinkVersion) {
        Write-Warning -Message ("PSShlink supports Shlink {0} or newer, your Shlink server is {1}. Some functions may not work as intended. Consider upgrading your Shlink instance." -f $Script:MinSupportedShlinkVersion, $Script:ShlinkVersion)
    }    
}

function InvokeShlinkRestMethod {
    [CmdletBinding()]
    param (
        [Parameter()]
        [String]$Server = $Script:ShlinkServer,

        [Parameter()]
        [SecureString]$ApiKey = $Script:ShlinkApiKey,

        [Parameter()]
        [Microsoft.PowerShell.Commands.WebRequestMethod]$Method = 'GET',

        [Parameter(Mandatory)]
        [String]$Endpoint,

        [Parameter()]
        [String]$Path,

        # Default value set where no Query parameter is passed because we still need the object for pagination later
        [Parameter()]
        [System.Collections.Specialized.NameValueCollection]$Query = [System.Web.HttpUtility]::ParseQueryString(''),

        [Parameter()]
        [hashtable]$Body,

        [Parameter()]
        [String[]]$PropertyTree,

        [Parameter()]
        [Int]$Page,

        [Parameter()]
        [String]$PSTypeName
    )

    $Params = @{
        Method        = $Method
        Uri           = "{0}/rest/v2/{1}" -f $Server, $Endpoint
        ContentType   = "application/json"
        Headers       = @{"X-Api-Key" = [PSCredential]::new("none", $ApiKey).GetNetworkCredential().Password}
        ErrorAction   = "Stop"
        ErrorVariable = "InvokeRestMethodError"
    }

    if ($PSBoundParameters.ContainsKey("Path")) {
        $Params["Uri"] = "{0}/{1}" -f $Params["Uri"], $Path
    }

    # Preserve the URI which does not contain any query parameters for the pagination URI building later
    $QuerylessUri = $Params["Uri"]

    if ($PSBoundParameters.ContainsKey("Query")) {
        $Params["Uri"] = "{0}?{1}" -f $Params["Uri"], $Query.ToString()
    }

    if ($PSBoundParameters.ContainsKey("Body")) {
        $Params["Body"] = $Body | ConvertTo-Json
    }

    $Result = do {
        try {
            Write-Verbose ("Body: {0}" -f $Params["Body"])
            $Data = Invoke-RestMethod @Params
        }
        catch {
            # The web exception class is different for Core vs Windows
            if ($InvokeRestMethodError.ErrorRecord.Exception.GetType().FullName -match "HttpResponseException|WebException") {
                $ExceptionMessage = $InvokeRestMethodError.Message | ConvertFrom-Json | Select-Object -ExpandProperty detail
                $ErrorId = "{0}{1}" -f 
                    [Int][System.Net.HttpStatusCode]$InvokeRestMethodError.ErrorRecord.Exception.Response.StatusCode, 
                    [String][System.Net.HttpStatusCode]$InvokeRestMethodError.ErrorRecord.Exception.Response.StatusCode

                switch -Regex ($InvokeRestMethodError.ErrorRecord.Exception.Response.StatusCode) {
                    "Unauthorized" {
                        $Exception = [System.UnauthorizedAccessException]::new($ExceptionMessage)
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            $ErrorId,
                            [System.Management.Automation.ErrorCategory]::AuthenticationError,
                            $Params['Uri']
                        )
                    }
                    "BadRequest|Conflict" {
                        $Exception = [System.ArgumentException]::new($ExceptionMessage)
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            $ErrorId,
                            [System.Management.Automation.ErrorCategory]::InvalidArgument,
                            $Params['Uri']
                        )
                    }
                    "NotFound" {
                        $Exception = [System.Management.Automation.ItemNotFoundException]::new($ExceptionMessage)
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            $ErrorId,
                            [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                            $Params['Uri']
                        )
                    }
                    "ServiceUnavailable" {
                        $Exception = [System.InvalidOperationException]::new($ExceptionMessage)
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            $ErrorId,
                            [System.Management.Automation.ErrorCategory]::ResourceUnavailable,
                            $Params['Uri']
                        )
                    }
                    default {
                        $Exception = [System.InvalidOperationException]::new($ExceptionMessage)
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            $ErrorId,
                            [System.Management.Automation.ErrorCategory]::InvalidOperation,
                            $Params['Uri']
                        )
                    }
                }
    
                $PSCmdlet.ThrowTerminatingError($ErrorRecord)
            }
            else {
                $PSCmdlet.ThrowTerminatingError($_)
            }   
        }

        $PaginationData = if ($PropertyTree) {
            Write-Output $Data.($PropertyTree[0]).pagination
        }
        else {
            Write-Output $Data.pagination
        }
        
        if ($PaginationData) {  
            $Query["page"] = $PaginationData.currentPage + 1
            $Params["Uri"] = "{0}?{1}" -f $QuerylessUri, $Query.ToString()
        }

        Write-Output $Data
    } while ($PaginationData.currentPage -ne $PaginationData.pagesCount -And $PaginationData.pagesCount -ne 0)

    # Walk down the object's properties to return the desired property
    # e.g. sometimes the data is burried in tags.data or shortUrls.data etc
    foreach ($Property in $PropertyTree) {
        $Result = $Result.$Property
    }

    if ($PSBoundParameters.ContainsKey("PSTypeName")) {
        foreach ($item in $Result) {
            $item.PSTypeNames.Insert(0, $PSTypeName)
        }
    }

    Write-Output $Result
}
#endregion

#region Public functions
function Get-ShlinkDomains {
    <#
    .SYNOPSIS
        Returns the list of all domains ever used, with a flag that tells if they are the default domain
    .DESCRIPTION
        Returns the list of all domains ever used, with a flag that tells if they are the default domain
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Get-ShlinkDomains

        Returns the list of all domains ever used, with a flag that tells if they are the default domain
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding()]
    param (
        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )

    try {
        GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
    }
    catch {
        Write-Error -ErrorRecord $_ -ErrorAction "Stop"
    }

    $Params = @{
        Endpoint     = "domains"
        PropertyTree = "domains", "data"
        ErrorAction  = "Stop"
    }

    try {
        InvokeShlinkRestMethod @Params
    }
    catch {
        Write-Error -ErrorRecord $_
    }
}

function Get-ShlinkServer {
    <#
    .SYNOPSIS
        Checks the healthiness of the service, making sure it can access required resources.
    .DESCRIPTION
        Checks the healthiness of the service, making sure it can access required resources.

        https://api-spec.shlink.io/#/Monitoring/health
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Get-ShlinkServer
        
        Returns the healthiness of the service.
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding()]
    param (
        [Parameter()]
        [String]$ShlinkServer
    )

    try {
        GetShlinkConnection -Server $ShlinkServer -ServerOnly
    }
    catch {
        Write-Error -ErrorRecord $_ -ErrorAction "Stop"
    }

    $Uri = "{0}/rest/health" -f $Script:ShlinkServer

    try {
        Invoke-RestMethod -Uri $Uri -ErrorAction "Stop"
    }
    catch {
        Write-Error -ErrorRecord $_
    }
}

function Get-ShlinkTags {
    <#
    .SYNOPSIS
        Returns the list of all tags used in any short URL, including stats and ordered by name.
    .DESCRIPTION
        Returns the list of all tags used in any short URL, including stats and ordered by name.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Get-ShlinkTags
        
        Returns the list of all tags used in any short URL, including stats and ordered by name.
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding()]
    param (
        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )

    try {
        GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
    }
    catch {
        Write-Error -ErrorRecord $_ -ErrorAction "Stop"
    }
    
    $QueryString = [System.Web.HttpUtility]::ParseQueryString('')

    $QueryString.Add("withStats", "true")

    $Params = @{
        Endpoint     = "tags"
        PropertyTree = "tags", "stats"
    }

    $Params["Query"] = $QueryString

    try {
        InvokeShlinkRestMethod @Params
    }
    catch {
        Write-Error -ErrorRecord $_
    }
}

function Get-ShlinkUrl {
    <#
    .SYNOPSIS
        Get details of all short codes, or just one.
    .DESCRIPTION
        Get details of all short codes, or just one. Various filtering options are available from the API to ambigiously search for short codes.
    .PARAMETER ShortCode
        The name of the short code you wish to search for. For example, if the short URL is "https://example.com/new-url" then the short code is "new-url".
    .PARAMETER Domain
        The domain (excluding schema) associated with the short code you wish to search for. For example, "example.com" is an acceptable value.
        This is useful if your Shlink instance is responding/creating short URLs for multiple domains.
    .PARAMETER SearchTerm
        The search term to search for a short code with.
    .PARAMETER Tags
        One or more tags can be passed to find short codes using said tag(s).
    .PARAMETER OrderBy
        Order the results returned by "longUrl", "shortCode", "dateCreated", or "visits". The default sort order is in ascending order.
    .PARAMETER StartDate
        A datetime object to search for short codes where its start date is equal or greater than this value.
        If a start date is not configured for the short code(s), this filters on the dateCreated property.
    .PARAMETER EndDate
        A datetime object to search for short codes where its end date is equal or less than this value.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Get-ShlinkUrl
        
        Returns all short codes with no filtering applied.
    .EXAMPLE
        PS C:\> Get-ShlinkUrl -ShortCode "profile"

        Returns the short code "profile".
    .EXAMPLE
        PS C:\> Get-ShlinkUrl -ShortCode "profile" -Domain "example.com"

        Returns the short code "profile" using the domain "example.com". This is useful if your Shlink instance is responding/creating short URLs for multiple domains.
    .EXAMPLE
        PS C:\> Get-ShlinkUrl -Tags "oldwebsite", "evenolderwebsite" -OrderBy "dateCreated"

        Returns short codes which are associated with the tags "oldwebsite" and "evenolderwebsite". Ordered by the dateCreated property in ascending order.
    .EXAMPLE
        PS C:\> Get-ShlinkUrl -StartDate (Get-Date "2020-10-25 11:00:00")

        Returns short codes which have a start date of 25th October 2020 11:00:00 AM or newer. If a start date was not configured for the short code(s), this filters on the dateCreated property.
    .EXAMPLE
        PS C:\> Get-ShlinkUrl -SearchTerm "microsoft"

        Returns the short codes which match the search term "microsoft".
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Management.Automation.PSObject

        Objects have a PSTypeName of 'PSShlink'.
    #>

    [CmdletBinding(DefaultParameterSetName="ListShortUrls")]
    param (
        [Parameter(Mandatory, ParameterSetName="ParseShortCode")]
        [String]$ShortCode,

        [Parameter(ParameterSetName="ParseShortCode")]
        [String]$Domain,

        [Parameter(ParameterSetName="ListShortUrls")]
        [String]$SearchTerm,

        [Parameter(ParameterSetName="ListShortUrls")]
        [String[]]$Tags,

        [Parameter(ParameterSetName="ListShortUrls")]
        [ValidateSet("longUrl", "shortCode", "dateCreated", "visits")]
        [String]$OrderBy,

        [Parameter(ParameterSetName="ListShortUrls")]
        [datetime]$StartDate,

        [Parameter(ParameterSetName="ListShortUrls")]
        [datetime]$EndDate,

        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )
    # Using begin / process / end blocks due to the way PowerShell processes all
    # begin blocks in the pipeline first before the process blocks. If user passed
    # -ShlinkServer and -ShlinkApiKey in this function and piped to something else,
    # e.g. Set-ShlinkUrl, and they omitted those parameters from the piped function,
    # they will be prompted for -ShlinkServer and -ShlinkApiKey. This is not my intended
    # user experience. Hence the decision to implement begin/process/end blocks here.
    begin {
        try {
            GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
        }
        catch {
            Write-Error -ErrorRecord $_ -ErrorAction "Stop"
        }

        $QueryString = [System.Web.HttpUtility]::ParseQueryString('')
    }
    process {        
        $Params = @{
            Endpoint    = "short-urls"
            PSTypeName  = "PSShlink"
            ErrorACtion = "Stop"
        }
    
        switch ($PSCmdlet.ParameterSetName) {
            "ParseShortCode" {
                switch ($PSBoundParameters.Keys) {
                    "ShortCode" {
                        $Params["Path"] = $ShortCode
                    }
                    "Domain" {
                        $QueryString.Add("domain", $Domain)
                    }
                }
            }
            "ListShortUrls" {   
                $Params["PropertyTree"] = "shortUrls", "data"
    
                switch ($PSBoundParameters.Keys) {
                    "Tags" {
                        foreach ($Tag in $Tags) {
                            $QueryString.Add("tags[]", $Tag)
                        }
                    }
                    "SearchTerm" {
                        $QueryString.Add("searchTerm", $SearchTerm)
                    }
                    "OrderBy" {
                        $QueryString.Add("orderBy", $OrderBy)
                    }
                    "StartDate" {
                        $QueryString.Add("startDate", (Get-Date $StartDate -Format "yyyy-MM-ddTHH:mm:sszzzz"))
                    }
                    "EndDate" {
                        $QueryString.Add("endDate", (Get-Date $EndDate -Format "yyyy-MM-ddTHH:mm:sszzzz"))
                    }
                }
            }
        }
    
        $Params["Query"] = $QueryString
        
        try {
            InvokeShlinkRestMethod @Params
        }
        catch {
            Write-Error -ErrorRecord $_
        }
    }
    end {
    }    
}

function Get-ShlinkVisits {
    <#
    .SYNOPSIS
        Get details of visits for a Shlink server, short codes or tags.
    .DESCRIPTION
        Get details of visits for a Shlink server, short codes or tags.
    .PARAMETER ShortCode
        The name of the short code you wish to return the visits data for. For example, if the short URL is "https://example.com/new-url" then the short code is "new-url".
    .PARAMETER Tag
        The name of the tag you wish to return the visits data for.
    .PARAMETER Domain
        The domain (excluding schema) associated with the short code you wish to search for. For example, "example.com" is an acceptable value.
        This is useful if your Shlink instance is responding/creating short URLs for multiple domains.
    .PARAMETER StartDate
        A datetime object to filter the visit data where the start date is equal or greater than this value.
    .PARAMETER EndDate
        A datetime object to filter the visit data where its end date is equal or less than this value.
    .PARAMETER ExcludeBots
        Exclude visits from bots or crawlers.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Get-ShlinkVists
        
        Returns the overall visit count for your Shlink server
    .EXAMPLE
        PS C:\> Get-ShlinkVisits -ShortCode "profile"
        
        Returns all visit data associated with the short code "profile"
    .EXAMPLE
        PS C:\> Get-ShlinkVisits -Tag "oldwebsite"

        Returns all the visit data for all short codes asociated with the tag "oldwebsite"
    .EXAMPLE
        PS C:\> Get-ShlinkVisits -ShortCode "profile" -StartDate (Get-Date "2020-11-01") -EndDate (Get-Date "2020-12-01")

        Returns all visit data associated with the short code "profile" for the whole of November 2020
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding(DefaultParameterSetName="Server")]
    param (
        [Parameter(Mandatory, ParameterSetName="ShortCode")]
        [String]$ShortCode,

        [Parameter(Mandatory, ParameterSetName="Tag")]
        [String]$Tag,

        [Parameter(ParameterSetName="ShortCode")]
        [Parameter(ParameterSetName="Tag")]
        [String]$Domain,

        [Parameter(ParameterSetName="ShortCode")]
        [Parameter(ParameterSetName="Tag")]
        [datetime]$StartDate,

        [Parameter(ParameterSetName="ShortCode")]
        [Parameter(ParameterSetName="Tag")]
        [datetime]$EndDate,

        [Parameter(ParameterSetName="ShortCode")]
        [Parameter(ParameterSetName="Tag")]
        [Switch]$ExcludeBots,

        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )

    try {
        GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
    }
    catch {
        Write-Error -ErrorRecord $_ -ErrorAction "Stop"
    }
    
    $QueryString = [System.Web.HttpUtility]::ParseQueryString('')

    $Params = @{
        PropertyTree = @("visits")
    }

    switch -Regex ($PSCmdlet.ParameterSetName) {
        "Server" {
            $Params["Endpoint"] = "visits"
        }
        "ShortCode|Tag" {
            $Params["PropertyTree"] += "data"
            $Params["PSTypeName"] = "PSShlinkVisits"

            switch ($PSBoundParameters.Keys) {
                "Domain" {
                    $QueryString.Add("domain", $Domain)
                }
                "StartDate" {
                    $QueryString.Add("startDate", (Get-Date $StartDate -Format "yyyy-MM-ddTHH:mm:sszzzz"))
                }
                "EndDate" {
                    $QueryString.Add("endDate", (Get-Date $EndDate -Format "yyyy-MM-ddTHH:mm:sszzzz"))
                }
                "ExcludeBots" {
                    $QueryString.Add("excludeBots", "true")
                }
            }
        }
        "ShortCode" {
            $Params["Endpoint"] = "short-urls/{0}/visits" -f $ShortCode
        }
        "Tag" {
            $Params["Endpoint"] = "tags/{0}/visits" -f $Tag
        }
    }

    $Params["Query"] = $QueryString

    try {
        $Result = InvokeShlinkRestMethod @Params

        # I figured it would be nice to add the Server property so it is immediately clear
        # the server's view count is returned when no parameters are used
        if ($PSCmdlet.ParameterSetName -eq "Server") {
            [PSCustomObject]@{
                Server      = $Script:ShlinkServer
                visitsCount = $Result.visitsCount
            }
        }
        else {
            $Result
        }
    }
    catch {
        Write-Error -ErrorRecord $_
    }
}

function Get-ShlinkVisitsOrphan {
    <#
    .SYNOPSIS
        Get the list of visits to invalid short URLs, the base URL or any other 404.
    .DESCRIPTION
        Get the list of visits to invalid short URLs, the base URL or any other 404.
    .PARAMETER StartDate
        A datetime object to filter the visit data where the start date is equal or greater than this value.
    .PARAMETER EndDate
        A datetime object to filter the visit data where its end date is equal or less than this value.
    .PARAMETER ExcludeBots
        Exclude visits from bots or crawlers.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Get-ShlinkVisitsOrphan

        Get the list of visits to invalid short URLs, the base URL or any other 404.
    .EXAMPLE
        PS C:\> Get-ShlinkVisitsOrphan -StartDate (Get-Date "2020-11-01") -EndDate (Get-Date "2020-12-01") -ExcludeBots

        Get the list of visits to invalid short URLs, the base URL or any other 404, for the whole of November and excluding bots/crawlers.
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding()]
    param (
        [Parameter()]
        [datetime]$StartDate,

        [Parameter()]
        [datetime]$EndDate,

        [Parameter()]
        [Switch]$ExcludeBots,

        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )

    try {
        GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
    }
    catch {
        Write-Error -ErrorRecord $_ -ErrorAction "Stop"
    }
    
    $QueryString = [System.Web.HttpUtility]::ParseQueryString('')

    $Params = @{
        Endpoint = "visits"
        Path = "orphan"
        PropertyTree = "visits", "data"
    }

    switch ($PSBoundParameters.Keys) {
        "StartDate" {
            $QueryString.Add("startDate", (Get-Date $StartDate -Format "yyyy-MM-ddTHH:mm:sszzzz"))
        }
        "EndDate" {
            $QueryString.Add("endDate", (Get-Date $EndDate -Format "yyyy-MM-ddTHH:mm:sszzzz"))
        }
        "ExcludeBots" {
            $QueryString.Add("excludeBots", "true")
        }
    }

    $Params["Query"] = $QueryString
        
    try {
        InvokeShlinkRestMethod @Params
    }
    catch {
        Write-Error -ErrorRecord $_
    }
}

function New-ShlinkTag {
    <#
    .SYNOPSIS
        Creates one or more new tags on your Shlink server
    .DESCRIPTION
        Creates one or more new tags on your Shlink server
    .PARAMETER Tags
        Name(s) for your new tag(s)
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> New-ShlinkTag -Tags "oldwebsite","newwebsite","misc"
        
        Creates the following new tags on your Shlink server: "oldwebsite","newwebsite","misc"
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String[]]$Tags,

        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )

    try {
        GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
    }
    catch {
        Write-Error -ErrorRecord $_ -ErrorAction "Stop"
    }

    $Params = @{
        Endpoint     = "tags"
        Method       = "POST"
        Body         = @{
            tags = @($Tags)
        }
        PropertyTree = "tags", "data"
        ErrorAction  = "Stop"
    }

    try {
        InvokeShlinkRestMethod @Params
    }
    catch {
        Write-Error -ErrorRecord $_
    }
    finally {
        Write-Warning -Message "As of Shlink 2.4.0, this endpoint is deprecated. New tags are automatically created when you specify them in the -Tags parameter with New-ShlinkUrl. At some point, this function may be removed from PSShlink."
    }
}

function New-ShlinkUrl {
    <#
    .SYNOPSIS
        Creates a new Shlink short code on your Shlink server.
    .DESCRIPTION
        Creates a new Shlink short code on your Shlink server.
    .PARAMETER LongUrl
        Define the long URL for the new short code.
    .PARAMETER CustomSlug
        Define a custom slug for the new short code.
    .PARAMETER Tags
        Associate tag(s) with the new short code.
    .PARAMETER ValidSince
        Define a "valid since" date with the new short code.
    .PARAMETER ValidUntil
        Define a "valid until" date with the new short code.
    .PARAMETER MaxVisits
        Set the maximum number of visits allowed for the new short code.
    .PARAMETER Title
        Define a title with the new short code.
    .PARAMETER Domain
        Associate a domain with the new short code to be something other than the default domain.
        This is useful if your Shlink instance is responding/creating short URLs for multiple domains.
    .PARAMETER ShortCodeLength
        Set the length of your new short code other than the default.
    .PARAMETER FindIfExists
        Specify this switch to first search and return the data about an existing short code that uses the same long URL if one exists.
    .PARAMETER ValidateUrl
        Control long URL validation while creating the short code.
    .PARAMETER ForwardQuery
        Forwards UTM query parameters to the long URL if any were passed to the short URL.
    .PARAMETER Crawlable
        Set short URLs as crawlable, making them be listed in the robots.txt as Allowed.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> New-ShlinkUrl -LongUrl "https://google.com"
        
        Will generate a new short code with the long URL of "https://google.com", using your Shlink server's default for creating new short codes, and return all the information about the new short code.
    .EXAMPLE
        PS C:\> New-ShlinkUrl -LongUrl "https://google.com" -CustomSlug "mygoogle" -Tags "search-engine" -ValidSince (Get-Date "2020-11-01") -ValidUntil (Get-Date "2020-11-30") -MaxVisits 99 -FindIfExists
    
        Will generate a new short code with the long URL of "https://google.com" using the custom slug "search-engine". The default domain for the Shlink server will be used. The link will only be valid for November 2020. The link will only work for 99 visits. If a duplicate short code is found using the same long URL, another is not made and instead data about the existing short code is returned.
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$LongUrl,

        [Parameter()]
        [String]$CustomSlug,

        [Parameter()]
        [String[]]$Tags,

        [Parameter()]
        [datetime]$ValidSince,

        [Parameter()]
        [datetime]$ValidUntil,

        [Parameter()]
        [Int]$MaxVisits,

        [Parameter()]
        [String]$Title,

        [Parameter()]
        [String]$Domain,

        [Parameter()]
        [Int]$ShortCodeLength,

        [Parameter()]
        [Bool]$FindIfExists,

        [Parameter()]
        [Bool]$ValidateUrl,

        [Parameter()]
        [Bool]$ForwardQuery,

        [Parameter()]
        [Bool]$Crawlable,

        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )

    try {
        GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
    }
    catch {
        Write-Error -ErrorRecord $_ -ErrorAction "Stop"
    }

    $Params = @{
        Endpoint    = "short-urls"
        Method      = "POST"
        Body        = @{
            longUrl      = $LongUrl
        }
        ErrorAction = "Stop"
    }

    switch ($PSBoundParameters.Keys) {
        "CustomSlug" {
            $Params["Body"]["customSlug"] = $CustomSlug
        }
        "Tags" {
            $Params["Body"]["tags"] = @($Tags)
        }
        "ValidSince" {
            $Params["Body"]["validSince"] = (Get-Date $ValidSince -Format "yyyy-MM-ddTHH:mm:sszzzz")
        }
        "ValidUntil" {
            $Params["Body"]["validUntil"] = (Get-Date $ValidUntil -Format "yyyy-MM-ddTHH:mm:sszzzz")
        }
        "MaxVisits" {
            $Params["Body"]["maxVisits"] = $MaxVisits
        }
        "Domain" {
            $Params["Body"]["domain"] = $Domain
        }
        "Title" {
            $Params["Body"]["title"] = $Title
        }
        "ShortCodeLength" {
            $Params["Body"]["shortCodeLength"] = $ShortCodeLength
        }
        "FindIfExists" {
            $Params["Body"]["findIfExists"] = $FindIfExists
        }
        "ValidateUrl" {
            $Params["Body"]["validateUrl"] = $ValidateUrl
        }
        "ForwardQuery" {
            $Params["Body"]["forwardQuery"] = $ForwardQuery
        }
        "Crawlable" {
            $Params["Body"]["crawlable"] = $Crawlable
        }
    }

    try {
        InvokeShlinkRestMethod @Params
    }
    catch {
        Write-Error -ErrorRecord $_
    }
}

function Remove-ShlinkTag {
    <#
    .SYNOPSIS
        Remove a tag from an existing Shlink server.
    .DESCRIPTION
        Remove a tag from an existing Shlink server.
    .PARAMETER Tags
        Name(s) of the tag(s) you want to remove.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Remove-ShlinkTag -Tags "oldwebsite" -WhatIf
        
        Reports what would happen if the command was invoked, because the -WhatIf parameter is present.
    .EXAMPLE
        PS C:\> Remove-ShlinkTag -Tags "oldwebsite", "newwebsite"

        Removes the following tags from the Shlink server: "oldwebsite", "newwebsite"
    .EXAMPLE
        PS C:\> "tag1","tag2" | Remove-ShlinkTag

        Removes "tag1" and "tag2" from your Shlink instance.
    .EXAMPLE
        PS C:\> Get-ShlinkUrl -ShortCode "profile" | Remove-ShlinkTag

        Removes all the tags which are associated with the short code "profile" from the Shlink instance.
    .INPUTS
        System.String[]

        Used for the -Tags parameter.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [String[]]$Tags,
        
        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )
    begin {
        try {
            GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
        }
        catch {
            Write-Error -ErrorRecord $_ -ErrorAction "Stop"
        }

        # Gather all tags and check if any of the user's desired tag(s) to delete
        # are currently an existing tag within the process / for loop later.
        # This is because the REST API does not produce any kind of feedback if the
        # user attempts to delete a tag which does not exist.
        $AllTags = Get-ShlinkTags
    }
    process {
        $QueryString = [System.Web.HttpUtility]::ParseQueryString('')
        
        foreach ($Tag in $Tags) {
            if ($AllTags.tag -notcontains $Tag) {
                $WriteErrorSplat = @{
                    Message      = "Tag '{0}' does not exist on Shlink server '{1}'" -f $Tag, $Script:ShlinkServer
                    Category     = "ObjectNotFound"
                    TargetObject = $Tag
                }
                Write-Error @WriteErrorSplat
                continue
            }
            else {
                $QueryString.Add("tags[]", $Tag)
            }

            $Params = @{
                Endpoint    = "tags"
                Method      = "DELETE"
                Query       = $QueryString
                ErrorAction = "Stop"
            }
    
            if ($PSCmdlet.ShouldProcess(
                ("Would delete tag '{0}' from Shlink server '{1}'" -f ([String]::Join("', '", $Tags)), $Script:ShlinkServer),
                "Are you sure you want to continue?",
                ("Removing tag '{0}' from Shlink server '{1}'" -f ([String]::Join("', '", $Tags)), $Script:ShlinkServer))) {
                    try {
                        InvokeShlinkRestMethod @Params
                    }
                    catch {
                        Write-Error -ErrorRecord $_
                    }
                }
        }
    }
    end {
    }
}

function Remove-ShlinkUrl {
    <#
    .SYNOPSIS
        Removes a short code from the Shlink server
    .DESCRIPTION
        Removes a short code from the Shlink server
    .PARAMETER ShortCode
        The name of the short code you wish to remove from the Shlink server.
    .PARAMETER Domain
        The domain associated with the short code you wish to remove from the Shlink server.
        This is useful if your Shlink instance is responding/creating short URLs for multiple domains.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Remove-ShlinkUrl -ShortCode "profile" -WhatIf
        
        Reports what would happen if the command was invoked, because the -WhatIf parameter is present.
    .EXAMPLE
        PS C:\> Remove-ShlinkUrl -ShortCode "profile" -Domain "example.com"

        Removes the short code "profile" associated with the domain "example.com" from the Shlink server.
    .EXAMPLE
        PS C:\> Get-ShlinkUrl -SearchTerm "oldwebsite" | Remove-ShlinkUrl

        Removes all existing short codes which match the search term "oldwebsite".
    .EXAMPLE
        PS C:\> "profile", "house" | Remove-ShlinkUrl

        Removes the short codes "profile" and "house" from the Shlink instance.
    .INPUTS
        System.String[]

        Used for the -ShortCode parameter.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [String[]]$ShortCode,

        [Parameter()]
        [String]$Domain,

        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )
    begin {
        try {
            GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
        }
        catch {
            Write-Error -ErrorRecord $_ -ErrorAction "Stop"
        }
    }
    process {
        foreach ($Code in $ShortCode) {
            $Params = @{
                Endpoint    = "short-urls"
                Path        = $Code
                Method      = "DELETE"
                ErrorAction = "Stop"
            }

            $WouldMessage = "Would delete short code '{0}' from Shlink server '{1}'" -f $Code, $Script:ShlinkServer
            $RemovingMessage = "Removing short code '{0}' from Shlink server '{1}'" -f $Code, $Script:ShlinkServer
            
            if ($PSBoundParameters.ContainsKey("Domain")) {
                $QueryString = [System.Web.HttpUtility]::ParseQueryString('')
                $QueryString.Add("domain", $Domain)
                $Params["Query"] = $QueryString
                
                $WouldMessage = $WouldMessage -replace "from Shlink server", ("for domain '{0}'" -f $Domain)
                $RemovingMessage = $RemovingMessage -replace "from Shlink server", ("for domain '{0}'" -f $Domain)
            }

            if ($PSCmdlet.ShouldProcess(
                $WouldMessage,
                "Are you sure you want to continue?",
                $RemovingMessage)) {
                    try {
                        $null = InvokeShlinkRestMethod @Params
                    }
                    catch {
                        Write-Error -ErrorRecord $_
                    }
            }
        }
    }
    end {
    }
}

function Save-ShlinkUrlQrCode {
    <#
    .SYNOPSIS
        Save a QR code to disk for a short code.
    .DESCRIPTION
        Save a QR code to disk for a short code.
        The default size of images is 300x300 and the default file type is png.
        The default folder for files to be saved to is $HOME\Downloads. The naming convention for the saved files is as follows: ShlinkQRCode_<shortCode>_<domain>_<size>.<format>
    .PARAMETER ShortCode
        The name of the short code you wish to create a QR code with.
    .PARAMETER Domain
        The domain which is associated with the short code you wish to create a QR code with.
        This is useful if your Shlink instance is responding/creating short URLs for multiple domains.
    .PARAMETER Path
        The path where you would like the save the QR code.
        If omitted, the default is the Downloads directory of the runner user's $Home environment variable.
        If the directory doesn't exist, it will be created.
    .PARAMETER Size
        Specify the pixel width you want for your generated shortcodes. The same value will be applied to the height.
        If omitted, the default configuration of your Shlink server is used.
    .PARAMETER Format
        Specify whether you would like your QR codes to save as .png or .svg files.
        If omitted, the default configuration of your Shlink server is used.
    .PARAMETER Margin
        Specify the margin/whitespace around the QR code image in pixels.
        If omitted, the default configuration of your Shlink server is used.
    .PARAMETER ErrorCorrection
        Specify the level of error correction you would like in the QR code.
        Choose from L for low, M for medium, Q for quartile, or H for high.
        If omitted, the default configuration of your Shlink server is used.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Save-ShlinkUrlQrCode -ShortCode "profile" -Domain "example.com" -Size 1000 -Format svg -Path "C:\temp"
        
        Saves a QR code to disk in C:\temp named "ShlinkQRCode_profile_example-com_1000.svg". It will be saved as 1000x1000 pixels and of SVG type.
    .EXAMPLE
        PS C:\> Get-ShlinkUrl -SearchTerm "someword" | Save-ShlinkUrlQrCode -Path "C:\temp"

        Saves QR codes for all short URLs returned by the Get-ShlinkUrl call. All files will be saved as the default values for size (300x300) and type (png). All files will be saved in "C:\temp" using the normal naming convention for file names, as detailed in the description.
    .INPUTS
        System.Management.Automation.PSObject[]

        Expects PSObjects with PSTypeName of 'PSTypeName', typically from Get-ShlinkUrl.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName="InputObject")]
        [PSTypeName('PSShlink')]
        [PSCustomObject[]]$InputObject,

        [Parameter(Mandatory, ParameterSetName="SpecifyProperties")]
        [String]$ShortCode,

        [Parameter(ParameterSetName="SpecifyProperties")]
        [String]$Domain,

        [Parameter()]
        [String]$Path = "{0}\Downloads" -f $home,
        
        [Parameter()]
        [Int]$Size,

        [Parameter()]
        [ValidateSet("png","svg")]
        [String]$Format,

        [Parameter()]
        [Int]$Margin,

        [Parameter()]
        [ValidateSet("L", "M", "Q", "H")]
        [String]$ErrorCorrection,

        [Parameter(ParameterSetName="SpecifyProperties")]
        [String]$ShlinkServer,

        [Parameter(ParameterSetName="SpecifyProperties")]
        [SecureString]$ShlinkApiKey
    )
    begin {
        $QueryString = [System.Web.HttpUtility]::ParseQueryString('')

        switch ($PSBoundParameters.Keys) {
            "Size" {
                $QueryString.Add("size", $Size)
            }
            "Format" {
                $QueryString.Add("format", $Format)
            }
            "Margin" {
                $QueryString.Add("margin", $Margin)
            }
            "ErrorCorrection" {
                $QueryString.Add("errorCorrection", $ErrorCorrection)
            }
        }

        if ($PSCmdlet.ParameterSetName -ne "InputObject") {
            $Params = @{ 
                ShortCode    = $ShortCode
                ShlinkServer = $ShlinkServer
                ShlinkApiKey = $ShlinkApiKey
                ErrorAction  = "Stop"
            }

            if ($PSBoundParameters.ContainsKey("Domain")) {
                $Params["Domain"] = $Domain
            }

            # Force result to be scalar, otherwise it returns as a collection of 1 element.
            # Thanks to Chris Dent for this being a big "ah-ha!" momemnt for me, especially
            # when piping stuff to Get-Member
            try {
                $Object = Get-ShlinkUrl @Params | ForEach-Object { $_ }
            }
            catch {
                Write-Error -ErrorRecord $_
            }

            if ([String]::IsNullOrWhiteSpace($Object.Domain)) {
                # We can safely assume the ShlinkServer variable will be set due to the Get-ShlinkUrl call
                # i.e. if it is not, then Get-ShlinkUrl will prompt the user for it and therefore set the variable
                $Object.Domain = [Uri]$Script:ShlinkServer | Select-Object -ExpandProperty "Host"
            }

            $InputObject = $Object
        }

        if (-not (Test-Path $Path)) {
            $null = New-Item -Path $Path -ItemType Directory -ErrorAction "Stop"
        }
    }
    process {
        foreach ($Object in $InputObject) {
            if ([String]::IsNullOrWhiteSpace($Object.Domain)) {
                $Object.Domain = [Uri]$Script:ShlinkServer | Select-Object -ExpandProperty "Host"
            }

            $Params = @{
                Uri         = "{0}/qr-code?{1}" -f $Object.ShortUrl, $QueryString.ToString()
                ErrorAction = "Stop"
            }

            try {
                $Result = Invoke-WebRequest @Params
            }
            catch {
                Write-Error -ErrorRecord $_
                continue
            }

            $FileType = [Regex]::Match($Result.Headers."Content-Type", "^image\/(\w+)").Groups[1].Value
            $FileName = "{0}\ShlinkQRCode_{1}_{2}.{3}" -f $Path, $Object.ShortCode, ($Object.Domain -replace "\.", "-"), $FileType

            if ($PSBoundParameters.ContainsKey("Size")) {
                $FileName = $FileName -replace "\.$FileType", "_$Size.$FileType"
            }

            $Params = @{
                Path        = $FileName
                Value       = $Result.Content
                ErrorAction = "Stop"
            }

            # Non-svg formats are returned from web servers as a byte array
            # Set-Content also changed to accepting byte array via -Encoding parameters after PS6+, so this is for back compatibility with Windows PS.
            if ($Result.Content -is [System.Byte[]]) {
                if ($PSVersionTable.PSVersion -ge [System.Version]"6.0") {
                    $Params["AsByteStream"] = $true
                }
                else {
                    $Params["Encoding"] = "Byte"
                }
            }

            try {
                Set-Content @Params
            }
            catch {
                Write-Error -ErrorRecord $_
                continue
            }
        }
    }
    end {
    }
}

function Set-ShlinkDomainRedirects {
    <#
    .SYNOPSIS
        Sets the URLs that you want a visitor to get redirected to for "not found" URLs for a specific domain.
    .DESCRIPTION
        Sets the URLs that you want a visitor to get redirected to for "not found" URLs for a specific domain.
    .PARAMETER Domain
        The domain (excluding schema) in which you would like to modify the redirects of. For example, "example.com" is an acceptable value.
    .PARAMETER BaseUrlRedirect
        Modify the 'BaseUrlRedirect' redirect setting of the domain.
    .PARAMETER Regular404Redirect
        Modify the 'Regular404Redirect' redirect setting of the domain.
    .PARAMETER InvalidShortUrlRedirect
        Modify the 'InvalidShortUrlRedirect' redirect setting of the domain.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Set-ShlinkDomainRedirects -Domain "example.com" -BaseUrlRedirect "https://someotheraddress.com"
        
        Modifies the redirect setting 'BaseUrlRedirect' of example.com to redirect to "https://someotheraddress.com".
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding(DefaultParameterSetName="BaseUrlRedirect")]
    param (
        [Parameter(Mandatory)]
        [String]$Domain,

        [Parameter(ParameterSetName="BaseUrlRedirect", Mandatory)]
        [Parameter(ParameterSetName="Regular404Redirect")]
        [Parameter(ParameterSetName="InvalidShortUrlRedirect")]
        [String]$BaseUrlRedirect,

        [Parameter(ParameterSetName="BaseUrlRedirect")]
        [Parameter(ParameterSetName="Regular404Redirect", Mandatory)]
        [Parameter(ParameterSetName="InvalidShortUrlRedirect")]
        [String]$Regular404Redirect,

        [Parameter(ParameterSetName="BaseUrlRedirect")]
        [Parameter(ParameterSetName="Regular404Redirect")]
        [Parameter(ParameterSetName="InvalidShortUrlRedirect", Mandatory)]
        [String]$InvalidShortUrlRedirect,

        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )

    try {
        GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
    }
    catch {
        Write-Error -ErrorRecord $_ -ErrorAction "Stop"
    }

    $Body = @{
        domain = $Domain
    }

    switch ($PSBoundParameters.Keys) {
        "BaseUrlRedirect" {
            $Body["baseUrlRedirect"] = $BaseUrlRedirect
        }
        "Regular404Redirect" {
            $Body["regular404Redirect"] = $Regular404Redirect
        }
        "InvalidShortUrlRedirect" {
            $Body["invalidShortUrlRedirect"] = $InvalidShortUrlRedirect
        }
    }

    $Params = @{
        Endpoint    = "domains"
        Path        = "redirects"
        Method      = "PATCH"
        Body        = $Body
        ErrorAction = "Stop"
    }

    try {
        InvokeShlinkRestMethod @Params
    }
    catch {
        Write-Error -ErrorRecord $_
    }
}

function Set-ShlinkTag {
    <#
    .SYNOPSIS
        Renames an existing tag to a new value on the Shlink server.
    .DESCRIPTION
        Renames an existing tag to a new value on the Shlink server.
    .PARAMETER OldTagName
        The name of the old tag you want to change the name of.
    .PARAMETER NewTagName
        The name fo the new tag you want to the new name to be.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Set-ShlinkTag -OldTagName "oldwebsite" -NewTagName "veryoldwebsite"
        
        Updates the tag with the name "oldwebsite" to have a new name of "veryoldwebsite".
    .INPUTS
        This function does not accept pipeline input.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$OldTagName,

        [Parameter(Mandatory)]
        [String]$NewTagName,

        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )

    try {
        GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
    }
    catch {
        Write-Error -ErrorRecord $_ -ErrorAction "Stop"
    }

    $Params = @{
        Endpoint    = "tags"
        Method      = "PUT"
        Body        = @{
            oldName = $OldTagName
            newName = $NewTagName
        }
        ErrorAction = "Stop"
    }

    try {
        InvokeShlinkRestMethod @Params
    }
    catch {
        Write-Error -ErrorRecord $_
    }
}

function Set-ShlinkUrl {
    <#
    .SYNOPSIS
        Update an existing short code on the Shlink server.
    .DESCRIPTION
        Update an existing short code on the Shlink server.
    .PARAMETER ShortCode
        The name of the short code you wish to update.
    .PARAMETER LongUrl
        The new long URL to associate with the existing short code.
    .PARAMETER Tags
        The name of one or more tags to associate with the existing short code.
        Due to the architecture of Shlink's REST API, this parameter can only be used in its own parameter set.
    .PARAMETER ValidSince
        Define a new "valid since" date with the existing short code.
    .PARAMETER ValidUntil
        Define a new "valid until" date with the existing short code.
    .PARAMETER MaxVisits
        Set a new maximum visits threshold for the existing short code.
    .PARAMETER Domain
        The domain which is associated with the short code you wish to update.
        This is useful if your Shlink instance is responding/creating short URLs for multiple domains.
    .PARAMETER Title
        Define a title with the new short code.
    .PARAMETER ValidateUrl
        Control long URL validation while creating the short code.
    .PARAMETER ForwardQuery
        Forwards UTM query parameters to the long URL if any were passed to the short URL.
    .PARAMETER Crawlable
        Set short URLs as crawlable, making them be listed in the robots.txt as Allowed.
    .PARAMETER ShlinkServer
        The URL of your Shlink server (including schema). For example "https://example.com".
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .PARAMETER ShlinkApiKey
        A SecureString object of your Shlink server's API key.
        It is not required to use this parameter for every use of this function. When it is used once for any of the functions in the PSShlink module, its value is retained throughout the life of the PowerShell session and its value is only accessible within the module's scope.
    .EXAMPLE
        PS C:\> Set-ShlinkUrl -ShortCode "profile" -LongUrl "https://github.com/codaamok" -ValidSince (Get-Date "2020-11-01") -ValidUntil (Get-Date "2020-11-30") -MaxVisits 99
        
        Update the existing short code "profile", associated with the default domain of the Shlink server, to point to URL "https://github.com/codaamok". The link will only be valid for November 2020. The link will only work for 99 visits.
    .EXAMPLE
        PS C:\> Set-ShlinkUrl -ShortCode "profile" -Tags "powershell","pwsh"

        Update the existing short code "profile" to have the tags "powershell" and "pwsh" associated with it.
    .EXAMPLE
        PS C:\> Get-ShlinkUrl -SearchTerm "preview" | Set-ShlinkUrl -Tags "preview"

        Updates all existing short codes which match the search term "preview" to have the tag "preview".
    .INPUTS
        System.String[]

        Used for the -ShortCode parameter.
    .OUTPUTS
        System.Management.Automation.PSObject
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [String[]]$ShortCode,

        [Parameter()]
        [String]$LongUrl,

        [Parameter()]
        [String[]]$Tags,

        [Parameter()]
        [datetime]$ValidSince,

        [Parameter()]
        [datetime]$ValidUntil,

        [Parameter()]
        [Int]$MaxVisits,

        [Parameter()]
        [String]$Title,

        [Parameter(ValueFromPipelineByPropertyName)]
        [String]$Domain,

        [Parameter()]
        [Bool]$ValidateUrl,

        [Parameter()]
        [Bool]$ForwardQuery,

        [Parameter()]
        [Bool]$Crawlable,

        [Parameter()]
        [String]$ShlinkServer,

        [Parameter()]
        [SecureString]$ShlinkApiKey
    )
    begin {
        try {
            GetShlinkConnection -Server $ShlinkServer -ApiKey $ShlinkApiKey
        }
        catch {
            Write-Error -ErrorRecord $_ -ErrorAction "Stop"
        }
    }
    process {
        $QueryString = [System.Web.HttpUtility]::ParseQueryString('')

        $Params = @{
            Endpoint = "short-urls"
            Method = "PATCH"
            Body = @{}
        }

        foreach ($Code in $ShortCode) {
            $Params["Path"] = $Code

            switch($PSBoundParameters.Keys) {
                "LongUrl" {
                    $Params["Body"]["longUrl"] = $LongUrl
                }
                "Tags" {
                    $Params["Body"]["tags"] = @($Tags)
                }
                "ValidSince" {
                    $Params["Body"]["validSince"] = Get-Date $ValidSince -Format "yyyy-MM-ddTHH:mm:sszzzz"
                }
                "ValidUntil" {
                    $Params["Body"]["validUntil"] = Get-Date $ValidUntil -Format "yyyy-MM-ddTHH:mm:sszzzz"
                }
                "MaxVisits" {
                    $Params["Body"]["maxVisits"] = $MaxVisits
                }
                "Title" {
                    $Params["Body"]["title"] = $Title
                }
                "Domain" {
                    # An additional null check here, and not as a validate parameter attribute, because I wanted it to be simple
                    # to pipe to Set-ShlinkUrl where some objects have a populated, or null, domain property.
                    # The domain property is blank for short codes if they were created to use the Shlink instance's default domain.
                    # They are also most commonly blank on Shlink instances where there are no additional domains responding / listening.
                    if (-not [String]::IsNullOrWhiteSpace($Domain)) {
                        $QueryString.Add("domain", $Domain)
                    }
                }
                "ValidateUrl" {
                    $Params["Body"]["validateUrl"] = $ValidateUrl
                }
                "ForwardQuery" {
                    $Params["Body"]["forwardQuery"] = $ForwardQuery
                }
                "Crawlable" {
                    $Params["Body"]["crawlable"] = $Crawlable
                }
            }

            $Params["Query"] = $QueryString

            try {
                InvokeShlinkRestMethod @Params
            }
            catch {
                Write-Error -ErrorRecord $_
            }
        }
    }
    end {
    }
}
#endregion