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

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