diff --git a/docs/libretranslate.md b/docs/libretranslate.md new file mode 100644 index 0000000..97a0582 --- /dev/null +++ b/docs/libretranslate.md @@ -0,0 +1,77 @@ +# Lokaler LibreTranslate-Server + +LibreTranslate stellt eine HTTP-API auf dem eigenen Rechner bereit. Es werden keine +Texte an einen externen Übersetzungsdienst gesendet. Die Standardadresse ist +`http://localhost:5000`. + +## Installation unter Linux + +Voraussetzung: Python 3.8 oder neuer. In einem Terminal: + +```bash +python3 -m venv ~/.local/share/libretranslate-venv +~/.local/share/libretranslate-venv/bin/pip install --upgrade pip libretranslate +~/.local/share/libretranslate-venv/bin/libretranslate +``` + +Beim ersten Start werden Sprachmodelle geladen. Für weniger Speicherbedarf kann man +nur benötigte Sprachen laden, beispielsweise Englisch und Deutsch: + +```bash +~/.local/share/libretranslate-venv/bin/libretranslate --load-only en,de +``` + +Der Server bleibt im Terminal aktiv. Anschließend in Renpy Translation Helper im +Auswahlfeld **Provider** `LibreTranslate (local)` wählen. Die voreingestellte lokale +URL `http://localhost:5000` übernehmen oder anpassen. Die verfügbaren Sprachpaare +werden automatisch geladen. + +## Nur Englisch und Deutsch nachinstallieren + +Wurde ein erster Start ohne `--load-only` abgebrochen, können bereits einige +unpassende Modelle installiert sein. Den Server mit `Strg+C` beenden und die zwei +benötigten Richtungen explizit installieren: + +```bash +~/.local/share/libretranslate-venv/bin/argospm update +~/.local/share/libretranslate-venv/bin/argospm install translate-en_de +~/.local/share/libretranslate-venv/bin/argospm install translate-de_en +~/.local/share/libretranslate-venv/bin/libretranslate --load-only en,de +``` + +Mit `curl http://127.0.0.1:5000/languages` prüfen, ob sowohl `en` mit Ziel `de` +als auch `de` mit Ziel `en` aufgelistet wird. Erst dann kann die App diese +Sprachrichtung anbieten. + +## Nutzung durch andere Anwendungen + +Sprachen abfragen: + +```bash +curl http://localhost:5000/languages +``` + +Einen Text übersetzen: + +```bash +curl -X POST http://localhost:5000/translate \ + -H 'Content-Type: application/json' \ + -d '{"q":"Hello world","source":"en","target":"de","format":"text"}' +``` + +Für mehrere Texte kann `q` ein Array sein: + +```bash +curl -X POST http://localhost:5000/translate \ + -H 'Content-Type: application/json' \ + -d '{"q":["Hello","Good night"],"source":"en","target":"de","format":"text"}' +``` + +Die Antwort enthält `translatedText` – bei mehreren Eingaben ebenfalls als Array. + +## Hinweis zu AMD-Grafikkarten + +LibreTranslate/Argos Translate verwendet regulär die CPU. Die offiziell dokumentierte +GPU-Beschleunigung setzt CUDA voraus und ist damit für Nvidia vorgesehen. Eine +AMD-GPU wird in diesem Standard-Setup nicht genutzt; für normale Ren'Py-Dialoge ist +der CPU-Betrieb dennoch brauchbar. diff --git a/mainwindow.cpp b/mainwindow.cpp index 06eeb94..e96add0 100755 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -154,6 +155,13 @@ MainWindow::MainWindow(QWidget *parent) ui->deeplApiKey->setText(configuration["deepl-key"].toString()); loadDeeplTranslationPossibilities(); } + if (configuration.contains("libretranslate-url")) { + ui->libreTranslateUrl->setText(configuration["libretranslate-url"].toString()); + } + if (configuration.contains("translation-provider")) { + ui->translationProviderCombo->setCurrentText(configuration["translation-provider"].toString()); + } + on_translationProviderCombo_currentIndexChanged(ui->translationProviderCombo->currentIndex()); if (configuration.contains("last-dir") && (QDir()).exists(configuration["last-dir"].toString())) { ui->projectDir->setText(configuration["last-dir"].toString()); crawlProject(); @@ -164,6 +172,67 @@ MainWindow::MainWindow(QWidget *parent) noConfigChange = false; } +bool MainWindow::isLibreTranslateProvider() const +{ + return ui->translationProviderCombo->currentIndex() == 1; +} + +QString MainWindow::libreTranslateUrl(const QString &path) const +{ + return ui->libreTranslateUrl->text().trimmed().replace(QRegularExpression("/+$/"), "") + path; +} + +void MainWindow::on_translationProviderCombo_currentIndexChanged(int) +{ + const bool libreTranslate = isLibreTranslateProvider(); + ui->label_9->setVisible(!libreTranslate); + ui->deeplApiKey->setVisible(!libreTranslate); + ui->checkDeeplUsageButton->setVisible(!libreTranslate); + ui->libreTranslateUrlLabel->setVisible(libreTranslate); + ui->libreTranslateUrl->setVisible(libreTranslate); + setConfigValue("translation-provider", ui->translationProviderCombo->currentText()); + if (libreTranslate) { + loadLibreTranslateLanguages(); + } else { + translationsMap.clear(); + loadDeeplTranslationPossibilities(); + } +} + +void MainWindow::on_libreTranslateUrl_editingFinished() +{ + setConfigValue("libretranslate-url", ui->libreTranslateUrl->text().trimmed()); + if (isLibreTranslateProvider()) { + loadLibreTranslateLanguages(); + } +} + +void MainWindow::loadLibreTranslateLanguages() +{ + QNetworkAccessManager *manager = new QNetworkAccessManager(this); + connect(manager, &QNetworkAccessManager::finished, this, [this, manager](QNetworkReply *reply) { + if (!isLibreTranslateProvider()) { + // The provider changed while this request was in flight. + } else if (reply->error() != QNetworkReply::NoError) { + ui->statusbar->showMessage(tr("LibreTranslate nicht erreichbar: %1").arg(reply->errorString()), 6000); + } else { + translationsMap.clear(); + const QJsonArray languages = QJsonDocument::fromJson(reply->readAll()).array(); + for (const QJsonValue &value : languages) { + const QJsonObject language = value.toObject(); + const QString source = language.value("code").toString(); + for (const QJsonValue &target : language.value("targets").toArray()) { + translationsMap[source].push_back(target.toString()); + } + } + renderDeeplSources(); + } + reply->deleteLater(); + manager->deleteLater(); + }); + manager->get(QNetworkRequest(QUrl(libreTranslateUrl("/languages")))); +} + MainWindow::~MainWindow() { delete ui; @@ -559,6 +628,9 @@ void MainWindow::renderDeeplSources() } void MainWindow::onDeeplTranslationPossibilitiesLoaded(QNetworkReply *reply) { + if (isLibreTranslateProvider()) { + return; + } if (reply->error() == QNetworkReply::NoError) { QByteArray responseData = reply->readAll(); QJsonDocument jsonResponse = QJsonDocument::fromJson(responseData); @@ -638,7 +710,8 @@ void MainWindow::on_deeplTranslateFrom_currentTextChanged(const QString &sourceL } void MainWindow::on_autoTranslateButton_clicked() { - if (ui->deeplApiKey->text().isEmpty()) { + const bool libreTranslate = isLibreTranslateProvider(); + if (!libreTranslate && ui->deeplApiKey->text().isEmpty()) { QMessageBox::information(this, "Deepl error", "For auto translation, you need to add a deepl API key."); return; } @@ -648,21 +721,29 @@ void MainWindow::on_autoTranslateButton_clicked() { QJsonObject jsonData; // Protect Mustache-/Platzhalter vor DeepL-Übersetzung QString protectedText = protectPlaceholdersForDeepl(originalText); - jsonData["text"] = QJsonArray::fromStringList({protectedText}); - jsonData["source_lang"] = sourceLang; - jsonData["target_lang"] = targetLang; - jsonData["formality"] = "less"; - // Use XML tag handling so our placeholders are preserved - jsonData["tag_handling"] = "xml"; - jsonData["tag_handling_version"] = "v2"; - QUrl url("https://api-free.deepl.com/v2/translate"); + if (libreTranslate) { + jsonData["q"] = protectedText; + jsonData["source"] = sourceLang; + jsonData["target"] = targetLang; + jsonData["format"] = "text"; + } else { + jsonData["text"] = QJsonArray::fromStringList({protectedText}); + jsonData["source_lang"] = sourceLang; + jsonData["target_lang"] = targetLang; + jsonData["formality"] = "less"; + jsonData["tag_handling"] = "xml"; + jsonData["tag_handling_version"] = "v2"; + } + QUrl url(libreTranslate ? libreTranslateUrl("/translate") : QStringLiteral("https://api-free.deepl.com/v2/translate")); QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Authorization", "DeepL-Auth-Key " + ui->deeplApiKey->text().toUtf8()); + if (!libreTranslate) { + request.setRawHeader("Authorization", "DeepL-Auth-Key " + ui->deeplApiKey->text().toUtf8()); + } static int attempt = 1; QNetworkAccessManager *manager = new QNetworkAccessManager(this); connect(manager, &QNetworkAccessManager::finished, this, - [this, manager, jsonData, request](QNetworkReply *reply) mutable { + [this, manager, jsonData, request, libreTranslate](QNetworkReply *reply) mutable { if (reply->error() != QNetworkReply::NoError) { qDebug() << "Translation request failed:" << reply->errorString(); qDebug() << "Attempt:" << attempt; @@ -675,7 +756,7 @@ void MainWindow::on_autoTranslateButton_clicked() { } } else { attempt = 1; - translationRequestFinished(reply); + translationRequestFinished(reply, libreTranslate); } reply->deleteLater(); manager->deleteLater(); @@ -686,7 +767,8 @@ void MainWindow::on_autoTranslateButton_clicked() { void MainWindow::on_bulkAutoTranslateButton_clicked() { - if (ui->deeplApiKey->text().isEmpty()) { + bulkUsesLibreTranslate = isLibreTranslateProvider(); + if (!bulkUsesLibreTranslate && ui->deeplApiKey->text().isEmpty()) { QMessageBox::information(this, tr("DeepL error"), tr("Für die automatische Übersetzung wird ein DeepL-API-Key benötigt.")); return; } @@ -718,6 +800,54 @@ void MainWindow::on_bulkAutoTranslateButton_clicked() requestNextBulkTranslationBatch(); } +void MainWindow::on_checkDeeplUsageButton_clicked() +{ + if (ui->deeplApiKey->text().isEmpty()) { + QMessageBox::information(this, tr("DeepL error"), tr("Bitte hinterlege zuerst einen DeepL-API-Key.")); + return; + } + + ui->checkDeeplUsageButton->setEnabled(false); + ui->statusbar->showMessage(tr("DeepL-Kontingent wird geprüft …")); + QNetworkRequest request(QUrl("https://api-free.deepl.com/v2/usage")); + request.setRawHeader("Authorization", "DeepL-Auth-Key " + ui->deeplApiKey->text().toUtf8()); + QNetworkAccessManager *manager = new QNetworkAccessManager(this); + connect(manager, &QNetworkAccessManager::finished, this, [this, manager](QNetworkReply *reply) { + ui->checkDeeplUsageButton->setEnabled(true); + ui->statusbar->clearMessage(); + const QByteArray responseBody = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) { + QString details; + const QJsonDocument response = QJsonDocument::fromJson(responseBody); + if (response.isObject()) { + details = response.object().value("message").toString(); + } + QMessageBox::critical(this, tr("DeepL error"), + tr("Das DeepL-Kontingent konnte nicht abgefragt werden: %1") + .arg(details.isEmpty() ? reply->errorString() : details)); + } else { + const QJsonObject usage = QJsonDocument::fromJson(responseBody).object(); + const qint64 usedCharacters = usage.value("character_count").toVariant().toLongLong(); + const qint64 characterLimit = usage.value("character_limit").toVariant().toLongLong(); + if (characterLimit <= 0) { + QMessageBox::critical(this, tr("DeepL error"), tr("DeepL hat keine gültigen Kontingentdaten geliefert.")); + } else { + const qint64 remainingCharacters = characterLimit - usedCharacters; + const double usedPercentage = 100.0 * usedCharacters / characterLimit; + QMessageBox::information(this, tr("DeepL-Kontingent"), + tr("Verbraucht: %1 von %2 Zeichen (%3 %).\nVerfügbar: %4 Zeichen.") + .arg(QLocale().toString(usedCharacters), + QLocale().toString(characterLimit), + QLocale().toString(usedPercentage, 'f', 1), + QLocale().toString(remainingCharacters))); + } + } + reply->deleteLater(); + manager->deleteLater(); + }); + manager->get(request); +} + void MainWindow::requestNextBulkTranslationBatch() { if (nextBulkTranslationIndex >= bulkTranslations.size()) { @@ -744,16 +874,25 @@ void MainWindow::requestNextBulkTranslationBatch() } QJsonObject payload; - payload["text"] = QJsonArray::fromStringList(texts); - payload["source_lang"] = ui->deeplTranslateFrom->currentText(); - payload["target_lang"] = ui->deeplTranslateTo->currentText(); - payload["formality"] = "less"; - payload["tag_handling"] = "xml"; - payload["tag_handling_version"] = "v2"; + if (bulkUsesLibreTranslate) { + payload["q"] = QJsonArray::fromStringList(texts); + payload["source"] = ui->deeplTranslateFrom->currentText(); + payload["target"] = ui->deeplTranslateTo->currentText(); + payload["format"] = "text"; + } else { + payload["text"] = QJsonArray::fromStringList(texts); + payload["source_lang"] = ui->deeplTranslateFrom->currentText(); + payload["target_lang"] = ui->deeplTranslateTo->currentText(); + payload["formality"] = "less"; + payload["tag_handling"] = "xml"; + payload["tag_handling_version"] = "v2"; + } - QNetworkRequest request(QUrl("https://api-free.deepl.com/v2/translate")); + QNetworkRequest request(QUrl(bulkUsesLibreTranslate ? libreTranslateUrl("/translate") : QStringLiteral("https://api-free.deepl.com/v2/translate"))); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Authorization", "DeepL-Auth-Key " + ui->deeplApiKey->text().toUtf8()); + if (!bulkUsesLibreTranslate) { + request.setRawHeader("Authorization", "DeepL-Auth-Key " + ui->deeplApiKey->text().toUtf8()); + } QNetworkAccessManager *manager = new QNetworkAccessManager(this); connect(manager, &QNetworkAccessManager::finished, this, [this, manager, firstItem, itemCount = texts.size()](QNetworkReply *reply) { @@ -769,13 +908,21 @@ void MainWindow::handleBulkTranslationReply(QNetworkReply *reply, int firstItem, if (reply->error() != QNetworkReply::NoError) { ui->bulkAutoTranslateButton->setEnabled(true); ui->statusbar->clearMessage(); - QMessageBox::critical(this, tr("DeepL error"), tr("Die Sammelübersetzung ist fehlgeschlagen: %1").arg(reply->errorString())); + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QString errorMessage = httpStatus == 456 + ? tr("Das DeepL-Zeichenkontingent für diesen API-Key ist ausgeschöpft. " + "Bei API Free wird es im nächsten Abrechnungszeitraum zurückgesetzt; bei API Pro prüfe bitte das Cost-Control-Limit.") + : tr("Die Sammelübersetzung ist fehlgeschlagen: %1").arg(reply->errorString()); + QMessageBox::critical(this, tr("DeepL error"), errorMessage); bulkTranslations.clear(); return; } const QJsonDocument response = QJsonDocument::fromJson(reply->readAll()); - const QJsonArray translations = response.object().value("translations").toArray(); + const QJsonObject responseObject = response.object(); + const QJsonArray translations = bulkUsesLibreTranslate + ? responseObject.value("translatedText").toArray() + : responseObject.value("translations").toArray(); if (translations.size() != itemCount) { ui->bulkAutoTranslateButton->setEnabled(true); ui->statusbar->clearMessage(); @@ -785,7 +932,9 @@ void MainWindow::handleBulkTranslationReply(QNetworkReply *reply, int firstItem, } for (int index = 0; index < translations.size(); ++index) { - QString translatedText = translations.at(index).toObject().value("text").toString(); + QString translatedText = bulkUsesLibreTranslate + ? translations.at(index).toString() + : translations.at(index).toObject().value("text").toString(); BulkTranslationItem &item = bulkTranslations[firstItem + index]; item.translatedText = restoreBulkPlaceholders(translatedText, item.placeholders); item.translatedText.replace('"', '\''); @@ -915,16 +1064,17 @@ void MainWindow::showBulkTranslationDialog() bulkTranslations.clear(); } -void MainWindow::translationRequestFinished(QNetworkReply *reply) { +void MainWindow::translationRequestFinished(QNetworkReply *reply, bool isLibreTranslate) { if (reply->error() == QNetworkReply::NoError) { QByteArray responseData = reply->readAll(); QJsonDocument jsonResponse = QJsonDocument::fromJson(responseData); if (!jsonResponse.isNull() && jsonResponse.isObject()) { QJsonObject jsonObject = jsonResponse.object(); QJsonArray translationsArray = jsonObject["translations"].toArray(); - if (!translationsArray.isEmpty()) { - QJsonObject translationObject = translationsArray[0].toObject(); - QString translatedText = translationObject["text"].toString(); + if (isLibreTranslate || !translationsArray.isEmpty()) { + QString translatedText = isLibreTranslate + ? jsonObject["translatedText"].toString() + : translationsArray[0].toObject()["text"].toString(); // Restore any placeholders that were protected before the request QString restored = restorePlaceholdersFromDeepl(translatedText); restored.replace("\"", "'"); diff --git a/mainwindow.h b/mainwindow.h index 1140f8f..51fbe8b 100755 --- a/mainwindow.h +++ b/mainwindow.h @@ -42,6 +42,9 @@ private slots: void on_deeplTranslateFrom_currentTextChanged(const QString &sourceLanguage); void on_autoTranslateButton_clicked(); void on_bulkAutoTranslateButton_clicked(); + void on_checkDeeplUsageButton_clicked(); + void on_translationProviderCombo_currentIndexChanged(int index); + void on_libreTranslateUrl_editingFinished(); void on_searchButton_clicked(); void on_searchNextButton_clicked(); void on_deeplApiKey_editingFinished(); @@ -83,7 +86,10 @@ private: QString findNewText(QTreeWidgetItem *fileItem, const QString &lineNumber, const QString &originalText, const QString &speaker); void loadDeeplTranslationPossibilities(); void renderDeeplSources(); - void translationRequestFinished(QNetworkReply *reply); + void translationRequestFinished(QNetworkReply *reply, bool isLibreTranslate = false); + void loadLibreTranslateLanguages(); + bool isLibreTranslateProvider() const; + QString libreTranslateUrl(const QString &path) const; void requestNextBulkTranslationBatch(); void handleBulkTranslationReply(QNetworkReply *reply, int firstItem, int itemCount); void showBulkTranslationDialog(); @@ -98,5 +104,6 @@ private: QString restorePlaceholdersFromDeepl(const QString &input); QVector bulkTranslations; int nextBulkTranslationIndex{0}; + bool bulkUsesLibreTranslate{false}; }; #endif // MAINWINDOW_H diff --git a/mainwindow.ui b/mainwindow.ui old mode 100755 new mode 100644 index 066a3ba..69a2922 --- a/mainwindow.ui +++ b/mainwindow.ui @@ -2,412 +2,106 @@ MainWindow - - - 0 - 0 - 1266 - 774 - - - - Renpy Translation Helper - + 001280820 + Ren'Py Translation Helper - - - - 10 - 10 - 1233 - 732 - - - - - + + 10 + 12 + 12 + 12 + 12 + + + Projekt + + Projektordner + 4800 + Projektordner auswählen + Übersetzungen laden + Qt::Horizontal1620 + Sprache + 1800 + Dateien neu laden + + + + + + 0300 + true + true + Datei / Zeile + Sprecher + Original + Übersetzung + + + + + Übersetzung bearbeiten + - - - Project Directory - - + + Nächster offener Text + Suchen + Weiter + Qt::Horizontal4020 + - - - - 500 - 0 - - - + + Originaltext + + Kopieren + Übersetzung + + - - - - 27 - 16777215 - - - - ... - - - - - - - Reload - - - - - - - Language - - - - - - - - 200 - 0 - - - - - - - - Reload - - + + Übernehmen + Übernehmen && weiter + Übernehmen, weiter && automatisch übersetzen + Qt::Horizontal4020 + - - - - - - 1231 - 400 - - - - - File - - - - - Speaker - - - - - Original - - - - - Translation - - - - - All items - - - - - - - - - - - - - - - - - - - Jump to next untranslated item - - - - - - - - 137 - 16777215 - - - - Deepl Translate from - - - - - - - - - - - 20 - 16777215 - - - - to - - - - - - - - - - Deepl API Key - - - - - - - - 200 - 16777215 - - - - - - - - Auto translate - - - - - - - Translate all untranslated texts and review the suggestions before applying them. - - - Translate all open texts - - - + + + + + Automatische Übersetzung + + Anbieter + DeepLLibreTranslate (lokal) + Von + + Nach + + Aktuellen Text übersetzen + DeepL-API-Key + + Kontingent prüfen + Alle offenen Texte übersetzen + Lokale URL + http://localhost:5000 - - - - - - - Search - - - - - - - Next - - - - - - - - - - - Original Text: - - - - - - - true - - - - - - - Copy - - - - - - - - - - - Translation: - - - - - - - - - - - - - - Set translation - - - - - - - Set translation and jump to next untranslated - - - - - - - Set translation, jump to next untranslated and auto translate - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Enter action: - - - - - - - - 400 - 0 - - - - - Set translation, jump to next untranslated and auto translate - - - - - Set translation and jump to next untranslated - - - - - Set translation - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Untranslated: - - - - - - - 0 - - - - - - - - - - - Save translations - - - - - - - Delete backups and save translations - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Delete backups - - - - - - - + + + + + Eingabetaste + 3600Übernehmen, weiter && automatisch übersetzenÜbernehmen && weiterNur übernehmen + Qt::Horizontal4020 + Offen + 0 + Übersetzungen speichern + Backups löschen && speichern + Backups löschen + + +