Garmin Connect: Upload Audio Notes to Workout

Adds audio upload buttons to workout page in Garmin Connect

当前为 2025-08-14 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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.0
// ==/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;
    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%;
}

@-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);
  }
[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 element = getElement('div')
        element.setAttribute('class', 'audio-upload-spinner garmin-upload-audio-button')
        body.appendChild(element);
    }

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

    function error(msg) {
        hideSpinner();
        setTimeout(() => {alert(msg); window.location.reload()}, 50)
        
        
    }
    function success(msg) {
        hideSpinner();
        setTimeout(() => {alert(msg); 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')
        // shareButtonWrapper.setAttribute('style', 'background-color: white; display: flex; justify-content: flex-end');
        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: rgb(205, 205, 205); color: black; padding: 8px 8px 8px 8px;' + margin);
        // shareButton.setAttribute('style', 'background-color: transparent; border-style: groove; border-color: rgb(205, 205, 205); color: black; padding: 8px 8px 8px 8px;' + margin);
        // shareButton.setAttribute('style', 'background-color: transparent; color: black; padding: 8px 8px 8px 8px;' + margin);
        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);
        // if (stepOrder) {
        //     var hr = getElement('hr')
        //     hr.style = "height:3px;border:none;color:#333;background-color:#333;"
        //     form.appendChild(hr);
        // }

        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")
                return
            }
            if (file) {
                var reader = new FileReader();
                reader.readAsText(file, "UTF-8");
                reader.onload = function (evt) {
                    showSpinner()
                    submitForm()
                }
                reader.onerror = function (evt) {
                    error("Error reading file")
                }
            }
        })

        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}`)
                    return
                }
                success('Audio note successfully added to workout!\n\nPress OK to refresh this window');
            },
            function () {
                error("Error updating workout")
            }
        );
    }

    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();
})();