Public/Send-FitFileToGarminConnect.ps1

<#
.SYNOPSIS
Uploads FIT files to Garmin Connect and moves uploaded files to an archive folder.
 
.DESCRIPTION
`Send-FitFileToGarminConnect` uploads `.fit` files to the Garmin Connect API endpoint
`/upload-service/upload` directly from PowerShell - either all files of an import directory
or the files given with `-Path`.
 
Authentication uses the DI OAuth2 token store written by `Get-GarminToken`
(`gctoken.json`). If no token exists yet, `Get-GarminToken` is started automatically.
An access token that expires within 15 minutes is refreshed automatically and saved back.
 
The files are uploaded in alphabetical order. A failed upload does not stop the
remaining uploads; every file is reported with its own status:
- Uploaded: Garmin Connect accepted the file.
- Duplicate: Garmin Connect already contains this data (HTTP 409).
- Failed: The upload failed; `Message` contains the reason.
 
To avoid uploading the same files again, files with the status `Uploaded` or `Duplicate`
are moved to the subfolder `Uploaded` (for example `FitFiles\Uploaded`). Failed files stay
in place, so the next run retries them. An existing file with the same name in the
`Uploaded` folder is replaced. Use `-KeepFiles` to leave all files where they are.
 
Returns one result object per file with these properties:
- File: Name of the FIT file.
- Status: `Uploaded`, `Duplicate` or `Failed`.
- Message: Error message or Garmin import message, empty when uploaded.
- MovedTo: Full path in the `Uploaded` folder, empty when the file was not moved.
 
.PARAMETER ImportDirectory
Path to the folder containing `.fit` files. Subfolders (including `Uploaded`) are not searched.
If omitted, the module default FIT folder is used (`$env:USERPROFILE\GCCare\FitFiles`).
 
.PARAMETER Path
One or more FIT files to upload instead of a whole import directory.
Uploaded files are moved to the `Uploaded` subfolder of their own folder.
 
.PARAMETER UploadedDirectory
Optional folder for uploaded files. Defaults to the subfolder `Uploaded` of the folder the
file comes from. The folder is created when needed.
 
.PARAMETER KeepFiles
Optional switch to keep uploaded files in place instead of moving them.
 
.PARAMETER TokenStore
Optional path to the Garmin Connect token store directory or `gctoken.json` file.
Defaults to `$env:GARMINTOKENS`, then `~\.garminconnect`.
 
.PARAMETER EnableLogging
Optional switch to enable module logging behavior (if supported by module logging helpers).
 
.OUTPUTS
System.Management.Automation.PSCustomObject
Returns one result object (`File`, `Status`, `Message`, `MovedTo`) per FIT file.
Returns nothing when there are no new FIT files to upload.
Throws when the import directory or a file given with `-Path` does not exist.
 
.NOTES
- Requires helper functions in module scope:
  `Get-FunctionName`, `Write-Log`, `Invoke-Output`, `Get-RunTime`, `Invoke-GarminConnectApi`.
- FIT files can be created with `Convert-TanitaExportToFitFile`.
 
.EXAMPLE
Send-FitFileToGarminConnect
 
Uploads all new FIT files from $env:USERPROFILE\GCCare\FitFiles and moves them to
$env:USERPROFILE\GCCare\FitFiles\Uploaded.
 
.EXAMPLE
Send-FitFileToGarminConnect -ImportDirectory "C:\Data\Garmin\Fit"
 
Uploads all FIT files from the specified folder and moves them to C:\Data\Garmin\Fit\Uploaded.
 
.EXAMPLE
Send-FitFileToGarminConnect -Path "$env:USERPROFILE\GCCare\FitFiles\weight_2026-09-30_06-47.fit"
 
Uploads a single FIT file.
 
.EXAMPLE
Send-FitFileToGarminConnect -KeepFiles
 
Uploads all FIT files from the default folder without moving them.
 
.EXAMPLE
Send-FitFileToGarminConnect | Where-Object Status -eq 'Failed'
 
Uploads all new FIT files and shows only the files that could not be uploaded.
#>

Function Send-FitFileToGarminConnect {

    [CmdletBinding(DefaultParameterSetName = 'Directory')]
    param(
        [Parameter(Position = 0, ParameterSetName = 'Directory')]
        [string]$ImportDirectory = $Script:DefaultFitFilesFolder,

        [Parameter(Mandatory = $true, ParameterSetName = 'Files')]
        [string[]]$Path,

        [string]$UploadedDirectory,
        [switch]$KeepFiles,
        [string]$TokenStore,
        [switch]$EnableLogging
    )

    $CurrentFunction = Get-FunctionName
    Write-Log -Message "### Start Function $CurrentFunction ###"
    $StartRunTime = (Get-Date).ToString($Script:DateFormatLog)
    #################### main code | out- host #####################

    Invoke-Output -Type Header -Message "Uploading FIT files to Garmin Connect..."

    if ($PSCmdlet.ParameterSetName -eq 'Files') {
        $fitFiles = @(foreach ($file in $Path) {
                if (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
                    throw "FIT file not found: $file"
                }
                Get-Item -LiteralPath $file
            }) | Sort-Object -Property Name
        $fitFiles = @($fitFiles)
        Invoke-Output -Type Bullet -Message "FIT files given: " -TextMaker $fitFiles.Count
    }
    else {
        if (-not $ImportDirectory) {
            $ImportDirectory = $Script:DefaultFitFilesFolder
        }
        $ImportDirectory = [System.IO.Path]::GetFullPath($ImportDirectory)

        if (-not (Test-Path -LiteralPath $ImportDirectory -PathType Container)) {
            throw "Import directory not found: $ImportDirectory"
        }

        $fitFiles = @(Get-ChildItem -LiteralPath $ImportDirectory -Filter '*.fit' -File -ErrorAction SilentlyContinue | Sort-Object -Property Name)

        Invoke-Output -Type Bullet -Message "Import directory: " -TextMaker $ImportDirectory -NoExtraLines
        Invoke-Output -Type Bullet -Message "FIT files found: " -TextMaker $fitFiles.Count
    }

    if ($fitFiles.Count -eq 0) {
        Invoke-Output -Type Info -Message "No new FIT files to upload."
        Write-Log -Message " >> No FIT files found"
        $runtime = Get-RunTime -StartRunTime $StartRunTime
        Write-Log -Message " Run Time: $runtime [h] ###"
        Write-Log -Message "### End Function $CurrentFunction ###"
        return
    }

    $results = foreach ($fitFile in $fitFiles) {
        $status = 'Uploaded'
        $message = ''
        $movedTo = ''

        try {
            $response = Invoke-GarminConnectApi -Path '/upload-service/upload' -Method Post -Form @{ file = $fitFile } -TokenStore $TokenStore

            # Garmin reports rejected files inside the import result
            $failures = @($response.detailedImportResult.failures | Where-Object { $_ })
            if ($failures.Count -gt 0) {
                $status = 'Failed'
                $message = (@($failures.messages.content | Where-Object { $_ }) -join '; ')
                if ($message -match '(?i)duplicate') {
                    $status = 'Duplicate'
                }
            }
        }
        catch {
            $message = $_.Exception.Message
            $status = if ($message -match '\(409\)') { 'Duplicate' } else { 'Failed' }
        }

        # Uploaded and duplicate files are already in Garmin Connect: archive them
        if ($status -ne 'Failed' -and -not $KeepFiles) {
            try {
                $targetDirectory = if ($UploadedDirectory) { [System.IO.Path]::GetFullPath($UploadedDirectory) } else { Join-Path -Path $fitFile.DirectoryName -ChildPath 'Uploaded' }
                if (-not (Test-Path -LiteralPath $targetDirectory -PathType Container)) {
                    $null = New-Item -Path $targetDirectory -ItemType Directory -Force
                }
                $movedTo = Join-Path -Path $targetDirectory -ChildPath $fitFile.Name
                Move-Item -LiteralPath $fitFile.FullName -Destination $movedTo -Force
            }
            catch {
                $movedTo = ''
                $message = "Uploaded, but could not be moved: $($_.Exception.Message)"
            }
        }

        switch ($status) {
            'Uploaded' { Invoke-Output -Type Bullet -Message "Uploaded: " -TextMaker $fitFile.Name -NoExtraLines }
            'Duplicate' { Invoke-Output -Type Bullet -Message "Duplicate: " -TextMaker $fitFile.Name -NoExtraLines }
            'Failed' { Invoke-Output -Type Missing -Message "Failed: " -TextMaker "$($fitFile.Name) - $message" -NoExtraLines }
        }
        Write-Log -Message " >> $status : $($fitFile.FullName) $message $movedTo"

        [pscustomobject]@{
            File    = $fitFile.Name
            Status  = $status
            Message = $message
            MovedTo = $movedTo
        }
    }

    $uploadedCount = @($results | Where-Object Status -eq 'Uploaded').Count
    $duplicateCount = @($results | Where-Object Status -eq 'Duplicate').Count
    $failedCount = @($results | Where-Object Status -eq 'Failed').Count
    $movedCount = @($results | Where-Object MovedTo).Count

    Write-Host
    Invoke-Output -Type Bullet -Message "Uploaded: " -TextMaker $uploadedCount -NoExtraLines
    Invoke-Output -Type Bullet -Message "Duplicates: " -TextMaker $duplicateCount -NoExtraLines
    Invoke-Output -Type Bullet -Message "Failed: " -TextMaker $failedCount -NoExtraLines
    if ($movedCount -gt 0) {
        Invoke-Output -Type Bullet -Message "Moved to: " -TextMaker (Split-Path -Path ($results | Where-Object MovedTo | Select-Object -First 1).MovedTo -Parent)
    }
    else {
        Write-Host
    }

    if ($failedCount -gt 0) {
        Invoke-Output -Type Warning -Message "$failedCount of $($fitFiles.Count) FIT files could not be uploaded."
    }
    else {
        Invoke-Output -Type Success -Message "FIT upload completed successfully."
    }
    ######################## main code ############################
    $runtime = Get-RunTime -StartRunTime $StartRunTime
    Write-Log -Message " Run Time: $runtime [h] ###"
    Write-Log -Message "### End Function $CurrentFunction ###"

    return $results
}