-
Notifications
You must be signed in to change notification settings - Fork 196
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
Rewrite rosetta.js #295
Changes from 2 commits
9c83ddd
5c57d5a
b63db38
71c9f34
cf84ac5
3fdca87
6304a7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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(/>/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, | ||
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(/</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"); | ||
// 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(); | ||
} | ||
|
||
// 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); | ||
}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the testproject there's a string with curly braces: There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've changed the regex here: 3fdca87 This expects the exact same |
||
}); | ||
|
||
$(".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 = ""; | ||
} | ||
}); | ||
}); |
There was a problem hiding this comment.
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:
So I'd rather remove this.
There was a problem hiding this comment.
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