From 9f2e1358d2c028c941b8be825522dd3351333710 Mon Sep 17 00:00:00 2001 From: Julien Valroff Date: Sun, 11 Jul 2010 09:01:25 +0200 Subject: Imported Upstream version 0.3.0 --- rapid/ChangeLog | 131 + rapid/INSTALL | 16 +- rapid/common.py | 44 + rapid/config.py | 26 +- rapid/dropshadow.py | 174 + rapid/filmstrip.py | 117 + rapid/glade3/filmstrip-100x75.xpm | 106 - rapid/glade3/image-missing.svg | 94 - rapid/glade3/photo.png | Bin 0 -> 6135 bytes rapid/glade3/photo24.png | Bin 0 -> 528 bytes rapid/glade3/photo_shadow.png | Bin 0 -> 6659 bytes rapid/glade3/photo_small_shadow.png | Bin 0 -> 1711 bytes rapid/glade3/rapid-photo-downloader-about.png | Bin 8084 -> 0 bytes .../rapid-photo-downloader-download-pending.svg | 187 + ...apid-photo-downloader-downloaded-with-error.svg | 350 ++ ...id-photo-downloader-downloaded-with-warning.svg | 351 ++ rapid/glade3/rapid-photo-downloader-downloaded.svg | 295 ++ rapid/glade3/rapid-photo-downloader-jobcode.svg | 265 ++ rapid/glade3/rapid-photo-downloader.svg | 2673 ++++++++++++++ rapid/glade3/rapid.glade | 434 ++- rapid/glade3/video.png | Bin 0 -> 6683 bytes rapid/glade3/video.svg | 1638 --------- rapid/glade3/video24.png | Bin 0 -> 736 bytes rapid/glade3/video_shadow.png | Bin 0 -> 7317 bytes rapid/glade3/video_small_shadow.png | Bin 0 -> 2647 bytes rapid/idletube.py | 24 +- rapid/media.py | 216 +- rapid/metadata.py | 34 + rapid/problemnotification.py | 427 +++ rapid/rapid.py | 3876 ++++++++++++++------ rapid/renamesubfolderprefs.py | 216 +- rapid/videometadata.py | 63 +- 32 files changed, 8504 insertions(+), 3253 deletions(-) create mode 100755 rapid/dropshadow.py create mode 100755 rapid/filmstrip.py delete mode 100644 rapid/glade3/filmstrip-100x75.xpm delete mode 100644 rapid/glade3/image-missing.svg create mode 100644 rapid/glade3/photo.png create mode 100644 rapid/glade3/photo24.png create mode 100644 rapid/glade3/photo_shadow.png create mode 100644 rapid/glade3/photo_small_shadow.png delete mode 100644 rapid/glade3/rapid-photo-downloader-about.png create mode 100644 rapid/glade3/rapid-photo-downloader-download-pending.svg create mode 100644 rapid/glade3/rapid-photo-downloader-downloaded-with-error.svg create mode 100644 rapid/glade3/rapid-photo-downloader-downloaded-with-warning.svg create mode 100644 rapid/glade3/rapid-photo-downloader-downloaded.svg create mode 100644 rapid/glade3/rapid-photo-downloader-jobcode.svg create mode 100644 rapid/glade3/rapid-photo-downloader.svg create mode 100644 rapid/glade3/video.png delete mode 100644 rapid/glade3/video.svg create mode 100644 rapid/glade3/video24.png create mode 100644 rapid/glade3/video_shadow.png create mode 100644 rapid/glade3/video_small_shadow.png create mode 100755 rapid/problemnotification.py (limited to 'rapid') diff --git a/rapid/ChangeLog b/rapid/ChangeLog index 96f3d4f..552c77d 100644 --- a/rapid/ChangeLog +++ b/rapid/ChangeLog @@ -1,3 +1,134 @@ +Version 0.3.0 +------------- + +2010-07-10 + +The major new feature of this release is the generation of previews before +a download takes place. You can now select which photos and videos you wish to +download. + +You can now assign different Job Codes to photos and videos in the same +download. Simply select photos and videos, and from the main window choose a Job +Code for them. You can select a new Job Code,or enter a new one (press Enter +to apply it). + +The errors and warnings reported have been completely overhauled, and are now +more concise. + +Now that you can select photos and videos to download, the "Report an error" +option in case of filename conflicts has been removed. If you try to download a +photo or video that already exists, an error will be reported. If you backup a +photo or video that already exists in the backup location, a warning will be +reported (regardless of whether overwriting or skipping of backups with +conflicting filenames is chosen). + +Likewise, the option of whether to report an error or warning in case of missing +backup devices has been removed. If you have chosen to backup your photos and +videos, and a backup device or location is not found, the files will be +downloaded with warnings. + +For each device in the main window, the progress bar is now updated much more +smoothly than before. This is useful when downloading and backing up large files +such as videos. (Note this is only the case on moderately up-to-date Linux +distributions that use GVFS, such as Ubuntu 8.10 or higher). + +The minimum version of python-gtk2 (pygtk) required to run the program is now +2.12. This will affect only outdated Linux distributions. + + +Version 0.3.0 beta 6 +-------------------- + +2010-07-06 + +Fixed bug #598736: don't allow file to jump to the bottom when it has a Job Code +assigned to it. + +Fixed bug #601993: don't prompt for a Job Code when downloading file of one type +(photo or video), and it's only a file of the other type that needs it. + +Log error messages are now cleaned up where a file already exists and there were +problems generating the file / subfolder name. + +Fixed crash on startup when using an old version of GIO. + +Fix crash in updating the time remaining in when downloading from extremely +slow devices. + +Set the default height to be 50 pixels taller. + +Bug fix: don't download from device that has been inserted after program starts +unless device auto detection is enabled. + +Updated German translation. + + +Version 0.3.0 beta 5 +-------------------- + +2010-07-04 + +Added warning dialog if attempting to download directly from a camera. + +Add backup errors details to error log window. + +Fixed program notifications. + +Fixed corner cases with problematic file and subfolder names. + +Disabled Download All button if all files that have not been downloaded have +errors. + +Enabled and disabled Download All button, depending on status, after subfolder +or filename preferences are modified after device has been scanned. + +Don't stop a file being downloaded if a valid subfolder or filename can be +generated using a Job Code. + +Bug fix: don't automatically exit if there were errors or warnings and a +download was occuring from more than one device. + +Auto start now works correctly again. + +Job Codes are now assigned correctly when multiple downloads occur. + +Default column sorting is by date, unless a warning or error occurs when +doing the initial scan of the devices, in which case it is set to status (unless +you have already clicked on a column heading yourself, in which case it will +not change). + +Use the command xdg-user-dir to get default download directories. + +Updated Czech, Dutch, Finnish, French, Italian, Polish, Russian and Ukrainian +translations. + + +Version 0.3.0 beta 4 +-------------------- + +2010-06-25 + +Fixed bug in Job Code addition in the preferences window. + +Made Job Code entry completion case insensitive. + +Update preview to be the most recently selected photo / video when +multiple files are selected. + +Don't crash when user selects a row that has its status set to be +download pending. + +Improve error log status messages and problem notifications. + + +Version 0.3.0 beta 3 +-------------------- + +2010-06-23 + +First beta release of 0.3.0. + + Version 0.2.3 ------------- diff --git a/rapid/INSTALL b/rapid/INSTALL index 144568d..3967608 100644 --- a/rapid/INSTALL +++ b/rapid/INSTALL @@ -3,18 +3,22 @@ Rapid Photo Downloader depends on the following software: - GNOME 2.18 or higher - GTK+ 2.10 or higher - Python 2.5 or 2.6 -- pygtk 2.10 or higher +- pygtk 2.12 or higher - python-gconf 2.18 or higher -- python-glade2 2.10 or higher -- gnome-python 2.10 or higher +- python-glade2 2.12 or higher +- gnome-python 2.12 or higher - libexiv2 0.15 or higher - pyexiv2 0.1.1 or higher To run Rapid Photo Downloader you will need all the software mentioned above. -A recommended package is exiv2 (not just the library libexiv2), so Rapid Photo -Downloader can determine if it can download additional types of RAW files -(some early versions of exiv2 and pyexiv2 segfault on certain RAW file types). +If you want to see dropshadows around thumbnail images, install python-imaging. +This is optional but recommended. + +A strongly recommended package is exiv2 (not just the library libexiv2), so +Rapid Photo Downloader can determine if it can download additional types of RAW +files (some early versions of exiv2 and pyexiv2 segfault on certain RAW file +types). If you want to download videos, you can install: diff --git a/rapid/common.py b/rapid/common.py index d687049..a55d835 100644 --- a/rapid/common.py +++ b/rapid/common.py @@ -22,6 +22,11 @@ import sys import gc import distutils.version import gtk.gdk as gdk +import gtk +try: + import gio +except: + pass import config @@ -173,6 +178,45 @@ def scale2pixbuf(width_max, height_max, pixbuf, return_size=False): return pixbuf, width_orig, height_orig return pixbuf +def get_icon_pixbuf(using_gio, icon, size, fallback='gtk-harddisk'): + """ returns icon for the volume, or None if not available""" + + icontheme = gtk.icon_theme_get_default() + + if using_gio: + f = None + if isinstance(icon, gio.ThemedIcon): + try: + # on some user's systems, themes do not have icons associated with them + iconinfo = icontheme.choose_icon(icon.get_names(), size, gtk.ICON_LOOKUP_USE_BUILTIN) + f = iconinfo.get_filename() + v = gtk.gdk.pixbuf_new_from_file_at_size(f, size, size) + except: + f = None + if not f: + v = icontheme.load_icon(fallback, size, gtk.ICON_LOOKUP_USE_BUILTIN) + else: + v = icontheme.load_icon(icon, size, gtk.ICON_LOOKUP_USE_BUILTIN) + return v + +def register_iconsets(icon_info): + """ + Register icons in the icon set if they're not already used + + From http://faq.pygtk.org/index.py?req=show&file=faq08.012.htp + """ + + iconfactory = gtk.IconFactory() + stock_ids = gtk.stock_list_ids() + for stock_id, file in icon_info: + # only load image files when our stock_id is not present + if stock_id not in stock_ids: + pixbuf = gtk.gdk.pixbuf_new_from_file(file) + iconset = gtk.IconSet(pixbuf) + iconfactory.add(stock_id, iconset) + iconfactory.add_default() + + if __name__ == '__main__': diff --git a/rapid/config.py b/rapid/config.py index cafcc16..2f0e1e3 100644 --- a/rapid/config.py +++ b/rapid/config.py @@ -15,7 +15,7 @@ ### along with this program; if not, write to the Free Software ### Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -version = '0.2.3' +version = '0.3.0' GCONF_KEY="/apps/rapid-photo-downloader" GLADE_FILE = "glade3/rapid.glade" @@ -30,11 +30,9 @@ MEDIA_LOCATION = "/media" SKIP_DOWNLOAD = "skip download" ADD_UNIQUE_IDENTIFIER = "add unique identifier" -REPORT_WARNING = "warning" -REPORT_ERROR = "error" -IGNORE = "ignore" - -DEFAULT_PHOTO_LOCATIONS = ['Pictures', 'Photos'] +# These next three values are fall back values that are used only +# if calls to xdg-user-dir fail +DEFAULT_PHOTO_LOCATIONS = ['Pictures', 'Photos'] DEFAULT_BACKUP_LOCATION = 'Pictures' DEFAULT_VIDEO_BACKUP_LOCATION = 'Videos' @@ -47,8 +45,14 @@ SERIOUS_ERROR = 2 WARNING = 3 MAX_LENGTH_DEVICE_NAME = 15 - -#logging - to be implemented -#LOGFILE_DIRECTORY = '.rapidPhotoDownloader' # relative to home directory -#MAX_LOGFILE_SIZE = 100 * 1024 # bytes -#MAX_LOGFILES = 5 +MAX_THUMBNAIL_SIZE = 160 + +STATUS_DOWNLOAD_PENDING = 0 # going to try to download it +STATUS_DOWNLOADED = 1 # downloaded successfully +STATUS_DOWNLOADED_WITH_WARNING = 2 # downloaded ok but there was a warning +STATUS_BACKUP_PROBLEM = 3 # downloaded ok, but the file was not backed up, or had a problem (overwrite or duplicate) +STATUS_NOT_DOWNLOADED = 4 # has not yet been downloaded (but might be if the user chooses) +STATUS_DOWNLOAD_AND_BACKUP_FAILED = 5 # tried to download but failed, and the backup failed or had an error +STATUS_DOWNLOAD_FAILED = 6 # tried to download but failed +STATUS_WARNING = 7 # warning (shown in pre-download preview) +STATUS_CANNOT_DOWNLOAD = 8 # cannot be downloaded diff --git a/rapid/dropshadow.py b/rapid/dropshadow.py new file mode 100755 index 0000000..fd7cf99 --- /dev/null +++ b/rapid/dropshadow.py @@ -0,0 +1,174 @@ +#!/usr/bin/python + + +import StringIO +import gtk +from PIL import Image, ImageFilter + +def image_to_pixbuf(image): + # this one handles transparency, unlike the default example in the pygtk FAQ + # this is also from the pygtk FAQ + IS_RGBA = image.mode=='RGBA' + return gtk.gdk.pixbuf_new_from_data( + image.tostring(), # data + gtk.gdk.COLORSPACE_RGB, # color mode + IS_RGBA, # has alpha + 8, # bits + image.size[0], # width + image.size[1], # height + (IS_RGBA and 4 or 3) * image.size[0] # rowstride + ) + + +def image_to_pixbuf_no_transparency(image): + fd = StringIO.StringIO() + image.save(fd, "ppm") + contents = fd.getvalue() + fd.close() + loader = gtk.gdk.PixbufLoader("pnm") + loader.write(contents, len(contents)) + pixbuf = loader.get_pixbuf() + loader.close() + return pixbuf + +def pixbuf_to_image(pb): + assert(pb.get_colorspace() == gtk.gdk.COLORSPACE_RGB) + dimensions = pb.get_width(), pb.get_height() + stride = pb.get_rowstride() + pixels = pb.get_pixels() + mode = pb.get_has_alpha() and "RGBA" or "RGB" + return Image.frombuffer(mode, dimensions, pixels, + "raw", mode, stride, 1) + + +class DropShadow(): + """ + Adds a gaussian blur drop shadow to a PIL image. + + Caches backgrounds of particular sizes for improved performance. + + Backgrounds can be made transparent. + + Modification of code from Kevin Schluff and Matimus + License: Python license + See: + http://code.activestate.com/recipes/474116/ (r2) + http://bytes.com/topic/python/answers/606952-pil-paste-image-top-other-dropshadow + + """ + + def __init__(self, offset=(5,5), background_color=0xffffff, shadow = (0x44, 0x44, 0x44, 0xff), + border=8, iterations=3, trim_border=False): + """ + offset - Offset of the shadow from the image as an (x,y) tuple. Can be + positive or negative. + background_color - Background colour behind the image. + shadow - Shadow colour (darkness). + border - Width of the border around the image. This must be wide + enough to account for the blurring of the shadow. + trim_border - If true, the border will only be created on the + sides it needs to be (i.e. only on two sides) + iterations - Number of times to apply the filter. More iterations + produce a more blurred shadow, but increase processing time. + + To make backgrounds transparent, ensure the alpha value of the shadow color is the + same as the background color, e.g. if background_color is 0xffffff, shadow's alpha should be 0xff + """ + self.backgrounds = {} + self.offset = offset + self.background_color = background_color + self.shadow = shadow + self.border = border + self.trim_border = trim_border + self.iterations = iterations + + if self.offset[0] < 0 or not self.trim_border: + self.left_spacing = self.border + else: + self.left_spacing = 0 + + if self.offset[1] < 0 or not self.trim_border: + self.top_spacing = self.border + else: + self.top_spacing = 0 + + + def dropShadow(self, image): + """ + image - The image to overlay on top of the shadow. + """ + dimensions = (image.size[0], image.size[1]) + if not dimensions in self.backgrounds: + + # Create the backdrop image -- a box in the background colour with a + # shadow on it. + + if self.trim_border: + totalWidth = image.size[0] + abs(self.offset[0]) + self.border + totalHeight = image.size[1] + abs(self.offset[1]) + self.border + else: + totalWidth = image.size[0] + abs(self.offset[0]) + 2 * self.border + totalHeight = image.size[1] + abs(self.offset[1]) + 2 * self.border + + back = Image.new("RGBA", (totalWidth, totalHeight), self.background_color) + + # Place the shadow, taking into account the offset from the image + if self.offset[0] > 0 and self.trim_border: + shadowLeft = max(self.offset[0], 0) + else: + shadowLeft = self.border + max(self.offset[0], 0) + if self.offset[1] > 0 and self.trim_border: + shadowTop = max(self.offset[1], 0) + else: + shadowTop = self.border + max(self.offset[1], 0) + + back.paste(self.shadow, [shadowLeft, shadowTop, shadowLeft + image.size[0], + shadowTop + image.size[1]] ) + + # Apply the filter to blur the edges of the shadow. Since a small kernel + # is used, the filter must be applied repeatedly to get a decent blur. + n = 0 + while n < self.iterations: + back = back.filter(ImageFilter.BLUR) + n += 1 + + self.backgrounds[dimensions] = back + + # Paste the input image onto the shadow backdrop + imageLeft = self.left_spacing - min(self.offset[0], 0) + imageTop = self.top_spacing - min(self.offset[1], 0) + + back = self.backgrounds[dimensions].copy() + back.paste(image, (imageLeft, imageTop)) + + return back + + + +if __name__ == "__main__": + import sys + import os + import common + + + # create another file with a drop shadow + f = sys.argv[1] + + image = Image.open(f) + image.thumbnail((60,36), Image.ANTIALIAS) + image2 = image.copy() + + path, name = os.path.split(f) + name, ext = os.path.splitext(name) + + #image = dropShadow(image, shadow = (0x44, 0x44, 0x44, 0xff)) + dropShadow = DropShadow(offset=(3,3), shadow = (0x34, 0x34, 0x34, 0xff), border=6) + image = dropShadow.dropShadow(image) + image2 = dropShadow.dropShadow(image2) + + nf = os.path.join(path, "%s_small_shadow%s" % (name, ext)) + nf2 = os.path.join(path, "%s_small_shadow2%s" % (name, ext)) + image.save(nf) + image2.save(nf2) + print "wrote %s , %s" % (nf, nf2) + diff --git a/rapid/filmstrip.py b/rapid/filmstrip.py new file mode 100755 index 0000000..275fd1d --- /dev/null +++ b/rapid/filmstrip.py @@ -0,0 +1,117 @@ +#!/usr/bin/python +# -*- coding: latin1 -*- + +### Copyright (C) 2010 Damon Lynch + +### 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 +### the Free Software Foundation; either version 2 of the License, or +### (at your option) any later version. + +### This program is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU General Public License for more details. + +### You should have received a copy of the GNU General Public License +### along with this program; if not, write to the Free Software +### Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Adds a filmstrip to the left and right of a file +""" + +import gtk + + +xpm_data = [ +"12 10 27 1", +" c #000000", +". c #232323", +"+ c #7A7A7A", +"@ c #838383", +"# c #8C8C8C", +"$ c #909090", +"% c #8E8E8E", +"& c #525252", +"* c #6E6E6E", +"= c #939393", +"- c #A3A3A3", +"; c #ABABAB", +"> c #A8A8A8", +", c #9B9B9B", +"' c #727272", +") c #A4A4A4", +"! c #BBBBBB", +"~ c #C4C4C4", +"{ c #C1C1C1", +"] c #AFAFAF", +"^ c #3E3E3E", +"/ c #A6A6A6", +"( c #BEBEBE", +"_ c #C8C8C8", +": c #070707", +"< c #090909", +"[ c #0A0A0A", +" ", +" ", +" ", +" .+@#$%& ", +" *@=-;>, ", +" '%)!~{] ", +" ^$/(_~% ", +" :<[[[ ", +" ", +" "] + + +def add_filmstrip(pixbuf): + """ + Adds a filmstrip to the left and right of a pixbuf + + Returns a pixbuf + + """ + filmstrip = gtk.gdk.pixbuf_new_from_xpm_data(xpm_data) + filmstrip_width = filmstrip.get_width() + filmstrip_height = filmstrip.get_height() + filmstrip_right = filmstrip.flip(True) + + + original = pixbuf + original_height = original.get_height() + thumbnail_width = original.get_width() + filmstrip_width * 2 + thumbnail_right_col = original.get_width() + filmstrip_width + + thumbnail = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, False, 8, thumbnail_width, original.get_height()) + + #add filmstrips to left and right + for i in range(original_height / filmstrip_height): + filmstrip.copy_area(0, 0, filmstrip_width, filmstrip_height, thumbnail, 0, i * filmstrip_height) + filmstrip_right.copy_area(0, 0, filmstrip_width, filmstrip_height, thumbnail, thumbnail_right_col, i * filmstrip_height) + + #now do the remainder, at the bottom + remaining_height = original_height % filmstrip_height + if remaining_height: + filmstrip.copy_area(0, 0, filmstrip_width, remaining_height, thumbnail, 0, original_height-remaining_height) + filmstrip_right.copy_area(0, 0, filmstrip_width, remaining_height, thumbnail, thumbnail_right_col, original_height-remaining_height) + + if original.get_has_alpha(): + thumbnail = thumbnail.add_alpha(False, 0,0,0) + #copy in the original image + original.copy_area(0, 0, original.get_width(), original_height, thumbnail, filmstrip_width, 0) + + return thumbnail + + +if __name__ == '__main__': + import sys + + + if (len(sys.argv) != 2): + print 'Usage: ' + sys.argv[0] + ' path/to/photo/image' + + else: + p = gtk.gdk.pixbuf_new_from_file(sys.argv[1]) + p2 = add_filmstrip(p) + p2.save('testing.png', 'png') diff --git a/rapid/glade3/filmstrip-100x75.xpm b/rapid/glade3/filmstrip-100x75.xpm deleted file mode 100644 index 5e3428e..0000000 --- a/rapid/glade3/filmstrip-100x75.xpm +++ /dev/null @@ -1,106 +0,0 @@ -/* XPM */ -static char * thumbnail_100x75_xpm[] = { -"100 75 28 1", -" c None", -". c #000000", -"+ c #232323", -"@ c #7A7A7A", -"# c #838383", -"$ c #8C8C8C", -"% c #909090", -"& c #8E8E8E", -"* c #525252", -"= c #6E6E6E", -"- c #939393", -"; c #A3A3A3", -"> c #ABABAB", -", c #A8A8A8", -"' c #9B9B9B", -") c #727272", -"! c #A4A4A4", -"~ c #BBBBBB", -"{ c #C4C4C4", -"] c #C1C1C1", -"^ c #AFAFAF", -"/ c #3E3E3E", -"( c #A6A6A6", -"_ c #BEBEBE", -": c #C8C8C8", -"< c #070707", -"[ c #090909", -"} c #0A0A0A", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"....+@#$%&*. .*&%$#@+....", -"....=#-;>,'. .',>;-#=....", -"....)&!~{]^. .^]{~!&)....", -"..../%(_:{&. .&{:_(%/....", -".....<[}}}.. ..}}}[<.....", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"....+@#$%&*. .*&%$#@+....", -"....=#-;>,'. .',>;-#=....", -"....)&!~{]^. .^]{~!&)....", -"..../%(_:{&. .&{:_(%/....", -".....<[}}}.. ..}}}[<.....", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"....+@#$%&*. .*&%$#@+....", -"....=#-;>,'. .',>;-#=....", -"....)&!~{]^. .^]{~!&)....", -"..../%(_:{&. .&{:_(%/....", -".....<[}}}.. ..}}}[<.....", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"....+@#$%&*. .*&%$#@+....", -"....=#-;>,'. .',>;-#=....", -"....)&!~{]^. .^]{~!&)....", -"..../%(_:{&. .&{:_(%/....", -".....<[}}}.. ..}}}[<.....", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"....+@#$%&*. .*&%$#@+....", -"....=#-;>,'. .',>;-#=....", -"....)&!~{]^. .^]{~!&)....", -"..../%(_:{&. .&{:_(%/....", -".....<[}}}.. ..}}}[<.....", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"....+@#$%&*. .*&%$#@+....", -"....=#-;>,'. .',>;-#=....", -"....)&!~{]^. .^]{~!&)....", -"..../%(_:{&. .&{:_(%/....", -".....<[}}}.. ..}}}[<.....", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"....+@#$%&*. .*&%$#@+....", -"....=#-;>,'. .',>;-#=....", -"....)&!~{]^. .^]{~!&)....", -"..../%(_:{&. .&{:_(%/....", -".....<[}}}.. ..}}}[<.....", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"............ ............", -"....+@#$%&*. .*&%$#@+...."}; diff --git a/rapid/glade3/image-missing.svg b/rapid/glade3/image-missing.svg deleted file mode 100644 index 4351feb..0000000 --- a/rapid/glade3/image-missing.svg +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - Generic Image - - - Lapo Calamandrei - - - http://www.gnome.org - - - Jakub Steiner, Andreas Nilsson - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/rapid/glade3/photo.png b/rapid/glade3/photo.png new file mode 100644 index 0000000..87cfc2a Binary files /dev/null and b/rapid/glade3/photo.png differ diff --git a/rapid/glade3/photo24.png b/rapid/glade3/photo24.png new file mode 100644 index 0000000..53b2271 Binary files /dev/null and b/rapid/glade3/photo24.png differ diff --git a/rapid/glade3/photo_shadow.png b/rapid/glade3/photo_shadow.png new file mode 100644 index 0000000..0053ba0 Binary files /dev/null and b/rapid/glade3/photo_shadow.png differ diff --git a/rapid/glade3/photo_small_shadow.png b/rapid/glade3/photo_small_shadow.png new file mode 100644 index 0000000..fe85cd9 Binary files /dev/null and b/rapid/glade3/photo_small_shadow.png differ diff --git a/rapid/glade3/rapid-photo-downloader-about.png b/rapid/glade3/rapid-photo-downloader-about.png deleted file mode 100644 index 0aefb1d..0000000 Binary files a/rapid/glade3/rapid-photo-downloader-about.png and /dev/null differ diff --git a/rapid/glade3/rapid-photo-downloader-download-pending.svg b/rapid/glade3/rapid-photo-downloader-download-pending.svg new file mode 100644 index 0000000..d6127b7 --- /dev/null +++ b/rapid/glade3/rapid-photo-downloader-download-pending.svg @@ -0,0 +1,187 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rapid/glade3/rapid-photo-downloader-downloaded-with-error.svg b/rapid/glade3/rapid-photo-downloader-downloaded-with-error.svg new file mode 100644 index 0000000..74f13e3 --- /dev/null +++ b/rapid/glade3/rapid-photo-downloader-downloaded-with-error.svg @@ -0,0 +1,350 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rapid/glade3/rapid-photo-downloader-downloaded-with-warning.svg b/rapid/glade3/rapid-photo-downloader-downloaded-with-warning.svg new file mode 100644 index 0000000..8065ac2 --- /dev/null +++ b/rapid/glade3/rapid-photo-downloader-downloaded-with-warning.svg @@ -0,0 +1,351 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rapid/glade3/rapid-photo-downloader-downloaded.svg b/rapid/glade3/rapid-photo-downloader-downloaded.svg new file mode 100644 index 0000000..378927e --- /dev/null +++ b/rapid/glade3/rapid-photo-downloader-downloaded.svg @@ -0,0 +1,295 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rapid/glade3/rapid-photo-downloader-jobcode.svg b/rapid/glade3/rapid-photo-downloader-jobcode.svg new file mode 100644 index 0000000..3096ee2 --- /dev/null +++ b/rapid/glade3/rapid-photo-downloader-jobcode.svg @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Insert Text + + + Lapo Calamandrei + + + http://www.gnome.org + + + + + + + + + insert + text + generic + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rapid/glade3/rapid-photo-downloader.svg b/rapid/glade3/rapid-photo-downloader.svg new file mode 100644 index 0000000..a1e9885 --- /dev/null +++ b/rapid/glade3/rapid-photo-downloader.svg @@ -0,0 +1,2673 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + Jakub Steiner + + + http://jimmac.musichall.cz + + + + + snapshot + camera + photo + compact + snap + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rapid/glade3/rapid.glade b/rapid/glade3/rapid.glade index 539ac9c..0954b5c 100644 --- a/rapid/glade3/rapid.glade +++ b/rapid/glade3/rapid.glade @@ -9,7 +9,7 @@ True center-on-parent 500 - rapid-photo-downloader-about.png + rapid-photo-downloader.svg dialog False @@ -1432,7 +1432,7 @@ True - emblem-photos + rapid-photo-downloader-jobcode False @@ -2560,81 +2560,6 @@ You can download photos from multiple devices simultaneously, or you can specify GTK_FILL - - - Ignore - True - True - False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - True - True - backup_error_radiobutton - - - - 1 - 2 - 13 - 14 - GTK_FILL - - - - - Report a warning - True - True - False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - True - True - backup_error_radiobutton - - - - 1 - 2 - 12 - 13 - GTK_FILL - - - - - Report an error - True - True - False - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - True - True - - - - 1 - 2 - 11 - 12 - GTK_FILL - - - - - True - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK - 0 - 12 - <b>Missing Backup Devices</b> - True - - - 2 - 9 - 10 - GTK_FILL - - Add unique identifier @@ -2650,8 +2575,8 @@ You can download photos from multiple devices simultaneously, or you can specify 1 2 - 5 - 6 + 3 + 4 GTK_FILL @@ -2665,40 +2590,6 @@ You can download photos from multiple devices simultaneously, or you can specify True True - - 1 - 2 - 4 - 5 - GTK_FILL - - - - - True - 0 - 12 - Choose whether to skip downloading the file, or to add a unique indentifier. - True - - - 1 - 2 - 3 - 4 - GTK_FILL - - - - - Report an error - True - True - False - True - True - - 1 2 @@ -2708,11 +2599,11 @@ You can download photos from multiple devices simultaneously, or you can specify - + True 0 12 - Specify what to do when a photo or video of the same name has already been downloaded or backed up. + When a photo or video of the same name has already been downloaded, choose whether to skip downloading the file, or to add a unique indentifier. True @@ -2723,21 +2614,6 @@ You can download photos from multiple devices simultaneously, or you can specify GTK_FILL - - - 0 - 12 - Specify what to do when there are no backup devices. - True - - - 1 - 2 - 10 - 11 - GTK_FILL - - True @@ -2749,8 +2625,8 @@ You can download photos from multiple devices simultaneously, or you can specify 1 2 - 6 - 7 + 4 + 5 GTK_FILL @@ -2768,8 +2644,8 @@ You can download photos from multiple devices simultaneously, or you can specify 1 2 - 7 - 8 + 5 + 6 @@ -2785,8 +2661,8 @@ You can download photos from multiple devices simultaneously, or you can specify 1 2 - 8 - 9 + 6 + 7 @@ -2822,6 +2698,30 @@ You can download photos from multiple devices simultaneously, or you can specify + + + + + + + + + + + + + + + + + + + + + + + + 1 @@ -2925,7 +2825,7 @@ You can download photos from multiple devices simultaneously, or you can specify GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK 5 True - rapid-photo-downloader-about.png + rapid-photo-downloader.svg normal Rapid Photo Downloader Copyright Damon Lynch 2007-10 @@ -2937,7 +2837,8 @@ Rapid Photo Downloader is distributed in the hope that it will be useful, but WI You should have received a copy of the GNU General Public License along with Rapid Photo Downloader; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Damon Lynch <damonlynch@gmail.com> - Lőrincz András <level.andrasnak@gmail.com> + Anton Alyab'ev <subeditor@dolgopa.org> +Lőrincz András <level.andrasnak@gmail.com> Michel Ange <michelange@wanadoo.fr> Alain J. Baudrez <a.baudrez@gmail.com> Martin Egger <martin.egger@gmx.net> @@ -2957,7 +2858,7 @@ Marco Solari <marcosolari@gmail.com> Toni Lähdekorpi <toni@lygon.net> Ulf Urdén <ulf.urden@purplescout.com> Julien Valroff <julien@kirya.net> - rapid-photo-downloader-about.png + rapid-photo-downloader.svg True @@ -2981,11 +2882,12 @@ Julien Valroff <julien@kirya.net> - 600 GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK Rapid Photo Downloader - rapid-photo-downloader-about.png + 650 + rapid-photo-downloader.svg + True @@ -2996,7 +2898,7 @@ Julien Valroff <julien@kirya.net> True - _Photos + _File True @@ -3015,6 +2917,16 @@ Julien Valroff <julien@kirya.net> + + + gtk-refresh + True + True + True + + + + gtk-preferences @@ -3038,6 +2950,76 @@ Julien Valroff <julien@kirya.net> + + + True + _Select + True + + + + + True + Select _All + True + + + + + + + True + Select All Pho_tos + True + + + + + + + True + Select All Vi_deos + True + + + + + + + True + Se_lect None + True + + + + + + + True + + + + + True + Select All Without _Job Code + True + + + + + + + True + Select All Wit_h Job Code + True + + + + + + + + True @@ -3046,26 +3028,86 @@ Julien Valroff <julien@kirya.net> - + True - _Thumbnails + _Preview True - + - + True - _Error Log + P_review Columns True - + + + True + + + True + _Type + True + + + + + + True + _Size + True + + + + + + True + _Device + True + + + + + + True + _Filename + True + + + + + + True + _Path + True + + + + + - + + True + Preview _Folders + True + + + + + True + + + True + _Error Log + True + + + True @@ -3074,6 +3116,11 @@ Julien Valroff <julien@kirya.net> + + + True + + @@ -3086,12 +3133,19 @@ Julien Valroff <julien@kirya.net> - - True + _Get Help Online... + True True + False + + + True + help + + @@ -3147,67 +3201,83 @@ Julien Valroff <julien@kirya.net> True 12 - + True + True - + True - True - automatic - automatic - + True - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + 6 - + True + True + automatic + automatic - + + True + GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + queue + + + True + + + + + + + + 12 + 0 + + + 0 + - 12 - 0 + False + False - - - 0 - - - - - True - + True - True - never + 2 + 3 - - 112 + True - GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + + 1 + 2 + + + + - 12 - 0 + True + False - False - 1 + 0 @@ -3220,7 +3290,7 @@ Julien Valroff <julien@kirya.net> False False - 2 + 1 @@ -3389,7 +3459,7 @@ Julien Valroff <julien@kirya.net> 600 400 True - rapid-photo-downloader-about.png + rapid-photo-downloader.svg dialog False diff --git a/rapid/glade3/video.png b/rapid/glade3/video.png new file mode 100644 index 0000000..ac1086b Binary files /dev/null and b/rapid/glade3/video.png differ diff --git a/rapid/glade3/video.svg b/rapid/glade3/video.svg deleted file mode 100644 index a03c672..0000000 --- a/rapid/glade3/video.svg +++ /dev/null @@ -1,1638 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - Applications Multimedia - August 2006 - - - Lapo Calamandrei - - - http://www.gnome.org - - - multimedia - sound - video - - - - - - Jakub Steiner, Andreas Nilsson - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/rapid/glade3/video24.png b/rapid/glade3/video24.png new file mode 100644 index 0000000..df8b22b Binary files /dev/null and b/rapid/glade3/video24.png differ diff --git a/rapid/glade3/video_shadow.png b/rapid/glade3/video_shadow.png new file mode 100644 index 0000000..9b66480 Binary files /dev/null and b/rapid/glade3/video_shadow.png differ diff --git a/rapid/glade3/video_small_shadow.png b/rapid/glade3/video_small_shadow.png new file mode 100644 index 0000000..eb42d92 Binary files /dev/null and b/rapid/glade3/video_small_shadow.png differ diff --git a/rapid/idletube.py b/rapid/idletube.py index 86ff1a4..0b07536 100644 --- a/rapid/idletube.py +++ b/rapid/idletube.py @@ -1,6 +1,8 @@ # Copyright (c) 2005 Antoon Pardon # +# Modified 2010 by Damon Lynch to use python's higher performance deque, rather than a regular list +# # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), # to deal in the Software without restriction, including without limitation @@ -18,6 +20,10 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import collections +from Queue import Queue + from threading import Lock from thread import get_ident @@ -25,26 +31,29 @@ from types import BooleanType as UnConnected UnRegistered, Registered = False, True + class EOInformation(Exception): pass class TubeAccess(Exception): pass + class Fifo: def __init__(self): - self.fifo = [] + self.fifo = collections.deque() def put(self, item): self.fifo.append(item) def get(self): - return self.fifo.pop(0) + return self.fifo.popleft() def size(self): return len(self.fifo) + class Tube: def __init__(self, maxsize, lck = Lock, container = None): @@ -90,18 +99,12 @@ class Tube: self.readers.discard(thrd) if 'w' in access: self.writers.discard(thrd) -## print "have", self.writers, "writers" if len(self.writers) == 0: if self.container.size() == 0: -## print "emptying container, as size is", self.container.size() self.empty.release() if self.cb_src is Registered and len(self.readers) > 0: -## print "adding callback" self.cb_src = gob.idle_add(self._idle_callback) -## else: -## print "container size not empty, is", self.container.size() for _ in self.readers: -## print "putting EOInformation" self.container.put(EOInformation) self.in_use.release() @@ -148,9 +151,7 @@ class Tube: if size == 0: self.empty.release() if self.cb_src is Registered: - #gdk.threads_enter() self.cb_src = gob.idle_add(self._idle_callback) - #gdk.threads_leave() self.container.put(item) if size + 1 < self.maxsize: self.full.release() @@ -185,9 +186,8 @@ class Tube: def tube_add_watch(tube, callback, *args): - global gob #, gdk + global gob import gobject as gob - #import gtk.gdk as gdk tube.in_use.acquire() tube.cb_arglst.append([callback] + list(args)) diff --git a/rapid/media.py b/rapid/media.py index d00855a..06ebb2e 100755 --- a/rapid/media.py +++ b/rapid/media.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: latin1 -*- -### Copyright (C) 2007 Damon Lynch +### Copyright (C) 2007, 2008, 2009, 2010 Damon Lynch ### 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 @@ -18,15 +18,30 @@ ### Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import os +import sys +import types +import datetime +import subprocess import config +from config import MAX_THUMBNAIL_SIZE +from config import STATUS_NOT_DOWNLOADED, \ + STATUS_DOWNLOAD_PENDING, \ + STATUS_CANNOT_DOWNLOAD + + import common import metadata import videometadata +from common import Configi18n +global _ +_ = Configi18n._ + import operator +import gtk -def _getDefaultLocation(options, ignore_missing_dir=False): +def _getDefaultLocationLegacy(options, ignore_missing_dir=False): if ignore_missing_dir: return common.getFullPath(options[0]) for default in options: @@ -34,12 +49,29 @@ def _getDefaultLocation(options, ignore_missing_dir=False): if os.path.isdir(path): return path return common.getFullPath('') + +def _getDefaultLocationXDG(dir_type): + proc = subprocess.Popen(['xdg-user-dir', dir_type], stdout=subprocess.PIPE) + output = proc.communicate()[0].strip() + return output def getDefaultPhotoLocation(ignore_missing_dir=False): - return _getDefaultLocation(config.DEFAULT_PHOTO_LOCATIONS, ignore_missing_dir) + try: + return _getDefaultLocationXDG('PICTURES') + except: + return _getDefaultLocationLegacy(config.DEFAULT_PHOTO_LOCATIONS, ignore_missing_dir) def getDefaultVideoLocation(ignore_missing_dir=False): - return _getDefaultLocation(config.DEFAULT_VIDEO_LOCATIONS, ignore_missing_dir) + try: + return _getDefaultLocationXDG('VIDEOS') + except: + return _getDefaultLocationLegacy(config.DEFAULT_VIDEO_LOCATIONS, ignore_missing_dir) + +def getDefaultBackupPhotoIdentifier(): + return os.path.split(getDefaultPhotoLocation(ignore_missing_dir = True))[1] + +def getDefaultBackupVideoIdentifier(): + return os.path.split(getDefaultVideoLocation(ignore_missing_dir = True))[1] def is_DCIM_Media(path): """ Returns true if directory specifies some media with photos on it """ @@ -79,25 +111,119 @@ def isVideo(fileName): ext = os.path.splitext(fileName)[1].lower()[1:] return (ext in videometadata.VIDEO_FILE_EXTENSIONS) -def getVideoThumbnailFile(fullFileName): + +class MediaFile: """ - Checks to see if a thumbnail file is in the same directory as the - file. Expects a full path to be part of the file name. - - Returns the filename, including path, if found, else returns None. + A photo or video file, with metadata """ - f = None - name, ext = os.path.splitext(fullFileName) - for e in videometadata.VIDEO_THUMBNAIL_FILE_EXTENSIONS: - if os.path.exists(name + '.' + e): - f = name + '.' + e - break - if os.path.exists(name + '.' + e.upper()): - f = name + '.' + e.upper() - break + def __init__(self, thread_id, name, path, size, fileSystemModificationTime, deviceName, downloadFolder, volume, isPhoto = True): + self.thread_id = thread_id + self.path = path + self.name = name + self.fullFileName = os.path.join(path, name) + self.size = size # type int + self.modificationTime = fileSystemModificationTime + self.deviceName = deviceName + self.downloadFolder = downloadFolder + self.volume = volume + + self.jobcode = '' - return f + # a reference into the SelectionTreeView's liststore + self.treerowref = None + + # generated values + self.downloadSubfolder = '' + self.downloadPath = '' + self.downloadName = '' + self.downloadFullFileName = '' + + self.isImage = isPhoto + self.isVideo = not self.isImage + if isPhoto: + self.displayName = _("photo") + self.displayNameCap = _("Photo") + else: + self.displayName = _("video") + self.displayNameCap = _("Video") + + + self.metadata = None + self.thumbnail = None + self.genericThumbnail = False + self.sampleName = '' + self.sampleSubfolder = '' + self.samplePath = '' + + # whether the sample genereated name, subfolder and path need to be refreshed in a preview + self.sampleStale = False + + self.status = STATUS_NOT_DOWNLOADED + self.problem = None # class Problem in problemnotifcation.py + + def loadMetadata(self): + """ + Attempt to load the metadata for the photo or video + + Raises errors if unable to be loaded + """ + if not self.metadata: + if self.isImage: + self.metadata = metadata.MetaData(self.fullFileName) + self.metadata.read() + else: + self.metadata = videometadata.VideoMetaData(self.fullFileName) + + def dateTime(self, alternative_if_date_missing=None): + date = None + if self.metadata: + date = self.metadata.dateTime() + if not date: + if alternative_if_date_missing: + date = alternative_if_date_missing + else: + date = datetime.datetime.fromtimestamp(self.modificationTime) + return date + + + def generateThumbnail(self, tempWorkingDir): + """ + Attempts to generate or extract a thumnail and its orientation for the photo or video + """ + if self.metadata is None: + sys.stderr.write("metadata should not be empty!") + else: + if self.isImage: + try: + thumbnail = self.metadata.getThumbnailData(MAX_THUMBNAIL_SIZE) + if not isinstance(thumbnail, types.StringType): + self.thumbnail = None + else: + orientation = self.metadata.orientation(missing=None) + pbloader = gtk.gdk.PixbufLoader() + pbloader.write(thumbnail) + pbloader.close() + # Get the resulting pixbuf and build an image to be displayed + pixbuf = pbloader.get_pixbuf() + if orientation == 8: + pixbuf = pixbuf.rotate_simple(gtk.gdk.PIXBUF_ROTATE_COUNTERCLOCKWISE) + elif orientation == 6: + pixbuf = pixbuf.rotate_simple(gtk.gdk.PIXBUF_ROTATE_CLOCKWISE) + elif orientation == 3: + pixbuf = pixbuf.rotate_simple(gtk.gdk.PIXBUF_ROTATE_UPSIDEDOWN) + + self.thumbnail = pixbuf + except: + pass + else: + # get thumbnail of video + # it may need to be generated + self.thumbnail = self.metadata.getThumbnailData(MAX_THUMBNAIL_SIZE, tempWorkingDir) + if self.thumbnail: + # scale to size + self.thumbnail = common.scale2pixbuf(MAX_THUMBNAIL_SIZE, MAX_THUMBNAIL_SIZE, self.thumbnail) + class Media: @@ -138,7 +264,7 @@ class Media: class CardMedia(Media): """Compact Flash cards, hard drives, etc.""" - def __init__(self, path, volume = None, doNotScan=True): + def __init__(self, path, volume = None): """ volume is a gnomevfs or gio volume, see class Volume in rapid.py """ @@ -146,7 +272,7 @@ class CardMedia(Media): def setMedia(self, imagesAndVideos, fileSizeSum, noFiles): - self.imagesAndVideos = imagesAndVideos + self.imagesAndVideos = imagesAndVideos # class MediaFile self.fileSizeSum = fileSizeSum self.noFiles = noFiles @@ -158,19 +284,55 @@ class CardMedia(Media): return common.formatSizeForUser(self.fileSizeSum) else: return self.fileSizeSum - - def _firstFile(self, function_to_check): + + def sizeAndNumberDownloadPending(self): + """ + Returns how many files have their status set to download pending, and their size + """ + v = s = 0 + fileIndex = [] + for i in range(len(self.imagesAndVideos)): + mediaFile = self.imagesAndVideos[i][0] + if mediaFile.status == STATUS_DOWNLOAD_PENDING: + v += 1 + s += mediaFile.size + fileIndex.append(i) + return (v, s, fileIndex) + + def numberOfFilesNotCannotDownload(self): + """ + Returns how many files whose status is not cannot download + """ + v = 0 + for i in range(len(self.imagesAndVideos)): + mediaFile = self.imagesAndVideos[i][0] + if mediaFile.status <> STATUS_CANNOT_DOWNLOAD: + v += 1 + + return v + def downloadPending(self): + """ + Returns true if there a mediaFile with status download pending on the device. + Inefficient. Not currently used. + """ + for i in range(len(self.imagesAndVideos)): + mediaFile = self.imagesAndVideos[i][0] + if mediaFile.status == config.STATUS_DOWNLOAD_PENDING: + return True + return False + + def _firstFile(self, isImage): if self.imagesAndVideos: for i in range(len(self.imagesAndVideos)): - if function_to_check(self.imagesAndVideos[i][0]): - return self.imagesAndVideos[i] + if self.imagesAndVideos[i][0].isImage == isImage: + return self.imagesAndVideos[i][0] else: return None def firstImage(self): - return self._firstFile(isImage) + return self._firstFile(True) def firstVideo(self): - return self._firstFile(isVideo) + return self._firstFile(False) diff --git a/rapid/metadata.py b/rapid/metadata.py index c116072..3077a61 100755 --- a/rapid/metadata.py +++ b/rapid/metadata.py @@ -23,6 +23,7 @@ import sys import subprocess import config import types +import time try: import pyexiv2 @@ -370,7 +371,24 @@ class MetaData(baseclass): return model else: return missing + + def filterMangledDates(self, d): + """ + Some EXIF dates are badly formed. Try to fix them + """ + _datetime = d.strip() + # remove any weird characters at the end of the string + while _datetime and not _datetime[-1].isdigit(): + _datetime = _datetime[:-1] + _date, _time = _datetime.split(' ') + _datetime = "%s %s" % (_date.replace(":", "-") , _time.replace("-", ":")) + try: + d = datetime.datetime.strptime(_datetime, '%Y-%m-%d %H:%M:%S') + except: + d = None + return d + def dateTime(self, missing=''): """ Returns in python datetime format the date and time the image was @@ -387,10 +405,26 @@ class MetaData(baseclass): v = self["Exif.Photo.DateTimeOriginal"] else: v = self["Exif.Image.DateTime"] + if isinstance(v, types.StringType): + v = self.filterMangledDates(v) + if v is None: + v = missing return v except: return missing + def timeStamp(self, missing=''): + dt = self.dateTime(missing=None) + if not dt is None: + try: + t = dt.timetuple() + ts = time.mktime(t) + except: + ts = missing + else: + ts = missing + return ts + def subSeconds(self, missing='00'): """ returns the subsecond the image was taken, as recorded by the camera""" try: diff --git a/rapid/problemnotification.py b/rapid/problemnotification.py new file mode 100755 index 0000000..8dc07d0 --- /dev/null +++ b/rapid/problemnotification.py @@ -0,0 +1,427 @@ +#!/usr/bin/python +# -*- coding: latin1 -*- + +### Copyright (C) 2010 Damon Lynch + +### 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 +### the Free Software Foundation; either version 2 of the License, or +### (at your option) any later version. + +### This program is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU General Public License for more details. + +### You should have received a copy of the GNU General Public License +### along with this program; if not, write to the Free Software +### Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +import sys +import types +from common import Configi18n +global _ +_ = Configi18n._ + + +# components +SUBFOLDER_COMPONENT = _('subfolder') +FILENAME_COMPONENT = _('filename') + +# problem categories +METADATA_PROBLEM = 1 +FILE_PROBLEM = 2 +GENERATION_PROBLEM = 3 +DOWNLOAD_PROBLEM = 4 +DOWNLOAD_PROBLEM_W_NO = 5 +DIFFERENT_EXIF = 6 +FILE_ALREADY_EXISTS = 7 +UNIQUE_IDENTIFIER_CAT = 8 +BACKUP_PROBLEM = 9 +BACKUP_OK = 10 +FILE_ALREADY_DOWN_CAT = 11 + +# problem text +MISSING_METADATA = 1 +INVALID_DATE_TIME = 2 +MISSING_FILE_EXTENSION = 3 +MISSING_IMAGE_NUMBER = 4 +ERROR_IN_GENERATION = 5 + +CANNOT_DOWNLOAD_BAD_METADATA = 6 + +ERROR_IN_NAME_GENERATION = 7 + +DOWNLOAD_COPYING_ERROR = 8 +DOWNLOAD_COPYING_ERROR_W_NO = 9 + +FILE_ALREADY_EXISTS_NO_DOWNLOAD = 10 +UNIQUE_IDENTIFIER_ADDED = 11 +BACKUP_EXISTS = 12 +BACKUP_EXISTS_OVERWRITTEN = 13 +NO_BACKUP_PERFORMED = 14 +BACKUP_ERROR = 15 +BACKUP_DIRECTORY_CREATION = 16 + +SAME_FILE_DIFFERENT_EXIF = 17 +NO_DOWNLOAD_WAS_BACKED_UP = 18 +FILE_ALREADY_DOWNLOADED = 19 + +#extra details +UNIQUE_IDENTIFIER = '__1' +EXISTING_FILE = '__2' +NO_DATA_TO_NAME = '__3' +DOWNLOAD_COPYING_ERROR_DETAIL = '__4' +DOWNLOAD_COPYING_ERROR_W_NO_DETAIL = '__5' +BACKUP_OK_TYPE = '__6' + +# category, text, duplicates allowed +problem_definitions = { + + MISSING_METADATA: (METADATA_PROBLEM, "%s", True), + INVALID_DATE_TIME: (METADATA_PROBLEM, _('Date time value %s appears invalid.'), False), + MISSING_FILE_EXTENSION: (METADATA_PROBLEM, _("Filename does not have an extension."), False), + # a number component is something like the 8346 in IMG_8346.JPG + MISSING_IMAGE_NUMBER: (METADATA_PROBLEM, _("Filename does not have a number component."), False), + ERROR_IN_GENERATION: (METADATA_PROBLEM, _("Error generating component %s."), False), # a generic problem + + CANNOT_DOWNLOAD_BAD_METADATA: (FILE_PROBLEM, _("%(filetype)s metadata cannot be read"), False), + + ERROR_IN_NAME_GENERATION: (GENERATION_PROBLEM, _("%(filetype)s %(area)s could not be generated"), False), + + DOWNLOAD_COPYING_ERROR: (DOWNLOAD_PROBLEM, _("An error occurred when copying the %(filetype)s"), False), + DOWNLOAD_COPYING_ERROR_W_NO: (DOWNLOAD_PROBLEM_W_NO, _("An error occurred when copying the %(filetype)s"), False), + + FILE_ALREADY_EXISTS_NO_DOWNLOAD:(FILE_ALREADY_EXISTS, _("%(filetype)s already exists"), False), + UNIQUE_IDENTIFIER_ADDED: (UNIQUE_IDENTIFIER_CAT, _("%(filetype)s already exists"), False), + BACKUP_EXISTS: (BACKUP_PROBLEM, "%s", True), + BACKUP_EXISTS_OVERWRITTEN: (BACKUP_PROBLEM, "%s", True), + NO_BACKUP_PERFORMED: (BACKUP_PROBLEM, _("%(filetype)s could not be backed up because no suitable backup locations were found."), False), + BACKUP_ERROR: (BACKUP_PROBLEM, "%s", True), + BACKUP_DIRECTORY_CREATION: (BACKUP_PROBLEM, "%s", True), + NO_DOWNLOAD_WAS_BACKED_UP: (BACKUP_OK, "%s", True), + + SAME_FILE_DIFFERENT_EXIF: (DIFFERENT_EXIF, _("%(image1)s was taken at on %(image1_date)s at %(image1_time)s, and %(image2)s on %(image2_date)s at %(image2_time)s."), False), + FILE_ALREADY_DOWNLOADED: (FILE_ALREADY_DOWN_CAT, _('%(filetype)s was already downloaded'), False), +} + +extra_detail_definitions = { + UNIQUE_IDENTIFIER: _("The existing %(filetype)s was last modified on %(date)s at %(time)s. Unique identifier '%(identifier)s' added."), + EXISTING_FILE: _("The existing %(filetype)s was last modified on %(date)s at %(time)s."), + NO_DATA_TO_NAME: _("There is no data with which to name the %(filetype)s."), + DOWNLOAD_COPYING_ERROR_DETAIL: "%s", + DOWNLOAD_COPYING_ERROR_W_NO_DETAIL: _("Error: %(errorno)s %(strerror)s"), + BACKUP_OK_TYPE: "%s", +} + +class Problem: + """ + Collect problems with subfolder and filename generation, download errors, and so forth + + Problems are human readable + """ + + def __init__(self): + self.problems = {} + self.categories = {} + self.components = [] + self.extra_detail = {} + + def add_problem(self, component, problem_definition, *args): + added = True + if problem_definition not in problem_definitions: + sys.stderr.write("FIXME: unknown problem definition!\n") + else: + category, problem, duplicates_ok = problem_definitions[problem_definition] + + if args: + # check for special case of named arguments in a dictionary + if isinstance(args[0], types.DictType): + problem_details = problem % args[0] + else: + problem_details = problem % args + else: + problem_details = problem + + if not duplicates_ok: + self.problems[problem_definition] = [problem_details] + else: + if problem_definition in self.problems: + if problem_details not in self.problems[problem_definition]: + self.problems[problem_definition].append(problem_details) + else: + added = False + else: + self.problems[problem_definition] = [problem_details] + + if category not in self.categories or not added: + self.categories[category] = 1 + else: + self.categories[category] += 1 + + if (component is not None) and (component not in self.components): + self.components.append(component) + + def add_extra_detail(self, extra_detail, *args): + if extra_detail not in extra_detail_definitions: + self.extra_detail[extra_detail] = args[0] + else: + detail = extra_detail_definitions[extra_detail] + + if args: + if isinstance(args[0], types.DictType): + extra_details = detail % args[0] + else: + extra_details = detail % args + else: + extra_details = detail + + self.extra_detail[extra_detail] = extra_details + + + def has_problem(self): + return len(self.problems) > 0 + + def get_problems(self): + """ + Returns a string with the problems encountered in downloading the file. + """ + + def get_backup_error_inst(volume): + if ('%s%s' % (BACKUP_ERROR, volume)) in self.extra_detail: + return self.extra_detail['%s%s' % (BACKUP_ERROR, volume)] + else: + return '' + + def get_dir_creation_inst(volume): + return self.extra_detail['%s%s' % (BACKUP_DIRECTORY_CREATION, volume)] + + v = '' + + # special cases + if FILE_PROBLEM in self.categories: + return _("The metadata might be corrupt.") + + if FILE_ALREADY_DOWN_CAT in self.categories: + return _("The filename, extension and Exif information indicate it has already been downloaded.") + + if FILE_ALREADY_EXISTS in self.categories: + if EXISTING_FILE in self.extra_detail: + v = self.extra_detail[EXISTING_FILE] + + + if UNIQUE_IDENTIFIER_CAT in self.categories: + v = self.extra_detail[UNIQUE_IDENTIFIER] + + if DOWNLOAD_PROBLEM in self.categories: + v = self.extra_detail[DOWNLOAD_COPYING_ERROR_DETAIL] + + if DOWNLOAD_PROBLEM_W_NO in self.categories: + v = self.extra_detail[DOWNLOAD_COPYING_ERROR_W_NO_DETAIL] + + if BACKUP_OK in self.categories: + details = self.problems[NO_DOWNLOAD_WAS_BACKED_UP] + if len(self.problems[NO_DOWNLOAD_WAS_BACKED_UP]) == 1: + vv = _(' It was backed up to %(volume)s') % {'volume': details[0]} + else: + vv = _(" It was backed up to these devices: ") + for d in details[:-1]: + vv += _("%s, ") % d + vv = _("%(volumes)s and %(final_volume)s.") % \ + {'volumes': vv[:-2], + 'final_volume': details[-1]} \ + + ' ' + v += vv + + if GENERATION_PROBLEM in self.categories: + v = self.extra_detail[NO_DATA_TO_NAME] + + if DIFFERENT_EXIF in self.categories: + v = self.problems[SAME_FILE_DIFFERENT_EXIF][0] + if METADATA_PROBLEM in self.categories: + v = _('Photos detected with the same filenames, but taken at different times: %(details)s' ) % {'details':v} + + # Problems backing up + if BACKUP_PROBLEM in self.categories: + vv = '' + for p in self.problems: + details = self.problems[p] + + if p == NO_BACKUP_PERFORMED: + vv = details[0] + + elif p == BACKUP_ERROR: + + if len(details) == 1: + volume = details[0] + inst = get_backup_error_inst(volume) + if inst: + vv += _("An error occurred when backing up on %(volume)s: %(inst)s.") % {'volume': volume, 'inst': inst} + ' ' + else: + vv += _("An error occurred when backing up on %(volume)s.") % {'volume': volume} + ' ' + else: + vv += _("Errors occurred when backing up on the following backup devices: ") + for volume in details[:-1]: + inst = get_backup_error_inst(volume) + if inst: + vv += _("%(volume)s (%(inst)s), ") % {'volume': volume, 'inst': inst} + else: + vv += _("%(volume)s, ") % {'volume': volume} + volume = details[-1] + inst = get_backup_error_inst(volume) + if inst: + vv = _("%(volumes)s and %(volume)s (%(inst)s).") % \ + {'volumes': vv[:-2], + 'volume': volume, + 'inst': get_inst(volume)} + else: + vv = _("%(volumes)s and %(volume)s.") % \ + {'volumes': vv[:-2], + 'volume': volume} \ + + ' ' + + + elif p == BACKUP_EXISTS: + if len(details) == 1: + vv += _("Backup already exists on %(volume)s.") % {'volume': details[0]} + ' ' + else: + vv += _("Backups already exist in these locations: ") + for d in details[:-1]: + vv += _("%s, ") % d + vv = _("%(volumes)s and %(final_volume)s.") % \ + {'volumes': vv[:-2], + 'final_volume': details[-1]} \ + + ' ' + + elif p == BACKUP_EXISTS_OVERWRITTEN: + if len(details) == 1: + vv += _("Backup overwritten on %(volume)s.") % {'volume': details[0]} + ' ' + else: + vv += _("Backups overwritten on these devices: ") + for d in details[:-1]: + vv += _("%s, ") % d + vv = _("%(volumes)s and %(final_volume)s.") % \ + {'volumes': vv[:-2], + 'final_volume': details[-1]} \ + + ' ' + + elif p == BACKUP_DIRECTORY_CREATION: + if len(details) == 1: + volume = details[0] + vv += _("An error occurred when creating directories on %(volume)s: %(inst)s.") % {'volume': volume, 'inst': get_dir_creation_inst(volume)} + ' ' + else: + vv += _("Errors occurred when creating directories on the following backup devices: ") + for volume in details[:-1]: + vv += _("%(volume)s (%(inst)s), ") % {'volume': volume, 'inst': get_dir_creation_inst(volume)} + volume = details[-1] + vv = _("%(volumes)s and %(volume)s (%(inst)s).") % \ + {'volumes': vv[:-2], + 'volume': volume, + 'inst': get_dir_creation_inst(volume)} \ + + ' ' + + if v: + v = _('%(previousproblem)s Additionally, %(newproblem)s') % {'previousproblem': v, 'newproblem': vv[0].lower() + vv[1:]} + else: + v = vv + + + if v and METADATA_PROBLEM in self.categories: + vv = self._get_generation_title() + if self.categories[METADATA_PROBLEM] > 1: + v += _(' Furthermore, there were %(problems)s.') % {'problems': vv[0].lower() + vv[1:]} + else: + v += _(' Furthermore, there was a %(problem)s.') % {'problem': vv[0].lower() + vv[1:]} + + # Problems generating file / subfolder names + if METADATA_PROBLEM in self.categories: + for p in self.problems: + vv = '' + details = self.problems[p] + if p == MISSING_METADATA: + if len(details) == 1: + vv = _("The %(type)s metadata is missing.") % {'type': details[0]} + else: + vv = _("The following metadata is missing: ") + for d in details[:-1]: + vv += ("%s, ") % d + vv = _("%(missing_metadata_elements)s and %(final_missing_metadata_element)s.") % \ + {'missing_metadata_elements': vv[:-2], + 'final_missing_metadata_element': details[-1]} + + + elif p in [MISSING_IMAGE_NUMBER, ERROR_IN_GENERATION, INVALID_DATE_TIME]: + vv = details[0] + + v += ' ' + vv + + v = v.strip() + return v + + def _get_generation_title(self): + if self.components: + if len(self.components) > 1: + if self.categories[METADATA_PROBLEM] > 1: + return _('Problems in subfolder and filename generation') + else: + return _('Problem in subfolder and filename generation') + else: + if self.categories[METADATA_PROBLEM] > 1: + return _('Problems in %s generation') % self.components[0] + else: + return _('Problem in %s generation') % self.components[0] + return '' + + + def get_title(self): + v = '' + + if BACKUP_OK in self.categories: + if FILE_ALREADY_EXISTS in self.categories: + v = _('%(filetype)s already exists, but it was backed up') % {'filetype': self.extra_detail[BACKUP_OK_TYPE]} + else: + v = _('An error occurred when copying the %(filetype)s, but it was backed up') % {'filetype': self.extra_detail[BACKUP_OK_TYPE]} + + # High priority problems + elif FILE_ALREADY_DOWN_CAT in self.categories: + v = self.problems[FILE_ALREADY_DOWNLOADED][0] + elif DOWNLOAD_PROBLEM in self.categories: + v = self.problems[DOWNLOAD_COPYING_ERROR][0] + elif DOWNLOAD_PROBLEM_W_NO in self.categories: + v = self.problems[DOWNLOAD_COPYING_ERROR_W_NO][0] + elif GENERATION_PROBLEM in self.categories: + v = self.problems[ERROR_IN_NAME_GENERATION][0] + elif FILE_ALREADY_EXISTS in self.categories: + v = self.problems[FILE_ALREADY_EXISTS_NO_DOWNLOAD][0] + elif UNIQUE_IDENTIFIER_CAT in self.categories: + v = self.problems[UNIQUE_IDENTIFIER_ADDED][0] + elif FILE_PROBLEM in self.categories: + v = self.problems[CANNOT_DOWNLOAD_BAD_METADATA][0] + + # Lesser priority + elif len(self.categories) > 1: + v = _('Multiple problems were encountered') + elif DIFFERENT_EXIF in self.categories: + v = _('Photos detected with the same filenames, but taken at different times') + elif METADATA_PROBLEM in self.categories: + v = self._get_generation_title() + + if BACKUP_PROBLEM in self.categories: + if self.categories[BACKUP_PROBLEM] >1: + vp = _("there were errors backing up") + vv = _("There were errors backing up") + else: + vp = _("there was an error backing up") + vv = _("There was an error backing up") + if v: + # e.g. + v = _("%(previousproblem)s, and %(backinguperror)s") % {'previousproblem': v, 'backinguperror':vp} + else: + v = vv + + return v + + + +if __name__ == '__main__': + pass diff --git a/rapid/rapid.py b/rapid/rapid.py index ddd0b95..6f5490d 100755 --- a/rapid/rapid.py +++ b/rapid/rapid.py @@ -43,11 +43,12 @@ from thread import get_ident import gtk.gdk as gdk import pango +import gobject try: import gio + import glib using_gio = True - import gobject except ImportError: import gnomevfs using_gio = False @@ -66,11 +67,24 @@ import ValidatedEntry import idletube as tube import config +from config import MAX_THUMBNAIL_SIZE +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 -from media import getDefaultPhotoLocation, getDefaultVideoLocation +from media import getDefaultPhotoLocation, getDefaultVideoLocation, \ + getDefaultBackupPhotoIdentifier, \ + getDefaultBackupVideoIdentifier + from media import CardMedia import media @@ -79,7 +93,8 @@ import metadata import videometadata from videometadata import DOWNLOAD_VIDEO -import renamesubfolderprefs as rn +import renamesubfolderprefs as rn +import problemnotification as pn import tableplusminus as tpm @@ -96,6 +111,12 @@ try: except: sys.exit(1) +try: + from dropshadow import image_to_pixbuf, pixbuf_to_image, DropShadow + DROP_SHADOW = True +except: + DROP_SHADOW = False + from common import Configi18n global _ _ = Configi18n._ @@ -103,7 +124,7 @@ _ = 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') -MAX_THUMBNAIL_SIZE = 100 + def today(): return datetime.date.today().strftime('%Y-%m-%d') @@ -151,12 +172,15 @@ class Queue(tube.Tube): # this is ugly but I don't know a better way :( display_queue = Queue() -media_collection_treeview = thumbnail_hbox = log_dialog = None +media_collection_treeview = selection_hbox = log_dialog = None job_code = None -need_job_code = False +need_job_code_for_renaming = False class ThreadManager: + """ + Manages the threads that actually download photos and videos + """ _workers = [] @@ -191,9 +215,15 @@ class ThreadManager: 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 @@ -222,11 +252,7 @@ class ThreadManager: 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 startDownloadingWorkers(self): - for w in self.getReadyToDownloadWorkers(): - w.startStop() + w.start() def quitAllWorkers(self): global exiting @@ -262,6 +288,12 @@ class ThreadManager: 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 @@ -270,6 +302,27 @@ class ThreadManager: 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: @@ -286,22 +339,21 @@ class ThreadManager: for w in self._workers: if self._isDownloading(w): yield w - - - def getPausedWorkers(self): - for w in self._workers: - if w.hasStarted and not w.running: - yield w def getPausedDownloadingWorkers(self): for w in self._workers: - if w.downloadStarted and not w.running: + 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: @@ -322,6 +374,13 @@ class ThreadManager: 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) @@ -354,7 +413,7 @@ class RapidPreferences(prefs.Preferences): "download_folder": prefs.Value(prefs.STRING, getDefaultPhotoLocation()), "video_download_folder": prefs.Value(prefs.STRING, - getDefaultVideoLocation()), + 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, @@ -371,9 +430,9 @@ class RapidPreferences(prefs.Preferences): "backup_images": prefs.Value(prefs.BOOL, False), "backup_device_autodetection": prefs.Value(prefs.BOOL, True), "backup_identifier": prefs.Value(prefs.STRING, - config.DEFAULT_BACKUP_LOCATION), + getDefaultBackupPhotoIdentifier()), "video_backup_identifier": prefs.Value(prefs.STRING, - config.DEFAULT_VIDEO_BACKUP_LOCATION), + 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), @@ -381,15 +440,19 @@ class RapidPreferences(prefs.Preferences): "auto_unmount": prefs.Value(prefs.BOOL, False), "auto_exit": prefs.Value(prefs.BOOL, False), "auto_delete": prefs.Value(prefs.BOOL, False), - "indicate_download_error": prefs.Value(prefs.BOOL, True), "download_conflict_resolution": prefs.Value(prefs.STRING, config.SKIP_DOWNLOAD), "backup_duplicate_overwrite": prefs.Value(prefs.BOOL, False), - "backup_missing": prefs.Value(prefs.STRING, config.IGNORE), - "display_thumbnails": prefs.Value(prefs.BOOL, True), + "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']), + "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'), @@ -397,6 +460,12 @@ class RapidPreferences(prefs.Preferences): _('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), } def __init__(self): @@ -503,7 +572,7 @@ class ImageRenameTable(tpm.TablePlusMinus): self.connect("remove", self.size_adjustment) # get scrollbar thickness from parent app scrollbar - very hackish, but what to do?? - self.bump = self.parentApp.parentApp.image_scrolledwindow.get_hscrollbar().allocation.height + 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 @@ -749,21 +818,19 @@ class PreferencesDialog(gnomeglade.Component): # get example photo and video data try: w = workers.firstWorkerReadyToDownload() - root, self.sampleImageName = w.firstImage() - image = os.path.join(root, self.sampleImageName) - - self.sampleImage = metadata.MetaData(image) - self.sampleImage.read() + 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: - root, self.sampleVideoName, modificationTime = w.firstVideo() - video = os.path.join(root, self.sampleVideoName) - self.sampleVideo = videometadata.VideoMetaData(video) - self.videoFallBackDate = modificationTime + 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' @@ -861,7 +928,6 @@ class PreferencesDialog(gnomeglade.Component): self.compatibility_table.set_row_spacing(0, hd.VERTICAL_CONTROL_LABEL_SPACE) self._setupTableSpacing(self.error_table) - self.error_table.set_row_spacing(5, hd.VERTICAL_CONTROL_SPACE / 2) def _setupTableSpacing(self, table): @@ -1016,11 +1082,7 @@ class PreferencesDialog(gnomeglade.Component): 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.missing_backup_label, - self.backup_error_radiobutton, - self.backup_warning_radiobutton, - self.backup_ignore_radiobutton] + self._backupControls0 = [self.auto_detect_backup_checkbutton] self._backupControls1 = [self.backup_identifier_explanation_label, self.backup_identifier_label, self.backup_identifier_entry, @@ -1059,21 +1121,11 @@ class PreferencesDialog(gnomeglade.Component): def _setupErrorTab(self): - self.indicate_download_error_checkbutton.set_active( - self.prefs.indicate_download_error) - 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_missing == config.REPORT_ERROR: - self.backup_error_radiobutton.set_active(True) - elif self.prefs.backup_missing == config.REPORT_WARNING: - self.backup_warning_radiobutton.set_active(True) - else: - self.backup_ignore_radiobutton.set_active(True) - if self.prefs.backup_duplicate_overwrite: self.backup_duplicate_overwrite_radiobutton.set_active(True) else: @@ -1081,18 +1133,20 @@ class PreferencesDialog(gnomeglade.Component): 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() - name, problem = rename_table.prefsFactory.generateNameUsingPreferences( + rename_table.prefsFactory.initializeProblem(problem) + name = rename_table.prefsFactory.generateNameUsingPreferences( sample, sampleName, self.prefs.strip_characters, sequencesPreliminary=False, fallback_date=fallback_date) else: - name = problem = '' + name = '' # since this is markup, escape it text = "%s" % common.escape(name) - if problem: + if problem.has_problem(): text += "\n" # Translators: please do not modify or leave out html formatting tags like and . These are used to format the text the users sees text += _("Warning: There is insufficient metadata to fully generate the name. Please use other renaming options.") @@ -1117,18 +1171,20 @@ class PreferencesDialog(gnomeglade.Component): Displays example subfolder name(s) to the user """ + problem = pn.Problem() if hasattr(self, display_table): subfolder_table.updateExampleJobCode() - path, problem = subfolder_table.prefsFactory.generateNameUsingPreferences( + subfolder_table.prefsFactory.initializeProblem(problem) + path = subfolder_table.prefsFactory.generateNameUsingPreferences( sample, sampleName, self.prefs.strip_characters, fallback_date = fallback_date) else: - path = problem = '' + path = '' text = os.path.join(download_folder, path) # since this is markup, escape it path = common.escape(text) - if problem: + if problem.has_problem(): warning = _("Warning: There is insufficient metadata to fully generate subfolders. Please use other subfolder naming options." ) else: warning = "" @@ -1235,10 +1291,10 @@ class PreferencesDialog(gnomeglade.Component): def on_add_job_code_button_clicked(self, button): - j = JobCodeDialog(self.widget, self.prefs.job_codes, None, self.add_job_code, False, True) + 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): + 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: @@ -1342,15 +1398,6 @@ class PreferencesDialog(gnomeglade.Component): def on_backup_duplicate_skip_radiobutton_toggled(self, widget): self.prefs.backup_duplicate_overwrite = not widget.get_active() - - def on_backup_error_radiobutton_toggled(self, widget): - self.prefs.backup_missing = config.REPORT_ERROR - - def on_backup_warning_radiobutton_toggled(self, widget): - self.prefs.backup_missing = config.REPORT_WARNING - - def on_backup_ignore_radiobutton_toggled(self, widget): - self.prefs.backup_missing = config.IGNORE def on_treeview_cursor_changed(self, tree): path, column = tree.get_cursor() @@ -1365,9 +1412,6 @@ class PreferencesDialog(gnomeglade.Component): self.updatePhotoDownloadFolderExample() self.updateVideoDownloadFolderExample() - def on_indicate_download_error_checkbutton_toggled(self, check_button): - self.prefs.indicate_download_error = check_button.get_active() - def on_add_identifier_radiobutton_toggled(self, widget): if widget.get_active(): self.prefs.download_conflict_resolution = config.ADD_UNIQUE_IDENTIFIER @@ -1512,12 +1556,88 @@ def file_types_by_number(noImages, noVideos): 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"), + 'hour':date.strftime("%H"), + 'minute':date.strftime("%M"), + '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 + + +class NeedAJobCode(): + """ + Convenience class to check whether a job code is missing for a given + file type (photo or video) + """ + 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): + downloadStats, autoStart = False, cardMedia = None): self.parentApp = parentApp self.thread_id = thread_id self.ctrl = True @@ -1545,10 +1665,19 @@ class CopyPhotos(Thread): self.cardMedia = cardMedia self.initializeDisplay(thread_id, self.cardMedia) + + self.scanComplete = self.downloadStarted = self.downloadComplete = False - self.noErrors = self.noWarnings = 0 + # 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.scanComplete = self.downloadStarted = self.downloadComplete = False + if DOWNLOAD_VIDEO: + self.types_searched_for = _('photos or videos') + else: + self.types_searched_for = _('photos') Thread.__init__(self) @@ -1557,39 +1686,34 @@ class CopyPhotos(Thread): if self.cardMedia: media_collection_treeview.addCard(thread_id, self.cardMedia.prettyName(), - '', 0, progress=0.0, + '', 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 of y photos"). + # (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 name, path and size of the first image + returns class mediaFile of the first photo """ - - name, root, size, modificationTime = self.cardMedia.firstImage() + mediaFile = self.cardMedia.firstImage() + return mediaFile - return root, name - def firstVideo(self): """ - returns name, path and size of the first image + returns class mediaFile of the first video """ - - name, root, size, modificationTime = self.cardMedia.firstVideo() - - return root, name, modificationTime - + 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): + def initializeFromPrefs(self, notifyOnError): """ Setup thread so that user preferences are handled """ @@ -1605,7 +1729,6 @@ class CopyPhotos(Thread): self.prefs = self.parentApp.prefs - #Image and Video filename preferences self.imageRenamePrefsFactory = rn.ImageRenamePreferences(self.prefs.image_rename, self, @@ -1627,8 +1750,7 @@ class CopyPhotos(Thread): # 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 @@ -1705,14 +1827,92 @@ class CopyPhotos(Thread): 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 + + if DROP_SHADOW: + self.photoThumbnail = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/photo_shadow.png')) + self.videoThumbnail = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/video_shadow.png')) + else: + self.photoThumbnail = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/photo.png')) + self.videoThumbnail = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/video.png')) + + 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: + mediaFile.genericThumbnail = True + if mediaFile.isImage: + mediaFile.thumbnail = self.photoThumbnail + else: + mediaFile.thumbnail = self.videoThumbnail - def downloadFile(name): + 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): + 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 @@ -1726,9 +1926,6 @@ class CopyPhotos(Thread): self.running = True if not self.ctrl: - self.running = False - display_queue.put((media_collection_treeview.removeCard, (self.thread_id, ))) - display_queue.close("rw") return None if child.get_file_type() == gio.FILE_TYPE_DIRECTORY: @@ -1738,15 +1935,13 @@ class CopyPhotos(Thread): return None elif child.get_file_type() == gio.FILE_TYPE_REGULAR: name = child.get_name() - download, isImage, isVideo = downloadFile(name) + download, isImage, isVideo = downloadable(name) if download: size = child.get_size() - imagesAndVideos.append((name, path.get_path(), size, child.get_modification_time()),) + modificationTime = child.get_modification_time() + addFile(name, path.get_path(), size, modificationTime, self.cardMedia.prettyName(limit=0), self.cardMedia.volume, isImage) fileSizeSum += size - if isVideo: - self.noVideos += 1 - else: - self.noImages += 1 + return fileSizeSum @@ -1754,7 +1949,7 @@ class CopyPhotos(Thread): 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: @@ -1763,23 +1958,17 @@ class CopyPhotos(Thread): self.running = True if not self.ctrl: - self.running = False - display_queue.put((media_collection_treeview.removeCard, (self.thread_id, ))) - display_queue.close("rw") - return + return None - download, isImage, isVideo = downloadFile(name) + download, isImage, isVideo = downloadable(name) if download: - image = os.path.join(root, name) - size = os.path.getsize(image) - modificationTime = os.path.getmtime(image) - imagesAndVideos.append((name, root, size, modificationTime),) + 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 - if isVideo: - self.noVideos += 1 - else: - self.noImages += 1 + else: # using gio and have a volume @@ -1787,19 +1976,15 @@ class CopyPhotos(Thread): fileSizeSum = gio_scan(self.cardMedia.volume.volume.get_root(), fileSizeSum) if fileSizeSum == None: # thread exiting - return + return None - imagesAndVideos.sort(key=operator.itemgetter(3)) + # 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 DOWNLOAD_VIDEO: - self.types_searched_for = _('photos or videos') - else: - self.types_searched_for = _('photos') if noFiles: @@ -1808,10 +1993,9 @@ class CopyPhotos(Thread): # 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 = _("0 of %(number)s %(filetypes)s") % {'number':noFiles, 'filetypes':self.display_file_types} - display_queue.put((media_collection_treeview.updateCard, (self.thread_id, self.cardMedia.sizeOfImagesAndVideos(), noFiles))) + 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.timeRemaining.add, (self.thread_id, fileSizeSum))) 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. @@ -1829,23 +2013,6 @@ class CopyPhotos(Thread): 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 cleanUp(): - """ - Cleanup functions that must be performed whether the thread exits - early or when it has completed its run. - """ - - - for tempWorkingDir in (videoTempWorkingDir, photoTempWorkingDir): - if tempWorkingDir: - # possibly delete any lingering files - tf = os.listdir(tempWorkingDir) - if tf: - for f in tf: - os.remove(os.path.join(tempWorkingDir, f)) - - os.rmdir(tempWorkingDir) def logError(severity, problem, details, resolution=None): @@ -1856,228 +2023,334 @@ class CopyPhotos(Thread): 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(newName, destination, source, problem, filetype): - if not newName: - # a serious problem - a filename should never be blank! - logError(config.SERIOUS_ERROR, - _("%(filetype)s filename could not be generated") % {'filetype': filetype}, - # '%(source)s' and '%(problem)s' are two more examples of text that should not be modified or left out - _("Source: %(source)s\nProblem: %(problem)s") % {'source': source, 'problem': problem}, - fileSkippedDisplay) - elif problem: + def checkProblemWithNameGeneration(mediaFile): + if mediaFile.problem.has_problem(): logError(config.WARNING, - _("%(filetype)s filename could not be properly generated. Check to ensure there is sufficient metadata.") % {'filetype': filetype}, - _("Source: %(source)s\nPartially generated filename: %(newname)s\nDestination: %(destination)s\nProblem: %(problem)s") % - {'source': source, 'destination': destination, 'newname': newName, 'problem': problem}) + 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(source, fileSkippedDisplay, fileAlreadyExistsDisplay, destination=None, identifier=None): + def fileAlreadyExists(mediaFile, identifier=None): """ Notify the user that the photo or video could not be downloaded because it already exists""" - if self.prefs.indicate_download_error: - if source and destination and identifier: - logError(config.SERIOUS_ERROR, fileAlreadyExistsDisplay, - _("Source: %(source)s\nDestination: %(destination)s") - % {'source': source, 'destination': newFile}, - _("Unique identifier '%s' added") % identifier) - elif source and destination: - logError(config.SERIOUS_ERROR, fileAlreadyExistsDisplay, - _("Source: %(source)s\nDestination: %(destination)s") - % {'source': source, 'destination': destination}, - fileSkippedDisplay) - else: - logError(config.SERIOUS_ERROR, fileAlreadyExistsDisplay, - _("Source: %(source)s") - % {'source': source}, - fileSkippedDisplay) - - - def downloadCopyingError(source, destination, filetype, errno=None, strerror=None): - """Notify the user that an error occurred when coyping an photo or video""" - if errno != None and strerror != None: - logError(config.SERIOUS_ERROR, _('Download copying error'), - _("Source: %(source)s\nDestination: %(destination)s\nError: %(errorno)s %(strerror)s") - % {'source': source, 'destination': destination, 'errorno': errno, 'strerror': strerror}, - _('The %(filetype)s was not copied.') % {'filetype': filetype}) + + # 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: - logError(config.SERIOUS_ERROR, _('Download copying error'), - _("Source: %(source)s\nDestination: %(destination)s") - % {'source': source, 'destination': destination}, - _('The %(filetype)s was not copied.') % {'filetype': filetype}) + 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} - - def sameFileNameDifferentExif(image1, image1_date_time, image1_subseconds, image2, image2_date_time, image2_subseconds): - logError(config.WARNING, _('Photos detected with the same filenames, but taken at different times:'), - _("First photo: %(image1)s %(image1_date_time)s:%(image1_subseconds)s\nSecond photo: %(image2)s %(image2_date_time)s:%(image2_subseconds)s") % - {'image1': image1, 'image1_date_time': image1_date_time, 'image1_subseconds': image1_subseconds, - 'image2': image2, 'image2_date_time': image2_date_time, 'image2_subseconds': image2_subseconds}) - + 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}) - def generateSubfolderAndFileName(fullFileName, name, needMetaDataToCreateUniqueImageName, - needMetaDataToCreateUniqueSubfolderName, fallback_date): + 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 not self.isImage: - # file is a video file + + if mediaFile.isVideo: fileRenameFactory = self.videoRenamePrefsFactory subfolderFactory = self.videoSubfolderPrefsFactory - try: - # this step immedidately reads the metadata from the video file - # (which is different than pyexiv2) - fileMetadata = videometadata.VideoMetaData(fullFileName) - except: - logError(config.CRITICAL_ERROR, _("Could not open %(filetype)s") % {'filetype': fileBeingDownloadedDisplay}, - _("Source: %s") % fullFileName, - fileSkippedDisplay) - skipFile = True - fileMetadata = newName = newFile = path = subfolder = sequence_to_use = None - return (skipFile, fileMetadata, newName, newFile, path, subfolder, sequence_to_use) else: # file is an photo fileRenameFactory = self.imageRenamePrefsFactory subfolderFactory = self.subfolderPrefsFactory - try: - fileMetadata = metadata.MetaData(fullFileName) - except IOError: - logError(config.CRITICAL_ERROR, _("Could not open %(filetype)s") % {'filetype': fileBeingDownloadedDisplay}, - _("Source: %s") % fullFileName, - fileSkippedDisplay) - skipFile = True - fileMetadata = newName = newFile = path = subfolder = sequence_to_use = None - return (skipFile, fileMetadata, newName, newFile, path, subfolder, sequence_to_use) - else: - try: - # this step can fail if the source photo is corrupt - fileMetadata.read() - except: - skipFile = True + + 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: - if self.isImage and not fileMetadata.rpd_keys() and (needMetaDataToCreateUniqueSubfolderName or - (needMetaDataToCreateUniqueImageName and - not addUniqueIdentifier)): + 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 - - #TODO similar checking for video - - if skipFile: - logError(config.SERIOUS_ERROR, _("%(filetype)s has no metadata") % {'filetype': fileBeingDownloadedDisplayCap}, - _("Metadata is essential for generating subfolder and/or file names.\nSource: %s") % fullFileName, - fileSkippedDisplay) - newName = newFile = path = subfolder = None + logError(config.SERIOUS_ERROR, pn.problem_definitions[ERROR_IN_NAME_GENERATION][1] % {'filetype': mediaFile.displayNameCap, 'area': area}) + + if not skipFile: + checkProblemWithNameGeneration(mediaFile) else: - # attempt to generate a subfolder name - subfolder, problem = subfolderFactory.generateNameUsingPreferences( - fileMetadata, name, - self.stripCharacters, fallback_date = fallback_date) - - if problem: - logError(config.WARNING, - _("Subfolder name could not be properly generated. Check to ensure there is sufficient metadata."), - _("Subfolder: %(subfolder)s\nFile: %(file)s\nProblem: %(problem)s") % - {'subfolder': subfolder, 'file': fullFileName, 'problem': problem}) - - if self.prefs.synchronize_raw_jpg and usesImageSequenceElements and self.isImage: - #synchronizing RAW and JPEG only applies to photos, not videos - image_name, image_ext = os.path.splitext(name) - with self.downloadedFilesLock: - i, sequence_to_use = downloaded_files.matching_pair(image_name, image_ext, fileMetadata.dateTime(), fileMetadata.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: - # there is no point to download it, as there is no way a unique filename will be generated - alreadyDownloaded = skipFile = True - elif i == -99: - i1_ext, i1_date_time, i1_subseconds = downloaded_files.extExifDateTime(image_name) - sameFileNameDifferentExif("%s%s" % (image_name, i1_ext), i1_date_time, i1_subseconds, name, fileMetadata.dateTime(), fileMetadata.subSeconds()) - - - # pass the subfolder the image will go into, as this is needed to determine subfolder sequence numbers - # indicate that sequences chosen should be queued + self.sizeDownloaded += mediaFile.size * (no_backup_devices + 1) + mediaFile.status = STATUS_DOWNLOAD_FAILED - # TODO check 'or alreadyDownloaded' is meant to be here - if not (skipFile or alreadyDownloaded): - newName, problem = fileRenameFactory.generateNameUsingPreferences( - fileMetadata, name, self.stripCharacters, subfolder, - sequencesPreliminary = True, - sequence_to_use = sequence_to_use, - fallback_date = fallback_date) - - path = os.path.join(baseDownloadDir, subfolder) - newFile = os.path.join(path, newName) - - if not newName: - skipFile = True - if not alreadyDownloaded: - checkProblemWithNameGeneration(newName, path, fullFileName, problem, fileBeingDownloadedDisplayCap) - else: - fileAlreadyExists(fullFileName, fileSkippedDisplay, fileAlreadyExistsDisplay, newFile) - newName = newFile = path = subfolder = None - - return (skipFile, fileMetadata, newName, newFile, path, subfolder, sequence_to_use) + 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(path, newFile, newName, originalName, image, fileMetadata, subfolder, sequence_to_use, modificationTime): + def downloadFile(mediaFile, sequence_to_use): """ Downloads the photo or video file to the specified subfolder """ - if not self.isImage: + if not mediaFile.isImage: renameFactory = self.videoRenamePrefsFactory else: renameFactory = self.imageRenamePrefsFactory - - def progress_callback(self, v): + + def progress_callback_no_update(amount_downloaded, total): pass try: fileDownloaded = False - if not os.path.isdir(path): - os.makedirs(path) + 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(newFile): + if os.path.exists(mediaFile.downloadFullFileName): nameUniqueBeforeCopy = False if not addUniqueIdentifier: downloadNonUniqueFile = False - if (usesVideoSequenceElements and not self.isImage) or (usesImageSequenceElements and self.isImage and not self.prefs.synchronize_raw_jpg): + 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, problem in renameFactory.generateNameSequencePossibilities(fileMetadata, - originalName, self.stripCharacters, subfolder): + for possibleName in renameFactory.generateNameSequencePossibilities( + mediaFile.metadata, + mediaFile.name, self.stripCharacters, mediaFile.downloadSubfolder): if possibleName: # no need to check for any problems here, it's just a temporary name - possibleFile = os.path.join(path, possibleName) - possibleTempFile = os.path.join(tempWorkingDir, possibleName) + 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(fullFileName, fileSkippedDisplay, fileAlreadyExistsDisplay, newFile) + fileAlreadyExists(mediaFile) copy_succeeded = False if nameUniqueBeforeCopy or downloadNonUniqueFile: - tempWorkingfile = os.path.join(tempWorkingDir, newName) + tempWorkingfile = os.path.join(tempWorkingDir, mediaFile.downloadName) if using_gio: g_dest = gio.File(path=tempWorkingfile) - g_src = gio.File(path=fullFileName) - if not g_src.copy(g_dest, progress_callback, cancellable=gio.Cancellable()): - downloadCopyingError(fullFileName, tempWorkingfile, fileBeingDownloadedDisplay) - else: - copy_succeeded = True - else: - shutil.copy2(fullFileName, 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: @@ -2086,333 +2359,364 @@ class CopyPhotos(Thread): 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 self.isImage: + 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(originalName) + image_name, image_ext = os.path.splitext(mediaFile.name) with self.downloadedFilesLock: - i, sequence_to_use = downloaded_files.matching_pair(image_name, image_ext, fileMetadata.dateTime(), fileMetadata.subSeconds()) + i, sequence_to_use = downloaded_files.matching_pair(image_name, image_ext, mediaFile.metadata.dateTime(), mediaFile.metadata.subSeconds()) if i == -99: - i1_ext, i1_date_time, i1_subseconds = downloaded_files.extExifDateTime(image_name) - sameFileNameDifferentExif("%s%s" % (image_name, i1_ext), i1_date_time, i1_subseconds, originalName, fileMetadata.dateTime(), fileMetadata.subSeconds()) - - + sameNameDifferentExif(image_name, mediaFile) - newName, problem = renameFactory.generateNameUsingPreferences( - fileMetadata, originalName, self.stripCharacters, subfolder, + mediaFile.downloadName = renameFactory.generateNameUsingPreferences( + mediaFile.metadata, mediaFile.name, self.stripCharacters, mediaFile.downloadSubfolder, sequencesPreliminary = False, sequence_to_use = sequence_to_use, - fallback_date = fallback_date) - checkProblemWithNameGeneration(newName, path, fullFileName, problem, fileBeingDownloadedDisplayCap) - if not newName: + fallback_date = mediaFile.modificationTime) + + if not mediaFile.downloadName: # there was a serious error generating the filename doRename = False else: - newFile = os.path.join(path, newName) + mediaFile.downloadFullFileName = os.path.join(mediaFile.downloadPath, mediaFile.downloadName) # check if the file exists again - if os.path.exists(newFile): + if os.path.exists(mediaFile.downloadFullFileName): if not addUniqueIdentifier: doRename = False - fileAlreadyExists(fullFileName, fileSkippedDisplay, fileAlreadyExistsDisplay, newFile) + fileAlreadyExists(mediaFile) else: # add basic suffix to make the filename unique - name = os.path.splitext(newName) + name = os.path.splitext(mediaFile.downloadName) suffixAlreadyUsed = True while suffixAlreadyUsed: - if newFile in duplicate_files: - duplicate_files[newFile] += 1 + if mediaFile.downloadFullFileName in duplicate_files: + duplicate_files[mediaFile.downloadFullFileName] += 1 else: - duplicate_files[newFile] = 1 - identifier = '_%s' % duplicate_files[newFile] - newName = name[0] + identifier + name[1] - possibleNewFile = os.path.join(path, newName) + 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(fullFileName, fileSkippedDisplay, fileAlreadyExistsDisplay, newFile, identifier=identifier) - newFile = possibleNewFile + fileAlreadyExists(mediaFile, identifier) + mediaFile.downloadFullFileName = possibleNewFile if doRename: + rename_succeeded = False if using_gio: - g_dest = gio.File(path=newFile) + g_dest = gio.File(path=mediaFile.downloadFullFileName) g_src = gio.File(path=tempWorkingfile) - if not g_src.move(g_dest, progress_callback, cancellable=gio.Cancellable()): - downloadCopyingError(tempWorkingfile, newFile, fileBeingDownloadedDisplay) + 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, newFile) + os.rename(tempWorkingfile, mediaFile.downloadFullFileName) + rename_succeeded = True - fileDownloaded = True - if usesImageSequenceElements: - if self.prefs.synchronize_raw_jpg and self.isImage: - name, ext = os.path.splitext(originalName) - if sequence_to_use is None: - with self.fileSequenceLock: - seq = self.imageRenamePrefsFactory.sequences.getFinalSequence() - else: - seq = sequence_to_use - with self.downloadedFilesLock: - downloaded_files.add_download(name, ext, fileMetadata.dateTime(), fileMetadata.subSeconds(), seq) + 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: - 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) + 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, (errno, strerror): - downloadCopyingError(fullFileName, newFile, fileBeingDownloadedDisplay, errno, strerror) - - except OSError, (errno, strerror): - downloadCopyingError(fullFileName, newFile, fileBeingDownloadedDisplay, errno, strerror) + except (IOError, OSError), (errno, strerror): + downloadCopyingError(mediaFile, errno=errno, strerror=strerror) - if usesImageSequenceElements: + if usesSequenceElements: if not fileDownloaded and sequence_to_use is None: - self.imageRenamePrefsFactory.sequences.imageCopyFailed() - - - return (fileDownloaded, newName, newFile) + 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(subfolder, newName, fileDownloaded, newFile, originalFile): + def backupFile(mediaFile, fileDownloaded, no_backup_devices): """ Backup photo or video to path(s) chosen by the user - there are two scenarios: + 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. """ - - #TODO convert to using GIO + backed_up = False fileNotBackedUpMessageDisplayed = False - try: + 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: - if self.isImage: + 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.exists(backupDir) or not self.prefs.backup_device_autodetection: + if os.path.isdir(backupDir) or not self.prefs.backup_device_autodetection: - backupPath = os.path.join(backupDir, subfolder) - newBackupFile = os.path.join(backupPath, newName) + 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 self.prefs.indicate_download_error: - severity = config.SERIOUS_ERROR - problem = _("Backup of %(file_type)s already exists") % {'file_type': fileBeingDownloadedDisplay} - details = _("Source: %(source)s\nDestination: %(destination)s") \ - % {'source': originalFile, 'destination': newBackupFile} - if copyBackup : - resolution = _("Backup %(file_type)s overwritten") % {'file_type': fileBeingDownloadedDisplay} + 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: - fileNotBackedUpMessageDisplayed = True - 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': fileBeingDownloadedDisplayCap, 'volume': volume} - else: - resolution = _("%(file_type)s not backed up") % {'file_type': fileBeingDownloadedDisplayCap} - logError(severity, problem, details, resolution) + resolution = _("%(file_type)s not backed up") % {'file_type': mediaFile.displayNameCap} + logError(severity, title, details, resolution) if copyBackup: if fileDownloaded: - fileToCopy = newFile + fileToCopy = mediaFile.downloadFullFileName else: - fileToCopy = originalFile + fileToCopy = mediaFile.fullFileName if os.path.isdir(backupPath): pathExists = True 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 = subfolder.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 - logError(config.SERIOUS_ERROR, _('Backing up error'), - _("Destination directory could not be created: %(directory)s\n") % - {'directory': folderToMake, } + - _("Source: %(source)s\nDestination: %(destination)s\n") % - {'source': originalFile, 'destination': newBackupFile} + - _("Error: %(errno)s %(strerror)s") % {'errno': errno, 'strerror': strerror}, - _('The %(file_type)s was not backed up.') % {'file_type': fileBeingDownloadedDisplay} - ) - pathExists = False - break + 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: - shutil.copy2(fileToCopy, newBackupFile) - backed_up = True - - except (IOError, OSError), (errno, strerror): - fileNotBackedUpMessageDisplayed = True - logError(config.SERIOUS_ERROR, _('Backing up error'), - _("Source: %(source)s\nDestination: %(destination)s\nError: %(errno)s %(strerror)s") - % {'source': originalFile, 'destination': newBackupFile, 'errno': errno, 'strerror': strerror}, - _('The %(file_type)s was not backed up.') % {'file_type': fileBeingDownloadedDisplay} - ) + 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': fileBeingDownloadedDisplayCap} - details = _("Source: %(source)s") % {'source': originalFile} + 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) - - return backed_up - def notifyAndUnmount(): - if not self.cardMedia.volume: - unmountMessage = "" - notificationName = PROGRAM_NAME - else: - notificationName = self.cardMedia.volume.get_name() - if self.prefs.auto_unmount: - 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 skipped") % {'noFiles':noFilesSkipped, 'filetypes':file_types_skipped} - - if unmountMessage: - message = "%s\n%s" % (message, unmountMessage) - - if self.noWarnings: - message = "%s\n%s " % (message, self.noWarnings) + _("warnings") - if self.noErrors: - message = "%s\n%s " % (message, self.noErrors) + _("errors") - - n = pynotify.Notification(notificationName, message) + if backed_up and mediaFile.status == STATUS_DOWNLOAD_FAILED: + mediaFile.problem.add_extra_detail(pn.BACKUP_OK_TYPE, mediaFile.displayNameCap) - if self.cardMedia.volume: - icon = self.cardMedia.volume.get_icon_pixbuf(self.parentApp.notification_icon_size) - else: - icon = self.parentApp.application_icon + 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 - n.set_icon_from_pixbuf(icon) - n.show() - + # 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 - - def getThumbnail(fileMetadata): - thumbnail = orientation = None - if self.isImage: - try: - thumbnail = fileMetadata.getThumbnailData(MAX_THUMBNAIL_SIZE) - if not isinstance(thumbnail, types.StringType): - thumbnail = None - except: - thumbnail = None - - if thumbnail is None: - logError(config.WARNING, _("Photo thumbnail could not be extracted"), fullFileName) - orientation = None - else: - orientation = fileMetadata.orientation(missing=None) - else: - # get thumbnail of video - # it may need to be generated - thumbnail = fileMetadata.getThumbnailData(MAX_THUMBNAIL_SIZE, tempWorkingDir) - if thumbnail: - orientation = 1 - return thumbnail, orientation - - 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 - self.hasStarted = True display_queue.open('w') #Do not try to handle any preference errors here getPrefs(False) - if not scanMedia(): + #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 - elif self.autoStart and need_job_code: - if job_code == None: - self.waitingForJobCode = True - display_queue.put((self.parentApp.getJobCode, ())) - self.running = False - self.lock.acquire() - self.running = True - self.waitingForJobCode = False - elif not self.autoStart: + 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() @@ -2425,233 +2729,229 @@ class CopyPhotos(Thread): return self.running = True - - 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)) - - #check for presence of backup path or volumes - if self.prefs.backup_images: - can_backup = True - if self.prefs.backup_missing == config.REPORT_ERROR: - e = config.SERIOUS_ERROR - elif self.prefs.backup_missing == config.REPORT_WARNING: - e = config.WARNING - 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: - if self.prefs.backup_missing <> config.IGNORE: - logError(e, _("Backup path does not exist"), - _("The path %s could not be created") % path, - _("No backups can occur") - ) - can_backup = False - - elif self.prefs.backup_missing <> config.IGNORE: - if not len(self.parentApp.backupVolumes): - logError(e, _("Backup device missing"), - _("No backup device was automatically detected"), - _("No backups can occur")) - can_backup = False - - if need_job_code and job_code == None: - sys.stderr.write(str(self.thread_id ) + ": job code should never be None\n") - self.imageRenamePrefsFactory.setJobCode('unknown-job-code') - self.subfolderPrefsFactory.setJobCode('unknown-job-code') - else: - self.imageRenamePrefsFactory.setJobCode(job_code) - self.videoRenamePrefsFactory.setJobCode(job_code) - self.subfolderPrefsFactory.setJobCode(job_code) - self.videoSubfolderPrefsFactory.setJobCode(job_code) - - # Some photos may not have metadata (this - # is unlikely for photos straight out of a - # camera, but it is possible for photos that have been edited). If - # only non-dynamic components make up the rest of an image name - # (e.g. text specified by the user), then relying on metadata will - # likely produce duplicate names. - - needMetaDataToCreateUniqueImageName = self.imageRenamePrefsFactory.needImageMetaDataToCreateUniqueName() - - # subfolder generation also need to be examined, but here the need is - # not so exacting, since subfolders contain photos, and naturally the - # requirement to be unique is far more relaxed. However if subfolder - # generation relies entirely on metadata, that is a problem worth - # looking for - needMetaDataToCreateUniqueSubfolderName = self.subfolderPrefsFactory.needMetaDataToCreateUniqueName() - - i = 0 - sizeDownloaded = noFilesDownloaded = noImagesDownloaded = noVideosDownloaded = noImagesSkipped = noVideosSkipped = 0 - filesDownloadedSuccessfully = [] - - sizeFiles = self.cardMedia.sizeOfImagesAndVideos(humanReadable = False) - display_queue.put((self.parentApp.addToTotalDownloadSize, (sizeFiles, ))) - display_queue.put((self.parentApp.setOverallDownloadMark, ())) - display_queue.put((self.parentApp.postStartDownloadTasks, ())) - - sizeFiles = float(sizeFiles) - noFiles = self.cardMedia.numberOfImagesAndVideos() - - if self.noImages > 0: - photoBaseDownloadDir = self.prefs.download_folder - if not checkDownloadPath(photoBaseDownloadDir): - return - photoTempWorkingDir = createTempDir(photoBaseDownloadDir) - if not photoTempWorkingDir: - return - else: - photoBaseDownloadDir = photoTempWorkingDir = None - if DOWNLOAD_VIDEO and self.noVideos > 0: - videoBaseDownloadDir = self.prefs.video_download_folder - if not checkDownloadPath(videoBaseDownloadDir): + if not createBothTempDirs(): return - videoTempWorkingDir = createTempDir(videoBaseDownloadDir) - if not videoTempWorkingDir: - return + else: - videoBaseDownloadDir = videoTempWorkingDir = None - - 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)) - + 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() - while i < noFiles: - if not self.running: + 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 + + while not all_files_downloaded: - if not self.ctrl: - self.running = False - cleanUp() - display_queue.close("rw") - return + self.noErrors = self.noWarnings = 0 - # get information about the image to deduce image name and path - name, root, size, modificationTime = self.cardMedia.imagesAndVideos[i] - fullFileName = os.path.join(root, name) - - self.isImage = media.isImage(name) - if self.isImage: - fileBeingDownloadedDisplay = _('photo') - fileBeingDownloadedDisplayCap = _('Photo') - fileSkippedDisplay = _("Photo skipped") - fileAlreadyExistsDisplay = _("Photo already exists") - fallback_date = None - tempWorkingDir = photoTempWorkingDir - baseDownloadDir = photoBaseDownloadDir - else: - fileBeingDownloadedDisplay = _('video') - fileBeingDownloadedDisplayCap = _('Video') - fileSkippedDisplay = _("Video skipped") - fileAlreadyExistsDisplay = _("Video already exists") - fallback_date = modificationTime - tempWorkingDir = videoTempWorkingDir - baseDownloadDir = videoBaseDownloadDir - - skipFile, fileMetadata, newName, newFile, path, subfolder, sequence_to_use = generateSubfolderAndFileName( - fullFileName, name, needMetaDataToCreateUniqueImageName, - needMetaDataToCreateUniqueSubfolderName, fallback_date) + 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() - if skipFile: - if self.isImage: - noImagesSkipped += 1 - else: - noVideosSkipped += 1 - else: - fileDownloaded, newName, newFile = downloadFile(path, newFile, newName, name, fullFileName, - fileMetadata, subfolder, sequence_to_use, fallback_date) + # 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) - if self.prefs.backup_images: - if can_backup: - backed_up = backupFile(subfolder, newName, fileDownloaded, newFile, fullFileName) + 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: - backed_up = False + tempWorkingDir = self.videoTempWorkingDir + baseDownloadDir = videoBaseDownloadDir + + skipFile, sequence_to_use = generateSubfolderAndFileName(mediaFile) - if fileDownloaded: - noFilesDownloaded += 1 - if self.isImage: - noImagesDownloaded += 1 + if skipFile: + if mediaFile.isImage: + noImagesSkipped += 1 + else: + noVideosSkipped += 1 else: - noVideosDownloaded += 1 - if self.prefs.backup_images and backed_up: - filesDownloadedSuccessfully.append(fullFileName) - elif not self.prefs.backup_images: - filesDownloadedSuccessfully.append(fullFileName) + 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: - if self.isImage: - noImagesSkipped += 1 - else: - noVideosSkipped += 1 + progressBarText = _("%(number)s of %(total)s %(filetypes)s") % {'number': i + 1, 'total': noFiles, 'filetypes':self.display_file_types} - thumbnail, orientation = getThumbnail(fileMetadata) - - display_queue.put((thumbnail_hbox.addImage, (self.thread_id, thumbnail, orientation, fullFileName, fileDownloaded, self.isImage))) + 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 - sizeDownloaded += size - percentComplete = (sizeDownloaded / sizeFiles) * 100 - if sizeDownloaded == sizeFiles: - self.downloadComplete = True - progressBarText = _("%(number)s of %(total)s %(filetypes)s") % {'number': i + 1, 'total': noFiles, 'filetypes':self.display_file_types} - display_queue.put((media_collection_treeview.updateProgress, (self.thread_id, percentComplete, progressBarText, size))) + 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,())) - i += 1 + 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 - with self.statsLock: - self.downloadStats.adjust(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}) + self.running = False + display_queue.close("rw") + return + self.running = True + if not createBothTempDirs(): + return - # must manually delete these variables, or else the media cannot be unmounted (bug in some versions of pyexiv2 / exiv2) - del self.subfolderPrefsFactory, self.imageRenamePrefsFactory - try: - del fileMetadata - except: - pass - - notifyAndUnmount() - cmd_line(_("Download complete from %s") % self.cardMedia.prettyName(limit=0)) - display_queue.put((self.parentApp.notifyUserAllDownloadsComplete,())) - display_queue.put((self.parentApp.resetSequences,())) - cleanUp() 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: @@ -2663,6 +2963,21 @@ class CopyPhotos(Thread): 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 @@ -2675,6 +2990,9 @@ class CopyPhotos(Thread): Completed (not alive, nothing to do) """ + # cleanup any temporary directories and files + self.cleanUp() + if self.hasStarted: if self.isAlive(): self.ctrl = False @@ -2697,15 +3015,15 @@ class CopyPhotos(Thread): class MediaTreeView(gtk.TreeView): """ - TreeView display of memory cards and associated copying progress. + TreeView display of devices and associated copying progress. Assumes a threaded environment. """ def __init__(self, parentApp): self.parentApp = parentApp - # card name, size of images, number of images, copy progress, copy text - self.liststore = gtk.ListStore(str, str, int, float, str) + # 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) @@ -2725,16 +3043,14 @@ class MediaTreeView(gtk.TreeView): 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 addCard(self, thread_id, cardName, sizeFiles, noFiles, progress = 0.0, - progressBarText = ''): + def addCard(self, thread_id, cardName, sizeFiles, progress = 0.0, progressBarText = ''): # add the row, and get a temporary pointer to the row - iter = self.liststore.append((cardName, sizeFiles, noFiles, - progress, progressBarText)) + iter = self.liststore.append((cardName, sizeFiles, progress, progressBarText)) self._setThreadMap(thread_id, iter) @@ -2747,11 +3063,13 @@ class MediaTreeView(gtk.TreeView): self.parentApp.media_collection_scrolledwindow.set_size_request(-1, height) - def updateCard(self, thread_id, sizeFiles, noFiles): + def updateCard(self, thread_id, totalSizeFiles): + """ + Updates the size of the photos and videos on the device, displayed to the user + """ if thread_id in self.mapThreadToRow: iter = self._getThreadMap(thread_id) - self.liststore.set_value(iter, 1, sizeFiles) - self.liststore.set_value(iter, 2, noFiles) + self.liststore.set_value(iter, 1, totalSizeFiles) else: sys.stderr.write("FIXME: this card is unknown") @@ -2777,19 +3095,23 @@ class MediaTreeView(gtk.TreeView): return the tree iter for this thread """ - treerowRef = self.mapThreadToRow[thread_id] - path = treerowRef.get_path() - iter = self.liststore.get_iter(path) - return iter + 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 updateProgress(self, thread_id, percentComplete, progressBarText, imageSize): + def updateProgress(self, thread_id, percentComplete, progressBarText, bytesDownloaded): iter = self._getThreadMap(thread_id) - - self.liststore.set_value(iter, 3, percentComplete) - self.liststore.set_value(iter, 4, progressBarText) - if percentComplete or imageSize: - self.parentApp.updateOverallProgress(thread_id, imageSize, percentComplete) + if iter: + 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): @@ -2799,90 +3121,69 @@ class MediaTreeView(gtk.TreeView): index = self.mapThreadToRow.keys()[0] path = self.mapThreadToRow[index].get_path() col = self.get_column(0) - return self.get_background_area(path, col)[3] + return self.get_background_area(path, col)[3] + 1 -class ThumbnailHBox(gtk.HBox): - """ - Displays thumbnails of the images being downloaded - """ - - def __init__(self, parentApp): - gtk.HBox.__init__(self) - self.parentApp = parentApp - self.padding = hd.CONTROL_IN_TABLE_SPACE / 2 - #create image used to lighten thumbnails - self.white = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, False, 8, width=MAX_THUMBNAIL_SIZE, height=MAX_THUMBNAIL_SIZE) - #fill with white - self.white.fill(0xffffffff) - - #load missing image - self.missingThumbnail = gtk.gdk.pixbuf_new_from_file_at_size(paths.share_dir('glade3/image-missing.svg'), MAX_THUMBNAIL_SIZE, MAX_THUMBNAIL_SIZE) - self.videoThumbnail = gtk.gdk.pixbuf_new_from_file_at_size(paths.share_dir('glade3/video.svg'), MAX_THUMBNAIL_SIZE, MAX_THUMBNAIL_SIZE) +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 - def addImage(self, thread_id, thumbnail, orientation, filename, fileDownloaded, isImage): - """ - Add thumbnail + 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.") - Orientation indicates if the thumbnail needs to be rotated or not. - """ + self.set_icon_from_file(paths.share_dir('glade3/rapid-photo-downloader.svg')) + + primary_label = gtk.Label() + primary_label.set_markup("%s" % primary_msg) + primary_label.set_line_wrap(True) + primary_label.set_alignment(0, 0.5) + + secondary_label = gtk.Label() + secondary_label.set_text(secondary_msg) + secondary_label.set_line_wrap(True) + secondary_label.set_alignment(0, 0.5) + + self.show_again_checkbutton = gtk.CheckButton(_('_Show this message again'), True) + self.show_again_checkbutton.set_active(True) - if isImage: - if not thumbnail: - pixbuf = self.missingThumbnail - else: - try: - pbloader = gdk.PixbufLoader() - pbloader.write(thumbnail) - pbloader.close() - # Get the resulting pixbuf and build an image to be displayed - pixbuf = pbloader.get_pixbuf() - except: - log_dialog.addMessage(thread_id, config.WARNING, - _("Photo thumbnail could not be extracted"), filename, - _('It may be corrupted')) - pbloader = None - pixbuf = self.missingThumbnail - else: - # the file downloaded is a video, not a photo or image - # if thumbnail is passed in, it is already in pixbuf format - if thumbnail: - pixbuf = thumbnail - else: - pixbuf = self.videoThumbnail - - if not pixbuf: - # get_pixbuf() can return None if not could not render the image - log_dialog.addMessage(thread_id, config.WARNING, - _("Photo thumbnail could not be extracted"), filename, - _('It may be corrupted')) - pixbuf = self.missingThumbnail - else: - # rotate if necessary - if orientation == 8: - pixbuf = pixbuf.rotate_simple(gdk.PIXBUF_ROTATE_COUNTERCLOCKWISE) - elif orientation == 6: - pixbuf = pixbuf.rotate_simple(gdk.PIXBUF_ROTATE_CLOCKWISE) - elif orientation == 3: - pixbuf = pixbuf.rotate_simple(gdk.PIXBUF_ROTATE_UPSIDEDOWN) - - # scale to size - pixbuf = common.scale2pixbuf(MAX_THUMBNAIL_SIZE, MAX_THUMBNAIL_SIZE, pixbuf) - if not fileDownloaded: - # lighten it - self.white.composite(pixbuf, 0, 0, pixbuf.props.width, pixbuf.props.height, 0, 0, 1.0, 1.0, gtk.gdk.INTERP_HYPER, 180) + 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(pixbuf) + 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) + + self.set_default_response(gtk.RESPONSE_OK) + + self.set_transient_for(parent_window) + self.show_all() - self.pack_start(image, expand=False, padding=self.padding) - image.show() + self.connect('response', self.on_response) - # move viewport to display the latest image - adjustment = self.parentApp.image_scrolledwindow.get_hadjustment() - adjustment.set_value(adjustment.upper) + def on_response(self, device_dialog, response): + show_again = self.show_again_checkbutton.get_active() + self.postChoiceCB(self, show_again) - class UseDeviceDialog(gtk.Dialog): def __init__(self, parent_window, path, volume, autostart, postChoiceCB): gtk.Dialog.__init__(self, _('Device Detected'), None, @@ -2892,7 +3193,7 @@ class UseDeviceDialog(gtk.Dialog): self.postChoiceCB = postChoiceCB - self.set_icon_from_file(paths.share_dir('glade3/rapid-photo-downloader-about.png')) + 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) @@ -2972,7 +3273,7 @@ class RemoveAllJobCodeDialog(gtk.Dialog): gtk.STOCK_YES, gtk.RESPONSE_OK)) self.postChoiceCB = postChoiceCB - self.set_icon_from_file(paths.share_dir('glade3/rapid-photo-downloader-about.png')) + self.set_icon_from_file(paths.share_dir('glade3/rapid-photo-downloader.svg')) prompt_hbox = gtk.HBox() @@ -2999,96 +3300,1246 @@ class RemoveAllJobCodeDialog(gtk.Dialog): self.show_all() - self.connect('response', self.on_response) + self.connect('response', self.on_response) + + def on_response(self, device_dialog, response): + userSelected = response == gtk.RESPONSE_OK + self.postChoiceCB(self, userSelected) + + +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.set_icon_from_file(paths.share_dir('glade3/rapid-photo-downloader.svg')) + self.postJobCodeEntryCB = postJobCodeEntryCB + self.autoStart = autoStart + self.downloadSelected = downloadSelected + + 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) + + 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) + + # make entry box have entry completion + self.entry = self.combobox.child + + 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) + + # 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) + + self.vbox.pack_start(task_hbox, False, False, padding = 6) + self.vbox.pack_start(self.job_code_hbox, False, False, padding=12) + + 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() + + 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.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() + + # 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 + + # icons to be displayed in status column + + 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 + self.generic_photo_with_shadow = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/photo_small_shadow.png')) + self.generic_video_with_shadow = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/video_small_shadow.png')) + + if DROP_SHADOW: + self.iconDropShadow = DropShadow(offset=(3,3), shadow = (0x34, 0x34, 0x34, 0xff), border=6) + self.previewDropShadow = DropShadow(shadow = (0x44, 0x44, 0x44, 0xff), trim_border = True) + + self.previewed_file_treerowref = None + self.icontheme = gtk.icon_theme_get_default() + + + + 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 + """ + if status == STATUS_WARNING: + status_icon = self.warning_icon + elif status == STATUS_CANNOT_DOWNLOAD: + status_icon = self.error_icon + elif status == STATUS_DOWNLOADED: + status_icon = self.downloaded_icon + elif status == STATUS_NOT_DOWNLOADED: + 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]: + status_icon = self.downloaded_with_error_icon + elif status == STATUS_DOWNLOAD_PENDING: + status_icon = self.download_pending_icon + else: + sys.stderr.write("FIXME: unknown status: %s\n" % status) + status_icon = self.not_downloaded_icon + 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 + + If selected_only is True, then only those from the selected + rows will be returned. + + 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, mediaFile): + if mediaFile.metadata: + date = mediaFile.dateTime() + timestamp = mediaFile.metadata.timeStamp(missing=None) + if timestamp is None: + timestamp = mediaFile.modificationTime + else: + timestamp = mediaFile.modificationTime + date = datetime.datetime.fromtimestamp(timestamp) + + timestamp = int(timestamp) + + date_human_readable = date_time_human_readable(date) + name = mediaFile.name + size = mediaFile.size + thumbnail = mediaFile.thumbnail + thumbnail_icon = common.scale2pixbuf(60, 36, mediaFile.thumbnail) + if DROP_SHADOW: + if not mediaFile.genericThumbnail: + pil_image = pixbuf_to_image(thumbnail_icon) + pil_image = self.iconDropShadow.dropShadow(pil_image) + thumbnail_icon = image_to_pixbuf(pil_image) + else: + if mediaFile.isImage: + thumbnail_icon = self.generic_photo_with_shadow + else: + thumbnail_icon = self.generic_video_with_shadow + + if mediaFile.isImage: + type_icon = self.icon_photo + else: + type_icon = self.icon_video + + status_icon = self.get_status_icon(mediaFile.status) + + 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) + + 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 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 + """ + 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 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) + + #update button text + no_available_for_download, threads = self.no_selected_rows_available_for_download() + + 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: + #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 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() + 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): + self.imageRenamePrefsFactory = rn.ImageRenamePreferences(self.rapidApp.prefs.image_rename, self, + self.rapidApp.fileSequenceLock, sequences) + self.videoRenamePrefsFactory = rn.VideoRenamePreferences(self.rapidApp.prefs.video_rename, self, + self.rapidApp.fileSequenceLock, sequences) + self.subfolderPrefsFactory = rn.SubfolderPreferences(self.rapidApp.prefs.subfolder, self) + self.videoSubfolderPrefsFactory = rn.VideoSubfolderPreferences(self.rapidApp.prefs.video_subfolder, self) + 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) + + 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: + 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_preview(self, iter): + + def status_human_readable(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 + + + 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 + + + elif not self.suspend_previews: + mediaFile = self.get_mediaFile(iter) + + self.previewed_file_treerowref = mediaFile.treerowref + + thumbnail = mediaFile.thumbnail + + if DROP_SHADOW and not mediaFile.genericThumbnail: + pil_image = pixbuf_to_image(thumbnail) + pil_image = self.previewDropShadow.dropShadow(pil_image) + thumbnail = image_to_pixbuf(pil_image) + + self.parentApp.preview_image.set_from_pixbuf(thumbnail) + + 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) + + 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 = 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('' + status_text + '') + 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('' + problem_title + '') + 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 + + 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: + selection.unselect_iter(iter) + iter = self.liststore.iter_next(iter) + + 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): + """ + if display is true, the column will be shown + otherwise, it will not be shown + """ + self.filename_column.set_visible(display) + + 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): + """ + 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. + """ + + 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: + 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 + + 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): + """ + 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. + """ + + 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: + 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 _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): + """ + 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) + + Returns a list of threads which can be downloaded + """ + threads = [] + + 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 this row is currently previewed, then should update the preview + if mediaFile.treerowref == self.previewed_file_treerowref: + self.show_preview(iter) + + +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. + """ + + gtk.VBox.__init__(self) + self.parentApp = parentApp + + selection_scrolledwindow = gtk.ScrolledWindow() + selection_scrolledwindow.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + selection_viewport = gtk.Viewport() - def on_response(self, device_dialog, response): - userSelected = response == gtk.RESPONSE_OK - self.postChoiceCB(self, userSelected) + self.selection_treeview = SelectionTreeView(self) -class JobCodeDialog(gtk.Dialog): - """ Dialog prompting for a job code""" - - def __init__(self, parent_window, job_codes, default_job_code, postJobCodeEntryCB, autoStart, 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)) - + 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) - self.set_icon_from_file(paths.share_dir('glade3/rapid-photo-downloader-about.png')) - self.postJobCodeEntryCB = postJobCodeEntryCB - self.autoStart = autoStart - 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) + #Preview pane - 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.')) + #Preview title + self.preview_title_label = gtk.Label() + self.preview_title_label.set_markup("%s" % _("Preview")) + self.preview_title_label.set_alignment(0, 0.5) + self.preview_title_label.set_padding(12, 0) + + #Preview image + self.preview_image = gtk.Image() + self.preview_image.set_alignment(0, 0.5) + #leave room for thumbnail shadow + if DROP_SHADOW: + shadow_size = 21 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) + shadow_size = 0 + self.preview_image.set_size_request(MAX_THUMBNAIL_SIZE + shadow_size, MAX_THUMBNAIL_SIZE + shadow_size) + + #labels to display file information + + #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) + + #Device (where it will be downloaded to) + 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() + + 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) + + 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) + + self.preview_device_expander.set_label_widget(device_hbox) + + #Filename that has been generated (path in tooltip) + self.preview_name_label = gtk.Label() + self.preview_name_label.set_alignment(0, 0.5) + self.preview_name_label.set_ellipsize(pango.ELLIPSIZE_END) + + #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) + + 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) + + self.preview_destination_expander.set_label_widget(destination_hbox) + + + #Status of the file + + 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) + + #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) - self.set_border_width(6) - self.set_has_separator(False) - # make entry box have entry completion - self.entry = self.combobox.child + spacer2 = gtk.Label('') - 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) + 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) + + self.preview_table.attach(self.preview_title_label, 0, 3, 0, 1, yoptions=gtk.SHRINK) + self.preview_table.attach(self.preview_image, 1, 3, 1, 2, yoptions=gtk.SHRINK) - # when user hits enter, close the dialog window - self.set_default_response(gtk.RESPONSE_OK) - self.entry.set_activates_default(True) + self.preview_table.attach(self.preview_original_name_label, 1, 3, 2, 3, xoptions=gtk.EXPAND|gtk.FILL, yoptions=gtk.SHRINK) + self.preview_table.attach(self.preview_device_expander, 1, 3, 3, 4, xoptions=gtk.EXPAND|gtk.FILL, yoptions=gtk.SHRINK) + + self.preview_table.attach(self.preview_name_label, 1, 3, 5, 6, xoptions=gtk.EXPAND|gtk.FILL, yoptions=gtk.SHRINK) + self.preview_table.attach(self.preview_destination_expander, 1, 3, 6, 7, xoptions=gtk.EXPAND|gtk.FILL, yoptions=gtk.SHRINK) - if default_job_code: - self.entry.set_text(default_job_code) + self.preview_table.attach(spacer2, 0, 7, 7, 8, yoptions=gtk.SHRINK) - self.vbox.pack_start(task_hbox, False, False, padding = 6) - self.vbox.pack_start(self.job_code_hbox, False, False, padding=12) + self.preview_table.attach(self.preview_status_icon, 1, 2, 8, 9, xoptions=gtk.SHRINK, yoptions=gtk.SHRINK) + self.preview_table.attach(self.preview_status_label, 2, 3, 8, 9, yoptions=gtk.SHRINK) - self.set_transient_for(parent_window) + self.preview_table.attach(self.preview_problem_title_label, 2, 3, 9, 10, yoptions=gtk.SHRINK) + self.preview_table.attach(self.preview_problem_label, 2, 4, 10, 11, xoptions=gtk.EXPAND|gtk.FILL, yoptions=gtk.EXPAND|gtk.FILL) + + self.file_hpaned = gtk.HPaned() + self.file_hpaned.pack1(left_pane_vbox, shrink=False) + #self.file_hpaned.pack2(self.preview_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) + 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].startswith(self.entry.get_text()) - - def on_completion_match(self, completion, model, iter): - self.entry.set_text(model[iter][0]) - self.entry.set_position(-1) + + + 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() - def get_job_code(self): - return self.combobox.child.get_text() + else: + self.preview_destination_expander.hide() + self.preview_device_expander.hide() + + def set_job_code_display(self): + """ + Shows or hides the job code entry - def on_job_code_resp(self, jc_dialog, response): - userChoseCode = False - if response == gtk.RESPONSE_OK: - userChoseCode = True - cmd_line(_("Job Code entered")) + If user is not using job codes in their file or subfolder names + then do not prompt for it + """ + + 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: - cmd_line(_("Job Code not entered")) - self.postJobCodeEntryCB(self, userChoseCode, self.get_job_code(), self.autoStart) + 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 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('') + + + + + 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 + """ + + # 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.job_code_chosen(widget.get_text()) + + 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 LogDialog(gnomeglade.Component): """ @@ -3167,8 +4618,7 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): gladefile = paths.share_dir(config.GLADE_FILE) gnomeglade.GnomeApp.__init__(self, "rapid", __version__, gladefile, "rapidapp") - - + # notifications self.displayDownloadSummaryNotification = False self.initPyNotify() @@ -3182,8 +4632,25 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): # sys.exit(0) + # 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) + self.widget.show() + self._setupIcons() + + # 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) + self.checkIfFirstTimeProgramEverRun() displayPreferences = self.checkForUpgrade(__version__) @@ -3202,8 +4669,7 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): displayPreferences = not self.checkPreferencesOnStartup() # display download information using threads - global media_collection_treeview, thumbnail_hbox, log_dialog - global download_queue, image_queue, log_queue + global media_collection_treeview, log_dialog global workers #track files that should have a suffix added to them @@ -3216,13 +4682,13 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): global sequences # whether we need to prompt for a job code - global need_job_code + global need_job_code_for_renaming duplicate_files = {} downloaded_files = DownloadedFiles() downloadsToday = self.prefs.getAndMaybeResetDownloadsToday() - sequences = rn.Sequences(downloadsToday, self.prefs.stored_sequence_no) + sequences = rn.Sequences(downloadsToday, self.prefs.stored_sequence_no) self.downloadStats = DownloadStats() @@ -3247,7 +4713,21 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): # flag to indicate whether the user changed some preferences that # indicate the image and backup devices should be setup again - self.rerunSetupAvailableImageAndBackupMedia = False + self.rerunSetupAvailableImageAndVideoMedia = False + self.rerunSetupAvailableBackupMedia = False + + # flag to indicate the user changes some preferences and the display + # of sample names and subfolders needs to be refreshed + self.refreshGeneratedSampleSubfolderAndName = False + + # 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 + + # 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 # flag to indicate that the preferences dialog window is being # displayed to the user @@ -3258,15 +4738,16 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): self.media_collection_vbox.pack_start(media_collection_treeview) - #thumbnail display - thumbnail_hbox = ThumbnailHBox(self) - self.image_viewport.add(thumbnail_hbox) - self.image_viewport.modify_bg(gtk.STATE_NORMAL, gdk.color_parse("white")) - self.set_display_thumbnails(self.prefs.display_thumbnails) + #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) self.backupVolumes = {} - self._setupDownloadbutton() + #Help button and download buttons + self._setupDownloadbuttons() #status bar progress bar self.download_progressbar = gtk.ProgressBar() @@ -3278,11 +4759,28 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): # menus - self.menu_display_thumbnails.set_active(self.prefs.display_thumbnails) + #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: + 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) + + 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 - need_job_code = self.needJobCode() self.last_chosen_job_code = None self.prompting_for_job_code = False @@ -3300,8 +4798,12 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): #adjust viewport size for displaying media #this is important because the code in MediaTreeView.addCard() is inaccurate at program startup - height = self.media_collection_viewport.size_request()[1] - self.media_collection_scrolledwindow.set_size_request(-1, height) + 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) self.download_button.grab_default() # for some reason, the grab focus command is not working... unsure why @@ -3342,7 +4844,17 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): 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 checkImageDevicePathOnStartup(self): msg = None if not os.path.isdir(self.prefs.device_location): @@ -3403,7 +4915,7 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): sys.stderr.write(msg +'\n') return prefsOk - def needJobCode(self): + 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): @@ -3429,6 +4941,15 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): self.prefs.job_codes = [code] + jcs + 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 """ @@ -3455,40 +4976,73 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): else: self.prefs.device_blacklist = [path] - def _getJobCode(self, postJobCodeEntryCB, autoStart): + def _getJobCode(self, postJobCodeEntryCB, autoStart, downloadSelected): """ prompt for a job code """ - if not self.prompting_for_job_code: cmd_line(_("Prompting for Job Code")) self.prompting_for_job_code = True - j = JobCodeDialog(self.widget, self.prefs.job_codes, self.last_chosen_job_code, postJobCodeEntryCB, autoStart, False) + j = JobCodeDialog(self.widget, self.prefs.job_codes, self.last_chosen_job_code, postJobCodeEntryCB, autoStart, downloadSelected, False) else: cmd_line(_("Already prompting for Job Code, do not prompt again")) - def getJobCode(self, autoStart=True): - """ called from the copyphotos thread""" + 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) + self._getJobCode(self.gotJobCode, autoStart, downloadSelected) - def gotJobCode(self, dialog, userChoseCode, code, autoStart): + def gotJobCode(self, dialog, userChoseCode, code, autoStart, downloadSelected): dialog.destroy() self.prompting_for_job_code = False if userChoseCode: self.assignJobCode(code) self.last_chosen_job_code = code - if autoStart: + 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: - cmd_line(_("Starting downloads")) - self.startDownload() - - # FIXME: what happens to these workers that are waiting? How will the user start their download? - # check if need to add code to start button + else: + # user cancelled + 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) + + 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) + + 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 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) + + 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) + + 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) def checkIfFirstTimeProgramEverRun(self): """ @@ -3593,23 +5147,27 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): 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.")) - self.notification_icon_size = 48 else: try: - if info['name'] == 'Notification Daemon': - self.notification_icon_size = 128 - else: - self.notification_icon_size = 48 + if info["name"] == 'notify-osd': + do_not_size_icon = True except: - self.notification_icon_size = 48 - - self.application_icon = gtk.gdk.pixbuf_new_from_file_at_size( - paths.share_dir('glade3/rapid-photo-downloader-about.png'), - self.notification_icon_size, self.notification_icon_size) + 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 usingVolumeMonitor(self): @@ -3662,12 +5220,23 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): return self.prefs.device_autodetection_psd and self.prefs.device_autodetection - def isGProxyShadowMount(self, gvfsVolume): + def isGProxyShadowMount(self, gMount): - """ gvfs GProxyShadowMount are used for camera specific things, not the data in the memory card """ + """ 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 + + def isCamera(self, volume): if using_gio: - #FIXME: this is a hack, but what is the correct function? - return str(type(gvfsVolume)).find('GProxyShadowMount') >= 0 + 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: return False @@ -3703,17 +5272,19 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): self.backupVolumes[path] = volume self.rapid_statusbar.push(self.statusbar_context_id, self.displayBackupVolumes()) - elif media.is_DCIM_Media(path) or self.searchForPsd(): + 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) + 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, True) + cardMedia = CardMedia(path, volume) i = workers.getNextThread_id() workers.append(CopyPhotos(i, self, self.fileRenameLock, @@ -3748,12 +5319,14 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): 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 @@ -3774,6 +5347,7 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): for w in workers.getFinishedWorkers(): media_collection_treeview.removeCard(w.thread_id) + self.selection_vbox.selection_treeview.clear_all(w.thread_id) @@ -3822,7 +5396,7 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): program's initialization. onPreferenceChange should be True if this is being called as the result of a preference - bring changed + being changed Removes any image media that are currently not downloaded, or finished downloading @@ -3839,10 +5413,10 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): if self.usingVolumeMonitor(): # either using automatically detected backup devices - # or image devices + # or download devices for v in self.volumeMonitor.get_mounts(): - volume = Volume(v) + volume = Volume(v) #'volumes' are actually mounts (legacy variable name at work here) path = volume.get_path(avoid_gnomeVFS_bug = True) if path: @@ -3888,25 +5462,86 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): autoStart = (not onPreferenceChange) and ((self.prefs.auto_download_at_startup and onStartup) or (self.prefs.auto_download_upon_device_insertion and not onStartup)) self._printAutoStart(autoStart) + + 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): + """ + Setup the backup media - def _setupDownloadbutton(self): - + Assumptions: this is being called after the user has changed their preferences AND download media has already been setup + """ + 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 + self.rapid_statusbar.push(self.statusbar_context_id, _('Backing up to %(path)s') % {'path':self.prefs.backup_location}) + 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.rapid_statusbar.push(self.statusbar_context_id, self.displayBackupVolumes()) + + + def _setupDownloadbuttons(self): self.download_hbutton_box = gtk.HButtonBox() + self.download_hbutton_box.set_spacing(12) + self.download_hbutton_box.set_homogeneous(False) + + 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_hbutton_box.set_layout(gtk.BUTTONBOX_START) + 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, @@ -3914,14 +5549,16 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): self.setDownloadButtonSensitivity() - - def set_display_thumbnails(self, value): + def set_display_selection(self, value): if value: - self.image_scrolledwindow.show_all() + self.selection_vbox.preview_table.show_all() else: - self.image_scrolledwindow.hide() - - + 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 @@ -3947,28 +5584,29 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): self.timeMark = self.startTime self.sizeMark = 0 - def startOrResumeWorkers(self): + def startOrResumeWorkers(self, threads): # resume any paused workers for w in workers.getPausedDownloadingWorkers(): w.startStop() self.timeRemaining.setTimeMark(w) - #start any new workers - workers.startDownloadingWorkers() + #start any new workers that have downloads pending + for i in threads: + workers[i].startStop() - if is_beta and verbose: + if is_beta and verbose and False: workers.printWorkerStatus() - def updateOverallProgress(self, thread_id, imageSize, percentComplete): + def updateOverallProgress(self, thread_id, bytesDownloaded, percentComplete): """ Updates progress bar and status bar text with time remaining to download images """ - self.totalDownloadedSoFar += imageSize - self.totalDownloadedSoFarThisRun += imageSize + self.totalDownloadedSoFar += bytesDownloaded + self.totalDownloadedSoFarThisRun += bytesDownloaded fraction = self.totalDownloadedSoFar / float(self.totalDownloadSize) @@ -3985,12 +5623,13 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): self._set_download_button() self.setDownloadButtonSensitivity() cmd_line(_("All downloads complete")) - if is_beta and verbose: + job_code = None + if is_beta and verbose and False: workers.printWorkerStatus() else: now = time.time() - self.timeRemaining.update(thread_id, imageSize) + self.timeRemaining.update(thread_id, bytesDownloaded) if now > (self.downloadTimeGap + self.timeMark): amtTime = now - self.timeMark @@ -4035,22 +5674,43 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): if self.displayDownloadSummaryNotification: message = _("All downloads complete") if self.downloadStats.noImagesDownloaded: - message += "\n%s " % self.downloadStats.noImagesDownloaded + _("photos downloaded") + 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: - message = "%s\n%s " % (message, self.downloadStats.noImagesSkipped) + _("photos skipped") + 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: - message += "\n%s " % self.downloadStats.noVideosDownloaded + _("videos downloaded") + 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: - message = "%s\n%s " % (message, self.downloadStats.noVideosSkipped) + _("videos skipped") + 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 = "%s\n%s " % (message, self.downloadStats.noWarnings) + _("warnings") + message += "\n" + _("%(number)s %(numberdownloaded)s") % \ + {'number': self.downloadStats.noWarnings, + 'numberdownloaded': _("warnings")} if self.downloadStats.noErrors: - message = "%s\n%s " % (message, self.downloadStats.noErrors) +_("errors") + 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 - self.downloadStats.clear() + # download statistics are cleared in exitOnDownloadComplete() self._resetDownloadInfo() self.speed_label.set_text(' ') @@ -4060,6 +5720,9 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): 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() + def downloadFailed(self, thread_id): if workers.noDownloadingWorkers() == 0: @@ -4072,13 +5735,19 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): def setDownloadButtonSensitivity(self): - isSensitive = workers.noReadyToDownloadWorkers() > 0 or workers.noDownloadingWorkers() > 0 + isSensitive = (workers.noReadyToDownloadWorkers() > 0 and + workers.noScanningWorkers() == 0 and + self.selection_vbox.selection_treeview.rows_available_for_download()) or \ + workers.noDownloadingWorkers() > 0 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 @@ -4086,6 +5755,15 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): def on_rapidapp_destroy(self, widget): """Called when the application is going to quit""" + + # 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() + + x, y = self.rapidapp.get_size() + self.prefs.main_window_size_x = x + self.prefs.main_window_size_y = y + workers.quitAllWorkers() self.flushevents() @@ -4093,10 +5771,20 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): 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) + def on_menu_refresh_activate(self, widget): + self.selection_vbox.selection_treeview.clear_all() + self.setupAvailableImageAndBackupMedia(onStartup = False, onPreferenceChange = True, doNotAllowAutoStart = True) + def on_menu_report_problem_activate(self, widget): webbrowser.open("https://bugs.launchpad.net/rapid") @@ -4122,8 +5810,30 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): else: log_dialog.widget.hide() - def on_menu_display_thumbnails_toggled(self, check_button): - self.prefs.display_thumbnails = check_button.get_active() + def on_menu_display_selection_toggled(self, check_button): + self.prefs.display_selection = check_button.get_active() + + def on_menu_preview_folders_toggled(self, check_button): + self.prefs.display_preview_folders = check_button.get_active() + + 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 on_menu_select_all_videos_activate(self, widget): + self.selection_vbox.selection_treeview.select_rows('videos') + + def on_menu_select_none_activate(self, widget): + self.selection_vbox.selection_treeview.select_rows('none') + + 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 """ @@ -4138,20 +5848,35 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): """ Sets download button to appropriate state """ + if self.download_button_is_download: # This text will be displayed to the user on the Download / Pause button. - # Please note the space at the end of the label - it is needed to meet the Gnome Human Interface Guidelines - self.download_button.set_label(_("_Download ")) + 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() + self.download_button.set_image(gtk.image_new_from_stock( gtk.STOCK_CONVERT, - gtk.ICON_SIZE_BUTTON)) + gtk.ICON_SIZE_BUTTON)) + + if workers.noPausedWorkers(): + self.download_button.set_label(_("_Resume")) + self.download_selected_button.hide() + else: + 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_button.set_label(_("_Pause")) + self.download_selected_button.set_sensitive(False) + self.download_selected_button.hide() def on_menu_download_pause_activate(self, widget): self.on_download_button_clicked(widget) @@ -4160,8 +5885,6 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): if workers.noReadyToStartWorkers() > 0: workers.startWorkers() - - def postStartDownloadTasks(self): if workers.noDownloadingWorkers() > 1: self.displayDownloadSummaryNotification = True @@ -4170,8 +5893,8 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): self.download_button_is_download = False self._set_download_button() - def startDownload(self): - self.startOrResumeWorkers() + def startDownload(self, threads): + self.startOrResumeWorkers(threads) self.postStartDownloadTasks() def pauseDownload(self): @@ -4186,55 +5909,141 @@ class RapidApp(gnomeglade.GnomeApp, dbus.service.Object): """ Handle download button click. - Button is in one of two states: download, or pause. + Button is in one of three states: download all, resume, or pause. 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 and job_code == None and not self.prompting_for_job_code: - self.getJobCode(autoStart=False) + 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: - self.startDownload() + threads = self.selection_vbox.selection_treeview.set_status_to_download_pending(selected_only = False) + self.startDownload(threads) + self._set_download_button() else: 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 on_help_button_clicked(self, widget): + webbrowser.open("http://www.damonlynch.net/rapid/help.html") def on_preference_changed(self, key, value): """ Called when user changes the program's preferences """ - if key == 'display_thumbnails': - self.set_display_thumbnails(value) + 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', 'backup_images', 'device_location', - 'backup_device_autodetection', 'backup_location' ]: - self.rerunSetupAvailableImageAndBackupMedia = True + 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 - need_job_code = self.needJobCode() + 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): """ Handle changes in program preferences after the preferences dialog window has been closed """ - if self.rerunSetupAvailableImageAndBackupMedia: + if self.rerunSetupAvailableImageAndVideoMedia: if self.usingVolumeMonitor(): self.startVolumeMonitor() - cmd_line("\n" + _("Preferences were changed.")) - - self.setupAvailableImageAndBackupMedia(onStartup = False, onPreferenceChange = True, doNotAllowAutoStart = False) - if is_beta and verbose: - print "Current worker status:" + cmd_line("\n" + _("Download device settings preferences were changed.")) + + self.selection_vbox.selection_treeview.clear_all() + self.setupAvailableImageAndBackupMedia(onStartup = False, onPreferenceChange = True, doNotAllowAutoStart = True) + if is_beta and verbose and False: workers.printWorkerStatus() - self.rerunSetupAvailableImageAndBackupMedia = False + 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 + + 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 regenerateScannedDevices(self, thread_id): + """ + Regenerate the filenames / subfolders / download folders for this thread + + The user must have adjusted their preferences as the device was being scanned + """ + + 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 + + def on_error_eventbox_button_press_event(self, widget, event): self.prefs.show_log_dialog = True @@ -4264,7 +6073,6 @@ class Volume: """ Transistion to gvfs from gnomevfs""" def __init__(self, volume): self.volume = volume - self.using_gio = using_gio def get_name(self, limit=config.MAX_LENGTH_DEVICE_NAME): if using_gio: @@ -4296,25 +6104,7 @@ class Volume: def get_icon_pixbuf(self, size): """ returns icon for the volume, or None if not available""" - icontheme = gtk.icon_theme_get_default() - - if using_gio: - gicon = self.volume.get_icon() - f = None - if isinstance(gicon, gio.ThemedIcon): - try: - # on some user's systems, themes do not have icons associated with them - iconinfo = icontheme.choose_icon(gicon.get_names(), size, gtk.ICON_LOOKUP_USE_BUILTIN) - f = iconinfo.get_filename() - v = gtk.gdk.pixbuf_new_from_file_at_size(f, size, size) - except: - f = None - if not f: - v = icontheme.load_icon('gtk-harddisk', size, gtk.ICON_LOOKUP_USE_BUILTIN) - else: - gicon = self.volume.get_icon() - v = icontheme.load_icon(gicon, size, gtk.ICON_LOOKUP_USE_BUILTIN) - return v + return common.get_icon_pixbuf(using_gio, self.volume.get_icon(), size) def unmount(self, callback): self.volume.unmount(callback) @@ -4323,7 +6113,7 @@ class DownloadStats: def __init__(self): self.clear() - def adjust(self, size, noImagesDownloaded, noVideosDownloaded, noImagesSkipped, noVideosSkipped, noWarnings, noErrors): + def adjust(self, size, noImagesDownloaded, noVideosDownloaded, noImagesSkipped, noVideosSkipped, noWarnings, noErrors): self.downloadSize += size self.noImagesDownloaded += noImagesDownloaded self.noVideosDownloaded += noVideosDownloaded @@ -4381,15 +6171,14 @@ class TimeRemaining: def __init__(self): self.clear() - def add(self, w, size): - if w not in self.times: - t = TimeForDownload() - t.timeRemaining = None - t.size = size - t.downloaded = 0 - t.sizeMark = 0 - t.timeMark = time.time() - self.times[w] = t + 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: @@ -4401,9 +6190,10 @@ class TimeRemaining: self.times[w].timeMark = now amtDownloaded = self.times[w].downloaded - self.times[w].sizeMark self.times[w].sizeMark = self.times[w].downloaded - timefraction = amtDownloaded / amtTime + timefraction = amtDownloaded / float(amtTime) amtToDownload = float(self.times[w].size) - self.times[w].downloaded - self.times[w].timeRemaining = amtToDownload / timefraction + if timefraction: + self.times[w].timeRemaining = amtToDownload / timefraction def _timeEstimates(self): for t in self.times: diff --git a/rapid/renamesubfolderprefs.py b/rapid/renamesubfolderprefs.py index feb7366..727eef0 100644 --- a/rapid/renamesubfolderprefs.py +++ b/rapid/renamesubfolderprefs.py @@ -68,6 +68,8 @@ import ValidatedEntry import config from common import pythonifyVersion +import problemnotification as pn + # Special key in each dictionary which specifies the order of elements. # It is very important to have a consistent and rational order when displaying @@ -649,7 +651,7 @@ def upgradePreferencesToCurrent(imageRenamePrefs, subfolderPrefs, previousVersio def usesJobCode(prefs): """ Returns True if the preferences contain a job code, else returns False""" - for i in range(0, len(prefs), 3): + for i in range(0, len(prefs), 3): if prefs[i] == JOB_CODE: return True return False @@ -834,7 +836,7 @@ class Comboi18n(gtk.ComboBox): def append_text(self, text): model = self.get_model() - model.append((text, _(text))) + model.append((text, _(text))) def get_active_text(self): model = self.get_model() @@ -870,8 +872,23 @@ class ImageRenamePreferences: self.defaultRow = self.defaultPrefs self.stripForwardSlash = True self.L1DateCheck = IMAGE_DATE #used in _getDateComponent() + self.component = pn.FILENAME_COMPONENT + def initializeProblem(self, problem): + """ + Set the problem tracker used in name generation + """ + self.problem = problem + + def getProblems(self): + """ + Returns Problem class if there were problems, else returns None. + """ + if self.problem.has_problem(): + return self.problem + else: + return None def checkPrefsForValidity(self): """ @@ -905,40 +922,24 @@ class ImageRenamePreferences: self.job_code = job_code - def _fixMangledDateTime(self, d): - """ Some EXIF dates are badly formed. Try to fix them """ - - _datetime = d.strip() - # remove any weird characters at the end of the string - while _datetime and not _datetime[-1].isdigit(): - _datetime = _datetime[:-1] - _date, _time = _datetime.split(' ') - _datetime = "%s %s" % (_date.replace(":", "-") , _time.replace("-", ":")) - try: - d = datetime.datetime.strptime(_datetime, '%Y-%m-%d %H:%M:%S') - except: - d = None - return d - + def _getDateComponent(self): """ Returns portion of new image / subfolder name based on date time. If the date is missing, will attempt to use the fallback date. """ - problem = None - + # step 1: get the correct value from metadata if self.L1 == self.L1DateCheck: if self.L2 == SUBSECONDS: - d = self.photo.subSeconds() - problem = _("Subsecond metadata not present in photo") + d = self.metadata.subSeconds() if d == '00': - return ('', problem) + self.problem.add_problem(self.component, pn.MISSING_METADATA, _(self.L2)) + return '' else: - return (d, None) + return d else: - d = self.photo.dateTime(missing=None) - problem = _("%s metadata is not present") % self.L1.lower() + d = self.metadata.dateTime(missing=None) elif self.L1 == TODAY: d = datetime.datetime.now() @@ -948,34 +949,23 @@ class ImageRenamePreferences: else: raise("Date options invalid") - if d: - # if format is not the standard floating point representation - # of a date time, there is a problem - if type(d) == type('string'): - # will be a string only if the date time could not be converted in the datetime type - # try to massage badly formed date / times into a valid value - d = self._fixMangledDateTime(d) - if d is None: - v = '' - problem = _('Error in date time component. Value %s appears invalid') % '' - return (v, problem) - else: + # step 2: handle a missing value + if not d: if self.fallback_date: try: d = datetime.datetime.fromtimestamp(self.fallback_date) except: - v = '' - problem = _('Error in date time component. Value %s appears invalid') % '' - return (v, problem) + self.problem.add_problem(self.component, pn.INVALID_DATE_TIME, '') + return '' else: - return ('', problem) + self.problem.add_problem(self.component, pn.MISSING_METADATA, _(self.L1)) + return '' try: - return (d.strftime(convertDateForStrftime(self.L2)), None) + return d.strftime(convertDateForStrftime(self.L2)) except: - v = '' - problem = _('Error in date time component. Value %s appears invalid') % d - return (v, problem) + self.problem.add_problem(self.component, pn.INVALID_DATE_TIME, d) + return '' def _getFilenameComponent(self): """ @@ -983,7 +973,6 @@ class ImageRenamePreferences: """ name, extension = os.path.splitext(self.existingFilename) - problem = None if self.L1 == NAME_EXTENSION: filename = self.existingFilename @@ -1000,12 +989,13 @@ class ImageRenamePreferences: # is a bad idea! filename = extension[1:] else: - filename = "" - problem = _("extension was specified but filename does not have an extension") + self.problem.add_problem(self.component, pn.MISSING_FILE_EXTENSION) + return "" elif self.L1 == IMAGE_NUMBER or self.L1 == VIDEO_NUMBER: n = re.search("(?P[0-9]+$)", name) if not n: - problem = _("image or video number was specified but filename has no number") + self.problem.add_problem(self.component, pn.MISSING_IMAGE_NUMBER) + return '' else: image_number = n.group("image_number") @@ -1027,7 +1017,7 @@ class ImageRenamePreferences: elif self.L2 == LOWERCASE: filename = filename.lower() - return (filename, problem) + return filename def _getMetadataComponent(self): """ @@ -1036,27 +1026,26 @@ class ImageRenamePreferences: Note: date time metadata found in _getDateComponent() """ - problem = None if self.L1 == APERTURE: - v = self.photo.aperture() + v = self.metadata.aperture() elif self.L1 == ISO: - v = self.photo.iso() + v = self.metadata.iso() elif self.L1 == EXPOSURE_TIME: - v = self.photo.exposureTime(alternativeFormat=True) + v = self.metadata.exposureTime(alternativeFormat=True) elif self.L1 == FOCAL_LENGTH: - v = self.photo.focalLength() + v = self.metadata.focalLength() elif self.L1 == CAMERA_MAKE: - v = self.photo.cameraMake() + v = self.metadata.cameraMake() elif self.L1 == CAMERA_MODEL: - v = self.photo.cameraModel() + v = self.metadata.cameraModel() elif self.L1 == SHORT_CAMERA_MODEL: - v = self.photo.shortCameraModel() + v = self.metadata.shortCameraModel() elif self.L1 == SHORT_CAMERA_MODEL_HYPHEN: - v = self.photo.shortCameraModel(includeCharacters = "\-") + v = self.metadata.shortCameraModel(includeCharacters = "\-") elif self.L1 == SERIAL_NUMBER: - v = self.photo.cameraSerial() + v = self.metadata.cameraSerial() elif self.L1 == SHUTTER_COUNT: - v = self.photo.shutterCount() + v = self.metadata.shutterCount() if v: v = int(v) padding = LIST_SHUTTER_COUNT_L2.index(self.L2) + 3 @@ -1064,7 +1053,7 @@ class ImageRenamePreferences: v = formatter % v elif self.L1 == OWNER_NAME: - v = self.photo.ownerName() + v = self.metadata.ownerName() else: raise TypeError("Invalid metadata option specified") if self.L1 in [CAMERA_MAKE, CAMERA_MODEL, SHORT_CAMERA_MODEL, @@ -1074,12 +1063,8 @@ class ImageRenamePreferences: elif self.L2 == LOWERCASE: v = v.lower() if not v: - if self.L1 <> ISO: - md = self.L1.lower() - else: - md = ISO - problem = _("%s metadata is not present in photo") % md - return (v, problem) + self.problem.add_problem(self.component, pn.MISSING_METADATA, _(self.L1)) + return v def _formatSequenceNo(self, value, amountToPad): @@ -1119,7 +1104,6 @@ class ImageRenamePreferences: than one subfolder sequence number in the same file name """ - problem = None self.subfolderSeqNoInstanceInFilename += 1 if self.downloadSubfolder: @@ -1135,33 +1119,23 @@ class ImageRenamePreferences: v = self.sequenceNos.calculate(subfolder) v = self.formatSequenceNo(v, self.L1) - return (v, problem) + return v def _getSessionSequenceNo(self): - problem = None - v = self._formatSequenceNo(self.sequences.getSessionSequenceNoUsingCounter(self.sequenceCounter), self.L2) - return (v, problem) + return self._formatSequenceNo(self.sequences.getSessionSequenceNoUsingCounter(self.sequenceCounter), self.L2) def _getDownloadsTodaySequenceNo(self): - problem = None - v = self._formatSequenceNo(self.sequences.getDownloadsTodayUsingCounter(self.sequenceCounter), self.L2) - - return (v, problem) + return self._formatSequenceNo(self.sequences.getDownloadsTodayUsingCounter(self.sequenceCounter), self.L2) + def _getStoredSequenceNo(self): - problem = None - v = self._formatSequenceNo(self.sequences.getStoredSequenceNoUsingCounter(self.sequenceCounter), self.L2) - - return (v, problem) + return self._formatSequenceNo(self.sequences.getStoredSequenceNoUsingCounter(self.sequenceCounter), self.L2) def _getSequenceLetter(self): + return self._calculateLetterSequence(self.sequences.getSequenceLetterUsingCounter(self.sequenceCounter)) - problem = None - v = self._calculateLetterSequence(self.sequences.getSequenceLetterUsingCounter(self.sequenceCounter)) - return (v, problem) def _getSequencesComponent(self): - problem = None if self.L1 == DOWNLOAD_SEQ_NUMBER: return self._getDownloadsTodaySequenceNo() elif self.L1 == SESSION_SEQ_NUMBER: @@ -1178,7 +1152,7 @@ class ImageRenamePreferences: if self.L0 == DATE_TIME: return self._getDateComponent() elif self.L0 == TEXT: - return (self.L1, None) + return self.L1 elif self.L0 == FILENAME: return self._getFilenameComponent() elif self.L0 == METADATA: @@ -1186,27 +1160,25 @@ class ImageRenamePreferences: elif self.L0 == SEQUENCES: return self._getSequencesComponent() elif self.L0 == JOB_CODE: - return (self.job_code, None) + return self.job_code elif self.L0 == SEPARATOR: - return (os.sep, None) + return os.sep except: - v = "" - problem = _("error generating name with component %s") % self.L0 - return (v, problem) - + self.problem.add_problem(self.component, pn.ERROR_IN_GENERATION, _(self.L0)) + return '' + def _getValuesFromList(self): for i in range(0, len(self.prefList), 3): yield (self.prefList[i], self.prefList[i+1], self.prefList[i+2]) - def _generateName(self, photo, existingFilename, stripCharacters, subfolder, stripInitialPeriodFromExtension, sequence, fallback_date): - self.photo = photo + def _generateName(self, metadata, existingFilename, stripCharacters, subfolder, stripInitialPeriodFromExtension, sequence, fallback_date): + self.metadata = metadata self.existingFilename = existingFilename self.stripInitialPeriodFromExtension = stripInitialPeriodFromExtension self.fallback_date = fallback_date name = '' - problem = '' #the subfolder in which the image will be downloaded to self.downloadSubfolder = subfolder @@ -1214,37 +1186,34 @@ class ImageRenamePreferences: self.sequenceCounter = sequence for self.L0, self.L1, self.L2 in self._getValuesFromList(): - v, p = self._getComponent() + v = self._getComponent() if v: name += v - if p: - problem += p + "; " - if problem: - # remove final semicolon and space - problem = problem[:-2] + '.' - if stripCharacters: for c in r'\:*?"<>|': name = name.replace(c, '') if self.stripForwardSlash: name = name.replace('/', '') + + name = name.strip() - return (name, problem) + return name - def generateNameUsingPreferences(self, photo, existingFilename=None, + def generateNameUsingPreferences(self, metadata, existingFilename=None, stripCharacters = False, subfolder=None, stripInitialPeriodFromExtension=False, sequencesPreliminary = True, sequence_to_use = None, fallback_date = None): """ - Generate a filename for the photo in string format based on user prefs. + Generate a filename for the photo or video in string format based on user preferences. - Returns a tuple of two strings: - - the name - - any problems generating the name. If blank, there were no problems + Returns the name in string format + + Any problems encountered during the generation of the name can be accessed + through the method getProblems() """ if self.sequences: @@ -1257,18 +1226,18 @@ class ImageRenamePreferences: else: sequence = 0 - return self._generateName(photo, existingFilename, stripCharacters, subfolder, - stripInitialPeriodFromExtension, sequence, fallback_date) + return self._generateName(metadata, existingFilename, stripCharacters, subfolder, + stripInitialPeriodFromExtension, sequence, fallback_date) - def generateNameSequencePossibilities(self, photo, existingFilename, + def generateNameSequencePossibilities(self, metadata, existingFilename, stripCharacters=False, subfolder=None, stripInitialPeriodFromExtension=False): """ Generates the possible image names using the sequence numbers / letter possibilities""" for sequence in self.sequences.getSequencePossibilities(): - yield self._generateName(photo, existingFilename, stripCharacters , subfolder, - stripInitialPeriodFromExtension, sequence) + yield self._generateName(metadata, existingFilename, stripCharacters, subfolder, + stripInitialPeriodFromExtension, sequence) def filterPreferences(self): """ @@ -1430,15 +1399,15 @@ def getVideoMetadataComponent(video): problem = None if video.L1 == CODEC: - v = video.photo.codec() + v = video.metadata.codec() elif video.L1 == WIDTH: - v = video.photo.width() + v = video.metadata.width() elif video.L1 == HEIGHT: - v = video.photo.height() + v = video.metadata.height() elif video.L1 == FPS: - v = video.photo.fps() + v = video.metadata.fps() elif video.L1 == LENGTH: - v = video.photo.length() + v = video.metadata.length() else: raise TypeError("Invalid metadata option specified") if video.L1 in [CODEC]: @@ -1447,8 +1416,8 @@ def getVideoMetadataComponent(video): elif video.L2 == LOWERCASE: v = v.lower() if not v: - problem = _("%s metadata is not present in video") % video.L1 - return (v, problem) + self.problem.add_problem(self.component, pn.MISSING_METADATA, _(video.L1)) + return v class VideoRenamePreferences(ImageRenamePreferences): def __init__(self, prefList, parent, fileSequenceLock=None, sequences=None): @@ -1457,6 +1426,7 @@ class VideoRenamePreferences(ImageRenamePreferences): self.defaultRow = self.defaultPrefs self.stripForwardSlash = True self.L1DateCheck = VIDEO_DATE + self.component = pn.FILENAME_COMPONENT ImageRenamePreferences.__init__(self, prefList, parent, fileSequenceLock, sequences) def _getMetadataComponent(self): @@ -1475,6 +1445,7 @@ class SubfolderPreferences(ImageRenamePreferences): self.defaultRow = [DATE_TIME, IMAGE_DATE, LIST_DATE_TIME_L2[0]] self.stripForwardSlash = False self.L1DateCheck = IMAGE_DATE + self.component = pn.SUBFOLDER_COMPONENT ImageRenamePreferences.__init__(self, prefList, parent) def generateNameUsingPreferences(self, photo, existingFilename=None, @@ -1487,7 +1458,7 @@ class SubfolderPreferences(ImageRenamePreferences): - any problems generating the name. If blank, there were no problems """ - subfolders, problem = ImageRenamePreferences.generateNameUsingPreferences( + subfolders = ImageRenamePreferences.generateNameUsingPreferences( self, photo, existingFilename, stripCharacters, stripInitialPeriodFromExtension=True, @@ -1499,7 +1470,7 @@ class SubfolderPreferences(ImageRenamePreferences): if subfolders[0] == os.sep: subfolders = subfolders[1:] - return (subfolders, problem) + return subfolders def filterPreferences(self): filtered, prefList = filterSubfolderPreferences(self.prefList) @@ -1561,6 +1532,7 @@ class VideoSubfolderPreferences(SubfolderPreferences): self.defaultPrefs = DEFAULT_VIDEO_SUBFOLDER_PREFS self.defaultRow = [DATE_TIME, VIDEO_DATE, LIST_DATE_TIME_L2[0]] self.L1DateCheck = VIDEO_DATE + self.component = pn.SUBFOLDER_COMPONENT def _getMetadataComponent(self): """ diff --git a/rapid/videometadata.py b/rapid/videometadata.py index 210da33..ab5d18c 100755 --- a/rapid/videometadata.py +++ b/rapid/videometadata.py @@ -27,6 +27,7 @@ import tempfile import gtk import media import paths +from filmstrip import add_filmstrip try: import kaa.metadata @@ -34,10 +35,13 @@ except ImportError: DOWNLOAD_VIDEO = False VIDEO_THUMBNAIL_FILE_EXTENSIONS = ['thm'] -VIDEO_FILE_EXTENSIONS = ['avi', 'mov', 'mp4'] +VIDEO_FILE_EXTENSIONS = ['avi', 'mov', 'mp4', 'mpg'] + + if DOWNLOAD_VIDEO: + try: subprocess.check_call(["ffmpegthumbnailer", "-h"], stdout=subprocess.PIPE) ffmpeg = True @@ -47,14 +51,31 @@ if DOWNLOAD_VIDEO: def version_info(): return str(kaa.metadata.VERSION) + + def get_video_THM_file(fullFileName): + """ + Checks to see if a thumbnail file (THM) is in the same directory as the + file. Expects a full path to be part of the file name. + + Returns the filename, including path, if found, else returns None. + """ + + f = None + name, ext = os.path.splitext(fullFileName) + for e in VIDEO_THUMBNAIL_FILE_EXTENSIONS: + if os.path.exists(name + '.' + e): + f = name + '.' + e + break + if os.path.exists(name + '.' + e.upper()): + f = name + '.' + e.upper() + break + + return f class VideoMetaData(): def __init__(self, filename): self.info = kaa.metadata.parse(filename) self.filename = filename - self.filmstrip = gtk.gdk.pixbuf_new_from_file(paths.share_dir('glade3/filmstrip-100x75.xpm')) - self.FILMSTRIP_WIDTH = 100 - self.FILMSTRIP_HEIGHT = 75 def rpd_keys(self): return self.info.keys() @@ -78,6 +99,17 @@ if DOWNLOAD_VIDEO: return missing else: return missing + + def timeStamp(self, missing=''): + """ + Returns a float value representing the time stamp, if it exists + """ + v = self._get('timestamp', missing=missing) + try: + v = float(v) + except: + v = missing + return v def codec(self, stream=0, missing=''): return self._get('codec', missing, stream) @@ -108,17 +140,24 @@ if DOWNLOAD_VIDEO: return self._get('fourcc', missing, stream) def getThumbnailData(self, size, tempWorkingDir): - thm = media.getVideoThumbnailFile(self.filename) + """ + Returns a pixbuf of the video's thumbnail + + If it cannot be created, returns None + """ + thm = get_video_THM_file(self.filename) if thm: - thumbnail = gtk.gdk.pixbuf_new_from_file_at_size(thm, size, size) - if thumbnail.get_width() <> self.FILMSTRIP_WIDTH or thumbnail.get_height() <> self.FILMSTRIP_HEIGHT: - thumbnail = thumbnail.scale_simple(self.FILMSTRIP_WIDTH, self.FILMSTRIP_HEIGHT, gtk.gdk.INTERP_BILINEAR) - - self.filmstrip.composite(thumbnail, 0, 0, self.filmstrip.props.width, self.filmstrip.props.height, 0, 0, 1.0, 1.0, gtk.gdk.INTERP_HYPER, 255) + thumbnail = gtk.gdk.pixbuf_new_from_file(thm) + aspect = float(thumbnail.get_height()) / thumbnail.get_width() + thumbnail = thumbnail.scale_simple(size, int(aspect*size), gtk.gdk.INTERP_BILINEAR) + thumbnail = add_filmstrip(thumbnail) else: if ffmpeg: - tmp = tempfile.NamedTemporaryFile(dir=tempWorkingDir, prefix="rpd-tmp") - tmp.close() + try: + tmp = tempfile.NamedTemporaryFile(dir=tempWorkingDir, prefix="rpd-tmp") + tmp.close() + except: + return None thm = os.path.join(tempWorkingDir, tmp.name) -- cgit v1.2.3