rhitakorrr / Habitica Tags: Level Up

// ==UserScript==
// @namespace https://openuserjs.org/users/rhitakorrr
// @name Habitica Tags: Level Up
// @description Make tags always visible on the tasks page, hide challenge tags, activate groups of tags with one click, and make certain tags mutually exclusive with one another.
// @author rhitakorrr
// @copyright 2019, Citrusella (CSS)
// @copyright 2019, Alys (JS) (https://openuserjs.org/users/Alys)
// @copyright 2019, rhitakorrr (JS - extended features) (https://openuserjs.org/users/rhitakorrr)
// @licence MIT
// @version 1.1.0
// @include https://habitica.com/*
// @grant GM_addStyle
// @grant GM_getResourceText
// @resource config https://gist.githubusercontent.com/robwhitaker/b56c3c24e70b2d2328cb12ec2baa056e/raw
// ==/UserScript==

// ==OpenUserJS==
// @author rhitakorrr
// ==/OpenUserJS==

GM_addStyle(`
  .tasks-navigation {
    height: 210px !important;
  }
  .filter-panel {
    max-height: 170px !important;
    min-height: 85px !important;
    max-width: 99% !important;
    width: 99% !important;
    left: 0px !important;
    z-index: 0 !important;
    overflow-y: scroll !important;
    display: flex !important;
    flex-direction: column !important;
  }
  .tasks-navigation .tags-list .custom-control {
    padding-left: 0rem !important;
    margin-bottom: 0rem !important;
    margin-left: -.5rem !important;
  }
  .tasks-navigation .tags-list .custom-control-input {
    left: .5rem !important;
  }
  .tasks-navigation .tags-list .custom-control .custom-control-label::before,.tasks-navigation .tags-list .custom-control-input:checked ~ .custom-control-label::before,.tasks-navigation .tags-list .custom-checkbox .custom-control-input:checked ~ .custom-control-label::after {
    visibility: hidden !important;
  }
  .tasks-navigation .tags-list .custom-control-label::after,.tasks-navigation .tags-list .custom-control-label::before {
    left: 0rem !important;
  }
  .tasks-navigation .tags-list .custom-checkbox .custom-control-input:checked ~ .custom-control-label {
    background-color: #96c676;
    border: 1px solid #F0F6EB;
    color: #fffffa;
  }
  .tasks-navigation .tags-list .custom-control-label {
    background-color: #F0F6EB;
    color: #3F6423;
    border: 1px solid #C4DEB2;
    border-radius: 12px;
    padding-left: 8px;
    padding-right: 8px;
  }
  .tasks-navigation .tags-list .col-6 {
    width: auto !important;
    flex: none !important;
    padding: 2px !important;
  }
  .tasks-navigation .col-md-4 {
    width: 100% !important;
    max-width: 100% !important;
    flex: 0 0 100% !important;
  }
  .offset-md-4 {
    margin-left: 0.5% !important;
  }
  .modal {
    z-index: 999999999 !important;
  }
  .filter-panel-footer {
    order: 1 !important;
    padding-top: 12px !important;
    padding-bottom: 4px !important;
  }
  .tags-category {
    order: 2 !important;
    padding-top: 6px !important;
    padding-bottom: 4px !important;
  }
`);

// parse the user's configuration
try {
    // strip the "comment" from the default config; JSON doesn't support comments
    // so the parser will choke otherwise.
    var rawConfig = GM_getResourceText("config").replace(/\/\*(.|\n)*\*\//, "").trim()
    var config = JSON.parse(rawConfig);
} catch(e) {
    // if parsing fails, let the user know their config is broken
    alert("Userscript: Habitica Tags Always Visible\n\n" +
          "Error: Unable to parse your configuration. Is there an error in your JSON?");
}

// return a list of tag labels, possible filtering on a predicate
var getTagLabels = (pred) => [].filter.call(
  document.querySelectorAll(".tags-list.container label"),
  pred || (() => true)
);

// a specialized version of getTagLabels which retrieves a specific label by name
var getTagLabelByName = name => getTagLabels(label => label.textContent.trim() === name)[0];

// toggle a tag checkbox on or off; if toggleTo is provided, set it to the specified state
// regardless of its current state.
var toggleTag = function (tagName, toggleTo) {
  var tagLabels = getTagLabels();
  for (var j = 0; j < tagLabels.length; j++) {
    //if the tag is the one we're looking for and it needs to be toggled, click the label
    if (tagLabels[j].textContent.trim() === tagName && tagLabels[j].control.checked !== toggleTo) {
      tagLabels[j].click();
    }
  }
};

// adds a listener to a child tag which will cause selecting or unselecting it to
// likewise toggle its parent tag
var addListenerToChildTag = function (childTag, parentTag) {
  var listenerKey = parentTag + ">" + childTag;
  if (!parentChildListeners[listenerKey]) {
    parentChildListeners[listenerKey] = true;

    var checkbox = getTagLabelByName(childTag).control;
    checkbox.addEventListener("change", function () {
      if (checkbox.checked) {
        parentRefs[parentTag] = (parentRefs[parentTag] || 0) + 1;
      }
      else {
        parentRefs[parentTag] -= 1;
      }
      toggleTag(parentTag, parentRefs[parentTag] > 0)
    });
  }
}

// add a listener to an exclusive tag which will toggle off all other tags in its
// exclusive group when it is selected
var addListenerToExclusiveTag = function (tagKey, excludeGroup, groupId) {
  var listenerKey = groupId + "-" + tagKey;
  if (!exclusiveListeners[listenerKey]) {
    exclusiveListeners[listenerKey] = true;
    var checkbox = getTagLabelByName(tagKey).control;
    checkbox.addEventListener("change", function () {
      excludeGroup.forEach(tag => (tag !== tagKey) && toggleTag(tag, false));
    });
  }
}

//set up some variables for state tracking

var initialOpening = false; // have we selected our initial tags yet?
var filterPanelScrollTop = 0; // keep track of tag filter panel scroll position so it doesn't
                              // awkwardly reset to the top whenever the user mouse-outs
var parentRefs = {}; // keep track of how many child tags are referencing each parent so we
                     // don't unselect a parent unless ALL of its child tags are toggled off
var parentChildListeners = {}; // keep track of listeners we've added to each parent-child combo
                               // so we don't accidentally add multiple listeners for the same event
var exclusiveListeners = {}; // same as parentChildListeners, but for exclusives

var callback = function (mutationsList, observer) {
  var tagsButton = document.querySelector("html body div div#app div.container-fluid div div.row.user-tasks-page div.col-12 div.row.tasks-navigation div.col-12.col-md-4.offset-md-4 div.d-flex button.btn.btn-secondary.dropdown-toggle");
  
  // if the we don't have the tags button, pretend it's a new page load and reset stuff
  if (!tagsButton) {
    initialOpening = false;
    filterPanelScrollTop = 0;
    parentRefs = {};
  }
  
  var filterPanel = document.querySelector(".filter-panel");
  if (tagsButton && !filterPanel) { // we have the tagsButton, but don't have our filter-panel
    // if the filter-panel doesn't exist, we've lost all our listeners; reset our listener tracking
    parentChildListeners = {};
    exclusiveListeners = {};
    tagsButton.click(); // this clicks on the "Tags" button to show the panel that contains the list of tags
  }
  else if (tagsButton && filterPanel) { // our tags are visible
    // hide challenge tags
    if (config.hideChallengeTags) {
      filterPanel.getElementsByClassName("tags-category")[0].style = "display: none !important;"
    }

    // restore the filter panel's scroll position and save any scroll changes
    filterPanel.scrollTop = filterPanelScrollTop;
    filterPanel.addEventListener("scroll", function () {
      filterPanelScrollTop = filterPanel.scrollTop;
    });

    // setup tag groups
    for (var parentTag in config.tagsWithChildren) {
      var childTags = config.tagsWithChildren[parentTag];
      for (var i = 0; i < childTags.length; i++) {
        addListenerToChildTag(childTags[i], parentTag);
      }

    }

    // setup exclude groups
    config.exclusive.forEach(function (group, groupId) {
      for (var i = 0; i < group.length; i++) {
        addListenerToExclusiveTag(group[i], group, groupId);
      }
    });

    // automatically select initial tags
    if (!initialOpening) {
      config.autoSelected.forEach(tag => toggleTag(tag, true));
      initialOpening = true;
    }
  }
};

var observer = new MutationObserver(callback);
var targetNode = document.querySelector('body');
var observerConfig = {
  childList: true,
  subtree: true,
};
observer.observe(targetNode, observerConfig);