feat(glossary): add episode groups, search, and import/export

Apply glossary edits directly to translated subtitles and reload the active track. Keep terms with their originating episode, preserve existing translations, and clear stale episode groups with glossary entries.
This commit is contained in:
tri committed 2026-11-11 19:44:38 +07:00
1 parent 0bfaf3c9db
commit 97b4a2fe32
9 files changed
+955 -50

No files matched your search

+243
View File
@@ -4,6 +4,8 @@
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
#include <QTest>
using namespace Qt::Literals::StringLiterals;
@@ -27,6 +29,138 @@ private Q_SLOTS:
QCOMPARE(merged.entries.at(1).source, u"Sect"_s);
}
void groupsEpisodesAndSkipDuplicateSources()
{
QTemporaryDir temp;
QStandardPaths::setTestModeEnabled(true);
const QString directory = temp.path() + u"/series"_s;
GlossaryStore::Glossary existing{directory, {{u"Lin"_s, u"Lâm"_s, u"Episode 1"_s}}, {u"Episode 1"_s}};
const auto merged = GlossaryStore::merge(existing,
{{u"lin"_s, u"Khác"_s}, {u"Sect"_s, u"Tông"_s}},
u"Episode 2"_s);
const QStringList expectedEpisodes{u"Episode 1"_s, u"Episode 2"_s};
QCOMPARE(merged.episodes, expectedEpisodes);
QCOMPARE(merged.entries.size(), 2);
QCOMPARE(merged.entries.at(1).episode, u"Episode 2"_s);
QVERIFY(GlossaryStore::save(merged));
const auto loaded = GlossaryStore::load(directory);
QCOMPARE(loaded.episodes, merged.episodes);
QCOMPARE(loaded.entries.at(0).episode, u"Episode 1"_s);
}
void regroupLegacyTermsFromOriginalSubtitles()
{
const GlossaryStore::Glossary original{
u"/series"_s,
{{u"Ye Xiu"_s, u"Diệp Tu đã sửa"_s, {}},
{u"Club"_s, u"Câu lạc bộ"_s, {}},
{u"ID"_s, u"ID"_s, {}},
{u"Other"_s, u"Khác"_s, u"02 - The Skilled Supervisor"_s}},
{u"02 - The Skilled Supervisor"_s}};
const auto episode0 = GlossaryStore::assignEpisode(original,
{u"Ye Xiu watches a video."_s, u"Other players are here."_s}, u"00 - For The Glory"_s);
QCOMPARE(episode0.entries.size(), original.entries.size());
QCOMPARE(episode0.entries.at(0).episode, u"00 - For The Glory"_s);
QCOMPARE(episode0.entries.at(0).target, u"Diệp Tu đã sửa"_s);
QVERIFY(episode0.entries.at(1).episode.isEmpty());
QVERIFY(episode0.entries.at(2).episode.isEmpty()); // "video" is not "ID".
QCOMPARE(episode0.entries.at(3).episode, u"02 - The Skilled Supervisor"_s);
// Metadata-only migration must preserve existing translation cache keys.
QCOMPARE(GlossaryStore::serialize(episode0), GlossaryStore::serialize(original));
const auto episode2 = GlossaryStore::assignEpisode(episode0,
{u"Ye Xiu's CLUB."_s}, u"02 - The Skilled Supervisor"_s);
QCOMPARE(episode2.entries.at(0).episode, u"00 - For The Glory"_s);
QCOMPARE(episode2.entries.at(1).episode, u"02 - The Skilled Supervisor"_s);
const auto episode3 = GlossaryStore::assignEpisode(episode2, {u"Your ID?"_s}, u"03 - Adventurers"_s);
QCOMPARE(episode3.entries.at(2).episode, u"03 - Adventurers"_s);
const auto repeat = GlossaryStore::assignEpisode(episode3, {u"Your ID?"_s}, u"03 - Adventurers"_s);
QCOMPARE(repeat.entries.size(), original.entries.size());
QCOMPARE(repeat.episodes, episode3.episodes);
QTemporaryDir temp;
const QString path = temp.filePath(u"grouped.json"_s);
QVERIFY(GlossaryStore::exportFile(path, episode3));
GlossaryStore::Glossary reloaded;
QVERIFY(GlossaryStore::importFile(path, &reloaded));
for (qsizetype i = 0; i < repeat.entries.size(); ++i) {
QCOMPARE(reloaded.entries.at(i).episode, repeat.entries.at(i).episode);
QCOMPARE(reloaded.entries.at(i).target, repeat.entries.at(i).target);
}
}
void matchOriginalTerms_data()
{
QTest::addColumn<QString>("term");
QTest::addColumn<QStringList>("texts");
QTest::addColumn<bool>("matches");
QTest::newRow("case insensitive") << u"Club"_s << QStringList{u"The CLUB is here."_s} << true;
QTest::newRow("substring") << u"Ye"_s << QStringList{u"Yesterday was good."_s} << false;
QTest::newRow("possessive") << u"Ye"_s << QStringList{u"Ye's weapon."_s} << true;
QTest::newRow("word prefix") << u"Club"_s << QStringList{u"Clubhouse"_s} << false;
QTest::newRow("word suffix") << u"Club"_s << QStringList{u"Fanclub"_s} << false;
QTest::newRow("literal punctuation") << u"C++"_s << QStringList{u"Learn C++."_s} << true;
QTest::newRow("punctuation is not regex") << u"C++"_s << QStringList{u"CCCC"_s} << false;
QTest::newRow("unicode normalization") << u"Diệp"_s
<< QStringList{QString(u"DIỆP's weapon"_s).normalized(QString::NormalizationForm_D)} << true;
QTest::newRow("unicode boundary") << u"Ye"_s << QStringList{u"Ye秋"_s} << false;
QTest::newRow("wrapped phrase") << u" account card "_s << QStringList{u"Your account\ncard is here."_s} << true;
QTest::newRow("separate cues") << u"account card"_s << QStringList{u"account"_s, u"card"_s} << false;
QTest::newRow("target is not source") << u"Ye"_s << QStringList{u"Diệp Tu"_s} << false;
}
void matchOriginalTerms()
{
QFETCH(QString, term);
QFETCH(QStringList, texts);
QFETCH(bool, matches);
const GlossaryStore::Glossary original{{}, {{term, u"Giữ bản dịch"_s, {}}}, {}};
const auto grouped = GlossaryStore::assignEpisode(original, texts, u"Episode 2"_s);
QCOMPARE(grouped.entries.first().episode, matches ? u"Episode 2"_s : QString());
QCOMPARE(grouped.entries.first().source, term);
QCOMPARE(grouped.entries.first().target, u"Giữ bản dịch"_s);
}
void episodeSortingIsNumericAndIndependentOfLocale()
{
const QLocale previousLocale;
QLocale::setDefault(QLocale::c());
const QStringList input{u"Episode 10"_s, u"Episode 2"_s, u"Episode 0"_s, u"Episode 3"_s, {}, u"Episode 2"_s};
const auto sorted = GlossaryStore::sortedEpisodes(input);
QLocale::setDefault(previousLocale);
const QStringList expected{{}, u"Episode 0"_s, u"Episode 2"_s, u"Episode 3"_s, u"Episode 10"_s};
QCOMPARE(sorted, expected);
}
void suggestionsAreVerifiedAgainstSubtitles()
{
const GlossaryStore::Glossary original{{}, {{u"Club"_s, u"Bản dịch đã sửa"_s, {}}}, {}};
const auto merged = GlossaryStore::merge(original,
{{u"CLUB"_s, u"Bản dịch khác"_s}, {u"Ye"_s, u"Diệp"_s}, {u"Made up"_s, u"Bịa"_s}});
const auto grouped = GlossaryStore::assignEpisode(merged, {u"Ye joins the Club."_s}, u"02 - Episode"_s);
QCOMPARE(grouped.entries.size(), 3);
QCOMPARE(grouped.entries.at(0).episode, u"02 - Episode"_s);
QCOMPARE(grouped.entries.at(0).target, u"Bản dịch đã sửa"_s);
QCOMPARE(grouped.entries.at(1).episode, u"02 - Episode"_s);
QVERIFY(grouped.entries.at(2).episode.isEmpty());
}
void unassignedTermsBelongToFirstEpisodeSeen()
{
const GlossaryStore::Glossary legacy{
{},
{{u"Old term"_s, u"Thuật ngữ cũ"_s, {}},
{u"Existing"_s, u"Đã có"_s, u"Episode 1"_s}},
{u"Episode 1"_s}};
const auto grouped = GlossaryStore::assignUnassigned(legacy, u"Episode 0"_s);
QCOMPARE(grouped.entries.at(0).episode, u"Episode 0"_s);
QCOMPARE(grouped.entries.at(1).episode, u"Episode 1"_s);
const auto repeat = GlossaryStore::assignUnassigned(grouped, u"Episode 2"_s);
QCOMPARE(repeat.entries.at(0).episode, u"Episode 0"_s);
QCOMPARE(repeat.entries.at(1).episode, u"Episode 1"_s);
QVERIFY(!repeat.episodes.contains(QString()));
}
void saveFailureReturnsError()
{
QTemporaryDir temp;
@@ -50,6 +184,115 @@ private Q_SLOTS:
QFile::remove(glossaries);
}
void portableRoundTrip()
{
QTemporaryDir temp;
const QString path = temp.filePath(u"glossary.json"_s);
const GlossaryStore::Glossary original{
u"/original/machine/series"_s,
{{u"Lin"_s, u"Lâm"_s, {}}, {u"Sect"_s, u"Tông môn"_s, u"Tập 2"_s}},
{u"Tập 2"_s, u"Tập 3 (empty)"_s}};
QString error;
QVERIFY2(GlossaryStore::exportFile(path, original, &error), qPrintable(error));
QFile file(path);
QVERIFY(file.open(QIODevice::ReadOnly));
const auto object = QJsonDocument::fromJson(file.readAll()).object();
QVERIFY(!object.contains(u"directory"_s));
QCOMPARE(object.value(u"version"_s).toInt(), 1);
GlossaryStore::Glossary imported;
QVERIFY2(GlossaryStore::importFile(path, &imported, &error), qPrintable(error));
QVERIFY(imported.directory.isEmpty());
QCOMPARE(imported.episodes, original.episodes);
QCOMPARE(imported.entries.size(), 2);
QVERIFY(imported.entries.at(0).episode.isEmpty());
QCOMPARE(imported.entries.at(1).target, u"Tông môn"_s);
QCOMPARE(imported.entries.at(1).episode, u"Tập 2"_s);
}
void mergeImportedKeepsEditsAndEpisodeOwnership()
{
const GlossaryStore::Glossary existing{
u"/current/series"_s, {{u"Lin"_s, u"Lâm đã sửa"_s, u"Tập 1"_s}}, {u"Tập 1"_s}};
const GlossaryStore::Glossary imported{
u"/other/series"_s,
{{u" lin "_s, u"Khác"_s, u"Tập 2"_s},
{u"Sect"_s, u"Tông"_s, u"Tập 2"_s},
{u"SECT"_s, u"Khác nữa"_s, u"Tập 3"_s},
{u"Master"_s, u"Sư phụ"_s, {}}},
{u"Tập 2"_s, u"Tập 3"_s}};
const auto merged = GlossaryStore::mergeImported(existing, imported);
QCOMPARE(merged.directory, existing.directory);
QCOMPARE(merged.entries.size(), 3);
QCOMPARE(merged.entries.at(0).target, u"Lâm đã sửa"_s);
QCOMPARE(merged.entries.at(0).episode, u"Tập 1"_s);
QCOMPARE(merged.entries.at(1).target, u"Tông"_s);
QCOMPARE(merged.entries.at(1).episode, u"Tập 2"_s);
QVERIFY(merged.entries.at(2).episode.isEmpty());
const QStringList expected{u"Tập 1"_s, u"Tập 2"_s, u"Tập 3"_s};
QCOMPARE(merged.episodes, expected);
QCOMPARE(GlossaryStore::mergeImported(merged, imported).entries.size(), 3);
}
void importLegacyFile()
{
QTemporaryDir temp;
const QString path = temp.filePath(u"old.json"_s);
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly));
file.write(R"({"directory":"/old/path","entries":[{"source":"Lin","target":"Lam"}]})");
file.close();
GlossaryStore::Glossary imported;
QString error;
QVERIFY2(GlossaryStore::importFile(path, &imported, &error), qPrintable(error));
QCOMPARE(imported.entries.size(), 1);
QVERIFY(imported.entries.at(0).episode.isEmpty());
QVERIFY(imported.directory.isEmpty());
}
void importRejectsInvalidFiles_data()
{
QTest::addColumn<QByteArray>("content");
QTest::newRow("broken json") << QByteArray("{");
QTest::newRow("wrong root") << QByteArray("[]");
QTest::newRow("missing entries") << QByteArray("{}");
QTest::newRow("wrong version") << QByteArray(R"({"version":2,"entries":[]})");
QTest::newRow("wrong episodes") << QByteArray(R"({"entries":[],"episodes":[7]})");
QTest::newRow("wrong entry") << QByteArray(R"({"entries":[7]})");
QTest::newRow("incomplete after valid entry") << QByteArray(R"({"entries":[{"source":"Lin","target":"Lam"},{"source":"Sect","target":""}]})");
QTest::newRow("wrong episode") << QByteArray(R"({"entries":[{"source":"Lin","target":"Lam","episode":7}]})");
}
void importRejectsInvalidFiles()
{
QFETCH(QByteArray, content);
QTemporaryDir temp;
const QString path = temp.filePath(u"invalid.json"_s);
QFile file(path);
QVERIFY(file.open(QIODevice::WriteOnly));
file.write(content);
file.close();
GlossaryStore::Glossary glossary{u"/unchanged"_s, {{u"Keep"_s, u"Giữ"_s, {}}}, {}};
QString error;
QVERIFY(!GlossaryStore::importFile(path, &glossary, &error));
QVERIFY(!error.isEmpty());
QCOMPARE(glossary.directory, u"/unchanged"_s);
QCOMPARE(glossary.entries.size(), 1);
QCOMPARE(glossary.entries.at(0).source, u"Keep"_s);
}
void fileAccessErrors()
{
QTemporaryDir temp;
GlossaryStore::Glossary glossary;
QString error;
QVERIFY(!GlossaryStore::importFile(temp.filePath(u"missing.json"_s), &glossary, &error));
QVERIFY(!error.isEmpty());
error.clear();
QVERIFY(!GlossaryStore::exportFile(temp.path(), glossary, &error));
QVERIFY(!error.isEmpty());
}
void saveRejectsIncompleteEntry()
{
QString error;