Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PICARD-9: Add support for user profiles #1851

Merged
merged 31 commits into from
Jun 28, 2021
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
405ad2a
Create subclass of ConfigSection to support user profiles
rdswift Jun 20, 2021
eba6306
Refactor. Correct problem getting value from a profile.
rdswift Jun 21, 2021
f30ea31
Change variable and method names for clarification and add docstring
rdswift Jun 21, 2021
21c7804
Refactor and use sets to combine settings
rdswift Jun 21, 2021
ea4fec1
Store profile list and profile settings separately
rdswift Jun 22, 2021
e9a90cf
Clean up saving profile settings updates. Reduce duplication.
rdswift Jun 23, 2021
16e6a94
Make keys into class constants
rdswift Jun 24, 2021
583058e
Add user profile editor dialog (WIP)
rdswift Jun 21, 2021
20ca5ec
Add tests
rdswift Jun 24, 2021
7284904
Retain selections tree expanded state between selected profiles.
rdswift Jun 24, 2021
2ea04a4
Move profiles to `config.profiles`. Need to resolve failing tests.
rdswift Jun 24, 2021
68b39ec
Use `self.__qt_config` when checking profiles and profile settings.
rdswift Jun 25, 2021
3eee2ec
Make `rename_option()` also update option name in profile settings
rdswift Jun 25, 2021
ec176d5
Fix KeyError and add tests
rdswift Jun 25, 2021
0ae6188
Use class method for initializing profile options
rdswift Jun 25, 2021
9d505e1
Use keys defined in SettingConfigSection and remove redundant Options
rdswift Jun 25, 2021
4ff1310
Remove unused (commented out) code
rdswift Jun 25, 2021
f6ef7c4
Remove unused profile switching system
rdswift Jun 25, 2021
abc3f38
Add selected file naming script as profile option
rdswift Jun 25, 2021
150eb40
Save editor settings on dialog close.
rdswift Jun 25, 2021
5af5024
Fix crash after deleting the last profile in the list
rdswift Jun 25, 2021
737c746
Convert profile settings to named tuple
zas Jun 26, 2021
7c1a508
Merge pull request #9 from zas/namedtuplesetting
rdswift Jun 26, 2021
c046c0a
Miscellaneous style cleanup items
rdswift Jun 26, 2021
e01b978
Correct the url to the help page
rdswift Jun 26, 2021
4f10a47
Update settings for id directly
rdswift Jun 27, 2021
8c967ef
Add keyboard shortcut to MainWindow
rdswift Jun 28, 2021
d9934ad
Revise and rearrange buttons. Auto-save changes within dialog.
rdswift Jun 28, 2021
85ce4b1
Rename `ProfileEditorPage` to `ProfileEditorDialog`
rdswift Jun 28, 2021
22d91a4
Add comment explaining why `deepcopy()` is required.
rdswift Jun 28, 2021
673b7a7
Fix crash when deleting the last profile from the list.
rdswift Jun 28, 2021
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
83 changes: 71 additions & 12 deletions picard/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# Copyright (C) 2017 Sophist-UK
# Copyright (C) 2018 Vishal Choudhary
# Copyright (C) 2020-2021 Gabriel Ferreira
# Copyright (C) 2021 Bob Swift
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
Expand Down Expand Up @@ -45,6 +46,7 @@
PICARD_VERSION,
log,
)
from picard.profile import UserProfileGroups
from picard.version import Version


Expand Down Expand Up @@ -129,6 +131,70 @@ def value(self, name, option_type, default=None):
return default


class SettingConfigSection(ConfigSection):
"""Custom subclass to automatically accommodate saving and retrieving values based on user profile settings.
"""
PROFILES_KEY = 'user_profiles'
SETTINGS_KEY = 'user_profile_settings'

@classmethod
def init_profile_options(cls):
ListOption.add_if_missing("profiles", cls.PROFILES_KEY, [])
Option.add_if_missing("profiles", cls.SETTINGS_KEY, {})

def __init__(self, config, name):
super().__init__(config, name)
self.__qt_config = config
self.__name = name
self.__prefix = self.__name + '/'
self._memoization = defaultdict(Memovar)
self.init_profile_options()

def _get_active_profile_ids(self):
profiles = self.__qt_config.profiles[self.PROFILES_KEY]
if profiles is None:
return
for profile in profiles:
if profile['enabled']:
yield profile["id"]

def _get_active_profile_settings(self):
for id in self._get_active_profile_ids():
yield id, self._get_profile_settings(id)

def _get_profile_settings(self, id):
profile_settings = self.__qt_config.profiles[self.SETTINGS_KEY][id]
if profile_settings is None:
log.error("Unable to find settings for user profile '%s'", id)
return {}
return profile_settings

def __getitem__(self, name):
# Don't process settings that are not profile-specific
if name in UserProfileGroups.get_all_settings_list():
for id, settings in self._get_active_profile_settings():
if name in settings and settings[name] is not None:
return settings[name]
opt = Option.get(self.__name, name)
if opt is None:
return None
return self.value(name, opt, opt.default)

def __setitem__(self, name, value):
# Don't process settings that are not profile-specific
if name in UserProfileGroups.get_all_settings_list():
for id, settings in self._get_active_profile_settings():
if name in settings:
settings[name] = value
all_settings = self.__qt_config.profiles[self.SETTINGS_KEY]
all_settings[id] = settings
self.__qt_config.profiles[self.SETTINGS_KEY] = all_settings
rdswift marked this conversation as resolved.
Show resolved Hide resolved
return
key = self.key(name)
self.__qt_config.setValue(key, value)
self._memoization[key].dirty = True


class Config(QtCore.QSettings):

"""Configuration.
Expand All @@ -153,10 +219,9 @@ def __initialize(self):

self.setAtomicSyncRequired(False) # See comment in event()
self.application = ConfigSection(self, "application")
self.setting = ConfigSection(self, "setting")
self.profiles = ConfigSection(self, "profiles")
self.setting = SettingConfigSection(self, "setting")
self.persist = ConfigSection(self, "persist")
self.profile = ConfigSection(self, "profile/default")
self.current_preset = "default"

TextOption("application", "version", '0.0.0dev0')
self._version = Version.from_string(self.application["version"])
Expand Down Expand Up @@ -222,14 +287,6 @@ def from_file(cls, parent, filename):
this.__initialize()
return this

def switchProfile(self, profilename):
"""Sets the current profile."""
key = "profile/%s" % (profilename,)
if self.contains(key):
self.profile.name = key
else:
raise KeyError("Unknown profile '%s'" % (profilename,))

def register_upgrade_hook(self, func, *args):
"""Register a function to upgrade from one config version to another"""
to_version = Version.from_string(func.__name__)
Expand Down Expand Up @@ -384,16 +441,18 @@ class ListOption(Option):
config = None
setting = None
persist = None
profiles = None


def setup_config(app, filename=None):
global config, setting, persist
global config, setting, persist, profiles
if filename is None:
config = Config.from_app(app)
else:
config = Config.from_file(app, filename)
setting = config.setting
persist = config.persist
profiles = config.profiles


def get_config():
Expand Down
10 changes: 10 additions & 0 deletions picard/config_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,16 @@ def rename_option(config, old_opt, new_opt, option_type, default):
_s[new_opt] = _s.value(old_opt, option_type, default)
_s.remove(old_opt)

_p = config.profiles
_s.init_profile_options()
all_settings = _p["user_profile_settings"]
for profile in _p["user_profiles"]:
id = profile["id"]
if id in all_settings and old_opt in all_settings[id]:
all_settings[id][new_opt] = all_settings[id][old_opt]
all_settings[id].pop(old_opt)
_p["user_profile_settings"] = all_settings


def upgrade_config(config):
cfg = config
Expand Down
3 changes: 3 additions & 0 deletions picard/const/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
'doc_scripting': DOCS_BASE_URL + '/extending/scripting.html',
'doc_tags_from_filenames': DOCS_BASE_URL + '/usage/tags_from_file_names.html',
'doc_naming_script_edit': DOCS_BASE_URL + '/config/options_filerenaming_editor.html',
'doc_profile_edit': DOCS_BASE_URL + '/config/usage/user_profiles.html',
'doc_cover_art_types': "https://musicbrainz.org/doc/Cover_Art/Types",
'plugins': "https://picard.musicbrainz.org/plugins/",
'forum': "https://community.metabrainz.org/c/picard",
Expand Down Expand Up @@ -187,5 +188,7 @@
DEFAULT_NUMBERED_SCRIPT_NAME = N_("My script %d")
DEFAULT_SCRIPT_NAME = N_("My script")
DEFAULT_COVER_IMAGE_FILENAME = "cover"
DEFAULT_NUMBERED_PROFILE_NAME = N_("My profile %d")
DEFAULT_PROFILE_NAME = N_("My profile")

SCRIPT_LANGUAGE_VERSION = '1.1'
137 changes: 137 additions & 0 deletions picard/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2021 Bob Swift
#
# 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 (
OrderedDict,
namedtuple,
)

# Imported to trigger inclusion of N_() in builtins
from picard import i18n # noqa: F401,E402 # pylint: disable=unused-import


SettingDesc = namedtuple('SettingDesc', ('name', 'title'))


class UserProfileGroups():
"""Provides information about the profile groups available for selecting in a user profile,
and the title and settings that apply to each profile group.
"""
SETTINGS_GROUPS = OrderedDict() # Add groups in the order they should be displayed

# Each item in "settings" is a tuple of the setting key and the display title
SETTINGS_GROUPS["metadata"] = {
"title": N_("Metadata"),
"settings": [
SettingDesc("va_name", N_("Various Artists name")),
SettingDesc("nat_name", N_("Non-album tracks name")),
SettingDesc("translate_artist_names", N_("Translate artist names")),
SettingDesc("artist_locale", N_("Translation locale")),
SettingDesc("release_ars", N_("Use release relationships")),
SettingDesc("track_ars", N_("Use track relationships")),
SettingDesc("convert_punctuation", N_("Convert Unicode to ASCII")),
SettingDesc("standardize_artists", N_("Standardize artist names")),
SettingDesc("standardize_instruments", N_("Standardize instrument names")),
SettingDesc("guess_tracknumber_and_title", N_("Guess track number and title")),
],
}

SETTINGS_GROUPS["tags"] = {
"title": N_("Tags"),
"settings": [
SettingDesc("dont_write_tags", N_("Don't write tags")),
SettingDesc("preserve_timestamps", N_("Preserve timestamps")),
SettingDesc("clear_existing_tags", N_("Clear existing tags")),
SettingDesc("preserve_images", N_("Preserve images")),
SettingDesc("remove_id3_from_flac", N_("Remove ID3 from FLAC")),
SettingDesc("remove_ape_from_mp3", N_("Remove APE from MP3")),
SettingDesc("preserved_tags", N_("Preserved tags list")),
SettingDesc("aac_save_ape", N_("Save APEv2 to AAC")),
SettingDesc("remove_ape_from_aac", N_("Remove APE from AAC")),
SettingDesc("ac3_save_ape", N_("Save APEv2 to AC3")),
SettingDesc("remove_ape_from_ac3", N_("Remove APE from AC3")),
SettingDesc("write_id3v1", N_("Write ID3v1 tags")),
SettingDesc("write_id3v23", N_("Write ID3v2.3 tags")),
SettingDesc("id3v2_encoding", N_("ID3v2.3 Text Encoding")),
SettingDesc("id3v23_join_with", N_("ID3v2.3 join character")),
SettingDesc("itunes_compatible_grouping", N_("iTunes compatible grouping")),
SettingDesc("write_wave_riff_info", N_("Write WAVE RIFF info")),
SettingDesc("remove_wave_riff_info", N_("Remove WAVE RIFF info")),
SettingDesc("wave_riff_info_encoding", N_("RIFF text encoding")),
],
}

SETTINGS_GROUPS["coverart"] = {
"title": N_("Cover Art"),
"settings": [
SettingDesc("save_images_to_tags", N_("Save images to tags")),
SettingDesc("embed_only_one_front_image", N_("Embed only one front image")),
SettingDesc("save_images_to_files", N_("Save images to files")),
SettingDesc("cover_image_filename", N_("File name for images")),
SettingDesc("save_images_overwrite", N_("Overwrite existing image files")),
SettingDesc("save_only_one_front_image", N_("Save only one front image")),
SettingDesc("image_type_as_filename", N_("Image type as file name")),
SettingDesc("ca_providers", N_("Cover art providers")),
],
}

SETTINGS_GROUPS["filenaming"] = {
"title": N_("File Naming"),
"settings": [
SettingDesc("windows_compatibility", N_("Windows compatibility")),
SettingDesc("ascii_filenames", N_("Replace non-ASCII characters")),
SettingDesc("rename_files", N_("Rename files")),
SettingDesc("move_files", N_("Move files")),
SettingDesc("move_files_to", N_("Destination directory")),
SettingDesc("move_additional_files", N_("Move additional files")),
SettingDesc("move_additional_files_pattern", N_("Additional file patterns")),
SettingDesc("delete_empty_dirs", N_("Delete empty directories")),
SettingDesc("selected_file_naming_script_id", N_("Selected file naming script")),
],
}

SETTINGS_GROUPS["scripting"] = {
"title": N_("Scripting"),
"settings": [
SettingDesc("enable_tagger_scripts", N_("Enable tagger scripts")),
SettingDesc("list_of_scripts", N_("Tagger scripts")),
],
}

@classmethod
def get_all_settings_list(cls):
"""Iterable of all settings names in all setting groups.

Yields:
str: Setting name
"""
settings = set()
for settings_group in cls.SETTINGS_GROUPS.values():
settings |= set(x.name for x in settings_group["settings"])
return settings

@classmethod
def get_setting_groups_list(cls):
"""Iterable of all setting groups keys.

Yields:
str: Key
"""
yield from cls.SETTINGS_GROUPS
9 changes: 9 additions & 0 deletions picard/ui/mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
PasswordDialog,
ProxyDialog,
)
from picard.ui.profileeditor import ProfileEditorDialog
from picard.ui.scripteditor import (
ScriptEditorDialog,
ScriptEditorExamples,
Expand Down Expand Up @@ -488,6 +489,9 @@ def create_actions(self):
self.show_script_editor_action = QtWidgets.QAction(_("Open &file naming script editor..."))
self.show_script_editor_action.triggered.connect(self.open_file_naming_script_editor)

self.show_profile_editor_action = QtWidgets.QAction(_("Open &user profile editor..."))
self.show_profile_editor_action.triggered.connect(self.open_profile_editor)

self.cut_action = QtWidgets.QAction(icontheme.lookup('edit-cut', icontheme.ICON_SIZE_MENU), _("&Cut"), self)
self.cut_action.setShortcut(QtGui.QKeySequence.Cut)
self.cut_action.setEnabled(False)
Expand Down Expand Up @@ -838,6 +842,8 @@ def create_menus(self):
menu.addSeparator()
menu.addAction(self.show_script_editor_action)
menu.addSeparator()
menu.addAction(self.show_profile_editor_action)
menu.addSeparator()
menu.addAction(self.options_action)
menu = self.menuBar().addMenu(_("&Tools"))
menu.addAction(self.refresh_action)
Expand Down Expand Up @@ -1618,6 +1624,9 @@ def update_scripts_list_from_editor(self):
"""
self.script_editor_save()

def open_profile_editor(self):
return ProfileEditorDialog.show_instance(self)


def update_last_check_date(is_success):
if is_success:
Expand Down
Loading