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:
1 parent
0bfaf3c9db
commit
97b4a2fe32
9 files changed
+955
-50
No files matched your search
@@ -90,6 +90,24 @@ AppImage only runs on distros whose glibc is at least as new as the build machin
|
||||
Translations are cached in `~/.cache/qtpotato/translations/` as `<video>.<key>.<lang>.srt`.
|
||||
Only text subtitles can be translated; image-based ones (PGS, VobSub) are not supported.
|
||||
|
||||
Use **Subtitles → Edit Translation Glossary…** to back up or transfer terms:
|
||||
|
||||
- **Export…** saves the full current list and episode groups to a JSON file, including edits
|
||||
you have made in the dialog. The search filter does not limit the export.
|
||||
- **Import…** merges a JSON glossary into the dialog. Existing Original terms take precedence
|
||||
(duplicates are compared without case differences), and episode groups are preserved.
|
||||
Review the result and click **Save** to apply it, or **Cancel** to discard it.
|
||||
|
||||
Importing and exporting glossary files do not call the translation API.
|
||||
|
||||
Terms extracted while translating an episode are stored under that episode. Later episodes
|
||||
reuse all existing terms and keep their original ownership; only newly extracted terms are
|
||||
added to the current episode. Old or imported terms without provenance are assigned to the
|
||||
first episode in which the glossary is opened, because their original episode cannot be
|
||||
reconstructed. Your edited translations and existing episode assignments are preserved.
|
||||
Groups sort by episode number, and changing group assignments does not invalidate the
|
||||
translation cache.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
The defaults follow Haruna.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -324,6 +324,7 @@ void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title)
|
||||
for (const QVariant &entry : std::as_const(m_subtitleTracks)) {
|
||||
const QVariantMap track = entry.toMap();
|
||||
if (track.value(QStringLiteral("externalFilename")).toString() == path) {
|
||||
commandAsync(QStringList{QStringLiteral("sub-reload"), QString::number(track.value(QStringLiteral("id")).toInt())});
|
||||
showOnlySubtitle(track.value(QStringLiteral("id")).toInt());
|
||||
return;
|
||||
}
|
||||
|
||||
+212
-12
@@ -4,6 +4,7 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Dialogs
|
||||
import org.kde.ki18n
|
||||
import org.kde.kirigami as Kirigami
|
||||
import org.kde.kirigami.dialogs as KDialogs
|
||||
@@ -24,6 +25,10 @@ Kirigami.Dialog {
|
||||
return false
|
||||
}
|
||||
property string saveError: ""
|
||||
property string searchQuery: ""
|
||||
property var episodeNames: []
|
||||
property string transferMessage: ""
|
||||
property bool transferFailed: false
|
||||
// One inset for the header, content and footer so every edge lines up.
|
||||
readonly property real inset: Kirigami.Units.largeSpacing
|
||||
|
||||
@@ -49,9 +54,18 @@ Kirigami.Dialog {
|
||||
function addTerm() {
|
||||
glossaryModel.append({
|
||||
source: "",
|
||||
target: ""
|
||||
target: "",
|
||||
episode: translator.glossaryEpisode
|
||||
})
|
||||
glossaryList.currentIndex = glossaryModel.count - 1
|
||||
searchQuery = ""
|
||||
searchField.text = ""
|
||||
rebuildFilteredModel()
|
||||
for (let i = 0; i < filteredModel.count; ++i) {
|
||||
if (filteredModel.get(i).sourceIndex === glossaryModel.count - 1) {
|
||||
glossaryList.currentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
Qt.callLater(() => {
|
||||
glossaryList.forceLayout()
|
||||
glossaryList.positionViewAtIndex(glossaryList.currentIndex, ListView.Contain)
|
||||
@@ -63,32 +77,146 @@ Kirigami.Dialog {
|
||||
})
|
||||
}
|
||||
|
||||
function saveGlossary() {
|
||||
function rebuildFilteredModel() {
|
||||
filteredModel.clear()
|
||||
const query = searchQuery.trim().toLocaleLowerCase()
|
||||
const groups = Object.create(null)
|
||||
const order = []
|
||||
for (let i = 0; i < glossaryModel.count; ++i) {
|
||||
const entry = glossaryModel.get(i)
|
||||
if (!query || entry.source.toLocaleLowerCase().includes(query) || entry.target.toLocaleLowerCase().includes(query) || (entry.episode || "").toLocaleLowerCase().includes(query)) {
|
||||
const group = entry.episode || ""
|
||||
if (!groups[group]) {
|
||||
groups[group] = []
|
||||
order.push(group)
|
||||
}
|
||||
groups[group].push({ source: entry.source, target: entry.target, sourceIndex: i })
|
||||
}
|
||||
}
|
||||
if (!query) {
|
||||
for (const group of root.episodeNames) {
|
||||
if (!groups[group]) {
|
||||
groups[group] = []
|
||||
order.push(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const group of translator.sortedGlossaryEpisodes(order)) {
|
||||
filteredModel.append({ type: "group", group, source: "", target: "", sourceIndex: -1 })
|
||||
for (const entry of groups[group]) {
|
||||
filteredModel.append({ type: "entry", group, source: entry.source, target: entry.target, sourceIndex: entry.sourceIndex })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateEntry(sourceIndex, field, value) {
|
||||
const query = searchQuery.trim().toLocaleLowerCase()
|
||||
let filteredIndex = -1
|
||||
for (let i = 0; i < filteredModel.count; ++i) {
|
||||
if (filteredModel.get(i).sourceIndex === sourceIndex) {
|
||||
filteredIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const wasMatching = filteredIndex >= 0
|
||||
glossaryModel.setProperty(sourceIndex, field, value)
|
||||
const entry = glossaryModel.get(sourceIndex)
|
||||
const isMatching = !query || entry.source.toLocaleLowerCase().includes(query) || entry.target.toLocaleLowerCase().includes(query) || (entry.episode || "").toLocaleLowerCase().includes(query)
|
||||
if (wasMatching && isMatching) {
|
||||
filteredModel.setProperty(filteredIndex, field, value)
|
||||
} else if (wasMatching !== isMatching) {
|
||||
rebuildFilteredModel()
|
||||
}
|
||||
}
|
||||
|
||||
function draftEntries() {
|
||||
const entries = []
|
||||
for (let i = 0; i < glossaryModel.count; ++i) {
|
||||
const entry = glossaryModel.get(i)
|
||||
entries.push({ source: entry.source, target: entry.target })
|
||||
entries.push({ source: entry.source, target: entry.target, episode: entry.episode })
|
||||
}
|
||||
if (!translator.saveGlossary(entries)) {
|
||||
return entries
|
||||
}
|
||||
|
||||
function saveGlossary() {
|
||||
if (!translator.saveGlossary(draftEntries(), episodeNames)) {
|
||||
saveError = translator.glossaryError || KI18n.i18nc("@info", "Could not save the translation glossary. Check that the storage location is writable.")
|
||||
return
|
||||
}
|
||||
saveError = ""
|
||||
searchQuery = ""
|
||||
searchField.text = ""
|
||||
root.accept()
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
saveError = ""
|
||||
transferMessage = ""
|
||||
transferFailed = false
|
||||
episodeNames = translator.glossaryEpisodes.slice()
|
||||
searchQuery = ""
|
||||
searchField.text = ""
|
||||
glossaryModel.clear()
|
||||
for (const entry of translator.glossaryEntries) {
|
||||
glossaryModel.append(entry)
|
||||
}
|
||||
rebuildFilteredModel()
|
||||
glossaryList.positionViewAtBeginning()
|
||||
}
|
||||
|
||||
onClosed: {
|
||||
importFileDialog.close()
|
||||
exportFileDialog.close()
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: importFileDialog
|
||||
objectName: "glossaryImportFileDialog"
|
||||
title: KI18n.i18nc("@title:window", "Import Glossary")
|
||||
fileMode: FileDialog.OpenFile
|
||||
nameFilters: [KI18n.i18nc("@item:inlistbox", "Glossary JSON files (*.json)")]
|
||||
onAccepted: {
|
||||
const result = root.translator.importGlossaryFile(selectedFile, root.draftEntries(), root.episodeNames)
|
||||
root.transferFailed = !result.ok
|
||||
if (!result.ok) {
|
||||
root.transferMessage = result.error
|
||||
return
|
||||
}
|
||||
glossaryModel.clear()
|
||||
for (const entry of result.entries) {
|
||||
glossaryModel.append(entry)
|
||||
}
|
||||
root.episodeNames = result.episodes
|
||||
searchField.text = ""
|
||||
root.rebuildFilteredModel()
|
||||
glossaryList.positionViewAtBeginning()
|
||||
root.transferMessage = KI18n.i18nc("@info", "Added %1 terms; skipped %2 duplicates. Click Save to apply.", result.added, result.skipped)
|
||||
}
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: exportFileDialog
|
||||
objectName: "glossaryExportFileDialog"
|
||||
title: KI18n.i18nc("@title:window", "Export Glossary")
|
||||
fileMode: FileDialog.SaveFile
|
||||
defaultSuffix: "json"
|
||||
nameFilters: [KI18n.i18nc("@item:inlistbox", "Glossary JSON files (*.json)")]
|
||||
onAccepted: {
|
||||
const result = root.translator.exportGlossaryFile(selectedFile, root.draftEntries(), root.episodeNames)
|
||||
root.transferFailed = !result.ok
|
||||
root.transferMessage = result.ok
|
||||
? KI18n.i18nc("@info", "Glossary exported successfully.")
|
||||
: result.error
|
||||
}
|
||||
}
|
||||
ListModel {
|
||||
id: glossaryModel
|
||||
}
|
||||
|
||||
ListModel {
|
||||
id: filteredModel
|
||||
}
|
||||
|
||||
// Union's DialogButtonBox adds its own padding on top of the dialog's, so
|
||||
// lay the footer out ourselves with the same inset as the header and content.
|
||||
footer: Control {
|
||||
@@ -162,6 +290,14 @@ Kirigami.Dialog {
|
||||
showCloseButton: false
|
||||
}
|
||||
|
||||
Kirigami.InlineMessage {
|
||||
Layout.fillWidth: true
|
||||
visible: root.transferMessage.length > 0
|
||||
type: root.transferFailed ? Kirigami.MessageType.Error : Kirigami.MessageType.Positive
|
||||
text: root.transferMessage
|
||||
showCloseButton: true
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
@@ -172,6 +308,23 @@ Kirigami.Dialog {
|
||||
color: Kirigami.Theme.disabledTextColor
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "document-import"
|
||||
Accessible.name: KI18n.i18nc("@action accessible name", "Import glossary")
|
||||
ToolTip.text: KI18n.i18nc("@info:tooltip", "Import glossary")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: importFileDialog.open()
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "document-export"
|
||||
enabled: !root.hasIncompleteTerms
|
||||
Accessible.name: KI18n.i18nc("@action accessible name", "Export glossary")
|
||||
ToolTip.text: KI18n.i18nc("@info:tooltip", "Export glossary")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: exportFileDialog.open()
|
||||
}
|
||||
|
||||
Button {
|
||||
action: addTermAction
|
||||
}
|
||||
@@ -180,7 +333,23 @@ Kirigami.Dialog {
|
||||
text: KI18n.i18nc("@action:button", "Clear All")
|
||||
icon.name: "edit-clear-all"
|
||||
enabled: glossaryModel.count > 0
|
||||
onClicked: glossaryModel.clear()
|
||||
onClicked: {
|
||||
glossaryModel.clear()
|
||||
episodeNames = []
|
||||
rebuildFilteredModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: searchField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: KI18n.i18nc("@info:placeholder glossary search", "Search original or translation…")
|
||||
Accessible.name: KI18n.i18nc("@label:textbox glossary search", "Search glossary")
|
||||
onTextChanged: {
|
||||
root.searchQuery = text
|
||||
root.rebuildFilteredModel()
|
||||
glossaryList.positionViewAtBeginning()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,24 +436,42 @@ Kirigami.Dialog {
|
||||
clip: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: glossaryModel
|
||||
model: filteredModel
|
||||
|
||||
delegate: RowLayout {
|
||||
id: termRow
|
||||
required property int index
|
||||
required property int sourceIndex
|
||||
required property string type
|
||||
required property string group
|
||||
required property string source
|
||||
required property string target
|
||||
|
||||
width: glossaryList.width
|
||||
height: implicitHeight
|
||||
height: type === "group" ? groupLabel.implicitHeight + Kirigami.Units.smallSpacing : implicitHeight
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Label {
|
||||
id: groupLabel
|
||||
visible: termRow.type === "group"
|
||||
Layout.fillWidth: true
|
||||
text: termRow.group || KI18n.i18nc("@label glossary group", "Unassigned (legacy/import)")
|
||||
font.bold: true
|
||||
elide: Text.ElideMiddle
|
||||
ToolTip.text: text
|
||||
ToolTip.visible: groupHover.hovered
|
||||
HoverHandler { id: groupHover }
|
||||
color: Kirigami.Theme.highlightColor
|
||||
bottomPadding: Kirigami.Units.smallSpacing
|
||||
}
|
||||
|
||||
function focusOriginal() {
|
||||
originalField.forceActiveFocus(Qt.TabFocusReason)
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: originalField
|
||||
visible: termRow.type === "entry"
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 1
|
||||
Layout.minimumWidth: 0
|
||||
@@ -293,10 +480,11 @@ Kirigami.Dialog {
|
||||
Component.onCompleted: cursorPosition = 0
|
||||
placeholderText: KI18n.i18nc("@info:placeholder glossary source term", "Original term")
|
||||
Accessible.name: KI18n.i18nc("@label:textbox glossary source term", "Original term")
|
||||
onTextEdited: glossaryModel.setProperty(termRow.index, "source", text)
|
||||
onTextEdited: root.updateEntry(termRow.sourceIndex, "source", text)
|
||||
}
|
||||
|
||||
TextField {
|
||||
visible: termRow.type === "entry"
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 1
|
||||
Layout.minimumWidth: 0
|
||||
@@ -305,10 +493,11 @@ Kirigami.Dialog {
|
||||
Component.onCompleted: cursorPosition = 0
|
||||
placeholderText: KI18n.i18nc("@info:placeholder glossary translated term", "Translation")
|
||||
Accessible.name: KI18n.i18nc("@label:textbox glossary translated term", "Translation")
|
||||
onTextEdited: glossaryModel.setProperty(termRow.index, "target", text)
|
||||
onTextEdited: root.updateEntry(termRow.sourceIndex, "target", text)
|
||||
}
|
||||
|
||||
Button {
|
||||
visible: termRow.type === "entry"
|
||||
Layout.preferredWidth: root.removeButtonWidth
|
||||
Layout.minimumWidth: root.removeButtonWidth
|
||||
Layout.maximumWidth: root.removeButtonWidth
|
||||
@@ -322,7 +511,10 @@ Kirigami.Dialog {
|
||||
ToolTip.text: text
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||
onClicked: glossaryModel.remove(termRow.index)
|
||||
onClicked: {
|
||||
glossaryModel.remove(termRow.sourceIndex)
|
||||
root.rebuildFilteredModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,11 +523,19 @@ Kirigami.Dialog {
|
||||
Kirigami.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Kirigami.Units.largeSpacing * 2
|
||||
visible: glossaryModel.count === 0
|
||||
visible: glossaryModel.count === 0 && filteredModel.count === 0 && !root.searchQuery.trim()
|
||||
text: KI18n.i18nc("@info:placeholder", "No glossary terms yet")
|
||||
explanation: KI18n.i18nc("@info:placeholder", "Terms are extracted automatically before translation, or you can add them manually.")
|
||||
helpfulAction: addTermAction
|
||||
}
|
||||
|
||||
Kirigami.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Kirigami.Units.largeSpacing * 2
|
||||
visible: glossaryModel.count > 0 && filteredModel.count === 0
|
||||
text: KI18n.i18nc("@info:placeholder glossary search", "No matching terms")
|
||||
explanation: KI18n.i18nc("@info:placeholder glossary search", "Try a different search.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-4
@@ -20,6 +20,11 @@ ApplicationWindow {
|
||||
|
||||
property int previousVisibility: Window.Windowed
|
||||
readonly property bool isFullScreen: visibility === Window.FullScreen
|
||||
// Keep the original subtitle track so glossary edits update the generated
|
||||
// subtitle even while that translated track is selected in mpv.
|
||||
property var lastTranslationTrack: null
|
||||
property url lastTranslationVideo: ""
|
||||
property var translatedSubtitleUrls: []
|
||||
|
||||
function openFile(url: url) {
|
||||
playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings)
|
||||
@@ -42,8 +47,13 @@ ApplicationWindow {
|
||||
}
|
||||
|
||||
function translateCurrentSubtitle() {
|
||||
const track = video.subtitleTracks.find(track => track.id === video.subtitleId)
|
||||
let track = video.subtitleTracks.find(track => track.id === video.subtitleId)
|
||||
if (track && translatedSubtitleUrls.includes(track.externalFilename) && lastTranslationTrack) {
|
||||
track = video.subtitleTracks.find(track => track.id === lastTranslationTrack.id)
|
||||
}
|
||||
if (track) {
|
||||
lastTranslationTrack = track
|
||||
lastTranslationVideo = video.currentUrl
|
||||
translationService.translate(video.currentUrl, track, video.mediaTitle)
|
||||
}
|
||||
}
|
||||
@@ -154,6 +164,8 @@ ApplicationWindow {
|
||||
TranslationService {
|
||||
id: translationService
|
||||
onTranslated: (subtitleUrl, fromCache) => {
|
||||
// mpv exposes local external subtitle filenames as filesystem paths.
|
||||
appWindow.translatedSubtitleUrls.push(decodeURIComponent(subtitleUrl.toString().replace(/^file:\/\//, "")))
|
||||
video.showTranslatedSubtitleFile(subtitleUrl,
|
||||
KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage))
|
||||
osdItem.message(fromCache
|
||||
@@ -165,6 +177,15 @@ ApplicationWindow {
|
||||
MpvVideo {
|
||||
id: video
|
||||
|
||||
onCurrentUrlChanged: {
|
||||
translationService.cancel()
|
||||
translationService.forgetTranslation()
|
||||
appWindow.lastTranslationTrack = null
|
||||
appWindow.lastTranslationVideo = ""
|
||||
appWindow.translatedSubtitleUrls = []
|
||||
glossaryDialog.close()
|
||||
}
|
||||
|
||||
actions: appActions
|
||||
fullscreen: appWindow.isFullScreen
|
||||
|
||||
@@ -208,9 +229,6 @@ ApplicationWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// A translation belongs to the file it was started for.
|
||||
onCurrentUrlChanged: translationService.cancel()
|
||||
|
||||
Osd {
|
||||
id: osdItem
|
||||
maxWidth: video.width
|
||||
|
||||
+247
-13
@@ -4,18 +4,103 @@
|
||||
#include "glossarystore.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QCollator>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QLocale>
|
||||
#include <QRegularExpression>
|
||||
#include <QSaveFile>
|
||||
#include <QSet>
|
||||
#include <QStandardPaths>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
QStringList GlossaryStore::sortedEpisodes(QStringList episodes)
|
||||
{
|
||||
episodes.removeDuplicates();
|
||||
// The C/POSIX collator ignores numeric mode. Use a fixed locale so the
|
||||
// order is stable across machines and 2 always precedes 10.
|
||||
QCollator collator(QLocale(QLocale::English, QLocale::UnitedStates));
|
||||
collator.setNumericMode(true);
|
||||
collator.setCaseSensitivity(Qt::CaseInsensitive);
|
||||
std::sort(episodes.begin(), episodes.end(), [&](const QString &a, const QString &b) {
|
||||
if (a.isEmpty() != b.isEmpty()) {
|
||||
return a.isEmpty();
|
||||
}
|
||||
const int order = collator.compare(a, b);
|
||||
return order == 0 ? a < b : order < 0;
|
||||
});
|
||||
return episodes;
|
||||
}
|
||||
|
||||
GlossaryStore::Glossary GlossaryStore::assignEpisode(const Glossary &existing, const QStringList &subtitleTexts, const QString &episode)
|
||||
{
|
||||
if (episode.isEmpty()) {
|
||||
return existing;
|
||||
}
|
||||
Glossary result = existing;
|
||||
if (!result.episodes.contains(episode)) {
|
||||
result.episodes.append(episode);
|
||||
}
|
||||
result.episodes = sortedEpisodes(result.episodes);
|
||||
|
||||
QStringList texts;
|
||||
for (const QString &text : subtitleTexts) {
|
||||
// Match wrapped phrases within a cue, not unrelated words across cues.
|
||||
texts.append(text.normalized(QString::NormalizationForm_C).toCaseFolded().simplified());
|
||||
}
|
||||
for (Entry &entry : result.entries) {
|
||||
if (!entry.episode.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
const QString term = entry.source.normalized(QString::NormalizationForm_C).toCaseFolded().simplified();
|
||||
if (term.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
// Unicode word boundaries prevent "Ye" matching "yesterday", or "ID"
|
||||
// matching "video". Escape punctuation so names like "C++" are literal.
|
||||
const QRegularExpression pattern(u"(?<![\\p{L}\\p{M}\\p{N}_])"_s
|
||||
+ QRegularExpression::escape(term)
|
||||
+ u"(?![\\p{L}\\p{M}\\p{N}_])"_s);
|
||||
for (const QString &text : std::as_const(texts)) {
|
||||
if (pattern.match(text).hasMatch()) {
|
||||
entry.episode = episode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
GlossaryStore::Glossary GlossaryStore::assignUnassigned(const Glossary &existing, const QString &episode)
|
||||
{
|
||||
if (episode.isEmpty()) {
|
||||
return existing;
|
||||
}
|
||||
Glossary result = existing;
|
||||
bool assigned = false;
|
||||
for (Entry &entry : result.entries) {
|
||||
if (entry.episode.isEmpty()) {
|
||||
entry.episode = episode;
|
||||
assigned = true;
|
||||
}
|
||||
}
|
||||
if (assigned && !result.episodes.contains(episode)) {
|
||||
result.episodes.append(episode);
|
||||
}
|
||||
if (assigned) {
|
||||
result.episodes = sortedEpisodes(result.episodes);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QString GlossaryStore::pathForDirectory(const QString &directory)
|
||||
{
|
||||
const QString root = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + u"/glossaries"_s;
|
||||
@@ -23,33 +108,63 @@ QString GlossaryStore::pathForDirectory(const QString &directory)
|
||||
return root + u"/"_s + QString::fromLatin1(digest) + u".json"_s;
|
||||
}
|
||||
|
||||
static QJsonObject glossaryJson(const GlossaryStore::Glossary &glossary)
|
||||
static QJsonObject glossaryJson(const GlossaryStore::Glossary &glossary, bool includeGroups = true)
|
||||
{
|
||||
QJsonArray entries;
|
||||
for (const auto &entry : glossary.entries) {
|
||||
if (!entry.source.trimmed().isEmpty() && !entry.target.trimmed().isEmpty()) {
|
||||
entries.append(QJsonObject{{u"source"_s, entry.source}, {u"target"_s, entry.target}});
|
||||
QJsonObject object{{u"source"_s, entry.source}, {u"target"_s, entry.target}};
|
||||
if (includeGroups && !entry.episode.isEmpty()) {
|
||||
object.insert(u"episode"_s, entry.episode);
|
||||
}
|
||||
entries.append(object);
|
||||
}
|
||||
}
|
||||
return QJsonObject{{u"directory"_s, glossary.directory}, {u"entries"_s, entries}};
|
||||
QJsonObject object{{u"directory"_s, glossary.directory}, {u"entries"_s, entries}};
|
||||
if (includeGroups) {
|
||||
object.insert(u"episodes"_s, QJsonArray::fromStringList(GlossaryStore::sortedEpisodes(glossary.episodes)));
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
GlossaryStore::Glossary GlossaryStore::load(const QString &directory)
|
||||
{
|
||||
Glossary result{QDir::cleanPath(directory), {}};
|
||||
Glossary result{QDir::cleanPath(directory), {}, {}};
|
||||
QFile file(pathForDirectory(result.directory));
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return result;
|
||||
}
|
||||
const QJsonObject object = QJsonDocument::fromJson(file.readAll()).object();
|
||||
for (const auto &value : object.value(u"episodes"_s).toArray()) {
|
||||
const QString episode = value.toString();
|
||||
if (!episode.isEmpty() && !result.episodes.contains(episode)) {
|
||||
result.episodes.append(episode);
|
||||
}
|
||||
}
|
||||
for (const QJsonValue &value : object.value(u"entries"_s).toArray()) {
|
||||
const QJsonObject entry = value.toObject();
|
||||
const QString source = entry.value(u"source"_s).toString().trimmed();
|
||||
const QString target = entry.value(u"target"_s).toString().trimmed();
|
||||
if (!source.isEmpty() && !target.isEmpty()) {
|
||||
result.entries.append({source, target});
|
||||
const QString episode = entry.value(u"episode"_s).toString();
|
||||
result.entries.append({source, target, episode});
|
||||
if (!episode.isEmpty() && !result.episodes.contains(episode)) {
|
||||
result.episodes.append(episode);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Do not resurrect empty groups from an older file. A group is meaningful
|
||||
// only while at least one entry belongs to it.
|
||||
QSet<QString> usedEpisodes;
|
||||
for (const Entry &entry : std::as_const(result.entries)) {
|
||||
if (!entry.episode.isEmpty()) {
|
||||
usedEpisodes.insert(entry.episode);
|
||||
}
|
||||
}
|
||||
result.episodes.erase(std::remove_if(result.episodes.begin(), result.episodes.end(), [&](const QString &episode) {
|
||||
return !usedEpisodes.contains(episode);
|
||||
}), result.episodes.end());
|
||||
result.episodes = sortedEpisodes(result.episodes);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -72,7 +187,7 @@ bool GlossaryStore::save(const Glossary &glossary, QString *error)
|
||||
return false;
|
||||
}
|
||||
QSaveFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(QJsonDocument(glossaryJson({directory, glossary.entries})).toJson(QJsonDocument::Indented)) < 0 || !file.commit()) {
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(QJsonDocument(glossaryJson({directory, glossary.entries, glossary.episodes})).toJson(QJsonDocument::Indented)) < 0 || !file.commit()) {
|
||||
if (error) {
|
||||
*error = file.errorString();
|
||||
if (error->isEmpty()) {
|
||||
@@ -84,19 +199,24 @@ bool GlossaryStore::save(const Glossary &glossary, QString *error)
|
||||
return true;
|
||||
}
|
||||
|
||||
GlossaryStore::Glossary GlossaryStore::merge(const Glossary &existing, const QList<Entry> &additions)
|
||||
GlossaryStore::Glossary GlossaryStore::merge(const Glossary &existing, const QList<Entry> &additions, const QString &episode)
|
||||
{
|
||||
Glossary result{QDir::cleanPath(existing.directory), existing.entries};
|
||||
Glossary result = existing;
|
||||
result.directory = QDir::cleanPath(existing.directory);
|
||||
if (!episode.isEmpty() && !result.episodes.contains(episode)) {
|
||||
result.episodes.append(episode);
|
||||
}
|
||||
QSet<QString> sources;
|
||||
for (const Entry &entry : result.entries) {
|
||||
sources.insert(entry.source.trimmed());
|
||||
sources.insert(entry.source.trimmed().normalized(QString::NormalizationForm_C).toCaseFolded());
|
||||
}
|
||||
for (const Entry &entry : additions) {
|
||||
const QString source = entry.source.trimmed();
|
||||
const QString target = entry.target.trimmed();
|
||||
if (!source.isEmpty() && !target.isEmpty() && !sources.contains(source)) {
|
||||
result.entries.append({source, target});
|
||||
sources.insert(source);
|
||||
const QString key = source.normalized(QString::NormalizationForm_C).toCaseFolded();
|
||||
if (!source.isEmpty() && !target.isEmpty() && !sources.contains(key)) {
|
||||
result.entries.append({source, target, episode});
|
||||
sources.insert(key);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -104,5 +224,119 @@ GlossaryStore::Glossary GlossaryStore::merge(const Glossary &existing, const QLi
|
||||
|
||||
QString GlossaryStore::serialize(const Glossary &glossary)
|
||||
{
|
||||
return QString::fromUtf8(QJsonDocument(glossaryJson(glossary)).toJson(QJsonDocument::Compact));
|
||||
// Grouping is presentation metadata, not a change to translation inputs.
|
||||
// Preserve existing cache keys when old entries acquire groups.
|
||||
return QString::fromUtf8(QJsonDocument(glossaryJson(glossary, false)).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
bool GlossaryStore::exportFile(const QString &path, const Glossary &glossary, QString *error)
|
||||
{
|
||||
for (const Entry &entry : glossary.entries) {
|
||||
if (entry.source.trimmed().isEmpty() || entry.target.trimmed().isEmpty()) {
|
||||
if (error) *error = QStringLiteral("Each glossary term must have both an original and a translation.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
QJsonObject object = glossaryJson(glossary);
|
||||
object.remove(u"directory"_s);
|
||||
object.insert(u"version"_s, 1);
|
||||
QSaveFile file(path);
|
||||
const QByteArray data = QJsonDocument(object).toJson(QJsonDocument::Indented);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) {
|
||||
if (error) *error = file.errorString();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GlossaryStore::importFile(const QString &path, Glossary *glossary, QString *error)
|
||||
{
|
||||
const auto invalid = [&](const QString &message) {
|
||||
if (error) *error = message;
|
||||
return false;
|
||||
};
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return invalid(file.errorString());
|
||||
}
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError) {
|
||||
return invalid(parseError.errorString());
|
||||
}
|
||||
if (!document.isObject()) {
|
||||
return invalid(QStringLiteral("Expected a glossary JSON object."));
|
||||
}
|
||||
const QJsonObject object = document.object();
|
||||
if (object.contains(u"version"_s) && object.value(u"version"_s) != QJsonValue(1)) {
|
||||
return invalid(QStringLiteral("Unsupported glossary file version."));
|
||||
}
|
||||
if (!object.value(u"entries"_s).isArray()) {
|
||||
return invalid(QStringLiteral("The glossary must contain an entries array."));
|
||||
}
|
||||
Glossary result;
|
||||
if (object.contains(u"episodes"_s)) {
|
||||
if (!object.value(u"episodes"_s).isArray()) {
|
||||
return invalid(QStringLiteral("The episodes field must be an array."));
|
||||
}
|
||||
for (const QJsonValue &value : object.value(u"episodes"_s).toArray()) {
|
||||
if (!value.isString()) {
|
||||
return invalid(QStringLiteral("Each episode must be a string."));
|
||||
}
|
||||
const QString episode = value.toString();
|
||||
if (!episode.isEmpty() && !result.episodes.contains(episode)) {
|
||||
result.episodes.append(episode);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const QJsonValue &value : object.value(u"entries"_s).toArray()) {
|
||||
if (!value.isObject()) {
|
||||
return invalid(QStringLiteral("Each glossary entry must be an object."));
|
||||
}
|
||||
const QJsonObject entry = value.toObject();
|
||||
const QString source = entry.value(u"source"_s).toString().trimmed();
|
||||
const QString target = entry.value(u"target"_s).toString().trimmed();
|
||||
if (source.isEmpty() || target.isEmpty()) {
|
||||
return invalid(QStringLiteral("Each glossary term must have both an original and a translation."));
|
||||
}
|
||||
if (entry.contains(u"episode"_s) && !entry.value(u"episode"_s).isString()) {
|
||||
return invalid(QStringLiteral("The entry episode must be a string."));
|
||||
}
|
||||
const QString episode = entry.value(u"episode"_s).toString();
|
||||
result.entries.append({source, target, episode});
|
||||
if (!episode.isEmpty() && !result.episodes.contains(episode)) {
|
||||
result.episodes.append(episode);
|
||||
}
|
||||
}
|
||||
*glossary = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
GlossaryStore::Glossary GlossaryStore::mergeImported(const Glossary &existing, const Glossary &imported)
|
||||
{
|
||||
Glossary result = existing;
|
||||
for (const QString &episode : imported.episodes) {
|
||||
if (!episode.isEmpty() && !result.episodes.contains(episode)) {
|
||||
result.episodes.append(episode);
|
||||
}
|
||||
}
|
||||
QSet<QString> sources;
|
||||
for (const Entry &entry : existing.entries) {
|
||||
sources.insert(entry.source.trimmed().normalized(QString::NormalizationForm_C).toCaseFolded());
|
||||
}
|
||||
// Use the same duplicate rule as automatic extraction, retaining the
|
||||
// existing spelling, translation and episode when an Original is known.
|
||||
for (const Entry &entry : imported.entries) {
|
||||
if (!entry.episode.isEmpty() && !result.episodes.contains(entry.episode)) {
|
||||
result.episodes.append(entry.episode);
|
||||
}
|
||||
const QString source = entry.source.trimmed();
|
||||
const QString target = entry.target.trimmed();
|
||||
const QString key = source.normalized(QString::NormalizationForm_C).toCaseFolded();
|
||||
if (!source.isEmpty() && !target.isEmpty() && !sources.contains(key)) {
|
||||
result.entries.append({source, target, entry.episode});
|
||||
sources.insert(key);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -5,16 +5,31 @@
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
class GlossaryStore
|
||||
{
|
||||
public:
|
||||
struct Entry { QString source; QString target; };
|
||||
struct Glossary { QString directory; QList<Entry> entries; };
|
||||
struct Entry { QString source; QString target; QString episode = {}; };
|
||||
// An empty episode has not been matched to source subtitles yet. Keep episode
|
||||
// names separately so episodes with no new terms still have a group.
|
||||
struct Glossary { QString directory; QList<Entry> entries; QStringList episodes = {}; };
|
||||
|
||||
static Glossary load(const QString &directory);
|
||||
static bool save(const Glossary &glossary, QString *error = nullptr);
|
||||
static Glossary merge(const Glossary &existing, const QList<Entry> &additions);
|
||||
static Glossary merge(const Glossary &existing, const QList<Entry> &additions, const QString &episode = {});
|
||||
static QString serialize(const Glossary &glossary);
|
||||
static QString pathForDirectory(const QString &directory);
|
||||
// Portable JSON: no dependency on the original video directory.
|
||||
static bool exportFile(const QString &path, const Glossary &glossary, QString *error = nullptr);
|
||||
static bool importFile(const QString &path, Glossary *glossary, QString *error = nullptr);
|
||||
static Glossary mergeImported(const Glossary &existing, const Glossary &imported);
|
||||
// Match unassigned entries against source cues. Existing translations and
|
||||
// ownership stay unchanged, and grouping never affects serialize() (cache keys).
|
||||
static Glossary assignEpisode(const Glossary &existing, const QStringList &subtitleTexts, const QString &episode);
|
||||
// New terms belong to the episode being translated. This also migrates
|
||||
// legacy/imported entries that have no episode because their provenance is
|
||||
// unavailable; existing episode ownership is never changed.
|
||||
static Glossary assignUnassigned(const Glossary &existing, const QString &episode);
|
||||
static QStringList sortedEpisodes(QStringList episodes);
|
||||
};
|
||||
@@ -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({}, {});
|
||||
}
|
||||
Q_EMIT glossaryChanged();
|
||||
|
||||
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.")}};
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -34,6 +34,8 @@ class TranslationService : public QObject
|
||||
Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged)
|
||||
Q_PROPERTY(QVariantList glossaryEntries READ glossaryEntries NOTIFY glossaryChanged)
|
||||
Q_PROPERTY(QString glossaryDirectory READ glossaryDirectory NOTIFY glossaryChanged)
|
||||
Q_PROPERTY(QString glossaryEpisode READ glossaryEpisode NOTIFY glossaryChanged)
|
||||
Q_PROPERTY(QStringList glossaryEpisodes READ glossaryEpisodes NOTIFY glossaryChanged)
|
||||
Q_PROPERTY(QString glossaryError READ glossaryError NOTIFY glossaryErrorChanged)
|
||||
|
||||
public:
|
||||
@@ -49,6 +51,8 @@ public:
|
||||
bool isFetchingModels() const;
|
||||
QVariantList glossaryEntries() const;
|
||||
QString glossaryDirectory() const;
|
||||
QString glossaryEpisode() const;
|
||||
QStringList glossaryEpisodes() const;
|
||||
QString glossaryError() const;
|
||||
|
||||
// \a track is an entry of MpvItem::subtitleTracks.
|
||||
@@ -57,9 +61,14 @@ public:
|
||||
// Asks the API configured in the settings for its models.
|
||||
Q_INVOKABLE void fetchModels();
|
||||
Q_INVOKABLE void clearError();
|
||||
Q_INVOKABLE void forgetTranslation();
|
||||
Q_INVOKABLE void loadGlossary(const QUrl &videoUrl);
|
||||
Q_INVOKABLE bool saveGlossary(const QVariantList &entries);
|
||||
Q_INVOKABLE bool saveGlossary(const QVariantList &entries, const QStringList &episodes = {});
|
||||
Q_INVOKABLE void clearGlossary();
|
||||
// File operations use the dialog draft; import only commits when Save is clicked.
|
||||
Q_INVOKABLE QVariantMap importGlossaryFile(const QUrl &url, const QVariantList &entries, const QStringList &episodes);
|
||||
Q_INVOKABLE QVariantMap exportGlossaryFile(const QUrl &url, const QVariantList &entries, const QStringList &episodes);
|
||||
Q_INVOKABLE QStringList sortedGlossaryEpisodes(const QStringList &episodes) const;
|
||||
|
||||
Q_SIGNALS:
|
||||
void busyChanged();
|
||||
@@ -94,6 +103,7 @@ private:
|
||||
void setStatusText(const QString &text);
|
||||
void setErrorText(const QString &text);
|
||||
void setFetchingModels(bool fetching);
|
||||
void applyGlossaryEdits(const GlossaryStore::Glossary &previous, const GlossaryStore::Glossary &updated);
|
||||
|
||||
QNetworkAccessManager *m_network;
|
||||
std::shared_ptr<Job> m_job;
|
||||
@@ -105,4 +115,6 @@ private:
|
||||
QStringList m_availableModels;
|
||||
bool m_fetchingModels = false;
|
||||
GlossaryStore::Glossary m_glossary;
|
||||
QString m_glossaryEpisode;
|
||||
QString m_lastTranslationPath;
|
||||
};
|
||||
Reference in new issue
Block a user