From cb339c6a4a09f551ba348e347143486312faaa0f Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sun, 4 Dec 2022 13:53:58 +0100 Subject: [PATCH] PICARD-356: Allow user defined replacements for Windows incompatible characters --- picard/ui/options/renaming_compat.py | 73 +++++ picard/ui/ui_options_renaming_compat.py | 23 +- picard/ui/ui_win_compat_dialog.py | 193 +++++++++++++ picard/util/__init__.py | 22 +- picard/util/scripttofilename.py | 2 +- test/formats/common.py | 1 + test/test_coverart_image.py | 2 + test/test_file.py | 1 + test/test_scripttofilename.py | 14 + test/test_utils.py | 65 ++++- ui/options_renaming_compat.ui | 64 ++++- ui/win_compat_dialog.ui | 349 ++++++++++++++++++++++++ 12 files changed, 774 insertions(+), 35 deletions(-) create mode 100644 picard/ui/ui_win_compat_dialog.py create mode 100644 ui/win_compat_dialog.ui diff --git a/picard/ui/options/renaming_compat.py b/picard/ui/options/renaming_compat.py index d40a94016..e2e53d354 100644 --- a/picard/ui/options/renaming_compat.py +++ b/picard/ui/options/renaming_compat.py @@ -32,24 +32,29 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +import re from PyQt5 import ( QtCore, + QtGui, QtWidgets, ) from picard.config import ( BoolOption, + Option, get_config, ) from picard.const.sys import IS_WIN from picard.util import system_supports_long_paths +from picard.ui import PicardDialog from picard.ui.options import ( OptionsPage, register_options_page, ) from picard.ui.ui_options_renaming_compat import Ui_RenamingCompatOptionsPage +from picard.ui.ui_win_compat_dialog import Ui_WinCompatDialog class RenamingCompatOptionsPage(OptionsPage): @@ -65,21 +70,34 @@ class RenamingCompatOptionsPage(OptionsPage): BoolOption("setting", "windows_long_paths", system_supports_long_paths() if IS_WIN else False), BoolOption("setting", "ascii_filenames", False), BoolOption("setting", "replace_spaces_with_underscores", False), + Option("setting", "win_compat_replacements", { + '*': '_', + ':': '_', + '<': '_', + '>': '_', + '?': '_', + '|': '_', + '"': '_', + }) ] options_changed = QtCore.pyqtSignal(dict) def __init__(self, parent=None): super().__init__(parent) + config = get_config() + self.win_compat_replacements = config.setting["win_compat_replacements"] self.ui = Ui_RenamingCompatOptionsPage() self.ui.setupUi(self) self.ui.ascii_filenames.toggled.connect(self.on_options_changed) self.ui.windows_compatibility.toggled.connect(self.on_options_changed) self.ui.windows_long_paths.toggled.connect(self.on_options_changed) self.ui.replace_spaces_with_underscores.toggled.connect(self.on_options_changed) + self.ui.btn_windows_compatibility_change.clicked.connect(self.open_win_compat_dialog) def load(self): config = get_config() + self.win_compat_replacements = config.setting["win_compat_replacements"] try: self.ui.windows_long_paths.toggled.disconnect(self.toggle_windows_long_paths) except TypeError: @@ -123,7 +141,62 @@ class RenamingCompatOptionsPage(OptionsPage): 'windows_compatibility': self.ui.windows_compatibility.isChecked(), 'windows_long_paths': self.ui.windows_long_paths.isChecked(), 'replace_spaces_with_underscores': self.ui.replace_spaces_with_underscores.isChecked(), + 'win_compat_replacements': self.win_compat_replacements, } + def open_win_compat_dialog(self): + dialog = WinCompatDialog(self.win_compat_replacements, parent=self) + if dialog.exec_() == QtWidgets.QDialog.DialogCode.Accepted: + self.win_compat_replacements = dialog.replacements + self.on_options_changed() + + +class WinCompatReplacementValidator(QtGui.QValidator): + _re_valid_win_replacement = re.compile(r'^[^"*:<>?|/\\\s]?$') + + def validate(self, text: str, pos): + if self._re_valid_win_replacement.match(text): + state = QtGui.QValidator.State.Acceptable + else: + state = QtGui.QValidator.State.Invalid + return state, text, pos + + +class WinCompatDialog(PicardDialog): + def __init__(self, replacements, parent=None): + super().__init__(parent) + self.replacements = dict(replacements) + self.ui = Ui_WinCompatDialog() + self.ui.setupUi(self) + self.ui.replace_asterisk.setValidator(WinCompatReplacementValidator()) + self.ui.replace_colon.setValidator(WinCompatReplacementValidator()) + self.ui.replace_gt.setValidator(WinCompatReplacementValidator()) + self.ui.replace_lt.setValidator(WinCompatReplacementValidator()) + self.ui.replace_pipe.setValidator(WinCompatReplacementValidator()) + self.ui.replace_questionmark.setValidator(WinCompatReplacementValidator()) + self.ui.replace_quotationmark.setValidator(WinCompatReplacementValidator()) + self.ui.buttonbox.accepted.connect(self.accept) + self.ui.buttonbox.rejected.connect(self.reject) + self.load() + + def load(self): + self.ui.replace_asterisk.setText(self.replacements['*']) + self.ui.replace_colon.setText(self.replacements[':']) + self.ui.replace_gt.setText(self.replacements['>']) + self.ui.replace_lt.setText(self.replacements['<']) + self.ui.replace_pipe.setText(self.replacements['|']) + self.ui.replace_questionmark.setText(self.replacements['?']) + self.ui.replace_quotationmark.setText(self.replacements['"']) + + def accept(self): + self.replacements['*'] = self.ui.replace_asterisk.text() + self.replacements[':'] = self.ui.replace_colon.text() + self.replacements['>'] = self.ui.replace_gt.text() + self.replacements['<'] = self.ui.replace_lt.text() + self.replacements['|'] = self.ui.replace_pipe.text() + self.replacements['?'] = self.ui.replace_questionmark.text() + self.replacements['"'] = self.ui.replace_quotationmark.text() + super().accept() + register_options_page(RenamingCompatOptionsPage) diff --git a/picard/ui/ui_options_renaming_compat.py b/picard/ui/ui_options_renaming_compat.py index 245a12b76..c7bd5b9bd 100644 --- a/picard/ui/ui_options_renaming_compat.py +++ b/picard/ui/ui_options_renaming_compat.py @@ -11,7 +11,7 @@ class Ui_RenamingCompatOptionsPage(object): def setupUi(self, RenamingCompatOptionsPage): RenamingCompatOptionsPage.setObjectName("RenamingCompatOptionsPage") RenamingCompatOptionsPage.setEnabled(True) - RenamingCompatOptionsPage.resize(453, 374) + RenamingCompatOptionsPage.resize(453, 332) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -22,9 +22,21 @@ class Ui_RenamingCompatOptionsPage(object): self.ascii_filenames = QtWidgets.QCheckBox(RenamingCompatOptionsPage) self.ascii_filenames.setObjectName("ascii_filenames") self.verticalLayout_5.addWidget(self.ascii_filenames) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") self.windows_compatibility = QtWidgets.QCheckBox(RenamingCompatOptionsPage) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.windows_compatibility.sizePolicy().hasHeightForWidth()) + self.windows_compatibility.setSizePolicy(sizePolicy) self.windows_compatibility.setObjectName("windows_compatibility") - self.verticalLayout_5.addWidget(self.windows_compatibility) + self.horizontalLayout.addWidget(self.windows_compatibility) + self.btn_windows_compatibility_change = QtWidgets.QPushButton(RenamingCompatOptionsPage) + self.btn_windows_compatibility_change.setEnabled(False) + self.btn_windows_compatibility_change.setObjectName("btn_windows_compatibility_change") + self.horizontalLayout.addWidget(self.btn_windows_compatibility_change) + self.verticalLayout_5.addLayout(self.horizontalLayout) self.windows_long_paths = QtWidgets.QCheckBox(RenamingCompatOptionsPage) self.windows_long_paths.setEnabled(False) self.windows_long_paths.setObjectName("windows_long_paths") @@ -32,6 +44,8 @@ class Ui_RenamingCompatOptionsPage(object): self.replace_spaces_with_underscores = QtWidgets.QCheckBox(RenamingCompatOptionsPage) self.replace_spaces_with_underscores.setObjectName("replace_spaces_with_underscores") self.verticalLayout_5.addWidget(self.replace_spaces_with_underscores) + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_5.addItem(spacerItem) self.example_selection_note = QtWidgets.QLabel(RenamingCompatOptionsPage) self.example_selection_note.setText("") self.example_selection_note.setWordWrap(True) @@ -40,13 +54,14 @@ class Ui_RenamingCompatOptionsPage(object): self.retranslateUi(RenamingCompatOptionsPage) self.windows_compatibility.toggled['bool'].connect(self.windows_long_paths.setEnabled) # type: ignore + self.windows_compatibility.toggled['bool'].connect(self.btn_windows_compatibility_change.setEnabled) # type: ignore QtCore.QMetaObject.connectSlotsByName(RenamingCompatOptionsPage) - RenamingCompatOptionsPage.setTabOrder(self.ascii_filenames, self.windows_compatibility) - RenamingCompatOptionsPage.setTabOrder(self.windows_compatibility, self.windows_long_paths) + RenamingCompatOptionsPage.setTabOrder(self.ascii_filenames, self.windows_long_paths) def retranslateUi(self, RenamingCompatOptionsPage): _translate = QtCore.QCoreApplication.translate self.ascii_filenames.setText(_("Replace non-ASCII characters")) self.windows_compatibility.setText(_("Windows compatibility")) + self.btn_windows_compatibility_change.setText(_("Customize...")) self.windows_long_paths.setText(_("Allow paths longer than 259 characters")) self.replace_spaces_with_underscores.setText(_("Replace spaces with underscores")) diff --git a/picard/ui/ui_win_compat_dialog.py b/picard/ui/ui_win_compat_dialog.py new file mode 100644 index 000000000..ca98af2ea --- /dev/null +++ b/picard/ui/ui_win_compat_dialog.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- + +# Automatically generated - don't edit. +# Use `python setup.py build_ui` to update it. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_WinCompatDialog(object): + def setupUi(self, WinCompatDialog): + WinCompatDialog.setObjectName("WinCompatDialog") + WinCompatDialog.setWindowModality(QtCore.Qt.WindowModal) + WinCompatDialog.resize(285, 295) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(1) + sizePolicy.setVerticalStretch(1) + sizePolicy.setHeightForWidth(WinCompatDialog.sizePolicy().hasHeightForWidth()) + WinCompatDialog.setSizePolicy(sizePolicy) + self.verticalLayout = QtWidgets.QVBoxLayout(WinCompatDialog) + self.verticalLayout.setSizeConstraint(QtWidgets.QLayout.SetFixedSize) + self.verticalLayout.setObjectName("verticalLayout") + self.gridLayout = QtWidgets.QGridLayout() + self.gridLayout.setObjectName("gridLayout") + self.label_header_character = QtWidgets.QLabel(WinCompatDialog) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.label_header_character.setFont(font) + self.label_header_character.setObjectName("label_header_character") + self.gridLayout.addWidget(self.label_header_character, 0, 0, 1, 1) + self.label_header_replace = QtWidgets.QLabel(WinCompatDialog) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.label_header_replace.sizePolicy().hasHeightForWidth()) + self.label_header_replace.setSizePolicy(sizePolicy) + font = QtGui.QFont() + font.setBold(True) + font.setWeight(75) + self.label_header_replace.setFont(font) + self.label_header_replace.setObjectName("label_header_replace") + self.gridLayout.addWidget(self.label_header_replace, 0, 2, 1, 1) + self.label_lt = QtWidgets.QLabel(WinCompatDialog) + font = QtGui.QFont() + font.setFamily("Monospace") + self.label_lt.setFont(font) + self.label_lt.setObjectName("label_lt") + self.gridLayout.addWidget(self.label_lt, 3, 0, 1, 1) + self.label_colon = QtWidgets.QLabel(WinCompatDialog) + font = QtGui.QFont() + font.setFamily("Monospace") + self.label_colon.setFont(font) + self.label_colon.setObjectName("label_colon") + self.gridLayout.addWidget(self.label_colon, 2, 0, 1, 1) + self.label_gt = QtWidgets.QLabel(WinCompatDialog) + font = QtGui.QFont() + font.setFamily("Monospace") + self.label_gt.setFont(font) + self.label_gt.setObjectName("label_gt") + self.gridLayout.addWidget(self.label_gt, 4, 0, 1, 1) + self.replace_questionmark = QtWidgets.QLineEdit(WinCompatDialog) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.replace_questionmark.sizePolicy().hasHeightForWidth()) + self.replace_questionmark.setSizePolicy(sizePolicy) + self.replace_questionmark.setMaximumSize(QtCore.QSize(20, 16777215)) + self.replace_questionmark.setMaxLength(1) + self.replace_questionmark.setObjectName("replace_questionmark") + self.gridLayout.addWidget(self.replace_questionmark, 5, 2, 1, 1) + self.label_questionmark = QtWidgets.QLabel(WinCompatDialog) + font = QtGui.QFont() + font.setFamily("Monospace") + self.label_questionmark.setFont(font) + self.label_questionmark.setObjectName("label_questionmark") + self.gridLayout.addWidget(self.label_questionmark, 5, 0, 1, 1) + self.label_pipe = QtWidgets.QLabel(WinCompatDialog) + font = QtGui.QFont() + font.setFamily("Monospace") + self.label_pipe.setFont(font) + self.label_pipe.setObjectName("label_pipe") + self.gridLayout.addWidget(self.label_pipe, 6, 0, 1, 1) + self.replace_asterisk = QtWidgets.QLineEdit(WinCompatDialog) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Minimum) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.replace_asterisk.sizePolicy().hasHeightForWidth()) + self.replace_asterisk.setSizePolicy(sizePolicy) + self.replace_asterisk.setMaximumSize(QtCore.QSize(20, 16777215)) + self.replace_asterisk.setMaxLength(1) + self.replace_asterisk.setObjectName("replace_asterisk") + self.gridLayout.addWidget(self.replace_asterisk, 1, 2, 1, 1) + self.replace_gt = QtWidgets.QLineEdit(WinCompatDialog) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.replace_gt.sizePolicy().hasHeightForWidth()) + self.replace_gt.setSizePolicy(sizePolicy) + self.replace_gt.setMaximumSize(QtCore.QSize(20, 16777215)) + self.replace_gt.setMaxLength(1) + self.replace_gt.setObjectName("replace_gt") + self.gridLayout.addWidget(self.replace_gt, 4, 2, 1, 1) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.gridLayout.addItem(spacerItem, 0, 3, 1, 1) + self.replace_lt = QtWidgets.QLineEdit(WinCompatDialog) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.replace_lt.sizePolicy().hasHeightForWidth()) + self.replace_lt.setSizePolicy(sizePolicy) + self.replace_lt.setMaximumSize(QtCore.QSize(20, 16777215)) + self.replace_lt.setMaxLength(1) + self.replace_lt.setObjectName("replace_lt") + self.gridLayout.addWidget(self.replace_lt, 3, 2, 1, 1) + self.label_asterisk = QtWidgets.QLabel(WinCompatDialog) + font = QtGui.QFont() + font.setFamily("Monospace") + self.label_asterisk.setFont(font) + self.label_asterisk.setObjectName("label_asterisk") + self.gridLayout.addWidget(self.label_asterisk, 1, 0, 1, 1) + self.replace_pipe = QtWidgets.QLineEdit(WinCompatDialog) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.replace_pipe.sizePolicy().hasHeightForWidth()) + self.replace_pipe.setSizePolicy(sizePolicy) + self.replace_pipe.setMaximumSize(QtCore.QSize(20, 16777215)) + self.replace_pipe.setMaxLength(1) + self.replace_pipe.setObjectName("replace_pipe") + self.gridLayout.addWidget(self.replace_pipe, 6, 2, 1, 1) + self.replace_colon = QtWidgets.QLineEdit(WinCompatDialog) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.replace_colon.sizePolicy().hasHeightForWidth()) + self.replace_colon.setSizePolicy(sizePolicy) + self.replace_colon.setMaximumSize(QtCore.QSize(20, 16777215)) + self.replace_colon.setMaxLength(1) + self.replace_colon.setObjectName("replace_colon") + self.gridLayout.addWidget(self.replace_colon, 2, 2, 1, 1) + self.label_quotationmark = QtWidgets.QLabel(WinCompatDialog) + font = QtGui.QFont() + font.setFamily("Monospace") + self.label_quotationmark.setFont(font) + self.label_quotationmark.setObjectName("label_quotationmark") + self.gridLayout.addWidget(self.label_quotationmark, 7, 0, 1, 1) + self.replace_quotationmark = QtWidgets.QLineEdit(WinCompatDialog) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.replace_quotationmark.sizePolicy().hasHeightForWidth()) + self.replace_quotationmark.setSizePolicy(sizePolicy) + self.replace_quotationmark.setMaximumSize(QtCore.QSize(20, 16777215)) + self.replace_quotationmark.setObjectName("replace_quotationmark") + self.gridLayout.addWidget(self.replace_quotationmark, 7, 2, 1, 1) + self.verticalLayout.addLayout(self.gridLayout) + spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout.addItem(spacerItem1) + self.buttonbox = QtWidgets.QDialogButtonBox(WinCompatDialog) + self.buttonbox.setOrientation(QtCore.Qt.Horizontal) + self.buttonbox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Save) + self.buttonbox.setObjectName("buttonbox") + self.verticalLayout.addWidget(self.buttonbox) + + self.retranslateUi(WinCompatDialog) + QtCore.QMetaObject.connectSlotsByName(WinCompatDialog) + WinCompatDialog.setTabOrder(self.replace_asterisk, self.replace_colon) + WinCompatDialog.setTabOrder(self.replace_colon, self.replace_lt) + WinCompatDialog.setTabOrder(self.replace_lt, self.replace_gt) + WinCompatDialog.setTabOrder(self.replace_gt, self.replace_questionmark) + WinCompatDialog.setTabOrder(self.replace_questionmark, self.replace_pipe) + WinCompatDialog.setTabOrder(self.replace_pipe, self.replace_quotationmark) + + def retranslateUi(self, WinCompatDialog): + _translate = QtCore.QCoreApplication.translate + WinCompatDialog.setWindowTitle(_("Windows compatibility")) + self.label_header_character.setText(_("Character")) + self.label_header_replace.setText(_("Replacement")) + self.label_lt.setText(_("<")) + self.label_colon.setText(_(":")) + self.label_gt.setText(_(">")) + self.replace_questionmark.setText(_("_")) + self.label_questionmark.setText(_("?")) + self.label_pipe.setText(_("|")) + self.replace_asterisk.setText(_("_")) + self.replace_gt.setText(_("_")) + self.replace_lt.setText(_("_")) + self.label_asterisk.setText(_("*")) + self.replace_pipe.setText(_("_")) + self.replace_colon.setText(_("_")) + self.label_quotationmark.setText(_("\"")) + self.replace_quotationmark.setText(_("_")) diff --git a/picard/util/__init__.py b/picard/util/__init__.py index c5ba1e8f5..3521a0b8c 100644 --- a/picard/util/__init__.py +++ b/picard/util/__init__.py @@ -39,7 +39,10 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -from collections import namedtuple +from collections import ( + defaultdict, + namedtuple, +) from collections.abc import Mapping from itertools import chain import json @@ -293,16 +296,21 @@ def sanitize_date(datestr): return ("", "%04d", "%04d-%02d", "%04d-%02d-%02d")[len(date)] % tuple(date) -_re_win32_incompat = re.compile(r'["*:<>?|]', re.UNICODE) -def replace_win32_incompat(string, repl="_"): # noqa: E302 +def replace_win32_incompat(string, repl="_", replacements=None): # noqa: E302 """Replace win32 filename incompatible characters from ``string`` by ``repl``.""" - # Don't replace : with _ for windows drive + # Don't replace : for windows drive if IS_WIN and os.path.isabs(string): - drive, rest = ntpath.splitdrive(string) - return drive + _re_win32_incompat.sub(repl, rest) + drive, string = ntpath.splitdrive(string) else: - return _re_win32_incompat.sub(repl, string) + drive = '' + + replacements = defaultdict(lambda: repl, replacements or {}) + for char in {'"', '*', ':', '<', '>', '?', '|'}: + if char in string: + string = string.replace(char, replacements[char]) + + return drive + string _re_non_alphanum = re.compile(r'\W+', re.UNICODE) diff --git a/picard/util/scripttofilename.py b/picard/util/scripttofilename.py index 23746b918..a72c39497 100644 --- a/picard/util/scripttofilename.py +++ b/picard/util/scripttofilename.py @@ -66,7 +66,7 @@ def script_to_filename_with_metadata(naming_format, metadata, file=None, setting filename = replace_non_ascii(filename, pathsave=True, win_compat=win_compat) # replace incompatible characters if win_compat: - filename = replace_win32_incompat(filename) + filename = replace_win32_incompat(filename, replacements=settings["win_compat_replacements"]) if settings["replace_spaces_with_underscores"]: filename = _re_replace_underscores.sub('_', filename.strip()) # remove null characters diff --git a/test/formats/common.py b/test/formats/common.py index ffcbb5379..0cdde9e1f 100644 --- a/test/formats/common.py +++ b/test/formats/common.py @@ -63,6 +63,7 @@ settings = { 'remove_wave_riff_info': False, 'wave_riff_info_encoding': 'iso-8859-1', 'replace_spaces_with_underscores': False, + 'win_compat_replacements': {}, } diff --git a/test/test_coverart_image.py b/test/test_coverart_image.py index 7e2582d9a..8764bd9c4 100644 --- a/test/test_coverart_image.py +++ b/test/test_coverart_image.py @@ -166,6 +166,7 @@ class CoverArtImageTest(PicardTestCase): self.set_config_values({ 'image_type_as_filename': True, 'windows_compatibility': True, + 'win_compat_replacements': {}, 'windows_long_paths': False, 'replace_spaces_with_underscores': False, 'enabled_plugins': [], @@ -197,6 +198,7 @@ class CoverArtImageMakeFilenameTest(PicardTestCase): self.metadata = Metadata() self.set_config_values({ 'windows_compatibility': False, + 'win_compat_replacements': {}, 'enabled_plugins': [], 'ascii_filenames': False, 'replace_spaces_with_underscores': False, diff --git a/test/test_file.py b/test/test_file.py index 0454ba272..952cacd44 100644 --- a/test/test_file.py +++ b/test/test_file.py @@ -210,6 +210,7 @@ class FileNamingTest(PicardTestCase): 'move_files': False, 'rename_files': False, 'windows_compatibility': True, + 'win_compat_replacements': {}, 'windows_long_paths': False, 'replace_spaces_with_underscores': False, 'file_renaming_scripts': {'test_id': {'script': '%album%/%title%'}}, diff --git a/test/test_scripttofilename.py b/test/test_scripttofilename.py index 627244f51..214a593ed 100644 --- a/test/test_scripttofilename.py +++ b/test/test_scripttofilename.py @@ -39,6 +39,7 @@ settings = { 'ascii_filenames': False, 'enabled_plugins': [], 'windows_compatibility': False, + 'win_compat_replacements': {}, 'replace_spaces_with_underscores': False, } @@ -116,6 +117,19 @@ class ScriptToFilenameTest(PicardTestCase): filename = script_to_filename('%artist%?', metadata, settings=settings) self.assertEqual(expect_compat, filename) + def test_windows_compatibility_custom_replacements(self): + metadata = Metadata() + metadata['artist'] = '\\*:' + expect_compat = '_+_!' + settings = config.setting.copy() + settings['windows_compatibility'] = True + settings['win_compat_replacements'] = { + '*': '+', + '?': '!', + } + filename = script_to_filename('%artist%?', metadata, settings=settings) + self.assertEqual(expect_compat, filename) + def test_replace_spaces_with_underscores(self): metadata = Metadata() metadata['artist'] = ' The \t New* _ Artist ' diff --git a/test/test_utils.py b/test/test_utils.py index e5f385a2a..3b7efc385 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -88,27 +88,62 @@ class ReplaceWin32IncompatTest(PicardTestCase): @unittest.skipUnless(IS_WIN, "windows test") def test_correct_absolute_win32(self): - self.assertEqual(util.replace_win32_incompat("c:\\test\\te\"st/2"), - "c:\\test\\te_st/2") - self.assertEqual(util.replace_win32_incompat("c:\\test\\d:/2"), - "c:\\test\\d_/2") + self.assertEqual(util.replace_win32_incompat('c:\\test\\te"st/2'), + 'c:\\test\\te_st/2') + self.assertEqual(util.replace_win32_incompat('c:\\test\\d:/2'), + 'c:\\test\\d_/2') - @unittest.skipUnless(not IS_WIN, "non-windows test") + @unittest.skipUnless(not IS_WIN, 'non-windows test') def test_correct_absolute_non_win32(self): - self.assertEqual(util.replace_win32_incompat("/test/te\"st/2"), - "/test/te_st/2") - self.assertEqual(util.replace_win32_incompat("/test/d:/2"), - "/test/d_/2") + self.assertEqual(util.replace_win32_incompat('/test/te"st/2'), + '/test/te_st/2') + self.assertEqual(util.replace_win32_incompat('/test/d:/2'), + '/test/d_/2') def test_correct_relative(self): - self.assertEqual(util.replace_win32_incompat("A\"*:<>?|b"), - "A_______b") - self.assertEqual(util.replace_win32_incompat("d:tes?|b'), + 'A_______b') + self.assertEqual(util.replace_win32_incompat('d:tes?|b', repl='+'), + "A+++++++b") + + def test_custom_replacement_map(self): + input = 'foo*:<>?|"' + replacments = { + '*': 'A', + ':': 'B', + '<': 'C', + '>': 'D', + '?': 'E', + '|': 'F', + '"': 'G', + } + replaced = util.replace_win32_incompat(input, replacements=replacments) + self.assertEqual('fooABCDEFG', replaced) + + def test_partial_replacement_map(self): + input = 'foo*:<>?|"' + replacments = { + '*': 'A', + '<': 'C', + } + replaced = util.replace_win32_incompat(input, repl='-', replacements=replacments) + self.assertEqual('fooA-C----', replaced) + + def test_empty_string_replacement_map(self): + input = 'foo:bar' + replacments = { + ':': '', + } + replaced = util.replace_win32_incompat(input, replacements=replacments) + self.assertEqual('foobar', replaced) class MakeFilenameTest(PicardTestCase): diff --git a/ui/options_renaming_compat.ui b/ui/options_renaming_compat.ui index fee60325b..af560d478 100644 --- a/ui/options_renaming_compat.ui +++ b/ui/options_renaming_compat.ui @@ -10,7 +10,7 @@ 0 0 453 - 374 + 332 @@ -19,7 +19,7 @@ 0 - + @@ -28,11 +28,31 @@ - - - Windows compatibility - - + + + + + + 0 + 0 + + + + Windows compatibility + + + + + + + false + + + Customize... + + + + @@ -51,6 +71,19 @@ + + + + Qt::Vertical + + + + 20 + 40 + + + + @@ -65,7 +98,6 @@ ascii_filenames - windows_compatibility windows_long_paths @@ -86,5 +118,21 @@ + + windows_compatibility + toggled(bool) + btn_windows_compatibility_change + setEnabled(bool) + + + 178 + 51 + + + 397 + 51 + + + diff --git a/ui/win_compat_dialog.ui b/ui/win_compat_dialog.ui new file mode 100644 index 000000000..fae71d10e --- /dev/null +++ b/ui/win_compat_dialog.ui @@ -0,0 +1,349 @@ + + + WinCompatDialog + + + Qt::WindowModal + + + + 0 + 0 + 285 + 295 + + + + + 1 + 1 + + + + Windows compatibility + + + + QLayout::SetFixedSize + + + + + + + + 75 + true + + + + Character + + + + + + + + 0 + 0 + + + + + 75 + true + + + + Replacement + + + + + + + + Monospace + + + + < + + + + + + + + Monospace + + + + : + + + + + + + + Monospace + + + + > + + + + + + + + 0 + 0 + + + + + 20 + 16777215 + + + + _ + + + 1 + + + + + + + + Monospace + + + + ? + + + + + + + + Monospace + + + + | + + + + + + + + 0 + 0 + + + + + 20 + 16777215 + + + + _ + + + 1 + + + + + + + + 0 + 0 + + + + + 20 + 16777215 + + + + _ + + + 1 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 20 + 16777215 + + + + _ + + + 1 + + + + + + + + Monospace + + + + * + + + + + + + + 0 + 0 + + + + + 20 + 16777215 + + + + _ + + + 1 + + + + + + + + 0 + 0 + + + + + 20 + 16777215 + + + + _ + + + 1 + + + + + + + + Monospace + + + + " + + + + + + + + 0 + 0 + + + + + 20 + 16777215 + + + + _ + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Save + + + + + + + replace_asterisk + replace_colon + replace_lt + replace_gt + replace_questionmark + replace_pipe + replace_quotationmark + + + +