1#!/usr/bin/env python3 2 3# Copyright (C) 2011-2020 Damon Lynch <damonlynch@gmail.com> 4 5# This file is part of Rapid Photo Downloader. 6# 7# Rapid Photo Downloader is free software: you can redistribute it and/or 8# modify it under the terms of the GNU General Public License as published by 9# the Free Software Foundation, either version 3 of the License, or 10# (at your option) any later version. 11# 12# Rapid Photo Downloader is distributed in the hope that it will be useful, 13# but WITHOUT ANY WARRANTY; without even the implied warranty of 14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15# GNU General Public License for more details. 16# 17# You should have received a copy of the GNU General Public License 18# along with Rapid Photo Downloader. If not, 19# see <http://www.gnu.org/licenses/>. 20 21__author__ = 'Damon Lynch' 22__copyright__ = "Copyright 2011-2020, Damon Lynch" 23 24import logging 25import re 26import os 27import pkg_resources 28import datetime 29from typing import List, Tuple, Optional 30 31from PyQt5.QtCore import QSettings, QTime, Qt 32 33 34from raphodo.storage import ( 35 xdg_photos_directory, xdg_videos_directory, xdg_photos_identifier, xdg_videos_identifier 36) 37from raphodo.generatenameconfig import * 38import raphodo.constants as constants 39from raphodo.constants import PresetPrefType, FileType 40from raphodo.utilities import available_cpu_count, make_internationalized_list 41import raphodo.__about__ 42from raphodo.fileformats import ALL_KNOWN_EXTENSIONS 43 44 45class ScanPreferences: 46 r""" 47 Handle user preferences while scanning devices like memory cards, 48 cameras or the filesystem. 49 50 Sets data attribute valid to True if ignored paths are valid. An ignored 51 path is always assumed to be valid unless regular expressions are used. 52 If regular expressions are used, then it is valid only if a valid 53 regular expression can be compiled from each line. 54 55 >>> no_ignored_paths = ScanPreferences([]) 56 >>> no_ignored_paths.valid 57 True 58 59 >>> some_paths = ScanPreferences(['.Trash', '.thumbnails']) 60 >>> some_paths.valid 61 True 62 63 >>> some_re_paths = ScanPreferences(['.Trash', '\.[tT]humbnails'], True) 64 >>> some_re_paths.valid 65 True 66 67 >>> some_more_re_paths = ScanPreferences(['.Trash', '\.[tThumbnails'], True) 68 >>> some_more_re_paths.valid 69 False 70 """ 71 72 def __init__(self, ignored_paths, use_regular_expressions=False): 73 """ 74 :type ignored_paths: List[str] 75 :type use_regular_expressions: bool 76 """ 77 78 self.ignored_paths = ignored_paths 79 self.use_regular_expressions = use_regular_expressions 80 81 if ignored_paths and use_regular_expressions: 82 self.valid = self._check_and_compile_re() 83 else: 84 self.re_pattern = None 85 self.valid = True 86 87 def scan_this_path(self, path: str) -> bool: 88 """ 89 Returns true if the path should be included in the scan. 90 Assumes path is a full path 91 92 :return: True|False 93 """ 94 95 # see method list_not_empty() in Preferences class to see 96 # what an "empty" list is: [''] 97 if not (self.ignored_paths and self.ignored_paths[0]): 98 return True 99 100 if not self.use_regular_expressions: 101 return not path.endswith(tuple(self.ignored_paths)) 102 103 return not self.re_pattern.match(path) 104 105 def _check_and_compile_re(self) -> bool: 106 """ 107 Take the ignored paths and attempt to compile a regular expression 108 out of them. Checks line by line. 109 110 :return: True if there were no problems creating the regular 111 expression pattern 112 """ 113 114 assert self.use_regular_expressions 115 116 error_encountered = False 117 pattern = '' 118 for path in self.ignored_paths: 119 # check path for validity 120 try: 121 re.match(path, '') 122 pattern += '.*{}s$|'.format(path) 123 except re.error: 124 logging.error("Ignoring malformed regular expression: {}".format(path)) 125 error_encountered = True 126 127 if pattern: 128 pattern = pattern[:-1] 129 130 try: 131 self.re_pattern = re.compile(pattern) 132 except re.error: 133 logging.error('This regular expression is invalid: {}'.format(pattern)) 134 self.re_pattern = None 135 error_encountered = True 136 137 logging.debug("Ignored paths regular expression pattern: {}".format(pattern)) 138 139 return not error_encountered 140 141 142class DownloadsTodayTracker: 143 """ 144 Handles tracking the number of successful downloads undertaken 145 during any one day. 146 147 When a day starts is flexible. See for more details: 148 http://damonlynch.net/rapid/documentation/#renameoptions 149 """ 150 151 def __init__(self, downloads_today: List[str], day_start: str) -> None: 152 """ 153 154 :param downloads_today: list[str,str] containing date and the 155 number of downloads today e.g. ['2015-08-15', '25'] 156 :param day_start: the time the day starts, e.g. "03:00" 157 indicates the day starts at 3 a.m. 158 """ 159 self.day_start = day_start 160 self.downloads_today = downloads_today 161 162 def get_or_reset_downloads_today(self) -> int: 163 """ 164 Primary method to get the Downloads Today value, because it 165 resets the value if no downloads have already occurred on the 166 day of the download. 167 :return: the number of successful downloads that have occurred 168 today 169 """ 170 v = self.get_downloads_today() 171 if v <= 0: 172 self.reset_downloads_today() 173 # -1 was returned in the Gtk+ version of Rapid Photo Downloader - 174 # why? 175 v = 0 176 return v 177 178 def get_downloads_today(self) -> int: 179 """ 180 :return the preference value for the number of successful 181 downloads performed today. If value is less than zero, 182 the date has changed since the value was last updated. 183 """ 184 185 hour, minute = self.get_day_start() 186 try: 187 adjusted_today = datetime.datetime.strptime( 188 "%s %s:%s" % (self.downloads_today[0], hour, minute), 189 "%Y-%m-%d %H:%M" 190 ) 191 except: 192 logging.critical( 193 "Failed to calculate date adjustment. Download today values " 194 "appear to be corrupted: %s %s:%s", 195 self.downloads_today[0], hour, minute 196 ) 197 adjusted_today = None 198 199 now = datetime.datetime.today() 200 201 if adjusted_today is None: 202 return -1 203 204 if now < adjusted_today: 205 try: 206 return int(self.downloads_today[1]) 207 except ValueError: 208 logging.error("Invalid Downloads Today value. Resetting value to zero.") 209 self.reset_downloads_today() 210 return 0 211 else: 212 return -1 213 214 def get_day_start(self) -> Tuple[int, int]: 215 """ 216 :return: hour and minute components as Tuple of ints 217 """ 218 try: 219 t1, t2 = self.day_start.split(":") 220 return int(t1), int(t2) 221 except ValueError: 222 logging.error( 223 "'Start of day' preference value %s is corrupted. Resetting to midnight", 224 self.day_start 225 ) 226 self.day_start = "0:0" 227 return 0, 0 228 229 def increment_downloads_today(self) -> bool: 230 """ 231 :return: True if day changed 232 """ 233 v = self.get_downloads_today() 234 if v >= 0: 235 self.set_downloads_today(self.downloads_today[0], v + 1) 236 return False 237 else: 238 self.reset_downloads_today(1) 239 return True 240 241 def reset_downloads_today(self, value: int=0) -> None: 242 now = datetime.datetime.today() 243 hour, minute = self.get_day_start() 244 t = datetime.time(hour, minute) 245 if now.time() < t: 246 date = today() 247 else: 248 d = datetime.datetime.today() + datetime.timedelta(days=1) 249 date = d.strftime(('%Y-%m-%d')) 250 251 self.set_downloads_today(date, value) 252 253 def set_downloads_today(self, date: str, value: int=0) -> None: 254 self.downloads_today = [date, str(value)] 255 256 def set_day_start(self, hour: int, minute: int) -> None: 257 self.day_start = "%s:%s" % (hour, minute) 258 259 def log_vals(self) -> None: 260 logging.info( 261 "Date %s Value %s Day start %s", 262 self.downloads_today[0], self.downloads_today[1], self.day_start 263 ) 264 265 266def today(): 267 return datetime.date.today().strftime('%Y-%m-%d') 268 269 270class Preferences: 271 """ 272 Program preferences, being a mix of user facing and non-user facing prefs. 273 """ 274 275 program_defaults = dict(program_version='') 276 rename_defaults = dict( 277 photo_download_folder=xdg_photos_directory(), 278 video_download_folder=xdg_videos_directory(), 279 photo_subfolder=DEFAULT_SUBFOLDER_PREFS, 280 video_subfolder=DEFAULT_VIDEO_SUBFOLDER_PREFS, 281 photo_rename=DEFAULT_PHOTO_RENAME_PREFS, 282 video_rename=DEFAULT_VIDEO_RENAME_PREFS, 283 # following two extension values introduced in 0.9.0a4: 284 photo_extension=LOWERCASE, 285 video_extension=LOWERCASE, 286 day_start="03:00", 287 downloads_today=[today(), '0'], 288 stored_sequence_no=0, 289 strip_characters=True, 290 synchronize_raw_jpg=False, 291 job_codes=[_('Wedding'), _('Birthday')], 292 remember_job_code=True, 293 ignore_mdatatime_for_mtp_dng=True, 294 ) 295 296 # custom preset prefs are define below in code such as get_preset() 297 timeline_defaults = dict(proximity_seconds=3600) 298 299 display_defaults = dict( 300 detailed_time_remaining=False, 301 warn_downloading_all=True, 302 warn_backup_problem=True, 303 warn_broken_or_missing_libraries=True, 304 warn_fs_metadata_error=True, 305 warn_unhandled_files=True, 306 ignore_unhandled_file_exts=['TMP', 'DAT'], 307 job_code_sort_key=0, 308 job_code_sort_order=0, 309 did_you_know_on_startup=True, 310 did_you_know_index=0, 311 # see constants.CompletedDownloads: 312 completed_downloads=3, 313 consolidate_identical=True, 314 # see constants.TreatRawJpeg: 315 treat_raw_jpeg=2, 316 # see constants.MarkRawJpeg: 317 mark_raw_jpeg=3, 318 # introduced in 0.9.6b1: 319 auto_scroll=True, 320 # If you change the language setting update it in __init__.py too, where it is 321 # read directly without using this class. 322 language='', 323 ) 324 device_defaults = dict( 325 only_external_mounts=True, 326 device_autodetection=True, 327 this_computer_source = False, 328 this_computer_path='', 329 scan_specific_folders=True, 330 # pre 0.9.3a1 value: device_without_dcim_autodetection=False, is now replaced by 331 # scan_specific_folders 332 folders_to_scan=['DCIM', 'PRIVATE', 'MP_ROOT'], 333 ignored_paths=['.Trash', '.thumbnails', 'THMBNL', '__MACOSX'], 334 use_re_ignored_paths=False, 335 volume_whitelist=[''], 336 volume_blacklist=[''], 337 camera_blacklist=[''], 338 ) 339 backup_defaults = dict( 340 backup_files=False, 341 backup_device_autodetection=True, 342 photo_backup_identifier=xdg_photos_identifier(), 343 video_backup_identifier=xdg_videos_identifier(), 344 backup_photo_location=os.path.expanduser('~'), 345 backup_video_location=os.path.expanduser('~'), 346 ) 347 automation_defaults = dict( 348 auto_download_at_startup=False, 349 auto_download_upon_device_insertion=False, 350 auto_unmount=False, 351 auto_exit=False, 352 auto_exit_force=False, 353 move=False, 354 verify_file=False 355 ) 356 performance_defaults = dict( 357 generate_thumbnails=True, 358 use_thumbnail_cache=True, 359 save_fdo_thumbnails=True, 360 max_cpu_cores=max(available_cpu_count(physical_only=True), 2), 361 keep_thumbnails_days=30 362 ) 363 error_defaults = dict( 364 conflict_resolution=int(constants.ConflictResolution.skip), 365 backup_duplicate_overwrite=False, 366 ) 367 destinations = dict( 368 photo_backup_destinations=[''], 369 video_backup_destinations=[''] 370 ) 371 version_check = dict( 372 check_for_new_versions=True, 373 include_development_release=False, 374 ignore_versions=[''] 375 ) 376 restart_directives = dict( 377 purge_thumbnails=False, 378 optimize_thumbnail_db=False 379 ) 380 metadata_defaults = dict( 381 force_exiftool=False, 382 ) 383 384 def __init__(self) -> None: 385 # To avoid infinite recursions arising from the use of __setattr__, 386 # manually assign class values to the class dict 387 self.__dict__['settings'] = QSettings("Rapid Photo Downloader", "Rapid Photo Downloader") 388 self.__dict__['valid'] = True 389 390 # These next two values must be kept in sync 391 dicts = ( 392 self.program_defaults, self.rename_defaults, 393 self.timeline_defaults, self.display_defaults, 394 self.device_defaults, 395 self.backup_defaults, self.automation_defaults, 396 self.performance_defaults, self.error_defaults, 397 self.destinations, self.version_check, self.restart_directives, 398 self.metadata_defaults, 399 ) 400 group_names = ( 401 'Program', 'Rename', 'Timeline', 'Display', 'Device', 'Backup', 402 'Automation', 'Performance', 'ErrorHandling', 'Destinations', 403 'VersionCheck', 'RestartDirectives', 'Metadata' 404 ) 405 assert len(dicts) == len(group_names) 406 407 # Create quick lookup table for types of each value, including the 408 # special case of lists, which use the type of what they contain. 409 # While we're at it also merge the dictionaries into one dictionary 410 # of default values. 411 self.__dict__['types'] = {} 412 self.__dict__['defaults'] = {} 413 for d in dicts: 414 for key, value in d.items(): 415 if isinstance(value, list): 416 t = type(value[0]) 417 else: 418 t = type(value) 419 self.types[key] = t 420 self.defaults[key] = value 421 # Create quick lookup table of the group each key is in 422 self.__dict__['groups'] = {} 423 for idx, d in enumerate(dicts): 424 for key in d: 425 self.groups[key] = group_names[idx] 426 427 def __getitem__(self, key): 428 group = self.groups.get(key, 'General') 429 self.settings.beginGroup(group) 430 v = self.settings.value(key, self.defaults[key], self.types[key]) 431 self.settings.endGroup() 432 return v 433 434 def __getattr__(self, key): 435 return self[key] 436 437 def __setitem__(self, key, value): 438 group = self.groups.get(key, 'General') 439 self.settings.beginGroup(group) 440 self.settings.setValue(key, value) 441 self.settings.endGroup() 442 443 def __setattr__(self, key, value): 444 self[key] = value 445 446 def value_is_set(self, key, group: Optional[str]=None) -> bool: 447 if group is None: 448 group = 'General' 449 450 group = self.groups.get(key, group) 451 self.settings.beginGroup(group) 452 v = self.settings.contains(key) 453 self.settings.endGroup() 454 return v 455 456 def sync(self): 457 self.settings.sync() 458 459 def restore(self, key: str) -> None: 460 self[key] = self.defaults[key] 461 462 def get_preset(self, preset_type: PresetPrefType) -> Tuple[List[str], List[List[str]]]: 463 """ 464 Returns the custom presets for the particular type. 465 466 :param preset_type: one of photo subfolder, video subfolder, photo 467 rename, or video rename 468 :return: Tuple of list of present names and list of pref lists. Each 469 item in the first list corresponds with the item of the same index in the 470 second list. 471 """ 472 473 preset_pref_lists = [] 474 preset_names = [] 475 476 self.settings.beginGroup('Presets') 477 478 preset = preset_type.name 479 size = self.settings.beginReadArray(preset) 480 for i in range(size): 481 self.settings.setArrayIndex(i) 482 preset_names.append(self.settings.value('name', type=str)) 483 preset_pref_lists.append(self.settings.value('pref_list', type=str)) 484 self.settings.endArray() 485 486 self.settings.endGroup() 487 488 return preset_names, preset_pref_lists 489 490 def set_preset(self, preset_type: PresetPrefType, 491 preset_names: List[str], 492 preset_pref_lists: List[List[str]]) -> None: 493 """ 494 Saves a list of custom presets in the user's preferences. 495 496 If the list of preset names is empty, the preference value will be cleared. 497 498 :param preset_type: one of photo subfolder, video subfolder, photo 499 rename, or video rename 500 :param preset_names: list of names for each pref list 501 :param preset_pref_lists: the list of pref lists 502 """ 503 504 self.settings.beginGroup('Presets') 505 506 preset = preset_type.name 507 508 # Clear all the existing presets with that name. 509 # If we don't do this, when the array shrinks, old values can hang around, 510 # even though the array size is set correctly. 511 self.settings.remove(preset) 512 513 self.settings.beginWriteArray(preset) 514 for i in range(len(preset_names)): 515 self.settings.setArrayIndex(i) 516 self.settings.setValue('name', preset_names[i]) 517 self.settings.setValue('pref_list', preset_pref_lists[i]) 518 self.settings.endArray() 519 520 self.settings.endGroup() 521 522 def get_proximity(self) -> int: 523 """ 524 Validates preference value proxmity_seconds against standard list. 525 526 Given the user could enter any old value into the preferences, need to validate it. 527 The validation technique is to match whatever value is in the preferences with the 528 closest value we need, which is found in the list of int proximity_time_steps. 529 530 For the algorithm, see: 531 http://stackoverflow.com/questions/12141150/from-list-of-integers-get-number-closest-to-a 532 -given-value 533 No need to use bisect list, as our list is tiny, and using min has the advantage 534 of getting the closest value. 535 536 Note: we store the value in seconds, but use it in minutes, just in case a user one day 537 makes a compelling case to be able to specify a proximity value less than 1 minute. 538 539 :return: closest valid value in minutes 540 """ 541 542 minutes = self.proximity_seconds // 60 543 return min(constants.proximity_time_steps, key=lambda x:abs(x - minutes)) 544 545 def set_proximity(self, minutes: int) -> None: 546 self.proximity_seconds = minutes * 60 547 548 def _pref_list_uses_component(self, pref_list, pref_component, offset: int=1) -> bool: 549 for i in range(0, len(pref_list), 3): 550 if pref_list[i+offset] == pref_component: 551 return True 552 return False 553 554 def any_pref_uses_stored_sequence_no(self) -> bool: 555 """ 556 :return True if any of the pref lists contain a stored sequence no 557 """ 558 for pref_list in self.get_pref_lists(file_name_only=True): 559 if self._pref_list_uses_component(pref_list, STORED_SEQ_NUMBER): 560 return True 561 return False 562 563 def any_pref_uses_session_sequence_no(self) -> bool: 564 """ 565 :return True if any of the pref lists contain a session sequence no 566 """ 567 for pref_list in self.get_pref_lists(file_name_only=True): 568 if self._pref_list_uses_component(pref_list, SESSION_SEQ_NUMBER): 569 return True 570 return False 571 572 def any_pref_uses_sequence_letter_value(self) -> bool: 573 """ 574 :return True if any of the pref lists contain a sequence letter 575 """ 576 for pref_list in self.get_pref_lists(file_name_only=True): 577 if self._pref_list_uses_component(pref_list, SEQUENCE_LETTER): 578 return True 579 return False 580 581 def photo_rename_pref_uses_downloads_today(self) -> bool: 582 """ 583 :return: True if the photo rename pref list contains a downloads today 584 """ 585 return self._pref_list_uses_component(self.photo_rename, DOWNLOAD_SEQ_NUMBER) 586 587 def video_rename_pref_uses_downloads_today(self) -> bool: 588 """ 589 :return: True if the video rename pref list contains a downloads today 590 """ 591 return self._pref_list_uses_component(self.video_rename, DOWNLOAD_SEQ_NUMBER) 592 593 def photo_rename_pref_uses_stored_sequence_no(self) -> bool: 594 """ 595 :return: True if the photo rename pref list contains a stored sequence no 596 """ 597 return self._pref_list_uses_component(self.photo_rename, STORED_SEQ_NUMBER) 598 599 def video_rename_pref_uses_stored_sequence_no(self) -> bool: 600 """ 601 :return: True if the video rename pref list contains a stored sequence no 602 """ 603 return self._pref_list_uses_component(self.video_rename, STORED_SEQ_NUMBER) 604 605 def check_prefs_for_validity(self) -> Tuple[bool, str]: 606 """ 607 Checks photo & video rename, and subfolder generation 608 preferences ensure they follow name generation rules. Moreover, 609 subfolder name specifications must not: 610 1. start with a separator 611 2. end with a separator 612 3. have two separators in a row 613 614 :return: tuple with two values: (1) bool and error message if 615 prefs are invalid (else empty string) 616 """ 617 618 msg = '' 619 valid = True 620 tests = ( 621 (self.photo_rename, DICT_IMAGE_RENAME_L0), 622 (self.video_rename, DICT_VIDEO_RENAME_L0), 623 (self.photo_subfolder, DICT_SUBFOLDER_L0), 624 (self.video_subfolder, DICT_VIDEO_SUBFOLDER_L0) 625 ) 626 627 # test file renaming 628 for pref, pref_defn in tests[:2]: 629 try: 630 check_pref_valid(pref_defn, pref) 631 except PrefError as e: 632 valid = False 633 msg += e.msg + "\n" 634 635 # test subfolder generation 636 for pref, pref_defn in tests[2:]: 637 try: 638 check_pref_valid(pref_defn, pref) 639 640 L1s = [pref[i] for i in range(0, len(pref), 3)] 641 642 if L1s[0] == SEPARATOR: 643 raise PrefValueKeyComboError( 644 _("Subfolder preferences should not start with a %s") % os.sep 645 ) 646 elif L1s[-1] == SEPARATOR: 647 raise PrefValueKeyComboError( 648 _("Subfolder preferences should not end with a %s") % os.sep 649 ) 650 else: 651 for i in range(len(L1s) - 1): 652 if L1s[i] == SEPARATOR and L1s[i + 1] == SEPARATOR: 653 raise PrefValueKeyComboError( 654 _( 655 "Subfolder preferences should not contain two %s one after " 656 "the other" 657 ) % os.sep 658 ) 659 660 except PrefError as e: 661 valid = False 662 msg += e.msg + "\n" 663 664 return valid, msg 665 666 def _filter_duplicate_generation_prefs(self, preset_type: PresetPrefType) -> None: 667 preset_names, preset_pref_lists = self.get_preset(preset_type=preset_type) 668 seen = set() 669 filtered_names = [] 670 filtered_pref_lists = [] 671 duplicates = [] 672 for name, pref_list in zip(preset_names, preset_pref_lists): 673 value = tuple(pref_list) 674 if value in seen: 675 duplicates.append(name) 676 else: 677 seen.add(value) 678 filtered_names.append(name) 679 filtered_pref_lists.append(pref_list) 680 681 if duplicates: 682 human_readable = preset_type.name[len('preset_'):].replace('_', ' ') 683 logging.warning( 684 'Removed %s duplicate(s) from %s presets: %s', 685 len(duplicates), human_readable, make_internationalized_list(duplicates) 686 ) 687 self.set_preset( 688 preset_type=preset_type, preset_names=filtered_names, 689 preset_pref_lists=filtered_pref_lists 690 ) 691 692 def filter_duplicate_generation_prefs(self) -> None: 693 """ 694 Remove any duplicate subfolder generation or file renaming custom presets 695 """ 696 697 logging.info("Checking for duplicate name generation preference values") 698 for preset_type in PresetPrefType: 699 self._filter_duplicate_generation_prefs(preset_type) 700 701 def must_synchronize_raw_jpg(self) -> bool: 702 """ 703 :return: True if synchronize_raw_jpg is True and photo 704 renaming uses sequence values 705 """ 706 if self.synchronize_raw_jpg: 707 for s in LIST_SEQUENCE_L1: 708 if self._pref_list_uses_component(self.photo_rename, s, 1): 709 return True 710 return False 711 712 def format_pref_list_for_pretty_print(self, pref_list) -> str: 713 """ 714 :return: string useful for printing the preferences 715 """ 716 717 v = '' 718 for i in range(0, len(pref_list), 3): 719 if (pref_list[i+1] or pref_list[i+2]): 720 c = ':' 721 else: 722 c = '' 723 s = "%s%s " % (pref_list[i], c) 724 725 if pref_list[i+1]: 726 s = "%s%s" % (s, pref_list[i+1]) 727 if pref_list[i+2]: 728 s = "%s (%s)" % (s, pref_list[i+2]) 729 v += s + "\n" 730 return v 731 732 def get_pref_lists(self, file_name_only: bool) -> Tuple[List[str], ...]: 733 """ 734 :return: a tuple of the photo & video rename and subfolder 735 generation preferences 736 """ 737 if file_name_only: 738 return self.photo_rename, self.video_rename 739 else: 740 return self.photo_rename, self.photo_subfolder, self.video_rename, self.video_subfolder 741 742 def get_day_start_qtime(self) -> QTime: 743 """ 744 :return: day start time in QTime format, resetting to midnight on value error 745 """ 746 try: 747 h, m = self.day_start.split(":") 748 h = int(h) 749 m = int(m) 750 assert 0 <= h <= 23 751 assert 0 <= m <= 59 752 return QTime(h, m) 753 except (ValueError, AssertionError): 754 logging.error( 755 "'Start of day' preference value %s is corrupted. Resetting to midnight.", 756 self.day_start) 757 self.day_start = "0:0" 758 return QTime(0, 0) 759 760 def get_checkable_value(self, key: str) -> Qt.CheckState: 761 """ 762 Gets a boolean preference value using Qt's CheckState values 763 :param key: the preference item to get 764 :return: value converted from bool to an Qt.CheckState enum value 765 """ 766 767 value = self[key] 768 if value: 769 return Qt.Checked 770 else: 771 return Qt.Unchecked 772 773 def pref_uses_job_code(self, pref_list: List[str]) -> bool: 774 """ Returns True if the particular preference contains a job code""" 775 for i in range(0, len(pref_list), 3): 776 if pref_list[i] == JOB_CODE: 777 return True 778 return False 779 780 def any_pref_uses_job_code(self) -> bool: 781 """ Returns True if any of the preferences contain a job code""" 782 for pref_list in self.get_pref_lists(file_name_only=False): 783 if self.pref_uses_job_code(pref_list): 784 return True 785 return False 786 787 def file_type_uses_job_code(self, file_type: FileType) -> bool: 788 """ 789 Returns True if either the subfolder generation or file 790 renaming for the file type uses a Job Code. 791 """ 792 793 if file_type == FileType.photo: 794 pref_lists = self.photo_rename, self.photo_subfolder 795 else: 796 pref_lists = self.video_rename, self.video_subfolder 797 798 for pref_list in pref_lists: 799 if self.pref_uses_job_code(pref_list): 800 return True 801 return False 802 803 def most_recent_job_code(self, missing: Optional[str]=None) -> str: 804 """ 805 Get the most recent Job Code used (which is assumed to be at the top). 806 :param missing: If there is no Job Code, and return this default value 807 :return: most recent job code, or missing, or if not found, '' 808 """ 809 810 if len(self.job_codes) > 0: 811 value = self.job_codes[0] 812 return value or missing or '' 813 elif missing is not None: 814 return missing 815 else: 816 return '' 817 818 def photo_subfolder_index(self, preset_pref_lists: List[List[str]]) -> int: 819 """ 820 Matches the photo pref list with program subfolder generation 821 defaults and the user's presets. 822 823 :param preset_pref_lists: list of custom presets 824 :return: -1 if no match (i.e. custom), or the index into 825 PHOTO_SUBFOLDER_MENU_DEFAULTS + photo subfolder presets if it matches 826 """ 827 828 subfolders = PHOTO_SUBFOLDER_MENU_DEFAULTS_CONV + tuple(preset_pref_lists) 829 try: 830 return subfolders.index(self.photo_subfolder) 831 except ValueError: 832 return -1 833 834 def video_subfolder_index(self, preset_pref_lists: List[List[str]]) -> int: 835 """ 836 Matches the photo pref list with program subfolder generation 837 defaults and the user's presets. 838 839 :param preset_pref_lists: list of custom presets 840 :return: -1 if no match (i.e. custom), or the index into 841 VIDEO_SUBFOLDER_MENU_DEFAULTS + video subfolder presets if it matches 842 """ 843 844 subfolders = VIDEO_SUBFOLDER_MENU_DEFAULTS_CONV + tuple(preset_pref_lists) 845 try: 846 return subfolders.index(self.video_subfolder) 847 except ValueError: 848 return -1 849 850 def photo_rename_index(self, preset_pref_lists: List[List[str]]) -> int: 851 """ 852 Matches the photo pref list with program filename generation 853 defaults and the user's presets. 854 855 :param preset_pref_lists: list of custom presets 856 :return: -1 if no match (i.e. custom), or the index into 857 PHOTO_RENAME_MENU_DEFAULTS_CONV + photo rename presets if it matches 858 """ 859 860 rename = PHOTO_RENAME_MENU_DEFAULTS_CONV + tuple(preset_pref_lists) 861 try: 862 return rename.index(self.photo_rename) 863 except ValueError: 864 return -1 865 866 def video_rename_index(self, preset_pref_lists: List[List[str]]) -> int: 867 """ 868 Matches the video pref list with program filename generation 869 defaults and the user's presets. 870 871 :param preset_pref_lists: list of custom presets 872 :return: -1 if no match (i.e. custom), or the index into 873 VIDEO_RENAME_MENU_DEFAULTS_CONV + video rename presets if it matches 874 """ 875 876 rename = VIDEO_RENAME_MENU_DEFAULTS_CONV + tuple(preset_pref_lists) 877 try: 878 return rename.index(self.video_rename) 879 except ValueError: 880 return -1 881 882 def add_list_value(self, key, value, max_list_size=0) -> None: 883 """ 884 Add value to pref list if it doesn't already exist. 885 886 Values are added to the start of the list. 887 888 An empty list contains only one item: [''] 889 890 :param key: the preference key 891 :param value: the value to add 892 :param max_list_size: if non-zero, the list's last value will be deleted 893 """ 894 895 if len(self[key]) == 1 and self[key][0] == '': 896 self[key] = [value] 897 elif value not in self[key]: 898 # Must assign the value like this, otherwise the preference value 899 # will not be updated: 900 if max_list_size: 901 self[key] = [value] + self[key][:max_list_size - 1] 902 else: 903 self[key] = [value] + self[key] 904 905 def del_list_value(self, key:str, value) -> None: 906 """ 907 Remove a value from the pref list indicated by key. 908 909 Exceptions are not caught. 910 911 An empty list contains only one item: [''] 912 913 :param key: the preference key 914 :param value: the value to delete 915 """ 916 917 # Must remove the value like this, otherwise the preference value 918 # will not be updated: 919 l = self[key] 920 l.remove(value) 921 self[key] = l 922 923 if len(self[key]) == 0: 924 self[key] = [''] 925 926 def list_not_empty(self, key: str) -> bool: 927 """ 928 In our pref schema, an empty list is [''], not [] 929 930 :param key: the preference value to examine 931 :return: True if the pref list is not empty 932 """ 933 934 return bool(self[key] and self[key][0]) 935 936 def reset(self) -> None: 937 """ 938 Reset all program preferences to their default settings 939 """ 940 self.settings.clear() 941 self.program_version = raphodo.__about__.__version__ 942 943 def upgrade_prefs(self, previous_version) -> None: 944 """ 945 Upgrade the user's preferences if needed. 946 947 :param previous_version: previous version as returned by pkg_resources.parse_version 948 """ 949 950 photo_video_rename_change = pkg_resources.parse_version('0.9.0a4') 951 if previous_version < photo_video_rename_change: 952 for key in ('photo_rename', 'video_rename'): 953 pref_list, case = upgrade_pre090a4_rename_pref(self[key]) 954 if pref_list != self[key]: 955 self[key] = pref_list 956 logging.info("Upgraded %s preference value", key.replace('_', ' ')) 957 if case is not None: 958 if key == 'photo_rename': 959 self.photo_extension = case 960 else: 961 self.video_extension = case 962 963 v090a5 = pkg_resources.parse_version('0.9.0a5') 964 if previous_version < v090a5: 965 # Versions prior to 0.9.0a5 incorrectly set the conflict resolution value 966 # when importing preferences from 0.4.11 or earlier 967 try: 968 value = self.conflict_resolution 969 except TypeError: 970 self.settings.endGroup() 971 default = self.defaults['conflict_resolution'] 972 default_name = constants.ConflictResolution(default).name 973 logging.warning( 974 'Resetting Conflict Resolution preference value to %s', default_name 975 ) 976 self.conflict_resolution = default 977 # destinationButtonPressed is no longer used by 0.9.0a5 978 self.settings.beginGroup("MainWindow") 979 key = 'destinationButtonPressed' 980 try: 981 if self.settings.contains(key): 982 logging.debug("Removing preference value %s", key) 983 self.settings.remove(key) 984 except Exception: 985 logging.warning("Unknown error removing %s preference value", key) 986 self.settings.endGroup() 987 988 v090b6 = pkg_resources.parse_version('0.9.0b6') 989 key = 'warn_broken_or_missing_libraries' 990 group = 'Display' 991 if previous_version < v090b6 and not self.value_is_set(key, group): 992 # Versions prior to 0.9.0b6 may have a preference value 'warn_no_libmediainfo' 993 # which is now renamed to 'broken_or_missing_libraries' 994 if self.value_is_set('warn_no_libmediainfo', group): 995 self.settings.beginGroup(group) 996 v = self.settings.value('warn_no_libmediainfo', True, type(True)) 997 self.settings.remove('warn_no_libmediainfo') 998 self.settings.endGroup() 999 logging.debug( 1000 "Transferring preference value %s for warn_no_libmediainfo to " 1001 "warn_broken_or_missing_libraries", v 1002 ) 1003 self.warn_broken_or_missing_libraries = v 1004 else: 1005 logging.debug( 1006 "Not transferring preference value warn_no_libmediainfo to " 1007 "warn_broken_or_missing_libraries because it doesn't exist" 1008 ) 1009 1010 v093a1 = pkg_resources.parse_version('0.9.3a1') 1011 key = 'scan_specific_folders' 1012 group = 'Device' 1013 if previous_version < v093a1 and not self.value_is_set(key, group): 1014 # Versions prior to 0.9.3a1 used a preference value to indicate if 1015 # devices lacking a DCIM folder should be scanned. It is now renamed 1016 # to 'scan_specific_folders' 1017 if self.value_is_set('device_without_dcim_autodetection'): 1018 self.settings.beginGroup(group) 1019 v = self.settings.value('device_without_dcim_autodetection', True, type(True)) 1020 self.settings.remove('device_without_dcim_autodetection') 1021 self.settings.endGroup() 1022 self.settings.endGroup() 1023 logging.debug( 1024 "Transferring preference value %s for device_without_dcim_autodetection to " 1025 "scan_specific_folders as %s", v, not v 1026 ) 1027 self.scan_specific_folders = not v 1028 else: 1029 logging.debug( 1030 "Not transferring preference value device_without_dcim_autodetection to " 1031 "scan_specific_folders because it doesn't exist" 1032 ) 1033 1034 v0919b2 = pkg_resources.parse_version('0.9.19b2') 1035 key = 'ignored_paths' 1036 group = 'device_defaults' 1037 if previous_version < v0919b2 and self.value_is_set(key, group): 1038 # Versions prior to 0.9.19b2 did not include all the ignored paths 1039 # introduced in 0.9.16 and 0.9.19b2. If the user already has some 1040 # values, these new defaults will not be added automatically. So add 1041 # them here. 1042 for value in ('THMBNL', '__MACOSX'): 1043 # If the value is not already in the list, add it 1044 self.add_list_value(key=key, value=value) 1045 1046 1047 def validate_max_CPU_cores(self) -> None: 1048 logging.debug('Validating CPU core count for thumbnail generation...') 1049 available = available_cpu_count(physical_only=True) 1050 logging.debug('...%s physical cores detected', available) 1051 if self.max_cpu_cores > available: 1052 logging.info('Setting CPU Cores for thumbnail generation to %s', available) 1053 self.max_cpu_cores = available 1054 1055 def validate_ignore_unhandled_file_exts(self) -> None: 1056 # logging.debug('Validating list of file extension to not warn about...') 1057 self.ignore_unhandled_file_exts = [ext.upper() for ext in self.ignore_unhandled_file_exts 1058 if ext.lower() not in ALL_KNOWN_EXTENSIONS] 1059 1060 def warn_about_unknown_file(self, ext: str) -> bool: 1061 if not self.warn_unhandled_files: 1062 return False 1063 1064 if not self.ignore_unhandled_file_exts[0]: 1065 return True 1066 1067 return ext.upper() not in self.ignore_unhandled_file_exts 1068 1069 def settings_path(self) -> str: 1070 """ 1071 :return: the full path of the settings file 1072 """ 1073 return self.settings.fileName() 1074 1075 1076def match_pref_list(pref_lists: List[List[str]], user_pref_list: List[str]) -> int: 1077 try: 1078 return pref_lists.index(user_pref_list) 1079 except ValueError: 1080 return -1 1081