mirror of
https://github.com/LibreTranslate/LibreTranslate.git
synced 2024-11-22 07:51:00 +00:00
Merge pull request #151 from dingedi/main
[WIP] Suggesting corrections to translations
This commit is contained in:
commit
c1da77fbeb
7 changed files with 225 additions and 30 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -131,3 +131,4 @@ installed_models/
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
api_keys.db
|
api_keys.db
|
||||||
|
suggestions.db
|
75
app/app.py
75
app/app.py
|
@ -9,6 +9,7 @@ from app import flood
|
||||||
from app.language import detect_languages, transliterate
|
from app.language import detect_languages, transliterate
|
||||||
|
|
||||||
from .api_keys import Database
|
from .api_keys import Database
|
||||||
|
from .suggestions import Database as SuggestionsDatabase
|
||||||
|
|
||||||
from translatehtml import translate_html
|
from translatehtml import translate_html
|
||||||
|
|
||||||
|
@ -565,6 +566,9 @@ def create_app(args):
|
||||||
frontendTimeout:
|
frontendTimeout:
|
||||||
type: integer
|
type: integer
|
||||||
description: Frontend translation timeout
|
description: Frontend translation timeout
|
||||||
|
suggestions:
|
||||||
|
type: boolean
|
||||||
|
description: Whether submitting suggestions is enabled.
|
||||||
language:
|
language:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
@ -591,6 +595,7 @@ def create_app(args):
|
||||||
{
|
{
|
||||||
"charLimit": args.char_limit,
|
"charLimit": args.char_limit,
|
||||||
"frontendTimeout": args.frontend_timeout,
|
"frontendTimeout": args.frontend_timeout,
|
||||||
|
"suggestions": args.suggestions,
|
||||||
"language": {
|
"language": {
|
||||||
"source": {
|
"source": {
|
||||||
"code": frontend_argos_language_source.code,
|
"code": frontend_argos_language_source.code,
|
||||||
|
@ -604,8 +609,76 @@ def create_app(args):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.route("/suggest", methods=["POST"])
|
||||||
|
@limiter.exempt
|
||||||
|
def suggest():
|
||||||
|
"""
|
||||||
|
Submit a suggestion to improve a translation
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- feedback
|
||||||
|
parameters:
|
||||||
|
- in: formData
|
||||||
|
name: q
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: Hello world!
|
||||||
|
required: true
|
||||||
|
description: Original text
|
||||||
|
- in: formData
|
||||||
|
name: s
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: ¡Hola mundo!
|
||||||
|
required: true
|
||||||
|
description: Suggested translation
|
||||||
|
- in: formData
|
||||||
|
name: source
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: en
|
||||||
|
required: true
|
||||||
|
description: Language of original text
|
||||||
|
- in: formData
|
||||||
|
name: target
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: es
|
||||||
|
required: true
|
||||||
|
description: Language of suggested translation
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Success
|
||||||
|
schema:
|
||||||
|
id: suggest-response
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
description: Whether submission was successful
|
||||||
|
403:
|
||||||
|
description: Not authorized
|
||||||
|
schema:
|
||||||
|
id: error-response
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
description: Error message
|
||||||
|
"""
|
||||||
|
if not args.suggestions:
|
||||||
|
abort(403, description="Suggestions are disabled on this server.")
|
||||||
|
|
||||||
|
q = request.values.get("q")
|
||||||
|
s = request.values.get("s")
|
||||||
|
source_lang = request.values.get("source")
|
||||||
|
target_lang = request.values.get("target")
|
||||||
|
|
||||||
|
SuggestionsDatabase().add(q, s, source_lang, target_lang)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
swag = swagger(app)
|
swag = swagger(app)
|
||||||
swag["info"]["version"] = "1.2"
|
swag["info"]["version"] = "1.2.1"
|
||||||
swag["info"]["title"] = "LibreTranslate"
|
swag["info"]["title"] = "LibreTranslate"
|
||||||
|
|
||||||
@app.route("/spec")
|
@app.route("/spec")
|
||||||
|
|
|
@ -110,6 +110,11 @@ _default_options_objects = [
|
||||||
'name': 'LOAD_ONLY',
|
'name': 'LOAD_ONLY',
|
||||||
'default_value': None,
|
'default_value': None,
|
||||||
'value_type': 'str'
|
'value_type': 'str'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'SUGGESTIONS',
|
||||||
|
'default_value': False,
|
||||||
|
'value_type': 'bool'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -102,6 +102,9 @@ def main():
|
||||||
metavar="<comma-separated language codes>",
|
metavar="<comma-separated language codes>",
|
||||||
help="Set available languages (ar,de,en,es,fr,ga,hi,it,ja,ko,pt,ru,zh)",
|
help="Set available languages (ar,de,en,es,fr,ga,hi,it,ja,ko,pt,ru,zh)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--suggestions", default=DEFARGS['SUGGESTIONS'], action="store_true", help="Allow user suggestions"
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
app = create_app(args)
|
app = create_app(args)
|
||||||
|
|
|
@ -96,7 +96,7 @@ h3.header {
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete-text:focus,
|
.btn-delete-text:focus,
|
||||||
.btn-copy-translated:focus {
|
.btn-action:focus {
|
||||||
background: none !important;
|
background: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,26 +107,35 @@ h3.header {
|
||||||
color: #777;
|
color: #777;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.actions {
|
||||||
.btn-copy-translated {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 2.75rem;
|
right: 1.25rem;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: #777;
|
color: #777;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: -1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-copy-translated span {
|
.btn-blue {
|
||||||
|
color: #42A5F5;
|
||||||
|
}
|
||||||
|
.btn-action:disabled {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action span {
|
||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-copy-translated .material-icons {
|
.btn-action .material-icons {
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
31
app/suggestions.py
Normal file
31
app/suggestions.py
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from expiringdict import ExpiringDict
|
||||||
|
|
||||||
|
DEFAULT_DB_PATH = "suggestions.db"
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_path=DEFAULT_DB_PATH, max_cache_len=1000, max_cache_age=30):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.cache = ExpiringDict(max_len=max_cache_len, max_age_seconds=max_cache_age)
|
||||||
|
|
||||||
|
# Make sure to do data synchronization on writes!
|
||||||
|
self.c = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
|
self.c.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS suggestions (
|
||||||
|
"q" TEXT NOT NULL,
|
||||||
|
"s" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"target" TEXT NOT NULL
|
||||||
|
);"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def add(self, q, s, source, target):
|
||||||
|
self.c.execute(
|
||||||
|
"INSERT INTO suggestions (q, s, source, target) VALUES (?, ?, ?, ?)",
|
||||||
|
(q, s, source, target),
|
||||||
|
)
|
||||||
|
self.c.commit()
|
||||||
|
return True
|
|
@ -126,7 +126,7 @@
|
||||||
<template v-for="option in langs">
|
<template v-for="option in langs">
|
||||||
<option v-if="option.code !== 'auto'" :value="option.code">[[ option.name ]]</option>
|
<option v-if="option.code !== 'auto'" :value="option.code">[[ option.name ]]</option>
|
||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -134,11 +134,11 @@
|
||||||
<div class="input-field textarea-container col s6">
|
<div class="input-field textarea-container col s6">
|
||||||
<label for="textarea1" class="sr-only">
|
<label for="textarea1" class="sr-only">
|
||||||
Text to translate
|
Text to translate
|
||||||
</label>
|
</label>
|
||||||
<textarea id="textarea1" v-model="inputText" @input="handleInput" ref="inputTextarea" dir="auto"></textarea>
|
<textarea id="textarea1" v-model="inputText" @input="handleInput" ref="inputTextarea" dir="auto"></textarea>
|
||||||
<button class="btn-delete-text" title="Delete text" @click="deleteText">
|
<button class="btn-delete-text" title="Delete text" @click="deleteText">
|
||||||
<i class="material-icons">close</i>
|
<i class="material-icons">close</i>
|
||||||
</button>
|
</button>
|
||||||
<div class="characters-limit-container" v-if="charactersLimit !== -1">
|
<div class="characters-limit-container" v-if="charactersLimit !== -1">
|
||||||
<label>[[ inputText.length ]] / [[ charactersLimit ]]</label>
|
<label>[[ inputText.length ]] / [[ charactersLimit ]]</label>
|
||||||
</div>
|
</div>
|
||||||
|
@ -147,15 +147,26 @@
|
||||||
<div class="input-field textarea-container col s6">
|
<div class="input-field textarea-container col s6">
|
||||||
<label for="textarea2" class="sr-only">
|
<label for="textarea2" class="sr-only">
|
||||||
Translated text
|
Translated text
|
||||||
</label>
|
</label>
|
||||||
<textarea id="textarea2" v-model="translatedText" ref="translatedTextarea" dir="auto" readonly></textarea>
|
<textarea id="textarea2" v-model="translatedText" ref="translatedTextarea" dir="auto" v-bind:readonly="suggestions && !isSuggesting"></textarea>
|
||||||
<button class="btn-copy-translated" @click="copyText">
|
<div class="actions">
|
||||||
<span>[[ copyTextLabel ]]</span> <i class="material-icons">content_copy</i>
|
<button v-if="suggestions && !loadingTranslation && inputText.length && !isSuggesting" class="btn-action" @click="suggestTranslation">
|
||||||
</button>
|
<i class="material-icons">edit</i>
|
||||||
<div class="position-relative">
|
</button>
|
||||||
|
<button v-if="suggestions && !loadingTranslation && inputText.length && isSuggesting" class="btn-action btn-blue" @click="closeSuggestTranslation">
|
||||||
|
<span>Cancel</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="suggestions && !loadingTranslation && inputText.length && isSuggesting" :disabled="!canSendSuggestion" class="btn-action btn-blue" @click="sendSuggestion">
|
||||||
|
<span>Send</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="!isSuggesting" class="btn-action btn-copy-translated" @click="copyText">
|
||||||
|
<span>[[ copyTextLabel ]]</span> <i class="material-icons">content_copy</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="position-relative">
|
||||||
<div class="progress translate" v-if="loadingTranslation">
|
<div class="progress translate" v-if="loadingTranslation">
|
||||||
<div class="indeterminate"></div>
|
<div class="indeterminate"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -261,11 +272,15 @@
|
||||||
loadingTranslation: false,
|
loadingTranslation: false,
|
||||||
inputText: "",
|
inputText: "",
|
||||||
inputTextareaHeight: 250,
|
inputTextareaHeight: 250,
|
||||||
translatedText: "",
|
savedTanslatedText: "",
|
||||||
|
translatedText: "",
|
||||||
output: "",
|
output: "",
|
||||||
charactersLimit: -1,
|
charactersLimit: -1,
|
||||||
|
|
||||||
copyTextLabel: "Copy text"
|
copyTextLabel: "Copy text",
|
||||||
|
|
||||||
|
suggestions: false,
|
||||||
|
isSuggesting: false,
|
||||||
},
|
},
|
||||||
mounted: function(){
|
mounted: function(){
|
||||||
var self = this;
|
var self = this;
|
||||||
|
@ -279,7 +294,8 @@
|
||||||
self.sourceLang = self.settings.language.source.code;
|
self.sourceLang = self.settings.language.source.code;
|
||||||
self.targetLang = self.settings.language.target.code;
|
self.targetLang = self.settings.language.target.code;
|
||||||
self.charactersLimit = self.settings.charLimit;
|
self.charactersLimit = self.settings.charLimit;
|
||||||
}else {
|
self.suggestions = self.settings.suggestions;
|
||||||
|
}else {
|
||||||
self.error = "Cannot load /frontend/settings";
|
self.error = "Cannot load /frontend/settings";
|
||||||
self.loading = false;
|
self.loading = false;
|
||||||
}
|
}
|
||||||
|
@ -335,7 +351,7 @@
|
||||||
if (this.charactersLimit !== -1 && this.inputText.length >= this.charactersLimit){
|
if (this.charactersLimit !== -1 && this.inputText.length >= this.charactersLimit){
|
||||||
this.inputText = this.inputText.substring(0, this.charactersLimit);
|
this.inputText = this.inputText.substring(0, this.charactersLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update "selected" attribute (to overcome a vue.js limitation)
|
// Update "selected" attribute (to overcome a vue.js limitation)
|
||||||
// but properly display checkmarks on supported browsers.
|
// but properly display checkmarks on supported browsers.
|
||||||
// Also change the <select> width value depending on the <option> length
|
// Also change the <select> width value depending on the <option> length
|
||||||
|
@ -344,7 +360,7 @@
|
||||||
if (el.value === this.sourceLang){
|
if (el.value === this.sourceLang){
|
||||||
el.setAttribute('selected', '');
|
el.setAttribute('selected', '');
|
||||||
this.$refs.sourceLangDropdown.style.width = getTextWidth(el.text) + 24 + 'px';
|
this.$refs.sourceLangDropdown.style.width = getTextWidth(el.text) + 24 + 'px';
|
||||||
}else{
|
}else{
|
||||||
el.removeAttribute('selected');
|
el.removeAttribute('selected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -355,9 +371,9 @@
|
||||||
this.$refs.targetLangDropdown.style.width = getTextWidth(el.text) + 24 + 'px';
|
this.$refs.targetLangDropdown.style.width = getTextWidth(el.text) + 24 + 'px';
|
||||||
}else{
|
}else{
|
||||||
el.removeAttribute('selected');
|
el.removeAttribute('selected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
requestCode: function(){
|
requestCode: function(){
|
||||||
|
@ -377,8 +393,11 @@
|
||||||
|
|
||||||
isHtml: function(){
|
isHtml: function(){
|
||||||
return htmlRegex.test(this.inputText);
|
return htmlRegex.test(this.inputText);
|
||||||
}
|
},
|
||||||
},
|
canSendSuggestion() {
|
||||||
|
return this.translatedText.trim() !== "" && this.translatedText !== this.savedTanslatedText;
|
||||||
|
}
|
||||||
|
},
|
||||||
filters: {
|
filters: {
|
||||||
escape: function(v){
|
escape: function(v){
|
||||||
return v.replace('"', '\\\"');
|
return v.replace('"', '\\\"');
|
||||||
|
@ -394,7 +413,9 @@
|
||||||
this.transRequest = null;
|
this.transRequest = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
swapLangs: function(){
|
swapLangs: function(e){
|
||||||
|
this.closeSuggestTranslation(e)
|
||||||
|
|
||||||
var t = this.sourceLang;
|
var t = this.sourceLang;
|
||||||
this.sourceLang = this.targetLang;
|
this.sourceLang = this.targetLang;
|
||||||
this.targetLang = t;
|
this.targetLang = t;
|
||||||
|
@ -406,6 +427,8 @@
|
||||||
this.error = '';
|
this.error = '';
|
||||||
},
|
},
|
||||||
handleInput: function(e){
|
handleInput: function(e){
|
||||||
|
this.closeSuggestTranslation(e)
|
||||||
|
|
||||||
if (this.timeout) clearTimeout(this.timeout);
|
if (this.timeout) clearTimeout(this.timeout);
|
||||||
this.timeout = null;
|
this.timeout = null;
|
||||||
|
|
||||||
|
@ -474,6 +497,56 @@
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
suggestTranslation: function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.savedTanslatedText = this.translatedText
|
||||||
|
|
||||||
|
this.isSuggesting = true;
|
||||||
|
},
|
||||||
|
closeSuggestTranslation: function(e) {
|
||||||
|
this.translatedText = this.savedTanslatedText
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
request.open('POST', BaseUrl + '/suggest', true);
|
||||||
|
request.onload = function() {
|
||||||
|
try{
|
||||||
|
var res = JSON.parse(this.response);
|
||||||
|
if (res.success){
|
||||||
|
M.toast({html: 'Thanks for your correction.'})
|
||||||
|
self.closeSuggestTranslation(e)
|
||||||
|
}else{
|
||||||
|
throw new Error(res.error || "Unknown error");
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
self.error = e.message;
|
||||||
|
self.closeSuggestTranslation(e)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = function() {
|
||||||
|
self.error = "Error while calling /suggest";
|
||||||
|
self.loadingTranslation = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
request.send(data);
|
||||||
|
},
|
||||||
deleteText: function(e){
|
deleteText: function(e){
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.inputText = this.translatedText = this.output = "";
|
this.inputText = this.translatedText = this.output = "";
|
||||||
|
@ -489,7 +562,7 @@
|
||||||
ctx.font = 'bold 16px sans-serif';
|
ctx.font = 'bold 16px sans-serif';
|
||||||
var textWidth = Math.ceil(ctx.measureText(text).width);
|
var textWidth = Math.ceil(ctx.measureText(text).width);
|
||||||
return textWidth;
|
return textWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setApiKey(){
|
function setApiKey(){
|
||||||
var prevKey = localStorage.getItem("api_key") || "";
|
var prevKey = localStorage.getItem("api_key") || "";
|
||||||
|
@ -499,7 +572,7 @@
|
||||||
|
|
||||||
localStorage.setItem("api_key", newKey);
|
localStorage.setItem("api_key", newKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// @license-end
|
// @license-end
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
Loading…
Reference in a new issue