Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite rosetta.js #295

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module.exports = {
browser: true,
node: true,
},
parserOptions: { ecmaVersion: 9 },
parserOptions: { ecmaVersion: 2020 },
globals: {
$: "readonly",
},
Expand Down
6 changes: 4 additions & 2 deletions rosetta/static/admin/rosetta/css/rosetta.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ td .context {
}
td.translation textarea {
width: 98.5%;
min-height: 25px;
margin: 2px 0;
}
.rtl td.translation textarea {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -158,3 +156,7 @@ div.module {
#action-toggle {
display: inline;
}
a.suggest {
display: block;
margin-bottom: 5px;
}
309 changes: 190 additions & 119 deletions rosetta/static/admin/rosetta/js/rosetta.js
Original file line number Diff line number Diff line change
@@ -1,157 +1,228 @@
"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 textareas = textarea.closest("td").querySelectorAll("textarea");
const nth = Array.from(textareas).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("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>/, "")
.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,
key: rosetta_settings.YANDEX_TRANSLATE_KEY,
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");
// For each translation field textarea
document.querySelectorAll(".translation textarea").forEach((textarea, i) => {
// Focus on the first textarea on page load
if (i === 0) {
textarea.focus();
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about accessibility, I guess focusing on the first textarea is probably not a good idea:

  • I imagine people who use the tab key for navigation expect it to start from the beginning of the page.
  • Making an on-screen keyboard show up on page load is likely not helpful in most cases either.
  • Some people use the space bar to scroll down, which makes a change in the translation instead.

So I'd rather remove this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the focus on page load here: b63db38


// Make textarea heights adapt to their contents on page load
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";

// On input
textarea.addEventListener("input", function () {
// Make textarea heights adapt to their contents
this.style.height = "auto";
this.style.height = this.scrollHeight + "px";

// 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
const cb = this.closest("tr").querySelector('td.c input[type="checkbox"]');
if (cb.checked) {
cb.checked = false;
}
});

// On blur show warnings for unmatched variables in translations
textarea.addEventListener("blur", function () {
const orig = originalForTextarea(this);
const variablePattern = /%(?:\([^\s)]*\))?[sdf]|\{[\w\d_]+?\}/g;
const origVars = orig.match(variablePattern) || [];
const transVars = this.value.match(variablePattern) || [];
const everyOrigVarUsed = origVars.every((origVar) => transVars.includes(origVar));
const onlyValidVarsUsed = transVars.every((transVar) => origVars.includes(transVar));
const valid = everyOrigVarUsed && onlyValidVarsUsed;
this.previousElementSibling.classList.toggle("hidden", valid);
});
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the testproject there's a string with curly braces: %{count} observations. I thought gettext works only with the %(count)d syntax. I'm not sure how this is supposed to be handled exactly.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that looks wrong 🤔

This is correct thought:

_("{observations:d} observations").format(observations=10)

which will produce

#, python-brace-format
msgid "{observations:d} observations"
msgstr ""

Copy link
Contributor Author

@balazs-endresz balazs-endresz Sep 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I didn't realise that's allowed too, I guess there's no reason why it wouldn't work. But the current regex doesn't recognise {observations:d} as a variable. We could change it to \{[^\s}]*\}, which would follow what the regex does for the percent syntax.

But are the translators always supposed to use the exact same modifier, or is there perhaps a use case to have something different in another language?

(Btw, compilemessages won't warn about missing variables with the curly braces. It seems to do that only for the percent syntax. I guess this is why all the django docs examples are written that way.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed the regex here: 3fdca87

This expects the exact same str.format modifiers to be used in translations.

});

$(".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;
}
// 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";
}
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");
}
});
});
}
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);
}
const initialDataJson = formToJsonString();
let isSubmitting = false;
form.addEventListener("submit", () => (isSubmitting = true));
window.addEventListener("beforeunload", (event) => {
if (!isSubmitting && initialDataJson !== formToJsonString()) {
event.preventDefault();
event.returnValue = "";
}
});
});
3 changes: 1 addition & 2 deletions rosetta/templates/rosetta/base.html
Original file line number Diff line number Diff line change
@@ -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" />

Expand All @@ -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>
Expand Down
Loading
Loading