mirror of
https://github.com/LibreTranslate/LibreTranslate.git
synced 2024-12-18 08:27:03 +02:00
614 lines
24 KiB
Plaintext
614 lines
24 KiB
Plaintext
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0
|
|
// API host/endpoint
|
|
var BaseUrl = window.location.protocol + "//" + window.location.host + "{{ url_prefix }}" ;
|
|
var htmlRegex = /<(.*)>.*?|<(.*)\/>/;
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
var sidenavElems = document.querySelectorAll('.sidenav');
|
|
var sidenavInstances = M.Sidenav.init(sidenavElems);
|
|
|
|
var app = new Vue({
|
|
el: '#app',
|
|
delimiters: ['[[',']]'],
|
|
data: {
|
|
BaseUrl: BaseUrl,
|
|
loading: true,
|
|
error: "",
|
|
langs: [],
|
|
settings: {},
|
|
sourceLang: "",
|
|
targetLang: "",
|
|
|
|
loadingTranslation: false,
|
|
inputText: "",
|
|
inputTextareaHeight: 250,
|
|
savedTanslatedText: "",
|
|
translatedText: "",
|
|
output: "",
|
|
charactersLimit: -1,
|
|
|
|
detectedLangText: "",
|
|
|
|
copyTextLabel: {{ _e("Copy text") }},
|
|
|
|
suggestions: false,
|
|
isSuggesting: false,
|
|
|
|
supportedFilesFormat : [],
|
|
translationType: "text",
|
|
inputFile: false,
|
|
loadingFileTranslation: false,
|
|
translatedFileUrl: false,
|
|
filesTranslation: true,
|
|
frontendTimeout: 500,
|
|
|
|
apiSecret: "{{ api_secret }}"
|
|
},
|
|
mounted: function() {
|
|
const self = this;
|
|
self.$el.classList.add("loaded");
|
|
|
|
const settingsRequest = new XMLHttpRequest();
|
|
settingsRequest.open("GET", BaseUrl + "/frontend/settings", true);
|
|
|
|
const langsRequest = new XMLHttpRequest();
|
|
langsRequest.open("GET", BaseUrl + "/languages", true);
|
|
|
|
settingsRequest.onload = function() {
|
|
if (this.status >= 200 && this.status < 400) {
|
|
self.settings = JSON.parse(this.response);
|
|
self.sourceLang = self.settings.language.source.code;
|
|
self.targetLang = self.settings.language.target.code;
|
|
self.charactersLimit = self.settings.charLimit;
|
|
self.suggestions = self.settings.suggestions;
|
|
self.supportedFilesFormat = self.settings.supportedFilesFormat;
|
|
self.filesTranslation = self.settings.filesTranslation;
|
|
self.frontendTimeout = self.settings.frontendTimeout;
|
|
|
|
if (langsRequest.response) {
|
|
handleLangsResponse(self, langsRequest);
|
|
} else {
|
|
langsRequest.onload = function() {
|
|
handleLangsResponse(self, this);
|
|
|
|
var hostname = window.location.hostname.toLowerCase();
|
|
if (hostname.indexOf("libretranslate.") === 0 && !hostname.endsWith(".com")){
|
|
self.error = "This website might be in violation of our trademark guidelines: https://github.com/LibreTranslate/LibreTranslate/blob/main/TRADEMARK.md";
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
self.error = {{ _e("Cannot load %(url)s", url="/frontend/settings") }};
|
|
self.loading = false;
|
|
}
|
|
};
|
|
|
|
settingsRequest.onerror = function() {
|
|
self.error = {{ _e("Cannot load %(url)s", url="/frontend/settings") }};
|
|
self.loading = false;
|
|
};
|
|
|
|
langsRequest.onerror = function() {
|
|
self.error = {{ _e("Cannot load %(url)s", url="/languages") }};
|
|
self.loading = false;
|
|
};
|
|
|
|
settingsRequest.send();
|
|
langsRequest.send();
|
|
},
|
|
updated: function(){
|
|
if (this.isSuggesting) return;
|
|
|
|
M.FormSelect.init(this.$refs.sourceLangDropdown);
|
|
M.FormSelect.init(this.$refs.targetLangDropdown);
|
|
|
|
if (this.$refs.inputTextarea){
|
|
this.$refs.inputTextarea.focus()
|
|
|
|
if (this.inputText === ""){
|
|
this.$refs.inputTextarea.style.height = this.inputTextareaHeight + "px";
|
|
this.$refs.translatedTextarea.style.height = this.inputTextareaHeight + "px";
|
|
} else{
|
|
this.$refs.inputTextarea.style.height = this.$refs.translatedTextarea.style.height = "1px";
|
|
this.$refs.inputTextarea.style.height = Math.max(this.inputTextareaHeight, this.$refs.inputTextarea.scrollHeight + 32) + "px";
|
|
this.$refs.translatedTextarea.style.height = Math.max(this.inputTextareaHeight, this.$refs.translatedTextarea.scrollHeight + 32) + "px";
|
|
}
|
|
}
|
|
|
|
// Update "selected" attribute (to overcome a vue.js limitation)
|
|
// but properly display checkmarks on supported browsers.
|
|
// Also change the <select> width value depending on the <option> length
|
|
if (this.$refs.sourceLangDropdown) {
|
|
updateSelectedAttribute(this.$refs.sourceLangDropdown, this.sourceLang);
|
|
}
|
|
|
|
if (this.$refs.targetLangDropdown) {
|
|
updateSelectedAttribute(this.$refs.targetLangDropdown, this.targetLang);
|
|
}
|
|
},
|
|
computed: {
|
|
requestCode: function(){
|
|
return ['const res = await fetch("' + this.BaseUrl + '/translate", {',
|
|
' method: "POST",',
|
|
' body: JSON.stringify({',
|
|
' q: ' + this.$options.filters.escape(this.inputText) + ',',
|
|
' source: ' + this.$options.filters.escape(this.sourceLang) + ',',
|
|
' target: ' + this.$options.filters.escape(this.targetLang) + ',',
|
|
' format: "' + (this.isHtml ? "html" : "text") + '",',
|
|
' alternatives: 3,',
|
|
' api_key: "' + (localStorage.getItem("api_key") || "") + '"',
|
|
' }),',
|
|
' headers: { "Content-Type": "application/json" }',
|
|
'});',
|
|
'',
|
|
'console.log(await res.json());'].join("\n");
|
|
},
|
|
supportedFilesFormatFormatted: function() {
|
|
return this.supportedFilesFormat.join(', ');
|
|
},
|
|
isHtml: function(){
|
|
return htmlRegex.test(this.inputText);
|
|
},
|
|
canSendSuggestion: function(){
|
|
return this.translatedText.trim() !== "" && this.translatedText !== this.savedTanslatedText;
|
|
},
|
|
targetLangs: function(){
|
|
if (!this.sourceLang) return this.langs;
|
|
else{
|
|
var lang = this.langs.find(l => l.code === this.sourceLang);
|
|
if (!lang) return this.langs;
|
|
var tgtLangs = lang.targets.map(t => this.langs.find(l => l.code === t));
|
|
tgtLangs.sort(function(a, b){
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
return tgtLangs;
|
|
}
|
|
}
|
|
},
|
|
filters: {
|
|
escape: function(v){
|
|
return JSON.stringify(v);
|
|
},
|
|
highlight: function(v){
|
|
return Prism.highlight(v, Prism.languages.javascript, 'javascript');
|
|
}
|
|
},
|
|
methods: {
|
|
abortPreviousTransRequest: function(){
|
|
if (this.transRequest){
|
|
this.transRequest.abort();
|
|
this.transRequest = null;
|
|
}
|
|
},
|
|
swapLangs: function(e){
|
|
this.closeSuggestTranslation(e);
|
|
|
|
// Make sure that we can swap
|
|
// by checking that the current target language
|
|
// has source language as target
|
|
var tgtLang = this.langs.find(l => l.code === this.targetLang);
|
|
if (tgtLang.targets.indexOf(this.sourceLang) === -1) return; // Not supported
|
|
|
|
var t = this.sourceLang;
|
|
this.sourceLang = this.targetLang;
|
|
this.targetLang = t;
|
|
this.inputText = this.translatedText;
|
|
this.translatedText = "";
|
|
this.handleInput(e);
|
|
},
|
|
dismissError: function(){
|
|
this.error = '';
|
|
},
|
|
getQueryParam: function (key) {
|
|
const params = new URLSearchParams(window.location.search);
|
|
return params.get(key)
|
|
},
|
|
updateQueryParam: function (key, value) {
|
|
let searchParams = new URLSearchParams(window.location.search)
|
|
searchParams.set(key, value);
|
|
let newRelativePathQuery = window.location.pathname + '?' + searchParams.toString();
|
|
history.pushState(null, '', newRelativePathQuery);
|
|
},
|
|
handleInput: function(e){
|
|
this.closeSuggestTranslation(e)
|
|
|
|
this.updateQueryParam('source', this.sourceLang)
|
|
this.updateQueryParam('target', this.targetLang)
|
|
this.updateQueryParam('q', this.inputText)
|
|
|
|
if (this.timeout) clearTimeout(this.timeout);
|
|
this.timeout = null;
|
|
|
|
this.detectedLangText = "";
|
|
|
|
if (this.inputText === ""){
|
|
this.translatedText = "";
|
|
this.output = "";
|
|
this.abortPreviousTransRequest();
|
|
this.loadingTranslation = false;
|
|
return;
|
|
}
|
|
|
|
var self = this;
|
|
|
|
self.loadingTranslation = true;
|
|
this.timeout = setTimeout(function(){
|
|
self.abortPreviousTransRequest();
|
|
|
|
var request = new XMLHttpRequest();
|
|
self.transRequest = request;
|
|
|
|
var data = new FormData();
|
|
data.append("q", self.inputText);
|
|
data.append("source", self.sourceLang);
|
|
data.append("target", self.targetLang);
|
|
data.append("format", self.isHtml ? "html" : "text");
|
|
data.append("alternatives", 3);
|
|
data.append("api_key", localStorage.getItem("api_key") || "");
|
|
if (self.apiSecret) data.append("secret", atob(self.apiSecret));
|
|
|
|
request.open('POST', BaseUrl + '/translate', true);
|
|
|
|
request.onload = function() {
|
|
try{
|
|
{% if api_secret != "" %}
|
|
if (this.status === 400){
|
|
if (self.refreshOnce()) return;
|
|
}
|
|
{% endif %}
|
|
|
|
var res = JSON.parse(this.response);
|
|
// Success!
|
|
if (res.translatedText !== undefined){
|
|
self.translatedText = res.translatedText;
|
|
self.loadingTranslation = false;
|
|
self.output = JSON.stringify(res, null, 4);
|
|
if(self.sourceLang == "auto" && res.detectedLanguage !== undefined){
|
|
let lang = self.langs.find(l => l.code === res.detectedLanguage.language)
|
|
self.detectedLangText = ": " + (lang !== undefined ? lang.name : res.detectedLanguage.language) + " (" + res.detectedLanguage.confidence + "%)";
|
|
}
|
|
} else{
|
|
throw new Error(res.error || {{ _e("Unknown error") }});
|
|
}
|
|
} catch (e) {
|
|
self.error = e.message;
|
|
self.loadingTranslation = false;
|
|
}
|
|
};
|
|
|
|
request.onerror = function() {
|
|
self.error = {{ _e("Cannot load %(url)s", url="/translate") }};
|
|
self.loadingTranslation = false;
|
|
};
|
|
|
|
request.send(data);
|
|
}, self.frontendTimeout);
|
|
},
|
|
copyText: function(e){
|
|
e.preventDefault();
|
|
this.$refs.translatedTextarea.select();
|
|
this.$refs.translatedTextarea.setSelectionRange(0, 9999999); /* For mobile devices */
|
|
document.execCommand("copy");
|
|
|
|
if (this.copyTextLabel === {{ _e("Copy text") }}){
|
|
this.copyTextLabel = {{ _e("Copied") }};
|
|
var self = this;
|
|
setTimeout(function(){
|
|
self.copyTextLabel = {{ _e("Copy text") }};
|
|
}, 1500);
|
|
}
|
|
},
|
|
suggestTranslation: function(e) {
|
|
e.preventDefault();
|
|
this.savedTanslatedText = this.translatedText
|
|
|
|
this.isSuggesting = true;
|
|
this.$nextTick(() => {
|
|
this.$refs.translatedTextarea.focus();
|
|
});
|
|
},
|
|
closeSuggestTranslation: function(e) {
|
|
if(this.isSuggesting) {
|
|
e.preventDefault();
|
|
// this.translatedText = this.savedTanslatedText
|
|
}
|
|
|
|
this.isSuggesting = false;
|
|
},
|
|
sendSuggestion: function(e) {
|
|
e.preventDefault();
|
|
|
|
var self = this;
|
|
|
|
var request = new XMLHttpRequest();
|
|
self.transRequest = request;
|
|
|
|
var data = new FormData();
|
|
data.append("q", self.inputText);
|
|
data.append("s", self.translatedText);
|
|
data.append("source", self.sourceLang);
|
|
data.append("target", self.targetLang);
|
|
data.append("api_key", localStorage.getItem("api_key") || "");
|
|
|
|
request.open('POST', BaseUrl + '/suggest', true);
|
|
request.onload = function() {
|
|
try{
|
|
var res = JSON.parse(this.response);
|
|
if (res.success){
|
|
M.toast({html: {{ _e("Thanks for your correction. Note the suggestion will not take effect right away.") }} })
|
|
self.closeSuggestTranslation(e)
|
|
}else{
|
|
throw new Error(res.error || {{ _e("Unknown error") }});
|
|
}
|
|
}catch(e){
|
|
self.error = e.message;
|
|
self.closeSuggestTranslation(e)
|
|
}
|
|
};
|
|
|
|
request.onerror = function() {
|
|
self.error = {{ _e("Cannot load %(url)s", url="/suggest") }};
|
|
self.loadingTranslation = false;
|
|
};
|
|
|
|
request.send(data);
|
|
},
|
|
deleteText: function(e){
|
|
e.preventDefault();
|
|
this.inputText = this.translatedText = this.output = "";
|
|
this.$refs.inputTextarea.focus();
|
|
},
|
|
switchType: function(type) {
|
|
this.translationType = type;
|
|
},
|
|
handleInputFile: function(e) {
|
|
this.inputFile = e.target.files[0];
|
|
},
|
|
removeFile: function(e) {
|
|
e.preventDefault()
|
|
this.inputFile = false;
|
|
this.translatedFileUrl = false;
|
|
this.loadingFileTranslation = false;
|
|
},
|
|
refreshOnce: function(){
|
|
var lastRefreshed = parseInt(localStorage.getItem("refreshed") || 0);
|
|
var now = new Date().getTime();
|
|
if (now - lastRefreshed > 1000 * 60 * 1){
|
|
localStorage.setItem("refreshed", now);
|
|
window.location.reload();
|
|
return true;
|
|
}
|
|
},
|
|
translateFile: function(e) {
|
|
e.preventDefault();
|
|
|
|
let self = this;
|
|
let translateFileRequest = new XMLHttpRequest();
|
|
|
|
translateFileRequest.open("POST", BaseUrl + "/translate_file", true);
|
|
|
|
let data = new FormData();
|
|
data.append("file", this.inputFile);
|
|
data.append("source", this.sourceLang);
|
|
data.append("target", this.targetLang);
|
|
data.append("api_key", localStorage.getItem("api_key") || "");
|
|
if (self.apiSecret) data.append("secret", self.apiSecret);
|
|
|
|
this.loadingFileTranslation = true
|
|
|
|
translateFileRequest.onload = function() {
|
|
if (translateFileRequest.readyState === 4 && translateFileRequest.status === 200) {
|
|
try{
|
|
{% if api_secret != "" %}
|
|
if (this.status === 400){
|
|
if (self.refreshOnce()) return;
|
|
}
|
|
{% endif %}
|
|
self.loadingFileTranslation = false;
|
|
|
|
let res = JSON.parse(this.response);
|
|
if (res.translatedFileUrl){
|
|
self.translatedFileUrl = res.translatedFileUrl;
|
|
|
|
let link = document.createElement("a");
|
|
link.href = self.translatedFileUrl;
|
|
link.download = "";
|
|
link.click();
|
|
handleNotification("Translation Complete", "Finished translating " + self.inputFile.name + ".");
|
|
}else{
|
|
throw new Error(res.error || {{ _e("Unknown error") }});
|
|
}
|
|
|
|
}catch(e){
|
|
self.error = e.message;
|
|
self.loadingFileTranslation = false;
|
|
self.inputFile = false;
|
|
handleNotification("Translation Failed", e.message);
|
|
}
|
|
}else{
|
|
let res = JSON.parse(this.response);
|
|
self.error = res.error || {{ _e("Unknown error") }};
|
|
self.loadingFileTranslation = false;
|
|
self.inputFile = false;
|
|
handleNotification("Translation Failed", self.error);
|
|
}
|
|
}
|
|
|
|
translateFileRequest.onerror = function() {
|
|
const message = {{ _e("Cannot load %(url)s", url="/translate_file") }};
|
|
self.error = message;
|
|
self.loadingFileTranslation = false;
|
|
self.inputFile = false;
|
|
handleNotification("Translation Failed", message);
|
|
};
|
|
|
|
translateFileRequest.send(data);
|
|
Notification.requestPermission();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* @param {object} self
|
|
* @param {XMLHttpRequest} response
|
|
*/
|
|
function handleLangsResponse(self, response) {
|
|
if (response.status >= 200 && response.status < 400) {
|
|
self.langs = JSON.parse(response.response);
|
|
|
|
if (self.langs.length === 0){
|
|
self.loading = false;
|
|
self.error = {{ _e("No languages available. Did you install the models correctly?") }};
|
|
return;
|
|
}
|
|
|
|
self.langs.push({ name: {{ _e("Auto Detect") }}, code: "auto", targets: self.langs.map(l => l.code)})
|
|
|
|
const sourceLanguage = self.langs.find(l => l.code === self.getQueryParam("source"))
|
|
const targetLanguage = self.langs.find(l => l.code === self.getQueryParam("target"))
|
|
|
|
if (sourceLanguage) {
|
|
self.sourceLang = sourceLanguage.code
|
|
}
|
|
|
|
if (targetLanguage) {
|
|
self.targetLang = targetLanguage.code
|
|
}
|
|
|
|
const defaultText = self.getQueryParam("q")
|
|
|
|
if (defaultText) {
|
|
self.inputText = defaultText
|
|
self.handleInput(new Event('none'))
|
|
}
|
|
} else {
|
|
self.error = {{ _e("Cannot load %(url)s", url="/languages") }};
|
|
}
|
|
|
|
self.loading = false;
|
|
}
|
|
|
|
/**
|
|
* @param {string} title
|
|
* @param {string} body
|
|
*/
|
|
function handleNotification(title, body) {
|
|
new Notification(title, {
|
|
body,
|
|
tag: 'libretranslate'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {object} langDropdown
|
|
* @param {string} lang
|
|
*/
|
|
function updateSelectedAttribute(langDropdown, lang) {
|
|
for (const child of langDropdown.children) {
|
|
if (child.value === lang){
|
|
child.setAttribute('selected', '');
|
|
langDropdown.style.width = getTextWidth(child.text) + 24 + 'px';
|
|
} else{
|
|
child.removeAttribute('selected');
|
|
}
|
|
}
|
|
}
|
|
|
|
function getTextWidth(text) {
|
|
var canvas = getTextWidth.canvas || (getTextWidth.canvas = document.createElement("canvas"));
|
|
var ctx = canvas.getContext("2d");
|
|
ctx.font = 'bold 16px sans-serif';
|
|
var textWidth = Math.ceil(ctx.measureText(text).width);
|
|
return textWidth;
|
|
}
|
|
|
|
function setApiKey(){
|
|
var prevKey = localStorage.getItem("api_key") || "";
|
|
var newKey = "";
|
|
newKey = window.prompt({{ _e("Type in your API Key. If you need an API key, %(instructions)s", instructions=_("press the \"Get API Key\" link.") if get_api_key_link else _("contact the server operator.")) }}, prevKey);
|
|
if (newKey === null) newKey = "";
|
|
|
|
localStorage.setItem("api_key", newKey);
|
|
}
|
|
|
|
// Color scheme handling
|
|
function getPreferredColorScheme(){
|
|
var systemScheme = 'light';
|
|
if(window.matchMedia('(prefers-color-scheme: dark)').matches){
|
|
systemScheme = 'dark';
|
|
}
|
|
var chosenScheme = systemScheme;
|
|
|
|
if(localStorage.getItem("scheme")){
|
|
chosenScheme = localStorage.getItem("scheme");
|
|
}
|
|
|
|
if(systemScheme === chosenScheme){
|
|
localStorage.removeItem("scheme");
|
|
}
|
|
|
|
return chosenScheme;
|
|
}
|
|
|
|
// Write chosen color scheme to local storage
|
|
// Unless the system scheme matches the the stored scheme, in which case... remove from local storage
|
|
function savePreferredColorScheme(scheme){
|
|
var systemScheme = 'light';
|
|
|
|
if(window.matchMedia('(prefers-color-scheme: dark)').matches){
|
|
systemScheme = 'dark';
|
|
}
|
|
|
|
if(systemScheme === scheme){
|
|
localStorage.removeItem("scheme");
|
|
} else {
|
|
localStorage.setItem("scheme", scheme);
|
|
}
|
|
|
|
}
|
|
|
|
// Get the current scheme, and apply the opposite
|
|
function toggleColorScheme(){
|
|
let newScheme = "light";
|
|
let scheme = getPreferredColorScheme();
|
|
if (scheme === "light"){
|
|
newScheme = "dark";
|
|
}
|
|
|
|
applyPreferredColorScheme(newScheme);
|
|
}
|
|
|
|
// Apply the chosen color scheme by traversing stylesheet rules, and applying a medium.
|
|
function applyPreferredColorScheme(scheme) {
|
|
for (var s = 0; s < document.styleSheets.length; s++) {
|
|
for (var i = 0; i < document.styleSheets[s].cssRules.length; i++) {
|
|
rule = document.styleSheets[s].cssRules[i];
|
|
if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) {
|
|
switch (scheme) {
|
|
case "light":
|
|
rule.media.appendMedium("original-prefers-color-scheme");
|
|
if (rule.media.mediaText.includes("light")) rule.media.deleteMedium("(prefers-color-scheme: light)");
|
|
if (rule.media.mediaText.includes("dark")) rule.media.deleteMedium("(prefers-color-scheme: dark)");
|
|
break;
|
|
case "dark":
|
|
rule.media.appendMedium("(prefers-color-scheme: light)");
|
|
rule.media.appendMedium("(prefers-color-scheme: dark)");
|
|
if (rule.media.mediaText.includes("original")) rule.media.deleteMedium("original-prefers-color-scheme");
|
|
break;
|
|
default:
|
|
rule.media.appendMedium("(prefers-color-scheme: dark)");
|
|
if (rule.media.mediaText.includes("light")) rule.media.deleteMedium("(prefers-color-scheme: light)");
|
|
if (rule.media.mediaText.includes("original")) rule.media.deleteMedium("original-prefers-color-scheme");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
savePreferredColorScheme(scheme);
|
|
}
|
|
|
|
applyPreferredColorScheme(getPreferredColorScheme());
|
|
|
|
// @license-end
|