1# Copyright (C) 2017-2020 Damon Lynch <damonlynch@gmail.com>
2
3# This file is part of Rapid Photo Downloader.
4#
5# Rapid Photo Downloader is free software: you can redistribute it and/or
6# modify it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# Rapid Photo Downloader is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with Rapid Photo Downloader.  If not,
17# see <http://www.gnu.org/licenses/>.
18
19"""
20Dialog window to show and manipulate selected user preferences
21"""
22
23__author__ = 'Damon Lynch'
24__copyright__ = "Copyright 2017-2020, Damon Lynch"
25
26import webbrowser
27from typing import List
28import logging
29
30
31from PyQt5.QtCore import (Qt, pyqtSlot, pyqtSignal, QObject, QThread, QTimer, QSize)
32from PyQt5.QtWidgets import (
33    QWidget, QSizePolicy, QComboBox, QVBoxLayout, QLabel, QLineEdit, QSpinBox, QGridLayout,
34    QAbstractItemView, QListWidgetItem, QHBoxLayout, QDialog, QDialogButtonBox, QCheckBox,
35    QStyle, QStackedWidget, QApplication, QPushButton, QGroupBox,  QFormLayout, QMessageBox,
36    QButtonGroup, QRadioButton, QAbstractButton
37)
38from PyQt5.QtGui import (
39    QShowEvent, QCloseEvent, QMouseEvent, QIcon, QFont, QFontMetrics, QPixmap, QPalette
40)
41
42from raphodo.preferences import Preferences
43from raphodo.constants import (
44    KnownDeviceType, CompletedDownloads, TreatRawJpeg, MarkRawJpeg
45)
46from raphodo.viewutils import QNarrowListWidget, translateDialogBoxButtons, standardMessageBox
47from raphodo.utilities import available_cpu_count, format_size_for_user, thousands
48from raphodo.cache import ThumbnailCacheSql
49from raphodo.constants import ConflictResolution
50from raphodo.utilities import (
51    current_version_is_dev_version, make_internationalized_list, version_check_disabled,
52    available_languages
53)
54from raphodo.fileformats import (
55    PHOTO_EXTENSIONS, AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, VIDEO_THUMBNAIL_EXTENSIONS,
56    ALL_KNOWN_EXTENSIONS
57)
58import raphodo.qrc_resources as qrc_resources
59
60
61class ClickableLabel(QLabel):
62    clicked = pyqtSignal()
63
64    def mousePressEvent(self, event: QMouseEvent) -> None:
65        self.clicked.emit()
66
67
68consolidation_implemented = False
69# consolidation_implemented = True
70
71system_language = 'SYSTEM'
72
73
74class PreferencesDialog(QDialog):
75    """
76    Preferences dialog for those preferences that are not adjusted via the main window
77
78    Note:
79
80    When pref value generate_thumbnails is made False, pref values use_thumbnail_cache and
81    generate_thumbnails are not changed, even though the preference value shown to the user
82    shows False (to indicate that the activity will not occur).
83    """
84
85    getCacheSize = pyqtSignal()
86
87    def __init__(self, prefs: Preferences, parent=None) -> None:
88        super().__init__(parent=parent)
89
90        self.rapidApp = parent
91
92        self.setWindowTitle(_('Preferences'))
93
94        self.prefs = prefs
95
96        self.is_prerelease = current_version_is_dev_version()
97
98        self.panels = QStackedWidget()
99
100        self.chooser = QNarrowListWidget(no_focus_recentangle=True)
101
102        font = QFont()
103        fontMetrics = QFontMetrics(font)
104        icon_padding = 6
105        icon_height = max(fontMetrics.height(), 16)
106        icon_width = icon_height + icon_padding
107        self.chooser.setIconSize(QSize(icon_width, icon_height))
108
109        palette = QPalette()
110        selectedColour = palette.color(palette.HighlightedText)
111
112        if consolidation_implemented:
113            self.chooser_items = (
114                _('Devices'), _('Language'), _('Automation'), _('Thumbnails'), _('Error Handling'),
115                _('Warnings'), _('Consolidation'), _('Miscellaneous')
116            )
117            icons = (
118                ":/prefs/devices.svg", ":/prefs/language.svg", ":/prefs/automation.svg",
119                ":/prefs/thumbnails.svg", ":/prefs/error-handling.svg", ":/prefs/warnings.svg",
120                ":/prefs/consolidation.svg", ":/prefs/miscellaneous.svg"
121            )
122        else:
123            self.chooser_items = (
124                _('Devices'), _('Language'), _('Automation'), _('Thumbnails'), _('Error Handling'),
125                _('Warnings'), _('Miscellaneous')
126            )
127            icons = (
128                ":/prefs/devices.svg", ":/prefs/language.svg", ":/prefs/automation.svg",
129                ":/prefs/thumbnails.svg", ":/prefs/error-handling.svg", ":/prefs/warnings.svg",
130                ":/prefs/miscellaneous.svg"
131            )
132
133        for prefIcon, label in zip(icons, self.chooser_items):
134            # make the selected icons be the same colour as the selected text
135            icon = QIcon()
136            pixmap = QPixmap(prefIcon)
137            selected = QPixmap(pixmap.size())
138            selected.fill(selectedColour)
139            selected.setMask(pixmap.createMaskFromColor(Qt.transparent))
140            icon.addPixmap(pixmap, QIcon.Normal)
141            icon.addPixmap(selected, QIcon.Selected)
142
143            item = QListWidgetItem(icon, label, self.chooser)
144            item.setFont(QFont())
145            width = fontMetrics.width(label) + icon_width + icon_padding * 2
146            item.setSizeHint(QSize(width, icon_height * 2))
147
148        self.chooser.currentRowChanged.connect(self.rowChanged)
149        self.chooser.setSelectionMode(QAbstractItemView.SingleSelection)
150        self.chooser.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.MinimumExpanding)
151
152        self.devices = QWidget()
153
154        self.scanBox = QGroupBox(_('Device Scanning'))
155        self.onlyExternal = QCheckBox(_('Scan only external devices'))
156        self.onlyExternal.setToolTip(_(
157            'Scan for photos and videos only on devices that are external to the computer,\n'
158            'including cameras, memory cards, external hard drives, and USB flash drives.'
159        ))
160        self.scanSpecificFolders = QCheckBox(_('Scan only specific folders on devices'))
161        tip = _(
162            'Scan for photos and videos only in the folders specified below (except paths\n'
163            'specified in Ignored Paths).\n\n'
164            'Changing this setting causes all devices to be scanned again.'
165        )
166        self.scanSpecificFolders.setToolTip(tip)
167
168        self.foldersToScanLabel = QLabel(_('Folders to scan:'))
169        self.foldersToScan = QNarrowListWidget(minimum_rows=5)
170        self.foldersToScan.setToolTip(_(
171            'Folders at the base level of device file systems that will be scanned\n'
172            'for photos and videos.'
173        ))
174        self.addFolderToScan = QPushButton(_('Add...'))
175        self.addFolderToScan.setToolTip(_(
176            'Add a folder to the list of folders to scan for photos and videos.\n\n'
177            'Changing this setting causes all devices to be scanned again.'
178        ))
179        self.removeFolderToScan = QPushButton(_('Remove'))
180        self.removeFolderToScan.setToolTip(_(
181            'Remove a folder from the list of folders to scan for photos and videos.\n\n'
182            'Changing this setting causes all devices to be scanned again.'
183        ))
184
185        self.addFolderToScan.clicked.connect(self.addFolderToScanClicked)
186        self.removeFolderToScan.clicked.connect(self.removeFolderToScanClicked)
187
188        scanLayout = QGridLayout()
189        scanLayout.setHorizontalSpacing(18)
190        scanLayout.addWidget(self.onlyExternal, 0, 0, 1, 3)
191        scanLayout.addWidget(self.scanSpecificFolders, 1, 0, 1, 3)
192        scanLayout.addWidget(self.foldersToScanLabel, 2, 1, 1, 2)
193        scanLayout.addWidget(self.foldersToScan, 3, 1, 3, 1)
194        scanLayout.addWidget(self.addFolderToScan, 3, 2, 1, 1)
195        scanLayout.addWidget(self.removeFolderToScan, 4, 2, 1, 1)
196        self.scanBox.setLayout(scanLayout)
197
198        tip = _('Devices that have been set to automatically ignore or download from.')
199        self.knownDevicesBox = QGroupBox(_('Remembered Devices'))
200        self.knownDevices = QNarrowListWidget(minimum_rows=5)
201        self.knownDevices.setToolTip(tip)
202        tip = _(
203            'Remove a device from the list of devices to automatically ignore or download from.'
204        )
205        self.removeDevice = QPushButton(_('Remove'))
206        self.removeDevice.setToolTip(tip)
207        self.removeAllDevice = QPushButton(_('Remove All'))
208        tip = _(
209            'Clear the list of devices from which to automatically ignore or download from.\n\n'
210            'Note: Changes take effect when the computer is next scanned for devices.'
211        )
212        self.removeAllDevice.setToolTip(tip)
213        self.removeDevice.clicked.connect(self.removeDeviceClicked)
214        self.removeAllDevice.clicked.connect(self.removeAllDeviceClicked)
215        knownDevicesLayout = QGridLayout()
216        knownDevicesLayout.setHorizontalSpacing(18)
217        knownDevicesLayout.addWidget(self.knownDevices, 0, 0, 3, 1)
218        knownDevicesLayout.addWidget(self.removeDevice, 0, 1, 1, 1)
219        knownDevicesLayout.addWidget(self.removeAllDevice, 1, 1, 1, 1)
220        self.knownDevicesBox.setLayout(knownDevicesLayout)
221
222        self.ignoredPathsBox = QGroupBox(_('Ignored Paths'))
223        tip = _('The end part of a path that should never be scanned for photos or videos.')
224        self.ignoredPaths = QNarrowListWidget(minimum_rows=4)
225        self.ignoredPaths.setToolTip(tip)
226        self.addPath = QPushButton(_('Add...'))
227        self.addPath.setToolTip(_(
228            'Add a path to the list of paths to ignore.\n\n'
229            'Changing this setting causes all devices to be scanned again.'
230        ))
231        self.removePath = QPushButton(_('Remove'))
232        self.removePath.setToolTip(_(
233            'Remove a path from the list of paths to ignore.\n\n'
234            'Changing this setting causes all devices to be scanned again.'
235        ))
236        self.removeAllPath = QPushButton(_('Remove All'))
237        self.removeAllPath.setToolTip(_(
238            'Clear the list of paths to ignore.\n\n'
239            'Changing this setting causes all devices to be scanned again.'
240        ))
241        self.addPath.clicked.connect(self.addPathClicked)
242        self.removePath.clicked.connect(self.removePathClicked)
243        self.removeAllPath.clicked.connect(self.removeAllPathClicked)
244        self.ignoredPathsRe = QCheckBox()
245        self.ignorePathsReLabel = ClickableLabel(
246            _('Use python-style '
247              '<a href="http://damonlynch.net/rapid/documentation/#regularexpressions">regular '
248              'expressions</a>'))
249        self.ignorePathsReLabel.setToolTip(_(
250            'Use regular expressions in the list of ignored paths.\n\n'
251            'Changing this setting causes all devices to be scanned again.'
252        ))
253        self.ignorePathsReLabel.setTextInteractionFlags(Qt.TextBrowserInteraction)
254        self.ignorePathsReLabel.setOpenExternalLinks(True)
255        self.ignorePathsReLabel.clicked.connect(self.ignorePathsReLabelClicked)
256        reLayout = QHBoxLayout()
257        reLayout.setSpacing(5)
258        reLayout.addWidget(self.ignoredPathsRe)
259        reLayout.addWidget(self.ignorePathsReLabel)
260        reLayout.addStretch()
261        ignoredPathsLayout = QGridLayout()
262        ignoredPathsLayout.setHorizontalSpacing(18)
263        ignoredPathsLayout.addWidget(self.ignoredPaths, 0, 0, 4, 1)
264        ignoredPathsLayout.addWidget(self.addPath, 0, 1, 1, 1)
265        ignoredPathsLayout.addWidget(self.removePath, 1, 1, 1, 1)
266        ignoredPathsLayout.addWidget(self.removeAllPath, 2, 1, 1, 1)
267        ignoredPathsLayout.addLayout(reLayout, 4, 0, 1, 2)
268        self.ignoredPathsBox.setLayout(ignoredPathsLayout)
269
270        self.setDeviceWidgetValues()
271
272        # connect these next 3 only after having set their values, so rescan / search again
273        # in rapidApp is not triggered
274        self.onlyExternal.stateChanged.connect(self.onlyExternalChanged)
275        self.scanSpecificFolders.stateChanged.connect(self.noDcimChanged)
276        self.ignoredPathsRe.stateChanged.connect(self.ignoredPathsReChanged)
277
278        devicesLayout = QVBoxLayout()
279        devicesLayout.addWidget(self.scanBox)
280        devicesLayout.addWidget(self.ignoredPathsBox)
281        devicesLayout.addWidget(self.knownDevicesBox)
282        devicesLayout.addStretch()
283        devicesLayout.setSpacing(18)
284
285        self.devices.setLayout(devicesLayout)
286        devicesLayout.setContentsMargins(0, 0, 0, 0)
287
288        self.language = QWidget()
289        self.languages = QComboBox()
290        self.languages.setEditable(False)
291        self.languagesLabel = QLabel(_('Language: '))
292        self.languages.setSizeAdjustPolicy(QComboBox.AdjustToContents)
293        # self.languages.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
294
295        self.setLanguageWidgetValues()
296
297        self.languages.currentIndexChanged.connect(self.languagesChanged)
298
299        languageWidgetsLayout = QHBoxLayout()
300        languageWidgetsLayout.addWidget(self.languagesLabel)
301        languageWidgetsLayout.addWidget(self.languages)
302        # Translators: the * acts as an asterisk to denote a reference to an annotation
303        # such as '* Takes effect upon program restart'
304        languageWidgetsLayout.addWidget(QLabel(_('*')))
305        languageWidgetsLayout.addStretch()
306        languageWidgetsLayout.setSpacing(5)
307
308        languageLayout = QVBoxLayout()
309        languageLayout.addLayout(languageWidgetsLayout)
310        # Translators: the * acts as an asterisk to denote a reference to this annotation
311        languageLayout.addWidget(QLabel(_('* Takes effect upon program restart')))
312        languageLayout.addStretch()
313        languageLayout.setContentsMargins(0, 0, 0, 0)
314        languageLayout.setSpacing(18)
315        self.language.setLayout(languageLayout)
316
317        self.automation = QWidget()
318
319        self.automationBox = QGroupBox(_('Program Automation'))
320        self.autoDownloadStartup = QCheckBox(_('Start downloading at program startup'))
321        self.autoDownloadInsertion = QCheckBox(_('Start downloading upon device insertion'))
322        self.autoEject = QCheckBox(_('Unmount (eject) device upon download completion'))
323        self.autoExit = QCheckBox(_('Exit program when download completes'))
324        self.autoExitError = QCheckBox(_('Exit program even if download had warnings or errors'))
325        self.setAutomationWidgetValues()
326        self.autoDownloadStartup.stateChanged.connect(self.autoDownloadStartupChanged)
327        self.autoDownloadInsertion.stateChanged.connect(self.autoDownloadInsertionChanged)
328        self.autoEject.stateChanged.connect(self.autoEjectChanged)
329        self.autoExit.stateChanged.connect(self.autoExitChanged)
330        self.autoExitError.stateChanged.connect(self.autoExitErrorChanged)
331
332        automationBoxLayout = QGridLayout()
333        automationBoxLayout.addWidget(self.autoDownloadStartup, 0, 0, 1, 2)
334        automationBoxLayout.addWidget(self.autoDownloadInsertion, 1, 0, 1, 2)
335        automationBoxLayout.addWidget(self.autoEject, 2, 0, 1, 2)
336        automationBoxLayout.addWidget(self.autoExit, 3, 0, 1, 2)
337        automationBoxLayout.addWidget(self.autoExitError, 4, 1, 1, 1)
338        checkbox_width = self.autoExit.style().pixelMetric(QStyle.PM_IndicatorWidth)
339        automationBoxLayout.setColumnMinimumWidth(0, checkbox_width)
340        self.automationBox.setLayout(automationBoxLayout)
341
342        automationLayout = QVBoxLayout()
343        automationLayout.addWidget(self.automationBox)
344        automationLayout.addStretch()
345        automationLayout.setContentsMargins(0, 0, 0, 0)
346
347        self.automation.setLayout(automationLayout)
348
349        self.performance = QWidget()
350
351        self.performanceBox = QGroupBox(_('Thumbnail Generation'))
352        self.generateThumbnails = QCheckBox(_('Generate thumbnails'))
353        self.generateThumbnails.setToolTip(
354            _('Generate thumbnails to show in the main program window')
355        )
356        self.useThumbnailCache = QCheckBox(_('Cache thumbnails'))
357        self.useThumbnailCache.setToolTip(
358            _(
359                "Save thumbnails shown in the main program window in a thumbnail cache unique to "
360                "Rapid Photo Downloader"
361            )
362        )
363        self.fdoThumbnails = QCheckBox(_('Generate system thumbnails'))
364        self.fdoThumbnails.setToolTip(
365            _(
366                'While downloading, save thumbnails that can be used by desktop file managers '
367                'and other programs'
368            )
369        )
370        self.generateThumbnails.stateChanged.connect(self.generateThumbnailsChanged)
371        self.useThumbnailCache.stateChanged.connect(self.useThumbnailCacheChanged)
372        self.fdoThumbnails.stateChanged.connect(self.fdoThumbnailsChanged)
373        self.maxCores = QComboBox()
374        self.maxCores.setEditable(False)
375        tip = _('Number of CPU cores used to generate thumbnails.')
376        self.coresLabel = QLabel(_('CPU cores:'))
377        self.coresLabel.setToolTip(tip)
378        self.maxCores.setSizeAdjustPolicy(QComboBox.AdjustToContents)
379        self.maxCores.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
380        self.maxCores.setToolTip(tip)
381
382        self.setPerformanceValues()
383
384        self.maxCores.currentIndexChanged.connect(self.maxCoresChanged)
385
386        coresLayout = QHBoxLayout()
387        coresLayout.addWidget(self.coresLabel)
388        coresLayout.addWidget(self.maxCores)
389        # Translators: the * acts as an asterisk to denote a reference to an annotation
390        # such as '* Takes effect upon program restart'
391        coresLayout.addWidget(QLabel(_('*')))
392        coresLayout.addStretch()
393
394        performanceBoxLayout = QVBoxLayout()
395        performanceBoxLayout.addWidget(self.generateThumbnails)
396        performanceBoxLayout.addWidget(self.useThumbnailCache)
397        performanceBoxLayout.addWidget(self.fdoThumbnails)
398        performanceBoxLayout.addLayout(coresLayout)
399        self.performanceBox.setLayout(performanceBoxLayout)
400
401        self.thumbnail_cache = ThumbnailCacheSql(create_table_if_not_exists=False)
402
403        self.cacheSize = CacheSize()
404        self.cacheSizeThread = QThread()
405        self.cacheSizeThread.started.connect(self.cacheSize.start)
406        self.getCacheSize.connect(self.cacheSize.getCacheSize)
407        self.cacheSize.size.connect(self.setCacheSize)
408        self.cacheSize.moveToThread(self.cacheSizeThread)
409
410        QTimer.singleShot(0, self.cacheSizeThread.start)
411
412        self.getCacheSize.emit()
413
414        self.cacheBox = QGroupBox(_('Thumbnail Cache'))
415        self.thumbnailCacheSize = QLabel()
416        self.thumbnailCacheSize.setText(_('Calculating...'))
417        self.thumbnailNumber = QLabel()
418        self.thumbnailSqlSize = QLabel()
419        self.thumbnailCacheDaysKeep = QSpinBox()
420        self.thumbnailCacheDaysKeep.setMinimum(0)
421        self.thumbnailCacheDaysKeep.setMaximum(360*3)
422        self.thumbnailCacheDaysKeep.setSuffix(' ' + _('days'))
423        self.thumbnailCacheDaysKeep.setSpecialValueText(_('forever'))
424        self.thumbnailCacheDaysKeep.valueChanged.connect(self.thumbnailCacheDaysKeepChanged)
425
426        cacheBoxLayout = QVBoxLayout()
427        cacheLayout = QGridLayout()
428        cacheLayout.addWidget(QLabel(_('Cache size:')), 0, 0, 1, 1)
429        cacheLayout.addWidget(self.thumbnailCacheSize, 0, 1, 1, 1)
430        cacheLayout.addWidget(QLabel(_('Number of thumbnails:')), 1, 0, 1, 1)
431        cacheLayout.addWidget(self.thumbnailNumber, 1, 1, 1, 1)
432        cacheLayout.addWidget(QLabel(_('Database size:')), 2, 0, 1, 1)
433        cacheLayout.addWidget(self.thumbnailSqlSize, 2, 1, 1, 1)
434        cacheLayout.addWidget(QLabel(_('Cache unaccessed thumbnails for:')), 3, 0, 1, 1)
435        cacheDays = QHBoxLayout()
436        cacheDays.addWidget(self.thumbnailCacheDaysKeep)
437        cacheDays.addWidget(QLabel(_('*')))
438        cacheLayout.addLayout(cacheDays, 3, 1, 1, 1)
439        cacheBoxLayout.addLayout(cacheLayout)
440
441        cacheButtons = QDialogButtonBox()
442        self.purgeCache = cacheButtons.addButton(_('Purge Cache...'), QDialogButtonBox.ResetRole)
443        self.optimizeCache = cacheButtons.addButton(
444            _('Optimize Cache...'), QDialogButtonBox.ResetRole
445        )
446        self.purgeCache.clicked.connect(self.purgeCacheClicked)
447        self.optimizeCache.clicked.connect(self.optimizeCacheClicked)
448
449        cacheBoxLayout.addWidget(cacheButtons)
450
451        self.cacheBox.setLayout(cacheBoxLayout)
452        self.setCacheValues()
453
454        performanceLayout = QVBoxLayout()
455        performanceLayout.addWidget(self.performanceBox)
456        performanceLayout.addWidget(self.cacheBox)
457        performanceLayout.addWidget(QLabel(_('* Takes effect upon program restart')))
458        performanceLayout.addStretch()
459        performanceLayout.setContentsMargins(0, 0, 0, 0)
460        performanceLayout.setSpacing(18)
461
462        self.performance.setLayout(performanceLayout)
463
464        self.errorBox = QGroupBox(_('Error Handling'))
465
466        self.downloadErrorGroup = QButtonGroup()
467        self.skipDownload = QRadioButton(_('Skip download'))
468        self.skipDownload.setToolTip(_("Don't download the file, and issue an error message"))
469        self.addIdentifier = QRadioButton(_('Add unique identifier'))
470        self.addIdentifier.setToolTip(
471            _(
472                "Add an identifier like _1 or _2 to the end of the filename, immediately before "
473                "the file's extension"
474            )
475        )
476        self.downloadErrorGroup.addButton(self.skipDownload)
477        self.downloadErrorGroup.addButton(self.addIdentifier)
478
479        self.backupErrorGroup = QButtonGroup()
480        self.overwriteBackup = QRadioButton(_('Overwrite'))
481        self.overwriteBackup.setToolTip(_("Overwrite the previously backed up file"))
482        self.skipBackup = QRadioButton(_('Skip'))
483        self.skipBackup.setToolTip(
484            _("Don't overwrite the backup file, and issue an error message")
485        )
486        self.backupErrorGroup.addButton(self.overwriteBackup)
487        self.backupErrorGroup.addButton(self.skipBackup)
488
489        errorBoxLayout = QVBoxLayout()
490        lbl = _(
491            'When a photo or video of the same name has already been downloaded, choose '
492            'whether to skip downloading the file, or to add a unique identifier:'
493        )
494        self.downloadError = QLabel(lbl)
495        self.downloadError.setWordWrap(True)
496        errorBoxLayout.addWidget(self.downloadError)
497        errorBoxLayout.addWidget(self.skipDownload)
498        errorBoxLayout.addWidget(self.addIdentifier)
499        lbl = '<i>' + _(
500            'Using sequence numbers to automatically generate unique filenames is '
501            'strongly recommended. Configure file renaming in the Rename panel in the '
502            'main window.'
503        ) + '</i>'
504        self.recommended = QLabel(lbl)
505        self.recommended.setWordWrap(True)
506        errorBoxLayout.addWidget(self.recommended)
507        errorBoxLayout.addSpacing(18)
508        lbl = _(
509            'When backing up, choose whether to overwrite a file on the backup device that '
510            'has the same name, or skip backing it up:'
511        )
512        self.backupError = QLabel(lbl)
513        self.backupError.setWordWrap(True)
514        errorBoxLayout.addWidget(self.backupError)
515        errorBoxLayout.addWidget(self.overwriteBackup)
516        errorBoxLayout.addWidget(self.skipBackup)
517        self.errorBox.setLayout(errorBoxLayout)
518
519        self.setErrorHandingValues()
520        self.downloadErrorGroup.buttonClicked.connect(self.downloadErrorGroupClicked)
521        self.backupErrorGroup.buttonClicked.connect(self.backupErrorGroupClicked)
522
523        self.errorWidget = QWidget()
524        errorLayout = QVBoxLayout()
525        self.errorWidget.setLayout(errorLayout)
526        errorLayout.addWidget(self.errorBox)
527        errorLayout.addStretch()
528        errorLayout.setContentsMargins(0, 0, 0, 0)
529
530        self.warningBox = QGroupBox(_('Program Warnings'))
531        lbl = _('Show a warning when:')
532        self.warningLabel = QLabel(lbl)
533        self.warningLabel.setWordWrap(True)
534        self.warnDownloadingAll = QCheckBox(_('Downloading files currently not displayed'))
535        tip = _('Warn when about to download files that are not displayed in the main window.')
536        self.warnDownloadingAll.setToolTip(tip)
537        self.warnBackupProblem = QCheckBox(_('Backup destinations are missing'))
538        tip = _("Warn before starting a download if it is not possible to back up files.")
539        self.warnBackupProblem.setToolTip(tip)
540        self.warnMissingLibraries = QCheckBox(_('Program libraries are missing or broken'))
541        tip = _('Warn if a software library used by Rapid Photo Downloader is missing or not '
542                'functioning.')
543        self.warnMissingLibraries.setToolTip(tip)
544        self.warnMetadata = QCheckBox(_('Filesystem metadata cannot be set'))
545        tip = _("Warn if there is an error setting a file's filesystem metadata, "
546                "such as its modification time.")
547        self.warnMetadata.setToolTip(tip)
548        self.warnUnhandledFiles = QCheckBox(_('Encountering unhandled files'))
549        tip = _('Warn after scanning a device or this computer if there are unrecognized files '
550                'that will not be included in the download.')
551        self.warnUnhandledFiles.setToolTip(tip)
552        self.exceptTheseFilesLabel = QLabel(
553            _('Do not warn about unhandled files with extensions:')
554        )
555        self.exceptTheseFilesLabel.setWordWrap(True)
556        self.exceptTheseFiles = QNarrowListWidget(minimum_rows=4)
557        tip = _(
558            'File extensions are case insensitive and do not need to include the leading dot.'
559        )
560        self.exceptTheseFiles.setToolTip(tip)
561        self.addExceptFiles = QPushButton(_('Add'))
562        tip = _('Add a file extension to the list of unhandled file types to not warn about.')
563        self.addExceptFiles.setToolTip(tip)
564        tip = _('Remove a file extension from the list of unhandled file types to not warn about.')
565        self.removeExceptFiles = QPushButton(_('Remove'))
566        self.removeExceptFiles.setToolTip(tip)
567        self.removeAllExceptFiles = QPushButton(_('Remove All'))
568        tip = _('Clear the list of file extensions of unhandled file types to not warn about.')
569        self.removeAllExceptFiles.setToolTip(tip)
570        self.addExceptFiles.clicked.connect(self.addExceptFilesClicked)
571        self.removeExceptFiles.clicked.connect(self.removeExceptFilesClicked)
572        self.removeAllExceptFiles.clicked.connect(self.removeAllExceptFilesClicked)
573
574        self.setWarningValues()
575        self.warnDownloadingAll.stateChanged.connect(self.warnDownloadingAllChanged)
576        self.warnBackupProblem.stateChanged.connect(self.warnBackupProblemChanged)
577        self.warnMissingLibraries.stateChanged.connect(self.warnMissingLibrariesChanged)
578        self.warnMetadata.stateChanged.connect(self.warnMetadataChanged)
579        self.warnUnhandledFiles.stateChanged.connect(self.warnUnhandledFilesChanged)
580
581        warningBoxLayout = QGridLayout()
582        warningBoxLayout.addWidget(self.warningLabel, 0, 0, 1, 3)
583        warningBoxLayout.addWidget(self.warnDownloadingAll, 1, 0, 1, 3)
584        warningBoxLayout.addWidget(self.warnBackupProblem, 2, 0, 1, 3)
585        warningBoxLayout.addWidget(self.warnMissingLibraries, 3, 0, 1, 3)
586        warningBoxLayout.addWidget(self.warnMetadata, 4, 0, 1, 3)
587        warningBoxLayout.addWidget(self.warnUnhandledFiles, 5, 0, 1, 3)
588        warningBoxLayout.addWidget(self.exceptTheseFilesLabel,  6, 1, 1, 2)
589        warningBoxLayout.addWidget(self.exceptTheseFiles, 7, 1, 4, 1)
590        warningBoxLayout.addWidget(self.addExceptFiles, 7, 2, 1, 1)
591        warningBoxLayout.addWidget(self.removeExceptFiles, 8, 2, 1, 1)
592        warningBoxLayout.addWidget(self.removeAllExceptFiles, 9, 2, 1, 1)
593        warningBoxLayout.setColumnMinimumWidth(0, checkbox_width)
594        self.warningBox.setLayout(warningBoxLayout)
595
596        self.warnings = QWidget()
597        warningLayout = QVBoxLayout()
598        self.warnings.setLayout(warningLayout)
599        warningLayout.addWidget(self.warningBox)
600        warningLayout.addStretch()
601        warningLayout.setContentsMargins(0, 0, 0, 0)
602
603        if consolidation_implemented:
604            self.consolidationBox = QGroupBox(_('Photo and Video Consolidation'))
605
606            self.consolidateIdentical = QCheckBox(
607                _('Consolidate files across devices and downloads')
608            )
609            tip = _(
610                "Analyze the results of device scans looking for duplicate files and matching "
611                "RAW and JPEG pairs,\ncomparing them across multiple devices and download "
612                "sessions."
613            )
614            self.consolidateIdentical.setToolTip(tip)
615
616            self.treatRawJpegLabel = QLabel(_('Treat matching RAW and JPEG files as:'))
617            self.oneRawJpeg = QRadioButton(_('One photo'))
618            self.twoRawJpeg = QRadioButton(_('Two photos'))
619            tip = _(
620                "Display matching pairs of RAW and JPEG photos as one photo, and if marked, "
621                "download both."
622            )
623            self.oneRawJpeg.setToolTip(tip)
624            tip = _(
625                "Display matching pairs of RAW and JPEG photos as two different photos. You can "
626                "still synchronize their sequence numbers."
627            )
628            self.twoRawJpeg.setToolTip(tip)
629
630            self.treatRawJpegGroup = QButtonGroup()
631            self.treatRawJpegGroup.addButton(self.oneRawJpeg)
632            self.treatRawJpegGroup.addButton(self.twoRawJpeg)
633
634            self.markRawJpegLabel = QLabel(_('With matching RAW and JPEG photos:'))
635
636            self.noJpegWhenRaw = QRadioButton(_('Do not mark JPEG for download'))
637            self.noRawWhenJpeg = QRadioButton(_('Do not mark RAW for download'))
638            self.markRawJpeg = QRadioButton(_('Mark both for download'))
639
640            self.markRawJpegGroup = QButtonGroup()
641            for widget in (self.noJpegWhenRaw, self.noRawWhenJpeg, self.markRawJpeg):
642                self.markRawJpegGroup.addButton(widget)
643
644            tip = _(
645                "When matching RAW and JPEG photos are found, do not automatically mark the "
646                "JPEG for\ndownload. You can still mark it for download yourself."
647            )
648            self.noJpegWhenRaw.setToolTip(tip)
649            tip = _(
650                "When matching RAW and JPEG photos are found, do not automatically mark the "
651                "RAW for\ndownload. You can still mark it for download yourself."
652            )
653            self.noRawWhenJpeg.setToolTip(tip)
654            tip = _(
655                "When matching RAW and JPEG photos are found, automatically mark both "
656                "for download."
657            )
658            self.markRawJpeg.setToolTip(tip)
659
660            explanation = _(
661                'If you disable file consolidation, choose what to do when a download device is '
662                'inserted while completed downloads are displayed:'
663            )
664
665        else:
666            explanation = _(
667                'When a download device is inserted while completed downloads are displayed:'
668            )
669        self.noconsolidationLabel = QLabel(explanation)
670        self.noconsolidationLabel.setWordWrap(True)
671        self.noconsolidationLabel.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Minimum)
672        # Unless this next call is made, for some reason the widget is far too high! :-(
673        self.noconsolidationLabel.setContentsMargins(0, 0, 1, 0)
674
675        self.noConsolidationGroup = QButtonGroup()
676        self.noConsolidationGroup.buttonClicked.connect(self.noConsolidationGroupClicked)
677
678        self.clearCompletedDownloads = QRadioButton(_('Clear completed downloads'))
679        self.keepCompletedDownloads = QRadioButton(_('Keep displaying completed downloads'))
680        self.promptCompletedDownloads = QRadioButton(_('Prompt for what to do'))
681        self.noConsolidationGroup.addButton(self.clearCompletedDownloads)
682        self.noConsolidationGroup.addButton(self.keepCompletedDownloads)
683        self.noConsolidationGroup.addButton(self.promptCompletedDownloads)
684        tip = _(
685            "Automatically clear the display of completed downloads whenever a new download "
686            "device is inserted."
687        )
688        self.clearCompletedDownloads.setToolTip(tip)
689        tip = _(
690            "Keep displaying completed downloads whenever a new download device is inserted."
691        )
692        self.keepCompletedDownloads.setToolTip(tip)
693        tip = _(
694            "Prompt whether to keep displaying completed downloads or clear them whenever a new "
695            "download device is inserted."
696        )
697        self.promptCompletedDownloads.setToolTip(tip)
698
699        if consolidation_implemented:
700            consolidationBoxLayout = QGridLayout()
701            consolidationBoxLayout.addWidget(self.consolidateIdentical, 0, 0, 1, 3)
702
703            consolidationBoxLayout.addWidget(self.treatRawJpegLabel, 1, 1, 1, 2)
704            consolidationBoxLayout.addWidget(self.oneRawJpeg, 2, 1, 1, 2)
705            consolidationBoxLayout.addWidget(self.twoRawJpeg, 3, 1, 1, 2)
706
707            consolidationBoxLayout.addWidget(self.markRawJpegLabel, 4, 2, 1, 1)
708            consolidationBoxLayout.addWidget(self.noJpegWhenRaw, 5, 2, 1, 1)
709            consolidationBoxLayout.addWidget(self.noRawWhenJpeg, 6, 2, 1, 1)
710            consolidationBoxLayout.addWidget(self.markRawJpeg, 7, 2, 1, 1, Qt.AlignTop)
711
712            consolidationBoxLayout.addWidget(self.noconsolidationLabel, 8, 0, 1, 3)
713            consolidationBoxLayout.addWidget(self.keepCompletedDownloads, 9, 0, 1, 3)
714            consolidationBoxLayout.addWidget(self.clearCompletedDownloads, 10, 0, 1, 3)
715            consolidationBoxLayout.addWidget(self.promptCompletedDownloads, 11, 0, 1, 3)
716
717            consolidationBoxLayout.setColumnMinimumWidth(0, checkbox_width)
718            consolidationBoxLayout.setColumnMinimumWidth(1, checkbox_width)
719
720            consolidationBoxLayout.setRowMinimumHeight(7, checkbox_width * 2)
721
722            self.consolidationBox.setLayout(consolidationBoxLayout)
723
724            self.consolidation = QWidget()
725            consolidationLayout = QVBoxLayout()
726            consolidationLayout.addWidget(self.consolidationBox)
727            consolidationLayout.addStretch()
728            consolidationLayout.setContentsMargins(0, 0, 0, 0)
729            consolidationLayout.setSpacing(18)
730            self.consolidation.setLayout(consolidationLayout)
731
732            self.setCompletedDownloadsValues()
733            self.setConsolidatedValues()
734            self.consolidateIdentical.stateChanged.connect(self.consolidateIdenticalChanged)
735            self.treatRawJpegGroup.buttonClicked.connect(self.treatRawJpegGroupClicked)
736            self.markRawJpegGroup.buttonClicked.connect(self.markRawJpegGroupClicked)
737
738        if not version_check_disabled():
739            self.newVersionBox = QGroupBox(_('Version Check'))
740            self.checkNewVersion = QCheckBox(_('Check for new version at startup'))
741            self.checkNewVersion.setToolTip(
742                _('Check for a new version of the program each time the program starts.')
743            )
744            self.includeDevRelease = QCheckBox(_('Include development releases'))
745            tip = _(
746                'Include alpha, beta and other development releases when checking for a new '
747                'version of the program.\n\nIf you are currently running a development version, '
748                'the check will always occur.'
749            )
750            self.includeDevRelease.setToolTip(tip)
751            self.setVersionCheckValues()
752            self.checkNewVersion.stateChanged.connect(self.checkNewVersionChanged)
753            self.includeDevRelease.stateChanged.connect(self.includeDevReleaseChanged)
754
755            newVersionLayout = QGridLayout()
756            newVersionLayout.addWidget(self.checkNewVersion, 0, 0, 1, 2)
757            newVersionLayout.addWidget(self.includeDevRelease, 1, 1, 1, 1)
758            newVersionLayout.setColumnMinimumWidth(0, checkbox_width)
759            self.newVersionBox.setLayout(newVersionLayout)
760
761        self.metadataBox = QGroupBox(_('Metadata'))
762        self.ignoreMdatatimeMtpDng = QCheckBox(_('Ignore DNG date/time metadata on MTP devices'))
763        tip = _(
764            "Ignore date/time metadata in DNG files located on MTP devices, and use the "
765            "file's modification time instead.\n\nUseful for devices like some phones and "
766            "tablets that create incorrect DNG metadata."
767        )
768        self.ignoreMdatatimeMtpDng.setToolTip(tip)
769
770        self.forceExiftool = QCheckBox(_('Read photo metadata using only ExifTool'))
771        tip = _(
772            'Use ExifTool instead of Exiv2 to read photo metadata and extract thumbnails.\n\n'
773            'The default is to use Exiv2, relying on ExifTool only when Exiv2 does not support\n'
774            'the file format being read.\n\n'
775            'Exiv2 is fast, accurate, and almost always reliable, but it crashes when extracting\n'
776            'metadata from a small number of files, such as DNG files produced by Leica M8\n'
777            'cameras.'
778        )
779
780        self.forceExiftool.setToolTip(tip)
781
782        self.setMetdataValues()
783        self.ignoreMdatatimeMtpDng.stateChanged.connect(self.ignoreMdatatimeMtpDngChanged)
784        self.forceExiftool.stateChanged.connect(self.forceExiftoolChanged)
785
786        metadataLayout = QVBoxLayout()
787        metadataLayout.addWidget(self.ignoreMdatatimeMtpDng)
788        metadataLayout.addWidget(self.forceExiftool)
789        self.metadataBox.setLayout(metadataLayout)
790
791        if not consolidation_implemented:
792            self.completedDownloadsBox = QGroupBox(_('Completed Downloads'))
793            completedDownloadsLayout = QVBoxLayout()
794            completedDownloadsLayout.addWidget(self.noconsolidationLabel)
795            completedDownloadsLayout.addWidget(self.keepCompletedDownloads)
796            completedDownloadsLayout.addWidget(self.clearCompletedDownloads)
797            completedDownloadsLayout.addWidget(self.promptCompletedDownloads)
798            self.completedDownloadsBox.setLayout(completedDownloadsLayout)
799            self.setCompletedDownloadsValues()
800
801        self.miscWidget = QWidget()
802        miscLayout = QVBoxLayout()
803        if not version_check_disabled():
804            miscLayout.addWidget(self.newVersionBox)
805        miscLayout.addWidget(self.metadataBox)
806        if not consolidation_implemented:
807            miscLayout.addWidget(self.completedDownloadsBox)
808        miscLayout.addStretch()
809        miscLayout.setContentsMargins(0, 0, 0, 0)
810        miscLayout.setSpacing(18)
811        self.miscWidget.setLayout(miscLayout)
812
813        self.panels.addWidget(self.devices)
814        self.panels.addWidget(self.language)
815        self.panels.addWidget(self.automation)
816        self.panels.addWidget(self.performance)
817        self.panels.addWidget(self.errorWidget)
818        self.panels.addWidget(self.warnings)
819        if consolidation_implemented:
820            self.panels.addWidget(self.consolidation)
821        self.panels.addWidget(self.miscWidget)
822
823        layout = QVBoxLayout()
824        self.setLayout(layout)
825        layout.setSpacing(layout.contentsMargins().left() * 2)
826        layout.setContentsMargins(18, 18, 18, 18)
827
828        buttons = QDialogButtonBox(
829            QDialogButtonBox.RestoreDefaults | QDialogButtonBox.Close | QDialogButtonBox.Help
830        )
831        translateDialogBoxButtons(buttons)
832        self.restoreButton = buttons.button(QDialogButtonBox.RestoreDefaults)  # type: QPushButton
833        self.restoreButton.clicked.connect(self.restoreDefaultsClicked)
834        self.helpButton = buttons.button(QDialogButtonBox.Help)  # type: QPushButton
835        self.helpButton.clicked.connect(self.helpButtonClicked)
836        self.helpButton.setToolTip(_('Get help online...'))
837        self.closeButton = buttons.button(QDialogButtonBox.Close)   # type: QPushButton
838        self.closeButton.clicked.connect(self.close)
839
840        controlsLayout = QHBoxLayout()
841        controlsLayout.addWidget(self.chooser)
842        controlsLayout.addWidget(self.panels)
843
844        controlsLayout.setStretch(0, 0)
845        controlsLayout.setStretch(1, 1)
846        controlsLayout.setSpacing(layout.contentsMargins().left())
847
848        layout.addLayout(controlsLayout)
849        layout.addWidget(buttons)
850
851        self.device_right_side_buttons = (
852            self.removeDevice, self.removeAllDevice, self.addPath, self.removePath,
853            self.removeAllPath
854        )
855
856        self.device_list_widgets = (self.knownDevices, self.ignoredPaths)
857        self.chooser.setCurrentRow(0)
858
859    def reject(self) -> None:
860        # If not called, rejecting this dialog will cause Rapid Photo Downloader to crash
861        self.close()
862
863    def _addItems(self, pref_list: str, pref_type: int) -> None:
864        if self.prefs.list_not_empty(key=pref_list):
865            for value in self.prefs[pref_list]:
866                QListWidgetItem(value, self.knownDevices, pref_type)
867
868    def setDeviceWidgetValues(self) -> None:
869        self.onlyExternal.setChecked(self.prefs.only_external_mounts)
870        self.scanSpecificFolders.setChecked(self.prefs.scan_specific_folders)
871        self.setFoldersToScanWidgetValues()
872        self.knownDevices.clear()
873        self._addItems('volume_whitelist', KnownDeviceType.volume_whitelist)
874        self._addItems('volume_blacklist', KnownDeviceType.volume_blacklist)
875        self._addItems('camera_blacklist', KnownDeviceType.camera_blacklist)
876        if self.knownDevices.count():
877            self.knownDevices.setCurrentRow(0)
878        self.removeDevice.setEnabled(self.knownDevices.count())
879        self.removeAllDevice.setEnabled(self.knownDevices.count())
880        self.setIgnorePathWidgetValues()
881
882    def setLanguageWidgetValues(self) -> None:
883        # Translators: this is an option when the user chooses the language to use for
884        # Rapid Photo Downloader and it allows them to reset it back to whatever their
885        # system language settings are. The < and > are not HTML codes. They are there
886        # simply to set this choice apart from all the other choices in the drop down list.
887        # You can keep the < > if you like, or replace them with whatever you typically use
888        # in your language.
889        self.languages.addItem(_('<System Language>'), system_language)
890        for code, language in available_languages(display_locale_code=self.prefs.language):
891            self.languages.addItem(language, code)
892        value = self.prefs.language
893        if value:
894            index = self.languages.findData(value)
895            self.languages.setCurrentIndex(index)
896
897    def setFoldersToScanWidgetValues(self) -> None:
898        self.foldersToScan.clear()
899        if self.prefs.list_not_empty('folders_to_scan'):
900            self.foldersToScan.addItems(self.prefs.folders_to_scan)
901            self.foldersToScan.setCurrentRow(0)
902        self.setFoldersToScanState()
903
904    def setFoldersToScanState(self) -> None:
905        scan_specific = self.prefs.scan_specific_folders
906        self.foldersToScanLabel.setEnabled(scan_specific)
907        self.foldersToScan.setEnabled(scan_specific)
908        self.addFolderToScan.setEnabled(scan_specific)
909        self.removeFolderToScan.setEnabled(scan_specific and self.foldersToScan.count() > 1)
910
911    def setIgnorePathWidgetValues(self) -> None:
912        self.ignoredPaths.clear()
913        if self.prefs.list_not_empty('ignored_paths'):
914            self.ignoredPaths.addItems(self.prefs.ignored_paths)
915            self.ignoredPaths.setCurrentRow(0)
916        self.removePath.setEnabled(self.ignoredPaths.count())
917        self.removeAllPath.setEnabled(self.ignoredPaths.count())
918        self.ignoredPathsRe.setChecked(self.prefs.use_re_ignored_paths)
919
920    def setAutomationWidgetValues(self) -> None:
921        self.autoDownloadStartup.setChecked(self.prefs.auto_download_at_startup)
922        self.autoDownloadInsertion.setChecked(self.prefs.auto_download_upon_device_insertion)
923        self.autoEject.setChecked(self.prefs.auto_unmount)
924        self.autoExit.setChecked(self.prefs.auto_exit)
925        self.setAutoExitErrorState()
926
927    def setAutoExitErrorState(self) -> None:
928        if self.prefs.auto_exit:
929            self.autoExitError.setChecked(self.prefs.auto_exit_force)
930            self.autoExitError.setEnabled(True)
931        else:
932            self.autoExitError.setChecked(False)
933            self.autoExitError.setEnabled(False)
934
935    def setPerformanceValues(self, check_boxes_only: bool=False) -> None:
936        self.generateThumbnails.setChecked(self.prefs.generate_thumbnails)
937        self.useThumbnailCache.setChecked(
938            self.prefs.use_thumbnail_cache and self.prefs.generate_thumbnails
939        )
940        self.fdoThumbnails.setChecked(
941            self.prefs.save_fdo_thumbnails and self.prefs.generate_thumbnails
942        )
943
944        if not check_boxes_only:
945            available = available_cpu_count(physical_only=True)
946            self.maxCores.addItems(str(i + 1) for i in range(0, available))
947            self.maxCores.setCurrentText(str(self.prefs.max_cpu_cores))
948
949    def setPerfomanceEnabled(self) -> None:
950        enable = self.prefs.generate_thumbnails
951        self.useThumbnailCache.setEnabled(enable)
952        self.fdoThumbnails.setEnabled(enable)
953        self.maxCores.setEnabled(enable)
954        self.coresLabel.setEnabled(enable)
955
956    def setCacheValues(self) -> None:
957        self.thumbnailNumber.setText(thousands(self.thumbnail_cache.no_thumbnails()))
958        self.thumbnailSqlSize.setText(format_size_for_user(self.thumbnail_cache.db_size()))
959        self.thumbnailCacheDaysKeep.setValue(self.prefs.keep_thumbnails_days)
960
961    @pyqtSlot('PyQt_PyObject')
962    def setCacheSize(self, size: int) -> None:
963        self.thumbnailCacheSize.setText(format_size_for_user(size))
964
965    def setErrorHandingValues(self) -> None:
966        if self.prefs.conflict_resolution == int(ConflictResolution.skip):
967            self.skipDownload.setChecked(True)
968        else:
969            self.addIdentifier.setChecked(True)
970        if self.prefs.backup_duplicate_overwrite:
971            self.overwriteBackup.setChecked(True)
972        else:
973            self.skipBackup.setChecked(True)
974
975    def setWarningValues(self) -> None:
976        self.warnDownloadingAll.setChecked(self.prefs.warn_downloading_all)
977        if self.prefs.backup_files:
978            self.warnBackupProblem.setChecked(self.prefs.warn_backup_problem)
979        else:
980            self.warnBackupProblem.setChecked(False)
981        self.warnMissingLibraries.setChecked(self.prefs.warn_broken_or_missing_libraries)
982        self.warnMetadata.setChecked(self.prefs.warn_fs_metadata_error)
983        self.warnUnhandledFiles.setChecked(self.prefs.warn_unhandled_files)
984        self.setAddExceptFilesValues()
985
986        self.setBackupWarningEnabled()
987        self.setUnhandledWarningEnabled()
988
989    def setAddExceptFilesValues(self) -> None:
990        self.exceptTheseFiles.clear()
991        if self.prefs.list_not_empty('ignore_unhandled_file_exts'):
992            self.exceptTheseFiles.addItems(self.prefs.ignore_unhandled_file_exts)
993            self.exceptTheseFiles.setCurrentRow(0)
994
995    def setBackupWarningEnabled(self) -> None:
996        self.warnBackupProblem.setEnabled(self.prefs.backup_files)
997
998    def setUnhandledWarningEnabled(self) -> None:
999        enabled = self.prefs.warn_unhandled_files
1000        for widget in (self.exceptTheseFilesLabel, self.exceptTheseFiles, self.addExceptFiles):
1001            widget.setEnabled(enabled)
1002        count = bool(self.exceptTheseFiles.count())
1003        for widget in (self.removeExceptFiles, self.removeAllExceptFiles):
1004            widget.setEnabled(enabled and count)
1005
1006    def setConsolidatedValues(self) -> None:
1007        enabled = self.prefs.consolidate_identical
1008        self.consolidateIdentical.setChecked(enabled)
1009
1010        self.setTreatRawJpeg()
1011        self.setMarkRawJpeg()
1012
1013        if enabled:
1014            # Must turn off the exclusive button group feature, or else
1015            # it's impossible to set all the radio buttons to False
1016            self.noConsolidationGroup.setExclusive(False)
1017            for widget in (
1018                    self.clearCompletedDownloads,
1019                    self.keepCompletedDownloads, self.promptCompletedDownloads):
1020                widget.setChecked(False)
1021            # Now turn it back on again
1022            self.noConsolidationGroup.setExclusive(True)
1023        else:
1024            self.setCompletedDownloadsValues()
1025
1026        self.setConsolidatedEnabled()
1027
1028    def setTreatRawJpeg(self) -> None:
1029        if self.prefs.consolidate_identical:
1030            if self.prefs.treat_raw_jpeg == int(TreatRawJpeg.one_photo):
1031                self.oneRawJpeg.setChecked(True)
1032            else:
1033                self.twoRawJpeg.setChecked(True)
1034        else:
1035            # Must turn off the exclusive button group feature, or else
1036            # it's impossible to set all the radio buttons to False
1037            self.treatRawJpegGroup.setExclusive(False)
1038            self.oneRawJpeg.setChecked(False)
1039            self.twoRawJpeg.setChecked(False)
1040            # Now turn it back on again
1041            self.treatRawJpegGroup.setExclusive(True)
1042
1043    def setMarkRawJpeg(self) -> None:
1044        if self.prefs.consolidate_identical and self.twoRawJpeg.isChecked():
1045            v = self.prefs.mark_raw_jpeg
1046            if v == int(MarkRawJpeg.no_jpeg):
1047                self.noJpegWhenRaw.setChecked(True)
1048            elif v == int(MarkRawJpeg.no_raw):
1049                self.noRawWhenJpeg.setChecked(True)
1050            else:
1051                self.markRawJpeg.setChecked(True)
1052        else:
1053            # Must turn off the exclusive button group feature, or else
1054            # it's impossible to set all the radio buttons to False
1055            self.markRawJpegGroup.setExclusive(False)
1056            for widget in (self.noJpegWhenRaw, self.noRawWhenJpeg, self.markRawJpeg):
1057                widget.setChecked(False)
1058            # Now turn it back on again
1059            self.markRawJpegGroup.setExclusive(True)
1060
1061    def setConsolidatedEnabled(self) -> None:
1062        enabled = self.prefs.consolidate_identical
1063
1064        for widget in self.treatRawJpegGroup.buttons():
1065            widget.setEnabled(enabled)
1066        self.treatRawJpegLabel.setEnabled(enabled)
1067
1068        self.setMarkRawJpegEnabled()
1069
1070        for widget in (
1071                self.noconsolidationLabel, self.clearCompletedDownloads,
1072                self.keepCompletedDownloads, self.promptCompletedDownloads):
1073            widget.setEnabled(not enabled)
1074
1075    def setMarkRawJpegEnabled(self) -> None:
1076        mark_enabled = self.prefs.consolidate_identical and self.twoRawJpeg.isChecked()
1077        for widget in self.markRawJpegGroup.buttons():
1078            widget.setEnabled(mark_enabled)
1079        self.markRawJpegLabel.setEnabled(mark_enabled)
1080
1081    def setVersionCheckValues(self) -> None:
1082        self.checkNewVersion.setChecked(self.prefs.check_for_new_versions)
1083        self.includeDevRelease.setChecked(
1084            self.prefs.include_development_release or self.is_prerelease
1085        )
1086        self.setVersionCheckEnabled()
1087
1088    def setVersionCheckEnabled(self) -> None:
1089        self.includeDevRelease.setEnabled(
1090            not(self.is_prerelease or not self.prefs.check_for_new_versions)
1091        )
1092
1093    def setMetdataValues(self) -> None:
1094        self.ignoreMdatatimeMtpDng.setChecked(self.prefs.ignore_mdatatime_for_mtp_dng)
1095        self.forceExiftool.setChecked(self.prefs.force_exiftool)
1096
1097    def setCompletedDownloadsValues(self) -> None:
1098        s = self.prefs.completed_downloads
1099        if s == int(CompletedDownloads.keep):
1100            self.keepCompletedDownloads.setChecked(True)
1101        elif s == int(CompletedDownloads.clear):
1102            self.clearCompletedDownloads.setChecked(True)
1103        else:
1104            self.promptCompletedDownloads.setChecked(True)
1105
1106    @pyqtSlot(int)
1107    def onlyExternalChanged(self, state: int) -> None:
1108        self.prefs.only_external_mounts = state == Qt.Checked
1109        if self.rapidApp is not None:
1110            self.rapidApp.search_for_devices_again = True
1111
1112    @pyqtSlot(int)
1113    def noDcimChanged(self, state: int) -> None:
1114        self.prefs.scan_specific_folders = state == Qt.Checked
1115        self.setFoldersToScanState()
1116        if self.rapidApp is not None:
1117            self.rapidApp.scan_non_cameras_again = True
1118
1119    @pyqtSlot(int)
1120    def ignoredPathsReChanged(self, state: int) -> None:
1121        self.prefs.use_re_ignored_paths = state == Qt.Checked
1122        if self.rapidApp is not None:
1123            self.rapidApp.scan_all_again = True
1124
1125    def _equalizeWidgetWidth(self, widget_list) -> None:
1126        max_width = max(widget.width() for widget in widget_list)
1127        for widget in widget_list:
1128            widget.setFixedWidth(max_width)
1129
1130    def showEvent(self, e: QShowEvent):
1131        self.chooser.minimum_width = self.restoreButton.width()
1132        self._equalizeWidgetWidth(self.device_right_side_buttons)
1133        self._equalizeWidgetWidth(self.device_list_widgets)
1134        super().showEvent(e)
1135
1136    @pyqtSlot(int)
1137    def rowChanged(self, row: int) -> None:
1138        self.panels.setCurrentIndex(row)
1139        # Translators: substituted value is a description for the set of preferences
1140        # shown in the preference dialog window, e.g. Devices, Automation, etc.
1141        # This string is shown in a tooltip for the "Restore Defaults" button
1142        self.restoreButton.setToolTip(_('Restores default %s preference values') %
1143                                      self.chooser_items[row])
1144
1145    @pyqtSlot()
1146    def removeDeviceClicked(self) -> None:
1147        row = self.knownDevices.currentRow()
1148        item = self.knownDevices.takeItem(row)  # type: QListWidgetItem
1149        known_device_type = item.type()
1150        if known_device_type == KnownDeviceType.volume_whitelist:
1151            self.prefs.del_list_value('volume_whitelist', item.text())
1152        elif known_device_type == KnownDeviceType.volume_blacklist:
1153            self.prefs.del_list_value('volume_blacklist', item.text())
1154        else:
1155            assert known_device_type == KnownDeviceType.camera_blacklist
1156            self.prefs.del_list_value('camera_blacklist', item.text())
1157
1158        self.removeDevice.setEnabled(self.knownDevices.count())
1159        self.removeAllDevice.setEnabled(self.knownDevices.count())
1160
1161        if self.rapidApp is not None:
1162            self.rapidApp.search_for_devices_again = True
1163
1164    @pyqtSlot()
1165    def removeAllDeviceClicked(self) -> None:
1166        self.knownDevices.clear()
1167        self.prefs.volume_whitelist = ['']
1168        self.prefs.volume_blacklist = ['']
1169        self.prefs.camera_blacklist = ['']
1170        self.removeDevice.setEnabled(False)
1171        self.removeAllDevice.setEnabled(False)
1172
1173        if self.rapidApp is not None:
1174            self.rapidApp.search_for_devices_again = True
1175
1176    @pyqtSlot()
1177    def removeFolderToScanClicked(self) -> None:
1178        row = self.foldersToScan.currentRow()
1179        if row >= 0 and self.foldersToScan.count() > 1:
1180            item = self.foldersToScan.takeItem(row)
1181            self.prefs.del_list_value('folders_to_scan', item.text())
1182            self.removeFolderToScan.setEnabled(self.foldersToScan.count() > 1)
1183
1184            if self.rapidApp is not None:
1185                self.rapidApp.scan_all_again = True
1186
1187    @pyqtSlot()
1188    def addFolderToScanClicked(self) -> None:
1189        dlg = FoldersToScanDialog(prefs=self.prefs, parent=self)
1190        if dlg.exec():
1191            self.setFoldersToScanWidgetValues()
1192
1193            if self.rapidApp is not None:
1194                self.rapidApp.scan_all_again = True
1195
1196    @pyqtSlot()
1197    def removePathClicked(self) -> None:
1198        row = self.ignoredPaths.currentRow()
1199        if row >= 0:
1200            item = self.ignoredPaths.takeItem(row)
1201            self.prefs.del_list_value('ignored_paths', item.text())
1202            self.removePath.setEnabled(self.ignoredPaths.count())
1203            self.removeAllPath.setEnabled(self.ignoredPaths.count())
1204
1205            if self.rapidApp is not None:
1206                self.rapidApp.scan_all_again = True
1207
1208    @pyqtSlot()
1209    def removeAllPathClicked(self) -> None:
1210        self.ignoredPaths.clear()
1211        self.prefs.ignored_paths = ['']
1212        self.removePath.setEnabled(False)
1213        self.removeAllPath.setEnabled(False)
1214
1215        if self.rapidApp is not None:
1216            self.rapidApp.scan_all_again = True
1217
1218    @pyqtSlot()
1219    def addPathClicked(self) -> None:
1220        dlg = IgnorePathDialog(prefs=self.prefs, parent=self)
1221        if dlg.exec():
1222            self.setIgnorePathWidgetValues()
1223
1224            if self.rapidApp is not None:
1225                self.rapidApp.scan_all_again = True
1226
1227    @pyqtSlot()
1228    def ignorePathsReLabelClicked(self) -> None:
1229        self.ignoredPathsRe.click()
1230
1231    @pyqtSlot(int)
1232    def languagesChanged(self, index: int) -> None:
1233        if index == 0:
1234            self.prefs.language = ''
1235            logging.info("Resetting user interface language to system default")
1236        elif index > 0:
1237            self.prefs.language = self.languages.currentData()
1238            logging.info("Setting user interface language to %s", self.prefs.language)
1239
1240    @pyqtSlot(int)
1241    def autoDownloadStartupChanged(self, state: int) -> None:
1242        self.prefs.auto_download_at_startup = state == Qt.Checked
1243
1244    @pyqtSlot(int)
1245    def autoDownloadInsertionChanged(self, state: int) -> None:
1246        self.prefs.auto_download_upon_device_insertion = state == Qt.Checked
1247
1248    @pyqtSlot(int)
1249    def autoEjectChanged(self, state: int) -> None:
1250        self.prefs.auto_unmount = state == Qt.Checked
1251
1252    @pyqtSlot(int)
1253    def autoExitChanged(self, state: int) -> None:
1254        auto_exit = state == Qt.Checked
1255        self.prefs.auto_exit = auto_exit
1256        self.setAutoExitErrorState()
1257        if not auto_exit:
1258            self.prefs.auto_exit_force = False
1259
1260    @pyqtSlot(int)
1261    def autoExitErrorChanged(self, state: int) -> None:
1262        self.prefs.auto_exit_force = state == Qt.Checked
1263
1264    @pyqtSlot(int)
1265    def generateThumbnailsChanged(self, state: int) -> None:
1266        self.prefs.generate_thumbnails = state == Qt.Checked
1267        self.setPerformanceValues(check_boxes_only=True)
1268        self.setPerfomanceEnabled()
1269
1270    @pyqtSlot(int)
1271    def useThumbnailCacheChanged(self, state: int) -> None:
1272        if self.prefs.generate_thumbnails:
1273            self.prefs.use_thumbnail_cache = state == Qt.Checked
1274
1275    @pyqtSlot(int)
1276    def fdoThumbnailsChanged(self, state: int) -> None:
1277        if self.prefs.generate_thumbnails:
1278            self.prefs.save_fdo_thumbnails = state == Qt.Checked
1279
1280    @pyqtSlot(int)
1281    def thumbnailCacheDaysKeepChanged(self, value: int) -> None:
1282        self.prefs.keep_thumbnails_days = value
1283
1284    @pyqtSlot(int)
1285    def maxCoresChanged(self, index: int) -> None:
1286        if index >= 0:
1287            self.prefs.max_cpu_cores = int(self.maxCores.currentText())
1288
1289    @pyqtSlot()
1290    def purgeCacheClicked(self) -> None:
1291        message = _(
1292            'Do you want to purge the thumbnail cache? The cache will be purged when the '
1293            'program is next started.'
1294        )
1295        msgBox = standardMessageBox(
1296            parent=self, title=_('Purge Thumbnail Cache'), message=message,
1297            standardButtons=QMessageBox.Yes | QMessageBox.No, rich_text=False
1298        )
1299
1300        if msgBox.exec_() == QMessageBox.Yes:
1301            self.prefs.purge_thumbnails = True
1302            self.prefs.optimize_thumbnail_db = False
1303        else:
1304            self.prefs.purge_thumbnails = False
1305
1306    @pyqtSlot()
1307    def optimizeCacheClicked(self) -> None:
1308        message = _(
1309            'Do you want to optimize the thumbnail cache? The cache will be optimized when '
1310            'the program is next started.'
1311        )
1312        msgBox = standardMessageBox(
1313            parent=self, title=_('Optimize Thumbnail Cache'), message=message,
1314            standardButtons=QMessageBox.Yes|QMessageBox.No, rich_text=False
1315        )
1316        if msgBox.exec_() == QMessageBox.Yes:
1317            self.prefs.purge_thumbnails = False
1318            self.prefs.optimize_thumbnail_db = True
1319        else:
1320            self.prefs.optimize_thumbnail_db = False
1321
1322    @pyqtSlot(QAbstractButton)
1323    def downloadErrorGroupClicked(self, button: QRadioButton) -> None:
1324        if self.downloadErrorGroup.checkedButton() == self.skipDownload:
1325            self.prefs.conflict_resolution = int(ConflictResolution.skip)
1326        else:
1327            self.prefs.conflict_resolution = int(ConflictResolution.add_identifier)
1328
1329    @pyqtSlot(QAbstractButton)
1330    def backupErrorGroupClicked(self, button: QRadioButton) -> None:
1331        self.prefs.backup_duplicate_overwrite = self.backupErrorGroup.checkedButton() == \
1332                                                self.overwriteBackup
1333
1334    @pyqtSlot(int)
1335    def warnDownloadingAllChanged(self, state: int) -> None:
1336        self.prefs.warn_downloading_all = state == Qt.Checked
1337
1338    @pyqtSlot(int)
1339    def warnBackupProblemChanged(self, state: int) -> None:
1340        self.prefs.warn_backup_problem = state == Qt.Checked
1341
1342    @pyqtSlot(int)
1343    def warnMissingLibrariesChanged(self, state: int) -> None:
1344        self.prefs.warn_broken_or_missing_libraries = state == Qt.Checked
1345
1346    @pyqtSlot(int)
1347    def warnMetadataChanged(self, state: int) -> None:
1348        self.prefs.warn_fs_metadata_error = state == Qt.Checked
1349
1350    @pyqtSlot(int)
1351    def warnUnhandledFilesChanged(self, state: int) -> None:
1352        self.prefs.warn_unhandled_files = state == Qt.Checked
1353        self.setUnhandledWarningEnabled()
1354
1355    @pyqtSlot()
1356    def addExceptFilesClicked(self) -> None:
1357        dlg = ExceptFileExtDialog(prefs=self.prefs, parent=self)
1358        if dlg.exec():
1359            self.setAddExceptFilesValues()
1360
1361    @pyqtSlot()
1362    def removeExceptFilesClicked(self) -> None:
1363        row = self.exceptTheseFiles.currentRow()
1364        if row >= 0:
1365            item = self.exceptTheseFiles.takeItem(row)
1366            self.prefs.del_list_value('ignore_unhandled_file_exts', item.text())
1367            self.removeExceptFiles.setEnabled(self.exceptTheseFiles.count())
1368            self.removeAllExceptFiles.setEnabled(self.exceptTheseFiles.count())
1369
1370    @pyqtSlot()
1371    def removeAllExceptFilesClicked(self) -> None:
1372        self.exceptTheseFiles.clear()
1373        self.prefs.ignore_unhandled_file_exts = ['']
1374        self.removeExceptFiles.setEnabled(False)
1375        self.removeAllExceptFiles.setEnabled(False)
1376
1377    @pyqtSlot(int)
1378    def consolidateIdenticalChanged(self, state: int) -> None:
1379        self.prefs.consolidate_identical = state == Qt.Checked
1380        self.setConsolidatedValues()
1381        self.setConsolidatedEnabled()
1382
1383    @pyqtSlot(QAbstractButton)
1384    def treatRawJpegGroupClicked(self, button: QRadioButton) -> None:
1385        if button == self.oneRawJpeg:
1386            self.prefs.treat_raw_jpeg = int(TreatRawJpeg.one_photo)
1387        else:
1388            self.prefs.treat_raw_jpeg = int(TreatRawJpeg.two_photos)
1389        self.setMarkRawJpeg()
1390        self.setMarkRawJpegEnabled()
1391
1392    @pyqtSlot(QAbstractButton)
1393    def markRawJpegGroupClicked(self, button: QRadioButton) -> None:
1394        if button == self.noJpegWhenRaw:
1395            self.prefs.mark_raw_jpeg = int(MarkRawJpeg.no_jpeg)
1396        elif button == self.noRawWhenJpeg:
1397            self.prefs.mark_raw_jpeg = int(MarkRawJpeg.no_raw)
1398        else:
1399            self.prefs.mark_raw_jpeg = int(MarkRawJpeg.both)
1400
1401    @pyqtSlot(int)
1402    def noJpegWhenRawChanged(self, state: int) -> None:
1403        self.prefs.do_not_mark_jpeg = state == Qt.Checked
1404
1405    @pyqtSlot(int)
1406    def noRawWhenJpegChanged(self, state: int) -> None:
1407        self.prefs.do_not_mark_raw = state == Qt.Checked
1408
1409    @pyqtSlot(int)
1410    def checkNewVersionChanged(self, state: int) -> None:
1411        do_check = state == Qt.Checked
1412        self.prefs.check_for_new_versions = do_check
1413        self.setVersionCheckEnabled()
1414
1415    @pyqtSlot(int)
1416    def includeDevReleaseChanged(self, state: int) -> None:
1417        self.prefs.include_development_release = state == Qt.Checked
1418
1419    @pyqtSlot(int)
1420    def ignoreMdatatimeMtpDngChanged(self, state: int) -> None:
1421        self.prefs.ignore_mdatatime_for_mtp_dng = state == Qt.Checked
1422
1423    @pyqtSlot(int)
1424    def forceExiftoolChanged(self, state: int) -> None:
1425        self.prefs.force_exiftool = state == Qt.Checked
1426
1427    @pyqtSlot(QAbstractButton)
1428    def noConsolidationGroupClicked(self, button: QRadioButton) -> None:
1429        if button == self.keepCompletedDownloads:
1430            self.prefs.completed_downloads = int(CompletedDownloads.keep)
1431        elif button == self.clearCompletedDownloads:
1432            self.prefs.completed_downloads = int(CompletedDownloads.clear)
1433        else:
1434            self.prefs.completed_downloads = int(CompletedDownloads.prompt)
1435
1436    @pyqtSlot()
1437    def restoreDefaultsClicked(self) -> None:
1438        row = self.chooser.currentRow()
1439        if row == 0:
1440            for value in ('only_external_mounts', 'scan_specific_folders', 'folders_to_scan',
1441                           'ignored_paths', 'use_re_ignored_paths'):
1442                self.prefs.restore(value)
1443            self.removeAllDeviceClicked()
1444            self.setDeviceWidgetValues()
1445        elif row == 1:
1446            self.prefs.restore('language')
1447            self.languages.setCurrentIndex(0)
1448        elif row == 2:
1449            for value in ('auto_download_at_startup', 'auto_download_upon_device_insertion',
1450                          'auto_unmount', 'auto_exit', 'auto_exit_force'):
1451                self.prefs.restore(value)
1452            self.setAutomationWidgetValues()
1453        elif row == 3:
1454            for value in ('generate_thumbnails', 'use_thumbnail_cache', 'save_fdo_thumbnails',
1455                          'max_cpu_cores', 'keep_thumbnails_days'):
1456                self.prefs.restore(value)
1457            self.setPerformanceValues(check_boxes_only=True)
1458            self.maxCores.setCurrentText(str(self.prefs.max_cpu_cores))
1459            self.setPerfomanceEnabled()
1460            self.thumbnailCacheDaysKeep.setValue(self.prefs.keep_thumbnails_days)
1461        elif row == 4:
1462            for value in ('conflict_resolution', 'backup_duplicate_overwrite'):
1463                self.prefs.restore(value)
1464            self.setErrorHandingValues()
1465        elif row == 5:
1466            for value in (
1467                    'warn_downloading_all', 'warn_backup_problem',
1468                    'warn_broken_or_missing_libraries', 'warn_fs_metadata_error',
1469                    'warn_unhandled_files', 'ignore_unhandled_file_exts'):
1470                self.prefs.restore(value)
1471            self.setWarningValues()
1472        elif row == 6 and consolidation_implemented:
1473            for value in (
1474                    'completed_downloads', 'consolidate_identical', 'one_raw_jpeg',
1475                    'do_not_mark_jpeg', 'do_not_mark_raw'):
1476                self.prefs.restore(value)
1477            self.setConsolidatedValues()
1478        elif (row == 7 and consolidation_implemented) or (row == 6 and not
1479                consolidation_implemented):
1480            if not version_check_disabled():
1481                self.prefs.restore('check_for_new_versions')
1482            for value in ('include_development_release', 'ignore_mdatatime_for_mtp_dng',
1483                          'force_exiftool'):
1484                self.prefs.restore(value)
1485            if not consolidation_implemented:
1486                self.prefs.restore('completed_downloads')
1487            if not version_check_disabled():
1488                self.setVersionCheckValues()
1489            self.setMetdataValues()
1490            if not consolidation_implemented:
1491                self.setCompletedDownloadsValues()
1492
1493    @pyqtSlot()
1494    def helpButtonClicked(self) -> None:
1495        row = self.chooser.currentRow()
1496        if row == 0:
1497            location = '#devicepreferences'
1498        elif row == 1:
1499            location = '#languagepreferences'
1500        elif row == 2:
1501            location = '#automationpreferences'
1502        elif row == 3:
1503            location = '#thumbnailpreferences'
1504        elif row == 4:
1505            location = '#errorhandlingpreferences'
1506        elif row == 5:
1507            location = '#warningpreferences'
1508        elif row == 6:
1509            if consolidation_implemented:
1510                location = '#consolidationpreferences'
1511            else:
1512                location = '#miscellaneousnpreferences'
1513        elif row == 7:
1514            location = '#miscellaneousnpreferences'
1515        else:
1516            location = ''
1517
1518        webbrowser.open_new_tab(
1519            "https://www.damonlynch.net/rapid/documentation/{}".format(location)
1520        )
1521
1522    def closeEvent(self, event: QCloseEvent) -> None:
1523        self.cacheSizeThread.quit()
1524        self.cacheSizeThread.wait(1000)
1525        event.accept()
1526
1527
1528class PreferenceAddDialog(QDialog):
1529    """
1530    Base class for adding value to pref list
1531    """
1532    def __init__(self, prefs: Preferences,
1533                 title: str,
1534                 instruction: str,
1535                 label: str,
1536                 pref_value: str,
1537                 parent=None) -> None:
1538        super().__init__(parent=parent)
1539
1540        self.prefs = prefs
1541        self.pref_value = pref_value
1542
1543        self.setWindowTitle(title)
1544
1545        self.instructionLabel = QLabel(instruction)
1546        self.instructionLabel.setWordWrap(False)
1547        layout = QVBoxLayout()
1548        self.setLayout(layout)
1549
1550        self.valueEdit = QLineEdit()
1551        formLayout = QFormLayout()
1552        formLayout.addRow(label, self.valueEdit)
1553
1554        buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok)
1555        translateDialogBoxButtons(buttons)
1556        buttons.rejected.connect(self.reject)
1557        buttons.accepted.connect(self.accept)
1558
1559        layout.addWidget(self.instructionLabel)
1560        layout.addLayout(formLayout)
1561        layout.addWidget(buttons)
1562
1563    def accept(self):
1564        value = self.valueEdit.text()
1565        if value:
1566            self.prefs.add_list_value(self.pref_value, value)
1567        super().accept()
1568
1569
1570class FoldersToScanDialog(PreferenceAddDialog):
1571    """
1572    Dialog prompting for a folder on devices to scan for photos and videos
1573    """
1574    def __init__(self, prefs: Preferences, parent=None) -> None:
1575        super().__init__(
1576            prefs=prefs,
1577            title=_('Enter a Folder to Scan'),
1578            instruction=_('Specify a folder that will be scanned for photos and videos'),
1579            label=_('Folder:'),
1580            pref_value='folders_to_scan',
1581            parent=parent
1582        )
1583
1584
1585class IgnorePathDialog(PreferenceAddDialog):
1586    """
1587    Dialog prompting for a path to ignore when scanning devices
1588    """
1589
1590    def __init__(self, prefs: Preferences, parent=None) -> None:
1591        super().__init__(
1592            prefs=prefs,
1593            title=_('Enter a Path to Ignore'),
1594            instruction=_('Specify a path that will never be scanned for photos or videos'),
1595            label=_('Path:'),
1596            pref_value='ignored_paths',
1597            parent=parent
1598        )
1599
1600
1601class ExceptFileExtDialog(PreferenceAddDialog):
1602    """
1603    Dialog prompting for file extensions never to warn about
1604    """
1605
1606    def __init__(self, prefs: Preferences, parent=None) -> None:
1607        super().__init__(
1608            prefs=prefs,
1609            title=_('Enter a File Extension'),
1610            instruction=_('Specify a file extension (without the leading dot)'),
1611            label=_('Extension:'),
1612            pref_value='ignore_unhandled_file_exts',
1613            parent=parent
1614        )
1615
1616    def exts(self, exts: List[str]) -> str:
1617        return make_internationalized_list([ext.upper() for ext in exts])
1618
1619    def accept(self):
1620        value = self.valueEdit.text()
1621        if value:
1622            while value.startswith('.'):
1623                value = value[1:]
1624            value = value.upper()
1625            if value.lower() in ALL_KNOWN_EXTENSIONS:
1626                title = _('Invalid File Extension')
1627                # Translators: please do not change HTML codes like <br>, <i>, </i>, or <b>, </b>
1628                # etc.
1629                message = _(
1630                    "The file extension <b>%s</b> is recognized by Rapid Photo Downloader, so it "
1631                    "makes no sense to warn about its presence."
1632                ) % value
1633                # Translators: %(variable)s represents Python code, not a plural of the term
1634                # variable. You must keep the %(variable)s untranslated, or the program will
1635                # crash.
1636                details = _(
1637                    'Recognized file types:\n\n'
1638                    'Photos:\n%(photos)s\n\nVideos:\n%(videos)s\n\n'
1639                    'Audio:\n%(audio)s\n\nOther:\n%(other)s'
1640                ) % dict(
1641                    photos=self.exts(PHOTO_EXTENSIONS),
1642                    videos=self.exts(VIDEO_EXTENSIONS + VIDEO_THUMBNAIL_EXTENSIONS),
1643                    audio=self.exts(AUDIO_EXTENSIONS),
1644                    other=self.exts(['xmp'])
1645                )
1646                msgBox = standardMessageBox(
1647                    parent=self, title=title, message=message, rich_text=True,
1648                    standardButtons=QMessageBox.Ok, iconType=QMessageBox.Information
1649                )
1650                msgBox.setDetailedText(details)
1651                msgBox.exec()
1652                self.valueEdit.setText(value)
1653                self.valueEdit.selectAll()
1654                return
1655            else:
1656                self.prefs.add_list_value(self.pref_value, value)
1657        QDialog.accept(self)
1658
1659
1660class CacheSize(QObject):
1661    size = pyqtSignal('PyQt_PyObject')  # don't convert python int to C++ int
1662
1663    @pyqtSlot()
1664    def start(self) -> None:
1665        self.thumbnail_cache = ThumbnailCacheSql(create_table_if_not_exists=False)
1666
1667    @pyqtSlot()
1668    def getCacheSize(self) -> None:
1669        self.size.emit(self.thumbnail_cache.cache_size())
1670
1671
1672if __name__ == '__main__':
1673
1674    # Application development test code:
1675
1676    app = QApplication([])
1677
1678    app.setOrganizationName("Rapid Photo Downloader")
1679    app.setOrganizationDomain("damonlynch.net")
1680    app.setApplicationName("Rapid Photo Downloader")
1681
1682    prefs = Preferences()
1683
1684    prefDialog = PreferencesDialog(prefs)
1685    prefDialog.show()
1686    app.exec_()
1687