Planetsuzy Threads - Auto fold posts

This script will automatically fold some of the forum posts based on their content

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

Bạn sẽ cần cài đặt một tiện ích mở rộng như Tampermonkey hoặc Violentmonkey để cài đặt kịch bản này.

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.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

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==
// @namespace Planetsuzy
// @name Planetsuzy Threads - Auto fold posts
// @description This script will automatically fold some of the forum posts based on their content
// @version 1.3.1
// @license MIT
// @icon http://ps.fscache.com/styles/style1/images/statusicon/forum_old.gif
// @include /^https?://(www\.)?planetsuzy\.org/showthread\.php/
// @include /^https?:\/\/(www\.)?planetsuzy\.org\/t\d+[\w-]+\.html$/
// @require https://cdnjs.cloudflare.com/ajax/libs/autolinker/1.8.3/Autolinker.min.js
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// ==/UserScript==

const decoratePosts = function() {

  /**
   * Posts containing only links from these domains will be less visible (values stored in settings).
   */
  const blacklistedDomains = GM_getValue('blacklistedDomains', []);

  /**
   * Returns two top levels of the URL's hostname.
   */
  const parseDomain = function (href) {
    return new URL(href, document.location).hostname.split('.').slice(-2).join('.');
  };

  /**
   * Returns 'inner' URL if it wrapped by a link anonymizer.
   */
  const unwrapUrl = function (href) {
    const baseUrl = new URL(href, document.location);
    if (baseUrl.pathname === '/' && baseUrl.search.length > 1) {
      const innerParam = baseUrl.search.slice(1);
      try {
        new URL(innerParam);
        return innerParam;
      } catch (e) {}
    }
    return href;
  };

  /**
   * Store domain of current document for later.
   */
  const documentDomain = parseDomain(document.location.href);

  /**
   * Class representing list of links in each post message sorted by link domains.
   */
  class LinksList {
    constructor() {
      this.links = {};
    }
    add(href) {
      let domain;
      try {
        domain = parseDomain(unwrapUrl(href));
      } catch (e) {
        // Invalid link, ignore.
        return;
      }
      // Local links ignored.
      if (domain !== documentDomain) {
        if (!(domain in this.links)) {
          this.links[domain] = [];
        }
        this.links[domain].push(href);
      }
    }
    get domains() {
      return Object.keys(this.links);
    }
  };

  /**
   * Class representing lists of elements linked with a tag such as a domain name
   * with a function to change multiple elements appearance at once.
   */
  class TaggedElementsList {
    constructor(decoratorFn) {
      this.elements = {};
      this.decoratorFn = decoratorFn;
    }
    addElement(tag, element) {
      if (this.elements[tag] === undefined) {
        this.elements[tag] = [];
      }
      this.elements[tag].push(element);
    }
    filter(tag, fn) {
      if (this.elements[tag] === undefined) {
        return [];
      }
      return this.elements[tag].filter(fn);
    }
    decorateElement(element) {
      const args = Array.from(arguments).slice(1);
      this.decoratorFn.apply(element, args);
    }
    decorateElements(tag) {
      const args = Array.from(arguments).slice(1);
      const that = this;
      this.elements[tag].forEach(function (element) {
        that.decoratorFn.apply(element, args);
      });
    }
  };

  const domainSwitches = new TaggedElementsList(function (isBlacklisted) {
    this.style.fontWeight = isBlacklisted ? 'normal' : 'bold';
    this.style.textDecoration = isBlacklisted ? 'line-through' : 'none';
  });
  const expandToggles = new TaggedElementsList(function (makeVisible) {
    const needTriggerClick = (makeVisible === true && this.getAttribute('data-initial-state') === '0' && this.getAttribute('data-state') === '0')
      || (makeVisible === false && this.getAttribute('data-initial-state') === '0' && this.getAttribute('data-state') === '1');
    if (needTriggerClick) {
      this.click();
    }
  });
  const refreshLabels = new TaggedElementsList(function (makeVisible) {
    this.style.display = makeVisible ? 'inline' : 'none';
  });

  document.querySelectorAll('table[id^=post]')
    .forEach(function (post) {
      // Find messages in thread and examine them for any links.
      const postId = post.getAttribute('id').substring(4);
      const message = post.querySelector('[id^=post_message_]');
      if (message) {
        const links = new LinksList();
        // Find anchors in post message.
        Array.from(message.querySelectorAll('a[href]'))
          .filter(function(link) {
            // Function to determine whether the image element inside anchor can be excluded due to not being a file hoster link.
            const canExcludeImage = function (image) {
              const parseUrlSegments = function (href) {
                // Returns URL pathname except the file name.
                return new URL(href, document.location).pathname.substring(1).split('/').slice(0, -1);
              };
              const isWorthlessLink = function (href) {
                const url = new URL(href, document.location);
                return (url.pathname.length === 1 && url.search.length <= 1)  // href is to website root URL
                  || url.hostname.indexOf('.') === -1;                        // hostname is TLD
              };
              const imageSrc = image.getAttribute('src');
              try {
                const linkHref = unwrapUrl(link.getAttribute('href'));
                const linkDomain = parseDomain(linkHref);
                return isWorthlessLink(linkHref)                                   // exclude if linked to the root of a website ("worthless link")
                  || [linkDomain, documentDomain].includes(parseDomain(imageSrc))  // exclude if same domain or local image
                  || parseUrlSegments(imageSrc).includes(linkDomain);              // exclude if link domain is contained in URL (eg. 3rd part CDN used for previews)
              } catch (e) {
                return false;  // invalid link, exclude for sure
              }
            };
            // Exclude images inside anchors if images hostnames match anchors hostnames (previews) or local images.
            // May cause false positives when download link contains an image (eg icon) of a file hoster directly from its domain name.
            const images = link.querySelectorAll('img');
            if (images.length) {
              const hostnamesExcluded = Array.from(images)
                .reduce(function (acc, cur) { return acc && canExcludeImage(cur) }, true);
              return !hostnamesExcluded;
            }
            return true;
          })
          .forEach(function(link) {
            const deemphasise = function (element) {
              element.style.opacity = '0.2';
            };
            const href = link.getAttribute('href').toString();
            links.add(href);
            try {
              if (blacklistedDomains.includes(parseDomain(unwrapUrl(href)))) {
                deemphasise(link);
              }
            } catch (e) {
              if (link.querySelectorAll('*').length === 0) {
                deemphasise(link);  // invalid link containing no other elements such as image previews, deemphasise for sure
              }
            }
          });
        // Additionally, parse links in 'pre' elements.
        message.querySelectorAll('pre')
          .forEach(function (pre) {
            Autolinker.parse(message.innerHTML, {urls: true})
              .forEach(function (match) {
                links.add(match.getUrl());
              });
          });

        // Set up additional elements.
        const postCountElement = post.querySelector('#postcount' + postId);
        const postAnchorElement = post.querySelector('a[name=' + post.getAttribute('id') + ']');
        const hasBlacklistedLinks = links.domains.length && !links.domains.filter(function (domain) { return !blacklistedDomains.includes(domain) }).length;
        const contentRemoved = message.innerText.replace(/Removed at request of claimed rights holder/, '').length < 10;
        const hiddenPost = hasBlacklistedLinks || contentRemoved;

        // Collapse/reveal post toggle switches.
        postCountElement.parentElement.prepend((function () {
          const labels = ['►', '▼'];
          const initialState = hiddenPost ? 0 : 1;
          const el = document.createElement('a');
          el.innerHTML = labels[1];
          el.setAttribute('data-state', 1);
          el.setAttribute('data-initial-state', initialState);
          el.style.cursor = 'pointer';
          el.addEventListener('click', function (e) {
            const newState = (parseInt(el.getAttribute('data-state')) + 1) % labels.length;
            Array.from(postCountElement.closest('tbody').querySelectorAll(':scope > tr'))
              .filter(function (row) { return row !== postCountElement.closest('tr') })
              .forEach(function (row) { row.style.display = newState ? 'table-row' : 'none' });
            post.parentElement.querySelectorAll('#post_thanks_box_' + postId)
              .forEach(function (row) { row.style.display = newState ? 'block' : 'none' });
            
            el.innerHTML = labels[newState];
            el.setAttribute('data-state', newState);
          });
          if (!initialState) {
            el.click();
          }
          expandToggles.addElement('a', el);
          return el;
        })());

        // Refresh label that shows up to indicate that a refresh is needed to apply changes.
        postCountElement.parentElement.prepend((function () {
          const el = document.createElement('a')
          el.innerHTML = 'F5 ⟳';
          el.classList.add('post-thead-label');
          el.classList.add('button');
          el.style.cursor = 'pointer';
          el.addEventListener('click', function () {
            window.location.reload();
          });
          refreshLabels.addElement('F5', el);
          refreshLabels.decorateElement(el, false);
          return el;
        })());

        // Domain name toggle switches.
        postCountElement.parentElement.prepend((function () {
          const el = document.createElement('span');
          if (hiddenPost) {
            el.appendChild(document.createTextNode('⚠️ '));
          }
          links.domains.forEach(function (domain, i) {
            const ch = document.createElement('span');
            ch.innerHTML = domain;
            ch.style.cursor = 'pointer';
            ch.addEventListener('click', function () {
              // Update domains blacklist property and redecorate elements.
              const oldBlacklist = GM_getValue('blacklistedDomains', []);
              const wasBlacklisted = oldBlacklist.includes(domain);
              const newBlacklist = wasBlacklisted
                ? oldBlacklist.filter(function (value) { return value !== domain})
                : oldBlacklist.concat([domain]);
              domainSwitches.decorateElements(domain, !wasBlacklisted);
              GM_setValue('blacklistedDomains', newBlacklist);
              const needRefresh = blacklistedDomains.length !== newBlacklist.length
                || blacklistedDomains.filter(function(a) { return newBlacklist.indexOf(a) === -1; }).length;
              refreshLabels.decorateElements('F5', needRefresh);
            });
            el.appendChild(ch);
            el.appendChild(document.createTextNode(i < links.domains.length - 1 ? ', ' : ' '));
            domainSwitches.addElement(domain, ch);
            domainSwitches.decorateElement(ch, blacklistedDomains.includes(domain));
          });
          return el;
        })());

        // Mark linked post.
        if (document.location.hash.slice(1) === post.getAttribute('id')) {
          post.classList.add('thread-tools-linked-post');
          postAnchorElement.parentElement.appendChild((function () {
            const label = document.createElement('div');
            const container = document.createElement('div');
            label.classList.add('thread-tools-linked-post-id-label');
            label.innerHTML = '#' + post.getAttribute('id');
            container.classList.add('thread-tools-linked-post-id-label-container');
            container.appendChild(label);
            return container;
          })());
        }

        if (contentRemoved) {
          // Refresh label that shows up to indicate that a refresh is needed to apply changes.
          postCountElement.parentElement.prepend((function () {
            const el = document.createElement('span')
            el.innerHTML = 'Removed';
            el.classList.add('post-thead-label');
            el.classList.add('removed');
            return el;
          })());
        }
      }
    });

  // Toggle buttons for multiple posts at once.
  const threadToolsMenu = document.querySelector('#threadtools_menu tbody');
  if (threadToolsMenu !== null) {
    const createThreadToolsMenuElement = function (autoFoldedElements, caption, nothingCaption) {
      const labels = ['►', '▼'];
      const tr = document.createElement('tr');
      tr.setAttribute('data-feature-multi-toggle', true);
      const td = document.createElement('td');
      const toggle = document.createElement('a');
      toggle.innerHTML = caption;
      const icon = document.createElement('span');
      icon.innerHTML = labels[0];
      icon.style.padding = '0 .7em';
      const onMouseOver = function () {
        td.setAttribute('class', 'vbmenu_hilite vbmenu_hilite_alink');
        td.style.cursor = 'pointer';
      };
      const onMouseOut = function () {
        td.setAttribute('class', 'vbmenu_option vbmenu_option_alink');
        td.style.cursor = 'default';
      };
      onMouseOut();
      if (autoFoldedElements.length) {
        td.addEventListener('mouseover', onMouseOver);
        td.addEventListener('mouseout', onMouseOut);
        toggle.setAttribute('data-state', 0);
        toggle.addEventListener('click', function () {
          const newState = (parseInt(toggle.getAttribute('data-state')) + 1) % labels.length;
          autoFoldedElements.forEach(function (el) {
            expandToggles.decorateElement(el, newState === 1);
          });
          toggle.setAttribute('data-state', newState);
          icon.innerHTML = labels[newState];
        });
        const threadToolsButton = document.querySelector('#threadtools');
        if (threadToolsButton !== null) {
          threadToolsButton.classList.add('thread-tools-have-additions')
        }
      } else {
        toggle.innerHTML += ' (' + nothingCaption + ')';
        toggle.style.opacity = '0.7';
      }
      toggle.prepend(icon);
      td.appendChild(toggle);
      tr.appendChild(td);
      return tr;
    };

    threadToolsMenu.appendChild(createThreadToolsMenuElement(
      expandToggles.filter('a', function (element) {
        return element.getAttribute('data-initial-state') === '0';
      }),
      'Toggle all auto-hidden posts',
      'nothing hidden'
    ));

    threadToolsMenu.appendChild(createThreadToolsMenuElement(
      expandToggles.filter('a', function (element) {
        if (element.getAttribute('data-initial-state') === '0') {
          const statusImage = element.closest('tr').querySelector('td a[name^=post] img.inlineimg');
          if (statusImage !== null) {
            return statusImage.getAttribute('alt') === 'Unread';
          }
        }
        return false;
      }),
      'Toggle unread auto-hidden posts',
      document.querySelector('a[name=newpost]') !== null ? 'nothing hidden' : 'no unread posts',
    ));

    // New feature notifier.
    const isMultiToggleNewFeature = GM_getValue('featureMultiToggleSeen', false);
    if (isMultiToggleNewFeature === false) {
      const threadToolsButton = document.querySelector('#threadtools');
      if (threadToolsButton !== null) {
        threadToolsMenu.querySelectorAll('tr[data-feature-multi-toggle]').forEach(function (tr) {
          const el = document.createElement('span')
          el.innerHTML = 'new';
          el.classList.add('post-thead-label');
          tr.querySelector(':scope > td').appendChild(el);
        });
        threadToolsButton.addEventListener('click', function () {
          GM_setValue('featureMultiToggleSeen', true);
          if (threadToolsButton.classList.contains('thread-tools-have-additions-feature')) {
            threadToolsButton.classList.remove('thread-tools-have-additions-feature');
          }
        });
        if (threadToolsButton.classList.contains('thread-tools-have-additions')) {
          threadToolsButton.classList.add('thread-tools-have-additions-feature')
        }
      }
    }
  }
};

GM_addStyle(`
  @-webkit-keyframes BackgroundPositionAnimation {
    0%{background-position:0% 50%}
    50%{background-position:100% 50%}
    100%{background-position:0% 50%}
  }
  @-moz-keyframes BackgroundPositionAnimation {
    0%{background-position:0% 50%}
    50%{background-position:100% 50%}
    100%{background-position:0% 50%}
  }
  @keyframes BackgroundPositionAnimation {
    0%{background-position:0% 50%}
    50%{background-position:100% 50%}
    100%{background-position:0% 50%}
  }
  .thread-tools-have-additions {
    background: #2c539e;
    transition: background 300ms linear;
  }
  .thread-tools-have-additions-feature {
    background: linear-gradient(270deg, #f2a304, #314ff5);
    background-size: 400% 400%;
    -webkit-animation: BackgroundPositionAnimation 30s ease infinite;
    -moz-animation: BackgroundPositionAnimation 30s ease infinite;
    animation: BackgroundPositionAnimation 30s ease infinite;
  }
  .post-thead-label {
    background: white;
    border-radius: 1px;
    color: #0b198c;
    font-weight: bold;
    margin: 0 3px;
    padding: 1px 3px;
  }
  .post-thead-label.removed {
    color: white;
    background: red;
  }
  .thead .post-thead-label.button:hover {
    color: #0b198c;
  }
  .thread-tools-linked-post {
    border-width: 2px;
    margin: -2px;
    width: calc(100% + 4px);
  }
  .thread-tools-linked-post-id-label  {
    background: #0B198C;
    margin-top: -7px;
    cursor: default;
    padding: 3px;
  }
  .thread-tools-linked-post-id-label-container {
    display: none;
    margin-left: 15px;
    vertical-align: top;
  }
  .thread-tools-linked-post-id-label-container + .thread-tools-linked-post-id-label-container {
    margin-left: 2px;
  }
  .thread-tools-linked-post:hover .thread-tools-linked-post-id-label-container {
    display: inline-block;
  }
`);

decoratePosts();