1
0
mirror of https://github.com/ciromattia/kcc synced 2026-05-19 05:51:49 +00:00

Merge pull request #1184 from jaroslawjanas/metadata-editor-in-bulk

Bulk Metadata Editing
This commit is contained in:
Alex Xu
2026-05-18 10:45:31 -07:00
committed by GitHub
3 changed files with 336 additions and 54 deletions

View File

@@ -62,6 +62,19 @@
<item row="1" column="1"> <item row="1" column="1">
<widget class="QLineEdit" name="volumeLine"/> <widget class="QLineEdit" name="volumeLine"/>
</item> </item>
<item row="1" column="2">
<widget class="QCheckBox" name="bulkVolumeCheck">
<property name="visible">
<bool>false</bool>
</property>
<property name="toolTip">
<string>&lt;b&gt;Bulk Volume Editing&lt;/b&gt;&lt;br&gt;Check this box to assign volume numbers to multiple files.&lt;br&gt;&lt;br&gt;&lt;b&gt;Input formats:&lt;/b&gt;&lt;br&gt;&lt;code&gt;5&lt;/code&gt; → sequence starting from 5 (5, 6, 7...)&lt;br&gt;&lt;code&gt;1-10&lt;/code&gt; → range from 1 to 10&lt;br&gt;&lt;code&gt;1, 3, 5&lt;/code&gt; → specific values&lt;br&gt;&lt;br&gt;&lt;i&gt;Note: Files are sorted alphabetically before assignment.&lt;/i&gt;</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="0"> <item row="3" column="0">
<widget class="QLabel" name="label_3"> <widget class="QLabel" name="label_3">
<property name="text"> <property name="text">
@@ -195,6 +208,7 @@
<tabstops> <tabstops>
<tabstop>seriesLine</tabstop> <tabstop>seriesLine</tabstop>
<tabstop>volumeLine</tabstop> <tabstop>volumeLine</tabstop>
<tabstop>bulkVolumeCheck</tabstop>
<tabstop>titleLine</tabstop> <tabstop>titleLine</tabstop>
<tabstop>numberLine</tabstop> <tabstop>numberLine</tabstop>
<tabstop>writerLine</tabstop> <tabstop>writerLine</tabstop>

View File

@@ -658,27 +658,45 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
def selectFileMetaEditor(self, sname): def selectFileMetaEditor(self, sname):
files = []
if not sname: if not sname:
if QApplication.keyboardModifiers() == Qt.ShiftModifier: if QApplication.keyboardModifiers() == Qt.ShiftModifier:
dname = QFileDialog.getExistingDirectory(MW, 'Select directory', self.lastPath) # Multi-directory selection for bulk editing ComicInfo.xml
if dname != '': dialog = QFileDialog(MW, 'Select volume directories', self.lastPath)
sname = os.path.join(dname, 'ComicInfo.xml') dialog.setFileMode(QFileDialog.FileMode.Directory)
self.lastPath = os.path.dirname(sname) dialog.setOption(QFileDialog.Option.ShowDirsOnly, True)
dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True)
# Enable multi-selection in the dialog (may not work with native dialog on all platforms)
file_view = dialog.findChild(QListView, 'listView')
if file_view:
file_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
file_tree = dialog.findChild(QTreeView)
if file_tree:
file_tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
if dialog.exec():
selected_dirs = dialog.selectedFiles()
if selected_dirs:
files = [os.path.join(d, 'ComicInfo.xml') for d in selected_dirs]
self.lastPath = os.path.dirname(selected_dirs[0])
else: else:
if self.sevenzip: if self.sevenzip:
fname = QFileDialog.getOpenFileName(MW, 'Select file', self.lastPath, fnames = QFileDialog.getOpenFileNames(MW, 'Select file(s)', self.lastPath,
'Comic (*.cbz *.cbr *.cb7)') 'Comic (*.cbz *.cbr *.cb7)')
files = fnames[0]
if files:
self.lastPath = os.path.abspath(os.path.join(files[0], os.pardir))
else: else:
fname = ['']
self.showDialog("Editor is disabled due to a lack of 7z.", 'error') self.showDialog("Editor is disabled due to a lack of 7z.", 'error')
self.addMessage('<a href="https://github.com/ciromattia/kcc#7-zip">Install 7z (link)</a>' self.addMessage('<a href="https://github.com/ciromattia/kcc#7-zip">Install 7z (link)</a>'
' to enable metadata editing.', 'warning') ' to enable metadata editing.', 'warning')
if fname[0] != '': else:
sname = fname[0] files = [sname]
self.lastPath = os.path.abspath(os.path.join(sname, os.pardir))
if sname: if files:
try: try:
self.editor.loadData(sname) self.editor.loadData(files)
except Exception as err: except Exception as err:
_, _, traceback = sys.exc_info() _, _, traceback = sys.exc_info()
self.showDialog("Failed to parse metadata!\n\n%s\n\nTraceback:\n%s" self.showDialog("Failed to parse metadata!\n\n%s\n\nTraceback:\n%s"
@@ -1536,61 +1554,300 @@ class KCCGUI(KCC_ui.Ui_mainWindow):
class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog): class KCCGUI_MetaEditor(KCC_ui_editor.Ui_editorDialog):
def loadData(self, file): def _buildBulkFieldToolTip(self, fieldLabel, valuesByFile):
self.parser = metadata.MetadataParser(file) note = '<p><em>Note: Changing this field will overwrite all values in all selected files.</em></p>'
if self.parser.format in ['RAR', 'RAR5']:
self.editorWidget.setEnabled(False) if len(valuesByFile) <= 20:
self.okButton.setEnabled(False) rows = ''.join(
self.statusLabel.setText('CBR metadata are read-only.') '<tr>'
f'<td style="padding:2px 6px; white-space:nowrap;">{escape(os.path.basename(f))}</td>'
f'<td style="padding:2px 6px;">{escape(v)}</td>'
'</tr>'
for f, v in valuesByFile
)
table = (
'<table border="1" cellspacing="0" cellpadding="0">'
'<tr>'
'<th style="padding:2px 6px; text-align:left;">File</th>'
'<th style="padding:2px 6px; text-align:left;">Value</th>'
'</tr>'
f'{rows}'
'</table>'
)
else: else:
counts = {}
for _, v in valuesByFile:
counts[v] = counts.get(v, 0) + 1
rows = ''.join(
'<tr>'
f'<td style="padding:2px 6px;">{escape(v)}</td>'
f'<td style="padding:2px 6px; text-align:right;">{c}</td>'
'</tr>'
for v, c in sorted(counts.items(), key=lambda t: (-t[1], t[0]))
)
table = (
'<table border="1" cellspacing="0" cellpadding="0">'
'<tr>'
'<th style="padding:2px 6px; text-align:left;">Value</th>'
'<th style="padding:2px 6px; text-align:right;">Count</th>'
'</tr>'
f'{rows}'
'</table>'
)
tooltipHTML = f'\
<b>{escape(fieldLabel)}</b>\
{note}\
{table}\
'
return tooltipHTML
def loadData(self, files):
self.files = files if isinstance(files, list) else [files]
self.bulkMode = len(self.files) > 1
# Sort files by name for consistent volume assignment
self.files.sort()
# Unified CBR check for all files (both single and bulk mode)
for file in self.files:
parser = metadata.MetadataParser(file)
if parser.format in ['RAR', 'RAR5']:
self.editorWidget.setEnabled(False)
self.okButton.setEnabled(False)
self.statusLabel.setText('CBR files in selection are read-only.')
return
if self.bulkMode:
firstFile = self.files[0]
self.parser = metadata.MetadataParser(firstFile)
self.editorWidget.setEnabled(True)
self.okButton.setEnabled(True)
self.statusLabel.setText(f'Editing {len(self.files)} files.')
# Show bulk volume checkbox
self.bulkVolumeCheck.setVisible(True)
self.bulkVolumeCheck.setChecked(False)
for field in (self.volumeLine, self.numberLine, self.titleLine):
field.setEnabled(False)
field.setText('')
field.setPlaceholderText('(multiple files)')
field.setToolTip('')
# Load metadata for all files and show common values, or “(multiple values)” + tooltip.
parsed = []
for file in self.files:
parsed.append((file, metadata.MetadataParser(file)))
field_specs = [
(self.seriesLine, 'Series', lambda p: (p.data.get('Series', '') or '')),
(self.writerLine, 'Writer', lambda p: ', '.join(p.data.get('Writers', []) or [])),
(self.pencillerLine, 'Penciller', lambda p: ', '.join(p.data.get('Pencillers', []) or [])),
(self.inkerLine, 'Inker', lambda p: ', '.join(p.data.get('Inkers', []) or [])),
(self.coloristLine, 'Colorist', lambda p: ', '.join(p.data.get('Colorists', []) or [])),
]
for line, label, extractor in field_specs:
line.setEnabled(True)
valuesByFile = [(f, extractor(p)) for f, p in parsed]
uniqueValues = {v for _, v in valuesByFile}
if len(uniqueValues) == 1:
common_value = valuesByFile[0][1] if valuesByFile else ''
line.setPlaceholderText('')
line.setToolTip('')
line.setText(common_value)
else:
line.setText('')
line.setPlaceholderText('(multiple values)')
line.setToolTip(self._buildBulkFieldToolTip(label, valuesByFile))
else:
file = self.files[0]
self.parser = metadata.MetadataParser(file)
# Hide bulk volume checkbox in single file mode
self.bulkVolumeCheck.setVisible(False)
for field in (self.volumeLine, self.numberLine, self.titleLine, self.seriesLine,
self.writerLine, self.pencillerLine, self.inkerLine, self.coloristLine):
field.setEnabled(True)
field.setPlaceholderText('')
self.editorWidget.setEnabled(True) self.editorWidget.setEnabled(True)
self.okButton.setEnabled(True) self.okButton.setEnabled(True)
self.statusLabel.setText('Separate authors with a comma.') self.statusLabel.setText('Separate authors with a comma.')
for field in (self.seriesLine, self.volumeLine, self.numberLine, self.titleLine):
field.setText(self.parser.data[field.objectName().capitalize()[:-4]]) for field in (self.seriesLine, self.volumeLine, self.numberLine, self.titleLine):
for field in (self.writerLine, self.pencillerLine, self.inkerLine, self.coloristLine): field.setText(self.parser.data[field.objectName().capitalize()[:-4]])
field.setText(', '.join(self.parser.data[field.objectName().capitalize()[:-4] + 's'])) for field in (self.writerLine, self.pencillerLine, self.inkerLine, self.coloristLine):
for field in (self.seriesLine, self.titleLine): field.setText(', '.join(self.parser.data[field.objectName().capitalize()[:-4] + 's']))
if field.text() == '': for field in (self.seriesLine, self.titleLine):
path = Path(file) if field.text() == '':
if file.endswith('.xml'): path = Path(file)
field.setText(path.parent.name) if file.endswith('.xml'):
else: field.setText(path.parent.name)
field.setText(path.stem) else:
field.setText(path.stem)
def saveData(self): def saveData(self):
for field in (self.volumeLine, self.numberLine): if self.bulkMode:
if field.text().isnumeric() or self.cleanData(field.text()) == '': bulkData = {}
self.parser.data[field.objectName().capitalize()[:-4]] = self.cleanData(field.text()) if self.cleanData(self.seriesLine.text()):
else: bulkData['Series'] = self.cleanData(self.seriesLine.text())
self.statusLabel.setText(field.objectName().capitalize()[:-4] + ' field must be a number.')
break
else:
for field in (self.seriesLine, self.titleLine):
self.parser.data[field.objectName().capitalize()[:-4]] = self.cleanData(field.text())
for field in (self.writerLine, self.pencillerLine, self.inkerLine, self.coloristLine): for field in (self.writerLine, self.pencillerLine, self.inkerLine, self.coloristLine):
fieldName = field.objectName().capitalize()[:-4] + 's'
values = self.cleanData(field.text()).split(',') values = self.cleanData(field.text()).split(',')
tmpData = [] tmpData = [self.cleanData(v) for v in values if self.cleanData(v)]
for value in values: if tmpData:
if self.cleanData(value) != '': bulkData[fieldName] = tmpData
tmpData.append(self.cleanData(value))
self.parser.data[field.objectName().capitalize()[:-4] + 's'] = tmpData # Handle bulk volume editing
try: volumes = None
self.parser.saveXML() if self.bulkVolumeCheck.isChecked():
except Exception as err: volumeText = self.volumeLine.text()
_, _, traceback = sys.exc_info() volumes, error = self.parseVolumeInput(volumeText, len(self.files))
GUI.showDialog("Failed to save metadata!\n\n%s\n\nTraceback:\n%s" if error:
% (str(err), sanitizeTrace(traceback)), 'error') self.statusLabel.setText(error)
self.ui.close() return
if not bulkData and volumes is None:
self.statusLabel.setText('No changes to apply.')
return
errors = []
total = len(self.files)
self.okButton.setEnabled(False)
self.cancelButton.setEnabled(False)
for i, file in enumerate(self.files, 1):
self.statusLabel.setText(f'Processing {i}/{total}: {os.path.basename(file)}')
QApplication.processEvents()
try:
parser = metadata.MetadataParser(file)
if parser.format in ['RAR', 'RAR5']:
errors.append(f'{os.path.basename(file)}: CBR is read-only')
continue
for key, value in bulkData.items():
parser.data[key] = value
# Set volume if bulk volume editing is enabled
if volumes is not None:
parser.data['Volume'] = str(volumes[i - 1])
parser.saveXML()
except Exception as err:
errors.append(f'{os.path.basename(file)}: {str(err)}')
self.okButton.setEnabled(True)
self.cancelButton.setEnabled(True)
if errors:
GUI.showDialog("Some files failed to save:\n\n" + "\n".join(errors[:10]) +
(f"\n...and {len(errors) - 10} more" if len(errors) > 10 else ""), 'error')
self.statusLabel.setText('Errors occurred.')
else:
self.statusLabel.setText(f'Successfully updated {total} files.')
self.ui.close()
else:
for field in (self.volumeLine, self.numberLine):
if field.text().isnumeric() or self.cleanData(field.text()) == '':
self.parser.data[field.objectName().capitalize()[:-4]] = self.cleanData(field.text())
else:
self.statusLabel.setText(field.objectName().capitalize()[:-4] + ' field must be a number.')
break
else:
for field in (self.seriesLine, self.titleLine):
self.parser.data[field.objectName().capitalize()[:-4]] = self.cleanData(field.text())
for field in (self.writerLine, self.pencillerLine, self.inkerLine, self.coloristLine):
values = self.cleanData(field.text()).split(',')
tmpData = []
for value in values:
if self.cleanData(value) != '':
tmpData.append(self.cleanData(value))
self.parser.data[field.objectName().capitalize()[:-4] + 's'] = tmpData
try:
self.parser.saveXML()
except Exception as err:
_, _, traceback = sys.exc_info()
GUI.showDialog("Failed to save metadata!\n\n%s\n\nTraceback:\n%s"
% (str(err), sanitizeTrace(traceback)), 'error')
self.ui.close()
def cleanData(self, s): def cleanData(self, s):
return escape(s.strip()) return escape(s.strip())
def parseVolumeInput(self, text, fileCount):
text = text.strip()
if not text:
return None, None
volumes = []
# Check if it's a range (e.g., "5-10")
if '-' in text and ',' not in text:
parts = text.split('-')
if len(parts) != 2 or not parts[0].strip() or not parts[1].strip():
return None, 'Invalid range format (use start-end)'
try:
start = int(parts[0].strip())
end = int(parts[1].strip())
if start < 0 or end < 0:
return None, 'Volume numbers must be positive'
if start > end:
return None, 'Invalid range: start > end'
volumes = list(range(start, end + 1))
except ValueError:
return None, 'Invalid range format'
# Check if it's a comma-separated list (e.g., "1,3,5")
elif ',' in text:
try:
volumes = [int(v.strip()) for v in text.split(',') if v.strip()]
if any(v < 0 for v in volumes):
return None, 'Volume numbers must be positive'
except ValueError:
return None, 'Invalid list format'
# Single number - generate sequence starting from that number
else:
try:
start = int(text)
if start < 0:
return None, 'Volume number must be positive'
volumes = list(range(start, start + fileCount))
except ValueError:
return None, 'Invalid number'
# Validate count
if not volumes:
return None, 'No valid volume numbers parsed'
if len(volumes) != fileCount:
return None, f'Volume count ({len(volumes)}) != file count ({fileCount})'
return volumes, None
def toggleBulkVolume(self, checked):
self.volumeLine.setEnabled(checked)
if checked:
self.volumeLine.setText('')
self.volumeLine.setPlaceholderText('e.g., 5 or 1-10 or 1,3,5')
else:
self.volumeLine.setText('')
self.volumeLine.setPlaceholderText('(multiple files)')
def __init__(self): def __init__(self):
self.ui = QDialog() self.ui = QDialog()
self.parser = None self.parser = None
self.files = []
self.bulkMode = False
self.setupUi(self.ui) self.setupUi(self.ui)
self.ui.setWindowFlags(self.ui.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) self.ui.setWindowFlags(self.ui.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
self.bulkVolumeCheck.stateChanged.connect(self.toggleBulkVolume)
self.okButton.clicked.connect(self.saveData) self.okButton.clicked.connect(self.saveData)
self.cancelButton.clicked.connect(self.ui.close) self.cancelButton.clicked.connect(self.ui.close)
if sys.platform.startswith('linux'): if sys.platform.startswith('linux'):

View File

@@ -15,9 +15,9 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon, QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter, QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform) QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QApplication, QDialog, QGridLayout, QHBoxLayout, from PySide6.QtWidgets import (QApplication, QCheckBox, QDialog, QGridLayout,
QLabel, QLineEdit, QPushButton, QSizePolicy, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QVBoxLayout, QWidget) QSizePolicy, QVBoxLayout, QWidget)
from . import KCC_rc from . import KCC_rc
class Ui_editorDialog(object): class Ui_editorDialog(object):
@@ -57,6 +57,12 @@ class Ui_editorDialog(object):
self.gridLayout.addWidget(self.volumeLine, 1, 1, 1, 1) self.gridLayout.addWidget(self.volumeLine, 1, 1, 1, 1)
self.bulkVolumeCheck = QCheckBox(self.editorWidget)
self.bulkVolumeCheck.setObjectName(u"bulkVolumeCheck")
self.bulkVolumeCheck.setVisible(False)
self.gridLayout.addWidget(self.bulkVolumeCheck, 1, 2, 1, 1)
self.label_3 = QLabel(self.editorWidget) self.label_3 = QLabel(self.editorWidget)
self.label_3.setObjectName(u"label_3") self.label_3.setObjectName(u"label_3")
@@ -157,7 +163,8 @@ class Ui_editorDialog(object):
self.verticalLayout.addWidget(self.optionWidget) self.verticalLayout.addWidget(self.optionWidget)
QWidget.setTabOrder(self.seriesLine, self.volumeLine) QWidget.setTabOrder(self.seriesLine, self.volumeLine)
QWidget.setTabOrder(self.volumeLine, self.titleLine) QWidget.setTabOrder(self.volumeLine, self.bulkVolumeCheck)
QWidget.setTabOrder(self.bulkVolumeCheck, self.titleLine)
QWidget.setTabOrder(self.titleLine, self.numberLine) QWidget.setTabOrder(self.titleLine, self.numberLine)
QWidget.setTabOrder(self.numberLine, self.writerLine) QWidget.setTabOrder(self.numberLine, self.writerLine)
QWidget.setTabOrder(self.writerLine, self.pencillerLine) QWidget.setTabOrder(self.writerLine, self.pencillerLine)
@@ -175,6 +182,10 @@ class Ui_editorDialog(object):
editorDialog.setWindowTitle(QCoreApplication.translate("editorDialog", u"Metadata editor", None)) editorDialog.setWindowTitle(QCoreApplication.translate("editorDialog", u"Metadata editor", None))
self.label_1.setText(QCoreApplication.translate("editorDialog", u"Series:", None)) self.label_1.setText(QCoreApplication.translate("editorDialog", u"Series:", None))
self.label_2.setText(QCoreApplication.translate("editorDialog", u"Volume:", None)) self.label_2.setText(QCoreApplication.translate("editorDialog", u"Volume:", None))
#if QT_CONFIG(tooltip)
self.bulkVolumeCheck.setToolTip(QCoreApplication.translate("editorDialog", u"<b>Bulk Volume Editing</b><br>Check this box to assign volume numbers to multiple files.<br><br><b>Input formats:</b><br><code>5</code> \u2192 sequence starting from 5 (5, 6, 7...)<br><code>1-10</code> \u2192 range from 1 to 10<br><code>1, 3, 5</code> \u2192 specific values<br><br><i>Note: Files are sorted alphabetically before assignment.</i>", None))
#endif // QT_CONFIG(tooltip)
self.bulkVolumeCheck.setText("")
self.label_3.setText(QCoreApplication.translate("editorDialog", u"Number:", None)) self.label_3.setText(QCoreApplication.translate("editorDialog", u"Number:", None))
self.label_4.setText(QCoreApplication.translate("editorDialog", u"Writer:", None)) self.label_4.setText(QCoreApplication.translate("editorDialog", u"Writer:", None))
self.label_5.setText(QCoreApplication.translate("editorDialog", u"Penciller:", None)) self.label_5.setText(QCoreApplication.translate("editorDialog", u"Penciller:", None))