Merge pull request #151 from dingedi/main

[WIP] Suggesting corrections to translations
This commit is contained in:
Piero Toffanin 2021-10-09 10:11:11 -04:00 committed by GitHub
commit c1da77fbeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 225 additions and 30 deletions

1
.gitignore vendored
View file

@ -131,3 +131,4 @@ installed_models/
# Misc # Misc
api_keys.db api_keys.db
suggestions.db

View file

@ -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")

View file

@ -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'
} }
] ]

View file

@ -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)

View file

@ -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
View 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

View file

@ -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>