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 = '{}…'.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