NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @id https://github.com/kvr000/zbynek-strava-util/ZbynekStravaSegmentInfo/ // @name Zbynek Strava Segment Info // @namespace https://github.com/kvr000/zbynek-strava-util/ // @description Strava - Enhance segment list with detailed segment information, applies to activity page, segment you're looking for page and segment detail page. // @author Zbynek Vyskovsky, kvr000@gmail.com https://github.com/kvr000/ // @copyright 2020+, Zbynek Vyskovsky,kvr000@gmail.com (https://github.com/kvr000/zbynek-strava-util/) // @license Apache-2.0 // @homepage https://github.com/kvr000/zbynek-strava-util/ // @homepageURL https://github.com/kvr000/zbynek-strava-util/ // @downloadURL https://raw.githubusercontent.com/kvr000/zbynek-strava-util/master/ZbynekStravaSegmentInfo/ZbynekStravaSegmentInfo.user.js // @updateURL https://raw.githubusercontent.com/kvr000/zbynek-strava-util/master/ZbynekStravaSegmentInfo/ZbynekStravaSegmentInfo.user.js // @supportURL https://github.com/kvr000/zbynek-strava-util/issues/ // @contributionURL https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=J778VRUGJRZRG&item_name=Support+features+development.¤cy_code=CAD&source=url // @version 1.1.3 // @include https://www.strava.com/activities/*/potential-segment-matches // @include http://www.strava.com/activities/*/potential-segment-matches // @include https://strava.com/activities/*/potential-segment-matches // @include http://strava.com/activities/*/potential-segment-matches // @include http://strava.com/segments/* // @include https://strava.com/segments/* // @include http://www.strava.com/segments/* // @include https://www.strava.com/segments/* // @include https://www.strava.com/activities/* // @include http://www.strava.com/activities/* // @include https://strava.com/activities/* // @include http://strava.com/activities/* // @grant GM_log // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setClipboard // @run-at document-idle // ==/UserScript== /*jshint loopfunc:true */ (function() { 'use strict'; const $ = unsafeWindow.jQuery; class Js { static undefinedElse(value, defaultValue) { return value === undefined ? defaultValue : value; } static undefinedElseGet(value, supplier) { return value === undefined ? supplier() : value; } static undefinedElseThrow(value, exceptionSupplier) { if (value === undefined) throw exceptionSupplier(); return value; } static nullElse(value, defaultValue) { return value == null ? defaultValue : value; } static nullElseGet(value, supplier) { return value == null ? supplier() : value; } static nullElseThrow(value, exceptionSupplier) { if (value == null) throw exceptionSupplier(); return value; } static objGetElse(obj, key, defaultValue) { return key in obj ? obj[key] : defaultValue; } static objGetElseGet(obj, key, supplier) { return key in obj ? obj[key] : supplier(key); } static objGetElseThrow(obj, key, exceptionSupplier) { if (key in obj) return obj[key]; throw exceptionSupplier(key); } static strEmptyToNull(str) { return str === "" ? null : str; } static strValueToNull(nullvalue, str) { return str === nullvalue ? null : str; } static strNullToEmpty(str) { return str === "" ? null : str; } static regexValueToNull(regex, str) { return str == null || regex.test(str) ? null : str; } static objMap(obj, mapper) { return obj == null ? null : mapper(obj); } static escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } } class GmAjaxService { execute(method, url, options = null, data = null) { return new Promise((resolve, reject) => { try { const fullOptions = Object.assign( { method, url, }, options || {}, { onload: (response) => response.status == 200 ? resolve(response.responseText) : reject("Failed "+method+" "+url+" : "+response.status+" "+response.statusText), onerror: reject, ontimeout: reject, } ); GM_xmlhttpRequest(fullOptions); } catch (err) { reject(err); } }); } executeTemplate(method, urlTemplate, placeholders, options = null, data = null) { const url = this.convertTemplate(urlTemplate, placeholders); return this.execute(method, url, options, data); } get(url, options = null) { return this.execute("GET", url, options); } getTemplate(urlTemplate, placeholders, options = null) { return this.executeTemplate("GET", urlTemplate, placeholders, options); } convertTemplate(urlTemplate, placeholders) { return urlTemplate.replace(/{([^}]+)}/g, (full, group1) => encodeURIComponent(Js.objGetElseThrow(placeholders, group1, (group1) => new Error("Undefined placeholder: "+group1)))); } } class HtmlWrapper { constructor(doc) { this.doc = doc; } evaluate(...args) { return this.doc.evaluate(...args); } needXpathNode(xpath, start) { let node; if ((node = this.doc.evaluate(xpath, start, null, XPathResult.FIRST_ORDERED_NODE_TYPE).singleNodeValue) != null) { return node; } throw new Error("Cannot find node: " + xpath); } listXpath(xpath, start) { const elements = []; for (let xpathOut = this.doc.evaluate(xpath, start), el = null; (el = xpathOut.iterateNext()); ) { elements.push(el); } return elements; } removeXpath(xpath, start) { this.listXpath(xpath, start).forEach((node) => node.remove()); } insertAfter(inserted, before) { before.parentNode.insertBefore(inserted, before.nextSibling); } insertMultiBefore(inserted, after) { inserted.forEach((e) => after.parentElement.insertBefore(e, after)); } insertMultiAfter(inserted, before) { let last = before; inserted.forEach((e) => { this.insertAfter(e, before); before = e; }); } appendMulti(inserted, parentElement) { inserted.forEach((e) => parentElement.appendChild(e)); } childElementPosition(child) { let i = 0; for (let left = child; (left = left.previousElementSibling) != null; ++i) ; return i; } createElementEx(name, attrs, children) { const element = this.doc.createElement(name); if (attrs) { Object.getOwnPropertyNames(attrs).forEach((k) => { const v = attrs[k]; if (k === 'class') element.setAttribute(k, v); else element[k] = v; }); } if (children) { if (!Array.isArray(children)) { throw new Error("Passed non-array as children object: "+children); } children.forEach(v => element.appendChild(v)); } return element; } createElementWithText(name, attrs, text) { return this.createElementEx(name, attrs, [ this.createTextNode(text) ]); } createTextNode(text) { return this.doc.createTextNode(text); } createSelect(attrs, options, current, listener) { const optionsElements = []; $.each(options, (k, v) => optionsElements.push(v instanceof Node ? this.createElementEx("option", { value: k }, [ v ]) : this.createElementWithText("option", { value: k }, v) )); const element = this.createElementEx("select", attrs, optionsElements); element.value = current == null && attrs.emptyIsNull ? "" : String(current); element.updateListener = listener; element.onchange = (event) => { event.target.updateListener(event.target.value == "" && event.target.emptyIsNull ? null : event.target.value) }; return element; } templateElement(html, placeholders, prefix = 'pl$-') { const elements = this.templateElements(html, placeholders, prefix); if (elements.length != 1) { throw Error("Template resulted into multiple elements: ", elements); } return elements[0]; } templateElements(html, placeholders, prefix = 'pl$-') { const elements = $.parseHTML(html); for (let i = 0; i < elements.length; ++i) { let current = elements[i]; if (!(current instanceof Element)) continue; while (current != null) { if (current.localName.startsWith(prefix)) { const command = current.localName.substring(prefix.length); switch (command) { case 'text': case 'textrun': { if (current.firstChild != null) throw new Error("Replacement node contains unexpected subelements: "+current); const textName = Js.nullElseThrow(current.getAttribute("name"), () => new Error("Cannot find name attribute in element: "+current)); const providedText = Js.objGetElseThrow(placeholders, textName, () => new Error("Cannot find placeholder: "+textName)); const node = current.parentNode.insertBefore(this.doc.createTextNode(command == 'textrun' ? providedText(current, this) : providedText), current); const old = current; current = node; old.remove(); break; } case 'node': case 'noderun': { if (current.firstChild != null) throw new Error("Replacement node contains unexpected subelements: "+current); const nodeName = Js.nullElseThrow(current.getAttribute("name"), () => new Error("Cannot find name attribute in element: "+current)); const providedNode = Js.objGetElseThrow(placeholders, nodeName, () => new Error("Cannot find placeholder: "+nodeName)); const node = current.parentNode.insertBefore(command == 'noderun' ? providedNode(current, this) : providedNode, current); const old = current; current = node; old.remove(); break; } case 'if': case 'ifrun': { let trueEl; let falseEl; if (current.firstElementChild == null || current.firstElementChild.nextSibling == null || current.firstElementChild.nextSibling.nextSibling != null) { throw new Error("Expected exactly two elements of if block, true and false: "+current); } if (current.firstElementChild.localName == 'true') { trueEl = current.firstElementChild; if (trueEl.nextSibling.localName != 'false') throw new Error("Expected false block, got "+trueEl.nextSibling); falseEl = trueEl.nextSibling; } else if (current.firstElementChild.localName == 'false') { falseEl = current.firstElementChild; if (falseEl.nextSibling.localName != 'true') throw new Error("Expected false block, got "+falseEl.nextSibling); trueEl = trueEl.nextSibling; } const conditionName = Js.nullElseThrow(current.getAttribute("condition"), () => new Error("Cannot find condition attribute in element: "+current)); const condition = Js.objGetElseThrow(placeholders, conditionName, () => new Error("Cannot find placeholder: "+conditionName)); const chosen = (command == 'ifrun' ? condition(current, this) : condition) ? trueEl : falseEl; let restart = chosen.firstElementChild; while (chosen.firstChild) { const next = chosen.firstChild; current.parentNode.insertBefore(next, current); } if (restart == null) { restart = current; do { if (restart.nextElementSibling != null) { restart = restart.nextElementSibling; break; } restart = restart.parentElement; } while (restart != null); } current.remove(); current = restart; continue; } default: throw new Error("Unexpected element: "+current); } } else { if (current.attributes.length != 0) { const names = []; for (let i = 0; i < current.attributes.length; ++i) { names.push(current.attributes[i].name); } names.forEach((name) => { if (name.startsWith(prefix)) { const placeholder = current.getAttribute(name); current[name.substring(prefix.length)] = Js.objGetElseThrow(placeholders, placeholder, () => new Error("Cannot find placeholder: "+placeholder)); current.removeAttribute(name); } }); } if (current.firstElementChild != null) { current = current.firstElementChild; continue; } } do { if (current.nextElementSibling != null) { current = current.nextElementSibling; break; } current = current.parentElement; } while (current != null); } } return elements; } setVisible(element, isVisible, visibilityType = 'block') { element.style.display = isVisible ? visibilityType : 'none'; return isVisible; } } class AbstractCache { constructor(version, expiration) { this.version = version; this.expiration = expiration; this.pendingPromises = {}; } promiseIfAbsent(id, resolver) { const item = this.get(id); if (!item) { let promise = this.pendingPromises[id]; if (promise == null) { promise = this.pendingPromises[id] = resolver(id); } return promise.then( (result) => { delete this.pendingPromises[id]; this.put(id, result); return result; }, (error) => { delete this.pendingPromises[id]; throw error; } ); } return Promise.resolve(item); } } class GlobalDbStorageCache extends AbstractCache { constructor(storage, name, version, expiration, options) { super(version, expiration); this.storage = storage; this.name = name; this.writebackTimeout = Js.objGetElse(options || {}, 'writebackTimeout', 5000); this.itemsToUpdate = {}; this.pendingWrite = false; this.loadDb(); } get(id) { const item = this.cache[id]; if (item) { if (item.version == this.version && (item.expire == null || item.expire > new Date().getTime())) { return item.value; } delete this.cache[id]; this.itemsToUpdate[id] = null; this.scheduleUpdate(); } return null; } put(id, value) { this.itemsToUpdate[id] = this.cache[id] = { expire: this.expiration == null ? null : new Date().getTime()+this.expiration, version: this.version, value: value }; this.scheduleUpdate(); } dump() { return JSON.stringify(this.cache, null, "\t"); } load(dump) { this.cache = JSON.parse(dump); this.writeCache(); } scheduleUpdate() { if (!this.pendingWrite) { setTimeout(() => this.doUpdate(), this.writebackTimeout); this.pendingWrite = true; } } doUpdate() { this.loadDb(); Object.getOwnPropertyNames(this.itemsToUpdate).forEach((key) => { if (this.itemsToUpdate[key] !== null) { this.cache[key] = this.itemsToUpdate[key]; } else { delete this.cache[key]; } }); this.writeCache(); } writeCache() { this.itemsToUpdate = {}; this.storage.setItem(this.name, JSON.stringify(this.cache)); this.pendingWrite = false; } loadDb() { try { this.cache = JSON.parse(this.storage.getItem(this.name)); const time = new Date().getTime(); Object.getOwnPropertyNames(this.cache).forEach((id) => { const value = this.cache[id]; if (value.expire != null && time >= value.expire) { delete this.cache[id]; } }); } catch (err) { console.error("Error loading db: ", err); } if (!this.cache) { this.cache = {}; } } } class PerItemStorageCache extends AbstractCache { constructor(storage, name, version, expiration) { super(version, expiration); this.storage = storage; this.prefix = name+"#"; this.pendingWrite = false; } get(id) { try { const item = JSON.parse(this.storage.getItem(this.constructPath(id))); if (item) { if (item.version == this.version && (item.expire == null || item.expire > new Date().getTime())) { return item.value; } this.remove(id); } } catch (error) { } return null; } put(id, value) { this.storage.setItem( this.constructPath(id), JSON.stringify({ expire: this.expiration == null ? null : new Date().getTime()+this.expiration, version: this.version, value: value }) ); } remove(id) { this.storage.removeItem(this.constructPath(id)); } constructPath(id) { return this.prefix+id; } dump() { let db = {}; for (let i = 0; i < this.storage.length; ++i) { const key = this.storage.key(i); if (key.startsWith(this.prefix)) { db[key.substring(this.prefix.length)] = this.storage.getItem(key); } } return JSON.stringify(db, null, "\t"); } load(dump) { let db = JSON.parse(dump); Object.getOwnPropertyNames(db).forEach((key) => { this.storage.setItem(this.constructPath(key), JSON.stringify(db[key])); }); } } class CsvFormatter { separator = ","; quote = '"'; specialMatch; headerMap = null; headerIndex = null; output = ""; constructor(options) { this.separator = Js.objGetElse(options || {}, 'separator', ','); this.quote = Js.objGetElse(options || {}, 'quote', '"'); this.specialMatch = new RegExp("["+Js.escapeRegExp(this.separator)+Js.escapeRegExp(this.quote)+"]"); } setHeader(headerMap) { let i = 0; this.headerMap = headerMap; this.headerIndex = {}; let array = []; Object.getOwnPropertyNames(headerMap).forEach((key) => { this.headerIndex[key] = i; array[i] = this.headerMap[key]; i++; }); } writeHeader(headerMap) { this.setHeader(headerMap); this.writeArray(Object.getOwnPropertyNames(this.headerIndex).map((key) => this.headerMap[key])); } writeMapped(row) { if (this.headerIndex == null) { throw new Error("headerMap not provided yet"); } let array = []; Object.getOwnPropertyNames(row).forEach((key) => { let index = this.headerIndex[key]; if (index == null) { throw new Error("headerMap not provided for field: "+key); } array[index] = row[key]; }); this.writeArray(array); } writeArray(row) { this.output += row.map((item) => this.formatItem(item)).join(this.separator)+"\n"; } getOutput() { return this.output; } formatItem(item) { let str = item == null ? "" : String(item); if (this.specialMatch.test(str)) { str = this.quote+str.replace(this.quote, this.quote+this.quote)+this.quote; } return str; } } class ZbynekStravaSegmentInfoUiBase { // TODO: Some split into strict UI and general support classes would be nice, for now simple split from original single purpose UI /* constants */ static PR_MATCH = /^\s*\u21b5?\s*-?\s*((\d+:)*\d+)\s*\u21b5?\s*$/; static TIME_MATCH = /^((((\d+)d\s*)?(\d+):)?(\d+):)?(\d+)$/; static TIME_ABBR_MATCH = /^((\d+:)*\d+)(|s)(<abbr.*)?$/; static LEVELS = { "": "", 1: "L1 (Always)", 2: "L2 (Relax)", 3: "L3 (Easy)", 4: "L4 (Medium)", 5: "L5 (Difficult)", 6: "L6 (Extreme)", 7: "L7 (Local)", 8: "L8 (Pro)", 9: "L9 (Tour)", 11: "Dangerous", 12: "Short", 13: "Flagged", 14: "Rebuilt", 15: "Wrong", 16: "Uninteresting", }; static CSV_ROW_HEADER = { name: "Name", level: "Level", done: "Done", stat: "Stat", distance: "Distance", place: "Place", orientation: "Orientation", direction: "Direction", note: "Note", protect: "Protect", time: "Time", url: "Url", }; /* dependencies */ ajaxService; segmentInfoCache; segmentPreferenceDb; dwrapper; donateUrl = "https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=J778VRUGJRZRG&item_name=Support+future+development.¤cy_code=CAD&source=url"; constructor(ajaxService, segmentInfoCache, segmentPreferenceDb, documentWrapper) { this.ajaxService = ajaxService; this.segmentInfoCache = segmentInfoCache; this.segmentPreferenceDb = segmentPreferenceDb; this.dwrapper = documentWrapper; } updatePreference(segmentFull) { this.segmentPreferenceDb.put(segmentFull.segment.info.id, segmentFull.preference); } convertTimeStr(timeStr) { if (!timeStr) return null; const group = timeStr.match(ZbynekStravaSegmentInfoUiBase.TIME_MATCH); if (group == null) throw new Error("Failed to match time for: "+timeStr); return ((Number(group[4] || 0)*24+Number(group[5] || 0))*60+Number(group[6] || 0))*60+Number(group[7]); } formatDate(time) { return Js.objMap( new Date(time), (d) => d.getUTCFullYear().toFixed(0).padStart(4, "0")+"-"+(d.getUTCMonth()+1).toFixed(0).padStart(2, "0")+"-"+d.getUTCDate().toFixed(0).padStart(2, "0") ); } formatTime(time) { const sec = time%60; let rest = parseInt(time/60); let str = sec.toFixed(0); if (rest != 0) { const min = rest%60; rest = parseInt(rest/60); str = min.toFixed(0)+":"+str.padStart(2, "0"); if (rest != 0) { str = rest.toFixed(0)+":"+str.padStart(5, "0"); } } return str; } formatTimeHms(time) { const sec = time%60; let rest = parseInt(time/60); let str = sec.toFixed(0); const min = rest%60; rest = parseInt(rest/60); str = min.toFixed(0)+":"+str.padStart(2, "0"); str = rest.toFixed(0)+":"+str.padStart(5, "0"); return str; } formatLevel(level) { return ZbynekStravaSegmentInfoUiBase.LEVELS[Js.strNullToEmpty(Js.objMap(level, String))]; } writeCsvSegment(csvFormatter, segmentFull) { const segment = segmentFull.segment; const preference = segmentFull.preference; csvFormatter.writeMapped({ name: segment.info.name, level: this.formatLevel(preference.level), done: Js.objMap(segment.pr.date, this.formatDate), stat: segment.isKqom ? "KOM" : null, distance: segment.info.distance, protect: preference.protect, time: segment.pr.time != null ? this.formatTimeHms(segment.pr.time) : null, url: segment.info.url, }); } updateEffortData(segmentInfo, root) { let updated = false; const detailsContent = root.evaluate("//div[@data-react-class = 'SegmentDetailsSideBar']/@data-react-props", root, null, XPathResult.STRING_TYPE).stringValue; const needsHazardWaiver = root.evaluate("//button[@id = 'hazard-waiver']/text()", root, null, XPathResult.STRING_TYPE).stringValue ? true : false; const bestDetails = !detailsContent ? {} : [ JSON.parse(detailsContent).sideBarProps ] .flatMap(a => [ a.fastestTimes, { pr: a.viewingAthlete } ]) .flatMap(a => Object.values(a)) .filter(rec => rec.stats) .reduce((result, rec) => { return { ...result, ...rec.stats.reduce((obj, e) => { return /^(KOM|QOM|All-Time PR)$/.test(e.label) ? { ...obj, [e.label]: { time_str: e.value?.match(ZbynekStravaSegmentInfoUiBase.TIME_ABBR_MATCH)?.[1], rec: rec } } : obj }, {}), } }, {}); const prTime_str = Js.nullElseGet(Js.strEmptyToNull(root.evaluate("//div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-row-')]//*[div[text() = 'All-Time PR']]/div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-effort-')]/text()", root, null, XPathResult.STRING_TYPE).stringValue.match(ZbynekStravaSegmentInfoUiBase.PR_MATCH)?.[1]), () => Js.strEmptyToNull(bestDetails['All-Time PR']?.time_str)); const prLink = Js.nullElseGet(Js.strEmptyToNull(root.evaluate("//div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-row-') and .//div[text() = 'All-Time PR']]/div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-date-')]/a/@href", root, null, XPathResult.STRING_TYPE).stringValue), () => Js.objMap(bestDetails['All-Time PR']?.rec, (rec) => rec.activityId ? "/activities/"+rec.activityId+"#"+rec.segmentEffortId : null)); const prDate_str = Js.nullElseGet(Js.strEmptyToNull(root.evaluate("//div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-row-') and .//div[text() = 'All-Time PR']]/div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-date-')]/a/text()", root, null, XPathResult.STRING_TYPE).stringValue), () => bestDetails['All-Time PR']?.rec?.date);; const komTime_str = Js.nullElseGet(Js.strEmptyToNull(root.evaluate("//div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-row-')]//*[div[text() = 'KOM']]/div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-effort-')]/text()", root, null, XPathResult.STRING_TYPE).stringValue.match(ZbynekStravaSegmentInfoUiBase.PR_MATCH)?.[1]), () => Js.strEmptyToNull(bestDetails['KOM']?.time_str)); const komLink = Js.nullElseGet(Js.strEmptyToNull(root.evaluate("//div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-row-') and .//div[text() = 'KOM']]/div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-date-')]/a/@href", root, null, XPathResult.STRING_TYPE).stringValue), () => Js.objMap(bestDetails['KOM']?.rec, (rec) => rec.activityId ? "/activities/"+rec.activityId+"#"+rec.segmentEffortId : null)); const komDate_str = Js.nullElseGet(Js.strEmptyToNull(root.evaluate("//div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-row-') and .//div[text() = 'KOM']]/div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-date-')]/a/text()", root, null, XPathResult.STRING_TYPE).stringValue), () => bestDetails['KOM']?.rec?.date);; const qomTime_str = Js.nullElseGet(Js.strEmptyToNull(root.evaluate("//div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-row-')]//*[div[text() = 'QOM']]/div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-effort-')]/text()", root, null, XPathResult.STRING_TYPE).stringValue.match(ZbynekStravaSegmentInfoUiBase.PR_MATCH)?.[1]), () => Js.strEmptyToNull(bestDetails['QOM']?.time_str)); const qomLink = Js.nullElseGet(Js.strEmptyToNull(root.evaluate("//div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-row-') and .//div[text() = 'QOM']]/div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-date-')]/a/@href", root, null, XPathResult.STRING_TYPE).stringValue), () => Js.objMap(bestDetails['QOM']?.rec, (rec) => rec.activityId ? "/activities/"+rec.activityId+"#"+rec.segmentEffortId : null)); const qomDate_str = Js.nullElseGet(Js.strEmptyToNull(root.evaluate("//div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-row-') and .//div[text() = 'QOM']]/div[contains(concat(' ', @class), ' AvatarWithDataRow--call-out-date-')]/a/text()", root, null, XPathResult.STRING_TYPE).stringValue), () => bestDetails['QOM']?.rec?.date);; const bestTime_str = Js.strEmptyToNull(root.evaluate("//table[contains(concat(' ', @class, ' '), 'table-leaderboard')]/tbody/tr[1]/td[@class='last-child']/text()", root, null, XPathResult.STRING_TYPE, null).stringValue); const bestSpeed_str = Js.strEmptyToNull(root.evaluate("//table[contains(concat(' ', @class, ' '), 'table-leaderboard')]/tbody/tr[1]/td[abbr[text() = ' km/h']]/text()", root, null, XPathResult.STRING_TYPE, null).stringValue); const bestBpm_str = Js.strEmptyToNull(Js.regexValueToNull(/^\s*-\s*$/, root.evaluate("//table[contains(concat(' ', @class, ' '), 'table-leaderboard')]/tbody/tr[1]/td[abbr[text() = 'bpm']]/text()", root, null, XPathResult.STRING_TYPE, null).stringValue)); const bestPowerColumn = Js.objMap(root.evaluate("//table[contains(concat(' ', @class, ' '), 'table-leaderboard')]/thead/tr/th[text() = 'Power']", root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue, (node) => this.dwrapper.childElementPosition(node)); const bestPower_str = Js.objMap(bestPowerColumn, (column) => Js.strEmptyToNull(Js.regexValueToNull(/^\s*-\s*$/, root.evaluate("//table[contains(concat(' ', @class, ' '), 'table-leaderboard')]/tbody/tr[1]/td[position() = "+(column+1)+"]/text()", root, null, XPathResult.STRING_TYPE, null).stringValue))); const bestVamColumn = Js.objMap(root.evaluate("//table[contains(concat(' ', @class, ' '), 'table-leaderboard')]/thead/tr/th[text() = 'VAM']", root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue, (node) => this.dwrapper.childElementPosition(node)); const bestVam_str = Js.objMap(bestVamColumn, (column) => Js.strEmptyToNull(Js.regexValueToNull(/^\s*-\s*$/, root.evaluate("//table[contains(concat(' ', @class, ' '), 'table-leaderboard')]/tbody/tr[1]/td[position() = "+(column+1)+"]/text()", root, null, XPathResult.STRING_TYPE, null).stringValue))); const prTime = this.convertTimeStr(prTime_str); const prDate = Js.objMap(prDate_str, (str) => Date.parse(str+" UTC")); if (prTime != segmentInfo.pr.time || prLink != segmentInfo.pr.link || prDate != segmentInfo.pr.date) { segmentInfo.pr.time = prTime; segmentInfo.pr.link = prLink; segmentInfo.pr.date = prDate; updated = true; } const komTime = this.convertTimeStr(komTime_str); const komDate = Js.objMap(komDate_str, (str) => Date.parse(str+" UTC")); if (komTime != segmentInfo.kom.time || komLink != segmentInfo.kom.link || komDate != segmentInfo.kom.date) { segmentInfo.kom.time = komTime; segmentInfo.kom.link = komLink; segmentInfo.kom.date = komDate; updated = true; } const qomTime = this.convertTimeStr(qomTime_str); const qomDate = Js.objMap(qomDate_str, (str) => Date.parse(str+" UTC")); if (qomTime != segmentInfo.qom.time || qomLink != segmentInfo.qom.link || qomDate != segmentInfo.qom.date) { segmentInfo.qom.time = qomTime; segmentInfo.qom.link = qomLink; segmentInfo.qom.date = qomDate; updated = true; } const bestTime = this.convertTimeStr(bestTime_str); const bestSpeed = Js.objMap(bestSpeed_str, Number); const bestHeartRate = Js.objMap(bestBpm_str, Number); const bestPower = Js.objMap(bestPower_str, s => Number(s.replace(",", ""))); const bestVam = Js.objMap(bestVam_str, s => Number(s.replace(",", ""))); if (bestTime != segmentInfo.best.time || bestSpeed != segmentInfo.best.speed || bestHeartRate != segmentInfo.best.heartRate || bestPower != segmentInfo.best.power || bestVam != segmentInfo.best.vam) { segmentInfo.best.time = bestTime; segmentInfo.best.speed = bestSpeed; segmentInfo.best.heartRate = bestHeartRate; segmentInfo.best.power = bestPower; segmentInfo.best.vam = bestVam; updated = true; } const isKqom = prLink != null && (prLink == komLink || prLink == qomLink); if (isKqom != segmentInfo.isKqom) { segmentInfo.isKqom = isKqom; updated = true; } if (needsHazardWaiver != segmentInfo.needsHazardWaiver) { segmentInfo.needsHazardWaiver = needsHazardWaiver; updated = true; } return updated; } fetchSegmentFull(segmentId) { return Promise.all([ this.segmentPreferenceDb.promiseIfAbsent(segmentId, (segmentId) => Promise.resolve({ level: null, type: null, protect: null, })), this.segmentInfoCache.promiseIfAbsent(segmentId, (segmentId) => Promise.all([ this.ajaxService.getTemplate("/segments/{segmentId}", { segmentId }), this.ajaxService.getTemplate("/stream/segments/{segmentId}?streams%5B%5D=altitude", { segmentId }), ]) .then((responses) => { const root = new DOMParser().parseFromString(responses[0], 'text/html'); const name = Js.strEmptyToNull(root.evaluate("//div[contains(@class, 'segment-heading')]//*[@id='js-full-name']/text()", root, null, XPathResult.STRING_TYPE, null).stringValue); const distance_str = Js.strEmptyToNull(root.evaluate("//div[contains(@class, 'segment-heading')]//div[@class='stat' and span[@class='stat-subtext' and text() = 'Distance']]/*[@class='stat-text']/text()", root, null, XPathResult.STRING_TYPE, null).stringValue); const elevationDiff_str = Js.strEmptyToNull(root.evaluate("//div[contains(@class, 'segment-heading')]//div[@class='stat' and span[@class='stat-subtext' and text() = 'Elev Difference']]/*[@class='stat-text']/text()", root, null, XPathResult.STRING_TYPE, null).stringValue); const avgGrade_str = Js.strEmptyToNull(root.evaluate("//div[contains(@class, 'segment-heading')]//div[@class='stat' and span[@class='stat-subtext' and text() = 'Avg Grade']]/*[@class='stat-text']/text()", root, null, XPathResult.STRING_TYPE, null).stringValue); const route = JSON.parse(responses[1]); const distance = Js.objMap(distance_str, Number); const elevationGain = Js.objMap(route, route => route.altitude.reduce((total, current, index, array) => total+(index == 0 ? 0 : Math.max(0, current-array[index-1])), 0)); const elevationDiff = Js.nullElseGet(route.altitude.length > 0 ? route.altitude[route.altitude.length-1]-route.altitude[0] : null, () => Js.objMap(elevationDiff_str, Number)); const avgGradeStrava = Js.objMap(avgGrade_str, Number); const avgGrade = distance != null && elevationDiff != null ? elevationDiff/(distance*10) : avgGradeStrava; const segmentInfo = { info: { id: segmentId, name: name, distance: distance, avgGradeStrava: avgGradeStrava, avgGrade: avgGrade, elevationDiff: elevationDiff, elevationGain: elevationGain, url: this.ajaxService.convertTemplate("https://www.strava.com/segments/{segmentId}", { segmentId: segmentId }), }, isKqom: null, pr: { time: null, link: null, date: null, }, kom: { time: null, link: null, date: null, }, qom: { time: null, link: null, date: null, }, best: { time: null, speed: null, heartRate: null, power: null, vam: null, }, }; this.updateEffortData(segmentInfo, root); this.segmentInfoCache.put(segmentId, segmentInfo); GM_log(segmentInfo); return segmentInfo; }) ) ]) .then((segmentAll) => { return { preference: segmentAll[0], segment: segmentAll[1], }; }); } initializeStatic() { const style = ".zbynek-strava-inline-select { appearance: none; border: none; }\n"+ ".zbynek-strava-max-width { width: 100%; }\n"+ "\n"+ ".zbynek-strava-segment-info-segment { display: block; }\n"+ ".zbynek-strava-segment-info-segment > span { display: inline-block; text-align: right; padding-left: 0px; padding-right: 0px; font-weight: normal; }\n"+ ".zbynek-strava-segment-info-segment > .distance { width: 12%; text-align: right; }\n"+ ".zbynek-strava-segment-info-segment > .grade { width: 8%; text-align: right; }\n"+ ".zbynek-strava-segment-info-segment > .elevationGain { width: 8%; text-align: right; }\n"+ ".zbynek-strava-segment-info-segment > .prTime { width: 9%; text-align: right; }\n"+ ".zbynek-strava-segment-info-segment > .prKqomIndicator { width: 5%; text-align: right; }\n"+ ".zbynek-strava-segment-info-segment > .kqomTime { width: 9%; text-align: right; }\n"+ ".zbynek-strava-segment-info-segment > .kqomSpeed { width: 15%; text-align: right; }\n"+ ".zbynek-strava-segment-info-segment > .kqomPower { width: 10%; text-align: right; }\n"+ ".zbynek-strava-segment-info-segment > .hazard { width: 34%; text-align: left; }\n"+ ".zbynek-strava-segment-info-segment > .level { width: 10%; }\n"+ ".zbynek-strava-segment-info-segment > .type { width: 10%; }\n"+ ".zbynek-strava-segment-info-segment > .segmentLink { width: 4%; text-align: right; }\n"+ "\n"+ ".zbynek-strava-segment-info-filter { padding-top: 40px; padding-left: 20px; padding-right: 20px; }\n"+ ".zbynek-strava-segment-info-filter > .enablers { width: 100%; }\n"+ ".zbynek-strava-segment-info-filter > .enablers > .enabler { display: inline-block; width: 24%; }\n"+ ".zbynek-strava-segment-info-filter > .row { width: 100%; display: none; }\n"+ ".zbynek-strava-segment-info-filter > .row > .name { display: inline-block; width: 20%; }\n"+ ".zbynek-strava-segment-info-filter > .row > .content { display: inline-block; width: 80%; }\n"+ "\n"+ ".zbynek-strava-segment-info-segment-value { font-size: 14px; font-weight: none; }\n"+ ""; //GM_addStyle(style); this.dwrapper.needXpathNode("//head", this.dwrapper.doc).appendChild(this.dwrapper.createElementEx("style", { type: "text/css" }, [ this.dwrapper.createTextNode(style) ])); } } /** * UI for Potential Segment Matcher */ class ZbynekStravaSegmentInfoMatcherUi extends ZbynekStravaSegmentInfoUiBase { /* UI */ menuEl; filterEl; /* status */ filterEnabled = false; batchUpdateEnabled = false; filterFunction = () => true; constructor(ajaxService, segmentInfoCache, segmentPreferenceDb, documentWrapper) { super(ajaxService, segmentInfoCache, segmentPreferenceDb, documentWrapper); } enrichSegments() { let counter = 0; let processedCounter = 0; const segments = this.dwrapper.listXpath("//*[@id='segment-visualizer']//ul[contains(concat(' ', @class, ' '), ' list-segments ')]/li[contains(concat(' ', @class, ' '), ' segment-row ')]", this.dwrapper.doc); for (let segmentIdx in segments) { const segmentRow = segments[segmentIdx]; const segmentId = segmentRow.getAttribute("data-segment-id"); Promise.all([ Promise.resolve(segmentRow), this.fetchSegmentFull(segmentId) ]) .then((segmentAll) => { const segmentRow = segmentAll[0]; const segmentFull = segmentAll[1]; try { this.dwrapper.removeXpath("./span[@id='zbynek-strava-segment-info-segment']", segmentRow); const segment = segmentFull.segment; const preference = segmentFull.preference; const infoEl = this.dwrapper.templateElement( ""+ "<span id='zbynek-strava-segment-info-segment' class='zbynek-strava-segment-info-segment' pl$-segmentfull='segmentFull' pl$-onchange='emptyFunc'>"+ "<span class='distance'><pl$-text name='distance_str'></pl$-text>km</span>"+ "<span class='grade'><pl$-text name='avgGrade_str'></pl$-text>%</span>"+ "<span class='elevationGain'><pl$-if condition='elevationGain_str'><true><pl$-text name='elevationGain_str'></pl$-text>m</true><false>unknown</false></pl$-if></span>"+ "<span class='prTime'><pl$-if condition='pr_link'><true><a pl$-href='pr_link' target='_blank'><pl$-textrun name='pr_time_str'></pl$-textrun>s</a></true><false></false></pl$-if></span>"+ "<span class='prKqomIndicator'><pl$-text name='pr_isKqom_str'></pl$-text></span>"+ "<pl$-if condition='needsHazardWaiver'><true>"+ "<span class='hazard'>Flagged, no data</span>"+ "</true><false>"+ "<span class='kqomTime'><pl$-if condition='kqom_time'><true><a pl$-href='kqom_link' target='_blank'><pl$-textrun name='kqom_time_str'></pl$-textrun>s</a></true><false></false></pl$-if></span>"+ "<span class='kqomSpeed'><pl$-if condition='kqom_speed'><true><pl$-textrun name='kqom_speed_str'></pl$-textrun>km/h</true><false></false></pl$-if></span>"+ "<span class='kqomPower'><pl$-if condition='kqom_power'><true><pl$-textrun name='kqom_power_str'></pl$-textrun>W</true><false></false></pl$-if></span>"+ "</false>"+ "</pl$-if>"+ "<span class='level'><pl$-node name='levelSelect'></pl$-node></span>"+ "<span class='type'><pl$-node name='typeSelect'></pl$-node></span>"+ "<span class='segmentLink'><a pl$-href='segmentLink' target='_blank'>\uD83D\uDD17</a></span>"+ "</span>", { emptyFunc: () => {}, needsHazardWaiver: segment.needsHazardWaiver, segmentFull: segmentFull, distance_str: Js.objMap(segment.info.distance, n => n.toFixed(2)), avgGrade_str: Js.objMap(segment.info.avgGrade, n => n.toFixed(1)), elevationGain_str: Js.objMap(segment.info.elevationGain, n => n.toFixed(0)), pr_link: Js.nullElse(segment.pr.link, ""), pr_time_str: () => this.formatTime(segment.pr.time), pr_isKqom_str: segment.isKqom ? "\uD83D\uDC51" : "", kqom_link: segment.kom.link, kqom_time: segment.best.time, kqom_time_str: () => this.formatTime(segment.best.time), kqom_speed: segment.best.speed, kqom_speed_str: () => Js.objMap(segment.best.speed, n => n.toFixed(1)), kqom_power: segment.best.power, kqom_power_str: () => Js.objMap(segment.best.power, n => n.toFixed(0)), levelSelect: this.dwrapper.createSelect({ class: "zbynek-strava-inline-select", emptyIsNull: true }, ZbynekStravaSegmentInfoUiBase.LEVELS, preference.level, (value) => { segmentFull.preference.level = value; this.updatePreference(segmentFull); }), typeSelect: this.dwrapper.createSelect({ class: "zbynek-strava-inline-select", emptyIsNull: true }, { "": "", road: "Rd", light: "Lgt", gravel: "Gr", mtb: "Mtb" }, preference.type, (value) => { segmentFull.preference.type = value; this.updatePreference(segmentFull); }), segmentLink: segment.info.url, }, 'pl$-' ); segmentRow.appendChild(infoEl); if (this.dwrapper.setVisible(segmentRow, this.filterFunction(segmentFull.preference, segmentFull.segment))) ++processedCounter; this.dwrapper.needXpathNode(".//span[@id='counter']", this.menuEl).textContent = "("+processedCounter+")"; } catch (err) { GM_log("Failed processing segment: "+segmentFull.segment.info.id, err); } }); if (++counter >= 1000000) break; } GM_log("Segments processed "+counter); } listVisibleSegments() { return this.dwrapper.listXpath("//*[@id='segment-visualizer']//ul[contains(concat(' ', @class, ' '), ' list-segments ')]/li[contains(concat(' ', @class, ' '), ' segment-row ') and .//span[@id = 'zbynek-strava-segment-info-segment']]", this.dwrapper.doc) .filter((el) => el.style.display != 'none'); } importDb(dialog, input) { dialog.style.display = 'block'; input.confirmHandler = () => { try { this.segmentPreferenceDb.load(input.value); } catch (err) { alert("Failed to parse data from clipboard, please make sure you copied preference dump correctly: "+err); } dialog.style.display = 'none'; } } exportDb() { GM_setClipboard(this.segmentPreferenceDb.dump()); alert("Preference dump was copied into clipboard"); } exportList() { let csvFormatter = new CsvFormatter({ separator: "\t" }); csvFormatter.writeHeader(ZbynekStravaSegmentInfoUiBase.CSV_ROW_HEADER); this.listVisibleSegments().forEach((segmentRow) => { const segmentFull = this.dwrapper.needXpathNode(".//span[@id = 'zbynek-strava-segment-info-segment']", segmentRow).segmentfull; this.writeCsvSegment(csvFormatter, segmentFull); }); GM_setClipboard(csvFormatter.getOutput()); alert("Listed segments exported into clipboard"); } refreshContent() { if (this.filterEnabled) { try { if (!(this.filterFunction = eval(this.dwrapper.needXpathNode(".//div[@id='filter']//textarea", this.filterEl).value))) { throw new Error("Empty function provided"); } } catch (error) { alert("Failed to compile filter function: "+error); } } else { this.filterFunction = () => true; } this.enrichSegments(); } runBatchUpdate() { let batchFunction; try { if (!(batchFunction = eval(this.dwrapper.needXpathNode(".//div[@id='batchUpdate']//textarea", this.filterEl).value))) throw new Error("Empty function provided"); } catch (error) { alert("Failed to compile batch update function: "+error); return; } try { this.listVisibleSegments().forEach((segmentRow) => { const segmentFull = this.dwrapper.needXpathNode(".//span[@id = 'zbynek-strava-segment-info-segment']", segmentRow).segmentfull; const newPreference = batchFunction(segmentFull.preference, segmentFull.segment); Object.assign(segmentFull.preference, newPreference); this.updatePreference(segmentFull); }); } catch (error) { alert("Failed to execute batch update: "+error); return; } this.enrichSegments(); } initializeUi() { const sidenavEl = this.dwrapper.needXpathNode("//*[contains(concat(' ', @class, ' '), ' sidenav ')]/ul[@id = 'pagenav']", this.dwrapper.doc); this.menuEl = this.dwrapper.templateElement( ""+ "<li>\n"+ " <ul>\n"+ " <li>Zbynek Info Segments</li>\n"+ " <li><a pl$-onclick='enrichSegmentsFunc'>Info Segments<span id='counter' style='padding-left: 5px;'/></a></li>\n"+ " <li>\n"+ " <a pl$-onclick='importDbFunc'>Import Db</a>\n"+ " <div class='zbynek-strava-segment-info-importDialog' zIndex='32768' style='display: none;'>\n"+ " <textarea placeholder='Paste the dump here' rows='80' cols='40'></textarea>\n"+ " <button pl$-onclick='importDbSubmitFunc'>Ok</button>\n"+ " </div>\n"+ " </li>\n"+ " <li><a pl$-onclick='exportDbFunc'>Export Db</a></li>\n"+ " <li><a pl$-onclick='exportListFunc'>Export List</a></li>\n"+ " <li><a pl$-href='donateUrl' target='_blank' title='Support further development by donating to project'>Donate to development</a></li>\n"+ " </ul>\n"+ "</li>", { enrichSegmentsFunc: (event) => this.enrichSegments(), importDbFunc: (event) => this.importDb( this.dwrapper.evaluate("../*[@class = 'zbynek-strava-segment-info-importDialog']", event.currentTarget, null, XPathResult.FIRST_ORDERED_NODE_TYPE).singleNodeValue, this.dwrapper.evaluate("../*[@class = 'zbynek-strava-segment-info-importDialog']//textarea", event.currentTarget, null, XPathResult.FIRST_ORDERED_NODE_TYPE).singleNodeValue ), importDbSubmitFunc: (event) => this.dwrapper.evaluate("..//textarea", event.currentTarget, null, XPathResult.FIRST_ORDERED_NODE_TYPE).singleNodeValue.confirmHandler(), exportDbFunc: () => this.exportDb(), exportListFunc: () => this.exportList(), donateUrl: this.donateUrl, }, "pl$-" ); sidenavEl.appendChild(this.menuEl); const segmentListEl = this.dwrapper.needXpathNode("//ul[contains(concat(' ', @class, ' '), ' list-segments ')]", this.dwrapper.doc); this.filterEl = this.dwrapper.templateElement( ""+ "<div class='zbynek-strava-segment-info-filter'>\n"+ " <div class='enablers'>\n"+ " <span class='enabler'>JS Filter <input type='checkbox' pl$-onchange='toggleJsFilter'></input></span>\n"+ " <span class='enabler'>Batch Update<input type='checkbox' pl$-onchange='toggleBatchUpdate'></input></span>\n"+ " <span class='enabler'><input type='button' value='Update' pl$-onclick='refreshFunc'></input></span>\n"+ " </div>\n"+ " <div class='row' id='filter'><span class='name' title='JsFilter by JavaScript function'>JS Filter</span><span class='content'><textarea rows='10' class='zbynek-strava-max-width'></textarea></span></div>\n"+ " <div class='row' id='batchUpdate'><span class='name' title='Batch update by JavaScript function'><div>JS Batch Update</div><div><input type='button' value='Execute' pl$-onclick='runUpdateFunc'></input></div></span><span class='content'><textarea rows='10' class='zbynek-strava-max-width'></textarea></span></div>\n"+ "</div>", { toggleJsFilter: (event) => this.filterEnabled = this.dwrapper.setVisible(this.dwrapper.needXpathNode("../../../div[@id = 'filter']", event.target), event.target.checked), toggleBatchUpdate: (event) => this.batchUpdateEnabled = this.dwrapper.setVisible(this.dwrapper.needXpathNode("../../../div[@id = 'batchUpdate']", event.target), event.target.checked), refreshFunc: (event) => this.refreshContent(), runUpdateFunc: (event) => this.runBatchUpdate(), }, "pl$-" ); segmentListEl.parentNode.insertBefore(this.filterEl, segmentListEl.nextSibling); } init() { this.initializeStatic(); this.initializeUi(); } } /** * UI for Segment UI */ class ZbynekStravaSegmentInfoSegmentUi extends ZbynekStravaSegmentInfoUiBase { /* UI */ menuEl; filterEl; /* status */ filterEnabled = false; batchUpdateEnabled = false; filterFunction = () => true; constructor(ajaxService, segmentInfoCache, segmentPreferenceDb, documentWrapper) { super(ajaxService, segmentInfoCache, segmentPreferenceDb, documentWrapper); } getSegmentId() { return Js.nullElseThrow( this.dwrapper.evaluate("//*[contains(concat(' ', @class, ' '), ' segment-name ')]//*[@data-segment-id]/@data-segment-id", this.dwrapper.doc, null, XPathResult.STRING_TYPE).stringValue, () => new Error("Failed to identify data-segment-id") ); } exportSegmentRow(segmentId) { Promise.all([ Promise.resolve(this.getSegmentId()), this.fetchSegmentFull(segmentId) ]) .then((segmentAll) => { const segmentId = segmentAll[0]; const segmentFull = segmentAll[1]; let csvFormatter = new CsvFormatter({ separator: "\t" }); csvFormatter.setHeader(ZbynekStravaSegmentInfoUiBase.CSV_ROW_HEADER); this.writeCsvSegment(csvFormatter, segmentFull); GM_setClipboard(csvFormatter.getOutput()); alert("Segment data exported"); }); } updateSegmentUi() { Promise.all([ Promise.resolve(this.getSegmentId()), this.fetchSegmentFull(this.getSegmentId()) ]) .then((segmentAll) => { const segmentId = segmentAll[0]; const segmentFull = segmentAll[1]; if (this.updateEffortData(segmentFull.segment, document)) { this.segmentInfoCache.put(segmentFull.segment.info.id, segmentFull.segment); } try { const segment = segmentFull.segment; const preference = segmentFull.preference; const addedEls = this.dwrapper.templateElements( ""+ "<li id='zbynek-strava-segment-info-segment-elevation-gain'>"+ "<div class='stat'>"+ "<span class='stat-subtext'>Elevation Gain</span>"+ "<b class='stat-text'><pl$-text name='elevationGain_str'></pl$-text><abbr class='unit' title='meters'>m</abbr></b>"+ "</span>"+ "</div>"+ "</li>"+ "<li id='zbynek-strava-segment-info-segment-level'>"+ "<div class='stat'>"+ "<span class='stat-subtext'>Level</span>"+ "<div class='zbynek-strava-segment-info-segment-value'><pl$-node name='levelSelect'></pl$-node></div>"+ "</span>"+ "</div>"+ "</li>"+ "<li id='zbynek-strava-segment-info-segment-type'>"+ "<div class='stat'>"+ "<span class='stat-subtext'>Type</span>"+ "<div class='zbynek-strava-segment-info-segment-value'><pl$-node name='typeSelect'></pl$-node></div>"+ "</span>"+ "</div>"+ "</li>"+ "<li id='zbynek-strava-segment-info-segment-type'>"+ "<div class='stat'>"+ "<span class='stat-subtext'>Protect</span>"+ "<div class='zbynek-strava-segment-info-segment-value'><input type='text' pl$-value='protectValue' pl$-onchange='updateProtect'></input></div>"+ "</span>"+ "</div>"+ "</li>"+ "<li id='zbynek-strava-segment-info-segment-export'>"+ "<div class='stat'>"+ "<span class='stat-subtext'>Export</span>"+ "<b class='stat-text'><a pl$-onclick='exportFunc'>Export</a></b>"+ "</span>"+ "</div>"+ "</li>"+ "<li id='zbynek-strava-segment-info-segment-donate'>"+ "<div class='stat'>"+ "<span class='stat-subtext'>Zbynek Strava Donate</span>"+ "<b class='stat-text'><a pl$-href='donateUrl' target='_blank' title='Support further development by donating to project'>Donate</a></b>"+ "</span>"+ "</div>"+ "</li>", { elevationGain_str: Js.objMap(segment.info.elevationGain, n => n.toFixed(0)), levelSelect: this.dwrapper.createSelect({ class: "zbynek-strava-inline-select", emptyIsNull: true }, ZbynekStravaSegmentInfoUiBase.LEVELS, preference.level, (value) => { segmentFull.preference.level = value; this.updatePreference(segmentFull); }), typeSelect: this.dwrapper.createSelect({ class: "zbynek-strava-inline-select", emptyIsNull: true }, { "": "", road: "Rd", light: "Lgt", gravel: "Gr", mtb: "Mtb" }, preference.type, (value) => { segmentFull.preference.type = value; this.updatePreference(segmentFull); }), protectValue: preference.protect, updateProtect: (event) => { segmentFull.preference.protect = Js.strEmptyToNull(event.target.value); this.updatePreference(segmentFull); }, exportFunc: () => { this.exportSegmentRow(segmentId); }, donateUrl: this.donateUrl, }, 'pl$-' ); const elevationDifferenceEl = this.dwrapper.needXpathNode("//ul[contains(concat(' ', @class, ' '), ' inline-stats ')]/li[.//span[text() = 'Elev Difference']]", this.dwrapper.doc); [ "./span[@id='zbynek-strava-segment-info-segment-elevation-gain']", "./span[@id='zbynek-strava-segment-info-segment-level']", "./span[@id='zbynek-strava-segment-info-segment-type']", "./span[@id='zbynek-strava-segment-info-segment-export']", "./span[@id='zbynek-strava-segment-info-segment-donate']", ].forEach((xpath) => this.dwrapper.removeXpath(xpath, this.dwrapper.doc)); this.dwrapper.insertMultiAfter(addedEls, elevationDifferenceEl); } catch (err) { GM_log("Failed processing segment: "+segmentId, err); } }); } initializeUi() { this.updateSegmentUi(); } init() { this.initializeStatic(); this.initializeUi(); } } /** * UI for Activity UI */ class ZbynekStravaSegmentInfoActivityUi extends ZbynekStravaSegmentInfoUiBase { filterEl = null; filterEnabled = false; batchUpdateEnabled = false; filterFunction = () => true; constructor(ajaxService, segmentInfoCache, segmentPreferenceDb, documentWrapper) { super(ajaxService, segmentInfoCache, segmentPreferenceDb, documentWrapper); } enrichSegments() { let counter = 0; let processedCounter = 0; const effortRows = this.dwrapper.listXpath("//table[contains(concat(' ', @class, ' '), ' segments ') or contains(concat(' ', @class, ' '), ' hidden-segments ')]//tr[@data-segment-effort-id]", this.dwrapper.doc); for (let effortIdx in effortRows) { const effortRow = effortRows[effortIdx]; const effortId = effortRow.getAttribute("data-segment-effort-id"); const segmentId = pageView.segmentEfforts().getEffort(effortId).attributes.segment_id; Promise.all([ Promise.resolve(effortRow), this.fetchSegmentFull(segmentId) ]) .then((segmentAll) => { const effortRow = segmentAll[0]; const segmentFull = segmentAll[1]; try { const segment = segmentFull.segment; const preference = segmentFull.preference; const statsEls = this.dwrapper.templateElements( ""+ "<span id='zbynek-strava-segment-info-activity-effort-avgGrade'> <pl$-if condition='avgGrade_str'><true><pl$-text name='avgGrade_str'></pl$-text><abbr class='unit' title='percent'>%</abbr></true><false>unknown</false></pl$-if></span>"+ "<span id='zbynek-strava-segment-info-activity-effort-elevationGain'>gain: <pl$-if condition='elevationGain_str'><true><pl$-text name='elevationGain_str'></pl$-text><abbr class='unit' title='meters'>m</abbr></true><false>unknown</false></pl$-if></span>"+ "<span id='zbynek-strava-segment-info-activity-effort-pr'><pl$-if condition='prTime'><true><pl$-textrun name='prTime_str'></pl$-textrun><abbr class='unit' title='s'>s</abbr></true><false>none</false></pl$-if></span>"+ "<span id='zbynek-strava-segment-info-activity-effort-best'><br/>best: <span id='time'><pl$-if condition='bestTime'><true><pl$-textrun name='bestTime_str'></pl$-textrun><abbr class='unit' title='s'>s</abbr></true><false>unknown</false></pl$-if></span> <span id='speed'><pl$-if condition='bestSpeed'><true><pl$-textrun name='bestSpeed_str'></pl$-textrun><abbr class='unit' title='km/h'>km/h</abbr></true><false>unknown</false></pl$-if></span> <span id='power'><pl$-if condition='bestPower'><true><pl$-textrun name='bestPower_str'></pl$-textrun><abbr class='unit' title='watts'>W</abbr></true><false>unknown</false></pl$-if></span></span>", { avgGrade_str: Js.objMap(segment.info.avgGrade, n => n.toFixed(1)), elevationGain_str: Js.objMap(segment.info.elevationGain, n => n.toFixed(0)), prTime: segment.pr?.time, prTime_str: () => Js.objMap(segment.pr?.time, n => this.formatTime(n)), bestPower: segment.best?.power, bestPower_str: () => Js.objMap(segment.best?.power, n => n.toFixed(0)), bestSpeed: segment.best?.speed, bestSpeed_str: () => Js.objMap(segment.best?.speed, n => n.toFixed(1)), bestTime: segment.best?.time, bestTime_str: () => Js.objMap(segment.best?.time, n => this.formatTime(n)), }, 'pl$-' ); const updatesEls = this.dwrapper.templateElements( ""+ "<td id='zbynek-strava-segment-info-activity-effort-updates'>"+ "<pl$-node name='levelSelect'></pl$-node>"+ "<pl$-node name='typeSelect'></pl$-node>"+ "</td>", { levelSelect: this.dwrapper.createSelect({ class: "zbynek-strava-inline-select", emptyIsNull: true }, ZbynekStravaSegmentInfoUiBase.LEVELS, preference.level, (value) => { preference.level = value; this.updatePreference(segmentFull); }), typeSelect: this.dwrapper.createSelect({ class: "zbynek-strava-inline-select", emptyIsNull: true }, { "": "", road: "Rd", light: "Lgt", gravel: "Gr", mtb: "Mtb" }, preference.type, (value) => { preference.type = value; this.updatePreference(segmentFull); }), }, 'pl$-' ); [ ".//span[@id='zbynek-strava-segment-info-activity-effort-avgGrade']", ".//span[@id='zbynek-strava-segment-info-activity-effort-elevationGain']", ".//span[@id='zbynek-strava-segment-info-activity-effort-pr']", ".//span[@id='zbynek-strava-segment-info-activity-effort-best']", ".//td[@id='zbynek-strava-segment-info-activity-effort-updates']", ].forEach((xpath) => this.dwrapper.removeXpath(xpath, effortRow)); const origAvgGradeEl = this.dwrapper.needXpathNode(".//span[@title = 'Average grade']", effortRow); const visibilityEl = this.dwrapper.needXpathNode(".//td[.//button[contains(concat(' ', @class, ' '), 'toggle-effort-visibility')]]", effortRow); this.dwrapper.insertMultiAfter(statsEls, origAvgGradeEl); this.dwrapper.insertMultiBefore(updatesEls, visibilityEl); this.dwrapper.setVisible(origAvgGradeEl, false); if (this.dwrapper.setVisible(effortRow, this.filterFunction(preference, segment), "table-row")) ++processedCounter; } catch (err) { GM_log("Failed processing segment: "+segmentFull.segment.info.id, err); } }); if (++counter >= 1000000) break; } GM_log("Segments processed "+counter); } listVisibleSegments() { const effortRows = this.dwrapper.listXpath("//table[contains(concat(' ', @class, ' '), ' segments ') or contains(concat(' ', @class, ' '), ' hidden-segments ')]//tr[@data-segment-effort-id]", this.dwrapper.doc); return effortRows.map((effortRow) => pageView.segmentEfforts().getEffort(effortRow.getAttribute("data-segment-effort-id")).attributes.segment_id); } refreshContent() { if (this.filterEnabled) { try { if (!(this.filterFunction = eval(this.dwrapper.needXpathNode(".//div[@id='filter']//textarea", this.filterEl).value))) { throw new Error("Empty function provided"); } } catch (error) { alert("Failed to compile filter function: "+error); } } else { this.filterFunction = () => true; } this.enrichSegments(); } runBatchUpdate() { let batchFunction; try { if (!(batchFunction = eval(this.dwrapper.needXpathNode(".//div[@id='batchUpdate']//textarea", this.filterEl).value))) throw new Error("Empty function provided"); } catch (error) { alert("Failed to compile batch update function: "+error); return; } let failed = 0; Promise.allSettled(this.listVisibleSegments().map((segmentId) => { this.fetchSegmentFull(segmentId) .then((segmentFull) => { let newPreference; try { newPreference = batchFunction(segmentFull.preference, segmentFull.segment); } catch (error) { if (failed++ == 0) { alert("Failed to execute batch update: "+error); } throw error; } Object.assign(segmentFull.preference, newPreference); this.updatePreference(segmentFull); }); })) .then((results) => { results.filter((result) => result.status == 'rejected').forEach((result) => console.error(result)); this.enrichSegments(); }); } initializeUi() { const segmentFooterEl = this.dwrapper.needXpathNode("//*[@id = 'segments']/footer", this.dwrapper.doc); this.filterEl = this.dwrapper.templateElement( ""+ "<div class='zbynek-strava-segment-info-filter'>\n"+ " <div class='enablers'>\n"+ " <span class='enabler'>JS Filter <input type='checkbox' pl$-onchange='toggleJsFilter'></input></span>\n"+ " <span class='enabler'>Batch Update<input type='checkbox' pl$-onchange='toggleBatchUpdate'></input></span>\n"+ " <span class='enabler'><input type='button' value='Update' pl$-onclick='refreshFunc'></input></span>\n"+ " <span class='enabler'><a pl$-href='donateUrl' target='_blank' title='Support further development by donating to project'>Donate</a></span>\n"+ " </div>\n"+ " <div class='row' id='filter'><span class='name' title='JsFilter by JavaScript function'>JS Filter</span><span class='content'><textarea rows='10' class='zbynek-strava-max-width'></textarea></span></div>\n"+ " <div class='row' id='batchUpdate'><span class='name' title='Batch update by JavaScript function'><div>JS Batch Update</div><div><input type='button' value='Execute' pl$-onclick='runUpdateFunc'></input></div></span><span class='content'><textarea rows='10' class='zbynek-strava-max-width'></textarea></span></div>\n"+ "</div>", { donateUrl: this.donateUrl, toggleJsFilter: (event) => this.filterEnabled = this.dwrapper.setVisible(this.dwrapper.needXpathNode("../../../div[@id = 'filter']", event.target), event.target.checked), toggleBatchUpdate: (event) => this.batchUpdateEnabled = this.dwrapper.setVisible(this.dwrapper.needXpathNode("../../../div[@id = 'batchUpdate']", event.target), event.target.checked), refreshFunc: (event) => this.refreshContent(), runUpdateFunc: (event) => this.runBatchUpdate(), }, "pl$-" ); segmentFooterEl.appendChild(this.filterEl); } init() { this.initializeStatic(); this.initializeUi(); } } if (/^\/activities\/\w+(|\/segments\/\w+)\/potential-segment-matches\/?$/.test(window.location.pathname)) { new ZbynekStravaSegmentInfoMatcherUi( new GmAjaxService(), new GlobalDbStorageCache(window.localStorage, "ZbynekStravaSegmentInfo.segmentInfoCache", 1, 10*86400*1000), new GlobalDbStorageCache(window.localStorage, "ZbynekStravaSegmentInfo.segmentPreferenceDb", 1, null), new HtmlWrapper(document) ) .init(); } else if (/^\/segments\/\w+\/?$/.test(window.location.pathname)) { new ZbynekStravaSegmentInfoSegmentUi( new GmAjaxService(), new GlobalDbStorageCache(window.localStorage, "ZbynekStravaSegmentInfo.segmentInfoCache", 1, 10*86400*1000), new GlobalDbStorageCache(window.localStorage, "ZbynekStravaSegmentInfo.segmentPreferenceDb", 1, null, { writebackTimeout: 1 }), new HtmlWrapper(document) ) .init(); } else if (/^\/activities\/\w+(\/?|\/overview|\/segments\/\w+\/?)$/.test(window.location.pathname)) { new ZbynekStravaSegmentInfoActivityUi( new GmAjaxService(), new GlobalDbStorageCache(window.localStorage, "ZbynekStravaSegmentInfo.segmentInfoCache", 1, 10*86400*1000), new GlobalDbStorageCache(window.localStorage, "ZbynekStravaSegmentInfo.segmentPreferenceDb", 1, null, { writebackTimeout: 5000 }), new HtmlWrapper(document) ) .init(); } else { GM_log("Failed to match URL to known pattern, ignoring: "+window.location.pathname); } })(); // vim: set sw=8 ts=8 noet smarttab: