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(' ', '&nbsp;')
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