diff options
Diffstat (limited to 'raphodo/preferences.py')
-rw-r--r-- | raphodo/preferences.py | 1030 |
1 files changed, 1030 insertions, 0 deletions
diff --git a/raphodo/preferences.py b/raphodo/preferences.py new file mode 100644 index 0000000..001c147 --- /dev/null +++ b/raphodo/preferences.py @@ -0,0 +1,1030 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2011-2017 Damon Lynch <damonlynch@gmail.com> + +# This file is part of Rapid Photo Downloader. +# +# Rapid Photo Downloader 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 3 of the License, or +# (at your option) any later version. +# +# Rapid Photo Downloader 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 Rapid Photo Downloader. If not, +# see <http://www.gnu.org/licenses/>. + +__author__ = 'Damon Lynch' +__copyright__ = "Copyright 2011-2017, Damon Lynch" + +import logging +import re +import os +import pkg_resources +import datetime +from typing import List, Tuple, Optional + +from PyQt5.QtCore import QSettings, QTime, Qt + +from gettext import gettext as _ + +from raphodo.storage import ( + xdg_photos_directory, xdg_videos_directory, xdg_photos_identifier, xdg_videos_identifier +) +from raphodo.generatenameconfig import * +import raphodo.constants as constants +from raphodo.constants import PresetPrefType +from raphodo.utilities import available_cpu_count, make_internationalized_list +import raphodo.__about__ +from raphodo.rpdfile import ALL_KNOWN_EXTENSIONS + + +class ScanPreferences: + r""" + Handle user preferences while scanning devices like memory cards, + cameras or the filesystem. + + Sets data attribute valid to True if ignored paths are valid. An ignored + path is always assumed to be valid unless regular expressions are used. + If regular expressions are used, then it is valid only if a valid + regular expression can be compiled from each line. + + >>> no_ignored_paths = ScanPreferences([]) + >>> no_ignored_paths.valid + True + + >>> some_paths = ScanPreferences(['.Trash', '.thumbnails']) + >>> some_paths.valid + True + + >>> some_re_paths = ScanPreferences(['.Trash', '\.[tT]humbnails'], True) + >>> some_re_paths.valid + True + + >>> some_more_re_paths = ScanPreferences(['.Trash', '\.[tThumbnails'], True) + >>> some_more_re_paths.valid + False + """ + + def __init__(self, ignored_paths, use_regular_expressions=False): + """ + :type ignored_paths: List[str] + :type use_regular_expressions: bool + """ + + self.ignored_paths = ignored_paths + self.use_regular_expressions = use_regular_expressions + + if ignored_paths and use_regular_expressions: + self.valid = self._check_and_compile_re() + else: + self.re_pattern = None + self.valid = True + + def scan_this_path(self, path: str) -> bool: + """ + Returns true if the path should be included in the scan. + Assumes path is a full path + + :return: True|False + """ + + # see method list_not_empty() in Preferences class to see + # what an "empty" list is: [''] + if not (self.ignored_paths and self.ignored_paths[0]): + return True + + if not self.use_regular_expressions: + return not path.endswith(tuple(self.ignored_paths)) + + return not self.re_pattern.match(path) + + def _check_and_compile_re(self) -> bool: + """ + Take the ignored paths and attempt to compile a regular expression + out of them. Checks line by line. + + :return: True if there were no problems creating the regular + expression pattern + """ + + assert self.use_regular_expressions + + error_encountered = False + pattern = '' + for path in self.ignored_paths: + # check path for validity + try: + re.match(path, '') + pattern += '.*{}s$|'.format(path) + except re.error: + logging.error("Ignoring malformed regular expression: {}".format(path)) + error_encountered = True + + if pattern: + pattern = pattern[:-1] + + try: + self.re_pattern = re.compile(pattern) + except re.error: + logging.error('This regular expression is invalid: {}'.format(pattern)) + self.re_pattern = None + error_encountered = True + + logging.debug("Ignored paths regular expression pattern: {}".format(pattern)) + + return not error_encountered + + +class DownloadsTodayTracker: + """ + Handles tracking the number of successful downloads undertaken + during any one day. + + When a day starts is flexible. See for more details: + http://damonlynch.net/rapid/documentation/#renameoptions + """ + + def __init__(self, downloads_today: List[str], day_start: str) -> None: + """ + + :param downloads_today: list[str,str] containing date and the + number of downloads today e.g. ['2015-08-15', '25'] + :param day_start: the time the day starts, e.g. "03:00" + indicates the day starts at 3 a.m. + """ + self.day_start = day_start + self.downloads_today = downloads_today + + def get_or_reset_downloads_today(self) -> int: + """ + Primary method to get the Downloads Today value, because it + resets the value if no downloads have already occurred on the + day of the download. + :return: the number of successful downloads that have occurred + today + """ + v = self.get_downloads_today() + if v <= 0: + self.reset_downloads_today() + # -1 was returned in the Gtk+ version of Rapid Photo Downloader - + # why? + v = 0 + return v + + def get_downloads_today(self) -> int: + """ + :return the preference value for the number of successful + downloads performed today. If value is less than zero, + the date has changed since the value was last updated. + """ + + hour, minute = self.get_day_start() + try: + adjusted_today = datetime.datetime.strptime( + "%s %s:%s" % (self.downloads_today[0], hour, minute), + "%Y-%m-%d %H:%M") + except: + logging.critical( + "Failed to calculate date adjustment. Download today values " + "appear to be corrupted: %s %s:%s", + self.downloads_today[0], hour, minute) + adjusted_today = None + + now = datetime.datetime.today() + + if adjusted_today is None: + return -1 + + if now < adjusted_today: + try: + return int(self.downloads_today[1]) + except ValueError: + logging.error( + "Invalid Downloads Today value. Resetting value to zero.") + self.reset_downloads_today() + return 0 + else: + return -1 + + def get_day_start(self) -> Tuple[int, int]: + """ + :return: hour and minute components as Tuple of ints + """ + try: + t1, t2 = self.day_start.split(":") + return int(t1), int(t2) + except ValueError: + logging.error( + "'Start of day' preference value %s is corrupted. Resetting " + "to midnight", + self.day_start) + self.day_start = "0:0" + return 0, 0 + + def increment_downloads_today(self) -> bool: + """ + :return: True if day changed + """ + v = self.get_downloads_today() + if v >= 0: + self.set_downloads_today(self.downloads_today[0], v + 1) + return False + else: + self.reset_downloads_today(1) + return True + + def reset_downloads_today(self, value: int=0) -> None: + now = datetime.datetime.today() + hour, minute = self.get_day_start() + t = datetime.time(hour, minute) + if now.time() < t: + date = today() + else: + d = datetime.datetime.today() + datetime.timedelta(days=1) + date = d.strftime(('%Y-%m-%d')) + + self.set_downloads_today(date, value) + + def set_downloads_today(self, date: str, value: int=0) -> None: + self.downloads_today = [date, str(value)] + + def set_day_start(self, hour: int, minute: int) -> None: + self.day_start = "%s:%s" % (hour, minute) + + def log_vals(self) -> None: + logging.info("Date %s Value %s Day start %s", self.downloads_today[0], + self.downloads_today[1], self.day_start) + + +def today(): + return datetime.date.today().strftime('%Y-%m-%d') + + +class Preferences: + """ + Program preferences, being a mix of user facing and non-user facing prefs. + """ + program_defaults = dict(program_version='') + rename_defaults = dict( + photo_download_folder=xdg_photos_directory(), + video_download_folder=xdg_videos_directory(), + photo_subfolder=DEFAULT_SUBFOLDER_PREFS, + video_subfolder=DEFAULT_VIDEO_SUBFOLDER_PREFS, + photo_rename=DEFAULT_PHOTO_RENAME_PREFS, + video_rename=DEFAULT_VIDEO_RENAME_PREFS, + # following two extension values introduced in 0.9.0a4: + photo_extension=LOWERCASE, + video_extension=LOWERCASE, + day_start="03:00", + downloads_today=[today(), '0'], + stored_sequence_no=0, + strip_characters=True, + synchronize_raw_jpg=False, + job_codes=[_('Wedding'), _('Birthday')], + remember_job_code=True, + ignore_mdatatime_for_mtp_dng=True, + ) + + # custom preset prefs are define below in code such as get_preset() + timeline_defaults = dict(proximity_seconds=3600) + + display_defaults = dict( + detailed_time_remaining=False, + warn_downloading_all=True, + warn_backup_problem=True, + warn_broken_or_missing_libraries=True, + warn_fs_metadata_error=True, + warn_unhandled_files=True, + ignore_unhandled_file_exts=['TMP', 'DAT'], + job_code_sort_key=0, + job_code_sort_order=0, + did_you_know_on_startup=True, + did_you_know_index=0, + # see constants.CompletedDownloads: + completed_downloads=3, + consolidate_identical=True, + # see constants.TreatRawJpeg: + treat_raw_jpeg=2, + # see constants.MarkRawJpeg: + mark_raw_jpeg=3, + # introduced in 0.9.6b1: + auto_scroll=True + ) + device_defaults = dict( + only_external_mounts=True, + device_autodetection=True, + this_computer_source = False, + this_computer_path='', + scan_specific_folders=True, + # pre 0.9.3a1 value: device_without_dcim_autodetection=False, is now replaced by + # scan_specific_folders + folders_to_scan=['DCIM', 'PRIVATE', 'MP_ROOT'], + ignored_paths=['.Trash', '.thumbnails'], + use_re_ignored_paths=False, + volume_whitelist=[''], + volume_blacklist=[''], + camera_blacklist=[''], + ) + backup_defaults = dict( + backup_files=False, + backup_device_autodetection=True, + photo_backup_identifier=xdg_photos_identifier(), + video_backup_identifier=xdg_videos_identifier(), + backup_photo_location=os.path.expanduser('~'), + backup_video_location=os.path.expanduser('~'), + ) + automation_defaults = dict( + auto_download_at_startup=False, + auto_download_upon_device_insertion=False, + auto_unmount=False, + auto_exit=False, + auto_exit_force=False, + move=False, + verify_file=False + ) + performance_defaults = dict( + generate_thumbnails=True, + use_thumbnail_cache=True, + save_fdo_thumbnails=True, + max_cpu_cores=max(available_cpu_count(physical_only=True), 2), + keep_thumbnails_days=30 + ) + error_defaults = dict( + conflict_resolution=int(constants.ConflictResolution.skip), + backup_duplicate_overwrite=False + ) + destinations = dict( + photo_backup_destinations=[''], + video_backup_destinations=[''] + ) + version_check = dict( + check_for_new_versions=True, + include_development_release=False, + ignore_versions=[''] + ) + restart_directives = dict( + purge_thumbnails=False, + optimize_thumbnail_db=False + ) + + + def __init__(self) -> None: + # To avoid infinite recursions arising from the use of __setattr__, + # manually assign class values to the class dict + self.__dict__['settings'] = QSettings("Rapid Photo Downloader", "Rapid Photo Downloader") + self.__dict__['valid'] = True + + # These next two values must be kept in sync + dicts = (self.program_defaults, self.rename_defaults, + self.timeline_defaults, self.display_defaults, + self.device_defaults, + self.backup_defaults, self.automation_defaults, + self.performance_defaults, self.error_defaults, + self.destinations, self.version_check, self.restart_directives) + group_names = ('Program', 'Rename', 'Timeline', 'Display', 'Device', 'Backup', + 'Automation', 'Performance', 'ErrorHandling', 'Destinations', + 'VersionCheck', 'RestartDirectives') + assert len(dicts) == len(group_names) + + # Create quick lookup table for types of each value, including the + # special case of lists, which use the type of what they contain. + # While we're at it also merge the dictionaries into one dictionary + # of default values. + self.__dict__['types'] = {} + self.__dict__['defaults'] = {} + for d in dicts: + for key, value in d.items(): + if isinstance(value, list): + t = type(value[0]) + else: + t = type(value) + self.types[key] = t + self.defaults[key] = value + # Create quick lookup table of the group each key is in + self.__dict__['groups'] = {} + for idx, d in enumerate(dicts): + for key in d: + self.groups[key] = group_names[idx] + + def __getitem__(self, key): + group = self.groups.get(key, 'General') + self.settings.beginGroup(group) + v = self.settings.value(key, self.defaults[key], self.types[key]) + self.settings.endGroup() + return v + + def __getattr__(self, key): + return self[key] + + def __setitem__(self, key, value): + group = self.groups.get(key, 'General') + self.settings.beginGroup(group) + self.settings.setValue(key, value) + self.settings.endGroup() + + def __setattr__(self, key, value): + self[key] = value + + def value_is_set(self, key, group: Optional[str]=None) -> bool: + if group is None: + group = 'General' + + group = self.groups.get(key, group) + self.settings.beginGroup(group) + v = self.settings.contains(key) + self.settings.endGroup() + return v + + def sync(self): + self.settings.sync() + + def restore(self, key: str) -> None: + self[key] = self.defaults[key] + + def get_preset(self, preset_type: PresetPrefType) -> Tuple[List[str], + List[List[str]]]: + """ + Returns the custom presets for the particular type. + + :param preset_type: one of photo subfolder, video subfolder, photo + rename, or video rename + :return: Tuple of list of present names and list of pref lists. Each + item in the first list corresponds with the item of the same index in the + second list. + """ + + preset_pref_lists = [] + preset_names = [] + + self.settings.beginGroup('Presets') + + preset = preset_type.name + size = self.settings.beginReadArray(preset) + for i in range(size): + self.settings.setArrayIndex(i) + preset_names.append(self.settings.value('name', type=str)) + preset_pref_lists.append(self.settings.value('pref_list', type=str)) + self.settings.endArray() + + self.settings.endGroup() + + return preset_names, preset_pref_lists + + def set_preset(self, preset_type: PresetPrefType, + preset_names: List[str], + preset_pref_lists: List[str]) -> None: + """ + Saves a list of custom presets in the user's preferences. + + If the list of preset names is empty, the preference value will be cleared. + + :param preset_type: one of photo subfolder, video subfolder, photo + rename, or video rename + :param preset_names: list of names for each pref list + :param preset_pref_lists: the list of pref lists + """ + + self.settings.beginGroup('Presets') + + preset = preset_type.name + + # Clear all the existing presets with that name. + # If we don't do this, when the array shrinks, old values can hang around, + # even though the array size is set correctly. + self.settings.remove(preset) + + self.settings.beginWriteArray(preset) + for i in range(len(preset_names)): + self.settings.setArrayIndex(i) + self.settings.setValue('name', preset_names[i]) + self.settings.setValue('pref_list', preset_pref_lists[i]) + self.settings.endArray() + + self.settings.endGroup() + + def get_proximity(self) -> int: + """ + Validates preference value proxmity_seconds against standard list. + + Given the user could enter any old value into the preferences, need to validate it. + The validation technique is to match whatever value is in the preferences with the + closest value we need, which is found in the list of int proximity_time_steps. + + For the algorithm, see: + http://stackoverflow.com/questions/12141150/from-list-of-integers-get-number-closest-to-a + -given-value + No need to use bisect list, as our list is tiny, and using min has the advantage + of getting the closest value. + + Note: we store the value in seconds, but use it in minutes, just in case a user one day + makes a compelling case to be able to specify a proximity value less than 1 minute. + + :return: closest valid value in minutes + """ + + minutes = self.proximity_seconds // 60 + return min(constants.proximity_time_steps, key=lambda x:abs(x - minutes)) + + def set_proximity(self, minutes: int) -> None: + self.proximity_seconds = minutes * 60 + + def _pref_list_uses_component(self, pref_list, pref_component, offset: int=1) -> bool: + for i in range(0, len(pref_list), 3): + if pref_list[i+offset] == pref_component: + return True + return False + + def any_pref_uses_stored_sequence_no(self) -> bool: + """ + :return True if any of the pref lists contain a stored sequence no + """ + for pref_list in self.get_pref_lists(): + if self._pref_list_uses_component(pref_list, STORED_SEQ_NUMBER): + return True + return False + + def any_pref_uses_session_sequence_no(self) -> bool: + """ + :return True if any of the pref lists contain a session sequence no + """ + for pref_list in self.get_pref_lists(): + if self._pref_list_uses_component(pref_list, SESSION_SEQ_NUMBER): + return True + return False + + def any_pref_uses_sequence_letter_value(self) -> bool: + """ + :return True if any of the pref lists contain a sequence letter + """ + for pref_list in self.get_pref_lists(): + if self._pref_list_uses_component(pref_list, SEQUENCE_LETTER): + return True + return False + + def photo_rename_pref_uses_downloads_today(self) -> bool: + """ + :return: True if the photo rename pref list contains a downloads today + """ + return self._pref_list_uses_component(self.photo_rename, DOWNLOAD_SEQ_NUMBER) + + def video_rename_pref_uses_downloads_today(self) -> bool: + """ + :return: True if the video rename pref list contains a downloads today + """ + return self._pref_list_uses_component(self.video_rename, DOWNLOAD_SEQ_NUMBER) + + def photo_rename_pref_uses_stored_sequence_no(self) -> bool: + """ + :return: True if the photo rename pref list contains a stored sequence no + """ + return self._pref_list_uses_component(self.photo_rename, STORED_SEQ_NUMBER) + + def video_rename_pref_uses_stored_sequence_no(self) -> bool: + """ + :return: True if the video rename pref list contains a stored sequence no + """ + return self._pref_list_uses_component(self.video_rename, STORED_SEQ_NUMBER) + + def check_prefs_for_validity(self) -> Tuple[bool, str]: + """ + Checks photo & video rename, and subfolder generation + preferences ensure they follow name generation rules. Moreover, + subfolder name specifications must not: + 1. start with a separator + 2. end with a separator + 3. have two separators in a row + + :return: tuple with two values: (1) bool and error message if + prefs are invalid (else empy string) + """ + + msg = '' + valid = True + tests = ( + (self.photo_rename, DICT_IMAGE_RENAME_L0), + (self.video_rename, DICT_VIDEO_RENAME_L0), + (self.photo_subfolder, DICT_SUBFOLDER_L0), + (self.video_subfolder, DICT_VIDEO_SUBFOLDER_L0) + ) + + # test file renaming + for pref, pref_defn in tests[:2]: + try: + check_pref_valid(pref_defn, pref) + except PrefError as e: + valid = False + msg += e.msg + "\n" + + # test subfolder generation + for pref, pref_defn in tests[2:]: + try: + check_pref_valid(pref_defn, pref) + + L1s = [pref[i] for i in range(0, len(pref), 3)] + + if L1s[0] == SEPARATOR: + raise PrefValueKeyComboError( + _("Subfolder preferences should not start with a %s") % os.sep + ) + elif L1s[-1] == SEPARATOR: + raise PrefValueKeyComboError( + _("Subfolder preferences should not end with a %s") % os.sep + ) + else: + for i in range(len(L1s) - 1): + if L1s[i] == SEPARATOR and L1s[i + 1] == SEPARATOR: + raise PrefValueKeyComboError( + _( + "Subfolder preferences should not contain two %s one after " + "the other" + ) % os.sep + ) + + except PrefError as e: + valid = False + msg += e.msg + "\n" + + return valid, msg + + def _filter_duplicate_generation_prefs(self, preset_type: PresetPrefType) -> None: + preset_names, preset_pref_lists = self.get_preset(preset_type=preset_type) + seen = set() + filtered_names = [] + filtered_pref_lists = [] + duplicates = [] + for name, pref_list in zip(preset_names, preset_pref_lists): + value = tuple(pref_list) + if value in seen: + duplicates.append(name) + else: + seen.add(value) + filtered_names.append(name) + filtered_pref_lists.append(pref_list) + + if duplicates: + human_readable = preset_type.name[len('preset_'):].replace('_', ' ') + logging.warning( + 'Removed %s duplicate(s) from %s presets: %s', + len(duplicates), human_readable, make_internationalized_list(duplicates) + ) + self.set_preset( + preset_type=preset_type, preset_names=filtered_names, + preset_pref_lists=filtered_pref_lists + ) + + def filter_duplicate_generation_prefs(self) -> None: + """ + Remove any duplicate subfolder generation or file renaming custom presets + """ + + logging.info("Checking for duplicate name generation preference values") + for preset_type in PresetPrefType: + self._filter_duplicate_generation_prefs(preset_type) + + def must_synchronize_raw_jpg(self) -> bool: + """ + :return: True if synchronize_raw_jpg is True and photo + renaming uses sequence values + """ + if self.synchronize_raw_jpg: + for s in LIST_SEQUENCE_L1: + if self._pref_list_uses_component(self.photo_rename, s, 1): + return True + return False + + def format_pref_list_for_pretty_print(self, pref_list) -> str: + """ + :return: string useful for printing the preferences + """ + + v = '' + for i in range(0, len(pref_list), 3): + if (pref_list[i+1] or pref_list[i+2]): + c = ':' + else: + c = '' + s = "%s%s " % (pref_list[i], c) + + if pref_list[i+1]: + s = "%s%s" % (s, pref_list[i+1]) + if pref_list[i+2]: + s = "%s (%s)" % (s, pref_list[i+2]) + v += s + "\n" + return v + + def get_pref_lists(self) -> Tuple[List[str], List[str], List[str], List[str]]: + """ + :return: a tuple of the photo & video rename and subfolder + generation preferences + """ + return self.photo_rename, self.photo_subfolder, self.video_rename, self.video_subfolder + + def get_day_start_qtime(self) -> QTime: + """ + :return: day start time in QTime format, resetting to midnight on value error + """ + try: + h, m = self.day_start.split(":") + h = int(h) + m = int(m) + assert 0 <= h <= 23 + assert 0 <= m <= 59 + return QTime(h, m) + except (ValueError, AssertionError): + logging.error( + "'Start of day' preference value %s is corrupted. Resetting to midnight.", + self.day_start) + self.day_start = "0:0" + return QTime(0, 0) + + def get_checkable_value(self, key: str) -> Qt.CheckState: + """ + Gets a boolean preference value using Qt's CheckState values + :param key: the preference item to get + :return: value converted from bool to an Qt.CheckState enum value + """ + + value = self[key] + if value: + return Qt.Checked + else: + return Qt.Unchecked + + def pref_uses_job_code(self, pref_list: List[str]): + """ Returns True if the particular preferences contains a job code""" + for i in range(0, len(pref_list), 3): + if pref_list[i] == JOB_CODE: + return True + return False + + def any_pref_uses_job_code(self) -> bool: + """ Returns True if any of the preferences contain a job code""" + for pref_list in self.get_pref_lists(): + if self.pref_uses_job_code(pref_list): + return True + return False + + def most_recent_job_code(self, missing: Optional[str]=None) -> str: + """ + Get the most recent Job Code used (which is assumed to be at the top). + :param missing: If there is no Job Code, and return this default value + :return: most recent job code, or missing, or if not found, '' + """ + + if len(self.job_codes) > 0: + value = self.job_codes[0] + return value or missing or '' + elif missing is not None: + return missing + else: + return '' + + def photo_subfolder_index(self, preset_pref_lists: List[List[str]]) -> int: + """ + Matches the photo pref list with program subfolder generation + defaults and the user's presets. + + :param preset_pref_lists: list of custom presets + :return: -1 if no match (i.e. custom), or the index into + PHOTO_SUBFOLDER_MENU_DEFAULTS + photo subfolder presets if it matches + """ + + subfolders = PHOTO_SUBFOLDER_MENU_DEFAULTS_CONV + tuple(preset_pref_lists) + try: + return subfolders.index(self.photo_subfolder) + except ValueError: + return -1 + + def video_subfolder_index(self, preset_pref_lists: List[List[str]]) -> int: + """ + Matches the photo pref list with program subfolder generation + defaults and the user's presets. + + :param preset_pref_lists: list of custom presets + :return: -1 if no match (i.e. custom), or the index into + VIDEO_SUBFOLDER_MENU_DEFAULTS + video subfolder presets if it matches + """ + + subfolders = VIDEO_SUBFOLDER_MENU_DEFAULTS_CONV + tuple(preset_pref_lists) + try: + return subfolders.index(self.video_subfolder) + except ValueError: + return -1 + + def photo_rename_index(self, preset_pref_lists: List[List[str]]) -> int: + """ + Matches the photo pref list with program filename generation + defaults and the user's presets. + + :param preset_pref_lists: list of custom presets + :return: -1 if no match (i.e. custom), or the index into + PHOTO_RENAME_MENU_DEFAULTS_CONV + photo rename presets if it matches + """ + + rename = PHOTO_RENAME_MENU_DEFAULTS_CONV + tuple(preset_pref_lists) + try: + return rename.index(self.photo_rename) + except ValueError: + return -1 + + def video_rename_index(self, preset_pref_lists: List[List[str]]) -> int: + """ + Matches the video pref list with program filename generation + defaults and the user's presets. + + :param preset_pref_lists: list of custom presets + :return: -1 if no match (i.e. custom), or the index into + VIDEO_RENAME_MENU_DEFAULTS_CONV + video rename presets if it matches + """ + + rename = VIDEO_RENAME_MENU_DEFAULTS_CONV + tuple(preset_pref_lists) + try: + return rename.index(self.video_rename) + except ValueError: + return -1 + + def add_list_value(self, key, value, max_list_size=0) -> None: + """ + Add value to pref list if it doesn't already exist. + + Values are added to the start of the list. + + An empty list contains only one item: [''] + + :param key: the preference key + :param value: the value to add + :param max_list_size: if non-zero, the list's last value will be deleted + """ + + if len(self[key]) == 1 and self[key][0] == '': + self[key] = [value] + elif value not in self[key]: + # Must assign the value like this, otherwise the preference value + # will not be updated: + if max_list_size: + self[key] = [value] + self[key][:max_list_size - 1] + else: + self[key] = [value] + self[key] + + def del_list_value(self, key:str, value) -> None: + """ + Remove a value from the pref list indicated by key. + + Exceptions are not caught. + + An empty list contains only one item: [''] + + :param key: the preference key + :param value: the value to add + """ + + # Must remove the value like this, otherwise the preference value + # will not be updated: + l = self[key] + l.remove(value) + self[key] = l + + if len(self[key]) == 0: + self[key] = [''] + + def list_not_empty(self, key: str) -> bool: + """ + In our pref schema, an empty list is [''], not [] + + :param key: the preference value to examine + :return: True if the pref list is not empty + """ + + return bool(self[key] and self[key][0]) + + def reset(self) -> None: + """ + Reset all program preferences to their default settings + """ + self.settings.clear() + self.program_version = raphodo.__about__.__version__ + + def upgrade_prefs(self, previous_version) -> None: + """ + Upgrade the user's preferences if needed. + + :param previous_version: previous version as returned by pkg_resources.parse_version + """ + + photo_video_rename_change = pkg_resources.parse_version('0.9.0a4') + if previous_version < photo_video_rename_change: + for key in ('photo_rename', 'video_rename'): + pref_list, case = upgrade_pre090a4_rename_pref(self[key]) + if pref_list != self[key]: + self[key] = pref_list + logging.info("Upgraded %s preference value", key.replace('_', ' ')) + if case is not None: + if key == 'photo_rename': + self.photo_extension = case + else: + self.video_extension = case + + v090a5 = pkg_resources.parse_version('0.9.0a5') + if previous_version < v090a5: + # Versions prior to 0.9.0a5 incorrectly set the conflict resolution value + # when importing preferences from 0.4.11 or earlier + try: + value = self.conflict_resolution + except TypeError: + self.settings.endGroup() + default = self.defaults['conflict_resolution'] + default_name = constants.ConflictResolution(default).name + logging.warning('Resetting Conflict Resolution preference value to %s', + default_name) + self.conflict_resolution = default + # destinationButtonPressed is no longer used by 0.9.0a5 + self.settings.beginGroup("MainWindow") + key = 'destinationButtonPressed' + try: + if self.settings.contains(key): + logging.debug("Removing preference value %s", key) + self.settings.remove(key) + except: + logging.warning("Unknown error removing %s preference value", key) + self.settings.endGroup() + + v090b6 = pkg_resources.parse_version('0.9.0b6') + key = 'warn_broken_or_missing_libraries' + group = 'Display' + if previous_version < v090b6 and not self.value_is_set(key, group): + # Versions prior to 0.9.0b6 may have a preference value 'warn_no_libmediainfo' + # which is now renamed to 'broken_or_missing_libraries' + if self.value_is_set('warn_no_libmediainfo', group): + self.settings.beginGroup(group) + v = self.settings.value('warn_no_libmediainfo', True, type(True)) + self.settings.remove('warn_no_libmediainfo') + self.settings.endGroup() + logging.debug( + "Transferring preference value %s for warn_no_libmediainfo to " + "warn_broken_or_missing_libraries", v + ) + self.warn_broken_or_missing_libraries = v + else: + logging.debug( + "Not transferring preference value warn_no_libmediainfo to " + "warn_broken_or_missing_libraries because it doesn't exist" + ) + + v093a1 = pkg_resources.parse_version('0.9.3a1') + key = 'scan_specific_folders' + group = 'Device' + if previous_version < v093a1 and not self.value_is_set(key, group): + # Versions prior to 0.9.3a1 used a preference value to indicate if + # devices lacking a DCIM folder should be scanned. It is now renamed + # to 'scan_specific_folders' + if self.value_is_set('device_without_dcim_autodetection'): + self.settings.beginGroup(group) + v = self.settings.value('device_without_dcim_autodetection', True, type(True)) + self.settings.remove('device_without_dcim_autodetection') + self.settings.endGroup() + self.settings.endGroup() + logging.debug( + "Transferring preference value %s for device_without_dcim_autodetection to " + "scan_specific_folders as %s", v, not v + ) + self.scan_specific_folders = not v + else: + logging.debug( + "Not transferring preference value device_without_dcim_autodetection to " + "scan_specific_folders because it doesn't exist" + ) + + + def validate_max_CPU_cores(self) -> None: + logging.debug('Validating CPU core count for thumbnail generation...') + available = available_cpu_count(physical_only=True) + logging.debug('...%s physical cores detected', available) + if self.max_cpu_cores > available: + logging.info('Setting CPU Cores for thumbnail generation to %s', available) + self.max_cpu_cores = available + + def validate_ignore_unhandled_file_exts(self) -> None: + # logging.debug('Validating list of file extension to not warn about...') + self.ignore_unhandled_file_exts = [ext.upper() for ext in self.ignore_unhandled_file_exts + if ext.lower() not in ALL_KNOWN_EXTENSIONS] + + def warn_about_unknown_file(self, ext: str) -> bool: + if not self.warn_unhandled_files: + return False + + if not self.ignore_unhandled_file_exts[0]: + return True + + return ext.upper() not in self.ignore_unhandled_file_exts + + +def match_pref_list(pref_lists: List[List[str]], user_pref_list: List[str]) -> int: + try: + return pref_lists.index(user_pref_list) + except ValueError: + return -1 |