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