MetadataParser

A solution to parse user-script metadata blocks.

Αυτός ο κώδικας δεν πρέπει να εγκατασταθεί άμεσα. Είναι μια βιβλιοθήκη για άλλους κώδικες που περιλαμβάνεται μέσω της οδηγίας meta // @require https://update.greasyfork.org/scripts/566592/1756806/MetadataParser.js

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name MetadataParser
// @namespace -
// @version 1.0.0
// @description A solution to parse user-script metadata blocks.
// @author NotYou
// @grant none
// @require https://unpkg.com/[email protected]/lib/index.umd.js
// @require https://update.greasyfork.org/scripts/565798/1752557/Zod%203x%20Error%20Formatter.js
// ==/UserScript==

/* global ZodErrorFormatter */

(function(z) {
    'use strict';

    // Source of the meta keys formatting:

    // https://greasyfork.org/en/help/meta-keys
    // https://wiki.greasespot.net/Metadata_Block
    // https://www.tampermonkey.net/documentation.php
    // https://violentmonkey.github.io/api/metadata-block/
    // https://docs.scriptcat.org/docs/dev/meta/

    class Metadata {
        get MetadataItemSchema() {
            return z.object({
                key: z.string(),
                value: z.string().nullable(),
                locale: z.string().refine(val => /^[a-zA-Z]{2,2}(\-[a-zA-Z]{2,2})?$/.test(val), {
                    message:
                    'Locale must be either a null or ISO 639-1 string (for example: "en", "es"),' +
                    'may also include region code in this format: xx-YY (for example: "en-US", "es-ES")'
                }).nullable()
            })
        }

        constructor(metadata) {
            try {
                this.metadata = this.MetadataItemSchema.array().parse(metadata)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error(ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        getRawData() {
            return this.metadata
        }

        indexOf(key, locale = null) {
            if (typeof locale === 'string') {
                const fullKey = key + ':' + locale

                return this.metadata.findIndex(item => typeof item.locale === 'string' && (item.key + ':' + item.locale) === fullKey)
            }

            return this.metadata.findIndex(item => item.key === key)
        }

        get(key, locale = null) {
            return this.metadata[this.indexOf(key, locale)]
        }

        getAll(key, locale = null) {
            if (typeof locale === 'string') {
                const fullKey = key + ':' + locale

                return this.metadata.filter(item => typeof item.locale === 'string' && (item.key + ':' + item.locale) === fullKey)
            }

            return this.metadata.filter(item => item.key === key)
        }

        has(key, locale) {
            return this.get(key, locale) !== undefined
        }

        add(item) {
            try {
                this.MetadataItemSchema.parse(item)

                this.metadata.push(item)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error(ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        set(key, value, locale = null) {
            const item = this.get(key, locale)

            if (item) {
                item.value = value
            } else {
                this.add({
                    key,
                    value,
                    locale
                })
            }
        }

        forEach(callback) {
            this.metadata.forEach(callback)
        }

        remove(key, locale = null) {
            const index = this.indexOf(key, locale)

            if (index === -1) return null

            return this.metadata.splice(index, 1)
        }

        removeAll(key, locale = null) {
            const removed = []
            const final = []

            if (typeof locale === 'string') {
                for (const item of this.metadata) {
                    if (
                        typeof item.locale === 'string' &&
                        item.key + ':' + item.locale === key + ':' + locale
                    ) {
                        removed.push(item)
                    } else {
                        final.push(item)
                    }
                }

                this.metadata = final
                return removed
            }

            for (const item of this.metadata) {
                if (item.key === key) {
                    removed.push(item)
                } else {
                    final.push(item)
                }
            }

            this.metadata = final
            return removed
        }

        [Symbol.toPrimitive](hint) {
            if (typeof hint === 'number') {
                return NaN
            }

            return this.stringify()
        }

        stringify(pretty = false) {
            let longestKeyLength = 0
            if (pretty) {
                longestKeyLength = this.getRawData().reduce((acc, curr) => {
                    if (typeof curr.locale === 'string' && curr.key.length + curr.locale.length > acc) {
                        return curr.key.length + curr.locale.length + 1
                    } else if (curr.key.length > acc) {
                        return curr.key.length
                    }

                    return acc
                }, 0)
            }

            return '// ==UserScript==\n' + this.metadata.reduce((acc, curr) => {
                let leftPart = ''
                let rightPart = ''

                if (curr.locale) {
                    leftPart = `// @${curr.key}:${curr.locale}`
                } else {
                    leftPart += `// @${curr.key}`
                }

                if (curr.value) {
                    const spacesCount = longestKeyLength - curr.key.length - (typeof curr.locale === 'string' ? curr.locale.length + 1 : 0)

                    rightPart = ' '.repeat(spacesCount <= 0 ? 0 : spacesCount) + ' ' + curr.value
                }

                const metadataItem = leftPart + rightPart + '\n'

                acc += metadataItem

                return acc
            }, '') + '// ==/UserScript=='
        }
    }

    class MetadataSanitizer {
        SanitizationError = class extends Error {
            constructor(message) {
                super(message)

                this.name = 'SanitizationError'
            }
        }

        get FormatIdSchema() {
            return z.string().refine(value => Object.keys(this._formats).indexOf(value) !== -1, {
                message: 'Format id must be one of these values: ' + Object.keys(this._formats).map(e => `"${e}"`).join(', ')
            })
        }

        get ConfigSchema() {
            return z.object({
                format: this.FormatIdSchema,
                allowNonExistentKeys: z.boolean().default(true),
                allowUnexpectedMultilingualKeys: z.boolean().default(false),
                allowDuplicatesForUniqueKeys: z.boolean().default(false),
                requireMandatoryKeys: z.boolean().default(true)
            })
        }

        constructor(config = {}) {
            try {
                this.config = this.ConfigSchema.parse(config)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error('Invalid configuration provided, ' + ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        _formats = {
            greasyfork: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                description: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: true,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                updateURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                installURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                license: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                contributionURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                contributionAmount: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                compatible: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                incompatible: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                antifeature: {
                    required: false,
                    multilingual: true,
                    multiple: false
                }
            },
            greasemonkey: {
                description: {
                    required: false,
                    multilingual: true,
                    multiple: false
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                homepageUrl: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: false,
                    multilingual: false,
                    multiple: false
                }
            },
            tampermonkey: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                copyright: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                description: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['iconURL', 'defaulticon']
                },
                icon64: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['icon64URL']
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                author: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                homepage: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['homepageURL', 'website', 'source']
                },
                antifeature: {
                    required: false,
                    multilingual: true,
                    multiple: true
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false,
                },
                'run-in': {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                sandbox: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                tag: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                connect: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                updateURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                webRequest: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                unwrap: {
                    required: false,
                    multilingual: false,
                    multiple: false
                }
            },
            violentmonkey: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['include']
                },
                'match-exclude': {
                    required: false,
                    multilingual: false,
                    multiple: true,
                    alternatives: ['exclude']
                },
                version: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                description: {
                    required: false,
                    multilingual: true,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'inject-into': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                homepageURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                unwrap: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'top-level-await': {
                    required: false,
                    multilingual: false,
                    multiple: false
                }
            },
            scriptcat: {
                name: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                namespace: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                version: {
                    required: true,
                    multilingual: false,
                    multiple: false
                },
                description: {
                    required: true,
                    multilingual: true,
                    multiple: false
                },
                author: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'run-at': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'run-in': {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'early-start': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                'inject-into': {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                storageName: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                background: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                crontab: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                match: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                include: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                exclude: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                grant: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                connect: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                resource: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                require: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                'require-css': {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                noframes: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                definition: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                antifeature: {
                    required: false,
                    multilingual: false,
                    multiple: true
                },
                license: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                updateURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                downloadURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                supportURL: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                homepage: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['homepageURL', 'website']
                },
                source: {
                    required: false,
                    multilingual: false,
                    multiple: false
                },
                icon: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['iconURL', 'defaulticon']
                },
                icon64: {
                    required: false,
                    multilingual: false,
                    multiple: false,
                    alternatives: ['icon64URL']
                }
            }
        }

        _getKeyData(queryKey) {
            const format = this._formats[this.config.format]

            for (const key in format) {
                const keyData = format[key]

                if (queryKey === key) {
                    return { key, keyData }
                } else if (keyData.alternatives && keyData.alternatives.indexOf(queryKey) !== -1) {
                    return { key, keyData }
                }
            }

            return null
        }

        sanitize(metadata) {
            const sanitized = []
            const format = this._formats[this.config.format]
            const acquiredKeys = {}
            const items = metadata.getRawData()

            for (const item of items) {
                const keyInfo = this._getKeyData(item.key)
                const fullKey = item.key + (item.locale ? ':' + item.locale : '')

                const keyDataNotFound = keyInfo === null

                if (keyDataNotFound && !this.config.allowNonExistentKeys) {
                    throw new this.SanitizationError(`You cannot use key "${item.key}" because it is not available in the format "${this.config.format}"`)
                } else if (keyDataNotFound) {
                    sanitized.push(item)

                    continue
                }

                const keyData = keyInfo.keyData
                const notMultilingualButHasLocale = item.locale && !keyData.multilingual

                if (notMultilingualButHasLocale && !this.config.allowUnexpectedMultilingualKeys) {
                    throw new this.SanitizationError(`You cannot use locale codes for the non-multilingual key "${item.key}" with the format "${this.config.format}"`)
                }

                if (acquiredKeys[fullKey] && !keyData.multiple && !this.config.allowDuplicatesForUniqueKeys) {
                    throw new this.SanitizationError(`You cannot use key "${item.key}" multiple times with the format "${this.config.format}"`)
                }

                acquiredKeys[keyInfo.key] = true
                acquiredKeys[fullKey] = true

                if (keyData.alternatives) {
                    for (const alternative of keyData.alternatives) {
                        acquiredKeys[alternative] = true
                    }
                }

                sanitized.push(item)
            }


            if (this.config.requireMandatoryKeys) {
                const requiredKeys = Object.keys(format).filter(key => format[key].required)
                const missingKeys = requiredKeys.filter(requiredKey => !acquiredKeys[requiredKey])

                if (missingKeys.length !== 0) {
                    throw new this.SanitizationError('Not all mandatory keys are present. These keys are missing: ' + missingKeys.map(key => `"${key}"`).join(', '))
                }
            }

            return new Metadata(sanitized)
        }
    }

    class MetadataParser {
        ParsingError = class extends Error {
            constructor(message) {
                super(message)

                this.name = 'ParsingError'
            }
        }

        get ParseOptionsSchema() {
            return z.object({
                sanitizer: z.custom(value => value instanceof MetadataSanitizer).optional(),
                strictSyntax: z.boolean().default(false)
            })
        }

        constructor(defaultOptions = {}) {
            try {
                this.defaultOptions = this.ParseOptionsSchema.parse(defaultOptions)
            } catch(err) {
                if (err instanceof z.ZodError) {
                    throw new Error('Invalid options provided, ' + ZodErrorFormatter.format(err))
                }

                throw new Error(err)
            }
        }

        parse(metadataString, options) {
            if (typeof metadataString !== 'string') {
                throw new Error('Provided metadata is not a string')
            }

            const parsed = this.ParseOptionsSchema.safeParse(options)

            if (!parsed.success) {
                if (this.defaultOptions) {
                    options = this.defaultOptions
                } else {
                    throw new Error(ZodErrorFormatter.format(parsed.error))
                }
            } else {
                options = parsed.data
            }

            metadataString = metadataString.trim()

            const metadataStart = metadataString.match(/\/\/\s+==UserScript==/)
            const metadataEnd = metadataString.match(/\/\/\s+==\/UserScript==/)

            if (metadataStart === null || metadataEnd === null) {
                throw new this.ParsingError('Invalid string, cannot parse data. Could not find user script metadata comment block')
            }

            if (metadataStart.index > metadataEnd.index) {
                throw new this.ParsingError('Invalid string, cannot parse data. Metadata start comment is farther than metadata end comment')
            }

            const metadataRaw = metadataString.slice(
                metadataStart.index + metadataStart[0].length, // cut start comment -> // ==UserScript==
                metadataEnd.index // cut end comment -> // ==/UserScript==
            ).split('\n')

            const result = []

            const reMetaItem = /\/\/\s+@(?<key>[a-zA-Z\-]+)(\:(?<locale>[a-zA-Z]{2,2}(\-[a-zA-Z]{2,2})?)?)?(\s+(?<value>.*?))?$/
            const falsyToNull = val => val ? val : null

            for (let i = 0; i < metadataRaw.length; i++) {
                const line = metadataRaw[i].trim()

                if (line === '') continue

                const match = line.match(reMetaItem)
                const isInvalidSyntax = match === null

                if (isInvalidSyntax && options.strictSyntax) {
                    throw new this.ParsingError(`Invalid syntax, cannot parse the metadata. Invalid line: "${line}"`)
                } else if (isInvalidSyntax) {
                    continue
                }

                const { key, value, locale } = match.groups

                result.push({ key, value: falsyToNull(value), locale: falsyToNull(locale) })
            }

            const metadata = new Metadata(result)

            if (options.sanitizer) {
                return options.sanitizer.sanitize(metadata)
            }

            return metadata
        }

        safeParse(metadataString, options) {
            try {
                const result = this.parse(metadataString, options)

                return { success: true, data: result }
            } catch(error) {
                return { success: false, error }
            }
        }
    }

    window.Metadata = Metadata
    window.MetadataSanitizer = MetadataSanitizer
    window.MetadataParser = MetadataParser
})(window.Zod);