class PhotiniUploader(QtWidgets.QWidget): upload_file = QtCore.pyqtSignal(object, object) def __init__(self, upload_config_widget, image_list, *arg, **kw): super(PhotiniUploader, self).__init__(*arg, **kw) QtWidgets.QApplication.instance().aboutToQuit.connect(self.shutdown) self.logger = logging.getLogger(self.__class__.__name__) self.image_list = image_list self.setLayout(QtWidgets.QGridLayout()) self.session = self.session_factory() self.upload_worker = None self.connected = False # user details self.user = {} user_group = QtWidgets.QGroupBox(self.tr('User')) user_group.setLayout(QtWidgets.QVBoxLayout()) self.user_photo = QtWidgets.QLabel() self.user_photo.setAlignment(Qt.AlignHCenter | Qt.AlignTop) user_group.layout().addWidget(self.user_photo) self.user_name = QtWidgets.QLabel() self.user_name.setWordWrap(True) self.user_name.setFixedWidth(80) user_group.layout().addWidget(self.user_name) user_group.layout().addStretch(1) self.layout().addWidget(user_group, 0, 0, 1, 2) # connect / disconnect button self.user_connect = QtWidgets.QPushButton() self.user_connect.setCheckable(True) self.user_connect.clicked.connect(self.connect_user) self.layout().addWidget(self.user_connect, 1, 0, 1, 2) # 'service' specific widget self.layout().addWidget(upload_config_widget, 0, 2, 2, 2) # upload button self.upload_button = StartStopButton(self.tr('Start upload'), self.tr('Stop upload')) self.upload_button.setEnabled(False) self.upload_button.click_start.connect(self.start_upload) self.upload_button.click_stop.connect(self.stop_upload) self.layout().addWidget(self.upload_button, 2, 3) # progress bar self.layout().addWidget(QtWidgets.QLabel(self.tr('Progress')), 2, 0) self.total_progress = QtWidgets.QProgressBar() self.layout().addWidget(self.total_progress, 2, 1, 1, 2) # adjust spacing self.layout().setColumnStretch(2, 1) self.layout().setRowStretch(0, 1) @QtCore.pyqtSlot() def shutdown(self): if self.upload_worker: self.upload_worker.abort_upload() self.upload_worker.thread.quit() self.upload_worker.thread.wait() def refresh(self, force=False): with Busy(): self.connected = (self.user_connect.isChecked() and self.session.permitted('read')) if self.connected: self.user_connect.setText(self.tr('Log out')) if force: # load_user_data can be slow, so only do it when forced try: self.load_user_data() except Exception as ex: self.logger.error(ex) self.connected = False if not self.connected: self.user_connect.setText(self.tr('Connect')) # clearing user data is quick so do it anyway self.load_user_data() self.user_connect.setChecked(self.connected) self.upload_config.setEnabled(self.connected and not self.upload_worker) self.user_connect.setEnabled(not self.upload_worker) # enable or disable upload button self.new_selection(self.image_list.get_selected_images()) @QtCore.pyqtSlot(bool) def connect_user(self, connect): if connect: self.authorise('read') else: self.session.log_out() self.refresh(force=True) def do_not_close(self): if not self.upload_worker: return False dialog = QtWidgets.QMessageBox(parent=self) dialog.setWindowTitle(self.tr('Photini: upload in progress')) dialog.setText( self.tr('<h3>Upload to {} has not finished.</h3>').format( self.service_name)) dialog.setInformativeText( self.tr('Closing now will terminate the upload.')) dialog.setIcon(QtWidgets.QMessageBox.Warning) dialog.setStandardButtons(QtWidgets.QMessageBox.Close | QtWidgets.QMessageBox.Cancel) dialog.setDefaultButton(QtWidgets.QMessageBox.Cancel) result = dialog.exec_() return result == QtWidgets.QMessageBox.Cancel def show_user(self, name, picture): if name: self.user_name.setText( self.tr('Connected to {0} on {1}').format( name, self.service_name)) else: self.user_name.setText( self.tr('Not connected to {}').format(self.service_name)) pixmap = QtGui.QPixmap() if picture: pixmap.loadFromData(picture) self.user_photo.setPixmap(pixmap) def get_temp_filename(self, image, ext='.jpg'): temp_dir = appdirs.user_cache_dir('photini') if not os.path.isdir(temp_dir): os.makedirs(temp_dir) return os.path.join(temp_dir, os.path.basename(image.path) + ext) def copy_metadata(self, image, path): # copy metadata, forcing IPTC creation md = Metadata(path, None) md.copy(image.metadata) md.save(True, 'none', True) def convert_to_jpeg(self, image): im = QtGui.QImage(image.path) path = self.get_temp_filename(image) im.save(path, format='jpeg', quality=95) self.copy_metadata(image, path) return path def copy_file_and_metadata(self, image): path = self.get_temp_filename(image, ext='') shutil.copyfile(image.path, path) self.copy_metadata(image, path) return path def is_convertible(self, image): file_type = image.file_type.split('/') if file_type[0] != 'image': # can only convert images return False if 'raw' in file_type[1]: # can't convert raw files return False if image.pixmap.isNull(): # if Qt can't read it, we can't convert it return False return True def get_conversion_function(self, image): if image.file_type in self.image_types['accepted']: if image.metadata._sc or not image.metadata.has_iptc(): # need to create file without sidecar and with IPTC return self.copy_file_and_metadata return None if not self.is_convertible(image): msg = self.tr('File "{0}" is of type "{1}", which {2} does not' + ' accept and Photini cannot convert.') buttons = QtWidgets.QMessageBox.Ignore elif (self.image_types['rejected'] == '*' or image.file_type in self.image_types['rejected']): msg = self.tr('File "{0}" is of type "{1}", which {2} does not' + ' accept. Would you like to convert it to JPEG?') buttons = QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Ignore else: msg = self.tr( 'File "{0}" is of type "{1}", which {2} may not' + ' handle correctly. Would you like to convert it to JPEG?') buttons = QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No dialog = QtWidgets.QMessageBox(parent=self) dialog.setWindowTitle(self.tr('Photini: incompatible type')) dialog.setText(self.tr('<h3>Incompatible image type.</h3>')) dialog.setInformativeText( msg.format(os.path.basename(image.path), image.file_type, self.service_name)) dialog.setIcon(QtWidgets.QMessageBox.Warning) dialog.setStandardButtons(buttons) dialog.setDefaultButton(QtWidgets.QMessageBox.Yes) result = dialog.exec_() if result == QtWidgets.QMessageBox.Ignore: return 'omit' if result == QtWidgets.QMessageBox.Yes: return self.convert_to_jpeg return None @QtCore.pyqtSlot() def stop_upload(self): if self.upload_worker: # invoke worker method in this thread as worker thread is busy self.upload_worker.abort_upload() # reset GUI self.upload_file_done(None, '') @QtCore.pyqtSlot() def start_upload(self): if not self.image_list.unsaved_files_dialog(with_discard=False): self.upload_button.setChecked(False) return # make list of items to upload self.upload_list = [] for image in self.image_list.get_selected_images(): convert = self.get_conversion_function(image) if convert == 'omit': continue self.upload_list.append((image, convert)) if not self.upload_list: self.upload_button.setChecked(False) return if not self.authorise('write'): self.refresh(force=True) self.upload_button.setChecked(False) return # start uploading in separate thread, so GUI can continue self.upload_worker = UploadWorker(self.session_factory, self.get_upload_params()) self.upload_file.connect(self.upload_worker.upload_file) self.upload_worker.upload_progress.connect( self.total_progress.setValue) self.upload_worker.upload_file_done.connect(self.upload_file_done) self.upload_worker.thread.start() self.upload_config.setEnabled(False) self.user_connect.setEnabled(False) self.uploads_done = 0 self.next_upload() def next_upload(self): image, convert = self.upload_list[self.uploads_done] self.total_progress.setFormat('{} ({}/{}) %p%'.format( os.path.basename(image.path), 1 + self.uploads_done, len(self.upload_list))) self.total_progress.setValue(0) QtWidgets.QApplication.processEvents() self.upload_file.emit(image, convert) @QtCore.pyqtSlot(object, str) def upload_file_done(self, image, error): if error: dialog = QtWidgets.QMessageBox(self) dialog.setWindowTitle(self.tr('Photini: upload error')) dialog.setText( self.tr('<h3>File "{}" upload failed.</h3>').format( os.path.basename(image.path))) dialog.setInformativeText(error) dialog.setIcon(QtWidgets.QMessageBox.Warning) dialog.setStandardButtons(QtWidgets.QMessageBox.Abort | QtWidgets.QMessageBox.Retry) dialog.setDefaultButton(QtWidgets.QMessageBox.Retry) if dialog.exec_() == QtWidgets.QMessageBox.Abort: self.upload_button.setChecked(False) else: self.uploads_done += 1 if (self.upload_button.isChecked() and self.uploads_done < len(self.upload_list)): # start uploading next file (or retry same file) self.next_upload() return self.upload_button.setChecked(False) self.total_progress.setValue(0) self.total_progress.setFormat('%p%') self.upload_config.setEnabled(True) self.user_connect.setEnabled(True) self.upload_finished() self.upload_file.disconnect() self.upload_worker.upload_progress.disconnect() self.upload_worker.upload_file_done.disconnect() self.upload_worker.thread.quit() self.upload_worker.thread.wait() self.upload_worker = None # enable or disable upload button self.new_selection(self.image_list.get_selected_images()) def auth_dialog(self, auth_url): if webbrowser.open(auth_url, new=2, autoraise=0): info_text = self.tr('use your web browser') else: info_text = self.tr('open "{0}" in a web browser').format(auth_url) auth_code, OK = QtWidgets.QInputDialog.getText( self, self.tr('Photini: authorise {}').format(self.service_name), self.tr("""Please {0} to grant access to Photini, then enter the verification code:""").format(info_text)) if OK: return six.text_type(auth_code).strip() return None def authorise(self, level): with Busy(): if self.session.permitted(level): return True # do full authentication procedure auth_url = self.session.get_auth_url(level) auth_code = self.auth_dialog(auth_url) if not auth_code: return False with Busy(): self.session.get_access_token(auth_code) return self.session.permitted(level) @QtCore.pyqtSlot(list) def new_selection(self, selection): self.upload_button.setEnabled(self.upload_button.isChecked() or (len(selection) > 0 and self.connected))
class Importer(QtWidgets.QWidget): def __init__(self, image_list, parent=None): super(Importer, self).__init__(parent) app = QtWidgets.QApplication.instance() self.config_store = app.config_store self.image_list = image_list self.setLayout(QtWidgets.QGridLayout()) form = QtWidgets.QFormLayout() form.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) self.nm = NameMangler() self.file_data = {} self.file_list = [] self.session_factory = None self.import_in_progress = False # source selector box = QtWidgets.QHBoxLayout() box.setContentsMargins(0, 0, 0, 0) self.source_selector = QtWidgets.QComboBox() self.source_selector.currentIndexChanged.connect(self.new_source) box.addWidget(self.source_selector) refresh_button = QtWidgets.QPushButton(self.tr('refresh')) refresh_button.clicked.connect(self.refresh) box.addWidget(refresh_button) box.setStretch(0, 1) form.addRow(self.tr('Source'), box) # path format self.path_format = QtWidgets.QLineEdit() self.path_format.setValidator(PathFormatValidator()) self.path_format.textChanged.connect(self.nm.new_format) self.path_format.editingFinished.connect(self.path_format_finished) form.addRow(self.tr('Target format'), self.path_format) # path example self.path_example = QtWidgets.QLabel() self.nm.new_example.connect(self.path_example.setText) form.addRow('=>', self.path_example) self.layout().addLayout(form, 0, 0) # file list self.file_list_widget = QtWidgets.QListWidget() self.file_list_widget.setSelectionMode( QtWidgets.QAbstractItemView.ExtendedSelection) self.file_list_widget.itemSelectionChanged.connect( self.selection_changed) self.layout().addWidget(self.file_list_widget, 1, 0) # selection buttons buttons = QtWidgets.QVBoxLayout() buttons.addStretch(1) self.selected_count = QtWidgets.QLabel() self.selection_changed() buttons.addWidget(self.selected_count) select_all = QtWidgets.QPushButton(self.tr('Select\nall')) select_all.clicked.connect(self.select_all) buttons.addWidget(select_all) select_new = QtWidgets.QPushButton(self.tr('Select\nnew')) select_new.clicked.connect(self.select_new) buttons.addWidget(select_new) self.copy_button = StartStopButton(self.tr('Copy\nphotos'), self.tr('Stop\nimport')) self.copy_button.click_start.connect(self.copy_selected) buttons.addWidget(self.copy_button) self.layout().addLayout(buttons, 0, 1, 2, 1) # final initialisation self.image_list.sort_order_changed.connect(self.sort_file_list) if sys.platform == 'win32': import win32com.shell as ws path = ws.shell.SHGetFolderPath(0, ws.shellcon.CSIDL_MYPICTURES, None, 0) else: path = os.path.expanduser('~/Pictures') self.path_format.setText(os.path.join(path, '%Y', '%Y_%m_%d', '(name)')) self.refresh() self.list_files() @QtCore.pyqtSlot(int) def new_source(self, idx): self.session_factory = None item_data = self.source_selector.itemData(idx) if callable(item_data): # a special 'source' that's actually a method to call (item_data)() return # select new source (self.session_factory, self.session_params, self.config_section) = item_data path_format = self.path_format.text() path_format = self.config_store.get(self.config_section, 'path_format', path_format) self.path_format.setText(path_format) self.file_list_widget.clear() # allow 100ms for display to update before getting file list QtCore.QTimer.singleShot(100, self.list_files) def add_folder(self): folders = eval(self.config_store.get('importer', 'folders', '[]')) if folders: directory = folders[0] else: directory = '' root = QtWidgets.QFileDialog.getExistingDirectory( self, self.tr("Select root folder"), directory) if not root: self._fail() return if root in folders: folders.remove(root) folders.insert(0, root) if len(folders) > 5: del folders[-1] self.config_store.set('importer', 'folders', repr(folders)) self.refresh() idx = self.source_selector.count() - (1 + len(folders)) self.source_selector.setCurrentIndex(idx) @QtCore.pyqtSlot() def path_format_finished(self): if self.session_factory: self.config_store.set(self.config_section, 'path_format', self.nm.format_string) self.show_file_list() @QtCore.pyqtSlot() def refresh(self): was_blocked = self.source_selector.blockSignals(True) # save current selection idx = self.source_selector.currentIndex() if idx >= 0: old_item_data = self.source_selector.itemData(idx) else: old_item_data = None # rebuild list self.source_selector.clear() self.source_selector.addItem(self.tr('<select source>'), self._new_file_list) for model, port_name in get_camera_list(): self.source_selector.addItem( self.tr('camera: {0}').format(model), (CameraSource, (model, port_name), 'importer ' + model)) for root in eval(self.config_store.get('importer', 'folders', '[]')): if os.path.isdir(root): self.source_selector.addItem( self.tr('folder: {0}').format(root), (FolderSource, (root, ), 'importer folder ' + root)) self.source_selector.addItem(self.tr('<add a folder>'), self.add_folder) # restore saved selection new_idx = -1 for idx in range(self.source_selector.count()): item_data = self.source_selector.itemData(idx) if item_data == old_item_data: new_idx = idx self.source_selector.setCurrentIndex(idx) break self.source_selector.blockSignals(was_blocked) if new_idx < 0: self.source_selector.setCurrentIndex(0) def do_not_close(self): if not self.import_in_progress: return False dialog = QtWidgets.QMessageBox() dialog.setWindowTitle(self.tr('Photini: import in progress')) dialog.setText(self.tr('<h3>Importing photos has not finished.</h3>')) dialog.setInformativeText( self.tr('Closing now will terminate the import.')) dialog.setIcon(QtWidgets.QMessageBox.Warning) dialog.setStandardButtons(QtWidgets.QMessageBox.Close | QtWidgets.QMessageBox.Cancel) dialog.setDefaultButton(QtWidgets.QMessageBox.Cancel) result = dialog.exec_() return result == QtWidgets.QMessageBox.Cancel @QtCore.pyqtSlot(list) def new_selection(self, selection): pass @contextmanager def session(self): session = (self.session_factory)(*self.session_params) yield session session.close() def list_files(self): file_data = {} if self.session_factory: with self.session() as session: with Busy(): try: file_list = session.list_files() except gp.GPhoto2Error: # camera is no longer visible self._fail() return for path in file_list: try: info = session.get_file_info(path) except gp.GPhoto2Error: self._fail() return file_data[info['name']] = info self._new_file_list(file_data) def _fail(self): self.source_selector.setCurrentIndex(0) self.refresh() def _new_file_list(self, file_data={}): self.file_list = list(file_data.keys()) self.file_data = file_data self.sort_file_list() @QtCore.pyqtSlot() def sort_file_list(self): if eval(self.config_store.get('controls', 'sort_date', 'False')): self.file_list.sort(key=lambda x: self.file_data[x]['timestamp']) else: self.file_list.sort() self.show_file_list() if self.file_list: example = self.file_data[self.file_list[-1]] else: example = { 'camera': None, 'name': 'IMG_9999.JPG', 'timestamp': datetime.now(), } self.nm.set_example(example) def show_file_list(self): self.file_list_widget.clear() first_active = None item = None for name in self.file_list: file_data = self.file_data[name] dest_path = self.nm.transform(file_data) file_data['dest_path'] = dest_path item = QtWidgets.QListWidgetItem(name + ' -> ' + dest_path) if os.path.exists(dest_path): item.setFlags(Qt.NoItemFlags) else: if not first_active: first_active = item item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) self.file_list_widget.addItem(item) if not first_active: first_active = item self.file_list_widget.scrollToItem( first_active, QtWidgets.QAbstractItemView.PositionAtTop) @QtCore.pyqtSlot() def selection_changed(self): count = len(self.file_list_widget.selectedItems()) self.selected_count.setText(self.tr('%n file(s)\nselected', '', count)) @QtCore.pyqtSlot() def select_all(self): self.select_files(datetime.min) @QtCore.pyqtSlot() def select_new(self): since = datetime.min if self.session_factory: since = self.config_store.get(self.config_section, 'last_transfer', since.isoformat(' ')) if len(since) > 19: since = datetime.strptime(since, '%Y-%m-%d %H:%M:%S.%f') else: since = datetime.strptime(since, '%Y-%m-%d %H:%M:%S') self.select_files(since) def select_files(self, since): count = self.file_list_widget.count() if not count: return self.file_list_widget.clearSelection() first_active = None for row in range(count): item = self.file_list_widget.item(row) if not (item.flags() & Qt.ItemIsSelectable): continue name = item.text().split()[0] timestamp = self.file_data[name]['timestamp'] if timestamp > since: if not first_active: first_active = item item.setSelected(True) if not first_active: first_active = item self.file_list_widget.scrollToItem( first_active, QtWidgets.QAbstractItemView.PositionAtTop) @QtCore.pyqtSlot() def copy_selected(self): if self.import_in_progress: # user has clicked while import is still cancelling self.copy_button.setChecked(False) return self.import_in_progress = True copy_list = [] for item in self.file_list_widget.selectedItems(): name = item.text().split()[0] copy_list.append(self.file_data[name]) last_item = None, datetime.min with self.session() as session: with Busy(): for item in copy_list: dest_path = item['dest_path'] dest_dir = os.path.dirname(dest_path) if self.abort_copy(): break if not os.path.isdir(dest_dir): os.makedirs(dest_dir) try: camera_file = session.copy_file(item, dest_path) if self.abort_copy(): break if camera_file: camera_file.save(dest_path) except gp.GPhoto2Error as ex: self.logger.error(str(ex)) self._fail() break timestamp = item['timestamp'] if last_item[1] < timestamp: last_item = dest_path, timestamp if self.abort_copy(): break self.image_list.open_file(dest_path) if self.abort_copy(): break QtCore.QCoreApplication.flush() if last_item[0]: self.config_store.set(self.config_section, 'last_transfer', last_item[1].isoformat(' ')) self.image_list.done_opening(last_item[0]) self.show_file_list() self.copy_button.setChecked(False) self.import_in_progress = False def abort_copy(self): # test if user has stopped copy or quit program QtCore.QCoreApplication.processEvents() return not (self.copy_button.isChecked() and self.isVisible())