-
Notifications
You must be signed in to change notification settings - Fork 0
/
photoOrgMain.py
1854 lines (1672 loc) · 99.8 KB
/
photoOrgMain.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import sys
import os
import re
from os.path import basename, dirname, isfile
from PyQt5 import QtGui
from PyQt5 import QtCore
from PyQt5 import QtWidgets
import piexif
import photoOrg_MainWindow
class MainWindow(QtWidgets.QMainWindow, photoOrg_MainWindow.Ui_MainWindow):
def __init__(self):
super(self.__class__, self).__init__()
self.setupUi(self)
self.installEventFilter(self)
self.actionOpen_Files.triggered.connect(self.ChooseFiles)
self.actionExit.triggered.connect(self.ExitApp)
self.actionSortPhotoListAlphabetically.triggered.connect(self.SortPhotoListAlphabetically)
self.actionSortPhotoListByOriginalDate.triggered.connect(self.SortPhotoListByOriginalDate)
self.btnChangeDate.clicked.connect(self.WriteExifDate)
self.btnChangeDate.setEnabled(False)
self.btnChangeExif.clicked.connect(self.WriteExifData)
self.btnChangeExif.setEnabled(False)
self.btnDelete.clicked.connect(self.DeleteFiles)
self.btnDelete.setEnabled(False)
self.btnOpen.clicked.connect(self.ChooseFiles)
self.btnRename.clicked.connect(self.RenameFiles)
self.btnRename.setEnabled(True)
self.btnRotate.clicked.connect(self.RotateImages)
self.btnRotate.setEnabled(True)
self.cboDateEdit.addItems(["Edit all parts", "Edit year", "Edit month", "Edit day"])
self.cboDateFiles.addItems(["Selected only", "All files"])
self.cboDateFiles.currentIndexChanged.connect(self.ComboDateFilesChanged)
self.cboDateOptions.addItems(["No date", "YMDhms", "YMD_hms", "Y-M-D_hms", "Y-M-D_h-m-s", "Custom"])
self.cboDateOptions.currentIndexChanged.connect(self.ComboboxDateOptionsChanged)
self.cboExifFiles.addItems(["Selected only", "All files"])
self.cboExifFiles.currentIndexChanged.connect(self.ComboExifFilesChanged)
self.cboFilenameBase.addItems(["Use existing file name", "Specify base name"])
self.cboFilenameBase.currentIndexChanged.connect(self.ComboboxFilenameBaseChanged)
self.cboMirror.addItems(["No mirroring", "Horizontal", "Vertical"])
self.cboNumberPosition.addItems(["Number at start", "Number at end"])
self.cboNumberPosition.currentIndexChanged.connect(self.DisplayNewFilename)
self.cboNumbers.addItems(["From 1", "From 1, pad zeros" ,"Specify start" ,"Specify start, pad zeros" , "No number"])
self.cboNumbers.currentIndexChanged.connect(self.ComboboxNumbersChanged)
self.cboRenameFiles.addItems(["Selected only", "All files"])
self.cboRenameFiles.currentIndexChanged.connect(self.ComboRenameFilesChanged)
self.cboRotate.addItems(["No rotation", "90 clockwise", "180 clockwise", "270 clockwise"])
self.cboRotateFiles.addItems(["Selected only", "All files"])
self.cboRotateFiles.currentIndexChanged.connect(self.ComboRotateFilesChanged)
self.cboRenameFiles.currentIndexChanged.connect(self.ComboRenameFilesChanged)
self.cboSuffix.addItems(["Use existing suffix", "Upper case", "Lower case", "Custom"])
self.cboSuffix.currentIndexChanged.connect(self.ComboboxSuffixChanged)
self.cboThumbnail.addItems(["Write if absent", "Write all (500x281)"])
self.cboTimeEdit.addItems(["Edit all parts", "Edit hour", "Edit minute", "Edit second"])
self.cboTimeShift.addItems(["Add", "Subtract"])
self.chkOriginalDate.stateChanged.connect(self.CheckOriginalDateChanged)
self.chkOriginalTime.stateChanged.connect(self.CheckOriginalTimeChanged)
self.chkShiftDate.stateChanged.connect(self.CheckShiftDateChanged)
self.chkDelete.stateChanged.connect(self.CheckDeleteChanged)
self.chkDeleteXMP.stateChanged.connect(self.CheckDeleteChanged)
self.db =photoDb(self)
self.lblCustomSuffix.hide()
self.lblDatePattern.hide()
self.lblFilename.hide()
self.lblProgressBar.hide()
self.lblStartValue.hide()
self.lstPhotos.clicked.connect(self.ListPhotosClicked)
self.lstPhotos.installEventFilter(self)
self.lstPhotos.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
self.lstPhotos.setIconSize(QtCore.QSize(100,100))
self.lstPhotos.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.progressBar.hide()
self.setStyleSheet(self.thisMainWindowStylesheet)
self.timeOriginalDate.dateChanged.connect(self.TimeOriginalDateChanged)
self.timeOriginalDate.installEventFilter(self)
self.timeOriginalDate.setDate(QtCore.QDate.currentDate())
self.txtDatePattern.hide()
self.txtDatePattern.installEventFilter(self)
self.txtFilename.hide()
self.txtFilename.installEventFilter(self)
self.txtShiftSecond.installEventFilter(self)
self.txtStartNumber.hide()
self.txtStartNumber.installEventFilter(self)
self.txtSuffix.hide()
self.txtSuffix.installEventFilter(self)
#cycle through all checkboxes, radiobuttons and comboboxes and set their click events
for c in self.tabExifData.children():
if "txt" in c.objectName(): c.installEventFilter(self)
if "chk" in c.objectName(): c.clicked.connect(self.ExifCheckboxClicked)
for c in self.tabDate.children():
if "txt" in c.objectName(): c.installEventFilter(self)
#set global variables, including stylesheet information for both the app and its dialogs.
#we need three stylesheets for different window parts because the main window, file open box, and other msg boxes
#each need slightly different styles. The key difference is the color and behavior of active/selected buttons.
thisPath = ""
thisStylesheetBase = """
QListWidget:item:selected {
background: rgb(56,114,189)
}
QListWidget:item:!active {
color: silver
}
QLabel {
border: none
}
QTabWidget::pane {
border: 1px solid silver
}
QTabWidget::tab-bar:top {
top: 1px
}
QTabWidget::tab-bar:bottom {
bottom: 1px
}
QTabWidget::tab-bar:left {
right: 1px
}
QTabWidget::tab-bar:right {
left: 1px
}
QTabBar::tab {
border: 1px solid silver;
min-width: 7ex;
padding: 3px
}
QTabBar::tab:selected {
background: rgb(80,80,80);
color: white
}
QLineEdit {
border: 1px solid silver;
color: rgb(56,114,189)
}
QHeaderView::section {
background-color: rgb(50,50,50);
color: silver;
padding-left: 4px;
border: none
}
QComboBox {
border-radius: 0px;
padding: 1px 18px 1px 3px;
min-width: 3em;
color: rgb(56,114,189)
} """
thisMainWindowStylesheetAdditions = """
QWidget {
border: none;
background: black;
color: silver
}
QCheckBox:indicator:unchecked {
background-color: white;
}
QCheckBox:indicator:checked {
background-color: blue;
}
QProgressBar {
border: none;
border-radius: 0px;
text-align: center;
color: black;
background-color: silver
}
QProgressBar:chunk{
border: none;
border-radius: 0px;
text-align: center;
color: rgb(56,114,189);
background-color: rgb(56,114,189)
}
QPushButton {
background-color: rgb(80,80,80);
border-radius: 0px;
padding: 5px;
min-width: 5em;
outline: none
}
QComboBox {
border: none;
border-radius: 0px;
padding: 1px 18px 1px 3px;
min-width: 6em;
color: silver
}
QScrollBar:vertical {
background: rgb(56,114,189);
width: 10px;
margin: 0px 0px 0px 0px;
}"""
thisDialogStylesheetAdditions = """
QWidget {
border: none;
background: rgb(50,50,50);
color: silver
}
QScrollBar:vertical {
background: rgb(80,80,80);
width: 10px;
margin: 0px 0px 0px 0px;
border: none
}
QScrollBar:horizontal {
background: rgb(80,80,80);
width: 10px;
margin: 0px 0px 0px 0px;
border: none
}"""
thisPushButtonsWhiteTextStylesheet = """
QPushButton:!focus {
color: white;
background-color: rgb(80,80,80);
border-radius: 0px;
padding: 5px;
min-width: 5em
}
QPushButton:focus {
color: white;
background-color: rgb(80,80,80);
border-radius: 0px;
padding: 5px;
min-width: 5em;
outline: none
}"""
thisPushButtonsUnfocusedGrayTextStylesheet = """
QPushButton:!focus {
color: rgb(120,120,120);
background-color: rgb(80,80,80);
border-radius: 0px;
padding: 5px;
min-width: 5em
}
QPushButton:focus {
color: white;
background-color: rgb(80,80,80);
border-radius: 0px;
padding: 5px;
min-width: 5em;
outline: none
}"""
#concatenate stylesheet strings for their appropriate use. The different dialog boxes need different color values for
#selected and unselected buttons.
thisDialogStylesheet = thisDialogStylesheetAdditions + thisStylesheetBase + thisPushButtonsUnfocusedGrayTextStylesheet
thisMainWindowStylesheet = thisMainWindowStylesheetAdditions + thisStylesheetBase + thisPushButtonsWhiteTextStylesheet
thisFileDialogStylesheet = thisDialogStylesheetAdditions + thisStylesheetBase + thisPushButtonsWhiteTextStylesheet
def keyPressEvent(self, e):
# open file dalog routine if user presses Crtl-O
if e.key() == QtCore.Qt.Key_O and e.modifiers() & QtCore.Qt.ControlModifier:
self.ChooseFiles()
# open file dalog routine if user presses Crtl-O
if e.key() == QtCore.Qt.Key_F11:
self.ToggleFullScreen()
def CheckIfRenamingWouldOverwriteFiles(self):
#get selected rows from lstPhotos, as long as it has rows to select
#loop through selected rows and return true of file with newName already exists
#allow the renaming of file to proceed if it would only overwrite itself
#if new filename appears twice in the list of new filenames, return true
#return false if no overwriting would occur
if self.lstPhotos.count() > 0:
selRows = self.GetAffectedRows("chosen")
newNames = []
for r in selRows:
n = self.GetNewFilename(r)
newNames.append(n)
if isfile(n) and basename(n) != str(self.lstPhotos.item(r).text()):
return True
for n in newNames:
if newNames.count(n) > 1:
return True
return False
def ComboDateFilesChanged(self):
self.cboRenameFiles.setCurrentIndex(self.cboDateFiles.currentIndex())
self.cboExifFiles.setCurrentIndex(self.cboDateFiles.currentIndex())
self.DisplayNewFilename()
self.DisplayEXIFData()
def ComboExifFilesChanged(self):
self.cboRenameFiles.setCurrentIndex(self.cboExifFiles.currentIndex())
self.cboDateFiles.setCurrentIndex(self.cboExifFiles.currentIndex())
self.cboRotateFiles.setCurrentIndex(self.cboExifFiles.currentIndex())
self.DisplayNewFilename()
self.DisplayEXIFData()
def ComboRenameFilesChanged(self):
self.cboExifFiles.setCurrentIndex(self.cboRenameFiles.currentIndex())
self.cboDateFiles.setCurrentIndex(self.cboRenameFiles.currentIndex())
self.cboRotateFiles.setCurrentIndex(self.cboRenameFiles.currentIndex())
self.DisplayNewFilename()
self.DisplayEXIFData()
def ComboRotateFilesChanged(self):
self.cboRenameFiles.setCurrentIndex(self.cboExifFiles.currentIndex())
self.cboExifFiles.setCurrentIndex(self.cboRotateFiles.currentIndex())
self.cboDateFiles.setCurrentIndex(self.cboRotateFiles.currentIndex())
self.DisplayNewFilename()
self.DisplayEXIFData()
def ComboboxFilenameBaseChanged(self):
if self.cboFilenameBase.currentText() == "Specify base name":
self.txtFilename.show()
self.lblFilename.show()
else:
self.txtFilename.hide()
self.lblFilename.hide()
self.DisplayNewFilename()
def ComboboxNumbersChanged(self):
if "Specify start" in self.cboNumbers.currentText():
self.txtStartNumber.show()
self.lblStartValue.show()
else:
self.txtStartNumber.hide()
self.lblStartValue.hide()
self.DisplayNewFilename()
def ComboboxDateOptionsChanged(self):
if self.cboDateOptions.currentText() == "Custom":
self.txtDatePattern.show()
self.lblDatePattern.show()
else:
self.txtDatePattern.hide()
self.lblDatePattern.hide()
self.DisplayNewFilename()
def ComboboxSuffixChanged(self):
if "Custom" in self.cboSuffix.currentText():
self.txtSuffix.show()
self.lblCustomSuffix.show()
else:
self.txtSuffix.hide()
self.lblCustomSuffix.hide()
self.DisplayNewFilename()
def ConvertPixmapToJpeg(self, pm, fmt='JPG'):
#routine to get thumbnail jpeg from the lstPhotos icons
#this saves time compared to reading the thumbnails from scratch from the files on disk
#piexif requires thumbnail to be in bytearray format (raw JPEG data)
#check if bytearray is below 50K so the resulting EXIF data will be less than 64K spec limit
#if bytearray is too large, trim the height and width by 10 pixels and recheck size until under 50K
array = QtCore.QByteArray()
firstPass = True
while len(array) > 50000 or firstPass is True:
if firstPass is False: pm = pm.scaled(pm.height()-10, pm.width()-10, QtCore.Qt.KeepAspectRatio)
array = QtCore.QByteArray()
buffer = QtCore.QBuffer(array)
buffer.open(QtCore.QIODevice.WriteOnly)
pm.save(buffer, fmt)
buffer.close()
firstPass = False
return array.data()#return the thumbnail in raw JPEG bytearray format
def DateCheckboxClicked(self):
#this routine enables the Change Exif button if an any checkbox on tabEIFData is clicked on
#start by setting flag to false. It will be set to true only if a checkbox is found to be checked
#loop thorugh all objects on the tab, and check if a checkbox is set to true
self.btnChangeDate.setEnabled(False)
for c in self.tabDate.children():
if "chk" in c.objectName():
if c.isChecked(): self.btnChangeDate.setEnabled(True)
def DeleteFiles(self):
if self.lstPhotos.count() > 0:
itemCount = self.GetAffectedRowsCount("delete")
if itemCount > 0:
self.setCursor(QtCore.Qt.WaitCursor)
msg = QtWidgets.QMessageBox()
msg.setIcon(QtWidgets.QMessageBox.Question)
if self.chkDelete.isChecked() is True:
msgText = str(itemCount) + ' files will permanently deleted.\n\n"'
if self.chkDeleteXMP.isChecked() is True:
msgText = msgText + str(itemCount) + ' XMP files will permanently deleted.\n\n"'
msgText = msgText + "Continue?"
msg.setText(msgText)
msg.setWindowTitle("Delete Files")
msg.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel)
msg = self.FormatMessageBox(msg)
result = msg.exec_()
if result == QtWidgets.QMessageBox.Ok:
if self.chkDeleteXMP.isChecked() is True:
selRows = self.GetAffectedRows("delete")
selRows = sorted(selRows, key=int, reverse=True)
for r in selRows:
p = self.db.getPhoto(r)
if isfile(p["filename"] + ".xmp") is True:
os.remove(p["filename"] + ".xmp")
if self.chkDelete.isChecked() is True:
selRows = self.GetAffectedRows("delete")
selRows = sorted(selRows, key=int, reverse=True)
for r in selRows:
p = self.db.getPhoto(r)
if isfile(p["filename"]) is True:
os.remove(p["filename"])
self.lstPhotos.takeItem(r)
self.db.deleteRow(r)
self.setCursor(QtCore.Qt.ArrowCursor)
def DisplayBigPhoto(self):
#routine to display the currently selected photo in lstPhotos as the big image
#first, hide the previously displayed image
#if a photo is selected, convert it to QPixmap format so we can display it in the QLabel object
#get a list of all selected photos, choose the one closest to the top, and assign its image to big display
#display the QLabel object with the newly assigned image
#get a list of all selected photos, choose the one closest to the top, and assign its image to big display
if self.GetAffectedRowsCount("selected") > 0:
pm = QtGui.QPixmap()
selItems = self.lstPhotos.selectedItems()
pm = selItems[0].icon().pixmap(selItems[0].icon().availableSizes()[0])
lblBigPhotoHeight = self.lblBigPhoto.frameGeometry().height()
lblBigPhotoWidth = self.lblBigPhoto.frameGeometry().width()
if pm.height() > pm.width():
pm = pm.scaledToHeight(lblBigPhotoHeight)
else:
pm = pm.scaledToWidth(lblBigPhotoWidth)
self.lblBigPhoto.setPixmap(pm)
self.lblBigPhoto.show()
def DisplayEXIFData(self):
#set text in display to default: Null. Use "N" for displays that are too small for the whole word.
#include the < characters to clarify for the user that this is truly a null value
#also, later routines will check for the presence of the < character and will not write to EXIF
#if the < character is present
self.txtCameraMake.setText("<Null>")
self.txtCameraModel.setText("<Null>")
self.txtImageDescription.setText("<Null>")
self.txtUserComment.setText("<Null>")
self.txtLensMake.setText("<Null>")
self.txtLensModel.setText("<Null>")
self.txtArtist.setText("<Null>")
self.txtCopyright.setText("<Null>")
self.txtYear.setText("<Null>")
self.txtMonth.setText("<N>")
self.txtDay.setText("<N>")
self.txtHour.setText("<Null>")
self.txtMinute.setText("<N>")
self.txtSecond.setText("<N>")
#make sure there is data to work with...
if self.GetAffectedRowsCount("chosen") > 0:
#as a safety precaution, reset all checkboxes so the user won't accidentally insert data.
#the user will have to check the checkbox manually before future EXIF data would be inserted into files
self.chkCameraMake.setChecked(False)
self.chkCameraModel.setChecked(False)
self.chkImageDescription.setChecked(False)
self.chkUserComment.setChecked(False)
self.chkLensMake.setChecked(False)
self.chkLensModel.setChecked(False)
self.chkArtist.setChecked(False)
self.chkCopyright.setChecked(False)
self.chkOriginalDate.setChecked(False)
self.chkOriginalTime.setChecked(False)
self.chkShiftDate.setChecked(False)
self.chkEmbedThumbnail.setChecked(False)
self.btnChangeExif.setEnabled(False)
self.btnChangeDate.setEnabled(False)
#reset all our variables to get started
#variables starting with "exif" will collect data that is common to all selected files
#variables starting with "blank" are flags to be triggered if one or more files have blank data
#variables starting with "varying" are flags to be triggered if data in selected files differ
exifMake = exifModel = exifImageDescription = exifLensMake = exifLensModel = exifUserComment = exifArtist = exifCopyright = ""
exifYear = exifMonth = exifDay = exifHour = exifMinute = exifSecond = ""
blankMakeExists = blankModelExists = blankImageDescriptionExists = blankDateExists = blankLensMakeExists = blankLensModelExists = blankUserCommentExists = blankArtistExists = blankCopyrightExists = False
varyingMakes = varyingModels = varyingImageDescriptions = varyingLensMakes = varyingLensModels = varyingUserComments = varyingArtists = varyingCopyrights = False
varyingExifYears = varyingExifMonths = varyingExifDays = varyingExifHours = varyingExifMinutes = varyingExifSeconds = False
#set flag to tell loop below whether we are processing the first file in selection
isFirstEntry = True
#create selRows list to hold indexes of selected items
selRows = self.GetAffectedRows("chosen")
#variables starting with "this" hold data unique to each looped file in selection
for r in selRows:
thisExifMake = thisExifModel = thisExifImageDescription = thisExifDate = thisExifLensMake = thisExifLensModel = thisExifUserComment = thisExifArtist = thisExifCopyright = ""
thisExifYear = thisExifMonth = thisExifDay = thisExifHour = thisExifMinute = thisExifSecond = ""
#fname is the full file name for the selected list item to be processed.
#the filename takes its path from the displayed lblPath. It takes the filename from the text in lstPhotos
# fname = self.thisPath + self.lstPhotos.item(thisItem).text()
#try to get EXIF data from the selected image. Only JPEG and TIFF files will return data.
#skil the variable assignments if EXIF is not retrieved.
#if EXIF data is gotten, try to assign each variable. Some photos won't have all the variables.
p = self.db.getPhoto(r)
if "exif" in p:
exif_dict = p["exif"]
try:
thisExifMake = exif_dict["0th"][piexif.ImageIFD.Make].decode("utf-8")
except:
thisExifMake = ""
try:
thisExifModel = exif_dict["0th"][piexif.ImageIFD.Model].decode("utf-8")
except:
thisExifModel = ""
try:
thisExifImageDescription = exif_dict["0th"][piexif.ImageIFD.ImageDescription].decode("utf-8")
except:
thisExifImageDescription = ""
try:
thisExifLensMake = exif_dict["Exif"][piexif.ExifIFD.LensMake].decode("utf-8")
except:
thisExifLensMake = ""
try:
thisExifLensModel = exif_dict["Exif"][piexif.ExifIFD.LensModel].decode("utf-8")
except:
thisExifLensModel = ""
try:
thisExifUserComment = exif_dict["Exif"][piexif.ExifIFD.UserComment].decode("utf-8")
except:
thisExifUserComment = ""
try:
thisExifArtist = exif_dict["0th"][piexif.ImageIFD.Artist].decode("utf-8")
except:
thisExifArtist = ""
try:
thisExifCopyright = exif_dict["0th"][piexif.ImageIFD.Copyright].decode("utf-8")
except:
thisExifCopyright = ""
try:
thisExifDate = exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal].decode("utf-8")
except:
thisExifDate = ""
#set flags if variables are blank
if thisExifMake == "": blankMakeExists = True
if thisExifModel == "": blankModelExists = True
if thisExifImageDescription == "": blankImageDescriptionExists = True
if thisExifLensMake == "": blankLensMakeExists = True
if thisExifLensModel == "": blankLensModelExists = True
if thisExifUserComment== "": blankUserCommentExists = True
if thisExifArtist == "": blankArtistExists = True
if thisExifCopyright== "": blankCopyrightExists = True
if thisExifDate == "": blankDateExists = True
else:
#parse EXIF data for date/time components
thisExifYear = thisExifDate[0:4]
thisExifMonth = thisExifDate[5:7]
thisExifDay = thisExifDate[8:10]
thisExifHour = thisExifDate[11:13]
thisExifMinute = thisExifDate[14:16]
thisExifSecond = thisExifDate[17:19]
#if this is the first file in the selection, set the whole selection's variables to this equal those
#of this first file. Later, we'll compare each file's data against these exif variables to see
#if data varies between files
if isFirstEntry == True:
exifMake = thisExifMake
exifModel = thisExifModel
exifImageDescription = thisExifImageDescription
exifLensMake= thisExifLensMake
exifLensModel = thisExifLensModel
exifUserComment= thisExifUserComment
exifArtist= thisExifArtist
exifCopyright = thisExifCopyright
exifYear = thisExifYear
exifMonth = thisExifMonth
exifDay = thisExifDay
exifHour = thisExifHour
exifMinute = thisExifMinute
exifSecond = thisExifSecond
#now that we've processed the first file, set the isFirstEntry to False
isFirstEntry = False
else:
#check to see if this file's data varies from the others in the selection so far
if exifMake != thisExifMake: varyingMakes = True
if exifModel != thisExifModel: varyingModels = True
if exifImageDescription != thisExifImageDescription: varyingImageDescriptions = True
if exifLensMake != thisExifLensMake: varyingLensMakes= True
if exifLensModel != thisExifLensModel: varyingLensModels = True
if exifUserComment != thisExifUserComment: varyingUserComments = True
if exifArtist != thisExifArtist: varyingArtists = True
if exifCopyright != thisExifCopyright: varyingCopyrights = True
if exifYear != thisExifYear: varyingExifYears = True
if exifMonth != thisExifMonth: varyingExifMonths = True
if exifDay != thisExifDay: varyingExifDays = True
if exifHour != thisExifHour: varyingExifHours = True
if exifMinute != thisExifMinute: varyingExifMinutes = True
if exifSecond != thisExifSecond: varyingExifSeconds = True
#Now that we've looped through all selected files, set the display text boxes to the data
#of the last files processed. This will be overwritten if any files' data was blank or varied.
self.txtCameraMake.setText(thisExifMake)
self.txtCameraModel.setText(thisExifModel)
self.txtImageDescription.setText(thisExifImageDescription)
self.txtLensMake.setText(thisExifLensMake)
self.txtLensModel.setText(thisExifLensModel)
self.txtUserComment.setText(thisExifUserComment)
self.txtArtist.setText(thisExifArtist)
self.txtCopyright.setText(thisExifCopyright)
self.txtYear.setText(thisExifYear)
self.txtMonth.setText(thisExifMonth)
self.txtDay.setText(thisExifDay)
self.txtHour.setText(thisExifHour)
self.txtMinute.setText(thisExifMinute)
self.txtSecond.setText(thisExifSecond)
#overwrite the displayed data if any selected files had blank or varying data
if varyingMakes == True: self.txtCameraMake.setText("<Values vary>")
if blankMakeExists == True: self.txtCameraMake.setText("<Some are null>")
if varyingModels == True: self.txtCameraModel.setText("<Values vary>")
if blankModelExists == True: self.txtCameraModel.setText("<Some are null>")
if varyingImageDescriptions == True: self.txtImageDescription.setText("<Values vary>")
if blankImageDescriptionExists == True: self.txtImageDescription.setText("<Some are null>")
if varyingLensMakes == True: self.txtLensMake.setText("<Values vary>")
if blankLensMakeExists == True: self.txtLensMake.setText("<Some are null>")
if varyingLensModels == True: self.txtLensModel.setText("<Values vary>")
if blankLensModelExists == True: self.txtLensModel.setText("<Some are null>")
if varyingUserComments == True: self.txtUserComment.setText("<Values vary>")
if blankUserCommentExists == True: self.txtUserComment.setText("<Some are null>")
if varyingArtists == True: self.txtArtist.setText("<Values vary>")
if blankArtistExists == True: self.txtArtist.setText("<Some are null>")
if varyingCopyrights == True: self.txtCopyright.setText("<Values vary>")
if blankCopyrightExists== True: self.txtCopyright.setText("<Some are null>")
if varyingExifYears == True: self.txtYear.setText("<Vary>")
if varyingExifMonths == True: self.txtMonth.setText("<V>")
if varyingExifDays == True: self.txtDay.setText("<V>")
if varyingExifHours == True: self.txtHour.setText("<Vary>")
if varyingExifMinutes == True: self.txtMinute.setText("<V>")
if varyingExifSeconds == True: self.txtSecond.setText("<V>")
if blankDateExists == True:
self.txtYear.setText("<Null>")
self.txtMonth.setText("<N>")
self.txtDay.setText("<N>")
self.txtHour.setText("<Null>")
self.txtMinute.setText("<N>")
self.txtSecond.setText("<N>")
for c in self.tabExifData.children():
if "txt" in c.objectName(): c.setCursorPosition(0)
def DisplayNewFilename(self):
#routine to create the new filename for the selected photo in list.
#this routine itself does not rename the file. It only creates the hypothetical file name.
#first, clear the displayed sample file name
#create the new file name, using the user's specifications
#only proceed if lstPhotos isn't empty
#if current row is selected, display its sample file name
#display just the basename of the file, not its entire path
#if the clicked file isn't selected, use the topmost selected file instead
#display just the basename of the file, not its entire path
self.lblSampleNewFilename.setText("")
if self.GetAffectedRowsCount("selected") > 0:
if self.lstPhotos.item(self.lstPhotos.currentRow()).isSelected() == True:
newName = self.GetNewFilename(int(self.lstPhotos.currentRow()))
else:
selRows = self.GetAffectedRows("selected")
newName = self.GetNewFilename(int(selRows[0]))
self.lblSampleNewFilename.setText(basename(newName))
self.lblSampleNewFilename.show()
def eventFilter(self, sourceObj, event):
#routine to handle events on objects, like clicks, lost focus, gained forcus, etc.
objName = str(sourceObj.objectName())
if event.type() == QtCore.QEvent.Resize:
if objName == "MainWindow":
self.ResizeWindow(sourceObj)
if event.type() == QtCore.QEvent.KeyRelease:
#if the clicked text box is one that expects only digits, remove any non-digit characters typed in.
if sourceObj is not None:
key = event.key()
if sourceObj.parent() is not None:
if sourceObj.parent().objectName() == "tabDate" and objName != "timeOriginalDate":
if key not in (QtCore.Qt.Key_Right, QtCore.Qt.Key_Left, QtCore.Qt.Key_End, QtCore.Qt.Key_Home):
sourceObj.setText(re.sub("\D", "", str(sourceObj.text())))
#if the clicked text box has limits on its number, force the user to use the correct number (e.g., 1-12 for a month value)
if "txt" in objName:
if sourceObj.text() != "":
if objName in ("txtYear", "txtShiftYear"):
if len(sourceObj.text()) > 4:
sourceObj.setText(sourceObj.text()[0:4])
if objName in ("txtMonth", "txtShiftMonth"):
if int(sourceObj.text()) > 12:
sourceObj.setText("12")
if objName in ("txtDay"):
if int(sourceObj.text()) > 31:
sourceObj.setText("31")
if objName in ("txtShiftDay") and "YMDhms" in self.cboTimeShift.currentText():
if int(sourceObj.text()) > 31:
sourceObj.setText("31")
if objName in ("txtHour", "txtShiftHour"):
if int(sourceObj.text()) > 23:
sourceObj.setText("23")
if objName in ("txtMinute", "txtSecond", "txtShiftMinute", "txtShiftSecond"):
if int(sourceObj.text()) > 59:
sourceObj.setText("59")
if sourceObj.parent().objectName() == "tabRename":
if key not in (QtCore.Qt.Key_Right, QtCore.Qt.Key_Left, QtCore.Qt.Key_End, QtCore.Qt.Key_Home):
for c in sourceObj.children():
if "txt" in objName: #prevent user from entering invalid slash or backslash characters
if event.key() == QtCore.Qt.Key_Slash or event.key() == QtCore.Qt.Key_Backslash:
sourceObj.setText(sourceObj.text()[0:len(sourceObj.text())-1])
self.DisplayNewFilename()
if objName == "lstPhotos": #handle navigation keys for lstPhotos. the ListPhotosClicked needs to be called for each of the following key presses
if key in (QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, QtCore.Qt.Key_A, QtCore.Qt.Key_End, QtCore.Qt.Key_Home, QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown):
self.ListPhotosClicked()
#need to check if date/time text boxes have < character, indicating null, varying, or blank, so they need to be set to blank so
#the non-digit characters are not written to the time/date string in the EXIF data
if event.type() == QtCore.QEvent.MouseButtonPress:
if "txt" in objName:
if "<" in sourceObj.text():
sourceObj.setText("")
if event.type() == QtCore.QEvent.FocusOut:
if objName == "txtStartNumber":
self.txtStartNumber.setText(re.sub("\D", "", str(self.txtStartNumber.text())))
if self.txtStartNumber.text() == "": self.txtStartNumber.setText("0")
if event.type() == QtCore.QEvent.ChildRemoved:
if objName == "lstPhotos":
basenames = [str(self.lstPhotos.item(i).text()) for i in range(self.lstPhotos.count())]
self.db.adjustOrder(basenames)
self.DisplayEXIFData()
#this is the default return. GUI needs it to run properly without throwing errors
return QtWidgets.QWidget.eventFilter(self, sourceObj, event)
def ExifCheckboxClicked(self):
#this routine enables the Change Exif button if an any checkbox on tabEIFData is clicked on
#start by setting flag to false. It will be set to true only if a checkbox is found to be checked
self.btnChangeExif.setEnabled(False)
#loop thorugh all objects on the tab, and check if a checkbox is set to true
for c in self.tabExifData.children():
if "chk" in c.objectName():
if c.isChecked(): self.btnChangeExif.setEnabled(True)
def ExitApp(self):
sys.exit()
def FormatMessageBox(self,msg):
#apply the stylesheet created when the main window is instantiated (above) to a dialog box
#this lets us have one stylesheet to edit in one place, rather than specifying style elements in every routine that
#creates a diaglog box
msg.setStyleSheet(self.thisDialogStylesheet)
return msg
def GetAffectedRows(self, mode):
#routine to return the rows selected or all rows
#rows can be manually selected, or all rows can be selected using the cboNumbers combobox
#if rows are selected individually in lstPhotos, cycle through them and add them to the list
#if all rows are selected, cycle through them and add them to the list
#unfortunately, there doesn't seem to be a rows() property to do this without looping through the list.
#sort the list because sometimes Python doesn't do it automatically
#return the sorted list of row integers
selRows = []
if mode == "chosen" and self.cboRenameFiles.currentText() == "All files":
for r in range(self.lstPhotos.count()):
selRows.append(r)
else:
for i in self.lstPhotos.selectedIndexes():
selRows.append(int(i.row()))
selRows.sort()
return selRows
def GetAffectedRowsCount(self, mode):
return len(self.GetAffectedRows(mode))
def GetFormattedNumber(self, thisRow):
specifiedStartNumber = str(self.txtStartNumber.text())
specifiedStartNumber = re.sub("\D", "", specifiedStartNumber)
specifiedLeadingZeros = re.search('(?!0)', specifiedStartNumber).start()
if specifiedStartNumber == "":
specifiedStartNumber = "0"
photoNumberStartInt = int(specifiedStartNumber)
if "Specify" not in self.cboNumbers.currentText():
specifiedLeadingZeros = 0
if "No number" in self.cboNumbers.currentText(): #No numbers
return ""
if "From 1" in self.cboNumbers.currentText(): #Number from 1 (continues these steps to add padding)
if self.cboRenameFiles.currentText() == "All files":
thisPhotoNumber = thisRow + 1
if self.cboRenameFiles.currentText() == "Selected only":
thisPhotoCount = 0
for i in self.GetAffectedRows("chosen"):
if int(i) == thisRow:
thisPhotoNumber = thisPhotoCount + 1
break
thisPhotoCount += 1
thisPhotoNumberString = str(thisPhotoNumber)
if "pad" not in self.cboNumbers.currentText():
return thisPhotoNumberString
if "Specify" in self.cboNumbers.currentText(): #Number from Specified (continues these steps to add padding)
if self.cboRenameFiles.currentText() == "All files":
thisPhotoNumber = thisRow + photoNumberStartInt
if self.cboRenameFiles.currentText() == "Selected only":
thisPhotoCount = 0
for i in self.GetAffectedRows("chosen"):
if int(i) == thisRow:
thisPhotoNumber = thisPhotoCount + photoNumberStartInt
break
thisPhotoCount += 1
thisPhotoNumberString = str(thisPhotoNumber)
if specifiedLeadingZeros > 0:
zerosToPad = len(re.sub("\D", "", self.txtStartNumber.text())) - len(thisPhotoNumberString)
thisPhotoNumberString = ("0" * zerosToPad) + thisPhotoNumberString
if "pad" not in self.cboNumbers.currentText():
return thisPhotoNumberString
if "pad" in self.cboNumbers.currentText(): #add padding zeros
imageCountMaxString = str(self.GetAffectedRowsCount("chosen") + photoNumberStartInt -1)
zerosToPad = len(imageCountMaxString) - len(thisPhotoNumberString) + specifiedLeadingZeros
thisPhotoNumberString = ("0" * zerosToPad) + thisPhotoNumberString
return thisPhotoNumberString
def GetNewFilename(self, r):
p = self.db.getPhoto(r)
oldName = p["filename"]
newName = p["path"] + "/"
existingBasename = p["basename"]
suffix = p["suffix"]
thisPhotoNumberString = self.GetFormattedNumber(r)
specifiedBase = re.sub('[\\\/]', '', self.txtFilename.text())
if self.cboDateOptions.currentText() == "No date":
OriginalDate = ""
else:
OriginalDate = str(self.GetFormattedOriginalDate(oldName))
if "Use existing suffix" not in self.cboSuffix.currentText():
if "Upper case" in self.cboSuffix.currentText():
suffix = suffix.upper()
if "Lower case" in self.cboSuffix.currentText():
suffix = suffix.lower()
if "Custom" in self.cboSuffix.currentText():
suffix = "." + self.txtSuffix.text()
if self.cboNumbers.currentText() != "No number": #User has chosen to number files
if self.cboFilenameBase.currentText() == "Use existing file name" and self.cboNumberPosition.currentText() == "Number at start":
newName = newName + thisPhotoNumberString + "-"
if OriginalDate != "":
newName = newName + OriginalDate + "-"
newName = newName + existingBasename + suffix
if self.cboFilenameBase.currentText() == "Use existing file name" and self.cboNumberPosition.currentText() == "Number at end":
newName = newName + existingBasename
newName = newName + "-" + thisPhotoNumberString
if OriginalDate != "":
newName = newName + "-" + OriginalDate
newName = newName + suffix
if self.cboFilenameBase.currentText() == "Specify base name" and self.cboNumberPosition.currentText() == "Number at start":
newName = newName + thisPhotoNumberString
if OriginalDate != "":
newName = newName + "-" + OriginalDate
if specifiedBase != "":
newName = newName + "-"
newName = newName + specifiedBase
newName = newName + suffix
if self.cboFilenameBase.currentText() == "Specify base name" and self.cboNumberPosition.currentText() == "Number at end":
newName = newName + specifiedBase
if specifiedBase != "":
newName = newName + "-"
newName = newName + thisPhotoNumberString
if OriginalDate != "":
newName = newName + "-" + OriginalDate
newName = newName + suffix
if self.cboNumbers.currentText() == "No number": #User has chosen NOT to number files
if self.cboFilenameBase.currentText() == "Use existing file name" and self.cboNumberPosition.currentText() == "Number at start":
if OriginalDate != "":
newName = newName + OriginalDate + "-"
newName = newName + existingBasename + suffix
if self.cboFilenameBase.currentText() == "Use existing file name" and self.cboNumberPosition.currentText() == "Number at end":
newName = newName + existingBasename
if OriginalDate != "":
newName = newName + "-" + OriginalDate
newName = newName + suffix
if self.cboFilenameBase.currentText() == "Specify base name" and self.cboNumberPosition.currentText() == "Number at start":
if OriginalDate != "":
newName = newName + OriginalDate
if specifiedBase != "" and OriginalDate != "":
newName = newName + "-"
newName = newName + specifiedBase
newName = newName + suffix
if self.cboFilenameBase.currentText() == "Specify base name" and self.cboNumberPosition.currentText() == "Number at end":
newName = newName + specifiedBase
if OriginalDate != "":
newName = newName + "-" + OriginalDate
newName = newName + suffix
return newName
def GetFormattedOriginalDate(self, path):
try:
exif_dict = piexif.load(path)
except Exception:
pass
try:
thisExifDate = exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal].decode()
thisExifDate = thisExifDate.replace(":","")
thisExifDate = thisExifDate.replace(" ","")
#format date per user setting in cboDateOptions. No need to format for YYYYMMDDHHMMSS option
#because thisExifDate is already in that format by default
if self.cboDateOptions.currentText() == "YMD_hms":
thisExifDate = thisExifDate[0:8] + "_" + thisExifDate[8:]
if self.cboDateOptions.currentText() == "Y-M-D_hms":
thisExifDate = thisExifDate[0:4] + "-" + thisExifDate[4:6] + "-" + thisExifDate[6:8] + "_" + thisExifDate[8:]
if self.cboDateOptions.currentText() == "Y-M-D_h-m-s":
thisExifDate = thisExifDate[0:4] + "-" + thisExifDate[4:6] + "-" + thisExifDate[6:8] + "_" + thisExifDate[8:10] + "-" + thisExifDate[10:12] + "-" + thisExifDate[12:]
if self.cboDateOptions.currentText() == "Custom":
Y = thisExifDate[0:4]
M = thisExifDate[4:6]
D = thisExifDate[6:8]
h = thisExifDate[8:10]
m = thisExifDate[10:12]
s = thisExifDate[12:]
thisPattern = self.txtDatePattern.text()
thisPattern= re.sub('[^YMDhms`\-\=\!\@\#\$\%\^\&\*\(\)\_\+\[\]\\{\}\:\'\"\;\.\,\?\<\>\~]', '', thisPattern) #remove all non-meaningful letters from pattern
if "Y" in thisPattern: thisPattern = thisPattern.replace("Y", Y)
if "M" in thisPattern: thisPattern = thisPattern.replace("M", M)
if "D" in thisPattern: thisPattern = thisPattern.replace("D", D)
if "h" in thisPattern: thisPattern = thisPattern.replace("h", h)
if "m" in thisPattern: thisPattern = thisPattern.replace("m", m)
if "s" in thisPattern: thisPattern = thisPattern.replace("s", s)
thisExifDate = thisPattern
return thisExifDate
except Exception:
return ""
def GetPixmapForThumbnail(self, p, r):
if "exif" in p:
exif_dict = p["exif"]
try:
pmOrientation = int(exif_dict["0th"][piexif.ImageIFD.Orientation] )
except:
pmOrientation = 1
try:
thumbimg = exif_dict["thumbnail"]
except:
thumbimg = None
else:
thumbimg = None
if thumbimg is None:
qimage = QtGui.QImage(p["filename"])
else:
qimage = QtGui.QImage()
qimage.loadFromData(thumbimg, format='JPG')
if pmOrientation == 2: qimage = qimage.mirrored(True, False)
if pmOrientation == 3: qimage = qimage.transformed(QtGui.QTransform().rotate(180))
if pmOrientation == 4: qimage = qimage.mirrored(False, True)
if pmOrientation == 5:
qimage = qimage.mirrored(True, False)
qimage = qimage.transformed(QtGui.QTransform().rotate(270))
if pmOrientation == 6: qimage = qimage.transformed(QtGui.QTransform().rotate(90))
if pmOrientation == 7:
qimage = qimage.mirrored(True, False)
qimage = qimage.transformed(QtGui.QTransform().rotate(90))
if pmOrientation == 8: qimage = qimage.transformed(QtGui.QTransform().rotate(270))
pm = QtGui.QPixmap()
pm.convertFromImage(qimage)
if pm.height() > pm.width():
pm = pm.scaledToHeight(281)
else:
pm = pm.scaledToWidth(500)
return pm
def ListPhotosClicked(self):
self.DisplayNewFilename()
self.DisplayBigPhoto()
self.DisplayEXIFData()
def CheckDeleteChanged(self):
if self.chkDelete.isChecked() is True or self.chkDeleteXMP.isChecked() is True:
self.btnDelete.setEnabled(True)
else:
self.btnChangeDate.setEnabled(False)
def CheckOriginalDateChanged(self):
if self.chkOriginalDate.isChecked() is True:
self.chkShiftDate.setChecked(False)
self.btnChangeDate.setEnabled(False)
for c in self.tabDate.children():
if "chk" in c.objectName():
if c.isChecked(): self.btnChangeDate.setEnabled(True)
def CheckOriginalTimeChanged(self):