☰

F95 Latest Match-based Sort

Sorts resources on Latest Update page: top -> most-important; bottom -> least relevant.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        F95 Latest Match-based Sort
// @namespace   1330126-edexal
// @match       *://f95zone.to/sam/latest_alpha/*
// @grant       GM.setValue
// @grant       GM.getValues
// @icon        https://external-content.duckduckgo.com/ip3/f95zone.to.ico
// @license     Unlicense
// @version     1.1.7
// @author      Edexal
// @description Sorts resources on Latest Update page: top -> most-important; bottom -> least relevant.
// @homepageURL https://sleazyfork.org/en/scripts/598317-f95-latest-match-based-sort
// @supportURL  https://github.com/Edexaal/scripts/issues
// @require     https://cdn.jsdelivr.net/gh/Edexaal/scripts@17861f671a93a6a1d41b8864391e3e37fac59e8f/_lib/utility.js
// @require     https://cdn.jsdelivr.net/gh/Edexaal/scripts@17861f671a93a6a1d41b8864391e3e37fac59e8f/_lib/metadata.js
// ==/UserScript==
(async () => {
  Edexal.addCSS(`
  #filter-block_match  {
    padding: 0;
    border-top: 1px solid #3f4043;
    & div ul{
      list-style: none;
      color: white;
      padding: 0;
    }
    & div:first-child {
      overflow: unset;
    }
    & h4 {
      color: #fc9b46 !important;
    }
    & hr {
      color: #c15858;
    }
  }
  #match-list {
    background-color: rgb(36,38,41);
    & div:first-child {
      background-color: rgb(19,21,23);
      border: 1px solid #333029;
      color: yellow;
      text-align: center;
      padding-top: 5px;
      padding-bottom: 5px;
      text-decoration: underline;
      font-weight: bold;
    }
    & div:last-child {
      & ul {
        height: 10em;
        overflow-y: scroll;
      }
      & ul li {
        padding-left: 20px;
        padding-bottom: 10px;
        &:hover {
          background-color: rgb(55,56,58);
          cursor: pointer;
        }
      }
    }
  }
  #match-tags {
    background-color: rgb(36,38,41);
    & ul li {
      display: flex;
      align-items: center;
      border: 1pt solid rgb(60, 61, 67);
      & button {
        flex: 1;
        font: bold 1.1em Arial, Verdana, sans-serif;
        background-color: #A3002C;
        color: #f9f920;
        padding: inherit;
        height: 30px;
        margin: 0;
        transition: 150ms opacity ease;
        &:hover {
          cursor: pointer;
          opacity: 0.9;
        }
        &:active {
          opacity: 0.8;
        }
        &::after {
            content: "[x]";
            color: inherit;
            margin-left: 8px;
        }
      }
    }
  }
  .ed-hide {
    display: none;
  }
  .ed-show {
    display: block !important;
  }
  `);
  let CONFIG = {category: {}, matches: null, pList: [], saveTimerID:null, saveDelay: 2000, newPageTimerID:null, newPageDelay: 1500};
  const CSS_SELECT = {matchList: "#match-list div:last-child ul", matchTags: "#match-tags ul", latestWrapper: "#latest-page_items-wrap_inner",
                      navPageBar:"#sub-nav_inner .sub-nav_paging", includeFilter: '#filter-block_tags .selectize-input',matchSect: '#filter-block_match',
                      latestResources:'div.resource-tile',lTileTags:'div.resource-tile .resource-tile_label-wrap_left',rTileTags:'div.resource-tile .resource-tile_label-wrap_right',
                     dropDownContent: '#filter-block_tags .selectize-dropdown-content', dropDown: '#filter-block_tags .selectize-dropdown'};
  const INVALID_CATEGORY_TAGS = new Set(['mods']);
  const EXTRA_TAGS = {watch: 'x1'};
  const CAT_TAG_CODES = {
    comics: TAG_CODE.comic,
    animations: TAG_CODE.animation,
    assets: TAG_CODE.asset,
  };
  const TIME_UNITS = ['min','hour','yesterday','day','week','month','year'];

  function showMatchListItem(tagCode,shouldHide) {
    const matchListItem = Edexal.$(`${CSS_SELECT.matchList} li[data-tag-code="${tagCode}"]`);
    if (!matchListItem) {
      return;
    }
    const className = 'ed-hide';
    if (shouldHide) {
      matchListItem.classList.add(className);
    } else {
      matchListItem.classList.remove(className);
    }
  }

  function updateMatchConfigList(tagCode) {
    CONFIG.matches.delete(tagCode);
    CONFIG.pList.push(tagCode);
  }

  function saveUserPriorities() {
    if (CONFIG.saveTimerID) {
      clearTimeout(CONFIG.saveTimerID);
    }
    CONFIG.saveTimerID = setTimeout(async () => {
      await GM.setValue('matches', Array.from(CONFIG.matches));
    }, CONFIG.saveDelay);
  }

  function matchTagsCount(dataArr) {
    return dataArr.reduce((accumVal, currentVal) => CONFIG.matches.has(currentVal) ? accumVal + 1 : accumVal, 0);
  }

  function hasWatchIconCount(el) {
    if (!CONFIG.matches.has(EXTRA_TAGS.watch)) {
      return 0;
    }
    return el.querySelector('i.watch-icon') ? 1 : 0;
  }
  
  function flatTagObject(tagCodeObj) {
    let tempObj = {};
    for (const name in tagCodeObj) {
      if (name === 'basic'){
        continue;
      }
      tempObj = {...tempObj, ...tagCodeObj[name]};
    }
    return tempObj;
  }

  function getExtraTags() {
    return {
      ...flatTagObject(CAT_TAG_CODES[CONFIG.cat_loc] || TAG_CODE.game),
      ...EXTRA_TAGS
    };
  }

  function countTiles(tileEls,extraTags) {
    let count = 0;
    for (const tileEl of tileEls) {
      const tagName = tileEl.textContent.toLowerCase();
      if (CONFIG.matches.has(extraTags[tagName])){
        count += 1;
      }
    }
    return count;
  }

  // Collection, engines, types, statuses, medias
  function TileCount(el) {
    const extraTags = getExtraTags();
    delete extraTags.watch; // watch is handled by 'hasWatchIconCount()'
    const leftTiles = el.querySelector(CSS_SELECT.lTileTags).children;
    const rightTiles = el.querySelector(CSS_SELECT.rTileTags).children;
    return countTiles(leftTiles, extraTags) + countTiles(rightTiles, extraTags);
  }

  function calculateTotalCount(el) {
    const basicTagsArr = el.dataset.tags.split(',');
    return matchTagsCount(basicTagsArr) + hasWatchIconCount(el) + TileCount(el);
  }
  
  function parseDate(el) {
    const dateDivEl = el.querySelector('.resource-tile_info-meta_time span');
    const dateNum = parseInt(dateDivEl.textContent.trim()) || 0;
    const dateType = getComputedStyle(dateDivEl, '::after').content.replace(/"/g, "").trim().toLowerCase();
    return {
      num: dateNum,
      type: dateType
    };
  }
  
  function compareDateType(dateType) {
    let num = 1;
    for (const unit of TIME_UNITS){
      if (dateType.startsWith(unit)){
        return num;
      }
      num++;
    }
    return 0;
  }
  
  function compareDateNums(aDateNum, bDateNum) {
    if(aDateNum > bDateNum) {
      return 1;
    } else if(aDateNum < bDateNum) {
      return -1;
    }
    return 0;
  }
  
  function compareDates(aEl, bEl) {
    const aDate = parseDate(aEl);
    const bDate = parseDate(bEl);
    const aSortNum = compareDateType(aDate.type);
    const bSortNum = compareDateType(bDate.type);
    if (aSortNum > bSortNum) {
      return 1;
    } else if(aSortNum < bSortNum) {
      return -1;
    } 
    return compareDateNums(aDate.num, bDate.num);
  }
  
  function hasSetMatches() {
    return CONFIG.matches && CONFIG.matches.size;
  }

  function organizeResources() {
    if (!hasSetMatches()) {
      return;
    }
    const latestWrapper = Edexal.$(CSS_SELECT.latestWrapper);
    const latestUpdatesArr = Array.from(latestWrapper.querySelectorAll(CSS_SELECT.latestResources));
    const sortedArr = latestUpdatesArr.toSorted((a,b) => {
      const aMatchCount = calculateTotalCount(a);
      const bMatchCount = calculateTotalCount(b);
      if (aMatchCount > bMatchCount) {
        return -1;
      } else if(aMatchCount < bMatchCount) {
        return 1;
      } else {
        return compareDates(a,b);
      }
    });
    latestWrapper.append(...sortedArr);
  }

  function attachRemoveMatchEvent(btnEl) {
    Edexal.on(btnEl,'click', (e) => {
      const liTag = e.target.parentElement;
      const tagCode = liTag.dataset.tagCode;
      updateMatchConfigList(tagCode);
      liTag.remove(liTag);
      showMatchListItem(tagCode, false);
      organizeResources();
      saveUserPriorities();
    });
  }

  function updateMatchConfigTags(tagCode) {
    const matchIndex = CONFIG.pList.indexOf(tagCode);
    CONFIG.matches.add(tagCode);
    if (matchIndex !== -1) {
      CONFIG.pList.splice(matchIndex, 1);
    }
  }

  function addChosenMatchTag(listEl){
    const matchTagsEl = Edexal.$(CSS_SELECT.matchTags);
    const li = Edexal.newEl({'element': 'LI', 'data-tag-code': listEl.dataset.tagCode});
    const btn = Edexal.newEl({'element': 'BUTTON', 'text': listEl.textContent, 'type': 'button'});
    attachRemoveMatchEvent(btn);
    li.append(btn);
    matchTagsEl.append(li);
    updateMatchConfigTags(listEl.dataset.tagCode);
    showMatchListItem(listEl.dataset.tagCode, true);
  }

  function applyChoiceEvent(matchListEl) {
    Edexal.on(matchListEl,'click', (e) => {
      if (e.target.classList.contains('ed-hide') || e.target.tagName.toLowerCase() !== 'li') {
        return;
      }
      addChosenMatchTag(e.target);
      organizeResources();
      saveUserPriorities();
    });
  }

  function addTagToSettings(tagName, tagCode, matchListEl) {
    const isInMatches = CONFIG.matches.has(tagCode);
      if (!isInMatches) {
        CONFIG.pList.push(tagCode);
      }
      const li = Edexal.newEl({
          element: 'LI',
          text: tagName,
          'data-tag-code': tagCode
      });
      matchListEl.append(li);
      if (isInMatches) {
        addChosenMatchTag(li);
      }
  }

  function addExtraTags(matchListEl) {
    const extraTags = getExtraTags();
    for (const tagName in extraTags) {
      const tagCode = extraTags[tagName];
      CONFIG.category[tagName] = tagCode;
      addTagToSettings(tagName, tagCode, matchListEl);
    }
  }

  function addTagsToSettings() {
    const dropDownTags = Edexal.$$(`${CSS_SELECT.dropDownContent} .option`);
    const matchList = Edexal.$(CSS_SELECT.matchList);
    addExtraTags(matchList);
    dropDownTags.forEach((el) => {
      //Ex: '2d game' : 1112
      const tagName = el.querySelector('span:first-child').textContent;
      CONFIG.category[tagName] = el.dataset.value;
      addTagToSettings(tagName, el.dataset.value, matchList);
    });
    applyChoiceEvent(matchList);
    organizeResources();
  }

  function observeCallback2(records,obs) {
    for (const record of records) {
      for (const node of record.addedNodes) {
        if (node.classList?.contains('option')) {
          obs.disconnect();
          addTagsToSettings();
          return;
        }
      }
    }
  }

  function addSettingSection() {
    const filterBarEl = Edexal.$(".content-block_filter");
    const html = `
    <div id="filter-block_match">
      <div class="filter-block accordion-block">
        <h4 class="filter-block_title accordion-toggle">Match Sorting</h4>
        <div class="filter-block_content filter-block_v accordion-content">
          <div id="match-list">
            <div>Select tags...</div>
            <div>
              <ul></ul>
            </div>
          </div>
          <hr/>
          <div id="match-tags">
            <ul></ul>
          </div>
        </div>
      </div>
  </div>`;
    filterBarEl.insertAdjacentHTML('beforeEnd', html);
  }

  function addSettingEvent() {
    const settingSect = Edexal.$('#filter-block_match');
    Edexal.on(settingSect, 'click', (e) => {
      if (e.target.matches('h4')) {
        settingSect.querySelector('div.accordion-content').classList.toggle('ed-show');
      }
    });
  }

  function runOnTagReveal() {
    const observer = new MutationObserver((records, obs) => observeCallback2(records,obs));
    observer.observe(Edexal.$(CSS_SELECT.dropDownContent), {subtree: true, childList: true});
  }

  function observeDropdown(records,obs) {
    for (const record of records) {
      if (record.target.classList.contains('focus') && record.target.classList.contains('dropdown-active')) {
        Edexal.$(`${CSS_SELECT.includeFilter} input`).blur();
        Edexal.$(CSS_SELECT.dropDown).style.display = 'none';
        return obs.disconnect();
      }
    }
  }

  // Procs loading of all tags for current category
  function procTagLoading() {
    const includeFilterEl = Edexal.$(CSS_SELECT.includeFilter);
    includeFilterEl.click();
    const observer = new MutationObserver(observeDropdown);
    observer.observe(includeFilterEl, {attributeFilter: ['class']});
  }

  function initTagChoices() {
    procTagLoading();
    runOnTagReveal();
  }

  function clearMatchLists() {
    const listEl = Edexal.$(CSS_SELECT.matchList);
    listEl.replaceChildren();
    const tagListEl = Edexal.$(CSS_SELECT.matchTags);
    tagListEl.replaceChildren();
  }

  function nextCategoryPage(curCategory) {
    CONFIG.cat_loc = curCategory;
    CONFIG.category = {};
    CONFIG.pList = [];
    clearMatchLists();
    initTagChoices();
  }

  function categoryPageType() {
    let curCatLoc = location.href.match(/cat=(\w+)/);
    return curCatLoc ? curCatLoc[1] : 'games';
  }

  function observePageChange(records, obs) {
    const curCat = categoryPageType();
    if (CONFIG.newPageTimerID || INVALID_CATEGORY_TAGS.has(curCat)) {
      return;
    }
    CONFIG.newPageTimerID = setTimeout(() => {
      CONFIG.cat_loc === curCat ?
        organizeResources() : nextCategoryPage(curCat);
      CONFIG.newPageTimerID = null;
    }, CONFIG.newPageDelay);
  }

  function organizeOnNewPages() {
    const observer = new MutationObserver((records, obs) => observePageChange(records, obs));
    observer.observe(Edexal.$(CSS_SELECT.navPageBar), {attributeFilter: ['class']});
  }

  function initSettings() {
    addSettingSection();
    addSettingEvent();
    initTagChoices();
    organizeOnNewPages();
  }

  function observeCallback(records, obs) {
    for (const record of records) {
      for (const node of record.addedNodes) {
        if (node.classList?.contains('resource-tile')) {
          obs.disconnect();
          initSettings();
          return;
        }
      }
    }
  }

  async function loadDB(curCatLoc){
    const dbObj = await GM.getValues({'cat_loc': '', 'matches': []});
    dbObj.matches = new Set(dbObj.matches);
    CONFIG = Object.assign(CONFIG, dbObj);
    CONFIG.cat_loc = curCatLoc;
    await GM.setValue('cat_loc', curCatLoc);
  }

  async function initOnResourceLoad() {
    const curCatLoc = categoryPageType();
    if (INVALID_CATEGORY_TAGS.has(curCatLoc)) {
      return;
    }
    await loadDB(curCatLoc);
    const latestUpdateWrapper = Edexal.$(CSS_SELECT.latestWrapper);
    const observer = new MutationObserver((records, obs) => observeCallback(records, obs));
    observer.observe(latestUpdateWrapper, {subtree: true, childList: true});
  }
  await initOnResourceLoad();
})();