diff options
author | Julien Valroff <julien@kirya.net> | 2011-04-08 07:12:47 +0200 |
---|---|---|
committer | Julien Valroff <julien@kirya.net> | 2011-04-08 07:12:47 +0200 |
commit | 5168fdb07d6dc2b77f0ef9c7502940ce4a02e9aa (patch) | |
tree | 92ee1b0789e6527052973d100ea9d6426afc70cc /rapid/rapid.py | |
parent | eb4c5cc4472b16ce10401611140381e5ba5b6aca (diff) |
Imported Upstream version 0.3.6upstream/0.3.6
Diffstat (limited to 'rapid/rapid.py')
-rwxr-xr-x | rapid/rapid.py | 8102 |
1 files changed, 5976 insertions, 2126 deletions
diff --git a/rapid/rapid.py b/rapid/rapid.py index 3603025..f41c79f 100755 --- a/rapid/rapid.py +++ b/rapid/rapid.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: latin1 -*- -### Copyright (C) 2011 Damon Lynch <damonlynch@gmail.com> +### Copyright (C) 2007, 2008, 2009, 2010 Damon Lynch <damonlynch@gmail.com> ### This program is free software; you can redistribute it and/or modify ### it under the terms of the GNU General Public License as published by @@ -17,8 +17,19 @@ ### along with this program; if not, write to the Free Software ### Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +#needed for python 2.5, unneeded for python 2.6 +from __future__ import with_statement +import sys +import os +import shutil +import time +import datetime +import atexit import tempfile +import types +import webbrowser +import operator import dbus import dbus.bus @@ -26,89 +37,1549 @@ import dbus.service from dbus.mainloop.glib import DBusGMainLoop DBusGMainLoop(set_as_default=True) -from optparse import OptionParser +from threading import Thread, Lock +from thread import error as thread_error +from thread import get_ident -import gtk import gtk.gdk as gdk +import pango +import gobject + +try: + import gio + import glib + using_gio = True +except ImportError: + import gnomevfs + using_gio = False + +import prefs +import paths +import gnomeglade -import webbrowser - -import sys, time, types, os, datetime +from optparse import OptionParser -import gobject, pango, cairo, array, pangocairo, gio +import pynotify -from multiprocessing import Process, Pipe, Queue, Event, Value, Array, current_process, log_to_stderr -from ctypes import c_int, c_bool, c_char +import idletube as tube -import logging -logger = log_to_stderr() +import config -# Rapid Photo Downloader modules +from config import STATUS_CANNOT_DOWNLOAD, STATUS_DOWNLOADED, \ + STATUS_DOWNLOADED_WITH_WARNING, \ + STATUS_DOWNLOAD_FAILED, \ + STATUS_DOWNLOAD_PENDING, \ + STATUS_BACKUP_PROBLEM, \ + STATUS_NOT_DOWNLOADED, \ + STATUS_DOWNLOAD_AND_BACKUP_FAILED, \ + STATUS_WARNING + +import common +import misc +import higdefaults as hd -import rpdfile +from media import getDefaultPhotoLocation, getDefaultVideoLocation, \ + getDefaultBackupPhotoIdentifier, \ + getDefaultBackupVideoIdentifier -import problemnotification as pn -import thumbnail as tn -import rpdmultiprocessing as rpdmp +import ValidatedEntry + +from media import CardMedia -import preferencesdialog -import prefsrapid +import media + +import metadata +import videometadata +from videometadata import DOWNLOAD_VIDEO + +import renamesubfolderprefs as rn +import problemnotification as pn import tableplusminus as tpm -import generatename as gn -import downloadtracker +__version__ = config.version + +try: + import pygtk + pygtk.require("2.0") +except: + pass +try: + import gtk + import gtk.glade +except: + sys.exit(1) + +try: + from dropshadow import image_to_pixbuf, pixbuf_to_image, DropShadow + DROP_SHADOW = True +except: + DROP_SHADOW = False + + +#~ DROP_SHADOW = False # for testing + +from common import Configi18n +global _ +_ = Configi18n._ + +#Translators: if neccessary, for guidance in how to translate this program, you may see http://damonlynch.net/translate.html +PROGRAM_NAME = _('Rapid Photo Downloader') -from metadatavideo import DOWNLOAD_VIDEO -import metadataphoto -import metadatavideo +TINY_SCREEN = gtk.gdk.screen_height() <= config.TINY_SCREEN_HEIGHT +#~ TINY_SCREEN = True -import scan as scan_process -import copyfiles -import subfolderfile +def today(): + return datetime.date.today().strftime('%Y-%m-%d') -import errorlog -import device as dv -import utilities -import config -__version__ = config.version +def cmd_line(msg): + if verbose: + print msg -import paths +exiting = False + +def updateDisplay(display_queue): + + try: + if display_queue.size() != 0: + call, args = display_queue.get() + if not exiting: + call(*args) +# else do not update display + else: + sys.stderr.write("Empty display queue!\n") + return True + + except tube.EOInformation: + for w in workers.getStartedWorkers(): + w.join() + + gtk.main_quit() + + return False -import gettext -gettext.bindtextdomain(config.APP_NAME) -gettext.textdomain(config.APP_NAME) -from gettext import gettext as _ +class Queue(tube.Tube): + def __init__(self, maxSize = config.MAX_NO_READERS): + tube.Tube.__init__(self, maxSize) + def setMaxSize(self, maxSize): + self.maxsize = maxSize -from utilities import format_size_for_user -from utilities import register_iconsets +# Module wide values - +# set up thesse variable in global name space, and initialize with proper +# values later +# this is ugly but I don't know a better way :( -from config import STATUS_CANNOT_DOWNLOAD, STATUS_DOWNLOADED, \ - STATUS_DOWNLOADED_WITH_WARNING, \ - STATUS_DOWNLOAD_FAILED, \ - STATUS_DOWNLOAD_PENDING, \ - STATUS_BACKUP_PROBLEM, \ - STATUS_NOT_DOWNLOADED, \ - STATUS_DOWNLOAD_AND_BACKUP_FAILED, \ - STATUS_WARNING +display_queue = Queue() +media_collection_treeview = selection_hbox = log_dialog = None + +job_code = None +need_job_code_for_renaming = False + +class ThreadManager: + """ + Manages the threads that actually download photos and videos + """ + _workers = [] + + + def append(self, w): + self._workers.append(w) + + def __getitem__(self, i): + return self._workers[i] + + def __len__(self): + return len(self._workers) + + def disableWorker(self, thread_id): + """ + set so a worker will not run, or if it is running, make it quit and therefore complete + """ + + self._workers[thread_id].manuallyDisabled = True + if self._workers[thread_id].hasStarted: + self._workers[thread_id].quit() + + else: + self._workers[thread_id].doNotStart = True + + def _isReadyToStart(self, w): + """ + Returns True if the worker is ready to start + and has not been disabled + """ + return not w.hasStarted and not w.doNotStart and not w.manuallyDisabled + + def _isReadyToDownload(self, w): + return w.scanComplete and not w.downloadStarted and not w.doNotStart and w.isAlive() and not w.manuallyDisabled + + def _isScanning(self, w): + return w.isAlive() and w.hasStarted and not w.scanComplete and not w.manuallyDisabled + + def _isDownloading(self, w): + return w.downloadStarted and w.isAlive() and not w.downloadComplete + + def _isPaused(self, w): + return w.downloadStarted and not w.running and not w.downloadComplete and not w.manuallyDisabled and w.isAlive() + + def _isFinished(self, w): + """ + Returns True if the worker has finished running + + It does not signify it finished a download + """ + + return (w.hasStarted and not w.isAlive()) or w.manuallyDisabled + + def completedDownload(self, w): + return w.completedDownload + + def firstWorkerReadyToStart(self): + for w in self._workers: + if self._isReadyToStart(w): + return w + return None + + def firstWorkerReadyToDownload(self): + for w in self._workers: + if self._isReadyToDownload(w): + return w + return None + + def startWorkers(self): + for w in self.getReadyToStartWorkers(): + #for some reason, very occassionally a thread that has been started shows up in this list, so must filter them out + if not w.isAlive(): + w.start() + + def quitAllWorkers(self): + global exiting + exiting = True + for w in self._workers: + w.quit() + + def getWorkers(self): + for w in self._workers: + yield w + + def getNonFinishedWorkers(self): + for w in self._workers: + if not self._isFinished(w): + yield w + + def getStartedWorkers(self): + for w in self._workers: + if w.hasStarted: + yield w + + def getReadyToStartWorkers(self): + for w in self._workers: + if self._isReadyToStart(w): + yield w + + def getReadyToDownloadWorkers(self): + for w in self._workers: + if self._isReadyToDownload(w): + yield w + + def getNotDownloadingWorkers(self): + for w in self._workers: + if w.hasStarted and not w.downloadStarted: + yield w + + def getNotDownloadingAndNotFinishedWorkers(self): + for w in self._workers: + if w.hasStarted and not w.downloadStarted and not self._isFinished(w): + yield w + + + def noReadyToStartWorkers(self): + n = 0 + for w in self._workers: + if self._isReadyToStart(w): + n += 1 + return n + + def noScanningWorkers(self): + n = 0 + for w in self._workers: + if self._isScanning(w): + n += 1 + return n + + def getScanningWorkers(self): + for w in self._workers: + if self._isScanning(w): + yield w + + def scanComplete(self, threads): + """ + Returns True only if the list of threads have completed their scan + """ + for thread_id in threads: + if not self[thread_id].scanComplete: + return False + return True + + def noReadyToDownloadWorkers(self): + n = 0 + for w in self._workers: + if self._isReadyToDownload(w): + n += 1 + return n + + def getRunningWorkers(self): + for w in self._workers: + if w.hasStarted and w.isAlive(): + yield w + + def getDownloadingWorkers(self): + for w in self._workers: + if self._isDownloading(w): + yield w + + def getPausedDownloadingWorkers(self): + for w in self._workers: + if self._isPaused(w): + yield w + + def getWaitingForJobCodeWorkers(self): + for w in self._workers: + if w.waitingForJobCode: + yield w + + def getAutoStartWorkers(self): + for w in self._workers: + if w.autoStart: + yield w + + def getFinishedWorkers(self): + for w in self._workers: + if self._isFinished(w): + yield w + + def noDownloadingWorkers(self): + i = 0 + for w in self._workers: + if self._isDownloading(w): + i += 1 + return i + + def noRunningWorkers(self): + i = 0 + for w in self._workers: + if w.hasStarted and w.isAlive(): + i += 1 + return i + + def noPausedWorkers(self): + i = 0 + for w in self._workers: + if self._isPaused(w): + i += 1 + return i + + def getNextThread_id(self): + return len(self._workers) + + def printWorkerStatus(self, worker=None): + if worker: + l = [worker] + else: + l = range(len(self._workers)) + for i in l: + print "\nThread %i\n=======\n" % i + w = self._workers[i] + print "Volume / source:", w.cardMedia.prettyName(limit=0) + print "Do not start:", w.doNotStart + print "Started:", w.hasStarted + print "Running:", w.running + print "Scan completed:", w.scanComplete + print "Download started:", w.downloadStarted + print "Download completed:", w.downloadComplete + print "Finished:", self._isFinished(w) + print "Alive:", w.isAlive() + print "Manually disabled:", w.manuallyDisabled, "\n" + + + +workers = ThreadManager() + +class RapidPreferences(prefs.Preferences): + if TINY_SCREEN: + zoom = 120 + else: + zoom = config.MIN_THUMBNAIL_SIZE * 2 + + defaults = { + "program_version": prefs.Value(prefs.STRING, ""), + "download_folder": prefs.Value(prefs.STRING, + getDefaultPhotoLocation()), + "video_download_folder": prefs.Value(prefs.STRING, + getDefaultVideoLocation()), + "subfolder": prefs.ListValue(prefs.STRING_LIST, rn.DEFAULT_SUBFOLDER_PREFS), + "video_subfolder": prefs.ListValue(prefs.STRING_LIST, rn.DEFAULT_VIDEO_SUBFOLDER_PREFS), + "image_rename": prefs.ListValue(prefs.STRING_LIST, [rn.FILENAME, + rn.NAME_EXTENSION, + rn.ORIGINAL_CASE]), + "video_rename": prefs.ListValue(prefs.STRING_LIST, [rn.FILENAME, + rn.NAME_EXTENSION, + rn.ORIGINAL_CASE]), + "device_autodetection": prefs.Value(prefs.BOOL, True), + "device_location": prefs.Value(prefs.STRING, os.path.expanduser('~')), + "device_autodetection_psd": prefs.Value(prefs.BOOL, False), + "device_whitelist": prefs.ListValue(prefs.STRING_LIST, ['']), + "device_blacklist": prefs.ListValue(prefs.STRING_LIST, ['']), + "backup_images": prefs.Value(prefs.BOOL, False), + "backup_device_autodetection": prefs.Value(prefs.BOOL, True), + "backup_identifier": prefs.Value(prefs.STRING, + getDefaultBackupPhotoIdentifier()), + "video_backup_identifier": prefs.Value(prefs.STRING, + getDefaultBackupVideoIdentifier()), + "backup_location": prefs.Value(prefs.STRING, os.path.expanduser('~')), + "strip_characters": prefs.Value(prefs.BOOL, True), + "auto_download_at_startup": prefs.Value(prefs.BOOL, False), + "auto_download_upon_device_insertion": prefs.Value(prefs.BOOL, False), + "auto_unmount": prefs.Value(prefs.BOOL, False), + "auto_exit": prefs.Value(prefs.BOOL, False), + "auto_delete": prefs.Value(prefs.BOOL, False), + "download_conflict_resolution": prefs.Value(prefs.STRING, + config.SKIP_DOWNLOAD), + "backup_duplicate_overwrite": prefs.Value(prefs.BOOL, False), + "display_selection": prefs.Value(prefs.BOOL, True), + "display_size_column": prefs.Value(prefs.BOOL, True), + "display_filename_column": prefs.Value(prefs.BOOL, False), + "display_type_column": prefs.Value(prefs.BOOL, True), + "display_path_column": prefs.Value(prefs.BOOL, False), + "display_device_column": prefs.Value(prefs.BOOL, False), + "display_preview_folders": prefs.Value(prefs.BOOL, True), + "show_log_dialog": prefs.Value(prefs.BOOL, False), + "day_start": prefs.Value(prefs.STRING, "03:00"), + "downloads_today": prefs.ListValue(prefs.STRING_LIST, [today(), '0']), + "stored_sequence_no": prefs.Value(prefs.INT, 0), + "job_codes": prefs.ListValue(prefs.STRING_LIST, [_('New York'), + _('Manila'), _('Prague'), _('Helsinki'), _('Wellington'), + _('Tehran'), _('Kampala'), _('Paris'), _('Berlin'), _('Sydney'), + _('Budapest'), _('Rome'), _('Moscow'), _('Delhi'), _('Warsaw'), + _('Jakarta'), _('Madrid'), _('Stockholm')]), + "synchronize_raw_jpg": prefs.Value(prefs.BOOL, False), + "hpaned_pos": prefs.Value(prefs.INT, 0), + "vpaned_pos": prefs.Value(prefs.INT, 0), + "main_window_size_x": prefs.Value(prefs.INT, 0), + "main_window_size_y": prefs.Value(prefs.INT, 0), + "main_window_maximized": prefs.Value(prefs.INT, 0), + "show_warning_downloading_from_camera": prefs.Value(prefs.BOOL, True), + "preview_zoom": prefs.Value(prefs.INT, zoom), + } + + def __init__(self): + prefs.Preferences.__init__(self, config.GCONF_KEY, self.defaults) + + def getAndMaybeResetDownloadsToday(self): + v = self.getDownloadsToday() + if v <= 0: + self.resetDownloadsToday() + return v + + def getDownloadsToday(self): + """Returns the preference value for the number of downloads performed today + + If value is less than zero, that means the date has changed""" + + hour, minute = self.getDayStart() + adjustedToday = datetime.datetime.strptime("%s %s:%s" % (self.downloads_today[0], hour, minute), "%Y-%m-%d %H:%M") + + now = datetime.datetime.today() + + if now < adjustedToday : + try: + return int(self.downloads_today[1]) + except ValueError: + sys.stderr.write(_("Invalid Downloads Today value.\n")) + sys.stderr.write(_("Resetting value to zero.\n")) + self.setDownloadsToday(self.downloads_today[0] , 0) + return 0 + else: + return -1 + + def setDownloadsToday(self, date, value=0): + self.downloads_today = [date, str(value)] + + def incrementDownloadsToday(self): + """ returns true if day changed """ + v = self.getDownloadsToday() + if v >= 0: + self.setDownloadsToday(self.downloads_today[0] , v + 1) + return False + else: + self.resetDownloadsToday(1) + return True + + def resetDownloadsToday(self, value=0): + now = datetime.datetime.today() + hour, minute = self.getDayStart() + t = datetime.time(hour, minute) + if now.time() < t: + date = today() + else: + d = datetime.datetime.today() + datetime.timedelta(days=1) + date = d.strftime(('%Y-%m-%d')) + + self.setDownloadsToday(date, value) + + def setDayStart(self, hour, minute): + self.day_start = "%s:%s" % (hour, minute) + + def getDayStart(self): + try: + t1, t2 = self.day_start.split(":") + return (int(t1), int(t2)) + except ValueError: + sys.stderr.write(_("'Start of day' preference value is corrupted.\n")) + sys.stderr.write(_("Resetting to midnight.\n")) + self.day_start = "0:0" + return 0, 0 + + def getSampleJobCode(self): + if self.job_codes: + return self.job_codes[0] + else: + return '' + + def reset(self): + """ + resets all preferences to default values + """ + + prefs.Preferences.reset(self) + self.program_version = __version__ + +class ImageRenameTable(tpm.TablePlusMinus): + + def __init__(self, parentApp, adjustScrollWindow): + + tpm.TablePlusMinus.__init__(self, 1, 3) + self.parentApp = parentApp + self.adjustScrollWindow = adjustScrollWindow + if not hasattr(self, "errorTitle"): + self.errorTitle = _("Error in Photo Rename preferences") -DOWNLOADED = [STATUS_DOWNLOADED, STATUS_DOWNLOADED_WITH_WARNING, STATUS_BACKUP_PROBLEM] + self.table_type = self.errorTitle[len("Error in "):] + self.i = 0 -#Translators: if neccessary, for guidance in how to translate this program, you may see http://damonlynch.net/translate.html -PROGRAM_NAME = _('Rapid Photo Downloader') -__version__ = config.version + if adjustScrollWindow: + self.scrollBar = self.adjustScrollWindow.get_vscrollbar() + #this next line does not work on early versions of pygtk :( + self.scrollBar.connect('visibility-notify-event', self.scrollbar_visibility_change) + self.connect("size-request", self.size_adjustment) + self.connect("add", self.size_adjustment) + self.connect("remove", self.size_adjustment) + + # get scrollbar thickness from parent app scrollbar - very hackish, but what to do?? + self.bump = 16# self.parentApp.parentApp.image_scrolledwindow.get_hscrollbar().allocation.height + self.haveVerticalScrollbar = False + + # vbar is '1' if there is not vertical scroll bar + # if there is a vertical scroll bar, then it will have a the width of the bar + #self.vbar = self.adjustScrollWindow.get_vscrollbar().allocation.width + + self.getParentAppPrefs() + self.getPrefsFactory() + self.prefsFactory.setDownloadStartTime(datetime.datetime.now()) + + try: + self.prefsFactory.checkPrefsForValidity() + + except (rn.PrefValueInvalidError, rn.PrefLengthError, + rn.PrefValueKeyComboError, rn.PrefKeyError), e: + + sys.stderr.write(self.errorTitle + "\n") + sys.stderr.write(_("Sorry,these preferences contain an error:\n")) + sys.stderr.write(self.prefsFactory.formatPreferencesForPrettyPrint() + "\n") + + # the preferences were invalid + # reset them to their default + + self.prefList = self.prefsFactory.defaultPrefs + self.getPrefsFactory() + self.updateParentAppPrefs() + + msg = "%s.\n" % e + msg += _("Resetting to default values." + "\n") + sys.stderr.write(msg) + + + misc.run_dialog(self.errorTitle, msg, + parentApp, + gtk.MESSAGE_ERROR) + + for row in self.prefsFactory.getWidgetsBasedOnPreferences(): + self.append(row) + + def updatePreferences(self): + prefList = [] + for row in self.pm_rows: + for col in range(self.pm_noColumns): + widget = row[col] + if widget: + name = widget.get_name() + if name == 'GtkComboBox': + value = widget.get_active_text() + elif name == 'GtkEntry': + value = widget.get_text() + else: + sys.stderr.write("Program error: Unknown preference widget!") + value = '' + else: + value = '' + prefList.append(value) + + self.prefList = prefList + self.updateParentAppPrefs() + self.prefsFactory.prefList = prefList + self.updateExample() + + + def scrollbar_visibility_change(self, widget, event): + if event.state == gdk.VISIBILITY_UNOBSCURED: + self.haveVerticalScrollbar = True + self.adjustScrollWindow.set_size_request(self.adjustScrollWindow.allocation.width + self.bump, -1) + + + def size_adjustment(self, widget, arg2): + """ + Adjust scrolledwindow width in preferences dialog to reflect width of image rename table + + The algorithm is complicated by the need to take into account the presence of a vertical scrollbar, + which might be added as the user adds more rows + + The pygtk code behaves inconsistently depending on the pygtk version + """ + + if self.adjustScrollWindow: + self.haveVerticalScrollbar = self.scrollBar.allocation.width > 1 or self.haveVerticalScrollbar + if not self.haveVerticalScrollbar: + if self.allocation.width > self.adjustScrollWindow.allocation.width: + self.adjustScrollWindow.set_size_request(self.allocation.width, -1) + else: + if self.allocation.width > self.adjustScrollWindow.allocation.width - self.bump: + self.adjustScrollWindow.set_size_request(self.allocation.width + self.bump, -1) + self.bump = 0 + + def getParentAppPrefs(self): + self.prefList = self.parentApp.prefs.image_rename + + + def getPrefsFactory(self): + self.prefsFactory = rn.ImageRenamePreferences(self.prefList, self, + sequences = sequences) + + def updateParentAppPrefs(self): + self.parentApp.prefs.image_rename = self.prefList + + def updateExampleJobCode(self): + job_code = self.parentApp.prefs.getSampleJobCode() + if not job_code: + job_code = _('Job code') + self.prefsFactory.setJobCode(job_code) + + def updateExample(self): + self.parentApp.updateImageRenameExample() + + def getDefaultRow(self): + return self.prefsFactory.getDefaultRow() + + def on_combobox_changed(self, widget, rowPosition): + + for col in range(self.pm_noColumns): + if self.pm_rows[rowPosition][col] == widget: + break + selection = [] + for i in range(col + 1): + # ensure it is a combo box we are getting the value from + w = self.pm_rows[rowPosition][i] + name = w.get_name() + if name == 'GtkComboBox': + selection.append(w.get_active_text()) + else: + selection.append(w.get_text()) + + for i in range(col + 1, self.pm_noColumns): + selection.append('') + + if col <> (self.pm_noColumns - 1): + widgets = self.prefsFactory.getWidgetsBasedOnUserSelection(selection) + + for i in range(col + 1, self.pm_noColumns): + oldWidget = self.pm_rows[rowPosition][i] + if oldWidget: + self.remove(oldWidget) + if oldWidget in self.pm_callbacks: + del self.pm_callbacks[oldWidget] + newWidget = widgets[i] + self.pm_rows[rowPosition][i] = newWidget + if newWidget: + self._createCallback(newWidget, rowPosition) + self.attach(newWidget, i, i+1, rowPosition, rowPosition + 1) + newWidget.show() + self.updatePreferences() + + + def on_entry_changed(self, widget, rowPosition): + self.updatePreferences() + + def on_rowAdded(self, rowPosition): + """ + Update preferences, as a row has been added + """ + self.updatePreferences() + + # if this was the last row or 2nd to last row, and another has just been added, move vertical scrollbar down + if rowPosition in range(self.pm_noRows - 3, self.pm_noRows - 2): + adjustment = self.parentApp.rename_scrolledwindow.get_vadjustment() + adjustment.set_value(adjustment.upper) + + + def on_rowDeleted(self, rowPosition): + """ + Update preferences, as a row has been deleted + """ + self.updatePreferences() + +class VideoRenameTable(ImageRenameTable): + def __init__(self, parentApp, adjustScollWindow): + self.errorTitle = _("Error in Video Rename preferences") + ImageRenameTable.__init__(self, parentApp, adjustScollWindow) + + def getParentAppPrefs(self): + self.prefList = self.parentApp.prefs.video_rename + + def getPrefsFactory(self): + self.prefsFactory = rn.VideoRenamePreferences(self.prefList, self, + sequences = sequences) + + def updateParentAppPrefs(self): + self.parentApp.prefs.video_rename = self.prefList + + def updateExample(self): + self.parentApp.updateVideoRenameExample() + +class SubfolderTable(ImageRenameTable): + def __init__(self, parentApp, adjustScollWindow): + self.errorTitle = _("Error in Photo Download Subfolders preferences") + ImageRenameTable.__init__(self, parentApp, adjustScollWindow) + + def getParentAppPrefs(self): + self.prefList = self.parentApp.prefs.subfolder + + def getPrefsFactory(self): + self.prefsFactory = rn.SubfolderPreferences(self.prefList, self) + + def updateParentAppPrefs(self): + self.parentApp.prefs.subfolder = self.prefList + + def updateExample(self): + self.parentApp.updatePhotoDownloadFolderExample() + +class VideoSubfolderTable(ImageRenameTable): + def __init__(self, parentApp, adjustScollWindow): + self.errorTitle = _("Error in Video Download Subfolders preferences") + ImageRenameTable.__init__(self, parentApp, adjustScollWindow) + + def getParentAppPrefs(self): + self.prefList = self.parentApp.prefs.video_subfolder + + def getPrefsFactory(self): + self.prefsFactory = rn.VideoSubfolderPreferences(self.prefList, self) + + def updateParentAppPrefs(self): + self.parentApp.prefs.video_subfolder = self.prefList + + def updateExample(self): + self.parentApp.updateVideoDownloadFolderExample() +class PreferencesDialog(gnomeglade.Component): + def __init__(self, parentApp): + gnomeglade.Component.__init__(self, + paths.share_dir(config.GLADE_FILE), + "preferencesdialog") + + self.widget.set_transient_for(parentApp.widget) + self.prefs = parentApp.prefs + + parentApp.preferencesDialogDisplayed = True + + self.parentApp = parentApp + + self._setupTabSelector() + + self._setupControlSpacing() + + if DOWNLOAD_VIDEO: + self.file_types = _("photos and videos") + else: + self.file_types = _("photos") + + # get example photo and video data + try: + w = workers.firstWorkerReadyToDownload() + mediaFile = w.firstImage() + self.sampleImageName = mediaFile.name + # assume the metadata is already read + self.sampleImage = mediaFile.metadata + except: + self.sampleImage = metadata.DummyMetaData() + self.sampleImageName = 'IMG_0524.CR2' + + try: + mediaFile = w.firstVideo() + self.sampleVideoName = mediaFile.name + self.sampleVideo = mediaFile.metadata + self.videoFallBackDate = mediaFile.modificationTime + except: + self.sampleVideo = videometadata.DummyMetaData() + self.sampleVideoName = 'MVI_1379.MOV' + self.videoFallBackDate = datetime.datetime.now() + + + # setup tabs + self._setupPhotoDownloadFolderTab() + self._setupImageRenameTab() + self._setupVideoDownloadFolderTab() + self._setupVideoRenameTab() + self._setupRenameOptionsTab() + self._setupJobCodeTab() + self._setupDeviceTab() + self._setupBackupTab() + self._setupAutomationTab() + self._setupErrorTab() + + if not DOWNLOAD_VIDEO: + self.disableVideoControls() + + self.widget.realize() + + #set the width of the left column for selecting values + #note: this must be called after self.widget.realize(), or else the width calculation will fail + width_of_widest_sel_row = self.treeview.get_background_area(1, self.treeview_column)[2] + self.scrolled_window.set_size_request(width_of_widest_sel_row + 2, -1) + + #set the minimum width of the scolled window holding the photo rename table + if self.rename_scrolledwindow.get_vscrollbar(): + extra = self.rename_scrolledwindow.get_vscrollbar().allocation.width + 10 + else: + extra = 10 + self.rename_scrolledwindow.set_size_request(self.rename_table.allocation.width + extra, -1) + + self.widget.show() + + def on_preferencesdialog_destroy(self, widget): + """ Delete variables from memory that cause a file descriptor to be created on a mounted media""" + del self.sampleImage, self.rename_table.prefsFactory, self.subfolder_table.prefsFactory + + def _setupTabSelector(self): + self.notebook.set_show_tabs(0) + self.model = gtk.ListStore(type("")) + column = gtk.TreeViewColumn() + rentext = gtk.CellRendererText() + column.pack_start(rentext, expand=0) + column.set_attributes(rentext, text=0) + self.treeview_column = column + self.treeview.append_column(column) + self.treeview.props.model = self.model + for c in self.notebook.get_children(): + label = self.notebook.get_tab_label(c).get_text() + if not label.startswith("_"): + self.model.append( (label,) ) + + + # select the first value in the list store + self.treeview.set_cursor(0,column) + + def on_download_folder_filechooser_button_selection_changed(self, widget): + self.prefs.download_folder = widget.get_current_folder() + self.updatePhotoDownloadFolderExample() + + def on_video_download_folder_filechooser_button_selection_changed(self, widget): + self.prefs.video_download_folder = widget.get_current_folder() + self.updateVideoDownloadFolderExample() + + def on_backup_folder_filechooser_button_selection_changed(self, widget): + self.prefs.backup_location = widget.get_current_folder() + self.updateBackupExample() + + def on_device_location_filechooser_button_selection_changed(self, widget): + self.prefs.device_location = widget.get_current_folder() + + def _setupControlSpacing(self): + """ + set spacing of some but not all controls + """ + + self._setupTableSpacing(self.download_folder_table) + self._setupTableSpacing(self.video_download_folder_table) + self.download_folder_table.set_row_spacing(2, + hd.VERTICAL_CONTROL_SPACE) + self.video_download_folder_table.set_row_spacing(2, + hd.VERTICAL_CONTROL_SPACE) + self._setupTableSpacing(self.rename_example_table) + self._setupTableSpacing(self.video_rename_example_table) + self.devices_table.set_col_spacing(0, hd.NESTED_CONTROLS_SPACE) + + self._setupTableSpacing(self.backup_table) + self.backup_table.set_col_spacing(1, hd.NESTED_CONTROLS_SPACE) + self.backup_table.set_col_spacing(2, hd.CONTROL_LABEL_SPACE) + self._setupTableSpacing(self.compatibility_table) + self.compatibility_table.set_row_spacing(0, + hd.VERTICAL_CONTROL_LABEL_SPACE) + self._setupTableSpacing(self.error_table) + + + def _setupTableSpacing(self, table): + table.set_col_spacing(0, hd.NESTED_CONTROLS_SPACE) + table.set_col_spacing(1, hd.CONTROL_LABEL_SPACE) + + def _setupSubfolderTable(self): + self.subfolder_table = SubfolderTable(self, None) + self.subfolder_vbox.pack_start(self.subfolder_table) + self.subfolder_table.show_all() + + def _setupVideoSubfolderTable(self): + self.video_subfolder_table = VideoSubfolderTable(self, None) + self.video_subfolder_vbox.pack_start(self.video_subfolder_table) + self.video_subfolder_table.show_all() + + def _setupPhotoDownloadFolderTab(self): + self.download_folder_filechooser_button = gtk.FileChooserButton( + _("Select a folder to download photos to")) + self.download_folder_filechooser_button.set_current_folder( + self.prefs.download_folder) + self.download_folder_filechooser_button.set_action( + gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER) + self.download_folder_filechooser_button.connect("selection-changed", + self.on_download_folder_filechooser_button_selection_changed) + + self.download_folder_table.attach( + self.download_folder_filechooser_button, + 2, 3, 2, 3, yoptions = gtk.SHRINK) + self.download_folder_filechooser_button.show() + + self._setupSubfolderTable() + self.updatePhotoDownloadFolderExample() + + def _setupVideoDownloadFolderTab(self): + self.video_download_folder_filechooser_button = gtk.FileChooserButton( + _("Select a folder to download videos to")) + self.video_download_folder_filechooser_button.set_current_folder( + self.prefs.video_download_folder) + self.video_download_folder_filechooser_button.set_action( + gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER) + self.video_download_folder_filechooser_button.connect("selection-changed", + self.on_video_download_folder_filechooser_button_selection_changed) + + self.video_download_folder_table.attach( + self.video_download_folder_filechooser_button, + 2, 3, 2, 3, yoptions = gtk.SHRINK) + self.video_download_folder_filechooser_button.show() + self._setupVideoSubfolderTable() + self.updateVideoDownloadFolderExample() + + def _setupImageRenameTab(self): + + self.rename_table = ImageRenameTable(self, self.rename_scrolledwindow) + self.rename_table_vbox.pack_start(self.rename_table) + self.rename_table.show_all() + self.original_name_label.set_markup("<i>%s</i>" % self.sampleImageName) + self.updateImageRenameExample() + + def _setupVideoRenameTab(self): + + self.video_rename_table = VideoRenameTable(self, self.video_rename_scrolledwindow) + self.video_rename_table_vbox.pack_start(self.video_rename_table) + self.video_rename_table.show_all() + self.video_original_name_label.set_markup("<i>%s</i>" % self.sampleVideoName) + self.updateVideoRenameExample() + + def _setupRenameOptionsTab(self): + + # sequence numbers + self.downloads_today_entry = ValidatedEntry.ValidatedEntry(ValidatedEntry.bounded(ValidatedEntry.v_int, int, 0)) + self.stored_number_entry = ValidatedEntry.ValidatedEntry(ValidatedEntry.bounded(ValidatedEntry.v_int, int, 1)) + self.downloads_today_entry.connect('changed', self.on_downloads_today_entry_changed) + self.stored_number_entry.connect('changed', self.on_stored_number_entry_changed) + v = self.prefs.getAndMaybeResetDownloadsToday() + self.downloads_today_entry.set_text(str(v)) + # make the displayed value of stored sequence no 1 more than actual value + # so as not to confuse the user + self.stored_number_entry.set_text(str(self.prefs.stored_sequence_no+1)) + self.sequence_vbox.pack_start(self.downloads_today_entry, expand=True, fill=True) + self.sequence_vbox.pack_start(self.stored_number_entry, expand=False) + self.downloads_today_entry.show() + self.stored_number_entry.show() + hour, minute = self.prefs.getDayStart() + self.hour_spinbutton.set_value(float(hour)) + self.minute_spinbutton.set_value(float(minute)) + + self.synchronize_raw_jpg_checkbutton.set_active( + self.prefs.synchronize_raw_jpg) + + #compatibility + self.strip_characters_checkbutton.set_active( + self.prefs.strip_characters) + + def _setupJobCodeTab(self): + self.job_code_liststore = gtk.ListStore(str) + column = gtk.TreeViewColumn() + rentext = gtk.CellRendererText() + rentext.connect('edited', self.on_job_code_edited) + rentext .set_property('editable', True) + + column.pack_start(rentext, expand=0) + column.set_attributes(rentext, text=0) + self.job_code_treeview_column = column + self.job_code_treeview.append_column(column) + self.job_code_treeview.props.model = self.job_code_liststore + for code in self.prefs.job_codes: + self.job_code_liststore.append((code, )) + + # set multiple selections + self.job_code_treeview.get_selection().set_mode(gtk.SELECTION_MULTIPLE) + + self.remove_all_job_code_button.set_image(gtk.image_new_from_stock( + gtk.STOCK_CLEAR, + gtk.ICON_SIZE_BUTTON)) + def _setupDeviceTab(self): + + self.device_location_filechooser_button = gtk.FileChooserButton( + _("Select a folder containing %(file_types)s") % {'file_types':self.file_types}) + self.device_location_filechooser_button.set_current_folder( + self.prefs.device_location) + self.device_location_filechooser_button.set_action( + gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER) + + self.device_location_filechooser_button.connect("selection-changed", + self.on_device_location_filechooser_button_selection_changed) + + self.devices2_table.attach(self.device_location_filechooser_button, + 1, 2, 1, 2, xoptions = gtk.EXPAND|gtk.FILL, yoptions = gtk.SHRINK) + self.device_location_filechooser_button.show() + self.autodetect_device_checkbutton.set_active( + self.prefs.device_autodetection) + self.autodetect_psd_checkbutton.set_active( + self.prefs.device_autodetection_psd) + + self.updateDeviceControls() + + + def _setupBackupTab(self): + self.backup_folder_filechooser_button = gtk.FileChooserButton( + _("Select a folder in which to backup %(file_types)s") % {'file_types':self.file_types}) + self.backup_folder_filechooser_button.set_current_folder( + self.prefs.backup_location) + self.backup_folder_filechooser_button.set_action( + gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER) + self.backup_folder_filechooser_button.connect("selection-changed", + self.on_backup_folder_filechooser_button_selection_changed) + self.backup_table.attach(self.backup_folder_filechooser_button, + 3, 4, 8, 9, yoptions = gtk.SHRINK) + self.backup_folder_filechooser_button.show() + self.backup_identifier_entry.set_text(self.prefs.backup_identifier) + self.video_backup_identifier_entry.set_text(self.prefs.video_backup_identifier) + + #setup controls for manipulating sensitivity + self._backupControls0 = [self.auto_detect_backup_checkbutton] + self._backupControls1 = [self.backup_identifier_explanation_label, + self.backup_identifier_label, + self.backup_identifier_entry, + self.example_backup_path_label, + self.backup_example_label,] + self._backupControls2 = [self.backup_location_label, + self.backup_folder_filechooser_button, + self.backup_location_explanation_label] + self._backupControls = self._backupControls0 + self._backupControls1 + \ + self._backupControls2 + + self._backupVideoControls = [self.video_backup_identifier_label, + self.video_backup_identifier_entry] + + #assign values to checkbuttons only when other controls + #have been setup, because their toggle signal is activated + #when a value is assigned + + self.backup_checkbutton.set_active(self.prefs.backup_images) + self.auto_detect_backup_checkbutton.set_active( + self.prefs.backup_device_autodetection) + self.updateBackupControls() + self.updateBackupExample() + + def _setupAutomationTab(self): + self.auto_startup_checkbutton.set_active( + self.prefs.auto_download_at_startup) + self.auto_insertion_checkbutton.set_active( + self.prefs.auto_download_upon_device_insertion) + self.auto_unmount_checkbutton.set_active( + self.prefs.auto_unmount) + self.auto_exit_checkbutton.set_active( + self.prefs.auto_exit) + self.auto_delete_checkbutton.set_active( + self.prefs.auto_delete) + + + def _setupErrorTab(self): + if self.prefs.download_conflict_resolution == config.SKIP_DOWNLOAD: + self.skip_download_radiobutton.set_active(True) + else: + self.add_identifier_radiobutton.set_active(True) + + if self.prefs.backup_duplicate_overwrite: + self.backup_duplicate_overwrite_radiobutton.set_active(True) + else: + self.backup_duplicate_skip_radiobutton.set_active(True) + + + def updateExampleFileName(self, display_table, rename_table, sample, sampleName, example_label, fallback_date = None): + problem = pn.Problem() + if hasattr(self, display_table): + rename_table.updateExampleJobCode() + rename_table.prefsFactory.initializeProblem(problem) + name = rename_table.prefsFactory.generateNameUsingPreferences( + sample, sampleName, + self.prefs.strip_characters, sequencesPreliminary=False, fallback_date=fallback_date) + else: + name = '' + + # since this is markup, escape it + text = "<i>%s</i>" % common.escape(name) + + if problem.has_problem(): + text += "\n" + # Translators: please do not modify or leave out html formatting tags like <i> and <b>. These are used to format the text the users sees + text += _("<i><b>Warning:</b> There is insufficient metadata to fully generate the name. Please use other renaming options.</i>") + + example_label.set_markup(text) + + def updateImageRenameExample(self): + """ + Displays example image name to the user + """ + self.updateExampleFileName('rename_table', self.rename_table, self.sampleImage, self.sampleImageName, self.new_name_label) + + + def updateVideoRenameExample(self): + """ + Displays example video name to the user + """ + self.updateExampleFileName('video_rename_table', self.video_rename_table, self.sampleVideo, self.sampleVideoName, self.video_new_name_label, self.videoFallBackDate) + + def updateDownloadFolderExample(self, display_table, subfolder_table, download_folder, sample, sampleName, example_download_path_label, subfolder_warning_label, fallback_date = None): + """ + Displays example subfolder name(s) to the user + """ + + problem = pn.Problem() + if hasattr(self, display_table): + subfolder_table.updateExampleJobCode() + subfolder_table.prefsFactory.initializeProblem(problem) + path = subfolder_table.prefsFactory.generateNameUsingPreferences( + sample, sampleName, + self.prefs.strip_characters, fallback_date = fallback_date) + else: + path = '' + + text = os.path.join(download_folder, path) + # since this is markup, escape it + path = common.escape(text) + if problem.has_problem(): + warning = _("<i><b>Warning:</b> There is insufficient metadata to fully generate subfolders. Please use other subfolder naming options.</i>" ) + else: + warning = "" + # Translators: you should not modify or leave out the %s. This is a code used by the programming language python to insert a value that thes user will see + example_download_path_label.set_markup(_("<i>Example: %s</i>") % text) + subfolder_warning_label.set_markup(warning) + + def updatePhotoDownloadFolderExample(self): + if hasattr(self, 'subfolder_table'): + self.updateDownloadFolderExample('subfolder_table', self.subfolder_table, self.prefs.download_folder, self.sampleImage, self.sampleImageName, self.example_photo_download_path_label, self.photo_subfolder_warning_label) + + def updateVideoDownloadFolderExample(self): + if hasattr(self, 'video_subfolder_table'): + self.updateDownloadFolderExample('video_subfolder_table', self.video_subfolder_table, self.prefs.video_download_folder, self.sampleVideo, self.sampleVideoName, self.example_video_download_path_label, self.video_subfolder_warning_label, self.videoFallBackDate) + + def on_hour_spinbutton_value_changed(self, spinbutton): + hour = spinbutton.get_value_as_int() + minute = self.minute_spinbutton.get_value_as_int() + self.prefs.setDayStart(hour, minute) + self.on_downloads_today_entry_changed(self.downloads_today_entry) + + def on_minute_spinbutton_value_changed(self, spinbutton): + hour = self.hour_spinbutton.get_value_as_int() + minute = spinbutton.get_value_as_int() + self.prefs.setDayStart(hour, minute) + self.on_downloads_today_entry_changed(self.downloads_today_entry) + + def on_downloads_today_entry_changed(self, entry): + # do not update value if a download is occurring - it will mess it up! + if workers.noDownloadingWorkers() <> 0: + cmd_line(_("Downloads today value not updated, as a download is currently occurring")) + else: + v = entry.get_text() + try: + v = int(v) + except: + v = 0 + if v < 0: + v = 0 + self.prefs.resetDownloadsToday(v) + sequences.setDownloadsToday(v) + self.updateImageRenameExample() + + def on_stored_number_entry_changed(self, entry): + # do not update value if a download is occurring - it will mess it up! + if workers.noDownloadingWorkers() <> 0: + cmd_line(_("Stored number value not updated, as a download is currently occurring")) + else: + v = entry.get_text() + try: + # the displayed value of stored sequence no 1 more than actual value + # so as not to confuse the user + v = int(v) - 1 + except: + v = 0 + if v < 0: + v = 0 + self.prefs.stored_sequence_no = v + sequences.setStoredSequenceNo(v) + self.updateImageRenameExample() + + def _updateSubfolderPrefOnError(self, newPrefList): + self.prefs.subfolder = newPrefList + + def _updateVideoSubfolderPrefOnError(self, newPrefList): + self.prefs.video_subfolder = newPrefList + + + def checkSubfolderValuesValidOnExit(self, usersPrefList, updatePrefFunction, filetype, defaultPrefList): + """ + Checks that the user has not entered in any inappropriate values + + If they have, filters out bad values and warns the user + """ + filtered, prefList = rn.filterSubfolderPreferences(usersPrefList) + if filtered: + cmd_line(_("The %(filetype)s subfolder preferences had some unnecessary values removed.") % {'filetype': filetype}) + if prefList: + updatePrefFunction(prefList) + else: + #Preferences list is now empty + msg = _("The %(filetype)s subfolder preferences entered are invalid and cannot be used.\nThey will be reset to their default values.") % {'filetype': filetype} + sys.stderr.write(msg + "\n") + misc.run_dialog(PROGRAM_NAME, msg) + updatePrefFunction(self.prefs.get_default(defaultPrefList)) + + def on_response(self, dialog, arg): + if arg == gtk.RESPONSE_HELP: + webbrowser.open("http://www.damonlynch.net/rapid/documentation") + else: + # arg==gtk.RESPONSE_CLOSE, or the user hit the 'x' to close the window + self.prefs.backup_identifier = self.backup_identifier_entry.get_property("text") + self.prefs.video_backup_identifier = self.video_backup_identifier_entry.get_property("text") + + #check subfolder preferences for bad values + self.checkSubfolderValuesValidOnExit(self.prefs.subfolder, self._updateSubfolderPrefOnError, _("photo"), "subfolder") + self.checkSubfolderValuesValidOnExit(self.prefs.video_subfolder, self._updateVideoSubfolderPrefOnError, _("video"), "video_subfolder") + + self.widget.destroy() + self.parentApp.preferencesDialogDisplayed = False + self.parentApp.postPreferenceChange() + + + + + def on_add_job_code_button_clicked(self, button): + j = JobCodeDialog(self.widget, self.prefs.job_codes, None, self.add_job_code, False, True, True) + + + def add_job_code(self, dialog, userChoseCode, job_code, autoStart, downloadSelected): + dialog.destroy() + if userChoseCode: + if job_code and job_code not in self.prefs.job_codes: + self.job_code_liststore.prepend((job_code, )) + self.update_job_codes() + selection = self.job_code_treeview.get_selection() + selection.unselect_all() + selection.select_path((0, )) + #scroll to the top + adjustment = self.job_code_scrolledwindow.get_vadjustment() + adjustment.set_value(adjustment.lower) + + def on_remove_job_code_button_clicked(self, button): + """ remove selected job codes (can be multiple selection)""" + selection = self.job_code_treeview.get_selection() + model, selected = selection.get_selected_rows() + iters = [model.get_iter(path) for path in selected] + # only delete if a jobe code is selected + if iters: + no = len(iters) + path = None + for i in range(0, no): + iter = iters[i] + if i == no - 1: + path = model.get_path(iter) + model.remove(iter) + + # now that we removed the selection, play nice with + # the user and select the next item + selection.select_path(path) + + # if there was no selection that meant the user + # removed the last entry, so we try to select the + # last item + if not selection.path_is_selected(path): + row = path[0]-1 + # test case for empty lists + if row >= 0: + selection.select_path((row,)) + + self.update_job_codes() + self.updateImageRenameExample() + self.updateVideoRenameExample() + self.updatePhotoDownloadFolderExample() + self.updateVideoDownloadFolderExample() + + def on_remove_all_job_code_button_clicked(self, button): + j = RemoveAllJobCodeDialog(self.widget, self.remove_all_job_code) + + def remove_all_job_code(self, dialog, userSelected): + dialog.destroy() + if userSelected: + self.job_code_liststore.clear() + self.update_job_codes() + self.updateImageRenameExample() + self.updateVideoRenameExample() + self.updatePhotoDownloadFolderExample() + self.updateVideoDownloadFolderExample() + + def on_job_code_edited(self, widget, path, new_text): + iter = self.job_code_liststore.get_iter(path) + self.job_code_liststore.set_value(iter, 0, new_text) + self.update_job_codes() + self.updateImageRenameExample() + self.updateVideoRenameExample() + self.updatePhotoDownloadFolderExample() + self.updateVideoDownloadFolderExample() + + def update_job_codes(self): + """ update preferences with list of job codes""" + job_codes = [] + for row in self.job_code_liststore: + job_codes.append(row[0]) + self.prefs.job_codes = job_codes + + def on_auto_startup_checkbutton_toggled(self, checkbutton): + self.prefs.auto_download_at_startup = checkbutton.get_active() + + def on_auto_insertion_checkbutton_toggled(self, checkbutton): + self.prefs.auto_download_upon_device_insertion = checkbutton.get_active() + + def on_auto_unmount_checkbutton_toggled(self, checkbutton): + self.prefs.auto_unmount = checkbutton.get_active() + + + def on_auto_delete_checkbutton_toggled(self, checkbutton): + self.prefs.auto_delete = checkbutton.get_active() + + def on_auto_exit_checkbutton_toggled(self, checkbutton): + self.prefs.auto_exit = checkbutton.get_active() + + def on_autodetect_device_checkbutton_toggled(self, checkbutton): + self.prefs.device_autodetection = checkbutton.get_active() + self.updateDeviceControls() + + def on_autodetect_psd_checkbutton_toggled(self, checkbutton): + self.prefs.device_autodetection_psd = checkbutton.get_active() + + def on_backup_duplicate_overwrite_radiobutton_toggled(self, widget): + self.prefs.backup_duplicate_overwrite = widget.get_active() + + def on_backup_duplicate_skip_radiobutton_toggled(self, widget): + self.prefs.backup_duplicate_overwrite = not widget.get_active() + + def on_treeview_cursor_changed(self, tree): + path, column = tree.get_cursor() + self.notebook.set_current_page(path[0]) + + def on_synchronize_raw_jpg_checkbutton_toggled(self, check_button): + self.prefs.synchronize_raw_jpg = check_button.get_active() + + def on_strip_characters_checkbutton_toggled(self, check_button): + self.prefs.strip_characters = check_button.get_active() + self.updateImageRenameExample() + self.updatePhotoDownloadFolderExample() + self.updateVideoDownloadFolderExample() + + def on_add_identifier_radiobutton_toggled(self, widget): + if widget.get_active(): + self.prefs.download_conflict_resolution = config.ADD_UNIQUE_IDENTIFIER + else: + self.prefs.download_conflict_resolution = config.SKIP_DOWNLOAD + + + def updateDeviceControls(self): + """ + Sets sensitivity of image device controls + """ + controls = [self.device_location_explanation_label, + self.device_location_label, + self.device_location_filechooser_button] + + if self.prefs.device_autodetection: + for c in controls: + c.set_sensitive(False) + self.autodetect_psd_checkbutton.set_sensitive(True) + self.autodetect_image_devices_label.set_sensitive(True) + else: + for c in controls: + c.set_sensitive(True) + self.autodetect_psd_checkbutton.set_sensitive(False) + self.autodetect_image_devices_label.set_sensitive(False) + + def updateBackupControls(self): + """ + Sets sensitivity of backup related widgets + """ + + if not self.backup_checkbutton.get_active(): + for c in self._backupControls + self._backupVideoControls: + c.set_sensitive(False) + + else: + for c in self._backupControls0: + c.set_sensitive(True) + self.updateBackupControlsAuto() + + def updateBackupControlsAuto(self): + """ + Sets sensitivity of subset of backup related widgets + """ + + if self.auto_detect_backup_checkbutton.get_active(): + for c in self._backupControls1: + c.set_sensitive(True) + for c in self._backupControls2: + c.set_sensitive(False) + for c in self._backupVideoControls: + c.set_sensitive(False) + if DOWNLOAD_VIDEO: + for c in self._backupVideoControls: + c.set_sensitive(True) + else: + for c in self._backupControls1: + c.set_sensitive(False) + for c in self._backupControls2: + c.set_sensitive(True) + if DOWNLOAD_VIDEO: + for c in self._backupVideoControls: + c.set_sensitive(False) + + def disableVideoControls(self): + """ + Disables video preferences if video downloading is disabled + (probably because the appropriate libraries to enable + video metadata extraction are not installed) + """ + controls = [self.example_video_filename_label, + self.original_video_filename_label, + self.new_video_filename_label, + self.video_new_name_label, + self.video_original_name_label, + self.video_rename_scrolledwindow, + self.video_folders_hbox, + self.video_backup_identifier_label, + self.video_backup_identifier_entry + ] + for c in controls: + c.set_sensitive(False) + + self.videos_cannot_be_downloaded_label.show() + self.folder_videos_cannot_be_downloaded_label.show() + self.folder_videos_cannot_be_downloaded_hbox.show() + + def on_auto_detect_backup_checkbutton_toggled(self, widget): + self.prefs.backup_device_autodetection = widget.get_active() + self.updateBackupControlsAuto() + + def on_backup_checkbutton_toggled(self, widget): + self.prefs.backup_images = self.backup_checkbutton.get_active() + self.updateBackupControls() + + def on_backup_identifier_entry_changed(self, widget): + self.updateBackupExample() + + def on_video_backup_identifier_entry_changed(self, widget): + self.updateBackupExample() + + def on_backup_scan_folder_on_entry_changed(self, widget): + self.updateBackupExample() + + def updateBackupExample(self): + # Translators: this value is used as an example device when automatic backup device detection is enabled. You should translate this. + drive1 = os.path.join(config.MEDIA_LOCATION, _("externaldrive1")) + # Translators: this value is used as an example device when automatic backup device detection is enabled. You should translate this. + drive2 = os.path.join(config.MEDIA_LOCATION, _("externaldrive2")) + + path = os.path.join(drive1, self.backup_identifier_entry.get_text()) + path2 = os.path.join(drive2, self.backup_identifier_entry.get_text()) + path3 = os.path.join(drive2, self.video_backup_identifier_entry.get_text()) + path = common.escape(path) + path2 = common.escape(path2) + path3 = common.escape(path3) + if DOWNLOAD_VIDEO: + example = "<i>%s</i>\n<i>%s</i>\n<i>%s</i>" % (path, path2, path3) + else: + example = "<i>%s</i>\n<i>%s</i>" % (path, path2) + self.example_backup_path_label.set_markup(example) + + +def file_types_by_number(noImages, noVideos): + """ + returns a string to be displayed to the user that can be used + to show if a value refers to photos or videos or both, or just one + of each + """ + if (noVideos > 0) and (noImages > 0): + v = _('photos and videos') + elif (noVideos == 0) and (noImages == 0): + v = _('photos or videos') + elif noVideos > 0: + if noVideos > 1: + v = _('videos') + else: + v = _('video') + else: + if noImages > 1: + v = _('photos') + else: + v = _('photo') + return v + def date_time_human_readable(date, with_line_break=True): if with_line_break: return _("%(date)s\n%(time)s") % {'date':date.strftime("%x"), 'time':date.strftime("%X")} else: return _("%(date)s %(time)s") % {'date':date.strftime("%x"), 'time':date.strftime("%X")} +def time_subseconds_human_readable(date, subseconds): + return _("%(hour)s:%(minute)s:%(second)s:%(subsecond)s") % \ + {'hour':date.strftime("%H"), + 'minute':date.strftime("%M"), + 'second':date.strftime("%S"), + 'subsecond': subseconds} + def date_time_subseconds_human_readable(date, subseconds): return _("%(date)s %(hour)s:%(minute)s:%(second)s:%(subsecond)s") % \ {'date':date.strftime("%x"), @@ -117,19 +1588,1498 @@ def date_time_subseconds_human_readable(date, subseconds): 'second':date.strftime("%S"), 'subsecond': subseconds} +def generateSubfolderAndName(mediaFile, problem, subfolderPrefsFactory, + renamePrefsFactory, + nameUsesJobCode, subfolderUsesJobCode, + strip_characters, fallback_date): + + subfolderPrefsFactory.initializeProblem(problem) + mediaFile.sampleSubfolder = subfolderPrefsFactory.generateNameUsingPreferences( + mediaFile.metadata, mediaFile.name, + strip_characters, + fallback_date = fallback_date) + + mediaFile.samplePath = os.path.join(mediaFile.downloadFolder, mediaFile.sampleSubfolder) + + renamePrefsFactory.initializeProblem(problem) + mediaFile.sampleName = renamePrefsFactory.generateNameUsingPreferences( + mediaFile.metadata, mediaFile.name, strip_characters, + sequencesPreliminary=False, + fallback_date = fallback_date) + + if not (mediaFile.sampleName or nameUsesJobCode) or not (mediaFile.sampleSubfolder or subfolderUsesJobCode): + if not (mediaFile.sampleName or nameUsesJobCode) and not (mediaFile.sampleSubfolder or subfolderUsesJobCode): + area = _("subfolder and filename") + elif not (mediaFile.sampleName or nameUsesJobCode): + area = _("filename") + else: + area = _("subfolder") + problem.add_problem(None, pn.ERROR_IN_NAME_GENERATION, {'filetype': mediaFile.displayNameCap, 'area': area}) + problem.add_extra_detail(pn.NO_DATA_TO_NAME, {'filetype': area}) + mediaFile.problem = problem + mediaFile.status = STATUS_CANNOT_DOWNLOAD + elif problem.has_problem(): + mediaFile.problem = problem + mediaFile.status = STATUS_WARNING + else: + mediaFile.status = STATUS_NOT_DOWNLOADED + +def getGenericPhotoImage(): + return gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/photo.png')) + +def getGenericVideoImage(): + return gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/video.png')) -class DeviceCollection(gtk.TreeView): +class NeedAJobCode(): """ - TreeView display of devices and how many files have been copied, shown - immediately under the menu in the main application window. + Convenience class to check whether a job code is missing for a given + file type (photo or video) """ - def __init__(self, parent_app): + def __init__(self, prefs): + self.imageRenameUsesJobCode = rn.usesJobCode(prefs.image_rename) + self.imageSubfolderUsesJobCode = rn.usesJobCode(prefs.subfolder) + self.videoRenameUsesJobCode = rn.usesJobCode(prefs.video_rename) + self.videoSubfolderUsesJobCode = rn.usesJobCode(prefs.video_subfolder) + + def needAJobCode(self, job_code, is_image): + if is_image: + return not job_code and (self.imageRenameUsesJobCode or self.imageSubfolderUsesJobCode) + else: + return not job_code and (self.videoRenameUsesJobCode or self.videoSubfolderUsesJobCode) + + +class CopyPhotos(Thread): + """Copies photos from source to destination, backing up if needed""" + def __init__(self, thread_id, parentApp, fileRenameLock, fileSequenceLock, + statsLock, downloadedFilesLock, + downloadStats, autoStart = False, cardMedia = None): + self.parentApp = parentApp + self.thread_id = thread_id + self.ctrl = True + self.running = False + self.manuallyDisabled = False + # enable the capacity to block oneself with a lock + # the lock will be first set when the thread begins + # it will then be locked when the thread needs to be paused + # releasing it will cause the code to restart from where it + # left off + self.lock = Lock() + + self.fileRenameLock = fileRenameLock + self.fileSequenceLock = fileSequenceLock + self.statsLock = statsLock + self.downloadedFilesLock = downloadedFilesLock + + self.downloadStats = downloadStats + + self.hasStarted = False + self.doNotStart = False + self.waitingForJobCode = False + + self.autoStart = autoStart + self.cardMedia = cardMedia + + self.initializeDisplay(thread_id, self.cardMedia) + + self.scanComplete = self.downloadStarted = self.downloadComplete = False + + # Need to account for situations where the user adjusts their preferences when the program is scanning + # Here the sample filenames and paths will be out of date, and they will need to be updated + # This flag indicates whether that is the case or not + self.scanResultsStale = False # name and subfolder + self.scanResultsStaleDownloadFolder = False #download folder only + + self.noErrors = self.noWarnings = 0 + self.videoTempWorkingDir = self.photoTempWorkingDir = '' + + if DOWNLOAD_VIDEO: + self.types_searched_for = _('photos or videos') + else: + self.types_searched_for = _('photos') + + Thread.__init__(self) + - self.parent_app = parent_app - # device icon & name, size of images on the device (human readable), - # copy progress (%), copy text - self.liststore = gtk.ListStore(gtk.gdk.Pixbuf, str, str, float, str) - self.map_process_to_row = {} + def initializeDisplay(self, thread_id, cardMedia = None): + + if self.cardMedia: + media_collection_treeview.addCard(thread_id, self.cardMedia.prettyName(), + '', progress=0.0, + # This refers to when a device like a hard drive is having its contents scanned, + # looking for photos or videos. It is visible initially in the progress bar for each device + # (which normally holds "x photos and videos"). + # It maybe displayed only briefly if the contents of the device being scanned is small. + progressBarText=_('scanning...')) + + def firstImage(self): + """ + returns class mediaFile of the first photo + """ + mediaFile = self.cardMedia.firstImage() + return mediaFile + + def firstVideo(self): + """ + returns class mediaFile of the first video + """ + mediaFile = self.cardMedia.firstVideo() + return mediaFile + + def handlePreferencesError(self, e, prefsFactory): + sys.stderr.write(_("Sorry,these preferences contain an error:\n")) + sys.stderr.write(prefsFactory.formatPreferencesForPrettyPrint() + "\n") + msg = str(e) + sys.stderr.write(msg + "\n") + + def initializeFromPrefs(self, notifyOnError): + """ + Setup thread so that user preferences are handled + """ + + def checkPrefs(prefsFactory): + try: + prefsFactory.checkPrefsForValidity() + except (rn.PrefValueInvalidError, rn.PrefLengthError, + rn.PrefValueKeyComboError, rn.PrefKeyError), e: + if notifyOnError: + self.handlePreferencesError(e, prefsFactory) + raise rn.PrefError + + self.prefs = self.parentApp.prefs + + #Image and Video filename preferences + sample_download_start_time = datetime.datetime.now() + + self.imageRenamePrefsFactory = rn.ImageRenamePreferences(self.prefs.image_rename, self, + self.fileSequenceLock, sequences) + self.imageRenamePrefsFactory.setDownloadStartTime(sample_download_start_time) + checkPrefs(self.imageRenamePrefsFactory) + + self.videoRenamePrefsFactory = rn.VideoRenamePreferences(self.prefs.video_rename, self, + self.fileSequenceLock, sequences) + self.videoRenamePrefsFactory.setDownloadStartTime(sample_download_start_time) + checkPrefs(self.videoRenamePrefsFactory) + + #Image and Video subfolder preferences + + self.subfolderPrefsFactory = rn.SubfolderPreferences(self.prefs.subfolder, self) + self.subfolderPrefsFactory.setDownloadStartTime(sample_download_start_time) + checkPrefs(self.subfolderPrefsFactory) + + self.videoSubfolderPrefsFactory = rn.VideoSubfolderPreferences(self.prefs.video_subfolder, self) + self.videoSubfolderPrefsFactory.setDownloadStartTime(sample_download_start_time) + checkPrefs(self.videoSubfolderPrefsFactory) + + # copy this variable, as it is used heavily in the loop + # and it is perhaps relatively expensive to read + self.stripCharacters = self.prefs.strip_characters + + def run(self): + """ + Copy photos from device to local drive, and if requested, backup + + 1. Should the image be downloaded? + 1.a generate file name + 1.a.1 generate sequence numbers if needed + 1.a.2 FIFO queue sequence numbers to indicate that they could + potentially be used in a filename + 1.b check to see if a file exists with the same name in the place it will + be downloaded to + 1.c if it exisits, and unique identifiers are not being used: + 1.b.1 if using sequence numbers or letters, then potentially any of the + sequence numbers in the queue could be used to make the filename + 1.b.1.a generate and check each filename using sequence numbers in the queue + 1.b.1.b if one of these filenames is unique, then image needs to be downloaded + 1.b.2 do not do not download + + + 2. Download the image + 2.a copy it to temporary folder (this takes time) + 2.b is the file name still unique? Perhaps a new file was created with this name in the meantime + (either by another thread or another program) + 2.b.1 don't allow any other thread to rename a file + 2.b.2 check file name + 2.b.3 adding suffix if it is not unique, being careful not to overwrite any existing file with a suffix + 2.b.4 rename it to the "real" name, effectively performing a mv + 2.b.5 allow other threads to rename files + + 3. Backup the image, using the same filename as was used when it was downloaded + 3.a does a file with the same name already exist on the backup medium? + 3.b if so, user preferences determine whether it should be overwritten or not + """ + + def checkDownloadPath(path): + """ + Checks to see if download folder exists. + + Creates it if it does not exist. + + Returns False if the path could not be created. + """ + + try: + if not os.path.isdir(path): + os.makedirs(path) + return True + + except: + display_queue.put((media_collection_treeview.removeCard, (self.thread_id, ))) + msg = _("The following download path could not be created:\n") + msg += _("%(path)s: ") % {'path': path} + logError(config.CRITICAL_ERROR, _("Download cannot proceed"), msg) + cmd_line(_("Download cannot proceed")) + cmd_line(msg) + display_queue.put((self.parentApp.downloadFailed, (self.thread_id, ))) + display_queue.close("rw") + return False + + def getPrefs(notifyOnError): + try: + self.initializeFromPrefs(notifyOnError) + return True + except rn.PrefError: + if notifyOnError: + display_queue.put((media_collection_treeview.removeCard, (self.thread_id, ))) + msg = _("There is an error in the program preferences.") + msg += _("\nPlease check preferences, restart the program, and try again.") + logError(config.CRITICAL_ERROR, _("Download cannot proceed"), msg) + cmd_line(_("Download cannot proceed")) + cmd_line(msg) + display_queue.put((self.parentApp.downloadFailed, (self.thread_id, ))) + display_queue.close("rw") + return False + + + + def scanMedia(): + """ + Scans media for photos and videos + """ + + # load images to display for when a thumbnail cannot be extracted or created + + self.photoThumbnail = getGenericPhotoImage() + self.videoThumbnail = getGenericVideoImage() + + imageRenameUsesJobCode = rn.usesJobCode(self.prefs.image_rename) + imageSubfolderUsesJobCode = rn.usesJobCode(self.prefs.subfolder) + videoRenameUsesJobCode = rn.usesJobCode(self.prefs.video_rename) + videoSubfolderUsesJobCode = rn.usesJobCode(self.prefs.video_subfolder) + + def loadFileMetadata(mediaFile): + """ + loads the metadate for the file, and additional information if required + """ + + problem = pn.Problem() + try: + mediaFile.loadMetadata() + except: + mediaFile.status = STATUS_CANNOT_DOWNLOAD + mediaFile.metadata = None + problem.add_problem(None, pn.CANNOT_DOWNLOAD_BAD_METADATA, {'filetype': mediaFile.displayNameCap}) + mediaFile.problem = problem + else: + # generate sample filename and subfolder + if mediaFile.isImage: + fallback_date = None + subfolderPrefsFactory = self.subfolderPrefsFactory + renamePrefsFactory = self.imageRenamePrefsFactory + nameUsesJobCode = imageRenameUsesJobCode + subfolderUsesJobCode = imageSubfolderUsesJobCode + else: + fallback_date = mediaFile.modificationTime + subfolderPrefsFactory = self.videoSubfolderPrefsFactory + renamePrefsFactory = self.videoRenamePrefsFactory + nameUsesJobCode = videoRenameUsesJobCode + subfolderUsesJobCode = videoSubfolderUsesJobCode + + generateSubfolderAndName(mediaFile, problem, subfolderPrefsFactory, renamePrefsFactory, + nameUsesJobCode, subfolderUsesJobCode, + self.prefs.strip_characters, fallback_date) + # generate thumbnail + mediaFile.generateThumbnail(self.videoTempWorkingDir) + + if mediaFile.thumbnail is None: + addGenericThumbnail(mediaFile) + + + def addGenericThumbnail(mediaFile): + """ + Adds a generic thumbnail to the mediafile, which + can be very useful when previews are disabled + """ + mediaFile.genericThumbnail = True + if mediaFile.isImage: + mediaFile.thumbnail = self.photoThumbnail + else: + mediaFile.thumbnail = self.videoThumbnail + + def downloadable(name): + isImage = media.isImage(name) + isVideo = media.isVideo(name) + download = (DOWNLOAD_VIDEO and (isImage or isVideo) or + ((not DOWNLOAD_VIDEO) and isImage)) + return (download, isImage, isVideo) + + def addFile(name, path, size, modificationTime, device, volume, isImage): + """ + Add an image or video to the list of scanned files to be shown to the user for potential downloading + """ + + if isImage: + downloadFolder = self.prefs.download_folder + else: + downloadFolder = self.prefs.video_download_folder + + mediaFile = media.MediaFile(self.thread_id, name, path, size, modificationTime, device, downloadFolder, volume, isImage) + loadFileMetadata(mediaFile) + # modificationTime is very useful for quick sorting + imagesAndVideos.append((mediaFile, modificationTime)) + display_queue.put((self.parentApp.addFile, (mediaFile,))) + + if isImage: + self.noImages += 1 + else: + self.noVideos += 1 + + + def gio_scan(path, fileSizeSum): + """recursive function to scan a directory and its subdirectories + for photos and possibly videos""" + + children = path.enumerate_children('standard::name,standard::type,standard::size,time::modified') + + for child in children: + if not self.running: + self.lock.acquire() + self.running = True + + if not self.ctrl: + return None + + if child.get_file_type() == gio.FILE_TYPE_DIRECTORY: + fileSizeSum = gio_scan(path.get_child(child.get_name()), fileSizeSum) + if fileSizeSum == None: + # this value will be None only if the thread is exiting + return None + elif child.get_file_type() == gio.FILE_TYPE_REGULAR: + name = child.get_name() + download, isImage, isVideo = downloadable(name) + if download: + size = child.get_size() + modificationTime = child.get_modification_time() + addFile(name, path.get_path(), size, modificationTime, self.cardMedia.prettyName(limit=0), self.cardMedia.volume, isImage) + fileSizeSum += size + + return fileSizeSum + + + imagesAndVideos = [] + fileSizeSum = 0 + self.noVideos = 0 + self.noImages = 0 + + if not using_gio or not self.cardMedia.volume: + for root, dirs, files in os.walk(self.cardMedia.getPath()): + for name in files: + if not self.running: + self.lock.acquire() + self.running = True + + if not self.ctrl: + return None + + + download, isImage, isVideo = downloadable(name) + if download: + fullFileName = os.path.join(root, name) + size = os.path.getsize(fullFileName) + modificationTime = os.path.getmtime(fullFileName) + addFile(name, root, size, modificationTime, self.cardMedia.prettyName(limit=0), self.cardMedia.volume, isImage) + fileSizeSum += size + + + else: + # using gio and have a volume + # make call to recursive function to scan volume + fileSizeSum = gio_scan(self.cardMedia.volume.volume.get_root(), fileSizeSum) + if fileSizeSum == None: + # thread exiting + return None + + # sort in place based on modification time + imagesAndVideos.sort(key=operator.itemgetter(1)) + noFiles = len(imagesAndVideos) + + self.scanComplete = True + + self.display_file_types = file_types_by_number(self.noImages, self.noVideos) + + + if noFiles: + self.cardMedia.setMedia(imagesAndVideos, fileSizeSum, noFiles) + # Translators: as already, mentioned the %s value should not be modified or left out. It may be moved if necessary. + # It refers to the actual number of photos that can be copied. For example, the user might see the following: + # '0 of 512 photos' or '0 of 10 videos' or '0 of 202 photos and videos'. + # This particular text is displayed to the user before the download has started. + display = _("%(number)s %(filetypes)s") % {'number':noFiles, 'filetypes':self.display_file_types} + display_queue.put((media_collection_treeview.updateCard, (self.thread_id, self.cardMedia.sizeOfImagesAndVideos()))) + display_queue.put((media_collection_treeview.updateProgress, (self.thread_id, 0.0, display, 0))) + display_queue.put((self.parentApp.setDownloadButtonSensitivity, ())) + + # Translators: as you have already seen, the text can contain values that should not be modified or left out by you, for example %s. + # This text is another example of that, but it is is a little more complex. Here there are two values which will be displayed + # to the user when they run the program, signifying the number of photos found, and the device they were found on. + # %(number)s should be left exactly as is: 'number' should not be translated. The same applies to %(device)s: 'device' should + # not be translated. Generally speaking, if translating the sentence requires it, you can move items like '%(xyz)s' around + # in a sentence, but you should never modify them or leave them out. + cmd_line(_("Device scan complete: found %(number)s %(filetypes)s on %(device)s") % + {'number': noFiles, 'filetypes':self.display_file_types, + 'device': self.cardMedia.prettyName(limit=0)}) + return True + else: + # it might be better to display "0 of 0" here + display_queue.put((media_collection_treeview.removeCard, (self.thread_id, ))) + cmd_line(_("Device scan complete: no %(filetypes)s found on %(device)s") % {'device':self.cardMedia.prettyName(limit=0), 'filetypes':self.types_searched_for}) + return False + + + def logError(severity, problem, details, resolution=None): + display_queue.put((log_dialog.addMessage, (self.thread_id, severity, problem, details, + resolution))) + if severity == config.WARNING: + self.noWarnings += 1 + else: + self.noErrors += 1 + + def notifyAndUnmount(umountAttemptOK): + if not self.cardMedia.volume: + unmountMessage = "" + notificationName = PROGRAM_NAME + else: + notificationName = self.cardMedia.volume.get_name() + if self.prefs.auto_unmount and umountAttemptOK: + self.cardMedia.volume.unmount(self.on_volume_unmount) + # This message informs the user that the device (e.g. camera, hard drive or memory card) was automatically unmounted and they can now remove it + unmountMessage = _("The device can now be safely removed") + else: + unmountMessage = "" + + file_types = file_types_by_number(noImagesDownloaded, noVideosDownloaded) + file_types_skipped = file_types_by_number(noImagesSkipped, noVideosSkipped) + message = _("%(noFiles)s %(filetypes)s downloaded") % {'noFiles':noFilesDownloaded, 'filetypes': file_types} + noFilesSkipped = noImagesSkipped + noVideosSkipped + if noFilesSkipped: + message += "\n" + _("%(noFiles)s %(filetypes)s failed to download") % {'noFiles':noFilesSkipped, 'filetypes':file_types_skipped} + + if self.noWarnings: + message = "%s\n%s " % (message, self.noWarnings) + _("warnings") + if self.noErrors: + message = "%s\n%s " % (message, self.noErrors) + _("errors") + + if unmountMessage: + message = "%s\n%s" % (message, unmountMessage) + + n = pynotify.Notification(notificationName, message) + + if self.cardMedia.volume: + icon = self.cardMedia.volume.get_icon_pixbuf(self.parentApp.notification_icon_size) + else: + icon = self.parentApp.application_icon + + n.set_icon_from_pixbuf(icon) + n.show() + + def createTempDir(baseDir): + """ + Create a temporary directory in which to download the photos to. + + Returns the directory if it was created, else returns None. + + Don't want to put it in system temp folder, as that is likely + to be on another partition and hence copying files from it + to the actual download folder will be slow!""" + try: + t = tempfile.mkdtemp(prefix='rapid-tmp-', + dir=baseDir) + return t + except OSError, (errno, strerror): + if not self.cardMedia.volume: + image_device = _("Source: %s\n") % self.cardMedia.getPath() + else: + _("Device: %s\n") % self.cardMedia.volume.get_name() + destination = _("Destination: %s") % baseDir + logError(config.CRITICAL_ERROR, _('Could not create temporary download directory'), + image_device + destination, + _("Download cannot proceed")) + cmd_line(_("Error:") + " " + _('Could not create temporary download directory')) + cmd_line(image_device + destination) + cmd_line(_("Download cannot proceed")) + display_queue.put((media_collection_treeview.removeCard, (self.thread_id, ))) + display_queue.put((self.parentApp.downloadFailed, (self.thread_id, ))) + display_queue.close("rw") + self.running = False + self.lock.release() + return None + + def setupBackup(): + """ + Check for presence of backup path or volumes, and return the number of devices being used (1 in case of a path) + """ + no_devices = 0 + if self.prefs.backup_images: + no_devices = len(self.parentApp.backupVolumes) + if not self.prefs.backup_device_autodetection: + if not os.path.isdir(self.prefs.backup_location): + # the user has manually specified a path, but it + # does not exist. This is a problem. + try: + os.makedirs(self.prefs.backup_location) + except: + logError(config.SERIOUS_ERROR, _("Backup path does not exist"), + _("The path %s could not be created") % path, + _("No backups can occur") + ) + no_devices = 0 + return no_devices + + def checkIfNeedAJobCode(): + needAJobCode = NeedAJobCode(self.prefs) + + for f in self.cardMedia.imagesAndVideos: + mediaFile = f[0] + if mediaFile.status in [STATUS_WARNING, STATUS_NOT_DOWNLOADED]: + if needAJobCode.needAJobCode(mediaFile.jobcode, mediaFile.isImage): + return True + return False + + def createBothTempDirs(): + self.photoTempWorkingDir = createTempDir(photoBaseDownloadDir) + created = self.photoTempWorkingDir is not None + if created and DOWNLOAD_VIDEO: + self.videoTempWorkingDir = createTempDir(videoBaseDownloadDir) + created = self.videoTempWorkingDir is not None + + return created + + + def checkProblemWithNameGeneration(mediaFile): + if mediaFile.problem.has_problem(): + logError(config.WARNING, + mediaFile.problem.get_title(), + _("Source: %(source)s\nDestination: %(destination)s\n%(problem)s") % + {'source': mediaFile.fullFileName, 'destination': mediaFile.downloadFullFileName, 'problem': mediaFile.problem.get_problems()}) + mediaFile.status = STATUS_DOWNLOADED_WITH_WARNING + + def fileAlreadyExists(mediaFile, identifier=None): + """ Notify the user that the photo or video could not be downloaded because it already exists""" + + # get information on when the existing file was last modified + try: + modificationTime = os.path.getmtime(mediaFile.downloadFullFileName) + dt = datetime.datetime.fromtimestamp(modificationTime) + date = dt.strftime("%x") + time = dt.strftime("%X") + except: + sys.stderr.write("WARNING: could not determine the file modification time of an existing file\n") + date = time = '' + + if not identifier: + mediaFile.problem.add_problem(None, pn.FILE_ALREADY_EXISTS_NO_DOWNLOAD, {'filetype':mediaFile.displayNameCap}) + mediaFile.problem.add_extra_detail(pn.EXISTING_FILE, {'filetype': mediaFile.displayName, 'date': date, 'time': time}) + mediaFile.status = STATUS_DOWNLOAD_FAILED + log_status = config.SERIOUS_ERROR + problem_text = pn.extra_detail_definitions[pn.EXISTING_FILE] % {'date':date, 'time':time, 'filetype': mediaFile.displayName} + else: + mediaFile.problem.add_problem(None, pn.UNIQUE_IDENTIFIER_ADDED, {'filetype':mediaFile.displayNameCap}) + mediaFile.problem.add_extra_detail(pn.UNIQUE_IDENTIFIER, {'identifier': identifier, 'filetype': mediaFile.displayName, 'date': date, 'time': time}) + mediaFile.status = STATUS_DOWNLOADED_WITH_WARNING + log_status = config.WARNING + problem_text = pn.extra_detail_definitions[pn.UNIQUE_IDENTIFIER] % {'identifier': identifier, 'filetype': mediaFile.displayName, 'date': date, 'time': time} + + logError(log_status, mediaFile.problem.get_title(), + _("Source: %(source)s\nDestination: %(destination)s") + % {'source': mediaFile.fullFileName, 'destination': mediaFile.downloadFullFileName}, + problem_text) + + def downloadCopyingError(mediaFile, inst=None, errno=None, strerror=None): + """Notify the user that an error occurred (most likely at the OS / filesystem level) when coyping a photo or video""" + + if errno != None and strerror != None: + mediaFile.problem.add_problem(None, pn.DOWNLOAD_COPYING_ERROR_W_NO, {'filetype': mediaFile.displayName}) + mediaFile.problem.add_extra_detail(pn.DOWNLOAD_COPYING_ERROR_W_NO_DETAIL, {'errorno': errno, 'strerror': strerror}) + + else: + mediaFile.problem.add_problem(None, pn.DOWNLOAD_COPYING_ERROR, {'filetype': mediaFile.displayName}) + if not inst: + # hopefully inst will never be None, but just to be safe... + inst = _("Please check your system and try again.") + mediaFile.problem.add_extra_detail(pn.DOWNLOAD_COPYING_ERROR_DETAIL, inst) + + logError(config.SERIOUS_ERROR, mediaFile.problem.get_title(), mediaFile.problem.get_problems()) + mediaFile.status = STATUS_DOWNLOAD_FAILED + + def sameNameDifferentExif(image_name, mediaFile): + """Notify the user that a file was already downloaded with the same name, but the exif information was different""" + i1_ext, i1_date_time, i1_subseconds = downloaded_files.extExifDateTime(image_name) + detail = {'image1': "%s%s" % (image_name, i1_ext), + 'image1_date': i1_date_time.strftime("%x"), + 'image1_time': time_subseconds_human_readable(i1_date_time, i1_subseconds), + 'image2': mediaFile.name, + 'image2_date': mediaFile.metadata.dateTime().strftime("%x"), + 'image2_time': time_subseconds_human_readable( + mediaFile.metadata.dateTime(), + mediaFile.metadata.subSeconds())} + mediaFile.problem.add_problem(None, pn.SAME_FILE_DIFFERENT_EXIF, detail) + + msg = pn.problem_definitions[pn.SAME_FILE_DIFFERENT_EXIF][1] % detail + logError(config.WARNING,_('Photos detected with the same filenames, but taken at different times'), msg) + mediaFile.status = STATUS_DOWNLOADED_WITH_WARNING + + def generateSubfolderAndFileName(mediaFile): + """ + Generates subfolder and file names for photos and videos + """ + + skipFile = alreadyDownloaded = False + sequence_to_use = None + + if mediaFile.isVideo: + fileRenameFactory = self.videoRenamePrefsFactory + subfolderFactory = self.videoSubfolderPrefsFactory + else: + # file is an photo + fileRenameFactory = self.imageRenamePrefsFactory + subfolderFactory = self.subfolderPrefsFactory + + fileRenameFactory.setJobCode(mediaFile.jobcode) + subfolderFactory.setJobCode(mediaFile.jobcode) + + mediaFile.problem = pn.Problem() + subfolderFactory.initializeProblem(mediaFile.problem) + fileRenameFactory.initializeProblem(mediaFile.problem) + + # Here we cannot assume that the subfolder value will contain something -- the user may have changed the preferences after the scan + mediaFile.downloadSubfolder = subfolderFactory.generateNameUsingPreferences( + mediaFile.metadata, mediaFile.name, + self.stripCharacters, fallback_date = mediaFile.modificationTime) + + + if self.prefs.synchronize_raw_jpg and usesImageSequenceElements and mediaFile.isImage: + #synchronizing RAW and JPEG only applies to photos, not videos + image_name, image_ext = os.path.splitext(mediaFile.name) + with self.downloadedFilesLock: + i, sequence_to_use = downloaded_files.matching_pair(image_name, image_ext, mediaFile.metadata.dateTime(), mediaFile.metadata.subSeconds()) + if i == -1: + # this exact file has already been downloaded (same extension, same filename, and same exif date time subsecond info) + if not addUniqueIdentifier: + logError(config.SERIOUS_ERROR,_('Photo has already been downloaded'), + _("Source: %(source)s") % {'source': mediaFile.fullFileName}) + mediaFile.problem.add_problem(None, pn.FILE_ALREADY_DOWNLOADED, {'filetype': mediaFile.displayNameCap}) + skipFile = True + + + # pass the subfolder the image will go into, as this is needed to determine subfolder sequence numbers + # indicate that sequences chosen should be queued + + if not skipFile: + mediaFile.downloadName = fileRenameFactory.generateNameUsingPreferences( + mediaFile.metadata, mediaFile.name, self.stripCharacters, mediaFile.downloadSubfolder, + sequencesPreliminary = True, + sequence_to_use = sequence_to_use, + fallback_date = mediaFile.modificationTime) + + mediaFile.downloadPath = os.path.join(mediaFile.downloadFolder, mediaFile.downloadSubfolder) + mediaFile.downloadFullFileName = os.path.join(mediaFile.downloadPath, mediaFile.downloadName) + + if not mediaFile.downloadName or not mediaFile.downloadSubfolder: + if not mediaFile.downloadName and not mediaFile.downloadSubfolder: + area = _("subfolder and filename") + elif not mediaFile.downloadName: + area = _("filename") + else: + area = _("subfolder") + problem.add_problem(None, pn.ERROR_IN_NAME_GENERATION, {'filetype': mediaFile.displayNameCap, 'area': area}) + problem.add_extra_detail(pn.NO_DATA_TO_NAME, {'filetype': area}) + skipFile = True + logError(config.SERIOUS_ERROR, pn.problem_definitions[ERROR_IN_NAME_GENERATION][1] % {'filetype': mediaFile.displayNameCap, 'area': area}) + + if not skipFile: + checkProblemWithNameGeneration(mediaFile) + else: + self.sizeDownloaded += mediaFile.size * (no_backup_devices + 1) + mediaFile.status = STATUS_DOWNLOAD_FAILED + + return (skipFile, sequence_to_use) + + def progress_callback(amount_downloaded, total): + if (amount_downloaded - self.bytes_downloaded > 2097152) or (amount_downloaded == total): + chunk_downloaded = amount_downloaded - self.bytes_downloaded + self.bytes_downloaded = amount_downloaded + percentComplete = (float(self.sizeDownloaded + amount_downloaded) / sizeFiles) * 100 + + display_queue.put((media_collection_treeview.updateProgress, (self.thread_id, percentComplete, None, chunk_downloaded))) + + def downloadFile(mediaFile, sequence_to_use): + """ + Downloads the photo or video file to the specified subfolder + """ + + if not mediaFile.isImage: + renameFactory = self.videoRenamePrefsFactory + else: + renameFactory = self.imageRenamePrefsFactory + + def progress_callback_no_update(amount_downloaded, total): + pass + + try: + fileDownloaded = False + if not os.path.isdir(mediaFile.downloadPath): + os.makedirs(mediaFile.downloadPath) + + nameUniqueBeforeCopy = True + downloadNonUniqueFile = True + + # do a preliminary check to see if a file with the same name already exists + if os.path.exists(mediaFile.downloadFullFileName): + nameUniqueBeforeCopy = False + if not addUniqueIdentifier: + downloadNonUniqueFile = False + if (usesVideoSequenceElements and not mediaFile.isImage) or (usesImageSequenceElements and mediaFile.isImage and not self.prefs.synchronize_raw_jpg): + # potentially, a unique file name could still be generated + # investigate this possibility + with self.fileSequenceLock: + for possibleName in renameFactory.generateNameSequencePossibilities( + mediaFile.metadata, + mediaFile.name, self.stripCharacters, mediaFile.downloadSubfolder, + fallback_date = mediaFile.modificationTime): + if possibleName: + # no need to check for any problems here, it's just a temporary name + possibleFile = os.path.join(mediaFile.downloadPath, possibleName) + possibleTempFile = os.path.join(tempWorkingDir, possibleName) + if not os.path.exists(possibleFile) and not os.path.exists(possibleTempFile): + downloadNonUniqueFile = True + break + + + if not downloadNonUniqueFile: + fileAlreadyExists(mediaFile) + + copy_succeeded = False + if nameUniqueBeforeCopy or downloadNonUniqueFile: + tempWorkingfile = os.path.join(tempWorkingDir, mediaFile.downloadName) + if using_gio: + g_dest = gio.File(path=tempWorkingfile) + g_src = gio.File(path=mediaFile.fullFileName) + try: + if not g_src.copy(g_dest, progress_callback, cancellable=gio.Cancellable()): + downloadCopyingError(mediaFile) + else: + copy_succeeded = True + except glib.GError, inst: + downloadCopyingError(mediaFile, inst=inst) + else: + shutil.copy2(mediaFile.fullFileName, tempWorkingfile) + copy_succeeded = True + + if copy_succeeded: + with self.fileRenameLock: + doRename = True + if usesSequenceElements: + with self.fileSequenceLock: + # get a filename and use this as the "real" filename + if sequence_to_use is None and self.prefs.synchronize_raw_jpg and mediaFile.isImage: + # must check again, just in case the matching pair has been downloaded in the meantime + image_name, image_ext = os.path.splitext(mediaFile.name) + with self.downloadedFilesLock: + i, sequence_to_use = downloaded_files.matching_pair(image_name, image_ext, mediaFile.metadata.dateTime(), mediaFile.metadata.subSeconds()) + if i == -99: + sameNameDifferentExif(image_name, mediaFile) + + mediaFile.downloadName = renameFactory.generateNameUsingPreferences( + mediaFile.metadata, mediaFile.name, self.stripCharacters, mediaFile.downloadSubfolder, + sequencesPreliminary = False, + sequence_to_use = sequence_to_use, + fallback_date = mediaFile.modificationTime) + + if not mediaFile.downloadName: + # there was a serious error generating the filename + doRename = False + else: + mediaFile.downloadFullFileName = os.path.join(mediaFile.downloadPath, mediaFile.downloadName) + # check if the file exists again + if os.path.exists(mediaFile.downloadFullFileName): + if not addUniqueIdentifier: + doRename = False + fileAlreadyExists(mediaFile) + else: + # add basic suffix to make the filename unique + name = os.path.splitext(mediaFile.downloadName) + suffixAlreadyUsed = True + while suffixAlreadyUsed: + if mediaFile.downloadFullFileName in duplicate_files: + duplicate_files[mediaFile.downloadFullFileName] += 1 + else: + duplicate_files[mediaFile.downloadFullFileName] = 1 + identifier = '_%s' % duplicate_files[mediaFile.downloadFullFileName] + mediaFile.downloadName = name[0] + identifier + name[1] + possibleNewFile = os.path.join(mediaFile.downloadPath, mediaFile.downloadName) + suffixAlreadyUsed = os.path.exists(possibleNewFile) + + fileAlreadyExists(mediaFile, identifier) + mediaFile.downloadFullFileName = possibleNewFile + + + if doRename: + rename_succeeded = False + if using_gio: + g_dest = gio.File(path=mediaFile.downloadFullFileName) + g_src = gio.File(path=tempWorkingfile) + try: + if not g_src.move(g_dest, progress_callback_no_update, cancellable=gio.Cancellable()): + downloadCopyingError(mediaFile) + else: + rename_succeeded = True + except glib.GError, inst: + downloadCopyingError(mediaFile, inst=inst) + else: + os.rename(tempWorkingfile, mediaFile.downloadFullFileName) + rename_succeeded = True + + if rename_succeeded: + fileDownloaded = True + if mediaFile.status != STATUS_DOWNLOADED_WITH_WARNING: + mediaFile.status = STATUS_DOWNLOADED + if usesImageSequenceElements: + if self.prefs.synchronize_raw_jpg and mediaFile.isImage: + name, ext = os.path.splitext(mediaFile.name) + if sequence_to_use is None: + with self.fileSequenceLock: + seq = renameFactory.sequences.getFinalSequence() + else: + seq = sequence_to_use + with self.downloadedFilesLock: + downloaded_files.add_download(name, ext, mediaFile.metadata.dateTime(), mediaFile.metadata.subSeconds(), seq) + + + with self.fileSequenceLock: + if sequence_to_use is None: + renameFactory.sequences.imageCopySucceeded() + if usesStoredSequenceNo: + self.prefs.stored_sequence_no += 1 + + with self.fileSequenceLock: + if sequence_to_use is None: + if self.prefs.incrementDownloadsToday(): + # A new day, according the user's preferences of what time a day begins, has started + cmd_line(_("New day has started - resetting 'Downloads Today' sequence number")) + + sequences.setDownloadsToday(0) + + except (IOError, OSError), (errno, strerror): + downloadCopyingError(mediaFile, errno=errno, strerror=strerror) + + if usesSequenceElements: + if not fileDownloaded and sequence_to_use is None: + renameFactory.sequences.imageCopyFailed() + + #update record keeping using in tracking progress + self.sizeDownloaded += mediaFile.size + self.bytes_downloaded_in_download = self.bytes_downloaded + + return fileDownloaded + + + def backupFile(mediaFile, fileDownloaded, no_backup_devices): + """ + Backup photo or video to path(s) chosen by the user + + there are three scenarios: + (1) file has just been downloaded and should now be backed up + (2) file was already downloaded on some previous occassion and should still be backed up, because it hasn't been yet + (3) file has been backed up already (or at least, a file with the same name already exists) + + A backup medium can be used to backup photos or videos, or both. + """ + + backed_up = False + fileNotBackedUpMessageDisplayed = False + error_encountered = False + expected_bytes_downloaded = self.sizeDownloaded + no_backup_devices * mediaFile.size + + if no_backup_devices: + for rootBackupDir in self.parentApp.backupVolumes: + self.bytes_downloaded = 0 + if self.prefs.backup_device_autodetection: + volume = self.parentApp.backupVolumes[rootBackupDir].get_name() + if mediaFile.isImage: + backupDir = os.path.join(rootBackupDir, self.prefs.backup_identifier) + else: + backupDir = os.path.join(rootBackupDir, self.prefs.video_backup_identifier) + else: + # photos and videos will be backed up into the same root folder, which the user has manually specified + backupDir = rootBackupDir + volume = backupDir # os.path.split(backupDir)[1] + + # if user has chosen auto detection, then: + # photos should only be backed up to photo backup locations + # videos should only be backed up to video backup locations + # if user did not choose autodetection, and the backup path doesn't exist, then + # will try to create it + if os.path.isdir(backupDir) or not self.prefs.backup_device_autodetection: + + backupPath = os.path.join(backupDir, mediaFile.downloadSubfolder) + newBackupFile = os.path.join(backupPath, mediaFile.downloadName) + copyBackup = True + if os.path.exists(newBackupFile): + # this check is of course not thread safe -- it doesn't need to be, because at this stage the file names are going to be unique + # (the folder structure is the same as the actual download folders, and the file names are unique in them) + copyBackup = self.prefs.backup_duplicate_overwrite + + if copyBackup: + mediaFile.problem.add_problem(None, pn.BACKUP_EXISTS_OVERWRITTEN, volume) + else: + mediaFile.problem.add_problem(None, pn.BACKUP_EXISTS, volume) + severity = config.SERIOUS_ERROR + fileNotBackedUpMessageDisplayed = True + + title = _("Backup of %(file_type)s already exists") % {'file_type': mediaFile.displayName} + details = _("Source: %(source)s\nDestination: %(destination)s") \ + % {'source': mediaFile.fullFileName, 'destination': newBackupFile} + if copyBackup: + resolution = _("Backup %(file_type)s overwritten") % {'file_type': mediaFile.displayName} + else: + if self.prefs.backup_device_autodetection: + volume = self.parentApp.backupVolumes[rootBackupDir].get_name() + resolution = _("%(file_type)s not backed up to %(volume)s") % {'file_type': mediaFile.displayNameCap, 'volume': volume} + else: + resolution = _("%(file_type)s not backed up") % {'file_type': mediaFile.displayNameCap} + logError(severity, title, details, resolution) + + if copyBackup: + if fileDownloaded: + fileToCopy = mediaFile.downloadFullFileName + else: + fileToCopy = mediaFile.fullFileName + if os.path.isdir(backupPath): + pathExists = True + else: + pathExists = False + # create the backup subfolders + if using_gio: + dirs = gio.File(backupPath) + try: + if dirs.make_directory_with_parents(cancellable=gio.Cancellable()): + pathExists = True + except glib.GError, inst: + fileNotBackedUpMessageDisplayed = True + mediaFile.problem.add_problem(None, pn.BACKUP_DIRECTORY_CREATION, volume) + mediaFile.problem.add_extra_detail('%s%s' % (pn.BACKUP_DIRECTORY_CREATION, volume), inst) + error_encountered = True + logError(config.SERIOUS_ERROR, _('Backing up error'), + _("Destination directory could not be created: %(directory)s\n") % + {'directory': backupPath, } + + _("Source: %(source)s\nDestination: %(destination)s") % + {'source': mediaFile.fullFileName, 'destination': newBackupFile} + "\n" + + _("Error: %(inst)s") % {'inst': inst}, + _('The %(file_type)s was not backed up.') % {'file_type': mediaFile.displayName} + ) + else: + # recreate folder structure in backup location + # cannot do os.makedirs(backupPath) - it can give bad results when using external drives + # we know backupDir exists + # all the components of subfolder may not + folders = mediaFile.downloadSubfolder.split(os.path.sep) + folderToMake = backupDir + for f in folders: + if f: + folderToMake = os.path.join(folderToMake, f) + if not os.path.isdir(folderToMake): + try: + os.mkdir(folderToMake) + pathExists = True + except (IOError, OSError), (errno, strerror): + fileNotBackedUpMessageDisplayed = True + inst = "%s: %s" % (errno, strerror) + mediaFile.problem.add_problem(None, pn.BACKUP_DIRECTORY_CREATION, volume) + mediaFile.problem.add_extra_detail('%s%s' % (pn.BACKUP_DIRECTORY_CREATION, volume), inst) + error_encountered = True + logError(config.SERIOUS_ERROR, _('Backing up error'), + _("Destination directory could not be created: %(directory)s\n") % + {'directory': backupPath, } + + _("Source: %(source)s\nDestination: %(destination)s") % + {'source': mediaFile.fullFileName, 'destination': newBackupFile} + "\n" + + _("Error: %(errno)s %(strerror)s") % {'errno': errno, 'strerror': strerror}, + _('The %(file_type)s was not backed up.') % {'file_type': mediaFile.displayName} + ) + + break + + if pathExists: + if using_gio: + g_dest = gio.File(path=newBackupFile) + g_src = gio.File(path=fileToCopy) + if self.prefs.backup_duplicate_overwrite: + flags = gio.FILE_COPY_OVERWRITE + else: + flags = gio.FILE_COPY_NONE + try: + if not g_src.copy(g_dest, progress_callback, flags, cancellable=gio.Cancellable()): + fileNotBackedUpMessageDisplayed = True + mediaFile.problem.add_problem(None, pn.BACKUP_ERROR, volume) + error_encountered = True + else: + backed_up = True + if mediaFile.status == STATUS_DOWNLOAD_FAILED: + mediaFile.problem.add_problem(None, pn.NO_DOWNLOAD_WAS_BACKED_UP, volume) + except glib.GError, inst: + fileNotBackedUpMessageDisplayed = True + mediaFile.problem.add_problem(None, pn.BACKUP_ERROR, volume) + mediaFile.problem.add_extra_detail('%s%s' % (pn.BACKUP_ERROR, volume), inst) + error_encountered = True + logError(config.SERIOUS_ERROR, _('Backing up error'), + _("Source: %(source)s\nDestination: %(destination)s") % + {'source': fileToCopy, 'destination': newBackupFile} + "\n" + + _("Error: %(inst)s") % {'inst': inst}, + _('The %(file_type)s was not backed up.') % {'file_type': mediaFile.displayName} + ) + else: + try: + shutil.copy2(fileToCopy, newBackupFile) + backed_up = True + if mediaFile.status == STATUS_DOWNLOAD_FAILED: + mediaFile.problem.add_problem(None, pn.NO_DOWNLOAD_WAS_BACKED_UP, volume) + + except (IOError, OSError), (errno, strerror): + fileNotBackedUpMessageDisplayed = True + mediaFile.problem.add_problem(None, pn.BACKUP_ERROR, volume) + inst = "%s: %s" % (errno, strerror) + mediaFile.problem.add_extra_detail('%s%s' % (pn.BACKUP_ERROR, volume), inst) + error_encountered = True + logError(config.SERIOUS_ERROR, _('Backing up error'), + _("Source: %(source)s\nDestination: %(destination)s") % + {'source': fileToCopy, 'destination': newBackupFile} + "\n" + + _("Error: %(errno)s %(strerror)s") % {'errno': errno, 'strerror': strerror}, + _('The %(file_type)s was not backed up.') % {'file_type': mediaFile.displayName} + ) + + #update record keeping using in tracking progress + self.sizeDownloaded += mediaFile.size + self.bytes_downloaded_in_backup += self.bytes_downloaded + + if not backed_up and not fileNotBackedUpMessageDisplayed: + # The file has not been backed up to any medium + mediaFile.problem.add_problem(None, pn.NO_BACKUP_PERFORMED, {'filetype': mediaFile.displayNameCap}) + + severity = config.SERIOUS_ERROR + problem = _("%(file_type)s could not be backed up") % {'file_type': mediaFile.displayName} + details = _("Source: %(source)s") % {'source': mediaFile.fullFileName} + if self.prefs.backup_device_autodetection: + resolution = _("No suitable backup volume was found") + else: + resolution = _("A backup location was not found") + logError(severity, problem, details, resolution) + + if backed_up and mediaFile.status == STATUS_DOWNLOAD_FAILED: + mediaFile.problem.add_extra_detail(pn.BACKUP_OK_TYPE, mediaFile.displayNameCap) + + if not backed_up: + if mediaFile.status == STATUS_DOWNLOAD_FAILED: + mediaFile.status = STATUS_DOWNLOAD_AND_BACKUP_FAILED + else: + mediaFile.status = STATUS_BACKUP_PROBLEM + elif error_encountered: + # it was backed up to at least one volume, but there was an error on another backup volume + if mediaFile.status != STATUS_DOWNLOAD_FAILED: + mediaFile.status = STATUS_BACKUP_PROBLEM + + # Take into account instances where a backup device has been removed part way through a download + # (thereby making self.parentApp.backupVolumes have less items than expected) + if self.sizeDownloaded < expected_bytes_downloaded: + self.sizeDownloaded = expected_bytes_downloaded + return backed_up + + + self.hasStarted = True + display_queue.open('w') + + #Do not try to handle any preference errors here + getPrefs(False) + + #Check photo and video download path, create if necessary + photoBaseDownloadDir = self.prefs.download_folder + if not checkDownloadPath(photoBaseDownloadDir): + return # cleanup already done + + if DOWNLOAD_VIDEO: + videoBaseDownloadDir = self.prefs.video_download_folder + if not checkDownloadPath(videoBaseDownloadDir): + return + else: + videoBaseDownloadDir = self.videoTempWorkingDir = None + + if not createBothTempDirs(): + return + + s = scanMedia() + if s is None: + if not self.ctrl: + self.running = False + display_queue.put((media_collection_treeview.removeCard, (self.thread_id, ))) + display_queue.close("rw") + return + else: + sys.stderr.write("FIXME: scan returned None, but the thread is not meant to be exiting\n") + if not s: + cmd_line(_("This device has no %(types_searched_for)s to download from.") % {'types_searched_for': self.types_searched_for}) + display_queue.put((self.parentApp.downloadFailed, (self.thread_id, ))) + display_queue.close("rw") + self.running = False + return + + if self.scanResultsStale or self.scanResultsStaleDownloadFolder: + display_queue.put((self.parentApp.regenerateScannedDevices, (self.thread_id, ))) + all_files_downloaded = False + + totalNonErrorFiles = self.cardMedia.numberOfFilesNotCannotDownload() + + if not self.autoStart: + # halt thread, waiting to be restarted so download proceeds + self.cleanUp() + self.running = False + self.lock.acquire() + + if not self.ctrl: + # thread will restart at this point, when the program is exiting + # so must exit if self.ctrl indicates this + + self.running = False + display_queue.close("rw") + return + + self.running = True + if not createBothTempDirs(): + return + + else: + if need_job_code_for_renaming: + if checkIfNeedAJobCode(): + if job_code == None: + self.cleanUp() + self.waitingForJobCode = True + display_queue.put((self.parentApp.getJobCode, ())) + self.running = False + self.lock.acquire() + + if not self.ctrl: + # thread is exiting + display_queue.close("rw") + return + + self.running = True + self.waitingForJobCode = False + if not createBothTempDirs(): + return + else: + # User has entered a job code, and it's in the global variable + # Assign it to all those files that do not have one + display_queue.put((self.parentApp.selection_vbox.selection_treeview.apply_job_code, (job_code, False, True, self.thread_id))) + + # auto start could be false if the user hit cancel when prompted for a job code + if self.autoStart: + # set all in this thread to download pending + display_queue.put((self.parentApp.selection_vbox.selection_treeview.set_status_to_download_pending, (False, self.thread_id))) + # wait until all the files have had their status set to download pending, and once that is done, restart + self.running = False + self.lock.acquire() + self.running = True + + # set download started time + display_queue.put((self.parentApp.setDownloadStartTime, ())) + + while not all_files_downloaded: + + # set the download start time to be the time that the user clicked the download button, or if on auto start, the value just set + i = 0 + while self.parentApp.download_start_time is None or i > 2: + time.sleep(0.5) + i += 1 + + if self.parentApp.download_start_time: + start_time = self.parentApp.download_start_time + else: + # in a bizarre corner case situation, with mulitple cards of greatly varying size, + # it's possible the start time was set above and then in the meantime unset (very unlikely, but conceivably it could happen) + # fall back to the current time in this less than satisfactory situation + start_time = datetime.datetime.now() + + self.imageRenamePrefsFactory.setDownloadStartTime(start_time) + self.subfolderPrefsFactory.setDownloadStartTime(start_time) + if DOWNLOAD_VIDEO: + self.videoRenamePrefsFactory.setDownloadStartTime(start_time) + self.videoSubfolderPrefsFactory.setDownloadStartTime(start_time) + + self.noErrors = self.noWarnings = 0 + + if not getPrefs(True): + self.running = False + display_queue.close("rw") + return + + self.downloadStarted = True + cmd_line(_("Download has started from %s") % self.cardMedia.prettyName(limit=0)) + + + noFiles, sizeFiles, fileIndex = self.cardMedia.sizeAndNumberDownloadPending() + cmd_line(_("Attempting to download %s files") % noFiles) + + no_backup_devices = setupBackup() + + # include the time it takes to copy to the backup volumes + sizeFiles = sizeFiles * (no_backup_devices + 1) + + display_queue.put((self.parentApp.timeRemaining.set, (self.thread_id, sizeFiles))) + + i = 0 + self.sizeDownloaded = noFilesDownloaded = noImagesDownloaded = noVideosDownloaded = noImagesSkipped = noVideosSkipped = 0 + filesDownloadedSuccessfully = [] + self.bytes_downloaded_in_backup = 0 + + display_queue.put((self.parentApp.addToTotalDownloadSize, (sizeFiles, ))) + display_queue.put((self.parentApp.setOverallDownloadMark, ())) + display_queue.put((self.parentApp.postStartDownloadTasks, ())) + + sizeFiles = float(sizeFiles) + + addUniqueIdentifier = self.prefs.download_conflict_resolution == config.ADD_UNIQUE_IDENTIFIER + usesImageSequenceElements = self.imageRenamePrefsFactory.usesSequenceElements() + usesVideoSequenceElements = self.videoRenamePrefsFactory.usesSequenceElements() + usesSequenceElements = usesVideoSequenceElements or usesImageSequenceElements + + usesStoredSequenceNo = (self.imageRenamePrefsFactory.usesTheSequenceElement(rn.STORED_SEQ_NUMBER) or + self.videoRenamePrefsFactory.usesTheSequenceElement(rn.STORED_SEQ_NUMBER)) + sequences.setUseOfSequenceElements( + self.imageRenamePrefsFactory.usesTheSequenceElement(rn.SESSION_SEQ_NUMBER), + self.imageRenamePrefsFactory.usesTheSequenceElement(rn.SEQUENCE_LETTER)) + + # reset the progress bar to update the status of this download attempt + progressBarText = _("%(number)s of %(total)s %(filetypes)s") % {'number': 0, 'total': noFiles, 'filetypes':self.display_file_types} + display_queue.put((media_collection_treeview.updateProgress, (self.thread_id, 0.0, progressBarText, 0))) + + + while i < noFiles: + # if the user pauses the download, then this will be triggered + if not self.running: + self.lock.acquire() + self.running = True + + if not self.ctrl: + self.running = False + self.cleanUp() + display_queue.close("rw") + return + + # get information about the image to deduce image name and path + mediaFile = self.cardMedia.imagesAndVideos[fileIndex[i]][0] + if not mediaFile.status == STATUS_DOWNLOAD_PENDING: + sys.stderr.write("FIXME: Thread %s is trying to download a file that it should not be!!" % self.thread_id) + else: + self.bytes_downloaded_in_download = self.bytes_downloaded_in_backup = self.bytes_downloaded = 0 + if mediaFile.isImage: + tempWorkingDir = self.photoTempWorkingDir + baseDownloadDir = photoBaseDownloadDir + else: + tempWorkingDir = self.videoTempWorkingDir + baseDownloadDir = videoBaseDownloadDir + + skipFile, sequence_to_use = generateSubfolderAndFileName(mediaFile) + + if skipFile: + if mediaFile.isImage: + noImagesSkipped += 1 + else: + noVideosSkipped += 1 + else: + fileDownloaded = downloadFile(mediaFile, sequence_to_use) + + if self.prefs.backup_images: + backed_up = backupFile(mediaFile, fileDownloaded, no_backup_devices) + + if fileDownloaded: + noFilesDownloaded += 1 + if mediaFile.isImage: + noImagesDownloaded += 1 + else: + noVideosDownloaded += 1 + if self.prefs.backup_images and backed_up: + filesDownloadedSuccessfully.append(mediaFile.fullFileName) + elif not self.prefs.backup_images: + filesDownloadedSuccessfully.append(mediaFile.fullFileName) + else: + if mediaFile.isImage: + noImagesSkipped += 1 + else: + noVideosSkipped += 1 + + #update the selction treeview in the main window with the new status of the file + display_queue.put((self.parentApp.update_status_post_download, (mediaFile.treerowref, ))) + + percentComplete = (float(self.sizeDownloaded) / sizeFiles) * 100 + + if self.sizeDownloaded == sizeFiles and (totalNonErrorFiles - noFiles): + progressBarText = _("%(number)s of %(total)s %(filetypes)s (%(remaining)s remaining)") % { + 'number': i + 1, 'total': noFiles, 'filetypes':self.display_file_types, + 'remaining': totalNonErrorFiles - noFiles} + else: + progressBarText = _("%(number)s of %(total)s %(filetypes)s") % {'number': i + 1, 'total': noFiles, 'filetypes':self.display_file_types} + + if using_gio: + # do not want to update the progress bar any more than it has already been updated + size = mediaFile.size * (no_backup_devices + 1) - self.bytes_downloaded_in_download - self.bytes_downloaded_in_backup + else: + size = mediaFile.size * (no_backup_devices + 1) + display_queue.put((media_collection_treeview.updateProgress, (self.thread_id, percentComplete, progressBarText, size))) + + i += 1 + + with self.statsLock: + self.downloadStats.adjust(self.sizeDownloaded, noImagesDownloaded, noVideosDownloaded, noImagesSkipped, noVideosSkipped, self.noWarnings, self.noErrors) + + if self.prefs.auto_delete: + j = 0 + for imageOrVideo in filesDownloadedSuccessfully: + try: + os.unlink(imageOrVideo) + j += 1 + except OSError, (errno, strerror): + logError(config.SERIOUS_ERROR, _("Could not delete photo or video from device"), + _("Photo: %(source)s\nError: %(errno)s %(strerror)s") + % {'source': image, 'errno': errno, 'strerror': strerror}) + except: + logError(config.SERIOUS_ERROR, _("Could not delete photo or video from device"), + _("Photo: %(source)s")) + + cmd_line(_("Deleted %(number)i %(filetypes)s from device") % {'number':j, 'filetypes':self.display_file_types}) + + totalNonErrorFiles = totalNonErrorFiles - noFiles + if totalNonErrorFiles == 0: + all_files_downloaded = True + + # must manually delete these variables, or else the media cannot be unmounted (bug in some versions of pyexiv2 / exiv2) + # for some reason directories on the device remain open with read only access, even after these steps - I don't know why + del self.subfolderPrefsFactory, self.imageRenamePrefsFactory, self.videoSubfolderPrefsFactory, self.videoRenamePrefsFactory + for i in self.cardMedia.imagesAndVideos: + i[0].metadata = None + + notifyAndUnmount(umountAttemptOK = all_files_downloaded) + cmd_line(_("Download complete from %s") % self.cardMedia.prettyName(limit=0)) + display_queue.put((self.parentApp.notifyUserAllDownloadsComplete,())) + display_queue.put((self.parentApp.resetSequences,())) + + if all_files_downloaded: + self.downloadComplete = True + else: + self.cleanUp() + self.downloadStarted = False + self.running = False + self.lock.acquire() + if not self.ctrl: + # thread will restart at this point, when the program is exiting + # so must exit if self.ctrl indicates this + + self.running = False + display_queue.close("rw") + return + self.running = True + if not createBothTempDirs(): + return + + + display_queue.put((self.parentApp.exitOnDownloadComplete, ())) + display_queue.close("rw") + + self.cleanUp() + + self.running = False + if noFiles: + self.lock.release() + + + def startStop(self): + if self.isAlive(): + if self.running: + self.running = False + else: + try: + self.lock.release() + + except thread_error: + sys.stderr.write(str(self.thread_id) + " thread error\n") + + def cleanUp(self): + """ + Deletes temporary files and folders + """ + + for tempWorkingDir in (self.videoTempWorkingDir, self.photoTempWorkingDir): + if tempWorkingDir: + # possibly delete any lingering files + if os.path.isdir(tempWorkingDir): + tf = os.listdir(tempWorkingDir) + if tf: + for f in tf: + os.remove(os.path.join(tempWorkingDir, f)) + os.rmdir(tempWorkingDir) + + def quit(self): + """ + Quits the thread + + A thread can be in one of four states: + + Not started (not alive, nothing to do) + Started and actively running (alive) + Started and paused (alive) + Completed (not alive, nothing to do) + """ + + # cleanup any temporary directories and files + self.cleanUp() + + if self.hasStarted: + if self.isAlive(): + self.ctrl = False + + if not self.running: + released = False + while not released: + try: + self.lock.release() + released = True + except thread_error: + sys.stderr.write("Could not release lock for thread %s\n" % self.thread_id) + + + + def on_volume_unmount(self, data1, data2): + """ needed for call to unmount volume""" + pass + + +class MediaTreeView(gtk.TreeView): + """ + TreeView display of devices and associated copying progress. + + Assumes a threaded environment. + """ + def __init__(self, parentApp): + + self.parentApp = parentApp + # device name, size of images on the device (human readable), copy progress (%), copy text + self.liststore = gtk.ListStore(str, str, float, str) + self.mapThreadToRow = {} gtk.TreeView.__init__(self, self.liststore) @@ -138,357 +3088,587 @@ class DeviceCollection(gtk.TreeView): selection = self.get_selection() selection.set_mode(gtk.SELECTION_NONE) - - # Device refers to a thing like a camera, memory card in its reader, - # external hard drive, Portable Storage Device, etc. - column0 = gtk.TreeViewColumn(_("Device")) - pixbuf_renderer = gtk.CellRendererPixbuf() - text_renderer = gtk.CellRendererText() - text_renderer.props.ellipsize = pango.ELLIPSIZE_MIDDLE - text_renderer.set_fixed_size(160, -1) - column0.pack_start(pixbuf_renderer, expand=False) - column0.pack_start(text_renderer, expand=True) - column0.add_attribute(pixbuf_renderer, 'pixbuf', 0) - column0.add_attribute(text_renderer, 'text', 1) + # Device refers to a thing like a camera, memory card in its reader, external hard drive, Portable Storage Device, etc. + column0 = gtk.TreeViewColumn(_("Device"), gtk.CellRendererText(), + text=0) self.append_column(column0) - - # Size refers to the total size of images on the device, typically in - # MB or GB - column1 = gtk.TreeViewColumn(_("Size"), gtk.CellRendererText(), text=2) + # Size refers to the total size of images on the device, typically in MB or GB + column1 = gtk.TreeViewColumn(_("Size"), gtk.CellRendererText(), text=1) self.append_column(column1) column2 = gtk.TreeViewColumn(_("Download Progress"), - gtk.CellRendererProgress(), - value=3, - text=4) + gtk.CellRendererProgress(), value=2, text=3) self.append_column(column2) self.show_all() - def add_device(self, process_id, device, progress_bar_text = ''): + def addCard(self, thread_id, cardName, sizeFiles, progress = 0.0, progressBarText = ''): # add the row, and get a temporary pointer to the row - size_files = '' - progress = 0.0 - iter = self.liststore.append((device.get_icon(), - device.get_name(), - size_files, - progress, - progress_bar_text)) + iter = self.liststore.append((cardName, sizeFiles, progress, progressBarText)) + + self._setThreadMap(thread_id, iter) - self._set_process_map(process_id, iter) + # adjust scrolled window height, based on row height and number of ready to start downloads + if workers.noReadyToStartWorkers() >= 1 or workers.noRunningWorkers() > 0: + # please note, at program startup, self.rowHeight() will be less than it will be when already running + # e.g. when starting with 3 cards, it could be 18, but when adding 2 cards to the already running program + # (with one card at startup), it could be 21 + height = (workers.noReadyToStartWorkers() + workers.noRunningWorkers() + 2) * (self.rowHeight()) + self.parentApp.media_collection_scrolledwindow.set_size_request(-1, height) - def update_device(self, process_id, total_size_files): + def updateCard(self, thread_id, totalSizeFiles): """ Updates the size of the photos and videos on the device, displayed to the user """ - if process_id in self.map_process_to_row: - iter = self._get_process_map(process_id) - self.liststore.set_value(iter, 2, total_size_files) + if thread_id in self.mapThreadToRow: + iter = self._getThreadMap(thread_id) + self.liststore.set_value(iter, 1, totalSizeFiles) else: - logger.error("This device is unknown") + sys.stderr.write("FIXME: this card is unknown") - def remove_device(self, process_id): - if process_id in self.map_process_to_row: - iter = self._get_process_map(process_id) + def removeCard(self, thread_id): + if thread_id in self.mapThreadToRow: + iter = self._getThreadMap(thread_id) self.liststore.remove(iter) - del self.map_process_to_row[process_id] - - def get_all_displayed_processes(self): - """ - returns a list of the processes currently being displayed to the user - """ - return self.map_process_to_row.keys() + del self.mapThreadToRow[thread_id] - def _set_process_map(self, process_id, iter): + def _setThreadMap(self, thread_id, iter): """ convert the temporary iter into a tree reference, which is permanent """ path = self.liststore.get_path(iter) - treerowref = gtk.TreeRowReference(self.liststore, path) - self.map_process_to_row[process_id] = treerowref + treerowRef = gtk.TreeRowReference(self.liststore, path) + self.mapThreadToRow[thread_id] = treerowRef - def _get_process_map(self, process_id): + def _getThreadMap(self, thread_id): """ - return the tree iter for this process + return the tree iter for this thread """ - if process_id in self.map_process_to_row: - treerowref = self.map_process_to_row[process_id] - path = treerowref.get_path() + if thread_id in self.mapThreadToRow: + treerowRef = self.mapThreadToRow[thread_id] + path = treerowRef.get_path() iter = self.liststore.get_iter(path) return iter else: return None - def update_progress(self, scan_pid, percent_complete, progress_bar_text, bytes_downloaded): + def updateProgress(self, thread_id, percentComplete, progressBarText, bytesDownloaded): - iter = self._get_process_map(scan_pid) + iter = self._getThreadMap(thread_id) if iter: - if percent_complete: - self.liststore.set_value(iter, 3, percent_complete) - if progress_bar_text: - self.liststore.set_value(iter, 4, progress_bar_text) - if percent_complete or bytes_downloaded: - pass - #~ logger.info("Implement update overall progress") + self.liststore.set_value(iter, 2, percentComplete) + if progressBarText: + self.liststore.set_value(iter, 3, progressBarText) + if percentComplete or bytesDownloaded: + self.parentApp.updateOverallProgress(thread_id, bytesDownloaded, percentComplete) + + def rowHeight(self): + if not self.mapThreadToRow: + return 0 + else: + index = self.mapThreadToRow.keys()[0] + path = self.mapThreadToRow[index].get_path() + col = self.get_column(0) + return self.get_background_area(path, col)[3] + 1 -def create_cairo_image_surface(pil_image, image_width, image_height): - imgd = pil_image.tostring("raw","BGRA", 0, 1) - data = array.array('B',imgd) - stride = image_width * 4 - image = cairo.ImageSurface.create_for_data(data, cairo.FORMAT_ARGB32, - image_width, image_height, stride) - return image -class ThumbnailCellRenderer(gtk.CellRenderer): - __gproperties__ = { - "image": (gobject.TYPE_PYOBJECT, "Image", - "Image", gobject.PARAM_READWRITE), - - "filename": (gobject.TYPE_STRING, "Filename", - "Filename", '', gobject.PARAM_READWRITE), - - "status": (gtk.gdk.Pixbuf, "Status", - "Status", gobject.PARAM_READWRITE), - } - - def __init__(self, checkbutton_height): - gtk.CellRenderer.__init__(self) - self.image = None +class ShowWarningDialog(gtk.Dialog): + """ + Displays a warning to the user that downloading directly from a + camera does not always work well + """ + def __init__(self, parent_window, postChoiceCB): + gtk.Dialog.__init__(self, _("Downloading From Cameras"), None, + gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + (gtk.STOCK_OK, gtk.RESPONSE_OK)) + + self.postChoiceCB = postChoiceCB - self.image_area_size = 100 - self.text_area_size = 30 - self.padding = 6 - self.checkbutton_height = checkbutton_height - self.icon_width = 20 + primary_msg = _("Downloading directly from a camera may work poorly or not at all") + secondary_msg = _("Downloading from a card reader always works and is generally much faster. It is strongly recommended to use a card reader.") - def do_set_property(self, pspec, value): - setattr(self, pspec.name, value) + self.set_icon_from_file(paths.share_dir('glade3/rapid-photo-downloader.svg')) - def do_get_property(self, pspec): - return getattr(self, pspec.name) - - def do_render(self, window, widget, background_area, cell_area, expose_area, flags): - - cairo_context = window.cairo_create() - - x = cell_area.x - y = cell_area.y + self.checkbutton_height - 8 - w = cell_area.width - h = cell_area.height - - - #constrain operations to cell area, allowing for a 1 pixel border - #either side - #~ cairo_context.rectangle(x-1, y-1, w+2, h+2) - #~ cairo_context.clip() - - #fill in the background with dark grey - #this ensures that a selected cell's fill does not make - #the text impossible to read - #~ cairo_context.rectangle(x, y, w, h) - #~ cairo_context.set_source_rgb(0.267, 0.267, 0.267) - #~ cairo_context.fill() - - #image width and height - image_w = self.image.size[0] - image_h = self.image.size[1] - - #center the image horizontally - #bottom align vertically - #top left and right corners for the image: - image_x = x + ((w - image_w) / 2) - image_y = y + self.image_area_size - image_h + primary_label = gtk.Label() + primary_label.set_markup("<b>%s</b>" % primary_msg) + primary_label.set_line_wrap(True) + primary_label.set_alignment(0, 0.5) - #convert PIL image to format suitable for cairo - image = create_cairo_image_surface(self.image, image_w, image_h) + secondary_label = gtk.Label() + secondary_label.set_text(secondary_msg) + secondary_label.set_line_wrap(True) + secondary_label.set_alignment(0, 0.5) - # draw a light grey border of 1px around the image - cairo_context.set_source_rgb(0.66, 0.66, 0.66) #light grey, #a9a9a9 - cairo_context.set_line_width(1) - cairo_context.rectangle(image_x-.5, image_y-.5, image_w+1, image_h+1) - cairo_context.stroke() - - # draw a thin border around each cell - # ouch - nasty hardcoding :( - #~ cairo_context.set_source_rgb(0.33, 0.33, 0.33) - #~ cairo_context.rectangle(x-6.5, y-9.5, w+14, h+31) - #~ cairo_context.stroke() + self.show_again_checkbutton = gtk.CheckButton(_('_Show this message again'), True) + self.show_again_checkbutton.set_active(True) - #place the image - cairo_context.set_source_surface(image, image_x, image_y) - cairo_context.paint() - - #text - context = pangocairo.CairoContext(cairo_context) + msg_vbox = gtk.VBox() + msg_vbox.pack_start(primary_label, False, False, padding=6) + msg_vbox.pack_start(secondary_label, False, False, padding=6) + msg_vbox.pack_start(self.show_again_checkbutton) + + icon = parent_window.render_icon(gtk.STOCK_DIALOG_WARNING, gtk.ICON_SIZE_DIALOG) + image = gtk.Image() + image.set_from_pixbuf(icon) + image.set_alignment(0, 0) + + warning_hbox = gtk.HBox() + warning_hbox.pack_start(image, False, False, padding = 12) + warning_hbox.pack_start(msg_vbox, False, False, padding = 12) + + self.vbox.pack_start(warning_hbox, padding=6) + + self.set_border_width(6) + self.set_has_separator(False) - text_y = y + self.image_area_size + 10 - text_w = w - self.icon_width - text_x = x + self.icon_width - #~ context.rectangle(text_x, text_y, text_w, 15) - #~ context.clip() + self.set_default_response(gtk.RESPONSE_OK) + + self.set_transient_for(parent_window) + self.show_all() - layout = context.create_layout() - - width = text_w * pango.SCALE - layout.set_width(width) - - layout.set_alignment(pango.ALIGN_CENTER) - layout.set_ellipsize(pango.ELLIPSIZE_END) + self.connect('response', self.on_response) - #font color and size - fg_color = pango.AttrForeground(65535, 65535, 65535, 0, -1) - font_size = pango.AttrSize(8192, 0, -1) # 8 * 1024 = 8192 - font_family = pango.AttrFamily('sans', 0, -1) - attr = pango.AttrList() - attr.insert(fg_color) - attr.insert(font_size) - attr.insert(font_family) - layout.set_attributes(attr) - - layout.set_text(self.filename) + def on_response(self, device_dialog, response): + show_again = self.show_again_checkbutton.get_active() + self.postChoiceCB(self, show_again) - context.move_to(text_x, text_y) - context.show_layout(layout) +class UseDeviceDialog(gtk.Dialog): + def __init__(self, parent_window, path, volume, autostart, postChoiceCB): + gtk.Dialog.__init__(self, _('Device Detected'), None, + gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + (gtk.STOCK_NO, gtk.RESPONSE_CANCEL, + gtk.STOCK_YES, gtk.RESPONSE_OK)) + + self.postChoiceCB = postChoiceCB + + self.set_icon_from_file(paths.share_dir('glade3/rapid-photo-downloader.svg')) + # Translators: for an explanation of what this means, see http://damonlynch.net/rapid/documentation/index.html#usedeviceprompt + prompt_label = gtk.Label(_('Should this device or partition be used to download photos or videos from?')) + prompt_label.set_line_wrap(True) + prompt_hbox = gtk.HBox() + prompt_hbox.pack_start(prompt_label, False, False, padding=6) + device_label = gtk.Label() + device_label.set_markup("<b>%s</b>" % volume.get_name(limit=0)) + device_hbox = gtk.HBox() + device_hbox.pack_start(device_label, False, False) + path_label = gtk.Label() + path_label.set_markup("<i>%s</i>" % path) + path_hbox = gtk.HBox() + path_hbox.pack_start(path_label, False, False) + + icon = volume.get_icon_pixbuf(36) + if icon: + image = gtk.Image() + image.set_from_pixbuf(icon) + + # Translators: for an explanation of what this means, see http://damonlynch.net/rapid/documentation/index.html#usedeviceprompt + self.always_checkbutton = gtk.CheckButton(_('_Remember this choice'), True) + + if icon: + device_hbox_icon = gtk.HBox(homogeneous=False, spacing=6) + device_hbox_icon.pack_start(image, False, False, padding = 6) + device_vbox = gtk.VBox(homogeneous=True, spacing=6) + device_vbox.pack_start(device_hbox, False, False) + device_vbox.pack_start(path_hbox, False, False) + device_hbox_icon.pack_start(device_vbox, False, False) + self.vbox.pack_start(device_hbox_icon, padding = 6) + else: + self.vbox.pack_start(device_hbox, padding=6) + self.vbox.pack_start(path_hbox, padding = 6) + + self.vbox.pack_start(prompt_hbox, padding=6) + self.vbox.pack_start(self.always_checkbutton, padding=6) - #status - cairo_context.set_source_pixbuf(self.status, x, y + self.image_area_size + 10) - cairo_context.paint() + self.set_border_width(6) + self.set_has_separator(False) - def do_get_size(self, widget, cell_area): - #~ return (0, 0, self.image_area_size, self.image_area_size + self.checkbutton_height + 10) - return (0, 0, self.image_area_size, self.image_area_size + self.text_area_size - self.checkbutton_height + 4) + self.set_default_response(gtk.RESPONSE_OK) + + + self.set_transient_for(parent_window) + self.show_all() + self.path = path + self.volume = volume + self.autostart = autostart + + self.connect('response', self.on_response) + + def on_response(self, device_dialog, response): + userSelected = False + permanent_choice = self.always_checkbutton.get_active() + if response == gtk.RESPONSE_OK: + userSelected = True + # Translators: for an explanation of what this means, see http://damonlynch.net/rapid/documentation/index.html#usedeviceprompt + cmd_line(_("%s selected for downloading from" % self.volume.get_name(limit=0))) + if permanent_choice: + # Translators: for an explanation of what this means, see http://damonlynch.net/rapid/documentation/index.html#usedeviceprompt + cmd_line(_("This device or partition will always be used to download from")) + else: + # Translators: for an explanation of what this means, see http://damonlynch.net/rapid/documentation/index.html#usedeviceprompt + cmd_line(_("%s rejected as a download device" % self.volume.get_name(limit=0))) + if permanent_choice: + # Translators: for an explanation of what this means, see http://damonlynch.net/rapid/documentation/index.html#usedeviceprompt + cmd_line(_("This device or partition will never be used to download from")) + + self.postChoiceCB(self, userSelected, permanent_choice, self.path, + self.volume, self.autostart) + +class RemoveAllJobCodeDialog(gtk.Dialog): + def __init__(self, parent_window, postChoiceCB): + gtk.Dialog.__init__(self, _('Remove all Job Codes?'), None, + gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + (gtk.STOCK_NO, gtk.RESPONSE_CANCEL, + gtk.STOCK_YES, gtk.RESPONSE_OK)) + + self.postChoiceCB = postChoiceCB + self.set_icon_from_file(paths.share_dir('glade3/rapid-photo-downloader.svg')) - -gobject.type_register(ThumbnailCellRenderer) - - -class ThumbnailDisplay(gtk.IconView): - def __init__(self, parent_app): - gtk.IconView.__init__(self) - self.rapid_app = parent_app + prompt_hbox = gtk.HBox() - self.batch_size = 10 + icontheme = gtk.icon_theme_get_default() + icon = icontheme.load_icon('gtk-dialog-question', 36, gtk.ICON_LOOKUP_USE_BUILTIN) + if icon: + image = gtk.Image() + image.set_from_pixbuf(icon) + prompt_hbox.pack_start(image, False, False, padding = 6) + + prompt_label = gtk.Label(_('Should all Job Codes be removed?')) + prompt_label.set_line_wrap(True) + prompt_hbox.pack_start(prompt_label, False, False, padding=6) + + self.vbox.pack_start(prompt_hbox, padding=6) + + self.set_border_width(6) + self.set_has_separator(False) - self.thumbnail_manager = ThumbnailManager(self.thumbnail_results, self.batch_size) - self.preview_manager = PreviewManager(self.preview_results) + self.set_default_response(gtk.RESPONSE_OK) + + + self.set_transient_for(parent_window) + self.show_all() + - self.treerow_index = {} - self.process_index = {} + self.connect('response', self.on_response) - self.rpd_files = {} + def on_response(self, device_dialog, response): + userSelected = response == gtk.RESPONSE_OK + self.postChoiceCB(self, userSelected) - self.total_files = 0 - self.thumbnails_generated = 0 - self.thumbnails = {} - self.previews = {} - self.previews_being_fetched = set() +class JobCodeDialog(gtk.Dialog): + """ Dialog prompting for a job code""" + + def __init__(self, parent_window, job_codes, default_job_code, postJobCodeEntryCB, autoStart, downloadSelected, entryOnly): + # Translators: for an explanation of what this means, see http://damonlynch.net/rapid/documentation/index.html#jobcode + gtk.Dialog.__init__(self, _('Enter a Job Code'), None, + gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, + (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, + gtk.STOCK_OK, gtk.RESPONSE_OK)) + - self.stock_photo_thumbnails = tn.PhotoIcons() - self.stock_video_thumbnails = tn.VideoIcons() + self.set_icon_from_file(paths.share_dir('glade3/rapid-photo-downloader.svg')) + self.postJobCodeEntryCB = postJobCodeEntryCB + self.autoStart = autoStart + self.downloadSelected = downloadSelected - self.SELECTED_COL = 1 - self.UNIQUE_ID_COL = 2 - self.TIMESTAMP_COL = 4 - self.FILETYPE_COL = 5 - self.CHECKBUTTON_VISIBLE_COL = 6 - self.DOWNLOAD_STATUS_COL = 7 - self.STATUS_ICON_COL = 8 + self.combobox = gtk.combo_box_entry_new_text() + for text in job_codes: + self.combobox.append_text(text) + + self.job_code_hbox = gtk.HBox(homogeneous = False) - self.liststore = gtk.ListStore( - gobject.TYPE_PYOBJECT, # 0 PIL thumbnail - gobject.TYPE_BOOLEAN, # 1 selected or not - str, # 2 unique id - str, # 3 file name - int, # 4 timestamp for sorting, converted float - int, # 5 file type i.e. photo or video - gobject.TYPE_BOOLEAN, # 6 visibility of checkbutton - int, # 7 status of download - gtk.gdk.Pixbuf, # 8 status icon - ) + if len(job_codes) and not entryOnly: + # Translators: for an explanation of what this means, see http://damonlynch.net/rapid/documentation/index.html#jobcode + task_label = gtk.Label(_('Enter a new Job Code, or select a previous one')) + else: + # Translators: for an explanation of what this means, see http://damonlynch.net/rapid/documentation/index.html#jobcode + task_label = gtk.Label(_('Enter a new Job Code')) + task_label.set_line_wrap(True) + task_hbox = gtk.HBox() + task_hbox.pack_start(task_label, False, False, padding=6) + label = gtk.Label(_('Job Code:')) + self.job_code_hbox.pack_start(label, False, False, padding=6) + self.job_code_hbox.pack_start(self.combobox, True, True, padding=6) + + self.set_border_width(6) + self.set_has_separator(False) - self.clear() - self.set_model(self.liststore) + # make entry box have entry completion + self.entry = self.combobox.child - checkbutton = gtk.CellRendererToggle() - checkbutton.set_radio(False) - checkbutton.props.activatable = True - checkbutton.props.xalign = 0.0 - checkbutton.connect('toggled', self.on_checkbutton_toggled) - self.pack_end(checkbutton, expand=False) - self.add_attribute(checkbutton, "active", 1) - self.add_attribute(checkbutton, "visible", 6) + completion = gtk.EntryCompletion() + completion.set_match_func(self.match_func) + completion.connect("match-selected", + self.on_completion_match) + completion.set_model(self.combobox.get_model()) + completion.set_text_column(0) + self.entry.set_completion(completion) - checkbutton_size = checkbutton.get_size(self, None) - checkbutton_height = checkbutton_size[3] - checkbutton_width = checkbutton_size[2] + # when user hits enter, close the dialog window + self.set_default_response(gtk.RESPONSE_OK) + self.entry.set_activates_default(True) + + if default_job_code: + self.entry.set_text(default_job_code) - #~ status_icon = gtk.CellRendererPixbuf() - #~ self.pack_start(status_icon, expand=False) - #~ self.add_attribute(status_icon, "pixbuf", self.STATUS_ICON_COL) + self.vbox.pack_start(task_hbox, False, False, padding = 6) + self.vbox.pack_start(self.job_code_hbox, False, False, padding=12) - image = ThumbnailCellRenderer(checkbutton_height) - self.pack_start(image, expand=True) - self.add_attribute(image, "image", 0) - self.add_attribute(image, "filename", 3) - self.add_attribute(image, "status", 8) + self.set_transient_for(parent_window) + self.show_all() + self.connect('response', self.on_job_code_resp) + + def match_func(self, completion, key, iter): + model = completion.get_model() + return model[iter][0].lower().startswith(self.entry.get_text().lower()) + + def on_completion_match(self, completion, model, iter): + self.entry.set_text(model[iter][0]) + self.entry.set_position(-1) + def get_job_code(self): + return self.combobox.child.get_text() - #set the background color to a darkish grey - self.modify_base(gtk.STATE_NORMAL, gtk.gdk.Color('#444444')) - - self.set_spacing(0) - #~ self.set_column_spacing(0) - self.set_row_spacing(5) - #~ self.set_row_spacing(0) - self.set_margin(25) + def on_job_code_resp(self, jc_dialog, response): + userChoseCode = False + if response == gtk.RESPONSE_OK: + userChoseCode = True + cmd_line(_("Job Code entered")) + else: + cmd_line(_("Job Code not entered")) + self.postJobCodeEntryCB(self, userChoseCode, self.get_job_code(), self.autoStart, self.downloadSelected) + + + +class SelectionTreeView(gtk.TreeView): + """ + TreeView display of photos and videos available for download + + Assumes a threaded environment. + """ + def __init__(self, parentApp): + + self.parentApp = parentApp + self.rapidApp = parentApp.parentApp - self._setup_icons() + self.liststore = gtk.ListStore( + gtk.gdk.Pixbuf, # 0 thumbnail icon + str, # 1 name (for sorting) + int, # 2 timestamp (for sorting), float converted into an int + str, # 3 date (human readable) + int, # 4 size (for sorting) + str, # 5 size (human readable) + int, # 6 isImage (for sorting) + gtk.gdk.Pixbuf, # 7 type (photo or video) + str, # 8 job code + gobject.TYPE_PYOBJECT, # 9 mediaFile (for data) + gtk.gdk.Pixbuf, # 10 status icon + int, # 11 status (downloaded, cannot download, etc, for sorting) + str, # 12 path (on the device) + str, # 13 device + int) # 14 thread id (worker the file is associated with) + + self.selected_rows = set() + + # sort by date (unless there is a problem) + self.liststore.set_sort_column_id(2, gtk.SORT_ASCENDING) + gtk.TreeView.__init__(self, self.liststore) + + selection = self.get_selection() + selection.set_mode(gtk.SELECTION_MULTIPLE) + selection.connect('changed', self.on_selection_changed) + + self.set_rubber_banding(True) + + # Status Column + # Indicates whether file was downloaded, or a warning or error of some kind + cell = gtk.CellRendererPixbuf() + cell.set_property("yalign", 0.5) + status_column = gtk.TreeViewColumn(_("Status"), cell, pixbuf=10) + status_column.set_sort_column_id(11) + status_column.connect('clicked', self.header_clicked) + self.append_column(status_column) + + # Type of file column i.e. photo or video (displays at user request) + cell = gtk.CellRendererPixbuf() + cell.set_property("yalign", 0.5) + self.type_column = gtk.TreeViewColumn(_("Type"), cell, pixbuf=7) + self.type_column.set_sort_column_id(6) + self.type_column.set_clickable(True) + self.type_column.connect('clicked', self.header_clicked) + self.append_column(self.type_column) + self.display_type_column(self.rapidApp.prefs.display_type_column) + + #File thumbnail column + if not DOWNLOAD_VIDEO: + title = _("Photo") + else: + title = _("File") + thumbnail_column = gtk.TreeViewColumn(title) + cellpb = gtk.CellRendererPixbuf() + if not DROP_SHADOW: + cellpb.set_fixed_size(60,50) + thumbnail_column.pack_start(cellpb, False) + thumbnail_column.set_attributes(cellpb, pixbuf=0) + thumbnail_column.set_sort_column_id(1) + thumbnail_column.set_clickable(True) + thumbnail_column.connect('clicked', self.header_clicked) + self.append_column(thumbnail_column) + + # Job code column + cell = gtk.CellRendererText() + cell.set_property("yalign", 0) + self.job_code_column = gtk.TreeViewColumn(_("Job Code"), cell, text=8) + self.job_code_column.set_sort_column_id(8) + self.job_code_column.set_resizable(True) + self.job_code_column.set_clickable(True) + self.job_code_column.connect('clicked', self.header_clicked) + self.append_column(self.job_code_column) + + # Date column + cell = gtk.CellRendererText() + cell.set_property("yalign", 0) + date_column = gtk.TreeViewColumn(_("Date"), cell, text=3) + date_column.set_sort_column_id(2) + date_column.set_resizable(True) + date_column.set_clickable(True) + date_column.connect('clicked', self.header_clicked) + self.append_column(date_column) + + # Size column (displays at user request) + cell = gtk.CellRendererText() + cell.set_property("yalign", 0) + self.size_column = gtk.TreeViewColumn(_("Size"), cell, text=5) + self.size_column.set_sort_column_id(4) + self.size_column.set_resizable(True) + self.size_column.set_clickable(True) + self.size_column.connect('clicked', self.header_clicked) + self.append_column(self.size_column) + self.display_size_column(self.rapidApp.prefs.display_size_column) + + # Device column (displays at user request) + cell = gtk.CellRendererText() + cell.set_property("yalign", 0) + self.device_column = gtk.TreeViewColumn(_("Device"), cell, text=13) + self.device_column.set_sort_column_id(13) + self.device_column.set_resizable(True) + self.device_column.set_clickable(True) + self.device_column.connect('clicked', self.header_clicked) + self.append_column(self.device_column) + self.display_device_column(self.rapidApp.prefs.display_device_column) + + # Filename column (displays at user request) + cell = gtk.CellRendererText() + cell.set_property("yalign", 0) + self.filename_column = gtk.TreeViewColumn(_("Filename"), cell, text=1) + self.filename_column.set_sort_column_id(1) + self.filename_column.set_resizable(True) + self.filename_column.set_clickable(True) + self.filename_column.connect('clicked', self.header_clicked) + self.append_column(self.filename_column) + self.display_filename_column(self.rapidApp.prefs.display_filename_column) + + # Path column (displays at user request) + cell = gtk.CellRendererText() + cell.set_property("yalign", 0) + self.path_column = gtk.TreeViewColumn(_("Path"), cell, text=12) + self.path_column.set_sort_column_id(12) + self.path_column.set_resizable(True) + self.path_column.set_clickable(True) + self.path_column.connect('clicked', self.header_clicked) + self.append_column(self.path_column) + self.display_path_column(self.rapidApp.prefs.display_path_column) + self.show_all() - self.connect('item-activated', self.on_item_activated) + # flag used to determine if a preview should be generated or not + # there is no point generating a preview for each photo when + # select all photos is called, for instance + self.suspend_previews = False + + self.user_has_clicked_header = False - def _setup_icons(self): # icons to be displayed in status column - size = 16 - # standard icons - failed = self.render_icon(gtk.STOCK_DIALOG_ERROR, gtk.ICON_SIZE_MENU) - self.download_failed_icon = failed.scale_simple(size, size, gtk.gdk.INTERP_HYPER) - error = self.render_icon(gtk.STOCK_DIALOG_ERROR, gtk.ICON_SIZE_MENU) - self.error_icon = error.scale_simple(size, size, gtk.gdk.INTERP_HYPER) - warning = self.render_icon(gtk.STOCK_DIALOG_WARNING, gtk.ICON_SIZE_MENU) - self.warning_icon = warning.scale_simple(size, size, gtk.gdk.INTERP_HYPER) - - # Rapid Photo Downloader specific icons - self.downloaded_icon = gtk.gdk.pixbuf_new_from_file_at_size( - paths.share_dir('glade3/rapid-photo-downloader-downloaded.svg'), - size, size) - self.download_pending_icon = gtk.gdk.pixbuf_new_from_file_at_size( - paths.share_dir('glade3/rapid-photo-downloader-download-pending.svg'), - size, size) - self.downloaded_with_warning_icon = gtk.gdk.pixbuf_new_from_file_at_size( - paths.share_dir('glade3/rapid-photo-downloader-downloaded-with-warning.svg'), - size, size) - self.downloaded_with_error_icon = gtk.gdk.pixbuf_new_from_file_at_size( - paths.share_dir('glade3/rapid-photo-downloader-downloaded-with-error.svg'), - size, size) + self.downloaded_icon = self.render_icon('rapid-photo-downloader-downloaded', gtk.ICON_SIZE_MENU) + self.download_failed_icon = self.render_icon(gtk.STOCK_DIALOG_ERROR, gtk.ICON_SIZE_MENU) + self.error_icon = self.render_icon(gtk.STOCK_DIALOG_ERROR, gtk.ICON_SIZE_MENU) + self.warning_icon = self.render_icon(gtk.STOCK_DIALOG_WARNING, gtk.ICON_SIZE_MENU) + + self.download_pending_icon = self.render_icon('rapid-photo-downloader-download-pending', gtk.ICON_SIZE_MENU) + self.downloaded_with_warning_icon = self.render_icon('rapid-photo-downloader-downloaded-with-warning', gtk.ICON_SIZE_MENU) + self.downloaded_with_error_icon = self.render_icon('rapid-photo-downloader-downloaded-with-error', gtk.ICON_SIZE_MENU) # make the not yet downloaded icon a transparent square self.not_downloaded_icon = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, False, 8, 16, 16) self.not_downloaded_icon.fill(0xffffffff) self.not_downloaded_icon = self.not_downloaded_icon.add_alpha(True, chr(255), chr(255), chr(255)) + # but make it be a tick in the preview pane + self.not_downloaded_icon_tick = self.render_icon(gtk.STOCK_YES, gtk.ICON_SIZE_MENU) + + #preload generic icons + self.icon_photo = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/photo24.png')) + self.icon_video = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/video24.png')) + #with shadows + if DROP_SHADOW: + self.generic_photo_thumbnail = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/photo_small_shadow.png')) + self.generic_video_thumbnail = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/video_small_shadow.png')) + self.iconDropShadow = DropShadow(offset=(3,3), shadow = (0x34, 0x34, 0x34, 0xff), border=6) + else: + self.generic_photo_thumbnail = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/photo_small.png')) + self.generic_video_thumbnail = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/video_small.png')) + + self.previewed_file_treerowref = None + self.icontheme = gtk.icon_theme_get_default() + - def get_status_icon(self, status): + + def get_thread(self, iter): + """ + Returns the thread associated with the liststore's iter + """ + return self.liststore.get_value(iter, 14) + + def get_status(self, iter): + """ + Returns the status associated with the liststore's iter + """ + return self.liststore.get_value(iter, 11) + + def get_mediaFile(self, iter): + """ + Returns the mediaFile associated with the liststore's iter + """ + return self.liststore.get_value(iter, 9) + + def get_is_image(self, iter): + """ + Returns the file type (is image or video) associated with the liststore's iter + """ + return self.liststore.get_value(iter, 6) + + def get_type_icon(self, iter): + """ + Returns the file type's pixbuf associated with the liststore's iter + """ + return self.liststore.get_value(iter, 7) + + def get_job_code(self, iter): + """ + Returns the job code associated with the liststore's iter + """ + return self.liststore.get_value(iter, 8) + + def get_status_icon(self, status, preview=False): """ Returns the correct icon, based on the status """ @@ -499,7 +3679,10 @@ class ThumbnailDisplay(gtk.IconView): elif status == STATUS_DOWNLOADED: status_icon = self.downloaded_icon elif status == STATUS_NOT_DOWNLOADED: - status_icon = self.not_downloaded_icon + if preview: + status_icon = self.not_downloaded_icon_tick + else: + status_icon = self.not_downloaded_icon elif status in [STATUS_DOWNLOADED_WITH_WARNING, STATUS_BACKUP_PROBLEM]: status_icon = self.downloaded_with_warning_icon elif status in [STATUS_DOWNLOAD_FAILED, STATUS_DOWNLOAD_AND_BACKUP_FAILED]: @@ -507,1196 +3690,1464 @@ class ThumbnailDisplay(gtk.IconView): elif status == STATUS_DOWNLOAD_PENDING: status_icon = self.download_pending_icon else: - logger.critical("FIXME: unknown status: %s", status) + sys.stderr.write("FIXME: unknown status: %s\n" % status) status_icon = self.not_downloaded_icon - return status_icon - - def sort_by_timestamp(self): - self.liststore.set_sort_column_id(self.TIMESTAMP_COL, gtk.SORT_ASCENDING) + return status_icon + + def get_tree_row_refs(self): + """ + Returns a list of all tree row references + """ + tree_row_refs = [] + iter = self.liststore.get_iter_first() + while iter: + tree_row_refs.append(self.get_mediaFile(iter).treerowref) + iter = self.liststore.iter_next(iter) + return tree_row_refs + + def get_selected_tree_row_refs(self): + """ + Returns a list of tree row references for the selected rows + """ + tree_row_refs = [] + selection = self.get_selection() + model, pathlist = selection.get_selected_rows() + for path in pathlist: + iter = self.liststore.get_iter(path) + tree_row_refs.append(self.get_mediaFile(iter).treerowref) + return tree_row_refs + + def get_tree_row_iters(self, selected_only=False): + """ + Yields tree row iters - def on_checkbutton_toggled(self, cellrenderertoggle, path): - iter = self.liststore.get_iter(path) - self.liststore.set_value(iter, self.SELECTED_COL, not cellrenderertoggle.get_active()) - self.rapid_app.set_download_action_sensitivity() + If selected_only is True, then only those from the selected + rows will be returned. - def set_selected(self, unique_id, value): - iter = self.get_iter_from_unique_id(unique_id) - self.liststore.set_value(iter, self.SELECTED_COL, value) + This function is essential when modifying any content + in the list store (because rows can easily be moved when their + content changes) + """ + if selected_only: + tree_row_refs = self.get_selected_tree_row_refs() + else: + tree_row_refs = self.get_tree_row_refs() + for reference in tree_row_refs: + path = reference.get_path() + yield self.liststore.get_iter(path) - def add_file(self, rpd_file): + def add_file(self, mediaFile): + if debug_info: + cmd_line('Adding file %s' % mediaFile.fullFileName) + + # metadata is loaded when previews are generated before downloading + if mediaFile.metadata: + date = mediaFile.dateTime() + timestamp = mediaFile.metadata.timeStamp(missing=None) + if timestamp is None: + timestamp = mediaFile.modificationTime + # if metadata has not been loaded, substitute other values + else: + timestamp = mediaFile.modificationTime + date = datetime.datetime.fromtimestamp(timestamp) - thumbnail_icon = self.get_stock_icon(rpd_file.file_type) - unique_id = rpd_file.unique_id - scan_pid = rpd_file.scan_pid + timestamp = int(timestamp) + + date_human_readable = date_time_human_readable(date) + name = mediaFile.name + size = mediaFile.size + thumbnail = mediaFile.thumbnail + + if mediaFile.genericThumbnail: + if mediaFile.isImage: + thumbnail_icon = self.generic_photo_thumbnail + else: + thumbnail_icon = self.generic_video_thumbnail + else: + thumbnail_icon = common.scale2pixbuf(60, 36, thumbnail) + + if DROP_SHADOW and not mediaFile.genericThumbnail: + pil_image = pixbuf_to_image(thumbnail_icon) + pil_image = self.iconDropShadow.dropShadow(pil_image) + thumbnail_icon = image_to_pixbuf(pil_image) - timestamp = int(rpd_file.modification_time) - - iter = self.liststore.append((thumbnail_icon, - True, - unique_id, - rpd_file.display_name, - timestamp, - rpd_file.file_type, - True, - STATUS_NOT_DOWNLOADED, - self.not_downloaded_icon - )) - - path = self.liststore.get_path(iter) - treerowref = gtk.TreeRowReference(self.liststore, path) - if scan_pid in self.process_index: - self.process_index[scan_pid].append(unique_id) + if mediaFile.isImage: + type_icon = self.icon_photo else: - self.process_index[scan_pid] = [unique_id,] - - self.treerow_index[unique_id] = treerowref - self.rpd_files[unique_id] = rpd_file + type_icon = self.icon_video + + status_icon = self.get_status_icon(mediaFile.status) + + if debug_info and False: + cmd_line('Thumbnail icon: %s' % thumbnail_icon) + cmd_line('Name: %s' % name) + cmd_line('Timestamp: %s' % timestamp) + cmd_line('Date: %s' % date_human_readable) + cmd_line('Size: %s %s' % (size, common.formatSizeForUser(size))) + cmd_line('Is an image: %s' % mediaFile.isImage) + cmd_line('Status: %s' % self.status_human_readable(mediaFile)) + cmd_line('Path: %s' % mediaFile.path) + cmd_line('Device name: %s' % mediaFile.deviceName) + cmd_line('Thread: %s' % mediaFile.thread_id) + cmd_line(' ') + + iter = self.liststore.append((thumbnail_icon, name, timestamp, date_human_readable, size, common.formatSizeForUser(size), mediaFile.isImage, type_icon, '', mediaFile, status_icon, mediaFile.status, mediaFile.path, mediaFile.deviceName, mediaFile.thread_id)) + + #create a reference to this row and store it in the mediaFile + path = self.liststore.get_path(iter) + mediaFile.treerowref = gtk.TreeRowReference(self.liststore, path) - self.total_files += 1 - - def get_sample_file(self, file_type): - """Returns an rpd_file for of a given file type, or None if it does - not exist""" - for unique_id, rpd_file in self.rpd_files.iteritems(): - if rpd_file.file_type == file_type: - if rpd_file.status <> STATUS_CANNOT_DOWNLOAD: - return rpd_file - - return None - - def get_unique_id_from_iter(self, iter): - return self.liststore.get_value(iter, 2) + if mediaFile.status in [STATUS_CANNOT_DOWNLOAD, STATUS_WARNING]: + if not self.user_has_clicked_header: + self.liststore.set_sort_column_id(11, gtk.SORT_DESCENDING) - def get_iter_from_unique_id(self, unique_id): - treerowref = self.treerow_index[unique_id] - path = treerowref.get_path() - return self.liststore.get_iter(path) - - def on_item_activated(self, iconview, path): + def no_selected_rows_available_for_download(self): """ + Gets the number of rows the user has selected that can actually + be downloaded, and the threads they are found in """ - iter = self.liststore.get_iter(path) - self.show_preview(iter=iter) - self.advance_get_preview_image(iter) - + v = 0 + threads = [] + model, paths = self.get_selection().get_selected_rows() + for path in paths: + iter = self.liststore.get_iter(path) + status = self.get_status(iter) + if status in [STATUS_NOT_DOWNLOADED, STATUS_WARNING]: + v += 1 + thread = self.get_thread(iter) + if thread not in threads: + threads.append(thread) + return v, threads + + def rows_available_for_download(self): + """ + Returns true if one or more rows has their status as STATUS_NOT_DOWNLOADED or STATUS_WARNING + """ + iter = self.liststore.get_iter_first() + while iter: + status = self.get_status(iter) + if status in [STATUS_NOT_DOWNLOADED, STATUS_WARNING]: + return True + iter = self.liststore.iter_next(iter) + return False - def _get_preview(self, unique_id, rpd_file): - if unique_id not in self.previews_being_fetched: - #check if preview should be from a downloaded file, or the source - if rpd_file.status in DOWNLOADED: - file_location = rpd_file.download_full_file_name - else: - file_location = rpd_file.full_file_name - self.preview_manager.get_preview(unique_id, file_location, - rpd_file.file_type, size_max=None,) - - self.previews_being_fetched.add(unique_id) - - def show_preview(self, unique_id=None, iter=None): - if unique_id is not None: - iter = self.get_iter_from_unique_id(unique_id) - elif iter is not None: - unique_id = self.get_unique_id_from_iter(iter) - else: - # neither an iter or a unique_id were passed - # use iter from first selected file - # if none is selected, choose the first file - selected = self.get_selected_items() - if selected: - path = selected[0] - else: - path = 0 + def update_download_selected_button(self): + """ + Updates the text on the Download Selection button, and set its sensitivity + """ + no_available_for_download = 0 + selection = self.get_selection() + model, paths = selection.get_selected_rows() + if paths: + path = paths[0] iter = self.liststore.get_iter(path) - unique_id = self.get_unique_id_from_iter(iter) + #update button text + no_available_for_download, threads = self.no_selected_rows_available_for_download() - rpd_file = self.rpd_files[unique_id] - - if unique_id in self.previews: - preview_image = self.previews[unique_id] + if no_available_for_download and workers.scanComplete(threads): + self.rapidApp.download_selected_button.set_label(self.rapidApp.DOWNLOAD_SELECTED_LABEL + " (%s)" % no_available_for_download) + self.rapidApp.download_selected_button.set_sensitive(True) else: - # request daemon process to get a full size thumbnail - self._get_preview(unique_id, rpd_file) - if unique_id in self.thumbnails: - preview_image = self.thumbnails[unique_id] - else: - preview_image = self.get_stock_icon(rpd_file.file_type) - - checked = self.liststore.get_value(iter, self.SELECTED_COL) - include_checkbutton_visible = rpd_file.status == STATUS_NOT_DOWNLOADED - self.rapid_app.show_preview_image(unique_id, preview_image, - include_checkbutton_visible, checked) + #nothing was selected, or nothing is available from what the user selected, or should not download right now + self.rapidApp.download_selected_button.set_label(self.rapidApp.DOWNLOAD_SELECTED_LABEL) + self.rapidApp.download_selected_button.set_sensitive(False) + + def on_selection_changed(self, selection): + """ + Update download selected button and preview the most recently + selected row in the treeview + """ + self.update_download_selected_button() + size = selection.count_selected_rows() + if size == 0: + self.selected_rows = set() + self.show_preview(None) + else: + if size <= len(self.selected_rows): + # discard everything, start over + self.selected_rows = set() + self.selection_size = size + model, paths = selection.get_selected_rows() + for path in paths: + iter = self.liststore.get_iter(path) + ref = self.get_mediaFile(iter).treerowref + + if ref not in self.selected_rows: + self.show_preview(iter) + self.selected_rows.add(ref) - def _get_next_iter(self, iter): - iter = self.liststore.iter_next(iter) - if iter is None: + def clear_all(self, thread_id = None): + if thread_id is None: + self.liststore.clear() + self.show_preview(None) + else: iter = self.liststore.get_iter_first() - return iter + while iter: + t = self.get_thread(iter) + if t == thread_id: + if self.previewed_file_treerowref: + mediaFile = self.get_mediaFile(iter) + if mediaFile.treerowref == self.previewed_file_treerowref: + self.show_preview(None) + self.liststore.remove(iter) + # need to start over, or else bad things happen + iter = self.liststore.get_iter_first() + else: + iter = self.liststore.iter_next(iter) + + def refreshSampleDownloadFolders(self, thread_id = None): + """ + Refreshes the download folder of every file that has not yet been downloaded + + This is useful when the user updates the preferences, and the scan has already occurred (or is occurring) + + If thread_id is specified, will only update rows with that thread + """ + for iter in self.get_tree_row_iters(): + status = self.get_status(iter) + if status in [STATUS_NOT_DOWNLOADED, STATUS_WARNING, STATUS_CANNOT_DOWNLOAD]: + regenerate = True + if thread_id is not None: + t = self.get_thread(iter) + regenerate = t == thread_id + + if regenerate: + mediaFile = self.get_mediaFile(iter) + if mediaFile.isImage: + mediaFile.downloadFolder = self.rapidApp.prefs.download_folder + else: + mediaFile.downloadFolder = self.rapidApp.prefs.video_download_folder + mediaFile.samplePath = os.path.join(mediaFile.downloadFolder, mediaFile.sampleSubfolder) + if mediaFile.treerowref == self.previewed_file_treerowref: + self.show_preview(iter) + + def _refreshNameFactories(self): + sample_download_start_time = datetime.datetime.now() + self.imageRenamePrefsFactory = rn.ImageRenamePreferences(self.rapidApp.prefs.image_rename, self, + self.rapidApp.fileSequenceLock, sequences) + self.imageRenamePrefsFactory.setDownloadStartTime(sample_download_start_time) + self.videoRenamePrefsFactory = rn.VideoRenamePreferences(self.rapidApp.prefs.video_rename, self, + self.rapidApp.fileSequenceLock, sequences) + self.videoRenamePrefsFactory.setDownloadStartTime(sample_download_start_time) + self.subfolderPrefsFactory = rn.SubfolderPreferences(self.rapidApp.prefs.subfolder, self) + self.subfolderPrefsFactory.setDownloadStartTime(sample_download_start_time) + self.videoSubfolderPrefsFactory = rn.VideoSubfolderPreferences(self.rapidApp.prefs.video_subfolder, self) + self.videoSubfolderPrefsFactory.setDownloadStartTime(sample_download_start_time) + self.strip_characters = self.rapidApp.prefs.strip_characters + + + def refreshGeneratedSampleSubfolderAndName(self, thread_id = None): + """ + Refreshes the name, subfolder and status of every file that has not yet been downloaded + + This is useful when the user updates the preferences, and the scan has already occurred (or is occurring) - def _get_prev_iter(self, iter): - row = self.liststore.get_path(iter)[0] - if row == 0: - row = len(self.liststore)-1 + If thread_id is specified, will only update rows with that thread + """ + self._setUsesJobCode() + self._refreshNameFactories() + for iter in self.get_tree_row_iters(): + status = self.get_status(iter) + if status in [STATUS_NOT_DOWNLOADED, STATUS_WARNING, STATUS_CANNOT_DOWNLOAD]: + regenerate = True + if thread_id is not None: + t = self.get_thread(iter) + regenerate = t == thread_id + + if regenerate: + mediaFile = self.get_mediaFile(iter) + self.generateSampleSubfolderAndName(mediaFile, iter) + if mediaFile.treerowref == self.previewed_file_treerowref: + self.show_preview(iter) + + def generateSampleSubfolderAndName(self, mediaFile, iter): + problem = pn.Problem() + if mediaFile.isImage: + fallback_date = None + subfolderPrefsFactory = self.subfolderPrefsFactory + renamePrefsFactory = self.imageRenamePrefsFactory + nameUsesJobCode = self.imageRenameUsesJobCode + subfolderUsesJobCode = self.imageSubfolderUsesJobCode else: - row -= 1 - iter = self.liststore.get_iter(row) - return iter + fallback_date = mediaFile.modificationTime + subfolderPrefsFactory = self.videoSubfolderPrefsFactory + renamePrefsFactory = self.videoRenamePrefsFactory + nameUsesJobCode = self.videoRenameUsesJobCode + subfolderUsesJobCode = self.videoSubfolderUsesJobCode + + renamePrefsFactory.setJobCode(self.get_job_code(iter)) + subfolderPrefsFactory.setJobCode(self.get_job_code(iter)) + + generateSubfolderAndName(mediaFile, problem, subfolderPrefsFactory, renamePrefsFactory, + nameUsesJobCode, subfolderUsesJobCode, + self.strip_characters, fallback_date) + if self.get_status(iter) != mediaFile.status: + self.liststore.set(iter, 11, mediaFile.status) + self.liststore.set(iter, 10, self.get_status_icon(mediaFile.status)) + mediaFile.sampleStale = False + + def _setUsesJobCode(self): + self.imageRenameUsesJobCode = rn.usesJobCode(self.rapidApp.prefs.image_rename) + self.imageSubfolderUsesJobCode = rn.usesJobCode(self.rapidApp.prefs.subfolder) + self.videoRenameUsesJobCode = rn.usesJobCode(self.rapidApp.prefs.video_rename) + self.videoSubfolderUsesJobCode = rn.usesJobCode(self.rapidApp.prefs.video_subfolder) - def show_next_image(self, unique_id): - iter = self.get_iter_from_unique_id(unique_id) - iter = self._get_next_iter(iter) - - if iter is not None: - self.show_preview(iter=iter) + + def status_human_readable(self, mediaFile): + if mediaFile.status == STATUS_DOWNLOADED: + v = _('%(filetype)s was downloaded successfully') % {'filetype': mediaFile.displayNameCap} + elif mediaFile.status == STATUS_DOWNLOAD_FAILED: + v = _('%(filetype)s was not downloaded') % {'filetype': mediaFile.displayNameCap} + elif mediaFile.status == STATUS_DOWNLOADED_WITH_WARNING: + v = _('%(filetype)s was downloaded with warnings') % {'filetype': mediaFile.displayNameCap} + elif mediaFile.status == STATUS_BACKUP_PROBLEM: + v = _('%(filetype)s was downloaded but there were problems backing up') % {'filetype': mediaFile.displayNameCap} + elif mediaFile.status == STATUS_DOWNLOAD_AND_BACKUP_FAILED: + v = _('%(filetype)s was neither downloaded nor backed up') % {'filetype': mediaFile.displayNameCap} + elif mediaFile.status == STATUS_NOT_DOWNLOADED: + v = _('%(filetype)s is ready to be downloaded') % {'filetype': mediaFile.displayNameCap} + elif mediaFile.status == STATUS_DOWNLOAD_PENDING: + v = _('%(filetype)s is about to be downloaded') % {'filetype': mediaFile.displayNameCap} + elif mediaFile.status == STATUS_WARNING: + v = _('%(filetype)s will be downloaded with warnings')% {'filetype': mediaFile.displayNameCap} + elif mediaFile.status == STATUS_CANNOT_DOWNLOAD: + v = _('%(filetype)s cannot be downloaded') % {'filetype': mediaFile.displayNameCap} + return v + + def show_preview(self, iter): - # cache next image - self.advance_get_preview_image(iter, prev=False, next=True) + if not iter: + # clear everything except the label Preview at the top + for widget in [self.parentApp.preview_original_name_label, + self.parentApp.preview_name_label, + self.parentApp.preview_status_label, + self.parentApp.preview_problem_title_label, + self.parentApp.preview_problem_label]: + widget.set_text('') + + for widget in [self.parentApp.preview_image, + self.parentApp.preview_name_label, + self.parentApp.preview_original_name_label, + self.parentApp.preview_status_label, + self.parentApp.preview_problem_title_label, + self.parentApp.preview_problem_label + ]: + widget.set_tooltip_text('') + + self.parentApp.preview_image.clear() + self.parentApp.preview_status_icon.clear() + self.parentApp.preview_destination_expander.hide() + self.parentApp.preview_device_expander.hide() + self.previewed_file_treerowref = None - def show_prev_image(self, unique_id): - iter = self.get_iter_from_unique_id(unique_id) - iter = self._get_prev_iter(iter) - - if iter is not None: - self.show_preview(iter=iter) + + elif not self.suspend_previews: + mediaFile = self.get_mediaFile(iter) - # cache next image - self.advance_get_preview_image(iter, prev=True, next=False) - + self.previewed_file_treerowref = mediaFile.treerowref - def advance_get_preview_image(self, iter, prev=True, next=True): - unique_ids = [] - if next: - next_iter = self._get_next_iter(iter) - unique_ids.append(self.get_unique_id_from_iter(next_iter)) + self.parentApp.set_base_preview_image(mediaFile.thumbnail) + thumbnail = self.parentApp.scaledPreviewImage() + + self.parentApp.preview_image.set_from_pixbuf(thumbnail) - if prev: - prev_iter = self._get_prev_iter(iter) - unique_ids.append(self.get_unique_id_from_iter(prev_iter)) + image_tool_tip = "%s\n%s" % (date_time_human_readable(mediaFile.dateTime(), False), common.formatSizeForUser(mediaFile.size)) + self.parentApp.preview_image.set_tooltip_text(image_tool_tip) + + if mediaFile.sampleStale and mediaFile.status in [STATUS_NOT_DOWNLOADED, STATUS_WARNING]: + self._refreshNameFactories() + self._setUsesJobCode() + self.generateSampleSubfolderAndName(mediaFile, iter) + + self.parentApp.preview_original_name_label.set_text(mediaFile.name) + self.parentApp.preview_original_name_label.set_tooltip_text(mediaFile.name) + if mediaFile.volume: + pixbuf = mediaFile.volume.get_icon_pixbuf(16) + else: + pixbuf = self.icontheme.load_icon('gtk-harddisk', 16, gtk.ICON_LOOKUP_USE_BUILTIN) + self.parentApp.preview_device_image.set_from_pixbuf(pixbuf) + self.parentApp.preview_device_label.set_text(mediaFile.deviceName) + self.parentApp.preview_device_path_label.set_text(mediaFile.path) + self.parentApp.preview_device_path_label.set_tooltip_text(mediaFile.path) - for unique_id in unique_ids: - if not unique_id in self.previews: - rpd_file = self.rpd_files[unique_id] - self._get_preview(unique_id, rpd_file) + if using_gio: + folder = gio.File(mediaFile.downloadFolder) + fileInfo = folder.query_info(gio.FILE_ATTRIBUTE_STANDARD_ICON) + icon = fileInfo.get_icon() + pixbuf = common.get_icon_pixbuf(using_gio, icon, 16, fallback='folder') + else: + pixbuf = self.icontheme.load_icon('folder', 16, gtk.ICON_LOOKUP_USE_BUILTIN) + + self.parentApp.preview_destination_image.set_from_pixbuf(pixbuf) + downloadFolderName = os.path.split(mediaFile.downloadFolder)[1] + self.parentApp.preview_destination_label.set_text(downloadFolderName) + + if mediaFile.status in [STATUS_WARNING, STATUS_CANNOT_DOWNLOAD, STATUS_NOT_DOWNLOADED, STATUS_DOWNLOAD_PENDING]: + + self.parentApp.preview_name_label.set_text(mediaFile.sampleName) + self.parentApp.preview_name_label.set_tooltip_text(mediaFile.sampleName) + self.parentApp.preview_destination_path_label.set_text(mediaFile.samplePath) + self.parentApp.preview_destination_path_label.set_tooltip_text(mediaFile.samplePath) + else: + self.parentApp.preview_name_label.set_text(mediaFile.downloadName) + self.parentApp.preview_name_label.set_tooltip_text(mediaFile.downloadName) + self.parentApp.preview_destination_path_label.set_text(mediaFile.downloadPath) + self.parentApp.preview_destination_path_label.set_tooltip_text(mediaFile.downloadPath) + + status_text = self.status_human_readable(mediaFile) + self.parentApp.preview_status_icon.set_from_pixbuf(self.get_status_icon(mediaFile.status, preview=True)) + self.parentApp.preview_status_label.set_markup('<b>' + status_text + '</b>') + self.parentApp.preview_status_label.set_tooltip_text(status_text) + + + if mediaFile.status in [STATUS_WARNING, STATUS_DOWNLOAD_FAILED, + STATUS_DOWNLOADED_WITH_WARNING, + STATUS_CANNOT_DOWNLOAD, + STATUS_BACKUP_PROBLEM, + STATUS_DOWNLOAD_AND_BACKUP_FAILED]: + problem_title = mediaFile.problem.get_title() + self.parentApp.preview_problem_title_label.set_markup('<i>' + problem_title + '</i>') + self.parentApp.preview_problem_title_label.set_tooltip_text(problem_title) + + problem_text = mediaFile.problem.get_problems() + self.parentApp.preview_problem_label.set_text(problem_text) + self.parentApp.preview_problem_label.set_tooltip_text(problem_text) + else: + self.parentApp.preview_problem_label.set_markup('') + self.parentApp.preview_problem_title_label.set_markup('') + for widget in [self.parentApp.preview_problem_title_label, + self.parentApp.preview_problem_label + ]: + widget.set_tooltip_text('') + + if self.rapidApp.prefs.display_preview_folders: + self.parentApp.preview_destination_expander.show() + self.parentApp.preview_device_expander.show() + + + def select_rows(self, range): + selection = self.get_selection() + if range == 'all': + selection.select_all() + elif range == 'none': + selection.unselect_all() + else: + # User chose to select all photos or all videos, + # or select all files with or without job codes. + + # Temporarily suspend previews while a large number of rows + # are being selected / unselected + self.suspend_previews = True - def check_all(self, check_all, file_type=None): - for row in self.liststore: - if row[self.CHECKBUTTON_VISIBLE_COL]: - if file_type is not None: - if row[self.FILETYPE_COL] == file_type: - row[self.SELECTED_COL] = check_all + iter = self.liststore.get_iter_first() + while iter is not None: + if range in ['photos', 'videos']: + type = self.get_is_image(iter) + select_row = (type and range == 'photos') or (not type and range == 'videos') + else: + job_code = self.get_job_code(iter) + select_row = (job_code and range == 'withjobcode') or (not job_code and range == 'withoutjobcode') + + if select_row: + selection.select_iter(iter) else: - row[self.SELECTED_COL] = check_all - self.rapid_app.set_download_action_sensitivity() + selection.unselect_iter(iter) + iter = self.liststore.iter_next(iter) - def files_are_checked_to_download(self): + self.suspend_previews = False + # select the first photo / video + iter = self.liststore.get_iter_first() + while iter is not None: + type = self.get_is_image(iter) + if (type and range == 'photos') or (not type and range == 'videos'): + self.show_preview(iter) + break + iter = self.liststore.iter_next(iter) + + + def header_clicked(self, column): + self.user_has_clicked_header = True + + def display_filename_column(self, display): """ - Returns True if there is any file that the user has indicated they - intend to download, else returns False. + if display is true, the column will be shown + otherwise, it will not be shown """ - for row in self.liststore: - if row[self.SELECTED_COL]: - rpd_file = self.rpd_files[row[self.UNIQUE_ID_COL]] - if rpd_file.status not in DOWNLOADED: - return True - return False + self.filename_column.set_visible(display) - def get_files_checked_for_download(self): + def display_size_column(self, display): + self.size_column.set_visible(display) + + def display_type_column(self, display): + if not DOWNLOAD_VIDEO: + self.type_column.set_visible(False) + else: + self.type_column.set_visible(display) + + def display_path_column(self, display): + self.path_column.set_visible(display) + + def display_device_column(self, display): + self.device_column.set_visible(display) + + def apply_job_code(self, job_code, overwrite=True, to_all_rows=False, thread_id=None): """ - Returns a dict of scan ids and associated files the user has indicated - they want to download + Applies the Job code to the selected rows, or all rows if to_all_rows is True. + + If overwrite is True, then it will overwrite any existing job code. """ - files = dict() - for row in self.liststore: - if row[self.SELECTED_COL]: - rpd_file = self.rpd_files[row[self.UNIQUE_ID_COL]] - if rpd_file.status not in DOWNLOADED: - scan_pid = rpd_file.scan_pid - if scan_pid in files: - files[scan_pid].append(rpd_file) + + def _apply_job_code(): + status = self.get_status(iter) + if status in [STATUS_DOWNLOAD_PENDING, STATUS_WARNING, STATUS_NOT_DOWNLOADED]: + + if mediaFile.isImage: + apply = rn.usesJobCode(self.rapidApp.prefs.image_rename) or rn.usesJobCode(self.rapidApp.prefs.subfolder) + else: + apply = rn.usesJobCode(self.rapidApp.prefs.video_rename) or rn.usesJobCode(self.rapidApp.prefs.video_subfolder) + if apply: + if overwrite: + self.liststore.set(iter, 8, job_code) + mediaFile.jobcode = job_code + mediaFile.sampleStale = True else: - files[scan_pid] = [rpd_file,] + if not self.get_job_code(iter): + self.liststore.set(iter, 8, job_code) + mediaFile.jobcode = job_code + mediaFile.sampleStale = True + else: + pass + #if they got an existing job code, may as well keep it there in case the user + #reactivates job codes again in their prefs - return files - - - def mark_download_pending(self, files_by_scan_pid): + if to_all_rows or thread_id is not None: + for iter in self.get_tree_row_iters(): + apply = True + if thread_id is not None: + t = self.get_thread(iter) + apply = t == thread_id + + if apply: + mediaFile = self.get_mediaFile(iter) + _apply_job_code() + if mediaFile.treerowref == self.previewed_file_treerowref: + self.show_preview(iter) + else: + for iter in self.get_tree_row_iters(selected_only = True): + mediaFile = self.get_mediaFile(iter) + _apply_job_code() + if mediaFile.treerowref == self.previewed_file_treerowref: + self.show_preview(iter) + + def job_code_missing(self, selected_only): """ - Sets status to download pending and updates thumbnails display + Returns True if any of the pending downloads do not have a + job code assigned. + + If selected_only is True, will only check in rows that the + user has selected. """ - for scan_pid in files_by_scan_pid: - for rpd_file in files_by_scan_pid[scan_pid]: - unique_id = rpd_file.unique_id - self.rpd_files[unique_id].status = STATUS_DOWNLOAD_PENDING - iter = self.get_iter_from_unique_id(unique_id) - self.liststore.set_value(iter, self.CHECKBUTTON_VISIBLE_COL, False) - self.liststore.set_value(iter, self.SELECTED_COL, False) - self.liststore.set_value(iter, self.DOWNLOAD_STATUS_COL, STATUS_DOWNLOAD_PENDING) - icon = self.get_status_icon(STATUS_DOWNLOAD_PENDING) - self.liststore.set_value(iter, self.STATUS_ICON_COL, icon) - - def select_image(self, unique_id): - iter = self.get_iter_from_unique_id(unique_id) - path = self.liststore.get_path(iter) - self.select_path(path) - self.scroll_to_path(path, use_align=False, row_align=0.5, col_align=0.5) - def get_stock_icon(self, file_type): - if file_type == rpdfile.FILE_TYPE_PHOTO: - return self.stock_photo_thumbnails.stock_thumbnail_image_icon + def _job_code_missing(iter): + status = self.get_status(iter) + if status in [STATUS_WARNING, STATUS_NOT_DOWNLOADED]: + is_image = self.get_is_image(iter) + job_code = self.get_job_code(iter) + return needAJobCode.needAJobCode(job_code, is_image) + return False + + self._setUsesJobCode() + needAJobCode = NeedAJobCode(self.rapidApp.prefs) + + v = False + if selected_only: + selection = self.get_selection() + model, pathlist = selection.get_selected_rows() + for path in pathlist: + iter = self.liststore.get_iter(path) + v = _job_code_missing(iter) + if v: + break else: - return self.stock_video_thumbnails.stock_thumbnail_image_icon - - def update_status_post_download(self, rpd_file): - iter = self.get_iter_from_unique_id(rpd_file.unique_id) - self.liststore.set_value(iter, self.DOWNLOAD_STATUS_COL, rpd_file.status) - icon = self.get_status_icon(rpd_file.status) - self.liststore.set_value(iter, self.STATUS_ICON_COL, icon) - self.rpd_files[rpd_file.unique_id] = rpd_file - - def generate_thumbnails(self, scan_pid): - """Initiate thumbnail generation for files scanned in one process - """ - rpd_files = [self.rpd_files[unique_id] for unique_id in self.process_index[scan_pid]] - self.thumbnail_manager.add_task(rpd_files) + iter = self.liststore.get_iter_first() + while iter: + v = _job_code_missing(iter) + if v: + break + iter = self.liststore.iter_next(iter) + return v + - def update_thumbnail(self, thumbnail_data): + def _set_download_pending(self, iter, threads): + existing_status = self.get_status(iter) + if existing_status in [STATUS_WARNING, STATUS_NOT_DOWNLOADED]: + self.liststore.set(iter, 11, STATUS_DOWNLOAD_PENDING) + self.liststore.set(iter, 10, self.download_pending_icon) + # this value is in a thread's list of files to download + mediaFile = self.get_mediaFile(iter) + # each thread will see this change in status + mediaFile.status = STATUS_DOWNLOAD_PENDING + thread = self.get_thread(iter) + if thread not in threads: + threads.append(thread) + + def set_status_to_download_pending(self, selected_only, thread_id=None): """ - Takes the generated thumbnail and updates the display + Sets status of files to be download pending, if they are waiting to be downloaded + if selected_only is true, only applies to selected rows + + If thread_id is not None, then after the statuses have been set, + the thread will be restarted (this is intended for the cases + where this method is called from a thread and auto start is True) - If the thumbnail_data includes a second image, that is used to - update the thumbnail list using the unique_id + Returns a list of threads which can be downloaded """ - unique_id = thumbnail_data[0] - thumbnail_icon = thumbnail_data[1] + threads = [] - if thumbnail_icon is not None: - # get the thumbnail icon in PIL format - thumbnail_icon = thumbnail_icon.get_image() - - treerowref = self.treerow_index[unique_id] - path = treerowref.get_path() + if selected_only: + for iter in self.get_tree_row_iters(selected_only = True): + self._set_download_pending(iter, threads) + else: + for iter in self.get_tree_row_iters(): + apply = True + if thread_id is not None: + t = self.get_thread(iter) + apply = t == thread_id + if apply: + self._set_download_pending(iter, threads) + + if thread_id is not None: + # restart the thread + workers[thread_id].startStop() + return threads + + def update_status_post_download(self, treerowref): + path = treerowref.get_path() + if not path: + sys.stderr.write("FIXME: SelectionTreeView treerowref no longer refers to valid row\n") + else: iter = self.liststore.get_iter(path) + mediaFile = self.get_mediaFile(iter) + status = mediaFile.status + self.liststore.set(iter, 11, status) + self.liststore.set(iter, 10, self.get_status_icon(status)) - if thumbnail_icon: - self.liststore.set(iter, 0, thumbnail_icon) - - if len(thumbnail_data) > 2: - # get the 2nd image in PIL format - self.thumbnails[unique_id] = thumbnail_data[2].get_image() + # If this row is currently previewed, then should update the preview + if mediaFile.treerowref == self.previewed_file_treerowref: + self.show_preview(iter) - - def thumbnail_results(self, source, condition): - connection = self.thumbnail_manager.get_pipe(source) + +class SelectionVBox(gtk.VBox): + """ + Dialog from which the user can select photos and videos to download + """ + + + def __init__(self, parentApp): + """ + Initialize values for log dialog, but do not display. + """ - conn_type, data = connection.recv() + gtk.VBox.__init__(self) + self.parentApp = parentApp - if conn_type == rpdmp.CONN_COMPLETE: - connection.close() - return False - else: - - for thumbnail_data in data: - self.update_thumbnail(thumbnail_data) - - self.thumbnails_generated += len(data) - - # clear progress bar information if all thumbnails have been - # extracted - if self.thumbnails_generated == self.total_files: - self.rapid_app.download_progressbar.set_fraction(0.0) - self.rapid_app.download_progressbar.set_text('') - else: - self.rapid_app.download_progressbar.set_fraction( - float(self.thumbnails_generated) / self.total_files) - + tiny_screen = TINY_SCREEN + if tiny_screen: + config.max_thumbnail_size = 160 - return True + selection_scrolledwindow = gtk.ScrolledWindow() + selection_scrolledwindow.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + selection_viewport = gtk.Viewport() - def preview_results(self, unique_id, preview_full_size, preview_small): - """ - Receive a full size preview image and update - """ - self.previews_being_fetched.remove(unique_id) - if preview_full_size: - preview_image = preview_full_size.get_image() - self.previews[unique_id] = preview_image - self.rapid_app.update_preview_image(unique_id, preview_image) - - - def clear_all(self, scan_pid=None, keep_downloaded_files=False): - """ - Removes files from display and internal tracking. - If scan_pid is not None, then only files matching that scan_pid will - be removed. Otherwise, everything will be removed. + self.selection_treeview = SelectionTreeView(self) - If keep_downloaded_files is True, files will not be removed if they - have been downloaded. - """ - if scan_pid is None and not keep_downloaded_files: - self.liststore.clear() - self.treerow_index = {} - self.process_index = {} - - self.rpd_files = {} - else: - if scan_pid in self.process_index: - for unique_id in self.process_index[scan_pid]: - rpd_file = self.rpd_files[unique_id] - if not keep_downloaded_files or not rpd_file.status in DOWNLOADED: - treerowref = self.treerow_index[rpd_file.unique_id] - path = treerowref.get_path() - iter = self.liststore.get_iter(path) - self.liststore.remove(iter) - del self.treerow_index[rpd_file.unique_id] - del self.rpd_files[rpd_file.unique_id] - if not keep_downloaded_files or not len(self.process_index[scan_pid]): - del self.process_index[scan_pid] - -class TaskManager: - def __init__(self, results_callback, batch_size): - self.results_callback = results_callback - - # List of actual process, it's terminate_queue, and it's run_event - self._processes = [] - - self._pipes = {} - self.batch_size = batch_size - - self.paused = False - - - def add_task(self, task): - pid = self._setup_task(task) - logger.debug("TaskManager PID: %s", pid) - return pid + selection_scrolledwindow.add(self.selection_treeview) + + # Job code controls + self.add_job_code_combo() + left_pane_vbox = gtk.VBox(spacing = 12) + left_pane_vbox.pack_start(selection_scrolledwindow, True, True) + left_pane_vbox.pack_start(self.job_code_hbox, False, True) + + # Window sizes + #selection_scrolledwindow.set_size_request(350, -1) - def _setup_task(self, task): - task_results_conn, task_process_conn = Pipe(duplex=False) - source = task_results_conn.fileno() - self._pipes[source] = task_results_conn - gobject.io_add_watch(source, gobject.IO_IN, self.results_callback) + # Preview pane - terminate_queue = Queue() - run_event = Event() - run_event.set() + # Zoom in and out slider (make the image bigger / smaller) - return self._initiate_task(task, task_process_conn, terminate_queue, run_event) + # Zoom out (on the left of the slider) + self.zoom_out_eventbox = gtk.EventBox() + self.zoom_out_eventbox.set_events(gtk.gdk.BUTTON_PRESS_MASK) + self.zoom_out_image = gtk.Image() + self.zoom_out_image.set_from_file(paths.share_dir('glade3/zoom-out.png')) + self.zoom_out_eventbox.add(self.zoom_out_image) + self.zoom_out_eventbox.connect("button_press_event", self.zoom_out_0_callback) - def _initiate_task(self, task, task_process_conn, terminate_queue, run_event): - logger.error("Implement child class method!") + # Zoom in (on the right of the slider) + self.zoom_in_eventbox = gtk.EventBox() + self.zoom_in_eventbox.set_events(gtk.gdk.BUTTON_PRESS_MASK) + self.zoom_in_image = gtk.Image() + self.zoom_in_image.set_from_file(paths.share_dir('glade3/zoom-in.png')) + self.zoom_in_eventbox.add(self.zoom_in_image) + self.zoom_in_eventbox.connect("button_press_event", self.zoom_in_100_callback) - - def processes(self): - for i in range(len(self._processes)): - yield self._processes[i] - - def start(self): - self.paused = False - for scan in self.processes(): - run_event = scan[2] - if not run_event.is_set(): - run_event.set() - - def pause(self): - self.paused = True - for scan in self.processes(): - run_event = scan[2] - if run_event.is_set(): - run_event.clear() - - def request_termination(self): - """ - Send a signal to processes that they should immediately terminate - """ - requested = False - for p in self.processes(): - if p[0].is_alive(): - requested = True - p[1].put(None) - # The process might be paused: let it run - run_event = p[2] - if not run_event.is_set(): - run_event.set() - - return requested - - def terminate_forcefully(self): - """ - Forcefully terminates any running processes. Use with great caution. - No cleanup action is performed. + self.slider_adjustment = gtk.Adjustment(value=self.parentApp.prefs.preview_zoom, + lower=config.MIN_THUMBNAIL_SIZE, upper=config.max_thumbnail_size, + step_incr=1.0, page_incr=config.THUMBNAIL_INCREMENT, page_size=0) + self.slider_adjustment.connect("value_changed", self.resize_image_callback) + self.slider_hscale = gtk.HScale(self.slider_adjustment) + self.slider_hscale.set_draw_value(False) # don't display numeric value + self.slider_hscale.set_size_request(config.MIN_THUMBNAIL_SIZE * 2, -1) - As python essential reference (4th edition) says, if the process - 'holds a lock or is involved with interprocess communication, - terminating it might cause a deadlock or corrupted I/O.' - """ - for p in self.processes(): - if p[0].is_alive(): - p[0].terminate() + #Preview image + self.base_preview_image = None # large size image used to scale down from + self.preview_image = gtk.Image() - - def get_pipe(self, source): - return self._pipes[source] + self.preview_image.set_alignment(0, 0.5) + #leave room for thumbnail shadow + if DROP_SHADOW: + self.cacheDropShadow() + else: + self.shadow_size = 0 - def get_no_active_processes(self): - """ - Returns how many processes are currently active, i.e. running - """ - i = 0 - for p in self.processes(): - if p[0].is_alive(): - i += 1 - return i - - -class ScanManager(TaskManager): - - def __init__(self, results_callback, batch_size, generate_folder, - add_device_function): - TaskManager.__init__(self, results_callback, batch_size) - self.add_device_function = add_device_function - self.generate_folder = generate_folder + image_size, shadow_size, offset = self._imageAndShadowSize() - def _initiate_task(self, device, task_process_conn, terminate_queue, run_event): - scan = scan_process.Scan(device.get_path(), self.batch_size, self.generate_folder, - task_process_conn, terminate_queue, run_event) - scan.start() - self._processes.append((scan, terminate_queue, run_event)) - self.add_device_function(scan.pid, device, - # This refers to when a device like a hard drive is having its contents scanned, - # looking for photos or videos. It is visible initially in the progress bar for each device - # (which normally holds "x photos and videos"). - # It maybe displayed only briefly if the contents of the device being scanned is small. - progress_bar_text=_('scanning...')) - - return scan.pid - -class CopyFilesManager(TaskManager): - - def _initiate_task(self, task, task_process_conn, terminate_queue, run_event): - photo_download_folder = task[0] - video_download_folder = task[1] - scan_pid = task[2] - files = task[3] + self.preview_image.set_size_request(image_size, image_size) - copy_files = copyfiles.CopyFiles(photo_download_folder, - video_download_folder, - files, scan_pid, self.batch_size, - task_process_conn, terminate_queue, run_event) - copy_files.start() - self._processes.append((copy_files, terminate_queue, run_event)) - return copy_files.pid + #labels to display file information -class ThumbnailManager(TaskManager): + #Original filename + self.preview_original_name_label = gtk.Label() + self.preview_original_name_label.set_alignment(0, 0.5) + self.preview_original_name_label.set_ellipsize(pango.ELLIPSIZE_END) - def _initiate_task(self, files, task_process_conn, terminate_queue, run_event): - generator = tn.GenerateThumbnails(files, self.batch_size, task_process_conn, terminate_queue, run_event) - generator.start() - self._processes.append((generator, terminate_queue, run_event)) - return generator.pid - - -class SingleInstanceTaskManager: - """ - Base class to manage single instance processes. Examples are daemon - processes, but also a non-daemon process that has one simple task. - - Core (infrastructure) functionality is implemented in this class. - Derived classes should implemented functionality to actually implement - specific tasks. - """ - def __init__(self, results_callback): - self.results_callback = results_callback - self.task_results_conn, self.task_process_conn = Pipe(duplex=True) + #Device (where it will be downloaded from) + self.preview_device_expander = gtk.Expander() + self.preview_device_label = gtk.Label() + self.preview_device_label.set_alignment(0, 0.5) + self.preview_device_image = gtk.Image() - source = self.task_results_conn.fileno() - gobject.io_add_watch(source, gobject.IO_IN, self.task_results) - + self.preview_device_path_label = gtk.Label() + self.preview_device_path_label.set_alignment(0, 0.5) + self.preview_device_path_label.set_ellipsize(pango.ELLIPSIZE_MIDDLE) + self.preview_device_path_label.set_padding(30, 0) + self.preview_device_expander.add(self.preview_device_path_label) -class PreviewManager(SingleInstanceTaskManager): - def __init__(self, results_callback): - SingleInstanceTaskManager.__init__(self, results_callback) - self._get_preview = tn.GetPreviewImage(self.task_process_conn) - self._get_preview.start() + device_hbox = gtk.HBox(False, spacing = 6) + device_hbox.pack_start(self.preview_device_image) + device_hbox.pack_start(self.preview_device_label, True, True) - def get_preview(self, unique_id, full_file_name, file_type, size_max): - self.task_results_conn.send((unique_id, full_file_name, file_type, size_max)) + self.preview_device_expander.set_label_widget(device_hbox) - def task_results(self, source, condition): - unique_id, preview_full_size, preview_small = self.task_results_conn.recv() - self.results_callback(unique_id, preview_full_size, preview_small) - return True + #Filename that has been generated + self.preview_name_label = gtk.Label() + self.preview_name_label.set_alignment(0, 0.5) + self.preview_name_label.set_ellipsize(pango.ELLIPSIZE_END) -class SubfolderFileManager(SingleInstanceTaskManager): - """ - Manages the daemon process that renames files and creates subfolders - """ - def __init__(self, results_callback, sequence_values): - SingleInstanceTaskManager.__init__(self, results_callback) - self._subfolder_file = subfolderfile.SubfolderFile(self.task_process_conn, sequence_values) - self._subfolder_file.start() - logger.debug("SubfolderFile PID: %s", self._subfolder_file.pid) - - def rename_file_and_move_to_subfolder(self, download_succeeded, - download_count, rpd_file): - - self.task_results_conn.send((download_succeeded, download_count, - rpd_file)) - logger.debug("Download count: %s.", download_count) - - - def task_results(self, source, condition): - move_succeeded, rpd_file = self.task_results_conn.recv() - self.results_callback(move_succeeded, rpd_file) - return True + #Download destination + self.preview_destination_expander = gtk.Expander() + self.preview_destination_label = gtk.Label() + self.preview_destination_label.set_alignment(0, 0.5) + self.preview_destination_image = gtk.Image() + self.preview_destination_path_label = gtk.Label() + self.preview_destination_path_label.set_alignment(0, 0.5) + self.preview_destination_path_label.set_ellipsize(pango.ELLIPSIZE_MIDDLE) + self.preview_destination_path_label.set_padding(30, 0) + self.preview_destination_expander.add(self.preview_destination_path_label) - -class ResizblePilImage(gtk.DrawingArea): - def __init__(self, bg_color=None): - gtk.DrawingArea.__init__(self) - self.base_image = None - self.bg_color = bg_color - self.connect('expose_event', self.expose) + destination_hbox = gtk.HBox(False, spacing = 6) + destination_hbox.pack_start(self.preview_destination_image) + destination_hbox.pack_start(self.preview_destination_label, True, True) - def set_image(self, image): - self.base_image = image + self.preview_destination_expander.set_label_widget(destination_hbox) + - #set up sizes and ratio used for drawing the derived image - self.base_image_w = self.base_image.size[0] - self.base_image_h = self.base_image.size[1] - self.base_image_aspect = float(self.base_image_w) / self.base_image_h + #Status of the file - self.queue_draw() + self.preview_status_icon = gtk.Image() + self.preview_status_icon.set_size_request(16,16) + + self.preview_status_label = gtk.Label() + self.preview_status_label.set_alignment(0, 0.5) + self.preview_status_label.set_ellipsize(pango.ELLIPSIZE_END) + self.preview_status_label.set_padding(12, 0) + + #Title of problems encountered in generating the name / subfolder + self.preview_problem_title_label = gtk.Label() + self.preview_problem_title_label.set_alignment(0, 0.5) + self.preview_problem_title_label.set_ellipsize(pango.ELLIPSIZE_END) + self.preview_problem_title_label.set_padding(12, 0) - def expose(self, widget, event): + #Details of what the problem(s) are + self.preview_problem_label = gtk.Label() + self.preview_problem_label.set_alignment(0, 0) + self.preview_problem_label.set_line_wrap(True) + self.preview_problem_label.set_padding(12, 0) + #Can't combine wrapping and ellipsize, sadly + #self.preview_problem_label.set_ellipsize(pango.ELLIPSIZE_END) + + #Put content into table + # Use a table so we can do the Gnome HIG layout more easily + self.preview_table = gtk.Table(10, 4) + self.preview_table.set_row_spacings(12) + left_spacer = gtk.Label('') + left_spacer.set_padding(12, 0) + right_spacer = gtk.Label('') + right_spacer.set_padding(6, 0) + + + spacer2 = gtk.Label('') + + #left and right spacers + self.preview_table.attach(left_spacer, 0, 1, 1, 2, xoptions=gtk.SHRINK, yoptions=gtk.SHRINK) + self.preview_table.attach(right_spacer, 3, 4, 1, 2, xoptions=gtk.SHRINK, yoptions=gtk.SHRINK) + + row = 0 + zoom_hbox = gtk.HBox() + zoom_hbox.pack_start(self.zoom_out_eventbox, False, False) + zoom_hbox.pack_start(self.slider_hscale, False, False) + zoom_hbox.pack_start(self.zoom_in_eventbox, False, False) + + self.preview_table.attach(zoom_hbox, 1, 3, row, row+1, yoptions=gtk.SHRINK) + + row += 1 + self.preview_table.attach(self.preview_image, 1, 3, row, row+1, yoptions=gtk.SHRINK) + row += 1 + + self.preview_table.attach(self.preview_original_name_label, 1, 3, row, row+1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=gtk.SHRINK) + row += 1 + if not tiny_screen: + self.preview_table.attach(self.preview_device_expander, 1, 3, row, row+1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=gtk.SHRINK) + row += 1 + + self.preview_table.attach(self.preview_name_label, 1, 3, row, row+1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=gtk.SHRINK) + row += 1 + if not tiny_screen: + self.preview_table.attach(self.preview_destination_expander, 1, 3, row, row+1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=gtk.SHRINK) + row += 1 + + if not tiny_screen: + self.preview_table.attach(spacer2, 0, 7, row, row+1, yoptions=gtk.SHRINK) + row += 1 + + self.preview_table.attach(self.preview_status_icon, 1, 2, row, row+1, xoptions=gtk.SHRINK, yoptions=gtk.SHRINK) + self.preview_table.attach(self.preview_status_label, 2, 3, row, row+1, yoptions=gtk.SHRINK) + row += 1 + + self.preview_table.attach(self.preview_problem_title_label, 2, 3, row, row+1, yoptions=gtk.SHRINK) + row += 1 + self.preview_table.attach(self.preview_problem_label, 2, 4, row, row+1, xoptions=gtk.EXPAND|gtk.FILL, yoptions=gtk.EXPAND|gtk.FILL) + row += 1 + + self.file_hpaned = gtk.HPaned() + self.file_hpaned.pack1(left_pane_vbox, shrink=False) + self.file_hpaned.pack2(self.preview_table, resize=True, shrink=False) + self.pack_start(self.file_hpaned, True, True) + if self.parentApp.prefs.hpaned_pos > 0: + self.file_hpaned.set_position(self.parentApp.prefs.hpaned_pos) + else: + # this is what the user will see the first time they run the app + self.file_hpaned.set_position(300) - cairo_context = self.window.cairo_create() + self.show_all() + + + def set_base_preview_image(self, pixbuf): + """ + sets the unscaled pixbuf image to be displayed to the user + the actual image the user will see will depend on the scale + they've set to view it at + """ + self.base_preview_image = pixbuf - x = event.area.x - y = event.area.y - w = event.area.width - h = event.area.height + def zoom_in(self): + self.slider_adjustment.set_value(min([config.max_thumbnail_size, int(self.slider_adjustment.get_value()) + config.THUMBNAIL_INCREMENT])) - #constrain operations to event area - cairo_context.rectangle(x, y, w, h) - cairo_context.clip_preserve() + def zoom_out(self): + self.slider_adjustment.set_value(max([config.MIN_THUMBNAIL_SIZE, int(self.slider_adjustment.get_value()) - config.THUMBNAIL_INCREMENT])) + + def zoom_in_100_callback(self, widget, value): + self.slider_adjustment.set_value(config.max_thumbnail_size) - #set background color, if needed - if self.bg_color: - cairo_context.set_source_rgb(*self.bg_color) - cairo_context.fill_preserve() + def zoom_out_0_callback(self, widget, value): + self.slider_adjustment.set_value(config.MIN_THUMBNAIL_SIZE) + + def set_display_preview_folders(self, value): + if value and self.selection_treeview.previewed_file_treerowref: + self.preview_destination_expander.show() + self.preview_device_expander.show() - if not self.base_image: - return False - - frame_aspect = float(w) / h - - if frame_aspect > self.base_image_aspect: - # Frame is wider than image - height = h - width = int(height * self.base_image_aspect) else: - # Frame is taller than image - width = w - height = int(width / self.base_image_aspect) + self.preview_destination_expander.hide() + self.preview_device_expander.hide() - #resize image - pil_image = self.base_image.copy() - if self.base_image_w < width or self.base_image_h < height: - logger.debug("Upsizing image") - pil_image = tn.upsize_pil(pil_image, (width, height)) + def cacheDropShadow(self): + i, self.shadow_size, offset_v = self._imageAndShadowSize() + self.drop_shadow = DropShadow(offset=(offset_v,offset_v), shadow = (0x44, 0x44, 0x44, 0xff), border=self.shadow_size, trim_border = True) + + def _imageAndShadowSize(self): + image_size = int(self.slider_adjustment.get_value()) + offset_v = max([image_size / 25, 5]) # realistically size the shadow based on the size of the image + shadow_size = offset_v + 3 + image_size = image_size + offset_v * 2 + 3 + return (image_size, shadow_size, offset_v) + + def resize_image_callback(self, adjustment): + """ + Resize the preview image after the adjustment value has been + changed + """ + size = int(adjustment.value) + self.parentApp.prefs.preview_zoom = size + self.cacheDropShadow() + + pixbuf = self.scaledPreviewImage() + if pixbuf: + self.preview_image.set_from_pixbuf(pixbuf) + size = max([pixbuf.get_width(), pixbuf.get_height()]) + self.preview_image.set_size_request(size, size) + else: + self.preview_image.set_size_request(size + self.shadow_size, size + self.shadow_size) + + def scaledPreviewImage(self): + """ + Generate a scaled version of the preview image + """ + size = int(self.slider_adjustment.get_value()) + if not self.base_preview_image: + return None else: - logger.debug("Downsizing image") - tn.downsize_pil(pil_image, (width, height)) - - #image width and height - image_w = pil_image.size[0] - image_h = pil_image.size[1] - - #center the image horizontally and vertically - #top left and right corners for the image: - image_x = x + ((w - image_w) / 2) - image_y = y + ((h - image_h) / 2) - - image = create_cairo_image_surface(pil_image, image_w, image_h) - cairo_context.set_source_surface(image, image_x, image_y) - cairo_context.paint() - - return False - + pixbuf = common.scale2pixbuf(size, size, self.base_preview_image) + + if DROP_SHADOW: + pil_image = pixbuf_to_image(pixbuf) + pil_image = self.drop_shadow.dropShadow(pil_image) + pixbuf = image_to_pixbuf(pil_image) + + return pixbuf + + def set_job_code_display(self): + """ + Shows or hides the job code entry + If user is not using job codes in their file or subfolder names + then do not prompt for it + """ -class PreviewImage: + if self.parentApp.needJobCodeForRenaming(): + self.job_code_hbox.show() + self.job_code_label.show() + self.job_code_combo.show() + self.selection_treeview.job_code_column.set_visible(True) + else: + self.job_code_hbox.hide() + self.job_code_label.hide() + self.job_code_combo.hide() + self.selection_treeview.job_code_column.set_visible(False) - def __init__(self, parent_app, builder): - #set background color to equivalent of '#444444 - self.preview_image = ResizblePilImage(bg_color=(0.267, 0.267, 0.267)) - self.preview_image_eventbox = builder.get_object("preview_eventbox") - self.preview_image_eventbox.add(self.preview_image) - self.preview_image.show() - self.download_this_checkbutton = builder.get_object("download_this_checkbutton") - self.rapid_app = parent_app + def update_job_code_combo(self): + # delete existing rows + while len(self.job_code_combo.get_model()) > 0: + self.job_code_combo.remove_text(0) + # add new ones + for text in self.parentApp.prefs.job_codes: + self.job_code_combo.append_text(text) + # clear existing entry displayed in entry box + self.job_code_entry.set_text('') - self.base_preview_image = None # large size image used to scale down from - self.current_preview_size = (0,0) - self.preview_image_size_limit = (0,0) - - self.unique_id = None + + def add_job_code_combo(self): + self.job_code_hbox = gtk.HBox(spacing = 12) + self.job_code_hbox.set_no_show_all(True) + self.job_code_label = gtk.Label(_("Job Code:")) + + self.job_code_combo = gtk.combo_box_entry_new_text() + for text in self.parentApp.prefs.job_codes: + self.job_code_combo.append_text(text) + + # make entry box have entry completion + self.job_code_entry = self.job_code_combo.child + + self.completion = gtk.EntryCompletion() + self.completion.set_match_func(self.job_code_match_func) + self.completion.connect("match-selected", + self.on_job_code_combo_completion_match) + self.completion.set_model(self.job_code_combo.get_model()) + self.completion.set_text_column(0) + self.job_code_entry.set_completion(self.completion) + + + self.job_code_combo.connect('changed', self.on_job_code_resp) + + self.job_code_entry.connect('activate', self.on_job_code_entry_resp) + + self.job_code_combo.set_tooltip_text(_("Enter a new Job Code and press Enter, or select an existing Job Code")) + + #add widgets + self.job_code_hbox.pack_start(self.job_code_label, False, False) + self.job_code_hbox.pack_start(self.job_code_combo, True, True) + self.set_job_code_display() + + def job_code_match_func(self, completion, key, iter): + model = completion.get_model() + return model[iter][0].lower().startswith(self.job_code_entry.get_text().lower()) + + def on_job_code_combo_completion_match(self, completion, model, iter): + self.job_code_entry.set_text(model[iter][0]) + self.job_code_entry.set_position(-1) + + def on_job_code_resp(self, widget): + """ + When the user has clicked on an existing job code + """ - def set_preview_image(self, unique_id, pil_image, include_checkbutton_visible=None, - checked=None): + # ignore changes because the user is typing in a new value + if widget.get_active() >= 0: + self.job_code_chosen(widget.get_active_text()) + + def on_job_code_entry_resp(self, widget): """ + When the user has hit enter after entering a new job code """ - self.preview_image.set_image(pil_image) - self.unique_id = unique_id - if checked is not None: - self.download_this_checkbutton.set_active(checked) - self.download_this_checkbutton.grab_focus() - - if include_checkbutton_visible is not None: - self.download_this_checkbutton.props.visible = include_checkbutton_visible + self.job_code_chosen(widget.get_text()) - def update_preview_image(self, unique_id, pil_image): - if unique_id == self.unique_id: - self.set_preview_image(unique_id, pil_image) - - + def job_code_chosen(self, job_code): + """ + The user has selected a Job code, apply it to selected images. + """ + self.selection_treeview.apply_job_code(job_code, overwrite = True) + self.completion.set_model(None) + self.parentApp.assignJobCode(job_code) + self.completion.set_model(self.job_code_combo.get_model()) + + def add_file(self, mediaFile): + self.selection_treeview.add_file(mediaFile) + -class RapidApp(dbus.service.Object): +class LogDialog(gnomeglade.Component): """ - The main Rapid Photo Downloader application class. - - Contains functionality for main program window, and directs all other - processes. + Displays a log of errors, warnings or other information to the user """ - - def __init__(self, bus, path, name, taskserver=None): - - dbus.service.Object.__init__ (self, bus, path, name) - self.running = False + + def __init__(self, parentApp): + """ + Initialize values for log dialog, but do not display. + """ - self.taskserver = taskserver + gnomeglade.Component.__init__(self, + paths.share_dir(config.GLADE_FILE), + "logdialog") + - # Setup program preferences, and set callback for when they change - self._init_prefs() + self.widget.connect("delete-event", self.hide_window) - # Initialize widgets in the main window, and variables that point to them - self._init_widgets() + self.parentApp = parentApp + self.log_textview.set_cursor_visible(False) + self.textbuffer = self.log_textview.get_buffer() - # Initialize job code handling - self._init_job_code() + self.errorTag = self.textbuffer.create_tag(weight=pango.WEIGHT_BOLD, foreground="red") + self.warningTag = self.textbuffer.create_tag(weight=pango.WEIGHT_BOLD) + self.resolutionTag = self.textbuffer.create_tag(style=pango.STYLE_ITALIC) - # Remember the window size from the last time the program was run, or - # set a default size - self._set_window_size() + def addMessage(self, thread_id, severity, problem, details, resolution): + if severity in [config.CRITICAL_ERROR, config.SERIOUS_ERROR]: + self.parentApp.error_image.show() + elif severity == config.WARNING: + self.parentApp.warning_image.show() + self.parentApp.warning_vseparator.show() - # Setup various widgets - self._setup_buttons() - self._setup_error_icons() - self._setup_icons() + iter = self.textbuffer.get_end_iter() + if severity in [config.CRITICAL_ERROR, config.SERIOUS_ERROR]: + self.textbuffer.insert_with_tags(iter, problem +"\n", self.errorTag) + else: + self.textbuffer.insert_with_tags(iter, problem +"\n", self.warningTag) + if details: + iter = self.textbuffer.get_end_iter() + self.textbuffer.insert(iter, details + "\n") + if resolution: + iter = self.textbuffer.get_end_iter() + self.textbuffer.insert_with_tags(iter, resolution +"\n", self.resolutionTag) - # Show the main window - self.rapidapp.show() - - # Check program preferences - don't allow auto start if there is a problem - prefs_valid = prefsrapid.check_prefs_for_validity(self.prefs) - do_not_allow_auto_start = prefs_valid - - # Initialize variables with which to track important downloads results - self._init_download_tracking() - - # Set up process managers. - # A task such as scanning a device or copying files is handled in its - # own process. - self._start_process_managers() + iter = self.textbuffer.get_end_iter() + self.textbuffer.insert(iter, "\n") - # Setup devices from which to download from and backup to - self.setup_devices(on_startup=True, on_preference_change=False, - do_not_allow_auto_start=do_not_allow_auto_start) + # move viewport to display the latest message + adjustment = self.log_scrolledwindow.get_vadjustment() + adjustment.set_value(adjustment.upper) - # Ensure the device collection scrolled window is not too small - self._set_device_collection_size() - #~ preferencesdialog.PreferencesDialog(self) - - def on_rapidapp_destroy(self, widget, data=None): + def on_logdialog_response(self, dialog, arg): + if arg == gtk.RESPONSE_CLOSE: + pass + self.parentApp.error_image.hide() + self.parentApp.warning_image.hide() + self.parentApp.warning_vseparator.hide() + self.parentApp.prefs.show_log_dialog = False + self.widget.hide() + return True - self._terminate_processes(terminate_file_copies = True) + def hide_window(self, window, event): + window.hide() + return True - # save window and component sizes - self.prefs.vpaned_pos = self.main_vpaned.get_position() - x, y, width, height = self.rapidapp.get_allocation() - self.prefs.main_window_size_x = width - self.prefs.main_window_size_y = height - - self.prefs.set_downloads_today_from_tracker(self.downloads_today_tracker) - - gtk.main_quit() - - def _terminate_processes(self, terminate_file_copies=False): - - # FIXME: need more fine grained tuning here - must cancel large file - # copies midstream - if terminate_file_copies: - logger.info("Terminating all processes...") - - scan_termination_requested = self.scan_manager.request_termination() - thumbnails_termination_requested = self.thumbnails.thumbnail_manager.request_termination() - if terminate_file_copies: - copy_files_termination_requested = self.copy_files_manager.request_termination() - else: - copy_files_termination_requested = False - - if scan_termination_requested or thumbnails_termination_requested: - time.sleep(1) - if (self.scan_manager.get_no_active_processes() > 0 or - self.thumbnails.thumbnail_manager.get_no_active_processes() > 0): - time.sleep(1) - # must try again, just in case a new scan has meanwhile started! - self.scan_manager.request_termination() - self.thumbnails.thumbnail_manager.terminate_forcefully() - self.scan_manager.terminate_forcefully() - - if terminate_file_copies and copy_files_termination_requested: - time.sleep(1) - self.copy_files_manager.terminate_forcefully() - - if terminate_file_copies: - self._clean_all_temp_dirs() - - # # # - # Events and tasks related to displaying preview images and thumbnails - # # # - - def on_download_this_checkbutton_toggled(self, checkbutton): - value = checkbutton.get_active() - self.thumbnails.set_selected(self.preview_image.unique_id, value) - self.set_download_action_sensitivity() - - def on_preview_eventbox_button_press_event(self, widget, event): - - if event.type == gtk.gdk._2BUTTON_PRESS and event.button == 1: - self.show_thumbnails() - - def on_show_thumbnails_action_activate(self, action): - logger.debug("on_show_thumbnails_action_activate") - self.show_thumbnails() - - def on_show_image_action_activate(self, action): - logger.debug("on_show_image_action_activate") - self.thumbnails.show_preview() - - def on_check_all_action_activate(self, action): - self.thumbnails.check_all(check_all=True) - - def on_uncheck_all_action_activate(self, action): - self.thumbnails.check_all(check_all=False) - - def on_check_all_photos_action_activate(self, action): - self.thumbnails.check_all(check_all=True, - file_type=rpdfile.FILE_TYPE_PHOTO) - - def on_check_all_videos_action_activate(self, action): - self.thumbnails.check_all(check_all=True, - file_type=rpdfile.FILE_TYPE_VIDEO) - - def on_quit_action_activate(self, action): - self.on_rapidapp_destroy(widget=self.rapidapp, data=None) - - def on_refresh_action_activate(self, action): - self.setup_devices(on_startup=False, on_preference_change=False, - do_not_allow_auto_start=True) - - def on_get_help_action_activate(self, action): - webbrowser.open("http://www.damonlynch.net/rapid/help.html") - - def on_about_action_activate(self, action): - self.about.set_property("name", PROGRAM_NAME) - self.about.set_property("version", utilities.human_readable_version( - __version__)) - self.about.run() - self.about.destroy() - - def on_report_problem_action_activate(self, action): - webbrowser.open("https://bugs.launchpad.net/rapid") - - def on_translate_action_activate(self, action): - webbrowser.open("http://www.damonlynch.net/rapid/translate.html") - - def on_donate_action_activate(self, action): - webbrowser.open("http://www.damonlynch.net/rapid/donate.html") - - def show_preview_image(self, unique_id, image, include_checkbutton_visible, checked): - if self.main_notebook.get_current_page() == 0: # thumbnails - logger.debug("Switching to preview image display") - self.main_notebook.set_current_page(1) - self.preview_image.set_preview_image(unique_id, image, include_checkbutton_visible, checked) - self.next_image_action.set_sensitive(True) - self.prev_image_action.set_sensitive(True) - - def update_preview_image(self, unique_id, image): - self.preview_image.update_preview_image(unique_id, image) - - def show_thumbnails(self): - logger.debug("Switching to thumbnails display") - self.main_notebook.set_current_page(0) - self.thumbnails.select_image(self.preview_image.unique_id) - self.next_image_action.set_sensitive(False) - self.prev_image_action.set_sensitive(False) + +class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): + def __init__(self, bus, path, name): + dbus.service.Object.__init__ (self, bus, path, name) + self.running = False - def on_next_image_action_activate(self, action): - if self.preview_image.unique_id is not None: - self.thumbnails.show_next_image(self.preview_image.unique_id) + gladefile = paths.share_dir(config.GLADE_FILE) + + gnomeglade.GnomeApp.__init__(self, "rapid", __version__, gladefile, "rapidapp") - def on_prev_image_action_activate(self, action): - if self.preview_image.unique_id is not None: - self.thumbnails.show_prev_image(self.preview_image.unique_id) + # notifications + self.displayDownloadSummaryNotification = False + self.initPyNotify() - def set_thumbnail_sort(self): - """ - If all the scans are complete, sets the sort order - """ - if self.scan_manager.get_no_active_processes() == 0: - self.thumbnails.sort_by_timestamp() - + self.prefs = RapidPreferences() + self.prefs.notify_add(self.on_preference_changed) + + self.testing = False + if self.testing: + self.setTestingEnv() + +# sys.exit(0) - # # # - # Volume management - # # # - - def start_volume_monitor(self): - if not self.vmonitor: - self.vmonitor = gio.volume_monitor_get() - self.vmonitor.connect("mount-added", self.on_mount_added) - self.vmonitor.connect("mount-removed", self.on_mount_removed) - + # remember the window size from the last time the program was run + if self.prefs.main_window_maximized: + self.rapidapp.maximize() + elif self.prefs.main_window_size_x > 0: + self.rapidapp.set_default_size(self.prefs.main_window_size_x, self.prefs.main_window_size_y) + else: + # set a default size + self.rapidapp.set_default_size(650, 650) - def setup_devices(self, on_startup, on_preference_change, do_not_allow_auto_start): - """ + if gtk.gdk.screen_height() <= config.TINY_SCREEN_HEIGHT: + self.prefs.display_preview_folders = False + self.menu_preview_folders.set_sensitive(False) + + self.widget.show() - Setup devices from which to download from and backup to + self._setupIcons() - Sets up volumes for downloading from and backing up to + # this must come after the window is shown + if self.prefs.vpaned_pos > 0: + self.main_vpaned.set_position(self.prefs.vpaned_pos) + else: + self.main_vpaned.set_position(66) - on_startup should be True if the program is still starting, - i.e. this is being called from the program's initialization. + self.checkIfFirstTimeProgramEverRun() + + displayPreferences = self.checkForUpgrade(__version__) + self.prefs.program_version = __version__ - on_preference_change should be True if this is being called as the - result of a preference being changed + self.timeRemaining = TimeRemaining() + self._resetDownloadInfo() + self.statusbar_context_id = self.rapid_statusbar.get_context_id("progress") - Removes any image media that are currently not downloaded, - or finished downloading - """ + # hide display of warning and error symbols in the taskbar until they are needed + self.error_image.hide() + self.warning_image.hide() + self.warning_vseparator.hide() - if self.using_volume_monitor(): - self.start_volume_monitor() + if not displayPreferences: + displayPreferences = not self.checkPreferencesOnStartup() + # display download information using threads + global media_collection_treeview, log_dialog + global workers - self.clear_non_running_downloads() - - mounts = [] - self.backup_devices = {} + #track files that should have a suffix added to them + global duplicate_files - # Clear download statistics and tracking - # FIXME + #track files that have been downloaded in this session + global downloaded_files - if self.using_volume_monitor(): - # either using automatically detected backup devices - # or download devices - for mount in self.vmonitor.get_mounts(): - if not mount.is_shadowed(): - path = mount.get_root().get_path() - if path: - if (path in self.prefs.device_blacklist and - self.search_for_PSD()): - logger.info("%s ignored", mount.get_name()) - else: - logger.info("Detected %s", mount.get_name()) - is_backup_mount = self.check_if_backup_mount(path) - if is_backup_mount: - self.backup_devices[path] = mount - elif (self.prefs.device_autodetection and - (dv.is_DCIM_device(path) or - self.search_for_PSD())): - mounts.append((path, mount)) - + # control sequence numbers and letters + global sequences - if not self.prefs.device_autodetection: - # user manually specified the path from which to download - path = self.prefs.device_location - if path: - logger.info("Using manually specified path %s", path) - if utilities.is_directory(path): - mounts.append((path, None)) - else: - logger.error("Download path does not exist: %s", path) + # whether we need to prompt for a job code + global need_job_code_for_renaming - if self.prefs.backup_images: - if not self.prefs.backup_device_autodetection: - # user manually specified backup location - # will backup to this path, but don't need any volume info - # associated with it - self.backup_devices[self.prefs.backup_location] = None + duplicate_files = {} + downloaded_files = DownloadedFiles() - # Display amount of free space in a status bar message - self.display_free_space() + self.download_start_time = None - if do_not_allow_auto_start: - self.auto_start_is_on = False - else: - self.auto_start_is_on = ((not on_preference_change) and - ((self.prefs.auto_download_at_startup and - on_startup) or - (self.prefs.auto_download_upon_device_insertion and - not on_startup))) + downloadsToday = self.prefs.getAndMaybeResetDownloadsToday() + sequences = rn.Sequences(downloadsToday, self.prefs.stored_sequence_no) + + self.downloadStats = DownloadStats() + # set the number of seconds gap with which to measure download time remaing + self.downloadTimeGap = 3 + + #locks for threadsafe file downloading and stats gathering + self.fileRenameLock = Lock() + self.fileSequenceLock = Lock() + self.statsLock = Lock() + self.downloadedFilesLock = Lock() - self.testing_auto_exit = False - self.testing_auto_exit_trip = len(mounts) - self.testing_auto_exit_trip_counter = 0 + # log window, in dialog format + # used for displaying download information to the user + log_dialog = LogDialog(self) - for m in mounts: - path, mount = m - device = dv.Device(path=path, mount=mount) - if (self.search_for_PSD() and - path not in self.prefs.device_whitelist): - # prompt user to see if device should be used or not - self.get_use_device(device) - else: - scan_pid = self.scan_manager.add_task(device) - if mount is not None: - self.mounts_by_path[path] = scan_pid + + self.volumeMonitor = None + if self.usingVolumeMonitor(): + self.startVolumeMonitor() - def get_use_device(self, device): - """ Prompt user whether or not to download from this device """ + # flag to indicate whether the user changed some preferences that + # indicate the image and backup devices should be setup again + self.rerunSetupAvailableImageAndVideoMedia = False + self.rerunSetupAvailableBackupMedia = False - logger.info("Prompting whether to use %s", device.get_name()) - d = dv.UseDeviceDialog(self.rapidapp, device, self.got_use_device) + # flag to indicate the user changes some preferences and the display + # of sample names and subfolders needs to be refreshed + self.refreshGeneratedSampleSubfolderAndName = False - def got_use_device(self, dialog, user_selected, permanent_choice, device): - """ User has chosen whether or not to use a device to download from """ - dialog.destroy() + # counter to indicate how many threads need their sample names and subfolders regenerated because the user + # changes their prefs at the same time as devices were being scanned + self.noAfterScanRefreshGeneratedSampleSubfolderAndName = 0 - path = device.get_path() + # flag to indicate the user changes some preferences and the display + # of sample download folders needs to be refreshed + self.refreshSampleDownloadFolder = False + self.noAfterScanRefreshSampleDownloadFolders = 0 - if user_selected: - if permanent_choice and path not in self.prefs.device_whitelist: - # do NOT do a list append operation here without the assignment, - # or the actual preferences will not be updated!! - if len(self.prefs.device_whitelist): - self.prefs.device_whitelist = self.prefs.device_whitelist + [path] - else: - self.prefs.device_whitelist = [path] - scan_pid = self.scan_manager.add_task(device) - self.mounts_by_path[path] = scan_pid - - elif permanent_choice and path not in self.prefs.device_blacklist: - # do not do a list append operation here without the assignment, or the preferences will not be updated! - if len(self.prefs.device_blacklist): - self.prefs.device_blacklist = self.prefs.device_blacklist + [path] - else: - self.prefs.device_blacklist = [path] - - def search_for_PSD(self): - """ - Check to see if user preferences are to automatically search for - Portable Storage Devices or not - """ - return self.prefs.device_autodetection_psd and self.prefs.device_autodetection + # flag to indicate that the preferences dialog window is being + # displayed to the user + self.preferencesDialogDisplayed = False + + # set up tree view display to display image devices and download status + media_collection_treeview = MediaTreeView(self) - def check_if_backup_mount(self, path): - """ - Checks to see if backups are enabled and path represents a valid backup location + self.media_collection_vbox.pack_start(media_collection_treeview) - Checks against user preferences. - """ - identifiers = [self.prefs.backup_identifier] - if DOWNLOAD_VIDEO: - identifiers.append(self.prefs.video_backup_identifier) - if self.prefs.backup_images: - if self.prefs.backup_device_autodetection: - if dv.is_backup_media(path, identifiers): - return True - elif path == self.prefs.backup_location: - # user manually specified the path - return True - return False - - def using_volume_monitor(self): - """ - Returns True if programs needs to use gio volume monitor - """ + #Selection display + self.selection_vbox = SelectionVBox(self) + self.selection_hbox.pack_start(self.selection_vbox, padding=12) + self.set_display_selection(self.prefs.display_selection) + self.set_display_preview_folders(self.prefs.display_preview_folders) - return (self.prefs.device_autodetection or - (self.prefs.backup_images and - self.prefs.backup_device_autodetection - )) - - def on_mount_added(self, vmonitor, mount): - """ - callback run when gio indicates a new volume - has been mounted - """ + self.backupVolumes = {} + #Help button and download buttons + self._setupDownloadbuttons() + + #status bar progress bar + self.download_progressbar = gtk.ProgressBar() + self.download_progressbar.set_size_request(150, -1) + self.download_progressbar.show() + self.download_progressbar_hbox.pack_start(self.download_progressbar, expand=False, + fill=0) + - if mount.is_shadowed(): - # ignore this type of mount - return - - path = mount.get_root().get_path() + # menus - if path in self.prefs.device_blacklist and self.search_for_PSD(): - logger.info("Device %(device)s (%(path)s) ignored" % { - 'device': mount.get_name(), 'path': path}) + #preview panes + self.menu_display_selection.set_active(self.prefs.display_selection) + self.menu_preview_folders.set_active(self.prefs.display_preview_folders) + + #preview columns in pane + if not DOWNLOAD_VIDEO: + self.menu_type_column.set_active(False) + self.menu_type_column.set_sensitive(False) else: - is_backup_mount = self.check_if_backup_mount(path) - - if is_backup_mount: - if path not in self.backup_devices: - self.backup_devices[path] = mount - self.display_free_space() + self.menu_type_column.set_active(self.prefs.display_type_column) + self.menu_size_column.set_active(self.prefs.display_size_column) + self.menu_filename_column.set_active(self.prefs.display_filename_column) + self.menu_device_column.set_active(self.prefs.display_device_column) + self.menu_path_column.set_active(self.prefs.display_path_column) + + self.menu_clear.set_sensitive(False) - elif self.prefs.device_autodetection and (dv.is_DCIM_device(path) or - self.search_for_PSD()): - - device = dv.Device(path=path, mount=mount) - if self.search_for_PSD() and path not in self.prefs.device_whitelist: - # prompt user if device should be used or not - self.get_use_device(device) - else: - scan_pid = self.scan_manager.add_task(device) - self.mounts_by_path[path] = scan_pid + need_job_code_for_renaming = self.needJobCodeForRenaming() + self.menu_select_all_without_job_code.set_sensitive(need_job_code_for_renaming) + self.menu_select_all_with_job_code.set_sensitive(need_job_code_for_renaming) + + #job code initialization + self.last_chosen_job_code = None + self.prompting_for_job_code = False + + #check to see if the download folder exists and is writable + displayPreferences_2 = not self.checkDownloadPathOnStartup() + displayPreferences = displayPreferences or displayPreferences_2 - def on_mount_removed(self, vmonitor, mount): - """ - callback run when gio indicates a new volume - has been mounted - """ + if self.prefs.device_autodetection == False: + displayPreferences_2 = not self.checkImageDevicePathOnStartup() + displayPreferences = displayPreferences or displayPreferences_2 - path = mount.get_root().get_path() + #setup download and backup mediums, initiating scans + self.setupAvailableImageAndBackupMedia(onStartup=True, onPreferenceChange=False, doNotAllowAutoStart = displayPreferences) - # three scenarios - - # the mount has been scanned but downloading has not yet started - # files are being downloaded from mount (it must be a messy unmount) - # files have finished downloading from mount + #adjust viewport size for displaying media + #this is important because the code in MediaTreeView.addCard() is inaccurate at program startup - if path in self.mounts_by_path: - scan_pid = self.mounts_by_path[path] - del self.mounts_by_path[path] - # temp directory should be cleaned by finishing of process - - #~ if scan_pid in self.download_active_by_scan_pid: - #~ self._clean_temp_dirs_for_scan_pid(scan_pid) - self.thumbnails.clear_all(scan_pid = scan_pid, - keep_downloaded_files = True) - self.device_collection.remove_device(scan_pid) - - - - # remove backup volumes - elif path in self.backup_devices: - del self.backup_devices[path] - self.display_free_space() - - # may need to disable download button and menu - self.set_download_action_sensitivity() - - def clear_non_running_downloads(self): - """ - Clears the display of downloads that are currently not running - """ + if media_collection_treeview.mapThreadToRow: + height = self.media_collection_viewport.size_request()[1] + self.media_collection_scrolledwindow.set_size_request(-1, height) + else: + # don't allow the media collection to be absolutely empty + self.media_collection_scrolledwindow.set_size_request(-1, 47) - # Stop any processes currently scanning or creating thumbnails - self._terminate_processes(terminate_file_copies=False) + self.download_button.grab_default() + # for some reason, the grab focus command is not working... unsure why + self.download_button.grab_focus() - # Remove them from the user interface - for scan_pid in self.device_collection.get_all_displayed_processes(): - if scan_pid not in self.download_active_by_scan_pid: - self.device_collection.remove_device(scan_pid) - self.thumbnails.clear_all(scan_pid=scan_pid) + if displayPreferences: + PreferencesDialog(self) - + + @dbus.service.method (config.DBUS_NAME, + in_signature='', out_signature='b') + def is_running (self): + return self.running - # # # - # Download and help buttons, and menu items - # # # + @dbus.service.method (config.DBUS_NAME, + in_signature='', out_signature='') + def start (self): + if self.is_running(): + self.rapidapp.present() + else: + self.running = True +# if not using_gio: + self.main() +# else: +# mainloop = gobject.MainLoop() +# mainloop.run() + self.running = False + + def setTestingEnv(self): + #self.prefs.program_version = '0.0.8~b7' + p = ['Date time', 'Image date', 'YYYYMMDD', 'Text', '-', '', 'Date time', 'Image date', 'HHMM', 'Text', '-', '', rn.SEQUENCES, rn.DOWNLOAD_SEQ_NUMBER, rn.SEQUENCE_NUMBER_3, 'Text', '-iso', '', 'Metadata', 'ISO', '', 'Text', '-f', '', 'Metadata', 'Aperture', '', 'Text', '-', '', 'Metadata', 'Focal length', '', 'Text', 'mm-', '', 'Metadata', 'Exposure time', '', 'Filename', 'Extension', 'lowercase'] + v = ['Date time', 'Video date', 'YYYYMMDD', 'Text', '-', '', 'Date time', 'Video date', 'HHMM', 'Text', '-', '', 'Sequences', 'Downloads today', 'One digit', 'Text', '-', '', 'Metadata', 'Width', '', 'Text', 'x', '', 'Metadata', 'Height', '', 'Filename', 'Extension', 'lowercase'] + f = '/home/damon/store/rapid-dump' + self.prefs.image_rename = p + self.prefs.video_rename = v + self.prefs.download_folder = f + self.prefs.video_download_folder = f + + + def _setupIcons(self): + icons = ['rapid-photo-downloader-downloaded', + 'rapid-photo-downloader-downloaded-with-error', + 'rapid-photo-downloader-downloaded-with-warning', + 'rapid-photo-downloader-download-pending', + 'rapid-photo-downloader-jobcode'] + + icon_list = [(icon, paths.share_dir('glade3/%s.svg' % icon)) for icon in icons] + common.register_iconsets(icon_list) - def on_download_action_activate(self, action): + def displayFreeSpace(self): """ - Called when a download is activated + Displays the amount of space free on the filesystem the files will be downloaded to. + Also displays backup volumes / path being used. """ + msg = '' + if using_gio and os.path.isdir(self.prefs.download_folder): + folder = gio.File(self.prefs.download_folder) + fileInfo = folder.query_filesystem_info(gio.FILE_ATTRIBUTE_FILESYSTEM_FREE) + free = common.formatSizeForUser(fileInfo.get_attribute_uint64(gio.FILE_ATTRIBUTE_FILESYSTEM_FREE)) + msg = " " + _("%(free)s available") % {'free': free} - if self.copy_files_manager.paused: - logger.debug("Download resumed") - self.resume_download() - else: - logger.debug("Download activated") - if self.download_action_is_download: - self.start_download() + if self.prefs.backup_images: + if not self.prefs.backup_device_autodetection: + # user manually specified backup location + msg2 = _('Backing up to %(path)s') % {'path':self.prefs.backup_location} else: - self.pause_download() - + msg2 = self.displayBackupVolumes() + + if msg: + msg = _("%(freespace)s. %(backuppaths)s.") % {'freespace': msg, 'backuppaths': msg2} + else: + msg = msg2 + + self.rapid_statusbar.push(self.statusbar_context_id, msg) - def on_help_action_activate(self, action): - webbrowser.open("http://www.damonlynch.net/rapid/documentation") - - def on_preferences_action_activate(self, action): - - preferencesdialog.PreferencesDialog(self) - - def set_download_action_sensitivity(self): - """ - Sets sensitivity of Download action to enable or disable it + def checkImageDevicePathOnStartup(self): + msg = None + if not os.path.isdir(self.prefs.device_location): + msg = _("Sorry, this device location does not exist:\n%(path)s\n\nPlease resolve the problem, or modify your preferences." % {"path": self.prefs.device_location}) + + if msg: + sys.stderr.write(msg +'\n') + misc.run_dialog(_("Problem with Device Location Folder"), msg, + self, + gtk.MESSAGE_ERROR) + return False + else: + return True - Affects download button and menu item - """ - if not self.download_is_occurring(): - sensitivity = False - if self.scan_manager.get_no_active_processes() == 0: - if self.thumbnails.files_are_checked_to_download(): - sensitivity = True + def checkDownloadPathOnStartup(self): + if DOWNLOAD_VIDEO: + paths = ((self.prefs.download_folder, _('Photo')), (self.prefs.video_download_folder, _('Video'))) + else: + paths = ((self.prefs.download_folder, _('Photo')),) + msg = '' + noProblems = 0 + for path, file_type in paths: + if not os.path.isdir(path): + msg += _("The %(file_type)s Download Folder does not exist.\n") % {'file_type': file_type} + noProblems += 1 + else: + #unfortunately 'os.access(self.prefs.download_folder, os.W_OK)' is not reliable + try: + tempWorkingDir = tempfile.mkdtemp(prefix='rapid-tmp-', + dir=path) + except: + noProblems += 1 + msg += _("The %(file_type)s Download Folder exists but cannot be written to.\n") % {'file_type': file_type} + else: + os.rmdir(tempWorkingDir) - self.download_action.set_sensitive(sensitivity) + if msg: + msg = _("Sorry, problems were encountered with your download folders. Please fix the problems or modify the preferences.\n\n") + msg + sys.stderr.write(msg) + if noProblems == 1: + title = _("Problem with Download Folder") + else: + title = _("Problem with Download Folders") - def set_download_action_label(self, is_download): - """ - Toggles label betwen pause and download - """ - - if is_download: - self.download_action.set_label(_("Download")) - self.download_action_is_download = True + misc.run_dialog(title, msg, + self, + gtk.MESSAGE_ERROR) + return False else: - self.download_action.set_label(_("Pause")) - self.download_action_is_download = False - - # # # - # Job codes - # # # - - - def _init_job_code(self): - self.job_code = None - self.need_job_code_for_naming = self.prefs.any_pref_uses_job_code() + return True - def assign_job_code(self, code): + def checkPreferencesOnStartup(self): + prefsOk = rn.checkPreferencesForValidity(self.prefs.image_rename, self.prefs.subfolder, self.prefs.video_rename, self.prefs.video_subfolder) + if not prefsOk: + msg = _("There is an error in the program preferences.") + msg += " " + _("Some preferences will be reset.") + # do not use cmd_line here, as this is a genuine error + sys.stderr.write(msg +'\n') + return prefsOk + + def needJobCodeForRenaming(self): + return rn.usesJobCode(self.prefs.image_rename) or rn.usesJobCode(self.prefs.subfolder) or rn.usesJobCode(self.prefs.video_rename) or rn.usesJobCode(self.prefs.video_subfolder) + + def assignJobCode(self, code): """ assign job code (which may be empty) to global variable and update user preferences Update preferences only if code is not empty. Do not duplicate job code. """ - # FIXME - #~ global job_code + global job_code if code == None: code = '' job_code = code @@ -1712,921 +5163,1301 @@ class RapidApp(dbus.service.Object): jcs.remove(code) self.prefs.job_codes = [code] + jcs - - def _get_job_code(self, post_job_code_entry_callback, autoStart, downloadSelected): + + + def getShowWarningDownloadingFromCamera(self): + if self.prefs.show_warning_downloading_from_camera: + cmd_line(_("Displaying warning about downloading directly from camera")) + d = ShowWarningDialog(self.widget, self.gotShowWarningDownloadingFromCamera) + + def gotShowWarningDownloadingFromCamera(self, dialog, showWarningAgain): + dialog.destroy() + self.prefs.show_warning_downloading_from_camera = showWarningAgain + + def getUseDevice(self, path, volume, autostart): + """ Prompt user whether or not to download from this device """ + + cmd_line(_("Prompting whether to use %s" % volume.get_name(limit=0))) + d = UseDeviceDialog(self.widget, path, volume, autostart, self.gotUseDevice) + + def gotUseDevice(self, dialog, userSelected, permanent_choice, path, volume, autostart): + """ User has chosen whether or not to use a device to download from """ + dialog.destroy() + + if userSelected: + if permanent_choice and path not in self.prefs.device_whitelist: + # do not do a list append operation here without the assignment, or the preferences will not be updated! + if len(self.prefs.device_whitelist): + self.prefs.device_whitelist = self.prefs.device_whitelist + [path] + else: + self.prefs.device_whitelist = [path] + self.initiateScan(path, volume, autostart) + + elif permanent_choice and path not in self.prefs.device_blacklist: + # do not do a list append operation here without the assignment, or the preferences will not be updated! + if len(self.prefs.device_blacklist): + self.prefs.device_blacklist = self.prefs.device_blacklist + [path] + else: + self.prefs.device_blacklist = [path] + + def _getJobCode(self, postJobCodeEntryCB, autoStart, downloadSelected): """ prompt for a job code """ if not self.prompting_for_job_code: - logger.debug("Prompting for Job Code") + cmd_line(_("Prompting for Job Code")) self.prompting_for_job_code = True - j = preferencesdialog.JobCodeDialog(parent_window = self.rapidapp, - job_codes = self.prefs.job_codes, - default_job_code = self.last_chosen_job_code, - post_job_code_entry_callback=post_job_code_entry_callback, - entry_only = False) + j = JobCodeDialog(self.widget, self.prefs.job_codes, self.last_chosen_job_code, postJobCodeEntryCB, autoStart, downloadSelected, False) else: - logger.debug("Already prompting for Job Code, do not prompt again") + cmd_line(_("Already prompting for Job Code, do not prompt again")) - def get_job_code(self): - self._get_job_code(self.got_job_code) + def getJobCode(self, autoStart=True, downloadSelected=False): + """ called from the copyphotos thread, or when the user clicks one of the two download buttons""" + + self._getJobCode(self.gotJobCode, autoStart, downloadSelected) - def got_job_code(self, dialog, user_chose_code, code): + def gotJobCode(self, dialog, userChoseCode, code, autoStart, downloadSelected): dialog.destroy() self.prompting_for_job_code = False - if user_chose_code: - self.assign_job_code(code) + if userChoseCode: + self.assignJobCode(code) self.last_chosen_job_code = code - #~ self.selection_vbox.selection_treeview.apply_job_code(code, overwrite=False, to_all_rows = not downloadSelected) - #~ threads = self.selection_vbox.selection_treeview.set_status_to_download_pending(selected_only = downloadSelected) - #~ if downloadSelected or not autoStart: - #~ logger.debug("Starting downloads") - #~ self.startDownload(threads) - #~ else: - #~ # autostart is true - #~ logger.debug("Starting downloads that have been waiting for a Job Code") - #~ for w in workers.getWaitingForJobCodeWorkers(): - #~ w.startStop() + self.selection_vbox.selection_treeview.apply_job_code(code, overwrite=False, to_all_rows = not downloadSelected) + threads = self.selection_vbox.selection_treeview.set_status_to_download_pending(selected_only = downloadSelected) + if downloadSelected or not autoStart: + cmd_line(_("Starting downloads")) + self.startDownload(threads) + else: + # autostart is true + cmd_line(_("Starting downloads that have been waiting for a Job Code")) + for w in workers.getWaitingForJobCodeWorkers(): + w.startStop() else: # user cancelled - pass - #~ logger.debug("No Job Code entered") - #~ for w in workers.getWaitingForJobCodeWorkers(): - #~ w.waitingForJobCode = False - #~ - #~ if autoStart: - #~ for w in workers.getAutoStartWorkers(): - #~ w.autoStart = False - - # # # - # Download - # # # - - def _init_download_tracking(self): - """ - Initialize variables to track downloads - """ - # Track download sizes and other values for each device. - # (Scan id acts as an index to each device. A device could be scanned - # more than once). - self.download_tracker = downloadtracker.DownloadTracker() + for w in workers.getWaitingForJobCodeWorkers(): + w.waitingForJobCode = False + + if autoStart: + for w in workers.getAutoStartWorkers(): + w.autoStart = False + + def addFile(self, mediaFile): + self.selection_vbox.add_file(mediaFile) - # Track which temporary directories are created when downloading files - self.temp_dirs_by_scan_pid = dict() + def update_status_post_download(self, treerowref): + self.selection_vbox.selection_treeview.update_status_post_download(treerowref) + + def on_menu_size_column_toggled(self, widget): + self.prefs.display_size_column = widget.get_active() + self.selection_vbox.selection_treeview.display_size_column(self.prefs.display_size_column) - # Track which downloads are running - self.download_active_by_scan_pid = [] + def on_menu_type_column_toggled(self, widget): + self.prefs.display_type_column = widget.get_active() + self.selection_vbox.selection_treeview.display_type_column(self.prefs.display_type_column) - - - def start_download(self): - """ - Start download, renaming and backup of files. - """ + def on_menu_filename_column_toggled(self, widget): + self.prefs.display_filename_column = widget.get_active() + self.selection_vbox.selection_treeview.display_filename_column(self.prefs.display_filename_column) - self.download_start_time = datetime.datetime.now() - files_by_scan_pid = self.thumbnails.get_files_checked_for_download() - folders_valid, invalid_dirs = self.check_download_folder_validity(files_by_scan_pid) + def on_menu_path_column_toggled(self, widget): + self.prefs.display_path_column = widget.get_active() + self.selection_vbox.selection_treeview.display_path_column(self.prefs.display_path_column) - if not folders_valid: - if len(invalid_dirs) > 1: - msg = _("These download folders are invalid:\n%(folder1)s\n%(folder2)s") % { - 'folder1': invalid_dirs[0], 'folder2': invalid_dirs[1]} - else: - msg = _("This download folder is invalid:\n%s") % invalid_dirs[0] - self.log_error(config.CRITICAL_ERROR, _("Download cannot proceed"), - msg) - else: - self.thumbnails.mark_download_pending(files_by_scan_pid) - for scan_pid in files_by_scan_pid: - files = files_by_scan_pid[scan_pid] - self.download_files(files, scan_pid) + def on_menu_device_column_toggled(self, widget): + self.prefs.display_device_column = widget.get_active() + self.selection_vbox.selection_treeview.display_device_column(self.prefs.display_device_column) - self.set_download_action_label(is_download = False) + def checkIfFirstTimeProgramEverRun(self): + """ + if this is the first time the program has been run, then + might need to create default directories + """ + if len(self.prefs.program_version) == 0: + path = getDefaultPhotoLocation(ignore_missing_dir=True) + if not os.path.isdir(path): + cmd_line(_("Creating photo download folder %(folder)s") % {'folder':path}) + try: + os.makedirs(path) + self.prefs.download_folder = path + except: + cmd_line(_("Failed to create default photo download folder %(folder)s") % {'folder':path}) + if DOWNLOAD_VIDEO: + path = getDefaultVideoLocation(ignore_missing_dir=True) + if not os.path.isdir(path): + cmd_line(_("Creating video download folder %(folder)s") % {'folder':path}) + try: + os.makedirs(path) + self.prefs.video_download_folder = path + except: + cmd_line(_("Failed to create default video download folder %(folder)s") % {'folder':path}) + + def checkForUpgrade(self, runningVersion): + """ Checks if the running version of the program is different from the version recorded in the preferences. - def pause_download(self): + If the version is different, then the preferences are checked to see whether they should be upgraded or not. - self.copy_files_manager.pause() + returns True if program preferences window should be opened """ - # set action to display Download - if not self.download_action_is_download: - self.set_download_action_label(is_download = True) - - def resume_download(self): - self.copy_files_manager.start() - - def download_files(self, files, scan_pid): - """ - Initiate downloading and renaming of files - """ + displayPrefs = upgraded = False - # Check which file types will be downloaded for this particular process - if self.files_of_type_present(files, rpdfile.FILE_TYPE_PHOTO): - photo_download_folder = self.prefs.download_folder - else: - photo_download_folder = None - - if self.files_of_type_present(files, rpdfile.FILE_TYPE_VIDEO): - video_download_folder = self.prefs.video_download_folder - else: - video_download_folder = None + previousVersion = self.prefs.program_version + if len(previousVersion) > 0: + # the program has been run previously for this user + + pv = common.pythonifyVersion(previousVersion) + rv = common.pythonifyVersion(runningVersion) - self.download_tracker.init_stats(scan_pid=scan_pid, - bytes=self.size_files_to_be_downloaded(files), - no_files=len(files)) + title = PROGRAM_NAME + imageRename = subfolder = None - self.download_active_by_scan_pid.append(scan_pid) - # Initiate copy files process - self.copy_files_manager.add_task((photo_download_folder, - video_download_folder, scan_pid, - files)) - - def copy_files_results(self, source, condition): - """ - Handle results from copy files process - """ - #FIXME: must handle early termination / pause of copy files process - connection = self.copy_files_manager.get_pipe(source) - conn_type, msg_data = connection.recv() - if conn_type == rpdmp.CONN_PARTIAL: - msg_type, data = msg_data - - if msg_type == rpdmp.MSG_TEMP_DIRS: - scan_pid, photo_temp_dir, video_temp_dir = data - self.temp_dirs_by_scan_pid[scan_pid] = (photo_temp_dir, video_temp_dir) - elif msg_type == rpdmp.MSG_BYTES: - scan_pid, total_downloaded = data - self.download_tracker.set_total_bytes_copied(scan_pid, - total_downloaded) - percent_complete = self.download_tracker.get_percent_complete(scan_pid) - self.device_collection.update_progress(scan_pid, percent_complete, - None, None) - elif msg_type == rpdmp.MSG_FILE: - download_succeeded, rpd_file, download_count, temp_full_file_name = data - - self.download_tracker.set_download_count_for_file( - rpd_file.unique_id, download_count) - self.download_tracker.set_download_count( - rpd_file.scan_pid, download_count) - rpd_file.download_start_time = self.download_start_time - - if download_succeeded: - # Insert preference values needed for name generation - rpd_file = prefsrapid.insert_pref_lists(self.prefs, rpd_file) - rpd_file.strip_characters = self.prefs.strip_characters - rpd_file.download_folder = self.prefs.get_download_folder_for_file_type(rpd_file.file_type) - rpd_file.download_conflict_resolution = self.prefs.download_conflict_resolution - rpd_file.synchronize_raw_jpg = self.prefs.must_synchronize_raw_jpg() - - self.subfolder_file_manager.rename_file_and_move_to_subfolder( - download_succeeded, - download_count, - rpd_file - ) - + if pv != rv: + if pv > rv: + prefsOk = rn.checkPreferencesForValidity(self.prefs.image_rename, self.prefs.subfolder, self.prefs.video_rename, self.prefs.video_subfolder) + + msg = _("A newer version of this program was previously run on this computer.\n\n") + if prefsOk: + msg += _("Program preferences appear to be valid, but please check them to ensure correct operation.") + else: + msg += _("Sorry, some preferences are invalid and will be reset.") + sys.stderr.write(_("Warning:") + " %s\n" % msg) + misc.run_dialog(title, msg) + displayPrefs = True - return True + else: + cmd_line(_("This version of the program is newer than the previously run version. Checking preferences.")) + + if rn.checkPreferencesForValidity(self.prefs.image_rename, self.prefs.subfolder, self.prefs.video_rename, self.prefs.video_subfolder, previousVersion): + upgraded, imageRename, subfolder = rn.upgradePreferencesToCurrent(self.prefs.image_rename, self.prefs.subfolder, previousVersion) + if upgraded: + self.prefs.image_rename = imageRename + self.prefs.subfolder = subfolder + cmd_line(_("Preferences were modified.")) + msg = _('This version of the program uses different preferences than the old version. Your preferences have been updated.\n\nPlease check them to ensure correct operation.') + misc.run_dialog(title, msg) + displayPrefs = True + else: + cmd_line(_("No preferences needed to be changed.")) + else: + msg = _('This version of the program uses different preferences than the old version. Some of your previous preferences were invalid, and could not be updated. They will be reset.') + sys.stderr.write(msg + "\n") + misc.run_dialog(title, msg) + displayPrefs = True + + + return displayPrefs + + def initPyNotify(self): + if not pynotify.init("TestCaps"): + sys.stderr.write(_("Problem using pynotify.") + "\n") + sys.exit(1) + + capabilities = {'actions': False, + 'body': False, + 'body-hyperlinks': False, + 'body-images': False, + 'body-markup': False, + 'icon-multi': False, + 'icon-static': False, + 'sound': False, + 'image/svg+xml': False, + 'append': False} + + caps = pynotify.get_server_caps () + if caps is None: + sys.stderr.write(_("Failed to receive pynotify server capabilities.") + "\n") + sys.exit (1) + + for cap in caps: + capabilities[cap] = True + + do_not_size_icon = False + self.notification_icon_size = 48 + try: + info = pynotify.get_server_info() + except: + cmd_line(_("Warning: desktop environment notification server is incorrectly configured.")) else: - # Process is complete, i.e. conn_type == rpdmp.CONN_COMPLETE - connection.close() - return False - + try: + if info["name"] == 'notify-osd': + do_not_size_icon = True + except: + pass + + if do_not_size_icon: + self.application_icon = gtk.gdk.pixbuf_new_from_file( + paths.share_dir('glade3/rapid-photo-downloader.svg')) + else: + self.application_icon = gtk.gdk.pixbuf_new_from_file_at_size( + paths.share_dir('glade3/rapid-photo-downloader.svg'), + self.notification_icon_size, self.notification_icon_size) + - def download_is_occurring(self): - """Returns True if a file is currently being downloaded or renamed + def usingVolumeMonitor(self): + """ + Returns True if programs needs to use gio or gnomevfs volume monitor """ - return not len(self.download_active_by_scan_pid) == 0 + + return (self.prefs.device_autodetection or + (self.prefs.backup_images and + self.prefs.backup_device_autodetection + )) + - # # # - # Create folder and file names for downloaded files - # # # + def startVolumeMonitor(self): + if not self.volumeMonitor: + self.volumeMonitor = VMonitor(self) - def subfolder_file_results(self, move_succeeded, rpd_file): + def displayBackupVolumes(self): """ - Handle results of subfolder creation and file renaming + Create a message to be displayed to the user showing which backup volumes will be used """ - - scan_pid = rpd_file.scan_pid - unique_id = rpd_file.unique_id + message = '' - self.thumbnails.update_status_post_download(rpd_file) + paths = self.backupVolumes.keys() + i = 0 + v = len(paths) + prefix = '' + for b in paths: + if v > 1: + if i < (v -1) and i > 0: + prefix = ', ' + elif i == (v - 1) : + prefix = " " + _("and") + " " + i += 1 + message = "%s%s'%s'" % (message, prefix, self.backupVolumes[b].get_name()) + + if v > 1: + message = _("Using backup devices") + " %s" % message + elif v == 1: + message = _("Using backup device") + " %s" % message + else: + message = _("No backup devices detected") + + return message - # Update error log window if neccessary - if not move_succeeded: - self.log_error(config.SERIOUS_ERROR, rpd_file.error_title, - rpd_file.error_msg, rpd_file.error_extra_detail) - elif rpd_file.status == config.STATUS_DOWNLOADED_WITH_WARNING: - self.log_error(config.WARNING, rpd_file.error_title, - rpd_file.error_msg, rpd_file.error_extra_detail) + def searchForPsd(self): + """ + Check to see if user preferences are to automatically search for Portable Storage Devices or not + """ + return self.prefs.device_autodetection_psd and self.prefs.device_autodetection - self.download_tracker.file_downloaded_increment(scan_pid) - self._update_file_download_device_progress(scan_pid, unique_id) - - download_count = self.download_tracker.get_download_count_for_file(unique_id) - if download_count == self.download_tracker.get_no_files_in_download(scan_pid): - # Last file has been downloaded, so clean temp directory - logger.debug("Purging temp directories") - self._clean_temp_dirs_for_scan_pid(scan_pid) - self.download_tracker.purge(scan_pid) - self.download_active_by_scan_pid.remove(scan_pid) + + def isGProxyShadowMount(self, gMount): + + """ gvfs GProxyShadowMount is used for the camera itself, not the data in the memory card """ + if using_gio: + if hasattr(gMount, 'is_shadowed'): + return gMount.is_shadowed() + else: + return str(type(gMount)).find('GProxyShadowMount') >= 0 + else: + return False - if not self.download_is_occurring(): - logger.debug("Download completed") - - self.prefs.stored_sequence_no = self.stored_sequence_value.value - self.downloads_today_tracker.set_raw_downloads_today_from_int(self.downloads_today_value.value) - self.downloads_today_tracker.set_raw_downloads_today_date(self.downloads_today_date_value.value) - self.prefs.set_downloads_today_from_tracker(self.downloads_today_tracker) - - self.display_free_space() - - self.set_download_action_label(is_download=True) - self.set_download_action_sensitivity() + def isCamera(self, volume): + if using_gio: + try: + return volume.get_root().query_filesystem_info(gio.FILE_ATTRIBUTE_GVFS_BACKEND).get_attribute_as_string(gio.FILE_ATTRIBUTE_GVFS_BACKEND) == 'gphoto2' + except: + return False else: - pass - #~ logger.info("Download count: %s", download_count) - + return False + def workerHasThisPath(self, path): + havePath= False + for w in workers.getNonFinishedWorkers(): + if w.cardMedia.path == path: + havePath = True + break + return havePath - def _update_file_download_device_progress(self, scan_pid, unique_id): + def on_volume_mounted(self, monitor, mount): """ - Increments the progress bar for an individual device + callback run when gnomevfs indicates a new volume + has been mounted """ - progress_bar_text = _("%(number)s of %(total)s %(filetypes)s") % \ - {'number': self.download_tracker.get_download_count_for_file(unique_id), - 'total': self.download_tracker.get_no_files_in_download(scan_pid), - 'filetypes': self.download_tracker.get_file_types_present(scan_pid)} - percent_complete = self.download_tracker.get_percent_complete(scan_pid) - self.device_collection.update_progress(scan_pid=scan_pid, - percent_complete=percent_complete, - progress_bar_text=progress_bar_text, - bytes_downloaded=None) - - def _clean_all_temp_dirs(self): + + if self.usingVolumeMonitor(): + volume = Volume(mount) + path = volume.get_path() + + if path in self.prefs.device_blacklist and self.searchForPsd(): + cmd_line(_("Device %(device)s (%(path)s) ignored") % { + 'device': volume.get_name(limit=0), 'path': path}) + else: + if not self.isGProxyShadowMount(mount): + self._printDetectedDevice(volume.get_name(limit=0), path) + + isBackupVolume = self.checkIfBackupVolume(path) + + if isBackupVolume: + if path not in self.backupVolumes: + self.backupVolumes[path] = volume + self.displayFreeSpace() + + elif self.prefs.device_autodetection and (media.is_DCIM_Media(path) or self.searchForPsd()): + if self.isCamera(volume.volume): + self.getShowWarningDownloadingFromCamera() + if self.searchForPsd() and path not in self.prefs.device_whitelist: + # prompt user if device should be used or not + self.getUseDevice(path, volume, self.prefs.auto_download_upon_device_insertion) + else: + self._printAutoStart(self.prefs.auto_download_upon_device_insertion) + self.initiateScan(path, volume, self.prefs.auto_download_upon_device_insertion) + + def initiateScan(self, path, volume, autostart): + """ initiates scan of image device""" + cardMedia = CardMedia(path, volume) + i = workers.getNextThread_id() + + workers.append(CopyPhotos(i, self, self.fileRenameLock, + self.fileSequenceLock, self.statsLock, + self.downloadedFilesLock, self.downloadStats, + autostart, cardMedia)) + + + self.setDownloadButtonSensitivity() + self.startScan() + + + def on_volume_unmounted(self, monitor, volume): """ - Cleans all temp dirs if they exist + callback run when gnomevfs indicates a volume + has been unmounted """ - for scan_pid in self.temp_dirs_by_scan_pid: - for temp_dir in self.temp_dirs_by_scan_pid[scan_pid]: - self._purge_dir(temp_dir) + + v = Volume(volume) + path = v.get_path() + + # four scenarios - + # volume is waiting to be scanned + # the volume has been scanned but downloading has not yet started + # images are being downloaded from volume (it must be a messy unmount) + # images finished downloading from volume + + if path: + # first scenario + + for w in workers.getReadyToStartWorkers(): + if w.cardMedia.volume: + if w.cardMedia.volume.volume == volume: + media_collection_treeview.removeCard(w.thread_id) + self.selection_vbox.selection_treeview.clear_all(w.thread_id) + workers.disableWorker(w.thread_id) + # second scenario + for w in workers.getReadyToDownloadWorkers(): + if w.cardMedia.volume: + if w.cardMedia.volume.volume == volume: + media_collection_treeview.removeCard(w.thread_id) + self.selection_vbox.selection_treeview.clear_all(w.thread_id) + workers.disableWorker(w.thread_id) + + # fourth scenario - nothing to do + + # remove backup volumes + if path in self.backupVolumes: + del self.backupVolumes[path] + self.displayFreeSpace() - self.temp_dirs_by_scan_pid = {} - + # may need to disable download button + self.setDownloadButtonSensitivity() + - def _clean_temp_dirs_for_scan_pid(self, scan_pid): + def clearCompletedDownloads(self): """ - Deletes temp files and folders used in download + clears the display of completed downloads """ - for temp_dir in self.temp_dirs_by_scan_pid[scan_pid]: - self._purge_dir(temp_dir) - del self.temp_dirs_by_scan_pid[scan_pid] - def _purge_dir(self, directory): - """ - Deletes all files in the directory, and the directory itself. + for w in workers.getFinishedWorkers(): + media_collection_treeview.removeCard(w.thread_id) + self.selection_vbox.selection_treeview.clear_all(w.thread_id) + + + - Does not recursively traverse any subfolders in the directory. + def clearNotStartedDownloads(self): + """ + Clears the display of the download and instructs the thread not to run """ - if directory: - try: - path = gio.File(directory) - # first delete any files in the temp directory - # assume there are no directories in the temp directory - file_attributes = "standard::name,standard::type" - children = path.enumerate_children(file_attributes) - for child in children: - f = path.get_child(child.get_name()) - #~ logger.info("Deleting %s", child.get_name()) - f.delete(cancellable=None) - path.delete(cancellable=None) - logger.debug("Deleted directory %s", directory) - except gio.Error, inst: - logger.error("Failure deleting temporary folder %s", directory) - logger.error(inst) - + for w in workers.getNotDownloadingWorkers(): + media_collection_treeview.removeCard(w.thread_id) + workers.disableWorker(w.thread_id) - # # # - # Preferences - # # # - - def check_prefs_on_startup(self): + def checkIfBackupVolume(self, path): """ - Checks the image & video rename, and subfolder prefs for validity. + Checks to see if backups are enabled and path represents a valid backup location - Returns True if no problem, false otherwise. + Checks against user preferences. """ - prefs_ok = prefsrapid.check_prefs_for_validity(self.prefs.image_rename, - self.prefs.subfolder, - self.prefs.video_rename, - self.prefs.video_subfolder) - if not prefs_ok: - logger.error("There is an error in the program preferences relating to file renaming and subfolder creation. Some preferences will be reset.") - return prefs_ok + identifiers = [self.prefs.backup_identifier] + if DOWNLOAD_VIDEO: + identifiers.append(self.prefs.video_backup_identifier) + if self.prefs.backup_images: + if self.prefs.backup_device_autodetection: + if media.isBackupMedia(path, identifiers): + return True + elif path == self.prefs.backup_location: + # user manually specified the path + return True + return False + def _printDetectedDevice(self, volume_name, path): + cmd_line (_("Detected %(device)s with path %(path)s") % {'device': volume_name, 'path': path}) - def _init_prefs(self): - self.prefs = prefsrapid.RapidPreferences() - self.prefs.notify_add(self.on_preference_changed) + def _printAutoStart(self, autoStart): + if autoStart: + cmd_line(_("Automatically start download is true") ) + else: + cmd_line(_("Automatically start download is false") ) - # flag to indicate whether the user changed some preferences that - # indicate the image and backup devices should be setup again - self.rerun_setup_available_image_and_video_media = False - self.rerun_setup_available_backup_media = False + def setupAvailableImageAndBackupMedia(self, onStartup, onPreferenceChange, doNotAllowAutoStart): + """ + Sets up volumes for downloading from and backing up to - # flag to indicate that the preferences dialog window is being - # displayed to the user - self.preferences_dialog_displayed = False - - # flag to indicate that the user has modified the download today - # related values in the preferences dialog window - self.refresh_downloads_today = False + onStartup should be True if the program is still starting, i.e. this is being called from the + program's initialization. + + onPreferenceChange should be True if this is being called as the result of a preference + being changed + + Removes any image media that are currently not downloaded, + or finished downloading + """ + + self.clearNotStartedDownloads() + + volumeList = [] + self.backupVolumes = {} + + if not workers.noDownloadingWorkers(): + self.downloadStats.clear() + self._resetDownloadInfo() + + if self.usingVolumeMonitor(): + # either using automatically detected backup devices + # or download devices + + for v in self.volumeMonitor.get_mounts(): + volume = Volume(v) #'volumes' are actually mounts (legacy variable name at work here) + path = volume.get_path(avoid_gnomeVFS_bug = True) + + if path: + if path in self.prefs.device_blacklist and self.searchForPsd(): + cmd_line(_("Device %(device)s (%(path)s) ignored") % { + 'device': volume.get_name(limit=0), + 'path': path}) + else: + if not self.isGProxyShadowMount(v): + self._printDetectedDevice(volume.get_name(limit=0), path) + isBackupVolume = self.checkIfBackupVolume(path) + if isBackupVolume: + #backupPath = os.path.join(path, self.prefs.backup_identifier) + self.backupVolumes[path] = volume + elif self.prefs.device_autodetection and (media.is_DCIM_Media(path) or self.searchForPsd()): + volumeList.append((path, volume)) + + + if not self.prefs.device_autodetection: + # user manually specified the path from which to download + path = self.prefs.device_location + if path: + cmd_line(_("Using manually specified path") + " %s" % path) + volumeList.append((path, None)) + + if self.prefs.backup_images: + if not self.prefs.backup_device_autodetection: + # user manually specified backup location + # will backup to this path, but don't need any volume info associated with it + self.backupVolumes[self.prefs.backup_location] = None - self.downloads_today_tracker = self.prefs.get_downloads_today_tracker() + self.displayFreeSpace() + # add each memory card / other device to the list of threads - downloads_today = self.downloads_today_tracker.get_and_maybe_reset_downloads_today() - if downloads_today > 0: - logger.info("Downloads that have occurred so far today: %s", downloads_today) + if doNotAllowAutoStart: + autoStart = False else: - logger.info("No downloads have occurred so far today") - - self.downloads_today_value = Value(c_int, - self.downloads_today_tracker.get_raw_downloads_today()) - self.downloads_today_date_value = Array(c_char, - self.downloads_today_tracker.get_raw_downloads_today_date()) - self.day_start_value = Array(c_char, - self.downloads_today_tracker.get_raw_day_start()) - self.refresh_downloads_today_value = Value(c_bool, False) - self.stored_sequence_value = Value(c_int, self.prefs.stored_sequence_no) - self.uses_stored_sequence_no_value = Value(c_bool, self.prefs.any_pref_uses_stored_sequence_no()) - self.uses_session_sequece_no_value = Value(c_bool, self.prefs.any_pref_uses_session_sequece_no()) - self.uses_sequence_letter_value = Value(c_bool, self.prefs.any_pref_uses_sequence_letter_value()) + autoStart = (not onPreferenceChange) and ((self.prefs.auto_download_at_startup and onStartup) or (self.prefs.auto_download_upon_device_insertion and not onStartup)) - self.prefs.program_version = __version__ + self._printAutoStart(autoStart) - def _check_for_sequence_value_use(self): - self.uses_stored_sequence_no_value.value = self.prefs.any_pref_uses_stored_sequence_no() - self.uses_session_sequece_no_value.value = self.prefs.any_pref_uses_session_sequece_no() - self.uses_sequence_letter_value.value = self.prefs.any_pref_uses_sequence_letter_value() - - def on_preference_changed(self, key, value): + shownWarning = False + + for i in range(len(volumeList)): + path, volume = volumeList[i] + if volume: + if self.isCamera(volume.volume) and not shownWarning: + self.getShowWarningDownloadingFromCamera() + shownWarning = True + if self.searchForPsd() and path not in self.prefs.device_whitelist: + # prompt user to see if device should be used or not + self.getUseDevice(path, volume, autoStart) + else: + self.initiateScan(path, volume, autoStart) + + def refreshBackupMedia(self): """ - Called when user changes the program's preferences + Setup the backup media + + Assumptions: this is being called after the user has changed their preferences AND download media has already been setup """ - logger.debug("Preference change detected: %s", key) + self.backupVolumes = {} + if self.prefs.backup_images: + if not self.prefs.backup_device_autodetection: + # user manually specified backup location + # will backup to this path, but don't need any volume info associated with it + self.backupVolumes[self.prefs.backup_location] = None + else: + for v in self.volumeMonitor.get_mounts(): + volume = Volume(v) + path = volume.get_path(avoid_gnomeVFS_bug = True) + if path: + if self.checkIfBackupVolume(path): + # is a backup volume + if path not in self.backupVolumes: + # ensure it is not in a list of workers which have not started downloading + # if it is, remove it + for w in workers.getNotDownloadingAndNotFinishedWorkers(): + if w.cardMedia.path == path: + media_collection_treeview.removeCard(w.thread_id) + self.selection_vbox.selection_treeview.clear_all(w.thread_id) + workers.disableWorker(w.thread_id) + + downloading_workers = [] + for w in workers.getDownloadingWorkers(): + downloading_workers.append(w) + + for w in downloading_workers: + if w.cardMedia.path == path: + # the user is trying to backup to a device that is currently being downloaded from..... we don't normally allow that, but what to do? + cmd_line(_("Warning: backup device %(device)s is currently being downloaded from") % {'device': volume.get_name(limit=0)}) + + self.backupVolumes[path] = volume + + self.displayFreeSpace() + def _setupDownloadbuttons(self): + self.download_hbutton_box = gtk.HButtonBox() + self.download_hbutton_box.set_spacing(12) + self.download_hbutton_box.set_homogeneous(False) - if key == 'show_log_dialog': - self.menu_log_window.set_active(value) - elif key in ['device_autodetection', 'device_autodetection_psd', 'device_location']: - self.rerun_setup_available_image_and_video_media = True - if not self.preferences_dialog_displayed: - self.post_preference_change() - - elif key in ['backup_images', 'backup_device_autodetection', 'backup_location', 'backup_identifier', 'video_backup_identifier']: - self.rerun_setup_available_backup_media = True - if not self.preferences_dialog_displayed: - self.post_preference_change() + help_button = gtk.Button(stock=gtk.STOCK_HELP) + help_button.connect("clicked", self.on_help_button_clicked) + self.download_hbutton_box.pack_start(help_button) + self.download_hbutton_box.set_child_secondary(help_button, True) + + self.DOWNLOAD_SELECTED_LABEL = _("D_ownload Selected") + self.download_button_is_download = True + self.download_button = gtk.Button() + self.download_button.set_use_underline(True) + self.download_button.set_flags(gtk.CAN_DEFAULT) + self.download_selected_button = gtk.Button() + self.download_selected_button.set_use_underline(True) + self._set_download_button() + self.download_button.connect('clicked', self.on_download_button_clicked) + self.download_selected_button.connect('clicked', self.on_download_selected_button_clicked) + self.download_hbutton_box.set_layout(gtk.BUTTONBOX_END) + self.download_hbutton_box.pack_start(self.download_selected_button) + self.download_hbutton_box.pack_start(self.download_button) + self.download_hbutton_box.show_all() + self.buttons_hbox.pack_start(self.download_hbutton_box, + padding=hd.WINDOW_BORDER_SPACE) + + self.setDownloadButtonSensitivity() + + def set_display_selection(self, value): + if value: + self.selection_vbox.preview_table.show_all() + else: + self.selection_vbox.preview_table.hide() + self.selection_vbox.set_display_preview_folders(self.prefs.display_preview_folders) + + def set_display_preview_folders(self, value): + self.selection_vbox.set_display_preview_folders(value) + + def _resetDownloadInfo(self): + self.markSet = False + self.startTime = None + self.totalDownloadSize = self.totalDownloadedSoFar = 0 + self.totalDownloadSizeThisRun = self.totalDownloadedSoFarThisRun = 0 + # there is no need to clear self.timeRemaining, as when each thread is completed, it removes itself + + # this next value is used by the date time option "Download Time" + self.download_start_time = None + + global job_code + job_code = None + + def addToTotalDownloadSize(self, size): + self.totalDownloadSize += size + + def setOverallDownloadMark(self): + if not self.markSet: + self.markSet = True + self.totalDownloadSizeThisRun = self.totalDownloadSize - self.totalDownloadedSoFar + self.totalDownloadedSoFarThisRun = 0 - # Downloads today and stored sequence numbers are kept in shared memory, - # so that the subfolderfile daemon process can access and modify them + self.startTime = time.time() + self.timeStatusBarUpdated = self.startTime + + self.timeMark = self.startTime + self.sizeMark = 0 - # Note, totally ignore any changes in downloads today, as it - # is modified in a special manner via a tracking class - - elif key == 'stored_sequence_no': - if type(value) <> types.IntType: - logger.critical("Stored sequence number value is malformed") - else: - self.stored_sequence_value.value = value - - elif key in ['image_rename', 'subfolder', 'video_rename', 'video_subfolder']: - # Check if stored sequence no is being used - self._check_for_sequence_value_use() - - #~ elif key == 'job_codes': - #~ # update job code list in left pane - #~ self.selection_vbox.update_job_code_combo() + def startOrResumeWorkers(self, threads): + + # resume any paused workers + for w in workers.getPausedDownloadingWorkers(): + w.startStop() + self.timeRemaining.setTimeMark(w) + + # set the time that the download started - this is used + # in the "Download Time" date time renaming option. + self.setDownloadStartTime() + - elif key in ['download_folder', 'video_download_folder']: - self.display_free_space() + #start any new workers that have downloads pending + for i in threads: + workers[i].startStop() + + if is_beta and verbose and False: + workers.printWorkerStatus() - def post_preference_change(self): - if self.rerun_setup_available_image_and_video_media: - if self.using_volume_monitor(): - self.start_volume_monitor() - logger.info("Download device settings preferences were changed.") - - self.thumbnails.clear_all() - self.setup_devices(on_startup = False, on_preference_change = True, do_not_allow_auto_start = True) - if self.main_notebook.get_current_page() == 1: # preview of file - self.main_notebook.set_current_page(0) + def setDownloadStartTime(self): + if not self.download_start_time: + self.download_start_time = datetime.datetime.now() + + def updateOverallProgress(self, thread_id, bytesDownloaded, percentComplete): + """ + Updates progress bar and status bar text with time remaining + to download images + """ - self.rerun_setup_available_image_and_video_media = False - - if self.rerun_setup_available_backup_media: - if self.using_volume_monitor(): - self.start_volume_monitor() - logger.info("Backup preferences were changed.") - - logger.info("self.refreshBackupMedia()") - - self.rerun_setup_available_backup_media = False - - if self.refresh_downloads_today: - self.downloads_today_value.value = self.downloads_today_tracker.get_raw_downloads_today() - self.downloads_today_date_value.value = self.downloads_today_tracker.get_raw_downloads_today_date() - self.day_start_value.value = self.downloads_today_tracker.get_raw_day_start() - self.refresh_downloads_today_value.value = True - self.prefs.set_downloads_today_from_tracker(self.downloads_today_tracker) + self.totalDownloadedSoFar += bytesDownloaded + self.totalDownloadedSoFarThisRun += bytesDownloaded + + fraction = self.totalDownloadedSoFar / float(self.totalDownloadSize) + + self.download_progressbar.set_fraction(fraction) + + if percentComplete == 100.0: + self.menu_clear.set_sensitive(True) + self.timeRemaining.remove(thread_id) + + if self.downloadComplete(): + # finished all downloads + self.rapid_statusbar.push(self.statusbar_context_id, "") + self.download_button_is_download = True + self._set_download_button() + self.setDownloadButtonSensitivity() + cmd_line(_("All downloads complete")) + job_code = None + if is_beta and verbose and False: + workers.printWorkerStatus() + + else: + now = time.time() + self.timeRemaining.update(thread_id, bytesDownloaded) - + if now > (self.downloadTimeGap + self.timeMark): + amtTime = now - self.timeMark + self.timeMark = now + amtDownloaded = self.totalDownloadedSoFarThisRun - self.sizeMark + self.sizeMark = self.totalDownloadedSoFarThisRun + amtToDownload = float(self.totalDownloadSizeThisRun) - self.totalDownloadedSoFarThisRun + downloadSpeed = "%1.1f" % (amtDownloaded / 1048576 / amtTime) +_("MB/s") + self.speed_label.set_text(downloadSpeed) + + timeRemaining = self.timeRemaining.timeRemaining() + if timeRemaining: + secs = int(timeRemaining) + + if secs == 0: + message = "" + elif secs == 1: + message = _("About 1 second remaining") + elif secs < 60: + message = _("About %i seconds remaining") % secs + elif secs == 60: + message = _("About 1 minute remaining") + else: + # Translators: in the text '%(minutes)i:%(seconds)02i', only the : should be translated, if needed. + # '%(minutes)i' and '%(seconds)02i' should not be modified or left out. They are used to format and display the amount + # of time the download has remainging, e.g. 'About 5:36 minutes remaining' + message = _("About %(minutes)i:%(seconds)02i minutes remaining") % {'minutes': secs / 60, 'seconds': secs % 60} + + self.rapid_statusbar.pop(self.statusbar_context_id) + self.rapid_statusbar.push(self.statusbar_context_id, message) + - # # # - # Main app window management and setup - # # # + def resetSequences(self): + if self.downloadComplete(): + sequences.reset(self.prefs.getDownloadsToday(), self.prefs.stored_sequence_no) - def _init_widgets(self): - """ - Initialize widgets in the main window, and variables that point to them - """ - builder = gtk.Builder() - self.builder = builder - builder.add_from_file(paths.share_dir("glade3/rapid.ui")) - self.rapidapp = builder.get_object("rapidapp") - self.main_vpaned = builder.get_object("main_vpaned") - self.main_notebook = builder.get_object("main_notebook") - self.download_action = builder.get_object("download_action") + def notifyUserAllDownloadsComplete(self): + """ If all downloads are complete, if needed notify the user using libnotify + + Reset progress bar info""" + + if self.downloadComplete(): + if self.displayDownloadSummaryNotification: + message = _("All downloads complete") + if self.downloadStats.noImagesDownloaded: + filetype = file_types_by_number(self.downloadStats.noImagesDownloaded, 0) + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': self.downloadStats.noImagesDownloaded, + 'numberdownloaded': _("%(filetype)s downloaded") % \ + {'filetype': filetype}} + if self.downloadStats.noImagesSkipped: + filetype = file_types_by_number(self.downloadStats.noImagesSkipped, 0) + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': self.downloadStats.noImagesSkipped, + 'numberdownloaded': _("%(filetype)s failed to download") % \ + {'filetype': filetype}} + if self.downloadStats.noVideosDownloaded: + filetype = file_types_by_number(0, self.downloadStats.noVideosDownloaded) + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': self.downloadStats.noVideosDownloaded, + 'numberdownloaded': _("%(filetype)s downloaded") % \ + {'filetype': filetype}} + if self.downloadStats.noVideosSkipped: + filetype = file_types_by_number(0, self.downloadStats.noVideosSkipped) + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': self.downloadStats.noVideosSkipped, + 'numberdownloaded': _("%(filetype)s failed to download") % \ + {'filetype': filetype}} + if self.downloadStats.noWarnings: + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': self.downloadStats.noWarnings, + 'numberdownloaded': _("warnings")} + if self.downloadStats.noErrors: + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': self.downloadStats.noErrors, + 'numberdownloaded': _("errors")} + + n = pynotify.Notification(PROGRAM_NAME, message) + n.set_icon_from_pixbuf(self.application_icon) + n.show() + self.displayDownloadSummaryNotification = False # don't show it again unless needed + # download statistics are cleared in exitOnDownloadComplete() + self._resetDownloadInfo() + self.speed_label.set_text(' ') + self.displayFreeSpace() + + + def exitOnDownloadComplete(self): + if self.downloadComplete(): + if self.prefs.auto_exit: + if not (self.downloadStats.noErrors or self.downloadStats.noWarnings): + self.quit() + # since for whatever reason am not exiting, clear the download statistics + self.downloadStats.clear() - self.download_progressbar = builder.get_object("download_progressbar") - self.rapid_statusbar = builder.get_object("rapid_statusbar") - self.statusbar_context_id = self.rapid_statusbar.get_context_id("progress") - self.device_collection_scrolledwindow = builder.get_object("device_collection_scrolledwindow") - self.next_image_action = builder.get_object("next_image_action") - self.prev_image_action = builder.get_object("prev_image_action") - self.menu_log_window = builder.get_object("menu_log_window") + + def downloadFailed(self, thread_id): + if workers.noDownloadingWorkers() == 0: + self.download_button_is_download = True + self._set_download_button() + self.setDownloadButtonSensitivity() + + def downloadComplete(self): + return self.totalDownloadedSoFar == self.totalDownloadSize + + def setDownloadButtonSensitivity(self): + + isSensitive = (workers.noReadyToDownloadWorkers() > 0 and + workers.noScanningWorkers() == 0 and + self.selection_vbox.selection_treeview.rows_available_for_download()) or \ + workers.noDownloadingWorkers() > 0 - # Only enable this action when actually displaying a preview - self.next_image_action.set_sensitive(False) - self.prev_image_action.set_sensitive(False) + if isSensitive: + self.download_button.props.sensitive = True + # download selected button sensitity is enabled only when the user selects something + self.selection_vbox.selection_treeview.update_download_selected_button() + self.menu_download_pause.props.sensitive = True + else: + self.download_button.props.sensitive = False + self.download_selected_button.props.sensitive = False + self.menu_download_pause.props.sensitive = False + + return isSensitive - # About dialog - builder.add_from_file(paths.share_dir("glade3/about.ui")) - self.about = builder.get_object("about") - builder.connect_signals(self) + def on_rapidapp_destroy(self, widget): + """Called when the application is going to quit""" - self.preview_image = PreviewImage(self, builder) + # save window and component sizes + self.prefs.hpaned_pos = self.selection_vbox.file_hpaned.get_position() + self.prefs.vpaned_pos = self.main_vpaned.get_position() - thumbnails_scrolledwindow = builder.get_object('thumbnails_scrolledwindow') - self.thumbnails = ThumbnailDisplay(self) - thumbnails_scrolledwindow.add(self.thumbnails) - - #collection of devices from which to download - self.device_collection_viewport = builder.get_object("device_collection_viewport") - self.device_collection = DeviceCollection(self) - self.device_collection_viewport.add(self.device_collection) + x, y = self.rapidapp.get_size() + self.prefs.main_window_size_x = x + self.prefs.main_window_size_y = y + + workers.quitAllWorkers() + + self.flushevents() - #error log window - self.error_log = errorlog.ErrorLog(self) + display_queue.close("w") + + + def on_rapidapp_window_state_event(self, widget, event): + """ Checkto see if the user maximized the main application window or not. """ + if event.changed_mask & gdk.WINDOW_STATE_MAXIMIZED: + self.prefs.main_window_maximized = event.new_window_state & gdk.WINDOW_STATE_MAXIMIZED + + + def on_menu_clear_activate(self, widget): + self.clearCompletedDownloads() + widget.set_sensitive(False) - # monitor to handle mounts and dismounts - self.vmonitor = None - # track scan ids for mount paths - very useful when a device is unmounted - self.mounts_by_path = {} + def on_menu_refresh_activate(self, widget): + self.selection_vbox.selection_treeview.clear_all() + self.setupAvailableImageAndBackupMedia(onStartup = False, onPreferenceChange = True, doNotAllowAutoStart = True) - # Download action state - self.download_action_is_download = True + def on_menu_report_problem_activate(self, widget): + webbrowser.open("https://bugs.launchpad.net/rapid") - #job code initialization - self.last_chosen_job_code = None - self.prompting_for_job_code = False + def on_menu_get_help_online_activate(self, widget): + webbrowser.open("http://www.damonlynch.net/rapid/help.html") - def _set_window_size(self): - """ - Remember the window size from the last time the program was run, or - set a default size - """ - - if self.prefs.main_window_maximized: - self.rapidapp.maximize() - self.rapidapp.set_default_size(config.DEFAULT_WINDOW_WIDTH, - config.DEFAULT_WINDOW_HEIGHT) - elif self.prefs.main_window_size_x > 0: - self.rapidapp.set_default_size(self.prefs.main_window_size_x, self.prefs.main_window_size_y) - else: - # set a default size - self.rapidapp.set_default_size(config.DEFAULT_WINDOW_WIDTH, - config.DEFAULT_WINDOW_HEIGHT) - + def on_menu_donate_activate(self, widget): + webbrowser.open("http://www.damonlynch.net/rapid/donate.html") - def _set_device_collection_size(self): - """ - Set the size of the device collection scrolled window widget - """ - + def on_menu_translate_activate(self, widget): + webbrowser.open("http://www.damonlynch.net/rapid/translate.html") + + def on_menu_preferences_activate(self, widget): + """ Sets preferences for the application using dialog window """ + + PreferencesDialog(self) - if self.device_collection.map_process_to_row: - height = self.device_collection_viewport.size_request()[1] - self.device_collection_scrolledwindow.set_size_request(-1, height) + def on_menu_log_window_toggled(self, widget): + active = widget.get_active() + self.prefs.show_log_dialog = active + if active: + log_dialog.widget.show() else: - # don't allow the media collection to be absolutely empty - self.device_collection_scrolledwindow.set_size_request(-1, 47) - - - def on_rapidapp_window_state_event(self, widget, event): - """ Records the window maximization state in the preferences.""" - - if event.changed_mask & gdk.WINDOW_STATE_MAXIMIZED: - self.prefs.main_window_maximized = event.new_window_state & gdk.WINDOW_STATE_MAXIMIZED + log_dialog.widget.hide() + + def on_menu_display_selection_toggled(self, check_button): + self.prefs.display_selection = check_button.get_active() - def _setup_buttons(self): - thumbnails_button = self.builder.get_object("thumbnails_button") - image = gtk.image_new_from_file(paths.share_dir('glade3/thumbnails_icon.png')) - thumbnails_button.set_image(image) + def on_menu_preview_folders_toggled(self, check_button): + self.prefs.display_preview_folders = check_button.get_active() - preview_button = self.builder.get_object("preview_button") - image = gtk.image_new_from_file(paths.share_dir('glade3/photo_icon.png')) - preview_button.set_image(image) + def on_menu_zoom_out_activate(self, widget): + self.selection_vbox.zoom_out() - next_image_button = self.builder.get_object("next_image_button") - image = gtk.image_new_from_stock(gtk.STOCK_GO_FORWARD, gtk.ICON_SIZE_BUTTON) - next_image_button.set_image(image) + def on_menu_zoom_in_activate(self, widget): + self.selection_vbox.zoom_in() - prev_image_button = self.builder.get_object("prev_image_button") - image = gtk.image_new_from_stock(gtk.STOCK_GO_BACK, gtk.ICON_SIZE_BUTTON) - prev_image_button.set_image(image) - - def _setup_icons(self): - icons = ['rapid-photo-downloader-jobcode',] - icon_list = [(icon, paths.share_dir('glade3/%s.svg' % icon)) for icon in icons] - register_iconsets(icon_list) + def on_menu_select_all_activate(self, widget): + self.selection_vbox.selection_treeview.select_rows('all') + + def on_menu_select_all_photos_activate(self, widget): + self.selection_vbox.selection_treeview.select_rows('photos') - def _setup_error_icons(self): - """ - hide display of warning and error symbols in the taskbar until they - are needed - """ - self.error_image = self.builder.get_object("error_image") - self.warning_image = self.builder.get_object("warning_image") - self.warning_vseparator = self.builder.get_object("warning_vseparator") - self.error_image.hide() - self.warning_image.hide() - self.warning_vseparator.hide() + def on_menu_select_all_videos_activate(self, widget): + self.selection_vbox.selection_treeview.select_rows('videos') - def statusbar_message(self, msg): - self.rapid_statusbar.push(self.statusbar_context_id, msg) - - def statusbar_message_remove(self): - self.rapid_statusbar.pop(self.statusbar_context_id) + def on_menu_select_none_activate(self, widget): + self.selection_vbox.selection_treeview.select_rows('none') - def display_free_space(self): + def on_menu_select_all_with_job_code_activate(self, widget): + self.selection_vbox.selection_treeview.select_rows('withjobcode') + + def on_menu_select_all_without_job_code_activate(self, widget): + self.selection_vbox.selection_treeview.select_rows('withoutjobcode') + + + def on_menu_about_activate(self, widget): + """ Display about dialog box """ + + about = gtk.glade.XML(paths.share_dir(config.GLADE_FILE), "about").get_widget("about") + about.set_property("name", PROGRAM_NAME) + about.set_property("version", __version__) + about.run() + about.destroy() + + def _set_download_button(self): """ - Displays the amount of space free on the filesystem the files will be - downloaded to. - - Also displays backup volumes / path being used. (NOT IMPLEMENTED YET) + Sets download button to appropriate state """ - photo_dir = self.is_valid_download_dir(path=self.prefs.download_folder, is_photo_dir=True, show_error_in_log=True) - video_dir = self.is_valid_download_dir(path=self.prefs.video_download_folder, is_photo_dir=False, show_error_in_log=True) - if photo_dir and video_dir: - same_file_system = self.same_file_system(self.prefs.download_folder, - self.prefs.video_download_folder) - else: - same_file_system = False - - dirs = [] - if photo_dir: - dirs.append((self.prefs.download_folder, _("photos"))) - if video_dir and not same_file_system: - dirs.append((self.prefs.video_download_folder, _("videos"))) - - msg = '' - if len(dirs) > 1: - msg = ' ' + _('Free space:') + ' ' - - for i in range(len(dirs)): - dir_info = dirs[i] - folder = gio.File(dir_info[0]) - file_info = folder.query_filesystem_info(gio.FILE_ATTRIBUTE_FILESYSTEM_FREE) - size = file_info.get_attribute_uint64(gio.FILE_ATTRIBUTE_FILESYSTEM_FREE) - free = format_size_for_user(bytes=size) - if len(dirs) > 1: - #(videos) or (photos) will be appended to the free space message displayed to the - #user in the status bar. - #you should only translate this if your language does not use parantheses - file_type = _("(%(file_type)s)") % {'file_type': dir_info[1]} - - #Freespace available on the filesystem for downloading to - #Displayed in status bar message on main window - msg += _("%(free)s %(file_type)s") % {'free': free, 'file_type': file_type} - if i == 0: - #Inserted in the middle of the statusbar message concerning the amount of freespace - #Used to differentiate between two different file systems - #e.g. Free space: 21.3GB (photos); 14.7GB (videos). - msg += _("; ") - else: - #Inserted at the end of the statusbar message concerning the amount of freespace - #Used to differentiate between two different file systems - #e.g. Free space: 21.3GB (photos); 14.7GB (videos). - msg += _(".") - - else: - #Freespace available on the filesystem for downloading to - #Displayed in status bar message on main window - #e.g. 14.7GB available - msg = " " + _("%(free)s free") % {'free': free} + if self.download_button_is_download: + # This text will be displayed to the user on the Download / Pause button. + self.download_selected_button.set_label(self.DOWNLOAD_SELECTED_LABEL) + self.download_selected_button.set_image(gtk.image_new_from_stock( + gtk.STOCK_CONVERT, + gtk.ICON_SIZE_BUTTON)) + self.selection_vbox.selection_treeview.update_download_selected_button() - if self.prefs.backup_images and False: #FIXME: skip this for now! - if not self.prefs.backup_device_autodetection: - # user manually specified backup location - msg2 = _('Backing up to %(path)s') % {'path':self.prefs.backup_location} - else: - msg2 = self.displayBackupVolumes() #FIXME - - if msg: - msg = _("%(freespace)s. %(backuppaths)s.") % {'freespace': msg, 'backuppaths': msg2} + self.download_button.set_image(gtk.image_new_from_stock( + gtk.STOCK_CONVERT, + gtk.ICON_SIZE_BUTTON)) + + if workers.noPausedWorkers(): + self.download_button.set_label(_("_Resume")) + self.download_selected_button.hide() else: - msg = msg2 - - msg = msg.rstrip() + self.download_button.set_label(_("_Download All")) + self.download_selected_button.show_all() + + else: + # button should indicate paused state + self.download_button.set_image(gtk.image_new_from_stock( + gtk.STOCK_MEDIA_PAUSE, + gtk.ICON_SIZE_BUTTON)) + # This text will be displayed to the user on the Download / Pause button. + self.download_button.set_label(_("_Pause")) + self.download_selected_button.set_sensitive(False) + self.download_selected_button.hide() - self.statusbar_message(msg) + def on_menu_download_pause_activate(self, widget): + self.on_download_button_clicked(widget) - def log_error(self, severity, problem, details, extra_detail=None): - """ - Display error and warning messages to user in log window + def startScan(self): + if workers.noReadyToStartWorkers() > 0: + workers.startWorkers() + + def postStartDownloadTasks(self): + if workers.noDownloadingWorkers() > 1: + self.displayDownloadSummaryNotification = True + + # set button to display Pause + self.download_button_is_download = False + self._set_download_button() + + def startDownload(self, threads): + self.startOrResumeWorkers(threads) + self.postStartDownloadTasks() + + def pauseDownload(self): + for w in workers.getDownloadingWorkers(): + w.startStop() + # set button to display Download + if not self.download_button_is_download: + self.download_button_is_download = True + self._set_download_button() + + def on_download_button_clicked(self, widget): """ - self.error_log.add_message(severity, problem, details, extra_detail) - - - def on_error_eventbox_button_press_event(self, widget, event): - self.prefs.show_log_dialog = True - self.error_log.widget.show() + Handle download button click. + Button is in one of three states: download all, resume, or pause. - def on_menu_log_window_toggled(self, widget): - active = widget.get_active() - self.prefs.show_log_dialog = active - if active: - self.error_log.widget.show() + If download, a click indicates to start or resume a download run. + If pause, a click indicates to pause all running downloads. + """ + if self.download_button_is_download: + if need_job_code_for_renaming and self.selection_vbox.selection_treeview.job_code_missing(False) and not self.prompting_for_job_code: + self.getJobCode(autoStart=False, downloadSelected=False) + else: + threads = self.selection_vbox.selection_treeview.set_status_to_download_pending(selected_only = False) + self.startDownload(threads) + self._set_download_button() else: - self.error_log.widget.hide() - - - # # # - # Utility functions - # # # + self.pauseDownload() + + def on_download_selected_button_clicked(self, widget): + # set the status of the selected workers to be downloading pending + if need_job_code_for_renaming and self.selection_vbox.selection_treeview.job_code_missing(True) and not self.prompting_for_job_code: + self.getJobCode(autoStart=False, downloadSelected=True) + else: + threads = self.selection_vbox.selection_treeview.set_status_to_download_pending(selected_only = True) + self.startDownload(threads) + - def files_of_type_present(self, files, file_type): - """ - Returns true if there is at least one instance of the file_type - in the list of files to be copied - """ - for rpd_file in files: - if rpd_file.file_type == file_type: - return True - return False - def size_files_to_be_downloaded(self, files): - """ - Returns the total size of the files to be downloaded in bytes + def on_help_button_clicked(self, widget): + webbrowser.open("http://www.damonlynch.net/rapid/help.html") + + def on_preference_changed(self, key, value): """ - size = 0 - for i in range(len(files)): - size += files[i].size - - return size - - def check_download_folder_validity(self, files_by_scan_pid): + Called when user changes the program's preferences """ - Checks validity of download folders based on the file types the user - is attempting to download. - If valid, returns a tuple of True and an empty list. - If invalid, returns a tuple of False and a list of the invalid directores. + if key == 'display_selection': + self.set_display_selection(value) + elif key == 'display_preview_folders': + self.set_display_preview_folders(value) + elif key == 'show_log_dialog': + self.menu_log_window.set_active(value) + elif key in ['device_autodetection', 'device_autodetection_psd', 'device_location']: + self.rerunSetupAvailableImageAndVideoMedia = True + if not self.preferencesDialogDisplayed: + self.postPreferenceChange() + + elif key in ['backup_images', 'backup_device_autodetection', 'backup_location', 'backup_identifier', 'video_backup_identifier']: + self.rerunSetupAvailableBackupMedia = True + if not self.preferencesDialogDisplayed: + self.postPreferenceChange() + + elif key in ['subfolder', 'image_rename', 'video_subfolder', 'video_rename']: + global need_job_code_for_renaming + need_job_code_for_renaming = self.needJobCodeForRenaming() + self.selection_vbox.set_job_code_display() + self.menu_select_all_without_job_code.set_sensitive(need_job_code_for_renaming) + self.menu_select_all_with_job_code.set_sensitive(need_job_code_for_renaming) + self.refreshGeneratedSampleSubfolderAndName = True + + if not self.preferencesDialogDisplayed: + self.postPreferenceChange() + + elif key in ['download_folder', 'video_download_folder']: + self.refreshSampleDownloadFolder = True + if not self.preferencesDialogDisplayed: + self.postPreferenceChange() + + elif key == 'job_codes': + # update job code list in left pane + self.selection_vbox.update_job_code_combo() + + + def postPreferenceChange(self): """ - valid = True - invalid_dirs = [] - # first, check what needs to be downloaded - photos and / or videos - need_photo_folder = False - need_video_folder = False - while not need_photo_folder and not need_video_folder: - for scan_pid in files_by_scan_pid: - files = files_by_scan_pid[scan_pid] - if not need_photo_folder: - if self.files_of_type_present(files, rpdfile.FILE_TYPE_PHOTO): - need_photo_folder = True - if not need_video_folder: - if self.files_of_type_present(files, rpdfile.FILE_TYPE_VIDEO): - need_video_folder = True + Handle changes in program preferences after the preferences dialog window has been closed + """ + if self.rerunSetupAvailableImageAndVideoMedia: + if self.usingVolumeMonitor(): + self.startVolumeMonitor() + cmd_line("\n" + _("Download device settings preferences were changed.")) - # second, check validity - if need_photo_folder: - if not self.is_valid_download_dir(self.prefs.download_folder, - is_photo_dir=True): - valid = False - invalid_dirs.append(self.prefs.download_folder) + self.selection_vbox.selection_treeview.clear_all() + self.setupAvailableImageAndBackupMedia(onStartup = False, onPreferenceChange = True, doNotAllowAutoStart = True) + if is_beta and verbose and False: + workers.printWorkerStatus() - if need_video_folder: - if not self.is_valid_download_dir(self.prefs.video_download_folder, - is_photo_dir=False): - valid = False - invalid_dirs.append(self.prefs.video_download_folder) + self.rerunSetupAvailableImageAndVideoMedia = False + + if self.rerunSetupAvailableBackupMedia: + if self.usingVolumeMonitor(): + self.startVolumeMonitor() + cmd_line("\n" + _("Backup preferences were changed.")) + + self.refreshBackupMedia() + self.rerunSetupAvailableBackupMedia = False + + if self.refreshGeneratedSampleSubfolderAndName: + cmd_line("\n" + _("Subfolder and filename preferences were changed.")) + for w in workers.getScanningWorkers(): + if not w.scanResultsStale: + w.scanResultsStale = True + self.noAfterScanRefreshGeneratedSampleSubfolderAndName += 1 - return (valid, invalid_dirs) + self.selection_vbox.selection_treeview.refreshGeneratedSampleSubfolderAndName() + self.refreshGeneratedSampleSubfolderAndName = False + self.setDownloadButtonSensitivity() + + if self.refreshSampleDownloadFolder: + cmd_line("\n" + _("Download folder preferences were changed.")) + for w in workers.getScanningWorkers(): + if not w.scanResultsStaleDownloadFolder: + w.scanResultsStaleDownloadFolder = True + self.noAfterScanRefreshSampleDownloadFolders += 1 + + self.selection_vbox.selection_treeview.refreshSampleDownloadFolders() + self.refreshSampleDownloadFolder = False - def same_file_system(self, file1, file2): - """Returns True if the files / diretories are on the same file system + def regenerateScannedDevices(self, thread_id): """ - f1 = gio.File(file1) - f2 = gio.File(file2) - f1_info = f1.query_info(gio.FILE_ATTRIBUTE_ID_FILESYSTEM) - f1_id = f1_info.get_attribute_string(gio.FILE_ATTRIBUTE_ID_FILESYSTEM) - f2_info = f2.query_info(gio.FILE_ATTRIBUTE_ID_FILESYSTEM) - f2_id = f2_info.get_attribute_string(gio.FILE_ATTRIBUTE_ID_FILESYSTEM) - return f1_id == f2_id + Regenerate the filenames / subfolders / download folders for this thread - - def same_file(self, file1, file2): - """Returns True if the files / directories are the same + The user must have adjusted their preferences as the device was being scanned """ - f1 = gio.File(file1) - f2 = gio.File(file2) - - file_attributes = "id::file" - f1_info = f1.query_filesystem_info(file_attributes) - f1_id = f1_info.get_attribute_string(gio.FILE_ATTRIBUTE_ID_FILE) - f2_info = f2.query_filesystem_info(file_attributes) - f2_id = f2_info.get_attribute_string(gio.FILE_ATTRIBUTE_ID_FILE) - return f1_id == f2_id - def is_valid_download_dir(self, path, is_photo_dir, show_error_in_log=False): - """ - Checks the following conditions: - Does the directory exist? - Is it writable? + if self.noAfterScanRefreshSampleDownloadFolders: + # no point updating it if we're going to update it in the + # refresh of sample names and subfolders anway! + if not self.noAfterScanRefreshGeneratedSampleSubfolderAndName: + self.selection_vbox.selection_treeview.refreshSampleDownloadFolders(thread_id) + self.noAfterScanRefreshSampleDownloadFolders -= 1 + + if self.noAfterScanRefreshGeneratedSampleSubfolderAndName: + self.selection_vbox.selection_treeview.refreshGeneratedSampleSubfolderAndName(thread_id) + self.noAfterScanRefreshGeneratedSampleSubfolderAndName -= 1 + + - if show_error_in_log is True, then display warning in log window, using - is_photo_dir, which if true means the download directory is for photos, - if false, for Videos - """ - valid = False - if is_photo_dir: - download_folder_type = _("Photo") + + def on_error_eventbox_button_press_event(self, widget, event): + self.prefs.show_log_dialog = True + log_dialog.widget.show() + +class VMonitor: + """ Transistion to gvfs from gnomevfs""" + def __init__(self, app): + self.app = app + if using_gio: + self.vmonitor = gio.volume_monitor_get() + self.vmonitor.connect("mount-added", self.app.on_volume_mounted) + self.vmonitor.connect("mount-removed", self.app.on_volume_unmounted) else: - download_folder_type = _("Video") + self.vmonitor = gnomevfs.VolumeMonitor() + self.vmonitor.connect("volume-mounted", self.app.on_volume_mounted) + self.vmonitor.connect("volume-unmounted", self.app.on_volume_unmounted) - try: - d = gio.File(path) - if not d.query_exists(cancellable=None): - logger.error("%s download folder does not exist: %s", - download_folder_type, path) - if show_error_in_log: - severity = config.WARNING - problem = _("%(file_type)s download folder does not exist") % { - 'file_type': download_folder_type} - details = _("Folder: %s") % path - self.log_error(severity, problem, details) - else: - file_attributes = "standard::type,access::can-read,access::can-write" - file_info = d.query_filesystem_info(file_attributes) - file_type = file_info.get_file_type() - - if file_type != gio.FILE_TYPE_DIRECTORY and file_type != gio.FILE_TYPE_UNKNOWN: - logger.error("%s download folder is invalid: %s", - download_folder_type, path) - if show_error_in_log: - severity = config.WARNING - problem = _("%(file_type)s download folder is invalid") % { - 'file_type': download_folder_type} - details = _("Folder: %s") % path - self.log_error(severity, problem, details) - else: - # is the directory writable? - try: - temp_dir = tempfile.mkdtemp(prefix="rpd-tmp", dir=path) - valid = True - except: - logger.error("%s is not writable", path) - if show_error_in_log: - severity = config.WARNING - problem = _("%(file_type)s download folder is not writable") % { - 'file_type': download_folder_type} - details = _("Folder: %s") % path - self.log_error(severity, problem, details) - else: - f = gio.File(temp_dir) - f.delete(cancellable=None) - except gio.Error, inst: - logger.error("Error checking download directory %s", path) - logger.error(inst) + def get_mounts(self): + if using_gio: + return self.vmonitor.get_mounts() + else: + return self.vmonitor.get_mounted_volumes() - return valid - - - - # # # - # Process results and management - # # # - +class Volume: + """ Transistion to gvfs from gnomevfs""" + def __init__(self, volume): + self.volume = volume + + def get_name(self, limit=config.MAX_LENGTH_DEVICE_NAME): + if using_gio: + v = self.volume.get_name() + else: + v = self.volume.get_display_name() + + if limit: + if len(v) > limit: + v = v[:limit] + '...' + return v - def _start_process_managers(self): - """ - Set up process managers. + def get_path(self, avoid_gnomeVFS_bug = False): + if using_gio: + path = self.volume.get_root().get_path() + else: + uri = self.volume.get_activation_uri() + path = None + if avoid_gnomeVFS_bug: + # ugly hack to work around bug where gnomevfs.get_local_path_from_uri(uri) causes a crash + mediaLocation = "file://" + config.MEDIA_LOCATION + if uri.find(mediaLocation) == 0: + path = gnomevfs.get_local_path_from_uri(uri) + else: + path = gnomevfs.get_local_path_from_uri(uri) + return path - A task such as scanning a device or copying files is handled in its - own process. - """ - self.batch_size = 10 - self.batch_size_MB = 2 + def get_icon_pixbuf(self, size): + """ returns icon for the volume, or None if not available""" - sequence_values = (self.downloads_today_value, - self.downloads_today_date_value, - self.day_start_value, - self.refresh_downloads_today_value, - self.stored_sequence_value, - self.uses_stored_sequence_no_value, - self.uses_session_sequece_no_value, - self.uses_sequence_letter_value) - - self.subfolder_file_manager = SubfolderFileManager( - self.subfolder_file_results, - sequence_values) + return common.get_icon_pixbuf(using_gio, self.volume.get_icon(), size) + def unmount(self, callback): + self.volume.unmount(callback) + +class DownloadStats: + def __init__(self): + self.clear() - self.generate_folder = False - self.scan_manager = ScanManager(self.scan_results, self.batch_size, - self.generate_folder, self.device_collection.add_device) - self.copy_files_manager = CopyFilesManager(self.copy_files_results, - self.batch_size_MB) + def adjust(self, size, noImagesDownloaded, noVideosDownloaded, noImagesSkipped, noVideosSkipped, noWarnings, noErrors): + self.downloadSize += size + self.noImagesDownloaded += noImagesDownloaded + self.noVideosDownloaded += noVideosDownloaded + self.noImagesSkipped += noImagesSkipped + self.noVideosSkipped += noVideosSkipped + self.noWarnings += noWarnings + self.noErrors += noErrors + + def clear(self): + self.noImagesDownloaded = self.noVideosDownloaded = self.noImagesSkipped = self.noVideosSkipped = 0 + self.downloadSize = 0 + self.noWarnings = self.noErrors = 0 + +class DownloadedFiles: + def __init__(self): + self.images = {} + + def add_download(self, name, extension, date_time, sub_seconds, sequence_number_used): + if name not in self.images: + self.images[name] = ([extension], date_time, sub_seconds, sequence_number_used) + else: + if extension not in self.images[name][0]: + self.images[name][0].append(extension) + - def scan_results(self, source, condition): - """ - Receive results from scan processes - """ - connection = self.scan_manager.get_pipe(source) + def matching_pair(self, name, extension, date_time, sub_seconds): + """Checks to see if the image matches an image that has already been downloaded. + Image name (minus extension), exif date time, and exif subseconds are checked. - conn_type, data = connection.recv() + Returns -1 and a sequence number if the name, extension, and exif values match (i.e. it has already been downloaded) + Returns 0 and a sequence number if name and exif values match, but the extension is different (i.e. a matching RAW + JPG image) + Returns -99 and a sequence number of None if images detected with the same filenames, but taken at different times + Returns 1 and a sequence number of None if no match""" - if conn_type == rpdmp.CONN_COMPLETE: - connection.close() - size, file_type_counter, scan_pid = data - size = format_size_for_user(bytes=size) - results_summary, file_types_present = file_type_counter.summarize_file_count() - self.download_tracker.set_file_types_present(scan_pid, file_types_present) - logger.info('Found %s' % results_summary) - logger.info('Files total %s' % size) - self.device_collection.update_device(scan_pid, size) - self.device_collection.update_progress(scan_pid, 0.0, results_summary, 0) - self.testing_auto_exit_trip_counter += 1 - if self.testing_auto_exit_trip_counter == self.testing_auto_exit_trip and self.testing_auto_exit: - self.on_rapidapp_destroy(self.rapidapp) - else: - if not self.testing_auto_exit: - self.download_progressbar.set_text(_("Thumbnails")) - self.thumbnails.generate_thumbnails(scan_pid) - self.set_download_action_sensitivity() - self.set_thumbnail_sort() - - # signal that no more data is coming, finishing io watch for this pipe - return False - else: - if len(data) > self.batch_size: - logger.critical("incoming pipe length is unexpectedly long: %s" % len(data)) + if name in self.images: + if self.images[name][1] == date_time and self.images[name][2] == sub_seconds: + if extension in self.images[name][0]: + return (-1, self.images[name][3]) + else: + return (0, self.images[name][3]) else: - for rpd_file in data: - self.thumbnails.add_file(rpd_file) + return (-99, None) + return (1, None) - # must return True for this method to be called again - return True + def extExifDateTime(self, name): + """Returns first extension, exif date time and subseconds data for the already downloaded image""" + return (self.images[name][0][0], self.images[name][1], self.images[name][2]) +class TimeForDownload: + # used to store variables, see below + pass + +class TimeRemaining: + gap = 2 + def __init__(self): + self.clear() + def set(self, w, size): + t = TimeForDownload() + t.timeRemaining = None + t.size = size + t.downloaded = 0 + t.sizeMark = 0 + t.timeMark = time.time() + self.times[w] = t + + def update(self, w, size): + if w in self.times: + self.times[w].downloaded += size + now = time.time() + tm = self.times[w].timeMark + amtTime = now - tm + if amtTime > self.gap: + self.times[w].timeMark = now + amtDownloaded = self.times[w].downloaded - self.times[w].sizeMark + self.times[w].sizeMark = self.times[w].downloaded + timefraction = amtDownloaded / float(amtTime) + amtToDownload = float(self.times[w].size) - self.times[w].downloaded + if timefraction: + self.times[w].timeRemaining = amtToDownload / timefraction + + def _timeEstimates(self): + for t in self.times: + yield self.times[t].timeRemaining + + def timeRemaining(self): + return max(self._timeEstimates()) - @dbus.service.method (config.DBUS_NAME, - in_signature='', out_signature='b') - def is_running (self): - return self.running - - @dbus.service.method (config.DBUS_NAME, - in_signature='', out_signature='') - def start (self): - if self.is_running(): - self.rapidapp.present() - else: - self.running = True - gtk.main() + def setTimeMark(self, w): + if w in self.times: + self.times[w].timeMark = time.time() + + def clear(self): + self.times = {} + + def remove(self, w): + if w in self.times: + del self.times[w] -def start(): +def programStatus(): + print _("Goodbye") - is_beta = config.version.find('~') > 0 + +def start (): + global is_beta + is_beta = config.version.find('~b') > 0 - parser = OptionParser(version= "%%prog %s" % utilities.human_readable_version(config.version)) + parser = OptionParser(version= "%%prog %s" % config.version) parser.set_defaults(verbose=is_beta, extensions=False) # Translators: this text is displayed to the user when they request information on the command line options. # The text %default should not be modified or left out. @@ -2637,18 +6468,19 @@ def start(): parser.add_option("-e", "--extensions", action="store_true", dest="extensions", help=_("list photo and video file extensions the program recognizes and exit")) parser.add_option("--reset-settings", action="store_true", dest="reset", help=_("reset all program settings and preferences and exit")) (options, args) = parser.parse_args() + global verbose + verbose = options.verbose - if options.debug: - logging_level = logging.DEBUG - elif options.verbose: - logging_level = logging.INFO - else: - logging_level = logging.ERROR + global debug_info + debug_info = options.debug + if debug_info: + verbose = True - logger.setLevel(logging_level) - + if verbose: + atexit.register(programStatus) + if options.extensions: - extensions = ((rpdfile.RAW_FILE_EXTENSIONS + rpdfile.NON_RAW_IMAGE_FILE_EXTENSIONS, _("Photos:")), (rpdfile.VIDEO_FILE_EXTENSIONS, _("Videos:"))) + extensions = ((metadata.RAW_FILE_EXTENSIONS + metadata.NON_RAW_IMAGE_FILE_EXTENSIONS, _("Photos:")), (videometadata.VIDEO_FILE_EXTENSIONS, _("Videos:"))) for exts, file_type in extensions: v = '' for e in exts[:-1]: @@ -2664,25 +6496,43 @@ def start(): print _("All settings and preferences have been reset") sys.exit(0) - logger.info("Rapid Photo Downloader %s", utilities.human_readable_version(config.version)) - logger.info("Using pyexiv2 %s", metadataphoto.pyexiv2_version_info()) - logger.info("Using exiv2 %s", metadataphoto.exiv2_version_info()) + cmd_line(_("Rapid Photo Downloader") + " %s" % config.version) + cmd_line(_("Using") + " pyexiv2 " + metadata.version_info()) + cmd_line(_("Using") + " exiv2 " + metadata.exiv2_version_info()) if DOWNLOAD_VIDEO: - logger.info("Using hachoir %s", metadatavideo.version_info()) + cmd_line(_("Using") + " hachoir " + videometadata.version_info()) + else: + cmd_line(_("\n" + "Video downloading functionality disabled.\nTo download videos, please install the hachoir metadata and kaa metadata packages for python.") + "\n") + + if using_gio: + cmd_line(_("Using") + " GIO") + gobject.threads_init() else: - logger.info(_("Video downloading functionality disabled.\nTo download videos, please install the hachoir metadata and kaa metadata packages for python.")) + # Which volume management code is being used (GIO or GnomeVFS) + cmd_line(_("Using") + " GnomeVFS") + gdk.threads_init() + + + + display_queue.open("rw") + tube.tube_add_watch(display_queue, updateDisplay) + gdk.threads_enter() + + # run only a single instance of the application bus = dbus.SessionBus () request = bus.request_name (config.DBUS_NAME, dbus.bus.NAME_FLAG_DO_NOT_QUEUE) - if request != dbus.bus.REQUEST_NAME_REPLY_EXISTS: - app = RapidApp(bus, '/', config.DBUS_NAME) + if request != dbus.bus.REQUEST_NAME_REPLY_EXISTS: + app = RapidApp (bus, '/', config.DBUS_NAME) else: # this application is already running - print "Rapid Photo Downloader is already running" + print _("%s is already running") % PROGRAM_NAME object = bus.get_object (config.DBUS_NAME, "/") app = dbus.Interface (object, config.DBUS_NAME) - app.start() + app.start() + + gdk.threads_leave() if __name__ == "__main__": start() |