Brazen Framework - Utilities

Utilities and helpers for Brazen user scripts framework

Bu script direkt olarak kurulamaz. Başka scriptler için bir kütüphanedir ve meta yönergeleri içerir // @require https://update.greasyfork.org/scripts/375557/1884361/Brazen%20Framework%20-%20Utilities.js

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu betiği yüklemek için bir betik yöneticisi eklentisi yüklemeniz gerekecektir.

(Zaten bir betik yöneticim var, hadi yükleyelim!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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