mirror of
https://github.com/LibreTranslate/LibreTranslate.git
synced 2024-11-21 23:41:01 +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
|
||||
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 .api_keys import Database
|
||||
from .suggestions import Database as SuggestionsDatabase
|
||||
|
||||
from translatehtml import translate_html
|
||||
|
||||
|
@ -565,6 +566,9 @@ def create_app(args):
|
|||
frontendTimeout:
|
||||
type: integer
|
||||
description: Frontend translation timeout
|
||||
suggestions:
|
||||
type: boolean
|
||||
description: Whether submitting suggestions is enabled.
|
||||
language:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -591,6 +595,7 @@ def create_app(args):
|
|||
{
|
||||
"charLimit": args.char_limit,
|
||||
"frontendTimeout": args.frontend_timeout,
|
||||
"suggestions": args.suggestions,
|
||||
"language": {
|
||||
"source": {
|
||||
"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["info"]["version"] = "1.2"
|
||||
swag["info"]["version"] = "1.2.1"
|
||||
swag["info"]["title"] = "LibreTranslate"
|
||||
|
||||
@app.route("/spec")
|
||||
|
|
|
@ -110,6 +110,11 @@ _default_options_objects = [
|
|||
'name': 'LOAD_ONLY',
|
||||
'default_value': None,
|
||||
'value_type': 'str'
|
||||
},
|
||||
{
|
||||
'name': 'SUGGESTIONS',
|
||||
'default_value': False,
|
||||
'value_type': 'bool'
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
@ -102,6 +102,9 @@ def main():
|
|||
metavar="<comma-separated language codes>",
|
||||
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()
|
||||
app = create_app(args)
|
||||
|
|
|
@ -96,7 +96,7 @@ h3.header {
|
|||
}
|
||||
|
||||
.btn-delete-text:focus,
|
||||
.btn-copy-translated:focus {
|
||||
.btn-action:focus {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
|
@ -107,26 +107,35 @@ h3.header {
|
|||
color: #777;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-copy-translated {
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 2.75rem;
|
||||
right: 1.25rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #777;
|
||||
font-size: 0.85rem;
|
||||
background: none;
|
||||
border: none;
|
||||
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;
|
||||
}
|
||||
|
||||
.btn-copy-translated .material-icons {
|
||||
.btn-action .material-icons {
|
||||
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">
|
||||
<option v-if="option.code !== 'auto'" :value="option.code">[[ option.name ]]</option>
|
||||
</template>
|
||||
</select>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -134,11 +134,11 @@
|
|||
<div class="input-field textarea-container col s6">
|
||||
<label for="textarea1" class="sr-only">
|
||||
Text to translate
|
||||
</label>
|
||||
</label>
|
||||
<textarea id="textarea1" v-model="inputText" @input="handleInput" ref="inputTextarea" dir="auto"></textarea>
|
||||
<button class="btn-delete-text" title="Delete text" @click="deleteText">
|
||||
<i class="material-icons">close</i>
|
||||
</button>
|
||||
</button>
|
||||
<div class="characters-limit-container" v-if="charactersLimit !== -1">
|
||||
<label>[[ inputText.length ]] / [[ charactersLimit ]]</label>
|
||||
</div>
|
||||
|
@ -147,15 +147,26 @@
|
|||
<div class="input-field textarea-container col s6">
|
||||
<label for="textarea2" class="sr-only">
|
||||
Translated text
|
||||
</label>
|
||||
<textarea id="textarea2" v-model="translatedText" ref="translatedTextarea" dir="auto" readonly></textarea>
|
||||
<button class="btn-copy-translated" @click="copyText">
|
||||
<span>[[ copyTextLabel ]]</span> <i class="material-icons">content_copy</i>
|
||||
</button>
|
||||
<div class="position-relative">
|
||||
</label>
|
||||
<textarea id="textarea2" v-model="translatedText" ref="translatedTextarea" dir="auto" v-bind:readonly="suggestions && !isSuggesting"></textarea>
|
||||
<div class="actions">
|
||||
<button v-if="suggestions && !loadingTranslation && inputText.length && !isSuggesting" class="btn-action" @click="suggestTranslation">
|
||||
<i class="material-icons">edit</i>
|
||||
</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="indeterminate"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -261,11 +272,15 @@
|
|||
loadingTranslation: false,
|
||||
inputText: "",
|
||||
inputTextareaHeight: 250,
|
||||
translatedText: "",
|
||||
savedTanslatedText: "",
|
||||
translatedText: "",
|
||||
output: "",
|
||||
charactersLimit: -1,
|
||||
|
||||
copyTextLabel: "Copy text"
|
||||
copyTextLabel: "Copy text",
|
||||
|
||||
suggestions: false,
|
||||
isSuggesting: false,
|
||||
},
|
||||
mounted: function(){
|
||||
var self = this;
|
||||
|
@ -279,7 +294,8 @@
|
|||
self.sourceLang = self.settings.language.source.code;
|
||||
self.targetLang = self.settings.language.target.code;
|
||||
self.charactersLimit = self.settings.charLimit;
|
||||
}else {
|
||||
self.suggestions = self.settings.suggestions;
|
||||
}else {
|
||||
self.error = "Cannot load /frontend/settings";
|
||||
self.loading = false;
|
||||
}
|
||||
|
@ -335,7 +351,7 @@
|
|||
if (this.charactersLimit !== -1 && this.inputText.length >= this.charactersLimit){
|
||||
this.inputText = this.inputText.substring(0, this.charactersLimit);
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
|
@ -344,7 +360,7 @@
|
|||
if (el.value === this.sourceLang){
|
||||
el.setAttribute('selected', '');
|
||||
this.$refs.sourceLangDropdown.style.width = getTextWidth(el.text) + 24 + 'px';
|
||||
}else{
|
||||
}else{
|
||||
el.removeAttribute('selected');
|
||||
}
|
||||
}
|
||||
|
@ -355,9 +371,9 @@
|
|||
this.$refs.targetLangDropdown.style.width = getTextWidth(el.text) + 24 + 'px';
|
||||
}else{
|
||||
el.removeAttribute('selected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
computed: {
|
||||
requestCode: function(){
|
||||
|
@ -377,8 +393,11 @@
|
|||
|
||||
isHtml: function(){
|
||||
return htmlRegex.test(this.inputText);
|
||||
}
|
||||
},
|
||||
},
|
||||
canSendSuggestion() {
|
||||
return this.translatedText.trim() !== "" && this.translatedText !== this.savedTanslatedText;
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
escape: function(v){
|
||||
return v.replace('"', '\\\"');
|
||||
|
@ -394,7 +413,9 @@
|
|||
this.transRequest = null;
|
||||
}
|
||||
},
|
||||
swapLangs: function(){
|
||||
swapLangs: function(e){
|
||||
this.closeSuggestTranslation(e)
|
||||
|
||||
var t = this.sourceLang;
|
||||
this.sourceLang = this.targetLang;
|
||||
this.targetLang = t;
|
||||
|
@ -406,6 +427,8 @@
|
|||
this.error = '';
|
||||
},
|
||||
handleInput: function(e){
|
||||
this.closeSuggestTranslation(e)
|
||||
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
|
||||
|
@ -474,6 +497,56 @@
|
|||
}, 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){
|
||||
e.preventDefault();
|
||||
this.inputText = this.translatedText = this.output = "";
|
||||
|
@ -489,7 +562,7 @@
|
|||
ctx.font = 'bold 16px sans-serif';
|
||||
var textWidth = Math.ceil(ctx.measureText(text).width);
|
||||
return textWidth;
|
||||
}
|
||||
}
|
||||
|
||||
function setApiKey(){
|
||||
var prevKey = localStorage.getItem("api_key") || "";
|
||||
|
@ -499,7 +572,7 @@
|
|||
|
||||
localStorage.setItem("api_key", newKey);
|
||||
}
|
||||
|
||||
|
||||
// @license-end
|
||||
</script>
|
||||
</body>
|
||||
|
|
Loading…
Reference in a new issue