feat(glossary): add episode groups, search, and import/export
Apply glossary edits directly to translated subtitles and reload the active track. Keep terms with their originating episode, preserve existing translations, and clear stale episode groups with glossary entries.
This commit is contained in:
1 parent
0bfaf3c9db
commit
97b4a2fe32
9 files changed
+955
-50
No files matched your search
+212
-12
@@ -4,6 +4,7 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Dialogs
|
||||
import org.kde.ki18n
|
||||
import org.kde.kirigami as Kirigami
|
||||
import org.kde.kirigami.dialogs as KDialogs
|
||||
@@ -24,6 +25,10 @@ Kirigami.Dialog {
|
||||
return false
|
||||
}
|
||||
property string saveError: ""
|
||||
property string searchQuery: ""
|
||||
property var episodeNames: []
|
||||
property string transferMessage: ""
|
||||
property bool transferFailed: false
|
||||
// One inset for the header, content and footer so every edge lines up.
|
||||
readonly property real inset: Kirigami.Units.largeSpacing
|
||||
|
||||
@@ -49,9 +54,18 @@ Kirigami.Dialog {
|
||||
function addTerm() {
|
||||
glossaryModel.append({
|
||||
source: "",
|
||||
target: ""
|
||||
target: "",
|
||||
episode: translator.glossaryEpisode
|
||||
})
|
||||
glossaryList.currentIndex = glossaryModel.count - 1
|
||||
searchQuery = ""
|
||||
searchField.text = ""
|
||||
rebuildFilteredModel()
|
||||
for (let i = 0; i < filteredModel.count; ++i) {
|
||||
if (filteredModel.get(i).sourceIndex === glossaryModel.count - 1) {
|
||||
glossaryList.currentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
Qt.callLater(() => {
|
||||
glossaryList.forceLayout()
|
||||
glossaryList.positionViewAtIndex(glossaryList.currentIndex, ListView.Contain)
|
||||
@@ -63,32 +77,146 @@ Kirigami.Dialog {
|
||||
})
|
||||
}
|
||||
|
||||
function saveGlossary() {
|
||||
function rebuildFilteredModel() {
|
||||
filteredModel.clear()
|
||||
const query = searchQuery.trim().toLocaleLowerCase()
|
||||
const groups = Object.create(null)
|
||||
const order = []
|
||||
for (let i = 0; i < glossaryModel.count; ++i) {
|
||||
const entry = glossaryModel.get(i)
|
||||
if (!query || entry.source.toLocaleLowerCase().includes(query) || entry.target.toLocaleLowerCase().includes(query) || (entry.episode || "").toLocaleLowerCase().includes(query)) {
|
||||
const group = entry.episode || ""
|
||||
if (!groups[group]) {
|
||||
groups[group] = []
|
||||
order.push(group)
|
||||
}
|
||||
groups[group].push({ source: entry.source, target: entry.target, sourceIndex: i })
|
||||
}
|
||||
}
|
||||
if (!query) {
|
||||
for (const group of root.episodeNames) {
|
||||
if (!groups[group]) {
|
||||
groups[group] = []
|
||||
order.push(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const group of translator.sortedGlossaryEpisodes(order)) {
|
||||
filteredModel.append({ type: "group", group, source: "", target: "", sourceIndex: -1 })
|
||||
for (const entry of groups[group]) {
|
||||
filteredModel.append({ type: "entry", group, source: entry.source, target: entry.target, sourceIndex: entry.sourceIndex })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateEntry(sourceIndex, field, value) {
|
||||
const query = searchQuery.trim().toLocaleLowerCase()
|
||||
let filteredIndex = -1
|
||||
for (let i = 0; i < filteredModel.count; ++i) {
|
||||
if (filteredModel.get(i).sourceIndex === sourceIndex) {
|
||||
filteredIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const wasMatching = filteredIndex >= 0
|
||||
glossaryModel.setProperty(sourceIndex, field, value)
|
||||
const entry = glossaryModel.get(sourceIndex)
|
||||
const isMatching = !query || entry.source.toLocaleLowerCase().includes(query) || entry.target.toLocaleLowerCase().includes(query) || (entry.episode || "").toLocaleLowerCase().includes(query)
|
||||
if (wasMatching && isMatching) {
|
||||
filteredModel.setProperty(filteredIndex, field, value)
|
||||
} else if (wasMatching !== isMatching) {
|
||||
rebuildFilteredModel()
|
||||
}
|
||||
}
|
||||
|
||||
function draftEntries() {
|
||||
const entries = []
|
||||
for (let i = 0; i < glossaryModel.count; ++i) {
|
||||
const entry = glossaryModel.get(i)
|
||||
entries.push({ source: entry.source, target: entry.target })
|
||||
entries.push({ source: entry.source, target: entry.target, episode: entry.episode })
|
||||
}
|
||||
if (!translator.saveGlossary(entries)) {
|
||||
return entries
|
||||
}
|
||||
|
||||
function saveGlossary() {
|
||||
if (!translator.saveGlossary(draftEntries(), episodeNames)) {
|
||||
saveError = translator.glossaryError || KI18n.i18nc("@info", "Could not save the translation glossary. Check that the storage location is writable.")
|
||||
return
|
||||
}
|
||||
saveError = ""
|
||||
searchQuery = ""
|
||||
searchField.text = ""
|
||||
root.accept()
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
saveError = ""
|
||||
transferMessage = ""
|
||||
transferFailed = false
|
||||
episodeNames = translator.glossaryEpisodes.slice()
|
||||
searchQuery = ""
|
||||
searchField.text = ""
|
||||
glossaryModel.clear()
|
||||
for (const entry of translator.glossaryEntries) {
|
||||
glossaryModel.append(entry)
|
||||
}
|
||||
rebuildFilteredModel()
|
||||
glossaryList.positionViewAtBeginning()
|
||||
}
|
||||
|
||||
onClosed: {
|
||||
importFileDialog.close()
|
||||
exportFileDialog.close()
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: importFileDialog
|
||||
objectName: "glossaryImportFileDialog"
|
||||
title: KI18n.i18nc("@title:window", "Import Glossary")
|
||||
fileMode: FileDialog.OpenFile
|
||||
nameFilters: [KI18n.i18nc("@item:inlistbox", "Glossary JSON files (*.json)")]
|
||||
onAccepted: {
|
||||
const result = root.translator.importGlossaryFile(selectedFile, root.draftEntries(), root.episodeNames)
|
||||
root.transferFailed = !result.ok
|
||||
if (!result.ok) {
|
||||
root.transferMessage = result.error
|
||||
return
|
||||
}
|
||||
glossaryModel.clear()
|
||||
for (const entry of result.entries) {
|
||||
glossaryModel.append(entry)
|
||||
}
|
||||
root.episodeNames = result.episodes
|
||||
searchField.text = ""
|
||||
root.rebuildFilteredModel()
|
||||
glossaryList.positionViewAtBeginning()
|
||||
root.transferMessage = KI18n.i18nc("@info", "Added %1 terms; skipped %2 duplicates. Click Save to apply.", result.added, result.skipped)
|
||||
}
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: exportFileDialog
|
||||
objectName: "glossaryExportFileDialog"
|
||||
title: KI18n.i18nc("@title:window", "Export Glossary")
|
||||
fileMode: FileDialog.SaveFile
|
||||
defaultSuffix: "json"
|
||||
nameFilters: [KI18n.i18nc("@item:inlistbox", "Glossary JSON files (*.json)")]
|
||||
onAccepted: {
|
||||
const result = root.translator.exportGlossaryFile(selectedFile, root.draftEntries(), root.episodeNames)
|
||||
root.transferFailed = !result.ok
|
||||
root.transferMessage = result.ok
|
||||
? KI18n.i18nc("@info", "Glossary exported successfully.")
|
||||
: result.error
|
||||
}
|
||||
}
|
||||
ListModel {
|
||||
id: glossaryModel
|
||||
}
|
||||
|
||||
ListModel {
|
||||
id: filteredModel
|
||||
}
|
||||
|
||||
// Union's DialogButtonBox adds its own padding on top of the dialog's, so
|
||||
// lay the footer out ourselves with the same inset as the header and content.
|
||||
footer: Control {
|
||||
@@ -162,6 +290,14 @@ Kirigami.Dialog {
|
||||
showCloseButton: false
|
||||
}
|
||||
|
||||
Kirigami.InlineMessage {
|
||||
Layout.fillWidth: true
|
||||
visible: root.transferMessage.length > 0
|
||||
type: root.transferFailed ? Kirigami.MessageType.Error : Kirigami.MessageType.Positive
|
||||
text: root.transferMessage
|
||||
showCloseButton: true
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
@@ -172,6 +308,23 @@ Kirigami.Dialog {
|
||||
color: Kirigami.Theme.disabledTextColor
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "document-import"
|
||||
Accessible.name: KI18n.i18nc("@action accessible name", "Import glossary")
|
||||
ToolTip.text: KI18n.i18nc("@info:tooltip", "Import glossary")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: importFileDialog.open()
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "document-export"
|
||||
enabled: !root.hasIncompleteTerms
|
||||
Accessible.name: KI18n.i18nc("@action accessible name", "Export glossary")
|
||||
ToolTip.text: KI18n.i18nc("@info:tooltip", "Export glossary")
|
||||
ToolTip.visible: hovered
|
||||
onClicked: exportFileDialog.open()
|
||||
}
|
||||
|
||||
Button {
|
||||
action: addTermAction
|
||||
}
|
||||
@@ -180,7 +333,23 @@ Kirigami.Dialog {
|
||||
text: KI18n.i18nc("@action:button", "Clear All")
|
||||
icon.name: "edit-clear-all"
|
||||
enabled: glossaryModel.count > 0
|
||||
onClicked: glossaryModel.clear()
|
||||
onClicked: {
|
||||
glossaryModel.clear()
|
||||
episodeNames = []
|
||||
rebuildFilteredModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: searchField
|
||||
Layout.fillWidth: true
|
||||
placeholderText: KI18n.i18nc("@info:placeholder glossary search", "Search original or translation…")
|
||||
Accessible.name: KI18n.i18nc("@label:textbox glossary search", "Search glossary")
|
||||
onTextChanged: {
|
||||
root.searchQuery = text
|
||||
root.rebuildFilteredModel()
|
||||
glossaryList.positionViewAtBeginning()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,24 +436,42 @@ Kirigami.Dialog {
|
||||
clip: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
model: glossaryModel
|
||||
model: filteredModel
|
||||
|
||||
delegate: RowLayout {
|
||||
id: termRow
|
||||
required property int index
|
||||
required property int sourceIndex
|
||||
required property string type
|
||||
required property string group
|
||||
required property string source
|
||||
required property string target
|
||||
|
||||
width: glossaryList.width
|
||||
height: implicitHeight
|
||||
height: type === "group" ? groupLabel.implicitHeight + Kirigami.Units.smallSpacing : implicitHeight
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Label {
|
||||
id: groupLabel
|
||||
visible: termRow.type === "group"
|
||||
Layout.fillWidth: true
|
||||
text: termRow.group || KI18n.i18nc("@label glossary group", "Unassigned (legacy/import)")
|
||||
font.bold: true
|
||||
elide: Text.ElideMiddle
|
||||
ToolTip.text: text
|
||||
ToolTip.visible: groupHover.hovered
|
||||
HoverHandler { id: groupHover }
|
||||
color: Kirigami.Theme.highlightColor
|
||||
bottomPadding: Kirigami.Units.smallSpacing
|
||||
}
|
||||
|
||||
function focusOriginal() {
|
||||
originalField.forceActiveFocus(Qt.TabFocusReason)
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: originalField
|
||||
visible: termRow.type === "entry"
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 1
|
||||
Layout.minimumWidth: 0
|
||||
@@ -293,10 +480,11 @@ Kirigami.Dialog {
|
||||
Component.onCompleted: cursorPosition = 0
|
||||
placeholderText: KI18n.i18nc("@info:placeholder glossary source term", "Original term")
|
||||
Accessible.name: KI18n.i18nc("@label:textbox glossary source term", "Original term")
|
||||
onTextEdited: glossaryModel.setProperty(termRow.index, "source", text)
|
||||
onTextEdited: root.updateEntry(termRow.sourceIndex, "source", text)
|
||||
}
|
||||
|
||||
TextField {
|
||||
visible: termRow.type === "entry"
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredWidth: 1
|
||||
Layout.minimumWidth: 0
|
||||
@@ -305,10 +493,11 @@ Kirigami.Dialog {
|
||||
Component.onCompleted: cursorPosition = 0
|
||||
placeholderText: KI18n.i18nc("@info:placeholder glossary translated term", "Translation")
|
||||
Accessible.name: KI18n.i18nc("@label:textbox glossary translated term", "Translation")
|
||||
onTextEdited: glossaryModel.setProperty(termRow.index, "target", text)
|
||||
onTextEdited: root.updateEntry(termRow.sourceIndex, "target", text)
|
||||
}
|
||||
|
||||
Button {
|
||||
visible: termRow.type === "entry"
|
||||
Layout.preferredWidth: root.removeButtonWidth
|
||||
Layout.minimumWidth: root.removeButtonWidth
|
||||
Layout.maximumWidth: root.removeButtonWidth
|
||||
@@ -322,7 +511,10 @@ Kirigami.Dialog {
|
||||
ToolTip.text: text
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||
onClicked: glossaryModel.remove(termRow.index)
|
||||
onClicked: {
|
||||
glossaryModel.remove(termRow.sourceIndex)
|
||||
root.rebuildFilteredModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,11 +523,19 @@ Kirigami.Dialog {
|
||||
Kirigami.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Kirigami.Units.largeSpacing * 2
|
||||
visible: glossaryModel.count === 0
|
||||
visible: glossaryModel.count === 0 && filteredModel.count === 0 && !root.searchQuery.trim()
|
||||
text: KI18n.i18nc("@info:placeholder", "No glossary terms yet")
|
||||
explanation: KI18n.i18nc("@info:placeholder", "Terms are extracted automatically before translation, or you can add them manually.")
|
||||
helpfulAction: addTermAction
|
||||
}
|
||||
|
||||
Kirigami.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Kirigami.Units.largeSpacing * 2
|
||||
visible: glossaryModel.count > 0 && filteredModel.count === 0
|
||||
text: KI18n.i18nc("@info:placeholder glossary search", "No matching terms")
|
||||
explanation: KI18n.i18nc("@info:placeholder glossary search", "Try a different search.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-4
@@ -20,6 +20,11 @@ ApplicationWindow {
|
||||
|
||||
property int previousVisibility: Window.Windowed
|
||||
readonly property bool isFullScreen: visibility === Window.FullScreen
|
||||
// Keep the original subtitle track so glossary edits update the generated
|
||||
// subtitle even while that translated track is selected in mpv.
|
||||
property var lastTranslationTrack: null
|
||||
property url lastTranslationVideo: ""
|
||||
property var translatedSubtitleUrls: []
|
||||
|
||||
function openFile(url: url) {
|
||||
playlistModel.openFile(url, QtPotatoSettings.playlistLoadSiblings)
|
||||
@@ -42,8 +47,13 @@ ApplicationWindow {
|
||||
}
|
||||
|
||||
function translateCurrentSubtitle() {
|
||||
const track = video.subtitleTracks.find(track => track.id === video.subtitleId)
|
||||
let track = video.subtitleTracks.find(track => track.id === video.subtitleId)
|
||||
if (track && translatedSubtitleUrls.includes(track.externalFilename) && lastTranslationTrack) {
|
||||
track = video.subtitleTracks.find(track => track.id === lastTranslationTrack.id)
|
||||
}
|
||||
if (track) {
|
||||
lastTranslationTrack = track
|
||||
lastTranslationVideo = video.currentUrl
|
||||
translationService.translate(video.currentUrl, track, video.mediaTitle)
|
||||
}
|
||||
}
|
||||
@@ -154,6 +164,8 @@ ApplicationWindow {
|
||||
TranslationService {
|
||||
id: translationService
|
||||
onTranslated: (subtitleUrl, fromCache) => {
|
||||
// mpv exposes local external subtitle filenames as filesystem paths.
|
||||
appWindow.translatedSubtitleUrls.push(decodeURIComponent(subtitleUrl.toString().replace(/^file:\/\//, "")))
|
||||
video.showTranslatedSubtitleFile(subtitleUrl,
|
||||
KI18n.i18nc("@item:inlistbox subtitle track, %1 is a language", "%1 (translated)", QtPotatoSettings.targetLanguage))
|
||||
osdItem.message(fromCache
|
||||
@@ -165,6 +177,15 @@ ApplicationWindow {
|
||||
MpvVideo {
|
||||
id: video
|
||||
|
||||
onCurrentUrlChanged: {
|
||||
translationService.cancel()
|
||||
translationService.forgetTranslation()
|
||||
appWindow.lastTranslationTrack = null
|
||||
appWindow.lastTranslationVideo = ""
|
||||
appWindow.translatedSubtitleUrls = []
|
||||
glossaryDialog.close()
|
||||
}
|
||||
|
||||
actions: appActions
|
||||
fullscreen: appWindow.isFullScreen
|
||||
|
||||
@@ -208,9 +229,6 @@ ApplicationWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// A translation belongs to the file it was started for.
|
||||
onCurrentUrlChanged: translationService.cancel()
|
||||
|
||||
Osd {
|
||||
id: osdItem
|
||||
maxWidth: video.width
|
||||
|
||||
Reference in new issue
Block a user