Garmin Connect: Upload Audio Notes to Workout

Adds audio upload buttons to workout page in Garmin Connect

目前為 2025-08-15 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Garmin Connect: Upload Audio Notes to Workout
// @namespace    http://tampermonkey.net/
// @description  Adds audio upload buttons to workout page in Garmin Connect
// @author       flowstate
// @match        https://connect.garmin.com/modern/workout/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=garmin.com
// @grant        none
// @license      MIT
// @version      1.5
// ==/UserScript==

(function () {
    'use strict';

    function ajaxRequest(method, url, data, setTypeAsJson, onload, onerror) {
        let garminVersion = document.getElementById('garmin-connect-version');
        let xhr = new XMLHttpRequest();

        xhr.onload = onload || noop
        xhr.onerror = onerror || noop

        let localStoredToken = window.localStorage.getItem("token");
        let accessTokenMap = JSON.parse(localStoredToken);
        let token = accessTokenMap.access_token;

        xhr.open(method, url, true);
        if (setTypeAsJson) {
            xhr.setRequestHeader("Content-Type", "application/json");
        }
        xhr.setRequestHeader("Accept", "application/json, text/javascript, */*; q=0.01");

        xhr.setRequestHeader("x-app-ver", garminVersion.innerText || '4.27.1.0');
        xhr.setRequestHeader("x-requested-with", "XMLHttpRequest");
        xhr.setRequestHeader("x-lang", "it-IT");
        xhr.setRequestHeader("nk", "NT");
        xhr.setRequestHeader("Di-Backend", "connectapi.garmin.com");
        xhr.setRequestHeader("authorization", "Bearer " + token);
        xhr.withCredentials = true;

        xhr.send(data);
    }

    function prepareButton(button, id, name) {
        if (name) button.text = name;
        button.removeAttribute('data-target');
        button.removeAttribute('data-toggle');
        button.style.marginLeft = '3px';
        button.setAttribute('class', 'btn btn-medium garmin-upload-audio-button');
        button.setAttribute('id', 'garmin-upload-audio-button' + id);
        return button;
    }

    function getElement(el) {
        var x = document.createElement(el)
        x.setAttribute('class', 'garmin-upload-audio-button')
        return x;
    }

    function noop() {}

    function addStyle(styleString) {
        const style = document.createElement('style');
        style.textContent = styleString;
        document.head.append(style);
    }

    function initSpinner() {
    var style = `
.audio-upload-spinner {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    background-color: black;
    opacity: 0.5;
    z-index: 9999998
}

.audio-upload-spinner-inner {
    position: absolute;
    left: 50%;
    top: 50%;
    height:60px;
    width:60px;
    margin:0px auto;
    -webkit-animation: rotation .6s infinite linear;
    -moz-animation: rotation .6s infinite linear;
    -o-animation: rotation .6s infinite linear;
    animation: rotation .6s infinite linear;
    border-left:6px solid rgba(0,174,239,.15);
    border-right:6px solid rgba(0,174,239,.15);
    border-bottom:6px solid rgba(0,174,239,.15);
    border-top:6px solid rgba(0,174,239,.8);
    border-radius:100%;
    z-index: 9999999
}

@-webkit-keyframes rotation {
    from {-webkit-transform: rotate(0deg);}
    to {-webkit-transform: rotate(359deg);}
}
@-moz-keyframes rotation {
    from {-moz-transform: rotate(0deg);}
    to {-moz-transform: rotate(359deg);}
}
@-o-keyframes rotation {
    from {-o-transform: rotate(0deg);}
    to {-o-transform: rotate(359deg);}
}
@keyframes rotation {
    from {transform: rotate(0deg);}
to {transform: rotate(359deg);}
    }
`
        addStyle(style);

        // tooltip
        addStyle(`
[data-customTooltip]{
    cursor: pointer;
    position: relative;
}

[data-customTooltip]::after {
    // background-color: #fff;
    // color: #222;
    background-color: #222;
    color: #fff;
    font-size:14px;
    padding: 8px 12px;
    height: fit-content;
    width: fit-content;
    text-wrap: nowrap;
    border-radius: 6px;
    position: absolute;
    text-align: center;
    bottom: -5px;
    left: 50%;
    content: attr(data-customTooltip);
    transform: translate(-50%, 110%) scale(0);
    transform-origin: top;
    transition: 0.14s;
    box-shadow: 0 4px 14px 0 rgba(0,0,0,.2), 0 0 0 1px rgba(0,0,0,.05);
    z-index: 9999999
  }
[data-customTooltip]:hover:after {
    display: block;
    transform: translate(-50%, 110%) scale(1);
  }

            `)
    }

    function showSpinner() {
        if (document.querySelector('.audio-upload-spinner')) {
            return
        }
        var body = document.body;

        var wrapper = getElement('div')
        wrapper.setAttribute('class', 'audio-upload-spinner garmin-upload-audio-button')

        var element = getElement('div')
        element.setAttribute('class', 'audio-upload-spinner-inner garmin-upload-audio-button')

        wrapper.appendChild(element);
        body.appendChild(wrapper);
    }

    function hideSpinner() {
        let element = document.querySelector('.audio-upload-spinner')
        if (element) {
            element.remove()
        }
    }

    function error(msg, forceReload) {
        hideSpinner();
        setTimeout(() => {alert(msg); forceReload && window.location.reload()}, 50)
        
        
    }
    function success(msg, forceReload) {
        hideSpinner();
        setTimeout(() => {alert(msg); forceReload && window.location.reload();}, 50)
    }

    function createButton(parentNode, index) {
        let shareButton;
        if (addButton.length > 0) {
            shareButton = addButton[0].cloneNode(true);
        } else if (oldAddButton.length > 0) {
            shareButton = document.createElement("a")
        }
        
        let stepOrder = index == null ? null : index + 1;
        var idSuffix = stepOrder ? `-step-order-${stepOrder}` : '-workout'
        
        let shareButtonWrapper = getElement('div')
        if (stepOrder == null) {
            shareButtonWrapper.setAttribute('style', 'background-color: white');
        } else {
            shareButtonWrapper.setAttribute('style', 'background-color: white; margin-left: auto');
        }
        shareButton = prepareButton(shareButton, idSuffix);
        shareButton.setAttribute('data-customTooltip', 'Upload audio note from MP3 file')
        let margin = '';
        if (index != null) {
            margin = 'margin-left: 10px; margin-bottom: 10px; margin-right: 10px'
        }
        shareButton.setAttribute('style', 'background-color: transparent; color: var(--color-primary); padding: 8px 8px 8px 8px;' + margin);

        let shareIcon = getElement('i');
        shareIcon.setAttribute('class', 'icon-plus')
        shareIcon.setAttribute('style', 'padding-right: 4px')
        let shareText = getElement('span');
        shareText.innerHTML = 'Audio'
        shareButton.appendChild(shareIcon)
        shareButton.appendChild(shareText)

        shareButtonWrapper.appendChild(shareButton)

        let input = document.createElement("input")
        let inputId = 'garmin-upload-audio-input' + idSuffix
        input.setAttribute('type', "file")
        input.setAttribute('name', "file")
        input.setAttribute('id', inputId);
        input.setAttribute('class', 'garmin-upload-audio-button');
        input.setAttribute('style', "display:none;pointer-events:auto") 

        let form = document.createElement("form")
        form.setAttribute('class', 'garmin-upload-audio-button');
        form.setAttribute('style', 'display:none');
        form.appendChild(input);

        function submitForm() {
            const url = 'https://connect.garmin.com/workout-service/workout/audionote/upload'

            try {
                const formData = new FormData(form);
                ajaxRequest("POST", url, formData, false, formOnLoad, formOnError)
            } catch (error) {
                // console.error(error);
                error("Error uploading audio " + error)
            }
        }

        function formOnLoad() {
            if (this.status > 299) {
                error(`Error uploading audio: ${this.status} ${this.statusText}`)
                return
            }

            let response = this.responseText;
            let data = JSON.parse(response)
            addNoteToWorkout(data, stepOrder)
        }

        function formOnError() {
            error(`error uploading audio`)
        }

        input.addEventListener('change', function () {
            let file = this.files[0];
            if (!file) {
                // error("Please select a file")
            } else {
                showSpinner()
                submitForm()
            }
        })

        shareButton.addEventListener('click', function () {
            if (isEditing()) {
                error("You appear to be editing this workout. Save or discard your changes before uploading audio notes.")
                return
            }
            document.querySelector(`#${inputId}`).click()
        })

        if (stepOrder == null) {
            // parentNode.parentNode.insertBefore(shareButtonWrapper, parentNode.nextSibling);
            parentNode.parentNode.insertBefore(shareButtonWrapper, parentNode.parentNode.firstChild);
            parentNode.parentNode.insertBefore(form, parentNode.nextSibling);
        } else {
            // parentNode.appendChild(shareButtonWrapper)
            let lastChild = parentNode.childNodes[parentNode.childNodes.length-1]
            let lastChildLastChild = lastChild.childNodes[lastChild.childNodes.length-1]
            if (lastChildLastChild.getAttribute('class').includes('stepNote')) {
                lastChild.insertBefore(shareButtonWrapper, lastChildLastChild);
            } else {
                lastChild.appendChild(shareButtonWrapper)
            }

            parentNode.parentNode.insertBefore(form, parentNode.nextSibling);
        }
    }

    function isHidden(el) {
        return (el.offsetParent === null)
    }

    function isEditing() {
        // guess
        let addButton = document.querySelectorAll(sendButtonSelector);
        let oldAddButton = document.querySelectorAll(sendButtonAlternativeSelector);

        if (addButton.length == 0 && oldAddButton.length == 0) {
            return true
        }

        if (addButton.length > 0) {
            return isHidden(addButton[0])
        }
        if (oldAddButton.length > 0) {
            return isHidden(oldAddButton[0])
        }

        return false;
    }

    function deleteUploadButtons() {
        let existingButtons = document.querySelectorAll('.garmin-upload-audio-button')
        existingButtons.forEach(e => e.remove())
    }

    function connectWorkoutAddAudioNoteUpload() {
        deleteUploadButtons() 
        if (addButton.length == 0 && oldAddButton.length == 0) {
            error("Could not find Send to Device button");
            return;
        }

        // TODO fixme
        let _addButton = document.querySelector(sendButtonSelector);
        let _oldAddButton = document.querySelector(sendButtonAlternativeSelector);
        let buttonToUse = _addButton || _oldAddButton;
        if (buttonToUse) {
            let parentNode = buttonToUse.parentNode
            createButton(parentNode, null)
        }

        let steps = document.querySelectorAll("[data-step-id]");
        for (let i = 0; i < steps.length; i++) {
            let c = steps[i].getAttribute('class')
            if (c.includes('WorkoutStep')) {
                createButton(steps[i], i)
            }
        }
        haveUploadButtons = true
        watchAddButtons()
    }

    let haveUploadButtons = false
    function watchAddButtons() {
        const tryNow = function () {
            let addButton = document.querySelector(sendButtonSelector);
            let oldAddButton = document.querySelector(sendButtonAlternativeSelector);

            let buttonToUse = addButton || oldAddButton;
            if (haveUploadButtons && (!buttonToUse || (buttonToUse && isHidden(buttonToUse)))) {
                deleteUploadButtons()
                haveUploadButtons = false
            } else if (!haveUploadButtons && buttonToUse && !isHidden(buttonToUse)) {
                connectWorkoutAddAudioNoteUpload()
            }
            setTimeout(tryNow, 300);
        };
        tryNow();
    }


    function addNoteToWorkout(audioMetadata, stepOrder) {
        let workoutID = document.URL.split('/').slice(-1).pop();
        let queryString = '?includeAudioNotes=true&_=' + Date.now();
        let workoutMatchID = workoutID.match(new RegExp('^([0-9]+)'))
        if (workoutMatchID.length > 0) {
            let workoutId = workoutMatchID[0]
            ajaxRequest(
                'GET',
                workoutEndpoint + workoutId + queryString,
                null,
                false,
                function () {
                    addNoteToWorkout2(audioMetadata, stepOrder, this, workoutId)
                },
                function () {
                    error("Error fetching workout data")
                }
            );
        } else {
            error("Error: could not determine workout ID")
        }
    }

    function findStep2(segmentOrStep, stepOrder) {
        for (let j = 0; j < segmentOrStep.workoutSteps.length; j++) {
            let step = segmentOrStep.workoutSteps[j]
            if (step.stepOrder === stepOrder) {
                return step
            }
            if (step.workoutSteps) {
                let matchingStep = findStep2(step, stepOrder)
                if (matchingStep) {
                    return matchingStep
                }
            }
        }
        return null;
    }

    function findStep(workoutData, stepOrder) {
        for (let i = 0; i < workoutData.workoutSegments.length; i++) {
            let segment = workoutData.workoutSegments[i]
            let step = findStep2(segment, stepOrder)
            if (step) {
                return step;
            }
        }
        return null
    }

    function addNoteToWorkout2(audioMetadata, stepOrder, xhr, workoutId) {
        if (xhr.status > 299) {
            error(`Error fetching workout data: ${this.status} ${this.statusText}`)
            return;
        }

        let workoutData = JSON.parse(xhr.responseText);
        console.log(stepOrder)
        if (stepOrder) {
            let step = findStep(workoutData, stepOrder)
            if (step) {
                let callback = () => {
                    step.stepAudioNote = audioMetadata
                    step.stepAudioNoteUuid = audioMetadata.audioNoteUuid
                    addNoteToWorkout3(workoutData, workoutId)
                }
                if (step.stepAudioNoteUuid) {
                    deleteAudioNote(step.stepAudioNoteUuid, callback)
                } else {
                    callback()
                }
                return
            }
            error("ERROR: unable to find workout step to add audio note")
            return
        } else {
            let callback = () => {
                workoutData.workoutAudioNote = audioMetadata
                workoutData.workoutAudioNoteUuid = audioMetadata.audioNoteUuid
                addNoteToWorkout3(workoutData, workoutId)
            }
            if (workoutData.workoutAudioNoteUuid) {
                deleteAudioNote(workoutData.workoutAudioNoteUuid, callback)
            } else {
                callback()
            }
            return
        }
    }

    function deleteAudioNote(uuid, callback) {
        const url = `https://connect.garmin.com/workout-service/workout/audionote?audiouuid=${uuid}`
        try {
            ajaxRequest("DELETE", url, null, false,
                function () {
                    if (this.status > 299) {
                        console.error(`Error deleting audio note: ${this.status} ${this.statusText}`);
                    }
                    callback()
                },
                function () {
                    console.error("Error deleting audio note");
                    callback()
                })
        } catch (error) {
            console.error("Error deleting audio note");
            console.error(error);
        }
    }

    function addNoteToWorkout3(workoutData, workoutId) {
        let payload = JSON.stringify(workoutData);
        ajaxRequest(
            'PUT',
            `${workoutEndpoint}${workoutId}`,
            payload,
            true,
            function () {
                if (this.status > 299) {
                    error(`Error updating workout: ${this.status} ${this.statusText}`, true)
                    return
                }
                success('Audio note successfully added to workout!', true);
            },
            function () {
                error("Error updating workout", true)
            }
        );
    }

    let workoutEndpoint = 'https://connect.garmin.com/workout-service/workout/';
    let sendButtonSelector = 'span a.send-to-device';
    let sendButtonAlternativeSelector = '#headerBtnRightState-readonly button'
    let addButton
    let oldAddButton

    function waitForElement(readySelector, callback) {
        const tryNow = function () {
            const elem = document.querySelector(readySelector);
            if (elem) {
                addButton = document.querySelectorAll(sendButtonSelector);
                oldAddButton = document.querySelectorAll(sendButtonAlternativeSelector);
                initSpinner()
                callback(elem);
            } else {
                setTimeout(tryNow, 300);
            }
        };
        tryNow();
    }

    function init() {
        waitForElement(`${sendButtonSelector}, ${sendButtonAlternativeSelector}`, connectWorkoutAddAudioNoteUpload);
    }
    init();
})();