Utilities and helpers for Brazen user scripts framework
Tätä skriptiä ei tulisi asentaa suoraan. Se on kirjasto muita skriptejä varten sisällytettäväksi metadirektiivillä // @require https://update.greasyfork.org/scripts/375557/1884361/Brazen%20Framework%20-%20Utilities.js.
// ==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. `&` → `&`) 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
}
}