IAFD

Add extra utilities to iafd.com

  1. // ==UserScript==
  2. // @name IAFD
  3. // @author peolic
  4. // @version 4.109
  5. // @description Add extra utilities to iafd.com
  6. // @icon https://www.iafd.com/favicon-196x196.png
  7. // @namespace https://github.com/peolic
  8. // @match https://www.iafd.com/*
  9. // @grant GM.setClipboard
  10. // @grant GM.addStyle
  11. // @homepageURL https://gist.github.com/peolic/9e2981a8a14a49b9626cb277f878b157
  12. // ==/UserScript==
  13.  
  14. function main() {
  15. makeInternalLinksHTTPS();
  16. fixIAFDLinks();
  17.  
  18. const { pathname } = window.location;
  19.  
  20. if (/^\/title\.(asp|rme\/)/.test(pathname))
  21. return titlePage();
  22. if (/^\/person\.(asp|rme\/)/.test(pathname))
  23. return personPage();
  24. if (/^\/(([a-z]{4}_)?studio|distrib)\.(asp|rme\/)$/.test(pathname))
  25. return studioDistribSelectPage();
  26. if (/^\/(studio|distrib)\.(asp\?|rme\/)(studio|distrib)=\d+/.test(pathname))
  27. return studioDistribPage();
  28. if (/^\/(new|updated)(perfs|headshots)\.asp/.test(pathname))
  29. return personUpdatePage();
  30. if (/^\/lookuptat\.asp/.test(pathname))
  31. return tattooLookupPage();
  32. if (/^\/results\.asp/.test(pathname))
  33. return setSearchField();
  34. }
  35.  
  36. const getBioDataElement = (headingText) => {
  37. return Array.from(document.querySelectorAll('p.bioheading'))
  38. .find(e => e.innerText.localeCompare(headingText, 'en', { sensitivity: 'accent' }) === 0)
  39. ?.nextElementSibling;
  40. };
  41.  
  42. const makeBioEntry = (heading, ...data) => {
  43. return [['heading', heading], ...data.map((d) => ['data', d])].map(([type, text]) => {
  44. const p = document.createElement('p');
  45. p.classList.add(`bio${type}`);
  46. if (text instanceof Node) p.append(text);
  47. else p.innerText = text;
  48. return p;
  49. });
  50. };
  51.  
  52. const makeQuickSelect = (text) => {
  53. const b = document.createElement('b');
  54. Object.assign(b.style, { userSelect: 'all', cursor: 'cell', textDecoration: 'underline dotted' });
  55. b.innerText = text;
  56. return b;
  57. };
  58.  
  59. const makeISODateElement = (date) => {
  60. const isoDate = new Date(`${date} 0:00 UTC`).toISOString().slice(0, 10);
  61. return makeQuickSelect(isoDate);
  62. };
  63.  
  64.  
  65. const slug = (text, char='-') => {
  66. return encodeURI(text.replace(/[^a-z0-9_.,:'-]+/gi, '___'))
  67. .toLowerCase()
  68. .replace(/___/g, char);
  69. };
  70.  
  71. const makeLegacyTitleLink = (title, year) => {
  72. const encodedTitle = slug(title, '+').replace(/^\+|\++$|^(a|an|the)\+/g, '');
  73. return `https://www.iafd.com/title.rme/title=${encodedTitle}/year=${year}/${encodedTitle.replace(/\+/g, '-')}.htm`;
  74. };
  75.  
  76. const makeLegacyPerformerLink = (perfId, gender, name=undefined) => {
  77. const nameSlug = name ? `/${slug(name, '-')}.htm` : '';
  78. return `https://www.iafd.com/person.rme/perfid=${perfId}/gender=${gender}${nameSlug}`;
  79. };
  80.  
  81. function makeInternalLinksHTTPS() {
  82. /** @type {NodeListOf<HTMLAnchorElement>} */
  83. (document.querySelectorAll('a[href^="http://www.iafd.com/"]')).forEach((el) => {
  84. el.href = el.href.replace(/^http:/, 'https:');
  85. });
  86. };
  87.  
  88. function fixIAFDLinks() {
  89. const replacer = (_, offset, string) => {
  90. if (offset > string.indexOf('/year=')) return '-';
  91. else return '+';
  92. };
  93. /** @type {NodeListOf<HTMLAnchorElement>} */
  94. (document.querySelectorAll('a[href*="//www.iafd.com/"]')).forEach((el) => {
  95. el.href = el.href
  96. // upper-case UUID to lower-case UUID
  97. .replace(/(?<=\/id=)([A-F0-9-]+)$/g, (t) => t.toLowerCase())
  98. // fix bad escaped characters
  99. .replace(/%2f/g, replacer);
  100. });
  101. };
  102.  
  103. function titlePage() {
  104. const canonical = document.querySelector('.panel:last-of-type .padded-panel')
  105. ?.innerText?.match(/should be linked to:\n(.+)/)?.[1]?.replace(/^http:/, 'https:');
  106. if (canonical)
  107. history.replaceState(null, '', canonical);
  108.  
  109. const titleHeading = document.querySelector('h1');
  110. // remove trailing space
  111. titleHeading.textContent = titleHeading.textContent.replace(/[ \t\n]+$/, '');
  112. formatTitle(titleHeading);
  113.  
  114. const correct = document.querySelector('#correct');
  115.  
  116. if (correct) {
  117. const filmIdBio = makeBioEntry('Film ID', correct.querySelector('input[name="FilmID"]')?.value);
  118. filmIdBio[0].style.marginTop = '4em';
  119. correct.before(...filmIdBio);
  120.  
  121. const links = [];
  122. if (canonical) {
  123. const link = document.createElement('a');
  124. link.href = canonical;
  125. link.title = link.href;
  126. link.innerText = 'Title Page Link';
  127. Object.assign(link.style, {
  128. color: '#337ab7',
  129. margin: '1em 0',
  130. });
  131. links.push(link);
  132. }
  133.  
  134. // Legacy link
  135. const titleAndYear = titleHeading.innerText.match(/^(.+) \((\d+)\)$/)?.slice(1) ?? [];
  136. const legacyLink = document.createElement('a');
  137. legacyLink.href = makeLegacyTitleLink(...titleAndYear);
  138. legacyLink.title = legacyLink.href;
  139. legacyLink.innerText = 'Legacy Title Page Link';
  140. Object.assign(legacyLink.style, {
  141. color: '#337ab7',
  142. margin: '1em 0',
  143. });
  144. links.push(legacyLink);
  145.  
  146. correct.before(...makeBioEntry('🔗 Links', ...links));
  147. }
  148.  
  149. const releaseDateElement = getBioDataElement('RELEASE DATE');
  150. const releaseDateNodes = Array.from(releaseDateElement.childNodes).filter((node) => node.nodeType === node.TEXT_NODE);
  151. releaseDateNodes.forEach((node) => {
  152. const text = node.textContent.trim();
  153. const releaseDate = text.replace(/ \(.+\)$/, '');
  154. if (!releaseDate || releaseDate === 'No Data')
  155. return;
  156. releaseDateElement.insertBefore(makeISODateElement(releaseDate), node);
  157. releaseDateElement.insertBefore(document.createElement('br'), node);
  158. });
  159.  
  160. setupScenePerformers();
  161. }
  162.  
  163. /**
  164. * @param {HTMLHeadingElement} titleHeading
  165. */
  166. const formatTitle = (titleHeading) => {
  167. // Break up the title text node
  168. for (let i = 0; i < Math.min(titleHeading.childNodes.length, 10); i++) {
  169. const node = titleHeading.childNodes[i];
  170. const open = node.textContent.indexOf('(', 1);
  171. const close = node.textContent.indexOf(')', open + 1) + 1;
  172. if (open < 0 && close <= 0) continue;
  173. node.splitText([open, close].find((v) => v > -1));
  174. }
  175.  
  176. /** @type {[RegExpMatchArray | null, string][]} */
  177. ([ /* roman numerals */
  178. [titleHeading.innerText.match(/(\(X{0,3}(IX|IV|V?I{0,3})\))/), '#c8c8c8'],
  179. /* year */
  180. [titleHeading.innerText.match(/(\(\d{1,4}\))\s*$/), '#828282'],
  181. ]).forEach(([match, color]) => {
  182. if (!(match?.[1])) return;
  183. const curNode = Array.from(titleHeading.childNodes).find((node) => node.textContent.includes(match?.[1]));
  184. const newNode = document.createElement('small');
  185. newNode.style.color = color;
  186. newNode.textContent = curNode.textContent;
  187. titleHeading.replaceChild(newNode, curNode);
  188. });
  189. };
  190.  
  191. const setupScenePerformers = () => {
  192. const sceneInfo = document.querySelector('#sceneinfo');
  193. if (!sceneInfo) return;
  194. const scenes = Array.from(sceneInfo.querySelectorAll('table tr'));
  195. if (scenes.length === 0) return;
  196.  
  197. const castboxes = Array.from(document.querySelectorAll('.castbox')).map((cb) => ({
  198. name: cb.querySelector('a').lastChild.textContent,
  199. cb,
  200. }));
  201.  
  202. GM.addStyle(`
  203. .ext__scene-performers-toggle { white-space: nowrap; }
  204. .ext__scene-performers { display: flex; flex-wrap: wrap; column-gap: 1em; }
  205. .ext__scene-performers .multiple-matches { background: #bf0000cc; color: white; flex-basis: 100%; font-weight: 600; margin: 0.5em 1em 0 0; padding: 0.25em 0.6em; }
  206. .ext__scene-performers .castbox { max-width: 180px; min-height: unset; float: unset; margin-left: 0; }
  207. .ext__scene-performers .castbox img.headshot { margin-left: -14px; }
  208. `);
  209.  
  210. const toggleStates = new Array(scenes.length).fill(false);
  211. const sceneToggles = scenes.map((sceneRow, sceneIndex) => {
  212. const sceneLabel = sceneRow.querySelector(':scope > td:first-of-type');
  213. const sceneLabelText = sceneLabel.innerText.toLowerCase();
  214. const scenePerformers = sceneLabel.nextElementSibling;
  215. const toggle = (newState = undefined) => {
  216. let sceneContainer = sceneRow.querySelector('.ext__scene-performers');
  217. if (sceneContainer) {
  218. sceneContainer.style.display = sceneContainer.style.display === 'none' || newState === true ? '' : 'none';
  219. toggleStates[sceneIndex] = sceneContainer.style.display !== 'none';
  220. return;
  221. } else if (newState === false) {
  222. return;
  223. }
  224. const performers = scenePerformers.innerText
  225. .match(/^.+$/m)[0] // grab first row
  226. .replace(/ \[[a-z0-9 ]+\]/gi, '') // remove tags
  227. .split(/, /g); // split names
  228. sceneContainer = document.createElement('div');
  229. sceneContainer.className = 'ext__scene-performers';
  230. performers.forEach((performer) => {
  231. // FIXME: 2+ performers with the same name, all in the same scene - https://ibb.co/sJhtKzy
  232. const matches = castboxes.filter(({ name }) => name === performer);
  233. if (matches.length === 0) {
  234. const [count, text] = performer.match(/^(\d+) (.+?)s?$/)?.slice(1, 3) ?? [1, performer];
  235. for (let i = 1; i <= count; i++) {
  236. const node = document.createElement('div');
  237. node.className = 'castbox';
  238. const p = document.createElement('p');
  239. const placeholder = document.createElement('div');
  240. Object.assign(placeholder.style, { width: '170px', height: '200px', padding: '1em' });
  241. const na = document.createElement('abbr');
  242. na.title = `Performer "${performer}" was not found above`;
  243. na.innerText = '[N/A]';
  244. p.append(placeholder, `${count == 1 ? performer : text} `, na);
  245. node.append(p);
  246. sceneContainer.append(node);
  247. }
  248. } else {
  249. if (matches.length > 1) {
  250. const warning = document.createElement('div');
  251. warning.innerText = `multiple matches for "${performer}"`;
  252. warning.className = 'multiple-matches';
  253. sceneContainer.prepend(warning);
  254. }
  255. matches.forEach(({ cb: performerCB }) => {
  256. const node = performerCB.cloneNode(true);
  257. // remove empty elements and extra line breaks from the inner paragraph element
  258. for (const cn of Array.from(node.firstElementChild.childNodes).reverse()) {
  259. if (cn.textContent.trim()) break;
  260. cn.remove();
  261. }
  262. sceneContainer.append(node);
  263. });
  264. }
  265. });
  266. scenePerformers.append(sceneContainer);
  267. toggleStates[sceneIndex] = true;
  268. };
  269.  
  270. const sceneToggle = document.createElement('div');
  271. sceneLabel.append(sceneToggle);
  272. sceneToggle.append(sceneLabel.firstChild);
  273. Object.assign(sceneToggle.style, { cursor: 'pointer', textDecoration: 'underline' });
  274. sceneToggle.title = `View performers for ${sceneLabelText}`;
  275. sceneToggle.className = 'ext__scene-performers-toggle';
  276. sceneToggle.addEventListener('click', () => {
  277. if (window.getSelection().type === 'Range') return; // Prevent unwanted action when selecting text
  278. toggle();
  279. });
  280.  
  281. return toggle;
  282. });
  283.  
  284. // Toggle all scenes
  285. const sceneInfoHeading = sceneInfo.querySelector('.panel-heading h3');
  286. Object.assign(sceneInfoHeading.style, { cursor: 'pointer', textDecoration: 'double underline' });
  287. sceneInfoHeading.title = `View performers for all ${scenes.length} scenes\n [hold Alt to reset]`;
  288. sceneInfoHeading.addEventListener('click', (ev) => {
  289. if (window.getSelection().type === 'Range') return; // Prevent unwanted action when selecting text
  290. if (ev.altKey) {
  291. sceneInfo.querySelectorAll('.ext__scene-performers').forEach((el) => el.remove());
  292. return toggleStates.fill(false);
  293. }
  294. const newState = toggleStates.filter(Boolean).length < scenes.length;
  295. sceneToggles.forEach((toggle) => toggle(newState));
  296. });
  297. };
  298.  
  299. function personPage() {
  300. makeExportButton();
  301.  
  302. const canonical = document.querySelector('#perfwith a[href^="/person.rme/"]')?.href;
  303. if (canonical)
  304. history.replaceState(null, '', canonical);
  305.  
  306. const nameHeading = document.querySelector('h1');
  307. // remove trailing space
  308. nameHeading.textContent = nameHeading.textContent.replace(/[ \n]$/, '');
  309. // Director page
  310. if (/\/gender=d\//.test(window.location.pathname)) {
  311. const directorPage = document.createElement('b');
  312. directorPage.innerText = 'Director-only page:';
  313. const [maleLink, femaleLink] = ['male', 'female'].map((gender) => {
  314. const a = document.createElement('a');
  315. a.href = (canonical ?? window.location.href).replace('/gender=d/', `/gender=${gender.charAt(0)}/`);
  316. a.innerText = gender;
  317. return a;
  318. });
  319. const directorHelp = document.createElement('div');
  320. directorHelp.append(directorPage, ' try the ', maleLink, ' or ', femaleLink, ' performer pages');
  321. nameHeading.after(directorHelp);
  322. }
  323.  
  324. const corrections = document.querySelector('#corrections');
  325.  
  326. if (corrections) {
  327. const perfIdCorrectionInput = corrections.querySelector('input[name="PerfID"]');
  328. const gender = (
  329. ({ Woman: 'f', Man: 'm', 'Trans woman': 'tf', 'Trans man': 'tm' })[getBioDataElement('GENDER')?.innerText]
  330. ?? corrections.querySelector('input[name="Gender"]')?.value
  331. );
  332. const perfIdScenePair = document.querySelector('#scenepairings')?.dataset.src.match(/\/perfid=(.+)$/)?.[1];
  333. const perfIdHeadshot = document.querySelector('#headshot > img:not([src*="/nophoto"])')?.src.match(/\/headshots\/(.+?)(?:\.jpg$|_t?[fmd]_)/)?.[1];
  334. const perfId = perfIdCorrectionInput?.value ?? perfIdScenePair ?? perfIdHeadshot;
  335.  
  336. const perfIdBio = makeBioEntry('Performer ID', `${perfId ?? '?'} [${gender.toUpperCase()}]`);
  337. perfIdBio[0].style.marginTop = '4em';
  338. corrections.before(...perfIdBio);
  339.  
  340. const links = [];
  341. if (canonical) {
  342. const link = document.createElement('a');
  343. link.href = canonical;
  344. link.title = link.href;
  345. link.innerText = 'Performer Page Link';
  346. Object.assign(link.style, {
  347. color: '#337ab7',
  348. margin: '1em 0',
  349. });
  350. links.push(link);
  351. }
  352.  
  353. // Legacy link
  354. if (perfId && gender) {
  355. const legacyLink = document.createElement('a');
  356. legacyLink.href = makeLegacyPerformerLink(perfId, gender, nameHeading.innerText);
  357. legacyLink.title = legacyLink.href;
  358. legacyLink.innerText = 'Legacy Performer Page Link';
  359. Object.assign(legacyLink.style, {
  360. color: '#337ab7',
  361. margin: '1em 0',
  362. });
  363. links.push(legacyLink);
  364. }
  365.  
  366. if (links.length > 0)
  367. corrections.before(...makeBioEntry('🔗 Links', ...links));
  368. }
  369.  
  370. performerDate(getBioDataElement('BIRTHDAY'));
  371. performerDate(getBioDataElement('DATE OF DEATH'));
  372.  
  373. ['HEIGHT', 'WEIGHT'].forEach((headingText) => {
  374. const node = /** @type {Text | null | undefined} */ (getBioDataElement(headingText)?.firstChild);
  375. const match = node?.textContent.match(/(?<= \()(\d+) [a-z]+\)$/);
  376. if (!(match && node)) return;
  377. const metricNode = node.splitText(match.index); // split before metric value
  378. metricNode.splitText(match[1].length); // split after metric value
  379. metricNode.replaceWith(makeQuickSelect(match[1])); // replace metric value with quick-select
  380. });
  381.  
  382. // examples: Lee Stone, Richard Moulton
  383. const akasDirectorElement = getBioDataElement('DIRECTOR AKA');
  384. if (/(?!^)No known aliases$/.test(akasDirectorElement?.innerText)) {
  385. akasDirectorElement.innerText = akasDirectorElement.innerText.match(/^(.+)No known aliases$/)[1];
  386. }
  387.  
  388. const akasElement = getBioDataElement('AKA') ?? getBioDataElement('PERFORMER AKA');
  389. const akas = akasElement?.innerText.trim();
  390. // empty Performer AKA
  391. if (akas === '') {
  392. akasElement.innerText = 'No known aliases';
  393. }
  394. if (akas && akas !== 'No known aliases') {
  395. const copyButtonDefaultText = '[copy names]';
  396.  
  397. const akasCopy = document.createElement('a');
  398. akasCopy.innerText = copyButtonDefaultText;
  399. akasCopy.title = 'Copy only the names used (removes site names)'
  400. akasCopy.id = 'copy-akas';
  401. Object.assign(akasCopy.style, { float: 'right', cursor: 'pointer', lineHeight: 1 });
  402.  
  403. akasCopy.addEventListener('click', async (ev) => {
  404. ev.stopPropagation();
  405. ev.preventDefault();
  406.  
  407. // https://regex101.com/r/7Ad3U1/2
  408. const names = akas.replace(/ \(.+?\)/g, '').split(/[,;\n] ?/g).map((s) => s.trim()).filter(Boolean);
  409. if (!names || names.length === 0) {
  410. akasCopy.innerText = '❌ Failed!';
  411. akasCopy.style.color = 'red';
  412. return;
  413. }
  414.  
  415. const result = names.join(', ');
  416. GM.setClipboard(result);
  417. akasCopy.innerText = '✔ Copied to clipboard';
  418. akasCopy.style.color = 'green';
  419. akasElement.innerText = result;
  420. akasElement.style.backgroundColor = 'yellow';
  421.  
  422. await wait(1500);
  423. akasCopy.innerText = copyButtonDefaultText;
  424. akasCopy.style.color = null;
  425. akasElement.innerText = akas;
  426. akasElement.style.backgroundColor = '';
  427. })
  428.  
  429. akasElement.previousElementSibling.append(akasCopy);
  430. }
  431. }
  432.  
  433. /**
  434. * @param {HTMLElement} [dateEl]
  435. */
  436. const performerDate = (dateEl) => {
  437. if (!dateEl) return;
  438. const dateText = dateEl.innerText;
  439. const fullDate = dateText.trim().match(/([A-Z][a-z]+ \d{1,2}, \d{4})\b/);
  440. if (fullDate) {
  441. dateEl.prepend(makeISODateElement(fullDate[1]), document.createElement('br'));
  442. } else {
  443. const partialDate = dateText.trim().match(/(?<month>\?\?|\d{1,2})\/(?<day>\?\?|\d{1,2})\/(?<year>\d{2}[\d?]{2})/);
  444. if (partialDate) {
  445. const { year, month, day } = partialDate.groups;
  446. const exactYear = /^\d{4}$/.test(year);
  447. if (exactYear) {
  448. const dateParts = [year, month, day].join('-');
  449. const firstQM = dateParts.indexOf('?', 4) - 1;
  450. const [isoDate, remainder] = [dateParts.slice(0, firstQM), dateParts.slice(firstQM)];
  451. dateEl.prepend(makeQuickSelect(isoDate), remainder, document.createElement('br'));
  452. }
  453.  
  454. if (month !== '??') {
  455. const partialDateStr = (new Date(dateText.replace(/\?\?/g, '01') + ' 12:00'))
  456. .toLocaleString('en-us', {
  457. month: month === '??' ? undefined : 'long',
  458. day: day === '??' ? undefined : 'numeric',
  459. year: exactYear ? 'numeric' : undefined,
  460. }) + (exactYear ? '' : `, ${year}`);
  461. dateEl.lastChild.before(document.createTextNode(partialDateStr), document.createElement('br'));
  462. }
  463. }
  464. }
  465. }
  466.  
  467. function studioDistribSelectPage() {
  468. const select = document.querySelector('select[name="Studio"], select[name="Distrib"]');
  469. const pageType = select.closest('form').getAttribute('action').replace(/^\/|\.rme\/$/g, '').toLowerCase();
  470. const selectType = select.name.toLowerCase();
  471. const fullType = selectType === 'distrib' ? 'distributor' : selectType;
  472. const listId = `${selectType}-list`;
  473. const submit = document.querySelector('form input[type="submit"]');
  474.  
  475. const input = document.createElement('input');
  476. input.type = 'text';
  477. input.placeholder = `Lookup ${fullType}...`;
  478. input.setAttribute('list', listId);
  479. Object.assign(input.style, {
  480. display: 'block',
  481. width: window.getComputedStyle(select).width,
  482. marginBottom: '.5rem',
  483. });
  484. select.before(input);
  485. input.focus();
  486.  
  487. const datalist = document.createElement('datalist');
  488. datalist.id = listId;
  489. for (const option of select.children) {
  490. const cloned = option.cloneNode(true);
  491. cloned.removeAttribute('value');
  492. datalist.append(cloned);
  493. }
  494. select.before(datalist);
  495.  
  496. const escapeRegex = (string) => {
  497. return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
  498. };
  499.  
  500. input.addEventListener('input', () => {
  501. const value = input.value.trim();
  502. if (!value) return;
  503. const search = new RegExp(`^${escapeRegex(value)}`, 'i');
  504. const found = Array.from(select.children).find((o) => search.test(o.innerText));
  505. if (!found) return;
  506. select.value = found.value;
  507. });
  508.  
  509. const handleClick = (/** @type {Event} */ ev) => {
  510. if (!select.value)
  511. return;
  512.  
  513. ev.preventDefault();
  514. ev.stopPropagation();
  515.  
  516. const displayName = select.selectedOptions[0].textContent.replace(/[^a-z0-9.-]/ig, '-');
  517. window.location = `https://www.iafd.com/${pageType}.rme/${selectType}=${select.value}/${displayName}.htm`;
  518. };
  519.  
  520. select.addEventListener('dblclick', (ev) => {
  521. if (ev.target instanceof HTMLOptionElement)
  522. return handleClick(ev);
  523. });
  524. select.addEventListener('keyup', (/** @type {KeyboardEvent} */ ev) => {
  525. if (ev.key === 'Enter')
  526. return handleClick(ev);
  527. });
  528.  
  529. submit.addEventListener('click', handleClick);
  530. }
  531.  
  532. function studioDistribPage() {
  533. makeExportButton();
  534. }
  535.  
  536. async function makeExportButton() {
  537. const filter = await elementReady('div[id$="_filter"]');
  538. const type = filter.id.split('_')[0];
  539.  
  540. const exportButtonDefaultText = 'Export CSV';
  541. const exportTimestamp = (new Date()).toISOString();
  542.  
  543. const tools = document.createElement('div');
  544. Object.assign(tools.style, {
  545. marginRight: '.5em',
  546. display: 'inline-block',
  547. });
  548. filter.prepend(tools);
  549.  
  550. const button = document.createElement('button');
  551. button.type = 'button';
  552. button.innerText = exportButtonDefaultText;
  553. button.style.marginRight = '.5em';
  554. tools.prepend(button);
  555.  
  556. (async () => {
  557. const info = await Promise.race([
  558. elementReady(`div#${type}_info`).then((el) => /** @type {HTMLDivElement} */ (el).innerText),
  559. wait(5000).then(() => null),
  560. ]);
  561.  
  562. if (!info) return;
  563.  
  564. /** @param {string} s */
  565. const toNumber = (s) => Number(s?.replace(/,/, ''));
  566.  
  567. const { start, end, total } = info
  568. .match(/Showing (?<start>[\d,]+) to (?<end>[\d,]+) of (?<total>[\d,]+) entries/i)
  569. ?.groups || {};
  570. const count = toNumber(end) - Math.max(toNumber(start) - 1, 0);
  571. const totalCount = toNumber(total);
  572. let countLabel = `Count: ${count}`;
  573. if (count !== totalCount)
  574. countLabel += ` out of ${total}`;
  575. button.title = countLabel;
  576. })();
  577.  
  578. button.addEventListener('click', async () => {
  579. const output = makeOutput(type);
  580. if (!output) {
  581. button.innerText = '❌ Failed!';
  582. button.style.backgroundColor = 'red';
  583. return;
  584. }
  585.  
  586. GM.setClipboard(output);
  587. button.innerText = '✔ Copied to clipboard';
  588. button.style.backgroundColor = 'yellow';
  589.  
  590. await wait(1500);
  591. button.innerText = exportButtonDefaultText;
  592. button.style.backgroundColor = '';
  593. });
  594.  
  595. /** @param {string} type */
  596. const makeOutput = (type) => {
  597. const dataRows = Array.from(document.querySelectorAll(`div#${type}_wrapper .dataTable tbody > tr`));
  598. let columns;
  599. let csv;
  600.  
  601. if (type === 'studio' || type === 'distable') {
  602. const data = dataRows.map((tr) => ({
  603. url: tr.children[0].querySelector('a').href,
  604. title: tr.children[0].innerText,
  605. studio: tr.children[1].innerText,
  606. year: Number(tr.children[2].innerText),
  607. }));
  608.  
  609. columns = ['Title', 'Studio', 'Year', 'URL'];
  610. csv = data.map((d) => columns.map((c) => d[c.toLowerCase()]).join('\t'));
  611. } else if (type === 'personal') {
  612. const data = dataRows.map((tr) => ({
  613. url: tr.children[0].querySelector('a').href,
  614. title: tr.children[0].innerText,
  615. year: Number(tr.children[1].innerText),
  616. distributor: tr.children[2].innerText,
  617. notes: tr.children[3].innerText,
  618. }));
  619.  
  620. columns = ['Title', 'Distributor', 'Year', /*'Notes', */'URL'];
  621. csv = data.map((d) => columns.map((c) => d[c.toLowerCase()]).join('\t'));
  622. } else {
  623. return null;
  624. }
  625.  
  626. return columns.join('\t') +`\t${exportTimestamp}` + '\n' + csv.join('\n') + '\n';
  627. };
  628. }
  629.  
  630. function personUpdatePage() {
  631. }
  632.  
  633. async function tattooLookupPage() {
  634. const info = await Promise.race([
  635. elementReady(`div[id^="tat"][id$="_info"]`).then((el) => /** @type {HTMLDivElement} */ (el).innerText),
  636. wait(5000).then(() => null),
  637. ]);
  638.  
  639. if (!info) return;
  640. }
  641.  
  642. function setSearchField() {
  643. const field = document.querySelector('#the-basics > input[name="searchstring"]');
  644. const params = new URLSearchParams(window.location.search);
  645. field.value = params.get('searchstring');
  646. }
  647.  
  648. const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms));
  649.  
  650. /**
  651. * Waits for an element satisfying selector to exist, then resolves promise with the element.
  652. * Useful for resolving race conditions.
  653. *
  654. * @param {string} selector
  655. * @param {HTMLElement} [parentEl]
  656. * @returns {Promise<Element>}
  657. */
  658. function elementReady(selector, parentEl) {
  659. return new Promise((resolve, reject) => {
  660. let el = (parentEl || document).querySelector(selector);
  661. if (el) {resolve(el);}
  662. new MutationObserver((mutationRecords, observer) => {
  663. // Query for elements matching the specified selector
  664. Array.from((parentEl || document).querySelectorAll(selector)).forEach((element) => {
  665. resolve(element);
  666. //Once we have resolved we don't need the observer anymore.
  667. observer.disconnect();
  668. });
  669. })
  670. .observe(parentEl || document.documentElement, {
  671. childList: true,
  672. subtree: true
  673. });
  674. });
  675. }
  676.  
  677. main();