// Copyright (C) 2022-2026 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'];
/**
* Decodes response as UTF-8 text using TextDecoder API.
* Forces UTF-8 interpretation regardless of Content-Type header.
* This fixes issues on Android WebView where response.text() doesn't always
* correctly interpret charset, causing Unicode characters to display incorrectly.
*
* @async
* @param {Response} response - Fetch API response object
* @return {Promise<string>}
* @private
*/
async function decodeResponseAsUTF8(response) {
try {
const arrayBuffer = await response.arrayBuffer();
const decoder = new TextDecoder('utf-8');
return decoder.decode(arrayBuffer);
} catch (error) {
console.warn('TextDecoder failed, falling back to response.text():', error);
return await response.text();
}
}
/**
* 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;
}
/**
* Fetches a resource and returns data along with version header (ETag or Last-Modified).
*
* @async
* @param {string} url - URL of the resource you want to fetch.
* @param {Object} [options={}] - Fetch options.
* @param {boolean} [options.parseJSON=false] - Parse response as JSON.
* @param {boolean} [options.headOnly=false] - Only fetch headers (HEAD request).
* @param {boolean} [options.use_fetch_head_method=true] - Allow HEAD requests (if false, always use GET).
* @return {Promise<{data: string|object|null, version: string|null}>}
*/
export async function fetchResource(url, options = {}) {
const { parseJSON = false, headOnly = false, use_fetch_head_method = true } = options;
// Using built-in fetch in browser, otherwise import polyfill
// 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 {
// If headOnly requested but HEAD not allowed, use GET anyway
const method = headOnly && use_fetch_head_method ? 'HEAD' : 'GET';
const response = await c_fetch(url + '?' + Date.now(), {
method: method,
cache: 'no-cache',
});
if (!response.ok) {
return { data: null, version: null };
}
const version = response.headers.get('ETag') || response.headers.get('Last-Modified');
// If we made HEAD request, no data
if (headOnly && method === 'HEAD') {
return { data: null, version };
}
// Parse data with forced UTF-8 decoding
const text = await decodeResponseAsUTF8(response);
const data = parseJSON ? JSON.parse(text) : text;
return { data, version };
} catch (error) {
console.error('Error in fetchResource:', error);
return { data: null, version: null };
}
}
/**
* This is a wrapper over the fetch() API method with pre-built parameters.
*
* @deprecated Use {@link fetchResource} instead for better version tracking.
* @async
* @param {string} url - URL of the resource you want to fetch.
* @param {"parseJSON" | "head" | null} [variant=null] - Type of request:
* "parseJSON" - Load the resource and parse it as a JSON response.
* "head" - Requests only headers (returns version: ETag or Last-Modified).
* null - Get resource as text.
* @return {Promise<string|object|null>}
*/
export async function ajaxGet(url, variant) {
const options = {};
if (variant === 'parseJSON') {
options.parseJSON = true;
} else if (variant === 'head') {
options.headOnly = true;
}
const { data, version } = await fetchResource(url, options);
// Old behavior: return version for 'head', otherwise return data
if (variant === 'head') {
return version;
}
return data;
}
/**
* 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;
}