Greasy Fork is available in English.

BeatMods Compare

Compare mods available for different Beat Saber versions

// ==UserScript==
// @name        BeatMods Compare
// @namespace   forked_bytes
// @match       https://beatmods.com/*
// @grant       none
// @version     1.2.0
// @author      forked_bytes
// @license     0BSD
// @description Compare mods available for different Beat Saber versions
// ==/UserScript==

let versionSelect = null;
let versionContainer = null;
let compareSelect = null;
let compareContainer = null;
let sort = null;
let sortDir = null;
let cache = {};

const results = h('div', { className: 'compare-results' });
const showAll = h('input', { type: 'checkbox', checked: true, onchange: update });

document.head.appendChild(h('style', { textContent: `
  .compare-results {
    display: grid;
    grid-template-columns: auto auto;
    justify-content: center;
    gap: 0 2em;
  }
  .compare-results label {
    margin-left: 1em;
    font-size: 0.875rem;
  }
  .compare-results h5 {
    margin: 0.2em 0;
  }
  .compare-results ul {
    margin: 0;
    list-style-type: none;
  }
  .compare-results a {
    color: inherit;
  }
  .compare-version {
    color: #999;
  }
  .compare-added {
    color: #082;
    list-style-type: "+ ";
  }
  .compare-removed {
    color: #b99;
    text-decoration: line-through;
    list-style-type: "− ";
  }
  .compare-removed .compare-version {
    display: none;
  }
` }));

function main() {
  if (compareContainer?.isConnected) return;
  // Find "Game Version" dropdown
  versionSelect = document.querySelector('select[name=gv-select]');
  versionContainer = versionSelect?.parentNode.parentNode;
  if (!versionContainer) return;

  // Create "Compare Version" dropdown
  if (!compareContainer) {
    compareContainer = versionContainer.cloneNode(true);
    compareContainer.querySelector('label').textContent = 'Compare Version';
    compareSelect = compareContainer.querySelector('select');
    compareSelect.name = 'c-select';
    compareSelect.firstChild.textContent = 'None';
    compareSelect.firstChild.value = '';
    compareSelect.onchange = update;
  }
  versionContainer.insertAdjacentElement('afterend', compareContainer);
  compareSelect.value = '';
  // Trigger update when version or sort changes
  sort = compareContainer.nextElementSibling.querySelector('select');
  sortDir = compareContainer.nextElementSibling.nextElementSibling.querySelector('select');
  for (let select of [versionSelect, sort, sortDir]) {
    select.addEventListener('change', update);
  }
}

function update() {
  const versionA = versionSelect.value;
  const versionB = compareSelect.value;
  if (!versionB || versionA === 'any') {
    results.replaceChildren(); // Invalid version, hide comparison
    return;
  }
  // Get mods for both versions
  Promise.all([getMods(versionA), getMods(versionB)]).then(([modsA, modsB]) => {
    console.log(versionA, modsA);
    console.log(versionB, modsB);
    render(versionA, versionB, modsA, modsB);
  });
}

function getMods(version) {
  const url = `https://beatmods.com/api/v1/mod?search=&status=approved&gameVersion=${version}&sort=${sort.value}&sortDirection=${sortDir.value}`;
  return cache[url] || (cache[url] = fetch(url).then(r => r.json()));
}

function getDiff(modsA, modsB) {
  // Compare mods by name
  const namesA = new Set(modsA.map(m => m.name));
  const namesB = new Set(modsB.map(m => m.name));
  return {
    onlyA: modsA.filter(m => !namesB.has(m.name)),
    onlyB: modsB.filter(m => !namesA.has(m.name)),
    bothA: modsA.filter(m => namesB.has(m.name)),
    bothB: modsB.filter(m => namesA.has(m.name)),
  };
}

function render(versionA, versionB, modsA, modsB) {
  const diff = getDiff(modsA, modsB);
  // Render headers
  results.replaceChildren(
    h('h2', { textContent: `${versionA} (${modsA.length} total, +${diff.onlyA.length})` }),
    h('h2', { textContent: `${versionB} (${modsB.length} total, +${diff.onlyB.length})` }, [
      h('label', { textContent: 'Show all ' }, [showAll])
    ])
  );
  // Render mods
  if (sort.value === 'category_lower') {
    // Group by category
    const categories = Array.from(new Set(modsA.concat(modsB).map(m => m.category)));
    for (const cat of categories.sort()) {
      const a = modsA.filter(m => m.category === cat).sort((a, b) => a.name.localeCompare(b.name));
      const b = modsB.filter(m => m.category === cat).sort((a, b) => a.name.localeCompare(b.name));
      results.append(
        h('h5', { textContent: cat }),
        h('h5', { textContent: cat }),
        ...renderDiff(getDiff(a, b))
      );
    }
  } else {
    results.append(...renderDiff(diff));
  }
  document.querySelector('.mods').insertAdjacentElement('beforebegin', results);
}

function renderDiff(diff) {
  if (showAll.checked) {
    return [
      h('ul', {}, diff.onlyA.map(m => renderMod(m, 'compare-added'))),
      h('ul', {}, diff.onlyA.map(m => renderMod(m, 'compare-removed'))),
      h('ul', {}, diff.onlyB.map(m => renderMod(m, 'compare-removed'))),
      h('ul', {}, diff.onlyB.map(m => renderMod(m, 'compare-added'))),
      h('ul', {}, diff.bothA.map(m => renderMod(m))),
      h('ul', {}, diff.bothB.map(m => renderMod(m))),
    ];
  } else {
    return [
      h('ul', {}, diff.onlyA.map(m => renderMod(m))),
      h('ul', {}, diff.onlyB.map(m => renderMod(m))),
    ];
  }
}

function renderMod(mod, className = '') {
  return h('li', { className }, [
    h('a', {
      textContent: mod.name + ' ',
      href: mod.link,
      target: '_blank',
    }),
    h('small', {
      textContent: mod.version,
      className: 'compare-version',
    })
  ]);
}

function h(tag, {style, ...props} = {}, children = []) {
  const el = Object.assign(document.createElement(tag), props);
  if (style) Object.assign(el.style, style);
  el.append(...children);
  return el;
}

setInterval(main, 500);