diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 8edc8f3..87b2dfd --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ CMakeLists.txt.user* *.exe CMakeLists.txt.usr +# CMake build directories +build/ +cmake-build-*/ \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt old mode 100644 new mode 100755 index 10dd071..38f5c75 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.5) +cmake_minimum_required(VERSION 3.10) project(RenpyTranslationHelper VERSION 0.1 LANGUAGES CXX) @@ -9,6 +9,11 @@ set(CMAKE_AUTORCC ON) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) +# C++ ABI-Kompatibilität mit Qt6 erzwingen (alte ABI verwenden) +# Qt6 wurde mit einer älteren GCC-Version kompiliert, die die alte ABI verwendet +# Dies muss VOR find_package(QT) gesetzt werden +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D_GLIBCXX_USE_CXX11_ABI=0") + #target_include_directories(RenpyTranslationHelper PRIVATE /usr/include/qt6) find_package(QT NAMES Qt6 REQUIRED COMPONENTS Widgets Network) diff --git a/RenpyTranslationHelper_en_GB.ts b/RenpyTranslationHelper_en_GB.ts old mode 100644 new mode 100755 diff --git a/main.cpp b/main.cpp old mode 100644 new mode 100755 diff --git a/mainwindow.cpp b/mainwindow.cpp old mode 100644 new mode 100755 index ae2ed2f..e6ffc89 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -18,6 +18,115 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class OriginalWordReplacementEdit final : public QPlainTextEdit +{ +public: + explicit OriginalWordReplacementEdit(const QString &originalText, QWidget *parent = nullptr) + : QPlainTextEdit(parent), m_originalText(originalText) + { + setMinimumHeight(72); + setWordWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + } + +protected: + void contextMenuEvent(QContextMenuEvent *event) override + { + QTextCursor wordCursor = cursorForPosition(event->pos()); + wordCursor.select(QTextCursor::WordUnderCursor); + if (!wordCursor.hasSelection()) { + QPlainTextEdit::contextMenuEvent(event); + return; + } + + const int wordStart = wordCursor.selectionStart(); + const int wordEnd = wordCursor.selectionEnd(); + QMenu *menu = createStandardContextMenu(); + QMenu *replacementMenu = menu->addMenu(tr("Durch Wort aus Original ersetzen")); + QSet seenWords; + const QRegularExpression wordExpression(QStringLiteral("[\\p{L}\\p{N}_'-]+")); + QRegularExpressionMatchIterator matches = wordExpression.globalMatch(m_originalText); + while (matches.hasNext()) { + const QString originalWord = matches.next().captured(0); + if (seenWords.contains(originalWord)) { + continue; + } + seenWords.insert(originalWord); + QAction *action = replacementMenu->addAction(originalWord); + connect(action, &QAction::triggered, this, [this, wordStart, wordEnd, originalWord] { + QTextCursor replacementCursor = textCursor(); + replacementCursor.setPosition(wordStart); + replacementCursor.setPosition(wordEnd, QTextCursor::KeepAnchor); + replacementCursor.insertText(originalWord); + setTextCursor(replacementCursor); + }); + } + if (replacementMenu->isEmpty()) { + replacementMenu->setEnabled(false); + } + menu->exec(event->globalPos()); + delete menu; + } + +private: + QString m_originalText; +}; + +QString protectBulkPlaceholders(const QString &input, QVector &placeholders) +{ + placeholders.clear(); + const QRegularExpression expression(QStringLiteral("\\{[^}]+\\}")); + QRegularExpressionMatchIterator matches = expression.globalMatch(input); + if (!matches.hasNext()) { + return input; + } + + QString protectedText; + int previousEnd = 0; + while (matches.hasNext()) { + const QRegularExpressionMatch match = matches.next(); + protectedText += input.mid(previousEnd, match.capturedStart() - previousEnd); + placeholders.append(match.captured(0)); + protectedText += QStringLiteral("").arg(placeholders.size() - 1); + previousEnd = match.capturedEnd(); + } + return protectedText + input.mid(previousEnd); +} + +QString restoreBulkPlaceholders(const QString &input, const QVector &placeholders) +{ + const QRegularExpression expression(QStringLiteral("")); + QRegularExpressionMatchIterator matches = expression.globalMatch(input); + if (!matches.hasNext()) { + return input; + } + + QString restoredText; + int previousEnd = 0; + while (matches.hasNext()) { + const QRegularExpressionMatch match = matches.next(); + restoredText += input.mid(previousEnd, match.capturedStart() - previousEnd); + bool validId = false; + const int id = match.captured(1).toInt(&validId); + restoredText += validId && id >= 0 && id < placeholders.size() + ? placeholders.at(id) + : match.captured(0); + previousEnd = match.capturedEnd(); + } + return restoredText + input.mid(previousEnd); +} + +} // namespace MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) @@ -516,8 +625,6 @@ void MainWindow::on_deeplTranslateFrom_currentTextChanged(const QString &sourceL } } -#include - void MainWindow::on_autoTranslateButton_clicked() { if (ui->deeplApiKey->text().isEmpty()) { QMessageBox::information(this, "Deepl error", "For auto translation, you need to add a deepl API key."); @@ -527,9 +634,15 @@ void MainWindow::on_autoTranslateButton_clicked() { QString sourceLang = ui->deeplTranslateFrom->currentText(); QString targetLang = ui->deeplTranslateTo->currentText(); QJsonObject jsonData; - jsonData["text"] = QJsonArray::fromStringList({originalText}); + // 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"); QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); @@ -559,6 +672,176 @@ void MainWindow::on_autoTranslateButton_clicked() { manager->post(request, QJsonDocument(jsonData).toJson()); } +void MainWindow::on_bulkAutoTranslateButton_clicked() +{ + if (ui->deeplApiKey->text().isEmpty()) { + QMessageBox::information(this, tr("DeepL error"), tr("Für die automatische Übersetzung wird ein DeepL-API-Key benötigt.")); + return; + } + if (ui->deeplTranslateFrom->currentText().isEmpty() || ui->deeplTranslateTo->currentText().isEmpty()) { + QMessageBox::information(this, tr("DeepL error"), tr("Bitte wähle Quell- und Zielsprache aus.")); + return; + } + + bulkTranslations.clear(); + for (int fileIndex = 0; fileIndex < ui->treeWidget->topLevelItemCount(); ++fileIndex) { + QTreeWidgetItem *fileItem = ui->treeWidget->topLevelItem(fileIndex); + for (int itemIndex = 0; itemIndex < fileItem->childCount(); ++itemIndex) { + QTreeWidgetItem *item = fileItem->child(itemIndex); + if (!item->text(2).isEmpty() && item->text(3).isEmpty()) { + BulkTranslationItem translation{item, item->text(2), QString(), {}}; + bulkTranslations.append(std::move(translation)); + } + } + } + + if (bulkTranslations.isEmpty()) { + QMessageBox::information(this, tr("Information"), tr("Es gibt keine offenen Texte zum Übersetzen.")); + return; + } + + nextBulkTranslationIndex = 0; + ui->bulkAutoTranslateButton->setEnabled(false); + ui->statusbar->showMessage(tr("Übersetze %1 offene Texte …").arg(bulkTranslations.size())); + requestNextBulkTranslationBatch(); +} + +void MainWindow::requestNextBulkTranslationBatch() +{ + if (nextBulkTranslationIndex >= bulkTranslations.size()) { + ui->bulkAutoTranslateButton->setEnabled(true); + ui->statusbar->clearMessage(); + showBulkTranslationDialog(); + return; + } + + constexpr int maximumTextsPerRequest = 50; + constexpr int maximumCharactersPerRequest = 100000; + const int firstItem = nextBulkTranslationIndex; + QStringList texts; + int characterCount = 0; + while (nextBulkTranslationIndex < bulkTranslations.size() && texts.size() < maximumTextsPerRequest) { + BulkTranslationItem &item = bulkTranslations[nextBulkTranslationIndex]; + const QString protectedText = protectBulkPlaceholders(item.originalText, item.placeholders); + if (!texts.isEmpty() && characterCount + protectedText.size() > maximumCharactersPerRequest) { + break; + } + texts.append(protectedText); + characterCount += protectedText.size(); + ++nextBulkTranslationIndex; + } + + 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"; + + QNetworkRequest request(QUrl("https://api-free.deepl.com/v2/translate")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + 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) { + handleBulkTranslationReply(reply, firstItem, itemCount); + reply->deleteLater(); + manager->deleteLater(); + }); + manager->post(request, QJsonDocument(payload).toJson()); +} + +void MainWindow::handleBulkTranslationReply(QNetworkReply *reply, int firstItem, int itemCount) +{ + 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())); + bulkTranslations.clear(); + return; + } + + const QJsonDocument response = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray translations = response.object().value("translations").toArray(); + if (translations.size() != itemCount) { + ui->bulkAutoTranslateButton->setEnabled(true); + ui->statusbar->clearMessage(); + QMessageBox::critical(this, tr("DeepL error"), tr("DeepL hat eine unvollständige Antwort für die Sammelübersetzung geliefert.")); + bulkTranslations.clear(); + return; + } + + for (int index = 0; index < translations.size(); ++index) { + QString translatedText = translations.at(index).toObject().value("text").toString(); + BulkTranslationItem &item = bulkTranslations[firstItem + index]; + item.translatedText = restoreBulkPlaceholders(translatedText, item.placeholders); + item.translatedText.replace('"', '\''); + } + ui->statusbar->showMessage(tr("Übersetzt: %1 von %2 …").arg(nextBulkTranslationIndex).arg(bulkTranslations.size())); + requestNextBulkTranslationBatch(); +} + +void MainWindow::showBulkTranslationDialog() +{ + QDialog dialog(this); + dialog.setWindowTitle(tr("Automatische Übersetzungen prüfen")); + dialog.resize(900, 700); + + QVBoxLayout *layout = new QVBoxLayout(&dialog); + QLabel *description = new QLabel(tr("Prüfe und bearbeite die Vorschläge vor dem Übernehmen. Mit Rechtsklick auf ein Wort in einer Übersetzung kannst du es durch ein Wort aus dem Original ersetzen."), &dialog); + description->setWordWrap(true); + layout->addWidget(description); + + QScrollArea *scrollArea = new QScrollArea(&dialog); + scrollArea->setWidgetResizable(true); + QWidget *content = new QWidget(scrollArea); + QVBoxLayout *contentLayout = new QVBoxLayout(content); + QVector edits; + edits.reserve(bulkTranslations.size()); + for (const BulkTranslationItem &item : std::as_const(bulkTranslations)) { + QLabel *originalLabel = new QLabel(item.originalText, content); + originalLabel->setWordWrap(true); + originalLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + originalLabel->setStyleSheet(QStringLiteral("font-weight: 600; padding-top: 10px;")); + contentLayout->addWidget(originalLabel); + + auto *translationEdit = new OriginalWordReplacementEdit(item.originalText, content); + translationEdit->setPlainText(item.translatedText); + translationEdit->setPlaceholderText(tr("Übersetzung")); + contentLayout->addWidget(translationEdit); + edits.append(translationEdit); + } + contentLayout->addStretch(); + scrollArea->setWidget(content); + layout->addWidget(scrollArea); + + auto applyTranslations = [this, &edits] { + for (int index = 0; index < bulkTranslations.size(); ++index) { + QTreeWidgetItem *item = bulkTranslations[index].item; + item->setText(3, edits[index]->toPlainText()); + if (QTreeWidgetItem *parent = item->parent()) { + parent->setData(0, Qt::UserRole, true); + } + } + countAndShowUntranslated(); + }; + + QDialogButtonBox *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Apply | QDialogButtonBox::Cancel, &dialog); + buttons->button(QDialogButtonBox::Save)->setText(tr("Zwischenspeichern")); + buttons->button(QDialogButtonBox::Apply)->setText(tr("Übernehmen und schließen")); + connect(buttons->button(QDialogButtonBox::Save), &QPushButton::clicked, &dialog, applyTranslations); + connect(buttons->button(QDialogButtonBox::Apply), &QPushButton::clicked, &dialog, [applyTranslations, &dialog] { + applyTranslations(); + dialog.accept(); + }); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + + dialog.exec(); + bulkTranslations.clear(); +} + void MainWindow::translationRequestFinished(QNetworkReply *reply) { if (reply->error() == QNetworkReply::NoError) { QByteArray responseData = reply->readAll(); @@ -569,8 +852,10 @@ void MainWindow::translationRequestFinished(QNetworkReply *reply) { if (!translationsArray.isEmpty()) { QJsonObject translationObject = translationsArray[0].toObject(); QString translatedText = translationObject["text"].toString(); - translatedText.replace("\"", "'"); - ui->translationEdit->setText(translatedText); + // Restore any placeholders that were protected before the request + QString restored = restorePlaceholdersFromDeepl(translatedText); + restored.replace("\"", "'"); + ui->translationEdit->setText(restored); } } } else { @@ -579,6 +864,55 @@ void MainWindow::translationRequestFinished(QNetworkReply *reply) { reply->deleteLater(); } +QString MainWindow::protectPlaceholdersForDeepl(const QString &input) { + deeplPlaceholderMap.clear(); + QRegularExpression rx(R"(\{[^}]+\})"); + QRegularExpressionMatchIterator it = rx.globalMatch(input); + if (!it.hasNext()) return input; + QString result; + int lastPos = 0; + int idx = 0; + while (it.hasNext()) { + QRegularExpressionMatch m = it.next(); + int start = m.capturedStart(); + int len = m.capturedLength(); + result += input.mid(lastPos, start - lastPos); + deeplPlaceholderMap.append(m.captured(0)); + result += QString("").arg(idx); + idx++; + lastPos = start + len; + } + result += input.mid(lastPos); + return result; +} + +QString MainWindow::restorePlaceholdersFromDeepl(const QString &input) { + if (deeplPlaceholderMap.isEmpty()) return input; + QRegularExpression rx(R"()"); + QRegularExpressionMatchIterator it = rx.globalMatch(input); + if (!it.hasNext()) return input; + QString result; + int lastPos = 0; + while (it.hasNext()) { + QRegularExpressionMatch m = it.next(); + int start = m.capturedStart(); + int len = m.capturedLength(); + result += input.mid(lastPos, start - lastPos); + bool ok; + int id = m.captured(1).toInt(&ok); + if (ok && id >= 0 && id < deeplPlaceholderMap.size()) { + result += deeplPlaceholderMap[id]; + } else { + result += m.captured(0); // fallback: leave tag as-is + } + lastPos = start + len; + } + result += input.mid(lastPos); + // clear mapping after restore to avoid stale values + deeplPlaceholderMap.clear(); + return result; +} + void MainWindow::countAndShowUntranslated() { int untranslatedCount = 0; std::function countUntranslated = [&](QTreeWidgetItem *parentItem) { @@ -663,4 +997,3 @@ void MainWindow::on_deeplApiKey_editingFinished() void MainWindow::on_copyButton_clicked() { QGuiApplication::clipboard()->setText(ui->originalTextEdit->text()); } - diff --git a/mainwindow.h b/mainwindow.h old mode 100644 new mode 100755 index 40a210c..324b241 --- a/mainwindow.h +++ b/mainwindow.h @@ -41,6 +41,7 @@ private slots: void onDeeplTranslationPossibilitiesLoaded(QNetworkReply *reply); void on_deeplTranslateFrom_currentTextChanged(const QString &sourceLanguage); void on_autoTranslateButton_clicked(); + void on_bulkAutoTranslateButton_clicked(); void on_searchButton_clicked(); void on_searchNextButton_clicked(); void on_deeplApiKey_editingFinished(); @@ -58,6 +59,12 @@ private: QString oldText; QString newText; }; + struct BulkTranslationItem { + QTreeWidgetItem *item; + QString originalText; + QString translatedText; + QVector placeholders; + }; std::unique_ptr networkManager; Ui::MainWindow *ui; QJsonObject configuration; @@ -76,10 +83,19 @@ private: void loadDeeplTranslationPossibilities(); void renderDeeplSources(); void translationRequestFinished(QNetworkReply *reply); + void requestNextBulkTranslationBatch(); + void handleBulkTranslationReply(QNetworkReply *reply, int firstItem, int itemCount); + void showBulkTranslationDialog(); void onDeeplTranslationAuthenticationError(QNetworkReply *reply, QAuthenticator *authenticator); void countAndShowUntranslated(); void searchNext(); QVector parseTextBlock(const QString &block); void setConfigValue(const QString &key, const QString &value); + // DeepL placeholder protection: maps tag id -> original placeholder text + QVector deeplPlaceholderMap; + QString protectPlaceholdersForDeepl(const QString &input); + QString restorePlaceholdersFromDeepl(const QString &input); + QVector bulkTranslations; + int nextBulkTranslationIndex{0}; }; #endif // MAINWINDOW_H diff --git a/mainwindow.ui b/mainwindow.ui old mode 100644 new mode 100755 index 0bded77..066a3ba --- a/mainwindow.ui +++ b/mainwindow.ui @@ -198,6 +198,16 @@ + + + + Translate all untranslated texts and review the suggestions before applying them. + + + Translate all open texts + + +