Garmin Connect: Upload Audio Notes to Workout

Adds audio upload buttons to workout page in Garmin Connect

您需要先安裝使用者腳本管理器擴展,如 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/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=garmin.com
// @grant        window.onurlchange
// @license      MIT
// @version      1.12.1
// ==/UserScript==

(function () {
    'use strict';

    // https://connect.garmin.com/modern/workout/WORKOUTID

    // Garmin API:

    const workoutEndpoint = 'https://connect.garmin.com/workout-service/workout';
    const audioNoteUploadEndpoint = 'https://connect.garmin.com/workout-service/workout/audionote/upload';

    // get workout id as a string. it's actually an integer but that doesn't
    // matter since we're only using it in urls
    function getWorkoutId() {
        const id = location.pathname.split('/').pop() 
        if (/^\d+$/.test(id)) {
            return id;
        }
        throw new Error("could not determine workout ID");
    }

    async function apiGetCurrentWorkout() {
        const url = `${workoutEndpoint}/${getWorkoutId()}?includeAudioNotes=true&_=${Date.now()}`;
        return fetchRequest('fetch workout data', url);
    }

    function apiPutCurrentWorkout(workoutData) {
        const url = `${workoutEndpoint}/${getWorkoutId()}`;
        const payload = JSON.stringify(workoutData);
        return fetchRequest('update workout', url, 'PUT', payload, true);
    }

    async function apiPostAudioNote(formData) {
        return fetchRequest('upload audio note', audioNoteUploadEndpoint, 'POST', formData, false,
            (response, defaultMsg) => {
                let msg = defaultMsg;
                if (response.status === 500) {
                    msg += ".\n\nTry again with a valid audio file that's supported by Garmin (e.g. MP3)"
                }
                throw new Error(msg);
            }
        )
    }

    async function apiDeleteAudioNote(uuid) {
        const url = `https://connect.garmin.com/workout-service/workout/audionote?audiouuid=${uuid}`;
        return fetchRequest('delete audio note', url, 'DELETE');
    }

    // ====================================================

    const sendButtonSelector = 'span a.send-to-device';
    const sendButtonAlternativeSelector = '#headerBtnRightState-readonly button';
    const editButtonId = 'headerLeftBtn';

    let addButton;
    let oldAddButton;
    let alreadyHere = false

    let tasks = [];

    const workoutConfirmMsg = 'Workout audio note already exists. Do you want to replace it?';
    const stepConfirmMsg = 'Step audio note already exists. Do you want to replace it?'
    
    oneTimeInit();
    
    function oneTimeInit() {
        initStyles();
        waitForUrl();
    }

    function waitForUrl() {
        // if (window.onurlchange == null) {
            // feature is supported
            window.addEventListener('urlchange', onUrlChange);
        // }
        onUrlChange();
    }

    function onUrlChange() {
        const urlMatches = window.location.href.startsWith('https://connect.garmin.com/modern/workout/');
        if (!alreadyHere) {
            if (urlMatches) {
                alreadyHere = true;
                init();
            }
        } else {
            if (!urlMatches) {
                alreadyHere = false;
                deinit();
            }        
        }
    }

    function init() {
        tasks = [];
        tasks.push(waitForElement(`${sendButtonSelector}, ${sendButtonAlternativeSelector}`, addUploadButtons));
    }

    function deinit() {
        hideSpinner();
        tasks.forEach(task => task.stop());
        tasks = [];
    }

    function waitForElement(readySelector, callback) {
        let timer = undefined;

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

        const stop = function () {
            clearTimeout(timer);
            timer = undefined;
        }

        tryNow();
        return {
            stop
        }
    }

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

    function initStyles() {
        // spinner
        addStyle(`
            .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);}
            }
        `);

        // 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);
            }
        `);
    }

    /** 
     * 
     * @param description {string}
     * @param url {string}
     * @param method {string}
     * @param requestIsJson {boolean|undefined}
     * 
     * **/

    async function fetchRequest(description, url, method, data, requestIsJson, customServerErrorHandler) {
        const localStoredToken = window.localStorage.getItem("token");
        const token = JSON.parse(localStoredToken).access_token;
    
        const headers = new Headers();
        if (requestIsJson) {
            headers.append("Content-Type", "application/json");
        }
        headers.append("NK", "NT");
        headers.append("Di-Backend", "connectapi.garmin.com");
        headers.append("Authorization", `Bearer ${token}`);

        let response;
        try {
            response = await fetch(url, {
                method: method || 'GET',
                headers: headers,
                body: data || null,
            })
        } catch (error) {
            throw new Error(`failed to ${description}. ${error}`);
        }
        if (!response.ok) {
            const defaultMsg = `failed to ${description}. Server returned ${formatResponseError(response)}`;
            if (customServerErrorHandler) {
                customServerErrorHandler(response, defaultMsg);
            } else {
                throw new Error(defaultMsg);
            }
        }
        return response;
    }

    function formatResponseError(response) {
        if (!response.statusText) {
            return `${response.status} error`;
        }
        return `${response.status} ${response.statusText}`;
    }

    let needReload = false;
    function success(msg, forceReload) {
        alert(msg);
        (forceReload || needReload) && window.location.reload();
    }

    function error(msg, forceReload) {
        alert(msg);
        (forceReload || needReload) && window.location.reload();
    }

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

        const wrapper = createElement('div', 'audio-upload-spinner');
        const element = createElement('div', 'audio-upload-spinner-inner');
        wrapper.appendChild(element);
        document.body.appendChild(wrapper);
    }

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

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

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

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

        const steps = document.querySelectorAll("[data-step-id]");
        for (let i = 0; i < steps.length; i++) {
            const c = steps[i].getAttribute('class');
            // exclude "steps" which are actually repeat groups
            if (c.includes('WorkoutStep')) {
                createButton(steps[i], i);
            }
        }

        haveUploadButtons = true;

        const editButton = document.getElementById(editButtonId);
        const sendToDeviceButton = document.getElementById('headerBtnRightState-edit');
        const saveWorkoutButton = document.getElementById('headerBtnRightState-readonly');
        const doneButton = document.getElementById('headerBtnRightState-saved');
        const headerRightButtonToUse = sendToDeviceButton || saveWorkoutButton || doneButton;

        if (editButton && headerRightButtonToUse) {
            // responsive way which has a bunch of assumptions
            headerRightButtonToUse.addEventListener('click', doneButtonClickHandler);
            editButton.addEventListener('click', editButtonClickHandler);
        } else {
            // slow way
            if (!isWatchingAddButton) {
                tasks.push(watchAddButton());
            }
        }
    }

    function editButtonClickHandler() {
        setTimeout(() => {
            let _sendToDeviceButton = document.getElementById('headerBtnRightState-edit');
            if (_sendToDeviceButton) {
                haveUploadButtons && deleteUploadButtons();
                haveUploadButtons = false;
            } else {
                !haveUploadButtons && addUploadButtons();
                haveUploadButtons = true;
            }
        }, 0);
    }

    function doneButtonClickHandler() {
        // cheaderBtnRightState-readonly => Send To Device
        // cheaderBtnRightState-edit => Save Workout
        // cheaderBtnRightState-saved => Done
        if (this.id === 'headerBtnRightState-saved') {
            setTimeout(() => {
                !haveUploadButtons && addUploadButtons();
                haveUploadButtons = true;
            }, 0);
        }
    }

    let haveUploadButtons = false;
    let isWatchingAddButton = false;
    function watchAddButton() {
        if (isWatchingAddButton) {
            return;
        }
        isWatchingAddButton = true;
        let timer = undefined;
        const tryNow = function () {
            const addButton = document.querySelector(sendButtonSelector);
            const oldAddButton = document.querySelector(sendButtonAlternativeSelector);

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

        const stop = function () {
            isWatchingAddButton = false;
            clearTimeout(timer);
            timer = undefined;
        }

        tryNow();
        return {
            stop
        };
    }

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

    function isEditing() {
        // guess
        const addButton = document.querySelectorAll(sendButtonSelector);
        const 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 createElement(tagName, additionalClasses) {
        var el = document.createElement(tagName);
        el.setAttribute('class', `garmin-upload-audio-button ${additionalClasses || ''}`);
        return el;
    }

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

    // index: null for workout, 0...n-1 for steps
    function createButton(parentNode, index) {
        const stepOrder = index == null ? null : index + 1;
        const idSuffix = stepOrder ? `-step-order-${stepOrder}` : '-workout';

        const stepNode = index == null ? null : parentNode;
        let audioNoteExists = false;
        if (stepNode) {
            audioNoteExists = !!stepNode.querySelector('[class*="AudioRecorder"]');
        } else {
            audioNoteExists = !!document.querySelector('[class*="WorkoutPageRightNav"] [class*="AudioRecorder"]');
        }
        
        const shareButtonWrapper = createElement('div')
        if (stepOrder == null) {
            shareButtonWrapper.setAttribute('style', 'background-color: white');
        } else {
            shareButtonWrapper.setAttribute('style', 'background-color: white; margin-left: auto');
        }

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

        const shareIcon = createElement('i', 'icon-plus');
        shareIcon.setAttribute('style', 'padding-right: 4px');

        const shareText = createElement('span');
        shareText.innerHTML = 'Audio';

        shareButtonWrapper.appendChild(shareButton);
        shareButton.appendChild(shareIcon);
        shareButton.appendChild(shareText);

        const input = createElement("input");
        input.setAttribute('type', "file");
        input.setAttribute('name', "file");
        input.setAttribute('id', `garmin-upload-audio-input${idSuffix}`);
        input.setAttribute('style', "display:none");

        const form = createElement("form");
        form.setAttribute('style', 'display:none');
        form.appendChild(input);
        
        let confirmedOverwrite = false;

        shareButton.addEventListener('click', function () {
            if (isEditing()) {
                error("You appear to be editing this workout. Save or discard your changes before uploading audio notes.");
            } else {
                if (audioNoteExists) {
                    if (!confirm(stepOrder == null ? workoutConfirmMsg : stepConfirmMsg)) {
                        return;
                    }
                    confirmedOverwrite = true;
                }
                input.click();
            }
        })

        input.addEventListener('change', function () {
            const file = this.files[0];
            if (file) {
                showSpinner();
                uploadAudioNote(form, stepOrder, confirmedOverwrite).then(
                    (result) => {
                        input.value = '';
                        hideSpinner();
                        if (result) {
                            setTimeout(
                                () => success('Audio note successfully added to workout!', true),
                                50
                            );
                        }
                    },
                    (e) => {
                        input.value = '';
                        hideSpinner();
                        setTimeout(() => error(e), 50);
                    }
                )
            } 
        })

        if (stepOrder == null) {
            // parentNode.parentNode.insertBefore(shareButtonWrapper, parentNode.nextSibling);

            // insert button to the left of main buttons (edit workout, send to device)
            parentNode.parentNode.insertBefore(shareButtonWrapper, parentNode.parentNode.firstChild);
            parentNode.parentNode.insertBefore(form, parentNode.nextSibling);
        } else {
            // parentNode.appendChild(shareButtonWrapper)

            // insert right-justified button next to step details (e.g. total distance, estimated timee)
            const lastChild = parentNode.childNodes[parentNode.childNodes.length-1];
            const lastChildLastChild = lastChild.childNodes[lastChild.childNodes.length-1];
            if (lastChildLastChild.getAttribute('class').includes('stepNote')) {
                lastChild.insertBefore(shareButtonWrapper, lastChildLastChild);
            } else {
                lastChild.appendChild(shareButtonWrapper);
            }

            // form/input field have to go outside of the step container, otherwise 
            // the input button won't work (clicks have no effect)
            parentNode.parentNode.insertBefore(form, parentNode.nextSibling);
        }
    }


    // returns true if upload succeeded, false if upload was cancelled (without fatal error)
    async function uploadAudioNote(form, stepOrder, confirmedOverwrite) {
        // get existing workout data
        const workoutData = await (await apiGetCurrentWorkout()).json();

        let step = null;
        if (stepOrder != null) {
            step = findStep(workoutData, stepOrder);
            if (step == null) {
                throw new Error("unable to find current step in workout data");
            }
        }

        // determine if audio note already exists; if so, it needs to be deleted before
        // we can upload a new one
        let audioNoteToDelete = undefined;
        if (step == null) {
            if (workoutData.workoutAudioNoteUuid) {
                if (confirmedOverwrite || confirm(workoutConfirmMsg)) {
                    audioNoteToDelete = workoutData.workoutAudioNoteUuid;
                } else {
                    return false;
                }
            }
        } else {
            if (step.stepAudioNoteUuid) {
                if (confirmedOverwrite || confirm(stepConfirmMsg)) {
                    audioNoteToDelete = step.stepAudioNoteUuid;
                } else {
                    return false;
                }
            }
        }

        // upload new audio note
        const formData = new FormData(form);
        const audioMetadata = await (await apiPostAudioNote(formData)).json();

        // delete existing audio note (order is important)
        if (audioNoteToDelete) {
            await apiDeleteAudioNote(audioNoteToDelete);
        }

        // update workout data
        if (step == null) {
            workoutData.workoutAudioNote = audioMetadata;
            workoutData.workoutAudioNoteUuid = audioMetadata.audioNoteUuid;
        } else {
            step.stepAudioNote = audioMetadata;
            step.stepAudioNoteUuid = audioMetadata.audioNoteUuid;
        }

        // save workout data

        // if saving the workout fails, then we might be in an undefined state
        // (especially if we deleted the existing note), so it's better if we
        // reload the page. But then again, reloading the page won't
        // fix anything...
        // e.g.
        // - the workout may still have a reference to a now-deleted audio note
        // - we may have created a new audio note which is not referenced anywhere

        // needReload = true;
        
        await apiPutCurrentWorkout(workoutData);
        return true;
    }

    // step containers in the dom have data-step-id values (and no other elements have this),
    // but unfortunately these values don't match what's in the workout JSON.
    // However, the 1-based stepOrder field in the JSON always matches the order in the
    // DOM. This is true even when the steps aren't a flat list,
    // which occurs when some of the step containers are actually repeat groups
    // (which means they have no data of their own except child steps)
    function findStep(workoutData, stepOrder) {
        for (let i = 0; i < workoutData.workoutSegments.length; i++) {
            const segment = workoutData.workoutSegments[i];
            const step = findStepInSegment(segment, stepOrder);
            if (step) return step;
        }
        return null;
    }

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