// Copyright (C) 2022-2025 IITC-CE - GPL-3.0 with Store Exception - see LICENSE and COPYING.STORE
export let wait_timeout_id = null;
const METABLOCK_RE_HEADER = /==UserScript==\s*([\s\S]*)\/\/\s*==\/UserScript==/m; // Note: \s\S to match linebreaks
const METABLOCK_RE_ENTRY = /\/\/\s*@(\S+)\s+(.*)$/gm; // example match: "\\ @name some text"
const META_ARRAY_TYPES = ['include', 'exclude', 'match', 'excludeMatch', 'require', 'grant'];
/**
* Parses code of UserScript and returns an object with data from ==UserScript== header.
*
* @param {string} code - UserScript plugin with ==UserScript== header.
* @return {Object.<string, string>|null}
*/
export function parseMeta(code) {
let header = METABLOCK_RE_HEADER.exec(code);
if (header === null) return null;
header = header[1];
const meta = {};
let entry = METABLOCK_RE_ENTRY.exec(header);
while (entry) {
const [keyName, locale] = entry[1].split(':');
const camelKey = keyName.replace(/[-_](\w)/g, (m, g) => g.toUpperCase());
const key = locale ? `${camelKey}:${locale.toLowerCase()}` : camelKey;
let value = entry[2];
if (camelKey === 'name') {
value = value.replace('IITC plugin: ', '').replace('IITC Plugin: ', '');
}
if (META_ARRAY_TYPES.includes(key)) {
if (typeof meta[key] === 'undefined') {
meta[key] = [];
}
meta[key].push(value);
} else {
meta[key] = value;
}
entry = METABLOCK_RE_ENTRY.exec(header);
}
// @homepageURL: compatible with @homepage
if (!meta.homepageURL && meta.homepage) meta.homepageURL = meta.homepage;
return meta;
}
/**
* This is a wrapper over the fetch() API method with pre-built parameters.
*
* @async
* @param {string} url - URL of the resource you want to fetch.
* @param {"parseJSON" | "Last-Modified" | null} [variant=null] - Type of request:
* "parseJSON" - Load the resource and parse it as a JSON response.
* "Last-Modified" - Requests the last modification date of a file.
* null - Get resource as text.
* @return {Promise<string|object|null>}
*/
export async function ajaxGet(url, variant) {
// Using built-in fetch in browser , otherwise import polyfil
// eslint-disable-next-line no-undef
const c_fetch = (...args) => (process.env.NODE_ENV !== 'test' ? fetch(...args) : import('node-fetch').then(({ default: fetch }) => fetch(...args)));
try {
const response = await c_fetch(url + '?' + Date.now(), {
method: variant === 'Last-Modified' ? 'HEAD' : 'GET',
cache: 'no-cache',
});
if (response.ok) {
switch (variant) {
case 'Last-Modified':
return response.headers.get('Last-Modified');
case 'parseJSON':
return await response.json();
default:
return await response.text();
}
}
} catch (error) {
console.error('Error in ajaxGet: ', error);
}
return null;
}
/**
* Generates a unique random string with prefix.
*
* @param {string} [prefix="VM"] prefix - Prefix string.
* @return {string}
*/
export function getUniqId(prefix = 'VM') {
const now = performance.now();
return prefix + Math.floor((now - Math.floor(now)) * 1e12).toString(36) + Math.floor(Math.random() * 1e12).toString(36);
}
/**
* Returns the unique identifier (UID) of plugin, composed of available plugin fields.
*
* @param {plugin} plugin - Plugin object.
* @return {string|null}
*/
export function getUID(plugin) {
const available_fields = [];
if (plugin['id']) {
available_fields.push(plugin['id']);
}
if (plugin['filename']) {
available_fields.push(plugin['filename']);
}
if (plugin['name']) {
available_fields.push(plugin['name']);
}
if (plugin['namespace']) {
available_fields.push(plugin['namespace']);
}
if (available_fields.length < 2) {
return null;
}
return available_fields.slice(-2).join('+');
}
/**
* Checks if the accepted URL matches one or all domains related to Ingress.
*
* @param {string} url - URL address.
* @param {"<all>" | "intel.ingress.com" | "missions.ingress.com"} [domain="<all>"] domain - One or all domains related to Ingress.
* @return {boolean}
*/
export function check_url_match_pattern(url, domain) {
if (url.startsWith('/^')) {
url = url
.replace(/\/\^|\?/g, '')
.replace(/\\\//g, '/')
.replace(/\.\*/g, '*')
.replace(/\\\./g, '.');
}
if (
(/^(http|https|\*):\/\/(www|\*)\.ingress\.com\/mission*/.test(url) || /^(http|https|\*):\/\/missions\.ingress\.com\/*/.test(url)) &&
(domain === '<all>' || domain === 'missions.ingress.com')
) {
return true;
}
if (
(/^(http|https|\*):\/\/(www\.|\*\.|\*|)ingress\.com(?!.*\/mission*)/.test(url) || /^(http|https|\*):\/\/intel\.ingress\.com*/.test(url)) &&
(domain === '<all>' || domain === 'intel.ingress.com')
) {
return true;
}
return false;
}
/**
* A simple check for a match Ingress sites.
* Far from implementing all the features of userscripts {@link https://violentmonkey.github.io/api/matching/|@match/@include},
* but sufficient for our needs.
*
* @param {plugin} meta - Object with data from ==UserScript== header.
* @param {"<all>" | "intel.ingress.com" | "missions.ingress.com"} [domain="<all>"] domain - One or all domains related to Ingress.
* @return {boolean}
*/
export function check_meta_match_pattern(meta, domain = '<all>') {
if (meta.match && meta.match.length) {
for (const url of meta.match) {
if (check_url_match_pattern(url, domain)) return true;
}
}
if (meta.include && meta.include.length) {
for (const url of meta.include) {
if (check_url_match_pattern(url, domain)) return true;
}
}
return false;
}
/**
* Sets a timer with a specified number of seconds to wait.
*
* @async
* @param {number} seconds
* @return {Promise<void>}
*/
export async function wait(seconds) {
return new Promise((resolve) => {
clearTimeout(wait_timeout_id);
wait_timeout_id = null;
wait_timeout_id = setTimeout(resolve, seconds * 1000);
});
}
/**
* Stops the timer created in {@link wait}
*
* @return {void}
*/
export function clearWait() {
clearTimeout(wait_timeout_id);
wait_timeout_id = null;
}
/**
* Checks if any value is set.
*
* @param {any} value - Any value.
* @return {boolean}
*/
export function isSet(value) {
return typeof value !== 'undefined' && value !== null;
}
/**
* Processes a string by removing invalid characters for the file system and limiting its length.
*
* @param {string} input - The original string to be converted into a file name.
* @param {number} maxLength - The maximum length of the file name (default is 255 characters).
* @returns {string} - The processed string.
*/
export function sanitizeFileName(input, maxLength = 255) {
const invalidChars = /[/\\:*?"<>|]/g;
let sanitized = input.replace(invalidChars, '');
// Truncate the length to maxLength characters
if (sanitized.length > maxLength) {
sanitized = sanitized.slice(0, maxLength);
}
return sanitized;
}