Raw Source
kvr000 / Zbynek Strava Segment Info

// ==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.&currency_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.&currency_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: