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

+18
View File
@@ -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`. 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. 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 ## Keyboard shortcuts
The defaults follow Haruna. The defaults follow Haruna.
+243
View File
@@ -4,6 +4,8 @@
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTest> #include <QTest>
using namespace Qt::Literals::StringLiterals; using namespace Qt::Literals::StringLiterals;
@@ -27,6 +29,138 @@ private Q_SLOTS:
QCOMPARE(merged.entries.at(1).source, u"Sect"_s); 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() void saveFailureReturnsError()
{ {
QTemporaryDir temp; QTemporaryDir temp;
@@ -50,6 +184,115 @@ private Q_SLOTS:
QFile::remove(glossaries); 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() void saveRejectsIncompleteEntry()
{ {
QString error; QString error;
+1
View File
@@ -324,6 +324,7 @@ void MpvItem::showTranslatedSubtitleFile(const QUrl &url, const QString &title)
for (const QVariant &entry : std::as_const(m_subtitleTracks)) { for (const QVariant &entry : std::as_const(m_subtitleTracks)) {
const QVariantMap track = entry.toMap(); const QVariantMap track = entry.toMap();
if (track.value(QStringLiteral("externalFilename")).toString() == path) { 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()); showOnlySubtitle(track.value(QStringLiteral("id")).toInt());
return; return;
} }
+212 -12
View File
@@ -4,6 +4,7 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Dialogs
import org.kde.ki18n import org.kde.ki18n
import org.kde.kirigami as Kirigami import org.kde.kirigami as Kirigami
import org.kde.kirigami.dialogs as KDialogs import org.kde.kirigami.dialogs as KDialogs
@@ -24,6 +25,10 @@ Kirigami.Dialog {
return false return false
} }
property string saveError: "" 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. // One inset for the header, content and footer so every edge lines up.
readonly property real inset: Kirigami.Units.largeSpacing readonly property real inset: Kirigami.Units.largeSpacing
@@ -49,9 +54,18 @@ Kirigami.Dialog {
function addTerm() { function addTerm() {
glossaryModel.append({ glossaryModel.append({
source: "", 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(() => { Qt.callLater(() => {
glossaryList.forceLayout() glossaryList.forceLayout()
glossaryList.positionViewAtIndex(glossaryList.currentIndex, ListView.Contain) 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 = [] const entries = []
for (let i = 0; i < glossaryModel.count; ++i) { for (let i = 0; i < glossaryModel.count; ++i) {
const entry = glossaryModel.get(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.") saveError = translator.glossaryError || KI18n.i18nc("@info", "Could not save the translation glossary. Check that the storage location is writable.")
return return
} }
saveError = "" saveError = ""
searchQuery = ""
searchField.text = ""
root.accept() root.accept()
} }
onOpened: { onOpened: {
saveError = "" saveError = ""
transferMessage = ""
transferFailed = false
episodeNames = translator.glossaryEpisodes.slice()
searchQuery = ""
searchField.text = ""
glossaryModel.clear() glossaryModel.clear()
for (const entry of translator.glossaryEntries) { for (const entry of translator.glossaryEntries) {
glossaryModel.append(entry) glossaryModel.append(entry)
} }
rebuildFilteredModel()
glossaryList.positionViewAtBeginning() 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 { ListModel {
id: glossaryModel id: glossaryModel
} }
ListModel {
id: filteredModel
}
// Union's DialogButtonBox adds its own padding on top of the dialog's, so // 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. // lay the footer out ourselves with the same inset as the header and content.
footer: Control { footer: Control {
@@ -162,6 +290,14 @@ Kirigami.Dialog {
showCloseButton: false 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 { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
@@ -172,6 +308,23 @@ Kirigami.Dialog {
color: Kirigami.Theme.disabledTextColor 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 { Button {
action: addTermAction action: addTermAction
} }
@@ -180,7 +333,23 @@ Kirigami.Dialog {
text: KI18n.i18nc("@action:button", "Clear All") text: KI18n.i18nc("@action:button", "Clear All")
icon.name: "edit-clear-all" icon.name: "edit-clear-all"
enabled: glossaryModel.count > 0 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 clip: true
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
boundsBehavior: Flickable.StopAtBounds boundsBehavior: Flickable.StopAtBounds
model: glossaryModel model: filteredModel
delegate: RowLayout { delegate: RowLayout {
id: termRow id: termRow
required property int index required property int index
required property int sourceIndex
required property string type
required property string group
required property string source required property string source
required property string target required property string target
width: glossaryList.width width: glossaryList.width
height: implicitHeight height: type === "group" ? groupLabel.implicitHeight + Kirigami.Units.smallSpacing : implicitHeight
spacing: Kirigami.Units.smallSpacing 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() { function focusOriginal() {
originalField.forceActiveFocus(Qt.TabFocusReason) originalField.forceActiveFocus(Qt.TabFocusReason)
} }
TextField { TextField {
id: originalField id: originalField
visible: termRow.type === "entry"
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredWidth: 1 Layout.preferredWidth: 1
Layout.minimumWidth: 0 Layout.minimumWidth: 0
@@ -293,10 +480,11 @@ Kirigami.Dialog {
Component.onCompleted: cursorPosition = 0 Component.onCompleted: cursorPosition = 0
placeholderText: KI18n.i18nc("@info:placeholder glossary source term", "Original term") placeholderText: KI18n.i18nc("@info:placeholder glossary source term", "Original term")
Accessible.name: KI18n.i18nc("@label:textbox 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 { TextField {
visible: termRow.type === "entry"
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredWidth: 1 Layout.preferredWidth: 1
Layout.minimumWidth: 0 Layout.minimumWidth: 0
@@ -305,10 +493,11 @@ Kirigami.Dialog {
Component.onCompleted: cursorPosition = 0 Component.onCompleted: cursorPosition = 0
placeholderText: KI18n.i18nc("@info:placeholder glossary translated term", "Translation") placeholderText: KI18n.i18nc("@info:placeholder glossary translated term", "Translation")
Accessible.name: KI18n.i18nc("@label:textbox 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 { Button {
visible: termRow.type === "entry"
Layout.preferredWidth: root.removeButtonWidth Layout.preferredWidth: root.removeButtonWidth
Layout.minimumWidth: root.removeButtonWidth Layout.minimumWidth: root.removeButtonWidth
Layout.maximumWidth: root.removeButtonWidth Layout.maximumWidth: root.removeButtonWidth
@@ -322,7 +511,10 @@ Kirigami.Dialog {
ToolTip.text: text ToolTip.text: text
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.delay: Kirigami.Units.toolTipDelay 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 { Kirigami.PlaceholderMessage {
anchors.centerIn: parent anchors.centerIn: parent
width: parent.width - Kirigami.Units.largeSpacing * 2 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") 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.") explanation: KI18n.i18nc("@info:placeholder", "Terms are extracted automatically before translation, or you can add them manually.")
helpfulAction: addTermAction 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
View File
@@ -20,6 +20,11 @@ ApplicationWindow {
property int previousVisibility: Window.Windowed property int previousVisibility: Window.Windowed
readonly property bool isFullScreen: visibility === Window.FullScreen 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) { function openFile(url: url) {
playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings) playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings)
@@ -42,8 +47,13 @@ ApplicationWindow {
} }
function translateCurrentSubtitle() { 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) { if (track) {
lastTranslationTrack = track
lastTranslationVideo = video.currentUrl
translationService.translate(video.currentUrl, track, video.mediaTitle) translationService.translate(video.currentUrl, track, video.mediaTitle)
} }
} }
@@ -154,6 +164,8 @@ ApplicationWindow {
TranslationService { TranslationService {
id: translationService id: translationService
onTranslated: (subtitleUrl, fromCache) => { onTranslated: (subtitleUrl, fromCache) => {
// mpv exposes local external subtitle filenames as filesystem paths.
appWindow.translatedSubtitleUrls.push(decodeURIComponent(subtitleUrl.toString().replace(/^file:\/\//, "")))
video.showTranslatedSubtitleFile(subtitleUrl, video.showTranslatedSubtitleFile(subtitleUrl,
KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage)) KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage))
osdItem.message(fromCache osdItem.message(fromCache
@@ -165,6 +177,15 @@ ApplicationWindow {
MpvVideo { MpvVideo {
id: video id: video
onCurrentUrlChanged: {
translationService.cancel()
translationService.forgetTranslation()
appWindow.lastTranslationTrack = null
appWindow.lastTranslationVideo = ""
appWindow.translatedSubtitleUrls = []
glossaryDialog.close()
}
actions: appActions actions: appActions
fullscreen: appWindow.isFullScreen fullscreen: appWindow.isFullScreen
@@ -208,9 +229,6 @@ ApplicationWindow {
} }
} }
// A translation belongs to the file it was started for.
onCurrentUrlChanged: translationService.cancel()
Osd { Osd {
id: osdItem id: osdItem
maxWidth: video.width maxWidth: video.width
+247 -13
View File
@@ -4,18 +4,103 @@
#include "glossarystore.h" #include "glossarystore.h"
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QCollator>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError>
#include <QLocale>
#include <QRegularExpression>
#include <QSaveFile> #include <QSaveFile>
#include <QSet> #include <QSet>
#include <QStandardPaths> #include <QStandardPaths>
#include <algorithm>
using namespace Qt::Literals::StringLiterals; 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) QString GlossaryStore::pathForDirectory(const QString &directory)
{ {
const QString root = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + u"/glossaries"_s; 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; 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; QJsonArray entries;
for (const auto &entry : glossary.entries) { for (const auto &entry : glossary.entries) {
if (!entry.source.trimmed().isEmpty() && !entry.target.trimmed().isEmpty()) { 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) GlossaryStore::Glossary GlossaryStore::load(const QString &directory)
{ {
Glossary result{QDir::cleanPath(directory), {}}; Glossary result{QDir::cleanPath(directory), {}, {}};
QFile file(pathForDirectory(result.directory)); QFile file(pathForDirectory(result.directory));
if (!file.open(QIODevice::ReadOnly)) { if (!file.open(QIODevice::ReadOnly)) {
return result; return result;
} }
const QJsonObject object = QJsonDocument::fromJson(file.readAll()).object(); 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()) { for (const QJsonValue &value : object.value(u"entries"_s).toArray()) {
const QJsonObject entry = value.toObject(); const QJsonObject entry = value.toObject();
const QString source = entry.value(u"source"_s).toString().trimmed(); const QString source = entry.value(u"source"_s).toString().trimmed();
const QString target = entry.value(u"target"_s).toString().trimmed(); const QString target = entry.value(u"target"_s).toString().trimmed();
if (!source.isEmpty() && !target.isEmpty()) { 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; return result;
} }
@@ -72,7 +187,7 @@ bool GlossaryStore::save(const Glossary &glossary, QString *error)
return false; return false;
} }
QSaveFile file(path); 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) { if (error) {
*error = file.errorString(); *error = file.errorString();
if (error->isEmpty()) { if (error->isEmpty()) {
@@ -84,19 +199,24 @@ bool GlossaryStore::save(const Glossary &glossary, QString *error)
return true; 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; QSet<QString> sources;
for (const Entry &entry : result.entries) { 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) { for (const Entry &entry : additions) {
const QString source = entry.source.trimmed(); const QString source = entry.source.trimmed();
const QString target = entry.target.trimmed(); const QString target = entry.target.trimmed();
if (!source.isEmpty() && !target.isEmpty() && !sources.contains(source)) { const QString key = source.normalized(QString::NormalizationForm_C).toCaseFolded();
result.entries.append({source, target}); if (!source.isEmpty() && !target.isEmpty() && !sources.contains(key)) {
sources.insert(source); result.entries.append({source, target, episode});
sources.insert(key);
} }
} }
return result; return result;
@@ -104,5 +224,119 @@ GlossaryStore::Glossary GlossaryStore::merge(const Glossary &existing, const QLi
QString GlossaryStore::serialize(const Glossary &glossary) 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;
} }
+18 -3
View File
@@ -5,16 +5,31 @@
#include <QList> #include <QList>
#include <QString> #include <QString>
#include <QStringList>
class GlossaryStore class GlossaryStore
{ {
public: public:
struct Entry { QString source; QString target; }; struct Entry { QString source; QString target; QString episode = {}; };
struct Glossary { QString directory; QList<Entry> entries; }; // 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 Glossary load(const QString &directory);
static bool save(const Glossary &glossary, QString *error = nullptr); 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 serialize(const Glossary &glossary);
static QString pathForDirectory(const QString &directory); 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);
}; };
+181 -17
View File
@@ -18,6 +18,8 @@
#include <QPointer> #include <QPointer>
#include <QSaveFile> #include <QSaveFile>
#include <QStandardPaths> #include <QStandardPaths>
#include <QFile>
#include <QSet>
#include <QtConcurrentRun> #include <QtConcurrentRun>
#include <chrono> #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; 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)); 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 { struct TranslationService::Job {
@@ -129,9 +150,7 @@ bool TranslationService::isFetchingModels() const
QVariantList TranslationService::glossaryEntries() const QVariantList TranslationService::glossaryEntries() const
{ {
QVariantList result; return glossaryEntriesToVariants(m_glossary.entries);
for (const auto &entry : m_glossary.entries) result.append(QVariantMap{{u"source"_s, entry.source}, {u"target"_s, entry.target}});
return result;
} }
QString TranslationService::glossaryDirectory() const QString TranslationService::glossaryDirectory() const
@@ -139,6 +158,21 @@ QString TranslationService::glossaryDirectory() const
return m_glossary.directory; 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 QString TranslationService::glossaryError() const
{ {
return m_glossaryError; return m_glossaryError;
@@ -147,7 +181,14 @@ QString TranslationService::glossaryError() const
void TranslationService::loadGlossary(const QUrl &videoUrl) void TranslationService::loadGlossary(const QUrl &videoUrl)
{ {
const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString(); 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); 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()) { if (!m_glossaryError.isEmpty()) {
m_glossaryError.clear(); m_glossaryError.clear();
Q_EMIT glossaryErrorChanged(); Q_EMIT glossaryErrorChanged();
@@ -155,16 +196,16 @@ void TranslationService::loadGlossary(const QUrl &videoUrl)
Q_EMIT glossaryChanged(); 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; 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()) { if (!candidate.directory.isEmpty()) {
QString error; QString error;
if (!GlossaryStore::save(candidate, &error)) { if (!GlossaryStore::save(candidate, &error)) {
@@ -183,16 +224,106 @@ bool TranslationService::saveGlossary(const QVariantList &entries)
Q_EMIT glossaryErrorChanged(); Q_EMIT glossaryErrorChanged();
} }
Q_EMIT glossaryChanged(); Q_EMIT glossaryChanged();
if (changed) {
applyGlossaryEdits(previous, candidate);
}
return true; return true;
} }
void TranslationService::clearGlossary() void TranslationService::clearGlossary()
{ {
m_glossary.entries.clear(); saveGlossary({}, {});
if (!m_glossary.directory.isEmpty()) { }
GlossaryStore::save(m_glossary);
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) void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle)
@@ -230,6 +361,11 @@ void TranslationService::clearError()
setErrorText({}); setErrorText({});
} }
void TranslationService::forgetTranslation()
{
m_lastTranslationPath.clear();
}
QCoro::Task<std::pair<LlmClient::Config, QString>> TranslationService::loadConfig() QCoro::Task<std::pair<LlmClient::Config, QString>> TranslationService::loadConfig()
{ {
const auto settings = QtPotatoSettings::self(); 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 QString seriesDirectory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
const bool useGlossary = !seriesDirectory.isEmpty(); const bool useGlossary = !seriesDirectory.isEmpty();
GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{}; 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); const QString sourceSrt = Subtitles::toSrt(cues);
// Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once. // 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); QString outputPath = outputPathFor(glossary);
if (QFileInfo::exists(outputPath)) { if (QFileInfo::exists(outputPath)) {
m_lastTranslationPath = outputPath;
m_job.reset(); m_job.reset();
setBusy(false); setBusy(false);
setStatusText({}); setStatusText({});
@@ -378,13 +536,18 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
const qsizetype tab = encoded.indexOf(u'\t'); const qsizetype tab = encoded.indexOf(u'\t');
if (tab > 0) additions.append({encoded.left(tab), encoded.mid(tab + 1)}); if (tab > 0) additions.append({encoded.left(tab), encoded.mid(tab + 1)});
} }
glossary = GlossaryStore::merge(glossary, additions); // Suggestions extracted while translating this episode belong to
GlossaryStore::save(glossary); // this episode. Existing terms keep their original ownership.
glossary = GlossaryStore::merge(glossary, additions, episode);
if (!saveGlossaryGroups()) {
co_return;
}
} else { } else {
setStatusText(i18n("Glossary unavailable; translating without it.")); setStatusText(i18n("Glossary unavailable; translating without it."));
} }
outputPath = outputPathFor(glossary); outputPath = outputPathFor(glossary);
if (QFileInfo::exists(outputPath)) { if (QFileInfo::exists(outputPath)) {
m_lastTranslationPath = outputPath;
m_job.reset(); m_job.reset();
setBusy(false); setBusy(false);
setStatusText({}); setStatusText({});
@@ -448,6 +611,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
} }
m_job.reset(); m_job.reset();
m_lastTranslationPath = outputPath;
setBusy(false); setBusy(false);
setStatusText({}); setStatusText({});
Q_EMIT translated(QUrl::fromLocalFile(outputPath), false); Q_EMIT translated(QUrl::fromLocalFile(outputPath), false);
+13 -1
View File
@@ -34,6 +34,8 @@ class TranslationService : public QObject
Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged) Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged)
Q_PROPERTY(QVariantList glossaryEntries READ glossaryEntries NOTIFY glossaryChanged) Q_PROPERTY(QVariantList glossaryEntries READ glossaryEntries NOTIFY glossaryChanged)
Q_PROPERTY(QString glossaryDirectory READ glossaryDirectory 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) Q_PROPERTY(QString glossaryError READ glossaryError NOTIFY glossaryErrorChanged)
public: public:
@@ -49,6 +51,8 @@ public:
bool isFetchingModels() const; bool isFetchingModels() const;
QVariantList glossaryEntries() const; QVariantList glossaryEntries() const;
QString glossaryDirectory() const; QString glossaryDirectory() const;
QString glossaryEpisode() const;
QStringList glossaryEpisodes() const;
QString glossaryError() const; QString glossaryError() const;
// \a track is an entry of MpvItem::subtitleTracks. // \a track is an entry of MpvItem::subtitleTracks.
@@ -57,9 +61,14 @@ public:
// Asks the API configured in the settings for its models. // Asks the API configured in the settings for its models.
Q_INVOKABLE void fetchModels(); Q_INVOKABLE void fetchModels();
Q_INVOKABLE void clearError(); Q_INVOKABLE void clearError();
Q_INVOKABLE void forgetTranslation();
Q_INVOKABLE void loadGlossary(const QUrl &videoUrl); 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(); 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: Q_SIGNALS:
void busyChanged(); void busyChanged();
@@ -94,6 +103,7 @@ private:
void setStatusText(const QString &text); void setStatusText(const QString &text);
void setErrorText(const QString &text); void setErrorText(const QString &text);
void setFetchingModels(bool fetching); void setFetchingModels(bool fetching);
void applyGlossaryEdits(const GlossaryStore::Glossary &previous, const GlossaryStore::Glossary &updated);
QNetworkAccessManager *m_network; QNetworkAccessManager *m_network;
std::shared_ptr<Job> m_job; std::shared_ptr<Job> m_job;
@@ -105,4 +115,6 @@ private:
QStringList m_availableModels; QStringList m_availableModels;
bool m_fetchingModels = false; bool m_fetchingModels = false;
GlossaryStore::Glossary m_glossary; GlossaryStore::Glossary m_glossary;
QString m_glossaryEpisode;
QString m_lastTranslationPath;
}; };