1# Copyright (C) 2015-2020 Damon Lynch <damonlynch@gmail.com> 2 3# This file is part of Rapid Photo Downloader. 4# 5# Rapid Photo Downloader is free software: you can redistribute it and/or 6# modify it under the terms of the GNU General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# Rapid Photo Downloader is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with Rapid Photo Downloader. If not, 17# see <http://www.gnu.org/licenses/>. 18 19__author__ = 'Damon Lynch' 20__copyright__ = "Copyright 2015-2020, Damon Lynch" 21 22import pickle 23import os 24import sys 25import datetime 26from collections import (namedtuple, defaultdict, deque) 27from operator import attrgetter 28import subprocess 29import shlex 30import logging 31from timeit import timeit 32from typing import Optional, Dict, List, Set, Tuple, Sequence 33import locale 34import pkg_resources as pkgr 35 36 37 38import arrow.arrow 39from dateutil.tz import tzlocal 40from colour import Color 41 42from PyQt5.QtCore import ( 43 QAbstractListModel, QModelIndex, Qt, pyqtSignal, QSizeF, QSize, QRect, QRectF, QEvent, QPoint, 44 QItemSelectionModel, QAbstractItemModel, pyqtSlot, QItemSelection, QTimeLine, QPointF, 45 QT_VERSION_STR 46) 47from PyQt5.QtWidgets import ( 48 QListView, QStyledItemDelegate, QStyleOptionViewItem, QApplication, QStyle, QStyleOptionButton, 49 QMenu, QWidget, QAbstractItemView, 50) 51from PyQt5.QtGui import ( 52 QPixmap, QImage, QPainter, QColor, QBrush, QFontMetricsF, QGuiApplication, QPen, QMouseEvent, 53 QFont, QKeyEvent 54) 55 56from raphodo.rpdfile import RPDFile, FileTypeCounter 57from raphodo.fileformats import ALL_USER_VISIBLE_EXTENSIONS, MUST_CACHE_VIDEOS 58from raphodo.interprocess import GenerateThumbnailsArguments, Device, GenerateThumbnailsResults 59from raphodo.constants import ( 60 DownloadStatus, Downloaded, FileType, DownloadingFileTypes, ThumbnailSize, 61 ThumbnailCacheStatus, Roles, DeviceType, CustomColors, Show, Sort, ThumbnailBackgroundName, 62 Desktop, DeviceState, extensionColor, FadeSteps, FadeMilliseconds, PaleGray, DarkGray, 63 DoubleDarkGray, Plural, manually_marked_previously_downloaded, thumbnail_margin 64) 65from raphodo.storage import ( 66 get_program_cache_directory, get_desktop, validate_download_folder, open_in_file_manager 67) 68from raphodo.utilities import ( 69 CacheDirs, make_internationalized_list, format_size_for_user, runs, arrow_locale 70) 71from raphodo.thumbnailer import Thumbnailer 72from raphodo.rpdsql import ThumbnailRowsSQL, ThumbnailRow 73from raphodo.viewutils import ThumbnailDataForProximity, scaledIcon 74from raphodo.proximity import TemporalProximityState 75from raphodo.rpdsql import DownloadedSQL 76from raphodo.preferences import Preferences 77 78 79DownloadFiles = namedtuple( 80 'DownloadFiles', 'files, download_types, download_stats, camera_access_needed' 81) 82 83MarkedSummary = namedtuple('MarkedSummary', 'marked size_photos_marked size_videos_marked') 84 85 86class DownloadStats: 87 def __init__(self): 88 self.no_photos = 0 89 self.no_videos = 0 90 self.photos_size_in_bytes = 0 91 self.videos_size_in_bytes = 0 92 self.post_download_thumb_generation = 0 93 94 95class AddBuffer: 96 """ 97 Buffers thumbnail rows for display. 98 99 Add thumbnail rows to the listview is a relatively expensive operation, as the 100 model must be reset. Buffer the rows here, and then when big enough, flush it. 101 """ 102 103 min_buffer_length = 10 104 105 def __init__(self): 106 self.initialize() 107 self.buffer_length = self.min_buffer_length 108 109 def initialize(self) -> None: 110 self.buffer = defaultdict(deque) # type: Dict[int, deque] 111 112 def __len__(self): 113 return sum(len(buffer) for buffer in self.buffer.values()) 114 115 def __getitem__(self, scan_id: int) -> deque: 116 return self.buffer[scan_id] 117 118 def should_flush(self) -> bool: 119 return len(self) > self.buffer_length 120 121 def reset(self, buffer_length: int) -> None: 122 self.initialize() 123 self.buffer_length = buffer_length 124 125 def set_buffer_length(self, length: int) -> None: 126 self.buffer_length = max(self.min_buffer_length, length) 127 128 def extend(self, scan_id: int, thumbnail_rows: Sequence[ThumbnailRow]) -> None: 129 self.buffer[scan_id].extend(thumbnail_rows) 130 131 def purge(self, scan_id: int) -> None: 132 if scan_id in self.buffer: 133 logging.debug("Purging %s thumbnails from buffer", len(self.buffer[scan_id])) 134 del self.buffer[scan_id] 135 136 137class ThumbnailListModel(QAbstractListModel): 138 selectionReset = pyqtSignal() 139 140 def __init__(self, parent, logging_port: int, log_gphoto2: bool) -> None: 141 super().__init__(parent) 142 self.rapidApp = parent 143 self.prefs = self.rapidApp.prefs # type: Preferences 144 145 self.thumbnailer_ready = False 146 self.thumbnailer_generation_queue = [] 147 148 # track what devices are having thumbnails generated, by scan_id 149 # see also DeviceCollection.thumbnailing 150 151 #FIXME maybe this duplicated set is stupid 152 self.generating_thumbnails = set() # type: Set[int] 153 154 # Sorting and filtering GUI defaults 155 self.sort_by = Sort.modification_time 156 self.sort_order = Qt.AscendingOrder 157 self.show = Show.all 158 159 self.initialize() 160 161 no_workers = parent.prefs.max_cpu_cores 162 self.thumbnailer = Thumbnailer( 163 parent=parent, no_workers=no_workers, 164 logging_port=logging_port, log_gphoto2=log_gphoto2 165 ) 166 self.thumbnailer.frontend_port.connect(self.rapidApp.initStage4) 167 self.thumbnailer.thumbnailReceived.connect(self.thumbnailReceived) 168 self.thumbnailer.cacheDirs.connect(self.cacheDirsReceived) 169 self.thumbnailer.workerFinished.connect(self.thumbnailWorkerFinished) 170 self.thumbnailer.cameraRemoved.connect(self.rapidApp.cameraRemovedWhileThumbnailing) 171 # Connect to the signal that is emitted when a thumbnailing operation is 172 # terminated by us, not merely finished 173 self.thumbnailer.workerStopped.connect(self.thumbnailWorkerStopped) 174 self.arrow_locale_for_humanize = arrow_locale(self.prefs.language) 175 logging.debug("Setting arrow locale to %s", self.arrow_locale_for_humanize) 176 177 def initialize(self) -> None: 178 # uid: QPixmap 179 self.thumbnails = {} # type: Dict[bytes, QPixmap] 180 181 self.add_buffer = AddBuffer() 182 183 # Proximity filtering 184 self.proximity_col1 = [] # type: List[int, ...] 185 self.proximity_col2 = [] # type: List[int, ...] 186 187 # scan_id 188 self.removed_devices = set() # type: Set[int] 189 190 # Files are hidden when the combo box "Show" in the main window is set to 191 # "New" instead of the default "All". 192 193 # uid: RPDFile 194 self.rpd_files = {} # type: Dict[bytes, RPDFile] 195 196 # In memory database to hold all thumbnail rows 197 self.tsql = ThumbnailRowsSQL() 198 199 # Rows used to render the thumbnail view - contains query result of the DB 200 # Each list element corresponds to a row in the thumbnail view such that 201 # index 0 in the list is row 0 in the view 202 # [(uid, marked)] 203 self.rows = [] # type: List[Tuple[bytes, bool]] 204 # {uid: row} 205 self.uid_to_row = {} # type: Dict[bytes, int] 206 207 size = QSize(106, 106) 208 self.photo_icon = scaledIcon(':/thumbnail/photo.svg').pixmap(size) 209 self.video_icon = scaledIcon(':/thumbnail/video.svg').pixmap(size) 210 211 self.total_thumbs_to_generate = 0 212 self.thumbnails_generated = 0 213 self.no_thumbnails_by_scan = defaultdict(int) 214 215 # scan_id 216 self.ctimes_differ = [] # type: List[int] 217 218 # Highlight thumbnails when from particular device when there is more than one device 219 # Thumbnails to highlight by uid 220 self.currently_highlighting_scan_id = None # type: Optional[int] 221 self._resetHighlightingValues() 222 self.highlighting_timeline = QTimeLine(FadeMilliseconds // 2) 223 self.highlighting_timeline.setCurveShape(QTimeLine.SineCurve) 224 self.highlighting_timeline.frameChanged.connect(self.doHighlightDeviceThumbs) 225 self.highlighting_timeline.finished.connect(self.highlightPhaseFinished) 226 self.highlighting_timeline_max = FadeSteps 227 self.highlighting_timeline_mint = 0 228 self.highlighting_timeline.setFrameRange(self.highlighting_timeline_mint, 229 self.highlighting_timeline_max) 230 self.highlight_value = 0 231 232 self._resetRememberSelection() 233 234 def stopThumbnailer(self) -> None: 235 self.thumbnailer.stop() 236 237 @pyqtSlot(int) 238 def thumbnailWorkerFinished(self, scan_id: int) -> None: 239 self.generating_thumbnails.remove(scan_id) 240 241 @pyqtSlot(int) 242 def thumbnailWorkerStopped(self, scan_id: int) -> None: 243 self.generating_thumbnails.remove(scan_id) 244 self.rapidApp.thumbnailGenerationStopped(scan_id=scan_id) 245 246 def logState(self) -> None: 247 logging.debug("-- Thumbnail Model --") 248 249 db_length = self.tsql.get_count() 250 db_length_and_buffer_length = db_length + len(self.add_buffer) 251 if (len(self.thumbnails) != db_length_and_buffer_length or 252 db_length_and_buffer_length != len(self.rpd_files)): 253 logging.error("Conflicting values: %s thumbnails; %s database rows; %s rpd_files", 254 len(self.thumbnails), db_length, len(self.rpd_files)) 255 else: 256 logging.debug("%s thumbnails (%s marked)", 257 db_length, self.tsql.get_count(marked=True)) 258 259 logging.debug("%s not downloaded; %s downloaded; %s previously downloaded", 260 self.tsql.get_count(downloaded=False), 261 self.tsql.get_count(downloaded=True), 262 self.tsql.get_count(previously_downloaded=True)) 263 264 if self.total_thumbs_to_generate: 265 logging.debug("%s to be generated; %s generated", self.total_thumbs_to_generate, 266 self.thumbnails_generated) 267 268 scan_ids = self.tsql.get_all_devices() 269 active_devices = ', '.join(self.rapidApp.devices[scan_id].display_name 270 for scan_id in scan_ids 271 if scan_id not in self.removed_devices) 272 if len(self.removed_devices): 273 logging.debug("Active devices: %s (%s removed)", 274 active_devices, len(self.removed_devices)) 275 else: 276 logging.debug("Active devices: %s", active_devices) 277 278 def validateModelConsistency(self): 279 logging.debug("Validating thumbnail model consistency...") 280 281 for idx, row in enumerate(self.rows): 282 uid = row[0] 283 if self.rpd_files.get(uid) is None: 284 raise KeyError('Missing key in rpd files at row {}'.format(idx)) 285 if self.thumbnails.get(uid) is None: 286 raise KeyError('Missing key in thumbnails at row {}'.format(idx)) 287 288 [self.tsql.validate_uid(uid=row[0]) for row in self.rows] 289 for uid, row in self.uid_to_row.items(): 290 assert self.rows[row][0] == uid 291 for uid in self.tsql.get_uids(): 292 assert uid in self.rpd_files 293 assert uid in self.thumbnails 294 logging.debug("...thumbnail model looks okay") 295 296 def refresh(self, suppress_signal=False, rememberSelection=False) -> None: 297 """ 298 Refresh thumbnail view after files have been added, the proximity filters 299 are used, or the sort criteria is changed. 300 301 :param suppress_signal: if True don't emit signals that layout is changing 302 :param rememberSelection: remember which uids were selected before change, 303 and reselect them 304 """ 305 306 if rememberSelection: 307 self.rememberSelection() 308 309 if not suppress_signal: 310 self.layoutAboutToBeChanged.emit() 311 312 self.rows = self.tsql.get_view( 313 sort_by=self.sort_by, sort_order=self.sort_order, 314 show=self.show, proximity_col1=self.proximity_col1, 315 proximity_col2=self.proximity_col2 316 ) 317 self.uid_to_row = {row[0]: idx for idx, row in enumerate(self.rows)} 318 319 if not suppress_signal: 320 self.layoutChanged.emit() 321 322 if rememberSelection: 323 self.reselect() 324 325 def _selectionModel(self) -> QItemSelectionModel: 326 return self.rapidApp.thumbnailView.selectionModel() 327 328 def rememberSelection(self): 329 selection = self._selectionModel() 330 selected = selection.selection() # type: QItemSelection 331 self.remember_selection_all_selected = len(selected) == len(self.rows) 332 if not self.remember_selection_all_selected: 333 self.remember_selection_selected_uids = [self.rows[index.row()][0] 334 for index in selected.indexes()] 335 selection.reset() 336 337 def reselect(self): 338 if not self.remember_selection_all_selected: 339 selection = self.rapidApp.thumbnailView.selectionModel() # type: QItemSelectionModel 340 new_selection = QItemSelection() # type: QItemSelection 341 rows = [self.uid_to_row[uid] for uid in self.remember_selection_selected_uids 342 if uid in self.uid_to_row] 343 rows.sort() 344 for first, last in runs(rows): 345 new_selection.select(self.index(first, 0), self.index(last, 0)) 346 347 selection.select(new_selection, QItemSelectionModel.Select) 348 349 for first, last in runs(rows): 350 self.dataChanged.emit(self.index(first, 0), self.index(last, 0)) 351 352 def _resetRememberSelection(self): 353 self.remember_selection_all_selected = None # type: Optional[bool] 354 self.remember_selection_selected_uids = [] # type: List[bytes] 355 356 def rowCount(self, parent: QModelIndex=QModelIndex()) -> int: 357 return len(self.rows) 358 359 def flags(self, index: QModelIndex) -> Qt.ItemFlags: 360 if not index.isValid(): 361 return Qt.NoItemFlags 362 363 row = index.row() 364 if row >= len(self.rows) or row < 0: 365 return Qt.NoItemFlags 366 367 uid = self.rows[row][0] 368 rpd_file = self.rpd_files[uid] # type: RPDFile 369 370 if rpd_file.status == DownloadStatus.not_downloaded: 371 return super().flags(index) | Qt.ItemIsEnabled | Qt.ItemIsSelectable 372 else: 373 return Qt.NoItemFlags 374 375 def data(self, index: QModelIndex, role=Qt.DisplayRole): 376 if not index.isValid(): 377 return None 378 379 row = index.row() 380 if row >= len(self.rows) or row < 0: 381 return None 382 383 uid = self.rows[row][0] 384 rpd_file = self.rpd_files[uid] # type: RPDFile 385 386 if role == Qt.DisplayRole: 387 # This is never displayed, but is (was?) used for filtering! 388 return rpd_file.modification_time 389 elif role == Roles.highlight: 390 if rpd_file.scan_id == self.currently_highlighting_scan_id: 391 return self.highlight_value 392 else: 393 return 0 394 elif role == Qt.DecorationRole: 395 return self.thumbnails[uid] 396 elif role == Qt.CheckStateRole: 397 if self.rows[row][1]: 398 return Qt.Checked 399 else: 400 return Qt.Unchecked 401 elif role == Roles.sort_extension: 402 return rpd_file.extension 403 elif role == Roles.filename: 404 return rpd_file.name 405 elif role == Roles.previously_downloaded: 406 return rpd_file.previously_downloaded 407 elif role == Roles.extension: 408 return rpd_file.extension, rpd_file.extension_type 409 elif role == Roles.download_status: 410 return rpd_file.status 411 elif role == Roles.job_code: 412 return rpd_file.job_code 413 elif role == Roles.has_audio: 414 return rpd_file.has_audio() 415 elif role == Roles.secondary_attribute: 416 if rpd_file.xmp_file_full_name: 417 return 'XMP' 418 elif rpd_file.log_file_full_name: 419 return 'LOG' 420 else: 421 return None 422 elif role == Roles.path: 423 if rpd_file.status in Downloaded: 424 return rpd_file.download_full_file_name 425 else: 426 return rpd_file.full_file_name 427 elif role == Roles.uri: 428 return rpd_file.get_uri() 429 elif role == Roles.camera_memory_card: 430 return rpd_file.camera_memory_card_identifiers 431 elif role == Roles.mtp: 432 return rpd_file.is_mtp_device 433 elif role == Roles.scan_id: 434 return rpd_file.scan_id 435 elif role == Roles.is_camera: 436 return rpd_file.from_camera 437 elif role == Qt.ToolTipRole: 438 devices = self.rapidApp.devices 439 if len(devices) > 1: 440 # To account for situations where the device has been removed, use 441 # the display name from the device archive 442 device_name = devices.device_archive[rpd_file.scan_id].name 443 else: 444 device_name = '' 445 size = format_size_for_user(rpd_file.size) 446 mtime = arrow.get(rpd_file.modification_time) 447 448 try: 449 mtime_h = mtime.humanize(locale=self.arrow_locale_for_humanize) 450 except Exception: 451 mtime_h = mtime.humanize() 452 logging.debug( 453 "Failed to humanize modification time %s with locale %s, reverting to English", 454 mtime_h, self.arrow_locale_for_humanize 455 ) 456 457 if rpd_file.ctime_mtime_differ(): 458 ctime = arrow.get(rpd_file.ctime) 459 460 # Sadly, arrow raises an exception if it's locale is not translated when using 461 # humanize. So attempt conversion using user's locale, and if that fails, use 462 # English. 463 464 try: 465 ctime_h = ctime.humanize(locale=self.arrow_locale_for_humanize) 466 except Exception: 467 ctime_h = ctime.humanize() 468 logging.debug( 469 "Failed to humanize taken on time %s with locale %s, reverting to English", 470 ctime_h, self.arrow_locale_for_humanize 471 ) 472 473 # Translators: %(variable)s represents Python code, not a plural of the term 474 # variable. You must keep the %(variable)s untranslated, or the program will 475 # crash. 476 humanized_ctime = _( 477 'Taken on %(date_time)s (%(human_readable)s)' 478 ) % dict( 479 date_time=ctime.to('local').naive.strftime('%c'), 480 human_readable=ctime_h 481 ) 482 483 # Translators: %(variable)s represents Python code, not a plural of the term 484 # variable. You must keep the %(variable)s untranslated, or the program will 485 # crash. 486 humanized_mtime = _( 487 'Modified on %(date_time)s (%(human_readable)s)' 488 ) % dict( 489 date_time=mtime.to('local').naive.strftime('%c'), 490 human_readable=mtime_h 491 ) 492 humanized_file_time = '{}<br>{}'.format(humanized_ctime, humanized_mtime) 493 else: 494 # Translators: %(variable)s represents Python code, not a plural of the term 495 # variable. You must keep the %(variable)s untranslated, or the program will 496 # crash. 497 humanized_file_time = _( 498 '%(date_time)s (%(human_readable)s)' 499 ) % dict( 500 date_time=mtime.to('local').naive.strftime('%c'), 501 human_readable=mtime_h 502 ) 503 504 humanized_file_time = humanized_file_time.replace(' ', ' ') 505 506 if not device_name: 507 msg = '<b>{}</b><br>{}<br>{}'.format(rpd_file.name, humanized_file_time, size) 508 else: 509 msg = '<b>{}</b><br>{}<br>{}<br>{}'.format( 510 rpd_file.name, device_name, humanized_file_time, size 511 ) 512 513 if rpd_file.camera_memory_card_identifiers: 514 if len(rpd_file.camera_memory_card_identifiers) > 1: 515 cards = _('Memory cards: %s') % make_internationalized_list( 516 rpd_file.camera_memory_card_identifiers 517 ) 518 else: 519 cards = _('Memory card: %s') % rpd_file.camera_memory_card_identifiers[0] 520 msg += '<br>' + cards 521 522 if rpd_file.status in Downloaded: 523 path = rpd_file.download_path + os.sep 524 downloaded_as = _('Downloaded as:') 525 # Translators: %(variable)s represents Python code, not a plural of the term 526 # variable. You must keep the %(variable)s untranslated, or the program will 527 # crash. 528 # Translators: please do not change HTML codes like <br>, <i>, </i>, or <b>, </b> 529 # etc. 530 msg += '<br><br><i>%(downloaded_as)s</i><br>%(filename)s<br>%(path)s' % dict( 531 filename=rpd_file.download_name, path=path, downloaded_as=downloaded_as 532 ) 533 534 if rpd_file.previously_downloaded: 535 536 prev_datetime = arrow.get(rpd_file.prev_datetime, tzlocal()) 537 try: 538 prev_dt_h = prev_datetime.humanize(locale=self.arrow_locale_for_humanize) 539 except Exception: 540 prev_dt_h = prev_datetime.humanize() 541 logging.debug( 542 "Failed to humanize taken on time %s with locale %s, reverting to English", 543 prev_dt_h, self.arrow_locale_for_humanize 544 ) 545 # Translators: %(variable)s represents Python code, not a plural of the term 546 # variable. You must keep the %(variable)s untranslated, or the program will 547 # crash. 548 prev_date = _('%(date_time)s (%(human_readable)s)') % dict( 549 date_time=prev_datetime.naive.strftime('%c'), 550 human_readable=prev_dt_h 551 ) 552 553 if rpd_file.prev_full_name != manually_marked_previously_downloaded: 554 path, prev_file_name = os.path.split(rpd_file.prev_full_name) 555 path += os.sep 556 # Translators: %(variable)s represents Python code, not a plural of the term 557 # variable. You must keep the %(variable)s untranslated, or the program will 558 # crash. 559 # Translators: please do not change HTML codes like <br>, <i>, </i>, or <b>, 560 # </b> etc. 561 msg += _( 562 '<br><br>Previous download:<br>%(filename)s<br>%(path)s<br>%(date)s' 563 ) % dict(date=prev_date, filename=prev_file_name, path=path) 564 else: 565 # Translators: %(variable)s represents Python code, not a plural of the term 566 # variable. You must keep the %(variable)s untranslated, or the program will 567 # crash. 568 # Translators: please do not change HTML codes like <br>, <i>, </i>, or <b>, 569 # </b> etc. 570 msg += _( 571 '<br><br><i>Manually set as previously downloaded on %(date)s</i>' 572 ) % dict(date=prev_date) 573 return msg 574 575 def setData(self, index: QModelIndex, value, role: int) -> bool: 576 if not index.isValid(): 577 return False 578 579 row = index.row() 580 if row >= len(self.rows) or row < 0: 581 return False 582 uid = self.rows[row][0] 583 if role == Qt.CheckStateRole: 584 self.tsql.set_marked(uid=uid, marked=value) 585 self.rows[row] = (uid, value == True) 586 self.dataChanged.emit(index, index) 587 return True 588 elif role == Roles.job_code: 589 self.rpd_files[uid].job_code = value 590 self.tsql.set_job_code_assigned(uids=[uid], job_code=True) 591 self.dataChanged.emit(index, index) 592 return True 593 return False 594 595 def setDataRange(self, indexes: Tuple[QModelIndex], value, role: int) -> bool: 596 """ 597 Modify a range of indexes simultaneously 598 :param indexes: the indexes 599 :param value: new value to assign 600 :param role: the role the value is associated with 601 :return: True 602 """ 603 valid_rows = (index.row() for index in indexes if index.isValid()) 604 rows = [row for row in valid_rows if 0 <= row < len(self.rows)] 605 rows.sort() 606 uids = [self.rows[row][0] for row in rows] 607 608 if role == Roles.previously_downloaded: 609 logging.debug("Manually setting %s files as previously downloaded", len(uids)) 610 # Set the files as unmarked 611 self.tsql.set_list_marked(uids=uids, marked=False) 612 for row, uid in zip(rows, uids): 613 self.rows[row] = (uid, False) 614 # Set the files as previously downloaded 615 self.tsql.set_list_previously_downloaded(uids=uids, previously_downloaded=value) 616 d = DownloadedSQL() 617 now = datetime.datetime.now() 618 for uid in uids: 619 rpd_file = self.rpd_files[uid] 620 rpd_file.previously_downloaded = value 621 rpd_file.prev_full_name = manually_marked_previously_downloaded 622 rpd_file.prev_datetime = now 623 d.add_downloaded_file( 624 name=rpd_file.name, size=rpd_file.size, 625 modification_time=rpd_file.modification_time, 626 download_full_file_name=manually_marked_previously_downloaded 627 ) 628 # Update Timeline formatting, if needed 629 self.rapidApp.temporalProximity.previouslyDownloadedManuallySet(uids=uids) 630 631 # Indicate to the list view that the rows have changed 632 for first, last in runs(rows): 633 self.dataChanged.emit(self.index(first, 0), self.index(last, 0)) 634 return True 635 636 def assignJobCodesToMarkedFilesWithNoJobCode(self, job_code: str) -> None: 637 """ 638 Called when assigning job codes when a download is initiated and not all 639 files have had a job code assigned to them. 640 641 :param job_code: job code to assign 642 """ 643 644 uids = self.tsql.get_uids(marked=True, job_code=False) 645 logging.debug("Assigning job code to %s files because a download was initiated", len(uids)) 646 for uid in uids: 647 self.rpd_files[uid].job_code = job_code 648 rows = [self.uid_to_row[uid] for uid in uids if uid in self.uid_to_row] 649 rows.sort() 650 for first, last in runs(rows): 651 self.dataChanged.emit(self.index(first, 0), self.index(last, 0)) 652 self.tsql.set_job_code_assigned(uids=uids, job_code=True) 653 654 def updateDisplayPostDataChange(self, scan_id: Optional[int]=None): 655 if scan_id is not None: 656 scan_ids = [scan_id] 657 else: 658 scan_ids = (scan_id for scan_id in self.rapidApp.devices) 659 for scan_id in scan_ids: 660 self.updateDeviceDisplayCheckMark(scan_id=scan_id) 661 self.rapidApp.displayMessageInStatusBar() 662 self.rapidApp.setDownloadCapabilities() 663 664 def removeRows(self, position, rows=1, index=QModelIndex()) -> bool: 665 """ 666 Removes Python list rows only, i.e. self.rows. 667 668 Does not touch database or other variables. 669 """ 670 671 self.beginRemoveRows(QModelIndex(), position, position + rows - 1) 672 del self.rows[position:position + rows] 673 self.endRemoveRows() 674 return True 675 676 def addOrUpdateDevice(self, scan_id: int) -> None: 677 device_name = self.rapidApp.devices[scan_id].display_name 678 self.tsql.add_or_update_device(scan_id=scan_id, device_name=device_name) 679 680 def addFiles(self, scan_id: int, rpd_files: List[RPDFile], generate_thumbnail: bool) -> None: 681 if not rpd_files: 682 return 683 684 thumbnail_rows = deque(maxlen=len(rpd_files)) 685 686 for rpd_file in rpd_files: 687 uid = rpd_file.uid 688 self.rpd_files[uid] = rpd_file 689 690 if rpd_file.file_type == FileType.photo: 691 self.thumbnails[uid] = self.photo_icon 692 else: 693 self.thumbnails[uid] = self.video_icon 694 695 if generate_thumbnail: 696 self.total_thumbs_to_generate += 1 697 self.no_thumbnails_by_scan[rpd_file.scan_id] += 1 698 699 tr = ThumbnailRow( 700 uid=uid, 701 scan_id=rpd_file.scan_id, 702 mtime=rpd_file.modification_time, 703 marked=not rpd_file.previously_downloaded, 704 file_name=rpd_file.name, 705 extension=rpd_file.extension, 706 file_type=rpd_file.file_type, 707 downloaded=False, 708 previously_downloaded=rpd_file.previously_downloaded, 709 job_code=False, 710 proximity_col1=-1, 711 proximity_col2=-1 712 ) 713 714 thumbnail_rows.append(tr) 715 716 self.add_buffer.extend(scan_id=scan_id, thumbnail_rows=thumbnail_rows) 717 718 if self.add_buffer.should_flush(): 719 self.flushAddBuffer() 720 marked_summary = self.getMarkedSummary() 721 destinations_good = self.rapidApp.updateDestinationViews(marked_summary=marked_summary) 722 self.rapidApp.destinationButton.setHighlighted(not destinations_good) 723 if self.prefs.backup_files: 724 backups_good = self.rapidApp.updateBackupView(marked_summary=marked_summary) 725 else: 726 backups_good = True 727 self.rapidApp.destinationButton.setHighlighted(not destinations_good) 728 self.rapidApp.backupButton.setHighlighted(not backups_good) 729 730 def flushAddBuffer(self): 731 if len(self.add_buffer): 732 self.beginResetModel() 733 734 for buffer in self.add_buffer.buffer.values(): 735 self.tsql.add_thumbnail_rows(thumbnail_rows=buffer) 736 self.refresh(suppress_signal=True) 737 738 self.add_buffer.reset(buffer_length=len(self.rows)) 739 740 self.endResetModel() 741 742 self._resetHighlightingValues() 743 self._resetRememberSelection() 744 745 def getMarkedSummary(self) -> MarkedSummary: 746 """ 747 :return: summary of files marked for download including sizes in bytes 748 """ 749 750 size_photos_marked = self.getSizeOfFilesMarkedForDownload(FileType.photo) 751 size_videos_marked = self.getSizeOfFilesMarkedForDownload(FileType.video) 752 marked = self.getNoFilesAndTypesMarkedForDownload() 753 return MarkedSummary(marked=marked, size_photos_marked=size_photos_marked, 754 size_videos_marked=size_videos_marked) 755 756 def setFileSort(self, sort: Sort, order: Qt.SortOrder, show: Show) -> None: 757 if self.sort_by != sort or self.sort_order != order or self.show != show: 758 logging.debug("Changing layout due to sort change: %s, %s, %s", sort, order, show) 759 self.sort_by = sort 760 self.sort_order = order 761 self.show = show 762 self.refresh(rememberSelection=True) 763 764 @pyqtSlot(int, CacheDirs) 765 def cacheDirsReceived(self, scan_id: int, cache_dirs: CacheDirs) -> None: 766 self.rapidApp.fileSystemFilter.setTempDirs([cache_dirs.photo_cache_dir, 767 cache_dirs.video_cache_dir]) 768 if scan_id in self.rapidApp.devices: 769 self.rapidApp.devices[scan_id].photo_cache_dir = cache_dirs.photo_cache_dir 770 self.rapidApp.devices[scan_id].video_cache_dir = cache_dirs.video_cache_dir 771 772 @pyqtSlot(RPDFile, QPixmap) 773 def thumbnailReceived(self, rpd_file: RPDFile, thumbnail: QPixmap) -> None: 774 """ 775 A thumbnail has been generated by either the dedicated thumbnailing phase, or 776 during the download by a daemon process. 777 778 :param rpd_file: details of the file the thumbnail was geneerated for 779 :param thumbnail: If isNull(), the thumbnail either could not be generated or 780 did not need to be (because it already had been). Otherwise, this is 781 the thumbnail to display. 782 """ 783 784 uid = rpd_file.uid 785 scan_id = rpd_file.scan_id 786 787 if uid not in self.rpd_files or scan_id not in self.rapidApp.devices: 788 # A thumbnail has been generated for a no longer displayed file 789 return 790 791 download_is_running = self.rapidApp.downloadIsRunning() 792 793 if rpd_file.mdatatime_caused_ctime_change and not rpd_file.modified_via_daemon_process: 794 rpd_file.mdatatime_caused_ctime_change = False 795 if scan_id not in self.ctimes_differ: 796 self.addCtimeDisparity(rpd_file=rpd_file) 797 798 if not rpd_file.modified_via_daemon_process and self.rpd_files[uid].status in ( 799 DownloadStatus.not_downloaded, DownloadStatus.download_pending): 800 # Only update the rpd_file if the file has not already been downloaded 801 # TODO consider merging this no matter what the status 802 self.rpd_files[uid] = rpd_file 803 804 if not thumbnail.isNull(): 805 self.thumbnails[uid] = thumbnail 806 # The thumbnail may or may not be displayed at this moment 807 row = self.uid_to_row.get(uid) 808 if row is not None: 809 # logging.debug("Updating thumbnail row %s with new thumbnail", row) 810 self.dataChanged.emit(self.index(row, 0), self.index(row, 0)) 811 else: 812 logging.debug("Thumbnail was null: %s", rpd_file.name) 813 814 if not rpd_file.modified_via_daemon_process: 815 self.thumbnails_generated += 1 816 self.no_thumbnails_by_scan[scan_id] -= 1 817 log_state = False 818 if self.no_thumbnails_by_scan[scan_id] == 0: 819 if self.rapidApp.deviceState(scan_id) == DeviceState.thumbnailing: 820 self.rapidApp.devices.set_device_state(scan_id, DeviceState.idle) 821 device = self.rapidApp.devices[scan_id] 822 logging.info('Finished thumbnail generation for %s', device.name()) 823 824 if scan_id in self.ctimes_differ: 825 uids = self.tsql.get_uids_for_device(scan_id=scan_id) 826 rpd_files = [self.rpd_files[uid] for uid in uids] 827 self.rapidApp.folder_preview_manager.add_rpd_files(rpd_files=rpd_files) 828 self.processCtimeDisparity(scan_id=scan_id) 829 log_state = True 830 831 if self.thumbnails_generated == self.total_thumbs_to_generate: 832 self.thumbnails_generated = 0 833 self.total_thumbs_to_generate = 0 834 if not download_is_running: 835 self.rapidApp.updateProgressBarState() 836 elif self.total_thumbs_to_generate and not download_is_running: 837 self.rapidApp.updateProgressBarState(thumbnail_generated=True) 838 839 if not download_is_running: 840 self.rapidApp.displayMessageInStatusBar() 841 842 if log_state: 843 self.logState() 844 845 else: 846 self.rapidApp.thumbnailGeneratedPostDownload(rpd_file=rpd_file) 847 848 def addCtimeDisparity(self, rpd_file: RPDFile) -> None: 849 """ 850 Track the fact that there was a disparity between the creation time and 851 modification time for a file, that was identified either during a download 852 or during a scan 853 :param rpd_file: sample rpd_file (scan id of the device will be taken from it) 854 """ 855 856 logging.info( 857 'Metadata time differs from file modification time for ' 858 '%s (with possibly more to come, but these will not be logged)', 859 rpd_file.full_file_name 860 ) 861 862 scan_id = rpd_file.scan_id 863 self.ctimes_differ.append(scan_id) 864 self.rapidApp.temporalProximity.setState(TemporalProximityState.ctime_rebuild) 865 if not self.rapidApp.downloadIsRunning(): 866 self.rapidApp.folder_preview_manager.remove_folders_for_device( 867 scan_id=scan_id 868 ) 869 870 def processCtimeDisparity(self, scan_id: int) -> None: 871 """ 872 A device that had a disparity between the creation time and 873 modification time for a file has been fully downloaded from. 874 875 :param scan_id: 876 :return: 877 """ 878 self.ctimes_differ.remove(scan_id) 879 if not self.ctimes_differ: 880 self.rapidApp.temporalProximity.setState(TemporalProximityState.ctime_rebuild_proceed) 881 self.rapidApp.generateTemporalProximityTableData( 882 reason="a photo or video's creation time differed from its file system " 883 "modification time" 884 ) 885 886 def _get_cache_location(self, download_folder: str) -> str: 887 if validate_download_folder(download_folder).valid: 888 return download_folder 889 else: 890 folder = get_program_cache_directory(create_if_not_exist=True) 891 if folder is not None: 892 return folder 893 else: 894 return os.path.expanduser('~') 895 896 def getCacheLocations(self) -> CacheDirs: 897 photo_cache_folder = self._get_cache_location(self.rapidApp.prefs.photo_download_folder) 898 video_cache_folder = self._get_cache_location(self.rapidApp.prefs.video_download_folder) 899 return CacheDirs(photo_cache_folder, video_cache_folder) 900 901 def generateThumbnails(self, scan_id: int, device: Device) -> None: 902 """Initiates generation of thumbnails for the device.""" 903 904 if scan_id not in self.removed_devices: 905 self.generating_thumbnails.add(scan_id) 906 self.rapidApp.updateProgressBarState() 907 cache_dirs = self.getCacheLocations() 908 uids = self.tsql.get_uids_for_device(scan_id=scan_id) 909 rpd_files = list((self.rpd_files[uid] for uid in uids)) 910 911 need_video_cache_dir = need_photo_cache_dir = False 912 if device.device_type == DeviceType.camera: 913 need_video_cache_dir = device.entire_video_required or \ 914 self.tsql.any_files_of_type(scan_id, FileType.video) 915 # defer check to see if ExifTool is needed until later 916 need_photo_cache_dir = device.entire_photo_required 917 918 gen_args = ( 919 scan_id, rpd_files, device.name(), self.rapidApp.prefs.proximity_seconds, 920 cache_dirs, need_photo_cache_dir, need_video_cache_dir, device.camera_model, 921 device.camera_port, device.entire_video_required, device.entire_photo_required 922 ) 923 self.thumbnailer.generateThumbnails(*gen_args) 924 925 def resetThumbnailTracking(self): 926 self.thumbnails_generated = 0 927 self.total_thumbs_to_generate = 0 928 929 def _deleteRows(self, uids: List[bytes]) -> None: 930 """ 931 Delete a list of thumbnails from the thumbnail display 932 933 :param uids: files to remove 934 """ 935 936 rows = [self.uid_to_row[uid] for uid in uids] 937 938 if rows: 939 # Generate groups of rows, and remove that group 940 # Must do it in reverse! 941 rows.sort() 942 rrows = reversed(list(runs(rows))) 943 for first, last in rrows: 944 no_rows = last - first + 1 945 self.removeRows(first, no_rows) 946 947 self.uid_to_row = {row[0]: idx for idx, row in enumerate(self.rows)} 948 949 def purgeRpdFiles(self, uids: List[bytes]) -> None: 950 for uid in uids: 951 del self.thumbnails[uid] 952 del self.rpd_files[uid] 953 954 def clearAll(self, scan_id: Optional[int]=None, keep_downloaded_files: bool=False) -> bool: 955 """ 956 Removes files from display and internal tracking. 957 958 If scan_id is not None, then only files matching that scan_id 959 will be removed. Otherwise, everything will be removed, regardless of 960 the keep_downloaded_files parameter.. 961 962 If keep_downloaded_files is True, files will not be removed if 963 they have been downloaded. 964 965 Two aspects to this task: 966 1. remove files list of rows which drive the list view display 967 2. remove files from backend DB and from thumbnails and rpd_files lists. 968 969 :param scan_id: if None, keep_downloaded_files must be False 970 :param keep_downloaded_files: don't remove thumbnails if they represent 971 files that have now been downloaded. Ignored if no device is passed. 972 :return: True if any thumbnail was removed (irrespective of whether 973 it was displayed at this moment), else False 974 """ 975 976 if scan_id is None and not keep_downloaded_files: 977 files_removed = self.tsql.any_files() 978 logging.debug("Clearing all thumbnails for all devices") 979 self.initialize() 980 return files_removed 981 else: 982 assert scan_id is not None 983 984 if not keep_downloaded_files: 985 files_removed = self.tsql.any_files(scan_id=scan_id) 986 else: 987 files_removed = self.tsql.any_files_to_download(scan_id=scan_id) 988 989 if keep_downloaded_files: 990 logging.debug("Clearing all non-downloaded thumbnails for scan id %s", scan_id) 991 else: 992 logging.debug("Clearing all thumbnails for scan id %s", scan_id) 993 # Generate list of displayed thumbnails to remove 994 if keep_downloaded_files: 995 uids = self.getDisplayedUids(scan_id=scan_id) 996 else: 997 uids = self.getDisplayedUids(scan_id=scan_id, downloaded=None) 998 999 self._deleteRows(uids) 1000 1001 # Delete from DB and thumbnails and rpd_files lists 1002 if keep_downloaded_files: 1003 uids = self.tsql.get_uids(scan_id=scan_id, downloaded=False) 1004 else: 1005 uids = self.tsql.get_uids(scan_id=scan_id) 1006 1007 logging.debug("Removing %s thumbnail and rpd_files rows", len(uids)) 1008 self.purgeRpdFiles(uids) 1009 1010 uids = [row.uid for row in self.add_buffer[scan_id]] 1011 if uids: 1012 logging.debug("Removing additional %s thumbnail and rpd_files rows", len(uids)) 1013 self.purgeRpdFiles(uids) 1014 1015 self.add_buffer.purge(scan_id=scan_id) 1016 self.add_buffer.set_buffer_length(len(self.rows)) 1017 1018 if keep_downloaded_files: 1019 self.tsql.delete_files_by_scan_id(scan_id=scan_id, downloaded=False) 1020 else: 1021 self.tsql.delete_files_by_scan_id(scan_id=scan_id) 1022 1023 self.removed_devices.add(scan_id) 1024 1025 if scan_id in self.no_thumbnails_by_scan: 1026 self.recalculateThumbnailsPercentage(scan_id=scan_id) 1027 self.rapidApp.displayMessageInStatusBar() 1028 1029 if self.tsql.get_count(scan_id=scan_id) == 0: 1030 self.tsql.delete_device(scan_id=scan_id) 1031 1032 if scan_id in self.ctimes_differ: 1033 self.ctimes_differ.remove(scan_id) 1034 1035 # self.validateModelConsistency() 1036 1037 return files_removed 1038 1039 def clearCompletedDownloads(self) -> None: 1040 logging.debug("Clearing all completed download thumbnails") 1041 1042 # Get uids for complete downloads that are currently displayed 1043 uids = self.getDisplayedUids(downloaded=True) 1044 self._deleteRows(uids) 1045 1046 # Now get uids of all downloaded files, regardless of whether they're 1047 # displayed at the moment 1048 uids = self.tsql.get_uids(downloaded=True) 1049 logging.debug("Removing %s thumbnail and rpd_files rows", len(uids)) 1050 self.purgeRpdFiles(uids) 1051 1052 # Delete the files from the internal database that drives the display 1053 self.tsql.delete_uids(uids) 1054 1055 def filesAreMarkedForDownload(self, scan_id: Optional[int]=None) -> bool: 1056 """ 1057 Checks for the presence of checkmark besides any file that has 1058 not yet been downloaded. 1059 1060 :param: scan_id: if specified, only for that device 1061 :return: True if there is any file that the user has indicated 1062 they intend to download, else False. 1063 """ 1064 1065 return self.tsql.any_files_marked(scan_id=scan_id) 1066 1067 def getNoFilesMarkedForDownload(self) -> int: 1068 return self.tsql.get_count(marked=True) 1069 1070 def getNoHiddenFiles(self) -> int: 1071 if self.rapidApp.showOnlyNewFiles(): 1072 return self.tsql.get_count(previously_downloaded=True, downloaded=False) 1073 else: 1074 return 0 1075 1076 def getNoFilesAndTypesMarkedForDownload(self) -> FileTypeCounter: 1077 no_photos = self.tsql.get_count(marked=True, file_type=FileType.photo) 1078 no_videos = self.tsql.get_count(marked=True, file_type=FileType.video) 1079 f = FileTypeCounter() 1080 f[FileType.photo] = no_photos 1081 f[FileType.video] = no_videos 1082 return f 1083 1084 def getSizeOfFilesMarkedForDownload(self, file_type: FileType) -> int: 1085 uids = self.tsql.get_uids(marked=True, file_type=file_type) 1086 return sum(self.rpd_files[uid].size for uid in uids) 1087 1088 def getNoFilesAvailableForDownload(self) -> FileTypeCounter: 1089 no_photos = self.tsql.get_count(downloaded=False, file_type=FileType.photo) 1090 no_videos = self.tsql.get_count(downloaded=False, file_type=FileType.video) 1091 f = FileTypeCounter() 1092 f[FileType.photo] = no_photos 1093 f[FileType.video] = no_videos 1094 return f 1095 1096 def getNoFilesSelected(self) -> FileTypeCounter: 1097 selection = self._selectionModel() 1098 selected = selection.selection() # type: QItemSelection 1099 1100 if not len(selected) == len(self.rows): 1101 # not all files are selected 1102 selected_uids = [self.rows[index.row()][0] for index in selected.indexes()] 1103 return FileTypeCounter(self.rpd_files[uid].file_type for uid in selected_uids) 1104 else: 1105 return self.getDisplayedCounter() 1106 1107 def getCountNotPreviouslyDownloadedAvailableForDownload(self) -> int: 1108 return self.tsql.get_count(previously_downloaded=False, downloaded=False) 1109 1110 def getAllDownloadableRPDFiles(self) -> List[RPDFile]: 1111 uids = self.tsql.get_uids(downloaded=False) 1112 return [self.rpd_files[uid] for uid in uids] 1113 1114 def getFilesMarkedForDownload(self, scan_id: Optional[int]) -> DownloadFiles: 1115 """ 1116 Returns a dict of scan ids and associated files the user has 1117 indicated they want to download, and whether there are photos 1118 or videos included in the download. 1119 1120 Exclude files from which a device is still scanning. 1121 1122 :param scan_id: if not None, then returns those files only from 1123 the device associated with that scan_id 1124 :return: namedtuple DownloadFiles with defaultdict() indexed by 1125 scan_id with value List(rpd_file), and defaultdict() indexed by 1126 scan_id with value DownloadStats 1127 """ 1128 1129 if scan_id is None: 1130 exclude_scan_ids = list(self.rapidApp.devices.scanning) 1131 else: 1132 exclude_scan_ids = None 1133 1134 files = defaultdict(list) 1135 download_stats = defaultdict(DownloadStats) 1136 camera_access_needed = defaultdict(bool) 1137 download_photos = download_videos = False 1138 1139 uids = self.tsql.get_uids(scan_id=scan_id, marked=True, downloaded=False, 1140 exclude_scan_ids=exclude_scan_ids) 1141 1142 for uid in uids: 1143 rpd_file = self.rpd_files[uid] # type: RPDFile 1144 1145 scan_id = rpd_file.scan_id 1146 files[scan_id].append(rpd_file) 1147 1148 # TODO contemplate using a counter here 1149 if rpd_file.file_type == FileType.photo: 1150 download_photos = True 1151 download_stats[scan_id].no_photos += 1 1152 download_stats[scan_id].photos_size_in_bytes += rpd_file.size 1153 else: 1154 download_videos = True 1155 download_stats[scan_id].no_videos += 1 1156 download_stats[scan_id].videos_size_in_bytes += rpd_file.size 1157 if rpd_file.from_camera and not rpd_file.cache_full_file_name: 1158 camera_access_needed[scan_id] = True 1159 1160 # Need to generate a thumbnail after a file has been downloaded 1161 # if generating FDO thumbnails or if the orientation of the 1162 # thumbnail we may have is unknown 1163 1164 if self.sendToDaemonThumbnailer(rpd_file=rpd_file): 1165 download_stats[scan_id].post_download_thumb_generation += 1 1166 1167 # self.validateModelConsistency() 1168 if download_photos: 1169 if download_videos: 1170 download_types = DownloadingFileTypes.photos_and_videos 1171 else: 1172 download_types = DownloadingFileTypes.photos 1173 elif download_videos: 1174 download_types = DownloadingFileTypes.videos 1175 else: 1176 download_types = None 1177 1178 return DownloadFiles( 1179 files=files, 1180 download_types=download_types, 1181 download_stats=download_stats, 1182 camera_access_needed=camera_access_needed 1183 ) 1184 1185 def sendToDaemonThumbnailer(self, rpd_file: RPDFile) -> bool: 1186 """ 1187 Determine if the file needs to be sent for thumbnail generation 1188 by the post download daemon. 1189 1190 :param rpd_file: file to analyze 1191 :return: True if need to send, False otherwise 1192 """ 1193 1194 return (self.prefs.generate_thumbnails and 1195 ((self.prefs.save_fdo_thumbnails and rpd_file.should_write_fdo()) or 1196 rpd_file.thumbnail_status not in (ThumbnailCacheStatus.ready, 1197 ThumbnailCacheStatus.fdo_256_ready))) 1198 1199 def markDownloadPending(self, files: Dict[int, List[RPDFile]]) -> None: 1200 """ 1201 Sets status to download pending and updates thumbnails display. 1202 1203 Assumes all marked files are being downloaded. 1204 1205 :param files: rpd_files by scan 1206 """ 1207 1208 uids = [rpd_file.uid for scan_id in files for rpd_file in files[scan_id]] 1209 rows = [self.uid_to_row[uid] for uid in uids if uid in self.uid_to_row] 1210 for row in rows: 1211 uid = self.rows[row][0] 1212 self.rows[row] = (uid, False) 1213 self.tsql.set_list_marked(uids=uids, marked=False) 1214 1215 for uid in uids: 1216 self.rpd_files[uid].status = DownloadStatus.download_pending 1217 1218 rows.sort() 1219 for first, last in runs(rows): 1220 self.dataChanged.emit(self.index(first, 0), self.index(last, 0)) 1221 1222 def markThumbnailsNeeded(self, rpd_files: List[RPDFile]) -> bool: 1223 """ 1224 Analyzes the files that will be downloaded, and sees if any of 1225 them still need to have their thumbnails generated. 1226 1227 Marks generate_thumbnail in each rpd_file those for that need 1228 thumbnails. 1229 1230 :param rpd_files: list of files to examine 1231 :return: True if at least one thumbnail needs to be generated 1232 """ 1233 1234 generation_needed = False 1235 for rpd_file in rpd_files: 1236 if rpd_file.uid not in self.thumbnails: 1237 rpd_file.generate_thumbnail = True 1238 generation_needed = True 1239 return generation_needed 1240 1241 def getNoFilesRemaining(self, scan_id: Optional[int]=None) -> int: 1242 """ 1243 :param scan_id: if None, returns files remaining to be 1244 downloaded for all scan_ids, else only for that scan_id. 1245 :return the number of files that have not yet been downloaded 1246 """ 1247 1248 return self.tsql.get_count(scan_id=scan_id, downloaded=False) 1249 1250 def updateSelectionAfterProximityChange(self) -> None: 1251 if self._selectionModel().hasSelection(): 1252 # completely reset the existing selection 1253 self._selectionModel().reset() 1254 self.dataChanged.emit(self.index(0, 0), self.index(len(self.rows)-1, 0)) 1255 1256 select_all_photos = self.rapidApp.selectAllPhotosCheckbox.isChecked() 1257 select_all_videos = self.rapidApp.selectAllVideosCheckbox.isChecked() 1258 if select_all_photos: 1259 self.selectAll(select_all=select_all_photos, file_type=FileType.photo) 1260 if select_all_videos: 1261 self.selectAll(select_all=select_all_videos, file_type=FileType.video) 1262 1263 def selectAll(self, select_all: bool, file_type: FileType)-> None: 1264 """ 1265 Check or deselect all visible files that are not downloaded. 1266 1267 :param select_all: if True, select, else deselect 1268 :param file_type: the type of files to select/deselect 1269 """ 1270 1271 uids = self.getDisplayedUids(file_type=file_type) 1272 1273 if not uids: 1274 return 1275 1276 if select_all: 1277 action = "Selecting all %s" 1278 else: 1279 action = "Deslecting all %ss" 1280 1281 logging.debug(action, file_type.name) 1282 1283 selection = self._selectionModel() 1284 selected = selection.selection() # type: QItemSelection 1285 1286 if select_all: 1287 # print("gathering unique ids") 1288 rows = [self.uid_to_row[uid] for uid in uids] 1289 # print(len(rows)) 1290 # print('doing sort') 1291 rows.sort() 1292 new_selection = QItemSelection() # type: QItemSelection 1293 # print("creating new selection") 1294 for first, last in runs(rows): 1295 new_selection.select(self.index(first, 0), self.index(last, 0)) 1296 # print('merging select') 1297 new_selection.merge(selected, QItemSelectionModel.Select) 1298 # print('resetting') 1299 selection.reset() 1300 # print('doing select') 1301 selection.select(new_selection, QItemSelectionModel.Select) 1302 else: 1303 # print("gathering unique ids from existing selection") 1304 if file_type == FileType.photo: 1305 keep_type = FileType.video 1306 else: 1307 keep_type = FileType.photo 1308 # print("filtering", keep_type) 1309 keep_rows = [index.row() for index in selected.indexes() 1310 if self.rpd_files[self.rows[index.row()][0]].file_type == keep_type] 1311 rows = [index.row() for index in selected.indexes()] 1312 # print(len(keep_rows), len(rows)) 1313 # print("sorting rows to keep") 1314 keep_rows.sort() 1315 new_selection = QItemSelection() # type: QItemSelection 1316 # print("creating new selection") 1317 for first, last in runs(keep_rows): 1318 new_selection.select(self.index(first, 0), self.index(last, 0)) 1319 # print('resetting') 1320 selection.reset() 1321 self.selectionReset.emit() 1322 # print('doing select') 1323 selection.select(new_selection, QItemSelectionModel.Select) 1324 1325 # print('doing data changed') 1326 for first, last in runs(rows): 1327 self.dataChanged.emit(self.index(first, 0), self.index(last, 0)) 1328 # print("finished") 1329 1330 def checkAll(self, check_all: bool, 1331 file_type: Optional[FileType]=None, 1332 scan_id: Optional[int]=None) -> None: 1333 """ 1334 Check or uncheck all visible files that are not downloaded. 1335 1336 A file is "visible" if it is in the current thumbnail display. 1337 That means if files are not showing because they are previously 1338 downloaded, they will not be affected. Likewise, if temporal 1339 proximity rows are selected, only those files are affected. 1340 1341 Runs in the main thread and is thus time sensitive. 1342 1343 :param check_all: if True, mark as checked, else unmark 1344 :param file_type: if specified, files must be of specified type 1345 :param scan_id: if specified, affects only files for that scan 1346 """ 1347 1348 uids = self.getDisplayedUids(marked=not check_all, file_type=file_type, scan_id=scan_id) 1349 self.tsql.set_list_marked(uids=uids, marked=check_all) 1350 rows = [self.uid_to_row[uid] for uid in uids] 1351 for row in rows: 1352 self.rows[row] = (self.rows[row][0], check_all) 1353 rows.sort() 1354 for first, last in runs(rows): 1355 self.dataChanged.emit(self.index(first, 0), self.index(last, 0)) 1356 1357 self.updateDeviceDisplayCheckMark(scan_id=scan_id) 1358 self.rapidApp.displayMessageInStatusBar() 1359 self.rapidApp.setDownloadCapabilities() 1360 1361 def getTypeCountForProximityCell(self, col1id: Optional[int]=None, 1362 col2id: Optional[int]=None) -> str: 1363 """ 1364 Generates a string displaying how many photos and videos are 1365 in the proximity table cell 1366 """ 1367 assert not (col1id is None and col2id is None) 1368 if col2id is not None: 1369 col2id = [col2id] 1370 else: 1371 col1id = [col1id] 1372 uids = self.tsql.get_uids(proximity_col1=col1id, proximity_col2=col2id) 1373 file_types = (self.rpd_files[uid].file_type for uid in uids) 1374 return FileTypeCounter(file_types).summarize_file_count()[0] 1375 1376 def getDisplayedUids(self, scan_id: Optional[int]=None, 1377 marked: Optional[bool]=None, 1378 file_type: Optional[FileType]=None, 1379 downloaded: Optional[bool]=False) -> List[bytes]: 1380 return self.tsql.get_uids(scan_id=scan_id, downloaded=downloaded, show=self.show, 1381 proximity_col1=self.proximity_col1, 1382 proximity_col2=self.proximity_col2, 1383 marked=marked, file_type=file_type) 1384 1385 def getFirstUidFromUidList(self, uids: List[bytes]) -> Optional[bytes]: 1386 return self.tsql.get_first_uid_from_uid_list( 1387 sort_by=self.sort_by, sort_order=self.sort_order, 1388 show=self.show, proximity_col1=self.proximity_col1, 1389 proximity_col2=self.proximity_col2, 1390 uids=uids 1391 ) 1392 1393 def getDisplayedCount(self, scan_id: Optional[int] = None, 1394 marked: Optional[bool] = None) -> int: 1395 return self.tsql.get_count(scan_id=scan_id, downloaded=False, show=self.show, 1396 proximity_col1=self.proximity_col1, 1397 proximity_col2=self.proximity_col2, marked=marked) 1398 1399 def getDisplayedCounter(self) -> FileTypeCounter: 1400 no_photos = self.tsql.get_count(downloaded=False, file_type=FileType.photo, show=self.show, 1401 proximity_col1=self.proximity_col1, 1402 proximity_col2=self.proximity_col2) 1403 no_videos = self.tsql.get_count(downloaded=False, file_type=FileType.video, show=self.show, 1404 proximity_col1=self.proximity_col1, 1405 proximity_col2=self.proximity_col2) 1406 f = FileTypeCounter() 1407 f[FileType.photo] = no_photos 1408 f[FileType.video] = no_videos 1409 return f 1410 1411 def _getSampleFileNonCamera(self, file_type: FileType) -> Optional[RPDFile]: 1412 """ 1413 Attempt to return a sample file used to illustrate file renaming and subfolder 1414 generation, but only if it's not from a camera. 1415 :return: 1416 """ 1417 1418 devices = self.rapidApp.devices 1419 exclude_scan_ids = [s_id for s_id, device in devices.devices.items() 1420 if device.device_type == DeviceType.camera] 1421 if not exclude_scan_ids: 1422 exclude_scan_ids = None 1423 1424 uid = self.tsql.get_single_file_of_type(file_type=file_type, 1425 exclude_scan_ids=exclude_scan_ids) 1426 if uid is not None: 1427 return self.rpd_files[uid] 1428 else: 1429 return None 1430 1431 def getSampleFile(self, scan_id: int, 1432 device_type: DeviceType, 1433 file_type: FileType) -> Optional[RPDFile]: 1434 """ 1435 Attempt to return a sample file used to illustrate file renaming and subfolder 1436 generation. 1437 1438 If the device_type is a camera, then search only for 1439 a downloaded instance of the file. 1440 1441 If the device is not a camera, prefer a non-downloaded file 1442 over a downloaded file for that scan_id. 1443 1444 If no file is available for that scan_id, try again with another scan_id. 1445 1446 :param scan_id: 1447 :param device_type: 1448 :param file_type: 1449 :return: 1450 """ 1451 1452 if device_type == DeviceType.camera: 1453 uid = self.tsql.get_single_file_of_type( 1454 scan_id=scan_id, file_type=file_type, downloaded=True 1455 ) 1456 if uid is not None: 1457 return self.rpd_files[uid] 1458 else: 1459 # try find a *downloaded* file from another camera 1460 1461 # could determine which devices to exclude in SQL but it's a little simpler 1462 # here 1463 devices = self.rapidApp.devices 1464 exclude_scan_ids = [s_id for s_id, device in devices.items() 1465 if device.device_type != DeviceType.camera] 1466 1467 if not exclude_scan_ids: 1468 exclude_scan_ids = None 1469 1470 uid = self.tsql.get_single_file_of_type( 1471 file_type=file_type, downloaded=True, exclude_scan_ids=exclude_scan_ids 1472 ) 1473 if uid is not None: 1474 return self.rpd_files[uid] 1475 else: 1476 return self._getSampleFileNonCamera(file_type=file_type) 1477 1478 else: 1479 uid = self.tsql.get_single_file_of_type(scan_id=scan_id, file_type=file_type) 1480 if uid is not None: 1481 return self.rpd_files[uid] 1482 else: 1483 return self._getSampleFileNonCamera(file_type=file_type) 1484 1485 def updateDeviceDisplayCheckMark(self, scan_id: int) -> None: 1486 if scan_id not in self.removed_devices: 1487 uid_count = self.getDisplayedCount(scan_id=scan_id) 1488 checked_uid_count = self.getDisplayedCount(scan_id=scan_id, marked=True) 1489 if uid_count == 0 or checked_uid_count == 0: 1490 checked = Qt.Unchecked 1491 elif uid_count != checked_uid_count: 1492 checked = Qt.PartiallyChecked 1493 else: 1494 checked = Qt.Checked 1495 self.rapidApp.mapModel(scan_id).setCheckedValue(checked, scan_id) 1496 1497 def updateAllDeviceDisplayCheckMarks(self) -> None: 1498 for scan_id in self.rapidApp.devices: 1499 self.updateDeviceDisplayCheckMark(scan_id=scan_id) 1500 1501 def highlightDeviceThumbs(self, scan_id) -> None: 1502 """ 1503 Animate fade to and from highlight color for thumbnails associated 1504 with device. 1505 :param scan_id: device's id 1506 """ 1507 1508 if scan_id == self.currently_highlighting_scan_id: 1509 return 1510 1511 self.resetHighlighting() 1512 1513 self.currently_highlighting_scan_id = scan_id 1514 if scan_id != self.most_recent_highlighted_device: 1515 highlighting = [self.uid_to_row[uid] for uid in self.getDisplayedUids(scan_id=scan_id)] 1516 highlighting.sort() 1517 self.highlighting_rows = list(runs(highlighting)) 1518 self.most_recent_highlighted_device = scan_id 1519 self.highlighting_timeline.setDirection(QTimeLine.Forward) 1520 self.highlighting_timeline.start() 1521 1522 def resetHighlighting(self) -> None: 1523 if self.currently_highlighting_scan_id is not None: 1524 self.highlighting_timeline.stop() 1525 self.doHighlightDeviceThumbs(value=0) 1526 1527 @pyqtSlot(int) 1528 def doHighlightDeviceThumbs(self, value: int) -> None: 1529 self.highlight_value = value 1530 for first, last in self.highlighting_rows: 1531 self.dataChanged.emit(self.index(first, 0), self.index(last, 0)) 1532 1533 @pyqtSlot() 1534 def highlightPhaseFinished(self): 1535 self.currently_highlighting_scan_id = None 1536 1537 def _resetHighlightingValues(self): 1538 self.most_recent_highlighted_device = None # type: Optional[int] 1539 self.highlighting_rows = [] # type: List[int] 1540 1541 def terminateThumbnailGeneration(self, scan_id: int) -> bool: 1542 """ 1543 Terminates thumbnail generation if thumbnails are currently 1544 being generated for this scan_id 1545 :return True if thumbnail generation had to be terminated, else 1546 False 1547 """ 1548 1549 # the slot for when a thumbnailing operation is terminated is in the 1550 # main window - thumbnailGenerationStopped() 1551 terminate = scan_id in self.generating_thumbnails 1552 if terminate: 1553 self.thumbnailer.stop_worker(scan_id) 1554 # TODO update this check once checking for thumnbnailing code is more robust 1555 # note that check == 1 because it is assumed the scan id has not been deleted 1556 # from the device collection 1557 if len(self.rapidApp.devices.thumbnailing) == 1: 1558 self.resetThumbnailTracking() 1559 else: 1560 self.recalculateThumbnailsPercentage(scan_id=scan_id) 1561 return terminate 1562 1563 def recalculateThumbnailsPercentage(self, scan_id: int) -> None: 1564 """ 1565 Adjust % of thumbnails generated calculations after device removal. 1566 1567 :param scan_id: id of removed device 1568 """ 1569 1570 self.total_thumbs_to_generate -= self.no_thumbnails_by_scan[scan_id] 1571 self.rapidApp.updateProgressBarState() 1572 del self.no_thumbnails_by_scan[scan_id] 1573 1574 def updateStatusPostDownload(self, rpd_file: RPDFile): 1575 # self.validateModelConsistency() 1576 1577 uid = rpd_file.uid 1578 self.rpd_files[uid] = rpd_file 1579 self.tsql.set_downloaded(uid=uid, downloaded=True) 1580 row = self.uid_to_row.get(uid) 1581 1582 if row is not None: 1583 self.dataChanged.emit(self.index(row, 0), self.index(row, 0)) 1584 1585 def filesRemainToDownload(self, scan_id: Optional[int]=None) -> bool: 1586 """ 1587 :return True if any files remain that are not downloaded, else 1588 returns False 1589 """ 1590 return self.tsql.any_files_to_download(scan_id) 1591 1592 def dataForProximityGeneration(self) -> List[ThumbnailDataForProximity]: 1593 return [ThumbnailDataForProximity(uid=rpd_file.uid, 1594 ctime=rpd_file.ctime, 1595 file_type=rpd_file.file_type, 1596 previously_downloaded=rpd_file.previously_downloaded) 1597 for rpd_file in self.rpd_files.values()] 1598 1599 def assignProximityGroups(self, col1_col2_uid: List[Tuple[int, int, bytes]]) -> None: 1600 """ 1601 For every uid, associates it with a cell in the temporal proximity view. 1602 1603 Relevant columns are col 1 and col 2. 1604 """ 1605 1606 self.tsql.assign_proximity_groups(col1_col2_uid) 1607 1608 def setProximityGroupFilter(self, col1: Optional[Sequence[int]], 1609 col2: Optional[Sequence[int]]) -> None: 1610 """ 1611 Filter display of thumbnails based on what cells the user has clicked in the 1612 Temporal Proximity view. 1613 1614 Relevant columns are col 1 and col 2. 1615 """ 1616 1617 if col1 != self.proximity_col1 or col2 != self.proximity_col2: 1618 self.proximity_col1 = col1 1619 self.proximity_col2 = col2 1620 self.refresh() 1621 1622 def anyCheckedFilesFiltered(self) -> bool: 1623 """ 1624 :return: True if any files checked for download are currently 1625 not displayed because they are filtered 1626 """ 1627 1628 return self.tsql.get_count(marked=True) != self.getDisplayedCount(marked=True) 1629 1630 def anyFileNotPreviouslyDownloaded(self, uids: List[bytes]) -> bool: 1631 return self.tsql.any_not_previously_downloaded(uids=uids) 1632 1633 def getFileDownloadsCompleted(self) -> FileTypeCounter: 1634 """ 1635 :return: counter for how many photos and videos have their downloads completed 1636 whether successfully or not 1637 """ 1638 1639 return FileTypeCounter( 1640 { 1641 FileType.photo: self.tsql.get_count(downloaded=True, file_type=FileType.photo), 1642 FileType.video: self.tsql.get_count(downloaded=True, file_type=FileType.video) 1643 } 1644 ) 1645 1646 def anyCompletedDownloads(self) -> bool: 1647 """ 1648 :return: True if any files have been downloaded (including failures) 1649 """ 1650 1651 return self.tsql.any_files_download_completed() 1652 1653 def jobCodeNeeded(self) -> bool: 1654 """ 1655 :return: True if any files checked for download do not have job codes 1656 assigned to them 1657 """ 1658 1659 return self.tsql.any_marked_file_no_job_code() 1660 1661 def getNoFilesJobCodeNeeded(self) -> FileTypeCounter: 1662 """ 1663 :return: the number of marked files that need a job code assigned to them, and the 1664 file types they will be applied to. 1665 """ 1666 1667 no_photos = no_videos = 0 1668 if self.prefs.file_type_uses_job_code(FileType.photo): 1669 no_photos = self.tsql.get_count(marked=True, file_type=FileType.photo, job_code=False) 1670 if self.prefs.file_type_uses_job_code(FileType.video): 1671 no_videos = self.tsql.get_count(marked=True, file_type=FileType.video, job_code=False) 1672 1673 f = FileTypeCounter() 1674 f[FileType.photo] = no_photos 1675 f[FileType.video] = no_videos 1676 1677 return f 1678 1679 1680class ThumbnailView(QListView): 1681 1682 def __init__(self, parent: QWidget) -> None: 1683 style = """QAbstractScrollArea { background-color: %s;}""" % ThumbnailBackgroundName 1684 super().__init__(parent) 1685 self.rapidApp = parent 1686 self.setViewMode(QListView.IconMode) 1687 self.setResizeMode(QListView.Adjust) 1688 self.setStyleSheet(style) 1689 self.setUniformItemSizes(True) 1690 self.setSpacing(8) 1691 self.setSelectionMode(QAbstractItemView.ExtendedSelection) 1692 1693 self.possiblyPreserveSelectionPostClick = False 1694 1695 def setScrollTogether(self, on: bool) -> None: 1696 """ 1697 Turn on or off the linking of scrolling the Timeline with the Thumbnail display. 1698 1699 Called from the Proximity (Timeline) widget 1700 1701 :param on: whether to turn on or off 1702 """ 1703 1704 if on: 1705 self.verticalScrollBar().valueChanged.connect(self.scrollTimeline) 1706 else: 1707 self.verticalScrollBar().valueChanged.disconnect(self.scrollTimeline) 1708 1709 def _scrollTemporalProximity(self, row: Optional[int]=None, 1710 index: Optional[QModelIndex]=None) -> None: 1711 temporalProximity = self.rapidApp.temporalProximity 1712 temporalProximity.setScrollTogether(False) 1713 if row is None: 1714 row = index.row() 1715 model = self.model() 1716 rows = model.rows 1717 uid = rows[row][0] 1718 temporalProximity.scrollToUid(uid=uid) 1719 temporalProximity.setScrollTogether(True) 1720 1721 def selectionChanged(self, selected: QItemSelection, deselected: QItemSelection) -> None: 1722 """ 1723 Reselect items if the user clicked a checkmark within an existing selection 1724 :param selected: new selection 1725 :param deselected: previous selection 1726 """ 1727 1728 super().selectionChanged(deselected, selected) 1729 1730 if self.possiblyPreserveSelectionPostClick: 1731 # Must set this to False before adjusting the selection! 1732 self.possiblyPreserveSelectionPostClick = False 1733 1734 current = self.currentIndex() 1735 if not(len(selected.indexes()) == 1 and selected.indexes()[0] == current): 1736 deselected.merge(self.selectionModel().selection(), QItemSelectionModel.Select) 1737 self.selectionModel().select(deselected, QItemSelectionModel.Select) 1738 1739 @pyqtSlot(QMouseEvent) 1740 def mousePressEvent(self, event: QMouseEvent) -> None: 1741 """ 1742 Filter selection changes when click is on a thumbnail checkbox. 1743 1744 When the user has selected multiple items (thumbnails), and 1745 then clicks one of the checkboxes, Qt's default behaviour is to 1746 treat that click as selecting the single item, because it doesn't 1747 know about our checkboxes. Therefore if the user is in fact 1748 clicking on a checkbox, we need to filter that event. 1749 1750 On some versions of Qt 5 (to be determined), no matter what we do here, 1751 the delegate's editorEvent will still be triggered. 1752 1753 :param event: the mouse click event 1754 """ 1755 1756 right_button_pressed = event.button() == Qt.RightButton 1757 if right_button_pressed: 1758 super().mousePressEvent(event) 1759 1760 else: 1761 index = self.indexAt(event.pos()) 1762 clicked_row = index.row() 1763 1764 if clicked_row >= 0: 1765 rect = self.visualRect(index) # type: QRect 1766 delegate = self.itemDelegate(index) # type: ThumbnailDelegate 1767 checkboxRect = delegate.getCheckBoxRect(rect) 1768 checkbox_clicked = checkboxRect.contains(event.pos()) 1769 if checkbox_clicked: 1770 status = index.data(Roles.download_status) # type: DownloadStatus 1771 checkbox_clicked = status not in Downloaded 1772 1773 if not checkbox_clicked: 1774 if self.rapidApp.prefs.auto_scroll and clicked_row >= 0: 1775 self._scrollTemporalProximity(row=clicked_row) 1776 else: 1777 self.possiblyPreserveSelectionPostClick = True 1778 super().mousePressEvent(event) 1779 1780 @pyqtSlot(int) 1781 def scrollTimeline(self, value) -> None: 1782 index = self.indexAt(self.topLeft()) # type: QModelIndex 1783 if index.isValid(): 1784 self._scrollTemporalProximity(index=index) 1785 1786 def topLeft(self) -> QPoint: 1787 return QPoint(thumbnail_margin, thumbnail_margin) 1788 1789 def visibleRows(self): 1790 """ 1791 Yield rows visible in viewport. Not currently used or properly tested. 1792 """ 1793 1794 rect = self.viewport().contentsRect() 1795 width = self.itemDelegate().width 1796 last_row = rect.bottomRight().x() // width * width 1797 topLeft = rect.topLeft() + QPoint(10, 10) 1798 top = self.indexAt(topLeft) 1799 if top.isValid(): 1800 bottom = self.indexAt(QPoint(last_row, rect.bottomRight().y())) 1801 if not bottom.isValid(): 1802 # take a guess with an arbitrary figure 1803 bottom = self.index(top.row() + 15) 1804 for row in range(top.row(), bottom.row() + 1): 1805 yield row 1806 1807 def scrollToUids(self, uids: List[bytes]) -> None: 1808 """ 1809 Scroll the Thumbnail Display to the first visible uid from the list of uids. 1810 1811 Remember not all uids are necessarily visible in the Thumbnail Display, 1812 because of filtering. 1813 1814 :param uids: list of uids to scroll to 1815 """ 1816 model = self.model() # type: ThumbnailListModel 1817 if self.rapidApp.showOnlyNewFiles(): 1818 uid = model.getFirstUidFromUidList(uids=uids) 1819 if uid is None: 1820 return 1821 else: 1822 uid = uids[0] 1823 try: 1824 row = model.uid_to_row[uid] 1825 except KeyError: 1826 logging.debug("Ignoring scroll request to unknown thumbnail") 1827 else: 1828 index = model.index(row, 0) 1829 self.scrollTo(index, QAbstractItemView.PositionAtTop) 1830 1831 1832class ThumbnailDelegate(QStyledItemDelegate): 1833 """ 1834 Render thumbnail cells 1835 """ 1836 1837 # markedWithMouse = pyqtSignal() 1838 1839 def __init__(self, rapidApp, parent=None) -> None: 1840 super().__init__(parent) 1841 self.rapidApp = rapidApp 1842 try: 1843 # Works on Qt 5.6 and above 1844 self.device_pixel_ratio = rapidApp.devicePixelRatioF() 1845 self.devicePixelF = True 1846 except AttributeError: 1847 self.device_pixel_ratio = rapidApp.devicePixelRatio() 1848 self.devicePixelF = False 1849 1850 self.checkboxStyleOption = QStyleOptionButton() 1851 self.checkboxRect = QRectF( 1852 QApplication.style().subElementRect( 1853 QStyle.SE_CheckBoxIndicator, self.checkboxStyleOption, None 1854 ) 1855 ) 1856 self.checkbox_size = self.checkboxRect.height() 1857 1858 size16 = QSize(16, 16) 1859 size24 = QSize(24, 24) 1860 self.downloadPendingPixmap = scaledIcon(':/thumbnail/download-pending.svg').pixmap(size16) 1861 self.downloadedPixmap = scaledIcon(':/thumbnail/downloaded.svg').pixmap(size16) 1862 self.downloadedWarningPixmap = scaledIcon( 1863 ':/thumbnail/downloaded-with-warning.svg' 1864 ).pixmap(size16) 1865 self.downloadedErrorPixmap = scaledIcon( 1866 ':/thumbnail/downloaded-with-error.svg' 1867 ).pixmap(size16) 1868 self.audioIcon = scaledIcon(':/thumbnail/audio.svg', size24).pixmap(size24) 1869 1870 # Determine pixel scaling for SVG files 1871 # Applies to all SVG files delegate will load 1872 if self.devicePixelF: 1873 self.pixmap_ratio = self.downloadPendingPixmap.devicePixelRatioF() 1874 else: 1875 self.pixmap_ratio = self.downloadedErrorPixmap.devicePixelRatio() 1876 1877 self.dimmed_opacity = 0.5 1878 1879 self.image_width = float(max(ThumbnailSize.width, ThumbnailSize.height)) 1880 self.image_height = self.image_width 1881 self.horizontal_margin = float(thumbnail_margin) 1882 self.vertical_margin = float(thumbnail_margin) 1883 self.image_footer = float(self.checkbox_size) 1884 self.footer_padding = 5.0 1885 1886 # Position of first memory card indicator 1887 self.card_x = float( 1888 max( 1889 self.checkboxRect.width(), 1890 self.downloadPendingPixmap.width() / self.pixmap_ratio, 1891 self.downloadedPixmap.width() / self.pixmap_ratio 1892 ) + self.horizontal_margin + self.footer_padding 1893 ) 1894 1895 self.shadow_size = 2.0 1896 self.width = self.image_width + self.horizontal_margin * 2 1897 self.height = self.image_height + self.footer_padding \ 1898 + self.image_footer + self.vertical_margin * 2 1899 1900 # Thumbnail is located in a 160px square... 1901 self.image_area_size = float(max(ThumbnailSize.width, ThumbnailSize.height)) 1902 self.image_frame_bottom = self.vertical_margin + self.image_area_size 1903 1904 self.contextMenu = QMenu() 1905 self.openInFileBrowserAct = self.contextMenu.addAction(_('Open in File Browser...')) 1906 self.openInFileBrowserAct.triggered.connect(self.doOpenInFileManagerAct) 1907 self.copyPathAct = self.contextMenu.addAction(_('Copy Path')) 1908 self.copyPathAct.triggered.connect(self.doCopyPathAction) 1909 # Translators: 'File' here applies to a single file. The command allows users to instruct 1910 # Rapid Photo Downloader that photos and videos have been previously downloaded by 1911 # another application. 1912 self.markFileDownloadedAct = self.contextMenu.addAction(_('Mark File as Downloaded')) 1913 self.markFileDownloadedAct.triggered.connect(self.doMarkFileDownloadedAct) 1914 # Translators: 'Files' here applies to two or more files 1915 self.markFilesDownloadedAct = self.contextMenu.addAction(_('Mark Files as Downloaded')) 1916 self.markFilesDownloadedAct.triggered.connect(self.doMarkFileDownloadedAct) 1917 # store the index in which the user right clicked 1918 self.clickedIndex = None # type: Optional[QModelIndex] 1919 1920 self.color3 = QColor(CustomColors.color3.value) 1921 1922 self.paleGray = QColor(PaleGray) 1923 self.darkGray = QColor(DarkGray) 1924 1925 palette = QGuiApplication.palette() 1926 self.highlight = palette.highlight().color() # type: QColor 1927 self.highlight_size = 3 1928 self.highlight_offset = self.highlight_size / 2 1929 self.highlightPen = QPen() 1930 self.highlightPen.setColor(self.highlight) 1931 self.highlightPen.setWidth(self.highlight_size) 1932 self.highlightPen.setStyle(Qt.SolidLine) 1933 self.highlightPen.setJoinStyle(Qt.MiterJoin) 1934 1935 self.emblemFont = QFont() 1936 self.emblemFont.setPointSize(self.emblemFont.pointSize() - 3) 1937 metrics = QFontMetricsF(self.emblemFont) 1938 1939 # Determine the actual height of the largest extension, and the actual 1940 # width of all extensions. 1941 # For our purposes, this is more accurate than the generic metrics.height() 1942 self.emblem_width = {} # type: Dict[str, int] 1943 height = 0 1944 # Include the emblems for which memory card on a camera the file came from 1945 for ext in ALL_USER_VISIBLE_EXTENSIONS + ['1', '2']: 1946 ext = ext.upper() 1947 tbr = metrics.tightBoundingRect(ext) # type: QRectF 1948 self.emblem_width[ext] = tbr.width() 1949 height = max(height, tbr.height()) 1950 1951 # Set and calculate the padding to go around each emblem 1952 self.emblem_pad = height / 3 1953 self.emblem_height = height + self.emblem_pad * 2 1954 self.emblem_width = { 1955 emblem: width + self.emblem_pad * 2 for emblem, width in self.emblem_width.items() 1956 } 1957 1958 self.jobCodeFont = QFont() 1959 self.jobCodeFont.setPointSize(self.jobCodeFont.pointSize() - 2) 1960 self.jobCodeMetrics = QFontMetricsF(self.jobCodeFont) 1961 height = self.jobCodeMetrics.height() 1962 self.job_code_pad = height / 4 1963 self.job_code_height = height + self.job_code_pad * 2 1964 self.job_code_width = self.image_width 1965 self.job_code_text_width = self.job_code_width - self.job_code_pad * 2 1966 self.jobCodeBackground = QColor(DoubleDarkGray) 1967 # alternative would be functools.lru_cache() decorator, but it 1968 # is required to be a function. It's easier to keep everything 1969 # in this class, especially regarding the default font 1970 self.job_code_lru = dict() # type: Dict[str, str] 1971 1972 # Generate the range of colors to be displayed when highlighting 1973 # files from a particular device 1974 ch = Color(self.highlight.name()) 1975 cg = Color(self.paleGray.name()) 1976 self.colorGradient = [QColor(c.hex) for c in cg.range_to(ch, FadeSteps)] 1977 1978 @pyqtSlot() 1979 def doCopyPathAction(self) -> None: 1980 index = self.clickedIndex 1981 if index: 1982 path = index.model().data(index, Roles.path) 1983 QApplication.clipboard().setText(path) 1984 1985 @pyqtSlot() 1986 def doOpenInFileManagerAct(self) -> None: 1987 index = self.clickedIndex 1988 if index: 1989 uri = index.model().data(index, Roles.uri) 1990 open_in_file_manager( 1991 file_manager=self.rapidApp.file_manager, 1992 file_manager_type=self.rapidApp.file_manager_type, 1993 uri=uri 1994 ) 1995 1996 @pyqtSlot() 1997 def doMarkFileDownloadedAct(self) -> None: 1998 selectedIndexes = self.selectedIndexes() 1999 if selectedIndexes is None: 2000 return 2001 not_downloaded = tuple( 2002 index for index in selectedIndexes if not index.data(Roles.previously_downloaded) 2003 ) # type: Tuple[QModelIndex] 2004 thumbnailModel = self.rapidApp.thumbnailModel # type: ThumbnailListModel 2005 thumbnailModel.setDataRange(not_downloaded, True, Roles.previously_downloaded) 2006 2007 def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: 2008 if index is None: 2009 return 2010 2011 # Save state of painter, restore on function exit 2012 painter.save() 2013 2014 checked = index.data(Qt.CheckStateRole) == Qt.Checked 2015 previously_downloaded = index.data(Roles.previously_downloaded) 2016 extension, ext_type = index.data( Roles.extension) 2017 download_status = index.data( Roles.download_status) # type: DownloadStatus 2018 has_audio = index.data( Roles.has_audio) 2019 secondary_attribute = index.data(Roles.secondary_attribute) 2020 memory_cards = index.data(Roles.camera_memory_card) # type: List[int] 2021 highlight = index.data(Roles.highlight) 2022 job_code = index.data(Roles.job_code) # type: Optional[str] 2023 2024 # job_code = 'An extremely long and complicated Job Code' 2025 # job_code = 'Job Code' 2026 2027 is_selected = option.state & QStyle.State_Selected 2028 2029 x = option.rect.x() 2030 y = option.rect.y() 2031 2032 # Draw rectangle in which the individual items will be placed 2033 boxRect = QRectF(x, y, self.width, self.height) 2034 shadowRect = QRectF(x + self.shadow_size, y + self.shadow_size, self.width, self.height) 2035 2036 painter.setRenderHint(QPainter.Antialiasing, True) 2037 painter.setPen(self.darkGray) 2038 painter.fillRect(shadowRect, self.darkGray) 2039 painter.drawRect(shadowRect) 2040 painter.setRenderHint(QPainter.Antialiasing, False) 2041 if highlight != 0: 2042 painter.fillRect(boxRect, self.colorGradient[highlight-1]) 2043 else: 2044 painter.fillRect(boxRect, self.paleGray) 2045 2046 if is_selected: 2047 hightlightRect = QRectF( 2048 boxRect.left() + self.highlight_offset, 2049 boxRect.top() + self.highlight_offset, 2050 boxRect.width() - self.highlight_size, 2051 boxRect.height() - self.highlight_size 2052 ) 2053 painter.setPen(self.highlightPen) 2054 painter.drawRect(hightlightRect) 2055 2056 thumbnail = index.model().data(index, Qt.DecorationRole) # type: QPixmap 2057 2058 # If on high DPI screen, scale the thumbnail using a smooth transform 2059 if self.device_pixel_ratio > 1.0: 2060 painter.setRenderHint(QPainter.SmoothPixmapTransform, True) 2061 2062 if previously_downloaded and not checked and \ 2063 download_status == DownloadStatus.not_downloaded: 2064 disabled = QPixmap(thumbnail.size()) 2065 if self.devicePixelF: 2066 disabled.setDevicePixelRatio(thumbnail.devicePixelRatioF()) 2067 else: 2068 disabled.setDevicePixelRatio(thumbnail.devicePixelRatio()) 2069 disabled.fill(Qt.transparent) 2070 p = QPainter(disabled) 2071 p.setBackgroundMode(Qt.TransparentMode) 2072 p.setBackground(QBrush(Qt.transparent)) 2073 p.eraseRect(thumbnail.rect()) 2074 p.setOpacity(self.dimmed_opacity) 2075 p.drawPixmap(0, 0, thumbnail) 2076 p.end() 2077 thumbnail = disabled 2078 2079 thumbnail_width = thumbnail.size().width() 2080 thumbnail_height = thumbnail.size().height() 2081 if self.devicePixelF: 2082 ratio = thumbnail.devicePixelRatioF() 2083 else: 2084 ratio = thumbnail.devicePixelRatio() 2085 2086 thumbnailX = self.horizontal_margin + \ 2087 (self.image_area_size - thumbnail_width / ratio) / 2 + x 2088 thumbnailY = self.vertical_margin + \ 2089 (self.image_area_size - thumbnail_height / ratio) / 2 + y 2090 2091 target = QRectF( 2092 thumbnailX, thumbnailY, 2093 thumbnail_width / ratio, thumbnail_height / ratio 2094 ) 2095 source = QRectF(0, 0, thumbnail_width, thumbnail_height) 2096 painter.drawPixmap(target, thumbnail, source) 2097 2098 dimmed = previously_downloaded and not checked 2099 2100 # Render the job code near the top of the square, if there is one 2101 if job_code: 2102 if is_selected: 2103 color = self.highlight 2104 painter.setOpacity(1.0) 2105 else: 2106 color = self.jobCodeBackground 2107 if not dimmed: 2108 painter.setOpacity(0.75) 2109 else: 2110 painter.setOpacity(self.dimmed_opacity) 2111 2112 jobCodeRect = QRectF( 2113 x + self.horizontal_margin, y + self.vertical_margin, 2114 self.job_code_width, self.job_code_height 2115 ) 2116 painter.fillRect(jobCodeRect, color) 2117 painter.setFont(self.jobCodeFont) 2118 painter.setPen(QColor(Qt.white)) 2119 if job_code in self.job_code_lru: 2120 text = self.job_code_lru[job_code] 2121 else: 2122 text = self.jobCodeMetrics.elidedText( 2123 job_code, Qt.ElideRight, self.job_code_text_width 2124 ) 2125 self.job_code_lru[job_code] = text 2126 if not dimmed: 2127 painter.setOpacity(1.0) 2128 else: 2129 painter.setOpacity(self.dimmed_opacity) 2130 painter.drawText(jobCodeRect, Qt.AlignCenter, text) 2131 2132 if dimmed: 2133 painter.setOpacity(self.dimmed_opacity) 2134 2135 # painter.setPen(QColor(Qt.blue)) 2136 # painter.drawText(x + 2, y + 15, str(index.row())) 2137 2138 if has_audio: 2139 audio_x = self.width / 2 - \ 2140 self.audioIcon.width() / self.pixmap_ratio / 2 + x 2141 audio_y = self.image_frame_bottom + self.footer_padding + y - 1 2142 painter.drawPixmap(QPointF(audio_x, audio_y), self.audioIcon) 2143 2144 # Draw a small coloured box containing the file extension in the 2145 # bottom right corner 2146 extension = extension.upper() 2147 # Calculate size of extension text 2148 painter.setFont(self.emblemFont) 2149 # em_width = self.emblemFontMetrics.width(extension) 2150 emblem_width = self.emblem_width[extension] 2151 emblem_rect_x = self.width - self.horizontal_margin - emblem_width + x 2152 emblem_rect_y = self.image_frame_bottom + self.footer_padding + y - 1 2153 2154 emblemRect = QRectF( 2155 emblem_rect_x, emblem_rect_y, emblem_width, self.emblem_height 2156 ) # type: QRectF 2157 2158 color = extensionColor(ext_type=ext_type) 2159 2160 # Use an angular rect, because a rounded rect with anti-aliasing doesn't look too good 2161 painter.fillRect(emblemRect, color) 2162 painter.setPen(QColor(Qt.white)) 2163 painter.drawText(emblemRect, Qt.AlignCenter, extension) 2164 2165 # Draw another small colored box to the left of the 2166 # file extension box containing a secondary 2167 # attribute, if it exists. Currently the secondary attribute is 2168 # only an XMP file, but in future it could be used to display a 2169 # matching jpeg in a RAW+jpeg set 2170 if secondary_attribute: 2171 # Assume the attribute is already upper case 2172 sec_width = self.emblem_width[secondary_attribute] 2173 sec_rect_x = emblem_rect_x - self.footer_padding - sec_width 2174 color = QColor(self.color3) 2175 secRect = QRectF(sec_rect_x, emblem_rect_y, sec_width, self.emblem_height) 2176 painter.fillRect(secRect, color) 2177 painter.drawText(secRect, Qt.AlignCenter, secondary_attribute) 2178 2179 if memory_cards: 2180 # if downloaded from a camera, and the camera has more than 2181 # one memory card, a list of numeric identifiers (i.e. 1 or 2182 # 2) identifying which memory card the file came from 2183 text_x = self.card_x + x 2184 for card in memory_cards: 2185 card = str(card) 2186 card_width = self.emblem_width[card] 2187 color = QColor(70, 70, 70) 2188 cardRect = QRectF(text_x, emblem_rect_y, card_width, self.emblem_height) 2189 painter.fillRect(cardRect, color) 2190 painter.drawText(cardRect, Qt.AlignCenter, card) 2191 text_x = text_x + card_width + self.footer_padding 2192 2193 if dimmed: 2194 painter.setOpacity(1.0) 2195 2196 if download_status == DownloadStatus.not_downloaded: 2197 checkboxStyleOption = QStyleOptionButton() 2198 if checked: 2199 checkboxStyleOption.state |= QStyle.State_On 2200 else: 2201 checkboxStyleOption.state |= QStyle.State_Off 2202 checkboxStyleOption.state |= QStyle.State_Enabled 2203 checkboxStyleOption.rect = self.getCheckBoxRect(option.rect) 2204 QApplication.style().drawControl(QStyle.CE_CheckBox, checkboxStyleOption, painter) 2205 else: 2206 if download_status == DownloadStatus.download_pending: 2207 pixmap = self.downloadPendingPixmap 2208 elif download_status == DownloadStatus.downloaded: 2209 pixmap = self.downloadedPixmap 2210 elif download_status == DownloadStatus.downloaded_with_warning or \ 2211 download_status == DownloadStatus.backup_problem: 2212 pixmap = self.downloadedWarningPixmap 2213 elif download_status == DownloadStatus.download_failed or \ 2214 download_status == DownloadStatus.download_and_backup_failed: 2215 pixmap = self.downloadedErrorPixmap 2216 else: 2217 pixmap = None 2218 if pixmap is not None: 2219 painter.drawPixmap( 2220 option.rect.x() + self.horizontal_margin, emblem_rect_y, pixmap 2221 ) 2222 2223 painter.restore() 2224 2225 def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: 2226 return QSize( 2227 self.width + self.shadow_size, self.height + self.shadow_size 2228 ) 2229 2230 def oneOrMoreNotDownloaded(self) -> Tuple[int, Plural]: 2231 i = 0 2232 selectedIndexes = self.selectedIndexes() 2233 if selectedIndexes is None: 2234 no_selected = 0 2235 else: 2236 no_selected = len(selectedIndexes) 2237 for index in selectedIndexes: 2238 if not index.data(Roles.previously_downloaded): 2239 i += 1 2240 if i == 2: 2241 break 2242 2243 if i == 0: 2244 return no_selected, Plural.zero 2245 elif i == 1: 2246 return no_selected, Plural.two_form_single 2247 else: 2248 return no_selected, Plural.two_form_plural 2249 2250 def editorEvent(self, event: QEvent, 2251 model: QAbstractItemModel, 2252 option: QStyleOptionViewItem, 2253 index: QModelIndex) -> bool: 2254 """ 2255 Change the data in the model and the state of the checkbox 2256 if the user presses the left mouse button or presses 2257 Key_Space or Key_Select and this cell is editable. Otherwise do nothing. 2258 2259 Handle right click too. 2260 """ 2261 2262 download_status = index.data(Roles.download_status) 2263 2264 if event.type() == QEvent.MouseButtonRelease or \ 2265 event.type() == QEvent.MouseButtonDblClick: 2266 2267 if event.button() == Qt.RightButton: 2268 self.clickedIndex = index 2269 2270 # Determine if user can manually mark file or files as previously downloaded 2271 noSelected, noDownloaded = self.oneOrMoreNotDownloaded() 2272 if noDownloaded == Plural.two_form_single: 2273 self.markFilesDownloadedAct.setVisible(False) 2274 self.markFileDownloadedAct.setVisible(True) 2275 self.markFileDownloadedAct.setEnabled(True) 2276 elif noDownloaded == Plural.two_form_plural: 2277 self.markFilesDownloadedAct.setVisible(True) 2278 self.markFilesDownloadedAct.setEnabled(True) 2279 self.markFileDownloadedAct.setVisible(False) 2280 else: 2281 assert noDownloaded == Plural.zero 2282 if noSelected == 1: 2283 self.markFilesDownloadedAct.setVisible(False) 2284 self.markFileDownloadedAct.setVisible(True) 2285 self.markFileDownloadedAct.setEnabled(False) 2286 else: 2287 self.markFilesDownloadedAct.setVisible(True) 2288 self.markFilesDownloadedAct.setEnabled(False) 2289 self.markFileDownloadedAct.setVisible(False) 2290 2291 globalPos = self.rapidApp.thumbnailView.viewport().mapToGlobal(event.pos()) 2292 # libgphoto2 needs exclusive access to the camera, so there are times when "open 2293 # in file browswer" should be disabled: 2294 # First, for all desktops, when a camera, disable when thumbnailing or 2295 # downloading. 2296 # Second, disable opening MTP devices in KDE environment, 2297 # as KDE won't release them until them the file browser is closed! 2298 # However if the file is already downloaded, we don't care, as can get it from 2299 # local source. 2300 # Finally, disable when we don't know what the default file manager is 2301 2302 active_camera = disable_kde = False 2303 have_file_manager = self.rapidApp.file_manager is not None 2304 if download_status not in Downloaded: 2305 if index.data(Roles.is_camera): 2306 scan_id = index.data(Roles.scan_id) 2307 active_camera = self.rapidApp.deviceState(scan_id) != DeviceState.idle 2308 if not active_camera: 2309 disable_kde = index.data(Roles.mtp) and get_desktop() == Desktop.kde 2310 2311 self.openInFileBrowserAct.setEnabled( 2312 not (disable_kde or active_camera) and have_file_manager 2313 ) 2314 self.contextMenu.popup(globalPos) 2315 return False 2316 if event.button() != Qt.LeftButton or \ 2317 not self.getCheckBoxRect(option.rect).contains(event.pos()): 2318 return False 2319 if event.type() == QEvent.MouseButtonDblClick: 2320 return True 2321 elif event.type() == QEvent.KeyPress: 2322 if event.key() != Qt.Key_Space and event.key() != Qt.Key_Select: 2323 return False 2324 else: 2325 return False 2326 2327 if download_status != DownloadStatus.not_downloaded: 2328 return False 2329 2330 # Change the checkbox-state 2331 self.setModelData(None, model, index) 2332 # print("Changed the checkbox-state") 2333 # print("these are the selected items", [index.row() for index in self.rapidApp.thumbnailView.selectionModel().selectedIndexes()]) 2334 return True 2335 2336 def setModelData (self, editor: QWidget, 2337 model: QAbstractItemModel, 2338 index: QModelIndex) -> None: 2339 newValue = not (index.data(Qt.CheckStateRole) == Qt.Checked) 2340 thumbnailModel = self.rapidApp.thumbnailModel # type: ThumbnailListModel 2341 selection = self.rapidApp.thumbnailView.selectionModel() # type: QItemSelectionModel 2342 if selection.hasSelection(): 2343 selected = selection.selection() # type: QItemSelection 2344 if index in selected.indexes(): 2345 for i in selected.indexes(): 2346 thumbnailModel.setData(i, newValue, Qt.CheckStateRole) 2347 else: 2348 # The user has clicked on a checkbox that for a 2349 # thumbnail that is outside their previous selection 2350 selection.clear() 2351 selection.select(index, QItemSelectionModel.Select) 2352 model.setData(index, newValue, Qt.CheckStateRole) 2353 else: 2354 # The user has previously selected nothing, so mark this 2355 # thumbnail as selected 2356 selection.select(index, QItemSelectionModel.Select) 2357 model.setData(index, newValue, Qt.CheckStateRole) 2358 thumbnailModel.updateDisplayPostDataChange() 2359 2360 def getLeftPoint(self, rect: QRect) -> QPoint: 2361 return QPoint( 2362 rect.x() + self.horizontal_margin, 2363 rect.y() + self.image_frame_bottom + self.footer_padding - 1 2364 ) 2365 2366 def getCheckBoxRect(self, rect: QRect) -> QRect: 2367 return QRect(self.getLeftPoint(rect), self.checkboxRect.toRect().size()) 2368 2369 def applyJobCode(self, job_code: str) -> None: 2370 thumbnailModel = self.rapidApp.thumbnailModel # type: ThumbnailListModel 2371 selectedIndexes = self.selectedIndexes() 2372 if selectedIndexes is not None: 2373 logging.debug("Applying job code to %s files", len(selectedIndexes)) 2374 for i in selectedIndexes: 2375 thumbnailModel.setData(i, job_code, Roles.job_code) 2376 else: 2377 logging.debug("Not applying job code because no files selected") 2378 2379 def selectedIndexes(self) -> Optional[List[QModelIndex]]: 2380 selection = self.rapidApp.thumbnailView.selectionModel() # type: QItemSelectionModel 2381 if selection.hasSelection(): 2382 selected = selection.selection() # type: QItemSelection 2383 return selected.indexes() 2384 return None