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

+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