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

+181 -17
View File
@@ -18,6 +18,8 @@
#include <QPointer>
#include <QSaveFile>
#include <QStandardPaths>
#include <QFile>
#include <QSet>
#include <QtConcurrentRun>
#include <chrono>
@@ -60,6 +62,25 @@ QString cachePath(const QUrl &videoUrl, const QString &key, const QString &langu
const QString baseName = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : u"subtitles"_s;
return u"%1/%2.%3.%4.srt"_s.arg(dir, baseName, key, languageSuffix(language));
}
QList<GlossaryStore::Entry> glossaryEntriesFromVariants(const QVariantList &entries)
{
QList<GlossaryStore::Entry> result;
for (const QVariant &value : entries) {
const QVariantMap map = value.toMap();
result.append({map.value(u"source"_s).toString().trimmed(), map.value(u"target"_s).toString().trimmed(), map.value(u"episode"_s).toString()});
}
return result;
}
QVariantList glossaryEntriesToVariants(const QList<GlossaryStore::Entry> &entries)
{
QVariantList result;
for (const auto &entry : entries) {
result.append(QVariantMap{{u"source"_s, entry.source}, {u"target"_s, entry.target}, {u"episode"_s, entry.episode}});
}
return result;
}
}
struct TranslationService::Job {
@@ -129,9 +150,7 @@ bool TranslationService::isFetchingModels() const
QVariantList TranslationService::glossaryEntries() const
{
QVariantList result;
for (const auto &entry : m_glossary.entries) result.append(QVariantMap{{u"source"_s, entry.source}, {u"target"_s, entry.target}});
return result;
return glossaryEntriesToVariants(m_glossary.entries);
}
QString TranslationService::glossaryDirectory() const
@@ -139,6 +158,21 @@ QString TranslationService::glossaryDirectory() const
return m_glossary.directory;
}
QString TranslationService::glossaryEpisode() const
{
return m_glossaryEpisode;
}
QStringList TranslationService::glossaryEpisodes() const
{
return GlossaryStore::sortedEpisodes(m_glossary.episodes);
}
QStringList TranslationService::sortedGlossaryEpisodes(const QStringList &episodes) const
{
return GlossaryStore::sortedEpisodes(episodes);
}
QString TranslationService::glossaryError() const
{
return m_glossaryError;
@@ -147,7 +181,14 @@ QString TranslationService::glossaryError() const
void TranslationService::loadGlossary(const QUrl &videoUrl)
{
const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
m_glossaryEpisode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : QString();
m_glossary = directory.isEmpty() ? GlossaryStore::Glossary{} : GlossaryStore::load(directory);
// A legacy glossary has no provenance for its old entries. Once it is
// opened in an episode, keep it with that episode instead of presenting a
// misleading shared bucket. Later episodes still reuse every term.
if (!m_glossaryEpisode.isEmpty()) {
m_glossary = GlossaryStore::assignUnassigned(m_glossary, m_glossaryEpisode);
}
if (!m_glossaryError.isEmpty()) {
m_glossaryError.clear();
Q_EMIT glossaryErrorChanged();
@@ -155,16 +196,16 @@ void TranslationService::loadGlossary(const QUrl &videoUrl)
Q_EMIT glossaryChanged();
}
bool TranslationService::saveGlossary(const QVariantList &entries)
bool TranslationService::saveGlossary(const QVariantList &entries, const QStringList &episodes)
{
QList<GlossaryStore::Entry> result;
for (const QVariant &value : entries) {
const QVariantMap map = value.toMap();
result.append({map.value(u"source"_s).toString().trimmed(), map.value(u"target"_s).toString().trimmed()});
}
GlossaryStore::Glossary candidate = m_glossary;
candidate.entries = result;
candidate.entries = glossaryEntriesFromVariants(entries);
candidate.episodes = episodes;
if (!m_glossaryEpisode.isEmpty()) {
candidate = GlossaryStore::assignUnassigned(candidate, m_glossaryEpisode);
}
const bool changed = GlossaryStore::serialize(candidate) != GlossaryStore::serialize(m_glossary);
const GlossaryStore::Glossary previous = m_glossary;
if (!candidate.directory.isEmpty()) {
QString error;
if (!GlossaryStore::save(candidate, &error)) {
@@ -183,16 +224,106 @@ bool TranslationService::saveGlossary(const QVariantList &entries)
Q_EMIT glossaryErrorChanged();
}
Q_EMIT glossaryChanged();
if (changed) {
applyGlossaryEdits(previous, candidate);
}
return true;
}
void TranslationService::clearGlossary()
{
m_glossary.entries.clear();
if (!m_glossary.directory.isEmpty()) {
GlossaryStore::save(m_glossary);
saveGlossary({}, {});
}
QVariantMap TranslationService::importGlossaryFile(const QUrl &url, const QVariantList &entries, const QStringList &episodes)
{
GlossaryStore::Glossary imported;
QString error;
if (!url.isLocalFile()) {
return {{u"ok"_s, false}, {u"error"_s, i18n("Choose a local glossary file.")}};
}
Q_EMIT glossaryChanged();
if (!GlossaryStore::importFile(url.toLocalFile(), &imported, &error)) {
return {{u"ok"_s, false}, {u"error"_s, i18n("Could not import glossary: %1", error)}};
}
const GlossaryStore::Glossary current{m_glossary.directory, glossaryEntriesFromVariants(entries), episodes};
auto merged = GlossaryStore::mergeImported(current, imported);
if (!m_glossaryEpisode.isEmpty()) {
merged = GlossaryStore::assignUnassigned(merged, m_glossaryEpisode);
}
const qsizetype added = merged.entries.size() - current.entries.size();
return {{u"ok"_s, true}, {u"entries"_s, glossaryEntriesToVariants(merged.entries)}, {u"episodes"_s, merged.episodes},
{u"added"_s, added}, {u"skipped"_s, imported.entries.size() - added}};
}
QVariantMap TranslationService::exportGlossaryFile(const QUrl &url, const QVariantList &entries, const QStringList &episodes)
{
if (!url.isLocalFile()) {
return {{u"ok"_s, false}, {u"error"_s, i18n("Choose a local destination file.")}};
}
const GlossaryStore::Glossary draft{{}, glossaryEntriesFromVariants(entries), episodes};
QString error;
if (!GlossaryStore::exportFile(url.toLocalFile(), draft, &error)) {
return {{u"ok"_s, false}, {u"error"_s, i18n("Could not export glossary: %1", error)}};
}
return {{u"ok"_s, true}};
}
void TranslationService::applyGlossaryEdits(const GlossaryStore::Glossary &previous, const GlossaryStore::Glossary &updated)
{
if (m_lastTranslationPath.isEmpty() || !QFileInfo::exists(m_lastTranslationPath)) {
return;
}
QFile file(m_lastTranslationPath);
if (!file.open(QIODevice::ReadOnly)) {
return;
}
QString text = QString::fromUtf8(file.readAll());
file.close();
QHash<QString, QString> oldTargets;
for (const auto &entry : previous.entries) {
oldTargets.insert(entry.source.trimmed(), entry.target.trimmed());
}
QList<QPair<QString, QString>> replacements;
for (const auto &entry : updated.entries) {
const QString source = entry.source.trimmed();
const QString target = entry.target.trimmed();
if (source.isEmpty() || target.isEmpty()) {
continue;
}
const QString oldTarget = oldTargets.value(source);
if (!oldTarget.isEmpty() && oldTarget != target) {
replacements.append({oldTarget, target});
} else if (oldTarget.isEmpty()) {
replacements.append({source, target});
}
}
QSet<QString> updatedSources;
for (const auto &entry : updated.entries) {
updatedSources.insert(entry.source.trimmed());
}
for (const auto &entry : previous.entries) {
const QString source = entry.source.trimmed();
const QString target = entry.target.trimmed();
if (!source.isEmpty() && !target.isEmpty() && !updatedSources.contains(source)) {
replacements.append({target, source});
}
}
QStringList markers;
for (qsizetype i = 0; i < replacements.size(); ++i) {
const QString marker = u"\\uE000qtpotato-glossary-%1\\uE001"_s.arg(i);
text.replace(replacements.at(i).first, marker);
markers.append(marker);
}
for (qsizetype i = 0; i < markers.size(); ++i) {
text.replace(markers.at(i), replacements.at(i).second);
}
QSaveFile output(m_lastTranslationPath);
if (!output.open(QIODevice::WriteOnly) || output.write(text.toUtf8()) < 0 || !output.commit()) {
return;
}
Q_EMIT translated(QUrl::fromLocalFile(m_lastTranslationPath), true);
}
void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle)
@@ -230,6 +361,11 @@ void TranslationService::clearError()
setErrorText({});
}
void TranslationService::forgetTranslation()
{
m_lastTranslationPath.clear();
}
QCoro::Task<std::pair<LlmClient::Config, QString>> TranslationService::loadConfig()
{
const auto settings = QtPotatoSettings::self();
@@ -320,6 +456,27 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
const QString seriesDirectory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
const bool useGlossary = !seriesDirectory.isEmpty();
GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{};
const QString episode = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).completeBaseName() : job->mediaTitle;
const auto saveGlossaryGroups = [&]() {
QString error;
if (!GlossaryStore::save(glossary, &error)) {
fail(job, i18n("Could not save the translation glossary: %1", error));
return false;
}
m_glossary = glossary;
m_glossaryEpisode = episode;
Q_EMIT glossaryChanged();
return true;
};
if (useGlossary) {
// New terms belong to the episode being translated. Migrate legacy or
// imported entries without provenance to this first episode too. This
// happens before the cache shortcut and never changes translation text.
glossary = GlossaryStore::assignUnassigned(glossary, episode);
if (!saveGlossaryGroups()) {
co_return;
}
}
const QString sourceSrt = Subtitles::toSrt(cues);
// Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once.
@@ -337,6 +494,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
QString outputPath = outputPathFor(glossary);
if (QFileInfo::exists(outputPath)) {
m_lastTranslationPath = outputPath;
m_job.reset();
setBusy(false);
setStatusText({});
@@ -378,13 +536,18 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
const qsizetype tab = encoded.indexOf(u'\t');
if (tab > 0) additions.append({encoded.left(tab), encoded.mid(tab + 1)});
}
glossary = GlossaryStore::merge(glossary, additions);
GlossaryStore::save(glossary);
// Suggestions extracted while translating this episode belong to
// this episode. Existing terms keep their original ownership.
glossary = GlossaryStore::merge(glossary, additions, episode);
if (!saveGlossaryGroups()) {
co_return;
}
} else {
setStatusText(i18n("Glossary unavailable; translating without it."));
}
outputPath = outputPathFor(glossary);
if (QFileInfo::exists(outputPath)) {
m_lastTranslationPath = outputPath;
m_job.reset();
setBusy(false);
setStatusText({});
@@ -448,6 +611,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
}
m_job.reset();
m_lastTranslationPath = outputPath;
setBusy(false);
setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), false);