Brazen Framework - Utilities

Utilities and helpers for Brazen user scripts framework

אין להתקין סקריפט זה ישירות. זוהי ספריה עבור סקריפטים אחרים // @require https://update.greasyfork.org/scripts/375557/1884361/Brazen%20Framework%20-%20Utilities.js

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 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.

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

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         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
  }
}