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)
|
if(QT_VERSION_MAJOR EQUAL 6)
|
||||||
qt_finalize_executable(RenpyTranslationHelper)
|
qt_finalize_executable(RenpyTranslationHelper)
|
||||||
endif()
|
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()
|
||||||
|
|||||||
381
mainwindow.cpp
381
mainwindow.cpp
@@ -6,6 +6,7 @@
|
|||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QDirIterator>
|
#include <QDirIterator>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QSaveFile>
|
||||||
#include <QTextStream>
|
#include <QTextStream>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
@@ -16,6 +17,8 @@
|
|||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QDialogButtonBox>
|
#include <QDialogButtonBox>
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QMap>
|
||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QCheckBox>
|
#include <QCheckBox>
|
||||||
#include <QCryptographicHash>
|
#include <QCryptographicHash>
|
||||||
@@ -36,9 +39,83 @@
|
|||||||
#include <QSignalBlocker>
|
#include <QSignalBlocker>
|
||||||
#include <QScrollBar>
|
#include <QScrollBar>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
#include <QWheelEvent>
|
||||||
|
|
||||||
namespace {
|
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
|
class OriginalWordReplacementEdit final : public QPlainTextEdit
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -383,12 +460,10 @@ QVector<MainWindow::TranslationItem> MainWindow::parseTextBlock(const QString& i
|
|||||||
int lineNumber = 0;
|
int lineNumber = 0;
|
||||||
QString identifier, originalText, translatedText;
|
QString identifier, originalText, translatedText;
|
||||||
for (const QString& line : std::as_const(lines)) {
|
for (const QString& line : std::as_const(lines)) {
|
||||||
if (line.contains("# game/")) {
|
static const QRegularExpression sourceLocation(QStringLiteral("^#\\s+.+:(\\d+)\\s*$"));
|
||||||
QRegularExpression regExp("# game/.+:(\\d+)");
|
const auto sourceMatch = sourceLocation.match(line.trimmed());
|
||||||
QRegularExpressionMatch match = regExp.match(line.trimmed());
|
if (sourceMatch.hasMatch()) {
|
||||||
if (match.hasMatch()) {
|
lineNumber = sourceMatch.captured(1).toInt();
|
||||||
lineNumber = match.captured(1).toInt();
|
|
||||||
}
|
|
||||||
} else if (line.startsWith(" # ") || line.startsWith(" old ")) {
|
} else if (line.startsWith(" # ") || line.startsWith(" old ")) {
|
||||||
const int firstQuote = line.indexOf('"');
|
const int firstQuote = line.indexOf('"');
|
||||||
const int lastQuote = line.lastIndexOf('"');
|
const int lastQuote = line.lastIndexOf('"');
|
||||||
@@ -451,21 +526,30 @@ void MainWindow::on_reloadFilesButton_clicked()
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::on_nextUntranslatedButton_clicked() {
|
void MainWindow::on_nextUntranslatedButton_clicked() {
|
||||||
|
QVector<QTreeWidgetItem *> items;
|
||||||
|
int currentIndex = -1;
|
||||||
for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) {
|
for (int i = 0; i < ui->treeWidget->topLevelItemCount(); ++i) {
|
||||||
QTreeWidgetItem *topLevelItem = ui->treeWidget->topLevelItem(i);
|
QTreeWidgetItem *topLevelItem = ui->treeWidget->topLevelItem(i);
|
||||||
for (int j = 0; j < topLevelItem->childCount(); ++j) {
|
for (int j = 0; j < topLevelItem->childCount(); ++j) {
|
||||||
QTreeWidgetItem *childItem = topLevelItem->child(j);
|
QTreeWidgetItem *childItem = topLevelItem->child(j);
|
||||||
// Prüfe, ob Übersetzung (Spalte 3) leer ist
|
if (childItem == ui->treeWidget->currentItem()) {
|
||||||
if (isOpenTranslation(childItem)) {
|
currentIndex = items.size();
|
||||||
ui->originalTextEdit->setText(childItem->text(2)); // Originaltext aus Spalte 2
|
}
|
||||||
ui->translationEdit->clear();
|
items.append(childItem);
|
||||||
ui->treeWidget->scrollToItem(childItem);
|
}
|
||||||
ui->treeWidget->setCurrentItem(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();
|
on_copyButton_clicked();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
QMessageBox::information(this, tr("Information"), tr("No untranslated item found."));
|
QMessageBox::information(this, tr("Information"), tr("No untranslated item found."));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,11 +564,25 @@ void MainWindow::on_treeWidget_itemSelectionChanged() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::on_setTranslationButton_clicked() {
|
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();
|
QTreeWidgetItem *selectedItem = ui->treeWidget->currentItem();
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
if (selectedItem->treeWidget()->indexOfTopLevelItem(selectedItem) < 0) {
|
if (selectedItem->treeWidget()->indexOfTopLevelItem(selectedItem) < 0) {
|
||||||
// Setze Übersetzung in Spalte 3
|
// Setze Übersetzung in Spalte 3
|
||||||
selectedItem->setText(3, ui->translationEdit->text());
|
applyTranslation(selectedItem);
|
||||||
QTreeWidgetItem *parentItem = selectedItem->parent();
|
QTreeWidgetItem *parentItem = selectedItem->parent();
|
||||||
if (parentItem) {
|
if (parentItem) {
|
||||||
parentItem->setData(0, Qt::UserRole, true);
|
parentItem->setData(0, Qt::UserRole, true);
|
||||||
@@ -492,12 +590,16 @@ void MainWindow::on_setTranslationButton_clicked() {
|
|||||||
auto item = parentItem->child(index);
|
auto item = parentItem->child(index);
|
||||||
// Vergleiche den Originaltext (Spalte 2)
|
// Vergleiche den Originaltext (Spalte 2)
|
||||||
if (item->text(2) == ui->originalTextEdit->text()) {
|
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();
|
ui->originalTextEdit->setFocus();
|
||||||
countAndShowUntranslated();
|
countAndShowUntranslated();
|
||||||
}
|
}
|
||||||
@@ -537,9 +639,9 @@ void MainWindow::on_saveButton_clicked()
|
|||||||
QStringList failedFiles;
|
QStringList failedFiles;
|
||||||
saveFiles(failedFiles);
|
saveFiles(failedFiles);
|
||||||
if (!failedFiles.isEmpty()) {
|
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");
|
errorMessage += failedFiles.join("\n");
|
||||||
QMessageBox::critical(this, tr("Error"), errorMessage);
|
QMessageBox::critical(this, tr("Fehler beim Speichern"), errorMessage);
|
||||||
} else {
|
} else {
|
||||||
QMessageBox::information(this, "Information", "The translations were saved succesfully.");
|
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()
|
const QString filePath = fileItem->data(0, Qt::UserRole + 1).toString().isEmpty()
|
||||||
? QString("%1/game/tl/%2/%3").arg(projectDir, ui->languageCombo->currentText(), fileName)
|
? QString("%1/game/tl/%2/%3").arg(projectDir, ui->languageCombo->currentText(), fileName)
|
||||||
: fileItem->data(0, Qt::UserRole + 1).toString();
|
: fileItem->data(0, Qt::UserRole + 1).toString();
|
||||||
const QString backupFileName = QString("%1.bak.%2")
|
const QString backupBase = QString("%1.bak.%2")
|
||||||
.arg(filePath, QDateTime::currentDateTime().toString("yyyyMMddHHmmss"));
|
.arg(filePath, QDateTime::currentDateTime().toString("yyyyMMddHHmmsszzz"));
|
||||||
if (!createBackup(filePath, backupFileName)) {
|
QString backupFileName = backupBase;
|
||||||
errorMessage = filePath + ": Failed to create backup";
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (!editAndSaveFile(fileItem, backupFileName, filePath)) {
|
if (!editAndSaveFile(fileItem, backupFileName, filePath, errorMessage)) {
|
||||||
errorMessage = filePath + ": Failed to edit and save file";
|
errorMessage = filePath + '\n' + errorMessage + tr("\nBackup: %1").arg(backupFileName);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
fileItem->setData(0, Qt::UserRole, false);
|
fileItem->setData(0, Qt::UserRole, false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MainWindow::createBackup(const QString &filePath, const QString &backupFileName) {
|
bool MainWindow::createBackup(const QString &filePath, const QString &backupFileName, QString &errorMessage) {
|
||||||
if (!QFile::rename(filePath, backupFileName)) {
|
QByteArray original;
|
||||||
qDebug() << "Failed to create backup file: " << backupFileName;
|
QFile source(filePath);
|
||||||
qDebug() << "Original name: " << 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 false;
|
||||||
}
|
}
|
||||||
return true;
|
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);
|
QFile backupFile(backupFileName);
|
||||||
if (!backupFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
QStringList lines;
|
QStringList lines;
|
||||||
bool changed = false;
|
bool changed = false;
|
||||||
if (!parseAndEditFile(backupFile, fileItem, lines, changed)) {
|
if (!parseAndEditFile(backupFile, fileItem, lines, changed)) {
|
||||||
qDebug() << "Failed to parse and edit file: " << backupFileName;
|
errorMessage = tr("Die Übersetzungsdatei konnte nicht verarbeitet werden.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
backupFile.close();
|
backupFile.close();
|
||||||
if (changed) {
|
if (!changed) {
|
||||||
if (!saveToFile(fileName, lines)) {
|
errorMessage = tr("Keine unterstützten Übersetzungsblöcke gefunden. Die Originaldatei bleibt unverändert.");
|
||||||
qDebug() << "Failed to save file: " << backupFileName;
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!saveToFile(fileName, lines, errorMessage)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
qDebug() << fileName << " created";
|
fileContentsMap[fileName] = lines.join('\n');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MainWindow::parseAndEditFile(QFile &backupFile, QTreeWidgetItem *fileItem, QStringList &lines, bool &changed) {
|
bool MainWindow::parseAndEditFile(QFile &backupFile, QTreeWidgetItem *fileItem, QStringList &lines, bool &changed) {
|
||||||
QTextStream backupStream(&backupFile);
|
QTextStream backupStream(&backupFile);
|
||||||
QString content = backupStream.readAll();
|
QString content = backupStream.readAll();
|
||||||
static QRegularExpression translateRegex(R"(# game\/(.+):(\d+)\ntranslate\s+[^\s]+\s+(.*?)\n\n\s*#.*?\"(.*?)\"\s*\n\s*(.*?)\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"(\n\s*#\s*game\/(.+?):(\d+?)\n\s*old\s*\"(.+?)\"\n\s*new\s*\"(.*?)\")");
|
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;
|
QRegularExpressionMatch match;
|
||||||
int offset = 0;
|
int offset = 0;
|
||||||
while ((match = translateRegex.match(content, offset)).hasMatch()) {
|
while ((match = translateRegex.match(content, offset)).hasMatch()) {
|
||||||
@@ -627,7 +756,7 @@ bool MainWindow::parseAndEditFile(QFile &backupFile, QTreeWidgetItem *fileItem,
|
|||||||
QString replacedBlock = block;
|
QString replacedBlock = block;
|
||||||
const int translatedTextOffset = match.capturedStart(6) - match.capturedStart(0);
|
const int translatedTextOffset = match.capturedStart(6) - match.capturedStart(0);
|
||||||
replacedBlock.replace(translatedTextOffset, match.capturedLength(6),
|
replacedBlock.replace(translatedTextOffset, match.capturedLength(6),
|
||||||
replacedText.isEmpty() ? newText : replacedText);
|
replacedText.isEmpty() ? newText : escapeTranslationForRenpy(replacedText));
|
||||||
content.replace(match.capturedStart(0), match.capturedLength(0), replacedBlock);
|
content.replace(match.capturedStart(0), match.capturedLength(0), replacedBlock);
|
||||||
// The replacement can have a different length. Continue after the new
|
// The replacement can have a different length. Continue after the new
|
||||||
// block, otherwise later translations in this file may be skipped.
|
// block, otherwise later translations in this file may be skipped.
|
||||||
@@ -644,7 +773,7 @@ bool MainWindow::parseAndEditFile(QFile &backupFile, QTreeWidgetItem *fileItem,
|
|||||||
QString replacedBlock = block;
|
QString replacedBlock = block;
|
||||||
const int translatedTextOffset = match.capturedStart(4) - match.capturedStart(0);
|
const int translatedTextOffset = match.capturedStart(4) - match.capturedStart(0);
|
||||||
replacedBlock.replace(translatedTextOffset, match.capturedLength(4),
|
replacedBlock.replace(translatedTextOffset, match.capturedLength(4),
|
||||||
replacedText.isEmpty() ? newText : replacedText);
|
replacedText.isEmpty() ? newText : escapeTranslationForRenpy(replacedText));
|
||||||
content.replace(match.capturedStart(0), match.capturedLength(0), replacedBlock);
|
content.replace(match.capturedStart(0), match.capturedLength(0), replacedBlock);
|
||||||
offset = match.capturedStart(0) + replacedBlock.size();
|
offset = match.capturedStart(0) + replacedBlock.size();
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -735,16 +864,13 @@ void MainWindow::onDeeplTranslationPossibilitiesLoaded(QNetworkReply *reply) {
|
|||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MainWindow::saveToFile(const QString &backupFileName, const QStringList &lines) {
|
bool MainWindow::saveToFile(const QString &fileName, const QStringList &lines, QString &errorMessage) {
|
||||||
QFile backupFile(backupFileName);
|
QSaveFile output(fileName);
|
||||||
if (!backupFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
const QByteArray data = lines.join('\n').toUtf8();
|
||||||
qDebug() << "Failed to open backup file for writing: " << backupFileName;
|
if (!output.open(QIODevice::WriteOnly) || output.write(data) != data.size() || !output.commit()) {
|
||||||
|
errorMessage = tr("Datei konnte nicht geschrieben werden: %1").arg(output.errorString());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
QTextStream out(&backupFile);
|
|
||||||
out << lines.join("\n") << Qt::endl;
|
|
||||||
backupFile.close();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,8 +987,7 @@ void MainWindow::on_reviewIdenticalTranslationsButton_clicked()
|
|||||||
QTreeWidgetItem *fileItem = ui->treeWidget->topLevelItem(fileIndex);
|
QTreeWidgetItem *fileItem = ui->treeWidget->topLevelItem(fileIndex);
|
||||||
for (int itemIndex = 0; itemIndex < fileItem->childCount(); ++itemIndex) {
|
for (int itemIndex = 0; itemIndex < fileItem->childCount(); ++itemIndex) {
|
||||||
QTreeWidgetItem *item = fileItem->child(itemIndex);
|
QTreeWidgetItem *item = fileItem->child(itemIndex);
|
||||||
if (!item->text(2).isEmpty() && item->text(2).trimmed() == item->text(3).trimmed()
|
if (isOpenTranslation(item) && item->text(2).trimmed() == item->text(3).trimmed()) {
|
||||||
&& !isIdenticalTranslationApproved(item)) {
|
|
||||||
candidates.append(item);
|
candidates.append(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -914,6 +1039,157 @@ void MainWindow::on_reviewIdenticalTranslationsButton_clicked()
|
|||||||
countAndShowUntranslated();
|
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()
|
void MainWindow::on_bulkAutoTranslateButton_clicked()
|
||||||
{
|
{
|
||||||
bulkUsesLibreTranslate = isLibreTranslateProvider();
|
bulkUsesLibreTranslate = isLibreTranslateProvider();
|
||||||
@@ -1086,7 +1362,6 @@ void MainWindow::handleBulkTranslationReply(QNetworkReply *reply, int firstItem,
|
|||||||
: translations.at(index).toObject().value("text").toString();
|
: translations.at(index).toObject().value("text").toString();
|
||||||
BulkTranslationItem &item = bulkTranslations[firstItem + index];
|
BulkTranslationItem &item = bulkTranslations[firstItem + index];
|
||||||
item.translatedText = restoreBulkPlaceholders(translatedText, item.placeholders);
|
item.translatedText = restoreBulkPlaceholders(translatedText, item.placeholders);
|
||||||
item.translatedText.replace('"', '\'');
|
|
||||||
// An unchanged response normally indicates that the selected local model
|
// An unchanged response normally indicates that the selected local model
|
||||||
// cannot translate this language pair. Keep it open for a later retry.
|
// cannot translate this language pair. Keep it open for a later retry.
|
||||||
if (item.translatedText.trimmed() == item.originalText.trimmed()) {
|
if (item.translatedText.trimmed() == item.originalText.trimmed()) {
|
||||||
@@ -1199,7 +1474,6 @@ void MainWindow::showBulkTranslationDialog()
|
|||||||
? response.value("translatedText").toString()
|
? response.value("translatedText").toString()
|
||||||
: (!translations.isEmpty() ? translations.at(0).toObject().value("text").toString() : QString());
|
: (!translations.isEmpty() ? translations.at(0).toObject().value("text").toString() : QString());
|
||||||
translatedText = restoreBulkPlaceholders(translatedText, placeholders);
|
translatedText = restoreBulkPlaceholders(translatedText, placeholders);
|
||||||
translatedText.replace('"', '\'');
|
|
||||||
bulkTranslations[bulkIndex].translatedText = translatedText;
|
bulkTranslations[bulkIndex].translatedText = translatedText;
|
||||||
translationEdit->setPlainText(translatedText);
|
translationEdit->setPlainText(translatedText);
|
||||||
}
|
}
|
||||||
@@ -1305,7 +1579,6 @@ void MainWindow::translationRequestFinished(QNetworkReply *reply, bool isLibreTr
|
|||||||
: translationsArray[0].toObject()["text"].toString();
|
: translationsArray[0].toObject()["text"].toString();
|
||||||
// Restore any placeholders that were protected before the request
|
// Restore any placeholders that were protected before the request
|
||||||
QString restored = restorePlaceholdersFromDeepl(translatedText);
|
QString restored = restorePlaceholdersFromDeepl(translatedText);
|
||||||
restored.replace("\"", "'");
|
|
||||||
ui->translationEdit->setText(restored);
|
ui->translationEdit->setText(restored);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1393,7 +1666,11 @@ bool MainWindow::isOpenTranslation(const QTreeWidgetItem *item) const
|
|||||||
}
|
}
|
||||||
const QString original = item->text(2).trimmed();
|
const QString original = item->text(2).trimmed();
|
||||||
const QString translation = item->text(3).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
|
QString MainWindow::identicalTranslationApprovalKey(const QTreeWidgetItem *item) const
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ private slots:
|
|||||||
void on_autoTranslateButton_clicked();
|
void on_autoTranslateButton_clicked();
|
||||||
void on_retryTranslationButton_clicked();
|
void on_retryTranslationButton_clicked();
|
||||||
void on_reviewIdenticalTranslationsButton_clicked();
|
void on_reviewIdenticalTranslationsButton_clicked();
|
||||||
|
void on_approveIdenticalTranslationButton_clicked();
|
||||||
|
void on_reuseTranslationsButton_clicked();
|
||||||
void on_bulkAutoTranslateButton_clicked();
|
void on_bulkAutoTranslateButton_clicked();
|
||||||
void on_checkDeeplUsageButton_clicked();
|
void on_checkDeeplUsageButton_clicked();
|
||||||
void on_translationProviderCombo_currentIndexChanged(int index);
|
void on_translationProviderCombo_currentIndexChanged(int index);
|
||||||
@@ -81,9 +83,9 @@ private:
|
|||||||
void populateTreeWidgetFromMap();
|
void populateTreeWidgetFromMap();
|
||||||
void saveFiles(QStringList &failedFiles);
|
void saveFiles(QStringList &failedFiles);
|
||||||
bool saveFile(QTreeWidgetItem *fileItem, QString &errorMessage);
|
bool saveFile(QTreeWidgetItem *fileItem, QString &errorMessage);
|
||||||
bool createBackup(const QString &filePath, const QString &backupFileName);
|
bool createBackup(const QString &filePath, const QString &backupFileName, QString &errorMessage);
|
||||||
bool editAndSaveFile(QTreeWidgetItem *fileItem, const QString &backupFileName, const QString &fileName);
|
bool editAndSaveFile(QTreeWidgetItem *fileItem, const QString &backupFileName, const QString &fileName, QString &errorMessage);
|
||||||
bool saveToFile(const QString &backupFileName, const QStringList &lines);
|
bool saveToFile(const QString &fileName, const QStringList &lines, QString &errorMessage);
|
||||||
bool parseAndEditFile(QFile &backupFile, QTreeWidgetItem *fileItem, QStringList &lines, bool &changed);
|
bool parseAndEditFile(QFile &backupFile, QTreeWidgetItem *fileItem, QStringList &lines, bool &changed);
|
||||||
QString findNewText(QTreeWidgetItem *fileItem, const QString &lineNumber, const QString &originalText, const QString &speaker);
|
QString findNewText(QTreeWidgetItem *fileItem, const QString &lineNumber, const QString &originalText, const QString &speaker);
|
||||||
void loadDeeplTranslationPossibilities();
|
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="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="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="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>
|
<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>
|
</layout>
|
||||||
</item>
|
</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="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="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="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>
|
<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>
|
</layout>
|
||||||
</item>
|
</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