1# Copyright (C) 2010-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"""
20Notify user of problems when downloading: problems with subfolder and filename generation,
21download errors, and so forth
22
23Goals
24=====
25
26Group problems into tasks:
27  1. scanning
28  2. copying
29  3. renaming (presented to user as finalizing file and download subfolder names)
30  4. backing up - per backup device
31
32Present messages in human readable manner.
33Multiple metadata problems can occur: group them.
34Distinguish error severity
35
36"""
37
38__author__ = 'Damon Lynch'
39__copyright__ = "Copyright 2010-2020, Damon Lynch"
40
41from collections import deque
42from typing import Tuple, Optional, List, Union, Iterator
43from html import escape
44import logging
45
46from raphodo.utilities import make_internationalized_list
47from raphodo.constants import ErrorType
48from raphodo.camera import gphoto2_named_error
49
50
51def make_href(name: str, uri: str) -> str:
52    """
53    Construct a hyperlink.
54    """
55
56    # Note: keep consistent with ErrorReport._saveUrls()
57    return '<a href="{}">{}</a>'.format(uri, escape(name))
58
59
60class Problem:
61    def __init__(self, name: Optional[str]=None,
62                 uri: Optional[str]=None,
63                 exception: Optional[Exception]=None,
64                 **attrs) -> None:
65        for attr, value in attrs.items():
66            setattr(self, attr, value)
67        self.name = name
68        self.uri = uri
69        self.exception = exception
70
71    @property
72    def title(self) -> str:
73        logging.critical('title() not implemented in subclass %s', self.__class__.__name__)
74        return 'undefined'
75
76    @property
77    def body(self) -> str:
78        logging.critical('body() not implemented in subclass %s', self.__class__.__name__)
79        return 'undefined'
80
81    @property
82    def details(self) -> List[str]:
83        if self.exception is not None:
84            try:
85                # Translators: %(variable)s represents Python code, not a plural of the term
86                # variable. You must keep the %(variable)s untranslated, or the program will
87                # crash.
88                return [
89                    escape(_("Error: %(errno)s %(strerror)s")) % dict(
90                        errno=self.exception.errno, strerror=self.exception.strerror
91                    )
92                ]
93            except AttributeError:
94                return [escape(_("Error: %s")) % self.exception]
95        else:
96            return []
97
98    @property
99    def href(self) -> str:
100        if self.name and self.uri:
101            return make_href(name=self.name, uri=self.uri)
102        else:
103            logging.critical(
104                'href() is missing name or uri in subclass %s', self.__class__.__name__
105            )
106
107    @property
108    def severity(self) -> ErrorType:
109        return ErrorType.warning
110
111
112class SeriousProblem(Problem):
113    @property
114    def severity(self) -> ErrorType:
115        return ErrorType.serious_error
116
117
118class CameraGpProblem(SeriousProblem):
119    @property
120    def details(self) -> List[str]:
121        try:
122            return [escape(_("GPhoto2 Error: %s")) % escape(gphoto2_named_error(self.gp_code))]
123        except AttributeError:
124            return []
125
126
127class CameraInitializationProblem(CameraGpProblem):
128    @property
129    def body(self) -> str:
130        return escape(
131            _(
132                "Unable to initialize the camera, probably because another program is using it. "
133                "No files were copied from it."
134            )
135        )
136    @property
137    def severity(self) -> ErrorType:
138        return ErrorType.critical_error
139
140
141class CameraDirectoryReadProblem(CameraGpProblem):
142    @property
143    def body(self) -> str:
144        return escape(_("Unable to read directory %s")) % self.href
145
146
147class CameraFileInfoProblem(CameraGpProblem):
148    @property
149    def body(self) -> str:
150        return escape(_('Unable to access modification time or size from %s')) % self.href
151
152
153class CameraFileReadProblem(CameraGpProblem):
154    @property
155    def body(self) -> str:
156        return escape(_('Unable to read file %s')) % self.href
157
158
159class FileWriteProblem(SeriousProblem):
160    @property
161    def body(self) -> str:
162        return escape(_('Unable to write file %s')) % self.href
163
164
165class FileMoveProblem(SeriousProblem):
166    @property
167    def body(self) -> str:
168        return escape(_('Unable to move file %s')) % self.href
169
170
171class FileDeleteProblem(SeriousProblem):
172    @property
173    def body(self) -> str:
174        return escape(_('Unable to remove file %s')) % self.href
175
176
177class FileCopyProblem(SeriousProblem):
178    @property
179    def body(self) -> str:
180        return escape(_('Unable to copy file %s')) % self.href
181
182
183class FileZeroLengthProblem(SeriousProblem):
184    @property
185    def body(self) -> str:
186        return escape(_('Zero length file %s will not be downloaded')) % self.href
187
188
189class FsMetadataReadProblem(Problem):
190    @property
191    def body(self) -> str:
192        return escape(_("Could not determine filesystem modification time for %s")) % self.href
193
194
195class FileMetadataLoadProblem(Problem):
196    @property
197    def body(self) -> str:
198        return escape(_('Unable to load metadata from %s')) % self.href
199
200
201class FileMetadataLoadProblemNoDownload(SeriousProblem):
202    @property
203    def body(self) -> str:
204        return escape(
205            # Translators: %(variable)s represents Python code, not a plural of the term
206            # variable. You must keep the %(variable)s untranslated, or the program will
207            # crash.
208            _(
209                'Unable to load metadata from %(name)s. The %(filetype)s was not downloaded.'
210            )
211        ) % dict(filetype=self.file_type, name=self.href)
212
213
214class FsMetadataWriteProblem(Problem):
215    @property
216    def body(self) -> str:
217        return escape(
218            _(
219                "An error occurred setting a file's filesystem metadata on the filesystem %s. "
220                "If this error occurs again on the same filesystem, it will not be reported again."
221            )
222        ) % self.href
223
224    @property
225    def details(self) -> List[str]:
226        return [
227            # Translators: %(variable)s represents Python code, not a plural of the term
228            # variable. You must keep the %(variable)s untranslated, or the program will
229            # crash.
230            escape(_("Error: %(errno)s %(strerror)s")) % dict(errno=e.errno, strerror=e.strerror)
231            for e in self.mdata_exceptions
232        ]
233
234
235class UnhandledFileProblem(SeriousProblem):
236    @property
237    def body(self) -> str:
238        return escape(_('Encountered unhandled file %s. It will not be downloaded.')) % self.href
239
240
241class FileAlreadyExistsProblem(SeriousProblem):
242    @property
243    def body(self) -> str:
244        return escape(
245            # Translators: %(variable)s represents Python code, not a plural of the term
246            # variable. You must keep the %(variable)s untranslated, or the program will
247            # crash.
248            _("%(filetype)s %(destination)s already exists.")
249        ) % dict(
250            filetype=escape(self.file_type_capitalized),
251            destination=self.href
252        )
253
254    @property
255    def details(self) -> List[str]:
256        d = list()
257        d.append(
258            escape(
259                # Translators: %(variable)s represents Python code, not a plural of the term
260                # variable. You must keep the %(variable)s untranslated, or the program will
261                # crash.
262                _(
263                    "The existing %(filetype)s %(destination)s was last modified on "
264                    "%(date)s at %(time)s."
265                )
266            ) % dict(
267                    filetype=escape(self.file_type),
268                    date=escape(self.date),
269                    time=escape(self.time),
270                    destination=self.href
271            )
272        )
273        d.append(
274            escape(
275                # Translators: %(variable)s represents Python code, not a plural of the term
276                # variable. You must keep the %(variable)s untranslated, or the program will
277                # crash.
278                _("The %(filetype)s %(source)s was not downloaded from %(device)s.")
279            ) % dict(
280                filetype=escape(self.file_type),
281                source=self.source,
282                device=self.device
283            )
284        )
285        return d
286
287
288class IdentifierAddedProblem(FileAlreadyExistsProblem):
289
290    @property
291    def details(self) -> List[str]:
292        d = list()
293        d.append(
294            escape(
295                # Translators: %(variable)s represents Python code, not a plural of the term
296                # variable. You must keep the %(variable)s untranslated, or the program will
297                # crash.
298                _(
299                    "The existing %(filetype)s %(destination)s was last modified on "
300                    "%(date)s at %(time)s."
301                )
302            ) % dict(
303                    filetype=escape(self.file_type),
304                    date=escape(self.date),
305                    time=escape(self.time),
306                    destination=self.href
307            )
308        )
309        d.append(
310            escape(
311                # Translators: %(variable)s represents Python code, not a plural of the term
312                # variable. You must keep the %(variable)s untranslated, or the program will
313                # crash.
314                _("The %(filetype)s %(source)s was downloaded from %(device)s.")
315            ) % dict(
316                filetype=escape(self.file_type),
317                source=self.source,
318                device=self.device
319            )
320        )
321        d.append(
322            escape(
323                _("The unique identifier '%s' was added to the filename.")) % self.identifier
324        )
325        return d
326
327    @property
328    def severity(self) -> ErrorType:
329        return ErrorType.warning
330
331
332class BackupAlreadyExistsProblem(FileAlreadyExistsProblem):
333
334    @property
335    def details(self) -> List[str]:
336        d = list()
337        d.append(
338            escape(
339                # Translators: %(variable)s represents Python code, not a plural of the term
340                # variable. You must keep the %(variable)s untranslated, or the program will
341                # crash.
342                _(
343                    "The existing backup %(filetype)s %(destination)s was last modified on "
344                    "%(date)s at %(time)s."
345                )
346            ) % dict(
347                    filetype=escape(self.file_type),
348                    date=escape(self.date),
349                    time=escape(self.time),
350                    destination=self.href
351            )
352        )
353        d.append(
354            escape(
355                # Translators: %(variable)s represents Python code, not a plural of the term
356                # variable. You must keep the %(variable)s untranslated, or the program will
357                # crash.
358                _("The %(filetype)s %(source)s was not backed up from %(device)s.")
359            ) % dict(
360                filetype=escape(self.file_type),
361                source=self.source,
362                device=self.device
363            )
364        )
365        return d
366
367
368class BackupOverwrittenProblem(BackupAlreadyExistsProblem):
369
370    @property
371    def details(self) -> List[str]:
372        d = list()
373        d.append(
374            escape(
375                # Translators: %(variable)s represents Python code, not a plural of the term
376                # variable. You must keep the %(variable)s untranslated, or the program will
377                # crash.
378                _(
379                    "The previous backup %(filetype)s %(destination)s was last modified on "
380                    "%(date)s at %(time)s."
381                )
382            ) % dict(
383                    filetype=escape(self.file_type),
384                    date=escape(self.date),
385                    time=escape(self.time),
386                    destination=self.name
387            )
388        )
389        d.append(
390            # Translators: %(variable)s represents Python code, not a plural of the term
391            # variable. You must keep the %(variable)s untranslated, or the program will
392            # crash.
393            escape(
394                _(
395                    "The %(filetype)s %(source)s from %(device)s was backed up, overwriting the "
396                    "previous backup %(filetype)s."
397                )
398            ) % dict(
399                filetype=escape(self.file_type),
400                source=self.source,
401                device=self.device
402            )
403        )
404        return d
405
406    @property
407    def severity(self) -> ErrorType:
408        return ErrorType.warning
409
410
411class DuplicateFileWhenSyncingProblem(SeriousProblem):
412    @property
413    def body(self) -> str:
414        return escape(
415            # Translators: %(variable)s represents Python code, not a plural of the term
416            # variable. You must keep the %(variable)s untranslated, or the program will
417            # crash.
418            _(
419                "When synchronizing RAW + JPEG sequence values, a duplicate %(filetype)s "
420                "%(file)s was encountered, and was not downloaded."
421            )
422        ) % dict(file=self.href, filetype=self.file_type)
423
424
425class SameNameDifferentExif(Problem):
426    @property
427    def body(self) -> str:
428        return escape(
429            _(
430                "When synchronizing RAW + JPEG sequence values, photos were detected with the "
431                "same filenames, but taken at different times:"
432            )
433        )
434
435    @property
436    def details(self) -> List[str]:
437        return [
438            escape(
439                # Translators: %(variable)s represents Python code, not a plural of the term
440                # variable. You must keep the %(variable)s untranslated, or the program will
441                # crash.
442                _(
443                    "%(image1)s was taken on %(image1_date)s at %(image1_time)s, and %(image2)s "
444                    "on %(image2_date)s at %(image2_time)s."
445                )
446            ) % dict(
447                image1=self.image1,
448                image1_date=self.image1_date,
449                image1_time=self.image1_time,
450                image2=self.image2,
451                image2_date=self.image2_date,
452                image2_time=self.image2_time
453            )
454        ]
455
456
457class RenamingAssociateFileProblem(SeriousProblem):
458    @property
459    def body(self) -> str:
460        return escape(
461            _("Unable to finalize the filename for %s")
462        ) % self.source
463
464
465class FilenameNotFullyGeneratedProblem(Problem):
466    def __init__(self, name: Optional[str]=None,
467                 uri: Optional[str]=None,
468                 exception: Optional[Exception]=None,
469                 **attrs) -> None:
470        super().__init__(name=name, uri=uri, exception=exception, **attrs)
471        self.missing_metadata = []
472        self.file_type = ''
473        self.destination = ''
474        self.source = ''
475        self.bad_converstion_date_time = False
476        self.bad_conversion_exception = None  # type: Optional[Exception]
477        self.invalid_date_time = False
478        self.missing_extension = False
479        self.missing_image_no = False
480        self.component_error = False
481        self.component_problem = ''
482        self.component_exception = None
483
484    def has_error(self) -> bool:
485        """
486        :return: True if any of the errors occurred
487        """
488
489        return bool(self.missing_metadata) or self.invalid_date_time or \
490               self.bad_converstion_date_time or self.missing_extension or self.missing_image_no \
491               or self.component_error
492
493    @property
494    def body(self) -> str:
495        return escape(
496            # Translators: %(variable)s represents Python code, not a plural of the term
497            # variable. You must keep the %(variable)s untranslated, or the program will
498            # crash.
499            _("The filename %(destination)s was not fully generated for %(filetype)s %(source)s.")
500        ) % dict(destination=self.destination, filetype=self.file_type, source=self.source)
501
502    @property
503    def details(self) -> List[str]:
504        d = []
505        if len(self.missing_metadata) == 1:
506            d.append(
507                escape(
508                    # Translators: %(variable)s represents Python code, not a plural of the term
509                    # variable. You must keep the %(variable)s untranslated, or the program will
510                    # crash.
511                    _("The %(type)s metadata is missing.")
512                ) % dict(type=self.missing_metadata[0])
513            )
514        elif len(self.missing_metadata) > 1:
515            d.append(
516                escape(
517                    _("The following metadata is missing: %s.")
518                ) % make_internationalized_list(self.missing_metadata)
519            )
520
521        if self.bad_converstion_date_time:
522            d.append(
523                escape(_('Date/time conversion failed: %s.')) % self.bad_conversion_exception
524            )
525
526        if self.invalid_date_time:
527            d.append(
528                escape(
529                    _("Could not extract valid date/time metadata or determine the file "
530                      "modification time.")
531                )
532            )
533
534        if self.missing_extension:
535            d.append(escape(_("Filename does not have an extension.")))
536
537        if self.missing_image_no:
538            d.append(escape(_("Filename does not have a number component.")))
539
540        if self.component_error:
541            d.append(
542                escape(_("Error generating component %(component)s. Error: %(error)s")) % dict(
543                    component=self.component_problem,
544                    error=self.component_exception
545                )
546            )
547
548        return d
549
550
551class FolderNotFullyGeneratedProblemProblem(FilenameNotFullyGeneratedProblem):
552    @property
553    def body(self) -> str:
554        return escape(
555            # Translators: %(variable)s represents Python code, not a plural of the term
556            # variable. You must keep the %(variable)s untranslated, or the program will
557            # crash.
558            _(
559                "The download subfolders %(folder)s were only partially generated for %(filetype)s "
560                "%(source)s."
561            )
562        ) % dict(folder=self.destination, filetype=self.file_type, source=self.source)
563
564
565class NoDataToNameProblem(SeriousProblem):
566    @property
567    def body(self) -> str:
568        return escape(
569            # Translators: %(variable)s represents Python code, not a plural of the term
570            # variable. You must keep the %(variable)s untranslated, or the program will
571            # crash.
572            _(
573                "There is no data with which to generate the %(subfolder_file)s for %(filename)s. "
574                "The %(filetype)s was not downloaded."
575            )
576        ) % dict(
577            subfolder_file = self.area,
578            filename = self.href,
579            filetype=self.file_type,
580        )
581
582
583class RenamingFileProblem(SeriousProblem):
584    @property
585    def body(self) -> str:
586        return escape(
587            # Translators: %(variable)s represents Python code, not a plural of the term
588            # variable. You must keep the %(variable)s untranslated, or the program will
589            # crash.
590            _(
591                'Unable to create the %(filetype)s %(destination)s in %(folder)s. The download '
592                'file was %(source)s in %(device)s. It was not downloaded.'
593            )
594        ) % dict(
595            filetype=escape(self.file_type),
596            destination=escape(self.destination),
597            folder=self.folder,
598            source=self.href,
599            device=self.device
600        )
601
602
603class SubfolderCreationProblem(Problem):
604    @property
605    def body(self) -> str:
606        return escape(
607            _('Unable to create the download subfolder %s.')
608        ) % self.folder
609
610    @property
611    def severity(self) -> ErrorType:
612        return ErrorType.critical_error
613
614
615class BackupSubfolderCreationProblem(SubfolderCreationProblem):
616    @property
617    def body(self) -> str:
618        return escape(
619            _('Unable to create the backup subfolder %s.')
620        ) % self.folder
621
622
623class Problems:
624    def __init__(self, name: Optional[str]='',
625                 uri: Optional[str]='',
626                 problem: Optional[Problem]=None) -> None:
627        self.problems = deque()
628        self.name = name
629        self.uri = uri
630        if problem:
631            self.append(problem=problem)
632
633    def __len__(self) -> int:
634        return len(self.problems)
635
636    def __iter__(self) -> Iterator[Problem]:
637        return iter(self.problems)
638
639    def __getitem__(self, index: int) -> Problem:
640        return self.problems[index]
641
642    def append(self, problem: Problem) -> None:
643        self.problems.append(problem)
644
645    @property
646    def title(self) -> str:
647        logging.critical('title() not implemented in subclass %s', self.__class__.__name__)
648        return 'undefined'
649
650    @property
651    def body(self) -> str:
652        return 'body'
653
654    @property
655    def details(self) -> List[str]:
656        return []
657
658    @property
659    def href(self) -> str:
660        if self.name and self.uri:
661            return make_href(name=self.name, uri=self.uri)
662        else:
663            logging.critical('href() is missing name or uri in %s', self.__class__.__name__)
664
665
666class ScanProblems(Problems):
667
668    @property
669    def title(self) -> str:
670        return escape(_('Problems scanning %s')) % self.href
671
672
673class CopyingProblems(Problems):
674
675    @property
676    def title(self) -> str:
677        return escape(_('Problems copying from %s')) % self.href
678
679
680class RenamingProblems(Problems):
681
682    @property
683    def title(self) -> str:
684        return escape(_('Problems while finalizing filenames and generating subfolders'))
685
686
687class BackingUpProblems(Problems):
688
689    @property
690    def title(self) -> str:
691        return escape(_('Problems backing up to %s')) % self.href
692
693