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