Translation Helper

Make translating stuff via i18n easier for the user

// ==UserScript==
// @name         Translation Helper
// @namespace    https://github.com/NBKelly/jinteki-translation-helper
// @version      2025-10-15
// @description  Make translating stuff via i18n easier for the user
// @author       nbkelly
// @match        *://localhost/*
// @match        *://127.0.0.1/*
// @match        *://*.jinteki.net/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=jinteki.net
// @license      Apache 2.0
// @run-at       document-end
// @grant        none
// ==/UserScript==

(function() {
    'use strict';
    const selector = "[data-i18n-key]:not([data-i18n-success])";
    const HIGHLIGHT_COLOR = "magenta";
    const LABEL_BG = "rgba(255, 0, 255, 0.15)";
    const highlighted = new WeakSet();

    function highlightElements(root = document) {
        const elements = root.querySelectorAll(selector);
        for (const el of elements) {
            if (highlighted.has(el)) continue;
            highlighted.add(el);

            const key = el.getAttribute("data-i18n-key");

            // highlight
            el.style.outline = `2px solid ${HIGHLIGHT_COLOR}`;
            el.style.outlineOffset = "2px";
        }
    }

    // Initial run
    const init = () => highlightElements();
    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
    } else {
        init();
    }

    // React to DOM changes
    const observer = new MutationObserver(mutations => {
        for (const m of mutations) {
            if (m.type === "childList") {
                for (const node of m.addedNodes) {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        highlightElements(node);
                    }
                }
            } else if (m.type === "attributes" &&
                       (m.attributeName === "data-i18n-key" ||
                        m.attributeName === "data-i18n-success")) {
                if (m.target.matches("[data-i18n-key]:not([data-i18n-success])")) {
                    highlightElements(m.target);
                } else {
                    if (highlighted.has(m.target)) {
                        m.target.style.outline = "";
                        highlighted.delete(m.target);
                    }
                }
            }
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ["data-i18n-key", "data-i18n-success"],
    });

    function getI18nData(el) {
        const key = el.getAttribute("data-i18n-key");
        const variables = {};

        // Collect all data-i18n-param-* attributes
        for (const attr of el.attributes) {
            if (attr.name.startsWith("data-i18n-param-")) {
                const varName = attr.name.slice("data-i18n-param-".length);
                variables[varName] = attr.value;
            }
        }

        return { key, variables };
    }

    // note: I actually have no clue what the hell this does, but it appears to work.
    function encodePlaygroundState(obj) {
        const jsonStr = JSON.stringify(obj);
        const utf8Bytes = new TextEncoder().encode(jsonStr);
        let binary = "";
        utf8Bytes.forEach(b => binary += String.fromCharCode(b));
        let b64 = btoa(binary);
        b64 = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
        return b64;
    }

    let enMessages = {};

    document.addEventListener("click", e => {
        if (!e.altKey) return; // only on Alt+Click
        const el = e.target.closest(selector);
        if (!el) return;

        e.preventDefault();
        e.stopPropagation();

        const {key, variables } = getI18nData(el);

        // Look up English message
        let englishComment = "";
        if (enMessages[key]) {
            let cmt = enMessages[key].split("\n").map(line => `# ${line}`).join("\n");
            englishComment = `# English reference: ${cmt}\n`;
        }

        const state = {
            messages: `${englishComment}${key} = `,
            variables,
            setup: {
                visible: ["messages", "output", "config"],
                locale: "en-US",
                dir: "ltr"
            }
        };

        const encoded = encodePlaygroundState(state);
        const url = `https://projectfluent.org/play/?s=${encoded}`;
        window.open(url, "_blank");
    });

    function parseFTL(ftlText) {
        const messages = {};
        const lines = ftlText.split("\n");

        let currentKey = null;
        let currentValue = [];

        for (let line of lines) {
            const trimmed = line.trimEnd();

            // Skip empty lines
            if (!trimmed) continue;

            // Skip full-line comments
            if (/^\s*#/.test(trimmed)) continue;

            // Top-level key match
            const topLevelMatch = trimmed.match(/^([^\s=]+)\s*=\s*(.*)$/);
            if (topLevelMatch) {
                // Save previous message
                if (currentKey) {
                    messages[currentKey] = currentValue.join("\n");
                }

                currentKey = topLevelMatch[1];
                currentValue = [topLevelMatch[2]];
            } else if (currentKey) {
                // Continuation line (indented)
                currentValue.push(trimmed);
            }
        }

        // Save last message
        if (currentKey) {
            messages[currentKey] = currentValue.join("\n");
        }

        return messages;
    }

    async function loadEnglishFTL() {
        try {
            const host = window.location.hostname;
            let ftlUrl;
            if (host === "localhost" || host === "127.0.0.1") {
                ftlUrl = "http://localhost:1042/i18n/en.ftl";
            } else {
                ftlUrl = "https://jinteki.net/i18n/en.ftl";
            }
            console.log("Fetching English FTL...");
            const resp = await fetch(ftlUrl);
            const text = await resp.text();
            enMessages = parseFTL(text);
        } catch (err) {
            console.error("Failed to fetch English FTL", err);
        }
    }

    loadEnglishFTL();
}) ();