Files
renpy-translator/mainwindow.cpp

1757 lines
74 KiB
C++
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "mainwindow.h"
#include "./ui_mainwindow.h"
#include <utility>
#include <QFileDialog>
#include <QMessageBox>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QSaveFile>
#include <QTextStream>
#include <QDebug>
#include <QDateTime>
#include <QtNetwork/QNetworkAccessManager>
#include <QtNetwork/QNetworkReply>
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonObject>
#include <QDialogButtonBox>
#include <QInputDialog>
#include <QHeaderView>
#include <QMap>
#include <QClipboard>
#include <QCheckBox>
#include <QCryptographicHash>
#include <QApplication>
#include <QCoreApplication>
#include <QTimer>
#include <QDialog>
#include <QDialogButtonBox>
#include <QFrame>
#include <QLabel>
#include <QLocale>
#include <QMenu>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QProgressDialog>
#include <QScrollArea>
#include <QSet>
#include <QSignalBlocker>
#include <QScrollBar>
#include <QVBoxLayout>
#include <QWheelEvent>
namespace {
QString escapeTranslationForRenpy(const QString &text)
{
// The editor may contain both literal quotes and already escaped Ren'Py
// text. Preserve existing escapes and make every output string valid.
QString escaped;
int backslashes = 0;
for (const QChar character : text) {
if (character == '\\') {
escaped += character;
++backslashes;
continue;
}
if (character == '\n' || character == '\r' || character == '\t') {
if (backslashes % 2 != 0) escaped += '\\';
escaped += character == '\n' ? QStringLiteral("\\n")
: character == '\r' ? QStringLiteral("\\r") : QStringLiteral("\\t");
} else {
if (character == '"' && backslashes % 2 == 0) escaped += '\\';
escaped += character;
}
backslashes = 0;
}
if (backslashes % 2 != 0) escaped += '\\';
return escaped;
}
class TranslationChoiceComboBox final : public QComboBox
{
public:
explicit TranslationChoiceComboBox(QWidget *parent) : QComboBox(parent)
{
setFocusPolicy(Qt::StrongFocus);
}
protected:
void wheelEvent(QWheelEvent *event) override
{
// Scroll the preview instead of silently changing a translation choice.
if (view()->isVisible()) {
QComboBox::wheelEvent(event);
} else {
for (QWidget *parent = parentWidget(); parent; parent = parent->parentWidget()) {
if (auto *area = qobject_cast<QAbstractScrollArea *>(parent)) {
QWheelEvent forwarded(area->viewport()->mapFromGlobal(event->globalPosition().toPoint()),
event->globalPosition(), event->pixelDelta(), event->angleDelta(),
event->buttons(), event->modifiers(), event->phase(),
event->inverted(), event->source(), event->pointingDevice());
QCoreApplication::sendEvent(area->viewport(), &forwarded);
event->setAccepted(forwarded.isAccepted());
return;
}
}
event->ignore();
}
}
};
bool containsOnlyPlaceholders(const QString &text)
{
// Ren'Py interpolation and positional placeholders may be separated by
// punctuation, e.g. "[skill]: [desc]". Escaped "[[" is literal text.
static const QRegularExpression placeholders(
QStringLiteral("(?<!\\[)\\[(?!\\[)[^\\[\\]\\r\\n]+\\]|\\{[0-9]+\\}"));
static const QRegularExpression separatorsOnly(
QStringLiteral("\\A[\\s\\p{P}\\p{S}]*\\z"));
if (!placeholders.match(text).hasMatch()) {
return false;
}
QString remainder = text;
remainder.remove(placeholders);
return separatorsOnly.match(remainder).hasMatch();
}
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 replacementCursor = textCursor();
if (!replacementCursor.hasSelection()) {
replacementCursor = cursorForPosition(event->pos());
replacementCursor.select(QTextCursor::WordUnderCursor);
}
if (!replacementCursor.hasSelection()) {
QPlainTextEdit::contextMenuEvent(event);
return;
}
const int wordStart = replacementCursor.selectionStart();
const int wordEnd = replacementCursor.selectionEnd();
QMenu *menu = createStandardContextMenu();
QMenu *replacementMenu = menu->addMenu(tr("Durch Wort aus Original ersetzen"));
QSet<QString> 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<QString> &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("<x id=\"%1\"/>").arg(placeholders.size() - 1);
previousEnd = match.capturedEnd();
}
return protectedText + input.mid(previousEnd);
}
QString restoreBulkPlaceholders(const QString &input, const QVector<QString> &placeholders)
{
const QRegularExpression expression(QStringLiteral("<x\\s+id=\"(\\d+)\"\\s*/>"));
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)
, ui(new Ui::MainWindow)
{
noConfigChange = true;
ui->setupUi(this);
loadDeeplTranslationPossibilities();
QString configPath = QDir::homePath() + "/.renpytranslate.conf";
QFile configFile(configPath);
if (configFile.exists()) {
if (configFile.open(QIODevice::ReadOnly)) {
QByteArray data = configFile.readAll();
QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isNull()) {
configuration = doc.object();
} else {
}
configFile.close();
}
}
if (configuration.contains("deepl-key")) {
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());
// Let the main window become visible before scanning potentially large projects.
QTimer::singleShot(0, this, [this] {
const QString savedLanguage = configuration.value("last-language").toString();
QProgressDialog startupProgress(tr("Projekt wird vorbereitet …"), QString(), 0, 2, this);
startupProgress.setWindowTitle(tr("Bitte warten"));
startupProgress.setCancelButton(nullptr);
startupProgress.setMinimumDuration(0);
startupProgress.setWindowModality(Qt::ApplicationModal);
startupProgress.show();
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
crawlProject();
startupProgress.setValue(1);
startupProgress.setLabelText(tr("Letzte Sprache und Übersetzungsdateien werden geladen …"));
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
if (!savedLanguage.isEmpty() && ui->languageCombo->findText(savedLanguage) >= 0) {
const QSignalBlocker blockLanguageSelection(ui->languageCombo);
ui->languageCombo->setCurrentText(savedLanguage);
on_languageCombo_currentTextChanged(savedLanguage);
}
startupProgress.setValue(2);
});
}
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->deeplSettingsWidget->setVisible(!libreTranslate);
ui->libreSettingsWidget->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;
}
void MainWindow::on_selectProjectDirButton_clicked()
{
auto dir = configuration.contains("last-dir") && (QDir()).exists(configuration["last-dir"].toString())
? configuration["last-dir"].toString()
: QDir::homePath();
QString selectedDir = QFileDialog::getExistingDirectory(this, tr("Verzeichnis auswählen"), dir);
if (!selectedDir.isEmpty()) {
setConfigValue("last-dir", selectedDir);
ui->projectDir->setText(selectedDir);
crawlProject();
}
}
void MainWindow::crawlProject() {
QString projectDir = ui->projectDir->text();
QDir dir(projectDir);
if (!dir.exists()) {
QMessageBox::critical(this, tr("Error"), tr("The project directory does not exist!"));
return;
}
QString tlDirPath = QDir::cleanPath(projectDir + QDir::separator() + "game" + QDir::separator() + "tl");
if (!QDir(tlDirPath).exists()) {
QMessageBox::information(this, tr("Information"), tr("No translations available."));
return;
}
QStringList languageDirs = QDir(tlDirPath).entryList(QDir::Dirs | QDir::NoDotAndDotDot);
if (languageDirs.isEmpty()) {
QMessageBox::information(this, tr("Information"), tr("No translations available."));
return;
}
const QSignalBlocker blockLanguageSelection(ui->languageCombo);
ui->languageCombo->clear();
ui->languageCombo->addItems(languageDirs);
ui->languageCombo->setCurrentIndex(-1);
}
void MainWindow::on_languageCombo_currentTextChanged(const QString &selectedLanguage)
{
setConfigValue("last-language", selectedLanguage);
fileContentsMap.clear();
ui->treeWidget->clear();
if (ui->languageCombo->currentIndex() == -1) {
return;
}
QString projectDir = ui->projectDir->text();
QString languageDirPath = QDir::cleanPath(projectDir + QDir::separator() + "game" + QDir::separator() + "tl" + QDir::separator() + selectedLanguage);
QDirIterator it(languageDirPath, QStringList() << "*.rpy", QDir::Files, QDirIterator::Subdirectories);
QStringList filePaths;
while (it.hasNext()) {
filePaths.append(it.next());
}
QProgressDialog progress(tr("Übersetzungsdateien werden geladen …"), QString(), 0, filePaths.size(), this);
progress.setWindowTitle(tr("Bitte warten"));
progress.setCancelButton(nullptr);
progress.setMinimumDuration(0);
progress.setWindowModality(Qt::ApplicationModal);
progress.show();
QApplication::setOverrideCursor(Qt::WaitCursor);
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
for (int fileIndex = 0; fileIndex < filePaths.size(); ++fileIndex) {
const QString &filePath = filePaths.at(fileIndex);
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
progress.setValue(fileIndex + 1);
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
continue;
}
QTextStream in(&file);
QString fileContent = in.readAll();
file.close();
fileContentsMap[filePath] = fileContent;
progress.setValue(fileIndex + 1);
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
}
progress.setLabelText(tr("Übersetzungsliste wird vorbereitet …"));
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
populateTreeWidgetFromMap();
QApplication::restoreOverrideCursor();
}
void MainWindow::populateTreeWidgetFromMap() {
try {
ui->treeWidget->clear();
foreach (const auto &pair, fileContentsMap) {
QTreeWidgetItem *fileItem = new QTreeWidgetItem(ui->treeWidget);
QString fileName = QFileInfo(pair.first).fileName();
fileItem->setText(0, fileName);
fileItem->setData(0, Qt::UserRole, false);
// Keep the full path: identical file names can exist in different subdirectories.
fileItem->setData(0, Qt::UserRole + 1, pair.first);
auto parsedBlocks = parseTextBlock(pair.second);
for (const auto &block : std::as_const(parsedBlocks)) {
QTreeWidgetItem *blockItem = new QTreeWidgetItem(fileItem);
blockItem->setText(0, QString::number(block.line));
blockItem->setText(1, QString("%1").arg(block.character));
blockItem->setText(2, block.oldText);
blockItem->setText(3, block.newText);
}
}
ui->treeWidget->resizeColumnToContents(0);
int firstColumnWidth = ui->treeWidget->columnWidth(0);
int secondColumnWidth = ui->treeWidget->columnWidth(1);
int remainingWidth = ui->treeWidget->viewport()->width() - firstColumnWidth - secondColumnWidth;
int columnWidth = remainingWidth / 2;
ui->treeWidget->setColumnWidth(2, columnWidth);
ui->treeWidget->setColumnWidth(3, columnWidth);
countAndShowUntranslated();
} catch (const std::exception &e) {
qDebug() << e.what();
}
}
QVector<MainWindow::TranslationItem> MainWindow::parseTextBlock(const QString& input) {
QVector<TranslationItem> blocks;
QStringList lines = input.split("\n", Qt::SkipEmptyParts);
int lineNumber = 0;
QString identifier, originalText, translatedText;
for (const QString& line : std::as_const(lines)) {
static const QRegularExpression sourceLocation(QStringLiteral("^#\\s+.+:(\\d+)\\s*$"));
const auto sourceMatch = sourceLocation.match(line.trimmed());
if (sourceMatch.hasMatch()) {
lineNumber = sourceMatch.captured(1).toInt();
} else if (line.startsWith(" # ") || line.startsWith(" old ")) {
const int firstQuote = line.indexOf('"');
const int lastQuote = line.lastIndexOf('"');
originalText = firstQuote >= 0 && lastQuote > firstQuote
? line.mid(firstQuote + 1, lastQuote - firstQuote - 1)
: QString();
identifier = "";
} else if ((line.startsWith(" ") && !line.startsWith(" # ")) || line.startsWith(" new ")) {
const QString translationLine = line.startsWith(" ") ? line.mid(4) : line;
const int firstQuote = translationLine.indexOf('"');
const int lastQuote = translationLine.lastIndexOf('"');
if (firstQuote < 0 || lastQuote <= firstQuote) {
continue;
}
identifier = translationLine.startsWith("new ") || translationLine.startsWith('"')
? QString()
: translationLine.left(firstQuote).trimmed();
translatedText = translationLine.mid(firstQuote + 1, lastQuote - firstQuote - 1);
blocks.append(TranslationItem(lineNumber, identifier, originalText, translatedText));
originalText.clear();
translatedText.clear();
}
}
blocks.erase(std::remove_if(blocks.begin(), blocks.end(), [](const TranslationItem& item) {
return item.oldText.trimmed().isEmpty(); // Prüft nun, ob oldText leer oder nur aus Leerzeichen besteht
}), blocks.end());
std::sort(blocks.begin(), blocks.end(), [](const TranslationItem& a, const TranslationItem& b) {
return a.line < b.line;
});
return blocks;
}
void MainWindow::on_reloadTranslationsButton_clicked()
{
crawlProject();
}
void MainWindow::on_reloadFilesButton_clicked()
{
if (ui->languageCombo->currentIndex() < 0) {
return;
}
bool hasUnsavedChanges = false;
for (int index = 0; index < ui->treeWidget->topLevelItemCount(); ++index) {
if (ui->treeWidget->topLevelItem(index)->data(0, Qt::UserRole).toBool()) {
hasUnsavedChanges = true;
break;
}
}
if (hasUnsavedChanges && QMessageBox::warning(
this,
tr("Ungespeicherte Änderungen"),
tr("Durch das Neuladen werden ungespeicherte Änderungen verworfen. Fortfahren?"),
QMessageBox::Yes | QMessageBox::Cancel,
QMessageBox::Cancel) != QMessageBox::Yes) {
return;
}
on_languageCombo_currentTextChanged(ui->languageCombo->currentText());
}
void MainWindow::on_nextUntranslatedButton_clicked() {
QVector<QTreeWidgetItem *> items;
int currentIndex = -1;
for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) {
QTreeWidgetItem *topLevelItem = ui->treeWidget->topLevelItem(i);
for (int j = 0; j < topLevelItem->childCount(); ++j) {
QTreeWidgetItem *childItem = topLevelItem->child(j);
if (childItem == ui->treeWidget->currentItem()) {
currentIndex = items.size();
}
items.append(childItem);
}
}
// Continue after the selected row and wrap around once at the end.
for (int offset = 1; offset <= items.size(); ++offset) {
QTreeWidgetItem *item = items.at((currentIndex + offset) % items.size());
if (isOpenTranslation(item)) {
ui->treeWidget->setCurrentItem(item);
ui->originalTextEdit->setText(item->text(2));
ui->translationEdit->setText(item->text(3));
ui->treeWidget->scrollToItem(item);
on_copyButton_clicked();
return;
}
}
QMessageBox::information(this, tr("Information"), tr("No untranslated item found."));
}
void MainWindow::on_treeWidget_itemSelectionChanged() {
QTreeWidgetItem *selectedItem = ui->treeWidget->currentItem();
if (selectedItem) {
if (selectedItem->treeWidget()->indexOfTopLevelItem(selectedItem) < 0) {
ui->originalTextEdit->setText(selectedItem->text(2)); // Originaltext
ui->translationEdit->setText(selectedItem->text(3)); // Übersetzung
}
}
}
void MainWindow::on_setTranslationButton_clicked() {
QJsonObject approvals = configuration.value("approved-identical-translations").toObject();
bool approvalsChanged = false;
const auto applyTranslation = [&](QTreeWidgetItem *item) {
const QString translation = ui->translationEdit->text();
item->setText(3, translation);
// Explicitly accepting an unchanged word (e.g. "Million") confirms it.
if (!translation.trimmed().isEmpty() && translation.trimmed() == item->text(2).trimmed()) {
const QString key = identicalTranslationApprovalKey(item);
if (!approvals.value(key).toBool()) {
approvals[key] = true;
approvalsChanged = true;
}
}
};
QTreeWidgetItem *selectedItem = ui->treeWidget->currentItem();
if (selectedItem) {
if (selectedItem->treeWidget()->indexOfTopLevelItem(selectedItem) < 0) {
// Setze Übersetzung in Spalte 3
applyTranslation(selectedItem);
QTreeWidgetItem *parentItem = selectedItem->parent();
if (parentItem) {
parentItem->setData(0, Qt::UserRole, true);
for (int index = 0; index < parentItem->childCount(); ++index) {
auto item = parentItem->child(index);
// Vergleiche den Originaltext (Spalte 2)
if (item->text(2) == ui->originalTextEdit->text()) {
applyTranslation(item);
}
}
}
}
}
if (approvalsChanged) {
configuration["approved-identical-translations"] = approvals;
saveConfiguration();
}
ui->originalTextEdit->setFocus();
countAndShowUntranslated();
}
void MainWindow::on_setTranslationAndJumpNextButton_clicked()
{
on_setTranslationButton_clicked();
on_nextUntranslatedButton_clicked();
}
void MainWindow::on_setAndNextAndAutoTranslate_clicked()
{
on_setTranslationAndJumpNextButton_clicked();
on_autoTranslateButton_clicked();
}
void MainWindow::on_translationEdit_returnPressed()
{
switch (ui->enterActionCombo->currentIndex()) {
case 0:
on_setAndNextAndAutoTranslate_clicked();
break;
case 1:
on_setTranslationAndJumpNextButton_clicked();
break;
case 2:
on_setTranslationButton_clicked();
break;
}
}
void MainWindow::on_saveButton_clicked()
{
QStringList failedFiles;
saveFiles(failedFiles);
if (!failedFiles.isEmpty()) {
QString errorMessage = tr("Folgende Dateien konnten nicht gespeichert werden:\n\n");
errorMessage += failedFiles.join("\n");
QMessageBox::critical(this, tr("Fehler beim Speichern"), errorMessage);
} else {
QMessageBox::information(this, "Information", "The translations were saved succesfully.");
}
}
void MainWindow::saveFiles(QStringList &failedFiles) {
for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) {
QTreeWidgetItem *fileItem = ui->treeWidget->topLevelItem(i);
if (fileItem->data(0, Qt::UserRole).toBool()) {
QString errorMessage;
if (!saveFile(fileItem, errorMessage)) {
failedFiles << errorMessage;
}
}
}
}
bool MainWindow::saveFile(QTreeWidgetItem *fileItem, QString &errorMessage)
{
const QString projectDir = ui->projectDir->text();
const QString fileName = fileItem->text(0);
const QString filePath = fileItem->data(0, Qt::UserRole + 1).toString().isEmpty()
? QString("%1/game/tl/%2/%3").arg(projectDir, ui->languageCombo->currentText(), fileName)
: fileItem->data(0, Qt::UserRole + 1).toString();
const QString backupBase = QString("%1.bak.%2")
.arg(filePath, QDateTime::currentDateTime().toString("yyyyMMddHHmmsszzz"));
QString backupFileName = backupBase;
for (int suffix = 1; QFileInfo::exists(backupFileName); ++suffix) {
backupFileName = backupBase + QStringLiteral(".%1").arg(suffix);
}
if (!createBackup(filePath, backupFileName, errorMessage)) {
errorMessage = filePath + '\n' + errorMessage;
return false;
}
if (!editAndSaveFile(fileItem, backupFileName, filePath, errorMessage)) {
errorMessage = filePath + '\n' + errorMessage + tr("\nBackup: %1").arg(backupFileName);
return false;
}
fileItem->setData(0, Qt::UserRole, false);
return true;
}
bool MainWindow::createBackup(const QString &filePath, const QString &backupFileName, QString &errorMessage) {
QByteArray original;
QFile source(filePath);
if (source.open(QIODevice::ReadOnly)) {
original = source.readAll();
if (source.error() != QFileDevice::NoError) {
errorMessage = tr("Originaldatei konnte nicht gelesen werden: %1").arg(source.errorString());
return false;
}
} else {
const auto cached = fileContentsMap.find(filePath);
if (!QFileInfo::exists(filePath) && cached != fileContentsMap.end()) {
// Preserve the loaded source if an earlier save removed the file.
original = cached->second.toUtf8();
} else {
errorMessage = tr("Originaldatei konnte nicht geöffnet werden: %1").arg(source.errorString());
if (!QFileInfo::exists(filePath)) {
errorMessage += tr("\nDie Originaldatei fehlt; es ist auch keine geladene Kopie verfügbar.");
}
return false;
}
}
QSaveFile backup(backupFileName);
if (!backup.open(QIODevice::WriteOnly) || backup.write(original) != original.size() || !backup.commit()) {
errorMessage = tr("Backup konnte nicht erstellt werden:\n%1\nGrund: %2")
.arg(backupFileName, backup.errorString());
return false;
}
return true;
}
bool MainWindow::editAndSaveFile(QTreeWidgetItem *fileItem, const QString &backupFileName, const QString &fileName, QString &errorMessage) {
QFile backupFile(backupFileName);
if (!backupFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
errorMessage = tr("Backup konnte nicht gelesen werden: %1").arg(backupFile.errorString());
return false;
}
QStringList lines;
bool changed = false;
if (!parseAndEditFile(backupFile, fileItem, lines, changed)) {
errorMessage = tr("Die Übersetzungsdatei konnte nicht verarbeitet werden.");
return false;
}
backupFile.close();
if (!changed) {
errorMessage = tr("Keine unterstützten Übersetzungsblöcke gefunden. Die Originaldatei bleibt unverändert.");
return false;
}
if (!saveToFile(fileName, lines, errorMessage)) {
return false;
}
fileContentsMap[fileName] = lines.join('\n');
return true;
}
bool MainWindow::parseAndEditFile(QFile &backupFile, QTreeWidgetItem *fileItem, QStringList &lines, bool &changed) {
QTextStream backupStream(&backupFile);
QString content = backupStream.readAll();
static QRegularExpression translateRegex(R"rx(# ([^\r\n]+):(\d+)\ntranslate\s+[^\s]+\s+(.*?)\n\n\s*#.*?"((?:\\.|[^"\\\r\n])*)"\s*\n\s*(.*?)\s*"((?:\\.|[^"\\\r\n])*)")rx");
static QRegularExpression oldNewRegex(R"rx((?:^|\n)\s*#\s*([^\r\n]+?):(\d+?)\n\s*old\s*"((?:\\.|[^"\\\r\n])*)"\n\s*new\s*"((?:\\.|[^"\\\r\n])*)")rx");
QRegularExpressionMatch match;
int offset = 0;
while ((match = translateRegex.match(content, offset)).hasMatch()) {
auto block = match.captured(0);
auto lineNumber = match.captured(2);
auto speaker = match.captured(5);
auto oldText = match.captured(4);
auto newText = match.captured(6);
auto replacedText = findNewText(fileItem, lineNumber, oldText, speaker);
QString replacedBlock = block;
const int translatedTextOffset = match.capturedStart(6) - match.capturedStart(0);
replacedBlock.replace(translatedTextOffset, match.capturedLength(6),
replacedText.isEmpty() ? newText : escapeTranslationForRenpy(replacedText));
content.replace(match.capturedStart(0), match.capturedLength(0), replacedBlock);
// The replacement can have a different length. Continue after the new
// block, otherwise later translations in this file may be skipped.
offset = match.capturedStart(0) + replacedBlock.size();
changed = true;
}
offset = 0;
while ((match = oldNewRegex.match(content, offset)).hasMatch()) {
auto block = match.captured(0);
auto lineNumber = match.captured(2);
auto oldText = match.captured(3);
auto newText = match.captured(4);
auto replacedText = findNewText(fileItem, lineNumber, oldText, "");
QString replacedBlock = block;
const int translatedTextOffset = match.capturedStart(4) - match.capturedStart(0);
replacedBlock.replace(translatedTextOffset, match.capturedLength(4),
replacedText.isEmpty() ? newText : escapeTranslationForRenpy(replacedText));
content.replace(match.capturedStart(0), match.capturedLength(0), replacedBlock);
offset = match.capturedStart(0) + replacedBlock.size();
changed = true;
}
lines.append(content.split("\n"));
return true;
}
QString MainWindow::findNewText(QTreeWidgetItem *fileItem, const QString &lineNumber, const QString &originalText, const QString &speaker) {
for (int i = 0; i < fileItem->childCount(); ++i) {
QTreeWidgetItem *childItem = fileItem->child(i);
if (childItem->text(0) == lineNumber &&
childItem->text(1) == speaker &&
childItem->text(2) == originalText) {
return childItem->text(3);
}
}
return QString();
}
void MainWindow::loadDeeplTranslationPossibilities() {
static int attempt = 1;
QNetworkAccessManager *manager = new QNetworkAccessManager(this);
connect(manager, &QNetworkAccessManager::finished, this, [this, manager](QNetworkReply *reply) {
if (reply->error() != QNetworkReply::NoError) {
qDebug() << "Network error:" << reply->errorString();
qDebug() << "Attempt:" << attempt;
if (attempt < 5) {
attempt++;
QTimer::singleShot(250, this, &MainWindow::loadDeeplTranslationPossibilities);
} else {
qDebug() << "Max attempts reached, giving up.";
attempt = 1;
}
} else {
attempt = 1;
onDeeplTranslationPossibilitiesLoaded(reply);
}
reply->deleteLater();
manager->deleteLater();
});
QNetworkRequest request(QUrl("https://api-free.deepl.com/v2/glossary-language-pairs"));
request.setRawHeader("Authorization", "DeepL-Auth-Key " + ui->deeplApiKey->text().toUtf8());
manager->get(request);
}
void MainWindow::renderDeeplSources()
{
ui->deeplTranslateFrom->clear();
for (const auto& pair : translationsMap) {
ui->deeplTranslateFrom->addItem(pair.first);
}
if (isLibreTranslateProvider() && ui->deeplTranslateFrom->findText("en") >= 0) {
ui->deeplTranslateFrom->setCurrentText("en");
}
}
void MainWindow::onDeeplTranslationPossibilitiesLoaded(QNetworkReply *reply) {
if (isLibreTranslateProvider()) {
return;
}
if (reply->error() == QNetworkReply::NoError) {
QByteArray responseData = reply->readAll();
QJsonDocument jsonResponse = QJsonDocument::fromJson(responseData);
if (jsonResponse.isObject()) {
QJsonObject jsonResponseObject = jsonResponse.object();
if (jsonResponseObject["supported_languages"].isArray()) {
QJsonArray languagePairsArray = jsonResponseObject["supported_languages"].toArray();
for (const QJsonValue &value : languagePairsArray) {
QJsonObject languagePair = value.toObject();
QString sourceLang = languagePair["source_lang"].toString();
QString targetLang = languagePair["target_lang"].toString();
translationsMap[sourceLang].push_back(targetLang);
}
renderDeeplSources();
} else {
qDebug() << "Invalid JSON format: Data not found";
qDebug() << responseData;
}
} else {
qDebug() << "Invalid JSON format: Response is not an object";
}
} else {
qDebug() << "Network error:" << reply->errorString();
}
reply->deleteLater();
}
bool MainWindow::saveToFile(const QString &fileName, const QStringList &lines, QString &errorMessage) {
QSaveFile output(fileName);
const QByteArray data = lines.join('\n').toUtf8();
if (!output.open(QIODevice::WriteOnly) || output.write(data) != data.size() || !output.commit()) {
errorMessage = tr("Datei konnte nicht geschrieben werden: %1").arg(output.errorString());
return false;
}
return true;
}
void MainWindow::on_cleanupButton_clicked()
{
QString projectDir = ui->projectDir->text();
QString language = ui->languageCombo->currentText();
QString tlDirPath = QDir::cleanPath(projectDir + QDir::separator() + "game" + QDir::separator() + "tl" + QDir::separator() + language);
QDir tlDir(tlDirPath);
QStringList filters;
filters << "*.bak.*";
tlDir.setNameFilters(filters);
QStringList fileList = tlDir.entryList(QDir::Files);
for (int i = 0; i < fileList.size(); ++i) {
const QString &fileName = fileList.at(i);
if (!tlDir.remove(fileName)) {
qDebug() << "Failed to remove" << fileName;
}
}
}
void MainWindow::on_cleanupAndSaveButton_clicked()
{
on_cleanupButton_clicked();
on_saveButton_clicked();
}
void MainWindow::on_deeplTranslateFrom_currentTextChanged(const QString &sourceLanguage)
{
ui->deeplTranslateTo->clear();
auto it = translationsMap.find(sourceLanguage);
if (it != translationsMap.end()) {
for (QString& targetLang : it->second) {
targetLang.replace("\"", "'");
ui->deeplTranslateTo->addItem(targetLang);
}
if (isLibreTranslateProvider() && ui->deeplTranslateTo->findText("de") >= 0) {
ui->deeplTranslateTo->setCurrentText("de");
}
}
}
void MainWindow::on_autoTranslateButton_clicked() {
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;
}
QString originalText = ui->originalTextEdit->text();
QString sourceLang = ui->deeplTranslateFrom->currentText();
QString targetLang = ui->deeplTranslateTo->currentText();
QJsonObject jsonData;
// Protect Mustache-/Platzhalter vor DeepL-Übersetzung
QString protectedText = protectPlaceholdersForDeepl(originalText);
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");
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, libreTranslate](QNetworkReply *reply) mutable {
if (reply->error() != QNetworkReply::NoError) {
qDebug() << "Translation request failed:" << reply->errorString();
qDebug() << "Attempt:" << attempt;
if (attempt < 5) {
attempt++;
QTimer::singleShot(250, this, &MainWindow::on_autoTranslateButton_clicked);
} else {
qDebug() << "Max attempts reached for auto translation.";
attempt = 1;
}
} else {
attempt = 1;
translationRequestFinished(reply, libreTranslate);
}
reply->deleteLater();
manager->deleteLater();
});
manager->post(request, QJsonDocument(jsonData).toJson());
}
void MainWindow::on_retryTranslationButton_clicked()
{
if (ui->originalTextEdit->text().trimmed().isEmpty()) {
QMessageBox::information(this, tr("Information"), tr("Bitte wähle zuerst einen Übersetzungseintrag aus."));
return;
}
on_autoTranslateButton_clicked();
}
void MainWindow::on_reviewIdenticalTranslationsButton_clicked()
{
QVector<QTreeWidgetItem *> candidates;
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 (isOpenTranslation(item) && item->text(2).trimmed() == item->text(3).trimmed()) {
candidates.append(item);
}
}
}
if (candidates.isEmpty()) {
QMessageBox::information(this, tr("Gleiche Texte prüfen"), tr("Es gibt keine unbestätigten, originalgleichen Übersetzungen."));
return;
}
QDialog dialog(this);
dialog.setWindowTitle(tr("Originalgleiche Texte prüfen"));
dialog.resize(760, 620);
auto *layout = new QVBoxLayout(&dialog);
auto *description = new QLabel(tr("Markiere nur Texte, die absichtlich unverändert bleiben sollen. Nicht markierte Texte bleiben offen und werden bei der nächsten Sammelübersetzung erneut angefragt."), &dialog);
description->setWordWrap(true);
layout->addWidget(description);
auto *scrollArea = new QScrollArea(&dialog);
scrollArea->setWidgetResizable(true);
auto *content = new QWidget(scrollArea);
auto *contentLayout = new QVBoxLayout(content);
QVector<QCheckBox *> checks;
checks.reserve(candidates.size());
for (QTreeWidgetItem *item : std::as_const(candidates)) {
const QString text = tr("%1 — Zeile %2: %3").arg(item->parent()->text(0), item->text(0), item->text(2));
auto *check = new QCheckBox(text, content);
contentLayout->addWidget(check);
checks.append(check);
}
contentLayout->addStretch();
scrollArea->setWidget(content);
layout->addWidget(scrollArea);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Apply | QDialogButtonBox::Cancel, &dialog);
buttons->button(QDialogButtonBox::Apply)->setText(tr("Ausgewählte bestätigen"));
connect(buttons->button(QDialogButtonBox::Apply), &QPushButton::clicked, &dialog, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
layout->addWidget(buttons);
if (dialog.exec() != QDialog::Accepted) {
return;
}
QJsonObject approvals = configuration.value("approved-identical-translations").toObject();
for (int index = 0; index < candidates.size(); ++index) {
if (checks[index]->isChecked()) {
approvals[identicalTranslationApprovalKey(candidates[index])] = true;
}
}
configuration["approved-identical-translations"] = approvals;
saveConfiguration();
countAndShowUntranslated();
}
void MainWindow::on_approveIdenticalTranslationButton_clicked()
{
QTreeWidgetItem *item = ui->treeWidget->currentItem();
if (!item || ui->treeWidget->indexOfTopLevelItem(item) >= 0) {
QMessageBox::information(this, tr("Gleicher Text"), tr("Bitte wähle zuerst einen Übersetzungseintrag aus."));
return;
}
if (item->text(2).trimmed() != ui->translationEdit->text().trimmed()) {
QMessageBox::information(this, tr("Gleicher Text"), tr("Diese Aktion ist nur möglich, wenn Original und Übersetzung identisch sind."));
return;
}
on_setTranslationButton_clicked();
on_nextUntranslatedButton_clicked();
}
void MainWindow::on_reuseTranslationsButton_clicked()
{
// Index by exact source text, never by the source line number. Keep distinct
// translations separate so context-dependent variants require a choice.
QMap<QString, QMap<QString, QStringList>> existing;
QVector<QTreeWidgetItem *> openItems;
const auto location = [this](const QTreeWidgetItem *item) {
const auto *file = item->parent();
const QString path = file->data(0, Qt::UserRole + 1).toString();
return tr("%1 — Zeile %2 (%3)")
.arg(path.isEmpty() ? file->text(0) : QDir(ui->projectDir->text()).relativeFilePath(path),
item->text(0), item->text(1).isEmpty() ? tr("Erzähler") : item->text(1));
};
for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) {
auto *file = ui->treeWidget->topLevelItem(i);
for (int j = 0; j < file->childCount(); ++j) {
auto *item = file->child(j);
if (isOpenTranslation(item)) {
openItems.append(item);
} else if (!item->text(2).trimmed().isEmpty() && !item->text(3).trimmed().isEmpty()) {
existing[item->text(2)][item->text(3)].append(location(item));
}
}
}
QVector<QTreeWidgetItem *> targets;
for (auto *item : std::as_const(openItems)) {
if (existing.contains(item->text(2))) {
targets.append(item);
}
}
if (targets.isEmpty()) {
QMessageBox::information(this, tr("Vorhandene Übersetzungen"),
tr("Für die offenen Einträge wurden keine bestätigten Übersetzungen mit exakt gleichem Originaltext in den geladenen Dateien gefunden."));
return;
}
QDialog dialog(this);
dialog.setObjectName(QStringLiteral("reuseTranslationsDialog"));
dialog.setWindowTitle(tr("Vorhandene Übersetzungen übernehmen"));
dialog.resize(1100, 650);
auto *layout = new QVBoxLayout(&dialog);
auto *description = new QLabel(tr("%1 offene Einträge haben bereits übersetzte Originaltexte. Prüfe die Auswahl und den Sprecher. Bei mehreren Varianten wähle eine Übersetzung aus. Nur markierte Einträge werden geändert; gespeichert wird anschließend über „Übersetzungen speichern“. Die bisherigen Einträge bleiben erhalten.").arg(targets.size()), &dialog);
description->setWordWrap(true);
layout->addWidget(description);
auto *table = new QTreeWidget(&dialog);
table->setObjectName(QStringLiteral("reuseTranslationsTable"));
table->setHeaderLabels({tr("Übernehmen / Ziel"), tr("Original"), tr("Vorhandene Übersetzung")});
table->setRootIsDecorated(false);
table->setAlternatingRowColors(true);
table->setUniformRowHeights(true);
table->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
table->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel);
table->verticalScrollBar()->setSingleStep(24);
table->horizontalScrollBar()->setSingleStep(24);
table->setColumnWidth(0, 290);
table->setColumnWidth(1, 340);
table->header()->setStretchLastSection(true);
layout->addWidget(table);
QVector<QComboBox *> choices;
for (auto *target : std::as_const(targets)) {
auto *row = new QTreeWidgetItem(table, {location(target), target->text(2)});
row->setToolTip(0, location(target));
row->setToolTip(1, target->text(2));
auto *choice = new TranslationChoiceComboBox(table);
choice->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon);
choice->setMinimumContentsLength(20);
choice->setPlaceholderText(tr("Übersetzung auswählen …"));
const auto &variants = existing[target->text(2)];
for (auto it = variants.cbegin(); it != variants.cend(); ++it) {
choice->addItem(it.key(), it.key());
choice->setItemData(choice->count() - 1,
it.key() + QStringLiteral("\n\n") + tr("Quelle:\n") + it.value().join('\n'),
Qt::ToolTipRole);
}
const bool unique = variants.size() == 1;
choice->setCurrentIndex(unique ? 0 : -1);
row->setCheckState(0, unique ? Qt::Checked : Qt::Unchecked);
table->setItemWidget(row, 2, choice);
choices.append(choice);
}
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Apply | QDialogButtonBox::Cancel, &dialog);
auto *apply = buttons->button(QDialogButtonBox::Apply);
apply->setText(tr("Ausgewählte übernehmen"));
const auto updateApply = [table, choices, apply] {
bool anySelected = false;
for (int i = 0; i < choices.size(); ++i) {
if (table->topLevelItem(i)->checkState(0) == Qt::Checked) {
if (choices[i]->currentIndex() < 0) {
apply->setEnabled(false);
return;
}
anySelected = true;
}
}
apply->setEnabled(anySelected);
};
connect(table, &QTreeWidget::itemChanged, &dialog, updateApply);
for (int i = 0; i < choices.size(); ++i) {
connect(choices[i], &QComboBox::currentIndexChanged, &dialog, [table, i, updateApply] {
table->topLevelItem(i)->setCheckState(0, Qt::Checked);
updateApply();
});
}
updateApply();
connect(apply, &QPushButton::clicked, &dialog, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
layout->addWidget(buttons);
if (dialog.exec() != QDialog::Accepted) {
return;
}
int copied = 0;
QJsonObject approvals = configuration.value("approved-identical-translations").toObject();
bool approvalsChanged = false;
for (int i = 0; i < targets.size(); ++i) {
if (table->topLevelItem(i)->checkState(0) != Qt::Checked || choices[i]->currentIndex() < 0) {
continue;
}
auto *target = targets[i];
const QString translation = choices[i]->currentData().toString();
target->setText(3, translation);
target->parent()->setData(0, Qt::UserRole, true);
if (translation.trimmed() == target->text(2).trimmed()) {
approvals[identicalTranslationApprovalKey(target)] = true;
approvalsChanged = true;
}
++copied;
}
if (approvalsChanged) {
configuration["approved-identical-translations"] = approvals;
saveConfiguration();
}
countAndShowUntranslated();
on_treeWidget_itemSelectionChanged();
ui->statusbar->showMessage(tr("%1 vorhandene Übersetzungen übernommen. Zum Schreiben der Dateien bitte speichern.").arg(copied));
}
void MainWindow::on_bulkAutoTranslateButton_clicked()
{
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;
}
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 (isOpenTranslation(item)) {
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::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()) {
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;
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(bulkUsesLibreTranslate ? libreTranslateUrl("/translate") : QStringLiteral("https://api-free.deepl.com/v2/translate")));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
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) {
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();
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 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();
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 = bulkUsesLibreTranslate
? translations.at(index).toString()
: translations.at(index).toObject().value("text").toString();
BulkTranslationItem &item = bulkTranslations[firstItem + index];
item.translatedText = restoreBulkPlaceholders(translatedText, item.placeholders);
// An unchanged response normally indicates that the selected local model
// cannot translate this language pair. Keep it open for a later retry.
if (item.translatedText.trimmed() == item.originalText.trimmed()) {
item.translatedText.clear();
}
}
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<OriginalWordReplacementEdit *> edits;
QVector<QPair<QWidget *, QTreeWidgetItem *>> fileSections;
const bool dialogUsesLibreTranslate = bulkUsesLibreTranslate;
const QString dialogSourceLanguage = ui->deeplTranslateFrom->currentText();
const QString dialogTargetLanguage = ui->deeplTranslateTo->currentText();
QSet<QTreeWidgetItem *> savedBulkFiles;
edits.reserve(bulkTranslations.size());
QTreeWidgetItem *currentFile = nullptr;
QVBoxLayout *fileLayout = nullptr;
for (int bulkIndex = 0; bulkIndex < bulkTranslations.size(); ++bulkIndex) {
BulkTranslationItem &item = bulkTranslations[bulkIndex];
QTreeWidgetItem *fileItem = item.item->parent();
if (fileItem != currentFile) {
if (currentFile) {
auto *separator = new QFrame(content);
separator->setFrameShape(QFrame::HLine);
separator->setFrameShadow(QFrame::Sunken);
contentLayout->addWidget(separator);
}
currentFile = fileItem;
auto *fileSection = new QWidget(content);
fileLayout = new QVBoxLayout(fileSection);
fileLayout->setContentsMargins(6, 6, 6, 6);
fileLayout->setSpacing(8);
auto *fileLabel = new QLabel(tr("Datei: %1").arg(fileItem->text(0)), fileSection);
fileLabel->setStyleSheet(QStringLiteral("font-size: 15px; font-weight: 700; color: palette(highlight);"));
fileLayout->addWidget(fileLabel);
contentLayout->addWidget(fileSection);
fileSections.append(qMakePair(fileSection, fileItem));
}
auto *originalRow = new QWidget(content);
auto *originalRowLayout = new QHBoxLayout(originalRow);
originalRowLayout->setContentsMargins(0, 0, 0, 0);
QLabel *originalLabel = new QLabel(item.originalText, originalRow);
originalLabel->setWordWrap(true);
originalLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
originalLabel->setStyleSheet(QStringLiteral("font-weight: 600; padding-top: 6px;"));
originalRowLayout->addWidget(originalLabel, 1);
auto *translationEdit = new OriginalWordReplacementEdit(item.originalText, content);
translationEdit->setPlainText(item.translatedText);
translationEdit->setPlaceholderText(tr("Keine Übersetzung erhalten – später erneut übersetzen oder manuell eintragen."));
auto *retryButton = new QPushButton(tr("Neu übersetzen"), originalRow);
retryButton->setToolTip(tr("Fordert für diesen einzelnen Text einen neuen Vorschlag an."));
originalRowLayout->addWidget(retryButton);
connect(retryButton, &QPushButton::clicked, &dialog,
[this, retryButton, translationEdit, bulkIndex, dialogUsesLibreTranslate, dialogSourceLanguage, dialogTargetLanguage, &dialog] {
BulkTranslationItem &retryItem = bulkTranslations[bulkIndex];
QVector<QString> placeholders;
const QString protectedText = protectBulkPlaceholders(retryItem.originalText, placeholders);
retryButton->setEnabled(false);
QJsonObject payload;
if (dialogUsesLibreTranslate) {
payload["q"] = protectedText;
payload["source"] = dialogSourceLanguage;
payload["target"] = dialogTargetLanguage;
payload["format"] = "text";
} else {
payload["text"] = QJsonArray::fromStringList({protectedText});
payload["source_lang"] = dialogSourceLanguage;
payload["target_lang"] = dialogTargetLanguage;
payload["formality"] = "less";
payload["tag_handling"] = "xml";
payload["tag_handling_version"] = "v2";
}
QNetworkRequest request(QUrl(dialogUsesLibreTranslate
? libreTranslateUrl("/translate")
: QStringLiteral("https://api-free.deepl.com/v2/translate")));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
if (!dialogUsesLibreTranslate) {
request.setRawHeader("Authorization", "DeepL-Auth-Key " + ui->deeplApiKey->text().toUtf8());
}
auto *manager = new QNetworkAccessManager(&dialog);
connect(manager, &QNetworkAccessManager::finished, &dialog,
[this, manager, retryButton, translationEdit, bulkIndex, placeholders, dialogUsesLibreTranslate](QNetworkReply *reply) {
retryButton->setEnabled(true);
if (reply->error() != QNetworkReply::NoError) {
QMessageBox::warning(this, tr("Übersetzung fehlgeschlagen"), reply->errorString());
} else {
const QJsonObject response = QJsonDocument::fromJson(reply->readAll()).object();
const QJsonArray translations = response.value("translations").toArray();
QString translatedText = dialogUsesLibreTranslate
? response.value("translatedText").toString()
: (!translations.isEmpty() ? translations.at(0).toObject().value("text").toString() : QString());
translatedText = restoreBulkPlaceholders(translatedText, placeholders);
bulkTranslations[bulkIndex].translatedText = translatedText;
translationEdit->setPlainText(translatedText);
}
reply->deleteLater();
manager->deleteLater();
});
manager->post(request, QJsonDocument(payload).toJson());
});
fileLayout->addWidget(originalRow);
fileLayout->addWidget(translationEdit);
edits.append(translationEdit);
}
contentLayout->addStretch();
scrollArea->setWidget(content);
layout->addWidget(scrollArea);
auto applyTranslations = [this, &edits, &savedBulkFiles] {
for (int index = 0; index < bulkTranslations.size(); ++index) {
QTreeWidgetItem *item = bulkTranslations[index].item;
if (savedBulkFiles.contains(item->parent())) {
continue;
}
const QString translation = edits[index]->toPlainText();
// An unchanged source string is not an automatic translation. Leave
// it open so it can be retried with a working language pair.
item->setText(3, translation.trimmed() == bulkTranslations[index].originalText.trimmed()
? QString()
: translation);
if (QTreeWidgetItem *parent = item->parent()) {
parent->setData(0, Qt::UserRole, true);
}
}
countAndShowUntranslated();
};
int currentFileSection = 0;
QSet<QTreeWidgetItem *> promptedFiles;
connect(scrollArea->verticalScrollBar(), &QScrollBar::valueChanged, &dialog,
[this, &fileSections, &currentFileSection, &promptedFiles, &savedBulkFiles, applyTranslations](int scrollPosition) {
int visibleFileSection = currentFileSection;
for (int index = 0; index < fileSections.size(); ++index) {
if (fileSections[index].first->y() <= scrollPosition + 12) {
visibleFileSection = index;
} else {
break;
}
}
if (visibleFileSection <= currentFileSection) {
return;
}
const int completedFileSection = currentFileSection;
QTreeWidgetItem *completedFile = fileSections[completedFileSection].second;
currentFileSection = visibleFileSection;
if (promptedFiles.contains(completedFile)) {
return;
}
promptedFiles.insert(completedFile);
const QMessageBox::StandardButton choice = QMessageBox::question(
this,
tr("Datei speichern?"),
tr("Du bist bei einer neuen Datei angekommen. Soll die abgeschlossene Datei \"%1\" jetzt gespeichert werden?")
.arg(completedFile->text(0)),
QMessageBox::Save | QMessageBox::No,
QMessageBox::Save);
if (choice == QMessageBox::Save) {
applyTranslations();
QString errorMessage;
if (!saveFile(completedFile, errorMessage)) {
QMessageBox::critical(this, tr("Error"), errorMessage);
} else {
savedBulkFiles.insert(completedFile);
fileSections[completedFileSection].first->hide();
}
}
});
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, 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 (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);
ui->translationEdit->setText(restored);
}
}
} else {
qDebug() << "Translation request failed:" << reply->errorString();
}
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("<x id=\"%1\"/>").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"(<x\s+id=\"(\d+)\"\s*/>)");
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<int(QTreeWidgetItem *)> countUntranslated = [&](QTreeWidgetItem *parentItem) {
int untranslatedInSectionCount = 0;
for (int i = 0; i < parentItem->childCount(); ++i) {
QTreeWidgetItem *childItem = parentItem->child(i);
if (isOpenTranslation(childItem)) {
untranslatedCount++;
untranslatedInSectionCount++;
}
countUntranslated(childItem);
}
return untranslatedInSectionCount;
};
for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) {
QTreeWidgetItem *topLevelItem = ui->treeWidget->topLevelItem(i);
int sectionCount = countUntranslated(topLevelItem);
topLevelItem->setText(1, QString("%1").arg(sectionCount));
}
ui->untranslatedLabel->setText(QString("%1").arg(untranslatedCount));
}
bool MainWindow::isOpenTranslation(const QTreeWidgetItem *item) const
{
if (!item || item->text(2).isEmpty()) {
return false;
}
const QString original = item->text(2).trimmed();
const QString translation = item->text(3).trimmed();
if (translation.isEmpty()) {
return true;
}
return translation == original && !containsOnlyPlaceholders(original)
&& !isIdenticalTranslationApproved(item);
}
QString MainWindow::identicalTranslationApprovalKey(const QTreeWidgetItem *item) const
{
const QTreeWidgetItem *fileItem = item ? item->parent() : nullptr;
const QString filePath = fileItem ? fileItem->data(0, Qt::UserRole + 1).toString() : QString();
const QString rawKey = filePath + '\n' + (item ? item->text(0) : QString()) + '\n' + (item ? item->text(2) : QString());
return QString::fromLatin1(QCryptographicHash::hash(rawKey.toUtf8(), QCryptographicHash::Sha256).toHex());
}
bool MainWindow::isIdenticalTranslationApproved(const QTreeWidgetItem *item) const
{
return configuration.value("approved-identical-translations").toObject()
.value(identicalTranslationApprovalKey(item)).toBool();
}
void MainWindow::on_searchButton_clicked() {
bool ok;
QString query = QInputDialog::getText(this, "Search", "Search for:", QLineEdit::Normal, "", &ok);
if (ok && !query.isEmpty()) {
searchQuery = query;
ui->treeWidget->setCurrentItem(NULL);
searchNext();
}
}
void MainWindow::on_searchNextButton_clicked() {
if (searchQuery.isEmpty()) {
return;
}
qDebug() << "clicked";
searchNext();
}
void MainWindow::searchNext() {
auto currentItem = ui->treeWidget->currentItem();
for (int fileItemPos = 0; fileItemPos < ui->treeWidget->invisibleRootItem()->childCount(); ++fileItemPos) {
auto fileItem {ui->treeWidget->invisibleRootItem()->child(fileItemPos)};
for (int linePos = 0; linePos < fileItem->childCount(); ++linePos) {
auto line {fileItem->child(linePos)};
if (currentItem == NULL && (line->text(2).contains(searchQuery, Qt::CaseInsensitive)
|| line->text(3).contains(searchQuery, Qt::CaseInsensitive))) {
ui->treeWidget->setCurrentItem(line);
ui->treeWidget->scrollToItem(line);
return;
}
if (currentItem == line) {
currentItem = NULL;
}
}
}
}
void MainWindow::setConfigValue(const QString &key, const QString &value) {
if (noConfigChange) {
return;
}
configuration[key] = value;
saveConfiguration();
}
void MainWindow::saveConfiguration()
{
QString configPath = QDir::homePath() + "/.renpytranslate.conf";
QJsonDocument doc(configuration);
QFile configFile(configPath);
if (configFile.open(QIODevice::WriteOnly)) {
configFile.write(doc.toJson());
configFile.close();
} else {
qWarning() << "Failed to open config file for writing:" << configPath;
}
}
void MainWindow::on_deeplApiKey_editingFinished()
{
setConfigValue("deepl-key", ui->deeplApiKey->text());
loadDeeplTranslationPossibilities();
}
void MainWindow::on_copyButton_clicked() {
QGuiApplication::clipboard()->setText(ui->originalTextEdit->text());
}