Brazen Framework - Utilities

Utilities and helpers for Brazen user scripts framework

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greasyfork.org/scripts/375557/1884361/Brazen%20Framework%20-%20Utilities.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Brazen Framework - Utilities
// @namespace    brazenvoid
// @version      5.1.1
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Utilities and helpers for Brazen user scripts framework
// @run-at  	 document-end
// ==/UserScript==

const REGEX_LINE_BREAK = /\r?\n/g
const REGEX_PRESERVE_NUMBERS = /\D/g

class ChildObserver
{
  /**
   * @callback observerOnMutation
   * @param {NodeList} nodes
   * @param {Element} previousSibling
   * @param {Element} nextSibling
   * @param {Element} target
   */

  // -------------------------------------------------------------------------
  // Static public methods
  // -------------------------------------------------------------------------

  /**
   * @return {ChildObserver}
   */
  static create()
  {
    return new ChildObserver
  }

  // -------------------------------------------------------------------------
  // Protected class variables
  // -------------------------------------------------------------------------

  _node = null

  _observer = null

  _onNodesAdded = null

  _onNodesRemoved = null

  // -------------------------------------------------------------------------
  // Private class methods
  // -------------------------------------------------------------------------

  /**
   * @return {ChildObserver}
   * @private
   */
  _observeNodes()
  {
    this._observer.observe(this._node, {childList: true})
    return this
  }

  // -------------------------------------------------------------------------
  // Public class methods
  // -------------------------------------------------------------------------

  /**
   * Attach an observer to the specified node(s)
   * @param {Node} node
   * @returns {ChildObserver}
   */
  observe(node)
  {
    // Re-observe must not orphan the previous MutationObserver.
    if (this._observer) {
      this.disconnect()
    }
    this._node = node
    this._observer = new MutationObserver((mutations) => {
      for (let mutation of mutations) {
        if (mutation.addedNodes.length && this._onNodesAdded) {
          this._onNodesAdded(
              mutation.addedNodes,
              mutation.previousSibling,
              mutation.nextSibling,
              mutation.target,
          )
        }
        if (mutation.removedNodes.length && this._onNodesRemoved) {
          this._onNodesRemoved(
              mutation.removedNodes,
              mutation.previousSibling,
              mutation.nextSibling,
              mutation.target,
          )
        }
      }
    })
    return this._observeNodes()
  }

  /**
   * @param {observerOnMutation} eventHandler
   * @returns {ChildObserver}
   */
  onNodesAdded(eventHandler)
  {
    this._onNodesAdded = eventHandler
    return this
  }

  /**
   * @param {observerOnMutation} eventHandler
   * @returns {ChildObserver}
   */
  onNodesRemoved(eventHandler)
  {
    this._onNodesRemoved = eventHandler
    return this
  }

  pauseObservation()
  {
    this._observer.disconnect()
  }

  /**
   * Permanently stops observation and drops the MutationObserver handle.
   */
  disconnect()
  {
    this._observer?.disconnect()
    this._observer = null
    this._node = null
  }

  resumeObservation()
  {
    this._observeNodes()
  }
}

class SelectorGenerator
{
  // -------------------------------------------------------------------------
  // Constructor
  // -------------------------------------------------------------------------

  /**
   * @param {string} selectorPrefix
   */
  constructor(selectorPrefix)
  {
    this._prefix = selectorPrefix
  }

  // -------------------------------------------------------------------------
  // Public class methods
  // -------------------------------------------------------------------------

  /**
   * @param {string} selector
   * @return {string}
   */
  getSelector(selector)
  {
    return this._prefix + selector
  }

  /**
   * @param {string} settingName
   * @return {string}
   */
  getSettingsInputSelector(settingName)
  {
    return this.getSelector(Utilities.toKebabCase(settingName) + '-setting')
  }

  /**
   * @param {string} settingName
   * @param {boolean} getMinInputSelector
   * @return {string}
   */
  getSettingsRangeInputSelector(settingName, getMinInputSelector)
  {
    return this.getSelector(Utilities.toKebabCase(settingName) + (getMinInputSelector ? '-min' : '-max') + '-setting')
  }

  /**
   * @param {string} statisticType
   * @return {string}
   */
  getStatLabelSelector(statisticType)
  {
    return this.getSelector(Utilities.toKebabCase(statisticType) + '-stat')
  }
}

class StatisticsRecorder
{
  // -------------------------------------------------------------------------
  // Private class variables
  // -------------------------------------------------------------------------

  /**
   * @type {{Total: number}}
   * @private
   */
  _statistics = {Total: 0}

  // -------------------------------------------------------------------------
  // Constructor
  // -------------------------------------------------------------------------

  /**
   * @param {string} selectorPrefix
   */
  constructor(selectorPrefix)
  {
    this._selectorGenerator = new SelectorGenerator(selectorPrefix)
  }

  // -------------------------------------------------------------------------
  // Public class methods
  // -------------------------------------------------------------------------

  getTotal()
  {
    return this._statistics.Total
  }

  /**
   * @param {string} statisticType
   * @param {boolean} validationResult
   * @param {number} value
   */
  record(statisticType, validationResult, value = 1)
  {
    if (!validationResult) {
      if (this._statistics[statisticType] === undefined) {
        this._statistics[statisticType] = value
      } else {
        this._statistics[statisticType] += value
      }
      this._statistics.Total += value
    }
  }

  reset()
  {
    for (const statisticType in this._statistics) {
      this._statistics[statisticType] = 0
    }
  }

  updateUI()
  {
    let label, labelSelector

    for (const statisticType in this._statistics) {
      labelSelector = this._selectorGenerator.getStatLabelSelector(statisticType)
      label = document.getElementById(labelSelector)
      if (label !== null) {
        label.textContent = this._statistics[statisticType]
      }
    }
  }
}

class ComplianceRuleRecorder
{
  // -------------------------------------------------------------------------
  // Private class variables
  // -------------------------------------------------------------------------

  /**
   * @type {Map<string, Map<string, number>>}
   * @private
   */
  _filterRules = new Map()

  // -------------------------------------------------------------------------
  // Public class methods
  // -------------------------------------------------------------------------

  reset()
  {
    this._filterRules.clear()
  }

  /**
   * @param {string} configKey
   * @param {string} ruleLabel
   * @param {number} count
   */
  record(configKey, ruleLabel, count = 1)
  {
    if (!this._filterRules.has(configKey)) {
      this._filterRules.set(configKey, new Map())
    }
    let rules = this._filterRules.get(configKey)
    rules.set(ruleLabel, (rules.get(ruleLabel) ?? 0) + count)
  }

  /**
   * @param {function(string): string|null|undefined} [resolveFilterLabel]
   * @return {{filterKey: string, filterLabel: string, rules: {label: string, count: number}[]}[]}
   */
  getReport(resolveFilterLabel)
  {
    let report = []

    for (let [filterKey, rules] of this._filterRules) {
      let rulesList = [...rules.entries()].
          map(([label, count]) => ({label, count})).
          sort((left, right) => right.count - left.count)
      if (!rulesList.length) {
        continue
      }
      report.push({
        filterKey,
        filterLabel: resolveFilterLabel?.(filterKey) ?? filterKey,
        rules: rulesList,
      })
    }

    report.sort((left, right) => {
      let leftTotal = left.rules.reduce((sum, rule) => sum + rule.count, 0)
      let rightTotal = right.rules.reduce((sum, rule) => sum + rule.count, 0)
      return rightTotal - leftTotal
    })
    return report
  }
}

class Utilities
{
  // -------------------------------------------------------------------------
  // Static public methods
  // -------------------------------------------------------------------------

  /**
   * @param {string[]} words
   * @return {RegExp|null}
   */
  static buildWholeWordMatchingRegex(words)
  {
    if (!words.length) {
      return null
    }

    const escapeRegex = str => str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)

    const patterns = words.map(word => {
      const escaped = escapeRegex(word.trim())

      // Match the phrase as-is, or wrapped in matching () or []
      return String.raw`(?:${escaped}|\(${escaped}\)|\[${escaped}\])`
    })

    return new RegExp(`(${patterns.join('|')})`, 'gi')
  }

  /**
   * Append one or many nodes to a parent. Accepts a node, array of nodes, or nullish
   * (nullish and falsey non-nodes are skipped). Prefer this over Element.append when
   * the child list may be an array (native append stringifies arrays).
   * @param {ParentNode} parent
   * @param {Node|Node[]|null|undefined} children
   * @return {ParentNode}
   */
  static appendChildren(parent, children)
  {
    if (!parent || children == null) {
      return parent
    }
    let list = Array.isArray(children) ? children : [children]
    for (let child of list) {
      if (child == null || child === false) {
        continue
      }
      if (Array.isArray(child)) {
        Utilities.appendChildren(parent, child)
        continue
      }
      parent.append(child)
    }
    return parent
  }

  /**
   * Create an element with optional class, attributes, text, dataset, listeners, and children.
   * Non-chainable; not a jQuery replacement.
   * @param {string} tag
   * @param {{
   *   class?: string,
   *   attrs?: Record<string, string|number|boolean|null|undefined>,
   *   text?: string,
   *   html?: string,
   *   dataset?: Record<string, string|number|boolean|null|undefined>,
   *   on?: Record<string, EventListenerOrEventListenerObject>,
   *   children?: Node|Node[]|null,
   *   hidden?: boolean,
   * }} [opts]
   * @return {HTMLElement}
   */
  static makeEl(tag, opts = {})
  {
    let el = document.createElement(tag)
    if (opts.class) {
      el.className = opts.class
    }
    if (opts.attrs) {
      for (let [key, value] of Object.entries(opts.attrs)) {
        if (value == null || value === false) {
          continue
        }
        if (value === true) {
          el.setAttribute(key, '')
          continue
        }
        el.setAttribute(key, String(value))
      }
    }
    if (opts.dataset) {
      for (let [key, value] of Object.entries(opts.dataset)) {
        if (value == null) {
          continue
        }
        el.dataset[key] = String(value)
      }
    }
    if (opts.text != null) {
      el.textContent = opts.text
    } else if (opts.html != null) {
      el.innerHTML = opts.html
    }
    if (opts.hidden) {
      el.classList.add('bv-hidden')
      el.hidden = true
    }
    if (opts.on) {
      for (let [type, handler] of Object.entries(opts.on)) {
        if (handler) {
          el.addEventListener(type, handler)
        }
      }
    }
    if (opts.children != null) {
      Utilities.appendChildren(el, opts.children)
    }
    return el
  }

  /**
   * Show or hide an element via the shared `.bv-hidden` class (survives `display:!important`).
   * @param {Element|null|undefined} el
   * @param {boolean} hidden
   * @return {Element|null|undefined}
   */
  static setHidden(el, hidden)
  {
    if (!el) {
      return el
    }
    el.classList.toggle('bv-hidden', !!hidden)
    el.hidden = !!hidden
    return el
  }

  static callEventHandler(handler, parameters = [], defaultValue = null)
  {
    return handler ? handler(...parameters) : defaultValue
  }

  static callEventHandlerOrFail(name, handler, parameters = [])
  {
    if (handler) {
      return handler(...parameters)
    }
    throw new Error('Callback "' + name + '" must be defined.')
  }

  /**
   * @return {number|string}
   */
  static generateId(prefix = null)
  {
    let id = Math.trunc(Math.random() * 1000000000)
    return prefix ? prefix + id.toString() : id
  }

  /**
   * @param {*} value
   * @return {*}
   */
  static reviveGmStoredValue(value)
  {
    if (value === null || value === undefined) {
      return value
    }
    if (Array.isArray(value)) {
      return value
    }
    if (typeof value !== 'object') {
      return value
    }
    if ('arrays' in value && 'objects' in value && 'properties' in value) {
      try {
        value = Utilities.objectFromJSON(JSON.stringify(value))
      } catch (error) {
        console.log('Failed to revive GM storage clone:', error)
        return value
      }
    }
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      let values = Object.values(value)
      if (values.length === 1 && Array.isArray(values[0])) {
        let nested = values[0]
        if (!nested.length || (nested[0] && typeof nested[0] === 'object' && ('tags' in nested[0] || 'url' in nested[0]))) {
          return nested
        }
      }
    }
    return value
  }

  /**
   * Decodes HTML entities (e.g. `&amp;` → `&`) from DOM-derived strings.
   * @param {string} text
   * @return {string}
   */
  static decodeHtmlEntities(text)
  {
    if (typeof text !== 'string' || text.length === 0) {
      return ''
    }
    if (!text.includes('&')) {
      return text
    }
    let textarea = document.createElement('textarea')
    textarea.innerHTML = text
    return textarea.value
  }

  /**
   * @param {*} stored
   * @return {Array}
   */
  static coerceBookmarkArray(stored)
  {
    if (stored === null || stored === undefined) {
      return []
    }
    stored = Utilities.reviveGmStoredValue(stored)
    if (Array.isArray(stored)) {
      return stored
    }
    if (typeof stored === 'string') {
      let trimmed = stored.trim()
      if (!trimmed.length) {
        return []
      }
      try {
        return Utilities.coerceBookmarkArray(JSON.parse(trimmed))
      } catch (error) {
        return []
      }
    }
    if (stored && typeof stored === 'object') {
      let values = Object.values(stored)
      if (values.length === 1 && Array.isArray(values[0])) {
        return Utilities.coerceBookmarkArray(values[0])
      }
      if (values.length && values.every((entry) => entry && typeof entry === 'object' && ('tags' in entry || 'url' in entry))) {
        return values
      }
    }
    return []
  }

  /**
   * @param {string} json
   * @return {Object}
   */
  static objectFromJSON(json)
  {
    /** @type {{arrays: Object, objects: Object, properties: Object}} */
    let parsedJSON = JSON.parse(json)
    let arrayObject = {}
    let result = {}

    for (let property in parsedJSON.arrays) {
      arrayObject = JSON.parse(parsedJSON.arrays[property])
      result[property] = []

      for (let key in arrayObject) {
        result[property].push(arrayObject[key])
      }
    }
    for (let property in parsedJSON.objects) {
      result[property] = Utilities.objectFromJSON(parsedJSON.objects[property])
    }
    for (let property in parsedJSON.properties) {
      result[property] = parsedJSON.properties[property]
    }
    return result
  }

  static processEventHandlerQueue(handlers, parameters = [], defaultValue = null)
  {
    if (handlers.length) {
      for (let handler of handlers) {
        handler(...parameters)
      }
    }
    return defaultValue
  }

  /**
   * @param milliseconds
   * @return {Promise<*>}
   */
  static sleep(milliseconds)
  {
    return new Promise(resolve => setTimeout(resolve, milliseconds))
  }

  /**
   * @param {string} text
   * @return {string}
   */
  static toKebabCase(text)
  {
    return text.toLowerCase().replaceAll(' ', '-')
  }

  /**
   * @param {string[]} strings
   */
  static trimAndKeepNonEmptyStrings(strings)
  {
    let nonEmptyStrings = [], trimmedString
    for (let string of strings) {
      trimmedString = string.trim()
      if (trimmedString !== '') {
        nonEmptyStrings.push(trimmedString)
      }
    }
    return nonEmptyStrings
  }
}

class Validator
{
  // -------------------------------------------------------------------------
  // Static public methods
  // -------------------------------------------------------------------------

  /**
   * @param {ParentNode|Element|null|undefined} item
   * @param {string} selector
   * @return {boolean}
   */
  static doesChildExist(item, selector)
  {
    return !!(item?.querySelector?.(selector))
  }

  static iFramesRemover()
  {
    GM_addStyle('iframe { display: none !important; }')
  }

  /**
   * @param {ParentNode|Element|null|undefined} item
   * @param {string} selector
   * @return {boolean}
   */
  static isChildMissing(item, selector)
  {
    return !item?.querySelector?.(selector)
  }

  /**
   * @param {number} value
   * @param {number} lowerBound
   * @param {number} upperBound
   * @return {boolean}
   */
  static isInRange(value, lowerBound, upperBound)
  {
    let validationCheck = true

    if (lowerBound > 0 && upperBound > 0) {
      validationCheck = value >= lowerBound && value <= upperBound
    } else {
      if (lowerBound > 0) {
        validationCheck = value >= lowerBound
      }
      if (upperBound > 0) {
        validationCheck = value <= upperBound
      }
    }
    return validationCheck
  }

  /**
   * @param {string} text
   * @param {Object} rules
   * @return {string}
   */
  static sanitize(text, rules)
  {
    if (rules) {
      for (const substitute in rules) {
        text = text.replace(rules[substitute], substitute)
      }
    }
    return text.trim()
  }

  /**
   * @param {Element|null|undefined} textNode
   * @param {Object} rules
   * @return {Validator}
   */
  static sanitizeTextNode(textNode, rules)
  {
    if (textNode) {
      textNode.textContent = Validator.sanitize(textNode.textContent ?? '', rules)
    }
    return this
  }

  /**
   * @param {string} selector
   * @param {Object} rules
   * @return {Validator}
   */
  static sanitizeNodeOfSelector(selector, rules)
  {
    let node = document.querySelector(selector)
    if (node) {
      let sanitizedText = Validator.sanitize(node.textContent ?? '', rules)
      node.textContent = sanitizedText
      document.title = sanitizedText
    }
    return this
  }

  /**
   * @param {string} text
   * @param {*} rules
   * @return {boolean}
   */
  static regexMatches(text, rules)
  {
    return rules ? new RegExp(rules).exec(text) !== null : true
  }

  /**
   * @param {string} text
   * @param {*} rules
   * @return {boolean}
   */
  static validateTextDoesNotContain(text, rules)
  {
    return rules ? new RegExp(rules).exec(text) === null : true
  }
}