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:
@@ -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()
|
||||
|
||||
387
mainwindow.cpp
387
mainwindow.cpp
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 && weiter</string></property></widget></item>
|
||||
<item><widget class="QPushButton" name="setAndNextAndAutoTranslate"><property name="text"><string>Übernehmen, weiter && 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
156
tests/save_tests.cpp
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user