Sleazy Fork is available in English.

IAFD

Add extra utilities to iafd.com

// ==UserScript==
// @name        IAFD
// @author      peolic
// @version     4.099
// @description Add extra utilities to iafd.com
// @icon        https://www.iafd.com/favicon-196x196.png
// @namespace   https://github.com/peolic
// @match       https://www.iafd.com/*
// @grant       GM.setClipboard
// @grant       GM.addStyle
// @homepageURL https://gist.github.com/peolic/9e2981a8a14a49b9626cb277f878b157
// ==/UserScript==

function main() {
  makeInternalLinksHTTPS();
  fixIAFDLinks();

  const { pathname } = window.location;

  if (/^\/title\.(asp|rme\/)/.test(pathname))
    return titlePage();
  if (/^\/person\.(asp|rme\/)/.test(pathname))
    return personPage();
  if (/^\/(([a-z]{4}_)?studio|distrib)\.(asp|rme\/)$/.test(pathname))
    return studioDistribSelectPage();
  if (/^\/(studio|distrib)\.(asp\?|rme\/)(studio|distrib)=\d+/.test(pathname))
    return studioDistribPage();
  if (/^\/(new|updated)(perfs|headshots)\.asp/.test(pathname))
    return personUpdatePage();
  if (/^\/lookuptat\.asp/.test(pathname))
    return tattooLookupPage();
  if (/^\/results\.asp/.test(pathname))
    return setSearchField();
}

const getBioDataElement = (headingText) => {
  return Array.from(document.querySelectorAll('p.bioheading'))
    .find(e => e.innerText.localeCompare(headingText, 'en', { sensitivity: 'accent' }) === 0)
    ?.nextElementSibling;
};

const makeBioEntry = (heading, ...data) => {
  return [['heading', heading], ...data.map((d) => ['data', d])].map(([type, text]) => {
    const p = document.createElement('p');
    p.classList.add(`bio${type}`);
    if (text instanceof Node) p.append(text);
    else p.innerText = text;
    return p;
  });
};

const makeQuickSelect = (text) => {
  const b = document.createElement('b');
  b.style.userSelect = 'all';
  b.innerText = text;
  return b;
};

const makeISODateElement = (date) => {
  const isoDate = new Date(`${date} 0:00 UTC`).toISOString().slice(0, 10);
  return makeQuickSelect(isoDate);
};


const slug = (text, char='-') => {
  return encodeURI(text.replace(/[^a-z0-9_.,:'-]+/gi, '___'))
    .toLowerCase()
    .replace(/___/g, char);
};

const makeLegacyTitleLink = (title, year) => {
  const encodedTitle = slug(title, '+').replace(/^\+|\++$|^(a|an|the)\+/g, '');
  return `https://www.iafd.com/title.rme/title=${encodedTitle}/year=${year}/${encodedTitle.replace(/\+/g, '-')}.htm`;
};

const makeLegacyPerformerLink = (perfId, gender, name=undefined) => {
  const nameSlug = name ? `/${slug(name, '-')}.htm` : '';
  return `https://www.iafd.com/person.rme/perfid=${perfId}/gender=${gender}${nameSlug}`;
};

function makeInternalLinksHTTPS() {
  /** @type {NodeListOf<HTMLAnchorElement>} */
  (document.querySelectorAll('a[href^="http://www.iafd.com/"]')).forEach((el) => {
    el.href = el.href.replace(/^http:/, 'https:');
  });
};

function fixIAFDLinks() {
  const replacer = (_, offset, string) => {
    if (offset > string.indexOf('/year=')) return '-';
    else return '+';
  };
  /** @type {NodeListOf<HTMLAnchorElement>} */
  (document.querySelectorAll('a[href*="//www.iafd.com/"]')).forEach((el) => {
    el.href = el.href
      // upper-case UUID to lower-case UUID
      .replace(/(?<=\/id=)([A-F0-9-]+)$/g, (t) => t.toLowerCase())
      // fix bad escaped characters
      .replace(/%2f/g, replacer);
  });
};

function titlePage() {
  const canonical = document.querySelector('.panel:last-of-type .padded-panel')
    ?.innerText?.match(/should be linked to:\n(.+)/)?.[1]?.replace(/^http:/, 'https:');
  if (canonical)
    history.replaceState(null, '', canonical);

  const titleHeading = document.querySelector('h1');
  // remove trailing space
  titleHeading.textContent = titleHeading.textContent.replace(/[ \n]$/, '');

  const correct = document.querySelector('#correct');

  if (correct) {
    const filmIdBio = makeBioEntry('Film ID', correct.querySelector('input[name="FilmID"]')?.value);
    filmIdBio[0].style.marginTop = '4em';
    correct.before(...filmIdBio);

    const links = [];
    if (canonical) {
      const link = document.createElement('a');
      link.href = canonical;
      link.title = link.href;
      link.innerText = 'Title Page Link';
      Object.assign(link.style, {
        color: '#337ab7',
        margin: '1em 0',
      });
      links.push(link);
    }

    // Legacy link
    const titleAndYear = titleHeading.innerText.match(/^(.+) \((\d+)\)$/)?.slice(1) ?? [];
    const legacyLink = document.createElement('a');
    legacyLink.href = makeLegacyTitleLink(...titleAndYear);
    legacyLink.title = legacyLink.href;
    legacyLink.innerText = 'Legacy Title Page Link';
    Object.assign(legacyLink.style, {
      color: '#337ab7',
      margin: '1em 0',
    });
    links.push(legacyLink);

    correct.before(...makeBioEntry('🔗 Links', ...links));
  }

  const titleSeq = titleHeading.innerText.match(/\((X{0,3}(IX|IV|V?I{0,3}))\)/);
  if (titleSeq) {
    // Break up the text node
    for (let i=0; i < titleHeading.childNodes.length; i++) {
      if (i >= 10) break;
      const node = titleHeading.childNodes[i];
      const open = node.textContent.indexOf('(', 1),
            close = node.textContent.indexOf(')');
      if (!(open || close)) continue;
      if (close && close+1 >= node.length) break;
      node.splitText(open ?? close+1);
    }
    const seqNode = Array.from(titleHeading.childNodes).find((node) => node.textContent.includes(titleSeq?.[1]));
    const seq = document.createElement('small');
    seq.style.color = 'lightgray';
    seq.textContent = seqNode.textContent;
    titleHeading.replaceChild(seq, seqNode);
  }

  const releaseDateElement = getBioDataElement('RELEASE DATE');
  const releaseDateNodes = Array.from(releaseDateElement.childNodes).filter((node) => node.nodeType === node.TEXT_NODE);
  releaseDateNodes.forEach((node) => {
    const text = node.textContent.trim();
    const releaseDate = text.replace(/ \(.+\)$/, '');
    if (!releaseDate || releaseDate === 'No Data')
      return;
    releaseDateElement.insertBefore(makeISODateElement(releaseDate), node);
    releaseDateElement.insertBefore(document.createElement('br'), node);
  });

  setupScenePerformers();
}

const setupScenePerformers = () => {
  const sceneInfo = document.querySelector('#sceneinfo');
  if (!sceneInfo) return;
  const scenes = Array.from(sceneInfo.querySelectorAll('table tr'));
  if (scenes.length === 0) return;

  const castboxes = Array.from(document.querySelectorAll('.castbox')).map((cb) => ({
    name: cb.querySelector('a').lastChild.textContent,
    cb,
  }));

  GM.addStyle(`
.ext__scene-performers-toggle { white-space: nowrap; }
.ext__scene-performers { display: flex; flex-wrap: wrap; column-gap: 1em; }
.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; }
.ext__scene-performers .castbox { max-width: 180px; min-height: unset; float: unset; margin-left: 0; }
.ext__scene-performers .castbox img.headshot { margin-left: -14px; }
`);

  const toggleStates = new Array(scenes.length).fill(false);
  const sceneToggles = scenes.map((sceneRow, sceneIndex) => {
    const sceneLabel = sceneRow.querySelector(':scope > td:first-of-type');
    const sceneLabelText = sceneLabel.innerText.toLowerCase();
    const scenePerformers = sceneLabel.nextElementSibling;
    const toggle = (newState = undefined) => {
      let sceneContainer = sceneRow.querySelector('.ext__scene-performers');
      if (sceneContainer) {
        sceneContainer.style.display = sceneContainer.style.display === 'none' || newState === true ? '' : 'none';
        toggleStates[sceneIndex] = sceneContainer.style.display !== 'none';
        return;
      } else if (newState === false) {
        return;
      }
      const performers = scenePerformers.innerText
        .match(/^.+$/m)[0] // grab first row
        .replace(/ \[[a-z0-9 ]+\]/gi, '') // remove tags
        .split(/, /g); // split names
      sceneContainer = document.createElement('div');
      sceneContainer.className = 'ext__scene-performers';
      performers.forEach((performer) => {
        const matches = castboxes.filter(({ name }) => name === performer);
        if (matches.length === 0) {
          const [count, text] = performer.match(/^(\d+) (.+?)s?$/)?.slice(1, 3) ?? [1, performer];
          for (let i = 1; i <= count; i++) {
            const node = document.createElement('div');
            node.className = 'castbox';
            const p = document.createElement('p');
            const placeholder = document.createElement('div');
            Object.assign(placeholder.style, { width: '170px', height: '200px', padding: '1em' });
            const na = document.createElement('abbr');
            na.title = `Performer "${performer}" was not found above`;
            na.innerText = '[N/A]';
            p.append(placeholder, `${count == 1 ? performer : text} `, na);
            node.append(p);
            sceneContainer.append(node);
          }
        } else {
          if (matches.length > 1) {
            const warning = document.createElement('div');
            warning.innerText = `multiple matches for "${performer}"`;
            warning.className = 'multiple-matches';
            sceneContainer.prepend(warning);
          }
          matches.forEach(({ cb: performerCB }) => {
            const node = performerCB.cloneNode(true);
            // remove empty elements and extra line breaks from the inner paragraph element
            for (const cn of Array.from(node.firstElementChild.childNodes).reverse()) {
              if (cn.textContent.trim()) break;
              cn.remove();
            }
            sceneContainer.append(node);
          });
        }
      });
      scenePerformers.append(sceneContainer);
      toggleStates[sceneIndex] = true;
    };

    const sceneToggle = document.createElement('div');
    sceneLabel.append(sceneToggle);
    sceneToggle.append(sceneLabel.firstChild);
    Object.assign(sceneToggle.style, { cursor: 'pointer', textDecoration: 'underline' });
    sceneToggle.title = `View performers for ${sceneLabelText}`;
    sceneToggle.className = 'ext__scene-performers-toggle';
    sceneToggle.addEventListener('click', () => {
      if (window.getSelection().type === 'Range') return; // Prevent unwanted action when selecting text
      toggle();
    });

    return toggle;
  });

  // Toggle all scenes
  const sceneInfoHeading = sceneInfo.querySelector('.panel-heading h3');
  Object.assign(sceneInfoHeading.style, { cursor: 'pointer', textDecoration: 'double underline' });
  sceneInfoHeading.title = `View performers for all ${scenes.length} scenes\n  [hold Alt to reset]`;
  sceneInfoHeading.addEventListener('click', (ev) => {
    if (window.getSelection().type === 'Range') return; // Prevent unwanted action when selecting text
    if (ev.altKey) {
      sceneInfo.querySelectorAll('.ext__scene-performers').forEach((el) => el.remove());
      return toggleStates.fill(false);
    }
    const newState = toggleStates.filter(Boolean).length < scenes.length;
    sceneToggles.forEach((toggle) => toggle(newState));
  });
};

function personPage() {
  makeExportButton();

  const canonical = document.querySelector('#perfwith a[href^="/person.rme/"]')?.href;
  if (canonical)
    history.replaceState(null, '', canonical);

  const nameHeading = document.querySelector('h1');
  // remove trailing space
  nameHeading.textContent = nameHeading.textContent.replace(/[ \n]$/, '');
  // Director page
  if (/\/gender=d\//.test(window.location.pathname)) {
    const directorPage = document.createElement('b');
    directorPage.innerText = 'Director-only page:';
    const [maleLink, femaleLink] = ['male', 'female'].map((gender) => {
      const a = document.createElement('a');
      a.href = (canonical ?? window.location.href).replace('/gender=d/', `/gender=${gender.charAt(0)}/`);
      a.innerText = gender;
      return a;
    });
    const directorHelp = document.createElement('div');
    directorHelp.append(directorPage, ' try the ', maleLink, ' or ', femaleLink, ' performer pages');
    nameHeading.after(directorHelp);
  }

  const corrections = document.querySelector('#corrections');

  if (corrections) {
    const perfIdCorrectionInput = corrections.querySelector('input[name="PerfID"]');
    const gender = corrections.querySelector('input[name="Gender"]')?.value;
    const perfIdScenePair = document.querySelector('#scenepairings')?.dataset.src.match(/\/perfid=(.+)$/)[1];
    const perfId = perfIdCorrectionInput?.value ?? perfIdScenePair;

    const perfIdBio = makeBioEntry('Performer ID', `${perfId} [${gender.toUpperCase()}]`);
    perfIdBio[0].style.marginTop = '4em';
    corrections.before(...perfIdBio);

    if (!perfIdCorrectionInput.getAttribute('value')) {
      corrections.querySelector('input[type="submit"]').style.color = 'red';
      perfIdCorrectionInput.setAttribute('value', perfId);
    }

    const links = [];
    if (canonical) {
      const link = document.createElement('a');
      link.href = canonical;
      link.title = link.href;
      link.innerText = 'Performer Page Link';
      Object.assign(link.style, {
        color: '#337ab7',
        margin: '1em 0',
      });
      links.push(link);
    }

    // Legacy link
    const legacyLink = document.createElement('a');
    legacyLink.href = makeLegacyPerformerLink(perfId, gender, nameHeading.innerText);
    legacyLink.title = legacyLink.href;
    legacyLink.innerText = 'Legacy Performer Page Link';
    Object.assign(legacyLink.style, {
      color: '#337ab7',
      margin: '1em 0',
    });
    links.push(legacyLink);

    corrections.before(...makeBioEntry('🔗 Links', ...links));
  }

  const birthDateElement = getBioDataElement('BIRTHDAY');
  const birthdayText = birthDateElement.innerText;
  const birthDate = birthdayText.trim().match(/([A-Z][a-z]+ \d{1,2}, \d{4})\b/);
  if (birthDate) {
    birthDateElement.prepend(makeISODateElement(birthDate[1]), document.createElement('br'));
  } else {
    const partialDate = birthdayText.trim().match(/(?<month>\?\?|\d{1,2})\/(?<day>\?\?|\d{1,2})\/(?<year>\d{2}[\d?]{2})/);
    if (partialDate) {
      const { year, month, day } = partialDate.groups;
      const exactYear = /^\d{4}$/.test(year);
      if (exactYear) {
        const dateParts = [year, month, day].join('-');
        const firstQM = dateParts.indexOf('?', 4)-1;
        const [isoDate, remainder] = [dateParts.slice(0, firstQM), dateParts.slice(firstQM)];
        birthDateElement.prepend(makeQuickSelect(isoDate), remainder, document.createElement('br'));
      }

      if (month !== '??') {
        const partialDateStr = (new Date(birthdayText.replace(/\?\?/g, '01') + ' 12:00'))
          .toLocaleString('en-us', {
            month: month === '??' ? undefined : 'long',
            day: day === '??' ? undefined : 'numeric',
            year: exactYear ? 'numeric' : undefined,
          }) + (exactYear ? '' : `, ${year}`);
        birthDateElement.lastChild.before(document.createTextNode(partialDateStr), document.createElement('br'));
      }
    }
  }

  const heightElement = getBioDataElement('HEIGHT');
  const height = heightElement?.innerText.trim().match(/\((\d+) cm\)/);
  if (height && heightElement) {
    heightElement.prepend(makeQuickSelect(height[1]), document.createElement('br'));
  }

  // example: Lee Stone
  const akasDirectorElement = getBioDataElement('DIRECTOR AKA');
  if (akasDirectorElement && akasDirectorElement.innerText.match(/^None No known aliases$/)) {
    akasDirectorElement.innerText = akasDirectorElement.innerText.replace(/^None /, '');
  }

  const akasElement = getBioDataElement('AKA') ?? getBioDataElement('PERFORMER AKA');
  const akas = akasElement?.innerText.trim();
  // empty Performer AKA
  if (akas === '') {
    akasElement.innerText = 'No known aliases';
  }
  if (akas && akas !== 'No known aliases') {
    const copyButtonDefaultText = '[copy names]';

    const akasCopy = document.createElement('a');
    akasCopy.innerText = copyButtonDefaultText;
    akasCopy.title = 'Copy only the names used (removes site names)'
    akasCopy.id = 'copy-akas';
    Object.assign(akasCopy.style, { float: 'right', cursor: 'pointer', lineHeight: 1 });

    akasCopy.addEventListener('click', async (ev) => {
      ev.stopPropagation();
      ev.preventDefault();

      // https://regex101.com/r/7Ad3U1/2
      const names = akas.replace(/ \(.+?\)/g, '').split(/[,;\n] ?/g).map((s) => s.trim()).filter(Boolean);
      if (!names || names.length === 0) {
        akasCopy.innerText = '❌ Failed!';
        akasCopy.style.color = 'red';
        return;
      }

      const result = names.join(', ');
      GM.setClipboard(result);
      akasCopy.innerText = '✔ Copied to clipboard';
      akasCopy.style.color = 'green';
      akasElement.innerText = result;
      akasElement.style.backgroundColor = 'yellow';

      await wait(1500);
      akasCopy.innerText = copyButtonDefaultText;
      akasCopy.style.color = null;
      akasElement.innerText = akas;
      akasElement.style.backgroundColor = '';
    })

    akasElement.previousElementSibling.append(akasCopy);
  }
}

function studioDistribSelectPage() {
  const select = document.querySelector('select[name="Studio"], select[name="Distrib"]');
  const pageType = select.closest('form').getAttribute('action').replace(/^\/|\.rme\/$/g, '').toLowerCase();
  const selectType = select.name.toLowerCase();
  const fullType = selectType === 'distrib' ? 'distributor' : selectType;
  const listId = `${selectType}-list`;
  const submit = document.querySelector('form input[type="submit"]');

  const input = document.createElement('input');
  input.type = 'text';
  input.placeholder = `Lookup ${fullType}...`;
  input.setAttribute('list', listId);
  Object.assign(input.style, {
    display: 'block',
    width: window.getComputedStyle(select).width,
    marginBottom: '.5rem',
  });
  select.before(input);
  input.focus();

  const datalist = document.createElement('datalist');
  datalist.id = listId;
  for (const option of select.children) {
    const cloned = option.cloneNode(true);
    cloned.removeAttribute('value');
    datalist.append(cloned);
  }
  select.before(datalist);

  const escapeRegex = (string) => {
    return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
  };

  input.addEventListener('input', () => {
    const value = input.value.trim();
    if (!value) return;
    const search = new RegExp(`^${escapeRegex(value)}`, 'i');
    const found = Array.from(select.children).find((o) => search.test(o.innerText));
    if (!found) return;
    select.value = found.value;
  });

  const handleClick = (/** @type {Event} */ ev) => {
    if (!select.value)
      return;

    ev.preventDefault();
    ev.stopPropagation();

    const displayName = select.selectedOptions[0].textContent.replace(/[^a-z0-9.-]/ig, '-');
    window.location = `https://www.iafd.com/${pageType}.rme/${selectType}=${select.value}/${displayName}.htm`;
  };

  select.addEventListener('dblclick', (ev) => {
    if (ev.target instanceof HTMLOptionElement)
      return handleClick(ev);
  });
  select.addEventListener('keyup', (/** @type {KeyboardEvent} */ ev) => {
    if (ev.key === 'Enter')
      return handleClick(ev);
  });

  submit.addEventListener('click', handleClick);
}

function studioDistribPage() {
  makeExportButton();
}

async function makeExportButton() {
  const filter = await elementReady('div[id$="_filter"]');
  const type = filter.id.split('_')[0];

  const exportButtonDefaultText = 'Export CSV';
  const exportTimestamp = (new Date()).toISOString();

  const tools = document.createElement('div');
  Object.assign(tools.style, {
    marginRight: '.5em',
    display: 'inline-block',
  });
  filter.prepend(tools);

  const button = document.createElement('button');
  button.type = 'button';
  button.innerText = exportButtonDefaultText;
  button.style.marginRight = '.5em';
  tools.prepend(button);

  (async () => {
    const info = await Promise.race([
      elementReady(`div#${type}_info`).then((el) => /** @type {HTMLDivElement} */ (el).innerText),
      wait(5000).then(() => null),
    ]);

    if (!info) return;

    /** @param {string} s */
    const toNumber = (s) => Number(s?.replace(/,/, ''));

    const { start, end, total } = info
      .match(/Showing (?<start>[\d,]+) to (?<end>[\d,]+) of (?<total>[\d,]+) entries/i)
      ?.groups || {};
    const count = toNumber(end) - Math.max(toNumber(start) - 1, 0);
    const totalCount = toNumber(total);
    let countLabel = `Count: ${count}`;
    if (count !== totalCount)
      countLabel += ` out of ${total}`;
    button.title = countLabel;
  })();

  button.addEventListener('click', async () => {
    const output = makeOutput(type);
    if (!output) {
      button.innerText = '❌ Failed!';
      button.style.backgroundColor = 'red';
      return;
    }

    GM.setClipboard(output);
    button.innerText = '✔ Copied to clipboard';
    button.style.backgroundColor = 'yellow';

    await wait(1500);
    button.innerText = exportButtonDefaultText;
    button.style.backgroundColor = '';
  });

  /** @param {string} type */
  const makeOutput = (type) => {
    const dataRows = Array.from(document.querySelectorAll(`div#${type}_wrapper .dataTable tbody > tr`));
    let columns;
    let csv;

    if (type === 'studio' || type === 'distable') {
      const data = dataRows.map((tr) => ({
        url: tr.children[0].querySelector('a').href,
        title: tr.children[0].innerText,
        studio: tr.children[1].innerText,
        year: Number(tr.children[2].innerText),
      }));

      columns = ['Title', 'Studio', 'Year', 'URL'];
      csv = data.map((d) => columns.map((c) => d[c.toLowerCase()]).join('\t'));
    } else if (type === 'personal') {
      const data = dataRows.map((tr) => ({
        url: tr.children[0].querySelector('a').href,
        title: tr.children[0].innerText,
        year: Number(tr.children[1].innerText),
        distributor: tr.children[2].innerText,
        notes: tr.children[3].innerText,
      }));

      columns = ['Title', 'Distributor', 'Year', /*'Notes', */'URL'];
      csv = data.map((d) => columns.map((c) => d[c.toLowerCase()]).join('\t'));
    } else {
      return null;
    }

    return columns.join('\t') +`\t${exportTimestamp}` + '\n' + csv.join('\n') + '\n';
  };
}

function personUpdatePage() {
}

async function tattooLookupPage() {
  const info = await Promise.race([
    elementReady(`div[id^="tat"][id$="_info"]`).then((el) => /** @type {HTMLDivElement} */ (el).innerText),
    wait(5000).then(() => null),
  ]);

  if (!info) return;
}

function setSearchField() {
  const field = document.querySelector('#the-basics > input[name="searchstring"]');
  const params = new URLSearchParams(window.location.search);
  field.value = params.get('searchstring');
}

const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * Waits for an element satisfying selector to exist, then resolves promise with the element.
 * Useful for resolving race conditions.
 *
 * @param {string} selector
 * @param {HTMLElement} [parentEl]
 * @returns {Promise<Element>}
 */
function elementReady(selector, parentEl) {
  return new Promise((resolve, reject) => {
    let el = (parentEl || document).querySelector(selector);
    if (el) {resolve(el);}
    new MutationObserver((mutationRecords, observer) => {
      // Query for elements matching the specified selector
      Array.from((parentEl || document).querySelectorAll(selector)).forEach((element) => {
        resolve(element);
        //Once we have resolved we don't need the observer anymore.
        observer.disconnect();
      });
    })
    .observe(parentEl || document.documentElement, {
      childList: true,
      subtree: true
    });
  });
}

main();