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