// ==UserScript==
// @name Base Brazen Resource
// @namespace brazen
// @version 3.2.0
// @author brazenvoid
// @license GPL-3.0-only
// @description Base library for my scripts
// @run-at document-end
// ==/UserScript==
const LINE_BREAK_REGEX = /\r?\n/g
const CONFIG_TYPE_CHECKBOXES_GROUP = 'checkboxes'
const CONFIG_TYPE_FLAG = 'flag'
const CONFIG_TYPE_NUMBER = 'number'
const CONFIG_TYPE_RADIOS_GROUP = 'radios'
const CONFIG_TYPE_RANGE = 'range'
const CONFIG_TYPE_RULESET = 'ruleset'
const CONFIG_TYPE_TEXT = 'text'
class ChildObserver
{
/**
* @callback observerOnMutation
* @param {NodeList} nodes
*/
/**
* @return {ChildObserver}
*/
static create ()
{
return new ChildObserver
}
/**
* ChildObserver constructor
*/
constructor ()
{
this._node = null
this._observer = null
this._onNodesAdded = null
this._onNodesRemoved = null
}
/**
* @return {ChildObserver}
* @private
*/
_observeNodes ()
{
this._observer.observe(this._node, {childList: true})
return this
}
/**
* Attach an observer to the specified node(s)
* @param {Node} node
* @returns {ChildObserver}
*/
observe (node)
{
this._node = node
this._observer = new MutationObserver((mutations) => {
for (let mutation of mutations) {
if (mutation.addedNodes.length && this._onNodesAdded !== null) {
this._onNodesAdded(
mutation.addedNodes,
mutation.previousSibling,
mutation.nextSibling,
mutation.target,
)
}
if (mutation.removedNodes.length && this._onNodesRemoved !== null) {
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()
}
resumeObservation ()
{
this._observeNodes()
}
}
class ConfigurationManager
{
/**
* @typedef {{title: string, type: string, element: null|JQuery, value: *, maximum: int, minimum: int, options: string[], helpText: string, onEncode: null|Function, onDecode:
* null|Function}} ConfigurationField
*/
/**
* @param {BrazenUIGenerator} uiGenerator
* @return {ConfigurationManager}
*/
static create (uiGenerator)
{
return new ConfigurationManager(uiGenerator)
}
constructor (uiGenerator)
{
/**
* @type {{}}
* @private
*/
this._config = {}
/**
* @type {LocalStore}
* @private
*/
this._localStore = null
/**
* @type BrazenUIGenerator
* @private
*/
this._uiGen = uiGenerator
}
/**
* @param {string} type
* @param {string} name
* @param {*} value
* @param {string} helpText
* @return ConfigurationField
* @private
*/
_createField (type, name, value, helpText)
{
let field
if (typeof this._config[name] === 'undefined') {
field = {
element: null,
helpText: helpText,
title: name,
type: type,
value: value,
}
this._config[Utilities.toKebabCase(name)] = field
} else {
field = this._config[name]
field.helpText = helpText
field.value = value
}
return field
}
/**
* @param {boolean} ignoreIfDefaultsSet
* @private
*/
_syncLocalStore (ignoreIfDefaultsSet)
{
let storeObject = this._localStore.get()
if (!ignoreIfDefaultsSet || !this._localStore.wereDefaultsSet()) {
for (let key in this._config) {
this._config[key].value = storeObject[key]
}
this.updateInterface()
}
return this
}
/**
* @return {{}}
* @private
*/
_toStoreObject ()
{
let storeObject = {}
for (let key in this._config) {
storeObject[key] = this._config[key].value
}
return storeObject
}
createElement (name)
{
let field = this.getField(name)
let inputGroup
switch (field.type) {
case CONFIG_TYPE_CHECKBOXES_GROUP:
inputGroup = this._uiGen.createFormCheckBoxesGroupSection(field.title, field.options, field.helpText)
field.element = inputGroup
break
case CONFIG_TYPE_FLAG:
inputGroup = this._uiGen.createFormInputGroup(field.title, 'checkbox', field.helpText)
field.element = inputGroup.find('input')
break
case CONFIG_TYPE_NUMBER:
inputGroup = this._uiGen.createFormInputGroup(field.title, 'number', field.helpText).attr('min', field.minimum).attr('max', field.maximum)
field.element = inputGroup.find('input')
break
case CONFIG_TYPE_RADIOS_GROUP:
inputGroup = this._uiGen.createFormRadiosGroupSection(field.title, field.options, field.helpText)
field.element = inputGroup
break
case CONFIG_TYPE_RANGE:
inputGroup = this._uiGen.createFormRangeInputGroup(field.title, 'number', field.minimum, field.maximum, field.helpText)
field.element = inputGroup.find('input')
break
case CONFIG_TYPE_RULESET:
inputGroup = this._uiGen.createFormTextAreaGroup(field.title, 2, field.helpText)
field.element = inputGroup.find('textarea')
break
}
return inputGroup
}
initialize (scriptPrefix)
{
this._localStore = new LocalStore(scriptPrefix + 'settings', this._toStoreObject())
this._localStore.onChange(() => this.updateInterface())
return this._syncLocalStore(true)
}
addCheckboxesGroup (name, options, helpText)
{
let field = this._createField(CONFIG_TYPE_CHECKBOXES_GROUP, name, [], helpText)
field.options = options
return this
}
addFlagField (name, helpText)
{
this._createField(CONFIG_TYPE_FLAG, name, false, helpText)
return this
}
addNumberField (name, minimum, maximum, helpText)
{
let field = this._createField(CONFIG_TYPE_NUMBER, name, minimum, helpText)
field.minimum = minimum
field.maximum = maximum
return this
}
addRadiosGroup (name, options, helpText)
{
let field = this._createField(CONFIG_TYPE_RADIOS_GROUP, name, options[0], helpText)
field.options = options
return this
}
addRangeField (name, minimum, maximum, helpText)
{
let field = this._createField(CONFIG_TYPE_RANGE, name, {minimum: minimum, maximum: minimum}, helpText)
field.minimum = minimum
field.maximum = maximum
return this
}
addRulesetField (name, helpText, onEncode = null, onDecode = null)
{
let field = this._createField(CONFIG_TYPE_RULESET, name, [], helpText)
field.onEncode = onEncode ?? field.onEncode
field.onDecode = onDecode ?? field.onDecode
return this
}
addTextField (name, helpText)
{
this._createField(CONFIG_TYPE_TEXT, name, '', helpText)
return this
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
getField (name)
{
let field = this._config[Utilities.toKebabCase(name)]
if (field) {
return field
}
throw new Error('Field named "'+ name +'" could not be found')
}
getValue (name)
{
return this.getField(name).value
}
revertChanges ()
{
return this._syncLocalStore(false)
}
save ()
{
this.update()._localStore.save(this._toStoreObject())
return this
}
update ()
{
let field, value
for (let fieldName in this._config) {
field = this._config[fieldName]
if (field.element) {
switch (field.type) {
case CONFIG_TYPE_CHECKBOXES_GROUP:
field.value = []
field.element.find('input:checked').each((index, element) => {
field.value.push($(element).attr('data-key'))
})
break
case CONFIG_TYPE_FLAG:
field.value = field.element.prop('checked')
break
case CONFIG_TYPE_NUMBER:
field.value = parseInt(field.element.val())
break
case CONFIG_TYPE_RADIOS_GROUP:
field.value = field.element.find('input:checked').attr('data-key')
break
case CONFIG_TYPE_RANGE:
field.value = {
minimum: field.element.first().val(),
maximum: field.element.last().val(),
}
break
case CONFIG_TYPE_RULESET:
value = Utilities.trimAndKeepNonEmptyStrings(field.element.val().split(LINE_BREAK_REGEX))
field.value = field.onDecode ? field.onDecode(value, field) : value
break
default:
field.value = field.element.val()
}
}
}
return this
}
updateInterface ()
{
let elements, field, value
for (let fieldName in this._config) {
field = this._config[fieldName]
if (field.element) {
switch (field.type) {
case CONFIG_TYPE_CHECKBOXES_GROUP:
elements = field.element.find('input')
for (let key of field.value) {
elements.filter('[data-key="'+ key +'"]').prop('checked', true)
}
break
case CONFIG_TYPE_FLAG:
field.element.prop('checked', field.value)
break
case CONFIG_TYPE_NUMBER:
field.element.val(field.value)
break
case CONFIG_TYPE_RADIOS_GROUP:
field.element.find('input[data-key="'+ field.value +'"]').prop('checked', true).trigger('change')
break
case CONFIG_TYPE_RANGE:
field.element.first().val(field.value.minimum)
field.element.last().val(field.value.maximum)
break
case CONFIG_TYPE_RULESET:
value = field.onEncode ? field.onEncode(field.value) : field.value
field.element.val(value.join('\n'))
break
default:
field.element.val(field.value)
}
}
}
return this
}
}
class LocalStore
{
/**
* @callback storeEventHandler
* @param {Object} store
*/
/**
* @param {string} key
* @param {Object} defaults
*/
constructor (key, defaults)
{
/**
* @type {Object}
* @private
*/
this._defaults = defaults
/**
* @type {boolean}
* @private
*/
this._defaultsSet = false
/**
* @type {string}
* @private
*/
this._key = key
// Events
/**
* @type {storeEventHandler}
*/
this._onChange = null
}
_handleOnChange ()
{
if (this._onChange !== null) {
this._onChange(this.get())
}
}
/**
* @return {LocalStore}
*/
delete ()
{
window.localStorage.removeItem(this._key)
return this
}
/**
* @param {string} filename
*/
exportToFile (filename)
{
let linkElement = document.createElement('a')
let file = new Blob([Utilities.objectToJSON(this.get())], {type: 'application/json'})
linkElement.href = URL.createObjectURL(file)
linkElement.download = filename
linkElement.click()
URL.revokeObjectURL(linkElement.href)
linkElement.remove()
}
/**
* @return {*}
*/
get ()
{
this._defaultsSet = false
let storedStore = window.localStorage.getItem(this._key)
return storedStore === null ? this.restoreDefaults() : Utilities.objectFromJSON(storedStore)
}
importFromFile (file)
{
}
/**
* @param {storeEventHandler} handler
* @return {LocalStore}
*/
onChange (handler)
{
this._onChange = handler
return this
}
/**
* @return {Object}
*/
restoreDefaults ()
{
this._defaultsSet = true
this.save(this._defaults)
return this._defaults
}
/**
* @param {Object} data
* @return {LocalStore}
*/
save (data)
{
window.localStorage.setItem(this._key, Utilities.objectToJSON(data))
this._handleOnChange()
return this
}
/**
* @return {boolean}
*/
wereDefaultsSet ()
{
return this._defaultsSet
}
}
class SelectorGenerator
{
/**
* @param {string} selectorPrefix
*/
constructor (selectorPrefix)
{
/**
* @type {string}
* @private
*/
this._prefix = selectorPrefix
}
/**
* @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
{
/**
* @param {string} selectorPrefix
*/
constructor (selectorPrefix)
{
/**
* @type {SelectorGenerator}
* @private
*/
this._selectorGenerator = new SelectorGenerator(selectorPrefix)
/**
* @type {{Total: number}}
* @private
*/
this._statistics = {Total: 0}
}
/**
* @param {string} statisticType
* @param {boolean} validationResult
* @param {number} value
*/
record (statisticType, validationResult, value = 1)
{
if (!validationResult) {
if (typeof 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 Utilities
{
/**
* @param {string[]} words
* @return {RegExp}
*/
static buildWholeWordMatchingRegex (words)
{
let patternedWords = []
for (let i = 0; i < words.length; i++) {
patternedWords.push('\\b' + words[i] + '\\b')
}
return new RegExp('(' + patternedWords.join('|') + ')', 'gi')
}
/**
* @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
}
/**
* @param {Object} object
* @return {string}
*/
static objectToJSON (object)
{
let arrayToObject
let json = {arrays: {}, objects: {}, properties: {}}
for (let property in object) {
if (typeof object[property] === 'object') {
if (Array.isArray(object[property])) {
arrayToObject = {}
for (let key in object[property]) {
arrayToObject[key] = object[property][key]
}
json.arrays[property] = JSON.stringify(arrayToObject)
} else {
json.objects[property] = Utilities.objectToJSON(object[property])
}
} else {
json.properties[property] = object[property]
}
}
return JSON.stringify(json)
}
/**
* @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 = []
for (let string of strings) {
string = string.trim()
if (string !== '') {
nonEmptyStrings.push(string)
}
}
return nonEmptyStrings
}
}
class Validator
{
static iFramesRemover ()
{
GM_addStyle(' iframe { display: none !important; } ')
}
/**
* @param {StatisticsRecorder} statisticsRecorder
*/
constructor (statisticsRecorder)
{
/**
* @type {StatisticsRecorder}
* @private
*/
this._statisticsRecorder = statisticsRecorder
}
/**
* @param {string} text
* @param {Object} rules
* @return {string}
*/
sanitize (text, rules)
{
for (const substitute in rules) {
text = text.replace(rules[substitute], substitute)
}
return text.trim()
}
/**
* @param {JQuery} textNode
* @param {Object} rules
* @return {Validator}
*/
sanitizeTextNode (textNode, rules)
{
textNode.text(this.sanitize(textNode.text(), rules))
return this
}
/**
* @param {string} selector
* @param {Object} rules
* @return {Validator}
*/
sanitizeNodeOfSelector (selector, rules)
{
let node = $(selector)
if (node.length) {
let sanitizedText = this.sanitize(node.text(), rules)
node.text(sanitizedText)
document.title = sanitizedText
}
return this
}
/**
* @param {string} name
* @param {JQuery} item
* @param {string} selector
* @return {boolean}
*/
validateNodeExistence (name, item, selector)
{
let validationCheck = item.find(selector).length > 0
this._statisticsRecorder.record(name, validationCheck)
return validationCheck
}
/**
* @param {string} name
* @param {JQuery} item
* @param {string} selector
* @return {boolean}
*/
validateNodeNonExistence (name, item, selector)
{
let validationCheck = item.find(selector).length === 0
this._statisticsRecorder.record(name, validationCheck)
return validationCheck
}
/**
* @param {string} name
* @param {number} value
* @param {number[]} bounds
* @return {boolean}
*/
validateRange (name, value, bounds)
{
let validationCheck = true
if (bounds[0] > 0 && bounds[1] > 0) {
validationCheck = value >= bounds[0] && value <= bounds[1]
} else {
if (bounds[0] > 0) {
validationCheck = value >= bounds[0]
}
if (bounds[1] > 0) {
validationCheck = value <= bounds[1]
}
}
this._statisticsRecorder.record(name, validationCheck)
return validationCheck
}
/**
* @param {string} name
* @param {number} lowerBound
* @param {number} upperBound
* @param getValueCallback
* @return {boolean}
*/
validateRangeFilter (name, lowerBound, upperBound, getValueCallback)
{
if (lowerBound > 0 || upperBound > 0) {
return this.validateRange(name, getValueCallback(), [lowerBound, upperBound])
}
return true
}
/**
* @param {string} text
* @param {Object} rules
* @param {string} key
* @return {boolean}
*/
validateTextContains (text, rules, key)
{
let validationCheck = true
if (rules) {
this._statisticsRecorder.record(key, validationCheck = text.match(rules) !== null)
}
return validationCheck
}
/**
* @param {string} text
* @param {Object} rules
* @param {string} key
* @return {boolean}
*/
validateTextDoesNotContain (text, rules, key)
{
let validationCheck = true
if (rules) {
this._statisticsRecorder.record(key, validationCheck = text.match(rules) === null)
}
return validationCheck
}
}