From 03d9f3884b5d2be4ce28888cb5d55bf6e26808c8 Mon Sep 17 00:00:00 2001
From: Marco Bonetti <marco@cruncher.ch>
Date: Mon, 7 Oct 2024 13:55:31 +0200
Subject: [PATCH] Squashed commit of the following:

commit a4068c66974d3ccc13e3a08a1c8bd7613f079e71
Author: Marco Bonetti <marco@cruncher.ch>
Date:   Mon Oct 7 13:54:41 2024 +0200

    Changelog

commit ca7d41224cce4cdeca82ee759197d4cdae0bcb00
Merge: 6139629 6304a7a
Author: Marco Bonetti <marco@cruncher.ch>
Date:   Mon Oct 7 12:06:29 2024 +0200

    Merge branch 'javascript-rewrite' of github.com:balazs-endresz/django-rosetta into balazs-endresz-javascript-rewrite

commit 6304a7a82a9e9230e5904e355afb6b39549f4efe
Author: Balazs Endresz <balazs.endresz@gmail.com>
Date:   Thu Oct 3 20:17:47 2024 +0200

    Fix js errors on file list page

commit 3fdca8762728bc1e14c090a26a14de97cbfdac40
Author: Balazs Endresz <balazs.endresz@gmail.com>
Date:   Thu Oct 3 20:01:33 2024 +0200

    Warn about unmatched variables when using curly braces with modifiers

commit cf84ac55bc522ea4a1d72cd607a9272675069ee8
Author: Balazs Endresz <balazs.endresz@gmail.com>
Date:   Thu Oct 3 19:51:52 2024 +0200

    Autofit textareas on window resize too

commit 71c9f34d92809663ea45f4dbe850da088bb93ff8
Author: Balazs Endresz <balazs.endresz@gmail.com>
Date:   Thu Oct 3 19:51:04 2024 +0200

    Move some code to separate functions for readability

commit b63db388fbfcbe9385ad3fda6b40a6648ce88dd2
Author: Balazs Endresz <balazs.endresz@gmail.com>
Date:   Thu Oct 3 19:46:56 2024 +0200

    Don't focus first textarea on page load

commit 5c57d5a69c25a9edd6a6641b08562c0a4a86852b
Author: Balazs Endresz <balazs.endresz@gmail.com>
Date:   Sat Sep 28 12:07:25 2024 +0200

    Fix error when reflang is disabled, use optional chaining

commit 9c83ddd299a10f581b29243af0528b285a48afc5
Author: Balazs Endresz <balazs.endresz@gmail.com>
Date:   Fri Sep 27 17:17:34 2024 +0200

    Rewrite rosetta.js
    * drop jQuery
    * fix various js bugs
    * add some new improvements
---
 .eslintrc.js                                 |   2 +-
 CHANGES                                      |   2 +-
 rosetta/static/admin/rosetta/css/rosetta.css |   6 +-
 rosetta/static/admin/rosetta/js/rosetta.js   | 319 ++++++++++++-------
 rosetta/templates/rosetta/base.html          |   3 +-
 rosetta/templates/rosetta/form.html          |   7 +-
 6 files changed, 212 insertions(+), 127 deletions(-)

diff --git a/.eslintrc.js b/.eslintrc.js
index 76eb3ac..1750559 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -4,7 +4,7 @@ module.exports = {
         browser: true,
         node: true,
     },
-    parserOptions: { ecmaVersion: 9 },
+    parserOptions: { ecmaVersion: 2020 },
     globals: {
         $: "readonly",
     },
diff --git a/CHANGES b/CHANGES
index e7a3c89..6694273 100644
--- a/CHANGES
+++ b/CHANGES
@@ -6,7 +6,7 @@ Version 0.10.2 (unreleased)
 * Tests: update flake8 in tox tests
 * Format all rendered assets (html, css, js) in a pre-commit task. (PR #294, thanks @balazs-endresz)
 * Fix Deepl translations containing variables (#276, PR #290, thanks @halitcelik)
-
+* Rewrite rosetta.js: drop jQuery and modernize rosetta.js (PR #295, thanks @balazs-endresz)
 
 
 Version 0.10.1
diff --git a/rosetta/static/admin/rosetta/css/rosetta.css b/rosetta/static/admin/rosetta/css/rosetta.css
index f8e2758..8c0ada9 100644
--- a/rosetta/static/admin/rosetta/css/rosetta.css
+++ b/rosetta/static/admin/rosetta/css/rosetta.css
@@ -28,7 +28,6 @@ td .context {
 }
 td.translation textarea {
     width: 98.5%;
-    min-height: 25px;
     margin: 2px 0;
 }
 .rtl td.translation textarea {
@@ -100,7 +99,6 @@ tr.row1 td.original code {
 .alert {
     font-weight: bold;
     padding: 4px 5px 4px 25px;
-    margin-left: 1em;
     color: red;
     background: transparent
         url(data:image/svg+xml,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%201792%201792%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%3Cpath%20fill%3D%22%23efb80b%22%20d%3D%22M1024%201375v-190q0-14-9.5-23.5t-22.5-9.5h-192q-13%200-22.5%209.5t-9.5%2023.5v190q0%2014%209.5%2023.5t22.5%209.5h192q13%200%2022.5-9.5t9.5-23.5zm-2-374l18-459q0-12-10-19-13-11-24-11h-220q-11%200-24%2011-10%207-10%2021l17%20457q0%2010%2010%2016.5t24%206.5h185q14%200%2023.5-6.5t10.5-16.5zm-14-934l768%201408q35%2063-2%20126-17%2029-46.5%2046t-63.5%2017h-1536q-34%200-63.5-17t-46.5-46q-37-63-2-126l768-1408q17-31%2047-49t65-18%2065%2018%2047%2049z%22%2F%3E%0A%3C%2Fsvg%3E%0A)
@@ -158,3 +156,7 @@ div.module {
 #action-toggle {
     display: inline;
 }
+a.suggest {
+    display: block;
+    margin-bottom: 5px;
+}
diff --git a/rosetta/static/admin/rosetta/js/rosetta.js b/rosetta/static/admin/rosetta/js/rosetta.js
index 784a9df..9c4dd1b 100644
--- a/rosetta/static/admin/rosetta/js/rosetta.js
+++ b/rosetta/static/admin/rosetta/js/rosetta.js
@@ -1,70 +1,110 @@
+"use strict";
+
 const rosetta_settings = JSON.parse(document.getElementById("rosetta-settings-js").textContent);
 
-$(document).ready(function () {
-    $(".location a")
-        .show()
-        .toggle(
-            function () {
-                $(".hide", $(this).parent()).show();
-            },
-            function () {
-                $(".hide", $(this).parent()).hide();
-            },
-        );
+document.addEventListener("DOMContentLoaded", () => {
+    // Get original html that corresponds to a given textarea containing the translation
+    function originalForTextarea(textarea) {
+        const textareasInCell = textarea.closest("td").querySelectorAll("textarea");
+        const nth = Array.from(textareasInCell).indexOf(textarea) + 1;
+        return textarea
+            .closest("tr")
+            .querySelector(".original")
+            .querySelector(`.message, .part:nth-of-type(${nth})`).innerHTML;
+    }
 
+    // Common code for handling translation suggestions
+    function suggest(translate) {
+        document.querySelectorAll("a.suggest").forEach((a) => {
+            a.addEventListener("click", (event) => {
+                event.preventDefault();
+                const textarea = a.previousElementSibling;
+                const orig = originalForTextarea(textarea);
+                a.classList.add("suggesting");
+                a.textContent = "...";
+                translate(
+                    orig,
+                    (translation) => {
+                        textarea.value = translation;
+                        textarea.dispatchEvent(new Event("input"));
+                        textarea.dispatchEvent(new Event("change"));
+                        textarea.dispatchEvent(new Event("blur"));
+                        a.style.visibility = "hidden";
+                    },
+                    (error) => {
+                        console.error("Rosetta translation suggestion error:", error);
+                        let errorMsg;
+                        if (error?.message) {
+                            errorMsg = error.message;
+                        } else if (error?.error) {
+                            errorMsg = error.error;
+                        } else if (typeof error === "object") {
+                            errorMsg = JSON.stringify(error);
+                        } else {
+                            errorMsg = error || "Error loading translation";
+                        }
+                        a.textContent = String(errorMsg).trim().substring(0, 100);
+                        alignPlurals();
+                    },
+                );
+            });
+        });
+    }
+
+    function jsonp(url, params, callback) {
+        var callbackName = "rosetta_jsonp_callback_" + Math.random().toString(36).substr(2, 8);
+        window[callbackName] = function (response) {
+            callback(response);
+            delete window[callbackName];
+        };
+        params.callback = callbackName;
+        var script = document.createElement("script");
+        script.src = `${url}?${new URLSearchParams(params).toString()}`;
+        document.body.appendChild(script);
+        script.onerror = function () {
+            callback("Failed to load translation with jsonp request");
+            delete window[callbackName];
+        };
+    }
+
+    // Translation suggestions
     if (rosetta_settings.ENABLE_TRANSLATION_SUGGESTIONS) {
         if (rosetta_settings.server_auth_key) {
-            $("a.suggest").click(function (e) {
-                e.preventDefault();
-                var a = $(this);
-                var orig = $(".original .message", a.parents("tr")).html();
-                var trans = $("textarea", a.parent());
-                var sourceLang = rosetta_settings.MESSAGES_SOURCE_LANGUAGE_CODE;
-                var destLang = rosetta_settings.rosetta_i18n_lang_code_normalized;
-
-                orig = unescape(orig)
+            suggest((orig, setTranslation, setError) => {
+                const origUnescaped = unescape(orig)
                     .replace(/<br\s?\/?>/g, "\n")
                     .replace(/<code>/g, "")
                     .replace(/<\/code>/g, "")
                     .replace(/&gt;/g, ">")
                     .replace(/&lt;/g, "<");
-                a.attr("class", "suggesting").html("...");
-
-                $.getJSON(
-                    rosetta_settings.translate_text_url,
-                    {
-                        from: sourceLang,
-                        to: destLang,
-                        text: orig,
-                    },
-                    function (data) {
+                const params = new URLSearchParams({
+                    from: rosetta_settings.MESSAGES_SOURCE_LANGUAGE_CODE,
+                    to: rosetta_settings.rosetta_i18n_lang_code_normalized,
+                    text: origUnescaped,
+                });
+                const url = `${rosetta_settings.translate_text_url}?${params.toString()}`;
+                fetch(url)
+                    .then((r) => r.json())
+                    .then((data) => {
                         if (data.success) {
-                            trans.val(
+                            setTranslation(
                                 unescape(data.translation)
                                     .replace(/&#39;/g, "'")
                                     .replace(/&quot;/g, '"')
                                     .replace(/%\s+(\([^)]+\))\s*s/g, " %$1s "),
                             );
-                            a.hide();
                         } else {
-                            a.text(data.error);
+                            setError(data);
                         }
-                    },
-                );
+                    })
+                    .catch(setError);
             });
         } else if (rosetta_settings.YANDEX_TRANSLATE_KEY) {
-            $("a.suggest").click(function (e) {
-                e.preventDefault();
-                var a = $(this);
-                var orig = $(".original .message", a.parents("tr")).html();
-                var trans = $("textarea", a.parent());
-                var apiUrl = "https://translate.yandex.net/api/v1.5/tr.json/translate";
-                var destLangRoot = rosetta_settings.rosetta_i18n_lang_code.split("-")[0];
-                var lang = rosetta_settings.MESSAGES_SOURCE_LANGUAGE_CODE + "-" + destLangRoot;
-
-                a.attr("class", "suggesting").html("...");
-
-                var apiData = {
+            suggest((orig, setTranslation, setError) => {
+                const apiUrl = "https://translate.yandex.net/api/v1.5/tr.json/translate";
+                const destLangRoot = rosetta_settings.rosetta_i18n_lang_code.split("-")[0];
+                const lang = rosetta_settings.MESSAGES_SOURCE_LANGUAGE_CODE + "-" + destLangRoot;
+                const apiData = {
                     error: "onTranslationError",
                     success: "onTranslationComplete",
                     lang: lang,
@@ -72,86 +112,127 @@ $(document).ready(function () {
                     format: "html",
                     text: orig,
                 };
-
-                $.ajax({
-                    url: apiUrl,
-                    data: apiData,
-                    dataType: "jsonp",
-                    success: function (response) {
-                        if (response.code == 200) {
-                            trans.val(
-                                response.text[0]
-                                    .replace(/<br>/g, "\n")
-                                    .replace(/<\/?code>/g, "")
-                                    .replace(/&lt;/g, "<")
-                                    .replace(/&gt;/g, ">"),
-                            );
-                            a.hide();
-                        } else {
-                            a.text(response);
-                        }
-                    },
-                    error: function (response) {
-                        a.text(response);
-                    },
+                jsonp(apiUrl, apiData, (response) => {
+                    if (response.code === 200) {
+                        setTranslation(
+                            response.text[0]
+                                .replace(/< ?br>/g, "\n")
+                                .replace(/< ?\/? ?code>/g, "")
+                                .replace(/&lt;/g, "<")
+                                .replace(/&gt;/g, ">"),
+                        );
+                    } else {
+                        setError(response);
+                    }
                 });
             });
         }
     }
 
-    $("td.plural").each(function () {
-        var td = $(this);
-        var trY = parseInt(td.closest("tr").offset().top);
-        $("textarea", $(this).closest("tr")).each(function (j) {
-            var textareaY = parseInt($(this).offset().top) - trY;
-            $($(".part", td).get(j)).css("top", textareaY + "px");
+    // Make textarea height adapt to the contents
+    function autofitTextarea(textarea) {
+        textarea.style.height = "auto";
+        textarea.style.height = textarea.scrollHeight + "px";
+    }
+
+    // If there are multiple textareas for plurals then align the originals vertically with the textareas
+    function alignPlurals() {
+        document.querySelectorAll(".results td.plural").forEach((td) => {
+            const tr = td.closest("tr");
+            const trY = tr.getBoundingClientRect().top + window.scrollY;
+            tr.querySelectorAll("textarea").forEach((textarea, i) => {
+                const part = td.querySelectorAll(".part")[i];
+                if (part) {
+                    const textareaY = textarea.getBoundingClientRect().top + window.scrollY - trY;
+                    part.style.top = textareaY + "px";
+                }
+            });
         });
+    }
+
+    // Show warning if the variables in the original and the translation don't match
+    function validateTranslation(textarea) {
+        const orig = originalForTextarea(textarea);
+        const variablePattern = /%(?:\([^\s)]*\))?[sdf]|\{[^\s}]*\}/g;
+        const origVars = orig.match(variablePattern) || [];
+        const transVars = textarea.value.match(variablePattern) || [];
+        const everyOrigVarUsed = origVars.every((origVar) => transVars.includes(origVar));
+        const onlyValidVarsUsed = transVars.every((transVar) => origVars.includes(transVar));
+        const valid = everyOrigVarUsed && onlyValidVarsUsed;
+        textarea.previousElementSibling.classList.toggle("hidden", valid);
+    }
+
+    // Select all the textareas that are used for translations
+    const textareas = document.querySelectorAll(".translation textarea");
+
+    // For each translation field textarea
+    textareas.forEach((textarea) => {
+        // On page load make textarea height adapt to its contents
+        autofitTextarea(textarea);
+
+        // On input
+        textarea.addEventListener("input", () => {
+            // Make textarea height adapt to its contents
+            autofitTextarea(textarea);
+
+            // If there are multiple textareas for plurals then align the originals vertically with the textareas
+            alignPlurals();
+
+            // Once users start editing the translation untick the fuzzy checkbox automatically
+            textarea.closest("tr").querySelector('td.c input[type="checkbox"]').checked = false;
+        });
+
+        // On blur show warnings for unmatched variables in translations
+        textarea.addEventListener("blur", () => validateTranslation(textarea));
     });
 
-    $(".translation textarea")
-        .blur(function () {
-            if ($(this).val()) {
-                $(".alert", $(this).parents("tr")).remove();
-                var RX = /%(?:\([^\s)]*\))?[sdf]|\{[\w\d_]+?\}/g;
-                var origs = $(this).parents("tr").find(".original span").html().match(RX);
-                var trads = $(this).val().match(RX);
-                var error = $('<span class="alert">Unmatched variables</span>');
-
-                if (origs && trads) {
-                    for (var i = trads.length; i--; ) {
-                        var key = trads[i];
-                        if (-1 == $.inArray(key, origs)) {
-                            $(this).before(error);
-                            return false;
-                        }
-                    }
-                    return true;
-                } else {
-                    if (!(origs === null && trads === null)) {
-                        $(this).before(error);
-                        return false;
-                    }
-                }
-                return true;
-            }
-        })
-        .keyup(function () {
-            var cb = $(this).parents("tr").find('td.c input[type="checkbox"]');
-            if (cb.is(":checked")) {
-                cb[0].checked = false;
-                cb.removeAttr("checked");
-            }
-        })
-        .eq(0)
-        .focus();
-
-    $("#action-toggle").change(function () {
-        $('tbody td.c input[type="checkbox"]').each(function (i, e) {
-            if ($("#action-toggle").is(":checked")) {
-                $(e).attr("checked", "checked");
-            } else {
-                $(e).removeAttr("checked");
-            }
+    // On window resize make textarea height adapt to their contents
+    window.addEventListener("resize", () => textareas.forEach(autofitTextarea), { passive: true });
+
+    // On page load if there are multiple textareas in a cell for plurals then align the originals vertically with them
+    alignPlurals();
+
+    // Reload page when changing ref-language
+    document.getElementById("ref-language-selector")?.addEventListener("change", function () {
+        window.location.href = this.value;
+    });
+
+    // Toggle fuzzy state for all entries on the current page
+    document.getElementById("action-toggle")?.addEventListener("change", function () {
+        const checkboxes = document.querySelectorAll('tbody td.c input[type="checkbox"]');
+        checkboxes.forEach((checkbox) => (checkbox.checked = this.checked));
+    });
+
+    // Toggle additional locations that are initially hidden
+    document.querySelectorAll(".location a").forEach((link) => {
+        link.addEventListener("click", (event) => {
+            event.preventDefault();
+            const prevText = link.innerText;
+            link.innerText = link.dataset.prevText;
+            link.dataset.prevText = prevText;
+            link.parentElement.querySelectorAll(".hide").forEach((loc) => {
+                const hidden = loc.style.display === "none" || loc.style.display === "";
+                loc.style.display = hidden ? "block" : "none";
+            });
         });
     });
+
+    // Warn about any unsaved changes before navigating away from the page
+    const form = document.querySelector("form.results");
+    function formToJsonString() {
+        const obj = {};
+        new FormData(form).forEach((value, key) => (obj[key] = value));
+        return JSON.stringify(obj);
+    }
+    if (form) {
+        const initialDataJson = formToJsonString();
+        let isSubmitting = false;
+        form.addEventListener("submit", () => (isSubmitting = true));
+        window.addEventListener("beforeunload", (event) => {
+            if (!isSubmitting && initialDataJson !== formToJsonString()) {
+                event.preventDefault();
+                event.returnValue = "";
+            }
+        });
+    }
 });
diff --git a/rosetta/templates/rosetta/base.html b/rosetta/templates/rosetta/base.html
index e7156bc..19207d7 100644
--- a/rosetta/templates/rosetta/base.html
+++ b/rosetta/templates/rosetta/base.html
@@ -1,7 +1,7 @@
 <!DOCTYPE html>{% load static %}
 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
     <head>
-        <meta http-equiv="Content-Security-Policy" content="default-src 'self';" />
+        <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; script-src-elem 'self' https://translate.yandex.net" />
         <title>{% block pagetitle %}Rosetta{% endblock %}</title>
         <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
 
@@ -11,7 +11,6 @@
         <link rel="stylesheet" href="{% static "admin/css/changelists.css" %}" type="text/css"/>
         <link rel="stylesheet" href="{% static "admin/rosetta/css/rosetta.css" %}" type="text/css"/>
         {% block extra_styles %}{% endblock %}
-        <script src="{% static "admin/js/vendor/jquery/jquery.min.js" %}"></script>
         {{ rosetta_settings_js|json_script:"rosetta-settings-js" }}
         <script src="{% static "admin/rosetta/js/rosetta.js" %}"></script>
     </head>
diff --git a/rosetta/templates/rosetta/form.html b/rosetta/templates/rosetta/form.html
index d58a237..95b5101 100644
--- a/rosetta/templates/rosetta/form.html
+++ b/rosetta/templates/rosetta/form.html
@@ -56,7 +56,7 @@ <h1>{% blocktrans %}Translate into {{ rosetta_i18n_lang_name }}{% endblocktrans
         {% if rosetta_settings.ENABLE_REFLANG %}
             <div class="actions">
                 <label for="ref-language-selector">{% trans "Reference language" %}:</label>
-                <select class="select-across" id="ref-language-selector" onchange="javascript:window.location.href = this.value;">
+                <select class="select-across" id="ref-language-selector">
                     {% for langid, langname in LANGUAGES %}
                         <option{% if ref_lang == langid %} selected="selected"{% endif %} value="?ref_lang={{ langid }}">{{ langname }}</option>
                     {% endfor %}
@@ -89,7 +89,9 @@ <h1>{% blocktrans %}Translate into {{ rosetta_i18n_lang_name }}{% endblocktrans
                                 <td class="translation">
                                     {% for k, msgstr in message.msgstr_plural.items %}
                                         <label for="m_{{ message.md5hash }}_{{ k }}">{{ k }}:</label>
+                                        <span class="alert hidden">{% trans "Unmatched variables" %}</span>
                                         <textarea rows="{{ message.msgid|format_message|lines_count }}" cols="40" id="m_{{ message.md5hash }}_{{ k }}" name="m_{{ message.md5hash }}_{{ k }}" tabindex="{% increment tab_idx %}">{{ msgstr }}</textarea>
+                                        {% if rosetta_settings.ENABLE_TRANSLATION_SUGGESTIONS %}<a href="#" class="suggest">{% trans "suggest" %}</a>{% endif %}
                                     {% endfor %}
                                 </td>
                             {% else %}
@@ -102,6 +104,7 @@ <h1>{% blocktrans %}Translate into {{ rosetta_i18n_lang_name }}{% endblocktrans
                                 </td>
                                 {% if main_language %}<td class="original">{{ message.main_lang|format_message|linebreaksbr }}</td>{% endif %}
                                 <td class="translation">
+                                    <span class="alert hidden">{% trans "Unmatched variables" %}</span>
                                     <textarea rows="{{ message.msgid|format_message|lines_count }}" cols="40" name="m_{{ message.md5hash }}" tabindex="{% increment tab_idx %}">{{ message.msgstr }}</textarea>
                                     {% if rosetta_settings.ENABLE_TRANSLATION_SUGGESTIONS %}<a href="#" class="suggest">{% trans "suggest" %}</a>{% endif %}
                                 </td>
@@ -115,7 +118,7 @@ <h1>{% blocktrans %}Translate into {{ rosetta_i18n_lang_name }}{% endblocktrans
                                         <code{% if forloop.counter|gt:"3" %} class="hide"{% endif %}>{{ fn }}{% if lineno %}:{{ lineno }}{% endif %}</code>
                                     {% endfor %}
                                     {% if message.occurrences|length|gt:"3" %}
-                                        <a href="#">&hellip; ({% blocktrans count message.occurrences|length|minus:"3" as more_count %}{{ more_count }} more{% plural %}{{ more_count }} more{% endblocktrans %})</a>
+                                        <a href="#" data-prev-text="{% trans "Show less" %}">&hellip; ({% blocktrans count message.occurrences|length|minus:"3" as more_count %}{{ more_count }} more{% plural %}{{ more_count }} more{% endblocktrans %})</a>
                                     {% endif %}
                                     {% if message.msgctxt or message.comment %}
                                         <span class="context">