MB Auto Track Lengths from CD TOC

Autoset track lengths from unique CD-TOC

  1. // ==UserScript==
  2. // @name MB Auto Track Lengths from CD TOC
  3. // @version 1.14
  4. // @match https://musicbrainz.org/release/*
  5. // @match https://beta.musicbrainz.org/release/*
  6. // @match https://musicbrainz.org/cdtoc/*
  7. // @match https://beta.musicbrainz.org/cdtoc/*
  8. // @run-at document-end
  9. // @author Anakunda
  10. // @namespace https://greasyfork.org/users/321857
  11. // @copyright 2024, Anakunda (https://greasyfork.org/users/321857)
  12. // @license GPL-3.0-or-later
  13. // @description Autoset track lengths from unique CD-TOC
  14. // @grant GM_getValue
  15. // @grant GM_setValue
  16. // @grant GM_registerMenuCommand
  17. // @require https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
  18. // ==/UserScript==
  19.  
  20. 'use strict';
  21.  
  22. const loggedIn = document.body.querySelector('div.links-container > ul.menu > li.account') != null;
  23. if (!loggedIn) console.warn('Not logged in: the script functionality is limited');
  24. const getTime = text => text ? text.split(':').reverse()
  25. .reduce((t, v, n) => t + parseInt(v) * Math.pow(60, n), 0) : NaN;
  26.  
  27. function getDiscIdDeltas(document = window.document) {
  28. if (!(document instanceof HTMLDocument)) throw 'Invalid argument';
  29. return Array.from(document.body.querySelectorAll('div#page > table.wrap-block.details'), function(track) {
  30. const times = ['old', 'new'].map(function(cls) {
  31. if ((cls = track.querySelector('td.' + cls)) != null) cls = cls.textContent; else return NaN;
  32. return getTime(cls);
  33. });
  34. return Math.abs(times[1] - times[0]);
  35. });
  36. }
  37.  
  38. const computeCompoundScore = deltas => Array.isArray(deltas) ? (deltas = deltas.filter(delta => !isNaN(delta)))
  39. .length > 0 ? deltas.reduce((score, delta) => score + (isNaN(delta) ? 0 : delta), 0) / deltas.length : 0 : NaN;
  40.  
  41. // State bits for medium
  42. // 7 - disc id(s) attached
  43. // 6 - set times enabled for this id
  44. // 4-5 - similarity level 0-3
  45. // 3 - unique id
  46. // 2 - set times enabled for all ids
  47. // 1 - all ids similar
  48. // 0 - applied now
  49.  
  50. function tooltipFromState(state, param) {
  51. if (state < 0) return 'Unhandled error occured (see browser console for more details)';
  52. if ((state >> 7 & 1) == 0) return 'No disc IDs attached';
  53. if ((state >> 0 & 1) != 0) return 'TOC lengths successfully applied on tracks';
  54. if ((state >> 6 & 1) == 0) return 'Track times match CD TOC';
  55. if ((state >> 3 & 0b11111) == 0b11111) return loggedIn ? 'Unique CD TOC available to apply' : 'Unique CD TOC';
  56. if ((state >> 3 & 1) == 0 && param != undefined) {
  57. let title = 'Ambiguity: multiple disc IDs attached to medium';
  58. if ((state >> 4 & 0b11) < 0b11) title += ' (some suspicious)';
  59. return title;
  60. } else if ((state >> 4 & 0b11) < 0b11)
  61. return `Suspicious CD TOC (${(state >> 4 & 0b11) < 2 ? 'severe' : 'considerable'} timing differences)`;
  62. if ((state >> 4 & 0b1111) == 0b1111 && loggedIn) return 'CD TOC fine to apply';
  63. }
  64.  
  65. function styleByState(element, state, considerAmbiguity = false) {
  66. console.assert(element instanceof HTMLElement);
  67. if (!(element instanceof HTMLElement)) throw 'Invalid argument';
  68. element.dataset.trackLengthsState = state.toString(2);
  69. if (element.offsetParent == null || (state & 1) != 0) return;
  70. if (state < 0) element.style = 'color: white; background-color: red;'; else {
  71. if ((state >> 4 & 0b11) < 1) element.style = 'color: #f00; background-color: #f002;';
  72. else if ((state >> 4 & 0b11) < 2) element.style = 'color: #f00;';
  73. else if ((state >> 4 & 0b11) < 3) element.style = 'color: #f60;';
  74. if (considerAmbiguity && (state >> 3 & 1) == 0) element.style.color = (state >> 4 & 0b11) < 3 ? '#f08' : '#d0d';
  75. if ((state >> 4 & 0b1111) == 0b1111) element.style.backgroundColor = '#0f01';
  76. if (loggedIn && (state >> 3 & 0b11111) == 0b11111) element.style.fontWeight = 'bold';
  77. }
  78. if (!element.title) {
  79. const tooltip = tooltipFromState(state, considerAmbiguity || undefined);
  80. if (tooltip) element.title = tooltip;
  81. }
  82. }
  83.  
  84. function computeDifferenceState(deltas) {
  85. if (!Array.isArray(deltas)) throw 'Invalid argument';
  86. const loThreshold = 5, hiThreshold = 15;
  87. let q = (loThreshold + 0.5 - computeCompoundScore(deltas)) / (hiThreshold - loThreshold);
  88. q = Math.max(Math.min(Math.ceil(q), 1) + 2, 1);
  89. const hasPresence = (threshold, rate) =>
  90. deltas.filter(delta => delta >= threshold + 0.5).length >= deltas.length * rate;
  91. if ((deltas = deltas.filter(delta => !isNaN(delta))).length > 0
  92. && (hasPresence(hiThreshold, 1/3) || hasPresence(loThreshold, 2/3))) --q; // q = 0;
  93. console.assert(q >= 0 && q <= 3, deltas, computeCompoundScore(deltas));
  94. return q << 4;
  95. }
  96.  
  97. if (document.location.pathname.endsWith('/set-durations')) {
  98. const deltas = getDiscIdDeltas();
  99. const compoundScore = computeCompoundScore(deltas);
  100. const scoreElem = Object.assign(document.createElement('div'), {
  101. innerHTML: `<b>Average delta:</b> <span class="avg-delta">${compoundScore.toFixed(3)}s</span>`,
  102. style: 'margin-top: 4pt;',
  103. className: 'compound-score',
  104. });
  105. let anchor = document.body.querySelector('form[method="post"]');
  106. if (anchor != null) anchor = anchor.parentNode.querySelector(':scope > table.details:last-of-type');
  107. if (anchor != null) {
  108. anchor.after(scoreElem);
  109. if ((anchor = scoreElem.querySelector('span.avg-delta')) != null)
  110. styleByState(anchor, 0b11 << 6 | computeDifferenceState(deltas));
  111. }
  112. return;
  113. }
  114.  
  115. let autoSet = loggedIn && GM_getValue('auto_set', true);
  116. const flashElement = elem => elem instanceof HTMLElement ? elem.animate([
  117. { offset: 0.0, opacity: 1 },
  118. { offset: 0.4, opacity: 1 },
  119. { offset: 0.5, opacity: 0.1 },
  120. { offset: 0.9, opacity: 0.1 },
  121. ], { duration: 600, iterations: Infinity }) : null;
  122. if ('mbDiscIdStates' in sessionStorage) try {
  123. var discIdStates = JSON.parse(sessionStorage.getItem('mbDiscIdStates'));
  124. } catch(e) { console.warn(e) }
  125. if (typeof discIdStates != 'object') discIdStates = { };
  126. const getEntity = url => /^\/(\w+)\/([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})(?=[\/\?]|$)/i.exec(url.pathname);
  127.  
  128. if (loggedIn) {
  129. const setMenu = oldId => GM_registerMenuCommand(`Switch to ${autoSet ? 'conservative' : 'full auto'} mode`, function(param) {
  130. GM_setValue('auto_set', autoSet = !autoSet);
  131. if (autoSet) document.location.reload(); else menuId = setMenu(menuId);
  132. }, { id: oldId, autoClose: false, title: `Operating in ${autoSet ? 'full auto' : 'conservative'} mode
  133.  
  134. Full auto mode: autoset times in background and report the status as style/tooltip
  135. Conservative mode: evaluate status in background and autoset times on user click` });
  136. let menuId = setMenu();
  137. }
  138.  
  139. const mbRequestRate = 1000, mbRequestsCache = new Map;
  140. let mbLastRequest = null;
  141. function apiRequest(endPoint, params) {
  142. if (!endPoint) throw 'Endpoint is missing';
  143. const url = new URL('/ws/2/' + endPoint.replace(/^\/+|\/+$/g, ''), 'https://musicbrainz.org');
  144. if (params) for (let key in params) url.searchParams.set(key, params[key]);
  145. url.searchParams.set('fmt', 'json');
  146. const cacheKey = url.pathname.slice(6) + url.search;
  147. if (mbRequestsCache.has(cacheKey)) return mbRequestsCache.get(cacheKey);
  148. const recoverableHttpErrors = [429, 500, 502, 503, 504, 520, /*521, */522, 523, 524, 525, /*526, */527, 530];
  149. const request = new Promise(function(resolve, reject) {
  150. function request() {
  151. if (mbLastRequest == Infinity) return setTimeout(request, 50);
  152. const availableAt = mbLastRequest + mbRequestRate, now = Date.now();
  153. if (now < availableAt) return setTimeout(request, availableAt - now); else mbLastRequest = Infinity;
  154. xhr.open('GET', url, true);
  155. xhr.setRequestHeader('Accept', 'application/json');
  156. xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
  157. xhr.send();
  158. }
  159. function errorHandler(response) {
  160. console.error('HTTP error:', response);
  161. let reason = 'HTTP error ' + response.status;
  162. if (response.status == 0) reason += '/' + response.readyState;
  163. let statusText = response.statusText;
  164. if (response.response) try {
  165. if (typeof response.response.error == 'string') statusText = response.response.error;
  166. } catch(e) { }
  167. if (statusText) reason += ' (' + statusText + ')';
  168. return reason;
  169. }
  170.  
  171. let retryCounter = 0;
  172. const xhr = Object.assign(new XMLHttpRequest, {
  173. responseType: 'json',
  174. timeout: 60e3,
  175. onload: function() {
  176. mbLastRequest = Date.now();
  177. if (this.status >= 200 && this.status < 400) resolve(this.response);
  178. else if (recoverableHttpErrors.includes(this.status))
  179. if (++retryCounter < 60) setTimeout(request, 1000); else reject('Request retry limit exceeded');
  180. else reject(errorHandler(this));
  181. },
  182. onerror: function() { mbLastRequest = Date.now(); reject(errorHandler(this)); },
  183. ontimeout: function() {
  184. mbLastRequest = Date.now();
  185. console.error('HTTP timeout:', this);
  186. let reason = 'HTTP timeout';
  187. if (this.timeout) reason += ' (' + this.timeout + ')';
  188. reject(reason);
  189. },
  190. });
  191. request();
  192. });
  193. mbRequestsCache.set(cacheKey, request);
  194. return request;
  195. }
  196.  
  197. function saveDiscIdStates(releaseId, states) {
  198. if (states) discIdStates[releaseId] = states; else delete discIdStates[releaseId];
  199. sessionStorage.setItem('mbDiscIdStates', JSON.stringify(discIdStates));
  200. }
  201.  
  202. function getRequestparams(link) {
  203. console.assert(link instanceof HTMLAnchorElement);
  204. if (!(link instanceof HTMLAnchorElement)) throw 'Invalid argument';
  205. const params = { discId: /^\/cdtoc\/([\w\_\.\-]+)\/set-durations$/i.exec(link.pathname) };
  206. if (params.discId != null) params.discId = params.discId[1]; else return null;
  207. const query = new URLSearchParams(link.search);
  208. if (!query.has('medium')) return null;
  209. console.assert(link.textContent.trim() == 'Set track lengths', link);
  210. params.mediumId = parseInt(query.get('medium'));
  211. console.assert(params.mediumId > 0, link.href);
  212. return params.mediumId >= 0 ? params : null;
  213. }
  214.  
  215. const getSetLink = row => Array.prototype.find.call(row.querySelectorAll('td a'), a => getRequestparams(a) != null) || null;
  216. const replacedLinkLabel = () => Object.assign(document.createElement('span'), {
  217. textContent: 'Track lengths successfully set',
  218. style: 'color: green;',
  219. });
  220.  
  221. function processRelease(param, autoSet = true, setParams) {
  222. if (param instanceof HTMLDocument) {
  223. function getDiscIds(row) {
  224. console.assert(row instanceof HTMLElement);
  225. const discIds = [ ], isDiscIdRow = row => row instanceof HTMLElement && ['odd', 'even']
  226. .some(cls => row.classList.contains(cls));
  227. while (isDiscIdRow(row = row.nextElementSibling)) discIds.push(getSetLink(row));
  228. return discIds;
  229. }
  230. function processTrackLengths(link, autoSet = true) {
  231. console.assert(link instanceof HTMLAnchorElement);
  232. if (!(link instanceof HTMLAnchorElement)) throw 'Invalid argument';
  233. if (loggedIn && autoSet && visible) link.disabled = true;
  234. const animation = visible ? flashElement(link) : null;
  235. let state = 0b11 << 6;
  236. return localXHR(link).then(getDiscIdDeltas).then(function(deltas) {
  237. state |= computeDifferenceState(deltas);
  238. const compoundScore = computeCompoundScore(deltas);
  239. link.dataset.compoundScore = compoundScore.toFixed(3);
  240. console.log('Compound score for %o:', getRequestparams(link), compoundScore);
  241. return (state >> 4 & 0b11) >= 3 ? Promise.resolve(state) : Promise.reject(state);
  242. }).then(function(state) {
  243. if (!loggedIn || !autoSet) {
  244. if (animation) animation.cancel();
  245. link.disabled = false;
  246. return state;
  247. }
  248. const postData = new URLSearchParams({ 'confirm.edit_note': GM_getValue('edit_note', '') });
  249. if (GM_getValue('make_votable', false)) postData.set('confirm.make_votable', 1);
  250. return localXHR(link, { responseType: null }, postData).then(function(statusCode) {
  251. console.log('Lengths set for %o with status code %d', getRequestparams(link), statusCode);
  252. if (visible) link.replaceWith(replacedLinkLabel());
  253. return state & ~(1 << 6) | 1 << 0;
  254. });
  255. }).catch(function(reason) {
  256. if (animation) animation.cancel();
  257. link.disabled = false;
  258. if (Number.isInteger(reason)) return reason; else if (visible) link.title = reason;
  259. return -1;
  260. });
  261. }
  262. function processMedium(medium) {
  263. function clickHandler(evt) {
  264. const link = evt.currentTarget;
  265. if (!link.disabled) processTrackLengths(link, true)
  266. .then(state => { if ((state & 1) == 0) document.location.assign(link) });
  267. return false;
  268. }
  269.  
  270. console.assert(medium instanceof HTMLElement);
  271. if (!(medium instanceof HTMLElement)) throw 'Invalid argument';
  272. const discIds = getDiscIds(medium), settable = discIds.filter(Boolean);
  273. let state = 0;
  274. if (discIds.length <= 0) return Promise.resolve(state); else state |= 1 << 7;
  275. if (discIds.length == 1) state |= 1 << 3;
  276. if (settable.length <= 0) return Promise.resolve(state |= 0b111010); else state |= 1 << 6;
  277. if (settable.length >= discIds.length) state |= 1 << 2;
  278. if (discIds.length > 1) {
  279. if (loggedIn && visible) settable.forEach(function(link) {
  280. link.onclick = clickHandler;
  281. processTrackLengths(link, false).then(function(status) {
  282. status |= state & ~(0b11 << 6);
  283. if ((status >> 4 & 0b1111) != 0b1111) link.onclick = null;
  284. if (visible) styleByState(link, status);
  285. });
  286. });
  287. return Promise.resolve(state | 0b11 << 4);
  288. } else {
  289. if (loggedIn && visible) settable[0].onclick = autoSet ? evt => !evt.currentTarget.disabled : clickHandler;
  290. return processTrackLengths(settable[0], autoSet).then(function(status) {
  291. if (((status |= state & ~(0b11 << 6)) >> 4 & 0b11) >= 3) status |= 1 << 1;
  292. if ((status >> 4 & 0b1111) != 0b1111) settable[0].onclick = null;
  293. if (visible && (status & 1) == 0) styleByState(settable[0], status);
  294. return status;
  295. });
  296. }
  297. }
  298.  
  299. const visible = param == window.document;
  300. const media = param.body.querySelectorAll('table.tbl > tbody > tr.subh');
  301. if (media.length <= 0) return Promise.reject('No media found');
  302. else if (setParams && typeof setParams == 'object') {
  303. const medium = Array.prototype.find.call(media, medium => getDiscIds(medium).some(function(link) {
  304. if (link == null) return false;
  305. const requestParams = getRequestparams(link);
  306. return requestParams != null && (requestParams.discId == setParams.discId)
  307. && (requestParams.mediumId == setParams.mediumId);
  308. }));
  309. console.assert(medium, setParams, media);
  310. return medium ? processMedium(medium) : Promise.reject('Medium not found');
  311. } else return Promise.all(Array.from(media, processMedium));
  312. } else if (param) {
  313. const url = `/release/${param}/discids`;
  314. if (setParams) return localXHR(url).then(document => processRelease(document, autoSet, setParams));
  315. return (param in discIdStates ? Promise.resolve(discIdStates[param]) : (function getDisdIdStates() {
  316. return apiRequest('release/' + param, { inc: 'recordings+discids'}).then(function(release) {
  317. const states = release.media.map(function(medium, mediumIndex) {
  318. let state = 0;
  319. if (!medium.discs || medium.discs.length <= 0) return state; else state |= 1 << 7;
  320. const discIdStates = medium.discs.map(function(discId, tocIndex) {
  321. const grpLabel = `Medium ${mediumIndex + 1} / Disc ID ${tocIndex + 1}`;
  322. console.groupCollapsed(grpLabel);
  323. const deltas = medium.tracks.map(function lengthsEqual(track, index, tracks) {
  324. const trackLength = 'length' in track ? typeof track.length == 'number' ? track.length / 1000
  325. : typeof track.length == 'string' ? getTime(track.length) : NaN : NaN;
  326. const hiOffset = (index + 1) in discId.offsets ? discId.offsets[index + 1] : discId.sectors;
  327. const tocLength = (hiOffset - discId.offsets[index]) / 75;
  328. const delta = Math.abs(tocLength - trackLength);
  329. console.debug('[%02d] Track length: %.3f (%s), TOC length: %.4f, Delta: %.4f',
  330. index + 1, trackLength, track.length, tocLength, delta);
  331. return delta;
  332. }), compoundScore = computeCompoundScore(deltas);
  333. console.log('Compound score:', compoundScore);
  334. console.groupEnd(grpLabel);
  335. return medium.tracks.every(function lengthsEqual(track, index, tracks) {
  336. if (typeof track.length != 'number') return false;
  337. const hiOffset = (index + 1) in discId.offsets ? discId.offsets[index + 1] : discId.sectors;
  338. return track.length == Math.floor((hiOffset - discId.offsets[index]) * 1000 / 75);
  339. }) ? 0b11 << 4 : 1 << 6 | computeDifferenceState(deltas);
  340. });
  341. if (discIdStates.some(state => (state >> 6 & 1) != 0)) state |= 1 << 6;
  342. if (discIdStates.every(state => (state >> 6 & 1) != 0)) state |= 1 << 2;
  343. if (discIdStates.every(state => (state >> 4 & 0b11) >= 3)) state |= 1 << 1;
  344. state |= Math.min(...discIdStates.map(state => state & 0b11 << 4));
  345. if (discIdStates.length == 1) state |= 1 << 3;
  346. return state;
  347. });
  348. return states;
  349. }).catch(reason => { console.warn('Disc ID states query failed:', reason, '; falling back to scraping HTML') });
  350. })()).then(function(states) {
  351. if (states && !(autoSet && states.some(state => (state >> 3 & 0b11111) == 0b11111))) return states;
  352. return localXHR(url).then(document => processRelease(document, autoSet));
  353. });
  354. } else throw 'Invalid argument';
  355. }
  356.  
  357. if (document.location.pathname.startsWith('/release/')) {
  358. let releaseId = getEntity(document.location);
  359. console.assert(releaseId != null && releaseId[1] == 'release', document.location.pathname);
  360. if (releaseId != null && releaseId[1] == 'release') releaseId = releaseId[2];
  361. else throw 'Failed to identify entity from page url';
  362. let tabLinks = document.body.querySelectorAll('div#page ul.tabs > li a');
  363. tabLinks = Array.prototype.filter.call(tabLinks, a => a.pathname.endsWith('/discids'));
  364. console.assert(tabLinks.length == 1, tabLinks);
  365. if (tabLinks.length != 1) throw 'Assertion failed: Disc ID tab links mismatch';
  366. const tabLink = tabLinks[0], li = tabLink.closest('li');
  367. console.assert(li != null);
  368. if (document.location.pathname.endsWith('/discids') || li.classList.contains('sel')) {
  369. saveDiscIdStates(releaseId);
  370. processRelease(document, autoSet).then(function(states) {
  371. if (states.every(state => (state >> 6 & 1) == 0)) saveDiscIdStates(releaseId, states);
  372. });
  373. return;
  374. } else if (li.classList.contains('disabled')) return Promise.reject('Release has no disc IDs attached');
  375. const animation = flashElement(tabLink);
  376. processRelease(releaseId, autoSet).then(function(states) {
  377. if (!states.some(state => state < 0)) saveDiscIdStates(releaseId, states.map(state => state & ~(1 << 0)));
  378. if (animation) animation.cancel();
  379. const setColor = color => { for (let child of li.children) child.style.color = color };
  380. if (states.some(state => state < 0)) {
  381. li.style.backgroundColor = 'red';
  382. setColor('white');
  383. } else {
  384. if (states.every(state => /*(state >> 7 & 1) == 0 || */(state >> 4 & 0b1111) == 0b1011)) setColor('#050');
  385. if (states.some(state => (state >> 7 & 1) != 0 && (state >> 4 & 0b11) < 1)) setColor('#840');
  386. if (states.some(state => (state >> 7 & 1) != 0 && (state >> 4 & 0b11) < 3)) setColor('#800');
  387. if (states.some(state => (state >> 3 & 0b10001) == 0b10000)) setColor('#804');
  388. if (states.some(state => (state & 1) != 0)) li.style.backgroundColor = '#0f02';
  389. if (states.some(state => (state >> 3 & 0b11111) == 0b11111)) li.style.fontWeight = 'bold';
  390. }
  391. li.title = states.map(tooltipFromState).map((state, index) => `Medium ${index + 1}: ${state}`).join('\n');
  392. }, function(reason) {
  393. if (animation) animation.cancel();
  394. [li.style, li.title] = ['color: white; background-color: red;', 'Something went wrong: ' + reason];
  395. });
  396. } else if (document.location.pathname.startsWith('/cdtoc/'))
  397. document.body.querySelectorAll('table.tbl > tbody > tr').forEach(function(medium, index) {
  398. function processLink(userClick) {
  399. setLink.disabled = true;
  400. const animation = flashElement(setLink);
  401. processRelease(release[2], userClick || autoSet, setparams).then(function(state) {
  402. if ((state & 1) != 0) {
  403. saveDiscIdStates(release[2]);
  404. return setLink.replaceWith(replacedLinkLabel());
  405. }
  406. if (animation) animation.cancel();
  407. styleByState(setLink, state, true);
  408. const redirect = (state >> 3 & 1) == 0 ? `/release/${release[2]}/discids` : undefined;
  409. if (userClick) return document.location.assign(redirect || setLink);
  410. if (redirect) setLink.href = redirect;
  411. if ((state >> 3 & 0b11111) != 0b11111) setLink.onclick = null;
  412. setLink.disabled = false;
  413. }, function(reason) {
  414. if (animation) animation.cancel();
  415. [setLink.style, setLink.disabled, setLink.title] =
  416. ['color: white; background-color: red;', false, 'Something went wrong: ' + reason];
  417. });
  418. }
  419.  
  420. let release = Array.prototype.find.call(medium.querySelectorAll(':scope > td a'),
  421. a => (a = getEntity(a)) != null && a[1] == 'release');
  422. console.assert(release, medium);
  423. if (release) release.pathname += '/discids';
  424. const setLink = getSetLink(medium);
  425. if (setLink != null && release) release = getEntity(release); else return;
  426. console.assert(release != null, medium);
  427. const setparams = getRequestparams(setLink);
  428. console.assert(setparams != null, setLink);
  429. if (loggedIn) setLink.onclick = autoSet ? evt => !evt.currentTarget.disabled : function(evt) {
  430. if (!evt.currentTarget.disabled) processLink(true);
  431. return false;
  432. };
  433. processLink(false);
  434. });