Füge Unterstützung für Regressionstests hinzu und erweitere die Benutzeroberfläche mit neuen Schaltflächen zur Genehmigung identischer Übersetzungen und zur Wiederverwendung vorhandener Übersetzungen.

This commit is contained in:
Torsten Schulz (local)
2026-10-29 09:48:07 +01:00
parent d1c68d78f1
commit 05d62aad5f
5 changed files with 508 additions and 58 deletions

View File

@@ -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()

View File

@@ -6,6 +6,7 @@
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QSaveFile>
#include <QTextStream>
#include <QDebug>
#include <QDateTime>
@@ -16,6 +17,8 @@
#include <QJsonObject>
#include <QDialogButtonBox>
#include <QInputDialog>
#include <QHeaderView>
#include <QMap>
#include <QClipboard>
#include <QCheckBox>
#include <QCryptographicHash>
@@ -36,9 +39,83 @@
#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:
@@ -383,12 +460,10 @@ QVector<MainWindow::TranslationItem> 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<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);
// 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<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();
@@ -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

View File

@@ -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();

View File

@@ -46,6 +46,7 @@
<item><widget class="QPushButton" name="nextUntranslatedButton"><property name="text"><string>Nächster offener Text</string></property></widget></item>
<item><widget class="QToolButton" name="searchButton"><property name="text"><string>Suchen</string></property></widget></item>
<item><widget class="QToolButton" name="searchNextButton"><property name="text"><string>Weiter</string></property></widget></item>
<item><widget class="QPushButton" name="reuseTranslationsButton"><property name="toolTip"><string>Sucht für offene Einträge vorhandene Übersetzungen desselben Originaltexts in allen geladenen Dateien, unabhängig von Zeilennummern.</string></property><property name="text"><string>Vorhandene Übersetzungen übernehmen…</string></property></widget></item>
<item><spacer name="navigationSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
</layout>
</item>
@@ -64,6 +65,7 @@
<item><widget class="QPushButton" name="setTranslationAndJumpNextButton"><property name="text"><string>Übernehmen &amp;&amp; weiter</string></property></widget></item>
<item><widget class="QPushButton" name="setAndNextAndAutoTranslate"><property name="text"><string>Übernehmen, weiter &amp;&amp; automatisch übersetzen</string></property></widget></item>
<item><widget class="QPushButton" name="retryTranslationButton"><property name="toolTip"><string>Fordert für den aktuellen Originaltext einen neuen Übersetzungsvorschlag an.</string></property><property name="text"><string>Neu übersetzen</string></property></widget></item>
<item><widget class="QPushButton" name="approveIdenticalTranslationButton"><property name="toolTip"><string>Bestätigt, dass dieser originalgleiche Text absichtlich unverändert bleibt, und springt weiter.</string></property><property name="text"><string>Als unverändert bestätigen</string></property></widget></item>
<item><spacer name="translationActionSpacer"><property name="orientation"><enum>Qt::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item>
</layout>
</item>

156
tests/save_tests.cpp Normal file
View File

@@ -0,0 +1,156 @@
#include "mainwindow.h"
#include <QApplication>
#include <QComboBox>
#include <QDir>
#include <QFile>
#include <QLineEdit>
#include <QMessageBox>
#include <QSignalBlocker>
#include <QTemporaryDir>
#include <QTimer>
#include <QTreeWidget>
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<QTreeWidget *>("treeWidget");
auto *languages = window.findChild<QComboBox *>("languageCombo");
auto *editor = window.findChild<QLineEdit *>("translationEdit");
window.findChild<QLineEdit *>("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<QTreeWidgetItem *>(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<QMessageBox *>(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<QPair<QString, QString>> 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");
}