URL Replacer/Redirector

Redirect specific sites by replacing part of the URL.

// ==UserScript==
// @name         URL Replacer/Redirector
// @namespace    https://github.com/theborg3of5/Userscripts/
// @version      2.0
// @description  Redirect specific sites by replacing part of the URL.
// @author       Gavin Borg
// @require      https://openuserjs.org/src/libs/sizzle/GM_config.js
// @match        https://greasyfork.org/en/scripts/403100-url-replacer-redirector
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM.getValue
// @grant        GM.setValue
// ==/UserScript==

// Grant GM_*value for legacy Greasemonkey, GM.*value for Greasemonkey 4+

// Settings conversion should be removed after a while, including:
//  - convertOldStyleSettings()
//  - Grant for GM_deleteValue

// Our configuration instance - this loads/saves settings and handles the config popup.
const Config = new GM_config();

(async function ()
{
    'use strict';

    // Find the site that we matched
    const startURL = window.location.href;
    const currentSite = getUserSiteForURL(startURL);
    
    // Add a menu item to the menu to launch the config
    GM_registerMenuCommand('Configure redirect settings', () => Config.open());
    
    // Set up and load config
    let configSettings = buildConfigSettings(currentSite);
    await initConfigAsync(configSettings); // await because we need to read from the resulting (async-loaded) values

    // Convert old-style settings if we find them.
    await convertOldStyleSettings(configSettings);
    
    // Get replacement settings for the current URL
    const replaceSettings = getSettingsForSite(currentSite);
    if (!replaceSettings)
    {
        return;
    }

    // Build new URL
    const newURL = transformURL(startURL, replaceSettings);
    
    // Redirect to the new URL
    if (startURL === newURL)
    { 
        logToConsole("Current URL is same as redirection target: " + newURL);
        return
    }
    window.location.replace(newURL);
})();

// Get the site (entry from user includes/matches) that matches the current URL.
function getUserSiteForURL(startURL)
{
    for (const site of getUserSites())
    {
        // Use a RegExp so we check case-insensitively
        let siteRegex = "";
        if (site.startsWith("/"))
        {
            siteRegex = new RegExp(site.slice(1, -1), "i"); // If the site starts with a /, treat it as a regex (but remove the leading/trailing /)
        }
        else
        {
            siteRegex = new RegExp(site.replace(/\*/g, "[^ ]*"), "i"); // Otherwise replace * wildcards with regex-style [^ ]* wildcards
        }

        if (siteRegex.test(startURL)) {
            return site; // First match always wins
        }
    }
}

// We support both includes and matches, but only the user-overridden ones of each.
function getUserSites()
{ 
    return GM_info.script.options.override.use_matches.concat(GM_info.script.options.override.use_includes);
}

// Perform the replacements specified by the given settings.
function transformURL(startURL, siteSettings)
{
    const { prefix, suffix, targetStrings, replacementStrings } = siteSettings;

    // Transform the URL
    let newURL = startURL;
    for (let i = 0; i < targetStrings.length; i++)
    {
        let toReplace = prefix + targetStrings[i] + suffix;
        const replaceWith = prefix + replacementStrings[i] + suffix;

        // Use a RegEx to allow case-insensitive matching
        toReplace = new RegExp(escapeRegex(toReplace), "i"); // Escape any regex characters - we don't support actual regex matching.

        newURL = newURL.replace(toReplace, replaceWith);
    }

    return newURL;
}

// From https://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript/3561711#3561711
function escapeRegex(string)
{
    return string.replace(/[-[\]{}()*+!<=:?.\/\\^$|#\s,]/g, '\\$&');
}

// Log a message to the console with a prefix so we know it's from this script.
function logToConsole(message)
{ 
    console.log("URL Replacer/Redirector: " + message);
}

//#region Config handling
// Build the settings object for GM_config.init()
function buildConfigSettings(currentSite)
{
    // Build fields for each site
    const fields = buildSiteFields(currentSite);

    const styles = `
        /* Float the target strings fields to the left so that they can line up with their corresponding replacements */
        div[id*=${fieldTargetStrings("")}] {
            float: left;
        }

        /* We use one section sub-header on the current site to call it out. We're overriding the
            default settings from the framework (which include the ID), so !important is needed for
            most of these properties. */
        .section_desc {
            float: right !important;
            background: #00FF00 !important;
            color: black !important;
            width: fit-content !important;
            font-weight: bold !important;
            padding: 4px !important;
            margin: 0px auto !important;
            border-top: none !important;
            border-radius: 0px 0px 10px 10px !important;
        }";
    `.replaceAll("\n", ""); // This format is nicer to read but the newlines cause issues in the config framework, so remove them

    return {
        id: "URLReplacerRedirectorConfig",
        title: "URL Replacer/Redirector Config",
        fields: fields,
        css: styles,
    };
}

// Build the specific fields in the config
function buildSiteFields(currentSite)
{
    let fields = {};
    for (const site of getUserSites())
    {
        // Section headers are the site URL as the user entered them
        const sectionName = [site];
        if (currentSite === site)
        {
            sectionName.push("This site"); // If this is the matched site, add a subheader to call it out
        }

        fields[fieldPrefix(site)] = {
            section: sectionName, // Section definition just goes on the first field inside
            type: "text",
            label: "Prefix",
            labelPos: "left",
            size: 75,
            title: "This string (if set) must appear directly before the target string in the URL.",
        }
        fields[fieldSuffix(site)] = {
            type: "text",
            label: "Suffix",
            labelPos: "left",
            size: 75,
            title: "This string (if set) must appear directly after the target string in the URL.",
        }
        fields[fieldTargetStrings(site)] = {
            type: "textarea",
            label: "Targets",
            labelPos: "above",
            title: "Enter one target per line. Each target will be replaced by its corresponding replacement.",
        }
        fields[fieldReplacementStrings(site)] = {
            type: "textarea",
            label: "Replacements",
            labelPos: "above",
            title: "Enter one replacement per line. Each replacement with replace its corresponding target.",
        }
        fields[fieldClearSite(site)] = {
            type: "button",
            label: "Clear redirects for this site",
            title: "Clear all fields for this site, removing all redirection.",
            save: false, // Don't save this field, it's just a button
            click: function (siteToClear)
            {
                return () => {
                    Config.set(fieldPrefix(siteToClear), "");
                    Config.set(fieldSuffix(siteToClear), "");
                    Config.set(fieldTargetStrings(siteToClear), "");
                    Config.set(fieldReplacementStrings(siteToClear), "");
                }
            }(site), // Immediately invoke this wrapper with the current site so the inner function can capture it
        }
    }

    return fields;
}

// This is just a Promise wrapper for GM_config.init that allows us to await initialization.
async function initConfigAsync(settings)
{
    return new Promise((resolve) =>
    {
        // Have the init event (which should fire once config is done loading) resolve the promise
        settings["events"] = {init: resolve};

        Config.init(settings);
    });
}

// Get the settings for the given site.
function getSettingsForSite(site)
{
    if (!site)
    {
        console.log("No matching site found for URL");
        return null;
    }

    // Retrieve and return the settings
    return {
        prefix:             Config.get(fieldPrefix(site)),
        suffix:             Config.get(fieldSuffix(site)),
        targetStrings:      Config.get(fieldTargetStrings(site)).split("\n"),
        replacementStrings: Config.get(fieldReplacementStrings(site)).split("\n"),
    }
}

//#region Field name "constants" based on their corresponding sites
// These are also the keys used with [GM_]Config.get/set.
function fieldPrefix(site)
{
    return "Prefix_" + site;
}
function fieldSuffix(site)
{
    return "Suffix_" + site;
}
function fieldTargetStrings(site)
{
    return "TargetString_" + site;
}
function fieldReplacementStrings(site)
{
    return "ReplacementString_" + site;
}
function fieldClearSite(site)
{
    return "ClearSite_" + site;
}
//#endregion Field name "constants" based on their corresponding sites
//#endregion Config handling

// Convert settings from the old style (simple GM_setValue/GM_getValue storage, 1 config for all
//  sites) to the new style (GM_config, one set of settings per site).
async function convertOldStyleSettings(gmConfigSettings)
{
    // Check the only really required setting (for the script to do anything)
    const replaceAry = GM_getValue("replaceTheseStrings");
    if (!replaceAry)
    {
        return; // No old settings to convert, done.
    }
    logToConsole("Old-style settings found");

    // Safety check: if we ALSO have new-style settings, leave it alone.
    if (GM_getValue("URLReplacerRedirectorConfig"))
    {
        logToConsole("New-style settings already exist, not converting old settings.");
        return;
    }
    
    const replacePrefix = GM_getValue("replacePrefix");
    const replaceSuffix = GM_getValue("replaceSuffix");
    
    // Old style: 1 config for ALL sites
    // New style: 1 config PER site
    // So, the conversion is just to copy the config onto each site.
    logToConsole("Starting settings conversion...");
    for (const site of getUserSites())
    {
        // Split replaceAry into targets (keys) and replacements (values)
        let targetsAry = [];
        let replacementsAry = [];
        for (const target in replaceAry)
        {
            targetsAry.push(target);
            replacementsAry.push(replaceAry[target]);
        }

        Config.set(fieldPrefix(site), replacePrefix);
        Config.set(fieldSuffix(site), replaceSuffix);
        Config.set(fieldTargetStrings(site), targetsAry.join("\n"));
        Config.set(fieldReplacementStrings(site), replacementsAry.join("\n"));
    }

    // Save new settings
    Config.save();
    logToConsole("New-style settings saved.");
    
    // Remove the old-style settings so we don't do this again each time.
    GM_deleteValue("replaceTheseStrings");
    GM_deleteValue("replacePrefix");
    GM_deleteValue("replaceSuffix");
    logToConsole("Old-style settings removed.");
    
    logToConsole("Conversion complete.");
}