MyHeritage: Flag injector with country detection

Add country flags to each node of your MyHeritage family tree using birthplaces. With caching and AJAX throttling.

// ==UserScript==
// @name         MyHeritage: Flag injector with country detection
// @namespace    http://tampermonkey.net/
// @version      1.0.4
// @description  Add country flags to each node of your MyHeritage family tree using birthplaces. With caching and AJAX throttling.
// @author       ciricuervo
// @match        https://www.myheritage.com/*
// @match        https://www.myheritage.es/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=myheritage.com
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    const svgNS = 'http://www.w3.org/2000/svg';
    const xlinkNS = 'http://www.w3.org/1999/xlink';
    const clientDate = new Date().toISOString().slice(0, 10);

    const csrf_token = window.mhXsrfToken;
    const siteId = window.currentSiteId;
    const lang = window.languageCode;

    const countryCache = new Map();
    const queue = [];
    let processing = false;
    let queueUpdated = false;

    const countryMap = new Map([
        ['alemania', 'de'],
        ['germany', 'de'],
        ['argentina', 'ar'],
        ['australia', 'au'],
        ['austria', 'at'],
        ['bolivia', 'bo'],
        ['brasil', 'br'],
        ['brazil', 'br'],
        ['bulgaria', 'bg'],
        ['bélgica', 'be'],
        ['belgium', 'be'],
        ['checoslovaquia', 'cz'], // dissolved
        ['czechoslovakia', 'cz'], // dissolved
        ['chile', 'cl'],
        ['china', 'cn'],
        ['croacia', 'hr'],
        ['croatia', 'hr'],
        ['dinamarca', 'dk'],
        ['denmark', 'dk'],
        ['ecuador', 'ec'],
        ['escocia', 'gb-sct'],
        ['scotland', 'gb-sct'],
        ['españa', 'es'],
        ['spain', 'es'],
        ['estados unidos', 'us'],
        ['united states', 'us'],
        ['eeuu', 'us'],
        ['usa', 'us'],
        ['francia', 'fr'],
        ['france', 'fr'],
        ['guatemala', 'gt'],
        ['hungría', 'hu'],
        ['hungary', 'hu'],
        ['inglaterra', 'gb-eng'],
        ['england', 'gb-eng'],
        ['irlanda', 'ie'],
        ['ireland', 'ie'],
        ['irlanda del norte', 'gb-nir'],
        ['northern ireland', 'gb-nir'],
        ['italia', 'it'],
        ['italy', 'it'],
        ['jamaica', 'jm'],
        ['japón', 'jp'],
        ['japan', 'jp'],
        ['líbano', 'lb'],
        ['lebanon', 'lb'],
        ['marruecos', 'ma'],
        ['morocco', 'ma'],
        ['nicaragua', 'ni'],
        ['paraguay', 'py'],
        ['países bajos', 'nl'],
        ['netherlands', 'nl'],
        ['perú', 'pe'],
        ['peru', 'pe'],
        ['polonia', 'pl'],
        ['poland', 'pl'],
        ['portugal', 'pt'],
        ['reino unido', 'gb'],
        ['united kingdom', 'gb'],
        ['ru', 'gb'],
        ['uk', 'gb'],
        ['república checa', 'cz'],
        ['czech republic', 'cz'],
        ['chequia', 'cz'],
        ['czechia', 'cz'],
        ['república dominicana', 'do'],
        ['dominican republic', 'do'],
        ['rusia', 'ru'],
        ['russia', 'ru'],
        ['siria', 'sy'],
        ['syria', 'sy'],
        ['sudáfrica', 'za'],
        ['south africa', 'za'],
        ['suiza', 'ch'],
        ['switzerland', 'ch'],
        ['uruguay', 'uy'],
        ['yugoslavia', 'hr'], // dissolved
    ]);

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function extractCountryFromPlace(place) {
        if (!place) return null;
        const parts = place.toLowerCase().split(',').map(p => p.trim());
        if (!parts.length) return null;
        if (parts.length > 1) {
            // Some countries belong to a major region, like: 'England, United Kingdom', in that case return 'england'
            const region = parts[parts.length - 1];
            const country = parts[parts.length - 2];
            if (region === 'reino unido' || region === 'united kingdom' || region === 'ru' || region === 'uk') return country;
        }
        return parts[parts.length - 1];
    }

    function countryToCode(country) {
        if (!country) return null;
        return countryMap.get(country) || null;
    }

    async function fetchCountryCode(individualID) {
        const url = `/FP/API/FamilyTree/get-extended-card-content.php?allEventsForIndividual=0&clientDate=${clientDate}&dataLang=&discoveries=0&dna=0&facts=0&individualID=${individualID}&lang=${lang}&matches=0&photos=0&relatives=0&s=${siteId}&sites=0&csrf_token=${csrf_token}`;

        try {
            const res = await fetch(url);
            const json = await res.json();
            const birthPlace = json.birthPlace;
            const country = extractCountryFromPlace(birthPlace);
            const code = countryToCode(country);
            return code;
        } catch (e) {
            console.warn('Error fetching country for ID:', individualID, e);
            return null;
        }
    }

    function processCachedNodes() {
        for (let i = queue.length - 1; i >= 0; i--) {
            const { node, individualID } = queue[i];
            const countryCode = countryCache.get(individualID);
            if (countryCode) {
                injectFlag(node, countryCode);
                queue.splice(i, 1);
            }
        }
    }

    async function processQueue() {
        queueUpdated = true;
        if (processing) return;
        processing = true;

        while (queue.length > 0) {
            if (queueUpdated) {
                // Process cached nodes first (inject without delay)
                processCachedNodes();
                queueUpdated = false;
                continue;
            }

            // Fetch the uncached (new nodes to process) and null-cached (that may have changed)
            const { node, individualID } = queue.shift();
            let countryCode = await fetchCountryCode(individualID);
            countryCache.set(individualID, countryCode); // can be null
            if (countryCode) injectFlag(node, countryCode);
            await sleep(300); // throttling

            // If by the time it wakes up (after the throttling/delay) there was a re-queue,
            // the `queueUpdated` flag will take care of processing the cached nodes first.
        }

        processing = false;
    }

    function injectFlag(outerNode, countryCode) {
        if (outerNode.querySelector('.country-flag')) return;

        let onTopNode = outerNode.querySelector('g[data-type="_svgOnTopGroup"]');
        if (!onTopNode) {
            onTopNode = document.createElementNS(svgNS, 'g');
            onTopNode.setAttribute('data-type', '_svgOnTopGroup');
            outerNode.appendChild(onTopNode);
        }

        const img = document.createElementNS(svgNS, 'image');
        img.setAttribute('filter', 'url(#outline)'); // the filter that adds the border to the country flag
        img.setAttribute('x', '0');
        img.setAttribute('y', '0');
        img.setAttribute('width', '28');
        img.setAttribute('height', '28');
        img.setAttribute('preserveAspectRatio', 'xMidYMid meet');
        img.setAttributeNS(xlinkNS, 'xlink:href', `https://flagcdn.com/w40/${countryCode}.png`);
        img.classList.add('country-flag');

        // If outer node has a width less than 200, we assume it is using vertical cards display
        //  - Horizontal cards display: (180, 86.5)
        //  - Vertical cards display: (100, -13.5)
        const outerNodeWidth = outerNode.getBBox().width;
        const flagNodeX = outerNodeWidth < 200 ? 100.0 : 180.0;
        const flagNodeY = outerNodeWidth < 200 ? -13.5 : 86.5;

        const flagNode = document.createElementNS(svgNS, 'g');
        flagNode.setAttribute('data-type', 'onTopGroup');
        flagNode.setAttribute('transform', `matrix(1.0, 0.0, 0.0, 1.0, ${flagNodeX}, ${flagNodeY})`);
        flagNode.appendChild(img);

        onTopNode.appendChild(flagNode);
    }

    function addAllFlags() {
        const outerNodes = document.querySelectorAll('g.cardWrapper[id^="card"]');

        outerNodes.forEach(node => {
            const match = node.id.match(/^card(\d+)/);
            if (!match) return;

            const individualID = match[1];
            queue.push({ node: node.closest('g[data-type="_svgOuterGroup"]'), individualID });
        });

        processQueue();
    }

    // This filter will be added only once to the SVG defs section
    // It is used to add a border to the country flags
    function appendOutlineFilter() {
        const svgDefs = document.querySelector('#NewTreeVector > svg > defs');
        if (svgDefs) {
            const parser = new DOMParser();
            const filterString = `
    <filter id="outline">
      <feMorphology in="SourceAlpha" result="expanded" operator="dilate" radius="1"/>
      <feFlood flood-color="lightgray"/>
      <feComposite in2="expanded" operator="in"/>
      <feComposite in="SourceGraphic"/>
    </filter>
  `;
            const filterElement = parser.parseFromString(`<svg xmlns="http://www.w3.org/2000/svg">${filterString}</svg>`, 'image/svg+xml').querySelector('filter');
            svgDefs.appendChild(filterElement);
        }
    }

    // Wait for the SVG node to become available, with retries
    async function waitForTreeNode(maxRetries = 8, delayMs = 400) {
        for (let attempt = 1; attempt <= maxRetries; attempt++) {
            const treeNode = document.querySelector('#NewTreeVector > svg > g');
            if (treeNode) return treeNode;
            await sleep(delayMs);
        }
        return null;
    }

    // Each time the observer detects a change it will re-queue the visible nodes
    const observer = new MutationObserver(() => {
        queue.length = 0;
        addAllFlags();
    });

    // Start of the script
    (async function init() {
        const treeNode = await waitForTreeNode();
        if (!treeNode) {
            console.warn('MyHeritage flag injector: SVG tree not found after retries.');
            return;
        }

        appendOutlineFilter();
        observer.observe(treeNode, { childList: true, subtree: false });
        addAllFlags();
    })();
})();