1#!/usr/bin/env python3
2# Copyright (C) 2016-2020 Damon Lynch <damonlynch@gmail.com>
3
4# This file is part of Rapid Photo Downloader.
5#
6# Rapid Photo Downloader is free software: you can redistribute it and/or
7# modify it under the terms of the GNU General Public License as published by
8# the Free Software Foundation, either version 3 of the License, or
9# (at your option) any later version.
10#
11# Rapid Photo Downloader is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with Rapid Photo Downloader.  If not,
18# see <http://www.gnu.org/licenses/>.
19
20"""
21Dialog for editing download subfolder structure and file renaming
22"""
23
24__author__ = 'Damon Lynch'
25__copyright__ = "Copyright 2016-2020, Damon Lynch"
26
27from typing import Dict, Optional, List, Union, Tuple, Sequence
28import webbrowser
29import datetime
30import copy
31import logging
32
33
34
35from PyQt5.QtWidgets import (
36    QTextEdit, QApplication, QComboBox, QPushButton, QLabel, QDialog, QDialogButtonBox,
37    QVBoxLayout, QFormLayout, QGridLayout, QGroupBox, QScrollArea, QWidget, QFrame, QStyle,
38    QSizePolicy, QLineEdit, QMessageBox
39)
40from PyQt5.QtGui import (
41    QTextCharFormat, QFont, QTextCursor, QMouseEvent, QSyntaxHighlighter, QTextDocument, QBrush,
42    QColor, QFontMetrics, QKeyEvent, QResizeEvent, QStandardItem, QWheelEvent
43)
44from PyQt5.QtCore import (Qt, pyqtSlot, QSignalMapper, QSize, pyqtSignal)
45
46from sortedcontainers import SortedList
47
48from raphodo.generatenameconfig import *
49import raphodo.generatename as gn
50from raphodo.constants import (
51    CustomColors, PrefPosition, NameGenerationType, PresetPrefType, PresetClass
52)
53from raphodo.rpdfile import SamplePhoto, SampleVideo, RPDFile, Photo, Video, FileType
54from raphodo.preferences import DownloadsTodayTracker, Preferences, match_pref_list
55import raphodo.exiftool as exiftool
56from raphodo.utilities import remove_last_char_from_list_str
57from raphodo.messagewidget import MessageWidget
58from raphodo.viewutils import (
59    translateDialogBoxButtons, standardMessageBox, translateMessageBoxButtons
60)
61import raphodo.qrc_resources
62
63
64class PrefEditor(QTextEdit):
65    """
66    File renaming and subfolder generation preference editor
67    """
68
69    prefListGenerated = pyqtSignal()
70
71    def __init__(self, subfolder: bool, parent=None) -> None:
72        """
73        :param subfolder: if True, the editor is for editing subfolder generation
74        """
75
76        super().__init__(parent)
77        self.subfolder = subfolder
78
79        self.user_pref_list = []  # type: List[str]
80        self.user_pref_colors = []  # type: List[str]
81
82        self.heightMin = 0
83        self.heightMax = 65000
84        # Start out with about 4 lines in height:
85        self.setMinimumHeight(QFontMetrics(self.font()).lineSpacing() * 5)
86        self.document().documentLayout().documentSizeChanged.connect(self.wrapHeightToContents)
87
88    def wrapHeightToContents(self) -> None:
89        """
90        Adjust the text area size to show contents without vertical scrollbar
91
92        Derived from:
93        http://stackoverflow.com/questions/11851020/a-qwidget-like-qtextedit-that-wraps-its-height-
94        automatically-to-its-contents/11858803#11858803
95        """
96
97        docHeight = self.document().size().height() + 5
98        if self.heightMin <= docHeight <= self.heightMax and docHeight > self.minimumHeight():
99            self.setMinimumHeight(docHeight)
100
101    def mousePressEvent(self, event: QMouseEvent) -> None:
102        """
103        Automatically select a pref value if it was clicked in
104        :param event:  the mouse event
105        """
106
107        super().mousePressEvent(event)
108        if event.button() == Qt.LeftButton:
109            position = self.textCursor().position()
110            pref_pos, start, end, left_start, left_end = self.locatePrefValue(position)
111
112            if pref_pos == PrefPosition.on_left:
113                start = left_start
114                end = left_end
115            if pref_pos != PrefPosition.not_here:
116                cursor = self.textCursor()
117                cursor.setPosition(start)
118                cursor.setPosition(end + 1, QTextCursor.KeepAnchor)
119                self.setTextCursor(cursor)
120
121    def keyPressEvent(self, event: QKeyEvent) -> None:
122        """
123        Automatically select pref values when navigating through the document.
124
125        Suppress the return / enter key.
126
127        :param event: the key press event
128        """
129
130        key = event.key()
131        if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Tab):
132            return
133
134        cursor = self.textCursor()  # type: QTextCursor
135
136        if cursor.hasSelection() and key in (Qt.Key_Left, Qt.Key_Right):
137            # Pass the key press on and let the selection deselect
138            pass
139        elif key in (Qt.Key_Left, Qt.Key_Right, Qt.Key_Home, Qt.Key_End, Qt.Key_PageUp,
140                   Qt.Key_PageDown, Qt.Key_Up, Qt.Key_Down):
141            # Navigation key was pressed
142
143            # Was ctrl key pressed too?
144            ctrl_key = event.modifiers() & Qt.ControlModifier
145
146            selection_start = selection_end = -1
147
148            # This event is called before the cursor is moved, so
149            # move the cursor as if it would be moved
150            if key == Qt.Key_Right and not cursor.atEnd():
151                if ctrl_key:
152                    cursor.movePosition(QTextCursor.WordRight)
153                else:
154                    cursor.movePosition(QTextCursor.Right)
155            elif key == Qt.Key_Left and not cursor.atStart():
156                if ctrl_key:
157                    cursor.movePosition(QTextCursor.WordLeft)
158                else:
159                    cursor.movePosition(QTextCursor.Left)
160            elif key == Qt.Key_Up:
161                cursor.movePosition(QTextCursor.Up)
162            elif key == Qt.Key_Down:
163                cursor.movePosition(QTextCursor.Down)
164            elif key in (Qt.Key_Home, Qt.Key_PageUp):
165                if ctrl_key or key == Qt.Key_PageUp:
166                    cursor.movePosition(QTextCursor.StartOfBlock)
167                else:
168                    cursor.movePosition(QTextCursor.StartOfLine)
169            elif key in (Qt.Key_End, Qt.Key_PageDown):
170                if ctrl_key or key == Qt.Key_PageDown:
171                    cursor.movePosition(QTextCursor.EndOfBlock)
172                else:
173                    cursor.movePosition(QTextCursor.EndOfLine)
174
175            # Get position of where the cursor would move to
176            position = cursor.position()
177
178            # Determine if there is a pref value to the left or at that position
179            pref_pos, start, end, left_start, left_end = self.locatePrefValue(position)
180            if pref_pos == PrefPosition.on_left:
181                selection_start = left_start
182                selection_end = left_end + 1
183            elif pref_pos == PrefPosition.at:
184                selection_start = end + 1
185                selection_end = start
186            elif pref_pos == PrefPosition.positioned_in:
187                if key == Qt.Key_Left or key == Qt.Key_Home:
188                    # because moving left, position the cursor on the left
189                    selection_start = end + 1
190                    selection_end = start
191                else:
192                    # because moving right, position the cursor on the right
193                    selection_start = start
194                    selection_end = end + 1
195
196            if selection_end >= 0 and selection_start >= 0:
197                cursor.setPosition(selection_start)
198                cursor.setPosition(selection_end, QTextCursor.KeepAnchor)
199                self.setTextCursor(cursor)
200                return
201
202        super().keyPressEvent(event)
203
204    def locatePrefValue(self, position: int) -> Tuple[PrefPosition, int, int, int, int]:
205        """
206        Determine where pref values are relative to the position passed.
207
208        :param position: some position in text, e.g. cursor position
209        :return: enum indicating where prefs are found and their start and end
210         positions. Return positions are -1 if not found.
211        """
212
213        start = end = -1
214        left_start = left_end = -1
215        pref_position = PrefPosition.not_here
216        b = self.highlighter.boundaries
217        if not(len(b)):
218            return (pref_position, start, end, left_start, left_end)
219
220        index = b.bisect_left((position, 0))
221        # Special cases
222        if index == 0:
223            # At or to the left of the first pref value
224            if b[0][0] == position:
225                pref_position = PrefPosition.at
226                start, end = b[0]
227        elif index == len(b):
228            # To the right of or in the last pref value
229            if position <= b[-1][1]:
230                start, end = b[-1]
231                pref_position = PrefPosition.positioned_in
232            elif b[-1][1] == position - 1:
233                left_start, left_end = b[-1]
234                pref_position = PrefPosition.on_left
235        else:
236            left = b[index -1]
237            right = b[index]
238
239            at = right[0] == position
240            to_left = left[1] == position -1
241            if at and to_left:
242                pref_position = PrefPosition.on_left_and_at
243                start, end = right
244                left_start, left_end = left
245            elif at:
246                pref_position = PrefPosition.at
247                start, end = right
248            elif to_left:
249                pref_position = PrefPosition.on_left
250                left_start, left_end = left
251            elif position <= left[1]:
252                pref_position = PrefPosition.positioned_in
253                start, end = b[index - 1]
254
255        return (pref_position, start, end, left_start, left_end)
256
257    def displayPrefList(self, pref_list: Sequence[str]) -> None:
258        p = pref_list
259        values = []
260        for i in range(0, len(pref_list), 3):
261            try:
262                value = '<{}>'.format(self.pref_mapper[(p[i], p[i+1], p[i+2])])
263            except KeyError:
264                if p[i] == SEPARATOR:
265                    value = SEPARATOR
266                else:
267                    assert p[i] == TEXT
268                    value = p[i+1]
269            values.append(value)
270
271        self.document().clear()
272        cursor = self.textCursor()  # type: QTextCursor
273        cursor.insertText(''.join(values))
274
275    def insertPrefValue(self, pref_value: str) -> None:
276        cursor = self.textCursor()  # type: QTextCursor
277        cursor.insertText('<{}>'.format(pref_value))
278
279    def _setHighlighter(self) -> None:
280        self.highlighter = PrefHighlighter(
281            list(self.string_to_pref_mapper.keys()), self.pref_color, self.document()
282        )
283
284        # when color coding of text in the editor is complete,
285        # generate the preference list
286        self.highlighter.blockHighlighted.connect(self.generatePrefList)
287
288    def setPrefMapper(self, pref_mapper: Dict[Tuple[str, str, str], str],
289                      pref_color: Dict[str, str]) -> None:
290        self.pref_mapper = pref_mapper
291        self.string_to_pref_mapper = {value: key for key, value in pref_mapper.items()}
292
293        self.pref_color = pref_color
294        self._setHighlighter()
295
296    def _parseTextFragment(self, text_fragment) -> None:
297        if self.subfolder:
298            text_fragments = text_fragment.split(os.sep)
299            for index, text_fragment in enumerate(text_fragments):
300                if text_fragment:
301                    self.user_pref_list.extend([TEXT, text_fragment, ''])
302                    self.user_pref_colors.append('')
303                if index < len(text_fragments) - 1:
304                    self.user_pref_list.extend([SEPARATOR, '', ''])
305                    self.user_pref_colors.append('')
306        else:
307            self.user_pref_list.extend([TEXT, text_fragment, ''])
308            self.user_pref_colors.append('')
309
310    def _addColor(self, pref_defn: str) -> None:
311        self.user_pref_colors.append(self.pref_color[pref_defn])
312
313    @pyqtSlot()
314    def generatePrefList(self) -> None:
315        """
316        After syntax highlighting has completed, use its findings
317        to generate the user's pref list
318        """
319
320        text = self.document().toPlainText()
321        b = self.highlighter.boundaries
322
323        self.user_pref_list = pl = []  # type: List[str]
324        self.user_pref_colors = []  # type: List[str]
325
326        # Handle any text at the very beginning
327        if b and b[0][0] > 0:
328            text_fragment = text[:b[0][0]]
329            self._parseTextFragment(text_fragment)
330
331        if len(b) > 1:
332            for index, item in enumerate(b[1:]):
333                start, end = b[index]
334                # Add + 1 to start to remove the opening <
335                pl.extend(self.string_to_pref_mapper[text[start + 1: end]])
336                # Add + 1 to start to include the closing >
337                self._addColor(text[start: end + 1])
338
339                text_fragment = text[b[index][1] + 1:item[0]]
340                self._parseTextFragment(text_fragment)
341
342        # Handle the final pref value
343        if b:
344            start, end = b[-1]
345            # Add + 1 to start to remove the opening <
346            pl.extend(self.string_to_pref_mapper[text[start + 1: end]])
347            # Add + 1 to start to include the closing >
348            self._addColor(text[start: end + 1])
349            final = end + 1
350        else:
351            final = 0
352
353        # Handle any remaining text at the very end (or the complete string if there are
354        # no pref definition values)
355        if final < len(text):
356            text_fragment = text[final:]
357            self._parseTextFragment(text_fragment)
358
359        assert len(self.user_pref_colors) == len(self.user_pref_list) / 3
360        self.prefListGenerated.emit()
361
362
363class PrefHighlighter(QSyntaxHighlighter):
364    """
365    Highlight non-text preference values in the editor
366    """
367
368    blockHighlighted = pyqtSignal()
369
370    def __init__(self, pref_defn_strings: List[str],
371                 pref_color: Dict[str, str],
372                 document: QTextDocument) -> None:
373        super().__init__(document)
374
375        # Where detected preference values start and end:
376        # [(start, end), (start, end), ...]
377        self.boundaries = SortedList()
378
379        pref_defns = ('<{}>'.format(pref) for pref in pref_defn_strings)
380        self.highlightingRules = []
381        for pref in pref_defns:
382            format = QTextCharFormat()
383            format.setForeground(QBrush(QColor(pref_color[pref])))
384            self.highlightingRules.append((pref, format))
385
386    def find_all(self, text: str, pref_defn: str):
387        """
388        Find all occurrences of a preference definition in the text
389        :param text: text to search
390        :param pref_defn: the preference definition
391        :return: yield the position in the document's text
392        """
393        if not len(pref_defn):
394            return  # do not use raise StopIteration as it is Python 3.7 incompatible
395        start = 0
396        while True:
397            start = text.find(pref_defn, start)
398            if start == -1:
399                return  # do not use raise StopIteration as it is Python 3.7 incompatible
400            yield start
401            start += len(pref_defn)
402
403    def highlightBlock(self, text: str) -> None:
404
405        # Recreate the preference value from scratch
406        self.boundaries = SortedList()
407
408        for expression, format in self.highlightingRules:
409            for index in self.find_all(text, expression):
410                length = len(expression)
411                self.setFormat(index, length, format)
412                self.boundaries.add((index, index + length - 1))
413
414        self.blockHighlighted.emit()
415
416
417def make_subfolder_menu_entry(prefs: Tuple[str]) -> str:
418    """
419    Create the text for a menu / combobox item
420
421    :param prefs: single pref item, with title and elements
422    :return: item text
423    """
424
425    desc = prefs[0]
426    elements = prefs[1:]
427    # Translators: this text appears in menus and combo boxes. It displays the
428    # description of an item, and its elements.
429    # Translators: %(variable)s represents Python code, not a plural of the term
430    # variable. You must keep the %(variable)s untranslated, or the program will
431    # crash.
432    return _("%(description)s - %(elements)s") % dict(
433        description=desc, elements=os.sep.join(elements)
434    )
435
436
437def make_rename_menu_entry(prefs: Tuple[str]) -> str:
438    """
439    Create the text for a menu / combobox item
440
441    :param prefs: single pref item, with title and elements
442    :return: item text
443    """
444
445    desc = prefs[0]
446    elements = prefs[1]
447    # Translators: this text appears in menus and combo boxes. It displays the
448    # description of an item, and its elements.
449    # Translators: %(variable)s represents Python code, not a plural of the term
450    # variable. You must keep the %(variable)s untranslated, or the program will
451    # crash.
452    return _("%(description)s - %(elements)s") % dict(description=desc, elements=elements)
453
454
455class PresetComboBox(QComboBox):
456    """
457    Combox box displaying built-in presets, custom presets,
458    and some commands relating to preset management.
459
460    Used in in dialog window used to edit name generation and
461    also in the rename files panel.
462    """
463
464    def __init__(self, prefs: Preferences,
465                 preset_names: List[str],
466                 preset_type: PresetPrefType,
467                 edit_mode: bool,
468                 parent=None) -> None:
469        """
470        :param prefs: program preferences
471        :param preset_names: list of custom preset names
472        :param preset_type: one of photo rename, video rename,
473         photo subfolder, or video subfolder
474        :param edit_mode: if True, the combo box is being displayed
475         in an edit dialog window, else it's being displayed in the
476         file rename panel
477        :param parent: parent widget
478        """
479
480        super().__init__(parent)
481        self.edit_mode = edit_mode
482        self.prefs = prefs
483
484        self.preset_edited = False
485        self.new_preset = False
486
487        self.preset_type = preset_type
488
489        if preset_type == PresetPrefType.preset_photo_subfolder:
490            self.builtin_presets = PHOTO_SUBFOLDER_MENU_DEFAULTS
491        elif preset_type == PresetPrefType.preset_video_subfolder:
492            self.builtin_presets = VIDEO_SUBFOLDER_MENU_DEFAULTS
493        elif preset_type == PresetPrefType.preset_photo_rename:
494            self.builtin_presets = PHOTO_RENAME_MENU_DEFAULTS
495        else:
496            assert preset_type == PresetPrefType.preset_video_rename
497            self.builtin_presets = VIDEO_RENAME_MENU_DEFAULTS
498
499        self._setup_entries(preset_names)
500
501    def _setup_entries(self, preset_names: List[str]) -> None:
502
503        idx = 0
504
505        if self.edit_mode:
506            for pref in self.builtin_presets:
507                self.addItem(make_subfolder_menu_entry(pref), PresetClass.builtin)
508                idx += 1
509        else:
510            for pref in self.builtin_presets:
511                self.addItem(pref[0], PresetClass.builtin)
512                idx += 1
513
514        if not len(preset_names):
515            # preset_separator bool is used to indicate the existence of
516            # a separator in the combo box that is used to distinguish
517            # custom from built-in prests
518            self.preset_separator = False
519        else:
520            self.preset_separator = True
521
522            self.insertSeparator(idx)
523            idx += 1
524
525            for name in preset_names:
526                self.addItem(name, PresetClass.custom)
527                idx += 1
528
529        self.insertSeparator(idx)
530
531        if self.edit_mode:
532            self.addItem(_('Save New Custom Preset...'), PresetClass.new_preset)
533            self.addItem(_('Remove All Custom Presets...'), PresetClass.remove_all)
534            self.setRemoveAllCustomEnabled(bool(len(preset_names)))
535        else:
536            self.addItem(_('Custom...'), PresetClass.start_editor)
537
538    def resetEntries(self, preset_names: List[str]) -> None:
539        assert not self.edit_mode
540        self.clear()
541        self._setup_entries(preset_names)
542
543    def addCustomPreset(self, text: str) -> None:
544        """
545        Adds a new custom preset name to the comboxbox and sets the
546        combobox to display it.
547
548        :param text: the custom preset name
549        """
550
551        assert self.edit_mode
552        if self.new_preset or self.preset_edited:
553            self.resetPresetList()
554        if not self.preset_separator:
555            self.insertSeparator(len(self.builtin_presets))
556            self.preset_separator = True
557        idx = len(self.builtin_presets) + 1
558        self.insertItem(idx, text, PresetClass.custom)
559        self.setCurrentIndex(idx)
560
561    def removeAllCustomPresets(self, no_presets: int) -> None:
562        assert self.edit_mode
563        assert self.preset_separator
564        start = len(self.builtin_presets)
565        if self.new_preset:
566            start += 2
567        elif self.preset_edited:
568            self.resetPresetList()
569        end = start + no_presets
570        for row in range(end, start -1, -1):
571            self.removeItem(row)
572        self.preset_separator = False
573
574    def setPresetNew(self) -> None:
575        assert self.edit_mode
576        assert not self.preset_edited
577        if self.new_preset:
578            return
579        item_text = _('(New Custom Preset)')
580        self.new_preset = True
581        self.insertItem(0, item_text, PresetClass.edited)
582        self.insertSeparator(1)
583        self.setCurrentIndex(0)
584
585    def setPresetEdited(self, text: str) -> None:
586        """
587        Adds a new entry at the top of the combobox indicating that the current
588        preset has been edited.
589
590        :param text: the preset name to use
591        """
592
593        assert self.edit_mode
594        assert not self.new_preset
595        assert not self.preset_edited
596        item_text = _('%s (edited)') % text
597        self.insertItem(0, item_text, PresetClass.edited)
598        self.insertSeparator(1)
599        self.addItem(_('Update Custom Preset "%s"') % text, PresetClass.update_preset)
600        self.preset_edited = True
601        self.setCurrentIndex(0)
602
603    def resetPresetList(self) -> None:
604        """
605        Removes the combo box first line 'Preset name (edited)' or '(New Custom Preset)',
606        and its separator
607        """
608
609        assert self.edit_mode
610        assert self.new_preset or self.preset_edited
611        # remove combo box first line 'Preset name (edited)' or '(New Custom Preset)'
612        self.removeItem(0)
613        # remove separator
614        self.removeItem(0)
615        # remove Update Preset
616        if self.preset_edited:
617            index = self.count() - 1
618            self.removeItem(index)
619        self.preset_edited = self.new_preset = False
620
621    def _setRowEnabled(self, enabled: bool, offset: int) -> None:
622        assert self.edit_mode
623        # Our big assumption here is that the model is a QStandardItemModel
624        model = self.model()
625        count = self.count()
626        if self.preset_edited:
627            row = count - offset - 1
628        else:
629            row = count - offset
630        item = model.item(row, 0)  # type: QStandardItem
631        if not enabled:
632            item.setFlags(Qt.NoItemFlags)
633        else:
634            item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
635
636    def setRemoveAllCustomEnabled(self, enabled: bool) -> None:
637        self._setRowEnabled(enabled=enabled, offset=1)
638
639    def setSaveNewCustomPresetEnabled(self, enabled: bool) -> None:
640        self._setRowEnabled(enabled=enabled, offset=2)
641
642    def getComboBoxIndex(self, preset_index: int) -> int:
643        """
644        Calculate the index into the combo box list allowing for the separator
645        and other elements in the list of entries the user sees
646
647        :param preset_index: the preset index (built-in & custom)
648        :return: the index into the actual combobox entries including
649         any separators etc.
650        """
651
652        if self.edit_mode and (self.new_preset or self.preset_edited):
653            preset_index += 2
654        if preset_index < len(self.builtin_presets):
655            return preset_index
656        else:
657            assert self.preset_separator
658            return preset_index + 1
659
660    def getPresetIndex(self, combobox_index: int) -> int:
661        """
662        Opposite of getComboBoxIndex: calculates the preset index based on the
663        given combo box index (which includes separators etc.)
664        :param combobox_index: the index into the combobox entries the user sees
665        :return: the index into the presets (built-in & custom)
666        """
667
668        if self.edit_mode and (self.new_preset or self.preset_edited):
669            combobox_index -= 2
670        if combobox_index < len(self.builtin_presets):
671            return combobox_index
672        else:
673            assert self.preset_separator
674            return combobox_index - 1
675
676
677class CreatePreset(QDialog):
678    """
679    Very simple dialog window that allows user entry of new preset name.
680
681    Save button is disabled when the current name entered equals an existing
682    preset name or is empty.
683    """
684
685    def __init__(self, existing_custom_names: List[str], parent=None) -> None:
686        super().__init__(parent)
687
688        self.existing_custom_names = existing_custom_names
689
690        self.setModal(True)
691
692        title = _("Save New Custom Preset - Rapid Photo Downloader")
693        self.setWindowTitle(title)
694
695        self.name = QLineEdit()
696        metrics = QFontMetrics(QFont())
697        self.name.setMinimumWidth(metrics.width(title))
698        self.name.textEdited.connect(self.nameEdited)
699        flayout = QFormLayout()
700        flayout.addRow(_('Preset Name:'), self.name)
701
702        buttonBox = QDialogButtonBox()
703        buttonBox.addButton(QDialogButtonBox.Cancel)  # type: QPushButton
704        self.saveButton = buttonBox.addButton(QDialogButtonBox.Save)  # type: QPushButton
705        self.saveButton.setEnabled(False)
706        translateDialogBoxButtons(buttonBox)
707        buttonBox.rejected.connect(self.reject)
708        buttonBox.accepted.connect(self.accept)
709
710        layout = QVBoxLayout()
711        layout.addLayout(flayout)
712        layout.addWidget(buttonBox)
713
714        self.setLayout(layout)
715
716    @pyqtSlot(str)
717    def nameEdited(self, name: str):
718        enabled = False
719        if len(name) > 0:
720            enabled = name not in self.existing_custom_names
721        self.saveButton.setEnabled(enabled)
722
723    def presetName(self) -> str:
724        """
725        :return: the name of the name the user wants to save the preset as
726        """
727
728        return self.name.text()
729
730
731def make_sample_rpd_file(sample_job_code: str,
732                         prefs: Preferences,
733                         generation_type: NameGenerationType,
734                         sample_rpd_file: Optional[Union[Photo, Video]]=None) -> Union[
735                                                                                 Photo, Video]:
736    """
737    Create a sample_rpd_file used for displaying to the user an example of their
738    file renaming preference in action on a sample file.
739
740    :param sample_job_code: sample of a Job Code
741    :param prefs: user preferences
742    :param generation_type: one of photo/video filenames/subfolders
743    :param sample_rpd_file: sample RPDFile that will possibly be overwritten
744     with new values
745    :return: sample RPDFile
746    """
747
748    downloads_today_tracker = DownloadsTodayTracker(
749        day_start=prefs.day_start,
750        downloads_today=prefs.downloads_today
751    )
752    sequences = gn.Sequences(downloads_today_tracker, prefs.stored_sequence_no)
753    if sample_rpd_file is not None:
754        if sample_rpd_file.metadata is None:
755            logging.error('Sample file %s is missing its metadata', sample_rpd_file.full_file_name)
756            sample_rpd_file = None
757        else:
758            sample_rpd_file.sequences = sequences
759            sample_rpd_file.download_start_time = datetime.datetime.now()
760
761    if sample_rpd_file is  None:
762        if generation_type in (NameGenerationType.photo_name,
763                               NameGenerationType.photo_subfolder):
764            sample_rpd_file = SamplePhoto(sequences=sequences)
765        else:
766            sample_rpd_file = SampleVideo(sequences=sequences)
767
768    sample_rpd_file.job_code = sample_job_code
769    sample_rpd_file.strip_characters = prefs.strip_characters
770    if sample_rpd_file.file_type == FileType.photo:
771        sample_rpd_file.generate_extension_case = prefs.photo_extension
772    else:
773        sample_rpd_file.generate_extension_case = prefs.video_extension
774
775    return sample_rpd_file
776
777
778class EditorCombobox(QComboBox):
779    """
780    Regular combobox, but ignores the mouse wheel
781    """
782
783    def wheelEvent(self, event: QWheelEvent) -> None:
784        event.ignore()
785
786
787class PrefDialog(QDialog):
788    """
789    Dialog window to allow editing of file renaming and subfolder generation
790    """
791
792    def __init__(self, pref_defn: OrderedDict,
793                 user_pref_list: List[str],
794                 generation_type: NameGenerationType,
795                 prefs: Preferences,
796                 sample_rpd_file: Optional[Union[Photo, Video]]=None,
797                 max_entries=0,
798                 parent=None) -> None:
799        """
800        Set up dialog to display all its controls based on the preference
801        definition being used.
802
803        :param pref_defn: definition of possible preference choices, i.e.
804         one of DICT_VIDEO_SUBFOLDER_L0, DICT_SUBFOLDER_L0, DICT_VIDEO_RENAME_L0
805         or DICT_IMAGE_RENAME_L0
806        :param user_pref_list: the user's actual rename / subfolder generation
807         preferences
808        :param generation_type: enum specifying what kind of name is being edited
809         (one of photo filename, video filename, photo subfolder, video subfolder)
810        :param prefs: program preferences
811        :param exiftool_process: daemon exiftool process
812        :param sample_rpd_file: a sample photo or video, whose contents will be
813         modified (i.e. don't pass a live RPDFile)
814        :param max_entries: maximum number of entries that will be displayed
815         to the user (in a menu, for example)
816        """
817
818        super().__init__(parent)
819
820        self.setModal(True)
821
822        self.generation_type = generation_type
823        if generation_type == NameGenerationType.photo_subfolder:
824            self.setWindowTitle(_('Photo Subfolder Generation Editor'))
825            self.preset_type = PresetPrefType.preset_photo_subfolder
826            self.builtin_pref_lists = PHOTO_SUBFOLDER_MENU_DEFAULTS_CONV
827            self.builtin_pref_names = [make_subfolder_menu_entry(pref)
828                                       for pref in PHOTO_SUBFOLDER_MENU_DEFAULTS]
829        elif generation_type == NameGenerationType.video_subfolder:
830            self.setWindowTitle(_('Video Subfolder Generation Editor'))
831            self.preset_type = PresetPrefType.preset_video_subfolder
832            self.builtin_pref_lists = VIDEO_SUBFOLDER_MENU_DEFAULTS_CONV
833            self.builtin_pref_names = [make_subfolder_menu_entry(pref)
834                                       for pref in VIDEO_SUBFOLDER_MENU_DEFAULTS]
835        elif generation_type == NameGenerationType.photo_name:
836            self.setWindowTitle(_('Photo Renaming Editor'))
837            self.preset_type = PresetPrefType.preset_photo_rename
838            self.builtin_pref_lists = PHOTO_RENAME_MENU_DEFAULTS_CONV
839            self.builtin_pref_names = [make_rename_menu_entry(pref)
840                                       for pref in PHOTO_RENAME_MENU_DEFAULTS]
841        else:
842            self.setWindowTitle(_('Video Renaming Editor'))
843            self.preset_type = PresetPrefType.preset_video_rename
844            self.builtin_pref_lists = VIDEO_RENAME_MENU_DEFAULTS_CONV
845            self.builtin_pref_names = [make_rename_menu_entry(pref)
846                                       for pref in VIDEO_RENAME_MENU_DEFAULTS]
847
848        self.prefs = prefs
849        self.max_entries = max_entries
850
851        # Cache custom preset name and pref lists
852        self.updateCachedPrefLists()
853
854        self.current_custom_name = None
855
856        # Setup values needed for name generation
857
858        self.sample_rpd_file = make_sample_rpd_file(
859            sample_rpd_file=sample_rpd_file,
860            sample_job_code=self.prefs.most_recent_job_code(missing=_('Job Code')),
861            prefs=self.prefs,
862            generation_type=generation_type
863        )
864
865        # Setup widgets and helper values
866
867        # Translators: please do not modify or leave out html formatting tags like <i> and
868        # <b>. These are used to format the text the users sees
869        warning_msg = _(
870            '<b><font color="red">Warning:</font></b> <i>There is insufficient data to fully '
871            'generate the name. Please use other renaming options.</i>'
872        )
873
874        self.is_subfolder = generation_type in (
875            NameGenerationType.photo_subfolder, NameGenerationType.video_subfolder
876        )
877
878        if self.is_subfolder:
879            # Translators: please do not modify, change the order of or leave out html formatting
880            # tags like <i> and <b>. These are used to format the text the users sees.
881            # In this case, the </i> really is supposed to come before the <i>.
882            # Translators: %(variable)s represents Python code, not a plural of the term
883            # variable. You must keep the %(variable)s untranslated, or the program will
884            # crash.
885            subfolder_msg = _(
886                "The character</i> %(separator)s <i>creates a new subfolder level."
887            ) % dict(separator=os.sep)
888            # Translators: please do not modify, change the order of or leave out html formatting
889            # tags like <i> and <b>. These are used to format the text the users sees
890            # In this case, the </i> really is supposed to come before the <i>.
891            # Translators: %(variable)s represents Python code, not a plural of the term
892            # variable. You must keep the %(variable)s untranslated, or the program will
893            # crash.
894            subfolder_first_char_msg = _(
895                "There is no need start or end with the folder separator </i> %(separator)s<i>, "
896                "because it is added automatically."
897            ) % dict(separator=os.sep)
898            messages = (warning_msg, subfolder_msg, subfolder_first_char_msg)
899        else:
900            # Translators: please do not modify or leave out html formatting tags like <i> and
901            # <b>. These are used to format the text the users sees
902            unique_msg = _(
903                '<b><font color="red">Warning:</font></b> <i>Unique filenames may not be '
904                'generated. Make filenames unique by using Sequence values.</i>'
905            )
906            messages = (warning_msg, unique_msg)
907
908        self.messageWidget = MessageWidget(messages=messages)
909
910        self.editor = PrefEditor(subfolder=self.is_subfolder)
911        sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
912        sizePolicy.setVerticalStretch(1)
913        self.editor.setSizePolicy(sizePolicy)
914
915        self.editor.prefListGenerated.connect(self.updateExampleFilename)
916
917        # Generated subfolder / file name example
918        self.example = QLabel()
919
920        # Combobox with built-in and user defined presets
921        self.preset = PresetComboBox(
922            prefs=prefs, preset_names=self.preset_names, preset_type=self.preset_type,
923            edit_mode=True
924        )
925        self.preset.activated.connect(self.presetComboItemActivated)
926
927        glayout = QGridLayout()
928        presetLabel = QLabel(_('Preset:'))
929        exampleLabel = QLabel(_('Example:'))
930
931        glayout.addWidget(presetLabel, 0, 0)
932        glayout.addWidget(self.preset, 0, 1)
933        glayout.addWidget(exampleLabel, 1, 0)
934        glayout.addWidget(self.example, 1, 1)
935        glayout.setColumnStretch(1, 10)
936
937        layout = QVBoxLayout()
938        self.setLayout(layout)
939
940        layout.addLayout(glayout)
941        layout.addSpacing(int(QFontMetrics(QFont()).height() / 2))
942        layout.addWidget(self.editor)
943        layout.addWidget(self.messageWidget)
944
945        self.area = QScrollArea()
946        sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
947        sizePolicy.setVerticalStretch(10)
948        self.area.setSizePolicy(sizePolicy)
949        self.area.setFrameShape(QFrame.NoFrame)
950        layout.addWidget(self.area)
951
952        gbSizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
953
954        areaWidget = QWidget()
955        areaLayout = QVBoxLayout()
956        areaWidget.setLayout(areaLayout)
957        areaWidget.setSizePolicy(gbSizePolicy)
958
959        self.area.setWidget(areaWidget)
960        self.area.setWidgetResizable(True)
961
962        areaLayout.setContentsMargins(0, 0, 0, 0)
963
964        self.pushButtonSizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
965
966        self.mapper = QSignalMapper(self)
967        self.widget_mapper = dict()  # type: Dict[str, Union[QComboBox, QLabel]]
968        self.pref_mapper = dict()  # type: Dict[Tuple[str, str, str], str]
969        self.pref_color = dict()  # type: Dict[str, str]
970
971        titles = [title for title in pref_defn if title not in (TEXT, SEPARATOR)]
972        pref_colors = {title: color.value for title, color in zip(titles, CustomColors)}
973        self.filename_pref_color = pref_colors[FILENAME]
974
975        for title in titles:
976            title_i18n = _(title)
977            color = pref_colors[title]
978            level1 = pref_defn[title]
979            gb = QGroupBox(title_i18n)
980            gb.setSizePolicy(gbSizePolicy)
981            gb.setFlat(True)
982            areaLayout.addWidget(gb)
983            gLayout = QGridLayout()
984            gb.setLayout(gLayout)
985            if level1 is None:
986                assert title == JOB_CODE
987                widget1 = QLabel(' ' + title_i18n)
988                widget2 = self.makeInsertButton()
989                self.widget_mapper[title] = widget1
990                self.mapper.setMapping(widget2, title)
991                self.pref_mapper[(title, '', '')] = title_i18n
992                self.pref_color['<{}>'.format(title_i18n)] = color
993                gLayout.addWidget(self.makeColorCodeLabel(color), 0, 0)
994                gLayout.addWidget(widget1, 0, 1)
995                gLayout.addWidget(widget2, 0, 2)
996            elif title == METADATA:
997                elements = []
998                data = []
999                for element in level1:
1000                    element_i18n = _(element)
1001                    level2 = level1[element]
1002                    if level2 is None:
1003                        elements.append(element_i18n)
1004                        data.append([METADATA, element, ''])
1005                        self.pref_mapper[(METADATA, element, '')] = element_i18n
1006                        self.pref_color['<{}>'.format(element_i18n)] = color
1007                    else:
1008                        for e in level2:
1009                            e_i18n = _(e)
1010                            # Translators: appears in a combobox, e.g. Image Date (YYYY)
1011                            item = _('{choice} ({variant})').format(choice=element_i18n,
1012                                                                    variant=e_i18n)
1013                            elements.append(item)
1014                            data.append([METADATA, element, e])
1015                            self.pref_mapper[(METADATA, element, e)] = item
1016                            self.pref_color['<{}>'.format(item)] = color
1017                widget1 = EditorCombobox()
1018                for element, data_item in zip(elements, data):
1019                    widget1.addItem(element, data_item)
1020                widget2 = self.makeInsertButton()
1021                widget1.currentTextChanged.connect(self.mapper.map)
1022                self.mapper.setMapping(widget2, title)
1023                self.mapper.setMapping(widget1, title)
1024                self.widget_mapper[title] = widget1
1025                gLayout.addWidget(self.makeColorCodeLabel(color), 0, 0)
1026                gLayout.addWidget(widget1, 0, 1)
1027                gLayout.addWidget(widget2, 0, 2)
1028            else:
1029                for row, level1 in enumerate(pref_defn[title]):
1030                    widget1 = EditorCombobox()
1031                    level1_i18n = _(level1)
1032                    items = (_('{choice} ({variant})').format(
1033                             choice=level1_i18n, variant=_(element))
1034                             for element in pref_defn[title][level1])
1035                    data = ([title, level1, element] for element in pref_defn[title][level1])
1036                    for item, data_item in zip(items, data):
1037                        widget1.addItem(item, data_item)
1038                        self.pref_mapper[tuple(data_item)] = item
1039                        self.pref_color['<{}>'.format(item)] = color
1040                    widget2 =  self.makeInsertButton()
1041                    widget1.currentTextChanged.connect(self.mapper.map)
1042
1043                    self.mapper.setMapping(widget2, level1)
1044                    self.mapper.setMapping(widget1, level1)
1045                    self.widget_mapper[level1] = widget1
1046                    gLayout.addWidget(self.makeColorCodeLabel(color), row, 0)
1047                    gLayout.addWidget(widget1, row, 1)
1048                    gLayout.addWidget(widget2, row, 2)
1049
1050        self.mapper.mapped[str].connect(self.choiceMade)
1051
1052        buttonBox = QDialogButtonBox(
1053            QDialogButtonBox.Cancel | QDialogButtonBox.Ok | QDialogButtonBox.Help
1054        )
1055        self.helpButton = buttonBox.button(QDialogButtonBox.Help)  # type: QPushButton
1056        self.helpButton.clicked.connect(self.helpButtonClicked)
1057        self.helpButton.setToolTip(_('Get help online...'))
1058        translateDialogBoxButtons(buttonBox)
1059
1060        buttonBox.rejected.connect(self.reject)
1061        buttonBox.accepted.connect(self.accept)
1062
1063        layout.addWidget(buttonBox)
1064
1065        self.editor.setPrefMapper(self.pref_mapper, self.pref_color)
1066        self.editor.displayPrefList(user_pref_list)
1067
1068        self.show()
1069        self.setWidgetSizes()
1070
1071    def helpButtonClicked(self) -> None:
1072        if self.generation_type in (NameGenerationType.photo_name, NameGenerationType.video_name):
1073            location = '#rename'
1074        else:
1075            location = '#subfoldergeneration'
1076        webbrowser.open_new_tab("http://www.damonlynch.net/rapid/documentation/{}".format(location))
1077
1078    def makeInsertButton(self) -> QPushButton:
1079        w = QPushButton(_('Insert'))
1080        w.clicked.connect(self.mapper.map)
1081        w.setSizePolicy(self.pushButtonSizePolicy)
1082        return w
1083
1084    def setWidgetSizes(self) -> None:
1085        """
1086        Resize widgets for enhanced visual layout
1087        """
1088
1089        # Set the widths of the comboboxes and labels to the width of the
1090        # longest control
1091        width = max(widget.width() for widget in self.widget_mapper.values())
1092        for widget in self.widget_mapper.values():
1093            widget.setMinimumWidth(width)
1094
1095        # Set the scroll area to be big enough to eliminate the horizontal scrollbar
1096        scrollbar_width = self.style().pixelMetric(QStyle.PM_ScrollBarExtent)
1097        self.area.setMinimumWidth(self.area.widget().width() + scrollbar_width)
1098
1099    @pyqtSlot(str)
1100    def choiceMade(self, widget: str) -> None:
1101        """
1102        User has pushed one of the "Insert" buttons or selected a new value in one
1103        of the combo boxes.
1104
1105        :param widget: widget's name, which uniquely identifies it
1106        """
1107
1108        if widget == JOB_CODE:
1109            pref_value = _(JOB_CODE)
1110        else:
1111            combobox = self.widget_mapper[widget]  # type: QComboBox
1112            pref_value = combobox.currentText()
1113
1114        self.editor.insertPrefValue(pref_value)
1115
1116        # Set focus not on the control that was just used, but the editor
1117        self.editor.setFocus(Qt.OtherFocusReason)
1118
1119    def makeColorCodeLabel(self, color: str) -> QLabel:
1120        """
1121        Generate a colored square to show beside the combo boxes / label
1122        :param color: color to use, e.g. #7a9c38
1123        :return: the square in form of a label
1124        """
1125
1126        colorLabel = QLabel(' ')
1127        colorLabel.setStyleSheet('QLabel {background-color: %s;}' % color)
1128        size = QFontMetrics(QFont()).height()
1129        colorLabel.setFixedSize(QSize(size, size))
1130        return colorLabel
1131
1132    def updateExampleFilename(self) -> None:
1133
1134        user_pref_list = self.editor.user_pref_list
1135        self.user_pref_colors = self.editor.user_pref_colors
1136
1137        if not self.is_subfolder:
1138            self.user_pref_colors.append(self.filename_pref_color)
1139
1140        self.messageWidget.setCurrentIndex(0)
1141
1142        if self.is_subfolder:
1143            if user_pref_list:
1144                try:
1145                    user_pref_list.index(SEPARATOR)
1146                except ValueError:
1147                    # Inform the user that a subfolder separator (os.sep) is used to create
1148                    # subfolder levels
1149                    self.messageWidget.setCurrentIndex(2)
1150                else:
1151                    if user_pref_list[0] == SEPARATOR or user_pref_list[-3] == SEPARATOR:
1152                        # inform the user that there is no need to start or finish with a
1153                        # subfolder separator (os.sep)
1154                        self.messageWidget.setCurrentIndex(3)
1155            else:
1156                # Inform the user that a subfolder separator (os.sep) is used to create
1157                # subfolder levels
1158                self.messageWidget.setCurrentIndex(2)
1159
1160            changed, user_pref_list, self.user_pref_colors = filter_subfolder_prefs(
1161                user_pref_list, self.user_pref_colors)
1162        else:
1163            try:
1164                user_pref_list.index(SEQUENCES)
1165            except ValueError:
1166                # Inform the user that sequences can be used to make filenames unique
1167                self.messageWidget.setCurrentIndex(2)
1168
1169        if self.generation_type == NameGenerationType.photo_name:
1170            self.name_generator = gn.PhotoName(user_pref_list)
1171        elif self.generation_type == NameGenerationType.video_name:
1172            self.name_generator = gn.VideoName(user_pref_list)
1173        elif self.generation_type == NameGenerationType.photo_subfolder:
1174            self.name_generator = gn.PhotoSubfolder(user_pref_list)
1175        else:
1176            assert self.generation_type == NameGenerationType.video_subfolder
1177            self.name_generator = gn.VideoSubfolder(user_pref_list)
1178
1179        self.name_parts = self.name_generator.generate_name(self.sample_rpd_file, parts=True)
1180        self.showExample()
1181        self.updateComboBoxCurrentIndex()
1182
1183    def updateComboBoxCurrentIndex(self) -> None:
1184        """
1185        Sets the combo value to match the current preference value
1186        """
1187
1188        combobox_index, pref_list_index = self.getPresetMatch()
1189        if pref_list_index >= 0:
1190            # the editor contains an existing preset
1191            self.preset.setCurrentIndex(combobox_index)
1192            if self.preset.preset_edited or self.preset.new_preset:
1193                self.preset.resetPresetList()
1194                self.preset.setSaveNewCustomPresetEnabled(enabled=False)
1195            if pref_list_index >= len(self.builtin_pref_names):
1196                self.current_custom_name = self.preset.currentText()
1197            else:
1198                self.current_custom_name = None
1199        elif not (self.preset.new_preset or self.preset.preset_edited):
1200            if self.current_custom_name is None:
1201                self.preset.setPresetNew()
1202            else:
1203                self.preset.setPresetEdited(self.current_custom_name)
1204            self.preset.setSaveNewCustomPresetEnabled(enabled=True)
1205        else:
1206            self.preset.setCurrentIndex(0)
1207
1208    def showExample(self) -> None:
1209        """
1210        Insert text into example widget, eliding it if necessary
1211        """
1212
1213        user_pref_colors = self.user_pref_colors
1214
1215        parts = copy.copy(self.name_parts)
1216        metrics = QFontMetrics(self.example.font())
1217        width = self.example.width() - metrics.width('…')
1218
1219        # Cannot elide rich text using Qt code. Thus, elide the plain text.
1220        plain_text_name = ''.join(parts)
1221
1222        if self.is_subfolder:
1223            plain_text_name = self.name_generator.filter_subfolder_characters(plain_text_name)
1224        elided_text = metrics.elidedText(plain_text_name, Qt.ElideRight, width)
1225        elided = False
1226
1227        while plain_text_name != elided_text:
1228            elided = True
1229            parts = remove_last_char_from_list_str(parts)
1230            plain_text_name = ''.join(parts)
1231            if self.is_subfolder:
1232                plain_text_name = self.name_generator.filter_subfolder_characters(plain_text_name)
1233            elided_text = metrics.elidedText(plain_text_name, Qt.ElideRight, width)
1234
1235        colored_parts = ['<span style="color: {};">{}</span>'.format(color, part) if color else part
1236                         for part, color in zip(parts, user_pref_colors)]
1237
1238        name = ''.join(colored_parts)
1239        if elided:
1240            name = '{}&hellip;'.format(name)
1241
1242        if self.is_subfolder:
1243            name = self.name_generator.filter_subfolder_characters(name)
1244
1245        if self.sample_rpd_file.name_generation_problem:
1246            self.messageWidget.setCurrentIndex(1)
1247
1248        self.example.setTextFormat(Qt.RichText)
1249        self.example.setText(name)
1250
1251    def resizeEvent(self, event: QResizeEvent) -> None:
1252        if self.example.text():
1253            self.showExample()
1254        super().resizeEvent(event)
1255
1256    def getPrefList(self) -> List[str]:
1257        """
1258        :return: the pref list the user has specified
1259        """
1260
1261        return self.editor.user_pref_list
1262
1263    @pyqtSlot(int)
1264    def presetComboItemActivated(self, index: int) -> None:
1265        """
1266        Respond to user activating the Preset combo box.
1267
1268        :param index: index of the item activated
1269        """
1270
1271        preset_class =  self.preset.currentData()
1272        if preset_class == PresetClass.new_preset:
1273            createPreset = CreatePreset(existing_custom_names=self.preset_names)
1274            if createPreset.exec():
1275                # User has created a new preset
1276                preset_name = createPreset.presetName()
1277                assert preset_name not in self.preset_names
1278                self.current_custom_name = preset_name
1279                self.preset.addCustomPreset(preset_name)
1280                self.saveNewPreset(preset_name=preset_name)
1281                if len(self.preset_names) == 1:
1282                    self.preset.setRemoveAllCustomEnabled(True)
1283                self.preset.setSaveNewCustomPresetEnabled(enabled=False)
1284            else:
1285                # User cancelled creating a new preset
1286                self.updateComboBoxCurrentIndex()
1287        elif preset_class in (PresetClass.builtin, PresetClass.custom):
1288            index = self.combined_pref_names.index(self.preset.currentText())
1289            pref_list = self.combined_pref_lists[index]
1290            self.editor.displayPrefList(pref_list=pref_list)
1291            if index >= len(self.builtin_pref_names):
1292                self.movePresetToFront(index=len(self.builtin_pref_names) - index)
1293        elif preset_class == PresetClass.remove_all:
1294            self.preset.removeAllCustomPresets(no_presets=len(self.preset_names))
1295            self.clearCustomPresets()
1296            self.preset.setRemoveAllCustomEnabled(False)
1297            self.updateComboBoxCurrentIndex()
1298        elif preset_class == PresetClass.update_preset:
1299            self.updateExistingPreset()
1300            self.updateComboBoxCurrentIndex()
1301
1302    def updateExistingPreset(self) -> None:
1303        """
1304        Updates (saves) an existing preset (assumed to be self.current_custom_name)
1305        with the new user_pref_list found in the editor.
1306
1307        Assumes cached self.preset_names and self.preset_pref_lists represent
1308        current save preferences. Will update these and overwrite the relevant
1309        preset preference.
1310        """
1311
1312        preset_name = self.current_custom_name
1313        user_pref_list = self.editor.user_pref_list
1314        index = self.preset_names.index(preset_name)
1315        self.preset_pref_lists[index] = user_pref_list
1316        if index > 0:
1317            self.movePresetToFront(index=index)
1318        else:
1319            self._updateCombinedPrefs()
1320            self.prefs.set_preset(
1321                preset_type=self.preset_type, preset_names=self.preset_names,
1322                preset_pref_lists=self.preset_pref_lists
1323            )
1324
1325    def movePresetToFront(self, index: int) -> None:
1326        """
1327        Extracts the preset from the current list of presets and moves it
1328        to the front if not already there.
1329
1330        Assumes cached self.preset_names and self.preset_pref_lists represent
1331        current save preferences. Will update these and overwrite the relevant
1332        preset preference.
1333
1334        :param index: index into self.preset_pref_lists / self.preset_names of
1335         the item to move
1336        """
1337
1338        if index == 0:
1339            return
1340        preset_name = self.preset_names.pop(index)
1341        pref_list = self.preset_pref_lists.pop(index)
1342        self.preset_names.insert(0, preset_name)
1343        self.preset_pref_lists.insert(0, pref_list)
1344        self._updateCombinedPrefs()
1345        self.prefs.set_preset(
1346            preset_type=self.preset_type, preset_names=self.preset_names,
1347            preset_pref_lists=self.preset_pref_lists
1348        )
1349
1350    def saveNewPreset(self, preset_name: str) -> None:
1351        """
1352        Saves the current user_pref_list (retrieved from the editor) and
1353        saves it in the program preferences.
1354
1355        Assumes cached self.preset_names and self.preset_pref_lists represent
1356        current save preferences. Will update these and overwrite the relevant
1357        preset preference.
1358
1359        :param preset_name: name for the new preset
1360        """
1361
1362        user_pref_list = self.editor.user_pref_list
1363        self.preset_names.insert(0, preset_name)
1364        self.preset_pref_lists.insert(0, user_pref_list)
1365        self._updateCombinedPrefs()
1366        self.prefs.set_preset(
1367            preset_type=self.preset_type, preset_names=self.preset_names,
1368            preset_pref_lists=self.preset_pref_lists
1369        )
1370
1371    def clearCustomPresets(self) -> None:
1372        """
1373        Deletes all of the custom presets.
1374
1375        Assumes cached self.preset_names and self.preset_pref_lists represent
1376        current save preferences. Will update these and overwrite the relevant
1377        preset preference.
1378        """
1379        self.preset_names = []
1380        self.preset_pref_lists = []
1381        self.current_custom_name = None
1382        self._updateCombinedPrefs()
1383        self.prefs.set_preset(
1384            preset_type=self.preset_type, preset_names=self.preset_names,
1385            preset_pref_lists=self.preset_pref_lists
1386        )
1387
1388    def updateCachedPrefLists(self) -> None:
1389        self.preset_names, self.preset_pref_lists = self.prefs.get_preset(
1390            preset_type=self.preset_type)
1391        self._updateCombinedPrefs()
1392
1393    def _updateCombinedPrefs(self):
1394        self.combined_pref_names = self.builtin_pref_names + self.preset_names
1395        self.combined_pref_lists = self.builtin_pref_lists + tuple(self.preset_pref_lists)
1396
1397    def getPresetMatch(self) -> Tuple[int, int]:
1398        """
1399        :return: Tuple of the Preset combobox index and the combined pref/name list index,
1400        if the current user pref list matches an entry in it. Else Tuple of (-1, -1).
1401        """
1402
1403        index = match_pref_list(
1404            pref_lists=self.combined_pref_lists, user_pref_list=self.editor.user_pref_list
1405        )
1406        if index >= 0:
1407            combobox_name = self.combined_pref_names[index]
1408            return self.preset.findText(combobox_name), index
1409        return -1, -1
1410
1411    @pyqtSlot()
1412    def accept(self) -> None:
1413        """
1414        Slot called when the okay button is clicked.
1415
1416        If there are unsaved changes, query the user if they want their changes
1417        saved as a new preset or if the existing preset should be updated
1418        """
1419
1420        if self.preset.preset_edited or self.preset.new_preset:
1421            title = _("Save Preset - Rapid Photo Downloader")
1422            if self.preset.new_preset:
1423
1424                message = _(
1425                    "<b>Do you want to save the changes in a new custom preset?</b><br><br>"
1426                    "Creating a custom preset is not required, but can help you keep "
1427                    "organized.<br><br>"
1428                    "The changes to the preferences will still be applied regardless of "
1429                    "whether you create a new custom preset or not."
1430                )
1431                msgBox = standardMessageBox(
1432                    standardButtons=QMessageBox.Yes | QMessageBox.No,
1433                    title=title, rich_text=True, message=message
1434                )
1435                updateButton = newButton = None
1436            else:
1437                assert self.preset.preset_edited
1438                msgBox = QMessageBox()
1439                msgBox.setTextFormat(Qt.RichText)
1440                msgBox.setIcon(QMessageBox.Question)
1441                msgBox.setWindowTitle(title)
1442                message = _(
1443                    "<b>Do you want to save the changes in a custom preset?</b><br><br>"
1444                    "If you like, you can create a new custom preset or update the "
1445                    "existing custom preset.<br><br>"
1446                    "The changes to the preferences will still be applied regardless of "
1447                    "whether you save a custom preset or not."
1448                )
1449                msgBox.setText(message)
1450                msgBox.addButton(QMessageBox.No)
1451                translateMessageBoxButtons(msgBox)
1452                updateButton = msgBox.addButton(
1453                    _('Update Custom Preset "%s"') % self.current_custom_name, QMessageBox.YesRole
1454                )
1455                newButton = msgBox.addButton(_('Save New Custom Preset'), QMessageBox.YesRole)
1456
1457            choice = msgBox.exec()
1458            save_new = update = False
1459            if self.preset.new_preset:
1460                save_new = choice == QMessageBox.Yes
1461            else:
1462                if msgBox.clickedButton() == updateButton:
1463                    update = True
1464                elif msgBox.clickedButton() == newButton:
1465                    save_new = True
1466
1467            if save_new:
1468                createPreset = CreatePreset(existing_custom_names=self.preset_names)
1469                if createPreset.exec():
1470                    # User has created a new preset
1471                    preset_name = createPreset.presetName()
1472                    assert preset_name not in self.preset_names
1473                    self.saveNewPreset(preset_name=preset_name)
1474            elif update:
1475                self.updateExistingPreset()
1476
1477        # Check to make sure that in menus (which have a limited number of menu items)
1478        # that our chosen entry is displayed
1479        if self.max_entries:
1480            combobox_index, pref_list_index = self.getPresetMatch()
1481            if pref_list_index >= self.max_entries:
1482                self.updateExistingPreset()
1483
1484        # Regardless of any user actions, close the dialog box
1485        super().accept()
1486
1487
1488if __name__ == '__main__':
1489
1490    # Application development test code:
1491
1492    app = QApplication([])
1493
1494    app.setOrganizationName("Rapid Photo Downloader")
1495    app.setOrganizationDomain("damonlynch.net")
1496    app.setApplicationName("Rapid Photo Downloader")
1497
1498    prefs = Preferences()
1499
1500    # prefDialog = PrefDialog(DICT_IMAGE_RENAME_L0, PHOTO_RENAME_MENU_DEFAULTS_CONV[1],
1501    #                         NameGenerationType.photo_name, prefs)
1502    # prefDialog = PrefDialog(DICT_VIDEO_RENAME_L0, VIDEO_RENAME_MENU_DEFAULTS_CONV[1],
1503    #                         NameGenerationType.video_name, prefs)
1504    prefDialog = PrefDialog(
1505        DICT_SUBFOLDER_L0, PHOTO_SUBFOLDER_MENU_DEFAULTS_CONV[2],
1506        NameGenerationType.photo_subfolder, prefs, max_entries=10
1507    )
1508    # prefDialog = PrefDialog(
1509    #     DICT_VIDEO_SUBFOLDER_L0, VIDEO_SUBFOLDER_MENU_DEFAULTS_CONV[2],
1510    #     NameGenerationType.video_subfolder, prefs, max_entries=10
1511    # )
1512    prefDialog.show()
1513    app.exec_()
1514
1515