Adds audio upload buttons to workout page in Garmin Connect
当前为
// ==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();
})();