From 05d62aad5fa46aaab49b59f16881cc5540f30140 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 29 Oct 2026 09:48:07 +0100 Subject: [PATCH] =?UTF-8?q?F=C3=BCge=20Unterst=C3=BCtzung=20f=C3=BCr=20Reg?= =?UTF-8?q?ressionstests=20hinzu=20und=20erweitere=20die=20Benutzeroberfl?= =?UTF-8?q?=C3=A4che=20mit=20neuen=20Schaltfl=C3=A4chen=20zur=20Genehmigun?= =?UTF-8?q?g=20identischer=20=C3=9Cbersetzungen=20und=20zur=20Wiederverwen?= =?UTF-8?q?dung=20vorhandener=20=C3=9Cbersetzungen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 13 ++ mainwindow.cpp | 387 +++++++++++++++++++++++++++++++++++++------ mainwindow.h | 8 +- mainwindow.ui | 2 + tests/save_tests.cpp | 156 +++++++++++++++++ 5 files changed, 508 insertions(+), 58 deletions(-) create mode 100644 tests/save_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 38f5c75..7386b7b 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,3 +74,16 @@ install(TARGETS RenpyTranslationHelper if(QT_VERSION_MAJOR EQUAL 6) qt_finalize_executable(RenpyTranslationHelper) endif() + +option(RENPY_BUILD_TESTS "Build translation save regression tests" OFF) +if(RENPY_BUILD_TESTS) + enable_testing() + add_executable(RenpyTranslationHelperSaveTests + tests/save_tests.cpp mainwindow.cpp mainwindow.h mainwindow.ui) + target_include_directories(RenpyTranslationHelperSaveTests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + target_link_libraries(RenpyTranslationHelperSaveTests PRIVATE + Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Network) + add_test(NAME translation_saving COMMAND RenpyTranslationHelperSaveTests) + set_tests_properties(translation_saving PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30) +endif() diff --git a/mainwindow.cpp b/mainwindow.cpp index 5aa91e3..314bac7 100755 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,8 @@ #include #include #include +#include +#include #include #include #include @@ -36,9 +39,83 @@ #include #include #include +#include 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(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("(? MainWindow::parseTextBlock(const QString& i int lineNumber = 0; QString identifier, originalText, translatedText; for (const QString& line : std::as_const(lines)) { - if (line.contains("# game/")) { - QRegularExpression regExp("# game/.+:(\\d+)"); - QRegularExpressionMatch match = regExp.match(line.trimmed()); - if (match.hasMatch()) { - lineNumber = match.captured(1).toInt(); - } + 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('"'); @@ -451,19 +526,28 @@ void MainWindow::on_reloadFilesButton_clicked() } void MainWindow::on_nextUntranslatedButton_clicked() { + QVector 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); - // Prüfe, ob Übersetzung (Spalte 3) leer ist - if (isOpenTranslation(childItem)) { - ui->originalTextEdit->setText(childItem->text(2)); // Originaltext aus Spalte 2 - ui->translationEdit->clear(); - ui->treeWidget->scrollToItem(childItem); - ui->treeWidget->setCurrentItem(childItem); - on_copyButton_clicked(); - return; + 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.")); @@ -480,11 +564,25 @@ void MainWindow::on_treeWidget_itemSelectionChanged() { } 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 - selectedItem->setText(3, ui->translationEdit->text()); + applyTranslation(selectedItem); QTreeWidgetItem *parentItem = selectedItem->parent(); if (parentItem) { parentItem->setData(0, Qt::UserRole, true); @@ -492,12 +590,16 @@ void MainWindow::on_setTranslationButton_clicked() { auto item = parentItem->child(index); // Vergleiche den Originaltext (Spalte 2) if (item->text(2) == ui->originalTextEdit->text()) { - item->setText(3, ui->translationEdit->text()); + applyTranslation(item); } } } } } + if (approvalsChanged) { + configuration["approved-identical-translations"] = approvals; + saveConfiguration(); + } ui->originalTextEdit->setFocus(); countAndShowUntranslated(); } @@ -537,9 +639,9 @@ void MainWindow::on_saveButton_clicked() QStringList failedFiles; saveFiles(failedFiles); if (!failedFiles.isEmpty()) { - QString errorMessage = "Failed to save the following files:\n\n"; + QString errorMessage = tr("Folgende Dateien konnten nicht gespeichert werden:\n\n"); errorMessage += failedFiles.join("\n"); - QMessageBox::critical(this, tr("Error"), errorMessage); + QMessageBox::critical(this, tr("Fehler beim Speichern"), errorMessage); } else { QMessageBox::information(this, "Information", "The translations were saved succesfully."); } @@ -564,57 +666,84 @@ bool MainWindow::saveFile(QTreeWidgetItem *fileItem, QString &errorMessage) 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 backupFileName = QString("%1.bak.%2") - .arg(filePath, QDateTime::currentDateTime().toString("yyyyMMddHHmmss")); - if (!createBackup(filePath, backupFileName)) { - errorMessage = filePath + ": Failed to create backup"; + 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 = filePath + ": Failed to edit and save file"; + 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) { - if (!QFile::rename(filePath, backupFileName)) { - qDebug() << "Failed to create backup file: " << backupFileName; - qDebug() << "Original name: " << filePath; +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) { +bool MainWindow::editAndSaveFile(QTreeWidgetItem *fileItem, const QString &backupFileName, const QString &fileName, QString &errorMessage) { QFile backupFile(backupFileName); if (!backupFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - qDebug() << "Failed to open backup file for reading: " << backupFileName; + errorMessage = tr("Backup konnte nicht gelesen werden: %1").arg(backupFile.errorString()); return false; } QStringList lines; bool changed = false; if (!parseAndEditFile(backupFile, fileItem, lines, changed)) { - qDebug() << "Failed to parse and edit file: " << backupFileName; + errorMessage = tr("Die Übersetzungsdatei konnte nicht verarbeitet werden."); return false; } backupFile.close(); - if (changed) { - if (!saveToFile(fileName, lines)) { - qDebug() << "Failed to save file: " << backupFileName; - return false; - } + if (!changed) { + errorMessage = tr("Keine unterstützten Übersetzungsblöcke gefunden. Die Originaldatei bleibt unverändert."); + return false; } - qDebug() << fileName << " created"; + 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"(# game\/(.+):(\d+)\ntranslate\s+[^\s]+\s+(.*?)\n\n\s*#.*?\"(.*?)\"\s*\n\s*(.*?)\s*\"(.*?)\")"); - static QRegularExpression oldNewRegex(R"(\n\s*#\s*game\/(.+?):(\d+?)\n\s*old\s*\"(.+?)\"\n\s*new\s*\"(.*?)\")"); + 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()) { @@ -627,7 +756,7 @@ bool MainWindow::parseAndEditFile(QFile &backupFile, QTreeWidgetItem *fileItem, QString replacedBlock = block; const int translatedTextOffset = match.capturedStart(6) - match.capturedStart(0); replacedBlock.replace(translatedTextOffset, match.capturedLength(6), - replacedText.isEmpty() ? newText : replacedText); + 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. @@ -644,7 +773,7 @@ bool MainWindow::parseAndEditFile(QFile &backupFile, QTreeWidgetItem *fileItem, QString replacedBlock = block; const int translatedTextOffset = match.capturedStart(4) - match.capturedStart(0); replacedBlock.replace(translatedTextOffset, match.capturedLength(4), - replacedText.isEmpty() ? newText : replacedText); + replacedText.isEmpty() ? newText : escapeTranslationForRenpy(replacedText)); content.replace(match.capturedStart(0), match.capturedLength(0), replacedBlock); offset = match.capturedStart(0) + replacedBlock.size(); changed = true; @@ -735,16 +864,13 @@ void MainWindow::onDeeplTranslationPossibilitiesLoaded(QNetworkReply *reply) { reply->deleteLater(); } -bool MainWindow::saveToFile(const QString &backupFileName, const QStringList &lines) { - QFile backupFile(backupFileName); - if (!backupFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { - qDebug() << "Failed to open backup file for writing: " << backupFileName; +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; } - - QTextStream out(&backupFile); - out << lines.join("\n") << Qt::endl; - backupFile.close(); return true; } @@ -861,8 +987,7 @@ void MainWindow::on_reviewIdenticalTranslationsButton_clicked() 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(2).trimmed() == item->text(3).trimmed() - && !isIdenticalTranslationApproved(item)) { + if (isOpenTranslation(item) && item->text(2).trimmed() == item->text(3).trimmed()) { candidates.append(item); } } @@ -914,6 +1039,157 @@ void MainWindow::on_reviewIdenticalTranslationsButton_clicked() 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> existing; + QVector 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 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 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(); @@ -1086,7 +1362,6 @@ void MainWindow::handleBulkTranslationReply(QNetworkReply *reply, int firstItem, : translations.at(index).toObject().value("text").toString(); BulkTranslationItem &item = bulkTranslations[firstItem + index]; item.translatedText = restoreBulkPlaceholders(translatedText, item.placeholders); - item.translatedText.replace('"', '\''); // 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()) { @@ -1199,7 +1474,6 @@ void MainWindow::showBulkTranslationDialog() ? response.value("translatedText").toString() : (!translations.isEmpty() ? translations.at(0).toObject().value("text").toString() : QString()); translatedText = restoreBulkPlaceholders(translatedText, placeholders); - translatedText.replace('"', '\''); bulkTranslations[bulkIndex].translatedText = translatedText; translationEdit->setPlainText(translatedText); } @@ -1305,7 +1579,6 @@ void MainWindow::translationRequestFinished(QNetworkReply *reply, bool isLibreTr : translationsArray[0].toObject()["text"].toString(); // Restore any placeholders that were protected before the request QString restored = restorePlaceholdersFromDeepl(translatedText); - restored.replace("\"", "'"); ui->translationEdit->setText(restored); } } @@ -1393,7 +1666,11 @@ bool MainWindow::isOpenTranslation(const QTreeWidgetItem *item) const } const QString original = item->text(2).trimmed(); const QString translation = item->text(3).trimmed(); - return translation.isEmpty() || (translation == original && !isIdenticalTranslationApproved(item)); + if (translation.isEmpty()) { + return true; + } + return translation == original && !containsOnlyPlaceholders(original) + && !isIdenticalTranslationApproved(item); } QString MainWindow::identicalTranslationApprovalKey(const QTreeWidgetItem *item) const diff --git a/mainwindow.h b/mainwindow.h index 3dfc16d..88fe165 100755 --- a/mainwindow.h +++ b/mainwindow.h @@ -43,6 +43,8 @@ private slots: void on_autoTranslateButton_clicked(); void on_retryTranslationButton_clicked(); void on_reviewIdenticalTranslationsButton_clicked(); + void on_approveIdenticalTranslationButton_clicked(); + void on_reuseTranslationsButton_clicked(); void on_bulkAutoTranslateButton_clicked(); void on_checkDeeplUsageButton_clicked(); void on_translationProviderCombo_currentIndexChanged(int index); @@ -81,9 +83,9 @@ private: void populateTreeWidgetFromMap(); void saveFiles(QStringList &failedFiles); bool saveFile(QTreeWidgetItem *fileItem, QString &errorMessage); - bool createBackup(const QString &filePath, const QString &backupFileName); - bool editAndSaveFile(QTreeWidgetItem *fileItem, const QString &backupFileName, const QString &fileName); - bool saveToFile(const QString &backupFileName, const QStringList &lines); + bool createBackup(const QString &filePath, const QString &backupFileName, QString &errorMessage); + bool editAndSaveFile(QTreeWidgetItem *fileItem, const QString &backupFileName, const QString &fileName, QString &errorMessage); + bool saveToFile(const QString &fileName, const QStringList &lines, QString &errorMessage); bool parseAndEditFile(QFile &backupFile, QTreeWidgetItem *fileItem, QStringList &lines, bool &changed); QString findNewText(QTreeWidgetItem *fileItem, const QString &lineNumber, const QString &originalText, const QString &speaker); void loadDeeplTranslationPossibilities(); diff --git a/mainwindow.ui b/mainwindow.ui index d6d4e2f..9356099 100644 --- a/mainwindow.ui +++ b/mainwindow.ui @@ -46,6 +46,7 @@ Nächster offener Text Suchen Weiter + Sucht für offene Einträge vorhandene Übersetzungen desselben Originaltexts in allen geladenen Dateien, unabhängig von Zeilennummern.Vorhandene Übersetzungen übernehmen… Qt::Horizontal4020 @@ -64,6 +65,7 @@ Übernehmen && weiter Übernehmen, weiter && automatisch übersetzen Fordert für den aktuellen Originaltext einen neuen Übersetzungsvorschlag an.Neu übersetzen + Bestätigt, dass dieser originalgleiche Text absichtlich unverändert bleibt, und springt weiter.Als unverändert bestätigen Qt::Horizontal4020 diff --git a/tests/save_tests.cpp b/tests/save_tests.cpp new file mode 100644 index 0000000..e424e38 --- /dev/null +++ b/tests/save_tests.cpp @@ -0,0 +1,156 @@ +#include "mainwindow.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void require(bool condition, const char *message) +{ + if (!condition) qFatal("%s", message); +} + +static void writeFile(const QString &path, const QString &text) +{ + QFile file(path); + require(file.open(QIODevice::WriteOnly), "Cannot create fixture"); + require(file.write(text.toUtf8()) == text.toUtf8().size(), "Cannot write fixture"); +} + +static QString readFile(const QString &path) +{ + QFile file(path); + require(file.open(QIODevice::ReadOnly), "Original file is missing"); + return QString::fromUtf8(file.readAll()); +} + +int main(int argc, char **argv) +{ + QTemporaryDir home; + require(home.isValid(), "Cannot create test directory"); + qputenv("HOME", home.path().toUtf8()); + QApplication application(argc, argv); + const QString project = home.path() + "/project"; + const QString directory = project + "/game/tl/german"; + require(QDir().mkpath(directory), "Cannot create project"); + const QString common = "translate german strings:\n\n # renpy/common/00action_file.rpy:42\n old \"Yes\"\n new \"Ja\"\n"; + const QString unknown = "translate german strings:\n\n old \"Other\"\n new \"Anderes\"\n"; + writeFile(directory + "/common.rpy", common); + writeFile(directory + "/missing.rpy", common); + writeFile(directory + "/unknown.rpy", unknown); + const QString quoted = "translate german strings:\n\n # renpy/common/test.rpy:50\n old \"A \\\"quote\\\" [name]\"\n new \"Ein \\\"Zitat\\\" [name]\"\n"; + writeFile(directory + "/quoted.rpy", quoted); + const QString dialogue = "# game/test.rpy:55\ntranslate german quoted_dialogue:\n\n # e \"A \\\"quote\\\" [name]\"\n e \"Ein \\\"Zitat\\\" [name]\"\n"; + writeFile(directory + "/dialogue.rpy", dialogue); + + MainWindow window; + auto *tree = window.findChild("treeWidget"); + auto *languages = window.findChild("languageCombo"); + auto *editor = window.findChild("translationEdit"); + window.findChild("projectDir")->setText(project); + { const QSignalBlocker blocker(languages); languages->addItem("german"); } + require(QMetaObject::invokeMethod(&window, "on_languageCombo_currentTextChanged", Qt::DirectConnection, + Q_ARG(QString, "german")), "Cannot load project"); + auto find = [tree](const QString &name) { + for (int i = 0; i < tree->topLevelItemCount(); ++i) { + if (tree->topLevelItem(i)->text(0) == name) return tree->topLevelItem(i); + } + return static_cast(nullptr); + }; + auto invoke = [&window](const char *slot) { + require(QMetaObject::invokeMethod(&window, slot, Qt::DirectConnection), "Cannot invoke action"); + }; + auto edit = [&](QTreeWidgetItem *file, const QString &text) { + require(file && file->childCount() == 1, "Fixture was not parsed"); + tree->setCurrentItem(file->child(0)); + editor->setText(text); + invoke("on_setTranslationButton_clicked"); + }; + auto save = [&](bool success) { + QString message; + QTimer::singleShot(0, [&] { + auto *box = qobject_cast(QApplication::activeModalWidget()); + require(box, "Save result dialog is missing"); + require((box->icon() != QMessageBox::Critical) == success, "Unexpected save result"); + message = box->text(); + box->accept(); + }); + invoke("on_saveButton_clicked"); + return message; + }; + + auto *commonItem = find("common.rpy"); + require(commonItem && commonItem->child(0)->text(0) == "42", "Common source line was not parsed"); + edit(commonItem, "Jawohl"); + save(true); + const QString saved = QString(common).replace(QStringLiteral("new \"Ja\""), QStringLiteral("new \"Jawohl\"")); + require(readFile(directory + "/common.rpy") == saved, "Common translation was not saved"); + const auto firstBackups = QDir(directory).entryList({"common.rpy.bak.*"}, QDir::Files); + require(firstBackups.size() == 1, "Backup is missing"); + require(readFile(directory + '/' + firstBackups.first()).contains("new \"Ja\""), "Backup was modified"); + edit(commonItem, "Ja, bitte"); + save(true); + require(QDir(directory).entryList({"common.rpy.bak.*"}, QDir::Files).size() == 2, "Repeated save collided with a backup"); + + auto *missing = find("missing.rpy"); + require(QFile::remove(directory + "/missing.rpy"), "Cannot simulate missing source"); + edit(missing, "Wiederhergestellt"); + save(true); + require(readFile(directory + "/missing.rpy").contains("new \"Wiederhergestellt\""), "Loaded source was not recovered"); + + for (const auto &fixture : {qMakePair(QStringLiteral("quoted.rpy"), quoted), + qMakePair(QStringLiteral("dialogue.rpy"), dialogue)}) { + edit(find(fixture.first), QStringLiteral("Ein anderes \\\"Zitat\\\" [name]")); + save(true); + QString expected = fixture.second; + expected.replace(QStringLiteral("Ein \\\"Zitat\\\" [name]"), QStringLiteral("Ein anderes \\\"Zitat\\\" [name]")); + require(readFile(directory + '/' + fixture.first) == expected, "Escaped quotes corrupted a translation"); + const QList> inputs = { + {QStringLiteral(R"(Er sagt "Hallo" [name].)"), QStringLiteral(R"(Er sagt \"Hallo\" [name].)")}, + {QStringLiteral(R"(Er sagt \"Hallo\" [name].)"), QStringLiteral(R"(Er sagt \"Hallo\" [name].)")}, + {QStringLiteral(R"(Pfad\)"), QStringLiteral(R"(Pfad\\)")}, + {QStringLiteral("Er sagt \"Hallo\"\nWeiter"), QStringLiteral(R"(Er sagt \"Hallo\"\nWeiter)")}, + {QStringLiteral(R"(Zwei Backslashes: \\"Zitat")"), QStringLiteral(R"(Zwei Backslashes: \\\"Zitat\")")}, + }; + for (const auto &input : inputs) { + edit(find(fixture.first), input.first); + save(true); + expected = fixture.second; + expected.replace(QStringLiteral("Ein \\\"Zitat\\\" [name]"), input.second); + require(readFile(directory + '/' + fixture.first) == expected, "Literal quotes or backslashes produced invalid source"); + // Repeated saves must not add another level of escaping. + edit(find(fixture.first), input.second); + save(true); + require(readFile(directory + '/' + fixture.first) == expected, "Repeated escaping changed the translation"); + } + } + + auto *unsupported = find("unknown.rpy"); + edit(unsupported, "Geändert"); + require(save(false).contains("Keine unterstützten Übersetzungsblöcke"), "Missing parser failure details"); + require(readFile(directory + "/unknown.rpy") == unknown, "Unsupported original was changed or removed"); + require(unsupported->data(0, Qt::UserRole).toBool(), "Failed save discarded dirty state"); + unsupported->setData(0, Qt::UserRole, false); + + // An unavailable destination must report the backup path and filesystem error. + edit(commonItem, "Noch einmal"); + require(QDir().rename(directory, directory + ".held"), "Cannot move fixture directory"); + const QString backupError = save(false); + require(QDir().rename(directory + ".held", directory), "Cannot restore fixture directory"); + require(backupError.contains("Backup konnte nicht erstellt werden") && backupError.contains("Grund:") + && backupError.contains("common.rpy.bak."), "Missing backup error details"); + require(readFile(directory + "/common.rpy").contains("Ja, bitte"), "Failed backup changed the original"); + commonItem->setData(0, Qt::UserRole, false); + + auto *absent = new QTreeWidgetItem(tree, {"absent.rpy"}); + absent->setData(0, Qt::UserRole + 1, directory + "/absent.rpy"); + absent->setData(0, Qt::UserRole, true); + const QString missingError = save(false); + require(missingError.contains("Originaldatei fehlt") && missingError.contains("keine geladene Kopie"), "Missing source error is unclear"); + qInfo("PASS: common sources, backups, repeated saves, cached recovery, original preservation, error details"); +}