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(//g, "\n") .replace(//g, "") .replace(/<\/code>/g, "") .replace(/>/g, ">") .replace(/</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(/'/g, "'") .replace(/"/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(/
/g, "\n") - .replace(/<\/?code>/g, "") - .replace(/</g, "<") - .replace(/>/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(/</g, "<") + .replace(/>/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 = $('Unmatched variables'); - - 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 @@ {% load static %} - + {% block pagetitle %}Rosetta{% endblock %} @@ -11,7 +11,6 @@ {% block extra_styles %}{% endblock %} - {{ rosetta_settings_js|json_script:"rosetta-settings-js" }} 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 @@

{% blocktrans %}Translate into {{ rosetta_i18n_lang_name }}{% endblocktrans {% if rosetta_settings.ENABLE_REFLANG %}
- {% for langid, langname in LANGUAGES %} {{ langname }} {% endfor %} @@ -89,7 +89,9 @@

{% blocktrans %}Translate into {{ rosetta_i18n_lang_name }}{% endblocktrans {% for k, msgstr in message.msgstr_plural.items %} + + {% if rosetta_settings.ENABLE_TRANSLATION_SUGGESTIONS %}{% trans "suggest" %}{% endif %} {% endfor %} {% else %} @@ -102,6 +104,7 @@

{% blocktrans %}Translate into {{ rosetta_i18n_lang_name }}{% endblocktrans {% if main_language %}{{ message.main_lang|format_message|linebreaksbr }}{% endif %} + {% if rosetta_settings.ENABLE_TRANSLATION_SUGGESTIONS %}{% trans "suggest" %}{% endif %} @@ -115,7 +118,7 @@

{% blocktrans %}Translate into {{ rosetta_i18n_lang_name }}{% endblocktrans {{ fn }}{% if lineno %}:{{ lineno }}{% endif %} {% endfor %} {% if message.occurrences|length|gt:"3" %} - … ({% blocktrans count message.occurrences|length|minus:"3" as more_count %}{{ more_count }} more{% plural %}{{ more_count }} more{% endblocktrans %}) + … ({% blocktrans count message.occurrences|length|minus:"3" as more_count %}{{ more_count }} more{% plural %}{{ more_count }} more{% endblocktrans %}) {% endif %} {% if message.msgctxt or message.comment %}