Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6dfe80f
Fixed bug in copying with multiple files selected
StevilKnevil Mar 13, 2025
d85a6ca
Copy multiple tags as JSON
StevilKnevil Mar 13, 2025
f032b58
Paste JSON as tags
StevilKnevil Mar 13, 2025
cd84954
Merge remote-tracking branch 'upstream/master'
StevilKnevil Mar 13, 2025
c9a8fbb
Merge remote-tracking branch 'upstream/master'
StevilKnevil Mar 14, 2025
b228e2b
Store the json as a custom mimetype on the clipboard
StevilKnevil Mar 14, 2025
5884cf1
Merge remote-tracking branch 'upstream/master' into copy-paste-multip…
StevilKnevil Mar 22, 2025
ad035ad
Added Tests and escaped TSV
StevilKnevil Mar 22, 2025
c5ab0bc
Move code from _paste_value() to new _paste_multiple() and _load_data…
zas Mar 29, 2025
73eeee5
Move code from _paste_value() to new _paste_single()
zas Mar 29, 2025
be0012b
Split _set_tag_values() into _set_tag_values_delayed_updates() and _u…
zas Mar 29, 2025
71ed43d
_paste_multiple(): only update objects once
zas Mar 29, 2025
5393143
_paste_single(): delay object updates
zas Mar 29, 2025
bd4c317
Move object updating code to _paste_value() and reduce code redundancy
zas Mar 29, 2025
9ad5be3
_update_objects(): do not catch exceptions, as it may hide issues
zas Mar 29, 2025
02336f0
Merge pull request #1 from zas/paste_updates
StevilKnevil Mar 29, 2025
120e08a
Rework Copy & Paste Actions
StevilKnevil Mar 29, 2025
f8b688a
Merge pull request #2 from StevilKnevil/Rework-c&p-actions
StevilKnevil Mar 31, 2025
de02cd7
More permissive copy
StevilKnevil Mar 31, 2025
ad4b168
Added mime data helper
StevilKnevil Apr 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 163 additions & 46 deletions picard/ui/metadatabox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@


from functools import partial
import json

from PyQt6 import (
QtCore,
Expand All @@ -47,14 +48,14 @@
from picard.config import get_config
from picard.file import File
from picard.i18n import (
N_,
gettext as _,
ngettext,
)
from picard.metadata import MULTI_VALUED_JOINER
from picard.track import Track
from picard.util import (
IgnoreUpdatesContext,
format_time,
icontheme,
restore_method,
thread,
Expand All @@ -73,6 +74,7 @@
)

from picard.ui.colors import interface_colors
from picard.ui.metadatabox.mimedatahelper import MimeDataHelper


class TableTagEditorDelegate(TagEditorDelegate):
Expand Down Expand Up @@ -137,6 +139,10 @@ def get_tag_name(self, index):

class MetadataBox(QtWidgets.QTableWidget):

MIMETYPE_PICARD_TAGS = "application/vdr.picard"
MIMETYPE_TSV = 'text/tab-separated-values'
MIMETYPE_TEXT = 'text/plain'

COLUMN_TAG = 0
COLUMN_ORIG = 1
COLUMN_NEW = 2
Expand Down Expand Up @@ -184,7 +190,6 @@ def __init__(self, parent=None):
self.selection_mutex = QtCore.QMutex()
self.selection_dirty = False
self.editing = None # the QTableWidgetItem being edited
self.clipboard = [""]
self.add_tag_action = QtGui.QAction(_("Add New Tag…"), self)
self.add_tag_action.triggered.connect(partial(self._edit_tag, ""))
self.changes_first_action = QtGui.QAction(_("Show Changes First"), self)
Expand All @@ -202,7 +207,23 @@ def __init__(self, parent=None):
self._single_file_album = False
self._single_track_album = False
self.ignore_updates = IgnoreUpdatesContext(on_exit=self.update)
self.tagger.clipboard().dataChanged.connect(self._update_clipboard)

self.mimedata_helper = MimeDataHelper()
self.mimedata_helper.register(
self.MIMETYPE_PICARD_TAGS,
encode_func=lambda tag_diff: tag_diff.to_json().encode('utf-8'),
decode_func=lambda target, mimedata: target._paste_from_json(mimedata),
)
self.mimedata_helper.register(
self.MIMETYPE_TSV,
encode_func=lambda tag_diff: tag_diff.to_tsv().encode('utf-8'),
decode_func=None,
)
self.mimedata_helper.register(
self.MIMETYPE_TEXT,
encode_func=lambda tag_diff: tag_diff.to_tsv().encode('utf-8'),
decode_func=lambda target, mimedata: target._paste_from_text(mimedata),
)

def _on_setting_changed(self, name, old_value, new_value):
settings_to_watch = {
Expand Down Expand Up @@ -265,41 +286,129 @@ def keyPressEvent(self, event):
else:
super().keyPressEvent(event)

def get_selected_tags(self, items):
result = TagDiff()
for item in items:
tag, value = self._get_row_info(item.row())
col = item.column()
result.add(
tag=tag,
old=value[self.COLUMN_ORIG] if col == self.COLUMN_ORIG else None,
new=value[self.COLUMN_NEW] if col == self.COLUMN_NEW else None,
removable=self.tag_diff.status[tag] != TagStatus.NOTREMOVABLE,
readonly=self.tag_diff.status[tag] == TagStatus.READONLY
)

result.update_tag_names()
return result

def _get_row_info(self, row):
tag = self.tag_diff.tag_names[row]
value = {
self.COLUMN_ORIG: self.tag_diff.old[tag],
self.COLUMN_NEW: self.tag_diff.new[tag],
}
return tag, value

def _can_copy(self):
return True

def _copy_value(self):
item = self.currentItem()
if item:
column = item.column()
tag = self.tag_diff.tag_names[item.row()]
value = None
if column == self.COLUMN_ORIG:
value = self.tag_diff.old[tag]
elif column == self.COLUMN_NEW:
value = self.tag_diff.new[tag]
if not self._can_copy():
msg = N_("Unable to copy current selection.")
self.tagger.window.set_statusbar_message(msg, echo=log.info, timeout=3000)
return

if tag == '~length':
items = self.selectedItems()
if len(items) > 1:
selected_data = self.get_selected_tags(items)
# Build the mimedata to use for the clipboard
mimedata = QtCore.QMimeData()
converted_data_cache = {}
for mimetype, encode_func in self.mimedata_helper.encode_funcs():
try:
value = [format_time(value or 0), ]
except (TypeError, ValueError) as why:
log.warning(why)
value = ['']

if value is not None:
self.tagger.clipboard().setText(MULTI_VALUED_JOINER.join(value))
self.clipboard = value
if encode_func not in converted_data_cache:
converted_data_cache[encode_func] = encode_func(selected_data)
mimedata.setData(mimetype, converted_data_cache[encode_func])
except Exception as e:
log.error("Failed to convert %r to '%s': %s", selected_data, mimetype, e)
# Ensure we actually have something to copy to the clipboard
if mimedata.formats():
log.debug("Copying %r to clipboard as %r", selected_data.tag_names, mimedata.formats())
self.tagger.clipboard().setMimeData(mimedata)
else:
# Just copy the current item as a string
item = self.currentItem()
if item:
tag, value = self._get_row_info(item.row())
value = value[item.column()]
if tag == '~length':
value = self.tag_diff.handle_length(value, prettify_times=True)
if value is not None:
log.debug("Copying '%s' to clipboard (from tag '%s')", value, tag)
self.tagger.clipboard().setText(MULTI_VALUED_JOINER.join(value))

def _paste_from_json(self, mimedata):
def _decode_json(mimedata):
try:
text = mimedata.data(self.MIMETYPE_PICARD_TAGS).data()
return json.loads(text)
except json.JSONDecodeError as e:
log.error("Failed to decode JSON data from clipboard: %r", e)

def _apply_tag_dict(data):
for tag in data:
if self._tag_is_editable(tag):
# Prefer 'new' values, but fall back to 'old' if not available
value = data[tag].get(TagDiff.NEW_VALUE) or data[tag].get(TagDiff.OLD_VALUE)
if value:
if isinstance(value, list):
# There are multiple values for the tag
value = MULTI_VALUED_JOINER.join(value)
# each value may also represent multiple values
log.info("Pasting '%s' from JSON clipboard to tag '%s'", value, tag)
value = value.split(MULTI_VALUED_JOINER)
yield from self._set_tag_values_delayed_updates(tag, value)
else:
log.error("Tag '%s' without new or old value found in clipboard, ignoring.", tag)

data = _decode_json(mimedata)
return _apply_tag_dict(data) if data else []

def _paste_from_text(self, mimedata):
item = self.currentItem()
column_is_editable = (item.column() == self.COLUMN_NEW)
tag = self.tag_diff.tag_names[item.row()]
value = mimedata.text()
if column_is_editable and self._tag_is_editable(tag) and value:
log.info("Pasting %s from text clipboard to tag %s", value, tag)
value = value.split(MULTI_VALUED_JOINER)
yield from self._set_tag_values_delayed_updates(tag, value)

def _can_paste(self):
mimedata = self.tagger.clipboard().mimeData()
has_valid_mime_data = any(self.mimedata_helper.decode_funcs(mimedata))
return has_valid_mime_data and len(self.tracks) <= 1 and len(self.files) <= 1

def _paste_value(self):
item = self.currentItem()
if item:
column = item.column()
tag = self.tag_diff.tag_names[item.row()]
if column == self.COLUMN_NEW and self._tag_is_editable(tag):
self._set_tag_values(tag, self.clipboard)
self.update()
if not self._can_paste():
msg = N_("No valid data in clipboard to paste")
self.tagger.window.set_statusbar_message(msg, echo=log.info, timeout=3000)
return

def _update_clipboard(self):
clipboard = self.tagger.clipboard().text().split(MULTI_VALUED_JOINER)
if clipboard:
self.clipboard = clipboard
objects_to_update = set()
mimedata = self.tagger.clipboard().mimeData()

for decode_func in self.mimedata_helper.decode_funcs(mimedata):
objs = decode_func(self, mimedata)
objects_to_update.update(objs)
if objs:
# We have successfully pasted from the clipboard, don't try other mimetypes
break

if objects_to_update:
objects_to_update.add(self)
self._update_objects(objects_to_update)

def closeEditor(self, editor, hint):
super().closeEditor(editor, hint)
Expand Down Expand Up @@ -395,17 +504,18 @@ def contextMenuEvent(self, event):
merge_tags_action.triggered.connect(partial(self._apply_update_funcs, mergeorigs))
menu.addAction(merge_tags_action)
menu.addSeparator()
if single_tag:
menu.addSeparator()
copy_action = QtGui.QAction(icontheme.lookup('edit-copy', icontheme.ICON_SIZE_MENU), _("&Copy"), self)
copy_action.triggered.connect(self._copy_value)
copy_action.setShortcut(QtGui.QKeySequence.StandardKey.Copy)
menu.addAction(copy_action)
paste_action = QtGui.QAction(icontheme.lookup('edit-paste', icontheme.ICON_SIZE_MENU), _("&Paste"), self)
paste_action.triggered.connect(self._paste_value)
paste_action.setShortcut(QtGui.QKeySequence.StandardKey.Paste)
paste_action.setEnabled(editable)
menu.addAction(paste_action)

menu.addSeparator()
copy_action = QtGui.QAction(icontheme.lookup('edit-copy', icontheme.ICON_SIZE_MENU), _("&Copy"), self)
copy_action.triggered.connect(self._copy_value)
copy_action.setShortcut(QtGui.QKeySequence.StandardKey.Copy)
copy_action.setEnabled(self._can_copy())
menu.addAction(copy_action)
paste_action = QtGui.QAction(icontheme.lookup('edit-paste', icontheme.ICON_SIZE_MENU), _("&Paste"), self)
paste_action.triggered.connect(self._paste_value)
paste_action.setShortcut(QtGui.QKeySequence.StandardKey.Paste)
paste_action.setEnabled(self._can_paste())
menu.addAction(paste_action)
if single_tag or removals or useorigs:
menu.addSeparator()
menu.addAction(self.add_tag_action)
Expand Down Expand Up @@ -451,7 +561,7 @@ def _set_tag_values_extra(self, tag, values, obj, extra_objects):
objects.extend(extra_objects)
self._set_tag_values(tag, values, objects=objects)

def _set_tag_values(self, tag, values, objects=None):
def _set_tag_values_delayed_updates(self, tag, values, objects=None):
if objects is None:
objects = self.objects
with self.tagger.window.ignore_selection_changes:
Expand All @@ -460,11 +570,18 @@ def _set_tag_values(self, tag, values, objects=None):
if not values and self._tag_is_removable(tag):
for obj in objects:
del obj.metadata[tag]
obj.update()
yield obj
elif values:
for obj in objects:
obj.metadata[tag] = values
obj.update()
yield obj

def _update_objects(self, objects):
for obj in set(objects):
obj.update()

def _set_tag_values(self, tag, values, objects=None):
self._update_objects(self._set_tag_values_delayed_updates(tag, values, objects=objects))

def _remove_tag(self, tag):
self._set_tag_values(tag, [])
Expand Down
85 changes: 85 additions & 0 deletions picard/ui/metadatabox/mimedatahelper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2025 Stevil Knevil
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.


from collections import namedtuple


class MimeDataHelper:
"""
A registry for encoding and decoding functions based on MIME types.

This class allows users to register custom encode and decode functions
for specific MIME types. These functions can then be used to handle
data serialization and deserialization for those MIME types.
"""

MimeConverters = namedtuple('MimeConverters', ('encode_func', 'decode_func'))

def __init__(self):
self._registry = {}

def register(self, mimetype, encode_func=None, decode_func=None):
"""
Registers encode and decode functions for a specific MIME type.

Args:
mimetype (str): The MIME type to register.
encode_func (callable or None): A function that encodes data for the MIME type, or None.
decode_func (callable or None): A function that decodes data for the MIME type, or None.
"""
if encode_func is not None and not callable(encode_func):
raise ValueError("encode_func must be callable or None.")
if decode_func is not None and not callable(decode_func):
raise ValueError("decode_func must be callable or None.")
if self.is_registered(mimetype):
raise ValueError(f"MIME type '{mimetype}' is already registered.")

self._registry[mimetype] = self.MimeConverters(
encode_func=encode_func,
decode_func=decode_func
)

def is_registered(self, mimetype):
return mimetype in self._registry

def encode_func(self, mimetype):
return self._registry[mimetype].encode_func

def decode_func(self, mimetype):
return self._registry[mimetype].decode_func

def encode_funcs(self):
"""
Return a generator of registered MIMETYPES and their handling encoding functions.
"""
for mimetype, converters in self._registry.items():
if converters.encode_func:
yield mimetype, self._registry[mimetype].encode_func

def decode_funcs(self, mimedata):
"""
Return a generator of decoding functions that can handle mimetypes in the given mimedata.
Args:
mimedata (QMimeData): The MIME data to check.
"""
for mimetype, converters in self._registry.items():
if mimedata.hasFormat(mimetype) and converters.decode_func:
yield self._registry[mimetype].decode_func
Loading
Loading