diff --git a/gulf.user.js b/gulf.user.js new file mode 100644 index 0000000..d7d4133 --- /dev/null +++ b/gulf.user.js @@ -0,0 +1,257 @@ +// ==UserScript== +// @name Fix The Gulf (UserScript Version) +// @namespace dev.namelessnanashi.FixTheGulf +// @version 0.0.1 +// @description Userscript version of Fix The Gulf +// @match https://maps.google.com/* +// @match https://www.google.com/maps/* +// @include https://maps.google.com/* +// @include https://www.google.com/maps/* +// @run-at document-idle +// @icon https://github.com/NanashiTheNameless/FixTheGulf/raw/refs/heads/main/icon.png +// @downloadURL https://github.com/NanashiTheNameless/FixTheGulf/raw/refs/heads/main/gulf.js +// @updateURL https://github.com/NanashiTheNameless/FixTheGulf/raw/refs/heads/main/gulf.js +// @copyright https://github.com/NanashiTheNameless/FixTheGulf/blob/main/LICENSE +// ==/UserScript== + +(() => { + /** + * The functions we're patching are available globally on the variable named `_`, + * but they have computer-generated names that change over time + * when the script is updated, like `_.N8a` or `_.gd`. + * + * In order to make this script slightly more resiliant against these + * name changes, we look up these function names at runtime based + * on the actual contents of the function. This relies on calling + * `toString()` on each function and seeing if it matches a + * pre-defined version. This function returns the name of a function + * matching that pre-defined version. + * + * This sounds awful, and maybe is, but the functions we're patching + * are super short, and don't depend on any other computer-generated + * function names, and therefore should be fairly resistant to changes + * over time. + * + * If the function implementations actually change, then this script + * will need to be patched - but that's a good thing, as we'd rather + * fail to patch anything than break the entire site. + * + * @param {string} stringRepresentation the `toString()` representation + * of the function to look up + * @returns the name of the function in the global `_` namespace matching + * that string representation, if any + */ + const findFunction = (stringRepresentation) => { + return Object.keys(_).find(key => _[key] && _[key].toString && _[key].toString() === stringRepresentation) + } + + /* + Look up the name of the first function to patch, + JSON-parsing related utility. This function + is used in a couple places, one of them being parsing + of JSON API requests. It's not the most direct place + to hook, but it is probably the most convenient + (meaning it is a global function that's close in + execution to the spot we want to modify, without + any other dependencies) + */ + const jsonParsingFunctionName = findFunction('function(a,b){const c=JSON.parse(a);if(Array.isArray(c))return new b(c);throw Error("U`"+a);}') + + /* + Store a copy of the original JSON parsing function + */ + const originalJsonParsingFunction = _[jsonParsingFunctionName] + + /* + Replace the JSON parsing function. This version + replaces 'Gulf of America' -> 'Gulf of Mexico' + indiscriminately in the JSON string being parsed, + and then calls out to the original function. + */ + _[jsonParsingFunctionName] = function(a, b) { + a = a.replaceAll(' (Gulf of America)', "").replaceAll('Gulf of America', 'Gulf of Mexico') + return originalJsonParsingFunction(a, b) + } + + + /* + Look up the name of the second function to patch, + a fun functional-programming utility that takes in + two parameters: + + a = an array of functions; only the first item is used + b = another function + + if we say A is the function at a[0], then + this overall function's impl is basically: + + return b(A) + + Like the first function we're hooking, this one is not + the most direct spot to hook (this one's not even) + directly text-processing-related, but it is the most convenient. + + We hook this method in order to inspect the value returned + by one of its functions. This value contains binary data + that ends up being translated into labels to place on the map. + */ + const labelProcessingFunctionName = findFunction('(a,b)=>{if(a.length!==0)return b(a[0])}') + + /* + Store a copy of the original processing function + */ + const originalLabelProcessingFunction = _[labelProcessingFunctionName] + + /* + Replace the original processing function + */ + _[labelProcessingFunctionName] = (a, b)=>{ + // We want to modify the value returned by function `a[0]`, + // so instead of passing `a` to the original function, + // we define our owh function to sit in the middle + const hookedFunction = function (...args) { + if (a.length == 0) { + return + } + + // Call the original `a[0]` function with whatever + // args were passed in to our function + const data = a[0](...args) + + // If that response contains a `labelGroupBytes` + // UInt8Array field, then call out to + // `patchLabelBytesIfNeeded` to do the heavy lifting + // of replacing references within it + if (data.labelGroupBytes && data.labelGroupBytes instanceof Uint8Array) { + patchLabelBytesIfNeeded(data.labelGroupBytes) + } + + // Return the data, patched or not + return data + } + + // Call the original function, injecting our + // own function as one of the parameters + originalLabelProcessingFunction([hookedFunction], b) + } + + /** + * Looks for "Gulf of America" in the given byte array and patches any occurrences + * in-place to say "Gulf of Mexico" (with a trailing null byte, to make the strings + * the same size). + * + * These byte arrays can contain unexpected characters at word/line breaks — + * e.g., `Gulf of ߘ\x01\n\x0F\n\x07America`. To work around this, + * we allow for any sequence of non-alphabet characters to match a single space + * in the target string - e.g., ` ` matches `ߘ\x01\n\x0F\n\x07`. + * + * @param {Uint8Array} labelBytes An array of bytes containing label information. + */ + const patchLabelBytesIfNeeded = (labelBytes) => { + // Define the bytes we want to search for + const SEARCH_PATTERN_BYTES = [...'Gulf of America'].map(char => char.charCodeAt(0)) + + // Constants for special cases + const CHAR_CODE_SPACE = " ".charCodeAt(0) + const CHAR_CODE_CAPITAL_A = "A".charCodeAt(0) + const CHAR_CODE_PARENTH = '('.charCodeAt(0) + const CHAR_CODE_CAPITAL_G = 'G'.charCodeAt(0) + // \u200B is a zero-width space character. We add it to make the strings the same length + const REPLACEMENT_BYTES = [..."Mexico\u200B"].map(char => char.charCodeAt(0)) + + // For every possible starting character in our `labelBytes` blob... + for(let labelByteStartingIndex = 0; labelByteStartingIndex < labelBytes.length; labelByteStartingIndex++) { + + // Start by assuming this is a match, until proven otherwise + let foundMatch = true + + // Because one search byte can match multiple target bytes + // (see this function's documentation), + // we keep track of our target byte index independently of + // our search byte index + let labelByteOffset = 0 + + // Start iterating through our search pattern and see if we have a match + for(let searchPatternIndex = 0; searchPatternIndex < SEARCH_PATTERN_BYTES.length; searchPatternIndex++) { + + // We've run out of bytes to check; not a complete match + if (labelByteStartingIndex + labelByteOffset >= labelBytes.length) { + foundMatch = false + break + } + + // Get the bytes we're comparing from the target & search string. + const labelByte = labelBytes[labelByteStartingIndex + labelByteOffset] + const searchByte = SEARCH_PATTERN_BYTES[searchPatternIndex] + + // Special case: if the searchByte is a space, then + // we want to match potentially many characters + if(searchByte == CHAR_CODE_SPACE && !isAlphaChar(labelByte)) { + // Advance at least one character forward in the target bytes, + // and keep repeating as long as the next character is also a non-alphabet character. + do { + labelByteOffset++ + } while(!isAlphaChar(labelBytes[labelByteStartingIndex + labelByteOffset])) + + // We've consumed all the non-alphabet characters we can; + // move on to checking the next character + continue + } + + // Normal case: if the bytes are equal, we can move forward + // and check the next one + if(labelByte == searchByte) { + labelByteOffset++ + continue + } + + // If we've made it this far, the current characters didn't match + foundMatch = false + break + } + + if (foundMatch) { + // We found a match! Find the offset of the letter "A" within the match + // (we can't just add a fixed value because we don't know how long the + // match even is, thanks to variable space matching) + const americaStartIndex = labelBytes.indexOf(CHAR_CODE_CAPITAL_A, labelByteStartingIndex) + let parenthStartIndex = -1; + // Check if the label is `Gulf of Mexico (Gulf of America)` + for (let i = 0; i < labelBytes.length; i++) { + if (labelBytes[i] == CHAR_CODE_PARENTH && labelBytes[i + 1] == CHAR_CODE_CAPITAL_G) { + parenthStartIndex = i + break + } + } + if (parenthStartIndex > -1) { + // Replace "(Gulf of" with zero-width spaces + for (let i = 0; i < 8; i++) { + labelBytes[parenthStartIndex + i] = '\u200B'.charCodeAt(0) + } + // Replace "America)" with zero-width spaces + for (let i = 0; i < 8; i++) { + labelBytes[americaStartIndex + i] = '\u200B'.charCodeAt(0) + } + } else { + // Replace "America" with "Mexico\u200B" + for (let i = 0; i < REPLACEMENT_BYTES.length; i++) { + labelBytes[americaStartIndex + i] = REPLACEMENT_BYTES[i] + } + } + } + + } + } + + /** + * Returns whether an ascii character code represents an + * alphabet character (A-Z or a-z). + * + * @param {int} code Ascii code of the character to check + * @returns `true` if ascii code represents an alphabet character + */ + const isAlphaChar = (code) => { + return (code > 64 && code < 91) || (code > 96 && code < 123) + } + +})()